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}