rapier-html 1.1.104 → 1.2.3

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.1.104",
3
+ "version": "1.2.3",
4
4
  "path": "dist/rapier.html",
5
- "bytes": 3565876,
6
- "sha256": "11ef86ddc14ffd60276d7266331ae27bd47c3a4a395aedf8c8601a3a395ae5d9",
7
- "builtAt": "2026-10-12T17:02:11.693Z",
5
+ "bytes": 5086203,
6
+ "sha256": "864fe7a94b5c3064653e2a7423449f439c0ab864062ec1644a88dee90c042af6",
7
+ "builtAt": "2026-10-14T16:53:28.260Z",
8
8
  "node": "v22.23.3",
9
9
  "canonical": true,
10
10
  "packing": "zopfli",
@@ -14,32 +14,39 @@
14
14
  "delivery": "built-in",
15
15
  "kind": "script",
16
16
  "element": "rapier-plugin-math",
17
- "bytes": 714051,
18
- "packedBytes": 495012,
17
+ "bytes": 749536,
18
+ "packedBytes": 504166,
19
19
  "files": [
20
20
  {
21
21
  "id": "rapier-math",
22
- "name": "rapier-math-1.1.0.js",
23
- "version": "1.1.0",
24
- "bytes": 714051,
25
- "sri": "dYIPrqCf4rrTcAKw1Zzzo7vujbNtk2C7Zik/tIYOaLVT0U66WOXcGNE6waFbKych"
22
+ "name": "rapier-math-2.2.0.js",
23
+ "version": "2.2.0",
24
+ "bytes": 749536,
25
+ "sri": "pzYte7azbAoS8XSwIKjFg7mxGOxAxBKo9/IUy07H4j0WoyguAS/zwkR1O6kDXq9S"
26
26
  }
27
27
  ]
28
28
  },
29
29
  {
30
30
  "id": "mermaid",
31
31
  "delivery": "built-in",
32
- "kind": "script",
32
+ "kind": "files",
33
33
  "element": "rapier-plugin-mermaid",
34
- "bytes": 292169,
35
- "packedBytes": 102242,
34
+ "bytes": 7384142,
35
+ "packedBytes": 1563922,
36
36
  "files": [
37
37
  {
38
- "id": "rapier-mermaid",
39
- "name": "rapier-diagrams-1.1.104.js",
40
- "version": "1.1.104",
41
- "bytes": 292169,
42
- "sri": "b+ByE92VMwroMyB4gJIAdisjv+ONeQERVFCCP9nBfchHTUxACYXg1YNbdm2bp7FT"
38
+ "id": "rapier-mermaid-core",
39
+ "name": "mermaid.min.js",
40
+ "version": "12.1.0",
41
+ "bytes": 5493176,
42
+ "sri": "EbBpjO7rlR6eqZEcG7GaPpyk9H9WrMyPWX4d3KvPYltgt8Z8l0z6R56B1qP40pR4"
43
+ },
44
+ {
45
+ "id": "rapier-mermaid-zenuml",
46
+ "name": "rapier-zenuml-4.5.0-1.js",
47
+ "version": "4.5.0-1",
48
+ "bytes": 1890966,
49
+ "sri": "MVn/Or1nIHTDvy84lSSJAH2Fx1Y7AGoaMfd7hW7i3LJe7M/dau8VDFnF8Q+X0qOl"
43
50
  }
44
51
  ]
45
52
  },
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Turn Markdown into one offline HTML file with 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 watercolor. Agents deliver documents, show what
5
- changed and receive the person's edited copy through the door. Start with `npx rapier-html@1.1.104 notes.md`.
5
+ changed and receive the person's edited copy through the door. Start with `npx rapier-html@1.2.3 notes.md`.
6
6
 
7
7
  Use it for a plan to rearrange, an illustrated guide to annotate, a draft to continue writing, or a revision
8
8
  to review. Text and drawings stay editable, and the file remains usable independently of a hosted workspace.
@@ -18,14 +18,14 @@ to review. Text and drawings stay editable, and the file remains usable independ
18
18
  Node 22 or newer.
19
19
 
