rapier-html 1.2.32 → 1.2.40

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,11 +1,11 @@
1
1
  {
2
2
  "package": "rapier-html",
3
- "version": "1.2.32",
3
+ "version": "1.2.40",
4
4
  "path": "dist/rapier.html",
5
- "bytes": 5757325,
6
- "sha256": "942ea78a99eecf884bac10962172f899904675220334a89ab632436cdf03f17e",
7
- "builtAt": "2026-10-22T18:36:56.645Z",
8
- "node": "v22.23.3",
5
+ "bytes": 5923993,
6
+ "sha256": "b0fc95b2633a468feba1c5dc1d7e59d49bc147ff48ae50c2eaa891296eacf23e",
7
+ "builtAt": "2026-10-24T09:33:09.207Z",
8
+ "node": "v22.23.4",
9
9
  "canonical": true,
10
10
  "packing": "zopfli",
11
11
  "plugins": [
@@ -49,7 +49,7 @@
49
49
  "kind": "files",
50
50
  "element": "rapier-plugin-mermaid-zenuml",
51
51
  "bytes": 1888111,
52
- "packedBytes": 251401,
52
+ "packedBytes": 249770,
53
53
  "files": [
54
54
  {
55
55
  "id": "rapier-mermaid-zenuml",
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. It opens in any browser, on a phone or
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.32 notes.md`.
3
+ Turn Markdown into one offline HTML file that contains the full Rapier editor.
6
4
 
7
- Use it for a plan to rearrange, a guide to annotate, a draft to keep writing, or a revision to review. The file works
8
- without any hosted workspace.
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.40 notes.md`, then open `notes.rapier.html` in any browser.
11
+ Install: `npm install rapier-html@1.2.40`.
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.32 notes.md # the editor, notes.md inside it, written beside the file
21
- npx -- rapier-html@1.2.32 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
22
- npx -- rapier-html@1.2.32 notes.md --include draw,math # carries Draw and Math (default: what the document needs)
23
- npx -- rapier-html@1.2.32 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
24
- npx -- rapier-html@1.2.32 revised.md --compare original.md # opens showing what changed
25
- npx -- rapier-html@1.2.32 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
26
- npx -- rapier-html@1.2.32 notes.md out.html # a named output
27
- npm install rapier-html@1.2.32 # as a library: writePage, unwrap
29
+ npx -- rapier-html@1.2.40 notes.md # the editor, notes.md inside it, written beside the file
30
+ npx -- rapier-html@1.2.40 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
31
+ npx -- rapier-html@1.2.40 notes.md --include draw,math # carries Draw and Math (default: what the document needs)
32
+ npx -- rapier-html@1.2.40 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
33
+ npx -- rapier-html@1.2.40 revised.md --compare original.md # opens showing what changed
34
+ npx -- rapier-html@1.2.40 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
35
+ npx -- rapier-html@1.2.40 notes.md out.html # a named output
36
+ npm install rapier-html@1.2.40 # 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.
@@ -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 (/[\uD800-\uDFFF]/u.test(value)) throw fault('unpaired surrogate'); return true; }
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,7 +39,7 @@ 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 && /[\uD800-\uDFFF]/u.test(part)) throw fault('unpaired surrogate');
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 '"';
@@ -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,
@@ -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, sha256Parts, createSHA256} from './hash.mjs';
6
+ import {textRoot, rootAfter, sha256, sha256PartsAsync, createSHA256} from './hash.mjs';
7
7
  import {canonicalJSON, canonicalParts, 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');
@@ -26,42 +28,61 @@ function* checkedRecords(ledger, prepared) {
26
28
  if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
27
29
  }
28
30
  const proofs = new WeakMap();
29
- function readLedgerState(input, expectedText, expectedMetadata) {
30
- const ledger = plainData(input);
31
+ function copiedLedger(input, proof) {
32
+ if (!proof || !input || typeof input !== 'object') return plainData(input);
33
+ const fields = Object.getOwnPropertyDescriptors(input), start = fields.start;
34
+ if (!start || !Object.hasOwn(start, 'value') || !start.value || typeof start.value !== 'object') return plainData(input);
35
+ const parts = Object.getOwnPropertyDescriptors(start.value), text = parts.text;
36
+ if (!text || !Object.hasOwn(text, 'value') || text.value !== proof.startText) return plainData(input);
37
+ const shell = Object.create(Object.getPrototypeOf(input), {...fields, start: {...start,
38
+ value: Object.create(Object.getPrototypeOf(start.value), {...parts, text: {...text, value: ''}})}});
39
+ const ledger = plainData(shell);
40
+ ledger.start.text = proof.startText;
41
+ return ledger;
42
+ }
43
+ function ledgerAdmission(input) {
44
+ const held = input && typeof input === 'object' ? proofs.get(input) : undefined;
45
+ const ledger = copiedLedger(input, held);
31
46
  if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
32
47
  typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
33
48
  typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
34
49
  !exactKeys(ledger.start, ['text','revision','root','sha256','metadata']) || !exactKeys(ledger.head, ['revision','root','sha256','metadata'])) throw fault('unsupported or invalid envelope');
35
50
  const shell = canonicalJSON({...ledger, start: {...ledger.start, text: ''}});
36
- let proof = typeof input === 'object' ? proofs.get(input) : undefined;
37
- if (!proof || proof.shell !== shell || proof.startText !== ledger.start.text) {
38
- proof = {shell, startText: ledger.start.text, ...proveLedger(ledger)};
39
- if (typeof input === 'object') proofs.set(input, proof);
40
- }
51
+ const proof = held?.shell === shell && held.startText === ledger.start.text ? held : null;
52
+ return {ledger, shell, proof};
53
+ }
54
+ function ledgerResult(ledger, proof, expectedText, expectedMetadata) {
41
55
  proofs.set(ledger, proof);
42
56
  const {text, metadata, root, revision, prepared, source} = proof;
43
57
  if (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata)))
44
58
  throw fault('metadata does not describe this document');
45
59
  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};
