@aicayzer/inkkit 0.0.17 → 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 +100 -12
- 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 +20 -3
- package/dist/preserve.js +793 -154
- 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,15 +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 { sourceFrontmatter } from './literals.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.
|
|
5
8
|
function signature(node) {
|
|
6
|
-
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
|
+
});
|
|
7
25
|
}
|
|
8
26
|
function ownShape(node) {
|
|
9
27
|
return JSON.stringify(node, (key, value) => ['position', 'data', 'children', 'value', 'marker'].includes(key)
|
|
10
28
|
? undefined
|
|
11
29
|
: value);
|
|
12
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
|
+
}
|
|
13
91
|
function retainText(raw, before, after, quoteIndent = false, inTable = false) {
|
|
14
92
|
before = before.replace(/\r\n?/g, '\n');
|
|
15
93
|
after = after.replace(/\r\n?/g, '\n');
|
|
@@ -108,8 +186,90 @@ function retainBoundaryWhitespace(raw, canonical) {
|
|
|
108
186
|
raw = raw.slice(0, -end.length) + encode(end);
|
|
109
187
|
return raw;
|
|
110
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
|
+
}
|
|
111
269
|
// Patch changed leaves so an adjacent edit retains authored delimiters and labels.
|
|
112
|
-
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) {
|
|
113
273
|
const start = before.position?.start.offset;
|
|
114
274
|
const end = before.position?.end.offset;
|
|
115
275
|
const newStart = after.position?.start.offset;
|
|
@@ -120,16 +280,25 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
|
|
|
120
280
|
if (before.type === 'code' &&
|
|
121
281
|
after.type === 'code' &&
|
|
122
282
|
before.lang === after.lang) {
|
|
283
|
+
if ((before.value ?? '') === (after.value ?? ''))
|
|
284
|
+
return raw;
|
|
123
285
|
const opening = /^([ \t>]*)(`{3,}|~{3,})([^\r\n]*)(\r?\n)/.exec(raw);
|
|
124
286
|
if (opening) {
|
|
125
287
|
const lines = raw.slice(opening[0].length).split(/\r?\n/);
|
|
126
288
|
const closing = /^([ \t>]*)(`{3,}|~{3,})([ \t]*)$/.exec(lines.at(-1) ?? '');
|
|
127
289
|
const originalLine = (before.value ?? '').replace(/\r\n?/g, '\n').split('\n')[0] ?? '';
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
+
: [];
|
|
133
302
|
const character = opening[2][0];
|
|
134
303
|
let length = opening[2].length;
|
|
135
304
|
for (const line of body) {
|
|
@@ -141,8 +310,8 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
|
|
|
141
310
|
return (opening[1] +
|
|
142
311
|
fence +
|
|
143
312
|
opening[3] +
|
|
144
|
-
lineEnding +
|
|
145
|
-
body.
|
|
313
|
+
body.map((line) => lineEnding + gutter + line).join('') +
|
|
314
|
+
(closing || body.length ? '' : opening[4]) +
|
|
146
315
|
(closing
|
|
147
316
|
? lineEnding +
|
|
148
317
|
closing[1] +
|
|
@@ -163,33 +332,92 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
|
|
|
163
332
|
: String(after.url)) +
|
|
164
333
|
destination[3]);
|
|
165
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
|
+
}
|
|
166
377
|
if (before.type === 'text' && after.type === 'text')
|
|
167
378
|
return retainBoundaryWhitespace(withEnding(retainText(raw, before.value, after.value, false, inTable), lineEnding), canonical.slice(newStart, newEnd));
|
|
168
379
|
if (signature(before) === signature(after) &&
|
|
169
380
|
ownShape(before) === ownShape(after))
|
|
170
381
|
return raw;
|
|
382
|
+
const marker = sameList(before, after)
|
|
383
|
+
? authoredMarker(before, source)
|
|
384
|
+
: undefined;
|
|
171
385
|
if (before.type === after.type &&
|
|
172
386
|
ownShape(before) === ownShape(after) &&
|
|
173
387
|
before.children &&
|
|
174
388
|
after.children &&
|
|
175
389
|
before.children.length === after.children.length) {
|
|
176
|
-
let output = '',
|
|
177
|
-
|
|
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
|
+
: []);
|
|
178
400
|
before.children.forEach((child, index) => {
|
|
179
401
|
const incoming = after.children[index];
|
|
180
|
-
const movedIndex =
|
|
181
|
-
?
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
402
|
+
const movedIndex = !reorderable
|
|
403
|
+
? -1
|
|
404
|
+
: same(child, incoming)
|
|
405
|
+
? index
|
|
406
|
+
: before.children.findIndex((candidate, candidateIndex) => !retained.has(candidateIndex) && same(candidate, incoming));
|
|
185
407
|
const original = movedIndex < 0 ? child : before.children[movedIndex];
|
|
186
408
|
if (movedIndex >= 0)
|
|
187
409
|
retained.add(movedIndex);
|
|
188
410
|
const childStart = child.position?.start.offset, childEnd = child.position?.end.offset;
|
|
189
411
|
if (childStart == null || childEnd == null)
|
|
190
412
|
throw new PreservationError('A source token has no location');
|
|
191
|
-
let retainedSource =
|
|
192
|
-
|
|
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) {
|
|
193
421
|
// Cell positions own different pipe boundaries at the row edges.
|
|
194
422
|
const originalFrom = original.children?.[0]?.position?.start.offset;
|
|
195
423
|
const originalTo = original.children?.at(-1)?.position?.end.offset;
|
|
@@ -205,10 +433,19 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
|
|
|
205
433
|
source.slice(to, childEnd)
|
|
206
434
|
: withEnding(canonical.slice(incoming.position.start.offset, incoming.position.end.offset), lineEnding);
|
|
207
435
|
}
|
|
208
|
-
|
|
209
|
-
|
|
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;
|
|
210
447
|
});
|
|
211
|
-
return output + source.slice(
|
|
448
|
+
return output + source.slice(at, end);
|
|
212
449
|
}
|
|
213
450
|
if (before.type === after.type &&
|
|
214
451
|
ownShape(before) === ownShape(after) &&
|
|
@@ -224,24 +461,81 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
|
|
|
224
461
|
signature(before.children[before.children.length - 1 - suffix]) ===
|
|
225
462
|
signature(after.children[after.children.length - 1 - suffix]))
|
|
226
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;
|
|
227
473
|
const from = prefix
|
|
228
474
|
? before.children[prefix - 1].position?.end.offset
|
|
229
|
-
:
|
|
475
|
+
: first;
|
|
230
476
|
const to = suffix
|
|
231
477
|
? before.children[before.children.length - suffix].position?.start.offset
|
|
232
|
-
:
|
|
478
|
+
: last;
|
|
233
479
|
const newFrom = prefix
|
|
234
480
|
? after.children[prefix - 1].position?.end.offset
|
|
235
|
-
:
|
|
481
|
+
: newFirst;
|
|
236
482
|
const newTo = suffix
|
|
237
483
|
? after.children[after.children.length - suffix].position?.start.offset
|
|
238
|
-
:
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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));
|
|
243
498
|
}
|
|
244
|
-
|
|
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;
|
|
245
539
|
}
|
|
246
540
|
// Canonical Markdown keeps authored CRLF inside literal blocks and code fences.
|
|
247
541
|
function withEnding(text, lineEnding) {
|
|
@@ -267,7 +561,7 @@ function blockForm(ctx, node) {
|
|
|
267
561
|
return '<br />';
|
|
268
562
|
return form(serialize(ctx, node.type.schema.topNodeType.create(null, [node])));
|
|
269
563
|
}
|
|
270
|
-
function semanticSignature(doc) {
|
|
564
|
+
export function semanticSignature(doc) {
|
|
271
565
|
const clean = (value) => {
|
|
272
566
|
if (value.type === 'inkkit_wiki_link' && value.attrs) {
|
|
273
567
|
const { raw: _raw, inTable: _inTable, ...attrs } = value.attrs;
|
|
@@ -306,7 +600,6 @@ function semanticSignature(doc) {
|
|
|
306
600
|
[
|
|
307
601
|
'referenceContent',
|
|
308
602
|
'referenceType',
|
|
309
|
-
'listPlaceholder',
|
|
310
603
|
'marker',
|
|
311
604
|
'id',
|
|
312
605
|
'label',
|
|
@@ -330,12 +623,9 @@ const MATCHING_BUDGET = 1_000_000;
|
|
|
330
623
|
const SIMILARITY_BUDGET = 4_000_000;
|
|
331
624
|
// Unchanged blocks are anchored first, so a long document with many scattered
|
|
332
625
|
// edits is matched as many short gaps rather than one wide span.
|
|
333
|
-
export function correspondence(before, after
|
|
626
|
+
export function correspondence(before, after) {
|
|
334
627
|
const result = new Array(after.length);
|
|
335
|
-
const work = {
|
|
336
|
-
budget: MATCHING_BUDGET,
|
|
337
|
-
similarity: resemblance ? SIMILARITY_BUDGET : 0,
|
|
338
|
-
};
|
|
628
|
+
const work = { budget: MATCHING_BUDGET, similarity: SIMILARITY_BUDGET };
|
|
339
629
|
const spans = [{ b: 0, a: 0, bEnd: before.length, aEnd: after.length }];
|
|
340
630
|
for (let span = spans.pop(); span; span = spans.pop()) {
|
|
341
631
|
let { b, a, bEnd, aEnd } = span;
|
|
@@ -537,7 +827,12 @@ export class Preservation {
|
|
|
537
827
|
lineEnding;
|
|
538
828
|
originalForms = new Set();
|
|
539
829
|
forms = new WeakMap();
|
|
540
|
-
|
|
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;
|
|
541
836
|
constructor(ctx, source) {
|
|
542
837
|
this.ctx = ctx;
|
|
543
838
|
this.source = source;
|
|
@@ -580,6 +875,19 @@ export class Preservation {
|
|
|
580
875
|
}
|
|
581
876
|
for (const block of this.blocks)
|
|
582
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
|
+
});
|
|
583
891
|
}
|
|
584
892
|
catch (cause) {
|
|
585
893
|
if (cause instanceof PreservationError)
|
|
@@ -597,85 +905,423 @@ export class Preservation {
|
|
|
597
905
|
return (this.source.slice(block.start, start + offset) +
|
|
598
906
|
retainSource(block.ast, after, this.source.slice(offset), canonical, this.lineEnding));
|
|
599
907
|
}
|
|
600
|
-
|
|
908
|
+
localEdits(doc) {
|
|
601
909
|
if (this.baseline.childCount !== this.blocks.length ||
|
|
602
910
|
doc.childCount !== this.blocks.length ||
|
|
603
911
|
!doc.sameMarkup(this.baseline))
|
|
604
912
|
return undefined;
|
|
605
913
|
let output = '', previous = 0;
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
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 = [];
|
|
611
921
|
continue;
|
|
612
|
-
|
|
922
|
+
}
|
|
923
|
+
run.push(index);
|
|
924
|
+
const block = this.blocks[index];
|
|
925
|
+
let retained = this.blockEdits.get(node);
|
|
613
926
|
if (retained?.index !== index) {
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
// References and HTML/comments can depend on other blocks. Restrict
|
|
617
|
-
// local validation to text blocks without these context-sensitive forms.
|
|
618
|
-
const contextual = /[\[<]|%%/;
|
|
619
|
-
const textOnly = (block) => {
|
|
620
|
-
let valid = true;
|
|
621
|
-
block.forEach((child) => {
|
|
622
|
-
if (!child.isText)
|
|
623
|
-
valid = false;
|
|
624
|
-
});
|
|
625
|
-
return valid;
|
|
626
|
-
};
|
|
627
|
-
if (!['paragraph', 'heading'].includes(node.type.name) ||
|
|
628
|
-
!expected.sameMarkup(withoutHeadingId(before)) ||
|
|
629
|
-
!textOnly(node) ||
|
|
630
|
-
!textOnly(before) ||
|
|
631
|
-
contextual.test(raw) ||
|
|
632
|
-
(index > 0 &&
|
|
633
|
-
!/\r?\n[ \t]*\r?\n/.test(this.source.slice(this.blocks[index - 1].end, block.start))) ||
|
|
634
|
-
(index + 1 < this.blocks.length &&
|
|
635
|
-
!/\r?\n[ \t]*\r?\n/.test(this.source.slice(block.end, this.blocks[index + 1].start))))
|
|
636
|
-
return undefined;
|
|
637
|
-
const canonical = blockForm(this.ctx, node);
|
|
638
|
-
if (contextual.test(canonical) ||
|
|
639
|
-
(canonical !== block.form && this.originalForms.has(canonical)))
|
|
640
|
-
return undefined;
|
|
641
|
-
const tree = this.ctx.get(remarkCtx).parse(canonical);
|
|
642
|
-
if (tree.children.length !== 1)
|
|
643
|
-
return undefined;
|
|
644
|
-
const text = this.retainBlock(block, tree.children[0], canonical);
|
|
645
|
-
const reopened = this.ctx.get(parserCtx)(text);
|
|
646
|
-
if (reopened.childCount !== 1)
|
|
647
|
-
return undefined;
|
|
648
|
-
if (!withoutHeadingId(reopened.firstChild).eq(expected))
|
|
927
|
+
const text = this.localBlock(index, node);
|
|
928
|
+
if (text === undefined)
|
|
649
929
|
return undefined;
|
|
650
930
|
retained = { index, text };
|
|
651
|
-
this.
|
|
931
|
+
this.blockEdits.set(node, retained);
|
|
652
932
|
}
|
|
653
933
|
output += this.source.slice(previous, block.start) + retained.text;
|
|
654
934
|
previous = block.end;
|
|
655
935
|
}
|
|
656
|
-
|
|
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;
|
|
657
1311
|
}
|
|
658
1312
|
serialize(doc) {
|
|
659
1313
|
if (doc.eq(this.baseline))
|
|
660
1314
|
return this.source;
|
|
661
1315
|
try {
|
|
662
|
-
const
|
|
663
|
-
if (
|
|
664
|
-
return
|
|
1316
|
+
const local = this.localEdits(doc);
|
|
1317
|
+
if (local !== undefined)
|
|
1318
|
+
return local;
|
|
665
1319
|
const canonical = serialize(this.ctx, doc);
|
|
666
1320
|
if (canonical === '')
|
|
667
1321
|
return this.source.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
668
1322
|
const current = [];
|
|
669
|
-
doc.forEach((node) =>
|
|
670
|
-
let fingerprint = this.forms.get(node);
|
|
671
|
-
if (fingerprint == null) {
|
|
672
|
-
fingerprint = blockForm(this.ctx, node);
|
|
673
|
-
this.forms.set(node, fingerprint);
|
|
674
|
-
}
|
|
675
|
-
current.push(fingerprint);
|
|
676
|
-
});
|
|
1323
|
+
doc.forEach((node) => current.push(this.formOf(node)));
|
|
677
1324
|
const forms = this.blocks.map((block) => block.form);
|
|
678
|
-
const resembling = correspondence(forms, current);
|
|
679
1325
|
const processor = this.ctx.get(remarkCtx);
|
|
680
1326
|
const currentRawTree = processor.parse(canonical);
|
|
681
1327
|
const currentTree = processor.runSync(structuredClone(currentRawTree), {
|
|
@@ -683,67 +1329,60 @@ export class Preservation {
|
|
|
683
1329
|
});
|
|
684
1330
|
const parse = this.ctx.get(parserCtx);
|
|
685
1331
|
const normalized = (text) => text.replace(/\r\n?/g, '\n');
|
|
686
|
-
const
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
}
|
|
698
|
-
else if (previous != null && position === previous + 1 && block) {
|
|
699
|
-
output += this.source.slice(this.blocks[previous].end, block.start);
|
|
700
|
-
}
|
|
701
|
-
else
|
|
702
|
-
output += this.lineEnding.repeat(2);
|
|
703
|
-
if (block && block.form === text)
|
|
704
|
-
output += this.source.slice(block.start, block.end);
|
|
705
|
-
else if (block && currentTree.children.length === current.length) {
|
|
706
|
-
output += this.retainBlock(block, positionalNode(currentRawTree, currentTree.children[index]), canonical);
|
|
707
|
-
}
|
|
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));
|
|
708
1343
|
else
|
|
709
|
-
|
|
710
|
-
previous = position;
|
|
711
|
-
});
|
|
712
|
-
if (previous === this.blocks.length - 1 && this.blocks.length)
|
|
713
|
-
output += this.source.slice(this.blocks[previous].end);
|
|
714
|
-
else if (output)
|
|
715
|
-
output += finalCodeEnding(doc) ?? this.lineEnding;
|
|
716
|
-
// A `---` rule that opens the file reads back as front matter when
|
|
717
|
-
// another `---` line follows, whether it is new or moved there.
|
|
718
|
-
const bom = output.startsWith('\uFEFF') ? 1 : 0;
|
|
719
|
-
if (doc.firstChild?.type.name === 'hr' &&
|
|
720
|
-
sourceFrontmatter(output.slice(bom))?.startsWith('---'))
|
|
721
|
-
output = output.slice(0, bom) + '***' + output.slice(bom + 3);
|
|
722
|
-
const reopened = parse(output);
|
|
723
|
-
if (semanticSignature(reopened) === semanticSignature(doc)) {
|
|
724
|
-
const actual = normalized(serialize(this.ctx, reopened));
|
|
725
|
-
if (actual === normalized(canonical))
|
|
726
|
-
return output;
|
|
727
|
-
// Parsing may normalize loose-list spacing. Compare both reconstructed
|
|
728
|
-
// source and intended canonical source after that same normalization.
|
|
729
|
-
if (actual === normalized(serialize(this.ctx, parse(canonical))))
|
|
730
|
-
return output;
|
|
1344
|
+
gaps.push(this.source.startsWith('\uFEFF') ? '\uFEFF' : '');
|
|
731
1345
|
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
+
}
|
|
747
1386
|
throw new PreservationError('The edited Markdown cannot be reopened without changing its content');
|
|
748
1387
|
}
|
|
749
1388
|
catch (cause) {
|