Skip to main content

ssg/
islands.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Resumable hydration — interactive islands (Web Components).
5//!
6//! Provides an `<ssg-island>` custom element that lazily loads JavaScript
7//! component bundles based on configurable hydration strategies:
8//! `visible` (IntersectionObserver), `idle` (requestIdleCallback), or
9//! `interaction` (click/focus/hover).
10//!
11//! ## Architecture
12//!
13//! 1. Content authors use `{{< island component="counter" hydrate="visible" >}}`
14//! 2. The shortcode expands to `<ssg-island component="counter" hydrate="visible">`
15//! 3. This plugin scans HTML for `<ssg-island>` elements and:
16//!    - Copies user-provided island bundles from `islands/` to `_islands/`
17//!    - Generates `_islands/manifest.json` listing all referenced components
18//!    - Injects the `ssg-island.js` custom element loader into pages
19
20use crate::plugin::{Plugin, PluginContext};
21use anyhow::Result;
22use std::{collections::BTreeSet, fs, path::Path};
23
24/// Plugin that enables interactive islands via Web Components.
25#[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()); // Already injected
49        }
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        // Scan all HTML files for <ssg-island component="..."> references
71        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        // Copy user-provided island bundles from source islands/ dir
87        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        // Write manifest
104        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        // Write the ssg-island.js custom element loader
110        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
117/// Extracts component names from `<ssg-island component="...">` elements.
118fn 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/// Injects the island loader `<script>` before `</body>`.
148#[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(()); // Already injected
154    }
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
169/// The `<ssg-island>` custom element loader.
170///
171/// - `hydrate="visible"`: loads when element enters viewport (`IntersectionObserver`)
172/// - `hydrate="idle"`: loads during browser idle time (`requestIdleCallback`)
173/// - `hydrate="interaction"`: loads on first click/focus/hover
174const 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        // Should appear exactly once
282        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        // Write a user island bundle
312        fs::write(
313            islands_src.join("counter.js"),
314            "export default (el, props) => {};",
315        )
316        .unwrap();
317
318        // Write HTML with an island
319        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        // Check manifest was created
326        assert!(site.join("_islands/manifest.json").exists());
327        // Check loader was created
328        assert!(site.join("_islands/ssg-island.js").exists());
329        // Check user bundle was copied
330        assert!(site.join("_islands/counter.js").exists());
331        // Check loader was injected into HTML via transform_html
332        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        // No _islands dir should be created
353        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}