Compare commits

..
18 Commits
Author SHA1 Message Date
root-at-skic 97b3b5f685 Add a 3D view of every diagram; organise source by job; 0.6.0
3D view: a "3D" menu beside Export switches the diagram to a three.js
scene built from the live Archify SVG. Nodes become blocks topped with
the node as Archify draws it, relationships become lit pipes along
Archify's routes, and the canvas, frames, lanes and legend form the
floor. Clicks and hover go to the SVG nodes, so focus, the Semantic
Passport and the intent trace work as in 2D; Archify's own animations
(trace, node pulse, passport/path/lens flows) are mirrored live. The
menu also offers flat or stacked levels, reset view and a top view;
Export gains "3D view" (PNG). Trackpad scrolling pans, pinch and the
mouse wheel zoom, and the zoom buttons work beyond the 2D limits.
The scene draws only when something moves. New setting
archify.defaultView.

Source: src/ is split by job (commands, views, webview, archify,
sources, language, util, viewer3d); behaviour unchanged.
2026-10-11 00:54:00 +03:00
root-at-skic 20786f4b5a Merge pull request 'Release 0.5.2: Archify menu section at the bottom, commands named "Archify | …"' (#18) from fix/archify-menu-section into main 2026-10-10 05:04:55 -07:00
root-at-skic 0b536f3e10 Name all commands "Archify | …", keep the menu section last; 0.5.2
All eight commands drop the Archify category and carry the prefix in the
title, so menus, the editor title bar and the Command Palette read the
same. The right-click group is zzz_archify: VS Code has a built-in
z_commands group that would otherwise sort after it.
2026-10-10 15:04:52 +03:00
root-at-skic bc294dc6bd Rename the new-diagram command to Archify | New Diagram… 2026-10-10 13:49:27 +03:00
root-at-skic 1a5482b4bf Put Archify right-click entries in their own section at the bottom
Explorer and editor-tab context menus: the Archify commands used the
built-in navigation group and were mixed with New File, New Folder and
the rest. They now use a z_archify group, which VS Code sorts last and
separates with a divider.
2026-10-10 13:47:57 +03:00
root-at-skic ac9200ccdd Merge pull request 'Release 0.5.1: describe .archify (YAML) as the diagram format everywhere' (#17) from docs/clarify-formats into main 2026-10-07 02:55:37 -07:00
root-at-skic 9871f774f4 Describe .archify (YAML) as the diagram format everywhere (0.5.1)
- Marketplace description, README and messages lead with .archify files
  in YAML; Archify's JSON files and rendered HTML are described as also
  supported, in their own README section and a supported-files table
- Rename the rendered-HTML editor to Archify HTML Viewer (it shared the
  extension's name) and its command to Open in Archify HTML Viewer
- Empty or broken .archify files ask for valid YAML instead of JSON
2026-10-07 12:55:27 +03:00
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
53 changed files with 4062 additions and 565 deletions

No files matched your search

+41
View File
@@ -1,5 +1,46 @@
# Changelog
## 0.6.0 — 2026-10-11
- **3D view:** a **3D ▾** menu in the diagram toolbar, styled like Archify's own menus, switches any diagram to 3D. Nodes become raised blocks topped with the node as Archify draws it, relationships become lit pipes along Archify's routes, and the canvas, frames, lanes and legend form the floor. Works for all five diagram kinds, in every theme and style.
- The menu also holds **Levels** (*Flat*, or *Stacked* by kind) and **Camera** (*Reset view*, *From above*).
- **Animation follows 2D:** the 3D view mirrors Archify's own animations live: the trace when the diagram opens (replayed when 3D is chosen), node pulses, the hover trace, and the Semantic Passport, Path and Lens flows. *Live* off and reduced motion keep it still.
- In 3D, clicking a block focuses it as in 2D (Semantic Passport, fading); hover shows the one-hop trace; trackpad scrolling pans, pinch and the mouse wheel zoom; the zoom buttons and <kbd>+</kbd>/<kbd>-</kbd>/<kbd>0</kbd> move the camera; **Export → 3D view** saves a PNG.
- **Performance:** the 3D view draws only when something moves (no GPU use while idle), uses lighter textures and pixel density, and the hidden 2D diagram is no longer painted. Hover and animations never rebuild textures.
- New setting `archify.defaultView` (`2d` or `3d`). Each open diagram remembers its view, camera and levels.
- The source code is reorganised by job (`commands/`, `views/`, `webview/`, `archify/`, `sources/`, `language/`, `util/`); no change in behaviour.
## 0.5.2 — 2026-10-10
- **Right-click menus:** the Archify commands (Archify | New Diagram…, Archify | Open Preview, Archify | Render to HTML File…, Archify | Open in HTML Viewer) now sit in their own section at the very bottom of the Explorer and editor-tab menus, instead of between New File, New Folder and the other built-in entries.
- **All commands are named "Archify | …"**, the same in right-click menus, the editor title bar and the Command Palette: Archify | New Diagram… (was *New Diagram Archify…*), Archify | Open Preview, Archify | Open Preview to the Side, Archify | Render to HTML File…, Archify | Open in HTML Viewer, Archify | Open in Browser, Archify | Refresh Preview, Archify | Show Source.
## 0.5.1 — 2026-10-07
- **Clearer descriptions:** the Marketplace description, README and messages now say plainly that diagrams are `.archify` files in YAML. Archify's own JSON files (`*.architecture.json`, …) and rendered HTML are described as also supported, in their own section.
- The rendered-HTML editor is now called **Archify HTML Viewer** (was *Archify Diagram Viewer*, the same name as the extension), and its command **Open in Archify HTML Viewer**.
- Messages: "open a .archify diagram first"; an empty or broken `.archify` file asks for valid YAML, not JSON.
## 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
- `.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`.
+89 -26
View File
@@ -1,63 +1,112 @@
# 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.
Create and view [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. You write a diagram as a **`.archify` file in YAML**; clicking it opens the interactive diagram, and the diagram follows your edits as you type.
![An Archify workflow diagram as shown by the preview](docs/preview.png)
This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i) and runs it on VS Code's own Node runtime, so nothing else needs to be installed.
## Supported files
| File | Format | What the extension does |
|---|---|---|
| **`name.archify`** | **YAML** (the main format) | Opens as the interactive diagram. Show Source, live updates, validation, completion, templates. |
| `name.architecture.json`, `name.workflow.json`, `name.sequence.json`, `name.dataflow.json`, `name.lifecycle.json` | JSON (Archify's own CLI format) | Opens as text, with a preview beside it and validation. For files produced by the Archify CLI or AI agents. |
| Rendered Archify `.html` | HTML | Read-only viewer instead of a browser. |
New diagrams should be `.archify` files; everything below describes them unless it says otherwise.
## Quick start
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.
To write one by hand:
1. Create a file ending in `.archify`, for example `hello.archify`:
```json
{
"schema_version": 1,
"diagram_type": "architecture",
"meta": { "title": "Hello Archify", "output": "hello.html" },
"components": [
{ "id": "browser", "type": "frontend", "label": "Browser", "pos": [40, 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": [
{ "from": "browser", "to": "api", "label": "HTTPS" },
{ "from": "api", "to": "db", "label": "SQL" }
]
}
```yaml
schema_version: 1
diagram_type: architecture
meta:
title: Hello Archify
output: hello.html
components:
- { id: browser, type: frontend, label: Browser, pos: [40, 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:
- { from: browser, to: api, label: HTTPS }
- { 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 `.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 written in **YAML**. Its fields are those of Archify's diagram schema; the extension converts the YAML for the renderer, so you never handle JSON yourself. `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` files from extension version 0.2.0 were written in JSON. They still open, because JSON is valid YAML, but new files should use YAML.
## Archify's JSON files
The Archify CLI and the AI agents that use it write diagrams as JSON, named `name.architecture.json`, `name.workflow.json` and so on. The extension supports these too: they open as text with a preview button in the editor title (<kbd>Cmd/Ctrl+K V</kbd> to the side, <kbd>Cmd/Ctrl+Shift+V</kbd> in place), get validation in Problems, and get schema completion from VS Code's built-in JSON support. Any other `.json` file that declares an Archify `diagram_type` previews the same way.
## 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.
- **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.
- **Schema help**: completion and hover documentation come from Archify's own JSON schemas. In `.archify` files, the schema follows `diagram_type`.
- **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.
- **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.
- **Problems**: YAML syntax errors, plus `archify validate`'s schema, layout and evidence problems, appear at the matching line of the `.archify` file.
- **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 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: Render to HTML File…*, *Archify: Open in Browser*, *Archify: Refresh Preview*, *Archify: Show Source*.
- **Commands**: *Archify | New Diagram…*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*.
## 3D view
Every diagram can also be seen in 3D. Open the **3D ▾** menu next to **Export** in the diagram's toolbar and choose **3D**; choose **2D** to go back. The button wears a cyan ring while the 3D view is on.
![The same diagram in the 3D view](docs/preview-3d.png)
The 3D view is built from the diagram Archify draws, so it looks the same: each node becomes a raised block whose top is the node as Archify draws it, relationships become pipes along Archify's routes with their colours, dashes and arrowheads, and the canvas, frames, lanes and legend form the floor. It works for all five diagram kinds, in every theme and style (Classic, Signal Flow, Blueprint, Editorial).
The menu has three groups:
- **View**: 2D or 3D.
- **Levels**: *Flat* keeps every node on the floor; *Stacked* lifts nodes into levels by kind: clients and external systems on top, security below them, services in the middle, data on the floor.
- **Camera**: *Reset view* (or <kbd>0</kbd>) flies back to the whole diagram, wherever you have moved to; *From above* looks straight down, like 2D.
Moving around:
- Drag to turn, right-drag to move. On a trackpad, scroll with two fingers to move in any direction and pinch to zoom; a mouse wheel zooms. The diagram's zoom buttons and the <kbd>+</kbd> and <kbd>-</kbd> keys work too, beyond the 2D limits.
- Click a block to focus it, as in 2D: the Semantic Passport opens and unrelated parts fade. Hover a block to see its one-hop trace. Double-click a block to fly to it, or empty space to show everything.
Animation follows 2D exactly, because the 3D view reads Archify's own animations as they play: the trace (relationships drawing in, nodes pulsing) when the diagram opens or 3D is chosen, the hover trace, and the flows from the Semantic Passport, Path and Lens. *Live* off, or reduced motion, keeps it still.
**Export → 3D view** saves the current 3D view as a PNG. Each open diagram remembers whether it was in 3D, its camera and its levels; set `archify.defaultView` to `3d` to open diagrams in 3D.
The 3D view draws only when something moves, so an idle diagram costs no GPU time.
## Source evidence
A diagram that declares `meta.repository` and `sources` is verified against a local Git checkout whose `origin` and pinned commit match. The extension uses the Git top-level directory of the diagram file, or `archify.repoRoot` when set.
When verification fails, the default (`archify.sourceEvidence: "fallback"`) still shows the diagram without source links, explains why in a notice, and reports the reason as a warning. Set it to `"strict"` to treat this as an error, as the Archify CLI does. *Render to HTML File…* always verifies strictly and asks before rendering without source links.
When verification fails, the default (`archify.sourceEvidence: "fallback"`) still shows the diagram without source links, explains why in a notice, and reports the reason as a warning. Set it to `"strict"` to treat this as an error, as the Archify CLI does. *Archify | Render to HTML File…* always verifies strictly and asks before rendering without source links.
## Settings
| Setting | Default | Description |
|---|---|---|
| `archify.theme` | `vscode` | `vscode` follows the color theme; `dark` or `light` force one; `diagram` keeps the diagram's own choice. |
| `archify.defaultView` | `2d` | `3d` opens diagrams in the 3D view. Each open diagram remembers its own choice. |
| `archify.preview.liveUpdate` | `true` | Re-render while typing; when off, re-render on save. |
| `archify.preview.debounceMs` | `400` | Delay before re-rendering after an edit. |
| `archify.validation.enabled` | `true` | Report validation results in Problems. |
@@ -68,7 +117,7 @@ When verification fails, the default (`archify.sourceEvidence: "fallback"`) stil
## Security
Diagrams run in a webview with a strict content security policy. Only the page's own inline scripts run (via a per-render nonce), there is no network access for scripts or fetches, and links open through VS Code.
Diagrams run in a webview with a strict content security policy. Only the page's own inline scripts and the extension's bundled 3D viewer run (via a per-render nonce), there is no network access for scripts or fetches, and links open through VS Code.
## Development
@@ -82,6 +131,20 @@ npm run package # build archify-vscode-ext-<version>.vsix
Press <kbd>F5</kbd> to launch an Extension Development Host with the test fixtures.
### Code layout
| Folder | What it holds |
|---|---|
| `src/extension.ts` | Activation: creates the views and registers everything |
| `src/commands/` | Command handlers, grouped by job, and the editor context keys |
| `src/views/` | The diagram view (editor for `.archify`, side preview for JSON) and the HTML viewer |
| `src/webview/` | The page the views show: CSP, the bridge script, messages from the page |
| `src/archify/` | Running the bundled Archify CLI: render, validate, runtime settings |
| `src/sources/` | Reading diagram files: type detection, YAML/JSON, source evidence |
| `src/language/` | Editor help: Problems, brand completion, templates |
| `src/util/` | Logging, settings, file reading |
| `src/viewer3d/` | The 3D view; runs inside the Archify page and is bundled to `dist/viewer3d.js` with three.js |
### Updating the bundled renderer
`vendor/archify/` and `schemas/` are generated from an Archify checkout:
Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

+20 -9
View File
@@ -33,16 +33,27 @@ if (tests) {
sourcemap: true,
});
} else {
const ctx = await esbuild.context({
...common,
entryPoints: ['src/extension.ts'],
outfile: 'dist/extension.js',
external: ['vscode'],
});
const contexts = await Promise.all([
esbuild.context({
...common,
entryPoints: ['src/extension.ts'],
outfile: 'dist/extension.js',
external: ['vscode'],
}),
// Runs inside the Archify page in the webview; three.js is bundled in.
esbuild.context({
...common,
format: 'iife',
platform: 'browser',
target: 'es2022',
entryPoints: ['src/viewer3d/index.ts'],
outfile: 'dist/viewer3d.js',
}),
]);
if (watch) {
await ctx.watch();
await Promise.all(contexts.map((ctx) => ctx.watch()));
} else {
await ctx.rebuild();
await ctx.dispose();
await Promise.all(contexts.map((ctx) => ctx.rebuild()));
await Promise.all(contexts.map((ctx) => ctx.dispose()));
}
}
+28 -17
View File
@@ -1,17 +1,28 @@
{
"schema_version": 1,
"diagram_type": "architecture",
"meta": {
"title": "Hello Archify",
"output": "hello.html"
},
"components": [
{ "id": "browser", "type": "frontend", "label": "Browser", "pos": [40, 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": [
{ "from": "browser", "to": "api", "label": "HTTPS" },
{ "from": "api", "to": "db", "label": "SQL" }
]
}
schema_version: 1
diagram_type: architecture
meta:
title: Hello Archify
output: hello.html
components:
- id: browser
type: frontend
label: Browser
pos: [40, 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:
- from: browser
to: api
label: HTTPS
- from: api
to: db
label: SQL
+85 -2
View File
@@ -1,15 +1,20 @@
{
"name": "archify-vscode-ext",
"version": "0.2.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "archify-vscode-ext",
"version": "0.2.0",
"version": "0.6.0",
"license": "MIT",
"dependencies": {
"three": "^0.186.1",
"yaml": "^2.9.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/three": "^0.186.0",
"@types/vscode": "~1.100.0",
"@vscode/test-electron": "^3.1.0",
"@vscode/vsce": "^3.0.0",
@@ -243,6 +248,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -996,6 +1008,13 @@
"@textlint/ast-node-types": "15.8.0"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.20.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz",
@@ -1020,6 +1039,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.186.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz",
"integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.3",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/vscode": {
"version": "1.100.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.100.0.tgz",
@@ -1027,6 +1068,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@typespec/ts-http-runtime": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz",
@@ -2159,6 +2207,13 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -3094,6 +3149,13 @@
"node": ">= 8"
}
},
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"dev": true,
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -4420,6 +4482,12 @@
"url": "https://bevry.me/fund"
}
},
"node_modules/three": {
"version": "0.186.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
"integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
@@ -4684,6 +4752,21 @@
"dev": true,
"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": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
+72 -32
View File
@@ -1,8 +1,8 @@
{
"name": "archify-vscode-ext",
"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.",
"version": "0.2.0",
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.",
"version": "0.6.0",
"publisher": "root-at-skic",
"license": "MIT",
"icon": "media/icon.png",
@@ -23,6 +23,7 @@
],
"keywords": [
"archify",
"yaml",
"diagram",
"architecture",
"sequence",
@@ -38,46 +39,44 @@
],
"contributes": {
"commands": [
{
"command": "archify.newDiagram",
"title": "Archify | New Diagram…",
"icon": "$(new-file)"
},
{
"command": "archify.showPreview",
"title": "Open Preview",
"category": "Archify",
"title": "Archify | Open Preview",
"icon": "$(open-preview)"
},
{
"command": "archify.showPreviewToSide",
"title": "Open Preview to the Side",
"category": "Archify",
"title": "Archify | Open Preview to the Side",
"icon": "$(open-preview)"
},
{
"command": "archify.openHtmlViewer",
"title": "Open in Archify Viewer",
"category": "Archify",
"title": "Archify | Open in HTML Viewer",
"icon": "$(open-preview)"
},
{
"command": "archify.renderToHtml",
"title": "Render to HTML File\u2026",
"category": "Archify",
"title": "Archify | Render to HTML File…",
"icon": "$(file-code)"
},
{
"command": "archify.openInBrowser",
"title": "Open in Browser",
"category": "Archify",
"title": "Archify | Open in Browser",
"icon": "$(globe)"
},
{
"command": "archify.refreshPreview",
"title": "Refresh Preview",
"category": "Archify",
"title": "Archify | Refresh Preview",
"icon": "$(refresh)"
},
{
"command": "archify.showSource",
"title": "Show Source",
"category": "Archify",
"title": "Archify | Show Source",
"icon": "$(go-to-file)"
}
],
@@ -97,19 +96,19 @@
},
{
"command": "archify.renderToHtml",
"when": "archify.isDiagramSource || archifyPreviewFocus"
"when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram'"
},
{
"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",
"when": "archifyPreviewFocus"
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.diagram'"
},
{
"command": "archify.showSource",
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'"
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'"
}
],
"editor/title": [
@@ -130,7 +129,7 @@
},
{
"command": "archify.showSource",
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'",
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'",
"group": "navigation"
},
{
@@ -145,27 +144,38 @@
}
],
"explorer/context": [
{
"command": "archify.newDiagram",
"when": "explorerResourceIsFolder",
"group": "zzz_archify@1"
},
{
"command": "archify.showPreview",
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
"group": "navigation@20"
"group": "zzz_archify@2"
},
{
"command": "archify.renderToHtml",
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
"group": "navigation@21"
"group": "zzz_archify@3"
},
{
"command": "archify.openHtmlViewer",
"when": "resourceExtname == .html",
"group": "navigation@20"
"group": "zzz_archify@2"
}
],
"editor/title/context": [
{
"command": "archify.showPreview",
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
"group": "1_open"
"group": "zzz_archify@1"
}
],
"file/newFile": [
{
"command": "archify.newDiagram",
"group": "file"
}
]
},
@@ -184,9 +194,19 @@
}
],
"customEditors": [
{
"viewType": "archify.diagram",
"displayName": "Archify Diagram",
"selector": [
{
"filenamePattern": "*.archify"
}
],
"priority": "default"
},
{
"viewType": "archify.htmlViewer",
"displayName": "Archify Diagram Viewer",
"displayName": "Archify HTML Viewer",
"selector": [
{
"filenamePattern": "*.html"
@@ -196,10 +216,6 @@
}
],
"jsonValidation": [
{
"fileMatch": "*.archify",
"url": "./schemas/archify.schema.json"
},
{
"fileMatch": "*.architecture.json",
"url": "./schemas/architecture.schema.json"
@@ -241,6 +257,19 @@
"default": "vscode",
"description": "Theme used when showing Archify diagrams."
},
"archify.defaultView": {
"type": "string",
"enum": [
"2d",
"3d"
],
"enumDescriptions": [
"Open diagrams as Archify draws them.",
"Open diagrams in the 3D view. The 3D button in the diagram toolbar switches back."
],
"default": "2d",
"description": "How diagrams open. Each open diagram remembers its own choice."
},
"archify.preview.liveUpdate": {
"type": "boolean",
"default": true,
@@ -299,11 +328,17 @@
},
"languages": [
{
"id": "json",
"id": "yaml",
"extensions": [
".archify"
]
}
],
"yamlValidation": [
{
"fileMatch": "*.archify",
"url": "./schemas/archify.schema.json"
}
]
},
"scripts": {
@@ -318,6 +353,7 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/three": "^0.186.0",
"@types/vscode": "~1.100.0",
"@vscode/test-electron": "^3.1.0",
"@vscode/vsce": "^3.0.0",
@@ -329,5 +365,9 @@
"bugs": {
"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": {
"three": "^0.186.1",
"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}`);
}
+6 -5
View File
@@ -1,7 +1,8 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { DiagramSource, DiagramType, hasSourceEvidence, stripSourceEvidence } from './detect';
import { DiagramSource, DiagramType } from '../sources/detect';
import { hasSourceEvidence, stripSourceEvidence } from '../sources/evidence';
export interface RuntimeOptions {
/** Absolute path to vendor/archify/bin/archify.mjs. */
@@ -144,7 +145,7 @@ async function renderOnce(
*/
export async function renderDiagram(
options: RuntimeOptions,
source: DiagramSource & { text: string },
source: DiagramSource,
evidence: 'fallback' | 'strict',
): Promise<RenderResult> {
const withEvidence = hasSourceEvidence(source.type, source.diagram);
@@ -156,7 +157,7 @@ export async function renderDiagram(
evidenceNotice: 'Source links are hidden: the diagram is not inside a Git checkout, so its source evidence cannot be verified. Set "archify.repoRoot" to the matching checkout.',
};
}
const first = await renderOnce(options, source.type, source.text, withEvidence);
const first = await renderOnce(options, source.type, source.json, withEvidence);
if (first.ok || !withEvidence || evidence !== 'fallback' || !isEvidenceError(first.error)) {
return first;
}
@@ -173,13 +174,13 @@ export async function renderDiagram(
*/
export async function renderToFile(
options: RuntimeOptions,
source: DiagramSource & { text: string },
source: DiagramSource,
outputPath: string,
stripEvidence = false,
): Promise<RenderResult> {
const text = stripEvidence
? JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2)
: source.text;
: source.json;
const withEvidence = !stripEvidence && hasSourceEvidence(source.type, source.diagram);
return withScratch(options, async (dir) => {
const input = path.join(dir, `diagram.${source.type}.json`);
+2 -24
View File
@@ -1,15 +1,8 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import * as vscode from 'vscode';
import { RuntimeOptions } from './renderer';
import { ViewerTheme } from './webviewHtml';
let output: vscode.LogOutputChannel | undefined;
export function log(): vscode.LogOutputChannel {
output ??= vscode.window.createOutputChannel('Archify', { log: true });
return output;
}
import { log } from '../util/log';
import { RuntimeOptions } from './cli';
const gitRoots = new Map<string, Promise<string | undefined>>();
@@ -60,18 +53,3 @@ export async function runtimeFor(context: vscode.ExtensionContext, uri: vscode.U
export function evidenceMode(uri: vscode.Uri | undefined): 'fallback' | 'strict' {
return vscode.workspace.getConfiguration('archify', uri).get<'fallback' | 'strict'>('sourceEvidence', 'fallback');
}
export function viewerTheme(): ViewerTheme {
const setting = vscode.workspace.getConfiguration('archify').get<string>('theme', 'vscode');
if (setting === 'dark' || setting === 'light') return setting;
if (setting === 'diagram') return undefined;
const kind = vscode.window.activeColorTheme.kind;
return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? 'light' : 'dark';
}
/** Current text of a document: the open editor buffer, else the file on disk. */
export async function readText(uri: vscode.Uri): Promise<string> {
const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString());
if (open) return open.getText();
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
}
+38
View File
@@ -0,0 +1,38 @@
import * as vscode from 'vscode';
import { HTML_VIEW_TYPE, HtmlViewerProvider } from '../views/htmlViewer';
import { DiagramViewManager } from '../views/diagramView';
/** What command handlers share: the extension context and the open views. */
export interface CommandContext {
extension: vscode.ExtensionContext;
views: DiagramViewManager;
htmlViewer: HtmlViewerProvider;
}
export function isHtmlUri(uri: vscode.Uri): boolean {
return /\.html?$/i.test(uri.path);
}
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
export function targetUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
if (arg instanceof vscode.Uri) return arg;
return ctx.views.activePreview?.uri ?? ctx.htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
}
export function sourceUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
const uri = targetUri(ctx, arg);
if (!uri) vscode.window.showWarningMessage('Archify: open a .archify diagram first.');
return uri;
}
export const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
export async function exists(uri: vscode.Uri): Promise<boolean> {
try {
await vscode.workspace.fs.stat(uri);
return true;
} catch {
return false;
}
}
+34
View File
@@ -0,0 +1,34 @@
import * as vscode from 'vscode';
import { clearGitRootCache } from '../archify/runtime';
import { isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from '../sources/detect';
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
/** Keeps the archify.* context keys (editor title buttons, keybindings) up to date. */
export function registerContextKeys(context: vscode.ExtensionContext): void {
let contextTimer: NodeJS.Timeout | undefined;
const updateContext = () => {
const doc = vscode.window.activeTextEditor?.document;
const diagram = Boolean(
doc &&
(isArchifyFile(doc.fileName) ||
(JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)))),
);
const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
void vscode.commands.executeCommand('setContext', 'archify.isArchifyHtml', html);
};
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(updateContext),
vscode.workspace.onDidChangeTextDocument((event) => {
if (event.document !== vscode.window.activeTextEditor?.document) return;
clearTimeout(contextTimer);
contextTimer = setTimeout(updateContext, 300);
}),
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('archify.repoRoot')) clearGitRootCache();
}),
{ dispose: () => clearTimeout(contextTimer) },
);
updateContext();
}
+59
View File
@@ -0,0 +1,59 @@
import * as vscode from 'vscode';
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES } from '../language/templates';
import { diagramBaseName, isArchifyFile } from '../sources/detect';
import { readText } from '../util/files';
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
import { CommandContext, exists } from './context';
/** 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;
}
export function newDiagramCommands(ctx: CommandContext): vscode.Disposable[] {
return [
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: 'Archify | New Diagram', 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: 'Archify | New Diagram',
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: 'Archify | New Diagram',
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(ctx.extension.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);
}),
];
}
+90
View File
@@ -0,0 +1,90 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import * as vscode from 'vscode';
import { isEvidenceError, renderDiagram, renderToFile } from '../archify/cli';
import { evidenceMode, runtimeFor } from '../archify/runtime';
import { diagramBaseName, readDiagramSource } from '../sources/detect';
import { log } from '../util/log';
import { readText } from '../util/files';
import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context';
export function renderCommands(ctx: CommandContext): vscode.Disposable[] {
return [
vscode.commands.registerCommand('archify.renderToHtml', async (arg?: unknown) => {
const uri = sourceUri(ctx, arg);
if (!uri) return;
const text = await readText(uri);
const source = readDiagramSource(text, uri.path);
if (!source) {
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
return;
}
const meta = (source.diagram.meta ?? {}) as { output?: unknown };
const authored = typeof meta.output === 'string' && !/[\\/]/.test(meta.output) ? meta.output : undefined;
const fileName = authored ?? `${diagramBaseName(uri.path)}.html`;
const defaultUri = uri.scheme === 'file' ? vscode.Uri.joinPath(uri, '..', fileName) : undefined;
const target = await vscode.window.showSaveDialog({
defaultUri,
filters: { HTML: ['html'] },
saveLabel: 'Render',
});
if (!target) return;
const runtime = await runtimeFor(ctx.extension, uri);
let result = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
() => renderToFile(runtime, source, target.fsPath),
);
if (!result.ok && isEvidenceError(result.error)) {
const choice = await vscode.window.showWarningMessage(
'Archify could not verify the diagram\'s source evidence against a local checkout.',
{ modal: true, detail: result.error },
'Render Without Source Links',
);
if (choice) result = await renderToFile(runtime, source, target.fsPath, true);
else return;
}
if (!result.ok) {
log().error(result.error ?? 'render failed');
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
return;
}
const choice = await vscode.window.showInformationMessage(
`Rendered ${path.basename(target.fsPath)}`,
'Open in Viewer',
'Open in Browser',
);
if (choice === 'Open in Viewer') await openHtmlViewer(target);
if (choice === 'Open in Browser') await vscode.env.openExternal(target);
}),
vscode.commands.registerCommand('archify.openInBrowser', async (arg?: unknown) => {
const uri = sourceUri(ctx, arg);
if (!uri) return;
if (isHtmlUri(uri)) {
await vscode.env.openExternal(uri);
return;
}
const text = await readText(uri);
const source = readDiagramSource(text, uri.path);
if (!source) {
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
return;
}
const runtime = await runtimeFor(ctx.extension, uri);
const result = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
() => renderDiagram(runtime, source, evidenceMode(uri)),
);
if (!result.ok || !result.html) {
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
return;
}
const dir = path.join(ctx.extension.globalStorageUri.fsPath, 'browser');
await fs.mkdir(dir, { recursive: true });
const file = path.join(dir, `${diagramBaseName(uri.path)}.html`);
await fs.writeFile(file, result.html, 'utf8');
await vscode.env.openExternal(vscode.Uri.file(file));
}),
];
}
+52
View File
@@ -0,0 +1,52 @@
import * as vscode from 'vscode';
import { isArchifyFile } from '../sources/detect';
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context';
export function viewCommands(ctx: CommandContext): vscode.Disposable[] {
return [
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
const uri = sourceUri(ctx, arg);
if (!uri) return;
if (isHtmlUri(uri)) return openHtmlViewer(uri);
if (isArchifyFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE);
ctx.views.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
}),
vscode.commands.registerCommand('archify.showPreviewToSide', (arg?: unknown) => {
const uri = sourceUri(ctx, arg);
if (!uri) return;
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside);
if (isArchifyFile(uri.path)) {
return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE, {
viewColumn: vscode.ViewColumn.Beside,
preserveFocus: true,
});
}
ctx.views.show(uri, vscode.ViewColumn.Beside, true);
}),
vscode.commands.registerCommand('archify.openHtmlViewer', (arg?: unknown) => {
const uri = sourceUri(ctx, arg);
if (uri) return openHtmlViewer(uri);
}),
vscode.commands.registerCommand('archify.refreshPreview', () => ctx.views.activePreview?.update(true)),
vscode.commands.registerCommand('archify.showSource', async () => {
const preview = ctx.views.activePreview;
if (preview?.isEditor) {
// The diagram is the editor; open the YAML text beside it.
await vscode.commands.executeCommand('vscode.openWith', preview.uri, 'default', vscode.ViewColumn.Beside);
return;
}
if (preview) {
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
return;
}
const html = ctx.htmlViewer.activeUri;
if (html) await vscode.commands.executeCommand('vscode.openWith', html, 'default');
}),
];
}
+23 -165
View File
@@ -1,174 +1,32 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import * as vscode from 'vscode';
import { diagramBaseName, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect';
import { DiagnosticsController } from './diagnostics';
import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
import { PreviewManager } from './preview';
import { isEvidenceError, renderDiagram, renderToFile } from './renderer';
import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime';
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
function isHtmlUri(uri: vscode.Uri): boolean {
return /\.html?$/i.test(uri.path);
}
import { CommandContext } from './commands/context';
import { registerContextKeys } from './commands/contextKeys';
import { newDiagramCommands } from './commands/newDiagram';
import { renderCommands } from './commands/renderCommands';
import { viewCommands } from './commands/viewCommands';
import { BRAND_COMPLETION_SELECTOR, BrandCompletionProvider } from './language/brandCompletion';
import { DiagnosticsController } from './language/diagnostics';
import { log } from './util/log';
import { HtmlViewerProvider } from './views/htmlViewer';
import { DiagramViewManager } from './views/diagramView';
export function activate(context: vscode.ExtensionContext): void {
const previews = new PreviewManager(context);
const htmlViewer = new HtmlViewerProvider();
context.subscriptions.push(previews, htmlViewer, new DiagnosticsController(context), log());
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
function targetUri(arg?: unknown): vscode.Uri | undefined {
if (arg instanceof vscode.Uri) return arg;
return previews.activePreview?.uri ?? htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
}
function sourceUri(arg?: unknown): vscode.Uri | undefined {
const uri = targetUri(arg);
if (!uri) vscode.window.showWarningMessage('Archify: open a diagram JSON source first.');
return uri;
}
// Context keys for editor title buttons and keybindings.
let contextTimer: NodeJS.Timeout | undefined;
const updateContext = () => {
const doc = vscode.window.activeTextEditor?.document;
const diagram = Boolean(
doc && JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)),
);
const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
void vscode.commands.executeCommand('setContext', 'archify.isArchifyHtml', html);
const ctx: CommandContext = {
extension: context,
views: new DiagramViewManager(context),
htmlViewer: new HtmlViewerProvider(context.extensionUri),
};
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(updateContext),
vscode.workspace.onDidChangeTextDocument((event) => {
if (event.document !== vscode.window.activeTextEditor?.document) return;
clearTimeout(contextTimer);
contextTimer = setTimeout(updateContext, 300);
}),
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('archify.repoRoot')) clearGitRootCache();
}),
{ dispose: () => clearTimeout(contextTimer) },
);
updateContext();
const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
context.subscriptions.push(
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
const uri = sourceUri(arg);
if (!uri) return;
if (isHtmlUri(uri)) return openHtmlViewer(uri);
previews.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
}),
vscode.commands.registerCommand('archify.showPreviewToSide', (arg?: unknown) => {
const uri = sourceUri(arg);
if (!uri) return;
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside);
previews.show(uri, vscode.ViewColumn.Beside, true);
}),
vscode.commands.registerCommand('archify.openHtmlViewer', (arg?: unknown) => {
const uri = sourceUri(arg);
if (uri) return openHtmlViewer(uri);
}),
vscode.commands.registerCommand('archify.refreshPreview', () => previews.activePreview?.update(true)),
vscode.commands.registerCommand('archify.showSource', async () => {
const preview = previews.activePreview;
if (preview) {
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
return;
}
const html = htmlViewer.activeUri;
if (html) await vscode.commands.executeCommand('vscode.openWith', html, 'default');
}),
vscode.commands.registerCommand('archify.renderToHtml', async (arg?: unknown) => {
const uri = sourceUri(arg);
if (!uri) return;
const text = await readText(uri);
const source = readDiagramSource(text, uri.path);
if (!source) {
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
return;
}
const meta = (source.diagram.meta ?? {}) as { output?: unknown };
const authored = typeof meta.output === 'string' && !/[\\/]/.test(meta.output) ? meta.output : undefined;
const fileName = authored ?? `${diagramBaseName(uri.path)}.html`;
const defaultUri = uri.scheme === 'file' ? vscode.Uri.joinPath(uri, '..', fileName) : undefined;
const target = await vscode.window.showSaveDialog({
defaultUri,
filters: { HTML: ['html'] },
saveLabel: 'Render',
});
if (!target) return;
const runtime = await runtimeFor(context, uri);
let result = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
() => renderToFile(runtime, { ...source, text }, target.fsPath),
);
if (!result.ok && isEvidenceError(result.error)) {
const choice = await vscode.window.showWarningMessage(
'Archify could not verify the diagram\'s source evidence against a local checkout.',
{ modal: true, detail: result.error },
'Render Without Source Links',
);
if (choice) result = await renderToFile(runtime, { ...source, text }, target.fsPath, true);
else return;
}
if (!result.ok) {
log().error(result.error ?? 'render failed');
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
return;
}
const choice = await vscode.window.showInformationMessage(
`Rendered ${path.basename(target.fsPath)}`,
'Open in Viewer',
'Open in Browser',
);
if (choice === 'Open in Viewer') await openHtmlViewer(target);
if (choice === 'Open in Browser') await vscode.env.openExternal(target);
}),
vscode.commands.registerCommand('archify.openInBrowser', async (arg?: unknown) => {
const uri = sourceUri(arg);
if (!uri) return;
if (isHtmlUri(uri)) {
await vscode.env.openExternal(uri);
return;
}
const text = await readText(uri);
const source = readDiagramSource(text, uri.path);
if (!source) {
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
return;
}
const runtime = await runtimeFor(context, uri);
const result = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
() => renderDiagram(runtime, { ...source, text }, evidenceMode(uri)),
);
if (!result.ok || !result.html) {
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
return;
}
const dir = path.join(context.globalStorageUri.fsPath, 'browser');
await fs.mkdir(dir, { recursive: true });
const file = path.join(dir, `${diagramBaseName(uri.path)}.html`);
await fs.writeFile(file, result.html, 'utf8');
await vscode.env.openExternal(vscode.Uri.file(file));
}),
ctx.views,
ctx.htmlViewer,
new DiagnosticsController(context),
log(),
vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'),
...viewCommands(ctx),
...newDiagramCommands(ctx),
...renderCommands(ctx),
);
registerContextKeys(context);
}
export function deactivate(): void {}
-39
View File
@@ -1,39 +0,0 @@
import { findNodeAtLocation, Node, parseTree } from 'jsonc-parser';
export interface OffsetRange {
start: number;
end: number;
}
/** Splits an RFC 6901 JSON pointer into path segments. */
export function pointerSegments(pointer: string): (string | number)[] {
if (!pointer || pointer === '/') return [];
return pointer
.replace(/^\//, '')
.split('/')
.map((raw) => raw.replace(/~1/g, '/').replace(/~0/g, '~'))
.map((segment) => (/^(0|[1-9]\d*)$/.test(segment) ? Number(segment) : segment));
}
/**
* Maps a JSON pointer to a text range. Objects and arrays resolve to their
* property key (or opening bracket) so a "missing property" error does not
* underline a whole block. Unresolvable paths fall back to the deepest
* ancestor that exists.
*/
export function rangeForPointer(text: string, pointer: string | undefined): OffsetRange {
const root = parseTree(text, [], { allowTrailingComma: true });
if (!root) return { start: 0, end: 0 };
const segments = pointerSegments(pointer ?? '');
let node: Node | undefined;
for (let length = segments.length; length >= 0 && !node; length -= 1) {
node = findNodeAtLocation(root, segments.slice(0, length));
}
if (!node) return { start: 0, end: 0 };
if (node.type === 'object' || node.type === 'array') {
const key = node.parent?.type === 'property' ? node.parent.children?.[0] : undefined;
if (key) return { start: key.offset, end: key.offset + key.length };
return { start: node.offset, end: node.offset + 1 };
}
return { start: node.offset, end: node.offset + node.length };
}
+48
View File
@@ -0,0 +1,48 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { isArchifyFile } from '../sources/detect';
import { log } from '../util/log';
import { Brand, brandValueAt, loadBrandCatalog } from './brands';
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;
}
@@ -1,9 +1,10 @@
import { parse, ParseError } from 'jsonc-parser';
import * as vscode from 'vscode';
import { readDiagramSource } from './detect';
import { rangeForPointer } from './jsonPointer';
import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer';
import { evidenceMode, log, runtimeFor } from './runtime';
import { ArchifyDiagnostic, isEvidenceError, validate } from '../archify/cli';
import { evidenceMode, runtimeFor } from '../archify/runtime';
import { isArchifyFile, readDiagramSource } from '../sources/detect';
import { log } from '../util/log';
import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
@@ -61,7 +62,7 @@ export class DiagnosticsController implements vscode.Disposable {
}
private schedule(doc: vscode.TextDocument, delay: number): void {
if (!JSON_LANGUAGES.has(doc.languageId)) return;
if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName)) return;
const key = doc.uri.toString();
clearTimeout(this.timers.get(key));
this.timers.set(key, setTimeout(() => void this.validate(doc), delay));
@@ -74,22 +75,48 @@ export class DiagnosticsController implements vscode.Disposable {
this.sequence.set(key, seq);
const text = doc.getText();
const source = readDiagramSource(text, doc.fileName);
if (!source || !this.enabled(doc.uri) || doc.isClosed) {
if (!this.enabled(doc.uri) || doc.isClosed) {
this.collection.delete(doc.uri);
return;
}
// Leave syntax errors to the JSON language service.
const syntax: ParseError[] = [];
parse(text, syntax, { allowTrailingComma: false });
if (syntax.length) {
if (isArchifyFile(doc.fileName)) {
// Nothing else reports YAML syntax errors in .archify files, and a file
// that does not parse has no diagram to validate yet.
const problems = yamlSyntaxProblems(text);
if (problems.length) {
this.collection.set(
doc.uri,
problems.map((problem) => {
const diagnostic = new vscode.Diagnostic(
new vscode.Range(doc.positionAt(problem.start), doc.positionAt(problem.end)),
`YAML: ${problem.message}`,
vscode.DiagnosticSeverity.Error,
);
diagnostic.source = 'archify';
return diagnostic;
}),
);
return;
}
}
const source = readDiagramSource(text, doc.fileName);
if (!source) {
this.collection.delete(doc.uri);
return;
}
if (source.format === 'json') {
// Leave JSON syntax errors to the JSON language service.
const syntax: ParseError[] = [];
parse(text, syntax, { allowTrailingComma: false });
if (syntax.length) {
this.collection.delete(doc.uri);
return;
}
}
try {
const runtime = await runtimeFor(this.context, doc.uri);
const result = await validate(runtime, source.type, text);
const result = await validate(runtime, source.type, source.json);
if (seq !== this.sequence.get(key) || doc.isClosed) return;
if (result.crash) {
@@ -107,7 +134,8 @@ export class DiagnosticsController implements vscode.Disposable {
this.collection.set(
doc.uri,
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 fix = item.supportedFixes?.[0];
const diagnostic = new vscode.Diagnostic(
+87
View File
@@ -0,0 +1,87 @@
import { findNodeAtLocation, Node, parseTree } from 'jsonc-parser';
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
export interface OffsetRange {
start: 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. */
export function pointerSegments(pointer: string): (string | number)[] {
if (!pointer || pointer === '/') return [];
return pointer
.replace(/^\//, '')
.split('/')
.map((raw) => raw.replace(/~1/g, '/').replace(/~0/g, '~'))
.map((segment) => (/^(0|[1-9]\d*)$/.test(segment) ? Number(segment) : segment));
}
/**
* Maps a JSON pointer to a text range. Objects and arrays resolve to their
* property key (or opening bracket) so a "missing property" error does not
* underline a whole block. Unresolvable paths fall back to the deepest
* ancestor that exists.
*/
export function rangeForPointer(text: string, pointer: string | undefined): OffsetRange {
const root = parseTree(text, [], { allowTrailingComma: true });
if (!root) return { start: 0, end: 0 };
const segments = pointerSegments(pointer ?? '');
let node: Node | undefined;
for (let length = segments.length; length >= 0 && !node; length -= 1) {
node = findNodeAtLocation(root, segments.slice(0, length));
}
if (!node) return { start: 0, end: 0 };
if (node.type === 'object' || node.type === 'array') {
const key = node.parent?.type === 'property' ? node.parent.children?.[0] : undefined;
if (key) return { start: key.offset, end: key.offset + key.length };
return { start: node.offset, end: node.offset + 1 };
}
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],
}));
}
+53
View File
@@ -0,0 +1,53 @@
import { isMap, parseDocument } from 'yaml';
import { DiagramType } from '../sources/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 });
}
+30 -39
View File
@@ -1,17 +1,9 @@
import { parse, ParseError } from 'jsonc-parser';
import { parse as parseYaml } from 'yaml';
export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const;
export type DiagramType = (typeof DIAGRAM_TYPES)[number];
/** Node collection that may carry `sources` evidence, per diagram type. */
export const EVIDENCE_COLLECTIONS: Record<DiagramType, string> = {
architecture: 'components',
workflow: 'nodes',
sequence: 'participants',
dataflow: 'nodes',
lifecycle: 'states',
};
const FILENAME_RE = /\.(architecture|workflow|sequence|dataflow|lifecycle)\.json$/i;
const ARCHIFY_EXT_RE = /\.archify$/i;
const MAX_SNIFF_BYTES = 8 * 1024 * 1024;
@@ -44,24 +36,44 @@ export function diagramBaseName(fileName: string): string {
export interface DiagramSource {
type: DiagramType;
diagram: Record<string, unknown>;
/** Source syntax: `.archify` files are YAML, Archify's own sources JSON. */
format: 'yaml' | 'json';
/** JSON text for the Archify renderer, which only reads JSON. */
json: string;
}
/** Parses `.archify` (YAML) or JSON text; undefined when it does not parse. */
export function parseDiagramText(text: string, fileName = ''): unknown {
if (isArchifyFile(fileName)) {
try {
// YAML 1.2 also accepts the JSON form of older .archify files.
return parseYaml(text, { prettyErrors: false, uniqueKeys: false });
} catch {
return undefined;
}
}
const errors: ParseError[] = [];
return parse(text, errors, { allowTrailingComma: true });
}
/**
* Parses JSON text and returns the diagram when it declares an Archify
* `diagram_type`. Falls back to the file name for documents that do not
* parse yet, so a half-typed source still counts as a diagram.
* Returns the diagram when the text declares an Archify `diagram_type`.
* Falls back to the file name for documents that do not parse yet, so a
* half-typed source still counts as a diagram.
*/
export function readDiagramSource(text: string, fileName = ''): DiagramSource | undefined {
if (text.length > MAX_SNIFF_BYTES) return undefined;
const errors: ParseError[] = [];
const value = parse(text, errors, { allowTrailingComma: true });
if (value && typeof value === 'object' && !Array.isArray(value) && isDiagramType(value.diagram_type)) {
return { type: value.diagram_type, diagram: value };
const format = isArchifyFile(fileName) ? 'yaml' : 'json';
const value = parseDiagramText(text, fileName) as Record<string, unknown> | undefined;
const isObject = value !== null && typeof value === 'object' && !Array.isArray(value);
const json = (diagram: unknown) => (format === 'json' ? text : JSON.stringify(diagram ?? {}, null, 2));
if (isObject && isDiagramType(value.diagram_type)) {
return { type: value.diagram_type, diagram: value, format, json: json(value) };
}
const byName = typeFromFilename(fileName);
if (byName) {
const diagram = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
return { type: byName, diagram };
const diagram = isObject ? value : {};
return { type: byName, diagram, format, json: json(diagram) };
}
return undefined;
}
@@ -71,24 +83,3 @@ export function isArchifyHtml(text: string): boolean {
const head = text.slice(0, 4096);
return /<meta\s+name=["']generator["']\s+content=["']archify\b/i.test(head);
}
export function hasSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): boolean {
const meta = diagram.meta as Record<string, unknown> | undefined;
if (meta && meta.repository) return true;
const nodes = diagram[EVIDENCE_COLLECTIONS[type]];
return Array.isArray(nodes) && nodes.some((node) => Array.isArray(node?.sources) && node.sources.length > 0);
}
/** Returns a copy of the diagram with repository evidence removed. */
export function stripSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): Record<string, unknown> {
const copy = structuredClone(diagram);
const meta = copy.meta as Record<string, unknown> | undefined;
if (meta) delete meta.repository;
const nodes = copy[EVIDENCE_COLLECTIONS[type]];
if (Array.isArray(nodes)) {
for (const node of nodes) {
if (node && typeof node === 'object') delete node.sources;
}
}
return copy;
}
+31
View File
@@ -0,0 +1,31 @@
import { DiagramType } from './detect';
/** Node collection that may carry `sources` evidence, per diagram type. */
export const EVIDENCE_COLLECTIONS: Record<DiagramType, string> = {
architecture: 'components',
workflow: 'nodes',
sequence: 'participants',
dataflow: 'nodes',
lifecycle: 'states',
};
export function hasSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): boolean {
const meta = diagram.meta as Record<string, unknown> | undefined;
if (meta && meta.repository) return true;
const nodes = diagram[EVIDENCE_COLLECTIONS[type]];
return Array.isArray(nodes) && nodes.some((node) => Array.isArray(node?.sources) && node.sources.length > 0);
}
/** Returns a copy of the diagram with repository evidence removed. */
export function stripSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): Record<string, unknown> {
const copy = structuredClone(diagram);
const meta = copy.meta as Record<string, unknown> | undefined;
if (meta) delete meta.repository;
const nodes = copy[EVIDENCE_COLLECTIONS[type]];
if (Array.isArray(nodes)) {
for (const node of nodes) {
if (node && typeof node === 'object') delete node.sources;
}
}
return copy;
}
+8
View File
@@ -0,0 +1,8 @@
import * as vscode from 'vscode';
/** Current text of a document: the open editor buffer, else the file on disk. */
export async function readText(uri: vscode.Uri): Promise<string> {
const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString());
if (open) return open.getText();
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
}
+8
View File
@@ -0,0 +1,8 @@
import * as vscode from 'vscode';
let output: vscode.LogOutputChannel | undefined;
export function log(): vscode.LogOutputChannel {
output ??= vscode.window.createOutputChannel('Archify', { log: true });
return output;
}
+11
View File
@@ -0,0 +1,11 @@
import * as vscode from 'vscode';
import { ViewerTheme } from '../webview/page';
/** Diagram theme from archify.theme: forced, VS Code's, or the diagram's own. */
export function viewerTheme(): ViewerTheme {
const setting = vscode.workspace.getConfiguration('archify').get<string>('theme', 'vscode');
if (setting === 'dark' || setting === 'light') return setting;
if (setting === 'diagram') return undefined;
const kind = vscode.window.activeColorTheme.kind;
return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? 'light' : 'dark';
}
+558
View File
@@ -0,0 +1,558 @@
import { layerMarkup, rasterize } from './raster';
import { CameraState, Layers, Stage } from './stage';
import { DiagramModel, readModel } from './svgModel';
import { STYLE } from './style';
/**
* 3D mode for the Archify viewer. It adds a 3D menu to Archify's toolbar
* and, when on, replaces the 2D canvas with a three.js stage built from the
* live SVG. Archify keeps owning the diagram: clicks in 3D go to the SVG
* nodes, so focus, Semantic Passport, reach and lens work as in 2D; the
* stage redraws whenever Archify changes the SVG, theme or preset, and
* follows Archify's animations as they play.
*/
interface Host {
defaultView: '2d' | '3d';
post(message: unknown): void;
getState(): Record<string, unknown>;
setState(patch: Record<string, unknown>): void;
}
interface SavedView {
active?: boolean;
layered?: boolean;
camera?: CameraState;
}
declare global {
interface Window {
__archifyHost?: Host;
}
}
const REDRAW_MS = 220;
const MARGIN = 80;
/** Texture pixels per SVG unit; more costs memory and upload time for little gain. */
const TEXTURE_SCALE = 2;
type MenuValue = '2d' | '3d' | 'flat' | 'stacked' | 'reset' | 'top';
interface MenuEntry {
value: MenuValue;
title: string;
hint: string;
/** Radio group; actions have none. */
group?: 'view' | 'levels';
shortcut?: string;
}
const MENU: { heading: string; entries: MenuEntry[] }[] = [
{
heading: 'View',
entries: [
{ value: '2d', group: 'view', title: '2D', hint: 'The diagram as Archify draws it' },
{ value: '3d', group: 'view', title: '3D', hint: 'Blocks and pipes you can turn' },
],
},
{
heading: 'Levels',
entries: [
{ value: 'flat', group: 'levels', title: 'Flat', hint: 'Every node on the floor' },
{ value: 'stacked', group: 'levels', title: 'Stacked', hint: 'Clients on top, data at the bottom' },
],
},
{
heading: 'Camera',
entries: [
{ value: 'reset', title: 'Reset view', hint: 'Show the whole diagram', shortcut: '0' },
{ value: 'top', title: 'From above', hint: 'Look straight down, like 2D' },
],
},
];
class Viewer3D {
private stage: Stage | undefined;
private model: DiagramModel | undefined;
private active = false;
private redrawTimer: number | undefined;
private drawing: Promise<void> | undefined;
private pendingRedraw = false;
private readonly observer = new MutationObserver((records) => this.onMutation(records));
/** Keeps Archify's zoom buttons usable in 3D (see holdZoomButtons). */
private readonly zoomObserver = new MutationObserver(() => this.enableZoomButtons());
private zoomDisabled = new Map<HTMLButtonElement, boolean>();
private readonly button: HTMLButtonElement;
private readonly menu: HTMLDivElement;
private ambientReplayed = false;
private exportItem: HTMLButtonElement | undefined;
private saved: SavedView;
constructor(
private readonly host: Host,
private readonly container: HTMLElement,
private readonly svg: SVGSVGElement,
) {
this.saved = (host.getState().view3d as SavedView | undefined) ?? {};
({ button: this.button, menu: this.menu } = this.buildMenu());
this.addExportItem();
this.interceptViewerControls();
if (this.saved.active ?? host.defaultView === '3d') this.setActive(true);
}
// -------------------------------------------------------------- chrome
/**
* The 3D menu: a toolbar button with a dropdown, built from Archify's own
* style-menu classes so it looks and behaves like the menus beside it.
*/
private buildMenu(): { button: HTMLButtonElement; menu: HTMLDivElement } {
const wrap = document.createElement('div');
wrap.className = 'preset-wrap archify-3d-wrap';
const button = document.createElement('button');
button.id = 'btn-3d';
button.type = 'button';
button.title = '3D view: switch 2D / 3D, levels and camera';
button.setAttribute('aria-label', '3D view options');
button.setAttribute('aria-haspopup', 'menu');
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-controls', 'archify-3d-menu');
// Same make-up as Archify's Export button: a label and a chevron.
button.innerHTML = '3D <span class="toolbar-icon toolbar-chevron" aria-hidden="true"></span>';
const menu = document.createElement('div');
menu.id = 'archify-3d-menu';
menu.className = 'preset-menu archify-3d-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', 'View options');
for (const section of MENU) {
const heading = document.createElement('div');
heading.className = 'preset-menu-heading';
heading.setAttribute('role', 'presentation');
heading.innerHTML = `<span>${section.heading}</span>`;
menu.appendChild(heading);
for (const entry of section.entries) {
const option = document.createElement('button');
option.type = 'button';
option.className = 'preset-option archify-3d-option';
option.tabIndex = -1;
option.dataset.archify3d = entry.value;
option.setAttribute('role', entry.group ? 'menuitemradio' : 'menuitem');
if (entry.group) option.setAttribute('aria-checked', 'false');
option.innerHTML =
`<span class="archify-3d-swatch" aria-hidden="true"><span class="archify-3d-swatch-icon archify-3d-i-${entry.value}"></span></span>` +
`<span class="preset-option-copy"><strong>${entry.title}</strong><small>${entry.hint}</small></span>` +
(entry.group
? '<span class="preset-option-check" aria-hidden="true"></span>'
: `<kbd class="archify-3d-kbd" aria-hidden="true">${entry.shortcut ?? ''}</kbd>`);
option.addEventListener('click', (event) => {
event.stopPropagation();
if (option.getAttribute('aria-disabled') === 'true') return;
this.choose(entry.value);
if (!entry.group) this.closeMenu();
else this.syncButtons();
});
menu.appendChild(option);
}
}
button.addEventListener('click', (event) => {
event.stopPropagation();
if (menu.classList.contains('open')) this.closeMenu();
else this.openMenu();
});
menu.addEventListener('keydown', (event) => this.menuKey(event));
document.addEventListener('click', (event) => {
if (menu.classList.contains('open') && !wrap.contains(event.target as Node)) this.closeMenu();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && menu.classList.contains('open')) {
this.closeMenu();
button.focus();
}
});
wrap.append(button, menu);
// Beside Export, outside the tools capsule, as its twin.
const exportWrap = document.querySelector('.toolbar .export-wrap');
if (exportWrap?.parentElement) exportWrap.parentElement.insertBefore(wrap, exportWrap);
else document.querySelector('.toolbar')?.appendChild(wrap);
return { button, menu };
}
private options(): HTMLButtonElement[] {
return [...this.menu.querySelectorAll<HTMLButtonElement>('.archify-3d-option')].filter((o) => o.getAttribute('aria-disabled') !== 'true');
}
private openMenu(): void {
// Only one toolbar menu at a time: close Archify's own.
document.querySelectorAll('.toolbar .preset-menu.open, .toolbar .export-menu.open').forEach((m) => {
if (m !== this.menu) (m.previousElementSibling as HTMLElement | null)?.click();
});
this.syncButtons();
this.menu.classList.add('open');
this.button.setAttribute('aria-expanded', 'true');
const checked = this.options().find((o) => o.getAttribute('aria-checked') === 'true');
(checked ?? this.options()[0])?.focus();
}
private closeMenu(): void {
this.menu.classList.remove('open');
this.button.setAttribute('aria-expanded', 'false');
}
private menuKey(event: KeyboardEvent): void {
const options = this.options();
const index = options.indexOf(document.activeElement as HTMLButtonElement);
const move = (to: number) => {
event.preventDefault();
options[(to + options.length) % options.length]?.focus();
};
if (event.key === 'ArrowDown') move(index + 1);
else if (event.key === 'ArrowUp') move(index - 1);
else if (event.key === 'Home') move(0);
else if (event.key === 'End') move(options.length - 1);
else if (event.key === 'Tab') this.closeMenu();
}
private choose(value: MenuValue): void {
switch (value) {
case '2d':
case '3d':
if ((value === '3d') !== this.active) this.setActive(value === '3d');
return;
case 'flat':
case 'stacked':
this.stage?.setLayered(value === 'stacked');
this.save();
return;
case 'reset':
this.stage?.fit();
return;
case 'top':
this.stage?.top();
return;
}
}
private addExportItem(): void {
const png = document.querySelector('.export-menu button[data-format="png"]');
if (!png?.parentElement) return;
const item = document.createElement('button');
item.type = 'button';
item.setAttribute('role', 'menuitem');
item.tabIndex = -1;
item.hidden = true;
item.dataset.archify3dExport = '';
item.innerHTML = '<span class="export-item-copy"><strong>3D view</strong><small class="hint">PNG of the current 3D view</small></span>';
item.addEventListener('click', (event) => {
event.stopPropagation();
document.getElementById('btn-export')?.click();
this.exportPng();
});
png.parentElement.insertBefore(item, png);
this.exportItem = item;
}
/** Archify's zoom buttons and keys drive the 3D camera while 3D is on. */
private interceptViewerControls(): void {
this.container.addEventListener(
'click',
(event) => {
if (!this.active || !this.stage) return;
const button = (event.target as Element | null)?.closest?.('[data-view]');
const view = button?.getAttribute('data-view');
if (!view) return;
event.stopPropagation();
event.preventDefault();
if (view === 'in') this.stage.zoom(1.25);
else if (view === 'out') this.stage.zoom(0.8);
else if (view === 'reset') this.stage.fit();
},
true,
);
window.addEventListener(
'keydown',
(event) => {
if (!this.active || !this.stage || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target as HTMLElement | null;
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
const actions: Record<string, () => void> = {
'0': () => this.stage!.fit(),
'+': () => this.stage!.zoom(1.25),
'=': () => this.stage!.zoom(1.25),
'-': () => this.stage!.zoom(0.8),
};
const action = actions[event.key];
if (!action) return;
event.stopPropagation();
event.preventDefault();
action();
},
true,
);
}
private syncButtons(): void {
const layered = !!this.stage?.layered;
this.button.toggleAttribute('data-active', this.active);
for (const option of this.menu.querySelectorAll<HTMLButtonElement>('.archify-3d-option')) {
const value = option.dataset.archify3d as MenuValue;
if (value === '2d' || value === '3d') option.setAttribute('aria-checked', String((value === '3d') === this.active));
if (value === 'flat' || value === 'stacked') option.setAttribute('aria-checked', String((value === 'stacked') === layered));
// Levels and camera only apply to the 3D view.
if (value !== '2d' && value !== '3d') option.setAttribute('aria-disabled', String(!this.active));
}
if (this.exportItem) this.exportItem.hidden = !this.active;
}
// ---------------------------------------------------------------- mode
setActive(on: boolean): void {
this.active = on;
document.documentElement.classList.toggle('archify-3d-on', on);
if (on) {
if (!this.stage) this.createStage();
this.container.appendChild(this.stage!.element);
this.stage!.start();
this.observer.observe(this.svg, { subtree: true, attributes: true, childList: true, characterData: true });
this.observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-preset'] });
this.holdZoomButtons(true);
void this.redraw().then(() => this.replayAmbient());
} else {
this.holdZoomButtons(false);
this.observer.disconnect();
this.stage?.stop();
this.stage?.element.remove();
}
this.syncButtons();
this.save();
}
private zoomButtons(): HTMLButtonElement[] {
return [...this.container.querySelectorAll<HTMLButtonElement>('button[data-view="in"], button[data-view="out"]')];
}
/**
* Archify disables its zoom buttons at the 2D limits (100% and 300%). The
* 3D camera has its own range, so the buttons stay enabled while 3D is on,
* and get Archify's state back when 3D is turned off.
*/
private holdZoomButtons(on: boolean): void {
if (on) {
this.zoomDisabled = new Map(this.zoomButtons().map((b) => [b, b.disabled]));
for (const button of this.zoomButtons()) this.zoomObserver.observe(button, { attributes: true, attributeFilter: ['disabled'] });
this.enableZoomButtons();
} else {
this.zoomObserver.disconnect();
for (const [button, disabled] of this.zoomDisabled) button.disabled = disabled;
}
}
private enableZoomButtons(): void {
for (const button of this.zoomButtons()) if (button.disabled) button.disabled = false;
}
private createStage(): void {
const stage = new Stage();
stage.onPick = (id) => this.pick(id);
stage.onHover = (id) => this.hover(id);
stage.onCameraChange = () => this.save();
if (this.saved.layered) stage.setLayered(true, false);
if (this.saved.camera) stage.restoreCamera(this.saved.camera);
this.stage = stage;
}
/**
* Archify plays its ambient trace once, when the page opens. Opening 3D
* later replays it once, so the 3D view starts the way 2D did. Readers who
* paused motion (Live off) or prefer reduced motion get no replay.
*/
private replayAmbient(): void {
const html = document.documentElement;
if (this.ambientReplayed || !this.active) return;
// The load-time pass may still be playing (usually under the not-yet-built
// 3D view): wait for it to settle, then replay it in 3D.
if (html.getAttribute('data-ambient-motion') === 'running') {
const wait = new MutationObserver(() => {
if (html.getAttribute('data-ambient-motion') === 'running') return;
wait.disconnect();
this.replayAmbient();
});
wait.observe(html, { attributes: true, attributeFilter: ['data-ambient-motion'] });
return;
}
this.ambientReplayed = true;
if (html.getAttribute('data-ambient-motion') !== 'settled' || html.getAttribute('data-motion') === 'still') return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (!this.svg.querySelector('[data-animate]')) return;
// Restart Archify's CSS trace: the "settled" state must be applied
// (style flushed) before "running" again, or the animations do not restart.
html.setAttribute('data-ambient-motion', 'settled');
void getComputedStyle(this.svg.querySelector('[data-animate]')!).animationName;
html.setAttribute('data-ambient-motion', 'running');
const settle = () => html.setAttribute('data-ambient-motion', 'settled');
const done = () => {
if (this.svg.getAnimations({ subtree: true }).some((anim) => anim.playState === 'running')) return;
this.svg.removeEventListener('animationend', done, true);
this.svg.removeEventListener('animationcancel', done, true);
settle();
};
this.svg.addEventListener('animationend', done, true);
this.svg.addEventListener('animationcancel', done, true);
// Safety net if an animation never reports its end.
window.setTimeout(settle, 12000);
}
private save(): void {
this.saved = { active: this.active, layered: this.stage?.layered ?? this.saved.layered, camera: this.stage?.cameraState() ?? this.saved.camera };
this.host.setState({ view3d: this.saved });
}
// ------------------------------------------------------------- picking
/** Hands a 3D click to Archify, as if the reader had clicked the 2D node. */
private pick(id: string | undefined): void {
const target = id ? this.svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`) : this.svg;
target?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
}
private hovered: Element | undefined;
/**
* Hands 3D hover to Archify as pointer events on the SVG node, so its
* intent trace (one-hop flows, fading the rest) plays as in 2D.
*/
private hover(id: string | undefined): void {
const next = id ? this.svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`) ?? undefined : undefined;
if (next === this.hovered) return;
const init = { bubbles: true, cancelable: true, pointerType: 'mouse', isPrimary: true, view: window };
this.hovered?.dispatchEvent(new PointerEvent('pointerout', init));
next?.dispatchEvent(new PointerEvent('pointerover', init));
this.hovered = next;
}
// -------------------------------------------------------------- redraw
private onMutation(records: MutationRecord[]): void {
if (!records.some((r) => this.changesDrawing(r))) return;
window.clearTimeout(this.redrawTimer);
// Let Archify's 140–200ms state transitions finish before sampling styles.
this.redrawTimer = window.setTimeout(() => void this.redraw(), REDRAW_MS);
}
/**
* Whether a mutation changes what the textures show. Archify's camera
* (the SVG root's style), its temporary overlays and the ambient-motion
* flag only change motion, which the stage follows live.
*/
private changesDrawing(record: MutationRecord): boolean {
const target = record.target as Element;
if (target === this.svg && record.attributeName === 'style') return false;
if (target === document.documentElement) return record.attributeName === 'data-theme' || record.attributeName === 'data-preset';
// Intent trace only fades and draws flows; the stage mirrors both live.
if (record.attributeName?.startsWith('data-intent-trace')) return false;
const inOverlay = (node: Node) => node instanceof Element && !!node.closest('[data-relationship-pulse-overlay], [data-intent-trace-overlay], [data-route-probe-overlay], [data-route-journey-overlay], [data-semantic-lens-overlay], [data-story-overlay], [data-story-carrier-overlay], [data-chapter-handoff-overlay], [data-relationship-hit-overlay]');
if (record.type === 'childList') {
const nodes = [...record.addedNodes, ...record.removedNodes].filter((n) => n.nodeType === Node.ELEMENT_NODE);
if (nodes.length && nodes.every((n) => inOverlay(n) || [...(n as Element).getAttributeNames()].some((a) => a.endsWith('-overlay')))) return false;
return nodes.length > 0 || !inOverlay(target);
}
return !inOverlay(target);
}
private async redraw(): Promise<void> {
if (this.drawing) {
this.pendingRedraw = true;
return this.drawing;
}
this.drawing = this.draw().finally(() => {
this.drawing = undefined;
if (this.pendingRedraw) {
this.pendingRedraw = false;
void this.redraw();
}
});
return this.drawing;
}
private async draw(): Promise<void> {
if (!this.active || !this.stage) return;
await document.fonts?.ready;
const model = readModel(this.svg);
this.model = model;
const { viewBox } = model;
const area = { x: viewBox.x - MARGIN, y: viewBox.y - MARGIN, w: viewBox.w + MARGIN * 2, h: viewBox.h + MARGIN * 2 };
const scale = Math.min(TEXTURE_SCALE, 8192 / Math.max(area.w, area.h));
const root = getComputedStyle(document.documentElement);
const containerStyle = getComputedStyle(this.container);
const background = opaque(containerStyle.backgroundColor) ?? (root.getPropertyValue('--bg').trim() || '#020617');
const dot = root.getPropertyValue('--canvas-dot').trim();
const paintFloor = (ctx: CanvasRenderingContext2D) => {
ctx.fillStyle = background;
ctx.fillRect(area.x, area.y, area.w, area.h);
if (dot) {
ctx.fillStyle = dot;
for (let x = Math.ceil(area.x / 20) * 20; x < area.x + area.w; x += 20) {
for (let y = Math.ceil(area.y / 20) * 20; y < area.y + area.h; y += 20) {
ctx.beginPath();
ctx.arc(x, y, 0.9, 0, Math.PI * 2);
ctx.fill();
}
}
}
};
const [floor, nodes, labels] = await Promise.all([
rasterize(layerMarkup(this.svg, viewBox, { keep: ['floor'] }, scale), viewBox, area, scale, paintFloor),
rasterize(layerMarkup(this.svg, viewBox, { keep: ['node'], opaqueNodes: true }, scale), viewBox, area, scale),
rasterize(layerMarkup(this.svg, viewBox, { keep: ['label'] }, scale), viewBox, area, scale),
]);
if (!this.active || this.model !== model) return;
const layers: Layers = { floor, nodes, labels };
const mask = root.getPropertyValue('--mask').trim() || '#0f172a';
this.stage.build(model, layers, { mask, dark: document.documentElement.getAttribute('data-theme') !== 'light' });
}
// -------------------------------------------------------------- export
private exportPng(): void {
if (!this.stage) return;
const shot = new Image();
shot.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = shot.width;
canvas.height = shot.height;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = opaque(getComputedStyle(this.container).backgroundColor) ?? getComputedStyle(document.documentElement).getPropertyValue('--bg');
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(shot, 0, 0);
const data = canvas.toDataURL('image/png');
const title = (document.querySelector('.header h1')?.textContent ?? 'archify').trim().replace(/[^\w.-]+/g, '-').toLowerCase();
this.host.post({ type: 'save', filename: `${title || 'archify'}-3d.png`, base64: data.slice(data.indexOf(',') + 1) });
};
shot.src = this.stage.snapshot();
}
}
/** A CSS colour if it is not fully transparent. */
function opaque(value: string): string | undefined {
if (!value || value === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(value)) return undefined;
return value;
}
function start(): void {
const host = window.__archifyHost;
const container = document.querySelector<HTMLElement>('.diagram-container');
const svg = container?.querySelector<SVGSVGElement>(':scope > svg');
if (!host || !container || !svg) return;
const style = document.createElement('style');
style.textContent = STYLE;
document.head.appendChild(style);
new Viewer3D(host, container, svg);
}
if (document.readyState === 'complete') start();
else window.addEventListener('load', start, { once: true });
+126
View File
@@ -0,0 +1,126 @@
import { Box, Role, roleOf } from './svgModel';
/**
* Turns parts of the live Archify SVG into images. Each element's computed
* style is written inline into a standalone copy, so the image carries the
* current theme, preset, focus and dimming exactly as the 2D view shows them.
*/
/** Presentation properties copied from computed style. */
const PROPS = [
'fill', 'fill-opacity', 'fill-rule',
'stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'stroke-dashoffset',
'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit',
// visibility is left out: the 3D view hides the 2D SVG with it.
'opacity', 'display', 'filter', 'paint-order',
'font-family', 'font-size', 'font-weight', 'font-style', 'letter-spacing', 'text-anchor',
'dominant-baseline', 'text-transform',
];
let fontCss: string | undefined;
/** The page's @font-face rules (Archify embeds JetBrains Mono as data URIs). */
function fontFaces(): string {
if (fontCss !== undefined) return fontCss;
const rules: string[] = [];
for (const sheet of [...document.styleSheets]) {
let list: CSSRuleList;
try {
list = sheet.cssRules;
} catch {
continue;
}
for (const rule of [...list]) if (rule instanceof CSSFontFaceRule) rules.push(rule.cssText);
}
fontCss = rules.join('\n');
return fontCss;
}
export interface LayerOptions {
/** Roles whose elements are drawn; everything else is hidden. */
keep: Role[];
/** Draw kept nodes fully opaque; the 3D view applies their dimming itself. */
opaqueNodes?: boolean;
}
/** Copies computed styles from the live tree onto a clone with the same shape. */
function inlineStyles(live: Element, copy: Element, options: LayerOptions, inherited: Role | undefined, root: boolean): void {
const role = inherited ?? roleOf(live);
const isDefs = live.tagName === 'defs' || !!live.closest('defs');
const style = getComputedStyle(live);
const parts: string[] = [];
for (const prop of PROPS) {
const value = style.getPropertyValue(prop);
if (value) parts.push(`${prop}:${value}`);
}
let hidden = false;
if (root) {
// The root carries viewer state (camera transform, our fade); keep only
// what its children inherit.
copy.setAttribute('style', parts.filter((p) => /^(font|letter|text)/.test(p)).join(';'));
} else {
if (!isDefs) {
if (role) hidden = !options.keep.includes(role);
else if (live.children.length === 0) hidden = !options.keep.includes('floor');
}
if (options.opaqueNodes && role === 'node' && !inherited) parts.push('opacity:1');
if (hidden) parts.push('display:none');
copy.setAttribute('style', parts.join(';'));
}
copy.removeAttribute('class');
if (hidden) return;
const liveKids = live.children;
const copyKids = copy.children;
for (let i = 0; i < liveKids.length && i < copyKids.length; i++) {
inlineStyles(liveKids[i], copyKids[i], options, role, false);
}
}
/**
* Standalone SVG markup for one layer of the diagram, sized at `scale`
* pixels per user unit so the browser rasterizes it sharply.
*/
export function layerMarkup(svg: SVGSVGElement, viewBox: Box, options: LayerOptions, scale: number): string {
const copy = svg.cloneNode(true) as SVGSVGElement;
inlineStyles(svg, copy, options, undefined, true);
// Viewer-only children (focus overlays, hit areas) carry no data roles but
// are not part of the authored drawing.
copy.querySelectorAll('[data-viewer-only], .no-export').forEach((el) => el.remove());
copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`);
copy.setAttribute('width', String(Math.round(viewBox.w * scale)));
copy.setAttribute('height', String(Math.round(viewBox.h * scale)));
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
style.textContent = fontFaces();
copy.insertBefore(style, copy.firstChild);
return new XMLSerializer().serializeToString(copy);
}
export interface Raster {
canvas: HTMLCanvasElement;
/** Pixels per SVG user unit. */
scale: number;
/** SVG area the canvas covers (the view box plus a margin). */
area: Box;
}
/** Rasterizes SVG markup over `area` (in user units) at `scale` pixels per unit. */
export async function rasterize(markup: string, viewBox: Box, area: Box, scale: number, paint?: (ctx: CanvasRenderingContext2D) => void): Promise<Raster> {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(area.w * scale));
canvas.height = Math.max(1, Math.round(area.h * scale));
const ctx = canvas.getContext('2d')!;
ctx.scale(scale, scale);
ctx.translate(-area.x, -area.y);
paint?.(ctx);
const url = URL.createObjectURL(new Blob([markup], { type: 'image/svg+xml' }));
try {
const image = new Image();
image.src = url;
await image.decode();
ctx.drawImage(image, viewBox.x, viewBox.y, viewBox.w, viewBox.h);
} finally {
URL.revokeObjectURL(url);
}
return { canvas, scale, area };
}
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
/** Styles for the 3D mode; they reuse Archify's tokens so every preset fits. */
export const STYLE = `
.diagram-container { position: relative; }
/* The 2D SVG stays in the page (its styles and animations drive the 3D
view) but is not painted while 3D is on. */
html.archify-3d-on .diagram-container > svg { visibility: hidden !important; pointer-events: none !important; }
html.archify-3d-on .diagram-container { min-height: max(420px, 62vh); }
.archify-3d-stage { position: absolute; inset: 0; z-index: 1; border-radius: inherit; overflow: hidden; }
.archify-3d-stage canvas { display: block; touch-action: none; cursor: grab; }
.archify-3d-stage canvas:active { cursor: grabbing; }
html.archify-3d-on .diagram-container > :not(svg):not(.archify-3d-stage) { z-index: 2; }
/* The 3D button is Export's twin: same pill, same type, same menu. */
.toolbar #btn-3d { padding-right: 0.95rem; padding-left: 1rem; color: var(--bg); font-weight: 550; }
.toolbar #btn-3d::before { inset: 0.25rem 0; border-radius: 0.6875rem; background: var(--text);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
.toolbar #btn-3d:hover,
.toolbar #btn-3d[aria-expanded="true"] { color: var(--bg); }
.toolbar #btn-3d:hover::before,
.toolbar #btn-3d[aria-expanded="true"]::before { background: color-mix(in srgb, var(--text) 86%, var(--bg)); }
/* While the 3D view is on, the pill wears a cyan ring. */
.toolbar #btn-3d[data-active]::before { box-shadow: 0 0 0 2px var(--frontend-stroke), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
.toolbar .archify-3d-wrap .archify-3d-menu { right: 0; }
.toolbar .export-menu button[data-archify3d-export]::before { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); }
/* Menu rows: Archify's .preset-option layout, with an icon swatch. */
.toolbar .archify-3d-menu { width: 18.5rem; }
.toolbar .archify-3d-menu .preset-menu-heading { padding-top: 0.6rem; }
.toolbar .archify-3d-menu .preset-menu-heading:first-child { padding-top: 0.45rem; }
.archify-3d-swatch { display: grid; place-items: center; width: 2.375rem; height: 1.75rem; border-radius: 0.4375rem; color: var(--text-muted);
background: color-mix(in srgb, var(--text) 5%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 14%, transparent);
transition: box-shadow 0.14s ease, color 0.14s ease; }
.toolbar .archify-3d-option[aria-checked="true"] .archify-3d-swatch { color: var(--frontend-stroke); box-shadow: 0 0 0 2px var(--toolbar-menu-bg), 0 0 0 3.5px var(--frontend-stroke); }
.archify-3d-swatch-icon { width: 1.05rem; height: 1.05rem; background: currentColor; -webkit-mask: center / contain no-repeat; mask: center / contain no-repeat; }
.archify-3d-i-2d { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='14' height='12' rx='1.5'/%3E%3Cpath d='M6 8h3M6 11h5M12.5 8h1.5'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='14' height='12' rx='1.5'/%3E%3Cpath d='M6 8h3M6 11h5M12.5 8h1.5'/%3E%3C/g%3E%3C/svg%3E"); }
.archify-3d-i-3d { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); }
.archify-3d-i-flat { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 6 17.5 9.8 10 13.6 2.5 9.8z'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 6 17.5 9.8 10 13.6 2.5 9.8z'/%3E%3C/g%3E%3C/svg%3E"); }
.archify-3d-i-stacked { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5 17.5 6.3 10 10 2.5 6.3z'/%3E%3Cpath d='m2.5 10 7.5 3.8 7.5-3.8M2.5 13.7 10 17.5l7.5-3.8'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5 17.5 6.3 10 10 2.5 6.3z'/%3E%3Cpath d='m2.5 10 7.5 3.8 7.5-3.8M2.5 13.7 10 17.5l7.5-3.8'/%3E%3C/g%3E%3C/svg%3E"); }
.archify-3d-i-reset { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 10a6.5 6.5 0 1 0 2-4.7'/%3E%3Cpath d='M3.5 3.5v3.5H7'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 10a6.5 6.5 0 1 0 2-4.7'/%3E%3Cpath d='M3.5 3.5v3.5H7'/%3E%3C/g%3E%3C/svg%3E"); }
.archify-3d-i-top { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5v9M6.5 8l3.5 3.5L13.5 8'/%3E%3Cpath d='M3 14.5h14M5 17.5h10'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5v9M6.5 8l3.5 3.5L13.5 8'/%3E%3Cpath d='M3 14.5h14M5 17.5h10'/%3E%3C/g%3E%3C/svg%3E"); }
.toolbar .archify-3d-option[aria-disabled="true"] { opacity: 0.42; cursor: default; }
.toolbar .archify-3d-option[aria-disabled="true"]:hover { background: transparent; }
.archify-3d-kbd { justify-self: end; min-width: 1rem; color: var(--text-faint); font: inherit; font-size: 0.6875rem; text-align: center; }
.archify-3d-kbd:empty { visibility: hidden; }
`;
+181
View File
@@ -0,0 +1,181 @@
/**
* Reads the semantic parts of a rendered Archify SVG: nodes, routed
* relationships, their labels and structural frames. Archify marks them
* with the same data attributes in all five diagram types, so the 3D view
* needs no per-type code.
*/
export interface Box {
x: number;
y: number;
w: number;
h: number;
}
export interface NodeShape extends Box {
id: string;
kind: string;
rx: number;
el: SVGGElement;
}
export interface EdgeShape {
key: string;
from: string;
to: string;
path: SVGPathElement;
/** Points along the route, every SAMPLE_STEP user units. */
points: [number, number][];
length: number;
animated: boolean;
}
export interface LabelShape extends Box {
key: string;
el: SVGGElement;
}
export interface FrameShape extends Box {
kind: string;
rx: number;
el: SVGRectElement;
}
export interface DiagramModel {
svg: SVGSVGElement;
viewBox: Box;
nodes: NodeShape[];
edges: EdgeShape[];
labels: LabelShape[];
frames: FrameShape[];
}
const SAMPLE_STEP = 3;
/** Role of an element for the 3D view; descendants share their ancestor's role. */
export type Role = 'node' | 'edge' | 'label' | 'floor' | 'overlay';
/**
* Archify's temporary viewer layers (focus pulse, intent trace, route probe,
* journey, lens, story) are marked data-…-overlay. They are animated in 3D
* from the live SVG and never baked into the textures.
*/
function isOverlay(el: Element): boolean {
for (const name of el.getAttributeNames()) if (name.startsWith('data-') && name.endsWith('-overlay')) return true;
return false;
}
/** Flow paths inside those overlays: clones of a relationship path with pathLength="1". */
export const FLOW_SELECTOR = [
'relationship-pulse',
'intent-trace',
'route-probe',
'route-journey',
'semantic-lens',
'story',
'story-carrier',
'chapter-handoff',
].map((name) => `[data-${name}-overlay] path[pathLength]`).join(', ');
export function roleOf(el: Element): Role | undefined {
if (isOverlay(el)) return 'overlay';
if (el.hasAttribute('data-node-id')) return 'node';
if (el.getAttribute('data-graph-role') === 'automatic-crossover') return 'edge';
if (el.tagName === 'path' && el.hasAttribute('data-edge-from')) return 'edge';
if (el.getAttribute('data-graph-role') === 'automatic-crossover-underlay') return 'edge';
if (el.tagName === 'g' && el.hasAttribute('data-edge-key') && el.querySelector('text')) return 'label';
return undefined;
}
function num(el: Element, name: string): number {
return Number(el.getAttribute(name) ?? 0) || 0;
}
function rectBox(rect: SVGRectElement): Box & { rx: number } {
return { x: num(rect, 'x'), y: num(rect, 'y'), w: num(rect, 'width'), h: num(rect, 'height'), rx: num(rect, 'rx') };
}
function nodeShape(el: SVGGElement): NodeShape | undefined {
const id = el.getAttribute('data-node-id');
if (!id) return undefined;
const kind = el.getAttribute('data-node-kind') ?? 'backend';
// The node body is the semantic rect (c-<kind>); c-mask sits under it.
const rect =
el.querySelector<SVGRectElement>(`:scope > rect.c-${CSS.escape(kind)}`) ??
el.querySelector<SVGRectElement>(':scope > rect:not(.c-mask)') ??
el.querySelector<SVGRectElement>(':scope > rect');
if (rect) return { id, kind, el, ...rectBox(rect) };
const bbox = el.getBBox();
return { id, kind, el, x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height, rx: 0 };
}
function samplePath(path: SVGPathElement): { points: [number, number][]; length: number } {
const length = path.getTotalLength();
const steps = Math.max(2, Math.ceil(length / SAMPLE_STEP));
const points: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
const p = path.getPointAtLength((length * i) / steps);
points.push([p.x, p.y]);
}
return { points, length };
}
export function readModel(svg: SVGSVGElement): DiagramModel {
const vb = svg.viewBox.baseVal;
const viewBox = vb && vb.width > 0 ? { x: vb.x, y: vb.y, w: vb.width, h: vb.height } : { x: 0, y: 0, w: svg.clientWidth, h: svg.clientHeight };
const nodes = [...svg.querySelectorAll<SVGGElement>('g[data-node-id]')]
.map(nodeShape)
.filter((n): n is NodeShape => !!n && n.w > 0 && n.h > 0);
const edges: EdgeShape[] = [];
svg.querySelectorAll<SVGPathElement>('path[data-edge-from]:not([data-graph-role])').forEach((path, index) => {
const from = path.getAttribute('data-edge-from') ?? '';
const to = path.getAttribute('data-edge-to') ?? '';
const group = path.parentElement;
const { points, length } = samplePath(path);
if (length <= 0) return;
edges.push({
key: path.getAttribute('data-edge-key') ?? `e${index}`,
from,
to,
path,
points,
length,
animated: path.hasAttribute('data-animate') || !!group?.hasAttribute('data-animate'),
});
});
const labels: LabelShape[] = [];
svg.querySelectorAll<SVGGElement>('g[data-edge-key]').forEach((el) => {
if (roleOf(el) !== 'label') return;
const rect = el.querySelector<SVGRectElement>(':scope > rect');
const box = rect ? rectBox(rect) : (() => {
const b = el.getBBox();
return { x: b.x, y: b.y, w: b.width, h: b.height, rx: 0 };
})();
if (box.w > 0 && box.h > 0) labels.push({ key: el.getAttribute('data-edge-key') ?? '', el, ...box });
});
const frames = [...svg.querySelectorAll<SVGRectElement>('rect[data-graph-role="structural-frame"]')].map((el) => ({
kind: el.getAttribute('data-composition-frame-kind') ?? 'region',
el,
...rectBox(el),
}));
return { svg, viewBox, nodes, edges, labels, frames };
}
/**
* Opacity of an element including its ancestors below the SVG root. The
* root is left out: the 3D view hides the 2D SVG by fading the root.
*/
export function effectiveOpacity(el: Element, root: Element): number {
let value = 1;
for (let cur: Element | null = el; cur && cur !== root; cur = cur.parentElement) {
const style = getComputedStyle(cur);
if (style.display === 'none') return 0;
value *= Number(style.opacity || 1);
}
return value;
}
+56 -18
View File
@@ -1,12 +1,18 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { readDiagramSource } from './detect';
import { renderDiagram } from './renderer';
import { evidenceMode, log, readText, runtimeFor, viewerTheme } from './runtime';
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
import { renderDiagram } from '../archify/cli';
import { evidenceMode, runtimeFor } from '../archify/runtime';
import { isArchifyFile, readDiagramSource } from '../sources/detect';
import { readText } from '../util/files';
import { log } from '../util/log';
import { viewerTheme } from '../util/settings';
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
import { makeNonce, messagePage, prepareDiagramHtml } from '../webview/page';
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
export const PREVIEW_VIEW_TYPE = 'archify.preview';
/** Default editor for .archify files: the rendered diagram. */
export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram';
interface PreviewState {
uri: string;
@@ -28,11 +34,15 @@ class DiagramPreview implements vscode.Disposable {
readonly panel: vscode.WebviewPanel,
readonly uri: vscode.Uri,
state?: PreviewState,
/** True when this is the custom editor of the file, not a side preview. */
readonly isEditor = false,
) {
this.hash = state?.hash;
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
panel.title = `Preview ${path.basename(uri.path)}`;
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
if (!isEditor) {
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
panel.title = `Preview ${path.basename(uri.path)}`;
}
this.disposables.push(
panel.onDidDispose(() => this.dispose()),
@@ -94,7 +104,7 @@ class DiagramPreview implements vscode.Disposable {
if (!source) {
this.showMessage(
'Nothing to show yet',
'Set "diagram_type" to architecture, workflow, sequence, dataflow or lifecycle, and make sure the file is valid JSON.',
`Set "diagram_type" to architecture, workflow, sequence, dataflow or lifecycle, and make sure the file is valid ${isArchifyFile(this.uri.path) ? 'YAML' : 'JSON'}.`,
);
return;
}
@@ -102,7 +112,7 @@ class DiagramPreview implements vscode.Disposable {
let result;
try {
const runtime = await runtimeFor(this.context, this.uri);
result = await renderDiagram(runtime, { ...source, text }, evidenceMode(this.uri));
result = await renderDiagram(runtime, source, evidenceMode(this.uri));
} catch (error) {
result = { ok: false, error: error instanceof Error ? error.message : String(error) };
}
@@ -128,6 +138,7 @@ class DiagramPreview implements vscode.Disposable {
hash: this.hash,
state: this.state(),
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
viewer3d: viewer3dOption(this.panel.webview, this.context.extensionUri),
});
// Nonces differ per render, so compare the renderer output instead.
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${result.html}`;
@@ -152,19 +163,30 @@ function firstLines(text: string | undefined, count = 6): string {
return (text ?? '').split('\n').slice(0, count).join('\n');
}
export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState> {
export class DiagramViewManager
implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState>, vscode.CustomTextEditorProvider
{
/** Side previews, one per source file. */
private readonly previews = new Map<string, DiagramPreview>();
/** Custom-editor tabs of .archify files (several per file are allowed). */
private readonly editors = new Set<DiagramPreview>();
private active: DiagramPreview | undefined;
private readonly disposables: vscode.Disposable[] = [];
constructor(private readonly context: vscode.ExtensionContext) {
this.disposables.push(
vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this),
vscode.window.registerCustomEditorProvider(DIAGRAM_EDITOR_VIEW_TYPE, this, {
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
supportsMultipleEditorsPerDocument: true,
}),
vscode.workspace.onDidChangeTextDocument((event) => {
const preview = this.previews.get(event.document.uri.toString());
if (preview && event.contentChanges.length && this.liveUpdate(preview.uri)) preview.scheduleUpdate();
if (!event.contentChanges.length || !this.liveUpdate(event.document.uri)) return;
for (const preview of this.forUri(event.document.uri)) preview.scheduleUpdate();
}),
vscode.workspace.onDidSaveTextDocument((doc) => {
for (const preview of this.forUri(doc.uri)) void preview.update();
}),
vscode.workspace.onDidSaveTextDocument((doc) => void this.previews.get(doc.uri.toString())?.update()),
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('archify')) this.refreshAll();
}),
@@ -180,6 +202,17 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
return this.active;
}
private forUri(uri: vscode.Uri): DiagramPreview[] {
const key = uri.toString();
return [...this.previews.values(), ...this.editors].filter((preview) => preview.uri.toString() === key);
}
/** Opening a .archify file lands here: the diagram is the editor. */
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
this.track(new DiagramPreview(this.context, panel, document.uri, undefined, true));
}
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
const existing = this.previews.get(uri.toString());
if (existing) {
@@ -190,7 +223,7 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
PREVIEW_VIEW_TYPE,
`Preview ${path.basename(uri.path)}`,
{ viewColumn, preserveFocus },
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: [] },
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) },
);
this.track(new DiagramPreview(this.context, panel, uri));
}
@@ -205,8 +238,11 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
private track(preview: DiagramPreview): void {
const key = preview.uri.toString();
this.previews.get(key)?.panel.dispose();
this.previews.set(key, preview);
if (preview.isEditor) this.editors.add(preview);
else {
this.previews.get(key)?.panel.dispose();
this.previews.set(key, preview);
}
const onViewState = () => {
if (preview.panel.active) this.active = preview;
else if (this.active === preview) this.active = undefined;
@@ -215,6 +251,7 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
preview.panel.onDidChangeViewState(onViewState);
onViewState();
preview.onDidDispose(() => {
this.editors.delete(preview);
if (this.previews.get(key) === preview) this.previews.delete(key);
if (this.active === preview) {
this.active = undefined;
@@ -224,12 +261,13 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
}
refreshAll(force = false): void {
for (const preview of this.previews.values()) void preview.update(force);
for (const preview of [...this.previews.values(), ...this.editors]) void preview.update(force);
}
dispose(): void {
for (const preview of this.previews.values()) preview.panel.dispose();
this.previews.clear();
this.editors.clear();
for (const disposable of this.disposables.splice(0)) disposable.dispose();
}
}
@@ -1,9 +1,10 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { isArchifyHtml } from './detect';
import { viewerTheme } from './runtime';
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
import { isArchifyHtml } from '../sources/detect';
import { viewerTheme } from '../util/settings';
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
import { makeNonce, messagePage, prepareDiagramHtml } from '../webview/page';
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
export const HTML_VIEW_TYPE = 'archify.htmlViewer';
@@ -18,7 +19,7 @@ export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<H
private active: { uri: vscode.Uri; panel: vscode.WebviewPanel } | undefined;
private readonly disposables: vscode.Disposable[];
constructor() {
constructor(private readonly extensionUri: vscode.Uri) {
this.disposables = [
vscode.window.registerCustomEditorProvider(HTML_VIEW_TYPE, this, {
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
@@ -47,7 +48,7 @@ export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<H
async resolveCustomEditor(document: HtmlDocument, panel: vscode.WebviewPanel): Promise<void> {
const uri = document.uri;
let hash: string | undefined;
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.extensionUri) };
const reload = async () => {
let text: string;
@@ -70,6 +71,7 @@ export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<H
notice: isArchifyHtml(text)
? undefined
: { kind: 'info', text: 'This file was not generated by Archify. It is shown in a sandbox without network access.' },
viewer3d: isArchifyHtml(text) ? viewer3dOption(panel.webview, this.extensionUri) : undefined,
});
};
@@ -1,6 +1,7 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { log, repoRootFor } from './runtime';
import { repoRootFor } from '../archify/runtime';
import { log } from '../util/log';
export type WebviewMessage =
| { type: 'hash'; hash: string }
+25 -4
View File
@@ -12,6 +12,8 @@ export interface PageOptions {
state?: unknown;
/** Banner shown on top of the diagram. */
notice?: { text: string; kind: 'info' | 'warning' | 'error' };
/** The 3D viewer script (a webview URI), added to Archify pages. */
viewer3d?: { scriptUri: string; defaultView: '2d' | '3d' };
}
export function makeNonce(): string {
@@ -46,7 +48,7 @@ export function contentSecurityPolicy(cspSource: string, nonce: string): string
`media-src ${cspSource} data: blob:`,
`font-src ${cspSource} data:`,
`style-src ${cspSource} 'unsafe-inline'`,
`script-src 'nonce-${nonce}'`,
`script-src ${cspSource} 'nonce-${nonce}'`,
'connect-src data: blob:',
'worker-src blob:',
].join('; ');
@@ -58,11 +60,17 @@ export function contentSecurityPolicy(cspSource: string, nonce: string): string
* webview cannot navigate or download on its own.
*/
function bridgeScript(options: PageOptions): string {
const config = { theme: options.theme, hash: options.hash, state: options.state, notice: options.notice };
const config = {
theme: options.theme,
hash: options.hash,
state: options.state,
notice: options.notice,
defaultView: options.viewer3d?.defaultView,
};
return `(function () {
var config = ${scriptJson(config)};
var vscode = acquireVsCodeApi();
if (config.state) vscode.setState(config.state);
if (config.state) vscode.setState(Object.assign({}, vscode.getState() || {}, config.state));
if (config.theme) {
try { localStorage.setItem('archify-theme', config.theme); } catch (_) {}
}
@@ -70,12 +78,20 @@ function bridgeScript(options: PageOptions): string {
try { history.replaceState(null, '', config.hash); } catch (_) {}
}
window.addEventListener('hashchange', function () {
if (config.state) vscode.setState(Object.assign({}, config.state, { hash: location.hash }));
if (config.state) vscode.setState(Object.assign({}, vscode.getState() || {}, config.state, { hash: location.hash }));
vscode.postMessage({ type: 'hash', hash: location.hash });
});
function post(message) { vscode.postMessage(message); }
// For the 3D viewer, which cannot call acquireVsCodeApi a second time.
window.__archifyHost = {
defaultView: config.defaultView || '2d',
post: post,
getState: function () { return vscode.getState() || {}; },
setState: function (patch) { vscode.setState(Object.assign({}, vscode.getState() || {}, patch)); }
};
function saveBlobUrl(url, filename) {
fetch(url).then(function (response) { return response.blob(); }).then(function (blob) {
var reader = new FileReader();
@@ -189,6 +205,11 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string {
// Allow the page's own inline scripts to run under the nonce policy.
.replace(/<script\b(?![^>]*\bnonce=)/gi, `<script nonce="${options.nonce}"`);
if (options.viewer3d) {
const tag = `<script nonce="${options.nonce}" defer src="${escapeHtml(options.viewer3d.scriptUri)}"></script>\n`;
page = /<\/head>/i.test(page) ? page.replace(/<\/head>/i, `${tag}</head>`) : `${tag}${page}`;
}
const headMatch = /<head\b[^>]*>/i.exec(page);
if (headMatch) {
const at = headMatch.index + headMatch[0].length;
+15
View File
@@ -0,0 +1,15 @@
import * as vscode from 'vscode';
/** Folder of the bundled webview scripts; webviews may load files from it. */
export function viewerRoots(extensionUri: vscode.Uri): vscode.Uri[] {
return [vscode.Uri.joinPath(extensionUri, 'dist')];
}
/** Page option that adds the 3D viewer to an Archify page. */
export function viewer3dOption(webview: vscode.Webview, extensionUri: vscode.Uri): { scriptUri: string; defaultView: '2d' | '3d' } {
const defaultView = vscode.workspace.getConfiguration('archify').get<string>('defaultView', '2d') === '3d' ? '3d' : '2d';
return {
scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'viewer3d.js')).toString(),
defaultView,
};
}
+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/language/brands';
import { diagnosticPointer } from '../src/language/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);
});
+30 -3
View File
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { hasSourceEvidence, isArchifyHtml, readDiagramSource, stripSourceEvidence, typeFromFilename } from '../src/detect';
import { pointerSegments, rangeForPointer } from '../src/jsonPointer';
import { pointerSegments, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from '../src/language/jsonPointer';
import { isArchifyHtml, readDiagramSource, typeFromFilename } from '../src/sources/detect';
import { hasSourceEvidence, stripSourceEvidence } from '../src/sources/evidence';
test('reads diagram_type from content', () => {
const source = readDiagramSource('{"schema_version":1,"diagram_type":"sequence","meta":{}}', 'plain.json');
@@ -49,7 +50,7 @@ test('json pointer ranges point at keys for containers and values for leaves', (
});
test('.archify files are diagram sources typed by their content', async () => {
const { diagramBaseName, isArchifyFile, isDiagramFileName } = await import('../src/detect');
const { diagramBaseName, isArchifyFile, isDiagramFileName } = await import('../src/sources/detect');
assert.ok(isArchifyFile('/w/My Platform.archify'));
assert.ok(isDiagramFileName('/w/x.ARCHIFY'));
assert.ok(isDiagramFileName('/w/x.workflow.json'));
@@ -58,4 +59,30 @@ test('.archify files are diagram sources typed by their content', async () => {
assert.equal(diagramBaseName('/w/web.architecture.json'), 'web.architecture');
assert.equal(readDiagramSource('{"diagram_type":"lifecycle"}', 'run.archify')?.type, 'lifecycle');
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,
"diagram_type": "sequence",
"meta": {
"title": "Cache Miss Request Sequence",
"output": "examples/sequence-cache-miss-request.html",
"viewBox": [1080, 580],
"column_fit": "spread",
"animation": "trace",
"quality_profile": "showcase"
},
"participants": [
{ "id": "user", "type": "external", "label": "User", "sublabel": "browser session" },
{ "id": "web", "type": "frontend", "label": "Web App", "sublabel": "React UI" },
{ "id": "api", "type": "backend", "label": "API", "sublabel": "request handler" },
{ "id": "auth", "type": "security", "label": "Auth", "sublabel": "JWT verify" },
{ "id": "redis", "type": "database", "label": "Redis", "sublabel": "cache" },
{ "id": "db", "type": "database", "label": "Postgres", "sublabel": "source of truth" },
{ "id": "trace", "type": "messagebus", "label": "Trace", "sublabel": "async event" }
],
"segments": [
{ "from": 150, "to": 250, "label": "Request" },
{ "from": 260, "to": 370, "label": "Fallback" },
{ "from": 380, "to": 480, "label": "Response + trace" }
],
"messages": [
{ "id": "open-page", "from": "user", "to": "web", "y": 160, "label": "open page", "variant": "default" },
{ "id": "dashboard-request", "from": "web", "to": "api", "y": 185, "label": "GET /dashboard", "variant": "emphasis" },
{ "id": "verify-jwt", "from": "api", "to": "auth", "y": 210, "label": "verify JWT", "variant": "security" },
{ "id": "auth-claims", "from": "auth", "to": "api", "y": 238, "label": "claims ok", "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"
]
}
]
}
schema_version: 1
diagram_type: sequence
meta:
title: Cache Miss Request Sequence
output: examples/sequence-cache-miss-request.html
viewBox: [1080, 580]
column_fit: spread
animation: trace
quality_profile: showcase
participants:
- id: user
type: external
label: User
sublabel: browser session
- id: web
type: frontend
label: Web App
sublabel: React UI
- id: api
type: backend
label: API
sublabel: request handler
- id: auth
type: security
label: Auth
sublabel: JWT verify
- id: redis
type: database
label: Redis
sublabel: cache
- id: db
type: database
label: Postgres
sublabel: source of truth
- id: trace
type: messagebus
label: Trace
sublabel: async event
segments:
- from: 150
to: 250
label: Request
- from: 260
to: 370
label: Fallback
- from: 380
to: 480
label: Response + trace
messages:
- id: open-page
from: user
to: web
y: 160
label: open page
variant: default
- id: dashboard-request
from: web
to: api
y: 185
label: GET /dashboard
variant: emphasis
- id: verify-jwt
from: api
to: auth
y: 210
label: verify JWT
variant: security
- id: auth-claims
from: auth
to: api
y: 238
label: claims ok
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,
"diagram_type": "architecture",
"meta": {
"title": "Production Deployment Ownership",
"output": "examples/production-deployment.html",
"visual_preset": "blueprint",
"animation": "trace",
"quality_profile": "showcase",
"engineering_profile": "deployment-ownership"
},
"components": [
{ "id": "clients", "type": "external", "label": "Customers", "sublabel": "web + mobile", "pos": [38, 300], "size": [122, 60] },
{ "id": "edge", "type": "cloud", "label": "Global Edge", "sublabel": "CDN + WAF", "pos": [230, 300], "size": [126, 60], "tag": "edge team" },
{ "id": "gateway", "type": "security", "label": "API Gateway", "sublabel": "public :443", "pos": [430, 300], "size": [128, 60], "tag": "platform" },
{ "id": "api_a", "type": "backend", "label": "API Pods / AZ-a", "sublabel": "private subnet", "pos": [630, 195], "size": [136, 62], "tag": "app team" },
{ "id": "api_b", "type": "backend", "label": "API Pods / AZ-b", "sublabel": "private subnet", "pos": [630, 405], "size": [136, 62], "tag": "app team" },
{ "id": "redis", "type": "database", "label": "Redis", "sublabel": "multi-AZ cache", "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"] }
]
}
schema_version: 1
diagram_type: architecture
meta:
title: Production Deployment Ownership
output: examples/production-deployment.html
visual_preset: blueprint
animation: trace
quality_profile: showcase
engineering_profile: deployment-ownership
components:
- id: clients
type: external
label: Customers
sublabel: web + mobile
pos: [38, 300]
size: [122, 60]
- id: edge
type: cloud
label: Global Edge
sublabel: CDN + WAF
pos: [230, 300]
size: [126, 60]
tag: edge team
- id: gateway
type: security
label: API Gateway
sublabel: public :443
pos: [430, 300]
size: [128, 60]
tag: platform
- id: api_a
type: backend
label: API Pods / AZ-a
sublabel: private subnet
pos: [630, 195]
size: [136, 62]
tag: app team
- id: api_b
type: backend
label: API Pods / AZ-b
sublabel: private subnet
pos: [630, 405]
size: [136, 62]
tag: app team
- id: redis
type: database
label: Redis
sublabel: multi-AZ cache
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 check('.archify files open as JSON with the preview available', async () => {
const doc = await vscode.workspace.openTextDocument(workspaceFile('web-platform.archify'));
assert.equal(doc.languageId, 'json');
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand('archify.showPreviewToSide');
const tabs = await waitFor('.archify preview tab', () => {
const found = webviewTabs('archify.preview').filter((tab) => /web-platform\.archify/.test(tab.label));
return found.length > 0 && found;
});
assert.equal(tabs.length, 1);
await check('clicking a .archify file opens the diagram, not the text', async () => {
const uri = workspaceFile('web-platform.archify');
await vscode.commands.executeCommand('vscode.open', uri);
const tab = await waitFor('diagram editor tab', () =>
vscode.window.tabGroups.all
.flatMap((group) => group.tabs)
.find((t) => t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.diagram' && t.input.uri.toString() === uri.toString()),
);
assert.ok(tab.isActive, 'the diagram tab is active');
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 () => {
const doc = await vscode.workspace.openTextDocument(workspaceFile('web-platform.archify'));
await vscode.window.showTextDocument(doc);
// Complete the value of the first component's "type".
const offset = doc.getText().indexOf('"type": "') + '"type": "'.length;
const position = doc.positionAt(offset);
let labels: string[] = [];
for (let attempt = 0; attempt < 50 && !labels.includes('backend'); attempt += 1) {
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 files are YAML and Show Source opens the text beside the diagram', async () => {
const uri = workspaceFile('web-platform.archify');
await vscode.commands.executeCommand('archify.showSource');
const editor = await waitFor('source text editor', () =>
vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri.toString()),
);
assert.equal(editor.document.languageId, 'yaml');
assert.match(editor.document.getText(), /^schema_version: 1/m);
});
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 broken = good.getText().replace('"participants"', '"actors"');
const broken = good.getText().replace(/^participants:/m, 'actors:');
const target = workspaceFile('broken.archify');
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(broken));
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));
});
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('Archify | New Diagram 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, 'Archify | New Diagram…');
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 vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json'));
const tabs = await waitFor('preview tab', () => {
+28 -2
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { prepareDiagramHtml } from '../src/webviewHtml';
import { prepareDiagramHtml } from '../src/webview/page';
const page = `<!DOCTYPE html><html><head>
<meta http-equiv="Content-Security-Policy" content="default-src *">
@@ -12,7 +12,7 @@ test('adds one CSP and nonces every inline script', () => {
const html = prepareDiagramHtml(page, { cspSource: 'vscode-resource:', nonce: 'N0NCE', theme: 'light' });
const csps = html.match(/Content-Security-Policy/g) ?? [];
assert.equal(csps.length, 1);
assert.match(html, /script-src (&#39;|')nonce-N0NCE/);
assert.match(html, /script-src vscode-resource: (&#39;|')nonce-N0NCE/);
const scripts = html.match(/<script\b[^>]*>/g) ?? [];
assert.equal(scripts.length, 4, 'bridge plus the three page scripts');
for (const tag of scripts) assert.match(tag, /nonce="N0NCE"/);
@@ -35,3 +35,29 @@ test('embedded config cannot close the script element', () => {
});
assert.ok(!html.includes('</script><script>alert(1)'));
});
test('adds the 3D viewer script after the page and before </head>, under the nonce', () => {
const html = prepareDiagramHtml(page, {
cspSource: 'x',
nonce: 'n',
theme: 'dark',
viewer3d: { scriptUri: 'https://file+.vscode-resource/ext/dist/viewer3d.js', defaultView: '3d' },
});
const tag = /<script nonce="n" defer src="([^"]+)"><\/script>/.exec(html);
assert.ok(tag, 'deferred script tag with the nonce');
assert.equal(tag[1], 'https://file+.vscode-resource/ext/dist/viewer3d.js');
assert.ok(tag.index < html.indexOf('</head>'));
assert.match(html, /"defaultView":"3d"/);
assert.match(html, /window\.__archifyHost = /);
});
test('pages without the 3D option get no viewer script', () => {
const html = prepareDiagramHtml(page, { cspSource: 'x', nonce: 'n', theme: 'dark' });
assert.ok(!html.includes('viewer3d.js'));
assert.ok(!html.includes('"defaultView":"'));
});
test('saved view state is merged, not replaced, when the hash changes', () => {
const html = prepareDiagramHtml(page, { cspSource: 'x', nonce: 'n', theme: 'dark', state: { uri: 'file:///a.archify' } });
assert.match(html, /vscode\.setState\(Object\.assign\(\{\}, vscode\.getState\(\) \|\| \{\}, config\.state, \{ hash: location\.hash \}\)\)/);
});
+4 -4
View File
@@ -3,8 +3,8 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { readDiagramSource } from '../src/detect';
import { renderDiagram, renderToFile, RuntimeOptions, validate } from '../src/renderer';
import { renderDiagram, renderToFile, RuntimeOptions, validate } from '../src/archify/cli';
import { readDiagramSource } from '../src/sources/detect';
const root = path.resolve(__dirname, '..', '..');
const fixtures = path.join(root, 'test', 'fixtures');
@@ -24,7 +24,7 @@ function load(name: string) {
const text = fs.readFileSync(path.join(fixtures, name), 'utf8');
const source = readDiagramSource(text, name);
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-'))) {
@@ -46,7 +46,7 @@ test('reports schema problems with JSON pointer paths', async () => {
test('render failure returns the CLI error', async () => {
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.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 { test } from 'node:test';
import Ajv2020 from 'ajv/dist/2020';
import { parse as parseYaml } from 'yaml';
const root = path.resolve(__dirname, '..', '..');
const schemas = path.join(root, 'schemas');
@@ -27,14 +28,14 @@ test('bundled schemas carry no absolute $id', () => {
test('the .archify schema accepts every fixture', () => {
const validate = validator();
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))}`);
}
});
test('the .archify schema applies the schema of the declared type', () => {
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.
assert.equal(validate({ ...sequence, diagram_type: 'architecture' }), 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 { validate } from '../src/archify/cli';
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES, titleFromName } from '../src/language/templates';
import { readDiagramSource } from '../src/sources/detect';
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');
});
+1 -1
View File
@@ -2,7 +2,7 @@
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"lib": ["ES2022"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,