astro-archify 0.3.4
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/LICENSE +21 -0
- package/README.md +169 -0
- package/astro-archify-integration.d.ts +100 -0
- package/astro-archify-integration.js +0 -0
- package/package.json +64 -0
- package/vendor/archify/LICENSE +22 -0
- package/vendor/archify/NOTICE.md +48 -0
- package/vendor/archify/assets/template.html +14787 -0
- package/vendor/archify/renderers/architecture/grid.mjs +62 -0
- package/vendor/archify/renderers/architecture/render-architecture.mjs +1089 -0
- package/vendor/archify/renderers/dataflow/render-dataflow.mjs +482 -0
- package/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +570 -0
- package/vendor/archify/renderers/sequence/render-sequence.mjs +468 -0
- package/vendor/archify/renderers/shared/brand-marks.mjs +563 -0
- package/vendor/archify/renderers/shared/cli.mjs +220 -0
- package/vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
- package/vendor/archify/renderers/shared/diagnostics.mjs +116 -0
- package/vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
- package/vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/vendor/archify/renderers/shared/generated-validators.mjs +13 -0
- package/vendor/archify/renderers/shared/geometry.mjs +1334 -0
- package/vendor/archify/renderers/shared/i18n.mjs +594 -0
- package/vendor/archify/renderers/shared/layout-report.mjs +40 -0
- package/vendor/archify/renderers/shared/legend.mjs +217 -0
- package/vendor/archify/renderers/shared/output-path.mjs +321 -0
- package/vendor/archify/renderers/shared/repository-evidence.mjs +235 -0
- package/vendor/archify/renderers/shared/text-fit.mjs +49 -0
- package/vendor/archify/renderers/shared/utils.mjs +232 -0
- package/vendor/archify/renderers/shared/validator.mjs +86 -0
- package/vendor/archify/renderers/workflow/render-workflow.mjs +749 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { throwDiagnosticError } from './diagnostics.mjs';
|
|
5
|
+
|
|
6
|
+
const FULL_SHA_RE = /^[a-f0-9]{40}$/i;
|
|
7
|
+
const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/;
|
|
8
|
+
|
|
9
|
+
function evidenceFailure(code, message, { subject = {}, evidence = {}, supportedFixes = [] } = {}) {
|
|
10
|
+
throwDiagnosticError(message, [{
|
|
11
|
+
code,
|
|
12
|
+
severity: 'error',
|
|
13
|
+
message,
|
|
14
|
+
subject: { surface: 'repository-evidence', ...subject },
|
|
15
|
+
evidence,
|
|
16
|
+
supportedFixes,
|
|
17
|
+
}]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function runGit(repoRoot, args) {
|
|
21
|
+
const result = spawnSync('git', ['-C', repoRoot, ...args], {
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
24
|
+
});
|
|
25
|
+
if (result.error) evidenceFailure('repository-evidence/git-unavailable', `Could not run Git: ${result.error.message}`, {
|
|
26
|
+
evidence: { reason: result.error.message },
|
|
27
|
+
supportedFixes: ['install Git and ensure it is available on PATH'],
|
|
28
|
+
});
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function gitValue(repoRoot, args, failure) {
|
|
33
|
+
const result = runGit(repoRoot, args);
|
|
34
|
+
if (result.status !== 0) evidenceFailure('repository-evidence/git-command', failure, {
|
|
35
|
+
evidence: { gitArgs: args, exitCode: result.status },
|
|
36
|
+
supportedFixes: ['use the intended local Git repository and verify its origin and revision'],
|
|
37
|
+
});
|
|
38
|
+
return result.stdout.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function githubSlug(value) {
|
|
42
|
+
const raw = String(value || '').trim();
|
|
43
|
+
const match = raw.match(/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
|
|
44
|
+
return match ? `${match[1]}/${match[2]}`.toLowerCase() : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function verifiedSourcePath(value, where) {
|
|
48
|
+
const sourcePath = String(value || '');
|
|
49
|
+
if (!sourcePath || sourcePath.startsWith('/') || sourcePath.includes('\\') || CONTROL_CHARACTER_RE.test(sourcePath)) {
|
|
50
|
+
evidenceFailure('repository-evidence/path-invalid', `${where} must be a repo-relative POSIX path.`, {
|
|
51
|
+
subject: { path: where },
|
|
52
|
+
evidence: { authoredPath: sourcePath },
|
|
53
|
+
supportedFixes: ['use a repository-relative path with forward slashes'],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const segments = sourcePath.split('/');
|
|
57
|
+
if (segments.some((segment) => !segment || segment === '.' || segment === '..') || segments[0] === '.git') {
|
|
58
|
+
evidenceFailure('repository-evidence/path-escape', `${where} must stay inside the repository and may not address .git.`, {
|
|
59
|
+
subject: { path: where },
|
|
60
|
+
evidence: { authoredPath: sourcePath },
|
|
61
|
+
supportedFixes: ['remove empty, dot, parent, or .git path segments'],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return segments.join('/');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sourceHref(repositoryUrl, revision, source) {
|
|
68
|
+
const encodedPath = source.path.split('/').map(encodeURIComponent).join('/');
|
|
69
|
+
const lineFragment = source.line
|
|
70
|
+
? `#L${source.line}${source.endLine && source.endLine !== source.line ? `-L${source.endLine}` : ''}`
|
|
71
|
+
: '';
|
|
72
|
+
return `${repositoryUrl}/blob/${revision}/${encodedPath}${lineFragment}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sourceLineCount(content) {
|
|
76
|
+
if (!content.length) return 0;
|
|
77
|
+
const lines = content.split(/\r\n|\n|\r/);
|
|
78
|
+
return lines.length - (/(?:\r\n|\n|\r)$/.test(content) ? 1 : 0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function hasRepositoryEvidence(diagramType, diagram) {
|
|
82
|
+
if (diagramType !== 'architecture') return false;
|
|
83
|
+
const components = Array.isArray(diagram?.components) ? diagram.components : [];
|
|
84
|
+
return Boolean(diagram?.meta?.repository) || components.some((component) => Array.isArray(component?.sources) && component.sources.length);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function verifyRepositoryEvidence(diagramType, diagram, repoRootInput) {
|
|
88
|
+
if (!hasRepositoryEvidence(diagramType, diagram)) return null;
|
|
89
|
+
if (diagramType !== 'architecture') evidenceFailure('repository-evidence/type-unsupported', 'Repository evidence is currently supported for architecture diagrams only.', {
|
|
90
|
+
subject: { diagramType },
|
|
91
|
+
supportedFixes: ['use architecture mode or remove repository evidence'],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const repository = diagram.meta?.repository;
|
|
95
|
+
if (!repository) evidenceFailure('repository-evidence/repository-required', 'Repository evidence requires /meta/repository.', {
|
|
96
|
+
subject: { path: '/meta/repository' },
|
|
97
|
+
supportedFixes: ['add the pinned public repository metadata or remove component sources'],
|
|
98
|
+
});
|
|
99
|
+
if (!FULL_SHA_RE.test(repository.revision || '')) {
|
|
100
|
+
evidenceFailure('repository-evidence/revision-invalid', '/meta/repository/revision must be a full 40-character commit SHA.', {
|
|
101
|
+
subject: { path: '/meta/repository/revision' },
|
|
102
|
+
evidence: { revision: repository.revision },
|
|
103
|
+
supportedFixes: ['pin one full 40-character commit SHA'],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const authoredSlug = githubSlug(repository.url);
|
|
107
|
+
if (!authoredSlug || !String(repository.url).startsWith('https://github.com/')) {
|
|
108
|
+
evidenceFailure('repository-evidence/url-invalid', '/meta/repository/url must be a public https://github.com owner/repository URL.', {
|
|
109
|
+
subject: { path: '/meta/repository/url' },
|
|
110
|
+
evidence: { repositoryUrl: repository.url },
|
|
111
|
+
supportedFixes: ['use the canonical public GitHub HTTPS repository URL'],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (!repoRootInput) {
|
|
115
|
+
evidenceFailure('repository-evidence/root-required', 'This diagram declares source evidence. Pass --repo-root <repository> so Archify can verify it before rendering.', {
|
|
116
|
+
subject: { path: '/meta/repository' },
|
|
117
|
+
supportedFixes: ['pass --repo-root with the matching local Git checkout'],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const requestedRoot = path.resolve(repoRootInput);
|
|
122
|
+
let realRoot;
|
|
123
|
+
try {
|
|
124
|
+
realRoot = fs.realpathSync(requestedRoot);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
evidenceFailure('repository-evidence/root-unreadable', `Could not resolve evidence repository root "${requestedRoot}": ${error.message}`, {
|
|
127
|
+
subject: { repoRoot: requestedRoot },
|
|
128
|
+
evidence: { reason: error.message },
|
|
129
|
+
supportedFixes: ['pass one readable local repository directory'],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const gitRoot = gitValue(realRoot, ['rev-parse', '--show-toplevel'], `Evidence root "${realRoot}" is not a Git repository.`);
|
|
133
|
+
if (fs.realpathSync(gitRoot) !== realRoot) {
|
|
134
|
+
evidenceFailure('repository-evidence/root-not-top-level', `Evidence root must be the Git top-level directory: ${gitRoot}`, {
|
|
135
|
+
subject: { repoRoot: realRoot },
|
|
136
|
+
evidence: { gitTopLevel: gitRoot },
|
|
137
|
+
supportedFixes: [`pass --repo-root ${gitRoot}`],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const origin = gitValue(realRoot, ['remote', 'get-url', 'origin'], 'Evidence repository must have an origin remote.');
|
|
141
|
+
if (githubSlug(origin) !== authoredSlug) {
|
|
142
|
+
evidenceFailure('repository-evidence/origin-mismatch', `Evidence repository origin ${JSON.stringify(origin)} does not match ${JSON.stringify(repository.url)}.`, {
|
|
143
|
+
subject: { repoRoot: realRoot },
|
|
144
|
+
evidence: { localOrigin: origin, authoredRepository: repository.url },
|
|
145
|
+
supportedFixes: ['use the matching local checkout or correct the authored repository URL'],
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const revision = repository.revision.toLowerCase();
|
|
150
|
+
const commit = runGit(realRoot, ['cat-file', '-e', `${revision}^{commit}`]);
|
|
151
|
+
if (commit.status !== 0) {
|
|
152
|
+
evidenceFailure('repository-evidence/revision-unavailable', `Evidence revision ${revision} is not available in the local repository.`, {
|
|
153
|
+
subject: { repoRoot: realRoot },
|
|
154
|
+
evidence: { revision },
|
|
155
|
+
supportedFixes: ['fetch the pinned commit or pin an available full commit SHA'],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const nodes = Object.create(null);
|
|
160
|
+
let referenceCount = 0;
|
|
161
|
+
const components = Array.isArray(diagram.components) ? diagram.components : [];
|
|
162
|
+
for (const [componentIndex, component] of components.entries()) {
|
|
163
|
+
if (!Array.isArray(component.sources) || component.sources.length === 0) continue;
|
|
164
|
+
const verified = [];
|
|
165
|
+
for (const [sourceIndex, authored] of component.sources.entries()) {
|
|
166
|
+
const where = `/components/${componentIndex}/sources/${sourceIndex}/path`;
|
|
167
|
+
const source = {
|
|
168
|
+
path: verifiedSourcePath(authored.path, where),
|
|
169
|
+
...(authored.line ? { line: authored.line } : {}),
|
|
170
|
+
...(authored.end_line ? { endLine: authored.end_line } : {}),
|
|
171
|
+
...(authored.label ? { label: authored.label } : {}),
|
|
172
|
+
};
|
|
173
|
+
if (source.endLine && !source.line) {
|
|
174
|
+
evidenceFailure('repository-evidence/line-required', `/components/${componentIndex}/sources/${sourceIndex}/end_line requires line.`, {
|
|
175
|
+
subject: { path: `/components/${componentIndex}/sources/${sourceIndex}/end_line`, componentId: component.id },
|
|
176
|
+
supportedFixes: ['add line or remove end_line'],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (source.endLine && source.endLine < source.line) {
|
|
180
|
+
evidenceFailure('repository-evidence/line-range-invalid', `/components/${componentIndex}/sources/${sourceIndex}/end_line must be greater than or equal to line.`, {
|
|
181
|
+
subject: { path: `/components/${componentIndex}/sources/${sourceIndex}`, componentId: component.id },
|
|
182
|
+
evidence: { line: source.line, endLine: source.endLine },
|
|
183
|
+
supportedFixes: ['use an end_line greater than or equal to line'],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const object = `${revision}:${source.path}`;
|
|
187
|
+
const type = runGit(realRoot, ['cat-file', '-t', object]);
|
|
188
|
+
if (type.status !== 0 || type.stdout.trim() !== 'blob') {
|
|
189
|
+
evidenceFailure('repository-evidence/file-missing', `${where} does not identify a file at revision ${revision}.`, {
|
|
190
|
+
subject: { path: where, componentId: component.id },
|
|
191
|
+
evidence: { sourcePath: source.path, revision },
|
|
192
|
+
supportedFixes: ['use a file path that exists at the pinned revision'],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (source.line) {
|
|
196
|
+
const content = runGit(realRoot, ['show', object]);
|
|
197
|
+
if (content.status !== 0) evidenceFailure('repository-evidence/file-unreadable', `${where} could not be read at revision ${revision}.`, {
|
|
198
|
+
subject: { path: where, componentId: component.id },
|
|
199
|
+
evidence: { sourcePath: source.path, revision },
|
|
200
|
+
supportedFixes: ['verify the pinned blob is readable in the local checkout'],
|
|
201
|
+
});
|
|
202
|
+
const lineCount = sourceLineCount(content.stdout);
|
|
203
|
+
const requestedLine = source.endLine || source.line;
|
|
204
|
+
if (requestedLine > lineCount) {
|
|
205
|
+
evidenceFailure('repository-evidence/line-out-of-range', `/components/${componentIndex}/sources/${sourceIndex} requests line ${requestedLine}, but ${source.path} has ${lineCount} lines at revision ${revision}.`, {
|
|
206
|
+
subject: { path: `/components/${componentIndex}/sources/${sourceIndex}`, componentId: component.id },
|
|
207
|
+
evidence: { sourcePath: source.path, requestedLine, lineCount, revision },
|
|
208
|
+
supportedFixes: ['use a line range that exists at the pinned revision'],
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
verified.push({ ...source, href: sourceHref(repository.url.replace(/\.git\/?$/i, '').replace(/\/$/, ''), revision, source) });
|
|
213
|
+
referenceCount += 1;
|
|
214
|
+
}
|
|
215
|
+
nodes[component.id] = verified;
|
|
216
|
+
}
|
|
217
|
+
if (referenceCount === 0) {
|
|
218
|
+
evidenceFailure('repository-evidence/source-required', '/meta/repository requires at least one component source reference.', {
|
|
219
|
+
subject: { path: '/meta/repository' },
|
|
220
|
+
supportedFixes: ['add at least one verified component source or remove repository metadata'],
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
schemaVersion: 1,
|
|
226
|
+
verified: true,
|
|
227
|
+
repository: {
|
|
228
|
+
url: repository.url.replace(/\.git\/?$/i, '').replace(/\/$/, ''),
|
|
229
|
+
revision,
|
|
230
|
+
shortRevision: revision.slice(0, 7),
|
|
231
|
+
},
|
|
232
|
+
referenceCount,
|
|
233
|
+
nodes,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Single-line node text fitting, shared by every renderer.
|
|
2
|
+
//
|
|
3
|
+
// Node text (`label`, `sublabel`, `tag`) renders as one <text> element with
|
|
4
|
+
// text-anchor="middle" and is never wrapped. Left unmeasured, an over-long
|
|
5
|
+
// value silently spills across its neighbours while validation still reports
|
|
6
|
+
// a clean receipt — the failure mode this module exists to close.
|
|
7
|
+
//
|
|
8
|
+
// Two halves, always used together:
|
|
9
|
+
// - fittedNodeFontSize shrinks the text toward a legible minimum at render
|
|
10
|
+
// time, so ordinary overruns simply get smaller instead of overlapping.
|
|
11
|
+
// - minimumNodeTextWidth reports the width the text still needs once it has
|
|
12
|
+
// shrunk as far as it may, so validation can reject what shrinking cannot
|
|
13
|
+
// save.
|
|
14
|
+
//
|
|
15
|
+
// The geometry constants below are shared; the per-field `preferred` and
|
|
16
|
+
// `minimum` font sizes are not, because renderers set node text at different
|
|
17
|
+
// sizes (architecture sublabels are 9px, the rest are 7px).
|
|
18
|
+
|
|
19
|
+
import { textUnits } from './utils.mjs';
|
|
20
|
+
|
|
21
|
+
// widthFactor: px of advance width per text unit, per px of font size.
|
|
22
|
+
// horizontalPadding: total px reserved inside the box so text never touches
|
|
23
|
+
// the border.
|
|
24
|
+
export const nodeTextFit = {
|
|
25
|
+
widthFactor: 0.6,
|
|
26
|
+
horizontalPadding: 8,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Largest font size at or below `preferred` that fits `text` inside `width`,
|
|
30
|
+
// floored at `minimum` — below that the text is no longer legible and the
|
|
31
|
+
// caller should be reporting a problem instead.
|
|
32
|
+
export function fittedNodeFontSize(text, width, preferred, minimum) {
|
|
33
|
+
const units = Math.max(1, textUnits(text));
|
|
34
|
+
const available = Math.max(1, width - nodeTextFit.horizontalPadding);
|
|
35
|
+
const fitted = Math.min(preferred, available / (units * nodeTextFit.widthFactor));
|
|
36
|
+
return Math.max(minimum, Math.floor(fitted * 10) / 10);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Width `text` occupies at its legible minimum. Compare against
|
|
40
|
+
// `width - nodeTextFit.horizontalPadding` to decide whether shrink-to-fit can
|
|
41
|
+
// rescue it.
|
|
42
|
+
export function minimumNodeTextWidth(text, minimum) {
|
|
43
|
+
return textUnits(text) * minimum * nodeTextFit.widthFactor;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Available text width inside a box of `width`.
|
|
47
|
+
export function availableNodeTextWidth(width) {
|
|
48
|
+
return width - nodeTextFit.horizontalPadding;
|
|
49
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import {
|
|
2
|
+
escapeHtml as esc,
|
|
3
|
+
localizeTemplate,
|
|
4
|
+
resolveLocale,
|
|
5
|
+
translateMessage,
|
|
6
|
+
viewerCatalog,
|
|
7
|
+
} from './i18n.mjs';
|
|
8
|
+
|
|
9
|
+
export { esc };
|
|
10
|
+
|
|
11
|
+
export function renderDefinitions() {
|
|
12
|
+
return ` <!-- Definitions -->
|
|
13
|
+
<defs>
|
|
14
|
+
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
15
|
+
<polygon points="0 0, 10 3.5, 0 7" class="m-default" />
|
|
16
|
+
</marker>
|
|
17
|
+
<marker id="arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
18
|
+
<polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
|
|
19
|
+
</marker>
|
|
20
|
+
<marker id="arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
21
|
+
<polygon points="0 0, 10 3.5, 0 7" class="m-security" />
|
|
22
|
+
</marker>
|
|
23
|
+
<marker id="arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
24
|
+
<polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
|
|
25
|
+
</marker>
|
|
26
|
+
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
|
27
|
+
<path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5"/>
|
|
28
|
+
</pattern>
|
|
29
|
+
</defs>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SIGIL_TONE = {
|
|
33
|
+
frontend: 'frontend',
|
|
34
|
+
start: 'frontend',
|
|
35
|
+
backend: 'backend',
|
|
36
|
+
active: 'backend',
|
|
37
|
+
database: 'database',
|
|
38
|
+
success: 'database',
|
|
39
|
+
cloud: 'cloud',
|
|
40
|
+
waiting: 'cloud',
|
|
41
|
+
security: 'security',
|
|
42
|
+
failure: 'security',
|
|
43
|
+
messagebus: 'messagebus',
|
|
44
|
+
external: 'external',
|
|
45
|
+
neutral: 'external',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const SIGIL_SHAPE = {
|
|
49
|
+
frontend: `<rect x="2" y="3" width="12" height="10" rx="2"/>
|
|
50
|
+
<path d="M2 6.5h12"/>
|
|
51
|
+
<circle cx="4.1" cy="4.8" r=".7" class="sigil-fill"/>
|
|
52
|
+
<circle cx="6.3" cy="4.8" r=".7" class="sigil-fill"/>`,
|
|
53
|
+
backend: `<path d="M6 3 3 8l3 5M10 3l3 5-3 5"/>`,
|
|
54
|
+
database: `<ellipse cx="8" cy="4" rx="5" ry="2"/>
|
|
55
|
+
<path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2"/>`,
|
|
56
|
+
cloud: `<path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z"/>`,
|
|
57
|
+
security: `<path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z"/>
|
|
58
|
+
<path d="m5.8 8 1.5 1.5 3-3"/>`,
|
|
59
|
+
messagebus: `<path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/>
|
|
60
|
+
<circle cx="5" cy="4.5" r="1" class="sigil-fill"/>
|
|
61
|
+
<circle cx="10.5" cy="8" r="1" class="sigil-fill"/>
|
|
62
|
+
<circle cx="7" cy="11.5" r="1" class="sigil-fill"/>`,
|
|
63
|
+
external: `<rect x="2.5" y="5" width="8.5" height="8" rx="1.5"/>
|
|
64
|
+
<path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5"/>`,
|
|
65
|
+
start: `<circle cx="8" cy="8" r="5"/>
|
|
66
|
+
<path d="m7 5.4 3.6 2.6L7 10.6Z" class="sigil-fill"/>`,
|
|
67
|
+
active: `<path d="M2 8h3l1.5-3.5L9 12l1.6-4H14"/>`,
|
|
68
|
+
waiting: `<path d="M4 2.5h8M4 13.5h8M5 3c0 2.8 2 3.2 3 5-1 1.8-3 2.2-3 5M11 3c0 2.8-2 3.2-3 5 1 1.8 3 2.2 3 5"/>`,
|
|
69
|
+
success: `<circle cx="8" cy="8" r="5.3"/>
|
|
70
|
+
<path d="m5.2 8 1.8 1.8 3.8-4"/>`,
|
|
71
|
+
failure: `<circle cx="8" cy="8" r="5.3"/>
|
|
72
|
+
<path d="m5.7 5.7 4.6 4.6m0-4.6-4.6 4.6"/>`,
|
|
73
|
+
neutral: `<rect x="3" y="3" width="10" height="10" rx="2"/>
|
|
74
|
+
<circle cx="8" cy="8" r="1.2" class="sigil-fill"/>`,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// A quiet, renderer-owned role stamp. It is authored SVG content rather than a
|
|
78
|
+
// viewer overlay, so it survives canonical export while adding no focus target,
|
|
79
|
+
// accessible name, layout box, or interaction state of its own.
|
|
80
|
+
export function renderSemanticSigil(kind, { x, y, size = 11 } = {}) {
|
|
81
|
+
const normalized = Object.hasOwn(SIGIL_SHAPE, kind) ? kind : 'neutral';
|
|
82
|
+
const tone = SIGIL_TONE[normalized] || 'external';
|
|
83
|
+
const scale = size / 16;
|
|
84
|
+
return `<g aria-hidden="true" data-semantic-sigil="${esc(normalized)}" class="semantic-sigil s-${tone}" transform="translate(${x} ${y}) scale(${scale})">
|
|
85
|
+
${SIGIL_SHAPE[normalized]}
|
|
86
|
+
</g>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renderCards(cards) {
|
|
90
|
+
const list = Array.isArray(cards) ? cards : [];
|
|
91
|
+
return ` <!-- Info Cards -->
|
|
92
|
+
<div class="cards">
|
|
93
|
+
${list.map((card) => ` <div class="card">
|
|
94
|
+
<div class="card-header">
|
|
95
|
+
<div class="card-dot ${esc(card.dot)}"></div>
|
|
96
|
+
<h3>${esc(card.title)}</h3>
|
|
97
|
+
</div>
|
|
98
|
+
<ul>
|
|
99
|
+
${card.items.map((item) => ` <li>• ${esc(item)}</li>`).join('\n')}
|
|
100
|
+
</ul>
|
|
101
|
+
</div>`).join('\n\n')}
|
|
102
|
+
</div>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const SVG_SLOT_RE = / <!-- ARCHIFY:SVG_SLOT_START -->[\s\S]*? <!-- ARCHIFY:SVG_SLOT_END -->/;
|
|
106
|
+
const CARDS_SLOT_RE = / <!-- ARCHIFY:CARDS_SLOT_START -->[\s\S]*? <!-- ARCHIFY:CARDS_SLOT_END -->/;
|
|
107
|
+
const SUBTITLE_SLOT_RE = /^([ \t]*)<p class="subtitle">\[Subtitle description\]<\/p>[ \t]*(\r?\n)?/m;
|
|
108
|
+
const GUIDED_VIEWS_PLACEHOLDER = '<!-- ARCHIFY:GUIDED_VIEWS_DATA -->';
|
|
109
|
+
const SOURCE_EVIDENCE_PLACEHOLDER = ' <!-- ARCHIFY:SOURCE_EVIDENCE_DATA -->';
|
|
110
|
+
const I18N_PLACEHOLDER = ' <!-- ARCHIFY:I18N_DATA -->';
|
|
111
|
+
|
|
112
|
+
function serializeScriptJson(value) {
|
|
113
|
+
return JSON.stringify(value)
|
|
114
|
+
.replaceAll('<', '\\u003c')
|
|
115
|
+
.replaceAll('>', '\\u003e')
|
|
116
|
+
.replaceAll('&', '\\u0026');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const TEMPLATE_PLACEHOLDERS = [
|
|
120
|
+
'<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">',
|
|
121
|
+
'<title>[PROJECT NAME] Architecture Diagram</title>',
|
|
122
|
+
'<h1>[PROJECT NAME] Architecture</h1>',
|
|
123
|
+
GUIDED_VIEWS_PLACEHOLDER,
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
export function applyTemplate(template, {
|
|
127
|
+
title,
|
|
128
|
+
subtitle,
|
|
129
|
+
svg,
|
|
130
|
+
cards,
|
|
131
|
+
locale,
|
|
132
|
+
visualPreset = 'classic',
|
|
133
|
+
guidedViews = [],
|
|
134
|
+
sourceEvidence = null,
|
|
135
|
+
}) {
|
|
136
|
+
if (!SVG_SLOT_RE.test(template)) {
|
|
137
|
+
throw new Error('applyTemplate: template missing ARCHIFY:SVG_SLOT sentinel');
|
|
138
|
+
}
|
|
139
|
+
if (!CARDS_SLOT_RE.test(template)) {
|
|
140
|
+
throw new Error('applyTemplate: template missing ARCHIFY:CARDS_SLOT sentinel');
|
|
141
|
+
}
|
|
142
|
+
if (!SUBTITLE_SLOT_RE.test(template)) {
|
|
143
|
+
throw new Error('applyTemplate: template missing subtitle placeholder');
|
|
144
|
+
}
|
|
145
|
+
for (const ph of TEMPLATE_PLACEHOLDERS) {
|
|
146
|
+
if (!template.includes(ph)) {
|
|
147
|
+
throw new Error(`applyTemplate: template missing placeholder ${JSON.stringify(ph)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Keep existing custom templates compatible when evidence is not requested.
|
|
151
|
+
// Silently dropping verified evidence would be misleading, so the new slot
|
|
152
|
+
// becomes mandatory only for the opt-in evidence path.
|
|
153
|
+
if (sourceEvidence && !template.includes(SOURCE_EVIDENCE_PLACEHOLDER)) {
|
|
154
|
+
throw new Error(`applyTemplate: repository evidence requires placeholder ${JSON.stringify(SOURCE_EVIDENCE_PLACEHOLDER)}`);
|
|
155
|
+
}
|
|
156
|
+
// Function replacers: a literal `$&`, `$'`, `$\`` or `$$` in titles, labels,
|
|
157
|
+
// or rendered SVG must not be interpreted as a replacement pattern.
|
|
158
|
+
const guidedViewsJson = serializeScriptJson(guidedViews);
|
|
159
|
+
const sourceEvidenceJson = serializeScriptJson(sourceEvidence);
|
|
160
|
+
const resolvedLocale = resolveLocale(locale);
|
|
161
|
+
const i18nJson = serializeScriptJson({ locale: resolvedLocale, messages: viewerCatalog(resolvedLocale) });
|
|
162
|
+
const renderedSubtitle = typeof subtitle === 'string' && subtitle.trim()
|
|
163
|
+
? `<p class="subtitle">${esc(subtitle)}</p>`
|
|
164
|
+
: '';
|
|
165
|
+
const i18nData = ` <script id="archify-i18n-data" type="application/json">${i18nJson}</script>`;
|
|
166
|
+
const localizedTemplate = localizeTemplate(template, resolvedLocale);
|
|
167
|
+
const templateWithI18n = localizedTemplate.includes(I18N_PLACEHOLDER)
|
|
168
|
+
? localizedTemplate.replace(I18N_PLACEHOLDER, () => i18nData)
|
|
169
|
+
: localizedTemplate.replace(GUIDED_VIEWS_PLACEHOLDER, () => `${i18nData}\n ${GUIDED_VIEWS_PLACEHOLDER}`);
|
|
170
|
+
return templateWithI18n
|
|
171
|
+
.replace(TEMPLATE_PLACEHOLDERS[0], () => `<html lang="${esc(resolvedLocale)}" data-theme="dark" data-preset="${esc(visualPreset)}">`)
|
|
172
|
+
.replace(TEMPLATE_PLACEHOLDERS[1], () => `<title>${esc(translateMessage(resolvedLocale, 'page.title', { title }))}</title>`)
|
|
173
|
+
.replace(TEMPLATE_PLACEHOLDERS[2], () => `<h1>${esc(title)}</h1>`)
|
|
174
|
+
.replace(SUBTITLE_SLOT_RE, (_match, indent, newline = '') => renderedSubtitle
|
|
175
|
+
? `${indent}${renderedSubtitle}${newline}`
|
|
176
|
+
: '')
|
|
177
|
+
.replace(SVG_SLOT_RE, () => svg)
|
|
178
|
+
.replace(CARDS_SLOT_RE, () => cards)
|
|
179
|
+
.replace(GUIDED_VIEWS_PLACEHOLDER, () => `<script id="archify-guided-views-data" type="application/json">${guidedViewsJson}</script>`)
|
|
180
|
+
.replace(SOURCE_EVIDENCE_PLACEHOLDER, () => sourceEvidence
|
|
181
|
+
? ` <script id="archify-source-evidence-data" type="application/json">${sourceEvidenceJson}</script>`
|
|
182
|
+
: '');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// CJK and other wide/fullwidth glyphs render at roughly twice the advance
|
|
186
|
+
// width of ASCII in the monospace stacks the template uses. Keep halfwidth
|
|
187
|
+
// forms (notably U+FF61–U+FF9F Katakana) out of this set. The explicit ranges
|
|
188
|
+
// also cover vertical punctuation and supplementary East Asian scripts that
|
|
189
|
+
// literal glyph ranges made difficult to audit.
|
|
190
|
+
// Code points that take two columns of advance width: East Asian Wide and
|
|
191
|
+
// Fullwidth per UAX #11, tracking Unicode 17.0. That takes in the BMP symbols
|
|
192
|
+
// carrying emoji presentation (U+2705, U+2B50, U+26A1, U+231B, ...), which
|
|
193
|
+
// render at the same square advance as the supplementary-plane emoji already
|
|
194
|
+
// listed here, and Hangul Jamo Extended-A. Two boundary calls worth naming:
|
|
195
|
+
// Unicode 16.0 reclassified the trigrams (U+2630-U+2637) and the monogram /
|
|
196
|
+
// digram symbols (U+268A-U+268F) from Neutral to Wide, so both are in; and
|
|
197
|
+
// Hangul Jamo Extended-A stops at U+A97C, its last assigned jamo, because
|
|
198
|
+
// U+A97D-U+A97F are unassigned, and unassigned code points outside the CJK
|
|
199
|
+
// ranges UAX #11 names default to Neutral rather than Wide. Spelled out as
|
|
200
|
+
// ranges because V8 has no \p{East_Asian_Width=W} property escape.
|
|
201
|
+
const FULLWIDTH_RE = /[\u1100-\u115F\u231A-\u231B\u2329-\u232A\u23E9-\u23EC\u23F0\u23F3\u25FD-\u25FE\u2614-\u2615\u2630-\u2637\u2648-\u2653\u267F\u268A-\u268F\u2693\u26A1\u26AA-\u26AB\u26BD-\u26BE\u26C4-\u26C5\u26CE\u26D4\u26EA\u26F2-\u26F3\u26F5\u26FA\u26FD\u2705\u270A-\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B-\u2B1C\u2B50\u2B55\u2E80-\uA4CF\uA960-\uA97C\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF01-\uFF60\uFFE0-\uFFE6\u{16FE0}-\u{18DFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B2FF}\u{1F000}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u;
|
|
202
|
+
|
|
203
|
+
// A variation selector (U+FE00-U+FE0F) carries no advance of its own: it
|
|
204
|
+
// re-presents the character before it. VS15 (U+FE0E) asks for text
|
|
205
|
+
// presentation, which renders narrow; VS16 (U+FE0F) asks for emoji
|
|
206
|
+
// presentation, which renders at the square emoji advance. So a base plus a
|
|
207
|
+
// selector is measured from the selector, not from the base -- otherwise
|
|
208
|
+
// widening the emoji-presentation bases above turns U+2B50 U+FE0F from two
|
|
209
|
+
// units into three while the glyph on screen stays one square, and leaves
|
|
210
|
+
// U+2708 U+FE0F at two only because its base happens to be narrow.
|
|
211
|
+
//
|
|
212
|
+
// A selector following a base that cannot take emoji presentation is
|
|
213
|
+
// malformed input; measuring it wide is the safe direction here, since
|
|
214
|
+
// over-measuring pads a box while under-measuring spills the label out of it.
|
|
215
|
+
const VARIATION_SELECTOR_FIRST = 0xfe00;
|
|
216
|
+
const VARIATION_SELECTOR_LAST = 0xfe0f;
|
|
217
|
+
const VARIATION_SELECTOR_TEXT = 0xfe0e;
|
|
218
|
+
const VARIATION_SELECTOR_EMOJI = 0xfe0f;
|
|
219
|
+
|
|
220
|
+
export function textUnits(text) {
|
|
221
|
+
const chars = Array.from(String(text ?? ''));
|
|
222
|
+
let units = 0;
|
|
223
|
+
for (let i = 0; i < chars.length; i += 1) {
|
|
224
|
+
const codePoint = chars[i].codePointAt(0);
|
|
225
|
+
if (codePoint >= VARIATION_SELECTOR_FIRST && codePoint <= VARIATION_SELECTOR_LAST) continue;
|
|
226
|
+
const next = i + 1 < chars.length ? chars[i + 1].codePointAt(0) : -1;
|
|
227
|
+
if (next === VARIATION_SELECTOR_EMOJI) units += 2;
|
|
228
|
+
else if (next === VARIATION_SELECTOR_TEXT) units += 1;
|
|
229
|
+
else units += FULLWIDTH_RE.test(chars[i]) ? 2 : 1;
|
|
230
|
+
}
|
|
231
|
+
return units;
|
|
232
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as validators from './generated-validators.mjs';
|
|
2
|
+
import { throwDiagnosticError } from './diagnostics.mjs';
|
|
3
|
+
|
|
4
|
+
// "/nodes/3/label" reads much better as "/nodes/3 (id: "router") /label" for the
|
|
5
|
+
// LLM fixing the JSON; resolve the nearest enclosing element's id or label.
|
|
6
|
+
function annotatedPath(instancePath, data) {
|
|
7
|
+
if (!instancePath) return { path: '/', identity: null };
|
|
8
|
+
let node = data;
|
|
9
|
+
let hint = null;
|
|
10
|
+
for (const seg of instancePath.split('/').slice(1)) {
|
|
11
|
+
if (node == null || typeof node !== 'object') break;
|
|
12
|
+
node = node[/^\d+$/.test(seg) ? Number(seg) : seg];
|
|
13
|
+
if (node && typeof node === 'object' && !Array.isArray(node)) {
|
|
14
|
+
const tag = node.id ?? node.label;
|
|
15
|
+
if (tag != null) hint = String(tag);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { path: instancePath, identity: hint };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function annotatePath(instancePath, data) {
|
|
22
|
+
const annotated = annotatedPath(instancePath, data);
|
|
23
|
+
return annotated.identity != null
|
|
24
|
+
? `${annotated.path} (id/label: ${JSON.stringify(annotated.identity)})`
|
|
25
|
+
: annotated.path;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatErrors(errors, data) {
|
|
29
|
+
return errors.map((e) => {
|
|
30
|
+
const where = annotatePath(e.instancePath, data);
|
|
31
|
+
const detail = e.params && Object.keys(e.params).length
|
|
32
|
+
? ' ' + JSON.stringify(e.params)
|
|
33
|
+
: '';
|
|
34
|
+
return ` ${where} ${e.message}${detail}`;
|
|
35
|
+
}).join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function validateSchema(diagramType, data) {
|
|
39
|
+
const validate = validators[diagramType];
|
|
40
|
+
if (!validate) {
|
|
41
|
+
throw new Error(`validateSchema: unknown diagram type "${diagramType}"`);
|
|
42
|
+
}
|
|
43
|
+
if (!validate(data)) {
|
|
44
|
+
const diagnostics = validate.errors.map((error) => {
|
|
45
|
+
const annotated = annotatedPath(error.instancePath, data);
|
|
46
|
+
const subject = {
|
|
47
|
+
diagramType,
|
|
48
|
+
path: annotated.path,
|
|
49
|
+
...(annotated.identity != null ? { identity: String(annotated.identity) } : {}),
|
|
50
|
+
};
|
|
51
|
+
const evidence = {
|
|
52
|
+
keyword: error.keyword,
|
|
53
|
+
expected: error.schema,
|
|
54
|
+
...error.params,
|
|
55
|
+
};
|
|
56
|
+
const supportedFixes = {
|
|
57
|
+
additionalProperties: [`remove unsupported property ${JSON.stringify(error.params?.additionalProperty)}`],
|
|
58
|
+
required: [`add required property ${JSON.stringify(error.params?.missingProperty)}`],
|
|
59
|
+
type: [`use ${JSON.stringify(error.params?.type)} at ${annotated.path}`],
|
|
60
|
+
enum: [`choose one of ${JSON.stringify(error.params?.allowedValues || [])}`],
|
|
61
|
+
pattern: [`match the required pattern ${JSON.stringify(error.params?.pattern)}`],
|
|
62
|
+
minimum: [`use a value ${error.params?.comparison || '>='} ${error.params?.limit}`],
|
|
63
|
+
maximum: [`use a value ${error.params?.comparison || '<='} ${error.params?.limit}`],
|
|
64
|
+
minItems: [`provide at least ${error.params?.limit} item(s)`],
|
|
65
|
+
maxItems: [`provide at most ${error.params?.limit} item(s)`],
|
|
66
|
+
minLength: [`provide at least ${error.params?.limit} character(s)`],
|
|
67
|
+
maxLength: [`provide at most ${error.params?.limit} character(s)`],
|
|
68
|
+
}[error.keyword] || [];
|
|
69
|
+
const detail = error.params && Object.keys(error.params).length
|
|
70
|
+
? ` ${JSON.stringify(error.params)}`
|
|
71
|
+
: '';
|
|
72
|
+
return {
|
|
73
|
+
code: `schema/${error.keyword}`,
|
|
74
|
+
severity: 'error',
|
|
75
|
+
message: `${annotatePath(error.instancePath, data)} ${error.message}${detail}`,
|
|
76
|
+
subject,
|
|
77
|
+
evidence,
|
|
78
|
+
supportedFixes,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
throwDiagnosticError(
|
|
82
|
+
`${diagramType} schema validation failed:\n${formatErrors(validate.errors, data)}`,
|
|
83
|
+
diagnostics,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|