@golden-frijoles/kit 0.15.0 → 0.17.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/dist/doc-format.mjs +51 -0
- package/dist/lib/surface.mjs +303 -0
- package/dist/sketch-render.mjs +180 -0
- package/package.json +1 -1
package/dist/doc-format.mjs
CHANGED
|
@@ -110,8 +110,57 @@ export function siblingDocs(readmeRelPath) {
|
|
|
110
110
|
|
|
111
111
|
// ── Individual checkers — each returns a list of { rule, detail } offenses for one file's content ──
|
|
112
112
|
|
|
113
|
+
// An HTML comment that never closes hides EVERYTHING after it when the doc renders — the whole epic README, its
|
|
114
|
+
// architecture lock included (think-skills and sketch-specs both shipped one, 2026-09-30, from an edit anchored on a
|
|
115
|
+
// heading the old template's comment named). Code is skipped first: a `<!-- jev:` inside backticks or a fence is an
|
|
116
|
+
// example, not a comment, and several sprint docs quote one.
|
|
117
|
+
// A code span opens with a run of backticks and closes with the same run (``<!-- x`` included).
|
|
118
|
+
const CODE_SPAN = /(`+)[\s\S]*?\1/g;
|
|
119
|
+
|
|
120
|
+
// A later `-->` closes an open comment wherever it appears (a mermaid `A --> B` included) — that is what CommonMark
|
|
121
|
+
// renders, so the rule agrees with the page; the text in between is still hidden, and only reading it shows that.
|
|
122
|
+
export function unclosedComments(content) {
|
|
123
|
+
const lines = content.split('\n');
|
|
124
|
+
let fence = null;
|
|
125
|
+
let open = null; // the line an unclosed <!-- started on, while we are inside one
|
|
126
|
+
for (let i = 0; i < lines.length; i++) {
|
|
127
|
+
// The whole run of marks: a ```` fence is closed only by ```` or longer, never by the ``` it quotes.
|
|
128
|
+
const fenceMark = lines[i].match(/^ {0,3}(`{3,}|~{3,})/);
|
|
129
|
+
if (open === null && fenceMark) {
|
|
130
|
+
const mark = fenceMark[1];
|
|
131
|
+
if (fence === null) fence = mark;
|
|
132
|
+
else if (mark[0] === fence[0] && mark.length >= fence.length) fence = null;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (fence !== null) continue;
|
|
136
|
+
let rest = open === null ? lines[i].replace(CODE_SPAN, '') : lines[i];
|
|
137
|
+
for (;;) {
|
|
138
|
+
if (open === null) {
|
|
139
|
+
const at = rest.indexOf('<!--');
|
|
140
|
+
if (at === -1) break;
|
|
141
|
+
open = i;
|
|
142
|
+
rest = rest.slice(at + 4);
|
|
143
|
+
} else {
|
|
144
|
+
const at = rest.indexOf('-->');
|
|
145
|
+
if (at === -1) break;
|
|
146
|
+
open = null;
|
|
147
|
+
rest = rest.slice(at + 3).replace(CODE_SPAN, '');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return open === null
|
|
152
|
+
? []
|
|
153
|
+
: [
|
|
154
|
+
{
|
|
155
|
+
rule: 'unclosed-html-comment',
|
|
156
|
+
detail: `line ${open + 1} opens <!-- and nothing closes it — everything after it is hidden when the doc renders`,
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
|
|
113
161
|
export function checkEpicReadme(content, { slug, exists = existsRelative } = {}) {
|
|
114
162
|
const offenses = [];
|
|
163
|
+
offenses.push(...unclosedComments(content));
|
|
115
164
|
// Many epics predate the seeds/ convention and genuinely have no seed file to link — that's an
|
|
116
165
|
// accepted state (Sprint 2 sweep decision), not drift. Only require a **Scope seed:** field when a
|
|
117
166
|
// real seed file exists for this epic's slug; a seed that exists but isn't linked IS still flagged.
|
|
@@ -230,6 +279,7 @@ export function bodyStartIndex(lines) {
|
|
|
230
279
|
|
|
231
280
|
export function checkSprintDoc(content) {
|
|
232
281
|
const offenses = [];
|
|
282
|
+
offenses.push(...unclosedComments(content));
|
|
233
283
|
const all = content.split('\n');
|
|
234
284
|
const statusLine = all
|
|
235
285
|
.slice(bodyStartIndex(all))
|
|
@@ -331,6 +381,7 @@ export const RETRO_SECTION_STEMS = [
|
|
|
331
381
|
// same date — that is fixing drift, not relaxing the check.
|
|
332
382
|
export function checkRetrospective(content) {
|
|
333
383
|
const offenses = [];
|
|
384
|
+
offenses.push(...unclosedComments(content));
|
|
334
385
|
const closedLine = content.split('\n').find((l) => /closed/i.test(l) && l.trim() !== '');
|
|
335
386
|
if (!closedLine) {
|
|
336
387
|
offenses.push({ rule: 'retro-closed-missing', detail: 'no "Closed" date line found near the top' });
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// surface.mjs — the `surface` block: one screen state written as an ordered list of blocks (sketch-specs D8–D10).
|
|
2
|
+
//
|
|
3
|
+
// ```surface
|
|
4
|
+
// state: ship-features
|
|
5
|
+
// route: /app/flags/[projectSlug]
|
|
6
|
+
// - head "Features" action "+ New feature"
|
|
7
|
+
// - summary count 4
|
|
8
|
+
// - list columns "feature | state in production | type & risk | on / off"
|
|
9
|
+
// ```
|
|
10
|
+
//
|
|
11
|
+
// One description serves three readers: the product owner (sketch-render.mjs draws it grey), the review (it is text
|
|
12
|
+
// in a seed) and a project's state contract (a project maps the generic kinds onto its own and compares the built
|
|
13
|
+
// page). So the grammar records only what survives a change of data: the kind of each block in order, the words
|
|
14
|
+
// that matter, and three facts — a primary action's words, a count, a list's column words. Never a value, a row
|
|
15
|
+
// count or a pixel.
|
|
16
|
+
//
|
|
17
|
+
// A line grammar rather than YAML because the kit has zero dependencies and no YAML parser; a hand-rolled YAML
|
|
18
|
+
// subset would accept things YAML readers reject and the other way round. Zero deps; no side effects at import.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The generic vocabulary: twelve kinds, each with the facts it may carry (D10).
|
|
22
|
+
*
|
|
23
|
+
* A fact on a kind that cannot carry it is an error, not ignored: a `count` on an `answer` means the author
|
|
24
|
+
* believes something is counted, and a sketch that silently drops it approves a picture of a different screen.
|
|
25
|
+
*/
|
|
26
|
+
export const SURFACE_KINDS = Object.freeze({
|
|
27
|
+
head: ['action'],
|
|
28
|
+
answer: [],
|
|
29
|
+
summary: ['count'],
|
|
30
|
+
tiles: ['count'],
|
|
31
|
+
toolbar: [],
|
|
32
|
+
list: ['columns'],
|
|
33
|
+
empty: [],
|
|
34
|
+
card: [],
|
|
35
|
+
steps: ['count'],
|
|
36
|
+
field: [],
|
|
37
|
+
tabs: [],
|
|
38
|
+
note: [],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const SURFACE_FACTS = Object.freeze(['action', 'count', 'columns']);
|
|
42
|
+
|
|
43
|
+
const KIND_NAMES = Object.keys(SURFACE_KINDS);
|
|
44
|
+
|
|
45
|
+
// OWN properties only. `kind in SURFACE_KINDS` also answers true for `toString`, `constructor` and `__proto__`, which
|
|
46
|
+
// then parsed as kinds and crashed the renderer with a raw TypeError instead of a line-numbered error (#210 review).
|
|
47
|
+
const isKind = (name) => Object.hasOwn(SURFACE_KINDS, name);
|
|
48
|
+
const STATE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
49
|
+
const HEADER = /^([A-Za-z][\w-]*):\s*(.*)$/;
|
|
50
|
+
|
|
51
|
+
/** A parse error that knows where it happened. `message` is `<file>:<line>: <reason>`, ready to print. */
|
|
52
|
+
export class SurfaceError extends Error {
|
|
53
|
+
constructor(file, line, reason) {
|
|
54
|
+
super(`${file}:${line}: ${reason}`);
|
|
55
|
+
this.name = 'SurfaceError';
|
|
56
|
+
this.file = file;
|
|
57
|
+
this.line = line;
|
|
58
|
+
this.reason = reason;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function distance(a, b) {
|
|
63
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
64
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
65
|
+
let diagonal = row[0];
|
|
66
|
+
row[0] = i;
|
|
67
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
68
|
+
const above = row[j];
|
|
69
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
70
|
+
diagonal = above;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return row[b.length];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function unknownKind(kind) {
|
|
77
|
+
const [nearest] = KIND_NAMES.map((name) => [name, distance(kind, name)]).sort((x, y) => x[1] - y[1]);
|
|
78
|
+
const hint = nearest[1] <= 2 ? ` — did you mean \`${nearest[0]}\`?` : '';
|
|
79
|
+
return `unknown kind \`${kind}\`${hint} Known kinds: ${KIND_NAMES.join(', ')}.`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Split the rest of a block line into quoted strings and bare words.
|
|
84
|
+
* Throws a plain string (the reason) so the caller attaches the line number once.
|
|
85
|
+
*/
|
|
86
|
+
function tokenize(text) {
|
|
87
|
+
const tokens = [];
|
|
88
|
+
let i = 0;
|
|
89
|
+
while (i < text.length) {
|
|
90
|
+
if (/\s/.test(text[i])) {
|
|
91
|
+
i += 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (text[i] === '"') {
|
|
95
|
+
let value = '';
|
|
96
|
+
i += 1;
|
|
97
|
+
for (;;) {
|
|
98
|
+
if (i >= text.length) throw 'unterminated string — a quoted string closes on the same line';
|
|
99
|
+
const char = text[i];
|
|
100
|
+
if (char === '\\') {
|
|
101
|
+
const next = text[i + 1];
|
|
102
|
+
if (next !== '"' && next !== '\\')
|
|
103
|
+
throw `unknown escape \`\\${next ?? ''}\` — only \\" and \\\\ are escapes`;
|
|
104
|
+
value += next;
|
|
105
|
+
i += 2;
|
|
106
|
+
} else if (char === '"') {
|
|
107
|
+
i += 1;
|
|
108
|
+
break;
|
|
109
|
+
} else {
|
|
110
|
+
value += char;
|
|
111
|
+
i += 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
tokens.push({ quoted: true, value });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
let word = '';
|
|
118
|
+
while (i < text.length && !/\s/.test(text[i])) {
|
|
119
|
+
if (text[i] === '"') throw `a quote inside \`${word}"\` — put a space before a quoted string`;
|
|
120
|
+
word += text[i];
|
|
121
|
+
i += 1;
|
|
122
|
+
}
|
|
123
|
+
tokens.push({ quoted: false, value: word });
|
|
124
|
+
}
|
|
125
|
+
return tokens;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Parse one block line (`<kind> ["words"] [fact value]…`, without its leading `- `). */
|
|
129
|
+
function parseBlockLine(rest) {
|
|
130
|
+
// The shape the seed template shipped before this grammar existed (`- heading: "Orders"`). Named, because the
|
|
131
|
+
// generic "unknown kind `heading:`" would send the author looking for a kind rather than at the format.
|
|
132
|
+
if (/^[a-z][\w-]*:(\s|$)/.test(rest)) {
|
|
133
|
+
throw (
|
|
134
|
+
'`- <kind>: "…"` is the old list shape — write `- <kind> "words"` (for example `- head "Orders"`); ' +
|
|
135
|
+
'see groom/references/intent-and-visuals.md'
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const tokens = tokenize(rest);
|
|
139
|
+
const first = tokens.shift();
|
|
140
|
+
if (first === undefined || first.quoted) throw 'a block line starts with its kind: `- <kind> …`';
|
|
141
|
+
const kind = first.value;
|
|
142
|
+
if (!isKind(kind)) throw unknownKind(kind);
|
|
143
|
+
|
|
144
|
+
const block = { kind, words: null };
|
|
145
|
+
if (tokens[0]?.quoted) block.words = tokens.shift().value;
|
|
146
|
+
|
|
147
|
+
while (tokens.length > 0) {
|
|
148
|
+
const name = tokens.shift();
|
|
149
|
+
if (name.quoted)
|
|
150
|
+
throw 'a second quoted string — a block has one words string; facts are `name value` pairs';
|
|
151
|
+
if (name.value === 'when:' || name.value === 'when') {
|
|
152
|
+
throw 'a surface is ONE state, so a block has no `when:` — write that state as its own surface block';
|
|
153
|
+
}
|
|
154
|
+
if (!SURFACE_FACTS.includes(name.value)) {
|
|
155
|
+
throw `unknown fact \`${name.value}\` — the facts are ${SURFACE_FACTS.join(', ')}`;
|
|
156
|
+
}
|
|
157
|
+
if (!SURFACE_KINDS[kind].includes(name.value)) {
|
|
158
|
+
const carries = SURFACE_KINDS[kind];
|
|
159
|
+
throw (
|
|
160
|
+
`\`${kind}\` carries no \`${name.value}\`` +
|
|
161
|
+
(carries.length > 0 ? ` (it carries: ${carries.join(', ')})` : ' (it carries no facts)')
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (Object.hasOwn(block, name.value)) throw `\`${name.value}\` given twice`;
|
|
165
|
+
const value = tokens.shift();
|
|
166
|
+
if (value === undefined) throw `\`${name.value}\` needs a value`;
|
|
167
|
+
if (name.value === 'count') {
|
|
168
|
+
// No leading zeros and no number past exact integers: `1e23` would be compared with a count read off a page.
|
|
169
|
+
if (value.quoted || !/^(0|[1-9]\d*)$/.test(value.value) || !Number.isSafeInteger(Number(value.value))) {
|
|
170
|
+
throw `\`count\` is a whole number, got \`${value.value}\``;
|
|
171
|
+
}
|
|
172
|
+
block.count = Number(value.value);
|
|
173
|
+
} else {
|
|
174
|
+
if (!value.quoted) throw `\`${name.value}\` takes a quoted string, got \`${value.value}\``;
|
|
175
|
+
if (name.value === 'action') block.action = value.value;
|
|
176
|
+
// A trailing bar is an unlabelled column — how a list's actions column reads today — so it is kept as "".
|
|
177
|
+
else block.columns = value.value.split('|').map((column) => column.trim());
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return block;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Parse one bare surface block — the whole of a `.surface` file, or the body of a fence.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} text the block's lines
|
|
187
|
+
* @param {string} file the name errors carry
|
|
188
|
+
* @param {number} firstLine the FILE line number of the text's first line, so an error inside a fenced block in a
|
|
189
|
+
* long seed points at the seed's line rather than the block's
|
|
190
|
+
* @returns {{ state: string, route: string, line: number, blocks: Array<{ kind: string, words: string|null,
|
|
191
|
+
* line: number, action?: string, count?: number, columns?: string[] }> }}
|
|
192
|
+
*/
|
|
193
|
+
export function parseSurface(text, file = '<surface>', firstLine = 1) {
|
|
194
|
+
const surface = { state: null, route: null, line: firstLine, blocks: [] };
|
|
195
|
+
const lines = text.split(/\r?\n/);
|
|
196
|
+
lines.forEach((raw, index) => {
|
|
197
|
+
const line = firstLine + index;
|
|
198
|
+
const content = raw.trim();
|
|
199
|
+
if (content === '' || content.startsWith('#')) return;
|
|
200
|
+
try {
|
|
201
|
+
if (content.startsWith('- ') || content === '-') {
|
|
202
|
+
surface.blocks.push({ ...parseBlockLine(content.slice(1).trim()), line });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const header = HEADER.exec(content);
|
|
206
|
+
if (header === null)
|
|
207
|
+
throw `expected \`state:\`, \`route:\` or a block line \`- <kind> …\`, got \`${content}\``;
|
|
208
|
+
const [, key, value = ''] = header;
|
|
209
|
+
if (key === 'blocks') {
|
|
210
|
+
throw 'no `blocks:` list — each block is its own line, `- <kind> "words"`, straight after `state:` and `route:`';
|
|
211
|
+
}
|
|
212
|
+
if (key === 'when')
|
|
213
|
+
throw 'a surface is ONE state, so it has no `when:` — write each state as its own block';
|
|
214
|
+
if (key !== 'state' && key !== 'route')
|
|
215
|
+
throw `unknown header \`${key}:\` — a surface has \`state:\` and \`route:\``;
|
|
216
|
+
if (surface.blocks.length > 0) throw `\`${key}:\` comes before the first block line`;
|
|
217
|
+
if (surface[key] !== null) throw `\`${key}:\` given twice`;
|
|
218
|
+
const trimmed = value.trim();
|
|
219
|
+
if (key === 'state' && !STATE_ID.test(trimmed)) {
|
|
220
|
+
throw `state id \`${trimmed}\` must be lower-case words joined by hyphens (e.g. \`ship-features-empty\`)`;
|
|
221
|
+
}
|
|
222
|
+
if (key === 'route' && !trimmed.startsWith('/')) throw `route \`${trimmed}\` must start with /`;
|
|
223
|
+
surface[key] = trimmed;
|
|
224
|
+
} catch (reason) {
|
|
225
|
+
if (typeof reason !== 'string') throw reason;
|
|
226
|
+
throw new SurfaceError(file, line, reason);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
if (surface.state === null)
|
|
230
|
+
throw new SurfaceError(file, firstLine, 'no `state:` line — every surface names its state id');
|
|
231
|
+
if (surface.route === null)
|
|
232
|
+
throw new SurfaceError(file, firstLine, 'no `route:` line — every surface names its route');
|
|
233
|
+
if (surface.blocks.length === 0) {
|
|
234
|
+
throw new SurfaceError(file, firstLine, 'no blocks — a surface lists at least one `- <kind> …` line');
|
|
235
|
+
}
|
|
236
|
+
return surface;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// CommonMark fences: up to three spaces of indent (four is an indented code block), three or more backticks or
|
|
240
|
+
// tildes. EVERY fence is tracked, not only `surface` ones, because a seed shows examples inside a longer fence
|
|
241
|
+
// (```` … ````) and a `surface` fence inside one is text, not a block.
|
|
242
|
+
const FENCE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
|
|
243
|
+
|
|
244
|
+
/** Every fenced `surface` block in a markdown file, in order. */
|
|
245
|
+
export function parseSurfaces(markdown, file = '<markdown>') {
|
|
246
|
+
const lines = markdown.split(/\r?\n/);
|
|
247
|
+
const surfaces = [];
|
|
248
|
+
let open = null;
|
|
249
|
+
lines.forEach((raw, index) => {
|
|
250
|
+
const fence = FENCE.exec(raw);
|
|
251
|
+
if (open === null) {
|
|
252
|
+
if (fence === null) return;
|
|
253
|
+
const [, , marks, info] = fence;
|
|
254
|
+
// A backtick fence's info string may not contain a backtick (CommonMark), so ```x``` is inline code.
|
|
255
|
+
if (marks[0] === '`' && info.includes('`')) return;
|
|
256
|
+
open = {
|
|
257
|
+
char: marks[0],
|
|
258
|
+
length: marks.length,
|
|
259
|
+
surface: info.trim() === 'surface',
|
|
260
|
+
line: index + 1,
|
|
261
|
+
body: [],
|
|
262
|
+
};
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const closes =
|
|
266
|
+
fence !== null && fence[2][0] === open.char && fence[2].length >= open.length && fence[3].trim() === '';
|
|
267
|
+
if (!closes) {
|
|
268
|
+
open.body.push(raw);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (open.surface) surfaces.push(parseSurface(open.body.join('\n'), file, open.line + 1));
|
|
272
|
+
open = null;
|
|
273
|
+
});
|
|
274
|
+
if (open?.surface) {
|
|
275
|
+
throw new SurfaceError(file, open.line, 'this `surface` fence is never closed');
|
|
276
|
+
}
|
|
277
|
+
return surfaces;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Check a project's `surface.map.json` (D11): `{ kinds: { <generic kind>: <project kind> } }`.
|
|
282
|
+
* Returns the problems, empty when the map is usable. Whether each VALUE is one of the project's own kinds only the
|
|
283
|
+
* project can say, so that half of the check lives with the project.
|
|
284
|
+
*/
|
|
285
|
+
export function validateMap(map) {
|
|
286
|
+
if (
|
|
287
|
+
map === null ||
|
|
288
|
+
typeof map !== 'object' ||
|
|
289
|
+
map.kinds === null ||
|
|
290
|
+
typeof map.kinds !== 'object' ||
|
|
291
|
+
Array.isArray(map.kinds)
|
|
292
|
+
) {
|
|
293
|
+
return ['the map has no `kinds` object'];
|
|
294
|
+
}
|
|
295
|
+
const problems = [];
|
|
296
|
+
for (const [generic, target] of Object.entries(map.kinds)) {
|
|
297
|
+
if (!isKind(generic)) problems.push(unknownKind(generic));
|
|
298
|
+
if (typeof target !== 'string' || target.trim() === '') {
|
|
299
|
+
problems.push(`\`${generic}\` maps to ${JSON.stringify(target)}, not a kind name`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return problems;
|
|
303
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sketch-render.mjs — draw every `surface` block in a file as a plain grey wireframe (sketch-specs D16).
|
|
3
|
+
//
|
|
4
|
+
// node scripts/sketch-render.mjs Roadmap/00-ideas/seeds/<seed>.md [--out <file.html>]
|
|
5
|
+
// node scripts/sketch-render.mjs apps/web/design-system/surfaces/<state>.surface
|
|
6
|
+
//
|
|
7
|
+
// The product owner approves a PICTURE, not a text list, so each state becomes one grey section: boxes in the
|
|
8
|
+
// order the blocks are written, with the words that matter and the facts drawn (a count as that many boxes, a
|
|
9
|
+
// list's column words over placeholder rows). Without --out the page goes to the OS temp folder, so a render never
|
|
10
|
+
// litters `Roadmap/`; the path is printed either way.
|
|
11
|
+
//
|
|
12
|
+
// ⚠️ **Deliberately grey, and deliberately blind to any design system (D4).** A wireframe in brand colours gets
|
|
13
|
+
// reviewed for its colours, and a renderer that imports the project's components becomes a second component
|
|
14
|
+
// library that drifts from the first. So this file imports only `node:*` and the parser, every colour is a grey,
|
|
15
|
+
// and a test fails on anything else. Zero deps.
|
|
16
|
+
|
|
17
|
+
import { realpathSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { basename, extname, join } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { parseSurface, parseSurfaces, SurfaceError } from './lib/surface.mjs';
|
|
22
|
+
|
|
23
|
+
// Above this a count stops being a shape and becomes noise; the true number is printed beside the boxes.
|
|
24
|
+
const MAX_BOXES = 12;
|
|
25
|
+
|
|
26
|
+
const escape = (text) =>
|
|
27
|
+
String(text).replace(
|
|
28
|
+
/[&<>"']/g,
|
|
29
|
+
(char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// A kind whose words are missing still draws its shape: a grey bar where the words would be.
|
|
33
|
+
const words = (block, tag = 'span', cls = 'w') =>
|
|
34
|
+
block.words === null
|
|
35
|
+
? `<span class="bar"></span>`
|
|
36
|
+
: `<${tag} class="${cls}">${escape(block.words)}</${tag}>`;
|
|
37
|
+
|
|
38
|
+
function boxes(count, cls, fill = () => '<span class="bar"></span>') {
|
|
39
|
+
const drawn = Math.min(count, MAX_BOXES);
|
|
40
|
+
const items = Array.from({ length: drawn }, (_, i) => `<div class="${cls}">${fill(i)}</div>`).join('');
|
|
41
|
+
return items + (count > MAX_BOXES ? `<span class="more">×${count}</span>` : '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DRAW = {
|
|
45
|
+
head: (b) =>
|
|
46
|
+
`<div class="row">${words(b, 'h2', 'title')}${
|
|
47
|
+
b.action === undefined ? '' : `<span class="button">${escape(b.action)}</span>`
|
|
48
|
+
}</div>`,
|
|
49
|
+
answer: (b) => words(b, 'p', 'answer'),
|
|
50
|
+
summary: (b) => `<div class="row wrap">${boxes(b.count ?? 0, 'stat')}</div>`,
|
|
51
|
+
tiles: (b) =>
|
|
52
|
+
`<div class="grid">${boxes(b.count ?? 0, 'tile', () => '<span class="bar"></span><span class="bar short"></span>')}</div>`,
|
|
53
|
+
steps: (b) =>
|
|
54
|
+
`<div class="row steps">${boxes(b.count ?? 3, 'step', (i) => `<span class="dot">${i + 1}</span>`)}</div>`,
|
|
55
|
+
toolbar: () =>
|
|
56
|
+
'<div class="row"><span class="search">Search</span><span class="pill"></span><span class="pill"></span></div>',
|
|
57
|
+
list: (b) => {
|
|
58
|
+
const columns = b.columns ?? null;
|
|
59
|
+
const cells = columns === null ? 1 : columns.length;
|
|
60
|
+
const head =
|
|
61
|
+
columns === null
|
|
62
|
+
? ''
|
|
63
|
+
: `<div class="lrow lhead">${columns.map((c) => `<span>${escape(c)}</span>`).join('')}</div>`;
|
|
64
|
+
const row = `<div class="lrow">${'<span><span class="bar"></span></span>'.repeat(cells)}</div>`;
|
|
65
|
+
return `<div class="listbox">${head}${row.repeat(3)}</div>`;
|
|
66
|
+
},
|
|
67
|
+
empty: (b) => `<div class="empty">${words(b)}</div>`,
|
|
68
|
+
card: (b) => `<div class="card">${words(b)}</div>`,
|
|
69
|
+
field: (b) => `<label class="field">${words(b)}<span class="input"></span></label>`,
|
|
70
|
+
tabs: (b) => {
|
|
71
|
+
const labels = b.words === null ? ['', '', ''] : b.words.split('|').map((t) => t.trim());
|
|
72
|
+
return `<div class="row tabs">${labels
|
|
73
|
+
.map(
|
|
74
|
+
(t, i) =>
|
|
75
|
+
`<span class="tab${i === 0 ? ' on' : ''}">${t === '' ? '<span class="bar"></span>' : escape(t)}</span>`
|
|
76
|
+
)
|
|
77
|
+
.join('')}</div>`;
|
|
78
|
+
},
|
|
79
|
+
note: (b) => words(b, 'p', 'note'),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const STYLE = `
|
|
83
|
+
*{box-sizing:border-box}body{margin:0;padding:24px 16px;background:#f4f4f4;color:#222;font:14px/1.45 system-ui,sans-serif}
|
|
84
|
+
main{max-width:960px;margin:0 auto}h1{font-size:15px;color:#666;font-weight:600;margin:0 0 16px}
|
|
85
|
+
section{background:#fff;border:1px solid #ccc;border-radius:8px;padding:20px;margin:0 0 28px}
|
|
86
|
+
header{font:12px ui-monospace,monospace;color:#777;border-bottom:1px dashed #ccc;padding-bottom:8px;margin-bottom:16px}
|
|
87
|
+
.block{margin:0 0 14px}.row{display:flex;gap:10px;align-items:center}.wrap{flex-wrap:wrap}
|
|
88
|
+
.title{font-size:22px;margin:0;flex:1}.button{border:1px solid #555;border-radius:6px;padding:6px 12px;background:#e6e6e6;font-weight:600}
|
|
89
|
+
.answer{font-size:16px;font-style:italic;margin:0;color:#333}.note{font-size:12px;color:#777;margin:0}
|
|
90
|
+
.bar{display:inline-block;height:10px;width:120px;max-width:100%;background:#ddd;border-radius:3px}.bar.short{width:60px}
|
|
91
|
+
.stat{border:1px solid #ccc;border-radius:6px;padding:10px;min-width:90px}
|
|
92
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px}
|
|
93
|
+
.tile{border:1px solid #ccc;border-radius:6px;padding:16px;display:flex;flex-direction:column;gap:8px;min-height:80px}
|
|
94
|
+
.more{font-size:12px;color:#777;align-self:center}
|
|
95
|
+
.steps .step{display:flex;align-items:center;gap:10px}.steps .step+.step::before{content:"";width:28px;border-top:1px solid #aaa}
|
|
96
|
+
.dot{display:inline-flex;width:26px;height:26px;border-radius:50%;border:1px solid #777;align-items:center;justify-content:center;font-size:12px}
|
|
97
|
+
.search{flex:1;max-width:320px;border:1px solid #bbb;border-radius:6px;padding:6px 10px;color:#999}
|
|
98
|
+
.pill{width:70px;height:26px;border:1px solid #bbb;border-radius:13px}
|
|
99
|
+
.listbox{border:1px solid #ccc;border-radius:6px;overflow:hidden}.lrow{display:flex;border-top:1px solid #e4e4e4}
|
|
100
|
+
.lrow:first-child{border-top:0}.lrow>span{flex:1;min-width:0;padding:10px;min-height:38px}
|
|
101
|
+
.lhead{overflow-wrap:anywhere;background:#eee;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#555;font-weight:600}
|
|
102
|
+
.empty{border:2px dashed #bbb;border-radius:8px;padding:32px;text-align:center;color:#666}
|
|
103
|
+
.card{border:1px solid #bbb;border-radius:8px;padding:16px}
|
|
104
|
+
.field{display:flex;flex-direction:column;gap:6px;max-width:420px}.input{height:34px;border:1px solid #aaa;border-radius:6px;background:#fafafa}
|
|
105
|
+
.tabs{gap:0;border-bottom:1px solid #ccc}.tab{padding:8px 14px;color:#777}.tab.on{color:#222;border-bottom:2px solid #222;font-weight:600}
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
/** One self-contained grey page, a section per surface. */
|
|
109
|
+
export function renderSketch(surfaces, { title = 'Sketch' } = {}) {
|
|
110
|
+
const sections = surfaces
|
|
111
|
+
.map(
|
|
112
|
+
(surface) =>
|
|
113
|
+
`<section><header>${escape(surface.state)} · ${escape(surface.route)}</header>${surface.blocks
|
|
114
|
+
// `block.kind` is one of the parser's twelve (an own-property check there), so it is safe as an attribute
|
|
115
|
+
// and always has a drawing.
|
|
116
|
+
.map((block) => `<div class="block" data-kind="${block.kind}">${DRAW[block.kind](block)}</div>`)
|
|
117
|
+
.join('\n')}</section>`
|
|
118
|
+
)
|
|
119
|
+
.join('\n');
|
|
120
|
+
return `<!doctype html>
|
|
121
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
122
|
+
<title>${escape(title)}</title><style>${STYLE}</style></head>
|
|
123
|
+
<body><main><h1>${escape(title)} — grey wireframe, ${surfaces.length} state${surfaces.length === 1 ? '' : 's'}</h1>
|
|
124
|
+
${sections}
|
|
125
|
+
</main></body></html>
|
|
126
|
+
`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const USAGE = 'usage: node scripts/sketch-render.mjs <file.md|file.surface> [--out <file.html>]';
|
|
130
|
+
|
|
131
|
+
export function main(argv) {
|
|
132
|
+
const args = [...argv];
|
|
133
|
+
let out = null;
|
|
134
|
+
let file = null;
|
|
135
|
+
while (args.length > 0) {
|
|
136
|
+
const arg = args.shift();
|
|
137
|
+
if (arg === '--out') {
|
|
138
|
+
out = args.shift() ?? null;
|
|
139
|
+
if (out === null) return { code: 2, stderr: USAGE };
|
|
140
|
+
} else if (arg.startsWith('-') || file !== null) {
|
|
141
|
+
return { code: 2, stderr: `unknown argument \`${arg}\`\n${USAGE}` };
|
|
142
|
+
} else {
|
|
143
|
+
file = arg;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (file === null) return { code: 2, stderr: USAGE };
|
|
147
|
+
|
|
148
|
+
const text = readFileSync(file, 'utf8');
|
|
149
|
+
let surfaces;
|
|
150
|
+
try {
|
|
151
|
+
surfaces = extname(file) === '.surface' ? [parseSurface(text, file)] : parseSurfaces(text, file);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof SurfaceError) return { code: 1, stderr: error.message };
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
if (surfaces.length === 0) return { code: 1, stderr: `${file}: no \`surface\` block to draw` };
|
|
157
|
+
|
|
158
|
+
const name = basename(file, extname(file));
|
|
159
|
+
const target = out ?? join(tmpdir(), `sketch-${name}.html`);
|
|
160
|
+
writeFileSync(target, renderSketch(surfaces, { title: `Sketch — ${name}` }));
|
|
161
|
+
return { code: 0, stdout: target };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// realpath on both sides: through a symlinked path a plain compare is false and the script exits 0 having done
|
|
165
|
+
// nothing (#189 review).
|
|
166
|
+
const isMain = (() => {
|
|
167
|
+
try {
|
|
168
|
+
return (
|
|
169
|
+
!!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
|
|
170
|
+
);
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
})();
|
|
175
|
+
if (isMain) {
|
|
176
|
+
const { code, stdout, stderr } = main(process.argv.slice(2));
|
|
177
|
+
if (stdout) console.log(stdout);
|
|
178
|
+
if (stderr) console.error(stderr);
|
|
179
|
+
process.exitCode = code;
|
|
180
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@golden-frijoles/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "The scripts the Golden Frijoles skills run, packaged so they work in any repo: planning, build-order, reporting and verification rails. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|