rapier-html 1.1.104 → 1.2.5

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.
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {separate, phrases} from './concerns.mjs';
3
+
4
+ // Moves bind equal transported fragments at both ends; renames group a binding's sites.
5
+ export function moves(c) {
6
+ const {before, after, spend, safe, budget, raw, code, tokens, matches, walk, structures, pa, pb, la, aliases} = c;
7
+ const overlaps = (a, b) => a[0] < b[1] && a[1] > b[0];
8
+ const inside = (c, a, b) => [a, b].every((range, side) => c[side][0] === c[side][1] ||
9
+ c[side][0] >= range[0] && c[side][1] <= range[1]);
10
+ try { if (!raw && budget.left >= 0) {
11
+ const check = n => { if (!spend(n)) throw null; };
12
+ // Split transported text at wording boundaries; positions and words own distinct edits.
13
+ const deleted = new Map();
14
+ const moveKey = (item, side, text, parts = []) => {
15
+ const [from, to] = item[side];
16
+ let k = 0, hi = parts.length;
17
+ while (k < hi) { const mid = k + hi >>> 1; if (parts[mid].start < from) k = mid + 1; else hi = mid; }
18
+ for (; k < parts.length && parts[k].start <= to; k++) {
19
+ const p = parts[k]; check(1);
20
+ if (p.end <= to && p.name === item[3][2] && !/\S/.test(text.slice(from, p.start) + text.slice(p.end, to))) return item[3][1] + '\0' + item[3][2];
21
+ }
22
+ return text.slice(from, to).trim();
23
+ };
24
+ for (const item of c.result) if (item[3][0] === 0 && !item[2]) {
25
+ const value = before.slice(...item[0]), key = moveKey(item, 0, before, pa);
26
+ if (item[3][2] || value.trim().length >= 4 && /[\n ]/.test(value)) deleted.set(key, deleted.has(key) ? null : item);
27
+ }
28
+ for (const item of c.result) if (item[3][0] === 2 && !item[2]) {
29
+ const value = after.slice(...item[1]), key = moveKey(item, 1, after, pb);
30
+ let old = deleted.get(key), score = 0;
31
+ // Edited wording still travels: plain text, or a prose part of the same kind.
32
+ const text = value.trim(), prose = !structures || /^(paragraph|item|task|row|quote|section)$/.test(item[3][1]);
33
+ if (!old && prose && text.length >= 24) for (const candidate of deleted.values()) {
34
+ check(1);
35
+ if (!candidate || candidate[4] || candidate[3][1] !== item[3][1]) continue;
36
+ const prior = before.slice(...candidate[0]).trim();
37
+ const a = tokens(prior, 0, prior.length, 2), b = tokens(text, 0, text.length, 2);
38
+ if (!a || !b) throw null;
39
+ const n = matches(a[0], b[0]).reduce((n, j, i) => n + a[1][i + 1] - a[1][i], 0);
40
+ check(0);
41
+ if (n * 5 > Math.max(prior.length, text.length) * 3 && n >= score) { old = n === score ? null : candidate; score = n; }
42
+ }
43
+ if (!old || old[4]) continue;
44
+ // Separators that only one end carries stay outside the transported text.
45
+ const x = [...old[0]], y = [...item[1]];
46
+ for (const end of [0, 1]) {
47
+ const pattern = end ? /\s*$/ : /^\s*/, left = before.slice(...x).match(pattern)[0], right = after.slice(...y).match(pattern)[0];
48
+ let common = 0;
49
+ while (common < Math.min(left.length, right.length) && left.at(end ? common : -1 - common) === right.at(end ? common : -1 - common)) common++;
50
+ const dx = (left.length - common) * (end ? -1 : 1), dy = (right.length - common) * (end ? -1 : 1);
51
+ if (safe(before, x[end] + dx) && safe(after, y[end] + dy)) { x[end] += dx; y[end] += dy; }
52
+ }
53
+ const outer = c.result; c.result = [];
54
+ walk(...x, ...y, 1);
55
+ // A changed heading can occupy a whole line while its marker still anchors the move.
56
+ if (c.result.length && (c.result[0][0][0] === x[0] || c.result[0][1][0] === y[0] ||
57
+ c.result.at(-1)[0][1] === x[1] || c.result.at(-1)[1][1] === y[1])) {
58
+ c.result = []; walk(...x, ...y, 2);
59
+ }
60
+ separate(c, x[1], y[1]);
61
+ phrases(c);
62
+ const inner = c.result; c.result = outer;
63
+ // Equal boundary fragments make the exact extent recoverable without display context.
64
+ if (inner.length && (inner[0][0][0] === x[0] || inner[0][1][0] === y[0] ||
65
+ inner.at(-1)[0][1] === x[1] || inner.at(-1)[1][1] === y[1] || inner.some(c => c[2]))) continue;
66
+ const move = [3, item[3][2] ? item[3][1] : code ? 'line' : 'block', item[3][2], [x, y]];
67
+ old[4] = []; item[4] = [];
68
+ const fragment = (a0, a1, b0, b1, group) => {
69
+ if (a0 < a1) old[4].push([[a0, a1], [old[1][0], old[1][0]], false, group]);
70
+ if (b0 < b1) item[4].push([[item[0][0], item[0][0]], [b0, b1], false, group]);
71
+ };
72
+ // Unmatched outer separators travel with the move, so restoring it alone keeps blocks apart.
73
+ fragment(old[0][0], x[0], 0, 0, move); fragment(0, 0, item[1][0], y[0], move);
74
+ let a = x[0], b = y[0], wording;
75
+ for (const edit of inner) {
76
+ if (edit[3][3]) wording = edit[3];
77
+ else if (a !== edit[0][0] || b !== edit[1][0]) wording = [edit[3][0], edit[3][1]];
78
+ wording[2] = item[3][2]; wording[3] = move[3];
79
+ if (wording[0] !== edit[3][0]) wording[0] = 1;
80
+ fragment(a, edit[0][0], b, edit[1][0], move);
81
+ fragment(...edit[0], ...edit[1], wording);
82
+ a = edit[0][1]; b = edit[1][1];
83
+ }
84
+ fragment(a, x[1], b, y[1], move);
85
+ fragment(x[1], old[0][1], 0, 0, move);
86
+ fragment(0, 0, y[1], item[1][1], move);
87
+ }
88
+ c.result = c.result.flatMap(item => item[4] || [item]);
89
+
90
+ if (structures) {
91
+ check(la.length * Math.ceil(Math.log2(la.length + 1)) + c.lb.length + pa.length + pb.length);
92
+ const mapped = {}; let cursor = 0, delta = 0;
93
+ for (const link of [...la].sort((a, b) => a.at[0] - b.at[0])) {
94
+ while (cursor < c.result.length && c.result[cursor][0][1] <= link.at[0]) { delta = c.result[cursor][1][1] - c.result[cursor][0][1]; cursor++; }
95
+ if (cursor === c.result.length || c.result[cursor][0][0] >= link.at[1]) mapped[[link.at[0] + delta, link.at[1] + delta]] = link.to;
96
+ }
97
+ c.lb = c.lb.map(l => l.key != null ? l : {...l, to: mapped[l.at] || l.to});
98
+ for (const [p, old] of aliases) {
99
+ if (before.slice(...old.nameAt) === after.slice(...p.nameAt)) continue;
100
+ check(la.length + c.lb.length);
101
+ const left = la.filter(l => l.key === old.key), right = c.lb.filter(l => l.key === p.key);
102
+ if (left.length !== right.length) continue;
103
+ const x = [old.nameAt], y = [p.nameAt], owned = [];
104
+ for (let i = 0; i < left.length; i++) {
105
+ x.push(left[i].at); y.push(right[i].at);
106
+ if (left[i].labelAt && right[i].labelAt && before.slice(...left[i].labelAt) === before.slice(...old.nameAt) &&
107
+ after.slice(...right[i].labelAt) === after.slice(...p.nameAt)) { x.push(left[i].labelAt); y.push(right[i].labelAt); }
108
+ }
109
+ x.sort((a, b) => a[0] - b[0]); y.sort((a, b) => a[0] - b[0]);
110
+ let complete = true;
111
+ for (let i = 0; i < x.length; i++) {
112
+ check(c.result.length * 2);
113
+ if (c.result.some(c => c[3]?.[0] !== 3 && (overlaps(c[0], x[i]) || overlaps(c[1], y[i])) && !inside(c, x[i], y[i]))) { complete = false; break; }
114
+ const found = c.result.filter(c => c[3][0] < 3 && inside(c, x[i], y[i]));
115
+ if (!found.length) { complete = false; break; }
116
+ owned.push(...found);
117
+ }
118
+ if (!complete) continue;
119
+ check(c.result.length + owned.length * 2);
120
+ const owners = new Set(owned.map(c => c[3]).filter(Boolean)), members = new Set(owned);
121
+ if (c.result.some(c => owners.has(c[3]) && !members.has(c))) continue;
122
+ const group = [4, p.kind, old.name, [[...old.nameAt], [...p.nameAt]]];
123
+ for (const item of owned) item[3] = group;
124
+ }
125
+ }
126
+ } } catch (error) {
127
+ if (error !== null) throw error;
128
+ c.result = c.result.flatMap(item => item[4] || [item]);
129
+ }
130
+ }
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Capture only the reader contract before another observation can reuse its objects.
3
+ export function observe(text, reader, spend, safe, references = true, lenient = false) {
4
+ let value;
5
+ try { value = reader(text); } catch (error) { return {invalid: true, threw: true, error}; }
6
+ try {
7
+ const flat = [], links = [], meaning = references && value?.meaning;
8
+ const span = (at, from, end) => {
9
+ if (!Array.isArray(at) || at.length !== 2 || !at.every(Number.isSafeInteger) ||
10
+ at[0] < from || at[1] < at[0] || at[1] > end) throw null;
11
+ if (safe(text, at[0]) && safe(text, at[1])) return [at[0], at[1]];
12
+ if (!lenient) throw null;
13
+ };
14
+ const scalar = (value, key = false) => {
15
+ if (value != null && typeof value !== 'string' && !(key && (typeof value === 'boolean' || Number.isFinite(value)))) throw false;
16
+ return value;
17
+ };
18
+ let count = 0;
19
+ function copy(rows, from, end, depth, retained = true) {
20
+ if (!Array.isArray(rows) || rows.length > 131072 || depth > 128) throw null;
21
+ const parts = [];
22
+ let at = from;
23
+ for (const row of rows) {
24
+ if (!spend() || count++ >= 131072) throw null;
25
+ const {start, end: stop, kind, name, key, nameAt, children} = row;
26
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(stop) ||
27
+ start < at || stop < start || stop > end) throw null;
28
+ at = stop;
29
+ const usable = safe(text, start) && safe(text, stop);
30
+ if (!usable && !lenient) throw null;
31
+ const part = {start, end: stop, kind: scalar(kind), name: scalar(name), key: scalar(key, true)};
32
+ if (nameAt != null) part.nameAt = span(nameAt, start, stop);
33
+ // Discard unsafe evidence only after validating its complete subtree.
34
+ if (retained && usable) { parts.push(part); flat.push(part); }
35
+ part.children = children == null ? [] : copy(children, start, stop, depth + 1, retained && usable);
36
+ }
37
+ return parts;
38
+ }
39
+ const parts = copy(value?.parts, 0, text.length, 0), rows = references && value.links || [];
40
+ if (!Array.isArray(rows) || rows.length > 131072) throw null;
41
+ for (const row of rows) {
42
+ if (!spend()) throw null;
43
+ const {at, to, key, labelAt} = row;
44
+ if (typeof to !== 'string') throw null;
45
+ const place = span(at, 0, text.length), label = labelAt == null ? null : span(labelAt, 0, text.length), binding = scalar(key, true);
46
+ if (place) links.push({at: place, to, key: binding, ...(label && {labelAt: label})});
47
+ }
48
+ return {parts, flat, links, meaning: typeof meaning === 'string' ? meaning : undefined};
49
+ } catch (error) { return error === false ? {invalid: true} : null; }
50
+ }
@@ -0,0 +1,72 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Space-free scripts (Thai, Lao, Myanmar, Khmer, kana, Han) have no word separator; the platform dictionary finds words.
3
+ export const SPACELESS = /[\u0e00-\u0eff\u1000-\u109f\u1780-\u17ff\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/;
4
+ export const words = (segmenter, text) => [...text.matchAll(/[\p{L}\p{N}\p{M}_]+/gu)].flatMap(word => SPACELESS.test(word[0])
5
+ ? [...segmenter().segment(word[0])].map(s => [word.index + s.index, word.index + s.index + s.segment.length])
6
+ : [[word.index, word.index + word[0].length]]);
7
+ export const wordSegmenter = () => { let segmenter; return () => segmenter ||= new Intl.Segmenter('en', {granularity: 'word'}); };
8
+
9
+ // Segment only requested non-ASCII boundaries; observations never share a global document cache.
10
+ export function boundaries() {
11
+ const segments = Object.create(null);
12
+ let segmenter, last = [];
13
+ return function safe(text, at) {
14
+ if (!at || at === text.length) return true;
15
+ const a = text.charCodeAt(at - 1), b = text.charCodeAt(at);
16
+ if ((a & 0xfc00) === 0xd800 && (b & 0xfc00) === 0xdc00 || a === 13 && b === 10) return false;
17
+ if (a < 0x300 && b < 0x300) return true;
18
+ // Retreats probe one long cluster many times; answer inside the last cluster without the segmenter.
19
+ if (last[0] === text && at > last[1] && at < last[2]) return false;
20
+ const found = (segments[text] ||= (segmenter ||= new Intl.Segmenter('en', {granularity: 'grapheme'})).segment(text)).containing(at);
21
+ last = [text, found.index, found.index + found.segment.length];
22
+ return found.index === at;
23
+ }
24
+ }
25
+
26
+ // Letters are code points: an astral letter is one letter, never two lone surrogates.
27
+ const pair = (text, at) => (text.charCodeAt(at - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(at) & 0xfc00) === 0xdc00;
28
+ export const prev = (text, at) => text.slice(Math.max(0, at - 1 - pair(text, at - 1)), at), next = (text, at) => text.slice(at, at + 1 + pair(text, at + 1));
29
+ export const letter = c => c && /[\p{L}\p{M}\p{N}_]/u.test(c), within = (text, at) => letter(prev(text, at)) && letter(next(text, at));
30
+
31
+ // Tokens at a level: 0 parts, 1 lines, 2 words, spaces and marks, 3 characters.
32
+ export function tokenizer(c) {
33
+ const {spend, safe, segmenter, raw, aliases} = c;
34
+ return function tokens(text, start, end, level, parts) {
35
+ // Each source boundary is stored once, alongside comparison keys and optional owners.
36
+ const keys = [], at = [start], owners = [];
37
+ const out = [keys, at, owners];
38
+ const add = (a, b, part, value) => {
39
+ if (b <= a) return;
40
+ if (!part && owners[keys.length - 1]?.name && !/\S/.test(text.slice(a, b))) at[at.length - 1] = b;
41
+ else {
42
+ at.push(b); if (part) owners[keys.length] = part;
43
+ const key = part?.name ? '\0' + part.kind + '\0' + (aliases?.get(part)?.name || part.name) : value ?? text.slice(a, b);
44
+ keys.push(level === 1 && !raw ? key.trimEnd() : key);
45
+ }
46
+ };
47
+ if (parts?.length && level === 0) {
48
+ let at = start;
49
+ for (const part of parts) {
50
+ if (!spend()) return null;
51
+ if (part.start < at || part.end <= part.start || part.end > end) continue;
52
+ if (at < part.start) add(at, part.start);
53
+ add(part.start, part.end, part); at = part.end;
54
+ }
55
+ add(at, end); return out;
56
+ }
57
+ const pattern = level === 1 ? raw ? /[^\n]*\n|[^\n]+$/g : /[^\n]*\n(?:\r?\n)*|[^\n]+$/g : level === 2 ? /[\p{L}\p{M}\p{N}_]+|[^\S\r\n]+|\r\n|[^]/gu : /[^]/gu;
58
+ pattern.lastIndex = start;
59
+ let match;
60
+ while (pattern.lastIndex < end && (match = pattern.exec(text))) {
61
+ if (!spend()) return null;
62
+ const a = match.index, b = Math.min(end, pattern.lastIndex);
63
+ if (keys.length && !safe(text, a)) {
64
+ at[at.length - 1] = b; keys[keys.length - 1] = text.slice(at[at.length - 2], b);
65
+ } else if (level === 2 && b - a > 1 && SPACELESS.test(match[0])) {
66
+ for (const [c, d] of words(segmenter, text.slice(a, b))) { if (!spend()) return null; add(a + c, a + d); }
67
+ } else add(a, b, undefined, b === pattern.lastIndex ? match[0] : undefined);
68
+ if (b === end) break;
69
+ }
70
+ return out;
71
+ };
72
+ }
@@ -0,0 +1,219 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {words, letter, within, prev, next} from './tokens.mjs';
3
+
4
+ // Exact source records for a range: lines, then words, then characters, or structural parts when supplied.
5
+ export function walker(c) {
6
+ const {before, after, spend, safe, budget, raw, chars, code, segmenter, tokens, matches} = c;
7
+ function record(a0, a1, b0, b1, unit, part, coarse = false) {
8
+ if (a1 - a0 === b1 - b0 && before.slice(a0, a1) === after.slice(b0, b1)) return;
9
+ // Internal records: before, after, coarse, concern, transported fragments.
10
+ c.result.push([[a0, a1], [b0, b1], coarse, [a0 === a1 ? 2 : b0 === b1 ? 0 : 1, part?.kind || unit, part?.name]]);
11
+ }
12
+
13
+ // A line deletion or insertion holding whole named parts and only blank text besides is one record per part,
14
+ // cut at line starts, so each keeps its own name (and its callers' breaks).
15
+ function name(from, ownersA, ownersB) {
16
+ for (let r = from; r < c.result.length; r++) {
17
+ const [[a0, a1], [b0, b1]] = c.result[r], side = a0 === a1 ? 1 : b0 === b1 ? 0 : -1;
18
+ if (side < 0) continue;
19
+ const [s0, s1] = c.result[r][side], text = side ? after : before;
20
+ const parts = (side ? ownersB : ownersA).filter(part => part?.name && part.start >= s0 && part.end <= s1);
21
+ if (!parts.length || !spend(s1 - s0)) continue;
22
+ let blank = text.slice(s0, parts[0].start);
23
+ for (let k = 1; k <= parts.length; k++) blank += text.slice(parts[k - 1].end, parts[k]?.start ?? s1);
24
+ if (/\S/.test(blank)) continue;
25
+ const rows = parts.map((part, k) => {
26
+ const start = k ? Math.max(parts[k - 1].end, text.lastIndexOf('\n', part.start - 1) + 1) : s0;
27
+ return [start, part];
28
+ });
29
+ c.result.splice(r, 1, ...rows.map(([start, part], k) => {
30
+ const end = rows[k + 1]?.[0] ?? s1, here = [side ? [a0, a0] : [start, end], side ? [start, end] : [b0, b0]];
31
+ return [here[0], here[1], false, [side ? 2 : 0, part.kind, part.name]];
32
+ }));
33
+ r += rows.length - 1;
34
+ }
35
+ }
36
+
37
+ // A search step scans at most one fixed-size source chunk.
38
+ function common(a, b, end, step) {
39
+ let n = 0;
40
+ while (n < end && spend()) {
41
+ const stop = Math.min(end, n + 256);
42
+ while (n < stop && before.charCodeAt(a) === after.charCodeAt(b)) { n++; a += step; b += step; }
43
+ if (n < stop) break;
44
+ }
45
+ return n;
46
+ }
47
+
48
+ function walk(a0, a1, b0, b1, level, ap, bp, context) {
49
+ if (a1 - a0 === b1 - b0 && before.slice(a0, a1) === after.slice(b0, b1)) return;
50
+ if (!spend()) { record(a0, a1, b0, b1, 'block', context, true); return; }
51
+ const structural = level <= 0 && level > -128 && (ap?.length || bp?.length);
52
+ if (!structural && raw !== 'word') {
53
+ let n = common(a0, b0, Math.min(a1 - a0, b1 - b0), 1);
54
+ while (n && (!safe(before, a0 + n) || !safe(after, b0 + n) || !raw && level < 3 && (within(before, a0 + n) || within(after, b0 + n)))) {
55
+ if (!spend()) { n = 0; break; } n--;
56
+ }
57
+ if (level === 1 && n) {
58
+ const next = Math.max(0, before.lastIndexOf('\n', a0 + n - 1) + 1 - a0);
59
+ spend(Math.ceil((n - next) / 256)); n = next;
60
+ }
61
+ a0 += n; b0 += n;
62
+ n = common(a1 - 1, b1 - 1, Math.min(a1 - a0, b1 - b0), -1);
63
+ while (n && (!safe(before, a1 - n) || !safe(after, b1 - n) || !raw && level < 3 && (within(before, a1 - n) || within(after, b1 - n)))) {
64
+ if (!spend()) { n = 0; break; } n--;
65
+ }
66
+ if (level === 1) while (n && (before[a1 - n - 1] !== '\n' || after[b1 - n - 1] !== '\n' || before[a1 - n] === '\n' || after[b1 - n] === '\n')) { if (!spend()) { n = 0; break; } n--; }
67
+ a1 -= n; b1 -= n;
68
+ if (budget.left < 0) { record(a0, a1, b0, b1, 'block', context, true); return; }
69
+ }
70
+ let unit = level >= 3 ? 'char' : level === 2 ? 'word' : 'line';
71
+ if (!raw && level === 2 && a1 - a0 + b1 - b0 <= 4 && [...before.slice(a0, a1), ...after.slice(b0, b1)].length <= 2 &&
72
+ letter(prev(before, a0) || prev(after, b0)) && letter(next(before, a1) || next(after, b1))) unit = 'char';
73
+ if (!structural && (a0 === a1 || b0 === b1)) { record(a0, a1, b0, b1, unit, context); return; }
74
+ const left = tokens(before, a0, a1, structural ? 0 : Math.max(1, level), ap);
75
+ const right = tokens(after, b0, b1, structural ? 0 : Math.max(1, level), bp);
76
+ if (!left || !right) { record(a0, a1, b0, b1, unit, context, true); return; }
77
+ const [a, x, ownersA] = left, [b, y, ownersB] = right, pairs = matches(a, b);
78
+ let ai = 0, bi = 0;
79
+ for (let i = 0; i <= a.length; i++) {
80
+ const j = i === a.length ? b.length : pairs[i];
81
+ if (j === undefined) continue;
82
+ const x0 = x[ai], x1 = x[i], y0 = y[bi], y1 = y[j];
83
+ if (ai < i || bi < j) {
84
+ if (budget.left < 0 || pairs.coarse?.has(ai)) record(x0, x1, y0, y1, unit, context, true);
85
+ else if (structural && (ai === i || bi === j)) {
86
+ for (let k = ai; k < i; k++) record(x[k], x[k + 1], y0, y0, unit, ownersA[k] || context);
87
+ for (let k = bi; k < j; k++) record(x1, x1, y[k], y[k + 1], unit, ownersB[k] || context);
88
+ } else if (structural) {
89
+ const p = ownersA[ai], q = ownersB[bi];
90
+ if (i - ai === 1 && j - bi === 1 && p && q && p.kind === q.kind) walk(x0, x1, y0, y1, level - 1, p.children, q.children, q);
91
+ else {
92
+ const from = c.result.length;
93
+ walk(x0, x1, y0, y1, 1, null, null, context);
94
+ name(from, ownersA.slice(ai, i), ownersB.slice(bi, j));
95
+ }
96
+ } else if (level < 2 && raw !== 'line') lines(x.slice(ai, i + 1), y.slice(bi, j + 1), context);
97
+ else if (level === 2 && chars && !raw && i - ai <= 1 && j - bi <= 1 && x1 - x0 + y1 - y0 < 100) {
98
+ // A changed token is replaced whole unless the caller asks for the characters inside it.
99
+ walk(x0, x1, y0, y1, 3, null, null, context);
100
+ } else record(x0, x1, y0, y1, unit, context);
101
+ }
102
+ if (i < a.length) {
103
+ const p = ownersA[i], q = ownersB[j];
104
+ if ((p || q || level <= 1 && !raw) && before.slice(x[i], x[i + 1]) !== after.slice(y[j], y[j + 1]))
105
+ walk(x[i], x[i + 1], y[j], y[j + 1], p || q ? level - 1 : 3, p?.children, q?.children, q || p || context);
106
+ }
107
+ ai = i + 1; bi = j + 1;
108
+ }
109
+ }
110
+
111
+ // Unequal lines pair in order by shared words. A line sharing under a quarter of its words with every candidate is
112
+ // replaced whole: an inserted line never borrows a neighbour's words, and a rewrite never reads as stray fragments.
113
+ function lines(x, y, context) {
114
+ const n = x.length - 1, m = y.length - 1;
115
+ if (n * m > 4096 || !spend(n * m + x[n] - x[0] + y[m] - y[0])) return row(x[0], x[n], y[0], y[m], context);
116
+ // A renamed name's sites count as shared words.
117
+ if (!c.renames) {
118
+ c.renames = [[], []];
119
+ for (const [p, old] of c.aliases || []) if (before.slice(...old.nameAt) !== after.slice(...p.nameAt)) {
120
+ c.renames[0].push(old.nameAt, ...c.la.filter(l => l.key === old.key).map(l => l.at));
121
+ c.renames[1].push(p.nameAt, ...c.lb.filter(l => l.key === p.key).map(l => l.at));
122
+ }
123
+ }
124
+ const weight = (side, a, b) => {
125
+ const counts = new Map(), text = side ? after : before;
126
+ for (const [c, d] of words(segmenter, text.slice(a, b))) counts.set(text.slice(a + c, a + d), (counts.get(text.slice(a + c, a + d)) || 0) + d - c);
127
+ return [counts, [...counts.values()].reduce((n, v) => n + v, 0), c.renames[side].reduce((n, at) => n + Math.max(0, Math.min(b, at[1]) - Math.max(a, at[0])), 0)];
128
+ };
129
+ const left = x.slice(0, -1).map((at, k) => weight(0, at, x[k + 1])), right = y.slice(0, -1).map((at, k) => weight(1, at, y[k + 1]));
130
+ const best = Array.from({length: n + 1}, () => new Float64Array(m + 1)), pairs = [];
131
+ for (let k = 1; k <= n; k++) for (let l = 1; l <= m; l++) {
132
+ const [a, sa, ra] = left[k - 1], [b, sb, rb] = right[l - 1];
133
+ let shared = sa || sb ? Math.max(ra, rb) : 0.5;
134
+ for (const [key, v] of a) shared += Math.min(v, b.get(key) || 0);
135
+ best[k][l] = Math.max(best[k - 1][l], best[k][l - 1], shared * 4 > Math.max(sa, sb) && best[k - 1][l - 1] + shared);
136
+ }
137
+ for (let k = n, l = m; k && l;) {
138
+ if (best[k][l] === best[k - 1][l]) k--;
139
+ else if (best[k][l] === best[k][l - 1]) l--;
140
+ else pairs.unshift([--k, --l]);
141
+ }
142
+ let k = 0, l = 0;
143
+ for (const [p, q] of [...pairs, [n, m]]) {
144
+ // A merge compares one line against one line by character, whatever they share.
145
+ if (raw === 'char' && p - k === 1 && q - l === 1) row(x[k], x[p], y[l], y[q], context);
146
+ else if (k < p || l < q) record(x[k], x[p], y[l], y[q], 'line', context);
147
+ if (p < n) row(x[p], x[p + 1], y[q], y[q + 1], context);
148
+ k = p + 1; l = q + 1;
149
+ }
150
+ }
151
+
152
+ // A table row with the same number of cells on both sides changes cell by cell; a character diff stays in its line.
153
+ function row(a0, a1, b0, b1, context) {
154
+ if (raw === 'char') return walk(a0, a1, b0, b1, 3, null, null, context);
155
+ const cells = (text, a, b) => /^[ \t]*\|/.test(text.slice(a, b)) && [...text.slice(a, b).matchAll(/(?<!\\)\|/g)].map(m => a + m.index);
156
+ const p = !code && cells(before, a0, a1), q = p && cells(after, b0, b1);
157
+ if (!q || p.length !== q.length || !spend(p.length)) return walk(a0, a1, b0, b1, 2, null, null, context);
158
+ for (let k = 0; k <= p.length; k++) walk(k ? p[k - 1] + 1 : a0, p[k] ?? a1, k ? q[k - 1] + 1 : b0, q[k] ?? b1, 2, null, null, context);
159
+ }
160
+
161
+ // Surviving source pieces keep the occurrence witnessed by the host.
162
+ function operations(rows) {
163
+ if (!Array.isArray(rows)) return null;
164
+ const pieces = [[0, before.length]];
165
+ let length = before.length, steps = 1e7;
166
+ const verify = n => {
167
+ if ((steps -= n) >= 0) return true;
168
+ budget.left = -1; return false;
169
+ };
170
+ const split = at => {
171
+ let position = 0;
172
+ for (let i = 0; i < pieces.length; i++) {
173
+ const [start, end, text] = pieces[i], size = end - start;
174
+ if (position === at) return i;
175
+ if (position + size > at) {
176
+ const n = at - position;
177
+ pieces.splice(i, 1, [start, start + n, text], [start + n, end, text]);
178
+ return i + 1;
179
+ }
180
+ position += size;
181
+ }
182
+ return pieces.length;
183
+ };
184
+ for (const row of rows) {
185
+ if (!row || !Number.isSafeInteger(row.pos) || typeof row.removed !== 'string' || typeof row.inserted !== 'string' ||
186
+ row.pos < 0 || row.pos + row.removed.length > length || !verify(pieces.length * 2 + 1)) return null;
187
+ const left = split(row.pos), right = split(row.pos + row.removed.length);
188
+ let at = 0;
189
+ for (let i = left; i < right; i++) {
190
+ const [start, end, text = before] = pieces[i];
191
+ if (!row.removed.startsWith(text.slice(start, end), at)) return null;
192
+ at += end - start;
193
+ }
194
+ if (row.removed === row.inserted) continue;
195
+ pieces.splice(left, right - left, [0, row.inserted.length, row.inserted]);
196
+ length += row.inserted.length - row.removed.length;
197
+ }
198
+ if (length !== after.length) return null;
199
+ const spans = [];
200
+ let a = 0, b = 0, at = 0;
201
+ for (const [start, end, text] of pieces) {
202
+ const size = end - start;
203
+ if (!verify(1) || !after.startsWith((text ?? before).slice(start, end), at)) return null;
204
+ if (text === undefined) {
205
+ let x = start, y = at, tail = end;
206
+ while (x < tail && (!safe(before, x) || !safe(after, y))) { x++; y++; if (!verify(1)) return null; }
207
+ while (tail > x && (!safe(before, tail) || !safe(after, at + tail - start))) { tail--; if (!verify(1)) return null; }
208
+ if (tail > x) {
209
+ if (a !== x || b !== y) spans.push([a, x, b, y]);
210
+ a = tail; b = at + tail - start;
211
+ }
212
+ }
213
+ at += size;
214
+ }
215
+ if (a !== before.length || b !== after.length) spans.push([a, before.length, b, after.length]);
216
+ return spans;
217
+ }
218
+ return {record, name, walk, operations};
219
+ }
@@ -27,12 +27,34 @@ function* checkedRecords(ledger, prepared) {
27
27
  }
28
28
  if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
29
29
  }
30
+ // A ledger object read again is proved once: a boot reads one stored history through several records. The proof holds only
31
+ // while that object still carries the exact proved data (its start text compared whole, the rest as canonical JSON), so a
32
+ // changed byte is proved again, and refused. The proof lives as long as the object does.
33
+ const proofs = new WeakMap();
30
34
  function readLedgerState(input, expectedText, expectedMetadata) {
31
35
  const ledger = plainData(input);
36
+ // The identifier rapier-ledger/2 is unchanged and is not backward compatible. This reader
37
+ // admits an empty physical splice whose authored rows name the same replacement. A reader
38
+ // from before that rule rejects the chain. Mixed-version sync throws notes_history_unavailable
39
+ // and leaves the older store unchanged; that refusal is not a held or caughtUp result.
32
40
  if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
33
41
  typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
34
42
  typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
35
43
  !exactKeys(ledger.start, ['text','revision','root','sha256','metadata']) || !exactKeys(ledger.head, ['revision','root','sha256','metadata'])) throw fault('unsupported or invalid envelope');
44
+ const shell = canonicalJSON({...ledger, start: {...ledger.start, text: ''}});
45
+ let proof = typeof input === 'object' ? proofs.get(input) : undefined;
46
+ if (!proof || proof.shell !== shell || proof.startText !== ledger.start.text) {
47
+ proof = {shell, startText: ledger.start.text, ...proveLedger(ledger)};
48
+ if (typeof input === 'object') proofs.set(input, proof);
49
+ }
50
+ proofs.set(ledger, proof);
51
+ const {text, metadata, root, revision, prepared, source} = proof;
52
+ if (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata)))
53
+ throw fault('metadata does not describe this document');
54
+ if (expectedText !== undefined && text !== expectedText) throw fault('history does not describe this document');
55
+ return {value: {ledger, text, metadata: {...metadata}, root, revision, complete: ledger.complete}, prepared, source};
56
+ }
57
+ function proveLedger(ledger) {
36
58
  const {sha256: claimed, ...payload} = ledger;
37
59
  if (claimed !== digest(payload)) throw fault('envelope checksum differs');
38
60
  let text = documentText(ledger.start.text), root = ledger.start.root, revision = ledger.start.revision;
@@ -55,12 +77,18 @@ function readLedgerState(input, expectedText, expectedMetadata) {
55
77
  try { authoredHistory(ledger.start.text, ledger.records); } catch (_) { throw fault('authored origin differs'); }
56
78
  }
57
79
  const metadata = prepared.replayMetadata(ledger.start.metadata);
58
- if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata) ||
59
- (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata))))
60
- throw fault('metadata does not describe this document');
61
- if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text) ||
62
- (expectedText !== undefined && text !== expectedText)) throw fault('history does not describe this document');
63
- return {value: {ledger, text, metadata, root, revision, complete: ledger.complete}, prepared, source};
80
+ if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata)) throw fault('metadata does not describe this document');
81
+ if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text)) throw fault('history does not describe this document');
82
+ return {text, metadata, root, revision, prepared, source};
83
+ }
84
+ // A stored copy may hold the head text alone. Its start is that text with every record reversed; readLedger proves it.
85
+ export function ledgerWithStart(input, text) {
86
+ let source = new HistorySource(documentText(text));
87
+ for (let i = input.records.length - 1; i >= 0; i--) {
88
+ source = source.apply(input.records[i].splices, true);
89
+ if (source === null) throw fault('history does not reverse from this document');
90
+ }
91
+ return {...input, start: {...input.start, text: source.slice()}};
64
92
  }
