@kevinpeckham/barkdown 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kevin Peckham
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # barkdown
2
+
3
+ **A Markdown ⇄ DOM round-trip codec, guaranteed to invert marked.** Parse markdown to HTML with [marked](https://github.com/markedjs/marked); edit it as a live DOM (contenteditable, WYSIWYG, programmatic transforms); serialize the DOM back to markdown that is *provably* the same document.
4
+
5
+ barkdown is the sibling of [@kevinpeckham/barkup](https://github.com/kevinpeckham/barkup):
6
+ barkup guards the tree's identity; barkdown guards the prose's.
7
+
8
+ ```ts
9
+ import { toDom, toMarkdown, roundTrip } from "@kevinpeckham/barkdown";
10
+
11
+ const html = toDom("# Hello\n\nSome **bold** prose.\n"); // marked + footnotes
12
+ element.innerHTML = html; // …user edits the DOM…
13
+ const markdown = toMarkdown(element); // back to markdown
14
+ ```
15
+
16
+ The serializer came out of a production blog CMS — a contenteditable
17
+ WYSIWYG whose every keystroke round-trips through this exact pair of
18
+ functions — and the round-trip guarantee is enforced by a corpus suite
19
+ plus fast-check property tests (~2,000 random documents per run).
20
+
21
+ ## The four guarantees
22
+
23
+ 1. **Canonical identity.** For markdown in barkdown's canonical dialect
24
+ (ATX headings, `**bold**`/`*italic*`, `-` bullets, fenced code, GFM
25
+ tables), `toMarkdown(toDom(md)) === md` — byte for byte.
26
+ 2. **Fixed-point convergence.** For ANY input markdown, one round trip
27
+ reaches the canonical form and a second round trip is byte-identical
28
+ to the first — serialize∘parse is idempotent.
29
+ 3. **Footnote identity.** marked-footnote's output shape (`footnote-N` /
30
+ `footnote-ref-N`, the `data-footnotes` section, back-reference
31
+ stripping) round-trips exactly, including the legacy
32
+ `data-footnote-ref` shape and non-numeric labels (`[^note]`).
33
+ 4. **No silent loss.** Unknown elements pass through as raw HTML (stable
34
+ across cycles via markdown's HTML passthrough); every DOM text node
35
+ reaches the output.
36
+
37
+ Guarantee 2 is the hard one, and it is exactly why this package exists:
38
+ most DOM-to-markdown serializers produce output that marked reads back
39
+ *differently* — emphasis that stops pairing, escapes that double every
40
+ cycle, footnotes that vanish a trip late. barkdown's serializer models
41
+ marked's actual parsing behavior (including its documented-by-test
42
+ deviations from CommonMark) and falls back to raw HTML — which is stable
43
+ — whenever a construct cannot be expressed reliably in markdown.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ npm install @kevinpeckham/barkdown marked marked-footnote
49
+ ```
50
+
51
+ `marked` and `marked-footnote` are peer dependencies — you own the
52
+ version, barkdown guarantees against it (see the version policy below).
53
+
54
+ ## API
55
+
56
+ ```ts
57
+ toMarkdown(input: HTMLElement | string, options?: { adapter?: DomAdapter }): string
58
+ ```
59
+ Serialize a DOM subtree (or an HTML string) to GFM markdown. Element
60
+ input is walked directly through a structural interface — browser
61
+ elements, happy-dom, and linkedom all satisfy it. String input needs a
62
+ `document` to parse with: the browser's global one by default, or an
63
+ adapter on the server.
64
+
65
+ ```ts
66
+ toDom(markdown: string): string
67
+ ```
68
+ Markdown → HTML string via marked (`gfm: true`) + marked-footnote.
69
+ barkdown configures its own `Marked` instance and never mutates the
70
+ `marked` singleton. DOM construction is your side (`innerHTML`, or any
71
+ DOM library).
72
+
73
+ ```ts
74
+ roundTrip(markdown: string, adapter?: DomAdapter): string
75
+ ```
76
+ `toMarkdown(toDom(markdown))` — one application canonicalizes; a second
77
+ is a fixed point.
78
+
79
+ ## Server-side usage (Node, Bun)
80
+
81
+ Runtimes without a global `document` pass one explicitly:
82
+
83
+ ```ts
84
+ // happy-dom
85
+ import { Window } from "happy-dom";
86
+ import { documentAdapter, toMarkdown } from "@kevinpeckham/barkdown";
87
+ const adapter = documentAdapter(new Window().document);
88
+
89
+ // linkedom
90
+ import { parseHTML } from "linkedom";
91
+ const adapter = documentAdapter(parseHTML("<html><body></body></html>").document);
92
+
93
+ toMarkdown("<p>Some <strong>html</strong></p>", { adapter });
94
+ ```
95
+
96
+ Both are covered by a conformance suite, including the CSS-styled-span
97
+ emphasis path (`style="font-weight: 700"` → `**bold**`).
98
+
99
+ ## The canonical dialect
100
+
101
+ What `roundTrip` normalizes any markdown into:
102
+
103
+ - **Headings**: ATX (`## Title`), single-line; setext converts. Emphasis
104
+ inside headings flattens away (editor-safety inherited from the
105
+ serializer's WYSIWYG origins).
106
+ - **Emphasis**: `**bold**`, `*italic*`, `~~strikethrough~~`. Nesting the
107
+ markers can't express reliably (empty content, delimiter runs adjacent
108
+ to punctuation, marked's pairing quirks) serializes as raw inline tags
109
+ (`<em>…</em>`) around markdown-escaped content — which reparses to the
110
+ identical DOM.
111
+ - **Lists**: `-` bullets, tight; loose lists flatten (paragraphs inside
112
+ an item become continuation lines). Ordered lists use `1.` and
113
+ preserve their `start` number. Task lists keep `[x]` / `[ ]`.
114
+ Adjacent same-marker lists are held apart by a round-tripping
115
+ `<!-- -->` comment (a blank line alone would merge them).
116
+ - **Code**: backtick fences with the language info string; inline code
117
+ picks a backtick run longer than any in the content.
118
+ - **Tables**: GFM pipe tables, alignment as `:---` / `:---:` / `---:`,
119
+ pipes escaped in cells.
120
+ - **Breaks**: two-space hard breaks; `---` thematic breaks.
121
+ - **Links**: `[text](dest "title")`; destinations are pre-normalized
122
+ with marked's own `cleanUrl` (encodeURI) transform so they're
123
+ byte-stable. Self-links (`text === href`, incl. `mailto:`) emit as
124
+ bare GFM autolinks when the surrounding context re-linkifies safely
125
+ *and* the URL is already in cleanUrl's canonical percent-encoded form.
126
+ URLs carrying raw unicode or DOM-decoded entities (`…/café`,
127
+ `…#&#169;`) canonicalize to the full form — readable text, encoded
128
+ destination — because re-linkifying them bare would percent-encode
129
+ the href out from under the text.
130
+ - **Images**: `![alt](src "title")`.
131
+ - **Footnotes**: `[^label]` + `[^label]: text`, multi-paragraph
132
+ definitions via 4-space continuation. Definitions never referenced
133
+ outside the footnote section are dropped (marked-footnote renders them
134
+ to nothing, so they'd silently vanish a trip late otherwise).
135
+ - **Escaping**: `\` `` ` `` `*` `_` `[` `]` `~` `<` always; `&` when it
136
+ reads as a character entity; `#` `>` `|` `-` `+` `N.` only at line
137
+ starts. Bare URLs/emails in plain text are defused (`https\://…`,
138
+ `user\@host`) because marked linkifies them at any position — real
139
+ links serialize as links instead.
140
+ - **Passthrough**: `<div>`/plain `<span>` wrappers are transparent
141
+ (contenteditable artifacts). HTML comments round-trip. Elements on
142
+ CommonMark's HTML-block tag list pass through as raw `outerHTML`;
143
+ other unknown elements are rebuilt attribute-for-attribute around
144
+ their markdown-escaped children.
145
+
146
+ Whitespace inside paragraphs is canonicalized (continuation-line indent
147
+ and trailing whitespace stripped, hard breaks normalized to exactly two
148
+ spaces) — markdown cannot represent the alternatives, so keeping them
149
+ would defeat idempotence.
150
+
151
+ ## Sanitization is out of scope
152
+
153
+ `toDom` returns whatever marked produces; `toMarkdown` preserves unknown
154
+ elements as raw HTML *by design*. If any input is untrusted, sanitize
155
+ before rendering or storing — e.g. [DOMPurify](https://github.com/cure53/DOMPurify)
156
+ between `toDom()` and `innerHTML`.
157
+
158
+ ## Version policy
159
+
160
+ The identity guarantee is **with respect to the tested marked range**
161
+ (currently `marked@^18`, `marked-footnote@^1.4`). CI runs the full
162
+ round-trip suite against the pinned versions, and marked upgrades land
163
+ only with a green suite. This is barkdown's one ongoing maintenance
164
+ commitment; the API surface is otherwise frozen at v1, like barkup's.
165
+
166
+ ## Maintenance posture
167
+
168
+ barkdown is **scoped and stable**: the v1 surface (`toMarkdown` /
169
+ `toDom` / `roundTrip` + the adapter seam) is the whole product, and it
170
+ is intentionally small. Bug reports and guarantee violations are always
171
+ welcome; feature scope is frozen by design.
172
+
173
+ ## License & credit
174
+
175
+ MIT © Kevin Peckham. Built at [Lightning Jar](https://www.lightningjar.com).
176
+ Pairs with [@kevinpeckham/barkup](https://github.com/kevinpeckham/barkup) —
177
+ typed trees as HTML, with round-trip guarantees of its own.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * DOM adapter seam. barkdown's serializer walks a DOM subtree through a
3
+ * small structural interface (`HtmlElementLike`), so any standards-shaped
4
+ * DOM works: the browser's, happy-dom's, linkedom's.
5
+ *
6
+ * The adapter is only needed for the string-input branch of
7
+ * `toMarkdown()` (and therefore `roundTrip()`): turning an HTML string
8
+ * into a container element requires a `document`. In browsers the global
9
+ * document is used automatically; on servers pass one explicitly:
10
+ *
11
+ * // happy-dom
12
+ * import { Window } from "happy-dom";
13
+ * const adapter = documentAdapter(new Window().document);
14
+ *
15
+ * // linkedom
16
+ * import { parseHTML } from "linkedom";
17
+ * const adapter = documentAdapter(
18
+ * parseHTML("<html><body></body></html>").document,
19
+ * );
20
+ */
21
+ /** Error type for barkdown misuse (e.g. no DOM available). */
22
+ export declare class BarkdownError extends Error {
23
+ name: string;
24
+ }
25
+ /** Structural subset of CSSStyleDeclaration the serializer reads. */
26
+ export interface StyleLike {
27
+ fontWeight?: string;
28
+ fontStyle?: string;
29
+ textDecoration?: string;
30
+ textDecorationLine?: string;
31
+ textAlign?: string;
32
+ }
33
+ /** Structural subset of Node the serializer reads. */
34
+ export interface DomNodeLike {
35
+ readonly nodeType: number;
36
+ readonly textContent: string | null;
37
+ }
38
+ /**
39
+ * Structural subset of HTMLElement the serializer reads. Browser
40
+ * `HTMLElement`, happy-dom and linkedom elements all satisfy it.
41
+ */
42
+ export interface HtmlElementLike extends DomNodeLike {
43
+ readonly tagName: string;
44
+ readonly id: string;
45
+ readonly childNodes: ArrayLike<DomNodeLike>;
46
+ readonly children: ArrayLike<HtmlElementLike>;
47
+ readonly outerHTML: string;
48
+ getAttribute(name: string): string | null;
49
+ /** Optional: used to rebuild unknown elements attribute-for-attribute;
50
+ * without it the serializer falls back to `outerHTML`. */
51
+ getAttributeNames?(): string[];
52
+ hasAttribute(name: string): boolean;
53
+ querySelector(selectors: string): HtmlElementLike | null;
54
+ querySelectorAll(selectors: string): ArrayLike<HtmlElementLike>;
55
+ cloneNode(deep?: boolean): DomNodeLike;
56
+ remove(): void;
57
+ readonly style?: StyleLike;
58
+ }
59
+ /** Structural subset of Document the adapter needs. */
60
+ export interface DocumentLike {
61
+ createElement(tagName: string): HtmlElementLike & {
62
+ innerHTML: string;
63
+ };
64
+ }
65
+ export interface DomAdapter {
66
+ /**
67
+ * Parse an HTML fragment and return a container element whose
68
+ * children are the fragment's top-level nodes.
69
+ */
70
+ containerFromHtml(html: string): HtmlElementLike;
71
+ }
72
+ /** Wrap any standards-shaped document (browser, happy-dom, linkedom, …). */
73
+ export declare function documentAdapter(document: DocumentLike): DomAdapter;
74
+ /**
75
+ * Resolve the platform document (browsers). Throws a helpful error in
76
+ * runtimes without one — pass an adapter explicitly there.
77
+ */
78
+ export declare function defaultAdapter(): DomAdapter;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * DOM adapter seam. barkdown's serializer walks a DOM subtree through a
3
+ * small structural interface (`HtmlElementLike`), so any standards-shaped
4
+ * DOM works: the browser's, happy-dom's, linkedom's.
5
+ *
6
+ * The adapter is only needed for the string-input branch of
7
+ * `toMarkdown()` (and therefore `roundTrip()`): turning an HTML string
8
+ * into a container element requires a `document`. In browsers the global
9
+ * document is used automatically; on servers pass one explicitly:
10
+ *
11
+ * // happy-dom
12
+ * import { Window } from "happy-dom";
13
+ * const adapter = documentAdapter(new Window().document);
14
+ *
15
+ * // linkedom
16
+ * import { parseHTML } from "linkedom";
17
+ * const adapter = documentAdapter(
18
+ * parseHTML("<html><body></body></html>").document,
19
+ * );
20
+ */
21
+ /** Error type for barkdown misuse (e.g. no DOM available). */
22
+ export class BarkdownError extends Error {
23
+ name = "BarkdownError";
24
+ }
25
+ /** Wrap any standards-shaped document (browser, happy-dom, linkedom, …). */
26
+ export function documentAdapter(document) {
27
+ return {
28
+ containerFromHtml(html) {
29
+ const container = document.createElement("div");
30
+ container.innerHTML = html;
31
+ return container;
32
+ },
33
+ };
34
+ }
35
+ /**
36
+ * Resolve the platform document (browsers). Throws a helpful error in
37
+ * runtimes without one — pass an adapter explicitly there.
38
+ */
39
+ export function defaultAdapter() {
40
+ const doc = globalThis.document;
41
+ if (!doc || typeof doc.createElement !== "function") {
42
+ throw new BarkdownError("No global document in this runtime. Pass an adapter explicitly, " +
43
+ "e.g. `documentAdapter(new Window().document)` with happy-dom, or " +
44
+ '`documentAdapter(parseHTML("<html><body></body></html>").document)` ' +
45
+ "with linkedom.");
46
+ }
47
+ return documentAdapter(doc);
48
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * barkdown — a Markdown ⇄ DOM round-trip codec, guaranteed to invert
3
+ * marked. Sibling of @kevinpeckham/barkup (barkup guards the tree's
4
+ * identity; barkdown guards the prose's).
5
+ *
6
+ * toMarkdown(input, options?) — DOM subtree (or HTML string) → markdown
7
+ * toDom(markdown) — markdown → HTML string (marked + footnotes)
8
+ * roundTrip(markdown, adapter?) — toMarkdown(toDom(markdown))
9
+ *
10
+ * Guarantees (see README):
11
+ * 1. Canonical identity: toMarkdown(toDom(md)) === md for canonical md.
12
+ * 2. Fixed-point convergence: one round trip canonicalizes; a second is
13
+ * byte-identical to the first.
14
+ * 3. Footnote identity: marked-footnote's output shape round-trips
15
+ * exactly, including the legacy data-footnote-ref shape.
16
+ * 4. No silent loss: unknown elements pass through as raw HTML; every
17
+ * DOM text node reaches the output.
18
+ */
19
+ import type { DomAdapter } from "./adapter.js";
20
+ /**
21
+ * Parse markdown with marked, serialize the resulting DOM back to
22
+ * markdown. One application canonicalizes; a second is a fixed point.
23
+ */
24
+ export declare function roundTrip(markdown: string, adapter?: DomAdapter): string;
25
+ export type { DocumentLike, DomAdapter, DomNodeLike, HtmlElementLike, StyleLike, } from "./adapter.js";
26
+ export { BarkdownError, defaultAdapter, documentAdapter, } from "./adapter.js";
27
+ export { toDom } from "./parse.js";
28
+ export type { ToMarkdownOptions } from "./serialize.js";
29
+ export { toMarkdown } from "./serialize.js";
package/dist/index.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * barkdown — a Markdown ⇄ DOM round-trip codec, guaranteed to invert
3
+ * marked. Sibling of @kevinpeckham/barkup (barkup guards the tree's
4
+ * identity; barkdown guards the prose's).
5
+ *
6
+ * toMarkdown(input, options?) — DOM subtree (or HTML string) → markdown
7
+ * toDom(markdown) — markdown → HTML string (marked + footnotes)
8
+ * roundTrip(markdown, adapter?) — toMarkdown(toDom(markdown))
9
+ *
10
+ * Guarantees (see README):
11
+ * 1. Canonical identity: toMarkdown(toDom(md)) === md for canonical md.
12
+ * 2. Fixed-point convergence: one round trip canonicalizes; a second is
13
+ * byte-identical to the first.
14
+ * 3. Footnote identity: marked-footnote's output shape round-trips
15
+ * exactly, including the legacy data-footnote-ref shape.
16
+ * 4. No silent loss: unknown elements pass through as raw HTML; every
17
+ * DOM text node reaches the output.
18
+ */
19
+ import { toDom } from "./parse.js";
20
+ import { toMarkdown } from "./serialize.js";
21
+ /**
22
+ * Parse markdown with marked, serialize the resulting DOM back to
23
+ * markdown. One application canonicalizes; a second is a fixed point.
24
+ */
25
+ export function roundTrip(markdown, adapter) {
26
+ return toMarkdown(toDom(markdown), adapter ? { adapter } : {});
27
+ }
28
+ export { BarkdownError, defaultAdapter, documentAdapter, } from "./adapter.js";
29
+ export { toDom } from "./parse.js";
30
+ export { toMarkdown } from "./serialize.js";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Render markdown to an HTML string with marked (GFM) + marked-footnote.
3
+ * This is the parse half of the codec that `toMarkdown` inverts.
4
+ *
5
+ * barkdown configures its own `Marked` instance — it never mutates the
6
+ * `marked` singleton, so a consumer's own marked configuration is
7
+ * untouched.
8
+ *
9
+ * Footnotes emit `<sup><a data-footnote-ref …>` refs + a trailing
10
+ * `<section data-footnotes>` — which `toMarkdown` inverts back to `[^N]`
11
+ * + `[^N]: text` GFM footnote syntax.
12
+ *
13
+ * NOTE: no sanitization is performed (marked does not sanitize).
14
+ * Sanitize the output (e.g. DOMPurify) before trusting it in a live DOM.
15
+ */
16
+ /** Markdown → HTML string. DOM construction is the consumer's side. */
17
+ export declare function toDom(markdown: string): string;
package/dist/parse.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Render markdown to an HTML string with marked (GFM) + marked-footnote.
3
+ * This is the parse half of the codec that `toMarkdown` inverts.
4
+ *
5
+ * barkdown configures its own `Marked` instance — it never mutates the
6
+ * `marked` singleton, so a consumer's own marked configuration is
7
+ * untouched.
8
+ *
9
+ * Footnotes emit `<sup><a data-footnote-ref …>` refs + a trailing
10
+ * `<section data-footnotes>` — which `toMarkdown` inverts back to `[^N]`
11
+ * + `[^N]: text` GFM footnote syntax.
12
+ *
13
+ * NOTE: no sanitization is performed (marked does not sanitize).
14
+ * Sanitize the output (e.g. DOMPurify) before trusting it in a live DOM.
15
+ */
16
+ import { Marked } from "marked";
17
+ import markedFootnote from "marked-footnote";
18
+ // Lazy singleton: the footnote extension registers exactly once, and
19
+ // only when toDom is first used.
20
+ let instance;
21
+ function getMarked() {
22
+ if (!instance) {
23
+ instance = new Marked({ gfm: true }).use(markedFootnote());
24
+ }
25
+ return instance;
26
+ }
27
+ /** Markdown → HTML string. DOM construction is the consumer's side. */
28
+ export function toDom(markdown) {
29
+ if (!markdown)
30
+ return "";
31
+ return getMarked().parse(markdown, { async: false });
32
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Emphasis-delimiter machinery: decide whether inline content can be
3
+ * wrapped in `**` / `*` / `~~` markers and still reparse (via marked) to
4
+ * the same bytes. Encodes CommonMark flanking rules plus the empirical
5
+ * quirks of marked's emStrong/del run accounting. Callers fall back to
6
+ * raw inline tags when wrapping is unrepresentable (`tryWrapDelimited`
7
+ * returns null).
8
+ */
9
+ import type { DomNodeLike } from "./adapter.js";
10
+ /**
11
+ * Wrap inline content in an emphasis delimiter, or return null when the
12
+ * result would not reparse as written:
13
+ * - CommonMark delimiters can't face whitespace, so leading/trailing
14
+ * whitespace is hoisted outside the markers; empty or whitespace-only
15
+ * content is unrepresentable (`****` reparses as literal asterisks).
16
+ * - Content whose edges already carry *unescaped* marker characters
17
+ * (nested emphasis) merges into a longer delimiter run. Symmetric
18
+ * runs reparse to the same bytes; asymmetric ones (`**0*!*`) do not.
19
+ * Tilde runs beyond `~~` never delimit, so any edge tilde bails.
20
+ * (`escapeMarkdownText` escapes `*`/`~` in text, so a backslash-preceded
21
+ * edge char is literal text, not a delimiter — hence the lookbehind.)
22
+ */
23
+ export declare function tryWrapDelimited(inner: string, marker: string, before: string, next: DomNodeLike | undefined): string | null;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Emphasis-delimiter machinery: decide whether inline content can be
3
+ * wrapped in `**` / `*` / `~~` markers and still reparse (via marked) to
4
+ * the same bytes. Encodes CommonMark flanking rules plus the empirical
5
+ * quirks of marked's emStrong/del run accounting. Callers fall back to
6
+ * raw inline tags when wrapping is unrepresentable (`tryWrapDelimited`
7
+ * returns null).
8
+ */
9
+ const ELEMENT_NODE = 1;
10
+ const TEXT_NODE = 3;
11
+ /**
12
+ * Wrap inline content in an emphasis delimiter, or return null when the
13
+ * result would not reparse as written:
14
+ * - CommonMark delimiters can't face whitespace, so leading/trailing
15
+ * whitespace is hoisted outside the markers; empty or whitespace-only
16
+ * content is unrepresentable (`****` reparses as literal asterisks).
17
+ * - Content whose edges already carry *unescaped* marker characters
18
+ * (nested emphasis) merges into a longer delimiter run. Symmetric
19
+ * runs reparse to the same bytes; asymmetric ones (`**0*!*`) do not.
20
+ * Tilde runs beyond `~~` never delimit, so any edge tilde bails.
21
+ * (`escapeMarkdownText` escapes `*`/`~` in text, so a backslash-preceded
22
+ * edge char is literal text, not a delimiter — hence the lookbehind.)
23
+ */
24
+ export function tryWrapDelimited(inner, marker, before, next) {
25
+ const match = inner.match(/^(\s*)([\s\S]*?)(\s*)$/);
26
+ if (!match)
27
+ return null;
28
+ const lead = match[1] ?? "";
29
+ const core = match[2] ?? "";
30
+ const trail = match[3] ?? "";
31
+ if (core === "")
32
+ return null;
33
+ const isTilde = marker[0] === "~";
34
+ const delimiterChar = isTilde ? "~" : "*";
35
+ if (hasUnsafeMarkerRuns(core, isTilde))
36
+ return null;
37
+ // Adjacency: a neighboring same-kind character — escaped or not, in
38
+ // either direction — merges into (or corrupts) the delimiter run.
39
+ if (lead === "" && before.endsWith(delimiterChar))
40
+ return null;
41
+ if (trail === "" && peekNextChar(next) === delimiterChar)
42
+ return null;
43
+ if (hasMarkedPairingHazard(core, marker.length, isTilde))
44
+ return null;
45
+ if (flankingFails(core, lead, trail, before, next))
46
+ return null;
47
+ return lead + marker + core + marker + trail;
48
+ }
49
+ /**
50
+ * Interior shapes that empirically corrupt marked's delimiter pairing:
51
+ * - Interior delimiter runs (nested markers): the wrapper's opener can
52
+ * pair early with the first interior run when that run is
53
+ * closer-capable and CommonMark's rule of three doesn't block the
54
+ * pairing ("**" + interior "**" mispairs; "**" + "*" is blocked —
55
+ * which is why plain bold-with-italic content stays wrappable).
56
+ * - Interior runs directly adjacent to punctuation corrupt marked's
57
+ * pairing regardless of CommonMark's flanking math (`*!**0**0*` and
58
+ * `*a**b**!*` fail while `**a*b*c**` and `**a *b* c**` pair fine).
59
+ * - Raw inline tags combined with interior marker characters corrupt
60
+ * marked's run scanning (`*<c>**x**</c>*` fails to pair while
61
+ * `*<c>x</c>*` is fine). Text `<` is always escaped, so an unescaped
62
+ * `<` here is one of our own raw-tag emissions.
63
+ * - A trailing escape pair (e.g. `…\~`) corrupts marked's closing-run
64
+ * scan the same way escaped marker chars do.
65
+ */
66
+ function hasMarkedPairingHazard(core, markerLength, isTilde) {
67
+ if (!isTilde && firstInteriorRunSteals(core, markerLength))
68
+ return true;
69
+ if (!isTilde && interiorRunTouchesPunctuation(core))
70
+ return true;
71
+ if (/(?<!\\)</.test(core) && (isTilde ? /~/ : /\*/).test(core))
72
+ return true;
73
+ return /\\[\s\S]$/.test(core);
74
+ }
75
+ /**
76
+ * Marker-run hazards inside the core content:
77
+ * - Edge delimiter runs. marked's emStrong run accounting does not honor
78
+ * backslash escapes adjacent to a run, so an *escaped* edge star is
79
+ * just as hazardous as a real one. Symmetric unescaped asterisk runs
80
+ * reparse to the same bytes (`***x***`); anything else bails to the
81
+ * raw-tag fallback. Tilde runs beyond `~~` never delimit, so any edge
82
+ * tilde bails.
83
+ * - marked's emStrong/del run arithmetic miscounts around
84
+ * backslash-escaped marker characters (anywhere in the content, not
85
+ * just at the edges), so any escaped marker char bails. Strikethrough
86
+ * pairs additionally match first-come — a `~~` anywhere inside the
87
+ * core would close the wrap early.
88
+ */
89
+ function hasUnsafeMarkerRuns(core, isTilde) {
90
+ const leadRun = core.match(isTilde ? /^~+/ : /^\*+/)?.[0].length ?? 0;
91
+ const trailRun = core.match(isTilde ? /~+$/ : /\*+$/)?.[0].length ?? 0;
92
+ const escapedMarkerChar = isTilde ? /\\~/.test(core) : /\\\*/.test(core);
93
+ const tildeInterior = isTilde && /~~/.test(core);
94
+ return (escapedMarkerChar ||
95
+ tildeInterior ||
96
+ (isTilde ? leadRun > 0 || trailRun > 0 : leadRun !== trailRun));
97
+ }
98
+ /**
99
+ * CommonMark flanking (and marked applies the same rules to `~~`): a
100
+ * delimiter followed by punctuation only *opens* if preceded by
101
+ * whitespace/punctuation, and one preceded by punctuation only *closes*
102
+ * if followed by whitespace/punctuation.
103
+ */
104
+ function flankingFails(core, lead, trail, before, next) {
105
+ const beforeChar = lead !== "" ? " " : before.slice(-1);
106
+ const afterChar = trail !== "" ? " " : peekNextChar(next);
107
+ const coreFirst = core.slice(0, 1);
108
+ const coreLast = core.slice(-1);
109
+ const openFails = isPunctuation(coreFirst) &&
110
+ !(isFlankWhitespace(beforeChar) || isPunctuation(beforeChar));
111
+ const closeFails = isPunctuation(coreLast) &&
112
+ !(isFlankWhitespace(afterChar) || isPunctuation(afterChar));
113
+ return openFails || closeFails;
114
+ }
115
+ /** Iterate the interior `*`-runs of `core` (edge runs merge — skipped). */
116
+ function* interiorStarRuns(core) {
117
+ for (const m of core.matchAll(/\*+/g)) {
118
+ const start = m.index ?? 0;
119
+ const end = start + m[0].length;
120
+ if (start === 0 || end === core.length)
121
+ continue; // edge runs merge
122
+ yield { run: m[0], prev: core[start - 1] ?? "", next: core[end] ?? "" };
123
+ }
124
+ }
125
+ /**
126
+ * Would the first interior `*`-run of `core` close against a wrapper
127
+ * opener of `markerLength` stars? Closer-capable = not preceded by
128
+ * whitespace and (not preceded by punctuation, or followed by
129
+ * whitespace/punctuation). The rule of three blocks the pairing when the
130
+ * combined lengths are a multiple of 3 (and the run lengths aren't).
131
+ */
132
+ function firstInteriorRunSteals(core, markerLength) {
133
+ for (const { run, prev, next } of interiorStarRuns(core)) {
134
+ if (prev === "\\")
135
+ return false; // escaped → already bailed upstream
136
+ const precededByWhitespace = /\s/.test(prev);
137
+ const precededByPunct = isPunctuation(prev);
138
+ const canClose = !precededByWhitespace &&
139
+ (!precededByPunct || /\s/.test(next) || isPunctuation(next));
140
+ if (!canClose)
141
+ return false; // first run opens; later runs pair inward
142
+ return (markerLength + run.length) % 3 !== 0;
143
+ }
144
+ return false;
145
+ }
146
+ /**
147
+ * True when any interior (non-edge) `*`-run has punctuation immediately
148
+ * on either side — the empirically unsafe shape in marked's emphasis
149
+ * pairing. (Whitespace- and alphanumeric-adjacent runs pair reliably.)
150
+ */
151
+ function interiorRunTouchesPunctuation(core) {
152
+ for (const { prev, next } of interiorStarRuns(core)) {
153
+ if (isPunctuation(prev) || isPunctuation(next))
154
+ return true;
155
+ }
156
+ return false;
157
+ }
158
+ /** Start/end of block counts as whitespace for flanking purposes. */
159
+ function isFlankWhitespace(char) {
160
+ return char === "" || /\s/.test(char);
161
+ }
162
+ function isPunctuation(char) {
163
+ return /[\p{P}\p{S}]/u.test(char);
164
+ }
165
+ /**
166
+ * First character that will follow the current construct in the emitted
167
+ * markdown — used for flanking checks. Unknown elements return a
168
+ * conservative non-space, non-punctuation placeholder.
169
+ */
170
+ function peekNextChar(next) {
171
+ if (!next)
172
+ return ""; // end of the inline run — block boundary
173
+ if (next.nodeType === TEXT_NODE) {
174
+ return (next.textContent ?? "").slice(0, 1);
175
+ }
176
+ if (next.nodeType === ELEMENT_NODE) {
177
+ const tag = next.tagName.toLowerCase();
178
+ if (tag === "br")
179
+ return "\n";
180
+ // Unknown until serialized: treat as a letter so the flanking
181
+ // check stays conservative (more raw-tag fallbacks, never a broken
182
+ // delimiter).
183
+ return "a";
184
+ }
185
+ return "";
186
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Post-processing pass for footnote definitions in serialized markdown.
3
+ *
4
+ * marked-footnote renders nothing for a definition that is never
5
+ * referenced outside the footnote section itself, so emitting such a
6
+ * definition would vanish one trip late. This pass canonicalizes the
7
+ * output by dropping unreferenced definitions up front. Pure
8
+ * string → string; no DOM involvement.
9
+ */
10
+ /**
11
+ * Drop footnote definitions that nothing references. Dropping a
12
+ * definition can orphan another (its content held the only ref) —
13
+ * iterate to a fixpoint. Bounded by the number of definitions.
14
+ */
15
+ export declare function dropUnreferencedFootnoteDefs(markdown: string): string;