rapier-html 1.1.58 → 1.1.62

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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "package": "rapier-html",
3
- "version": "1.1.58",
3
+ "version": "1.1.62",
4
4
  "path": "dist/rapier.html",
5
- "bytes": 1788599,
6
- "sha256": "9e2969719713a5b278e549b5d2ce02916ea02de32e2451e7b01b9f9a987f67ee",
7
- "builtAt": "2026-10-04T09:14:25.892Z",
5
+ "bytes": 1838935,
6
+ "sha256": "160c45f037b0badded610c60016729527f05a26b68e04570862ec4137a4ea342",
7
+ "builtAt": "2026-10-05T00:10:34.442Z",
8
8
  "node": "v22.22.2",
9
9
  "canonical": true,
10
10
  "packing": "zopfli",
package/README.md CHANGED
@@ -14,14 +14,14 @@ editor, offline, with the document inside.
14
14
  Node 22 or newer.
15
15
 
16
16
  ```sh
17
- npx -- rapier-html@1.1.58 notes.md # the editor, notes.md inside it, written beside the file
18
- npx -- rapier-html@1.1.58 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
19
- npx -- rapier-html@1.1.58 notes.md --view notes # opens the Notes library
20
- npx -- rapier-html@1.1.58 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
21
- npx -- rapier-html@1.1.58 proposal.md --base original.md # opens on the diff of a proposed change
22
- npx -- rapier-html@1.1.58 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
23
- npx -- rapier-html@1.1.58 notes.md out.html # a named output
24
- npm install rapier-html@1.1.58 # as a library: wrap, unwrap
17
+ npx -- rapier-html@1.1.62 notes.md # the editor, notes.md inside it, written beside the file
18
+ npx -- rapier-html@1.1.62 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
19
+ npx -- rapier-html@1.1.62 notes.md --view notes # opens the Notes library
20
+ npx -- rapier-html@1.1.62 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
21
+ npx -- rapier-html@1.1.62 proposal.md --base original.md # opens on the diff of a proposed change
22
+ npx -- rapier-html@1.1.62 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
23
+ npx -- rapier-html@1.1.62 notes.md out.html # a named output
24
+ npm install rapier-html@1.1.62 # as a library: wrap, unwrap
25
25
  ```
26
26
 
27
27
  The first `--` is for npm, so `--help` reaches the command. It never overwrites a file.
@@ -24,13 +24,19 @@ Files under this licence carry `SPDX-License-Identifier: MIT` in their first lin
24
24
  grammar, marks and reference style (`spec/md-layout.mjs`, `spec/md-marks.mjs`, `spec/flowchart.mjs`, `spec/markdown-style.css`), the layout model and line planner
25
25
  (`layout/model.mjs`, `layout/line-plan.mjs`), the Will grammar (`agent/will.mjs`), the picture appendix reader and writer
26
26
  (`spec/md-assets.mjs`), the two shared-page reference readers (`tools/read-shared-page.mjs`,
27
- `tools/read-shared-page.py`), and the host helper `packages/rapier-embed/embed.mjs`. The vendored Pretext (`agent/vendor/pretext`) and micromark carry their
27
+ `tools/read-shared-page.py`), and the host helper `packages/rapier-embed/embed.mjs` with its
28
+ contract owner `packages/rapier-embed/contract.mjs`. The vendored Pretext (`agent/vendor/pretext`) and micromark carry their
28
29
  own MIT notices. The edit-ledger module (`kit/rapier-ledger.mjs`, `kit/ledger/*.mjs`) and
29
30
  its editor compatibility import (`editor/journal-records.mjs`) are also covered. The
30
31
  record validator/replayer and text-only conflict merger were moved, not copied,
31
32
  into that module; the Notes index merger and the rest of the agent/editor remain
32
33
  AGPL-3.0-only. The wrapper's carrier-staging helper
33
- (`skills/rapier-html/package-files.mjs`) is MIT. Everything else in Rapier is AGPL-3.0-only (see `LICENSE`).
34
+ (`skills/rapier-html/package-files.mjs`) is MIT. The kit's MIT-marked facades,
35
+ `kit/render-entry.mjs`, `kit/render*.mjs`, `kit/stage.mjs` and MIT-marked conformance
36
+ modules are covered too. These names describe the Rapier-owned files carrying the
37
+ MIT notice, not a grant over the editor or third-party code. Other Rapier-owned
38
+ files retain their stated licence; the editor is AGPL-3.0-only (see `LICENSE`).
39
+ Third-party components retain their own copyright and licence notices.
34
40
 
