rapier-markdown-kit 1.2.8 → 1.2.9

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.
Files changed (60) hide show
  1. package/dist/agent/will.mjs +0 -6
  2. package/dist/images/gif.mjs +0 -3
  3. package/dist/images/playback.mjs +0 -10
  4. package/dist/kit/assets.mjs +0 -1
  5. package/dist/kit/conformance/measurer.mjs +0 -0
  6. package/dist/kit/conformance/run.mjs +1 -8
  7. package/dist/kit/export-fonts.mjs +0 -14
  8. package/dist/kit/ledger/authored-origin.mjs +0 -5
  9. package/dist/kit/ledger/authorship.mjs +0 -2
  10. package/dist/kit/ledger/carried.mjs +4 -7
  11. package/dist/kit/ledger/data.mjs +0 -8
  12. package/dist/kit/ledger/format.mjs +0 -25
  13. package/dist/kit/ledger/hash.mjs +11 -7
  14. package/dist/kit/ledger/history.mjs +10 -29
  15. package/dist/kit/ledger/inverse-source.mjs +0 -46
  16. package/dist/kit/ledger/journal-records.mjs +24 -56
  17. package/dist/kit/ledger/merge.mjs +0 -27
  18. package/dist/kit/ledger/text-merge.mjs +0 -20
  19. package/dist/kit/ledger/transport.mjs +0 -47
  20. package/dist/kit/ledger.mjs +0 -4
  21. package/dist/kit/markdown-it.mjs +0 -3
  22. package/dist/kit/pdf-glyphs.mjs +1 -10
  23. package/dist/kit/pdf-source.mjs +5 -5
  24. package/dist/kit/pdf-vectors.mjs +0 -3
  25. package/dist/kit/print-capitals.mjs +0 -4
  26. package/dist/kit/render-entry.mjs +0 -1
  27. package/dist/kit/render-ink.mjs +0 -5
  28. package/dist/kit/render-markdown.mjs +12 -117
  29. package/dist/kit/render-print.mjs +1 -47
  30. package/dist/kit/render-sanitize.mjs +7 -59
  31. package/dist/kit/render-styles.mjs +0 -7
  32. package/dist/kit/render-work.mjs +0 -12
  33. package/dist/kit/render.mjs +18 -173
  34. package/dist/layout/hyphenation.mjs +0 -23
  35. package/dist/layout/line-plan.mjs +0 -20
  36. package/dist/layout/model.mjs +0 -55
  37. package/dist/layout/paragraph-fit.mjs +50 -60
  38. package/dist/layout/preparation.mjs +23 -56
  39. package/dist/layout/prepared-paragraph.mjs +0 -25
  40. package/dist/layout/typography.mjs +103 -41
  41. package/dist/packages/rapier-diff/concerns.mjs +0 -16
  42. package/dist/packages/rapier-diff/diff.mjs +0 -7
  43. package/dist/packages/rapier-diff/index.mjs +0 -1
  44. package/dist/packages/rapier-diff/matching.mjs +0 -19
  45. package/dist/packages/rapier-diff/merge.mjs +0 -9
  46. package/dist/packages/rapier-diff/moves.mjs +0 -7
  47. package/dist/packages/rapier-diff/structure.mjs +0 -2
  48. package/dist/packages/rapier-diff/tokens.mjs +0 -6
  49. package/dist/packages/rapier-diff/walk.mjs +0 -12
  50. package/dist/spec/document-fonts.mjs +0 -9
  51. package/dist/spec/document-settings.mjs +4 -23
  52. package/dist/spec/frontmatter.mjs +0 -1
  53. package/dist/spec/html-blocks.mjs +0 -36
  54. package/dist/spec/md-assets.mjs +2 -61
  55. package/dist/spec/md-layout.mjs +0 -34
  56. package/dist/spec/md-marks.mjs +0 -51
  57. package/dist/spec/md-source.mjs +0 -16
  58. package/dist/spec/word-sidecar.mjs +0 -2
  59. package/package.json +1 -1
  60. package/style.css +32 -16
@@ -10,7 +10,6 @@ const _RAPIER_WILL_UNSPACED_OPENER = '<!--will/';
10
10
  const _RAPIER_WILL_UNSPACED_CLOSER = '<!--/will';
11
11
 
12
12
  function hasWillMarkers(text) {
13
- // Lines end at \r and \n only, as the parser splits them; /m would also start a line after U+2028 and U+2029.
14
13
  return /(?:^|[\r\n])<!-- ?(?:will\/|\/will)/.test(String(text == null ? '' : text));
15
14
  }
16
15
 
@@ -49,18 +48,14 @@ function _rapierWillMarkerOf(content) {
49
48
  if (!intent.length || intent.indexOf('--') >= 0) return near('malformed_marker');
50
49
  if ([...intent].length > _RAPIER_WILL_INTENT_LIMIT) return near('intent_over_bound');
51
50
  }
