@nusoft/nuos-build-catalogue 0.26.0 → 0.28.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 +14 -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/scripts/hooks/pre-commit +2 -1
- package/templates/agents/coder.md +33 -5
- package/templates/agents/reviewer.md +6 -4
- package/templates/claude-hooks/check-design-system-compliance.sh +211 -0
- package/templates/protocols/end-of-session.md +8 -2
|
@@ -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>;
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { parseSections, isPlaceholder } from './parser.js';
|
|
13
|
+
import { pageWrapper, card, emptyState, escapeHtml, renderProse, } from './html.js';
|
|
14
|
+
const TEMPLATE_PATTERN = /-template\.md$/i;
|
|
15
|
+
export async function renderMaps(opts) {
|
|
16
|
+
const dir = path.join(opts.buildRoot, 'maps');
|
|
17
|
+
const outPath = path.join(dir, '_view.html');
|
|
18
|
+
if (!existsSync(dir)) {
|
|
19
|
+
return { written: false, outPath, mapCount: 0 };
|
|
20
|
+
}
|
|
21
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
22
|
+
const maps = [];
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
if (!entry.isFile() || !entry.name.endsWith('.md'))
|
|
25
|
+
continue;
|
|
26
|
+
if (entry.name === '_index.md' || TEMPLATE_PATTERN.test(entry.name))
|
|
27
|
+
continue;
|
|
28
|
+
const md = await readFile(path.join(dir, entry.name), 'utf8');
|
|
29
|
+
if (isPlaceholder(md))
|
|
30
|
+
continue;
|
|
31
|
+
const titleMatch = md.match(/^#\s+(.+)$/m);
|
|
32
|
+
maps.push({
|
|
33
|
+
file: entry.name,
|
|
34
|
+
title: titleMatch ? titleMatch[1].trim() : entry.name,
|
|
35
|
+
sections: parseSections(md),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
maps.sort((a, b) => a.file.localeCompare(b.file));
|
|
39
|
+
const body = maps.length === 0
|
|
40
|
+
? card('No maps filed yet', emptyState('Phases A and D of planning produce this content.'))
|
|
41
|
+
: `<div class="timeline">${maps.map(renderMapCard).join('')}</div>`;
|
|
42
|
+
const html = pageWrapper({
|
|
43
|
+
title: 'Maps & journey',
|
|
44
|
+
projectName: opts.projectName,
|
|
45
|
+
generatedAt: opts.generatedAt,
|
|
46
|
+
sourceNote: '<code>docs/build/maps/</code>',
|
|
47
|
+
body,
|
|
48
|
+
});
|
|
49
|
+
await mkdir(dir, { recursive: true });
|
|
50
|
+
await writeFile(outPath, html, 'utf8');
|
|
51
|
+
return { written: true, outPath, mapCount: maps.length };
|
|
52
|
+
}
|
|
53
|
+
function renderMapCard(m) {
|
|
54
|
+
const sectionBlocks = [];
|
|
55
|
+
for (const [heading, body] of m.sections) {
|
|
56
|
+
if (heading === '' || heading.toLowerCase() === 'how this map changes')
|
|
57
|
+
continue;
|
|
58
|
+
const rendered = body.trim() ? renderProse(body) : '';
|
|
59
|
+
if (!rendered)
|
|
60
|
+
continue;
|
|
61
|
+
sectionBlocks.push(`<h4>${escapeHtml(heading)}</h4>${rendered}`);
|
|
62
|
+
}
|
|
63
|
+
return `<section class="card">
|
|
64
|
+
<h3>${escapeHtml(m.title)}</h3>
|
|
65
|
+
<div class="card__body">${sectionBlocks.join('')}</div>
|
|
66
|
+
</section>`;
|
|
67
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared markdown parsing helpers for the render module.
|
|
3
|
+
*
|
|
4
|
+
* The register markdown files in `templates/starter-kit/docs/build/` follow
|
|
5
|
+
* a few consistent shapes:
|
|
6
|
+
* - `## Section` headings with prose underneath (maps, surfaces, modules)
|
|
7
|
+
* - GFM tables with a header row, separator row, and data rows (design-system tokens)
|
|
8
|
+
* - Inline code in backticks for token names and hex values
|
|
9
|
+
*
|
|
10
|
+
* The parsers here are deliberately small and forgiving. If a file diverges
|
|
11
|
+
* from the template the renderer degrades to plain prose rather than throwing.
|
|
12
|
+
*/
|
|
13
|
+
export interface ParsedTable {
|
|
14
|
+
headers: string[];
|
|
15
|
+
rows: string[][];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Split a markdown document into a map of `## Section` → body text.
|
|
19
|
+
* Content before the first H2 is filed under the empty string key.
|
|
20
|
+
* Body text is trimmed and includes any nested headings underneath the H2.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseSections(md: string): Map<string, string>;
|
|
23
|
+
/**
|
|
24
|
+
* Parse the first GFM table in a markdown block. Returns null if none found.
|
|
25
|
+
* Cells with inline code/markdown are kept as-is; the renderer handles inline formatting.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseTable(md: string): ParsedTable | null;
|
|
28
|
+
/**
|
|
29
|
+
* Extract every inline-code substring from a markdown blob (e.g. `colour.brand.primary`).
|
|
30
|
+
*/
|
|
31
|
+
export declare function extractInlineCode(md: string): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Extract every hex colour (`#rgb`, `#rrggbb`, `#rrggbbaa`) from a markdown blob.
|
|
34
|
+
* Filters out the all-zero `#000000` placeholder *if* the file still looks unfilled,
|
|
35
|
+
* because the starter-kit template seeds tokens with `#000000` to signal "TBD".
|
|
36
|
+
*/
|
|
37
|
+
export declare function extractHexColours(md: string): string[];
|
|
38
|
+
/**
|
|
39
|
+
* Return the first non-blank, non-hint paragraph of a markdown block.
|
|
40
|
+
* "Hint" blockquotes (lines starting with `> *italics*`) are skipped because
|
|
41
|
+
* they're template scaffolding the operator forgot to delete.
|
|
42
|
+
*/
|
|
43
|
+
export declare function firstParagraph(md: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Detect placeholder-only files. A file that still contains the literal
|
|
46
|
+
* "Replace bracketed placeholders" hint, or whose body is mostly `[bracketed]`
|
|
47
|
+
* scaffolding, hasn't been filled in yet and shouldn't be rendered as if it had.
|
|
48
|
+
*/
|
|
49
|
+
export declare function isPlaceholder(md: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Strip the front-matter-y top of a register file: the `>` hint blockquotes and any
|
|
52
|
+
* **Status:** / **Last updated:** metadata. Leaves the actual content sections intact.
|
|
53
|
+
*/
|
|
54
|
+
export declare function stripPreamble(md: string): string;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared markdown parsing helpers for the render module.
|
|
3
|
+
*
|
|
4
|
+
* The register markdown files in `templates/starter-kit/docs/build/` follow
|
|
5
|
+
* a few consistent shapes:
|
|
6
|
+
* - `## Section` headings with prose underneath (maps, surfaces, modules)
|
|
7
|
+
* - GFM tables with a header row, separator row, and data rows (design-system tokens)
|
|
8
|
+
* - Inline code in backticks for token names and hex values
|
|
9
|
+
*
|
|
10
|
+
* The parsers here are deliberately small and forgiving. If a file diverges
|
|
11
|
+
* from the template the renderer degrades to plain prose rather than throwing.
|
|
12
|
+
*/
|
|
13
|
+
const HEADING_LINE = /^##\s+(.+?)\s*$/;
|
|
14
|
+
const TABLE_SEPARATOR = /^\|?\s*:?-+:?(\s*\|\s*:?-+:?)+\s*\|?\s*$/;
|
|
15
|
+
const HEX_COLOUR = /#(?:[0-9a-fA-F]{3,8})\b/g;
|
|
16
|
+
const INLINE_CODE = /`([^`]+)`/g;
|
|
17
|
+
const HINT_BLOCKQUOTE = /^>\s*\*[^*]+\*\s*$/;
|
|
18
|
+
/**
|
|
19
|
+
* Split a markdown document into a map of `## Section` → body text.
|
|
20
|
+
* Content before the first H2 is filed under the empty string key.
|
|
21
|
+
* Body text is trimmed and includes any nested headings underneath the H2.
|
|
22
|
+
*/
|
|
23
|
+
export function parseSections(md) {
|
|
24
|
+
const sections = new Map();
|
|
25
|
+
const lines = md.split('\n');
|
|
26
|
+
let currentHeading = '';
|
|
27
|
+
let currentBody = [];
|
|
28
|
+
const flush = () => {
|
|
29
|
+
const body = currentBody.join('\n').trim();
|
|
30
|
+
if (currentHeading || body)
|
|
31
|
+
sections.set(currentHeading, body);
|
|
32
|
+
};
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
const match = HEADING_LINE.exec(line);
|
|
35
|
+
if (match) {
|
|
36
|
+
flush();
|
|
37
|
+
currentHeading = match[1];
|
|
38
|
+
currentBody = [];
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
currentBody.push(line);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
flush();
|
|
45
|
+
return sections;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Parse the first GFM table in a markdown block. Returns null if none found.
|
|
49
|
+
* Cells with inline code/markdown are kept as-is; the renderer handles inline formatting.
|
|
50
|
+
*/
|
|
51
|
+
export function parseTable(md) {
|
|
52
|
+
const lines = md.split('\n');
|
|
53
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
54
|
+
const header = lines[i].trim();
|
|
55
|
+
const separator = lines[i + 1].trim();
|
|
56
|
+
if (!header.includes('|') || !TABLE_SEPARATOR.test(separator))
|
|
57
|
+
continue;
|
|
58
|
+
const headers = splitTableRow(header);
|
|
59
|
+
const rows = [];
|
|
60
|
+
for (let j = i + 2; j < lines.length; j++) {
|
|
61
|
+
const row = lines[j].trim();
|
|
62
|
+
if (!row.includes('|'))
|
|
63
|
+
break;
|
|
64
|
+
rows.push(splitTableRow(row));
|
|
65
|
+
}
|
|
66
|
+
return { headers, rows };
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function splitTableRow(line) {
|
|
71
|
+
// Trim leading/trailing pipes; split on |; trim each cell.
|
|
72
|
+
const trimmed = line.replace(/^\|/, '').replace(/\|$/, '');
|
|
73
|
+
return trimmed.split('|').map((c) => c.trim());
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Extract every inline-code substring from a markdown blob (e.g. `colour.brand.primary`).
|
|
77
|
+
*/
|
|
78
|
+
export function extractInlineCode(md) {
|
|
79
|
+
const out = [];
|
|
80
|
+
let m;
|
|
81
|
+
const re = new RegExp(INLINE_CODE.source, 'g');
|
|
82
|
+
while ((m = re.exec(md)) !== null)
|
|
83
|
+
out.push(m[1]);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Extract every hex colour (`#rgb`, `#rrggbb`, `#rrggbbaa`) from a markdown blob.
|
|
88
|
+
* Filters out the all-zero `#000000` placeholder *if* the file still looks unfilled,
|
|
89
|
+
* because the starter-kit template seeds tokens with `#000000` to signal "TBD".
|
|
90
|
+
*/
|
|
91
|
+
export function extractHexColours(md) {
|
|
92
|
+
return md.match(HEX_COLOUR) ?? [];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Return the first non-blank, non-hint paragraph of a markdown block.
|
|
96
|
+
* "Hint" blockquotes (lines starting with `> *italics*`) are skipped because
|
|
97
|
+
* they're template scaffolding the operator forgot to delete.
|
|
98
|
+
*/
|
|
99
|
+
export function firstParagraph(md) {
|
|
100
|
+
const lines = md.split('\n');
|
|
101
|
+
const para = [];
|
|
102
|
+
for (const raw of lines) {
|
|
103
|
+
const line = raw.trim();
|
|
104
|
+
if (!line) {
|
|
105
|
+
if (para.length > 0)
|
|
106
|
+
break;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (HINT_BLOCKQUOTE.test(line))
|
|
110
|
+
continue;
|
|
111
|
+
if (line.startsWith('#')) {
|
|
112
|
+
if (para.length > 0)
|
|
113
|
+
break;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
para.push(line);
|
|
117
|
+
}
|
|
118
|
+
return para.join(' ').trim();
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Detect placeholder-only files. A file that still contains the literal
|
|
122
|
+
* "Replace bracketed placeholders" hint, or whose body is mostly `[bracketed]`
|
|
123
|
+
* scaffolding, hasn't been filled in yet and shouldn't be rendered as if it had.
|
|
124
|
+
*/
|
|
125
|
+
export function isPlaceholder(md) {
|
|
126
|
+
if (/Replace\s+(?:the\s+)?bracketed\s+placeholders?/i.test(md))
|
|
127
|
+
return true;
|
|
128
|
+
if (/Delete this hint block/i.test(md))
|
|
129
|
+
return true;
|
|
130
|
+
// A title that's itself a bracketed placeholder ("# [Module name]") is the canonical
|
|
131
|
+
// signal of an unfilled file — title bracketing dominates everything else.
|
|
132
|
+
const titleMatch = md.match(/^#\s+(.+)$/m);
|
|
133
|
+
if (titleMatch && /\[[^\]]+\]/.test(titleMatch[1]))
|
|
134
|
+
return true;
|
|
135
|
+
// Count [bracketed] vs total non-blank non-heading lines; if dominant, treat as placeholder.
|
|
136
|
+
const lines = md
|
|
137
|
+
.split('\n')
|
|
138
|
+
.map((l) => l.trim())
|
|
139
|
+
.filter((l) => l && !l.startsWith('#') && !l.startsWith('>'));
|
|
140
|
+
if (lines.length === 0)
|
|
141
|
+
return true;
|
|
142
|
+
const bracketed = lines.filter((l) => /^\[.+\]$/.test(l) || /^[-*]\s*\[.+\]/.test(l)).length;
|
|
143
|
+
return bracketed / lines.length > 0.6;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Strip the front-matter-y top of a register file: the `>` hint blockquotes and any
|
|
147
|
+
* **Status:** / **Last updated:** metadata. Leaves the actual content sections intact.
|
|
148
|
+
*/
|
|
149
|
+
export function stripPreamble(md) {
|
|
150
|
+
const lines = md.split('\n');
|
|
151
|
+
const out = [];
|
|
152
|
+
let seenHeading = false;
|
|
153
|
+
for (const line of lines) {
|
|
154
|
+
if (HEADING_LINE.test(line))
|
|
155
|
+
seenHeading = true;
|
|
156
|
+
if (seenHeading)
|
|
157
|
+
out.push(line);
|
|
158
|
+
else if (line.startsWith('# '))
|
|
159
|
+
out.push(line);
|
|
160
|
+
}
|
|
161
|
+
return out.join('\n');
|
|
162
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator for `nuos-catalogue render` — generates companion HTML views
|
|
3
|
+
* for the visual registers (ui-ux, design-system, maps, architecture) from
|
|
4
|
+
* the canonical markdown.
|
|
5
|
+
*
|
|
6
|
+
* Companions are *generated artefacts*, not source. The markdown remains the
|
|
7
|
+
* source of truth for every agent, every search, every diff. The companion
|
|
8
|
+
* exists so a human operator can scan visual artefacts (wireframes, swatches,
|
|
9
|
+
* timelines, module graphs) in their natural medium instead of reading prose
|
|
10
|
+
* descriptions of them.
|
|
11
|
+
*/
|
|
12
|
+
export declare const RENDERABLE_REGISTERS: readonly ["surfaces", "design-system", "maps", "architecture"];
|
|
13
|
+
export type RenderableRegister = (typeof RENDERABLE_REGISTERS)[number];
|
|
14
|
+
export interface RenderOptions {
|
|
15
|
+
buildRoot: string;
|
|
16
|
+
/** Only render these registers (default: all). */
|
|
17
|
+
only?: RenderableRegister[];
|
|
18
|
+
/** Override "now" for deterministic output in tests. */
|
|
19
|
+
now?: () => Date;
|
|
20
|
+
}
|
|
21
|
+
export interface RenderReport {
|
|
22
|
+
results: {
|
|
23
|
+
register: RenderableRegister;
|
|
24
|
+
written: boolean;
|
|
25
|
+
outPath: string;
|
|
26
|
+
detail: string;
|
|
27
|
+
}[];
|
|
28
|
+
}
|
|
29
|
+
export declare function runRender(options: RenderOptions): Promise<RenderReport>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator for `nuos-catalogue render` — generates companion HTML views
|
|
3
|
+
* for the visual registers (ui-ux, design-system, maps, architecture) from
|
|
4
|
+
* the canonical markdown.
|
|
5
|
+
*
|
|
6
|
+
* Companions are *generated artefacts*, not source. The markdown remains the
|
|
7
|
+
* source of truth for every agent, every search, every diff. The companion
|
|
8
|
+
* exists so a human operator can scan visual artefacts (wireframes, swatches,
|
|
9
|
+
* timelines, module graphs) in their natural medium instead of reading prose
|
|
10
|
+
* descriptions of them.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { renderSurfaces } from './surfaces.js';
|
|
16
|
+
import { renderDesignSystem } from './design-system.js';
|
|
17
|
+
import { renderMaps } from './maps.js';
|
|
18
|
+
import { renderArchitecture } from './architecture.js';
|
|
19
|
+
export const RENDERABLE_REGISTERS = ['surfaces', 'design-system', 'maps', 'architecture'];
|
|
20
|
+
export async function runRender(options) {
|
|
21
|
+
const { buildRoot } = options;
|
|
22
|
+
const targets = options.only ?? [...RENDERABLE_REGISTERS];
|
|
23
|
+
const now = options.now ?? (() => new Date());
|
|
24
|
+
const generatedAt = isoDate(now());
|
|
25
|
+
const projectName = await resolveProjectName(buildRoot);
|
|
26
|
+
const ctx = { buildRoot, projectName, generatedAt };
|
|
27
|
+
const report = { results: [] };
|
|
28
|
+
for (const register of targets) {
|
|
29
|
+
switch (register) {
|
|
30
|
+
case 'surfaces': {
|
|
31
|
+
const r = await renderSurfaces(ctx);
|
|
32
|
+
report.results.push({
|
|
33
|
+
register,
|
|
34
|
+
written: r.written,
|
|
35
|
+
outPath: r.outPath,
|
|
36
|
+
detail: r.written ? `${r.surfaceCount} surfaces` : 'skipped (no ui-ux/)',
|
|
37
|
+
});
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
case 'design-system': {
|
|
41
|
+
const r = await renderDesignSystem(ctx);
|
|
42
|
+
report.results.push({
|
|
43
|
+
register,
|
|
44
|
+
written: r.written,
|
|
45
|
+
outPath: r.outPath,
|
|
46
|
+
detail: r.written ? 'rendered' : 'skipped (no design-system/)',
|
|
47
|
+
});
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case 'maps': {
|
|
51
|
+
const r = await renderMaps(ctx);
|
|
52
|
+
report.results.push({
|
|
53
|
+
register,
|
|
54
|
+
written: r.written,
|
|
55
|
+
outPath: r.outPath,
|
|
56
|
+
detail: r.written ? `${r.mapCount} maps` : 'skipped (no maps/)',
|
|
57
|
+
});
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case 'architecture': {
|
|
61
|
+
const r = await renderArchitecture(ctx);
|
|
62
|
+
report.results.push({
|
|
63
|
+
register,
|
|
64
|
+
written: r.written,
|
|
65
|
+
outPath: r.outPath,
|
|
66
|
+
detail: r.written ? `${r.moduleCount} modules` : 'skipped (no architecture/)',
|
|
67
|
+
});
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return report;
|
|
73
|
+
}
|
|
74
|
+
async function resolveProjectName(buildRoot) {
|
|
75
|
+
// buildRoot is `<project>/docs/build`; methodfile.json lives at the project root.
|
|
76
|
+
const projectRoot = path.resolve(buildRoot, '..', '..');
|
|
77
|
+
const methodfilePath = path.join(projectRoot, 'methodfile.json');
|
|
78
|
+
if (existsSync(methodfilePath)) {
|
|
79
|
+
try {
|
|
80
|
+
const mf = JSON.parse(await readFile(methodfilePath, 'utf8'));
|
|
81
|
+
if (mf.project?.name)
|
|
82
|
+
return mf.project.name;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// fall through to directory-name default
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return path.basename(projectRoot);
|
|
89
|
+
}
|
|
90
|
+
function isoDate(d) {
|
|
91
|
+
return d.toISOString().slice(0, 10);
|
|
92
|
+
}
|