rapier-markdown-kit 1.1.55 → 1.1.57

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/LICENSE CHANGED
@@ -25,7 +25,12 @@ grammar, marks and reference style (`spec/md-layout.mjs`, `spec/md-marks.mjs`, `
25
25
  (`layout/model.mjs`, `layout/line-plan.mjs`), the Will grammar (`agent/will.mjs`), the picture appendix reader and writer
26
26
  (`spec/md-assets.mjs`), the two shared-page reference readers (`tools/read-shared-page.mjs`,
27
27
  `tools/read-shared-page.py`), and the host helper `packages/rapier-embed/embed.mjs`. The vendored Pretext (`agent/vendor/pretext`) and micromark carry their
28
- own MIT notices. Everything else in Rapier is AGPL-3.0-only (see `LICENSE`).
28
+ own MIT notices. The edit-ledger module (`kit/rapier-ledger.mjs`, `kit/ledger/*.mjs`) and
29
+ its editor compatibility import (`editor/journal-records.mjs`) are also covered. The
30
+ record validator/replayer and text-only conflict merger were moved, not copied,
31
+ into that module; the Notes index merger and the rest of the agent/editor remain
32
+ AGPL-3.0-only. The wrapper's carrier-staging helper
33
+ (`skills/rapier-html/package-files.mjs`) is MIT. Everything else in Rapier is AGPL-3.0-only (see `LICENSE`).
29
34
 
30
35
  The picture appendix reader and writer used to be part of `images/assets.mjs`, a file that also
31
36
  holds JPEG XL/raster header inspection and SVG sanitizing -- both of which reach the editor's own
package/README.md CHANGED
@@ -26,6 +26,47 @@ import {parseWill} from 'rapier-markdown-kit/will'; // Will/1 markers: kee
26
26
  import {parseAssets} from 'rapier-markdown-kit/assets'; // the picture appendix
27
27
  ```
28
28
 
29
+ ## The shared renderer
30
+
31
+ `rapier-markdown-kit/render` exports `createRenderer`, `createMarkdownRenderer`,
32
+ `createRenderStyles`, `createRenderSanitizer` and `createPrintRenderer`. These are the
33
+ actual editor/Share owners, not a second Markdown dialect or a lookalike stylesheet.
34
+ Their host dependencies are explicit named properties in each factory's first argument;
35
+ no factory reaches into an editor session to find its document. `style.css` is the exact
36
+ MIT `spec/markdown-style.css`, including the numbered-list, checkbox, callout, table,
37
+ footnote and picture-layout treatment.
38
+
39
+ ```js
40
+ import {createRenderer} from 'rapier-markdown-kit/render';
41
+
42
+ // host is your bound DOM/codec/Markdown/image/style environment. Its named ports are
43
+ // declared at the top of createRenderer; preparation returns a detached semantic root.
44
+ const writer = createRenderer(host);
45
+ const {html, filename} = await writer.render(source, {filename: 'proposal.md'});
46
+ ```
47
+
48
+ `createMarkdownRenderer(host).render(source, metadata)` returns the semantic HTML.
49
+ The page writer's `render` returns `{html, filename}` asynchronously and preserves the
50
+ original source, including BOM and line endings, in the SHA-256-verified source carrier.
51
+ Every existing lower-level method is also returned for hosts that compose the stages.
52
+ Create a new bound instance when its parser or host state changes; do not share mutable
53
+ DOM objects between document realms.
54
+
55
+ This is a **DOM-hosted library**, not a bundled headless browser, DOM shim, parser or
56
+ image decoder. Supplying only `{source}` or running it in bare Node without those ports
57
+ is not supported. A host must provide a real DOM and sanitizer, the Rapier grammar,
58
+ portable image preparation, style access and the named codec/layout ports it uses.
59
+ Those dependencies keep their own licences: the MIT grant here does not relicense a
60
+ separately supplied host or vendor. The repository's `editor/render-host.js` binds the
61
+ editor; `server/` provides a complete local host with a pinned Chromium process. Its
62
+ Word exporter and filesystem service are AGPL, not part of this MIT package.
63
+
64
+ The extraction receipt in the existing `html-export-corpus` row fixes all 226 baseline
65
+ writer pages byte for byte. That corpus supplies a clean DOM and named style/sanitizer
66
+ adapters: it proves serialization and source recovery, not browser appearance or
67
+ sanitizer security. The server's integration cells additionally use a real DOM and
68
+ round-trip the actual HTTP output.
69
+
29
70
  ## What it gives you
30
71
 
31
72
  | Import | What it is |
@@ -36,7 +77,9 @@ import {parseAssets} from 'rapier-markdown-kit/assets'; // the picture appendi
36
77
  | `rapier-markdown-kit/will` | The Will/1 grammar (a separate, optional standard): the markers around an agent's intent regions. |
37
78
  | `rapier-markdown-kit/assets` | The picture appendix: the reference definitions a document's pictures live in. It never decodes a picture. |
38
79
  | `rapier-markdown-kit/style.css` | The reference style: type, lists, tables, tasks, pictures and the document's other markup, scoped to `.md-render`. |
39
- | `rapier-markdown-kit` | All five in one import. |
80
+ | `rapier-markdown-kit/rapier-ledger` | The MIT edit-ledger format, replay, authorship projection and offline merge. |
81
+ | `rapier-markdown-kit/ledger/carried` | Checked optional parts for pages and saved Markdown. |
82
+ | `rapier-markdown-kit` | The modules above as named namespaces. |
40
83
 
41
84
  Every convention is plain Markdown or an HTML comment other readers ignore. Laying lines out as Rapier does takes
42
85
  the host's font metrics (below).
@@ -240,3 +283,156 @@ value the editor's release reads.
240
283
 
241
284
  Each module has one home in the Rapier source (`spec/`, `layout/`, `agent/`), where Rapier itself reads it; `dist/`
242
285
  is that closure copied byte for byte, and `style.css` is the same `spec/markdown-style.css` Rapier bundles.
286
+
287
+
288
+ ## rapier-ledger
289
+
290
+ The edit ledger and its carried parts. MIT, no dependencies; part of
291
+ `rapier-markdown-kit`. The editor and this module use the **same** record validator,
292
+ splice replayer, root transition and interval transport. This is not a second history store.
293
+
294
+ ```js
295
+ import {exportLedger, readLedger, authorship, merge} from 'rapier-markdown-kit/rapier-ledger';
296
+ import {writeDocument, readDocument} from 'rapier-markdown-kit/ledger/carried';
297
+
298
+ const ledger = exportLedger({
299
+ text: 'Hello', records: [], documentAuthority: 'example-document', complete: true,
300
+ });
301
+ const saved = writeDocument('Hello', {ledger});
302
+ const reopened = readDocument(saved);
303
+ const verified = readLedger(reopened.ledger, reopened.text);
304
+ console.assert(verified.text === 'Hello');
305
+ const clean = writeDocument(verified.text, {authorship: authorship(ledger)});
306
+ console.assert(readDocument(clean).ledger === null);
307
+ const together = merge(ledger, reopened.ledger);
308
+ console.assert(together.text === 'Hello');
309
+ ```
310
+
311
+ ## The format at launch
312
+
313
+ `rapier-ledger/1` is a plain JSON object:
314
+
315
+ ```
316
+ {format, documentAuthority, start: {text, revision, root, sha256}, records,
317
+ head: {revision, root, sha256}, complete, sha256}
318
+ records[]: {transaction, splices: [{pos, removed, inserted}], beforeHash, afterHash}
319
+ transaction: {id, documentAuthority, baseRevision, revision, actor: {kind, id},
320
+ transport, operation, requestId, sourceTransactionId, affectedBlockIds,
321
+ parent, reverts, reapplies, createdAt}
322
+ ```
323
+
324
+ `format` is `rapier-ledger/1`. `start.text` is the exact text before the first
325
+ retained record. Every outgoing record carries its own splices, including Undo and
326
+ Redo. Offsets and ranges count UTF-16 code units; no boundary may cut a surrogate
327
+ pair. Splices execute in their recorded order. UTF-8 checksums preserve CRLF, tabs,
328
+ NUL and non-ASCII text. The canonical text limit is 25 MiB; each record has at most
329
+ 64 splices. The existing record validator remains authoritative for field bounds.
330
+
331
+ `start.sha256` and `head.sha256` hash UTF-8 text. The envelope `sha256` hashes
332
+ canonical JSON of every other envelope field: recursively sorted object keys,
333
+ array order preserved, JSON string/number spelling. Plain finite scalar data only:
334
+ no accessors, sparse arrays, cycles, undefined, negative zero or unpaired surrogates.
335
+ Readers reject unknown envelope/start/head fields, bad checksums, invalid records,
336
+ invalid source boundaries, a replay/root mismatch, or a head different from the
337
+ actual document. `historyEnvelope` additionally proves the editor's Undo/Redo branch.
338
+
339
+ **Roots are path-dependent.** `textRoot(text)` mints the existing
340
+ `chars:fnv-base36:adler-base36` initial root. Each splice advances it with
341
+ `rootAfter(priorRoot, splice)`, matching the source store. A later root is **not**
342
+ a fresh hash of the whole current text. SHA-256 of the exact checkpoint text is
343
+ separate. This distinction preserves the editor's actual format rather than the
344
+ earlier design shorthand. A nonzero start names a retained checkpoint; it does not
345
+ claim to reconstruct discarded events. `complete: false` also permits a stated
346
+ revision gap from the editor's pruning of cancelling navigation pairs; each
347
+ remaining splice must still replay exactly. It is never a claim about missing edits.
348
+
349
+ `actor.kind` is `human`, `agent` or `system`. A named agent's `actor.id` is
350
+ `<door>/<host-given name>` (name at most 96 characters, combined id at most 160).
351
+ Without a name, the door stands alone. The name is a **host-supplied claim**, not a
352
+ verified identity. Timestamps are supplied record times, not independently attested.
353
+ Checksums detect inconsistency/corruption, **not forgery**: anyone able to rewrite a
354
+ file can rewrite its checksums and labels. Importing a label never grants a live
355
+ principal's read handles, review authority or undo permissions.
356
+
357
+ ## Optional carried parts
358
+
359
+ The default is **neither part**. In a self-contained page these inert scripts stand
360
+ beside the document's existing carried source:
361
+
362
+ | Element | Type and contents | Choice |
363
+ | --- | --- | --- |
364
+ | `#rapier-authorship` | `application/json`, `rapier-authorship/1` | Who wrote what |
365
+ | `#rapier-ledger` | `application/json`, `rapier-ledger/1` | The whole history |
366
+
367
+ Each body uses the same reversible `encodeCarried`/`decodeCarried` escape as the
368
+ page's source. `writeParts`, `readParts` and `takeParts` implement it. Duplicate,
369
+ executable, malformed or wrong-document parts are refused. If both are present,
370
+ the authorship projection recomputed from the ledger must agree exactly.
371
+
372
+ `rapier-authorship/1` is `{format, sha256, runs}`. `sha256` hashes the **current**
373
+ text. Runs are `[start, end, actorKind, actorId, at]`, in order, nonoverlapping and
374
+ covering every current character. A start without earlier authorship is attributed
375
+ to `system/unattributed`, time zero. Insertions take the inserting record's actor
376
+ and time; deleted runs disappear. Thus the projection contains **no removed text**.
377
+ An authorship-only import becomes a clearly incomplete insertion ledger; future
378
+ attribution is again derived from that one ledger, never a mutable blame database.
379
+
380
+ A Markdown file carries the same optional JSON at EOF, outside its exact source:
381
+
382
+ ```
383
+
384
+
385
+ <!-- rapier-carried/1
386
+ {"ledger":...,"authorship":...}
387
+ -->
388
+
389
+ ```
390
+
391
+ The writer escapes `<` and `>` in that JSON. This versioned EOF marker is reserved;
392
+ an unfinished marker is refused, not silently imported as prose. The reader strips
393
+ only that exact suffix, validates it against the remaining exact text, and treats a
394
+ leading BOM as file metadata. Other Markdown readers ignore the comment. Plain
395
+ saves without a choice are unchanged byte for byte. For executable code, use a web
396
+ page to carry metadata rather than appending a Markdown comment to program source.
397
+
398
+ The existing proposal page's `#rapier-base` is independent: `text/markdown`, with
399
+ `data-revision`, `data-sha256`, `data-by` and `data-at`, carries the proposal's
400
+ baseline. It is **not** reconstructed from an optional ledger, and this module does
401
+ not implement the separate proposal-tool/CLI workflow.
402
+
403
+ ## Merging copies without a server
404
+
405
+ `merge(first, second)` locates a common checkpoint with the same root **and exact
406
+ text**, then transports the second copy's record splices over the first's through
407
+ the shared interval transport. It preserves the first's ancestry and incoming
408
+ actors/times, minting local revision/root links. Both the public reader and the
409
+ editor's restore reader can replay the result. Both merge orders yield the same
410
+ text for independently edited nonoverlapping ranges; their ordered ledgers need
411
+ not be identical. Full and conflict receipts make repeated imports idempotent.
412
+
413
+ Overlaps fall back to the existing text three-way merge. Unresolved alternatives
414
+ are carried in the existing `note-conflict:v1` envelope, never silently selected.
415
+ Different Will `keep` regions require conflict review even when one copy was
416
+ unchanged. `review.required` names an unresolved result; a UI must not auto-accept
417
+ it. Rapier offers carried-copy imports from Compare through its existing review,
418
+ checks the live document again at commit, and installs the proven ledger before
419
+ publishing its receipt or opening the durable-save barrier. A rejected or stale
420
+ proposal mutates nothing. A failed install rolls the source and undo window back.
421
+ Copies without a proven common checkpoint are refused by this API.
422
+
423
+ ## Host integration and retention
424
+
425
+ The embed helper accepts `agentName`; the checked `rapier-connect` message carries
426
+ `agent: {name}`. WebMCP uses the registration owner's supplied client name. Modern
427
+ MCP uses the request's client metadata, outside tool arguments; a legacy stateless
428
+ request without a supplied name stays unnamed. MCP editor snapshots include the
429
+ server's retained journal only in editor metadata, not in model-facing tool results.
430
+ The browser proves a replay suffix before preserving remote writers under local
431
+ revision links; missing remote events never become an invented complete history.
432
+
433
+ Rapier retains its existing 500-record / 4-MiB in-memory window. A carried file can
434
+ outlive that window; reopening may trim it again. The module preserves an explicitly
435
+ incomplete start. Full history includes removed text and can be much larger than
436
+ the current document; it is opt-in for each outgoing file, never a persistent
437
+ preference. Authorship is also opt-in. Neither option implies signatures, identity
438
+ verification, infinite retention, a relay server or automatic synchronization.
@@ -4,3 +4,5 @@ export * from './marks.mjs';
4
4
  export * from './model.mjs';
5
5
  export * from './will.mjs';
6
6
  export * from './assets.mjs';
7
+ export * from './render-entry.mjs';
8
+ export * as ledger from './rapier-ledger.mjs';
@@ -0,0 +1,59 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {readLedger, exportLedger, textRoot, rootAfter} from './format.mjs';
3
+ import {_rapierTransformSplices} from './journal-records.mjs';
4
+ import {sha256} from './hash.mjs';
5
+ import {plainData, documentText, nonnegative, exactKeys, fault} from './data.mjs';
6
+ function coalesce(runs) {
7
+ const out = [];
8
+ for (const run of runs) {
9
+ if (run[0] === run[1]) continue;
10
+ const last = out.at(-1);
11
+ if (last && last[1] === run[0] && last.slice(2).every((value, i) => value === run[i + 2])) last[1] = run[1];
12
+ else out.push(run.slice());
13
+ }
14
+ return out;
15
+ }
16
+ export function authorship(input) {
17
+ const {ledger, text} = readLedger(input);
18
+ let runs = ledger.start.text ? [[0, ledger.start.text.length, 'system', 'unattributed', 0]] : [];
19
+ for (const record of ledger.records) for (const row of record.splices) {
20
+ const cut = row.pos + row.removed.length, delta = row.inserted.length - row.removed.length, before = [], after = [];
21
+ for (const run of runs) {
22
+ if (run[0] < row.pos) before.push([run[0], Math.min(run[1], row.pos), ...run.slice(2)]);
23
+ if (run[1] > cut) after.push([Math.max(run[0], cut) + delta, run[1] + delta, ...run.slice(2)]);
24
+ }
25
+ const tx = record.transaction;
26
+ runs = coalesce([...before, [row.pos, row.pos + row.inserted.length, tx.actor.kind, tx.actor.id, tx.createdAt], ...after]);
27
+ }
28
+ return {format: 'rapier-authorship/1', sha256: sha256(text), runs};
29
+ }
30
+ export function readAuthorship(input, text) {
31
+ documentText(text); const value = plainData(input);
32
+ if (!exactKeys(value, ['format','sha256','runs']) || value.format !== 'rapier-authorship/1' || value.sha256 !== sha256(text) || !Array.isArray(value.runs)) throw fault('authorship is not for this text');
33
+ let cursor = 0;
34
+ for (const run of value.runs) {
35
+ if (!Array.isArray(run) || run.length !== 5 || run[0] !== cursor || !nonnegative(run[1]) || run[1] <= cursor || run[1] > text.length ||
36
+ !['human','agent','system'].includes(run[2]) || typeof run[3] !== 'string' || !run[3] || run[3].length > 160 || !nonnegative(run[4]) ||
37
+ /[\uD800-\uDFFF]/u.test(text.slice(cursor, run[1]))) throw fault('invalid authorship run');
38
+ cursor = run[1];
39
+ }
40
+ if (cursor !== text.length) throw fault('authorship does not cover the text');
41
+ return value;
42
+ }
43
+ // Authorship-only imports become an incomplete insertion ledger. There is no mutable blame database.
44
+ export function ledgerFromAuthorship(input, text, documentAuthority) {
45
+ const value = readAuthorship(input, text), records = [];
46
+ let current = '', root = textRoot('');
47
+ for (const [start, end, kind, id, createdAt] of value.runs) {
48
+ const row = {pos: start, removed: '', inserted: text.slice(start, end)}, revision = records.length + 1;
49
+ const beforeHash = root; root = rootAfter(root, row);
50
+ current = _rapierTransformSplices(current, [row]);
51
+ records.push({beforeHash, afterHash: root, splices: [row], transaction: {
52
+ id: documentAuthority.slice(0, 48) + ':authorship:' + revision.toString(36), documentAuthority,
53
+ baseRevision: revision - 1, revision, actor: {kind, id}, transport: 'platform', operation: 'authorship.import',
54
+ requestId: null, sourceTransactionId: null, affectedBlockIds: [], parent: records.at(-1)?.transaction.id ?? null,
55
+ reverts: null, reapplies: null, createdAt,
56
+ }});
57
+ }
58
+ return exportLedger({text: current, records, documentAuthority, revision: records.length, root, complete: false});
59
+ }
@@ -0,0 +1,69 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {readLedger} from './format.mjs';
3
+ import {authorship as projectAuthorship, readAuthorship} from './authorship.mjs';
4
+ import {canonicalJSON, fault} from './data.mjs';
5
+ export function encodeCarried(text) {
6
+ return String(text).replace(/[\\\r\0]|<[/!]/g, m => m === '\\' ? '\\\\' : m === '\r' ? '\\r' : m === '\0' ? '\\0' : m === '</' ? '\\/' : '\\!');
7
+ }
8
+ export function decodeCarried(text) {
9
+ return String(text).replace(/\\([\\/!r0])/g, (_, c) => c === '\\' ? '\\' : c === 'r' ? '\r' : c === '0' ? '\0' : c === '/' ? '</' : '<!');
10
+ }
11
+ export function validateParts(text, {ledger = null, authorship = null} = {}) {
12
+ // A file's leading BOM is metadata, as in the editor's source intake.
13
+ const canonical = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
14
+ const history = ledger === null ? null : readLedger(ledger, canonical).ledger;
15
+ const runs = authorship === null ? null : readAuthorship(authorship, canonical);
16
+ if (history && runs && canonicalJSON(projectAuthorship(history)) !== canonicalJSON(runs)) throw fault('authorship differs from ledger replay');
17
+ return {ledger: history, authorship: runs};
18
+ }
19
+ export function writeParts(text, options) {
20
+ const parts = validateParts(text, options);
21
+ return Object.entries(parts).filter(([, value]) => value !== null).map(([name, value]) =>
22
+ '<script type="application/json" id="rapier-' + name + '">' + encodeCarried(JSON.stringify(value)) + '</script>\n').join('');
23
+ }
24
+ // Pure adapter: the browser supplies inert script elements, Node supplies parsed script records.
25
+ export function readParts(text, elements) {
26
+ const parts = {ledger: null, authorship: null};
27
+ for (const element of elements) {
28
+ const name = element.id === 'rapier-ledger' ? 'ledger' : element.id === 'rapier-authorship' ? 'authorship' : null;
29
+ if (!name) continue;
30
+ if (parts[name] !== null || element.type !== 'application/json' || (element.tagName && element.tagName.toLowerCase() !== 'script')) throw fault('duplicate or executable carried part');
31
+ try { parts[name] = JSON.parse(decodeCarried(element.textContent)); }
32
+ catch (_) { throw fault('invalid carried JSON'); }
33
+ if (parts[name] === null) throw fault('empty carried part');
34
+ }
35
+ return validateParts(text, parts);
36
+ }
37
+ // The wrapper only removes whole script elements. Strings inside other scripts are not carriers.
38
+ export function takeParts(html, text) {
39
+ const elements = [];
40
+ const stripped = html.replace(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi, (whole, attrs, content) => {
41
+ const attributes = [...attrs.matchAll(/(?:^|\s)([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g)];
42
+ const ids = attributes.filter(m => m[1].toLowerCase() === 'id').map(m => m[2] ?? m[3] ?? m[4]);
43
+ if (!ids.some(id => id === 'rapier-ledger' || id === 'rapier-authorship')) return whole;
44
+ const types = attributes.filter(m => m[1].toLowerCase() === 'type').map(m => m[2] ?? m[3] ?? m[4]);
45
+ if (ids.length !== 1 || types.length !== 1) throw fault('ambiguous carried part');
46
+ elements.push({id: ids[0], type: types[0].toLowerCase(), textContent: content}); return '';
47
+ });
48
+ if (elements.length && text === null) throw fault('carried history without a document');
49
+ return {html: stripped, ...readParts(text ?? '', elements)};
50
+ }
51
+ const OPEN = '\n\n<!-- rapier-carried/1\n', CLOSE = '\n-->\n';
52
+ // One optional HTML comment at EOF; every other Markdown reader ignores it. Source is never rewritten.
53
+ export function writeDocument(text, options) {
54
+ const parts = validateParts(text, options);
55
+ if (!parts.ledger && !parts.authorship) return text;
56
+ const json = JSON.stringify(parts).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
57
+ return text + OPEN + json + CLOSE;
58
+ }
59
+ export function readDocument(source) {
60
+ const at = source.lastIndexOf(OPEN);
61
+ if (at < 0) return {text: source, ledger: null, authorship: null};
62
+ if (!source.endsWith(CLOSE)) throw fault('unfinished document metadata');
63
+ let parts;
64
+ try { parts = JSON.parse(source.slice(at + OPEN.length, -CLOSE.length)); }
65
+ catch (_) { throw fault('invalid document metadata'); }
66
+ if (!parts || Object.keys(parts).length !== 2 || !Object.hasOwn(parts, 'ledger') || !Object.hasOwn(parts, 'authorship')) throw fault('invalid document parts');
67
+ const text = source.slice(0, at);
68
+ return {text, ...validateParts(text, parts)};
69
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Only plain JSON data enters the public format. Accessors and toJSON are never executed.
3
+ export function fault(message) { return Object.assign(new Error('Ledger: ' + message), {code: 'ledger_invalid'}); }
4
+ export function canonicalJSON(value, depth = 0, active = new Set()) {
5
+ if (value === null || typeof value === 'boolean') return JSON.stringify(value);
6
+ if (typeof value === 'string') { if (/[\uD800-\uDFFF]/u.test(value)) throw fault('unpaired surrogate'); return JSON.stringify(value); }
7
+ if (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) return JSON.stringify(value);
8
+ if (!value || typeof value !== 'object' || depth > 64 || active.has(value)) throw fault('not plain JSON data');
9
+ const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
10
+ if (!array && proto !== null && (Object.getPrototypeOf(proto) !== null || Object.getOwnPropertyDescriptor(proto, 'constructor')?.value?.name !== 'Object')) throw fault('not a plain object');
11
+ const descriptors = Object.getOwnPropertyDescriptors(value), keys = Reflect.ownKeys(descriptors);
12
+ for (const key of keys) {
13
+ if (array && key === 'length') continue;
14
+ const field = descriptors[key];
15
+ if (typeof key !== 'string' || ['__proto__', 'constructor', 'prototype'].includes(key) || !Object.hasOwn(field, 'value') || !field.enumerable) throw fault('non-data property');
16
+ }
17
+ if (array && (keys.length !== value.length + 1 || keys.some(k => k !== 'length' && (!/^(0|[1-9]\d*)$/.test(k) || +k >= value.length)))) throw fault('sparse array');
18
+ active.add(value);
19
+ const encode = item => canonicalJSON(item, depth + 1, active);
20
+ const out = array ? '[' + Array.from({length: value.length}, (_, i) => encode(descriptors[i].value)).join(',') + ']'
21
+ : '{' + keys.sort().map(key => JSON.stringify(key) + ':' + encode(descriptors[key].value)).join(',') + '}';
22
+ active.delete(value); return out;
23
+ }
24
+ export function plainData(value) { return JSON.parse(canonicalJSON(typeof value === 'string' ? JSON.parse(value) : value)); }
25
+ export function exactKeys(value, keys) {
26
+ return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
27
+ }
28
+ export function documentText(text) {
29
+ if (typeof text !== 'string' || /[\uD800-\uDFFF]/u.test(text)) throw fault('document is not whole-character text');
30
+ if (new TextEncoder().encode(text).length > 25 * 1024 * 1024) throw fault('document exceeds the editor text limit');
31
+ return text;
32
+ }
33
+ export const nonnegative = value => Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {_rapierValidLedgerRecord, _rapierRecordSplices, _rapierTransformSplices} from './journal-records.mjs';
3
+ import {textRoot, rootAfter, sha256} from './hash.mjs';
4
+ import {canonicalJSON, plainData, documentText, exactKeys, nonnegative, fault} from './data.mjs';
5
+ export {textRoot, rootAfter};
6
+ const digest = value => sha256(canonicalJSON(value));
7
+ const rootShape = value => typeof value === 'string' && /^\d+:[a-z0-9]+:[a-z0-9]+$/.test(value);
8
+ export function readLedger(input, expectedText) {
9
+ const ledger = plainData(input);
10
+ if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/1' ||
11
+ typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
12
+ typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
13
+ !exactKeys(ledger.start, ['text','revision','root','sha256']) || !exactKeys(ledger.head, ['revision','root','sha256'])) throw fault('invalid envelope');
14
+ const {sha256: claimed, ...payload} = ledger;
15
+ if (claimed !== digest(payload)) throw fault('envelope checksum differs');
16
+ let text = documentText(ledger.start.text), root = ledger.start.root, revision = ledger.start.revision;
17
+ if (!nonnegative(revision) || !rootShape(root) || (revision === 0 && root !== textRoot(text)) || ledger.start.sha256 !== sha256(text) ||
18
+ (ledger.complete && revision !== 0)) throw fault('invalid start');
19
+ const earlier = [];
20
+ for (const record of ledger.records) {
21
+ if (!Array.isArray(record.splices) || !record.splices.length || !_rapierValidLedgerRecord(record, earlier.at(-1) || null, earlier,
22
+ ledger.documentAuthority, 25 * 1024 * 1024, text => new TextEncoder().encode(text).length, !ledger.complete) ||
23
+ record.transaction.baseRevision < revision || (record.transaction.baseRevision !== revision && ledger.complete) ||
24
+ (record.transaction.baseRevision === revision && record.beforeHash !== root)) throw fault('record chain differs');
25
+ // A pruned cancelling pair leaves a stated revision gap, just as in the editor's restore reader.
26
+ if (record.transaction.baseRevision !== revision) root = record.beforeHash;
27
+ const next = _rapierTransformSplices(text, record.splices);
28
+ if (next === null) throw fault('splice does not replay');
29
+ text = documentText(next);
30
+ for (const row of record.splices) root = rootAfter(root, row);
31
+ if (root !== record.afterHash) throw fault('record root differs');
32
+ revision = record.transaction.revision; earlier.push(record);
33
+ }
34
+ if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text) ||
35
+ (expectedText !== undefined && text !== expectedText)) throw fault('history does not describe this document');
36
+ return {ledger, text, root, revision, complete: ledger.complete};
37
+ }
38
+ export function exportLedger({text, records, documentAuthority, revision, root, complete = false, earlier = records} = {}) {
39
+ documentText(text);
40
+ if (!Array.isArray(records) || !Array.isArray(earlier)) throw fault('records required');
41
+ // Inspect before touching properties: exporting must not call accessors on an alleged record.
42
+ records = plainData(records); earlier = plainData(earlier);
43
+ const expanded = records.map(record => {
44
+ const rows = _rapierRecordSplices(record, earlier);
45
+ if (!rows) throw fault('navigation target was not retained');
46
+ return {transaction: record.transaction, splices: rows.map(row => ({pos: row.pos, removed: row.removed, inserted: row.inserted})),
47
+ beforeHash: record.beforeHash, afterHash: record.afterHash};
48
+ });
49
+ let startText = text;
50
+ for (let i = expanded.length - 1; i >= 0; i--) {
51
+ startText = _rapierTransformSplices(startText, expanded[i].splices, true);
52
+ if (startText === null) throw fault('history does not reverse from this document');
53
+ }
54
+ revision ??= expanded.at(-1)?.transaction.revision ?? 0;
55
+ root ??= expanded.at(-1)?.afterHash ?? textRoot(text);
56
+ documentAuthority ??= expanded[0]?.transaction.documentAuthority;
57
+ const startRevision = expanded[0]?.transaction.baseRevision ?? revision;
58
+ const payload = {format: 'rapier-ledger/1', documentAuthority,
59
+ start: {text: startText, revision: startRevision, root: expanded[0]?.beforeHash ?? root, sha256: sha256(startText)},
60
+ records: expanded, head: {revision, root, sha256: sha256(text)}, complete: complete === true && startRevision === 0 && expanded.every((row, i) => !i || row.transaction.baseRevision === expanded[i - 1].transaction.revision)};
61
+ const ledger = {...payload, sha256: digest(payload)};
62
+ readLedger(ledger, text); return ledger;
63
+ }
64
+ // Derived navigation, never a second history. A target older than start is intentionally unavailable.
65
+ export function historyEnvelope(input, segmentIdentity = []) {
66
+ const {ledger, text} = readLedger(input), ids = new Set(ledger.records.map(row => row.transaction.id));
67
+ let branch = [], cursor = 0;
68
+ for (const {transaction: tx} of ledger.records) {
69
+ const target = tx.reverts || tx.reapplies;
70
+ if (target) {
71
+ if (!ids.has(target)) continue;
72
+ if (tx.reverts && branch[cursor - 1] === target) cursor--;
73
+ else if (tx.reapplies && branch[cursor] === target) cursor++;
74
+ else throw fault('invalid navigation order');
75
+ } else { branch = branch.slice(0, cursor); branch.push(tx.id); cursor = branch.length; }
76
+ }
77
+ const byId = new Map(ledger.records.map(row => [row.transaction.id, row]));
78
+ let traversed = text;
79
+ for (let i = cursor - 1; i >= 0; i--) {
80
+ traversed = _rapierTransformSplices(traversed, byId.get(branch[i]).splices, true);
81
+ if (traversed === null) throw fault('undo branch does not reverse');
82
+ }
83
+ traversed = text;
84
+ for (let i = cursor; i < branch.length; i++) {
85
+ traversed = _rapierTransformSplices(traversed, byId.get(branch[i]).splices);
86
+ if (traversed === null) throw fault('redo branch does not replay');
87
+ }
88
+ if (ledger.complete) {
89
+ if (ledger.records.length && !branch.length) throw fault('complete history lost its branch');
90
+ let active = ledger.start.text;
91
+ if (!cursor && active !== text) throw fault('complete history start differs');
92
+ for (let i = 0; i < branch.length; i++) {
93
+ active = _rapierTransformSplices(active, byId.get(branch[i]).splices);
94
+ if (active === null || (i + 1 === cursor && active !== text)) throw fault('complete branch differs');
95
+ }
96
+ }
97
+ return {schemaVersion: 4, documentAuthority: ledger.documentAuthority, documentRevision: ledger.head.revision,
98
+ ledger: ledger.records, branch, cursor, earliestRevision: ledger.start.revision, earliestHash: ledger.start.root,
99
+ sourceRootId: ledger.head.root, segmentIdentity, historyComplete: ledger.complete,
100
+ trimReason: ledger.complete ? '' : 'carried_start', trimmedBytes: 0};
101
+ }
102
+ export function agentActorId(door, agent) {
103
+ if (typeof door !== 'string' || !door || door.length > 160 || /[\u0000-\u001f\u007f]/.test(door)) throw fault('invalid door');
104
+ if (agent == null) return door;
105
+ agent = plainData(agent);
106
+ if (!exactKeys(agent, ['name']) || typeof agent.name !== 'string' || !agent.name.trim() || agent.name.length > 96 ||
107
+ /[\u0000-\u001f\u007f]/.test(agent.name) || door.length + agent.name.length + 1 > 160) throw fault('invalid host-given name');
108
+ return door + '/' + agent.name;
109
+ }
110
+
111
+ // A writer changing representation records that change, rather than attaching an unrelated head.
112
+ export function replaceLedgerText(input, text, {actor = {kind: 'system', id: 'rapier'}, operation = 'document.replace', at} = {}) {
113
+ const current = readLedger(input); documentText(text);
114
+ if (current.text === text) return current.ledger;
115
+ let start = 0, a = current.text.length, b = text.length;
116
+ while (start < a && start < b && current.text[start] === text[start]) start++;
117
+ if (start && /[\uDC00-\uDFFF]/.test(current.text[start] || text[start] || '')) start--;
118
+ while (a > start && b > start && current.text[a - 1] === text[b - 1]) { a--; b--; }
119
+ if (/[\uDC00-\uDFFF]/.test(current.text[a] || text[b] || '')) { a++; b++; }
120
+ const splices = [{pos: start, removed: current.text.slice(start, a), inserted: text.slice(start, b)}];
121
+ const revision = current.revision + 1, root = rootAfter(current.root, splices[0]);
122
+ const record = {beforeHash: current.root, afterHash: root, splices, transaction: {
123
+ id: current.ledger.documentAuthority.slice(0, 48) + ':export:' + revision.toString(36),
124
+ documentAuthority: current.ledger.documentAuthority, baseRevision: current.revision, revision,
125
+ actor, transport: 'platform', operation, requestId: null, sourceTransactionId: null,
126
+ affectedBlockIds: [], parent: current.ledger.records.at(-1)?.transaction.id ?? null,
127
+ reverts: null, reapplies: null, createdAt: at ?? current.ledger.records.at(-1)?.transaction.createdAt ?? 0,
128
+ }};
129
+ return exportLedger({text, records: [...current.ledger.records, record], documentAuthority: current.ledger.documentAuthority,
130
+ revision, root, complete: current.complete});
131
+ }
@@ -0,0 +1,47 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The editor's path-dependent root, plus synchronous SHA-256 for file readers (including offline wrappers).
3
+ export function textRoot(text) {
4
+ let fnv = 0x811c9dc5, a = 1, b = 0;
5
+ for (let i = 0; i < text.length; i++) {
6
+ const c = text.charCodeAt(i);
7
+ fnv = Math.imul(fnv ^ c, 16777619); a = (a + c) % 65521; b = (b + a) % 65521;
8
+ }
9
+ return text.length + ':' + (fnv >>> 0).toString(36) + ':' + (((b << 16) | a) >>> 0).toString(36);
10
+ }
11
+ export function rootAfter(root, row) {
12
+ return textRoot(String(root || '') + ':' + row.pos + ':' + textRoot(row.removed) + ':' + textRoot(row.inserted));
13
+ }
14
+ const K = new Uint32Array([
15
+ 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
16
+ 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
17
+ 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
18
+ 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
19
+ 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
20
+ 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
21
+ 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
22
+ 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
23
+ ]);
24
+ const rotate = (x, n) => (x >>> n) | (x << (32 - n));
25
+ export function sha256(text) {
26
+ const bytes = new TextEncoder().encode(text);
27
+ const padded = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64);
28
+ padded.set(bytes); padded[bytes.length] = 0x80;
29
+ const data = new DataView(padded.buffer), bits = bytes.length * 8;
30
+ data.setUint32(padded.length - 8, Math.floor(bits / 0x100000000)); data.setUint32(padded.length - 4, bits >>> 0);
31
+ const h = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]), w = new Uint32Array(64);
32
+ for (let offset = 0; offset < padded.length; offset += 64) {
33
+ for (let i = 0; i < 16; i++) w[i] = data.getUint32(offset + i * 4);
34
+ for (let i = 16; i < 64; i++) {
35
+ const x = w[i - 15], y = w[i - 2];
36
+ w[i] = w[i - 16] + (rotate(x, 7) ^ rotate(x, 18) ^ (x >>> 3)) + w[i - 7] + (rotate(y, 17) ^ rotate(y, 19) ^ (y >>> 10));
37
+ }
38
+ let [a,b,c,d,e,f,g,k] = h;
39
+ for (let i = 0; i < 64; i++) {
40
+ const t1 = (k + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
41
+ const t2 = ((rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
42
+ k = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
43
+ }
44
+ for (const [i, value] of [a,b,c,d,e,f,g,k].entries()) h[i] += value;
45
+ }
46
+ return [...h].map(x => x.toString(16).padStart(8, '0')).join('');
47
+ }