rapier-html 1.1.0 → 1.1.2

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.0",
3
+ "version": "1.1.2",
4
4
  "path": "rapier.html",
5
- "bytes": 1733515,
6
- "sha256": "b75dedcb78f9bd60ae04d896bbe3f4606b2127e6fa4275ada92c0b668c7e5694",
7
- "builtAt": "2026-09-26T13:30:00.640Z",
5
+ "bytes": 1814968,
6
+ "sha256": "14f9723eb1d16fdeb39830aed5cbb0d4a71d304c7f859cbcc0662fbd7fafff3a",
7
+ "builtAt": "2026-09-27T17:48:31.488Z",
8
8
  "node": "v22.22.2",
9
9
  "canonical": true,
10
10
  "packing": "zopfli",
package/README.md CHANGED
@@ -1,87 +1,120 @@
1
1
  # rapier-html
2
2
 
3
- Rapier with your document inside it. One command turns a Markdown file into a single HTML page
4
- that is the whole Rapier editor, carrying the document's exact text: it opens anywhere a browser
5
- is, offline, with no account. Read it, edit it, save it to the device, share it on. Agents hand
6
- people documents this way; people hand them back the same way.
7
-
8
- Nothing is fetched to open, edit or save the page, and nothing reports on its use. It goes to the
9
- network only when the document or the person asks: a picture or video the document links to on the
10
- web, or the math, diagram and PDF-import helpers, fetched once from a pinned CDN when first needed.
11
-
12
- ## Use it
13
-
14
- Node 22 or newer:
3
+ Rapier, the offline Markdown editor, as one HTML page with your document inside it. No account, no
4
+ install, nothing fetched to open, edit or save. Node 22 or newer.
15
5
 
16
6
  ```sh
17
- npx -- rapier-html notes.md # writes notes.rapier.html beside it
18
- npx -- rapier-html notes.md out.html # a named output
19
- npx -- rapier-html notes.md --drawing sketch.svg # carries a drawing, opened on Draw as the page boots
20
- npx -- rapier-html --help
7
+ npx -- rapier-html notes.md # the editor, notes.md inside it, written beside the file
8
+ npx -- rapier-html notes.md --view draw # opens on Draw: sketch and paint, the document behind it
9
+ npx -- rapier-html notes.md --view notes # opens on Notes: the document as cards
10
+ npx -- rapier-html notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
11
+ npx -- rapier-html proposal.md --base original.md # opens on the diff of a proposed change
12
+ npx -- rapier-html notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
13
+ npx -- rapier-html notes.md out.html # a named output
14
+ npm install rapier-html # as a library: wrap, unwrap
21
15
  ```
22
16
 
