rapier-html 1.2.33 → 1.2.42
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 +21 -21
- package/README.md +22 -13
- package/dist/ledger/data.mjs +57 -4
- package/dist/ledger/document-settings.mjs +1 -0
- package/dist/ledger/format.mjs +114 -47
- package/dist/ledger/hash.mjs +43 -2
- package/dist/ledger/history.mjs +6 -0
- package/dist/ledger/inverse-source.mjs +15 -2
- package/dist/ledger/journal-records.mjs +32 -9
- package/dist/ledger/md-assets.mjs +709 -0
- package/dist/ledger/will.mjs +239 -0
- package/dist/ledger/word-sidecar.mjs +5 -0
- package/dist/page.d.mts +2 -0
- package/dist/rapier.html +85 -84
- package/dist/wrap.mjs +11 -4
- package/package.json +1 -1
package/BUILD.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package": "rapier-html",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.42",
|
|
4
4
|
"path": "dist/rapier.html",
|
|
5
|
-
"bytes":
|
|
6
|
-
"sha256": "
|
|
7
|
-
"builtAt": "2026-10-
|
|
8
|
-
"node": "v22.23.
|
|
5
|
+
"bytes": 5828319,
|
|
6
|
+
"sha256": "12f1cc548ff4e0d95a105b0170ce2f40aed8989cc58a4937f43eacb05581833c",
|
|
7
|
+
"builtAt": "2026-10-24T21:57:08.348Z",
|
|
8
|
+
"node": "v22.23.4",
|
|
9
9
|
"canonical": true,
|
|
10
10
|
"packing": "zopfli",
|
|
11
11
|
"plugins": [
|
|
@@ -31,15 +31,15 @@
|
|
|
31
31
|
"delivery": "built-in",
|
|
32
32
|
"kind": "files",
|
|
33
33
|
"element": "rapier-plugin-mermaid",
|
|
34
|
-
"bytes":
|
|
35
|
-
"packedBytes":
|
|
34
|
+
"bytes": 720263,
|
|
35
|
+
"packedBytes": 240280,
|
|
36
36
|
"files": [
|
|
37
37
|
{
|
|
38
38
|
"id": "rapier-mermaid-core",
|
|
39
|
-
"name": "pele-1.
|
|
40
|
-
"version": "1.
|
|
41
|
-
"bytes":
|
|
42
|
-
"sri": "
|
|
39
|
+
"name": "pele-1.9.0-native-1.js",
|
|
40
|
+
"version": "1.9.0-native-1",
|
|
41
|
+
"bytes": 720263,
|
|
42
|
+
"sri": "zPE63bYrlp1ZqlwxePk6pclPq6VoxZ7o769Lz109vEo8OEPtvOl1GPrMDj0OhtVB"
|
|
43
43
|
}
|
|
44
44
|
]
|
|
45
45
|
},
|
|
@@ -48,15 +48,15 @@
|
|
|
48
48
|
"delivery": "built-in",
|
|
49
49
|
"kind": "files",
|
|
50
50
|
"element": "rapier-plugin-mermaid-zenuml",
|
|
51
|
-
"bytes":
|
|
52
|
-
"packedBytes":
|
|
51
|
+
"bytes": 420431,
|
|
52
|
+
"packedBytes": 110835,
|
|
53
53
|
"files": [
|
|
54
54
|
{
|
|
55
55
|
"id": "rapier-mermaid-zenuml",
|
|
56
|
-
"name": "rapier-zenuml-4.5.0-
|
|
57
|
-
"version": "4.5.0-
|
|
58
|
-
"bytes":
|
|
59
|
-
"sri": "/
|
|
56
|
+
"name": "rapier-zenuml-svg-4.5.0-1.js",
|
|
57
|
+
"version": "4.5.0-svg-1",
|
|
58
|
+
"bytes": 420431,
|
|
59
|
+
"sri": "Dma7QZLWfZx3gWM0ie8JpjR4KwOXqndKcIJywkdxJ2shsdw/gDRFByjGlUv+eNoQ"
|
|
60
60
|
}
|
|
61
61
|
]
|
|
62
62
|
},
|
|
@@ -145,15 +145,15 @@
|
|
|
145
145
|
"delivery": "built-in",
|
|
146
146
|
"kind": "files",
|
|
147
147
|
"element": "rapier-plugin-font-subset",
|
|
148
|
-
"bytes":
|
|
149
|
-
"packedBytes":
|
|
148
|
+
"bytes": 1207570,
|
|
149
|
+
"packedBytes": 439575,
|
|
150
150
|
"files": [
|
|
151
151
|
{
|
|
152
152
|
"id": "rapier-font-subset",
|
|
153
153
|
"name": "rapier-font-subset-3.js",
|
|
154
154
|
"version": "3",
|
|
155
|
-
"bytes":
|
|
156
|
-
"sri": "
|
|
155
|
+
"bytes": 1207570,
|
|
156
|
+
"sri": "eyBreJCziFoHQdGSPtvd0S2IRcHna191kq83/ps/rafZwWWd6reInBALyPdLQmdL"
|
|
157
157
|
}
|
|
158
158
|
]
|
|
159
159
|
}
|
package/README.md
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
# rapier-html
|
|
2
2
|
|
|
3
|
-
Turn Markdown into one offline HTML file that contains the full Rapier editor.
|
|
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.33 notes.md`.
|
|
3
|
+
Turn Markdown into one offline HTML file that contains the full Rapier editor.
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
<picture>
|
|
6
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jackskip22/rapier-plugins/main/assets/write-390-dark.png">
|
|
7
|
+
<img src="https://raw.githubusercontent.com/jackskip22/rapier-plugins/main/assets/write-390-light.png" alt="The Rapier editor on a phone: a launch plan with an agent's change marked" width="320">
|
|
8
|
+
</picture>
|
|
9
|
+
|
|
10
|
+
Try it: `npx rapier-html@1.2.42 notes.md`, then open `notes.rapier.html` in any browser.
|
|
11
|
+
Install: `npm install rapier-html@1.2.42`.
|
|
12
|
+
|
|
13
|
+
It opens on a phone or computer, with no account. People read, edit, draw and paint; agents hand over documents, show
|
|
14
|
+
what changed and get the edited copy back.
|
|
15
|
+
|
|
16
|
+
The file needs no hosted workspace.
|
|
9
17
|
|
|
10
18
|
- The page writes, saves Markdown, exports Word, PDF and a web page, and prints. It needs no account and fetches
|
|
11
19
|
nothing to open, edit or save. It is the base editor (about 1.7 MB) with the plug-ins the document displays with
|
|
12
20
|
(Math, Mermaid, and page layout for a template page); `--include draw` adds drawing and painting. Notes never travel.
|
|
21
|
+
- Where scripts don't run (a mail preview, a strict viewer), the file shows the document's words.
|
|
13
22
|
- `--return` adds a one-use Send back address from the agent door.
|
|
14
23
|
- `--view draw` opens on Draw. `--compare original.md` opens the edit with its diff; `--by <name>` attributes it.
|
|
15
24
|
- `writePage(text, name)` makes the page in one call; `unwrap` gives the document back, exactly.
|
|
@@ -17,14 +26,14 @@ without any hosted workspace.
|
|
|
17
26
|
Needs Node 22 or newer.
|
|
18
27
|
|
|
19
28
|
```sh
|
|
20
|
-
npx -- rapier-html@1.2.
|
|
21
|
-
npx -- rapier-html@1.2.
|
|
22
|
-
npx -- rapier-html@1.2.
|
|
23
|
-
npx -- rapier-html@1.2.
|
|
24
|
-
npx -- rapier-html@1.2.
|
|
25
|
-
npx -- rapier-html@1.2.
|
|
26
|
-
npx -- rapier-html@1.2.
|
|
27
|
-
npm install rapier-html@1.2.
|
|
29
|
+
npx -- rapier-html@1.2.42 notes.md # the editor, notes.md inside it, written beside the file
|
|
30
|
+
npx -- rapier-html@1.2.42 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
|
|
31
|
+
npx -- rapier-html@1.2.42 notes.md --include draw,math # carries Draw and Math (default: what the document needs)
|
|
32
|
+
npx -- rapier-html@1.2.42 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
|
|
33
|
+
npx -- rapier-html@1.2.42 revised.md --compare original.md # opens showing what changed
|
|
34
|
+
npx -- rapier-html@1.2.42 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
|
|
35
|
+
npx -- rapier-html@1.2.42 notes.md out.html # a named output
|
|
36
|
+
npm install rapier-html@1.2.42 # as a library: writePage, unwrap
|
|
28
37
|
```
|
|
29
38
|
|
|
30
39
|
The first `--` is for npm, so `--help` reaches the command. It never overwrites a file.
|
package/dist/ledger/data.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export function fields(value, depth, active, sorted = true) {
|
|
|
17
17
|
}
|
|
18
18
|
export function scalar(value) {
|
|
19
19
|
if (value === null || typeof value === 'boolean') return true;
|
|
20
|
-
if (typeof value === 'string') { if (
|
|
20
|
+
if (typeof value === 'string') { if (!value.isWellFormed()) throw fault('unpaired surrogate'); return true; }
|
|
21
21
|
return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0);
|
|
22
22
|
}
|
|
23
23
|
export function plainData(value) {
|
|
@@ -39,16 +39,18 @@ function* quoted(text, whole = true) {
|
|
|
39
39
|
let end = Math.min(text.length, at + 16384);
|
|
40
40
|
if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
|
|
41
41
|
const part = text.slice(at, end);
|
|
42
|
-
if (whole &&
|
|
42
|
+
if (whole && !part.isWellFormed()) throw fault('unpaired surrogate');
|
|
43
43
|
yield JSON.stringify(part).slice(1, -1); at = end;
|
|
44
44
|
}
|
|
45
45
|
yield '"';
|
|
46
46
|
}
|
|
47
|
-
function* dataParts(value, depth, active, sorted) {
|
|
47
|
+
function* dataParts(value, depth, active, sorted, known = null) {
|
|
48
48
|
if (typeof value === 'string') { yield* quoted(value); return; }
|
|
49
49
|
if (value === null || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) {
|
|
50
50
|
yield JSON.stringify(value); return;
|
|
51
51
|
}
|
|
52
|
+
const text = known?.(value, depth);
|
|
53
|
+
if (text !== undefined) { yield text; return; }
|
|
52
54
|
const {array, descriptors, keys} = fields(value, depth, active, sorted);
|
|
53
55
|
active.add(value);
|
|
54
56
|
yield array ? '[' : '{';
|
|
@@ -56,14 +58,65 @@ function* dataParts(value, depth, active, sorted) {
|
|
|
56
58
|
if (i) yield ',';
|
|
57
59
|
const key = keys[i];
|
|
58
60
|
if (!array) { yield* quoted(key, false); yield ':'; }
|
|
59
|
-
yield* dataParts(descriptors[key].value, depth + 1, active, sorted);
|
|
61
|
+
yield* dataParts(descriptors[key].value, depth + 1, active, sorted, known);
|
|
60
62
|
}
|
|
61
63
|
yield array ? ']' : '}';
|
|
62
64
|
active.delete(value);
|
|
63
65
|
}
|
|
64
66
|
export function canonicalParts(value, depth = 0, active = new Set()) { return dataParts(value, depth, active, true); }
|
|
65
67
|
export function jsonParts(value) { return dataParts(value, 0, new Set(), false); }
|
|
68
|
+
export function jsonPartsKnown(value, known) { return dataParts(value, 0, new Set(), false, known); }
|
|
69
|
+
export function jsonTextAt(value, depth) { return Array.from(dataParts(value, depth, new Set(), false)).join(''); }
|
|
66
70
|
export function canonicalJSON(value, depth = 0, active = new Set()) { return Array.from(canonicalParts(value, depth, active)).join(''); }
|
|
71
|
+
const ownedRows = new WeakSet(), rowCanonical = new Map();
|
|
72
|
+
let rowCanonicalChars = 0;
|
|
73
|
+
const ROW_CANONICAL_LIMIT = 32 * 1024 * 1024;
|
|
74
|
+
function freezeData(value) {
|
|
75
|
+
if (!value || typeof value !== 'object' || ownedRows.has(value) || ownedLedgers.has(value)) return value;
|
|
76
|
+
for (const key of Object.keys(value)) freezeData(value[key]);
|
|
77
|
+
return Object.freeze(value);
|
|
78
|
+
}
|
|
79
|
+
export function ownRow(row) { if (!ownedRows.has(row)) { freezeData(row); ownedRows.add(row); } return row; }
|
|
80
|
+
const ownedLedgers = new WeakSet();
|
|
81
|
+
export function ownLedger(ledger) {
|
|
82
|
+
if (ledger && typeof ledger === 'object' && !ownedLedgers.has(ledger)) { freezeData(ledger); ownedLedgers.add(ledger); }
|
|
83
|
+
return ledger;
|
|
84
|
+
}
|
|
85
|
+
export function isOwnedLedger(ledger) { return !!ledger && ownedLedgers.has(ledger); }
|
|
86
|
+
const rowText = new WeakMap();
|
|
87
|
+
function canonicalRow(row, depth) {
|
|
88
|
+
if (!ownedRows.has(row)) return canonicalJSON(row, depth);
|
|
89
|
+
const own = rowText.get(row);
|
|
90
|
+
if (own?.depth === depth) return own.json;
|
|
91
|
+
const json = keyedRow(row, depth);
|
|
92
|
+
rowText.set(row, {depth, json});
|
|
93
|
+
return json;
|
|
94
|
+
}
|
|
95
|
+
function keyedRow(row, depth) {
|
|
96
|
+
const id = typeof row.transaction?.id === 'string' ? row.transaction.id : null, seen = JSON.stringify(row);
|
|
97
|
+
const hit = id === null ? null : rowCanonical.get(id);
|
|
98
|
+
if (hit && hit.depth === depth && hit.seen.includes(seen)) return hit.json;
|
|
99
|
+
const json = canonicalJSON(row, depth);
|
|
100
|
+
if (id !== null && 2 * seen.length + json.length <= ROW_CANONICAL_LIMIT / 4) {
|
|
101
|
+
const size = entry => entry.seen.reduce((sum, text) => sum + text.length, entry.json.length);
|
|
102
|
+
const seenAlso = hit && hit.depth === depth && hit.json === json ? [hit.seen.at(-1), seen] : [seen];
|
|
103
|
+
if (hit) { rowCanonical.delete(id); rowCanonicalChars -= size(hit); }
|
|
104
|
+
const entry = {depth, seen: seenAlso, json};
|
|
105
|
+
rowCanonical.set(id, entry); rowCanonicalChars += size(entry);
|
|
106
|
+
for (const [key, value] of rowCanonical) {
|
|
107
|
+
if (rowCanonicalChars <= ROW_CANONICAL_LIMIT) break;
|
|
108
|
+
rowCanonical.delete(key); rowCanonicalChars -= size(value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return json;
|
|
112
|
+
}
|
|
113
|
+
export function* canonicalRowsParts(rows, depth = 0) {
|
|
114
|
+
const {array, keys} = fields(rows, depth, new Set());
|
|
115
|
+
if (!array) { yield* canonicalParts(rows, depth); return; }
|
|
116
|
+
yield '[';
|
|
117
|
+
for (let i = 0; i < keys.length; i++) { if (i) yield ','; yield canonicalRow(rows[keys[i]], depth + 1); }
|
|
118
|
+
yield ']';
|
|
119
|
+
}
|
|
67
120
|
export function exactKeys(value, keys) {
|
|
68
121
|
return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
69
122
|
}
|
|
@@ -129,6 +129,7 @@ function paperOf(value) {
|
|
|
129
129
|
return pixels && width <= 10000 && height <= 10000 ? Object.freeze({name, width: width * 15, height: height * 15, css: width + 'px ' + height + 'px'}) : null;
|
|
130
130
|
}
|
|
131
131
|
export const pageSizeOf = settings => settings?.papersize || PAPERS.letter;
|
|
132
|
+
export const paperForLanguage = language => /(?:^|[-_])(?:US|CA|MX|PH|CL|CO|VE|CR|GT)$/i.test(String(language || '')) ? PAPERS.letter : PAPERS.a4;
|
|
132
133
|
export const PAGE_SIZES = Object.freeze(['letter', 'legal', 'tabloid', 'a3', 'a4', 'a5', '1080x1080px', '1080x1350px', '1920x1080px', '1200x630px']);
|
|
133
134
|
const EMPTY = Object.freeze({
|
|
134
135
|
title: null, subtitle: null, lang: null, fontsize: null, mainfont: null, sansfont: null, monofont: null, defaultfont: null, linestretch: null,
|
package/dist/ledger/format.mjs
CHANGED
|
@@ -3,10 +3,12 @@ import {_rapierPrepareHistoryRecords, _rapierValidMetadata, _rapierTransformMeta
|
|
|
3
3
|
import {pictureHistorySplices} from './history.mjs';
|
|
4
4
|
import {HistorySource} from './source.mjs';
|
|
5
5
|
import {authoredHistory} from './transport.mjs';
|
|
6
|
-
import {textRoot, rootAfter, sha256,
|
|
7
|
-
import {canonicalJSON, canonicalParts, plainData, documentText, utf8Length, exactKeys, nonnegative, fault} from './data.mjs';
|
|
6
|
+
import {textRoot, rootAfter, sha256, sha256PartsAsync, createSHA256} from './hash.mjs';
|
|
7
|
+
import {canonicalJSON, canonicalParts, canonicalRowsParts, ownRow, ownLedger, isOwnedLedger, plainData, documentText, utf8Length, exactKeys, nonnegative, fault} from './data.mjs';
|
|
8
|
+
import {diff} from './diff/diff.mjs';
|
|
9
|
+
import {minimalHistorySplice} from './inverse-source.mjs';
|
|
10
|
+
import {evaluateWill} from './will.mjs';
|
|
8
11
|
export {textRoot, rootAfter};
|
|
9
|
-
const digest = value => sha256Parts(canonicalParts(value));
|
|
10
12
|
const rootShape = value => typeof value === 'string' && /^\d+:[a-z0-9]+:[a-z0-9]+$/.test(value);
|
|
11
13
|
function* checkedRecords(ledger, prepared) {
|
|
12
14
|
if (!prepared.ok || ledger.records.length !== prepared.records.length) throw fault('record chain differs');
|
|
@@ -25,43 +27,77 @@ function* checkedRecords(ledger, prepared) {
|
|
|
25
27
|
}
|
|
26
28
|
if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
|
|
27
29
|
}
|
|
30
|
+
function* envelopeParts(envelope) {
|
|
31
|
+
const keys = Object.keys(envelope).sort();
|
|
32
|
+
yield '{';
|
|
33
|
+
for (let i = 0; i < keys.length; i++) {
|
|
34
|
+
if (i) yield ',';
|
|
35
|
+
yield JSON.stringify(keys[i]); yield ':';
|
|
36
|
+
yield* keys[i] === 'records' ? canonicalRowsParts(envelope.records, 1) : canonicalParts(envelope[keys[i]], 1, new Set());
|
|
37
|
+
}
|
|
38
|
+
yield '}';
|
|
39
|
+
}
|
|
28
40
|
const proofs = new WeakMap();
|
|
29
|
-
function
|
|
30
|
-
const ledger
|
|
41
|
+
function ownedRows(ledger) {
|
|
42
|
+
if (ledger && Array.isArray(ledger.records)) for (const row of ledger.records) if (row && typeof row === 'object') ownRow(row);
|
|
43
|
+
return ledger;
|
|
44
|
+
}
|
|
45
|
+
function copiedLedger(input, proof) {
|
|
46
|
+
if (!proof || !input || typeof input !== 'object') return ownedRows(plainData(input));
|
|
47
|
+
const fields = Object.getOwnPropertyDescriptors(input), start = fields.start;
|
|
48
|
+
if (!start || !Object.hasOwn(start, 'value') || !start.value || typeof start.value !== 'object') return ownedRows(plainData(input));
|
|
49
|
+
const parts = Object.getOwnPropertyDescriptors(start.value), text = parts.text;
|
|
50
|
+
if (!text || !Object.hasOwn(text, 'value') || text.value !== proof.startText) return ownedRows(plainData(input));
|
|
51
|
+
const shell = Object.create(Object.getPrototypeOf(input), {...fields, start: {...start,
|
|
52
|
+
value: Object.create(Object.getPrototypeOf(start.value), {...parts, text: {...text, value: ''}})}});
|
|
53
|
+
const ledger = ownedRows(plainData(shell));
|
|
54
|
+
ledger.start.text = proof.startText;
|
|
55
|
+
return ledger;
|
|
56
|
+
}
|
|
57
|
+
function ledgerAdmission(input) {
|
|
58
|
+
const held = input && typeof input === 'object' ? proofs.get(input) : undefined;
|
|
59
|
+
if (held && isOwnedLedger(input)) return {ledger: input, shell: held.shell, proof: held};
|
|
60
|
+
const ledger = copiedLedger(input, held);
|
|
31
61
|
if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
|
|
32
62
|
typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
|
|
33
63
|
typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
|
|
34
64
|
!exactKeys(ledger.start, ['text','revision','root','sha256','metadata']) || !exactKeys(ledger.head, ['revision','root','sha256','metadata'])) throw fault('unsupported or invalid envelope');
|
|
35
|
-
const shell =
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
proofs.set(ledger, proof);
|
|
65
|
+
const shell = Array.from(envelopeParts({...ledger, start: {...ledger.start, text: ''}})).join('');
|
|
66
|
+
const proof = held?.shell === shell && held.startText === ledger.start.text ? held : null;
|
|
67
|
+
return {ledger, shell, proof};
|
|
68
|
+
}
|
|
69
|
+
function ledgerResult(ledger, proof, expectedText, expectedMetadata) {
|
|
70
|
+
proofs.set(ledger, proof); ownLedger(ledger);
|
|
42
71
|
const {text, metadata, root, revision, prepared, source} = proof;
|
|
43
72
|
if (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata)))
|
|
44
73
|
throw fault('metadata does not describe this document');
|
|
45
74
|
if (expectedText !== undefined && text !== expectedText) throw fault('history does not describe this document');
|
|
46
|
-
return {value: {ledger, text, metadata: {...metadata}, root, revision, complete: ledger.complete}, prepared, source};
|
|
75
|
+
return {value: {ledger, text, metadata: {...metadata}, root, revision, complete: ledger.complete}, prepared, source, proof};
|
|
76
|
+
}
|
|
77
|
+
function readLedgerState(input, expectedText, expectedMetadata) {
|
|
78
|
+
const {ledger, shell, proof: held} = ledgerAdmission(input);
|
|
79
|
+
const proof = held || {shell, startText: ledger.start.text, ...finish(proveLedger(ledger))};
|
|
80
|
+
if (typeof input === 'object') proofs.set(input, proof);
|
|
81
|
+
return ledgerResult(ledger, proof, expectedText, expectedMetadata);
|
|
47
82
|
}
|
|
48
|
-
function proveLedger(ledger) {
|
|
83
|
+
function* proveLedger(ledger, native = false) {
|
|
49
84
|
const {sha256: claimed, ...payload} = ledger;
|
|
50
|
-
if (claimed !==
|
|
51
|
-
let text =
|
|
85
|
+
if (claimed !== (yield* checksum(envelopeParts(payload), native))) throw fault('envelope checksum differs');
|
|
86
|
+
let text = ledger.start.text, root = ledger.start.root, revision = ledger.start.revision;
|
|
87
|
+
const startFacts = yield* sourceFacts(text, native);
|
|
52
88
|
if (!_rapierValidMetadata(ledger.start.metadata) || !_rapierValidMetadata(ledger.head.metadata)) throw fault('invalid document metadata');
|
|
53
|
-
if (!nonnegative(revision) || !rootShape(root) || (revision === 0 && root !==
|
|
89
|
+
if (!nonnegative(revision) || !rootShape(root) || (revision === 0 && root !== startFacts.contentRoot) || ledger.start.sha256 !== startFacts.sha256 ||
|
|
54
90
|
(ledger.complete && revision !== 0)) throw fault('invalid start');
|
|
55
91
|
const prepared = _rapierPrepareHistoryRecords(ledger.records);
|
|
56
92
|
if (!prepared.ok) throw fault('record chain differs');
|
|
57
|
-
let source = new HistorySource(text), bytes =
|
|
93
|
+
let source = new HistorySource(text), bytes = startFacts.utf8Bytes;
|
|
58
94
|
for (const record of checkedRecords(ledger, prepared)) {
|
|
59
95
|
const next = source.apply(record.splices);
|
|
60
96
|
if (next === null) throw fault('splice does not replay');
|
|
61
97
|
source = next;
|
|
62
98
|
for (const row of record.splices) bytes += utf8Length(row.inserted) - utf8Length(row.removed);
|
|
63
99
|
if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
|
|
64
|
-
root = record.afterHash; revision = record.transaction.revision;
|
|
100
|
+
root = record.afterHash; revision = record.transaction.revision; yield;
|
|
65
101
|
}
|
|
66
102
|
text = source.slice();
|
|
67
103
|
if (ledger.records.some(row => Object.hasOwn(row, 'authored'))) {
|
|
@@ -69,7 +105,8 @@ function proveLedger(ledger) {
|
|
|
69
105
|
}
|
|
70
106
|
const metadata = prepared.replayMetadata(ledger.start.metadata);
|
|
71
107
|
if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata)) throw fault('metadata does not describe this document');
|
|
72
|
-
|
|
108
|
+
const headFacts = ledger.records.every(row => !row.splices.length) ? startFacts : yield* sourceFacts(text, native);
|
|
109
|
+
if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== headFacts.sha256) throw fault('history does not describe this document');
|
|
73
110
|
return {text, metadata, root, revision, prepared, source};
|
|
74
111
|
}
|
|
75
112
|
export function ledgerWithStart(input, text) {
|
|
@@ -83,14 +120,25 @@ export function ledgerWithStart(input, text) {
|
|
|
83
120
|
export function readLedger(input, expectedText, expectedMetadata) {
|
|
84
121
|
return readLedgerState(input, expectedText, expectedMetadata).value;
|
|
85
122
|
}
|
|
86
|
-
function
|
|
123
|
+
export async function readLedgerAsync(input, expectedText, expectedMetadata, options = {}) {
|
|
124
|
+
options.signal?.throwIfAborted();
|
|
125
|
+
const {ledger, shell, proof: held} = ledgerAdmission(input);
|
|
126
|
+
const metadata = expectedMetadata === undefined ? undefined : plainData(expectedMetadata);
|
|
127
|
+
const proof = held || {shell, startText: ledger.start.text,
|
|
128
|
+
...await finishAsync(proveLedger(ledger, !!globalThis.crypto?.subtle), options)};
|
|
129
|
+
options.signal?.throwIfAborted();
|
|
130
|
+
if (typeof input === 'object') proofs.set(input, proof);
|
|
131
|
+
return ledgerResult(ledger, proof, expectedText, metadata).value;
|
|
132
|
+
}
|
|
133
|
+
function* checksum(parts, native = false) {
|
|
134
|
+
if (native) return yield {parts};
|
|
87
135
|
const hash = createSHA256();
|
|
88
136
|
for (const part of parts) { hash.update(part); yield; }
|
|
89
137
|
return hash.digest();
|
|
90
138
|
}
|
|
91
|
-
function* sourceFacts(text) {
|
|
139
|
+
function* sourceFacts(text, native = false) {
|
|
92
140
|
if (typeof text !== 'string') throw fault('document is not whole-character text');
|
|
93
|
-
const hash = createSHA256();
|
|
141
|
+
const hash = native ? null : createSHA256();
|
|
94
142
|
let fnv = 0x811c9dc5, a = 1, b = 0, bytes = 0;
|
|
95
143
|
for (let at = 0; at < text.length;) {
|
|
96
144
|
const start = at, end = Math.min(text.length, at + 65536);
|
|
@@ -112,26 +160,34 @@ function* sourceFacts(text) {
|
|
|
112
160
|
a %= 65521; b %= 65521;
|
|
113
161
|
}
|
|
114
162
|
if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
|
|
115
|
-
hash
|
|
163
|
+
hash?.update(text.slice(start, end)); yield;
|
|
116
164
|
}
|
|
117
|
-
|
|
165
|
+
const digest = hash ? hash.digest() : yield {parts: [text], byteLength: bytes};
|
|
166
|
+
return {utf8Bytes: bytes, sha256: digest, contentRoot: text.length + ':' +
|
|
118
167
|
(fnv >>> 0).toString(36) + ':' + (((b << 16) | a) >>> 0).toString(36)};
|
|
119
168
|
}
|
|
120
|
-
|
|
169
|
+
const expandedRows = new WeakMap();
|
|
170
|
+
const sameSplices = (a, b) => a === b || a.length === b.length &&
|
|
171
|
+
a.every((row, i) => row.pos === b[i].pos && row.removed === b[i].removed && row.inserted === b[i].inserted);
|
|
172
|
+
const sameEffect = (a, b) => a === b || !!a && !!b && canonicalJSON(a) === canonicalJSON(b);
|
|
173
|
+
function* produceLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}, native = false) {
|
|
121
174
|
if (!_rapierValidMetadata(metadata)) throw fault('document metadata required');
|
|
122
175
|
metadata = {...metadata};
|
|
123
176
|
if (!Array.isArray(records) || !Array.isArray(earlier)) throw fault('records required');
|
|
124
177
|
const prepared = _rapierPrepareHistoryRecords(records, earlier);
|
|
125
178
|
if (!prepared.ok) throw fault('navigation target was not retained');
|
|
126
|
-
const headFacts = yield* sourceFacts(text);
|
|
179
|
+
const headFacts = yield* sourceFacts(text, native);
|
|
127
180
|
let bytes = headFacts.utf8Bytes;
|
|
128
181
|
const expanded = [];
|
|
129
182
|
for (const {record, splices: rows, metadata: effect} of prepared.rows) {
|
|
130
183
|
if (!rows || effect === undefined) throw fault('navigation target was not retained');
|
|
131
|
-
|
|
184
|
+
const held = expandedRows.get(record);
|
|
185
|
+
if (held && sameSplices(held.rows, rows) && sameEffect(held.effect, effect)) { expanded.push(held.row); yield; continue; }
|
|
186
|
+
expanded.push(ownRow({transaction: plainData(record.transaction), splices: rows.map(row => ({pos: row.pos, removed: row.removed, inserted: row.inserted})),
|
|
132
187
|
beforeHash: record.beforeHash, afterHash: record.afterHash, ...(Object.hasOwn(record, 'authored') ? {authored: plainData(record.authored)} : {}), ...(effect ? {metadata: plainData(effect)} : {}),
|
|
133
188
|
...(record.changeSet && Object.hasOwn(record.changeSet, 'label') ? {changeSet: {label: typeof record.changeSet.label === 'string' ? record.changeSet.label : plainData(record.changeSet.label)}} : {}),
|
|
134
|
-
...(record.derivedCommentIndex == null ? {} : {derivedCommentIndex: typeof record.derivedCommentIndex === 'string' ? record.derivedCommentIndex : plainData(record.derivedCommentIndex)})});
|
|
189
|
+
...(record.derivedCommentIndex == null ? {} : {derivedCommentIndex: typeof record.derivedCommentIndex === 'string' ? record.derivedCommentIndex : plainData(record.derivedCommentIndex)})}));
|
|
190
|
+
expandedRows.set(record, {row: expanded.at(-1), rows, effect});
|
|
135
191
|
yield;
|
|
136
192
|
}
|
|
137
193
|
const headBytes = bytes;
|
|
@@ -153,7 +209,7 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
|
|
|
153
209
|
const startRevision = expanded[0]?.transaction.baseRevision ?? revision, startRoot = expanded[0]?.beforeHash ?? root;
|
|
154
210
|
if (typeof documentAuthority !== 'string' || !documentAuthority || documentAuthority.length > 256) throw fault('unsupported or invalid envelope');
|
|
155
211
|
if (!nonnegative(startRevision) || !rootShape(startRoot)) throw fault('invalid start');
|
|
156
|
-
const startFacts = unchangedText ? headFacts : yield* sourceFacts(startText);
|
|
212
|
+
const startFacts = unchangedText ? headFacts : yield* sourceFacts(startText, native);
|
|
157
213
|
if (startRevision === 0 && startRoot !== startFacts.contentRoot) throw fault('invalid start');
|
|
158
214
|
const headSHA = headFacts.sha256, startSHA = startFacts.sha256;
|
|
159
215
|
const payload = {format: 'rapier-ledger/2', documentAuthority,
|
|
@@ -165,7 +221,11 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
|
|
|
165
221
|
}
|
|
166
222
|
const replayedMetadata = prepared.replayMetadata(startMetadata);
|
|
167
223
|
if (!replayedMetadata || canonicalJSON(replayedMetadata) !== canonicalJSON(metadata)) throw fault('metadata does not describe this document');
|
|
168
|
-
const ledger = {...payload, sha256: yield* checksum(
|
|
224
|
+
const ledger = {...payload, sha256: yield* checksum(envelopeParts(payload), native)};
|
|
225
|
+
ownLedger(ledger);
|
|
226
|
+
proofs.set(ledger, {shell: Array.from(envelopeParts({...ledger, start: {...ledger.start, text: ''}})).join(''), startText,
|
|
227
|
+
text, metadata: {...metadata}, root, revision, prepared: _rapierPrepareHistoryRecords(ledger.records),
|
|
228
|
+
source: new HistorySource(text)});
|
|
169
229
|
return {ledger, contentRoot, utf8Bytes: headBytes};
|
|
170
230
|
}
|
|
171
231
|
function finish(producer) {
|
|
@@ -174,13 +234,14 @@ function finish(producer) {
|
|
|
174
234
|
return step.value;
|
|
175
235
|
}
|
|
176
236
|
export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
let step;
|
|
237
|
+
async function finishAsync(producer, {yield: yieldWork, signal} = {}) {
|
|
238
|
+
let step, digest;
|
|
180
239
|
do {
|
|
181
240
|
signal?.throwIfAborted();
|
|
182
|
-
step = producer.next();
|
|
183
|
-
if (!step.done &&
|
|
241
|
+
step = producer.next(digest); digest = undefined;
|
|
242
|
+
if (!step.done && step.value) digest = await sha256PartsAsync(step.value.parts,
|
|
243
|
+
{yield: yieldWork, signal, byteLength: step.value.byteLength});
|
|
244
|
+
else if (!step.done && yieldWork) {
|
|
184
245
|
const pending = yieldWork();
|
|
185
246
|
if (pending) await pending;
|
|
186
247
|
}
|
|
@@ -188,8 +249,10 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
|
|
|
188
249
|
signal?.throwIfAborted();
|
|
189
250
|
return step.value;
|
|
190
251
|
}
|
|
191
|
-
export function
|
|
192
|
-
|
|
252
|
+
export function prepareLedgerAsync(input, options = {}) {
|
|
253
|
+
return finishAsync(produceLedger(input, !!globalThis.crypto?.subtle), options);
|
|
254
|
+
}
|
|
255
|
+
function historyNavigation(ledger, text, metadata, prepared, source) {
|
|
193
256
|
const {byId, effects: {pictures}} = prepared;
|
|
194
257
|
let branch = [], cursor = 0;
|
|
195
258
|
for (const {transaction: tx} of ledger.records) {
|
|
@@ -232,8 +295,13 @@ export function historyEnvelope(input, segmentIdentity = [], expectedText, expec
|
|
|
232
295
|
if (active === null || !activeMetadata || (i + 1 === cursor && (active.length !== text.length || !active.matches(0, text) || canonicalJSON(activeMetadata) !== canonicalJSON(metadata)))) throw fault('complete branch differs');
|
|
233
296
|
}
|
|
234
297
|
}
|
|
298
|
+
return {branch, cursor};
|
|
299
|
+
}
|
|
300
|
+
export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
|
|
301
|
+
const {value: {ledger, text, metadata}, prepared, source, proof} = readLedgerState(input, expectedText, expectedMetadata);
|
|
302
|
+
const {branch, cursor} = proof.navigation ??= historyNavigation(ledger, text, metadata, prepared, source);
|
|
235
303
|
return {schemaVersion: 5, documentAuthority: ledger.documentAuthority, documentRevision: ledger.head.revision,
|
|
236
|
-
ledger: ledger.records, branch, cursor, earliestRevision: ledger.start.revision, earliestHash: ledger.start.root,
|
|
304
|
+
ledger: ledger.records, branch: branch.slice(), cursor, earliestRevision: ledger.start.revision, earliestHash: ledger.start.root,
|
|
237
305
|
sourceRootId: ledger.head.root, earliestMetadata: {...ledger.start.metadata}, documentMetadata: {...metadata}, segmentIdentity, historyComplete: ledger.complete,
|
|
238
306
|
trimReason: ledger.complete ? '' : 'carried_start', trimmedBytes: 0};
|
|
239
307
|
}
|
|
@@ -249,15 +317,14 @@ export function agentActorId(door, agent) {
|
|
|
249
317
|
export function replaceLedgerText(input, text, {actor = {kind: 'system', id: 'rapier'}, operation = 'document.replace', at} = {}) {
|
|
250
318
|
const current = readLedger(input); documentText(text);
|
|
251
319
|
if (current.text === text) return current.ledger;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
const revision = current.revision + 1, root = rootAfter(current.root, splices[0]);
|
|
320
|
+
const splices = diff(current.text, text, {_unit: 'word'}).edits.map(edit =>
|
|
321
|
+
minimalHistorySplice(current.text.slice(...edit.before), text.slice(...edit.after), edit.before[0])).reverse();
|
|
322
|
+
const revision = current.revision + 1, root = splices.reduce(rootAfter, current.root);
|
|
323
|
+
if (actor.kind === 'agent' && current.metadata.docKind === 'markdown' && evaluateWill(current.text, splices).outcome !== 'applied')
|
|
324
|
+
throw fault('will_violation');
|
|
325
|
+
const identity = sha256(canonicalJSON({before: current.ledger.sha256, text, actor, operation, at: at ?? null}));
|
|
259
326
|
const record = {beforeHash: current.root, afterHash: root, splices, transaction: {
|
|
260
|
-
id: current.ledger.documentAuthority.slice(0, 48) + ':export:' +
|
|
327
|
+
id: current.ledger.documentAuthority.slice(0, 48) + ':export:' + identity,
|
|
261
328
|
documentAuthority: current.ledger.documentAuthority, baseRevision: current.revision, revision,
|
|
262
329
|
actor, transport: 'platform', operation, requestId: null, sourceTransactionId: null,
|
|
263
330
|
affectedBlockIds: [], parent: current.ledger.records.at(-1)?.transaction.id ?? null,
|
package/dist/ledger/hash.mjs
CHANGED
|
@@ -111,9 +111,50 @@ export function sha256Parts(parts) {
|
|
|
111
111
|
for (const part of parts) hash.update(part);
|
|
112
112
|
return hash.digest();
|
|
113
113
|
}
|
|
114
|
-
|
|
114
|
+
export async function sha256PartsAsync(parts, {yield: yieldWork, signal, byteLength} = {}) {
|
|
115
|
+
const encoder = new TextEncoder(), chunks = [];
|
|
116
|
+
let bytes = byteLength === undefined ? null : new Uint8Array(byteLength);
|
|
117
|
+
let high = '', length = 0;
|
|
118
|
+
const write = text => {
|
|
119
|
+
if (bytes) {
|
|
120
|
+
const result = encoder.encodeInto(text, bytes.subarray(length));
|
|
121
|
+
if (result.read !== text.length) throw new RangeError('SHA-256 byte length differs');
|
|
122
|
+
length += result.written;
|
|
123
|
+
} else {
|
|
124
|
+
const chunk = encoder.encode(text); chunks.push(chunk); length += chunk.length;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
for (const value of parts) {
|
|
128
|
+
let text = high + String(value === undefined ? '' : value); high = '';
|
|
129
|
+
if (text.length && (text.charCodeAt(text.length - 1) & 0xfc00) === 0xd800) {
|
|
130
|
+
high = text.slice(-1); text = text.slice(0, -1);
|
|
131
|
+
}
|
|
132
|
+
for (let at = 0; at < text.length;) {
|
|
133
|
+
signal?.throwIfAborted();
|
|
134
|
+
let end = Math.min(text.length, at + 32768);
|
|
135
|
+
if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 &&
|
|
136
|
+
(text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
|
|
137
|
+
write(text.slice(at, end)); at = end;
|
|
138
|
+
const pending = yieldWork?.(); if (pending) await pending;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (high) write(high);
|
|
142
|
+
if (bytes && length !== bytes.length) throw new RangeError('SHA-256 byte length differs');
|
|
143
|
+
bytes ||= new Uint8Array(length);
|
|
144
|
+
let at = 0;
|
|
145
|
+
for (const chunk of chunks) {
|
|
146
|
+
signal?.throwIfAborted(); bytes.set(chunk, at); at += chunk.length;
|
|
147
|
+
const pending = yieldWork?.(); if (pending) await pending;
|
|
148
|
+
}
|
|
149
|
+
chunks.length = 0;
|
|
150
|
+
signal?.throwIfAborted();
|
|
151
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
|
|
152
|
+
signal?.throwIfAborted();
|
|
153
|
+
return Array.from(new Uint8Array(digest), value => value.toString(16).padStart(2, '0')).join('');
|
|
154
|
+
}
|
|
155
|
+
const LONG_TEXT = 65536, DOCUMENT_TEXT = 1048576, DIGESTS = new Map();
|
|
115
156
|
export function sha256(text) {
|
|
116
|
-
if (typeof text !== 'string' || text.length < LONG_TEXT) return createSHA256().update(text).digest();
|
|
157
|
+
if (typeof text !== 'string' || text.length < LONG_TEXT || text.length >= DOCUMENT_TEXT) return createSHA256().update(text).digest();
|
|
117
158
|
let digest = DIGESTS.get(text);
|
|
118
159
|
if (digest === undefined) {
|
|
119
160
|
digest = createSHA256().update(text).digest();
|
package/dist/ledger/history.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import {sourceView, HistorySource} from './source.mjs';
|
|
|
5
5
|
import {textRootParts, rootAfter} from './hash.mjs';
|
|
6
6
|
import {authoredHistory, authoredPlacements} from './transport.mjs';
|
|
7
7
|
import {plainData, canonicalJSON} from './data.mjs';
|
|
8
|
+
import {evaluateWill} from './will.mjs';
|
|
8
9
|
|
|
9
10
|
const historyFailure = reason => ({ok: false, reason});
|
|
10
11
|
const historyInteger = value => Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
|
|
@@ -323,6 +324,8 @@ export function selectiveUndo(input, actIds, options = {}) {
|
|
|
323
324
|
(options.splitSplice !== undefined && typeof options.splitSplice !== 'function')) return historyFailure('act_invalid');
|
|
324
325
|
const requested = new Set(actIds), available = prepared.byId;
|
|
325
326
|
if ([...requested].some(id => !available.has(id))) return historyFailure('act_unavailable');
|
|
327
|
+
if (options.actor?.kind === 'agent' && [...requested].some(id => available.get(id).record.transaction.actor.kind !== 'agent'))
|
|
328
|
+
return historyFailure('act_forbidden');
|
|
326
329
|
const selected = new Set([...requested].filter(id => prepared.effects.active.has(id)));
|
|
327
330
|
const records = prepared.records;
|
|
328
331
|
let inverseId = 'planned-inverse'; while (available.has(inverseId)) inverseId += ':';
|
|
@@ -359,6 +362,9 @@ export function selectiveUndo(input, actIds, options = {}) {
|
|
|
359
362
|
sourceIndices: transported.sourceIndices.get(record.transaction.id)})) : prepared.rows;
|
|
360
363
|
const result = projectInverseSource(transported ? {...prepared, rows, records: transported.records} : prepared, selected, projectedEffects, options);
|
|
361
364
|
if (!result.ok) return result;
|
|
365
|
+
if (options.actor?.kind === 'agent' && prepared.metadata.docKind === 'markdown' &&
|
|
366
|
+
evaluateWill(prepared.source, result.splices, {restores: result.splices}).outcome !== 'applied')
|
|
367
|
+
return historyFailure('will_refused');
|
|
362
368
|
return {...result, actIds: [...requested], sourceTransactionIds: [...selected], metadata, afterMetadata,
|
|
363
369
|
effectChanged, unchanged: result.after === prepared.source && !metadata && !effectChanged};
|
|
364
370
|
} catch (_) { return historyFailure('history_invalid'); }
|