0.4.1 — a code-fence registry that ships empty, and seven handlers that cost nothing
Diagram fences normally mean a routing table inside somebody's renderer, edited once per format and reimplemented once per site. This inverts it: remarkCodeFences knows no formats, you register what you want, and seven handlers ship without adding a single dependency.
Why Care?
Mermaid and JSON Canvas fences already rendered on lossless-monorepo/site — each wired by hand, in that repo, behind a routing table (getLanguageRoutingStrategy) no other site could reach. Every new format meant editing a renderer; every new site meant reimplementing the table.
The fix isn’t a bigger table. It’s a registry that ships empty.
remarkCodeFences
The plugin knows no formats. You register handlers and pay for exactly those:
import { remarkCodeFences } from '@lossless-group/lfm';
import { yang } from '@lossless-group/lfm/formats/yang';
unified().use(remarkParse).use(remarkCodeFences, { formats: [yang] });
A handler is plain data plus an optional pure function, so anyone can author and publish one without coordinating with this package:
interface FenceFormat<T> {
name: string;
match: string[]; // fence languages it claims
parse?: (raw: string) => T; // omit to merely claim the language
}
It stamps code.data.fence = { format, parsed?, error? }. There’s a convenience path — parseMarkdown(md, { codeFences: { formats: [...] } }) — but it’s opt-in and inert with no formats registered.
Seven handlers, zero dependencies
“Support a diagram language” means three different things depending on who does the drawing, and the handlers reflect that rather than pretending it’s uniform:
| handler | fence languages | what it does | who draws |
|---|---|---|---|
yang | yang | RFC 7950 → RFC 8340 tree | nobody — it’s text |
jsonSchema | json-schema | schema → tree, $refs expanded | nobody — it’s text |
plantuml | plantuml, puml, uml | deflate + encode → server URL | a PlantUML server, via <img> |
vegaLite | vega-lite, vl | parse spec + summary | vega-embed, client-side |
mermaid | mermaid | claim the language | mermaid.js, client-side |
graphviz | graphviz, dot | claim the language | @viz-js/viz (WASM) |
jsonCanvas | jsoncanvas, canvas | parse + normalize nodes/edges | the site’s canvas renderer |
YANG and JSON Schema render as text
module: lossless-fleet schema: Participant
+--rw fleet +-- handle string
| +--rw site* [slug] +-- kauffman_class? integer | null
| +--ro build-state +-- status? enum {"active", …}
+--x rebuild-site +-- current_stack?* array<$ref StackItem>
YANG handles the real grammar: mandatory true suppresses ?, leaf-list takes *, presence takes !, list keys render as [slug], config false propagates ro to every descendant, uses expands groupings inline, rpc/notification get x/n. JSON Schema expands local $refs with cycle detection.
Neither needs a dependency. RFC 7950’s grammar is unusually regular —
statement = keyword [argument] ( ";" | "{" *statement "}" )
— so a tokenizer plus recursive descent covers it in ~150 lines, cheaper than putting a YANG toolchain on the install graph of every splash page that will never write a yang fence.
PlantUML needs no renderer at all
The one that turned out nearly free. PlantUML covers the full UML surface Mermaid doesn’t — class, activity, component, deployment, use-case — and rendering it normally means running Java. It doesn’t have to: a PlantUML server accepts the source deflated and encoded into the URL path. node:zlib is a runtime builtin, so the handler is ~60 lines and the page is a plain <img> with no client JavaScript.
Two calls: it auto-wraps bare source in @startuml/@enduml, since authors skip the wrapper when the fence already says plantuml; and it is not re-exported from formats/index.ts, because importing a node builtin into that barrel would make it unusable in a browser. Subpath import only.
The default sends diagram source to the public plantuml.com instance as part of the URL. Fine for public docs, wrong otherwise — createPlantUml({ server }) points it at a self-hosted one.
Failing honestly
Malformed input records fence.error and the renderer falls back to source rather than failing a build. YANG previously parsed an unterminated block “successfully” into a module with no children — a confident, empty, wrong diagram. It now reports Unterminated 'module broken' block opened on line 1.
Two departures from the existing prototype
remark-jsoncanvas-codeblocks.ts on the lossless site informed both:
- The
codenode is annotated, never replaced. That prototype swaps in anhtmlnode holding a rendered<div>and a<script>, so a renderer that doesn’t know the format gets foreign HTML instead of readable source. - Nothing is nondeterministic. It mints ids with
Math.random(), so identical markdown yields a different AST every build — poison for caching and diffing. Ids are the renderer’s job.
Leanness is real now, not theoretical
sideEffects: false and subpath exports (/formats, /formats/yang, /formats/json-schema, /formats/plantuml) were missing, so bundlers had to assume any import pulled the whole package. Config flags control behavior; only import granularity controls weight.
Upgrading
Nothing breaks. remarkCodeFences is inert until you register a format, and no fence behavior changes for anyone who doesn’t.
pnpm add jsr:@lossless-group/lfm@^0.4.1
Known gaps
- A
yangfence in an authored markdown file still renders as a plain code block. The registry producesdata.fence; no renderer consumes it yet. The gallery at/formatsbuilds its own processor, so it proves the parsers, not the authoring path. - The splash renders markdown through Astro’s built-in pipeline, not LFM — so the package’s own showcase doesn’t yet dogfood the package.
- Kroki (~25 formats behind one endpoint) remains unevaluated;
OGDispatcheralready has the cache/retry/concurrency machinery such a handler would want.