Models: main.archify, registries, refs, links and the model page; 0.7.0
A folder with main.archify at its top is a model, a set of diagrams that belong together. Opening main.archify shows the model page: the model's name and description, its own overview diagram when the file has one, the diagrams grouped by folder (click to open), and the registry with where each element is used. The page is built by traversing the folder tree and refreshes when files change. *.archify-registry files define shared elements once; diagrams draw them with `ref: <id>` plus position and may override label, sublabel and tag. References are resolved before Archify runs, so Archify is unchanged. Hover, go-to-definition and completion on ref; unknown names, forbidden overrides, duplicate ids and broken registries in Problems; registry edits re-render open diagrams. `diagram:` links on elements and components open the linked diagram on double-click. New command Archify | Open Model, a Model template for New Diagram…, editor schemas for the new keys and for registries, docs/model.md and examples/online-shop. Single diagrams behave exactly as before.
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.7.0 — 2026-10-19
|
||||||
|
|
||||||
|
- **Models:** a folder with a `main.archify` at its top is a model, a set of diagrams that belong together. Opening `main.archify` shows the model page: name and description, the overview diagram if the file has one, the diagrams grouped by folder (click to open), and the registry with where each element is used.
|
||||||
|
- **Registry:** `*.archify-registry` files define shared elements once; diagrams draw them with `ref: <id>` plus position, and may override `label`, `sublabel` and `tag`. Hover, go-to-definition and completion on `ref`; unknown names, forbidden overrides, duplicate ids and broken registries in Problems. Registry edits re-render open diagrams.
|
||||||
|
- **Links:** `diagram:` on a registry element or a component points at the diagram that explains it; double-click the node (2D view) to open it.
|
||||||
|
- New command *Archify | Open Model*; *New Diagram… → Model (main.archify)* template; editor schema for registries and the new keys; example model in `examples/online-shop`.
|
||||||
|
|
||||||
## 0.6.2 — 2026-10-19
|
## 0.6.2 — 2026-10-19
|
||||||
|
|
||||||
- **Sponsor button:** the extension now declares a sponsor link, so VS Code's Extensions view and the Marketplace page show **Sponsor** (Buy Me a Coffee). The README has a Support section with the same link.
|
- **Sponsor button:** the extension now declares a sponsor link, so VS Code's Extensions view and the Marketplace page show **Sponsor** (Buy Me a Coffee). The README has a Support section with the same link.
|
||||||
|
|||||||
@@ -97,6 +97,29 @@ Animation follows 2D exactly, because the 3D view reads Archify's own animations
|
|||||||
|
|
||||||
The 3D view draws only when something moves, so an idle diagram costs no GPU time.
|
The 3D view draws only when something moves, so an idle diagram costs no GPU time.
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
A single `.archify` file is one diagram. A folder with a **`main.archify`** at its top is a **model**: a set of diagrams that belong together, of any kind, with shared elements defined once. Diagrams inside a model still open, render and export on their own.
|
||||||
|
|
||||||
|
```
|
||||||
|
online-shop/
|
||||||
|
main.archify id, name, description; optionally an overview diagram
|
||||||
|
registry/
|
||||||
|
people.archify-registry shared elements: one name for one thing
|
||||||
|
systems.archify-registry
|
||||||
|
containers.archify-registry
|
||||||
|
0100-context/online-shop.archify
|
||||||
|
0200-containers/online-shop.archify
|
||||||
|
0300-components/api.archify
|
||||||
|
```
|
||||||
|
|
||||||
|
- **The model page.** Click `main.archify` and you get the model's page: its name and description, the overview diagram if the file has one, the diagrams of the model grouped by folder (click one to open it), and the registry with "used in N diagrams".
|
||||||
|
- **Shared elements.** A registry file defines an element once (`type`, `label`, `sublabel`, `brand`, `description`, …). A diagram draws it with `ref: api` plus `pos` and `size`; it may override `label`, `sublabel` and `tag`, and may still define unshared elements inline. Hover a `ref` for the description, F12 jumps to the registry, completion after `ref:` lists the elements, unknown names appear in Problems.
|
||||||
|
- **Links.** A registry element (or a component) with `diagram: 0300-components/api.archify` points at the diagram that explains it: double-click the node in the 2D view to open it.
|
||||||
|
- **Commands.** *Archify | Open Model* opens the model page of the current file; *Archify | New Diagram… → Model* creates a `main.archify` from the folder name.
|
||||||
|
|
||||||
|
Formats in detail: [docs/model.md](docs/model.md). A full example: [examples/online-shop](examples/online-shop).
|
||||||
|
|
||||||
## Source evidence
|
## Source evidence
|
||||||
|
|
||||||
A diagram that declares `meta.repository` and `sources` is verified against a local Git checkout whose `origin` and pinned commit match. The extension uses the Git top-level directory of the diagram file, or `archify.repoRoot` when set.
|
A diagram that declares `meta.repository` and `sources` is verified against a local Git checkout whose `origin` and pinned commit match. The extension uses the Git top-level directory of the diagram file, or `archify.repoRoot` when set.
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Models: the file formats
|
||||||
|
|
||||||
|
A model is a folder of diagrams that belong together: a `main.archify` at its top, any number of diagrams below it, and `*.archify-registry` files for the things several diagrams share. Three file kinds, all YAML, all read by the extension; Archify itself only ever sees plain diagrams.
|
||||||
|
|
||||||
|
## `main.archify`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
schema_version: 1
|
||||||
|
model:
|
||||||
|
id: online-shop
|
||||||
|
name: Online Shop
|
||||||
|
description: >
|
||||||
|
Web shop, its API, workers and stores.
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally, below the `model` block, a diagram exactly as in any other `.archify` file (`diagram_type`, `meta`, `components`, …). The extension shows it on the model page; Archify never sees the `model` block.
|
||||||
|
|
||||||
|
## `name.archify-registry`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
schema_version: 1
|
||||||
|
elements:
|
||||||
|
api:
|
||||||
|
type: backend
|
||||||
|
label: API
|
||||||
|
sublabel: REST service
|
||||||
|
brand: node-js
|
||||||
|
description: >
|
||||||
|
Node.js service behind the load balancer.
|
||||||
|
diagram: 0300-components/api.archify
|
||||||
|
db:
|
||||||
|
type: database
|
||||||
|
label: Database
|
||||||
|
sublabel: PostgreSQL
|
||||||
|
brand: postgresql
|
||||||
|
description: Orders, customers and catalogue.
|
||||||
|
```
|
||||||
|
|
||||||
|
- The key is the element's id, unique across all registry files of the model.
|
||||||
|
- `type`, `label`, `sublabel`, `tag`, `icon`, `brand`, `sources` are Archify's component fields and are copied into every diagram that refers to the element.
|
||||||
|
- `description` is the one description of the thing; shown on hover and on the model page, never sent to Archify.
|
||||||
|
- `diagram` is the link: the diagram that explains the element, relative to the model root. Clicking the element in any diagram opens it.
|
||||||
|
|
||||||
|
## A diagram that refers to the registry
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
components:
|
||||||
|
- ref: api
|
||||||
|
pos: [250, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: db
|
||||||
|
label: Orders DB # a diagram may override label, sublabel, tag
|
||||||
|
pos: [460, 260]
|
||||||
|
size: [130, 60]
|
||||||
|
- id: cron # an unshared element, inline, as today
|
||||||
|
type: backend
|
||||||
|
label: Nightly job
|
||||||
|
pos: [460, 420]
|
||||||
|
size: [130, 60]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ref` names a registry element; `id` defaults to the same name. `id: db-replica` with `ref: db` draws the same element twice or under another id.
|
||||||
|
- A diagram may override `label`, `sublabel` and `tag`; never `type` or `brand` (if those differ, it is a different element).
|
||||||
|
- Before Archify runs, the extension replaces each `ref` component with the registry element plus the diagram's own fields, drops `description` and `diagram`, and hands Archify an ordinary component.
|
||||||
|
- Connections, boundaries and everything else stay as they are and use the ids as before.
|
||||||
|
|
||||||
|
## Links between diagrams
|
||||||
|
|
||||||
|
A diagram may point at another diagram from a component, whether shared or inline:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- ref: api
|
||||||
|
pos: [250, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
diagram: 0300-components/api.archify # overrides the registry's link for this diagram
|
||||||
|
```
|
||||||
|
|
||||||
|
Registry `diagram` links and component `diagram` links are the same mechanism; the component's wins when both exist. Paths are relative to the model root.
|
||||||
|
|
||||||
|
## Outside a model
|
||||||
|
|
||||||
|
A diagram with `ref` components outside any model (no `main.archify` above it) reports "no registry found" and shows the component by its ref name with type `external`, so the picture still renders.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
schema_version: 1
|
||||||
|
diagram_type: architecture
|
||||||
|
meta:
|
||||||
|
title: System context
|
||||||
|
output: context.html
|
||||||
|
components:
|
||||||
|
- ref: customer
|
||||||
|
pos: [40, 60]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: support
|
||||||
|
pos: [40, 260]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: shop
|
||||||
|
pos: [280, 160]
|
||||||
|
size: [150, 60]
|
||||||
|
- ref: psp
|
||||||
|
pos: [520, 60]
|
||||||
|
size: [150, 60]
|
||||||
|
- ref: email
|
||||||
|
pos: [520, 260]
|
||||||
|
size: [150, 60]
|
||||||
|
connections:
|
||||||
|
- { from: customer, to: shop, label: buys, variant: emphasis }
|
||||||
|
- { from: support, to: shop, label: manages orders }
|
||||||
|
- { from: shop, to: psp, label: charges, variant: security }
|
||||||
|
- { from: shop, to: email, label: sends mail }
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
schema_version: 1
|
||||||
|
diagram_type: architecture
|
||||||
|
meta:
|
||||||
|
title: Containers
|
||||||
|
output: containers.html
|
||||||
|
components:
|
||||||
|
- ref: customer
|
||||||
|
pos: [40, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: web
|
||||||
|
pos: [250, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: api
|
||||||
|
pos: [460, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: cache
|
||||||
|
pos: [670, 60]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: db
|
||||||
|
pos: [670, 260]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: queue
|
||||||
|
pos: [460, 360]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: worker
|
||||||
|
pos: [670, 460]
|
||||||
|
size: [130, 60]
|
||||||
|
boundaries:
|
||||||
|
- kind: region
|
||||||
|
label: Online Shop
|
||||||
|
wraps: [web, api, cache, db, queue, worker]
|
||||||
|
connections:
|
||||||
|
- { from: customer, to: web, label: HTTPS, variant: emphasis }
|
||||||
|
- { from: web, to: api, label: JSON }
|
||||||
|
- { from: api, to: cache, label: read }
|
||||||
|
- { from: api, to: db, label: SQL }
|
||||||
|
- { from: api, to: queue, label: enqueue, labelAt: [560, 290] }
|
||||||
|
- { from: queue, to: worker, label: poll }
|
||||||
|
- { from: worker, to: db, label: SQL, labelAt: [735, 420] }
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
schema_version: 1
|
||||||
|
diagram_type: architecture
|
||||||
|
meta:
|
||||||
|
title: API components
|
||||||
|
output: api.html
|
||||||
|
components:
|
||||||
|
- ref: web
|
||||||
|
pos: [40, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- id: auth
|
||||||
|
type: security
|
||||||
|
label: Auth
|
||||||
|
sublabel: sessions, OIDC
|
||||||
|
pos: [250, 60]
|
||||||
|
size: [130, 60]
|
||||||
|
- id: orders
|
||||||
|
type: backend
|
||||||
|
label: Orders
|
||||||
|
pos: [250, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- id: catalogue
|
||||||
|
type: backend
|
||||||
|
label: Catalogue
|
||||||
|
pos: [250, 260]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: db
|
||||||
|
pos: [460, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: queue
|
||||||
|
pos: [460, 310]
|
||||||
|
size: [130, 60]
|
||||||
|
boundaries:
|
||||||
|
- kind: region
|
||||||
|
label: API
|
||||||
|
wraps: [auth, orders, catalogue]
|
||||||
|
connections:
|
||||||
|
- { from: web, to: auth, label: sign in, variant: security }
|
||||||
|
- { from: web, to: orders, label: place order }
|
||||||
|
- { from: web, to: catalogue, label: browse }
|
||||||
|
- { from: orders, to: db, label: SQL }
|
||||||
|
- { from: catalogue, to: db, label: SQL }
|
||||||
|
- { from: orders, to: queue, label: enqueue }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
schema_version: 1
|
||||||
|
model:
|
||||||
|
id: online-shop
|
||||||
|
name: Online Shop
|
||||||
|
description: >
|
||||||
|
The web shop, its API and workers, and the stores behind them.
|
||||||
|
Context, containers and components, C4 style.
|
||||||
|
diagram_type: architecture
|
||||||
|
meta:
|
||||||
|
title: Online Shop — overview
|
||||||
|
output: main.html
|
||||||
|
components:
|
||||||
|
- ref: customer
|
||||||
|
pos: [40, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: shop
|
||||||
|
pos: [250, 160]
|
||||||
|
size: [150, 60]
|
||||||
|
- ref: psp
|
||||||
|
pos: [480, 160]
|
||||||
|
size: [150, 60]
|
||||||
|
connections:
|
||||||
|
- { from: customer, to: shop, label: buys, variant: emphasis }
|
||||||
|
- { from: shop, to: psp, label: charges }
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
schema_version: 1
|
||||||
|
elements:
|
||||||
|
web:
|
||||||
|
type: frontend
|
||||||
|
label: Web App
|
||||||
|
sublabel: React
|
||||||
|
brand: react
|
||||||
|
description: The shop as customers see it; talks only to the API.
|
||||||
|
api:
|
||||||
|
type: backend
|
||||||
|
label: API
|
||||||
|
sublabel: Node.js
|
||||||
|
brand: node-js
|
||||||
|
description: Owns authentication, orders and the catalogue.
|
||||||
|
diagram: 0300-components/api.archify
|
||||||
|
worker:
|
||||||
|
type: backend
|
||||||
|
label: Worker
|
||||||
|
sublabel: async jobs
|
||||||
|
description: Picks jobs from the queue; emails, invoices, exports.
|
||||||
|
queue:
|
||||||
|
type: messagebus
|
||||||
|
label: Queue
|
||||||
|
sublabel: SQS
|
||||||
|
description: Job queue between the API and the workers.
|
||||||
|
db:
|
||||||
|
type: database
|
||||||
|
label: Database
|
||||||
|
sublabel: PostgreSQL
|
||||||
|
brand: postgresql
|
||||||
|
description: Orders, customers and catalogue; the source of truth.
|
||||||
|
cache:
|
||||||
|
type: database
|
||||||
|
label: Cache
|
||||||
|
sublabel: Redis
|
||||||
|
brand: redis
|
||||||
|
description: Sessions and catalogue cache; nothing here is the source of truth.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
schema_version: 1
|
||||||
|
elements:
|
||||||
|
customer:
|
||||||
|
type: external
|
||||||
|
label: Customer
|
||||||
|
sublabel: web and mobile
|
||||||
|
description: Buys products; signs in with email or a social account.
|
||||||
|
support:
|
||||||
|
type: external
|
||||||
|
label: Support agent
|
||||||
|
sublabel: back office
|
||||||
|
description: Handles refunds and order problems in the admin console.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
schema_version: 1
|
||||||
|
elements:
|
||||||
|
shop:
|
||||||
|
type: cloud
|
||||||
|
label: Online Shop
|
||||||
|
sublabel: the system
|
||||||
|
description: Everything we build and run; web app, API, workers and stores.
|
||||||
|
diagram: 0200-containers/online-shop.archify
|
||||||
|
psp:
|
||||||
|
type: external
|
||||||
|
label: Payment provider
|
||||||
|
sublabel: Stripe
|
||||||
|
brand: stripe
|
||||||
|
description: Charges cards and pays out; we never see card numbers.
|
||||||
|
email:
|
||||||
|
type: external
|
||||||
|
label: Email service
|
||||||
|
sublabel: transactional mail
|
||||||
|
description: Sends order confirmations and password resets.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"version": "0.6.2",
|
"version": "0.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"version": "0.6.2",
|
"version": "0.7.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"three": "^0.186.1",
|
"three": "^0.186.1",
|
||||||
|
|||||||
+29
-6
@@ -2,7 +2,7 @@
|
|||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"displayName": "Archify Diagram Viewer",
|
"displayName": "Archify Diagram Viewer",
|
||||||
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.",
|
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.",
|
||||||
"version": "0.6.2",
|
"version": "0.7.0",
|
||||||
"publisher": "root-at-skic",
|
"publisher": "root-at-skic",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"sponsor": {
|
"sponsor": {
|
||||||
@@ -26,11 +26,14 @@
|
|||||||
],
|
],
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"archify",
|
"archify",
|
||||||
"yaml",
|
|
||||||
"diagram",
|
|
||||||
"architecture",
|
"architecture",
|
||||||
|
"c4",
|
||||||
|
"diagram",
|
||||||
|
"model",
|
||||||
|
"registry",
|
||||||
"sequence",
|
"sequence",
|
||||||
"workflow"
|
"workflow",
|
||||||
|
"yaml"
|
||||||
],
|
],
|
||||||
"main": "./dist/extension.js",
|
"main": "./dist/extension.js",
|
||||||
"activationEvents": [
|
"activationEvents": [
|
||||||
@@ -38,7 +41,8 @@
|
|||||||
"onLanguage:jsonc",
|
"onLanguage:jsonc",
|
||||||
"onLanguage:html",
|
"onLanguage:html",
|
||||||
"workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}",
|
"workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}",
|
||||||
"onWebviewPanel:archify.preview"
|
"onWebviewPanel:archify.preview",
|
||||||
|
"workspaceContains:**/*.archify-registry"
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"commands": [
|
"commands": [
|
||||||
@@ -81,6 +85,11 @@
|
|||||||
"command": "archify.showSource",
|
"command": "archify.showSource",
|
||||||
"title": "Archify | Show Source",
|
"title": "Archify | Show Source",
|
||||||
"icon": "$(go-to-file)"
|
"icon": "$(go-to-file)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.openModel",
|
||||||
|
"title": "Archify | Open Model",
|
||||||
|
"icon": "$(symbol-structure)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
@@ -112,6 +121,10 @@
|
|||||||
{
|
{
|
||||||
"command": "archify.showSource",
|
"command": "archify.showSource",
|
||||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'"
|
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.openModel",
|
||||||
|
"when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram'"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor/title": [
|
"editor/title": [
|
||||||
@@ -144,6 +157,11 @@
|
|||||||
"command": "archify.renderToHtml",
|
"command": "archify.renderToHtml",
|
||||||
"when": "archify.isDiagramSource || archifyPreviewFocus",
|
"when": "archify.isDiagramSource || archifyPreviewFocus",
|
||||||
"group": "1_archify"
|
"group": "1_archify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.openModel",
|
||||||
|
"when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram') && !notebookEditorFocused",
|
||||||
|
"group": "navigation@60"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"explorer/context": [
|
"explorer/context": [
|
||||||
@@ -333,7 +351,8 @@
|
|||||||
{
|
{
|
||||||
"id": "yaml",
|
"id": "yaml",
|
||||||
"extensions": [
|
"extensions": [
|
||||||
".archify"
|
".archify",
|
||||||
|
".archify-registry"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -341,6 +360,10 @@
|
|||||||
{
|
{
|
||||||
"fileMatch": "*.archify",
|
"fileMatch": "*.archify",
|
||||||
"url": "./schemas/archify.schema.json"
|
"url": "./schemas/archify.schema.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fileMatch": "*.archify-registry",
|
||||||
|
"url": "./schemas/archify-registry.schema.json"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "Archify model registry",
|
||||||
|
"description": "Shared elements of a model: one name for one thing. Diagrams refer to an element with `ref: <id>`.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"elements"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"elements": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Element id → element. Ids are unique across all registry files of the model.",
|
||||||
|
"propertyNames": {
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$"
|
||||||
|
},
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"label"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"frontend",
|
||||||
|
"backend",
|
||||||
|
"database",
|
||||||
|
"cloud",
|
||||||
|
"security",
|
||||||
|
"messagebus",
|
||||||
|
"external"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"sublabel": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"description": "Decorative corner icon. Omit the icon field for the type default; none hides it. Does not change type, color, or legend grouping.",
|
||||||
|
"enum": [
|
||||||
|
"calendar",
|
||||||
|
"clock",
|
||||||
|
"person",
|
||||||
|
"briefcase",
|
||||||
|
"flag",
|
||||||
|
"moon",
|
||||||
|
"frontend",
|
||||||
|
"backend",
|
||||||
|
"database",
|
||||||
|
"cloud",
|
||||||
|
"security",
|
||||||
|
"messagebus",
|
||||||
|
"external",
|
||||||
|
"start",
|
||||||
|
"active",
|
||||||
|
"waiting",
|
||||||
|
"success",
|
||||||
|
"failure",
|
||||||
|
"neutral",
|
||||||
|
"none"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"brand": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 2048,
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 80,
|
||||||
|
"pattern": "^[^\\r\\n]+$"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "^https?://"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"url",
|
||||||
|
"sha256"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 8,
|
||||||
|
"maxLength": 2048,
|
||||||
|
"pattern": "^https?://"
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-f0-9]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 3,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 240
|
||||||
|
},
|
||||||
|
"line": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"end_line": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 48
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The one description of this thing; shown on hover and on the model page."
|
||||||
|
},
|
||||||
|
"diagram": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,11 +158,6 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": [
|
|
||||||
"id",
|
|
||||||
"type",
|
|
||||||
"label"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
"$ref": "common.schema.json#/$defs/id"
|
"$ref": "common.schema.json#/$defs/id"
|
||||||
@@ -215,8 +210,31 @@
|
|||||||
"items": false,
|
"items": false,
|
||||||
"minItems": 2,
|
"minItems": 2,
|
||||||
"maxItems": 2
|
"maxItems": 2
|
||||||
|
},
|
||||||
|
"ref": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||||
|
"description": "A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds pos/size and may override label, sublabel and tag."
|
||||||
|
},
|
||||||
|
"diagram": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"required": [
|
||||||
|
"ref"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"type",
|
||||||
|
"label"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"boundaries": {
|
"boundaries": {
|
||||||
@@ -320,6 +338,31 @@
|
|||||||
},
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"$ref": "common.schema.json#/$defs/cards"
|
"$ref": "common.schema.json#/$defs/cards"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||||
|
"description": "Stable id of the model."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Name shown on the model page."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What the model covers; shown on the model page."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,6 +262,31 @@
|
|||||||
},
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"$ref": "common.schema.json#/$defs/cards"
|
"$ref": "common.schema.json#/$defs/cards"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||||
|
"description": "Stable id of the model."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Name shown on the model page."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What the model covers; shown on the model page."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,6 +294,31 @@
|
|||||||
},
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"$ref": "common.schema.json#/$defs/cards"
|
"$ref": "common.schema.json#/$defs/cards"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||||
|
"description": "Stable id of the model."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Name shown on the model page."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What the model covers; shown on the model page."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,31 @@
|
|||||||
},
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"$ref": "common.schema.json#/$defs/cards"
|
"$ref": "common.schema.json#/$defs/cards"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||||
|
"description": "Stable id of the model."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Name shown on the model page."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What the model covers; shown on the model page."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,6 +425,31 @@
|
|||||||
},
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"$ref": "common.schema.json#/$defs/cards"
|
"$ref": "common.schema.json#/$defs/cards"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||||
|
"description": "Stable id of the model."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Name shown on the model page."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What the model covers; shown on the model page."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"$defs": {
|
"$defs": {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Adds the model keys to the editor schemas in schemas/ (the CLI keeps the
|
||||||
|
// strict originals): `model` on main.archify, `ref` and `diagram` on
|
||||||
|
// architecture components, and a schema for *.archify-registry files.
|
||||||
|
// Run after sync-archify.mjs, or on its own after editing this file.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const schemas = path.join(root, 'schemas');
|
||||||
|
const read = (file) => JSON.parse(fs.readFileSync(path.join(schemas, file), 'utf8'));
|
||||||
|
const write = (file, value) => fs.writeFileSync(path.join(schemas, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
|
||||||
|
const MODEL = {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Only in main.archify: the entry point of a model (a folder of diagrams that belong together).',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['id', 'name'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', pattern: '^[a-zA-Z][a-zA-Z0-9_-]*$', description: 'Stable id of the model.' },
|
||||||
|
name: { type: 'string', minLength: 1, description: 'Name shown on the model page.' },
|
||||||
|
description: { type: 'string', description: 'What the model covers; shown on the model page.' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIAGRAM_LINK = { type: 'string', description: 'Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it.' };
|
||||||
|
|
||||||
|
for (const file of fs.readdirSync(schemas)) {
|
||||||
|
if (!/^(architecture|workflow|sequence|dataflow|lifecycle)\.schema\.json$/.test(file)) continue;
|
||||||
|
const schema = read(file);
|
||||||
|
schema.properties.model = MODEL;
|
||||||
|
if (file === 'architecture.schema.json') {
|
||||||
|
const item = schema.properties.components.items;
|
||||||
|
item.properties.ref = { type: 'string', pattern: '^[a-zA-Z][a-zA-Z0-9_-]*$', description: 'A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds pos/size and may override label, sublabel and tag.' };
|
||||||
|
item.properties.diagram = DIAGRAM_LINK;
|
||||||
|
// A ref brings type and label with it; an inline component must state them.
|
||||||
|
item.anyOf = [{ required: ['ref'] }, { required: ['id', 'type', 'label'] }];
|
||||||
|
delete item.required;
|
||||||
|
}
|
||||||
|
write(file, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
const common = read('common.schema.json');
|
||||||
|
const componentType = common.$defs.componentType;
|
||||||
|
const registry = {
|
||||||
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||||
|
title: 'Archify model registry',
|
||||||
|
description: 'Shared elements of a model: one name for one thing. Diagrams refer to an element with `ref: <id>`.',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['elements'],
|
||||||
|
properties: {
|
||||||
|
schema_version: { const: 1 },
|
||||||
|
elements: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Element id → element. Ids are unique across all registry files of the model.',
|
||||||
|
propertyNames: { pattern: '^[a-zA-Z][a-zA-Z0-9_-]*$' },
|
||||||
|
additionalProperties: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['type', 'label'],
|
||||||
|
properties: {
|
||||||
|
type: componentType,
|
||||||
|
label: { type: 'string', minLength: 1 },
|
||||||
|
sublabel: { type: 'string' },
|
||||||
|
tag: { type: 'string' },
|
||||||
|
icon: common.$defs.nodeIcon,
|
||||||
|
brand: common.$defs.brandMark,
|
||||||
|
sources: common.$defs.sourceReferences,
|
||||||
|
description: { type: 'string', description: 'The one description of this thing; shown on hover and on the model page.' },
|
||||||
|
diagram: DIAGRAM_LINK,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
write('archify-registry.schema.json', registry);
|
||||||
|
console.log('Patched editor schemas: model, ref, diagram; wrote archify-registry.schema.json');
|
||||||
@@ -92,3 +92,4 @@ fs.writeFileSync(
|
|||||||
`${JSON.stringify({ upstream: 'https://github.com/tt-a1i/archify', version, revision }, null, 2)}\n`,
|
`${JSON.stringify({ upstream: 'https://github.com/tt-a1i/archify', version, revision }, null, 2)}\n`,
|
||||||
);
|
);
|
||||||
console.log(`Synced archify ${version} (${revision.slice(0, 12)}) from ${source}`);
|
console.log(`Synced archify ${version} (${revision.slice(0, 12)}) from ${source}`);
|
||||||
|
await import('./patch-schemas.mjs');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { HTML_VIEW_TYPE, HtmlViewerProvider } from '../views/htmlViewer';
|
import { HTML_VIEW_TYPE, HtmlViewerProvider } from '../views/htmlViewer';
|
||||||
|
import { ModelService } from '../model/service';
|
||||||
import { DiagramViewManager } from '../views/diagramView';
|
import { DiagramViewManager } from '../views/diagramView';
|
||||||
|
|
||||||
/** What command handlers share: the extension context and the open views. */
|
/** What command handlers share: the extension context and the open views. */
|
||||||
@@ -7,6 +8,7 @@ export interface CommandContext {
|
|||||||
extension: vscode.ExtensionContext;
|
extension: vscode.ExtensionContext;
|
||||||
views: DiagramViewManager;
|
views: DiagramViewManager;
|
||||||
htmlViewer: HtmlViewerProvider;
|
htmlViewer: HtmlViewerProvider;
|
||||||
|
models: ModelService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isHtmlUri(uri: vscode.Uri): boolean {
|
export function isHtmlUri(uri: vscode.Uri): boolean {
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { clearGitRootCache } from '../archify/runtime';
|
import { clearGitRootCache } from '../archify/runtime';
|
||||||
|
import { ModelService } from '../model/service';
|
||||||
import { isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from '../sources/detect';
|
import { isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from '../sources/detect';
|
||||||
|
|
||||||
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
||||||
|
|
||||||
/** Keeps the archify.* context keys (editor title buttons, keybindings) up to date. */
|
/** Keeps the archify.* context keys (editor title buttons, keybindings) up to date. */
|
||||||
export function registerContextKeys(context: vscode.ExtensionContext): void {
|
export function registerContextKeys(context: vscode.ExtensionContext, models: ModelService): void {
|
||||||
let contextTimer: NodeJS.Timeout | undefined;
|
let contextTimer: NodeJS.Timeout | undefined;
|
||||||
const updateContext = () => {
|
const updateContext = () => {
|
||||||
const doc = vscode.window.activeTextEditor?.document;
|
const doc = vscode.window.activeTextEditor?.document;
|
||||||
|
if (doc) void models.rootOf(doc.uri).then((root) => vscode.commands.executeCommand('setContext', 'archify.inModel', Boolean(root)));
|
||||||
const diagram = Boolean(
|
const diagram = Boolean(
|
||||||
doc &&
|
doc &&
|
||||||
(isArchifyFile(doc.fileName) ||
|
(isArchifyFile(doc.fileName) ||
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { MAIN_FILE } from '../model/format';
|
||||||
|
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
|
||||||
|
import { CommandContext, targetUri } from './context';
|
||||||
|
|
||||||
|
export function modelCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||||
|
return [
|
||||||
|
// Opens the model page (main.archify) of the model the current file is in.
|
||||||
|
vscode.commands.registerCommand('archify.openModel', async (arg?: unknown) => {
|
||||||
|
const uri = targetUri(ctx, arg);
|
||||||
|
const root = uri ? await ctx.models.rootOf(uri) : undefined;
|
||||||
|
if (!root) {
|
||||||
|
vscode.window.showInformationMessage(`Archify: this file is not inside a model (no ${MAIN_FILE} in a folder above it).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await vscode.commands.executeCommand('vscode.openWith', vscode.Uri.joinPath(root, MAIN_FILE), DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES } from '../language/templates';
|
import { diagramFromTemplate, diagramName, invalidNameReason, modelFromTemplate, TEMPLATES } from '../language/templates';
|
||||||
import { diagramBaseName, isArchifyFile } from '../sources/detect';
|
import { diagramBaseName, isArchifyFile } from '../sources/detect';
|
||||||
import { readText } from '../util/files';
|
import { readText } from '../util/files';
|
||||||
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
|
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
|
||||||
@@ -24,11 +25,17 @@ export function newDiagramCommands(ctx: CommandContext): vscode.Disposable[] {
|
|||||||
{ title: 'Archify | New Diagram', placeHolder: 'Kind of diagram' },
|
{ title: 'Archify | New Diagram', placeHolder: 'Kind of diagram' },
|
||||||
);
|
);
|
||||||
if (!template) return;
|
if (!template) return;
|
||||||
const { file, defaultName } = template.template;
|
const { file, defaultName, fixedName } = template.template;
|
||||||
|
|
||||||
const folder = await newDiagramFolder(arg);
|
const folder = await newDiagramFolder(arg);
|
||||||
let target: vscode.Uri | undefined;
|
let target: vscode.Uri | undefined;
|
||||||
if (folder) {
|
if (folder && fixedName) {
|
||||||
|
target = vscode.Uri.joinPath(folder, fixedName);
|
||||||
|
if (await exists(target)) {
|
||||||
|
vscode.window.showWarningMessage(`Archify: ${vscode.workspace.asRelativePath(folder, true)} already has a ${fixedName}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (folder) {
|
||||||
const name = await vscode.window.showInputBox({
|
const name = await vscode.window.showInputBox({
|
||||||
title: 'Archify | New Diagram',
|
title: 'Archify | New Diagram',
|
||||||
prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}`,
|
prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}`,
|
||||||
@@ -51,7 +58,9 @@ export function newDiagramCommands(ctx: CommandContext): vscode.Disposable[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const templateText = await readText(vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', file));
|
const templateText = await readText(vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', file));
|
||||||
const text = diagramFromTemplate(templateText, diagramBaseName(target.path));
|
const text = fixedName
|
||||||
|
? modelFromTemplate(templateText, path.basename(path.dirname(target.fsPath)))
|
||||||
|
: diagramFromTemplate(templateText, diagramBaseName(target.path));
|
||||||
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
|
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
|
||||||
await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE);
|
await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import path from 'node:path';
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { isEvidenceError, renderDiagram, renderToFile } from '../archify/cli';
|
import { isEvidenceError, renderDiagram, renderToFile } from '../archify/cli';
|
||||||
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||||
import { diagramBaseName, readDiagramSource } from '../sources/detect';
|
import { prepareDiagram } from '../model/prepare';
|
||||||
|
import { diagramBaseName } from '../sources/detect';
|
||||||
import { log } from '../util/log';
|
import { log } from '../util/log';
|
||||||
import { readText } from '../util/files';
|
import { readText } from '../util/files';
|
||||||
import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context';
|
import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context';
|
||||||
@@ -14,7 +15,7 @@ export function renderCommands(ctx: CommandContext): vscode.Disposable[] {
|
|||||||
const uri = sourceUri(ctx, arg);
|
const uri = sourceUri(ctx, arg);
|
||||||
if (!uri) return;
|
if (!uri) return;
|
||||||
const text = await readText(uri);
|
const text = await readText(uri);
|
||||||
const source = readDiagramSource(text, uri.path);
|
const source = (await prepareDiagram(ctx.models, uri, text))?.source;
|
||||||
if (!source) {
|
if (!source) {
|
||||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
||||||
return;
|
return;
|
||||||
@@ -66,7 +67,7 @@ export function renderCommands(ctx: CommandContext): vscode.Disposable[] {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const text = await readText(uri);
|
const text = await readText(uri);
|
||||||
const source = readDiagramSource(text, uri.path);
|
const source = (await prepareDiagram(ctx.models, uri, text))?.source;
|
||||||
if (!source) {
|
if (!source) {
|
||||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
+14
-3
@@ -4,29 +4,40 @@ import { registerContextKeys } from './commands/contextKeys';
|
|||||||
import { newDiagramCommands } from './commands/newDiagram';
|
import { newDiagramCommands } from './commands/newDiagram';
|
||||||
import { renderCommands } from './commands/renderCommands';
|
import { renderCommands } from './commands/renderCommands';
|
||||||
import { viewCommands } from './commands/viewCommands';
|
import { viewCommands } from './commands/viewCommands';
|
||||||
|
import { modelCommands } from './commands/modelCommands';
|
||||||
import { BRAND_COMPLETION_SELECTOR, BrandCompletionProvider } from './language/brandCompletion';
|
import { BRAND_COMPLETION_SELECTOR, BrandCompletionProvider } from './language/brandCompletion';
|
||||||
|
import { REF_SELECTOR, RefLanguage } from './language/refLanguage';
|
||||||
|
import { ModelService } from './model/service';
|
||||||
import { DiagnosticsController } from './language/diagnostics';
|
import { DiagnosticsController } from './language/diagnostics';
|
||||||
import { log } from './util/log';
|
import { log } from './util/log';
|
||||||
import { HtmlViewerProvider } from './views/htmlViewer';
|
import { HtmlViewerProvider } from './views/htmlViewer';
|
||||||
import { DiagramViewManager } from './views/diagramView';
|
import { DiagramViewManager } from './views/diagramView';
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): void {
|
export function activate(context: vscode.ExtensionContext): void {
|
||||||
|
const models = new ModelService();
|
||||||
const ctx: CommandContext = {
|
const ctx: CommandContext = {
|
||||||
extension: context,
|
extension: context,
|
||||||
views: new DiagramViewManager(context),
|
views: new DiagramViewManager(context, models),
|
||||||
htmlViewer: new HtmlViewerProvider(context.extensionUri),
|
htmlViewer: new HtmlViewerProvider(context.extensionUri),
|
||||||
|
models,
|
||||||
};
|
};
|
||||||
|
const refs = new RefLanguage(models);
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
|
models,
|
||||||
ctx.views,
|
ctx.views,
|
||||||
ctx.htmlViewer,
|
ctx.htmlViewer,
|
||||||
new DiagnosticsController(context),
|
new DiagnosticsController(context, models),
|
||||||
log(),
|
log(),
|
||||||
vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'),
|
vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'),
|
||||||
|
vscode.languages.registerCompletionItemProvider(REF_SELECTOR, refs, ' ', '"'),
|
||||||
|
vscode.languages.registerHoverProvider(REF_SELECTOR, refs),
|
||||||
|
vscode.languages.registerDefinitionProvider(REF_SELECTOR, refs),
|
||||||
...viewCommands(ctx),
|
...viewCommands(ctx),
|
||||||
...newDiagramCommands(ctx),
|
...newDiagramCommands(ctx),
|
||||||
...renderCommands(ctx),
|
...renderCommands(ctx),
|
||||||
|
...modelCommands(ctx),
|
||||||
);
|
);
|
||||||
registerContextKeys(context);
|
registerContextKeys(context, models);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deactivate(): void {}
|
export function deactivate(): void {}
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { parse, ParseError } from 'jsonc-parser';
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { ArchifyDiagnostic, isEvidenceError, validate } from '../archify/cli';
|
import { ArchifyDiagnostic, isEvidenceError, validate } from '../archify/cli';
|
||||||
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||||
import { isArchifyFile, readDiagramSource } from '../sources/detect';
|
import { ModelProblem, isRegistryFile, parseRegistry } from '../model/format';
|
||||||
|
import { prepareDiagram } from '../model/prepare';
|
||||||
|
import { ModelService } from '../model/service';
|
||||||
|
import { isArchifyFile } from '../sources/detect';
|
||||||
import { log } from '../util/log';
|
import { log } from '../util/log';
|
||||||
|
import path from 'node:path';
|
||||||
import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
|
import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
|
||||||
|
|
||||||
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
||||||
@@ -29,9 +33,13 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
private readonly sequence = new Map<string, number>();
|
private readonly sequence = new Map<string, number>();
|
||||||
private readonly disposables: vscode.Disposable[];
|
private readonly disposables: vscode.Disposable[];
|
||||||
|
|
||||||
constructor(private readonly context: vscode.ExtensionContext) {
|
constructor(
|
||||||
|
private readonly context: vscode.ExtensionContext,
|
||||||
|
private readonly models: ModelService,
|
||||||
|
) {
|
||||||
this.disposables = [
|
this.disposables = [
|
||||||
this.collection,
|
this.collection,
|
||||||
|
models.onDidChangeModel(() => this.revalidateAll()),
|
||||||
vscode.workspace.onDidOpenTextDocument((doc) => this.schedule(doc, 0)),
|
vscode.workspace.onDidOpenTextDocument((doc) => this.schedule(doc, 0)),
|
||||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||||
if (event.contentChanges.length) this.schedule(event.document, 700);
|
if (event.contentChanges.length) this.schedule(event.document, 700);
|
||||||
@@ -62,7 +70,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) && !isArchifyFile(doc.fileName)) return;
|
if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName) && !isRegistryFile(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));
|
||||||
@@ -79,6 +87,10 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
this.collection.delete(doc.uri);
|
this.collection.delete(doc.uri);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isRegistryFile(doc.fileName)) {
|
||||||
|
await this.validateRegistry(doc, seq);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isArchifyFile(doc.fileName)) {
|
if (isArchifyFile(doc.fileName)) {
|
||||||
// Nothing else reports YAML syntax errors in .archify files, and a file
|
// Nothing else reports YAML syntax errors in .archify files, and a file
|
||||||
// that does not parse has no diagram to validate yet.
|
// that does not parse has no diagram to validate yet.
|
||||||
@@ -99,11 +111,16 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const source = readDiagramSource(text, doc.fileName);
|
const prepared = await prepareDiagram(this.models, doc.uri, text);
|
||||||
if (!source) {
|
if (seq !== this.sequence.get(key) || doc.isClosed) return;
|
||||||
|
if (!prepared) {
|
||||||
this.collection.delete(doc.uri);
|
this.collection.delete(doc.uri);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const source = prepared.source;
|
||||||
|
const modelDiagnostics = prepared.problems
|
||||||
|
.filter((problem) => !problem.file)
|
||||||
|
.map((problem) => this.modelDiagnostic(doc, text, source.format, problem));
|
||||||
if (source.format === 'json') {
|
if (source.format === 'json') {
|
||||||
// Leave JSON syntax errors to the JSON language service.
|
// Leave JSON syntax errors to the JSON language service.
|
||||||
const syntax: ParseError[] = [];
|
const syntax: ParseError[] = [];
|
||||||
@@ -131,9 +148,9 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const downgradeEvidence = evidenceMode(doc.uri) === 'fallback';
|
const downgradeEvidence = evidenceMode(doc.uri) === 'fallback';
|
||||||
this.collection.set(
|
this.collection.set(doc.uri, [
|
||||||
doc.uri,
|
...modelDiagnostics,
|
||||||
result.diagnostics.map((item) => {
|
...result.diagnostics.map((item) => {
|
||||||
const pointer = diagnosticPointer(item);
|
const pointer = diagnosticPointer(item);
|
||||||
const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, pointer) : rangeForPointer(text, pointer);
|
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));
|
||||||
@@ -147,12 +164,42 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
if (item.code) diagnostic.code = item.code;
|
if (item.code) diagnostic.code = item.code;
|
||||||
return diagnostic;
|
return diagnostic;
|
||||||
}),
|
}),
|
||||||
);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log().error(`Validation failed for ${doc.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
log().error(`Validation failed for ${doc.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private modelDiagnostic(doc: vscode.TextDocument, text: string, format: 'yaml' | 'json', problem: ModelProblem): vscode.Diagnostic {
|
||||||
|
const offsets = format === 'yaml' ? rangeForPointerYaml(text, problem.pointer) : rangeForPointer(text, problem.pointer);
|
||||||
|
const range = problem.line !== undefined && !problem.pointer ? doc.lineAt(Math.min(problem.line, doc.lineCount - 1)).range : new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
|
||||||
|
const diagnostic = new vscode.Diagnostic(range, problem.message, problem.severity === 'warning' ? vscode.DiagnosticSeverity.Warning : vscode.DiagnosticSeverity.Error);
|
||||||
|
diagnostic.source = 'archify';
|
||||||
|
return diagnostic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A registry file: its own YAML and element problems, plus duplicates across the model. */
|
||||||
|
private async validateRegistry(doc: vscode.TextDocument, seq: number): Promise<void> {
|
||||||
|
const text = doc.getText();
|
||||||
|
const root = await this.models.rootOf(doc.uri);
|
||||||
|
const rel = root ? path.relative(root.fsPath, doc.uri.fsPath) : path.basename(doc.uri.fsPath);
|
||||||
|
const problems: ModelProblem[] = [...parseRegistry(text, rel).problems];
|
||||||
|
if (root) {
|
||||||
|
try {
|
||||||
|
const index = await this.models.index(root);
|
||||||
|
for (const problem of index.registry.problems) {
|
||||||
|
if (problem.file === rel && !problems.some((p) => p.message === problem.message)) problems.push(problem);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The index reports its own failures when the model page opens.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
problems.push({ message: `This registry is not inside a model: no main.archify above ${path.basename(doc.uri.fsPath)}.`, severity: 'warning', line: 0 });
|
||||||
|
}
|
||||||
|
if (seq !== this.sequence.get(doc.uri.toString()) || doc.isClosed) return;
|
||||||
|
this.collection.set(doc.uri, problems.map((problem) => this.modelDiagnostic(doc, text, 'yaml', problem)));
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
for (const timer of this.timers.values()) clearTimeout(timer);
|
for (const timer of this.timers.values()) clearTimeout(timer);
|
||||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { RegistryElement } from '../model/format';
|
||||||
|
import { ModelService } from '../model/service';
|
||||||
|
|
||||||
|
export const REF_SELECTOR: vscode.DocumentSelector = [{ pattern: '**/*.archify' }];
|
||||||
|
|
||||||
|
/** `ref: api` or `{ ref: ap` → what has been typed of the value. */
|
||||||
|
export function refValueAt(linePrefix: string): { typed: string } | undefined {
|
||||||
|
const match = /(?:^|[\s{,])ref:\s*["']?([\w.-]*)$/.exec(linePrefix);
|
||||||
|
return match ? { typed: match[1] } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `ref` value under a position, with its range, or undefined. */
|
||||||
|
export function refAt(line: string, character: number): { id: string; start: number; end: number } | undefined {
|
||||||
|
const match = /(?:^|[\s{,])ref:\s*["']?([\w.-]+)/.exec(line);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const start = match.index + match[0].length - match[1].length;
|
||||||
|
const end = start + match[1].length;
|
||||||
|
return character >= start && character <= end ? { id: match[1], start, end } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(element: RegistryElement): vscode.MarkdownString {
|
||||||
|
const md = new vscode.MarkdownString();
|
||||||
|
md.appendMarkdown(`**${element.label ?? element.id}**${element.sublabel ? ` · ${element.sublabel}` : ''}${element.type ? ` \`${element.type}\`` : ''}\n\n`);
|
||||||
|
if (element.description) md.appendMarkdown(`${element.description.trim()}\n\n`);
|
||||||
|
if (element.diagram) md.appendMarkdown(`Opens: \`${element.diagram}\`\n\n`);
|
||||||
|
md.appendMarkdown(`*Defined in ${element.file}, line ${element.line + 1}*`);
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Completion, hover and go-to-definition for `ref:` values in diagrams of a model. */
|
||||||
|
export class RefLanguage implements vscode.CompletionItemProvider, vscode.HoverProvider, vscode.DefinitionProvider {
|
||||||
|
constructor(private readonly models: ModelService) {}
|
||||||
|
|
||||||
|
async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.CompletionItem[] | undefined> {
|
||||||
|
const value = refValueAt(document.lineAt(position.line).text.slice(0, position.character));
|
||||||
|
if (!value) return undefined;
|
||||||
|
const found = await this.models.registryFor(document.uri);
|
||||||
|
if (!found) return undefined;
|
||||||
|
const range = new vscode.Range(position.translate(0, -value.typed.length), position);
|
||||||
|
return [...found.registry.elements.values()].map((element) => {
|
||||||
|
const item = new vscode.CompletionItem({ label: element.id, description: element.label }, vscode.CompletionItemKind.Reference);
|
||||||
|
item.detail = [element.label, element.sublabel, element.type].filter(Boolean).join(' · ');
|
||||||
|
item.documentation = describe(element);
|
||||||
|
item.filterText = [element.id, element.label ?? '', element.sublabel ?? ''].join(' ');
|
||||||
|
item.range = range;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async provideHover(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Hover | undefined> {
|
||||||
|
const ref = refAt(document.lineAt(position.line).text, position.character);
|
||||||
|
if (!ref) return undefined;
|
||||||
|
const found = await this.models.registryFor(document.uri);
|
||||||
|
const element = found?.registry.elements.get(ref.id);
|
||||||
|
const range = new vscode.Range(position.line, ref.start, position.line, ref.end);
|
||||||
|
if (!element) {
|
||||||
|
return new vscode.Hover(found ? `Unknown element \`${ref.id}\`: no registry of this model defines it.` : `\`${ref.id}\` cannot be resolved: this file is not inside a model.`, range);
|
||||||
|
}
|
||||||
|
return new vscode.Hover(describe(element), range);
|
||||||
|
}
|
||||||
|
|
||||||
|
async provideDefinition(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Location | undefined> {
|
||||||
|
const ref = refAt(document.lineAt(position.line).text, position.character);
|
||||||
|
if (!ref) return undefined;
|
||||||
|
const found = await this.models.registryFor(document.uri);
|
||||||
|
const element = found?.registry.elements.get(ref.id);
|
||||||
|
if (!found || !element) return undefined;
|
||||||
|
const file = vscode.Uri.joinPath(found.root, ...element.file.split(/[\\/]/));
|
||||||
|
return new vscode.Location(file, new vscode.Position(element.line, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ export interface DiagramTemplate {
|
|||||||
description: string;
|
description: string;
|
||||||
/** Suggested name for the new file, without .archify. */
|
/** Suggested name for the new file, without .archify. */
|
||||||
defaultName: string;
|
defaultName: string;
|
||||||
|
/** Reserved file name; the user is not asked for one. */
|
||||||
|
fixedName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TEMPLATES: readonly DiagramTemplate[] = [
|
export const TEMPLATES: readonly DiagramTemplate[] = [
|
||||||
@@ -18,6 +20,7 @@ export const TEMPLATES: readonly DiagramTemplate[] = [
|
|||||||
{ file: 'dataflow.archify', type: 'dataflow', label: 'Data Flow', description: 'Where data comes from and where it goes', defaultName: 'dataflow' },
|
{ 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: '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' },
|
{ file: 'everyday-plan.archify', type: 'workflow', label: 'Everyday Plan', description: 'A plan or process from daily life', defaultName: 'plan' },
|
||||||
|
{ file: 'model.archify', type: 'architecture', label: 'Model (main.archify)', description: 'Entry point of a folder of diagrams that belong together', defaultName: 'main', fixedName: 'main.archify' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Strips a typed .archify suffix and surrounding spaces. */
|
/** Strips a typed .archify suffix and surrounding spaces. */
|
||||||
@@ -40,6 +43,21 @@ export function titleFromName(name: string): string {
|
|||||||
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
|
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "online-shop" → "online-shop"; "My Shop 2" → "my-shop-2": a model id from a folder name. */
|
||||||
|
export function modelIdFromName(name: string): string {
|
||||||
|
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
return /^[a-z]/.test(id) ? id : `m-${id || 'model'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The model template with id, name and overview title taken from the folder name. */
|
||||||
|
export function modelFromTemplate(templateText: string, folderName: string): string {
|
||||||
|
const doc = parseDocument(templateText);
|
||||||
|
doc.setIn(['model', 'id'], modelIdFromName(folderName));
|
||||||
|
doc.setIn(['model', 'name'], titleFromName(folderName));
|
||||||
|
doc.setIn(['meta', 'title'], `${titleFromName(folderName)} overview`);
|
||||||
|
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template YAML with meta.title and meta.output set for the new file.
|
* 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.
|
* Works on the YAML document so the template's comments and layout stay.
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { isMap, isScalar, parseDocument, parse as parseYaml } from 'yaml';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model file formats (see docs/model.md): `main.archify` with a `model`
|
||||||
|
* block, `*.archify-registry` files with shared elements, and `ref`
|
||||||
|
* components in diagrams. Pure functions, no VS Code imports, so they run
|
||||||
|
* in unit tests and can be reused by other tools.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MAIN_FILE = 'main.archify';
|
||||||
|
export const REGISTRY_EXT = '.archify-registry';
|
||||||
|
|
||||||
|
/** Component fields copied from a registry element into every diagram that refers to it. */
|
||||||
|
export const COPIED_FIELDS = ['type', 'label', 'sublabel', 'tag', 'icon', 'brand', 'sources'] as const;
|
||||||
|
/** The subset a diagram may override on a `ref` component. */
|
||||||
|
export const OVERRIDABLE_FIELDS = ['label', 'sublabel', 'tag'] as const;
|
||||||
|
|
||||||
|
export interface ModelMeta {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistryElement {
|
||||||
|
id: string;
|
||||||
|
type?: string;
|
||||||
|
label?: string;
|
||||||
|
sublabel?: string;
|
||||||
|
tag?: string;
|
||||||
|
icon?: unknown;
|
||||||
|
brand?: string;
|
||||||
|
sources?: unknown;
|
||||||
|
description?: string;
|
||||||
|
/** Diagram that explains the element, relative to the model root. */
|
||||||
|
diagram?: string;
|
||||||
|
/** Registry file that defines it, and the 0-based line of its key. */
|
||||||
|
file: string;
|
||||||
|
line: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelProblem {
|
||||||
|
message: string;
|
||||||
|
severity: 'error' | 'warning';
|
||||||
|
/** File the problem is about; defaults to the diagram being resolved. */
|
||||||
|
file?: string;
|
||||||
|
/** JSON pointer into that file's document, when it is a diagram. */
|
||||||
|
pointer?: string;
|
||||||
|
/** 0-based line, when known (registry files). */
|
||||||
|
line?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Registry {
|
||||||
|
elements: Map<string, RegistryElement>;
|
||||||
|
files: string[];
|
||||||
|
problems: ModelProblem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMainFile(fileName: string): boolean {
|
||||||
|
return (fileName.split(/[\\/]/).pop() ?? '') === MAIN_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRegistryFile(fileName: string): boolean {
|
||||||
|
return fileName.endsWith(REGISTRY_EXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Obj = Record<string, unknown>;
|
||||||
|
const isObj = (v: unknown): v is Obj => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||||
|
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.length > 0 ? v : undefined);
|
||||||
|
|
||||||
|
/** The `model` block of a `main.archify` document, if present. */
|
||||||
|
export function readModelMeta(doc: unknown): ModelMeta | undefined {
|
||||||
|
if (!isObj(doc) || !isObj(doc.model)) return undefined;
|
||||||
|
const { id, name, description } = doc.model;
|
||||||
|
return { id: str(id), name: str(name), description: str(description) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the document also carries a diagram (a `diagram_type`). */
|
||||||
|
export function hasDiagramBody(doc: unknown): boolean {
|
||||||
|
return isObj(doc) && typeof doc.diagram_type === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses one registry file. Line numbers come from the YAML document. */
|
||||||
|
export function parseRegistry(text: string, file: string): { elements: RegistryElement[]; problems: ModelProblem[] } {
|
||||||
|
const problems: ModelProblem[] = [];
|
||||||
|
const elements: RegistryElement[] = [];
|
||||||
|
const doc = parseDocument(text, { prettyErrors: false, uniqueKeys: false });
|
||||||
|
for (const error of doc.errors) {
|
||||||
|
problems.push({ message: `Registry is not valid YAML: ${error.message.split('\n')[0]}`, severity: 'error', file, line: error.linePos?.[0].line ? error.linePos[0].line - 1 : 0 });
|
||||||
|
}
|
||||||
|
if (doc.errors.length) return { elements, problems };
|
||||||
|
const lineOf = (offset: number | undefined) => (offset === undefined ? 0 : text.slice(0, offset).split('\n').length - 1);
|
||||||
|
const root = doc.contents;
|
||||||
|
if (!isMap(root)) {
|
||||||
|
problems.push({ message: 'A registry must be a mapping with an "elements" key.', severity: 'error', file, line: 0 });
|
||||||
|
return { elements, problems };
|
||||||
|
}
|
||||||
|
const elementsNode = root.get('elements', true);
|
||||||
|
if (!isMap(elementsNode)) {
|
||||||
|
problems.push({ message: 'A registry needs an "elements" mapping (id → element).', severity: 'error', file, line: 0 });
|
||||||
|
return { elements, problems };
|
||||||
|
}
|
||||||
|
for (const pair of elementsNode.items) {
|
||||||
|
const key = pair.key;
|
||||||
|
const id = isScalar(key) ? String(key.value) : undefined;
|
||||||
|
const line = isScalar(key) ? lineOf(key.range?.[0]) : 0;
|
||||||
|
if (!id) continue;
|
||||||
|
const value = isMap(pair.value) ? (pair.value.toJSON() as Obj) : undefined;
|
||||||
|
if (!value) {
|
||||||
|
problems.push({ message: `Element "${id}" must be a mapping (type, label, description, …).`, severity: 'error', file, line });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
elements.push({
|
||||||
|
id,
|
||||||
|
type: str(value.type),
|
||||||
|
label: str(value.label),
|
||||||
|
sublabel: str(value.sublabel),
|
||||||
|
tag: str(value.tag),
|
||||||
|
icon: value.icon,
|
||||||
|
brand: str(value.brand),
|
||||||
|
sources: value.sources,
|
||||||
|
description: str(value.description),
|
||||||
|
diagram: str(value.diagram),
|
||||||
|
file,
|
||||||
|
line,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { elements, problems };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges parsed registry files; an id defined twice is an error on the second definition. */
|
||||||
|
export function mergeRegistries(parts: { elements: RegistryElement[]; problems: ModelProblem[]; file: string }[]): Registry {
|
||||||
|
const elements = new Map<string, RegistryElement>();
|
||||||
|
const problems: ModelProblem[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
problems.push(...part.problems);
|
||||||
|
for (const element of part.elements) {
|
||||||
|
const existing = elements.get(element.id);
|
||||||
|
if (existing) {
|
||||||
|
problems.push({
|
||||||
|
message: `Element "${element.id}" is defined twice (also in ${existing.file}, line ${existing.line + 1}).`,
|
||||||
|
severity: 'error',
|
||||||
|
file: element.file,
|
||||||
|
line: element.line,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
elements.set(element.id, element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { elements, files: parts.map((p) => p.file), problems };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedDiagram {
|
||||||
|
/** The diagram as Archify expects it: refs expanded, model block and links removed. */
|
||||||
|
diagram: Obj;
|
||||||
|
problems: ModelProblem[];
|
||||||
|
/** Component index → the registry element it came from. */
|
||||||
|
refs: Map<number, RegistryElement>;
|
||||||
|
/** Node id → diagram path (relative to the model root) it links to. */
|
||||||
|
links: Map<string, string>;
|
||||||
|
/** Registry element ids used by this diagram. */
|
||||||
|
used: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveOptions {
|
||||||
|
/** Undefined when the diagram is not inside a model. */
|
||||||
|
registry?: Registry;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands `ref` components from the registry and strips what Archify does
|
||||||
|
* not know. Component order and indexes are kept, so Archify's diagnostics
|
||||||
|
* (JSON pointers) still point at the right component in the source file.
|
||||||
|
*/
|
||||||
|
export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiagram {
|
||||||
|
const problems: ModelProblem[] = [];
|
||||||
|
const refs = new Map<number, RegistryElement>();
|
||||||
|
const links = new Map<string, string>();
|
||||||
|
const used = new Set<string>();
|
||||||
|
if (!isObj(doc)) return { diagram: {}, problems, refs, links, used };
|
||||||
|
const diagram: Obj = { ...doc };
|
||||||
|
delete diagram.model;
|
||||||
|
const { registry } = options;
|
||||||
|
const components = Array.isArray(doc.components) ? doc.components : undefined;
|
||||||
|
if (!components) return { diagram, problems, refs, links, used };
|
||||||
|
|
||||||
|
diagram.components = components.map((raw, index) => {
|
||||||
|
if (!isObj(raw)) return raw;
|
||||||
|
const ref = str(raw.ref);
|
||||||
|
const linkFromComponent = str(raw.diagram);
|
||||||
|
if (!ref) {
|
||||||
|
const { diagram: _link, ...plain } = raw;
|
||||||
|
const id = str(plain.id);
|
||||||
|
if (id && linkFromComponent) links.set(id, linkFromComponent);
|
||||||
|
return plain;
|
||||||
|
}
|
||||||
|
const { ref: _ref, diagram: _link, ...own } = raw;
|
||||||
|
const element = registry?.elements.get(ref);
|
||||||
|
const id = str(own.id) ?? ref;
|
||||||
|
if (!element) {
|
||||||
|
problems.push({
|
||||||
|
message: registry
|
||||||
|
? `Unknown element "${ref}": no registry of this model defines it.`
|
||||||
|
: `"${ref}" cannot be resolved: ${options.fileName.split(/[\\/]/).pop()} is not inside a model (no ${MAIN_FILE} above it).`,
|
||||||
|
severity: 'error',
|
||||||
|
pointer: `/components/${index}/ref`,
|
||||||
|
});
|
||||||
|
return { type: 'external', label: ref, ...own, id };
|
||||||
|
}
|
||||||
|
used.add(ref);
|
||||||
|
refs.set(index, element);
|
||||||
|
for (const key of Object.keys(own)) {
|
||||||
|
if ((COPIED_FIELDS as readonly string[]).includes(key) && !(OVERRIDABLE_FIELDS as readonly string[]).includes(key)) {
|
||||||
|
problems.push({
|
||||||
|
message: `"${key}" comes from the registry element "${ref}" and cannot be changed in a diagram.`,
|
||||||
|
severity: 'error',
|
||||||
|
pointer: `/components/${index}/${key}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const merged: Obj = { id };
|
||||||
|
for (const key of COPIED_FIELDS) if (element[key] !== undefined) merged[key] = element[key];
|
||||||
|
for (const [key, value] of Object.entries(own)) {
|
||||||
|
if (key === 'id') continue;
|
||||||
|
if (!(COPIED_FIELDS as readonly string[]).includes(key) || (OVERRIDABLE_FIELDS as readonly string[]).includes(key)) merged[key] = value;
|
||||||
|
}
|
||||||
|
const link = linkFromComponent ?? element.diagram;
|
||||||
|
if (link) links.set(id, link);
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
return { diagram, problems, refs, links, used };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group of a diagram inside a model: '' for the root folder, else the folder path with '/' separators. */
|
||||||
|
export function groupOf(relativePath: string): string {
|
||||||
|
const parts = relativePath.split(/[\\/]/);
|
||||||
|
parts.pop();
|
||||||
|
return parts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagramSummary {
|
||||||
|
relativePath: string;
|
||||||
|
group: string;
|
||||||
|
kind?: string;
|
||||||
|
title?: string;
|
||||||
|
used: string[];
|
||||||
|
parseError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the model page needs to know about one diagram file. */
|
||||||
|
export function summarizeDiagram(text: string, relativePath: string): DiagramSummary {
|
||||||
|
let doc: unknown;
|
||||||
|
try {
|
||||||
|
doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false });
|
||||||
|
} catch (error) {
|
||||||
|
return { relativePath, group: groupOf(relativePath), used: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) };
|
||||||
|
}
|
||||||
|
const obj = isObj(doc) ? doc : {};
|
||||||
|
const meta = isObj(obj.meta) ? obj.meta : {};
|
||||||
|
const components = Array.isArray(obj.components) ? obj.components : [];
|
||||||
|
const used = [...new Set(components.map((c) => (isObj(c) ? str(c.ref) : undefined)).filter((r): r is string => !!r))];
|
||||||
|
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Groups in display order: the root group first, then folders in path order. */
|
||||||
|
export function sortedGroups(summaries: DiagramSummary[]): { group: string; diagrams: DiagramSummary[] }[] {
|
||||||
|
const byGroup = new Map<string, DiagramSummary[]>();
|
||||||
|
for (const s of summaries) byGroup.set(s.group, [...(byGroup.get(s.group) ?? []), s]);
|
||||||
|
const groups = [...byGroup.keys()].sort((a, b) => (a === '' ? -1 : b === '' ? 1 : a.localeCompare(b)));
|
||||||
|
return groups.map((group) => ({ group, diagrams: byGroup.get(group)!.sort((a, b) => a.relativePath.localeCompare(b.relativePath)) }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ModelIndex } from './service';
|
||||||
|
|
||||||
|
/** What the model page shows; serialised into the webview. */
|
||||||
|
export interface ModelPanel {
|
||||||
|
meta: { id?: string; name?: string; description?: string };
|
||||||
|
/** True when main.archify also carries a diagram, shown between metadata and the list. */
|
||||||
|
hasDiagram: boolean;
|
||||||
|
groups: { group: string; diagrams: { path: string; title?: string; kind?: string; parseError?: string }[] }[];
|
||||||
|
registry: { id: string; label?: string; type?: string; description?: string; file: string; usedIn: string[] }[];
|
||||||
|
problems: string[];
|
||||||
|
nested: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function panelFromIndex(index: ModelIndex): ModelPanel {
|
||||||
|
const registry = [...index.registry.elements.values()]
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
.map((e) => ({ id: e.id, label: e.label, type: e.type, description: e.description, file: e.file, usedIn: index.usage.get(e.id) ?? [] }));
|
||||||
|
return {
|
||||||
|
meta: index.meta,
|
||||||
|
hasDiagram: index.mainHasDiagram,
|
||||||
|
groups: index.groups.map((g) => ({
|
||||||
|
group: g.group,
|
||||||
|
diagrams: g.diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind, parseError: d.parseError })),
|
||||||
|
})),
|
||||||
|
registry,
|
||||||
|
problems: index.registry.problems.map((p) => (p.file ? `${p.file}: ${p.message}` : p.message)),
|
||||||
|
nested: index.nested,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { DiagramSource, readDiagramSource } from '../sources/detect';
|
||||||
|
import { ModelProblem, Registry, resolveRefs } from './format';
|
||||||
|
import { ModelService } from './service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A diagram ready for Archify: the source as written, plus the resolved JSON
|
||||||
|
* with registry references expanded, the model problems found on the way,
|
||||||
|
* and the diagram links the viewer should offer.
|
||||||
|
*/
|
||||||
|
export interface PreparedDiagram {
|
||||||
|
source: DiagramSource;
|
||||||
|
/** Model root when the file is inside a model. */
|
||||||
|
root?: vscode.Uri;
|
||||||
|
registry?: Registry;
|
||||||
|
problems: ModelProblem[];
|
||||||
|
/** Node id → diagram path relative to the model root. */
|
||||||
|
links: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a diagram and resolves it against its model, if any. Every place
|
||||||
|
* that hands a diagram to Archify (preview, Problems, render, browser) goes
|
||||||
|
* through here, so they all see the same resolved diagram.
|
||||||
|
*/
|
||||||
|
export async function prepareDiagram(models: ModelService, uri: vscode.Uri, text: string): Promise<PreparedDiagram | undefined> {
|
||||||
|
const source = readDiagramSource(text, uri.path);
|
||||||
|
if (!source) return undefined;
|
||||||
|
const found = await models.registryFor(uri);
|
||||||
|
const resolved = resolveRefs(source.diagram, { registry: found?.registry, fileName: uri.path });
|
||||||
|
// Only diagrams that use refs (or carry a model block) need the resolved form.
|
||||||
|
const changed = resolved.refs.size > 0 || resolved.problems.length > 0 || 'model' in source.diagram || resolved.links.size > 0;
|
||||||
|
const prepared: DiagramSource = changed
|
||||||
|
? { ...source, diagram: resolved.diagram, json: JSON.stringify(resolved.diagram, null, 2) }
|
||||||
|
: source;
|
||||||
|
return { source: prepared, root: found?.root, registry: found?.registry, problems: resolved.problems, links: resolved.links };
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { log } from '../util/log';
|
||||||
|
import {
|
||||||
|
DiagramSummary,
|
||||||
|
MAIN_FILE,
|
||||||
|
ModelMeta,
|
||||||
|
REGISTRY_EXT,
|
||||||
|
Registry,
|
||||||
|
hasDiagramBody,
|
||||||
|
isMainFile,
|
||||||
|
isRegistryFile,
|
||||||
|
mergeRegistries,
|
||||||
|
parseRegistry,
|
||||||
|
readModelMeta,
|
||||||
|
sortedGroups,
|
||||||
|
summarizeDiagram,
|
||||||
|
} from './format';
|
||||||
|
import { parse as parseYaml } from 'yaml';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds models in the workspace and keeps what they contain. A model is the
|
||||||
|
* folder tree under a `main.archify`; this service answers "which model is
|
||||||
|
* this file in?", loads the model's registry, lists its diagrams, and tells
|
||||||
|
* listeners when any of that changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ModelIndex {
|
||||||
|
root: vscode.Uri;
|
||||||
|
main: vscode.Uri;
|
||||||
|
meta: ModelMeta;
|
||||||
|
/** True when main.archify also carries a diagram. */
|
||||||
|
mainHasDiagram: boolean;
|
||||||
|
registry: Registry;
|
||||||
|
diagrams: DiagramSummary[];
|
||||||
|
groups: { group: string; diagrams: DiagramSummary[] }[];
|
||||||
|
/** Element id → diagrams (relative paths) that refer to it. */
|
||||||
|
usage: Map<string, string[]>;
|
||||||
|
/** Nested main.archify files found below the root; not supported. */
|
||||||
|
nested: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKIP = '{**/node_modules/**,**/.git/**}';
|
||||||
|
|
||||||
|
async function exists(uri: vscode.Uri): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await vscode.workspace.fs.stat(uri);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(bytes: Uint8Array): string {
|
||||||
|
return new TextDecoder('utf-8').decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open editor text when the file is open, else the file on disk. */
|
||||||
|
async function readText(uri: vscode.Uri): Promise<string> {
|
||||||
|
const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString());
|
||||||
|
if (open) return open.getText();
|
||||||
|
return text(await vscode.workspace.fs.readFile(uri));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ModelService implements vscode.Disposable {
|
||||||
|
private readonly roots = new Map<string, vscode.Uri | null>();
|
||||||
|
private readonly indexes = new Map<string, Promise<ModelIndex>>();
|
||||||
|
private readonly changeEmitter = new vscode.EventEmitter<{ root: vscode.Uri; file: vscode.Uri }>();
|
||||||
|
/** Fires with the model root and the file under it that changed. */
|
||||||
|
readonly onDidChangeModel = this.changeEmitter.event;
|
||||||
|
private readonly disposables: vscode.Disposable[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const watcher = vscode.workspace.createFileSystemWatcher(`**/*{.archify,${REGISTRY_EXT}}`);
|
||||||
|
const changed = (uri: vscode.Uri) => void this.onFileChanged(uri);
|
||||||
|
this.disposables = [
|
||||||
|
watcher,
|
||||||
|
watcher.onDidCreate(changed),
|
||||||
|
watcher.onDidDelete(changed),
|
||||||
|
watcher.onDidChange(changed),
|
||||||
|
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||||
|
// Registry edits in the editor should re-render open diagrams before a save.
|
||||||
|
if (isRegistryFile(event.document.fileName) || isMainFile(event.document.fileName)) changed(event.document.uri);
|
||||||
|
}),
|
||||||
|
this.changeEmitter,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onFileChanged(uri: vscode.Uri): Promise<void> {
|
||||||
|
const name = path.basename(uri.path);
|
||||||
|
if (name === MAIN_FILE) this.roots.clear(); // a model may have appeared or disappeared
|
||||||
|
const root = await this.rootOf(uri);
|
||||||
|
if (!root) return;
|
||||||
|
this.indexes.delete(root.toString());
|
||||||
|
this.changeEmitter.fire({ root, file: uri });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The model root (folder with main.archify) above a file, or undefined. */
|
||||||
|
async rootOf(uri: vscode.Uri): Promise<vscode.Uri | undefined> {
|
||||||
|
if (uri.scheme !== 'file') return undefined;
|
||||||
|
const dirKey = path.dirname(uri.fsPath);
|
||||||
|
const cached = this.roots.get(dirKey);
|
||||||
|
if (cached !== undefined) return cached ?? undefined;
|
||||||
|
let dir = dirKey;
|
||||||
|
const visited: string[] = [];
|
||||||
|
let found: vscode.Uri | null = null;
|
||||||
|
for (;;) {
|
||||||
|
visited.push(dir);
|
||||||
|
if (await exists(vscode.Uri.file(path.join(dir, MAIN_FILE)))) {
|
||||||
|
found = vscode.Uri.file(dir);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) break;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
for (const d of visited) this.roots.set(d, found);
|
||||||
|
return found ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The registry of the model a file belongs to, or undefined outside a model. */
|
||||||
|
async registryFor(uri: vscode.Uri): Promise<{ root: vscode.Uri; registry: Registry } | undefined> {
|
||||||
|
const root = await this.rootOf(uri);
|
||||||
|
if (!root) return undefined;
|
||||||
|
const index = await this.index(root);
|
||||||
|
return { root, registry: index.registry };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything known about a model, cached until a file under it changes. */
|
||||||
|
index(root: vscode.Uri): Promise<ModelIndex> {
|
||||||
|
const key = root.toString();
|
||||||
|
let pending = this.indexes.get(key);
|
||||||
|
if (!pending) {
|
||||||
|
pending = this.build(root).catch((error) => {
|
||||||
|
this.indexes.delete(key);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
this.indexes.set(key, pending);
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async build(root: vscode.Uri): Promise<ModelIndex> {
|
||||||
|
const main = vscode.Uri.joinPath(root, MAIN_FILE);
|
||||||
|
let meta: ModelMeta = {};
|
||||||
|
let mainHasDiagram = false;
|
||||||
|
try {
|
||||||
|
const doc = parseYaml(await readText(main), { prettyErrors: false, uniqueKeys: false });
|
||||||
|
meta = readModelMeta(doc) ?? {};
|
||||||
|
mainHasDiagram = hasDiagramBody(doc);
|
||||||
|
} catch (error) {
|
||||||
|
log().warn(`main.archify of ${root.fsPath} does not parse: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registryFiles = await vscode.workspace.findFiles(new vscode.RelativePattern(root, `**/*${REGISTRY_EXT}`), SKIP);
|
||||||
|
const parts = await Promise.all(
|
||||||
|
registryFiles.sort((a, b) => a.fsPath.localeCompare(b.fsPath)).map(async (file) => {
|
||||||
|
const rel = path.relative(root.fsPath, file.fsPath);
|
||||||
|
return { file: rel, ...parseRegistry(await readText(file), rel) };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const registry = mergeRegistries(parts);
|
||||||
|
|
||||||
|
const diagramFiles = await vscode.workspace.findFiles(new vscode.RelativePattern(root, '**/*.archify'), SKIP);
|
||||||
|
const nested: string[] = [];
|
||||||
|
const diagrams: DiagramSummary[] = [];
|
||||||
|
for (const file of diagramFiles) {
|
||||||
|
const rel = path.relative(root.fsPath, file.fsPath).split(path.sep).join('/');
|
||||||
|
if (rel === MAIN_FILE) continue;
|
||||||
|
if (path.basename(file.fsPath) === MAIN_FILE) {
|
||||||
|
nested.push(rel);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
diagrams.push(summarizeDiagram(await readText(file), rel));
|
||||||
|
}
|
||||||
|
const usage = new Map<string, string[]>();
|
||||||
|
for (const d of diagrams) for (const id of d.used) usage.set(id, [...(usage.get(id) ?? []), d.relativePath]);
|
||||||
|
return { root, main, meta, mainHasDiagram, registry, diagrams, groups: sortedGroups(diagrams), usage, nested };
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const d of this.disposables.splice(0)) d.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,16 @@ import path from 'node:path';
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { renderDiagram } from '../archify/cli';
|
import { renderDiagram } from '../archify/cli';
|
||||||
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||||
import { isArchifyFile, readDiagramSource } from '../sources/detect';
|
import { isMainFile, isRegistryFile } from '../model/format';
|
||||||
|
import { ModelPanel, panelFromIndex } from '../model/panel';
|
||||||
|
import { prepareDiagram } from '../model/prepare';
|
||||||
|
import { ModelService } from '../model/service';
|
||||||
|
import { isArchifyFile } from '../sources/detect';
|
||||||
import { readText } from '../util/files';
|
import { readText } from '../util/files';
|
||||||
import { log } from '../util/log';
|
import { log } from '../util/log';
|
||||||
import { viewerTheme } from '../util/settings';
|
import { viewerTheme } from '../util/settings';
|
||||||
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||||
import { makeNonce, messagePage, prepareDiagramHtml } from '../webview/page';
|
import { makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
|
||||||
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||||
|
|
||||||
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
||||||
@@ -28,9 +32,12 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
private readonly onDisposeEmitter = new vscode.EventEmitter<void>();
|
private readonly onDisposeEmitter = new vscode.EventEmitter<void>();
|
||||||
readonly onDidDispose = this.onDisposeEmitter.event;
|
readonly onDidDispose = this.onDisposeEmitter.event;
|
||||||
|
/** Root of the model this file belongs to, once known. */
|
||||||
|
root: vscode.Uri | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly context: vscode.ExtensionContext,
|
private readonly context: vscode.ExtensionContext,
|
||||||
|
private readonly models: ModelService,
|
||||||
readonly panel: vscode.WebviewPanel,
|
readonly panel: vscode.WebviewPanel,
|
||||||
readonly uri: vscode.Uri,
|
readonly uri: vscode.Uri,
|
||||||
state?: PreviewState,
|
state?: PreviewState,
|
||||||
@@ -51,6 +58,10 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
this.hash = message.hash;
|
this.hash = message.hash;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (message.type === 'openDiagram') {
|
||||||
|
void this.openLinked(message.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void handleWebviewMessage(message, this.uri, this.panel);
|
void handleWebviewMessage(message, this.uri, this.panel);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -84,6 +95,27 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens a diagram of the model, given its path relative to the model root. */
|
||||||
|
private async openLinked(relativePath: string): Promise<void> {
|
||||||
|
if (!this.root) return;
|
||||||
|
const target = vscode.Uri.joinPath(this.root, ...relativePath.split('/'));
|
||||||
|
try {
|
||||||
|
await vscode.workspace.fs.stat(target);
|
||||||
|
} catch {
|
||||||
|
vscode.window.showWarningMessage(`Archify: ${relativePath} does not exist in this model.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active);
|
||||||
|
}
|
||||||
|
|
||||||
|
private showModelPage(model: ModelPanel): void {
|
||||||
|
const fingerprint = `model\u0000${JSON.stringify(model)}`;
|
||||||
|
if (fingerprint === this.lastHtml) return;
|
||||||
|
this.lastHtml = fingerprint;
|
||||||
|
this.rendered = false;
|
||||||
|
this.panel.webview.html = modelPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() }, model);
|
||||||
|
}
|
||||||
|
|
||||||
scheduleUpdate(): void {
|
scheduleUpdate(): void {
|
||||||
const delay = vscode.workspace.getConfiguration('archify', this.uri).get<number>('preview.debounceMs', 400);
|
const delay = vscode.workspace.getConfiguration('archify', this.uri).get<number>('preview.debounceMs', 400);
|
||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
@@ -100,14 +132,37 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
this.showMessage('Cannot read diagram', this.uri.fsPath, error instanceof Error ? error.message : String(error));
|
this.showMessage('Cannot read diagram', this.uri.fsPath, error instanceof Error ? error.message : String(error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const source = readDiagramSource(text, this.uri.path);
|
// main.archify: the model page wraps the diagram, or stands alone.
|
||||||
if (!source) {
|
const main = isMainFile(this.uri.path) && this.uri.scheme === 'file';
|
||||||
|
let model: ModelPanel | undefined;
|
||||||
|
if (main) {
|
||||||
|
this.root = vscode.Uri.file(path.dirname(this.uri.fsPath));
|
||||||
|
try {
|
||||||
|
model = panelFromIndex(await this.models.index(this.root));
|
||||||
|
} catch (error) {
|
||||||
|
log().warn(`Model index failed for ${this.root.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let prepared;
|
||||||
|
try {
|
||||||
|
prepared = await prepareDiagram(this.models, this.uri, text);
|
||||||
|
} catch (error) {
|
||||||
|
log().warn(`Could not resolve ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
if (seq !== this.seq) return;
|
||||||
|
if (!main) this.root = prepared?.root;
|
||||||
|
if (!prepared) {
|
||||||
|
if (model) {
|
||||||
|
this.showModelPage(model);
|
||||||
|
return;
|
||||||
|
}
|
||||||
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 ${isArchifyFile(this.uri.path) ? 'YAML' : '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;
|
||||||
}
|
}
|
||||||
|
const source = prepared.source;
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
@@ -139,9 +194,11 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
state: this.state(),
|
state: this.state(),
|
||||||
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
|
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
|
||||||
viewer3d: viewer3dOption(this.panel.webview, this.context.extensionUri),
|
viewer3d: viewer3dOption(this.panel.webview, this.context.extensionUri),
|
||||||
|
links: Object.fromEntries(prepared.links),
|
||||||
|
model,
|
||||||
});
|
});
|
||||||
// Nonces differ per render, so compare the renderer output instead.
|
// Nonces differ per render, so compare the renderer output instead.
|
||||||
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${result.html}`;
|
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${JSON.stringify([model ?? null, [...prepared.links]])}\u0000${result.html}`;
|
||||||
if (!force && this.rendered && fingerprint === this.lastHtml) {
|
if (!force && this.rendered && fingerprint === this.lastHtml) {
|
||||||
void this.panel.webview.postMessage({ type: 'notice', notice: undefined });
|
void this.panel.webview.postMessage({ type: 'notice', notice: undefined });
|
||||||
return;
|
return;
|
||||||
@@ -173,8 +230,20 @@ export class DiagramViewManager
|
|||||||
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,
|
||||||
|
private readonly models: ModelService,
|
||||||
|
) {
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
|
// A registry or main.archify change re-renders every diagram of the model;
|
||||||
|
// any other file change only refreshes the model page's list.
|
||||||
|
models.onDidChangeModel(({ root, file }) => {
|
||||||
|
const whole = isRegistryFile(file.path) || isMainFile(file.path);
|
||||||
|
for (const preview of [...this.previews.values(), ...this.editors]) {
|
||||||
|
if (preview.root?.toString() !== root.toString()) continue;
|
||||||
|
if (whole || isMainFile(preview.uri.path)) preview.scheduleUpdate();
|
||||||
|
}
|
||||||
|
}),
|
||||||
vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this),
|
vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this),
|
||||||
vscode.window.registerCustomEditorProvider(DIAGRAM_EDITOR_VIEW_TYPE, this, {
|
vscode.window.registerCustomEditorProvider(DIAGRAM_EDITOR_VIEW_TYPE, this, {
|
||||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||||
@@ -210,7 +279,7 @@ export class DiagramViewManager
|
|||||||
/** Opening a .archify file lands here: the diagram is the editor. */
|
/** Opening a .archify file lands here: the diagram is the editor. */
|
||||||
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
|
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||||
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
|
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
|
||||||
this.track(new DiagramPreview(this.context, panel, document.uri, undefined, true));
|
this.track(new DiagramPreview(this.context, this.models, 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 {
|
||||||
@@ -225,7 +294,7 @@ export class DiagramViewManager
|
|||||||
{ viewColumn, preserveFocus },
|
{ viewColumn, preserveFocus },
|
||||||
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) },
|
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) },
|
||||||
);
|
);
|
||||||
this.track(new DiagramPreview(this.context, panel, uri));
|
this.track(new DiagramPreview(this.context, this.models, panel, uri));
|
||||||
}
|
}
|
||||||
|
|
||||||
async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> {
|
async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> {
|
||||||
@@ -233,7 +302,7 @@ export class DiagramViewManager
|
|||||||
panel.dispose();
|
panel.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.track(new DiagramPreview(this.context, panel, vscode.Uri.parse(state.uri), state));
|
this.track(new DiagramPreview(this.context, this.models, panel, vscode.Uri.parse(state.uri), state));
|
||||||
}
|
}
|
||||||
|
|
||||||
private track(preview: DiagramPreview): void {
|
private track(preview: DiagramPreview): void {
|
||||||
@@ -247,6 +316,7 @@ export class DiagramViewManager
|
|||||||
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;
|
||||||
void vscode.commands.executeCommand('setContext', 'archifyPreviewFocus', Boolean(this.active));
|
void vscode.commands.executeCommand('setContext', 'archifyPreviewFocus', Boolean(this.active));
|
||||||
|
if (preview.panel.active) void vscode.commands.executeCommand('setContext', 'archify.inModel', Boolean(preview.root));
|
||||||
};
|
};
|
||||||
preview.panel.onDidChangeViewState(onViewState);
|
preview.panel.onDidChangeViewState(onViewState);
|
||||||
onViewState();
|
onViewState();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type WebviewMessage =
|
|||||||
| { type: 'openExternal'; href: string }
|
| { type: 'openExternal'; href: string }
|
||||||
| { type: 'openRelative'; href: string }
|
| { type: 'openRelative'; href: string }
|
||||||
| { type: 'openSource'; path: string; line?: number; endLine?: number; href?: string }
|
| { type: 'openSource'; path: string; line?: number; endLine?: number; href?: string }
|
||||||
|
| { type: 'openDiagram'; path: string }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
/** Column for opening files next to a webview without covering it. */
|
/** Column for opening files next to a webview without covering it. */
|
||||||
@@ -92,6 +93,8 @@ export async function handleWebviewMessage(
|
|||||||
vscode.window.showErrorMessage(`Archify: ${message.message}`);
|
vscode.window.showErrorMessage(`Archify: ${message.message}`);
|
||||||
break;
|
break;
|
||||||
case 'hash':
|
case 'hash':
|
||||||
|
case 'openDiagram':
|
||||||
|
// Handled by the view, which knows the model root.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+143
-1
@@ -14,8 +14,14 @@ export interface PageOptions {
|
|||||||
notice?: { text: string; kind: 'info' | 'warning' | 'error' };
|
notice?: { text: string; kind: 'info' | 'warning' | 'error' };
|
||||||
/** The 3D viewer script (a webview URI), added to Archify pages. */
|
/** The 3D viewer script (a webview URI), added to Archify pages. */
|
||||||
viewer3d?: { scriptUri: string; defaultView: '2d' | '3d' };
|
viewer3d?: { scriptUri: string; defaultView: '2d' | '3d' };
|
||||||
|
/** Node id → diagram path (relative to the model root); double-click opens it. */
|
||||||
|
links?: Record<string, string>;
|
||||||
|
/** The model page: metadata above the diagram, the diagram list and registry below. */
|
||||||
|
model?: ModelPanel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { ModelPanel } from '../model/panel';
|
||||||
|
|
||||||
export function makeNonce(): string {
|
export function makeNonce(): string {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
let nonce = '';
|
let nonce = '';
|
||||||
@@ -66,6 +72,8 @@ function bridgeScript(options: PageOptions): string {
|
|||||||
state: options.state,
|
state: options.state,
|
||||||
notice: options.notice,
|
notice: options.notice,
|
||||||
defaultView: options.viewer3d?.defaultView,
|
defaultView: options.viewer3d?.defaultView,
|
||||||
|
links: options.links ?? {},
|
||||||
|
model: options.model,
|
||||||
};
|
};
|
||||||
return `(function () {
|
return `(function () {
|
||||||
var config = ${scriptJson(config)};
|
var config = ${scriptJson(config)};
|
||||||
@@ -162,10 +170,98 @@ function bridgeScript(options: PageOptions): string {
|
|||||||
(document.body || document.documentElement).appendChild(bar);
|
(document.body || document.documentElement).appendChild(bar);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A node that links to another diagram opens it on double-click.
|
||||||
|
document.addEventListener('dblclick', function (event) {
|
||||||
|
var target = event.target;
|
||||||
|
var node = target && target.closest ? target.closest('[data-node-id]') : null;
|
||||||
|
if (!node) return;
|
||||||
|
var id = node.getAttribute('data-node-id');
|
||||||
|
if (id && config.links[id]) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
post({ type: 'openDiagram', path: config.links[id] });
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
function el(tag, className, text) {
|
||||||
|
var node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The model page: metadata first, the diagram (if any) where Archify put
|
||||||
|
// it, then the diagrams of the model and the registry.
|
||||||
|
function renderModel(model) {
|
||||||
|
if (!model) return;
|
||||||
|
var anchor = document.querySelector('.container');
|
||||||
|
var head = el('section', 'archify-model archify-model-head');
|
||||||
|
head.appendChild(el('div', 'archify-model-kicker', 'Model' + (model.meta.id ? ' · ' + model.meta.id : '')));
|
||||||
|
head.appendChild(el('h1', 'archify-model-name', model.meta.name || model.meta.id || 'Untitled model'));
|
||||||
|
if (model.meta.description) head.appendChild(el('p', 'archify-model-description', model.meta.description.trim()));
|
||||||
|
if (model.nested.length) head.appendChild(el('p', 'archify-model-problem', 'Nested main.archify files are not supported and were ignored: ' + model.nested.join(', ')));
|
||||||
|
model.problems.forEach(function (problem) { head.appendChild(el('p', 'archify-model-problem', problem)); });
|
||||||
|
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(head, anchor); else document.body.appendChild(head);
|
||||||
|
|
||||||
|
var body = el('section', 'archify-model archify-model-body');
|
||||||
|
var count = model.groups.reduce(function (n, g) { return n + g.diagrams.length; }, 0);
|
||||||
|
body.appendChild(el('h2', 'archify-model-title', 'Diagrams' + (count ? ' · ' + count : '')));
|
||||||
|
if (!count) body.appendChild(el('p', 'archify-model-empty', 'No diagrams yet. Add .archify files next to main.archify or in subfolders.'));
|
||||||
|
model.groups.forEach(function (group) {
|
||||||
|
if (group.group) body.appendChild(el('h3', 'archify-model-group', group.group));
|
||||||
|
var list = el('ul', 'archify-model-list');
|
||||||
|
group.diagrams.forEach(function (diagram) {
|
||||||
|
var item = el('li', 'archify-model-item' + (diagram.parseError ? ' is-broken' : ''));
|
||||||
|
var button = el('button', 'archify-model-open');
|
||||||
|
button.type = 'button';
|
||||||
|
button.title = diagram.path;
|
||||||
|
button.appendChild(el('span', 'archify-model-item-title', diagram.title || diagram.path.split('/').pop()));
|
||||||
|
button.appendChild(el('span', 'archify-model-item-meta', diagram.parseError ? 'does not parse: ' + diagram.parseError : (diagram.kind || 'diagram') + ' · ' + diagram.path));
|
||||||
|
button.addEventListener('click', function () { post({ type: 'openDiagram', path: diagram.path }); });
|
||||||
|
item.appendChild(button);
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
body.appendChild(list);
|
||||||
|
});
|
||||||
|
if (model.registry.length) {
|
||||||
|
body.appendChild(el('h2', 'archify-model-title', 'Registry · ' + model.registry.length));
|
||||||
|
var table = el('table', 'archify-model-registry');
|
||||||
|
model.registry.forEach(function (element) {
|
||||||
|
var row = el('tr');
|
||||||
|
var name = el('td', 'archify-model-element');
|
||||||
|
name.appendChild(el('strong', '', element.label || element.id));
|
||||||
|
name.appendChild(el('span', 'archify-model-item-meta', element.id + (element.type ? ' · ' + element.type : '')));
|
||||||
|
row.appendChild(name);
|
||||||
|
row.appendChild(el('td', 'archify-model-element-description', element.description || ''));
|
||||||
|
var used = el('td', 'archify-model-used');
|
||||||
|
if (element.usedIn.length) {
|
||||||
|
element.usedIn.forEach(function (path) {
|
||||||
|
var link = el('button', 'archify-model-used-link', path.replace(/\.archify$/, ''));
|
||||||
|
link.type = 'button';
|
||||||
|
link.title = path;
|
||||||
|
link.addEventListener('click', function () { post({ type: 'openDiagram', path: path }); });
|
||||||
|
used.appendChild(link);
|
||||||
|
});
|
||||||
|
} else used.appendChild(el('span', 'archify-model-item-meta', 'not used yet'));
|
||||||
|
row.appendChild(used);
|
||||||
|
table.appendChild(row);
|
||||||
|
});
|
||||||
|
body.appendChild(table);
|
||||||
|
}
|
||||||
|
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('message', function (event) {
|
window.addEventListener('message', function (event) {
|
||||||
var message = event.data || {};
|
var message = event.data || {};
|
||||||
if (message.type === 'notice') showNotice(message.notice);
|
if (message.type === 'notice') showNotice(message.notice);
|
||||||
});
|
});
|
||||||
|
if (config.model) {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function () { renderModel(config.model); });
|
||||||
|
} else {
|
||||||
|
renderModel(config.model);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (config.notice) {
|
if (config.notice) {
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
||||||
@@ -176,6 +272,33 @@ function bridgeScript(options: PageOptions): string {
|
|||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MODEL_STYLE = `
|
||||||
|
.archify-model { --m-text: var(--text, var(--vscode-foreground)); --m-muted: var(--text-muted, var(--vscode-descriptionForeground)); --m-panel: var(--panel, var(--vscode-editorWidget-background)); --m-border: var(--panel-border, var(--vscode-editorWidget-border, rgba(128,128,128,.35))); --m-accent: var(--frontend-stroke, var(--vscode-textLink-foreground));
|
||||||
|
max-width: 1400px; margin: 0 auto; padding: 1.25rem 2rem; color: var(--m-text); font-family: var(--vscode-font-family, system-ui, sans-serif); }
|
||||||
|
.archify-model-head { padding-top: 1.5rem; }
|
||||||
|
.archify-model-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--m-accent); }
|
||||||
|
.archify-model-name { margin: 0.25rem 0 0.5rem; font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||||
|
.archify-model-description { margin: 0; max-width: 72ch; font-size: 0.9375rem; line-height: 1.55; color: var(--m-muted); white-space: pre-line; }
|
||||||
|
.archify-model-problem { margin: 0.75rem 0 0; padding: 0.5rem 0.75rem; border-radius: 0.5rem; border: 1px solid var(--vscode-inputValidation-errorBorder, #be1100); color: var(--m-text); font-size: 0.8125rem; }
|
||||||
|
.archify-model-title { margin: 1.5rem 0 0.75rem; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.02em; }
|
||||||
|
.archify-model-group { margin: 1rem 0 0.25rem; font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; }
|
||||||
|
.archify-model-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.5rem; }
|
||||||
|
.archify-model-open { display: flex; flex-direction: column; gap: 0.2rem; width: 100%; padding: 0.7rem 0.85rem; text-align: left; cursor: pointer; font: inherit; color: var(--m-text); background: var(--m-panel); border: 1px solid var(--m-border); border-radius: 0.6rem; transition: border-color 0.15s, transform 0.15s; }
|
||||||
|
.archify-model-open:hover { border-color: var(--m-accent); transform: translateY(-1px); }
|
||||||
|
.archify-model-open:focus-visible { outline: 2px solid var(--m-accent); outline-offset: 2px; }
|
||||||
|
.archify-model-item.is-broken .archify-model-open { border-style: dashed; }
|
||||||
|
.archify-model-item-title { font-weight: 600; font-size: 0.9375rem; }
|
||||||
|
.archify-model-item-meta { font-size: 0.75rem; color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; }
|
||||||
|
.archify-model-empty { color: var(--m-muted); }
|
||||||
|
.archify-model-registry { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||||
|
.archify-model-registry td { padding: 0.55rem 0.6rem; border-top: 1px solid var(--m-border); vertical-align: top; }
|
||||||
|
.archify-model-element { width: 22%; } .archify-model-element strong { display: block; }
|
||||||
|
.archify-model-element-description { color: var(--m-muted); line-height: 1.45; }
|
||||||
|
.archify-model-used { width: 28%; display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||||
|
.archify-model-used-link { font: inherit; font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 999px; border: 1px solid var(--m-border); background: transparent; color: var(--m-text); cursor: pointer; font-family: ui-monospace, Menlo, monospace; }
|
||||||
|
.archify-model-used-link:hover { border-color: var(--m-accent); }
|
||||||
|
`;
|
||||||
|
|
||||||
const NOTICE_STYLE = `
|
const NOTICE_STYLE = `
|
||||||
.archify-vscode-notice {
|
.archify-vscode-notice {
|
||||||
position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 2147483647;
|
position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 2147483647;
|
||||||
@@ -197,7 +320,7 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string {
|
|||||||
const head =
|
const head =
|
||||||
`<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">\n` +
|
`<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">\n` +
|
||||||
`<script nonce="${options.nonce}">${bridgeScript(options)}</script>\n` +
|
`<script nonce="${options.nonce}">${bridgeScript(options)}</script>\n` +
|
||||||
`<style>${NOTICE_STYLE}</style>\n`;
|
`<style>${NOTICE_STYLE}${options.model ? MODEL_STYLE : ''}</style>\n`;
|
||||||
|
|
||||||
let page = html
|
let page = html
|
||||||
// Drop any CSP the file brings along; ours is the one that applies.
|
// Drop any CSP the file brings along; ours is the one that applies.
|
||||||
@@ -220,6 +343,25 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string {
|
|||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The model page for a main.archify without a diagram of its own. */
|
||||||
|
export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, model: ModelPanel): string {
|
||||||
|
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, model, links: {} });
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
|
||||||
|
<script nonce="${options.nonce}">${bridge}</script>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
||||||
|
${MODEL_STYLE}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
/** A small VS Code-styled page for loading and error states. */
|
/** A small VS Code-styled page for loading and error states. */
|
||||||
export function messagePage(
|
export function messagePage(
|
||||||
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>,
|
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# main.archify: the entry point of a model, a folder of diagrams that belong
|
||||||
|
# together. The folder that holds this file is the model's root. Diagrams
|
||||||
|
# below it may refer to shared elements from *.archify-registry files.
|
||||||
|
schema_version: 1
|
||||||
|
model:
|
||||||
|
id: my-model
|
||||||
|
name: My Model
|
||||||
|
description: >
|
||||||
|
What this model covers, in a sentence or two.
|
||||||
|
# Optional: a diagram of your own for the model page (an overview).
|
||||||
|
diagram_type: architecture
|
||||||
|
meta:
|
||||||
|
title: Overview
|
||||||
|
output: main.html
|
||||||
|
components:
|
||||||
|
- id: users
|
||||||
|
type: external
|
||||||
|
label: Users
|
||||||
|
pos: [40, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- id: system
|
||||||
|
type: cloud
|
||||||
|
label: The system
|
||||||
|
pos: [250, 160]
|
||||||
|
size: [150, 60]
|
||||||
|
connections:
|
||||||
|
- { from: users, to: system, label: use }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Shared elements of the model: one name for one thing. A diagram refers to
|
||||||
|
# an element with `ref: <id>` and adds only its position and size.
|
||||||
|
schema_version: 1
|
||||||
|
elements:
|
||||||
|
api:
|
||||||
|
type: backend
|
||||||
|
label: API
|
||||||
|
sublabel: REST service
|
||||||
|
description: >
|
||||||
|
What this thing is and does, in one place. Shown on hover and on the
|
||||||
|
model page wherever the element is used.
|
||||||
|
# diagram: 0300-components/api.archify # the diagram that explains it
|
||||||
|
db:
|
||||||
|
type: database
|
||||||
|
label: Database
|
||||||
|
sublabel: PostgreSQL
|
||||||
|
brand: postgresql
|
||||||
|
description: The source of truth.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
schema_version: 1
|
||||||
|
diagram_type: architecture
|
||||||
|
meta:
|
||||||
|
title: System context
|
||||||
|
output: context.html
|
||||||
|
components:
|
||||||
|
- ref: customer
|
||||||
|
pos: [40, 60]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: support
|
||||||
|
pos: [40, 260]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: shop
|
||||||
|
pos: [280, 160]
|
||||||
|
size: [150, 60]
|
||||||
|
- ref: psp
|
||||||
|
pos: [520, 60]
|
||||||
|
size: [150, 60]
|
||||||
|
- ref: email
|
||||||
|
pos: [520, 260]
|
||||||
|
size: [150, 60]
|
||||||
|
connections:
|
||||||
|
- { from: customer, to: shop, label: buys, variant: emphasis }
|
||||||
|
- { from: support, to: shop, label: manages orders }
|
||||||
|
- { from: shop, to: psp, label: charges, variant: security }
|
||||||
|
- { from: shop, to: email, label: sends mail }
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
schema_version: 1
|
||||||
|
diagram_type: architecture
|
||||||
|
meta:
|
||||||
|
title: Containers
|
||||||
|
output: containers.html
|
||||||
|
components:
|
||||||
|
- ref: customer
|
||||||
|
pos: [40, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: web
|
||||||
|
pos: [250, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: api
|
||||||
|
pos: [460, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: cache
|
||||||
|
pos: [670, 60]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: db
|
||||||
|
pos: [670, 260]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: queue
|
||||||
|
pos: [460, 360]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: worker
|
||||||
|
pos: [670, 460]
|
||||||
|
size: [130, 60]
|
||||||
|
boundaries:
|
||||||
|
- kind: region
|
||||||
|
label: Online Shop
|
||||||
|
wraps: [web, api, cache, db, queue, worker]
|
||||||
|
connections:
|
||||||
|
- { from: customer, to: web, label: HTTPS, variant: emphasis }
|
||||||
|
- { from: web, to: api, label: JSON }
|
||||||
|
- { from: api, to: cache, label: read }
|
||||||
|
- { from: api, to: db, label: SQL }
|
||||||
|
- { from: api, to: queue, label: enqueue, labelAt: [560, 290] }
|
||||||
|
- { from: queue, to: worker, label: poll }
|
||||||
|
- { from: worker, to: db, label: SQL, labelAt: [735, 420] }
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
schema_version: 1
|
||||||
|
diagram_type: architecture
|
||||||
|
meta:
|
||||||
|
title: API components
|
||||||
|
output: api.html
|
||||||
|
components:
|
||||||
|
- ref: web
|
||||||
|
pos: [40, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- id: auth
|
||||||
|
type: security
|
||||||
|
label: Auth
|
||||||
|
sublabel: sessions, OIDC
|
||||||
|
pos: [250, 60]
|
||||||
|
size: [130, 60]
|
||||||
|
- id: orders
|
||||||
|
type: backend
|
||||||
|
label: Orders
|
||||||
|
pos: [250, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- id: catalogue
|
||||||
|
type: backend
|
||||||
|
label: Catalogue
|
||||||
|
pos: [250, 260]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: db
|
||||||
|
pos: [460, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: queue
|
||||||
|
pos: [460, 310]
|
||||||
|
size: [130, 60]
|
||||||
|
boundaries:
|
||||||
|
- kind: region
|
||||||
|
label: API
|
||||||
|
wraps: [auth, orders, catalogue]
|
||||||
|
connections:
|
||||||
|
- { from: web, to: auth, label: sign in, variant: security }
|
||||||
|
- { from: web, to: orders, label: place order }
|
||||||
|
- { from: web, to: catalogue, label: browse }
|
||||||
|
- { from: orders, to: db, label: SQL }
|
||||||
|
- { from: catalogue, to: db, label: SQL }
|
||||||
|
- { from: orders, to: queue, label: enqueue }
|
||||||
Vendored
+24
@@ -0,0 +1,24 @@
|
|||||||
|
schema_version: 1
|
||||||
|
model:
|
||||||
|
id: online-shop
|
||||||
|
name: Online Shop
|
||||||
|
description: >
|
||||||
|
The web shop, its API and workers, and the stores behind them.
|
||||||
|
Context, containers and components, C4 style.
|
||||||
|
diagram_type: architecture
|
||||||
|
meta:
|
||||||
|
title: Online Shop — overview
|
||||||
|
output: main.html
|
||||||
|
components:
|
||||||
|
- ref: customer
|
||||||
|
pos: [40, 160]
|
||||||
|
size: [130, 60]
|
||||||
|
- ref: shop
|
||||||
|
pos: [250, 160]
|
||||||
|
size: [150, 60]
|
||||||
|
- ref: psp
|
||||||
|
pos: [480, 160]
|
||||||
|
size: [150, 60]
|
||||||
|
connections:
|
||||||
|
- { from: customer, to: shop, label: buys, variant: emphasis }
|
||||||
|
- { from: shop, to: psp, label: charges }
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
schema_version: 1
|
||||||
|
elements:
|
||||||
|
web:
|
||||||
|
type: frontend
|
||||||
|
label: Web App
|
||||||
|
sublabel: React
|
||||||
|
brand: react
|
||||||
|
description: The shop as customers see it; talks only to the API.
|
||||||
|
api:
|
||||||
|
type: backend
|
||||||
|
label: API
|
||||||
|
sublabel: Node.js
|
||||||
|
brand: node-js
|
||||||
|
description: Owns authentication, orders and the catalogue.
|
||||||
|
diagram: 0300-components/api.archify
|
||||||
|
worker:
|
||||||
|
type: backend
|
||||||
|
label: Worker
|
||||||
|
sublabel: async jobs
|
||||||
|
description: Picks jobs from the queue; emails, invoices, exports.
|
||||||
|
queue:
|
||||||
|
type: messagebus
|
||||||
|
label: Queue
|
||||||
|
sublabel: SQS
|
||||||
|
description: Job queue between the API and the workers.
|
||||||
|
db:
|
||||||
|
type: database
|
||||||
|
label: Database
|
||||||
|
sublabel: PostgreSQL
|
||||||
|
brand: postgresql
|
||||||
|
description: Orders, customers and catalogue; the source of truth.
|
||||||
|
cache:
|
||||||
|
type: database
|
||||||
|
label: Cache
|
||||||
|
sublabel: Redis
|
||||||
|
brand: redis
|
||||||
|
description: Sessions and catalogue cache; nothing here is the source of truth.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
schema_version: 1
|
||||||
|
elements:
|
||||||
|
customer:
|
||||||
|
type: external
|
||||||
|
label: Customer
|
||||||
|
sublabel: web and mobile
|
||||||
|
description: Buys products; signs in with email or a social account.
|
||||||
|
support:
|
||||||
|
type: external
|
||||||
|
label: Support agent
|
||||||
|
sublabel: back office
|
||||||
|
description: Handles refunds and order problems in the admin console.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
schema_version: 1
|
||||||
|
elements:
|
||||||
|
shop:
|
||||||
|
type: cloud
|
||||||
|
label: Online Shop
|
||||||
|
sublabel: the system
|
||||||
|
description: Everything we build and run; web app, API, workers and stores.
|
||||||
|
diagram: 0200-containers/online-shop.archify
|
||||||
|
psp:
|
||||||
|
type: external
|
||||||
|
label: Payment provider
|
||||||
|
sublabel: Stripe
|
||||||
|
brand: stripe
|
||||||
|
description: Charges cards and pays out; we never see card numbers.
|
||||||
|
email:
|
||||||
|
type: external
|
||||||
|
label: Email service
|
||||||
|
sublabel: transactional mail
|
||||||
|
description: Sends order confirmations and password resets.
|
||||||
@@ -248,4 +248,96 @@ export async function run(): Promise<void> {
|
|||||||
'unverifiable evidence is a warning in fallback mode',
|
'unverifiable evidence is a warning in fallback mode',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- models: a folder with main.archify, registries and diagrams that use `ref`
|
||||||
|
|
||||||
|
await check('a diagram inside a model resolves registry refs and validates clean', async () => {
|
||||||
|
const uri = workspaceFile('model/0200-containers/online-shop.archify');
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
// Validation runs through the Archify CLI; give it time, then expect no errors at all.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 6000));
|
||||||
|
const errors = vscode.languages.getDiagnostics(doc.uri).filter((d) => d.source === 'archify' && d.severity === vscode.DiagnosticSeverity.Error);
|
||||||
|
assert.deepEqual(errors.map((d) => d.message), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('an unknown ref is reported at its line', async () => {
|
||||||
|
const target = workspaceFile('model/0300-components/ghost.archify');
|
||||||
|
await vscode.workspace.fs.writeFile(
|
||||||
|
target,
|
||||||
|
new TextEncoder().encode('schema_version: 1\ndiagram_type: architecture\nmeta: { title: Ghost, output: ghost.html }\ncomponents:\n - ref: ghost\n pos: [40, 40]\n size: [130, 60]\n'),
|
||||||
|
);
|
||||||
|
const doc = await vscode.workspace.openTextDocument(target);
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
const diagnostic = await waitFor('unknown ref diagnostic', () =>
|
||||||
|
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && /Unknown element "ghost"/.test(d.message)),
|
||||||
|
);
|
||||||
|
assert.equal(diagnostic.range.start.line, 4, 'reported on the ref line');
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('hover and completion on ref come from the registry', async () => {
|
||||||
|
const uri = workspaceFile('model/0200-containers/online-shop.archify');
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
const line = doc.getText().split('\n').findIndex((l) => /ref: api$/.test(l));
|
||||||
|
assert.ok(line >= 0, 'the containers diagram refers to api');
|
||||||
|
const hovers = await vscode.commands.executeCommand<vscode.Hover[]>('vscode.executeHoverProvider', uri, new vscode.Position(line, 10));
|
||||||
|
const text = hovers.flatMap((h) => h.contents).map((c) => (typeof c === 'string' ? c : c.value)).join('\n');
|
||||||
|
assert.match(text, /Owns authentication/);
|
||||||
|
const defs = await vscode.commands.executeCommand<vscode.Location[]>('vscode.executeDefinitionProvider', uri, new vscode.Position(line, 10));
|
||||||
|
assert.match(defs[0].uri.path, /registry\/containers\.archify-registry$/);
|
||||||
|
const completions = await vscode.commands.executeCommand<vscode.CompletionList>('vscode.executeCompletionItemProvider', uri, new vscode.Position(line, 9));
|
||||||
|
const labels = completions.items.map((i) => (typeof i.label === 'string' ? i.label : i.label.label));
|
||||||
|
assert.ok(labels.includes('api') && labels.includes('queue'), `completion lists registry elements: ${labels.join(', ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('a registry file reports duplicate ids and parse problems', async () => {
|
||||||
|
const target = workspaceFile('model/registry/extra.archify-registry');
|
||||||
|
await vscode.workspace.fs.writeFile(target, new TextEncoder().encode('schema_version: 1\nelements:\n api:\n type: backend\n label: API again\n'));
|
||||||
|
const doc = await vscode.workspace.openTextDocument(target);
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
const diagnostic = await waitFor('duplicate id diagnostic', () =>
|
||||||
|
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && /defined twice/.test(d.message)),
|
||||||
|
);
|
||||||
|
assert.equal(diagnostic.range.start.line, 2);
|
||||||
|
await vscode.workspace.fs.delete(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('main.archify opens as the model page and Open Model finds it from a diagram', async () => {
|
||||||
|
const main = workspaceFile('model/main.archify');
|
||||||
|
await vscode.commands.executeCommand('vscode.open', main);
|
||||||
|
await waitFor('model page tab', () =>
|
||||||
|
vscode.window.tabGroups.all.flatMap((g) => g.tabs).some((t) => t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.diagram' && t.input.uri.toString() === main.toString()),
|
||||||
|
);
|
||||||
|
const diagram = workspaceFile('model/0100-context/online-shop.archify');
|
||||||
|
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(diagram));
|
||||||
|
await vscode.commands.executeCommand('archify.openModel', diagram);
|
||||||
|
const tab = await waitFor('model page active', () =>
|
||||||
|
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.uri.toString() === main.toString()),
|
||||||
|
);
|
||||||
|
assert.ok(tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('Archify | New Diagram… → Model creates main.archify from the folder name', async () => {
|
||||||
|
const folder = workspaceFile('new-shop');
|
||||||
|
await vscode.workspace.fs.createDirectory(folder);
|
||||||
|
const { window } = vscode;
|
||||||
|
const originalPick = window.showQuickPick;
|
||||||
|
(window as { showQuickPick: unknown }).showQuickPick = async (items: Thenable<{ label: string }[]> | { label: string }[]) => (await items).find((i) => /^Model/.test(i.label));
|
||||||
|
try {
|
||||||
|
await vscode.commands.executeCommand('archify.newDiagram', folder);
|
||||||
|
} finally {
|
||||||
|
window.showQuickPick = originalPick;
|
||||||
|
}
|
||||||
|
const main = vscode.Uri.joinPath(folder, 'main.archify');
|
||||||
|
let content = '';
|
||||||
|
for (let i = 0; i < 50 && !content; i++) {
|
||||||
|
try {
|
||||||
|
content = new TextDecoder().decode(await vscode.workspace.fs.readFile(main));
|
||||||
|
} catch {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.match(content, /id: new-shop/);
|
||||||
|
assert.match(content, /name: New shop/);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import {
|
||||||
|
groupOf,
|
||||||
|
hasDiagramBody,
|
||||||
|
isMainFile,
|
||||||
|
isRegistryFile,
|
||||||
|
mergeRegistries,
|
||||||
|
parseRegistry,
|
||||||
|
readModelMeta,
|
||||||
|
resolveRefs,
|
||||||
|
sortedGroups,
|
||||||
|
summarizeDiagram,
|
||||||
|
} from '../src/model/format';
|
||||||
|
import { parse } from 'yaml';
|
||||||
|
|
||||||
|
const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model');
|
||||||
|
const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8');
|
||||||
|
|
||||||
|
function registryOfModel() {
|
||||||
|
const files = ['registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry'];
|
||||||
|
return mergeRegistries(files.map((file) => ({ file, ...parseRegistry(read(file), file) })));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('file kinds are told apart by name', () => {
|
||||||
|
assert.ok(isMainFile('/m/online-shop/main.archify'));
|
||||||
|
assert.ok(!isMainFile('/m/online-shop/0100-context/main-flow.archify'));
|
||||||
|
assert.ok(isRegistryFile('registry/people.archify-registry'));
|
||||||
|
assert.ok(!isRegistryFile('people.archify'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('main.archify: model block and optional diagram body', () => {
|
||||||
|
const doc = parse(read('main.archify'));
|
||||||
|
assert.deepEqual(readModelMeta(doc), {
|
||||||
|
id: 'online-shop',
|
||||||
|
name: 'Online Shop',
|
||||||
|
description: 'The web shop, its API and workers, and the stores behind them. Context, containers and components, C4 style.\n',
|
||||||
|
});
|
||||||
|
assert.ok(hasDiagramBody(doc));
|
||||||
|
assert.equal(readModelMeta(parse(read('0100-context/online-shop.archify'))), undefined);
|
||||||
|
assert.ok(!hasDiagramBody({ schema_version: 1, model: { id: 'x' } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registry: elements with line numbers, duplicates reported once', () => {
|
||||||
|
const people = parseRegistry(read('registry/people.archify-registry'), 'people');
|
||||||
|
assert.deepEqual(people.problems, []);
|
||||||
|
assert.equal(people.elements.length, 2);
|
||||||
|
const customer = people.elements[0];
|
||||||
|
assert.equal(customer.id, 'customer');
|
||||||
|
assert.equal(customer.type, 'external');
|
||||||
|
assert.equal(customer.line, 2);
|
||||||
|
const merged = mergeRegistries([
|
||||||
|
{ file: 'a', ...parseRegistry('elements:\n api: { type: backend, label: API }\n', 'a') },
|
||||||
|
{ file: 'b', ...parseRegistry('elements:\n api: { type: backend, label: API again }\n db: { type: database, label: DB }\n', 'b') },
|
||||||
|
]);
|
||||||
|
assert.equal(merged.elements.size, 2);
|
||||||
|
assert.equal(merged.problems.length, 1);
|
||||||
|
assert.match(merged.problems[0].message, /"api" is defined twice/);
|
||||||
|
assert.equal(merged.problems[0].file, 'b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registry: broken files are reported, not thrown', () => {
|
||||||
|
const bad = parseRegistry('elements:\n api: {\n', 'bad');
|
||||||
|
assert.equal(bad.elements.length, 0);
|
||||||
|
assert.equal(bad.problems[0].severity, 'error');
|
||||||
|
const noMap = parseRegistry('- just\n- a list\n', 'list');
|
||||||
|
assert.match(noMap.problems[0].message, /mapping/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolve: refs become full components, links and usage are collected', () => {
|
||||||
|
const registry = registryOfModel();
|
||||||
|
const doc = parse(read('0200-containers/online-shop.archify'));
|
||||||
|
const r = resolveRefs(doc, { registry, fileName: 'containers.archify' });
|
||||||
|
assert.deepEqual(r.problems, []);
|
||||||
|
const api = (r.diagram.components as Record<string, unknown>[]).find((c) => c.id === 'api')!;
|
||||||
|
assert.deepEqual(api, { id: 'api', type: 'backend', label: 'API', sublabel: 'Node.js', brand: 'node-js', pos: [460, 160], size: [130, 60] });
|
||||||
|
assert.equal(r.links.get('api'), '0300-components/api.archify');
|
||||||
|
assert.ok(!r.links.has('db'));
|
||||||
|
assert.deepEqual([...r.used].sort(), ['api', 'cache', 'customer', 'db', 'queue', 'web', 'worker']);
|
||||||
|
assert.equal(r.refs.get(2)?.id, 'api');
|
||||||
|
assert.ok(!('model' in r.diagram));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolve: overrides, second id for the same element, inline elements and links', () => {
|
||||||
|
const registry = registryOfModel();
|
||||||
|
const doc = {
|
||||||
|
components: [
|
||||||
|
{ ref: 'db', label: 'Orders DB', pos: [0, 0] },
|
||||||
|
{ ref: 'db', id: 'db-replica', sublabel: 'replica', pos: [0, 0] },
|
||||||
|
{ id: 'cron', type: 'backend', label: 'Nightly job', diagram: 'jobs.archify', pos: [0, 0] },
|
||||||
|
{ ref: 'api', diagram: 'other.archify', pos: [0, 0] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const r = resolveRefs(doc, { registry, fileName: 'x.archify' });
|
||||||
|
const components = r.diagram.components as Record<string, unknown>[];
|
||||||
|
assert.equal(components[0].label, 'Orders DB');
|
||||||
|
assert.equal(components[0].brand, 'postgresql');
|
||||||
|
assert.equal(components[1].id, 'db-replica');
|
||||||
|
assert.equal(components[1].sublabel, 'replica');
|
||||||
|
assert.deepEqual(components[2], { id: 'cron', type: 'backend', label: 'Nightly job', pos: [0, 0] });
|
||||||
|
assert.equal(r.links.get('cron'), 'jobs.archify');
|
||||||
|
assert.equal(r.links.get('api'), 'other.archify', 'the component link wins over the registry link');
|
||||||
|
assert.deepEqual(r.problems, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolve: unknown refs, forbidden overrides, outside a model', () => {
|
||||||
|
const registry = registryOfModel();
|
||||||
|
const r = resolveRefs({ components: [{ ref: 'ghost', pos: [0, 0] }, { ref: 'api', type: 'frontend', brand: 'react', pos: [0, 0] }] }, { registry, fileName: 'x.archify' });
|
||||||
|
assert.equal(r.problems.length, 3);
|
||||||
|
assert.equal(r.problems[0].pointer, '/components/0/ref');
|
||||||
|
assert.match(r.problems[0].message, /Unknown element "ghost"/);
|
||||||
|
assert.deepEqual(r.problems.slice(1).map((p) => p.pointer), ['/components/1/type', '/components/1/brand']);
|
||||||
|
const ghost = (r.diagram.components as Record<string, unknown>[])[0];
|
||||||
|
assert.deepEqual(ghost, { type: 'external', label: 'ghost', pos: [0, 0], id: 'ghost' });
|
||||||
|
const outside = resolveRefs({ components: [{ ref: 'api', pos: [0, 0] }] }, { fileName: '/w/loose.archify' });
|
||||||
|
assert.match(outside.problems[0].message, /not inside a model/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summaries and groups: root first, then folders', () => {
|
||||||
|
const summaries = [
|
||||||
|
summarizeDiagram(read('0300-components/api.archify'), '0300-components/api.archify'),
|
||||||
|
summarizeDiagram(read('0100-context/online-shop.archify'), '0100-context/online-shop.archify'),
|
||||||
|
summarizeDiagram('schema_version: 1\ndiagram_type: workflow\nmeta: { title: Checkout }\n', 'checkout.archify'),
|
||||||
|
summarizeDiagram('oops: [', 'broken.archify'),
|
||||||
|
];
|
||||||
|
assert.equal(summaries[0].title, 'API components');
|
||||||
|
assert.equal(summaries[0].kind, 'architecture');
|
||||||
|
assert.deepEqual(summaries[0].used, ['web', 'db', 'queue']);
|
||||||
|
assert.ok(summaries[3].parseError);
|
||||||
|
assert.equal(groupOf('a/b/c.archify'), 'a/b');
|
||||||
|
assert.equal(groupOf('c.archify'), '');
|
||||||
|
const groups = sortedGroups(summaries);
|
||||||
|
assert.deepEqual(groups.map((g) => g.group), ['', '0100-context', '0300-components']);
|
||||||
|
assert.deepEqual(groups[0].diagrams.map((d) => d.relativePath), ['broken.archify', 'checkout.archify']);
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ function load(name: string) {
|
|||||||
return source;
|
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-') && fs.statSync(path.join(fixtures, file)).isFile())) {
|
||||||
test(`renders ${name}`, async () => {
|
test(`renders ${name}`, async () => {
|
||||||
const result = await renderDiagram(runtime(), load(name), 'strict');
|
const result = await renderDiagram(runtime(), load(name), 'strict');
|
||||||
assert.equal(result.error, undefined);
|
assert.equal(result.error, undefined);
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ 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).filter((entry) => fs.statSync(path.join(fixtures, entry)).isFile())) {
|
||||||
const ok = validate(parseYaml(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))}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { parse } from 'yaml';
|
import { parse } from 'yaml';
|
||||||
import { validate } from '../src/archify/cli';
|
import { validate } from '../src/archify/cli';
|
||||||
|
import { resolveRefs } from '../src/model/format';
|
||||||
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES, titleFromName } from '../src/language/templates';
|
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES, titleFromName } from '../src/language/templates';
|
||||||
import { readDiagramSource } from '../src/sources/detect';
|
import { readDiagramSource } from '../src/sources/detect';
|
||||||
|
|
||||||
@@ -29,7 +30,9 @@ for (const item of TEMPLATES) {
|
|||||||
assert.ok(source);
|
assert.ok(source);
|
||||||
assert.equal(source.type, item.type);
|
assert.equal(source.type, item.type);
|
||||||
assert.equal(source.format, 'yaml');
|
assert.equal(source.format, 'yaml');
|
||||||
const result = await validate(runtime, source.type, source.json);
|
// As the extension does before Archify runs: drop the model block, expand refs.
|
||||||
|
const resolved = resolveRefs(source.diagram, { fileName: `${item.defaultName}.archify` });
|
||||||
|
const result = await validate(runtime, source.type, JSON.stringify(resolved.diagram));
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
result.diagnostics.filter((d) => d.severity === 'error').map((d) => `${d.code}: ${d.message}`),
|
result.diagnostics.filter((d) => d.severity === 'error').map((d) => `${d.code}: ${d.message}`),
|
||||||
[],
|
[],
|
||||||
|
|||||||
Reference in New Issue
Block a user