rapier-markdown-kit 1.1.104 → 1.2.5
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 +8 -4
- package/README.md +50 -7
- package/dist/images/gif.mjs +49 -0
- package/dist/images/playback.mjs +103 -0
- package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
- package/dist/kit/conformance/04-wrap-box.md +1 -1
- package/dist/kit/conformance/05-behind.md +1 -1
- package/dist/kit/conformance/06-front.md +1 -1
- package/dist/kit/conformance/07-rotate-raster.md +1 -1
- package/dist/kit/conformance/08-drawing-rotation.md +1 -1
- package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
- package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
- package/dist/kit/conformance/10-both-sides.md +1 -1
- package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
- package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
- package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
- package/dist/kit/conformance/README.md +19 -28
- package/dist/kit/conformance/run.mjs +10 -8
- package/dist/kit/ledger/authored-origin.mjs +49 -0
- package/dist/kit/ledger/authorship.mjs +18 -9
- package/dist/kit/ledger/carried.mjs +10 -5
- package/dist/kit/ledger/data.mjs +7 -4
- package/dist/kit/ledger/format.mjs +37 -11
- package/dist/kit/ledger/hash.mjs +31 -15
- package/dist/kit/ledger/history.mjs +48 -10
- package/dist/kit/ledger/inverse-source.mjs +111 -49
- package/dist/kit/ledger/journal-records.mjs +24 -29
- package/dist/kit/ledger/merge.mjs +51 -15
- package/dist/kit/ledger/text-merge.mjs +75 -29
- package/dist/kit/ledger/transport.mjs +30 -45
- package/dist/kit/pdf-vectors.mjs +6 -2
- package/dist/kit/print-capitals.mjs +0 -1
- package/dist/kit/rapier-ledger.mjs +4 -2
- package/dist/kit/render-ink.mjs +1 -1
- package/dist/kit/render-markdown.mjs +155 -31
- package/dist/kit/render-print.mjs +173 -61
- package/dist/kit/render-sanitize.mjs +25 -15
- package/dist/kit/render-styles.mjs +3 -3
- package/dist/kit/render.mjs +37 -115
- package/dist/layout/model.mjs +10 -8
- package/dist/layout/paragraph-fit.mjs +1 -15
- package/dist/layout/typography.mjs +2 -1
- package/dist/packages/rapier-diff/concerns.mjs +163 -0
- package/dist/packages/rapier-diff/diff.mjs +68 -0
- package/dist/packages/rapier-diff/index.mjs +4 -701
- package/dist/packages/rapier-diff/matching.mjs +157 -0
- package/dist/packages/rapier-diff/merge.mjs +226 -0
- package/dist/packages/rapier-diff/moves.mjs +130 -0
- package/dist/packages/rapier-diff/structure.mjs +50 -0
- package/dist/packages/rapier-diff/tokens.mjs +72 -0
- package/dist/packages/rapier-diff/walk.mjs +219 -0
- package/dist/spec/document-fonts.mjs +1 -1
- package/dist/spec/document-settings.mjs +11 -164
- package/dist/spec/html-blocks.mjs +618 -79
- package/dist/spec/md-assets.mjs +29 -6
- package/dist/spec/md-layout.mjs +9 -1
- package/dist/spec/md-marks.mjs +25 -147
- package/dist/spec/md-source.mjs +69 -26
- package/package.json +1 -1
- package/style.css +7 -51
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Immutable authored origins shared by causal transport and inverse projection.
|
|
3
|
+
import {_rapierTransformSplices, _rapierValidAuthored} from './journal-records.mjs';
|
|
4
|
+
import {sha256} from './hash.mjs';
|
|
5
|
+
|
|
6
|
+
// The frontier names only causal maxima. Linear history needs one predecessor,
|
|
7
|
+
// and concurrent predecessors remain separate until an act observes both.
|
|
8
|
+
function authoredFrontier(records, visit = null) {
|
|
9
|
+
const dependencies = new Map(), frontier = new Set();
|
|
10
|
+
for (const record of records) {
|
|
11
|
+
const id = record.transaction.id, basis = record.authored?.basis;
|
|
12
|
+
if (visit) visit(record, basis ?? [...frontier].sort());
|
|
13
|
+
if (!basis) { dependencies.set(id, [...frontier]); frontier.clear(); }
|
|
14
|
+
else {
|
|
15
|
+
const pending = [...basis], seen = new Set();
|
|
16
|
+
while (pending.length) {
|
|
17
|
+
const prior = pending.pop();
|
|
18
|
+
if (seen.has(prior)) continue;
|
|
19
|
+
if (!dependencies.has(prior)) throw new RangeError('canonical_origin_invalid');
|
|
20
|
+
seen.add(prior); frontier.delete(prior); pending.push(...dependencies.get(prior));
|
|
21
|
+
}
|
|
22
|
+
dependencies.set(id, basis);
|
|
23
|
+
}
|
|
24
|
+
frontier.add(id);
|
|
25
|
+
}
|
|
26
|
+
return [...frontier].sort();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function authoredBasis(records) { return authoredFrontier(records); }
|
|
30
|
+
|
|
31
|
+
export function authoredPlacements(base, records) {
|
|
32
|
+
const origins = new Map(); let source = base;
|
|
33
|
+
authoredFrontier(records, (record, basis) => {
|
|
34
|
+
origins.set(record.transaction.id, record.authored ?? authoredPlacement(source, record.splices, basis));
|
|
35
|
+
source = _rapierTransformSplices(source, record.splices);
|
|
36
|
+
if (source === null) throw new RangeError('canonical_origin_invalid');
|
|
37
|
+
});
|
|
38
|
+
return origins;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Immutable authored placement stays on its own act. Basis IDs refer only to this
|
|
42
|
+
// canonical history; no source snapshot or duplicate history is stored.
|
|
43
|
+
export function authoredPlacement(source, splices, basis) {
|
|
44
|
+
const origin = {basis: basis.slice().sort(), source: sha256(source), splices: splices.map(row => ({...row}))};
|
|
45
|
+
if (!_rapierValidAuthored(origin) || _rapierTransformSplices(source, splices) === null)
|
|
46
|
+
throw new RangeError('canonical_origin_invalid');
|
|
47
|
+
return origin;
|
|
48
|
+
}
|
|
49
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import {readLedger, exportLedger, textRoot, rootAfter} from './format.mjs';
|
|
3
|
-
import {_rapierTransformSplices} from './journal-records.mjs';
|
|
3
|
+
import {_rapierTransformSplices, _rapierHistoryEffects} from './journal-records.mjs';
|
|
4
4
|
import {sha256} from './hash.mjs';
|
|
5
5
|
import {plainData, documentText, nonnegative, exactKeys, fault} from './data.mjs';
|
|
6
6
|
function coalesce(runs) {
|
|
@@ -14,16 +14,25 @@ function coalesce(runs) {
|
|
|
14
14
|
return out;
|
|
15
15
|
}
|
|
16
16
|
export function authorship(input) {
|
|
17
|
-
const {ledger, text} = readLedger(input);
|
|
17
|
+
const {ledger, text} = readLedger(input), references = _rapierHistoryEffects(ledger.records).references, removed = new Map();
|
|
18
18
|
let runs = ledger.start.text ? [[0, ledger.start.text.length, 'system', 'unattributed', 0]] : [];
|
|
19
|
-
for (const record of ledger.records)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
for (const record of ledger.records) {
|
|
20
|
+
// An Undo or Redo puts back text the act it references removed: that text keeps the runs it had.
|
|
21
|
+
const tx = record.transaction, gone = [], kept = (references?.get(tx.id) || []).flatMap(id => removed.get(id) || []);
|
|
22
|
+
for (const row of record.splices) {
|
|
23
|
+
const cut = row.pos + row.removed.length, delta = row.inserted.length - row.removed.length, before = [], after = [], inside = [];
|
|
24
|
+
for (const run of runs) {
|
|
25
|
+
if (run[0] < row.pos) before.push([run[0], Math.min(run[1], row.pos), ...run.slice(2)]);
|
|
26
|
+
if (run[1] > cut) after.push([Math.max(run[0], cut) + delta, run[1] + delta, ...run.slice(2)]);
|
|
27
|
+
if (run[0] < cut && run[1] > row.pos) inside.push([Math.max(run[0], row.pos) - row.pos, Math.min(run[1], cut) - row.pos, ...run.slice(2)]);
|
|
28
|
+
}
|
|
29
|
+
if (row.removed) gone.push({text: row.removed, runs: inside});
|
|
30
|
+
const at = row.inserted ? kept.findIndex(entry => entry.text === row.inserted) : -1;
|
|
31
|
+
const put = at < 0 ? [[row.pos, row.pos + row.inserted.length, tx.actor.kind, tx.actor.id, tx.createdAt]]
|
|
32
|
+
: kept.splice(at, 1)[0].runs.map(run => [run[0] + row.pos, run[1] + row.pos, ...run.slice(2)]);
|
|
33
|
+
runs = coalesce([...before, ...put, ...after]);
|
|
24
34
|
}
|
|
25
|
-
|
|
26
|
-
runs = coalesce([...before, [row.pos, row.pos + row.inserted.length, tx.actor.kind, tx.actor.id, tx.createdAt], ...after]);
|
|
35
|
+
removed.set(tx.id, gone);
|
|
27
36
|
}
|
|
28
37
|
return {format: 'rapier-authorship/1', sha256: sha256(text), runs};
|
|
29
38
|
}
|
|
@@ -73,7 +73,7 @@ export function readDocument(source) {
|
|
|
73
73
|
export function readBase(value) {
|
|
74
74
|
if (!value || typeof value !== 'object') throw fault('comparison base SHA-256 differs from its text');
|
|
75
75
|
// Capture each caller-owned field once; admission and serialization use these same scalars.
|
|
76
|
-
const {text, sha256: digest, revision, name, by, at} = value;
|
|
76
|
+
const {text, sha256: digest, revision, name, by, at, fonts} = value;
|
|
77
77
|
if (typeof text !== 'string' || typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest) || sha256(text) !== digest)
|
|
78
78
|
throw fault('comparison base SHA-256 differs from its text');
|
|
79
79
|
if (typeof revision !== 'string' || !revision || revision.length > 256 || /[\u0000-\u001f\u007f]/.test(revision))
|
|
@@ -84,7 +84,8 @@ export function readBase(value) {
|
|
|
84
84
|
throw fault('a comparison needs its author name');
|
|
85
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)))
|
|
86
86
|
throw fault('the time of a comparison is an ISO 8601 UTC timestamp');
|
|
87
|
-
|
|
87
|
+
if (fonts != null && typeof fonts !== 'string') throw fault('invalid comparison font resources');
|
|
88
|
+
return {text, sha256: digest, revision, name, by, at, ...(fonts ? {fonts} : {})};
|
|
88
89
|
}
|
|
89
90
|
const escapeAttribute = value => value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
90
91
|
const readAttribute = value => value.replace(/&(quot|lt|gt|amp);/g, (_, name) => ({quot: '"', lt: '<', gt: '>', amp: '&'})[name]);
|
|
@@ -93,7 +94,8 @@ export function writeBase(text, value) {
|
|
|
93
94
|
const base = readBase(value);
|
|
94
95
|
if (base.text === text) return '';
|
|
95
96
|
return '<script type="text/markdown" id="rapier-base"' + ['name', 'sha256', 'revision', 'by', 'at']
|
|
96
|
-
.map(key => ' data-' + key + '="' + escapeAttribute(base[key]) + '"').join('') +
|
|
97
|
+
.map(key => ' data-' + key + '="' + escapeAttribute(base[key]) + '"').join('') +
|
|
98
|
+
(base.fonts ? ' data-fonts="' + escapeAttribute(JSON.stringify(base.fonts)) + '"' : '') + '>' + encodeCarried(base.text) + '</script>';
|
|
97
99
|
}
|
|
98
100
|
export function readBaseElements(elements) {
|
|
99
101
|
const rows = [...elements].filter(element => element.id === 'rapier-base');
|
|
@@ -102,7 +104,10 @@ export function readBaseElements(elements) {
|
|
|
102
104
|
if (rows.length !== 1 || row.type !== 'text/markdown' || (row.tagName && row.tagName.toLowerCase() !== 'script'))
|
|
103
105
|
throw fault('duplicate or executable comparison base');
|
|
104
106
|
const get = name => row.getAttribute ? row.getAttribute('data-' + name) : row[name];
|
|
105
|
-
|
|
107
|
+
let fonts;
|
|
108
|
+
try { const value = get('fonts'); if (value != null) fonts = JSON.parse(value); }
|
|
109
|
+
catch (_) { throw fault('invalid comparison font resources'); }
|
|
110
|
+
return readBase({text: decodeCarried(row.textContent), fonts, ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at'].map(key => [key, get(key)]))});
|
|
106
111
|
}
|
|
107
112
|
export function takeBase(html) {
|
|
108
113
|
const elements = [];
|
|
@@ -115,7 +120,7 @@ export function takeBase(html) {
|
|
|
115
120
|
seen.add(key); values[key] = value;
|
|
116
121
|
}
|
|
117
122
|
elements.push({id: values.id, type: values.type, textContent: content,
|
|
118
|
-
...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at'].map(key => [key, values['data-' + key]]))});
|
|
123
|
+
...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at', 'fonts'].map(key => [key, values['data-' + key]]))});
|
|
119
124
|
return '';
|
|
120
125
|
});
|
|
121
126
|
return {html: stripped, base: readBaseElements(elements)};
|
package/dist/kit/ledger/data.mjs
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
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
|
-
|
|
4
|
+
// A plain object's prototype is null or Object's own. Both lookups read descriptors, so no getter runs.
|
|
5
|
+
export const plainPrototype = proto => proto === null || Object.getPrototypeOf(proto) === null &&
|
|
6
|
+
Object.getOwnPropertyDescriptor(Object.getOwnPropertyDescriptor(proto, 'constructor')?.value || {}, 'name')?.value === 'Object';
|
|
7
|
+
export function fields(value, depth, active, sorted = true) {
|
|
5
8
|
if (!value || typeof value !== 'object' || depth > 64 || active.has(value)) throw fault('not plain JSON data');
|
|
6
|
-
const array = Array.isArray(value)
|
|
7
|
-
if (!array &&
|
|
9
|
+
const array = Array.isArray(value);
|
|
10
|
+
if (!array && !plainPrototype(Object.getPrototypeOf(value))) throw fault('not a plain object');
|
|
8
11
|
const descriptors = Object.getOwnPropertyDescriptors(value), keys = Reflect.ownKeys(descriptors);
|
|
9
12
|
for (const key of keys) {
|
|
10
13
|
if (array && key === 'length') continue;
|
|
@@ -14,7 +17,7 @@ function fields(value, depth, active, sorted = true) {
|
|
|
14
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');
|
|
15
18
|
return {array, descriptors, keys: array ? keys.filter(key => key !== 'length') : sorted ? keys.sort() : keys};
|
|
16
19
|
}
|
|
17
|
-
function scalar(value) {
|
|
20
|
+
export function scalar(value) {
|
|
18
21
|
if (value === null || typeof value === 'boolean') return true;
|
|
19
22
|
if (typeof value === 'string') { if (/[\uD800-\uDFFF]/u.test(value)) throw fault('unpaired surrogate'); return true; }
|
|
20
23
|
return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0);
|
|
@@ -27,12 +27,34 @@ function* checkedRecords(ledger, prepared) {
|
|
|
27
27
|
}
|
|
28
28
|
if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
|
|
29
29
|
}
|
|
30
|
+
// A ledger object read again is proved once: a boot reads one stored history through several records. The proof holds only
|
|
31
|
+
// while that object still carries the exact proved data (its start text compared whole, the rest as canonical JSON), so a
|
|
32
|
+
// changed byte is proved again, and refused. The proof lives as long as the object does.
|
|
33
|
+
const proofs = new WeakMap();
|
|
30
34
|
function readLedgerState(input, expectedText, expectedMetadata) {
|
|
31
35
|
const ledger = plainData(input);
|
|
36
|
+
// The identifier rapier-ledger/2 is unchanged and is not backward compatible. This reader
|
|
37
|
+
// admits an empty physical splice whose authored rows name the same replacement. A reader
|
|
38
|
+
// from before that rule rejects the chain. Mixed-version sync throws notes_history_unavailable
|
|
39
|
+
// and leaves the older store unchanged; that refusal is not a held or caughtUp result.
|
|
32
40
|
if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
|
|
33
41
|
typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
|
|
34
42
|
typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
|
|
35
43
|
!exactKeys(ledger.start, ['text','revision','root','sha256','metadata']) || !exactKeys(ledger.head, ['revision','root','sha256','metadata'])) throw fault('unsupported or invalid envelope');
|
|
44
|
+
const shell = canonicalJSON({...ledger, start: {...ledger.start, text: ''}});
|
|
45
|
+
let proof = typeof input === 'object' ? proofs.get(input) : undefined;
|
|
46
|
+
if (!proof || proof.shell !== shell || proof.startText !== ledger.start.text) {
|
|
47
|
+
proof = {shell, startText: ledger.start.text, ...proveLedger(ledger)};
|
|
48
|
+
if (typeof input === 'object') proofs.set(input, proof);
|
|
49
|
+
}
|
|
50
|
+
proofs.set(ledger, proof);
|
|
51
|
+
const {text, metadata, root, revision, prepared, source} = proof;
|
|
52
|
+
if (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata)))
|
|
53
|
+
throw fault('metadata does not describe this document');
|
|
54
|
+
if (expectedText !== undefined && text !== expectedText) throw fault('history does not describe this document');
|
|
55
|
+
return {value: {ledger, text, metadata: {...metadata}, root, revision, complete: ledger.complete}, prepared, source};
|
|
56
|
+
}
|
|
57
|
+
function proveLedger(ledger) {
|
|
36
58
|
const {sha256: claimed, ...payload} = ledger;
|
|
37
59
|
if (claimed !== digest(payload)) throw fault('envelope checksum differs');
|
|
38
60
|
let text = documentText(ledger.start.text), root = ledger.start.root, revision = ledger.start.revision;
|
|
@@ -55,12 +77,18 @@ function readLedgerState(input, expectedText, expectedMetadata) {
|
|
|
55
77
|
try { authoredHistory(ledger.start.text, ledger.records); } catch (_) { throw fault('authored origin differs'); }
|
|
56
78
|
}
|
|
57
79
|
const metadata = prepared.replayMetadata(ledger.start.metadata);
|
|
58
|
-
if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
80
|
+
if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata)) throw fault('metadata does not describe this document');
|
|
81
|
+
if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text)) throw fault('history does not describe this document');
|
|
82
|
+
return {text, metadata, root, revision, prepared, source};
|
|
83
|
+
}
|
|
84
|
+
// A stored copy may hold the head text alone. Its start is that text with every record reversed; readLedger proves it.
|
|
85
|
+
export function ledgerWithStart(input, text) {
|
|
86
|
+
let source = new HistorySource(documentText(text));
|
|
87
|
+
for (let i = input.records.length - 1; i >= 0; i--) {
|
|
88
|
+
source = source.apply(input.records[i].splices, true);
|
|
89
|
+
if (source === null) throw fault('history does not reverse from this document');
|
|
90
|
+
}
|
|
91
|
+
return {...input, start: {...input.start, text: source.slice()}};
|
|
64
92
|
}
|
|
65
93
|
export function readLedger(input, expectedText, expectedMetadata) {
|
|
66
94
|
return readLedgerState(input, expectedText, expectedMetadata).value;
|
|
@@ -163,9 +191,7 @@ function finish(producer) {
|
|
|
163
191
|
return step.value;
|
|
164
192
|
}
|
|
165
193
|
export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
|
|
166
|
-
// Both synchronous public export and cooperative recovery use the same producer.
|
|
167
194
|
// Its returned facts were computed while admitting/hashing the exact head, never supplied.
|
|
168
|
-
export function prepareLedger(input) { return finish(produceLedger(input)); }
|
|
169
195
|
export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
|
|
170
196
|
const producer = produceLedger(input);
|
|
171
197
|
let step;
|
|
@@ -182,8 +208,8 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
|
|
|
182
208
|
return step.value;
|
|
183
209
|
}
|
|
184
210
|
// Derived navigation, never a second history. A target older than start is intentionally unavailable.
|
|
185
|
-
export function historyEnvelope(input, segmentIdentity = []) {
|
|
186
|
-
const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input);
|
|
211
|
+
export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
|
|
212
|
+
const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input, expectedText, expectedMetadata);
|
|
187
213
|
const {byId, effects: {pictures}} = prepared;
|
|
188
214
|
let branch = [], cursor = 0;
|
|
189
215
|
for (const {transaction: tx} of ledger.records) {
|
|
@@ -198,7 +224,7 @@ export function historyEnvelope(input, segmentIdentity = []) {
|
|
|
198
224
|
}
|
|
199
225
|
const step = (source, id, redo) => {
|
|
200
226
|
const target = byId.get(id), folded = pictures.size
|
|
201
|
-
? pictureHistorySplices({source
|
|
227
|
+
? pictureHistorySplices({get source() { return source.slice(); }, records: prepared.records}, target, redo) : null;
|
|
202
228
|
return source.apply(folded || target.splices, !folded && !redo);
|
|
203
229
|
};
|
|
204
230
|
let traversed = source, traversedMetadata = {...metadata};
|
package/dist/kit/ledger/hash.mjs
CHANGED
|
@@ -30,13 +30,11 @@ const K = new Uint32Array([
|
|
|
30
30
|
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
|
|
31
31
|
]);
|
|
32
32
|
const rotate = (x, n) => (x >>> n) | (x << (32 - n));
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
export function createSHA256() {
|
|
33
|
+
// One bounded SHA-256 compression state for file bytes and encoded source text.
|
|
34
|
+
export function sha256State() {
|
|
36
35
|
const h = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
|
|
37
|
-
const w = new Uint32Array(64), block = new Uint8Array(64)
|
|
38
|
-
|
|
39
|
-
let used = 0, bytes = 0, high = '', finished = false;
|
|
36
|
+
const w = new Uint32Array(64), block = new Uint8Array(64);
|
|
37
|
+
let used = 0, bytes = 0, finished = false;
|
|
40
38
|
const compress = (input, offset) => {
|
|
41
39
|
for (let i = 0; i < 16; i++) {
|
|
42
40
|
const at = offset + i * 4;
|
|
@@ -65,13 +63,37 @@ export function createSHA256() {
|
|
|
65
63
|
for (; at + 64 <= size; at += 64) compress(input, at);
|
|
66
64
|
if (at < size) { block.set(input.subarray(at, size), 0); used = size - at; }
|
|
67
65
|
};
|
|
66
|
+
return {
|
|
67
|
+
update(input) {
|
|
68
|
+
if (finished || !(input instanceof Uint8Array)) throw new TypeError('SHA-256 needs bytes before finish');
|
|
69
|
+
if (!Number.isSafeInteger(bytes + input.length)) throw new Error('SHA-256 input length is not exact');
|
|
70
|
+
feed(input, input.length);
|
|
71
|
+
},
|
|
72
|
+
finish() {
|
|
73
|
+
if (finished) throw new Error('SHA-256 is already finished');
|
|
74
|
+
finished = true;
|
|
75
|
+
block[used++] = 0x80; block.fill(0, used);
|
|
76
|
+
if (used > 56) { compress(block, 0); block.fill(0); }
|
|
77
|
+
const view = new DataView(block.buffer);
|
|
78
|
+
view.setUint32(56, Math.floor(bytes / 0x20000000)); view.setUint32(60, (bytes * 8) >>> 0);
|
|
79
|
+
compress(block, 0);
|
|
80
|
+
return Array.from(h, x => x.toString(16).padStart(8, '0')).join('');
|
|
81
|
+
},
|
|
82
|
+
get bytes() { return bytes; },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// UTF-16 chunks share that byte state. Retain a trailing high surrogate so a
|
|
86
|
+
// caller can split a character between updates without changing its UTF-8 digest.
|
|
87
|
+
export function createSHA256() {
|
|
88
|
+
const hash = sha256State(), encoded = new Uint8Array(65536), encoder = new TextEncoder();
|
|
89
|
+
let high = '', finished = false;
|
|
68
90
|
const write = text => {
|
|
69
91
|
for (let at = 0; at < text.length;) {
|
|
70
92
|
// Bound both the substring and byte allocation; encodeInto never cuts a scalar.
|
|
71
93
|
let end = Math.min(text.length, at + 32768);
|
|
72
94
|
if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
|
|
73
95
|
const part = encoder.encodeInto(text.slice(at, end), encoded);
|
|
74
|
-
|
|
96
|
+
hash.update(encoded.subarray(0, part.written)); at += part.read;
|
|
75
97
|
}
|
|
76
98
|
};
|
|
77
99
|
return {
|
|
@@ -85,15 +107,9 @@ export function createSHA256() {
|
|
|
85
107
|
if (finished) throw new Error('SHA-256 is finished');
|
|
86
108
|
if (high) { write(high); high = ''; }
|
|
87
109
|
finished = true;
|
|
88
|
-
|
|
89
|
-
block[used++] = 0x80; block.fill(0, used);
|
|
90
|
-
if (used > 56) { compress(block, 0); block.fill(0); }
|
|
91
|
-
const view = new DataView(block.buffer);
|
|
92
|
-
view.setUint32(56, Math.floor(bits / 0x100000000)); view.setUint32(60, bits >>> 0);
|
|
93
|
-
compress(block, 0);
|
|
94
|
-
return Array.from(h, x => x.toString(16).padStart(8, '0')).join('');
|
|
110
|
+
return hash.finish();
|
|
95
111
|
},
|
|
96
|
-
get bytes() { return bytes; },
|
|
112
|
+
get bytes() { return hash.bytes; },
|
|
97
113
|
};
|
|
98
114
|
}
|
|
99
115
|
export function sha256Parts(parts) {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// Transient projections and selective inverses of the existing canonical journal.
|
|
3
|
-
import {_rapierTransformSplices,
|
|
3
|
+
import {_rapierTransformSplices, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierPrepareHistoryRecords} from './journal-records.mjs';
|
|
4
4
|
import {projectInverseSource, HistorySource} from './inverse-source.mjs';
|
|
5
5
|
import {textRootParts, rootAfter} from './hash.mjs';
|
|
6
|
-
import {authoredHistory} from './transport.mjs';
|
|
7
|
-
import {plainData} from './data.mjs';
|
|
6
|
+
import {authoredHistory, authoredPlacements} from './transport.mjs';
|
|
7
|
+
import {plainData, canonicalJSON} from './data.mjs';
|
|
8
8
|
|
|
9
9
|
const historyFailure = reason => ({ok: false, reason});
|
|
10
10
|
const historyInteger = value => Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
|
|
@@ -43,11 +43,15 @@ export function groupHistoryActs(acts) {
|
|
|
43
43
|
|
|
44
44
|
// A picture's codec replacement travels with its original navigation step.
|
|
45
45
|
// Match every complete replacement exactly before moving any source bytes.
|
|
46
|
-
export function pictureHistorySplices(
|
|
46
|
+
export function pictureHistorySplices(input, target, redo) {
|
|
47
|
+
const {records} = input;
|
|
47
48
|
const id = target?.transaction?.id, at = records.indexOf(target);
|
|
48
49
|
if (!id || at < 0 || target.transaction.operation === 'document.finish-picture' || !Array.isArray(target.splices)) return null;
|
|
49
50
|
const later = records.slice(at + 1).filter(row => row?.transaction?.operation === 'document.finish-picture' && Array.isArray(row.splices) && row.splices.length);
|
|
50
51
|
if (!later.length) return null;
|
|
52
|
+
// A caller may hold a persistent source tree: request its text only when
|
|
53
|
+
// a later picture replacement can actually affect this navigation step.
|
|
54
|
+
const source = input.source;
|
|
51
55
|
const own = new Set(later.filter(row => row.transaction.sourceTransactionId === id));
|
|
52
56
|
const step = redo ? target.splices : target.splices.slice().reverse().map(row => ({pos: row.pos, removed: row.inserted, inserted: row.removed}));
|
|
53
57
|
const plan = moveOthers => {
|
|
@@ -100,8 +104,11 @@ function prepareHistory(input) {
|
|
|
100
104
|
!(tx.createdAt === null || historyInteger(tx.createdAt)) ||
|
|
101
105
|
(tx.turnId != null && (typeof tx.turnId !== 'string' || !tx.turnId || tx.turnId.length > 160))) return historyFailure('history_invalid');
|
|
102
106
|
if (tx.baseRevision !== prior) complete = false;
|
|
107
|
+
const coincidentWitness = !splices.length && record.authored && Array.isArray(record.authored.splices) &&
|
|
108
|
+
record.authored.splices.some(row => row.removed.length);
|
|
103
109
|
if (!splices || effect === undefined || (!splices.length &&
|
|
104
|
-
!Object.values(effect || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length
|
|
110
|
+
!Object.values(effect || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length &&
|
|
111
|
+
!coincidentWitness)) return historyFailure('history_unavailable');
|
|
105
112
|
const row = {record, splices, metadata: effect};
|
|
106
113
|
rows.push(row); byId.set(tx.id, row); prior = tx.revision;
|
|
107
114
|
}
|
|
@@ -116,7 +123,9 @@ function prepareHistory(input) {
|
|
|
116
123
|
row.beforeSource = initial; row.beforeMetadata = initialMetadata;
|
|
117
124
|
}
|
|
118
125
|
const earliestRevision = records[0]?.transaction.baseRevision ?? revision;
|
|
119
|
-
|
|
126
|
+
// The window's owner may name its root (the source store mints it once per text); otherwise hash the initial text.
|
|
127
|
+
let root = earliestRevision === 0 ? (typeof input.earliestRoot === 'string' && input.earliestRoot || textRootParts(initial.parts())) : null,
|
|
128
|
+
rootRevision = earliestRevision;
|
|
120
129
|
for (const {record, splices} of rows) {
|
|
121
130
|
const hashed = Object.hasOwn(record, 'beforeHash') || Object.hasOwn(record, 'afterHash');
|
|
122
131
|
if (hashed && (![record.beforeHash, record.afterHash].every(value =>
|
|
@@ -281,10 +290,19 @@ export function historyPlaces(input, blocks) {
|
|
|
281
290
|
}
|
|
282
291
|
touched.set(node, after);
|
|
283
292
|
});
|
|
293
|
+
// Blocks one splice covers share its range (a whole-document delete maps every block to all it removed): slice each range once.
|
|
294
|
+
const slices = new Map(), cut = (source, side, from, to) => {
|
|
295
|
+
const key = side + from + ':' + to;
|
|
296
|
+
if (!slices.has(key)) slices.set(key, source.slice(from, to));
|
|
297
|
+
return slices.get(key);
|
|
298
|
+
};
|
|
299
|
+
// One record's act is read once; the blocks it touched share its fields (a whole-document delete touches every block).
|
|
300
|
+
let act = null;
|
|
284
301
|
for (const [node, afterRange] of touched) {
|
|
285
|
-
const beforeRange = ranges.range(node), previous = {...beforeRange, source: beforeSource
|
|
286
|
-
after = {...afterRange, source: afterSource
|
|
287
|
-
|
|
302
|
+
const beforeRange = ranges.range(node), previous = {...beforeRange, source: cut(beforeSource, 'b', beforeRange.from, beforeRange.to)},
|
|
303
|
+
after = {...afterRange, source: cut(afterSource, 'a', afterRange.from, afterRange.to)};
|
|
304
|
+
// A point (where words were deleted) has no words to compare: the act that touched it wrote there.
|
|
305
|
+
if (previous.source !== after.source || afterRange.from === afterRange.to) node.block.acts.push({...(act ||= historyAct(record)), before: previous, after});
|
|
288
306
|
}
|
|
289
307
|
}
|
|
290
308
|
|
|
@@ -322,7 +340,27 @@ export function selectiveUndo(input, actIds, options = {}) {
|
|
|
322
340
|
const metadata = _rapierMetadataDelta(prepared.metadata, afterMetadata), effectChanged = selected.size > 0;
|
|
323
341
|
const sourceEffectChanged = records.some(record => record.splices.length && !prepared.effects.references.get(record.transaction.id)?.length &&
|
|
324
342
|
prepared.effects.active.has(record.transaction.id) !== projectedEffects.active.has(record.transaction.id));
|
|
325
|
-
|
|
343
|
+
// One shared replacement is visible while any of its authors is active. That
|
|
344
|
+
// includes an author stored with no second physical splice, and an inverse
|
|
345
|
+
// whose only effect is to make such an author active again.
|
|
346
|
+
let coverVisibilityChanged = false;
|
|
347
|
+
if (!sourceEffectChanged && selected.size) {
|
|
348
|
+
let origins = null;
|
|
349
|
+
try { origins = authoredPlacements(prepared.initialSource, records); } catch { origins = null; }
|
|
350
|
+
const groups = new Map();
|
|
351
|
+
if (origins) for (const record of records) {
|
|
352
|
+
const tx = record.transaction, origin = origins.get(tx.id);
|
|
353
|
+
if (!origin?.splices.some(row => row.removed.length) || tx.reverts || tx.reapplies ||
|
|
354
|
+
tx.sourceTransactionId || tx.sourceTransactionIds?.length) continue;
|
|
355
|
+
const key = canonicalJSON({basis: origin.basis, splices: origin.splices});
|
|
356
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
357
|
+
groups.get(key).push(tx.id);
|
|
358
|
+
}
|
|
359
|
+
const covered = (effects, ids) => ids.some(id => effects.active.has(id));
|
|
360
|
+
for (const ids of groups.values()) if (ids.length > 1 && covered(prepared.effects, ids) !== covered(projectedEffects, ids))
|
|
361
|
+
coverVisibilityChanged = true;
|
|
362
|
+
}
|
|
363
|
+
if (!sourceEffectChanged && !coverVisibilityChanged) return {ok: true, splices: [], after: prepared.source, actIds: [...requested],
|
|
326
364
|
sourceTransactionIds: [...selected], metadata, afterMetadata, effectChanged, unchanged: !metadata && !effectChanged,
|
|
327
365
|
superseded: [], overlaps: []};
|
|
328
366
|
try {
|