@texuli/core 0.4.0 → 0.6.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/README.md +30 -6
- package/dist/editor/document.js +8 -1
- package/dist/editor/extensions.d.ts +64 -0
- package/dist/editor/extensions.js +116 -0
- package/dist/editor/inline.d.ts +5 -3
- package/dist/editor/inline.js +24 -12
- package/dist/editor/load.d.ts +10 -0
- package/dist/editor/load.js +16 -0
- package/dist/editor/markdown.d.ts +10 -1
- package/dist/editor/markdown.js +340 -100
- package/dist/editor/model.d.ts +3 -1
- package/dist/editor/model.js +3 -1
- package/dist/editor/transforms.d.ts +1 -1
- package/dist/editor/tree.d.ts +11 -1
- package/dist/editor/tree.js +11 -2
- package/dist/editor/types.d.ts +10 -0
- package/dist/editor/types.js +1 -0
- package/dist/focus-scope/focus-scope.d.ts +7 -0
- package/dist/focus-scope/focus-scope.js +24 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/inert/inert.d.ts +11 -0
- package/dist/inert/inert.js +13 -4
- package/dist/markup/index.d.ts +5 -0
- package/dist/markup/index.js +5 -0
- package/dist/markup/markup.d.ts +72 -0
- package/dist/markup/markup.js +233 -0
- package/dist/motion/animate.d.ts +109 -0
- package/dist/motion/animate.js +225 -0
- package/dist/motion/flip.d.ts +58 -0
- package/dist/motion/flip.js +167 -0
- package/dist/motion/index.d.ts +15 -0
- package/dist/motion/index.js +15 -0
- package/dist/motion/preferences.d.ts +68 -0
- package/dist/motion/preferences.js +111 -0
- package/dist/motion/presence.d.ts +61 -0
- package/dist/motion/presence.js +231 -0
- package/dist/motion/sequence.d.ts +70 -0
- package/dist/motion/sequence.js +159 -0
- package/dist/motion/shared.d.ts +45 -0
- package/dist/motion/shared.js +144 -0
- package/dist/motion/spring.d.ts +109 -0
- package/dist/motion/spring.js +166 -0
- package/dist/overlay/core.js +16 -3
- package/dist/url-policy/filter-image-srcset.d.ts +11 -0
- package/dist/url-policy/filter-image-srcset.js +60 -0
- package/dist/url-policy/index.d.ts +1 -0
- package/dist/url-policy/index.js +1 -0
- package/dist/zip/index.d.ts +5 -0
- package/dist/zip/index.js +5 -0
- package/dist/zip/zip.d.ts +35 -0
- package/dist/zip/zip.js +190 -0
- package/package.json +6 -2
package/dist/editor/markdown.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeDocument, paragraph } from "./document.js";
|
|
2
|
+
import { acceptsExtensionId, EDITOR_EXTENSION_LIMITS, isExtensionIdChar, toInlineExtensions, } from "./extensions.js";
|
|
2
3
|
import { markKey, normalizeInline, sortMarks } from "./inline.js";
|
|
3
4
|
import { LoadBudget } from "./load.js";
|
|
4
5
|
/**
|
|
@@ -14,16 +15,62 @@ export const MARKDOWN_CODEC_LIMITS = Object.freeze({
|
|
|
14
15
|
/** Nested emphasis and links. */
|
|
15
16
|
inlineDepth: 6,
|
|
16
17
|
});
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
// Line patterns stay linear on hostile lines: no two neighboring quantifiers
|
|
19
|
+
// can match the same characters, and `.` uses the `s` flag so U+2028 and
|
|
20
|
+
// U+2029 inside a line never make a `(.*)$` tail backtrack. The fence info
|
|
21
|
+
// string and the heading closing sequence are read by hand (their regex
|
|
22
|
+
// forms backtracked quadratically, and cubically, on runs of spaces).
|
|
23
|
+
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
|
|
24
|
+
const FENCE_LANG = /^[a-z][a-z0-9+-]{0,31}$/i;
|
|
25
|
+
const HEADING_OPEN = /^ {0,3}(#+)/;
|
|
19
26
|
const RULE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
20
|
-
const QUOTE = /^ {0,3}> ?(.*)
|
|
21
|
-
const BULLET = /^( {0,3})([-*+])[ \t]+(.*)
|
|
22
|
-
const ORDERED = /^( {0,3})(\d{1,9})[.)][ \t]+(.*)
|
|
27
|
+
const QUOTE = /^ {0,3}> ?(.*)$/s;
|
|
28
|
+
const BULLET = /^( {0,3})([-*+])[ \t]+(.*)$/s;
|
|
29
|
+
const ORDERED = /^( {0,3})(\d{1,9})[.)][ \t]+(.*)$/s;
|
|
23
30
|
const TASK = /^\[([ xX])\][ \t]+/;
|
|
24
31
|
const IMAGE_ONLY = /^!\[((?:[^\]\\\n]|\\.)*)\]\(\s*<?([^\s()<>]+)>?\s*\)$/;
|
|
25
32
|
const ESCAPABLE = /[\\`*_{}[\]()#+\-.!~<>|]/;
|
|
26
33
|
const isBlank = (line) => line.trim() === '';
|
|
34
|
+
const isSpace = (char) => char === ' ' || char === '\t';
|
|
35
|
+
/** A fence opening line: its marker and the optional language. */
|
|
36
|
+
function readFence(line) {
|
|
37
|
+
const open = FENCE_OPEN.exec(line);
|
|
38
|
+
if (!open)
|
|
39
|
+
return null;
|
|
40
|
+
const info = line.slice(open[0].length).trim();
|
|
41
|
+
if (info !== '' && !FENCE_LANG.test(info))
|
|
42
|
+
return null;
|
|
43
|
+
return info
|
|
44
|
+
? { marker: open[1], lang: info.toLowerCase() }
|
|
45
|
+
: { marker: open[1] };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* An ATX heading line: up to 6 `#` (a 7th or later reads as part of the
|
|
49
|
+
* closing sequence), then text after a space or tab, with the closing
|
|
50
|
+
* sequence of spaces, `#` and spaces dropped from the end.
|
|
51
|
+
*/
|
|
52
|
+
function readHeading(line) {
|
|
53
|
+
const open = HEADING_OPEN.exec(line);
|
|
54
|
+
if (!open)
|
|
55
|
+
return null;
|
|
56
|
+
const hashes = open[1];
|
|
57
|
+
const level = Math.min(hashes.length, 6);
|
|
58
|
+
const rest = line.slice(open[0].length - hashes.length + level);
|
|
59
|
+
let start = 0;
|
|
60
|
+
while (isSpace(rest[start]))
|
|
61
|
+
start += 1;
|
|
62
|
+
let end = rest.length;
|
|
63
|
+
while (end > start && isSpace(rest[end - 1]))
|
|
64
|
+
end -= 1;
|
|
65
|
+
while (end > start && rest[end - 1] === '#')
|
|
66
|
+
end -= 1;
|
|
67
|
+
while (end > start && isSpace(rest[end - 1]))
|
|
68
|
+
end -= 1;
|
|
69
|
+
// Without a space or tab after the opening, only a closing sequence may follow.
|
|
70
|
+
if (start === 0 && end > 0)
|
|
71
|
+
return null;
|
|
72
|
+
return { level, text: rest.slice(start, end) };
|
|
73
|
+
}
|
|
27
74
|
function listMarker(line) {
|
|
28
75
|
const bullet = BULLET.exec(line);
|
|
29
76
|
if (bullet)
|
|
@@ -33,8 +80,8 @@ function listMarker(line) {
|
|
|
33
80
|
return { ordered: true, start: Number(ordered[2]), content: ordered[3] ?? '' };
|
|
34
81
|
return null;
|
|
35
82
|
}
|
|
36
|
-
const startsBlock = (line) =>
|
|
37
|
-
|
|
83
|
+
const startsBlock = (line) => readFence(line) !== null ||
|
|
84
|
+
readHeading(line) !== null ||
|
|
38
85
|
RULE.test(line) ||
|
|
39
86
|
QUOTE.test(line) ||
|
|
40
87
|
listMarker(line) !== null;
|
|
@@ -84,9 +131,9 @@ class Reader {
|
|
|
84
131
|
i += 1;
|
|
85
132
|
continue;
|
|
86
133
|
}
|
|
87
|
-
const fence =
|
|
134
|
+
const fence = readFence(line);
|
|
88
135
|
if (fence) {
|
|
89
|
-
const marker = fence
|
|
136
|
+
const marker = fence.marker;
|
|
90
137
|
const body = [];
|
|
91
138
|
i += 1;
|
|
92
139
|
while (i < lines.length && !(lines[i] ?? '').trim().startsWith(marker)) {
|
|
@@ -94,7 +141,7 @@ class Reader {
|
|
|
94
141
|
i += 1;
|
|
95
142
|
}
|
|
96
143
|
i += 1;
|
|
97
|
-
const lang = fence
|
|
144
|
+
const lang = fence.lang;
|
|
98
145
|
push(() => {
|
|
99
146
|
const code = this.budget.text(this.budget.clean(body.join('\n'), '\n\t'));
|
|
100
147
|
return lang
|
|
@@ -103,16 +150,16 @@ class Reader {
|
|
|
103
150
|
});
|
|
104
151
|
continue;
|
|
105
152
|
}
|
|
106
|
-
const heading =
|
|
153
|
+
const heading = readHeading(line);
|
|
107
154
|
if (heading) {
|
|
108
|
-
const level =
|
|
155
|
+
const level = heading.level;
|
|
109
156
|
if (level > 4)
|
|
110
157
|
this.budget.report('heading-clamped', 'Headings deeper than level 4 became level 4.');
|
|
111
158
|
push(() => ({
|
|
112
159
|
id: this.budget.newId(),
|
|
113
160
|
type: 'heading',
|
|
114
161
|
level: Math.min(level, 4),
|
|
115
|
-
content: trimEdges(this.inline(
|
|
162
|
+
content: trimEdges(this.inline(heading.text.trim())),
|
|
116
163
|
}));
|
|
117
164
|
i += 1;
|
|
118
165
|
continue;
|
|
@@ -253,9 +300,51 @@ class Scan {
|
|
|
253
300
|
source;
|
|
254
301
|
closers = new Map();
|
|
255
302
|
brackets = null;
|
|
303
|
+
tokens = null;
|
|
256
304
|
constructor(source) {
|
|
257
305
|
this.source = source;
|
|
258
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* The extension token `<sigil>{id|label}` whose sigil is at `at`, or null.
|
|
309
|
+
* Two linear passes the first time (the id-character run from each index,
|
|
310
|
+
* and the next unescaped `}` before a newline), then constant time.
|
|
311
|
+
*/
|
|
312
|
+
extension(at) {
|
|
313
|
+
const { source } = this;
|
|
314
|
+
if (source[at + 1] !== '{')
|
|
315
|
+
return null;
|
|
316
|
+
if (!this.tokens) {
|
|
317
|
+
const n = source.length;
|
|
318
|
+
const idRun = new Int32Array(n + 1);
|
|
319
|
+
for (let j = n - 1; j >= 0; j -= 1)
|
|
320
|
+
idRun[j] = isExtensionIdChar(source.charCodeAt(j)) ? idRun[j + 1] + 1 : 0;
|
|
321
|
+
const escaped = new Uint8Array(n);
|
|
322
|
+
for (let j = 0; j < n; j += 1) {
|
|
323
|
+
if (source[j] === '\\') {
|
|
324
|
+
if (j + 1 < n)
|
|
325
|
+
escaped[j + 1] = 1;
|
|
326
|
+
j += 1;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const close = new Int32Array(n + 1).fill(-1);
|
|
330
|
+
for (let j = n - 1; j >= 0; j -= 1) {
|
|
331
|
+
const c = source[j];
|
|
332
|
+
close[j] = c === '\n' ? -1 : c === '}' && !escaped[j] ? j : close[j + 1];
|
|
333
|
+
}
|
|
334
|
+
this.tokens = { idRun, close };
|
|
335
|
+
}
|
|
336
|
+
const idStart = at + 2;
|
|
337
|
+
const idEnd = idStart + (this.tokens.idRun[idStart] ?? 0);
|
|
338
|
+
if (idEnd === idStart || idEnd - idStart > EDITOR_EXTENSION_LIMITS.id)
|
|
339
|
+
return null;
|
|
340
|
+
if (source[idEnd] !== '|')
|
|
341
|
+
return null;
|
|
342
|
+
const labelStart = idEnd + 1;
|
|
343
|
+
const labelEnd = this.tokens.close[labelStart] ?? -1;
|
|
344
|
+
if (labelEnd <= labelStart || labelEnd - labelStart > EDITOR_EXTENSION_LIMITS.label)
|
|
345
|
+
return null;
|
|
346
|
+
return { idStart, idEnd, labelStart, labelEnd, end: labelEnd + 1 };
|
|
347
|
+
}
|
|
259
348
|
/** The first valid closer of `marker` at or after `from`, or -1. */
|
|
260
349
|
closer(marker, from) {
|
|
261
350
|
let table = this.closers.get(marker);
|
|
@@ -327,16 +416,31 @@ class Scan {
|
|
|
327
416
|
/** Inline Markdown into runs; `marks` are the marks open around `source`. */
|
|
328
417
|
function parseInline(source, depth, marks, inLink, out, budget) {
|
|
329
418
|
let buffer = '';
|
|
419
|
+
// Spaces at the end of `buffer`, kept as it grows: reading the buffer's
|
|
420
|
+
// last characters would flatten the string being built on every soft line
|
|
421
|
+
// break (quadratic in a long paragraph).
|
|
422
|
+
let spaces = 0;
|
|
423
|
+
const append = (text) => {
|
|
424
|
+
buffer += text;
|
|
425
|
+
let end = text.length;
|
|
426
|
+
while (end > 0 && text.charCodeAt(end - 1) === 32)
|
|
427
|
+
end -= 1;
|
|
428
|
+
spaces = end === 0 ? spaces + text.length : text.length - end;
|
|
429
|
+
};
|
|
330
430
|
const flush = () => {
|
|
331
431
|
if (buffer)
|
|
332
432
|
out.push(...budget.inline(buffer.replace(/\n/g, ' '), marks));
|
|
333
433
|
buffer = '';
|
|
434
|
+
spaces = 0;
|
|
334
435
|
};
|
|
335
436
|
const push = (text, extra) => {
|
|
336
437
|
flush();
|
|
337
438
|
out.push(...budget.inline(text, sortMarks([...marks, ...extra])));
|
|
338
439
|
};
|
|
339
440
|
const scan = new Scan(source);
|
|
441
|
+
const extensions = budget.extensions;
|
|
442
|
+
// The characters that can start an inline construct, registered sigils included.
|
|
443
|
+
const special = extensions.special;
|
|
340
444
|
let i = 0;
|
|
341
445
|
while (i < source.length) {
|
|
342
446
|
const ch = source[i] ?? '';
|
|
@@ -350,23 +454,36 @@ function parseInline(source, depth, marks, inLink, out, budget) {
|
|
|
350
454
|
continue;
|
|
351
455
|
}
|
|
352
456
|
if (ESCAPABLE.test(next)) {
|
|
353
|
-
|
|
457
|
+
append(next);
|
|
354
458
|
i += 2;
|
|
355
459
|
continue;
|
|
356
460
|
}
|
|
357
461
|
}
|
|
358
462
|
if (ch === '\n') {
|
|
359
|
-
|
|
360
|
-
|
|
463
|
+
// Two or more trailing spaces make a hard break.
|
|
464
|
+
if (spaces >= 2) {
|
|
465
|
+
buffer = buffer.slice(0, buffer.length - spaces);
|
|
361
466
|
flush();
|
|
362
467
|
out.push({ type: 'break' });
|
|
363
468
|
budget.chars += 1;
|
|
364
469
|
}
|
|
365
470
|
else
|
|
366
|
-
|
|
471
|
+
append('\n');
|
|
367
472
|
i += 1;
|
|
368
473
|
continue;
|
|
369
474
|
}
|
|
475
|
+
const extension = inLink ? undefined : extensions.bySigil.get(ch);
|
|
476
|
+
if (extension) {
|
|
477
|
+
const token = scan.extension(i);
|
|
478
|
+
const id = token ? source.slice(token.idStart, token.idEnd) : '';
|
|
479
|
+
if (token && acceptsExtensionId(extensions, extension.name, id)) {
|
|
480
|
+
push(unescapeMarkdown(source.slice(token.labelStart, token.labelEnd)), [
|
|
481
|
+
{ type: 'extension', name: extension.name, id },
|
|
482
|
+
]);
|
|
483
|
+
i = token.end;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
370
487
|
if (ch === '`') {
|
|
371
488
|
const run = /^`+/.exec(source.slice(i, i + 64))?.[0] ?? '`';
|
|
372
489
|
const close = source.indexOf(run, i + run.length);
|
|
@@ -376,7 +493,7 @@ function parseInline(source, depth, marks, inLink, out, budget) {
|
|
|
376
493
|
i = close + run.length;
|
|
377
494
|
continue;
|
|
378
495
|
}
|
|
379
|
-
|
|
496
|
+
append(run);
|
|
380
497
|
i += run.length;
|
|
381
498
|
continue;
|
|
382
499
|
}
|
|
@@ -392,7 +509,7 @@ function parseInline(source, depth, marks, inLink, out, budget) {
|
|
|
392
509
|
if (ch === '!' && source[i + 1] === '[') {
|
|
393
510
|
const image = matchLink(scan, i + 1);
|
|
394
511
|
if (image) {
|
|
395
|
-
|
|
512
|
+
append(unescapeMarkdown(source.slice(image.labelStart, image.labelEnd)));
|
|
396
513
|
i = image.end;
|
|
397
514
|
continue;
|
|
398
515
|
}
|
|
@@ -418,8 +535,12 @@ function parseInline(source, depth, marks, inLink, out, budget) {
|
|
|
418
535
|
}
|
|
419
536
|
}
|
|
420
537
|
}
|
|
421
|
-
|
|
422
|
-
|
|
538
|
+
// Plain text up to the next character that can open a construct is
|
|
539
|
+
// copied in one slice, not one character at a time.
|
|
540
|
+
special.lastIndex = i + 1;
|
|
541
|
+
const stop = special.test(source) ? special.lastIndex - 1 : source.length;
|
|
542
|
+
append(source.slice(i, stop));
|
|
543
|
+
i = stop;
|
|
423
544
|
}
|
|
424
545
|
flush();
|
|
425
546
|
}
|
|
@@ -453,19 +574,92 @@ function matchLink(scan, at) {
|
|
|
453
574
|
const labelEnd = scan.bracket(at);
|
|
454
575
|
if (labelEnd === -1 || source[labelEnd + 1] !== '(')
|
|
455
576
|
return null;
|
|
456
|
-
|
|
577
|
+
// A target is read from at most 2,100 characters after the label.
|
|
578
|
+
const target = readLinkTarget(source.slice(labelEnd + 1, labelEnd + 1 + 2_100), 0);
|
|
457
579
|
if (!target)
|
|
458
580
|
return null;
|
|
459
|
-
return {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
581
|
+
return { labelStart: at + 1, labelEnd, href: target.href, end: labelEnd + 1 + target.end };
|
|
582
|
+
}
|
|
583
|
+
const isWhitespace = (char) => char !== undefined && /\s/.test(char);
|
|
584
|
+
/** A target character: not whitespace, a parenthesis or an angle bracket. */
|
|
585
|
+
const isTargetChar = (char) => char !== undefined && !/[\s()<>]/.test(char);
|
|
586
|
+
/**
|
|
587
|
+
* Reads a link target `(<href> "title")` whose `(` is at `open`, by hand.
|
|
588
|
+
* It returns what `/\(\s*<?((?:[^\s()<>]|\([^\s()<>]*\))*)>?(?:\s+"[^"\n]*")?\s*\)/y`
|
|
589
|
+
* returned (the href and the index after `)`), but in linear time: in that
|
|
590
|
+
* regex `\(\s*`, `\s+"` and `\s*\)` competed for one whitespace run, so
|
|
591
|
+
* `[a](` + 3,990 spaces + `x` took about 250 ms.
|
|
592
|
+
*/
|
|
593
|
+
function readLinkTarget(source, open) {
|
|
594
|
+
// The optional title and the closing parenthesis from `from`: the index
|
|
595
|
+
// after `)`, or -1.
|
|
596
|
+
const close = (from) => {
|
|
597
|
+
let at = from;
|
|
598
|
+
while (isWhitespace(source[at]))
|
|
599
|
+
at += 1;
|
|
600
|
+
if (source[at] === ')')
|
|
601
|
+
return at + 1;
|
|
602
|
+
if (at === from || source[at] !== '"')
|
|
603
|
+
return -1;
|
|
604
|
+
at += 1;
|
|
605
|
+
while (at < source.length && source[at] !== '"' && source[at] !== '\n')
|
|
606
|
+
at += 1;
|
|
607
|
+
if (source[at] !== '"')
|
|
608
|
+
return -1;
|
|
609
|
+
at += 1;
|
|
610
|
+
while (isWhitespace(source[at]))
|
|
611
|
+
at += 1;
|
|
612
|
+
return source[at] === ')' ? at + 1 : -1;
|
|
464
613
|
};
|
|
614
|
+
let at = open + 1;
|
|
615
|
+
while (isWhitespace(source[at]))
|
|
616
|
+
at += 1;
|
|
617
|
+
const afterSpace = at;
|
|
618
|
+
if (source[at] === '<')
|
|
619
|
+
at += 1;
|
|
620
|
+
const hrefStart = at;
|
|
621
|
+
// Plain characters and balanced `(…)` groups without whitespace inside.
|
|
622
|
+
for (;;) {
|
|
623
|
+
if (isTargetChar(source[at]))
|
|
624
|
+
at += 1;
|
|
625
|
+
else if (source[at] === '(') {
|
|
626
|
+
let inner = at + 1;
|
|
627
|
+
while (isTargetChar(source[inner]))
|
|
628
|
+
inner += 1;
|
|
629
|
+
if (source[inner] !== ')')
|
|
630
|
+
break;
|
|
631
|
+
at = inner + 1;
|
|
632
|
+
}
|
|
633
|
+
else
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
const href = source.slice(hrefStart, at);
|
|
637
|
+
if (source[at] === '>')
|
|
638
|
+
at += 1;
|
|
639
|
+
const end = close(at);
|
|
640
|
+
if (end !== -1)
|
|
641
|
+
return { href, end };
|
|
642
|
+
// The regex backtracked into the leading whitespace: `( "title")` is an
|
|
643
|
+
// empty href with a title.
|
|
644
|
+
if (afterSpace > open + 1) {
|
|
645
|
+
const titled = close(open + 1);
|
|
646
|
+
if (titled !== -1)
|
|
647
|
+
return { href: '', end: titled };
|
|
648
|
+
}
|
|
649
|
+
return null;
|
|
465
650
|
}
|
|
466
651
|
// ---------------------------------------------------------------------------
|
|
467
652
|
// Writing
|
|
468
653
|
const escapeText = (text) => text.replace(/[\\`*_[\]~<!]/g, '\\$&');
|
|
654
|
+
/** Plain text: a registered sigil before `{` would read as a token, so `{` is escaped. */
|
|
655
|
+
const escapePlain = (text, extensions) => extensions.literal ? escapeText(text).replace(extensions.literal, '$1\\{') : escapeText(text);
|
|
656
|
+
/** An extension token: the label escaped so no closer or delimiter inside it counts. */
|
|
657
|
+
function extensionToken(mark, label, extensions) {
|
|
658
|
+
const extension = extensions.byName.get(mark.name);
|
|
659
|
+
if (!extension)
|
|
660
|
+
return null;
|
|
661
|
+
return `${extension.sigil}{${mark.id}|${escapeText(label).replace(/[{}]/g, '\\$&')}}`;
|
|
662
|
+
}
|
|
469
663
|
/** A line that would start another block gets its first marker escaped. */
|
|
470
664
|
function escapeLineStart(line) {
|
|
471
665
|
const ordered = /^( {0,3}\d{1,9})([.)])([ \t]|$)/.exec(line);
|
|
@@ -483,69 +677,98 @@ function codeSpan(text) {
|
|
|
483
677
|
const pad = text.startsWith('`') || text.endsWith('`') || /^ .* $/.test(text) ? ' ' : '';
|
|
484
678
|
return `${fence}${pad}${text}${pad}${fence}`;
|
|
485
679
|
}
|
|
486
|
-
/**
|
|
680
|
+
/**
|
|
681
|
+
* Move whitespace and trailing backslashes out of strong, em and strike spans
|
|
682
|
+
* (Markdown cannot open or close there). Works on runs, splitting a run only
|
|
683
|
+
* where a span edge moves: one pass per mark type, no object per character.
|
|
684
|
+
*/
|
|
487
685
|
function prepareMarks(content) {
|
|
488
|
-
|
|
489
|
-
for (const node of content) {
|
|
490
|
-
if (node.type === 'break')
|
|
491
|
-
chars.push(null);
|
|
492
|
-
else
|
|
493
|
-
for (const ch of node.text)
|
|
494
|
-
chars.push({ ch, marks: node.marks });
|
|
495
|
-
}
|
|
686
|
+
let runs = content.flatMap((node) => node.type === 'break' ? [null] : node.text ? [{ text: node.text, marks: node.marks }] : []);
|
|
496
687
|
for (const type of ['strong', 'em', 'strike']) {
|
|
688
|
+
const marked = (run) => run?.marks.some((m) => m.type === type) === true;
|
|
689
|
+
const strip = (run, text) => ({
|
|
690
|
+
text,
|
|
691
|
+
marks: run.marks.filter((m) => m.type !== type),
|
|
692
|
+
});
|
|
693
|
+
const out = [];
|
|
497
694
|
let i = 0;
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if (chars[k] !== null)
|
|
502
|
-
return marked(chars[k]);
|
|
503
|
-
let left = k - 1;
|
|
504
|
-
while (chars[left] === null)
|
|
505
|
-
left -= 1;
|
|
506
|
-
let right = k + 1;
|
|
507
|
-
while (chars[right] === null)
|
|
508
|
-
right += 1;
|
|
509
|
-
return left >= 0 && marked(chars[left]) && marked(chars[right]);
|
|
510
|
-
};
|
|
511
|
-
while (i < chars.length) {
|
|
512
|
-
if (!has(i)) {
|
|
695
|
+
while (i < runs.length) {
|
|
696
|
+
if (!marked(runs[i])) {
|
|
697
|
+
out.push(runs[i]);
|
|
513
698
|
i += 1;
|
|
514
699
|
continue;
|
|
515
700
|
}
|
|
701
|
+
// A span: marked text, and breaks with marked text on both sides.
|
|
516
702
|
let end = i;
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
703
|
+
for (let k = i + 1; k < runs.length; k += 1) {
|
|
704
|
+
if (runs[k] === null)
|
|
705
|
+
continue;
|
|
706
|
+
if (!marked(runs[k]))
|
|
707
|
+
break;
|
|
708
|
+
end = k;
|
|
709
|
+
}
|
|
710
|
+
const span = runs.slice(i, end + 1);
|
|
711
|
+
// Leading whitespace leaves the span, up to the first other character
|
|
712
|
+
// or break; then trailing whitespace and backslashes, from the end.
|
|
713
|
+
const head = [];
|
|
714
|
+
const tail = [];
|
|
715
|
+
let a = 0;
|
|
716
|
+
let b = span.length - 1;
|
|
717
|
+
for (; a <= b; a += 1) {
|
|
718
|
+
const run = span[a];
|
|
719
|
+
if (!run)
|
|
720
|
+
break;
|
|
721
|
+
let cut = 0;
|
|
722
|
+
while (/\s/.test(run.text[cut] ?? ''))
|
|
723
|
+
cut += 1;
|
|
724
|
+
if (cut)
|
|
725
|
+
head.push(strip(run, run.text.slice(0, cut)));
|
|
726
|
+
if (cut < run.text.length) {
|
|
727
|
+
span[a] = { text: run.text.slice(cut), marks: run.marks };
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
for (; b >= a; b -= 1) {
|
|
732
|
+
const run = span[b];
|
|
733
|
+
if (!run)
|
|
734
|
+
break;
|
|
735
|
+
let cut = run.text.length;
|
|
736
|
+
while (/[\s\\]/.test(run.text[cut - 1] ?? ''))
|
|
737
|
+
cut -= 1;
|
|
738
|
+
if (cut < run.text.length)
|
|
739
|
+
tail.push(strip(run, run.text.slice(cut)));
|
|
740
|
+
if (cut) {
|
|
741
|
+
span[b] = { text: run.text.slice(0, cut), marks: run.marks };
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
// One push per run: a spread of a long span could overflow the call stack.
|
|
746
|
+
for (const run of [...head, ...span.slice(a, b + 1), ...tail.reverse()])
|
|
747
|
+
out.push(run);
|
|
529
748
|
i = end + 1;
|
|
530
749
|
}
|
|
750
|
+
runs = out;
|
|
531
751
|
}
|
|
532
|
-
|
|
533
|
-
for (const c of chars)
|
|
534
|
-
out.push(c ? { type: 'text', text: c.ch, marks: c.marks } : { type: 'break' });
|
|
535
|
-
return normalizeInline(out);
|
|
752
|
+
return normalizeInline(runs.map((run) => (run ? { type: 'text', ...run } : { type: 'break' })));
|
|
536
753
|
}
|
|
537
|
-
function writeInline(content, em) {
|
|
754
|
+
function writeInline(content, em, extensions) {
|
|
538
755
|
const open = { strong: '**', em, strike: '~~' };
|
|
539
|
-
const texts = content.flatMap((node
|
|
756
|
+
const texts = content.flatMap((node) => (node.type === 'text' ? [{ node }] : []));
|
|
540
757
|
const stack = [];
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
758
|
+
// Code spans and extension tokens are leaves: other marks nest around them.
|
|
759
|
+
const outer = (marks) => marks.filter((m) => m.type !== 'code' && m.type !== 'extension');
|
|
760
|
+
// Where the span of each mark starting at text `t` ends (marks that end
|
|
761
|
+
// later nest outside), computed once from the end so long spans stay linear.
|
|
762
|
+
const ends = new Array(texts.length);
|
|
763
|
+
for (let t = texts.length - 1; t >= 0; t -= 1) {
|
|
764
|
+
const here = new Map();
|
|
765
|
+
for (const mark of texts[t].node.marks) {
|
|
766
|
+
const key = markKey(mark);
|
|
767
|
+
here.set(key, ends[t + 1]?.get(key) ?? t);
|
|
768
|
+
}
|
|
769
|
+
ends[t] = here;
|
|
770
|
+
}
|
|
771
|
+
const spanEnd = (t, mark) => ends[t]?.get(markKey(mark)) ?? t;
|
|
549
772
|
const ordered = (t) => {
|
|
550
773
|
const marks = outer(texts[t].node.marks);
|
|
551
774
|
const opened = (mark) => {
|
|
@@ -562,7 +785,7 @@ function writeInline(content, em) {
|
|
|
562
785
|
const close = (to) => {
|
|
563
786
|
while (stack.length > to) {
|
|
564
787
|
const mark = stack.pop();
|
|
565
|
-
out += mark.type === 'link' ? `](${encodeRef(mark.href)})` : open[mark.type];
|
|
788
|
+
out += mark.type === 'link' ? `](${encodeRef(mark.href)})` : (open[mark.type] ?? '');
|
|
566
789
|
}
|
|
567
790
|
};
|
|
568
791
|
const keepCommon = (wanted) => {
|
|
@@ -575,10 +798,11 @@ function writeInline(content, em) {
|
|
|
575
798
|
return common;
|
|
576
799
|
};
|
|
577
800
|
let t = 0;
|
|
578
|
-
content.forEach((node
|
|
801
|
+
content.forEach((node) => {
|
|
579
802
|
if (node.type === 'break') {
|
|
580
803
|
// Marks the next text keeps stay open across the break.
|
|
581
|
-
|
|
804
|
+
// `t` texts are written, so the next one is `texts[t]`.
|
|
805
|
+
const next = t < texts.length ? t : -1;
|
|
582
806
|
keepCommon(next === -1 ? [] : ordered(next).filter((m) => stack.some((s) => markKey(s) === markKey(m))));
|
|
583
807
|
out += '\\\n';
|
|
584
808
|
return;
|
|
@@ -586,10 +810,16 @@ function writeInline(content, em) {
|
|
|
586
810
|
const wanted = ordered(t);
|
|
587
811
|
const common = keepCommon(wanted);
|
|
588
812
|
for (const mark of wanted.slice(common)) {
|
|
589
|
-
out += mark.type === 'link' ? '[' : open[mark.type];
|
|
813
|
+
out += mark.type === 'link' ? '[' : (open[mark.type] ?? '');
|
|
590
814
|
stack.push(mark);
|
|
591
815
|
}
|
|
592
|
-
|
|
816
|
+
const entity = node.marks.find((m) => m.type === 'extension');
|
|
817
|
+
const token = entity ? extensionToken(entity, node.text, extensions) : null;
|
|
818
|
+
out +=
|
|
819
|
+
token ??
|
|
820
|
+
(node.marks.some((m) => m.type === 'code')
|
|
821
|
+
? codeSpan(node.text)
|
|
822
|
+
: escapePlain(node.text, extensions));
|
|
593
823
|
t += 1;
|
|
594
824
|
});
|
|
595
825
|
close(0);
|
|
@@ -603,8 +833,8 @@ const sameInline = (a, b) => a.length === b.length &&
|
|
|
603
833
|
return (node.text === other.text && node.marks.map(markKey).join() === other.marks.map(markKey).join());
|
|
604
834
|
});
|
|
605
835
|
/** Read inline Markdown back without caps or policy changes (for the write check). */
|
|
606
|
-
function reread(markdown) {
|
|
607
|
-
const budget = new LoadBudget({ trust: 'developer' });
|
|
836
|
+
function reread(markdown, extensions) {
|
|
837
|
+
const budget = new LoadBudget({ trust: 'developer', extensions });
|
|
608
838
|
const out = [];
|
|
609
839
|
parseInline(markdown, 0, [], false, out, budget);
|
|
610
840
|
return normalizeInline(out);
|
|
@@ -614,11 +844,11 @@ function reread(markdown) {
|
|
|
614
844
|
* combination has no faithful spelling in the subset, the result drops the
|
|
615
845
|
* least significant marks (em, then strike, then strong) until it does.
|
|
616
846
|
*/
|
|
617
|
-
function inlineMarkdown(content, lineStart) {
|
|
618
|
-
let prepared = prepareMarks(content);
|
|
847
|
+
function inlineMarkdown(content, lineStart, extensions) {
|
|
848
|
+
let prepared = prepareMarks(withRegistered(content, extensions));
|
|
619
849
|
const attempt = (em) => {
|
|
620
|
-
const text = writeInline(prepared, em).split('\n').map(lineStart).join('\n');
|
|
621
|
-
return sameInline(reread(text), prepared) ? text : null;
|
|
850
|
+
const text = writeInline(prepared, em, extensions).split('\n').map(lineStart).join('\n');
|
|
851
|
+
return sameInline(reread(text, extensions), prepared) ? text : null;
|
|
622
852
|
};
|
|
623
853
|
for (const drop of [null, 'em', 'strike', 'strong']) {
|
|
624
854
|
if (drop)
|
|
@@ -627,25 +857,34 @@ function inlineMarkdown(content, lineStart) {
|
|
|
627
857
|
if (text !== null)
|
|
628
858
|
return text;
|
|
629
859
|
}
|
|
630
|
-
return writeInline(prepared, '*').split('\n').map(lineStart).join('\n');
|
|
860
|
+
return writeInline(prepared, '*', extensions).split('\n').map(lineStart).join('\n');
|
|
861
|
+
}
|
|
862
|
+
/** Extension marks of unregistered names leave (their label stays plain text). */
|
|
863
|
+
function withRegistered(content, extensions) {
|
|
864
|
+
const foreign = (mark) => mark.type === 'extension' && !extensions.byName.has(mark.name);
|
|
865
|
+
if (!content.some((n) => n.type === 'text' && n.marks.some(foreign)))
|
|
866
|
+
return content;
|
|
867
|
+
return normalizeInline(content.map((n) => n.type === 'text' && n.marks.some(foreign)
|
|
868
|
+
? { ...n, marks: n.marks.filter((m) => !foreign(m)) }
|
|
869
|
+
: n));
|
|
631
870
|
}
|
|
632
871
|
const indent = (text, by) => text
|
|
633
872
|
.split('\n')
|
|
634
873
|
.map((line) => (line ? by + line : line))
|
|
635
874
|
.join('\n');
|
|
636
|
-
function writeBlocks(blocks) {
|
|
875
|
+
function writeBlocks(blocks, extensions) {
|
|
637
876
|
return blocks
|
|
638
|
-
.map(writeBlock)
|
|
877
|
+
.map((block) => writeBlock(block, extensions))
|
|
639
878
|
.filter((text) => text !== '')
|
|
640
879
|
.join('\n\n');
|
|
641
880
|
}
|
|
642
|
-
function writeBlock(block) {
|
|
881
|
+
function writeBlock(block, extensions) {
|
|
643
882
|
switch (block.type) {
|
|
644
883
|
case 'paragraph':
|
|
645
|
-
return inlineMarkdown(trimEdges(block.content), escapeLineStart);
|
|
884
|
+
return inlineMarkdown(trimEdges(block.content), escapeLineStart, extensions);
|
|
646
885
|
case 'heading': {
|
|
647
886
|
const flat = block.content.map((n) => (n.type === 'break' ? { type: 'text', text: ' ', marks: [] } : n));
|
|
648
|
-
const text = inlineMarkdown(trimEdges(normalizeInline(flat)), (line) => line);
|
|
887
|
+
const text = inlineMarkdown(trimEdges(normalizeInline(flat)), (line) => line, extensions);
|
|
649
888
|
return `${'#'.repeat(block.level)}${text ? ` ${text}` : ''}${text.endsWith('#') ? ' #' : ''}`;
|
|
650
889
|
}
|
|
651
890
|
case 'code': {
|
|
@@ -662,13 +901,13 @@ function writeBlock(block) {
|
|
|
662
901
|
? `- [${item.checked ? 'x' : ' '}] `
|
|
663
902
|
: '- ';
|
|
664
903
|
const [first, ...rest] = item.blocks;
|
|
665
|
-
const head = first ? indent(writeBlock(first), ' ').trimStart() : '';
|
|
666
|
-
const tail = writeBlocks(rest);
|
|
904
|
+
const head = first ? indent(writeBlock(first, extensions), ' ').trimStart() : '';
|
|
905
|
+
const tail = writeBlocks(rest, extensions);
|
|
667
906
|
return `${marker}${head}${tail ? `\n\n${indent(tail, ' ')}` : ''}`;
|
|
668
907
|
})
|
|
669
908
|
.join('\n');
|
|
670
909
|
case 'quote':
|
|
671
|
-
return writeBlocks(block.blocks)
|
|
910
|
+
return writeBlocks(block.blocks, extensions)
|
|
672
911
|
.split('\n')
|
|
673
912
|
.map((line) => (line ? `> ${line}` : '>'))
|
|
674
913
|
.join('\n');
|
|
@@ -685,7 +924,8 @@ function trimEdges(content) {
|
|
|
685
924
|
out.shift();
|
|
686
925
|
while (out.at(-1)?.type === 'break')
|
|
687
926
|
out.pop();
|
|
688
|
-
const plain = (node) => node?.type === 'text' &&
|
|
927
|
+
const plain = (node) => node?.type === 'text' &&
|
|
928
|
+
!node.marks.some((mark) => mark.type === 'code' || mark.type === 'extension');
|
|
689
929
|
const first = out[0];
|
|
690
930
|
if (plain(first))
|
|
691
931
|
out[0] = { ...first, text: first.text.trimStart() };
|
|
@@ -700,6 +940,6 @@ function trimEdges(content) {
|
|
|
700
940
|
* paragraphs, breaks in headings (written as spaces), breaks at the edges of
|
|
701
941
|
* a block, and whitespace at the edges of a paragraph or a mark.
|
|
702
942
|
*/
|
|
703
|
-
export function toMarkdown(doc) {
|
|
704
|
-
return writeBlocks(doc.blocks);
|
|
943
|
+
export function toMarkdown(doc, options = {}) {
|
|
944
|
+
return writeBlocks(doc.blocks, toInlineExtensions(options.extensions));
|
|
705
945
|
}
|