@aicayzer/inkkit 0.0.16 → 0.0.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/README.md +2 -0
- package/dist/callouts.d.ts +0 -1
- package/dist/callouts.js +12 -35
- package/dist/code-fences.js +6 -1
- package/dist/comments.d.ts +21 -0
- package/dist/comments.js +55 -4
- package/dist/core.d.ts +7 -0
- package/dist/core.js +2 -0
- package/dist/cursor.d.ts +2 -0
- package/dist/cursor.js +40 -0
- package/dist/dialect.js +109 -11
- package/dist/drop-placement.d.ts +19 -0
- package/dist/drop-placement.js +47 -0
- package/dist/editor.d.ts +32 -1
- package/dist/editor.js +293 -61
- package/dist/html-import.d.ts +13 -0
- package/dist/html-import.js +477 -0
- package/dist/image-blocks.d.ts +15 -0
- package/dist/image-blocks.js +44 -1
- package/dist/index.d.ts +1 -7
- package/dist/index.js +3 -2
- package/dist/link-safety.d.ts +17 -0
- package/dist/link-safety.js +29 -0
- package/dist/literals.d.ts +1 -0
- package/dist/literals.js +14 -9
- package/dist/mermaid.d.ts +21 -1
- package/dist/mermaid.js +92 -9
- package/dist/paste-html.d.ts +8 -0
- package/dist/paste-html.js +21 -0
- package/dist/paste.d.ts +7 -0
- package/dist/paste.js +152 -43
- package/dist/preserve.d.ts +19 -2
- package/dist/preserve.js +861 -124
- package/dist/print.js +2 -5
- package/dist/references.d.ts +2 -0
- package/dist/references.js +63 -13
- package/dist/scroll.d.ts +15 -0
- package/dist/scroll.js +27 -0
- package/dist/spreadsheet.d.ts +6 -1
- package/dist/spreadsheet.js +29 -15
- package/dist/style.css +11 -19
- package/dist/tables.js +24 -2
- package/package.json +12 -4
package/dist/preserve.js
CHANGED
|
@@ -1,14 +1,93 @@
|
|
|
1
1
|
import { parserCtx, remarkCtx } from '@milkdown/kit/core';
|
|
2
2
|
import { serialize } from './dialect.js';
|
|
3
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.
|
|
4
8
|
function signature(node) {
|
|
5
|
-
return JSON.stringify(node, (key, value)
|
|
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
|
+
});
|
|
6
25
|
}
|
|
7
26
|
function ownShape(node) {
|
|
8
27
|
return JSON.stringify(node, (key, value) => ['position', 'data', 'children', 'value', 'marker'].includes(key)
|
|
9
28
|
? undefined
|
|
10
29
|
: value);
|
|
11
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
|
+
}
|
|
12
91
|
function retainText(raw, before, after, quoteIndent = false, inTable = false) {
|
|
13
92
|
before = before.replace(/\r\n?/g, '\n');
|
|
14
93
|
after = after.replace(/\r\n?/g, '\n');
|
|
@@ -107,8 +186,90 @@ function retainBoundaryWhitespace(raw, canonical) {
|
|
|
107
186
|
raw = raw.slice(0, -end.length) + encode(end);
|
|
108
187
|
return raw;
|
|
109
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
|
+
}
|
|
110
269
|
// Patch changed leaves so an adjacent edit retains authored delimiters and labels.
|
|
111
|
-
function retainSource(before, after, source, canonical, lineEnding, inTable = false
|
|
270
|
+
function retainSource(before, after, source, canonical, lineEnding, inTable = false,
|
|
271
|
+
// The header's cell count, for the rows of a table.
|
|
272
|
+
width) {
|
|
112
273
|
const start = before.position?.start.offset;
|
|
113
274
|
const end = before.position?.end.offset;
|
|
114
275
|
const newStart = after.position?.start.offset;
|
|
@@ -119,16 +280,25 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
|
|
|
119
280
|
if (before.type === 'code' &&
|
|
120
281
|
after.type === 'code' &&
|
|
121
282
|
before.lang === after.lang) {
|
|
283
|
+
if ((before.value ?? '') === (after.value ?? ''))
|
|
284
|
+
return raw;
|
|
122
285
|
const opening = /^([ \t>]*)(`{3,}|~{3,})([^\r\n]*)(\r?\n)/.exec(raw);
|
|
123
286
|
if (opening) {
|
|
124
287
|
const lines = raw.slice(opening[0].length).split(/\r?\n/);
|
|
125
288
|
const closing = /^([ \t>]*)(`{3,}|~{3,})([ \t]*)$/.exec(lines.at(-1) ?? '');
|
|
126
289
|
const originalLine = (before.value ?? '').replace(/\r\n?/g, '\n').split('\n')[0] ?? '';
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
: [];
|
|
132
302
|
const character = opening[2][0];
|
|
133
303
|
let length = opening[2].length;
|
|
134
304
|
for (const line of body) {
|
|
@@ -140,8 +310,8 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
|
|
|
140
310
|
return (opening[1] +
|
|
141
311
|
fence +
|
|
142
312
|
opening[3] +
|
|
143
|
-
lineEnding +
|
|
144
|
-
body.
|
|
313
|
+
body.map((line) => lineEnding + gutter + line).join('') +
|
|
314
|
+
(closing || body.length ? '' : opening[4]) +
|
|
145
315
|
(closing
|
|
146
316
|
? lineEnding +
|
|
147
317
|
closing[1] +
|
|
@@ -162,33 +332,92 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
|
|
|
162
332
|
: String(after.url)) +
|
|
163
333
|
destination[3]);
|
|
164
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
|
+
}
|
|
165
377
|
if (before.type === 'text' && after.type === 'text')
|
|
166
|
-
return retainBoundaryWhitespace(retainText(raw, before.value, after.value, false, inTable), canonical.slice(newStart, newEnd));
|
|
378
|
+
return retainBoundaryWhitespace(withEnding(retainText(raw, before.value, after.value, false, inTable), lineEnding), canonical.slice(newStart, newEnd));
|
|
167
379
|
if (signature(before) === signature(after) &&
|
|
168
380
|
ownShape(before) === ownShape(after))
|
|
169
381
|
return raw;
|
|
382
|
+
const marker = sameList(before, after)
|
|
383
|
+
? authoredMarker(before, source)
|
|
384
|
+
: undefined;
|
|
170
385
|
if (before.type === after.type &&
|
|
171
386
|
ownShape(before) === ownShape(after) &&
|
|
172
387
|
before.children &&
|
|
173
388
|
after.children &&
|
|
174
389
|
before.children.length === after.children.length) {
|
|
175
|
-
let output = '',
|
|
176
|
-
|
|
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
|
+
: []);
|
|
177
400
|
before.children.forEach((child, index) => {
|
|
178
401
|
const incoming = after.children[index];
|
|
179
|
-
const movedIndex =
|
|
180
|
-
?
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
402
|
+
const movedIndex = !reorderable
|
|
403
|
+
? -1
|
|
404
|
+
: same(child, incoming)
|
|
405
|
+
? index
|
|
406
|
+
: before.children.findIndex((candidate, candidateIndex) => !retained.has(candidateIndex) && same(candidate, incoming));
|
|
184
407
|
const original = movedIndex < 0 ? child : before.children[movedIndex];
|
|
185
408
|
if (movedIndex >= 0)
|
|
186
409
|
retained.add(movedIndex);
|
|
187
410
|
const childStart = child.position?.start.offset, childEnd = child.position?.end.offset;
|
|
188
411
|
if (childStart == null || childEnd == null)
|
|
189
412
|
throw new PreservationError('A source token has no location');
|
|
190
|
-
let retainedSource =
|
|
191
|
-
|
|
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) {
|
|
192
421
|
// Cell positions own different pipe boundaries at the row edges.
|
|
193
422
|
const originalFrom = original.children?.[0]?.position?.start.offset;
|
|
194
423
|
const originalTo = original.children?.at(-1)?.position?.end.offset;
|
|
@@ -202,14 +431,21 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
|
|
|
202
431
|
? source.slice(childStart, from) +
|
|
203
432
|
source.slice(originalFrom, originalTo) +
|
|
204
433
|
source.slice(to, childEnd)
|
|
205
|
-
: canonical
|
|
206
|
-
.slice(incoming.position.start.offset, incoming.position.end.offset)
|
|
207
|
-
.replaceAll('\n', lineEnding);
|
|
434
|
+
: withEnding(canonical.slice(incoming.position.start.offset, incoming.position.end.offset), lineEnding);
|
|
208
435
|
}
|
|
209
|
-
|
|
210
|
-
|
|
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;
|
|
211
447
|
});
|
|
212
|
-
return output + source.slice(
|
|
448
|
+
return output + source.slice(at, end);
|
|
213
449
|
}
|
|
214
450
|
if (before.type === after.type &&
|
|
215
451
|
ownShape(before) === ownShape(after) &&
|
|
@@ -225,24 +461,85 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
|
|
|
225
461
|
signature(before.children[before.children.length - 1 - suffix]) ===
|
|
226
462
|
signature(after.children[after.children.length - 1 - suffix]))
|
|
227
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;
|
|
228
473
|
const from = prefix
|
|
229
474
|
? before.children[prefix - 1].position?.end.offset
|
|
230
|
-
:
|
|
475
|
+
: first;
|
|
231
476
|
const to = suffix
|
|
232
477
|
? before.children[before.children.length - suffix].position?.start.offset
|
|
233
|
-
:
|
|
478
|
+
: last;
|
|
234
479
|
const newFrom = prefix
|
|
235
480
|
? after.children[prefix - 1].position?.end.offset
|
|
236
|
-
:
|
|
481
|
+
: newFirst;
|
|
237
482
|
const newTo = suffix
|
|
238
483
|
? after.children[after.children.length - suffix].position?.start.offset
|
|
239
|
-
:
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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));
|
|
244
498
|
}
|
|
245
|
-
|
|
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);
|
|
246
543
|
}
|
|
247
544
|
function positionalNode(tree, transformed) {
|
|
248
545
|
return (tree.children.find((node) => node.position?.start.offset === transformed.position?.start.offset &&
|
|
@@ -264,7 +561,7 @@ function blockForm(ctx, node) {
|
|
|
264
561
|
return '<br />';
|
|
265
562
|
return form(serialize(ctx, node.type.schema.topNodeType.create(null, [node])));
|
|
266
563
|
}
|
|
267
|
-
function semanticSignature(doc) {
|
|
564
|
+
export function semanticSignature(doc) {
|
|
268
565
|
const clean = (value) => {
|
|
269
566
|
if (value.type === 'inkkit_wiki_link' && value.attrs) {
|
|
270
567
|
const { raw: _raw, inTable: _inTable, ...attrs } = value.attrs;
|
|
@@ -303,7 +600,6 @@ function semanticSignature(doc) {
|
|
|
303
600
|
[
|
|
304
601
|
'referenceContent',
|
|
305
602
|
'referenceType',
|
|
306
|
-
'listPlaceholder',
|
|
307
603
|
'marker',
|
|
308
604
|
'id',
|
|
309
605
|
'label',
|
|
@@ -324,11 +620,12 @@ function withoutHeadingId(node) {
|
|
|
324
620
|
: node;
|
|
325
621
|
}
|
|
326
622
|
const MATCHING_BUDGET = 1_000_000;
|
|
623
|
+
const SIMILARITY_BUDGET = 4_000_000;
|
|
327
624
|
// Unchanged blocks are anchored first, so a long document with many scattered
|
|
328
625
|
// edits is matched as many short gaps rather than one wide span.
|
|
329
626
|
export function correspondence(before, after) {
|
|
330
627
|
const result = new Array(after.length);
|
|
331
|
-
const work = { budget: MATCHING_BUDGET };
|
|
628
|
+
const work = { budget: MATCHING_BUDGET, similarity: SIMILARITY_BUDGET };
|
|
332
629
|
const spans = [{ b: 0, a: 0, bEnd: before.length, aEnd: after.length }];
|
|
333
630
|
for (let span = spans.pop(); span; span = spans.pop()) {
|
|
334
631
|
let { b, a, bEnd, aEnd } = span;
|
|
@@ -401,17 +698,21 @@ function uniqueAnchors(before, after, b, bEnd, a, aEnd) {
|
|
|
401
698
|
}
|
|
402
699
|
return anchors.reverse();
|
|
403
700
|
}
|
|
404
|
-
//
|
|
405
|
-
//
|
|
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;
|
|
406
706
|
function align(before, after, b, bEnd, a, aEnd, result, work) {
|
|
407
707
|
const rows = bEnd - b + 1;
|
|
408
708
|
const columns = aEnd - a + 1;
|
|
409
|
-
|
|
709
|
+
const offset = Math.abs(rows - columns);
|
|
710
|
+
let width = Math.max(1, offset);
|
|
410
711
|
for (;;) {
|
|
411
712
|
const costs = [];
|
|
412
713
|
const get = (i, j) => i < 0 || j < 0 || Math.abs(i - j) > width
|
|
413
|
-
?
|
|
414
|
-
: (costs[i]?.[j - Math.max(0, i - width)] ??
|
|
714
|
+
? Infinity
|
|
715
|
+
: (costs[i]?.[j - Math.max(0, i - width)] ?? Infinity);
|
|
415
716
|
for (let i = 0; i < rows; i++) {
|
|
416
717
|
const from = Math.max(0, i - width);
|
|
417
718
|
const to = Math.min(columns - 1, i + width);
|
|
@@ -421,38 +722,102 @@ function align(before, after, b, bEnd, a, aEnd, result, work) {
|
|
|
421
722
|
const row = new Uint32Array(to - from + 1);
|
|
422
723
|
costs.push(row);
|
|
423
724
|
for (let j = from; j <= to; j++) {
|
|
424
|
-
const change = before[b + i - 1] === after[a + j - 1] ? 0 :
|
|
725
|
+
const change = before[b + i - 1] === after[a + j - 1] ? 0 : REPLACE;
|
|
425
726
|
row[j - from] =
|
|
426
727
|
i === 0
|
|
427
|
-
? j
|
|
728
|
+
? j * INDEL
|
|
428
729
|
: j === 0
|
|
429
|
-
? i
|
|
430
|
-
: Math.min(get(i - 1, j) +
|
|
730
|
+
? i * INDEL
|
|
731
|
+
: Math.min(get(i - 1, j) + INDEL, get(i, j - 1) + INDEL, get(i - 1, j - 1) + change);
|
|
431
732
|
}
|
|
432
733
|
}
|
|
433
|
-
// A path
|
|
434
|
-
//
|
|
435
|
-
|
|
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)) {
|
|
436
740
|
width *= 2;
|
|
437
741
|
continue;
|
|
438
742
|
}
|
|
743
|
+
const unchanged = [];
|
|
439
744
|
let i = rows - 1;
|
|
440
745
|
let j = columns - 1;
|
|
441
746
|
while (i || j) {
|
|
442
|
-
const
|
|
443
|
-
if (i && j && get(i, j) === get(i - 1, j - 1) +
|
|
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)) {
|
|
444
749
|
result[a + j - 1] = b + i - 1;
|
|
750
|
+
if (same)
|
|
751
|
+
unchanged.push({ b: b + i - 1, a: a + j - 1 });
|
|
445
752
|
i--;
|
|
446
753
|
j--;
|
|
447
754
|
}
|
|
448
|
-
else if (i && get(i, j) === get(i - 1, j) +
|
|
755
|
+
else if (i && get(i, j) === get(i - 1, j) + INDEL)
|
|
449
756
|
i--;
|
|
450
757
|
else
|
|
451
758
|
j--;
|
|
452
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);
|
|
453
764
|
return;
|
|
454
765
|
}
|
|
455
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
|
+
}
|
|
456
821
|
/** Retains the loaded source of blocks the edit leaves alone. */
|
|
457
822
|
export class Preservation {
|
|
458
823
|
ctx;
|
|
@@ -462,7 +827,12 @@ export class Preservation {
|
|
|
462
827
|
lineEnding;
|
|
463
828
|
originalForms = new Set();
|
|
464
829
|
forms = new WeakMap();
|
|
465
|
-
|
|
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;
|
|
466
836
|
constructor(ctx, source) {
|
|
467
837
|
this.ctx = ctx;
|
|
468
838
|
this.source = source;
|
|
@@ -505,6 +875,19 @@ export class Preservation {
|
|
|
505
875
|
}
|
|
506
876
|
for (const block of this.blocks)
|
|
507
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
|
+
});
|
|
508
891
|
}
|
|
509
892
|
catch (cause) {
|
|
510
893
|
if (cause instanceof PreservationError)
|
|
@@ -522,121 +905,475 @@ export class Preservation {
|
|
|
522
905
|
return (this.source.slice(block.start, start + offset) +
|
|
523
906
|
retainSource(block.ast, after, this.source.slice(offset), canonical, this.lineEnding));
|
|
524
907
|
}
|
|
525
|
-
|
|
908
|
+
localEdits(doc) {
|
|
526
909
|
if (this.baseline.childCount !== this.blocks.length ||
|
|
527
910
|
doc.childCount !== this.blocks.length ||
|
|
528
911
|
!doc.sameMarkup(this.baseline))
|
|
529
912
|
return undefined;
|
|
530
913
|
let output = '', previous = 0;
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
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 = [];
|
|
536
921
|
continue;
|
|
537
|
-
|
|
922
|
+
}
|
|
923
|
+
run.push(index);
|
|
924
|
+
const block = this.blocks[index];
|
|
925
|
+
let retained = this.blockEdits.get(node);
|
|
538
926
|
if (retained?.index !== index) {
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
// References and HTML/comments can depend on other blocks. Restrict
|
|
542
|
-
// local validation to text blocks without these context-sensitive forms.
|
|
543
|
-
const contextual = /[\[<]|%%/;
|
|
544
|
-
const textOnly = (block) => {
|
|
545
|
-
let valid = true;
|
|
546
|
-
block.forEach((child) => {
|
|
547
|
-
if (!child.isText)
|
|
548
|
-
valid = false;
|
|
549
|
-
});
|
|
550
|
-
return valid;
|
|
551
|
-
};
|
|
552
|
-
if (!['paragraph', 'heading'].includes(node.type.name) ||
|
|
553
|
-
!expected.sameMarkup(withoutHeadingId(before)) ||
|
|
554
|
-
!textOnly(node) ||
|
|
555
|
-
!textOnly(before) ||
|
|
556
|
-
contextual.test(raw) ||
|
|
557
|
-
(index > 0 &&
|
|
558
|
-
!/\r?\n[ \t]*\r?\n/.test(this.source.slice(this.blocks[index - 1].end, block.start))) ||
|
|
559
|
-
(index + 1 < this.blocks.length &&
|
|
560
|
-
!/\r?\n[ \t]*\r?\n/.test(this.source.slice(block.end, this.blocks[index + 1].start))))
|
|
561
|
-
return undefined;
|
|
562
|
-
const canonical = blockForm(this.ctx, node);
|
|
563
|
-
if (contextual.test(canonical) ||
|
|
564
|
-
(canonical !== block.form && this.originalForms.has(canonical)))
|
|
565
|
-
return undefined;
|
|
566
|
-
const tree = this.ctx.get(remarkCtx).parse(canonical);
|
|
567
|
-
if (tree.children.length !== 1)
|
|
568
|
-
return undefined;
|
|
569
|
-
const text = this.retainBlock(block, tree.children[0], canonical);
|
|
570
|
-
const reopened = this.ctx.get(parserCtx)(text);
|
|
571
|
-
if (reopened.childCount !== 1)
|
|
572
|
-
return undefined;
|
|
573
|
-
if (!withoutHeadingId(reopened.firstChild).eq(expected))
|
|
927
|
+
const text = this.localBlock(index, node);
|
|
928
|
+
if (text === undefined)
|
|
574
929
|
return undefined;
|
|
575
930
|
retained = { index, text };
|
|
576
|
-
this.
|
|
931
|
+
this.blockEdits.set(node, retained);
|
|
577
932
|
}
|
|
578
933
|
output += this.source.slice(previous, block.start) + retained.text;
|
|
579
934
|
previous = block.end;
|
|
580
935
|
}
|
|
581
|
-
|
|
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;
|
|
582
1311
|
}
|
|
583
1312
|
serialize(doc) {
|
|
584
1313
|
if (doc.eq(this.baseline))
|
|
585
1314
|
return this.source;
|
|
586
1315
|
try {
|
|
587
|
-
const
|
|
588
|
-
if (
|
|
589
|
-
return
|
|
1316
|
+
const local = this.localEdits(doc);
|
|
1317
|
+
if (local !== undefined)
|
|
1318
|
+
return local;
|
|
590
1319
|
const canonical = serialize(this.ctx, doc);
|
|
591
1320
|
if (canonical === '')
|
|
592
1321
|
return this.source.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
593
1322
|
const current = [];
|
|
594
|
-
doc.forEach((node) =>
|
|
595
|
-
|
|
596
|
-
if (fingerprint == null) {
|
|
597
|
-
fingerprint = blockForm(this.ctx, node);
|
|
598
|
-
this.forms.set(node, fingerprint);
|
|
599
|
-
}
|
|
600
|
-
current.push(fingerprint);
|
|
601
|
-
});
|
|
602
|
-
const positions = correspondence(this.blocks.map((block) => block.form), current);
|
|
1323
|
+
doc.forEach((node) => current.push(this.formOf(node)));
|
|
1324
|
+
const forms = this.blocks.map((block) => block.form);
|
|
603
1325
|
const processor = this.ctx.get(remarkCtx);
|
|
604
1326
|
const currentRawTree = processor.parse(canonical);
|
|
605
1327
|
const currentTree = processor.runSync(structuredClone(currentRawTree), {
|
|
606
1328
|
value: canonical,
|
|
607
1329
|
});
|
|
608
|
-
|
|
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 = [];
|
|
609
1336
|
let previous;
|
|
610
1337
|
current.forEach((text, index) => {
|
|
611
1338
|
const position = positions[index];
|
|
612
1339
|
const block = position == null ? undefined : this.blocks[position];
|
|
613
1340
|
if (index === 0) {
|
|
614
1341
|
if (position === 0 && block)
|
|
615
|
-
|
|
616
|
-
else
|
|
617
|
-
|
|
1342
|
+
gaps.push(this.source.slice(0, block.start));
|
|
1343
|
+
else
|
|
1344
|
+
gaps.push(this.source.startsWith('\uFEFF') ? '\uFEFF' : '');
|
|
618
1345
|
}
|
|
619
1346
|
else if (previous != null && position === previous + 1 && block) {
|
|
620
|
-
|
|
621
|
-
}
|
|
622
|
-
else
|
|
623
|
-
output += this.lineEnding.repeat(2);
|
|
624
|
-
if (block && block.form === text)
|
|
625
|
-
output += this.source.slice(block.start, block.end);
|
|
626
|
-
else if (block && currentTree.children.length === current.length) {
|
|
627
|
-
output += this.retainBlock(block, positionalNode(currentRawTree, currentTree.children[index]), canonical);
|
|
1347
|
+
gaps.push(this.source.slice(this.blocks[previous].end, block.start));
|
|
628
1348
|
}
|
|
629
1349
|
else
|
|
630
|
-
|
|
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)));
|
|
631
1362
|
previous = position;
|
|
632
1363
|
});
|
|
1364
|
+
this.separateLists(doc, pieces, kept, positions, current);
|
|
1365
|
+
let output = pieces.map((piece, index) => gaps[index] + piece).join('');
|
|
633
1366
|
if (previous === this.blocks.length - 1 && this.blocks.length)
|
|
634
1367
|
output += this.source.slice(this.blocks[previous].end);
|
|
635
1368
|
else if (output)
|
|
636
1369
|
output += finalCodeEnding(doc) ?? this.lineEnding;
|
|
637
|
-
|
|
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);
|
|
638
1376
|
const reopened = parse(output);
|
|
639
|
-
const normalized = (text) => text.replace(/\r\n?/g, '\n');
|
|
640
1377
|
if (semanticSignature(reopened) === semanticSignature(doc)) {
|
|
641
1378
|
const actual = normalized(serialize(this.ctx, reopened));
|
|
642
1379
|
if (actual === normalized(canonical))
|