23
- The first `--` is for npm: without it `npx` keeps `--help`, and a later `--`, for itself.
24
-
25
- **It never overwrites a file.** An output that already exists -- the document itself included, or a
26
- link to it -- is refused and nothing is written; name a new one. An option it does not know, a
27
- `--drawing` with no file and a third filename are refused as well, so a typo cannot quietly make a
28
- different page. `--` makes everything after it a filename: `npx -- rapier-html -- --draft.md`.
17
+ The first `--` is for npm, so `--help` reaches the command. It never overwrites a file.
18
+
19
+ ## What the page can do
20
+
21
+ **Write**
22
+ - Markdown the way it reads: headings, lists, checklists, tables, code, quotes, footnotes, pictures placed
23
+ around text.
24
+ - Find and replace, Undo, a source view of the exact bytes.
25
+ - Math and diagrams on demand, fetched once from a pinned address only when a document asks.
26
+
27
+ **Draw and paint**
28
+ - Shapes, lines, arrows, text on a canvas that goes into the document as a picture.
29
+ - Paint with real brushes: pencil, pen, ink, watercolour that runs and dries, oil that mixes, a smudge
30
+ finger; dip the brush for more paint or more water.
31
+ - Your own brush files, a dropper, paint laid over the drawing as a layer of its own.
32
+
33
+ **Notes**
34
+ - The document as cards: sections, drag to reorder, search across every note.
35
+ - Voice recordings, reminders, a recycle bin that keeps seven days.
36
+ - Import from Keep, Notion, Evernote, Joplin, Bear, Simplenote, OneNote, Obsidian and more.
37
+
38
+ **Review a change**
39
+ - Open on the diff of a proposal against the original; keep or drop each change; nothing applies until
40
+ the person says so.
41
+ - Will markers in the Markdown say what an agent may change: `keep`, `append`, `edit`.
42
+
43
+ **Keep it**
44
+ - Save to the device, share the page on, open it again anywhere a browser is; offline throughout.
45
+ - Export the document as Markdown or as a web page; print it.
46
+
47
+ **For agents**
48
+ - Hand a person a document, a sketch or a change as one file that opens with one click.
49
+ - What they save comes back byte for byte through `unwrap`.
50
+ - Add the return URL from `document.create_return`: the person edits the page and presses Send back;
51
+ `document.wait_for_user` wakes the agent, which reads the exact returned Markdown and continues.
52
+
53
+ ## Send an edited document back
54
+
55
+ Call `document.create_return` on the Rapier agent door for the document's workspace. Pass its
56
+ `return_url` and `return_expires_at` as `--return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT"`
57
+ or `wrap(..., {return: return_url, return_expires_at})`. Both values come from the same mint. Only the worker's
58
+ HTTPS return route at `https://mcp.rapier.website/return/…` is accepted. The page shows Send back
59
+ in Share; a page without an address has no such action.
60
+
61
+ The person reads and edits offline, then presses Send back when ready. That press sends the current
62
+ source and filename once, and shows acceptance or the worker's refusal. After acceptance, that page
63
+ sends nothing again. Save to the device stays local. The return URL lasts up to one day, accepts one
64
+ document up to 25 MiB, and grants no read or edit access to the agent's workspace.
65
+
66
+ Once the carried expiry passes, Share offers Save in place of Send back: the work is safe on the page.
67
+ A used or expired return refused by the worker offers the same Save. Saving keeps the current words,
68
+ filename, byte-order mark and line endings through the normal local save path. Bring that file back to the
69
+ assistant, which compares it against the workspace's current work and creates a fresh return for the next
70
+ handoff; the old capability stays spent.
71
+
72
+ The agent's `document.wait_for_user` returns a `returned` receipt with the `return_id`, name and time.
73
+ `document.get_context` lists receipts even when no agent was waiting. Read the source with
74
+ `document.read_context({return_id, start: 0})`, continuing at the returned `end` until `complete`.
75
+ The returned copy stays separate from the workspace's current document, so newer work survives too.
29
76
 
30
77
  ## As a library
31
78
 
32
- Save this as `example.mjs` in the folder you installed into (`npm install rapier-html`) and run
33
- `node example.mjs`:
79
+ Save this as `example.mjs` in the folder you installed into and run `node example.mjs`:
34
80
 
35
81
  ```js
36
82
  import {readFile, writeFile} from 'node:fs/promises';
37
83
  import {wrap, unwrap} from 'rapier-html';
38
84
 
39
- // The page inside the installed package.
40
85
  const rapier = await readFile(new URL('./rapier.html', import.meta.resolve('rapier-html')), 'utf8');
41
-
42
86
  const words = '# Hello\r\n\r\nA note about </script> and C:\\Users\\me.\n';
43
- const page = wrap(rapier, words, 'hello.md'); // Rapier carrying the document
44
- // A drawing rides along, opened on Draw right after the document lands (Done places it in).
45
- const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="6"/></svg>';
46
- const both = wrap(rapier, words, 'hello.md', {drawing: svg, drawingName: 'sketch.svg'});
47
-
48
- console.log('words back exactly:', unwrap(page).text === words, '| drawing back exactly:', unwrap(both).drawing === svg);
49
- await writeFile('hello.rapier.html', page, {flag: 'wx'}); // 'wx': never over an existing file
87
+ const page = wrap(rapier, words, 'hello.md', {view: 'draw'}); // opens on Draw, the note behind it
88
+ console.log('words back exactly:', unwrap(page).text === words);
89
+ await writeFile('hello.rapier.html', page, {flag: 'wx'}); // 'wx': never over an existing file
50
90
  ```