60
+ return {value: {ledger, text, metadata: {...metadata}, root, revision, complete: ledger.complete}, prepared, source, proof};
47
61
  }
48
- function proveLedger(ledger) {
62
+ function readLedgerState(input, expectedText, expectedMetadata) {
63
+ const {ledger, shell, proof: held} = ledgerAdmission(input);
64
+ const proof = held || {shell, startText: ledger.start.text, ...finish(proveLedger(ledger))};
65
+ if (typeof input === 'object') proofs.set(input, proof);
66
+ return ledgerResult(ledger, proof, expectedText, expectedMetadata);
67
+ }
68
+ function* proveLedger(ledger, native = false) {
49
69
  const {sha256: claimed, ...payload} = ledger;
50
- if (claimed !== digest(payload)) throw fault('envelope checksum differs');
51
- let text = documentText(ledger.start.text), root = ledger.start.root, revision = ledger.start.revision;
70
+ if (claimed !== (yield* checksum(canonicalParts(payload), native))) throw fault('envelope checksum differs');
71
+ let text = ledger.start.text, root = ledger.start.root, revision = ledger.start.revision;
72
+ const startFacts = yield* sourceFacts(text, native);
52
73
  if (!_rapierValidMetadata(ledger.start.metadata) || !_rapierValidMetadata(ledger.head.metadata)) throw fault('invalid document metadata');
53
- if (!nonnegative(revision) || !rootShape(root) || (revision === 0 && root !== textRoot(text)) || ledger.start.sha256 !== sha256(text) ||
74
+ if (!nonnegative(revision) || !rootShape(root) || (revision === 0 && root !== startFacts.contentRoot) || ledger.start.sha256 !== startFacts.sha256 ||
54
75
  (ledger.complete && revision !== 0)) throw fault('invalid start');
55
76
  const prepared = _rapierPrepareHistoryRecords(ledger.records);
56
77
  if (!prepared.ok) throw fault('record chain differs');
57
- let source = new HistorySource(text), bytes = utf8Length(text);
78
+ let source = new HistorySource(text), bytes = startFacts.utf8Bytes;
58
79
  for (const record of checkedRecords(ledger, prepared)) {
59
80
  const next = source.apply(record.splices);
60
81
  if (next === null) throw fault('splice does not replay');
61
82
  source = next;
62
83
  for (const row of record.splices) bytes += utf8Length(row.inserted) - utf8Length(row.removed);
63
84
  if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
64
- root = record.afterHash; revision = record.transaction.revision;
85
+ root = record.afterHash; revision = record.transaction.revision; yield;
65
86
  }
66
87
  text = source.slice();
67
88
  if (ledger.records.some(row => Object.hasOwn(row, 'authored'))) {
@@ -69,7 +90,8 @@ function proveLedger(ledger) {
69
90
  }
70
91
  const metadata = prepared.replayMetadata(ledger.start.metadata);
71
92
  if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata)) throw fault('metadata does not describe this document');
72
- if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text)) throw fault('history does not describe this document');
93
+ const headFacts = ledger.records.every(row => !row.splices.length) ? startFacts : yield* sourceFacts(text, native);
94
+ if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== headFacts.sha256) throw fault('history does not describe this document');
73
95
  return {text, metadata, root, revision, prepared, source};
