Renderer internals

~5 min read

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:

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:

HTMLSanitizer

User content that contains embedded HTML (allowed in Markdown) is sanitised:

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:

  1. Define the syntax in a Sources/LettuceKit/Docs/Core concepts/<feature>.md doc.

  2. Handle it in HTMLRenderer — recognise the pattern in the relevant visit/render method and emit the HTML inline during the walk (the AST is immutable; there’s no separate emitter pass).

  3. Add a CSS class in Sources/LettuceKit/Resources/reader.css (the same stylesheet the app, QuickLook, and published site use).

  4. 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.