rapier-html 1.1.0
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 +12 -0
- package/LICENSE +661 -0
- package/README.md +87 -0
- package/cli.mjs +5 -0
- package/package.json +42 -0
- package/page.mjs +102 -0
- package/rapier.html +179 -0
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# rapier-html
|
|
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:
|
|
15
|
+
|
|
16
|
+
```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
|
|
21
|
+
```
|
|
22
|
+
|
|
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`.
|
|
29
|
+
|
|
30
|
+
## As a library
|
|
31
|
+
|
|
32
|
+
Save this as `example.mjs` in the folder you installed into (`npm install rapier-html`) and run
|
|
33
|
+
`node example.mjs`:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
import {readFile, writeFile} from 'node:fs/promises';
|
|
37
|
+
import {wrap, unwrap} from 'rapier-html';
|
|
38
|
+
|
|
39
|
+
// The page inside the installed package.
|
|
40
|
+
const rapier = await readFile(new URL('./rapier.html', import.meta.resolve('rapier-html')), 'utf8');
|
|
41
|
+
|
|
42
|
+
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
|
|
50
|
+
```
|
|
51
|
+
|
|
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.
|
|
78
|
+
|
|
79
|
+
## Licence
|
|
80
|
+
|
|
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.
|
|
84
|
+
|
|
85
|
+
## The page inside
|
|
86
|
+
|
|
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.
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {dirname} from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {main} from './page.mjs';
|
|
5
|
+
main(process.argv.slice(2), dirname(fileURLToPath(import.meta.url))).catch(error => { console.error(String(error?.message || error)); process.exit(1); });
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
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.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"markdown",
|
|
7
|
+
"editor",
|
|
8
|
+
"offline",
|
|
9
|
+
"single-file",
|
|
10
|
+
"html",
|
|
11
|
+
"agent",
|
|
12
|
+
"mcp",
|
|
13
|
+
"rapier"
|
|
14
|
+
],
|
|
15
|
+
"author": "Jack Skipworth",
|
|
16
|
+
"license": "AGPL-3.0-only",
|
|
17
|
+
"homepage": "https://rapier.website",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/jackskip22/rapier-plugins.git",
|
|
21
|
+
"directory": "npm/rapier-html"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22"
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"rapier-html": "cli.mjs"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./page.mjs"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"cli.mjs",
|
|
35
|
+
"page.mjs",
|
|
36
|
+
"rapier.html",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"BUILD.json"
|
|
40
|
+
],
|
|
41
|
+
"sideEffects": false
|
|
42
|
+
}
|
package/page.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {readFile, writeFile} from 'node:fs/promises';
|
|
2
|
+
import {basename, dirname, join} from 'node:path';
|
|
3
|
+
|
|
4
|
+
const START = '<script type="text/markdown" id="rapier-document"';
|
|
5
|
+
const DRAWING_START = '<script type="text/plain" id="rapier-drawing"';
|
|
6
|
+
const END = '</script>';
|
|
7
|
+
|
|
8
|
+
// One encoding, exactly reversible (docs/page-door.md, "What a block may hold"): no `<` before `/` or `!`, no CR, no NUL.
|
|
9
|
+
// engine.js _rapierDecodeCarried is its inverse.
|
|
10
|
+
function encodeCarried(text) {
|
|
11
|
+
return String(text).replace(/[\\\r\0]|<[/!]/g, m =>
|
|
12
|
+
m === '\\' ? '\\\\' : m === '\r' ? '\\r' : m === '\0' ? '\\0' : m === '</' ? '\\/' : '\\!');
|
|
13
|
+
}
|
|
14
|
+
function decodeCarried(text) {
|
|
15
|
+
return String(text).replace(/\\([\\/!r0])/g, (_, c) =>
|
|
16
|
+
c === '\\' ? '\\' : c === 'r' ? '\r' : c === '0' ? '\0' : c === '/' ? '</' : '<!');
|
|
17
|
+
}
|
|
18
|
+
export {encodeCarried, decodeCarried};
|
|
19
|
+
|
|
20
|
+
function safeName(name, fallback) {
|
|
21
|
+
return String(name || fallback).replace(/[\\/\0"<>&]/g, '-').slice(0, 255) || fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function wrap(pageHtml, text, name, options) {
|
|
25
|
+
if (typeof pageHtml !== 'string' || !pageHtml.includes('</body>')) throw new Error('not a Rapier page: no </body>');
|
|
26
|
+
if (typeof text !== 'string') throw new Error('the document must be text');
|
|
27
|
+
const opts = options || {};
|
|
28
|
+
const docName = safeName(name, 'document.md');
|
|
29
|
+
const block = START + ' data-name="' + docName + '">' + encodeCarried(text) + END;
|
|
30
|
+
let drawingBlock = '';
|
|
31
|
+
if (opts.drawing != null) {
|
|
32
|
+
if (typeof opts.drawing !== 'string') throw new Error('the drawing must be SVG text');
|
|
33
|
+
const drawingName = safeName(opts.drawingName, 'drawing.svg');
|
|
34
|
+
drawingBlock = '\n' + DRAWING_START + ' data-name="' + drawingName + '">' + encodeCarried(opts.drawing) + END;
|
|
35
|
+
}
|
|
36
|
+
const stripped = unwrap(pageHtml).html;
|
|
37
|
+
// Before Rapier's scripts: parsed before the engine boots.
|
|
38
|
+
const bodyTag = /<body[^>]*>/.exec(stripped);
|
|
39
|
+
if (!bodyTag) throw new Error('not a Rapier page: no <body>');
|
|
40
|
+
const at = bodyTag.index + bodyTag[0].length;
|
|
41
|
+
// A function, not a string: `$'`, `$&` and `` $` `` are replacement patterns.
|
|
42
|
+
const titled = stripped.replace(/<title>[^<]*<\/title>/, () => '<title>' + docName.replace(/&/g, '&').replace(/</g, '<') + ' — Rapier</title>');
|
|
43
|
+
const shift = titled.length - stripped.length;
|
|
44
|
+
return titled.slice(0, at + shift) + '\n' + block + drawingBlock + titled.slice(at + shift);
|
|
45
|
+
}
|
|
46
|
+
export function unwrap(pageHtml) {
|
|
47
|
+
let html = pageHtml, text = null, name = null;
|
|
48
|
+
const a = pageHtml.indexOf(START);
|
|
49
|
+
if (a >= 0) {
|
|
50
|
+
const open = pageHtml.indexOf('>', a), b = pageHtml.indexOf(END, open);
|
|
51
|
+
if (open < 0 || b < 0) throw new Error('a carried document without its end');
|
|
52
|
+
name = /data-name="([^"]*)"/.exec(pageHtml.slice(a, open))?.[1] ?? null;
|
|
53
|
+
text = decodeCarried(pageHtml.slice(open + 1, b));
|
|
54
|
+
html = pageHtml.slice(0, a) + pageHtml.slice(b + END.length);
|
|
55
|
+
if (a > 0 && html[a - 1] === '\n') html = html.slice(0, a - 1) + html.slice(a);
|
|
56
|
+
}
|
|
57
|
+
let drawing = null, drawingName = null;
|
|
58
|
+
const d = html.indexOf(DRAWING_START);
|
|
59
|
+
if (d >= 0) {
|
|
60
|
+
const open = html.indexOf('>', d), b = html.indexOf(END, open);
|
|
61
|
+
if (open < 0 || b < 0) throw new Error('a carried drawing without its end');
|
|
62
|
+
drawingName = /data-name="([^"]*)"/.exec(html.slice(d, open))?.[1] ?? null;
|
|
63
|
+
drawing = decodeCarried(html.slice(open + 1, b));
|
|
64
|
+
html = html.slice(0, d) + html.slice(b + END.length);
|
|
65
|
+
if (d > 0 && html[d - 1] === '\n') html = html.slice(0, d - 1) + html.slice(d);
|
|
66
|
+
}
|
|
67
|
+
return {html, text, name, drawing, drawingName};
|
|
68
|
+
}
|
|
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' +
|
|
71
|
+
' writes <document>.rapier.html beside the file: Rapier with the document inside it, one file, offline.\n' +
|
|
72
|
+
' It never overwrites: an output that already exists, the document itself included, is refused.\n' +
|
|
73
|
+
' --drawing carries an SVG drawing alongside the document, opened on Draw as the page boots\n' +
|
|
74
|
+
' -- everything after it is a filename, even one that starts with a dash\n' +
|
|
75
|
+
' --help this text';
|
|
76
|
+
export async function main(argv, pagesDir, {log = console.log} = {}) {
|
|
77
|
+
const names = [];
|
|
78
|
+
let drawingPath = null, onlyNames = false;
|
|
79
|
+
for (let i = 0; i < argv.length; i++) {
|
|
80
|
+
const arg = argv[i];
|
|
81
|
+
if (onlyNames || !arg.startsWith('-')) names.push(arg);
|
|
82
|
+
else if (arg === '--') onlyNames = true;
|
|
83
|
+
else if (arg === '--help') { log(USAGE); return null; }
|
|
84
|
+
else if (arg !== '--drawing') throw new Error('unknown option ' + arg + '\n' + USAGE);
|
|
85
|
+
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
|
+
else drawingPath = argv[++i];
|
|
87
|
+
}
|
|
88
|
+
if (names.length < 1 || names.length > 2) throw new Error(USAGE);
|
|
89
|
+
const [input, named] = names;
|
|
90
|
+
const text = await readFile(input, 'utf8');
|
|
91
|
+
const page = await readFile(join(pagesDir, 'rapier.html'), 'utf8');
|
|
92
|
+
const name = basename(input);
|
|
93
|
+
const out = named || join(dirname(input), name.replace(/\.(md|markdown|txt)$/i, '') + '.rapier.html');
|
|
94
|
+
const wrapOptions = {};
|
|
95
|
+
if (drawingPath) { wrapOptions.drawing = await readFile(drawingPath, 'utf8'); wrapOptions.drawingName = basename(drawingPath); }
|
|
96
|
+
// R85b: always a new file; 'wx' refuses any existing path, links included.
|
|
97
|
+
try { await writeFile(out, wrap(page, text, name, wrapOptions), {flag: 'wx'}); }
|
|
98
|
+
catch (error) { throw error.code === 'EEXIST' ? new Error(out + ' already exists, and rapier-html never overwrites a file: name a new one') : error; }
|
|
99
|
+
log(out + ' (' + name + ', ' + Buffer.byteLength(text) + ' bytes of document' +
|
|
100
|
+
(drawingPath ? ', ' + basename(drawingPath) + ' drawing' : '') + ')');
|
|
101
|
+
return out;
|
|
102
|
+
}
|