35
41
  The picture appendix reader and writer used to be part of `images/assets.mjs`, a file that also
36
42
  holds JPEG XL/raster header inspection and SVG sanitizing -- both of which reach the editor's own
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {readLedger} from './format.mjs';
3
+ import {sha256} from './hash.mjs';
3
4
  import {authorship as projectAuthorship, readAuthorship} from './authorship.mjs';
4
5
  import {canonicalJSON, fault} from './data.mjs';
5
6
  export function encodeCarried(text) {
@@ -67,3 +68,53 @@ export function readDocument(source) {
67
68
  const text = source.slice(0, at);
68
69
  return {text, ...validateParts(text, parts)};
69
70
  }
71
+
72
+ // One portable baseline. A digest is integrity evidence, not authentication of the named proposer.
73
+ export function readBase(value) {
74
+ if (!value || typeof value !== 'object' || typeof value.text !== 'string' ||
75
+ typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256) || sha256(value.text) !== value.sha256)
76
+ throw fault('proposal base SHA-256 differs from its text');
77
+ if (typeof value.revision !== 'string' || !value.revision || value.revision.length > 256 || /[\u0000-\u001f\u007f]/.test(value.revision))
78
+ throw fault('invalid proposal base revision');
79
+ if (typeof value.name !== 'string' || !value.name || value.name.length > 255 || /[\\/\u0000-\u001f\u007f]/.test(value.name))
80
+ throw fault('invalid proposal base name');
81
+ if (typeof value.by !== 'string' || !value.by.trim() || value.by.length > 96 || /[\u0000-\u001f\u007f]/.test(value.by))
82
+ throw fault('a proposal needs its proposer name');
83
+ if (typeof value.at !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value.at) || !Number.isFinite(Date.parse(value.at)))
84
+ throw fault('the time of a proposal is an ISO 8601 UTC timestamp');
85
+ return {text: value.text, sha256: value.sha256, revision: value.revision, name: value.name, by: value.by, at: value.at};
86
+ }
87
+ const escapeAttribute = value => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
88
+ const readAttribute = value => value.replace(/&(quot|lt|gt|amp);/g, (_, name) => ({quot: '"', lt: '<', gt: '>', amp: '&'})[name]);
89
+ export function writeBase(text, value) {
90
+ if (value == null) return '';
91
+ const base = readBase(value);
92
+ if (base.text === text) return '';
93
+ return '<script type="text/markdown" id="rapier-base"' + ['name', 'sha256', 'revision', 'by', 'at']
94
+ .map(key => ' data-' + key + '="' + escapeAttribute(base[key]) + '"').join('') + '>' + encodeCarried(base.text) + '</script>';
95
+ }
96
+ export function readBaseElements(elements) {
97
+ const rows = [...elements].filter(element => element.id === 'rapier-base');
98
+ if (!rows.length) return null;
99
+ const row = rows[0];
100
+ if (rows.length !== 1 || row.type !== 'text/markdown' || (row.tagName && row.tagName.toLowerCase() !== 'script'))
101
+ throw fault('duplicate or executable proposal base');
102
+ const get = name => row.getAttribute ? row.getAttribute('data-' + name) : row[name];
103
+ return readBase({text: decodeCarried(row.textContent), ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at'].map(key => [key, get(key)]))});
104
+ }
105
+ export function takeBase(html) {
106
+ const elements = [];
107
+ const stripped = html.replace(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi, (whole, attrs, content) => {
108
+ const pairs = [...attrs.matchAll(/(?:^|\s)([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g)].map(m => [m[1].toLowerCase(), readAttribute(m[2] ?? m[3] ?? m[4])]);
109
+ if (!pairs.some(([key, value]) => key === 'id' && value === 'rapier-base')) return whole;
110
+ const seen = new Set(), values = {};
111
+ for (const [key, value] of pairs) {
112
+ if (seen.has(key)) throw fault('ambiguous proposal base');
113
+ seen.add(key); values[key] = value;
114
+ }
115
+ elements.push({id: values.id, type: values.type, textContent: content,
116
+ ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at'].map(key => [key, values['data-' + key]]))});
117
+ return '';
118
+ });
119
+ return {html: stripped, base: readBaseElements(elements)};
120
+ }
package/dist/page.mjs CHANGED
@@ -1,123 +1,17 @@
1
- import {encodeCarried, decodeCarried, writeParts, takeParts, readDocument} from './ledger/carried.mjs';
1
+ // SPDX-License-Identifier: MIT
2
2
  import {readFile, writeFile} from 'node:fs/promises';
3
3
  import {basename, dirname, join} from 'node:path';
4
- import {PAGE_SEED, returnAddress, returnExpiresAt} from './return-address.mjs';
5
-
6
- const START = '<script type="text/markdown" id="rapier-document"';
7
- const DRAWING_START = '<script type="text/plain" id="rapier-drawing"';
8
- // The text the document was proposed against (docs/page-door.md, "2. A change carried"): the page opens on its diff.
9
- const BASE_START = '<script type="text/markdown" id="rapier-base"';
10
- const END = '</script>';
11
- // The page's search words (rapier.html's RAPIER_SEO regions: description, canonical address, previews, structured data and the plain guide
12
- // a crawler reads) are rapier.website's own. A page that carries someone's document never claims that address or that description.
13
- const SEARCH_WORDS = /<!-- RAPIER_SEO_BEGIN -->[\s\S]*?<!-- RAPIER_SEO_END -->\n?/g;
14
-
15
- // One encoding, exactly reversible (docs/page-door.md, "What a block may hold"): no `<` before `/` or `!`, no CR, no NUL.
16
- // engine.js _rapierDecodeCarried is its inverse.
17
- export {encodeCarried, decodeCarried};
18
-
19
- const PROPOSED_AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
20
- const attribute = text => text.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
21
- const unattribute = text => text.replace(/&(quot|lt|gt|amp);/g, (_, e) => ({quot: '"', lt: '<', gt: '>', amp: '&'})[e]);
22
- function safeName(name, fallback) {
23
- return String(name || fallback).replace(/[\\/\0"<>&]/g, '-').slice(0, 255) || fallback;
24
- }
25
-
26
- export function wrap(pageHtml, text, name, options) {
27
- if (typeof pageHtml !== 'string' || !pageHtml.includes('</body>')) throw new Error('not a Rapier page: no </body>');
28
- if (typeof text !== 'string') throw new Error('the document must be text');
29
- const saved = readDocument(text);
30
- text = saved.text;
31
- const opts = {...saved, ...(options || {})};
32
- const docName = safeName(name, 'document.md');
33
- // `view`: the view the page opens on with the document behind it, `draw` or `notes` (docs/page-door.md).
34
- if (opts.view != null && !['draw', 'notes'].includes(opts.view)) throw new Error('the view is draw or notes');
35
- const address = opts.return == null ? null : returnAddress(opts.return);
36
- const expiry = opts.return_expires_at == null ? null : returnExpiresAt(opts.return_expires_at);
37
- if (!!address !== !!expiry) throw new Error('a return needs its URL and return_expires_at from document.create_return');
38
- const block = START + ' data-name="' + docName + '"' + (opts.view ? ' data-view="' + opts.view + '"' : '') +
39
- (address ? ' data-return="' + address + '" data-return-expires-at="' + expiry + '"' : '') + '>' + encodeCarried(text) + END;
40
- let carriedBlocks = '\n' + writeParts(text, opts);
41
- if (opts.drawing != null) {
42
- if (typeof opts.drawing !== 'string') throw new Error('the drawing must be SVG text');
43
- const drawingName = safeName(opts.drawingName, 'drawing.svg');
44
- carriedBlocks += '\n' + DRAWING_START + ' data-name="' + drawingName + '">' + encodeCarried(opts.drawing) + END;
45
- }
46
- if ((opts.by != null || opts.at != null) && opts.base == null) throw new Error('a proposer needs the base it proposes over');
47
- if (opts.base != null) {
48
- if (typeof opts.base !== 'string') throw new Error('the base must be text');
49
- // A proposal page (docs/briefs/ledger.md section 3): who proposed it, in the host's own words, and when.
50
- const by = opts.by == null ? null : String(opts.by).replace(/[\0-\x1f\x7f]/g, ' ').trim().slice(0, 96);
51
- const at = opts.at == null ? null : String(opts.at);
52
- if (at != null && !(PROPOSED_AT.test(at) && Number.isFinite(Date.parse(at)))) throw new Error('the time of a proposal is an ISO 8601 UTC timestamp');
53
- carriedBlocks += '\n' + BASE_START + ' data-name="' + safeName(opts.baseName, docName) + '"' + (by ? ' data-by="' + attribute(by) + '"' : '') +
54
- (at ? ' data-at="' + at + '"' : '') + '>' + encodeCarried(opts.base) + END;
55
- }
56
- // The agent seed goes after the charset (or <head>), once: a page that already carries it, wrapped again, loses its old one.
57
- const unseeded = unwrap(pageHtml).html.replace(SEARCH_WORDS, '').split(PAGE_SEED + '\n').join('');
58
- const headTag = /<meta charset[^>]*>\n?/i.exec(unseeded) || /<head[^>]*>\n?/.exec(unseeded);
59
- if (!headTag) throw new Error('not a Rapier page: no <head>');
60
- const seededAt = headTag.index + headTag[0].length;
61
- const stripped = unseeded.slice(0, seededAt) + PAGE_SEED + '\n' + unseeded.slice(seededAt);
62
- // Before Rapier's scripts: parsed before the engine boots.
63
- const bodyTag = /<body[^>]*>/.exec(stripped);
64
- if (!bodyTag) throw new Error('not a Rapier page: no <body>');
65
- const at = bodyTag.index + bodyTag[0].length;
66
- // A function, not a string: `$'`, `$&` and `` $` `` are replacement patterns.
67
- const titled = stripped.replace(/<title>[^<]*<\/title>/, () => '<title>' + docName.replace(/&/g, '&amp;').replace(/</g, '&lt;') + ' — Rapier</title>');
68
- const shift = titled.length - stripped.length;
69
- return titled.slice(0, at + shift) + '\n' + block + carriedBlocks + titled.slice(at + shift);
70
- }
71
- export function unwrap(pageHtml) {
72
- let html = pageHtml, text = null, name = null, view = null, address = null, expiry = null;
73
- const a = pageHtml.indexOf(START);
74
- if (a >= 0) {
75
- const open = pageHtml.indexOf('>', a), b = pageHtml.indexOf(END, open);
76
- if (open < 0 || b < 0) throw new Error('a carried document without its end');
77
- name = /data-name="([^"]*)"/.exec(pageHtml.slice(a, open))?.[1] ?? null;
78
- view = /data-view="(draw|notes)"/.exec(pageHtml.slice(a, open))?.[1] ?? null;
79
- const carriedReturn = /data-return="([^"]*)"/.exec(pageHtml.slice(a, open));
80
- if (carriedReturn) address = returnAddress(carriedReturn[1]);
81
- const carriedExpiry = /data-return-expires-at="([^"]*)"/.exec(pageHtml.slice(a, open));
82
- if (carriedExpiry) expiry = returnExpiresAt(carriedExpiry[1]);
83
- if (!!address !== !!expiry) throw new Error('a carried return needs its URL and return_expires_at');
84
- text = decodeCarried(pageHtml.slice(open + 1, b));
85
- html = pageHtml.slice(0, a) + pageHtml.slice(b + END.length);
86
- if (a > 0 && html[a - 1] === '\n') html = html.slice(0, a - 1) + html.slice(a);
87
- }
88
- let drawing = null, drawingName = null;
89
- const d = html.indexOf(DRAWING_START);
90
- if (d >= 0) {
91
- const open = html.indexOf('>', d), b = html.indexOf(END, open);
92
- if (open < 0 || b < 0) throw new Error('a carried drawing without its end');
93
- drawingName = /data-name="([^"]*)"/.exec(html.slice(d, open))?.[1] ?? null;
94
- drawing = decodeCarried(html.slice(open + 1, b));
95
- html = html.slice(0, d) + html.slice(b + END.length);
96
- if (d > 0 && html[d - 1] === '\n') html = html.slice(0, d - 1) + html.slice(d);
97
- }
98
- let base = null, baseName = null, by = null, at = null;
99
- const e = html.indexOf(BASE_START);
100
- if (e >= 0) {
101
- const open = html.indexOf('>', e), b = html.indexOf(END, open);
102
- if (open < 0 || b < 0) throw new Error('a carried base without its end');
103
- baseName = /data-name="([^"]*)"/.exec(html.slice(e, open))?.[1] ?? null;
104
- const proposer = /data-by="([^"]*)"/.exec(html.slice(e, open))?.[1];
105
- by = proposer == null ? null : unattribute(proposer);
106
- at = /data-at="([^"]*)"/.exec(html.slice(e, open))?.[1] ?? null;
107
- base = decodeCarried(html.slice(open + 1, b));
108
- html = html.slice(0, e) + html.slice(b + END.length);
109
- if (e > 0 && html[e - 1] === '\n') html = html.slice(0, e - 1) + html.slice(e);
110
- }
111
- const parts = takeParts(html, text);
112
- return {...parts, text, name, view, drawing, drawingName, base, baseName, by, at, return: address, return_expires_at: expiry};
113
- }
4
+ import {wrap} from './wrap.mjs';
5
+ import {returnAddress, returnExpiresAt} from './return-address.mjs';
6
+ export {wrap, unwrap, encodeCarried, decodeCarried} from './wrap.mjs';
114
7
  // Unknown options, a bare --drawing or --base and a third name are refused, never dropped.
