use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::CoreError;
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ManifestEntry {
pub js: Option<String>,
pub wasm: Option<String>,
pub css: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct Manifest {
pub css_base: Option<String>,
pub entries: HashMap<String, ManifestEntry>,
}
impl Manifest {
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 }
}
}
pub fn island_marker(
name: &str,
props: &serde_json::Value,
initial_html: &str,
) -> Result<String, CoreError> {
let props_json = serde_json::to_string(props)?;
let props_escaped = props_json.replace('\'', "'").replace('<', "<");
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();
})();"##;
#[cfg(feature = "suspense")]
fn replace_script_tag() -> String {
format!("<script>{}</script>\n", crate::suspense::REPLACE_SCRIPT)
}
#[cfg(not(feature = "suspense"))]
fn replace_script_tag() -> String {
String::new()
}
#[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()
}
}
#[cfg(not(feature = "dev"))]
fn hmr_script_tag(_dev_mode: bool) -> String {
String::new()
}
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>"
)
}
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>"
)
}
#[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>"
)
}