51
91
 
52
- It prints `words back exactly: true | drawing back exactly: true` and writes `hello.rapier.html`.
53
- The package exports `wrap`, `unwrap`, `encodeCarried`, `decodeCarried` and the command's `main`;
54
- `unwrap` returns `{html, text, name, drawing, drawingName}`, with `null` for what a page does not
55
- carry.
56
-
57
- ## What the page carries
58
-
59
- The page carries the document as one `<script type="text/markdown" id="rapier-document">` block
60
- at the top of the body; the browser never executes it and Rapier reads it once at boot in place
61
- of the Welcome. A second, optional `<script type="text/plain" id="rapier-drawing">` block carries
62
- a drawing Rapier made (its SVG carries the drawing's recipe in its own metadata); Rapier opens Draw
63
- on that recipe once the document is up, the document behind it. Wrapping is a byte insertion, not
64
- a build: a second wrap replaces the blocks it is given.
65
-
66
- **Your words come back byte for byte, whatever is in them.** A script element's text is not inert
67
- to an HTML parser: `<!--` followed by `<script` puts it in a state where the block's own
68
- `</script>` stops closing it and the rest of the page is swallowed, CRLF and a lone carriage
69
- return are rewritten to a newline, and NUL becomes U+FFFD. A note *about* HTML is enough to hit
70
- the first. So a carried block holds no `<` before `/` or `!`, no carriage return and no NUL: a
71
- backslash is written `\\`, `</` is `\/`, `<!` is `\!`, a carriage return is `\r`, a NUL is
72
- `\0`, and nothing else changes. One pass each way, exactly reversible, and the same size as your
73
- words except where they already held a backslash. A byte-order mark is kept as a fact of the file
74
- and written back on Save, not folded into the text.
75
-
76
- `unwrap` reverses exactly what `wrap` wrote, and the editor inside the page reads by the same law,
77
- so a page always reads its own bytes correctly. The command reads the document as UTF-8 text.
92
+ `wrap(rapierHtml, text, name, {view, drawing, drawingName, base, baseName, return: returnURL, return_expires_at})` and `unwrap(pageHtml)`,
93
+ which returns `{html, text, name, view, drawing, drawingName, base, baseName, return: returnURL, return_expires_at}` with `null` for what a
94
+ page does not carry. Also exported: `encodeCarried`, `decodeCarried`, `main`.
78
95
 
79
- ## Licence
96
+ ## How the page carries the document
97
+
98
+ One `<script type="text/markdown" id="rapier-document">` block at the top of the body, never executed,
99
+ read once at boot. A drawing rides in `id="rapier-drawing"`, the text a change was proposed against in
100
+ `id="rapier-base"`, the view to open on in the document block's `data-view`, the optional return URL
101
+ in `data-return` and its exact expiry in `data-return-expires-at`. The block holds no `<`
102
+ before `/` or `!`, no carriage return and no NUL: `\\`, `\/`, `\!`, `\r` and `\0` stand for them, one
103
+ reversible pass each way, so a note about HTML is as safe as any other. A byte-order mark is kept as a
104
+ fact of the file and written back on Save.
80
105
 
81
- AGPL-3.0-only, the full text in `LICENSE`: the page is the Rapier editor, with the notices of the
82
- libraries it carries. For the Markdown standard without the editor there is `rapier-markdown-kit`
83
- (MIT). Rapier is https://rapier.website.
106
+ ## Safety
84
107
 
85
- ## The page inside
108
+ - It never overwrites: an output that exists, the document itself included, is refused. Unknown options,
109
+ a `--view` that is not `draw` or `notes`, a `--drawing` or `--base` with no file, a missing or repeated
110
+ `--return` or `--return-expires-at`, a return without its expiry, an invalid expiry, an unsafe return URL and a third filename are refused too.
111
+ - Nothing reports on the page's use. It reaches the network only when the document or the person asks:
112
+ a picture or video the document links to, the math, diagram and PDF-import helpers on first use,
113
+ or the person's Send back press. A return URL names only the worker's exact origin and return route;
114
+ credentials, query strings and fragments are refused by both the writer and carried-page boot.
115
+
116
+ ## Licence
86
117
 
87
- Its version is the version of the Rapier inside it. `BUILD.json` records the page's size, SHA-256, build time and Node version.
118
+ AGPL-3.0-only (`LICENSE`): the page is the Rapier editor with the notices of the libraries it carries.
119
+ The Markdown standard without the editor is `rapier-markdown-kit` (MIT). Rapier is https://rapier.website.
120
+ `BUILD.json` records the page's size, SHA-256, build time and Node version.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rapier-html",
3
- "version": "1.1.0",
4
- "description": "Rapier with your document inside it: wrap a Markdown file into a single offline HTML page that is the whole Rapier editor, opened anywhere a browser is.",
3
+ "version": "1.1.2",
4
+ "description": "Rapier, the offline Markdown editor, as one HTML page with your document inside it: write, draw and paint, notes, the diff of a change; no account, nothing fetched.",
5
5
  "keywords": [
6
6
  "markdown",
7
7
  "editor",
@@ -33,6 +33,7 @@
33
33
  "files": [
34
34
  "cli.mjs",
35
35
  "page.mjs",
36
+ "return-address.mjs",
36
37
  "rapier.html",
37
38
  "README.md",
38
39
  "LICENSE",
package/page.mjs CHANGED
@@ -1,8 +1,11 @@
1
1
  import {readFile, writeFile} from 'node:fs/promises';
2
2
  import {basename, dirname, join} from 'node:path';
3
+ import {returnAddress, returnExpiresAt} from './return-address.mjs';
3
4
 
4
5
  const START = '<script type="text/markdown" id="rapier-document"';
5
6
  const DRAWING_START = '<script type="text/plain" id="rapier-drawing"';
7
+ // The text the document was proposed against (docs/page-door.md, "2. A change carried"): the page opens on its diff.
8
+ const BASE_START = '<script type="text/markdown" id="rapier-base"';
6
9
  const END = '</script>';
7
10
 
8
11
  // One encoding, exactly reversible (docs/page-door.md, "What a block may hold"): no `<` before `/` or `!`, no CR, no NUL.
@@ -26,12 +29,22 @@ export function wrap(pageHtml, text, name, options) {
26
29
  if (typeof text !== 'string') throw new Error('the document must be text');
27
30
  const opts = options || {};
28
31
  const docName = safeName(name, 'document.md');
29
- const block = START + ' data-name="' + docName + '">' + encodeCarried(text) + END;
30
- let drawingBlock = '';
32
+ // `view`: the view the page opens on with the document behind it, `draw` or `notes` (docs/page-door.md).
33
+ if (opts.view != null && !['draw', 'notes'].includes(opts.view)) throw new Error('the view is draw or notes');
34
+ const address = opts.return == null ? null : returnAddress(opts.return);
35
+ const expiry = opts.return_expires_at == null ? null : returnExpiresAt(opts.return_expires_at);
36
+ if (!!address !== !!expiry) throw new Error('a return needs its URL and return_expires_at from document.create_return');
37
+ const block = START + ' data-name="' + docName + '"' + (opts.view ? ' data-view="' + opts.view + '"' : '') +
38
+ (address ? ' data-return="' + address + '" data-return-expires-at="' + expiry + '"' : '') + '>' + encodeCarried(text) + END;
39
+ let carriedBlocks = '';
31
40
  if (opts.drawing != null) {
32
41
  if (typeof opts.drawing !== 'string') throw new Error('the drawing must be SVG text');
33
42
  const drawingName = safeName(opts.drawingName, 'drawing.svg');
34
- drawingBlock = '\n' + DRAWING_START + ' data-name="' + drawingName + '">' + encodeCarried(opts.drawing) + END;
43
+ carriedBlocks = '\n' + DRAWING_START + ' data-name="' + drawingName + '">' + encodeCarried(opts.drawing) + END;
44
+ }
45
+ if (opts.base != null) {
46
+ if (typeof opts.base !== 'string') throw new Error('the base must be text');
47
+ carriedBlocks += '\n' + BASE_START + ' data-name="' + safeName(opts.baseName, docName) + '">' + encodeCarried(opts.base) + END;
35
48
  }
36
49
  const stripped = unwrap(pageHtml).html;
37
50
  // Before Rapier's scripts: parsed before the engine boots.
@@ -41,15 +54,21 @@ export function wrap(pageHtml, text, name, options) {
41
54
  // A function, not a string: `$'`, `$&` and `` $` `` are replacement patterns.
42
55
  const titled = stripped.replace(/<title>[^<]*<\/title>/, () => '<title>' + docName.replace(/&/g, '&amp;').replace(/</g, '&lt;') + ' — Rapier</title>');
43
56
  const shift = titled.length - stripped.length;
44
- return titled.slice(0, at + shift) + '\n' + block + drawingBlock + titled.slice(at + shift);
57
+ return titled.slice(0, at + shift) + '\n' + block + carriedBlocks + titled.slice(at + shift);
45
58
  }
46
59
  export function unwrap(pageHtml) {
47
- let html = pageHtml, text = null, name = null;
60
+ let html = pageHtml, text = null, name = null, view = null, address = null, expiry = null;
48
61
  const a = pageHtml.indexOf(START);
49
62
  if (a >= 0) {
50
63
  const open = pageHtml.indexOf('>', a), b = pageHtml.indexOf(END, open);
51
64
  if (open < 0 || b < 0) throw new Error('a carried document without its end');
52
65
  name = /data-name="([^"]*)"/.exec(pageHtml.slice(a, open))?.[1] ?? null;
66
+ view = /data-view="(draw|notes)"/.exec(pageHtml.slice(a, open))?.[1] ?? null;
67
+ const carriedReturn = /data-return="([^"]*)"/.exec(pageHtml.slice(a, open));
68
+ if (carriedReturn) address = returnAddress(carriedReturn[1]);
69
+ const carriedExpiry = /data-return-expires-at="([^"]*)"/.exec(pageHtml.slice(a, open));
70
+ if (carriedExpiry) expiry = returnExpiresAt(carriedExpiry[1]);
71
+ if (!!address !== !!expiry) throw new Error('a carried return needs its URL and return_expires_at');
53
72
  text = decodeCarried(pageHtml.slice(open + 1, b));
