thefoldwithin-earth/.old2/src/lib.rs
Mark Randall Havens 7f86647175 fresh start
2025-10-19 16:48:12 -05:00

260 lines
7.2 KiB
Rust
Executable file

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<String>,
section: String,
slug: String,
#[serde(rename = "readingTime")]
reading_time: Option<u32>,
cover: Option<String>,
author: Option<String>,
series: Option<String>,
programs: Option<Vec<String>>,
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#"<article data-slug="{slug}" tabindex="0">
<div class="thumb"{cover}></div>
<h3>{title}</h3>
<span class="pill section">{section}</span>
<p class="date">{date}</p>
<p>{excerpt}</p>
</article>"#,
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::<Vec<_>>().join("");
format!(
r#"<section class="latest-all">
<h2>Latest Across All Sections</h2>
<div class="grid">{cards}</div>
</section>"#
)
}
fn post_html(post: &PostMeta, body_html: &str) -> String {
let author = post
.author
.as_ref()
.map(|a| format!(r#"<p class="author">By {a}</p>"#))
.unwrap_or_default();
let programs = post.programs.as_ref().map(|ps| {
ps.iter()
.map(|p| format!(r#"<span class="pill program">{}</span>"#, p))
.collect::<String>()
}).unwrap_or_default();
// NOTE: raw strings here avoid escaping issues; the comma is outside.
format!(
r#"<section class="post">
<a href="#/" id="back">← Back</a>
<div class="markdown">
<h1>{title}</h1>
<p class="date">{date}</p>
{author}
<div class="meta"><span class="pill section">{section}</span>{programs}</div>
<hr/>
{body}
</div>
</section>"#,
title = &post.title,
date = &post.date,
author = author,
section = &post.section,
programs = programs,
body = body_html
)
}
// ---------- routing & rendering ----------
async fn fetch_index() -> Result<Vec<PostMeta>, JsValue> {
let text = Request::get("index.json").send().await?.text().await?;
let posts: Vec<PostMeta> = 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<String, JsValue> {
// content/<relative>
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/<slug>" -> 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#"<p class="error">⚠️ Post not found.</p>"#);
}
}
_ => {
set_html(&main, r#"<p class="error">⚠️ Page not found.</p>"#);
}
}
Ok(())
}
fn add_hashchange_handler() {
// Use a no-arg closure to satisfy wasm-bindgen type inference (fixes E0283)
let cb = Closure::<dyn FnMut()>::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#"<p class="error">⚠️ Render failed.</p>"#));
}
});
});
// 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#"<p class="error">⚠️ Initial render failed.</p>"#);
}
}
// ---------- 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 });
}