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 7c2624c465
commit d2f52d43ac
18 changed files with 744 additions and 250 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## 0.3.0 — 2026-09-30
- **`.archify` files are YAML.** They are parsed as YAML and handed to the renderer as JSON. Older JSON `.archify` files still open, since JSON is valid YAML.
- **Clicking a `.archify` file opens the diagram:** the new **Archify Diagram** editor is the default for `*.archify`. **Show Source** opens the YAML beside it, and the diagram follows your edits.
- **YAML syntax errors** are reported in the Problems panel. Archify validation problems point at the matching YAML line.
- **Schema completion** for `.archify` works through the YAML extension (`redhat.vscode-yaml`), via `yamlValidation`.
## 0.2.0 — 2026-09-30 ## 0.2.0 — 2026-09-30
- `.archify` files: Archify JSON diagram sources with their own extension. They open as JSON, preview with the same button and keys, report problems, and get completion from the schema of their `diagram_type`. - `.archify` files: Archify JSON diagram sources with their own extension. They open as JSON, preview with the same button and keys, report problems, and get completion from the schema of their `diagram_type`.
+25 -22
View File
@@ -1,6 +1,6 @@
# Archify Diagram Viewer # Archify Diagram Viewer
View [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. Write a diagram in a `.archify` file and see the interactive diagram next to it, updated as you type. Open rendered Archify HTML files in a viewer tab instead of a browser. View [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. Click a `.archify` file (YAML) and the interactive diagram opens straight away; open its source beside it and the diagram follows your edits. Rendered Archify HTML files open in a viewer tab instead of a browser.
![An Archify workflow diagram as shown by the preview](docs/preview.png) ![An Archify workflow diagram as shown by the preview](docs/preview.png)
@@ -10,39 +10,42 @@ This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i
1. Create a file ending in `.archify`, for example `hello.archify`: 1. Create a file ending in `.archify`, for example `hello.archify`:
```json ```yaml
{ schema_version: 1
"schema_version": 1, diagram_type: architecture
"diagram_type": "architecture", meta:
"meta": { "title": "Hello Archify", "output": "hello.html" }, title: Hello Archify
"components": [ output: hello.html
{ "id": "browser", "type": "frontend", "label": "Browser", "pos": [40, 120], "size": [140, 60] }, components:
{ "id": "api", "type": "backend", "label": "API", "pos": [260, 120], "size": [140, 60] }, - { id: browser, type: frontend, label: Browser, pos: [40, 120], size: [140, 60] }
{ "id": "db", "type": "database", "label": "PostgreSQL", "pos": [480, 120], "size": [140, 60] } - { id: api, type: backend, label: API, pos: [260, 120], size: [140, 60] }
], - { id: db, type: database, label: PostgreSQL, pos: [480, 120], size: [140, 60] }
"connections": [ connections:
{ "from": "browser", "to": "api", "label": "HTTPS" }, - { from: browser, to: api, label: HTTPS }
{ "from": "api", "to": "db", "label": "SQL" } - { from: api, to: db, label: SQL }
]
}
``` ```
2. Click the preview button in the editor title, or press <kbd>Cmd/Ctrl+K V</kbd>. 2. Click it in the Explorer: the diagram opens.
3. To edit, use **Show Source** in the editor title. The YAML opens beside the diagram, which re-renders as you type.
The same file is in [examples/hello.archify](examples/hello.archify). The same file is in [examples/hello.archify](examples/hello.archify).
## The `.archify` format ## The `.archify` format
A `.archify` file is an Archify JSON diagram source, the same JSON the Archify CLI renders. `diagram_type` selects the kind of diagram: `architecture`, `workflow`, `sequence`, `dataflow` or `lifecycle`. VS Code treats `.archify` as JSON, so you get syntax highlighting, formatting, and completion and hover from the schema for that `diagram_type`. A `.archify` file is an Archify diagram source written in **YAML**. It has the same fields as the JSON the Archify CLI renders, and plain JSON content still works, since JSON is valid YAML. `diagram_type` selects the kind of diagram: `architecture`, `workflow`, `sequence`, `dataflow` or `lifecycle`.
Archify's own naming, `name.architecture.json`, `name.workflow.json` and so on, works too. - **Opening:** the **Archify Diagram** editor is the default for `.archify`. Use *Reopen Editor With… → Text Editor* to edit the YAML full-screen.
- **Completion:** with the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) (`redhat.vscode-yaml`) installed, `.archify` files get completion and hover from Archify's schemas. The schema follows `diagram_type`.
Archify's own JSON naming, `name.architecture.json`, `name.workflow.json` and so on, works too. Those open as text with a preview button, and get schema help from VS Code's built-in JSON support.
## Features ## Features
- **Live preview**: for `.archify` files, Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, <kbd>Cmd/Ctrl+K V</kbd> (to the side) or <kbd>Cmd/Ctrl+Shift+V</kbd>. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders. - **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live.
- **Live preview**: for Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, <kbd>Cmd/Ctrl+K V</kbd> (to the side) or <kbd>Cmd/Ctrl+Shift+V</kbd>. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders.
- **HTML viewer**: right-click a rendered `.html` file and choose **Open in Archify Viewer**, or use *Reopen Editor With… → Archify Diagram Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk. - **HTML viewer**: right-click a rendered `.html` file and choose **Open in Archify Viewer**, or use *Reopen Editor With… → Archify Diagram Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk.
- **Problems**: `archify validate` runs on the source and reports schema, layout and evidence problems at the matching JSON location. - **Problems**: YAML syntax errors, plus `archify validate`'s schema, layout and evidence problems, appear at the matching line of the source.
- **Schema help**: completion and hover documentation come from Archify's own JSON schemas. In `.archify` files, the schema follows `diagram_type`. - **Schema help**: completion and hover come from Archify's own schemas: built in for the JSON files, and through the YAML extension for `.archify` files.
- **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead. - **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead.
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog. - **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
- **Commands**: *Archify: Render to HTML File…*, *Archify: Open in Browser*, *Archify: Refresh Preview*, *Archify: Show Source*. - **Commands**: *Archify: Render to HTML File…*, *Archify: Open in Browser*, *Archify: Refresh Preview*, *Archify: Show Source*.
+28 -17
View File
@@ -1,17 +1,28 @@
{ schema_version: 1
"schema_version": 1, diagram_type: architecture
"diagram_type": "architecture", meta:
"meta": { title: Hello Archify
"title": "Hello Archify", output: hello.html
"output": "hello.html" components:
}, - id: browser
"components": [ type: frontend
{ "id": "browser", "type": "frontend", "label": "Browser", "pos": [40, 120], "size": [140, 60] }, label: Browser
{ "id": "api", "type": "backend", "label": "API", "pos": [260, 120], "size": [140, 60] }, pos: [40, 120]
{ "id": "db", "type": "database", "label": "PostgreSQL", "pos": [480, 120], "size": [140, 60] } size: [140, 60]
], - id: api
"connections": [ type: backend
{ "from": "browser", "to": "api", "label": "HTTPS" }, label: API
{ "from": "api", "to": "db", "label": "SQL" } pos: [260, 120]
] size: [140, 60]
} - id: db
type: database
label: PostgreSQL
pos: [480, 120]
size: [140, 60]
connections:
- from: browser
to: api
label: HTTPS
- from: api
to: db
label: SQL
+18
View File
@@ -8,6 +8,9 @@
"name": "archify-vscode-ext", "name": "archify-vscode-ext",
"version": "0.2.0", "version": "0.2.0",
"license": "MIT", "license": "MIT",
"dependencies": {
"yaml": "^2.9.1"
},
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/vscode": "~1.100.0", "@types/vscode": "~1.100.0",
@@ -4684,6 +4687,21 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yauzl": { "node_modules/yauzl": {
"version": "3.4.0", "version": "3.4.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
+28 -13
View File
@@ -1,8 +1,8 @@
{ {
"name": "archify-vscode-ext", "name": "archify-vscode-ext",
"displayName": "Archify Diagram Viewer", "displayName": "Archify Diagram Viewer",
"description": "Visualize Archify diagrams: preview .archify files (and *.architecture.json-style sources) live, and open rendered Archify HTML inside VS Code.", "description": "Visualize Archify diagrams: .archify files (YAML) open straight into the interactive diagram; also previews Archify JSON sources and rendered Archify HTML inside VS Code.",
"version": "0.2.0", "version": "0.3.0",
"publisher": "root-at-skic", "publisher": "root-at-skic",
"license": "MIT", "license": "MIT",
"icon": "media/icon.png", "icon": "media/icon.png",
@@ -97,19 +97,19 @@
}, },
{ {
"command": "archify.renderToHtml", "command": "archify.renderToHtml",
"when": "archify.isDiagramSource || archifyPreviewFocus" "when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram'"
}, },
{ {
"command": "archify.openInBrowser", "command": "archify.openInBrowser",
"when": "archify.isDiagramSource || archify.isArchifyHtml || archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'" "when": "archify.isDiagramSource || archify.isArchifyHtml || archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'"
}, },
{ {
"command": "archify.refreshPreview", "command": "archify.refreshPreview",
"when": "archifyPreviewFocus" "when": "archifyPreviewFocus || activeCustomEditorId == 'archify.diagram'"
}, },
{ {
"command": "archify.showSource", "command": "archify.showSource",
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'" "when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'"
} }
], ],
"editor/title": [ "editor/title": [
@@ -130,7 +130,7 @@
}, },
{ {
"command": "archify.showSource", "command": "archify.showSource",
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'", "when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'",
"group": "navigation" "group": "navigation"
}, },
{ {
@@ -184,6 +184,16 @@
} }
], ],
"customEditors": [ "customEditors": [
{
"viewType": "archify.diagram",
"displayName": "Archify Diagram",
"selector": [
{
"filenamePattern": "*.archify"
}
],
"priority": "default"
},
{ {
"viewType": "archify.htmlViewer", "viewType": "archify.htmlViewer",
"displayName": "Archify Diagram Viewer", "displayName": "Archify Diagram Viewer",
@@ -196,10 +206,6 @@
} }
], ],
"jsonValidation": [ "jsonValidation": [
{
"fileMatch": "*.archify",
"url": "./schemas/archify.schema.json"
},
{ {
"fileMatch": "*.architecture.json", "fileMatch": "*.architecture.json",
"url": "./schemas/architecture.schema.json" "url": "./schemas/architecture.schema.json"
@@ -299,11 +305,17 @@
}, },
"languages": [ "languages": [
{ {
"id": "json", "id": "yaml",
"extensions": [ "extensions": [
".archify" ".archify"
] ]
} }
],
"yamlValidation": [
{
"fileMatch": "*.archify",
"url": "./schemas/archify.schema.json"
}
] ]
}, },
"scripts": { "scripts": {
@@ -329,5 +341,8 @@
"bugs": { "bugs": {
"url": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext/issues" "url": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext/issues"
}, },
"homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme" "homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme",
"dependencies": {
"yaml": "^2.9.1"
}
} }
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env node
// Rewrites .archify files from JSON to YAML in place (JSON input is also
// valid YAML, so already-converted files are simply re-formatted).
// Usage: node scripts/to-yaml.mjs <file.archify>...
import fs from 'node:fs';
import { Document, isSeq, parse, visit } from 'yaml';
export function archifyYaml(value) {
const doc = new Document(value);
// Short all-scalar lists such as pos/size/viewBox stay on one line.
visit(doc, {
Seq(_, node) {
if (isSeq(node) && node.items.length <= 4 && node.items.every((item) => typeof item.value === 'number')) node.flow = true;
},
});
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
}
for (const file of process.argv.slice(2)) {
const value = parse(fs.readFileSync(file, 'utf8'));
fs.writeFileSync(file, archifyYaml(value));
console.log(`yaml: ${file}`);
}
+30 -9
View File
@@ -1,4 +1,5 @@
import { parse, ParseError } from 'jsonc-parser'; import { parse, ParseError } from 'jsonc-parser';
import { parse as parseYaml } from 'yaml';
export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const; export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const;
export type DiagramType = (typeof DIAGRAM_TYPES)[number]; export type DiagramType = (typeof DIAGRAM_TYPES)[number];
@@ -44,24 +45,44 @@ export function diagramBaseName(fileName: string): string {
export interface DiagramSource { export interface DiagramSource {
type: DiagramType; type: DiagramType;
diagram: Record<string, unknown>; 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 * Returns the diagram when the text declares an Archify `diagram_type`.
* `diagram_type`. Falls back to the file name for documents that do not * Falls back to the file name for documents that do not parse yet, so a
* parse yet, so a half-typed source still counts as a diagram. * half-typed source still counts as a diagram.
*/ */
export function readDiagramSource(text: string, fileName = ''): DiagramSource | undefined { export function readDiagramSource(text: string, fileName = ''): DiagramSource | undefined {
if (text.length > MAX_SNIFF_BYTES) return undefined; if (text.length > MAX_SNIFF_BYTES) return undefined;
const errors: ParseError[] = []; const format = isArchifyFile(fileName) ? 'yaml' : 'json';
const value = parse(text, errors, { allowTrailingComma: true }); const value = parseDiagramText(text, fileName) as Record<string, unknown> | undefined;
if (value && typeof value === 'object' && !Array.isArray(value) && isDiagramType(value.diagram_type)) { const isObject = value !== null && typeof value === 'object' && !Array.isArray(value);
return { type: value.diagram_type, diagram: 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); const byName = typeFromFilename(fileName);
if (byName) { if (byName) {
const diagram = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const diagram = isObject ? value : {};
return { type: byName, diagram }; return { type: byName, diagram, format, json: json(diagram) };
} }
return undefined; return undefined;
} }
+37 -11
View File
@@ -1,7 +1,7 @@
import { parse, ParseError } from 'jsonc-parser'; import { parse, ParseError } from 'jsonc-parser';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { readDiagramSource } from './detect'; import { isArchifyFile, readDiagramSource } from './detect';
import { rangeForPointer } from './jsonPointer'; import { rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer'; import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer';
import { evidenceMode, log, runtimeFor } from './runtime'; import { evidenceMode, log, runtimeFor } from './runtime';
@@ -61,7 +61,7 @@ export class DiagnosticsController implements vscode.Disposable {
} }
private schedule(doc: vscode.TextDocument, delay: number): void { 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(); const key = doc.uri.toString();
clearTimeout(this.timers.get(key)); clearTimeout(this.timers.get(key));
this.timers.set(key, setTimeout(() => void this.validate(doc), delay)); 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); this.sequence.set(key, seq);
const text = doc.getText(); const text = doc.getText();
const source = readDiagramSource(text, doc.fileName); if (!this.enabled(doc.uri) || doc.isClosed) {
if (!source || !this.enabled(doc.uri) || doc.isClosed) {
this.collection.delete(doc.uri); this.collection.delete(doc.uri);
return; return;
} }
// Leave syntax errors to the JSON language service. if (isArchifyFile(doc.fileName)) {
const syntax: ParseError[] = []; // Nothing else reports YAML syntax errors in .archify files, and a file
parse(text, syntax, { allowTrailingComma: false }); // that does not parse has no diagram to validate yet.
if (syntax.length) { 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); this.collection.delete(doc.uri);
return; 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 { try {
const runtime = await runtimeFor(this.context, doc.uri); 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 (seq !== this.sequence.get(key) || doc.isClosed) return;
if (result.crash) { if (result.crash) {
@@ -107,7 +133,7 @@ export class DiagnosticsController implements vscode.Disposable {
this.collection.set( this.collection.set(
doc.uri, doc.uri,
result.diagnostics.map((item) => { 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 range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
const fix = item.supportedFixes?.[0]; const fix = item.supportedFixes?.[0];
const diagnostic = new vscode.Diagnostic( const diagnostic = new vscode.Diagnostic(
+20 -6
View File
@@ -1,10 +1,10 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import * as vscode from 'vscode'; 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 { DiagnosticsController } from './diagnostics';
import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer'; 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 { isEvidenceError, renderDiagram, renderToFile } from './renderer';
import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime'; import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime';
@@ -36,7 +36,9 @@ export function activate(context: vscode.ExtensionContext): void {
const updateContext = () => { const updateContext = () => {
const doc = vscode.window.activeTextEditor?.document; const doc = vscode.window.activeTextEditor?.document;
const diagram = Boolean( 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())); const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram); void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
@@ -64,6 +66,7 @@ export function activate(context: vscode.ExtensionContext): void {
const uri = sourceUri(arg); const uri = sourceUri(arg);
if (!uri) return; if (!uri) return;
if (isHtmlUri(uri)) return openHtmlViewer(uri); 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); 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); const uri = sourceUri(arg);
if (!uri) return; if (!uri) return;
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside); 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); previews.show(uri, vscode.ViewColumn.Beside, true);
}), }),
@@ -83,6 +92,11 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.commands.registerCommand('archify.showSource', async () => { vscode.commands.registerCommand('archify.showSource', async () => {
const preview = previews.activePreview; 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) { if (preview) {
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One; const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
await vscode.window.showTextDocument(preview.uri, { viewColumn: column }); 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); const runtime = await runtimeFor(context, uri);
let result = await vscode.window.withProgress( let result = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' }, { location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
() => renderToFile(runtime, { ...source, text }, target.fsPath), () => renderToFile(runtime, source, target.fsPath),
); );
if (!result.ok && isEvidenceError(result.error)) { if (!result.ok && isEvidenceError(result.error)) {
const choice = await vscode.window.showWarningMessage( const choice = await vscode.window.showWarningMessage(
@@ -123,7 +137,7 @@ export function activate(context: vscode.ExtensionContext): void {
{ modal: true, detail: result.error }, { modal: true, detail: result.error },
'Render Without Source Links', '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; else return;
} }
if (!result.ok) { if (!result.ok) {
@@ -156,7 +170,7 @@ export function activate(context: vscode.ExtensionContext): void {
const runtime = await runtimeFor(context, uri); const runtime = await runtimeFor(context, uri);
const result = await vscode.window.withProgress( const result = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' }, { location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
() => renderDiagram(runtime, { ...source, text }, evidenceMode(uri)), () => renderDiagram(runtime, source, evidenceMode(uri)),
); );
if (!result.ok || !result.html) { if (!result.ok || !result.html) {
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error }); 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 { findNodeAtLocation, Node, parseTree } from 'jsonc-parser';
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
export interface OffsetRange { export interface OffsetRange {
start: number; start: number;
@@ -37,3 +38,42 @@ export function rangeForPointer(text: string, pointer: string | undefined): Offs
} }
return { start: node.offset, end: node.offset + node.length }; 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'; import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
export const PREVIEW_VIEW_TYPE = 'archify.preview'; 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 { interface PreviewState {
uri: string; uri: string;
@@ -28,11 +30,15 @@ class DiagramPreview implements vscode.Disposable {
readonly panel: vscode.WebviewPanel, readonly panel: vscode.WebviewPanel,
readonly uri: vscode.Uri, readonly uri: vscode.Uri,
state?: PreviewState, state?: PreviewState,
/** True when this is the custom editor of the file, not a side preview. */
readonly isEditor = false,
) { ) {
this.hash = state?.hash; this.hash = state?.hash;
panel.webview.options = { enableScripts: true, localResourceRoots: [] }; panel.webview.options = { enableScripts: true, localResourceRoots: [] };
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg'); if (!isEditor) {
panel.title = `Preview ${path.basename(uri.path)}`; panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
panel.title = `Preview ${path.basename(uri.path)}`;
}
this.disposables.push( this.disposables.push(
panel.onDidDispose(() => this.dispose()), panel.onDidDispose(() => this.dispose()),
@@ -102,7 +108,7 @@ class DiagramPreview implements vscode.Disposable {
let result; let result;
try { try {
const runtime = await runtimeFor(this.context, this.uri); 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) { } catch (error) {
result = { ok: false, error: error instanceof Error ? error.message : String(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'); 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>(); 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 active: DiagramPreview | undefined;
private readonly disposables: vscode.Disposable[] = []; private readonly disposables: vscode.Disposable[] = [];
constructor(private readonly context: vscode.ExtensionContext) { constructor(private readonly context: vscode.ExtensionContext) {
this.disposables.push( this.disposables.push(
vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this), 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) => { vscode.workspace.onDidChangeTextDocument((event) => {
const preview = this.previews.get(event.document.uri.toString()); if (!event.contentChanges.length || !this.liveUpdate(event.document.uri)) return;
if (preview && event.contentChanges.length && this.liveUpdate(preview.uri)) preview.scheduleUpdate(); 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) => { vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('archify')) this.refreshAll(); if (event.affectsConfiguration('archify')) this.refreshAll();
}), }),
@@ -180,6 +197,17 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
return this.active; 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 { show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
const existing = this.previews.get(uri.toString()); const existing = this.previews.get(uri.toString());
if (existing) { if (existing) {
@@ -205,8 +233,11 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
private track(preview: DiagramPreview): void { private track(preview: DiagramPreview): void {
const key = preview.uri.toString(); const key = preview.uri.toString();
this.previews.get(key)?.panel.dispose(); if (preview.isEditor) this.editors.add(preview);
this.previews.set(key, preview); else {
this.previews.get(key)?.panel.dispose();
this.previews.set(key, preview);
}
const onViewState = () => { const onViewState = () => {
if (preview.panel.active) this.active = preview; if (preview.panel.active) this.active = preview;
else if (this.active === preview) this.active = undefined; else if (this.active === preview) this.active = undefined;
@@ -215,6 +246,7 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
preview.panel.onDidChangeViewState(onViewState); preview.panel.onDidChangeViewState(onViewState);
onViewState(); onViewState();
preview.onDidDispose(() => { preview.onDidDispose(() => {
this.editors.delete(preview);
if (this.previews.get(key) === preview) this.previews.delete(key); if (this.previews.get(key) === preview) this.previews.delete(key);
if (this.active === preview) { if (this.active === preview) {
this.active = undefined; this.active = undefined;
@@ -224,12 +256,13 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
} }
refreshAll(force = false): void { 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 { dispose(): void {
for (const preview of this.previews.values()) preview.panel.dispose(); for (const preview of this.previews.values()) preview.panel.dispose();
this.previews.clear(); this.previews.clear();
this.editors.clear();
for (const disposable of this.disposables.splice(0)) disposable.dispose(); 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( export async function renderDiagram(
options: RuntimeOptions, options: RuntimeOptions,
source: DiagramSource & { text: string }, source: DiagramSource,
evidence: 'fallback' | 'strict', evidence: 'fallback' | 'strict',
): Promise<RenderResult> { ): Promise<RenderResult> {
const withEvidence = hasSourceEvidence(source.type, source.diagram); 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.', 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)) { if (first.ok || !withEvidence || evidence !== 'fallback' || !isEvidenceError(first.error)) {
return first; return first;
} }
@@ -173,13 +173,13 @@ export async function renderDiagram(
*/ */
export async function renderToFile( export async function renderToFile(
options: RuntimeOptions, options: RuntimeOptions,
source: DiagramSource & { text: string }, source: DiagramSource,
outputPath: string, outputPath: string,
stripEvidence = false, stripEvidence = false,
): Promise<RenderResult> { ): Promise<RenderResult> {
const text = stripEvidence const text = stripEvidence
? JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2) ? JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2)
: source.text; : source.json;
const withEvidence = !stripEvidence && hasSourceEvidence(source.type, source.diagram); const withEvidence = !stripEvidence && hasSourceEvidence(source.type, source.diagram);
return withScratch(options, async (dir) => { return withScratch(options, async (dir) => {
const input = path.join(dir, `diagram.${source.type}.json`); const input = path.join(dir, `diagram.${source.type}.json`);
+27 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { test } from 'node:test'; import { test } from 'node:test';
import { hasSourceEvidence, isArchifyHtml, readDiagramSource, stripSourceEvidence, typeFromFilename } from '../src/detect'; import { hasSourceEvidence, isArchifyHtml, readDiagramSource, stripSourceEvidence, typeFromFilename } from '../src/detect';
import { pointerSegments, rangeForPointer } from '../src/jsonPointer'; import { pointerSegments, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from '../src/jsonPointer';
test('reads diagram_type from content', () => { test('reads diagram_type from content', () => {
const source = readDiagramSource('{"schema_version":1,"diagram_type":"sequence","meta":{}}', 'plain.json'); const source = readDiagramSource('{"schema_version":1,"diagram_type":"sequence","meta":{}}', 'plain.json');
@@ -58,4 +58,30 @@ test('.archify files are diagram sources typed by their content', async () => {
assert.equal(diagramBaseName('/w/web.architecture.json'), 'web.architecture'); assert.equal(diagramBaseName('/w/web.architecture.json'), 'web.architecture');
assert.equal(readDiagramSource('{"diagram_type":"lifecycle"}', 'run.archify')?.type, 'lifecycle'); assert.equal(readDiagramSource('{"diagram_type":"lifecycle"}', 'run.archify')?.type, 'lifecycle');
assert.equal(readDiagramSource('{"title":"no type yet"}', 'run.archify'), undefined); assert.equal(readDiagramSource('{"title":"no type yet"}', 'run.archify'), undefined);
const yaml = readDiagramSource('schema_version: 1\ndiagram_type: sequence\nmeta:\n title: T\n', 'flow.archify');
assert.equal(yaml?.type, 'sequence');
assert.equal(yaml?.format, 'yaml');
assert.deepEqual(JSON.parse(yaml!.json), { schema_version: 1, diagram_type: 'sequence', meta: { title: 'T' } });
assert.equal(readDiagramSource('diagram_type: [unclosed', 'bad.archify'), undefined);
});
test('yaml pointer ranges match the json behaviour', () => {
const text = 'meta:\n title: x\ncomponents:\n - id: a\n label: A\n';
const meta = rangeForPointerYaml(text, '/meta');
assert.equal(text.slice(meta.start, meta.end), 'meta');
const title = rangeForPointerYaml(text, '/meta/title');
assert.equal(text.slice(title.start, title.end), 'x');
const item = rangeForPointerYaml(text, '/components/0');
assert.equal(text.slice(item.start, item.end), 'i', 'first character of the item');
const label = rangeForPointerYaml(text, '/components/0/label');
assert.equal(text.slice(label.start, label.end), 'A');
const missing = rangeForPointerYaml(text, '/components/0/type');
assert.equal(text.slice(missing.start, missing.end), 'i', 'falls back to the nearest existing ancestor');
});
test('yaml syntax problems carry offsets', () => {
assert.deepEqual(yamlSyntaxProblems('a: 1\nb: 2\n'), []);
const problems = yamlSyntaxProblems('a: [1, 2\nb: 3\n');
assert.ok(problems.length > 0);
assert.ok(problems[0].start >= 0 && problems[0].end > problems[0].start);
}); });
+165 -77
View File
@@ -1,77 +1,165 @@
{ schema_version: 1
"schema_version": 1, diagram_type: sequence
"diagram_type": "sequence", meta:
"meta": { title: Cache Miss Request Sequence
"title": "Cache Miss Request Sequence", output: examples/sequence-cache-miss-request.html
"output": "examples/sequence-cache-miss-request.html", viewBox: [1080, 580]
"viewBox": [1080, 580], column_fit: spread
"column_fit": "spread", animation: trace
"animation": "trace", quality_profile: showcase
"quality_profile": "showcase" participants:
}, - id: user
"participants": [ type: external
{ "id": "user", "type": "external", "label": "User", "sublabel": "browser session" }, label: User
{ "id": "web", "type": "frontend", "label": "Web App", "sublabel": "React UI" }, sublabel: browser session
{ "id": "api", "type": "backend", "label": "API", "sublabel": "request handler" }, - id: web
{ "id": "auth", "type": "security", "label": "Auth", "sublabel": "JWT verify" }, type: frontend
{ "id": "redis", "type": "database", "label": "Redis", "sublabel": "cache" }, label: Web App
{ "id": "db", "type": "database", "label": "Postgres", "sublabel": "source of truth" }, sublabel: React UI
{ "id": "trace", "type": "messagebus", "label": "Trace", "sublabel": "async event" } - id: api
], type: backend
"segments": [ label: API
{ "from": 150, "to": 250, "label": "Request" }, sublabel: request handler
{ "from": 260, "to": 370, "label": "Fallback" }, - id: auth
{ "from": 380, "to": 480, "label": "Response + trace" } type: security
], label: Auth
"messages": [ sublabel: JWT verify
{ "id": "open-page", "from": "user", "to": "web", "y": 160, "label": "open page", "variant": "default" }, - id: redis
{ "id": "dashboard-request", "from": "web", "to": "api", "y": 185, "label": "GET /dashboard", "variant": "emphasis" }, type: database
{ "id": "verify-jwt", "from": "api", "to": "auth", "y": 210, "label": "verify JWT", "variant": "security" }, label: Redis
{ "id": "auth-claims", "from": "auth", "to": "api", "y": 238, "label": "claims ok", "variant": "return" }, sublabel: cache
{ "id": "cache-read", "from": "api", "to": "redis", "y": 270, "label": "read cache", "variant": "default" }, - id: db
{ "id": "cache-miss", "from": "redis", "to": "api", "y": 298, "label": "miss", "variant": "return" }, type: database
{ "id": "profile-query", "from": "api", "to": "db", "y": 330, "label": "query profile + metrics", "variant": "emphasis" }, label: Postgres
{ "id": "profile-rows", "from": "db", "to": "api", "y": 358, "label": "rows", "variant": "return" }, sublabel: source of truth
{ "id": "cache-write", "from": "api", "to": "redis", "y": 390, "label": "set cache", "variant": "dashed" }, - id: trace
{ "id": "trace-emit", "from": "api", "to": "trace", "y": 418, "label": "emit trace", "variant": "dashed" }, type: messagebus
{ "id": "dashboard-response", "from": "api", "to": "web", "y": 443, "label": "200 JSON", "variant": "return" }, label: Trace
{ "id": "page-render", "from": "web", "to": "user", "y": 468, "label": "render", "variant": "return" } sublabel: async event
], segments:
"activations": [ - from: 150
{ "participant": "web", "from": 180, "to": 474, "type": "frontend" }, to: 250
{ "participant": "api", "from": 185, "to": 450, "type": "backend" }, label: Request
{ "participant": "auth", "from": 205, "to": 244, "type": "security" }, - from: 260
{ "participant": "redis", "from": 265, "to": 304, "type": "database" }, to: 370
{ "participant": "db", "from": 325, "to": 364, "type": "database" }, label: Fallback
{ "participant": "trace", "from": 413, "to": 449, "type": "messagebus" } - from: 380
], to: 480
"cards": [ label: Response + trace
{ messages:
"dot": "emerald", - id: open-page
"title": "Happy Path", from: user
"items": [ to: web
"The main request is Web App -> API -> data source -> response", y: 160
"Return messages are quieter than forward calls", label: open page
"Activation bars make ownership duration visible" variant: default
] - id: dashboard-request
}, from: web
{ to: api
"dot": "rose", y: 185
"title": "Policy + Fallback", label: GET /dashboard
"items": [ variant: emphasis
"JWT verification is colored as a security interaction", - id: verify-jwt
"Cache miss is visible without overpowering the main path", from: api
"Database access only appears after cache fallback" to: auth
] y: 210
}, label: verify JWT
{ variant: security
"dot": "orange", - id: auth-claims
"title": "Async Trace", from: auth
"items": [ to: api
"Trace emission is dashed and secondary", y: 238
"It does not block the response path", label: claims ok
"The diagram separates user-facing latency from observability" variant: return
] - id: cache-read
} from: api
] to: redis
} y: 270
label: read cache
variant: default
- id: cache-miss
from: redis
to: api
y: 298
label: miss
variant: return
- id: profile-query
from: api
to: db
y: 330
label: query profile + metrics
variant: emphasis
- id: profile-rows
from: db
to: api
y: 358
label: rows
variant: return
- id: cache-write
from: api
to: redis
y: 390
label: set cache
variant: dashed
- id: trace-emit
from: api
to: trace
y: 418
label: emit trace
variant: dashed
- id: dashboard-response
from: api
to: web
y: 443
label: 200 JSON
variant: return
- id: page-render
from: web
to: user
y: 468
label: render
variant: return
activations:
- participant: web
from: 180
to: 474
type: frontend
- participant: api
from: 185
to: 450
type: backend
- participant: auth
from: 205
to: 244
type: security
- participant: redis
from: 265
to: 304
type: database
- participant: db
from: 325
to: 364
type: database
- participant: trace
from: 413
to: 449
type: messagebus
cards:
- dot: emerald
title: Happy Path
items:
- The main request is Web App -> API -> data source -> response
- Return messages are quieter than forward calls
- Activation bars make ownership duration visible
- dot: rose
title: Policy + Fallback
items:
- JWT verification is colored as a security interaction
- Cache miss is visible without overpowering the main path
- Database access only appears after cache fallback
- dot: orange
title: Async Trace
items:
- Trace emission is dashed and secondary
- It does not block the response path
- The diagram separates user-facing latency from observability
+212 -51
View File
@@ -1,51 +1,212 @@
{ schema_version: 1
"schema_version": 1, diagram_type: architecture
"diagram_type": "architecture", meta:
"meta": { title: Production Deployment Ownership
"title": "Production Deployment Ownership", output: examples/production-deployment.html
"output": "examples/production-deployment.html", visual_preset: blueprint
"visual_preset": "blueprint", animation: trace
"animation": "trace", quality_profile: showcase
"quality_profile": "showcase", engineering_profile: deployment-ownership
"engineering_profile": "deployment-ownership" components:
}, - id: clients
"components": [ type: external
{ "id": "clients", "type": "external", "label": "Customers", "sublabel": "web + mobile", "pos": [38, 300], "size": [122, 60] }, label: Customers
{ "id": "edge", "type": "cloud", "label": "Global Edge", "sublabel": "CDN + WAF", "pos": [230, 300], "size": [126, 60], "tag": "edge team" }, sublabel: web + mobile
{ "id": "gateway", "type": "security", "label": "API Gateway", "sublabel": "public :443", "pos": [430, 300], "size": [128, 60], "tag": "platform" }, pos: [38, 300]
{ "id": "api_a", "type": "backend", "label": "API Pods / AZ-a", "sublabel": "private subnet", "pos": [630, 195], "size": [136, 62], "tag": "app team" }, size: [122, 60]
{ "id": "api_b", "type": "backend", "label": "API Pods / AZ-b", "sublabel": "private subnet", "pos": [630, 405], "size": [136, 62], "tag": "app team" }, - id: edge
{ "id": "redis", "type": "database", "label": "Redis", "sublabel": "multi-AZ cache", "pos": [840, 195], "size": [126, 62], "tag": "platform" }, type: cloud
{ "id": "postgres", "type": "database", "label": "PostgreSQL", "sublabel": "primary / encrypted", "pos": [840, 405], "size": [126, 62], "tag": "data team" }, label: Global Edge
{ "id": "events", "type": "messagebus", "label": "Event Bus", "sublabel": "orders.v1", "pos": [1040, 300], "size": [126, 60], "tag": "platform" }, sublabel: CDN + WAF
{ "id": "worker", "type": "backend", "label": "Workers", "sublabel": "private workload", "pos": [1190, 300], "size": [126, 60], "tag": "app team" }, pos: [230, 300]
{ "id": "replica", "type": "database", "label": "DR Replica", "sublabel": "eu-west-1", "pos": [1040, 578], "size": [126, 62], "tag": "data team" }, size: [126, 60]
{ "id": "audit", "type": "cloud", "label": "Audit Archive", "sublabel": "immutable objects", "pos": [1190, 450], "size": [126, 62], "tag": "security" }, tag: edge team
{ "id": "observability", "type": "external", "label": "Observability", "sublabel": "metrics + traces", "pos": [1190, 85], "size": [126, 62], "tag": "SRE" } - id: gateway
], type: security
"boundaries": [ label: API Gateway
{ "kind": "region", "label": "AWS us-east-1 / production", "wraps": ["edge", "gateway", "api_a", "api_b", "redis", "postgres", "events", "worker", "audit"], "pad": 20 }, sublabel: public :443
{ "kind": "security-group", "label": "private application network", "wraps": ["api_a", "api_b", "redis", "postgres", "events", "worker"], "pad": 14 }, pos: [430, 300]
{ "kind": "region", "label": "AWS eu-west-1 / disaster recovery", "wraps": ["replica"] }, size: [128, 60]
{ "kind": "security-group", "label": "DR private subnet", "wraps": ["replica"], "pad": 14 } tag: platform
], - id: api_a
"connections": [ type: backend
{ "from": "clients", "to": "edge", "label": "HTTPS", "variant": "emphasis" }, label: API Pods / AZ-a
{ "from": "edge", "to": "gateway", "label": "mTLS", "variant": "security" }, sublabel: private subnet
{ "from": "gateway", "to": "api_a", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 275] }, pos: [630, 195]
{ "from": "gateway", "to": "api_b", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 385] }, size: [136, 62]
{ "from": "api_a", "to": "redis", "label": "cache", "route": "straight" }, tag: app team
{ "from": "api_b", "to": "postgres", "label": "SQL", "route": "straight" }, - id: api_b
{ "from": "api_a", "to": "events", "label": "publish", "variant": "dashed", "fromSide": "top", "toSide": "top", "via": [[698, 170], [1103, 170]] }, type: backend
{ "from": "api_b", "to": "events", "variant": "dashed", "fromSide": "top", "toSide": "bottom", "via": [[698, 380], [1103, 380]] }, label: API Pods / AZ-b
{ "from": "events", "to": "worker", "variant": "emphasis" }, sublabel: private subnet
{ "from": "postgres", "to": "replica", "label": "cross-region WAL", "variant": "security", "route": "orthogonal-v", "labelAt": [1003, 529] }, pos: [630, 405]
{ "from": "worker", "to": "audit", "label": "evidence", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "labelDy": 58 }, size: [136, 62]
{ "from": "worker", "to": "observability", "label": "OTLP", "variant": "dashed", "route": "orthogonal-v" } tag: app team
], - id: redis
"cards": [ type: database
{ "dot": "cyan", "title": "Runtime Ownership", "items": ["Platform owns the edge, gateway, cache, and event bus", "Application teams own API pods and workers", "Data owns primary and disaster-recovery state"] }, label: Redis
{ "dot": "rose", "title": "Named Crossings", "items": ["Public HTTPS terminates at the managed edge", "mTLS crosses into the application network", "Cross-region WAL is explicit and encrypted"] }, sublabel: multi-AZ cache
{ "dot": "emerald", "title": "Operational Evidence", "items": ["Workers emit traces to SRE-owned observability", "Audit evidence lands in immutable storage", "Unknown placement should remain marked, never invented"] } pos: [840, 195]
] size: [126, 62]
} tag: platform
- id: postgres
type: database
label: PostgreSQL
sublabel: primary / encrypted
pos: [840, 405]
size: [126, 62]
tag: data team
- id: events
type: messagebus
label: Event Bus
sublabel: orders.v1
pos: [1040, 300]
size: [126, 60]
tag: platform
- id: worker
type: backend
label: Workers
sublabel: private workload
pos: [1190, 300]
size: [126, 60]
tag: app team
- id: replica
type: database
label: DR Replica
sublabel: eu-west-1
pos: [1040, 578]
size: [126, 62]
tag: data team
- id: audit
type: cloud
label: Audit Archive
sublabel: immutable objects
pos: [1190, 450]
size: [126, 62]
tag: security
- id: observability
type: external
label: Observability
sublabel: metrics + traces
pos: [1190, 85]
size: [126, 62]
tag: SRE
boundaries:
- kind: region
label: AWS us-east-1 / production
wraps:
- edge
- gateway
- api_a
- api_b
- redis
- postgres
- events
- worker
- audit
pad: 20
- kind: security-group
label: private application network
wraps:
- api_a
- api_b
- redis
- postgres
- events
- worker
pad: 14
- kind: region
label: AWS eu-west-1 / disaster recovery
wraps:
- replica
- kind: security-group
label: DR private subnet
wraps:
- replica
pad: 14
connections:
- from: clients
to: edge
label: HTTPS
variant: emphasis
- from: edge
to: gateway
label: mTLS
variant: security
- from: gateway
to: api_a
label: VPC route
variant: emphasis
route: orthogonal-h
labelAt: [594, 275]
- from: gateway
to: api_b
label: VPC route
variant: emphasis
route: orthogonal-h
labelAt: [594, 385]
- from: api_a
to: redis
label: cache
route: straight
- from: api_b
to: postgres
label: SQL
route: straight
- from: api_a
to: events
label: publish
variant: dashed
fromSide: top
toSide: top
via:
- [698, 170]
- [1103, 170]
- from: api_b
to: events
variant: dashed
fromSide: top
toSide: bottom
via:
- [698, 380]
- [1103, 380]
- from: events
to: worker
variant: emphasis
- from: postgres
to: replica
label: cross-region WAL
variant: security
route: orthogonal-v
labelAt: [1003, 529]
- from: worker
to: audit
label: evidence
variant: dashed
fromSide: bottom
toSide: top
labelDy: 58
- from: worker
to: observability
label: OTLP
variant: dashed
route: orthogonal-v
cards:
- dot: cyan
title: Runtime Ownership
items:
- Platform owns the edge, gateway, cache, and event bus
- Application teams own API pods and workers
- Data owns primary and disaster-recovery state
- dot: rose
title: Named Crossings
items:
- Public HTTPS terminates at the managed edge
- mTLS crosses into the application network
- Cross-region WAL is explicit and encrypted
- dot: emerald
title: Operational Evidence
items:
- Workers emit traces to SRE-owned observability
- Audit evidence lands in immutable storage
- Unknown placement should remain marked, never invented
+32 -25
View File
@@ -49,36 +49,31 @@ export async function run(): Promise<void> {
await waitFor('activation', () => extension.isActive); await waitFor('activation', () => extension.isActive);
}); });
await check('.archify files open as JSON with the preview available', async () => { await check('clicking a .archify file opens the diagram, not the text', async () => {
const doc = await vscode.workspace.openTextDocument(workspaceFile('web-platform.archify')); const uri = workspaceFile('web-platform.archify');
assert.equal(doc.languageId, 'json'); await vscode.commands.executeCommand('vscode.open', uri);
await vscode.window.showTextDocument(doc); const tab = await waitFor('diagram editor tab', () =>
await vscode.commands.executeCommand('archify.showPreviewToSide'); vscode.window.tabGroups.all
const tabs = await waitFor('.archify preview tab', () => { .flatMap((group) => group.tabs)
const found = webviewTabs('archify.preview').filter((tab) => /web-platform\.archify/.test(tab.label)); .find((t) => t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.diagram' && t.input.uri.toString() === uri.toString()),
return found.length > 0 && found; );
}); assert.ok(tab.isActive, 'the diagram tab is active');
assert.equal(tabs.length, 1); assert.equal(vscode.window.activeTextEditor?.document.uri.toString() === uri.toString(), false, 'no text editor was opened for it');
}); });
await check('.archify completion comes from the schema of its diagram_type', async () => { await check('.archify files are YAML and Show Source opens the text beside the diagram', async () => {
const doc = await vscode.workspace.openTextDocument(workspaceFile('web-platform.archify')); const uri = workspaceFile('web-platform.archify');
await vscode.window.showTextDocument(doc); await vscode.commands.executeCommand('archify.showSource');
// Complete the value of the first component's "type". const editor = await waitFor('source text editor', () =>
const offset = doc.getText().indexOf('"type": "') + '"type": "'.length; vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri.toString()),
const position = doc.positionAt(offset); );
let labels: string[] = []; assert.equal(editor.document.languageId, 'yaml');
for (let attempt = 0; attempt < 50 && !labels.includes('backend'); attempt += 1) { assert.match(editor.document.getText(), /^schema_version: 1/m);
const list = await vscode.commands.executeCommand<vscode.CompletionList>('vscode.executeCompletionItemProvider', doc.uri, position);
labels = list.items.map((item) => (typeof item.label === 'string' ? item.label : item.label.label).replace(/"/g, ''));
if (!labels.includes('backend')) await new Promise((resolve) => setTimeout(resolve, 300));
}
assert.ok(labels.includes('backend') && labels.includes('database'), `offered: ${labels.join(', ')}`);
}); });
await check('.archify problems are reported against the file', async () => { await check('.archify validation problems are reported against the YAML', async () => {
const good = await vscode.workspace.openTextDocument(workspaceFile('cache-miss.archify')); const good = await vscode.workspace.openTextDocument(workspaceFile('cache-miss.archify'));
const broken = good.getText().replace('"participants"', '"actors"'); const broken = good.getText().replace(/^participants:/m, 'actors:');
const target = workspaceFile('broken.archify'); const target = workspaceFile('broken.archify');
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(broken)); await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(broken));
const doc = await vscode.workspace.openTextDocument(target); const doc = await vscode.workspace.openTextDocument(target);
@@ -90,6 +85,18 @@ export async function run(): Promise<void> {
assert.ok(diagnostics.some((d) => d.severity === vscode.DiagnosticSeverity.Error)); assert.ok(diagnostics.some((d) => d.severity === vscode.DiagnosticSeverity.Error));
}); });
await check('.archify YAML syntax errors are reported at their line', async () => {
const target = workspaceFile('syntax.archify');
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode('schema_version: 1\ndiagram_type: architecture\nmeta: [unclosed\n'));
const doc = await vscode.workspace.openTextDocument(target);
await vscode.window.showTextDocument(doc);
const diagnostics = await waitFor('yaml syntax diagnostics', () => {
const found = vscode.languages.getDiagnostics(doc.uri).filter((d) => d.source === 'archify' && d.message.startsWith('YAML:'));
return found.length > 0 && found;
});
assert.ok(diagnostics[0].range.start.line >= 2, `reported on line ${diagnostics[0].range.start.line + 1}`);
});
await check('opens a live preview beside the source', async () => { await check('opens a live preview beside the source', async () => {
await vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json')); await vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json'));
const tabs = await waitFor('preview tab', () => { const tabs = await waitFor('preview tab', () => {
+2 -2
View File
@@ -24,7 +24,7 @@ function load(name: string) {
const text = fs.readFileSync(path.join(fixtures, name), 'utf8'); const text = fs.readFileSync(path.join(fixtures, name), 'utf8');
const source = readDiagramSource(text, name); const source = readDiagramSource(text, name);
assert.ok(source, `${name} is a diagram source`); assert.ok(source, `${name} is a diagram source`);
return { ...source, text }; return source;
} }
for (const name of fs.readdirSync(fixtures).filter((file) => !file.startsWith('mco-'))) { for (const name of fs.readdirSync(fixtures).filter((file) => !file.startsWith('mco-'))) {
@@ -46,7 +46,7 @@ test('reports schema problems with JSON pointer paths', async () => {
test('render failure returns the CLI error', async () => { test('render failure returns the CLI error', async () => {
const text = JSON.stringify({ schema_version: 1, diagram_type: 'architecture', meta: {}, components: [] }); const text = JSON.stringify({ schema_version: 1, diagram_type: 'architecture', meta: {}, components: [] });
const result = await renderDiagram(runtime(), { type: 'architecture', diagram: JSON.parse(text), text }, 'fallback'); const result = await renderDiagram(runtime(), { type: 'architecture', diagram: JSON.parse(text), format: 'json', json: text }, 'fallback');
assert.equal(result.ok, false); assert.equal(result.ok, false);
assert.match(result.error ?? '', /schema validation failed/); assert.match(result.error ?? '', /schema validation failed/);
}); });
+3 -2
View File
@@ -3,6 +3,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { test } from 'node:test'; import { test } from 'node:test';
import Ajv2020 from 'ajv/dist/2020'; import Ajv2020 from 'ajv/dist/2020';
import { parse as parseYaml } from 'yaml';
const root = path.resolve(__dirname, '..', '..'); const root = path.resolve(__dirname, '..', '..');
const schemas = path.join(root, 'schemas'); const schemas = path.join(root, 'schemas');
@@ -27,14 +28,14 @@ test('bundled schemas carry no absolute $id', () => {
test('the .archify schema accepts every fixture', () => { test('the .archify schema accepts every fixture', () => {
const validate = validator(); const validate = validator();
for (const file of fs.readdirSync(fixtures)) { for (const file of fs.readdirSync(fixtures)) {
const ok = validate(JSON.parse(fs.readFileSync(path.join(fixtures, file), 'utf8'))); const ok = validate(parseYaml(fs.readFileSync(path.join(fixtures, file), 'utf8')));
assert.ok(ok, `${file}: ${JSON.stringify(validate.errors?.slice(0, 3))}`); assert.ok(ok, `${file}: ${JSON.stringify(validate.errors?.slice(0, 3))}`);
} }
}); });
test('the .archify schema applies the schema of the declared type', () => { test('the .archify schema applies the schema of the declared type', () => {
const validate = validator(); const validate = validator();
const sequence = JSON.parse(fs.readFileSync(path.join(fixtures, 'cache-miss.archify'), 'utf8')); const sequence = parseYaml(fs.readFileSync(path.join(fixtures, 'cache-miss.archify'), 'utf8'));
// A sequence diagram is not a valid architecture diagram. // A sequence diagram is not a valid architecture diagram.
assert.equal(validate({ ...sequence, diagram_type: 'architecture' }), false); assert.equal(validate({ ...sequence, diagram_type: 'architecture' }), false);
assert.equal(validate({ ...sequence, diagram_type: 'poster' }), false); assert.equal(validate({ ...sequence, diagram_type: 'poster' }), false);