diff --git a/content/docs/language/index.md b/content/docs/language/index.md index 3515587..438c28c 100644 --- a/content/docs/language/index.md +++ b/content/docs/language/index.md @@ -1,6 +1,6 @@ --- title: Language Reference -template: page +template: docs --- # Language Reference diff --git a/src/main.rs b/src/main.rs index efc780a..55b8f15 100644 --- a/src/main.rs +++ b/src/main.rs @@ -118,6 +118,7 @@ fn render_page(md_path: &Path, tera: &Tera) -> Result<()> { let (mut meta, body) = parse_frontmatter(&source); + let toc = markdown::extract_toc(body); let page = Page { title: meta.remove("title"), template: meta.remove("template").unwrap_or_else(|| "page".to_string()), @@ -150,6 +151,15 @@ fn render_page(md_path: &Path, tera: &Tera) -> Result<()> { ctx.insert("body", &page.body_html); ctx.insert("meta", &page.meta); ctx.insert("root", &root); + let toc_items: Vec> = toc.iter() + .map(|e| { + let mut m = HashMap::new(); + m.insert("text".to_string(), e.text.clone()); + m.insert("slug".to_string(), e.slug.clone()); + m + }) + .collect(); + ctx.insert("toc", &toc_items); let rendered = tera .render(&template_name, &ctx) diff --git a/src/markdown.rs b/src/markdown.rs index 718c84c..7c71f48 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -4,13 +4,64 @@ // intercepted and replaced with highlighted HTML produced by highlight::highlight(). // All other code blocks fall through to pulldown-cmark's default handling // (no highlighting — plain text in ). +// +// h2 headings get id attributes derived from their text so the sidebar TOC +// can link to them. -use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd, html}; +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd, html}; use crate::highlight; +/// A single TOC entry extracted from a ## heading. +pub struct TocEntry { + pub text: String, + pub slug: String, +} + +/// Slugify a heading: lowercase, spaces/punctuation → hyphens, collapse runs. +pub fn slugify(text: &str) -> String { + text.chars() + .map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Extract TOC entries from ## headings in the markdown source. +pub fn extract_toc(md: &str) -> Vec { + let parser = Parser::new_ext(md, Options::empty()); + + let mut entries = Vec::new(); + let mut in_h2 = false; + let mut text_buf = String::new(); + + for event in parser { + match event { + Event::Start(Tag::Heading { level: HeadingLevel::H2, .. }) => { + in_h2 = true; + text_buf.clear(); + } + Event::Text(ref text) if in_h2 => { + text_buf.push_str(text); + } + Event::End(TagEnd::Heading(HeadingLevel::H2)) if in_h2 => { + in_h2 = false; + let text = text_buf.trim().to_string(); + let slug = slugify(&text); + entries.push(TocEntry { text, slug }); + } + _ => {} + } + } + + entries +} + /// Render a markdown string to an HTML string. /// Fink code blocks are syntax-highlighted. +/// h2 headings get id attributes for sidebar anchor links. pub fn render(md: &str) -> String { let opts = Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES @@ -23,8 +74,14 @@ pub fn render(md: &str) -> String { let mut in_fink_block = false; let mut fink_buf = String::new(); + // For h2: buffer text events between Start/End so we can wrap with id= + let mut in_h2 = false; + let mut h2_buf: Vec> = Vec::new(); + let mut h2_text = String::new(); + for event in parser { match event { + // --- Fink code blocks --- Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(ref lang))) if lang.as_ref() == "fink" => { @@ -38,7 +95,6 @@ pub fn render(md: &str) -> String { Event::End(TagEnd::CodeBlock) if in_fink_block => { in_fink_block = false; - // Trim trailing newline that pulldown-cmark adds let src = fink_buf.trim_end_matches('\n'); let highlighted = highlight::highlight(src); let html = format!( @@ -48,8 +104,32 @@ pub fn render(md: &str) -> String { events.push(Event::Html(html.into())); } + // --- h2 headings: buffer contents, inject id= on close --- + Event::Start(Tag::Heading { level: HeadingLevel::H2, .. }) => { + in_h2 = true; + h2_buf.clear(); + h2_text.clear(); + } + + Event::Text(text) if in_h2 => { + h2_text.push_str(&text); + h2_buf.push(Event::Text(text.into_static())); + } + + Event::End(TagEnd::Heading(HeadingLevel::H2)) if in_h2 => { + in_h2 = false; + let slug = slugify(&h2_text); + events.push(Event::Html(format!("

").into())); + events.extend(h2_buf.drain(..)); + events.push(Event::Html("

\n".into())); + } + other => { - events.push(other); + if in_h2 { + h2_buf.push(other.into_static()); + } else { + events.push(other); + } } } } diff --git a/static/style.css b/static/style.css index bae0854..54c1f55 100644 --- a/static/style.css +++ b/static/style.css @@ -174,12 +174,73 @@ footer { color: var(--muted); } +/* --- Docs layout (sidebar + content) ----------------------- */ +.docs-layout { + display: flex; + max-width: calc(var(--max-w) + 16rem); + margin: 0 auto; + padding: 2rem 2rem 0; + gap: 2rem; + align-items: flex-start; +} + +.docs-sidebar { + width: 12rem; + flex-shrink: 0; + align-self: flex-start; + position: sticky; + top: calc(3.5rem + 1.5rem); + font-size: 0.875rem; + max-height: calc(100vh - 3.5rem - 3rem); + overflow-y: auto; +} + +.docs-sidebar ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.docs-sidebar a { + display: block; + padding: 0.3rem 0.75rem; + color: var(--muted); + border-left: 2px solid var(--border); + text-decoration: none; + line-height: 1.4; +} + +.docs-sidebar a:hover { + color: var(--accent); + border-left-color: var(--accent); +} + +.docs-content { + flex: 1; + min-width: 0; +} + +@media (max-width: 800px) { + .docs-sidebar { display: none; } + .docs-layout { padding: 0; } +} + /* --- Prose (docs pages) ------------------------------------- */ .prose { max-width: var(--max-w); margin: 0 auto; padding: 3rem 2rem; } + +/* When prose is inside the docs layout, let the layout control width */ +.docs-content.prose { + max-width: none; + margin: 0; + padding: 1rem 0 3rem; +} .prose h1 { font-size: 2rem; font-weight: 800; margin-bottom: 0.5rem; } .prose h2 { font-size: 1.4rem; diff --git a/templates/docs.html b/templates/docs.html new file mode 100644 index 0000000..ca723d9 --- /dev/null +++ b/templates/docs.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block content %} +
+ +
+ {{ body | safe }} +
+
+{% endblock content %}