use std::cell::RefCell;
use std::collections::HashMap;
use serde::Deserialize;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::JsFuture;
pub(crate) const NAV_MANIFEST_PATH: &str = "/static/nav-manifest.json";
pub(crate) const NAV_HEADER_NAME: &str = "X-Islands-Nav";
pub(crate) const NAV_HEADER_VALUE: &str = "1";
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct NavEntry {
pub js: String,
#[serde(default)]
#[allow(dead_code)]
pub wasm: Option<String>,
#[serde(default)]
#[allow(dead_code)]
pub css: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct NavManifest {
pub routes: HashMap<String, NavEntry>,
}
impl NavManifest {
pub(crate) fn parse(json: &str) -> Result<NavManifest, serde_json::Error> {
serde_json::from_str(json)
}
pub(crate) fn from_json(json: &str) -> Result<NavManifest, JsValue> {
NavManifest::parse(json)
.map_err(|error| JsValue::from_str(&format!("nav-manifest parse error: {error}")))
}
pub(crate) fn entry_for(&self, pathname: &str) -> Option<&NavEntry> {
self.routes.get(pathname)
}
}
thread_local! {
static MANIFEST_CACHE: RefCell<Option<NavManifest>> = const { RefCell::new(None) };
}
pub(crate) fn is_cached() -> bool {
MANIFEST_CACHE.with(|cache| cache.borrow().is_some())
}
pub(crate) fn resolved_entry(pathname: &str) -> Option<NavEntry> {
MANIFEST_CACHE.with(|cache| {
cache
.borrow()
.as_ref()
.and_then(|manifest| manifest.entry_for(pathname).cloned())
})
}
fn store(manifest: NavManifest) {
MANIFEST_CACHE.with(|cache| *cache.borrow_mut() = Some(manifest));
}
pub(crate) async fn fetch_and_cache() -> Result<(), JsValue> {
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
let response_value = JsFuture::from(window.fetch_with_str(NAV_MANIFEST_PATH)).await?;
let response: web_sys::Response = response_value.dyn_into()?;
if response.status() == 404 {
store(NavManifest {
routes: HashMap::new(),
});
return Ok(());
}
if !response.ok() {
return Err(JsValue::from_str(&format!(
"nav-manifest fetch failed: HTTP {}",
response.status()
)));
}
let text_value = JsFuture::from(response.text()?).await?;
let json = text_value
.as_string()
.ok_or_else(|| JsValue::from_str("nav-manifest body was not a string"))?;
store(NavManifest::from_json(&json)?);
Ok(())
}
pub(crate) async fn ensure_cached() -> Result<(), JsValue> {
if is_cached() {
return Ok(());
}
fetch_and_cache().await
}
#[cfg(test)]
mod tests {
use super::NavManifest;
#[test]
fn parses_full_entry_with_all_urls() {
let json = r#"{
"routes": {
"/": {
"js": "/static/page-home/page_home.abc123.js",
"wasm": "/static/page-home/page_home.def456.wasm",
"css": "/static/page-home/page-home.ghi789.css"
}
}
}"#;
let manifest = NavManifest::parse(json).expect("parse");
let entry = manifest.entry_for("/").expect("route present");
assert_eq!(entry.js, "/static/page-home/page_home.abc123.js");
assert_eq!(
entry.wasm.as_deref(),
Some("/static/page-home/page_home.def456.wasm")
);
assert_eq!(
entry.css.as_deref(),
Some("/static/page-home/page-home.ghi789.css")
);
}
#[test]
fn parses_entry_with_only_js() {
let json = r#"{ "routes": { "/about": { "js": "/static/page-about/page_about.js" } } }"#;
let manifest = NavManifest::parse(json).expect("parse");
let entry = manifest.entry_for("/about").expect("route present");
assert_eq!(entry.js, "/static/page-about/page_about.js");
assert!(entry.wasm.is_none());
assert!(entry.css.is_none());
}
#[test]
fn unknown_route_resolves_to_none() {
let json = r#"{ "routes": { "/": { "js": "/static/page-home/page_home.js" } } }"#;
let manifest = NavManifest::parse(json).expect("parse");
assert!(manifest.entry_for("/missing").is_none());
}
#[test]
fn empty_routes_map_is_valid() {
let manifest = NavManifest::parse(r#"{ "routes": {} }"#).expect("parse");
assert!(manifest.entry_for("/").is_none());
}
#[test]
fn malformed_json_is_an_error() {
assert!(NavManifest::parse("not json").is_err());
assert!(NavManifest::parse(r#"{ "routes": { "/": {} } }"#).is_err());
}
}