use markplus_core::json::SiteAsset;
use serde_json::{Value, json};
pub fn ast_to_template_context(asset: &SiteAsset) -> Value {
let toc = build_toc(&asset.ast);
let slug = derive_slug(asset);
json!({
"meta": asset.meta,
"slug": slug,
"toc": toc,
"body": asset.ast,
})
}
fn build_toc(ast: &[Value]) -> Vec<Value> {
let mut toc = Vec::new();
collect_headings(ast, &mut toc);
toc
}
fn collect_headings(nodes: &[Value], out: &mut Vec<Value>) {
for node in nodes {
if node["t"] == "heading" {
let text = collect_text(node.get("children").and_then(Value::as_array).map_or(&[], |v| v));
let slug = slugify_text(&text);
let level = node["level"].as_u64().unwrap_or(1);
out.push(json!({ "level": level, "text": text, "slug": slug }));
}
if let Some(children) = node.get("children").and_then(Value::as_array) {
collect_headings(children, out);
}
if let Some(items) = node.get("items").and_then(Value::as_array) {
for item in items {
if let Some(ch) = item.get("children").and_then(Value::as_array) {
collect_headings(ch, out);
}
}
}
}
}
fn derive_slug(asset: &SiteAsset) -> String {
if let Some(title) = asset.meta.as_ref().and_then(|m| m.get("title")).and_then(Value::as_str) {
return slugify_text(title);
}
for node in &asset.ast {
if node["t"] == "heading" {
let text = collect_text(node.get("children").and_then(Value::as_array).map_or(&[], |v| v));
if !text.is_empty() {
return slugify_text(&text);
}
}
}
"document".into()
}
pub fn collect_text(children: &[Value]) -> String {
let mut out = String::new();
for child in children {
if let Some(t) = child.get("text").and_then(Value::as_str) {
out.push_str(t);
} else if let Some(src) = child.get("src").and_then(Value::as_str) {
out.push_str(src);
} else if let Some(ch) = child.get("children").and_then(Value::as_array) {
out.push_str(&collect_text(ch));
}
}
out
}
pub fn slugify_text(s: &str) -> String {
s.chars()
.map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::*;
use markplus_core::parse_document;
const DOC: &str = "---\ntitle: Hello World\ntags:\n - rust\n---\n# Hello World\n\nSome text.\n\n## Sub-section\n\nMore text.\n";
#[test]
fn context_has_meta_slug_toc_body() {
let asset = parse_document(DOC).unwrap();
let ctx = ast_to_template_context(&asset);
assert_eq!(ctx["meta"]["title"], "Hello World");
assert_eq!(ctx["slug"], "hello-world");
assert_eq!(ctx["toc"].as_array().unwrap().len(), 2);
assert!(!ctx["body"].as_array().unwrap().is_empty());
}
#[test]
fn toc_entries_have_level_text_slug() {
let asset = parse_document(DOC).unwrap();
let ctx = ast_to_template_context(&asset);
let first = &ctx["toc"][0];
assert_eq!(first["level"], 1);
assert_eq!(first["text"], "Hello World");
assert_eq!(first["slug"], "hello-world");
}
#[test]
fn slug_falls_back_to_first_heading() {
let asset = parse_document("# My Heading\n\nBody.\n").unwrap();
let ctx = ast_to_template_context(&asset);
assert_eq!(ctx["slug"], "my-heading");
}
#[test]
fn slug_defaults_to_document_when_empty() {
let asset = parse_document("Just a paragraph.\n").unwrap();
let ctx = ast_to_template_context(&asset);
assert_eq!(ctx["slug"], "document");
}
#[test]
fn slugify_handles_special_chars() {
assert_eq!(slugify_text("Hello, World! (2026)"), "hello-world-2026");
}
}