Compare commits

...
Author SHA1 Message Date
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
14 changed files with 593 additions and 4 deletions

No files matched your search

+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 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.
+6 -1
View File
@@ -8,6 +8,10 @@ This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i
## 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`:
```yaml
@@ -41,6 +45,7 @@ Archify's own JSON naming, `name.architecture.json`, `name.workflow.json` and so
## Features
- **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.
@@ -48,7 +53,7 @@ Archify's own JSON naming, `name.architecture.json`, `name.workflow.json` and so
- **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.
- **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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "archify-vscode-ext",
"version": "0.2.0",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "archify-vscode-ext",
"version": "0.2.0",
"version": "0.4.1",
"license": "MIT",
"dependencies": {
"yaml": "^2.9.1"
+17 -1
View File
@@ -2,7 +2,7 @@
"name": "archify-vscode-ext",
"displayName": "Archify Diagram Viewer",
"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.3.0",
"version": "0.4.1",
"publisher": "root-at-skic",
"license": "MIT",
"icon": "media/icon.png",
@@ -38,6 +38,11 @@
],
"contributes": {
"commands": [
{
"command": "archify.newDiagram",
"title": "New Diagram Archify\u2026",
"icon": "$(new-file)"
},
{
"command": "archify.showPreview",
"title": "Open Preview",
@@ -145,6 +150,11 @@
}
],
"explorer/context": [
{
"command": "archify.newDiagram",
"when": "explorerResourceIsFolder",
"group": "navigation@10"
},
{
"command": "archify.showPreview",
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
@@ -167,6 +177,12 @@
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
"group": "1_open"
}
],
"file/newFile": [
{
"command": "archify.newDiagram",
"group": "file"
}
]
},
"keybindings": [
+59
View File
@@ -7,6 +7,7 @@ import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
import { DIAGRAM_EDITOR_VIEW_TYPE, PreviewManager } from './preview';
import { isEvidenceError, renderDiagram, renderToFile } from './renderer';
import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime';
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES } from './templates';
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
@@ -61,6 +62,26 @@ export function activate(context: vscode.ExtensionContext): void {
const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.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(
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
const uri = sourceUri(arg);
@@ -88,6 +109,44 @@ export function activate(context: vscode.ExtensionContext): void {
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.showSource', async () => {
+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
+34
View File
@@ -97,6 +97,40 @@ export async function run(): Promise<void> {
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('opens a live preview beside the source', async () => {
await vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json'));
const tabs = await waitFor('preview tab', () => {
+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');
});