Release 0.7.0: models (main.archify, registries, refs, links, model page) #22

Merged
root-at-skic merged 5 commits from feat/model into main 2026-10-19 04:46:19 -07:00
29 changed files with 874 additions and 8 deletions
Showing only changes of commit 57914d435c - Show all commits
+2 -1
View File
@@ -5,7 +5,8 @@
- **Models:** a folder with a `main.archify` at its top is a model, a set of diagrams that belong together. Opening `main.archify` shows the model page: name and description, the overview diagram if the file has one, the diagrams grouped by folder (click to open), and the registry with where each element is used.
- **Registry:** `*.archify-registry` files define shared elements once; diagrams draw them with `ref: <id>` plus position, and may override `label`, `sublabel` and `tag`. Hover, go-to-definition and completion on `ref`; unknown names, forbidden overrides, duplicate ids and broken registries in Problems. Registry edits re-render open diagrams.
- **Links:** `diagram:` on a registry element or a component points at the diagram that explains it; double-click the node (2D view) to open it.
- New command *Archify | Open Model*; *New Diagram… → Model (main.archify)* template; editor schema for registries and the new keys; example model in `examples/online-shop`.
- **New Model…:** right-click a folder and choose *Archify | New Model…* (also in *File → New File…* and the Command Palette) to start from one of three complete C4 examples: **C4 - Standard Web App**, **C4 - E-Shop**, **C4 - Event-Based Integration**. Each creates a model folder with `main.archify`, a registry and context, container and component diagrams, named after the folder you choose, and opens the model page.
- New commands *Archify | Open Model* (editor title, Command Palette and the Explorer menu of `.archify` and `.archify-registry` files); *New Diagram… → Model (main.archify)* template for an empty model; editor schema for registries and the new keys; example model in `examples/online-shop`.
## 0.6.2 — 2026-10-19
+6 -2
View File
@@ -22,6 +22,8 @@ New diagrams should be `.archify` files; everything below describes them unless
The fastest start: right-click a folder in the Explorer and choose **Archify | New Diagram…** (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.
For a whole set of diagrams, choose **Archify | New Model…** instead and pick one of the examples (C4 - Standard Web App, C4 - E-Shop, C4 - Event-Based Integration). A model folder with context, container and component diagrams and a shared registry is created and its model page opens. See [Models](#models).
To write one by hand:
1. Create a file ending in `.archify`, for example `hello.archify`:
@@ -62,6 +64,7 @@ The Archify CLI and the AI agents that use it write diagrams as JSON, named `nam
## Features
- **New diagram from a template**: *Archify | New Diagram…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder.
- **New model from an example**: *Archify | New Model…* creates a complete model folder from a C4 example (Standard Web App, E-Shop, Event-Based Integration).
- **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live.
- **Live updates**: the diagram re-renders as you edit, and keeps the focus and route you selected (`#focus=…`, `#route=…`).
- **HTML viewer**: right-click a rendered `.html` file and choose **Archify | Open in HTML Viewer**, or use *Reopen Editor With… → Archify HTML Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk.
@@ -70,7 +73,7 @@ The Archify CLI and the AI agents that use it write diagrams as JSON, named `nam
- **Schema help**: completion and hover from Archify's own schemas, 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 | New Diagram…*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*.
- **Commands**: *Archify | New Diagram…*, *Archify | New Model…*, *Archify | Open Model*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*.
## 3D view
@@ -116,7 +119,8 @@ online-shop/
- **The model page.** Click `main.archify` and you get the model's page: its name and description, the overview diagram if the file has one, the diagrams of the model grouped by folder (click one to open it), and the registry with "used in N diagrams".
- **Shared elements.** A registry file defines an element once (`type`, `label`, `sublabel`, `brand`, `description`, …). A diagram draws it with `ref: api` plus `pos` and `size`; it may override `label`, `sublabel` and `tag`, and may still define unshared elements inline. Hover a `ref` for the description, F12 jumps to the registry, completion after `ref:` lists the elements, unknown names appear in Problems.
- **Links.** A registry element (or a component) with `diagram: 0300-components/api.archify` points at the diagram that explains it: double-click the node in the 2D view to open it.
- **Commands.** *Archify | Open Model* opens the model page of the current file; *Archify | New Diagram… → Model* creates a `main.archify` from the folder name.
- **Starting one.** *Archify | New Model…* (right-click a folder) copies one of the shipped examples into a new folder, named as you choose: **C4 - Standard Web App** (single-page app, API, database, cache, storage, identity provider), **C4 - E-Shop** (web shop, API, worker, queue, payment and email providers) or **C4 - Event-Based Integration** (event bus, router, dead-letter queue, event store, consumers). Each has a registry and context, container and component diagrams. For an empty model, *Archify | New Diagram… → Model* creates just a `main.archify`.
- **Commands.** *Archify | Open Model* opens the model page of the current file, from the editor title, the Command Palette or the Explorer menu of a `.archify` or `.archify-registry` file.
Formats in detail: [docs/model.md](docs/model.md). A full example: [examples/online-shop](examples/online-shop).
+4
View File
@@ -80,3 +80,7 @@ Registry `diagram` links and component `diagram` links are the same mechanism; t
## Outside a model
A diagram with `ref` components outside any model (no `main.archify` above it) reports "no registry found" and shows the component by its ref name with type `external`, so the picture still renders.
## Starting a model
*Archify | New Model…* (right-click a folder, *File → New File…*, Command Palette) offers three complete examples: **C4 - Standard Web App**, **C4 - E-Shop** and **C4 - Event-Based Integration**. Pick one and a folder name; the example is copied into that folder with `model.id`, `model.name` and the overview title taken from the folder name (an example kept under its own name is copied as is). Each example has `registry/{people,systems,containers}.archify-registry` and diagrams under `0100-context`, `0200-containers` and `0300-components`; every registry element is used by at least one diagram and every diagram validates.
+23 -4
View File
@@ -51,6 +51,11 @@
"title": "Archify | New Diagram…",
"icon": "$(new-file)"
},
{
"command": "archify.newModel",
"title": "Archify | New Model…",
"icon": "$(new-folder)"
},
{
"command": "archify.showPreview",
"title": "Archify | Open Preview",
@@ -171,19 +176,29 @@
"group": "zzz_archify@1"
},
{
"command": "archify.showPreview",
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
"command": "archify.newModel",
"when": "explorerResourceIsFolder",
"group": "zzz_archify@2"
},
{
"command": "archify.renderToHtml",
"command": "archify.showPreview",
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
"group": "zzz_archify@3"
},
{
"command": "archify.renderToHtml",
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
"group": "zzz_archify@4"
},
{
"command": "archify.openHtmlViewer",
"when": "resourceExtname == .html",
"group": "zzz_archify@2"
"group": "zzz_archify@3"
},
{
"command": "archify.openModel",
"when": "resourceFilename =~ /\\.archify(-registry)?$/",
"group": "zzz_archify@5"
}
],
"editor/title/context": [
@@ -197,6 +212,10 @@
{
"command": "archify.newDiagram",
"group": "file"
},
{
"command": "archify.newModel",
"group": "file"
}
]
},
+52 -1
View File
@@ -1,7 +1,21 @@
import * as vscode from 'vscode';
import { MODEL_EXAMPLES, mainFromExample } from '../language/examples';
import { diagramName, invalidNameReason } from '../language/templates';
import { MAIN_FILE } from '../model/format';
import { readText } from '../util/files';
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
import { CommandContext, targetUri } from './context';
import { CommandContext, exists, targetUri } from './context';
/** Folder the new model folder goes into: the clicked folder (or a file's folder), else a workspace folder. */
async function parentFolder(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 model' }))?.uri;
}
export function modelCommands(ctx: CommandContext): vscode.Disposable[] {
return [
@@ -15,5 +29,42 @@ export function modelCommands(ctx: CommandContext): vscode.Disposable[] {
}
await vscode.commands.executeCommand('vscode.openWith', vscode.Uri.joinPath(root, MAIN_FILE), DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active);
}),
// Creates a new model folder from one of the shipped examples and opens its model page.
vscode.commands.registerCommand('archify.newModel', async (arg?: unknown) => {
const picked = await vscode.window.showQuickPick(
MODEL_EXAMPLES.map((example) => ({ label: example.label, description: example.description, example })),
{ title: 'Archify | New Model', placeHolder: 'Example to start from' },
);
if (!picked) return;
const { example } = picked;
let parent = await parentFolder(arg);
if (!parent) {
const chosen = await vscode.window.showOpenDialog({ title: 'Archify | New Model', canSelectFiles: false, canSelectFolders: true, openLabel: 'Create model here' });
parent = chosen?.[0];
if (!parent) return;
}
const name = await vscode.window.showInputBox({
title: 'Archify | New Model',
prompt: `Folder name in ${vscode.workspace.asRelativePath(parent, true)}`,
value: example.folder,
validateInput: async (value) =>
invalidNameReason(value) ?? ((await exists(vscode.Uri.joinPath(parent, diagramName(value)))) ? `${diagramName(value)} already exists.` : undefined),
});
if (name === undefined) return;
const folderName = diagramName(name);
const target = vscode.Uri.joinPath(parent, folderName);
if (await exists(target)) {
vscode.window.showWarningMessage(`Archify: ${vscode.workspace.asRelativePath(target, true)} already exists.`);
return;
}
const source = vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', 'models', example.folder);
await vscode.workspace.fs.copy(source, target, { overwrite: false });
const main = vscode.Uri.joinPath(target, MAIN_FILE);
await vscode.workspace.fs.writeFile(main, Buffer.from(mainFromExample(await readText(main), folderName, example), 'utf8'));
await vscode.commands.executeCommand('vscode.openWith', main, DIAGRAM_EDITOR_VIEW_TYPE);
}),
];
}
+34
View File
@@ -0,0 +1,34 @@
import { parseDocument } from 'yaml';
import { modelIdFromName, titleFromName } from './templates';
/**
* Example models shipped with the extension (templates/models/<folder>).
* "Archify | New Model…" offers them; each is a complete model folder:
* main.archify, registry/*.archify-registry and diagrams by level.
*/
export interface ModelExample {
/** Folder under templates/models and the default name of the new model folder. */
folder: string;
label: string;
description: string;
}
export const MODEL_EXAMPLES: readonly ModelExample[] = [
{ folder: 'c4-standard-web-app', label: 'C4 - Standard Web App', description: 'Single-page app, API, database, cache, file storage, identity provider' },
{ folder: 'c4-e-shop', label: 'C4 - E-Shop', description: 'Web shop, API, worker, queue, payment and email providers' },
{ folder: 'c4-event-based-integration', label: 'C4 - Event-Based Integration', description: 'Event bus, router, dead-letter queue, event store, consumers' },
];
/**
* The example's main.archify for a new model folder. When the folder keeps
* the example's name the file is used as written; otherwise id, name and the
* overview title follow the folder name and the description is kept.
*/
export function mainFromExample(mainText: string, folderName: string, example: ModelExample): string {
if (folderName === example.folder) return mainText;
const doc = parseDocument(mainText);
doc.setIn(['model', 'id'], modelIdFromName(folderName));
doc.setIn(['model', 'name'], titleFromName(folderName));
if (doc.hasIn(['meta', 'title'])) doc.setIn(['meta', 'title'], `${titleFromName(folderName)} — overview`);
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
}
@@ -0,0 +1,26 @@
schema_version: 1
diagram_type: architecture
meta:
title: System context
output: context.html
components:
- ref: customer
pos: [40, 60]
size: [130, 60]
- ref: support
pos: [40, 260]
size: [130, 60]
- ref: shop
pos: [280, 160]
size: [150, 60]
- ref: psp
pos: [520, 60]
size: [150, 60]
- ref: email
pos: [520, 260]
size: [150, 60]
connections:
- { from: customer, to: shop, label: buys, variant: emphasis }
- { from: support, to: shop, label: manages orders }
- { from: shop, to: psp, label: charges, variant: security }
- { from: shop, to: email, label: sends mail }
@@ -0,0 +1,39 @@
schema_version: 1
diagram_type: architecture
meta:
title: Containers
output: containers.html
components:
- ref: customer
pos: [40, 160]
size: [130, 60]
- ref: web
pos: [250, 160]
size: [130, 60]
- ref: api
pos: [460, 160]
size: [130, 60]
- ref: cache
pos: [670, 60]
size: [130, 60]
- ref: db
pos: [670, 260]
size: [130, 60]
- ref: queue
pos: [460, 360]
size: [130, 60]
- ref: worker
pos: [670, 460]
size: [130, 60]
boundaries:
- kind: region
label: Online Shop
wraps: [web, api, cache, db, queue, worker]
connections:
- { from: customer, to: web, label: HTTPS, variant: emphasis }
- { from: web, to: api, label: JSON }
- { from: api, to: cache, label: read }
- { from: api, to: db, label: SQL }
- { from: api, to: queue, label: enqueue, labelAt: [560, 290] }
- { from: queue, to: worker, label: poll }
- { from: worker, to: db, label: SQL, labelAt: [735, 420] }
@@ -0,0 +1,42 @@
schema_version: 1
diagram_type: architecture
meta:
title: API components
output: api.html
components:
- ref: web
pos: [40, 160]
size: [130, 60]
- id: auth
type: security
label: Auth
sublabel: sessions, OIDC
pos: [250, 60]
size: [130, 60]
- id: orders
type: backend
label: Orders
pos: [250, 160]
size: [130, 60]
- id: catalogue
type: backend
label: Catalogue
pos: [250, 260]
size: [130, 60]
- ref: db
pos: [460, 160]
size: [130, 60]
- ref: queue
pos: [460, 310]
size: [130, 60]
boundaries:
- kind: region
label: API
wraps: [auth, orders, catalogue]
connections:
- { from: web, to: auth, label: sign in, variant: security }
- { from: web, to: orders, label: place order }
- { from: web, to: catalogue, label: browse }
- { from: orders, to: db, label: SQL }
- { from: catalogue, to: db, label: SQL }
- { from: orders, to: queue, label: enqueue }
+24
View File
@@ -0,0 +1,24 @@
schema_version: 1
model:
id: e-shop
name: E-Shop
description: >
The web shop, its API and workers, and the stores behind them.
Context, containers and components, C4 style.
diagram_type: architecture
meta:
title: E-Shop — overview
output: main.html
components:
- ref: customer
pos: [40, 160]
size: [130, 60]
- ref: shop
pos: [250, 160]
size: [150, 60]
- ref: psp
pos: [480, 160]
size: [150, 60]
connections:
- { from: customer, to: shop, label: buys, variant: emphasis }
- { from: shop, to: psp, label: charges }
@@ -0,0 +1,37 @@
schema_version: 1
elements:
web:
type: frontend
label: Web App
sublabel: React
brand: react
description: The shop as customers see it; talks only to the API.
api:
type: backend
label: API
sublabel: Node.js
brand: node-js
description: Owns authentication, orders and the catalogue.
diagram: 0300-components/api.archify
worker:
type: backend
label: Worker
sublabel: async jobs
description: Picks jobs from the queue; emails, invoices, exports.
queue:
type: messagebus
label: Queue
sublabel: SQS
description: Job queue between the API and the workers.
db:
type: database
label: Database
sublabel: PostgreSQL
brand: postgresql
description: Orders, customers and catalogue; the source of truth.
cache:
type: database
label: Cache
sublabel: Redis
brand: redis
description: Sessions and catalogue cache; nothing here is the source of truth.
@@ -0,0 +1,12 @@
schema_version: 1
elements:
customer:
type: external
label: Customer
sublabel: web and mobile
description: Buys products; signs in with email or a social account.
support:
type: external
label: Support agent
sublabel: back office
description: Handles refunds and order problems in the admin console.
@@ -0,0 +1,19 @@
schema_version: 1
elements:
shop:
type: cloud
label: Online Shop
sublabel: the system
description: Everything we build and run; web app, API, workers and stores.
diagram: 0200-containers/online-shop.archify
psp:
type: external
label: Payment provider
sublabel: Stripe
brand: stripe
description: Charges cards and pays out; we never see card numbers.
email:
type: external
label: Email service
sublabel: transactional mail
description: Sends order confirmations and password resets.
@@ -0,0 +1,26 @@
schema_version: 1
diagram_type: architecture
meta:
title: System context
output: context.html
components:
- ref: crm
pos: [40, 60]
size: [130, 60]
- ref: erp
pos: [40, 260]
size: [130, 60]
- ref: platform
pos: [280, 160]
size: [170, 60]
- ref: warehouse
pos: [540, 60]
size: [150, 60]
- ref: ops
pos: [540, 260]
size: [150, 60]
connections:
- { from: crm, to: platform, label: customer events, variant: emphasis }
- { from: erp, to: platform, label: order events, variant: emphasis }
- { from: platform, to: warehouse, label: pick lists }
- { from: ops, to: platform, label: replays, variant: dashed }
@@ -0,0 +1,46 @@
schema_version: 1
diagram_type: architecture
meta:
title: Containers
output: containers.html
components:
- ref: crm
pos: [40, 60]
size: [130, 60]
- ref: erp
pos: [40, 260]
size: [130, 60]
- ref: ingest
pos: [250, 160]
size: [140, 60]
- ref: bus
pos: [470, 160]
size: [140, 60]
- ref: router
pos: [690, 160]
size: [150, 60]
- ref: dlq
pos: [690, 320]
size: [150, 60]
- ref: store
pos: [920, 60]
size: [140, 60]
- ref: notifier
pos: [920, 160]
size: [140, 60]
- ref: analytics
pos: [920, 260]
size: [140, 60]
boundaries:
- kind: region
label: Integration platform
wraps: [ingest, bus, router, dlq, store, notifier, analytics]
connections:
- { from: crm, to: ingest, label: HTTPS, variant: emphasis }
- { from: erp, to: ingest, label: HTTPS, variant: emphasis }
- { from: ingest, to: bus, label: publish }
- { from: bus, to: router, label: consume }
- { from: router, to: store, label: append }
- { from: router, to: notifier, label: notify }
- { from: router, to: analytics, label: flatten }
- { from: router, to: dlq, label: failed, variant: dashed, labelDy: 24 }
@@ -0,0 +1,46 @@
schema_version: 1
diagram_type: architecture
meta:
title: Event router components
output: router.html
components:
- ref: bus
pos: [40, 160]
size: [140, 60]
- id: validate
type: security
label: Validate
sublabel: schema check
pos: [270, 160]
size: [130, 60]
- id: enrich
type: backend
label: Enrich
sublabel: reference data
pos: [480, 160]
size: [130, 60]
- id: dispatch
type: backend
label: Dispatch
pos: [690, 160]
size: [130, 60]
- ref: store
pos: [920, 60]
size: [140, 60]
- ref: notifier
pos: [920, 160]
size: [140, 60]
- ref: dlq
pos: [270, 320]
size: [150, 60]
boundaries:
- kind: region
label: Event router
wraps: [validate, enrich, dispatch]
connections:
- { from: bus, to: validate, label: event }
- { from: validate, to: enrich, label: valid }
- { from: enrich, to: dispatch, label: enriched }
- { from: dispatch, to: store, label: append }
- { from: dispatch, to: notifier, label: notify }
- { from: validate, to: dlq, label: invalid, variant: dashed, labelDy: 24 }
@@ -0,0 +1,29 @@
schema_version: 1
model:
id: event-based-integration
name: Event-Based Integration
description: >
Systems that never call each other directly; events go through a bus,
a router validates and enriches them, consumers react. Context,
containers, the router's components and one flow, C4 style.
diagram_type: architecture
meta:
title: Event-Based Integration — overview
output: main.html
components:
- ref: crm
pos: [40, 60]
size: [130, 60]
- ref: erp
pos: [40, 260]
size: [130, 60]
- ref: platform
pos: [280, 160]
size: [170, 60]
- ref: warehouse
pos: [540, 160]
size: [150, 60]
connections:
- { from: crm, to: platform, label: customer events, variant: emphasis }
- { from: erp, to: platform, label: order events, variant: emphasis }
- { from: platform, to: warehouse, label: pick lists }
@@ -0,0 +1,40 @@
schema_version: 1
elements:
ingest:
type: backend
label: Ingest API
sublabel: HTTPS in, events out
description: Accepts events from systems that cannot publish to the bus themselves.
bus:
type: messagebus
label: Event bus
sublabel: Kafka
brand: apache-kafka
description: One topic per event type; retained for seven days.
router:
type: backend
label: Event router
sublabel: validate, enrich, dispatch
description: Checks each event against its schema, adds reference data, hands it to the consumers.
diagram: 0300-components/router.archify
dlq:
type: messagebus
label: Dead-letter queue
sublabel: failed events
description: Events the router could not process; replayed by operations.
store:
type: database
label: Event store
sublabel: PostgreSQL
brand: postgresql
description: Every accepted event, for audit and replay.
notifier:
type: backend
label: Notifier
sublabel: email, chat
description: Turns selected events into messages to people.
analytics:
type: database
label: Analytics
sublabel: warehouse tables
description: Flattened events for reporting.
@@ -0,0 +1,7 @@
schema_version: 1
elements:
ops:
type: external
label: Operations
sublabel: on-call
description: Watches the dead-letter queue and replays failed events.
@@ -0,0 +1,23 @@
schema_version: 1
elements:
platform:
type: cloud
label: Integration platform
sublabel: the system
description: Receives events from the business systems, routes them, keeps a record.
diagram: 0200-containers/platform.archify
crm:
type: external
label: CRM
sublabel: customers
description: Source of customer events; created, updated, merged.
erp:
type: external
label: ERP
sublabel: orders and stock
description: Source of order events and the target of stock updates.
warehouse:
type: external
label: Warehouse system
sublabel: fulfilment
description: Receives pick lists; reports shipments back as events.
@@ -0,0 +1,26 @@
schema_version: 1
diagram_type: architecture
meta:
title: System context
output: context.html
components:
- ref: user
pos: [40, 60]
size: [130, 60]
- ref: admin
pos: [40, 260]
size: [130, 60]
- ref: webapp
pos: [280, 160]
size: [160, 60]
- ref: idp
pos: [530, 60]
size: [150, 60]
- ref: email
pos: [530, 260]
size: [150, 60]
connections:
- { from: user, to: webapp, label: uses, variant: emphasis }
- { from: admin, to: webapp, label: administers }
- { from: webapp, to: idp, label: signs in via, variant: security }
- { from: webapp, to: email, label: sends mail }
@@ -0,0 +1,38 @@
schema_version: 1
diagram_type: architecture
meta:
title: Containers
output: containers.html
components:
- ref: user
pos: [40, 160]
size: [130, 60]
- ref: spa
pos: [250, 160]
size: [150, 60]
- ref: api
pos: [480, 160]
size: [130, 60]
- ref: idp
pos: [480, 20]
size: [130, 60]
- ref: cache
pos: [690, 60]
size: [130, 60]
- ref: db
pos: [690, 160]
size: [130, 60]
- ref: storage
pos: [690, 260]
size: [130, 60]
boundaries:
- kind: region
label: Web Application
wraps: [spa, api, cache, db, storage]
connections:
- { from: user, to: spa, label: HTTPS, variant: emphasis }
- { from: spa, to: api, label: JSON }
- { from: api, to: idp, label: verify token, variant: security, labelAt: [531, 110] }
- { from: api, to: cache, label: read }
- { from: api, to: db, label: SQL }
- { from: api, to: storage, label: files }
@@ -0,0 +1,42 @@
schema_version: 1
diagram_type: architecture
meta:
title: API components
output: api.html
components:
- ref: spa
pos: [40, 160]
size: [150, 60]
- id: auth
type: security
label: Auth
sublabel: token check
pos: [270, 40]
size: [130, 60]
- id: accounts
type: backend
label: Accounts
pos: [270, 160]
size: [130, 60]
- id: content
type: backend
label: Content
pos: [270, 280]
size: [130, 60]
- ref: db
pos: [500, 160]
size: [130, 60]
- ref: storage
pos: [500, 280]
size: [130, 60]
boundaries:
- kind: region
label: API
wraps: [auth, accounts, content]
connections:
- { from: spa, to: auth, label: token, variant: security }
- { from: spa, to: accounts, label: profile }
- { from: spa, to: content, label: pages }
- { from: accounts, to: db, label: SQL }
- { from: content, to: db, label: SQL }
- { from: content, to: storage, label: files }
@@ -0,0 +1,29 @@
schema_version: 1
model:
id: standard-web-app
name: Standard Web App
description: >
A classic web application; a single-page app, an API, a database,
a cache and file storage, with sign-in through an identity provider.
Context, containers and the API's components, C4 style.
diagram_type: architecture
meta:
title: Standard Web App — overview
output: main.html
components:
- ref: user
pos: [40, 160]
size: [130, 60]
- ref: webapp
pos: [250, 160]
size: [160, 60]
- ref: idp
pos: [490, 60]
size: [150, 60]
- ref: email
pos: [490, 260]
size: [150, 60]
connections:
- { from: user, to: webapp, label: uses, variant: emphasis }
- { from: webapp, to: idp, label: signs in via, variant: security }
- { from: webapp, to: email, label: sends mail }
@@ -0,0 +1,32 @@
schema_version: 1
elements:
spa:
type: frontend
label: Single-page app
sublabel: React
brand: react
description: The user interface; talks only to the API.
api:
type: backend
label: API
sublabel: Node.js
brand: node-js
description: Owns accounts, content and files; checks tokens from the identity provider.
diagram: 0300-components/api.archify
db:
type: database
label: Database
sublabel: PostgreSQL
brand: postgresql
description: Accounts and content; the source of truth.
cache:
type: database
label: Cache
sublabel: Redis
brand: redis
description: Sessions and hot content; safe to lose.
storage:
type: cloud
label: Object storage
sublabel: uploaded files
description: User uploads and generated exports.
@@ -0,0 +1,12 @@
schema_version: 1
elements:
user:
type: external
label: User
sublabel: browser
description: Anyone with an account; uses the application from a browser.
admin:
type: external
label: Administrator
sublabel: back office
description: Manages users and content from the same application with extra rights.
@@ -0,0 +1,18 @@
schema_version: 1
elements:
webapp:
type: cloud
label: Web Application
sublabel: the system
description: Everything we build and run; the single-page app, the API and its stores.
diagram: 0200-containers/web-app.archify
idp:
type: external
label: Identity provider
sublabel: OpenID Connect
description: Signs users in and issues tokens; we never store passwords.
email:
type: external
label: Email service
sublabel: transactional mail
description: Sends sign-up confirmations and notifications.
+93
View File
@@ -0,0 +1,93 @@
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 { validate } from '../src/archify/cli';
import { MODEL_EXAMPLES, mainFromExample } from '../src/language/examples';
import { MAIN_FILE, REGISTRY_EXT, hasDiagramBody, mergeRegistries, parseRegistry, readModelMeta, resolveRefs } from '../src/model/format';
import { readDiagramSource } from '../src/sources/detect';
const root = path.resolve(__dirname, '..', '..');
const examples = path.join(root, 'templates', 'models');
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-examples-')),
};
function walk(dir: string, rel = ''): string[] {
return fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap((entry) => {
const next = rel ? `${rel}/${entry.name}` : entry.name;
return entry.isDirectory() ? walk(dir, next) : [next];
});
}
test('every example folder is listed, every listed example exists, labels are the three C4 examples', () => {
const folders = fs.readdirSync(examples, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort();
assert.deepEqual(MODEL_EXAMPLES.map((e) => e.folder).sort(), folders);
assert.deepEqual(
MODEL_EXAMPLES.map((e) => e.label),
['C4 - Standard Web App', 'C4 - E-Shop', 'C4 - Event-Based Integration'],
);
});
for (const example of MODEL_EXAMPLES) {
test(`example ${example.folder} is a complete model that validates`, async () => {
const dir = path.join(examples, example.folder);
const files = walk(dir);
const read = (rel: string) => fs.readFileSync(path.join(dir, rel), 'utf8');
// main.archify: model block with id/name/description and an overview diagram.
assert.ok(files.includes(MAIN_FILE));
const main = parse(read(MAIN_FILE));
const meta = readModelMeta(main);
assert.equal(meta?.id, example.folder.replace(/^c4-/, ''));
assert.ok(meta?.name && meta.description);
assert.ok(hasDiagramBody(main));
// Registry: at least people, systems and containers; no problems.
const registryFiles = files.filter((f) => f.endsWith(REGISTRY_EXT)).sort();
assert.ok(registryFiles.length >= 3, `registry files: ${registryFiles.join(', ')}`);
const registry = mergeRegistries(registryFiles.map((file) => ({ file, ...parseRegistry(read(file), file) })));
assert.deepEqual(registry.problems, []);
for (const element of registry.elements.values()) {
assert.ok(element.type && element.label && element.description, `${element.id} needs type, label and description`);
if (element.diagram) assert.ok(files.includes(element.diagram), `${element.id} links to missing ${element.diagram}`);
}
// Diagrams: the three C4 levels, each resolving without problems and valid for Archify.
const diagrams = files.filter((f) => f.endsWith('.archify')).sort();
for (const level of ['0100-context', '0200-containers', '0300-components']) {
assert.ok(diagrams.some((d) => d.startsWith(`${level}/`)), `missing ${level}`);
}
const used = new Set<string>();
for (const file of diagrams) {
const source = readDiagramSource(read(file), file);
assert.ok(source, `${file} is not a diagram`);
const resolved = resolveRefs(source.diagram, { registry, fileName: file });
assert.deepEqual(resolved.problems, [], file);
for (const link of resolved.links.values()) assert.ok(files.includes(link), `${file} links to missing ${link}`);
for (const id of resolved.used) used.add(id);
const result = await validate(runtime, source.type, JSON.stringify(resolved.diagram));
assert.deepEqual(result.diagnostics.filter((d) => d.severity === 'error').map((d) => `${file}: ${d.code}: ${d.message}`), []);
assert.ok(result.ok, file);
}
// Every registry element is used by some diagram.
for (const id of registry.elements.keys()) assert.ok(used.has(id), `element ${id} is never used`);
});
}
test('mainFromExample keeps the example as written, or renames it after the folder', () => {
const example = MODEL_EXAMPLES[1];
const text = fs.readFileSync(path.join(examples, example.folder, MAIN_FILE), 'utf8');
assert.equal(mainFromExample(text, example.folder, example), text);
const renamed = parse(mainFromExample(text, 'shop two', example));
assert.equal(renamed.model.id, 'shop-two');
assert.equal(renamed.model.name, 'Shop two');
assert.equal(renamed.meta.title, 'Shop two — overview');
assert.equal(renamed.model.description, parse(text).model.description);
assert.deepEqual(renamed.components, parse(text).components);
});
+47
View File
@@ -340,4 +340,51 @@ export async function run(): Promise<void> {
assert.match(content, /id: new-shop/);
assert.match(content, /name: New shop/);
});
await check('Archify | New Model… creates a model folder from an example and opens its page', async () => {
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes.commands as { command: string; title: string }[];
assert.equal(commands.find((item) => item.command === 'archify.newModel')?.title, 'Archify | New Model…');
const parent = workspaceFile('.');
const window = vscode.window as unknown as Record<string, unknown>;
const originalPick = window.showQuickPick;
const originalInput = window.showInputBox;
let labels: string[] = [];
let existingNameMessage: unknown;
window.showQuickPick = async (items: readonly { label: string }[]) => {
labels = (await items).map((item) => item.label);
return (await items).find((item) => item.label === 'C4 - E-Shop');
};
window.showInputBox = async (options: vscode.InputBoxOptions) => {
existingNameMessage = await options.validateInput?.('new-shop');
return 'shop-two';
};
try {
await vscode.commands.executeCommand('archify.newModel', parent);
} finally {
window.showQuickPick = originalPick;
window.showInputBox = originalInput;
}
assert.deepEqual(labels, ['C4 - Standard Web App', 'C4 - E-Shop', 'C4 - Event-Based Integration']);
assert.match(String(existingNameMessage), /already exists/);
const folder = vscode.Uri.joinPath(parent, 'shop-two');
const main = vscode.Uri.joinPath(folder, 'main.archify');
const content = new TextDecoder().decode(await vscode.workspace.fs.readFile(main));
assert.match(content, /id: shop-two/);
assert.match(content, /name: Shop two/);
assert.match(content, /title: Shop two — overview/);
const names = (await vscode.workspace.fs.readDirectory(folder)).map(([name]) => name).sort();
assert.deepEqual(names, ['0100-context', '0200-containers', '0300-components', 'main.archify', 'registry']);
const registry = (await vscode.workspace.fs.readDirectory(vscode.Uri.joinPath(folder, 'registry'))).map(([name]) => name).sort();
assert.deepEqual(registry, ['containers.archify-registry', 'people.archify-registry', 'systems.archify-registry']);
const tab = await waitFor('model page tab', () =>
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.uri.toString() === main.toString()),
);
assert.ok(tab);
// The new model is indexed: its diagrams resolve against the copied registry.
const containers = await vscode.workspace.openTextDocument(vscode.Uri.joinPath(folder, '0200-containers', 'online-shop.archify'));
await vscode.window.showTextDocument(containers);
await new Promise((resolve) => setTimeout(resolve, 1500));
const errors = vscode.languages.getDiagnostics(containers.uri).filter((d) => d.severity === vscode.DiagnosticSeverity.Error);
assert.deepEqual(errors.map((d) => d.message), []);
});
}