52
- // The words after the colon are document data, never permission; willGovern alone decides.
53
51
  return { kind: 'open', law: word, intent, content: line };
54
52
  }
55
53
 
56
- // Scan CR, CRLF and LF lines directly into Will's reducer, including a trailing empty line.
57
- // Offsets remain authored UTF-16 coordinates; no editor helper or whole-document line tables.
58
54
  function _rapierWillParse(text) {
59
55
  const source = String(text == null ? '' : text);
60
56
  const will = {
61
57
  present: false, fault: '', faults: [], blocks: [], markers: [], regions: [], text: source,
62
58
  };
63
- // A marker, well formed or near, opens its line with one of the four spellings hasWillMarkers looks for.
64
59
  if (!hasWillMarkers(source)) return will;
65
60
  let open = null;
66
61
  for (let index = 0, start = 0; start <= source.length; index++) {
@@ -151,7 +146,6 @@ function _rapierWillIntentOf(will, start, end) {
151
146
  const found = _rapierWillRegionsIn(will, start, end);
152
147
  if (found.length !== 1) return '';
153
148
  const region = found[0];
154
- // Disclosed with the region's law, never in place of it: under keep and append the words widen nothing.
155
149
  return region.intent && start >= region.start && end <= region.end ? region.intent : '';
156
150
  }
157
151
 
@@ -1,6 +1,4 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Container admission and a single-frame carrier, not a pixel/LZW decoder.
3
- // Original bytes remain the asset. Native ImageDecoder owns frame composition.
4
2
  export function inspectGIF(bytes) {
5
3
  const fail = message => {throw Object.assign(new Error(message), {code: 'GIF_ADMISSION'});};
6
4
  if (!(bytes instanceof Uint8Array) || bytes.length < 13 || bytes.length > 16 * 1024 * 1024 ||
@@ -39,7 +37,6 @@ export function inspectGIF(bytes) {
39
37
  at += 9; if (packed & 128) at += 3 * (2 << (packed & 7));
40
38
  need(1); if (bytes[at] < 2 || bytes[at] > 8) fail('Invalid GIF code size');
41
39
  at++; blocks(); frames++;
42
- // A delay of 0 or 1 centisecond shows for 100 ms, as the browser draws it (images/playback.mjs times frames the same).
43
40
  const delay = control ? control[4] + control[5] * 256 : 0;
44
41
  duration += delay > 1 ? delay * 10 : 100;
45
42
  if (frames > 240 || width * height * frames > 96000000 || duration > 120000) fail('GIF animation exceeds the frame, work or duration budget');
@@ -1,9 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Shared, self-contained playback for the host and its opaque HTML frames.
3
- // No source/history authority; at most one decoder and one returned frame per object.
4
2
  export function createMediaPlayback(win, inspectGIF) {
5
3
  const active = new Map(), encode = bytes => bytes.toBase64();
6
- // Base64 decodes in 1 MiB slices with a task yield between them, so a large animation never holds the main thread.
7
4
  const bytes = async url => {
8
5
  const text = url.slice(url.indexOf(',') + 1), slice = 1 << 20;
9
6
  const out = new Uint8Array(text.length / 4 * 3 - (text.endsWith('==') ? 2 : text.endsWith('=') ? 1 : 0));
@@ -15,7 +12,6 @@ export function createMediaPlayback(win, inspectGIF) {
15
12
  return out;
16
13
  };
17
14
  function raster(url, canvas, changed = () => {}, dimensions = null) {
18
- // setFromBase64 refuses any character outside base64 while decoding.
19
15
  if (url.length > 24 * 1024 * 1024 || !/^data:image\/(gif|jxl);base64,/i.test(url) || (url.length - url.indexOf(',') - 1) % 4) throw new Error('Unsupported animation source');
20
16
  const type = url.slice(5, url.indexOf(';')).toLowerCase();
21
17
  let data = null, info = null, size = null, decoder = null, timer = 0, closed = false, paused = true, ticket = 0, next = 0, loops = 0, duration = 0, single = false, held = false;
@@ -30,8 +26,6 @@ export function createMediaPlayback(win, inspectGIF) {
30
26
  if (!closed) changed(state);
31
27
  })();
32
28
  admitted.catch(error => {if (!closed) {state.status = 'admission-failed'; state.error = String(error.message).slice(0, 160); changed(state);}});
33
- // A decoder closes only after its in-flight decode settles; Chrome 155's JPEG XL decoder fails when closed mid-decode.
34
- // Its budget place goes with it: a poster or a finished loop holds no decoder.
35
29
  let inflight = null;
36
30
  const closeDecoder = () => {ticket++; win.clearTimeout(timer); timer = 0; active.delete(canvas); const old = decoder; decoder = null; if (old) inflight ? inflight.then(() => old.close()) : old.close();};
37
31
  const settle = async promise => {
@@ -73,8 +67,6 @@ export function createMediaPlayback(win, inspectGIF) {
73
67
  if (canvas.height !== h) canvas.height = h;
74
68
  const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, w, h); ctx.drawImage(frame, 0, 0);
75
69
  if (!state.first && next === 0) state.first = canvas.toDataURL('image/png');
76
- // A GIF frame of 10 ms or less shows for 100 ms, as the browser draws an <img> (images/gif.mjs counts the same).
77
- // A JPEG XL frame keeps its own duration: its ticks are exact, and 100 frames a second is a real animation.
78
70
  const delay = type !== 'image/gif' ? Math.max(8, frame.duration / 1000) : frame.duration > 10000 ? Math.max(20, frame.duration / 1000) : 100;
79
71
  duration += delay; if (duration > 120000) throw new Error('Animation exceeds the duration budget');
80
72
  state.status = 'ready'; state.frame = next; changed(state);
@@ -92,9 +84,7 @@ export function createMediaPlayback(win, inspectGIF) {
92
84
  }
93
85
  }
94
86
  return {state, play() {if (!closed && paused && !['decode-failed', 'admission-failed'].includes(state.status)) {paused = false; void step();}},
95
- // A suspension that arrives during the one-frame decode (an offscreen host's first visibility report) waits for it.
96
87
  async first() {if (!state.first && !closed) {single = true; next = 0; paused = false; try {await step();} finally {single = false; paused = true; closeDecoder(); if (held) {held = false; this.suspend();}}} return state.first;},
97
- // Pause keeps the decoder and its frame position; only suspension and removal release it.
98
88
  pause() {paused = true; ticket++; win.clearTimeout(timer); timer = 0;},
99
89
  suspend() {if (single) {held = true; return;} paused = true; closeDecoder(); canvas.width = canvas.height = 0; state.status = 'suspended'; changed(state);},
100
90
  close() {closed = true; paused = true; closeDecoder(); canvas.width = canvas.height = 0;},
@@ -1,6 +1,5 @@
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
- // The kit's names are listed: that module also exports what only Rapier's own parser consumers share.
4
3
  export {IMAGE_LIMITS, configureParser, normalizeLabel, assetTitle, installMarkdownImages, markdownParser, markdownBodyOffset,
5
4
  dataImage, parseAssets, documentAssets, packAssetIndex, restoreAssetIndex, projectImageDefinitions, blockwiseAssets,
6
5
  imageEnvironment, referenceOccurs, mayRetireImageDefinitions, retireDeletedImageDefinitions, assetOmissions, isAssetBlock,
Binary file
@@ -1,6 +1,4 @@
1
- #!/usr/bin/env node
2
1
  // SPDX-License-Identifier: MIT
3
- // Usage: node run.mjs [--impl path/to/module.mjs] [--tolerance 2]
4
2
  import {resolve} from 'node:path';
5
3
  import {dirname} from 'node:path';
6
4
  import {fileURLToPath} from 'node:url';
@@ -32,11 +30,9 @@ function buildProfile(hint) {
32
30
  }
33
31
 
34
32
  const documents = loadDocuments(here);
35
- // Install once over merged data: Pretext memoizes its context (measurer.mjs).
36
33
  const measurer = installMeasurer(documents);
37
34
  const preparation = impl.createPreparationScope();
38
35
 
39
- // Preservation and presentation are separate claims; preservation is exact or a bug.
40
36
  const preservationRows = documents.map(expected => {
41
37
  const comment = expected.picture?.layout;
42
38
  if (!comment) return {document: expected.document, verdict: 'n/a', detail: "this fixture's picture carries no md-layout comment"};
@@ -61,7 +57,7 @@ const preservationRows = documents.map(expected => {
61
57
  console.log('Presentation (line boxes; hits/misses show how much of each row rests on the real trace vs. the character-sum fallback)\n');
62
58
  const rows = [];
63
59
  for (const expected of documents) {
64
- const font = expected.measurer.font.replace(/\/[^ ]+ /, ' '); // canvas fonts take no line-height
60
+ const font = expected.measurer.font.replace(/\/[^ ]+ /, ' ');
65
61
  const before = {hits: measurer.stats.hits, misses: measurer.stats.misses};
66
62
  const flow = preparation.prepare(expected, [{text: expected.anchorText, font, letterSpacing: expected.measurer.letterSpacing || 0}]);
67
63
  if (!flow) { rows.push({document: expected.document, verdict: 'ERROR', detail: 'rich preparation refused this font/text/letterSpacing'}); continue; }
@@ -80,14 +76,11 @@ for (const expected of documents) {
80
76
  let maxLeftDelta = 0, maxRightDelta = 0, maxTopDelta = 0;
81
77
  for (let i = 0; i < Math.min(plan.lines.length, expected.lines.length); i++) {
82
78
  const got = plan.lines[i], want = expected.lines[i];
83
- // Flowed lines are line boxes; an unflowed line's recorded top is its glyphs' own.
84
79
  if (expected.anchorFlowed) maxTopDelta = Math.max(maxTopDelta, Math.abs(got.y - want.top));
85
- // Flowed lines compare slot geometry (x+width); unflowed lines compare the measured tight extent.
86
80
  const right = expected.anchorFlowed ? got.x + got.width : got.x + measurer.measure(font, got.fragments.map(f => f.text).join(''));
87
81
  maxLeftDelta = Math.max(maxLeftDelta, Math.abs(got.x - want.left));
88
82
  maxRightDelta = Math.max(maxRightDelta, Math.abs(right - want.right));
89
83
  }
90
- // A line that keeps its edges but lands on another row is a different layout.
91
84
  const rowsAgree = maxTopDelta <= args.tolerance;
92
85
  const withinTolerance = lineCountAgrees && rowsAgree && maxLeftDelta <= args.tolerance && maxRightDelta <= args.tolerance;
93
86
  rows.push({
@@ -1,12 +1,10 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {documentFontUsage, CSS_GENERIC_FONT_FAMILIES} from '../spec/document-fonts.mjs';
3
3
  import {normalizeFontFamily} from '../spec/md-marks.mjs';
4
- // Collect document font data without a layout engine. Authored drawing resources keep their identities.
5
4
  const FONT_FIELDS = new Set(['font', 'font-family', 'font-weight', 'font-style', 'font-stretch', 'text-transform']);
6
5
  const FAMILY = "'Geist',system-ui,sans-serif";
7
6
  const MONO = "'Geist Mono',monospace";
8
7
 
9
- // Optional HTML enhancement: the font payload and its encoded stylesheet have separate limits.
10
8
  const FACTORY_FONT_BYTES = 8 * 1024, FACTORY_FONT_CSS_BYTES = 12 * 1024;
11
9
  const decodeCss = value => value.replace(/\\(?:([\da-f]{1,6})(?:\r\n|[ \t\n\r\f])?|([^\n\r\f]))/gi,
12
10
  (_, hex, character) => hex ? String.fromCodePoint(Math.max(1, Math.min(0x10ffff, parseInt(hex, 16)))) : character);
@@ -68,7 +66,6 @@ function rules(text, visit) {
68
66
  }
69
67
 
70
68
  function specificity(selector) {
71
- // The reference sheet uses :where() for zero-specificity defaults.
72
69
  let value = selector.replace(/:where\((?:[^()]|\([^()]*\))*\)/g, '');
73
70
  const ids = (value.match(/#[\w-]+/g) || []).length;
74
71
  const classes = (value.match(/\.[\w-]+|\[[^\]]*\]|:(?!:)[\w-]+/g) || []).length;
@@ -291,14 +288,12 @@ export async function exportFontCss(root, {settings, css = '', fontCss = '', sub
291
288
  if (typeof subset !== 'function') throw new TypeError('The page font writer needs a subsetter.');
292
289
  let candidates = fontFaces(fontCss);
293
290
  if (portableFactory) {
294
- // These candidates come from the factory resource host. Carried data owns a same-name family.
295
291
  const custom = new Set(fontFaces(customFontCss).map(face => face.family));
296
292
  candidates = candidates.filter(face => face.family === 'geist' && !custom.has(face.family));
297
293
  }
298
294
  const faces = fontRuns(root, {settings, css, faces: candidates}), output = [], prepared = new Map();
299
295
  let payloadBytes = 0;
300
296
  for (const face of faces) {
301
- // Adjacent inline nodes retain shaping sequences; independent runs cannot compose together.
302
297
  const text = face.runs.join('\n');
303
298
  if (!prepared.has(face.source)) prepared.set(face.source, new Map());
304
299
  const variants = prepared.get(face.source);
@@ -310,7 +305,6 @@ export async function exportFontCss(root, {settings, css = '', fontCss = '', sub
310
305
  if (typeof validateFont !== 'function' || await validateFont(Uint8Array.from(bytes), {family: 'Geist'}) !== true) return '';
311
306
  } else if (bytes === null) { variants.set(text, null); continue; }
312
307
  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
308
  variants.set(text, {source: fontSource(bytes), length: bytes.length});
315
309
  }
316
310
  const saved = variants.get(text);
@@ -329,10 +323,8 @@ export async function exportFontCss(root, {settings, css = '', fontCss = '', sub
329
323
  }
330
324
  }
331
325
 
332
- // Validate the captured custom resources without rewriting their source or borrowing a live catalog.
333
326
  export async function requireDocumentFonts(source, {parser, index, root, settings, css = '', fontCss = '', validateFont}) {
334
327
  const candidates = fontFaces(fontCss), known = new Set(candidates.map(face => face.family));
335
- // Missing named choices participate in the same cascade, but inert and generic runs need no data.
336
328
  for (const family of ['Geist', 'Geist Mono', ...documentFontUsage(source, parser).keys()]) {
337
329
  const name = family.toLowerCase();
338
330
  if (!known.has(name)) {
@@ -363,8 +355,6 @@ export async function requireDocumentFonts(source, {parser, index, root, setting
363
355
  throw Object.assign(new Error('Embedded font validation is unavailable: ' + asset.family), {code: 'export_font_unavailable'});
364
356
  if (await validateFont(bytes, {family: asset.family, format: asset.format}) !== true)
365
357
  throw Object.assign(new Error('The embedded font is invalid: ' + asset.family), {code: 'export_font_invalid'});
366
- // The host validates the font; the viewing engine must also accept this exact container.
367
- // Loading an uninstalled face checks bytes without changing this document's font set.
368
358
  if (typeof Font === 'function') try { await new Font('rapier-export-validation', bytes).load(); }
369
359
  catch (cause) { throw Object.assign(new Error('The embedded font could not be loaded: ' + asset.family, {cause}), {code: 'export_font_invalid'}); }
370
360
  checked.add(asset.url);
@@ -378,7 +368,6 @@ export function fontRequests(root, options = {}) {
378
368
  stretch: record.properties['font-stretch'] || '100%'}));
379
369
  }
380
370
 
381
- // This self-contained readiness step also runs in the isolated server print document.
382
371
  export async function waitForFontRequests(doc, requests) {
383
372
  if (!requests?.length) return;
384
373
  const family = value => String(value).trim().replace(/^(['"])([\s\S]*)\1$/, '$2').replace(/\\(?:([\da-f]{1,6})\s?|([^\r\n\f]))/gi,
@@ -395,9 +384,6 @@ export async function waitForFontRequests(doc, requests) {
395
384
  }
396
385
  }
397
386
 
398
- // CSS font loading can finish successfully while a required face is in the error state.
399
- // Load the exact embedded bytes before print takes its immutable page, and leave unrelated
400
- // local fallbacks and unused family styles outside this document's readiness decision.
401
387
  export async function waitForFonts(root, {settings, css = '', fontCss = '', install = true} = {}) {
402
388
  const required = fontRuns(root, {settings, css, fontCss});
403
389
  if (!required.length) return () => {};
@@ -1,10 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Immutable authored origins shared by causal transport and inverse projection.
3
2
  import {_rapierTransformSplices, _rapierValidAuthored} from './journal-records.mjs';
4
3
  import {sha256} from './hash.mjs';
5
4
 
6
- // The frontier names only causal maxima. Linear history needs one predecessor,
7
- // and concurrent predecessors remain separate until an act observes both.
8
5
  function authoredFrontier(records, visit = null) {
9
6
  const dependencies = new Map(), frontier = new Set();
10
7
  for (const record of records) {
@@ -38,8 +35,6 @@ export function authoredPlacements(base, records) {
38
35
  return origins;
39
36
  }
40
37
 
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
38
  export function authoredPlacement(source, splices, basis) {
44
39
  const origin = {basis: basis.slice().sort(), source: sha256(source), splices: splices.map(row => ({...row}))};
45
40
  if (!_rapierValidAuthored(origin) || _rapierTransformSplices(source, splices) === null)
@@ -17,7 +17,6 @@ export function authorship(input) {
17
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
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
20
  const tx = record.transaction, gone = [], kept = (references?.get(tx.id) || []).flatMap(id => removed.get(id) || []);
22
21
  for (const row of record.splices) {
23
22
  const cut = row.pos + row.removed.length, delta = row.inserted.length - row.removed.length, before = [], after = [], inside = [];
@@ -49,7 +48,6 @@ export function readAuthorship(input, text) {
49
48
  if (cursor !== text.length) throw fault('authorship does not cover the text');
50
49
  return value;
51
50
  }
52
- // Authorship-only imports become an incomplete insertion ledger. There is no mutable blame database.
53
51
  export function ledgerFromAuthorship(input, text, documentAuthority, metadata) {
54
52
  const value = readAuthorship(input, text), records = [];
55
53
  let current = '', root = textRoot('');
@@ -10,7 +10,6 @@ export function decodeCarried(text) {
10
10
  return String(text).replace(/\\([\\/!r0])/g, (_, c) => c === '\\' ? '\\' : c === 'r' ? '\r' : c === '0' ? '\0' : c === '/' ? '</' : '<!');
11
11
  }
12
12
  export function validateParts(text, {ledger = null, authorship = null, metadata} = {}) {
13
- // A file's leading BOM is metadata, as in the editor's source intake.
14
13
  const canonical = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
15
14
  const history = ledger === null ? null : readLedger(ledger, canonical, metadata).ledger;
16
15
  const runs = authorship === null ? null : readAuthorship(authorship, canonical);
@@ -22,7 +21,6 @@ export function writeParts(text, options) {
22
21
  return Object.entries(parts).filter(([, value]) => value !== null).map(([name, value]) =>
23
22
  '<script type="application/json" id="rapier-' + name + '">' + encodeCarried(JSON.stringify(value)) + '</script>\n').join('');
24
23
  }
25
- // Pure adapter: the browser supplies inert script elements, Node supplies parsed script records.
26
24
  export function readParts(text, elements) {
27
25
  const parts = {ledger: null, authorship: null};
28
26
  for (const element of elements) {
@@ -35,7 +33,6 @@ export function readParts(text, elements) {
35
33
  }
36
34
  return validateParts(text, parts);
37
35
  }
38
- // The wrapper only removes whole script elements. Strings inside other scripts are not carriers.
39
36
  export function takeParts(html, text) {
40
37
  const elements = [];
41
38
  const stripped = html.replace(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi, (whole, attrs, content) => {
@@ -50,7 +47,6 @@ export function takeParts(html, text) {
50
47
  return {html: stripped, ...readParts(text ?? '', elements)};
51
48
  }
52
49
  const OPEN = '\n\n<!-- rapier-carried/1\n', CLOSE = '\n-->\n';
53
- // One optional HTML comment at EOF; every other Markdown reader ignores it. Source is never rewritten.
54
50
  export function writeDocument(text, options) {
55
51
  const parts = validateParts(text, options);
56
52
  if (!parts.ledger && !parts.authorship) return text;
@@ -58,9 +54,12 @@ export function writeDocument(text, options) {
58
54
  return text + OPEN + json + CLOSE;
59
55
  }
60
56
  export function readDocument(source) {
57
+ if (!source.endsWith(CLOSE)) {
58
+ if (source.includes(OPEN)) throw fault('unfinished document metadata');
59
+ return {text: source, ledger: null, authorship: null};
60
+ }
61
61
  const at = source.lastIndexOf(OPEN);
62
62
  if (at < 0) return {text: source, ledger: null, authorship: null};
63
- if (!source.endsWith(CLOSE)) throw fault('unfinished document metadata');
64
63
  let parts;
65
64
  try { parts = JSON.parse(source.slice(at + OPEN.length, -CLOSE.length)); }
66
65
  catch (_) { throw fault('invalid document metadata'); }
@@ -69,10 +68,8 @@ export function readDocument(source) {
69
68
  return {text, ...validateParts(text, parts)};
70
69
  }
71
70
 
72
- // One portable baseline. A digest is integrity evidence, not authentication of the named author.
73
71
  export function readBase(value) {
74
72
  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
73
  const {text, sha256: digest, revision, name, by, at, fonts} = value;
77
74
  if (typeof text !== 'string' || typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest) || sha256(text) !== digest)
78
75
  throw fault('comparison base SHA-256 differs from its text');
@@ -1,7 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Only plain JSON data enters the public format. Accessors and toJSON are never executed.
3
2
  export function fault(message) { return Object.assign(new Error('Ledger: ' + message), {code: 'ledger_invalid'}); }
4
- // A plain object's prototype is null or Object's own. Both lookups read descriptors, so no getter runs.
5
3
  export const plainPrototype = proto => proto === null || Object.getPrototypeOf(proto) === null &&
6
4
  Object.getOwnPropertyDescriptor(Object.getOwnPropertyDescriptor(proto, 'constructor')?.value || {}, 'name')?.value === 'Object';
7
5
  export function fields(value, depth, active, sorted = true) {
@@ -22,8 +20,6 @@ export function scalar(value) {
22
20
  if (typeof value === 'string') { if (/[\uD800-\uDFFF]/u.test(value)) throw fault('unpaired surrogate'); return true; }
23
21
  return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0);
24
22
  }
25
- // A canonical copy does not need a serialized document and a second parsed document.
26
- // Sorted insertion preserves the previous plainData result, including numeric-key ordering.
27
23
  export function plainData(value) {
28
24
  if (typeof value === 'string') value = JSON.parse(value);
29
25
  const active = new Set();
@@ -37,8 +33,6 @@ export function plainData(value) {
37
33
  };
38
34
  return copy(value, 0);
39
35
  }
40
- // Each yielded string is bounded, even when one value contains the whole document.
41
- // JSON.stringify still owns escaping; scalar boundaries keep its spelling exact.
42
36
  function* quoted(text, whole = true) {
43
37
  yield '"';
44
38
  for (let at = 0; at < text.length;) {
@@ -73,8 +67,6 @@ export function canonicalJSON(value, depth = 0, active = new Set()) { return Arr
73
67
  export function exactKeys(value, keys) {
74
68
  return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
75
69
  }
76
- // TextEncoder's exact byte count, without allocating an encoded copy. Whole-character
77
- // admission is performed in this same pass when the document owner requests it.
78
70
  export function utf8Length(text, whole = false) {
79
71
  let bytes = 0;
80
72
  for (let at = 0; at < text.length; at++) {
@@ -8,8 +8,6 @@ import {canonicalJSON, canonicalParts, plainData, documentText, utf8Length, exac
8
8
  export {textRoot, rootAfter};
9
9
  const digest = value => sha256Parts(canonicalParts(value));
10
10
  const rootShape = value => typeof value === 'string' && /^\d+:[a-z0-9]+:[a-z0-9]+$/.test(value);
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
11
  function* checkedRecords(ledger, prepared) {
14
12
  if (!prepared.ok || ledger.records.length !== prepared.records.length) throw fault('record chain differs');
15
13
  let root = ledger.start.root, revision = ledger.start.revision;
@@ -27,16 +25,9 @@ function* checkedRecords(ledger, prepared) {
27
25
  }
28
26
  if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
29
27
  }
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
28
  const proofs = new WeakMap();
34
29
  function readLedgerState(input, expectedText, expectedMetadata) {
35
30
  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.
40
31
  if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
41
32
  typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
42
33
  typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
@@ -81,7 +72,6 @@ function proveLedger(ledger) {
81
72
  if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text)) throw fault('history does not describe this document');
82
73
  return {text, metadata, root, revision, prepared, source};
83
74
  }
84
- // A stored copy may hold the head text alone. Its start is that text with every record reversed; readLedger proves it.
85
75
  export function ledgerWithStart(input, text) {
86
76
  let source = new HistorySource(documentText(text));
87
77
  for (let i = input.records.length - 1; i >= 0; i--) {
@@ -129,11 +119,8 @@ function* sourceFacts(text) {
129
119
  }
130
120
  function* produceLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}) {
131
121
  if (!_rapierValidMetadata(metadata)) throw fault('document metadata required');
132
- // Metadata has two admitted scalar fields. Keep its insertion order in the
133
- // emitted envelope while capturing it before the first cooperative boundary.
134
122
  metadata = {...metadata};
135
123
  if (!Array.isArray(records) || !Array.isArray(earlier)) throw fault('records required');
136
- // Inspect before touching properties: exporting must not call accessors on an alleged record.
137
124
  const prepared = _rapierPrepareHistoryRecords(records, earlier);
138
125
  if (!prepared.ok) throw fault('navigation target was not retained');
139
126
  const headFacts = yield* sourceFacts(text);
@@ -153,8 +140,6 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
153
140
  startSource = startSource.apply(expanded[i].splices, true);
154
141
  startMetadata = _rapierTransformMetadata(startMetadata, expanded[i].metadata, true);
155
142
  if (startSource === null || !startMetadata) throw fault('history does not reverse from this document');
156
- // Whole scalars and exact splice boundaries were just checked. Count the changed
157
- // bytes, rather than re-encoding every reconstructed whole-document revision.
158
143
  for (const row of expanded[i].splices) bytes += utf8Length(row.removed) - utf8Length(row.inserted);
159
144
  if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
160
145
  yield;
@@ -174,8 +159,6 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
174
159
  const payload = {format: 'rapier-ledger/2', documentAuthority,
175
160
  start: {text: startText, revision: startRevision, root: startRoot, sha256: startSHA, metadata: startMetadata},
176
161
  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)};
177
- // Expanded rows are the exact canonical projection of this owned preparation.
178
- // Keep its navigation/effect proof instead of admitting another copied graph.
179
162
  for (const _record of checkedRecords(payload, prepared)) yield;
180
163
  if (expanded.some(row => Object.hasOwn(row, 'authored'))) {
181
164
  try { authoredHistory(startText, expanded); } catch (_) { throw fault('authored origin differs'); }
@@ -191,7 +174,6 @@ function finish(producer) {
191
174
  return step.value;
192
175
  }
193
176
  export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
194
- // Its returned facts were computed while admitting/hashing the exact head, never supplied.
195
177
  export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
196
178
  const producer = produceLedger(input);
197
179
  let step;
@@ -199,7 +181,6 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
199
181
  signal?.throwIfAborted();
200
182
  step = producer.next();
201
183
  if (!step.done && yieldWork) {
202
- // The caller owns scheduling and its clock; ledger admission reads neither.
203
184
  const pending = yieldWork();
204
185
  if (pending) await pending;
205
186
  }
@@ -207,7 +188,6 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
207
188
  signal?.throwIfAborted();
208
189
  return step.value;
209
190
  }
210
- // Derived navigation, never a second history. A target older than start is intentionally unavailable.
211
191
  export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
212
192
  const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input, expectedText, expectedMetadata);
213
193
  const {byId, effects: {pictures}} = prepared;
@@ -235,8 +215,6 @@ export function historyEnvelope(input, segmentIdentity = [], expectedText, expec
235
215
  }
236
216
  if (ledger.complete) {
237
217
  if (ledger.records.length && !branch.length) throw fault('complete history lost its branch');
238
- // Literal admitted splices are reversible. Proving the actual head back to
239
- // the exact start also proves its forward active branch, without replaying it.
240
218
  if (traversed.length !== ledger.start.text.length || !traversed.matches(0, ledger.start.text) ||
241
219
  canonicalJSON(traversedMetadata) !== canonicalJSON(ledger.start.metadata)) throw fault('complete branch differs');
242
220
  }
@@ -246,8 +224,6 @@ export function historyEnvelope(input, segmentIdentity = [], expectedText, expec
246
224
  traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata);
247
225
  if (traversed === null || !traversedMetadata) throw fault('redo branch does not replay');
248
226
  }
249
- // Picture folding may relocate finished definitions while navigating. Keep its
250
- // distinct forward proof; the literal-only case above needs no second traversal.
251
227
  if (ledger.complete && pictures.size) {
252
228
  let active = new HistorySource(ledger.start.text), activeMetadata = {...ledger.start.metadata};
253
229
  for (let i = 0; i < branch.length; i++) {
@@ -270,7 +246,6 @@ export function agentActorId(door, agent) {
270
246
  return door + '/' + agent.name;
271
247
  }
272
248
 
273
- // A writer changing representation records that change, rather than attaching an unrelated head.
274
249
  export function replaceLedgerText(input, text, {actor = {kind: 'system', id: 'rapier'}, operation = 'document.replace', at} = {}) {
275
250
  const current = readLedger(input); documentText(text);
276
251
  if (current.text === text) return current.ledger;
@@ -1,9 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // The editor's path-dependent root, plus synchronous SHA-256 for file readers (including offline wrappers).
3
2
  export function textRoot(text) {
4
3
  return textRootParts([text]);
5
4
  }
6
- // The same UTF-16 root over shared source pieces, without joining a historical body.
7
5
  export function textRootParts(parts) {
8
6
  let fnv = 0x811c9dc5, a = 1, b = 0, length = 0;
9
7
  for (const text of parts) {
@@ -30,7 +28,6 @@ const K = new Uint32Array([
30
28
  0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
31
29
  ]);
32
30
  const rotate = (x, n) => (x >>> n) | (x << (32 - n));
33
- // One bounded SHA-256 compression state for file bytes and encoded source text.
34
31
  export function sha256State() {
35
32
  const h = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
36
33
  const w = new Uint32Array(64), block = new Uint8Array(64);
@@ -82,14 +79,11 @@ export function sha256State() {
82
79
  get bytes() { return bytes; },
83
80
  };
84
81
  }
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
82
  export function createSHA256() {
88
83
  const hash = sha256State(), encoded = new Uint8Array(65536), encoder = new TextEncoder();
89
84
  let high = '', finished = false;
90
85
  const write = text => {
91
86
  for (let at = 0; at < text.length;) {
92
- // Bound both the substring and byte allocation; encodeInto never cuts a scalar.
93
87
  let end = Math.min(text.length, at + 32768);
94
88
  if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
95
89
  const part = encoder.encodeInto(text.slice(at, end), encoded);
@@ -117,4 +111,14 @@ export function sha256Parts(parts) {
117
111
  for (const part of parts) hash.update(part);
118
112
  return hash.digest();
119
113
  }
120
- export function sha256(text) { return createSHA256().update(text).digest(); }
114
+ const LONG_TEXT = 65536, DIGESTS = new Map();
115
+ export function sha256(text) {
116
+ if (typeof text !== 'string' || text.length < LONG_TEXT) return createSHA256().update(text).digest();
117
+ let digest = DIGESTS.get(text);
118
+ if (digest === undefined) {
119
+ digest = createSHA256().update(text).digest();
120
+ if (DIGESTS.size >= 4) DIGESTS.delete(DIGESTS.keys().next().value);
121
+ } else DIGESTS.delete(text);
122
+ DIGESTS.set(text, digest);
123
+ return digest;
124
+ }