Archify Diagram Viewer 0.1.0

VS Code extension that previews Archify diagrams from their JSON sources
(live, as you type) and opens rendered Archify HTML in a viewer tab.
Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime.
Adds validation diagnostics, JSON schema help, source-link navigation,
export saving, render-to-file and open-in-browser commands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 12:40:55 +03:00
co-authored by Claude Opus 5.5
commit d56e784e97
119 changed files with 66708 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
import {
escapeHtml as esc,
localizeTemplate,
resolveLocale,
translateMessage,
viewerCatalog,
} from './i18n.mjs';
export { esc };
export function renderDefinitions() {
return ` <!-- Definitions -->
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" class="m-default" />
</marker>
<marker id="arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
</marker>
<marker id="arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" class="m-security" />
</marker>
<marker id="arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
</marker>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5"/>
</pattern>
</defs>`;
}
const SIGIL_TONE = {
frontend: 'frontend',
start: 'frontend',
backend: 'backend',
active: 'frontend',
database: 'database',
success: 'backend',
cloud: 'cloud',
waiting: 'cloud',
security: 'security',
decision: 'database',
failure: 'security',
messagebus: 'messagebus',
external: 'external',
neutral: 'external',
};
const SIGIL_SHAPE = {
calendar: `<rect x="2" y="3.5" width="12" height="10.5" rx="2"/><path d="M5 2v3M11 2v3M2 7h12M5 10h2M9 10h2"/>`,
clock: `<circle cx="8" cy="8" r="6"/><path d="M8 4v4l3 2"/>`,
person: `<circle cx="8" cy="4.5" r="2.5"/><path d="M3 14v-2a5 5 0 0 1 10 0v2"/>`,
briefcase: `<rect x="2" y="5" width="12" height="9" rx="2"/><path d="M5 5V2h6v3M2 9h12M7 9v2h2V9"/>`,
flag: `<path d="M3 14V2h10l-2 3 2 3H3"/>`,
moon: `<path d="M13.5 10A6 6 0 0 1 6 2.5 6 6 0 1 0 13.5 10Z"/>`,
frontend: `<rect x="2" y="3" width="12" height="10" rx="2"/>
<path d="M2 6.5h12"/>
<circle cx="4.1" cy="4.8" r=".7" class="sigil-fill"/>
<circle cx="6.3" cy="4.8" r=".7" class="sigil-fill"/>`,
backend: `<path d="M6 3 3 8l3 5M10 3l3 5-3 5"/>`,
database: `<ellipse cx="8" cy="4" rx="5" ry="2"/>
<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"/>`,
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"/>`,
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"/>
<path d="m5.8 8 1.5 1.5 3-3"/>`,
messagebus: `<path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/>
<circle cx="5" cy="4.5" r="1" class="sigil-fill"/>
<circle cx="10.5" cy="8" r="1" class="sigil-fill"/>
<circle cx="7" cy="11.5" r="1" class="sigil-fill"/>`,
external: `<rect x="2.5" y="5" width="8.5" height="8" rx="1.5"/>
<path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5"/>`,
start: `<circle cx="8" cy="8" r="5"/>
<path d="m7 5.4 3.6 2.6L7 10.6Z" class="sigil-fill"/>`,
active: `<path d="M2 8h3l1.5-3.5L9 12l1.6-4H14"/>`,
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"/>`,
success: `<circle cx="8" cy="8" r="5.3"/>
<path d="m5.2 8 1.8 1.8 3.8-4"/>`,
failure: `<circle cx="8" cy="8" r="5.3"/>
<path d="m5.7 5.7 4.6 4.6m0-4.6-4.6 4.6"/>`,
neutral: `<rect x="3" y="3" width="10" height="10" rx="2"/>
<circle cx="8" cy="8" r="1.2" class="sigil-fill"/>`,
};
// A quiet, renderer-owned corner symbol (type default or authored icon). It is
// SVG content rather than a
// viewer overlay, so it survives canonical export while adding no focus target,
// accessible name, layout box, or interaction state of its own.
// Shared with label clearance so the reserved rail matches the actual icon.
export const SEMANTIC_SIGIL_INSET = 6;
export const SEMANTIC_SIGIL_SIZE = 11;
export const SEMANTIC_SIGIL_FOOTPRINT = SEMANTIC_SIGIL_INSET + SEMANTIC_SIGIL_SIZE;
// The viewer installs a runtime "sources" beacon on the node's top-right
// rail, just left of the brand mark. Layout must reserve the same footprint
// so labels never sit under the badge.
export const SOURCE_BADGE_FOOTPRINT = 38;
export function renderSemanticSigil(kind, { x, y, size = SEMANTIC_SIGIL_SIZE, icon } = {}) {
if (icon === 'none') return '';
const selected = icon ?? kind;
const normalized = Object.hasOwn(SIGIL_SHAPE, selected) ? selected : 'neutral';
const tone = SIGIL_TONE[kind] || 'external';
const scale = size / 16;
return `<g aria-hidden="true" data-semantic-sigil="${esc(normalized)}" class="semantic-sigil s-${tone}" transform="translate(${x} ${y}) scale(${scale})">
${SIGIL_SHAPE[normalized]}
</g>`;
}
export function renderCards(cards) {
const list = Array.isArray(cards) ? cards : [];
return ` <!-- Info Cards -->
<div class="cards">
${list.map((card) => ` <div class="card">
<div class="card-header">
<div class="card-dot ${esc(card.dot)}"></div>
<h3>${esc(card.title)}</h3>
</div>
<ul>
${card.items.map((item) => ` <li>${esc(item)}</li>`).join('\n')}
</ul>
</div>`).join('\n\n')}
</div>`;
}
const SVG_SLOT_RE = / <!-- ARCHIFY:SVG_SLOT_START -->[\s\S]*? <!-- ARCHIFY:SVG_SLOT_END -->/;
const CARDS_SLOT_RE = / <!-- ARCHIFY:CARDS_SLOT_START -->[\s\S]*? <!-- ARCHIFY:CARDS_SLOT_END -->/;
const SUBTITLE_SLOT_RE = /^([ \t]*)<p class="subtitle">\[Subtitle description\]<\/p>[ \t]*(\r?\n)?/m;
const SOURCE_EVIDENCE_PLACEHOLDER = ' <!-- ARCHIFY:SOURCE_EVIDENCE_DATA -->';
const I18N_PLACEHOLDER = ' <!-- ARCHIFY:I18N_DATA -->';
function serializeScriptJson(value) {
return JSON.stringify(value)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026');
}
const TEMPLATE_PLACEHOLDERS = [
'<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">',
'<title>[PROJECT NAME] Architecture Diagram</title>',
'<h1>[PROJECT NAME] Architecture</h1>',
I18N_PLACEHOLDER,
];
export function applyTemplate(template, {
title,
subtitle,
svg,
cards,
locale,
visualPreset = 'classic',
sourceEvidence = null,
}) {
if (!SVG_SLOT_RE.test(template)) {
throw new Error('applyTemplate: template missing ARCHIFY:SVG_SLOT sentinel');
}
if (!CARDS_SLOT_RE.test(template)) {
throw new Error('applyTemplate: template missing ARCHIFY:CARDS_SLOT sentinel');
}
if (!SUBTITLE_SLOT_RE.test(template)) {
throw new Error('applyTemplate: template missing subtitle placeholder');
}
for (const ph of TEMPLATE_PLACEHOLDERS) {
if (!template.includes(ph)) {
throw new Error(`applyTemplate: template missing placeholder ${JSON.stringify(ph)}`);
}
}
// Keep existing custom templates compatible when evidence is not requested.
// Silently dropping verified evidence would be misleading, so the new slot
// becomes mandatory only for the opt-in evidence path.
if (sourceEvidence && !template.includes(SOURCE_EVIDENCE_PLACEHOLDER)) {
throw new Error(`applyTemplate: repository evidence requires placeholder ${JSON.stringify(SOURCE_EVIDENCE_PLACEHOLDER)}`);
}
// Function replacers: a literal `$&`, `$'`, `$\`` or `$$` in titles, labels,
// or rendered SVG must not be interpreted as a replacement pattern.
const sourceEvidenceJson = serializeScriptJson(sourceEvidence);
const resolvedLocale = resolveLocale(locale);
const i18nJson = serializeScriptJson({ locale: resolvedLocale, messages: viewerCatalog(resolvedLocale) });
const renderedSubtitle = typeof subtitle === 'string' && subtitle.trim()
? `<p class="subtitle">${esc(subtitle)}</p>`
: '';
const i18nData = ` <script id="archify-i18n-data" type="application/json">${i18nJson}</script>`;
return localizeTemplate(template, resolvedLocale)
.replace(I18N_PLACEHOLDER, () => i18nData)
.replace(TEMPLATE_PLACEHOLDERS[0], () => `<html lang="${esc(resolvedLocale)}" data-theme="dark" data-preset="${esc(visualPreset)}">`)
.replace(TEMPLATE_PLACEHOLDERS[1], () => `<title>${esc(translateMessage(resolvedLocale, 'page.title', { title }))}</title>`)
.replace(TEMPLATE_PLACEHOLDERS[2], () => `<h1>${esc(title)}</h1>`)
.replace(SUBTITLE_SLOT_RE, (_match, indent, newline = '') => renderedSubtitle
? `${indent}${renderedSubtitle}${newline}`
: '')
.replace(SVG_SLOT_RE, () => svg)
.replace(CARDS_SLOT_RE, () => cards)
.replace(SOURCE_EVIDENCE_PLACEHOLDER, () => sourceEvidence
? ` <script id="archify-source-evidence-data" type="application/json">${sourceEvidenceJson}</script>`
: '');
}
// CJK and other wide/fullwidth glyphs render at roughly twice the advance
// width of ASCII in the monospace stacks the template uses. Keep halfwidth
// forms (notably U+FF61–U+FF9F Katakana) out of this set. The explicit ranges
// also cover vertical punctuation and supplementary East Asian scripts that
// literal glyph ranges made difficult to audit.
// Code points that take two columns of advance width: East Asian Wide and
// Fullwidth per UAX #11, tracking Unicode 17.0. That takes in the BMP symbols
// carrying emoji presentation (U+2705, U+2B50, U+26A1, U+231B, ...), which
// render at the same square advance as the supplementary-plane emoji already
// listed here, and Hangul Jamo Extended-A. Two boundary calls worth naming:
// Unicode 16.0 reclassified the trigrams (U+2630-U+2637) and the monogram /
// digram symbols (U+268A-U+268F) from Neutral to Wide, so both are in; and
// Hangul Jamo Extended-A stops at U+A97C, its last assigned jamo, because
// U+A97D-U+A97F are unassigned, and unassigned code points outside the CJK
// ranges UAX #11 names default to Neutral rather than Wide. Spelled out as
// ranges because V8 has no \p{East_Asian_Width=W} property escape.
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;
// Variation selectors contribute no separate unit. This is a conservative
// width estimate, not a measurement of the selected glyph: its actual advance
// depends on the font and presentation (Unicode UAX #11).
// VS16 requests emoji presentation, so reserve two units for the sequence.
// VS15 retains the base's width estimate; forcing every text-presentation
// sequence to one unit undercounts wide bases, including CJK characters whose
// font ignores that selector. Neutral bases remain one unit. Some selected
// text glyphs can be narrower than this estimate; prefer extra space to overflow.
const VARIATION_SELECTOR_FIRST = 0xfe00;
const VARIATION_SELECTOR_LAST = 0xfe0f;
const VARIATION_SELECTOR_EMOJI = 0xfe0f;
// Width measurement is pure and the same labels are measured many times per
// compile, so the unit count is memoized by its input string.
const TEXT_UNITS_CACHE = new Map();
const MAX_TEXT_UNITS_CACHE_ENTRIES = 4096;
const MAX_CACHED_TEXT_LENGTH = 1024;
export function textUnits(text) {
const cacheKey = String(text ?? '');
const cachedUnits = TEXT_UNITS_CACHE.get(cacheKey);
if (cachedUnits !== undefined) return cachedUnits;
const chars = Array.from(cacheKey);
let units = 0;
for (let i = 0; i < chars.length; i += 1) {
const codePoint = chars[i].codePointAt(0);
if (codePoint >= VARIATION_SELECTOR_FIRST && codePoint <= VARIATION_SELECTOR_LAST) continue;
const next = i + 1 < chars.length ? chars[i + 1].codePointAt(0) : -1;
if (next === VARIATION_SELECTOR_EMOJI) units += 2;
else units += FULLWIDTH_RE.test(chars[i]) ? 2 : 1;
}
// Keep repeated in-process compiles bounded, including unusually long labels.
if (cacheKey.length <= MAX_CACHED_TEXT_LENGTH) {
if (TEXT_UNITS_CACHE.size >= MAX_TEXT_UNITS_CACHE_ENTRIES) {
TEXT_UNITS_CACHE.delete(TEXT_UNITS_CACHE.keys().next().value);
}
TEXT_UNITS_CACHE.set(cacheKey, units);
}
return units;
}