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 +25 -18
- package/README.md +11 -11
- package/dist/ledger/LICENSE +5 -2
- package/dist/ledger/carried.mjs +10 -5
- package/dist/ledger/diff.mjs +75 -32
- package/dist/ledger/format.mjs +12 -3
- package/dist/ledger/hash.mjs +31 -15
- package/dist/ledger/history.mjs +17 -5
- package/dist/rapier.html +75 -54
- package/package.json +1 -1
package/BUILD.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package": "rapier-html",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"path": "dist/rapier.html",
|
|
5
|
-
"bytes":
|
|
6
|
-
"sha256": "
|
|
7
|
-
"builtAt": "2026-10-
|
|
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":
|
|
18
|
-
"packedBytes":
|
|
17
|
+
"bytes": 749536,
|
|
18
|
+
"packedBytes": 504166,
|
|
19
19
|
"files": [
|
|
20
20
|
{
|
|
21
21
|
"id": "rapier-math",
|
|
22
|
-
"name": "rapier-math-
|
|
23
|
-
"version": "
|
|
24
|
-
"bytes":
|
|
25
|
-
"sri": "
|
|
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": "
|
|
32
|
+
"kind": "files",
|
|
33
33
|
"element": "rapier-plugin-mermaid",
|
|
34
|
-
"bytes":
|
|
35
|
-
"packedBytes":
|
|
34
|
+
"bytes": 7384142,
|
|
35
|
+
"packedBytes": 1563922,
|
|
36
36
|
"files": [
|
|
37
37
|
{
|
|
38
|
-
"id": "rapier-mermaid",
|
|
39
|
-
"name": "
|
|
40
|
-
"version": "
|
|
41
|
-
"bytes":
|
|
42
|
-
"sri": "
|
|
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.
|
|
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.
|
|
22
|
-
npx -- rapier-html@1.
|
|
23
|
-
npx -- rapier-html@1.
|
|
24
|
-
npx -- rapier-html@1.
|
|
25
|
-
npx -- rapier-html@1.
|
|
26
|
-
npx -- rapier-html@1.
|
|
27
|
-
npx -- rapier-html@1.
|
|
28
|
-
npm install rapier-html@1.
|
|
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.
|
|
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.
|
package/dist/ledger/LICENSE
CHANGED
|
@@ -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`),
|
|
25
|
-
(`
|
|
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`,
|
package/dist/ledger/carried.mjs
CHANGED
|
@@ -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
|
-
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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('') +
|
|
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
|
-
|
|
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)};
|
package/dist/ledger/diff.mjs
CHANGED
|
@@ -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)) {
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
while (span[
|
|
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) {
|
package/dist/ledger/format.mjs
CHANGED
|
@@ -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
|
|
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};
|
package/dist/ledger/hash.mjs
CHANGED
|
@@ -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
|
-
//
|
|
34
|
-
|
|
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)
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/ledger/history.mjs
CHANGED
|
@@ -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,
|
|
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(
|
|
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
|
-
|
|
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
|
|
286
|
-
after = {...afterRange, source: afterSource
|
|
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
|
}
|