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 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 fall back to English.`); } } // Common CLI head: node render-.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 ` ${esc(meta.title)}\n ${esc(description)}`; } 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 `${esc(parts.join(' · '))}`; } 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}`; }