115
8
  const USAGE = 'usage: rapier-html <document.md> [out.html] [--view draw|notes] [--drawing sketch.svg] [--base original.md [--by <your name>]] [--return <url> --return-expires-at <timestamp>]\n' +
116
9
  ' writes <document>.rapier.html beside the file: Rapier with the document inside it, one file, offline.\n' +
117
10
  ' It never overwrites: an output that already exists, the document itself included, is refused.\n' +
118
11
  ' --view opens the page on Draw (sketch and paint, the document behind it) or on Notes\n' +
119
12
  ' --drawing carries an SVG drawing alongside the document, opened on Draw as the page boots\n' +
120
- ' --base carries the text the document was proposed against; the page opens on their diff\n' +
13
+ ' --propose <base.md> <proposed.md>: carries the original base for per-change review\n' +
14
+ ' --base the older spelling of --propose; carries the text the document was proposed against; the page opens on their diff\n' +
121
15
  ' --by with --base: who proposes it, shown on the diff with the time the page was made\n' +
122
16
  ' --return carries a one-use return URL from document.create_return; Share offers Send back\n' +
123
17
  ' --return-expires-at carries the same mint\'s expiry; Share offers Save once the return expires\n' +
@@ -135,7 +29,7 @@ export async function main(argv, pagesDir, {log = console.log} = {}) {
135
29
  if (view !== null || !['draw', 'notes'].includes(argv[i + 1])) throw new Error('--view takes draw or notes');
136
30
  view = argv[++i];
137
31
  }
