Plan
The build order for markz v1, from the repo skeleton to a measured 20 KB bundle. Each step is small enough to land on its own, with tests, and each leaves pnpm check, pnpm test and pnpm build green. The design lives in spec.md and the dialect in syntax.md. This file is only sequencing.
Packaging
The output is built with vp pack (tsdown, driven by the pack section of vite.config.ts), with no separate bundler config.
- ESM only:
dist/index.jsplusdist/index.d.ts, andexportsinpackage.jsonpoints at both. - One entry,
src/index.ts. Anything not re-exported from it is private. sideEffects: false, so consumers can tree-shakehtml,walk,textContentandposition.- No runtime dependencies.
micromark,micromark-extension-gfmandmicromark-extension-directivearedevDependencies, as the test oracle, andyamlis the metadata oracle. prepublishOnlyrunsvp pack, so a publish can never ship a staledist/.pnpm size(scripts/size.ts) bundles and minifies the entry and fails above 20 KB gzip. CI runs it on every PR, from step 3 on, so growth shows in the PR that causes it.
Steps
1. Repo and package setup — done
Vite+ library skeleton, CI, prose tooling, placeholder parse().
2. AST representation — done
src/ast.ts: the node types (T, string names over numeric codes), typed arrays for type,
start, end, parent, firstChild and nextSibling, and side tables for node data and
attributes. Builder is how the parser writes the tree: an open-node stack, constant-time append
through a lastChild array only the builder keeps, and arrays that grow by doubling. finish()
hands over a read-only Document with children(node) as a generator, data(node, type) as the
checked accessor, attributes, metadata and diagnostics. test/tree.ts holds the tree
invariants every later step’s documents are checked against.
3. Oracle harness — done
test/oracle.ts, test/examples.ts and test/oracle.test.ts:
- The oracle is micromark with GFM and directives, with every URL scheme allowed (markz has its
own blocklist) and a directive handler that writes
syntax.md’s directive shapes. micromark, its extensions andyamlare dev dependencies now. - Comparison normalizes whitespace outside
<pre>and smart punctuation. - CommonMark 0.31.2 and GFM’s extension examples are vendored in
test/spec/. The exclusion list names asyntax.mdrow or heading for every excluded section and example, and a test checks that each one resolves. - The oracle is checked against each spec’s own HTML on every included example (the two cmark-gfm task-list examples differ only in attribute order).
- Examples that use a construct the dialect cuts are excluded by the oracle’s own tokens
(
cutsintest/examples.ts), each with itssyntax.mdrow. Hand lists cover whole sections and rules with no token, such as lazy lines.
4. Block pass — done
src/block.ts, a line-by-line scan into containers and leaves, in one linear pass. Every block construct in syntax.md is a case in this scan. There are no plug-ins and no extension layer:
- containers: blockquote, list, listItem, and container directives (
:::name…:::) - leaves: paragraph, ATX heading, fenced code (including
```=formatraw blocks), thematic break, table, comment,$$math, and leaf directives (::name) - block-attribute lines, attached to the next block. A heading’s explicit
{#id}is recorded here. - the metadata block at offset 0, parsed line by line into a flat object by
syntax.md’s value rules
Rejected constructs (setext, indented code, ~~~, HTML, reference definitions, lazy lines) are recognised in the same scan. Each becomes paragraph text plus a diagnostic in the place it is met.
html() in src/html.ts writes every block node. Tests assert exact offsets, and until step 5 the oracle checked every example with no inline syntax. pnpm size removes whitespace too, so it measures real minified output (8.5 KB gzip after this step).
5. Inline pass, with heading ids — done
src/inline.ts, run on each leaf as it closes, in one pass. Every inline construct is a case in the same scanner:
- inline code,
${…}expressions and$…$math, which bind tightest - text directives (
:name[label]{…}) - links and images (inline form only) with an optional
{…}directly after, and<…>autolinks. Expressions inside destinations and attribute values are found by the same${scanner. - strong (
**), emphasis (_, and*where formatters write it) and strikethrough (~~), by CommonMark’s flanking with no rule of 3 and no run splitting - backslash escapes, numeric references,
\hard breaks and\non-breaking spaces - smart punctuation on text: quotes by the character before them,
--,---,... - the rejected forms (raw HTML, named references, reference links and definitions,
*a*,__a__,~a~), kept as text and reported
What has been read is a linked list of items, and a closer wraps the items since its opener into one node. Text nodes keep their decoded value and their raw source range. The inline pass returns the leaf’s plain text, and the block pass settles a heading’s id from it as the heading closes, against the ids used so far, so there is no step after the passes. The whole filtered oracle suite runs from here on: every included example matches. Bundle: 12.9 KB gzip.
6. Diagnostics — done
Every row of the “Not supported” table in syntax.md has cases in src/diagnostics.test.ts,
which reads the table itself: the input stays text, each diagnostic covers exactly the rejected
characters, and its instead is the row’s “Write instead” cell. Quiet cases hold the look-alikes
([sic], braces, 10:30, a URL as a link’s text) to no report. Diagnostics are in source order.
This step added the reports that were missing: bare URLs, relative autolinks, JSX, footnotes,
+++ metadata, trailing heading attributes, multi-line attributes and attributes after inline
text.
7. Traversal and position utilities
Public API, kept minimal: parse, html, walk (enter/exit), textContent, position. Lines are 1-based and columns are 0-based. Nothing else is exported until a consumer needs it.
8. Robustness and fuzzing
- A grammar-based generator of documents in the shared grammar, fed to the oracle.
- Malformed input, CRLF and lone
\r, BOM, and astral-plane offsets. - Adversarial unclosed openers, with a timing check that fails on super-linear growth. Code spans and math already record a failed scan; link destinations,
<!--and attribute blocks don’t yet. - Unclosed
${is quadratic today: 80,000 of them in one paragraph take about 100 s, 16 times the time for 4 times the input. One failed scan doesn’t settle later ones (${a ${b}has a valid second expression), so the fix is to reuse the failed scan’s brace depths for every${it passed, rather than a flag. - A multi-MB document that guards against quadratic behaviour.
9. Benchmarks and bundle size
bench/ (not published): parse throughput and AST memory versus micromark, markdown-it, marked, markdown-exit and Comark. The size gate is already in CI; this step adds the comparisons.
Definition of done for v1
import { parse, html } from 'markz'works with no options and no runtime dependencies.html()is identical to micromark + GFM on the filtered spec suites and on fuzzed documents in the shared grammar.- Every rejected construct produces its diagnostic.
dist/is at most 20 KB gzip, and CI enforces it.- The README documents the API and links to
syntax.md.