islands-core 0.1.0

Server-side SSR primitives for islands.rs: island markers, the page shell, the asset manifest, and streaming Suspense.
Documentation
use std::collections::HashMap;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::error::CoreError;

/// A single entry in the asset manifest for one page bundle.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ManifestEntry {
    pub js: Option<String>,
    pub wasm: Option<String>,
    pub css: Option<String>,
}

/// Asset manifest mapping bundle names to their hashed file paths.
///
/// Top-level `css_base` is the hashed path for the shared base stylesheet.
#[derive(Debug, Clone, Default)]
pub struct Manifest {
    pub css_base: Option<String>,
    pub entries: HashMap<String, ManifestEntry>,
}

impl Manifest {
    /// Load manifest from `path`. Returns `Default` if the file is missing or unparseable.
    ///
    /// Reads two surfaces:
    /// - Top-level `"islands-core"` entry (the shared runtime bundle).
    /// - Nested `"pages"` map keyed by `"<example>/<page>"`, flattened into
    ///   `entries` so look-ups by bundle key resolve directly.
    pub fn load(path: &Path) -> Self {
        let Ok(data) = std::fs::read_to_string(path) else {
            return Self::default();
        };
        let Ok(raw): Result<serde_json::Value, _> = serde_json::from_str(&data) else {
            return Self::default();
        };
        let css_base = raw
            .get("css-base")
            .and_then(|v| v.as_str())
            .map(str::to_owned);
        let mut entries: HashMap<String, ManifestEntry> = HashMap::new();
        if let Some(object) = raw.as_object() {
            for (key, value) in object {
                if key == "css-base" || key == "pages" {
                    continue;
                }
                if let Ok(entry) = serde_json::from_value::<ManifestEntry>(value.clone()) {
                    entries.insert(key.clone(), entry);
                }
            }
        }
        if let Some(pages_value) = raw.get("pages").and_then(|value| value.as_object()) {
            for (key, value) in pages_value {
                if let Ok(entry) = serde_json::from_value::<ManifestEntry>(value.clone()) {
                    entries.insert(key.clone(), entry);
                }
            }
        }
        Self { css_base, entries }
    }
}

/// Renders an island marker with serialized props and initial server-rendered HTML.
///
/// Produces: `<div data-island="NAME" data-island-props='{json}'>{initial_html}</div>`
///
/// Props are single-quoted; single quotes and angle brackets in the JSON are escaped.
pub fn island_marker(
    name: &str,
    props: &serde_json::Value,
    initial_html: &str,
) -> Result<String, CoreError> {
    let props_json = serde_json::to_string(props)?;
    // Single-quoted attribute: escape ' as &#39; and < as &lt; to prevent injection.
    let props_escaped = props_json.replace('\'', "&#39;").replace('<', "&lt;");
    Ok(format!(
        "<div data-island=\"{name}\" data-island-props='{props_escaped}'>{initial_html}</div>"
    ))
}

fn resolve_css_base(manifest: &Manifest, prefix: &str) -> String {
    manifest
        .css_base
        .as_deref()
        .map(|p| format!("{prefix}/static/{p}"))
        .unwrap_or_else(|| format!("{prefix}/static/css/base.css"))
}

fn resolve_page_css(manifest: &Manifest, page_bundle: &str, prefix: &str) -> String {
    manifest
        .entries
        .get(page_bundle)
        .and_then(|entry| entry.css.as_deref())
        .map(|path| format!("{prefix}/static/{path}"))
        .unwrap_or_else(|| {
            let leaf = page_bundle.rsplit('/').next().unwrap_or(page_bundle);
            let filename = leaf.replace('-', "_");
            format!("{prefix}/static/{page_bundle}/{filename}.css")
        })
}

fn resolve_core_js(manifest: &Manifest, prefix: &str) -> String {
    manifest
        .entries
        .get("islands-core")
        .and_then(|entry| entry.js.as_deref())
        .map(|path| format!("{prefix}/static/{path}"))
        .unwrap_or_else(|| format!("{prefix}/static/islands-core/islands_core.js"))
}

