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>
196 lines
7.2 KiB
TypeScript
196 lines
7.2 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { DiagramSource, DiagramType, hasSourceEvidence, stripSourceEvidence } from './detect';
|
|
|
|
export interface RuntimeOptions {
|
|
/** Absolute path to vendor/archify/bin/archify.mjs. */
|
|
cliPath: string;
|
|
/** Node (or Electron) executable. */
|
|
nodePath: string;
|
|
/** Set ELECTRON_RUN_AS_NODE so VS Code's Electron binary behaves as Node. */
|
|
runAsNode: boolean;
|
|
/** Scratch directory for input/output files. */
|
|
workDir: string;
|
|
quality?: string;
|
|
repoRoot?: string;
|
|
timeoutMs?: number;
|
|
log?: (line: string) => void;
|
|
}
|
|
|
|
export interface ArchifyDiagnostic {
|
|
code?: string;
|
|
severity?: 'error' | 'warning' | 'info';
|
|
message: string;
|
|
subject?: { path?: string; identity?: string; [key: string]: unknown };
|
|
supportedFixes?: string[];
|
|
}
|
|
|
|
export interface ValidateResult {
|
|
ok: boolean;
|
|
diagnostics: ArchifyDiagnostic[];
|
|
/** Set when the CLI did not produce parseable JSON. */
|
|
crash?: string;
|
|
}
|
|
|
|
export interface RenderResult {
|
|
ok: boolean;
|
|
html?: string;
|
|
error?: string;
|
|
/** Set when the diagram rendered without its source evidence. */
|
|
evidenceNotice?: string;
|
|
}
|
|
|
|
interface RunResult {
|
|
code: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
const EVIDENCE_ERROR_RE = /repository-evidence\/|source evidence|--repo-root|Evidence (root|repository|revision)/i;
|
|
|
|
function run(options: RuntimeOptions, args: string[], env: Record<string, string> = {}): Promise<RunResult> {
|
|
return new Promise((resolve, reject) => {
|
|
options.log?.(`archify ${args.join(' ')}`);
|
|
const child = spawn(options.nodePath, [options.cliPath, ...args], {
|
|
cwd: options.workDir,
|
|
env: {
|
|
...process.env,
|
|
...(options.runAsNode ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
|
|
...env,
|
|
},
|
|
windowsHide: true,
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.setEncoding('utf8').on('data', (chunk: string) => (stdout += chunk));
|
|
child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk));
|
|
const timer = setTimeout(() => child.kill(), options.timeoutMs ?? 60_000);
|
|
child.on('error', (error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
});
|
|
child.on('close', (code) => {
|
|
clearTimeout(timer);
|
|
if (stderr.trim()) options.log?.(stderr.trim());
|
|
resolve({ code, stdout, stderr });
|
|
});
|
|
});
|
|
}
|
|
|
|
function commonArgs(options: RuntimeOptions, withRepoRoot: boolean): string[] {
|
|
const args: string[] = [];
|
|
if (options.quality) args.push('--quality', options.quality);
|
|
if (withRepoRoot && options.repoRoot) args.push('--repo-root', options.repoRoot);
|
|
return args;
|
|
}
|
|
|
|
async function withScratch<T>(options: RuntimeOptions, body: (dir: string) => Promise<T>): Promise<T> {
|
|
await fs.mkdir(options.workDir, { recursive: true });
|
|
const dir = await fs.mkdtemp(path.join(options.workDir, 'run-'));
|
|
try {
|
|
return await body(dir);
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export async function validate(options: RuntimeOptions, type: DiagramType, text: string): Promise<ValidateResult> {
|
|
return withScratch(options, async (dir) => {
|
|
const input = path.join(dir, `diagram.${type}.json`);
|
|
await fs.writeFile(input, text, 'utf8');
|
|
const result = await run(options, ['validate', type, input, '--json', ...commonArgs(options, true)]);
|
|
try {
|
|
const report = JSON.parse(result.stdout);
|
|
const diagnostics: ArchifyDiagnostic[] = Array.isArray(report.diagnostics) ? report.diagnostics : [];
|
|
if (!report.ok && diagnostics.length === 0 && report.error) {
|
|
diagnostics.push({ severity: 'error', message: String(report.error) });
|
|
}
|
|
return { ok: Boolean(report.ok), diagnostics };
|
|
} catch {
|
|
const crash = (result.stderr || result.stdout || `archify exited with code ${result.code}`).trim();
|
|
return { ok: false, diagnostics: [], crash };
|
|
}
|
|
});
|
|
}
|
|
|
|
async function renderOnce(
|
|
options: RuntimeOptions,
|
|
type: DiagramType,
|
|
text: string,
|
|
withRepoRoot: boolean,
|
|
): Promise<RenderResult> {
|
|
return withScratch(options, async (dir) => {
|
|
const input = path.join(dir, `diagram.${type}.json`);
|
|
const output = path.join(dir, 'diagram.html');
|
|
await fs.writeFile(input, text, 'utf8');
|
|
const result = await run(options, ['render', type, input, output, ...commonArgs(options, withRepoRoot)]);
|
|
if (result.code === 0) {
|
|
try {
|
|
return { ok: true, html: await fs.readFile(output, 'utf8') };
|
|
} catch {
|
|
// fall through to the error below
|
|
}
|
|
}
|
|
const error = (result.stderr || result.stdout || `archify exited with code ${result.code}`).trim();
|
|
return { ok: false, error };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Renders a diagram to HTML. With `evidence: 'fallback'`, a diagram whose
|
|
* source evidence cannot be verified is rendered again without it, and the
|
|
* reason is returned as `evidenceNotice`.
|
|
*/
|
|
export async function renderDiagram(
|
|
options: RuntimeOptions,
|
|
source: DiagramSource & { text: string },
|
|
evidence: 'fallback' | 'strict',
|
|
): Promise<RenderResult> {
|
|
const withEvidence = hasSourceEvidence(source.type, source.diagram);
|
|
if (withEvidence && evidence === 'fallback' && !options.repoRoot) {
|
|
const stripped = JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2);
|
|
const result = await renderOnce(options, source.type, stripped, false);
|
|
return {
|
|
...result,
|
|
evidenceNotice: 'Source links are hidden: the diagram is not inside a Git checkout, so its source evidence cannot be verified. Set "archify.repoRoot" to the matching checkout.',
|
|
};
|
|
}
|
|
const first = await renderOnce(options, source.type, source.text, withEvidence);
|
|
if (first.ok || !withEvidence || evidence !== 'fallback' || !isEvidenceError(first.error)) {
|
|
return first;
|
|
}
|
|
const stripped = JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2);
|
|
const second = await renderOnce(options, source.type, stripped, false);
|
|
if (!second.ok) return first;
|
|
const reason = (first.error ?? '').split('\n').find((line) => line.trim()) ?? 'verification failed';
|
|
return { ...second, evidenceNotice: `Source links are hidden: ${reason.replace(/^\[[^\]]+\]\s*/, '')}` };
|
|
}
|
|
|
|
/**
|
|
* Renders to a file the user chose. Source evidence is verified strictly
|
|
* unless `stripEvidence` is set, in which case it is removed first.
|
|
*/
|
|
export async function renderToFile(
|
|
options: RuntimeOptions,
|
|
source: DiagramSource & { text: string },
|
|
outputPath: string,
|
|
stripEvidence = false,
|
|
): Promise<RenderResult> {
|
|
const text = stripEvidence
|
|
? JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2)
|
|
: source.text;
|
|
const withEvidence = !stripEvidence && hasSourceEvidence(source.type, source.diagram);
|
|
return withScratch(options, async (dir) => {
|
|
const input = path.join(dir, `diagram.${source.type}.json`);
|
|
await fs.writeFile(input, text, 'utf8');
|
|
const result = await run(options, ['render', source.type, input, outputPath, ...commonArgs(options, withEvidence)]);
|
|
if (result.code === 0) return { ok: true };
|
|
return { ok: false, error: (result.stderr || result.stdout || `archify exited with code ${result.code}`).trim() };
|
|
});
|
|
}
|
|
|
|
export function isEvidenceError(error: string | undefined): boolean {
|
|
return EVIDENCE_ERROR_RE.test(error ?? '');
|
|
}
|