rapier-markdown-kit 1.1.101 → 1.1.104
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 +2 -2
- package/README.md +213 -53
- package/dist/agent/vendor/hyphen-en-us/LICENSE +14 -0
- package/dist/agent/vendor/hyphen-en-us/SOURCE.json +33 -0
- package/dist/agent/vendor/hyphen-en-us/hyph-en-us.hyp.txt +14 -0
- package/dist/agent/vendor/hyphen-en-us/hyph-en-us.pat.txt +4938 -0
- package/dist/agent/vendor/hyphen-en-us/patterns.js +25 -0
- package/dist/agent/vendor/pretext/LICENSE +208 -0
- package/dist/agent/vendor/pretext/NOTICE +26 -13
- package/dist/agent/vendor/pretext/SOURCE.json +213 -45
- package/dist/agent/vendor/pretext/analysis.js +675 -1224
- package/dist/agent/vendor/pretext/entry-geometry.js +183 -0
- package/dist/agent/vendor/pretext/gecko-line-breaks.js +688 -0
- package/dist/agent/vendor/pretext/generated/engine-break-data.js +57 -0
- package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +80 -0
- package/dist/agent/vendor/pretext/graphemes.js +98 -0
- package/dist/agent/vendor/pretext/han-kerning.js +253 -0
- package/dist/agent/vendor/pretext/layout.js +352 -290
- package/dist/agent/vendor/pretext/line-break.js +1154 -588
- package/dist/agent/vendor/pretext/line-breaks.js +892 -0
- package/dist/agent/vendor/pretext/line-text.js +81 -39
- package/dist/agent/vendor/pretext/measurement.js +1105 -176
- package/dist/agent/vendor/pretext/prepare.js +807 -0
- package/dist/agent/vendor/pretext/rich-inline.js +1242 -273
- package/dist/agent/will.mjs +2 -0
- package/dist/kit/assets.mjs +5 -1
- package/dist/kit/conformance/run.mjs +4 -4
- package/dist/kit/export-fonts.mjs +26 -11
- package/dist/kit/index.mjs +1 -0
- package/dist/kit/ledger/carried.mjs +9 -7
- package/dist/kit/ledger/data.mjs +73 -13
- package/dist/kit/ledger/format.mjs +166 -53
- package/dist/kit/ledger/hash.mjs +75 -18
- package/dist/kit/ledger/history.mjs +143 -115
- package/dist/kit/ledger/inverse-source.mjs +158 -19
- package/dist/kit/ledger/journal-records.mjs +292 -67
- package/dist/kit/model.mjs +1 -1
- package/dist/kit/pdf-glyphs.mjs +4 -4
- package/dist/kit/render-markdown.mjs +259 -122
- package/dist/kit/render-sanitize.mjs +24 -4
- package/dist/kit/render-work.mjs +99 -20
- package/dist/kit/render.mjs +101 -65
- package/dist/kit/typography.mjs +2 -0
- package/dist/layout/hyphenation.mjs +127 -0
- package/dist/layout/line-plan.mjs +88 -96
- package/dist/layout/model.mjs +61 -80
- package/dist/layout/paragraph-fit.mjs +191 -0
- package/dist/layout/preparation.mjs +373 -0
- package/dist/layout/prepared-paragraph.mjs +223 -0
- package/dist/layout/typography.mjs +345 -111
- package/dist/packages/rapier-diff/index.mjs +288 -196
- package/dist/spec/document-fonts.mjs +13 -4
- package/dist/spec/md-assets.mjs +149 -30
- package/dist/spec/md-layout.mjs +5 -2
- package/dist/spec/md-marks.mjs +218 -3
- package/dist/spec/md-source.mjs +122 -0
- package/package.json +2 -1
- package/style.css +9 -0
- package/dist/agent/vendor/pretext/bidi.js +0 -151
- package/dist/agent/vendor/pretext/generated/bidi-data.js +0 -3831
package/dist/agent/will.mjs
CHANGED
|
@@ -59,6 +59,8 @@ function _rapierWillParse(text) {
|
|
|
59
59
|
const will = {
|
|
60
60
|
present: false, fault: '', faults: [], blocks: [], markers: [], regions: [], text: source,
|
|
61
61
|
};
|
|
62
|
+
// A marker, well formed or near, opens its line with one of the four spellings hasWillMarkers looks for.
|
|
63
|
+
if (!hasWillMarkers(source)) return will;
|
|
62
64
|
let open = null;
|
|
63
65
|
for (let index = 0, start = 0; start <= source.length; index++) {
|
|
64
66
|
let contentEnd = start;
|
package/dist/kit/assets.mjs
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// Re-export from ../spec/md-assets.mjs only: ../images/assets.mjs pulls AGPL decoders into the MIT graph (tools/check-kit.mjs).
|
|
3
|
-
|
|
3
|
+
// The kit's names are listed: that module also exports what only Rapier's own parser consumers share.
|
|
4
|
+
export {IMAGE_LIMITS, configureParser, normalizeLabel, assetTitle, installMarkdownImages, markdownParser, markdownBodyOffset,
|
|
5
|
+
dataImage, parseAssets, documentAssets, packAssetIndex, restoreAssetIndex, projectImageDefinitions, blockwiseAssets,
|
|
6
|
+
imageEnvironment, referenceOccurs, mayRetireImageDefinitions, retireDeletedImageDefinitions, assetOmissions, isAssetBlock,
|
|
7
|
+
escapeImageAlt, serializeAsset, appendAssetText, appendAsset} from '../spec/md-assets.mjs';
|
|
@@ -19,7 +19,7 @@ function parseArgs(argv) {
|
|
|
19
19
|
const args = parseArgs(process.argv.slice(2));
|
|
20
20
|
const impl = await import(args.impl.startsWith('file://') ? args.impl : 'file://' + args.impl);
|
|
21
21
|
|
|
22
|
-
const need = ['parseLayout', 'formatLayout', '
|
|
22
|
+
const need = ['parseLayout', 'formatLayout', 'createPreparationScope', 'flowLines', 'pictureSlices', 'bandsProfile', 'polygonProfile', 'rasterTiltProfile', 'parseProfile', 'wrapColumnFloor'];
|
|
23
23
|
const missing = need.filter(name => typeof impl[name] !== 'function');
|
|
24
24
|
if (missing.length) { console.error('run.mjs: implementation at ' + args.impl + ' is missing: ' + missing.join(', ')); process.exit(2); }
|
|
25
25
|
|
|
@@ -34,6 +34,7 @@ function buildProfile(hint) {
|
|
|
34
34
|
const documents = loadDocuments(here);
|
|
35
35
|
// Install once over merged data: Pretext memoizes its context (measurer.mjs).
|
|
36
36
|
const measurer = installMeasurer(documents);
|
|
37
|
+
const preparation = impl.createPreparationScope();
|
|
37
38
|
|
|
38
39
|
// Preservation and presentation are separate claims; preservation is exact or a bug.
|
|
39
40
|
const preservationRows = documents.map(expected => {
|
|
@@ -62,9 +63,8 @@ const rows = [];
|
|
|
62
63
|
for (const expected of documents) {
|
|
63
64
|
const font = expected.measurer.font.replace(/\/[^ ]+ /, ' '); // canvas fonts take no line-height
|
|
64
65
|
const before = {hits: measurer.stats.hits, misses: measurer.stats.misses};
|
|
65
|
-
const
|
|
66
|
-
if (!
|
|
67
|
-
const flow = impl.prepareRichInline([{text: run.raw, font, letterSpacing: expected.measurer.letterSpacing || 0}]);
|
|
66
|
+
const flow = preparation.prepare(expected, [{text: expected.anchorText, font, letterSpacing: expected.measurer.letterSpacing || 0}]);
|
|
67
|
+
if (!flow) { rows.push({document: expected.document, verdict: 'ERROR', detail: 'rich preparation refused this font/text/letterSpacing'}); continue; }
|
|
68
68
|
let obstacles = [];
|
|
69
69
|
if (expected.picture?.rect) {
|
|
70
70
|
const profile = buildProfile(expected.profileHint);
|
|
@@ -157,13 +157,17 @@ function unicodeIncludes(face, character) {
|
|
|
157
157
|
});
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
function
|
|
160
|
+
function fontSource(bytes) {
|
|
161
161
|
const signature = String.fromCharCode(...bytes.subarray(0, 4));
|
|
162
162
|
const format = signature === 'wOFF' ? 'woff' : signature === 'wOF2' ? 'woff2' : signature === 'OTTO' ? 'opentype' : 'truetype';
|
|
163
163
|
const mime = {woff: 'woff', woff2: 'woff2', opentype: 'otf', truetype: 'ttf'}[format];
|
|
164
164
|
let binary = '';
|
|
165
165
|
for (let at = 0; at < bytes.length; at += 16384) binary += String.fromCharCode(...bytes.subarray(at, at + 16384));
|
|
166
|
-
|
|
166
|
+
return 'url("data:font/' + mime + ';base64,' + btoa(binary) + '") format("' + format + '")';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function writeFace(face, source) {
|
|
170
|
+
const properties = {...face.properties, src: source};
|
|
167
171
|
delete properties['unicode-range'];
|
|
168
172
|
return '@font-face{' + Object.entries(properties).map(([name, value]) => name + ':' + value).join(';') + '}';
|
|
169
173
|
}
|
|
@@ -291,20 +295,31 @@ export async function exportFontCss(root, {settings, css = '', fontCss = '', sub
|
|
|
291
295
|
const custom = new Set(fontFaces(customFontCss).map(face => face.family));
|
|
292
296
|
candidates = candidates.filter(face => face.family === 'geist' && !custom.has(face.family));
|
|
293
297
|
}
|
|
294
|
-
const faces = fontRuns(root, {settings, css, faces: candidates}), output = [];
|
|
298
|
+
const faces = fontRuns(root, {settings, css, faces: candidates}), output = [], prepared = new Map();
|
|
295
299
|
let payloadBytes = 0;
|
|
296
300
|
for (const face of faces) {
|
|
297
|
-
const original = Uint8Array.from(atob(face.source.slice(face.source.indexOf(',') + 1)), character => character.charCodeAt(0));
|
|
298
301
|
// Adjacent inline nodes retain shaping sequences; independent runs cannot compose together.
|
|
299
|
-
const
|
|
302
|
+
const text = face.runs.join('\n');
|
|
303
|
+
if (!prepared.has(face.source)) prepared.set(face.source, new Map());
|
|
304
|
+
const variants = prepared.get(face.source);
|
|
305
|
+
if (!variants.has(text)) {
|
|
306
|
+
const original = Uint8Array.from(atob(face.source.slice(face.source.indexOf(',') + 1)), character => character.charCodeAt(0));
|
|
307
|
+
const bytes = await subset(original, text);
|
|
308
|
+
if (portableFactory) {
|
|
309
|
+
if (!(bytes instanceof Uint8Array) || !bytes.length || bytes.length >= original.length || bytes.length > FACTORY_FONT_BYTES) return '';
|
|
310
|
+
if (typeof validateFont !== 'function' || await validateFont(Uint8Array.from(bytes), {family: 'Geist'}) !== true) return '';
|
|
311
|
+
} else if (bytes === null) { variants.set(text, null); continue; }
|
|
312
|
+
if (!(bytes instanceof Uint8Array) || !bytes.length) throw new Error('The page font subset is empty.');
|
|
313
|
+
// Retain the encoded boundary value, not additional original or subset byte buffers.
|
|
314
|
+
variants.set(text, {source: fontSource(bytes), length: bytes.length});
|
|
315
|
+
}
|
|
316
|
+
const saved = variants.get(text);
|
|
317
|
+
if (saved === null) continue;
|
|
300
318
|
if (portableFactory) {
|
|
301
|
-
|
|
302
|
-
payloadBytes += bytes.length;
|
|
319
|
+
payloadBytes += saved.length;
|
|
303
320
|
if (payloadBytes > FACTORY_FONT_BYTES) return '';
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (!(bytes instanceof Uint8Array) || !bytes.length) throw new Error('The page font subset is empty.');
|
|
307
|
-
output.push(writeFace(face, bytes));
|
|
321
|
+
}
|
|
322
|
+
output.push(writeFace(face, saved.source));
|
|
308
323
|
}
|
|
309
324
|
const result = output.join('\n');
|
|
310
325
|
return portableFactory && new TextEncoder().encode(result).length > FACTORY_FONT_CSS_BYTES ? '' : result;
|
package/dist/kit/index.mjs
CHANGED
|
@@ -71,18 +71,20 @@ export function readDocument(source) {
|
|
|
71
71
|
|
|
72
72
|
// One portable baseline. A digest is integrity evidence, not authentication of the named author.
|
|
73
73
|
export function readBase(value) {
|
|
74
|
-
if (!value || typeof value !== 'object'
|
|
75
|
-
|
|
74
|
+
if (!value || typeof value !== 'object') throw fault('comparison base SHA-256 differs from its text');
|
|
75
|
+
// Capture each caller-owned field once; admission and serialization use these same scalars.
|
|
76
|
+
const {text, sha256: digest, revision, name, by, at} = value;
|
|
77
|
+
if (typeof text !== 'string' || typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest) || sha256(text) !== digest)
|
|
76
78
|
throw fault('comparison base SHA-256 differs from its text');
|
|
77
|
-
if (typeof
|
|
79
|
+
if (typeof revision !== 'string' || !revision || revision.length > 256 || /[\u0000-\u001f\u007f]/.test(revision))
|
|
78
80
|
throw fault('invalid comparison base revision');
|
|
79
|
-
if (typeof
|
|
81
|
+
if (typeof name !== 'string' || !name || [...name].length > 256 || /[\\/\u0000-\u001f\u007f]/.test(name))
|
|
80
82
|
throw fault('invalid comparison base name');
|
|
81
|
-
if (typeof
|
|
83
|
+
if (typeof by !== 'string' || !by.trim() || by.length > 96 || /[\u0000-\u001f\u007f]/.test(by))
|
|
82
84
|
throw fault('a comparison needs its author name');
|
|
83
|
-
if (typeof
|
|
85
|
+
if (typeof at !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(at) || !Number.isFinite(Date.parse(at)))
|
|
84
86
|
throw fault('the time of a comparison is an ISO 8601 UTC timestamp');
|
|
85
|
-
return {text
|
|
87
|
+
return {text, sha256: digest, revision, name, by, at};
|
|
86
88
|
}
|
|
87
89
|
const escapeAttribute = value => value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
88
90
|
const readAttribute = value => value.replace(/&(quot|lt|gt|amp);/g, (_, name) => ({quot: '"', lt: '<', gt: '>', amp: '&'})[name]);
|
package/dist/kit/ledger/data.mjs
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// Only plain JSON data enters the public format. Accessors and toJSON are never executed.
|
|
3
3
|
export function fault(message) { return Object.assign(new Error('Ledger: ' + message), {code: 'ledger_invalid'}); }
|
|
4
|
-
|
|
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);
|
|
4
|
+
function fields(value, depth, active, sorted = true) {
|
|
8
5
|
if (!value || typeof value !== 'object' || depth > 64 || active.has(value)) throw fault('not plain JSON data');
|
|
9
6
|
const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
|
|
10
7
|
if (!array && proto !== null && (Object.getPrototypeOf(proto) !== null || Object.getOwnPropertyDescriptor(proto, 'constructor')?.value?.name !== 'Object')) throw fault('not a plain object');
|
|
@@ -15,19 +12,82 @@ export function canonicalJSON(value, depth = 0, active = new Set()) {
|
|
|
15
12
|
if (typeof key !== 'string' || ['__proto__', 'constructor', 'prototype'].includes(key) || !Object.hasOwn(field, 'value') || !field.enumerable) throw fault('non-data property');
|
|
16
13
|
}
|
|
17
14
|
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');
|
|
15
|
+
return {array, descriptors, keys: array ? keys.filter(key => key !== 'length') : sorted ? keys.sort() : keys};
|
|
16
|
+
}
|
|
17
|
+
function scalar(value) {
|
|
18
|
+
if (value === null || typeof value === 'boolean') return true;
|
|
19
|
+
if (typeof value === 'string') { if (/[\uD800-\uDFFF]/u.test(value)) throw fault('unpaired surrogate'); return true; }
|
|
20
|
+
return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0);
|
|
21
|
+
}
|
|
22
|
+
// A canonical copy does not need a serialized document and a second parsed document.
|
|
23
|
+
// Sorted insertion preserves the previous plainData result, including numeric-key ordering.
|
|
24
|
+
export function plainData(value) {
|
|
25
|
+
if (typeof value === 'string') value = JSON.parse(value);
|
|
26
|
+
const active = new Set();
|
|
27
|
+
const copy = (value, depth) => {
|
|
28
|
+
if (scalar(value)) return value;
|
|
29
|
+
const {array, descriptors, keys} = fields(value, depth, active);
|
|
30
|
+
const out = array ? [] : {};
|
|
31
|
+
active.add(value);
|
|
32
|
+
for (const key of keys) out[key] = copy(descriptors[key].value, depth + 1);
|
|
33
|
+
active.delete(value); return out;
|
|
34
|
+
};
|
|
35
|
+
return copy(value, 0);
|
|
36
|
+
}
|
|
37
|
+
// Each yielded string is bounded, even when one value contains the whole document.
|
|
38
|
+
// JSON.stringify still owns escaping; scalar boundaries keep its spelling exact.
|
|
39
|
+
function* quoted(text, whole = true) {
|
|
40
|
+
yield '"';
|
|
41
|
+
for (let at = 0; at < text.length;) {
|
|
42
|
+
let end = Math.min(text.length, at + 16384);
|
|
43
|
+
if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
|
|
44
|
+
const part = text.slice(at, end);
|
|
45
|
+
if (whole && /[\uD800-\uDFFF]/u.test(part)) throw fault('unpaired surrogate');
|
|
46
|
+
yield JSON.stringify(part).slice(1, -1); at = end;
|
|
47
|
+
}
|
|
48
|
+
yield '"';
|
|
49
|
+
}
|
|
50
|
+
function* dataParts(value, depth, active, sorted) {
|
|
51
|
+
if (typeof value === 'string') { yield* quoted(value); return; }
|
|
52
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) {
|
|
53
|
+
yield JSON.stringify(value); return;
|
|
54
|
+
}
|
|
55
|
+
const {array, descriptors, keys} = fields(value, depth, active, sorted);
|
|
18
56
|
active.add(value);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
57
|
+
yield array ? '[' : '{';
|
|
58
|
+
for (let i = 0; i < keys.length; i++) {
|
|
59
|
+
if (i) yield ',';
|
|
60
|
+
const key = keys[i];
|
|
61
|
+
if (!array) { yield* quoted(key, false); yield ':'; }
|
|
62
|
+
yield* dataParts(descriptors[key].value, depth + 1, active, sorted);
|
|
63
|
+
}
|
|
64
|
+
yield array ? ']' : '}';
|
|
65
|
+
active.delete(value);
|
|
23
66
|
}
|
|
24
|
-
export function
|
|
67
|
+
export function canonicalParts(value, depth = 0, active = new Set()) { return dataParts(value, depth, active, true); }
|
|
68
|
+
export function jsonParts(value) { return dataParts(value, 0, new Set(), false); }
|
|
69
|
+
export function canonicalJSON(value, depth = 0, active = new Set()) { return Array.from(canonicalParts(value, depth, active)).join(''); }
|
|
25
70
|
export function exactKeys(value, keys) {
|
|
26
71
|
return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
27
72
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
73
|
+
// TextEncoder's exact byte count, without allocating an encoded copy. Whole-character
|
|
74
|
+
// admission is performed in this same pass when the document owner requests it.
|
|
75
|
+
export function utf8Length(text, whole = false) {
|
|
76
|
+
let bytes = 0;
|
|
77
|
+
for (let at = 0; at < text.length; at++) {
|
|
78
|
+
const code = text.charCodeAt(at);
|
|
79
|
+
if (code < 0x80) bytes++;
|
|
80
|
+
else if (code < 0x800) bytes += 2;
|
|
81
|
+
else if ((code & 0xfc00) === 0xd800 && (text.charCodeAt(at + 1) & 0xfc00) === 0xdc00) { bytes += 4; at++; }
|
|
82
|
+
else { if (whole && code >= 0xd800 && code <= 0xdfff) throw fault('document is not whole-character text'); bytes += 3; }
|
|
83
|
+
}
|
|
84
|
+
return bytes;
|
|
85
|
+
}
|
|
86
|
+
export function documentBytes(text) {
|
|
87
|
+
if (typeof text !== 'string') throw fault('document is not whole-character text');
|
|
88
|
+
const bytes = utf8Length(text, true);
|
|
89
|
+
if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
|
|
90
|
+
return bytes;
|
|
32
91
|
}
|
|
92
|
+
export function documentText(text) { documentBytes(text); return text; }
|
|
33
93
|
export const nonnegative = value => Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
|
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
import {
|
|
2
|
+
import {_rapierPrepareHistoryRecords, _rapierValidMetadata, _rapierTransformMetadata} from './journal-records.mjs';
|
|
3
3
|
import {pictureHistorySplices} from './history.mjs';
|
|
4
|
+
import {HistorySource} from './inverse-source.mjs';
|
|
4
5
|
import {authoredHistory} from './transport.mjs';
|
|
5
|
-
import {textRoot, rootAfter, sha256} from './hash.mjs';
|
|
6
|
-
import {canonicalJSON, plainData, documentText, exactKeys, nonnegative, fault} from './data.mjs';
|
|
6
|
+
import {textRoot, rootAfter, sha256, sha256Parts, createSHA256} from './hash.mjs';
|
|
7
|
+
import {canonicalJSON, canonicalParts, plainData, documentText, utf8Length, exactKeys, nonnegative, fault} from './data.mjs';
|
|
7
8
|
export {textRoot, rootAfter};
|
|
8
|
-
const digest = value =>
|
|
9
|
+
const digest = value => sha256Parts(canonicalParts(value));
|
|
9
10
|
const rootShape = value => typeof value === 'string' && /^\d+:[a-z0-9]+:[a-z0-9]+$/.test(value);
|
|
10
|
-
|
|
11
|
+
// Structural/history admission is shared by the reader and the producer. The producer
|
|
12
|
+
// proves source replay by reversing exact admitted splices; it need not replay its output.
|
|
13
|
+
function* checkedRecords(ledger, prepared) {
|
|
14
|
+
if (!prepared.ok || ledger.records.length !== prepared.records.length) throw fault('record chain differs');
|
|
15
|
+
let root = ledger.start.root, revision = ledger.start.revision;
|
|
16
|
+
for (let index = 0; index < prepared.records.length; index++) {
|
|
17
|
+
const record = ledger.records[index];
|
|
18
|
+
if (!Array.isArray(record.splices) || !prepared.validRecord(index,
|
|
19
|
+
ledger.documentAuthority, 64 * 1024 * 1024, utf8Length, !ledger.complete) ||
|
|
20
|
+
record.transaction.baseRevision < revision || (record.transaction.baseRevision !== revision && ledger.complete) ||
|
|
21
|
+
(record.transaction.baseRevision === revision && record.beforeHash !== root)) throw fault('record chain differs');
|
|
22
|
+
if (record.transaction.baseRevision !== revision) root = record.beforeHash;
|
|
23
|
+
for (const row of record.splices) root = rootAfter(root, row);
|
|
24
|
+
if (root !== record.afterHash) throw fault('record root differs');
|
|
25
|
+
revision = record.transaction.revision;
|
|
26
|
+
yield record;
|
|
27
|
+
}
|
|
28
|
+
if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
|
|
29
|
+
}
|
|
30
|
+
function readLedgerState(input, expectedText, expectedMetadata) {
|
|
11
31
|
const ledger = plainData(input);
|
|
12
32
|
if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
|
|
13
33
|
typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
|
|
@@ -19,102 +39,195 @@ export function readLedger(input, expectedText, expectedMetadata) {
|
|
|
19
39
|
if (!_rapierValidMetadata(ledger.start.metadata) || !_rapierValidMetadata(ledger.head.metadata)) throw fault('invalid document metadata');
|
|
20
40
|
if (!nonnegative(revision) || !rootShape(root) || (revision === 0 && root !== textRoot(text)) || ledger.start.sha256 !== sha256(text) ||
|
|
21
41
|
(ledger.complete && revision !== 0)) throw fault('invalid start');
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
(record.transaction.baseRevision === revision && record.beforeHash !== root)) throw fault('record chain differs');
|
|
28
|
-
// A pruned cancelling pair leaves a stated revision gap, just as in the editor's restore reader.
|
|
29
|
-
if (record.transaction.baseRevision !== revision) root = record.beforeHash;
|
|
30
|
-
const next = _rapierTransformSplices(text, record.splices);
|
|
42
|
+
const prepared = _rapierPrepareHistoryRecords(ledger.records);
|
|
43
|
+
if (!prepared.ok) throw fault('record chain differs');
|
|
44
|
+
let source = new HistorySource(text), bytes = utf8Length(text);
|
|
45
|
+
for (const record of checkedRecords(ledger, prepared)) {
|
|
46
|
+
const next = source.apply(record.splices);
|
|
31
47
|
if (next === null) throw fault('splice does not replay');
|
|
32
|
-
|
|
33
|
-
for (const row of record.splices)
|
|
34
|
-
if (
|
|
35
|
-
revision = record.transaction.revision;
|
|
48
|
+
source = next;
|
|
49
|
+
for (const row of record.splices) bytes += utf8Length(row.inserted) - utf8Length(row.removed);
|
|
50
|
+
if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
|
|
51
|
+
root = record.afterHash; revision = record.transaction.revision;
|
|
36
52
|
}
|
|
53
|
+
text = source.slice();
|
|
37
54
|
if (ledger.records.some(row => Object.hasOwn(row, 'authored'))) {
|
|
38
55
|
try { authoredHistory(ledger.start.text, ledger.records); } catch (_) { throw fault('authored origin differs'); }
|
|
39
56
|
}
|
|
40
|
-
const metadata =
|
|
57
|
+
const metadata = prepared.replayMetadata(ledger.start.metadata);
|
|
41
58
|
if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata) ||
|
|
42
59
|
(expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata))))
|
|
43
60
|
throw fault('metadata does not describe this document');
|
|
44
61
|
if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text) ||
|
|
45
62
|
(expectedText !== undefined && text !== expectedText)) throw fault('history does not describe this document');
|
|
46
|
-
return {ledger, text, metadata, root, revision, complete: ledger.complete};
|
|
63
|
+
return {value: {ledger, text, metadata, root, revision, complete: ledger.complete}, prepared, source};
|
|
64
|
+
}
|
|
65
|
+
export function readLedger(input, expectedText, expectedMetadata) {
|
|
66
|
+
return readLedgerState(input, expectedText, expectedMetadata).value;
|
|
47
67
|
}
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
function* checksum(parts) {
|
|
69
|
+
const hash = createSHA256();
|
|
70
|
+
for (const part of parts) { hash.update(part); yield; }
|
|
71
|
+
return hash.digest();
|
|
72
|
+
}
|
|
73
|
+
function* sourceFacts(text) {
|
|
74
|
+
if (typeof text !== 'string') throw fault('document is not whole-character text');
|
|
75
|
+
const hash = createSHA256();
|
|
76
|
+
let fnv = 0x811c9dc5, a = 1, b = 0, bytes = 0;
|
|
77
|
+
for (let at = 0; at < text.length;) {
|
|
78
|
+
const start = at, end = Math.min(text.length, at + 65536);
|
|
79
|
+
for (; at < end;) {
|
|
80
|
+
const stop = Math.min(end, at + 4096);
|
|
81
|
+
for (; at < stop; at++) {
|
|
82
|
+
const code = text.charCodeAt(at);
|
|
83
|
+
fnv = Math.imul(fnv ^ code, 16777619); a += code; b += a;
|
|
84
|
+
if (code < 0x80) bytes++;
|
|
85
|
+
else if (code < 0x800) bytes += 2;
|
|
86
|
+
else if ((code & 0xfc00) === 0xd800) {
|
|
87
|
+
if ((text.charCodeAt(at + 1) & 0xfc00) !== 0xdc00) throw fault('document is not whole-character text');
|
|
88
|
+
bytes += 3;
|
|
89
|
+
} else if ((code & 0xfc00) === 0xdc00) {
|
|
90
|
+
if (!at || (text.charCodeAt(at - 1) & 0xfc00) !== 0xd800) throw fault('document is not whole-character text');
|
|
91
|
+
bytes++;
|
|
92
|
+
} else bytes += 3;
|
|
93
|
+
}
|
|
94
|
+
a %= 65521; b %= 65521;
|
|
95
|
+
}
|
|
96
|
+
if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
|
|
97
|
+
hash.update(text.slice(start, end)); yield;
|
|
98
|
+
}
|
|
99
|
+
return {utf8Bytes: bytes, sha256: hash.digest(), contentRoot: text.length + ':' +
|
|
100
|
+
(fnv >>> 0).toString(36) + ':' + (((b << 16) | a) >>> 0).toString(36)};
|
|
101
|
+
}
|
|
102
|
+
function* produceLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}) {
|
|
50
103
|
if (!_rapierValidMetadata(metadata)) throw fault('document metadata required');
|
|
104
|
+
// Metadata has two admitted scalar fields. Keep its insertion order in the
|
|
105
|
+
// emitted envelope while capturing it before the first cooperative boundary.
|
|
106
|
+
metadata = {...metadata};
|
|
51
107
|
if (!Array.isArray(records) || !Array.isArray(earlier)) throw fault('records required');
|
|
52
108
|
// Inspect before touching properties: exporting must not call accessors on an alleged record.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
109
|
+
const prepared = _rapierPrepareHistoryRecords(records, earlier);
|
|
110
|
+
if (!prepared.ok) throw fault('navigation target was not retained');
|
|
111
|
+
const headFacts = yield* sourceFacts(text);
|
|
112
|
+
let bytes = headFacts.utf8Bytes;
|
|
113
|
+
const expanded = [];
|
|
114
|
+
for (const {record, splices: rows, metadata: effect} of prepared.rows) {
|
|
56
115
|
if (!rows || effect === undefined) throw fault('navigation target was not retained');
|
|
57
|
-
|
|
58
|
-
beforeHash: record.beforeHash, afterHash: record.afterHash, ...(Object.hasOwn(record, 'authored') ? {authored: record.authored} : {}), ...(effect ? {metadata: effect} : {}),
|
|
59
|
-
...(record.changeSet && Object.hasOwn(record.changeSet, 'label') ? {changeSet: {label: record.changeSet.label}} : {}),
|
|
60
|
-
...(record.derivedCommentIndex == null ? {} : {derivedCommentIndex: record.derivedCommentIndex})};
|
|
61
|
-
|
|
62
|
-
|
|
116
|
+
expanded.push({transaction: plainData(record.transaction), splices: rows.map(row => ({pos: row.pos, removed: row.removed, inserted: row.inserted})),
|
|
117
|
+
beforeHash: record.beforeHash, afterHash: record.afterHash, ...(Object.hasOwn(record, 'authored') ? {authored: plainData(record.authored)} : {}), ...(effect ? {metadata: plainData(effect)} : {}),
|
|
118
|
+
...(record.changeSet && Object.hasOwn(record.changeSet, 'label') ? {changeSet: {label: typeof record.changeSet.label === 'string' ? record.changeSet.label : plainData(record.changeSet.label)}} : {}),
|
|
119
|
+
...(record.derivedCommentIndex == null ? {} : {derivedCommentIndex: typeof record.derivedCommentIndex === 'string' ? record.derivedCommentIndex : plainData(record.derivedCommentIndex)})});
|
|
120
|
+
yield;
|
|
121
|
+
}
|
|
122
|
+
const headBytes = bytes;
|
|
123
|
+
let startSource = new HistorySource(text), startMetadata = {...metadata};
|
|
63
124
|
for (let i = expanded.length - 1; i >= 0; i--) {
|
|
64
|
-
|
|
125
|
+
startSource = startSource.apply(expanded[i].splices, true);
|
|
65
126
|
startMetadata = _rapierTransformMetadata(startMetadata, expanded[i].metadata, true);
|
|
66
|
-
if (
|
|
127
|
+
if (startSource === null || !startMetadata) throw fault('history does not reverse from this document');
|
|
128
|
+
// Whole scalars and exact splice boundaries were just checked. Count the changed
|
|
129
|
+
// bytes, rather than re-encoding every reconstructed whole-document revision.
|
|
130
|
+
for (const row of expanded[i].splices) bytes += utf8Length(row.removed) - utf8Length(row.inserted);
|
|
131
|
+
if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
|
|
132
|
+
yield;
|
|
67
133
|
}
|
|
134
|
+
const startText = startSource.slice();
|
|
135
|
+
const unchangedText = expanded.every(row => !row.splices.length);
|
|
136
|
+
const contentRoot = headFacts.contentRoot;
|
|
68
137
|
revision ??= expanded.at(-1)?.transaction.revision ?? 0;
|
|
69
|
-
root ??= expanded.at(-1)?.afterHash ??
|
|
138
|
+
root ??= expanded.at(-1)?.afterHash ?? contentRoot;
|
|
70
139
|
documentAuthority ??= expanded[0]?.transaction.documentAuthority;
|
|
71
|
-
const startRevision = expanded[0]?.transaction.baseRevision ?? revision;
|
|
140
|
+
const startRevision = expanded[0]?.transaction.baseRevision ?? revision, startRoot = expanded[0]?.beforeHash ?? root;
|
|
141
|
+
if (typeof documentAuthority !== 'string' || !documentAuthority || documentAuthority.length > 256) throw fault('unsupported or invalid envelope');
|
|
142
|
+
if (!nonnegative(startRevision) || !rootShape(startRoot)) throw fault('invalid start');
|
|
143
|
+
const startFacts = unchangedText ? headFacts : yield* sourceFacts(startText);
|
|
144
|
+
if (startRevision === 0 && startRoot !== startFacts.contentRoot) throw fault('invalid start');
|
|
145
|
+
const headSHA = headFacts.sha256, startSHA = startFacts.sha256;
|
|
72
146
|
const payload = {format: 'rapier-ledger/2', documentAuthority,
|
|
73
|
-
start: {text: startText, revision: startRevision, root:
|
|
74
|
-
records: expanded, head: {revision, root, sha256:
|
|
75
|
-
|
|
76
|
-
|
|
147
|
+
start: {text: startText, revision: startRevision, root: startRoot, sha256: startSHA, metadata: startMetadata},
|
|
148
|
+
records: expanded, head: {revision, root, sha256: headSHA, metadata: {...metadata}}, complete: complete === true && startRevision === 0 && expanded.every((row, i) => !i || row.transaction.baseRevision === expanded[i - 1].transaction.revision)};
|
|
149
|
+
// Expanded rows are the exact canonical projection of this owned preparation.
|
|
150
|
+
// Keep its navigation/effect proof instead of admitting another copied graph.
|
|
151
|
+
for (const _record of checkedRecords(payload, prepared)) yield;
|
|
152
|
+
if (expanded.some(row => Object.hasOwn(row, 'authored'))) {
|
|
153
|
+
try { authoredHistory(startText, expanded); } catch (_) { throw fault('authored origin differs'); }
|
|
154
|
+
}
|
|
155
|
+
const replayedMetadata = prepared.replayMetadata(startMetadata);
|
|
156
|
+
if (!replayedMetadata || canonicalJSON(replayedMetadata) !== canonicalJSON(metadata)) throw fault('metadata does not describe this document');
|
|
157
|
+
const ledger = {...payload, sha256: yield* checksum(canonicalParts(payload))};
|
|
158
|
+
return {ledger, contentRoot, utf8Bytes: headBytes};
|
|
159
|
+
}
|
|
160
|
+
function finish(producer) {
|
|
161
|
+
let step;
|
|
162
|
+
do { step = producer.next(); } while (!step.done);
|
|
163
|
+
return step.value;
|
|
164
|
+
}
|
|
165
|
+
export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
|
|
166
|
+
// Both synchronous public export and cooperative recovery use the same producer.
|
|
167
|
+
// Its returned facts were computed while admitting/hashing the exact head, never supplied.
|
|
168
|
+
export function prepareLedger(input) { return finish(produceLedger(input)); }
|
|
169
|
+
export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
|
|
170
|
+
const producer = produceLedger(input);
|
|
171
|
+
let step;
|
|
172
|
+
do {
|
|
173
|
+
signal?.throwIfAborted();
|
|
174
|
+
step = producer.next();
|
|
175
|
+
if (!step.done && yieldWork) {
|
|
176
|
+
// The caller owns scheduling and its clock; ledger admission reads neither.
|
|
177
|
+
const pending = yieldWork();
|
|
178
|
+
if (pending) await pending;
|
|
179
|
+
}
|
|
180
|
+
} while (!step.done);
|
|
181
|
+
signal?.throwIfAborted();
|
|
182
|
+
return step.value;
|
|
77
183
|
}
|
|
78
184
|
// Derived navigation, never a second history. A target older than start is intentionally unavailable.
|
|
79
185
|
export function historyEnvelope(input, segmentIdentity = []) {
|
|
80
|
-
const {ledger, text, metadata}
|
|
81
|
-
const pictures =
|
|
186
|
+
const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input);
|
|
187
|
+
const {byId, effects: {pictures}} = prepared;
|
|
82
188
|
let branch = [], cursor = 0;
|
|
83
189
|
for (const {transaction: tx} of ledger.records) {
|
|
84
190
|
if (pictures.has(tx.id)) continue;
|
|
85
191
|
const target = tx.reverts || tx.reapplies;
|
|
86
192
|
if (target) {
|
|
87
|
-
if (!
|
|
193
|
+
if (!byId.has(target)) continue;
|
|
88
194
|
if (tx.reverts && branch[cursor - 1] === target) cursor--;
|
|
89
195
|
else if (tx.reapplies && branch[cursor] === target) cursor++;
|
|
90
196
|
else throw fault('invalid navigation order');
|
|
91
|
-
} else { branch =
|
|
197
|
+
} else { branch.length = cursor; branch.push(tx.id); cursor = branch.length; }
|
|
92
198
|
}
|
|
93
|
-
const byId = new Map(ledger.records.map(row => [row.transaction.id, row]));
|
|
94
199
|
const step = (source, id, redo) => {
|
|
95
|
-
const target = byId.get(id), folded =
|
|
96
|
-
|
|
200
|
+
const target = byId.get(id), folded = pictures.size
|
|
201
|
+
? pictureHistorySplices({source: source.slice(), records: prepared.records}, target, redo) : null;
|
|
202
|
+
return source.apply(folded || target.splices, !folded && !redo);
|
|
97
203
|
};
|
|
98
|
-
let traversed =
|
|
204
|
+
let traversed = source, traversedMetadata = {...metadata};
|
|
99
205
|
for (let i = cursor - 1; i >= 0; i--) {
|
|
100
206
|
traversed = step(traversed, branch[i], false);
|
|
101
207
|
traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata, true);
|
|
102
208
|
if (traversed === null || !traversedMetadata) throw fault('undo branch does not reverse');
|
|
103
209
|
}
|
|
104
|
-
|
|
210
|
+
if (ledger.complete) {
|
|
211
|
+
if (ledger.records.length && !branch.length) throw fault('complete history lost its branch');
|
|
212
|
+
// Literal admitted splices are reversible. Proving the actual head back to
|
|
213
|
+
// the exact start also proves its forward active branch, without replaying it.
|
|
214
|
+
if (traversed.length !== ledger.start.text.length || !traversed.matches(0, ledger.start.text) ||
|
|
215
|
+
canonicalJSON(traversedMetadata) !== canonicalJSON(ledger.start.metadata)) throw fault('complete branch differs');
|
|
216
|
+
}
|
|
217
|
+
traversed = source; traversedMetadata = {...metadata};
|
|
105
218
|
for (let i = cursor; i < branch.length; i++) {
|
|
106
219
|
traversed = step(traversed, branch[i], true);
|
|
107
220
|
traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata);
|
|
108
221
|
if (traversed === null || !traversedMetadata) throw fault('redo branch does not replay');
|
|
109
222
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
223
|
+
// Picture folding may relocate finished definitions while navigating. Keep its
|
|
224
|
+
// distinct forward proof; the literal-only case above needs no second traversal.
|
|
225
|
+
if (ledger.complete && pictures.size) {
|
|
226
|
+
let active = new HistorySource(ledger.start.text), activeMetadata = {...ledger.start.metadata};
|
|
114
227
|
for (let i = 0; i < branch.length; i++) {
|
|
115
228
|
active = step(active, branch[i], true);
|
|
116
229
|
activeMetadata = _rapierTransformMetadata(activeMetadata, byId.get(branch[i]).metadata);
|
|
117
|
-
if (active === null || !activeMetadata || (i + 1 === cursor && (active !== text || canonicalJSON(activeMetadata) !== canonicalJSON(metadata)))) throw fault('complete branch differs');
|
|
230
|
+
if (active === null || !activeMetadata || (i + 1 === cursor && (active.length !== text.length || !active.matches(0, text) || canonicalJSON(activeMetadata) !== canonicalJSON(metadata)))) throw fault('complete branch differs');
|
|
118
231
|
}
|
|
119
232
|
}
|
|
120
233
|
return {schemaVersion: 5, documentAuthority: ledger.documentAuthority, documentRevision: ledger.head.revision,
|