20
20
  ```sh
21
- npx -- rapier-html@1.1.104 notes.md # the editor, notes.md inside it, written beside the file
22
- npx -- rapier-html@1.1.104 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
23
- npx -- rapier-html@1.1.104 notes.md --view notes # opens the Notes library
24
- npx -- rapier-html@1.1.104 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
25
- npx -- rapier-html@1.1.104 revised.md --compare original.md # opens showing what changed
26
- npx -- rapier-html@1.1.104 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
27
- npx -- rapier-html@1.1.104 notes.md out.html # a named output
28
- npm install rapier-html@1.1.104 # as a library: wrap, unwrap
21
+ npx -- rapier-html@1.2.3 notes.md # the editor, notes.md inside it, written beside the file
22
+ npx -- rapier-html@1.2.3 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
23
+ npx -- rapier-html@1.2.3 notes.md --view notes # opens the Notes library
24
+ npx -- rapier-html@1.2.3 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
25
+ npx -- rapier-html@1.2.3 revised.md --compare original.md # opens showing what changed
26
+ npx -- rapier-html@1.2.3 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
27
+ npx -- rapier-html@1.2.3 notes.md out.html # a named output
28
+ npm install rapier-html@1.2.3 # as a library: wrap, unwrap
29
29
  ```
30
30
 
31
31
  The first `--` is for npm, so `--help` reaches the command. It never overwrites a file.
@@ -46,8 +46,8 @@ Collaborate over MCP and WebMCP, use optional encrypted sync to your own Cloudfl
46
46
  editor and lightweight reader (about 190 kB gzipped). The agent edits live; the person sees its presence and
47
47
  changes, taps a change to see the before and undoes anything. Use `show {target:"comparison"}` to show a diff deliberately.
48
48
 
49
- 1. **Work together live.** Connect to `https://mcp.rapier.website/mcp`, then call `open` to share one document with the person.
50
- 2. **Deliver an offline editor.** Run `npx rapier-html@1.1.104 notes.md` to put the document and editor in one HTML file ([rapier-html](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-html)).
49
+ 1. **Work together live.** Connect to `https://mcp.rapier.website/door/mcp`, then call `open` to share one document with the person.
50
+ 2. **Deliver an offline editor.** Run `npx rapier-html@1.2.3 notes.md` to put the document and editor in one HTML file ([rapier-html](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-html)).
51
51
  3. **Embed in your app.** Install [rapier-embed](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-embed); mount the editor or reader, with your app's agent over WebMCP in the editor.