138
- else if (arg === '--base') {
32
+ else if (arg === '--base' || arg === '--propose') {
139
33
  if (basePath !== null || !argv[i + 1] || argv[i + 1].startsWith('-')) throw new Error('--base takes one text file (write a name that starts with a dash as ./-original.md)');
140
34
  basePath = argv[++i];
141
35
  }
@@ -164,7 +58,7 @@ export async function main(argv, pagesDir, {log = console.log} = {}) {
164
58
  const wrapOptions = {};
165
59
  if (drawingPath) { wrapOptions.drawing = await readFile(drawingPath, 'utf8'); wrapOptions.drawingName = basename(drawingPath); }
166
60
  if (by !== null && !basePath) throw new Error('--by names who proposes a change: give the --base it proposes over');
167
- if (basePath) { wrapOptions.base = await readFile(basePath, 'utf8'); wrapOptions.baseName = basename(basePath); }
61
+ if (basePath) { wrapOptions.base = await readFile(basePath, 'utf8'); wrapOptions.baseName = basename(basePath); wrapOptions.at = new Date().toISOString(); }
168
62
  if (by !== null) { wrapOptions.by = by; wrapOptions.at = new Date().toISOString(); }
169
63
  if (view) wrapOptions.view = view;
170
64
  if (address) wrapOptions.return = address;