@kevinpeckham/barkdown
v0.1.1
Published
Markdown ⇄ DOM round-trip codec, guaranteed to invert marked. Canonical identity, fixed-point convergence, footnote fidelity, and no silent loss — the serializer half of an editable-markdown pipeline.
Downloads
763
Maintainers
Readme
barkdown
A Markdown ⇄ DOM round-trip codec, guaranteed to invert marked. Parse markdown to HTML with marked; edit it as a live DOM (contenteditable, WYSIWYG, programmatic transforms); serialize the DOM back to markdown that is provably the same document.
barkdown is the sibling of @kevinpeckham/barkup: barkup guards the tree's identity; barkdown guards the prose's.
import { toDom, toMarkdown, roundTrip } from "@kevinpeckham/barkdown";
const html = toDom("# Hello\n\nSome **bold** prose.\n"); // marked + footnotes
element.innerHTML = html; // …user edits the DOM…
const markdown = toMarkdown(element); // back to markdownThe serializer came out of a production blog CMS — a contenteditable WYSIWYG whose every keystroke round-trips through this exact pair of functions — and the round-trip guarantee is enforced by a corpus suite plus fast-check property tests (~2,000 random documents per run).
The four guarantees
- Canonical identity. For markdown in barkdown's canonical dialect
(ATX headings,
**bold**/*italic*,-bullets, fenced code, GFM tables),toMarkdown(toDom(md)) === md— byte for byte. - Fixed-point convergence. For ANY input markdown, one round trip reaches the canonical form and a second round trip is byte-identical to the first — serialize∘parse is idempotent.
- Footnote identity. marked-footnote's output shape (
footnote-N/footnote-ref-N, thedata-footnotessection, back-reference stripping) round-trips exactly, including the legacydata-footnote-refshape and non-numeric labels ([^note]). - No silent loss. Unknown elements pass through as raw HTML (stable across cycles via markdown's HTML passthrough); every DOM text node reaches the output.
Guarantee 2 is the hard one, and it is exactly why this package exists: most DOM-to-markdown serializers produce output that marked reads back differently — emphasis that stops pairing, escapes that double every cycle, footnotes that vanish a trip late. barkdown's serializer models marked's actual parsing behavior (including its documented-by-test deviations from CommonMark) and falls back to raw HTML — which is stable — whenever a construct cannot be expressed reliably in markdown.
Install
npm install @kevinpeckham/barkdown marked marked-footnotemarked and marked-footnote are peer dependencies — you own the
version, barkdown guarantees against it (see the version policy below).
API
toMarkdown(input: HTMLElement | string, options?: { adapter?: DomAdapter }): stringSerialize a DOM subtree (or an HTML string) to GFM markdown. Element
input is walked directly through a structural interface — browser
elements, happy-dom, and linkedom all satisfy it. String input needs a
document to parse with: the browser's global one by default, or an
adapter on the server.
toDom(markdown: string): stringMarkdown → HTML string via marked (gfm: true) + marked-footnote.
barkdown configures its own Marked instance and never mutates the
marked singleton. DOM construction is your side (innerHTML, or any
DOM library).
roundTrip(markdown: string, adapter?: DomAdapter): stringtoMarkdown(toDom(markdown)) — one application canonicalizes; a second
is a fixed point.
Server-side usage (Node, Bun)
Runtimes without a global document pass one explicitly:
// happy-dom
import { Window } from "happy-dom";
import { documentAdapter, toMarkdown } from "@kevinpeckham/barkdown";
const adapter = documentAdapter(new Window().document);
// linkedom
import { parseHTML } from "linkedom";
const adapter = documentAdapter(parseHTML("<html><body></body></html>").document);
toMarkdown("<p>Some <strong>html</strong></p>", { adapter });Both are covered by a conformance suite, including the CSS-styled-span
emphasis path (style="font-weight: 700" → **bold**).
The canonical dialect
What roundTrip normalizes any markdown into:
- Headings: ATX (
## Title), single-line; setext converts. Emphasis inside headings flattens away (editor-safety inherited from the serializer's WYSIWYG origins). - Emphasis:
**bold**,*italic*,~~strikethrough~~. Nesting the markers can't express reliably (empty content, delimiter runs adjacent to punctuation, marked's pairing quirks) serializes as raw inline tags (<em>…</em>) around markdown-escaped content — which reparses to the identical DOM. - Lists:
-bullets, tight; loose lists flatten (paragraphs inside an item become continuation lines). Ordered lists use1.and preserve theirstartnumber. Task lists keep[x]/[ ]. Adjacent same-marker lists are held apart by a round-tripping<!-- -->comment (a blank line alone would merge them). - Code: backtick fences with the language info string; inline code picks a backtick run longer than any in the content.
- Tables: GFM pipe tables, alignment as
:---/:---:/---:, pipes escaped in cells. - Breaks: two-space hard breaks;
---thematic breaks. - Links:
[text](dest "title"); destinations are pre-normalized with marked's owncleanUrl(encodeURI) transform so they're byte-stable. Self-links (text === href, incl.mailto:) emit as bare GFM autolinks when the surrounding context re-linkifies safely and the URL is already in cleanUrl's canonical percent-encoded form. URLs carrying raw unicode or DOM-decoded entities (…/café,…#©) canonicalize to the full form — readable text, encoded destination — because re-linkifying them bare would percent-encode the href out from under the text. - Images:
. - Footnotes:
[^label]+[^label]: text, multi-paragraph definitions via 4-space continuation. Definitions never referenced outside the footnote section are dropped (marked-footnote renders them to nothing, so they'd silently vanish a trip late otherwise). - Escaping:
\`*_[]~<always;&when it reads as a character entity;#>|-+N.only at line starts. Bare URLs/emails in plain text are defused (https\://…,user\@host) because marked linkifies them at any position — real links serialize as links instead. - Passthrough:
<div>/plain<span>wrappers are transparent (contenteditable artifacts). HTML comments round-trip. Elements on CommonMark's HTML-block tag list pass through as rawouterHTML; other unknown elements are rebuilt attribute-for-attribute around their markdown-escaped children.
Whitespace inside paragraphs is canonicalized (continuation-line indent and trailing whitespace stripped, hard breaks normalized to exactly two spaces) — markdown cannot represent the alternatives, so keeping them would defeat idempotence.
Sanitization is out of scope
toDom returns whatever marked produces; toMarkdown preserves unknown
elements as raw HTML by design. If any input is untrusted, sanitize
before rendering or storing — e.g. DOMPurify
between toDom() and innerHTML.
Version policy
The identity guarantee is with respect to the tested marked range
(currently marked@^18, marked-footnote@^1.4). CI runs the full
round-trip suite against the pinned versions, and marked upgrades land
only with a green suite. This is barkdown's one ongoing maintenance
commitment; the API surface is otherwise frozen at v1, like barkup's.
Maintenance posture
barkdown is scoped and stable: the v1 surface (toMarkdown /
toDom / roundTrip + the adapter seam) is the whole product, and it
is intentionally small. Bug reports and guarantee violations are always
welcome; feature scope is frozen by design.
License & credit
MIT © Kevin Peckham. Built at Lightning Jar. Pairs with @kevinpeckham/barkup — typed trees as HTML, with round-trip guarantees of its own.
