rapier-markdown-kit 1.2.3 → 1.2.6

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 (41) hide show
  1. package/LICENSE +6 -5
  2. package/README.md +28 -16
  3. package/dist/images/gif.mjs +3 -1
  4. package/dist/images/playback.mjs +6 -4
  5. package/dist/kit/conformance/run.mjs +1 -1
  6. package/dist/kit/ledger/authored-origin.mjs +49 -0
  7. package/dist/kit/ledger/authorship.mjs +18 -9
  8. package/dist/kit/ledger/data.mjs +7 -4
  9. package/dist/kit/ledger/format.mjs +25 -8
  10. package/dist/kit/ledger/history.mjs +31 -5
  11. package/dist/kit/ledger/inverse-source.mjs +111 -49
  12. package/dist/kit/ledger/journal-records.mjs +24 -29
  13. package/dist/kit/ledger/merge.mjs +48 -13
  14. package/dist/kit/ledger/text-merge.mjs +75 -29
  15. package/dist/kit/ledger/transport.mjs +30 -45
  16. package/dist/kit/pdf-vectors.mjs +2 -1
  17. package/dist/kit/print-capitals.mjs +0 -1
  18. package/dist/kit/rapier-ledger.mjs +2 -0
  19. package/dist/kit/render-markdown.mjs +12 -3
  20. package/dist/kit/render-print.mjs +43 -7
  21. package/dist/kit/render-sanitize.mjs +19 -12
  22. package/dist/kit/render-styles.mjs +3 -3
  23. package/dist/kit/render-work.mjs +1 -1
  24. package/dist/kit/render.mjs +6 -41
  25. package/dist/layout/paragraph-fit.mjs +1 -15
  26. package/dist/layout/typography.mjs +2 -1
  27. package/dist/packages/rapier-diff/concerns.mjs +163 -0
  28. package/dist/packages/rapier-diff/diff.mjs +68 -0
  29. package/dist/packages/rapier-diff/index.mjs +4 -744
  30. package/dist/packages/rapier-diff/matching.mjs +157 -0
  31. package/dist/packages/rapier-diff/merge.mjs +226 -0
  32. package/dist/packages/rapier-diff/moves.mjs +130 -0
  33. package/dist/packages/rapier-diff/structure.mjs +50 -0
  34. package/dist/packages/rapier-diff/tokens.mjs +72 -0
  35. package/dist/packages/rapier-diff/walk.mjs +219 -0
  36. package/dist/spec/html-blocks.mjs +3 -3
  37. package/dist/spec/md-layout.mjs +1 -1
  38. package/dist/spec/md-marks.mjs +5 -5
  39. package/dist/spec/md-source.mjs +15 -7
  40. package/package.json +1 -1
  41. package/style.css +11 -11
package/LICENSE CHANGED
@@ -20,7 +20,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
22
 
23
- Files under this licence carry `SPDX-License-Identifier: MIT` in their first lines: the standard's
23
+ Files under this license carry `SPDX-License-Identifier: MIT` in their first lines: the standard's
24
24
  grammar, marks and reference style (`spec/md-layout.mjs`, `spec/md-marks.mjs`, `spec/md-source.mjs`, `spec/flowchart.mjs`, `spec/markdown-style.css`), ink geometry
25
25
  (`spec/ink.mjs`),
