rapier-html 1.1.59 → 1.1.63
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 +4 -4
- package/README.md +8 -8
- package/dist/ledger/carried.mjs +51 -0
- package/dist/page.mjs +8 -118
- package/dist/rapier.html +174 -142
- package/dist/wrap.mjs +108 -0
- package/package.json +1 -1
package/BUILD.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package": "rapier-html",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.63",
|
|
4
4
|
"path": "dist/rapier.html",
|
|
5
|
-
"bytes":
|
|
6
|
-
"sha256": "
|
|
7
|
-
"builtAt": "2026-10-
|
|
5
|
+
"bytes": 1841901,
|
|
6
|
+
"sha256": "b320b93379a1b2d52c03db9a8494f14c187e3b8e1c6535a42aad679387a65718",
|
|
7
|
+
"builtAt": "2026-10-05T06:48:14.140Z",
|
|
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.
|
|
18
|
-
npx -- rapier-html@1.1.
|
|
19
|
-
npx -- rapier-html@1.1.
|
|
20
|
-
npx -- rapier-html@1.1.
|
|
21
|
-
npx -- rapier-html@1.1.
|
|
22
|
-
npx -- rapier-html@1.1.
|
|
23
|
-
npx -- rapier-html@1.1.
|
|
24
|
-
npm install rapier-html@1.1.
|
|
17
|
+
npx -- rapier-html@1.1.63 notes.md # the editor, notes.md inside it, written beside the file
|
|
18
|
+
npx -- rapier-html@1.1.63 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
|
|
19
|
+
npx -- rapier-html@1.1.63 notes.md --view notes # opens the Notes library
|
|
20
|
+
npx -- rapier-html@1.1.63 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
|
|
21
|
+
npx -- rapier-html@1.1.63 proposal.md --base original.md # opens on the diff of a proposed change
|
|
22
|
+
npx -- rapier-html@1.1.63 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
|
|
23
|
+
npx -- rapier-html@1.1.63 notes.md out.html # a named output
|
|
24
|
+
npm install rapier-html@1.1.63 # 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.
|
package/dist/ledger/carried.mjs
CHANGED
|
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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,127 +1,17 @@
|
|
|
1
|
-
|
|
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 {
|
|
5
|
-
|
|
6
|
-
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
// As with the proposed document, validate saved parts before carrying only their source.
|
|
50
|
-
// A mismatched history is refused here, never displayed as part of the baseline.
|
|
51
|
-
const base = readDocument(opts.base).text;
|
|
52
|
-
// A proposal page (docs/briefs/ledger.md section 3): who proposed it, in the host's own words, and when.
|
|
53
|
-
const by = opts.by == null ? null : String(opts.by).replace(/[\0-\x1f\x7f]/g, ' ').trim().slice(0, 96);
|
|
54
|
-
const at = opts.at == null ? null : String(opts.at);
|
|
55
|
-
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');
|
|
56
|
-
// Equal versions are a first draft, not a second carried document.
|
|
57
|
-
if (base !== text) carriedBlocks += '\n' + BASE_START + ' data-name="' + safeName(opts.baseName, docName) + '"' + (by ? ' data-by="' + attribute(by) + '"' : '') +
|
|
58
|
-
(at ? ' data-at="' + at + '"' : '') + '>' + encodeCarried(base) + END;
|
|
59
|
-
}
|
|
60
|
-
// The agent seed goes after the charset (or <head>), once: a page that already carries it, wrapped again, loses its old one.
|
|
61
|
-
const unseeded = unwrap(pageHtml).html.replace(SEARCH_WORDS, '').split(PAGE_SEED + '\n').join('');
|
|
62
|
-
const headTag = /<meta charset[^>]*>\n?/i.exec(unseeded) || /<head[^>]*>\n?/.exec(unseeded);
|
|
63
|
-
if (!headTag) throw new Error('not a Rapier page: no <head>');
|
|
64
|
-
const seededAt = headTag.index + headTag[0].length;
|
|
65
|
-
const stripped = unseeded.slice(0, seededAt) + PAGE_SEED + '\n' + unseeded.slice(seededAt);
|
|
66
|
-
// Before Rapier's scripts: parsed before the engine boots.
|
|
67
|
-
const bodyTag = /<body[^>]*>/.exec(stripped);
|
|
68
|
-
if (!bodyTag) throw new Error('not a Rapier page: no <body>');
|
|
69
|
-
const at = bodyTag.index + bodyTag[0].length;
|
|
70
|
-
// A function, not a string: `$'`, `$&` and `` $` `` are replacement patterns.
|
|
71
|
-
const titled = stripped.replace(/<title>[^<]*<\/title>/, () => '<title>' + docName.replace(/&/g, '&').replace(/</g, '<') + ' — Rapier</title>');
|
|
72
|
-
const shift = titled.length - stripped.length;
|
|
73
|
-
return titled.slice(0, at + shift) + '\n' + block + carriedBlocks + titled.slice(at + shift);
|
|
74
|
-
}
|
|
75
|
-
export function unwrap(pageHtml) {
|
|
76
|
-
let html = pageHtml, text = null, name = null, view = null, address = null, expiry = null;
|
|
77
|
-
const a = pageHtml.indexOf(START);
|
|
78
|
-
if (a >= 0) {
|
|
79
|
-
const open = pageHtml.indexOf('>', a), b = pageHtml.indexOf(END, open);
|
|
80
|
-
if (open < 0 || b < 0) throw new Error('a carried document without its end');
|
|
81
|
-
name = /data-name="([^"]*)"/.exec(pageHtml.slice(a, open))?.[1] ?? null;
|
|
82
|
-
view = /data-view="(draw|notes)"/.exec(pageHtml.slice(a, open))?.[1] ?? null;
|
|
83
|
-
const carriedReturn = /data-return="([^"]*)"/.exec(pageHtml.slice(a, open));
|
|
84
|
-
if (carriedReturn) address = returnAddress(carriedReturn[1]);
|
|
85
|
-
const carriedExpiry = /data-return-expires-at="([^"]*)"/.exec(pageHtml.slice(a, open));
|
|
86
|
-
if (carriedExpiry) expiry = returnExpiresAt(carriedExpiry[1]);
|
|
87
|
-
if (!!address !== !!expiry) throw new Error('a carried return needs its URL and return_expires_at');
|
|
88
|
-
text = decodeCarried(pageHtml.slice(open + 1, b));
|
|
89
|
-
html = pageHtml.slice(0, a) + pageHtml.slice(b + END.length);
|
|
90
|
-
if (a > 0 && html[a - 1] === '\n') html = html.slice(0, a - 1) + html.slice(a);
|
|
91
|
-
}
|
|
92
|
-
let drawing = null, drawingName = null;
|
|
93
|
-
const d = html.indexOf(DRAWING_START);
|
|
94
|
-
if (d >= 0) {
|
|
95
|
-
const open = html.indexOf('>', d), b = html.indexOf(END, open);
|
|
96
|
-
if (open < 0 || b < 0) throw new Error('a carried drawing without its end');
|
|
97
|
-
drawingName = /data-name="([^"]*)"/.exec(html.slice(d, open))?.[1] ?? null;
|
|
98
|
-
drawing = decodeCarried(html.slice(open + 1, b));
|
|
99
|
-
html = html.slice(0, d) + html.slice(b + END.length);
|
|
100
|
-
if (d > 0 && html[d - 1] === '\n') html = html.slice(0, d - 1) + html.slice(d);
|
|
101
|
-
}
|
|
102
|
-
let base = null, baseName = null, by = null, at = null;
|
|
103
|
-
const e = html.indexOf(BASE_START);
|
|
104
|
-
if (e >= 0) {
|
|
105
|
-
const open = html.indexOf('>', e), b = html.indexOf(END, open);
|
|
106
|
-
if (open < 0 || b < 0) throw new Error('a carried base without its end');
|
|
107
|
-
baseName = /data-name="([^"]*)"/.exec(html.slice(e, open))?.[1] ?? null;
|
|
108
|
-
const proposer = /data-by="([^"]*)"/.exec(html.slice(e, open))?.[1];
|
|
109
|
-
by = proposer == null ? null : unattribute(proposer);
|
|
110
|
-
at = /data-at="([^"]*)"/.exec(html.slice(e, open))?.[1] ?? null;
|
|
111
|
-
base = decodeCarried(html.slice(open + 1, b));
|
|
112
|
-
html = html.slice(0, e) + html.slice(b + END.length);
|
|
113
|
-
if (e > 0 && html[e - 1] === '\n') html = html.slice(0, e - 1) + html.slice(e);
|
|
114
|
-
}
|
|
115
|
-
const parts = takeParts(html, text);
|
|
116
|
-
return {...parts, text, name, view, drawing, drawingName, base, baseName, by, at, return: address, return_expires_at: expiry};
|
|
117
|
-
}
|
|
4
|
+
import {wrap} from './wrap.mjs';
|
|
5
|
+
import {returnAddress, returnExpiresAt} from './return-address.mjs';
|
|
6
|
+
export {wrap, unwrap, encodeCarried, decodeCarried} from './wrap.mjs';
|
|
118
7
|
// Unknown options, a bare --drawing or --base and a third name are refused, never dropped.
|
|
119
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' +
|
|
120
9
|
' writes <document>.rapier.html beside the file: Rapier with the document inside it, one file, offline.\n' +
|
|
121
10
|
' It never overwrites: an output that already exists, the document itself included, is refused.\n' +
|
|
122
11
|
' --view opens the page on Draw (sketch and paint, the document behind it) or on Notes\n' +
|
|
123
12
|
' --drawing carries an SVG drawing alongside the document, opened on Draw as the page boots\n' +
|
|
124
|
-
' --base
|
|
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' +
|
|
125
15
|
' --by with --base: who proposes it, shown on the diff with the time the page was made\n' +
|
|
126
16
|
' --return carries a one-use return URL from document.create_return; Share offers Send back\n' +
|
|
127
17
|
' --return-expires-at carries the same mint\'s expiry; Share offers Save once the return expires\n' +
|
|
@@ -139,7 +29,7 @@ export async function main(argv, pagesDir, {log = console.log} = {}) {
|
|
|
139
29
|
if (view !== null || !['draw', 'notes'].includes(argv[i + 1])) throw new Error('--view takes draw or notes');
|
|
140
30
|
view = argv[++i];
|
|
141
31
|
}
|
|
142
|
-
else if (arg === '--base') {
|
|
32
|
+
else if (arg === '--base' || arg === '--propose') {
|
|
143
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)');
|
|
144
34
|
basePath = argv[++i];
|
|
145
35
|
}
|
|
@@ -168,7 +58,7 @@ export async function main(argv, pagesDir, {log = console.log} = {}) {
|
|
|
168
58
|
const wrapOptions = {};
|
|
169
59
|
if (drawingPath) { wrapOptions.drawing = await readFile(drawingPath, 'utf8'); wrapOptions.drawingName = basename(drawingPath); }
|
|
170
60
|
if (by !== null && !basePath) throw new Error('--by names who proposes a change: give the --base it proposes over');
|
|
171
|
-
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(); }
|
|
172
62
|
if (by !== null) { wrapOptions.by = by; wrapOptions.at = new Date().toISOString(); }
|
|
173
63
|
if (view) wrapOptions.view = view;
|
|
174
64
|
if (address) wrapOptions.return = address;
|