0.3.0: .archify files are YAML and open straight into the diagram

- .archify content is YAML (JSON still accepted); converted to JSON for the renderer
- Archify Diagram custom editor is the default for *.archify; Show Source opens the YAML beside it
- YAML syntax errors and validation problems mapped to YAML lines
- yamlValidation for schema completion with redhat.vscode-yaml
- scripts/to-yaml.mjs converts existing .archify files
This commit is contained in:
2026-09-30 15:21:55 +03:00
parent a00859858c
commit d1eab34f7f
18 changed files with 744 additions and 250 deletions
+30 -9
View File
@@ -1,4 +1,5 @@
import { parse, ParseError } from 'jsonc-parser';
import { parse as parseYaml } from 'yaml';
export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const;
export type DiagramType = (typeof DIAGRAM_TYPES)[number];
@@ -44,24 +45,44 @@ export function diagramBaseName(fileName: string): string {
export interface DiagramSource {
type: DiagramType;
diagram: Record<string, unknown>;
/** Source syntax: `.archify` files are YAML, Archify's own sources JSON. */
format: 'yaml' | 'json';
/** JSON text for the Archify renderer, which only reads JSON. */
json: string;
}
/** Parses `.archify` (YAML) or JSON text; undefined when it does not parse. */
export function parseDiagramText(text: string, fileName = ''): unknown {
if (isArchifyFile(fileName)) {
try {
// YAML 1.2 also accepts the JSON form of older .archify files.
return parseYaml(text, { prettyErrors: false, uniqueKeys: false });
} catch {
return undefined;
}
}
const errors: ParseError[] = [];
return parse(text, errors, { allowTrailingComma: true });
}
/**
* Parses JSON text and returns the diagram when it declares an Archify
* `diagram_type`. Falls back to the file name for documents that do not
* parse yet, so a half-typed source still counts as a diagram.
* Returns the diagram when the text declares an Archify `diagram_type`.
* Falls back to the file name for documents that do not parse yet, so a
* half-typed source still counts as a diagram.
*/
export function readDiagramSource(text: string, fileName = ''): DiagramSource | undefined {
if (text.length > MAX_SNIFF_BYTES) return undefined;
const errors: ParseError[] = [];
const value = parse(text, errors, { allowTrailingComma: true });
if (value && typeof value === 'object' && !Array.isArray(value) && isDiagramType(value.diagram_type)) {
return { type: value.diagram_type, diagram: value };
const format = isArchifyFile(fileName) ? 'yaml' : 'json';
const value = parseDiagramText(text, fileName) as Record<string, unknown> | undefined;
const isObject = value !== null && typeof value === 'object' && !Array.isArray(value);
const json = (diagram: unknown) => (format === 'json' ? text : JSON.stringify(diagram ?? {}, null, 2));
if (isObject && isDiagramType(value.diagram_type)) {
return { type: value.diagram_type, diagram: value, format, json: json(value) };
}
const byName = typeFromFilename(fileName);
if (byName) {
const diagram = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
return { type: byName, diagram };
const diagram = isObject ? value : {};
return { type: byName, diagram, format, json: json(diagram) };
}
return undefined;
}
+37 -11
View File
@@ -1,7 +1,7 @@
import { parse, ParseError } from 'jsonc-parser';
import * as vscode from 'vscode';
import { readDiagramSource } from './detect';
import { rangeForPointer } from './jsonPointer';
import { isArchifyFile, readDiagramSource } from './detect';
import { rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer';
import { evidenceMode, log, runtimeFor } from './runtime';
@@ -61,7 +61,7 @@ export class DiagnosticsController implements vscode.Disposable {
}
private schedule(doc: vscode.TextDocument, delay: number): void {
if (!JSON_LANGUAGES.has(doc.languageId)) return;
if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName)) return;
const key = doc.uri.toString();
clearTimeout(this.timers.get(key));
this.timers.set(key, setTimeout(() => void this.validate(doc), delay));
@@ -74,22 +74,48 @@ export class DiagnosticsController implements vscode.Disposable {
this.sequence.set(key, seq);
const text = doc.getText();
const source = readDiagramSource(text, doc.fileName);
if (!source || !this.enabled(doc.uri) || doc.isClosed) {
if (!this.enabled(doc.uri) || doc.isClosed) {
this.collection.delete(doc.uri);
return;
}
// Leave syntax errors to the JSON language service.
const syntax: ParseError[] = [];
parse(text, syntax, { allowTrailingComma: false });
if (syntax.length) {
if (isArchifyFile(doc.fileName)) {
// Nothing else reports YAML syntax errors in .archify files, and a file
// that does not parse has no diagram to validate yet.
const problems = yamlSyntaxProblems(text);
if (problems.length) {
this.collection.set(
doc.uri,
problems.map((problem) => {
const diagnostic = new vscode.Diagnostic(
new vscode.Range(doc.positionAt(problem.start), doc.positionAt(problem.end)),
`YAML: ${problem.message}`,
vscode.DiagnosticSeverity.Error,
);
diagnostic.source = 'archify';
return diagnostic;
}),
);
return;
}
}
const source = readDiagramSource(text, doc.fileName);
if (!source) {
this.collection.delete(doc.uri);
return;
}
if (source.format === 'json') {
// Leave JSON syntax errors to the JSON language service.
const syntax: ParseError[] = [];
parse(text, syntax, { allowTrailingComma: false });
if (syntax.length) {
this.collection.delete(doc.uri);
return;
}
}
try {
const runtime = await runtimeFor(this.context, doc.uri);
const result = await validate(runtime, source.type, text);
const result = await validate(runtime, source.type, source.json);
if (seq !== this.sequence.get(key) || doc.isClosed) return;
if (result.crash) {
@@ -107,7 +133,7 @@ export class DiagnosticsController implements vscode.Disposable {
this.collection.set(
doc.uri,
result.diagnostics.map((item) => {
const offsets = rangeForPointer(text, item.subject?.path);
const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, item.subject?.path) : rangeForPointer(text, item.subject?.path);
const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
const fix = item.supportedFixes?.[0];
const diagnostic = new vscode.Diagnostic(
+20 -6
View File
@@ -1,10 +1,10 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import * as vscode from 'vscode';
import { diagramBaseName, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect';
import { diagramBaseName, isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect';
import { DiagnosticsController } from './diagnostics';
import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
import { PreviewManager } from './preview';
import { DIAGRAM_EDITOR_VIEW_TYPE, PreviewManager } from './preview';
import { isEvidenceError, renderDiagram, renderToFile } from './renderer';
import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime';
@@ -36,7 +36,9 @@ export function activate(context: vscode.ExtensionContext): void {
const updateContext = () => {
const doc = vscode.window.activeTextEditor?.document;
const diagram = Boolean(
doc && JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)),
doc &&
(isArchifyFile(doc.fileName) ||
(JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)))),
);
const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
@@ -64,6 +66,7 @@ export function activate(context: vscode.ExtensionContext): void {
const uri = sourceUri(arg);
if (!uri) return;
if (isHtmlUri(uri)) return openHtmlViewer(uri);
if (isArchifyFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE);
previews.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
}),
@@ -71,6 +74,12 @@ export function activate(context: vscode.ExtensionContext): void {
const uri = sourceUri(arg);
if (!uri) return;
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside);
if (isArchifyFile(uri.path)) {
return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE, {
viewColumn: vscode.ViewColumn.Beside,
preserveFocus: true,
});
}
previews.show(uri, vscode.ViewColumn.Beside, true);
}),
@@ -83,6 +92,11 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.commands.registerCommand('archify.showSource', async () => {
const preview = previews.activePreview;
if (preview?.isEditor) {
// The diagram is the editor; open the YAML text beside it.
await vscode.commands.executeCommand('vscode.openWith', preview.uri, 'default', vscode.ViewColumn.Beside);
return;
}
if (preview) {
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
@@ -115,7 +129,7 @@ export function activate(context: vscode.ExtensionContext): void {
const runtime = await runtimeFor(context, uri);
let result = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
() => renderToFile(runtime, { ...source, text }, target.fsPath),
() => renderToFile(runtime, source, target.fsPath),
);
if (!result.ok && isEvidenceError(result.error)) {
const choice = await vscode.window.showWarningMessage(
@@ -123,7 +137,7 @@ export function activate(context: vscode.ExtensionContext): void {
{ modal: true, detail: result.error },
'Render Without Source Links',
);
if (choice) result = await renderToFile(runtime, { ...source, text }, target.fsPath, true);
if (choice) result = await renderToFile(runtime, source, target.fsPath, true);
else return;
}
if (!result.ok) {
@@ -156,7 +170,7 @@ export function activate(context: vscode.ExtensionContext): void {
const runtime = await runtimeFor(context, uri);
const result = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
() => renderDiagram(runtime, { ...source, text }, evidenceMode(uri)),
() => renderDiagram(runtime, source, evidenceMode(uri)),
);
if (!result.ok || !result.html) {
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
+40
View File
@@ -1,4 +1,5 @@
import { findNodeAtLocation, Node, parseTree } from 'jsonc-parser';
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
export interface OffsetRange {
start: number;
@@ -37,3 +38,42 @@ export function rangeForPointer(text: string, pointer: string | undefined): Offs
}
return { start: node.offset, end: node.offset + node.length };
}
/** The same mapping for YAML text (`.archify` files). */
export function rangeForPointerYaml(text: string, pointer: string | undefined): OffsetRange {
const doc = parseDocument(text, { keepSourceTokens: false, uniqueKeys: false });
let node: unknown = doc.contents;
let keyRange: [number, number] | undefined;
for (const segment of pointerSegments(pointer ?? '')) {
if (isMap(node)) {
const pair = node.items.find((item) => isScalar(item.key) && String(item.key.value) === String(segment));
if (!pair) break;
keyRange = isScalar(pair.key) && pair.key.range ? [pair.key.range[0], pair.key.range[1]] : undefined;
node = pair.value;
} else if (isSeq(node) && typeof segment === 'number' && segment < node.items.length) {
keyRange = undefined;
node = node.items[segment];
} else {
break;
}
}
if ((isMap(node) || isSeq(node)) && keyRange) return { start: keyRange[0], end: keyRange[1] };
const range = (node as { range?: [number, number, number] } | null)?.range;
if (!range) return { start: 0, end: 0 };
if (isMap(node) || isSeq(node)) return { start: range[0], end: Math.min(range[0] + 1, range[1]) };
return { start: range[0], end: range[1] };
}
export interface SyntaxProblem extends OffsetRange {
message: string;
}
/** YAML syntax errors with their offsets (none for valid YAML). */
export function yamlSyntaxProblems(text: string): SyntaxProblem[] {
const doc = parseDocument(text, { uniqueKeys: false });
return doc.errors.map((error) => ({
start: error.pos[0],
end: Math.max(error.pos[1], error.pos[0] + 1),
message: error.message.split('\n')[0],
}));
}
+43 -10
View File
@@ -7,6 +7,8 @@ import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
export const PREVIEW_VIEW_TYPE = 'archify.preview';
/** Default editor for .archify files: the rendered diagram. */
export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram';
interface PreviewState {
uri: string;
@@ -28,11 +30,15 @@ class DiagramPreview implements vscode.Disposable {
readonly panel: vscode.WebviewPanel,
readonly uri: vscode.Uri,
state?: PreviewState,
/** True when this is the custom editor of the file, not a side preview. */
readonly isEditor = false,
) {
this.hash = state?.hash;
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
panel.title = `Preview ${path.basename(uri.path)}`;
if (!isEditor) {
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
panel.title = `Preview ${path.basename(uri.path)}`;
}
this.disposables.push(
panel.onDidDispose(() => this.dispose()),
@@ -102,7 +108,7 @@ class DiagramPreview implements vscode.Disposable {
let result;
try {
const runtime = await runtimeFor(this.context, this.uri);
result = await renderDiagram(runtime, { ...source, text }, evidenceMode(this.uri));
result = await renderDiagram(runtime, source, evidenceMode(this.uri));
} catch (error) {
result = { ok: false, error: error instanceof Error ? error.message : String(error) };
}
@@ -152,19 +158,30 @@ function firstLines(text: string | undefined, count = 6): string {
return (text ?? '').split('\n').slice(0, count).join('\n');
}
export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState> {
export class PreviewManager
implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState>, vscode.CustomTextEditorProvider
{
/** Side previews, one per source file. */
private readonly previews = new Map<string, DiagramPreview>();
/** Custom-editor tabs of .archify files (several per file are allowed). */
private readonly editors = new Set<DiagramPreview>();
private active: DiagramPreview | undefined;
private readonly disposables: vscode.Disposable[] = [];
constructor(private readonly context: vscode.ExtensionContext) {
this.disposables.push(
vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this),
vscode.window.registerCustomEditorProvider(DIAGRAM_EDITOR_VIEW_TYPE, this, {
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
supportsMultipleEditorsPerDocument: true,
}),
vscode.workspace.onDidChangeTextDocument((event) => {
const preview = this.previews.get(event.document.uri.toString());
if (preview && event.contentChanges.length && this.liveUpdate(preview.uri)) preview.scheduleUpdate();
if (!event.contentChanges.length || !this.liveUpdate(event.document.uri)) return;
for (const preview of this.forUri(event.document.uri)) preview.scheduleUpdate();
}),
vscode.workspace.onDidSaveTextDocument((doc) => {
for (const preview of this.forUri(doc.uri)) void preview.update();
}),
vscode.workspace.onDidSaveTextDocument((doc) => void this.previews.get(doc.uri.toString())?.update()),
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('archify')) this.refreshAll();
}),
@@ -180,6 +197,17 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
return this.active;
}
private forUri(uri: vscode.Uri): DiagramPreview[] {
const key = uri.toString();
return [...this.previews.values(), ...this.editors].filter((preview) => preview.uri.toString() === key);
}
/** Opening a .archify file lands here: the diagram is the editor. */
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
this.track(new DiagramPreview(this.context, panel, document.uri, undefined, true));
}
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
const existing = this.previews.get(uri.toString());
if (existing) {
@@ -205,8 +233,11 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
private track(preview: DiagramPreview): void {
const key = preview.uri.toString();
this.previews.get(key)?.panel.dispose();
this.previews.set(key, preview);
if (preview.isEditor) this.editors.add(preview);
else {
this.previews.get(key)?.panel.dispose();
this.previews.set(key, preview);
}
const onViewState = () => {
if (preview.panel.active) this.active = preview;
else if (this.active === preview) this.active = undefined;
@@ -215,6 +246,7 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
preview.panel.onDidChangeViewState(onViewState);
onViewState();
preview.onDidDispose(() => {
this.editors.delete(preview);
if (this.previews.get(key) === preview) this.previews.delete(key);
if (this.active === preview) {
this.active = undefined;
@@ -224,12 +256,13 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
}
refreshAll(force = false): void {
for (const preview of this.previews.values()) void preview.update(force);
for (const preview of [...this.previews.values(), ...this.editors]) void preview.update(force);
}
dispose(): void {
for (const preview of this.previews.values()) preview.panel.dispose();
this.previews.clear();
this.editors.clear();
for (const disposable of this.disposables.splice(0)) disposable.dispose();
}
}
+4 -4
View File
@@ -144,7 +144,7 @@ async function renderOnce(
*/
export async function renderDiagram(
options: RuntimeOptions,
source: DiagramSource & { text: string },
source: DiagramSource,
evidence: 'fallback' | 'strict',
): Promise<RenderResult> {
const withEvidence = hasSourceEvidence(source.type, source.diagram);
@@ -156,7 +156,7 @@ export async function renderDiagram(
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);
const first = await renderOnce(options, source.type, source.json, withEvidence);
if (first.ok || !withEvidence || evidence !== 'fallback' || !isEvidenceError(first.error)) {
return first;
}
@@ -173,13 +173,13 @@ export async function renderDiagram(
*/
export async function renderToFile(
options: RuntimeOptions,
source: DiagramSource & { text: string },
source: DiagramSource,
outputPath: string,
stripEvidence = false,
): Promise<RenderResult> {
const text = stripEvidence
? JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2)
: source.text;
: source.json;
const withEvidence = !stripEvidence && hasSourceEvidence(source.type, source.diagram);
return withScratch(options, async (dir) => {
const input = path.join(dir, `diagram.${source.type}.json`);