26
26
  the layout model and line planner (`layout/model.mjs`, `layout/line-plan.mjs`, `layout/typography.mjs`, `layout/preparation.mjs`,
@@ -35,14 +35,15 @@ own MIT notices. The edit-ledger module (`kit/rapier-ledger.mjs`, `kit/ledger/*.
35
35
  its editor compatibility import (`editor/journal-records.mjs`) are also covered. The
36
36
  record validator/replayer and text-only conflict merger were moved, not copied,
37
37
  into that module; the Notes index merger and the rest of the agent/editor remain
38
- AGPL-3.0-only. The wrapper's carrier-staging helper
39
- (`skills/rapier-html/package-files.mjs`) is MIT. The kit's MIT-marked facades,
38
+ AGPL-3.0-only. The page wrapper (`skills/rapier-html/wrap.mjs`, `skills/rapier-html/page.mjs`,
39
+ `skills/rapier-html/return-address.mjs`) and its carrier-staging helper
40
+ (`skills/rapier-html/package-files.mjs`) are MIT. The kit's MIT-marked facades,
40
41
  `kit/render-entry.mjs`, `kit/render*.mjs`, `kit/export-fonts.mjs`, `kit/print-capitals.mjs`,
41
42
  `kit/pdf-vectors.mjs`, `kit/pdf-glyphs.mjs`, `kit/stage.mjs` and MIT-marked conformance
42
43
  modules are covered too. These names describe the Rapier-owned files carrying the
43
44
  MIT notice, not a grant over the editor or third-party code. Other Rapier-owned
44
- files retain their stated licence; the editor is AGPL-3.0-only (see `LICENSE`).
45
- Third-party components retain their own copyright and licence notices.
45
+ files retain their stated license; the editor is AGPL-3.0-only (see `LICENSE`).
46
+ Third-party components retain their own copyright and license notices.
46
47
 
47
48
  The portable font helpers read document definitions and accept explicit host validation and
48
49
  subsetting functions. The application binding (`editor/export-fonts.mjs`), captured-font
package/README.md CHANGED
@@ -4,7 +4,7 @@ Read, render and write Self-contained Markdown in browsers and Node: one `.md` f
4
4
  drawings, layout and precise inline typography. Build portable documents for people and agents. Will/1 regions mark what an agent
5
5
  may edit, append to or preserve. MIT, no dependencies.
6
6
 
7
- - Typography uses ordinary inline HTML spans with validated size, colour and family. Comments and references
7
+ - Typography uses ordinary inline HTML spans with validated size, color and family. Comments and references
8
8
  carry other optional facts; each reader chooses its HTML, style, extension and resource support.
9
9
  - One stylesheet renders the document as the editor and its exported page do.
10
10
  - The line planner flows text around a picture's real shape; a conformance suite of thirteen documents runs
@@ -93,7 +93,7 @@ DOM objects between document realms.
93
93
 
94
94
  This **DOM-hosted library** requires a DOM and sanitizer, the Rapier grammar,
95
95
  portable image preparation, style access and the named codec/layout ports it uses.
96
- Those dependencies keep their own licences: the MIT grant here does not relicense a
96
+ Those dependencies keep their own licenses: the MIT grant here does not relicense a
97
97
  separately supplied host or vendor. The [editor host binding](https://github.com/jackskip22/rapier/blob/main/src/editor/render-host.js)
98
98
  lists the renderer's dependencies. [rapier-server](https://github.com/jackskip22/rapier/blob/main/server/README.md)
99
99
  provides a complete local host with a pinned Chromium process. Its Word exporter and filesystem service are AGPL,
@@ -118,7 +118,7 @@ Browser hosts additionally check the exact captured container with an uninstalle
118
118
  Without `FontFace`, only the host validator's admission is established; it does not establish
119
119
  browser container support. The HTML font helpers do not bundle the application's font importer,
120
120
  container codecs or subsetter. Rapier binds its existing materializer and shared importer at `editor/render-host.js` through
121
- `editor/export-fonts.mjs`; those implementations retain their application licence.
121
+ `editor/export-fonts.mjs`; those implementations retain their application license.
122
122
  An embedded family named Geist or Geist Mono remains custom data and takes precedence over the
123
123
  factory family. Custom fonts have no optional factory size limit.
124
124
 
@@ -140,7 +140,7 @@ family name share the host's font namespace. Print retains its existing factory
140
140
  | Import | What it is |
141
141
  | --- | --- |
142
142
  | `rapier-markdown-kit/layout` | The `md-layout:v1` comment a picture carries: parse it, validate it, write it back. |
143
- | `rapier-markdown-kit/marks` | Unified typography values, span writer and parser installer; preserved colour/font comments, ink and page breaks. |
143
+ | `rapier-markdown-kit/marks` | Unified typography values, span writer and parser installer; preserved color/font comments, ink and page breaks. |
144
144
  | `rapier-markdown-kit/model` | Occupancy profiles and the line planner: the wrapping itself. |
145
145
  | `rapier-markdown-kit/typography` | Source-preserving paragraph refinement through `createTypography()`. |
146
146
  | `rapier-markdown-kit/will` | The Will/1 grammar (a separate, optional standard): the markers around an agent's intent regions. |
@@ -244,11 +244,11 @@ font-data authority, including a name that collides with a factory face.
244
244
  inline span; split block selections and keep their `#`, list markers and separators outside. `typography.parseStyle`
245
245
  returns only admitted facts. `typography.install(md)` installs that interpretation on the caller's markdown-it
246
246
  parser, and `typographySourceRanges(source, md)` reports original UTF-16 wrapper ranges through that parser.
247
- These calls neither mutate the source nor load resources. The installer also reads already-authored colour and
247
+ These calls neither mutate the source nor load resources. The installer also reads already-authored color and
248
248
  font comments into the same facts; an ordinary open/save must preserve their bytes, not rewrite them as spans.
249
249
 
250
250
  Sizes accept positive decimal `pt`/`px`, at most 12 fractional digits, up to 1638pt/2184px; 96px = 72pt. New values
251
- use lowercase units and omit insignificant zeros. Colours become lowercase six-digit RGB hex; opaque integer
251
+ use lowercase units and omit insignificant zeros. Colors become lowercase six-digit RGB hex; opaque integer
252
252
  RGB CSSOM values are also read. Families are one validated name, an unquoted CSS generic, or an existing preset
253
253
  stack. `sans`, `serif`, `mono`, `system`, `hand` are reserved choice aliases, not quoted custom identities. Styles
254
254
  are bounded to 4096 characters, 32 declarations and 64 paired levels. Invalid declarations and unknown properties
@@ -271,7 +271,7 @@ unit, independent of editor zoom. No mapping claims identical glyphs or line bre
271
271
  See the original [typography guidance](https://github.com/jackskip22/rapier/blob/main/docs/markdown-standard.md#inline-typography),
272
272
  [CommonMark raw HTML](https://spec.commonmark.org/0.31.2/#raw-html), [CSS absolute units](https://www.w3.org/TR/css-values-4/#absolute-lengths),
273
273
  [Pandoc spans](https://pandoc.org/MANUAL.html#extension-bracketed_spans) and [Quarto spans](https://quarto.org/docs/authoring/markdown-basics.html#divs-and-spans).
274
- Those specifications retain their own licences.
274
+ Those specifications retain their own licenses.
275
275
 
276
276
  ## Render with the reference style
277
277
 
@@ -351,9 +351,9 @@ export const exampleSource = [
351
351
  'plain code',
352
352
  '~~~',
353
353
  '',
354
- 'A centred paragraph.' + formatLayout({align: 'center'}),
354
+ 'A centered paragraph.' + formatLayout({align: 'center'}),
355
355
  '',
356
- 'A ' + formatColorRun(TEXT_COLOR_NAMES.blue, 'blue phrase') + ' keeps its colour.',
356
+ 'A ' + formatColorRun(TEXT_COLOR_NAMES.blue, 'blue phrase') + ' keeps its color.',
357
357
  '',
358
358
  'And ' + typography.wrap({size: '13.5pt', color: '#175dc4', family: 'serif'}, '**precise words**') + ' keep their emphasis.',
359
359
  '',
@@ -499,11 +499,11 @@ Its historical line counts are not a current-engine or font-shaping acceptance g
499
499
  `--impl path/to/module.mjs` runs another implementation against the same documents; `--tolerance 2` sets the pixel
500
500
  tolerance.
501
501
 
502
- ## Licence
502
+ ## License
503
503
 
504
- MIT, the full text in `LICENSE`; Rapier-owned modules and the stylesheet keep their MIT line. Pretext's complete licence,
504
+ MIT, the full text in `LICENSE`; Rapier-owned modules and the stylesheet keep their MIT line. Pretext's complete license,
505
505
  notice and pinned source inventory are in `dist/agent/vendor/pretext/`. The selected upstream is unreleased main
506
- `bac1774c0f8b7a92e44b9da43b6d5443d55d0d9b`, despite its package version `0.0.9`. Its upstream licence includes
506
+ `bac1774c0f8b7a92e44b9da43b6d5443d55d0d9b`, despite its package version `0.0.9`. Its upstream license includes
507
507
  Chromium/WebKit BSD, Unicode and ICU notices for derived code and generated data; those notices travel with the kit
508
508
  and the exported wrapping runtime. The history projection uses the MIT diff core;
509
509
  its full grant is in `dist/packages/rapier-diff/LICENSE`. The Rapier editor is AGPL-3.0-only and
@@ -623,7 +623,9 @@ the authorship projection recomputed from the ledger must agree exactly.
623
623
  text. Runs are `[start, end, actorKind, actorId, at]`, in order, nonoverlapping and
624
624
  covering every current character. A start without earlier authorship is attributed
625
625
  to `system/unattributed`, time zero. Insertions take the inserting record's actor
626
- and time; deleted runs disappear. Thus the projection contains **no removed text**.
626
+ and time, except text an Undo or Redo puts back: a splice inserting exactly the text
627
+ that a splice of the referenced act removed keeps the runs that text had. Deleted
628
+ runs disappear. Thus the projection contains **no removed text**.
627
629
  An authorship-only import becomes a clearly incomplete insertion ledger; future
628
630
  attribution is again derived from that one ledger, never a mutable blame database.
629
631
 
@@ -669,9 +671,19 @@ editor's restore reader can replay the result. Both merge orders yield the same
669
671
  text for independently edited nonoverlapping ranges; their ordered ledgers need
670
672
  not be identical. Full and conflict receipts make repeated imports idempotent.
671
673
 
672
- Overlaps fall back to the existing text three-way merge. Unresolved alternatives
673
- are carried in the existing `note-conflict:v1` envelope, never silently selected.
674
- Different Will `keep` regions require conflict review even when one copy was
674
+ An identical replacement converges with both original acts retained and one physical
675
+ splice. The format identifier stays `rapier-ledger/2`. That unchanged identifier is not
676
+ backward compatibility with an older reader. There is no `rapier-ledger/3`, no
677
+ compatibility reader, and no migration. An older reader rejects the empty physical
678
+ witness with `ledger_invalid` and does not mutate the ledger. Mixed-version sync throws
679
+ `notes_history_unavailable` (`the note and its canonical history cannot be proved`).
680
+ The older participant's store snapshot and remote object count stay unchanged. That
681
+ throw is not a per-note `held` or `caughtUp` result. The same format string is not
682
+ interoperability, and the ledger is not rewritten into the older shape. Reactivating
683
+ any author of that replacement checks the shared text again; undoing that author
684
+ withdraws it once no author remains. A different overlap returns `text_overlap_unpreserved` and the caller's exact
685
+ ledger, including the text merge's alternatives for review, and does not mint a synthetic
686
+ act. Different Will `keep` regions require conflict review even when one copy was
675
687
  unchanged. `review.required` names an unresolved result; a UI must not auto-accept
676
688
  it. Rapier offers carried-copy imports from Compare through its existing review,
677
689
  checks the live document again at commit, and installs the proven ledger before
@@ -39,7 +39,9 @@ export function inspectGIF(bytes) {
39
39
  at += 9; if (packed & 128) at += 3 * (2 << (packed & 7));
40
40
  need(1); if (bytes[at] < 2 || bytes[at] > 8) fail('Invalid GIF code size');
41
41
  at++; blocks(); frames++;
42
- duration += control ? Math.max(20, (control[4] + control[5] * 256) * 10) : 100;
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
+ const delay = control ? control[4] + control[5] * 256 : 0;
44
+ duration += delay > 1 ? delay * 10 : 100;
43
45
  if (frames > 240 || width * height * frames > 96000000 || duration > 120000) fail('GIF animation exceeds the frame, work or duration budget');
44
46
  first ||= {control, image: bytes.subarray(start, at)}; control = null;
45
47
  }
@@ -31,8 +31,9 @@ export function createMediaPlayback(win, inspectGIF) {
31
31
  })();
32
32
  admitted.catch(error => {if (!closed) {state.status = 'admission-failed'; state.error = String(error.message).slice(0, 160); changed(state);}});
33
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.
34
35
  let inflight = null;
35
- const closeDecoder = () => {ticket++; win.clearTimeout(timer); timer = 0; const old = decoder; decoder = null; if (old) inflight ? inflight.then(() => old.close()) : old.close();};
36
+ 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();};
36
37
  const settle = async promise => {
37
38
  let timeout;
38
39
  try {return await Promise.race([promise, new Promise((_, reject) => {timeout = win.setTimeout(() => reject(new Error('Animation decode timeout')), 5000);})]);}
@@ -72,7 +73,8 @@ export function createMediaPlayback(win, inspectGIF) {
72
73
  if (canvas.height !== h) canvas.height = h;
73
74
  const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, w, h); ctx.drawImage(frame, 0, 0);
74
75
  if (!state.first && next === 0) state.first = canvas.toDataURL('image/png');
75
- const delay = Math.max(20, (frame.duration || 100000) / 1000);
76
+ // A frame of 10 ms or less shows for 100 ms, as the browser draws an <img> (images/gif.mjs counts the same).
77
+ const delay = frame.duration > 10000 ? Math.max(20, frame.duration / 1000) : 100;
76
78
  duration += delay; if (duration > 120000) throw new Error('Animation exceeds the duration budget');
77
79
  state.status = 'ready'; state.frame = next; changed(state);
78
80
  if (single) {paused = true; closeDecoder(); return;}
@@ -93,8 +95,8 @@ export function createMediaPlayback(win, inspectGIF) {
93
95
  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;},
94
96
  // Pause keeps the decoder and its frame position; only suspension and removal release it.
95
97
  pause() {paused = true; ticket++; win.clearTimeout(timer); timer = 0;},
96
- suspend() {if (single) {held = true; return;} paused = true; closeDecoder(); canvas.width = canvas.height = 0; active.delete(canvas); state.status = 'suspended'; changed(state);},
97
- close() {closed = true; paused = true; closeDecoder(); canvas.width = canvas.height = 0; active.delete(canvas);},
98
+ suspend() {if (single) {held = true; return;} paused = true; closeDecoder(); canvas.width = canvas.height = 0; state.status = 'suspended'; changed(state);},
99
+ close() {closed = true; paused = true; closeDecoder(); canvas.width = canvas.height = 0;},
98
100
  get paused() {return paused;}};
99
101
  }
100
102
  return {raster, inspectGIF, encode, source: '(' + createMediaPlayback.toString() + ')(window, ' + inspectGIF.toString() + ')'};
@@ -115,7 +115,7 @@ console.log(`A row's own hits/misses can undercount its real coverage: several f
115
115
  console.log(`segment cache (agent/vendor/pretext/measurement.js) serves a repeated (font, segment) query straight from that cache without calling`);
116
116
  console.log(`measureText again at all -- real data, just not counted against the SECOND document to ask for it. Two named, separate reasons a row still`);
117
117
  console.log(`reads CLOSE rather than AGREE, none of them "close enough": (a) no real trace exists at all for 01-inline/02-width-x-align/05-behind/06-front`);
118
- console.log(`-- their picture pushes no flow obstacle, so layout/interchange.js's own prepare() never calls Pretext for this paragraph during export (see`);
118
+ console.log(`-- their picture pushes no flow obstacle, so kit/render.mjs's exported projector's prepare() never calls Pretext for this paragraph during export (see`);
119
119
  console.log(`measurer.mjs) -- the from-scratch character-sum approximation disagrees with real shaping by a bounded, named amount; (b) 03/11/13 DO run`);
120
120
  console.log(`Pretext for real (hits > 0, 13-rtl-text at 100% -- every query answered from the real trace) yet still carry a small, constant left-edge delta`);
121
121
  console.log(`(9.64px today, a constant from before this measurer existed) -- a picture-obstacle geometry fact, not a text one: these`);
@@ -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) for (const row of record.splices) {
20
- const cut = row.pos + row.removed.length, delta = row.inserted.length - row.removed.length, before = [], after = [];
21
- for (const run of runs) {
22
- if (run[0] < row.pos) before.push([run[0], Math.min(run[1], row.pos), ...run.slice(2)]);
23
- if (run[1] > cut) after.push([Math.max(run[0], cut) + delta, run[1] + delta, ...run.slice(2)]);
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
- const tx = record.transaction;
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
  }
@@ -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
- function fields(value, depth, active, sorted = true) {
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), proto = Object.getPrototypeOf(value);
7
- if (!array && proto !== null && (Object.getPrototypeOf(proto) !== null || Object.getOwnPropertyDescriptor(proto, 'constructor')?.value?.name !== 'Object')) throw fault('not a plain object');
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,9 @@ 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
- (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata))))
60
- throw fault('metadata does not describe this document');
61
- if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text) ||
62
- (expectedText !== undefined && text !== expectedText)) throw fault('history does not describe this document');
63
- return {value: {ledger, text, metadata, root, revision, complete: ledger.complete}, prepared, source};
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};
64
83
  }
