@laitszkin/apollo-toolkit 3.10.0 → 3.11.1
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 +37 -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 +117 -125
- package/init-project-html/agents/openai.yaml +18 -9
- package/init-project-html/lib/atlas/assets/architecture.css +161 -0
- package/init-project-html/lib/atlas/assets/viewer.client.js +136 -0
- package/init-project-html/lib/atlas/cli.js +1023 -0
- package/init-project-html/lib/atlas/layout.js +330 -0
- package/init-project-html/lib/atlas/render.js +583 -0
- package/init-project-html/lib/atlas/schema.js +347 -0
- package/init-project-html/lib/atlas/state.js +402 -0
- package/init-project-html/references/TEMPLATE_SPEC.md +140 -83
- package/init-project-html/sample-demo/resources/project-architecture/assets/architecture.css +160 -1058
- package/init-project-html/sample-demo/resources/project-architecture/assets/viewer.client.js +136 -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 +172 -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 +64 -163
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/invite-issuance-service.html +102 -196
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/postgresql.html +82 -163
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/public-api.html +88 -150
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/web-get-invite-ui.html +83 -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 +84 -159
- package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/public-api.html +81 -143
- package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/registration-service.html +98 -188
- package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/web-register-ui.html +83 -138
- package/init-project-html/sample-demo/resources/project-architecture/index.html +256 -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 +74 -67
- package/spec-to-project-html/agents/openai.yaml +14 -8
- package/spec-to-project-html/references/TEMPLATE_SPEC.md +98 -83
- package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
|
@@ -0,0 +1,583 @@
|
|
|
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, measureSubmodule } = 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.kind || 'service';
|
|
120
|
+
const role = meta.role || '';
|
|
121
|
+
const measured = measureSubmodule({ slug: sub.slug, kind, role });
|
|
122
|
+
|
|
123
|
+
const cx = sub.x + sub.width / 2;
|
|
124
|
+
const titleY = sub.y + 14 + 16; // SUB_TOP_PAD (14) + ascent for the title line
|
|
125
|
+
const kindY = titleY + 4 + 12; // KIND_GAP + kind ascent
|
|
126
|
+
const roleStartY = kindY + 8 + 12; // ROLE_GAP + first role line ascent
|
|
127
|
+
|
|
128
|
+
const href = `features/${sub.featureSlug}/${sub.slug}.html`;
|
|
129
|
+
const tooltip = role ? `${sub.slug} — ${role}` : sub.slug;
|
|
130
|
+
parts.push(` <a class="m-node m-node--${kind}" href="${htmlEscape(href)}" data-feature="${htmlEscape(sub.featureSlug)}" data-submodule="${htmlEscape(sub.slug)}" tabindex="0" aria-label="${htmlEscape(tooltip)} — open sub-module page">`);
|
|
131
|
+
parts.push(` <title>${htmlEscape(tooltip)}</title>`);
|
|
132
|
+
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" />`);
|
|
133
|
+
parts.push(` <text class="m-node__title" x="${cx.toFixed(2)}" y="${titleY.toFixed(2)}" text-anchor="middle">${htmlEscape(sub.slug)}</text>`);
|
|
134
|
+
parts.push(` <text class="m-node__kind" x="${cx.toFixed(2)}" y="${kindY.toFixed(2)}" text-anchor="middle">${htmlEscape(measured.kindLabel || KIND_LABEL[kind] || kind)}</text>`);
|
|
135
|
+
measured.roleLines.forEach((line, idx) => {
|
|
136
|
+
const ly = roleStartY + idx * 16;
|
|
137
|
+
parts.push(` <text class="m-node__role" x="${cx.toFixed(2)}" y="${ly.toFixed(2)}" text-anchor="middle">${htmlEscape(line)}</text>`);
|
|
138
|
+
});
|
|
139
|
+
parts.push(' </a>');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const edge of layout.edges) {
|
|
143
|
+
const meta = findEdgeMeta(state, edge.id);
|
|
144
|
+
const kind = edgeKindFor(meta);
|
|
145
|
+
const d = renderEdgePath(edge);
|
|
146
|
+
if (!d) continue;
|
|
147
|
+
parts.push(` <g class="m-edge m-edge--${kind}" data-edge="${htmlEscape(edge.id)}">`);
|
|
148
|
+
parts.push(` <path d="${d}" fill="none" marker-end="url(#arrow-${kind})" />`);
|
|
149
|
+
for (const label of edge.labels || []) {
|
|
150
|
+
if (!label.text) continue;
|
|
151
|
+
const lx = label.x + (label.width || 0) / 2;
|
|
152
|
+
const ly = label.y + (label.height || 0) / 2 + 4;
|
|
153
|
+
parts.push(` <text class="m-edge__label" x="${lx.toFixed(2)}" y="${ly.toFixed(2)}" text-anchor="middle">${htmlEscape(label.text)}</text>`);
|
|
154
|
+
}
|
|
155
|
+
parts.push(' </g>');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
parts.push(' </g>');
|
|
159
|
+
parts.push('</svg>');
|
|
160
|
+
return parts.join('\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderAtlasSubmoduleIndex(state) {
|
|
164
|
+
const items = [];
|
|
165
|
+
for (const feature of state.features || []) {
|
|
166
|
+
for (const sub of feature.submodules || []) {
|
|
167
|
+
items.push({
|
|
168
|
+
feature: feature.slug,
|
|
169
|
+
featureTitle: feature.title || feature.slug,
|
|
170
|
+
sub: sub.slug,
|
|
171
|
+
kind: sub.kind,
|
|
172
|
+
role: sub.role,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (items.length === 0) return '';
|
|
177
|
+
const rows = items.map((it) => ` <li class="atlas-submodule-index__item">
|
|
178
|
+
<a href="features/${htmlEscape(it.feature)}/${htmlEscape(it.sub)}.html">
|
|
179
|
+
<span class="atlas-submodule-index__feature">${htmlEscape(it.featureTitle)}</span>
|
|
180
|
+
<span class="atlas-submodule-index__sub">${htmlEscape(it.sub)}</span>
|
|
181
|
+
<span class="atlas-submodule-index__kind atlas-submodule-index__kind--${htmlEscape(it.kind)}">${htmlEscape(KIND_LABEL[it.kind] || it.kind)}</span>
|
|
182
|
+
</a>
|
|
183
|
+
${it.role ? `<p class="atlas-submodule-index__role">${htmlEscape(it.role)}</p>` : ''}
|
|
184
|
+
</li>`).join('\n');
|
|
185
|
+
return ` <ul class="atlas-submodule-index">
|
|
186
|
+
${rows}
|
|
187
|
+
</ul>`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function renderMacro({ state, layout, outDir }) {
|
|
191
|
+
const pageRel = pagePathFor('macro');
|
|
192
|
+
const assetRel = relAssetPath(path.join(outDir, pageRel), outDir);
|
|
193
|
+
const svg = renderMacroSvg(layout, state);
|
|
194
|
+
const title = (state.meta && state.meta.title) || 'Project architecture';
|
|
195
|
+
const summary = (state.meta && state.meta.summary) || '';
|
|
196
|
+
const submoduleIndex = renderAtlasSubmoduleIndex(state);
|
|
197
|
+
|
|
198
|
+
const body = `<body>
|
|
199
|
+
<header class="atlas-header">
|
|
200
|
+
<h1>${htmlEscape(title)}</h1>
|
|
201
|
+
${summary ? `<p class="atlas-summary">${htmlEscape(summary)}</p>` : ''}
|
|
202
|
+
</header>
|
|
203
|
+
<main class="atlas-main">
|
|
204
|
+
<section class="atlas-canvas" aria-label="Macro architecture diagram">
|
|
205
|
+
<div class="atlas-canvas__toolbar" role="toolbar" aria-label="Diagram controls">
|
|
206
|
+
<button type="button" data-pan-zoom="zoom-in" aria-label="Zoom in">+</button>
|
|
207
|
+
<button type="button" data-pan-zoom="zoom-out" aria-label="Zoom out">−</button>
|
|
208
|
+
<button type="button" data-pan-zoom="fit" aria-label="Reset view">Fit</button>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="atlas-canvas__viewport" data-pan-zoom-viewport>
|
|
211
|
+
${svg}
|
|
212
|
+
</div>
|
|
213
|
+
<ol class="atlas-legend" aria-label="Edge legend">
|
|
214
|
+
<li><span class="legend-swatch legend-swatch--call"></span>call</li>
|
|
215
|
+
<li><span class="legend-swatch legend-swatch--return"></span>return</li>
|
|
216
|
+
<li><span class="legend-swatch legend-swatch--data-row"></span>data-row</li>
|
|
217
|
+
<li><span class="legend-swatch legend-swatch--failure"></span>failure</li>
|
|
218
|
+
</ol>
|
|
219
|
+
</section>
|
|
220
|
+
<section class="atlas-index" aria-label="Submodule index">
|
|
221
|
+
<h2>Submodule index</h2>
|
|
222
|
+
${submoduleIndex}
|
|
223
|
+
</section>
|
|
224
|
+
</main>
|
|
225
|
+
<script src="${assetRel}/viewer.client.js" defer></script>
|
|
226
|
+
</body>
|
|
227
|
+
</html>`;
|
|
228
|
+
|
|
229
|
+
return `${head({ title, assetRel, pageKind: 'macro' })}\n${body}\n`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderSubmoduleCard(featureSlug, sub) {
|
|
233
|
+
const kindLabel = KIND_LABEL[sub.kind] || sub.kind;
|
|
234
|
+
const link = `${sub.slug}.html`;
|
|
235
|
+
return ` <li class="submodule-card">
|
|
236
|
+
<a class="submodule-card__link" href="${htmlEscape(link)}">
|
|
237
|
+
<span class="submodule-card__name">${htmlEscape(sub.slug)}</span>
|
|
238
|
+
<span class="submodule-card__kind submodule-card__kind--${htmlEscape(sub.kind)}">${htmlEscape(kindLabel)}</span>
|
|
239
|
+
</a>
|
|
240
|
+
${sub.role ? `<p class="submodule-card__role">${htmlEscape(sub.role)}</p>` : ''}
|
|
241
|
+
</li>`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function renderFeaturePage({ feature, outDir }) {
|
|
245
|
+
const pageRel = pagePathFor('feature', { featureSlug: feature.slug });
|
|
246
|
+
const assetRel = relAssetPath(path.join(outDir, pageRel), outDir);
|
|
247
|
+
const title = feature.title || feature.slug;
|
|
248
|
+
const subNav = (feature.submodules || []).map((s) => renderSubmoduleCard(feature.slug, s)).join('\n');
|
|
249
|
+
const dependsOn = Array.isArray(feature.dependsOn) ? feature.dependsOn : [];
|
|
250
|
+
const dependsList = dependsOn.length > 0
|
|
251
|
+
? `<p class="feature-depends">Depends on: ${dependsOn.map((d) => `<a href="../${htmlEscape(d)}/index.html">${htmlEscape(d)}</a>`).join(', ')}</p>`
|
|
252
|
+
: '';
|
|
253
|
+
const intraEdges = (feature.edges || []).filter((e) => e.kind && e.label);
|
|
254
|
+
|
|
255
|
+
const body = `<body>
|
|
256
|
+
<header class="feature-header">
|
|
257
|
+
<nav class="feature-breadcrumb"><a href="../../index.html">← Atlas</a></nav>
|
|
258
|
+
<h1>${htmlEscape(title)}</h1>
|
|
259
|
+
${dependsList}
|
|
260
|
+
</header>
|
|
261
|
+
<main class="feature-main">
|
|
262
|
+
${feature.story ? `<section class="feature-story"><p>${htmlEscape(feature.story)}</p></section>` : ''}
|
|
263
|
+
<section class="feature-submodules" aria-label="Submodules">
|
|
264
|
+
<h2>Submodules</h2>
|
|
265
|
+
<ul class="submodule-nav">
|
|
266
|
+
${subNav}
|
|
267
|
+
</ul>
|
|
268
|
+
</section>
|
|
269
|
+
${intraEdges.length > 0 ? `<section class="feature-edges" aria-label="Intra-feature edges">
|
|
270
|
+
<h2>Intra-feature edges</h2>
|
|
271
|
+
<ul class="feature-edges__list">
|
|
272
|
+
${intraEdges.map((e) => {
|
|
273
|
+
const from = typeof e.from === 'string' ? e.from : (e.from && e.from.submodule);
|
|
274
|
+
const to = typeof e.to === 'string' ? e.to : (e.to && e.to.submodule);
|
|
275
|
+
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>`;
|
|
276
|
+
}).join('\n')}
|
|
277
|
+
</ul>
|
|
278
|
+
</section>` : ''}
|
|
279
|
+
</main>
|
|
280
|
+
</body>
|
|
281
|
+
</html>`;
|
|
282
|
+
|
|
283
|
+
return `${head({ title, assetRel, pageKind: 'feature' })}\n${body}\n`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function renderSubmoduleTable(headers, rows) {
|
|
287
|
+
return `<table class="sub-table">
|
|
288
|
+
<thead><tr>${headers.map((h) => `<th scope="col">${htmlEscape(h)}</th>`).join('')}</tr></thead>
|
|
289
|
+
<tbody>
|
|
290
|
+
${rows.map((r) => ` <tr>${r.map((c) => `<td>${htmlEscape(c == null ? '' : c)}</td>`).join('')}</tr>`).join('\n')}
|
|
291
|
+
</tbody>
|
|
292
|
+
</table>`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function normalizeDataflowStep(item) {
|
|
296
|
+
if (typeof item === 'string') return { step: item, fn: '', reads: [], writes: [] };
|
|
297
|
+
if (!item || typeof item !== 'object') return { step: '', fn: '', reads: [], writes: [] };
|
|
298
|
+
return {
|
|
299
|
+
step: typeof item.step === 'string' ? item.step : '',
|
|
300
|
+
fn: typeof item.fn === 'string' ? item.fn.trim() : '',
|
|
301
|
+
reads: Array.isArray(item.reads) ? item.reads.filter((v) => typeof v === 'string' && v.trim()) : [],
|
|
302
|
+
writes: Array.isArray(item.writes) ? item.writes.filter((v) => typeof v === 'string' && v.trim()) : [],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renderInternalDataflowSvg(steps) {
|
|
307
|
+
if (!steps || steps.length === 0) {
|
|
308
|
+
return '<p class="sub-dataflow__empty">No internal dataflow steps recorded.</p>';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Each step renders as a box with three optional zones: a top fn pill
|
|
312
|
+
// (which function executes this step), the step description in the
|
|
313
|
+
// middle, and a bottom row of variable chips (← reads / → writes).
|
|
314
|
+
// The surrounding viewport handles zoom/pan, so we size boxes to the
|
|
315
|
+
// content rather than the viewport.
|
|
316
|
+
const boxW = 520;
|
|
317
|
+
const lineHeight = 20;
|
|
318
|
+
const innerPadY = 18;
|
|
319
|
+
const fnRowH = 32; // fn pill + spacing
|
|
320
|
+
const chipsRowH = 26; // chips row + spacing
|
|
321
|
+
const minBoxH = 72;
|
|
322
|
+
const gap = 44;
|
|
323
|
+
const padLeft = 80; // room for the left-side step-number badge
|
|
324
|
+
const padTop = 32;
|
|
325
|
+
const padBottom = 32;
|
|
326
|
+
const padRight = 28;
|
|
327
|
+
|
|
328
|
+
const normalized = steps.map(normalizeDataflowStep);
|
|
329
|
+
const layouts = normalized.map((s) => {
|
|
330
|
+
const lines = wrapText(s.step, 60);
|
|
331
|
+
const hasFn = s.fn.length > 0;
|
|
332
|
+
const hasChips = s.reads.length > 0 || s.writes.length > 0;
|
|
333
|
+
const textBlockH = lines.length * lineHeight;
|
|
334
|
+
const boxH = Math.max(minBoxH, innerPadY * 2 + (hasFn ? fnRowH : 0) + textBlockH + (hasChips ? chipsRowH : 0));
|
|
335
|
+
return { lines, hasFn, hasChips, boxH };
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const totalH = padTop + layouts.reduce((a, l) => a + l.boxH, 0) + (normalized.length - 1) * gap + padBottom;
|
|
339
|
+
const totalW = padLeft + boxW + padRight;
|
|
340
|
+
|
|
341
|
+
const parts = [];
|
|
342
|
+
parts.push(`<svg class="sub-dataflow__svg" data-atlas-svg="sub-dataflow" viewBox="0 0 ${totalW} ${totalH}" role="img" aria-label="Internal dataflow">`);
|
|
343
|
+
parts.push(' <defs>');
|
|
344
|
+
parts.push(' <marker id="sub-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 Z" /></marker>');
|
|
345
|
+
parts.push(' </defs>');
|
|
346
|
+
|
|
347
|
+
let cursorY = padTop;
|
|
348
|
+
normalized.forEach((s, i) => {
|
|
349
|
+
const layout = layouts[i];
|
|
350
|
+
const boxX = padLeft;
|
|
351
|
+
const boxY = cursorY;
|
|
352
|
+
const boxH = layout.boxH;
|
|
353
|
+
const badgeCx = padLeft - 38;
|
|
354
|
+
const badgeCy = boxY + boxH / 2;
|
|
355
|
+
|
|
356
|
+
parts.push(' <g class="sub-dataflow__step">');
|
|
357
|
+
parts.push(` <circle class="sub-dataflow__badge" cx="${badgeCx}" cy="${badgeCy}" r="18" />`);
|
|
358
|
+
parts.push(` <text class="sub-dataflow__badge-text" x="${badgeCx}" y="${badgeCy + 5}" text-anchor="middle">${i + 1}</text>`);
|
|
359
|
+
parts.push(` <rect class="sub-dataflow__box" x="${boxX}" y="${boxY}" width="${boxW}" height="${boxH}" rx="14" ry="14" />`);
|
|
360
|
+
|
|
361
|
+
if (layout.hasFn) {
|
|
362
|
+
const fnLabel = `fn ${s.fn}`;
|
|
363
|
+
const pillX = boxX + 14;
|
|
364
|
+
const pillY = boxY + 14;
|
|
365
|
+
const pillW = Math.max(72, fnLabel.length * 7.4 + 20);
|
|
366
|
+
parts.push(` <rect class="sub-dataflow__fn-bg" x="${pillX}" y="${pillY}" width="${pillW}" height="20" rx="10" ry="10" />`);
|
|
367
|
+
parts.push(` <text class="sub-dataflow__fn-text" x="${pillX + 10}" y="${pillY + 14}">${htmlEscape(fnLabel)}</text>`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const topUsed = layout.hasFn ? fnRowH : 0;
|
|
371
|
+
const bottomUsed = layout.hasChips ? chipsRowH : 0;
|
|
372
|
+
const textZoneH = boxH - topUsed - bottomUsed;
|
|
373
|
+
const textBlockH = layout.lines.length * lineHeight;
|
|
374
|
+
const textStartY = boxY + topUsed + (textZoneH - textBlockH) / 2 + lineHeight - 4;
|
|
375
|
+
layout.lines.forEach((line, idx) => {
|
|
376
|
+
parts.push(` <text class="sub-dataflow__text" x="${boxX + boxW / 2}" y="${textStartY + idx * lineHeight}" text-anchor="middle">${htmlEscape(line)}</text>`);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
if (layout.hasChips) {
|
|
380
|
+
const chipY = boxY + boxH - 12;
|
|
381
|
+
if (s.reads.length > 0) {
|
|
382
|
+
const text = `← reads: ${s.reads.join(', ')}`;
|
|
383
|
+
parts.push(` <text class="sub-dataflow__chip sub-dataflow__chip--reads" x="${boxX + 14}" y="${chipY}">${htmlEscape(text)}</text>`);
|
|
384
|
+
}
|
|
385
|
+
if (s.writes.length > 0) {
|
|
386
|
+
const text = `→ writes: ${s.writes.join(', ')}`;
|
|
387
|
+
parts.push(` <text class="sub-dataflow__chip sub-dataflow__chip--writes" x="${boxX + boxW - 14}" y="${chipY}" text-anchor="end">${htmlEscape(text)}</text>`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
parts.push(' </g>');
|
|
392
|
+
|
|
393
|
+
if (i < normalized.length - 1) {
|
|
394
|
+
const aY = boxY + boxH + 6;
|
|
395
|
+
const bY = aY + gap - 14;
|
|
396
|
+
const x = boxX + boxW / 2;
|
|
397
|
+
parts.push(` <line class="sub-dataflow__arrow" x1="${x}" y1="${aY}" x2="${x}" y2="${bY}" marker-end="url(#sub-arrow)" />`);
|
|
398
|
+
}
|
|
399
|
+
cursorY += boxH + gap;
|
|
400
|
+
});
|
|
401
|
+
parts.push('</svg>');
|
|
402
|
+
return parts.join('\n');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function wrapText(text, maxChars) {
|
|
406
|
+
if (!text) return [''];
|
|
407
|
+
const words = String(text).split(/\s+/);
|
|
408
|
+
const lines = [];
|
|
409
|
+
let current = '';
|
|
410
|
+
for (const word of words) {
|
|
411
|
+
if (!current) { current = word; continue; }
|
|
412
|
+
if ((current.length + 1 + word.length) <= maxChars) current = `${current} ${word}`;
|
|
413
|
+
else { lines.push(current); current = word; }
|
|
414
|
+
}
|
|
415
|
+
if (current) lines.push(current);
|
|
416
|
+
// Allow up to 4 lines so long error/rollback notes stay readable; the
|
|
417
|
+
// surrounding viewport handles scroll/zoom for anything beyond.
|
|
418
|
+
return lines.slice(0, 4);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function renderSubmodulePage({ feature, sub, outDir }) {
|
|
422
|
+
const pageRel = pagePathFor('submodule', { featureSlug: feature.slug, submoduleSlug: sub.slug });
|
|
423
|
+
const assetRel = relAssetPath(path.join(outDir, pageRel), outDir);
|
|
424
|
+
const title = `${feature.title || feature.slug} · ${sub.slug}`;
|
|
425
|
+
const ioRows = (sub.functions || []).map((fn) => [fn.name, fn.in || '', fn.out || '', fn.side || '', fn.purpose || '']);
|
|
426
|
+
const varRows = (sub.variables || []).map((v) => [v.name, v.type || '', v.scope || '', v.purpose || '']);
|
|
427
|
+
const errRows = (sub.errors || []).map((e) => [e.name, e.when || '', e.means || '']);
|
|
428
|
+
|
|
429
|
+
const body = `<body>
|
|
430
|
+
<header class="submodule-header">
|
|
431
|
+
<nav class="submodule-breadcrumb"><a href="../../index.html">← Atlas</a> · <a href="index.html">← ${htmlEscape(feature.title || feature.slug)}</a></nav>
|
|
432
|
+
<h1>${htmlEscape(sub.slug)} <small class="submodule-kind submodule-kind--${htmlEscape(sub.kind)}">${htmlEscape(KIND_LABEL[sub.kind] || sub.kind)}</small></h1>
|
|
433
|
+
${sub.role ? `<p class="submodule-role">${htmlEscape(sub.role)}</p>` : ''}
|
|
434
|
+
</header>
|
|
435
|
+
<main class="submodule-main">
|
|
436
|
+
<section class="sub-io" aria-label="Function I/O">
|
|
437
|
+
<h2>Function I/O</h2>
|
|
438
|
+
${ioRows.length > 0
|
|
439
|
+
? renderSubmoduleTable(['Name', 'In', 'Out', 'Side', 'Purpose'], ioRows)
|
|
440
|
+
: '<p class="sub-section__empty">No functions recorded.</p>'}
|
|
441
|
+
</section>
|
|
442
|
+
<section class="sub-vars" aria-label="Variables">
|
|
443
|
+
<h2>Variables</h2>
|
|
444
|
+
${varRows.length > 0
|
|
445
|
+
? renderSubmoduleTable(['Name', 'Type', 'Scope', 'Purpose'], varRows)
|
|
446
|
+
: '<p class="sub-section__empty">No variables recorded.</p>'}
|
|
447
|
+
</section>
|
|
448
|
+
<section class="sub-dataflow" aria-label="Internal data flow">
|
|
449
|
+
<h2>Internal data flow</h2>
|
|
450
|
+
${(sub.dataflow && sub.dataflow.length > 0)
|
|
451
|
+
? `<div class="sub-dataflow__canvas" data-pan-zoom-container>
|
|
452
|
+
<div class="sub-dataflow__toolbar" role="toolbar" aria-label="Diagram controls">
|
|
453
|
+
<button type="button" data-pan-zoom="zoom-in" aria-label="Zoom in">+</button>
|
|
454
|
+
<button type="button" data-pan-zoom="zoom-out" aria-label="Zoom out">−</button>
|
|
455
|
+
<button type="button" data-pan-zoom="fit" aria-label="Reset view">Fit</button>
|
|
456
|
+
</div>
|
|
457
|
+
<div class="sub-dataflow__viewport" data-pan-zoom-viewport>
|
|
458
|
+
${renderInternalDataflowSvg(sub.dataflow)}
|
|
459
|
+
</div>
|
|
460
|
+
</div>`
|
|
461
|
+
: renderInternalDataflowSvg(sub.dataflow)}
|
|
462
|
+
</section>
|
|
463
|
+
<section class="sub-errors" aria-label="Errors">
|
|
464
|
+
<h2>Errors</h2>
|
|
465
|
+
${errRows.length > 0
|
|
466
|
+
? renderSubmoduleTable(['Name', 'When', 'Means'], errRows)
|
|
467
|
+
: '<p class="sub-section__empty">No errors recorded.</p>'}
|
|
468
|
+
</section>
|
|
469
|
+
</main>
|
|
470
|
+
<script src="${assetRel}/viewer.client.js" defer></script>
|
|
471
|
+
</body>
|
|
472
|
+
</html>`;
|
|
473
|
+
|
|
474
|
+
return `${head({ title, assetRel, pageKind: 'submodule' })}\n${body}\n`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function copyAssets(outDir) {
|
|
478
|
+
const assetsDir = path.join(outDir, 'assets');
|
|
479
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
480
|
+
const srcCss = path.join(__dirname, 'assets', 'architecture.css');
|
|
481
|
+
const srcJs = path.join(__dirname, 'assets', 'viewer.client.js');
|
|
482
|
+
fs.copyFileSync(srcCss, path.join(assetsDir, 'architecture.css'));
|
|
483
|
+
fs.copyFileSync(srcJs, path.join(assetsDir, 'viewer.client.js'));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// renderAll({outDir, state, scope?}) writes every page for the
|
|
487
|
+
// resolved state. When scope is provided, only the listed pages are
|
|
488
|
+
// emitted; this is how spec mode generates the proposed-after subset.
|
|
489
|
+
async function renderAll({ outDir, state, scope = null, removedPaths = [] }) {
|
|
490
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
491
|
+
copyAssets(outDir);
|
|
492
|
+
|
|
493
|
+
const layout = await layoutMacro(state);
|
|
494
|
+
|
|
495
|
+
const shouldEmit = (kind, slug, subSlug) => {
|
|
496
|
+
if (!scope) return true;
|
|
497
|
+
if (kind === 'macro') return scope.macro === true;
|
|
498
|
+
if (kind === 'feature') return scope.features && scope.features.has(slug);
|
|
499
|
+
if (kind === 'submodule') {
|
|
500
|
+
return (scope.submodules || []).some((s) => s.feature === slug && s.submodule === subSlug);
|
|
501
|
+
}
|
|
502
|
+
return false;
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const written = [];
|
|
506
|
+
|
|
507
|
+
if (shouldEmit('macro')) {
|
|
508
|
+
const html = renderMacro({ state, layout, outDir });
|
|
509
|
+
const file = path.join(outDir, pagePathFor('macro'));
|
|
510
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
511
|
+
fs.writeFileSync(file, html, 'utf8');
|
|
512
|
+
written.push(pagePathFor('macro'));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
for (const feature of state.features || []) {
|
|
516
|
+
if (shouldEmit('feature', feature.slug)) {
|
|
517
|
+
const html = renderFeaturePage({ feature, outDir });
|
|
518
|
+
const file = path.join(outDir, pagePathFor('feature', { featureSlug: feature.slug }));
|
|
519
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
520
|
+
fs.writeFileSync(file, html, 'utf8');
|
|
521
|
+
written.push(pagePathFor('feature', { featureSlug: feature.slug }));
|
|
522
|
+
}
|
|
523
|
+
for (const sub of feature.submodules || []) {
|
|
524
|
+
if (shouldEmit('submodule', feature.slug, sub.slug)) {
|
|
525
|
+
const html = renderSubmodulePage({ feature, sub, outDir });
|
|
526
|
+
const file = path.join(outDir, pagePathFor('submodule', { featureSlug: feature.slug, submoduleSlug: sub.slug }));
|
|
527
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
528
|
+
fs.writeFileSync(file, html, 'utf8');
|
|
529
|
+
written.push(pagePathFor('submodule', { featureSlug: feature.slug, submoduleSlug: sub.slug }));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (removedPaths && removedPaths.length > 0) {
|
|
535
|
+
const lines = ['# Pages removed by this spec. Used by `apltk architecture diff`.', ...removedPaths];
|
|
536
|
+
fs.writeFileSync(path.join(outDir, '_removed.txt'), `${lines.join('\n')}\n`, 'utf8');
|
|
537
|
+
} else {
|
|
538
|
+
const file = path.join(outDir, '_removed.txt');
|
|
539
|
+
if (fs.existsSync(file)) fs.rmSync(file);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return { written, layout };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function scopeFromDiff(diff) {
|
|
546
|
+
const submodules = [];
|
|
547
|
+
for (const item of diff.modifiedSubmodules || []) submodules.push(item);
|
|
548
|
+
for (const item of diff.addedSubmodules || []) submodules.push(item);
|
|
549
|
+
const features = new Set([...(diff.modifiedFeatures || []), ...(diff.addedFeatures || [])]);
|
|
550
|
+
// If only a submodule changed but its feature isn't otherwise modified,
|
|
551
|
+
// the feature index page does not need to re-emit. The submodule page
|
|
552
|
+
// itself still needs the feature title/role for breadcrumb, which is
|
|
553
|
+
// pulled from state at render time, so omitting the feature page is safe.
|
|
554
|
+
return {
|
|
555
|
+
macro: diff.macroChanged === true,
|
|
556
|
+
features,
|
|
557
|
+
submodules,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function removedPagePathsFromDiff(diff) {
|
|
562
|
+
const paths = [];
|
|
563
|
+
for (const slug of diff.removedFeatures || []) {
|
|
564
|
+
paths.push(pagePathFor('feature', { featureSlug: slug }));
|
|
565
|
+
}
|
|
566
|
+
for (const item of diff.removedSubmodules || []) {
|
|
567
|
+
paths.push(pagePathFor('submodule', { featureSlug: item.feature, submoduleSlug: item.submodule }));
|
|
568
|
+
}
|
|
569
|
+
return paths;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
module.exports = {
|
|
573
|
+
KIND_LABEL,
|
|
574
|
+
htmlEscape,
|
|
575
|
+
pagePathFor,
|
|
576
|
+
renderAll,
|
|
577
|
+
renderMacro,
|
|
578
|
+
renderFeaturePage,
|
|
579
|
+
renderSubmodulePage,
|
|
580
|
+
copyAssets,
|
|
581
|
+
scopeFromDiff,
|
|
582
|
+
removedPagePathsFromDiff,
|
|
583
|
+
};
|