74
96
  }
75
97
  export function ledgerWithStart(input, text) {
@@ -83,14 +105,25 @@ export function ledgerWithStart(input, text) {
83
105
  export function readLedger(input, expectedText, expectedMetadata) {
84
106
  return readLedgerState(input, expectedText, expectedMetadata).value;
85
107
  }
86
- function* checksum(parts) {
108
+ export async function readLedgerAsync(input, expectedText, expectedMetadata, options = {}) {
109
+ options.signal?.throwIfAborted();
110
+ const {ledger, shell, proof: held} = ledgerAdmission(input);
111
+ const metadata = expectedMetadata === undefined ? undefined : plainData(expectedMetadata);
112
+ const proof = held || {shell, startText: ledger.start.text,
113
+ ...await finishAsync(proveLedger(ledger, !!globalThis.crypto?.subtle), options)};
114
+ options.signal?.throwIfAborted();
115
+ if (typeof input === 'object') proofs.set(input, proof);
116
+ return ledgerResult(ledger, proof, expectedText, metadata).value;
117
+ }
118
+ function* checksum(parts, native = false) {
119
+ if (native) return yield {parts};
87
120
  const hash = createSHA256();
88
121
  for (const part of parts) { hash.update(part); yield; }
89
122
  return hash.digest();
90
123
  }
91
- function* sourceFacts(text) {
124
+ function* sourceFacts(text, native = false) {
92
125
  if (typeof text !== 'string') throw fault('document is not whole-character text');
93
- const hash = createSHA256();
126
+ const hash = native ? null : createSHA256();
94
127
  let fnv = 0x811c9dc5, a = 1, b = 0, bytes = 0;
95
128
  for (let at = 0; at < text.length;) {
96
129
  const start = at, end = Math.min(text.length, at + 65536);
@@ -112,18 +145,19 @@ function* sourceFacts(text) {
112
145
  a %= 65521; b %= 65521;
113
146
  }
114
147
  if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
115
- hash.update(text.slice(start, end)); yield;
148
+ hash?.update(text.slice(start, end)); yield;
116
149
  }
117
- return {utf8Bytes: bytes, sha256: hash.digest(), contentRoot: text.length + ':' +
150
+ const digest = hash ? hash.digest() : yield {parts: [text], byteLength: bytes};
151
+ return {utf8Bytes: bytes, sha256: digest, contentRoot: text.length + ':' +
118
152
  (fnv >>> 0).toString(36) + ':' + (((b << 16) | a) >>> 0).toString(36)};
119
153
  }
120
- function* produceLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}) {
154
+ function* produceLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}, native = false) {
121
155
  if (!_rapierValidMetadata(metadata)) throw fault('document metadata required');
122
156
  metadata = {...metadata};
123
157
  if (!Array.isArray(records) || !Array.isArray(earlier)) throw fault('records required');
124
158
  const prepared = _rapierPrepareHistoryRecords(records, earlier);
125
159
  if (!prepared.ok) throw fault('navigation target was not retained');
126
- const headFacts = yield* sourceFacts(text);
160
+ const headFacts = yield* sourceFacts(text, native);
127
161
  let bytes = headFacts.utf8Bytes;
128
162
  const expanded = [];
129
163
  for (const {record, splices: rows, metadata: effect} of prepared.rows) {
@@ -153,7 +187,7 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
153
187
  const startRevision = expanded[0]?.transaction.baseRevision ?? revision, startRoot = expanded[0]?.beforeHash ?? root;
154
188
  if (typeof documentAuthority !== 'string' || !documentAuthority || documentAuthority.length > 256) throw fault('unsupported or invalid envelope');
155
189
  if (!nonnegative(startRevision) || !rootShape(startRoot)) throw fault('invalid start');
156
- const startFacts = unchangedText ? headFacts : yield* sourceFacts(startText);
190
+ const startFacts = unchangedText ? headFacts : yield* sourceFacts(startText, native);
157
191
  if (startRevision === 0 && startRoot !== startFacts.contentRoot) throw fault('invalid start');
158
192
  const headSHA = headFacts.sha256, startSHA = startFacts.sha256;
159
193
  const payload = {format: 'rapier-ledger/2', documentAuthority,
@@ -165,7 +199,10 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
165
199
  }
166
200
  const replayedMetadata = prepared.replayMetadata(startMetadata);
167
201
  if (!replayedMetadata || canonicalJSON(replayedMetadata) !== canonicalJSON(metadata)) throw fault('metadata does not describe this document');
168
- const ledger = {...payload, sha256: yield* checksum(canonicalParts(payload))};
202
+ const ledger = {...payload, sha256: yield* checksum(canonicalParts(payload), native)};
203
+ proofs.set(ledger, {shell: canonicalJSON({...ledger, start: {...ledger.start, text: ''}}), startText,
204
+ text, metadata: {...metadata}, root, revision, prepared: _rapierPrepareHistoryRecords(ledger.records),
205
+ source: new HistorySource(text)});
169
206
  return {ledger, contentRoot, utf8Bytes: headBytes};
170
207
  }
171
208
  function finish(producer) {
@@ -174,13 +211,14 @@ function finish(producer) {
174
211
  return step.value;
175
212
  }
176
213
  export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
177
- export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
178
- const producer = produceLedger(input);
179
- let step;
214
+ async function finishAsync(producer, {yield: yieldWork, signal} = {}) {
215
+ let step, digest;
180
216
  do {
181
217
  signal?.throwIfAborted();
182
- step = producer.next();
183
- if (!step.done && yieldWork) {
218
+ step = producer.next(digest); digest = undefined;
219
+ if (!step.done && step.value) digest = await sha256PartsAsync(step.value.parts,
220
+ {yield: yieldWork, signal, byteLength: step.value.byteLength});
221
+ else if (!step.done && yieldWork) {
184
222
  const pending = yieldWork();
185
223
  if (pending) await pending;
186
224
  }
@@ -188,8 +226,10 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
188
226
  signal?.throwIfAborted();
189
227
  return step.value;
190
228
  }
191
- export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
192
- const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input, expectedText, expectedMetadata);
229
+ export function prepareLedgerAsync(input, options = {}) {
230
+ return finishAsync(produceLedger(input, !!globalThis.crypto?.subtle), options);
231
+ }
232
+ function historyNavigation(ledger, text, metadata, prepared, source) {
193
233
  const {byId, effects: {pictures}} = prepared;
194
234
  let branch = [], cursor = 0;
195
235
  for (const {transaction: tx} of ledger.records) {
@@ -232,8 +272,13 @@ export function historyEnvelope(input, segmentIdentity = [], expectedText, expec
232
272
  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
273
  }
234
274
  }
275
+ return {branch, cursor};
276
+ }
277
+ export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
278
+ const {value: {ledger, text, metadata}, prepared, source, proof} = readLedgerState(input, expectedText, expectedMetadata);
279
+ const {branch, cursor} = proof.navigation ??= historyNavigation(ledger, text, metadata, prepared, source);
235
280
  return {schemaVersion: 5, documentAuthority: ledger.documentAuthority, documentRevision: ledger.head.revision,
236
- ledger: ledger.records, branch, cursor, earliestRevision: ledger.start.revision, earliestHash: ledger.start.root,
281
+ ledger: ledger.records, branch: branch.slice(), cursor, earliestRevision: ledger.start.revision, earliestHash: ledger.start.root,
237
282
  sourceRootId: ledger.head.root, earliestMetadata: {...ledger.start.metadata}, documentMetadata: {...metadata}, segmentIdentity, historyComplete: ledger.complete,
238
283
  trimReason: ledger.complete ? '' : 'carried_start', trimmedBytes: 0};
239
284
  }
@@ -249,15 +294,14 @@ export function agentActorId(door, agent) {
249
294
  export function replaceLedgerText(input, text, {actor = {kind: 'system', id: 'rapier'}, operation = 'document.replace', at} = {}) {
250
295
  const current = readLedger(input); documentText(text);
251
296
  if (current.text === text) return current.ledger;
252
- let start = 0, a = current.text.length, b = text.length;
253
- while (start < a && start < b && current.text[start] === text[start]) start++;
254
- if (start && /[\uDC00-\uDFFF]/.test(current.text[start] || text[start] || '')) start--;
255
- while (a > start && b > start && current.text[a - 1] === text[b - 1]) { a--; b--; }
256
- if (/[\uDC00-\uDFFF]/.test(current.text[a] || text[b] || '')) { a++; b++; }
257
- const splices = [{pos: start, removed: current.text.slice(start, a), inserted: text.slice(start, b)}];
258
- const revision = current.revision + 1, root = rootAfter(current.root, splices[0]);
297
+ const splices = diff(current.text, text, {_unit: 'word'}).edits.map(edit =>
298
+ minimalHistorySplice(current.text.slice(...edit.before), text.slice(...edit.after), edit.before[0])).reverse();
299
+ const revision = current.revision + 1, root = splices.reduce(rootAfter, current.root);
300
+ if (actor.kind === 'agent' && current.metadata.docKind === 'markdown' && evaluateWill(current.text, splices).outcome !== 'applied')
301
+ throw fault('will_violation');
302
+ const identity = sha256(canonicalJSON({before: current.ledger.sha256, text, actor, operation, at: at ?? null}));
259
303
  const record = {beforeHash: current.root, afterHash: root, splices, transaction: {
260
- id: current.ledger.documentAuthority.slice(0, 48) + ':export:' + revision.toString(36),
304
+ id: current.ledger.documentAuthority.slice(0, 48) + ':export:' + identity,
261
305
  documentAuthority: current.ledger.documentAuthority, baseRevision: current.revision, revision,
262
306
  actor, transport: 'platform', operation, requestId: null, sourceTransactionId: null,
263
307
  affectedBlockIds: [], parent: current.ledger.records.at(-1)?.transaction.id ?? null,
@@ -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
- const LONG_TEXT = 65536, DIGESTS = new Map();
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();
@@ -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).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'); }
@@ -10,7 +10,7 @@ const historyBoundary = (source, at) => Number.isSafeInteger(at) && at >= 0 && !
10
10
  !(at > 0 && at < source.length && (source.charCodeAt(at - 1) & 0xFC00) === 0xD800 &&
11
11
  (source.charCodeAt(at) & 0xFC00) === 0xDC00);
12
12
 
13
- function minimalHistorySplice(before, after, pos = 0) {
13
+ export function minimalHistorySplice(before, after, pos = 0) {
14
14
  let first = 0, a = before.length, b = after.length;
15
15
  while (first < a && first < b && before[first] === after[first]) first++;
16
16
  if (!historyBoundary(before, first) || !historyBoundary(after, first)) first--;
@@ -262,7 +262,11 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
262
262
  const start = boundary(row.pos), end = boundary(row.pos + row.removed.length);
263
263
  let at = start.last, count = Math.max(0, end.first - at);
264
264
  if (!row.removed.length) {
265
- const marker = pieces.slice(start.first, start.last).findIndex(item => item.mark?.inserted.length);
265
+ const run = pieces.slice(start.first, start.last), next = pieces[start.last];
266
+ let before = start.first - 1; while (before >= 0 && !pieces[before].text) before--;
267
+ const leftAct = before >= 0 ? byId.get(pieces[before].owner)?.actId : undefined;
268
+ const sameAct = leftAct !== undefined && run.some(item => item.mark?.actId === leftAct);
269
+ const marker = run.findIndex(item => item.mark?.inserted.length && (next && owns(next, item.mark) || !sameAct));
266
270
  at = marker < 0 ? start.last : start.first + marker; count = 0;
267
271
  }
268
272
  const removed = pieces.slice(at, at + count);
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {fields, scalar, plainPrototype} from './data.mjs';
3
3
  import {transformText} from './source.mjs';
4
+ import {dataImage} from './md-assets.mjs';
4
5
 
5
6
  const _RAPIER_TRANSACTION_ACTOR_LIMIT = 160;
6
7
  const _RAPIER_TRANSACTION_OPERATION_LIMIT = 96;
@@ -175,10 +176,10 @@ function _rapierPictureFinish(record, original, earlier, lookup) {
175
176
  if (!reference || !/^\]\[[a-z0-9-]+\]$/i.test(reference.inserted) || reference.removed === reference.inserted) return false;
176
177
  const definition = rows.find(row => row !== reference), oldLabel = reference.removed.slice(2, -1), newLabel = reference.inserted.slice(2, -1);
177
178
  const body = (text, label) => {
178
- const prefix = '[' + label + ']: data:image/svg+xml;base64,';
179
+ const prefix = '[' + label + ']: ';
179
180
  if (typeof text !== 'string' || !text.startsWith(prefix)) return null;
180
181
  const value = text.slice(prefix.length), end = value.indexOf(' '), bytes = end < 0 ? value : value.slice(0, end), title = end < 0 ? '' : value.slice(end);
181
- return /^[A-Za-z0-9+/]+={0,2}$/.test(bytes) && !/[\r\n]/.test(title) ? title : null;
182
+ return dataImage(bytes)?.codec === 'image/svg+xml' && !/[\r\n]/.test(title) ? title : null;
182
183
  };
183
184
  const title = body(definition.removed, oldLabel);
184
185
  if (title === null || body(definition.inserted, newLabel) !== title) return false;
@@ -292,6 +293,7 @@ function _rapierWalkHistoryEffects(records, lookup, visit = null, observe = null
292
293
  }
293
294
  }
294
295
  if (refs.some(id => typeof id !== 'string' || !nodes.has(id) || pictures.has(id))) return {ok: false};
296
+ if (tx.actor?.kind === 'agent' && refs.some(id => byId.get(id).transaction.actor?.kind !== 'agent')) return {ok: false};
295
297
  nodes.set(tx.id, {active: true, blockers: 0, references: refs, dependents: []}); references.set(tx.id, refs.slice());
296
298
  byId.set(tx.id, record); earlier.push(record);
297
299
  for (const ref of refs) {