rapier-html 1.2.7 → 1.2.9

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/BUILD.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "package": "rapier-html",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "path": "dist/rapier.html",
5
- "bytes": 5886529,
6
- "sha256": "1fda3f30b4140f90c74cef0ea3fce795e9a9672ed7c37a4d393d42dfcf1de8c1",
7
- "builtAt": "2026-10-16T19:38:46.299Z",
5
+ "bytes": 6025685,
6
+ "sha256": "8ea5bfa5b5cbeba8312b6df5305162224ed00e38cdcf5e780e89839f9410a91c",
7
+ "builtAt": "2026-10-17T20:46:16.744Z",
8
8
  "node": "v22.23.3",
9
9
  "canonical": true,
10
10
  "packing": "zopfli",
@@ -14,15 +14,15 @@
14
14
  "delivery": "built-in",
15
15
  "kind": "script",
16
16
  "element": "rapier-plugin-math",
17
- "bytes": 691650,
18
- "packedBytes": 438088,
17
+ "bytes": 733943,
18
+ "packedBytes": 449582,
19
19
  "files": [
20
20
  {
21
21
  "id": "rapier-math",
22
- "name": "rapier-math-2.4.0.js",
23
- "version": "2.4.0",
24
- "bytes": 691650,
25
- "sri": "C92x26+pBI4q9FoWypMhBb8CqSGaaPLw8dpYigWiYNsYQRTcPVrFyPVjrhc/Tnwh"
22
+ "name": "rapier-math-2.6.1.js",
23
+ "version": "2.6.1",
24
+ "bytes": 733943,
25
+ "sri": "c6Y4kudfuc16Hkmvfgr0MpdQcJs1jU4qbsKu2tUSduVuQdwLQ/38rR0GmQMatLsS"
26
26
  }
27
27
  ]
28
28
  },
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Turn Markdown into one offline HTML file that contains the full Rapier editor. It opens in any browser, on a phone or
4
4
  computer, with no account. People read, edit, draw and paint; agents hand over documents, show what changed and get
5
- the edited copy back. Start with `npx rapier-html@1.2.7 notes.md`.
5
+ the edited copy back. Start with `npx rapier-html@1.2.9 notes.md`.
6
6
 
7
7
  Use it for a plan to rearrange, a guide to annotate, a draft to keep writing, or a revision to review. The file works
8
8
  without any hosted workspace.
@@ -12,19 +12,19 @@ without any hosted workspace.
12
12
  (Math, Mermaid); `--include draw` adds drawing and painting. Notes never travel.
13
13
  - `--return` adds a one-use Send back address from the agent door.
14
14
  - `--view draw` opens on Draw. `--compare original.md` opens the edit with its diff; `--by <name>` attributes it.
15
- - `wrap` and `unwrap` move a document into a page and back out, exactly.
15
+ - `writePage(text, name)` makes the page in one call; `unwrap` gives the document back, exactly.
16
16
 
17
17
  Needs Node 22 or newer.
18
18
 
19
19
  ```sh
20
- npx -- rapier-html@1.2.7 notes.md # the editor, notes.md inside it, written beside the file
21
- npx -- rapier-html@1.2.7 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
22
- npx -- rapier-html@1.2.7 notes.md --include draw,math # carries Draw and Math (default: what the document needs)
23
- npx -- rapier-html@1.2.7 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
24
- npx -- rapier-html@1.2.7 revised.md --compare original.md # opens showing what changed
25
- npx -- rapier-html@1.2.7 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
26
- npx -- rapier-html@1.2.7 notes.md out.html # a named output
27
- npm install rapier-html@1.2.7 # as a library: wrap, unwrap
20
+ npx -- rapier-html@1.2.9 notes.md # the editor, notes.md inside it, written beside the file
21
+ npx -- rapier-html@1.2.9 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
22
+ npx -- rapier-html@1.2.9 notes.md --include draw,math # carries Draw and Math (default: what the document needs)
23
+ npx -- rapier-html@1.2.9 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
24
+ npx -- rapier-html@1.2.9 revised.md --compare original.md # opens showing what changed
25
+ npx -- rapier-html@1.2.9 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
26
+ npx -- rapier-html@1.2.9 notes.md out.html # a named output
27
+ npm install rapier-html@1.2.9 # as a library: writePage, unwrap
28
28
  ```
29
29
 
30
30
  The first `--` is for npm, so `--help` reaches the command. It never overwrites a file.
@@ -60,20 +60,21 @@ current document. After expiry or a refusal, Share offers Save instead; bring th
60
60
  Save this as `example.mjs` in the folder you installed into, then run `node example.mjs`:
61
61
 
62
62
  ```js
63
- import {readFile, writeFile} from 'node:fs/promises';
64
- import {wrap, unwrap} from 'rapier-html';
63
+ import {writeFile} from 'node:fs/promises';
64
+ import {writePage, unwrap} from 'rapier-html';
65
65
 
66
- const rapier = await readFile(new URL('./rapier.html', import.meta.resolve('rapier-html')), 'utf8');
67
66
  const words = '# Hello\r\n\r\nA note about </script> and C:\\Users\\me.\n';
68
- const page = wrap(rapier, words, 'hello.md', {view: 'draw'}); // opens on Draw, the note behind it
67
+ const page = await writePage(words, 'hello.md', {view: 'draw'}); // opens on Draw, the note behind it
69
68
  console.log('words back exactly:', unwrap(page).text === words);
70
69
  await writeFile('hello.rapier.html', page, {flag: 'wx'}); // 'wx': never over an existing file
71
70
  ```
