Skip to main content

ssg/plugins/
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::error::{PathErrorExt, SsgError};
21use crate::plugin::{Plugin, PluginContext};
22use crate::util::html_rewriter::inject_before_body_close_or_append;
23use std::{collections::BTreeSet, fs, path::Path};
24
25/// Plugin that enables interactive islands via Web Components.
26#[derive(Debug, Clone, Copy)]
27pub struct IslandPlugin;
28
29impl Default for IslandPlugin {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl IslandPlugin {
36    /// Creates a new `IslandPlugin`.
37    ///
38    /// # Examples
39    ///
40    /// ```rust
41    /// use ssg::islands::IslandPlugin;
42    /// use ssg::plugin::Plugin;
43    ///
44    /// let p = IslandPlugin::new();
45    /// assert_eq!(p.name(), "islands");
46    /// ```
47    #[must_use]
48    pub const fn new() -> Self {
49        Self
50    }
51}
52
53impl Plugin for IslandPlugin {
54    fn name(&self) -> &'static str {
55        "islands"
56    }
57
58    fn has_transform(&self) -> bool {
59        true
60    }
61
62    fn transform_html(
63        &self,
64        html: &str,
65        _path: &Path,
66        ctx: &PluginContext,
67    ) -> Result<String, SsgError> {
68        if !html.contains("<ssg-island") {
69            return Ok(html.to_string());
70        }
71
72        if html.contains("ssg-island.js") {
73            return Ok(html.to_string()); // Already injected
74        }
75
76        // A site published under a sub-path resolves `/_islands/…` against
77        // the domain root, so the loader 404s and no island ever hydrates.
78        // Same fix as the extracted `_csp/` assets.
79        let prefix = ctx.config.as_ref().map_or_else(String::new, |c| {
80            crate::plugins_group::csp::base_url_path_prefix(&c.base_url)
81        });
82        let script = format!(
83            "\n<script type=\"module\" src=\"{prefix}/_islands/ssg-island.js\"></script>\n"
84        );
85
86        let output = inject_before_body_close_or_append(html, &script);
87
88        Ok(output)
89    }
90
91    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
92        if !ctx.site_dir.exists() {
93            return Ok(());
94        }
95
96        let html_files = ctx.get_html_files();
97
98        // Scan all HTML files for <ssg-island component="..."> references
99        let mut components = BTreeSet::new();
100
101        for path in &html_files {
102            let html = fs::read_to_string(path).with_path(path)?;
103            let page_components = extract_island_components(&html);
104            components.extend(page_components);
105        }
106
107        if components.is_empty() {
108            return Ok(());
109        }
110
111        let islands_dir = ctx.site_dir.join("_islands");
112        fs::create_dir_all(&islands_dir).with_path(&islands_dir)?;
113
114        // Copy user-provided island bundles from source islands/ dir
115        let source_islands = ctx
116            .content_dir
117            .parent()
118            .unwrap_or(&ctx.content_dir)
119            .join("islands");
120
121        if source_islands.exists() {
122            for component in &components {
123                let src = source_islands.join(format!("{component}.js"));
124                if src.exists() {
125                    let dst = islands_dir.join(format!("{component}.js"));
126                    let _ = fs::copy(&src, &dst).with_path(&dst)?;
127                }
128            }
129        }
130
131        // Write manifest
132        let manifest: Vec<_> = components.iter().collect();
133        let manifest_json = serde_json::to_string_pretty(&manifest)
134            .unwrap_or_else(|_| "[]".to_string());
135        let manifest_path = islands_dir.join("manifest.json");
136        fs::write(&manifest_path, manifest_json).with_path(&manifest_path)?;
137
138        // Write the ssg-island.js custom element loader
139        let loader_path = islands_dir.join("ssg-island.js");
140        fs::write(&loader_path, ISLAND_LOADER_JS).with_path(&loader_path)?;
141
142        log::info!("[islands] {} component(s) bundled", components.len());
143        Ok(())
144    }
145}
146
147/// Extracts component names from `<ssg-island component="...">` elements.
148fn extract_island_components(html: &str) -> BTreeSet<String> {
149    let mut components = BTreeSet::new();
150
151    let mut search_from = 0;
152    while let Some(tag_start) = html[search_from..].find("<ssg-island") {
153        let abs_start = search_from + tag_start;
154        let rest = &html[abs_start..];
155
156        if let Some(tag_end) = rest.find('>') {
157            let tag = &rest[..tag_end];
158            if let Some(component) = attr_value(tag, "component") {
159                if !component.is_empty() {
160                    let _ = components.insert(component);
161                }
162            }
163            search_from = abs_start + tag_end;
164        } else {
165            break;
166        }
167    }
168
169    components
170}
171
172/// Reads an attribute value out of a start tag, accepting all three forms
173/// the HTML syntax allows: `a="v"`, `a='v'` and bare `a=v`.
174///
175/// The bare form is not exotic. `html-generator` minifies pages during
176/// generation and strips quotes it does not need, so a page that authored
177/// `component="pricing-toggle"` reaches this function as
178/// `component=pricing-toggle`. Matching only the double-quoted form meant
179/// every island on a minified page was dropped: no bundle was copied, the
180/// component never reached the manifest, and the page silently served its
181/// static fallback for ever. Nothing errored, which is why it went
182/// unnoticed.
183fn attr_value(tag: &str, name: &str) -> Option<String> {
184    let mut from = 0;
185    loop {
186        let at = tag[from..].find(name)? + from;
187        let before_ok = at == 0
188            || tag[..at]
189                .chars()
190                .next_back()
191                .is_some_and(|c| c.is_whitespace());
192        let after = &tag[at + name.len()..];
193        // Guard against matching a longer attribute that merely starts with
194        // this name (`component-id=`), and against a value containing it.
195        if before_ok && after.starts_with('=') {
196            let value = &after[1..];
197            return Some(match value.chars().next()? {
198                q @ ('"' | '\'') => {
199                    value[1..].find(q).map(|e| value[1..1 + e].to_string())?
200                }
201                _ => value
202                    .find(|c: char| c.is_whitespace() || c == '>')
203                    .map_or_else(
204                        || value.to_string(),
205                        |e| value[..e].to_string(),
206                    ),
207            });
208        }
209        from = at + name.len();
210    }
211}
212
213#[cfg(test)]
214fn inject_island_loader(path: &Path) -> Result<(), SsgError> {
215    let html = fs::read_to_string(path).with_path(path)?;
216
217    if html.contains("ssg-island.js") {
218        return Ok(()); // Already injected
219    }
220
221    let script =
222        "\n<script type=\"module\" src=\"/_islands/ssg-island.js\"></script>\n";
223
224    let output = inject_before_body_close_or_append(&html, script);
225
226    fs::write(path, output).with_path(path)?;
227    Ok(())
228}
229
230/// The `<ssg-island>` custom element loader.
231///
232/// Lazy-hydration strategies:
233///
234/// - `hydrate="visible"` (default): loads when the element enters the
235///   viewport via `IntersectionObserver` — used for below-the-fold
236///   widgets so they don't compete for first-paint bandwidth (AC4).
237/// - `hydrate="idle"`: loads during browser idle time
238///   (`requestIdleCallback`) — for non-critical widgets that should
239///   still warm up shortly after first paint.
240/// - `hydrate="interaction"`: loads on the first click/focus/hover —
241///   for components that have zero value until the user touches them.
242///
243/// The element exposes a `detach()` method called by the view
244/// transitions client (issue #547) before removing the outgoing page's
245/// `<main>` from the DOM. It also listens for an `ssg:detach`
246/// `CustomEvent` for userland code that prefers an event-based API.
247/// Both paths cancel any pending `IntersectionObserver` /
248/// `requestIdleCallback` / interaction listeners so the outgoing
249/// page leaves no dangling event subscriptions (AC5).
250const ISLAND_LOADER_JS: &str = r#"/**
251 * SSG Island — lazy-hydrating Web Component loader.
252 * Each <ssg-island> loads its component bundle on demand.
253 *
254 * Hydration strategies: visible | idle | interaction (default visible)
255 * Lifecycle:
256 *   connectedCallback → arm strategy
257 *   detach() / disconnectedCallback / ssg:detach → tear down
258 */
259class SsgIsland extends HTMLElement {
260  constructor() {
261    super();
262    this._cleanup = [];
263    this._hydrated = false;
264    this.addEventListener('ssg:detach', () => this.detach());
265  }
266
267  connectedCallback() {
268    const strategy = this.getAttribute('hydrate') || 'visible';
269    const component = this.getAttribute('component');
270    if (!component) return;
271
272    const load = () => this._hydrate(component);
273
274    if (strategy === 'idle') {
275      let handle;
276      if ('requestIdleCallback' in window) {
277        handle = requestIdleCallback(load);
278        this._cleanup.push(() => {
279          if ('cancelIdleCallback' in window) cancelIdleCallback(handle);
280        });
281      } else {
282        handle = setTimeout(load, 200);
283        this._cleanup.push(() => clearTimeout(handle));
284      }
285    } else if (strategy === 'interaction') {
286      const events = ['click', 'focusin', 'pointerover'];
287      const once = () => {
288        events.forEach(e => this.removeEventListener(e, once));
289        load();
290      };
291      events.forEach(e => this.addEventListener(e, once, { once: true }));
292      this._cleanup.push(() => {
293        events.forEach(e => this.removeEventListener(e, once));
294      });
295    } else {
296      // Default: visible (IntersectionObserver, AC4)
297      const io = new IntersectionObserver((entries, obs) => {
298        if (entries[0] && entries[0].isIntersecting) {
299          obs.disconnect();
300          load();
301        }
302      });
303      io.observe(this);
304      this._cleanup.push(() => io.disconnect());
305    }
306  }
307
308  disconnectedCallback() {
309    this.detach();
310  }
311
312  /**
313   * Tear down any pending hydration triggers and notify the loaded
314   * component (if it exposed `detach`). Idempotent and safe to call
315   * multiple times — used by the view-transitions client (#547) to
316   * clean up before swapping <main>.
317   */
318  detach() {
319    while (this._cleanup.length) {
320      const fn = this._cleanup.pop();
321      try { fn(); } catch (e) {}
322    }
323    if (this._module && typeof this._module.detach === 'function') {
324      try { this._module.detach(this); } catch (e) {}
325    }
326    this._module = null;
327  }
328
329  async _hydrate(component) {
330    if (this._hydrated) return;
331    this._hydrated = true;
332    try {
333      const props = JSON.parse(this.getAttribute('props') || '{}');
334      const mod = await import(new URL(`./${component}.js`, import.meta.url).href);
335      this._module = mod;
336      if (mod.default) mod.default(this, props);
337      else if (mod.hydrate) mod.hydrate(this, props);
338    } catch (e) {
339      console.error(`[ssg-island] Failed to hydrate "${component}":`, e);
340    }
341  }
342}
343
344customElements.define('ssg-island', SsgIsland);
345
346// Re-arm islands on view-transition page swaps (issue #547).
347// The transitions client dispatches `ssg:after-swap` after each
348// successful navigation; the new <main> is fresh DOM, so the
349// browser's own connectedCallback fires automatically. We only
350// need to re-confirm any island whose connectedCallback may have
351// raced with the swap.
352document.addEventListener('ssg:after-swap', () => {
353  document.querySelectorAll('ssg-island').forEach(el => {
354    if (el.isConnected && !el._hydrated && el._cleanup.length === 0) {
355      try { el.connectedCallback(); } catch (e) {}
356    }
357  });
358});
359"#;
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use tempfile::tempdir;
365
366    /// `html-generator` minifies pages during generation and drops quotes
367    /// it does not need, so an authored `component="x"` can arrive as
368    /// `component=x`. Matching only the double-quoted form silently
369    /// dropped every island on a minified page.
370    #[test]
371    fn island_components_are_found_whatever_the_quoting() {
372        for html in [
373            r#"<ssg-island component="feature-tabs"></ssg-island>"#,
374            r#"<ssg-island component='feature-tabs'></ssg-island>"#,
375            r#"<ssg-island component=feature-tabs></ssg-island>"#,
376            r#"<ssg-island props='{"a":1}' component=feature-tabs hydrate=visible>"#,
377        ] {
378            let got = extract_island_components(html);
379            assert!(
380                got.contains("feature-tabs"),
381                "missed the component in: {html}"
382            );
383        }
384    }
385
386    /// A longer attribute that merely starts with the name must not be
387    /// mistaken for it.
388    #[test]
389    fn island_component_lookup_is_not_fooled_by_a_prefix_match() {
390        let html =
391            r#"<ssg-island component-id=nope component=real></ssg-island>"#;
392        let got = extract_island_components(html);
393        assert!(got.contains("real"), "{got:?}");
394        assert!(!got.contains("nope"), "{got:?}");
395    }
396
397    /// Several islands on one page all reach the manifest.
398    #[test]
399    fn island_components_collect_across_a_page() {
400        let html = concat!(
401            "<ssg-island component=feature-tabs></ssg-island>",
402            r#"<ssg-island component="pricing-toggle"></ssg-island>"#,
403        );
404        let got = extract_island_components(html);
405        assert_eq!(got.len(), 2, "{got:?}");
406    }
407
408    #[test]
409    fn extract_components_finds_all() {
410        let html = r#"
411            <ssg-island component="counter" hydrate="visible"></ssg-island>
412            <p>Some text</p>
413            <ssg-island component="search" hydrate="idle"></ssg-island>
414        "#;
415        let components = extract_island_components(html);
416        assert_eq!(components.len(), 2);
417        assert!(components.contains("counter"));
418        assert!(components.contains("search"));
419    }
420
421    #[test]
422    fn extract_components_deduplicates() {
423        let html = r#"
424            <ssg-island component="counter" hydrate="visible"></ssg-island>
425            <ssg-island component="counter" hydrate="idle"></ssg-island>
426        "#;
427        let components = extract_island_components(html);
428        assert_eq!(components.len(), 1);
429    }
430
431    #[test]
432    fn extract_components_empty_html() {
433        let components =
434            extract_island_components("<html><body></body></html>");
435        assert!(components.is_empty());
436    }
437
438    #[test]
439    fn inject_loader_adds_script() {
440        let dir = tempdir().unwrap();
441        let html_path = dir.path().join("index.html");
442        fs::write(&html_path, "<html><body></body></html>").unwrap();
443
444        inject_island_loader(&html_path).unwrap();
445
446        let output = fs::read_to_string(&html_path).unwrap();
447        assert!(output.contains("ssg-island.js"));
448    }
449
450    #[test]
451    fn inject_loader_idempotent() {
452        let dir = tempdir().unwrap();
453        let html_path = dir.path().join("index.html");
454        fs::write(&html_path, "<html><body><script type=\"module\" src=\"/_islands/ssg-island.js\"></script></body></html>").unwrap();
455
456        inject_island_loader(&html_path).unwrap();
457
458        let output = fs::read_to_string(&html_path).unwrap();
459        // Should appear exactly once
460        assert_eq!(output.matches("ssg-island.js").count(), 1);
461    }
462
463    #[test]
464    fn island_plugin_name() {
465        assert_eq!(IslandPlugin.name(), "islands");
466    }
467
468    #[test]
469    fn island_plugin_skips_missing_site_dir() {
470        let ctx = PluginContext::new(
471            Path::new("/tmp/c"),
472            Path::new("/tmp/b"),
473            Path::new("/nonexistent/site"),
474            Path::new("/tmp/t"),
475        );
476        assert!(IslandPlugin.after_compile(&ctx).is_ok());
477    }
478
479    #[test]
480    fn island_plugin_processes_pages_with_islands() {
481        let dir = tempdir().unwrap();
482        let site = dir.path().join("site");
483        let content = dir.path().join("content");
484        let islands_src = dir.path().join("islands");
485        fs::create_dir_all(&site).unwrap();
486        fs::create_dir_all(&content).unwrap();
487        fs::create_dir_all(&islands_src).unwrap();
488
489        // Write a user island bundle
490        fs::write(
491            islands_src.join("counter.js"),
492            "export default (el, props) => {};",
493        )
494        .unwrap();
495
496        // Write HTML with an island
497        let html_content = "<html><body><ssg-island component=\"counter\" hydrate=\"visible\"></ssg-island></body></html>";
498        fs::write(site.join("index.html"), html_content).unwrap();
499
500        let ctx = PluginContext::new(&content, dir.path(), &site, dir.path());
501        IslandPlugin.after_compile(&ctx).unwrap();
502
503        // Check manifest was created
504        assert!(site.join("_islands/manifest.json").exists());
505        // Check loader was created
506        assert!(site.join("_islands/ssg-island.js").exists());
507        // Check user bundle was copied
508        assert!(site.join("_islands/counter.js").exists());
509        // Check loader was injected into HTML via transform_html
510        let output = IslandPlugin
511            .transform_html(html_content, &site.join("index.html"), &ctx)
512            .unwrap();
513        assert!(output.contains("ssg-island.js"));
514    }
515
516    #[test]
517    fn island_plugin_no_islands_in_html() {
518        let dir = tempdir().unwrap();
519        let site = dir.path().join("site");
520        fs::create_dir_all(&site).unwrap();
521        fs::write(
522            site.join("index.html"),
523            "<html><body><p>No islands here</p></body></html>",
524        )
525        .unwrap();
526
527        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
528        IslandPlugin.after_compile(&ctx).unwrap();
529
530        // No _islands dir should be created
531        assert!(!site.join("_islands").exists());
532    }
533
534    #[test]
535    fn island_shortcode_expansion() {
536        let input = r#"{{< island component="counter" hydrate="visible" >}}"#;
537        let result = crate::shortcodes::expand_shortcodes(input);
538        assert!(result.contains("<ssg-island"));
539        assert!(result.contains("component=\"counter\""));
540        assert!(result.contains("hydrate=\"visible\""));
541    }
542
543    #[test]
544    // The lint says to write the unit struct directly, which would remove
545    // the `Default::default()` call this test exists to compare against.
546    #[allow(clippy::default_constructed_unit_structs)]
547    fn island_plugin_new_and_default_yield_same_unit() {
548        // Plugin is a unit struct — Default and new() are
549        // interchangeable. Exercise both so the function-coverage
550        // counter records them (this is the point of the test).
551        let a = IslandPlugin::new();
552        let b = IslandPlugin::default();
553        assert_eq!(a.name(), b.name());
554        assert!(a.has_transform());
555        assert!(b.has_transform());
556    }
557
558    #[test]
559    fn island_after_compile_with_html_file_but_zero_island_refs_returns_early()
560    {
561        // Site dir exists, contains an HTML file with no <ssg-island>,
562        // so the components set is empty and the early-return arm fires
563        // (the closure that maps over the empty set).
564        let dir = tempdir().unwrap();
565        let site = dir.path().join("site");
566        fs::create_dir_all(&site).unwrap();
567        fs::write(
568            site.join("page.html"),
569            "<html><body><p>nothing</p></body></html>",
570        )
571        .unwrap();
572
573        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
574        IslandPlugin.after_compile(&ctx).unwrap();
575        assert!(!site.join("_islands").exists());
576    }
577
578    // -------------------------------------------------------------------
579    // transform_html — remaining branches
580    // -------------------------------------------------------------------
581
582    /// Regression: the loader tag was emitted as `/_islands/…`, which
583    /// resolves against the domain root. On a site published under a
584    /// sub-path the loader 404s and no island on the site ever hydrates.
585    #[test]
586    fn loader_tag_is_prefixed_for_sub_path_deploys() {
587        use crate::cmd::SsgConfig;
588
589        let dir = tempdir().unwrap();
590        let config = SsgConfig {
591            base_url: "https://example.com/velocity".to_string(),
592            ..SsgConfig::default()
593        };
594        let ctx = PluginContext::with_config(
595            dir.path(),
596            dir.path(),
597            dir.path(),
598            dir.path(),
599            config,
600        );
601
602        let out = IslandPlugin::new()
603            .transform_html(
604                "<html><body><ssg-island component=\"c\"></ssg-island></body></html>",
605                Path::new("index.html"),
606                &ctx,
607            )
608            .unwrap();
609
610        assert!(
611            out.contains("src=\"/velocity/_islands/ssg-island.js\""),
612            "loader must carry the sub-path prefix: {out}"
613        );
614        assert!(
615            !out.contains("src=\"/_islands/"),
616            "no unprefixed reference may survive: {out}"
617        );
618    }
619
620    /// A site at the domain root keeps the original, unprefixed reference.
621    #[test]
622    fn loader_tag_is_unprefixed_at_the_domain_root() {
623        use crate::cmd::SsgConfig;
624
625        let dir = tempdir().unwrap();
626        let config = SsgConfig {
627            base_url: "https://example.com".to_string(),
628            ..SsgConfig::default()
629        };
630        let ctx = PluginContext::with_config(
631            dir.path(),
632            dir.path(),
633            dir.path(),
634            dir.path(),
635            config,
636        );
637
638        let out = IslandPlugin::new()
639            .transform_html(
640                "<html><body><ssg-island component=\"c\"></ssg-island></body></html>",
641                Path::new("index.html"),
642                &ctx,
643            )
644            .unwrap();
645
646        assert!(out.contains("src=\"/_islands/ssg-island.js\""), "{out}");
647    }
648
649    #[test]
650    fn transform_html_skips_when_loader_already_injected() {
651        let dir = tempdir().unwrap();
652        let ctx =
653            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
654        let html = "<body><ssg-island component=\"c\"></ssg-island>\
655                    <script src=\"/_islands/ssg-island.js\"></script></body>";
656        let out = IslandPlugin
657            .transform_html(html, Path::new("i.html"), &ctx)
658            .unwrap();
659        assert_eq!(out, html);
660    }
661
662    #[test]
663    fn transform_html_appends_loader_when_body_close_missing() {
664        let dir = tempdir().unwrap();
665        let ctx =
666            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
667        let html = "<ssg-island component=\"c\"></ssg-island>";
668        let out = IslandPlugin
669            .transform_html(html, Path::new("i.html"), &ctx)
670            .unwrap();
671        assert!(out.ends_with("</script>\n"));
672        assert!(out.starts_with(html));
673    }
674
675    // -------------------------------------------------------------------
676    // extract_island_components — parser edges
677    // -------------------------------------------------------------------
678
679    #[test]
680    fn extract_ignores_empty_component_name() {
681        let html = "<ssg-island component=\"\"></ssg-island>";
682        assert!(extract_island_components(html).is_empty());
683    }
684
685    #[test]
686    fn extract_ignores_unterminated_component_value() {
687        // No closing quote before the tag's `>` — the value never
688        // terminates within the tag.
689        let html = "<ssg-island component=\"counter></ssg-island>";
690        assert!(extract_island_components(html).is_empty());
691    }
692
693    #[test]
694    fn extract_stops_on_unterminated_tag() {
695        let html = "<ssg-island component=\"counter\"";
696        assert!(extract_island_components(html).is_empty());
697    }
698
699    #[test]
700    fn extract_ignores_tag_without_component_attribute() {
701        // The tag closes normally but never contains `component="`, so
702        // `tag.find(pattern)` returns `None` — a distinct branch from
703        // the empty-value and unterminated-value cases above.
704        let html = "<ssg-island hydrate=\"idle\"></ssg-island>";
705        assert!(extract_island_components(html).is_empty());
706    }
707
708    // -------------------------------------------------------------------
709    // after_compile — copy branches + IO errors
710    // -------------------------------------------------------------------
711
712    /// Site layout with one page referencing `counter`.
713    fn island_site(dir: &Path) -> std::path::PathBuf {
714        let site = dir.join("site");
715        fs::create_dir_all(&site).unwrap();
716        fs::write(
717            site.join("index.html"),
718            "<html><body><ssg-island component=\"counter\"></ssg-island></body></html>",
719        )
720        .unwrap();
721        site
722    }
723
724    #[test]
725    fn after_compile_without_source_islands_dir_still_writes_loader() {
726        let dir = tempdir().unwrap();
727        let site = island_site(dir.path());
728        // content_dir has no sibling islands/ directory.
729        let content = dir.path().join("nested").join("content");
730        fs::create_dir_all(&content).unwrap();
731        let ctx = PluginContext::new(&content, dir.path(), &site, dir.path());
732
733        IslandPlugin.after_compile(&ctx).unwrap();
734        assert!(site.join("_islands/ssg-island.js").exists());
735        assert!(!site.join("_islands/counter.js").exists());
736    }
737
738    #[test]
739    fn after_compile_skips_component_without_source_bundle() {
740        let dir = tempdir().unwrap();
741        let site = island_site(dir.path());
742        let content = dir.path().join("content");
743        fs::create_dir_all(&content).unwrap();
744        // islands/ exists, but has no counter.js.
745        fs::create_dir_all(dir.path().join("islands")).unwrap();
746        let ctx = PluginContext::new(&content, dir.path(), &site, dir.path());
747
748        IslandPlugin.after_compile(&ctx).unwrap();
749        assert!(!site.join("_islands/counter.js").exists());
750        assert!(site.join("_islands/manifest.json").exists());
751    }
752
753    #[test]
754    fn after_compile_fails_when_islands_dir_squatted_by_file() {
755        let dir = tempdir().unwrap();
756        let site = island_site(dir.path());
757        fs::write(site.join("_islands"), "not a dir").unwrap();
758        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
759        let err = IslandPlugin.after_compile(&ctx).unwrap_err();
760        assert!(!format!("{err}").is_empty());
761    }
762
763    #[test]
764    fn after_compile_fails_when_bundle_dst_squatted_by_dir() {
765        let dir = tempdir().unwrap();
766        let site = island_site(dir.path());
767        let content = dir.path().join("content");
768        fs::create_dir_all(&content).unwrap();
769        fs::create_dir_all(dir.path().join("islands")).unwrap();
770        fs::write(dir.path().join("islands/counter.js"), "export {}").unwrap();
771        // A directory squats the copy destination.
772        fs::create_dir_all(site.join("_islands/counter.js")).unwrap();
773        let ctx = PluginContext::new(&content, dir.path(), &site, dir.path());
774
775        let err = IslandPlugin.after_compile(&ctx).unwrap_err();
776        assert!(!format!("{err}").is_empty());
777    }
778
779    #[test]
780    fn after_compile_fails_when_manifest_squatted_by_dir() {
781        let dir = tempdir().unwrap();
782        let site = island_site(dir.path());
783        let content = dir.path().join("content");
784        fs::create_dir_all(&content).unwrap();
785        fs::create_dir_all(site.join("_islands/manifest.json")).unwrap();
786        let ctx = PluginContext::new(&content, dir.path(), &site, dir.path());
787
788        let err = IslandPlugin.after_compile(&ctx).unwrap_err();
789        assert!(!format!("{err}").is_empty());
790    }
791
792    #[test]
793    fn after_compile_fails_when_loader_squatted_by_dir() {
794        let dir = tempdir().unwrap();
795        let site = island_site(dir.path());
796        let content = dir.path().join("content");
797        fs::create_dir_all(&content).unwrap();
798        fs::create_dir_all(site.join("_islands/ssg-island.js")).unwrap();
799        let ctx = PluginContext::new(&content, dir.path(), &site, dir.path());
800
801        let err = IslandPlugin.after_compile(&ctx).unwrap_err();
802        assert!(!format!("{err}").is_empty());
803    }
804
805    #[test]
806    #[cfg(unix)]
807    fn after_compile_fails_when_html_is_unreadable() {
808        use std::os::unix::fs::PermissionsExt;
809        let dir = tempdir().unwrap();
810        let site = island_site(dir.path());
811        let html = site.join("index.html");
812        fs::set_permissions(&html, fs::Permissions::from_mode(0o000)).unwrap();
813
814        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
815        let res = IslandPlugin.after_compile(&ctx);
816
817        let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
818        // Root CI runners bypass perms; only assert when it errored.
819        if let Err(e) = res {
820            assert!(!format!("{e}").is_empty());
821        }
822    }
823
824    // -------------------------------------------------------------------
825    // inject_island_loader (test-only helper) — remaining branches
826    // -------------------------------------------------------------------
827
828    #[test]
829    fn inject_island_loader_errors_on_missing_file() {
830        let dir = tempdir().unwrap();
831        assert!(inject_island_loader(&dir.path().join("nope.html")).is_err());
832    }
833
834    #[test]
835    fn inject_island_loader_appends_without_body_close() {
836        let dir = tempdir().unwrap();
837        let page = dir.path().join("p.html");
838        fs::write(&page, "<p>no body close</p>").unwrap();
839        inject_island_loader(&page).unwrap();
840        let out = fs::read_to_string(&page).unwrap();
841        assert!(out.ends_with("</script>\n"));
842    }
843
844    #[test]
845    #[cfg(unix)]
846    fn inject_island_loader_write_error_on_readonly_file() {
847        use std::os::unix::fs::PermissionsExt;
848        let dir = tempdir().unwrap();
849        let page = dir.path().join("p.html");
850        fs::write(&page, "<body></body>").unwrap();
851        fs::set_permissions(&page, fs::Permissions::from_mode(0o444)).unwrap();
852
853        let res = inject_island_loader(&page);
854
855        let _ = fs::set_permissions(&page, fs::Permissions::from_mode(0o644));
856        if let Err(e) = res {
857            assert!(!format!("{e}").is_empty());
858        }
859    }
860}