1use crate::plugin::{Plugin, PluginContext};
21use anyhow::Result;
22use std::{collections::BTreeSet, fs, path::Path};
23
24#[derive(Debug, Clone, Copy)]
26pub struct IslandPlugin;
27
28impl Plugin for IslandPlugin {
29 fn name(&self) -> &'static str {
30 "islands"
31 }
32
33 fn has_transform(&self) -> bool {
34 true
35 }
36
37 fn transform_html(
38 &self,
39 html: &str,
40 _path: &Path,
41 _ctx: &PluginContext,
42 ) -> Result<String> {
43 if !html.contains("<ssg-island") {
44 return Ok(html.to_string());
45 }
46
47 if html.contains("ssg-island.js") {
48 return Ok(html.to_string()); }
50
51 let script =
52 "\n<script type=\"module\" src=\"/_islands/ssg-island.js\"></script>\n";
53
54 let output = if let Some(pos) = html.rfind("</body>") {
55 format!("{}{script}{}", &html[..pos], &html[pos..])
56 } else {
57 format!("{html}{script}")
58 };
59
60 Ok(output)
61 }
62
63 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
64 if !ctx.site_dir.exists() {
65 return Ok(());
66 }
67
68 let html_files = ctx.get_html_files();
69
70 let mut components = BTreeSet::new();
72
73 for path in &html_files {
74 let html = fs::read_to_string(path)?;
75 let page_components = extract_island_components(&html);
76 components.extend(page_components);
77 }
78
79 if components.is_empty() {
80 return Ok(());
81 }
82
83 let islands_dir = ctx.site_dir.join("_islands");
84 fs::create_dir_all(&islands_dir)?;
85
86 let source_islands = ctx
88 .content_dir
89 .parent()
90 .unwrap_or(&ctx.content_dir)
91 .join("islands");
92
93 if source_islands.exists() {
94 for component in &components {
95 let src = source_islands.join(format!("{component}.js"));
96 if src.exists() {
97 let dst = islands_dir.join(format!("{component}.js"));
98 let _ = fs::copy(&src, &dst)?;
99 }
100 }
101 }
102
103 let manifest: Vec<_> = components.iter().collect();
105 let manifest_json = serde_json::to_string_pretty(&manifest)
106 .unwrap_or_else(|_| "[]".to_string());
107 fs::write(islands_dir.join("manifest.json"), manifest_json)?;
108
109 fs::write(islands_dir.join("ssg-island.js"), ISLAND_LOADER_JS)?;
111
112 log::info!("[islands] {} component(s) bundled", components.len());
113 Ok(())
114 }
115}
116
117fn extract_island_components(html: &str) -> BTreeSet<String> {
119 let mut components = BTreeSet::new();
120 let pattern = "component=\"";
121
122 let mut search_from = 0;
123 while let Some(tag_start) = html[search_from..].find("<ssg-island") {
124 let abs_start = search_from + tag_start;
125 let rest = &html[abs_start..];
126
127 if let Some(tag_end) = rest.find('>') {
128 let tag = &rest[..tag_end];
129 if let Some(comp_start) = tag.find(pattern) {
130 let value_start = comp_start + pattern.len();
131 if let Some(value_end) = tag[value_start..].find('"') {
132 let component = &tag[value_start..value_start + value_end];
133 if !component.is_empty() {
134 let _ = components.insert(component.to_string());
135 }
136 }
137 }
138 search_from = abs_start + tag_end;
139 } else {
140 break;
141 }
142 }
143
144 components
145}
146
147#[cfg(test)]
149fn inject_island_loader(path: &Path) -> Result<()> {
150 let html = fs::read_to_string(path)?;
151
152 if html.contains("ssg-island.js") {
153 return Ok(()); }
155
156 let script =
157 "\n<script type=\"module\" src=\"/_islands/ssg-island.js\"></script>\n";
158
159 let output = if let Some(pos) = html.rfind("</body>") {
160 format!("{}{script}{}", &html[..pos], &html[pos..])
161 } else {
162 format!("{html}{script}")
163 };
164
165 fs::write(path, output)?;
166 Ok(())
167}
168
169const ISLAND_LOADER_JS: &str = r#"/**
175 * SSG Island — lazy-hydrating Web Component loader.
176 * Each <ssg-island> loads its component bundle on demand.
177 */
178class SsgIsland extends HTMLElement {
179 connectedCallback() {
180 const strategy = this.getAttribute('hydrate') || 'visible';
181 const component = this.getAttribute('component');
182 if (!component) return;
183
184 const load = () => this._hydrate(component);
185
186 if (strategy === 'idle') {
187 ('requestIdleCallback' in window)
188 ? requestIdleCallback(load)
189 : setTimeout(load, 200);
190 } else if (strategy === 'interaction') {
191 const events = ['click', 'focusin', 'pointerover'];
192 const once = () => {
193 events.forEach(e => this.removeEventListener(e, once));
194 load();
195 };
196 events.forEach(e => this.addEventListener(e, once, { once: true }));
197 } else {
198 // Default: visible (IntersectionObserver)
199 const io = new IntersectionObserver((entries, obs) => {
200 if (entries[0].isIntersecting) {
201 obs.disconnect();
202 load();
203 }
204 });
205 io.observe(this);
206 }
207 }
208
209 async _hydrate(component) {
210 try {
211 const props = JSON.parse(this.getAttribute('props') || '{}');
212 const mod = await import(`/_islands/${component}.js`);
213 if (mod.default) mod.default(this, props);
214 else if (mod.hydrate) mod.hydrate(this, props);
215 } catch (e) {
216 console.error(`[ssg-island] Failed to hydrate "${component}":`, e);
217 }
218 }
219}
220
221customElements.define('ssg-island', SsgIsland);
222"#;
223
224#[cfg(test)]
225#[allow(clippy::unwrap_used, clippy::expect_used)]
226mod tests {
227 use super::*;
228 use tempfile::tempdir;
229
230 #[test]
231 fn extract_components_finds_all() {
232 let html = r#"
233 <ssg-island component="counter" hydrate="visible"></ssg-island>
234 <p>Some text</p>
235 <ssg-island component="search" hydrate="idle"></ssg-island>
236 "#;
237 let components = extract_island_components(html);
238 assert_eq!(components.len(), 2);
239 assert!(components.contains("counter"));
240 assert!(components.contains("search"));
241 }
242
243 #[test]
244 fn extract_components_deduplicates() {
245 let html = r#"
246 <ssg-island component="counter" hydrate="visible"></ssg-island>
247 <ssg-island component="counter" hydrate="idle"></ssg-island>
248 "#;
249 let components = extract_island_components(html);
250 assert_eq!(components.len(), 1);
251 }
252
253 #[test]
254 fn extract_components_empty_html() {
255 let components =
256 extract_island_components("<html><body></body></html>");
257 assert!(components.is_empty());
258 }
259
260 #[test]
261 fn inject_loader_adds_script() {
262 let dir = tempdir().unwrap();
263 let html_path = dir.path().join("index.html");
264 fs::write(&html_path, "<html><body></body></html>").unwrap();
265
266 inject_island_loader(&html_path).unwrap();
267
268 let output = fs::read_to_string(&html_path).unwrap();
269 assert!(output.contains("ssg-island.js"));
270 }
271
272 #[test]
273 fn inject_loader_idempotent() {
274 let dir = tempdir().unwrap();
275 let html_path = dir.path().join("index.html");
276 fs::write(&html_path, "<html><body><script type=\"module\" src=\"/_islands/ssg-island.js\"></script></body></html>").unwrap();
277
278 inject_island_loader(&html_path).unwrap();
279
280 let output = fs::read_to_string(&html_path).unwrap();
281 assert_eq!(output.matches("ssg-island.js").count(), 1);
283 }
284
285 #[test]
286 fn island_plugin_name() {
287 assert_eq!(IslandPlugin.name(), "islands");
288 }
289
290 #[test]
291 fn island_plugin_skips_missing_site_dir() {
292 let ctx = PluginContext::new(
293 Path::new("/tmp/c"),
294 Path::new("/tmp/b"),
295 Path::new("/nonexistent/site"),
296 Path::new("/tmp/t"),
297 );
298 assert!(IslandPlugin.after_compile(&ctx).is_ok());
299 }
300
301 #[test]
302 fn island_plugin_processes_pages_with_islands() {
303 let dir = tempdir().unwrap();
304 let site = dir.path().join("site");
305 let content = dir.path().join("content");
306 let islands_src = dir.path().join("islands");
307 fs::create_dir_all(&site).unwrap();
308 fs::create_dir_all(&content).unwrap();
309 fs::create_dir_all(&islands_src).unwrap();
310
311 fs::write(
313 islands_src.join("counter.js"),
314 "export default (el, props) => {};",
315 )
316 .unwrap();
317
318 let html_content = "<html><body><ssg-island component=\"counter\" hydrate=\"visible\"></ssg-island></body></html>";
320 fs::write(site.join("index.html"), html_content).unwrap();
321
322 let ctx = PluginContext::new(&content, dir.path(), &site, dir.path());
323 IslandPlugin.after_compile(&ctx).unwrap();
324
325 assert!(site.join("_islands/manifest.json").exists());
327 assert!(site.join("_islands/ssg-island.js").exists());
329 assert!(site.join("_islands/counter.js").exists());
331 let output = IslandPlugin
333 .transform_html(html_content, &site.join("index.html"), &ctx)
334 .unwrap();
335 assert!(output.contains("ssg-island.js"));
336 }
337
338 #[test]
339 fn island_plugin_no_islands_in_html() {
340 let dir = tempdir().unwrap();
341 let site = dir.path().join("site");
342 fs::create_dir_all(&site).unwrap();
343 fs::write(
344 site.join("index.html"),
345 "<html><body><p>No islands here</p></body></html>",
346 )
347 .unwrap();
348
349 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
350 IslandPlugin.after_compile(&ctx).unwrap();
351
352 assert!(!site.join("_islands").exists());
354 }
355
356 #[test]
357 fn island_shortcode_expansion() {
358 let input = r#"{{< island component="counter" hydrate="visible" >}}"#;
359 let result = crate::shortcodes::expand_shortcodes(input);
360 assert!(result.contains("<ssg-island"));
361 assert!(result.contains("component=\"counter\""));
362 assert!(result.contains("hydrate=\"visible\""));
363 }
364}