autumnnote 1.9.1 → 1.11.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/dist/autumnnote.css +40 -0
- package/dist/autumnnote.es.js +382 -52
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +382 -52
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/js/core/markdown.js +361 -55
- package/src/js/index.js +1 -1
- package/src/js/module/Clipboard.js +69 -11
- package/src/styles/autumnnote.scss +23 -0
- package/types/index.d.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autumnnote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "WYSIWYG rich-text editor built with vanilla JavaScript — zero dependencies, no jQuery. Dark mode, @mention, markdown shortcuts, bubble toolbar. React and Vue 3 wrappers included.",
|
|
5
5
|
"main": "dist/autumnnote.umd.js",
|
|
6
6
|
"module": "dist/autumnnote.es.js",
|
package/src/js/core/markdown.js
CHANGED
|
@@ -31,6 +31,18 @@ export function htmlToMarkdown(html) {
|
|
|
31
31
|
* @param {number} [depth=0] - Current nesting depth used to indent nested list items.
|
|
32
32
|
* @returns {string} The Markdown representation of the node subtree.
|
|
33
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
|
+
|
|
34
46
|
function _domToMd(node, depth = 0) {
|
|
35
47
|
if (node.nodeType === 3) {
|
|
36
48
|
return node.textContent.replace(/\s+/g, ' ');
|
|
@@ -60,6 +72,18 @@ function _domToMd(node, depth = 0) {
|
|
|
60
72
|
case 'strike': return `~~${inner()}~~`;
|
|
61
73
|
case 'sup': return `^${inner()}^`;
|
|
62
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
|
+
}
|
|
63
87
|
case 'code': {
|
|
64
88
|
// Inside <pre> we emit raw text; outside we wrap in backticks
|
|
65
89
|
if (el.closest('pre')) return inner();
|
|
@@ -73,8 +97,10 @@ function _domToMd(node, depth = 0) {
|
|
|
73
97
|
return `\n\n\`\`\`${lang}\n${content}\n\`\`\`\n\n`;
|
|
74
98
|
}
|
|
75
99
|
case 'blockquote': {
|
|
76
|
-
const
|
|
77
|
-
|
|
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`;
|
|
78
104
|
}
|
|
79
105
|
case 'a': {
|
|
80
106
|
const href = el.getAttribute('href') || '';
|
|
@@ -86,14 +112,16 @@ function _domToMd(node, depth = 0) {
|
|
|
86
112
|
return ``;
|
|
87
113
|
}
|
|
88
114
|
case 'ul': {
|
|
89
|
-
const items =
|
|
115
|
+
const items = _directChildren(el, 'li');
|
|
90
116
|
if (!items.length) return inner();
|
|
91
117
|
const indent = ' '.repeat(depth);
|
|
92
118
|
const isChecklist = el.classList.contains('an-checklist');
|
|
93
119
|
const lines = items.map((li) => {
|
|
120
|
+
const cb = /** @type {HTMLInputElement | undefined} */ (
|
|
121
|
+
_directChildren(li, 'input').find((c) => c.getAttribute('type') === 'checkbox')
|
|
122
|
+
);
|
|
94
123
|
let prefix = '- ';
|
|
95
|
-
if (isChecklist) {
|
|
96
|
-
const cb = /** @type {HTMLInputElement | null} */ (li.querySelector('input[type="checkbox"]'));
|
|
124
|
+
if (isChecklist || cb) {
|
|
97
125
|
const checked = cb ? cb.checked : false;
|
|
98
126
|
prefix = checked ? '- [x] ' : '- [ ] ';
|
|
99
127
|
}
|
|
@@ -102,7 +130,7 @@ function _domToMd(node, depth = 0) {
|
|
|
102
130
|
return depth === 0 ? `\n\n${lines}\n\n` : `\n${lines}`;
|
|
103
131
|
}
|
|
104
132
|
case 'ol': {
|
|
105
|
-
const items =
|
|
133
|
+
const items = _directChildren(el, 'li');
|
|
106
134
|
if (!items.length) return inner();
|
|
107
135
|
const indent = ' '.repeat(depth);
|
|
108
136
|
const lines = items.map((li, i) => `${indent}${i + 1}. ${_domToMd(li, depth + 1).trim()}`).join('\n');
|
|
@@ -111,17 +139,24 @@ function _domToMd(node, depth = 0) {
|
|
|
111
139
|
case 'li': return inner();
|
|
112
140
|
case 'hr': return '\n\n---\n\n';
|
|
113
141
|
case 'table': {
|
|
114
|
-
const
|
|
115
|
-
if (!
|
|
116
|
-
const
|
|
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) =>
|
|
117
150
|
Array.from(tr.querySelectorAll('th, td')).map((c) => c.textContent.trim().replaceAll('|', String.raw`\|`)),
|
|
118
151
|
);
|
|
119
152
|
const cols = Math.max(...cellTexts.map((r) => r.length));
|
|
120
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('');
|
|
121
156
|
let md = '\n\n';
|
|
122
|
-
md += `| ${
|
|
157
|
+
md += `| ${headerCells.join(' | ')} |\n`;
|
|
123
158
|
md += `| ${new Array(cols).fill('---').join(' | ')} |\n`;
|
|
124
|
-
for (let r =
|
|
159
|
+
for (let r = bodyStart; r < cellTexts.length; r++) {
|
|
125
160
|
md += `| ${padRow(cellTexts[r]).join(' | ')} |\n`;
|
|
126
161
|
}
|
|
127
162
|
return md + '\n';
|
|
@@ -139,18 +174,45 @@ function _domToMd(node, depth = 0) {
|
|
|
139
174
|
* @returns {boolean} `true` if any Markdown-like pattern is present, `false` otherwise.
|
|
140
175
|
*/
|
|
141
176
|
export function isMarkdown(text) {
|
|
142
|
-
return /^#{1,6} [^\s]|^[ \t]*[-*+] [^\s]|^[ \t]*\d+\. [^\s]|^> [^\s]|^```|^\*{2}[^*\n]+\*{2}/m.test(text)
|
|
177
|
+
return /^#{1,6} [^\s]|^[ \t]*[-*+] [^\s]|^[ \t]*\d+\. [^\s]|^> ?[^\s]|^```|^\*{2}[^*\n]+\*{2}/m.test(text)
|
|
143
178
|
|| /^.+\n=+\s*$/m.test(text)
|
|
144
|
-
|| /^.+\n-{2,}\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);
|
|
145
182
|
}
|
|
146
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
|
+
|
|
147
193
|
/**
|
|
148
194
|
* Converts a Markdown string to an HTML string.
|
|
149
195
|
* @param {string} text
|
|
150
196
|
* @returns {string}
|
|
151
197
|
*/
|
|
152
198
|
export function markdownToHTML(text) {
|
|
153
|
-
|
|
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) {
|
|
154
216
|
const out = [];
|
|
155
217
|
let i = 0;
|
|
156
218
|
|
|
@@ -174,7 +236,7 @@ export function markdownToHTML(text) {
|
|
|
174
236
|
}
|
|
175
237
|
|
|
176
238
|
// ---- Setext headings (Title\n=== or Title\n---) -------------------------
|
|
177
|
-
if (line.trim() &&
|
|
239
|
+
if (line.trim() && !HR_RE.test(line) && !/^#{1,6} /.test(line) && i + 1 < lines.length) {
|
|
178
240
|
if (/^=+\s*$/.test(lines[i + 1])) {
|
|
179
241
|
out.push(`<h1>${_inline(line.trim())}</h1>`);
|
|
180
242
|
i += 2;
|
|
@@ -187,8 +249,8 @@ export function markdownToHTML(text) {
|
|
|
187
249
|
}
|
|
188
250
|
}
|
|
189
251
|
|
|
190
|
-
// ---- Horizontal rule --- / *** /
|
|
191
|
-
if (
|
|
252
|
+
// ---- Horizontal rule --- / *** / ___ / - - - / * * * ----------------------
|
|
253
|
+
if (HR_RE.test(line)) {
|
|
192
254
|
out.push('<hr>');
|
|
193
255
|
i++;
|
|
194
256
|
continue;
|
|
@@ -198,19 +260,22 @@ export function markdownToHTML(text) {
|
|
|
198
260
|
const hMatch = /^(#{1,6})\s+(.+)$/.exec(line);
|
|
199
261
|
if (hMatch) {
|
|
200
262
|
const level = hMatch[1].length;
|
|
201
|
-
|
|
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}>`);
|
|
202
267
|
i++;
|
|
203
268
|
continue;
|
|
204
269
|
}
|
|
205
270
|
|
|
206
271
|
// ---- Blockquote > text --------------------------------------------------
|
|
207
|
-
if (
|
|
272
|
+
if (BQ_RE.test(line)) {
|
|
208
273
|
const bqLines = [];
|
|
209
|
-
while (i < lines.length && lines[i]
|
|
210
|
-
bqLines.push(lines[i]
|
|
274
|
+
while (i < lines.length && BQ_RE.test(lines[i])) {
|
|
275
|
+
bqLines.push(BQ_RE.exec(lines[i])[2]);
|
|
211
276
|
i++;
|
|
212
277
|
}
|
|
213
|
-
out.push(`<blockquote>${bqLines
|
|
278
|
+
out.push(`<blockquote>${_parseBlocks(bqLines)}</blockquote>`);
|
|
214
279
|
continue;
|
|
215
280
|
}
|
|
216
281
|
|
|
@@ -266,7 +331,9 @@ export function markdownToHTML(text) {
|
|
|
266
331
|
while (
|
|
267
332
|
i < lines.length &&
|
|
268
333
|
lines[i].trim() !== '' &&
|
|
269
|
-
!/^(#{1,6}
|
|
334
|
+
!/^(#{1,6} |[-*+] |\d+\. |```)/.test(lines[i]) &&
|
|
335
|
+
!BQ_RE.test(lines[i]) &&
|
|
336
|
+
!HR_RE.test(lines[i]) &&
|
|
270
337
|
!/^\|.+\|/.test(lines[i]) &&
|
|
271
338
|
!(i + 1 < lines.length && /^=+\s*$/.test(lines[i + 1])) &&
|
|
272
339
|
!(i + 1 < lines.length && /^-{2,}\s*$/.test(lines[i + 1]))
|
|
@@ -275,7 +342,7 @@ export function markdownToHTML(text) {
|
|
|
275
342
|
i++;
|
|
276
343
|
}
|
|
277
344
|
if (paraLines.length) {
|
|
278
|
-
out.push(`<p>${_inline(paraLines.
|
|
345
|
+
out.push(`<p>${_inline(_joinParagraphLines(paraLines)).replaceAll(HARD_BREAK, '<br>')}</p>`);
|
|
279
346
|
}
|
|
280
347
|
}
|
|
281
348
|
|
|
@@ -286,18 +353,105 @@ export function markdownToHTML(text) {
|
|
|
286
353
|
// Inline formatting
|
|
287
354
|
// ---------------------------------------------------------------------------
|
|
288
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
|
+
|
|
289
390
|
/**
|
|
290
|
-
*
|
|
291
|
-
*
|
|
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']
|
|
292
441
|
* @param {string} row
|
|
293
442
|
* @returns {string[]}
|
|
294
443
|
*/
|
|
295
444
|
function _parseTableRow(row) {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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());
|
|
301
455
|
}
|
|
302
456
|
|
|
303
457
|
function _parseListBlock(lines, startIdx) {
|
|
@@ -305,11 +459,31 @@ function _parseListBlock(lines, startIdx) {
|
|
|
305
459
|
const isOL = /^\s*\d+\. /.test(lines[startIdx]);
|
|
306
460
|
const items = [];
|
|
307
461
|
let firstIsCB = null;
|
|
462
|
+
let loose = false;
|
|
463
|
+
let pendingBlank = false;
|
|
308
464
|
let i = startIdx;
|
|
309
465
|
|
|
310
466
|
while (i < lines.length) {
|
|
311
467
|
const line = lines[i];
|
|
312
|
-
|
|
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
|
+
|
|
313
487
|
const indent = (line.match(/^(\s*)/)[1]).length;
|
|
314
488
|
if (indent < baseIndent) break;
|
|
315
489
|
|
|
@@ -317,12 +491,18 @@ function _parseListBlock(lines, startIdx) {
|
|
|
317
491
|
if (!/^\s*(?:[-*+]|\d+\.) /.test(line)) break;
|
|
318
492
|
if (/^\s*\d+\. /.test(line) !== isOL) break;
|
|
319
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.
|
|
320
499
|
const isCB = !isOL && /^\[[ xX]\]\s+/.test(raw);
|
|
321
500
|
if (firstIsCB === null) firstIsCB = isCB;
|
|
322
501
|
if (isCB !== firstIsCB) break;
|
|
323
502
|
const checked = isCB && raw[1].toLowerCase() === 'x';
|
|
324
503
|
const text = isCB ? raw.replace(/^\[[ xX]\]\s+/, '') : raw;
|
|
325
|
-
items.push({ text, isCB, checked, sub: '' });
|
|
504
|
+
items.push({ paras: [text], isCB, checked, sub: '' });
|
|
505
|
+
pendingBlank = false;
|
|
326
506
|
i++;
|
|
327
507
|
} else {
|
|
328
508
|
if (!items.length) { i++; continue; }
|
|
@@ -330,48 +510,164 @@ function _parseListBlock(lines, startIdx) {
|
|
|
330
510
|
const nested = _parseListBlock(lines, i);
|
|
331
511
|
items[items.length - 1].sub += nested.html;
|
|
332
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++;
|
|
333
518
|
} else {
|
|
334
|
-
items[items.length - 1].
|
|
519
|
+
const paras = items[items.length - 1].paras;
|
|
520
|
+
paras[paras.length - 1] += ' ' + line.trim();
|
|
335
521
|
i++;
|
|
336
522
|
}
|
|
337
523
|
}
|
|
338
524
|
}
|
|
339
525
|
|
|
340
526
|
const hasCB = !isOL && (firstIsCB === true);
|
|
341
|
-
const
|
|
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>');
|
|
342
532
|
const close = isOL ? '</ol>' : '</ul>';
|
|
343
|
-
const liHTML = items.map(({
|
|
533
|
+
const liHTML = items.map(({ paras, isCB, checked, sub }) => {
|
|
344
534
|
const cbHTML = isCB
|
|
345
535
|
? `<input type="checkbox" contenteditable="false"${checked ? ' checked' : ''}>`
|
|
346
536
|
: '';
|
|
347
|
-
|
|
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>`;
|
|
348
541
|
}).join('');
|
|
349
542
|
return { html: `${open}${liHTML}${close}`, endIdx: i };
|
|
350
543
|
}
|
|
351
544
|
|
|
352
|
-
|
|
353
|
-
|
|
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) {
|
|
354
587
|
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) =>
|
|
355
|
-
`<img src="${
|
|
356
|
-
// Links
|
|
588
|
+
`<img src="${_escAttrQuotes(src)}" alt="${_escAttrQuotes(alt)}" class="an-image">`);
|
|
357
589
|
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) =>
|
|
358
|
-
`<a href="${
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
text = text.replace(
|
|
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(/<(https?:\/\/[^\s&]+?)>/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>`);
|
|
372
645
|
return text;
|
|
373
646
|
}
|
|
374
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
|
+
|
|
375
671
|
function _esc(v) {
|
|
376
672
|
return String(v)
|
|
377
673
|
.replaceAll('&', '&')
|
|
@@ -387,3 +683,13 @@ function _escAttr(v) {
|
|
|
387
683
|
.replaceAll('<', '<')
|
|
388
684
|
.replaceAll('>', '>');
|
|
389
685
|
}
|
|
686
|
+
|
|
687
|
+
/** Escapes only quote characters — for attribute values already run through _esc(). */
|
|
688
|
+
function _escAttrQuotes(v) {
|
|
689
|
+
return String(v).replaceAll('"', '"').replaceAll("'", ''');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Reverses _esc()'s &/</> substitutions, for matching against un-escaped _linkDefs/_footnoteIds keys. */
|
|
693
|
+
function _unescAmpLtGt(v) {
|
|
694
|
+
return String(v).replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&');
|
|
695
|
+
}
|