@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 +21 -0
- package/README.md +177 -0
- package/dist/adapter.d.ts +78 -0
- package/dist/adapter.js +48 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +30 -0
- package/dist/parse.d.ts +17 -0
- package/dist/parse.js +32 -0
- package/dist/serialize-emphasis.d.ts +23 -0
- package/dist/serialize-emphasis.js +186 -0
- package/dist/serialize-footnote-defs.d.ts +15 -0
- package/dist/serialize-footnote-defs.js +106 -0
- package/dist/serialize.d.ts +26 -0
- package/dist/serialize.js +1219 -0
- package/package.json +74 -0
|
@@ -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, "&").replace(/"/g, """);
|
|
853
|
+
}
|
|
854
|
+
/** `<img>` → `` (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
|
+
? ``
|
|
867
|
+
: `}")`;
|
|
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 (`©`, `&`),
|
|
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
|
+
}
|