Skip to main content

docs_core/
island.rs

1//! Island emission: the strategic seam (ADR-0004 §2.5).
2//!
3//! Leptos and Dioxus islands are Rust/WASM-only, so a Rust shell cannot
4//! hydrate the estate's existing SolidJS bundles — there is no
5//! astro-island-compatible protocol anywhere in Rust. This module emits
6//! that protocol's *shape* (a custom element with `component-url` +
7//! `renderer-url`, JSON props, a `client:*` hydration hint), and
8//! `islands/island.mjs` implements the ~120-line client side. The result:
9//! the docs core can be Rust while the islands stay the SolidJS packages
10//! pediment/hosting-dashboard already maintain.
11
12use crate::error::DocsError;
13
14/// How an island wants to hydrate.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Client {
17    /// Immediately.
18    Load,
19    /// When scrolled into view (the default).
20    Visible,
21    /// When the browser is idle.
22    Idle,
23}
24
25impl Client {
26    /// Parse the fence-info hint; unknown values fall back to `Visible`
27    /// (documented, total).
28    #[must_use]
29    pub fn parse(src: &str) -> Client {
30        match src {
31            "load" => Client::Load,
32            "idle" => Client::Idle,
33            _ => Client::Visible,
34        }
35    }
36
37    /// The wire value.
38    #[must_use]
39    pub const fn as_str(self) -> &'static str {
40        match self {
41            Client::Load => "load",
42            Client::Visible => "visible",
43            Client::Idle => "idle",
44        }
45    }
46}
47
48/// A parsed ```` ```island ```` fence: the island's component name,
49/// hydration hint, and raw JSON props from the fence body.
50#[derive(Debug, Clone, PartialEq)]
51pub struct Island {
52    /// Component name (kebab-case; maps to a client bundle).
53    pub name: String,
54    /// Hydration hint.
55    pub client: Client,
56    /// Raw JSON props (validated at emission).
57    pub props: String,
58}
59
60impl Island {
61    /// Parse a fence info string like `island nav-search client=load` plus a
62    /// JSON props body.
63    ///
64    /// # Errors
65    /// [`DocsError::IslandWithoutName`] when the name is missing;
66    /// [`DocsError::InvalidIslandProps`] when the body is not JSON.
67    pub fn parse(info: &str, body: &str) -> Result<Island, DocsError> {
68        let mut words = info.split_whitespace();
69        let keyword = words.next().unwrap_or("");
70        if keyword != "island" {
71            return Err(DocsError::IslandWithoutName(info.to_owned()));
72        }
73        let mut name = String::new();
74        let mut client = Client::Visible;
75        for word in words {
76            match word.split_once('=') {
77                Some(("client", value)) => client = Client::parse(value),
78                Some(_) => {}
79                None => name = word.to_owned(),
80            }
81        }
82        if name.is_empty() {
83            return Err(DocsError::IslandWithoutName(info.to_owned()));
84        }
85        let props = body.trim();
86        if !props.is_empty() {
87            // Validate as JSON without pulling in serde_json: the shim parses
88            // it client-side with JSON.parse; we only need to know it's
89            // shaped like an object, so a parse error is caught here, at
90            // build time, not in the browser.
91            let first = props.chars().next();
92            let last = props.chars().last();
93            if first != Some('{') || last != Some('}') {
94                return Err(DocsError::InvalidIslandProps(props.to_owned()));
95            }
96        }
97        Ok(Island {
98            name,
99            client,
100            props: if props.is_empty() { "{}".to_owned() } else { props.to_owned() },
101        })
102    }
103
104    /// The static HTML for this island: a custom element carrying the
105    /// component URL, the hydration hint, and the props as a JSON
106    /// `<script type="application/json">` (never an inline eval).
107    #[must_use]
108    pub fn to_html(&self, base: &str) -> String {
109        let component_url = format!("{}/islands/{}.js", base.trim_end_matches('/'), self.name);
110        format!(
111            "<docs-island component-url=\"{component_url}\" client=\"{client}\"><script type=\"application/json\" data-island-props>{props}</script></docs-island>",
112            client = self.client.as_str(),
113            props = escape_json_for_html(&self.props),
114        )
115    }
116}
117
118/// Escape the JSON so it can live inside an HTML script element: the only
119/// character that can terminate the element early is `<`.
120fn escape_json_for_html(src: &str) -> String {
121    src.replace('<', "\\u003c")
122}