rapier-html 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/BUILD.json +18 -18
- package/README.md +68 -88
- package/dist/ledger/LICENSE +6 -5
- package/dist/ledger/authored-origin.mjs +49 -0
- package/dist/ledger/authorship.mjs +18 -9
- package/dist/ledger/data.mjs +7 -4
- package/dist/ledger/diff/concerns.mjs +163 -0
- package/dist/ledger/diff/diff.mjs +68 -0
- package/dist/ledger/diff/index.mjs +5 -0
- package/dist/ledger/diff/matching.mjs +157 -0
- package/dist/ledger/diff/merge.mjs +226 -0
- package/dist/ledger/diff/moves.mjs +130 -0
- package/dist/ledger/diff/structure.mjs +50 -0
- package/dist/ledger/diff/tokens.mjs +72 -0
- package/dist/ledger/diff/walk.mjs +219 -0
- package/dist/ledger/format.mjs +25 -8
- package/dist/ledger/history.mjs +31 -5
- package/dist/ledger/inverse-source.mjs +112 -50
- package/dist/ledger/journal-records.mjs +24 -29
- package/dist/ledger/transport.mjs +30 -45
- package/dist/page.mjs +10 -7
- package/dist/rapier.html +149 -148
- package/dist/return-address.mjs +2 -2
- package/dist/wrap.mjs +1 -1
- package/package.json +1 -1
- package/dist/ledger/diff.mjs +0 -745
package/BUILD.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package": "rapier-html",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.6",
|
|
4
4
|
"path": "dist/rapier.html",
|
|
5
|
-
"bytes":
|
|
6
|
-
"sha256": "
|
|
7
|
-
"builtAt": "2026-10-
|
|
5
|
+
"bytes": 4952859,
|
|
6
|
+
"sha256": "48b694df7db506a77f5a98e01098f37d54e7f9a88d6e5647ca09ea43578c3927",
|
|
7
|
+
"builtAt": "2026-10-16T12:09:12.607Z",
|
|
8
8
|
"node": "v22.23.3",
|
|
9
9
|
"canonical": true,
|
|
10
10
|
"packing": "zopfli",
|
|
@@ -14,15 +14,15 @@
|
|
|
14
14
|
"delivery": "built-in",
|
|
15
15
|
"kind": "script",
|
|
16
16
|
"element": "rapier-plugin-math",
|
|
17
|
-
"bytes":
|
|
18
|
-
"packedBytes":
|
|
17
|
+
"bytes": 691650,
|
|
18
|
+
"packedBytes": 438088,
|
|
19
19
|
"files": [
|
|
20
20
|
{
|
|
21
21
|
"id": "rapier-math",
|
|
22
|
-
"name": "rapier-math-2.
|
|
23
|
-
"version": "2.
|
|
24
|
-
"bytes":
|
|
25
|
-
"sri": "
|
|
22
|
+
"name": "rapier-math-2.4.0.js",
|
|
23
|
+
"version": "2.4.0",
|
|
24
|
+
"bytes": 691650,
|
|
25
|
+
"sri": "C92x26+pBI4q9FoWypMhBb8CqSGaaPLw8dpYigWiYNsYQRTcPVrFyPVjrhc/Tnwh"
|
|
26
26
|
}
|
|
27
27
|
]
|
|
28
28
|
},
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"delivery": "built-in",
|
|
32
32
|
"kind": "files",
|
|
33
33
|
"element": "rapier-plugin-mermaid",
|
|
34
|
-
"bytes":
|
|
35
|
-
"packedBytes":
|
|
34
|
+
"bytes": 7389640,
|
|
35
|
+
"packedBytes": 1526485,
|
|
36
36
|
"files": [
|
|
37
37
|
{
|
|
38
38
|
"id": "rapier-mermaid-core",
|
|
@@ -43,10 +43,10 @@
|
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
"id": "rapier-mermaid-zenuml",
|
|
46
|
-
"name": "rapier-zenuml-4.5.0-
|
|
47
|
-
"version": "4.5.0-
|
|
48
|
-
"bytes":
|
|
49
|
-
"sri": "
|
|
46
|
+
"name": "rapier-zenuml-4.5.0-2.js",
|
|
47
|
+
"version": "4.5.0-2",
|
|
48
|
+
"bytes": 1896464,
|
|
49
|
+
"sri": "8Ptjhh1zNTOvy1dxFHhXgYKfs290DSlpzow5rF9FvP38cohtbsBW381Whym1WI6E"
|
|
50
50
|
}
|
|
51
51
|
]
|
|
52
52
|
},
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"kind": "files",
|
|
57
57
|
"element": "rapier-plugin-letters",
|
|
58
58
|
"bytes": 463027,
|
|
59
|
-
"packedBytes":
|
|
59
|
+
"packedBytes": 198962,
|
|
60
60
|
"files": [
|
|
61
61
|
{
|
|
62
62
|
"id": "rapier-letters-field",
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
"kind": "files",
|
|
95
95
|
"element": "rapier-plugin-font-subset",
|
|
96
96
|
"bytes": 1214356,
|
|
97
|
-
"packedBytes":
|
|
97
|
+
"packedBytes": 441192,
|
|
98
98
|
"files": [
|
|
99
99
|
{
|
|
100
100
|
"id": "rapier-font-subset",
|
package/README.md
CHANGED
|
@@ -1,79 +1,62 @@
|
|
|
1
1
|
# rapier-html
|
|
2
2
|
|
|
3
|
-
Turn Markdown into one offline HTML file
|
|
4
|
-
computer, with no account. People read, edit, draw and paint
|
|
5
|
-
|
|
3
|
+
Turn Markdown into one offline HTML file that contains the full Rapier editor. It opens in any browser, on a phone or
|
|
4
|
+
computer, with no account. People read, edit, draw and paint; agents hand over documents, show what changed and get
|
|
5
|
+
the edited copy back. Start with `npx rapier-html@1.2.6 notes.md`.
|
|
6
6
|
|
|
7
|
-
Use it for a plan to rearrange,
|
|
8
|
-
|
|
7
|
+
Use it for a plan to rearrange, a guide to annotate, a draft to keep writing, or a revision to review. The file works
|
|
8
|
+
without any hosted workspace.
|
|
9
9
|
|
|
10
|
-
- The page writes, draws and paints, saves Markdown, exports Word, PDF and a web page, and prints
|
|
11
|
-
and nothing
|
|
12
|
-
- `--return` adds a one-use Send back address from the agent door
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
`--by <name>` attributes the comparison with the time the page was made.
|
|
16
|
-
- `wrap` and `unwrap` carry a document into a page and out again, exactly, for programs.
|
|
10
|
+
- The page writes, draws and paints, saves Markdown, exports Word, PDF and a web page, and prints. It needs no account
|
|
11
|
+
and fetches nothing to open, edit or save.
|
|
12
|
+
- `--return` adds a one-use Send back address from the agent door.
|
|
13
|
+
- `--view draw` opens on Draw. `--compare original.md` opens the edit with its diff; `--by <name>` attributes it.
|
|
14
|
+
- `wrap` and `unwrap` move a document into a page and back out, exactly.
|
|
17
15
|
|
|
18
|
-
Node 22 or newer.
|
|
16
|
+
Needs Node 22 or newer.
|
|
19
17
|
|
|
20
18
|
```sh
|
|
21
|
-
npx -- rapier-html@1.2.
|
|
22
|
-
npx -- rapier-html@1.2.
|
|
23
|
-
npx -- rapier-html@1.2.
|
|
24
|
-
npx -- rapier-html@1.2.
|
|
25
|
-
npx -- rapier-html@1.2.
|
|
26
|
-
npx -- rapier-html@1.2.
|
|
27
|
-
npx -- rapier-html@1.2.
|
|
28
|
-
npm install rapier-html@1.2.
|
|
19
|
+
npx -- rapier-html@1.2.6 notes.md # the editor, notes.md inside it, written beside the file
|
|
20
|
+
npx -- rapier-html@1.2.6 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
|
|
21
|
+
npx -- rapier-html@1.2.6 notes.md --view notes # opens the Notes library
|
|
22
|
+
npx -- rapier-html@1.2.6 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
|
|
23
|
+
npx -- rapier-html@1.2.6 revised.md --compare original.md # opens showing what changed
|
|
24
|
+
npx -- rapier-html@1.2.6 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
|
|
25
|
+
npx -- rapier-html@1.2.6 notes.md out.html # a named output
|
|
26
|
+
npm install rapier-html@1.2.6 # as a library: wrap, unwrap
|
|
29
27
|
```
|
|
30
28
|
|
|
31
29
|
The first `--` is for npm, so `--help` reaches the command. It never overwrites a file.
|
|
32
30
|
|
|
33
|
-
|
|
34
|
-
while they are away. Rapier shows its presence and change marks; the person taps a change to see the before
|
|
35
|
-
and undoes anything. Use `show {target:"comparison"}` to show a diff deliberately. Export Markdown or an offline
|
|
36
|
-
editor when the result needs an independent copy. Send back returns the edited copy alongside the hosted
|
|
37
|
-
document, preserving both.
|
|
38
|
-
|
|
39
|
-
Will markers in the Markdown control agent edits: `keep`, `append`, `edit`.
|
|
31
|
+
Will markers in the Markdown (`keep`, `append`, `edit`) control what agents may change.
|
|
40
32
|
|
|
41
33
|
## Choose a Rapier workflow
|
|
42
34
|
|
|
43
|
-
Rapier is a phone-first Markdown editor for notes, diagrams, drawing and watercolor painting. It runs offline
|
|
44
|
-
as one HTML file on Android, Web and Windows.
|
|
45
|
-
Collaborate over MCP and WebMCP, use optional encrypted sync to your own Cloudflare account, or embed the
|
|
46
|
-
editor and lightweight reader (about 190 kB gzipped). The agent edits live; the person sees its presence and
|
|
47
|
-
changes, taps a change to see the before and undoes anything. Use `show {target:"comparison"}` to show a diff deliberately.
|
|
48
|
-
|
|
49
35
|
1. **Work together live.** Connect to `https://mcp.rapier.website/door/mcp`, then call `open` to share one document with the person.
|
|
50
|
-
2. **
|
|
51
|
-
3. **Embed
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
36
|
+
2. **Hand over an offline editor.** Use this package.
|
|
37
|
+
3. **Embed the editor.** [rapier-embed](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-embed)
|
|
38
|
+
mounts the editor or reader in your app.
|
|
39
|
+
4. **Keep portable Markdown.** [rapier-markdown-kit](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-markdown-kit)
|
|
40
|
+
keeps pictures, drawings and layout in one `.md` file.
|
|
55
41
|
|
|
56
42
|
## Send an edited document back
|
|
57
43
|
|
|
58
|
-
Call `show {target:"return"}` on the Rapier agent door for the document's workspace
|
|
59
|
-
and `return_expires_at` as `--return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT"`,
|
|
60
|
-
`wrap(..., {return: return_url, return_expires_at})`. Only
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
document with a 64 MiB decoded-source ceiling, subject to the workspace’s remaining storage quota, and grants no
|
|
67
|
-
access to the agent's workspace. After the expiry, or a refusal, Share offers
|
|
68
|
-
Save instead; bring that file back to the assistant for a fresh return.
|
|
44
|
+
1. Call `show {target:"return"}` on the Rapier agent door for the document's workspace.
|
|
45
|
+
2. Pass its `return_url` and `return_expires_at` as `--return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT"`,
|
|
46
|
+
or `wrap(..., {return: return_url, return_expires_at})`. Only `https://mcp.rapier.website/return/…` is accepted.
|
|
47
|
+
Share then shows Send back; a page without an address doesn't.
|
|
48
|
+
3. The person edits offline and taps Send back. The source and filename go once; the page shows acceptance or
|
|
49
|
+
refusal and never sends again.
|
|
50
|
+
4. `show {target:"ask",wait:"message"}` returns a `returned` receipt with the `return_id`, name and time, and
|
|
51
|
+
`read {state:true}` lists receipts. Read the source with `read {return_id}`, then `cursor` alone until complete.
|
|
69
52
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
53
|
+
A return lasts up to one day and takes one document of up to 64 MiB of decoded source, within the workspace's
|
|
54
|
+
remaining storage. It grants no access to the workspace, and the returned copy stays separate from the workspace's
|
|
55
|
+
current document. After expiry or a refusal, Share offers Save instead; bring that file back for a fresh return.
|
|
73
56
|
|
|
74
57
|
## As a library
|
|
75
58
|
|
|
76
|
-
Save this as `example.mjs` in the folder you installed into
|
|
59
|
+
Save this as `example.mjs` in the folder you installed into, then run `node example.mjs`:
|
|
77
60
|
|
|
78
61
|
```js
|
|
79
62
|
import {readFile, writeFile} from 'node:fs/promises';
|
|
@@ -86,50 +69,47 @@ console.log('words back exactly:', unwrap(page).text === words);
|
|
|
86
69
|
await writeFile('hello.rapier.html', page, {flag: 'wx'}); // 'wx': never over an existing file
|
|
87
70
|
```
|
|
88
71
|
|
|
89
|
-
`wrap(rapierHtml, text, name, {view, drawing, drawingName, base, baseName, by, at, return: returnURL, return_expires_at})`
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
`encodeCarried`, `decodeCarried
|
|
72
|
+
- `wrap(rapierHtml, text, name, {view, drawing, drawingName, base, baseName, by, at, return: returnURL, return_expires_at})`
|
|
73
|
+
- `unwrap(pageHtml)` returns the same fields, plus the validated `comparisonBase` record, with `null` for anything the
|
|
74
|
+
page doesn't carry.
|
|
75
|
+
- Also exported: `encodeCarried`, `decodeCarried` and `main`.
|
|
93
76
|
|
|
94
77
|
## How the page carries the document
|
|
95
78
|
|
|
96
|
-
One `<script type="text/markdown" id="rapier-document">` block at the top of the body, never
|
|
97
|
-
|
|
98
|
-
`id="rapier-
|
|
99
|
-
|
|
100
|
-
before `/` or `!`, no carriage return and no NUL: `\\`, `\/`, `\!`, `\r` and `\0` stand for
|
|
101
|
-
reversible pass each way
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
its original text, hash and revision; a baseline equal to the current text is omitted. It is a display
|
|
106
|
-
reference, never a write grant or a history record. The edited document remains the source used by Save and
|
|
107
|
-
Send back.
|
|
79
|
+
- One `<script type="text/markdown" id="rapier-document">` block at the top of the body, never run, read once at
|
|
80
|
+
start-up.
|
|
81
|
+
- A drawing goes in `id="rapier-drawing"` and the comparison original in `id="rapier-base"`. The document block's
|
|
82
|
+
`data-view` names the opening view, `data-return` the return URL, and `data-return-expires-at` its expiry.
|
|
83
|
+
- The block holds no `<` before `/` or `!`, no carriage return and no NUL: `\\`, `\/`, `\!`, `\r` and `\0` stand for
|
|
84
|
+
them, one reversible pass each way. A byte-order mark is kept and written back on Save.
|
|
85
|
+
- `comparisonBase` holds the original `text`, `sha256`, `revision`, `name`, `by` and `at`. Rewrapping keeps the
|
|
86
|
+
original text, hash and revision; a baseline equal to the current text is left out. It is for display only, never a
|
|
87
|
+
write grant or history. Save and Send back use the edited document.
|
|
108
88
|
|
|
109
89
|
## Safety
|
|
110
90
|
|
|
111
|
-
- It never overwrites: an existing output, the document itself
|
|
112
|
-
`--view` other than `draw` or `notes`,
|
|
91
|
+
- It never overwrites: an existing output, including the document itself, is refused. So are unknown options, a
|
|
92
|
+
`--view` other than `draw` or `notes`, `--drawing` or `--compare` without a file, a return without its expiry, an
|
|
113
93
|
invalid expiry, an unsafe return URL and a third filename.
|
|
114
|
-
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
94
|
+
- The page opens and renders from what it carries; it can't fetch pictures, media, fonts or plugins. The editor
|
|
95
|
+
includes verified math, Mermaid and lettering resources. Embed any document pictures.
|
|
96
|
+
- Send back connects only to the return origin it was given. Credentials, query strings and fragments in the return
|
|
97
|
+
URL are refused.
|
|
118
98
|
|
|
119
99
|
## Optional edit history
|
|
120
100
|
|
|
121
|
-
`wrap(page, text, filename, {ledger, authorship})` accepts the
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
`ledger/carried.writeDocument` is read automatically when wrapping. Rewrapping a
|
|
128
|
-
|
|
129
|
-
|
|
101
|
+
`wrap(page, text, filename, {ledger, authorship})` accepts the kit's `rapier-ledger/2` and `rapier-authorship/1`
|
|
102
|
+
objects, and `unwrap(page)` returns the checked parts beside the text. Both are off by default.
|
|
103
|
+
|
|
104
|
+
- An authorship projection holds only who wrote the current characters; a full ledger also holds deleted text.
|
|
105
|
+
- A part that doesn't match the text, or is malformed, duplicated or inconsistent, throws instead of attaching false
|
|
106
|
+
history.
|
|
107
|
+
- Markdown saved with the kit's `ledger/carried.writeDocument` is read automatically when wrapping. Rewrapping a page
|
|
108
|
+
without parts drops the old parts.
|
|
109
|
+
- The published package carries its own MIT validator; no source checkout is needed.
|
|
130
110
|
|
|
131
|
-
##
|
|
111
|
+
## License
|
|
132
112
|
|
|
133
|
-
AGPL-3.0-only (`LICENSE`): the page is the Rapier editor with the notices of the libraries it carries.
|
|
134
|
-
|
|
113
|
+
AGPL-3.0-only (`LICENSE`): the page is the Rapier editor, with the notices of the libraries it carries. For the
|
|
114
|
+
Markdown standard without the editor, use `rapier-markdown-kit` (MIT). Rapier is at https://rapier.website.
|
|
135
115
|
`BUILD.json` records the page's size, SHA-256, build time and Node version.
|
package/dist/ledger/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
|
|
@@ -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/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);
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {letter, within, prev, next} from './tokens.mjs';
|
|
3
|
+
|
|
4
|
+
// Separator-only pieces join the edit beside them; adjacent blocks each carry their own line break.
|
|
5
|
+
export function separate(c, endA = c.before.length, endB = c.after.length) {
|
|
6
|
+
const {before, after, safe, raw, spend} = c;
|
|
7
|
+
// A separator-only piece beside another edit joins it; equal separators at its ends stay unchanged.
|
|
8
|
+
if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
|
|
9
|
+
const p = c.result[i - 1], q = c.result[i], side = p[0][0] === p[0][1] ? 1 : p[1][0] === p[1][1] ? 0 : -1, text = side ? after : before;
|
|
10
|
+
if (side >= 0 && !/\S/.test(text.slice(...p[side]))) {
|
|
11
|
+
let n = 0;
|
|
12
|
+
while (p[0][1] + n < q[0][0] && text[p[side][0] + n] === text[p[side][1] + n] && spend()) n++;
|
|
13
|
+
if (p[0][1] + n === q[0][0] && p.slice(0, 2).every((span, k) => safe(k ? after : before, span[0] + n) && safe(k ? after : before, span[1] + n)))
|
|
14
|
+
for (const span of p.slice(0, 2)) { span[0] += n; span[1] += n; }
|
|
15
|
+
}
|
|
16
|
+
if (p[2] || q[2] || p[0][1] !== q[0][0] || p[1][1] !== q[1][0] ||
|
|
17
|
+
![p, q].some(r => !/\S/.test(before.slice(...r[0]) + after.slice(...r[1])))) continue;
|
|
18
|
+
const x = [p[0][0], q[0][1]], y = [p[1][0], q[1][1]], keep = /\S/.test(before.slice(...q[0]) + after.slice(...q[1])) ? q : p;
|
|
19
|
+
// CRLF steps as one separator.
|
|
20
|
+
for (let k; x[0] < x[1] && y[0] < y[1] && /\s/.test(before[x[1] - 1]) && before[x[1] - 1] === after[y[1] - 1] &&
|
|
21
|
+
(k = before.endsWith('\r\n', x[1]) && after.endsWith('\r\n', y[1]) ? 2 : 1) <= Math.min(x[1] - x[0], y[1] - y[0]) &&
|
|
22
|
+
safe(before, x[1] - k) && safe(after, y[1] - k);) { x[1] -= k; y[1] -= k; }
|
|
23
|
+
keep[3][0] = x[0] === x[1] ? 2 : y[0] === y[1] ? 0 : 1;
|
|
24
|
+
c.result.splice(i - 1, 2, [x, y, false, keep[3]]);
|
|
25
|
+
}
|
|
26
|
+
// Repeated closing lines can attach to the wrong block. Rotate an exact insertion/deletion to the
|
|
27
|
+
// next blank-line boundary, without crossing another edit or splitting a character.
|
|
28
|
+
if (!raw) for (let i = 0; i < c.result.length; i++) {
|
|
29
|
+
const p = c.result[i], q = c.result[i + 1], side = p[0][0] === p[0][1] ? 1 : p[1][0] === p[1][1] ? 0 : -1;
|
|
30
|
+
if (side < 0 || p[2]) continue;
|
|
31
|
+
const text = side ? after : before;
|
|
32
|
+
const room = Math.min((q?.[0][0] ?? endA) - p[0][1], (q?.[1][0] ?? endB) - p[1][1]);
|
|
33
|
+
let n = 0, shift = 0;
|
|
34
|
+
while (n < room && text[p[side][0] + n] === text[p[side][1] + n] && spend()) {
|
|
35
|
+
n++;
|
|
36
|
+
if (text[p[side][0] + n - 1] === '\n' && /^\r?\n/.test(text.slice(p[side][0] + n))) { shift = n; break; }
|
|
37
|
+
}
|
|
38
|
+
if (shift && p.slice(0, 2).every((span, k) => safe(k ? after : before, span[0] + shift) && safe(k ? after : before, span[1] + shift)))
|
|
39
|
+
for (const span of p.slice(0, 2)) { span[0] += shift; span[1] += shift; }
|
|
40
|
+
}
|
|
41
|
+
// Adjacent blocks deleted or inserted together: when the first carries the line break before it, each carries
|
|
42
|
+
// its own, so any one restores apart from its neighbors.
|
|
43
|
+
if (!raw) for (let i = 1; i < c.result.length; i++) {
|
|
44
|
+
const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
|
|
45
|
+
if (kind % 2 || q[3][0] !== kind || p[2] || q[2] || p[side][1] !== q[side][0] || p[1 - side][0] !== q[1 - side][0]) continue;
|
|
46
|
+
const blank = /^\r?\n/.test(text.slice(...p[side])) && !/^\r?\n/.test(text.slice(...q[side])) && /(\r?\n)(?:[ \t]*\r?\n)+$/.exec(text.slice(...p[side]));
|
|
47
|
+
if (blank && blank.index > 0) p[side][1] = q[side][0] -= blank[0].length - blank[1].length;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Refine the outer walk and each move's inner walk before assigning transport ownership.
|
|
52
|
+
export function phrases(c) {
|
|
53
|
+
const {before, after, raw, code} = c;
|
|
54
|
+
// Whole-word replacements separated only by spaces read and restore as one phrase, and one number stays one number.
|
|
55
|
+
if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
|
|
56
|
+
const p = c.result[i - 1], q = c.result[i], gap = before.slice(p[0][1], q[0][0]);
|
|
57
|
+
if (p[3][0] !== 1 || q[3][0] !== 1 || p[3][3] || q[3][3] || p[2] || q[2] || gap !== after.slice(p[1][1], q[1][0])) continue;
|
|
58
|
+
// A time, a date, a version or a decimal is one number.
|
|
59
|
+
const number = /^(?:[.,:/-]\d*)+$/.test(gap) && /\d/.test(before[p[0][1] - 1] + after[p[1][1] - 1]) && /\d/.test(before[q[0][0]] + after[q[1][0]]);
|
|
60
|
+
if (!number && (code || !/^[^\S\r\n]+$/.test(gap) || within(before, p[0][0]) || within(after, p[1][0]) ||
|
|
61
|
+
within(before, q[0][1]) || within(after, q[1][1]))) continue;
|
|
62
|
+
p[0][1] = q[0][1]; p[1][1] = q[1][1]; c.result.splice(i, 1);
|
|
63
|
+
}
|
|
64
|
+
// Paired inline markers added or removed around unchanged text on one line are one concern.
|
|
65
|
+
if (!raw && !code) for (let i = 1; i < c.result.length; i++) {
|
|
66
|
+
const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
|
|
67
|
+
if (kind % 2 || kind !== q[3][0] || p[2] || q[2] || p[3][3] || q[3][3]) continue;
|
|
68
|
+
const open = text.slice(...p[side]), close = text.slice(...q[side]), inner = text.slice(p[side][1], q[side][0]);
|
|
69
|
+
if (!inner.trim() || inner.includes('\n') || !(open === close && /^(?:[*_]{1,3}|~~|==|`+)$/.test(open) ||
|
|
70
|
+
open === '[' && /^\]\([^\s()]*\)$/.test(close))) continue;
|
|
71
|
+
p[3] = q[3] = [kind, 'word', p[3][2], [[p[0][0], q[0][1]], [p[1][0], q[1][1]]]];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Readable concerns: phrases, numbers and paired markers, then the public edits and changes with context.
|
|
76
|
+
export function concerns(c) {
|
|
77
|
+
phrases(c);
|
|
78
|
+
const {before, after, spend, safe, budget, raw, code, evidence, structures, initial, options, pa, pb} = c, lb = c.lb;
|
|
79
|
+
const overlaps = (a, b) => a[0] < b[1] && a[1] > b[0];
|
|
80
|
+
// Siblings are ordered and disjoint: the parts holding a range are found by descent, outermost first.
|
|
81
|
+
const containing = (parts, from, to, out = []) => {
|
|
82
|
+
let lo = 0, hi = parts.length;
|
|
83
|
+
while (lo < hi) { const mid = lo + hi >>> 1; if (parts[mid].end < to) lo = mid + 1; else hi = mid; }
|
|
84
|
+
for (let k = lo; k < parts.length && parts[k].start <= from && spend(); k++)
|
|
85
|
+
if (parts[k].end >= to) { out.push(parts[k]); containing(parts[k].children, from, to, out); }
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
// Edits are ordered and disjoint on the after side.
|
|
89
|
+
const touched = (edits, range) => {
|
|
90
|
+
let lo = 0, hi = edits.length;
|
|
91
|
+
while (lo < hi) { const mid = lo + hi >>> 1; if (edits[mid].after[1] <= range[0]) lo = mid + 1; else hi = mid; }
|
|
92
|
+
return lo < edits.length && overlaps(range, edits[lo].after);
|
|
93
|
+
};
|
|
94
|
+
const changes = [], edits = [];
|
|
95
|
+
let normalized;
|
|
96
|
+
const meaning = structures?.[0]?.meaning;
|
|
97
|
+
const format = typeof meaning === 'string' && meaning === structures[1].meaning;
|
|
98
|
+
for (let i = 0; i < c.result.length; i++) {
|
|
99
|
+
const item = c.result[i];
|
|
100
|
+
if (raw === 'char' && !evidence) {
|
|
101
|
+
const a = item[0], b = item[1], next = c.result[i + 1];
|
|
102
|
+
if (item[3][0] !== 1) {
|
|
103
|
+
const room = Math.min((next?.[0][0] ?? before.length) - a[1], (next?.[1][0] ?? after.length) - b[1]);
|
|
104
|
+
let n = 0;
|
|
105
|
+
while (n < room && before[a[0] + n] === after[b[0] + n] && spend()) n++;
|
|
106
|
+
while (n && (!safe(before, a[0] + n) || !safe(before, a[1] + n) || !safe(after, b[0] + n) || !safe(after, b[1] + n))) n--;
|
|
107
|
+
a[0] += n; a[1] += n; b[0] += n; b[1] += n;
|
|
108
|
+
}
|
|
109
|
+
const p = normalized;
|
|
110
|
+
if (p && p[0][0] === p[0][1] && item[3][0] === 2 && p[0][0] === a[0] && p[1][1] === b[0]) {
|
|
111
|
+
p[1][1] = b[1]; if (item[2]) edits.at(-1).coarse = true; continue;
|
|
112
|
+
}
|
|
113
|
+
normalized = item;
|
|
114
|
+
}
|
|
115
|
+
const group = item[3];
|
|
116
|
+
if (!raw && !group[3]) group[3] = [[...item[0]], [...item[1]]];
|
|
117
|
+
if (!group[4]) {
|
|
118
|
+
const context = group[3];
|
|
119
|
+
// Context completes a word only where an edge falls inside it.
|
|
120
|
+
if (!raw) for (const [text, span] of [[before, context[0]], [after, context[1]]]) {
|
|
121
|
+
const start = within(text, span[0]), end = within(text, span[1]);
|
|
122
|
+
for (let c; start && letter(c = prev(text, span[0])) && spend() && safe(text, span[0] - c.length);) span[0] -= c.length;
|
|
123
|
+
for (let c; end && letter(c = next(text, span[1])) && spend() && safe(text, span[1] + c.length);) span[1] += c.length;
|
|
124
|
+
}
|
|
125
|
+
group[4] = []; changes.push(group);
|
|
126
|
+
if (format) group[0] = 5;
|
|
127
|
+
}
|
|
128
|
+
group[4].push(edits.length);
|
|
129
|
+
edits.push({before: item[0], after: item[1], ...(item[2] ? {coarse: true} : {})});
|
|
130
|
+
}
|
|
131
|
+
if (structures && !raw && budget.left >= 0) {
|
|
132
|
+
const oldNames = new Set(pa.filter(p => p.nameAt).map(p => p.name)), newNames = new Set(pb.filter(p => p.nameAt).map(p => p.name));
|
|
133
|
+
const linksTo = new Map();
|
|
134
|
+
if (spend(pa.length + pb.length + lb.length)) lb.forEach((link, k) => linksTo.set(link.to, [...linksTo.get(link.to) || [], k]));
|
|
135
|
+
for (const change of changes) {
|
|
136
|
+
if (change[0] === 5) continue;
|
|
137
|
+
if (budget.left < 0) break;
|
|
138
|
+
const range = change[3];
|
|
139
|
+
const older = containing(structures[0].parts, ...range[0]), newer = containing(structures[1].parts, ...range[1]);
|
|
140
|
+
const part = (change[0] === 0 ? older : newer).at(-1) || older.at(-1);
|
|
141
|
+
if (!change[2] && part?.name) { change[2] = part.name; change[1] = part.kind; }
|
|
142
|
+
const names = new Set([change[2], ...older.map(p => p.name), ...newer.map(p => p.name)]), breaks = [], affects = [];
|
|
143
|
+
for (const k of [...names].flatMap(name => linksTo.get(name) || []).sort((a, b) => a - b)) {
|
|
144
|
+
const {at, to} = lb[k];
|
|
145
|
+
if (!newNames.has(to)) { if (oldNames.has(to)) breaks.push({at, to}); continue; }
|
|
146
|
+
if (!spend() || touched(edits, at)) continue;
|
|
147
|
+
const p = containing(structures[1].parts, ...at).filter(p => /^(paragraph|call|item|task|cell|declaration)$/.test(p.kind)).at(-1);
|
|
148
|
+
affects.push(p && !touched(edits, [p.start, p.end]) ? [p.start, p.end] : at);
|
|
149
|
+
}
|
|
150
|
+
if (breaks.length) change[6] = breaks;
|
|
151
|
+
if (affects.length) change[5] = affects;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (options._stats) Object.assign(options._stats, {work: Math.max(0, initial - Math.max(0, budget.left)), coarse: edits.some(c => c.coarse)});
|
|
155
|
+
// Concern tuples: kind, unit, name, context, edits, affects, breaks. Wording inside a move shares its context.
|
|
156
|
+
const moves = new Map(changes.map((change, i) => change[0] === 3 && [change[3], i + 1]).filter(Boolean));
|
|
157
|
+
return {edits, changes: changes.map(([kind, unit, name, context, owned, affects, breaks], i) => ({
|
|
158
|
+
id: i + 1, kind: ['delete', 'replace', 'insert', 'move', 'rename', 'format'][kind], unit, edits: owned,
|
|
159
|
+
...(name ? {name} : {}), ...(context ? {context: {before: context[0], after: context[1]}} : {}),
|
|
160
|
+
...(kind !== 3 && moves.has(context) ? {move: moves.get(context)} : {}),
|
|
161
|
+
...(affects ? {affects} : {}), ...(breaks ? {breaks} : {})
|
|
162
|
+
}))};
|
|
163
|
+
}
|