autumnnote 2.0.0 → 2.2.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.
Files changed (64) hide show
  1. package/README.md +72 -5
  2. package/dist/autumnnote.cjs +20 -20
  3. package/dist/autumnnote.css +1 -1
  4. package/dist/autumnnote.es.js +661 -798
  5. package/dist/autumnnote.es.js.map +1 -1
  6. package/dist/autumnnote.min.js +20 -20
  7. package/dist/autumnnote.umd.js +20 -20
  8. package/dist/autumnnote.umd.js.map +1 -1
  9. package/dist/icon-data-V0Xqv-wX.js +255 -0
  10. package/dist/icon-data-V0Xqv-wX.js.map +1 -0
  11. package/package.json +13 -4
  12. package/types/index.d.ts +8 -0
  13. package/src/js/Context.js +0 -854
  14. package/src/js/core/detectLang.js +0 -98
  15. package/src/js/core/dom.js +0 -372
  16. package/src/js/core/env.js +0 -25
  17. package/src/js/core/key.js +0 -66
  18. package/src/js/core/lists.js +0 -121
  19. package/src/js/core/markdown.js +0 -695
  20. package/src/js/core/range.js +0 -194
  21. package/src/js/core/sanitise.js +0 -231
  22. package/src/js/editing/History.js +0 -266
  23. package/src/js/editing/Style.js +0 -812
  24. package/src/js/editing/Table.js +0 -105
  25. package/src/js/editing/Typing.js +0 -397
  26. package/src/js/index.js +0 -193
  27. package/src/js/index.umd.js +0 -17
  28. package/src/js/module/AutoSaveRestore.js +0 -125
  29. package/src/js/module/BaseDialog.js +0 -133
  30. package/src/js/module/BaseMediaTooltip.js +0 -142
  31. package/src/js/module/BaseResizer.js +0 -312
  32. package/src/js/module/BubbleToolbar.js +0 -483
  33. package/src/js/module/Buttons.js +0 -399
  34. package/src/js/module/Clipboard.js +0 -579
  35. package/src/js/module/CodeTooltip.js +0 -493
  36. package/src/js/module/Codeview.js +0 -125
  37. package/src/js/module/ContextMenu.js +0 -621
  38. package/src/js/module/Editor.js +0 -747
  39. package/src/js/module/EmojiDialog.js +0 -254
  40. package/src/js/module/FindReplace.js +0 -512
  41. package/src/js/module/Fullscreen.js +0 -80
  42. package/src/js/module/IconDialog.js +0 -618
  43. package/src/js/module/ImageCropOverlay.js +0 -586
  44. package/src/js/module/ImageDialog.js +0 -193
  45. package/src/js/module/ImageResizer.js +0 -42
  46. package/src/js/module/ImageTooltip.js +0 -285
  47. package/src/js/module/LinkDialog.js +0 -145
  48. package/src/js/module/LinkTooltip.js +0 -250
  49. package/src/js/module/MarkdownShortcuts.js +0 -250
  50. package/src/js/module/Mention.js +0 -365
  51. package/src/js/module/Placeholder.js +0 -51
  52. package/src/js/module/ShortcutsDialog.js +0 -111
  53. package/src/js/module/SlashMenu.js +0 -376
  54. package/src/js/module/Statusbar.js +0 -246
  55. package/src/js/module/TableTooltip.js +0 -1521
  56. package/src/js/module/Toolbar.js +0 -750
  57. package/src/js/module/VideoDialog.js +0 -193
  58. package/src/js/module/VideoResizer.js +0 -66
  59. package/src/js/module/VideoTooltip.js +0 -248
  60. package/src/js/module/emoji-data.js +0 -496
  61. package/src/js/renderer.js +0 -120
  62. package/src/js/settings.js +0 -214
  63. package/src/styles/_variables.scss +0 -48
  64. package/src/styles/autumnnote.scss +0 -2866
