rapier-markdown-kit 1.2.3 → 1.2.6
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/LICENSE +6 -5
- package/README.md +28 -16
- package/dist/images/gif.mjs +3 -1
- package/dist/images/playback.mjs +6 -4
- package/dist/kit/conformance/run.mjs +1 -1
- package/dist/kit/ledger/authored-origin.mjs +49 -0
- package/dist/kit/ledger/authorship.mjs +18 -9
- package/dist/kit/ledger/data.mjs +7 -4
- package/dist/kit/ledger/format.mjs +25 -8
- package/dist/kit/ledger/history.mjs +31 -5
- package/dist/kit/ledger/inverse-source.mjs +111 -49
- package/dist/kit/ledger/journal-records.mjs +24 -29
- package/dist/kit/ledger/merge.mjs +48 -13
- package/dist/kit/ledger/text-merge.mjs +75 -29
- package/dist/kit/ledger/transport.mjs +30 -45
- package/dist/kit/pdf-vectors.mjs +2 -1
- package/dist/kit/print-capitals.mjs +0 -1
- package/dist/kit/rapier-ledger.mjs +2 -0
- package/dist/kit/render-markdown.mjs +12 -3
- package/dist/kit/render-print.mjs +43 -7
- package/dist/kit/render-sanitize.mjs +19 -12
- package/dist/kit/render-styles.mjs +3 -3
- package/dist/kit/render-work.mjs +1 -1
- package/dist/kit/render.mjs +6 -41
- package/dist/layout/paragraph-fit.mjs +1 -15
- package/dist/layout/typography.mjs +2 -1
- package/dist/packages/rapier-diff/concerns.mjs +163 -0
- package/dist/packages/rapier-diff/diff.mjs +68 -0
- package/dist/packages/rapier-diff/index.mjs +4 -744
- package/dist/packages/rapier-diff/matching.mjs +157 -0
- package/dist/packages/rapier-diff/merge.mjs +226 -0
- package/dist/packages/rapier-diff/moves.mjs +130 -0
- package/dist/packages/rapier-diff/structure.mjs +50 -0
- package/dist/packages/rapier-diff/tokens.mjs +72 -0
- package/dist/packages/rapier-diff/walk.mjs +219 -0
- package/dist/spec/html-blocks.mjs +3 -3
- package/dist/spec/md-layout.mjs +1 -1
- package/dist/spec/md-marks.mjs +5 -5
- package/dist/spec/md-source.mjs +15 -7
- package/package.json +1 -1
- package/style.css +11 -11
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {letter, within, prev, next} from './tokens.mjs';
|
|
3
|
+
|
|
4
|
+
// Separator-only pieces join the edit beside them; adjacent blocks each carry their own line break.
|
|
5
|
+
export function separate(c, endA = c.before.length, endB = c.after.length) {
|
|
6
|
+
const {before, after, safe, raw, spend} = c;
|
|
7
|
+
// A separator-only piece beside another edit joins it; equal separators at its ends stay unchanged.
|
|
8
|
+
if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
|
|
9
|
+
const p = c.result[i - 1], q = c.result[i], side = p[0][0] === p[0][1] ? 1 : p[1][0] === p[1][1] ? 0 : -1, text = side ? after : before;
|
|
10
|
+
if (side >= 0 && !/\S/.test(text.slice(...p[side]))) {
|
|
11
|
+
let n = 0;
|
|
12
|
+
while (p[0][1] + n < q[0][0] && text[p[side][0] + n] === text[p[side][1] + n] && spend()) n++;
|
|
13
|
+
if (p[0][1] + n === q[0][0] && p.slice(0, 2).every((span, k) => safe(k ? after : before, span[0] + n) && safe(k ? after : before, span[1] + n)))
|
|
14
|
+
for (const span of p.slice(0, 2)) { span[0] += n; span[1] += n; }
|
|
15
|
+
}
|
|
16
|
+
if (p[2] || q[2] || p[0][1] !== q[0][0] || p[1][1] !== q[1][0] ||
|
|
17
|
+
![p, q].some(r => !/\S/.test(before.slice(...r[0]) + after.slice(...r[1])))) continue;
|
|
18
|
+
const x = [p[0][0], q[0][1]], y = [p[1][0], q[1][1]], keep = /\S/.test(before.slice(...q[0]) + after.slice(...q[1])) ? q : p;
|
|
19
|
+
// CRLF steps as one separator.
|
|
20
|
+
for (let k; x[0] < x[1] && y[0] < y[1] && /\s/.test(before[x[1] - 1]) && before[x[1] - 1] === after[y[1] - 1] &&
|
|
21
|
+
(k = before.endsWith('\r\n', x[1]) && after.endsWith('\r\n', y[1]) ? 2 : 1) <= Math.min(x[1] - x[0], y[1] - y[0]) &&
|
|
22
|
+
safe(before, x[1] - k) && safe(after, y[1] - k);) { x[1] -= k; y[1] -= k; }
|
|
23
|
+
keep[3][0] = x[0] === x[1] ? 2 : y[0] === y[1] ? 0 : 1;
|
|
24
|
+
c.result.splice(i - 1, 2, [x, y, false, keep[3]]);
|
|
25
|
+
}
|
|
26
|
+
// Repeated closing lines can attach to the wrong block. Rotate an exact insertion/deletion to the
|
|
27
|
+
// next blank-line boundary, without crossing another edit or splitting a character.
|
|
28
|
+
if (!raw) for (let i = 0; i < c.result.length; i++) {
|
|
29
|
+
const p = c.result[i], q = c.result[i + 1], side = p[0][0] === p[0][1] ? 1 : p[1][0] === p[1][1] ? 0 : -1;
|
|
30
|
+
if (side < 0 || p[2]) continue;
|
|
31
|
+
const text = side ? after : before;
|
|
32
|
+
const room = Math.min((q?.[0][0] ?? endA) - p[0][1], (q?.[1][0] ?? endB) - p[1][1]);
|
|
33
|
+
let n = 0, shift = 0;
|
|
34
|
+
while (n < room && text[p[side][0] + n] === text[p[side][1] + n] && spend()) {
|
|
35
|
+
n++;
|
|
36
|
+
if (text[p[side][0] + n - 1] === '\n' && /^\r?\n/.test(text.slice(p[side][0] + n))) { shift = n; break; }
|
|
37
|
+
}
|
|
38
|
+
if (shift && p.slice(0, 2).every((span, k) => safe(k ? after : before, span[0] + shift) && safe(k ? after : before, span[1] + shift)))
|
|
39
|
+
for (const span of p.slice(0, 2)) { span[0] += shift; span[1] += shift; }
|
|
40
|
+
}
|
|
41
|
+
// Adjacent blocks deleted or inserted together: when the first carries the line break before it, each carries
|
|
42
|
+
// its own, so any one restores apart from its neighbors.
|
|
43
|
+
if (!raw) for (let i = 1; i < c.result.length; i++) {
|
|
44
|
+
const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
|
|
45
|
+
if (kind % 2 || q[3][0] !== kind || p[2] || q[2] || p[side][1] !== q[side][0] || p[1 - side][0] !== q[1 - side][0]) continue;
|
|
46
|
+
const blank = /^\r?\n/.test(text.slice(...p[side])) && !/^\r?\n/.test(text.slice(...q[side])) && /(\r?\n)(?:[ \t]*\r?\n)+$/.exec(text.slice(...p[side]));
|
|
47
|
+
if (blank && blank.index > 0) p[side][1] = q[side][0] -= blank[0].length - blank[1].length;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Refine the outer walk and each move's inner walk before assigning transport ownership.
|
|
52
|
+
export function phrases(c) {
|
|
53
|
+
const {before, after, raw, code} = c;
|
|
54
|
+
// Whole-word replacements separated only by spaces read and restore as one phrase, and one number stays one number.
|
|
55
|
+
if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
|
|
56
|
+
const p = c.result[i - 1], q = c.result[i], gap = before.slice(p[0][1], q[0][0]);
|
|
57
|
+
if (p[3][0] !== 1 || q[3][0] !== 1 || p[3][3] || q[3][3] || p[2] || q[2] || gap !== after.slice(p[1][1], q[1][0])) continue;
|
|
58
|
+
// A time, a date, a version or a decimal is one number.
|
|
59
|
+
const number = /^(?:[.,:/-]\d*)+$/.test(gap) && /\d/.test(before[p[0][1] - 1] + after[p[1][1] - 1]) && /\d/.test(before[q[0][0]] + after[q[1][0]]);
|
|
60
|
+
if (!number && (code || !/^[^\S\r\n]+$/.test(gap) || within(before, p[0][0]) || within(after, p[1][0]) ||
|
|
61
|
+
within(before, q[0][1]) || within(after, q[1][1]))) continue;
|
|
62
|
+
p[0][1] = q[0][1]; p[1][1] = q[1][1]; c.result.splice(i, 1);
|
|
63
|
+
}
|
|
64
|
+
// Paired inline markers added or removed around unchanged text on one line are one concern.
|
|
65
|
+
if (!raw && !code) for (let i = 1; i < c.result.length; i++) {
|
|
66
|
+
const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
|
|
67
|
+
if (kind % 2 || kind !== q[3][0] || p[2] || q[2] || p[3][3] || q[3][3]) continue;
|
|
68
|
+
const open = text.slice(...p[side]), close = text.slice(...q[side]), inner = text.slice(p[side][1], q[side][0]);
|
|
69
|
+
if (!inner.trim() || inner.includes('\n') || !(open === close && /^(?:[*_]{1,3}|~~|==|`+)$/.test(open) ||
|
|
70
|
+
open === '[' && /^\]\([^\s()]*\)$/.test(close))) continue;
|
|
71
|
+
p[3] = q[3] = [kind, 'word', p[3][2], [[p[0][0], q[0][1]], [p[1][0], q[1][1]]]];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Readable concerns: phrases, numbers and paired markers, then the public edits and changes with context.
|
|
76
|
+
export function concerns(c) {
|
|
77
|
+
phrases(c);
|
|
78
|
+
const {before, after, spend, safe, budget, raw, code, evidence, structures, initial, options, pa, pb} = c, lb = c.lb;
|
|
79
|
+
const overlaps = (a, b) => a[0] < b[1] && a[1] > b[0];
|
|
80
|
+
// Siblings are ordered and disjoint: the parts holding a range are found by descent, outermost first.
|
|
81
|
+
const containing = (parts, from, to, out = []) => {
|
|
82
|
+
let lo = 0, hi = parts.length;
|
|
83
|
+
while (lo < hi) { const mid = lo + hi >>> 1; if (parts[mid].end < to) lo = mid + 1; else hi = mid; }
|
|
84
|
+
for (let k = lo; k < parts.length && parts[k].start <= from && spend(); k++)
|
|
85
|
+
if (parts[k].end >= to) { out.push(parts[k]); containing(parts[k].children, from, to, out); }
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
// Edits are ordered and disjoint on the after side.
|
|
89
|
+
const touched = (edits, range) => {
|
|
90
|
+
let lo = 0, hi = edits.length;
|
|
91
|
+
while (lo < hi) { const mid = lo + hi >>> 1; if (edits[mid].after[1] <= range[0]) lo = mid + 1; else hi = mid; }
|
|
92
|
+
return lo < edits.length && overlaps(range, edits[lo].after);
|
|
93
|
+
};
|
|
94
|
+
const changes = [], edits = [];
|
|
95
|
+
let normalized;
|
|
96
|
+
const meaning = structures?.[0]?.meaning;
|
|
97
|
+
const format = typeof meaning === 'string' && meaning === structures[1].meaning;
|
|
98
|
+
for (let i = 0; i < c.result.length; i++) {
|
|
99
|
+
const item = c.result[i];
|
|
100
|
+
if (raw === 'char' && !evidence) {
|
|
101
|
+
const a = item[0], b = item[1], next = c.result[i + 1];
|
|
102
|
+
if (item[3][0] !== 1) {
|
|
103
|
+
const room = Math.min((next?.[0][0] ?? before.length) - a[1], (next?.[1][0] ?? after.length) - b[1]);
|
|
104
|
+
let n = 0;
|
|
105
|
+
while (n < room && before[a[0] + n] === after[b[0] + n] && spend()) n++;
|
|
106
|
+
while (n && (!safe(before, a[0] + n) || !safe(before, a[1] + n) || !safe(after, b[0] + n) || !safe(after, b[1] + n))) n--;
|
|
107
|
+
a[0] += n; a[1] += n; b[0] += n; b[1] += n;
|
|
108
|
+
}
|
|
109
|
+
const p = normalized;
|
|
110
|
+
if (p && p[0][0] === p[0][1] && item[3][0] === 2 && p[0][0] === a[0] && p[1][1] === b[0]) {
|
|
111
|
+
p[1][1] = b[1]; if (item[2]) edits.at(-1).coarse = true; continue;
|
|
112
|
+
}
|
|
113
|
+
normalized = item;
|
|
114
|
+
}
|
|
115
|
+
const group = item[3];
|
|
116
|
+
if (!raw && !group[3]) group[3] = [[...item[0]], [...item[1]]];
|
|
117
|
+
if (!group[4]) {
|
|
118
|
+
const context = group[3];
|
|
119
|
+
// Context completes a word only where an edge falls inside it.
|
|
120
|
+
if (!raw) for (const [text, span] of [[before, context[0]], [after, context[1]]]) {
|
|
121
|
+
const start = within(text, span[0]), end = within(text, span[1]);
|
|
122
|
+
for (let c; start && letter(c = prev(text, span[0])) && spend() && safe(text, span[0] - c.length);) span[0] -= c.length;
|
|
123
|
+
for (let c; end && letter(c = next(text, span[1])) && spend() && safe(text, span[1] + c.length);) span[1] += c.length;
|
|
124
|
+
}
|
|
125
|
+
group[4] = []; changes.push(group);
|
|
126
|
+
if (format) group[0] = 5;
|
|
127
|
+
}
|
|
128
|
+
group[4].push(edits.length);
|
|
129
|
+
edits.push({before: item[0], after: item[1], ...(item[2] ? {coarse: true} : {})});
|
|
130
|
+
}
|
|
131
|
+
if (structures && !raw && budget.left >= 0) {
|
|
132
|
+
const oldNames = new Set(pa.filter(p => p.nameAt).map(p => p.name)), newNames = new Set(pb.filter(p => p.nameAt).map(p => p.name));
|
|
133
|
+
const linksTo = new Map();
|
|
134
|
+
if (spend(pa.length + pb.length + lb.length)) lb.forEach((link, k) => linksTo.set(link.to, [...linksTo.get(link.to) || [], k]));
|
|
135
|
+
for (const change of changes) {
|
|
136
|
+
if (change[0] === 5) continue;
|
|
137
|
+
if (budget.left < 0) break;
|
|
138
|
+
const range = change[3];
|
|
139
|
+
const older = containing(structures[0].parts, ...range[0]), newer = containing(structures[1].parts, ...range[1]);
|
|
140
|
+
const part = (change[0] === 0 ? older : newer).at(-1) || older.at(-1);
|
|
141
|
+
if (!change[2] && part?.name) { change[2] = part.name; change[1] = part.kind; }
|
|
142
|
+
const names = new Set([change[2], ...older.map(p => p.name), ...newer.map(p => p.name)]), breaks = [], affects = [];
|
|
143
|
+
for (const k of [...names].flatMap(name => linksTo.get(name) || []).sort((a, b) => a - b)) {
|
|
144
|
+
const {at, to} = lb[k];
|
|
145
|
+
if (!newNames.has(to)) { if (oldNames.has(to)) breaks.push({at, to}); continue; }
|
|
146
|
+
if (!spend() || touched(edits, at)) continue;
|
|
147
|
+
const p = containing(structures[1].parts, ...at).filter(p => /^(paragraph|call|item|task|cell|declaration)$/.test(p.kind)).at(-1);
|
|
148
|
+
affects.push(p && !touched(edits, [p.start, p.end]) ? [p.start, p.end] : at);
|
|
149
|
+
}
|
|
150
|
+
if (breaks.length) change[6] = breaks;
|
|
151
|
+
if (affects.length) change[5] = affects;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (options._stats) Object.assign(options._stats, {work: Math.max(0, initial - Math.max(0, budget.left)), coarse: edits.some(c => c.coarse)});
|
|
155
|
+
// Concern tuples: kind, unit, name, context, edits, affects, breaks. Wording inside a move shares its context.
|
|
156
|
+
const moves = new Map(changes.map((change, i) => change[0] === 3 && [change[3], i + 1]).filter(Boolean));
|
|
157
|
+
return {edits, changes: changes.map(([kind, unit, name, context, owned, affects, breaks], i) => ({
|
|
158
|
+
id: i + 1, kind: ['delete', 'replace', 'insert', 'move', 'rename', 'format'][kind], unit, edits: owned,
|
|
159
|
+
...(name ? {name} : {}), ...(context ? {context: {before: context[0], after: context[1]}} : {}),
|
|
160
|
+
...(kind !== 3 && moves.has(context) ? {move: moves.get(context)} : {}),
|
|
161
|
+
...(affects ? {affects} : {}), ...(breaks ? {breaks} : {})
|
|
162
|
+
}))};
|
|
163
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// One comparison: observe structure, walk the source into exact records, find moves and renames, then read them as
|
|
3
|
+
// concerns. Each stage lives in its own module and shares one comparison context.
|
|
4
|
+
import {observe} from './structure.mjs';
|
|
5
|
+
import {boundaries, tokenizer, wordSegmenter} from './tokens.mjs';
|
|
6
|
+
import {matcher} from './matching.mjs';
|
|
7
|
+
import {walker} from './walk.mjs';
|
|
8
|
+
import {moves} from './moves.mjs';
|
|
9
|
+
import {separate, concerns} from './concerns.mjs';
|
|
10
|
+
|
|
11
|
+
export function diff(before, after, options = {}) {
|
|
12
|
+
if (before === after) return {edits: [], changes: []};
|
|
13
|
+
const budget = options._budget || {left: Number.isFinite(options.limit) ? Math.max(0, options.limit) : 1e7};
|
|
14
|
+
const spend = (n = 1) => (budget.left -= n) >= 0, safe = boundaries(), raw = options._unit;
|
|
15
|
+
// Internal records: [before range, after range, coarse, concern, transported fragments].
|
|
16
|
+
const c = {before, after, options, budget, spend, safe, raw, chars: options.unit === 'char', initial: budget.left,
|
|
17
|
+
segmenter: wordSegmenter(), result: [], code: options.as === 'code'};
|
|
18
|
+
// A Markdown fence holds code inside prose; only code outside fences makes the text code.
|
|
19
|
+
if (options.as !== 'prose' && options.as !== 'code') c.code = /(?:^|\n)\s*(?:[{}]|(?:function|class|const|let|var|import|export|def|fn|func|package)\s+\w[^]{0,80}[({=:;])/
|
|
20
|
+
.test((before.slice(0, 4096) + '\n' + after.slice(0, 4096)).replace(/^ {0,3}(`{3,}|~{3,})[^]*?(?:^ {0,3}\1[`~]*[ \t]*$|(?![^]))/gm, ''));
|
|
21
|
+
let fallback;
|
|
22
|
+
if (options.structure && (!raw || options._structure)) {
|
|
23
|
+
let structures;
|
|
24
|
+
try {
|
|
25
|
+
structures = options._observed || [observe(before, options.structure, spend, safe), observe(after, options.structure, spend, safe)];
|
|
26
|
+
if (structures.some(value => !value || value.invalid)) throw null;
|
|
27
|
+
const [pa, pb] = structures.map(value => value.flat), aliases = new Map();
|
|
28
|
+
const oldByKey = new Map(pa.filter(p => p.key != null).map(p => [p.key, p]));
|
|
29
|
+
const oldNames = new Set(pa.map(p => p.name)), newNames = new Set(pb.map(p => p.name));
|
|
30
|
+
for (const p of pb) {
|
|
31
|
+
const old = oldByKey.get(p.key);
|
|
32
|
+
if (old?.nameAt && p.nameAt && old.kind === p.kind && !oldNames.has(p.name) && !newNames.has(old.name)) aliases.set(p, old);
|
|
33
|
+
}
|
|
34
|
+
Object.assign(c, {structures, pa, pb, la: structures[0].links, lb: structures[1].links, aliases});
|
|
35
|
+
} catch {
|
|
36
|
+
// A failed reader preserves an explicit prose choice and reports why structure was not used.
|
|
37
|
+
const failed = structures?.find(value => !value || value.invalid);
|
|
38
|
+
fallback = {structure: failed?.threw ? 'threw' : budget.left < 0 ? 'limit' : 'invalid', ...(failed?.threw ? {error: failed.error} : {})};
|
|
39
|
+
c.code = options.as !== 'prose';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
c.tokens = tokenizer(c); c.matches = matcher(spend, budget);
|
|
43
|
+
const {walk, operations} = Object.assign(c, walker(c)), {structures, pa, pb} = c;
|
|
44
|
+
const evidence = c.evidence = options.from && operations(options.from);
|
|
45
|
+
if (options.from && !evidence) (fallback ||= {}).from = budget.left < 0 ? 'limit' : 'invalid';
|
|
46
|
+
if (evidence) for (const span of evidence) walk(...span, structures ? 0 : raw === 'char' ? 3 : 1, pa, pb);
|
|
47
|
+
else walk(0, before.length, 0, after.length, structures ? 0 : raw === 'word' ? 2 : 1,
|
|
48
|
+
structures?.[0]?.parts, structures?.[1]?.parts);
|
|
49
|
+
separate(c);
|
|
50
|
+
moves(c);
|
|
51
|
+
// A heading edit can re-record its children. Compare both walks after transport is known: unchanged moved
|
|
52
|
+
// fragments cost nothing, so a smaller plain walk cannot discard a move just by aligning some of its lines.
|
|
53
|
+
if (structures && !evidence && !raw && c.result.some(r => r[0][0] === r[0][1]) && c.result.some(r => r[1][0] === r[1][1]) &&
|
|
54
|
+
budget.left >= c.initial - budget.left) {
|
|
55
|
+
const size = (rows, transport = false) => rows.reduce((n, r) => n + (!transport && r[3][0] === 3 ? 0 : r[0][1] - r[0][0] + r[1][1] - r[1][0]), 0);
|
|
56
|
+
const walked = c.result, links = c.lb, cost = size(walked);
|
|
57
|
+
if (cost) {
|
|
58
|
+
c.result = []; c.lb = structures[1].links;
|
|
59
|
+
walk(0, before.length, 0, after.length, 1);
|
|
60
|
+
separate(c); moves(c);
|
|
61
|
+
const plain = size(c.result);
|
|
62
|
+
if (budget.left < 0 || c.result.some(r => r[2]) || plain > cost ||
|
|
63
|
+
plain === cost && size(c.result, true) >= size(walked, true)) { c.result = walked; c.lb = links; }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const {edits, changes} = concerns(c);
|
|
67
|
+
return {edits, ...(fallback && !raw ? {fallback} : {}), changes};
|
|
68
|
+
}
|