@laitszkin/apollo-toolkit 3.10.0 → 3.11.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/CHANGELOG.md +20 -0
- package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
- package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
- package/generate-spec/SKILL.md +17 -15
- package/generate-spec/agents/openai.yaml +1 -1
- package/generate-spec/references/TEMPLATE_SPEC.md +103 -84
- package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
- package/init-project-html/SKILL.md +82 -126
- package/init-project-html/agents/openai.yaml +17 -8
- package/init-project-html/lib/atlas/assets/architecture.css +140 -0
- package/init-project-html/lib/atlas/assets/viewer.client.js +93 -0
- package/init-project-html/lib/atlas/cli.js +995 -0
- package/init-project-html/lib/atlas/layout.js +229 -0
- package/init-project-html/lib/atlas/render.js +485 -0
- package/init-project-html/lib/atlas/schema.js +310 -0
- package/init-project-html/lib/atlas/state.js +402 -0
- package/init-project-html/references/TEMPLATE_SPEC.md +123 -84
- package/init-project-html/sample-demo/resources/project-architecture/assets/architecture.css +139 -1058
- package/init-project-html/sample-demo/resources/project-architecture/assets/viewer.client.js +93 -0
- package/init-project-html/sample-demo/resources/project-architecture/atlas/atlas.index.yaml +34 -0
- package/init-project-html/sample-demo/resources/project-architecture/atlas/features/get-invite-codes.yaml +159 -0
- package/init-project-html/sample-demo/resources/project-architecture/atlas/features/invite-code-registration.yaml +160 -0
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/index.html +67 -52
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/invite-code-generator.html +48 -163
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/invite-issuance-service.html +70 -196
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/postgresql.html +64 -163
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/public-api.html +68 -150
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/web-get-invite-ui.html +65 -138
- package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/index.html +61 -51
- package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/postgresql.html +66 -159
- package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/public-api.html +63 -143
- package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/registration-service.html +77 -188
- package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/web-register-ui.html +65 -138
- package/init-project-html/sample-demo/resources/project-architecture/index.html +232 -335
- package/init-project-html/scripts/architecture.js +65 -247
- package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
- package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
- package/package.json +6 -2
- package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
- package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
- package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
- package/spec-to-project-html/SKILL.md +61 -63
- package/spec-to-project-html/agents/openai.yaml +14 -8
- package/spec-to-project-html/references/TEMPLATE_SPEC.md +96 -83
- package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// render.js — declarative atlas → HTML/SVG.
|
|
4
|
+
//
|
|
5
|
+
// Three page types:
|
|
6
|
+
// 1. Macro `index.html` (atlas-summary + atlas SVG with clusters + cross-feature edges + submodule index)
|
|
7
|
+
// 2. Feature `features/<slug>/index.html` (feature story + sub-module navigation)
|
|
8
|
+
// 3. Sub-module `features/<slug>/<sub>.html` (sub-io + sub-vars + sub-dataflow + sub-errors)
|
|
9
|
+
//
|
|
10
|
+
// Render output is deterministic so tests can snapshot it. Assets
|
|
11
|
+
// (architecture.css + viewer.client.js) are copied to <outDir>/assets/.
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
|
|
16
|
+
const { layoutMacro } = require('./layout');
|
|
17
|
+
|
|
18
|
+
const KIND_LABEL = {
|
|
19
|
+
ui: 'UI',
|
|
20
|
+
api: 'API',
|
|
21
|
+
service: 'Service',
|
|
22
|
+
db: 'DB',
|
|
23
|
+
'pure-fn': 'Pure fn',
|
|
24
|
+
queue: 'Queue',
|
|
25
|
+
external: 'External',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function htmlEscape(value) {
|
|
29
|
+
return String(value == null ? '' : value)
|
|
30
|
+
.replace(/&/g, '&')
|
|
31
|
+
.replace(/</g, '<')
|
|
32
|
+
.replace(/>/g, '>')
|
|
33
|
+
.replace(/"/g, '"')
|
|
34
|
+
.replace(/'/g, ''');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function relAssetPath(fromPagePath, outDir) {
|
|
38
|
+
const fromDir = path.dirname(fromPagePath);
|
|
39
|
+
const rel = path.relative(fromDir, path.join(outDir, 'assets'));
|
|
40
|
+
return (rel === '' ? '.' : rel).split(path.sep).join('/');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pagePathFor(kind, { featureSlug, submoduleSlug } = {}) {
|
|
44
|
+
if (kind === 'macro') return 'index.html';
|
|
45
|
+
if (kind === 'feature') return `features/${featureSlug}/index.html`;
|
|
46
|
+
if (kind === 'submodule') return `features/${featureSlug}/${submoduleSlug}.html`;
|
|
47
|
+
throw new Error(`unknown page kind: ${kind}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function head({ title, assetRel, pageKind }) {
|
|
51
|
+
return [
|
|
52
|
+
'<!DOCTYPE html>',
|
|
53
|
+
`<html lang="en" data-atlas-page="${pageKind}">`,
|
|
54
|
+
'<head>',
|
|
55
|
+
' <meta charset="utf-8">',
|
|
56
|
+
` <title>${htmlEscape(title)}</title>`,
|
|
57
|
+
' <meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
58
|
+
` <link rel="stylesheet" href="${assetRel}/architecture.css">`,
|
|
59
|
+
'</head>',
|
|
60
|
+
].join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderEdgePath(edge) {
|
|
64
|
+
const segments = [];
|
|
65
|
+
for (const section of edge.sections || []) {
|
|
66
|
+
const pts = [section.startPoint, ...(section.bendPoints || []), section.endPoint];
|
|
67
|
+
if (pts.length === 0) continue;
|
|
68
|
+
segments.push(pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' '));
|
|
69
|
+
}
|
|
70
|
+
return segments.join(' ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function edgeKindFor(stateEdge) {
|
|
74
|
+
return stateEdge && stateEdge.kind ? stateEdge.kind : 'call';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findEdgeMeta(state, edgeId) {
|
|
78
|
+
for (const feature of state.features || []) {
|
|
79
|
+
for (const e of feature.edges || []) {
|
|
80
|
+
if (e.id === edgeId) return e;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const e of state.edges || []) {
|
|
84
|
+
if (e.id === edgeId) return e;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderMacroSvg(layout, state) {
|
|
90
|
+
if (layout.empty) {
|
|
91
|
+
return '<svg class="atlas-svg" viewBox="0 0 320 160" role="img" aria-label="Atlas is empty"><text x="160" y="80" text-anchor="middle" fill="currentColor">Atlas has no features yet</text></svg>';
|
|
92
|
+
}
|
|
93
|
+
const pad = 24;
|
|
94
|
+
const vbW = Math.max(320, Math.ceil(layout.width + pad * 2));
|
|
95
|
+
const vbH = Math.max(160, Math.ceil(layout.height + pad * 2));
|
|
96
|
+
const parts = [];
|
|
97
|
+
parts.push(`<svg class="atlas-svg" viewBox="0 0 ${vbW} ${vbH}" role="img" aria-label="Project architecture atlas" data-atlas-svg="macro">`);
|
|
98
|
+
parts.push(' <defs>');
|
|
99
|
+
for (const kind of ['call', 'return', 'data-row', 'failure']) {
|
|
100
|
+
parts.push(` <marker id="arrow-${kind}" class="m-arrow m-arrow--${kind}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 Z" /></marker>`);
|
|
101
|
+
}
|
|
102
|
+
parts.push(' </defs>');
|
|
103
|
+
parts.push(` <g transform="translate(${pad},${pad})">`);
|
|
104
|
+
|
|
105
|
+
for (const feat of layout.features) {
|
|
106
|
+
parts.push(` <g class="m-cluster" data-feature="${htmlEscape(feat.slug)}">`);
|
|
107
|
+
parts.push(` <rect class="m-cluster__bg" x="${feat.x.toFixed(2)}" y="${feat.y.toFixed(2)}" width="${feat.width.toFixed(2)}" height="${feat.height.toFixed(2)}" rx="14" ry="14" />`);
|
|
108
|
+
const titleX = feat.x + feat.width / 2;
|
|
109
|
+
const titleY = feat.y + 26;
|
|
110
|
+
const featureState = (state.features || []).find((f) => f.slug === feat.slug);
|
|
111
|
+
const title = (featureState && featureState.title) || feat.slug;
|
|
112
|
+
parts.push(` <text class="m-cluster__title" x="${titleX.toFixed(2)}" y="${titleY.toFixed(2)}" text-anchor="middle">${htmlEscape(title)}</text>`);
|
|
113
|
+
parts.push(' </g>');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const sub of layout.submodules) {
|
|
117
|
+
const subState = ((state.features || []).find((f) => f.slug === sub.featureSlug) || {}).submodules || [];
|
|
118
|
+
const meta = subState.find((s) => s.slug === sub.slug);
|
|
119
|
+
const kind = (meta && meta.kind) || 'service';
|
|
120
|
+
const cx = sub.x + sub.width / 2;
|
|
121
|
+
const labelY = sub.y + 28;
|
|
122
|
+
const kindLabelY = sub.y + 52;
|
|
123
|
+
const role = meta && meta.role ? meta.role : '';
|
|
124
|
+
const href = `features/${sub.featureSlug}/${sub.slug}.html`;
|
|
125
|
+
parts.push(` <a class="m-node m-node--${kind}" href="${htmlEscape(href)}" data-feature="${htmlEscape(sub.featureSlug)}" data-submodule="${htmlEscape(sub.slug)}">`);
|
|
126
|
+
parts.push(` <rect x="${sub.x.toFixed(2)}" y="${sub.y.toFixed(2)}" width="${sub.width.toFixed(2)}" height="${sub.height.toFixed(2)}" rx="10" ry="10" />`);
|
|
127
|
+
parts.push(` <text class="m-node__title" x="${cx.toFixed(2)}" y="${labelY.toFixed(2)}" text-anchor="middle">${htmlEscape(sub.slug)}</text>`);
|
|
128
|
+
parts.push(` <text class="m-node__kind" x="${cx.toFixed(2)}" y="${kindLabelY.toFixed(2)}" text-anchor="middle">${htmlEscape(KIND_LABEL[kind] || kind)}</text>`);
|
|
129
|
+
if (role) {
|
|
130
|
+
const truncated = role.length > 38 ? `${role.slice(0, 36)}…` : role;
|
|
131
|
+
parts.push(` <text class="m-node__role" x="${cx.toFixed(2)}" y="${(sub.y + sub.height - 14).toFixed(2)}" text-anchor="middle">${htmlEscape(truncated)}</text>`);
|
|
132
|
+
}
|
|
133
|
+
parts.push(' </a>');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const edge of layout.edges) {
|
|
137
|
+
const meta = findEdgeMeta(state, edge.id);
|
|
138
|
+
const kind = edgeKindFor(meta);
|
|
139
|
+
const d = renderEdgePath(edge);
|
|
140
|
+
if (!d) continue;
|
|
141
|
+
parts.push(` <g class="m-edge m-edge--${kind}" data-edge="${htmlEscape(edge.id)}">`);
|
|
142
|
+
parts.push(` <path d="${d}" fill="none" marker-end="url(#arrow-${kind})" />`);
|
|
143
|
+
for (const label of edge.labels || []) {
|
|
144
|
+
if (!label.text) continue;
|
|
145
|
+
const lx = label.x + (label.width || 0) / 2;
|
|
146
|
+
const ly = label.y + (label.height || 0) / 2 + 4;
|
|
147
|
+
parts.push(` <text class="m-edge__label" x="${lx.toFixed(2)}" y="${ly.toFixed(2)}" text-anchor="middle">${htmlEscape(label.text)}</text>`);
|
|
148
|
+
}
|
|
149
|
+
parts.push(' </g>');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
parts.push(' </g>');
|
|
153
|
+
parts.push('</svg>');
|
|
154
|
+
return parts.join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderAtlasSubmoduleIndex(state) {
|
|
158
|
+
const items = [];
|
|
159
|
+
for (const feature of state.features || []) {
|
|
160
|
+
for (const sub of feature.submodules || []) {
|
|
161
|
+
items.push({
|
|
162
|
+
feature: feature.slug,
|
|
163
|
+
featureTitle: feature.title || feature.slug,
|
|
164
|
+
sub: sub.slug,
|
|
165
|
+
kind: sub.kind,
|
|
166
|
+
role: sub.role,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (items.length === 0) return '';
|
|
171
|
+
const rows = items.map((it) => ` <li class="atlas-submodule-index__item">
|
|
172
|
+
<a href="features/${htmlEscape(it.feature)}/${htmlEscape(it.sub)}.html">
|
|
173
|
+
<span class="atlas-submodule-index__feature">${htmlEscape(it.featureTitle)}</span>
|
|
174
|
+
<span class="atlas-submodule-index__sub">${htmlEscape(it.sub)}</span>
|
|
175
|
+
<span class="atlas-submodule-index__kind atlas-submodule-index__kind--${htmlEscape(it.kind)}">${htmlEscape(KIND_LABEL[it.kind] || it.kind)}</span>
|
|
176
|
+
</a>
|
|
177
|
+
${it.role ? `<p class="atlas-submodule-index__role">${htmlEscape(it.role)}</p>` : ''}
|
|
178
|
+
</li>`).join('\n');
|
|
179
|
+
return ` <ul class="atlas-submodule-index">
|
|
180
|
+
${rows}
|
|
181
|
+
</ul>`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderMacro({ state, layout, outDir }) {
|
|
185
|
+
const pageRel = pagePathFor('macro');
|
|
186
|
+
const assetRel = relAssetPath(path.join(outDir, pageRel), outDir);
|
|
187
|
+
const svg = renderMacroSvg(layout, state);
|
|
188
|
+
const title = (state.meta && state.meta.title) || 'Project architecture';
|
|
189
|
+
const summary = (state.meta && state.meta.summary) || '';
|
|
190
|
+
const submoduleIndex = renderAtlasSubmoduleIndex(state);
|
|
191
|
+
|
|
192
|
+
const body = `<body>
|
|
193
|
+
<header class="atlas-header">
|
|
194
|
+
<h1>${htmlEscape(title)}</h1>
|
|
195
|
+
${summary ? `<p class="atlas-summary">${htmlEscape(summary)}</p>` : ''}
|
|
196
|
+
</header>
|
|
197
|
+
<main class="atlas-main">
|
|
198
|
+
<section class="atlas-canvas" aria-label="Macro architecture diagram">
|
|
199
|
+
<div class="atlas-canvas__toolbar" role="toolbar" aria-label="Diagram controls">
|
|
200
|
+
<button type="button" data-pan-zoom="zoom-in" aria-label="Zoom in">+</button>
|
|
201
|
+
<button type="button" data-pan-zoom="zoom-out" aria-label="Zoom out">−</button>
|
|
202
|
+
<button type="button" data-pan-zoom="fit" aria-label="Reset view">Fit</button>
|
|
203
|
+
</div>
|
|
204
|
+
<div class="atlas-canvas__viewport" data-pan-zoom-viewport>
|
|
205
|
+
${svg}
|
|
206
|
+
</div>
|
|
207
|
+
<ol class="atlas-legend" aria-label="Edge legend">
|
|
208
|
+
<li><span class="legend-swatch legend-swatch--call"></span>call</li>
|
|
209
|
+
<li><span class="legend-swatch legend-swatch--return"></span>return</li>
|
|
210
|
+
<li><span class="legend-swatch legend-swatch--data-row"></span>data-row</li>
|
|
211
|
+
<li><span class="legend-swatch legend-swatch--failure"></span>failure</li>
|
|
212
|
+
</ol>
|
|
213
|
+
</section>
|
|
214
|
+
<section class="atlas-index" aria-label="Submodule index">
|
|
215
|
+
<h2>Submodule index</h2>
|
|
216
|
+
${submoduleIndex}
|
|
217
|
+
</section>
|
|
218
|
+
</main>
|
|
219
|
+
<script src="${assetRel}/viewer.client.js" defer></script>
|
|
220
|
+
</body>
|
|
221
|
+
</html>`;
|
|
222
|
+
|
|
223
|
+
return `${head({ title, assetRel, pageKind: 'macro' })}\n${body}\n`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function renderSubmoduleCard(featureSlug, sub) {
|
|
227
|
+
const kindLabel = KIND_LABEL[sub.kind] || sub.kind;
|
|
228
|
+
const link = `${sub.slug}.html`;
|
|
229
|
+
return ` <li class="submodule-card">
|
|
230
|
+
<a class="submodule-card__link" href="${htmlEscape(link)}">
|
|
231
|
+
<span class="submodule-card__name">${htmlEscape(sub.slug)}</span>
|
|
232
|
+
<span class="submodule-card__kind submodule-card__kind--${htmlEscape(sub.kind)}">${htmlEscape(kindLabel)}</span>
|
|
233
|
+
</a>
|
|
234
|
+
${sub.role ? `<p class="submodule-card__role">${htmlEscape(sub.role)}</p>` : ''}
|
|
235
|
+
</li>`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function renderFeaturePage({ feature, outDir }) {
|
|
239
|
+
const pageRel = pagePathFor('feature', { featureSlug: feature.slug });
|
|
240
|
+
const assetRel = relAssetPath(path.join(outDir, pageRel), outDir);
|
|
241
|
+
const title = feature.title || feature.slug;
|
|
242
|
+
const subNav = (feature.submodules || []).map((s) => renderSubmoduleCard(feature.slug, s)).join('\n');
|
|
243
|
+
const dependsOn = Array.isArray(feature.dependsOn) ? feature.dependsOn : [];
|
|
244
|
+
const dependsList = dependsOn.length > 0
|
|
245
|
+
? `<p class="feature-depends">Depends on: ${dependsOn.map((d) => `<a href="../${htmlEscape(d)}/index.html">${htmlEscape(d)}</a>`).join(', ')}</p>`
|
|
246
|
+
: '';
|
|
247
|
+
const intraEdges = (feature.edges || []).filter((e) => e.kind && e.label);
|
|
248
|
+
|
|
249
|
+
const body = `<body>
|
|
250
|
+
<header class="feature-header">
|
|
251
|
+
<nav class="feature-breadcrumb"><a href="../../index.html">← Atlas</a></nav>
|
|
252
|
+
<h1>${htmlEscape(title)}</h1>
|
|
253
|
+
${dependsList}
|
|
254
|
+
</header>
|
|
255
|
+
<main class="feature-main">
|
|
256
|
+
${feature.story ? `<section class="feature-story"><p>${htmlEscape(feature.story)}</p></section>` : ''}
|
|
257
|
+
<section class="feature-submodules" aria-label="Submodules">
|
|
258
|
+
<h2>Submodules</h2>
|
|
259
|
+
<ul class="submodule-nav">
|
|
260
|
+
${subNav}
|
|
261
|
+
</ul>
|
|
262
|
+
</section>
|
|
263
|
+
${intraEdges.length > 0 ? `<section class="feature-edges" aria-label="Intra-feature edges">
|
|
264
|
+
<h2>Intra-feature edges</h2>
|
|
265
|
+
<ul class="feature-edges__list">
|
|
266
|
+
${intraEdges.map((e) => {
|
|
267
|
+
const from = typeof e.from === 'string' ? e.from : (e.from && e.from.submodule);
|
|
268
|
+
const to = typeof e.to === 'string' ? e.to : (e.to && e.to.submodule);
|
|
269
|
+
return ` <li class="feature-edges__item feature-edges__item--${htmlEscape(e.kind)}"><span class="feature-edges__endpoints">${htmlEscape(from)} → ${htmlEscape(to)}</span><span class="feature-edges__kind">${htmlEscape(e.kind)}</span><span class="feature-edges__label">${htmlEscape(e.label || '')}</span></li>`;
|
|
270
|
+
}).join('\n')}
|
|
271
|
+
</ul>
|
|
272
|
+
</section>` : ''}
|
|
273
|
+
</main>
|
|
274
|
+
</body>
|
|
275
|
+
</html>`;
|
|
276
|
+
|
|
277
|
+
return `${head({ title, assetRel, pageKind: 'feature' })}\n${body}\n`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function renderSubmoduleTable(headers, rows) {
|
|
281
|
+
return `<table class="sub-table">
|
|
282
|
+
<thead><tr>${headers.map((h) => `<th scope="col">${htmlEscape(h)}</th>`).join('')}</tr></thead>
|
|
283
|
+
<tbody>
|
|
284
|
+
${rows.map((r) => ` <tr>${r.map((c) => `<td>${htmlEscape(c == null ? '' : c)}</td>`).join('')}</tr>`).join('\n')}
|
|
285
|
+
</tbody>
|
|
286
|
+
</table>`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function renderInternalDataflowSvg(steps) {
|
|
290
|
+
if (!steps || steps.length === 0) {
|
|
291
|
+
return '<p class="sub-dataflow__empty">No internal dataflow steps recorded.</p>';
|
|
292
|
+
}
|
|
293
|
+
const boxW = 360;
|
|
294
|
+
const boxH = 56;
|
|
295
|
+
const gap = 28;
|
|
296
|
+
const totalH = steps.length * boxH + (steps.length - 1) * gap + 40;
|
|
297
|
+
const parts = [];
|
|
298
|
+
parts.push(`<svg class="sub-dataflow__svg" viewBox="0 0 ${boxW + 40} ${totalH}" role="img" aria-label="Internal dataflow">`);
|
|
299
|
+
parts.push(' <defs><marker id="sub-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 Z" /></marker></defs>');
|
|
300
|
+
steps.forEach((step, i) => {
|
|
301
|
+
const y = 20 + i * (boxH + gap);
|
|
302
|
+
parts.push(` <g class="sub-dataflow__step">`);
|
|
303
|
+
parts.push(` <rect x="20" y="${y}" width="${boxW}" height="${boxH}" rx="8" ry="8" />`);
|
|
304
|
+
const lines = wrapText(step, 52);
|
|
305
|
+
lines.forEach((line, idx) => {
|
|
306
|
+
const ly = y + 24 + idx * 16;
|
|
307
|
+
parts.push(` <text x="${20 + boxW / 2}" y="${ly}" text-anchor="middle">${htmlEscape(line)}</text>`);
|
|
308
|
+
});
|
|
309
|
+
parts.push(' </g>');
|
|
310
|
+
if (i < steps.length - 1) {
|
|
311
|
+
const aY = y + boxH;
|
|
312
|
+
const bY = aY + gap;
|
|
313
|
+
const x = 20 + boxW / 2;
|
|
314
|
+
parts.push(` <line class="sub-dataflow__arrow" x1="${x}" y1="${aY}" x2="${x}" y2="${bY}" marker-end="url(#sub-arrow)" />`);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
parts.push('</svg>');
|
|
318
|
+
return parts.join('\n');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function wrapText(text, maxChars) {
|
|
322
|
+
if (!text) return [''];
|
|
323
|
+
const words = String(text).split(/\s+/);
|
|
324
|
+
const lines = [];
|
|
325
|
+
let current = '';
|
|
326
|
+
for (const word of words) {
|
|
327
|
+
if (!current) { current = word; continue; }
|
|
328
|
+
if ((current.length + 1 + word.length) <= maxChars) current = `${current} ${word}`;
|
|
329
|
+
else { lines.push(current); current = word; }
|
|
330
|
+
}
|
|
331
|
+
if (current) lines.push(current);
|
|
332
|
+
return lines.slice(0, 3);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function renderSubmodulePage({ feature, sub, outDir }) {
|
|
336
|
+
const pageRel = pagePathFor('submodule', { featureSlug: feature.slug, submoduleSlug: sub.slug });
|
|
337
|
+
const assetRel = relAssetPath(path.join(outDir, pageRel), outDir);
|
|
338
|
+
const title = `${feature.title || feature.slug} · ${sub.slug}`;
|
|
339
|
+
const ioRows = (sub.functions || []).map((fn) => [fn.name, fn.in || '', fn.out || '', fn.side || '', fn.purpose || '']);
|
|
340
|
+
const varRows = (sub.variables || []).map((v) => [v.name, v.type || '', v.scope || '', v.purpose || '']);
|
|
341
|
+
const errRows = (sub.errors || []).map((e) => [e.name, e.when || '', e.means || '']);
|
|
342
|
+
|
|
343
|
+
const body = `<body>
|
|
344
|
+
<header class="submodule-header">
|
|
345
|
+
<nav class="submodule-breadcrumb"><a href="../../index.html">← Atlas</a> · <a href="index.html">← ${htmlEscape(feature.title || feature.slug)}</a></nav>
|
|
346
|
+
<h1>${htmlEscape(sub.slug)} <small class="submodule-kind submodule-kind--${htmlEscape(sub.kind)}">${htmlEscape(KIND_LABEL[sub.kind] || sub.kind)}</small></h1>
|
|
347
|
+
${sub.role ? `<p class="submodule-role">${htmlEscape(sub.role)}</p>` : ''}
|
|
348
|
+
</header>
|
|
349
|
+
<main class="submodule-main">
|
|
350
|
+
<section class="sub-io" aria-label="Function I/O">
|
|
351
|
+
<h2>Function I/O</h2>
|
|
352
|
+
${ioRows.length > 0
|
|
353
|
+
? renderSubmoduleTable(['Name', 'In', 'Out', 'Side', 'Purpose'], ioRows)
|
|
354
|
+
: '<p class="sub-section__empty">No functions recorded.</p>'}
|
|
355
|
+
</section>
|
|
356
|
+
<section class="sub-vars" aria-label="Variables">
|
|
357
|
+
<h2>Variables</h2>
|
|
358
|
+
${varRows.length > 0
|
|
359
|
+
? renderSubmoduleTable(['Name', 'Type', 'Scope', 'Purpose'], varRows)
|
|
360
|
+
: '<p class="sub-section__empty">No variables recorded.</p>'}
|
|
361
|
+
</section>
|
|
362
|
+
<section class="sub-dataflow" aria-label="Internal data flow">
|
|
363
|
+
<h2>Internal data flow</h2>
|
|
364
|
+
${renderInternalDataflowSvg(sub.dataflow)}
|
|
365
|
+
</section>
|
|
366
|
+
<section class="sub-errors" aria-label="Errors">
|
|
367
|
+
<h2>Errors</h2>
|
|
368
|
+
${errRows.length > 0
|
|
369
|
+
? renderSubmoduleTable(['Name', 'When', 'Means'], errRows)
|
|
370
|
+
: '<p class="sub-section__empty">No errors recorded.</p>'}
|
|
371
|
+
</section>
|
|
372
|
+
</main>
|
|
373
|
+
</body>
|
|
374
|
+
</html>`;
|
|
375
|
+
|
|
376
|
+
return `${head({ title, assetRel, pageKind: 'submodule' })}\n${body}\n`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function copyAssets(outDir) {
|
|
380
|
+
const assetsDir = path.join(outDir, 'assets');
|
|
381
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
382
|
+
const srcCss = path.join(__dirname, 'assets', 'architecture.css');
|
|
383
|
+
const srcJs = path.join(__dirname, 'assets', 'viewer.client.js');
|
|
384
|
+
fs.copyFileSync(srcCss, path.join(assetsDir, 'architecture.css'));
|
|
385
|
+
fs.copyFileSync(srcJs, path.join(assetsDir, 'viewer.client.js'));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// renderAll({outDir, state, scope?}) writes every page for the
|
|
389
|
+
// resolved state. When scope is provided, only the listed pages are
|
|
390
|
+
// emitted; this is how spec mode generates the proposed-after subset.
|
|
391
|
+
async function renderAll({ outDir, state, scope = null, removedPaths = [] }) {
|
|
392
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
393
|
+
copyAssets(outDir);
|
|
394
|
+
|
|
395
|
+
const layout = await layoutMacro(state);
|
|
396
|
+
|
|
397
|
+
const shouldEmit = (kind, slug, subSlug) => {
|
|
398
|
+
if (!scope) return true;
|
|
399
|
+
if (kind === 'macro') return scope.macro === true;
|
|
400
|
+
if (kind === 'feature') return scope.features && scope.features.has(slug);
|
|
401
|
+
if (kind === 'submodule') {
|
|
402
|
+
return (scope.submodules || []).some((s) => s.feature === slug && s.submodule === subSlug);
|
|
403
|
+
}
|
|
404
|
+
return false;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const written = [];
|
|
408
|
+
|
|
409
|
+
if (shouldEmit('macro')) {
|
|
410
|
+
const html = renderMacro({ state, layout, outDir });
|
|
411
|
+
const file = path.join(outDir, pagePathFor('macro'));
|
|
412
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
413
|
+
fs.writeFileSync(file, html, 'utf8');
|
|
414
|
+
written.push(pagePathFor('macro'));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
for (const feature of state.features || []) {
|
|
418
|
+
if (shouldEmit('feature', feature.slug)) {
|
|
419
|
+
const html = renderFeaturePage({ feature, outDir });
|
|
420
|
+
const file = path.join(outDir, pagePathFor('feature', { featureSlug: feature.slug }));
|
|
421
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
422
|
+
fs.writeFileSync(file, html, 'utf8');
|
|
423
|
+
written.push(pagePathFor('feature', { featureSlug: feature.slug }));
|
|
424
|
+
}
|
|
425
|
+
for (const sub of feature.submodules || []) {
|
|
426
|
+
if (shouldEmit('submodule', feature.slug, sub.slug)) {
|
|
427
|
+
const html = renderSubmodulePage({ feature, sub, outDir });
|
|
428
|
+
const file = path.join(outDir, pagePathFor('submodule', { featureSlug: feature.slug, submoduleSlug: sub.slug }));
|
|
429
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
430
|
+
fs.writeFileSync(file, html, 'utf8');
|
|
431
|
+
written.push(pagePathFor('submodule', { featureSlug: feature.slug, submoduleSlug: sub.slug }));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (removedPaths && removedPaths.length > 0) {
|
|
437
|
+
const lines = ['# Pages removed by this spec. Used by `apltk architecture diff`.', ...removedPaths];
|
|
438
|
+
fs.writeFileSync(path.join(outDir, '_removed.txt'), `${lines.join('\n')}\n`, 'utf8');
|
|
439
|
+
} else {
|
|
440
|
+
const file = path.join(outDir, '_removed.txt');
|
|
441
|
+
if (fs.existsSync(file)) fs.rmSync(file);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return { written, layout };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function scopeFromDiff(diff) {
|
|
448
|
+
const submodules = [];
|
|
449
|
+
for (const item of diff.modifiedSubmodules || []) submodules.push(item);
|
|
450
|
+
for (const item of diff.addedSubmodules || []) submodules.push(item);
|
|
451
|
+
const features = new Set([...(diff.modifiedFeatures || []), ...(diff.addedFeatures || [])]);
|
|
452
|
+
// If only a submodule changed but its feature isn't otherwise modified,
|
|
453
|
+
// the feature index page does not need to re-emit. The submodule page
|
|
454
|
+
// itself still needs the feature title/role for breadcrumb, which is
|
|
455
|
+
// pulled from state at render time, so omitting the feature page is safe.
|
|
456
|
+
return {
|
|
457
|
+
macro: diff.macroChanged === true,
|
|
458
|
+
features,
|
|
459
|
+
submodules,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function removedPagePathsFromDiff(diff) {
|
|
464
|
+
const paths = [];
|
|
465
|
+
for (const slug of diff.removedFeatures || []) {
|
|
466
|
+
paths.push(pagePathFor('feature', { featureSlug: slug }));
|
|
467
|
+
}
|
|
468
|
+
for (const item of diff.removedSubmodules || []) {
|
|
469
|
+
paths.push(pagePathFor('submodule', { featureSlug: item.feature, submoduleSlug: item.submodule }));
|
|
470
|
+
}
|
|
471
|
+
return paths;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
module.exports = {
|
|
475
|
+
KIND_LABEL,
|
|
476
|
+
htmlEscape,
|
|
477
|
+
pagePathFor,
|
|
478
|
+
renderAll,
|
|
479
|
+
renderMacro,
|
|
480
|
+
renderFeaturePage,
|
|
481
|
+
renderSubmodulePage,
|
|
482
|
+
copyAssets,
|
|
483
|
+
scopeFromDiff,
|
|
484
|
+
removedPagePathsFromDiff,
|
|
485
|
+
};
|