Code fences get a format registry — YANG and JSON Schema render as trees, PlantUML needs no renderer at all
Seven fence handlers — YANG and JSON Schema render as trees, PlantUML encodes to a URL with no renderer at all, Vega-Lite parses for a client chart, Graphviz and Mermaid just claim their language. None add a dependency. The mechanism underneath ships knowing nothing: remarkCodeFences has an empty registry and you name the formats you want.
Why Care?
Mermaid fences already render on lossless-monorepo/site, and JSON Canvas does too — but each was wired by hand, in that repo, with a routing table (getLanguageRoutingStrategy) that no other site can reach. Every new diagram 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.
What’s New?
remarkCodeFences knows nothing about any format. You register handlers, and you pay for exactly the ones you name:
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
}
The plugin stamps code.data.fence = { format, parsed?, error? }. The convenience path exists too — parseMarkdown(md, { codeFences: { formats: [...] } }) — but it’s opt-in and inert without registered formats.
Seven handlers ship, and none add a dependency. They do deliberately different jobs, because “support a diagram language” means three different things depending on who does the drawing:
| handler | what it does | who draws |
|---|---|---|
yang | RFC 7950 → RFC 8340 tree | nobody — it’s text |
jsonSchema | schema → tree, $refs expanded | nobody — it’s text |
plantuml | deflate + encode → server URL | a PlantUML server, via <img> |
vegaLite | parse spec + summary | vega-embed, client-side |
mermaid | claim the language | mermaid.js, client-side |
graphviz | claim the language | @viz-js/viz (WASM), client-side |
jsonCanvas | parse + normalize nodes/edges | the site’s canvas renderer |
jsonSchema is YANG’s closest sibling — a schema is JSON, so parsing is free and the work is walking it. Local $refs (#/$defs/…) expand inline with cycle detection, required vs optional becomes ?, arrays take *, enums render their values:
schema: Participant
+-- handle string
+-- kauffman_class? integer | null
+-- status? enum {"active", "alumni", "prospective"}
+-- current_stack?* array<$ref StackItem>
+-- tool string
+-- notes? string
plantuml is the one worth dwelling on, because it 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 about 60 lines and the page is a plain <img> with no client JavaScript at all.
Two calls there. It auto-wraps bare source in @startuml/@enduml, because authors skip the wrapper when the fence already says plantuml. And it is not re-exported from formats/index.ts — it imports a node builtin, and dragging that into the barrel would make the whole file unusable in a browser. Subpath import only.
The default points at the public plantuml.com instance, which means diagram source travels to a third party in the URL. Fine for public docs, wrong otherwise; createPlantUml({ server }) points it at a self-hosted one.
vegaLite parses to { spec, mark, channels, data, title }. The summary isn’t decoration — a chart that renders as nothing without JS should still say what it meant to draw, so the fallback can describe it.
graphviz deliberately does nothing but claim graphviz/dot. DOT’s whole value is the layout, which is exactly the part that can’t happen at parse time.
YANG, rendered
The point of a yang fence is the tree, which is how YANG is actually read:
module: lossless-fleet
+--rw fleet
| +--rw name string
| +--rw tag* string
| +--rw registry!
| +--rw site* [slug]
| +--rw created-at string
| +--rw hosting?
| | +--rw vercel
| +--ro build-state
| +--ro last-status? enumeration
+--x rebuild-site
| +--w input
+--n build-finished
It handles the grammar properly: mandatory true suppresses the ?, leaf-list takes *, presence takes !, list keys render as [slug], config false propagates ro to every descendant, uses expands the grouping inline, and rpc/notification get x/n with w/ro sections. Comments and "a" + "b" string concatenation parse.
No dependencies. The obvious move is a YANG toolchain, but RFC 7950’s grammar is unusually regular —
statement = keyword [argument] ( ";" | "{" *statement "}" )
— so a tokenizer plus recursive descent covers it in about 150 lines. That’s cheaper than putting a parser on the install graph of every splash page that will never write a yang fence. Same reasoning as the hand-rolled walkers in og-fetcher.ts.
Malformed input fails honestly. An unterminated block used to parse “successfully” into a module with no children — a confident, empty, wrong diagram. Now it throws Unterminated 'module broken' block opened on line 1, recorded in fence.error so the renderer falls back to showing source instead of failing the build.
Two things deliberately unlike the existing prototype
remark-jsoncanvas-codeblocks.ts on the lossless site informed both:
- The
codenode is annotated, never replaced. That prototype swaps in anhtmlnode containing a rendered<div>and a<script>, so a renderer that doesn’t know the format gets foreign HTML instead of readable source. Annotation degrades to a normal code block, which is the correct floor. - Nothing is nondeterministic. That prototype mints element ids with
Math.random(), so identical markdown produces a different AST every build — poison for caching, content hashing and diffing. Ids are the renderer’s job, derived at render time.
Leanness is now real, not theoretical
sideEffects: false and subpath exports (/formats, /formats/yang) were missing, which meant bundlers had to assume every import pulled the whole package. Config flags control behavior; only .use() and import granularity control weight. Both are wired now.
Also included: the splash was rendering no article bodies
Found while verifying that a yang fence in an authored file actually renders. It doesn’t — but the reason turned out to be much larger than a missing renderer branch.
splash/src/content.config.ts’s localLoader called store.set({ id, data, body }) and never set rendered. Astro’s render(entry) needs rendered to produce <Content />. So every changelog and context-v page shipped a title, a lede, metadata, and no body — confirmed on the live GitHub Pages site, not just locally. The 0.3.0 release notes have been body-less since publication.
Fixed by pulling renderMarkdown off the loader context (available in Astro 6) and setting rendered.
What’s Next?
- The splash doesn’t use the markdown package it exists to showcase. Bodies now render through Astro’s built-in pipeline — fences come out as
<pre class="astro-code github-dark" data-language="ts">, with a hardcoded theme that ignores the splash’s own tokens. Routing the splash’s markdown through LFM is what makes every LFM feature reach authored content. - A renderer branch dispatching on
data.fence.format— ayangfence in an authored file still renders as a plain code block. Verified: zero+--rwin the output. The demo at/formats/yanghand-builds its own processor, so it proves the parser, not the authoring path. - The demo page isn’t linked from anywhere and has no
feature-highlightsentry.