65
93
  export function readLedger(input, expectedText, expectedMetadata) {
66
94
  return readLedgerState(input, expectedText, expectedMetadata).value;
@@ -163,9 +191,7 @@ function finish(producer) {
163
191
  return step.value;
164
192
  }
165
193
  export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
166
- // Both synchronous public export and cooperative recovery use the same producer.
167
194
  // Its returned facts were computed while admitting/hashing the exact head, never supplied.
168
- export function prepareLedger(input) { return finish(produceLedger(input)); }
169
195
  export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
170
196
  const producer = produceLedger(input);
171
197
  let step;
@@ -182,8 +208,8 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
182
208
  return step.value;
183
209
  }
184
210
  // Derived navigation, never a second history. A target older than start is intentionally unavailable.
185
- export function historyEnvelope(input, segmentIdentity = []) {
186
- const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input);
211
+ export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
212
+ const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input, expectedText, expectedMetadata);
187
213
  const {byId, effects: {pictures}} = prepared;
188
214
  let branch = [], cursor = 0;
189
215
  for (const {transaction: tx} of ledger.records) {
@@ -198,7 +224,7 @@ export function historyEnvelope(input, segmentIdentity = []) {
198
224
  }
199
225
  const step = (source, id, redo) => {
200
226
  const target = byId.get(id), folded = pictures.size
201
- ? pictureHistorySplices({source: source.slice(), records: prepared.records}, target, redo) : null;
227
+ ? pictureHistorySplices({get source() { return source.slice(); }, records: prepared.records}, target, redo) : null;
202
228
  return source.apply(folded || target.splices, !folded && !redo);
203
229
  };
204
230
  let traversed = source, traversedMetadata = {...metadata};
@@ -30,13 +30,11 @@ const K = new Uint32Array([
30
30
  0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
31
31
  ]);
32
32
  const rotate = (x, n) => (x >>> n) | (x << (32 - n));
33
- // A bounded encoder and one compression block. No full UTF-8 or padded-input copy.
34
- // Updates retain a trailing high surrogate so an arbitrary string boundary stays exact.
35
- export function createSHA256() {
33
+ // One bounded SHA-256 compression state for file bytes and encoded source text.
34
+ export function sha256State() {
36
35
  const h = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
37
- const w = new Uint32Array(64), block = new Uint8Array(64), encoded = new Uint8Array(65536);
38
- const encoder = new TextEncoder();
39
- let used = 0, bytes = 0, high = '', finished = false;
36
+ const w = new Uint32Array(64), block = new Uint8Array(64);
37
+ let used = 0, bytes = 0, finished = false;
40
38
  const compress = (input, offset) => {
41
39
  for (let i = 0; i < 16; i++) {
42
40
  const at = offset + i * 4;
@@ -65,13 +63,37 @@ export function createSHA256() {
65
63
  for (; at + 64 <= size; at += 64) compress(input, at);
66
64
  if (at < size) { block.set(input.subarray(at, size), 0); used = size - at; }
67
65
  };
66
+ return {
67
+ update(input) {
68
+ if (finished || !(input instanceof Uint8Array)) throw new TypeError('SHA-256 needs bytes before finish');
69
+ if (!Number.isSafeInteger(bytes + input.length)) throw new Error('SHA-256 input length is not exact');
70
+ feed(input, input.length);
71
+ },
72
+ finish() {
73
+ if (finished) throw new Error('SHA-256 is already finished');
74
+ finished = true;
75
+ block[used++] = 0x80; block.fill(0, used);
76
+ if (used > 56) { compress(block, 0); block.fill(0); }
77
+ const view = new DataView(block.buffer);
78
+ view.setUint32(56, Math.floor(bytes / 0x20000000)); view.setUint32(60, (bytes * 8) >>> 0);
79
+ compress(block, 0);
80
+ return Array.from(h, x => x.toString(16).padStart(8, '0')).join('');
81
+ },
82
+ get bytes() { return bytes; },
83
+ };
84
+ }
85
+ // UTF-16 chunks share that byte state. Retain a trailing high surrogate so a
86
+ // caller can split a character between updates without changing its UTF-8 digest.
87
+ export function createSHA256() {
88
+ const hash = sha256State(), encoded = new Uint8Array(65536), encoder = new TextEncoder();
89
+ let high = '', finished = false;
68
90
  const write = text => {
69
91
  for (let at = 0; at < text.length;) {
70
92
  // Bound both the substring and byte allocation; encodeInto never cuts a scalar.
71
93
  let end = Math.min(text.length, at + 32768);
72
94
  if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
73
95
  const part = encoder.encodeInto(text.slice(at, end), encoded);
74
- feed(encoded, part.written); at += part.read;
96
+ hash.update(encoded.subarray(0, part.written)); at += part.read;
75
97
  }
76
98
  };
77
99
  return {
@@ -85,15 +107,9 @@ export function createSHA256() {
85
107
  if (finished) throw new Error('SHA-256 is finished');
86
108
  if (high) { write(high); high = ''; }
87
109
  finished = true;
88
- const bits = bytes * 8;
89
- block[used++] = 0x80; block.fill(0, used);
90
- if (used > 56) { compress(block, 0); block.fill(0); }
91
- const view = new DataView(block.buffer);
92
- view.setUint32(56, Math.floor(bits / 0x100000000)); view.setUint32(60, bits >>> 0);
93
- compress(block, 0);
94
- return Array.from(h, x => x.toString(16).padStart(8, '0')).join('');
110
+ return hash.finish();
95
111
  },
96
- get bytes() { return bytes; },
112
+ get bytes() { return hash.bytes; },
97
113
  };
98
114
  }
99
115
  export function sha256Parts(parts) {