Skip to main content

islands_core/
markup.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::CoreError;
7
8/// A single entry in the asset manifest for one page bundle.
9#[derive(Debug, Clone, Deserialize, Serialize, Default)]
10pub struct ManifestEntry {
11    pub js: Option<String>,
12    pub wasm: Option<String>,
13    pub css: Option<String>,
14}
15
16/// Asset manifest mapping bundle names to their hashed file paths.
17///
18/// Top-level `css_base` is the hashed path for the shared base stylesheet.
19#[derive(Debug, Clone, Default)]
20pub struct Manifest {
21    pub css_base: Option<String>,
22    pub entries: HashMap<String, ManifestEntry>,
23}
24
25impl Manifest {
26    /// Load manifest from `path`. Returns `Default` if the file is missing or unparseable.
27    ///
28    /// Reads two surfaces:
29    /// - Top-level `"islands-core"` entry (the shared runtime bundle).
30    /// - Nested `"pages"` map keyed by `"<example>/<page>"`, flattened into
31    ///   `entries` so look-ups by bundle key resolve directly.
32    pub fn load(path: &Path) -> Self {
33        let Ok(data) = std::fs::read_to_string(path) else {
34            return Self::default();
35        };
36        let Ok(raw): Result<serde_json::Value, _> = serde_json::from_str(&data) else {
37            return Self::default();
38        };
39        let css_base = raw
40            .get("css-base")
41            .and_then(|v| v.as_str())
42            .map(str::to_owned);
43        let mut entries: HashMap<String, ManifestEntry> = HashMap::new();
44        if let Some(object) = raw.as_object() {
45            for (key, value) in object {
46                if key == "css-base" || key == "pages" {
47                    continue;
48                }
49                if let Ok(entry) = serde_json::from_value::<ManifestEntry>(value.clone()) {
50                    entries.insert(key.clone(), entry);
51                }
52            }
53        }
54        if let Some(pages_value) = raw.get("pages").and_then(|value| value.as_object()) {
55            for (key, value) in pages_value {
56                if let Ok(entry) = serde_json::from_value::<ManifestEntry>(value.clone()) {
57                    entries.insert(key.clone(), entry);
58                }
59            }
60        }
61        Self { css_base, entries }
62    }
63}
64
65/// Renders an island marker with serialized props and initial server-rendered HTML.
66///
67/// Produces: `<div data-island="NAME" data-island-props='{json}'>{initial_html}</div>`
68///
69/// Props are single-quoted; single quotes and angle brackets in the JSON are escaped.
70pub fn island_marker(
71    name: &str,
72    props: &serde_json::Value,
73    initial_html: &str,
74) -> Result<String, CoreError> {
75    let props_json = serde_json::to_string(props)?;
76    // Single-quoted attribute: escape ' as &#39; and < as &lt; to prevent injection.
77    let props_escaped = props_json.replace('\'', "&#39;").replace('<', "&lt;");
78    Ok(format!(
79        "<div data-island=\"{name}\" data-island-props='{props_escaped}'>{initial_html}</div>"
80    ))
81}
82
83fn resolve_css_base(manifest: &Manifest, prefix: &str) -> String {
84    manifest
85        .css_base
86        .as_deref()
87        .map(|p| format!("{prefix}/static/{p}"))
88        .unwrap_or_else(|| format!("{prefix}/static/css/base.css"))
89}
90
91fn resolve_page_css(manifest: &Manifest, page_bundle: &str, prefix: &str) -> String {
92    manifest
93        .entries
94        .get(page_bundle)
95        .and_then(|entry| entry.css.as_deref())
96        .map(|path| format!("{prefix}/static/{path}"))
97        .unwrap_or_else(|| {
98            let leaf = page_bundle.rsplit('/').next().unwrap_or(page_bundle);
99            let filename = leaf.replace('-', "_");
100            format!("{prefix}/static/{page_bundle}/{filename}.css")
101        })
102}
103
104fn resolve_core_js(manifest: &Manifest, prefix: &str) -> String {
105    manifest
106        .entries
107        .get("islands-core")
108        .and_then(|entry| entry.js.as_deref())
109        .map(|path| format!("{prefix}/static/{path}"))
110        .unwrap_or_else(|| format!("{prefix}/static/islands-core/islands_core.js"))
111}
112
113fn resolve_page_js(manifest: &Manifest, page_bundle: &str, prefix: &str) -> String {
114    manifest
115        .entries
116        .get(page_bundle)
117        .and_then(|entry| entry.js.as_deref())
118        .map(|path| format!("{prefix}/static/{path}"))
119        .unwrap_or_else(|| {
120            let leaf = page_bundle.rsplit('/').next().unwrap_or(page_bundle);
121            let filename = leaf.replace('-', "_");
122            format!("{prefix}/static/{page_bundle}/{filename}.js")
123        })
124}
125
126#[cfg(feature = "dev")]
127const HMR_CLIENT_SCRIPT: &str = r##"(() => {
128  const STATUS_ID = "__islands_dev_status";
129  let banner = document.getElementById(STATUS_ID);
130  if (!banner) {
131    banner = document.createElement("div");
132    banner.id = STATUS_ID;
133    banner.style.cssText =
134      "position:fixed;bottom:8px;right:8px;z-index:2147483647;" +
135      "padding:4px 8px;border-radius:6px;font:12px/1 ui-monospace,monospace;" +
136      "background:#111;color:#fff;opacity:0.75;pointer-events:none;" +
137      "transition:opacity 200ms";
138    document.body.appendChild(banner);
139  }
140  const setBanner = (text, color) => {
141    banner.textContent = "[islands-dev] " + text;
142    banner.style.background = color || "#111";
143  };
144  setBanner("connecting...");
145
146  const swapStylesheet = (link) => new Promise((resolve) => {
147    const next = link.cloneNode();
148    const url = new URL(link.href, location.href);
149    url.searchParams.set("v", Date.now().toString());
150    next.href = url.toString();
151    next.addEventListener("load", () => {
152      link.remove();
153      resolve();
154    }, { once: true });
155    next.addEventListener("error", () => {
156      next.remove();
157      resolve();
158    }, { once: true });
159    link.parentNode.insertBefore(next, link.nextSibling);
160  });
161
162  const handleCss = async () => {
163    setBanner("css swap...", "#0a6");
164    const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
165    await Promise.all(links.map(swapStylesheet));
166    setBanner("connected", "#0a6");
167  };
168
169  const handleReload = () => {
170    setBanner("reload coming", "#a60");
171    setTimeout(() => location.reload(), 50);
172  };
173
174  let source;
175  const connect = () => {
176    source = new EventSource("/_dev/reload-events");
177    source.addEventListener("connected", () => setBanner("connected", "#0a6"));
178    source.addEventListener("css", handleCss);
179    source.addEventListener("reload", handleReload);
180    source.onerror = () => {
181      setBanner("disconnected", "#a00");
182      source.close();
183      setTimeout(connect, 1000);
184    };
185  };
186  connect();
187})();"##;
188
189/// The inline `$ISLANDS_REPLACE` `<script>` tag — emitted only with the `suspense`
190/// feature, since it is what streamed `<template>` chunks call to swap fallbacks.
191#[cfg(feature = "suspense")]
192fn replace_script_tag() -> String {
193    format!("<script>{}</script>\n", crate::suspense::REPLACE_SCRIPT)
194}
195
196/// Without `suspense`, no streaming chunk ever calls `$ISLANDS_REPLACE`, so the
197/// shell omits it entirely.
198#[cfg(not(feature = "suspense"))]
199fn replace_script_tag() -> String {
200    String::new()
201}
202
203/// The dev-mode HMR client `<script>` tag, emitted when `dev_mode` is set. The
204/// script itself is only compiled in under the `dev` feature.
205#[cfg(feature = "dev")]
206fn hmr_script_tag(dev_mode: bool) -> String {
207    if dev_mode {
208        format!("<script type=\"module\">{HMR_CLIENT_SCRIPT}</script>\n")
209    } else {
210        String::new()
211    }
212}
213
214/// Without `dev`, the HMR client is never compiled in; the shell never injects it.
215#[cfg(not(feature = "dev"))]
216fn hmr_script_tag(_dev_mode: bool) -> String {
217    String::new()
218}
219
220/// Renders a complete HTML page shell for an interactive (island-bearing) page:
221/// base + page stylesheets, the shared core module, and the page's WASM module.
222pub fn page_shell(
223    title: &str,
224    body: &str,
225    page_bundle: &str,
226    manifest: &Manifest,
227    prefix: &str,
228    dev_mode: bool,
229) -> String {
230    let title_escaped = html_escape::encode_safe(title);
231    let css_base = resolve_css_base(manifest, prefix);
232    let page_css = resolve_page_css(manifest, page_bundle, prefix);
233    let core_js = resolve_core_js(manifest, prefix);
234    let page_js = resolve_page_js(manifest, page_bundle, prefix);
235    let replace_script = replace_script_tag();
236    let hmr_script = hmr_script_tag(dev_mode);
237    format!(
238        "<!DOCTYPE html>\n<html lang=\"en\"><head>\n\
239<meta charset=\"utf-8\">\n\
240<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
241<title>{title_escaped}</title>\n\
242<link rel=\"stylesheet\" href=\"{css_base}\">\n\
243<link rel=\"stylesheet\" href=\"{page_css}\">\n\
244{replace_script}\
245<script type=\"module\">\
246import core_init from \"{core_js}\";\
247import page_init from \"{page_js}\";\
248await core_init();\
249await page_init();\
250</script>\n\
251{hmr_script}</head><body>{body}</body></html>"
252    )
253}
254
255/// Renders a complete HTML page shell for a **non-interactive** page that has no
256/// island, hence no page WASM bundle.
257///
258/// Same shell as [`page_shell`] but without the page-module `import` /
259/// `page_init()`: only the shared core is loaded (so cross-page client navigation
260/// still works), and no per-page stylesheet is linked. A static page's utility
261/// classes must therefore be reachable through the base stylesheet's content scan.
262pub fn page_shell_static(
263    title: &str,
264    body: &str,
265    manifest: &Manifest,
266    prefix: &str,
267    dev_mode: bool,
268) -> String {
269    let title_escaped = html_escape::encode_safe(title);
270    let css_base = resolve_css_base(manifest, prefix);
271    let core_js = resolve_core_js(manifest, prefix);
272    let replace_script = replace_script_tag();
273    let hmr_script = hmr_script_tag(dev_mode);
274    format!(
275        "<!DOCTYPE html>\n<html lang=\"en\"><head>\n\
276<meta charset=\"utf-8\">\n\
277<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
278<title>{title_escaped}</title>\n\
279<link rel=\"stylesheet\" href=\"{css_base}\">\n\
280{replace_script}\
281<script type=\"module\">\
282import core_init from \"{core_js}\";\
283await core_init();\
284</script>\n\
285{hmr_script}</head><body>{body}</body></html>"
286    )
287}
288
289/// Emits only the head portion (`<!DOCTYPE>` through `</head><body>`) for streaming
290/// SSR. Flush this chunk before Suspense futures resolve so the browser can start
291/// parsing CSS and scripts immediately. Available only with the `suspense` feature.
292#[cfg(feature = "suspense")]
293pub fn page_shell_streaming_head(
294    title: &str,
295    page_bundle: &str,
296    manifest: &Manifest,
297    prefix: &str,
298    dev_mode: bool,
299) -> String {
300    let title_escaped = html_escape::encode_safe(title);
301    let css_base = resolve_css_base(manifest, prefix);
302    let page_css = resolve_page_css(manifest, page_bundle, prefix);
303    let core_js = resolve_core_js(manifest, prefix);
304    let page_js = resolve_page_js(manifest, page_bundle, prefix);
305    let replace_script = replace_script_tag();
306    let hmr_script = hmr_script_tag(dev_mode);
307    format!(
308        "<!DOCTYPE html>\n<html lang=\"en\"><head>\n\
309<meta charset=\"utf-8\">\n\
310<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
311<title>{title_escaped}</title>\n\
312<link rel=\"stylesheet\" href=\"{css_base}\">\n\
313<link rel=\"stylesheet\" href=\"{page_css}\">\n\
314{replace_script}\
315<script type=\"module\">\
316import core_init from \"{core_js}\";\
317import page_init from \"{page_js}\";\
318await core_init();\
319await page_init();\
320</script>\n\
321{hmr_script}</head><body>"
322    )
323}