Renderer internals
Renderer internals
How Markdown becomes HTML inside Lettuce. Useful when adding a new Markdown extension or debugging output quirks.
Pipeline
Markdown source
↓ Frontmatter.split (strip the leading --- YAML block)
body text
↓ Document(parsing:) (swift-markdown CommonMark parser)
AST (immutable Markup tree)
↓ HTMLRenderer (a MarkupWalker) — walks the AST and emits HTML, handling
Lettuce syntax (wikilinks/tags/callouts/footnotes) inline
HTML string (+ collected wikilinks & footnotes)
↓ HTMLSanitizer (only for user-supplied embedded HTML)
Safe HTML
The pipeline lives in Sources/LettuceKit/Markdown/ — chiefly HTMLRenderer.swift, Frontmatter.swift, Footnotes.swift, and HTMLSanitizer.swift.
Parser: swift-markdown
swift-markdown is Apple’s CommonMark parser. We use the AST directly — no string-rewriting passes. This is what gives us robust handling of edge cases (nested code spans, escaped chars in link bodies, etc.).
import Markdown
let doc = Document(parsing: source)
The result is a tree of Markup nodes (Heading, Paragraph, Link, Text, …).
Frontmatter stripper
Before parsing, we look for a leading YAML block delimited by ---. If present:
let (frontmatter, body) = Frontmatter.split(source) // frontmatter: String?, body: String
Frontmatter is YAML-parsed via Yams. The body is what goes into Document(parsing:).
This is before the markdown parser sees the text, so frontmatter never appears in the AST or in the output.
The renderer walk
HTMLRenderer is a MarkupWalker (from swift-markdown). The AST is immutable, so rather than mutating nodes it recognises Lettuce-specific syntax as it walks and emits the right HTML directly:
Wikilinks:
Text("[[name]]")→Link(destination: resolveWikilink("name")). Resolution uses the link index passed in via context.Tags:
Text("...#tag...")→ split intoText+Link(tag-page-url)+Text. Uses a regex with whitespace boundary check.Callouts:
BlockQuotewhose first paragraph starts with[!type]→ emits a<div class="callout callout-type">instead of<blockquote>. The[!type]line itself is consumed.Block IDs: Trailing
^idon a block → set the block’sidattribute. The^idtext is stripped.Footnotes: CommonMark already handles
[^id]references and definitions. The^[inline]extension is a Lettuce add.Inline footnotes:
Text("^[content]")→ auto-numbered footnote ref + definition.
Producing HTML
HTMLRenderer.render walks the AST and returns the HTML plus the wikilinks and footnotes it collected along the way:
let (html, wikilinks, referencedFootnotes) = HTMLRenderer.render(doc)
Differences from swift-markdown’s built-in:
<a class="wikilink internal">/wikilink externalfor resolved wikilinks<a class="tag">for tags<div class="callout callout-<type>">for callouts<code>blocks getclass="language-<lang>"for syntax highlightingKaTeX
$...$and$$...$$are preserved as<span class="math">$...$</span>; KaTeX JS rendering happens client-sideMermaid code blocks become
<pre class="mermaid">...</pre>for client-side render
HTMLSanitizer
User content that contains embedded HTML (allowed in Markdown) is sanitised:
Allowed tags:
a, abbr, b, blockquote, br, caption, code, dd, del, details, dl, dt, em, figcaption, figure, h1–h6, hr, i, img, ins, kbd, li, mark, ol, p, pre, s, samp, small, span, strong, sub, summary, sup, table, tbody, td, tfoot, th, thead, tr, u, ul, var. (Nodiv/video/audio/iframe/script— any tag not on the list is stripped.)Allowed attributes:
title, classglobally; plus per-tag —a→href,img→src, alt, width, height,td→colspan, rowspan, align,th→colspan, rowspan, align, scope,details→open,ol→start. Everything else is dropped.URL schemes (for
href/src):http,https,mailto,tel. Anything else is rejected.javascript:URLs are stripped.Inline event handlers (
onclick,onload, etc.) are stripped.
If a disallowed tag appears, it’s escaped (<script>) rather than removed — preserves the source intent but renders inert.
Live Preview (editor)
The editor (CodeMirror 6) runs a parallel renderer using the same AST for in-place rendering. The bundle is built via bun:
cd Resources/cm6/
bun install
bun build src/editor.ts --outfile=../cm6.js
The CM6 bundle is shipped as Sources/LettuceKit/Resources/cm6.js. Lettuce loads it into a WKWebView for the editor; communication is via evaluateJavaScript.
Live Preview uses CodeMirror’s StateField decorations — it doesn’t re-parse Markdown on every keystroke. Instead, a small subset of decorations applied via regex against the visible viewport.
Performance
| Input | Time (M2 Pro) |
|---|---|
| 1KB note | ~0.5ms |
| 50KB note | ~12ms |
| 500KB note | ~140ms |
The bottleneck above ~100KB is HTML string construction (lots of small concatenations). For very large notes, we could switch to a string-builder pattern; haven’t needed to yet.
Common extensions
To add a new Markdown feature:
Define the syntax in a
Sources/LettuceKit/Docs/Core concepts/<feature>.mddoc.Handle it in
HTMLRenderer— recognise the pattern in the relevantvisit/rendermethod and emit the HTML inline during the walk (the AST is immutable; there’s no separate emitter pass).Add a CSS class in
Sources/LettuceKit/Resources/reader.css(the same stylesheet the app, QuickLook, and published site use).Add a test plus a snapshot under
Tests/LettuceKitTests/.
See For developers/06 Writing a new MCP tool for the analog on the agent side.
Related
Core concepts/06 Callouts — an example feature implemented this way