@aicayzer/inkkit 0.0.15 → 0.0.17

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,6 +1,7 @@
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
5
  function signature(node) {
5
6
  return JSON.stringify(node, (key, value) => ['position', 'data', 'marker'].includes(key) ? undefined : value);
6
7
  }
@@ -163,7 +164,7 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
163
164
  destination[3]);
164
165
  }
165
166
  if (before.type === 'text' && after.type === 'text')
166
- return retainBoundaryWhitespace(retainText(raw, before.value, after.value, false, inTable), canonical.slice(newStart, newEnd));
167
+ return retainBoundaryWhitespace(withEnding(retainText(raw, before.value, after.value, false, inTable), lineEnding), canonical.slice(newStart, newEnd));
167
168
  if (signature(before) === signature(after) &&
168
169
  ownShape(before) === ownShape(after))
169
170
  return raw;
@@ -202,9 +203,7 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
202
203
  ? source.slice(childStart, from) +
203
204
  source.slice(originalFrom, originalTo) +
204
205
  source.slice(to, childEnd)
205
- : canonical
206
- .slice(incoming.position.start.offset, incoming.position.end.offset)
207
- .replaceAll('\n', lineEnding);
206
+ : withEnding(canonical.slice(incoming.position.start.offset, incoming.position.end.offset), lineEnding);
208
207
  }
209
208
  output += source.slice(previous, childStart) + retainedSource;
210
209
  previous = childEnd;
@@ -239,10 +238,14 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
239
238
  : newEnd;
240
239
  if (from != null && to != null && newFrom != null && newTo != null)
241
240
  return retainBoundaryWhitespace(source.slice(start, from) +
242
- canonical.slice(newFrom, newTo).replaceAll('\n', lineEnding) +
241
+ withEnding(canonical.slice(newFrom, newTo), lineEnding) +
243
242
  source.slice(to, end), canonical.slice(newStart, newEnd));
244
243
  }
245
- return canonical.slice(newStart, newEnd).replaceAll('\n', lineEnding);
244
+ return withEnding(canonical.slice(newStart, newEnd), lineEnding);
245
+ }
246
+ // Canonical Markdown keeps authored CRLF inside literal blocks and code fences.
247
+ function withEnding(text, lineEnding) {
248
+ return lineEnding === '\n' ? text : text.replace(/\r?\n/g, lineEnding);
246
249
  }
