Rendering
Rendering is the last mile and the most configuration-light stage. It turns an ExpandedDocument into a string — HTML or Markdown — with a pure tree
walk: no I/O, no state beyond the options you pass. Know the two knobs (mode and
CSS) and the fixed AST-to-HTML mapping and you know the whole job.
HTML
renderHtml(doc: ExpandedDocument, options?: RenderHtmlOptions) → string.
RenderHtmlOptions:
mode—'fragment' | 'document'. Defaults to'fragment'.fragmentemits just<article class="wit-doc">…</article>;documentwraps it in a full<!doctype html>page with the stylesheet inlined into<head>.title?: string— the document-mode<title>(default'Wit document').lang?: string— the document-mode<html lang>(default'en').css?: string— the document-mode stylesheet, inlined into<head>. Defaults todefaultThemeCss. Pass''for an unstyled page, or your own string.
Theming
Two stylesheet strings ship from @witlang/render-html:
defaultThemeCss— the Word-like house style: CSS custom properties, aprefers-color-schemedark mode, print and@pagerules. A document with no CSS of its own renders like a word-processor page.rawThemeCss— a mechanical reset only (box model, no body margin, responsive media). You style everything yourself via@@styleand wrapping nodes.
import { renderHtml, defaultThemeCss, rawThemeCss } from '@witlang/render-html';
renderHtml(expanded, { mode: 'document' }); // house style
renderHtml(expanded, { mode: 'document', css: rawThemeCss }); // bare reset
renderHtml(expanded, { mode: 'document', css: myOwnCss }); // your CSS
renderHtml(expanded, { mode: 'document', css: '' }); // no <style> at allThe AST-to-HTML mapping
The walk is fixed. The mappings that matter:
Paragraph→<p>; empty paragraphs are dropped, so no stray<p></p>.Italic→<em>;Bold→<strong>;Textis escaped throughescapeHtml.- Core vocab maps to its fixed element:
@h1→<h1>,@a |href …|→<a href=…>,@img→<img>,@row/@col→ flex<div>s,@br/@hr→ void elements. @table→ its dedicated renderer (schema, rows, caption; three call forms).@bibliography→<div class="wit-bibliography">with one<p>per entry.- An opaque
@node→ dispatched on itstypeparam (see Custom nodes). - A definition body that collapsed to a literal
Record→<table class="wit-record">; aCollection→<ul class="wit-collection">. - A surviving unknown user
@name(rare) →<div|span class="wit-node" data-wit-name="…">with each param as adata-*attribute. - An unresolved
::x::interpolation →<span class="wit-unresolved">::x::</span>; a surviving unresolved access@x.y→<span class="wit-unresolved">@x.y</span>— visible, never silent.
Safety
All text and attribute values pass through escapeHtml ('→ ', " → "), so malformed prose can
never produce malformed HTML. The raw-text elements @@style and @@script emit their body verbatim — CSS and JS
must not be escaped — but the renderer guards the closing tag
(</style becomes <\/style inside the body):
@@style .x{color:red} style@@ // → <style>.x { color: red; }</style>
Markdown
renderMarkdown(doc: ExpandedDocument) → string is the single
export — no options. It is CommonMark-ish, hand-rolled per node
name. The same source rendered both ways:
@h1 Title h1@ Some *bold* and _italic_ prose. @ul @li One li@ @li Two li@ ul@
Title
Some bold and italic prose.
- One
- Two
→ Markdown:
# Title Some **bold** and *italic* prose. - One - Two
# Title
Some *bold* and italic prose.
- One - Two
Sectioning wrappers (section, article, header, footer, nav, main) are
transparent; comments and scripts are dropped; unknown nodes emit their content
unwrapped; there is no Markdown analog for the opaque container, so it emits just
its body.
Progressive examples
- Fragment vs document.
renderHtml(expanded)gives the bare<article>;renderHtml(expanded, { mode: 'document', title: 'Essay' })gives a full page. - Re-theme. Swap
css: rawThemeCssfor the mechanical reset, or pass your own string. - Two targets, one source. Call
renderHtmlandrenderMarkdownon the sameExpandedDocument.
Every edge
modedefault isfragment— easy to get wrong.css: ''yields an unstyled document; the<style>tag is omitted entirely.- Because
escapeHtmlruns on all text, you never double-escape by letting the renderer do its job. - The PDF path (CLI) is just document-mode HTML paginated by headless Chrome — no renderer difference.
Common mistakes
- Expecting
documentmode by default fromrenderHtml. - Expecting
renderMarkdownto take options — it takes none. - Double-escaping — the renderer already escapes; hand it raw strings.
- Worrying about a
</style>inside@@style— it is auto-guarded.
See also
Custom nodes(opaque dispatch) · Build your own renderer · Architecture (render is a pure walk) · Resolve & expand.