rewritable 0.3.0 → 0.5.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.
@@ -0,0 +1,109 @@
1
+ // `rwa new <kind>` template discovery + label strip
2
+ // (docs/plans/2026-05-05-cli-templates-design.md).
3
+ //
4
+ // A user labels one rwa file per kind with data-rwa-template="<kind>" on the
5
+ // first element of its body (#rwa-doc-mount's first child). `rwa new <kind>`
6
+ // scans cwd, finds the labeled file, and clones it — pristine seed + the
7
+ // template's INLINE_DOC, fresh UUID, label stripped. No registry, no shipped
8
+ // starters: the file you made yesterday is the template for the file you make
9
+ // tomorrow. CLI-only for v1; cross-folder discovery is deferred.
10
+
11
+ import { readdir, readFile, stat } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { extractInlineDoc, KNOWN_KINDS } from './seed.mjs';
14
+
15
+ const HTML_RE = /\.html?$/i;
16
+ // The first opening tag inside the body (the template's root element).
17
+ const FIRST_TAG_RE = /<[a-zA-Z][^>]*>/;
18
+ const LABEL_ATTR_RE = /\s*\bdata-rwa-template=["'][^"']*["']/;
19
+
20
+ // The data-rwa-template value on the body's first element, or null.
21
+ function templateLabelOf(body) {
22
+ const tag = (body || '').match(FIRST_TAG_RE);
23
+ if (!tag) return null;
24
+ const m = tag[0].match(/\bdata-rwa-template=["']([^"']*)["']/);
25
+ return m ? m[1] : null;
26
+ }
27
+
28
+ /**
29
+ * Strip data-rwa-template="…" from the body's first opening tag (the cloned
30
+ * container is an instance, not the template). No-op when absent; only the first
31
+ * element is touched, so a later mention in prose survives.
32
+ * @param {string} body — the INLINE_DOC body
33
+ * @returns {string}
34
+ */
35
+ export function stripTemplateAttribute(body) {
36
+ const tag = (body || '').match(FIRST_TAG_RE);
37
+ if (!tag) return body;
38
+ const stripped = tag[0].replace(LABEL_ATTR_RE, '');
39
+ return body.slice(0, tag.index) + stripped + body.slice(tag.index + tag[0].length);
40
+ }
41
+
42
+ /**
43
+ * Find the rwa container in `dir` labeled `data-rwa-template="<name>"`. Scans
44
+ * (non-recursive) `*.html`; cheap-pre-checks for the bootstrap id before parsing;
45
+ * skips malformed candidates; most-recent mtime wins when several match.
46
+ *
47
+ * @param {string} dir — directory to scan (typically cwd)
48
+ * @param {string} name — the template kind
49
+ * @returns {Promise<{path:string, inlineDoc:string, ambiguous:boolean}|null>}
50
+ * @throws {Error} exitCode 2 when the directory holds more than 200 .html files
51
+ */
52
+ export async function findTemplate(dir, name) {
53
+ let entries;
54
+ try { entries = await readdir(dir); } catch { return null; }
55
+ const htmls = entries.filter(n => HTML_RE.test(n));
56
+ if (htmls.length > 200) {
57
+ const e = new Error(`too many .html files in ${dir} (>200) to scan for a "${name}" template`);
58
+ e.exitCode = 2;
59
+ throw e;
60
+ }
61
+ const matches = [];
62
+ for (const n of htmls) {
63
+ const p = join(dir, n);
64
+ let text;
65
+ try { text = await readFile(p, 'utf8'); } catch { continue; }
66
+ if (!text.includes('id="rwa-bootstrap"')) continue; // cheap: not an rwa file
67
+ let body;
68
+ try { body = extractInlineDoc(text); } catch { continue; } // malformed → skip, keep scanning
69
+ if (templateLabelOf(body) !== name) continue;
70
+ let mtime = 0;
71
+ try { mtime = (await stat(p)).mtimeMs; } catch { /* keep 0 */ }
72
+ matches.push({ path: p, inlineDoc: body, mtime });
73
+ }
74
+ if (!matches.length) return null;
75
+ matches.sort((a, b) => b.mtime - a.mtime); // most-recent first
76
+ return { path: matches[0].path, inlineDoc: matches[0].inlineDoc, ambiguous: matches.length > 1 };
77
+ }
78
+
79
+ /**
80
+ * Resolve a bare word to a creation frame, template-first then built-in kind
81
+ * (design 2026-05-31 §3.2). This is THE single resolver shared by `rwa new <word>`
82
+ * and `rwa create <word> …` so the two surfaces never diverge.
83
+ *
84
+ * 1. a cwd file labeled data-rwa-template="<word>" → clone it
85
+ * → { source:'template', kind:'document', body:<stripped>, templatePath, ambiguous }
86
+ * 2. else <word> ∈ KNOWN_KINDS → emit that built-in kind
87
+ * → { source:'kind', kind:<word>, body:null } (body comes from kindOverrides)
88
+ * 3. else → null (caller decides: error, or Stage-2 inference)
89
+ *
90
+ * @param {string} word — the bare leading token
91
+ * @param {string} cwd — directory to scan for a labeled template
92
+ * @returns {Promise<{source:string, kind:string, body:string|null, templatePath?:string, ambiguous?:boolean}|null>}
93
+ */
94
+ export async function resolveBareWord(word, cwd) {
95
+ const tmpl = await findTemplate(cwd, word);
96
+ if (tmpl) {
97
+ return {
98
+ source: 'template',
99
+ kind: 'document',
100
+ body: stripTemplateAttribute(tmpl.inlineDoc),
101
+ templatePath: tmpl.path,
102
+ ambiguous: tmpl.ambiguous,
103
+ };
104
+ }
105
+ if (KNOWN_KINDS.includes(word)) {
106
+ return { source: 'kind', kind: word, body: null };
107
+ }
108
+ return null;
109
+ }