@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/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) => ['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
+ });
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
- const first = lines[0] ?? '';
129
- const gutter = first.endsWith(originalLine)
130
- ? first.slice(0, first.length - originalLine.length)
131
- : opening[1];
132
- 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
+ : [];
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.map((line) => gutter + line).join(lineEnding) +
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 = '', previous = start;
177
- 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
+ : []);
178
400
  before.children.forEach((child, index) => {
179
401
  const incoming = after.children[index];
180
- const movedIndex = before.type === 'table' || before.type === 'tableRow'
181
- ? before.children.findIndex((candidate, candidateIndex) => !retained.has(candidateIndex) &&
182
- signature(candidate) === signature(incoming) &&
183
- ownShape(candidate) === ownShape(incoming))
184
- : -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));
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 = retainSource(original, incoming, source, canonical, lineEnding, inTable || before.type === 'tableCell');
192
- 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) {
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
- output += source.slice(previous, childStart) + retainedSource;
209
- 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;
210
447
  });
211
- return output + source.slice(previous, end);
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
- : start;
475
+ : first;
230
476
  const to = suffix
231
477
  ? before.children[before.children.length - suffix].position?.start.offset
232
- : end;
478
+ : last;
233
479
  const newFrom = prefix
234
480
  ? after.children[prefix - 1].position?.end.offset
235
- : newStart;
481
+ : newFirst;
236
482
  const newTo = suffix
237
483
  ? after.children[after.children.length - suffix].position?.start.offset
238
- : newEnd;
239
- if (from != null && to != null && newFrom != null && newTo != null)
240
- return retainBoundaryWhitespace(source.slice(start, from) +
241
- withEnding(canonical.slice(newFrom, newTo), lineEnding) +
242
- 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));
243
498
  }
244
- return withEnding(canonical.slice(newStart, newEnd), 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;
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, resemblance = true) {
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
- 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;
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
- independentTextBlocks(doc) {
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
- for (let index = 0; index < doc.childCount; index++) {
607
- const node = doc.child(index);
608
- const before = this.baseline.child(index);
609
- const block = this.blocks[index];
610
- 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 = [];
611
921
  continue;
612
- let retained = this.textBlockEdits.get(node);
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 raw = this.source.slice(block.start, block.end);
615
- const expected = withoutHeadingId(node);
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.textBlockEdits.set(node, retained);
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
- 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;
657
1311
  }
658
1312
  serialize(doc) {
659
1313
  if (doc.eq(this.baseline))
660
1314
  return this.source;
661
1315
  try {
662
- const independent = this.independentTextBlocks(doc);
663
- if (independent !== undefined)
664
- return independent;
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 reconstruct = (positions) => {
687
- let output = '';
688
- let previous;
689
- current.forEach((text, index) => {
690
- const position = positions[index];
691
- const block = position == null ? undefined : this.blocks[position];
692
- if (index === 0) {
693
- if (position === 0 && block)
694
- output += this.source.slice(0, block.start);
695
- else if (this.source.startsWith('\uFEFF'))
696
- output += '\uFEFF';
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
- output += restoreCodeEndings(this.ctx, doc.type.create(null, [doc.child(index)]), withEnding(text, this.lineEnding));
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
- return undefined;
733
- };
734
- // Pairing by resemblance can leave a block in canonical form beside an
735
- // authored neighbour it then merges with, such as two lists. The
736
- // alignment's own pairing does not depend on resemblance, so try it too.
737
- const output = reconstruct(resembling) ??
738
- (() => {
739
- const aligned = correspondence(forms, current, false);
740
- // Unmatched blocks are holes, which every() would skip.
741
- return current.every((_, index) => aligned[index] === resembling[index])
742
- ? undefined
743
- : reconstruct(aligned);
744
- })();
745
- if (output !== undefined)
746
- return output;
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) {