fn resolve_page_js(manifest: &Manifest, page_bundle: &str, prefix: &str) -> String {
    manifest
        .entries
        .get(page_bundle)
        .and_then(|entry| entry.js.as_deref())
        .map(|path| format!("{prefix}/static/{path}"))
        .unwrap_or_else(|| {
            let leaf = page_bundle.rsplit('/').next().unwrap_or(page_bundle);
            let filename = leaf.replace('-', "_");
            format!("{prefix}/static/{page_bundle}/{filename}.js")
        })
}

#[cfg(feature = "dev")]
const HMR_CLIENT_SCRIPT: &str = r##"(() => {
  const STATUS_ID = "__islands_dev_status";
  let banner = document.getElementById(STATUS_ID);
  if (!banner) {
    banner = document.createElement("div");
    banner.id = STATUS_ID;
    banner.style.cssText =
      "position:fixed;bottom:8px;right:8px;z-index:2147483647;" +
      "padding:4px 8px;border-radius:6px;font:12px/1 ui-monospace,monospace;" +
      "background:#111;color:#fff;opacity:0.75;pointer-events:none;" +
      "transition:opacity 200ms";
    document.body.appendChild(banner);
  }
  const setBanner = (text, color) => {
    banner.textContent = "[islands-dev] " + text;
    banner.style.background = color || "#111";
  };
  setBanner("connecting...");

  const swapStylesheet = (link) => new Promise((resolve) => {
    const next = link.cloneNode();
    const url = new URL(link.href, location.href);
    url.searchParams.set("v", Date.now().toString());
    next.href = url.toString();
    next.addEventListener("load", () => {
      link.remove();
      resolve();
    }, { once: true });
    next.addEventListener("error", () => {
      next.remove();
      resolve();
    }, { once: true });
    link.parentNode.insertBefore(next, link.nextSibling);
  });

  const handleCss = async () => {
    setBanner("css swap...", "#0a6");
    const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
    await Promise.all(links.map(swapStylesheet));
    setBanner("connected", "#0a6");
  };

  const handleReload = () => {
    setBanner("reload coming", "#a60");
    setTimeout(() => location.reload(), 50);
  };

  let source;
  const connect = () => {
    source = new EventSource("/_dev/reload-events");
    source.addEventListener("connected", () => setBanner("connected", "#0a6"));
    source.addEventListener("css", handleCss);
    source.addEventListener("reload", handleReload);
    source.onerror = () => {
      setBanner("disconnected", "#a00");
      source.close();
      setTimeout(connect, 1000);
    };
  };
  connect();
})();"##;

/// The inline `$ISLANDS_REPLACE` `<script>` tag — emitted only with the `suspense`
/// feature, since it is what streamed `<template>` chunks call to swap fallbacks.
#[cfg(feature = "suspense")]
fn replace_script_tag() -> String {
    format!("<script>{}</script>\n", crate::suspense::REPLACE_SCRIPT)
}

/// Without `suspense`, no streaming chunk ever calls `$ISLANDS_REPLACE`, so the
/// shell omits it entirely.
#[cfg(not(feature = "suspense"))]
fn replace_script_tag() -> String {
    String::new()
}

/// The dev-mode HMR client `<script>` tag, emitted when `dev_mode` is set. The
/// script itself is only compiled in under the `dev` feature.
#[cfg(feature = "dev")]
fn hmr_script_tag(dev_mode: bool) -> String {
    if dev_mode {
        format!("<script type=\"module\">{HMR_CLIENT_SCRIPT}</script>\n")
    } else {
        String::new()
    }
}

/// Without `dev`, the HMR client is never compiled in; the shell never injects it.
#[cfg(not(feature = "dev"))]
fn hmr_script_tag(_dev_mode: bool) -> String {
    String::new()
}