54
73
  html = pageHtml.slice(0, a) + pageHtml.slice(b + END.length);
55
74
  if (a > 0 && html[a - 1] === '\n') html = html.slice(0, a - 1) + html.slice(a);
@@ -64,23 +83,53 @@ export function unwrap(pageHtml) {
64
83
  html = html.slice(0, d) + html.slice(b + END.length);
65
84
  if (d > 0 && html[d - 1] === '\n') html = html.slice(0, d - 1) + html.slice(d);
66
85
  }
67
- return {html, text, name, drawing, drawingName};
86
+ let base = null, baseName = null;
87
+ const e = html.indexOf(BASE_START);
88
+ if (e >= 0) {
89
+ const open = html.indexOf('>', e), b = html.indexOf(END, open);
90
+ if (open < 0 || b < 0) throw new Error('a carried base without its end');
91
+ baseName = /data-name="([^"]*)"/.exec(html.slice(e, open))?.[1] ?? null;
92
+ base = decodeCarried(html.slice(open + 1, b));
93
+ html = html.slice(0, e) + html.slice(b + END.length);
94
+ if (e > 0 && html[e - 1] === '\n') html = html.slice(0, e - 1) + html.slice(e);
95
+ }
96
+ return {html, text, name, view, drawing, drawingName, base, baseName, return: address, return_expires_at: expiry};
68
97
  }
