1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::CoreError;
7
8#[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#[derive(Debug, Clone, Default)]
20pub struct Manifest {
21 pub css_base: Option<String>,
22 pub entries: HashMap<String, ManifestEntry>,
23}
24
25impl Manifest {
26 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
65pub 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 let props_escaped = props_json.replace('\'', "'").replace('<', "<");
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#[cfg(feature = "suspense")]
192fn replace_script_tag() -> String {
193 format!("<script>{}</script>\n", crate::suspense::REPLACE_SCRIPT)
194}
195
196#[cfg(not(feature = "suspense"))]
199fn replace_script_tag() -> String {
200 String::new()
201}
202
203#[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#[cfg(not(feature = "dev"))]
216fn hmr_script_tag(_dev_mode: bool) -> String {
217 String::new()
218}
219
220pub 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
255pub 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#[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}