Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion content/docs/language/index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Language Reference
template: page
template: docs
---

# Language Reference
Expand Down
10 changes: 10 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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<HashMap<String, String>> = 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)
Expand Down
86 changes: 83 additions & 3 deletions src/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>).
//
// 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::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}

/// Extract TOC entries from ## headings in the markdown source.
pub fn extract_toc(md: &str) -> Vec<TocEntry> {
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
Expand All @@ -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<Event<'static>> = 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" =>
{
Expand All @@ -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!(
Expand All @@ -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!("<h2 id=\"{slug}\">").into()));
events.extend(h2_buf.drain(..));
events.push(Event::Html("</h2>\n".into()));
}

other => {
events.push(other);
if in_h2 {
h2_buf.push(other.into_static());
} else {
events.push(other);
}
}
}
}
Expand Down
61 changes: 61 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions templates/docs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block content %}
<div class="docs-layout">
<aside class="docs-sidebar">
<ul>
{% for entry in toc %}
<li><a href="#{{ entry.slug }}">{{ entry.text }}</a></li>
{% endfor %}
</ul>
</aside>
<article class="docs-content prose">
{{ body | safe }}
</article>
</div>
{% endblock content %}
Loading