use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use gloo_net::http::Request; use pulldown_cmark::{Options, Parser, html}; use serde::Deserialize; use web_sys::{Document, Element}; // ---------- utilities ---------- fn window() -> web_sys::Window { web_sys::window().expect("no global `window`") } fn doc() -> Document { window().document().expect("no document on window") } fn by_id(id: &str) -> Element { doc() .get_element_by_id(id) .unwrap_or_else(|| panic!("element #{id} not found")) } fn set_html(el: &Element, html_str: &str) { el.set_inner_html(html_str); } fn md_to_html(md: &str) -> String { let mut opts = Options::empty(); opts.insert(Options::ENABLE_TABLES); opts.insert(Options::ENABLE_FOOTNOTES); let parser = Parser::new_ext(md, opts); let mut out = String::new(); html::push_html(&mut out, parser); out } fn strip_front_matter(s: &str) -> &str { // VERY small, robust front-matter stripper: // starts with '---\n', find the next '\n---' boundary. let bytes = s.as_bytes(); if bytes.starts_with(b"---\n") { if let Some(end) = s[4..].find("\n---") { let idx = 4 + end + 4; // 4 for '---\n', + end, +4 for '\n---' return &s[idx..]; } } s } // ---------- data types ---------- #[derive(Debug, Deserialize, Clone)] #[serde(default)] struct PostMeta { title: String, date: String, excerpt: String, tags: Vec, section: String, slug: String, #[serde(rename = "readingTime")] reading_time: Option, cover: Option, author: Option, series: Option, programs: Option>, file: String, } impl Default for PostMeta { fn default() -> Self { Self { title: String::new(), date: String::new(), excerpt: String::new(), tags: vec![], section: String::new(), slug: String::new(), reading_time: None, cover: None, author: None, series: None, programs: None, file: String::new(), } } } // ---------- HTML builders (pure, no DOM globals) ---------- fn card_html(p: &PostMeta) -> String { let cover_style = p .cover .as_ref() .map(|u| format!(r#" style="background-image:url({}); background-size:cover;""#, u)) .unwrap_or_default(); format!( r#"

{title}

{section}

{date}

{excerpt}

"#, slug = p.slug, cover = cover_style, title = &p.title, section = &p.section, date = &p.date, excerpt = &p.excerpt ) } fn home_html(posts: &[PostMeta]) -> String { let cards = posts.iter().map(card_html).collect::>().join(""); format!( r#"

Latest Across All Sections

{cards}
"# ) } fn post_html(post: &PostMeta, body_html: &str) -> String { let author = post .author .as_ref() .map(|a| format!(r#"

By {a}

"#)) .unwrap_or_default(); let programs = post.programs.as_ref().map(|ps| { ps.iter() .map(|p| format!(r#"{}"#, p)) .collect::() }).unwrap_or_default(); // NOTE: raw strings here avoid escaping issues; the comma is outside. format!( r#"
← Back

{title}

{date}

{author}
{section}{programs}

{body}
"#, title = &post.title, date = &post.date, author = author, section = &post.section, programs = programs, body = body_html ) } // ---------- routing & rendering ---------- async fn fetch_index() -> Result, JsValue> { let text = Request::get("index.json").send().await?.text().await?; let posts: Vec = serde_json::from_str(&text) .map_err(|e| JsValue::from_str(&format!("index.json parse error: {e}")))?; Ok(posts) } async fn fetch_markdown(rel_path: &str) -> Result { // content/ let url = format!("content/{rel}", rel = rel_path); let text = Request::get(&url).send().await?.text().await?; Ok(text) } async fn render_route_async() -> Result<(), JsValue> { let hash = window().location().hash()?; // e.g. "#/post/slug" let main = by_id("main"); // Ensure index.json is reachable let posts = fetch_index().await?; // Simple router // "" or "#/" -> home // "#/post/" -> post let route = hash.trim_start_matches('#'); let parts: Vec<&str> = route.split('/').filter(|s| !s.is_empty()).collect(); if parts.is_empty() { let html = home_html(&posts); set_html(&main, &html); return Ok(()); } match parts.as_slice() { ["post", slug] => { // find post if let Some(p) = posts.iter().find(|p| p.slug == *slug) { // fetch md, strip front matter, convert to HTML let md = fetch_markdown(&p.file).await.unwrap_or_else(|_| String::from("# Missing\nFile not found.")); let body_md = strip_front_matter(&md).trim(); let body_html = md_to_html(body_md); let html = post_html(p, &body_html); set_html(&main, &html); } else { set_html(&main, r#"

⚠️ Post not found.

"#); } } _ => { set_html(&main, r#"

⚠️ Page not found.

"#); } } Ok(()) } fn add_hashchange_handler() { // Use a no-arg closure to satisfy wasm-bindgen type inference (fixes E0283) let cb = Closure::::new(move || { // We can't `.await` here directly; spawn a future. wasm_bindgen_futures::spawn_local(async { if let Err(e) = render_route_async().await { web_sys::console::error_1(&e); let _ = doc() .get_element_by_id("main") .map(|el| el.set_inner_html(r#"

⚠️ Render failed.

"#)); } }); }); // assign and forget (leak) to keep closure alive window().set_onhashchange(Some(cb.as_ref().unchecked_ref())); cb.forget(); } async fn initial_render() { if let Err(e) = render_route_async().await { web_sys::console::error_1(&e); set_html(&by_id("main"), r#"

⚠️ Initial render failed.

"#); } } // ---------- wasm entry ---------- #[wasm_bindgen(start)] pub fn start() { // better panics in console console_error_panic_hook::set_once(); // Ensure there is a #main element to render into // (If not found, this will panic clearly at runtime.) let _ = by_id("main"); add_hashchange_handler(); // kick once wasm_bindgen_futures::spawn_local(async { initial_render().await }); }