/// Renders a complete HTML page shell for an interactive (island-bearing) page:
/// base + page stylesheets, the shared core module, and the page's WASM module.
pub fn page_shell(
    title: &str,
    body: &str,
    page_bundle: &str,
    manifest: &Manifest,
    prefix: &str,
    dev_mode: bool,
) -> String {
    let title_escaped = html_escape::encode_safe(title);
    let css_base = resolve_css_base(manifest, prefix);
    let page_css = resolve_page_css(manifest, page_bundle, prefix);
    let core_js = resolve_core_js(manifest, prefix);
    let page_js = resolve_page_js(manifest, page_bundle, prefix);
    let replace_script = replace_script_tag();
    let hmr_script = hmr_script_tag(dev_mode);
    format!(
        "<!DOCTYPE html>\n<html lang=\"en\"><head>\n\
<meta charset=\"utf-8\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
<title>{title_escaped}</title>\n\
<link rel=\"stylesheet\" href=\"{css_base}\">\n\
<link rel=\"stylesheet\" href=\"{page_css}\">\n\
{replace_script}\
<script type=\"module\">\
import core_init from \"{core_js}\";\
import page_init from \"{page_js}\";\
await core_init();\
await page_init();\
</script>\n\
{hmr_script}</head><body>{body}</body></html>"
    )
}

/// Renders a complete HTML page shell for a **non-interactive** page that has no
/// island, hence no page WASM bundle.
///
/// Same shell as [`page_shell`] but without the page-module `import` /
/// `page_init()`: only the shared core is loaded (so cross-page client navigation
/// still works), and no per-page stylesheet is linked. A static page's utility
/// classes must therefore be reachable through the base stylesheet's content scan.
pub fn page_shell_static(
    title: &str,
    body: &str,
    manifest: &Manifest,
    prefix: &str,
    dev_mode: bool,
) -> String {
    let title_escaped = html_escape::encode_safe(title);
    let css_base = resolve_css_base(manifest, prefix);
    let core_js = resolve_core_js(manifest, prefix);
    let replace_script = replace_script_tag();
    let hmr_script = hmr_script_tag(dev_mode);
    format!(
        "<!DOCTYPE html>\n<html lang=\"en\"><head>\n\
<meta charset=\"utf-8\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
<title>{title_escaped}</title>\n\
<link rel=\"stylesheet\" href=\"{css_base}\">\n\
{replace_script}\
<script type=\"module\">\
import core_init from \"{core_js}\";\
await core_init();\
</script>\n\
{hmr_script}</head><body>{body}</body></html>"
    )
}

/// Emits only the head portion (`<!DOCTYPE>` through `</head><body>`) for streaming
/// SSR. Flush this chunk before Suspense futures resolve so the browser can start
/// parsing CSS and scripts immediately. Available only with the `suspense` feature.
#[cfg(feature = "suspense")]
pub fn page_shell_streaming_head(
    title: &str,
    page_bundle: &str,
    manifest: &Manifest,
    prefix: &str,
    dev_mode: bool,
) -> String {
    let title_escaped = html_escape::encode_safe(title);
    let css_base = resolve_css_base(manifest, prefix);
    let page_css = resolve_page_css(manifest, page_bundle, prefix);
    let core_js = resolve_core_js(manifest, prefix);
    let page_js = resolve_page_js(manifest, page_bundle, prefix);
    let replace_script = replace_script_tag();
    let hmr_script = hmr_script_tag(dev_mode);
    format!(
        "<!DOCTYPE html>\n<html lang=\"en\"><head>\n\
<meta charset=\"utf-8\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
<title>{title_escaped}</title>\n\
<link rel=\"stylesheet\" href=\"{css_base}\">\n\
<link rel=\"stylesheet\" href=\"{page_css}\">\n\
{replace_script}\
<script type=\"module\">\
import core_init from \"{core_js}\";\
import page_init from \"{page_js}\";\
await core_init();\
await page_init();\
</script>\n\
{hmr_script}</head><body>"
    )
}