@aicayzer/inkkit 0.0.16 → 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/dialect.js CHANGED
@@ -99,6 +99,16 @@ function remarkDialect() {
99
99
  ? value.replace(/^[ \t]+|[ \t]+$/g, (spaces) => spaces.replaceAll(' ', ' ').replaceAll('\t', '	'))
100
100
  : value;
101
101
  },
102
+ thematicBreak(node, parent, state) {
103
+ const value = defaultHandlers.thematicBreak(node, parent, state);
104
+ // A document opening with `---` reads back as front matter when
105
+ // another `---` line follows.
106
+ return parent?.type === 'root' &&
107
+ parent.children.length > 1 &&
108
+ parent.children[0] === node
109
+ ? value.replaceAll('-', '*')
110
+ : value;
111
+ },
102
112
  },
103
113
  });
104
114
  add('toMarkdownExtensions', { handlers: { link, code: fencedCodeHandler } });
@@ -3,7 +3,7 @@ import type { Node as ProseNode } from '@milkdown/kit/prose/model';
3
3
  export declare class PreservationError extends Error {
4
4
  constructor(message: string, options?: ErrorOptions);
5
5
  }
6
- export declare function correspondence(before: string[], after: string[]): Array<number | undefined>;
6
+ export declare function correspondence(before: string[], after: string[], resemblance?: boolean): Array<number | undefined>;
7
7
  /** Retains the loaded source of blocks the edit leaves alone. */
8
8
  export declare class Preservation {
9
9
  private readonly ctx;
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 &&
@@ -324,11 +327,15 @@ function withoutHeadingId(node) {
324
327
  : node;
325
328
  }
326
329
  const MATCHING_BUDGET = 1_000_000;
330
+ const SIMILARITY_BUDGET = 4_000_000;
327
331
  // Unchanged blocks are anchored first, so a long document with many scattered
328
332
  // edits is matched as many short gaps rather than one wide span.
329
- export function correspondence(before, after) {
333
+ export function correspondence(before, after, resemblance = true) {
330
334
  const result = new Array(after.length);
331
- const work = { budget: MATCHING_BUDGET };
335
+ const work = {
336
+ budget: MATCHING_BUDGET,
337
+ similarity: resemblance ? SIMILARITY_BUDGET : 0,
338
+ };
332
339
  const spans = [{ b: 0, a: 0, bEnd: before.length, aEnd: after.length }];
333
340
  for (let span = spans.pop(); span; span = spans.pop()) {
334
341
  let { b, a, bEnd, aEnd } = span;
@@ -401,17 +408,21 @@ function uniqueAnchors(before, after, b, bEnd, a, aEnd) {
401
408
  }
402
409
  return anchors.reverse();
403
410
  }
404
- // Edit distance distinguishes replacements from insertions, so replacing a block
405
- // retains its surrounding whitespace without assigning it a neighbour's source.
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;
406
416
  function align(before, after, b, bEnd, a, aEnd, result, work) {
407
417
  const rows = bEnd - b + 1;
408
418
  const columns = aEnd - a + 1;
409
- let width = Math.max(1, Math.abs(rows - columns));
419
+ const offset = Math.abs(rows - columns);
420
+ let width = Math.max(1, offset);
410
421
  for (;;) {
411
422
  const costs = [];
412
423
  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);
424
+ ? Infinity
425
+ : (costs[i]?.[j - Math.max(0, i - width)] ?? Infinity);
415
426
  for (let i = 0; i < rows; i++) {
416
427
  const from = Math.max(0, i - width);
417
428
  const to = Math.min(columns - 1, i + width);
@@ -421,38 +432,102 @@ function align(before, after, b, bEnd, a, aEnd, result, work) {
421
432
  const row = new Uint32Array(to - from + 1);
422
433
  costs.push(row);
423
434
  for (let j = from; j <= to; j++) {
424
- const change = before[b + i - 1] === after[a + j - 1] ? 0 : 1;
435
+ const change = before[b + i - 1] === after[a + j - 1] ? 0 : REPLACE;
425
436
  row[j - from] =
426
437
  i === 0
427
- ? j
438
+ ? j * INDEL
428
439
  : j === 0
429
- ? i
430
- : 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);
431
442
  }
432
443
  }
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) {
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)) {
436
450
  width *= 2;
437
451
  continue;
438
452
  }
453
+ const unchanged = [];
439
454
  let i = rows - 1;
440
455
  let j = columns - 1;
441
456
  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) {
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)) {
444
459
  result[a + j - 1] = b + i - 1;
460
+ if (same)
461
+ unchanged.push({ b: b + i - 1, a: a + j - 1 });
445
462
  i--;
446
463
  j--;
447
464
  }
448
- else if (i && get(i, j) === get(i - 1, j) + 1)
465
+ else if (i && get(i, j) === get(i - 1, j) + INDEL)
449
466
  i--;
450
467
  else
451
468
  j--;
452
469
  }
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);
453
474
  return;
454
475
  }
455
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--;
517
+ }
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;
530
+ }
456
531
  /** Retains the loaded source of blocks the edit leaves alone. */
457
532
  export class Preservation {
458
533
  ctx;
@@ -599,53 +674,76 @@ export class Preservation {
599
674
  }
600
675
  current.push(fingerprint);
601
676
  });
602
- 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);
603
679
  const processor = this.ctx.get(remarkCtx);
604
680
  const currentRawTree = processor.parse(canonical);
605
681
  const currentTree = processor.runSync(structuredClone(currentRawTree), {
606
682
  value: canonical,
607
683
  });
608
- let output = '';
609
- let previous;
610
- current.forEach((text, index) => {
611
- const position = positions[index];
612
- const block = position == null ? undefined : this.blocks[position];
613
- if (index === 0) {
614
- if (position === 0 && block)
615
- output += this.source.slice(0, block.start);
616
- else if (this.source.startsWith('\uFEFF'))
617
- output += '\uFEFF';
618
- }
619
- 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);
628
- }
629
- else
630
- output += restoreCodeEndings(this.ctx, doc.type.create(null, [doc.child(index)]), text.replaceAll('\n', this.lineEnding));
631
- previous = position;
632
- });
633
- if (previous === this.blocks.length - 1 && this.blocks.length)
634
- output += this.source.slice(this.blocks[previous].end);
635
- else if (output)
636
- output += finalCodeEnding(doc) ?? this.lineEnding;
637
684
  const parse = this.ctx.get(parserCtx);
638
- const reopened = parse(output);
639
685
  const normalized = (text) => text.replace(/\r\n?/g, '\n');
640
- if (semanticSignature(reopened) === semanticSignature(doc)) {
641
- const actual = normalized(serialize(this.ctx, reopened));
642
- if (actual === normalized(canonical))
643
- return output;
644
- // Parsing may normalize loose-list spacing. Compare both reconstructed
645
- // source and intended canonical source after that same normalization.
646
- if (actual === normalized(serialize(this.ctx, parse(canonical))))
647
- return output;
648
- }
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;
649
747
  throw new PreservationError('The edited Markdown cannot be reopened without changing its content');
650
748
  }
651
749
  catch (cause) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicayzer/inkkit",
3
- "version": "0.0.16",
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",