72
71
 
73
- - `wrap(rapierHtml, text, name, {view, drawing, drawingName, base, baseName, by, at, return: returnURL, return_expires_at})`
72
+ - `writePage(text, name, {include, view, drawing, drawingName, base, baseName, by, at, return: returnURL, return_expires_at})`
73
+ carries the editor with what the document displays with (`include` names the parts: `drawing`, `math`, `mermaid`).
74
+ - `wrap(rapierHtml, text, name, options)` does the same into a page you supply; `carry(rapierHtml, parts)` cuts one.
74
75
  - `unwrap(pageHtml)` returns the same fields, plus the validated `comparisonBase` record, with `null` for anything the
75
76
  page doesn't carry.
76
- - Also exported: `encodeCarried`, `decodeCarried` and `main`.
77
+ - Also exported: `portableNeeds`, `encodeCarried`, `decodeCarried` and `main`. TypeScript declarations ship in `dist/page.d.mts`.
77
78
 
78
79
  ## How the page carries the document
79
80
 
package/dist/cli.mjs CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ // SPDX-License-Identifier: MIT
2
2
  import {dirname} from 'node:path';
3
3
  import {fileURLToPath} from 'node:url';
4
4
  import {main} from './page.mjs';
@@ -1,10 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Immutable authored origins shared by causal transport and inverse projection.
3
2
  import {_rapierTransformSplices, _rapierValidAuthored} from './journal-records.mjs';
4
3
  import {sha256} from './hash.mjs';
5
4
 
