WEP: Marl — Markdown Renderer and Formatter
Historical note: this WEP absorbs the former "Marl Format" WEP (
wep-2026-07-10-marl-format.md), which added the Markdown-to-Markdown formatter; the two documents are merged here.
Context
The Wado project needs a blog at wado-lang.github.io. Rather than reach for
an existing generator, we build one in Wado and compile it with Wado's own
toolchain — a dogfooding vehicle for the language, the package system, and the
WASI filesystem bindings. The blog generator itself is a thin static
transform; the substantive missing piece in the ecosystem is a
Markdown-to-HTML processor, plus an HTML escaping helper. That processor is
Marl, and it gets its own package.
The design goal is stated up front: start small, grow big. The minimal processor must be genuinely small yet leave clean seams for later growth (wider Markdown coverage, more backends).
The formatter grew out of the same dogfooding impulse. wado-dev-tools format-md (invoked by mise run format) formats the repo's Markdown by
embedding the dprint-plugin-markdown Rust crate; Marl adds a
Markdown-to-Markdown formatter (format) written in Wado, shipped together
with the renderer as a wasi:cli/command program — alongside Kiln, another
instance of Wado dogfooding its own toolchain.
The formatter's bar is "sufficiently reasonable Markdown output," not byte-for-byte parity with dprint. dprint stays the design reference: it is mature, and this repo's Markdown is already in its canonical form, which makes it a convenient corpus for round-trip / idempotency testing.
Decision
Ship Marl, a Markdown toolkit — a Markdown-to-HTML renderer (render) and
a Markdown-to-Markdown formatter (format) — as a package-* workspace
member (not stdlib), mirroring package-gale.
Name
The ecosystem's tool names are short, evocative natural nouns — Gale (Grammar Adaptive LL Engine), Tide (the WebIDL binding generator), Kiln (Keyed IDL Lowering Notation). Read as elements, they cover air, water, and fire.
Marl — Markdown Adaptive Rendering Layer. Marl is a calcareous earth (a mudstone), completing the elemental set with earth, and its spelling echoes "Markdown".
package-marl/, module idwado:marl@0.1
One parser, two backends
render and format share a single CommonMark + GFM parser
(fmt_parse_block.wado / fmt_parse_inline.wado) producing one AST
(fmt_ast.wado). The AST retains enough of the source — marker characters,
delimiter choices, table alignment — for the formatter to normalize rather
than merely echo it. Two backends walk the same tree:
fmt_html.wado— AST to HTML (render).fmt_print.wado— AST to canonical Markdown (format).
lib.wado re-exports render, RenderResult / HeadingInfo, format, and
the escape_text / escape_attr helpers for HTML-templating consumers.
Public API: WIT-representable, no references
The public surface takes and returns values only — no &T — so it maps
directly onto a WIT interface (string, list, records). render and
format are export: a --lib build lowers them into a CM interface marl
(with RenderResult / HeadingInfo riding along render's signature), so any
Component Model consumer can pull Marl from a registry. escape_text /
escape_attr stay pub (Wado-native, no CM boundary). Reference-taking
workhorses stay internal behind the value-based facade, so the hot path
avoids copies.
Rendering: the HTML backend
The renderer has no I/O — a single pure entry point over strings, which keeps
it trivially testable and reusable by any consumer. It returns the HTML plus
the heading outline collected during rendering, so a consumer (e.g. a CMS) can
opt into a table of contents by walking headings, or ignore it for plain
HTML.
pub struct RenderResult {
pub html: String,
pub headings: List<HeadingInfo>,
}
pub struct HeadingInfo {
pub level: u8, // 1..=6
pub id: String, // GFM slug, unique in the document; also the `id` attr
pub text: String, // plain-text content: slug source and TOC label
}
export fn render(source: String) -> RenderResult { ... }
Heading ids (GFM slugs)
Every heading is emitted as <hN id="slug">…</hN> and recorded in
RenderResult.headings in document order (headings nested in blockquotes and
list items included). The slug follows github-slugger:
- lowercase ASCII letters; keep ASCII alphanumerics,
-, and_; - turn each space into
-; drop other ASCII punctuation; - keep non-ASCII characters verbatim as UTF-8, so
## 日本語yieldsid="日本語"; - deduplicate within the document by appending
-1,-2, … to a repeated base, skipping any suffix that collides with an explicit slug.
Fidelity is exact for ASCII and for caseless scripts (CJK). The one accepted
divergence from GitHub: non-ASCII letters are not case-folded (an uppercase
Ü stays Ü), since the guest has ASCII-only case tables and full Unicode
case folding is out of scope for this iteration.
Supported blocks (v0.1):
| Block | Notation |
|---|---|
| ATX heading | # … ###### |
| Paragraph | |
| Fenced code | ``` / ~~~, with language label |
| List | - / 1., nestable |
| Blockquote | > |
| Thematic break | --- / *** |
| Table (GFM) | \| a \| b \| |
Supported inline (v0.1): emphasis (*em*) and strong (**strong**), inline
code (`code`), links ([text](url), with optional title), images
(), strikethrough (~~del~~), autolinks (bare URLs), and
backslash hard line breaks.
Fence rules follow CommonMark: an opening fence is three or more of the same
character (` or ~); the closing fence uses the same character and is at
least as long as the opening one. This lets a code block that itself contains a
triple-backtick fence be wrapped in a longer fence — useful for a compiler
blog that shows Markdown and shell snippets.
HTML output is safe by construction: render escapes <, >, &, and "
in text; renders raw HTML — block and inline — as escaped literal text, never
passthrough; and scheme-filters link / image / autolink destinations
(sanitize_url: only http, https, mailto, tel, and scheme-less
(relative) URLs survive; javascript:, data:, and any other scheme are
dropped to an empty destination, so untrusted Markdown cannot inject an
executable link). Reference-style links resolve against the document's
definition table; link reference definitions and front matter produce no
output. This keeps the renderer simple and the output safe by construction.
Marl scope: CommonMark non-goals and deferrals
Reference links, raw HTML, and indented code all parse; raw HTML still renders as escaped literal text, never passthrough. A few CommonMark features stay out of scope and render as ordinary text:
- Setext headings (
===/---underline). - Trailing-space hard breaks — a backslash before the newline is the only hard break; invisible-whitespace breaks are a footgun.
- HTML entity and numeric character references —
&is always escaped.
Simplifications: loose/tight lists follow a simplified model; tabs count as a
single indentation column (no tab-stop expansion, though tabs in content are
preserved); blockquote lazy continuation, URL normalization, GFM footnotes,
www./email autolinks, CJK-aware emphasis flanking, and nested-paren or
multi-line link titles are not implemented.
Accepted ambiguity: a --- line is a thematic break, or a table delimiter row
when it follows a pipe header. Both constructs matter, and setext (which would
also claim ---) is excluded, so no further disambiguation is needed.
A standalone CLI
package-marl/wado.toml declares [world]."wasi:cli/command" = "src/main.wado"
alongside its lib, so the package stays importable as a library and is also
directly runnable. The CLI mirrors format_md.rs's surface (positional paths,
--check) with WASI file I/O: walk the preopened tree for
*.md (skipping vendor / target / .git / node_modules / .vscode-test),
then read / format / compare / write. No Rust, no wado-dev-tools dependency,
no mise or CI wiring.
Unicode width
Table-column alignment is the one place display width determines the exact
bytes emitted. unicode_width.wado ships the full East Asian Width
Wide+Fullwidth table (Unicode 15.0) plus a small zero-width set (combining
marks, variation selectors, ZWJ/ZWNJ); no grapheme clustering. The brief
allowed an approximation, but the exact table is small enough to ship whole.
Testing
- Idempotency over the real corpus (
format(format(x)) == format(x)) — the strongest cheap invariant, mirroringwado format's existing invariant tests. - Per-module unit tests, one
fmt_*_test.wadoper module (fmt_html_test.wadocovers the renderer). - A non-blocking reference diff against dprint (dev-time, not CI) to spot-check reasonableness against the already-canonical corpus.
Non-goals (this iteration)
- Byte-for-byte dprint parity.
- Footnotes and
$…$math (zero genuine corpus occurrences); a footnote-shaped line is only protected from being misparsed as a link reference definition. - Recursive code-block / front-matter reformatting (dprint's callback no-ops too).
- Configurability (
wado.tomlformatting options).
Consequences
Positive
- Marl is a self-contained package whose rendering and formatting cores are I/O-free, independently useful and testable, built and tested by Wado's own compiler — real dogfooding.
- One parser and AST serve both
renderandformat; there is no duplicated Markdown scanning. renderhandles the full grammar — reference links, raw HTML, indented code — where a subset renderer would treat them as literal text. Output stays safe by construction (escaping plus scheme filtering).- The already-canonical corpus makes the formatter's reference diff against dprint free and informative, and the width table is independently reusable.
Marlextends the ecosystem's naming scheme and completes its elemental set.- Every deferred feature (wider Markdown coverage, more backends) has a named, non-disruptive growth seam.
Negative
- A hand-written CommonMark + GFM parser is real work, and coverage will diverge from full CommonMark in edge cases until it grows.
- Running the CLI from source pays the usual fixed
wado runstartup each invocation — fine for an occasional standalone tool, a real cost only if it is ever wired into a hot path.
Trade-offs
A hand-written parser over Gale: Marl does not use the Gale parser generator,
because Markdown is not context-free. The CommonMark fence rule (a closing
fence at least as long as the opening one) is context-sensitive and cannot be
expressed in a context-free grammar; block structure is defined as a line-based
stack machine with lazy continuation, and emphasis resolution uses a delimiter
stack with backtracking. These defeat an ANTLR-style LL grammar, which is why
every serious Markdown implementation is hand-written. Gale remains the right
tool for context-free DSLs, not for Markdown.
Progress
Phase 0: Scaffolding
- [x]
package-marl/withwado.toml, added to workspace members
Phase 1: Marl v0.1 (renderer)
- [x] HTML escaping helper
- [x] Block parsing: headings, paragraphs, fenced code, lists (nested), blockquotes, thematic breaks, tables
- [x] Inline parsing: emphasis, strong, code, links, images, strikethrough, autolinks, hard breaks, backslash escapes
- [x] CommonMark fence-length rules
- [x]
pub fn render(source: String) -> RenderResult— HTML plus the heading outline; every heading carries a GFM slugid - [x] Value-only public API (no references), so the surface maps onto WIT
Within the implemented subset, rendering follows CommonMark: emphasis uses
the delimiter-stack procedure (flanking, rule of three, nested ***), ATX
headings strip closing sequences, lists split on marker change and emit
start, and links carry titles. Remaining divergences are all in features
left out of the subset (setext headings, blockquote lazy continuation, raw
HTML and entity passthrough) and are tracked as growth lines. List tightness
still follows a simplified model.
Building the blog site at -O2 surfaced an optimizer miscompile (a value-copy
of a &mut referent dropped when the referent is reassigned), filed as
wado-lang/wado#1522. It is now handled at copy-insertion time
(lower::plan::value_copy): a value sourced through a reference deref is not
owned, so the fold inserts the copy and no pass removes it. The minimal
reproduction lives as the e2e regression fixture
opt_move_out_string_reset_1522.wado, and Marl's flush_text uses the natural
move-out again with no workaround.
Phase 2: Marl Format (formatter)
- [x] AST, block/inline parsers, links (
fmt_ast/fmt_parse_block/fmt_parse_inline/fmt_links) - [x] HTML renderer (
fmt_html) and canonical printer (fmt_print/unicode_width/format), re-exported fromlib.wado - [x] Standalone CLI (
main.wado+ the[world]entry) - [x] Idempotent across all tracked
.mdfiles in this repo; manual reference-diff against dprint reviewed as reasonable
Known defects (deferred)
A recall-oriented review surfaced these CommonMark/GFM conformance defects. None is triggered by this repo's corpus (the acceptance checks stay green), so they are deferred — fix them if Marl is ever pointed at arbitrary Markdown.
- [ ] Paragraph-interruption rules are not enforced: a
[label]: url-shaped line, a 4+-space-indented list marker or thematic break, or an ordered marker not starting at1each wrongly interrupts an open paragraph. - [ ] Reference definitions nested in a blockquote or list item are never registered, so references to them do not resolve and stay literal text.
- [ ] A failed inline-link tail (
[text](…) drops the link instead of falling back to reference/shortcut resolution. - [ ]
 is printed as![alt][alt]— there is no shortcut-image form. - [ ]
write_filetruncates the target before the new content is confirmed written, risking a truncated file on a mid-write I/O failure.
Growth lines (post-MVP)
- [ ] Deferred Markdown features (see "Marl scope"): GFM footnotes and task lists, GFM autolink compatibility, CJK-aware emphasis, raw HTML, URL normalization
- [ ] Promote Marl's package-local
StrCursorto a stdlib type once other parsers (core:json,core:url, Gale) validate its shape
