@blue-patterns/richtext 0.0.0-stage → 0.7.18
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/CHANGELOG.md +275 -0
- package/LICENSE +22 -0
- package/README.md +88 -2
- package/astryx.integration.mjs +11 -0
- package/blocks/RichTextEditorShowcase.template.mjs +13 -0
- package/blocks/RichTextEditorShowcase.tsx +19 -0
- package/dist/RichTextEditor.d.ts +282 -0
- package/dist/RichTextEditor.d.ts.map +1 -0
- package/dist/RichTextEditor.js +657 -0
- package/dist/RichTextEditorAutoLinkPlugin.d.ts +83 -0
- package/dist/RichTextEditorAutoLinkPlugin.d.ts.map +1 -0
- package/dist/RichTextEditorAutoLinkPlugin.js +99 -0
- package/dist/RichTextEditorToolbar.d.ts +186 -0
- package/dist/RichTextEditorToolbar.d.ts.map +1 -0
- package/dist/RichTextEditorToolbar.js +688 -0
- package/dist/RichTextView.d.ts +92 -0
- package/dist/RichTextView.d.ts.map +1 -0
- package/dist/RichTextView.js +267 -0
- package/dist/codeLanguages.d.ts +14 -0
- package/dist/codeLanguages.d.ts.map +1 -0
- package/dist/codeLanguages.js +34 -0
- package/dist/codeLineTokens.d.ts +53 -0
- package/dist/codeLineTokens.d.ts.map +1 -0
- package/dist/codeLineTokens.js +188 -0
- package/dist/editorNodes.d.ts +8 -0
- package/dist/editorNodes.d.ts.map +1 -0
- package/dist/editorNodes.js +30 -0
- package/dist/editorTheme.d.ts +7 -0
- package/dist/editorTheme.d.ts.map +1 -0
- package/dist/editorTheme.js +328 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/internal/CodeBackspacePlugin.d.ts +2 -0
- package/dist/internal/CodeBackspacePlugin.d.ts.map +1 -0
- package/dist/internal/CodeBackspacePlugin.js +70 -0
- package/dist/internal/CodeBlockHeaderPlugin.d.ts +11 -0
- package/dist/internal/CodeBlockHeaderPlugin.d.ts.map +1 -0
- package/dist/internal/CodeBlockHeaderPlugin.js +204 -0
- package/dist/internal/CodePastePlugin.d.ts +5 -0
- package/dist/internal/CodePastePlugin.d.ts.map +1 -0
- package/dist/internal/CodePastePlugin.js +104 -0
- package/dist/internal/CodeSyntaxPlugin.d.ts +8 -0
- package/dist/internal/CodeSyntaxPlugin.d.ts.map +1 -0
- package/dist/internal/CodeSyntaxPlugin.js +397 -0
- package/dist/internal/CodeTabPlugin.d.ts +2 -0
- package/dist/internal/CodeTabPlugin.d.ts.map +1 -0
- package/dist/internal/CodeTabPlugin.js +256 -0
- package/dist/internal/DisabledLinkGuardPlugin.d.ts +4 -0
- package/dist/internal/DisabledLinkGuardPlugin.d.ts.map +1 -0
- package/dist/internal/DisabledLinkGuardPlugin.js +93 -0
- package/dist/internal/ImageSourceCaretPlugin.d.ts +3 -0
- package/dist/internal/ImageSourceCaretPlugin.d.ts.map +1 -0
- package/dist/internal/ImageSourceCaretPlugin.js +105 -0
- package/dist/internal/ListItemShortcutsPlugin.d.ts +6 -0
- package/dist/internal/ListItemShortcutsPlugin.d.ts.map +1 -0
- package/dist/internal/ListItemShortcutsPlugin.js +314 -0
- package/dist/internal/MarkdownExtensionsPlugin.d.ts +17 -0
- package/dist/internal/MarkdownExtensionsPlugin.d.ts.map +1 -0
- package/dist/internal/MarkdownExtensionsPlugin.js +185 -0
- package/dist/internal/SelectAllScopePlugin.d.ts +2 -0
- package/dist/internal/SelectAllScopePlugin.d.ts.map +1 -0
- package/dist/internal/SelectAllScopePlugin.js +67 -0
- package/dist/internal/TableColumnFloorPlugin.d.ts +12 -0
- package/dist/internal/TableColumnFloorPlugin.d.ts.map +1 -0
- package/dist/internal/TableColumnFloorPlugin.js +84 -0
- package/dist/internal/TablePastePlugin.d.ts +9 -0
- package/dist/internal/TablePastePlugin.d.ts.map +1 -0
- package/dist/internal/TablePastePlugin.js +183 -0
- package/dist/internal/TableScrollRegionPlugin.d.ts +9 -0
- package/dist/internal/TableScrollRegionPlugin.d.ts.map +1 -0
- package/dist/internal/TableScrollRegionPlugin.js +95 -0
- package/dist/internal/TaskCheckboxPlugin.d.ts +7 -0
- package/dist/internal/TaskCheckboxPlugin.d.ts.map +1 -0
- package/dist/internal/TaskCheckboxPlugin.js +193 -0
- package/dist/internal/ToolbarLinkPopover.d.ts +74 -0
- package/dist/internal/ToolbarLinkPopover.d.ts.map +1 -0
- package/dist/internal/ToolbarLinkPopover.js +292 -0
- package/dist/linkEditing.d.ts +6 -0
- package/dist/linkEditing.d.ts.map +1 -0
- package/dist/linkEditing.js +20 -0
- package/dist/linkForm.d.ts +28 -0
- package/dist/linkForm.d.ts.map +1 -0
- package/dist/linkForm.js +137 -0
- package/dist/linkUtils.d.ts +27 -0
- package/dist/linkUtils.d.ts.map +1 -0
- package/dist/linkUtils.js +101 -0
- package/dist/markdown.d.ts +17 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +17 -0
- package/dist/markdownCharacterReferences.d.ts +135 -0
- package/dist/markdownCharacterReferences.d.ts.map +1 -0
- package/dist/markdownCharacterReferences.js +1127 -0
- package/dist/markdownCodeFence.d.ts +4 -0
- package/dist/markdownCodeFence.d.ts.map +1 -0
- package/dist/markdownCodeFence.js +167 -0
- package/dist/markdownExtensionNode.d.ts +88 -0
- package/dist/markdownExtensionNode.d.ts.map +1 -0
- package/dist/markdownExtensionNode.js +248 -0
- package/dist/markdownExtensions.d.ts +115 -0
- package/dist/markdownExtensions.d.ts.map +1 -0
- package/dist/markdownExtensions.js +392 -0
- package/dist/markdownHardLineBreak.d.ts +25 -0
- package/dist/markdownHardLineBreak.d.ts.map +1 -0
- package/dist/markdownHardLineBreak.js +59 -0
- package/dist/markdownHeading.d.ts +20 -0
- package/dist/markdownHeading.d.ts.map +1 -0
- package/dist/markdownHeading.js +30 -0
- package/dist/markdownLink.d.ts +5 -0
- package/dist/markdownLink.d.ts.map +1 -0
- package/dist/markdownLink.js +80 -0
- package/dist/markdownListExport.d.ts +3 -0
- package/dist/markdownListExport.d.ts.map +1 -0
- package/dist/markdownListExport.js +68 -0
- package/dist/markdownListIndentation.d.ts +8 -0
- package/dist/markdownListIndentation.d.ts.map +1 -0
- package/dist/markdownListIndentation.js +101 -0
- package/dist/markdownOrderedList.d.ts +3 -0
- package/dist/markdownOrderedList.d.ts.map +1 -0
- package/dist/markdownOrderedList.js +51 -0
- package/dist/markdownQuote.d.ts +22 -0
- package/dist/markdownQuote.d.ts.map +1 -0
- package/dist/markdownQuote.js +32 -0
- package/dist/markdownSerializers.d.ts +49 -0
- package/dist/markdownSerializers.d.ts.map +1 -0
- package/dist/markdownSerializers.js +83 -0
- package/dist/markdownSource.d.ts +71 -0
- package/dist/markdownSource.d.ts.map +1 -0
- package/dist/markdownSource.js +804 -0
- package/dist/markdownSpaceRuns.d.ts +24 -0
- package/dist/markdownSpaceRuns.d.ts.map +1 -0
- package/dist/markdownSpaceRuns.js +147 -0
- package/dist/markdownTable.d.ts +35 -0
- package/dist/markdownTable.d.ts.map +1 -0
- package/dist/markdownTable.js +227 -0
- package/dist/markdownTaskList.d.ts +48 -0
- package/dist/markdownTaskList.d.ts.map +1 -0
- package/dist/markdownTaskList.js +166 -0
- package/dist/markdownThematicBreak.d.ts +3 -0
- package/dist/markdownThematicBreak.d.ts.map +1 -0
- package/dist/markdownThematicBreak.js +55 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts +12 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts.map +1 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.js +16 -0
- package/dist/richtext.css +213 -0
- package/dist/textSemantics.d.ts +3 -0
- package/dist/textSemantics.d.ts.map +1 -0
- package/dist/textSemantics.js +87 -0
- package/package.json +158 -3
- package/src/RichTextEditor.doc.mjs +308 -0
- package/src/RichTextEditor.tsx +1109 -0
- package/src/RichTextEditorAutoLinkPlugin.doc.mjs +43 -0
- package/src/RichTextEditorAutoLinkPlugin.tsx +126 -0
- package/src/RichTextEditorToolbar.doc.mjs +92 -0
- package/src/RichTextEditorToolbar.tsx +871 -0
- package/src/RichTextView.doc.mjs +101 -0
- package/src/RichTextView.tsx +351 -0
- package/src/codeLanguages.ts +42 -0
- package/src/codeLineTokens.ts +241 -0
- package/src/doc-mjs.d.ts +23 -0
- package/src/editorNodes.ts +44 -0
- package/src/editorTheme.ts +550 -0
- package/src/index.ts +59 -0
- package/src/internal/CodeBackspacePlugin.tsx +96 -0
- package/src/internal/CodeBlockHeaderPlugin.tsx +282 -0
- package/src/internal/CodePastePlugin.tsx +144 -0
- package/src/internal/CodeSyntaxPlugin.tsx +493 -0
- package/src/internal/CodeTabPlugin.tsx +298 -0
- package/src/internal/DisabledLinkGuardPlugin.tsx +93 -0
- package/src/internal/ImageSourceCaretPlugin.tsx +159 -0
- package/src/internal/ListItemShortcutsPlugin.tsx +404 -0
- package/src/internal/MarkdownExtensionsPlugin.tsx +289 -0
- package/src/internal/SelectAllScopePlugin.tsx +86 -0
- package/src/internal/TableColumnFloorPlugin.tsx +99 -0
- package/src/internal/TablePastePlugin.tsx +223 -0
- package/src/internal/TableScrollRegionPlugin.tsx +107 -0
- package/src/internal/TaskCheckboxPlugin.tsx +246 -0
- package/src/internal/ToolbarLinkPopover.tsx +392 -0
- package/src/linkEditing.ts +20 -0
- package/src/linkForm.tsx +156 -0
- package/src/linkUtils.ts +106 -0
- package/src/markdown.ts +25 -0
- package/src/markdownCharacterReferences.ts +1137 -0
- package/src/markdownCodeFence.ts +216 -0
- package/src/markdownExtensionNode.ts +339 -0
- package/src/markdownExtensions.ts +496 -0
- package/src/markdownHardLineBreak.ts +73 -0
- package/src/markdownHeading.ts +33 -0
- package/src/markdownLink.ts +86 -0
- package/src/markdownListExport.ts +85 -0
- package/src/markdownListIndentation.ts +105 -0
- package/src/markdownOrderedList.ts +60 -0
- package/src/markdownQuote.ts +35 -0
- package/src/markdownSerializers.ts +108 -0
- package/src/markdownSource.ts +1045 -0
- package/src/markdownSpaceRuns.ts +164 -0
- package/src/markdownTable.ts +309 -0
- package/src/markdownTaskList.ts +222 -0
- package/src/markdownThematicBreak.ts +66 -0
- package/src/privateAttributes/layerPortalBoundary.stylex.ts +18 -0
- package/src/textSemantics.ts +104 -0
|
@@ -0,0 +1,1127 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file markdownCharacterReferences.ts
|
|
5
|
+
* @input Uses lexical, @lexical/link, @lexical/code, and core Markdown's
|
|
6
|
+
* decodeMarkdownCharacterReferences.
|
|
7
|
+
* @output Exports protectRefusedLinks, protectBackslashEscapes,
|
|
8
|
+
* protectLinkDestinationParentheses, and protectCharacterReferences, which
|
|
9
|
+
* swap every link core refuses, every backslash escape, every parenthesis
|
|
10
|
+
* inside a link destination, and every character reference in Markdown
|
|
11
|
+
* source for a private-use stand-in before Lexical imports it, and
|
|
12
|
+
* $restoreCharacterReferences, which puts the literal or decoded text in
|
|
13
|
+
* place of the stand-ins afterwards.
|
|
14
|
+
* @position Used by importMarkdownKeepingSource (markdownSource.ts), so every
|
|
15
|
+
* block — paragraphs, headings, lists, quotes, table cells, link text and
|
|
16
|
+
* destinations — decodes references with the one decoder core Markdown
|
|
17
|
+
* renders with (spec:AST-061 FR7, DEC-5), in one pass over the source and
|
|
18
|
+
* one over the imported text. The stand-ins mean nothing to Lexical's
|
|
19
|
+
* Markdown import, so a decoded `*` never starts emphasis, and no `&#digits;`
|
|
20
|
+
* reaches Lexical's own decoding, which ignores escapes and throws on a
|
|
21
|
+
* number past Unicode. Code keeps references as written.
|
|
22
|
+
* Backslash escapes go through the same way, so an escaped character is
|
|
23
|
+
* literal text wherever it is — `\[x](y)` is text, not a link, as in core
|
|
24
|
+
* Markdown (spec:AST-061 FR7, spec:AST-062 FR3) — and code keeps its
|
|
25
|
+
* backslashes. Parentheses inside a link destination go through the same
|
|
26
|
+
* way, so Lexical, which reads none in a destination, reads balanced ones
|
|
27
|
+
* as core does.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { $isCodeNode } from '@lexical/code';
|
|
31
|
+
import { $isLinkNode } from '@lexical/link';
|
|
32
|
+
import { $isTableCellNode } from '@lexical/table';
|
|
33
|
+
import { $dfs, $findMatchingParent } from '@lexical/utils';
|
|
34
|
+
import { $getState, $isElementNode, $isTextNode, $setState, createState } from 'lexical';
|
|
35
|
+
import { decodeMarkdownCharacterReferences, parseInlineAst } from '@blue-patterns/core/Markdown/parser';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What a stand-in replaced: a reference as written, an escaped `&`, or a
|
|
39
|
+
* backslash-escaped character.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
// Something that looks like a reference; the decoder decides if it is one.
|
|
43
|
+
const REFERENCE_LIKE = /&(?:#[xX][0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*);/y;
|
|
44
|
+
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
|
|
45
|
+
const PRIVATE_USE_RANGES = [[0xe000, 0xf8ff], [0xf0000, 0xffffd], [0x100000, 0x10fffd]];
|
|
46
|
+
|
|
47
|
+
/** Private-use characters that do not occur in `text`, in order. */
|
|
48
|
+
export function* absentCharacters(text) {
|
|
49
|
+
const present = new Set(text);
|
|
50
|
+
for (const [first, last] of PRIVATE_USE_RANGES) {
|
|
51
|
+
for (let codePoint = first; codePoint <= last; codePoint++) {
|
|
52
|
+
const character = String.fromCodePoint(codePoint);
|
|
53
|
+
if (!present.has(character)) {
|
|
54
|
+
yield character;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The ranges of `text` that are code: fenced code blocks, and code spans,
|
|
62
|
+
* where a backtick run opens a span only if a later run of the same length
|
|
63
|
+
* closes it (CommonMark 0.31). One scan collects the runs; runs of each length
|
|
64
|
+
* are searched by position, so the whole is O(n log n).
|
|
65
|
+
*/
|
|
66
|
+
function codeRanges(text) {
|
|
67
|
+
const {
|
|
68
|
+
fenced,
|
|
69
|
+
spans
|
|
70
|
+
} = codeRangesByKind(text);
|
|
71
|
+
return [...fenced, ...spans].sort((a, b) => a[0] - b[0]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The fenced code blocks of `text`, and its code spans, each in order. */
|
|
75
|
+
function codeRangesByKind(text) {
|
|
76
|
+
const ranges = [];
|
|
77
|
+
// Fenced code blocks, line by line.
|
|
78
|
+
let lineStart = 0;
|
|
79
|
+
let fence = null;
|
|
80
|
+
while (lineStart <= text.length) {
|
|
81
|
+
const lineEnd = text.indexOf('\n', lineStart);
|
|
82
|
+
const end = lineEnd === -1 ? text.length : lineEnd;
|
|
83
|
+
const line = text.slice(lineStart, end);
|
|
84
|
+
if (fence == null) {
|
|
85
|
+
const open = FENCE_OPEN.exec(line);
|
|
86
|
+
if (open != null) {
|
|
87
|
+
fence = {
|
|
88
|
+
marker: open[1],
|
|
89
|
+
start: lineStart
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
} else if (new RegExp(`^ {0,3}\\${fence.marker[0]}{${fence.marker.length},}[ \\t]*$`).test(line)) {
|
|
93
|
+
ranges.push([fence.start, end]);
|
|
94
|
+
fence = null;
|
|
95
|
+
}
|
|
96
|
+
if (lineEnd === -1) {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
lineStart = lineEnd + 1;
|
|
100
|
+
}
|
|
101
|
+
if (fence != null) {
|
|
102
|
+
ranges.push([fence.start, text.length]);
|
|
103
|
+
}
|
|
104
|
+
const fencedCount = ranges.length;
|
|
105
|
+
// Code spans, outside fenced code. A backtick run is a string of backticks
|
|
106
|
+
// with no backtick on either side. Backslashes are literal inside a code
|
|
107
|
+
// span, so they never hide a closing run; an odd number of backslashes
|
|
108
|
+
// before a run only makes its first backtick literal, so the rest of the
|
|
109
|
+
// run (if any) may still open a span.
|
|
110
|
+
const runs = [];
|
|
111
|
+
let index = 0;
|
|
112
|
+
let fenceIndex = 0;
|
|
113
|
+
while (index < text.length) {
|
|
114
|
+
const current = ranges[fenceIndex];
|
|
115
|
+
if (current != null && index >= current[0]) {
|
|
116
|
+
index = current[1];
|
|
117
|
+
fenceIndex++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (text[index] === '`') {
|
|
121
|
+
let end = index;
|
|
122
|
+
while (text[end] === '`') {
|
|
123
|
+
end++;
|
|
124
|
+
}
|
|
125
|
+
runs.push({
|
|
126
|
+
start: index,
|
|
127
|
+
length: end - index
|
|
128
|
+
});
|
|
129
|
+
index = end;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
index++;
|
|
133
|
+
}
|
|
134
|
+
// Each run's position in the list of runs of its length, for finding the
|
|
135
|
+
// next run of a given length after a point by binary search.
|
|
136
|
+
const byLength = new Map();
|
|
137
|
+
for (const run of runs) {
|
|
138
|
+
const starts = byLength.get(run.length) ?? [];
|
|
139
|
+
starts.push(run.start);
|
|
140
|
+
byLength.set(run.length, starts);
|
|
141
|
+
}
|
|
142
|
+
const nextRunAfter = (length, after) => {
|
|
143
|
+
const starts = byLength.get(length);
|
|
144
|
+
if (starts == null) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
let low = 0;
|
|
148
|
+
let high = starts.length;
|
|
149
|
+
while (low < high) {
|
|
150
|
+
const middle = low + high >> 1;
|
|
151
|
+
if (starts[middle] < after) {
|
|
152
|
+
low = middle + 1;
|
|
153
|
+
} else {
|
|
154
|
+
high = middle;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return starts[low] ?? null;
|
|
158
|
+
};
|
|
159
|
+
let resumeAt = 0;
|
|
160
|
+
for (const run of runs) {
|
|
161
|
+
if (run.start < resumeAt) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
let backslashes = 0;
|
|
165
|
+
while (text[run.start - 1 - backslashes] === '\\') {
|
|
166
|
+
backslashes++;
|
|
167
|
+
}
|
|
168
|
+
const isEscaped = backslashes % 2 === 1;
|
|
169
|
+
const openStart = isEscaped ? run.start + 1 : run.start;
|
|
170
|
+
const openLength = isEscaped ? run.length - 1 : run.length;
|
|
171
|
+
if (openLength === 0) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const closer = nextRunAfter(openLength, run.start + run.length);
|
|
175
|
+
if (closer == null) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
ranges.push([openStart, closer + openLength]);
|
|
179
|
+
resumeAt = closer + openLength;
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
fenced: ranges.slice(0, fencedCount),
|
|
183
|
+
spans: ranges.slice(fencedCount).sort((a, b) => a[0] - b[0])
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The characters a backslash escapes (CommonMark 0.31 §2.4). */
|
|
188
|
+
const ESCAPABLE = new Set('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~');
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Returns `markdown` with every backslash escape outside code — a backslash
|
|
192
|
+
* before ASCII punctuation — replaced by a private-use stand-in for the
|
|
193
|
+
* escaped character, and every backslash in code by a stand-in for itself,
|
|
194
|
+
* which Lexical would otherwise drop from a code span — except before `|`,
|
|
195
|
+
* which a table reads before its code. A backslash before anything else
|
|
196
|
+
* outside code stays as written. One pass over the source.
|
|
197
|
+
*/
|
|
198
|
+
export function protectBackslashEscapes(markdown) {
|
|
199
|
+
const standIns = new Map();
|
|
200
|
+
if (!markdown.includes('\\')) {
|
|
201
|
+
return {
|
|
202
|
+
markdown,
|
|
203
|
+
standIns
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const available = absentCharacters(markdown);
|
|
207
|
+
const byKey = new Map();
|
|
208
|
+
const standInFor = (key, value) => {
|
|
209
|
+
const existing = byKey.get(key);
|
|
210
|
+
if (existing != null) {
|
|
211
|
+
return existing;
|
|
212
|
+
}
|
|
213
|
+
const next = available.next();
|
|
214
|
+
if (next.done === true) {
|
|
215
|
+
// Every private-use character is in the text: leave the rest as
|
|
216
|
+
// written.
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
byKey.set(key, next.value);
|
|
220
|
+
standIns.set(next.value, value);
|
|
221
|
+
return next.value;
|
|
222
|
+
};
|
|
223
|
+
const code = codeRanges(markdown);
|
|
224
|
+
let output = '';
|
|
225
|
+
let copied = 0;
|
|
226
|
+
let codeIndex = 0;
|
|
227
|
+
let index = markdown.indexOf('\\');
|
|
228
|
+
while (index !== -1) {
|
|
229
|
+
while (code[codeIndex] != null && code[codeIndex][1] <= index) {
|
|
230
|
+
codeIndex++;
|
|
231
|
+
}
|
|
232
|
+
const range = code[codeIndex];
|
|
233
|
+
const character = markdown[index + 1];
|
|
234
|
+
const inCode = range != null && range[0] <= index;
|
|
235
|
+
// A table splits its cells before it reads code, so `\|` in code is the
|
|
236
|
+
// table's to read.
|
|
237
|
+
if (inCode && character === '|') {
|
|
238
|
+
index = markdown.indexOf('\\', index + 2);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const isEscape = !inCode && character != null && ESCAPABLE.has(character);
|
|
242
|
+
if (inCode || isEscape) {
|
|
243
|
+
const standIn = inCode ? standInFor('code', {
|
|
244
|
+
kind: 'backslash'
|
|
245
|
+
}) : standInFor(`escape:${character}`, {
|
|
246
|
+
kind: 'escape',
|
|
247
|
+
character: character ?? ''
|
|
248
|
+
});
|
|
249
|
+
if (standIn == null) {
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
output += markdown.slice(copied, index) + standIn;
|
|
253
|
+
copied = index + (inCode ? 1 : 2);
|
|
254
|
+
index = markdown.indexOf('\\', copied);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
index = markdown.indexOf('\\', index + 1);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
markdown: output + markdown.slice(copied),
|
|
261
|
+
standIns
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* For each `[`, `]`, `(`, and `)` in `text` outside code and not escaped,
|
|
267
|
+
* the index of the bracket or parenthesis it pairs with by nesting; -1 for
|
|
268
|
+
* the rest. One pass, so a candidate never rescans the text.
|
|
269
|
+
*/
|
|
270
|
+
function pairedDelimiters(text, code) {
|
|
271
|
+
const pair = new Int32Array(text.length).fill(-1);
|
|
272
|
+
const brackets = [];
|
|
273
|
+
const parentheses = [];
|
|
274
|
+
let codeIndex = 0;
|
|
275
|
+
for (let index = 0; index < text.length; index++) {
|
|
276
|
+
while (codeIndex < code.length && code[codeIndex][1] <= index) {
|
|
277
|
+
codeIndex++;
|
|
278
|
+
}
|
|
279
|
+
if (codeIndex < code.length && code[codeIndex][0] <= index) {
|
|
280
|
+
index = code[codeIndex][1] - 1;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const character = text[index];
|
|
284
|
+
const open = character === ']' ? brackets.pop() : character === ')' ? parentheses.pop() : undefined;
|
|
285
|
+
if (character === '\\') {
|
|
286
|
+
index++;
|
|
287
|
+
} else if (character === '[') {
|
|
288
|
+
brackets.push(index);
|
|
289
|
+
} else if (character === '(') {
|
|
290
|
+
parentheses.push(index);
|
|
291
|
+
} else if (open !== undefined) {
|
|
292
|
+
pair[index] = open;
|
|
293
|
+
pair[open] = index;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return pair;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Where a destination that opens with `<` ends — the index of the `)` that
|
|
301
|
+
* closes the link — read as core Markdown reads it (CommonMark 0.31 §6.3):
|
|
302
|
+
* `'refused'` when it opens with `<` but is no angle-bracket destination,
|
|
303
|
+
* and null when the content after `open` (the `(`) does not open with `<`.
|
|
304
|
+
* Inside the brackets parentheses are plain characters; a line ending, even
|
|
305
|
+
* escaped, or an unescaped `<` makes it no destination; after the `>` come
|
|
306
|
+
* only spaces, an optional title, and the `)`.
|
|
307
|
+
*/
|
|
308
|
+
function angleDestinationEnd(text, open) {
|
|
309
|
+
let index = open + 1;
|
|
310
|
+
while (text[index] === ' ' || text[index] === '\t') {
|
|
311
|
+
index++;
|
|
312
|
+
}
|
|
313
|
+
if (text[index] !== '<') {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
for (index++; index < text.length; index++) {
|
|
317
|
+
const character = text[index];
|
|
318
|
+
if (character === '\\' && text[index + 1] !== '\n' && text[index + 1] !== '\r') {
|
|
319
|
+
index++;
|
|
320
|
+
} else if (character === '\n' || character === '\r' || character === '<') {
|
|
321
|
+
return 'refused';
|
|
322
|
+
} else if (character === '>') {
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (index >= text.length) {
|
|
327
|
+
return 'refused';
|
|
328
|
+
}
|
|
329
|
+
index++;
|
|
330
|
+
while (/\s/.test(text[index] ?? '')) {
|
|
331
|
+
index++;
|
|
332
|
+
}
|
|
333
|
+
const quote = text[index];
|
|
334
|
+
if (quote === '"' || quote === "'" || quote === '(') {
|
|
335
|
+
const closer = quote === '(' ? ')' : quote;
|
|
336
|
+
for (index++; index < text.length && text[index] !== closer; index++) {
|
|
337
|
+
if (text[index] === '\\') {
|
|
338
|
+
index++;
|
|
339
|
+
} else if (quote === '(' && text[index] === '(') {
|
|
340
|
+
return 'refused';
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
index++;
|
|
344
|
+
while (/\s/.test(text[index] ?? '')) {
|
|
345
|
+
index++;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return text[index] === ')' ? index : 'refused';
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Whether core Markdown refuses a link to `destination`, as written in the
|
|
353
|
+
* source: its own parser decides, so both surfaces refuse the same
|
|
354
|
+
* destinations by one policy.
|
|
355
|
+
*/
|
|
356
|
+
function coreRefusesDestination(destination) {
|
|
357
|
+
const probe = `[x](${destination})`;
|
|
358
|
+
const [only, ...rest] = parseInlineAst(probe);
|
|
359
|
+
return rest.length === 0 && only?.type === 'text' && only.value === probe;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Whether core Markdown refuses a link to `url`, a destination already
|
|
364
|
+
* decoded: the probe writes it back with every special character escaped.
|
|
365
|
+
* A URL that cannot be written back is refused.
|
|
366
|
+
*/
|
|
367
|
+
export function coreRefusesUrl(url) {
|
|
368
|
+
if (/[\n\r]/.test(url)) {
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
// Angle brackets never decide a scheme, so the probe percent-encodes them:
|
|
372
|
+
// the URL then fits one angle-bracket destination even with spaces in it.
|
|
373
|
+
const escaped = url.replace(/[<>]/g, character => character === '<' ? '%3C' : '%3E').replace(/[\\()&[\]"']/g, character => `\\${character}`);
|
|
374
|
+
return coreRefusesDestination(/\s/.test(url) ? `<${escaped}>` : escaped);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Returns `markdown` with each inline link whose destination core Markdown
|
|
379
|
+
* refuses — `javascript:` and the like — kept from becoming a link
|
|
380
|
+
* (spec:AST-061 FR7). Where core shows the link as its source text, exactly
|
|
381
|
+
* as written, the whole source becomes one stand-in that comes back as that
|
|
382
|
+
* text. Where core reads no link there at all (its link text holds brackets
|
|
383
|
+
* of its own), only the `(` that opens the destination becomes a stand-in,
|
|
384
|
+
* so the text around it reads as core reads it, marks included. Brackets and
|
|
385
|
+
* parentheses pair by nesting, so link text holding brackets is found whole.
|
|
386
|
+
* Linear in the source: pairs come from one pass, and a destination inside a
|
|
387
|
+
* found link is not probed again. Run first, on the source as written;
|
|
388
|
+
* `$unwrapRefusedLinks` catches any link the editor still makes.
|
|
389
|
+
*/
|
|
390
|
+
export function protectRefusedLinks(markdown) {
|
|
391
|
+
const standIns = new Map();
|
|
392
|
+
if (!markdown.includes('](')) {
|
|
393
|
+
return {
|
|
394
|
+
markdown,
|
|
395
|
+
standIns
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
const pair = pairedDelimiters(markdown, codeRanges(markdown));
|
|
399
|
+
// Ranges to replace, in order: a whole link, or the `(` that opens its
|
|
400
|
+
// destination.
|
|
401
|
+
const refused = [];
|
|
402
|
+
// Where the last probed destination ends: a link cannot sit inside one.
|
|
403
|
+
let probedTo = -1;
|
|
404
|
+
for (let index = markdown.indexOf(']('); index !== -1; index = markdown.indexOf('](', index + 1)) {
|
|
405
|
+
const open = pair[index];
|
|
406
|
+
// A destination in angle brackets ends where core ends it — parentheses
|
|
407
|
+
// inside the brackets are plain characters — so `[a](<b)c>)` is probed
|
|
408
|
+
// whole, not as `<b`.
|
|
409
|
+
const angleClose = angleDestinationEnd(markdown, index + 1);
|
|
410
|
+
const close = typeof angleClose === 'number' ? angleClose : pair[index + 1];
|
|
411
|
+
if (index < probedTo || open === -1 || close === -1) {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
probedTo = close;
|
|
415
|
+
if (!coreRefusesDestination(markdown.slice(index + 2, close))) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const source = markdown.slice(open, close + 1);
|
|
419
|
+
const [only, ...rest] = parseInlineAst(source);
|
|
420
|
+
if (rest.length === 0 && only?.type === 'text' && only.value === source) {
|
|
421
|
+
// A whole link stands for the refused links found inside it.
|
|
422
|
+
while (refused.length > 0 && refused[refused.length - 1].start >= open) {
|
|
423
|
+
refused.pop();
|
|
424
|
+
}
|
|
425
|
+
refused.push({
|
|
426
|
+
start: open,
|
|
427
|
+
end: close + 1,
|
|
428
|
+
whole: true
|
|
429
|
+
});
|
|
430
|
+
} else {
|
|
431
|
+
refused.push({
|
|
432
|
+
start: index + 1,
|
|
433
|
+
end: index + 2,
|
|
434
|
+
whole: false
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (refused.length === 0) {
|
|
439
|
+
return {
|
|
440
|
+
markdown,
|
|
441
|
+
standIns
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
const available = absentCharacters(markdown);
|
|
445
|
+
let output = '';
|
|
446
|
+
let copied = 0;
|
|
447
|
+
for (const {
|
|
448
|
+
start,
|
|
449
|
+
end,
|
|
450
|
+
whole
|
|
451
|
+
} of refused) {
|
|
452
|
+
const next = available.next();
|
|
453
|
+
if (next.done === true || start < copied) {
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
standIns.set(next.value, whole ? {
|
|
457
|
+
kind: 'literal',
|
|
458
|
+
text: markdown.slice(start, end)
|
|
459
|
+
} : {
|
|
460
|
+
kind: 'escape',
|
|
461
|
+
character: '('
|
|
462
|
+
});
|
|
463
|
+
output += markdown.slice(copied, start) + next.value;
|
|
464
|
+
copied = end;
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
markdown: output + markdown.slice(copied),
|
|
468
|
+
standIns
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Marks text that holds one image's Markdown source. Export writes it
|
|
474
|
+
* unescaped, so it stays an image.
|
|
475
|
+
*/
|
|
476
|
+
const imageSourceState = createState('astryxMdImage', {
|
|
477
|
+
parse: value => value === true
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
/** Whether `node` is text that holds an image's Markdown source. */
|
|
481
|
+
export function $isImageSource(node) {
|
|
482
|
+
return $isTextNode(node) && $getState(node, imageSourceState);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Returns `markdown` with every image outside code replaced by a private-use
|
|
487
|
+
* stand-in, and the source each stand-in replaced. An image starts with a `!`
|
|
488
|
+
* that is not escaped and the paired brackets of its text. An inline image
|
|
489
|
+
* (``) ends with the destination and title that close it, read whole
|
|
490
|
+
* by core's inline parser. A reference image (`![a][b]`, `![a][]`, `![a]`)
|
|
491
|
+
* ends with its label; its definition can sit in another block, so it is kept
|
|
492
|
+
* whether or not one exists. A link around an image keeps its own brackets,
|
|
493
|
+
* so the image stays inside the link's text.
|
|
494
|
+
*/
|
|
495
|
+
export function protectImages(markdown) {
|
|
496
|
+
const images = new Map();
|
|
497
|
+
if (!markdown.includes('![')) {
|
|
498
|
+
return {
|
|
499
|
+
markdown,
|
|
500
|
+
images
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
const pair = pairedDelimiters(markdown, codeRanges(markdown));
|
|
504
|
+
const found = [];
|
|
505
|
+
for (let start = markdown.indexOf('!['); start !== -1; start = markdown.indexOf('![', start + 1)) {
|
|
506
|
+
const open = start + 1;
|
|
507
|
+
const textClose = pair[open];
|
|
508
|
+
if (textClose === -1 || textClose < open) {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
// An image inside another image's text belongs to the outer one.
|
|
512
|
+
if (found.length > 0 && start < found[found.length - 1].end) {
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
let backslashes = 0;
|
|
516
|
+
while (markdown[start - 1 - backslashes] === '\\') {
|
|
517
|
+
backslashes++;
|
|
518
|
+
}
|
|
519
|
+
if (backslashes % 2 === 1) {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const after = markdown[textClose + 1];
|
|
523
|
+
if (after === '(') {
|
|
524
|
+
const angleClose = angleDestinationEnd(markdown, textClose + 1);
|
|
525
|
+
const close = typeof angleClose === 'number' ? angleClose : pair[textClose + 1];
|
|
526
|
+
if (close === -1 || angleClose === 'refused') {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
const [only, ...rest] = parseInlineAst(markdown.slice(start, close + 1));
|
|
530
|
+
if (rest.length > 0 || only?.type !== 'image') {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
found.push({
|
|
534
|
+
start,
|
|
535
|
+
end: close + 1
|
|
536
|
+
});
|
|
537
|
+
} else if (after === '[' && pair[textClose + 1] > textClose + 1) {
|
|
538
|
+
found.push({
|
|
539
|
+
start,
|
|
540
|
+
end: pair[textClose + 1] + 1
|
|
541
|
+
});
|
|
542
|
+
} else {
|
|
543
|
+
found.push({
|
|
544
|
+
start,
|
|
545
|
+
end: textClose + 1
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (found.length === 0) {
|
|
550
|
+
return {
|
|
551
|
+
markdown,
|
|
552
|
+
images
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
const available = absentCharacters(markdown);
|
|
556
|
+
let output = '';
|
|
557
|
+
let copied = 0;
|
|
558
|
+
for (const {
|
|
559
|
+
start,
|
|
560
|
+
end
|
|
561
|
+
} of found) {
|
|
562
|
+
const next = available.next();
|
|
563
|
+
if (next.done === true) {
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
images.set(next.value, markdown.slice(start, end));
|
|
567
|
+
output += markdown.slice(copied, start) + next.value;
|
|
568
|
+
copied = end;
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
markdown: output + markdown.slice(copied),
|
|
572
|
+
images
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Puts each image's source back where its stand-in sits under `node`, as
|
|
578
|
+
* text marked as an image's source. The text is a token: a person deletes it
|
|
579
|
+
* whole, and autolinking leaves its URL alone. ImageSourceCaretPlugin keeps
|
|
580
|
+
* the caret out of it, so typing never replaces it.
|
|
581
|
+
*/
|
|
582
|
+
export function $restoreImages(node, images) {
|
|
583
|
+
if (images.size === 0) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
for (const text of node.getAllTextNodes()) {
|
|
587
|
+
const content = text.getTextContent();
|
|
588
|
+
const offsets = [];
|
|
589
|
+
let offset = 0;
|
|
590
|
+
for (const character of content) {
|
|
591
|
+
if (images.has(character)) {
|
|
592
|
+
offsets.push(offset, offset + character.length);
|
|
593
|
+
}
|
|
594
|
+
offset += character.length;
|
|
595
|
+
}
|
|
596
|
+
if (offsets.length === 0) {
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
const pieces = text.splitText(...offsets.filter(point => point > 0 && point < content.length));
|
|
600
|
+
for (const piece of pieces) {
|
|
601
|
+
const source = images.get(piece.getTextContent());
|
|
602
|
+
if (source == null) {
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
piece.setTextContent(source);
|
|
606
|
+
piece.setMode('token');
|
|
607
|
+
$setState(piece, imageSourceState, true);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Takes every link under `node` whose URL core Markdown refuses out of its
|
|
614
|
+
* link, leaving its text in place, so no form of link text or destination
|
|
615
|
+
* the source pass did not find can store or draw an unsafe URL.
|
|
616
|
+
*/
|
|
617
|
+
export function $unwrapRefusedLinks(node) {
|
|
618
|
+
for (const {
|
|
619
|
+
node: descendant
|
|
620
|
+
} of $dfs(node)) {
|
|
621
|
+
if ($isLinkNode(descendant) && coreRefusesUrl(descendant.getURL())) {
|
|
622
|
+
for (const child of descendant.getChildren()) {
|
|
623
|
+
descendant.insertBefore(child);
|
|
624
|
+
}
|
|
625
|
+
descendant.remove();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Returns `markdown` with every parenthesis inside an inline link
|
|
632
|
+
* destination — between the destination's own parentheses, balanced, as core
|
|
633
|
+
* Markdown and CommonMark read them — replaced by a private-use stand-in for
|
|
634
|
+
* itself, outside code. Run after protectBackslashEscapes, so an escaped
|
|
635
|
+
* parenthesis is already a stand-in and never counts toward the balance.
|
|
636
|
+
*/
|
|
637
|
+
export function protectLinkDestinationParentheses(markdown) {
|
|
638
|
+
const standIns = new Map();
|
|
639
|
+
if (!markdown.includes('](')) {
|
|
640
|
+
return {
|
|
641
|
+
markdown,
|
|
642
|
+
standIns
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
const available = absentCharacters(markdown);
|
|
646
|
+
const byCharacter = new Map();
|
|
647
|
+
const standInFor = character => {
|
|
648
|
+
const existing = byCharacter.get(character);
|
|
649
|
+
if (existing != null) {
|
|
650
|
+
return existing;
|
|
651
|
+
}
|
|
652
|
+
const next = available.next();
|
|
653
|
+
if (next.done === true) {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
byCharacter.set(character, next.value);
|
|
657
|
+
standIns.set(next.value, {
|
|
658
|
+
kind: 'escape',
|
|
659
|
+
character
|
|
660
|
+
});
|
|
661
|
+
return next.value;
|
|
662
|
+
};
|
|
663
|
+
const code = codeRanges(markdown);
|
|
664
|
+
const inner = [];
|
|
665
|
+
let codeIndex = 0;
|
|
666
|
+
let index = markdown.indexOf('](');
|
|
667
|
+
while (index !== -1) {
|
|
668
|
+
while (code[codeIndex] != null && code[codeIndex][1] <= index) {
|
|
669
|
+
codeIndex++;
|
|
670
|
+
}
|
|
671
|
+
const range = code[codeIndex];
|
|
672
|
+
if (range != null && range[0] <= index) {
|
|
673
|
+
index = markdown.indexOf('](', range[1]);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
// The destination's own parentheses are depth one; any inside it are
|
|
677
|
+
// deeper. A space at depth one ends the destination (a title follows);
|
|
678
|
+
// a space deeper, or no closing parenthesis, means no link.
|
|
679
|
+
const parentheses = [];
|
|
680
|
+
let depth = 1;
|
|
681
|
+
let position = index + 2;
|
|
682
|
+
let isLink = markdown[position] !== '<';
|
|
683
|
+
for (; isLink && position < markdown.length; position++) {
|
|
684
|
+
const character = markdown[position];
|
|
685
|
+
if (/\s/.test(character)) {
|
|
686
|
+
isLink = depth === 1;
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
if (character === '(') {
|
|
690
|
+
depth++;
|
|
691
|
+
parentheses.push(position);
|
|
692
|
+
} else if (character === ')') {
|
|
693
|
+
depth--;
|
|
694
|
+
if (depth === 0) {
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
parentheses.push(position);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (isLink && depth <= 1) {
|
|
701
|
+
inner.push(...parentheses);
|
|
702
|
+
}
|
|
703
|
+
index = markdown.indexOf('](', position + 1);
|
|
704
|
+
}
|
|
705
|
+
if (inner.length === 0) {
|
|
706
|
+
return {
|
|
707
|
+
markdown,
|
|
708
|
+
standIns
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
let output = '';
|
|
712
|
+
let copied = 0;
|
|
713
|
+
for (const position of inner) {
|
|
714
|
+
const standIn = standInFor(markdown[position]);
|
|
715
|
+
if (standIn == null) {
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
output += markdown.slice(copied, position) + standIn;
|
|
719
|
+
copied = position + 1;
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
markdown: output + markdown.slice(copied),
|
|
723
|
+
standIns
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** The code spans a stand-in pass replaced: each stand-in's code. */
|
|
728
|
+
|
|
729
|
+
/** Whether `text` holds a `|` with no backslash before it. */
|
|
730
|
+
function hasUnescapedPipe(text) {
|
|
731
|
+
for (let index = text.indexOf('|'); index !== -1;) {
|
|
732
|
+
let backslashes = 0;
|
|
733
|
+
while (text[index - 1 - backslashes] === '\\') {
|
|
734
|
+
backslashes++;
|
|
735
|
+
}
|
|
736
|
+
if (backslashes % 2 === 0) {
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
index = text.indexOf('|', index + 1);
|
|
740
|
+
}
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Test each cell on its own. One regex over the whole row backtracks in
|
|
746
|
+
* quadratic time on a long run of spaces.
|
|
747
|
+
*/
|
|
748
|
+
function isTableDelimiterRow(line) {
|
|
749
|
+
let body = line.trim();
|
|
750
|
+
if (body.startsWith('|')) {
|
|
751
|
+
body = body.slice(1);
|
|
752
|
+
}
|
|
753
|
+
if (body.endsWith('|')) {
|
|
754
|
+
body = body.slice(0, -1);
|
|
755
|
+
}
|
|
756
|
+
return body.split('|').every(cell => /^:?-+:?$/.test(cell.trim()));
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* The ranges of `markdown` a code span must not start in: inline link
|
|
761
|
+
* destinations and titles, from the `(` after a `]` to its paired `)`. The
|
|
762
|
+
* link reads its destination before any code span there, as core and
|
|
763
|
+
* CommonMark read it.
|
|
764
|
+
*/
|
|
765
|
+
function linkTargetRanges(markdown, code) {
|
|
766
|
+
const ranges = [];
|
|
767
|
+
if (!markdown.includes('](')) {
|
|
768
|
+
return ranges;
|
|
769
|
+
}
|
|
770
|
+
const pair = pairedDelimiters(markdown, code);
|
|
771
|
+
for (let index = markdown.indexOf(']('); index !== -1; index = markdown.indexOf('](', index + 1)) {
|
|
772
|
+
// An angle-bracket destination ends where core ends it: a parenthesis
|
|
773
|
+
// inside the brackets pairs with nothing.
|
|
774
|
+
const angleClose = angleDestinationEnd(markdown, index + 1);
|
|
775
|
+
const close = typeof angleClose === 'number' ? angleClose : pair[index + 1];
|
|
776
|
+
if (pair[index] !== -1 && close !== -1) {
|
|
777
|
+
ranges.push([index + 1, close + 1]);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return ranges;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** The ranges of `markdown` that are GFM table rows, header row included. */
|
|
784
|
+
function tableRowRanges(markdown) {
|
|
785
|
+
const ranges = [];
|
|
786
|
+
const lines = markdown.split('\n');
|
|
787
|
+
const starts = [];
|
|
788
|
+
let offset = 0;
|
|
789
|
+
for (const line of lines) {
|
|
790
|
+
starts.push(offset);
|
|
791
|
+
offset += line.length + 1;
|
|
792
|
+
}
|
|
793
|
+
for (let index = 1; index < lines.length; index++) {
|
|
794
|
+
const line = lines[index];
|
|
795
|
+
if (line.includes('|') && isTableDelimiterRow(line) && lines[index - 1].includes('|')) {
|
|
796
|
+
let end = index + 1;
|
|
797
|
+
while (end < lines.length && lines[end].trim() !== '') {
|
|
798
|
+
end++;
|
|
799
|
+
}
|
|
800
|
+
ranges.push([starts[index - 1], starts[end - 1] + lines[end - 1].length]);
|
|
801
|
+
index = end;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return ranges;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Returns `markdown` with each code span outside fenced code replaced by a
|
|
809
|
+
* private-use stand-in, and the code each stands for, as core Markdown reads
|
|
810
|
+
* it. Code spans bind tighter than emphasis and links (CommonMark 0.31
|
|
811
|
+
* §6.1), but Lexical's import applies a code span before an earlier mark on
|
|
812
|
+
* the same line, which breaks every mark around code that follows another
|
|
813
|
+
* (`~~a~~ ~~`c`~~`); with the code out of the way, the marks pair first and
|
|
814
|
+
* the code comes back inside them. Backticks in a link's destination or
|
|
815
|
+
* title are the link's, never a code span: they become stand-ins too, put
|
|
816
|
+
* back into the link's address and title after import, so Lexical cannot
|
|
817
|
+
* pair them into code either. In a table row, a span holding an unescaped
|
|
818
|
+
* `|` is left alone, so the row still splits its cells there, as GFM does.
|
|
819
|
+
*/
|
|
820
|
+
export function protectCodeSpans(markdown) {
|
|
821
|
+
const spans = new Map();
|
|
822
|
+
const standIns = new Map();
|
|
823
|
+
if (!markdown.includes('`')) {
|
|
824
|
+
return {
|
|
825
|
+
markdown,
|
|
826
|
+
spans,
|
|
827
|
+
standIns
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
const code = codeRangesByKind(markdown);
|
|
831
|
+
const targets = linkTargetRanges(markdown, [...code.fenced, ...code.spans]);
|
|
832
|
+
const rows = tableRowRanges(markdown);
|
|
833
|
+
// Whether a position lies in one of ranges sorted by start, for positions
|
|
834
|
+
// asked in order: one pass over the ranges in all.
|
|
835
|
+
const cursor = ranges => {
|
|
836
|
+
let next = 0;
|
|
837
|
+
let coveredTo = -1;
|
|
838
|
+
return position => {
|
|
839
|
+
while (next < ranges.length && ranges[next][0] <= position) {
|
|
840
|
+
coveredTo = Math.max(coveredTo, ranges[next][1]);
|
|
841
|
+
next++;
|
|
842
|
+
}
|
|
843
|
+
return position < coveredTo;
|
|
844
|
+
};
|
|
845
|
+
};
|
|
846
|
+
const inTarget = cursor(targets);
|
|
847
|
+
const inRow = cursor(rows);
|
|
848
|
+
// Replacements in source order: code spans, and backticks in targets.
|
|
849
|
+
const replacements = [];
|
|
850
|
+
for (const [start, end] of code.spans) {
|
|
851
|
+
const source = markdown.slice(start, end);
|
|
852
|
+
const isInRow = inRow(start);
|
|
853
|
+
if (inTarget(start) || isInRow && hasUnescapedPipe(source)) {
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
const [only, ...rest] = parseInlineAst(source);
|
|
857
|
+
if (rest.length > 0 || only?.type !== 'inlineCode') {
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
replacements.push({
|
|
861
|
+
start,
|
|
862
|
+
end,
|
|
863
|
+
code: only.value
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
for (const [from, to] of targets) {
|
|
867
|
+
for (let index = markdown.indexOf('`', from); index !== -1 && index < to; index = markdown.indexOf('`', index + 1)) {
|
|
868
|
+
replacements.push({
|
|
869
|
+
start: index,
|
|
870
|
+
end: index + 1,
|
|
871
|
+
code: null
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
replacements.sort((a, b) => a.start - b.start);
|
|
876
|
+
const available = absentCharacters(markdown);
|
|
877
|
+
let output = '';
|
|
878
|
+
let copied = 0;
|
|
879
|
+
for (const {
|
|
880
|
+
start,
|
|
881
|
+
end,
|
|
882
|
+
code: content
|
|
883
|
+
} of replacements) {
|
|
884
|
+
if (start < copied) {
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
const next = available.next();
|
|
888
|
+
if (next.done === true) {
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
if (content == null) {
|
|
892
|
+
standIns.set(next.value, {
|
|
893
|
+
kind: 'literal',
|
|
894
|
+
text: '`'
|
|
895
|
+
});
|
|
896
|
+
} else {
|
|
897
|
+
spans.set(next.value, content);
|
|
898
|
+
}
|
|
899
|
+
output += markdown.slice(copied, start) + next.value;
|
|
900
|
+
copied = end;
|
|
901
|
+
}
|
|
902
|
+
return {
|
|
903
|
+
markdown: output + markdown.slice(copied),
|
|
904
|
+
spans,
|
|
905
|
+
standIns
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Puts each code span back where its stand-in sits under `node`: the code as
|
|
911
|
+
* text with the code format, inside whatever marks the stand-in took. In a
|
|
912
|
+
* table cell an escaped `\|` in code is a `|`, as GFM reads cells.
|
|
913
|
+
*/
|
|
914
|
+
export function $restoreCodeSpans(node, spans) {
|
|
915
|
+
if (spans.size === 0) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
for (const text of node.getAllTextNodes()) {
|
|
919
|
+
const content = text.getTextContent();
|
|
920
|
+
const offsets = [];
|
|
921
|
+
let offset = 0;
|
|
922
|
+
for (const character of content) {
|
|
923
|
+
if (spans.has(character)) {
|
|
924
|
+
offsets.push(offset, offset + character.length);
|
|
925
|
+
}
|
|
926
|
+
offset += character.length;
|
|
927
|
+
}
|
|
928
|
+
if (offsets.length === 0) {
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
const pieces = text.splitText(...offsets.filter(point => point > 0 && point < content.length));
|
|
932
|
+
for (const piece of pieces) {
|
|
933
|
+
const code = spans.get(piece.getTextContent());
|
|
934
|
+
if (code == null) {
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
const inCell = $findMatchingParent(piece, $isTableCellNode) != null;
|
|
938
|
+
piece.setTextContent(inCell ? code.replace(/\\\|/g, '|') : code);
|
|
939
|
+
if (!piece.hasFormat('code')) {
|
|
940
|
+
piece.toggleFormat('code');
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Returns `markdown` with every character reference outside code replaced by
|
|
948
|
+
* a private-use stand-in that occurs nowhere else in it, and the escaped `&`
|
|
949
|
+
* of an escaped reference replaced by one too. One pass over the source.
|
|
950
|
+
*/
|
|
951
|
+
export function protectCharacterReferences(markdown) {
|
|
952
|
+
const standIns = new Map();
|
|
953
|
+
if (!markdown.includes('&')) {
|
|
954
|
+
return {
|
|
955
|
+
markdown,
|
|
956
|
+
standIns
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
const available = absentCharacters(markdown);
|
|
960
|
+
const byReference = new Map();
|
|
961
|
+
let ampersand = null;
|
|
962
|
+
const standInFor = reference => {
|
|
963
|
+
const existing = reference == null ? ampersand : byReference.get(reference);
|
|
964
|
+
if (existing != null) {
|
|
965
|
+
return existing;
|
|
966
|
+
}
|
|
967
|
+
const next = available.next();
|
|
968
|
+
if (next.done === true) {
|
|
969
|
+
// Every private-use character is in the text or already used: leave
|
|
970
|
+
// the rest as written.
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
if (reference == null) {
|
|
974
|
+
ampersand = next.value;
|
|
975
|
+
standIns.set(next.value, {
|
|
976
|
+
kind: 'ampersand'
|
|
977
|
+
});
|
|
978
|
+
} else {
|
|
979
|
+
byReference.set(reference, next.value);
|
|
980
|
+
standIns.set(next.value, {
|
|
981
|
+
kind: 'reference',
|
|
982
|
+
source: reference
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
return next.value;
|
|
986
|
+
};
|
|
987
|
+
const code = codeRanges(markdown);
|
|
988
|
+
let output = '';
|
|
989
|
+
let copied = 0;
|
|
990
|
+
let codeIndex = 0;
|
|
991
|
+
let index = markdown.indexOf('&');
|
|
992
|
+
while (index !== -1) {
|
|
993
|
+
while (code[codeIndex] != null && code[codeIndex][1] <= index) {
|
|
994
|
+
codeIndex++;
|
|
995
|
+
}
|
|
996
|
+
const range = code[codeIndex];
|
|
997
|
+
if (range != null && range[0] <= index) {
|
|
998
|
+
index = markdown.indexOf('&', range[1]);
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
REFERENCE_LIKE.lastIndex = index;
|
|
1002
|
+
const match = REFERENCE_LIKE.exec(markdown);
|
|
1003
|
+
if (match == null) {
|
|
1004
|
+
index = markdown.indexOf('&', index + 1);
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
let backslashes = 0;
|
|
1008
|
+
while (markdown[index - 1 - backslashes] === '\\') {
|
|
1009
|
+
backslashes++;
|
|
1010
|
+
}
|
|
1011
|
+
const reference = match[0];
|
|
1012
|
+
const end = index + reference.length;
|
|
1013
|
+
if (backslashes % 2 === 1) {
|
|
1014
|
+
// An escaped `&`: the reference stays literal, without its backslash.
|
|
1015
|
+
const standIn = standInFor(null);
|
|
1016
|
+
if (standIn != null) {
|
|
1017
|
+
output += markdown.slice(copied, index - 1) + standIn;
|
|
1018
|
+
copied = index + 1;
|
|
1019
|
+
}
|
|
1020
|
+
} else if (decodeMarkdownCharacterReferences(reference) !== reference) {
|
|
1021
|
+
const standIn = standInFor(reference);
|
|
1022
|
+
if (standIn != null) {
|
|
1023
|
+
output += markdown.slice(copied, index) + standIn;
|
|
1024
|
+
copied = end;
|
|
1025
|
+
}
|
|
1026
|
+
} else if (reference.startsWith('&#')) {
|
|
1027
|
+
// Not a reference (too many digits): literal, but kept from Lexical's
|
|
1028
|
+
// own numeric decoding.
|
|
1029
|
+
const standIn = standInFor(null);
|
|
1030
|
+
if (standIn != null) {
|
|
1031
|
+
output += markdown.slice(copied, index) + standIn;
|
|
1032
|
+
copied = index + 1;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
index = markdown.indexOf('&', end);
|
|
1036
|
+
}
|
|
1037
|
+
return {
|
|
1038
|
+
markdown: output + markdown.slice(copied),
|
|
1039
|
+
standIns
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** `text` with each stand-in replaced as `replace` says. */
|
|
1044
|
+
function restored(text, standIns, replace) {
|
|
1045
|
+
let output = '';
|
|
1046
|
+
let changed = false;
|
|
1047
|
+
for (const character of text) {
|
|
1048
|
+
const standIn = standIns.get(character);
|
|
1049
|
+
if (standIn == null) {
|
|
1050
|
+
output += character;
|
|
1051
|
+
} else {
|
|
1052
|
+
output += replace(standIn);
|
|
1053
|
+
changed = true;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return changed ? output : text;
|
|
1057
|
+
}
|
|
1058
|
+
const decoded = standIn => {
|
|
1059
|
+
switch (standIn.kind) {
|
|
1060
|
+
case 'literal':
|
|
1061
|
+
return standIn.text;
|
|
1062
|
+
case 'ampersand':
|
|
1063
|
+
return '&';
|
|
1064
|
+
case 'backslash':
|
|
1065
|
+
return '\\';
|
|
1066
|
+
case 'escape':
|
|
1067
|
+
return standIn.character;
|
|
1068
|
+
case 'reference':
|
|
1069
|
+
return decodeMarkdownCharacterReferences(standIn.source);
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
const asWritten = standIn => {
|
|
1073
|
+
switch (standIn.kind) {
|
|
1074
|
+
case 'literal':
|
|
1075
|
+
return standIn.text;
|
|
1076
|
+
case 'ampersand':
|
|
1077
|
+
return '&';
|
|
1078
|
+
case 'backslash':
|
|
1079
|
+
return '\\';
|
|
1080
|
+
case 'escape':
|
|
1081
|
+
return `\\${standIn.character}`;
|
|
1082
|
+
case 'reference':
|
|
1083
|
+
return standIn.source;
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Replaces the stand-ins under `node` with the characters their references
|
|
1089
|
+
* name or their escapes stand for — or, in code Lexical read as code, with
|
|
1090
|
+
* the references and escapes as written — in text, link destinations, and
|
|
1091
|
+
* link titles. One pass over the text.
|
|
1092
|
+
*/
|
|
1093
|
+
export function $restoreCharacterReferences(node, standIns) {
|
|
1094
|
+
if (standIns.size === 0) {
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const visit = (current, inCode) => {
|
|
1098
|
+
if ($isTextNode(current)) {
|
|
1099
|
+
const text = current.getTextContent();
|
|
1100
|
+
const next = restored(text, standIns, inCode || current.hasFormat('code') ? asWritten : decoded);
|
|
1101
|
+
if (next !== text) {
|
|
1102
|
+
current.setTextContent(next);
|
|
1103
|
+
}
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if ($isLinkNode(current)) {
|
|
1107
|
+
const url = restored(current.getURL(), standIns, decoded);
|
|
1108
|
+
if (url !== current.getURL()) {
|
|
1109
|
+
current.setURL(url);
|
|
1110
|
+
}
|
|
1111
|
+
const title = current.getTitle();
|
|
1112
|
+
if (title != null) {
|
|
1113
|
+
const nextTitle = restored(title, standIns, decoded);
|
|
1114
|
+
if (nextTitle !== title) {
|
|
1115
|
+
current.setTitle(nextTitle);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if ($isElementNode(current)) {
|
|
1120
|
+
const isCode = inCode || $isCodeNode(current);
|
|
1121
|
+
for (const child of current.getChildren()) {
|
|
1122
|
+
visit(child, isCode);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
visit(node, false);
|
|
1127
|
+
}
|