← Changelog

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:

handlerfence languageswhat it doeswho draws
yangyangRFC 7950 → RFC 8340 treenobody — it’s text
jsonSchemajson-schemaschema → tree, $refs expandednobody — it’s text
plantumlplantuml, puml, umldeflate + encode → server URLa PlantUML server, via <img>
vegaLitevega-lite, vlparse spec + summaryvega-embed, client-side
mermaidmermaidclaim the languagemermaid.js, client-side
graphvizgraphviz, dotclaim the language@viz-js/viz (WASM)
jsonCanvasjsoncanvas, canvasparse + normalize nodes/edgesthe 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:

  1. The code node is annotated, never replaced. That prototype swaps in an html node holding a rendered <div> and a <script>, so a renderer that doesn’t know the format gets foreign HTML instead of readable source.
  2. 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 yang fence in an authored markdown file still renders as a plain code block. The registry produces data.fence; no renderer consumes it yet. The gallery at /formats builds 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; OGDispatcher already has the cache/retry/concurrency machinery such a handler would want.