rewritable 0.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/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # rwa
2
+
3
+ CLI for [re-write-able](https://github.com/ikangai/rewritable) — emit and import single-file rwa documents.
4
+
5
+ A re-writeable file is a self-contained `.html` that renders, stores, modifies, and commits itself with no server. Open it in a browser, press `⌘K`, and tell it what to become.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npx rewritable --help # zero-install (one-time cost is the longer name)
11
+ npm i -g rewritable # global; after this, the bin is `rwa` so daily use is `rwa <verb>`
12
+ ```
13
+
14
+ Requires Node ≥ 18.
15
+
16
+ ## Usage
17
+
18
+ ```sh
19
+ rwa new [path] # → ./rewritable.html (default)
20
+ rwa new my-notes.html # → ./my-notes.html
21
+
22
+ rwa import notes.md # → ./notes.html
23
+ rwa import page.html out.html
24
+ ```
25
+
26
+ ### `rwa new`
27
+
28
+ Writes a fresh rwa container with a unique per-file `DOC_UUID`, a filename-derived `<title>`, and the seed's "Hello, world." starter content. Press `⌘K` in the browser to make it become anything.
29
+
30
+ ### `rwa import <input> [path]`
31
+
32
+ Embeds the input file's content as the document's initial state. Supported formats:
33
+
34
+ - `.md`, `.markdown` — converted via [`marked`](https://marked.js.org/) (GFM enabled)
35
+ - `.html`, `.htm` — `<!DOCTYPE>`/`<html>`/`<head>`/`<body>` shells stripped, `<style>` tags retained from `<head>`, body content kept as-is. **`<script>` tags are preserved** (rwa documents support inline JS); a stderr warning is printed when scripts are detected.
36
+ - `.txt` — paragraph-split on blank lines, HTML chars escaped
37
+
38
+ Output defaults to `<input-basename>.html` in the input's directory. Conversion is deterministic and offline — no API key, no network.
39
+
40
+ ### Flags
41
+
42
+ | Flag | Effect |
43
+ |---|---|
44
+ | `--force`, `-f` | overwrite the destination if it exists |
45
+ | `--version` | print version |
46
+ | `--help`, `-h` | usage |
47
+
48
+ ### Exit codes
49
+
50
+ - `0` — success
51
+ - `1` — generic error (read failure, bad seed, etc.)
52
+ - `2` — bad arguments / unsupported format / destination conflict
53
+
54
+ ## Design
55
+
56
+ This CLI is **offline-first**. It ships with its own pinned copy of the bootstrap seed; nothing is fetched from a server. The bootstrap version embedded in any file you create is fixed at the moment of `rwa new` / `rwa import`. To upgrade an existing file's bootstrap to a newer version, see the project's `rwa upgrade` (planned).
57
+
58
+ The seed and the runtime in any file the CLI emits are byte-identical to the seed used by the hosted service at `rewritable.ikangai.com`. Files emitted by either channel are interchangeable.
59
+
60
+ ## License
61
+
62
+ MIT
package/bin/rwa.mjs ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ import { newCmd, importCmd, version } from '../src/commands.mjs';
3
+
4
+ const HELP = `rwa — single-file re-writeable documents
5
+
6
+ Usage:
7
+ rwa new [path] create a fresh rwa document
8
+ (default: ./rewritable.html)
9
+ rwa import <input> [path] convert a md/html/txt file into an rwa document
10
+ (default: <input-basename>.html, in input's dir)
11
+
12
+ Flags:
13
+ --force, -f overwrite the destination if it exists
14
+ --version print version and exit
15
+ --help, -h this help
16
+
17
+ Supported import formats: .md, .markdown, .html, .htm, .txt
18
+ `;
19
+
20
+ const args = process.argv.slice(2);
21
+ const verb = args[0];
22
+
23
+ (async () => {
24
+ try {
25
+ if (verb === '--version' || verb === '-V') {
26
+ console.log(await version());
27
+ return;
28
+ }
29
+ if (!verb || verb === '--help' || verb === '-h' || verb === 'help') {
30
+ process.stdout.write(HELP);
31
+ if (!verb) process.exitCode = 2;
32
+ return;
33
+ }
34
+ const rest = args.slice(1);
35
+ const force = rest.includes('--force') || rest.includes('-f');
36
+ const positional = rest.filter(a => !a.startsWith('-'));
37
+ if (verb === 'new') {
38
+ await newCmd({ outPath: positional[0], force });
39
+ } else if (verb === 'import') {
40
+ if (!positional[0]) {
41
+ console.error('rwa import: missing <input> argument');
42
+ process.exitCode = 2;
43
+ return;
44
+ }
45
+ await importCmd({ inputPath: positional[0], outPath: positional[1], force });
46
+ } else {
47
+ console.error(`rwa: unknown verb "${verb}". Try --help.`);
48
+ process.exitCode = 2;
49
+ }
50
+ } catch (e) {
51
+ console.error('rwa: ' + (e && e.message || e));
52
+ process.exitCode = (e && e.exitCode) || 1;
53
+ }
54
+ })();
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "rewritable",
3
+ "version": "0.1.0",
4
+ "description": "CLI for re-writeable: emit and import single-file rwa documents.",
5
+ "type": "module",
6
+ "bin": {
7
+ "rwa": "bin/rwa.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "seeds/",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "dependencies": {
19
+ "marked": "^14.1.0"
20
+ },
21
+ "scripts": {
22
+ "prepublishOnly": "mkdir -p seeds && cp ../seeds/rewritable.html seeds/rewritable.html"
23
+ }
24
+ }
@@ -0,0 +1,336 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>re-writeable</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Instrument+Serif:ital@0;1&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
8
+ <style>
9
+ :root{--bg:#0e0e0f;--surf:#161618;--b1:#232327;--b2:#2d2d34;--text:#dddde4;--muted:#575766;--accent:#b8ff57;--blue:#57c8ff;--red:#ff5757;}
10
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
11
+ body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;}
12
+ #rwa-set{position:fixed;top:12px;right:12px;display:flex;gap:6px;z-index:1000;}
13
+ .rwa-st-btn{background:var(--surf);border:1px solid var(--b2);color:var(--muted);font-family:'DM Mono',monospace;font-size:10px;padding:6px 10px;border-radius:4px;cursor:pointer;letter-spacing:.5px;text-transform:uppercase;}
14
+ .rwa-st-btn:hover{color:var(--text);border-color:var(--muted);}
15
+ .rwa-st-btn.dirty{color:var(--accent);border-color:var(--accent);}
16
+ .rwa-st-btn.pri{background:var(--accent);color:#000;border-color:var(--accent);}
17
+ .rwa-st-btn.run{color:var(--blue);border-color:var(--blue);}
18
+ .rwa-st-btn.err{color:var(--red);border-color:var(--red);}
19
+ .rwa-st-btn.ok{color:var(--accent);border-color:var(--accent);}
20
+ #rwa-set-panel{position:fixed;top:50px;right:12px;background:var(--surf);border:1px solid var(--b2);border-radius:6px;padding:12px;display:none;min-width:300px;z-index:999;}
21
+ #rwa-set-panel.open{display:block;}
22
+ .rwa-set-row{display:flex;flex-direction:column;gap:4px;margin-bottom:10px;}
23
+ .rwa-set-row:last-child{margin-bottom:0;}
24
+ .rwa-set-row label{font-family:'DM Mono',monospace;font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted);}
25
+ .rwa-set-row input{background:var(--bg);border:1px solid var(--b2);color:var(--text);font-family:'DM Mono',monospace;font-size:12px;padding:6px 9px;outline:none;border-radius:3px;}
26
+ #rwa-pal{position:fixed;inset:0;display:none;align-items:flex-start;justify-content:center;padding-top:13vh;background:rgba(0,0,0,.6);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:99999;}
27
+ #rwa-pal.open{display:flex;}
28
+ #rwa-pal-box{width:min(540px,92vw);background:var(--surf);border:1px solid var(--b2);border-radius:8px;overflow:hidden;}
29
+ .rwa-pal-top{display:flex;align-items:center;border-bottom:1px solid var(--b1);}
30
+ .rwa-pal-sig{padding:0 14px;font-size:13px;color:var(--accent);font-family:'DM Mono',monospace;letter-spacing:.5px;}
31
+ #rwa-pal-inp{flex:1;background:transparent;border:none;outline:none;color:var(--text);font-family:'DM Sans',sans-serif;font-size:14px;padding:14px 0;}
32
+ #rwa-pal-inp::placeholder{color:#363642;}
33
+ #rwa-pal-go{background:var(--accent);color:#000;border:none;border-radius:4px;font-family:'DM Mono',monospace;font-size:10px;padding:5px 11px;margin:7px 10px;cursor:pointer;letter-spacing:.5px;}
34
+ #rwa-pal-go:disabled{opacity:.3;pointer-events:none;}
35
+ .rwa-pal-foot{border-top:1px solid var(--b1);padding:7px 14px;display:flex;justify-content:space-between;align-items:center;}
36
+ #rwa-pal-st{font-family:'DM Mono',monospace;font-size:10px;color:var(--muted);}
37
+ #rwa-pal-st.run{color:var(--blue);}#rwa-pal-st.ok{color:var(--accent);}#rwa-pal-st.err{color:var(--red);}
38
+ .rwa-pal-hint{font-family:'DM Mono',monospace;font-size:10px;color:#363642;}
39
+ </style>
40
+ </head>
41
+ <body>
42
+ <div id="rwa-doc-mount"></div>
43
+ <div id="rwa-runtime"></div>
44
+ <script id="rwa-bootstrap">
45
+ 'use strict';
46
+
47
+ // Per-container identity — namespaces IndexedDB so file:// docs don't collide.
48
+ // Generated at creation time; preserved across commits because buildFile only
49
+ // rewrites the INLINE_DOC literal contents.
50
+ //
51
+ // The nil UUID below is a sentinel — the /new service substitutes a fresh
52
+ // crypto.randomUUID() at request time. If a downloaded file still has all
53
+ // zeros here, the substitution failed and isolation is broken.
54
+ const DOC_UUID = '00000000-0000-0000-0000-000000000000';
55
+
56
+ // The document — a frozen snapshot, hydrated into IndexedDB on first open
57
+ // and rewritten in place on every commit.
58
+ const INLINE_DOC = `<style>
59
+ .hello{display:grid;place-items:center;min-height:100dvh;text-align:center;padding:24px;}
60
+ .hello h1{font-family:'Instrument Serif',serif;font-style:italic;font-size:clamp(56px,9vw,112px);line-height:1;letter-spacing:-.02em;background:linear-gradient(135deg,var(--text) 50%,var(--muted));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
61
+ .hello p{font-family:'Instrument Serif',serif;font-size:18px;color:var(--muted);margin-top:22px;font-style:italic;max-width:42ch;line-height:1.5;}
62
+ .hello kbd{font-family:'DM Mono',monospace;font-size:11px;background:var(--surf);border:1px solid var(--b2);padding:2px 7px;border-radius:3px;color:var(--accent);font-style:normal;}
63
+ </style>
64
+ <div class="hello">
65
+ <h1>Hello, world.</h1>
66
+ <p>This is a re-writeable document. Press <kbd>⌘K</kbd> and tell it what to become.</p>
67
+ </div>`;
68
+
69
+ // ─── Storage ────────────────────────────────────────────────────────
70
+ const RWA = {
71
+ DB:'rwa_'+DOC_UUID, KEY:'self',
72
+ DOC:'rwa_doc', UNDO:'rwa_undo', HIST:'rwa_hist', FSA:'rwa_fsa',
73
+ UNDO_CAP:10, HIST_CAP:15,
74
+ K_API:'rwa_apikey', K_MODEL:'rwa_model',
75
+ MODEL:'google/gemini-3-flash-preview',
76
+ FILE:'rewritable.html',
77
+ };
78
+ let _db;
79
+ const REQUIRED_STORES = [RWA.DOC, RWA.UNDO, RWA.HIST, RWA.FSA];
80
+ async function openDB() {
81
+ if (_db) return _db;
82
+ // Open without specifying a version, so we work whatever the existing db version is.
83
+ const probe = await new Promise((res, rej) => {
84
+ const r = indexedDB.open(RWA.DB);
85
+ r.onsuccess = () => res(r.result);
86
+ r.onerror = () => rej(r.error);
87
+ r.onblocked = () => rej(new Error('db open blocked by another tab'));
88
+ });
89
+ // We expect every required store to use out-of-line keys (keyPath === null).
90
+ // If a store is missing — or exists with the wrong schema (in-line keys from
91
+ // an old prototype) — we need to (re)create it.
92
+ const toRecreate = [];
93
+ for (const name of REQUIRED_STORES) {
94
+ if (!probe.objectStoreNames.contains(name)) { toRecreate.push(name); continue; }
95
+ const store = probe.transaction(name, 'readonly').objectStore(name);
96
+ if (store.keyPath !== null) toRecreate.push(name);
97
+ }
98
+ if (toRecreate.length === 0) { _db = probe; return _db; }
99
+ const newVersion = probe.version + 1;
100
+ probe.close();
101
+ _db = await new Promise((res, rej) => {
102
+ const r = indexedDB.open(RWA.DB, newVersion);
103
+ r.onupgradeneeded = e => {
104
+ const db = e.target.result;
105
+ toRecreate.forEach(name => {
106
+ if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name);
107
+ db.createObjectStore(name); // out-of-line keys
108
+ });
109
+ };
110
+ r.onsuccess = () => res(r.result);
111
+ r.onerror = () => rej(r.error);
112
+ r.onblocked = () => rej(new Error('db upgrade blocked by another tab'));
113
+ });
114
+ return _db;
115
+ }
116
+ const idbGet = (s, k = RWA.KEY) => openDB().then(db => new Promise((res, rej) => {
117
+ const r = db.transaction(s).objectStore(s).get(k);
118
+ r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
119
+ }));
120
+ const idbPut = (s, v, k = RWA.KEY) => openDB().then(db => new Promise((res, rej) => {
121
+ const r = db.transaction(s, 'readwrite').objectStore(s).put(v, k);
122
+ r.onsuccess = () => res(); r.onerror = () => rej(r.error);
123
+ }));
124
+
125
+ async function getDoc() {
126
+ let d = await idbGet(RWA.DOC);
127
+ if (d == null) { d = INLINE_DOC; await idbPut(RWA.DOC, d); }
128
+ return d;
129
+ }
130
+ async function pushUndo(p) {
131
+ const s = (await idbGet(RWA.UNDO)) || [];
132
+ s.push(p); while (s.length > RWA.UNDO_CAP) s.shift();
133
+ await idbPut(RWA.UNDO, s);
134
+ }
135
+ async function popUndo() {
136
+ const s = (await idbGet(RWA.UNDO)) || [];
137
+ if (!s.length) return null;
138
+ const v = s.pop(); await idbPut(RWA.UNDO, s); return v;
139
+ }
140
+ async function addHist(i) {
141
+ const l = (await idbGet(RWA.HIST)) || [];
142
+ await idbPut(RWA.HIST, [i, ...l.filter(x => x !== i)].slice(0, RWA.HIST_CAP));
143
+ }
144
+
145
+ // ─── Render ─────────────────────────────────────────────────────────
146
+ function renderDoc(html) {
147
+ const m = document.getElementById('rwa-doc-mount');
148
+ m.innerHTML = html;
149
+ m.querySelectorAll('script').forEach(o => {
150
+ const s = document.createElement('script');
151
+ for (const a of o.attributes) s.setAttribute(a.name, a.value);
152
+ s.textContent = o.textContent;
153
+ o.parentNode.replaceChild(s, o);
154
+ });
155
+ }
156
+
157
+ // ─── File rebuild ───────────────────────────────────────────────────
158
+ let FROZEN = '';
159
+ const escapeTL = s => s
160
+ .replace(/\\/g, '\\\\')
161
+ .replace(/`/g, '\\`')
162
+ .replace(/\$\{/g, '\\${')
163
+ .replace(/<\/script/gi, '<\\/script');
164
+ function buildFile(doc) {
165
+ const marker = 'const INLINE_DOC = `';
166
+ const start = FROZEN.indexOf(marker);
167
+ if (start < 0) throw new Error('cannot locate INLINE_DOC');
168
+ const cs = start + marker.length;
169
+ let i = cs;
170
+ while (i < FROZEN.length) {
171
+ if (FROZEN[i] === '\\') { i += 2; continue; }
172
+ if (FROZEN[i] === '`') break;
173
+ i++;
174
+ }
175
+ if (i >= FROZEN.length) throw new Error('unterminated INLINE_DOC');
176
+ return FROZEN.slice(0, cs) + escapeTL(doc) + FROZEN.slice(i);
177
+ }
178
+
179
+ // ─── Runtime UI ─────────────────────────────────────────────────────
180
+ function buildUI() {
181
+ document.getElementById('rwa-runtime').innerHTML = `
182
+ <div id="rwa-set">
183
+ <button class="rwa-st-btn" id="rwa-st-status">● ready</button>
184
+ <button class="rwa-st-btn" id="rwa-st-cog">⚙</button>
185
+ <button class="rwa-st-btn pri" id="rwa-st-commit">⌘S</button>
186
+ </div>
187
+ <div id="rwa-set-panel">
188
+ <div class="rwa-set-row"><label>OpenRouter Key</label><input type="password" id="rwa-key" placeholder="sk-or-..." autocomplete="off"></div>
189
+ <div class="rwa-set-row"><label>Model</label><input type="text" id="rwa-model" autocomplete="off"></div>
190
+ </div>
191
+ <div id="rwa-pal">
192
+ <div id="rwa-pal-box">
193
+ <div class="rwa-pal-top">
194
+ <span class="rwa-pal-sig">⌘K</span>
195
+ <input id="rwa-pal-inp" placeholder="modify this document..." autocomplete="off" spellcheck="false">
196
+ <button id="rwa-pal-go" disabled>Run ↵</button>
197
+ </div>
198
+ <div class="rwa-pal-foot">
199
+ <span id="rwa-pal-st">● ready</span>
200
+ <span class="rwa-pal-hint">⌘Z undo · ⌘S commit · esc close</span>
201
+ </div>
202
+ </div>
203
+ </div>`;
204
+
205
+ const k = document.getElementById('rwa-key'), m = document.getElementById('rwa-model');
206
+ k.value = sessionStorage.getItem(RWA.K_API) || '';
207
+ m.value = sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
208
+ k.oninput = e => sessionStorage.setItem(RWA.K_API, e.target.value.trim());
209
+ m.oninput = e => sessionStorage.setItem(RWA.K_MODEL, e.target.value.trim() || RWA.MODEL);
210
+
211
+ document.getElementById('rwa-st-cog').onclick = () => document.getElementById('rwa-set-panel').classList.toggle('open');
212
+ document.getElementById('rwa-st-commit').onclick = commit;
213
+
214
+ const pal = document.getElementById('rwa-pal'), inp = document.getElementById('rwa-pal-inp'), go = document.getElementById('rwa-pal-go');
215
+ pal.onclick = e => { if (e.target === pal) closePal(); };
216
+ inp.oninput = () => { go.disabled = !inp.value.trim(); };
217
+ inp.onkeydown = e => {
218
+ if (e.key === 'Enter' && !e.shiftKey && inp.value.trim()) { e.preventDefault(); modify(inp.value.trim()); }
219
+ if (e.key === 'Escape') closePal();
220
+ };
221
+ go.onclick = () => inp.value.trim() && modify(inp.value.trim());
222
+ }
223
+ const setStatus = (cls, msg) => { const e = document.getElementById('rwa-st-status'); if (e) { e.className = 'rwa-st-btn ' + (cls || ''); e.textContent = msg; } };
224
+ const setPalSt = (cls, msg) => { const e = document.getElementById('rwa-pal-st'); if (e) { e.className = cls || ''; e.textContent = msg; } };
225
+ const setDirty = d => { const e = document.getElementById('rwa-st-commit'); if (e) e.classList.toggle('dirty', d); };
226
+ const openPal = () => { document.getElementById('rwa-pal').classList.add('open'); requestAnimationFrame(() => document.getElementById('rwa-pal-inp').focus()); setPalSt('', '● ready'); };
227
+ const closePal = () => { document.getElementById('rwa-pal').classList.remove('open'); document.getElementById('rwa-pal-inp').value = ''; document.getElementById('rwa-pal-go').disabled = true; };
228
+
229
+ // ─── Agent ──────────────────────────────────────────────────────────
230
+ const SYSTEM_PROMPT = `You are modifying a document. The document may be prose, it may be a tracker, it may be a spreadsheet, it may be all three. Read what is there. Apply the user's instruction to the actual content — its tone if it is prose, its structure if it is data, its behavior if it is interactive.
231
+
232
+ If the user's input is itself substantial content — a long block of prose, a structured outline, a markdown document, a list of items — they want that content rendered into the document, not summarized. Preserve every paragraph, every section, every list item, every example. Do not condense, abbreviate with ellipsis, or omit anything for brevity. If the input has 100 items, the output has 100 items. If the input has 12 sections, the output has 12 sections.
233
+
234
+ Return the complete modified document only — no commentary, no markdown fence. The document is an HTML fragment that lives inside a mount div. CSS may be inline in <style> tags; JS may be inline in <script> tags. Do NOT include <!DOCTYPE>, <html>, <head>, or <body> — those belong to the bootstrap. The first character of your response should begin the modified content directly.`;
235
+
236
+ async function modify(instr) {
237
+ const key = sessionStorage.getItem(RWA.K_API);
238
+ const model = sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
239
+ if (!key) {
240
+ setPalSt('err', 'no API key — open ⚙ settings');
241
+ document.getElementById('rwa-set-panel').classList.add('open');
242
+ document.getElementById('rwa-key').focus();
243
+ return;
244
+ }
245
+ closePal();
246
+ setStatus('run', '⌘K running');
247
+ try {
248
+ const cur = await getDoc();
249
+ const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
250
+ method: 'POST',
251
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + key, 'HTTP-Referer': 'https://github.com/ikangai/rewritable', 'X-Title': 're-write-able' },
252
+ body: JSON.stringify({ model, max_tokens: 32000, messages: [{ role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: 'Document:\n' + cur + '\n\nInstruction: ' + instr }] }),
253
+ });
254
+ if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error((e.error && e.error.message) || r.statusText); }
255
+ let text = (((await r.json()).choices || [{}])[0].message?.content || '').trim();
256
+ const fence = '```';
257
+ if (text.startsWith(fence)) { const nl = text.indexOf('\n'); if (nl > 0) text = text.slice(nl + 1); }
258
+ if (text.endsWith(fence)) text = text.slice(0, text.lastIndexOf(fence)).trim();
259
+ if (!text) throw new Error('empty response');
260
+ await pushUndo(cur);
261
+ await idbPut(RWA.DOC, text);
262
+ await addHist(instr);
263
+ renderDoc(text);
264
+ setDirty(true);
265
+ setStatus('ok', '✓ done');
266
+ } catch (e) {
267
+ setStatus('err', '✗ ' + e.message);
268
+ console.error(e);
269
+ }
270
+ }
271
+
272
+ async function undo() {
273
+ const p = await popUndo();
274
+ if (!p) { setStatus('err', '✗ nothing to undo'); return; }
275
+ await idbPut(RWA.DOC, p);
276
+ renderDoc(p);
277
+ setDirty(true);
278
+ setStatus('ok', '↩ undone');
279
+ }
280
+
281
+ async function commit() {
282
+ setStatus('run', '⌘S writing');
283
+ try {
284
+ const text = buildFile(await getDoc());
285
+ if ('showSaveFilePicker' in window) {
286
+ let h = null; try { h = await idbGet(RWA.FSA); } catch (_) {}
287
+ try {
288
+ if (h) {
289
+ let p = await h.queryPermission({ mode: 'readwrite' });
290
+ if (p === 'prompt') p = await h.requestPermission({ mode: 'readwrite' });
291
+ if (p !== 'granted') throw new Error('permission denied');
292
+ } else {
293
+ h = await window.showSaveFilePicker({ suggestedName: RWA.FILE, types: [{ description: 'HTML', accept: { 'text/html': ['.html'] } }] });
294
+ await idbPut(RWA.FSA, h);
295
+ }
296
+ const w = await h.createWritable(); await w.write(text); await w.close();
297
+ setDirty(false); setStatus('ok', '✓ committed'); return;
298
+ } catch (e) {
299
+ if (e.name === 'AbortError') { setStatus('', 'cancelled'); return; }
300
+ console.warn('FSA failed, falling back to download:', e);
301
+ }
302
+ }
303
+ const a = document.createElement('a');
304
+ a.href = URL.createObjectURL(new Blob([text], { type: 'text/html' }));
305
+ a.download = RWA.FILE;
306
+ a.click();
307
+ URL.revokeObjectURL(a.href);
308
+ setDirty(false); setStatus('ok', '✓ exported');
309
+ } catch (e) {
310
+ setStatus('err', '✗ ' + e.message);
311
+ }
312
+ }
313
+
314
+ document.addEventListener('keydown', e => {
315
+ const mod = navigator.platform.includes('Mac') ? e.metaKey : e.ctrlKey;
316
+ if (!mod) return;
317
+ if (e.key === 'k') { e.preventDefault(); document.getElementById('rwa-pal').classList.contains('open') ? closePal() : openPal(); }
318
+ else if (e.key === 'z') { e.preventDefault(); undo(); }
319
+ else if (e.key === 's') { e.preventDefault(); commit(); }
320
+ });
321
+
322
+ (async () => {
323
+ try {
324
+ FROZEN = '<!DOCTYPE html>\n' + document.documentElement.outerHTML;
325
+ buildUI();
326
+ if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {});
327
+ renderDoc(await getDoc());
328
+ setStatus('ok', '● ready');
329
+ } catch (e) {
330
+ document.body.textContent = 'Bootstrap error: ' + e.message;
331
+ console.error(e);
332
+ }
333
+ })();
334
+ </script>
335
+ </body>
336
+ </html>
@@ -0,0 +1,100 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import crypto from 'node:crypto';
5
+
6
+ import { loadSeed, applySeedSubs, replaceInlineDoc } from './seed.mjs';
7
+ import { convert } from './import.mjs';
8
+
9
+ const here = path.dirname(fileURLToPath(import.meta.url));
10
+ const packageRoot = path.dirname(here);
11
+
12
+ // Look in the in-package copy first (published case), fall back to the
13
+ // repo-canonical seed (dev case where cli/ sits next to seeds/).
14
+ const SEED_CANDIDATES = [
15
+ path.join(packageRoot, 'seeds', 'rewritable.html'),
16
+ path.join(packageRoot, '..', 'seeds', 'rewritable.html'),
17
+ ];
18
+
19
+ async function readPkg() {
20
+ return JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8'));
21
+ }
22
+
23
+ export async function version() {
24
+ const pkg = await readPkg();
25
+ return `rwa ${pkg.version}`;
26
+ }
27
+
28
+ function titleFromBasename(basename) {
29
+ return basename
30
+ .replace(/[-_]+/g, ' ')
31
+ .split(' ')
32
+ .filter(Boolean)
33
+ .map(w => w[0].toUpperCase() + w.slice(1))
34
+ .join(' ') || 'Untitled';
35
+ }
36
+
37
+ async function ensureWritable(outPath, force) {
38
+ try {
39
+ await fs.stat(outPath);
40
+ } catch (e) {
41
+ if (e.code === 'ENOENT') return;
42
+ throw e;
43
+ }
44
+ if (!force) {
45
+ const e = new Error(`destination exists: ${outPath} (use --force to overwrite)`);
46
+ e.exitCode = 2;
47
+ throw e;
48
+ }
49
+ }
50
+
51
+ function rel(p) {
52
+ const r = path.relative(process.cwd(), p);
53
+ return r || p;
54
+ }
55
+
56
+ export async function newCmd({ outPath, force }) {
57
+ const out = path.resolve(outPath || './rewritable.html');
58
+ await ensureWritable(out, force);
59
+ const seed = await loadSeed(SEED_CANDIDATES);
60
+ const fileMeta = path.basename(out);
61
+ const title = titleFromBasename(path.basename(out, path.extname(out)));
62
+ const result = applySeedSubs(seed, {
63
+ uuid: crypto.randomUUID(),
64
+ title,
65
+ fileMeta,
66
+ });
67
+ await fs.writeFile(out, result, 'utf8');
68
+ console.log(`wrote ${rel(out)}`);
69
+ }
70
+
71
+ export async function importCmd({ inputPath, outPath, force }) {
72
+ const input = path.resolve(inputPath);
73
+ const inputDir = path.dirname(input);
74
+ const inputBasename = path.basename(input, path.extname(input));
75
+ const out = path.resolve(outPath || path.join(inputDir, `${inputBasename}.html`));
76
+ await ensureWritable(out, force);
77
+
78
+ const ext = path.extname(input).toLowerCase().replace(/^\./, '');
79
+ const contents = await fs.readFile(input, 'utf8');
80
+ const { html, warnings } = await convert(ext, contents);
81
+ for (const w of warnings) console.error(`note: ${w}`);
82
+
83
+ const seed = await loadSeed(SEED_CANDIDATES);
84
+ const fileMeta = path.basename(out);
85
+ const title = titleFromBasename(path.basename(out, path.extname(out)));
86
+
87
+ // Order matters: apply seed-level substitutions (DOC_UUID, title, FILE)
88
+ // FIRST against the pristine seed, then drop the imported content into
89
+ // INLINE_DOC. Otherwise an imported file containing `const DOC_UUID = ...`
90
+ // (e.g. another rwa file) would produce two regex matches and trip the
91
+ // exactly-one check in applySeedSubs.
92
+ const subbed = applySeedSubs(seed, {
93
+ uuid: crypto.randomUUID(),
94
+ title,
95
+ fileMeta,
96
+ });
97
+ const result = replaceInlineDoc(subbed, html);
98
+ await fs.writeFile(out, result, 'utf8');
99
+ console.log(`wrote ${rel(out)}`);
100
+ }
package/src/import.mjs ADDED
@@ -0,0 +1,60 @@
1
+ import { marked } from 'marked';
2
+
3
+ export async function convert(ext, content) {
4
+ switch (ext) {
5
+ case 'md':
6
+ case 'markdown':
7
+ return convertMd(content);
8
+ case 'html':
9
+ case 'htm':
10
+ return convertHtml(content);
11
+ case 'txt':
12
+ case '':
13
+ return convertTxt(content);
14
+ default: {
15
+ const e = new Error(`unsupported format: .${ext} (supported: .md, .markdown, .html, .htm, .txt)`);
16
+ e.exitCode = 2;
17
+ throw e;
18
+ }
19
+ }
20
+ }
21
+
22
+ function convertMd(md) {
23
+ const html = marked.parse(md, { gfm: true, breaks: false });
24
+ return { html: `<article>\n${html.trim()}\n</article>`, warnings: [] };
25
+ }
26
+
27
+ function convertHtml(input) {
28
+ const warnings = [];
29
+
30
+ let body = input.replace(/<!DOCTYPE[^>]*>/gi, '').replace(/<\/?html[^>]*>/gi, '');
31
+
32
+ const headMatch = body.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
33
+ let headStyles = '';
34
+ if (headMatch) {
35
+ const styles = headMatch[1].match(/<style[^>]*>[\s\S]*?<\/style>/gi);
36
+ if (styles) headStyles = styles.join('\n') + '\n';
37
+ body = body.replace(/<head[^>]*>[\s\S]*?<\/head>/i, '');
38
+ }
39
+
40
+ const bodyMatch = body.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
41
+ if (bodyMatch) body = bodyMatch[1];
42
+
43
+ body = body.trim();
44
+
45
+ if (/<script[\s>]/i.test(body)) {
46
+ warnings.push('imported HTML contained <script> tags; they will execute when the document loads');
47
+ }
48
+
49
+ return { html: headStyles + body, warnings };
50
+ }
51
+
52
+ function convertTxt(text) {
53
+ const escape = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
54
+ const blocks = text
55
+ .split(/\n\s*\n/)
56
+ .map(b => b.trim().replace(/\s+/g, ' '))
57
+ .filter(Boolean)
58
+ .map(b => `<p>${escape(b)}</p>`);
59
+ return { html: `<article>\n${blocks.join('\n')}\n</article>`, warnings: [] };
60
+ }
package/src/seed.mjs ADDED
@@ -0,0 +1,58 @@
1
+ import fs from 'node:fs/promises';
2
+
3
+ export async function loadSeed(candidates) {
4
+ for (const p of candidates) {
5
+ try {
6
+ return await fs.readFile(p, 'utf8');
7
+ } catch (e) {
8
+ if (e.code !== 'ENOENT') throw e;
9
+ }
10
+ }
11
+ throw new Error(`seed not found in any of: ${candidates.join(', ')}`);
12
+ }
13
+
14
+ const UUID_RE = /const DOC_UUID = '[0-9a-f-]{36}';/;
15
+ const TITLE_RE = /<title>[^<]*<\/title>/;
16
+ const FILE_RE = /(FILE\s*:\s*)'[^']*'/;
17
+
18
+ export function applySeedSubs(seed, { uuid, title, fileMeta }) {
19
+ const uuidMatches = seed.match(new RegExp(UUID_RE.source, 'g')) || [];
20
+ if (uuidMatches.length !== 1) {
21
+ throw new Error(`seed must contain exactly one DOC_UUID line, found ${uuidMatches.length}`);
22
+ }
23
+ let out = seed.replace(UUID_RE, `const DOC_UUID = '${uuid}';`);
24
+ if (title != null) out = out.replace(TITLE_RE, `<title>${escapeHtml(title)}</title>`);
25
+ if (fileMeta != null) out = out.replace(FILE_RE, (_m, prefix) => `${prefix}'${escapeJsString(fileMeta)}'`);
26
+ return out;
27
+ }
28
+
29
+ // Mirrors the bootstrap's escapeTL — keep in sync with seeds/rewritable.html.
30
+ const escapeTL = s => s
31
+ .replace(/\\/g, '\\\\')
32
+ .replace(/`/g, '\\`')
33
+ .replace(/\$\{/g, '\\${')
34
+ .replace(/<\/script/gi, '<\\/script');
35
+
36
+ const INLINE_DOC_MARKER = 'const INLINE_DOC = `';
37
+
38
+ export function replaceInlineDoc(seed, newDoc) {
39
+ const start = seed.indexOf(INLINE_DOC_MARKER);
40
+ if (start < 0) throw new Error('cannot locate INLINE_DOC marker in seed');
41
+ const cs = start + INLINE_DOC_MARKER.length;
42
+ let i = cs;
43
+ while (i < seed.length) {
44
+ if (seed[i] === '\\') { i += 2; continue; }
45
+ if (seed[i] === '`') break;
46
+ i++;
47
+ }
48
+ if (i >= seed.length) throw new Error('unterminated INLINE_DOC literal in seed');
49
+ return seed.slice(0, cs) + escapeTL(newDoc) + seed.slice(i);
50
+ }
51
+
52
+ function escapeHtml(s) {
53
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
54
+ }
55
+
56
+ function escapeJsString(s) {
57
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
58
+ }