LFM gets a test suite, and the test suite immediately earns its keep
161 assertions across every plugin and every diagram handler, zero new dependencies — plus a demo page whose numbers are computed by the real pipeline at build time. Writing them found a bug that had been shipping for months: a callout nested inside a callout was never transformed.
LFM gets a test suite, and the test suite immediately earns its keep
Why Care?
A markdown pipeline is a bad thing to be unsure about. It runs at build time across every page of every site, its failures are silent by design — a plugin that doesn’t fire leaves your markdown looking like markdown — and by the time a reader notices, the wrong thing has been published for a week.
Until today LFM had no tests. Every release was verified by parsing a few strings in a throwaway script and reading the output. That worked because the package was small. It stopped scaling somewhere around the seventh plugin.
There are now 161 assertions across all nine plugins and all seven diagram handlers, plus a demo page whose every number is computed by the real pipeline at build time. Writing them took an afternoon and found a bug that had been shipping for months.
What’s New?
pnpm test— 161 assertions, twelve files, zero new dependencies.node:testandnode:assertare in the runtime already.- Tests run against
dist/, notsrc/— so every run exercises the barrel exports, the tsup entry map, and the built output, not just the TypeScript. - A demo page at
/demoon the splash, where each feature shows authored markdown beside the payloadparseMarkdownactually returned. - A fixed bug: callouts nested inside callouts now work.
- A documented limitation: a typo’d footnote id produces no warning, and now there’s a test explaining exactly why.
pnpm test # builds, then runs everything
pnpm test:only # skip the build when dist/ is current
Every diagram language, proved
The seven fence handlers got their own file, and it’s organized around the thing that makes them interesting: “support a diagram language” means four different jobs, depending on who does the drawing.
| Handler | Claims | What it does |
|---|---|---|
mermaid | mermaid | claims the language; mermaid.js draws |
graphviz | graphviz, dot | claims the language; @viz-js/viz draws |
jsoncanvas | jsoncanvas, json-canvas, canvas | parses nodes + edges |
vega-lite | vega-lite, vegalite, vl | parses spec + mark/channel summary |
yang | yang | parses to an RFC 8340 tree — nobody draws it |
json-schema | json-schema, jsonschema | parses to a tree, $refs expanded |
plantuml | plantuml, puml, uml | deflates + encodes to a server URL |
The two that parse nothing are tested for not parsing — recognition is the
correct behavior when the value of a diagram is its layout, and a test that
demanded parsed would be demanding a lie. Mermaid also gets a test that its
source survives byte-for-byte, because the older site implementation rewrote
wikilinks out of diagram bodies and minted element ids with Math.random().
The parsers are tested against their actual grammar rather than smoke-tested.
YANG’s marker rules all hold: mandatory suppresses the ?, a leaf-list takes
*, a presence container takes !, config false propagates ro to
descendants, and a list prints its keys.
module: acme-system
+--rw system
+--rw hostname string
+--rw domain-search* string
+--rw ntp!
| +--rw enabled? boolean
+--rw interface* [name]
+--rw name? string
+--ro speed? uint32
An unterminated YANG block is asserted to fail, not to parse — it used to succeed into a module with no children, which is a confident, empty, wrong diagram.
One inconsistency surfaced and is recorded rather than fixed.
jsonCanvas.parse('[]') returns an empty canvas instead of throwing: its guard
is typeof doc !== 'object' || doc === null, and typeof [] === 'object'. Its
sibling in the same file guards properly with Array.isArray(spec). A
top-level array is not a valid JSON Canvas document, and this is exactly the
“confident, empty, wrong” failure the YANG handler was fixed to stop doing. The
fix is one clause; it wants its own release rather than riding along with a
test pass, so the test asserts today’s behavior and explains why.
Seven examples on the demo page
/demo now carries one worked example per language, each run through the real
registry at build time — a Mermaid flowchart, a Graphviz digraph, a JSON Canvas
document, a Vega-Lite bar chart, a YANG module, a JSON Schema with a $ref,
and a PlantUML sequence diagram.
For yang and json-schema the rendered tree is shown underneath, because for
those two the tree is the output. For plantuml the page links the actual
encoded server URL and reports the detected diagram kind and whether the
@startuml wrapper was authored or added. The registry table above them is
read off the exports, so it cannot drift from the code.
The bug it found
remark-lfm-callouts walks blockquotes, and when it converts one into a callout directive it swaps the node in place:
if (transformed) {
node.children[i] = transformed;
continue; // ← and here it stops
}
That continue skips the recursion at the bottom of the loop. So the plugin descended into every blockquote it didn’t transform, and never into one it did.
The consequence is exactly the kind of thing tests exist for. A callout inside a callout was never visited, so its marker survived as literal text:
> [!info] Outer
> > [!warning] Inner
> > Inner body.
rendered: Outer
[!warning] Inner Inner body. ← the marker, on the page
Nothing errored. Nothing warned. The build passed. The page just quietly showed [!warning] to a reader. One line fixes it — descend into what you just built — and the test that caught it now stands guard over it.
The limitation it documented
The other find isn’t a bug, and the test says so in the test rather than in a wiki nobody reads.
remark-lfm-citations defines an orphan-reference warning for a footnote reference with no matching definition. It never fires, and it can’t: remark-gfm only creates a footnoteReference node when a matching definition exists. Otherwise [^typo] stays ordinary text, so the citations plugin never sees a reference at all — there’s nothing to warn about.
The practical consequence for an author is that a mistyped footnote id renders as visible [^typo] on the published page with no build-time warning. Catching it would mean scanning raw text for the pattern, which this plugin deliberately doesn’t do.
We left the behaviour alone and wrote the reasoning into test/citations.test.mjs, asserting what actually happens. A test that documents a real limitation is worth more than a test that asserts a wish.
What the tests are actually about
Not coverage percentages. Each file pins the thing that would be expensive to get wrong:
| File | What it guards |
|---|---|
naming-contract | Every deprecated alias still resolves; no unshipped intermediate name leaks out |
heading-ids | The slugifier’s quirks — extension stripping, -- collapse, underscore survival. 646 published anchors depend on them |
heading-blocks | Mostly what must not fire: blank lines, wrong order, bare markers, the known math residue |
toc-helpers | Every nestHeadings edge case — opens at h3, h2→h4 jump, trailing h6, depth returning |
preset | Which plugins are on by default, and the two orderings that are load bearing |
callouts · citations · code-fences · wikilinks · image-carousel · link-preview | Each plugin’s contract, including its degradation path |
The naming-contract file is the odd one and the most valuable. It asserts that remarkCallouts === remarkLfmCallouts and so on for all seven renamed exports — because roughly twenty files across four consuming sites import the old names, and dropping one wouldn’t fail a build here. It would fail in someone else’s repo, weeks later.
Two of the first assertions were wrong rather than the code: WikilinkResolution.display is required and used verbatim, and sortSlides ties on authoredIndex. Both tests were corrected. That’s the suite working in the other direction — pinning behaviour the docs had described loosely.
The demo page is also a test
/demo on the splash shows eight features. For each: the authored markdown on the left, and on the right the payload parseMarkdown returned when the page was built.
Nothing on it is hand-written illustration. That’s the point — a demo that computes its own output can’t drift from the package. If a plugin regresses the page shows the regression, and if a plugin throws the splash build fails.
It also settles a framing question the package cares about. LFM returns an AST, not HTML; deciding what a reader sees belongs to the render layer. So the payload is the honest default view, and where a rendering makes the point better it sits underneath — the heading block styled through its real class contract, and a genuine nested table of contents built by:
nestHeadings(filterHeadings(outline, 2, 4).filter(h => !h.inContainer))
which is the clearest way to show what inContainer bought.
And it caught something on its first build. The splash imports plugin files by path, and the 0.5.0 rename broke two of them — but the package’s tsc doesn’t cover splash/, so nothing failed until Astro tried to build. Without the demo page, 0.5.0 would have shipped a splash that didn’t compile.
What’s Next?
- CI. The suite exists but nothing runs it on push yet. That’s the obvious next step and it’s small.
lfm-og-fetcherhas no tests, deliberately for now — it’s the one plugin that makes network calls, and testing it properly means a fixture backend rather than a live fetch.- A
changelog/releases/0.5.0.mdand the actual publish, once this settles.
References
- [[2026-08-17_02]] — the 0.5.0 release these tests were written against
context-v/blueprints/Naming-Plugins-Against-the-Remark-Ecosystem.md— the ruletest/naming-contract.test.mjsenforces