@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/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) => ['position', 'data', 'marker'].includes(key) ? undefined : 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
- const first = lines[0] ?? '';
128
- const gutter = first.endsWith(originalLine)
129
- ? first.slice(0, first.length - originalLine.length)
130
- : opening[1];
131
- const body = (after.value ?? '').replace(/\r\n?/g, '\n').split('\n');
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.map((line) => gutter + line).join(lineEnding) +
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 = '', previous = start;
176
- const retained = new Set();
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 = before.type === 'table' || before.type === 'tableRow'
180
- ? before.children.findIndex((candidate, candidateIndex) => !retained.has(candidateIndex) &&
181
- signature(candidate) === signature(incoming) &&
182
- ownShape(candidate) === ownShape(incoming))
183
- : -1;
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 = retainSource(original, incoming, source, canonical, lineEnding, inTable || before.type === 'tableCell');
191
- if (before.type === 'tableRow' && movedIndex >= 0) {
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
- output += source.slice(previous, childStart) + retainedSource;
210
- previous = childEnd;
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(previous, end);
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
- : start;
475
+ : first;
231
476
  const to = suffix
232
477
  ? before.children[before.children.length - suffix].position?.start.offset
233
- : end;
478
+ : last;
234
479
  const newFrom = prefix
235
480
  ? after.children[prefix - 1].position?.end.offset
236
- : newStart;
481
+ : newFirst;
237
482
  const newTo = suffix
238
483
  ? after.children[after.children.length - suffix].position?.start.offset
239
- : newEnd;
240
- if (from != null && to != null && newFrom != null && newTo != null)
241
- return retainBoundaryWhitespace(source.slice(start, from) +
242
- canonical.slice(newFrom, newTo).replaceAll('\n', lineEnding) +
243
- source.slice(to, end), canonical.slice(newStart, newEnd));
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
- return canonical.slice(newStart, newEnd).replaceAll('\n', lineEnding);
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
- // Edit distance distinguishes replacements from insertions, so replacing a block
405
- // retains its surrounding whitespace without assigning it a neighbour's source.
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
- let width = Math.max(1, Math.abs(rows - columns));
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
- ? width + 1
414
- : (costs[i]?.[j - Math.max(0, i - width)] ?? width + 1);
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 : 1;
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) + 1, get(i, j - 1) + 1, get(i - 1, j - 1) + change);
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 with at most width edits cannot leave this diagonal band.
434
- // Widen only when needed, charging every attempt to the same work budget.
435
- if (get(rows - 1, columns - 1) > width) {
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 change = i && j && before[b + i - 1] === after[a + j - 1] ? 0 : 1;
443
- if (i && j && get(i, j) === get(i - 1, j - 1) + change) {
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) + 1)
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
- textBlockEdits = new WeakMap();
830
+ blockEdits = new WeakMap();
831
+ itemEdits = new WeakMap();
832
+ definitionNodes = new Map();
833
+ // Undefined when a definition sits inside another block, where appending
834
+ // the top-level ones would not reproduce the document.
835
+ definitionSources;
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
- independentTextBlocks(doc) {
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
- for (let index = 0; index < doc.childCount; index++) {
532
- const node = doc.child(index);
533
- const before = this.baseline.child(index);
534
- const block = this.blocks[index];
535
- if (node.eq(before))
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
- let retained = this.textBlockEdits.get(node);
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 raw = this.source.slice(block.start, block.end);
540
- const expected = withoutHeadingId(node);
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.textBlockEdits.set(node, retained);
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
- return output + this.source.slice(previous);
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 independent = this.independentTextBlocks(doc);
588
- if (independent !== undefined)
589
- return independent;
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
- let fingerprint = this.forms.get(node);
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
- let output = '';
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
- output += this.source.slice(0, block.start);
616
- else if (this.source.startsWith('\uFEFF'))
617
- output += '\uFEFF';
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
- output += this.source.slice(this.blocks[previous].end, block.start);
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
- output += restoreCodeEndings(this.ctx, doc.type.create(null, [doc.child(index)]), text.replaceAll('\n', this.lineEnding));
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
- const parse = this.ctx.get(parserCtx);
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))