247
250
  function positionalNode(tree, transformed) {
248
251
  return (tree.children.find((node) => node.position?.start.offset === transformed.position?.start.offset &&
@@ -323,74 +326,207 @@ function withoutHeadingId(node) {
323
326
  ? node.type.create({ ...node.attrs, id: '' }, node.content, node.marks)
324
327
  : node;
325
328
  }
326
- // Edit distance distinguishes replacements from insertions, so replacing a block
327
- // retains its surrounding whitespace without assigning it a neighbour's source.
328
- function correspondence(before, after) {
329
+ const MATCHING_BUDGET = 1_000_000;
330
+ const SIMILARITY_BUDGET = 4_000_000;
331
+ // Unchanged blocks are anchored first, so a long document with many scattered
332
+ // edits is matched as many short gaps rather than one wide span.
333
+ export function correspondence(before, after, resemblance = true) {
329
334
  const result = new Array(after.length);
330
- let head = 0;
331
- while (head < before.length &&
332
- head < after.length &&
333
- before[head] === after[head]) {
334
- result[head] = head;
335
- head++;
335
+ const work = {
336
+ budget: MATCHING_BUDGET,
337
+ similarity: resemblance ? SIMILARITY_BUDGET : 0,
338
+ };
339
+ const spans = [{ b: 0, a: 0, bEnd: before.length, aEnd: after.length }];
340
+ for (let span = spans.pop(); span; span = spans.pop()) {
341
+ let { b, a, bEnd, aEnd } = span;
342
+ while (b < bEnd && a < aEnd && before[b] === after[a])
343
+ result[a++] = b++;
344
+ while (b < bEnd && a < aEnd && before[bEnd - 1] === after[aEnd - 1])
345
+ result[--aEnd] = --bEnd;
346
+ if (b === bEnd || a === aEnd)
347
+ continue;
348
+ work.budget -= bEnd - b + (aEnd - a);
349
+ if (work.budget < 0)
350
+ throw tooManyBlocks();
351
+ const anchors = uniqueAnchors(before, after, b, bEnd, a, aEnd);
352
+ if (!anchors.length) {
353
+ align(before, after, b, bEnd, a, aEnd, result, work);
354
+ continue;
355
+ }
356
+ for (const anchor of anchors) {
357
+ result[anchor.a] = anchor.b;
358
+ spans.push({ b, a, bEnd: anchor.b, aEnd: anchor.a });
359
+ b = anchor.b + 1;
360
+ a = anchor.a + 1;
361
+ }
362
+ spans.push({ b, a, bEnd, aEnd });
336
363
  }
337
- let endBefore = before.length;
338
- let endAfter = after.length;
339
- while (endBefore > head &&
340
- endAfter > head &&
341
- before[endBefore - 1] === after[endAfter - 1]) {
342
- result[--endAfter] = --endBefore;
364
+ return result;
365
+ }
366
+ function tooManyBlocks() {
367
+ return new PreservationError('The edit changes too many blocks to preserve safely');
368
+ }
369
+ // Blocks that occur exactly once on each side, in the longest subsequence whose
370
+ // order the two sides share. A repeated block is never an anchor: which copy it
371
+ // belongs to is not known until its neighbours are placed.
372
+ function uniqueAnchors(before, after, b, bEnd, a, aEnd) {
373
+ const once = (list, from, to) => {
374
+ const at = new Map();
375
+ for (let i = from; i < to; i++)
376
+ at.set(list[i], at.has(list[i]) ? -1 : i);
377
+ return at;
378
+ };
379
+ const beforeAt = once(before, b, bEnd);
380
+ const afterAt = once(after, a, aEnd);
381
+ const pairs = [];
382
+ for (let i = a; i < aEnd; i++) {
383
+ const match = beforeAt.get(after[i]);
384
+ if (match != null && match >= 0 && afterAt.get(after[i]) === i)
385
+ pairs.push({ b: match, a: i });
386
+ }
387
+ // Patience sorting: tails[k] ends the best increasing subsequence of length k + 1.
388
+ const tails = [];
389
+ const previous = new Array(pairs.length);
390
+ for (const [index, pair] of pairs.entries()) {
391
+ let low = 0;
392
+ let high = tails.length;
393
+ while (low < high) {
394
+ const middle = (low + high) >> 1;
395
+ if (pairs[tails[middle]].b < pair.b)
396
+ low = middle + 1;
397
+ else
398
+ high = middle;
399
+ }
400
+ previous[index] = low ? tails[low - 1] : -1;
401
+ tails[low] = index;
343
402
  }
344
- const rows = endBefore - head + 1;
345
- const columns = endAfter - head + 1;
346
- let budget = 1_000_000;
347
- let width = Math.max(1, Math.abs(rows - columns));
403
+ const anchors = [];
404
+ let at = tails.length ? tails[tails.length - 1] : -1;
405
+ while (at >= 0) {
406
+ anchors.push(pairs[at]);
407
+ at = previous[at];
408
+ }
409
+ return anchors.reverse();
410
+ }
411
+ // Replacing a block costs more than inserting or deleting one, so an unchanged
412
+ // block keeps its own copy rather than pairing with a deleted neighbour, and
413
+ // less than both, so an edited block still keeps its surrounding whitespace.
414
+ const INDEL = 2;
415
+ const REPLACE = 3;
416
+ function align(before, after, b, bEnd, a, aEnd, result, work) {
417
+ const rows = bEnd - b + 1;
418
+ const columns = aEnd - a + 1;
419
+ const offset = Math.abs(rows - columns);
420
+ let width = Math.max(1, offset);
348
421
  for (;;) {
349
422
  const costs = [];
350
423
  const get = (i, j) => i < 0 || j < 0 || Math.abs(i - j) > width
351
- ? width + 1
352
- : (costs[i]?.[j - Math.max(0, i - width)] ?? width + 1);
424
+ ? Infinity
425
+ : (costs[i]?.[j - Math.max(0, i - width)] ?? Infinity);
353
426
  for (let i = 0; i < rows; i++) {
354
427
  const from = Math.max(0, i - width);
355
428
  const to = Math.min(columns - 1, i + width);
356
- budget -= to - from + 1;
357
- if (budget < 0)
358
- throw new PreservationError('The edit changes too many blocks to preserve safely');
429
+ work.budget -= to - from + 1;
430
+ if (work.budget < 0)
431
+ throw tooManyBlocks();
359
432
  const row = new Uint32Array(to - from + 1);
360
433
  costs.push(row);
361
434
  for (let j = from; j <= to; j++) {
362
- const change = before[head + i - 1] === after[head + j - 1] ? 0 : 1;
435
+ const change = before[b + i - 1] === after[a + j - 1] ? 0 : REPLACE;
363
436
  row[j - from] =
364
437
  i === 0
365
- ? j
438
+ ? j * INDEL
366
439
  : j === 0
367
- ? i
368
- : Math.min(get(i - 1, j) + 1, get(i, j - 1) + 1, get(i - 1, j - 1) + change);
440
+ ? i * INDEL
441
+ : Math.min(get(i - 1, j) + INDEL, get(i, j - 1) + INDEL, get(i - 1, j - 1) + change);
369
442
  }
370
443
  }
371
- // A path with at most width edits cannot leave this diagonal band.
372
- // Widen only when needed, charging every attempt to the same work budget.
373
- if (get(rows - 1, columns - 1) > width) {
444
+ // A path that leaves the band takes at least width + 1 insertions or
445
+ // deletions away from the diagonal and width + 1 - offset back to its end,
446
+ // so a path inside the band that costs no more is optimal. Widen only when
447
+ // needed, charging every attempt to the same work budget.
448
+ if (width < Math.max(rows, columns) - 1 &&
449
+ get(rows - 1, columns - 1) > INDEL * (2 * (width + 1) - offset)) {
374
450
  width *= 2;
375
451
  continue;
376
452
  }
453
+ const unchanged = [];
377
454
  let i = rows - 1;
378
455
  let j = columns - 1;
379
456
  while (i || j) {
380
- const change = i && j && before[head + i - 1] === after[head + j - 1] ? 0 : 1;
381
- if (i && j && get(i, j) === get(i - 1, j - 1) + change) {
382
- result[head + j - 1] = head + i - 1;
457
+ const same = i && j && before[b + i - 1] === after[a + j - 1];
458
+ if (i && j && get(i, j) === get(i - 1, j - 1) + (same ? 0 : REPLACE)) {
459
+ result[a + j - 1] = b + i - 1;
460
+ if (same)
461
+ unchanged.push({ b: b + i - 1, a: a + j - 1 });
383
462
  i--;
384
463
  j--;
385
464
  }
386
- else if (i && get(i, j) === get(i - 1, j) + 1)
465
+ else if (i && get(i, j) === get(i - 1, j) + INDEL)
387
466
  i--;
388
467
  else
389
468
  j--;
390
469
  }
391
- break;
470
+ unchanged.push({ b: b - 1, a: a - 1 });
471
+ unchanged.reverse().push({ b: bEnd, a: aEnd });
472
+ for (let index = 1; index < unchanged.length; index++)
473
+ pairSimilar(before, after, unchanged[index - 1].b + 1, unchanged[index].b, unchanged[index - 1].a + 1, unchanged[index].a, result, work);
474
+ return;
475
+ }
476
+ }
477
+ // Between unchanged blocks, edit distance prices every replacement alike, so
478
+ // it cannot tell which original an edited block came from. Pair each with the
479
+ // original it most resembles, keeping their order: typing in a block then keeps
480
+ // its own source even when a neighbour was inserted or deleted beside it.
481
+ function pairSimilar(before, after, b, bEnd, a, aEnd, result, work) {
482
+ const removed = bEnd - b;
483
+ const added = aEnd - a;
484
+ if (!removed || !added || (removed === 1 && added === 1))
485
+ return;
486
+ // Resemblance decides; among equally resembling pairings, more pairs keep
487
+ // more replaced blocks in place with their surrounding whitespace.
488
+ const bonus = 1 / (Math.min(removed, added) + 1);
489
+ // This is a refinement with its own budget of characters compared. When it
490
+ // runs out, the alignment's pairing stands rather than the edit failing.
491
+ if (removed * added > work.similarity)
492
+ return;
493
+ const weights = new Float64Array(removed * added);
494
+ for (let x = 0; x < removed; x++)
495
+ for (let y = 0; y < added; y++) {
496
+ const shared = similarity(before[b + x], after[a + y]);
497
+ work.similarity -= shared + 1;
498
+ if (work.similarity < 0)
499
+ return;
500
+ weights[x * added + y] = shared + bonus;
501
+ }
502
+ const best = Array.from({ length: removed + 1 }, () => new Float64Array(added + 1));
503
+ for (let x = 1; x <= removed; x++)
504
+ for (let y = 1; y <= added; y++)
505
+ best[x][y] = Math.max(best[x - 1][y], best[x][y - 1], best[x - 1][y - 1] + weights[(x - 1) * added + y - 1]);
506
+ for (let index = a; index < aEnd; index++)
507
+ result[index] = undefined;
508
+ for (let x = removed, y = added; x && y;) {
509
+ if (best[x][y] ===
510
+ best[x - 1][y - 1] + weights[(x - 1) * added + y - 1]) {
511
+ result[a + --y] = b + --x;
512
+ }
513
+ else if (best[x][y] === best[x - 1][y])
514
+ x--;
515
+ else
516
+ y--;
392
517
  }
393
- return result;
518
+ }
519
+ // Characters an edit left in place at the start and end of a block.
520
+ function similarity(left, right) {
521
+ const limit = Math.min(left.length, right.length);
522
+ let prefix = 0;
523
+ while (prefix < limit && left[prefix] === right[prefix])
524
+ prefix++;
525
+ let suffix = 0;
526
+ while (suffix < limit - prefix &&
527
+ left[left.length - 1 - suffix] === right[right.length - 1 - suffix])
528
+ suffix++;
529
+ return prefix + suffix;
394
530
  }
395
531
  /** Retains the loaded source of blocks the edit leaves alone. */
396
532
  export class Preservation {
@@ -538,53 +674,76 @@ export class Preservation {
538
674
  }
539
675
  current.push(fingerprint);
540
676
  });
541
- const positions = correspondence(this.blocks.map((block) => block.form), current);
677
+ const forms = this.blocks.map((block) => block.form);
678
+ const resembling = correspondence(forms, current);
542
679
  const processor = this.ctx.get(remarkCtx);
543
680
  const currentRawTree = processor.parse(canonical);
544
681
  const currentTree = processor.runSync(structuredClone(currentRawTree), {
545
682
  value: canonical,
546
683
  });
547
- let output = '';
548
- let previous;
549
- current.forEach((text, index) => {
550
- const position = positions[index];
551
- const block = position == null ? undefined : this.blocks[position];
552
- if (index === 0) {
553
- if (position === 0 && block)
554
- output += this.source.slice(0, block.start);
555
- else if (this.source.startsWith('\uFEFF'))
556
- output += '\uFEFF';
557
- }
558
- else if (previous != null && position === previous + 1 && block) {
559
- output += this.source.slice(this.blocks[previous].end, block.start);
560
- }
561
- else
562
- output += this.lineEnding.repeat(2);
563
- if (block && block.form === text)
564
- output += this.source.slice(block.start, block.end);
565
- else if (block && currentTree.children.length === current.length) {
566
- output += this.retainBlock(block, positionalNode(currentRawTree, currentTree.children[index]), canonical);
567
- }
568
- else
569
- output += restoreCodeEndings(this.ctx, doc.type.create(null, [doc.child(index)]), text.replaceAll('\n', this.lineEnding));
570
- previous = position;
571
- });
572
- if (previous === this.blocks.length - 1 && this.blocks.length)
573
- output += this.source.slice(this.blocks[previous].end);
574
- else if (output)
575
- output += finalCodeEnding(doc) ?? this.lineEnding;
576
684
  const parse = this.ctx.get(parserCtx);
577
- const reopened = parse(output);
578
685
  const normalized = (text) => text.replace(/\r\n?/g, '\n');
579
- if (semanticSignature(reopened) === semanticSignature(doc)) {
580
- const actual = normalized(serialize(this.ctx, reopened));
581
- if (actual === normalized(canonical))
582
- return output;
583
- // Parsing may normalize loose-list spacing. Compare both reconstructed
584
- // source and intended canonical source after that same normalization.
585
- if (actual === normalized(serialize(this.ctx, parse(canonical))))
586
- return output;
587
- }
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
+ }
708
+ 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;
731
+ }
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;
588
747
  throw new PreservationError('The edited Markdown cannot be reopened without changing its content');
589
748
  }
590
749
  catch (cause) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicayzer/inkkit",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "A preserving Markdown editor for offline web views and the web",
5
5
  "license": "MIT",
6
6
  "type": "module",