Wit

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'. fragment emits just <article class="wit-doc">…</article>; document wraps 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 to defaultThemeCss. 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, a prefers-color-scheme dark mode, print and @page rules. 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 @@style and 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 all

The 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>; Text is escaped through escapeHtml.
  • Core vocab maps to its fixed element: @h1 → <h1>, @a(href: …) → <a href=…>, @img → <img>, @row / @col → flex <div> code@s, @br / @hr → void elements.
  • @table → its dedicated renderer: rows list of rows, the first the header), schema (pick and label the columns of a list of records), caption, or child @row / @col nodes.
  • A +#name list has no special renderer: the document renders it with (each @bibliography as entry), so the renderer sees ordinary nodes.
  • An opaque @node → dispatched on its type param (see Custom nodes).
  • A definition body that collapsed to a literal Record → <table class="wit-record">; a Collection → <ul class="wit-collection">.
  • A non-core NodeUse that survives expansion (only in a tree you built or edited yourself) → <div|span class="wit-node" data-wit-name="…"> with each param as a data-* 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 (' → &#39;, " → &quot;), 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@@
// → <style>.x{color:red} <\/style></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:

source
(h1 Title h1)

Some *bold* and _italic_ prose.

@ul
(li One li)
(li Two li)
ul@
result

Title

Some bold and italic prose.

  • One
  • Two

→ Markdown:

source
# Title

Some **bold** and *italic* prose.

- One
- Two
result

# 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: rawThemeCss for the mechanical reset, or pass your own string.
  • Two targets, one source. Call renderHtml and renderMarkdown on the same ExpandedDocument.

Every edge

  • mode default is fragment — easy to get wrong.
  • css: '' yields an unstyled document; the <style> tag is omitted entirely.
  • Because escapeHtml runs 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 document mode by default from renderHtml.
  • Expecting renderMarkdown to 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.