6
- // The frontier names only causal maxima. Linear history needs one predecessor,
7
- // and concurrent predecessors remain separate until an act observes both.
8
5
  function authoredFrontier(records, visit = null) {
9
6
  const dependencies = new Map(), frontier = new Set();
10
7
  for (const record of records) {
@@ -38,8 +35,6 @@ export function authoredPlacements(base, records) {
38
35
  return origins;
39
36
  }
40
37
 
41
- // Immutable authored placement stays on its own act. Basis IDs refer only to this
42
- // canonical history; no source snapshot or duplicate history is stored.
43
38
  export function authoredPlacement(source, splices, basis) {
44
39
  const origin = {basis: basis.slice().sort(), source: sha256(source), splices: splices.map(row => ({...row}))};
45
40
  if (!_rapierValidAuthored(origin) || _rapierTransformSplices(source, splices) === null)
@@ -17,7 +17,6 @@ export function authorship(input) {
17
17
  const {ledger, text} = readLedger(input), references = _rapierHistoryEffects(ledger.records).references, removed = new Map();
18
18
  let runs = ledger.start.text ? [[0, ledger.start.text.length, 'system', 'unattributed', 0]] : [];
19
19
  for (const record of ledger.records) {
20
- // An Undo or Redo puts back text the act it references removed: that text keeps the runs it had.
21
20
  const tx = record.transaction, gone = [], kept = (references?.get(tx.id) || []).flatMap(id => removed.get(id) || []);
22
21
  for (const row of record.splices) {
23
22
  const cut = row.pos + row.removed.length, delta = row.inserted.length - row.removed.length, before = [], after = [], inside = [];
@@ -49,7 +48,6 @@ export function readAuthorship(input, text) {
49
48
  if (cursor !== text.length) throw fault('authorship does not cover the text');
50
49
  return value;
51
50
  }
52
- // Authorship-only imports become an incomplete insertion ledger. There is no mutable blame database.
53
51
  export function ledgerFromAuthorship(input, text, documentAuthority, metadata) {
54
52
  const value = readAuthorship(input, text), records = [];
55
53
  let current = '', root = textRoot('');
@@ -10,7 +10,6 @@ export function decodeCarried(text) {
10
10
  return String(text).replace(/\\([\\/!r0])/g, (_, c) => c === '\\' ? '\\' : c === 'r' ? '\r' : c === '0' ? '\0' : c === '/' ? '</' : '<!');
11
11
  }
12
12
  export function validateParts(text, {ledger = null, authorship = null, metadata} = {}) {
13
- // A file's leading BOM is metadata, as in the editor's source intake.
14
13
  const canonical = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
15
14
  const history = ledger === null ? null : readLedger(ledger, canonical, metadata).ledger;
16
15
  const runs = authorship === null ? null : readAuthorship(authorship, canonical);
@@ -22,7 +21,6 @@ export function writeParts(text, options) {
22
21
  return Object.entries(parts).filter(([, value]) => value !== null).map(([name, value]) =>
23
22
  '<script type="application/json" id="rapier-' + name + '">' + encodeCarried(JSON.stringify(value)) + '</script>\n').join('');
24
23
  }
25
- // Pure adapter: the browser supplies inert script elements, Node supplies parsed script records.
26
24
  export function readParts(text, elements) {
27
25
  const parts = {ledger: null, authorship: null};
28
26
  for (const element of elements) {
@@ -35,7 +33,6 @@ export function readParts(text, elements) {
35
33
  }
36
34
  return validateParts(text, parts);
37
35
  }
38
- // The wrapper only removes whole script elements. Strings inside other scripts are not carriers.
39
36
  export function takeParts(html, text) {
40
37
  const elements = [];
41
38
  const stripped = html.replace(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi, (whole, attrs, content) => {
@@ -50,7 +47,6 @@ export function takeParts(html, text) {
50
47
  return {html: stripped, ...readParts(text ?? '', elements)};
51
48
  }
52
49
  const OPEN = '\n\n<!-- rapier-carried/1\n', CLOSE = '\n-->\n';
53
- // One optional HTML comment at EOF; every other Markdown reader ignores it. Source is never rewritten.
54
50
  export function writeDocument(text, options) {
55
51
  const parts = validateParts(text, options);
56
52
  if (!parts.ledger && !parts.authorship) return text;
@@ -58,9 +54,12 @@ export function writeDocument(text, options) {
58
54
  return text + OPEN + json + CLOSE;
59
55
  }
60
56
  export function readDocument(source) {
57
+ if (!source.endsWith(CLOSE)) {
58
+ if (source.includes(OPEN)) throw fault('unfinished document metadata');
59
+ return {text: source, ledger: null, authorship: null};
60
+ }
61
61
  const at = source.lastIndexOf(OPEN);
62
62
  if (at < 0) return {text: source, ledger: null, authorship: null};
63
- if (!source.endsWith(CLOSE)) throw fault('unfinished document metadata');
64
63
  let parts;
65
64
  try { parts = JSON.parse(source.slice(at + OPEN.length, -CLOSE.length)); }
66
65
  catch (_) { throw fault('invalid document metadata'); }
@@ -69,10 +68,8 @@ export function readDocument(source) {
69
68
  return {text, ...validateParts(text, parts)};
70
69
  }
71
70
 
72
- // One portable baseline. A digest is integrity evidence, not authentication of the named author.
73
71
  export function readBase(value) {
74
72
  if (!value || typeof value !== 'object') throw fault('comparison base SHA-256 differs from its text');
75
- // Capture each caller-owned field once; admission and serialization use these same scalars.
76
73
  const {text, sha256: digest, revision, name, by, at, fonts} = value;
77
74
  if (typeof text !== 'string' || typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest) || sha256(text) !== digest)
78
75
  throw fault('comparison base SHA-256 differs from its text');
@@ -1,7 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Only plain JSON data enters the public format. Accessors and toJSON are never executed.
3
2
  export function fault(message) { return Object.assign(new Error('Ledger: ' + message), {code: 'ledger_invalid'}); }
4
- // A plain object's prototype is null or Object's own. Both lookups read descriptors, so no getter runs.
5
3
  export const plainPrototype = proto => proto === null || Object.getPrototypeOf(proto) === null &&
6
4
  Object.getOwnPropertyDescriptor(Object.getOwnPropertyDescriptor(proto, 'constructor')?.value || {}, 'name')?.value === 'Object';
7
5
  export function fields(value, depth, active, sorted = true) {
@@ -22,8 +20,6 @@ export function scalar(value) {
22
20
  if (typeof value === 'string') { if (/[\uD800-\uDFFF]/u.test(value)) throw fault('unpaired surrogate'); return true; }
23
21
  return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0);
24
22
  }
25
- // A canonical copy does not need a serialized document and a second parsed document.
26
- // Sorted insertion preserves the previous plainData result, including numeric-key ordering.
27
23
  export function plainData(value) {
28
24
  if (typeof value === 'string') value = JSON.parse(value);
29
25
  const active = new Set();
@@ -37,8 +33,6 @@ export function plainData(value) {
37
33
  };
38
34
  return copy(value, 0);
39
35
  }
40
- // Each yielded string is bounded, even when one value contains the whole document.
41
- // JSON.stringify still owns escaping; scalar boundaries keep its spelling exact.
42
36
  function* quoted(text, whole = true) {
43
37
  yield '"';
44
38
  for (let at = 0; at < text.length;) {
@@ -73,8 +67,6 @@ export function canonicalJSON(value, depth = 0, active = new Set()) { return Arr
73
67
  export function exactKeys(value, keys) {
74
68
  return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
75
69
  }
76
- // TextEncoder's exact byte count, without allocating an encoded copy. Whole-character
77
- // admission is performed in this same pass when the document owner requests it.
78
70
  export function utf8Length(text, whole = false) {
79
71
  let bytes = 0;
80
72
  for (let at = 0; at < text.length; at++) {
@@ -1,10 +1,8 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {letter, within, prev, next} from './tokens.mjs';
3
3
 
4
- // Separator-only pieces join the edit beside them; adjacent blocks each carry their own line break.
5
4
  export function separate(c, endA = c.before.length, endB = c.after.length) {
6
5
  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
6
  if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
9
7
  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
8
  if (side >= 0 && !/\S/.test(text.slice(...p[side]))) {
@@ -16,15 +14,12 @@ export function separate(c, endA = c.before.length, endB = c.after.length) {
16
14
  if (p[2] || q[2] || p[0][1] !== q[0][0] || p[1][1] !== q[1][0] ||
17
15
  ![p, q].some(r => !/\S/.test(before.slice(...r[0]) + after.slice(...r[1])))) continue;
18
16
  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
17
  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
18
  (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
19
  safe(before, x[1] - k) && safe(after, y[1] - k);) { x[1] -= k; y[1] -= k; }
23
20
  keep[3][0] = x[0] === x[1] ? 2 : y[0] === y[1] ? 0 : 1;
24
21
  c.result.splice(i - 1, 2, [x, y, false, keep[3]]);
25
22
  }
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
23
  if (!raw) for (let i = 0; i < c.result.length; i++) {
29
24
  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
25
  if (side < 0 || p[2]) continue;
@@ -38,8 +33,6 @@ export function separate(c, endA = c.before.length, endB = c.after.length) {
38
33
  if (shift && p.slice(0, 2).every((span, k) => safe(k ? after : before, span[0] + shift) && safe(k ? after : before, span[1] + shift)))
39
34
  for (const span of p.slice(0, 2)) { span[0] += shift; span[1] += shift; }
40
35
  }
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
36
  if (!raw) for (let i = 1; i < c.result.length; i++) {
44
37
  const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
45
38
  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;
@@ -48,20 +41,16 @@ export function separate(c, endA = c.before.length, endB = c.after.length) {
48
41
  }
49
42
  }
50
43
 
51
- // Refine the outer walk and each move's inner walk before assigning transport ownership.
52
44
  export function phrases(c) {
53
45
  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
46
  if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
56
47
  const p = c.result[i - 1], q = c.result[i], gap = before.slice(p[0][1], q[0][0]);
57
48
  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
49
  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
50
  if (!number && (code || !/^[^\S\r\n]+$/.test(gap) || within(before, p[0][0]) || within(after, p[1][0]) ||
61
51
  within(before, q[0][1]) || within(after, q[1][1]))) continue;
62
52
  p[0][1] = q[0][1]; p[1][1] = q[1][1]; c.result.splice(i, 1);
63
53
  }
64
- // Paired inline markers added or removed around unchanged text on one line are one concern.
65
54
  if (!raw && !code) for (let i = 1; i < c.result.length; i++) {
66
55
  const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
67
56
  if (kind % 2 || kind !== q[3][0] || p[2] || q[2] || p[3][3] || q[3][3]) continue;
@@ -72,12 +61,10 @@ export function phrases(c) {
72
61
  }
73
62
  }
74
63
 
75
- // Readable concerns: phrases, numbers and paired markers, then the public edits and changes with context.
76
64
  export function concerns(c) {
77
65
  phrases(c);
78
66
  const {before, after, spend, safe, budget, raw, code, evidence, structures, initial, options, pa, pb} = c, lb = c.lb;
79
67
  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
68
  const containing = (parts, from, to, out = []) => {
82
69
  let lo = 0, hi = parts.length;
83
70
  while (lo < hi) { const mid = lo + hi >>> 1; if (parts[mid].end < to) lo = mid + 1; else hi = mid; }
@@ -85,7 +72,6 @@ export function concerns(c) {
85
72
  if (parts[k].end >= to) { out.push(parts[k]); containing(parts[k].children, from, to, out); }
86
73
  return out;
87
74
  };
88
- // Edits are ordered and disjoint on the after side.
89
75
  const touched = (edits, range) => {
90
76
  let lo = 0, hi = edits.length;
91
77
  while (lo < hi) { const mid = lo + hi >>> 1; if (edits[mid].after[1] <= range[0]) lo = mid + 1; else hi = mid; }
@@ -116,7 +102,6 @@ export function concerns(c) {
116
102
  if (!raw && !group[3]) group[3] = [[...item[0]], [...item[1]]];
117
103
  if (!group[4]) {
118
104
  const context = group[3];
119
- // Context completes a word only where an edge falls inside it.
120
105
  if (!raw) for (const [text, span] of [[before, context[0]], [after, context[1]]]) {
121
106
  const start = within(text, span[0]), end = within(text, span[1]);
122
107
  for (let c; start && letter(c = prev(text, span[0])) && spend() && safe(text, span[0] - c.length);) span[0] -= c.length;
@@ -152,7 +137,6 @@ export function concerns(c) {
152
137
  }
153
138
  }
154
139
  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
140
  const moves = new Map(changes.map((change, i) => change[0] === 3 && [change[3], i + 1]).filter(Boolean));
157
141
  return {edits, changes: changes.map(([kind, unit, name, context, owned, affects, breaks], i) => ({
158
142
  id: i + 1, kind: ['delete', 'replace', 'insert', 'move', 'rename', 'format'][kind], unit, edits: owned,
@@ -1,6 +1,4 @@
1
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
2
  import {observe} from './structure.mjs';
5
3
  import {boundaries, tokenizer, wordSegmenter} from './tokens.mjs';
6
4
  import {matcher} from './matching.mjs';
@@ -12,10 +10,8 @@ export function diff(before, after, options = {}) {
12
10
  if (before === after) return {edits: [], changes: []};
13
11
  const budget = options._budget || {left: Number.isFinite(options.limit) ? Math.max(0, options.limit) : 1e7};
14
12
  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
13
  const c = {before, after, options, budget, spend, safe, raw, chars: options.unit === 'char', initial: budget.left,
17
14
  segmenter: wordSegmenter(), result: [], code: options.as === 'code'};
18
- // A Markdown fence holds code inside prose; only code outside fences makes the text code.
19
15
  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
16
  .test((before.slice(0, 4096) + '\n' + after.slice(0, 4096)).replace(/^ {0,3}(`{3,}|~{3,})[^]*?(?:^ {0,3}\1[`~]*[ \t]*$|(?![^]))/gm, ''));
21
17
  let fallback;
@@ -33,7 +29,6 @@ export function diff(before, after, options = {}) {
33
29
  }
34
30
  Object.assign(c, {structures, pa, pb, la: structures[0].links, lb: structures[1].links, aliases});
35
31
  } catch {
36
- // A failed reader preserves an explicit prose choice and reports why structure was not used.
37
32
  const failed = structures?.find(value => !value || value.invalid);
38
33
  fallback = {structure: failed?.threw ? 'threw' : budget.left < 0 ? 'limit' : 'invalid', ...(failed?.threw ? {error: failed.error} : {})};
39
34
  c.code = options.as !== 'prose';
@@ -48,8 +43,6 @@ export function diff(before, after, options = {}) {
48
43
  structures?.[0]?.parts, structures?.[1]?.parts);
49
44
  separate(c);
50
45
  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
46
  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
47
  budget.left >= c.initial - budget.left) {
55
48
  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);
@@ -1,5 +1,4 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // The package entry: compare two texts, or merge two edits of one base.
3
2
  import {diff} from './diff.mjs';
4
3
  import {merge} from './merge.mjs';
5
4
  export {diff, merge};
@@ -1,12 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Subsequence search over token keys, bounded by a shared work budget.
3
2
  export function matcher(spend, budget) {
4
- // Unique anchors and fallback equal pairs share one bounded subsequence search. Repetitive text, where equal pairs
5
- // are too many to visit, anchors on runs of tokens found once on each side, then on positions, then on a shortest
6
- // script; only a range none of them can afford within the work limit stays coarse.
7
3
  function matches(a, b) {
8
4
  const found = new Array(a.length), pending = [[0, a.length, 0, b.length]];
9
- // Patience chain over candidate pairs in a order; unique anchors split the range into searched gaps.
10
5
  const chain = (pairs, a0, a1, b0, b1, split, near) => {
11
6
  const tails = [];
12
7
  for (const [i, j] of pairs) {
@@ -29,7 +24,6 @@ export function matcher(spend, budget) {
29
24
  while (a0 < a1 && b0 < b1 && a[a0] === b[b0]) found[a0++] = b0++;
30
25
  while (a0 < a1 && b0 < b1 && a[a1 - 1] === b[b1 - 1]) found[--a1] = --b1;
31
26
  if (a0 === a1 || b0 === b1 || !spend(a1 - a0 + b1 - b0)) continue;
32
- // Between anchored runs the change is usually small; a token found once by chance must not misalign it.
33
27
  if (near && shortest(a, b, a0, a1, b0, b1, found, 4 * (a1 - a0 + b1 - b0))) continue;
34
28
  const positions = new Map();
35
29
  for (let j = b0; j < b1; j++) {
@@ -45,12 +39,10 @@ export function matcher(spend, budget) {
45
39
  if (list[0] === 1 && list.length === 2) unique++;
46
40
  }
47
41
  if (!count) continue;
48
- // With no unique anchors, equal-length repeated runs prefer their witnessed positions to an arbitrary LIS.
49
42
  if (!unique && a1 - a0 === b1 - b0) {
50
43
  const anchors = diagonal(a, b, a0, a1, b0, b1);
51
44
  if (anchors) { chain(anchors, a0, a1, b0, b1, true, true); continue; }
52
45
  }
53
- // Charge the chain before building it: one that cannot be afforded falls through to cheaper anchors.
54
46
  const chained = unique || count, cost = chained * Math.ceil(Math.log2(chained + 2));
55
47
  if (cost <= budget.left) {
56
48
  const pairs = [];
@@ -63,7 +55,6 @@ export function matcher(spend, budget) {
63
55
  chain(pairs, a0, a1, b0, b1, unique > 0);
64
56
  continue;
65
57
  }
66
- // Few changes make a shortest script cheap (about n + d²); try that first at a bounded cost.
67
58
  if (shortest(a, b, a0, a1, b0, b1, found, a1 - a0 + b1 - b0)) continue;
68
59
  const anchors = runs(a, b, a0, a1, b0, b1) || diagonal(a, b, a0, a1, b0, b1);
69
60
  if (anchors) chain(anchors, a0, a1, b0, b1, true, true);
@@ -72,14 +63,11 @@ export function matcher(spend, budget) {
72
63
  return found;
73
64
  }
74
65
 
75
- // Runs of k tokens found once on each side anchor text whose single tokens all repeat. Window hashes are checked
76
- // token by token, so a collision costs work, never correctness.
77
66
  function runs(a, b, a0, a1, b0, b1) {
78
67
  const ids = new Map(), id = key => ids.get(key) ?? (ids.set(key, ids.size), ids.size - 1);
79
68
  const left = Int32Array.from({length: a1 - a0}, (_, k) => id(a[a0 + k])), right = Int32Array.from({length: b1 - b0}, (_, k) => id(b[b0 + k]));
80
69
  for (const k of [16, 4, 64]) {
81
70
  if (k > Math.min(left.length, right.length) || !spend(2 * (left.length + right.length))) return;
82
- // A prime modulus below 2^31 and a base below 2^21 keep every product exact in a double.
83
71
  const P = 2147483647, B = 1000003;
84
72
  let power = 1;
85
73
  for (let n = 1; n < k; n++) power = power * B % P;
@@ -101,7 +89,6 @@ export function matcher(spend, budget) {
101
89
  while (n < k && left[i + n] === right[j + n]) n++;
102
90
  if (n === k) pairs.push([a0 + i, b0 + j]);
103
91
  }
104
- // Overlapping windows add nothing a gap's equal-run trim does not find; keep one per k tokens.
105
92
  pairs.sort((p, q) => p[0] - q[0]);
106
93
  const spaced = [];
107
94
  for (const pair of pairs) if (!spaced.length || pair[0] >= spaced.at(-1)[0] + k) spaced.push(pair);
@@ -109,16 +96,12 @@ export function matcher(spend, budget) {
109
96
  }
110
97
  }
111
98
 
112
- // Repeated lines align by position: tokens equal on the start diagonal before a split and on the end diagonal after
113
- // it, at the split that keeps the most. Used only when they cover at least half of the shorter side.
114
99
  function diagonal(a, b, a0, a1, b0, b1) {
115
100
  const n = a1 - a0, m = b1 - b0, shift = m - n, size = Math.min(n, m);
116
101
  if (!spend(2 * (n + m))) return;
117
- // head[s]: equal pairs on the start diagonal among the first s tokens; tail[s]: on the end diagonal from s on.
118
102
  const head = new Int32Array(n + 1), tail = new Int32Array(n + 2);
119
103
  for (let i = 0; i < n; i++) head[i + 1] = head[i] + (i < m && a[a0 + i] === b[b0 + i]);
120
104
  for (let i = n - 1; i >= 0; i--) tail[i] = tail[i + 1] + (i + shift >= 0 && a[a0 + i] === b[b0 + i + shift]);
121
- // A deletion (shift < 0) skips -shift tokens of a between the diagonals.
122
105
  let best = -1, split = 0;
123
106
  for (let s = 0; s <= n; s++) {
124
107
  const rest = Math.min(n, s + Math.max(0, -shift)), kept = head[s] + tail[rest];
@@ -131,8 +114,6 @@ export function matcher(spend, budget) {
131
114
  return pairs;
132
115
  }
133
116
 
134
- // Myers' greedy search: O((N + M) D) steps; each frontier is kept for the backward pass. An attempt can stop at
135
- // its own share of the work and report no script.
136
117
  function shortest(a, b, a0, a1, b0, b1, found, share = Infinity) {
137
118
  const n = a1 - a0, m = b1 - b0, v = new Int32Array(2 * (n + m) + 3), o = n + m + 1, trace = [];
138
119
  const step = (k = 1) => (share -= k) >= 0 && spend(k);
@@ -13,7 +13,6 @@ export function merge(base, mine, theirs, options = {}) {
13
13
  const check = n => { if (!spend(n)) throw null; };
14
14
  const safe = options.structure && boundaries();
15
15
  const trees = options.structure && !options._edits
16
- // A reader that throws gives no structure: the texts merge by source coordinates.
17
16
  ? [base, mine, base, theirs].map(text => observe(text, options.structure, spend, safe, false, true)).map(tree => tree?.threw ? null : tree) : [];
18
17
  const repeatedBase = trees.splice(2, 1)[0];
19
18
  const equal = (a, b) => a.length === b.length && a.every((item, i) => item === b[i]);
@@ -71,13 +70,11 @@ export function merge(base, mine, theirs, options = {}) {
71
70
  check(list.length); map.set(name, null);
72
71
  return list.map(part => content(part, side));
73
72
  });
74
- // Each occurrence keeps its base text on one side, or the whole list does.
75
73
  if (!equal(values[0], values[1]) && !equal(values[0], values[2]) && !(values.every(list => list.length === values[0].length) &&
76
74
  values[0].every((text, k) => text === values[1][k] || text === values[2][k]))) throw null;
77
75
  }
78
76
  return maps;
79
77
  };
80
- // A part renamed on one side keeps its reader key and aligns with its base part.
81
78
  const keyed = new Map(rows[0].filter(part => part.nameAt && part.key != null).map(part => [part.key, part]));
82
79
  const own = new Set(rows[0].map(part => part.id)), renamed = new Map();
83
80
  for (const side of [1, 2]) {
@@ -89,7 +86,6 @@ export function merge(base, mine, theirs, options = {}) {
89
86
  }
90
87
  const id = part => renamed.get(part) ?? part.id;
91
88
  const maps = index(id), orders = rows.map(list => list.map(id));
92
- // A vanished or added copy of a repeated name keeps the order; only a different order of shared names is a reorder.
93
89
  const within = (a, b) => { let k = 0; for (const name of b) if (name === a[k]) k++; return k === a.length; };
94
90
  const reordered = [1, 2].map(side => {
95
91
  const a = orders[0].filter(name => maps[side].has(name)), b = orders[side].filter(name => maps[0].has(name));
@@ -128,7 +124,6 @@ export function merge(base, mine, theirs, options = {}) {
128
124
  if (child) children.set(name, child);
129
125
  }
130
126
  if (frame < 0 && !children.size) return;
131
- // A plan is a tree of range triples; a string is an already-admitted gap.
132
127
  const plan = [];
133
128
  if (frame >= 0) {
134
129
  let at = nodes[frame].start;
@@ -160,7 +155,6 @@ export function merge(base, mine, theirs, options = {}) {
160
155
  if ([...removed[0].keys()].some(name => removed[1].has(name)) &&
161
156
  (added[0].size !== added[1].size || [...added[0].keys()].some(name => !added[1].has(name)))) throw null;
162
157
  if (plan) {
163
- // Project original ranges once. Nested moves never rebuild aligned documents.
164
158
  const chunks = [], conflicts = [];
165
159
  let length = 0;
166
160
  function project(item) {
@@ -183,8 +177,6 @@ export function merge(base, mine, theirs, options = {}) {
183
177
  const changes = options._edits || [mine, theirs].map((text, side) => diff(base, text,
184
178
  {...budget, structure: trees[0] && options.structure, _observed: trees[0] ? [trees[0], trees[side + 1]] : undefined}).edits);
185
179
  if (trees.some(Boolean) && budget._budget.left < 0) return complete();
186
- // An edit reaches over the words it touches; edits whose reaches overlap form one group, so letters
187
- // joined from both branches never form a word neither wrote.
188
180
  const spans = words(wordSegmenter(), base);
189
181
  const word = at => {
190
182
  let low = 0, high = spans.length;
@@ -210,7 +202,6 @@ export function merge(base, mine, theirs, options = {}) {
210
202
  row[1] = edit[1];
211
203
  if (index === edits.length) break;
212
204
  const [a, b, t, side2, from] = edits[index], peer = sides[1 - side2][2];
213
- // One branch's insertion and replacement at the same base position are one contiguous edit.
214
205
  if (!(a < end || a === start || from < reach || edits[index - 1][0] === a && edits[index - 1][3] === side2 || peer &&
215
206
  (a === end && peer[0] === end || a === b && peer[0] < end &&
216
207
  a - end < t.length && (peer[2] + base.slice(end, a)).endsWith(t)))) break;
@@ -1,7 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {separate, phrases} from './concerns.mjs';
3
3
 
4
- // Moves bind equal transported fragments at both ends; renames group a binding's sites.
5
4
  export function moves(c) {
6
5
  const {before, after, spend, safe, budget, raw, code, tokens, matches, walk, structures, pa, pb, la, aliases} = c;
7
6
  const overlaps = (a, b) => a[0] < b[1] && a[1] > b[0];
@@ -9,7 +8,6 @@ export function moves(c) {
9
8
  c[side][0] >= range[0] && c[side][1] <= range[1]);
10
9
  try { if (!raw && budget.left >= 0) {
11
10
  const check = n => { if (!spend(n)) throw null; };
12
- // Split transported text at wording boundaries; positions and words own distinct edits.
13
11
  const deleted = new Map();
14
12
  const moveKey = (item, side, text, parts = []) => {
15
13
  const [from, to] = item[side];
@@ -28,7 +26,6 @@ export function moves(c) {
28
26
  for (const item of c.result) if (item[3][0] === 2 && !item[2]) {
29
27
  const value = after.slice(...item[1]), key = moveKey(item, 1, after, pb);
30
28
  let old = deleted.get(key), score = 0;
31
- // Edited wording still travels: plain text, or a prose part of the same kind.
32
29
  const text = value.trim(), prose = !structures || /^(paragraph|item|task|row|quote|section)$/.test(item[3][1]);
33
30
  if (!old && prose && text.length >= 24) for (const candidate of deleted.values()) {
34
31
  check(1);
@@ -41,7 +38,6 @@ export function moves(c) {
41
38
  if (n * 5 > Math.max(prior.length, text.length) * 3 && n >= score) { old = n === score ? null : candidate; score = n; }
42
39
  }
43
40
  if (!old || old[4]) continue;
44
- // Separators that only one end carries stay outside the transported text.
45
41
  const x = [...old[0]], y = [...item[1]];
46
42
  for (const end of [0, 1]) {
47
43
  const pattern = end ? /\s*$/ : /^\s*/, left = before.slice(...x).match(pattern)[0], right = after.slice(...y).match(pattern)[0];
@@ -52,7 +48,6 @@ export function moves(c) {
52
48
  }
53
49
  const outer = c.result; c.result = [];
54
50
  walk(...x, ...y, 1);
55
- // A changed heading can occupy a whole line while its marker still anchors the move.
56
51
  if (c.result.length && (c.result[0][0][0] === x[0] || c.result[0][1][0] === y[0] ||
57
52
  c.result.at(-1)[0][1] === x[1] || c.result.at(-1)[1][1] === y[1])) {
58
53
  c.result = []; walk(...x, ...y, 2);
@@ -60,7 +55,6 @@ export function moves(c) {
60
55
  separate(c, x[1], y[1]);
61
56
  phrases(c);
62
57
  const inner = c.result; c.result = outer;
63
- // Equal boundary fragments make the exact extent recoverable without display context.
64
58
  if (inner.length && (inner[0][0][0] === x[0] || inner[0][1][0] === y[0] ||
65
59
  inner.at(-1)[0][1] === x[1] || inner.at(-1)[1][1] === y[1] || inner.some(c => c[2]))) continue;
66
60
  const move = [3, item[3][2] ? item[3][1] : code ? 'line' : 'block', item[3][2], [x, y]];
@@ -69,7 +63,6 @@ export function moves(c) {
69
63
  if (a0 < a1) old[4].push([[a0, a1], [old[1][0], old[1][0]], false, group]);
70
64
  if (b0 < b1) item[4].push([[item[0][0], item[0][0]], [b0, b1], false, group]);
71
65
  };
72
- // Unmatched outer separators travel with the move, so restoring it alone keeps blocks apart.
73
66
  fragment(old[0][0], x[0], 0, 0, move); fragment(0, 0, item[1][0], y[0], move);
74
67
  let a = x[0], b = y[0], wording;
75
68
  for (const edit of inner) {
@@ -1,5 +1,4 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Capture only the reader contract before another observation can reuse its objects.
3
2
  export function observe(text, reader, spend, safe, references = true, lenient = false) {
4
3
  let value;
5
4
  try { value = reader(text); } catch (error) { return {invalid: true, threw: true, error}; }
@@ -30,7 +29,6 @@ export function observe(text, reader, spend, safe, references = true, lenient =
30
29
  if (!usable && !lenient) throw null;
31
30
  const part = {start, end: stop, kind: scalar(kind), name: scalar(name), key: scalar(key, true)};
32
31
  if (nameAt != null) part.nameAt = span(nameAt, start, stop);
33
- // Discard unsafe evidence only after validating its complete subtree.
34
32
  if (retained && usable) { parts.push(part); flat.push(part); }
35
33
  part.children = children == null ? [] : copy(children, start, stop, depth + 1, retained && usable);
36
34
  }
@@ -1,12 +1,10 @@
1
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
2
  export const SPACELESS = /[\u0e00-\u0eff\u1000-\u109f\u1780-\u17ff\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/;
4
3
  export const words = (segmenter, text) => [...text.matchAll(/[\p{L}\p{N}\p{M}_]+/gu)].flatMap(word => SPACELESS.test(word[0])
5
4
  ? [...segmenter().segment(word[0])].map(s => [word.index + s.index, word.index + s.index + s.segment.length])
6
5
  : [[word.index, word.index + word[0].length]]);
7
6
  export const wordSegmenter = () => { let segmenter; return () => segmenter ||= new Intl.Segmenter('en', {granularity: 'word'}); };
8
7
 
9
- // Segment only requested non-ASCII boundaries; observations never share a global document cache.
10
8
  export function boundaries() {
11
9
  const segments = Object.create(null);
12
10
  let segmenter, last = [];
@@ -15,7 +13,6 @@ export function boundaries() {
15
13
  const a = text.charCodeAt(at - 1), b = text.charCodeAt(at);
16
14
  if ((a & 0xfc00) === 0xd800 && (b & 0xfc00) === 0xdc00 || a === 13 && b === 10) return false;
17
15
  if (a < 0x300 && b < 0x300) return true;
18
- // Retreats probe one long cluster many times; answer inside the last cluster without the segmenter.
19
16
  if (last[0] === text && at > last[1] && at < last[2]) return false;
20
17
  const found = (segments[text] ||= (segmenter ||= new Intl.Segmenter('en', {granularity: 'grapheme'})).segment(text)).containing(at);
21
18
  last = [text, found.index, found.index + found.segment.length];
@@ -23,16 +20,13 @@ export function boundaries() {
23
20
  }
24
21
  }
25
22
 
26
- // Letters are code points: an astral letter is one letter, never two lone surrogates.
27
23
  const pair = (text, at) => (text.charCodeAt(at - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(at) & 0xfc00) === 0xdc00;
28
24
  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
25
  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
26
 
31
- // Tokens at a level: 0 parts, 1 lines, 2 words, spaces and marks, 3 characters.
32
27
  export function tokenizer(c) {
33
28
  const {spend, safe, segmenter, raw, aliases} = c;
34
29
  return function tokens(text, start, end, level, parts) {
35
- // Each source boundary is stored once, alongside comparison keys and optional owners.
36
30
  const keys = [], at = [start], owners = [];
37
31
  const out = [keys, at, owners];
38
32
  const add = (a, b, part, value) => {