69
- // Unknown options, a bare --drawing and a third name are refused, never dropped.
70
- const USAGE = 'usage: rapier-html <document.md> [out.html] [--drawing sketch.svg]\n' +
98
+ // Unknown options, a bare --drawing or --base and a third name are refused, never dropped.
99
+ const USAGE = 'usage: rapier-html <document.md> [out.html] [--view draw|notes] [--drawing sketch.svg] [--base original.md] [--return <url> --return-expires-at <timestamp>]\n' +
71
100
  ' writes <document>.rapier.html beside the file: Rapier with the document inside it, one file, offline.\n' +
72
101
  ' It never overwrites: an output that already exists, the document itself included, is refused.\n' +
102
+ ' --view opens the page on Draw (sketch and paint, the document behind it) or on Notes\n' +
73
103
  ' --drawing carries an SVG drawing alongside the document, opened on Draw as the page boots\n' +
104
+ ' --base carries the text the document was proposed against; the page opens on their diff\n' +
105
+ ' --return carries a one-use return URL from document.create_return; Share offers Send back\n' +
106
+ ' --return-expires-at carries the same mint\'s expiry; Share offers Save once the return expires\n' +
74
107
  ' -- everything after it is a filename, even one that starts with a dash\n' +
75
108
  ' --help this text';
