Compare commits

..
11 Commits
Author SHA1 Message Date
root-at-skic ca3f08bac9 Merge pull request 'Release 0.5.0' (#16) from release/0.5.0 into main 2026-10-04 12:16:46 -07:00
root-at-skic 86f6d5c668 Release 0.5.0 2026-10-04 22:16:39 +03:00
root-at-skic 789ccb2d5c Merge pull request 'Brand completion and unknown-brand errors on their line' (#15) from feat/brand-completion into main 2026-10-04 12:16:30 -07:00
root-at-skic adc5cdbe97 Suggest brand IDs after brand: and place unknown-brand errors on their line
- Completion for brand values in .archify (YAML) and Archify JSON sources,
  from the bundled brand-marks/catalog.json (107 marks); aliases and
  product names match, so "kafka" offers apache-kafka
- brand/unknown diagnostics carry their JSON pointer only at the start of
  the message; use it when subject.path is missing, so the error lands on
  the brand line instead of the top of the file
- Unit tests for catalog loading, value detection and pointer fallback;
  integration checks for completion and diagnostic placement

Closes #5
2026-10-04 22:05:30 +03:00
root-at-skic e9da971523 Merge pull request 'Release 0.4.1: title the command New Diagram Archify…' (#14) from fix/new-diagram-title into main 2026-10-04 11:50:00 -07:00
root-at-skic 8d8fc6f65e Title the new-diagram command New Diagram Archify… (0.4.1)
The Explorer folder menu shows a command's title without its category,
so it read just "New Diagram…". The title now names Archify itself and
the category is dropped, so the Command Palette shows the same text.
Integration test asserts the title.
2026-10-04 21:49:54 +03:00
root-at-skic 6f6ffae96f Merge pull request 'Release 0.4.0' (#13) from release/0.4.0 into main 2026-10-04 11:35:06 -07:00
root-at-skic f102e722ec Release 0.4.0 2026-10-04 21:35:00 +03:00
root-at-skic 3be676f3b2 Merge pull request 'Add New Diagram command with starter templates' (#12) from feat/new-diagram-command into main 2026-10-04 11:34:06 -07:00
root-at-skic 9357176673 Add New Diagram command with starter templates
- Archify: New Diagram… (Explorer folder menu, File > New File…, Command
  Palette) creates a .archify file from a template and opens the diagram
- Six YAML templates: architecture, workflow, sequence, dataflow,
  lifecycle and an everyday plan; connections carry ids, workflows use
  schema 2
- Title and meta.output follow the file name; template comments are kept
- Tests: every template passes archify validate; integration check for
  the command

Closes #2
2026-10-04 21:33:11 +03:00
root-at-skic d1eab34f7f 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
2026-09-30 15:21:55 +03:00
29 changed files with 1532 additions and 254 deletions

No files matched your search

+20
View File
@@ -1,5 +1,25 @@
# Changelog # Changelog
## 0.5.0 — 2026-10-04
- **Brand completion:** after `brand:` in `.archify` files and Archify JSON sources, the editor suggests the 107 built-in brand IDs with their product names. Aliases and product names match too (`kafka` → `apache-kafka`, `postgres` → `postgresql`).
- **Unknown brands are underlined on their own line.** Archify reports these without a location, so they used to appear at the top of the file.
## 0.4.1 — 2026-10-04
- The new-diagram command is now titled **New Diagram Archify…** everywhere, including the Explorer folder menu, where it read *New Diagram…*.
## 0.4.0 — 2026-10-04
- **New Diagram…** (Explorer folder menu, *File → New File…*, Command Palette): pick Architecture, Workflow, Sequence, Data Flow, Lifecycle or Everyday Plan, enter a name, and a small valid `.archify` (YAML) file is created from a template and opened as a diagram. Title and output file follow the name.
## 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`.
+32 -23
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)
@@ -8,44 +8,53 @@ This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i
## Quick start ## Quick start
The fastest start: right-click a folder in the Explorer and choose **New Diagram Archify…** (also in *File → New File…* and the Command Palette). Pick a kind of diagram (Architecture, Workflow, Sequence, Data Flow, Lifecycle or Everyday Plan), give it a name, and a small working `.archify` file is created and opened. Change its YAML to fit your case.
To write one by hand:
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. - **New diagram from a template**: *New Diagram Archify…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder.
- **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`. - **Brand logos**: after `brand:` the editor suggests Archify's built-in brand IDs (PostgreSQL, Redis, Kafka, Docker, …). Typing an alias or product name finds them too, so `kafka` offers `apache-kafka`. An unknown brand is reported on its own line.
- **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**: *New Diagram Archify…*, *Archify: Render to HTML File…*, *Archify: Open in Browser*, *Archify: Refresh Preview*, *Archify: Show Source*.
## Source evidence ## Source evidence
+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
+20 -2
View File
@@ -1,13 +1,16 @@
{ {
"name": "archify-vscode-ext", "name": "archify-vscode-ext",
"version": "0.2.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "archify-vscode-ext", "name": "archify-vscode-ext",
"version": "0.2.0", "version": "0.5.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",
+44 -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.5.0",
"publisher": "root-at-skic", "publisher": "root-at-skic",
"license": "MIT", "license": "MIT",
"icon": "media/icon.png", "icon": "media/icon.png",
@@ -38,6 +38,11 @@
], ],
"contributes": { "contributes": {
"commands": [ "commands": [
{
"command": "archify.newDiagram",
"title": "New Diagram Archify\u2026",
"icon": "$(new-file)"
},
{ {
"command": "archify.showPreview", "command": "archify.showPreview",
"title": "Open Preview", "title": "Open Preview",
@@ -97,19 +102,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 +135,7 @@
}, },
{ {
"command": "archify.showSource", "command": "archify.showSource",
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'", "when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'",
"group": "navigation" "group": "navigation"
}, },
{ {
@@ -145,6 +150,11 @@
} }
], ],
"explorer/context": [ "explorer/context": [
{
"command": "archify.newDiagram",
"when": "explorerResourceIsFolder",
"group": "navigation@10"
},
{ {
"command": "archify.showPreview", "command": "archify.showPreview",
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/", "when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
@@ -167,6 +177,12 @@
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/", "when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
"group": "1_open" "group": "1_open"
} }
],
"file/newFile": [
{
"command": "archify.newDiagram",
"group": "file"
}
] ]
}, },
"keybindings": [ "keybindings": [
@@ -184,6 +200,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 +222,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 +321,17 @@
}, },
"languages": [ "languages": [
{ {
"id": "json", "id": "yaml",
"extensions": [ "extensions": [
".archify" ".archify"
] ]
} }
],
"yamlValidation": [
{
"fileMatch": "*.archify",
"url": "./schemas/archify.schema.json"
}
] ]
}, },
"scripts": { "scripts": {
@@ -329,5 +357,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}`);
}
+48
View File
@@ -0,0 +1,48 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { Brand, brandValueAt, loadBrandCatalog } from './brands';
import { isArchifyFile } from './detect';
import { log } from './runtime';
const JSON_SOURCES = '**/*.{architecture,workflow,sequence,dataflow,lifecycle}.json';
export const BRAND_COMPLETION_SELECTOR: vscode.DocumentSelector = [
{ pattern: '**/*.archify' },
{ language: 'json', pattern: JSON_SOURCES },
{ language: 'jsonc', pattern: JSON_SOURCES },
];
/** Suggests Archify's built-in brand IDs for `brand` values. */
export class BrandCompletionProvider implements vscode.CompletionItemProvider {
private brands: Brand[] | undefined;
constructor(private readonly extensionPath: string) {}
private catalog(): Brand[] {
if (!this.brands) {
try {
this.brands = loadBrandCatalog(path.join(this.extensionPath, 'vendor', 'archify', 'brand-marks', 'catalog.json'));
} catch (error) {
log().error(`Could not read the Archify brand catalog: ${error instanceof Error ? error.message : String(error)}`);
this.brands = [];
}
}
return this.brands;
}
provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): vscode.CompletionItem[] | undefined {
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
const value = brandValueAt(linePrefix, isArchifyFile(document.fileName) ? 'yaml' : 'json');
if (!value) return undefined;
const range = new vscode.Range(position.translate(0, -value.typed.length), position);
return this.catalog().map((brand) => {
const item = new vscode.CompletionItem({ label: brand.id, description: brand.title }, vscode.CompletionItemKind.Value);
item.detail = `${brand.title} · ${brand.category}`;
if (brand.aliases.length) item.documentation = `Also matches: ${brand.aliases.join(', ')}`;
// Typing an alias or the product name finds the brand too.
item.filterText = [brand.id, ...brand.aliases, brand.title].join(' ');
item.range = range;
return item;
});
}
}
+33
View File
@@ -0,0 +1,33 @@
import fs from 'node:fs';
export interface Brand {
id: string;
title: string;
category: string;
aliases: string[];
}
/** Archify's built-in brand marks, read from brand-marks/catalog.json. */
export function loadBrandCatalog(file: string): Brand[] {
const catalog = JSON.parse(fs.readFileSync(file, 'utf8')) as { marks?: Partial<Brand>[] };
return (catalog.marks ?? [])
.filter((mark): mark is Partial<Brand> & { id: string } => typeof mark.id === 'string')
.map((mark) => ({ id: mark.id, title: mark.title ?? mark.id, category: mark.category ?? '', aliases: mark.aliases ?? [] }));
}
export interface BrandValue {
/** What has been typed of the value so far. */
typed: string;
}
/**
* Whether the text before the cursor ends inside a `brand` value:
* `brand: post` or `{ id: db, brand: "post` in YAML, `"brand": "post` in JSON.
*/
export function brandValueAt(linePrefix: string, format: 'yaml' | 'json'): BrandValue | undefined {
const match =
format === 'yaml'
? /(?:^|[\s{,])brand:\s*["']?([\w.-]*)$/.exec(linePrefix)
: /"brand"\s*:\s*"([\w.-]*)$/.exec(linePrefix);
return match ? { typed: match[1] } : undefined;
}
+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;
} }
+38 -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 { diagnosticPointer, 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,8 @@ 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 pointer = diagnosticPointer(item);
const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, pointer) : rangeForPointer(text, pointer);
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(
+87 -7
View File
@@ -1,12 +1,14 @@
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 { BRAND_COMPLETION_SELECTOR, BrandCompletionProvider } from './brandCompletion';
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';
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES } from './templates';
const JSON_LANGUAGES = new Set(['json', 'jsonc']); const JSON_LANGUAGES = new Set(['json', 'jsonc']);
@@ -17,7 +19,13 @@ function isHtmlUri(uri: vscode.Uri): boolean {
export function activate(context: vscode.ExtensionContext): void { export function activate(context: vscode.ExtensionContext): void {
const previews = new PreviewManager(context); const previews = new PreviewManager(context);
const htmlViewer = new HtmlViewerProvider(); const htmlViewer = new HtmlViewerProvider();
context.subscriptions.push(previews, htmlViewer, new DiagnosticsController(context), log()); context.subscriptions.push(
previews,
htmlViewer,
new DiagnosticsController(context),
log(),
vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'),
);
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */ /** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
function targetUri(arg?: unknown): vscode.Uri | undefined { function targetUri(arg?: unknown): vscode.Uri | undefined {
@@ -36,7 +44,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);
@@ -59,11 +69,32 @@ export function activate(context: vscode.ExtensionContext): void {
const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) => const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn); vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
const exists = async (uri: vscode.Uri) => {
try {
await vscode.workspace.fs.stat(uri);
return true;
} catch {
return false;
}
};
/** Folder for a new diagram: the clicked folder (or a file's folder), else a workspace folder. */
async function newDiagramFolder(arg?: unknown): Promise<vscode.Uri | undefined> {
if (arg instanceof vscode.Uri) {
const stat = await vscode.workspace.fs.stat(arg);
return stat.type & vscode.FileType.Directory ? arg : vscode.Uri.joinPath(arg, '..');
}
const folders = vscode.workspace.workspaceFolders ?? [];
if (folders.length <= 1) return folders[0]?.uri;
return (await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Folder for the new diagram' }))?.uri;
}
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => { vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
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 +102,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);
}), }),
@@ -79,10 +116,53 @@ export function activate(context: vscode.ExtensionContext): void {
if (uri) return openHtmlViewer(uri); if (uri) return openHtmlViewer(uri);
}), }),
vscode.commands.registerCommand('archify.newDiagram', async (arg?: unknown) => {
const template = await vscode.window.showQuickPick(
TEMPLATES.map((item) => ({ label: item.label, description: item.description, template: item })),
{ title: 'New Diagram Archify', placeHolder: 'Kind of diagram' },
);
if (!template) return;
const { file, defaultName } = template.template;
const folder = await newDiagramFolder(arg);
let target: vscode.Uri | undefined;
if (folder) {
const name = await vscode.window.showInputBox({
title: 'New Diagram Archify',
prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}`,
value: defaultName,
validateInput: async (value) =>
invalidNameReason(value) ??
((await exists(vscode.Uri.joinPath(folder, `${diagramName(value)}.archify`))) ? `${diagramName(value)}.archify already exists.` : undefined),
});
if (name === undefined) return;
target = vscode.Uri.joinPath(folder, `${diagramName(name)}.archify`);
} else {
// No workspace: let the user choose where to save.
target = await vscode.window.showSaveDialog({
title: 'New Diagram Archify',
filters: { 'Archify Diagram': ['archify'] },
saveLabel: 'Create',
});
if (!target) return;
if (!isArchifyFile(target.path)) target = target.with({ path: `${target.path}.archify` });
}
const templateText = await readText(vscode.Uri.joinPath(context.extensionUri, 'templates', file));
const text = diagramFromTemplate(templateText, diagramBaseName(target.path));
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE);
}),
vscode.commands.registerCommand('archify.refreshPreview', () => previews.activePreview?.update(true)), vscode.commands.registerCommand('archify.refreshPreview', () => previews.activePreview?.update(true)),
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 +195,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 +203,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 +236,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 });
+48
View File
@@ -1,10 +1,19 @@
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;
end: number; end: number;
} }
/**
* The JSON pointer an Archify diagnostic is about. Most diagnostics carry it
* in subject.path; brand diagnostics only start their message with it.
*/
export function diagnosticPointer(diagnostic: { message: string; subject?: { path?: string } }): string | undefined {
return diagnostic.subject?.path ?? /^(\/[^\s"]+)\s/.exec(diagnostic.message)?.[1];
}
/** Splits an RFC 6901 JSON pointer into path segments. */ /** Splits an RFC 6901 JSON pointer into path segments. */
export function pointerSegments(pointer: string): (string | number)[] { export function pointerSegments(pointer: string): (string | number)[] {
if (!pointer || pointer === '/') return []; if (!pointer || pointer === '/') return [];
@@ -37,3 +46,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`);
+53
View File
@@ -0,0 +1,53 @@
import { isMap, parseDocument } from 'yaml';
import { DiagramType } from './detect';
export interface DiagramTemplate {
/** File name under templates/. */
file: string;
type: DiagramType;
label: string;
description: string;
/** Suggested name for the new file, without .archify. */
defaultName: string;
}
export const TEMPLATES: readonly DiagramTemplate[] = [
{ file: 'architecture.archify', type: 'architecture', label: 'Architecture', description: 'Parts of a system and how they connect', defaultName: 'architecture' },
{ file: 'workflow.archify', type: 'workflow', label: 'Workflow', description: 'Steps of a process, in lanes', defaultName: 'workflow' },
{ file: 'sequence.archify', type: 'sequence', label: 'Sequence', description: 'Who calls whom, in order', defaultName: 'sequence' },
{ file: 'dataflow.archify', type: 'dataflow', label: 'Data Flow', description: 'Where data comes from and where it goes', defaultName: 'dataflow' },
{ file: 'lifecycle.archify', type: 'lifecycle', label: 'Lifecycle', description: 'States something moves through', defaultName: 'lifecycle' },
{ file: 'everyday-plan.archify', type: 'workflow', label: 'Everyday Plan', description: 'A plan or process from daily life', defaultName: 'plan' },
];
/** Strips a typed .archify suffix and surrounding spaces. */
export function diagramName(input: string): string {
return input.trim().replace(/\.archify$/i, '').trim();
}
/** Why a name cannot be used as a file name, or undefined when it can. */
export function invalidNameReason(input: string): string | undefined {
const name = diagramName(input);
if (!name) return 'Enter a name.';
if (/[\\/:*?"<>|\u0000-\u001f]/.test(name)) return 'A name cannot contain \\ / : * ? " < > | or control characters.';
if (/^\.+$/.test(name)) return 'Enter a name.';
return undefined;
}
/** "sales-report_v2" → "Sales report v2". */
export function titleFromName(name: string): string {
const words = name.replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim();
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
}
/**
* Template YAML with meta.title and meta.output set for the new file.
* Works on the YAML document so the template's comments and layout stay.
*/
export function diagramFromTemplate(templateText: string, name: string): string {
const doc = parseDocument(templateText);
if (!isMap(doc.get('meta'))) doc.set('meta', doc.createNode({}));
doc.setIn(['meta', 'title'], titleFromName(name));
doc.setIn(['meta', 'output'], `${name}.html`);
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
}
+50
View File
@@ -0,0 +1,50 @@
# Architecture: the parts of a system and how they connect.
# Components need a unique id; connections link component ids.
schema_version: 1
diagram_type: architecture
meta:
title: Web Application
output: architecture.html
components:
- id: users
type: external
label: Users
sublabel: Browser
pos: [40, 160]
size: [130, 60]
- id: api
type: backend
label: API
sublabel: REST service
pos: [250, 160]
size: [130, 60]
- id: cache
type: database
label: Cache
sublabel: Redis
pos: [460, 60]
size: [130, 60]
- id: db
type: database
label: Database
sublabel: PostgreSQL
pos: [460, 260]
size: [130, 60]
boundaries:
- kind: region
label: Production
wraps: [api, cache, db]
connections:
- id: users-api
from: users
to: api
label: HTTPS
variant: emphasis
- id: api-cache
from: api
to: cache
label: read
- id: api-db
from: api
to: db
label: SQL
+55
View File
@@ -0,0 +1,55 @@
# Data flow: where data comes from, how it is transformed, and where it ends up.
# Nodes sit in stages (columns) and rows; flows link node ids.
schema_version: 1
diagram_type: dataflow
meta:
title: Sales Reporting
output: dataflow.html
viewBox: [960, 420]
stages:
- label: Sources
- label: Processing
- label: Storage
- label: Consumers
nodes:
- id: shop
type: frontend
label: Web Shop
stage: 0
row: 0
- id: crm
type: external
label: CRM
stage: 0
row: 1
- id: etl
type: backend
label: Nightly ETL
stage: 1
row: 0
- id: warehouse
type: database
label: Warehouse
stage: 2
row: 0
- id: dashboard
type: frontend
label: Sales Dashboard
stage: 3
row: 0
flows:
- from: shop
to: etl
label: orders
- from: crm
to: etl
label: customers
classification: PII
variant: security
- from: etl
to: warehouse
label: daily facts
variant: emphasis
- from: warehouse
to: dashboard
label: aggregates
+72
View File
@@ -0,0 +1,72 @@
# Everyday plan: a workflow about daily life rather than software.
# Node types set the colour; meta.legend renames them for the reader,
# and icon picks the corner symbol (calendar, clock, person, briefcase, flag, moon).
schema_version: 2
diagram_type: workflow
meta:
title: Summer Holiday Plan
output: everyday-plan.html
legend:
entries:
backend:
label: Time off
security:
label: Work
external:
label: Milestone
lanes:
- id: plan
label: Plan
- id: work
label: Work
mainPath: [today, book, holiday, back]
nodes:
- id: today
lane: plan
col: 0
type: external
label: Today
icon: clock
- id: book
lane: plan
col: 1
type: external
label: Book flights
sublabel: by end of May
icon: flag
- id: holiday
lane: plan
col: 2
type: backend
label: Holiday
sublabel: 2 weeks
icon: calendar
- id: back
lane: plan
col: 3
type: external
label: Back home
icon: flag
- id: handover
lane: work
col: 1
type: security
label: Handover
icon: briefcase
edges:
- id: plan-booking
from: today
to: book
- id: go
from: book
to: holiday
label: leave approved
- id: return
from: holiday
to: back
- id: prepare
from: today
to: handover
label: before leaving
role: branch
variant: security
+53
View File
@@ -0,0 +1,53 @@
# Lifecycle: the states something moves through and what moves it.
# States sit in lanes and columns; transitions link state ids.
schema_version: 1
diagram_type: lifecycle
meta:
title: Order Status
output: lifecycle.html
viewBox: [880, 566]
lanes:
- id: main
label: Normal path
- id: exits
label: Exits
states:
- id: placed
type: start
label: Placed
lane: main
col: 0
- id: paid
type: active
label: Paid
lane: main
col: 1
- id: shipped
type: active
label: Shipped
lane: main
col: 2
- id: delivered
type: success
label: Delivered
lane: main
col: 3
- id: cancelled
type: failure
label: Cancelled
lane: exits
col: 0
transitions:
- id: pay
from: placed
to: paid
- id: ship
from: paid
to: shipped
- id: deliver
from: shipped
to: delivered
- id: cancel
from: placed
to: cancelled
variant: security
+50
View File
@@ -0,0 +1,50 @@
# Sequence: who calls whom, in order. Participants are columns;
# messages run top to bottom by y.
schema_version: 1
diagram_type: sequence
meta:
title: Login Request
output: sequence.html
viewBox: [960, 520]
participants:
- id: client
type: external
label: Client
- id: api
type: backend
label: API
- id: auth
type: security
label: Auth Service
- id: db
type: database
label: User Store
messages:
- from: client
to: api
y: 160
label: POST /login
variant: emphasis
- from: api
to: auth
y: 210
label: verify credentials
- from: auth
to: db
y: 260
label: find user
- from: db
to: auth
y: 310
label: user record
variant: return
- from: auth
to: api
y: 360
label: token
variant: return
- from: api
to: client
y: 410
label: 200 OK + token
variant: return
+70
View File
@@ -0,0 +1,70 @@
# Workflow: steps of a process, grouped in lanes, left to right by col.
# mainPath lists the happy path; edges link node ids.
schema_version: 2
diagram_type: workflow
meta:
title: Change Approval
output: workflow.html
# Rename the legend entries of the node types you use.
legend:
entries:
frontend:
label: Author step
security:
label: Review gate
backend:
label: Decision
external:
label: Outcome
lanes:
- id: author
label: Author
- id: review
label: Reviewers
mainPath: [draft, review, approved, released]
nodes:
- id: draft
lane: author
col: 0
type: frontend
label: Draft change
- id: review
lane: review
col: 1
type: security
label: Review
sublabel: two approvals
- id: approved
lane: review
col: 2
type: backend
label: Approved
- id: released
lane: author
col: 3
type: external
label: Released
- id: rework
lane: author
col: 1
type: frontend
label: Rework
edges:
- id: submit
from: draft
to: review
label: submit
- id: approve
from: review
to: approved
label: approve
- id: release
from: approved
to: released
label: release
- id: request-changes
from: review
to: rework
label: changes requested
role: branch
variant: dashed
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import { test } from 'node:test';
import { brandValueAt, loadBrandCatalog } from '../src/brands';
import { diagnosticPointer } from '../src/jsonPointer';
const root = path.resolve(__dirname, '..', '..');
const catalogFile = path.join(root, 'vendor', 'archify', 'brand-marks', 'catalog.json');
test('loads the bundled brand catalog', () => {
const brands = loadBrandCatalog(catalogFile);
assert.ok(brands.length >= 100, `${brands.length} brands`);
const postgres = brands.find((brand) => brand.id === 'postgresql');
assert.ok(postgres);
assert.ok(postgres.aliases.includes('postgres'));
assert.ok(brands.every((brand) => brand.id && brand.title));
});
test('finds a brand value being typed in YAML', () => {
assert.deepEqual(brandValueAt(' brand: ', 'yaml'), { typed: '' });
assert.deepEqual(brandValueAt(' brand: post', 'yaml'), { typed: 'post' });
assert.deepEqual(brandValueAt(' - brand: apache-ka', 'yaml'), { typed: 'apache-ka' });
assert.deepEqual(brandValueAt(' brand: "red', 'yaml'), { typed: 'red' });
assert.deepEqual(brandValueAt(' - { id: db, brand: post', 'yaml'), { typed: 'post' });
assert.equal(brandValueAt(' label: post', 'yaml'), undefined);
assert.equal(brandValueAt(' subbrand: post', 'yaml'), undefined);
assert.equal(brandValueAt(' brand: postgresql ', 'yaml'), undefined, 'value already finished');
});
test('finds a brand value being typed in JSON', () => {
assert.deepEqual(brandValueAt(' "brand": "', 'json'), { typed: '' });
assert.deepEqual(brandValueAt('{ "id": "db", "brand": "post', 'json'), { typed: 'post' });
assert.equal(brandValueAt(' "brand": ', 'json'), undefined, 'not inside the string yet');
assert.equal(brandValueAt(' "label": "post', 'json'), undefined);
});
test('diagnostic pointer comes from subject.path, else from a leading pointer in the message', () => {
assert.equal(diagnosticPointer({ message: 'x', subject: { path: '/components/0' } }), '/components/0');
assert.equal(
diagnosticPointer({ message: '/components/0/brand "aws" is not a built-in brand; closest IDs: airtable' }),
'/components/0/brand',
);
assert.equal(diagnosticPointer({ message: 'Label "x" is wider than node' }), undefined);
});
+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
+114 -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,100 @@ 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('New Diagram Archify creates a .archify file from a template and opens the diagram', async () => {
// Menus show the bare title, so it must name Archify by itself.
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes.commands as { command: string; title: string; category?: string }[];
const newDiagram = commands.find((item) => item.command === 'archify.newDiagram');
assert.equal(newDiagram?.title, 'New Diagram Archify…');
assert.equal(newDiagram?.category, undefined);
const folder = workspaceFile('.');
// Stub the template pick and the name prompt; check the name validation on the way.
const window = vscode.window as unknown as Record<string, unknown>;
const originalPick = window.showQuickPick;
const originalInput = window.showInputBox;
let existingNameMessage: unknown;
window.showQuickPick = async (items: readonly { label: string }[]) => (await items).find((item) => item.label === 'Everyday Plan');
window.showInputBox = async (options: vscode.InputBoxOptions) => {
existingNameMessage = await options.validateInput?.('web-platform');
return 'summer-plan';
};
try {
await vscode.commands.executeCommand('archify.newDiagram', folder);
} finally {
window.showQuickPick = originalPick;
window.showInputBox = originalInput;
}
assert.match(String(existingNameMessage), /already exists/);
const uri = vscode.Uri.joinPath(folder, 'summer-plan.archify');
const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(uri));
assert.match(text, /^diagram_type: workflow$/m);
assert.match(text, /^ {2}title: Summer plan$/m);
assert.match(text, /^ {2}output: summer-plan\.html$/m);
await waitFor('diagram editor for the new file', () =>
webviewTabs('archify.diagram').some((tab) => tab.input instanceof vscode.TabInputCustom && tab.input.uri.toString() === uri.toString()),
);
});
await check('suggests brand IDs after brand: in .archify files', async () => {
const target = workspaceFile('brands.archify');
const text = [
'schema_version: 1',
'diagram_type: architecture',
'meta:',
' title: Brands',
'components:',
' - id: db',
' type: database',
' label: DB',
' brand: ',
' - id: bus',
' type: messagebus',
' label: Bus',
' brand: kafka',
'',
].join('\n');
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(text));
const doc = await vscode.workspace.openTextDocument(target);
const labels = async (line: number) => {
const list = await vscode.commands.executeCommand<vscode.CompletionList>(
'vscode.executeCompletionItemProvider',
doc.uri,
new vscode.Position(line, doc.lineAt(line).text.length),
);
return list.items.map((item) => (typeof item.label === 'string' ? item.label : item.label.label));
};
const empty = await labels(8);
assert.ok(empty.includes('postgresql') && empty.includes('redis'), 'brand IDs offered after "brand: "');
const kafka = await labels(12);
assert.ok(kafka.includes('apache-kafka'), 'the alias "kafka" finds apache-kafka');
assert.equal((await labels(7)).includes('postgresql'), false, 'nothing offered for other fields');
});
await check('an unknown brand is reported on its own line', async () => {
const target = workspaceFile('unknown-brand.archify');
const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(workspaceFile('web-platform.archify')))
.replace(/^( {2}- id: [^\n]+\n)/m, '$1 brand: not-a-brand\n');
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(text));
const doc = await vscode.workspace.openTextDocument(target);
await vscode.window.showTextDocument(doc);
const diagnostic = await waitFor('brand/unknown diagnostic', () =>
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && d.code === 'brand/unknown'),
);
assert.match(doc.lineAt(diagnostic.range.start.line).text, /brand: not-a-brand/);
});
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);
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { parse } from 'yaml';
import { readDiagramSource } from '../src/detect';
import { validate } from '../src/renderer';
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES, titleFromName } from '../src/templates';
const root = path.resolve(__dirname, '..', '..');
const templates = path.join(root, 'templates');
const runtime = {
cliPath: path.join(root, 'vendor', 'archify', 'bin', 'archify.mjs'),
nodePath: process.execPath,
runAsNode: false,
workDir: fs.mkdtempSync(path.join(os.tmpdir(), 'archify-ext-test-')),
};
test('every template file is listed and every listed template exists', () => {
const files = fs.readdirSync(templates).filter((file) => file.endsWith('.archify')).sort();
assert.deepEqual(TEMPLATES.map((item) => item.file).sort(), files);
});
for (const item of TEMPLATES) {
test(`template ${item.file} is a valid ${item.type} diagram`, async () => {
const text = diagramFromTemplate(fs.readFileSync(path.join(templates, item.file), 'utf8'), item.defaultName);
const source = readDiagramSource(text, `${item.defaultName}.archify`);
assert.ok(source);
assert.equal(source.type, item.type);
assert.equal(source.format, 'yaml');
const result = await validate(runtime, source.type, source.json);
assert.deepEqual(
result.diagnostics.filter((d) => d.severity === 'error').map((d) => `${d.code}: ${d.message}`),
[],
);
assert.ok(result.ok);
});
}
test('diagramFromTemplate sets title and output and keeps comments and flow lists', () => {
const text = diagramFromTemplate('# keep me\nschema_version: 1\nmeta:\n title: Old\n output: old.html\npos: [1, 2]\n', 'sales-report');
assert.match(text, /^# keep me/);
assert.match(text, /pos: \[1, 2\]/);
assert.deepEqual(parse(text).meta, { title: 'Sales report', output: 'sales-report.html' });
});
test('diagramFromTemplate adds meta when the template has none', () => {
assert.deepEqual(parse(diagramFromTemplate('diagram_type: workflow\n', 'plan')).meta, { title: 'Plan', output: 'plan.html' });
});
test('diagram names', () => {
assert.equal(diagramName(' web.archify '), 'web');
assert.equal(diagramName('web.ARCHIFY'), 'web');
assert.equal(invalidNameReason('web'), undefined);
assert.equal(invalidNameReason('my plan 2026'), undefined);
assert.ok(invalidNameReason(''));
assert.ok(invalidNameReason('.archify'));
assert.ok(invalidNameReason('..'));
assert.ok(invalidNameReason('a/b'));
assert.ok(invalidNameReason('a:b'));
assert.equal(titleFromName('sales-report_v2'), 'Sales report v2');
assert.equal(titleFromName('plan'), 'Plan');
});