52
52
  4. **Keep portable Markdown.** Use [rapier-markdown-kit](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-markdown-kit) for pictures, editable SVG drawings and layout in one `.md` file. Respect [Will/1](https://github.com/jackskip22/will) `keep`, `append` and `edit` regions.
53
53
  5. **Encode JPEG XL.** Install [rapier-jxl](https://github.com/jackskip22/rapier-jxl) to encode pixels, photographs and existing JPEGs in JavaScript.
@@ -21,8 +21,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
22
 
23
23
  Files under this licence carry `SPDX-License-Identifier: MIT` in their first lines: the standard's
24
- grammar, marks and reference style (`spec/md-layout.mjs`, `spec/md-marks.mjs`, `spec/md-source.mjs`, `spec/flowchart.mjs`, `spec/markdown-style.css`), the layout model and line planner
25
- (`layout/model.mjs`, `layout/line-plan.mjs`, `layout/typography.mjs`, `layout/preparation.mjs`),
24
+ grammar, marks and reference style (`spec/md-layout.mjs`, `spec/md-marks.mjs`, `spec/md-source.mjs`, `spec/flowchart.mjs`, `spec/markdown-style.css`), ink geometry
25
+ (`spec/ink.mjs`),
26
+ the layout model and line planner (`layout/model.mjs`, `layout/line-plan.mjs`, `layout/typography.mjs`, `layout/preparation.mjs`,
27
+ `layout/prepared-paragraph.mjs`, `layout/hyphenation.mjs`, `layout/paragraph-fit.mjs`), the animated-picture reader and playback
28
+ the HTML blocks use (`images/gif.mjs`, `images/playback.mjs`),
26
29
  the portable HTML and document metadata helpers (`spec/html-blocks.mjs`, `spec/frontmatter.mjs`,
27
30
  `spec/document-settings.mjs`, `spec/document-fonts.mjs`), the Will grammar (`agent/will.mjs`), the picture appendix reader and writer
28
31
  (`spec/md-assets.mjs`), the two shared-page reference readers (`tools/read-shared-page.mjs`,
@@ -73,7 +73,7 @@ export function readDocument(source) {
73
73
  export function readBase(value) {
74
74
  if (!value || typeof value !== 'object') throw fault('comparison base SHA-256 differs from its text');
75
75
  // Capture each caller-owned field once; admission and serialization use these same scalars.
76
- const {text, sha256: digest, revision, name, by, at} = value;
76
+ const {text, sha256: digest, revision, name, by, at, fonts} = value;
77
77
  if (typeof text !== 'string' || typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest) || sha256(text) !== digest)
78
78
  throw fault('comparison base SHA-256 differs from its text');
79
79
  if (typeof revision !== 'string' || !revision || revision.length > 256 || /[\u0000-\u001f\u007f]/.test(revision))
@@ -84,7 +84,8 @@ export function readBase(value) {
84
84
  throw fault('a comparison needs its author name');
85
85
  if (typeof at !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(at) || !Number.isFinite(Date.parse(at)))
86
86
  throw fault('the time of a comparison is an ISO 8601 UTC timestamp');
87
- return {text, sha256: digest, revision, name, by, at};
87
+ if (fonts != null && typeof fonts !== 'string') throw fault('invalid comparison font resources');
88
+ return {text, sha256: digest, revision, name, by, at, ...(fonts ? {fonts} : {})};
88
89
  }
89
90
  const escapeAttribute = value => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
90
91
  const readAttribute = value => value.replace(/&(quot|lt|gt|amp);/g, (_, name) => ({quot: '"', lt: '<', gt: '>', amp: '&'})[name]);
@@ -93,7 +94,8 @@ export function writeBase(text, value) {
93
94
  const base = readBase(value);
94
95
  if (base.text === text) return '';
95
96
  return '<script type="text/markdown" id="rapier-base"' + ['name', 'sha256', 'revision', 'by', 'at']
96
- .map(key => ' data-' + key + '="' + escapeAttribute(base[key]) + '"').join('') + '>' + encodeCarried(base.text) + '</script>';
97
+ .map(key => ' data-' + key + '="' + escapeAttribute(base[key]) + '"').join('') +
98
+ (base.fonts ? ' data-fonts="' + escapeAttribute(JSON.stringify(base.fonts)) + '"' : '') + '>' + encodeCarried(base.text) + '</script>';
97
99
  }
98
100
  export function readBaseElements(elements) {
99
101
  const rows = [...elements].filter(element => element.id === 'rapier-base');
@@ -102,7 +104,10 @@ export function readBaseElements(elements) {
102
104
  if (rows.length !== 1 || row.type !== 'text/markdown' || (row.tagName && row.tagName.toLowerCase() !== 'script'))
103
105
  throw fault('duplicate or executable comparison base');
104
106
  const get = name => row.getAttribute ? row.getAttribute('data-' + name) : row[name];
105
- return readBase({text: decodeCarried(row.textContent), ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at'].map(key => [key, get(key)]))});
107
+ let fonts;
108
+ try { const value = get('fonts'); if (value != null) fonts = JSON.parse(value); }
109
+ catch (_) { throw fault('invalid comparison font resources'); }
110
+ return readBase({text: decodeCarried(row.textContent), fonts, ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at'].map(key => [key, get(key)]))});
106
111
  }
107
112
  export function takeBase(html) {
108
113
  const elements = [];
@@ -115,7 +120,7 @@ export function takeBase(html) {
115
120
  seen.add(key); values[key] = value;
116
121
  }
117
122
  elements.push({id: values.id, type: values.type, textContent: content,
118
- ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at'].map(key => [key, values['data-' + key]]))});
123
+ ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at', 'fonts'].map(key => [key, values['data-' + key]]))});
119
124
  return '';
120
125
  });
121
126
  return {html: stripped, base: readBaseElements(elements)};
@@ -68,7 +68,7 @@ export function diff(before, after, options = {}) {
68
68
  const initial = budget.left, raw = options._unit;
69
69
  let result = [], code = options.as === 'code', structures, pa, pb, la, lb, aliases;
70
70
  const spend = (n = 1) => (budget.left -= n) >= 0;
71
- const letter = c => c && /[\p{L}\p{M}\p{N}_]/u.test(c);
71
+ const letter = c => c && /[\p{L}\p{M}\p{N}_]/u.test(c), within = (text, at) => letter(text[at - 1]) && letter(text[at]);
72
72
  const overlaps = (a, b) => a[0] < b[1] && a[1] > b[0];
73
73
  const inside = (c, a, b) => [a, b].every((range, side) => c[side][0] === c[side][1] ||
74
74
  c[side][0] >= range[0] && c[side][1] <= range[1]);
@@ -146,7 +146,11 @@ export function diff(before, after, options = {}) {
146
146
  if (!count) continue;
147
147
  // A chain is admitted only after visiting every candidate. Reject a
148
148
  // search whose mandatory visits cannot fit before allocating its nodes.
149
- if ((unique || count) > Math.min(131072 - nodes, budget.left)) { budget.left = -1; continue; }
149
+ if ((unique || count) > Math.min(131072 - nodes, budget.left)) {
150
+ // Repetitive text has too many equal pairs; a shortest script needs no anchors.
151
+ if (!shortest(a, b, a0, a1, b0, b1, found) && budget.left >= 0) (found.coarse ||= new Set()).add(a0);
152
+ continue;
153
+ }
150
154
  }
151
155
  search: for (let i = a0; i < a1; i++) {
152
156
  const list = positions[a[i]];
@@ -178,6 +182,27 @@ export function diff(before, after, options = {}) {
178
182
  return found;
179
183
  }
180
184
 
185
+ // Myers' greedy search: O((N + M) D) steps; each frontier is kept for the backward pass.
186
+ function shortest(a, b, a0, a1, b0, b1, found) {
187
+ const n = a1 - a0, m = b1 - b0, v = new Int32Array(2 * (n + m) + 3), o = n + m + 1, trace = [];
188
+ for (let d = 0; d <= 2048 && spend(2 * d + 1); d++) {
189
+ trace.push(v.slice(o - d, o + d + 1));
190
+ for (let k = -d; k <= d; k += 2) {
191
+ let x = k === -d || k !== d && v[o + k - 1] < v[o + k + 1] ? v[o + k + 1] : v[o + k - 1] + 1, y = x - k;
192
+ while (x < n && y < m && a[a0 + x] === b[b0 + y] && spend()) { x++; y++; }
193
+ v[o + k] = x;
194
+ if (x < n || y < m) continue;
195
+ for (x = n, y = m; d >= 0; d--) {
196
+ const w = trace[d], k = x - y, prev = k === -d || k !== d && w[d + k - 1] < w[d + k + 1] ? k + 1 : k - 1;
197
+ const px = d ? w[d + prev] : 0, py = px - prev;
198
+ while (x > px && y > py) if (a[a0 + --x] === b[b0 + --y]) found[a0 + x] = b0 + y;
199
+ x = px; y = py;
200
+ }
201
+ return true;
202
+ }
203
+ }
204
+ }
205
+
181
206
  // A search step scans at most one fixed-size source chunk.
182
207
  function common(a, b, end, step) {
183
208
  let n = 0;
@@ -195,14 +220,18 @@ export function diff(before, after, options = {}) {
195
220
  const structural = level <= 0 && level > -128 && (ap?.length || bp?.length);
196
221
  if (!structural && raw !== 'word') {
197
222
  let n = common(a0, b0, Math.min(a1 - a0, b1 - b0), 1);
198
- while (n && (!safe(before, a0 + n) || !safe(after, b0 + n))) { if (!spend()) { n = 0; break; } n--; }
223
+ while (n && (!safe(before, a0 + n) || !safe(after, b0 + n) || !raw && level < 3 && (within(before, a0 + n) || within(after, b0 + n)))) {
224
+ if (!spend()) { n = 0; break; } n--;
225
+ }
199
226
  if (level === 1 && n) {
200
227
  const next = Math.max(0, before.lastIndexOf('\n', a0 + n - 1) + 1 - a0);
201
228
  spend(Math.ceil((n - next) / 256)); n = next;
202
229
  }
203
230
  a0 += n; b0 += n;
204
231
  n = common(a1 - 1, b1 - 1, Math.min(a1 - a0, b1 - b0), -1);
205
- while (n && (!safe(before, a1 - n) || !safe(after, b1 - n))) { if (!spend()) { n = 0; break; } n--; }
232
+ while (n && (!safe(before, a1 - n) || !safe(after, b1 - n) || !raw && level < 3 && (within(before, a1 - n) || within(after, b1 - n)))) {
233
+ if (!spend()) { n = 0; break; } n--;
234
+ }
206
235
  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--; }
207
236
  a1 -= n; b1 -= n;
208
237
  if (budget.left < 0) { record(a0, a1, b0, b1, 'block', context, true); return; }
@@ -220,7 +249,7 @@ export function diff(before, after, options = {}) {
220
249
  if (j === undefined) continue;
221
250
  const x0 = x[ai], x1 = x[i], y0 = y[bi], y1 = y[j];
222
251
  if (ai < i || bi < j) {
223
- if (budget.left < 0) record(x0, x1, y0, y1, unit, context, true);
252
+ if (budget.left < 0 || pairs.coarse?.has(ai)) record(x0, x1, y0, y1, unit, context, true);
224
253
  else if (structural && (ai === i || bi === j)) {
225
254
  for (let k = ai; k < i; k++) record(x[k], x[k + 1], y0, y0, unit, ownersA[k] || context);
226
255
  for (let k = bi; k < j; k++) record(x1, x1, y[k], y[k + 1], unit, ownersB[k] || context);
@@ -233,12 +262,9 @@ export function diff(before, after, options = {}) {
233
262
  const start = result.length;
234
263
  walk(x0, x1, y0, y1, 3, null, null, context);
235
264
  const edits = result.slice(start);
265
+ // A letter-sized correction stays a letter; any other word change marks the whole word.
236
266
  if (edits.length !== 1 || edits[0][0][1] - edits[0][0][0] + edits[0][1][1] - edits[0][1][0] > Math.max(2, (x1 - x0 + y1 - y0) / 3)) {
237
- const reading = [[x0, x1], [y0, y1]], group = [edits[0][3][0], context?.kind || 'word', edits[0][3][2], reading];
238
- for (const edit of edits) {
239
- if (group[0] !== edit[3][0]) group[0] = 1;
240
- edit[3] = group;
241
- }
267
+ result.length = start; record(x0, x1, y0, y1, unit, context);
242
268
  }
243
269
  } else record(x0, x1, y0, y1, unit, context);
244
270
  }
@@ -328,6 +354,23 @@ export function diff(before, after, options = {}) {
328
354
  else walk(0, before.length, 0, after.length, structures ? 0 : raw === 'char' ? 3 : raw === 'word' ? 2 : 1,
329
355
  structures?.[0]?.parts, structures?.[1]?.parts);
330
356
 
357
+ // A separator-only piece beside another edit joins it; equal separators at its ends stay unchanged.
358
+ if (!raw) for (let i = result.length - 1; i > 0; i--) {
359
+ const p = result[i - 1], q = result[i], side = p[0][0] === p[0][1] ? 1 : p[1][0] === p[1][1] ? 0 : -1, text = side ? after : before;
360
+ if (side >= 0 && !/\S/.test(text.slice(...p[side]))) {
361
+ let n = 0;
362
+ while (p[0][1] + n < q[0][0] && text[p[side][0] + n] === text[p[side][1] + n]) n++;
363
+ 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)))
364
+ for (const span of p.slice(0, 2)) { span[0] += n; span[1] += n; }
365
+ }
366
+ if (p[2] || q[2] || p[0][1] !== q[0][0] || p[1][1] !== q[1][0] ||
367
+ ![p, q].some(r => !/\S/.test(before.slice(...r[0]) + after.slice(...r[1])))) continue;
368
+ 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;
369
+ while (x[0] < x[1] && y[0] < y[1] && /\s/.test(before[x[1] - 1]) && before[x[1] - 1] === after[y[1] - 1] &&
370
+ safe(before, x[1] - 1) && safe(after, y[1] - 1)) { x[1]--; y[1]--; }
371
+ keep[3][0] = x[0] === x[1] ? 2 : y[0] === y[1] ? 0 : 1;
372
+ result.splice(i - 1, 2, [x, y, false, keep[3]]);
373
+ }
331
374
  try { if (!raw && budget.left >= 0) {
332
375
  const check = n => { if (!spend(n)) throw null; };
333
376
  // Split transported text at wording boundaries; positions and words own distinct edits.
@@ -353,11 +396,14 @@ export function diff(before, after, options = {}) {
353
396
  if (n * 5 > Math.max(prior.length, key.length) * 3 && n >= score) { old = n === score ? null : candidate; score = n; }
354
397
  }
355
398
  if (!old || old[4]) continue;
356
- const x = [...old[0]], y = [...item[1]], left = before.slice(...x).match(/\s*$/)[0], right = after.slice(...y).match(/\s*$/)[0];
357
- let common = 0;
358
- while (common < Math.min(left.length, right.length) && left[common] === right[common]) common++;
359
- if (common && safe(before, x[1] - left.length + common) && safe(after, y[1] - right.length + common)) {
360
- x[1] -= left.length - common; y[1] -= right.length - common;
399
+ // Separators that only one end carries stay outside the transported text.
400
+ const x = [...old[0]], y = [...item[1]];
401
+ for (const end of [0, 1]) {
402
+ const pattern = end ? /\s*$/ : /^\s*/, left = before.slice(...x).match(pattern)[0], right = after.slice(...y).match(pattern)[0];
403
+ let common = 0;
404
+ while (common < Math.min(left.length, right.length) && left.at(end ? common : -1 - common) === right.at(end ? common : -1 - common)) common++;
405
+ const dx = (left.length - common) * (end ? -1 : 1), dy = (right.length - common) * (end ? -1 : 1);
406
+ if (safe(before, x[end] + dx) && safe(after, y[end] + dy)) { x[end] += dx; y[end] += dy; }
361
407
  }
362
408
  const outer = result; result = [];
363
409
  walk(...x, ...y, 1);
@@ -371,7 +417,8 @@ export function diff(before, after, options = {}) {
371
417
  if (a0 < a1) old[4].push([[a0, a1], [old[1][0], old[1][0]], false, group]);
372
418
  if (b0 < b1) item[4].push([[item[0][0], item[0][0]], [b0, b1], false, group]);
373
419
  };
374
- let a = old[0][0], b = item[1][0], wording;
420
+ fragment(old[0][0], x[0], 0, 0, [0, 'char']); fragment(0, 0, item[1][0], y[0], [2, 'char']);
421
+ let a = x[0], b = y[0], wording;
375
422
  for (const edit of inner) {
376
423
  if (edit[3][3]) wording = edit[3];
377
424
  else if (a !== edit[0][0] || b !== edit[1][0]) wording = [edit[3][0], edit[3][1]];
@@ -428,6 +475,14 @@ export function diff(before, after, options = {}) {
428
475
  result = result.flatMap(item => item[4] || [item]);
429
476
  }
430
477
 
478
+ // Whole-word replacements separated only by spaces read and restore as one phrase.
479
+ if (!raw && !code) for (let i = result.length - 1; i > 0; i--) {
480
+ const p = result[i - 1], q = result[i], gap = before.slice(p[0][1], q[0][0]);
481
+ if (p[3][0] !== 1 || q[3][0] !== 1 || p[3][3] || q[3][3] || p[2] || q[2] || !/^[^\S\r\n]+$/.test(gap) ||
482
+ gap !== after.slice(p[1][1], q[1][0]) || within(before, p[0][0]) || within(after, p[1][0]) ||
483
+ within(before, q[0][1]) || within(after, q[1][1])) continue;
484
+ p[0][1] = q[0][1]; p[1][1] = q[1][1]; result.splice(i, 1);
485
+ }
431
486
  const changes = [], edits = [];
432
487
  let normalized;
433
488
  const meaning = structures?.[0]?.meaning;
@@ -453,9 +508,11 @@ export function diff(before, after, options = {}) {
453
508
  if (!raw && !group[3]) group[3] = [[...item[0]], [...item[1]]];
454
509
  if (!group[4]) {
455
510
  const context = group[3];
511
+ // Context completes a word only where an edge falls inside it.
456
512
  if (!raw) for (const [text, span] of [[before, context[0]], [after, context[1]]]) {
457
- while (span[0] && spend() && letter(text[span[0] - 1]) && safe(text, span[0] - 1)) span[0]--;
458
- while (span[1] < text.length && spend() && letter(text[span[1]]) && safe(text, span[1] + 1)) span[1]++;
513
+ const start = within(text, span[0]), end = within(text, span[1]);
514
+ while (start && span[0] && spend() && letter(text[span[0] - 1]) && safe(text, span[0] - 1)) span[0]--;
515
+ while (end && span[1] < text.length && spend() && letter(text[span[1]]) && safe(text, span[1] + 1)) span[1]++;
459
516
  }
460
517
  group[4] = []; changes.push(group);
461
518
  if (format) group[0] = 5;
@@ -463,20 +520,6 @@ export function diff(before, after, options = {}) {
463
520
  group[4].push(edits.length);
464
521
  edits.push({before: item[0], after: item[1], ...(item[2] ? {coarse: true} : {})});
465
522
  }
466
- if (!raw && !structures && !code && !evidence) for (let i = 0; i < changes.length; i++) {
467
- let end = i + 1;
468
- for (; end < changes.length; end++) {
469
- const a = changes[end - 1], b = changes[end];
470
- const gap = before.slice(a[3][0][1], b[3][0][0]);
471
- if (a[0] !== 1 || b[0] !== 1 || a[1] !== 'word' || b[1] !== 'word' ||
472
- /\S|\r|\n/.test(gap) || gap !== after.slice(a[3][1][1], b[3][1][0])) break;
473
- }
474
- if (end - i < 4) continue;
475
- const group = changes[i]; group[1] = 'sentence';
476
- for (const next of changes.splice(i + 1, end - i - 1)) {
477
- group[4].push(...next[4]); group[3][0][1] = next[3][0][1]; group[3][1][1] = next[3][1][1];
478
- }
479
- }
480
523
  if (structures && !raw && budget.left >= 0) {
481
524
  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));
482
525
  for (const change of changes) {
@@ -62,6 +62,15 @@ function readLedgerState(input, expectedText, expectedMetadata) {
62
62
  (expectedText !== undefined && text !== expectedText)) throw fault('history does not describe this document');
63
63
  return {value: {ledger, text, metadata, root, revision, complete: ledger.complete}, prepared, source};
64
64
  }
65
+ // A stored copy may hold the head text alone. Its start is that text with every record reversed; readLedger proves it.
66
+ export function ledgerWithStart(input, text) {
67
+ let source = new HistorySource(documentText(text));
68
+ for (let i = input.records.length - 1; i >= 0; i--) {
69
+ source = source.apply(input.records[i].splices, true);
70
+ if (source === null) throw fault('history does not reverse from this document');
71
+ }
72
+ return {...input, start: {...input.start, text: source.slice()}};
73
+ }
65
74
  export function readLedger(input, expectedText, expectedMetadata) {
66
75
  return readLedgerState(input, expectedText, expectedMetadata).value;
67
76
  }
@@ -182,8 +191,8 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
182
191
  return step.value;
183
192
  }
184
193
  // 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);
194
+ export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
195
+ const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input, expectedText, expectedMetadata);
187
196
  const {byId, effects: {pictures}} = prepared;
188
197
  let branch = [], cursor = 0;
189
198
  for (const {transaction: tx} of ledger.records) {
@@ -198,7 +207,7 @@ export function historyEnvelope(input, segmentIdentity = []) {
198
207
  }
199
208
  const step = (source, id, redo) => {
200
209
  const target = byId.get(id), folded = pictures.size
201
- ? pictureHistorySplices({source: source.slice(), records: prepared.records}, target, redo) : null;
210
+ ? pictureHistorySplices({get source() { return source.slice(); }, records: prepared.records}, target, redo) : null;
202
211
  return source.apply(folded || target.splices, !folded && !redo);
203
212
  };
204
213
  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) {
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Transient projections and selective inverses of the existing canonical journal.
3
- import {_rapierTransformSplices, _rapierRecordSplices, _rapierRecordMetadata, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierReplayMetadata, _rapierPrepareHistoryRecords} from './journal-records.mjs';
3
+ import {_rapierTransformSplices, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierPrepareHistoryRecords} from './journal-records.mjs';
4
4
  import {projectInverseSource, HistorySource} from './inverse-source.mjs';
5
5
  import {textRootParts, rootAfter} from './hash.mjs';
6
6
  import {authoredHistory} from './transport.mjs';
@@ -43,11 +43,15 @@ export function groupHistoryActs(acts) {
43
43
 
44
44
  // A picture's codec replacement travels with its original navigation step.
45
45
  // Match every complete replacement exactly before moving any source bytes.
46
- export function pictureHistorySplices({source, records}, target, redo) {
46
+ export function pictureHistorySplices(input, target, redo) {
47
+ const {records} = input;
47
48
  const id = target?.transaction?.id, at = records.indexOf(target);
48
49
  if (!id || at < 0 || target.transaction.operation === 'document.finish-picture' || !Array.isArray(target.splices)) return null;
49
50
  const later = records.slice(at + 1).filter(row => row?.transaction?.operation === 'document.finish-picture' && Array.isArray(row.splices) && row.splices.length);
50
51
  if (!later.length) return null;
52
+ // A caller may hold a persistent source tree: request its text only when
53
+ // a later picture replacement can actually affect this navigation step.
54
+ const source = input.source;
51
55
  const own = new Set(later.filter(row => row.transaction.sourceTransactionId === id));
52
56
  const step = redo ? target.splices : target.splices.slice().reverse().map(row => ({pos: row.pos, removed: row.inserted, inserted: row.removed}));
53
57
  const plan = moveOthers => {
@@ -116,7 +120,9 @@ function prepareHistory(input) {
116
120
  row.beforeSource = initial; row.beforeMetadata = initialMetadata;
117
121
  }
118
122
  const earliestRevision = records[0]?.transaction.baseRevision ?? revision;
119
- let root = earliestRevision === 0 ? textRootParts(initial.parts()) : null, rootRevision = earliestRevision;
123
+ // The window's owner may name its root (the source store mints it once per text); otherwise hash the initial text.
124
+ let root = earliestRevision === 0 ? (typeof input.earliestRoot === 'string' && input.earliestRoot || textRootParts(initial.parts())) : null,
125
+ rootRevision = earliestRevision;
120
126
  for (const {record, splices} of rows) {
121
127
  const hashed = Object.hasOwn(record, 'beforeHash') || Object.hasOwn(record, 'afterHash');
122
128
  if (hashed && (![record.beforeHash, record.afterHash].every(value =>
@@ -281,9 +287,15 @@ export function historyPlaces(input, blocks) {
281
287
  }
282
288
  touched.set(node, after);
283
289
  });
290
+ // Blocks one splice covers share its range (a whole-document delete maps every block to all it removed): slice each range once.
291
+ const slices = new Map(), cut = (source, side, from, to) => {
292
+ const key = side + from + ':' + to;
293
+ if (!slices.has(key)) slices.set(key, source.slice(from, to));
294
+ return slices.get(key);
295
+ };
284
296
  for (const [node, afterRange] of touched) {
285
- const beforeRange = ranges.range(node), previous = {...beforeRange, source: beforeSource.slice(beforeRange.from, beforeRange.to)},
286
- after = {...afterRange, source: afterSource.slice(afterRange.from, afterRange.to)};
297
+ const beforeRange = ranges.range(node), previous = {...beforeRange, source: cut(beforeSource, 'b', beforeRange.from, beforeRange.to)},
298
+ after = {...afterRange, source: cut(afterSource, 'a', afterRange.from, afterRange.to)};
287
299
  if (previous.source !== after.source) node.block.acts.push({...historyAct(record), before: previous, after});
288
300
  }
289
301
  }