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.
- package/LICENSE +6 -5
- package/README.md +28 -16
- package/dist/images/gif.mjs +3 -1
- package/dist/images/playback.mjs +6 -4
- package/dist/kit/conformance/run.mjs +1 -1
- package/dist/kit/ledger/authored-origin.mjs +49 -0
- package/dist/kit/ledger/authorship.mjs +18 -9
- package/dist/kit/ledger/data.mjs +7 -4
- package/dist/kit/ledger/format.mjs +25 -8
- package/dist/kit/ledger/history.mjs +31 -5
- package/dist/kit/ledger/inverse-source.mjs +111 -49
- package/dist/kit/ledger/journal-records.mjs +24 -29
- package/dist/kit/ledger/merge.mjs +48 -13
- package/dist/kit/ledger/text-merge.mjs +75 -29
- package/dist/kit/ledger/transport.mjs +30 -45
- package/dist/kit/pdf-vectors.mjs +2 -1
- package/dist/kit/print-capitals.mjs +0 -1
- package/dist/kit/rapier-ledger.mjs +2 -0
- package/dist/kit/render-markdown.mjs +12 -3
- package/dist/kit/render-print.mjs +43 -7
- package/dist/kit/render-sanitize.mjs +19 -12
- package/dist/kit/render-styles.mjs +3 -3
- package/dist/kit/render-work.mjs +1 -1
- package/dist/kit/render.mjs +6 -41
- 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 -744
- 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/html-blocks.mjs +3 -3
- package/dist/spec/md-layout.mjs +1 -1
- package/dist/spec/md-marks.mjs +5 -5
- package/dist/spec/md-source.mjs +15 -7
- package/package.json +1 -1
- 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
|
|
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
|
|
39
|
-
|
|
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
|
|
45
|
-
Third-party components retain their own copyright and
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
354
|
+
'A centered paragraph.' + formatLayout({align: 'center'}),
|
|
355
355
|
'',
|
|
356
|
-
'A ' + formatColorRun(TEXT_COLOR_NAMES.blue, 'blue phrase') + ' keeps its
|
|
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
|
-
##
|
|
502
|
+
## License
|
|
503
503
|
|
|
504
|
-
MIT, the full text in `LICENSE`; Rapier-owned modules and the stylesheet keep their MIT line. Pretext's complete
|
|
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
|
|
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
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
package/dist/images/gif.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/images/playback.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
97
|
-
close() {closed = true; paused = true; closeDecoder(); canvas.width = canvas.height = 0;
|
|
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
|
|
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)
|
|
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
|
}
|
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,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
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|