@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.
@@ -0,0 +1,1219 @@
1
+ /**
2
+ * Serialize a DOM subtree back to markdown. Inverse of `toDom` — walks
3
+ * the HTML shape that `marked` produces plus the tags contenteditable
4
+ * editors inject, and emits GFM-flavored markdown.
5
+ *
6
+ * Design principles:
7
+ * - Closed subset. We only serialize elements we know about. Anything
8
+ * else is preserved as raw `outerHTML` so leaks surface early rather
9
+ * than silently disappearing.
10
+ * - Adapter-seamed. Element inputs are walked through the structural
11
+ * `HtmlElementLike` interface; string inputs are parsed via a
12
+ * `DomAdapter` (browser document by default).
13
+ * - Cursor-agnostic. Selection state is the caller's responsibility;
14
+ * this function just reads DOM → returns a string.
15
+ * - No side effects. Pure. Callers can invoke it debounced without
16
+ * worrying about mutation of the input.
17
+ */
18
+ import { defaultAdapter } from "./adapter.js";
19
+ import { tryWrapDelimited } from "./serialize-emphasis.js";
20
+ import { dropUnreferencedFootnoteDefs } from "./serialize-footnote-defs.js";
21
+ const ELEMENT_NODE = 1;
22
+ const TEXT_NODE = 3;
23
+ const COMMENT_NODE = 8;
24
+ export function toMarkdown(input, options = {}) {
25
+ let container;
26
+ if (typeof input === "string") {
27
+ const adapter = options.adapter ?? defaultAdapter();
28
+ container = adapter.containerFromHtml(input);
29
+ }
30
+ else {
31
+ container = input;
32
+ }
33
+ const body = dropUnreferencedFootnoteDefs(serializeBlocks(container)).trimEnd();
34
+ return body.length ? body + "\n" : "";
35
+ }
36
+ /** Serialize the direct-child sequence of an element as a list of blocks. */
37
+ function serializeBlocks(container) {
38
+ const parts = [];
39
+ let previousListTag = null;
40
+ for (const node of arrayFrom(container.childNodes)) {
41
+ const part = serializeBlockNode(node);
42
+ if (part === "")
43
+ continue;
44
+ const listTag = node.nodeType === ELEMENT_NODE
45
+ ? listTagOf(node)
46
+ : null;
47
+ // Markdown can't separate adjacent same-marker lists with a blank
48
+ // line alone — they'd merge into one loose list on reparse. An HTML
49
+ // comment (which round-trips) keeps them apart.
50
+ if (listTag !== null && listTag === previousListTag) {
51
+ parts.push("<!-- -->");
52
+ }
53
+ previousListTag = listTag;
54
+ parts.push(part);
55
+ }
56
+ return parts.join("\n\n");
57
+ }
58
+ function listTagOf(el) {
59
+ const tag = el.tagName.toLowerCase();
60
+ return tag === "ul" || tag === "ol" ? tag : null;
61
+ }
62
+ function serializeBlockNode(node) {
63
+ if (node.nodeType === TEXT_NODE) {
64
+ const raw = node.textContent ?? "";
65
+ const trimmed = raw.trim();
66
+ return trimmed ? normalizeBlockLines(escapeMarkdownText(trimmed)) : "";
67
+ }
68
+ // Comments round-trip (markdown passes HTML comments through); losing
69
+ // them would also silently merge adjacent lists (see serializeBlocks).
70
+ if (node.nodeType === COMMENT_NODE) {
71
+ return `<!--${node.textContent ?? ""}-->`;
72
+ }
73
+ if (node.nodeType !== ELEMENT_NODE)
74
+ return "";
75
+ const el = node;
76
+ const tag = el.tagName.toLowerCase();
77
+ switch (tag) {
78
+ // Headings serialize with `stripEmphasis: true` — editors hide
79
+ // bold/italic on headings, but keyboard shortcuts (Cmd+B / Cmd+I)
80
+ // and pasted markup can still introduce <strong>/<em> inside one.
81
+ // Stripping the markers here is the round-trip safety net so a
82
+ // heading never emits `**`/`*`/`~~` into the markdown.
83
+ case "h1":
84
+ return serializeHeading(el, 1);
85
+ case "h2":
86
+ return serializeHeading(el, 2);
87
+ case "h3":
88
+ return serializeHeading(el, 3);
89
+ case "h4":
90
+ return serializeHeading(el, 4);
91
+ case "h5":
92
+ return serializeHeading(el, 5);
93
+ case "h6":
94
+ return serializeHeading(el, 6);
95
+ case "p":
96
+ return liftLineStartComments(normalizeBlockLines(serializeInline(el).trim()));
97
+ case "ul":
98
+ return serializeList(el, false);
99
+ case "ol":
100
+ return serializeList(el, true);
101
+ case "blockquote":
102
+ return serializeBlockquote(el);
103
+ case "pre":
104
+ return serializeCodeBlock(el);
105
+ case "hr":
106
+ return "---";
107
+ case "table":
108
+ return serializeTable(el);
109
+ case "img":
110
+ // A bare block-level image (contenteditable artifact); marked
111
+ // re-wraps it in a paragraph, which serializes back identically.
112
+ return serializeImage(el);
113
+ case "section":
114
+ if (el.hasAttribute("data-footnotes"))
115
+ return serializeFootnotes(el);
116
+ return el.outerHTML;
117
+ case "div":
118
+ // Common contenteditable artifact — an empty <div> between
119
+ // paragraphs from Chrome's Enter-key handling. Recurse into
120
+ // children as if it were transparent.
121
+ return serializeBlocks(el);
122
+ default:
123
+ // Tags on CommonMark's HTML-block list pass through raw and
124
+ // unparsed, so outerHTML is byte-stable for them. Anything else
125
+ // reparses in *paragraph* context (its text is markdown-parsed),
126
+ // so serialize it as a paragraph of inline content — block
127
+ // children inside it (invalid-HTML artifacts) split out.
128
+ if (HTML_BLOCK_TAGS.has(tag))
129
+ return el.outerHTML;
130
+ return serializeStrayBlockElement(el);
131
+ }
132
+ }
133
+ /** Block-level tags that must split out of a stray inline wrapper. */
134
+ const SPLITTING_BLOCK_TAGS = new Set([
135
+ "h1",
136
+ "h2",
137
+ "h3",
138
+ "h4",
139
+ "h5",
140
+ "h6",
141
+ "blockquote",
142
+ "pre",
143
+ "table",
144
+ "hr",
145
+ "ul",
146
+ "ol",
147
+ ]);
148
+ /**
149
+ * A non-HTML-block element at block position (e.g. `<b>` from an HTML
150
+ * block, custom elements). It reparses in paragraph context, so it
151
+ * serializes as raw tags around escaped inline content — but block
152
+ * children (a `<pre>` swallowed into a `<b>` by tree construction) must
153
+ * become their own blocks, mirroring how the reparse splits them.
154
+ */
155
+ function serializeStrayBlockElement(el) {
156
+ const kids = nonVanishingChildren(el);
157
+ const hasBlockChild = kids.some((n) => n.nodeType === ELEMENT_NODE &&
158
+ SPLITTING_BLOCK_TAGS.has(n.tagName.toLowerCase()));
159
+ if (!hasBlockChild) {
160
+ return normalizeBlockLines(serializeInlineElement("", el, false, undefined).trim());
161
+ }
162
+ const parts = [];
163
+ let inline = "";
164
+ const flushInline = () => {
165
+ const clamped = clampTagInner(inline).trim();
166
+ if (clamped !== "") {
167
+ parts.push(normalizeBlockLines(rawInlineTag(el, clamped).trim()));
168
+ }
169
+ inline = "";
170
+ };
171
+ for (let i = 0; i < kids.length; i++) {
172
+ const kid = kids[i];
173
+ if (kid.nodeType === TEXT_NODE) {
174
+ inline += escapeMarkdownText(kid.textContent ?? "");
175
+ continue;
176
+ }
177
+ if (kid.nodeType === COMMENT_NODE) {
178
+ flushInline();
179
+ parts.push(`<!--${kid.textContent ?? ""}-->`);
180
+ continue;
181
+ }
182
+ if (kid.nodeType !== ELEMENT_NODE)
183
+ continue;
184
+ const childEl = kid;
185
+ if (SPLITTING_BLOCK_TAGS.has(childEl.tagName.toLowerCase())) {
186
+ flushInline();
187
+ parts.push(serializeBlockNode(childEl));
188
+ }
189
+ else {
190
+ inline = serializeInlineElement(inline, childEl, false, kids[i + 1]);
191
+ }
192
+ }
193
+ flushInline();
194
+ return parts.filter((p) => p !== "").join("\n\n");
195
+ }
196
+ /**
197
+ * CommonMark HTML-block (type 1/6) tag names barkdown doesn't already
198
+ * handle: for these, marked passes content through without markdown
199
+ * parsing, so raw outerHTML round-trips byte-for-byte.
200
+ */
201
+ const HTML_BLOCK_TAGS = new Set([
202
+ "address",
203
+ "article",
204
+ "aside",
205
+ "base",
206
+ "basefont",
207
+ "body",
208
+ "caption",
209
+ "center",
210
+ "col",
211
+ "colgroup",
212
+ "dd",
213
+ "details",
214
+ "dialog",
215
+ "dir",
216
+ "dl",
217
+ "dt",
218
+ "fieldset",
219
+ "figcaption",
220
+ "figure",
221
+ "footer",
222
+ "form",
223
+ "frame",
224
+ "frameset",
225
+ "head",
226
+ "header",
227
+ "html",
228
+ "iframe",
229
+ "legend",
230
+ "li",
231
+ "link",
232
+ "main",
233
+ "menu",
234
+ "menuitem",
235
+ "meta",
236
+ "nav",
237
+ "noframes",
238
+ "optgroup",
239
+ "option",
240
+ "param",
241
+ "script",
242
+ "search",
243
+ "style",
244
+ "summary",
245
+ "tbody",
246
+ "td",
247
+ "textarea",
248
+ "tfoot",
249
+ "th",
250
+ "thead",
251
+ "title",
252
+ "tr",
253
+ "track",
254
+ ]);
255
+ /**
256
+ * ATX headings are single-line: internal line breaks collapse to spaces,
257
+ * and a trailing `#` run gets escaped so it can't read as a closing
258
+ * sequence.
259
+ */
260
+ function serializeHeading(el, level) {
261
+ let content = serializeInline(el, true).trim();
262
+ content = content.replace(/[ \t]*\n[ \t]*/g, " ");
263
+ content = content.replace(/(^|[ \t])(#+)$/, "$1\\$2");
264
+ return `${"#".repeat(level)} ${content}`;
265
+ }
266
+ /**
267
+ * Canonicalize the lines of a paragraph-like block:
268
+ * - leading whitespace is stripped (markdown can't represent it — the
269
+ * parser strips continuation-line indent, so keeping it would defeat
270
+ * idempotence);
271
+ * - trailing whitespace is stripped, except a two-plus-space hard break
272
+ * before another line, which normalizes to exactly two spaces;
273
+ * - characters that would start a different block construct at a line
274
+ * start are escaped.
275
+ */
276
+ function normalizeBlockLines(text) {
277
+ const lines = text.split("\n");
278
+ const out = [];
279
+ for (let i = 0; i < lines.length; i++) {
280
+ let line = (lines[i] ?? "").replace(/^[ \t]+/, "");
281
+ const isLast = i === lines.length - 1;
282
+ const trailingSpaces = line.match(/ +$/)?.[0] ?? "";
283
+ line = line.replace(/[ \t]+$/, "");
284
+ line = escapeLineStart(line);
285
+ // A hard break survives only before a non-blank line (a blank line
286
+ // ends the paragraph, where trailing spaces are stripped).
287
+ const nextIsContent = (lines[i + 1] ?? "").trim() !== "";
288
+ if (!isLast && nextIsContent && trailingSpaces.length >= 2 && line !== "")
289
+ line += " ";
290
+ out.push(line);
291
+ }
292
+ return out.join("\n");
293
+ }
294
+ /**
295
+ * A paragraph line that *starts* with an HTML comment reparses as a raw
296
+ * HTML block that swallows the rest of the line unparsed — escapes after
297
+ * it would double every trip. Lift such comments into their own blocks
298
+ * (which is exactly the shape reparsing produces).
299
+ */
300
+ function liftLineStartComments(text) {
301
+ if (!text.includes("<!--"))
302
+ return text;
303
+ const blocks = [];
304
+ let current = [];
305
+ const flush = () => {
306
+ if (current.length > 0) {
307
+ // The block's final line can't keep a hard-break marker — the
308
+ // parser strips trailing spaces at a paragraph end.
309
+ const lastIndex = current.length - 1;
310
+ current[lastIndex] = (current[lastIndex] ?? "").replace(/ +$/, "");
311
+ blocks.push(current.join("\n"));
312
+ current = [];
313
+ }
314
+ };
315
+ for (const originalLine of text.split("\n")) {
316
+ let line = originalLine;
317
+ let m = line.match(/^(<!--[\s\S]*?-->)[ \t]*(.*)$/);
318
+ while (m) {
319
+ flush();
320
+ blocks.push(m[1] ?? "");
321
+ // The remainder now sits at a fresh line start — re-apply the
322
+ // line-start escapes.
323
+ line = escapeLineStart(m[2] ?? "");
324
+ m = line.match(/^(<!--[\s\S]*?-->)[ \t]*(.*)$/);
325
+ }
326
+ // Keep pre-existing blank lines; drop a line that became empty
327
+ // because it was only comments.
328
+ if (line !== "" || line === originalLine)
329
+ current.push(line);
330
+ }
331
+ flush();
332
+ return blocks.join("\n\n");
333
+ }
334
+ /**
335
+ * Escape text that would parse as a block construct at a line start.
336
+ * Characters that are special *everywhere* (`*`, `_`, `` ` ``, `~`, `[`)
337
+ * are already escaped by `escapeMarkdownText`; this handles the
338
+ * line-start-only markers the escaper deliberately leaves alone.
339
+ */
340
+ function escapeLineStart(line) {
341
+ if (line === "")
342
+ return line;
343
+ // ATX heading: 1–6 hashes then space or end.
344
+ if (/^#{1,6}(\s|$)/.test(line))
345
+ return `\\${line}`;
346
+ // Blockquote: any leading `>` (space optional).
347
+ if (line.startsWith(">"))
348
+ return `\\${line}`;
349
+ // Table row / delimiter starting with a pipe.
350
+ if (line.startsWith("|"))
351
+ return `\\${line}`;
352
+ // Bullet list marker (`*` is already escaped in text).
353
+ if (/^[-+](\s|$)/.test(line))
354
+ return `\\${line}`;
355
+ // Ordered list marker.
356
+ const ordered = line.match(/^(\d{1,9})[.)](\s|$)/);
357
+ if (ordered) {
358
+ const digits = ordered[1] ?? "";
359
+ return `${digits}\\${line.slice(digits.length)}`;
360
+ }
361
+ // Setext underline / thematic break / table delimiter row: a line of
362
+ // nothing but -, =, :, | and spaces.
363
+ if (/^[-=:|\s]+$/.test(line) && /[-=:|]/.test(line))
364
+ return `\\${line}`;
365
+ return line;
366
+ }
367
+ /**
368
+ * Serialize inline content (children of a block) as markdown.
369
+ * `nextAfterContainer` is what follows the container itself — forwarded
370
+ * to the last child so boundary decisions (bare autolinks, flanking) see
371
+ * through transparent wrappers.
372
+ */
373
+ function serializeInline(container, stripEmphasis = false, allowLeadingCheckbox = false, nextAfterContainer, precedingText = "") {
374
+ // Seeded with what precedes the container so first-child boundary
375
+ // decisions (delimiter adjacency, flanking) see through transparent
376
+ // wrappers; the seed is sliced back off before returning.
377
+ let out = precedingText;
378
+ let trimLeading = false;
379
+ // Wrappers that emit nothing are dropped up front so they can't skew
380
+ // next-sibling boundary decisions (they won't exist next trip).
381
+ const nodes = nonVanishingChildren(container);
382
+ for (let i = 0; i < nodes.length; i++) {
383
+ const node = nodes[i];
384
+ // GFM task-list checkbox (loose items: marked nests it in the
385
+ // item's first <p>) is only recognized in leading position.
386
+ const allowCheckbox = allowLeadingCheckbox && out === precedingText;
387
+ const take = takeInlineToken(node, trimLeading, allowCheckbox);
388
+ if (take) {
389
+ trimLeading = take.trimLeading;
390
+ out += take.text;
391
+ continue;
392
+ }
393
+ if (node.nodeType === COMMENT_NODE) {
394
+ out += `<!--${node.textContent ?? ""}-->`;
395
+ continue;
396
+ }
397
+ if (node.nodeType !== ELEMENT_NODE)
398
+ continue;
399
+ trimLeading = false;
400
+ out = serializeInlineElement(out, node, stripEmphasis, nodes[i + 1] ?? nextAfterContainer);
401
+ }
402
+ return out.slice(precedingText.length);
403
+ }
404
+ /**
405
+ * True for transparent wrapper elements that serialize to exactly ""
406
+ * (recursively empty div/p/span). They vanish on the next trip, so
407
+ * boundary decisions (e.g. bare autolinks) must not see them.
408
+ */
409
+ function isVanishingNode(node) {
410
+ if (node.nodeType !== ELEMENT_NODE)
411
+ return false;
412
+ const el = node;
413
+ const tag = el.tagName.toLowerCase();
414
+ if (tag !== "div" && tag !== "p" && tag !== "span")
415
+ return false;
416
+ if (tag === "span") {
417
+ const style = el.getAttribute("style") ?? "";
418
+ if (/font-weight|font-style|text-decoration/i.test(style))
419
+ return false;
420
+ }
421
+ for (const child of arrayFrom(el.childNodes)) {
422
+ if (!isVanishingNode(child))
423
+ return false;
424
+ }
425
+ return true;
426
+ }
427
+ /**
428
+ * Child nodes minus the vanishing wrappers (see `isVanishingNode`) —
429
+ * the node sequence every walker iterates.
430
+ */
431
+ function nonVanishingChildren(el) {
432
+ return arrayFrom(el.childNodes).filter((n) => !isVanishingNode(n));
433
+ }
434
+ /**
435
+ * Shared token handling for the two checkbox-aware inline walkers
436
+ * (`serializeInline`, `serializeListItem`); null = walker-specific node.
437
+ * - A text node escapes for markdown. While `trimLeading` is set (a
438
+ * task-list checkbox marker was just emitted, carrying its own
439
+ * trailing space so the space marked renders after the input isn't
440
+ * doubled), leading whitespace is swallowed; trimming stays armed
441
+ * across whitespace-only nodes.
442
+ * - A checkbox input in leading position becomes the GFM task marker
443
+ * (`[x] ` / `[ ] `) and arms `trimLeading`.
444
+ */
445
+ function takeInlineToken(node, trimLeading, allowCheckbox) {
446
+ if (node.nodeType === TEXT_NODE) {
447
+ let text = node.textContent ?? "";
448
+ if (trimLeading) {
449
+ const stripped = text.replace(/^[ \t]+/, "");
450
+ if (stripped !== "")
451
+ trimLeading = false;
452
+ text = stripped;
453
+ }
454
+ return { text: escapeMarkdownText(text), trimLeading };
455
+ }
456
+ const isElement = node.nodeType === ELEMENT_NODE;
457
+ if (allowCheckbox && isElement && isCheckboxInput(node)) {
458
+ const checked = node.hasAttribute("checked");
459
+ return { text: checked ? "[x] " : "[ ] ", trimLeading: true };
460
+ }
461
+ return null;
462
+ }
463
+ function isCheckboxInput(el) {
464
+ return (el.tagName.toLowerCase() === "input" &&
465
+ (el.getAttribute("type") ?? "").toLowerCase() === "checkbox");
466
+ }
467
+ /**
468
+ * Append one inline element to the accumulated markdown `out` and return
469
+ * the new accumulation. (Takes `out` because some constructs must adjust
470
+ * what precedes them, e.g. escaping a trailing `!` before a link.)
471
+ */
472
+ function serializeInlineElement(out, el, stripEmphasis, next) {
473
+ const tag = el.tagName.toLowerCase();
474
+ switch (tag) {
475
+ case "strong":
476
+ case "b":
477
+ return out + emphasisMarkup(el, "**", stripEmphasis, out, next);
478
+ case "em":
479
+ case "i":
480
+ return out + emphasisMarkup(el, "*", stripEmphasis, out, next);
481
+ case "s":
482
+ case "strike":
483
+ case "del":
484
+ return out + emphasisMarkup(el, "~~", stripEmphasis, out, next);
485
+ case "div":
486
+ case "p":
487
+ // Wrappers in an inline position (contenteditable artifacts,
488
+ // paragraphs inside odd containers): transparent, unwrapping
489
+ // every level in a single pass. The wrapper's own next sibling
490
+ // and preceding output are forwarded for boundary decisions.
491
+ return out + serializeInline(el, stripEmphasis, false, next, out);
492
+ case "a":
493
+ return appendAnchor(out, el, stripEmphasis, next);
494
+ case "code":
495
+ // Inline code — no escaping inside backticks.
496
+ return out + serializeCodeSpan(el.textContent ?? "");
497
+ case "img":
498
+ return out + serializeImage(el);
499
+ case "br":
500
+ // Two-space hard break in markdown.
501
+ return `${out} \n`;
502
+ case "sup":
503
+ return appendFootnoteRef(out, el);
504
+ case "span":
505
+ return appendSpan(out, el, stripEmphasis, next);
506
+ default:
507
+ return out + unknownInlineMarkup(el);
508
+ }
509
+ }
510
+ /**
511
+ * Emphasis element (`strong`/`em`/`del` and aliases). In `stripEmphasis`
512
+ * mode (headings) recurse without the markers so emphasized text inside
513
+ * a heading flattens; otherwise wrap in the delimiter with the raw-tag
514
+ * fallback.
515
+ */
516
+ function emphasisMarkup(el, marker, stripEmphasis, before, next) {
517
+ return stripEmphasis
518
+ ? serializeInline(el, true)
519
+ : wrapOrRawTag(el, serializeInline(el), marker, before, next);
520
+ }
521
+ /** `<a>` → bare autolink, `[text](dest "title")`, or raw-anchor fallback. */
522
+ function appendAnchor(out, el, stripEmphasis, next) {
523
+ const href = el.getAttribute("href") ?? "";
524
+ const title = el.getAttribute("title");
525
+ const inner = serializeInline(el, stripEmphasis);
526
+ if (!href)
527
+ return out + inner;
528
+ // Canonical form for self-links is the bare GFM autolink —
529
+ // marked linkifies bare URLs/emails, so emitting the full
530
+ // [text](url) form here would never re-read as written.
531
+ // Only safe when both source boundaries stay delimiters.
532
+ if (title === null &&
533
+ isBareAutolinkable(href, el.textContent ?? "", out) &&
534
+ autolinkBoundaryAfter(next)) {
535
+ return out + (el.textContent ?? "");
536
+ }
537
+ // marked quirk: inside link text an escaped `\[…\]` pair followed
538
+ // by `(` re-parses as a nested link/image despite the escapes.
539
+ // A raw anchor round-trips stably.
540
+ if (LINK_TEXT_QUIRK.test(inner)) {
541
+ return out + rawAnchorTag(el, inner);
542
+ }
543
+ const dest = encodeLinkDestination(href);
544
+ return (escapeTrailingBang(out) +
545
+ (title === null
546
+ ? `[${inner}](${dest})`
547
+ : `[${inner}](${dest} "${escapeLinkTitle(title)}")`));
548
+ }
549
+ /** `<sup>` footnote reference → `[^label]`, else unknown-markup fallback. */
550
+ function appendFootnoteRef(out, el) {
551
+ const label = footnoteRefLabel(el);
552
+ if (label === null)
553
+ return out + unknownInlineMarkup(el);
554
+ return `${escapeTrailingBang(out)}[^${label}]`;
555
+ }
556
+ /**
557
+ * Resolve a `<sup>` element's footnote label, or null when it isn't a
558
+ * recognizable footnote reference. Two shapes:
559
+ * - marked-footnote: <sup><a id="footnote-ref-N"
560
+ * data-footnote-ref>N</a></sup>
561
+ * - legacy: <sup data-footnote-ref="N">N</sup>
562
+ * Labels aren't only numeric: `[^note]` yields id="footnote-ref-note"
563
+ * while the *displayed* text is the footnote's index.
564
+ */
565
+ function footnoteRefLabel(el) {
566
+ const legacyAttr = el.getAttribute("data-footnote-ref");
567
+ if (legacyAttr && legacyAttr.length > 0)
568
+ return legacyAttr;
569
+ const anchor = el.querySelector("a[data-footnote-ref]");
570
+ if (!anchor)
571
+ return null;
572
+ // The href points at the definition and is the authoritative
573
+ // label; the anchor id gets a `-2` suffix on repeated refs to
574
+ // the same footnote, so it's only a fallback.
575
+ const hrefMatch = (anchor.getAttribute("href") ?? "").match(/#footnote-([A-Za-z0-9_-]+)$/);
576
+ const idMatch = anchor.id.match(/footnote-ref-([A-Za-z0-9_-]+)$/);
577
+ const label = hrefMatch?.[1] ?? idMatch?.[1] ?? anchor.textContent?.trim();
578
+ if (label && /^[A-Za-z0-9_-]+$/.test(label))
579
+ return label;
580
+ return null;
581
+ }
582
+ /**
583
+ * Contenteditable + browsers frequently produce structural <span>s with
584
+ * no semantic meaning; treat as transparent — but detect inline emphasis
585
+ * styling (font-weight / font-style / line-through) so CSS-styled spans
586
+ * (e.g. pasted from Word or Google Docs) still round-trip to **\/*\/~~
587
+ * instead of silently losing the emphasis.
588
+ */
589
+ function appendSpan(out, el, stripEmphasis, next) {
590
+ const markers = stripEmphasis ? [] : spanEmphasisMarkers(el);
591
+ if (markers.length === 0) {
592
+ // Transparent spans forward the surrounding context (next sibling
593
+ // and preceding output) for boundary decisions.
594
+ return out + serializeInline(el, stripEmphasis, false, next, out);
595
+ }
596
+ return out + styledSpanMarkup(el, markers, out, next);
597
+ }
598
+ /**
599
+ * Emphasis markers implied by a span's inline styling, innermost first
600
+ * (the order they wrap the content in).
601
+ */
602
+ function spanEmphasisMarkers(el) {
603
+ const style = el.style;
604
+ const fw = style?.fontWeight ?? "";
605
+ const isBold = fw === "bold" || fw === "bolder" || (/^\d+$/.test(fw) && Number(fw) >= 600);
606
+ const isItalic = (style?.fontStyle ?? "") === "italic";
607
+ const deco = `${style?.textDecoration ?? ""} ${style?.textDecorationLine ?? ""}`;
608
+ const markers = [];
609
+ if (deco.includes("line-through"))
610
+ markers.push("~~");
611
+ if (isItalic)
612
+ markers.push("*");
613
+ if (isBold)
614
+ markers.push("**");
615
+ return markers;
616
+ }
617
+ /**
618
+ * Wrap a styled span's content in its emphasis markers. Styled spans
619
+ * start fresh (their content sits inside the markers) rather than
620
+ * forwarding the surrounding context.
621
+ */
622
+ function styledSpanMarkup(el, markers, before, next) {
623
+ let inner = serializeInline(el);
624
+ for (const marker of markers) {
625
+ const wrapped = tryWrapDelimited(inner, marker, before, next);
626
+ if (wrapped === null) {
627
+ // Unrepresentable (empty or ambiguous delimiter run):
628
+ // a raw styled span with the escaped inner content
629
+ // round-trips stably and keeps the styling.
630
+ return rawInlineTag(el, serializeInline(el));
631
+ }
632
+ inner = wrapped;
633
+ }
634
+ return inner;
635
+ }
636
+ /** Void elements: no closing tag when rebuilding unknown markup. */
637
+ const VOID_TAGS = new Set([
638
+ "area",
639
+ "base",
640
+ "br",
641
+ "col",
642
+ "embed",
643
+ "hr",
644
+ "img",
645
+ "input",
646
+ "link",
647
+ "meta",
648
+ "param",
649
+ "source",
650
+ "track",
651
+ "wbr",
652
+ ]);
653
+ /**
654
+ * Rebuild an unknown inline element as raw tags around its markdown-
655
+ * escaped serialized children. `outerHTML` would be unstable here:
656
+ * marked parses the text between raw inline tags as markdown, so
657
+ * unescaped `*`/`\` text characters would change structure next trip.
658
+ * The rebuilt form reparses to the identical DOM.
659
+ */
660
+ function unknownInlineMarkup(el) {
661
+ const names = el.getAttributeNames?.();
662
+ if (names === undefined)
663
+ return el.outerHTML; // adapter can't enumerate
664
+ const tag = el.tagName.toLowerCase();
665
+ const attrs = names
666
+ .map((name) => ` ${name}="${escapeHtmlAttr(el.getAttribute(name) ?? "")}"`)
667
+ .join("");
668
+ if (VOID_TAGS.has(tag))
669
+ return `<${tag}${attrs}>`;
670
+ return `<${tag}${attrs}>${clampTagInner(serializeInline(el))}</${tag}>`;
671
+ }
672
+ /**
673
+ * Inner content of a rebuilt raw tag must not leave the open tag (or the
674
+ * closing tag) alone on its own line — that shape is a CommonMark type-7
675
+ * HTML block, which would swallow the following lines unparsed. Leading/
676
+ * trailing newline-bearing whitespace is normalized away.
677
+ */
678
+ function clampTagInner(inner) {
679
+ return inner.replace(/^[ \t]*\n\s*/, "").replace(/\s*\n[ \t]*$/, "");
680
+ }
681
+ /** Serialize a `<ul>` or `<ol>` (recursive for nested lists). */
682
+ function serializeList(el, ordered) {
683
+ const lines = [];
684
+ // marked emits start="N" when an ordered list doesn't begin at 1;
685
+ // renumbering from 1 would silently rewrite the document.
686
+ let index = 1;
687
+ if (ordered) {
688
+ const startAttr = (el.getAttribute("start") ?? "").trim();
689
+ if (/^\d{1,9}$/.test(startAttr))
690
+ index = Number(startAttr);
691
+ }
692
+ for (const child of arrayFrom(el.children)) {
693
+ if (child.tagName.toLowerCase() !== "li")
694
+ continue;
695
+ const marker = ordered ? `${index}.` : "-";
696
+ const childIndent = marker.length + 1;
697
+ const itemBody = serializeListItem(child, childIndent);
698
+ // No trailing whitespace on the final line (empty items, trailing
699
+ // hard breaks): the parser strips it, so it can't round-trip.
700
+ let composed = `${marker} ${itemBody}`.replace(/ +$/, "");
701
+ // Chained empty nested lists or a leading hr can compose a line of
702
+ // only dashes and spaces ("- - -", "- ---"), which reparses as a
703
+ // thematic break — drop the item body to its own (indented) line.
704
+ const firstLine = (composed.split("\n", 1)[0] ?? "").trimEnd();
705
+ if (/^[- ]+$/.test(firstLine) &&
706
+ (firstLine.match(/-/g) ?? []).length >= 3) {
707
+ composed = `${marker}\n${itemBody
708
+ .split("\n")
709
+ .map((l) => " ".repeat(childIndent) + l)
710
+ .join("\n")}`;
711
+ }
712
+ lines.push(composed);
713
+ index++;
714
+ }
715
+ return lines.join("\n");
716
+ }
717
+ /** Block-level tags a list item can legally contain in marked's output. */
718
+ const LIST_ITEM_BLOCK_TAGS = new Set([
719
+ "h1",
720
+ "h2",
721
+ "h3",
722
+ "h4",
723
+ "h5",
724
+ "h6",
725
+ "blockquote",
726
+ "pre",
727
+ "table",
728
+ "hr",
729
+ ]);
730
+ /**
731
+ * Serialize an `<li>`. The item body is a sequence of parts — inline
732
+ * runs, nested lists, and block children — joined by single newlines
733
+ * (canonical lists are tight) with continuation lines indented to the
734
+ * item's content column. A leading checkbox input becomes a GFM task
735
+ * marker (`[x] ` / `[ ] `).
736
+ */
737
+ function serializeListItem(li, childIndent) {
738
+ const acc = { parts: [], inline: "" };
739
+ let trimLeading = false;
740
+ const nodes = nonVanishingChildren(li);
741
+ for (let i = 0; i < nodes.length; i++) {
742
+ const node = nodes[i];
743
+ // GFM task-list checkbox (tight items: direct child of the li) is
744
+ // only recognized before any other content.
745
+ const isFirst = acc.parts.length === 0 && acc.inline === "";
746
+ const take = takeInlineToken(node, trimLeading, isFirst);
747
+ if (take) {
748
+ trimLeading = take.trimLeading;
749
+ acc.inline += take.text;
750
+ continue;
751
+ }
752
+ if (node.nodeType === COMMENT_NODE) {
753
+ appendListItemComment(acc, node);
754
+ continue;
755
+ }
756
+ if (node.nodeType !== ELEMENT_NODE)
757
+ continue;
758
+ trimLeading = false;
759
+ appendListItemElement(acc, node, nodes[i + 1]);
760
+ }
761
+ flushListItemInline(acc);
762
+ return indentContinuationLines(acc.parts.join("\n"), childIndent);
763
+ }
764
+ /**
765
+ * Close the accumulating inline run into finished parts. Line-start
766
+ * comments must become their own parts (see `appendListItemComment`);
767
+ * blank lines are dropped — a blank line inside an item would split it
768
+ * into a loose item, which reparses differently.
769
+ */
770
+ function flushListItemInline(acc) {
771
+ if (acc.inline === "")
772
+ return;
773
+ const lifted = liftLineStartComments(normalizeBlockLines(acc.inline));
774
+ for (const block of lifted.split("\n\n")) {
775
+ const kept = block
776
+ .split("\n")
777
+ .filter((line) => line.trim() !== "")
778
+ .join("\n")
779
+ // A hard break can't end a part — the parser strips trailing
780
+ // spaces at the end of a paragraph.
781
+ .replace(/ +$/, "");
782
+ if (kept.trim() !== "")
783
+ acc.parts.push(kept);
784
+ }
785
+ acc.inline = "";
786
+ }
787
+ /**
788
+ * A comment that would *start* a line must own that line: a line
789
+ * starting with `<!--` is one raw HTML block to the end of the line, so
790
+ * any escaped text after it would go through raw and re-escape every
791
+ * trip. Mid-line comments are plain inline HTML.
792
+ */
793
+ function appendListItemComment(acc, node) {
794
+ const comment = `<!--${node.textContent ?? ""}-->`;
795
+ if (acc.inline === "" || acc.inline.endsWith("\n")) {
796
+ flushListItemInline(acc);
797
+ acc.parts.push(comment);
798
+ }
799
+ else {
800
+ acc.inline += comment;
801
+ }
802
+ }
803
+ /** An element child of an `<li>`: nested list, paragraph, block, or inline. */
804
+ function appendListItemElement(acc, child, next) {
805
+ const tag = child.tagName.toLowerCase();
806
+ if (tag === "ul" || tag === "ol") {
807
+ flushListItemInline(acc);
808
+ acc.parts.push(serializeList(child, tag === "ol"));
809
+ }
810
+ else if (tag === "p") {
811
+ // Paragraphs flatten to continuation lines: canonical lists are
812
+ // tight, so loose input converges to tight output.
813
+ const isFirstContent = acc.parts.length === 0 && acc.inline === "";
814
+ if (acc.inline)
815
+ acc.inline += "\n";
816
+ acc.inline += serializeInline(child, false, isFirstContent);
817
+ }
818
+ else if (LIST_ITEM_BLOCK_TAGS.has(tag)) {
819
+ flushListItemInline(acc);
820
+ acc.parts.push(serializeBlockNode(child));
821
+ }
822
+ else {
823
+ // Inline element: same handling as inside any other block
824
+ // (divs are transparent there too).
825
+ acc.inline = serializeInlineElement(acc.inline, child, false, next);
826
+ }
827
+ }
828
+ /**
829
+ * Indent every line after the first (blank lines excepted) to a list
830
+ * item's content column.
831
+ */
832
+ function indentContinuationLines(body, indent) {
833
+ return body
834
+ .split("\n")
835
+ .map((line, idx) => idx === 0 || line === "" ? line : " ".repeat(indent) + line)
836
+ .join("\n");
837
+ }
838
+ /**
839
+ * marked quirk detector: inside link text (and image alt), an escaped
840
+ * `\[…\]` pair followed by `(` re-parses as a nested construct even
841
+ * though the brackets are escaped. Callers fall back to raw tags.
842
+ */
843
+ const LINK_TEXT_QUIRK = /\\\[[\s\S]*?\\\]\(|\\\[\^/;
844
+ /** Raw-HTML anchor fallback for link text markdown can't carry. */
845
+ function rawAnchorTag(el, inner) {
846
+ const href = el.getAttribute("href") ?? "";
847
+ const title = el.getAttribute("title");
848
+ const titleAttr = title === null ? "" : ` title="${escapeHtmlAttr(title)}"`;
849
+ return `<a href="${escapeHtmlAttr(href)}"${titleAttr}>${inner}</a>`;
850
+ }
851
+ function escapeHtmlAttr(value) {
852
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
853
+ }
854
+ /** `<img>` → `![alt](src "title")` (title optional). */
855
+ function serializeImage(el) {
856
+ const src = el.getAttribute("src") ?? "";
857
+ const rawAlt = el.getAttribute("alt") ?? "";
858
+ const alt = rawAlt.replace(/([\\[\]])/g, "\\$1").replace(/\n/g, " ");
859
+ const title = el.getAttribute("title");
860
+ if (LINK_TEXT_QUIRK.test(alt)) {
861
+ const titleAttr = title === null ? "" : ` title="${escapeHtmlAttr(title)}"`;
862
+ return `<img src="${escapeHtmlAttr(src)}" alt="${escapeHtmlAttr(rawAlt)}"${titleAttr}>`;
863
+ }
864
+ const dest = encodeLinkDestination(src);
865
+ return title === null
866
+ ? `![${alt}](${dest})`
867
+ : `![${alt}](${dest} "${escapeLinkTitle(title)}")`;
868
+ }
869
+ /**
870
+ * `<table>` → GFM pipe table. marked emits `<thead>`/`<tbody>` with an
871
+ * `align` attribute per cell; contenteditable tables may use
872
+ * `style="text-align: …"` instead — both are read. Pipes inside cell
873
+ * content are escaped (GFM cell boundaries split on unescaped `|`
874
+ * everywhere, even inside code spans).
875
+ */
876
+ function serializeTable(el) {
877
+ const rows = [];
878
+ const collectRows = (parent) => {
879
+ for (const child of arrayFrom(parent.children)) {
880
+ const tag = child.tagName.toLowerCase();
881
+ if (tag === "tr")
882
+ rows.push(child);
883
+ else if (tag === "thead" || tag === "tbody" || tag === "tfoot") {
884
+ collectRows(child);
885
+ }
886
+ }
887
+ };
888
+ collectRows(el);
889
+ const headerRow = rows[0];
890
+ if (!headerRow)
891
+ return el.outerHTML;
892
+ const headerCells = tableCells(headerRow);
893
+ if (headerCells.length === 0)
894
+ return el.outerHTML;
895
+ const delimiter = headerCells
896
+ .map((cell) => {
897
+ const align = (cell.getAttribute("align") ??
898
+ cell.style?.textAlign ??
899
+ "").toLowerCase();
900
+ if (align === "center")
901
+ return ":---:";
902
+ if (align === "right")
903
+ return "---:";
904
+ if (align === "left")
905
+ return ":---";
906
+ return "---";
907
+ })
908
+ .join(" | ");
909
+ const lines = [];
910
+ lines.push(tableRowLine(headerCells));
911
+ lines.push(`| ${delimiter} |`);
912
+ for (const row of rows.slice(1)) {
913
+ lines.push(tableRowLine(tableCells(row)));
914
+ }
915
+ return lines.join("\n");
916
+ }
917
+ function tableCells(row) {
918
+ return arrayFrom(row.children).filter((cell) => {
919
+ const tag = cell.tagName.toLowerCase();
920
+ return tag === "th" || tag === "td";
921
+ });
922
+ }
923
+ function tableRowLine(cells) {
924
+ const rendered = cells.map((cell) => serializeInline(cell)
925
+ .trim()
926
+ .replace(/[ \t]*\n[ \t]*/g, " ")
927
+ .replace(/\|/g, "\\|"));
928
+ return `| ${rendered.join(" | ")} |`;
929
+ }
930
+ /**
931
+ * Fallback for emphasis that markdown delimiters can't express: emit the
932
+ * element as raw inline tags (all attributes preserved) around the
933
+ * markdown-escaped inner content. (Not `outerHTML` — marked reparses the
934
+ * text between raw inline tags as markdown, so unescaped `*`/`\` text
935
+ * characters would change structure on the next trip. The escaped
936
+ * serialization reparses to the identical DOM.)
937
+ */
938
+ function rawInlineTag(el, inner) {
939
+ const tag = el.tagName.toLowerCase();
940
+ const attrs = (el.getAttributeNames?.() ?? [])
941
+ .map((name) => ` ${name}="${escapeHtmlAttr(el.getAttribute(name) ?? "")}"`)
942
+ .join("");
943
+ return `<${tag}${attrs}>${clampTagInner(inner)}</${tag}>`;
944
+ }
945
+ /** Emphasis wrap with the raw-tag fallback (see tryWrapDelimited). */
946
+ function wrapOrRawTag(el, inner, marker, before, next) {
947
+ return (tryWrapDelimited(inner, marker, before, next) ?? rawInlineTag(el, inner));
948
+ }
949
+ /**
950
+ * Render a code span. The delimiter must be a longer backtick run than
951
+ * any inside the content; content that starts/ends with a backtick (or
952
+ * with spaces on both ends) is padded per CommonMark's stripping rule.
953
+ * Newlines become spaces — the parser does that anyway.
954
+ */
955
+ function serializeCodeSpan(text) {
956
+ const content = text.replace(/\n/g, " ");
957
+ if (content === "")
958
+ return "";
959
+ let maxRun = 0;
960
+ for (const match of content.matchAll(/`+/g)) {
961
+ if (match[0].length > maxRun)
962
+ maxRun = match[0].length;
963
+ }
964
+ const fence = "`".repeat(maxRun + 1);
965
+ const needsPad = content.startsWith("`") ||
966
+ content.endsWith("`") ||
967
+ (content.startsWith(" ") && content.endsWith(" ") && content.trim() !== "");
968
+ const pad = needsPad ? " " : "";
969
+ return fence + pad + content + pad + fence;
970
+ }
971
+ /**
972
+ * A link can serialize as bare autolink text only when marked would
973
+ * re-linkify it identically: text === href (or mailto:text), a
974
+ * conservative URL/email shape, a plain domain, and an end character
975
+ * outside the trailing-punctuation trim set. marked's GFM text rule
976
+ * stops before `https?://` at ANY position, so URLs need no preceding
977
+ * boundary check.
978
+ */
979
+ function isBareAutolinkable(href, text, before) {
980
+ if (href === `mailto:${text}`) {
981
+ // marked only tokenizes an email after a non-local-part character.
982
+ if (/[A-Za-z0-9._+-]$/.test(before))
983
+ return false;
984
+ return /^[A-Za-z0-9._+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(text);
985
+ }
986
+ if (href !== text)
987
+ return false;
988
+ if (!/^https?:\/\/[^\s<>()]+$/.test(text))
989
+ return false;
990
+ if (!/[A-Za-z0-9/]$/.test(text))
991
+ return false;
992
+ // The text must be a fixed point of marked's cleanUrl transform:
993
+ // re-linkifying the bare text sets href = cleanUrl(text), so any
994
+ // character that percent-encodes (unicode, or a DOM-decoded entity
995
+ // that made text === href this trip) would break the self-link
996
+ // equality on the next trip. Such URLs canonicalize to the full
997
+ // [text](encoded-dest) form instead.
998
+ if (markedCleanUrl(text) !== text)
999
+ return false;
1000
+ const domain = text.match(/^https?:\/\/([^/?#:]+)/)?.[1] ?? "";
1001
+ return /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(domain);
1002
+ }
1003
+ /**
1004
+ * marked's `cleanUrl` normalization, verbatim: every link/image
1005
+ * destination it parses goes through this before landing in `href`.
1006
+ */
1007
+ function markedCleanUrl(href) {
1008
+ try {
1009
+ return encodeURI(href).replace(/%25/g, "%");
1010
+ }
1011
+ catch {
1012
+ // lone surrogates etc — marked would drop the link; keep raw.
1013
+ return href;
1014
+ }
1015
+ }
1016
+ /**
1017
+ * …and only end cleanly when followed by whitespace. A next sibling that
1018
+ * starts with anything else would be absorbed into the linkified URL.
1019
+ */
1020
+ /** Inline tags whose emission may start with markdown-active characters. */
1021
+ const ACTIVE_INLINE_TAGS = new Set(["a", "code", "img", "sup", "input"]);
1022
+ const EMPHASIS_TAGS = new Set(["strong", "b", "em", "i", "s", "strike", "del"]);
1023
+ function autolinkBoundaryAfter(next) {
1024
+ if (!next)
1025
+ return true; // end of the inline run — a block boundary follows
1026
+ if (next.nodeType === TEXT_NODE)
1027
+ return /^\s/.test(next.textContent ?? "");
1028
+ // Comments emit `<`, which terminates marked's URL scan cleanly.
1029
+ if (next.nodeType === COMMENT_NODE)
1030
+ return true;
1031
+ if (next.nodeType !== ELEMENT_NODE)
1032
+ return false;
1033
+ const el = next;
1034
+ const tag = el.tagName.toLowerCase();
1035
+ if (tag === "br")
1036
+ return true;
1037
+ if (ACTIVE_INLINE_TAGS.has(tag))
1038
+ return false;
1039
+ // Transparent wrappers contribute whatever their first child emits;
1040
+ // emphasis wrappers hoist leading whitespace *outside* their markers —
1041
+ // peek through both (vanishing wrappers are pre-filtered).
1042
+ if (tag === "div" ||
1043
+ tag === "p" ||
1044
+ tag === "span" ||
1045
+ EMPHASIS_TAGS.has(tag)) {
1046
+ if (tag === "span") {
1047
+ const style = el.getAttribute("style") ?? "";
1048
+ if (/font-weight|font-style|text-decoration/i.test(style)) {
1049
+ return false; // would emit emphasis markers first
1050
+ }
1051
+ }
1052
+ const kids = nonVanishingChildren(el);
1053
+ if (kids.length === 0)
1054
+ return true; // emits raw tags: `<` ends the scan
1055
+ return autolinkBoundaryAfter(kids[0]);
1056
+ }
1057
+ // Unknown elements rebuild as raw tags — `<` terminates the URL scan.
1058
+ return true;
1059
+ }
1060
+ /**
1061
+ * `text!` + `[link](…)` would reparse as an image. Escape a trailing
1062
+ * unescaped `!` before emitting a bracket construct.
1063
+ */
1064
+ function escapeTrailingBang(out) {
1065
+ const match = out.match(/(\\*)!$/);
1066
+ if (match && (match[1] ?? "").length % 2 === 0) {
1067
+ return `${out.slice(0, -1)}\\!`;
1068
+ }
1069
+ return out;
1070
+ }
1071
+ /**
1072
+ * Render a link/image destination. marked normalizes every destination
1073
+ * through `cleanUrl` (encodeURI), so we pre-apply the identical
1074
+ * transform — otherwise a non-ASCII destination comes back
1075
+ * percent-encoded and the second trip differs from the first. What
1076
+ * still contains parens/whitespace after encoding uses the `<…>` form.
1077
+ */
1078
+ function encodeLinkDestination(href) {
1079
+ const encoded = markedCleanUrl(href);
1080
+ if (encoded === "")
1081
+ return "<>";
1082
+ if (/[\s<>()\\]/.test(encoded)) {
1083
+ return `<${encoded.replace(/\n/g, " ").replace(/([<>\\])/g, "\\$1")}>`;
1084
+ }
1085
+ return encoded;
1086
+ }
1087
+ function escapeLinkTitle(title) {
1088
+ return title.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, " ");
1089
+ }
1090
+ function serializeBlockquote(el) {
1091
+ const inner = serializeBlocks(el);
1092
+ return inner
1093
+ .split("\n")
1094
+ .map((line) => (line.length > 0 ? `> ${line}` : ">"))
1095
+ .join("\n");
1096
+ }
1097
+ function serializeCodeBlock(el) {
1098
+ const code = el.querySelector("code");
1099
+ let lang = "";
1100
+ let text;
1101
+ if (!code) {
1102
+ text = el.textContent ?? "";
1103
+ }
1104
+ else {
1105
+ for (const cls of (code.getAttribute("class") ?? "").split(/\s+/)) {
1106
+ if (cls.startsWith("language-")) {
1107
+ // Info strings resolve backslash escapes (so escape ours) and
1108
+ // can't contain backticks or whitespace.
1109
+ lang = cls
1110
+ .slice("language-".length)
1111
+ .replace(/\\/g, "\\\\")
1112
+ .replace(/[`\s]/g, "");
1113
+ break;
1114
+ }
1115
+ }
1116
+ text = code.textContent ?? "";
1117
+ }
1118
+ // marked emits a trailing newline inside <code>; strip exactly one so
1119
+ // the fence doesn't grow a blank line every round trip. Whitespace-only
1120
+ // content doesn't survive marked's fence trimming consistently —
1121
+ // canonicalize it to an empty block.
1122
+ text = text.replace(/\n$/, "");
1123
+ if (text.trim() === "")
1124
+ text = "";
1125
+ // A backtick run at a line start could close the fence early — use a
1126
+ // longer fence than any run the content opens a line with.
1127
+ let fenceLength = 3;
1128
+ for (const match of text.matchAll(/^ {0,3}(`{3,})/gm)) {
1129
+ const runLength = (match[1] ?? "").length;
1130
+ if (runLength >= fenceLength)
1131
+ fenceLength = runLength + 1;
1132
+ }
1133
+ const fence = "`".repeat(fenceLength);
1134
+ if (text === "")
1135
+ return fence + lang + "\n" + fence;
1136
+ return fence + lang + "\n" + text + "\n" + fence;
1137
+ }
1138
+ /**
1139
+ * Emit `[^n]: text` lines for the trailing `<section data-footnotes>`
1140
+ * that marked-footnote produces. Strips the "back to reference" links
1141
+ * that the extension appends.
1142
+ */
1143
+ function serializeFootnotes(el) {
1144
+ const items = [];
1145
+ const ol = el.querySelector("ol");
1146
+ if (!ol)
1147
+ return el.outerHTML;
1148
+ for (const li of arrayFrom(ol.children)) {
1149
+ if (li.tagName.toLowerCase() !== "li")
1150
+ continue;
1151
+ const id = li.id || "";
1152
+ // Match `footnote-<label>` (marked-footnote — labels aren't only
1153
+ // numeric) or `fn-N`/`fnN` (legacy).
1154
+ const idMatch = id.match(/^footnote-([A-Za-z0-9_-]+)$/) ?? id.match(/^fn[-:]?(\d+)$/);
1155
+ const label = idMatch?.[1] ?? String(items.length + 1);
1156
+ const clone = li.cloneNode(true);
1157
+ for (const back of arrayFrom(clone.querySelectorAll("[data-footnote-backref], [data-footnote-back-ref], .footnote-back, .footnote-backref"))) {
1158
+ back.remove();
1159
+ }
1160
+ // marked-footnote wraps definition paragraphs in `<p>`s. Unwrap
1161
+ // them so `serializeInline` doesn't emit raw `<p>` tags; multiple
1162
+ // paragraphs become 4-space-indented continuation paragraphs.
1163
+ let text;
1164
+ const children = arrayFrom(clone.children).filter((c) => c.tagName.toLowerCase() !== "br");
1165
+ if (children.length >= 1 &&
1166
+ children.every((c) => c.tagName.toLowerCase() === "p")) {
1167
+ const paragraphs = children
1168
+ .map((p) => serializeInline(p).trim())
1169
+ .filter((s) => s !== "");
1170
+ text = indentContinuationLines(paragraphs.join("\n\n"), 4);
1171
+ }
1172
+ else {
1173
+ text = serializeInline(clone).trim();
1174
+ }
1175
+ items.push(`[^${label}]: ${text}`);
1176
+ }
1177
+ return items.join("\n");
1178
+ }
1179
+ /**
1180
+ * Escape the minimum set of markdown special characters. We
1181
+ * intentionally under-escape rather than over-escape:
1182
+ * - `\` — in the class, and handled first, so it doesn't double-escape
1183
+ * our own escapes.
1184
+ * - `*` and `_` — emphasis markers.
1185
+ * - `~` — GFM strikethrough (a single tilde pair delimits too).
1186
+ * - `[` and `]` — link markers.
1187
+ * - `` ` `` — code marker.
1188
+ * - `<` — only when it could open a tag, autolink, or comment
1189
+ * (followed by a letter, `/`, `!`, or `?`).
1190
+ * - `&` — only when it reads as a character entity (`&copy;`, `&#38;`),
1191
+ * which the DOM would decode on the next trip.
1192
+ *
1193
+ * We do NOT escape `#`, `-`, `+`, `>`, `|`, digits because those only
1194
+ * matter at a line start — `escapeLineStart` handles that positionally.
1195
+ * Escaping them everywhere would produce ugly output like `\-` in the
1196
+ * middle of hyphenated words.
1197
+ */
1198
+ function escapeMarkdownText(text) {
1199
+ return (text
1200
+ // `<` is escaped unconditionally: a `<` at a text-node edge can
1201
+ // combine with whatever the *next* node emits (or break marked's
1202
+ // emphasis scanning), and `\<` is stable in every context.
1203
+ .replace(/([\\`*_[\]~<])/g, "\\$1")
1204
+ .replace(/&(?=[a-zA-Z][a-zA-Z0-9]{1,31};|#\d{1,7};|#[xX][0-9a-fA-F]{1,6};)/g, "\\&")
1205
+ // Defuse GFM extended autolinks: a bare URL/email in *text* (e.g.
1206
+ // from raw HTML-block content) would come back as a link element.
1207
+ // An escaped scheme colon / www dot / at-sign resolves to the same
1208
+ // text but never linkifies. No word-boundary guard: GFM links
1209
+ // after `_`/`*`/`~`/`(` too, which our escapes can put adjacent.
1210
+ // (Anchor elements don't take this path — real self-links
1211
+ // serialize as bare autolinks upstream.)
1212
+ .replace(/(https?)(?=:\/\/)/gi, "$1\\")
1213
+ .replace(/(www)(?=\.[A-Za-z0-9])/gi, "$1\\")
1214
+ .replace(/[A-Za-z0-9.!#$%&'*+/=?^_`{|}~\\-]+(@)(?=[A-Za-z0-9-]+\.[A-Za-z0-9])/g, (match) => match.replace(/@$/, "\\@")));
1215
+ }
1216
+ /** Array.from over the minimal ArrayLike the adapter types expose. */
1217
+ function arrayFrom(list) {
1218
+ return Array.from(list);
1219
+ }