76
109
  export async function main(argv, pagesDir, {log = console.log} = {}) {
77
110
  const names = [];
78
- let drawingPath = null, onlyNames = false;
111
+ let drawingPath = null, basePath = null, view = null, address = null, expiry = null, onlyNames = false;
79
112
  for (let i = 0; i < argv.length; i++) {
80
113
  const arg = argv[i];
81
114
  if (onlyNames || !arg.startsWith('-')) names.push(arg);
82
115
  else if (arg === '--') onlyNames = true;
83
116
  else if (arg === '--help') { log(USAGE); return null; }
117
+ else if (arg === '--view') {
118
+ if (view !== null || !['draw', 'notes'].includes(argv[i + 1])) throw new Error('--view takes draw or notes');
119
+ view = argv[++i];
120
+ }
121
+ else if (arg === '--base') {
122
+ 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)');
123
+ basePath = argv[++i];
124
+ }
125
+ else if (arg === '--return') {
126
+ if (address !== null || !argv[i + 1] || argv[i + 1].startsWith('-')) throw new Error('--return takes one HTTPS return URL');
127
+ address = returnAddress(argv[++i]);
128
+ }
129
+ else if (arg === '--return-expires-at') {
130
+ if (expiry !== null || !argv[i + 1] || argv[i + 1].startsWith('-')) throw new Error('--return-expires-at takes one ISO timestamp from document.create_return');
131
+ expiry = returnExpiresAt(argv[++i]);
132
+ }
84
133
  else if (arg !== '--drawing') throw new Error('unknown option ' + arg + '\n' + USAGE);
85
134
  else if (drawingPath !== null || !argv[i + 1] || argv[i + 1].startsWith('-')) throw new Error('--drawing takes one SVG file (write a name that starts with a dash as ./-sketch.svg)');
86
135
  else drawingPath = argv[++i];
@@ -93,10 +142,14 @@ export async function main(argv, pagesDir, {log = console.log} = {}) {
93
142
  const out = named || join(dirname(input), name.replace(/\.(md|markdown|txt)$/i, '') + '.rapier.html');
94
143
  const wrapOptions = {};
95
144
  if (drawingPath) { wrapOptions.drawing = await readFile(drawingPath, 'utf8'); wrapOptions.drawingName = basename(drawingPath); }
145
+ if (basePath) { wrapOptions.base = await readFile(basePath, 'utf8'); wrapOptions.baseName = basename(basePath); }
146
+ if (view) wrapOptions.view = view;
147
+ if (address) wrapOptions.return = address;
148
+ if (expiry) wrapOptions.return_expires_at = expiry;
96
149
  // R85b: always a new file; 'wx' refuses any existing path, links included.
97
150
  try { await writeFile(out, wrap(page, text, name, wrapOptions), {flag: 'wx'}); }
98
151
  catch (error) { throw error.code === 'EEXIST' ? new Error(out + ' already exists, and rapier-html never overwrites a file: name a new one') : error; }
99
152
  log(out + ' (' + name + ', ' + Buffer.byteLength(text) + ' bytes of document' +
100
- (drawingPath ? ', ' + basename(drawingPath) + ' drawing' : '') + ')');
153
+ (view ? ', opens on ' + (view === 'draw' ? 'Draw' : 'Notes') : '') + (drawingPath ? ', ' + basename(drawingPath) + ' drawing' : '') + (basePath ? ', its diff from ' + basename(basePath) : '') + ')');
101
154
  return out;
102
155
  }