@nusoft/nuos-build-catalogue 0.25.0 → 0.27.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/cli.js +24 -0
- package/dist/commands/init.js +10 -32
- package/dist/commands/mode.d.ts +24 -0
- package/dist/commands/mode.js +82 -0
- package/dist/commands/render.d.ts +25 -0
- package/dist/commands/render.js +40 -0
- package/dist/render/architecture.d.ts +18 -0
- package/dist/render/architecture.js +74 -0
- package/dist/render/design-system.d.ts +26 -0
- package/dist/render/design-system.js +308 -0
- package/dist/render/html.d.ts +47 -0
- package/dist/render/html.js +258 -0
- package/dist/render/maps.d.ts +18 -0
- package/dist/render/maps.js +67 -0
- package/dist/render/parser.d.ts +54 -0
- package/dist/render/parser.js +162 -0
- package/dist/render/run.d.ts +29 -0
- package/dist/render/run.js +92 -0
- package/dist/render/surfaces.d.ts +23 -0
- package/dist/render/surfaces.js +144 -0
- package/package.json +2 -2
- package/templates/agents/coder.md +7 -5
- package/templates/agents/reviewer.md +6 -4
- package/templates/protocols/build-wu.md +13 -63
- package/templates/protocols/end-of-session.md +9 -3
- package/templates/protocols/persona-new.md +1 -1
- package/templates/protocols/plan-architecture.md +10 -24
- package/templates/protocols/plan-initial-wu.md +6 -26
- package/templates/protocols/plan-maps.md +5 -17
- package/templates/protocols/plan-orientation.md +13 -29
- package/templates/protocols/plan-review.md +2 -0
- package/templates/protocols/plan-uiux.md +35 -86
- package/templates/protocols/start-of-session.md +13 -1
- package/templates/protocols/wu-new.md +1 -1
- package/templates/starter-kit/CLAUDE.md +9 -37
- package/templates/starter-kit/docs/build/OPERATOR-MODES.md +44 -0
- package/templates/starter-kit/methodfile.json +5 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render the `design-system/` register as an interactive companion gallery.
|
|
3
|
+
*
|
|
4
|
+
* What gets rendered:
|
|
5
|
+
* - Colour tokens — as visual swatches with the hex value beside the name.
|
|
6
|
+
* - Type scale — each step rendered at its declared px size with its line-height
|
|
7
|
+
* and weight applied so the operator sees the real shape, not a description.
|
|
8
|
+
* - Spacing scale — rendered as horizontal bars at the actual pixel widths.
|
|
9
|
+
* - Radius & elevation — small boxes showing the corner radius applied.
|
|
10
|
+
* - Motion — table only (no animation; would push complexity past MVP scope).
|
|
11
|
+
* - Components + patterns — name list with first-paragraph summary.
|
|
12
|
+
* - Voice + accessibility — first-paragraph summary blocks.
|
|
13
|
+
*
|
|
14
|
+
* Token files use GFM tables with a `Token` column and a value column. The parser
|
|
15
|
+
* looks for either a `Hex` / `Value` / `Size` / `Used for` column shape and falls
|
|
16
|
+
* back to "list the table verbatim" when the shape isn't recognised.
|
|
17
|
+
*/
|
|
18
|
+
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { parseSections, parseTable, firstParagraph, isPlaceholder, extractHexColours, } from './parser.js';
|
|
22
|
+
import { pageWrapper, card, emptyState, escapeHtml, renderInlineMarkdown, renderProse, } from './html.js';
|
|
23
|
+
export async function renderDesignSystem(opts) {
|
|
24
|
+
const dir = path.join(opts.buildRoot, 'design-system');
|
|
25
|
+
const outPath = path.join(dir, '_view.html');
|
|
26
|
+
if (!existsSync(dir)) {
|
|
27
|
+
return { written: false, outPath };
|
|
28
|
+
}
|
|
29
|
+
const blocks = [];
|
|
30
|
+
blocks.push(await renderTokenFile(dir, 'tokens-colour.md', renderColourTokens));
|
|
31
|
+
blocks.push(await renderTokenFile(dir, 'tokens-typography.md', renderTypeTokens));
|
|
32
|
+
blocks.push(await renderTokenFile(dir, 'tokens-spacing.md', renderSpacingTokens));
|
|
33
|
+
blocks.push(await renderTokenFile(dir, 'tokens-radius-elevation.md', renderRadiusTokens));
|
|
34
|
+
blocks.push(await renderTokenFile(dir, 'tokens-motion.md', renderMotionTokens));
|
|
35
|
+
blocks.push(await renderListing(path.join(dir, 'components'), 'Components'));
|
|
36
|
+
blocks.push(await renderListing(path.join(dir, 'patterns'), 'Patterns'));
|
|
37
|
+
blocks.push(await renderPlainFile(dir, 'voice.md', 'Voice'));
|
|
38
|
+
blocks.push(await renderPlainFile(dir, 'accessibility.md', 'Accessibility'));
|
|
39
|
+
const body = blocks.filter(Boolean).join('\n');
|
|
40
|
+
const html = pageWrapper({
|
|
41
|
+
title: 'Design system',
|
|
42
|
+
projectName: opts.projectName,
|
|
43
|
+
generatedAt: opts.generatedAt,
|
|
44
|
+
sourceNote: '<code>docs/build/design-system/</code>',
|
|
45
|
+
body: body || card('Design system not yet populated', emptyState('Phase C of planning populates this register.')),
|
|
46
|
+
});
|
|
47
|
+
await mkdir(dir, { recursive: true });
|
|
48
|
+
await writeFile(outPath, html, 'utf8');
|
|
49
|
+
return { written: true, outPath };
|
|
50
|
+
}
|
|
51
|
+
async function renderTokenFile(dir, filename, renderer) {
|
|
52
|
+
const filePath = path.join(dir, filename);
|
|
53
|
+
if (!existsSync(filePath))
|
|
54
|
+
return '';
|
|
55
|
+
const md = await readFile(filePath, 'utf8');
|
|
56
|
+
if (isPlaceholder(md)) {
|
|
57
|
+
return card(prettyTitle(filename), emptyState(`Not yet filled in (${filename}).`), { tone: 'muted' });
|
|
58
|
+
}
|
|
59
|
+
const sections = parseSections(md);
|
|
60
|
+
return renderer(sections, md);
|
|
61
|
+
}
|
|
62
|
+
function renderColourTokens(sections, md) {
|
|
63
|
+
const swatchBlocks = [];
|
|
64
|
+
for (const [heading, body] of sections) {
|
|
65
|
+
if (heading === '')
|
|
66
|
+
continue;
|
|
67
|
+
const table = parseTable(body);
|
|
68
|
+
if (!table)
|
|
69
|
+
continue;
|
|
70
|
+
const tokenColumn = findColumn(table, ['Token', 'Name']);
|
|
71
|
+
const hexColumn = findColumn(table, ['Hex', 'Value']);
|
|
72
|
+
const useColumn = findColumn(table, ['Used for', 'Role', 'Description']);
|
|
73
|
+
if (tokenColumn === -1)
|
|
74
|
+
continue;
|
|
75
|
+
const swatches = table.rows
|
|
76
|
+
.map((row) => {
|
|
77
|
+
const token = stripInline(row[tokenColumn] ?? '');
|
|
78
|
+
const value = stripInline(row[hexColumn === -1 ? 1 : hexColumn] ?? '');
|
|
79
|
+
const purpose = useColumn === -1 ? '' : row[useColumn] ?? '';
|
|
80
|
+
const hexes = extractHexColours(value);
|
|
81
|
+
const hex = hexes[0];
|
|
82
|
+
if (!hex)
|
|
83
|
+
return '';
|
|
84
|
+
return `<div class="swatch">
|
|
85
|
+
<div class="swatch__chip" style="background:${escapeHtml(hex)}"></div>
|
|
86
|
+
<div class="swatch__meta">
|
|
87
|
+
<code>${escapeHtml(token)}</code>
|
|
88
|
+
<small>${escapeHtml(hex)}${purpose ? ` · ${escapeHtml(stripInline(purpose))}` : ''}</small>
|
|
89
|
+
</div>
|
|
90
|
+
</div>`;
|
|
91
|
+
})
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.join('');
|
|
94
|
+
if (swatches) {
|
|
95
|
+
swatchBlocks.push(`<h4>${escapeHtml(heading)}</h4>${swatches}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (swatchBlocks.length === 0) {
|
|
99
|
+
return card('Colour tokens', renderProse(md));
|
|
100
|
+
}
|
|
101
|
+
return card('Colour tokens', swatchBlocks.join(''));
|
|
102
|
+
}
|
|
103
|
+
function renderTypeTokens(sections, md) {
|
|
104
|
+
const blocks = [];
|
|
105
|
+
for (const [heading, body] of sections) {
|
|
106
|
+
if (heading === '')
|
|
107
|
+
continue;
|
|
108
|
+
const table = parseTable(body);
|
|
109
|
+
if (!table)
|
|
110
|
+
continue;
|
|
111
|
+
const tokenCol = findColumn(table, ['Token', 'Name']);
|
|
112
|
+
const sizeCol = findColumn(table, ['Size']);
|
|
113
|
+
const lineCol = findColumn(table, ['Line height']);
|
|
114
|
+
const weightCol = findColumn(table, ['Weight']);
|
|
115
|
+
const useCol = findColumn(table, ['Used for']);
|
|
116
|
+
if (tokenCol === -1)
|
|
117
|
+
continue;
|
|
118
|
+
if (sizeCol !== -1) {
|
|
119
|
+
const samples = table.rows
|
|
120
|
+
.map((row) => {
|
|
121
|
+
const token = stripInline(row[tokenCol] ?? '');
|
|
122
|
+
const size = stripInline(row[sizeCol] ?? '');
|
|
123
|
+
const line = lineCol === -1 ? '' : stripInline(row[lineCol] ?? '');
|
|
124
|
+
const weight = weightCol === -1 ? '' : stripInline(row[weightCol] ?? '');
|
|
125
|
+
const use = useCol === -1 ? '' : stripInline(row[useCol] ?? '');
|
|
126
|
+
const cssSize = parsePxOrRem(size) ?? '16px';
|
|
127
|
+
const cssLine = parseUnitless(line) ?? '1.4';
|
|
128
|
+
const cssWeight = parseWeight(weight) ?? '400';
|
|
129
|
+
return `<div class="type-row">
|
|
130
|
+
<span class="type-sample" style="font-size:${cssSize};line-height:${cssLine};font-weight:${cssWeight}">The quick brown fox</span>
|
|
131
|
+
<small><code>${escapeHtml(token)}</code> · ${escapeHtml([size, weight, use].filter(Boolean).join(' · '))}</small>
|
|
132
|
+
</div>`;
|
|
133
|
+
})
|
|
134
|
+
.join('');
|
|
135
|
+
blocks.push(`<h4>${escapeHtml(heading)}</h4>${samples}`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
blocks.push(`<h4>${escapeHtml(heading)}</h4>${renderTableAsTokens(table)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (blocks.length === 0)
|
|
142
|
+
return card('Typography', renderProse(md));
|
|
143
|
+
return card('Typography', blocks.join(''));
|
|
144
|
+
}
|
|
145
|
+
function renderSpacingTokens(sections, md) {
|
|
146
|
+
const blocks = [];
|
|
147
|
+
for (const [heading, body] of sections) {
|
|
148
|
+
if (heading === '')
|
|
149
|
+
continue;
|
|
150
|
+
const table = parseTable(body);
|
|
151
|
+
if (!table)
|
|
152
|
+
continue;
|
|
153
|
+
const tokenCol = findColumn(table, ['Token', 'Name', 'Step']);
|
|
154
|
+
const valCol = findColumn(table, ['Value', 'Size', 'px']);
|
|
155
|
+
if (tokenCol === -1 || valCol === -1) {
|
|
156
|
+
blocks.push(`<h4>${escapeHtml(heading)}</h4>${renderTableAsTokens(table)}`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const bars = table.rows
|
|
160
|
+
.map((row) => {
|
|
161
|
+
const token = stripInline(row[tokenCol] ?? '');
|
|
162
|
+
const value = stripInline(row[valCol] ?? '');
|
|
163
|
+
const px = parsePx(value);
|
|
164
|
+
if (!px)
|
|
165
|
+
return '';
|
|
166
|
+
return `<div class="spacing-row">
|
|
167
|
+
<small><code>${escapeHtml(token)}</code> · ${escapeHtml(value)}</small>
|
|
168
|
+
<div class="spacing-bar" style="width:${px}px;max-width:100%"></div>
|
|
169
|
+
</div>`;
|
|
170
|
+
})
|
|
171
|
+
.filter(Boolean)
|
|
172
|
+
.join('');
|
|
173
|
+
blocks.push(`<h4>${escapeHtml(heading)}</h4>${bars || renderTableAsTokens(table)}`);
|
|
174
|
+
}
|
|
175
|
+
if (blocks.length === 0)
|
|
176
|
+
return card('Spacing', renderProse(md));
|
|
177
|
+
return card('Spacing', blocks.join(''));
|
|
178
|
+
}
|
|
179
|
+
function renderRadiusTokens(sections, md) {
|
|
180
|
+
const blocks = [];
|
|
181
|
+
for (const [heading, body] of sections) {
|
|
182
|
+
if (heading === '')
|
|
183
|
+
continue;
|
|
184
|
+
const table = parseTable(body);
|
|
185
|
+
if (!table)
|
|
186
|
+
continue;
|
|
187
|
+
const tokenCol = findColumn(table, ['Token', 'Name']);
|
|
188
|
+
const valCol = findColumn(table, ['Value', 'Radius', 'Size']);
|
|
189
|
+
if (tokenCol === -1 || valCol === -1) {
|
|
190
|
+
blocks.push(`<h4>${escapeHtml(heading)}</h4>${renderTableAsTokens(table)}`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const examples = table.rows
|
|
194
|
+
.map((row) => {
|
|
195
|
+
const token = stripInline(row[tokenCol] ?? '');
|
|
196
|
+
const value = stripInline(row[valCol] ?? '');
|
|
197
|
+
const px = parsePx(value);
|
|
198
|
+
return `<div class="radius-row">
|
|
199
|
+
<div class="radius-chip" style="border-radius:${px ?? 0}px"></div>
|
|
200
|
+
<small><code>${escapeHtml(token)}</code> · ${escapeHtml(value)}</small>
|
|
201
|
+
</div>`;
|
|
202
|
+
})
|
|
203
|
+
.join('');
|
|
204
|
+
blocks.push(`<h4>${escapeHtml(heading)}</h4>${examples}`);
|
|
205
|
+
}
|
|
206
|
+
if (blocks.length === 0)
|
|
207
|
+
return card('Radius & elevation', renderProse(md));
|
|
208
|
+
return card('Radius & elevation', blocks.join(''));
|
|
209
|
+
}
|
|
210
|
+
function renderMotionTokens(sections, md) {
|
|
211
|
+
const blocks = [];
|
|
212
|
+
for (const [heading, body] of sections) {
|
|
213
|
+
if (heading === '')
|
|
214
|
+
continue;
|
|
215
|
+
const table = parseTable(body);
|
|
216
|
+
if (table)
|
|
217
|
+
blocks.push(`<h4>${escapeHtml(heading)}</h4>${renderTableAsTokens(table)}`);
|
|
218
|
+
}
|
|
219
|
+
if (blocks.length === 0)
|
|
220
|
+
return card('Motion', renderProse(md));
|
|
221
|
+
return card('Motion', blocks.join(''));
|
|
222
|
+
}
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Components / patterns listing
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
async function renderListing(dir, title) {
|
|
227
|
+
if (!existsSync(dir))
|
|
228
|
+
return '';
|
|
229
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
230
|
+
const items = [];
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
if (!entry.isFile() || !entry.name.endsWith('.md'))
|
|
233
|
+
continue;
|
|
234
|
+
if (entry.name === '_index.md' || entry.name === '_template.md')
|
|
235
|
+
continue;
|
|
236
|
+
const md = await readFile(path.join(dir, entry.name), 'utf8');
|
|
237
|
+
if (isPlaceholder(md))
|
|
238
|
+
continue;
|
|
239
|
+
const titleMatch = md.match(/^#\s+(.+)$/m);
|
|
240
|
+
const name = titleMatch ? titleMatch[1].trim() : entry.name;
|
|
241
|
+
const summary = firstParagraph(md);
|
|
242
|
+
items.push(`<div class="listing-item">
|
|
243
|
+
<strong>${escapeHtml(name)}</strong>
|
|
244
|
+
${summary ? `<p>${renderInlineMarkdown(summary)}</p>` : ''}
|
|
245
|
+
<small><code>${escapeHtml(entry.name)}</code></small>
|
|
246
|
+
</div>`);
|
|
247
|
+
}
|
|
248
|
+
if (items.length === 0)
|
|
249
|
+
return card(title, emptyState('None filed yet.'), { tone: 'muted' });
|
|
250
|
+
return card(title, items.join(''));
|
|
251
|
+
}
|
|
252
|
+
async function renderPlainFile(dir, filename, title) {
|
|
253
|
+
const filePath = path.join(dir, filename);
|
|
254
|
+
if (!existsSync(filePath))
|
|
255
|
+
return '';
|
|
256
|
+
const md = await readFile(filePath, 'utf8');
|
|
257
|
+
if (isPlaceholder(md)) {
|
|
258
|
+
return card(title, emptyState(`Not yet filled in (${filename}).`), { tone: 'muted' });
|
|
259
|
+
}
|
|
260
|
+
const summary = firstParagraph(md);
|
|
261
|
+
return card(title, summary ? `<p>${renderInlineMarkdown(summary)}</p>` : renderProse(md));
|
|
262
|
+
}
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Helpers
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
function prettyTitle(filename) {
|
|
267
|
+
return filename
|
|
268
|
+
.replace(/\.md$/, '')
|
|
269
|
+
.replace(/^tokens-/, '')
|
|
270
|
+
.replace(/-/g, ' ')
|
|
271
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
272
|
+
}
|
|
273
|
+
function findColumn(table, candidates) {
|
|
274
|
+
const lower = table.headers.map((h) => h.toLowerCase());
|
|
275
|
+
for (const c of candidates) {
|
|
276
|
+
const idx = lower.indexOf(c.toLowerCase());
|
|
277
|
+
if (idx >= 0)
|
|
278
|
+
return idx;
|
|
279
|
+
}
|
|
280
|
+
return -1;
|
|
281
|
+
}
|
|
282
|
+
function stripInline(s) {
|
|
283
|
+
return s.replace(/^`(.+)`$/, '$1').replace(/\\\|/g, '|').trim();
|
|
284
|
+
}
|
|
285
|
+
function renderTableAsTokens(table) {
|
|
286
|
+
const head = table.headers.map((h) => `<th>${escapeHtml(h)}</th>`).join('');
|
|
287
|
+
const rows = table.rows
|
|
288
|
+
.map((row) => row.map((cell) => `<td>${renderInlineMarkdown(cell)}</td>`).join(''))
|
|
289
|
+
.map((r) => `<tr>${r}</tr>`)
|
|
290
|
+
.join('');
|
|
291
|
+
return `<table class="tokens-table"><thead><tr>${head}</tr></thead><tbody>${rows}</tbody></table>`;
|
|
292
|
+
}
|
|
293
|
+
function parsePxOrRem(s) {
|
|
294
|
+
const m = s.match(/(\d+(?:\.\d+)?)(px|rem|em)/);
|
|
295
|
+
return m ? `${m[1]}${m[2]}` : null;
|
|
296
|
+
}
|
|
297
|
+
function parsePx(s) {
|
|
298
|
+
const m = s.match(/(\d+(?:\.\d+)?)\s*px/i);
|
|
299
|
+
return m ? Number(m[1]) : null;
|
|
300
|
+
}
|
|
301
|
+
function parseUnitless(s) {
|
|
302
|
+
const m = s.match(/^(\d+(?:\.\d+)?)$/);
|
|
303
|
+
return m ? m[1] : null;
|
|
304
|
+
}
|
|
305
|
+
function parseWeight(s) {
|
|
306
|
+
const m = s.match(/\b(100|200|300|400|500|600|700|800|900)\b/);
|
|
307
|
+
return m ? m[1] : null;
|
|
308
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML primitives shared across the per-register renderers.
|
|
3
|
+
*
|
|
4
|
+
* Design notes:
|
|
5
|
+
* - Pure static HTML. No JS, no framework, no external CSS or fonts.
|
|
6
|
+
* - One self-contained file per register so the operator can open it directly
|
|
7
|
+
* in a browser or share it without a build step.
|
|
8
|
+
* - Inline styles in a single `<style>` block; CSS variables for the few values
|
|
9
|
+
* the renderers care about (token swatches, type previews) so they can be
|
|
10
|
+
* overridden per-block without restating the rule set.
|
|
11
|
+
* - The visual language is deliberately neutral — cards, restrained spacing,
|
|
12
|
+
* system fonts. Consumer projects with strong design opinions can post-process
|
|
13
|
+
* or skip companion rendering; the default isn't trying to be a brand.
|
|
14
|
+
*/
|
|
15
|
+
export declare function escapeHtml(s: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Convert a single line of markdown to inline HTML — `code`, **bold**, *italic*,
|
|
18
|
+
* and [text](url) links. Block-level constructs (headings, lists, tables) are not
|
|
19
|
+
* processed; callers parse those structurally and use this helper on the cells.
|
|
20
|
+
*/
|
|
21
|
+
export declare function renderInlineMarkdown(s: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Render a multi-line markdown block as paragraphs. Lists become `<ul>`/`<ol>`.
|
|
24
|
+
* Sub-headings (### / ####) become `<h4>`. Tables are skipped — the renderer should
|
|
25
|
+
* have parsed those structurally before getting here.
|
|
26
|
+
*/
|
|
27
|
+
export declare function renderProse(md: string): string;
|
|
28
|
+
export declare function card(title: string, body: string, opts?: {
|
|
29
|
+
tone?: 'default' | 'muted';
|
|
30
|
+
}): string;
|
|
31
|
+
export declare function grid(items: string[], opts?: {
|
|
32
|
+
columns?: 'auto' | 'wide';
|
|
33
|
+
}): string;
|
|
34
|
+
export declare function emptyState(message: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Wrap rendered fragments into a self-contained HTML document.
|
|
37
|
+
*
|
|
38
|
+
* The doc carries an explicit "generated, do not edit" banner so the operator
|
|
39
|
+
* doesn't try to hand-edit the file — the source of truth is the markdown.
|
|
40
|
+
*/
|
|
41
|
+
export declare function pageWrapper(opts: {
|
|
42
|
+
title: string;
|
|
43
|
+
projectName: string;
|
|
44
|
+
generatedAt: string;
|
|
45
|
+
sourceNote: string;
|
|
46
|
+
body: string;
|
|
47
|
+
}): string;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML primitives shared across the per-register renderers.
|
|
3
|
+
*
|
|
4
|
+
* Design notes:
|
|
5
|
+
* - Pure static HTML. No JS, no framework, no external CSS or fonts.
|
|
6
|
+
* - One self-contained file per register so the operator can open it directly
|
|
7
|
+
* in a browser or share it without a build step.
|
|
8
|
+
* - Inline styles in a single `<style>` block; CSS variables for the few values
|
|
9
|
+
* the renderers care about (token swatches, type previews) so they can be
|
|
10
|
+
* overridden per-block without restating the rule set.
|
|
11
|
+
* - The visual language is deliberately neutral — cards, restrained spacing,
|
|
12
|
+
* system fonts. Consumer projects with strong design opinions can post-process
|
|
13
|
+
* or skip companion rendering; the default isn't trying to be a brand.
|
|
14
|
+
*/
|
|
15
|
+
export function escapeHtml(s) {
|
|
16
|
+
return s
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, ''');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Convert a single line of markdown to inline HTML — `code`, **bold**, *italic*,
|
|
25
|
+
* and [text](url) links. Block-level constructs (headings, lists, tables) are not
|
|
26
|
+
* processed; callers parse those structurally and use this helper on the cells.
|
|
27
|
+
*/
|
|
28
|
+
export function renderInlineMarkdown(s) {
|
|
29
|
+
let out = escapeHtml(s);
|
|
30
|
+
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
31
|
+
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
32
|
+
out = out.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
|
|
33
|
+
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text, href) => `<a href="${escapeHtml(href)}">${text}</a>`);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Render a multi-line markdown block as paragraphs. Lists become `<ul>`/`<ol>`.
|
|
38
|
+
* Sub-headings (### / ####) become `<h4>`. Tables are skipped — the renderer should
|
|
39
|
+
* have parsed those structurally before getting here.
|
|
40
|
+
*/
|
|
41
|
+
export function renderProse(md) {
|
|
42
|
+
const lines = md.split('\n');
|
|
43
|
+
const blocks = [];
|
|
44
|
+
let para = [];
|
|
45
|
+
let list = null;
|
|
46
|
+
let inTable = false;
|
|
47
|
+
const flushPara = () => {
|
|
48
|
+
if (para.length === 0)
|
|
49
|
+
return;
|
|
50
|
+
blocks.push(`<p>${renderInlineMarkdown(para.join(' '))}</p>`);
|
|
51
|
+
para = [];
|
|
52
|
+
};
|
|
53
|
+
const flushList = () => {
|
|
54
|
+
if (!list)
|
|
55
|
+
return;
|
|
56
|
+
const items = list.items.map((it) => `<li>${renderInlineMarkdown(it)}</li>`).join('');
|
|
57
|
+
blocks.push(`<${list.kind}>${items}</${list.kind}>`);
|
|
58
|
+
list = null;
|
|
59
|
+
};
|
|
60
|
+
for (const raw of lines) {
|
|
61
|
+
const line = raw.trim();
|
|
62
|
+
// Skip GFM tables — those are parsed structurally.
|
|
63
|
+
if (line.includes('|') && lines.indexOf(raw) + 1 < lines.length) {
|
|
64
|
+
const next = lines[lines.indexOf(raw) + 1]?.trim() ?? '';
|
|
65
|
+
if (/^\|?\s*:?-+:?(\s*\|\s*:?-+:?)+\s*\|?\s*$/.test(next))
|
|
66
|
+
inTable = true;
|
|
67
|
+
}
|
|
68
|
+
if (inTable) {
|
|
69
|
+
if (!line.includes('|'))
|
|
70
|
+
inTable = false;
|
|
71
|
+
else
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (inTable)
|
|
75
|
+
continue;
|
|
76
|
+
if (!line) {
|
|
77
|
+
flushPara();
|
|
78
|
+
flushList();
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (line.startsWith('> *') && line.endsWith('*'))
|
|
82
|
+
continue; // hint blockquote
|
|
83
|
+
if (line.startsWith('#### ')) {
|
|
84
|
+
flushPara();
|
|
85
|
+
flushList();
|
|
86
|
+
blocks.push(`<h5>${renderInlineMarkdown(line.slice(5))}</h5>`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (line.startsWith('### ')) {
|
|
90
|
+
flushPara();
|
|
91
|
+
flushList();
|
|
92
|
+
blocks.push(`<h4>${renderInlineMarkdown(line.slice(4))}</h4>`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const ulMatch = line.match(/^[-*]\s+(.+)$/);
|
|
96
|
+
if (ulMatch) {
|
|
97
|
+
flushPara();
|
|
98
|
+
if (list?.kind !== 'ul') {
|
|
99
|
+
flushList();
|
|
100
|
+
list = { kind: 'ul', items: [] };
|
|
101
|
+
}
|
|
102
|
+
list.items.push(ulMatch[1]);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const olMatch = line.match(/^\d+\.\s+(.+)$/);
|
|
106
|
+
if (olMatch) {
|
|
107
|
+
flushPara();
|
|
108
|
+
if (list?.kind !== 'ol') {
|
|
109
|
+
flushList();
|
|
110
|
+
list = { kind: 'ol', items: [] };
|
|
111
|
+
}
|
|
112
|
+
list.items.push(olMatch[1]);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (line.startsWith('>'))
|
|
116
|
+
continue; // generic blockquotes are usually template hints
|
|
117
|
+
para.push(line);
|
|
118
|
+
}
|
|
119
|
+
flushPara();
|
|
120
|
+
flushList();
|
|
121
|
+
return blocks.join('\n');
|
|
122
|
+
}
|
|
123
|
+
export function card(title, body, opts = {}) {
|
|
124
|
+
const cls = opts.tone === 'muted' ? 'card card--muted' : 'card';
|
|
125
|
+
return `<section class="${cls}"><h3>${escapeHtml(title)}</h3><div class="card__body">${body}</div></section>`;
|
|
126
|
+
}
|
|
127
|
+
export function grid(items, opts = {}) {
|
|
128
|
+
const cls = opts.columns === 'wide' ? 'grid grid--wide' : 'grid';
|
|
129
|
+
return `<div class="${cls}">${items.join('')}</div>`;
|
|
130
|
+
}
|
|
131
|
+
export function emptyState(message) {
|
|
132
|
+
return `<div class="empty">${escapeHtml(message)}</div>`;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Wrap rendered fragments into a self-contained HTML document.
|
|
136
|
+
*
|
|
137
|
+
* The doc carries an explicit "generated, do not edit" banner so the operator
|
|
138
|
+
* doesn't try to hand-edit the file — the source of truth is the markdown.
|
|
139
|
+
*/
|
|
140
|
+
export function pageWrapper(opts) {
|
|
141
|
+
return `<!doctype html>
|
|
142
|
+
<html lang="en">
|
|
143
|
+
<head>
|
|
144
|
+
<meta charset="utf-8">
|
|
145
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
146
|
+
<title>${escapeHtml(opts.title)} — ${escapeHtml(opts.projectName)}</title>
|
|
147
|
+
<style>${BASE_CSS}</style>
|
|
148
|
+
</head>
|
|
149
|
+
<body>
|
|
150
|
+
<header class="page-header">
|
|
151
|
+
<div class="page-header__title">
|
|
152
|
+
<small>${escapeHtml(opts.projectName)} catalogue companion</small>
|
|
153
|
+
<h1>${escapeHtml(opts.title)}</h1>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="page-header__meta">
|
|
156
|
+
<p>Generated ${escapeHtml(opts.generatedAt)} from ${opts.sourceNote}. Do not hand-edit — the markdown is the source of truth.</p>
|
|
157
|
+
</div>
|
|
158
|
+
</header>
|
|
159
|
+
<main>
|
|
160
|
+
${opts.body}
|
|
161
|
+
</main>
|
|
162
|
+
<footer class="page-footer">
|
|
163
|
+
<p>Regenerate with <code>npx @nusoft/nuos-build-catalogue render</code>.</p>
|
|
164
|
+
</footer>
|
|
165
|
+
</body>
|
|
166
|
+
</html>
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
169
|
+
const BASE_CSS = `
|
|
170
|
+
:root {
|
|
171
|
+
--bg: #fafaf9;
|
|
172
|
+
--surface: #ffffff;
|
|
173
|
+
--surface-muted: #f4f4f3;
|
|
174
|
+
--border: #e5e5e2;
|
|
175
|
+
--text: #1a1a1a;
|
|
176
|
+
--text-muted: #666;
|
|
177
|
+
--accent: #2a5cdc;
|
|
178
|
+
--shadow: 0 1px 2px rgba(0,0,0,0.04), 0 1px 6px rgba(0,0,0,0.03);
|
|
179
|
+
--radius: 8px;
|
|
180
|
+
--gap: 16px;
|
|
181
|
+
}
|
|
182
|
+
* { box-sizing: border-box; }
|
|
183
|
+
body {
|
|
184
|
+
margin: 0;
|
|
185
|
+
background: var(--bg);
|
|
186
|
+
color: var(--text);
|
|
187
|
+
font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
188
|
+
}
|
|
189
|
+
.page-header {
|
|
190
|
+
display: flex;
|
|
191
|
+
flex-wrap: wrap;
|
|
192
|
+
justify-content: space-between;
|
|
193
|
+
align-items: end;
|
|
194
|
+
gap: var(--gap);
|
|
195
|
+
padding: 32px max(24px, 4vw) 24px;
|
|
196
|
+
border-bottom: 1px solid var(--border);
|
|
197
|
+
background: var(--surface);
|
|
198
|
+
}
|
|
199
|
+
.page-header__title small { color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; font-size: 12px; }
|
|
200
|
+
.page-header__title h1 { margin: 4px 0 0; font-size: 28px; font-weight: 600; }
|
|
201
|
+
.page-header__meta { color: var(--text-muted); font-size: 13px; max-width: 50ch; }
|
|
202
|
+
.page-header__meta p { margin: 0; }
|
|
203
|
+
main { padding: 32px max(24px, 4vw); display: flex; flex-direction: column; gap: 28px; }
|
|
204
|
+
.page-footer { padding: 24px max(24px, 4vw); color: var(--text-muted); font-size: 12px; border-top: 1px solid var(--border); }
|
|
205
|
+
h2 { margin: 8px 0 4px; font-size: 18px; font-weight: 600; }
|
|
206
|
+
h2 small { color: var(--text-muted); font-weight: 400; font-size: 13px; margin-left: 8px; }
|
|
207
|
+
h3 { margin: 0 0 8px; font-size: 16px; font-weight: 600; }
|
|
208
|
+
h4 { margin: 16px 0 4px; font-size: 14px; font-weight: 600; }
|
|
209
|
+
h5 { margin: 12px 0 4px; font-size: 13px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
210
|
+
p { margin: 0 0 8px; }
|
|
211
|
+
p:last-child { margin-bottom: 0; }
|
|
212
|
+
ul, ol { margin: 0 0 8px; padding-left: 22px; }
|
|
213
|
+
li { margin-bottom: 4px; }
|
|
214
|
+
code { background: var(--surface-muted); padding: 1px 6px; border-radius: 4px; font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: 0.92em; }
|
|
215
|
+
a { color: var(--accent); text-decoration: none; }
|
|
216
|
+
a:hover { text-decoration: underline; }
|
|
217
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; box-shadow: var(--shadow); }
|
|
218
|
+
.card--muted { background: var(--surface-muted); box-shadow: none; }
|
|
219
|
+
.card__body { color: var(--text); font-size: 14px; line-height: 1.55; }
|
|
220
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--gap); }
|
|
221
|
+
.grid--wide { grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); }
|
|
222
|
+
.empty { color: var(--text-muted); font-style: italic; font-size: 14px; padding: 12px 0; }
|
|
223
|
+
.swatch { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; }
|
|
224
|
+
.swatch__chip { width: 36px; height: 36px; border-radius: 6px; border: 1px solid var(--border); flex-shrink: 0; }
|
|
225
|
+
.swatch__meta { display: flex; flex-direction: column; min-width: 0; }
|
|
226
|
+
.swatch__meta code { background: transparent; padding: 0; font-size: 13px; }
|
|
227
|
+
.swatch__meta small { color: var(--text-muted); font-size: 12px; }
|
|
228
|
+
.tokens-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
229
|
+
.tokens-table th { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); font-weight: 600; color: var(--text-muted); }
|
|
230
|
+
.tokens-table td { padding: 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
|
231
|
+
.type-sample { display: block; margin: 4px 0 12px; line-height: 1.2; }
|
|
232
|
+
.spacing-bar { height: 14px; background: var(--accent); border-radius: 3px; }
|
|
233
|
+
.tag { display: inline-block; padding: 2px 8px; border-radius: 999px; background: var(--surface-muted); font-size: 11px; color: var(--text-muted); margin-right: 4px; }
|
|
234
|
+
.tag--status { background: #e5f0ff; color: #0b3da1; }
|
|
235
|
+
.timeline { display: flex; flex-direction: column; gap: 12px; position: relative; padding-left: 24px; border-left: 2px solid var(--border); }
|
|
236
|
+
.timeline .card { position: relative; }
|
|
237
|
+
.timeline .card::before { content: ""; position: absolute; width: 10px; height: 10px; background: var(--accent); border-radius: 50%; left: -29px; top: 22px; }
|
|
238
|
+
.sitemap-list { columns: 2 320px; column-gap: 24px; padding-left: 18px; }
|
|
239
|
+
.sitemap-list li { break-inside: avoid; margin-bottom: 4px; }
|
|
240
|
+
.card__tags { display: flex; flex-wrap: wrap; gap: 6px; margin: -4px 0 12px; }
|
|
241
|
+
.wireframe { border: 1.5px dashed var(--border); border-radius: 6px; padding: 18px 16px; margin: 4px 0 12px; background: linear-gradient(135deg, var(--surface-muted) 25%, transparent 25%, transparent 50%, var(--surface-muted) 50%, var(--surface-muted) 75%, transparent 75%); background-size: 14px 14px; color: var(--text-muted); font-size: 13px; font-style: italic; }
|
|
242
|
+
.wireframe__label { background: var(--surface); padding: 6px 10px; border-radius: 4px; display: inline-block; }
|
|
243
|
+
.listing-item { padding: 10px 0; border-bottom: 1px solid var(--border); }
|
|
244
|
+
.listing-item:last-child { border-bottom: none; }
|
|
245
|
+
.listing-item p { margin: 4px 0; }
|
|
246
|
+
.listing-item small { color: var(--text-muted); }
|
|
247
|
+
.type-row { margin-bottom: 12px; }
|
|
248
|
+
.type-row small { color: var(--text-muted); font-size: 12px; }
|
|
249
|
+
.spacing-row { margin-bottom: 10px; display: flex; flex-direction: column; gap: 4px; }
|
|
250
|
+
.spacing-row small { color: var(--text-muted); font-size: 12px; }
|
|
251
|
+
.radius-row { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
|
252
|
+
.radius-chip { width: 48px; height: 32px; background: var(--accent); flex-shrink: 0; }
|
|
253
|
+
.radius-row small { color: var(--text-muted); font-size: 12px; }
|
|
254
|
+
@media (max-width: 720px) {
|
|
255
|
+
.page-header { flex-direction: column; align-items: flex-start; }
|
|
256
|
+
.sitemap-list { columns: 1; }
|
|
257
|
+
}
|
|
258
|
+
`;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render the `maps/` register as a single companion HTML page.
|
|
3
|
+
*
|
|
4
|
+
* Each map file (`01-the-horizon.md`, `02-phases.md`, `03-near-term.md`, etc.)
|
|
5
|
+
* is parsed by its `## Section` structure and rendered as a card with the
|
|
6
|
+
* section contents underneath. Multiple maps become a vertical timeline so the
|
|
7
|
+
* operator sees them in their intended order (numeric prefix).
|
|
8
|
+
*/
|
|
9
|
+
export interface MapsRenderResult {
|
|
10
|
+
written: boolean;
|
|
11
|
+
outPath: string;
|
|
12
|
+
mapCount: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function renderMaps(opts: {
|
|
15
|
+
buildRoot: string;
|
|
16
|
+
projectName: string;
|
|
17
|
+
generatedAt: string;
|
|
18
|
+
}): Promise<MapsRenderResult>;
|