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.
Files changed (30) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/astro-archify-integration.d.ts +100 -0
  4. package/astro-archify-integration.js +0 -0
  5. package/package.json +64 -0
  6. package/vendor/archify/LICENSE +22 -0
  7. package/vendor/archify/NOTICE.md +48 -0
  8. package/vendor/archify/assets/template.html +14787 -0
  9. package/vendor/archify/renderers/architecture/grid.mjs +62 -0
  10. package/vendor/archify/renderers/architecture/render-architecture.mjs +1089 -0
  11. package/vendor/archify/renderers/dataflow/render-dataflow.mjs +482 -0
  12. package/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +570 -0
  13. package/vendor/archify/renderers/sequence/render-sequence.mjs +468 -0
  14. package/vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  15. package/vendor/archify/renderers/shared/cli.mjs +220 -0
  16. package/vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  17. package/vendor/archify/renderers/shared/diagnostics.mjs +116 -0
  18. package/vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  19. package/vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  20. package/vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  21. package/vendor/archify/renderers/shared/geometry.mjs +1334 -0
  22. package/vendor/archify/renderers/shared/i18n.mjs +594 -0
  23. package/vendor/archify/renderers/shared/layout-report.mjs +40 -0
  24. package/vendor/archify/renderers/shared/legend.mjs +217 -0
  25. package/vendor/archify/renderers/shared/output-path.mjs +321 -0
  26. package/vendor/archify/renderers/shared/repository-evidence.mjs +235 -0
  27. package/vendor/archify/renderers/shared/text-fit.mjs +49 -0
  28. package/vendor/archify/renderers/shared/utils.mjs +232 -0
  29. package/vendor/archify/renderers/shared/validator.mjs +86 -0
  30. 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>&bull; ${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
+ }