@@ -1,695 +0,0 @@
1
- /**
2
- * markdown.js - Lightweight Markdown → HTML converter for paste handling.
3
- *
4
- * Handles: headings H1–H6, bold/italic/strikethrough/inline-code, fenced code
5
- * blocks (with language), blockquotes, unordered/ordered lists, horizontal
6
- * rules, links, images, and plain paragraphs.
7
- *
8
- * The HTML output MUST be passed through sanitiseHTML() before insertion.
9
- */
10
-
11
- /**
12
- * Converts an HTML string to Markdown.
13
- * Handles: headings, paragraphs, bold/italic/del/code, links, images,
14
- * unordered/ordered lists, blockquote, pre/code blocks, tables, hr.
15
- * @param {string} html
16
- * @returns {string}
17
- */
18
- export function htmlToMarkdown(html) {
19
- const doc = new DOMParser().parseFromString(`<body>${html || ''}</body>`, 'text/html');
20
- return _domToMd(doc.body).replace(/\n{3,}/g, '\n\n').trim();
21
- }
22
-
23
- /**
24
- * Convert a DOM node subtree into Markdown.
25
- *
26
- * Recursively produces a Markdown string representing the given DOM node and its descendants,
27
- * handling common HTML constructs such as paragraphs, headings, lists (with nested indentation),
28
- * blockquotes, fenced and inline code, links, images, tables, horizontal rules, and basic inline emphasis.
29
- *
30
- * @param {Node} node - The DOM node to convert.
31
- * @param {number} [depth=0] - Current nesting depth used to indent nested list items.
32
- * @returns {string} The Markdown representation of the node subtree.
33
- */
34
- /**
35
- * Direct child elements matching a tag name. Used instead of the CSS
36
- * `:scope > tag` combinator, which this project's jsdom version resolves
37
- * incorrectly (matches descendants at any depth, not just direct children).
38
- * @param {Element} el
39
- * @param {string} tagName
40
- * @returns {Element[]}
41
- */
42
- function _directChildren(el, tagName) {
43
- return Array.from(el.children).filter((c) => c.tagName === tagName.toUpperCase());
44
- }
45
-
46
- function _domToMd(node, depth = 0) {
47
- if (node.nodeType === 3) {
48
- return node.textContent.replace(/\s+/g, ' ');
49
- }
50
- if (node.nodeType !== 1) return '';
51
-
52
- const el = /** @type {Element} */ (node);
53
- const tag = el.nodeName.toLowerCase();
54
- const inner = () => Array.from(el.childNodes).map(n => _domToMd(n, depth)).join('');
55
-
56
- switch (tag) {
57
- case 'p':
58
- case 'div': return `\n\n${inner()}\n\n`;
59
- case 'br': return ' \n';
60
- case 'h1': return `\n\n# ${inner()}\n\n`;
61
- case 'h2': return `\n\n## ${inner()}\n\n`;
62
- case 'h3': return `\n\n### ${inner()}\n\n`;
63
- case 'h4': return `\n\n#### ${inner()}\n\n`;
64
- case 'h5': return `\n\n##### ${inner()}\n\n`;
65
- case 'h6': return `\n\n###### ${inner()}\n\n`;
66
- case 'strong':
67
- case 'b': return `**${inner()}**`;
68
- case 'em':
69
- case 'i': return `*${inner()}*`;
70
- case 'del':
71
- case 's':
72
- case 'strike': return `~~${inner()}~~`;
73
- case 'sup': return `^${inner()}^`;
74
- case 'sub': return `~${inner()}~`;
75
- case 'u': return `<u>${inner()}</u>`;
76
- case 'span': {
77
- // Markdown has no native underline/color/size syntax; pass through as
78
- // raw inline HTML for the specific styles the editor's own toolbar
79
- // creates (foreColor/backColor/fontSize) — other noise spans (e.g. from
80
- // pasted content) are unwrapped to plain text as before.
81
- const style = el.getAttribute('style') || '';
82
- if (/\b(color|background-color|font-size)\s*:/.test(style)) {
83
- return `<span style="${_escAttr(style)}">${inner()}</span>`;
84
- }
85
- return inner();
86
- }
87
- case 'code': {
88
- // Inside <pre> we emit raw text; outside we wrap in backticks
89
- if (el.closest('pre')) return inner();
90
- return `\`${inner()}\``;
91
- }
92
- case 'pre': {
93
- const codeEl = el.querySelector('code');
94
- const langMatch = /language-(\S+)/.exec(codeEl?.className || '');
95
- const lang = langMatch ? langMatch[1] : '';
96
- const content = (codeEl || el).textContent || '';
97
- return `\n\n\`\`\`${lang}\n${content}\n\`\`\`\n\n`;
98
- }
99
- case 'blockquote': {
100
- const rawLines = inner().trim().split('\n');
101
- // Collapse consecutive blank lines (from adjacent <p> blocks) into one.
102
- const lines = rawLines.filter((l, idx) => l.trim() !== '' || (rawLines[idx - 1] ?? '').trim() !== '');
103
- return `\n\n${lines.map((l) => (l.trim() === '' ? '>' : `> ${l}`)).join('\n')}\n\n`;
104
- }
105
- case 'a': {
106
- const href = el.getAttribute('href') || '';
107
- return `[${inner()}](${href})`;
108
- }
109
- case 'img': {
110
- const src = el.getAttribute('src') || '';
111
- const alt = el.getAttribute('alt') || '';
112
- return `![${alt}](${src})`;
113
- }
114
- case 'ul': {
115
- const items = _directChildren(el, 'li');
116
- if (!items.length) return inner();
117
- const indent = ' '.repeat(depth);
118
- const isChecklist = el.classList.contains('an-checklist');
119
- const lines = items.map((li) => {
120
- const cb = /** @type {HTMLInputElement | undefined} */ (
121
- _directChildren(li, 'input').find((c) => c.getAttribute('type') === 'checkbox')
122
- );
123
- let prefix = '- ';
124
- if (isChecklist || cb) {
125
- const checked = cb ? cb.checked : false;
126
- prefix = checked ? '- [x] ' : '- [ ] ';
127
- }
128
- return `${indent}${prefix}${_domToMd(li, depth + 1).trim()}`;
129
- }).join('\n');
130
- return depth === 0 ? `\n\n${lines}\n\n` : `\n${lines}`;
131
- }
132
- case 'ol': {
133
- const items = _directChildren(el, 'li');
134
- if (!items.length) return inner();
135
- const indent = ' '.repeat(depth);
136
- const lines = items.map((li, i) => `${indent}${i + 1}. ${_domToMd(li, depth + 1).trim()}`).join('\n');
137
- return depth === 0 ? `\n\n${lines}\n\n` : `\n${lines}`;
138
- }
139
- case 'li': return inner();
140
- case 'hr': return '\n\n---\n\n';
141
- case 'table': {
142
- const allRows = Array.from(el.querySelectorAll('tr'));
143
- if (!allRows.length) return inner();
144
- const theadEl = _directChildren(el, 'thead')[0];
145
- const firstRowIsHeader = !!theadEl || (
146
- allRows[0].children.length > 0 &&
147
- Array.from(allRows[0].children).every((c) => c.tagName === 'TH')
148
- );
149
- const cellTexts = allRows.map((tr) =>
150
- Array.from(tr.querySelectorAll('th, td')).map((c) => c.textContent.trim().replaceAll('|', String.raw`\|`)),
151
- );
152
- const cols = Math.max(...cellTexts.map((r) => r.length));
153
- const padRow = (row) => { const r = [...row]; while (r.length < cols) r.push(''); return r; };
154
- const bodyStart = firstRowIsHeader ? 1 : 0;
155
- const headerCells = firstRowIsHeader ? padRow(cellTexts[0]) : new Array(cols).fill('');
156
- let md = '\n\n';
157
- md += `| ${headerCells.join(' | ')} |\n`;
158
- md += `| ${new Array(cols).fill('---').join(' | ')} |\n`;
159
- for (let r = bodyStart; r < cellTexts.length; r++) {
160
- md += `| ${padRow(cellTexts[r]).join(' | ')} |\n`;
161
- }
162
- return md + '\n';
163
- }
164
- default: return inner();
165
- }
166
- }
167
-
168
- /**
169
- * Detects whether a string likely contains Markdown syntax.
170
- *
171
- * Checks for common Markdown constructs such as ATX headings, unordered or
172
- * ordered list items, blockquotes, fenced code blocks, and bold emphasis.
173
- * @param {string} text - Input text to inspect for Markdown patterns.
174
- * @returns {boolean} `true` if any Markdown-like pattern is present, `false` otherwise.
175
- */
176
- export function isMarkdown(text) {
177
- return /^#{1,6} [^\s]|^[ \t]*[-*+] [^\s]|^[ \t]*\d+\. [^\s]|^> ?[^\s]|^```|^\*{2}[^*\n]+\*{2}/m.test(text)
178
- || /^.+\n=+\s*$/m.test(text)
179
- || /^.+\n-{2,}\s*$/m.test(text)
180
- || /^---\s*\n(?:[\s\S]*?\n)?(?:---|\.\.\.)\s*(?:\n|$)/.test(text)
181
- || /^\|.+\|[ \t]*\n\|[ \t:|-]+\|/m.test(text);
182
- }
183
-
184
- // Blockquote line: optional up-to-3 leading spaces, '>', optional single space, rest of line.
185
- const BQ_RE = /^ {0,3}>( ?)(.*)$/;
186
- // Horizontal rule: 3+ of the same character (-, * or _), optionally space-separated.
187
- const HR_RE = /^ {0,3}([-*_])( *\1){2,}\s*$/;
188
- // Hard-break marker — placed between paragraph lines that end in a
189
- // CommonMark hard-break (trailing 2+ spaces or a trailing backslash),
190
- // restored to <br> after _inline() runs. Distinct from _inline()'s own MARK.
191
- const HARD_BREAK = String.fromCharCode(1);
192
-
193
- /**
194
- * Converts a Markdown string to an HTML string.
195
- * @param {string} text
196
- * @returns {string}
197
- */
198
- export function markdownToHTML(text) {
199
- let lines = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n');
200
- lines = _stripFrontmatter(lines);
201
- const refs = _extractReferenceDefinitions(lines);
202
- lines = refs.clean;
203
- _linkDefs = refs.linkDefs;
204
- _footnoteIds = refs.footnoteIds;
205
- return _parseBlocks(lines);
206
- }
207
-
208
- /**
209
- * Parses a line array into block-level HTML. Called recursively for content
210
- * nested inside a blockquote so nested quotes and block content (lists,
211
- * headings, etc.) inside `>` are parsed the same as top-level content.
212
- * @param {string[]} lines
213
- * @returns {string}
214
- */
215
- function _parseBlocks(lines) {
216
- const out = [];
217
- let i = 0;
218
-
219
- while (i < lines.length) {
220
- const line = lines[i];
221
-
222
- // ---- Fenced code block ``` lang ... ``` -----------------------------------
223
- const fenceMatch = /^```(\S*)$/.exec(line);
224
- if (fenceMatch) {
225
- const lang = fenceMatch[1];
226
- const codeLines = [];
227
- i++;
228
- while (i < lines.length && !lines[i].startsWith('```')) {
229
- codeLines.push(_esc(lines[i]));
230
- i++;
231
- }
232
- const langAttr = lang ? ` class="language-${_escAttr(lang)}"` : '';
233
- out.push(`<pre><code${langAttr}>${codeLines.join('\n')}</code></pre>`);
234
- i++; // skip closing ```
235
- continue;
236
- }
237
-
238
- // ---- Setext headings (Title\n=== or Title\n---) -------------------------
239
- if (line.trim() && !HR_RE.test(line) && !/^#{1,6} /.test(line) && i + 1 < lines.length) {
240
- if (/^=+\s*$/.test(lines[i + 1])) {
241
- out.push(`<h1>${_inline(line.trim())}</h1>`);
242
- i += 2;
243
- continue;
244
- }
245
- if (/^-{2,}\s*$/.test(lines[i + 1])) {
246
- out.push(`<h2>${_inline(line.trim())}</h2>`);
247
- i += 2;
248
- continue;
249
- }
250
- }
251
-
252
- // ---- Horizontal rule --- / *** / ___ / - - - / * * * ----------------------
253
- if (HR_RE.test(line)) {
254
- out.push('<hr>');
255
- i++;
256
- continue;
257
- }
258
-
259
- // ---- ATX Headings # – ###### -------------------------------------------
260
- const hMatch = /^(#{1,6})\s+(.+)$/.exec(line);
261
- if (hMatch) {
262
- const level = hMatch[1].length;
263
- // Strip an optional closing sequence of #'s (e.g. "## Heading ##"),
264
- // only when preceded by whitespace — "Heading#" (no space) is untouched.
265
- const content = hMatch[2].replace(/(?:^|\s)#+\s*$/, '');
266
- out.push(`<h${level}>${_inline(content)}</h${level}>`);
267
- i++;
268
- continue;
269
- }
270
-
271
- // ---- Blockquote > text --------------------------------------------------
272
- if (BQ_RE.test(line)) {
273
- const bqLines = [];
274
- while (i < lines.length && BQ_RE.test(lines[i])) {
275
- bqLines.push(BQ_RE.exec(lines[i])[2]);
276
- i++;
277
- }
278
- out.push(`<blockquote>${_parseBlocks(bqLines)}</blockquote>`);
279
- continue;
280
- }
281
-
282
- // ---- Checklist or Unordered list - / * / + item ----------------------
283
- if (/^[-*+] /.test(line)) {
284
- const { html: listHtml, endIdx } = _parseListBlock(lines, i);
285
- out.push(listHtml); i = endIdx; continue;
286
- }
287
-
288
- // ---- Ordered list 1. item ----------------------------------------------
289
- if (/^\d+\. /.test(line)) {
290
- const { html: listHtml, endIdx } = _parseListBlock(lines, i);
291
- out.push(listHtml); i = endIdx; continue;
292
- }
293
-
294
- // ---- Blank line ----------------------------------------------------------
295
- if (line.trim() === '') {
296
- i++;
297
- continue;
298
- }
299
-
300
- // ---- GFM Table | col | col | -------------------------------------------
301
- // A table starts with a pipe-prefixed or pipe-containing line followed by
302
- // a separator row (| --- | --- |). We detect and collect all rows.
303
- if (/^\|.+\|/.test(line) && i + 1 < lines.length && /^\|[\s|:-]+\|/.test(lines[i + 1])) {
304
- const headerCells = _parseTableRow(line);
305
- const alignments = _parseTableRow(lines[i + 1]).map((c) => {
306
- if (c.startsWith(':') && c.endsWith(':')) return 'center';
307
- if (c.endsWith(':')) return 'right';
308
- if (c.startsWith(':')) return 'left';
309
- return null;
310
- });
311
- i += 2; // skip header + separator
312
- const bodyRows = [];
313
- while (i < lines.length && /^\|.+\|/.test(lines[i])) {
314
- bodyRows.push(_parseTableRow(lines[i]));
315
- i++;
316
- }
317
- const _cell = (tag, content, align) => {
318
- const s = align ? ` style="text-align:${align}"` : '';
319
- return `<${tag}${s}>${_inline(content)}</${tag}>`;
320
- };
321
- const thCells = headerCells.map((c, idx) => _cell('th', c, alignments[idx])).join('');
322
- const thead = `<thead><tr>${thCells}</tr></thead>`;
323
- const renderRow = (row) => `<tr>${row.map((c, idx) => _cell('td', c, alignments[idx])).join('')}</tr>`;
324
- const tbody = bodyRows.length ? `<tbody>${bodyRows.map(renderRow).join('')}</tbody>` : '';
325
- out.push(`<table>${thead}${tbody}</table>`);
326
- continue;
327
- }
328
-
329
- // ---- Paragraph: collect consecutive non-block lines ---------------------
330
- const paraLines = [];
331
- while (
332
- i < lines.length &&
333
- lines[i].trim() !== '' &&
334
- !/^(#{1,6} |[-*+] |\d+\. |```)/.test(lines[i]) &&
335
- !BQ_RE.test(lines[i]) &&
336
- !HR_RE.test(lines[i]) &&
337
- !/^\|.+\|/.test(lines[i]) &&
338
- !(i + 1 < lines.length && /^=+\s*$/.test(lines[i + 1])) &&
339
- !(i + 1 < lines.length && /^-{2,}\s*$/.test(lines[i + 1]))
340
- ) {
341
- paraLines.push(lines[i]);
342
- i++;
343
- }
344
- if (paraLines.length) {
345
- out.push(`<p>${_inline(_joinParagraphLines(paraLines)).replaceAll(HARD_BREAK, '<br>')}</p>`);
346
- }
347
- }
348
-
349
- return out.join('');
350
- }
351
-
352
- // ---------------------------------------------------------------------------
353
- // Inline formatting
354
- // ---------------------------------------------------------------------------
355
-
356
- /** Reference-link and footnote definitions collected per markdownToHTML() call. */
357
- let _linkDefs = new Map();
358
- let _footnoteIds = new Set();
359
-
360
- /**
361
- * Strips a leading YAML frontmatter block (--- ... --- or --- ... ...) from
362
- * the line array, only when it is the very first line and the enclosed body
363
- * looks like YAML (key: value / list items / indented continuations) — this
364
- * disambiguates real frontmatter from a horizontal rule followed by prose.
365
- * @param {string[]} lines
366
- * @returns {string[]}
367
- */
368
- function _stripFrontmatter(lines) {
369
- if ((lines[0] || '').trim() !== '---') return lines;
370
- let closeIdx = -1;
371
- for (let j = 1; j < lines.length; j++) {
372
- const t = lines[j].trim();
373
- if (t === '---' || t === '...') { closeIdx = j; break; }
374
- }
375
- if (closeIdx === -1) return lines;
376
-
377
- const body = lines.slice(1, closeIdx);
378
- const looksLikeYAML = body.every((l) =>
379
- l.trim() === '' ||
380
- /^[ \t]*[\w$.-]+\s*:(\s|$)/.test(l) ||
381
- /^[ \t]*-\s+\S/.test(l) ||
382
- /^[ \t]+\S/.test(l));
383
- if (!looksLikeYAML) return lines;
384
-
385
- let start = closeIdx + 1;
386
- if (lines[start] !== undefined && lines[start].trim() === '') start++;
387
- return lines.slice(start);
388
- }
389
-
390
- /**
391
- * Extracts GFM reference-link definitions (`[ref]: url "title"`) and footnote
392
- * definitions (`[^id]: text`) from the line array, skipping fenced code
393
- * regions. Returns the definition-free line array plus lookup maps.
394
- * @param {string[]} lines
395
- * @returns {{ clean: string[], linkDefs: Map<string, {href: string, title?: string}>, footnoteIds: Set<string> }}
396
- */
397
- function _extractReferenceDefinitions(lines) {
398
- const linkDefs = new Map();
399
- const footnoteIds = new Set();
400
- const clean = [];
401
- let inFence = false;
402
- const linkDefRe = /^\[([^\]]+)\]:\s*(\S+)(?:\s+"([^"]*)")?\s*$/;
403
- const footnoteDefRe = /^\[\^([^\]]+)\]:\s*(.+)$/;
404
-
405
- for (const line of lines) {
406
- if (/^```/.test(line)) { inFence = !inFence; clean.push(line); continue; }
407
- if (!inFence) {
408
- const fm = footnoteDefRe.exec(line);
409
- if (fm) { footnoteIds.add(fm[1]); continue; }
410
- const lm = linkDefRe.exec(line);
411
- if (lm) { linkDefs.set(lm[1].trim().toLowerCase(), { href: lm[2], title: lm[3] }); continue; }
412
- }
413
- clean.push(line);
414
- }
415
- return { clean, linkDefs, footnoteIds };
416
- }
417
-
418
- /**
419
- * Joins a paragraph's source lines into one string, converting CommonMark
420
- * hard-break markers (a trailing backslash, or 2+ trailing spaces) on all
421
- * but the last line into a HARD_BREAK placeholder instead of a plain space.
422
- * @param {string[]} paraLines
423
- * @returns {string}
424
- */
425
- function _joinParagraphLines(paraLines) {
426
- let joined = '';
427
- for (let idx = 0; idx < paraLines.length; idx++) {
428
- const isLast = idx === paraLines.length - 1;
429
- const ln = paraLines[idx];
430
- if (!isLast && /\\$/.test(ln)) { joined += ln.replace(/\\$/, '') + HARD_BREAK; continue; }
431
- if (!isLast && / {2,}$/.test(ln)) { joined += ln.replace(/ {2,}$/, '') + HARD_BREAK; continue; }
432
- joined += ln + (isLast ? '' : ' ');
433
- }
434
- return joined;
435
- }
436
-
437
- /**
438
- * Splits a GFM table row string into trimmed cell strings, treating an
439
- * escaped pipe (`\|`) as a literal character rather than a cell separator.
440
- * '| a | b | c |' → ['a', 'b', 'c']; '| a\|b | c |' → ['a|b', 'c']
441
- * @param {string} row
442
- * @returns {string[]}
443
- */
444
- function _parseTableRow(row) {
445
- const trimmed = row.replace(/^\|/, '').replace(/\|$/, '');
446
- const cells = [];
447
- let cur = '';
448
- for (let i = 0; i < trimmed.length; i++) {
449
- if (trimmed[i] === '\\' && trimmed[i + 1] === '|') { cur += '|'; i++; continue; }
450
- if (trimmed[i] === '|') { cells.push(cur); cur = ''; continue; }
451
- cur += trimmed[i];
452
- }
453
- cells.push(cur);
454
- return cells.map((c) => c.trim());
455
- }
456
-
457
- function _parseListBlock(lines, startIdx) {
458
- const baseIndent = (lines[startIdx].match(/^(\s*)/)[1]).length;
459
- const isOL = /^\s*\d+\. /.test(lines[startIdx]);
460
- const items = [];
461
- let firstIsCB = null;
462
- let loose = false;
463
- let pendingBlank = false;
464
- let i = startIdx;
465
-
466
- while (i < lines.length) {
467
- const line = lines[i];
468
-
469
- if (line.trim() === '') {
470
- // A blank line only ends the list if what follows isn't a continuation
471
- // of it (another item at the same marker/indent, or indented text
472
- // belonging to the current item) — otherwise it marks a "loose" list.
473
- const next = lines[i + 1];
474
- const nextIndent = next !== undefined ? (next.match(/^(\s*)/)[1]).length : -1;
475
- const nextIsSameItem = next !== undefined &&
476
- /^\s*(?:[-*+]|\d+\.) /.test(next) &&
477
- (/^\s*\d+\. /.test(next) === isOL) &&
478
- nextIndent === baseIndent;
479
- const nextIsContinuation = next !== undefined && next.trim() !== '' && nextIndent > baseIndent;
480
- if (!items.length || (!nextIsSameItem && !nextIsContinuation)) break;
481
- loose = true;
482
- pendingBlank = true;
483
- i++;
484
- continue;
485
- }
486
-
487
- const indent = (line.match(/^(\s*)/)[1]).length;
488
- if (indent < baseIndent) break;
489
-
490
- if (indent === baseIndent) {
491
- if (!/^\s*(?:[-*+]|\d+\.) /.test(line)) break;
492
- if (/^\s*\d+\. /.test(line) !== isOL) break;
493
- const raw = isOL ? line.replace(/^\s*\d+\. /, '') : line.replace(/^\s*[-*+] /, '');
494
- // Checklists are intentionally UL-only: sanitise.js's checkbox guard,
495
- // the injected checklist CSS, and every checklist-toggle command are
496
- // all hardcoded to `ul.an-checklist` with no `ol` equivalent, so an
497
- // ordered-list checkbox would be stripped by the sanitiser and get no
498
- // styling even if parsed here — "1. [ ] item" intentionally stays plain.
499
- const isCB = !isOL && /^\[[ xX]\]\s+/.test(raw);
500
- if (firstIsCB === null) firstIsCB = isCB;
501
- if (isCB !== firstIsCB) break;
502
- const checked = isCB && raw[1].toLowerCase() === 'x';
503
- const text = isCB ? raw.replace(/^\[[ xX]\]\s+/, '') : raw;
504
- items.push({ paras: [text], isCB, checked, sub: '' });
505
- pendingBlank = false;
506
- i++;
507
- } else {
508
- if (!items.length) { i++; continue; }
509
- if (/^\s*(?:[-*+]|\d+\.) /.test(line)) {
510
- const nested = _parseListBlock(lines, i);
511
- items[items.length - 1].sub += nested.html;
512
- i = nested.endIdx;
513
- pendingBlank = false;
514
- } else if (pendingBlank) {
515
- items[items.length - 1].paras.push(line.trim());
516
- pendingBlank = false;
517
- i++;
518
- } else {
519
- const paras = items[items.length - 1].paras;
520
- paras[paras.length - 1] += ' ' + line.trim();
521
- i++;
522
- }
523
- }
524
- }
525
-
526
- const hasCB = !isOL && (firstIsCB === true);
527
- const startMatch = isOL ? /^\s*(\d+)\. /.exec(lines[startIdx]) : null;
528
- const startNum = startMatch ? Number.parseInt(startMatch[1], 10) : 1;
529
- const open = isOL
530
- ? (startNum !== 1 ? `<ol start="${startNum}">` : '<ol>')
531
- : (hasCB ? '<ul class="an-checklist">' : '<ul>');
532
- const close = isOL ? '</ol>' : '</ul>';
533
- const liHTML = items.map(({ paras, isCB, checked, sub }) => {
534
- const cbHTML = isCB
535
- ? `<input type="checkbox" contenteditable="false"${checked ? ' checked' : ''}>`
536
- : '';
537
- const body = loose
538
- ? paras.map((p, idx) => `<p>${idx === 0 ? cbHTML : ''}${_inline(p)}</p>`).join('')
539
- : `${cbHTML}${_inline(paras[0])}`;
540
- return `<li>${body}${sub}</li>`;
541
- }).join('');
542
- return { html: `${open}${liHTML}${close}`, endIdx: i };
543
- }
544
-
545
- // Backslash-escapable inline punctuation (CommonMark-ish, narrowed to the
546
- // syntax characters this converter actually uses).
547
- const ESCAPABLE_RE = /\\([*_`#[\]()>\\~|])/g;
548
- // Placeholder marker for escaped literals — a NUL character can't appear in
549
- // real markdown text, so it's safe as a delimiter. Built at runtime (not
550
- // written as a literal escape) to avoid embedding a raw NUL byte in this file.
551
- const MARK = String.fromCharCode(0);
552
-
553
- /**
554
- * Step 0 of _inline(): replaces backslash-escaped punctuation with inert
555
- * placeholders so later syntax regexes can't match them.
556
- * @param {string} text
557
- * @returns {{ text: string, literals: string[] }}
558
- */
559
- function _extractBackslashEscapes(text) {
560
- const literals = [];
561
- const replaced = text.replace(ESCAPABLE_RE, (_, ch) => {
562
- literals.push(ch);
563
- return `${MARK}${literals.length - 1}${MARK}`;
564
- });
565
- return { text: replaced, literals };
566
- }
567
-
568
- /**
569
- * Restores placeholders from _extractBackslashEscapes(), HTML-escaping each
570
- * literal since it's inserted directly into the output.
571
- * @param {string} text
572
- * @param {string[]} literals
573
- * @returns {string}
574
- */
575
- function _restoreBackslashEscapes(text, literals) {
576
- return text.replace(new RegExp(`${MARK}(\\d+)${MARK}`, 'g'), (_, idx) => _esc(literals[Number(idx)]));
577
- }
578
-
579
- /**
580
- * Resolves images, inline links, GFM reference-style links (explicit,
581
- * shortcut, and bare/implicit forms), and footnote markers. Must run on text
582
- * already passed through _esc() — see _inline()'s Step 1 comment.
583
- * @param {string} text
584
- * @returns {string}
585
- */
586
- function _resolveLinksAndFootnotes(text) {
587
- text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) =>
588
- `<img src="${_escAttrQuotes(src)}" alt="${_escAttrQuotes(alt)}" class="an-image">`);
589
- text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) =>
590
- `<a href="${_escAttrQuotes(href)}">${label}</a>`);
591
- text = text.replace(/\[([^\]]+)\]\[([^\]]*)\]/g, (m, label, ref) => {
592
- const def = _linkDefs.get(_unescAmpLtGt(ref || label).trim().toLowerCase());
593
- if (!def) return m;
594
- const titleAttr = def.title ? ` title="${_escAttr(def.title)}"` : '';
595
- return `<a href="${_escAttr(def.href)}"${titleAttr}>${label}</a>`;
596
- });
597
- text = text.replace(/\[([^\]]+)\]/g, (m, label) => {
598
- const def = _linkDefs.get(_unescAmpLtGt(label).trim().toLowerCase());
599
- if (!def) return m;
600
- const titleAttr = def.title ? ` title="${_escAttr(def.title)}"` : '';
601
- return `<a href="${_escAttr(def.href)}"${titleAttr}>${label}</a>`;
602
- });
603
- text = text.replace(/\[\^([^\]]+)\]/g, (m, id) => (_footnoteIds.has(_unescAmpLtGt(id)) ? `<sup>[${id}]</sup>` : m));
604
- return text;
605
- }
606
-
607
- /**
608
- * Converts angle-bracket (`<https://...>`) and bare (`https://...`)
609
- * autolinks. Runs after _resolveLinksAndFootnotes() so an already-linked URL
610
- * isn't reprocessed, and on already-_esc()'d text (see _inline()).
611
- * @param {string} text
612
- * @returns {string}
613
- */
614
- function _applyAutolinks(text) {
615
- text = text.replace(/&lt;(https?:\/\/[^\s&]+?)&gt;/g, (_, url) => `<a href="${_escAttrQuotes(url)}">${url}</a>`);
616
- text = text.replace(/(^|[\s(])(https?:\/\/[^\s()]+)/g, (m, pre, rawUrl) => {
617
- const trail = /[.,;:!?)]+$/.exec(rawUrl);
618
- const url = trail ? rawUrl.slice(0, -trail[0].length) : rawUrl;
619
- if (!url) return m;
620
- const suffix = trail ? trail[0] : '';
621
- return `${pre}<a href="${_escAttrQuotes(url)}">${url}</a>${suffix}`;
622
- });
623
- return text;
624
- }
625
-
626
- /**
627
- * Applies bold/italic/bold-italic (asterisk and underscore forms — underscore
628
- * requires a non-word-character boundary per CommonMark), strikethrough, and
629
- * inline code.
630
- * @param {string} text
631
- * @returns {string}
632
- */
633
- function _applyEmphasisAndCode(text) {
634
- text = text.replace(/\*{3}([^*\n]+?)\*{3}/g, (_, c) => `<strong><em>${c}</em></strong>`);
635
- text = text.replace(/(?<!\w)_{3}([^_\n]+?)_{3}(?!\w)/g, (_, c) => `<strong><em>${c}</em></strong>`);
636
- text = text.replace(/\*{2}([^*\n]+?)\*{2}/g, (_, c) => `<strong>${c}</strong>`);
637
- text = text.replace(/(?<!\w)_{2}([^_\n]+?)_{2}(?!\w)/g, (_, c) => `<strong>${c}</strong>`);
638
- text = text.replace(/\*([^*\n]+?)\*/g, (_, c) => `<em>${c}</em>`);
639
- text = text.replace(/(?<!\w)_([^_\n]+?)_(?!\w)/g, (_, c) => `<em>${c}</em>`);
640
- text = text.replace(/~~([^~\n]+?)~~/g, (_, c) => `<del>${c}</del>`);
641
- // Double-backtick code spans first (tolerates a single literal ` inside),
642
- // then single-backtick spans.
643
- text = text.replace(/``([\s\S]*?)``/g, (_, c) => `<code>${c}</code>`);
644
- text = text.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`);
645
- return text;
646
- }
647
-
648
- function _inline(text) {
649
- // Step 0: backslash escapes (\* \_ \` \# \[ \] \( \) \> \\ \~ \|) — replaced
650
- // with inert placeholders before any syntax regex below can match them, so
651
- // e.g. \*not bold\* never gets treated as emphasis. Restored at the end.
652
- const { text: withoutEscapes, literals } = _extractBackslashEscapes(text);
653
-
654
- // Step 1: escape raw &/</> in the plain-text parts of the string exactly
655
- // once, up front — none of these are markdown-syntax characters used below,
656
- // so this doesn't interfere with matching. Capture-group content in the
657
- // steps below is therefore ALREADY escaped and must NOT be re-escaped;
658
- // attribute values captured from `text` only need quotes escaped
659
- // (_escAttrQuotes), since & < > are already entities. Values that come from
660
- // _linkDefs (sourced from the raw, unescaped line array) still need the
661
- // full _escAttr/_esc treatment.
662
- let result = _esc(withoutEscapes);
663
-
664
- result = _resolveLinksAndFootnotes(result);
665
- result = _applyAutolinks(result);
666
- result = _applyEmphasisAndCode(result);
667
-
668
- return _restoreBackslashEscapes(result, literals);
669
- }
670
-
671
- function _esc(v) {
672
- return String(v)
673
- .replaceAll('&', '&amp;')
674
- .replaceAll('<', '&lt;')
675
- .replaceAll('>', '&gt;');
676
- }
677
-
678
- function _escAttr(v) {
679
- return String(v)
680
- .replaceAll('&', '&amp;')
681
- .replaceAll('"', '&quot;')
682
- .replaceAll("'", '&#39;')
683
- .replaceAll('<', '&lt;')
684
- .replaceAll('>', '&gt;');
685
- }
686
-
687
- /** Escapes only quote characters — for attribute values already run through _esc(). */
688
- function _escAttrQuotes(v) {
689
- return String(v).replaceAll('"', '&quot;').replaceAll("'", '&#39;');
690
- }
691
-
692
- /** Reverses _esc()'s &amp;/&lt;/&gt; substitutions, for matching against un-escaped _linkDefs/_footnoteIds keys. */
693
- function _unescAmpLtGt(v) {
694
- return String(v).replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&');
695
- }