@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 +10 -0
- package/dist/preserve.d.ts +1 -1
- package/dist/preserve.js +161 -63
- package/package.json +1 -1
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 } });
|
package/dist/preserve.d.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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 = {
|
|
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
|
-
//
|
|
405
|
-
//
|
|
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
|
-
|
|
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
|
-
?
|
|
414
|
-
: (costs[i]?.[j - Math.max(0, i - width)] ??
|
|
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 :
|
|
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) +
|
|
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
|
|
434
|
-
//
|
|
435
|
-
|
|
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
|
|
443
|
-
if (i && j && get(i, j) === get(i - 1, j - 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)) {
|
|
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) +
|
|
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
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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) {
|