@aicayzer/inkkit 0.0.18 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +4 -2
- package/README.md +11 -7
- package/dist/THIRD-PARTY-NOTICES.txt +2742 -0
- package/dist/chunks/core-vtCr4MBS.js +56503 -0
- package/dist/chunks/highlight-Daj2fv_u.js +14122 -0
- package/dist/core.d.ts +2 -5
- package/dist/core.js +2 -2
- package/dist/editor.d.ts +229 -47
- package/dist/index.js +5 -3
- package/dist/style.css +231 -202
- package/dist/types.d.ts +143 -18
- package/package.json +16 -13
- package/dist/autolink.d.ts +0 -1
- package/dist/autolink.js +0 -18
- package/dist/callouts.d.ts +0 -25
- package/dist/callouts.js +0 -288
- package/dist/clipboard.d.ts +0 -11
- package/dist/clipboard.js +0 -261
- package/dist/code-fences.d.ts +0 -8
- package/dist/code-fences.js +0 -171
- package/dist/comments.d.ts +0 -76
- package/dist/comments.js +0 -545
- package/dist/cursor.d.ts +0 -2
- package/dist/cursor.js +0 -40
- package/dist/decorations.d.ts +0 -2
- package/dist/decorations.js +0 -67
- package/dist/dialect.d.ts +0 -9
- package/dist/dialect.js +0 -446
- package/dist/drop-placement.d.ts +0 -19
- package/dist/drop-placement.js +0 -47
- package/dist/editor.js +0 -2673
- package/dist/files.d.ts +0 -40
- package/dist/files.js +0 -133
- package/dist/highlight.d.ts +0 -1
- package/dist/highlight.js +0 -136
- package/dist/html-import.d.ts +0 -13
- package/dist/html-import.js +0 -477
- package/dist/image-blocks.d.ts +0 -86
- package/dist/image-blocks.js +0 -329
- package/dist/images.d.ts +0 -51
- package/dist/images.js +0 -398
- package/dist/inline-highlight.d.ts +0 -6
- package/dist/inline-highlight.js +0 -112
- package/dist/labels.d.ts +0 -36
- package/dist/labels.js +0 -34
- package/dist/link-safety.d.ts +0 -17
- package/dist/link-safety.js +0 -29
- package/dist/linked-syntax.d.ts +0 -48
- package/dist/linked-syntax.js +0 -320
- package/dist/literals.d.ts +0 -20
- package/dist/literals.js +0 -216
- package/dist/mermaid.d.ts +0 -28
- package/dist/mermaid.js +0 -404
- package/dist/outline.d.ts +0 -22
- package/dist/outline.js +0 -79
- package/dist/paste-html.d.ts +0 -31
- package/dist/paste-html.js +0 -462
- package/dist/paste.d.ts +0 -33
- package/dist/paste.js +0 -591
- package/dist/portable-files.d.ts +0 -10
- package/dist/portable-files.js +0 -36
- package/dist/preserve.d.ts +0 -38
- package/dist/preserve.js +0 -1396
- package/dist/print.d.ts +0 -7
- package/dist/print.js +0 -406
- package/dist/reference-clipboard.d.ts +0 -8
- package/dist/reference-clipboard.js +0 -201
- package/dist/references.d.ts +0 -16
- package/dist/references.js +0 -418
- package/dist/scroll.d.ts +0 -15
- package/dist/scroll.js +0 -27
- package/dist/search.d.ts +0 -15
- package/dist/search.js +0 -202
- package/dist/selection.d.ts +0 -1
- package/dist/selection.js +0 -59
- package/dist/source.d.ts +0 -16
- package/dist/source.js +0 -57
- package/dist/spreadsheet.d.ts +0 -8
- package/dist/spreadsheet.js +0 -289
- package/dist/table-alignment.d.ts +0 -2
- package/dist/table-alignment.js +0 -46
- package/dist/tables.d.ts +0 -27
- package/dist/tables.js +0 -344
- package/dist/tasks.d.ts +0 -3
- package/dist/tasks.js +0 -51
- package/dist/text-ranges.d.ts +0 -29
- package/dist/text-ranges.js +0 -256
- package/dist/types.js +0 -8
- package/dist/wiki-view.d.ts +0 -21
- package/dist/wiki-view.js +0 -78
package/dist/preserve.js
DELETED
|
@@ -1,1396 +0,0 @@
|
|
|
1
|
-
import { parserCtx, remarkCtx } from '@milkdown/kit/core';
|
|
2
|
-
import { serialize } from './dialect.js';
|
|
3
|
-
import { restoreCodeEndings, finalCodeEnding } from './code-fences.js';
|
|
4
|
-
import { lineBreakHTML, sourceFrontmatter } from './literals.js';
|
|
5
|
-
import { normalizeIdentifier } from 'micromark-util-normalize-identifier';
|
|
6
|
-
// Line endings are the document's, not the node's: a CRLF source and its LF
|
|
7
|
-
// canonical form describe the same text.
|
|
8
|
-
function signature(node) {
|
|
9
|
-
return JSON.stringify(node, function (key, value) {
|
|
10
|
-
if (['position', 'data', 'marker'].includes(key))
|
|
11
|
-
return undefined;
|
|
12
|
-
const holder = this;
|
|
13
|
-
// Every spelling of a line break reads back as the same break.
|
|
14
|
-
if (key === 'value' && holder.type === 'html' && lineBreakHTML(value))
|
|
15
|
-
return '<br />';
|
|
16
|
-
// A row read as ending in empty cells is the row written without them.
|
|
17
|
-
if (key === 'children' && holder.type === 'tableRow') {
|
|
18
|
-
let end = value.length;
|
|
19
|
-
while (end > 0 && !value[end - 1].children?.length)
|
|
20
|
-
end--;
|
|
21
|
-
return value.slice(0, end);
|
|
22
|
-
}
|
|
23
|
-
return typeof value === 'string' ? value.replace(/\r\n?/g, '\n') : value;
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
function ownShape(node) {
|
|
27
|
-
return JSON.stringify(node, (key, value) => ['position', 'data', 'children', 'value', 'marker'].includes(key)
|
|
28
|
-
? undefined
|
|
29
|
-
: value);
|
|
30
|
-
}
|
|
31
|
-
// The character that decides which list an item belongs to: its bullet, or the
|
|
32
|
-
// delimiter after its number. Items with different ones start a new list.
|
|
33
|
-
function markerAt(text, at) {
|
|
34
|
-
const match = /^(?:[-*+]|\d{1,9}[.)])/.exec(text.slice(at, at + 10));
|
|
35
|
-
return match
|
|
36
|
-
? { at: at + match[0].length - 1, marker: match[0].at(-1) }
|
|
37
|
-
: undefined;
|
|
38
|
-
}
|
|
39
|
-
function relabel(text, offsets, marker) {
|
|
40
|
-
for (const offset of offsets) {
|
|
41
|
-
const found = markerAt(text, offset);
|
|
42
|
-
if (found &&
|
|
43
|
-
found.marker !== marker &&
|
|
44
|
-
/[-*+]/.test(found.marker) === /[-*+]/.test(marker))
|
|
45
|
-
text = text.slice(0, found.at) + marker + text.slice(found.at + 1);
|
|
46
|
-
}
|
|
47
|
-
return text;
|
|
48
|
-
}
|
|
49
|
-
function authoredMarker(list, source) {
|
|
50
|
-
const at = list.children?.[0]?.position?.start.offset;
|
|
51
|
-
return at == null ? undefined : markerAt(source, at)?.marker;
|
|
52
|
-
}
|
|
53
|
-
const sameList = (before, after) => before.type === 'list' &&
|
|
54
|
-
after.type === 'list' &&
|
|
55
|
-
before.ordered === after.ordered;
|
|
56
|
-
// Canonical Markdown written into authored source. Items written into an
|
|
57
|
-
// authored list take its marker, which they would otherwise split away from.
|
|
58
|
-
// A list written beside an authored list takes a marker that list does not
|
|
59
|
-
// use, which it would otherwise merge with.
|
|
60
|
-
function canonicalSlice(marker, children, canonical, from, to, previous, next) {
|
|
61
|
-
let text = canonical.slice(from, to);
|
|
62
|
-
const offsets = (list) => (list.children ?? []).map((item) => item.position.start.offset - from);
|
|
63
|
-
if (marker)
|
|
64
|
-
return relabel(text, children.map((item) => item.position.start.offset - from), marker);
|
|
65
|
-
children.forEach((child, index) => {
|
|
66
|
-
if (child.type !== 'list') {
|
|
67
|
-
previous = undefined;
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
const avoid = [previous, index === children.length - 1 ? next : undefined]
|
|
71
|
-
.filter((neighbour) => neighbour?.ordered === child.ordered)
|
|
72
|
-
.map((neighbour) => neighbour.marker);
|
|
73
|
-
const own = markerAt(canonical, child.children[0].position.start.offset)?.marker;
|
|
74
|
-
const choice = [
|
|
75
|
-
own,
|
|
76
|
-
...(child.ordered ? ['.', ')'] : ['-', '*', '+']),
|
|
77
|
-
].find((candidate) => candidate && !avoid.includes(candidate));
|
|
78
|
-
if (choice)
|
|
79
|
-
text = relabel(text, offsets(child), choice);
|
|
80
|
-
previous = { ordered: child.ordered, marker: choice ?? own ?? '' };
|
|
81
|
-
});
|
|
82
|
-
return text;
|
|
83
|
-
}
|
|
84
|
-
// The authored list a child is, as a neighbour of canonical Markdown.
|
|
85
|
-
function authoredNeighbour(before, after, source) {
|
|
86
|
-
if (!before || !after || !sameList(before, after))
|
|
87
|
-
return undefined;
|
|
88
|
-
const marker = authoredMarker(before, source);
|
|
89
|
-
return marker ? { ordered: before.ordered, marker } : undefined;
|
|
90
|
-
}
|
|
91
|
-
function retainText(raw, before, after, quoteIndent = false, inTable = false) {
|
|
92
|
-
before = before.replace(/\r\n?/g, '\n');
|
|
93
|
-
after = after.replace(/\r\n?/g, '\n');
|
|
94
|
-
let prefix = 0;
|
|
95
|
-
while (prefix < before.length &&
|
|
96
|
-
prefix < after.length &&
|
|
97
|
-
before[prefix] === after[prefix])
|
|
98
|
-
prefix++;
|
|
99
|
-
let suffix = 0;
|
|
100
|
-
while (suffix < before.length - prefix &&
|
|
101
|
-
suffix < after.length - prefix &&
|
|
102
|
-
before[before.length - 1 - suffix] === after[after.length - 1 - suffix])
|
|
103
|
-
suffix++;
|
|
104
|
-
const offsets = [0];
|
|
105
|
-
let decoded = '';
|
|
106
|
-
for (let index = 0; index < raw.length;) {
|
|
107
|
-
if (index > 0 && raw[index - 1] === '\n') {
|
|
108
|
-
const gutter = (quoteIndent ? /^(?:[ \t]*>[ \t]*)+/ : /^(?:[ \t]*>[ \t]?)+/).exec(raw.slice(index));
|
|
109
|
-
if (gutter) {
|
|
110
|
-
index += gutter[0].length;
|
|
111
|
-
offsets[offsets.length - 1] = index;
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
const escaped = /^\\([!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~])/.exec(raw.slice(index));
|
|
116
|
-
const entity = /^&(?:#[xX][\da-fA-F]+|#\d+|[A-Za-z][A-Za-z\d]+);/.exec(raw.slice(index));
|
|
117
|
-
let value;
|
|
118
|
-
let length;
|
|
119
|
-
if (escaped) {
|
|
120
|
-
value = escaped[1];
|
|
121
|
-
length = escaped[0].length;
|
|
122
|
-
}
|
|
123
|
-
else if (entity) {
|
|
124
|
-
const element = document.createElement('textarea');
|
|
125
|
-
element.innerHTML = entity[0];
|
|
126
|
-
value = element.value;
|
|
127
|
-
length = entity[0].length;
|
|
128
|
-
}
|
|
129
|
-
else if (raw.slice(index, index + 2) === '\r\n') {
|
|
130
|
-
value = '\n';
|
|
131
|
-
length = 2;
|
|
132
|
-
}
|
|
133
|
-
else {
|
|
134
|
-
value = raw[index];
|
|
135
|
-
length = 1;
|
|
136
|
-
}
|
|
137
|
-
decoded += value;
|
|
138
|
-
index += length;
|
|
139
|
-
for (let part = 0; part < value.length; part++)
|
|
140
|
-
offsets.push(index);
|
|
141
|
-
}
|
|
142
|
-
if (decoded !== before) {
|
|
143
|
-
// Continuation indentation is excluded from a quoted paragraph's text.
|
|
144
|
-
return quoteIndent ? after : retainText(raw, before, after, true, inTable);
|
|
145
|
-
}
|
|
146
|
-
const inserted = after
|
|
147
|
-
.slice(prefix, after.length - suffix)
|
|
148
|
-
.split('')
|
|
149
|
-
.map((character, index) => {
|
|
150
|
-
const at = prefix + index;
|
|
151
|
-
const delimiter = (character === '%' || character === '=') &&
|
|
152
|
-
(after[at - 1] === character || after[at + 1] === character);
|
|
153
|
-
return delimiter ||
|
|
154
|
-
/[\\`*_[\]<>]/.test(character) ||
|
|
155
|
-
(inTable && character === '|')
|
|
156
|
-
? `\\${character}`
|
|
157
|
-
: character;
|
|
158
|
-
})
|
|
159
|
-
.join('');
|
|
160
|
-
return (raw.slice(0, offsets[prefix]) +
|
|
161
|
-
inserted +
|
|
162
|
-
raw.slice(offsets[before.length - suffix]));
|
|
163
|
-
}
|
|
164
|
-
function retainBoundaryWhitespace(raw, canonical) {
|
|
165
|
-
// A middle-of-line space becomes indentation or trimmed text at a block edge.
|
|
166
|
-
const token = '(?:[ \\t]|&#(?:[xX]0*(?:20|9)|0*(?:32|9));|	)';
|
|
167
|
-
const decoded = (value) => (value.match(new RegExp(token, 'g')) ?? [])
|
|
168
|
-
.map((value) => value === '\t' || /(?:9;|Tab;)$/.test(value) ? '\t' : ' ')
|
|
169
|
-
.join('');
|
|
170
|
-
const encode = (value) => value.replace(/[ \t]/g, (value) => (value === '\t' ? '	' : ' '));
|
|
171
|
-
const start = /^[ \t]+/.exec(raw)?.[0];
|
|
172
|
-
const rawStart = new RegExp(`^${token}+`).exec(raw)?.[0];
|
|
173
|
-
const encodedStart = new RegExp(`^${token}+`).exec(canonical)?.[0];
|
|
174
|
-
if (start &&
|
|
175
|
-
rawStart &&
|
|
176
|
-
encodedStart?.includes('&') &&
|
|
177
|
-
decoded(rawStart) === decoded(encodedStart))
|
|
178
|
-
raw = encode(start) + raw.slice(start.length);
|
|
179
|
-
const end = /[ \t]+$/.exec(raw)?.[0];
|
|
180
|
-
const rawEnd = new RegExp(`${token}+$`).exec(raw)?.[0];
|
|
181
|
-
const encodedEnd = new RegExp(`${token}+$`).exec(canonical)?.[0];
|
|
182
|
-
if (end &&
|
|
183
|
-
rawEnd &&
|
|
184
|
-
encodedEnd?.includes('&') &&
|
|
185
|
-
decoded(rawEnd) === decoded(encodedEnd))
|
|
186
|
-
raw = raw.slice(0, -end.length) + encode(end);
|
|
187
|
-
return raw;
|
|
188
|
-
}
|
|
189
|
-
// The pipes GFM splits a row at; an escaped pipe does not split it.
|
|
190
|
-
function rowPipes(row) {
|
|
191
|
-
const pipes = [];
|
|
192
|
-
for (let index = 0; index < row.length; index++)
|
|
193
|
-
if (row[index] === '\\' && /[\\|]/.test(row[index + 1] ?? ''))
|
|
194
|
-
index++;
|
|
195
|
-
else if (row[index] === '|')
|
|
196
|
-
pipes.push(index);
|
|
197
|
-
const opening = pipes[0] === row.search(/\S/);
|
|
198
|
-
const last = row.trimEnd().length - 1;
|
|
199
|
-
const closing = pipes.at(-1) === last && (!opening || pipes.length > 1) ? last : undefined;
|
|
200
|
-
return {
|
|
201
|
-
opening,
|
|
202
|
-
closing,
|
|
203
|
-
cells: pipes.length + 1 - Number(opening) - Number(closing != null),
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
// A source row may stop before the header's last cell; the editor holds the
|
|
207
|
-
// rest as empty cells, which stay unwritten until one of them gains content.
|
|
208
|
-
// Then the new cells follow the row's own source, before its closing pipe.
|
|
209
|
-
function retainShortRow(before, after, retain, source, canonical) {
|
|
210
|
-
const count = before.children.length;
|
|
211
|
-
const row = retain({ ...after, children: after.children.slice(0, count) });
|
|
212
|
-
let filled = after.children.length;
|
|
213
|
-
while (filled > count && !after.children[filled - 1].children?.length)
|
|
214
|
-
filled--;
|
|
215
|
-
if (filled === count)
|
|
216
|
-
return row;
|
|
217
|
-
const from = after.children[count].position?.start.offset;
|
|
218
|
-
const to = after.children[filled - 1].position?.end.offset;
|
|
219
|
-
const start = before.position?.start.offset;
|
|
220
|
-
const end = before.position?.end.offset;
|
|
221
|
-
if (from == null || to == null || start == null || end == null)
|
|
222
|
-
throw new PreservationError('A source token has no location');
|
|
223
|
-
const kept = rowPipes(row);
|
|
224
|
-
let output = (kept.closing == null ? row : row.slice(0, kept.closing)).trimEnd();
|
|
225
|
-
// Without an opening pipe, an empty first cell would not be read at all.
|
|
226
|
-
if (!kept.opening && !after.children[0].children?.length)
|
|
227
|
-
output = `|${output ? ' ' : ''}${output.trimStart()}`;
|
|
228
|
-
for (let cell = kept.cells; cell < count; cell++)
|
|
229
|
-
output += ' |';
|
|
230
|
-
// A cell that matches one of the row's own, such as one moved there, keeps
|
|
231
|
-
// that cell's authored text.
|
|
232
|
-
const contentOf = (cell) => [
|
|
233
|
-
cell.children[0].position.start.offset,
|
|
234
|
-
cell.children.at(-1).position.end.offset,
|
|
235
|
-
];
|
|
236
|
-
const added = after
|
|
237
|
-
.children.slice(count, filled)
|
|
238
|
-
.map((cell) => {
|
|
239
|
-
const text = canonical.slice(cell.position.start.offset, cell.position.end.offset);
|
|
240
|
-
const original = cell.children?.length
|
|
241
|
-
? before.children.find((candidate) => candidate.children?.length &&
|
|
242
|
-
signature(candidate) === signature(cell))
|
|
243
|
-
: undefined;
|
|
244
|
-
if (!original)
|
|
245
|
-
return text;
|
|
246
|
-
const [cellFrom, cellTo] = contentOf(cell);
|
|
247
|
-
const [sourceFrom, sourceTo] = contentOf(original);
|
|
248
|
-
const offset = cell.position.start.offset;
|
|
249
|
-
return (text.slice(0, cellFrom - offset) +
|
|
250
|
-
source.slice(sourceFrom, sourceTo) +
|
|
251
|
-
text.slice(cellTo - offset));
|
|
252
|
-
})
|
|
253
|
-
.join('');
|
|
254
|
-
const addedPipes = rowPipes(added);
|
|
255
|
-
const closing = kept.closing != null || rowPipes(source.slice(start, end)).closing != null;
|
|
256
|
-
return (`${output} ` +
|
|
257
|
-
(addedPipes.closing == null
|
|
258
|
-
? added
|
|
259
|
-
: added.slice(0, addedPipes.closing)).trimEnd() +
|
|
260
|
-
(closing ? ' |' : ''));
|
|
261
|
-
}
|
|
262
|
-
// A `<br />` line, read as an HTML block or as a paragraph holding only it.
|
|
263
|
-
function spacer(node) {
|
|
264
|
-
const html = node?.type === 'paragraph' ? node.children?.[0] : node;
|
|
265
|
-
return ((node?.type !== 'paragraph' || node.children?.length === 1) &&
|
|
266
|
-
html?.type === 'html' &&
|
|
267
|
-
lineBreakHTML(String(html.value)));
|
|
268
|
-
}
|
|
269
|
-
// Patch changed leaves so an adjacent edit retains authored delimiters and labels.
|
|
270
|
-
function retainSource(before, after, source, canonical, lineEnding, inTable = false,
|
|
271
|
-
// The header's cell count, for the rows of a table.
|
|
272
|
-
width) {
|
|
273
|
-
const start = before.position?.start.offset;
|
|
274
|
-
const end = before.position?.end.offset;
|
|
275
|
-
const newStart = after.position?.start.offset;
|
|
276
|
-
const newEnd = after.position?.end.offset;
|
|
277
|
-
if (start == null || end == null || newStart == null || newEnd == null)
|
|
278
|
-
throw new PreservationError('A source token has no location');
|
|
279
|
-
const raw = source.slice(start, end);
|
|
280
|
-
if (before.type === 'code' &&
|
|
281
|
-
after.type === 'code' &&
|
|
282
|
-
before.lang === after.lang) {
|
|
283
|
-
if ((before.value ?? '') === (after.value ?? ''))
|
|
284
|
-
return raw;
|
|
285
|
-
const opening = /^([ \t>]*)(`{3,}|~{3,})([^\r\n]*)(\r?\n)/.exec(raw);
|
|
286
|
-
if (opening) {
|
|
287
|
-
const lines = raw.slice(opening[0].length).split(/\r?\n/);
|
|
288
|
-
const closing = /^([ \t>]*)(`{3,}|~{3,})([ \t]*)$/.exec(lines.at(-1) ?? '');
|
|
289
|
-
const originalLine = (before.value ?? '').replace(/\r\n?/g, '\n').split('\n')[0] ?? '';
|
|
290
|
-
// A fence whose code is empty or starts with a blank line has no line
|
|
291
|
-
// to take the indent from but its closing one.
|
|
292
|
-
const first = originalLine ? lines[0] : undefined;
|
|
293
|
-
const gutter = first === undefined
|
|
294
|
-
? (closing?.[1] ?? opening[1])
|
|
295
|
-
: first.endsWith(originalLine)
|
|
296
|
-
? first.slice(0, first.length - originalLine.length)
|
|
297
|
-
: opening[1];
|
|
298
|
-
// A code block without code is written as a fence with no line inside.
|
|
299
|
-
const body = after.value
|
|
300
|
-
? after.value.replace(/\r\n?/g, '\n').split('\n')
|
|
301
|
-
: [];
|
|
302
|
-
const character = opening[2][0];
|
|
303
|
-
let length = opening[2].length;
|
|
304
|
-
for (const line of body) {
|
|
305
|
-
const run = new RegExp(`^ {0,3}(${character}+)[ \\t]*$`).exec(line);
|
|
306
|
-
if (run)
|
|
307
|
-
length = Math.max(length, run[1].length + 1);
|
|
308
|
-
}
|
|
309
|
-
const fence = character.repeat(length);
|
|
310
|
-
return (opening[1] +
|
|
311
|
-
fence +
|
|
312
|
-
opening[3] +
|
|
313
|
-
body.map((line) => lineEnding + gutter + line).join('') +
|
|
314
|
-
(closing || body.length ? '' : opening[4]) +
|
|
315
|
-
(closing
|
|
316
|
-
? lineEnding +
|
|
317
|
-
closing[1] +
|
|
318
|
-
character.repeat(Math.max(length, closing[2].length)) +
|
|
319
|
-
closing[3]
|
|
320
|
-
: ''));
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
if (before.type === 'definition' &&
|
|
324
|
-
after.type === 'definition' &&
|
|
325
|
-
before.label === after.label &&
|
|
326
|
-
before.title === after.title) {
|
|
327
|
-
const destination = /^(\[[\s\S]*?\]:[ \t]*)(<[^>\n]*>|[^\s]+)([\s\S]*)$/.exec(raw);
|
|
328
|
-
if (destination)
|
|
329
|
-
return (destination[1] +
|
|
330
|
-
(destination[2].startsWith('<')
|
|
331
|
-
? `<${after.url}>`
|
|
332
|
-
: String(after.url)) +
|
|
333
|
-
destination[3]);
|
|
334
|
-
}
|
|
335
|
-
if (before.type === 'tableRow' &&
|
|
336
|
-
after.type === 'tableRow' &&
|
|
337
|
-
before.children &&
|
|
338
|
-
after.children &&
|
|
339
|
-
width != null &&
|
|
340
|
-
before.children.length < width &&
|
|
341
|
-
after.children.length === width)
|
|
342
|
-
return retainShortRow(before, after, (row) => retainSource(before, row, source, canonical, lineEnding, inTable), source, canonical);
|
|
343
|
-
if (before.type === 'tableCell' &&
|
|
344
|
-
after.type === 'tableCell' &&
|
|
345
|
-
!before.children?.length &&
|
|
346
|
-
after.children?.length) {
|
|
347
|
-
// An empty cell's range is only its pipe and padding, so its new content
|
|
348
|
-
// goes inside them rather than through the whole-range boundary check.
|
|
349
|
-
const from = after.children[0].position?.start.offset;
|
|
350
|
-
const to = after.children.at(-1).position?.end.offset;
|
|
351
|
-
if (from == null || to == null)
|
|
352
|
-
throw new PreservationError('A source token has no location');
|
|
353
|
-
const pipes = rowPipes(raw);
|
|
354
|
-
return `${pipes.opening ? '| ' : ''}${canonical.slice(from, to)} ${pipes.closing != null ? '|' : ''}`;
|
|
355
|
-
}
|
|
356
|
-
if (before.type === 'listItem' &&
|
|
357
|
-
after.type === 'listItem' &&
|
|
358
|
-
before.children &&
|
|
359
|
-
after.children &&
|
|
360
|
-
before.children.length === after.children.length + 1 &&
|
|
361
|
-
(spacer(before.children[0]) || before.children[0].type === 'paragraph') &&
|
|
362
|
-
after.children[0]?.type !== 'paragraph' &&
|
|
363
|
-
!spacer(after.children[0])) {
|
|
364
|
-
// The item now opens with an empty line before a block, which is written
|
|
365
|
-
// as no line. The blocks after it keep their authored source.
|
|
366
|
-
const retained = retainSource({ ...before, children: before.children.slice(1) }, { ...after, spread: before.spread }, source, canonical, lineEnding, inTable);
|
|
367
|
-
const first = before.children[0];
|
|
368
|
-
if (spacer(first))
|
|
369
|
-
return retained;
|
|
370
|
-
// An emptied first line is kept as the `<br />` line it reads back as.
|
|
371
|
-
const from = first.position.start.offset - start;
|
|
372
|
-
const to = first.position.end.offset - start;
|
|
373
|
-
return retained.startsWith(source.slice(start, before.children[1].position.start.offset))
|
|
374
|
-
? retained.slice(0, from) + '<br />' + retained.slice(to)
|
|
375
|
-
: retained;
|
|
376
|
-
}
|
|
377
|
-
if (before.type === 'text' && after.type === 'text')
|
|
378
|
-
return retainBoundaryWhitespace(withEnding(retainText(raw, before.value, after.value, false, inTable), lineEnding), canonical.slice(newStart, newEnd));
|
|
379
|
-
if (signature(before) === signature(after) &&
|
|
380
|
-
ownShape(before) === ownShape(after))
|
|
381
|
-
return raw;
|
|
382
|
-
const marker = sameList(before, after)
|
|
383
|
-
? authoredMarker(before, source)
|
|
384
|
-
: undefined;
|
|
385
|
-
if (before.type === after.type &&
|
|
386
|
-
ownShape(before) === ownShape(after) &&
|
|
387
|
-
before.children &&
|
|
388
|
-
after.children &&
|
|
389
|
-
before.children.length === after.children.length) {
|
|
390
|
-
let output = '', at = start;
|
|
391
|
-
let previous;
|
|
392
|
-
const same = (candidate, incoming) => signature(candidate) === signature(incoming) &&
|
|
393
|
-
ownShape(candidate) === ownShape(incoming);
|
|
394
|
-
const reorderable = before.type === 'table' || before.type === 'tableRow';
|
|
395
|
-
// Equal rows or cells can differ in spelling, so each one that stayed in
|
|
396
|
-
// place keeps its own source before any moved one is matched.
|
|
397
|
-
const retained = new Set(reorderable
|
|
398
|
-
? before.children.flatMap((child, index) => same(child, after.children[index]) ? [index] : [])
|
|
399
|
-
: []);
|
|
400
|
-
before.children.forEach((child, index) => {
|
|
401
|
-
const incoming = after.children[index];
|
|
402
|
-
const movedIndex = !reorderable
|
|
403
|
-
? -1
|
|
404
|
-
: same(child, incoming)
|
|
405
|
-
? index
|
|
406
|
-
: before.children.findIndex((candidate, candidateIndex) => !retained.has(candidateIndex) && same(candidate, incoming));
|
|
407
|
-
const original = movedIndex < 0 ? child : before.children[movedIndex];
|
|
408
|
-
if (movedIndex >= 0)
|
|
409
|
-
retained.add(movedIndex);
|
|
410
|
-
const childStart = child.position?.start.offset, childEnd = child.position?.end.offset;
|
|
411
|
-
if (childStart == null || childEnd == null)
|
|
412
|
-
throw new PreservationError('A source token has no location');
|
|
413
|
-
let retainedSource = incoming.type === 'list' && !sameList(original, incoming)
|
|
414
|
-
? withEnding(canonicalSlice(undefined, [incoming], canonical, incoming.position.start.offset, incoming.position.end.offset, previous, authoredNeighbour(before.children[index + 1], after.children[index + 1], source)), lineEnding)
|
|
415
|
-
: retainSource(original, incoming, source, canonical, lineEnding, inTable || before.type === 'tableCell', before.type === 'table'
|
|
416
|
-
? before.children[0]?.children?.length
|
|
417
|
-
: undefined);
|
|
418
|
-
if (before.type === 'tableRow' &&
|
|
419
|
-
movedIndex >= 0 &&
|
|
420
|
-
movedIndex !== index) {
|
|
421
|
-
// Cell positions own different pipe boundaries at the row edges.
|
|
422
|
-
const originalFrom = original.children?.[0]?.position?.start.offset;
|
|
423
|
-
const originalTo = original.children?.at(-1)?.position?.end.offset;
|
|
424
|
-
const from = child.children?.[0]?.position?.start.offset;
|
|
425
|
-
const to = child.children?.at(-1)?.position?.end.offset;
|
|
426
|
-
retainedSource =
|
|
427
|
-
originalFrom != null &&
|
|
428
|
-
originalTo != null &&
|
|
429
|
-
from != null &&
|
|
430
|
-
to != null
|
|
431
|
-
? source.slice(childStart, from) +
|
|
432
|
-
source.slice(originalFrom, originalTo) +
|
|
433
|
-
source.slice(to, childEnd)
|
|
434
|
-
: withEnding(canonical.slice(incoming.position.start.offset, incoming.position.end.offset), lineEnding);
|
|
435
|
-
}
|
|
436
|
-
if (marker)
|
|
437
|
-
retainedSource = relabel(retainedSource, [0], marker);
|
|
438
|
-
previous =
|
|
439
|
-
incoming.type !== 'list'
|
|
440
|
-
? undefined
|
|
441
|
-
: (authoredNeighbour(original, incoming, source) ?? {
|
|
442
|
-
ordered: incoming.ordered,
|
|
443
|
-
marker: markerAt(retainedSource, /^[ \t]*/.exec(retainedSource)[0].length)?.marker ?? '',
|
|
444
|
-
});
|
|
445
|
-
output += source.slice(at, childStart) + retainedSource;
|
|
446
|
-
at = childEnd;
|
|
447
|
-
});
|
|
448
|
-
return output + source.slice(at, end);
|
|
449
|
-
}
|
|
450
|
-
if (before.type === after.type &&
|
|
451
|
-
ownShape(before) === ownShape(after) &&
|
|
452
|
-
before.children &&
|
|
453
|
-
after.children) {
|
|
454
|
-
let prefix = 0, suffix = 0;
|
|
455
|
-
while (prefix < before.children.length &&
|
|
456
|
-
prefix < after.children.length &&
|
|
457
|
-
signature(before.children[prefix]) === signature(after.children[prefix]))
|
|
458
|
-
prefix++;
|
|
459
|
-
while (suffix < before.children.length - prefix &&
|
|
460
|
-
suffix < after.children.length - prefix &&
|
|
461
|
-
signature(before.children[before.children.length - 1 - suffix]) ===
|
|
462
|
-
signature(after.children[after.children.length - 1 - suffix]))
|
|
463
|
-
suffix++;
|
|
464
|
-
// A cell's range includes its pipe and padding, which would hide a space
|
|
465
|
-
// now at the edge of its content from the boundary check.
|
|
466
|
-
const cell = before.type === 'tableCell' &&
|
|
467
|
-
before.children.length > 0 &&
|
|
468
|
-
after.children.length > 0;
|
|
469
|
-
const first = cell ? before.children[0].position?.start.offset : start;
|
|
470
|
-
const last = cell ? before.children.at(-1).position?.end.offset : end;
|
|
471
|
-
const newFirst = cell ? after.children[0].position?.start.offset : newStart;
|
|
472
|
-
const newLast = cell ? after.children.at(-1).position?.end.offset : newEnd;
|
|
473
|
-
const from = prefix
|
|
474
|
-
? before.children[prefix - 1].position?.end.offset
|
|
475
|
-
: first;
|
|
476
|
-
const to = suffix
|
|
477
|
-
? before.children[before.children.length - suffix].position?.start.offset
|
|
478
|
-
: last;
|
|
479
|
-
const newFrom = prefix
|
|
480
|
-
? after.children[prefix - 1].position?.end.offset
|
|
481
|
-
: newFirst;
|
|
482
|
-
const newTo = suffix
|
|
483
|
-
? after.children[after.children.length - suffix].position?.start.offset
|
|
484
|
-
: newLast;
|
|
485
|
-
if (first != null &&
|
|
486
|
-
last != null &&
|
|
487
|
-
newFirst != null &&
|
|
488
|
-
newLast != null &&
|
|
489
|
-
from != null &&
|
|
490
|
-
to != null &&
|
|
491
|
-
newFrom != null &&
|
|
492
|
-
newTo != null)
|
|
493
|
-
return (source.slice(start, first) +
|
|
494
|
-
retainBoundaryWhitespace(source.slice(first, from) +
|
|
495
|
-
withEnding(canonicalSlice(marker, after.children.slice(prefix, after.children.length - suffix), canonical, newFrom, newTo, authoredNeighbour(before.children[prefix - 1], after.children[prefix - 1], source), authoredNeighbour(before.children[before.children.length - suffix], after.children[after.children.length - suffix], source)), lineEnding) +
|
|
496
|
-
source.slice(to, last), canonical.slice(newFirst, newLast)) +
|
|
497
|
-
source.slice(last, end));
|
|
498
|
-
}
|
|
499
|
-
// A list can end after blank lines that its container gave it; keep the
|
|
500
|
-
// authored ones rather than add the canonical ones beside them, unless the
|
|
501
|
-
// list became tight or loose, which those lines can undo.
|
|
502
|
-
const lastAfter = after.children?.at(-1)?.position?.end.offset;
|
|
503
|
-
const lastBefore = before.children?.at(-1)?.position?.end.offset;
|
|
504
|
-
const trailing = marker != null &&
|
|
505
|
-
lastAfter != null &&
|
|
506
|
-
lastBefore != null &&
|
|
507
|
-
loose(before, source) === loose(after, canonical);
|
|
508
|
-
return (withEnding(canonicalSlice(marker, after.children ?? [], canonical, newStart, trailing ? lastAfter : newEnd), lineEnding) + (trailing ? source.slice(lastBefore, end) : ''));
|
|
509
|
-
}
|
|
510
|
-
// Whether blank lines separate a list's items or the blocks inside one. A
|
|
511
|
-
// list's own spread can also count blank lines after its last item.
|
|
512
|
-
function loose(list, text) {
|
|
513
|
-
const blank = (from, to) => from != null && to != null && /\n[ \t>]*\r?\n/.test(text.slice(from, to));
|
|
514
|
-
const gaps = (nodes) => nodes.some((node, index) => index
|
|
515
|
-
? blank(nodes[index - 1].position?.end.offset, node.position?.start.offset)
|
|
516
|
-
: false);
|
|
517
|
-
const items = list.children ?? [];
|
|
518
|
-
return gaps(items) || items.some((item) => gaps(item.children ?? []));
|
|
519
|
-
}
|
|
520
|
-
// How a reference label is matched to a definition. Footnote labels, which
|
|
521
|
-
// start with `^`, are a separate set.
|
|
522
|
-
function definitionLabel(label) {
|
|
523
|
-
return label.startsWith('^')
|
|
524
|
-
? '^' + normalizeIdentifier(label.slice(1))
|
|
525
|
-
: normalizeIdentifier(label);
|
|
526
|
-
}
|
|
527
|
-
// Whether two nodes have the same blocks, leaving inline content aside.
|
|
528
|
-
function sameBlocks(left, right) {
|
|
529
|
-
if (left.type !== right.type || left.isTextblock !== right.isTextblock)
|
|
530
|
-
return false;
|
|
531
|
-
if (left.isTextblock)
|
|
532
|
-
return true;
|
|
533
|
-
if (left.childCount !== right.childCount)
|
|
534
|
-
return false;
|
|
535
|
-
for (let index = 0; index < left.childCount; index++)
|
|
536
|
-
if (!sameBlocks(left.child(index), right.child(index)))
|
|
537
|
-
return false;
|
|
538
|
-
return true;
|
|
539
|
-
}
|
|
540
|
-
// Canonical Markdown keeps authored CRLF inside literal blocks and code fences.
|
|
541
|
-
function withEnding(text, lineEnding) {
|
|
542
|
-
return lineEnding === '\n' ? text : text.replace(/\r?\n/g, lineEnding);
|
|
543
|
-
}
|
|
544
|
-
function positionalNode(tree, transformed) {
|
|
545
|
-
return (tree.children.find((node) => node.position?.start.offset === transformed.position?.start.offset &&
|
|
546
|
-
node.position?.end.offset === transformed.position?.end.offset) ?? transformed);
|
|
547
|
-
}
|
|
548
|
-
export class PreservationError extends Error {
|
|
549
|
-
constructor(message, options) {
|
|
550
|
-
super(message, options);
|
|
551
|
-
this.name = 'PreservationError';
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
function form(text) {
|
|
555
|
-
return text.replace(/\r\n?/g, '\n').replace(/\n+$/, '');
|
|
556
|
-
}
|
|
557
|
-
function blockForm(ctx, node) {
|
|
558
|
-
// An empty document is empty Markdown, but an empty block inside a document
|
|
559
|
-
// is authored spacing and must remain a real block when read again.
|
|
560
|
-
if (node.type.name === 'paragraph' && node.content.size === 0)
|
|
561
|
-
return '<br />';
|
|
562
|
-
return form(serialize(ctx, node.type.schema.topNodeType.create(null, [node])));
|
|
563
|
-
}
|
|
564
|
-
export function semanticSignature(doc) {
|
|
565
|
-
const clean = (value) => {
|
|
566
|
-
if (value.type === 'inkkit_wiki_link' && value.attrs) {
|
|
567
|
-
const { raw: _raw, inTable: _inTable, ...attrs } = value.attrs;
|
|
568
|
-
value = { ...value, attrs };
|
|
569
|
-
}
|
|
570
|
-
const content = value.content;
|
|
571
|
-
if (!content)
|
|
572
|
-
return value;
|
|
573
|
-
const tokens = [];
|
|
574
|
-
for (const child of content) {
|
|
575
|
-
if (child.type !== 'text') {
|
|
576
|
-
tokens.push(clean(child));
|
|
577
|
-
continue;
|
|
578
|
-
}
|
|
579
|
-
const parts = /^(\s*)([\s\S]*?)(\s*)$/.exec(String(child.text));
|
|
580
|
-
for (const [index, text] of parts.slice(1).entries()) {
|
|
581
|
-
if (!text)
|
|
582
|
-
continue;
|
|
583
|
-
const token = {
|
|
584
|
-
...child,
|
|
585
|
-
text: text.replace(/\r\n?/g, '\n'),
|
|
586
|
-
marks: index === 1 ? child.marks : undefined,
|
|
587
|
-
};
|
|
588
|
-
const previous = tokens.at(-1);
|
|
589
|
-
if (previous?.type === 'text' &&
|
|
590
|
-
JSON.stringify(previous.marks) === JSON.stringify(token.marks))
|
|
591
|
-
previous.text = String(previous.text) + text;
|
|
592
|
-
else
|
|
593
|
-
tokens.push(token);
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
return { ...value, content: tokens };
|
|
597
|
-
};
|
|
598
|
-
const json = JSON.parse(JSON.stringify(doc.toJSON(), (key, value) =>
|
|
599
|
-
// Fresh list items default to loose even when their Markdown reopens tight.
|
|
600
|
-
[
|
|
601
|
-
'referenceContent',
|
|
602
|
-
'referenceType',
|
|
603
|
-
'marker',
|
|
604
|
-
'id',
|
|
605
|
-
'label',
|
|
606
|
-
'spread',
|
|
607
|
-
'authoredFence',
|
|
608
|
-
'inkkitFileRaw',
|
|
609
|
-
'inkkitFileWidth',
|
|
610
|
-
'inkkitFileInTable',
|
|
611
|
-
].includes(key)
|
|
612
|
-
? undefined
|
|
613
|
-
: value));
|
|
614
|
-
return JSON.stringify(clean(json));
|
|
615
|
-
}
|
|
616
|
-
// Milkdown derives heading DOM IDs; Markdown stores the level and content.
|
|
617
|
-
function withoutHeadingId(node) {
|
|
618
|
-
return node.type.name === 'heading' && node.attrs.id
|
|
619
|
-
? node.type.create({ ...node.attrs, id: '' }, node.content, node.marks)
|
|
620
|
-
: node;
|
|
621
|
-
}
|
|
622
|
-
const MATCHING_BUDGET = 1_000_000;
|
|
623
|
-
const SIMILARITY_BUDGET = 4_000_000;
|
|
624
|
-
// Unchanged blocks are anchored first, so a long document with many scattered
|
|
625
|
-
// edits is matched as many short gaps rather than one wide span.
|
|
626
|
-
export function correspondence(before, after) {
|
|
627
|
-
const result = new Array(after.length);
|
|
628
|
-
const work = { budget: MATCHING_BUDGET, similarity: SIMILARITY_BUDGET };
|
|
629
|
-
const spans = [{ b: 0, a: 0, bEnd: before.length, aEnd: after.length }];
|
|
630
|
-
for (let span = spans.pop(); span; span = spans.pop()) {
|
|
631
|
-
let { b, a, bEnd, aEnd } = span;
|
|
632
|
-
while (b < bEnd && a < aEnd && before[b] === after[a])
|
|
633
|
-
result[a++] = b++;
|
|
634
|
-
while (b < bEnd && a < aEnd && before[bEnd - 1] === after[aEnd - 1])
|
|
635
|
-
result[--aEnd] = --bEnd;
|
|
636
|
-
if (b === bEnd || a === aEnd)
|
|
637
|
-
continue;
|
|
638
|
-
work.budget -= bEnd - b + (aEnd - a);
|
|
639
|
-
if (work.budget < 0)
|
|
640
|
-
throw tooManyBlocks();
|
|
641
|
-
const anchors = uniqueAnchors(before, after, b, bEnd, a, aEnd);
|
|
642
|
-
if (!anchors.length) {
|
|
643
|
-
align(before, after, b, bEnd, a, aEnd, result, work);
|
|
644
|
-
continue;
|
|
645
|
-
}
|
|
646
|
-
for (const anchor of anchors) {
|
|
647
|
-
result[anchor.a] = anchor.b;
|
|
648
|
-
spans.push({ b, a, bEnd: anchor.b, aEnd: anchor.a });
|
|
649
|
-
b = anchor.b + 1;
|
|
650
|
-
a = anchor.a + 1;
|
|
651
|
-
}
|
|
652
|
-
spans.push({ b, a, bEnd, aEnd });
|
|
653
|
-
}
|
|
654
|
-
return result;
|
|
655
|
-
}
|
|
656
|
-
function tooManyBlocks() {
|
|
657
|
-
return new PreservationError('The edit changes too many blocks to preserve safely');
|
|
658
|
-
}
|
|
659
|
-
// Blocks that occur exactly once on each side, in the longest subsequence whose
|
|
660
|
-
// order the two sides share. A repeated block is never an anchor: which copy it
|
|
661
|
-
// belongs to is not known until its neighbours are placed.
|
|
662
|
-
function uniqueAnchors(before, after, b, bEnd, a, aEnd) {
|
|
663
|
-
const once = (list, from, to) => {
|
|
664
|
-
const at = new Map();
|
|
665
|
-
for (let i = from; i < to; i++)
|
|
666
|
-
at.set(list[i], at.has(list[i]) ? -1 : i);
|
|
667
|
-
return at;
|
|
668
|
-
};
|
|
669
|
-
const beforeAt = once(before, b, bEnd);
|
|
670
|
-
const afterAt = once(after, a, aEnd);
|
|
671
|
-
const pairs = [];
|
|
672
|
-
for (let i = a; i < aEnd; i++) {
|
|
673
|
-
const match = beforeAt.get(after[i]);
|
|
674
|
-
if (match != null && match >= 0 && afterAt.get(after[i]) === i)
|
|
675
|
-
pairs.push({ b: match, a: i });
|
|
676
|
-
}
|
|
677
|
-
// Patience sorting: tails[k] ends the best increasing subsequence of length k + 1.
|
|
678
|
-
const tails = [];
|
|
679
|
-
const previous = new Array(pairs.length);
|
|
680
|
-
for (const [index, pair] of pairs.entries()) {
|
|
681
|
-
let low = 0;
|
|
682
|
-
let high = tails.length;
|
|
683
|
-
while (low < high) {
|
|
684
|
-
const middle = (low + high) >> 1;
|
|
685
|
-
if (pairs[tails[middle]].b < pair.b)
|
|
686
|
-
low = middle + 1;
|
|
687
|
-
else
|
|
688
|
-
high = middle;
|
|
689
|
-
}
|
|
690
|
-
previous[index] = low ? tails[low - 1] : -1;
|
|
691
|
-
tails[low] = index;
|
|
692
|
-
}
|
|
693
|
-
const anchors = [];
|
|
694
|
-
let at = tails.length ? tails[tails.length - 1] : -1;
|
|
695
|
-
while (at >= 0) {
|
|
696
|
-
anchors.push(pairs[at]);
|
|
697
|
-
at = previous[at];
|
|
698
|
-
}
|
|
699
|
-
return anchors.reverse();
|
|
700
|
-
}
|
|
701
|
-
// Replacing a block costs more than inserting or deleting one, so an unchanged
|
|
702
|
-
// block keeps its own copy rather than pairing with a deleted neighbour, and
|
|
703
|
-
// less than both, so an edited block still keeps its surrounding whitespace.
|
|
704
|
-
const INDEL = 2;
|
|
705
|
-
const REPLACE = 3;
|
|
706
|
-
function align(before, after, b, bEnd, a, aEnd, result, work) {
|
|
707
|
-
const rows = bEnd - b + 1;
|
|
708
|
-
const columns = aEnd - a + 1;
|
|
709
|
-
const offset = Math.abs(rows - columns);
|
|
710
|
-
let width = Math.max(1, offset);
|
|
711
|
-
for (;;) {
|
|
712
|
-
const costs = [];
|
|
713
|
-
const get = (i, j) => i < 0 || j < 0 || Math.abs(i - j) > width
|
|
714
|
-
? Infinity
|
|
715
|
-
: (costs[i]?.[j - Math.max(0, i - width)] ?? Infinity);
|
|
716
|
-
for (let i = 0; i < rows; i++) {
|
|
717
|
-
const from = Math.max(0, i - width);
|
|
718
|
-
const to = Math.min(columns - 1, i + width);
|
|
719
|
-
work.budget -= to - from + 1;
|
|
720
|
-
if (work.budget < 0)
|
|
721
|
-
throw tooManyBlocks();
|
|
722
|
-
const row = new Uint32Array(to - from + 1);
|
|
723
|
-
costs.push(row);
|
|
724
|
-
for (let j = from; j <= to; j++) {
|
|
725
|
-
const change = before[b + i - 1] === after[a + j - 1] ? 0 : REPLACE;
|
|
726
|
-
row[j - from] =
|
|
727
|
-
i === 0
|
|
728
|
-
? j * INDEL
|
|
729
|
-
: j === 0
|
|
730
|
-
? i * INDEL
|
|
731
|
-
: Math.min(get(i - 1, j) + INDEL, get(i, j - 1) + INDEL, get(i - 1, j - 1) + change);
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
// A path that leaves the band takes at least width + 1 insertions or
|
|
735
|
-
// deletions away from the diagonal and width + 1 - offset back to its end,
|
|
736
|
-
// so a path inside the band that costs no more is optimal. Widen only when
|
|
737
|
-
// needed, charging every attempt to the same work budget.
|
|
738
|
-
if (width < Math.max(rows, columns) - 1 &&
|
|
739
|
-
get(rows - 1, columns - 1) > INDEL * (2 * (width + 1) - offset)) {
|
|
740
|
-
width *= 2;
|
|
741
|
-
continue;
|
|
742
|
-
}
|
|
743
|
-
const unchanged = [];
|
|
744
|
-
let i = rows - 1;
|
|
745
|
-
let j = columns - 1;
|
|
746
|
-
while (i || j) {
|
|
747
|
-
const same = i && j && before[b + i - 1] === after[a + j - 1];
|
|
748
|
-
if (i && j && get(i, j) === get(i - 1, j - 1) + (same ? 0 : REPLACE)) {
|
|
749
|
-
result[a + j - 1] = b + i - 1;
|
|
750
|
-
if (same)
|
|
751
|
-
unchanged.push({ b: b + i - 1, a: a + j - 1 });
|
|
752
|
-
i--;
|
|
753
|
-
j--;
|
|
754
|
-
}
|
|
755
|
-
else if (i && get(i, j) === get(i - 1, j) + INDEL)
|
|
756
|
-
i--;
|
|
757
|
-
else
|
|
758
|
-
j--;
|
|
759
|
-
}
|
|
760
|
-
unchanged.push({ b: b - 1, a: a - 1 });
|
|
761
|
-
unchanged.reverse().push({ b: bEnd, a: aEnd });
|
|
762
|
-
for (let index = 1; index < unchanged.length; index++)
|
|
763
|
-
pairSimilar(before, after, unchanged[index - 1].b + 1, unchanged[index].b, unchanged[index - 1].a + 1, unchanged[index].a, result, work);
|
|
764
|
-
return;
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
// Between unchanged blocks, edit distance prices every replacement alike, so
|
|
768
|
-
// it cannot tell which original an edited block came from. Pair each with the
|
|
769
|
-
// original it most resembles, keeping their order: typing in a block then keeps
|
|
770
|
-
// its own source even when a neighbour was inserted or deleted beside it.
|
|
771
|
-
function pairSimilar(before, after, b, bEnd, a, aEnd, result, work) {
|
|
772
|
-
const removed = bEnd - b;
|
|
773
|
-
const added = aEnd - a;
|
|
774
|
-
if (!removed || !added || (removed === 1 && added === 1))
|
|
775
|
-
return;
|
|
776
|
-
// Resemblance decides; among equally resembling pairings, more pairs keep
|
|
777
|
-
// more replaced blocks in place with their surrounding whitespace.
|
|
778
|
-
const bonus = 1 / (Math.min(removed, added) + 1);
|
|
779
|
-
// This is a refinement with its own budget of characters compared. When it
|
|
780
|
-
// runs out, the alignment's pairing stands rather than the edit failing.
|
|
781
|
-
if (removed * added > work.similarity)
|
|
782
|
-
return;
|
|
783
|
-
const weights = new Float64Array(removed * added);
|
|
784
|
-
for (let x = 0; x < removed; x++)
|
|
785
|
-
for (let y = 0; y < added; y++) {
|
|
786
|
-
const shared = similarity(before[b + x], after[a + y]);
|
|
787
|
-
work.similarity -= shared + 1;
|
|
788
|
-
if (work.similarity < 0)
|
|
789
|
-
return;
|
|
790
|
-
weights[x * added + y] = shared + bonus;
|
|
791
|
-
}
|
|
792
|
-
const best = Array.from({ length: removed + 1 }, () => new Float64Array(added + 1));
|
|
793
|
-
for (let x = 1; x <= removed; x++)
|
|
794
|
-
for (let y = 1; y <= added; y++)
|
|
795
|
-
best[x][y] = Math.max(best[x - 1][y], best[x][y - 1], best[x - 1][y - 1] + weights[(x - 1) * added + y - 1]);
|
|
796
|
-
for (let index = a; index < aEnd; index++)
|
|
797
|
-
result[index] = undefined;
|
|
798
|
-
for (let x = removed, y = added; x && y;) {
|
|
799
|
-
if (best[x][y] ===
|
|
800
|
-
best[x - 1][y - 1] + weights[(x - 1) * added + y - 1]) {
|
|
801
|
-
result[a + --y] = b + --x;
|
|
802
|
-
}
|
|
803
|
-
else if (best[x][y] === best[x - 1][y])
|
|
804
|
-
x--;
|
|
805
|
-
else
|
|
806
|
-
y--;
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
// Characters an edit left in place at the start and end of a block.
|
|
810
|
-
function similarity(left, right) {
|
|
811
|
-
const limit = Math.min(left.length, right.length);
|
|
812
|
-
let prefix = 0;
|
|
813
|
-
while (prefix < limit && left[prefix] === right[prefix])
|
|
814
|
-
prefix++;
|
|
815
|
-
let suffix = 0;
|
|
816
|
-
while (suffix < limit - prefix &&
|
|
817
|
-
left[left.length - 1 - suffix] === right[right.length - 1 - suffix])
|
|
818
|
-
suffix++;
|
|
819
|
-
return prefix + suffix;
|
|
820
|
-
}
|
|
821
|
-
/** Retains the loaded source of blocks the edit leaves alone. */
|
|
822
|
-
export class Preservation {
|
|
823
|
-
ctx;
|
|
824
|
-
source;
|
|
825
|
-
baseline;
|
|
826
|
-
blocks = [];
|
|
827
|
-
lineEnding;
|
|
828
|
-
originalForms = new Set();
|
|
829
|
-
forms = new WeakMap();
|
|
830
|
-
blockEdits = new WeakMap();
|
|
831
|
-
itemEdits = new WeakMap();
|
|
832
|
-
definitionNodes = new Map();
|
|
833
|
-
// Undefined when a definition sits inside another block, where appending
|
|
834
|
-
// the top-level ones would not reproduce the document.
|
|
835
|
-
definitionSources;
|
|
836
|
-
constructor(ctx, source) {
|
|
837
|
-
this.ctx = ctx;
|
|
838
|
-
this.source = source;
|
|
839
|
-
try {
|
|
840
|
-
const parse = ctx.get(parserCtx);
|
|
841
|
-
this.baseline = parse(source);
|
|
842
|
-
this.lineEnding =
|
|
843
|
-
source.includes('\r\n') && !source.replaceAll('\r\n', '').includes('\n')
|
|
844
|
-
? '\r\n'
|
|
845
|
-
: '\n';
|
|
846
|
-
const processor = ctx.get(remarkCtx);
|
|
847
|
-
const offset = source.startsWith('\uFEFF') ? 1 : 0;
|
|
848
|
-
const body = source.slice(offset);
|
|
849
|
-
const rawTree = processor.parse(source);
|
|
850
|
-
const tree = processor.runSync(structuredClone(rawTree), {
|
|
851
|
-
value: body,
|
|
852
|
-
});
|
|
853
|
-
for (const [index, node] of tree.children.entries()) {
|
|
854
|
-
let start = node.position?.start.offset;
|
|
855
|
-
const end = node.position?.end.offset;
|
|
856
|
-
if (start == null || end == null)
|
|
857
|
-
throw new PreservationError('A source block has no location');
|
|
858
|
-
start += offset;
|
|
859
|
-
const sourceEnd = end + offset;
|
|
860
|
-
const firstColumn = source.lastIndexOf('\n', start - 1) + 1;
|
|
861
|
-
if (/^[ \t]*$/.test(source.slice(firstColumn, start)))
|
|
862
|
-
start = firstColumn;
|
|
863
|
-
const text = source.slice(start, sourceEnd);
|
|
864
|
-
const parsed = this.baseline.childCount === tree.children.length
|
|
865
|
-
? this.baseline.type.create(null, [this.baseline.child(index)])
|
|
866
|
-
: parse(text);
|
|
867
|
-
this.blocks.push({
|
|
868
|
-
start,
|
|
869
|
-
end: sourceEnd,
|
|
870
|
-
form: parsed.childCount === 1
|
|
871
|
-
? blockForm(ctx, parsed.firstChild)
|
|
872
|
-
: form(serialize(ctx, parsed)),
|
|
873
|
-
ast: positionalNode(rawTree, node),
|
|
874
|
-
});
|
|
875
|
-
}
|
|
876
|
-
for (const block of this.blocks)
|
|
877
|
-
this.originalForms.add(block.form);
|
|
878
|
-
const definition = (node) => node.type === 'definition' || node.type === 'footnoteDefinition';
|
|
879
|
-
const nested = (node) => (node.children ?? []).some((child) => definition(child) || nested(child));
|
|
880
|
-
const top = rawTree.children;
|
|
881
|
-
if (!top.some(nested))
|
|
882
|
-
this.definitionSources = top.filter(definition).map((node) => {
|
|
883
|
-
const raw = source.slice(node.position.start.offset + offset, node.position.end.offset + offset);
|
|
884
|
-
// A footnote reference is never validated alone, so only its label
|
|
885
|
-
// matters, and its body need not be parsed on every keystroke.
|
|
886
|
-
const label = /^\[((?:\\[\s\S]|[^\\\]])*)\]:/.exec(raw)[1];
|
|
887
|
-
return node.type === 'footnoteDefinition'
|
|
888
|
-
? { label: definitionLabel(label), text: `[${label}]: x` }
|
|
889
|
-
: { label: definitionLabel(label), text: raw };
|
|
890
|
-
});
|
|
891
|
-
}
|
|
892
|
-
catch (cause) {
|
|
893
|
-
if (cause instanceof PreservationError)
|
|
894
|
-
throw cause;
|
|
895
|
-
throw new PreservationError('Cannot read Markdown for preservation', {
|
|
896
|
-
cause,
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
retainBlock(block, after, canonical) {
|
|
901
|
-
const offset = this.source.startsWith('\uFEFF') ? 1 : 0;
|
|
902
|
-
const start = block.ast.position?.start.offset;
|
|
903
|
-
if (start == null)
|
|
904
|
-
throw new PreservationError('A source block has no location');
|
|
905
|
-
return (this.source.slice(block.start, start + offset) +
|
|
906
|
-
retainSource(block.ast, after, this.source.slice(offset), canonical, this.lineEnding));
|
|
907
|
-
}
|
|
908
|
-
localEdits(doc) {
|
|
909
|
-
if (this.baseline.childCount !== this.blocks.length ||
|
|
910
|
-
doc.childCount !== this.blocks.length ||
|
|
911
|
-
!doc.sameMarkup(this.baseline))
|
|
912
|
-
return undefined;
|
|
913
|
-
let output = '', previous = 0;
|
|
914
|
-
let run = [];
|
|
915
|
-
for (let index = 0; index <= doc.childCount; index++) {
|
|
916
|
-
const node = index < doc.childCount ? doc.child(index) : undefined;
|
|
917
|
-
if (!node || node.eq(this.baseline.child(index))) {
|
|
918
|
-
if (run.length > 1 && !this.pairedInPlace(doc, run))
|
|
919
|
-
return undefined;
|
|
920
|
-
run = [];
|
|
921
|
-
continue;
|
|
922
|
-
}
|
|
923
|
-
run.push(index);
|
|
924
|
-
const block = this.blocks[index];
|
|
925
|
-
let retained = this.blockEdits.get(node);
|
|
926
|
-
if (retained?.index !== index) {
|
|
927
|
-
const text = this.localBlock(index, node);
|
|
928
|
-
if (text === undefined)
|
|
929
|
-
return undefined;
|
|
930
|
-
retained = { index, text };
|
|
931
|
-
this.blockEdits.set(node, retained);
|
|
932
|
-
}
|
|
933
|
-
output += this.source.slice(previous, block.start) + retained.text;
|
|
934
|
-
previous = block.end;
|
|
935
|
-
}
|
|
936
|
-
output += this.source.slice(previous);
|
|
937
|
-
// Front matter is found by reading from the start of the file to the next
|
|
938
|
-
// delimiter line, which an edited block can supply.
|
|
939
|
-
const bom = (text) => text.replace(/^\uFEFF/, '');
|
|
940
|
-
return sourceFrontmatter(bom(output)) ===
|
|
941
|
-
sourceFrontmatter(bom(this.source))
|
|
942
|
-
? output
|
|
943
|
-
: undefined;
|
|
944
|
-
}
|
|
945
|
-
// Whole-document matching pairs adjacent edited blocks by resemblance, which
|
|
946
|
-
// can pair one with its neighbour's original. Keep its result exactly.
|
|
947
|
-
pairedInPlace(doc, run) {
|
|
948
|
-
if (run.some((index) => ['bullet_list', 'ordered_list'].includes(doc.child(index).type.name)))
|
|
949
|
-
return false;
|
|
950
|
-
const before = run.map((index) => this.blocks[index].form);
|
|
951
|
-
const after = run.map((index) => this.formOf(doc.child(index)));
|
|
952
|
-
const result = run.map((_, at) => at);
|
|
953
|
-
pairSimilar(before, after, 0, run.length, 0, run.length, result, {
|
|
954
|
-
similarity: SIMILARITY_BUDGET,
|
|
955
|
-
});
|
|
956
|
-
return result.every((position, at) => position === at);
|
|
957
|
-
}
|
|
958
|
-
// Every definition in the source, in order. A block's links can depend on
|
|
959
|
-
// definitions and nothing else outside it, so parsing it with these gives
|
|
960
|
-
// the meaning it has in the document.
|
|
961
|
-
// The definitions a block's text can refer to: only a bracketed label that
|
|
962
|
-
// matches one can resolve, so the others need not be parsed with it.
|
|
963
|
-
definitions(text) {
|
|
964
|
-
if (this.definitionSources === undefined)
|
|
965
|
-
return undefined;
|
|
966
|
-
const labels = new Set();
|
|
967
|
-
for (const match of text.matchAll(/\[((?:\\[\s\S]|[^\\[\]])*)\]/g))
|
|
968
|
-
labels.add(definitionLabel(match[1]));
|
|
969
|
-
const sources = this.definitionSources
|
|
970
|
-
.filter((source) => labels.has(source.label))
|
|
971
|
-
.map((source) => source.text);
|
|
972
|
-
const appended = sources.join('\n\n');
|
|
973
|
-
let nodes = this.definitionNodes.get(appended);
|
|
974
|
-
if (nodes === undefined) {
|
|
975
|
-
nodes = appended ? this.ctx.get(parserCtx)(appended).childCount : 0;
|
|
976
|
-
this.definitionNodes.set(appended, nodes);
|
|
977
|
-
}
|
|
978
|
-
return { text: appended, blocks: sources.length, nodes };
|
|
979
|
-
}
|
|
980
|
-
// The block's text followed by what it can depend on elsewhere in the
|
|
981
|
-
// document, or undefined when that cannot be known from the block alone.
|
|
982
|
-
inContext(text) {
|
|
983
|
-
// HTML and %% comments can span blocks.
|
|
984
|
-
if (/<|%%/.test(text))
|
|
985
|
-
return undefined;
|
|
986
|
-
if (!text.includes('['))
|
|
987
|
-
return { text, blocks: 1, nodes: 1 };
|
|
988
|
-
const definitions = this.definitions(text);
|
|
989
|
-
if (!definitions)
|
|
990
|
-
return undefined;
|
|
991
|
-
return definitions.text
|
|
992
|
-
? {
|
|
993
|
-
text: text + this.lineEnding.repeat(2) + definitions.text,
|
|
994
|
-
blocks: 1 + definitions.blocks,
|
|
995
|
-
nodes: 1 + definitions.nodes,
|
|
996
|
-
}
|
|
997
|
-
: { text, blocks: 1, nodes: 1 };
|
|
998
|
-
}
|
|
999
|
-
// The first block of text as the document would read it in place.
|
|
1000
|
-
parseLocally(text) {
|
|
1001
|
-
const local = this.inContext(text);
|
|
1002
|
-
if (!local)
|
|
1003
|
-
return undefined;
|
|
1004
|
-
const parsed = this.ctx.get(parserCtx)(local.text);
|
|
1005
|
-
return parsed.childCount === local.nodes ? parsed.firstChild : undefined;
|
|
1006
|
-
}
|
|
1007
|
-
// Canonical Markdown for a block and its source tree, read in the same
|
|
1008
|
-
// context, so references in it resolve as they do in the document.
|
|
1009
|
-
canonicalTree(text) {
|
|
1010
|
-
const local = this.inContext(text);
|
|
1011
|
-
if (!local)
|
|
1012
|
-
return undefined;
|
|
1013
|
-
const tree = this.ctx.get(remarkCtx).parse(local.text);
|
|
1014
|
-
return tree.children.length === local.blocks
|
|
1015
|
-
? {
|
|
1016
|
-
canonical: local.text,
|
|
1017
|
-
node: tree.children[0],
|
|
1018
|
-
}
|
|
1019
|
-
: undefined;
|
|
1020
|
-
}
|
|
1021
|
-
// Inline content other than text and line breaks can depend on other blocks
|
|
1022
|
-
// or carry attributes that a reparse does not reproduce.
|
|
1023
|
-
static plainInline(node) {
|
|
1024
|
-
let plain = true;
|
|
1025
|
-
node.descendants((child) => {
|
|
1026
|
-
if (child.isInline && !child.isText && child.type.name !== 'hardbreak')
|
|
1027
|
-
plain = false;
|
|
1028
|
-
return plain;
|
|
1029
|
-
});
|
|
1030
|
-
return plain;
|
|
1031
|
-
}
|
|
1032
|
-
separated(index) {
|
|
1033
|
-
const block = this.blocks[index];
|
|
1034
|
-
return ((index === 0 ||
|
|
1035
|
-
/\r?\n[ \t]*\r?\n/.test(this.source.slice(this.blocks[index - 1].end, block.start))) &&
|
|
1036
|
-
(index + 1 === this.blocks.length ||
|
|
1037
|
-
/\r?\n[ \t]*\r?\n/.test(this.source.slice(block.end, this.blocks[index + 1].start))));
|
|
1038
|
-
}
|
|
1039
|
-
// Validates a changed block on its own when nothing outside it can change
|
|
1040
|
-
// what it means, so a keystroke does not serialize and reparse the document.
|
|
1041
|
-
localBlock(index, node) {
|
|
1042
|
-
const before = this.baseline.child(index);
|
|
1043
|
-
const block = this.blocks[index];
|
|
1044
|
-
const expected = withoutHeadingId(node);
|
|
1045
|
-
if (!expected.sameMarkup(withoutHeadingId(before)) ||
|
|
1046
|
-
!this.separated(index))
|
|
1047
|
-
return undefined;
|
|
1048
|
-
if (node.type.name === 'bullet_list' || node.type.name === 'ordered_list')
|
|
1049
|
-
return this.localItems(index, node);
|
|
1050
|
-
const code = node.type.name === 'code_block';
|
|
1051
|
-
// Indented code is rewritten fenced, and the blocks after it can then read
|
|
1052
|
-
// differently.
|
|
1053
|
-
if (code &&
|
|
1054
|
-
!/^ {0,3}(?:`{3,}|~{3,})/.test(this.source.slice(block.start, block.end)))
|
|
1055
|
-
return undefined;
|
|
1056
|
-
if (!code &&
|
|
1057
|
-
(!['paragraph', 'heading', 'table'].includes(node.type.name) ||
|
|
1058
|
-
!Preservation.plainInline(node) ||
|
|
1059
|
-
!Preservation.plainInline(before) ||
|
|
1060
|
-
!this.inContext(this.source.slice(block.start, block.end))))
|
|
1061
|
-
return undefined;
|
|
1062
|
-
const form = this.formOf(node);
|
|
1063
|
-
if (form !== block.form && this.originalForms.has(form))
|
|
1064
|
-
return undefined;
|
|
1065
|
-
// Code is literal, so only its fence decides where it ends.
|
|
1066
|
-
const canonical = code
|
|
1067
|
-
? (() => {
|
|
1068
|
-
const tree = this.ctx.get(remarkCtx).parse(form);
|
|
1069
|
-
return tree.children.length === 1
|
|
1070
|
-
? {
|
|
1071
|
-
canonical: form,
|
|
1072
|
-
node: tree.children[0],
|
|
1073
|
-
}
|
|
1074
|
-
: undefined;
|
|
1075
|
-
})()
|
|
1076
|
-
: this.canonicalTree(form);
|
|
1077
|
-
if (!canonical)
|
|
1078
|
-
return undefined;
|
|
1079
|
-
const text = this.retainBlock(block, canonical.node, canonical.canonical);
|
|
1080
|
-
const reopened = code
|
|
1081
|
-
? (() => {
|
|
1082
|
-
const parsed = this.ctx.get(parserCtx)(text);
|
|
1083
|
-
return parsed.childCount === 1 ? parsed.firstChild : undefined;
|
|
1084
|
-
})()
|
|
1085
|
-
: this.parseLocally(text);
|
|
1086
|
-
// The authored fence is presentation the editor keeps from loading.
|
|
1087
|
-
const same = (node) => code
|
|
1088
|
-
? node.type === expected.type &&
|
|
1089
|
-
node.attrs.language === expected.attrs.language &&
|
|
1090
|
-
node.content.eq(expected.content)
|
|
1091
|
-
: withoutHeadingId(node).eq(expected);
|
|
1092
|
-
return reopened && same(reopened) ? text : undefined;
|
|
1093
|
-
}
|
|
1094
|
-
formOf(node) {
|
|
1095
|
-
let form = this.forms.get(node);
|
|
1096
|
-
if (form == null) {
|
|
1097
|
-
form = blockForm(this.ctx, node);
|
|
1098
|
-
this.forms.set(node, form);
|
|
1099
|
-
}
|
|
1100
|
-
return form;
|
|
1101
|
-
}
|
|
1102
|
-
// A list is one block, so typing in one item of a long list validates that
|
|
1103
|
-
// item alone, read as the only item of a list with the same marker.
|
|
1104
|
-
localItems(index, node) {
|
|
1105
|
-
const before = this.baseline.child(index);
|
|
1106
|
-
const block = this.blocks[index];
|
|
1107
|
-
const list = block.ast;
|
|
1108
|
-
const offset = this.source.startsWith('\uFEFF') ? 1 : 0;
|
|
1109
|
-
const source = this.source.slice(offset);
|
|
1110
|
-
if (list.type !== 'list' ||
|
|
1111
|
-
list.children?.length !== node.childCount ||
|
|
1112
|
-
before.childCount !== node.childCount)
|
|
1113
|
-
return undefined;
|
|
1114
|
-
// Whole-document matching would read a list that now matches another one
|
|
1115
|
-
// as moved there.
|
|
1116
|
-
for (let other = 0; other < this.baseline.childCount; other++) {
|
|
1117
|
-
const candidate = this.baseline.child(other);
|
|
1118
|
-
if (other !== index &&
|
|
1119
|
-
candidate.type === node.type &&
|
|
1120
|
-
candidate.childCount === node.childCount &&
|
|
1121
|
-
candidate.textContent === node.textContent)
|
|
1122
|
-
return undefined;
|
|
1123
|
-
}
|
|
1124
|
-
const marker = authoredMarker(list, source);
|
|
1125
|
-
let output = '';
|
|
1126
|
-
let previous = block.start;
|
|
1127
|
-
for (let at = 0; at < node.childCount; at++) {
|
|
1128
|
-
const item = node.child(at);
|
|
1129
|
-
if (item.eq(before.child(at)))
|
|
1130
|
-
continue;
|
|
1131
|
-
let retained = this.itemEdits.get(item);
|
|
1132
|
-
if (retained?.index !== index || retained.item !== at) {
|
|
1133
|
-
const text = this.localItem(node, item, before.child(at), list.children[at], source, marker);
|
|
1134
|
-
if (text === undefined)
|
|
1135
|
-
return undefined;
|
|
1136
|
-
retained = { index, item: at, text };
|
|
1137
|
-
this.itemEdits.set(item, retained);
|
|
1138
|
-
}
|
|
1139
|
-
const ast = list.children[at];
|
|
1140
|
-
output +=
|
|
1141
|
-
this.source.slice(previous, ast.position.start.offset + offset) +
|
|
1142
|
-
retained.text;
|
|
1143
|
-
previous = ast.position.end.offset + offset;
|
|
1144
|
-
}
|
|
1145
|
-
return output + this.source.slice(previous, block.end);
|
|
1146
|
-
}
|
|
1147
|
-
localItem(list, item, before, ast, source, marker) {
|
|
1148
|
-
const start = ast.position.start.offset;
|
|
1149
|
-
const end = ast.position.end.offset;
|
|
1150
|
-
if (!item.sameMarkup(before) ||
|
|
1151
|
-
!sameBlocks(item, before) ||
|
|
1152
|
-
!Preservation.plainInline(item) ||
|
|
1153
|
-
!Preservation.plainInline(before) ||
|
|
1154
|
-
// The authored item is compared with the edit, so it must read the same
|
|
1155
|
-
// in this context too.
|
|
1156
|
-
!this.inContext(source.slice(start, end)))
|
|
1157
|
-
return undefined;
|
|
1158
|
-
const local = this.canonicalTree(form(serialize(this.ctx, list.type.schema.topNodeType.create(null, [
|
|
1159
|
-
list.type.create(list.attrs, [item]),
|
|
1160
|
-
]))));
|
|
1161
|
-
const after = local?.node.children?.[0];
|
|
1162
|
-
if (!local || local.node.children.length !== 1 || !after)
|
|
1163
|
-
return undefined;
|
|
1164
|
-
// Whether an item is spread depends on the blank lines around it, which an
|
|
1165
|
-
// edit that keeps its blocks does not change.
|
|
1166
|
-
let text = retainSource(ast, { ...after, spread: ast.spread }, source, local.canonical, this.lineEnding);
|
|
1167
|
-
if (marker)
|
|
1168
|
-
text = relabel(text, [0], marker);
|
|
1169
|
-
// The marker and the spaces after it set the column that continuation
|
|
1170
|
-
// lines, and blocks after the list, are read against.
|
|
1171
|
-
const content = ast.children?.[0]?.position?.start.offset;
|
|
1172
|
-
if (content == null || !text.startsWith(source.slice(start, content)))
|
|
1173
|
-
return undefined;
|
|
1174
|
-
// An item with nothing on its first line has a different content column.
|
|
1175
|
-
if (/^(?:[-*+]|\d{1,9}[.)])[ \t]*(?:\r?\n|$)/.test(text))
|
|
1176
|
-
return undefined;
|
|
1177
|
-
const indent = source.slice(source.lastIndexOf('\n', start - 1) + 1, start);
|
|
1178
|
-
if (!/^[ \t]*$/.test(indent))
|
|
1179
|
-
return undefined;
|
|
1180
|
-
const reopened = this.parseLocally(indent + text);
|
|
1181
|
-
if (reopened?.type !== list.type || reopened.childCount !== 1)
|
|
1182
|
-
return undefined;
|
|
1183
|
-
const reread = reopened.firstChild;
|
|
1184
|
-
// The label and spacing of an item come from its list.
|
|
1185
|
-
const { label: _label, spread: _spread, ...attrs } = reread.attrs;
|
|
1186
|
-
const { label: _l, spread: _s, ...expected } = item.attrs;
|
|
1187
|
-
return reread.type === item.type &&
|
|
1188
|
-
JSON.stringify(attrs) === JSON.stringify(expected) &&
|
|
1189
|
-
reread.content.eq(item.content)
|
|
1190
|
-
? text
|
|
1191
|
-
: undefined;
|
|
1192
|
-
}
|
|
1193
|
-
// Two lists of one kind side by side are one list unless their markers
|
|
1194
|
-
// differ, so each run of adjacent lists gets markers that alternate. A list
|
|
1195
|
-
// written afresh changes first, then an edited list. An unchanged list keeps
|
|
1196
|
-
// its source unless a move changed one of its neighbours; otherwise the
|
|
1197
|
-
// reopen check refuses.
|
|
1198
|
-
separateLists(doc, pieces, kept, positions, current) {
|
|
1199
|
-
const kind = (index) => {
|
|
1200
|
-
const name = doc.maybeChild(index)?.type.name;
|
|
1201
|
-
return name === 'bullet_list' || name === 'ordered_list'
|
|
1202
|
-
? name
|
|
1203
|
-
: undefined;
|
|
1204
|
-
};
|
|
1205
|
-
const marker = (index) => markerAt(pieces[index], /^[ \t]*/.exec(pieces[index])[0].length)
|
|
1206
|
-
?.marker;
|
|
1207
|
-
const elsewhere = new Set(current.filter((_, index) => kept[index] !== 'unchanged'));
|
|
1208
|
-
const moved = (index) => positions[index] == null && this.originalForms.has(current[index]);
|
|
1209
|
-
// Beside its source neighbour, or beside new blocks only, with nothing
|
|
1210
|
-
// between them in the source moved elsewhere.
|
|
1211
|
-
const inPlace = (index, step) => {
|
|
1212
|
-
const own = positions[index];
|
|
1213
|
-
let next = index + step;
|
|
1214
|
-
while (next >= 0 &&
|
|
1215
|
-
next < pieces.length &&
|
|
1216
|
-
positions[next] == null &&
|
|
1217
|
-
!moved(next))
|
|
1218
|
-
next += step;
|
|
1219
|
-
if (next >= 0 && next < pieces.length && positions[next] == null)
|
|
1220
|
-
return false;
|
|
1221
|
-
const other = next < 0
|
|
1222
|
-
? -1
|
|
1223
|
-
: next >= pieces.length
|
|
1224
|
-
? this.blocks.length
|
|
1225
|
-
: positions[next];
|
|
1226
|
-
if ((other - own) * step <= 0)
|
|
1227
|
-
return false;
|
|
1228
|
-
for (let at = own + step; at !== other; at += step)
|
|
1229
|
-
if (elsewhere.has(this.blocks[at].form))
|
|
1230
|
-
return false;
|
|
1231
|
-
return true;
|
|
1232
|
-
};
|
|
1233
|
-
// An unchanged list changes only to part from a list with its marker across
|
|
1234
|
-
// a boundary that a move disturbed, and one moved on both sides first.
|
|
1235
|
-
const cost = (index) => {
|
|
1236
|
-
if (kept[index] === 'canonical')
|
|
1237
|
-
return 1;
|
|
1238
|
-
if (kept[index] === 'edited')
|
|
1239
|
-
return 10;
|
|
1240
|
-
const own = marker(index);
|
|
1241
|
-
const sides = [-1, 1].map((step) => inPlace(index, step));
|
|
1242
|
-
const collides = [-1, 1].some((step, side) => !sides[side] &&
|
|
1243
|
-
kind(index + step) === kind(index) &&
|
|
1244
|
-
marker(index + step) === own);
|
|
1245
|
-
return collides
|
|
1246
|
-
? [50, 150][Number(sides[0]) + Number(sides[1])]
|
|
1247
|
-
: Infinity;
|
|
1248
|
-
};
|
|
1249
|
-
for (let start = 0; start < pieces.length;) {
|
|
1250
|
-
const list = kind(start);
|
|
1251
|
-
let end = start + 1;
|
|
1252
|
-
while (list && kind(end) === list)
|
|
1253
|
-
end++;
|
|
1254
|
-
const run = Array.from({ length: end - start }, (_, i) => start + i);
|
|
1255
|
-
start = end;
|
|
1256
|
-
if (run.length < 2 ||
|
|
1257
|
-
run.every((index, i) => !i || marker(index) !== marker(index - 1)))
|
|
1258
|
-
continue;
|
|
1259
|
-
const markers = list === 'bullet_list' ? ['-', '*', '+'] : ['.', ')'];
|
|
1260
|
-
const options = run.map((index) => {
|
|
1261
|
-
const own = marker(index);
|
|
1262
|
-
const change = cost(index);
|
|
1263
|
-
const choices = own ? [{ marker: own, cost: 0, text: pieces[index] }] : [];
|
|
1264
|
-
if (change !== Infinity)
|
|
1265
|
-
for (const choice of markers) {
|
|
1266
|
-
if (choice === own)
|
|
1267
|
-
continue;
|
|
1268
|
-
const text = this.relabelList(pieces[index], choice);
|
|
1269
|
-
if (text !== undefined)
|
|
1270
|
-
choices.push({ marker: choice, cost: change, text });
|
|
1271
|
-
}
|
|
1272
|
-
return choices;
|
|
1273
|
-
});
|
|
1274
|
-
// Fewest and cheapest changes, with adjacent markers always different.
|
|
1275
|
-
const best = options.map((choices) => choices.map(() => Infinity));
|
|
1276
|
-
const from = options.map((choices) => choices.map(() => -1));
|
|
1277
|
-
options[0].forEach((choice, j) => (best[0][j] = choice.cost));
|
|
1278
|
-
for (let i = 1; i < options.length; i++)
|
|
1279
|
-
options[i].forEach((choice, j) => {
|
|
1280
|
-
options[i - 1].forEach((previous, k) => {
|
|
1281
|
-
const total = best[i - 1][k] + choice.cost;
|
|
1282
|
-
if (previous.marker !== choice.marker && total < best[i][j]) {
|
|
1283
|
-
best[i][j] = total;
|
|
1284
|
-
from[i][j] = k;
|
|
1285
|
-
}
|
|
1286
|
-
});
|
|
1287
|
-
});
|
|
1288
|
-
const last = best.at(-1);
|
|
1289
|
-
let j = last.indexOf(Math.min(...last));
|
|
1290
|
-
if (j < 0 || last[j] === Infinity)
|
|
1291
|
-
continue;
|
|
1292
|
-
for (let i = options.length - 1; i >= 0; j = from[i][j], i--)
|
|
1293
|
-
pieces[run[i]] = options[i][j].text;
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
relabelList(text, marker) {
|
|
1297
|
-
const processor = this.ctx.get(remarkCtx);
|
|
1298
|
-
const read = (text) => processor.parse(text).children;
|
|
1299
|
-
const [list, ...rest] = read(text);
|
|
1300
|
-
if (list?.type !== 'list' || rest.length)
|
|
1301
|
-
return undefined;
|
|
1302
|
-
const relabelled = relabel(text, list.children.map((item) => item.position.start.offset), marker);
|
|
1303
|
-
const reread = read(relabelled);
|
|
1304
|
-
// A bullet can turn an item into a thematic break, such as `* * *`.
|
|
1305
|
-
return reread.length === 1 &&
|
|
1306
|
-
signature(reread[0]) === signature(list) &&
|
|
1307
|
-
markerAt(relabelled, list.children[0].position.start.offset)
|
|
1308
|
-
?.marker === marker
|
|
1309
|
-
? relabelled
|
|
1310
|
-
: undefined;
|
|
1311
|
-
}
|
|
1312
|
-
serialize(doc) {
|
|
1313
|
-
if (doc.eq(this.baseline))
|
|
1314
|
-
return this.source;
|
|
1315
|
-
try {
|
|
1316
|
-
const local = this.localEdits(doc);
|
|
1317
|
-
if (local !== undefined)
|
|
1318
|
-
return local;
|
|
1319
|
-
const canonical = serialize(this.ctx, doc);
|
|
1320
|
-
if (canonical === '')
|
|
1321
|
-
return this.source.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
1322
|
-
const current = [];
|
|
1323
|
-
doc.forEach((node) => current.push(this.formOf(node)));
|
|
1324
|
-
const forms = this.blocks.map((block) => block.form);
|
|
1325
|
-
const processor = this.ctx.get(remarkCtx);
|
|
1326
|
-
const currentRawTree = processor.parse(canonical);
|
|
1327
|
-
const currentTree = processor.runSync(structuredClone(currentRawTree), {
|
|
1328
|
-
value: canonical,
|
|
1329
|
-
});
|
|
1330
|
-
const parse = this.ctx.get(parserCtx);
|
|
1331
|
-
const normalized = (text) => text.replace(/\r\n?/g, '\n');
|
|
1332
|
-
const positions = correspondence(forms, current);
|
|
1333
|
-
const gaps = [];
|
|
1334
|
-
const pieces = [];
|
|
1335
|
-
const kept = [];
|
|
1336
|
-
let previous;
|
|
1337
|
-
current.forEach((text, index) => {
|
|
1338
|
-
const position = positions[index];
|
|
1339
|
-
const block = position == null ? undefined : this.blocks[position];
|
|
1340
|
-
if (index === 0) {
|
|
1341
|
-
if (position === 0 && block)
|
|
1342
|
-
gaps.push(this.source.slice(0, block.start));
|
|
1343
|
-
else
|
|
1344
|
-
gaps.push(this.source.startsWith('\uFEFF') ? '\uFEFF' : '');
|
|
1345
|
-
}
|
|
1346
|
-
else if (previous != null && position === previous + 1 && block) {
|
|
1347
|
-
gaps.push(this.source.slice(this.blocks[previous].end, block.start));
|
|
1348
|
-
}
|
|
1349
|
-
else
|
|
1350
|
-
gaps.push(this.lineEnding.repeat(2));
|
|
1351
|
-
const kind = block && block.form === text
|
|
1352
|
-
? 'unchanged'
|
|
1353
|
-
: block && currentTree.children.length === current.length
|
|
1354
|
-
? 'edited'
|
|
1355
|
-
: 'canonical';
|
|
1356
|
-
kept.push(kind);
|
|
1357
|
-
pieces.push(kind === 'unchanged'
|
|
1358
|
-
? this.source.slice(block.start, block.end)
|
|
1359
|
-
: kind === 'edited'
|
|
1360
|
-
? this.retainBlock(block, positionalNode(currentRawTree, currentTree.children[index]), canonical)
|
|
1361
|
-
: restoreCodeEndings(this.ctx, doc.type.create(null, [doc.child(index)]), withEnding(text, this.lineEnding)));
|
|
1362
|
-
previous = position;
|
|
1363
|
-
});
|
|
1364
|
-
this.separateLists(doc, pieces, kept, positions, current);
|
|
1365
|
-
let output = pieces.map((piece, index) => gaps[index] + piece).join('');
|
|
1366
|
-
if (previous === this.blocks.length - 1 && this.blocks.length)
|
|
1367
|
-
output += this.source.slice(this.blocks[previous].end);
|
|
1368
|
-
else if (output)
|
|
1369
|
-
output += finalCodeEnding(doc) ?? this.lineEnding;
|
|
1370
|
-
// A `---` rule that opens the file reads back as front matter when
|
|
1371
|
-
// another `---` line follows, whether it is new or moved there.
|
|
1372
|
-
const bom = output.startsWith('\uFEFF') ? 1 : 0;
|
|
1373
|
-
if (doc.firstChild?.type.name === 'hr' &&
|
|
1374
|
-
sourceFrontmatter(output.slice(bom))?.startsWith('---'))
|
|
1375
|
-
output = output.slice(0, bom) + '***' + output.slice(bom + 3);
|
|
1376
|
-
const reopened = parse(output);
|
|
1377
|
-
if (semanticSignature(reopened) === semanticSignature(doc)) {
|
|
1378
|
-
const actual = normalized(serialize(this.ctx, reopened));
|
|
1379
|
-
if (actual === normalized(canonical))
|
|
1380
|
-
return output;
|
|
1381
|
-
// Parsing may normalize loose-list spacing. Compare both reconstructed
|
|
1382
|
-
// source and intended canonical source after that same normalization.
|
|
1383
|
-
if (actual === normalized(serialize(this.ctx, parse(canonical))))
|
|
1384
|
-
return output;
|
|
1385
|
-
}
|
|
1386
|
-
throw new PreservationError('The edited Markdown cannot be reopened without changing its content');
|
|
1387
|
-
}
|
|
1388
|
-
catch (cause) {
|
|
1389
|
-
if (cause instanceof PreservationError)
|
|
1390
|
-
throw cause;
|
|
1391
|
-
throw new PreservationError('Cannot preserve the edited Markdown', {
|
|
1392
|
-
cause,
|
|
1393
|
-
});
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
}
|