65
84
  // A stored copy may hold the head text alone. Its start is that text with every record reversed; readLedger proves it.
66
85
  export function ledgerWithStart(input, text) {
@@ -172,9 +191,7 @@ function finish(producer) {
172
191
  return step.value;
173
192
  }
174
193
  export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
175
- // Both synchronous public export and cooperative recovery use the same producer.
176
194
  // Its returned facts were computed while admitting/hashing the exact head, never supplied.
177
- export function prepareLedger(input) { return finish(produceLedger(input)); }
178
195
  export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
179
196
  const producer = produceLedger(input);
180
197
  let step;
@@ -3,8 +3,8 @@
3
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);
@@ -104,8 +104,11 @@ function prepareHistory(input) {
104
104
  !(tx.createdAt === null || historyInteger(tx.createdAt)) ||
105
105
  (tx.turnId != null && (typeof tx.turnId !== 'string' || !tx.turnId || tx.turnId.length > 160))) return historyFailure('history_invalid');
106
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);
107
109
  if (!splices || effect === undefined || (!splices.length &&
108
- !Object.values(effect || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length)) return historyFailure('history_unavailable');
110
+ !Object.values(effect || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length &&
111
+ !coincidentWitness)) return historyFailure('history_unavailable');
109
112
  const row = {record, splices, metadata: effect};
