rapier-markdown-kit 1.1.56 → 1.1.58
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 +6 -1
- package/README.md +156 -1
- package/dist/kit/index.mjs +1 -0
- package/dist/kit/ledger/authorship.mjs +59 -0
- package/dist/kit/ledger/carried.mjs +69 -0
- package/dist/kit/ledger/data.mjs +33 -0
- package/dist/kit/ledger/format.mjs +131 -0
- package/dist/kit/ledger/hash.mjs +47 -0
- package/dist/kit/ledger/journal-records.mjs +139 -0
- package/dist/kit/ledger/merge.mjs +101 -0
- package/dist/kit/ledger/text-merge.mjs +345 -0
- package/dist/kit/rapier-ledger.mjs +9 -0
- package/dist/kit/render.mjs +6 -3
- package/package.json +5 -3
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.
|
|
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
|
@@ -77,7 +77,9 @@ round-trip the actual HTTP output.
|
|
|
77
77
|
| `rapier-markdown-kit/will` | The Will/1 grammar (a separate, optional standard): the markers around an agent's intent regions. |
|
|
78
78
|
| `rapier-markdown-kit/assets` | The picture appendix: the reference definitions a document's pictures live in. It never decodes a picture. |
|
|
79
79
|
| `rapier-markdown-kit/style.css` | The reference style: type, lists, tables, tasks, pictures and the document's other markup, scoped to `.md-render`. |
|
|
80
|
-
| `rapier-markdown-kit` |
|
|
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. |
|
|
81
83
|
|
|
82
84
|
Every convention is plain Markdown or an HTML comment other readers ignore. Laying lines out as Rapier does takes
|
|
83
85
|
the host's font metrics (below).
|
|
@@ -281,3 +283,156 @@ value the editor's release reads.
|
|
|
281
283
|
|
|
282
284
|
Each module has one home in the Rapier source (`spec/`, `layout/`, `agent/`), where Rapier itself reads it; `dist/`
|
|
283
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.
|
package/dist/kit/index.mjs
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
const _RAPIER_TRANSACTION_ACTOR_LIMIT = 160;
|
|
4
|
+
const _RAPIER_TRANSACTION_OPERATION_LIMIT = 96;
|
|
5
|
+
const _RAPIER_TRANSACTION_REQUEST_LIMIT = 160;
|
|
6
|
+
|
|
7
|
+
/* Validate every row, not only the final string: a half-pair replacement can produce a whole
|
|
8
|
+
character while leaving unencodable fragments in the ledger; two invalid rows can also repair
|
|
9
|
+
each other. Refusal is atomic. Pre-existing damage elsewhere stays untouched and editable. */
|
|
10
|
+
function _rapierTransformSplices(text, splices, inverse = false) {
|
|
11
|
+
try {
|
|
12
|
+
if (!Array.isArray(splices)) return null;
|
|
13
|
+
let value = String(text == null ? '' : text);
|
|
14
|
+
// In Unicode mode a complete pair is one scalar, outside the surrogate range.
|
|
15
|
+
const whole = text => typeof text === 'string' && !/[\uD800-\uDFFF]/u.test(text);
|
|
16
|
+
const splitsPair = at => at > 0 && at < value.length &&
|
|
17
|
+
(value.charCodeAt(at - 1) & 0xFC00) === 0xD800 &&
|
|
18
|
+
(value.charCodeAt(at) & 0xFC00) === 0xDC00;
|
|
19
|
+
const rows = inverse ? splices.slice().reverse() : splices;
|
|
20
|
+
for (const row of rows) {
|
|
21
|
+
if (!row || !Number.isSafeInteger(row.pos) || row.pos < 0 || Object.is(row.pos, -0)) return null;
|
|
22
|
+
const pos = row.pos;
|
|
23
|
+
const removed = inverse ? row.inserted : row.removed;
|
|
24
|
+
const inserted = inverse ? row.removed : row.inserted;
|
|
25
|
+
if (!whole(removed) || !whole(inserted) || pos > value.length ||
|
|
26
|
+
pos + removed.length > value.length || splitsPair(pos) || splitsPair(pos + removed.length) ||
|
|
27
|
+
value.slice(pos, pos + removed.length) !== removed) return null;
|
|
28
|
+
value = value.slice(0, pos) + inserted + value.slice(pos + removed.length);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
} catch (_) { return null; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _rapierRecordSplices(entry, ledger) {
|
|
35
|
+
try {
|
|
36
|
+
if (entry && Array.isArray(entry.splices) && entry.splices.length) return entry.splices;
|
|
37
|
+
const transaction = entry && entry.transaction;
|
|
38
|
+
const targetId = String(transaction?.reverts || transaction?.reapplies || '');
|
|
39
|
+
const target = targetId && (ledger || []).find(record => record?.transaction?.id === targetId);
|
|
40
|
+
if (!target || !Array.isArray(target.splices) || !target.splices.length) return null;
|
|
41
|
+
return transaction.reverts ? target.splices.slice().reverse().map(row => ({
|
|
42
|
+
pos: row.pos, removed: row.inserted, inserted: row.removed,
|
|
43
|
+
})) : target.splices;
|
|
44
|
+
} catch (_) { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A ledger record as the journal and the Notes return validate it below: plain data, nothing
|
|
48
|
+
// undefined anywhere in it. A live record could carry a row whose type or order was not yet known
|
|
49
|
+
// (a block just inserted, before its first render: `type: undefined`), and the validator refused
|
|
50
|
+
// the whole ledger for that one field -- silently at boot, where a refused ledger is cleared, and on
|
|
51
|
+
// the founder's phone as "Rapier could not be reopened: the document history could not be restored"
|
|
52
|
+
// on every return from Notes (24 September). The copy is made once per record; records are frozen,
|
|
53
|
+
// so it never goes stale.
|
|
54
|
+
const _rapierJournalRecord = (() => {
|
|
55
|
+
const copies = new WeakMap();
|
|
56
|
+
return record => {
|
|
57
|
+
let copy = copies.get(record);
|
|
58
|
+
if (!copy) { copy = JSON.parse(JSON.stringify(record)); copies.set(record, copy); }
|
|
59
|
+
return copy;
|
|
60
|
+
};
|
|
61
|
+
})();
|
|
62
|
+
|
|
63
|
+
function _rapierValidLedgerRecord(record, prior, earlier, documentAuthority, maxDocumentBytes, utf8Length, allowGap = false) {
|
|
64
|
+
// Recovery records are data, not executable objects. Inspect descriptors without invoking
|
|
65
|
+
// accessors, and use an ancestor set (not a global seen set) so shared acyclic data stays valid.
|
|
66
|
+
// Iterative traversal refuses cycles without exhausting the call stack. Proxies/counters that
|
|
67
|
+
// throw are refused by the same gate rather than escaping into document recovery.
|
|
68
|
+
try {
|
|
69
|
+
const active = new Set(), pending = [[record, false]];
|
|
70
|
+
while (pending.length) {
|
|
71
|
+
const [value, leaving] = pending.pop();
|
|
72
|
+
if (leaving) { active.delete(value); continue; }
|
|
73
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') continue;
|
|
74
|
+
if (typeof value === 'number') { if (!Number.isFinite(value)) return false; continue; }
|
|
75
|
+
if (!value || typeof value !== 'object' || active.has(value)) return false;
|
|
76
|
+
const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
|
|
77
|
+
if (!array && proto !== null && (Object.getPrototypeOf(proto) !== null ||
|
|
78
|
+
Object.getOwnPropertyDescriptor(proto, 'constructor')?.value?.name !== 'Object')) return false;
|
|
79
|
+
const keys = Reflect.ownKeys(value);
|
|
80
|
+
if (array && keys.length !== value.length + 1) return false;
|
|
81
|
+
active.add(value); pending.push([value, true]);
|
|
82
|
+
for (const key of keys) {
|
|
83
|
+
if (array && key === 'length') continue;
|
|
84
|
+
if (typeof key !== 'string' || key === '__proto__' || key === 'constructor' || key === 'prototype' ||
|
|
85
|
+
(array && (!Number.isSafeInteger(Number(key)) || Number(key) < 0 ||
|
|
86
|
+
Number(key) >= value.length || String(Number(key)) !== key))) return false;
|
|
87
|
+
const field = Object.getOwnPropertyDescriptor(value, key);
|
|
88
|
+
if (!field || !Object.hasOwn(field, 'value')) return false;
|
|
89
|
+
pending.push([field.value, false]);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!record || typeof record !== 'object' ||
|
|
93
|
+
typeof record.beforeHash !== 'string' || !record.beforeHash.length ||
|
|
94
|
+
typeof record.afterHash !== 'string' || !record.afterHash.length || record.beforeHash === record.afterHash) return false;
|
|
95
|
+
const own = Array.isArray(record.splices) && record.splices.length;
|
|
96
|
+
const navigation = !!(record.transaction?.reverts || record.transaction?.reapplies);
|
|
97
|
+
if ((!own && !navigation) || (own && record.splices.length > 64) ||
|
|
98
|
+
(record.transaction?.reverts && record.transaction?.reapplies)) return false;
|
|
99
|
+
const splices = _rapierRecordSplices(record, earlier);
|
|
100
|
+
if (!splices || splices.length > 64 || !splices.every(row =>
|
|
101
|
+
row && Number.isSafeInteger(row.pos) && row.pos >= 0 && !Object.is(row.pos, -0) &&
|
|
102
|
+
typeof row.removed === 'string' && typeof row.inserted === 'string' &&
|
|
103
|
+
!/[\uD800-\uDFFF]/u.test(row.removed) && !/[\uD800-\uDFFF]/u.test(row.inserted) &&
|
|
104
|
+
utf8Length(row.removed) <= maxDocumentBytes &&
|
|
105
|
+
utf8Length(row.inserted) <= maxDocumentBytes)) return false;
|
|
106
|
+
const transaction = record.transaction;
|
|
107
|
+
const nullableText = (value, limit, empty = true) => value === null ||
|
|
108
|
+
(typeof value === 'string' && value.length <= limit && (empty || value.length > 0));
|
|
109
|
+
if (!transaction || typeof transaction.id !== 'string' || !transaction.id.length ||
|
|
110
|
+
transaction.id.length > 256 || !Array.isArray(earlier) ||
|
|
111
|
+
earlier.some(row => row?.transaction?.id === transaction.id) ||
|
|
112
|
+
typeof transaction.documentAuthority !== 'string' ||
|
|
113
|
+
transaction.documentAuthority !== documentAuthority ||
|
|
114
|
+
(transaction.transport !== 'platform' && transaction.transport !== 'webmcp') ||
|
|
115
|
+
typeof transaction.operation !== 'string' || !transaction.operation.length ||
|
|
116
|
+
transaction.operation.length > _RAPIER_TRANSACTION_OPERATION_LIMIT ||
|
|
117
|
+
!nullableText(transaction.requestId, _RAPIER_TRANSACTION_REQUEST_LIMIT) ||
|
|
118
|
+
!nullableText(transaction.sourceTransactionId, _RAPIER_TRANSACTION_REQUEST_LIMIT) ||
|
|
119
|
+
!nullableText(transaction.parent, 256, false) ||
|
|
120
|
+
!nullableText(transaction.reverts, 256, false) ||
|
|
121
|
+
!nullableText(transaction.reapplies, 256, false) ||
|
|
122
|
+
!Number.isSafeInteger(transaction.createdAt) || transaction.createdAt < 0 ||
|
|
123
|
+
!Array.isArray(transaction.affectedBlockIds) || transaction.affectedBlockIds.length > 64 ||
|
|
124
|
+
!transaction.affectedBlockIds.every(id => Number.isSafeInteger(id) && id >= 0) ||
|
|
125
|
+
!Number.isSafeInteger(transaction.revision) || transaction.revision < 1 ||
|
|
126
|
+
!Number.isSafeInteger(transaction.baseRevision) ||
|
|
127
|
+
transaction.baseRevision + 1 !== transaction.revision ||
|
|
128
|
+
(prior && (transaction.revision <= prior.transaction.revision ||
|
|
129
|
+
(transaction.baseRevision === prior.transaction.revision
|
|
130
|
+
? (transaction.parent !== prior.transaction.id || record.beforeHash !== prior.afterHash)
|
|
131
|
+
: (!allowGap || transaction.baseRevision <= prior.transaction.revision))))) return false;
|
|
132
|
+
return !!transaction.actor && typeof transaction.actor.kind === 'string' &&
|
|
133
|
+
/^(?:human|agent|system)$/.test(transaction.actor.kind) &&
|
|
134
|
+
typeof transaction.actor.id === 'string' && transaction.actor.id.length > 0 &&
|
|
135
|
+
transaction.actor.id.length <= _RAPIER_TRANSACTION_ACTOR_LIMIT;
|
|
136
|
+
} catch (_) { return false; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { _RAPIER_TRANSACTION_ACTOR_LIMIT, _RAPIER_TRANSACTION_OPERATION_LIMIT, _RAPIER_TRANSACTION_REQUEST_LIMIT, _rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord, _rapierJournalRecord};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {readLedger, exportLedger, rootAfter} from './format.mjs';
|
|
3
|
+
import {_rapierTransformSplices} from './journal-records.mjs';
|
|
4
|
+
import {mergeText} from './text-merge.mjs';
|
|
5
|
+
import {parseWill} from '../../agent/will.mjs';
|
|
6
|
+
import {sha256} from './hash.mjs';
|
|
7
|
+
import {canonicalJSON, fault} from './data.mjs';
|
|
8
|
+
// The same interval transport used by the agent kernel, now shared with the public ledger.
|
|
9
|
+
export function transportInterval(start, end, splices) {
|
|
10
|
+
let a = start, b = end;
|
|
11
|
+
for (const row of splices) {
|
|
12
|
+
if (!Number.isSafeInteger(row.pos) || row.pos < 0) return null;
|
|
13
|
+
if (row.pos + row.removed.length <= a) {
|
|
14
|
+
const delta = row.inserted.length - row.removed.length; a += delta; b += delta;
|
|
15
|
+
} else if (row.pos < b || (a === b && row.pos < a && row.pos + row.removed.length > a)) return null;
|
|
16
|
+
}
|
|
17
|
+
return {start: a, end: b};
|
|
18
|
+
}
|
|
19
|
+
function over(row, other) {
|
|
20
|
+
// Equal-position insertions have no ordering evidence. Review, never a lexical winner.
|
|
21
|
+
if (!row.removed.length && !other.removed.length && row.pos === other.pos) return null;
|
|
22
|
+
const range = transportInterval(row.pos, row.pos + row.removed.length, [other]);
|
|
23
|
+
return range ? {...row, pos: range.start} : null;
|
|
24
|
+
}
|
|
25
|
+
const recordKey = record => sha256(canonicalJSON(record));
|
|
26
|
+
const origin = record => record.transaction.sourceTransactionId?.startsWith('ledger:') ? record.transaction.sourceTransactionId : 'ledger:' + recordKey(record);
|
|
27
|
+
function checkpointList(read) {
|
|
28
|
+
const list = [{root: read.ledger.start.root, text: read.ledger.start.text, index: 0}], records = read.ledger.records;
|
|
29
|
+
let text = read.ledger.start.text;
|
|
30
|
+
for (let i = 0; i < records.length; i++) { text = _rapierTransformSplices(text, records[i].splices); list.push({root: records[i].afterHash, text, index: i + 1}); }
|
|
31
|
+
return list;
|
|
32
|
+
}
|
|
33
|
+
function kept(text) {
|
|
34
|
+
const will = parseWill(text);
|
|
35
|
+
if (will.fault) return {fault: will.fault, text};
|
|
36
|
+
return will.regions.filter(region => region.law === 'keep').map(region => text.slice(region.start, region.end));
|
|
37
|
+
}
|
|
38
|
+
export function merge(first, second) {
|
|
39
|
+
const ours = readLedger(first), theirs = readLedger(second);
|
|
40
|
+
// A reviewed conflict receipt names the complete incoming envelope: it too is idempotent.
|
|
41
|
+
if (ours.ledger.records.some(record => record.transaction.operation === 'document.merge' && record.transaction.sourceTransactionId === 'ledger:' + theirs.ledger.sha256)) {
|
|
42
|
+
const clean = !ours.text.includes('<!-- note-conflict:v1 ');
|
|
43
|
+
return {text: ours.text, ledger: ours.ledger, clean, conflicts: [], review: {required: !clean, base: ours.text, proposed: ours.text}};
|
|
44
|
+
}
|
|
45
|
+
const a = checkpointList(ours), b = checkpointList(theirs);
|
|
46
|
+
let common = null;
|
|
47
|
+
for (let i = b.length - 1; i >= 0 && !common; i--) {
|
|
48
|
+
const found = a.findLast(point => point.root === b[i].root && point.text === b[i].text);
|
|
49
|
+
if (found) common = {ours: found.index, theirs: b[i].index, text: found.text};
|
|
50
|
+
}
|
|
51
|
+
if (!common) throw fault('copies have no proven common start');
|
|
52
|
+
const records = ours.ledger.records.slice(), imported = new Set(records.map(origin));
|
|
53
|
+
let text = ours.text, root = ours.root;
|
|
54
|
+
const append = (splices, actor, sourceId, operation, at) => {
|
|
55
|
+
const next = _rapierTransformSplices(text, splices);
|
|
56
|
+
if (next === null) throw fault('transported record failed replay');
|
|
57
|
+
const beforeHash = root, revision = (records.at(-1)?.transaction.revision ?? ours.ledger.start.revision) + 1;
|
|
58
|
+
for (const row of splices) root = rootAfter(root, row);
|
|
59
|
+
records.push({beforeHash, afterHash: root, splices, transaction: {
|
|
60
|
+
id: ours.ledger.documentAuthority.slice(0, 48) + ':merge:' + revision.toString(36), documentAuthority: ours.ledger.documentAuthority,
|
|
61
|
+
baseRevision: revision - 1, revision, actor, transport: 'platform', operation, requestId: null,
|
|
62
|
+
sourceTransactionId: sourceId, affectedBlockIds: [], parent: records.at(-1)?.transaction.id ?? null,
|
|
63
|
+
reverts: null, reapplies: null, createdAt: at,
|
|
64
|
+
}}); text = next;
|
|
65
|
+
};
|
|
66
|
+
let against = ours.ledger.records.slice(common.ours).flatMap(record => record.splices.map(row => ({...row})));
|
|
67
|
+
const incoming = theirs.ledger.records.slice(common.theirs);
|
|
68
|
+
// Complete repeats are detected by record content, never the revision ID (copies can reuse IDs).
|
|
69
|
+
const unseen = incoming.filter(record => !imported.has(origin(record)));
|
|
70
|
+
const keepDiffers = canonicalJSON(kept(ours.text)) !== canonicalJSON(kept(theirs.text));
|
|
71
|
+
let conflicted = keepDiffers && unseen.length > 0;
|
|
72
|
+
if (!conflicted && unseen.length && unseen.length !== incoming.length) conflicted = true; // partial receipt needs a common text merge
|
|
73
|
+
if (!conflicted) for (const record of unseen) {
|
|
74
|
+
const moved = [];
|
|
75
|
+
for (const splice of record.splices) {
|
|
76
|
+
let row = {...splice}; const nextAgainst = [];
|
|
77
|
+
for (const other of against) {
|
|
78
|
+
const next = over(row, other), otherNext = over(other, row);
|
|
79
|
+
if (!next || !otherNext) { conflicted = true; break; }
|
|
80
|
+
nextAgainst.push(otherNext); row = next;
|
|
81
|
+
}
|
|
82
|
+
if (conflicted) break;
|
|
83
|
+
moved.push(row); against = nextAgainst;
|
|
84
|
+
}
|
|
85
|
+
if (conflicted || _rapierTransformSplices(text, moved) === null) { conflicted = true; break; }
|
|
86
|
+
append(moved, record.transaction.actor, origin(record), record.transaction.operation, record.transaction.createdAt);
|
|
87
|
+
}
|
|
88
|
+
let conflicts = [];
|
|
89
|
+
if (conflicted) {
|
|
90
|
+
// Restart from the original local head: partially transported records never leak into a conflict result.
|
|
91
|
+
records.splice(ours.ledger.records.length); text = ours.text; root = ours.root;
|
|
92
|
+
const merged = mergeText(keepDiffers ? null : common.text, ours.text, theirs.text, {oursId: 'copy-' + ours.ledger.sha256.slice(0, 16), theirsId: 'copy-' + theirs.ledger.sha256.slice(0, 16)});
|
|
93
|
+
conflicts = merged.conflicts;
|
|
94
|
+
if (merged.text !== text) append([{pos: 0, removed: text, inserted: merged.text}], {kind: 'system', id: 'merge'},
|
|
95
|
+
'ledger:' + theirs.ledger.sha256, 'document.merge', Math.max(0, ...incoming.map(record => record.transaction.createdAt)));
|
|
96
|
+
}
|
|
97
|
+
const ledger = exportLedger({text, records, documentAuthority: ours.ledger.documentAuthority, root,
|
|
98
|
+
revision: records.at(-1)?.transaction.revision ?? ours.revision, complete: ours.complete});
|
|
99
|
+
const clean = !conflicts.length && !text.includes('<!-- note-conflict:v1 ');
|
|
100
|
+
return {text, ledger, clean, conflicts, review: {required: !clean, base: ours.text, proposed: text}};
|
|
101
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Shared conflict envelopes and exact three-way text merge; Notes and the ledger import this owner.
|
|
3
|
+
function textFault(code, message) { return Object.assign(new Error(message), {code}); }
|
|
4
|
+
export function canonicalNote(text) {
|
|
5
|
+
if (typeof text !== 'string') throw textFault('note_text', 'A note must be text');
|
|
6
|
+
for (let i = 0; i < text.length; i++) {
|
|
7
|
+
const c = text.charCodeAt(i);
|
|
8
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
9
|
+
const next = text.charCodeAt(++i);
|
|
10
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) throw textFault('note_unicode', 'An unpaired surrogate has no exact UTF-8 bytes');
|
|
11
|
+
} else if (c >= 0xdc00 && c <= 0xdfff) throw textFault('note_unicode', 'An unpaired surrogate has no exact UTF-8 bytes');
|
|
12
|
+
}
|
|
13
|
+
return text;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TEXT_CONFLICT = '<!-- note-conflict:v1 ';
|
|
17
|
+
const textLine = line => line.replace(/(?:\r\n|\r|\n)$/, '');
|
|
18
|
+
const textBlank = line => /^[ \t]*$/.test(textLine(line));
|
|
19
|
+
const textSpace = unit => /^[ \t\r\n]*$/.test(unit);
|
|
20
|
+
const textList = line => /^( {0,3})(?:[-+*]|\d{1,9}[.)])[ \t]+/.exec(line);
|
|
21
|
+
const textFence = line => /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
22
|
+
const textRule = line => /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/.test(line);
|
|
23
|
+
const textHeading = line => /^ {0,3}#{1,6}(?:[ \t]|$)/.test(line);
|
|
24
|
+
const textDefinition = line => /^ {0,3}\[(?:\\.|[^\]\\\r\n])+\]:/.test(line);
|
|
25
|
+
const textStart = line => textHeading(line) || textRule(line) || textFence(line) || /^ {0,3}(?:[-+*]|1[.)])[ \t]+/.test(line) || /^ {0,3}(?:>|<!--|<(?:script|style|pre|textarea)(?:\s|>))/i.test(line);
|
|
26
|
+
|
|
27
|
+
// Only the exact public envelope is structural; malformed comments and fenced examples stay text.
|
|
28
|
+
function textEnvelopeAt(text, start = 0) {
|
|
29
|
+
if (!text.startsWith(TEXT_CONFLICT, start)) return null;
|
|
30
|
+
const headerEnd = text.indexOf(' -->\n\n', start), variants = [];
|
|
31
|
+
if (headerEnd < 0) return null;
|
|
32
|
+
let header;
|
|
33
|
+
try { header = JSON.parse(text.slice(start + TEXT_CONFLICT.length, headerEnd)); } catch { return null; }
|
|
34
|
+
if (!header || typeof header.id !== 'string' || !Array.isArray(header.variants) || header.variants.length < 2) return null;
|
|
35
|
+
let at = headerEnd + 6;
|
|
36
|
+
for (const item of header.variants) {
|
|
37
|
+
if (!item || typeof item.device !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(item.device) || !Number.isSafeInteger(item.length) || item.length < 0) return null;
|
|
38
|
+
const label = '**Version from ' + item.device + '**\n\n';
|
|
39
|
+
if (!text.startsWith(label, at)) return null;
|
|
40
|
+
at += label.length;
|
|
41
|
+
const fence = /^(`{3,})markdown\n/.exec(text.slice(at));
|
|
42
|
+
if (!fence) return null;
|
|
43
|
+
at += fence[0].length;
|
|
44
|
+
const value = text.slice(at, at + item.length); at += item.length;
|
|
45
|
+
const tail = (value.endsWith('\n') ? '' : '\n') + fence[1] + '\n\n';
|
|
46
|
+
if (!text.startsWith(tail, at)) return null;
|
|
47
|
+
at += tail.length; variants.push({device: item.device, text: value});
|
|
48
|
+
}
|
|
49
|
+
const tail = '<!-- /note-conflict:v1 -->\n';
|
|
50
|
+
if (!text.startsWith(tail, at)) return null;
|
|
51
|
+
const end = at + tail.length, block = text.slice(start, end);
|
|
52
|
+
if (textEnvelope(header.id, variants) !== block) return null;
|
|
53
|
+
return {kind: 'text', id: header.id, start, end, block, variants};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function inspectTextConflicts(text, {nested = false} = {}) {
|
|
57
|
+
canonicalNote(text);
|
|
58
|
+
const out = []; let at = 0;
|
|
59
|
+
for (const unit of textUnits(text)) {
|
|
60
|
+
const found = textEnvelopeAt(unit);
|
|
61
|
+
if (found && found.end === unit.length) {
|
|
62
|
+
out.push({...found, start: at, end: at + unit.length});
|
|
63
|
+
// Nested offsets belong to their alternative's source; custody callers use
|
|
64
|
+
// the exact block bytes. Ordinary fenced examples remain opaque at every level.
|
|
65
|
+
if (nested) for (const variant of found.variants) out.push(...inspectTextConflicts(variant.text, {nested}));
|
|
66
|
+
}
|
|
67
|
+
at += unit.length;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function mapTextConflictVariants(text, mapper) {
|
|
73
|
+
const conflicts = inspectTextConflicts(text); let out = '', at = 0;
|
|
74
|
+
for (const block of conflicts) {
|
|
75
|
+
out += text.slice(at, block.start) + textEnvelope(block.id, block.variants.map(variant => ({
|
|
76
|
+
device: variant.device, text: canonicalNote(mapper(mapTextConflictVariants(variant.text, mapper), variant.device)),
|
|
77
|
+
})));
|
|
78
|
+
at = block.end;
|
|
79
|
+
}
|
|
80
|
+
return out + text.slice(at);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// This is a conservative edit boundary scan, never the document's rendering grammar.
|
|
84
|
+
function textUnits(text) {
|
|
85
|
+
const lines = text.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) || [], out = [], offsets = [0];
|
|
86
|
+
for (const line of lines) offsets.push(offsets[offsets.length - 1] + line.length);
|
|
87
|
+
for (let i = 0; i < lines.length;) {
|
|
88
|
+
const start = i, first = textLine(lines[i]).replace(/^\ufeff/, ''), list = textList(first), fence = textFence(first);
|
|
89
|
+
const envelope = first.startsWith(TEXT_CONFLICT) ? textEnvelopeAt(text, offsets[i]) : null;
|
|
90
|
+
if (envelope) {
|
|
91
|
+
while (i < lines.length && offsets[i] < envelope.end) i++;
|
|
92
|
+
out.push(lines.slice(start, i).join('')); continue;
|
|
93
|
+
}
|
|
94
|
+
if (textBlank(lines[i])) { while (++i < lines.length && textBlank(lines[i])) {} }
|
|
95
|
+
else if (fence) {
|
|
96
|
+
const mark = fence[1][0], count = fence[1].length;
|
|
97
|
+
i++;
|
|
98
|
+
while (i < lines.length) {
|
|
99
|
+
const close = new RegExp('^ {0,3}' + (mark === '`' ? '`' : '~') + '{' + count + ',}[ \\t]*$').test(textLine(lines[i++]));
|
|
100
|
+
if (close) break;
|
|
101
|
+
}
|
|
102
|
+
} else if (list) {
|
|
103
|
+
const indent = list[1].length;
|
|
104
|
+
i++;
|
|
105
|
+
while (i < lines.length) {
|
|
106
|
+
const line = textLine(lines[i]), next = textList(line);
|
|
107
|
+
if (next && next[1].length <= indent) break;
|
|
108
|
+
if (textBlank(lines[i])) {
|
|
109
|
+
let j = i; while (j < lines.length && textBlank(lines[j])) j++;
|
|
110
|
+
if (j === lines.length || !new RegExp('^(?: {' + (indent + 1) + ',}|\\t)').test(lines[j])) break;
|
|
111
|
+
i = j; continue;
|
|
112
|
+
}
|
|
113
|
+
if (!/^[ \t]/.test(line) && textStart(line)) break;
|
|
114
|
+
i++;
|
|
115
|
+
}
|
|
116
|
+
} else if (/^ {0,3}>/.test(first)) {
|
|
117
|
+
i++;
|
|
118
|
+
while (i < lines.length) {
|
|
119
|
+
if (textBlank(lines[i])) {
|
|
120
|
+
let j = i; while (j < lines.length && textBlank(lines[j])) j++;
|
|
121
|
+
if (j === lines.length || !/^ {0,3}>/.test(lines[j])) break;
|
|
122
|
+
i = j; continue;
|
|
123
|
+
}
|
|
124
|
+
if (!/^ {0,3}>/.test(lines[i]) && textStart(textLine(lines[i]))) break;
|
|
125
|
+
i++;
|
|
126
|
+
}
|
|
127
|
+
} else if (/^ {0,3}<!--/.test(first)) {
|
|
128
|
+
i++;
|
|
129
|
+
if (!first.includes('-->')) while (i < lines.length && !lines[i++].includes('-->')) {}
|
|
130
|
+
} else if (/^ {0,3}<(script|style|pre|textarea)(?:\s|>)/i.test(first)) {
|
|
131
|
+
const tag = /^ {0,3}<([a-z]+)/i.exec(first)[1]; i++;
|
|
132
|
+
if (!new RegExp('</' + tag + '\\s*>', 'i').test(first)) while (i < lines.length && !new RegExp('</' + tag + '\\s*>', 'i').test(lines[i++])) {}
|
|
133
|
+
} else if (textHeading(first) || textRule(first)) i++;
|
|
134
|
+
else {
|
|
135
|
+
i++;
|
|
136
|
+
while (i < lines.length && !textBlank(lines[i])) {
|
|
137
|
+
if (/^ {0,3}(?:=+|-+)[ \t]*$/.test(textLine(lines[i]))) { i++; break; }
|
|
138
|
+
if (textStart(textLine(lines[i]))) break;
|
|
139
|
+
i++;
|
|
140
|
+
}
|
|
141
|
+
if (textDefinition(first) || /^(?: {4}|\t)/.test(first)) {
|
|
142
|
+
while (i < lines.length) {
|
|
143
|
+
let j = i; while (j < lines.length && textBlank(lines[j])) j++;
|
|
144
|
+
if (j === lines.length || !/^(?: {4}|\t)/.test(lines[j])) break;
|
|
145
|
+
i = j + 1; while (i < lines.length && !textBlank(lines[i]) && /^(?: {4}|\t)/.test(lines[i])) i++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
out.push(lines.slice(start, i).join(''));
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function textEdits(base, side) {
|
|
155
|
+
const n = base.length, m = side.length;
|
|
156
|
+
if ((n + 1) * (m + 1) > 1000000) return null;
|
|
157
|
+
const width = m + 1, score = new Uint32Array((n + 1) * width);
|
|
158
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
|
|
159
|
+
const p = i * width + j;
|
|
160
|
+
score[p] = base[i] === side[j] ? score[p + width + 1] + 1 : Math.max(score[p + width], score[p + 1]);
|
|
161
|
+
}
|
|
162
|
+
const walk = () => {
|
|
163
|
+
const pairs = []; let i = 0, j = 0;
|
|
164
|
+
while (i < n && j < m) {
|
|
165
|
+
const value = score[i * width + j]; if (!value) break;
|
|
166
|
+
if (base[i] === side[j]) { pairs.push([i++, j++]); continue; }
|
|
167
|
+
if (score[(i + 1) * width + j] >= score[i * width + j + 1]) i++; else j++;
|
|
168
|
+
}
|
|
169
|
+
return pairs;
|
|
170
|
+
};
|
|
171
|
+
const pairs = walk(), meaningful = pairs.filter(([i]) => !textSpace(base[i]));
|
|
172
|
+
// Every optimal path must agree on every authored anchor, not just two greedy walks.
|
|
173
|
+
const seen = new Uint32Array(score.length); seen.fill(0xffffffff); seen[0] = 0;
|
|
174
|
+
for (let i = 0; i <= n; i++) for (let j = 0; j <= m; j++) {
|
|
175
|
+
const p = i * width + j, at = seen[p]; if (at === 0xffffffff) continue;
|
|
176
|
+
const visit = (next, count) => { if (seen[next] !== 0xffffffff && seen[next] !== count) return false; seen[next] = count; return true; };
|
|
177
|
+
if (i < n && score[p + width] === score[p] && !visit(p + width, at)) return null;
|
|
178
|
+
if (j < m && score[p + 1] === score[p] && !visit(p + 1, at)) return null;
|
|
179
|
+
if (i < n && j < m && base[i] === side[j] && score[p] === score[p + width + 1] + 1) {
|
|
180
|
+
const content = !textSpace(base[i]);
|
|
181
|
+
if (content && (meaningful[at]?.[0] !== i || meaningful[at]?.[1] !== j)) return null;
|
|
182
|
+
if (!visit(p + width + 1, at + (content ? 1 : 0))) return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (seen[n * width + m] !== meaningful.length) return null;
|
|
186
|
+
const edits = []; let i = 0, j = 0;
|
|
187
|
+
for (const [bi, sj] of [...pairs, [n, m]]) {
|
|
188
|
+
if (i !== bi || j !== sj) edits.push({start: i, end: bi, text: side.slice(j, sj).join('')});
|
|
189
|
+
i = bi + 1; j = sj + 1;
|
|
190
|
+
}
|
|
191
|
+
return edits;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function textTick(base, a, b) {
|
|
195
|
+
const box = value => {
|
|
196
|
+
const m = /^( {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+\[)([ xX])(\])/.exec(value);
|
|
197
|
+
return m && {at: m[1].length, tick: m[2], body: value.slice(0, m[1].length) + ' ' + value.slice(m[1].length + 1)};
|
|
198
|
+
};
|
|
199
|
+
const c = box(base); if (!c) return null;
|
|
200
|
+
// One side ticked, the other added lines: the tick lands on the item found whole among the other side's units. Ambiguity is a conflict.
|
|
201
|
+
const au = textUnits(a), bu = textUnits(b);
|
|
202
|
+
if (au.length !== 1 || bu.length !== 1) {
|
|
203
|
+
const [one, many] = au.length === 1 ? [a, bu] : bu.length === 1 ? [b, au] : [null, null];
|
|
204
|
+
const x = one === null ? null : box(one);
|
|
205
|
+
if (!x || x.body !== c.body || x.tick === c.tick) return null;
|
|
206
|
+
const at = many.map((unit, i) => unit === base ? i : -1).filter(i => i >= 0);
|
|
207
|
+
if (at.length !== 1) return null;
|
|
208
|
+
return many.map((unit, i) => i === at[0] ? one : unit).join('');
|
|
209
|
+
}
|
|
210
|
+
const x = box(a), y = box(b); if (!x || !y) return null;
|
|
211
|
+
const body = x.body === y.body ? x.body : x.body === c.body ? y.body : y.body === c.body ? x.body : null;
|
|
212
|
+
if (body === null) return null;
|
|
213
|
+
const marked = x.tick !== ' ' && y.tick !== ' ' ? [x.tick, y.tick].sort()[0] : x.tick === c.tick ? y.tick : y.tick === c.tick ? x.tick : null;
|
|
214
|
+
const at = box(body).at;
|
|
215
|
+
return marked === null ? null : body.slice(0, at) + marked + body.slice(at + 1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function textDeviceOptions(options) {
|
|
219
|
+
const {oursId, theirsId} = options || {};
|
|
220
|
+
if (![oursId, theirsId].every(id => typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(id)) || oursId === theirsId) {
|
|
221
|
+
throw textFault('merge_devices', 'A conflict needs two distinct stable device identifiers');
|
|
222
|
+
}
|
|
223
|
+
return [oursId, theirsId];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function textEnvelope(id, variants) {
|
|
227
|
+
let longest = 2;
|
|
228
|
+
for (const v of variants) for (const run of v.text.match(/`+/g) || []) longest = Math.max(longest, run.length);
|
|
229
|
+
const fence = '`'.repeat(longest + 1);
|
|
230
|
+
let out = TEXT_CONFLICT + JSON.stringify({id, variants: variants.map(v => ({device: v.device, length: v.text.length}))}) + ' -->\n\n';
|
|
231
|
+
for (const v of variants) {
|
|
232
|
+
out += '**Version from ' + v.device + '**\n\n' + fence + 'markdown\n' + v.text + (v.text.endsWith('\n') ? '' : '\n') + fence + '\n\n';
|
|
233
|
+
}
|
|
234
|
+
return out + '<!-- /note-conflict:v1 -->\n';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function textDeclarations(text) {
|
|
238
|
+
text = textUnits(text).filter(unit => !textEnvelopeAt(unit)).join('');
|
|
239
|
+
// A definition can move a destination/title onto the next unindented line, or span its label.
|
|
240
|
+
const definitions = text.match(/^\ufeff? {0,3}\[(?:\\.|[^\]\\])+\]:[^\r\n]*(?:(?:\r\n|\r|\n)(?![ ]*(?:\r\n|\r|\n|$))[^\r\n]*)*/gm) || [];
|
|
241
|
+
const continuations = textUnits(text).filter(unit => /^\ufeff? {0,3}\[\^/.test(unit));
|
|
242
|
+
return [...definitions, ...continuations, ...(text.match(/<!--[\s\S]*?(?:-->|$)/g) || [])].join('\0');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function mergeText(base, ours, theirs, options = {}) {
|
|
246
|
+
canonicalNote(ours); canonicalNote(theirs); if (base !== null) canonicalNote(base);
|
|
247
|
+
const unchanged = value => ({text: value, clean: !value.includes(TEXT_CONFLICT), conflicts: value.includes(TEXT_CONFLICT) ? [{kind: 'existing-conflict', advisory: true}] : []});
|
|
248
|
+
if (ours === theirs) return unchanged(ours);
|
|
249
|
+
if (base === ours) return unchanged(theirs);
|
|
250
|
+
if (base === theirs) return unchanged(ours);
|
|
251
|
+
const conflicts = []; let text = '';
|
|
252
|
+
const conflict = (before, a, b, kind) => {
|
|
253
|
+
// Shared framing remains ordinary text; only the changed region is a choice.
|
|
254
|
+
const ab = inspectTextConflicts(a), bb = inspectTextConflicts(b);
|
|
255
|
+
const au = textUnits(a), bu = textUnits(b); let first = 0, last = 0;
|
|
256
|
+
while (first < Math.min(au.length, bu.length) && au[first] === bu[first]) first++;
|
|
257
|
+
while (last < Math.min(au.length, bu.length) - first && au[au.length - 1 - last] === bu[bu.length - 1 - last]) last++;
|
|
258
|
+
if ((ab.length || bb.length) && (first || last)) {
|
|
259
|
+
const prefix = au.slice(0, first).join(''), suffix = last ? au.slice(-last).join('') : '';
|
|
260
|
+
text += prefix;
|
|
261
|
+
conflict(null, au.slice(first, last ? -last : undefined).join(''), bu.slice(first, last ? -last : undefined).join(''), kind);
|
|
262
|
+
text += suffix; return;
|
|
263
|
+
}
|
|
264
|
+
// A shared unresolved region is an anchor even when its alternatives have grown. Splitting
|
|
265
|
+
// around it keeps surrounding edits in their own choices instead of quoting an old choice.
|
|
266
|
+
const overlaps = (x, y) => x.variants.some(v => y.variants.some(w => v.device === w.device && v.text === w.text));
|
|
267
|
+
const anchor = ab.map(x => ({x, matches: bb.filter(y => overlaps(x, y))})).find(({x, matches}) =>
|
|
268
|
+
matches.length === 1 && ab.filter(y => overlaps(y, matches[0])).length === 1 &&
|
|
269
|
+
(x.start || matches[0].start || x.end < a.length || matches[0].end < b.length));
|
|
270
|
+
if (anchor) {
|
|
271
|
+
const {x, matches: [y]} = anchor;
|
|
272
|
+
for (const [av, bv] of [[a.slice(0, x.start), b.slice(0, y.start)], [x.block, y.block], [a.slice(x.end), b.slice(y.end)]]) {
|
|
273
|
+
if (av === bv) text += av; else conflict(null, av, bv, kind);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const ids = textDeviceOptions(options), candidates = [{device: ids[0], text: a}, {device: ids[1], text: b}];
|
|
278
|
+
const variants = [...new Map(candidates.flatMap(v => {
|
|
279
|
+
const block = textEnvelopeAt(v.text);
|
|
280
|
+
return block && block.end === v.text.length ? block.variants : [v];
|
|
281
|
+
}).map(v => [JSON.stringify(v), v])).values()].sort((x, y) => x.device < y.device ? -1 : x.device > y.device ? 1 : x.text < y.text ? -1 : x.text > y.text ? 1 : 0);
|
|
282
|
+
if (text && !/[\r\n]$/.test(text)) text += '\n\n';
|
|
283
|
+
const start = text.length, block = textEnvelope(String(conflicts.length), variants);
|
|
284
|
+
conflicts.push({kind, base: before, variants, start, end: start + block.length, block});
|
|
285
|
+
text += block;
|
|
286
|
+
};
|
|
287
|
+
if (base === null) conflict(null, ours, theirs, 'no-ancestor');
|
|
288
|
+
else if (textDeclarations(ours) !== textDeclarations(base) || textDeclarations(theirs) !== textDeclarations(base)) conflict(base, ours, theirs, 'document-bindings');
|
|
289
|
+
else {
|
|
290
|
+
const units = textUnits(base), a = textEdits(units, textUnits(ours)), b = textEdits(units, textUnits(theirs));
|
|
291
|
+
if (!a || !b) conflict(base, ours, theirs, 'ambiguous-alignment-or-budget');
|
|
292
|
+
else {
|
|
293
|
+
const changes = [...a.map(e => ({...e, side: 0})), ...b.map(e => ({...e, side: 1}))].sort((x, y) => x.start - y.start || x.end - y.end || x.side - y.side);
|
|
294
|
+
let pos = 0;
|
|
295
|
+
for (let i = 0; i < changes.length;) {
|
|
296
|
+
const group = [changes[i++]], start = group[0].start; let end = group[0].end;
|
|
297
|
+
// An insertion can anchor on either side of a blank run; that is still one gap.
|
|
298
|
+
while (i < changes.length && (changes[i].start < end || group.some(e => {
|
|
299
|
+
const next = changes[i];
|
|
300
|
+
// Appending after an unresolved block does not edit any of its alternatives.
|
|
301
|
+
const afterBlock = (edit, insert) => insert.start === insert.end && insert.start === edit.end && edit.start < edit.end && units.slice(edit.start, edit.end).every(unit => textEnvelopeAt(unit));
|
|
302
|
+
if (afterBlock(e, next) || afterBlock(next, e)) return false;
|
|
303
|
+
if (e.start === e.end && next.start <= e.start && e.start <= next.end) return true;
|
|
304
|
+
if (next.start === next.end && e.start <= next.start && next.start <= e.end) return true;
|
|
305
|
+
return next.start >= e.end && units.slice(e.start, e.end).every(textSpace) &&
|
|
306
|
+
units.slice(e.end, next.start).every(textSpace) && units.slice(next.start, next.end).every(textSpace);
|
|
307
|
+
}))) {
|
|
308
|
+
group.push(changes[i]); end = Math.max(end, changes[i++].end);
|
|
309
|
+
}
|
|
310
|
+
text += units.slice(pos, start).join('');
|
|
311
|
+
const before = units.slice(start, end).join('');
|
|
312
|
+
const version = side => {
|
|
313
|
+
let value = '', at = start;
|
|
314
|
+
for (const e of group.filter(e => e.side === side)) { value += units.slice(at, e.start).join('') + e.text; at = e.end; }
|
|
315
|
+
return value + units.slice(at, end).join('');
|
|
316
|
+
};
|
|
317
|
+
const av = version(0), bv = version(1);
|
|
318
|
+
if (av === bv) text += av;
|
|
319
|
+
else if (av === before) text += bv;
|
|
320
|
+
else if (bv === before) text += av;
|
|
321
|
+
else {
|
|
322
|
+
const tick = end === start + 1 ? textTick(before, av, bv) : null;
|
|
323
|
+
if (tick !== null) text += tick; else conflict(before, av, bv, 'text');
|
|
324
|
+
}
|
|
325
|
+
pos = end;
|
|
326
|
+
}
|
|
327
|
+
text += units.slice(pos).join('');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return {text, clean: conflicts.length === 0 && !text.includes(TEXT_CONFLICT), conflicts};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// A marker is advisory data. The caller supplies its trusted descriptor and current revision.
|
|
334
|
+
export function resolveTextConflict(text, conflict, device) {
|
|
335
|
+
canonicalNote(text);
|
|
336
|
+
if (!conflict || !Number.isInteger(conflict.start) || !Number.isInteger(conflict.end) || conflict.start < 0 || conflict.end < conflict.start || text.slice(conflict.start, conflict.end) !== conflict.block) {
|
|
337
|
+
throw textFault('merge_stale', 'The inspected conflict changed');
|
|
338
|
+
}
|
|
339
|
+
const matches = Number.isInteger(device) ? [conflict.variants?.[device]].filter(Boolean) : conflict.variants?.filter(v => v.device === device) || [];
|
|
340
|
+
const version = matches.length === 1 ? matches[0] : null;
|
|
341
|
+
if (!version) throw textFault('merge_choice', 'Choose one inspected version');
|
|
342
|
+
canonicalNote(version.text);
|
|
343
|
+
return text.slice(0, conflict.start) + version.text + text.slice(conflict.end);
|
|
344
|
+
}
|
|
345
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {exportLedger, replaceLedgerText, readLedger, textRoot, rootAfter, historyEnvelope, agentActorId} from './ledger/format.mjs';
|
|
3
|
+
export {exportLedger, replaceLedgerText, readLedger, textRoot, rootAfter, historyEnvelope, agentActorId};
|
|
4
|
+
import {authorship, readAuthorship, ledgerFromAuthorship} from './ledger/authorship.mjs';
|
|
5
|
+
export {authorship, readAuthorship, ledgerFromAuthorship};
|
|
6
|
+
import {merge, transportInterval} from './ledger/merge.mjs';
|
|
7
|
+
export {merge, transportInterval};
|
|
8
|
+
import {_rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord} from './ledger/journal-records.mjs';
|
|
9
|
+
export {_rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord};
|
package/dist/kit/render.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// The document's renderer. DOM, codecs and host state are explicit inputs.
|
|
3
3
|
function createRenderer(runtime) {
|
|
4
|
-
const {RAPIER_COLOR_CLOSE, RapierPageReturnAddress, RapierTextCodec, _rapierArtifactHighlight, _rapierArtifactLexerScript, _rapierArtifactMarkLexed, _rapierArtifactPreference, _rapierArtifactStyles, _rapierBlobDataUrl, _rapierBuildInterchangeContext, _rapierDocumentNameIsAdmissible, _rapierDrawReadSVGRecipe, _rapierDrawShapeProfileFor, _rapierFillDiagram, _rapierFormatColorOpen, _rapierLanguageClass, _rapierLineStartOffsets, _rapierMarkdownEnvironment, _rapierPortableHtml, _rapierPrepareInterchangeContext, _rapierProjectPortableRoot, _rapierSourceCharEscaped, _rapierSourceLineSpan, crypto, document, escapeRapierHtmlText, globalThis, rapierConfirm, sanitizeRapierHtml} = runtime;
|
|
4
|
+
const {RAPIER_COLOR_CLOSE, RapierLedgerCarried, RapierPageReturnAddress, RapierTextCodec, _rapierArtifactHighlight, _rapierArtifactLexerScript, _rapierArtifactMarkLexed, _rapierArtifactPreference, _rapierArtifactStyles, _rapierBlobDataUrl, _rapierBuildInterchangeContext, _rapierDocumentNameIsAdmissible, _rapierLedgerParts, _rapierDrawReadSVGRecipe, _rapierDrawShapeProfileFor, _rapierFillDiagram, _rapierFormatColorOpen, _rapierLanguageClass, _rapierLineStartOffsets, _rapierMarkdownEnvironment, _rapierPortableHtml, _rapierPrepareInterchangeContext, _rapierProjectPortableRoot, _rapierSourceCharEscaped, _rapierSourceLineSpan, crypto, document, escapeRapierHtmlText, globalThis, rapierConfirm, sanitizeRapierHtml} = runtime;
|
|
5
5
|
let md = runtime.md;
|
|
6
6
|
async function render(source, options = {}) {
|
|
7
7
|
if (typeof source !== 'string') throw new TypeError('Document source must be a string');
|
|
@@ -404,7 +404,9 @@ async function _rapierSharedSourceCarrier(context, root) {
|
|
|
404
404
|
'" data-kind="' + esc(context.metadata.docKind) + '" data-sha256="' + await _rapierSharedSourceHash(forms.resolved) +
|
|
405
405
|
'"' + (forms.ids.length ? ' data-images="' + forms.ids.join(' ') + '"' : '') +
|
|
406
406
|
(forms.definitions.length ? ' data-image-definitions="' + forms.definitions.map(encodeURIComponent).join(' ') + '"' : '') + '>\n' +
|
|
407
|
-
forms.carried + '\n</script>\n'
|
|
407
|
+
forms.carried + '\n</script>\n' +
|
|
408
|
+
// The optional edit ledger (W/4): carried only when the host binds its owner; a host without one writes none.
|
|
409
|
+
(RapierLedgerCarried ? RapierLedgerCarried.writeParts(forms.resolved, _rapierLedgerParts(forms.resolved, context.ledger, context.carried)) : '');
|
|
408
410
|
}
|
|
409
411
|
|
|
410
412
|
function _rapierSharedResolve(text, ids, images, definitions) {
|
|
@@ -460,7 +462,8 @@ async function _rapierReadSharedDocument(text, filename) {
|
|
|
460
462
|
if (resolved.broken || source.length > RapierTextCodec.maxDocumentBytes || await _rapierSharedSourceHash(source) !== sha256) return invalid();
|
|
461
463
|
const bom = source.charCodeAt(0) === 0xFEFF;
|
|
462
464
|
if (RapierTextCodec.normalizeDocument(source) !== (bom ? source.slice(1) : source)) return invalid();
|
|
463
|
-
|
|
465
|
+
const parts = RapierLedgerCarried ? RapierLedgerCarried.readParts(source, Array.from(template.content.querySelectorAll('#rapier-ledger, #rapier-authorship'))) : {};
|
|
466
|
+
return {source, filename: name, kind, bom, ...parts};
|
|
464
467
|
}
|
|
465
468
|
|
|
466
469
|
function _rapierSharedPageFallbackCss() {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rapier-markdown-kit",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "Read, render, write and style Self-contained Markdown: the shared HTML writer, semantic renderer, stylesheet, layout
|
|
3
|
+
"version": "1.1.58",
|
|
4
|
+
"description": "Read, render, write and style Self-contained Markdown, and carry its optional edit ledger: the shared HTML writer, semantic renderer, stylesheet, layout, marks, pictures, Will/1, replay, authorship and offline merge. MIT, no npm dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"markdown",
|
|
7
7
|
"self-contained",
|
|
@@ -46,7 +46,9 @@
|
|
|
46
46
|
"./assets": "./dist/kit/assets.mjs",
|
|
47
47
|
"./style.css": "./style.css",
|
|
48
48
|
"./package.json": "./package.json",
|
|
49
|
-
"./render": "./dist/kit/render-entry.mjs"
|
|
49
|
+
"./render": "./dist/kit/render-entry.mjs",
|
|
50
|
+
"./rapier-ledger": "./dist/kit/rapier-ledger.mjs",
|
|
51
|
+
"./ledger/carried": "./dist/kit/ledger/carried.mjs"
|
|
50
52
|
},
|
|
51
53
|
"repository": {
|
|
52
54
|
"type": "git",
|