Files
root-at-skicandClaude Opus 5.5 22815a9940 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>
2026-09-30 12:44:15 +03:00

452 lines
20 KiB
JavaScript

import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { applyTemplate, renderCards, esc } from './utils.mjs';
import { validateSchema } from './validator.mjs';
import { verifyRepositoryEvidence } from './repository-evidence.mjs';
import { installRendererDiagnosticBoundary, throwDiagnosticError, throwDiagnosticProblems, recordDiagnostic } from './diagnostics.mjs';
import { validateEngineeringProfile } from './engineering-profiles.mjs';
import {
resolveOutputPath,
validateAuthoredOutputPath,
} from './output-path.mjs';
import {
captureAtomicOutput,
captureRegularFileBinding,
publishRegularFileBinding,
releaseRegularFileBinding,
removeOwnedRegularFile,
verifyAtomicOutput,
} from './atomic-output.mjs';
import { resolveLocale, translateMessage, registerLocale, SUPPORTED_LOCALES } from './i18n.mjs';
import { prepareDiagramBrandMarks } from './brand-marks.mjs';
const outputPathGuards = new Map();
let renderCandidateSequence = 0;
// meta.locale is renderer-owned Viewer UI, not authored content.
// en and zh-CN ship as built-in catalogs.
// Any other tag needs meta.translations (validated against the English
// message-key set, layered over English per-key so partial/invalid entries
// never break rendering) or it falls back to the English Viewer chrome —
// the same "omit locale, disclose the fallback" contract as before, just
// resolved from data instead of a hard-coded enum. See i18n.mjs.
function applyLocaleTranslations(diagramType, diagram) {
const locale = diagram.meta?.locale;
if (!locale) return;
const translations = diagram.meta?.translations;
if (translations && Object.keys(translations).length) {
const report = registerLocale(locale, translations);
if (report.missingKeys.length || report.unknownKeys.length || report.placeholderMismatches.length) {
recordDiagnostic({
code: 'i18n/translation-coverage',
severity: 'warning',
message: `meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`,
subject: { diagramType, path: '/meta/translations' },
evidence: {
missingKeys: report.missingKeys.slice(0, 10),
missingKeysTotal: report.missingKeys.length,
unknownKeys: report.unknownKeys.slice(0, 10),
unknownKeysTotal: report.unknownKeys.length,
placeholderMismatches: report.placeholderMismatches.slice(0, 10),
placeholderMismatchesTotal: report.placeholderMismatches.length,
},
supportedFixes: ['Add the missing keys to meta.translations.', 'Match each translation\'s {placeholders} to the English source string.'],
});
// Coverage is a fact about this render, not just a diagnostic-mode
// artifact: print it to stderr unconditionally so `render`/`deliver`/
// `validate` disclose the fallback even without ARCHIFY_DIAGNOSTIC_FORMAT.
console.warn(`archify: meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`);
}
} else if (!SUPPORTED_LOCALES.includes(locale)) {
recordDiagnostic({
code: 'i18n/locale-fallback',
severity: 'warning',
message: `meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`,
subject: { diagramType, path: '/meta/locale' },
supportedFixes: ['Supply meta.translations for this locale.', `Use a built-in locale: ${SUPPORTED_LOCALES.join(', ')}.`],
});
console.warn(`archify: meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`);
}
}
// Common CLI head: node render-<type>.mjs [input.json] [output.html]
// Keep this synchronous because callers also use it to establish the guarded
// output path before testing a last-moment filesystem alias change.
export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = process.argv }) {
// Compilers also import this module for SVG helpers. Only CLI execution
// should install a process-level handler, before reading or validating input.
installRendererDiagnosticBoundary();
const skillRoot = path.resolve(rendererDir, '../..');
const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample));
let input;
try {
input = fs.readFileSync(inputPath, 'utf8');
} catch (error) {
if (!isFilesystemError(error)) throw error;
const message = `Input could not be read: ${error.message}`;
throwDiagnosticError(message, [{
code: 'input/read', message,
subject: { input: inputPath },
evidence: { systemCode: error.code, reason: error.message },
supportedFixes: ['provide one readable JSON input file'],
}]);
}
let diagram;
try {
diagram = JSON.parse(input);
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
const message = `Input JSON could not be parsed: ${error.message}`;
throwDiagnosticError(message, [{
code: 'input/json-parse', message,
subject: { input: inputPath },
evidence: { reason: error.message },
supportedFixes: ['repair the JSON syntax and run validation again'],
}]);
}
const authoredOutput = diagram?.meta?.output;
if (authoredOutput !== undefined) validateAuthoredOutputPath(authoredOutput);
validateSchema(diagramType, diagram);
applyLocaleTranslations(diagramType, diagram);
validateCrossCollectionContracts(diagramType, diagram);
validateEngineeringProfile(diagramType, diagram);
const sourceEvidence = verifyRepositoryEvidence(diagramType, diagram, process.env.ARCHIFY_REPO_ROOT);
const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
const outputRequest = {
requestedOutput: argv[3],
authoredOutput: diagram.meta?.output,
defaultOutput: `${diagramType}.html`,
inputPaths: [inputPath],
cwd: process.cwd(),
};
let outPath;
try {
({ outputPath: outPath } = resolveOutputPath(outputRequest));
} catch (error) {
throwOutputError(error, path.resolve(outputRequest.requestedOutput || outputRequest.authoredOutput || outputRequest.defaultOutput));
}
outputPathGuards.set(outPath, outputRequest);
return { diagram, template, outPath, sourceEvidence };
}
// Brand URL capture is the only asynchronous authoring step. Typed renderers
// opt into it through this wrapper without changing loadDiagram's long-lived
// synchronous safety contract.
export async function loadDiagramWithBrandMarks(options) {
const loaded = loadDiagram(options);
await prepareDiagramBrandMarks(options.diagramType, loaded.diagram);
return loaded;
}
const START_TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);
function isFilesystemError(error) {
return typeof error?.code === 'string'
&& typeof error?.syscall === 'string'
&& typeof error?.errno === 'number';
}
function throwOutputError(error, output) {
if (error?.archifyDiagnostics || !isFilesystemError(error)) throw error;
const message = `Output could not be written: ${error.message}`;
throwDiagnosticError(message, [{
code: 'output/write', message,
subject: { output },
evidence: { systemCode: error.code, reason: error.message },
supportedFixes: ['choose a writable HTML file path and ensure its parent directories can be created'],
}]);
}
function throwAtomicOutputFailure(result, output) {
const reason = result.reason || { code: 'unclassified' };
const changed = result.status === 'different';
const candidate = reason.code.startsWith('candidate-');
const nonRegular = ['target-not-regular-file', 'candidate-not-regular-file'].includes(reason.code);
const hardlinked = ['target-hardlinked', 'candidate-hardlinked'].includes(reason.code);
const message = changed
? 'Output target changed while the rendered artifact was being prepared.'
: nonRegular
? candidate
? 'Temporary output candidate is no longer a regular file.'
: 'Output already exists and is not a regular file.'
: hardlinked
? candidate
? 'Temporary output candidate has multiple hard-link names.'
: 'Output already exists through multiple hard-link names.'
: 'Output target stability could not be determined safely before commit.';
throwDiagnosticError(message, [{
code: changed
? 'output/target-changed'
: nonRegular
? 'output/target-not-regular-file'
: hardlinked
? 'output/target-hardlinked'
: 'output/target-indeterminate',
message,
subject: { output },
evidence: { relation: reason },
supportedFixes: [hardlinked
? 'choose a non-hardlinked output path; atomic replacement cannot update every hard-link name'
: 'retry after other processes stop replacing or redirecting the output path'],
}]);
}
function stageRenderedHtml(outputPath, html, mode) {
for (let attempt = 0; attempt < 100; attempt += 1) {
renderCandidateSequence += 1;
const candidatePath = path.join(
path.dirname(outputPath),
`.archify-render-${process.pid}-${Date.now().toString(36)}-${renderCandidateSequence}.tmp`,
);
let descriptor;
let identity;
try {
const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
descriptor = fs.openSync(
candidatePath,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
mode ?? 0o666,
);
let metadata;
try {
metadata = fs.fstatSync(descriptor, { bigint: true });
} catch (error) {
// A transient first inspection failure must not strand the exclusive
// candidate. A successful retry binds cleanup to the still-open file;
// if both inspections fail, preserving the unknown entry is safer.
try {
const retry = fs.fstatSync(descriptor, { bigint: true });
if (retry.isFile() && retry.ino !== 0n) {
identity = { device: retry.dev, inode: retry.ino };
}
} catch {}
throw error;
}
if (!metadata.isFile() || metadata.ino === 0n) {
throw new Error('Temporary render candidate identity could not be verified safely.');
}
identity = { device: metadata.dev, inode: metadata.ino };
fs.writeFileSync(descriptor, html);
// Creation modes are filtered through the process umask. An atomic
// replacement must retain the exact permissions of an existing target,
// while a brand-new target should keep normal umask behavior.
if (mode !== null) fs.fchmodSync(descriptor, mode);
fs.closeSync(descriptor);
descriptor = undefined;
return { candidatePath, identity };
} catch (error) {
if (descriptor !== undefined) {
try { fs.closeSync(descriptor); } catch {}
}
if (error.code === 'EEXIST') continue;
if (identity) {
const cleanup = removeOwnedRegularFile(candidatePath, identity);
if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
const cleanupError = new Error(`${error.message}; temporary render candidate cleanup also failed.`);
cleanupError.cause = error;
throw cleanupError;
}
}
throw error;
}
}
const error = new Error(`Could not reserve a temporary render candidate beside "${outputPath}".`);
error.code = 'EEXIST';
error.errno = -17;
error.syscall = 'open';
throw error;
}
// Common CLI tail: fill the template and write the standalone HTML file.
export function writeDiagram({ outPath, template, diagramType, meta, svg, cards, sourceEvidence = null }) {
if (!START_TYPES.has(diagramType)) throw new Error(`writeDiagram: unknown diagram type ${JSON.stringify(diagramType)}`);
const outputGuard = outputPathGuards.get(outPath);
const html = applyTemplate(template, {
title: meta.title,
subtitle: meta.subtitle,
svg,
cards: renderCards(cards),
locale: meta.locale,
visualPreset: meta.visual_preset || 'classic',
sourceEvidence,
});
let candidatePath;
let candidateIdentity;
let candidateBinding;
try {
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const outputCapture = captureAtomicOutput(outPath);
if (outputCapture.status !== 'captured') throwAtomicOutputFailure(outputCapture, outPath);
const beforeStage = verifyAtomicOutput(outputCapture.snapshot);
if (beforeStage.status !== 'match') throwAtomicOutputFailure(beforeStage, outPath);
({ candidatePath, identity: candidateIdentity } = stageRenderedHtml(
outputCapture.commitPath,
html,
outputCapture.mode,
));
// The renderer may spend substantial time building HTML after loadDiagram
// establishes the guard. Re-run it after staging so a last-moment alias
// cannot redirect the commit onto an input file.
if (outputGuard) resolveOutputPath(outputGuard);
const candidateCapture = captureRegularFileBinding(candidatePath, {
subject: 'candidate',
expectedSha256: createHash('sha256').update(html).digest('hex'),
expectedBytes: Buffer.byteLength(html),
expectedIdentity: candidateIdentity,
...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }),
});
if (candidateCapture.status !== 'captured') throwAtomicOutputFailure(candidateCapture, outPath);
candidateBinding = candidateCapture.binding;
const beforeCommit = verifyAtomicOutput(outputCapture.snapshot);
if (beforeCommit.status !== 'match') throwAtomicOutputFailure(beforeCommit, outPath);
const publication = publishRegularFileBinding(
candidateBinding,
candidatePath,
outputCapture.snapshot,
{ subject: 'candidate' },
);
if (!['committed', 'committed-with-warning'].includes(publication.status)) {
throwAtomicOutputFailure(publication, outPath);
}
const releasedCandidate = releaseRegularFileBinding(candidateBinding);
candidateBinding = undefined;
if (releasedCandidate.status !== 'released') throwAtomicOutputFailure(releasedCandidate, outPath);
candidatePath = undefined;
candidateIdentity = undefined;
} catch (error) {
throwOutputError(error, outPath);
} finally {
outputPathGuards.delete(outPath);
if (candidateBinding) releaseRegularFileBinding(candidateBinding);
if (candidatePath && candidateIdentity) {
const cleanup = removeOwnedRegularFile(candidatePath, candidateIdentity);
if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
throwAtomicOutputFailure(cleanup, outPath);
}
}
}
console.log(outPath);
}
const SEMANTIC_COLLECTIONS = {
architecture: 'components',
workflow: 'nodes',
sequence: 'participants',
dataflow: 'nodes',
lifecycle: 'states',
};
const RELATIONSHIP_COLLECTIONS = {
architecture: 'connections',
workflow: 'edges',
sequence: 'messages',
dataflow: 'flows',
lifecycle: 'transitions',
};
// Relationship IDs are optional for backwards compatibility, but once an
// author supplies one it becomes the durable identity used by viewer links.
// Keep uniqueness enforcement in the shared zero-install path so every typed
// renderer fails the same way even when development dependencies are absent.
export function validateRelationshipIds(diagramType, diagram) {
const collection = RELATIONSHIP_COLLECTIONS[diagramType];
const relationships = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
const seen = new Set();
const problems = [];
relationships.forEach((relationship, index) => {
if (relationship.id === undefined || relationship.id === null || relationship.id === '') return;
if (seen.has(relationship.id)) {
problems.push(`/${collection}/${index}/id duplicates relationship id ${JSON.stringify(relationship.id)}`);
}
seen.add(relationship.id);
});
if (problems.length) {
throwDiagnosticProblems('Relationship identity validation failed', problems, {
code: 'relationship/duplicate-id',
subject: { diagramType, collection },
});
}
}
// Share relationship-ID semantic checks between the loader
// and workflow compiler without performing filesystem operations (see #429).
export function validateCrossCollectionContracts(diagramType, diagram) {
validateRelationshipIds(diagramType, diagram);
}
// Accessible name for the generated diagram SVG.
export function svgRootAttrs(meta, explicitQualityProfile) {
const animation = meta.animation === 'trace' ? ' data-animation="trace"' : '';
const preset = ` data-preset="${esc(meta.visual_preset || 'classic')}"`;
const engineeringProfile = meta.engineering_profile
? ` data-engineering-profile="${esc(meta.engineering_profile)}"`
: '';
const requestedProfile = explicitQualityProfile || process.env.ARCHIFY_QUALITY_PROFILE || meta.quality_profile;
const qualityProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
const advisory = requestedProfile ? '' : ' data-quality-gates="advisory"';
return `role="img" lang="${esc(resolveLocale(meta.locale))}" aria-labelledby="archify-diagram-title archify-diagram-description"${animation}${preset}${engineeringProfile} data-quality-profile="${esc(qualityProfile)}"${advisory}`;
}
// Keep the accessible name inside the SVG so it survives standalone SVG
// export and embedding. The fixed IDs are deterministic because an Archify
// artifact intentionally contains one primary diagram SVG.
export function svgAccessibleText(meta, kind) {
const description = meta.subtitle || translateMessage(meta.locale, `diagram.description.${kind}`);
return ` <title id="archify-diagram-title">${esc(meta.title)}</title>\n <desc id="archify-diagram-description">${esc(description)}</desc>`;
}
export function animateAttr(meta, kind, step) {
if (meta.animation !== 'trace') return '';
// Ambient trace must finish inside the fixed six-second WebM capture. The
// cap affects visual delay only; authored order and semantic identity stay
// untouched in the JSON, DOM, and relationship contracts.
const safeStep = Number.isFinite(step) && step >= 0 ? Math.min(12, Math.floor(step)) : 0;
return ` data-animate="${kind}" style="--step:${safeStep}"`;
}
// Stable semantic hooks for the standalone HTML explorer. IDs already pass
// the schema's conservative identifier pattern; escape again at the markup
// boundary so these helpers remain safe if that contract expands later.
export function focusNodeAttrs(id, label, metadata = {}, locale) {
const optional = [
['data-node-kind', metadata.kind],
['data-node-sublabel', metadata.sublabel],
['data-node-tag', metadata.tag],
['data-node-context', metadata.context],
['data-node-brand', metadata.brand],
['data-node-brand-id', metadata.brandId],
['data-node-brand-status', metadata.brandStatus],
['data-node-brand-source', metadata.brandSource],
].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
.map(([name, value]) => ` ${name}="${esc(String(value))}"`)
.join('');
const detail = [metadata.sublabel, metadata.context, metadata.brand]
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
.join(', ');
const aria = detail
? translateMessage(locale, 'node.focus.detail', { label, detail })
: translateMessage(locale, 'node.focus', { label });
return `id="node-${esc(id)}" data-node-id="${esc(id)}" data-node-label="${esc(label)}" tabindex="0" role="button" aria-label="${esc(aria)}" aria-pressed="false"${optional}`;
}
// Native SVG titles preserve a compact details-on-demand fallback when the
// canonical SVG is embedded inline outside the full Archify viewer.
export function focusNodeTitle(label, metadata = {}) {
const parts = [label, metadata.sublabel, metadata.context, metadata.tag, metadata.brand]
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '');
return `<title>${esc(parts.join(' · '))}</title>`;
}
export function focusEdgeAttrs(from, to, label, key, id) {
const named = label ? ` data-edge-label="${esc(label)}"` : '';
const keyed = key !== undefined && key !== null ? ` data-edge-key="${esc(String(key))}"` : '';
const identified = id !== undefined && id !== null && String(id).trim() !== ''
? ` data-edge-id="${esc(String(id))}"`
: '';
return `data-edge-from="${esc(from)}" data-edge-to="${esc(to)}"${named}${keyed}${identified}`;
}