110
113
  rows.push(row); byId.set(tx.id, row); prior = tx.revision;
111
114
  }
@@ -293,10 +296,13 @@ export function historyPlaces(input, blocks) {
293
296
  if (!slices.has(key)) slices.set(key, source.slice(from, to));
294
297
  return slices.get(key);
295
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;
296
301
  for (const [node, afterRange] of touched) {
297
302
  const beforeRange = ranges.range(node), previous = {...beforeRange, source: cut(beforeSource, 'b', beforeRange.from, beforeRange.to)},
298
303
  after = {...afterRange, source: cut(afterSource, 'a', afterRange.from, afterRange.to)};
299
- if (previous.source !== after.source) node.block.acts.push({...historyAct(record), before: previous, after});
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});
300
306
  }
301
307
  }
302
308
 
@@ -334,7 +340,27 @@ export function selectiveUndo(input, actIds, options = {}) {
334
340
  const metadata = _rapierMetadataDelta(prepared.metadata, afterMetadata), effectChanged = selected.size > 0;
335
341
  const sourceEffectChanged = records.some(record => record.splices.length && !prepared.effects.references.get(record.transaction.id)?.length &&
336
342
  prepared.effects.active.has(record.transaction.id) !== projectedEffects.active.has(record.transaction.id));
337
- if (!sourceEffectChanged) return {ok: true, splices: [], after: prepared.source, actIds: [...requested],
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],
338
364
  sourceTransactionIds: [...selected], metadata, afterMetadata, effectChanged, unchanged: !metadata && !effectChanged,
339
365
  superseded: [], overlaps: []};
340
366
  try {