Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac9200ccdd | ||
|
|
9871f774f4 | ||
|
|
ca3f08bac9 | ||
|
|
86f6d5c668 | ||
|
|
789ccb2d5c | ||
|
|
adc5cdbe97 | ||
|
|
e9da971523 | ||
|
|
8d8fc6f65e | ||
|
|
6f6ffae96f | ||
|
|
f102e722ec | ||
|
|
3be676f3b2 | ||
|
|
9357176673 | ||
|
|
d1eab34f7f |
No files matched your search
@@ -1,5 +1,31 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 0.2.0 — 2026-09-30
|
||||||
|
|
||||||
- `.archify` files: Archify JSON diagram sources with their own extension. They open as JSON, preview with the same button and keys, report problems, and get completion from the schema of their `diagram_type`.
|
- `.archify` files: Archify JSON diagram sources with their own extension. They open as JSON, preview with the same button and keys, report problems, and get completion from the schema of their `diagram_type`.
|
||||||
|
|||||||
@@ -1,51 +1,74 @@
|
|||||||
# Archify Diagram Viewer
|
# Archify Diagram Viewer
|
||||||
|
|
||||||
View [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. Write a diagram in a `.archify` file and see the interactive diagram next to it, updated as you type. Open rendered Archify HTML files in a viewer tab instead of a browser.
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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.
|
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
|
## Quick start
|
||||||
|
|
||||||
|
The fastest start: right-click a folder in the Explorer and choose **New Diagram Archify…** (also in *File → New File…* and the Command Palette). Pick a kind of diagram (Architecture, Workflow, Sequence, Data Flow, Lifecycle or Everyday Plan), give it a name, and a small working `.archify` file is created and opened. Change its YAML to fit your case.
|
||||||
|
|
||||||
|
To write one by hand:
|
||||||
|
|
||||||
1. Create a file ending in `.archify`, for example `hello.archify`:
|
1. Create a file ending in `.archify`, for example `hello.archify`:
|
||||||
|
|
||||||
```json
|
```yaml
|
||||||
{
|
schema_version: 1
|
||||||
"schema_version": 1,
|
diagram_type: architecture
|
||||||
"diagram_type": "architecture",
|
meta:
|
||||||
"meta": { "title": "Hello Archify", "output": "hello.html" },
|
title: Hello Archify
|
||||||
"components": [
|
output: hello.html
|
||||||
{ "id": "browser", "type": "frontend", "label": "Browser", "pos": [40, 120], "size": [140, 60] },
|
components:
|
||||||
{ "id": "api", "type": "backend", "label": "API", "pos": [260, 120], "size": [140, 60] },
|
- { id: browser, type: frontend, label: Browser, pos: [40, 120], size: [140, 60] }
|
||||||
{ "id": "db", "type": "database", "label": "PostgreSQL", "pos": [480, 120], "size": [140, 60] }
|
- { id: api, type: backend, label: API, pos: [260, 120], size: [140, 60] }
|
||||||
],
|
- { id: db, type: database, label: PostgreSQL, pos: [480, 120], size: [140, 60] }
|
||||||
"connections": [
|
connections:
|
||||||
{ "from": "browser", "to": "api", "label": "HTTPS" },
|
- { from: browser, to: api, label: HTTPS }
|
||||||
{ "from": "api", "to": "db", "label": "SQL" }
|
- { from: api, to: db, label: SQL }
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Click the preview button in the editor title, or press <kbd>Cmd/Ctrl+K V</kbd>.
|
2. Click it in the Explorer: the diagram opens.
|
||||||
|
3. To edit, use **Show Source** in the editor title. The YAML opens beside the diagram, which re-renders as you type.
|
||||||
|
|
||||||
The same file is in [examples/hello.archify](examples/hello.archify).
|
The same file is in [examples/hello.archify](examples/hello.archify).
|
||||||
|
|
||||||
## The `.archify` format
|
## The `.archify` format
|
||||||
|
|
||||||
A `.archify` file is an Archify JSON diagram source, the same JSON the Archify CLI renders. `diagram_type` selects the kind of diagram: `architecture`, `workflow`, `sequence`, `dataflow` or `lifecycle`. VS Code treats `.archify` as JSON, so you get syntax highlighting, formatting, and completion and hover from the schema for that `diagram_type`.
|
A `.archify` file is an Archify diagram 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
|
## Features
|
||||||
|
|
||||||
- **Live preview**: for `.archify` files, Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, <kbd>Cmd/Ctrl+K V</kbd> (to the side) or <kbd>Cmd/Ctrl+Shift+V</kbd>. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders.
|
- **New diagram from a template**: *New Diagram Archify…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder.
|
||||||
- **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.
|
- **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live.
|
||||||
- **Problems**: `archify validate` runs on the source and reports schema, layout and evidence problems at the matching JSON location.
|
- **Live updates**: the diagram re-renders as you edit, and keeps the focus and route you selected (`#focus=…`, `#route=…`).
|
||||||
- **Schema help**: completion and hover documentation come from Archify's own JSON schemas. In `.archify` files, the schema follows `diagram_type`.
|
- **HTML viewer**: right-click a rendered `.html` file and choose **Open in Archify 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.
|
- **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead.
|
||||||
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
|
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
|
||||||
- **Commands**: *Archify: Render to HTML File…*, *Archify: Open in Browser*, *Archify: Refresh Preview*, *Archify: Show Source*.
|
- **Commands**: *New Diagram Archify…*, *Archify: Render to HTML File…*, *Archify: Open in Browser*, *Archify: Refresh Preview*, *Archify: Show Source*.
|
||||||
|
|
||||||
## Source evidence
|
## Source evidence
|
||||||
|
|
||||||
|
|||||||
+28
-17
@@ -1,17 +1,28 @@
|
|||||||
{
|
schema_version: 1
|
||||||
"schema_version": 1,
|
diagram_type: architecture
|
||||||
"diagram_type": "architecture",
|
meta:
|
||||||
"meta": {
|
title: Hello Archify
|
||||||
"title": "Hello Archify",
|
output: hello.html
|
||||||
"output": "hello.html"
|
components:
|
||||||
},
|
- id: browser
|
||||||
"components": [
|
type: frontend
|
||||||
{ "id": "browser", "type": "frontend", "label": "Browser", "pos": [40, 120], "size": [140, 60] },
|
label: Browser
|
||||||
{ "id": "api", "type": "backend", "label": "API", "pos": [260, 120], "size": [140, 60] },
|
pos: [40, 120]
|
||||||
{ "id": "db", "type": "database", "label": "PostgreSQL", "pos": [480, 120], "size": [140, 60] }
|
size: [140, 60]
|
||||||
],
|
- id: api
|
||||||
"connections": [
|
type: backend
|
||||||
{ "from": "browser", "to": "api", "label": "HTTPS" },
|
label: API
|
||||||
{ "from": "api", "to": "db", "label": "SQL" }
|
pos: [260, 120]
|
||||||
]
|
size: [140, 60]
|
||||||
}
|
- id: db
|
||||||
|
type: database
|
||||||
|
label: PostgreSQL
|
||||||
|
pos: [480, 120]
|
||||||
|
size: [140, 60]
|
||||||
|
connections:
|
||||||
|
- from: browser
|
||||||
|
to: api
|
||||||
|
label: HTTPS
|
||||||
|
- from: api
|
||||||
|
to: db
|
||||||
|
label: SQL
|
||||||
Generated
+20
-2
@@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"version": "0.2.0",
|
"version": "0.5.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"version": "0.2.0",
|
"version": "0.5.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.9.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/vscode": "~1.100.0",
|
"@types/vscode": "~1.100.0",
|
||||||
@@ -4684,6 +4687,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yauzl": {
|
"node_modules/yauzl": {
|
||||||
"version": "3.4.0",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
|
||||||
|
|||||||
+47
-15
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"displayName": "Archify Diagram Viewer",
|
"displayName": "Archify Diagram Viewer",
|
||||||
"description": "Visualize Archify diagrams: preview .archify files (and *.architecture.json-style sources) live, and open rendered Archify HTML inside VS Code.",
|
"description": "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, \u2026) and opens rendered Archify HTML.",
|
||||||
"version": "0.2.0",
|
"version": "0.5.1",
|
||||||
"publisher": "root-at-skic",
|
"publisher": "root-at-skic",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"icon": "media/icon.png",
|
"icon": "media/icon.png",
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
],
|
],
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"archify",
|
"archify",
|
||||||
|
"yaml",
|
||||||
"diagram",
|
"diagram",
|
||||||
"architecture",
|
"architecture",
|
||||||
"sequence",
|
"sequence",
|
||||||
@@ -38,6 +39,11 @@
|
|||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"commands": [
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "archify.newDiagram",
|
||||||
|
"title": "New Diagram Archify\u2026",
|
||||||
|
"icon": "$(new-file)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.showPreview",
|
"command": "archify.showPreview",
|
||||||
"title": "Open Preview",
|
"title": "Open Preview",
|
||||||
@@ -52,7 +58,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.openHtmlViewer",
|
"command": "archify.openHtmlViewer",
|
||||||
"title": "Open in Archify Viewer",
|
"title": "Open in Archify HTML Viewer",
|
||||||
"category": "Archify",
|
"category": "Archify",
|
||||||
"icon": "$(open-preview)"
|
"icon": "$(open-preview)"
|
||||||
},
|
},
|
||||||
@@ -97,19 +103,19 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.renderToHtml",
|
"command": "archify.renderToHtml",
|
||||||
"when": "archify.isDiagramSource || archifyPreviewFocus"
|
"when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram'"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.openInBrowser",
|
"command": "archify.openInBrowser",
|
||||||
"when": "archify.isDiagramSource || archify.isArchifyHtml || archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'"
|
"when": "archify.isDiagramSource || archify.isArchifyHtml || archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.refreshPreview",
|
"command": "archify.refreshPreview",
|
||||||
"when": "archifyPreviewFocus"
|
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.diagram'"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.showSource",
|
"command": "archify.showSource",
|
||||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'"
|
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor/title": [
|
"editor/title": [
|
||||||
@@ -130,7 +136,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.showSource",
|
"command": "archify.showSource",
|
||||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer'",
|
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'",
|
||||||
"group": "navigation"
|
"group": "navigation"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -145,6 +151,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"explorer/context": [
|
"explorer/context": [
|
||||||
|
{
|
||||||
|
"command": "archify.newDiagram",
|
||||||
|
"when": "explorerResourceIsFolder",
|
||||||
|
"group": "navigation@10"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.showPreview",
|
"command": "archify.showPreview",
|
||||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||||
@@ -167,6 +178,12 @@
|
|||||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||||
"group": "1_open"
|
"group": "1_open"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"file/newFile": [
|
||||||
|
{
|
||||||
|
"command": "archify.newDiagram",
|
||||||
|
"group": "file"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"keybindings": [
|
"keybindings": [
|
||||||
@@ -184,9 +201,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"customEditors": [
|
"customEditors": [
|
||||||
|
{
|
||||||
|
"viewType": "archify.diagram",
|
||||||
|
"displayName": "Archify Diagram",
|
||||||
|
"selector": [
|
||||||
|
{
|
||||||
|
"filenamePattern": "*.archify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"priority": "default"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"viewType": "archify.htmlViewer",
|
"viewType": "archify.htmlViewer",
|
||||||
"displayName": "Archify Diagram Viewer",
|
"displayName": "Archify HTML Viewer",
|
||||||
"selector": [
|
"selector": [
|
||||||
{
|
{
|
||||||
"filenamePattern": "*.html"
|
"filenamePattern": "*.html"
|
||||||
@@ -196,10 +223,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"jsonValidation": [
|
"jsonValidation": [
|
||||||
{
|
|
||||||
"fileMatch": "*.archify",
|
|
||||||
"url": "./schemas/archify.schema.json"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"fileMatch": "*.architecture.json",
|
"fileMatch": "*.architecture.json",
|
||||||
"url": "./schemas/architecture.schema.json"
|
"url": "./schemas/architecture.schema.json"
|
||||||
@@ -299,11 +322,17 @@
|
|||||||
},
|
},
|
||||||
"languages": [
|
"languages": [
|
||||||
{
|
{
|
||||||
"id": "json",
|
"id": "yaml",
|
||||||
"extensions": [
|
"extensions": [
|
||||||
".archify"
|
".archify"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"yamlValidation": [
|
||||||
|
{
|
||||||
|
"fileMatch": "*.archify",
|
||||||
|
"url": "./schemas/archify.schema.json"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -329,5 +358,8 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext/issues"
|
"url": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme"
|
"homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.9.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { Brand, brandValueAt, loadBrandCatalog } from './brands';
|
||||||
|
import { isArchifyFile } from './detect';
|
||||||
|
import { log } from './runtime';
|
||||||
|
|
||||||
|
const JSON_SOURCES = '**/*.{architecture,workflow,sequence,dataflow,lifecycle}.json';
|
||||||
|
|
||||||
|
export const BRAND_COMPLETION_SELECTOR: vscode.DocumentSelector = [
|
||||||
|
{ pattern: '**/*.archify' },
|
||||||
|
{ language: 'json', pattern: JSON_SOURCES },
|
||||||
|
{ language: 'jsonc', pattern: JSON_SOURCES },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Suggests Archify's built-in brand IDs for `brand` values. */
|
||||||
|
export class BrandCompletionProvider implements vscode.CompletionItemProvider {
|
||||||
|
private brands: Brand[] | undefined;
|
||||||
|
|
||||||
|
constructor(private readonly extensionPath: string) {}
|
||||||
|
|
||||||
|
private catalog(): Brand[] {
|
||||||
|
if (!this.brands) {
|
||||||
|
try {
|
||||||
|
this.brands = loadBrandCatalog(path.join(this.extensionPath, 'vendor', 'archify', 'brand-marks', 'catalog.json'));
|
||||||
|
} catch (error) {
|
||||||
|
log().error(`Could not read the Archify brand catalog: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
this.brands = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.brands;
|
||||||
|
}
|
||||||
|
|
||||||
|
provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): vscode.CompletionItem[] | undefined {
|
||||||
|
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||||
|
const value = brandValueAt(linePrefix, isArchifyFile(document.fileName) ? 'yaml' : 'json');
|
||||||
|
if (!value) return undefined;
|
||||||
|
const range = new vscode.Range(position.translate(0, -value.typed.length), position);
|
||||||
|
return this.catalog().map((brand) => {
|
||||||
|
const item = new vscode.CompletionItem({ label: brand.id, description: brand.title }, vscode.CompletionItemKind.Value);
|
||||||
|
item.detail = `${brand.title} · ${brand.category}`;
|
||||||
|
if (brand.aliases.length) item.documentation = `Also matches: ${brand.aliases.join(', ')}`;
|
||||||
|
// Typing an alias or the product name finds the brand too.
|
||||||
|
item.filterText = [brand.id, ...brand.aliases, brand.title].join(' ');
|
||||||
|
item.range = range;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
export interface Brand {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
aliases: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archify's built-in brand marks, read from brand-marks/catalog.json. */
|
||||||
|
export function loadBrandCatalog(file: string): Brand[] {
|
||||||
|
const catalog = JSON.parse(fs.readFileSync(file, 'utf8')) as { marks?: Partial<Brand>[] };
|
||||||
|
return (catalog.marks ?? [])
|
||||||
|
.filter((mark): mark is Partial<Brand> & { id: string } => typeof mark.id === 'string')
|
||||||
|
.map((mark) => ({ id: mark.id, title: mark.title ?? mark.id, category: mark.category ?? '', aliases: mark.aliases ?? [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandValue {
|
||||||
|
/** What has been typed of the value so far. */
|
||||||
|
typed: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the text before the cursor ends inside a `brand` value:
|
||||||
|
* `brand: post` or `{ id: db, brand: "post` in YAML, `"brand": "post` in JSON.
|
||||||
|
*/
|
||||||
|
export function brandValueAt(linePrefix: string, format: 'yaml' | 'json'): BrandValue | undefined {
|
||||||
|
const match =
|
||||||
|
format === 'yaml'
|
||||||
|
? /(?:^|[\s{,])brand:\s*["']?([\w.-]*)$/.exec(linePrefix)
|
||||||
|
: /"brand"\s*:\s*"([\w.-]*)$/.exec(linePrefix);
|
||||||
|
return match ? { typed: match[1] } : undefined;
|
||||||
|
}
|
||||||
+30
-9
@@ -1,4 +1,5 @@
|
|||||||
import { parse, ParseError } from 'jsonc-parser';
|
import { parse, ParseError } from 'jsonc-parser';
|
||||||
|
import { parse as parseYaml } from 'yaml';
|
||||||
|
|
||||||
export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const;
|
export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const;
|
||||||
export type DiagramType = (typeof DIAGRAM_TYPES)[number];
|
export type DiagramType = (typeof DIAGRAM_TYPES)[number];
|
||||||
@@ -44,24 +45,44 @@ export function diagramBaseName(fileName: string): string {
|
|||||||
export interface DiagramSource {
|
export interface DiagramSource {
|
||||||
type: DiagramType;
|
type: DiagramType;
|
||||||
diagram: Record<string, unknown>;
|
diagram: Record<string, unknown>;
|
||||||
|
/** Source syntax: `.archify` files are YAML, Archify's own sources JSON. */
|
||||||
|
format: 'yaml' | 'json';
|
||||||
|
/** JSON text for the Archify renderer, which only reads JSON. */
|
||||||
|
json: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses `.archify` (YAML) or JSON text; undefined when it does not parse. */
|
||||||
|
export function parseDiagramText(text: string, fileName = ''): unknown {
|
||||||
|
if (isArchifyFile(fileName)) {
|
||||||
|
try {
|
||||||
|
// YAML 1.2 also accepts the JSON form of older .archify files.
|
||||||
|
return parseYaml(text, { prettyErrors: false, uniqueKeys: false });
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const errors: ParseError[] = [];
|
||||||
|
return parse(text, errors, { allowTrailingComma: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses JSON text and returns the diagram when it declares an Archify
|
* Returns the diagram when the text declares an Archify `diagram_type`.
|
||||||
* `diagram_type`. Falls back to the file name for documents that do not
|
* Falls back to the file name for documents that do not parse yet, so a
|
||||||
* parse yet, so a half-typed source still counts as a diagram.
|
* half-typed source still counts as a diagram.
|
||||||
*/
|
*/
|
||||||
export function readDiagramSource(text: string, fileName = ''): DiagramSource | undefined {
|
export function readDiagramSource(text: string, fileName = ''): DiagramSource | undefined {
|
||||||
if (text.length > MAX_SNIFF_BYTES) return undefined;
|
if (text.length > MAX_SNIFF_BYTES) return undefined;
|
||||||
const errors: ParseError[] = [];
|
const format = isArchifyFile(fileName) ? 'yaml' : 'json';
|
||||||
const value = parse(text, errors, { allowTrailingComma: true });
|
const value = parseDiagramText(text, fileName) as Record<string, unknown> | undefined;
|
||||||
if (value && typeof value === 'object' && !Array.isArray(value) && isDiagramType(value.diagram_type)) {
|
const isObject = value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
return { type: value.diagram_type, diagram: value };
|
const json = (diagram: unknown) => (format === 'json' ? text : JSON.stringify(diagram ?? {}, null, 2));
|
||||||
|
if (isObject && isDiagramType(value.diagram_type)) {
|
||||||
|
return { type: value.diagram_type, diagram: value, format, json: json(value) };
|
||||||
}
|
}
|
||||||
const byName = typeFromFilename(fileName);
|
const byName = typeFromFilename(fileName);
|
||||||
if (byName) {
|
if (byName) {
|
||||||
const diagram = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
const diagram = isObject ? value : {};
|
||||||
return { type: byName, diagram };
|
return { type: byName, diagram, format, json: json(diagram) };
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-8
@@ -1,7 +1,7 @@
|
|||||||
import { parse, ParseError } from 'jsonc-parser';
|
import { parse, ParseError } from 'jsonc-parser';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { readDiagramSource } from './detect';
|
import { isArchifyFile, readDiagramSource } from './detect';
|
||||||
import { rangeForPointer } from './jsonPointer';
|
import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
|
||||||
import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer';
|
import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer';
|
||||||
import { evidenceMode, log, runtimeFor } from './runtime';
|
import { evidenceMode, log, runtimeFor } from './runtime';
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private schedule(doc: vscode.TextDocument, delay: number): void {
|
private schedule(doc: vscode.TextDocument, delay: number): void {
|
||||||
if (!JSON_LANGUAGES.has(doc.languageId)) return;
|
if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName)) return;
|
||||||
const key = doc.uri.toString();
|
const key = doc.uri.toString();
|
||||||
clearTimeout(this.timers.get(key));
|
clearTimeout(this.timers.get(key));
|
||||||
this.timers.set(key, setTimeout(() => void this.validate(doc), delay));
|
this.timers.set(key, setTimeout(() => void this.validate(doc), delay));
|
||||||
@@ -74,22 +74,48 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
this.sequence.set(key, seq);
|
this.sequence.set(key, seq);
|
||||||
|
|
||||||
const text = doc.getText();
|
const text = doc.getText();
|
||||||
const source = readDiagramSource(text, doc.fileName);
|
if (!this.enabled(doc.uri) || doc.isClosed) {
|
||||||
if (!source || !this.enabled(doc.uri) || doc.isClosed) {
|
|
||||||
this.collection.delete(doc.uri);
|
this.collection.delete(doc.uri);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Leave syntax errors to the JSON language service.
|
if (isArchifyFile(doc.fileName)) {
|
||||||
|
// 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[] = [];
|
const syntax: ParseError[] = [];
|
||||||
parse(text, syntax, { allowTrailingComma: false });
|
parse(text, syntax, { allowTrailingComma: false });
|
||||||
if (syntax.length) {
|
if (syntax.length) {
|
||||||
this.collection.delete(doc.uri);
|
this.collection.delete(doc.uri);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const runtime = await runtimeFor(this.context, doc.uri);
|
const runtime = await runtimeFor(this.context, doc.uri);
|
||||||
const result = await validate(runtime, source.type, text);
|
const result = await validate(runtime, source.type, source.json);
|
||||||
if (seq !== this.sequence.get(key) || doc.isClosed) return;
|
if (seq !== this.sequence.get(key) || doc.isClosed) return;
|
||||||
|
|
||||||
if (result.crash) {
|
if (result.crash) {
|
||||||
@@ -107,7 +133,8 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
this.collection.set(
|
this.collection.set(
|
||||||
doc.uri,
|
doc.uri,
|
||||||
result.diagnostics.map((item) => {
|
result.diagnostics.map((item) => {
|
||||||
const offsets = rangeForPointer(text, item.subject?.path);
|
const pointer = diagnosticPointer(item);
|
||||||
|
const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, pointer) : rangeForPointer(text, pointer);
|
||||||
const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
|
const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
|
||||||
const fix = item.supportedFixes?.[0];
|
const fix = item.supportedFixes?.[0];
|
||||||
const diagnostic = new vscode.Diagnostic(
|
const diagnostic = new vscode.Diagnostic(
|
||||||
|
|||||||
+88
-8
@@ -1,12 +1,14 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { diagramBaseName, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect';
|
import { BRAND_COMPLETION_SELECTOR, BrandCompletionProvider } from './brandCompletion';
|
||||||
|
import { diagramBaseName, isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect';
|
||||||
import { DiagnosticsController } from './diagnostics';
|
import { DiagnosticsController } from './diagnostics';
|
||||||
import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
|
import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
|
||||||
import { PreviewManager } from './preview';
|
import { DIAGRAM_EDITOR_VIEW_TYPE, PreviewManager } from './preview';
|
||||||
import { isEvidenceError, renderDiagram, renderToFile } from './renderer';
|
import { isEvidenceError, renderDiagram, renderToFile } from './renderer';
|
||||||
import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime';
|
import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime';
|
||||||
|
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES } from './templates';
|
||||||
|
|
||||||
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
||||||
|
|
||||||
@@ -17,7 +19,13 @@ function isHtmlUri(uri: vscode.Uri): boolean {
|
|||||||
export function activate(context: vscode.ExtensionContext): void {
|
export function activate(context: vscode.ExtensionContext): void {
|
||||||
const previews = new PreviewManager(context);
|
const previews = new PreviewManager(context);
|
||||||
const htmlViewer = new HtmlViewerProvider();
|
const htmlViewer = new HtmlViewerProvider();
|
||||||
context.subscriptions.push(previews, htmlViewer, new DiagnosticsController(context), log());
|
context.subscriptions.push(
|
||||||
|
previews,
|
||||||
|
htmlViewer,
|
||||||
|
new DiagnosticsController(context),
|
||||||
|
log(),
|
||||||
|
vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'),
|
||||||
|
);
|
||||||
|
|
||||||
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
|
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
|
||||||
function targetUri(arg?: unknown): vscode.Uri | undefined {
|
function targetUri(arg?: unknown): vscode.Uri | undefined {
|
||||||
@@ -27,7 +35,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
|
|
||||||
function sourceUri(arg?: unknown): vscode.Uri | undefined {
|
function sourceUri(arg?: unknown): vscode.Uri | undefined {
|
||||||
const uri = targetUri(arg);
|
const uri = targetUri(arg);
|
||||||
if (!uri) vscode.window.showWarningMessage('Archify: open a diagram JSON source first.');
|
if (!uri) vscode.window.showWarningMessage('Archify: open a .archify diagram first.');
|
||||||
return uri;
|
return uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +44,9 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
const updateContext = () => {
|
const updateContext = () => {
|
||||||
const doc = vscode.window.activeTextEditor?.document;
|
const doc = vscode.window.activeTextEditor?.document;
|
||||||
const diagram = Boolean(
|
const diagram = Boolean(
|
||||||
doc && JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)),
|
doc &&
|
||||||
|
(isArchifyFile(doc.fileName) ||
|
||||||
|
(JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)))),
|
||||||
);
|
);
|
||||||
const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
|
const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
|
||||||
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
|
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
|
||||||
@@ -59,11 +69,32 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
|
const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
|
||||||
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
|
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
|
||||||
|
|
||||||
|
const exists = async (uri: vscode.Uri) => {
|
||||||
|
try {
|
||||||
|
await vscode.workspace.fs.stat(uri);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Folder for a new diagram: the clicked folder (or a file's folder), else a workspace folder. */
|
||||||
|
async function newDiagramFolder(arg?: unknown): Promise<vscode.Uri | undefined> {
|
||||||
|
if (arg instanceof vscode.Uri) {
|
||||||
|
const stat = await vscode.workspace.fs.stat(arg);
|
||||||
|
return stat.type & vscode.FileType.Directory ? arg : vscode.Uri.joinPath(arg, '..');
|
||||||
|
}
|
||||||
|
const folders = vscode.workspace.workspaceFolders ?? [];
|
||||||
|
if (folders.length <= 1) return folders[0]?.uri;
|
||||||
|
return (await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Folder for the new diagram' }))?.uri;
|
||||||
|
}
|
||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
|
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
|
||||||
const uri = sourceUri(arg);
|
const uri = sourceUri(arg);
|
||||||
if (!uri) return;
|
if (!uri) return;
|
||||||
if (isHtmlUri(uri)) return openHtmlViewer(uri);
|
if (isHtmlUri(uri)) return openHtmlViewer(uri);
|
||||||
|
if (isArchifyFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||||
previews.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
|
previews.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -71,6 +102,12 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
const uri = sourceUri(arg);
|
const uri = sourceUri(arg);
|
||||||
if (!uri) return;
|
if (!uri) return;
|
||||||
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside);
|
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside);
|
||||||
|
if (isArchifyFile(uri.path)) {
|
||||||
|
return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE, {
|
||||||
|
viewColumn: vscode.ViewColumn.Beside,
|
||||||
|
preserveFocus: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
previews.show(uri, vscode.ViewColumn.Beside, true);
|
previews.show(uri, vscode.ViewColumn.Beside, true);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -79,10 +116,53 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
if (uri) return openHtmlViewer(uri);
|
if (uri) return openHtmlViewer(uri);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand('archify.newDiagram', async (arg?: unknown) => {
|
||||||
|
const template = await vscode.window.showQuickPick(
|
||||||
|
TEMPLATES.map((item) => ({ label: item.label, description: item.description, template: item })),
|
||||||
|
{ title: 'New Diagram Archify', placeHolder: 'Kind of diagram' },
|
||||||
|
);
|
||||||
|
if (!template) return;
|
||||||
|
const { file, defaultName } = template.template;
|
||||||
|
|
||||||
|
const folder = await newDiagramFolder(arg);
|
||||||
|
let target: vscode.Uri | undefined;
|
||||||
|
if (folder) {
|
||||||
|
const name = await vscode.window.showInputBox({
|
||||||
|
title: 'New Diagram Archify',
|
||||||
|
prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}`,
|
||||||
|
value: defaultName,
|
||||||
|
validateInput: async (value) =>
|
||||||
|
invalidNameReason(value) ??
|
||||||
|
((await exists(vscode.Uri.joinPath(folder, `${diagramName(value)}.archify`))) ? `${diagramName(value)}.archify already exists.` : undefined),
|
||||||
|
});
|
||||||
|
if (name === undefined) return;
|
||||||
|
target = vscode.Uri.joinPath(folder, `${diagramName(name)}.archify`);
|
||||||
|
} else {
|
||||||
|
// No workspace: let the user choose where to save.
|
||||||
|
target = await vscode.window.showSaveDialog({
|
||||||
|
title: 'New Diagram Archify',
|
||||||
|
filters: { 'Archify Diagram': ['archify'] },
|
||||||
|
saveLabel: 'Create',
|
||||||
|
});
|
||||||
|
if (!target) return;
|
||||||
|
if (!isArchifyFile(target.path)) target = target.with({ path: `${target.path}.archify` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateText = await readText(vscode.Uri.joinPath(context.extensionUri, 'templates', file));
|
||||||
|
const text = diagramFromTemplate(templateText, diagramBaseName(target.path));
|
||||||
|
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
|
||||||
|
await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||||
|
}),
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.refreshPreview', () => previews.activePreview?.update(true)),
|
vscode.commands.registerCommand('archify.refreshPreview', () => previews.activePreview?.update(true)),
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.showSource', async () => {
|
vscode.commands.registerCommand('archify.showSource', async () => {
|
||||||
const preview = previews.activePreview;
|
const preview = previews.activePreview;
|
||||||
|
if (preview?.isEditor) {
|
||||||
|
// The diagram is the editor; open the YAML text beside it.
|
||||||
|
await vscode.commands.executeCommand('vscode.openWith', preview.uri, 'default', vscode.ViewColumn.Beside);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (preview) {
|
if (preview) {
|
||||||
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
||||||
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
|
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
|
||||||
@@ -115,7 +195,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
const runtime = await runtimeFor(context, uri);
|
const runtime = await runtimeFor(context, uri);
|
||||||
let result = await vscode.window.withProgress(
|
let result = await vscode.window.withProgress(
|
||||||
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
||||||
() => renderToFile(runtime, { ...source, text }, target.fsPath),
|
() => renderToFile(runtime, source, target.fsPath),
|
||||||
);
|
);
|
||||||
if (!result.ok && isEvidenceError(result.error)) {
|
if (!result.ok && isEvidenceError(result.error)) {
|
||||||
const choice = await vscode.window.showWarningMessage(
|
const choice = await vscode.window.showWarningMessage(
|
||||||
@@ -123,7 +203,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
{ modal: true, detail: result.error },
|
{ modal: true, detail: result.error },
|
||||||
'Render Without Source Links',
|
'Render Without Source Links',
|
||||||
);
|
);
|
||||||
if (choice) result = await renderToFile(runtime, { ...source, text }, target.fsPath, true);
|
if (choice) result = await renderToFile(runtime, source, target.fsPath, true);
|
||||||
else return;
|
else return;
|
||||||
}
|
}
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
@@ -156,7 +236,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
const runtime = await runtimeFor(context, uri);
|
const runtime = await runtimeFor(context, uri);
|
||||||
const result = await vscode.window.withProgress(
|
const result = await vscode.window.withProgress(
|
||||||
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
||||||
() => renderDiagram(runtime, { ...source, text }, evidenceMode(uri)),
|
() => renderDiagram(runtime, source, evidenceMode(uri)),
|
||||||
);
|
);
|
||||||
if (!result.ok || !result.html) {
|
if (!result.ok || !result.html) {
|
||||||
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { findNodeAtLocation, Node, parseTree } from 'jsonc-parser';
|
import { findNodeAtLocation, Node, parseTree } from 'jsonc-parser';
|
||||||
|
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
||||||
|
|
||||||
export interface OffsetRange {
|
export interface OffsetRange {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JSON pointer an Archify diagnostic is about. Most diagnostics carry it
|
||||||
|
* in subject.path; brand diagnostics only start their message with it.
|
||||||
|
*/
|
||||||
|
export function diagnosticPointer(diagnostic: { message: string; subject?: { path?: string } }): string | undefined {
|
||||||
|
return diagnostic.subject?.path ?? /^(\/[^\s"]+)\s/.exec(diagnostic.message)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
/** Splits an RFC 6901 JSON pointer into path segments. */
|
/** Splits an RFC 6901 JSON pointer into path segments. */
|
||||||
export function pointerSegments(pointer: string): (string | number)[] {
|
export function pointerSegments(pointer: string): (string | number)[] {
|
||||||
if (!pointer || pointer === '/') return [];
|
if (!pointer || pointer === '/') return [];
|
||||||
@@ -37,3 +46,42 @@ export function rangeForPointer(text: string, pointer: string | undefined): Offs
|
|||||||
}
|
}
|
||||||
return { start: node.offset, end: node.offset + node.length };
|
return { start: node.offset, end: node.offset + node.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The same mapping for YAML text (`.archify` files). */
|
||||||
|
export function rangeForPointerYaml(text: string, pointer: string | undefined): OffsetRange {
|
||||||
|
const doc = parseDocument(text, { keepSourceTokens: false, uniqueKeys: false });
|
||||||
|
let node: unknown = doc.contents;
|
||||||
|
let keyRange: [number, number] | undefined;
|
||||||
|
for (const segment of pointerSegments(pointer ?? '')) {
|
||||||
|
if (isMap(node)) {
|
||||||
|
const pair = node.items.find((item) => isScalar(item.key) && String(item.key.value) === String(segment));
|
||||||
|
if (!pair) break;
|
||||||
|
keyRange = isScalar(pair.key) && pair.key.range ? [pair.key.range[0], pair.key.range[1]] : undefined;
|
||||||
|
node = pair.value;
|
||||||
|
} else if (isSeq(node) && typeof segment === 'number' && segment < node.items.length) {
|
||||||
|
keyRange = undefined;
|
||||||
|
node = node.items[segment];
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((isMap(node) || isSeq(node)) && keyRange) return { start: keyRange[0], end: keyRange[1] };
|
||||||
|
const range = (node as { range?: [number, number, number] } | null)?.range;
|
||||||
|
if (!range) return { start: 0, end: 0 };
|
||||||
|
if (isMap(node) || isSeq(node)) return { start: range[0], end: Math.min(range[0] + 1, range[1]) };
|
||||||
|
return { start: range[0], end: range[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyntaxProblem extends OffsetRange {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YAML syntax errors with their offsets (none for valid YAML). */
|
||||||
|
export function yamlSyntaxProblems(text: string): SyntaxProblem[] {
|
||||||
|
const doc = parseDocument(text, { uniqueKeys: false });
|
||||||
|
return doc.errors.map((error) => ({
|
||||||
|
start: error.pos[0],
|
||||||
|
end: Math.max(error.pos[1], error.pos[0] + 1),
|
||||||
|
message: error.message.split('\n')[0],
|
||||||
|
}));
|
||||||
|
}
|
||||||
+41
-8
@@ -1,12 +1,14 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { readDiagramSource } from './detect';
|
import { isArchifyFile, readDiagramSource } from './detect';
|
||||||
import { renderDiagram } from './renderer';
|
import { renderDiagram } from './renderer';
|
||||||
import { evidenceMode, log, readText, runtimeFor, viewerTheme } from './runtime';
|
import { evidenceMode, log, readText, runtimeFor, viewerTheme } from './runtime';
|
||||||
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
|
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
|
||||||
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
|
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
|
||||||
|
|
||||||
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
||||||
|
/** Default editor for .archify files: the rendered diagram. */
|
||||||
|
export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram';
|
||||||
|
|
||||||
interface PreviewState {
|
interface PreviewState {
|
||||||
uri: string;
|
uri: string;
|
||||||
@@ -28,11 +30,15 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
readonly panel: vscode.WebviewPanel,
|
readonly panel: vscode.WebviewPanel,
|
||||||
readonly uri: vscode.Uri,
|
readonly uri: vscode.Uri,
|
||||||
state?: PreviewState,
|
state?: PreviewState,
|
||||||
|
/** True when this is the custom editor of the file, not a side preview. */
|
||||||
|
readonly isEditor = false,
|
||||||
) {
|
) {
|
||||||
this.hash = state?.hash;
|
this.hash = state?.hash;
|
||||||
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
|
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
|
||||||
|
if (!isEditor) {
|
||||||
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
|
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
|
||||||
panel.title = `Preview ${path.basename(uri.path)}`;
|
panel.title = `Preview ${path.basename(uri.path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
panel.onDidDispose(() => this.dispose()),
|
panel.onDidDispose(() => this.dispose()),
|
||||||
@@ -94,7 +100,7 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
if (!source) {
|
if (!source) {
|
||||||
this.showMessage(
|
this.showMessage(
|
||||||
'Nothing to show yet',
|
'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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -102,7 +108,7 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
const runtime = await runtimeFor(this.context, this.uri);
|
const runtime = await runtimeFor(this.context, this.uri);
|
||||||
result = await renderDiagram(runtime, { ...source, text }, evidenceMode(this.uri));
|
result = await renderDiagram(runtime, source, evidenceMode(this.uri));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
result = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
}
|
}
|
||||||
@@ -152,19 +158,30 @@ function firstLines(text: string | undefined, count = 6): string {
|
|||||||
return (text ?? '').split('\n').slice(0, count).join('\n');
|
return (text ?? '').split('\n').slice(0, count).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState> {
|
export class PreviewManager
|
||||||
|
implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState>, vscode.CustomTextEditorProvider
|
||||||
|
{
|
||||||
|
/** Side previews, one per source file. */
|
||||||
private readonly previews = new Map<string, DiagramPreview>();
|
private readonly previews = new Map<string, DiagramPreview>();
|
||||||
|
/** Custom-editor tabs of .archify files (several per file are allowed). */
|
||||||
|
private readonly editors = new Set<DiagramPreview>();
|
||||||
private active: DiagramPreview | undefined;
|
private active: DiagramPreview | undefined;
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
|
|
||||||
constructor(private readonly context: vscode.ExtensionContext) {
|
constructor(private readonly context: vscode.ExtensionContext) {
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this),
|
vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this),
|
||||||
|
vscode.window.registerCustomEditorProvider(DIAGRAM_EDITOR_VIEW_TYPE, this, {
|
||||||
|
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||||
|
supportsMultipleEditorsPerDocument: true,
|
||||||
|
}),
|
||||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||||
const preview = this.previews.get(event.document.uri.toString());
|
if (!event.contentChanges.length || !this.liveUpdate(event.document.uri)) return;
|
||||||
if (preview && event.contentChanges.length && this.liveUpdate(preview.uri)) preview.scheduleUpdate();
|
for (const preview of this.forUri(event.document.uri)) preview.scheduleUpdate();
|
||||||
|
}),
|
||||||
|
vscode.workspace.onDidSaveTextDocument((doc) => {
|
||||||
|
for (const preview of this.forUri(doc.uri)) void preview.update();
|
||||||
}),
|
}),
|
||||||
vscode.workspace.onDidSaveTextDocument((doc) => void this.previews.get(doc.uri.toString())?.update()),
|
|
||||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||||
if (event.affectsConfiguration('archify')) this.refreshAll();
|
if (event.affectsConfiguration('archify')) this.refreshAll();
|
||||||
}),
|
}),
|
||||||
@@ -180,6 +197,17 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
|
|||||||
return this.active;
|
return this.active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private forUri(uri: vscode.Uri): DiagramPreview[] {
|
||||||
|
const key = uri.toString();
|
||||||
|
return [...this.previews.values(), ...this.editors].filter((preview) => preview.uri.toString() === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opening a .archify file lands here: the diagram is the editor. */
|
||||||
|
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||||
|
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
|
||||||
|
this.track(new DiagramPreview(this.context, panel, document.uri, undefined, true));
|
||||||
|
}
|
||||||
|
|
||||||
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
|
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
|
||||||
const existing = this.previews.get(uri.toString());
|
const existing = this.previews.get(uri.toString());
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -205,8 +233,11 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
|
|||||||
|
|
||||||
private track(preview: DiagramPreview): void {
|
private track(preview: DiagramPreview): void {
|
||||||
const key = preview.uri.toString();
|
const key = preview.uri.toString();
|
||||||
|
if (preview.isEditor) this.editors.add(preview);
|
||||||
|
else {
|
||||||
this.previews.get(key)?.panel.dispose();
|
this.previews.get(key)?.panel.dispose();
|
||||||
this.previews.set(key, preview);
|
this.previews.set(key, preview);
|
||||||
|
}
|
||||||
const onViewState = () => {
|
const onViewState = () => {
|
||||||
if (preview.panel.active) this.active = preview;
|
if (preview.panel.active) this.active = preview;
|
||||||
else if (this.active === preview) this.active = undefined;
|
else if (this.active === preview) this.active = undefined;
|
||||||
@@ -215,6 +246,7 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
|
|||||||
preview.panel.onDidChangeViewState(onViewState);
|
preview.panel.onDidChangeViewState(onViewState);
|
||||||
onViewState();
|
onViewState();
|
||||||
preview.onDidDispose(() => {
|
preview.onDidDispose(() => {
|
||||||
|
this.editors.delete(preview);
|
||||||
if (this.previews.get(key) === preview) this.previews.delete(key);
|
if (this.previews.get(key) === preview) this.previews.delete(key);
|
||||||
if (this.active === preview) {
|
if (this.active === preview) {
|
||||||
this.active = undefined;
|
this.active = undefined;
|
||||||
@@ -224,12 +256,13 @@ export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
refreshAll(force = false): void {
|
refreshAll(force = false): void {
|
||||||
for (const preview of this.previews.values()) void preview.update(force);
|
for (const preview of [...this.previews.values(), ...this.editors]) void preview.update(force);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
for (const preview of this.previews.values()) preview.panel.dispose();
|
for (const preview of this.previews.values()) preview.panel.dispose();
|
||||||
this.previews.clear();
|
this.previews.clear();
|
||||||
|
this.editors.clear();
|
||||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-4
@@ -144,7 +144,7 @@ async function renderOnce(
|
|||||||
*/
|
*/
|
||||||
export async function renderDiagram(
|
export async function renderDiagram(
|
||||||
options: RuntimeOptions,
|
options: RuntimeOptions,
|
||||||
source: DiagramSource & { text: string },
|
source: DiagramSource,
|
||||||
evidence: 'fallback' | 'strict',
|
evidence: 'fallback' | 'strict',
|
||||||
): Promise<RenderResult> {
|
): Promise<RenderResult> {
|
||||||
const withEvidence = hasSourceEvidence(source.type, source.diagram);
|
const withEvidence = hasSourceEvidence(source.type, source.diagram);
|
||||||
@@ -156,7 +156,7 @@ export async function renderDiagram(
|
|||||||
evidenceNotice: 'Source links are hidden: the diagram is not inside a Git checkout, so its source evidence cannot be verified. Set "archify.repoRoot" to the matching checkout.',
|
evidenceNotice: 'Source links are hidden: the diagram is not inside a Git checkout, so its source evidence cannot be verified. Set "archify.repoRoot" to the matching checkout.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const first = await renderOnce(options, source.type, source.text, withEvidence);
|
const first = await renderOnce(options, source.type, source.json, withEvidence);
|
||||||
if (first.ok || !withEvidence || evidence !== 'fallback' || !isEvidenceError(first.error)) {
|
if (first.ok || !withEvidence || evidence !== 'fallback' || !isEvidenceError(first.error)) {
|
||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
@@ -173,13 +173,13 @@ export async function renderDiagram(
|
|||||||
*/
|
*/
|
||||||
export async function renderToFile(
|
export async function renderToFile(
|
||||||
options: RuntimeOptions,
|
options: RuntimeOptions,
|
||||||
source: DiagramSource & { text: string },
|
source: DiagramSource,
|
||||||
outputPath: string,
|
outputPath: string,
|
||||||
stripEvidence = false,
|
stripEvidence = false,
|
||||||
): Promise<RenderResult> {
|
): Promise<RenderResult> {
|
||||||
const text = stripEvidence
|
const text = stripEvidence
|
||||||
? JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2)
|
? JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2)
|
||||||
: source.text;
|
: source.json;
|
||||||
const withEvidence = !stripEvidence && hasSourceEvidence(source.type, source.diagram);
|
const withEvidence = !stripEvidence && hasSourceEvidence(source.type, source.diagram);
|
||||||
return withScratch(options, async (dir) => {
|
return withScratch(options, async (dir) => {
|
||||||
const input = path.join(dir, `diagram.${source.type}.json`);
|
const input = path.join(dir, `diagram.${source.type}.json`);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { isMap, parseDocument } from 'yaml';
|
||||||
|
import { DiagramType } from './detect';
|
||||||
|
|
||||||
|
export interface DiagramTemplate {
|
||||||
|
/** File name under templates/. */
|
||||||
|
file: string;
|
||||||
|
type: DiagramType;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
/** Suggested name for the new file, without .archify. */
|
||||||
|
defaultName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TEMPLATES: readonly DiagramTemplate[] = [
|
||||||
|
{ file: 'architecture.archify', type: 'architecture', label: 'Architecture', description: 'Parts of a system and how they connect', defaultName: 'architecture' },
|
||||||
|
{ file: 'workflow.archify', type: 'workflow', label: 'Workflow', description: 'Steps of a process, in lanes', defaultName: 'workflow' },
|
||||||
|
{ file: 'sequence.archify', type: 'sequence', label: 'Sequence', description: 'Who calls whom, in order', defaultName: 'sequence' },
|
||||||
|
{ file: 'dataflow.archify', type: 'dataflow', label: 'Data Flow', description: 'Where data comes from and where it goes', defaultName: 'dataflow' },
|
||||||
|
{ file: 'lifecycle.archify', type: 'lifecycle', label: 'Lifecycle', description: 'States something moves through', defaultName: 'lifecycle' },
|
||||||
|
{ file: 'everyday-plan.archify', type: 'workflow', label: 'Everyday Plan', description: 'A plan or process from daily life', defaultName: 'plan' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Strips a typed .archify suffix and surrounding spaces. */
|
||||||
|
export function diagramName(input: string): string {
|
||||||
|
return input.trim().replace(/\.archify$/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a name cannot be used as a file name, or undefined when it can. */
|
||||||
|
export function invalidNameReason(input: string): string | undefined {
|
||||||
|
const name = diagramName(input);
|
||||||
|
if (!name) return 'Enter a name.';
|
||||||
|
if (/[\\/:*?"<>|\u0000-\u001f]/.test(name)) return 'A name cannot contain \\ / : * ? " < > | or control characters.';
|
||||||
|
if (/^\.+$/.test(name)) return 'Enter a name.';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "sales-report_v2" → "Sales report v2". */
|
||||||
|
export function titleFromName(name: string): string {
|
||||||
|
const words = name.replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template YAML with meta.title and meta.output set for the new file.
|
||||||
|
* Works on the YAML document so the template's comments and layout stay.
|
||||||
|
*/
|
||||||
|
export function diagramFromTemplate(templateText: string, name: string): string {
|
||||||
|
const doc = parseDocument(templateText);
|
||||||
|
if (!isMap(doc.get('meta'))) doc.set('meta', doc.createNode({}));
|
||||||
|
doc.setIn(['meta', 'title'], titleFromName(name));
|
||||||
|
doc.setIn(['meta', 'output'], `${name}.html`);
|
||||||
|
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { brandValueAt, loadBrandCatalog } from '../src/brands';
|
||||||
|
import { diagnosticPointer } from '../src/jsonPointer';
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..', '..');
|
||||||
|
const catalogFile = path.join(root, 'vendor', 'archify', 'brand-marks', 'catalog.json');
|
||||||
|
|
||||||
|
test('loads the bundled brand catalog', () => {
|
||||||
|
const brands = loadBrandCatalog(catalogFile);
|
||||||
|
assert.ok(brands.length >= 100, `${brands.length} brands`);
|
||||||
|
const postgres = brands.find((brand) => brand.id === 'postgresql');
|
||||||
|
assert.ok(postgres);
|
||||||
|
assert.ok(postgres.aliases.includes('postgres'));
|
||||||
|
assert.ok(brands.every((brand) => brand.id && brand.title));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds a brand value being typed in YAML', () => {
|
||||||
|
assert.deepEqual(brandValueAt(' brand: ', 'yaml'), { typed: '' });
|
||||||
|
assert.deepEqual(brandValueAt(' brand: post', 'yaml'), { typed: 'post' });
|
||||||
|
assert.deepEqual(brandValueAt(' - brand: apache-ka', 'yaml'), { typed: 'apache-ka' });
|
||||||
|
assert.deepEqual(brandValueAt(' brand: "red', 'yaml'), { typed: 'red' });
|
||||||
|
assert.deepEqual(brandValueAt(' - { id: db, brand: post', 'yaml'), { typed: 'post' });
|
||||||
|
assert.equal(brandValueAt(' label: post', 'yaml'), undefined);
|
||||||
|
assert.equal(brandValueAt(' subbrand: post', 'yaml'), undefined);
|
||||||
|
assert.equal(brandValueAt(' brand: postgresql ', 'yaml'), undefined, 'value already finished');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds a brand value being typed in JSON', () => {
|
||||||
|
assert.deepEqual(brandValueAt(' "brand": "', 'json'), { typed: '' });
|
||||||
|
assert.deepEqual(brandValueAt('{ "id": "db", "brand": "post', 'json'), { typed: 'post' });
|
||||||
|
assert.equal(brandValueAt(' "brand": ', 'json'), undefined, 'not inside the string yet');
|
||||||
|
assert.equal(brandValueAt(' "label": "post', 'json'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diagnostic pointer comes from subject.path, else from a leading pointer in the message', () => {
|
||||||
|
assert.equal(diagnosticPointer({ message: 'x', subject: { path: '/components/0' } }), '/components/0');
|
||||||
|
assert.equal(
|
||||||
|
diagnosticPointer({ message: '/components/0/brand "aws" is not a built-in brand; closest IDs: airtable' }),
|
||||||
|
'/components/0/brand',
|
||||||
|
);
|
||||||
|
assert.equal(diagnosticPointer({ message: 'Label "x" is wider than node' }), undefined);
|
||||||
|
});
|
||||||
+27
-1
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { hasSourceEvidence, isArchifyHtml, readDiagramSource, stripSourceEvidence, typeFromFilename } from '../src/detect';
|
import { hasSourceEvidence, isArchifyHtml, readDiagramSource, stripSourceEvidence, typeFromFilename } from '../src/detect';
|
||||||
import { pointerSegments, rangeForPointer } from '../src/jsonPointer';
|
import { pointerSegments, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from '../src/jsonPointer';
|
||||||
|
|
||||||
test('reads diagram_type from content', () => {
|
test('reads diagram_type from content', () => {
|
||||||
const source = readDiagramSource('{"schema_version":1,"diagram_type":"sequence","meta":{}}', 'plain.json');
|
const source = readDiagramSource('{"schema_version":1,"diagram_type":"sequence","meta":{}}', 'plain.json');
|
||||||
@@ -58,4 +58,30 @@ test('.archify files are diagram sources typed by their content', async () => {
|
|||||||
assert.equal(diagramBaseName('/w/web.architecture.json'), 'web.architecture');
|
assert.equal(diagramBaseName('/w/web.architecture.json'), 'web.architecture');
|
||||||
assert.equal(readDiagramSource('{"diagram_type":"lifecycle"}', 'run.archify')?.type, 'lifecycle');
|
assert.equal(readDiagramSource('{"diagram_type":"lifecycle"}', 'run.archify')?.type, 'lifecycle');
|
||||||
assert.equal(readDiagramSource('{"title":"no type yet"}', 'run.archify'), undefined);
|
assert.equal(readDiagramSource('{"title":"no type yet"}', 'run.archify'), undefined);
|
||||||
|
const yaml = readDiagramSource('schema_version: 1\ndiagram_type: sequence\nmeta:\n title: T\n', 'flow.archify');
|
||||||
|
assert.equal(yaml?.type, 'sequence');
|
||||||
|
assert.equal(yaml?.format, 'yaml');
|
||||||
|
assert.deepEqual(JSON.parse(yaml!.json), { schema_version: 1, diagram_type: 'sequence', meta: { title: 'T' } });
|
||||||
|
assert.equal(readDiagramSource('diagram_type: [unclosed', 'bad.archify'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('yaml pointer ranges match the json behaviour', () => {
|
||||||
|
const text = 'meta:\n title: x\ncomponents:\n - id: a\n label: A\n';
|
||||||
|
const meta = rangeForPointerYaml(text, '/meta');
|
||||||
|
assert.equal(text.slice(meta.start, meta.end), 'meta');
|
||||||
|
const title = rangeForPointerYaml(text, '/meta/title');
|
||||||
|
assert.equal(text.slice(title.start, title.end), 'x');
|
||||||
|
const item = rangeForPointerYaml(text, '/components/0');
|
||||||
|
assert.equal(text.slice(item.start, item.end), 'i', 'first character of the item');
|
||||||
|
const label = rangeForPointerYaml(text, '/components/0/label');
|
||||||
|
assert.equal(text.slice(label.start, label.end), 'A');
|
||||||
|
const missing = rangeForPointerYaml(text, '/components/0/type');
|
||||||
|
assert.equal(text.slice(missing.start, missing.end), 'i', 'falls back to the nearest existing ancestor');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('yaml syntax problems carry offsets', () => {
|
||||||
|
assert.deepEqual(yamlSyntaxProblems('a: 1\nb: 2\n'), []);
|
||||||
|
const problems = yamlSyntaxProblems('a: [1, 2\nb: 3\n');
|
||||||
|
assert.ok(problems.length > 0);
|
||||||
|
assert.ok(problems[0].start >= 0 && problems[0].end > problems[0].start);
|
||||||
});
|
});
|
||||||
Vendored
+165
-77
@@ -1,77 +1,165 @@
|
|||||||
{
|
schema_version: 1
|
||||||
"schema_version": 1,
|
diagram_type: sequence
|
||||||
"diagram_type": "sequence",
|
meta:
|
||||||
"meta": {
|
title: Cache Miss Request Sequence
|
||||||
"title": "Cache Miss Request Sequence",
|
output: examples/sequence-cache-miss-request.html
|
||||||
"output": "examples/sequence-cache-miss-request.html",
|
viewBox: [1080, 580]
|
||||||
"viewBox": [1080, 580],
|
column_fit: spread
|
||||||
"column_fit": "spread",
|
animation: trace
|
||||||
"animation": "trace",
|
quality_profile: showcase
|
||||||
"quality_profile": "showcase"
|
participants:
|
||||||
},
|
- id: user
|
||||||
"participants": [
|
type: external
|
||||||
{ "id": "user", "type": "external", "label": "User", "sublabel": "browser session" },
|
label: User
|
||||||
{ "id": "web", "type": "frontend", "label": "Web App", "sublabel": "React UI" },
|
sublabel: browser session
|
||||||
{ "id": "api", "type": "backend", "label": "API", "sublabel": "request handler" },
|
- id: web
|
||||||
{ "id": "auth", "type": "security", "label": "Auth", "sublabel": "JWT verify" },
|
type: frontend
|
||||||
{ "id": "redis", "type": "database", "label": "Redis", "sublabel": "cache" },
|
label: Web App
|
||||||
{ "id": "db", "type": "database", "label": "Postgres", "sublabel": "source of truth" },
|
sublabel: React UI
|
||||||
{ "id": "trace", "type": "messagebus", "label": "Trace", "sublabel": "async event" }
|
- id: api
|
||||||
],
|
type: backend
|
||||||
"segments": [
|
label: API
|
||||||
{ "from": 150, "to": 250, "label": "Request" },
|
sublabel: request handler
|
||||||
{ "from": 260, "to": 370, "label": "Fallback" },
|
- id: auth
|
||||||
{ "from": 380, "to": 480, "label": "Response + trace" }
|
type: security
|
||||||
],
|
label: Auth
|
||||||
"messages": [
|
sublabel: JWT verify
|
||||||
{ "id": "open-page", "from": "user", "to": "web", "y": 160, "label": "open page", "variant": "default" },
|
- id: redis
|
||||||
{ "id": "dashboard-request", "from": "web", "to": "api", "y": 185, "label": "GET /dashboard", "variant": "emphasis" },
|
type: database
|
||||||
{ "id": "verify-jwt", "from": "api", "to": "auth", "y": 210, "label": "verify JWT", "variant": "security" },
|
label: Redis
|
||||||
{ "id": "auth-claims", "from": "auth", "to": "api", "y": 238, "label": "claims ok", "variant": "return" },
|
sublabel: cache
|
||||||
{ "id": "cache-read", "from": "api", "to": "redis", "y": 270, "label": "read cache", "variant": "default" },
|
- id: db
|
||||||
{ "id": "cache-miss", "from": "redis", "to": "api", "y": 298, "label": "miss", "variant": "return" },
|
type: database
|
||||||
{ "id": "profile-query", "from": "api", "to": "db", "y": 330, "label": "query profile + metrics", "variant": "emphasis" },
|
label: Postgres
|
||||||
{ "id": "profile-rows", "from": "db", "to": "api", "y": 358, "label": "rows", "variant": "return" },
|
sublabel: source of truth
|
||||||
{ "id": "cache-write", "from": "api", "to": "redis", "y": 390, "label": "set cache", "variant": "dashed" },
|
- id: trace
|
||||||
{ "id": "trace-emit", "from": "api", "to": "trace", "y": 418, "label": "emit trace", "variant": "dashed" },
|
type: messagebus
|
||||||
{ "id": "dashboard-response", "from": "api", "to": "web", "y": 443, "label": "200 JSON", "variant": "return" },
|
label: Trace
|
||||||
{ "id": "page-render", "from": "web", "to": "user", "y": 468, "label": "render", "variant": "return" }
|
sublabel: async event
|
||||||
],
|
segments:
|
||||||
"activations": [
|
- from: 150
|
||||||
{ "participant": "web", "from": 180, "to": 474, "type": "frontend" },
|
to: 250
|
||||||
{ "participant": "api", "from": 185, "to": 450, "type": "backend" },
|
label: Request
|
||||||
{ "participant": "auth", "from": 205, "to": 244, "type": "security" },
|
- from: 260
|
||||||
{ "participant": "redis", "from": 265, "to": 304, "type": "database" },
|
to: 370
|
||||||
{ "participant": "db", "from": 325, "to": 364, "type": "database" },
|
label: Fallback
|
||||||
{ "participant": "trace", "from": 413, "to": 449, "type": "messagebus" }
|
- from: 380
|
||||||
],
|
to: 480
|
||||||
"cards": [
|
label: Response + trace
|
||||||
{
|
messages:
|
||||||
"dot": "emerald",
|
- id: open-page
|
||||||
"title": "Happy Path",
|
from: user
|
||||||
"items": [
|
to: web
|
||||||
"The main request is Web App -> API -> data source -> response",
|
y: 160
|
||||||
"Return messages are quieter than forward calls",
|
label: open page
|
||||||
"Activation bars make ownership duration visible"
|
variant: default
|
||||||
]
|
- id: dashboard-request
|
||||||
},
|
from: web
|
||||||
{
|
to: api
|
||||||
"dot": "rose",
|
y: 185
|
||||||
"title": "Policy + Fallback",
|
label: GET /dashboard
|
||||||
"items": [
|
variant: emphasis
|
||||||
"JWT verification is colored as a security interaction",
|
- id: verify-jwt
|
||||||
"Cache miss is visible without overpowering the main path",
|
from: api
|
||||||
"Database access only appears after cache fallback"
|
to: auth
|
||||||
]
|
y: 210
|
||||||
},
|
label: verify JWT
|
||||||
{
|
variant: security
|
||||||
"dot": "orange",
|
- id: auth-claims
|
||||||
"title": "Async Trace",
|
from: auth
|
||||||
"items": [
|
to: api
|
||||||
"Trace emission is dashed and secondary",
|
y: 238
|
||||||
"It does not block the response path",
|
label: claims ok
|
||||||
"The diagram separates user-facing latency from observability"
|
variant: return
|
||||||
]
|
- id: cache-read
|
||||||
}
|
from: api
|
||||||
]
|
to: redis
|
||||||
}
|
y: 270
|
||||||
|
label: read cache
|
||||||
|
variant: default
|
||||||
|
- id: cache-miss
|
||||||
|
from: redis
|
||||||
|
to: api
|
||||||
|
y: 298
|
||||||
|
label: miss
|
||||||
|
variant: return
|
||||||
|
- id: profile-query
|
||||||
|
from: api
|
||||||
|
to: db
|
||||||
|
y: 330
|
||||||
|
label: query profile + metrics
|
||||||
|
variant: emphasis
|
||||||
|
- id: profile-rows
|
||||||
|
from: db
|
||||||
|
to: api
|
||||||
|
y: 358
|
||||||
|
label: rows
|
||||||
|
variant: return
|
||||||
|
- id: cache-write
|
||||||
|
from: api
|
||||||
|
to: redis
|
||||||
|
y: 390
|
||||||
|
label: set cache
|
||||||
|
variant: dashed
|
||||||
|
- id: trace-emit
|
||||||
|
from: api
|
||||||
|
to: trace
|
||||||
|
y: 418
|
||||||
|
label: emit trace
|
||||||
|
variant: dashed
|
||||||
|
- id: dashboard-response
|
||||||
|
from: api
|
||||||
|
to: web
|
||||||
|
y: 443
|
||||||
|
label: 200 JSON
|
||||||
|
variant: return
|
||||||
|
- id: page-render
|
||||||
|
from: web
|
||||||
|
to: user
|
||||||
|
y: 468
|
||||||
|
label: render
|
||||||
|
variant: return
|
||||||
|
activations:
|
||||||
|
- participant: web
|
||||||
|
from: 180
|
||||||
|
to: 474
|
||||||
|
type: frontend
|
||||||
|
- participant: api
|
||||||
|
from: 185
|
||||||
|
to: 450
|
||||||
|
type: backend
|
||||||
|
- participant: auth
|
||||||
|
from: 205
|
||||||
|
to: 244
|
||||||
|
type: security
|
||||||
|
- participant: redis
|
||||||
|
from: 265
|
||||||
|
to: 304
|
||||||
|
type: database
|
||||||
|
- participant: db
|
||||||
|
from: 325
|
||||||
|
to: 364
|
||||||
|
type: database
|
||||||
|
- participant: trace
|
||||||
|
from: 413
|
||||||
|
to: 449
|
||||||
|
type: messagebus
|
||||||
|
cards:
|
||||||
|
- dot: emerald
|
||||||
|
title: Happy Path
|
||||||
|
items:
|
||||||
|
- The main request is Web App -> API -> data source -> response
|
||||||
|
- Return messages are quieter than forward calls
|
||||||
|
- Activation bars make ownership duration visible
|
||||||
|
- dot: rose
|
||||||
|
title: Policy + Fallback
|
||||||
|
items:
|
||||||
|
- JWT verification is colored as a security interaction
|
||||||
|
- Cache miss is visible without overpowering the main path
|
||||||
|
- Database access only appears after cache fallback
|
||||||
|
- dot: orange
|
||||||
|
title: Async Trace
|
||||||
|
items:
|
||||||
|
- Trace emission is dashed and secondary
|
||||||
|
- It does not block the response path
|
||||||
|
- The diagram separates user-facing latency from observability
|
||||||
Vendored
+212
-51
@@ -1,51 +1,212 @@
|
|||||||
{
|
schema_version: 1
|
||||||
"schema_version": 1,
|
diagram_type: architecture
|
||||||
"diagram_type": "architecture",
|
meta:
|
||||||
"meta": {
|
title: Production Deployment Ownership
|
||||||
"title": "Production Deployment Ownership",
|
output: examples/production-deployment.html
|
||||||
"output": "examples/production-deployment.html",
|
visual_preset: blueprint
|
||||||
"visual_preset": "blueprint",
|
animation: trace
|
||||||
"animation": "trace",
|
quality_profile: showcase
|
||||||
"quality_profile": "showcase",
|
engineering_profile: deployment-ownership
|
||||||
"engineering_profile": "deployment-ownership"
|
components:
|
||||||
},
|
- id: clients
|
||||||
"components": [
|
type: external
|
||||||
{ "id": "clients", "type": "external", "label": "Customers", "sublabel": "web + mobile", "pos": [38, 300], "size": [122, 60] },
|
label: Customers
|
||||||
{ "id": "edge", "type": "cloud", "label": "Global Edge", "sublabel": "CDN + WAF", "pos": [230, 300], "size": [126, 60], "tag": "edge team" },
|
sublabel: web + mobile
|
||||||
{ "id": "gateway", "type": "security", "label": "API Gateway", "sublabel": "public :443", "pos": [430, 300], "size": [128, 60], "tag": "platform" },
|
pos: [38, 300]
|
||||||
{ "id": "api_a", "type": "backend", "label": "API Pods / AZ-a", "sublabel": "private subnet", "pos": [630, 195], "size": [136, 62], "tag": "app team" },
|
size: [122, 60]
|
||||||
{ "id": "api_b", "type": "backend", "label": "API Pods / AZ-b", "sublabel": "private subnet", "pos": [630, 405], "size": [136, 62], "tag": "app team" },
|
- id: edge
|
||||||
{ "id": "redis", "type": "database", "label": "Redis", "sublabel": "multi-AZ cache", "pos": [840, 195], "size": [126, 62], "tag": "platform" },
|
type: cloud
|
||||||
{ "id": "postgres", "type": "database", "label": "PostgreSQL", "sublabel": "primary / encrypted", "pos": [840, 405], "size": [126, 62], "tag": "data team" },
|
label: Global Edge
|
||||||
{ "id": "events", "type": "messagebus", "label": "Event Bus", "sublabel": "orders.v1", "pos": [1040, 300], "size": [126, 60], "tag": "platform" },
|
sublabel: CDN + WAF
|
||||||
{ "id": "worker", "type": "backend", "label": "Workers", "sublabel": "private workload", "pos": [1190, 300], "size": [126, 60], "tag": "app team" },
|
pos: [230, 300]
|
||||||
{ "id": "replica", "type": "database", "label": "DR Replica", "sublabel": "eu-west-1", "pos": [1040, 578], "size": [126, 62], "tag": "data team" },
|
size: [126, 60]
|
||||||
{ "id": "audit", "type": "cloud", "label": "Audit Archive", "sublabel": "immutable objects", "pos": [1190, 450], "size": [126, 62], "tag": "security" },
|
tag: edge team
|
||||||
{ "id": "observability", "type": "external", "label": "Observability", "sublabel": "metrics + traces", "pos": [1190, 85], "size": [126, 62], "tag": "SRE" }
|
- id: gateway
|
||||||
],
|
type: security
|
||||||
"boundaries": [
|
label: API Gateway
|
||||||
{ "kind": "region", "label": "AWS us-east-1 / production", "wraps": ["edge", "gateway", "api_a", "api_b", "redis", "postgres", "events", "worker", "audit"], "pad": 20 },
|
sublabel: public :443
|
||||||
{ "kind": "security-group", "label": "private application network", "wraps": ["api_a", "api_b", "redis", "postgres", "events", "worker"], "pad": 14 },
|
pos: [430, 300]
|
||||||
{ "kind": "region", "label": "AWS eu-west-1 / disaster recovery", "wraps": ["replica"] },
|
size: [128, 60]
|
||||||
{ "kind": "security-group", "label": "DR private subnet", "wraps": ["replica"], "pad": 14 }
|
tag: platform
|
||||||
],
|
- id: api_a
|
||||||
"connections": [
|
type: backend
|
||||||
{ "from": "clients", "to": "edge", "label": "HTTPS", "variant": "emphasis" },
|
label: API Pods / AZ-a
|
||||||
{ "from": "edge", "to": "gateway", "label": "mTLS", "variant": "security" },
|
sublabel: private subnet
|
||||||
{ "from": "gateway", "to": "api_a", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 275] },
|
pos: [630, 195]
|
||||||
{ "from": "gateway", "to": "api_b", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 385] },
|
size: [136, 62]
|
||||||
{ "from": "api_a", "to": "redis", "label": "cache", "route": "straight" },
|
tag: app team
|
||||||
{ "from": "api_b", "to": "postgres", "label": "SQL", "route": "straight" },
|
- id: api_b
|
||||||
{ "from": "api_a", "to": "events", "label": "publish", "variant": "dashed", "fromSide": "top", "toSide": "top", "via": [[698, 170], [1103, 170]] },
|
type: backend
|
||||||
{ "from": "api_b", "to": "events", "variant": "dashed", "fromSide": "top", "toSide": "bottom", "via": [[698, 380], [1103, 380]] },
|
label: API Pods / AZ-b
|
||||||
{ "from": "events", "to": "worker", "variant": "emphasis" },
|
sublabel: private subnet
|
||||||
{ "from": "postgres", "to": "replica", "label": "cross-region WAL", "variant": "security", "route": "orthogonal-v", "labelAt": [1003, 529] },
|
pos: [630, 405]
|
||||||
{ "from": "worker", "to": "audit", "label": "evidence", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "labelDy": 58 },
|
size: [136, 62]
|
||||||
{ "from": "worker", "to": "observability", "label": "OTLP", "variant": "dashed", "route": "orthogonal-v" }
|
tag: app team
|
||||||
],
|
- id: redis
|
||||||
"cards": [
|
type: database
|
||||||
{ "dot": "cyan", "title": "Runtime Ownership", "items": ["Platform owns the edge, gateway, cache, and event bus", "Application teams own API pods and workers", "Data owns primary and disaster-recovery state"] },
|
label: Redis
|
||||||
{ "dot": "rose", "title": "Named Crossings", "items": ["Public HTTPS terminates at the managed edge", "mTLS crosses into the application network", "Cross-region WAL is explicit and encrypted"] },
|
sublabel: multi-AZ cache
|
||||||
{ "dot": "emerald", "title": "Operational Evidence", "items": ["Workers emit traces to SRE-owned observability", "Audit evidence lands in immutable storage", "Unknown placement should remain marked, never invented"] }
|
pos: [840, 195]
|
||||||
]
|
size: [126, 62]
|
||||||
}
|
tag: platform
|
||||||
|
- id: postgres
|
||||||
|
type: database
|
||||||
|
label: PostgreSQL
|
||||||
|
sublabel: primary / encrypted
|
||||||
|
pos: [840, 405]
|
||||||
|
size: [126, 62]
|
||||||
|
tag: data team
|
||||||
|
- id: events
|
||||||
|
type: messagebus
|
||||||
|
label: Event Bus
|
||||||
|
sublabel: orders.v1
|
||||||
|
pos: [1040, 300]
|
||||||
|
size: [126, 60]
|
||||||
|
tag: platform
|
||||||
|
- id: worker
|
||||||
|
type: backend
|
||||||
|
label: Workers
|
||||||
|
sublabel: private workload
|
||||||
|
pos: [1190, 300]
|
||||||
|
size: [126, 60]
|
||||||
|
tag: app team
|
||||||
|
- id: replica
|
||||||
|
type: database
|
||||||
|
label: DR Replica
|
||||||
|
sublabel: eu-west-1
|
||||||
|
pos: [1040, 578]
|
||||||
|
size: [126, 62]
|
||||||
|
tag: data team
|
||||||
|
- id: audit
|
||||||
|
type: cloud
|
||||||
|
label: Audit Archive
|
||||||
|
sublabel: immutable objects
|
||||||
|
pos: [1190, 450]
|
||||||
|
size: [126, 62]
|
||||||
|
tag: security
|
||||||
|
- id: observability
|
||||||
|
type: external
|
||||||
|
label: Observability
|
||||||
|
sublabel: metrics + traces
|
||||||
|
pos: [1190, 85]
|
||||||
|
size: [126, 62]
|
||||||
|
tag: SRE
|
||||||
|
boundaries:
|
||||||
|
- kind: region
|
||||||
|
label: AWS us-east-1 / production
|
||||||
|
wraps:
|
||||||
|
- edge
|
||||||
|
- gateway
|
||||||
|
- api_a
|
||||||
|
- api_b
|
||||||
|
- redis
|
||||||
|
- postgres
|
||||||
|
- events
|
||||||
|
- worker
|
||||||
|
- audit
|
||||||
|
pad: 20
|
||||||
|
- kind: security-group
|
||||||
|
label: private application network
|
||||||
|
wraps:
|
||||||
|
- api_a
|
||||||
|
- api_b
|
||||||
|
- redis
|
||||||
|
- postgres
|
||||||
|
- events
|
||||||
|
- worker
|
||||||
|
pad: 14
|
||||||
|
- kind: region
|
||||||
|
label: AWS eu-west-1 / disaster recovery
|
||||||
|
wraps:
|
||||||
|
- replica
|
||||||
|
- kind: security-group
|
||||||
|
label: DR private subnet
|
||||||
|
wraps:
|
||||||
|
- replica
|
||||||
|
pad: 14
|
||||||
|
connections:
|
||||||
|
- from: clients
|
||||||
|
to: edge
|
||||||
|
label: HTTPS
|
||||||
|
variant: emphasis
|
||||||
|
- from: edge
|
||||||
|
to: gateway
|
||||||
|
label: mTLS
|
||||||
|
variant: security
|
||||||
|
- from: gateway
|
||||||
|
to: api_a
|
||||||
|
label: VPC route
|
||||||
|
variant: emphasis
|
||||||
|
route: orthogonal-h
|
||||||
|
labelAt: [594, 275]
|
||||||
|
- from: gateway
|
||||||
|
to: api_b
|
||||||
|
label: VPC route
|
||||||
|
variant: emphasis
|
||||||
|
route: orthogonal-h
|
||||||
|
labelAt: [594, 385]
|
||||||
|
- from: api_a
|
||||||
|
to: redis
|
||||||
|
label: cache
|
||||||
|
route: straight
|
||||||
|
- from: api_b
|
||||||
|
to: postgres
|
||||||
|
label: SQL
|
||||||
|
route: straight
|
||||||
|
- from: api_a
|
||||||
|
to: events
|
||||||
|
label: publish
|
||||||
|
variant: dashed
|
||||||
|
fromSide: top
|
||||||
|
toSide: top
|
||||||
|
via:
|
||||||
|
- [698, 170]
|
||||||
|
- [1103, 170]
|
||||||
|
- from: api_b
|
||||||
|
to: events
|
||||||
|
variant: dashed
|
||||||
|
fromSide: top
|
||||||
|
toSide: bottom
|
||||||
|
via:
|
||||||
|
- [698, 380]
|
||||||
|
- [1103, 380]
|
||||||
|
- from: events
|
||||||
|
to: worker
|
||||||
|
variant: emphasis
|
||||||
|
- from: postgres
|
||||||
|
to: replica
|
||||||
|
label: cross-region WAL
|
||||||
|
variant: security
|
||||||
|
route: orthogonal-v
|
||||||
|
labelAt: [1003, 529]
|
||||||
|
- from: worker
|
||||||
|
to: audit
|
||||||
|
label: evidence
|
||||||
|
variant: dashed
|
||||||
|
fromSide: bottom
|
||||||
|
toSide: top
|
||||||
|
labelDy: 58
|
||||||
|
- from: worker
|
||||||
|
to: observability
|
||||||
|
label: OTLP
|
||||||
|
variant: dashed
|
||||||
|
route: orthogonal-v
|
||||||
|
cards:
|
||||||
|
- dot: cyan
|
||||||
|
title: Runtime Ownership
|
||||||
|
items:
|
||||||
|
- Platform owns the edge, gateway, cache, and event bus
|
||||||
|
- Application teams own API pods and workers
|
||||||
|
- Data owns primary and disaster-recovery state
|
||||||
|
- dot: rose
|
||||||
|
title: Named Crossings
|
||||||
|
items:
|
||||||
|
- Public HTTPS terminates at the managed edge
|
||||||
|
- mTLS crosses into the application network
|
||||||
|
- Cross-region WAL is explicit and encrypted
|
||||||
|
- dot: emerald
|
||||||
|
title: Operational Evidence
|
||||||
|
items:
|
||||||
|
- Workers emit traces to SRE-owned observability
|
||||||
|
- Audit evidence lands in immutable storage
|
||||||
|
- Unknown placement should remain marked, never invented
|
||||||
+114
-25
@@ -49,36 +49,31 @@ export async function run(): Promise<void> {
|
|||||||
await waitFor('activation', () => extension.isActive);
|
await waitFor('activation', () => extension.isActive);
|
||||||
});
|
});
|
||||||
|
|
||||||
await check('.archify files open as JSON with the preview available', async () => {
|
await check('clicking a .archify file opens the diagram, not the text', async () => {
|
||||||
const doc = await vscode.workspace.openTextDocument(workspaceFile('web-platform.archify'));
|
const uri = workspaceFile('web-platform.archify');
|
||||||
assert.equal(doc.languageId, 'json');
|
await vscode.commands.executeCommand('vscode.open', uri);
|
||||||
await vscode.window.showTextDocument(doc);
|
const tab = await waitFor('diagram editor tab', () =>
|
||||||
await vscode.commands.executeCommand('archify.showPreviewToSide');
|
vscode.window.tabGroups.all
|
||||||
const tabs = await waitFor('.archify preview tab', () => {
|
.flatMap((group) => group.tabs)
|
||||||
const found = webviewTabs('archify.preview').filter((tab) => /web-platform\.archify/.test(tab.label));
|
.find((t) => t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.diagram' && t.input.uri.toString() === uri.toString()),
|
||||||
return found.length > 0 && found;
|
);
|
||||||
});
|
assert.ok(tab.isActive, 'the diagram tab is active');
|
||||||
assert.equal(tabs.length, 1);
|
assert.equal(vscode.window.activeTextEditor?.document.uri.toString() === uri.toString(), false, 'no text editor was opened for it');
|
||||||
});
|
});
|
||||||
|
|
||||||
await check('.archify completion comes from the schema of its diagram_type', async () => {
|
await check('.archify files are YAML and Show Source opens the text beside the diagram', async () => {
|
||||||
const doc = await vscode.workspace.openTextDocument(workspaceFile('web-platform.archify'));
|
const uri = workspaceFile('web-platform.archify');
|
||||||
await vscode.window.showTextDocument(doc);
|
await vscode.commands.executeCommand('archify.showSource');
|
||||||
// Complete the value of the first component's "type".
|
const editor = await waitFor('source text editor', () =>
|
||||||
const offset = doc.getText().indexOf('"type": "') + '"type": "'.length;
|
vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri.toString()),
|
||||||
const position = doc.positionAt(offset);
|
);
|
||||||
let labels: string[] = [];
|
assert.equal(editor.document.languageId, 'yaml');
|
||||||
for (let attempt = 0; attempt < 50 && !labels.includes('backend'); attempt += 1) {
|
assert.match(editor.document.getText(), /^schema_version: 1/m);
|
||||||
const list = await vscode.commands.executeCommand<vscode.CompletionList>('vscode.executeCompletionItemProvider', doc.uri, position);
|
|
||||||
labels = list.items.map((item) => (typeof item.label === 'string' ? item.label : item.label.label).replace(/"/g, ''));
|
|
||||||
if (!labels.includes('backend')) await new Promise((resolve) => setTimeout(resolve, 300));
|
|
||||||
}
|
|
||||||
assert.ok(labels.includes('backend') && labels.includes('database'), `offered: ${labels.join(', ')}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await check('.archify problems are reported against the file', async () => {
|
await check('.archify validation problems are reported against the YAML', async () => {
|
||||||
const good = await vscode.workspace.openTextDocument(workspaceFile('cache-miss.archify'));
|
const good = await vscode.workspace.openTextDocument(workspaceFile('cache-miss.archify'));
|
||||||
const broken = good.getText().replace('"participants"', '"actors"');
|
const broken = good.getText().replace(/^participants:/m, 'actors:');
|
||||||
const target = workspaceFile('broken.archify');
|
const target = workspaceFile('broken.archify');
|
||||||
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(broken));
|
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(broken));
|
||||||
const doc = await vscode.workspace.openTextDocument(target);
|
const doc = await vscode.workspace.openTextDocument(target);
|
||||||
@@ -90,6 +85,100 @@ export async function run(): Promise<void> {
|
|||||||
assert.ok(diagnostics.some((d) => d.severity === vscode.DiagnosticSeverity.Error));
|
assert.ok(diagnostics.some((d) => d.severity === vscode.DiagnosticSeverity.Error));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await check('.archify YAML syntax errors are reported at their line', async () => {
|
||||||
|
const target = workspaceFile('syntax.archify');
|
||||||
|
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode('schema_version: 1\ndiagram_type: architecture\nmeta: [unclosed\n'));
|
||||||
|
const doc = await vscode.workspace.openTextDocument(target);
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
const diagnostics = await waitFor('yaml syntax diagnostics', () => {
|
||||||
|
const found = vscode.languages.getDiagnostics(doc.uri).filter((d) => d.source === 'archify' && d.message.startsWith('YAML:'));
|
||||||
|
return found.length > 0 && found;
|
||||||
|
});
|
||||||
|
assert.ok(diagnostics[0].range.start.line >= 2, `reported on line ${diagnostics[0].range.start.line + 1}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('New Diagram Archify creates a .archify file from a template and opens the diagram', async () => {
|
||||||
|
// Menus show the bare title, so it must name Archify by itself.
|
||||||
|
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes.commands as { command: string; title: string; category?: string }[];
|
||||||
|
const newDiagram = commands.find((item) => item.command === 'archify.newDiagram');
|
||||||
|
assert.equal(newDiagram?.title, 'New Diagram Archify…');
|
||||||
|
assert.equal(newDiagram?.category, undefined);
|
||||||
|
const folder = workspaceFile('.');
|
||||||
|
// Stub the template pick and the name prompt; check the name validation on the way.
|
||||||
|
const window = vscode.window as unknown as Record<string, unknown>;
|
||||||
|
const originalPick = window.showQuickPick;
|
||||||
|
const originalInput = window.showInputBox;
|
||||||
|
let existingNameMessage: unknown;
|
||||||
|
window.showQuickPick = async (items: readonly { label: string }[]) => (await items).find((item) => item.label === 'Everyday Plan');
|
||||||
|
window.showInputBox = async (options: vscode.InputBoxOptions) => {
|
||||||
|
existingNameMessage = await options.validateInput?.('web-platform');
|
||||||
|
return 'summer-plan';
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await vscode.commands.executeCommand('archify.newDiagram', folder);
|
||||||
|
} finally {
|
||||||
|
window.showQuickPick = originalPick;
|
||||||
|
window.showInputBox = originalInput;
|
||||||
|
}
|
||||||
|
assert.match(String(existingNameMessage), /already exists/);
|
||||||
|
const uri = vscode.Uri.joinPath(folder, 'summer-plan.archify');
|
||||||
|
const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(uri));
|
||||||
|
assert.match(text, /^diagram_type: workflow$/m);
|
||||||
|
assert.match(text, /^ {2}title: Summer plan$/m);
|
||||||
|
assert.match(text, /^ {2}output: summer-plan\.html$/m);
|
||||||
|
await waitFor('diagram editor for the new file', () =>
|
||||||
|
webviewTabs('archify.diagram').some((tab) => tab.input instanceof vscode.TabInputCustom && tab.input.uri.toString() === uri.toString()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('suggests brand IDs after brand: in .archify files', async () => {
|
||||||
|
const target = workspaceFile('brands.archify');
|
||||||
|
const text = [
|
||||||
|
'schema_version: 1',
|
||||||
|
'diagram_type: architecture',
|
||||||
|
'meta:',
|
||||||
|
' title: Brands',
|
||||||
|
'components:',
|
||||||
|
' - id: db',
|
||||||
|
' type: database',
|
||||||
|
' label: DB',
|
||||||
|
' brand: ',
|
||||||
|
' - id: bus',
|
||||||
|
' type: messagebus',
|
||||||
|
' label: Bus',
|
||||||
|
' brand: kafka',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(text));
|
||||||
|
const doc = await vscode.workspace.openTextDocument(target);
|
||||||
|
const labels = async (line: number) => {
|
||||||
|
const list = await vscode.commands.executeCommand<vscode.CompletionList>(
|
||||||
|
'vscode.executeCompletionItemProvider',
|
||||||
|
doc.uri,
|
||||||
|
new vscode.Position(line, doc.lineAt(line).text.length),
|
||||||
|
);
|
||||||
|
return list.items.map((item) => (typeof item.label === 'string' ? item.label : item.label.label));
|
||||||
|
};
|
||||||
|
const empty = await labels(8);
|
||||||
|
assert.ok(empty.includes('postgresql') && empty.includes('redis'), 'brand IDs offered after "brand: "');
|
||||||
|
const kafka = await labels(12);
|
||||||
|
assert.ok(kafka.includes('apache-kafka'), 'the alias "kafka" finds apache-kafka');
|
||||||
|
assert.equal((await labels(7)).includes('postgresql'), false, 'nothing offered for other fields');
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('an unknown brand is reported on its own line', async () => {
|
||||||
|
const target = workspaceFile('unknown-brand.archify');
|
||||||
|
const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(workspaceFile('web-platform.archify')))
|
||||||
|
.replace(/^( {2}- id: [^\n]+\n)/m, '$1 brand: not-a-brand\n');
|
||||||
|
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(text));
|
||||||
|
const doc = await vscode.workspace.openTextDocument(target);
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
const diagnostic = await waitFor('brand/unknown diagnostic', () =>
|
||||||
|
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && d.code === 'brand/unknown'),
|
||||||
|
);
|
||||||
|
assert.match(doc.lineAt(diagnostic.range.start.line).text, /brand: not-a-brand/);
|
||||||
|
});
|
||||||
|
|
||||||
await check('opens a live preview beside the source', async () => {
|
await check('opens a live preview beside the source', async () => {
|
||||||
await vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json'));
|
await vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json'));
|
||||||
const tabs = await waitFor('preview tab', () => {
|
const tabs = await waitFor('preview tab', () => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function load(name: string) {
|
|||||||
const text = fs.readFileSync(path.join(fixtures, name), 'utf8');
|
const text = fs.readFileSync(path.join(fixtures, name), 'utf8');
|
||||||
const source = readDiagramSource(text, name);
|
const source = readDiagramSource(text, name);
|
||||||
assert.ok(source, `${name} is a diagram source`);
|
assert.ok(source, `${name} is a diagram source`);
|
||||||
return { ...source, text };
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const name of fs.readdirSync(fixtures).filter((file) => !file.startsWith('mco-'))) {
|
for (const name of fs.readdirSync(fixtures).filter((file) => !file.startsWith('mco-'))) {
|
||||||
@@ -46,7 +46,7 @@ test('reports schema problems with JSON pointer paths', async () => {
|
|||||||
|
|
||||||
test('render failure returns the CLI error', async () => {
|
test('render failure returns the CLI error', async () => {
|
||||||
const text = JSON.stringify({ schema_version: 1, diagram_type: 'architecture', meta: {}, components: [] });
|
const text = JSON.stringify({ schema_version: 1, diagram_type: 'architecture', meta: {}, components: [] });
|
||||||
const result = await renderDiagram(runtime(), { type: 'architecture', diagram: JSON.parse(text), text }, 'fallback');
|
const result = await renderDiagram(runtime(), { type: 'architecture', diagram: JSON.parse(text), format: 'json', json: text }, 'fallback');
|
||||||
assert.equal(result.ok, false);
|
assert.equal(result.ok, false);
|
||||||
assert.match(result.error ?? '', /schema validation failed/);
|
assert.match(result.error ?? '', /schema validation failed/);
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-2
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import Ajv2020 from 'ajv/dist/2020';
|
import Ajv2020 from 'ajv/dist/2020';
|
||||||
|
import { parse as parseYaml } from 'yaml';
|
||||||
|
|
||||||
const root = path.resolve(__dirname, '..', '..');
|
const root = path.resolve(__dirname, '..', '..');
|
||||||
const schemas = path.join(root, 'schemas');
|
const schemas = path.join(root, 'schemas');
|
||||||
@@ -27,14 +28,14 @@ test('bundled schemas carry no absolute $id', () => {
|
|||||||
test('the .archify schema accepts every fixture', () => {
|
test('the .archify schema accepts every fixture', () => {
|
||||||
const validate = validator();
|
const validate = validator();
|
||||||
for (const file of fs.readdirSync(fixtures)) {
|
for (const file of fs.readdirSync(fixtures)) {
|
||||||
const ok = validate(JSON.parse(fs.readFileSync(path.join(fixtures, file), 'utf8')));
|
const ok = validate(parseYaml(fs.readFileSync(path.join(fixtures, file), 'utf8')));
|
||||||
assert.ok(ok, `${file}: ${JSON.stringify(validate.errors?.slice(0, 3))}`);
|
assert.ok(ok, `${file}: ${JSON.stringify(validate.errors?.slice(0, 3))}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the .archify schema applies the schema of the declared type', () => {
|
test('the .archify schema applies the schema of the declared type', () => {
|
||||||
const validate = validator();
|
const validate = validator();
|
||||||
const sequence = JSON.parse(fs.readFileSync(path.join(fixtures, 'cache-miss.archify'), 'utf8'));
|
const sequence = parseYaml(fs.readFileSync(path.join(fixtures, 'cache-miss.archify'), 'utf8'));
|
||||||
// A sequence diagram is not a valid architecture diagram.
|
// A sequence diagram is not a valid architecture diagram.
|
||||||
assert.equal(validate({ ...sequence, diagram_type: 'architecture' }), false);
|
assert.equal(validate({ ...sequence, diagram_type: 'architecture' }), false);
|
||||||
assert.equal(validate({ ...sequence, diagram_type: 'poster' }), false);
|
assert.equal(validate({ ...sequence, diagram_type: 'poster' }), false);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { parse } from 'yaml';
|
||||||
|
import { readDiagramSource } from '../src/detect';
|
||||||
|
import { validate } from '../src/renderer';
|
||||||
|
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES, titleFromName } from '../src/templates';
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..', '..');
|
||||||
|
const templates = path.join(root, 'templates');
|
||||||
|
const runtime = {
|
||||||
|
cliPath: path.join(root, 'vendor', 'archify', 'bin', 'archify.mjs'),
|
||||||
|
nodePath: process.execPath,
|
||||||
|
runAsNode: false,
|
||||||
|
workDir: fs.mkdtempSync(path.join(os.tmpdir(), 'archify-ext-test-')),
|
||||||
|
};
|
||||||
|
|
||||||
|
test('every template file is listed and every listed template exists', () => {
|
||||||
|
const files = fs.readdirSync(templates).filter((file) => file.endsWith('.archify')).sort();
|
||||||
|
assert.deepEqual(TEMPLATES.map((item) => item.file).sort(), files);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of TEMPLATES) {
|
||||||
|
test(`template ${item.file} is a valid ${item.type} diagram`, async () => {
|
||||||
|
const text = diagramFromTemplate(fs.readFileSync(path.join(templates, item.file), 'utf8'), item.defaultName);
|
||||||
|
const source = readDiagramSource(text, `${item.defaultName}.archify`);
|
||||||
|
assert.ok(source);
|
||||||
|
assert.equal(source.type, item.type);
|
||||||
|
assert.equal(source.format, 'yaml');
|
||||||
|
const result = await validate(runtime, source.type, source.json);
|
||||||
|
assert.deepEqual(
|
||||||
|
result.diagnostics.filter((d) => d.severity === 'error').map((d) => `${d.code}: ${d.message}`),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.ok(result.ok);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('diagramFromTemplate sets title and output and keeps comments and flow lists', () => {
|
||||||
|
const text = diagramFromTemplate('# keep me\nschema_version: 1\nmeta:\n title: Old\n output: old.html\npos: [1, 2]\n', 'sales-report');
|
||||||
|
assert.match(text, /^# keep me/);
|
||||||
|
assert.match(text, /pos: \[1, 2\]/);
|
||||||
|
assert.deepEqual(parse(text).meta, { title: 'Sales report', output: 'sales-report.html' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diagramFromTemplate adds meta when the template has none', () => {
|
||||||
|
assert.deepEqual(parse(diagramFromTemplate('diagram_type: workflow\n', 'plan')).meta, { title: 'Plan', output: 'plan.html' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diagram names', () => {
|
||||||
|
assert.equal(diagramName(' web.archify '), 'web');
|
||||||
|
assert.equal(diagramName('web.ARCHIFY'), 'web');
|
||||||
|
assert.equal(invalidNameReason('web'), undefined);
|
||||||
|
assert.equal(invalidNameReason('my plan 2026'), undefined);
|
||||||
|
assert.ok(invalidNameReason(''));
|
||||||
|
assert.ok(invalidNameReason('.archify'));
|
||||||
|
assert.ok(invalidNameReason('..'));
|
||||||
|
assert.ok(invalidNameReason('a/b'));
|
||||||
|
assert.ok(invalidNameReason('a:b'));
|
||||||
|
assert.equal(titleFromName('sales-report_v2'), 'Sales report v2');
|
||||||
|
assert.equal(titleFromName('plan'), 'Plan');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user