Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9684464630 | ||
|
|
094ca2925f | ||
|
|
de1a57e4c6 | ||
|
|
4382730343 | ||
|
|
b9676ece83 | ||
|
|
36bccb69a5 | ||
|
|
38cdc7604f | ||
|
|
57914d435c | ||
|
|
7a2b661afe | ||
|
|
701ffc7607 | ||
|
|
17ca07d9ba | ||
|
|
66a60bada6 |
No files matched your search
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.1 — 2026-10-19
|
||||
|
||||
- **No generated cards.** The cards 0.7.0 generated under model diagrams from registry descriptions are gone; they repeated the Elements panel. The description now shows as the tooltip of the element's row in the Elements panel. Hand-written `cards:` are unchanged.
|
||||
- **Model page, Registry panel:** lists the registry files of the model, one collapsible header per file with its element count and an **Open** button that opens the registry view; expand a file to see its elements as cards. Before, all elements of all files were shown in one grid.
|
||||
|
||||
## 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.
|
||||
- **Registry view:** `.archify-registry` files open as their elements (cards). Clicking an element opens a dialog with its description, the registry line that defines it and the diagrams that use it; the same dialog opens from the registry cards on the model page. *Show Source* opens the YAML beside the view.
|
||||
- **Elements and Connectors:** the node index under a diagram is titled *Elements*; a new *Connectors* panel lists every connection (from → to, label), highlights it on hover and keeps it highlighted on click.
|
||||
- **Model page panels:** Diagrams and Registry are collapsible panels; inside Diagrams every folder has its own collapsible header above its diagram cards. Open or closed is remembered.
|
||||
- **Cards:** a model diagram without `cards` of its own gets Archify cards from the registry descriptions of the elements it draws; the model page shows the registry as the same cards, each with the diagrams it is used in.
|
||||
- **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 Model…:** right-click a folder and choose *Archify | New Model…* (also in *File → New File…* and the Command Palette) to start from one of three complete C4 examples: **C4 - Standard Web App**, **C4 - E-Shop**, **C4 - Event-Based Integration**. Each creates a model folder with `main.archify`, a registry and context, container and component diagrams, named after the folder you choose, and opens the model page.
|
||||
- New commands *Archify | Open Model* (editor title, Command Palette and the Explorer menu of `.archify` and `.archify-registry` files); *New Diagram… → Model (main.archify)* template for an empty model; editor schema for registries and the new keys; example model in `examples/online-shop`.
|
||||
|
||||
## 0.6.2 — 2026-10-19
|
||||
|
||||
- **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.
|
||||
|
||||
## 0.6.1 — 2026-10-12
|
||||
|
||||
- The 3D menu is replaced by a **2D | 3D** switch next to **Export**: the filled half is the view you are in, one click flips it.
|
||||
|
||||
@@ -6,6 +6,8 @@ Create and view [Archify](https://github.com/tt-a1i/archify) diagrams inside VS
|
||||
|
||||
This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i) and runs it on VS Code's own Node runtime, so nothing else needs to be installed.
|
||||
|
||||
Free and open source. If it saves you time, you can [buy me a coffee](https://buymeacoffee.com/root.at.skic) ☕
|
||||
|
||||
## Supported files
|
||||
|
||||
| File | Format | What the extension does |
|
||||
@@ -20,6 +22,8 @@ New diagrams should be `.archify` files; everything below describes them unless
|
||||
|
||||
The fastest start: right-click a folder in the Explorer and choose **Archify | New Diagram…** (also in *File → New File…* and the Command Palette). Pick a kind of diagram (Architecture, Workflow, Sequence, Data Flow, Lifecycle or Everyday Plan), give it a name, and a small working `.archify` file is created and opened. Change its YAML to fit your case.
|
||||
|
||||
For a whole set of diagrams, choose **Archify | New Model…** instead and pick one of the examples (C4 - Standard Web App, C4 - E-Shop, C4 - Event-Based Integration). A model folder with context, container and component diagrams and a shared registry is created and its model page opens. See [Models](#models).
|
||||
|
||||
To write one by hand:
|
||||
|
||||
1. Create a file ending in `.archify`, for example `hello.archify`:
|
||||
@@ -60,6 +64,7 @@ The Archify CLI and the AI agents that use it write diagrams as JSON, named `nam
|
||||
## Features
|
||||
|
||||
- **New diagram from a template**: *Archify | New Diagram…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder.
|
||||
- **New model from an example**: *Archify | New Model…* creates a complete model folder from a C4 example (Standard Web App, E-Shop, Event-Based Integration).
|
||||
- **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live.
|
||||
- **Live updates**: the diagram re-renders as you edit, and keeps the focus and route you selected (`#focus=…`, `#route=…`).
|
||||
- **HTML viewer**: right-click a rendered `.html` file and choose **Archify | Open in HTML Viewer**, or use *Reopen Editor With… → Archify HTML Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk.
|
||||
@@ -68,7 +73,7 @@ The Archify CLI and the AI agents that use it write diagrams as JSON, named `nam
|
||||
- **Schema help**: completion and hover from Archify's own schemas, through the YAML extension for `.archify` files.
|
||||
- **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead.
|
||||
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
|
||||
- **Commands**: *Archify | New Diagram…*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*.
|
||||
- **Commands**: *Archify | New Diagram…*, *Archify | New Model…*, *Archify | Open Model*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*.
|
||||
|
||||
## 3D view
|
||||
|
||||
@@ -95,6 +100,33 @@ 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.
|
||||
|
||||
## 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, then two collapsible panels: **Diagrams**, with a collapsible header per folder and a card per diagram (click to open), and **Registry**, one collapsible header per registry file (with an **Open** button for its view) and the file's elements as cards inside, each with the diagrams it is used in. Open or closed is remembered per page.
|
||||
- **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.
|
||||
- **Descriptions.** Under a model diagram, hovering a row of the **Elements** panel shows the registry description of that element.
|
||||
- **Registry view.** A `.archify-registry` file opens as its elements, as cards. Click one and a dialog shows its description, where it is defined and the diagrams that use it; click a diagram to open it. **Show Source** opens the YAML beside it.
|
||||
- **Elements and Connectors.** Under every diagram, Archify's node index is titled **Elements**, and a **Connectors** panel next to it lists every connection as "from → to" with its label. Hovering highlights the connector in the diagram; clicking keeps it highlighted and scrolls to it.
|
||||
- **Starting one.** *Archify | New Model…* (right-click a folder) copies one of the shipped examples into a new folder, named as you choose: **C4 - Standard Web App** (single-page app, API, database, cache, storage, identity provider), **C4 - E-Shop** (web shop, API, worker, queue, payment and email providers) or **C4 - Event-Based Integration** (event bus, router, dead-letter queue, event store, consumers). Each has a registry and context, container and component diagrams. For an empty model, *Archify | New Diagram… → Model* creates just a `main.archify`.
|
||||
- **Commands.** *Archify | Open Model* opens the model page of the current file, from the editor title, the Command Palette or the Explorer menu of a `.archify` or `.archify-registry` file.
|
||||
|
||||
Formats in detail: [docs/model.md](docs/model.md). A full example: [examples/online-shop](examples/online-shop).
|
||||
|
||||
## 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.
|
||||
@@ -156,6 +188,14 @@ npm test
|
||||
|
||||
`vendor/archify.json` records the upstream version and commit.
|
||||
|
||||
## Support
|
||||
|
||||
Archify Diagram Viewer is built and maintained in my spare time. If it helps you, a coffee keeps the releases coming:
|
||||
|
||||
[](https://buymeacoffee.com/root.at.skic)
|
||||
|
||||
Bug reports and ideas are welcome in the [repository issues](https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext/issues).
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE). The bundled renderer is MIT-licensed by tt-a1i; see `vendor/archify/LICENSE` and `vendor/archify/THIRD_PARTY_NOTICES.md`.
|
||||
@@ -0,0 +1,90 @@
|
||||
# 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.
|
||||
|
||||
## Descriptions under a model diagram
|
||||
|
||||
Archify's **Elements** panel under a diagram lists its nodes. For a `ref` component the row's tooltip is the element's registry `description`. Archify's own `cards:` stay what they are: write them in the diagram when you want text blocks below it; nothing is generated.
|
||||
|
||||
## 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.
|
||||
|
||||
## Starting a model
|
||||
|
||||
*Archify | New Model…* (right-click a folder, *File → New File…*, Command Palette) offers three complete examples: **C4 - Standard Web App**, **C4 - E-Shop** and **C4 - Event-Based Integration**. Pick one and a folder name; the example is copied into that folder with `model.id`, `model.name` and the overview title taken from the folder name (an example kept under its own name is copied as is). Each example has `registry/{people,systems,containers}.archify-registry` and diagrams under `0100-context`, `0200-containers` and `0300-components`; every registry element is used by at least one diagram and every diagram validates.
|
||||
@@ -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",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "archify-vscode-ext",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"three": "^0.186.1",
|
||||
|
||||
+70
-15
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"name": "archify-vscode-ext",
|
||||
"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.",
|
||||
"version": "0.6.1",
|
||||
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, \u2026) and opens rendered Archify HTML.",
|
||||
"version": "0.7.1",
|
||||
"publisher": "root-at-skic",
|
||||
"license": "MIT",
|
||||
"sponsor": {
|
||||
"url": "https://buymeacoffee.com/root.at.skic"
|
||||
},
|
||||
"icon": "media/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#0f172a",
|
||||
@@ -23,11 +26,14 @@
|
||||
],
|
||||
"keywords": [
|
||||
"archify",
|
||||
"yaml",
|
||||
"diagram",
|
||||
"architecture",
|
||||
"c4",
|
||||
"diagram",
|
||||
"model",
|
||||
"registry",
|
||||
"sequence",
|
||||
"workflow"
|
||||
"workflow",
|
||||
"yaml"
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"activationEvents": [
|
||||
@@ -35,15 +41,21 @@
|
||||
"onLanguage:jsonc",
|
||||
"onLanguage:html",
|
||||
"workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}",
|
||||
"onWebviewPanel:archify.preview"
|
||||
"onWebviewPanel:archify.preview",
|
||||
"workspaceContains:**/*.archify-registry"
|
||||
],
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"title": "Archify | New Diagram…",
|
||||
"title": "Archify | New Diagram\u2026",
|
||||
"icon": "$(new-file)"
|
||||
},
|
||||
{
|
||||
"command": "archify.newModel",
|
||||
"title": "Archify | New Model\u2026",
|
||||
"icon": "$(new-folder)"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"title": "Archify | Open Preview",
|
||||
@@ -61,7 +73,7 @@
|
||||
},
|
||||
{
|
||||
"command": "archify.renderToHtml",
|
||||
"title": "Archify | Render to HTML File…",
|
||||
"title": "Archify | Render to HTML File\u2026",
|
||||
"icon": "$(file-code)"
|
||||
},
|
||||
{
|
||||
@@ -78,6 +90,11 @@
|
||||
"command": "archify.showSource",
|
||||
"title": "Archify | Show Source",
|
||||
"icon": "$(go-to-file)"
|
||||
},
|
||||
{
|
||||
"command": "archify.openModel",
|
||||
"title": "Archify | Open Model",
|
||||
"icon": "$(symbol-structure)"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -108,7 +125,11 @@
|
||||
},
|
||||
{
|
||||
"command": "archify.showSource",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'"
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry'"
|
||||
},
|
||||
{
|
||||
"command": "archify.openModel",
|
||||
"when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry'"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
@@ -129,7 +150,7 @@
|
||||
},
|
||||
{
|
||||
"command": "archify.showSource",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry'",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
@@ -141,6 +162,11 @@
|
||||
"command": "archify.renderToHtml",
|
||||
"when": "archify.isDiagramSource || archifyPreviewFocus",
|
||||
"group": "1_archify"
|
||||
},
|
||||
{
|
||||
"command": "archify.openModel",
|
||||
"when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry') && !notebookEditorFocused",
|
||||
"group": "navigation@60"
|
||||
}
|
||||
],
|
||||
"explorer/context": [
|
||||
@@ -150,19 +176,29 @@
|
||||
"group": "zzz_archify@1"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"command": "archify.newModel",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@2"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.archify-registry|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"group": "zzz_archify@3"
|
||||
},
|
||||
{
|
||||
"command": "archify.renderToHtml",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"group": "zzz_archify@3"
|
||||
"group": "zzz_archify@4"
|
||||
},
|
||||
{
|
||||
"command": "archify.openHtmlViewer",
|
||||
"when": "resourceExtname == .html",
|
||||
"group": "zzz_archify@2"
|
||||
"group": "zzz_archify@3"
|
||||
},
|
||||
{
|
||||
"command": "archify.openModel",
|
||||
"when": "resourceFilename =~ /\\.archify(-registry)?$/",
|
||||
"group": "zzz_archify@5"
|
||||
}
|
||||
],
|
||||
"editor/title/context": [
|
||||
@@ -176,6 +212,10 @@
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"group": "file"
|
||||
},
|
||||
{
|
||||
"command": "archify.newModel",
|
||||
"group": "file"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -204,6 +244,16 @@
|
||||
],
|
||||
"priority": "default"
|
||||
},
|
||||
{
|
||||
"viewType": "archify.registry",
|
||||
"displayName": "Archify Registry",
|
||||
"selector": [
|
||||
{
|
||||
"filenamePattern": "*.archify-registry"
|
||||
}
|
||||
],
|
||||
"priority": "default"
|
||||
},
|
||||
{
|
||||
"viewType": "archify.htmlViewer",
|
||||
"displayName": "Archify HTML Viewer",
|
||||
@@ -330,7 +380,8 @@
|
||||
{
|
||||
"id": "yaml",
|
||||
"extensions": [
|
||||
".archify"
|
||||
".archify",
|
||||
".archify-registry"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -338,6 +389,10 @@
|
||||
{
|
||||
"fileMatch": "*.archify",
|
||||
"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": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
@@ -215,8 +210,31 @@
|
||||
"items": false,
|
||||
"minItems": 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": {
|
||||
@@ -320,6 +338,31 @@
|
||||
},
|
||||
"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": {
|
||||
"$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": {
|
||||
"$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": {
|
||||
"$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": {
|
||||
"$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": {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
console.log(`Synced archify ${version} (${revision.slice(0, 12)}) from ${source}`);
|
||||
await import('./patch-schemas.mjs');
|
||||
@@ -1,12 +1,16 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { HTML_VIEW_TYPE, HtmlViewerProvider } from '../views/htmlViewer';
|
||||
import { ModelService } from '../model/service';
|
||||
import { DiagramViewManager } from '../views/diagramView';
|
||||
import { RegistryViewProvider } from '../views/registryView';
|
||||
|
||||
/** What command handlers share: the extension context and the open views. */
|
||||
export interface CommandContext {
|
||||
extension: vscode.ExtensionContext;
|
||||
views: DiagramViewManager;
|
||||
htmlViewer: HtmlViewerProvider;
|
||||
registry: RegistryViewProvider;
|
||||
models: ModelService;
|
||||
}
|
||||
|
||||
export function isHtmlUri(uri: vscode.Uri): boolean {
|
||||
@@ -16,7 +20,7 @@ export function isHtmlUri(uri: vscode.Uri): boolean {
|
||||
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
|
||||
export function targetUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
|
||||
if (arg instanceof vscode.Uri) return arg;
|
||||
return ctx.views.activePreview?.uri ?? ctx.htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
|
||||
return ctx.views.activePreview?.uri ?? ctx.registry.activeUri ?? ctx.htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
|
||||
}
|
||||
|
||||
export function sourceUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { clearGitRootCache } from '../archify/runtime';
|
||||
import { ModelService } from '../model/service';
|
||||
import { isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from '../sources/detect';
|
||||
|
||||
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
||||
|
||||
/** Keeps the archify.* context keys (editor title buttons, keybindings) up to date. */
|
||||
export function registerContextKeys(context: vscode.ExtensionContext): void {
|
||||
export function registerContextKeys(context: vscode.ExtensionContext, models: ModelService): void {
|
||||
let contextTimer: NodeJS.Timeout | undefined;
|
||||
const updateContext = () => {
|
||||
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(
|
||||
doc &&
|
||||
(isArchifyFile(doc.fileName) ||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { MODEL_EXAMPLES, mainFromExample } from '../language/examples';
|
||||
import { diagramName, invalidNameReason } from '../language/templates';
|
||||
import { MAIN_FILE } from '../model/format';
|
||||
import { readText } from '../util/files';
|
||||
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
|
||||
import { CommandContext, exists, targetUri } from './context';
|
||||
|
||||
/** Folder the new model folder goes into: the clicked folder (or a file's folder), else a workspace folder. */
|
||||
async function parentFolder(arg?: unknown): Promise<vscode.Uri | undefined> {
|
||||
if (arg instanceof vscode.Uri) {
|
||||
const stat = await vscode.workspace.fs.stat(arg);
|
||||
return stat.type & vscode.FileType.Directory ? arg : vscode.Uri.joinPath(arg, '..');
|
||||
}
|
||||
const folders = vscode.workspace.workspaceFolders ?? [];
|
||||
if (folders.length <= 1) return folders[0]?.uri;
|
||||
return (await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Folder for the new model' }))?.uri;
|
||||
}
|
||||
|
||||
export function modelCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
return [
|
||||
// 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);
|
||||
}),
|
||||
|
||||
// Creates a new model folder from one of the shipped examples and opens its model page.
|
||||
vscode.commands.registerCommand('archify.newModel', async (arg?: unknown) => {
|
||||
const picked = await vscode.window.showQuickPick(
|
||||
MODEL_EXAMPLES.map((example) => ({ label: example.label, description: example.description, example })),
|
||||
{ title: 'Archify | New Model', placeHolder: 'Example to start from' },
|
||||
);
|
||||
if (!picked) return;
|
||||
const { example } = picked;
|
||||
|
||||
let parent = await parentFolder(arg);
|
||||
if (!parent) {
|
||||
const chosen = await vscode.window.showOpenDialog({ title: 'Archify | New Model', canSelectFiles: false, canSelectFolders: true, openLabel: 'Create model here' });
|
||||
parent = chosen?.[0];
|
||||
if (!parent) return;
|
||||
}
|
||||
const name = await vscode.window.showInputBox({
|
||||
title: 'Archify | New Model',
|
||||
prompt: `Folder name in ${vscode.workspace.asRelativePath(parent, true)}`,
|
||||
value: example.folder,
|
||||
validateInput: async (value) =>
|
||||
invalidNameReason(value) ?? ((await exists(vscode.Uri.joinPath(parent, diagramName(value)))) ? `${diagramName(value)} already exists.` : undefined),
|
||||
});
|
||||
if (name === undefined) return;
|
||||
const folderName = diagramName(name);
|
||||
const target = vscode.Uri.joinPath(parent, folderName);
|
||||
if (await exists(target)) {
|
||||
vscode.window.showWarningMessage(`Archify: ${vscode.workspace.asRelativePath(target, true)} already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const source = vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', 'models', example.folder);
|
||||
await vscode.workspace.fs.copy(source, target, { overwrite: false });
|
||||
const main = vscode.Uri.joinPath(target, MAIN_FILE);
|
||||
await vscode.workspace.fs.writeFile(main, Buffer.from(mainFromExample(await readText(main), folderName, example), 'utf8'));
|
||||
await vscode.commands.executeCommand('vscode.openWith', main, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'node:path';
|
||||
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 { readText } from '../util/files';
|
||||
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' },
|
||||
);
|
||||
if (!template) return;
|
||||
const { file, defaultName } = template.template;
|
||||
const { file, defaultName, fixedName } = template.template;
|
||||
|
||||
const folder = await newDiagramFolder(arg);
|
||||
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({
|
||||
title: 'Archify | New Diagram',
|
||||
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 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.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,8 @@ import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { isEvidenceError, renderDiagram, renderToFile } from '../archify/cli';
|
||||
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||
import { diagramBaseName, readDiagramSource } from '../sources/detect';
|
||||
import { prepareDiagram } from '../model/prepare';
|
||||
import { diagramBaseName } from '../sources/detect';
|
||||
import { log } from '../util/log';
|
||||
import { readText } from '../util/files';
|
||||
import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context';
|
||||
@@ -14,7 +15,7 @@ export function renderCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
const uri = sourceUri(ctx, arg);
|
||||
if (!uri) return;
|
||||
const text = await readText(uri);
|
||||
const source = readDiagramSource(text, uri.path);
|
||||
const source = (await prepareDiagram(ctx.models, uri, text))?.source;
|
||||
if (!source) {
|
||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
||||
return;
|
||||
@@ -66,7 +67,7 @@ export function renderCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
return;
|
||||
}
|
||||
const text = await readText(uri);
|
||||
const source = readDiagramSource(text, uri.path);
|
||||
const source = (await prepareDiagram(ctx.models, uri, text))?.source;
|
||||
if (!source) {
|
||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { isRegistryFile } from '../model/format';
|
||||
import { isArchifyFile } from '../sources/detect';
|
||||
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
|
||||
import { REGISTRY_VIEW_TYPE } from '../views/registryView';
|
||||
import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context';
|
||||
|
||||
export function viewCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
@@ -9,6 +11,7 @@ export function viewCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
const uri = sourceUri(ctx, arg);
|
||||
if (!uri) return;
|
||||
if (isHtmlUri(uri)) return openHtmlViewer(uri);
|
||||
if (isRegistryFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, REGISTRY_VIEW_TYPE);
|
||||
if (isArchifyFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
ctx.views.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
|
||||
}),
|
||||
@@ -45,6 +48,11 @@ export function viewCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
|
||||
return;
|
||||
}
|
||||
const registry = ctx.registry.activeUri;
|
||||
if (registry) {
|
||||
await vscode.commands.executeCommand('vscode.openWith', registry, 'default', vscode.ViewColumn.Beside);
|
||||
return;
|
||||
}
|
||||
const html = ctx.htmlViewer.activeUri;
|
||||
if (html) await vscode.commands.executeCommand('vscode.openWith', html, 'default');
|
||||
}),
|
||||
|
||||
+17
-3
@@ -4,29 +4,43 @@ import { registerContextKeys } from './commands/contextKeys';
|
||||
import { newDiagramCommands } from './commands/newDiagram';
|
||||
import { renderCommands } from './commands/renderCommands';
|
||||
import { viewCommands } from './commands/viewCommands';
|
||||
import { modelCommands } from './commands/modelCommands';
|
||||
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 { log } from './util/log';
|
||||
import { HtmlViewerProvider } from './views/htmlViewer';
|
||||
import { DiagramViewManager } from './views/diagramView';
|
||||
import { RegistryViewProvider } from './views/registryView';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
const models = new ModelService();
|
||||
const ctx: CommandContext = {
|
||||
extension: context,
|
||||
views: new DiagramViewManager(context),
|
||||
views: new DiagramViewManager(context, models),
|
||||
htmlViewer: new HtmlViewerProvider(context.extensionUri),
|
||||
registry: new RegistryViewProvider(models),
|
||||
models,
|
||||
};
|
||||
const refs = new RefLanguage(models);
|
||||
context.subscriptions.push(
|
||||
models,
|
||||
ctx.views,
|
||||
ctx.htmlViewer,
|
||||
new DiagnosticsController(context),
|
||||
ctx.registry,
|
||||
new DiagnosticsController(context, models),
|
||||
log(),
|
||||
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),
|
||||
...newDiagramCommands(ctx),
|
||||
...renderCommands(ctx),
|
||||
...modelCommands(ctx),
|
||||
);
|
||||
registerContextKeys(context);
|
||||
registerContextKeys(context, models);
|
||||
}
|
||||
|
||||
export function deactivate(): void {}
|
||||
@@ -2,8 +2,12 @@ import { parse, ParseError } from 'jsonc-parser';
|
||||
import * as vscode from 'vscode';
|
||||
import { ArchifyDiagnostic, isEvidenceError, validate } from '../archify/cli';
|
||||
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 path from 'node:path';
|
||||
import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
|
||||
|
||||
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 disposables: vscode.Disposable[];
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext) {
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly models: ModelService,
|
||||
) {
|
||||
this.disposables = [
|
||||
this.collection,
|
||||
models.onDidChangeModel(() => this.revalidateAll()),
|
||||
vscode.workspace.onDidOpenTextDocument((doc) => this.schedule(doc, 0)),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
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 {
|
||||
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();
|
||||
clearTimeout(this.timers.get(key));
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if (isRegistryFile(doc.fileName)) {
|
||||
await this.validateRegistry(doc, seq);
|
||||
return;
|
||||
}
|
||||
if (isArchifyFile(doc.fileName)) {
|
||||
// Nothing else reports YAML syntax errors in .archify files, and a file
|
||||
// that does not parse has no diagram to validate yet.
|
||||
@@ -99,11 +111,16 @@ export class DiagnosticsController implements vscode.Disposable {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const source = readDiagramSource(text, doc.fileName);
|
||||
if (!source) {
|
||||
const prepared = await prepareDiagram(this.models, doc.uri, text);
|
||||
if (seq !== this.sequence.get(key) || doc.isClosed) return;
|
||||
if (!prepared) {
|
||||
this.collection.delete(doc.uri);
|
||||
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') {
|
||||
// Leave JSON syntax errors to the JSON language service.
|
||||
const syntax: ParseError[] = [];
|
||||
@@ -131,9 +148,9 @@ export class DiagnosticsController implements vscode.Disposable {
|
||||
}
|
||||
|
||||
const downgradeEvidence = evidenceMode(doc.uri) === 'fallback';
|
||||
this.collection.set(
|
||||
doc.uri,
|
||||
result.diagnostics.map((item) => {
|
||||
this.collection.set(doc.uri, [
|
||||
...modelDiagnostics,
|
||||
...result.diagnostics.map((item) => {
|
||||
const pointer = diagnosticPointer(item);
|
||||
const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, pointer) : rangeForPointer(text, pointer);
|
||||
const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
|
||||
@@ -147,12 +164,42 @@ export class DiagnosticsController implements vscode.Disposable {
|
||||
if (item.code) diagnostic.code = item.code;
|
||||
return diagnostic;
|
||||
}),
|
||||
);
|
||||
]);
|
||||
} catch (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 {
|
||||
for (const timer of this.timers.values()) clearTimeout(timer);
|
||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { parseDocument } from 'yaml';
|
||||
import { modelIdFromName, titleFromName } from './templates';
|
||||
|
||||
/**
|
||||
* Example models shipped with the extension (templates/models/<folder>).
|
||||
* "Archify | New Model…" offers them; each is a complete model folder:
|
||||
* main.archify, registry/*.archify-registry and diagrams by level.
|
||||
*/
|
||||
export interface ModelExample {
|
||||
/** Folder under templates/models and the default name of the new model folder. */
|
||||
folder: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const MODEL_EXAMPLES: readonly ModelExample[] = [
|
||||
{ folder: 'c4-standard-web-app', label: 'C4 - Standard Web App', description: 'Single-page app, API, database, cache, file storage, identity provider' },
|
||||
{ folder: 'c4-e-shop', label: 'C4 - E-Shop', description: 'Web shop, API, worker, queue, payment and email providers' },
|
||||
{ folder: 'c4-event-based-integration', label: 'C4 - Event-Based Integration', description: 'Event bus, router, dead-letter queue, event store, consumers' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The example's main.archify for a new model folder. When the folder keeps
|
||||
* the example's name the file is used as written; otherwise id, name and the
|
||||
* overview title follow the folder name and the description is kept.
|
||||
*/
|
||||
export function mainFromExample(mainText: string, folderName: string, example: ModelExample): string {
|
||||
if (folderName === example.folder) return mainText;
|
||||
const doc = parseDocument(mainText);
|
||||
doc.setIn(['model', 'id'], modelIdFromName(folderName));
|
||||
doc.setIn(['model', 'name'], titleFromName(folderName));
|
||||
if (doc.hasIn(['meta', 'title'])) doc.setIn(['meta', 'title'], `${titleFromName(folderName)} — overview`);
|
||||
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
/** Suggested name for the new file, without .archify. */
|
||||
defaultName: string;
|
||||
/** Reserved file name; the user is not asked for one. */
|
||||
fixedName?: string;
|
||||
}
|
||||
|
||||
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: '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: '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. */
|
||||
@@ -40,6 +43,21 @@ export function titleFromName(name: string): string {
|
||||
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.
|
||||
* Works on the YAML document so the template's comments and layout stay.
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
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>;
|
||||
/** Node id → registry description, for the Elements panel. */
|
||||
descriptions: Map<string, 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>();
|
||||
const descriptions = new Map<string, string>();
|
||||
if (!isObj(doc)) return { diagram: {}, problems, refs, links, used, descriptions };
|
||||
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, descriptions };
|
||||
|
||||
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);
|
||||
if (element.description) descriptions.set(id, element.description.trim());
|
||||
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, descriptions };
|
||||
}
|
||||
|
||||
/** 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,110 @@
|
||||
import { DiagramSummary, RegistryElement } from './format';
|
||||
import { ModelIndex } from './service';
|
||||
|
||||
/** A diagram an element is used in, as the pages show it. */
|
||||
export interface UsedIn {
|
||||
path: string;
|
||||
title?: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
/** A registry element as the model page and the registry page show it. */
|
||||
export interface ElementCard {
|
||||
id: string;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
type?: string;
|
||||
brand?: string;
|
||||
description?: string;
|
||||
/** Diagram that explains the element, relative to the model root. */
|
||||
diagram?: string;
|
||||
/** Registry file (relative to the model root) and 0-based line of the element. */
|
||||
file: string;
|
||||
line: number;
|
||||
usedIn: UsedIn[];
|
||||
}
|
||||
|
||||
/** 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: ElementCard[];
|
||||
problems: string[];
|
||||
nested: string[];
|
||||
}
|
||||
|
||||
/** What the registry page (one .archify-registry file) shows. */
|
||||
export interface RegistryPanel {
|
||||
/** The registry file, relative to the model root (or its base name outside a model). */
|
||||
file: string;
|
||||
inModel: boolean;
|
||||
model?: { id?: string; name?: string };
|
||||
elements: ElementCard[];
|
||||
problems: string[];
|
||||
}
|
||||
|
||||
function usedIn(id: string, usage: Map<string, string[]>, diagrams: DiagramSummary[]): UsedIn[] {
|
||||
return (usage.get(id) ?? []).map((path) => {
|
||||
const summary = diagrams.find((d) => d.relativePath === path);
|
||||
return { path, title: summary?.title, kind: summary?.kind };
|
||||
});
|
||||
}
|
||||
|
||||
export function elementCard(e: RegistryElement, usage: Map<string, string[]>, diagrams: DiagramSummary[]): ElementCard {
|
||||
return {
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
sublabel: e.sublabel,
|
||||
type: e.type,
|
||||
brand: e.brand,
|
||||
description: e.description,
|
||||
diagram: e.diagram,
|
||||
file: e.file,
|
||||
line: e.line,
|
||||
usedIn: usedIn(e.id, usage, diagrams),
|
||||
};
|
||||
}
|
||||
|
||||
const byId = (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id);
|
||||
|
||||
export function panelFromIndex(index: ModelIndex): ModelPanel {
|
||||
const registry = [...index.registry.elements.values()].sort(byId).map((e) => elementCard(e, index.usage, index.diagrams));
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** The registry page of one file inside a model: its elements, in file order, with usage. */
|
||||
export function registryPanelFromIndex(index: ModelIndex, file: string): RegistryPanel {
|
||||
const elements = [...index.registry.elements.values()]
|
||||
.filter((e) => e.file === file)
|
||||
.sort((a, b) => a.line - b.line)
|
||||
.map((e) => elementCard(e, index.usage, index.diagrams));
|
||||
return {
|
||||
file,
|
||||
inModel: true,
|
||||
model: { id: index.meta.id, name: index.meta.name },
|
||||
elements,
|
||||
problems: index.registry.problems.filter((p) => p.file === file).map((p) => p.message),
|
||||
};
|
||||
}
|
||||
|
||||
/** The registry page of a file outside a model: elements only, nothing is used anywhere. */
|
||||
export function registryPanelStandalone(file: string, parsed: { elements: RegistryElement[]; problems: { message: string }[] }): RegistryPanel {
|
||||
return {
|
||||
file,
|
||||
inModel: false,
|
||||
elements: parsed.elements.map((e) => elementCard(e, new Map(), [])),
|
||||
problems: parsed.problems.map((p) => p.message),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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>;
|
||||
/** Node id → registry description. */
|
||||
descriptions: 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, descriptions: resolved.descriptions };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+87
-12
@@ -2,17 +2,21 @@ import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { renderDiagram } from '../archify/cli';
|
||||
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 { log } from '../util/log';
|
||||
import { viewerTheme } from '../util/settings';
|
||||
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';
|
||||
|
||||
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
||||
/** Default editor for .archify files: the rendered diagram. */
|
||||
export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram';
|
||||
import { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
|
||||
export { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE };
|
||||
|
||||
interface PreviewState {
|
||||
uri: string;
|
||||
@@ -28,9 +32,12 @@ class DiagramPreview implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly onDisposeEmitter = new vscode.EventEmitter<void>();
|
||||
readonly onDidDispose = this.onDisposeEmitter.event;
|
||||
/** Root of the model this file belongs to, once known. */
|
||||
root: vscode.Uri | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly models: ModelService,
|
||||
readonly panel: vscode.WebviewPanel,
|
||||
readonly uri: vscode.Uri,
|
||||
state?: PreviewState,
|
||||
@@ -51,6 +58,14 @@ class DiagramPreview implements vscode.Disposable {
|
||||
this.hash = message.hash;
|
||||
return;
|
||||
}
|
||||
if (message.type === 'openDiagram') {
|
||||
void this.openLinked(message.path, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'openRegistry') {
|
||||
void this.openLinked(message.file, REGISTRY_VIEW_TYPE);
|
||||
return;
|
||||
}
|
||||
void handleWebviewMessage(message, this.uri, this.panel);
|
||||
}),
|
||||
);
|
||||
@@ -84,6 +99,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, viewType: 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, viewType, 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 {
|
||||
const delay = vscode.workspace.getConfiguration('archify', this.uri).get<number>('preview.debounceMs', 400);
|
||||
clearTimeout(this.timer);
|
||||
@@ -100,14 +136,37 @@ class DiagramPreview implements vscode.Disposable {
|
||||
this.showMessage('Cannot read diagram', this.uri.fsPath, error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
}
|
||||
const source = readDiagramSource(text, this.uri.path);
|
||||
if (!source) {
|
||||
// main.archify: the model page wraps the diagram, or stands alone.
|
||||
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(
|
||||
'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'}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const source = prepared.source;
|
||||
|
||||
let result;
|
||||
try {
|
||||
@@ -139,9 +198,12 @@ class DiagramPreview implements vscode.Disposable {
|
||||
state: this.state(),
|
||||
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
|
||||
viewer3d: viewer3dOption(this.panel.webview, this.context.extensionUri),
|
||||
links: Object.fromEntries(prepared.links),
|
||||
descriptions: Object.fromEntries(prepared.descriptions),
|
||||
model,
|
||||
});
|
||||
// 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], [...prepared.descriptions]])}\u0000${result.html}`;
|
||||
if (!force && this.rendered && fingerprint === this.lastHtml) {
|
||||
void this.panel.webview.postMessage({ type: 'notice', notice: undefined });
|
||||
return;
|
||||
@@ -173,8 +235,20 @@ export class DiagramViewManager
|
||||
private active: DiagramPreview | undefined;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext) {
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly models: ModelService,
|
||||
) {
|
||||
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.registerCustomEditorProvider(DIAGRAM_EDITOR_VIEW_TYPE, this, {
|
||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||
@@ -210,7 +284,7 @@ export class DiagramViewManager
|
||||
/** Opening a .archify file lands here: the diagram is the editor. */
|
||||
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
|
||||
this.track(new DiagramPreview(this.context, panel, document.uri, undefined, true));
|
||||
this.track(new DiagramPreview(this.context, this.models, panel, document.uri, undefined, true));
|
||||
}
|
||||
|
||||
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
|
||||
@@ -225,7 +299,7 @@ export class DiagramViewManager
|
||||
{ viewColumn, preserveFocus },
|
||||
{ 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> {
|
||||
@@ -233,7 +307,7 @@ export class DiagramViewManager
|
||||
panel.dispose();
|
||||
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 {
|
||||
@@ -247,6 +321,7 @@ export class DiagramViewManager
|
||||
if (preview.panel.active) this.active = preview;
|
||||
else if (this.active === preview) this.active = undefined;
|
||||
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);
|
||||
onViewState();
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { isMainFile, isRegistryFile, parseRegistry } from '../model/format';
|
||||
import { RegistryPanel, registryPanelFromIndex, registryPanelStandalone } from '../model/panel';
|
||||
import { ModelService } from '../model/service';
|
||||
import { readText } from '../util/files';
|
||||
import { log } from '../util/log';
|
||||
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||
import { makeNonce, registryPage } from '../webview/page';
|
||||
import { DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
|
||||
export { REGISTRY_VIEW_TYPE };
|
||||
|
||||
class RegistryDocument implements vscode.CustomDocument {
|
||||
constructor(readonly uri: vscode.Uri) {}
|
||||
dispose(): void {}
|
||||
}
|
||||
|
||||
class RegistryView implements vscode.Disposable {
|
||||
private seq = 0;
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private last: string | undefined;
|
||||
root: vscode.Uri | undefined;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly models: ModelService,
|
||||
readonly panel: vscode.WebviewPanel,
|
||||
readonly uri: vscode.Uri,
|
||||
) {
|
||||
this.disposables.push(
|
||||
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
|
||||
if (message.type === 'openDiagram') return void this.openInModel(message.path, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
if (message.type === 'openRegistry') return void this.openRegistryLine(message.file, message.line);
|
||||
void handleWebviewMessage(message, this.uri, this.panel);
|
||||
}),
|
||||
);
|
||||
void this.update();
|
||||
}
|
||||
|
||||
private async openInModel(relativePath: string, viewType: 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, viewType, vscode.ViewColumn.Active);
|
||||
}
|
||||
|
||||
private async openRegistryLine(file: string, line?: number): Promise<void> {
|
||||
const target = this.root ? vscode.Uri.joinPath(this.root, ...file.split('/')) : this.uri;
|
||||
const column = this.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
||||
const at = Math.max(line ?? 0, 0);
|
||||
await vscode.window.showTextDocument(target, { viewColumn: column, selection: new vscode.Range(at, 0, at, 0) });
|
||||
}
|
||||
|
||||
scheduleUpdate(): void {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => void this.update(), 300);
|
||||
}
|
||||
|
||||
async update(): Promise<void> {
|
||||
clearTimeout(this.timer);
|
||||
const seq = ++this.seq;
|
||||
let registry: RegistryPanel;
|
||||
try {
|
||||
this.root = await this.models.rootOf(this.uri);
|
||||
if (this.root) {
|
||||
const rel = path.relative(this.root.fsPath, this.uri.fsPath).split(path.sep).join('/');
|
||||
registry = registryPanelFromIndex(await this.models.index(this.root), rel);
|
||||
} else {
|
||||
const name = path.basename(this.uri.path);
|
||||
registry = registryPanelStandalone(name, parseRegistry(await readText(this.uri), name));
|
||||
}
|
||||
} catch (error) {
|
||||
log().warn(`Registry view failed for ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return;
|
||||
}
|
||||
if (seq !== this.seq) return;
|
||||
const fingerprint = JSON.stringify(registry);
|
||||
if (fingerprint === this.last) return;
|
||||
this.last = fingerprint;
|
||||
this.panel.webview.html = registryPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce() }, registry);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.timer);
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export class RegistryViewProvider implements vscode.CustomReadonlyEditorProvider<RegistryDocument>, vscode.Disposable {
|
||||
private readonly views = new Set<RegistryView>();
|
||||
private active: RegistryView | undefined;
|
||||
private readonly disposables: vscode.Disposable[];
|
||||
|
||||
constructor(private readonly models: ModelService) {
|
||||
this.disposables = [
|
||||
vscode.window.registerCustomEditorProvider(REGISTRY_VIEW_TYPE, this, {
|
||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||
supportsMultipleEditorsPerDocument: true,
|
||||
}),
|
||||
// Any change in the model (a diagram starts or stops using an element, a registry edit) refreshes the page.
|
||||
models.onDidChangeModel(({ root }) => {
|
||||
for (const view of this.views) if (view.root?.toString() === root.toString()) view.scheduleUpdate();
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (!isRegistryFile(event.document.fileName) && !isMainFile(event.document.fileName)) return;
|
||||
// Outside a model the model service stays quiet; refresh the page of the edited file itself.
|
||||
for (const view of this.views) if (!view.root && view.uri.toString() === event.document.uri.toString()) view.scheduleUpdate();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** File shown in the focused registry view, if any. */
|
||||
get activeUri(): vscode.Uri | undefined {
|
||||
return this.active?.uri;
|
||||
}
|
||||
|
||||
openCustomDocument(uri: vscode.Uri): RegistryDocument {
|
||||
return new RegistryDocument(uri);
|
||||
}
|
||||
|
||||
async resolveCustomEditor(document: RegistryDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||
panel.webview.options = { enableScripts: true };
|
||||
const view = new RegistryView(this.models, panel, document.uri);
|
||||
this.views.add(view);
|
||||
const onViewState = () => {
|
||||
if (panel.active) this.active = view;
|
||||
else if (this.active === view) this.active = undefined;
|
||||
if (panel.active) void vscode.commands.executeCommand('setContext', 'archify.inModel', Boolean(view.root));
|
||||
};
|
||||
panel.onDidChangeViewState(onViewState);
|
||||
onViewState();
|
||||
panel.onDidDispose(() => {
|
||||
this.views.delete(view);
|
||||
if (this.active === view) this.active = undefined;
|
||||
view.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const view of this.views) view.dispose();
|
||||
this.views.clear();
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Custom editor view types, in one place so views can refer to each other without importing each other. */
|
||||
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
||||
/** Default editor for .archify files: the rendered diagram. */
|
||||
export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram';
|
||||
/** Default editor for .archify-registry files: the elements as cards. */
|
||||
export const REGISTRY_VIEW_TYPE = 'archify.registry';
|
||||
@@ -9,6 +9,8 @@ export type WebviewMessage =
|
||||
| { type: 'openExternal'; href: string }
|
||||
| { type: 'openRelative'; href: string }
|
||||
| { type: 'openSource'; path: string; line?: number; endLine?: number; href?: string }
|
||||
| { type: 'openDiagram'; path: string }
|
||||
| { type: 'openRegistry'; file: string; line?: number }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
/** Column for opening files next to a webview without covering it. */
|
||||
@@ -92,6 +94,8 @@ export async function handleWebviewMessage(
|
||||
vscode.window.showErrorMessage(`Archify: ${message.message}`);
|
||||
break;
|
||||
case 'hash':
|
||||
case 'openDiagram':
|
||||
// Handled by the view, which knows the model root.
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
+456
-1
@@ -14,8 +14,18 @@ export interface PageOptions {
|
||||
notice?: { text: string; kind: 'info' | 'warning' | 'error' };
|
||||
/** The 3D viewer script (a webview URI), added to Archify pages. */
|
||||
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;
|
||||
/** Node id → registry description, shown as tooltips in the Elements panel. */
|
||||
descriptions?: Record<string, string>;
|
||||
/** Registry page: the elements of one .archify-registry file. */
|
||||
registry?: RegistryPanel;
|
||||
}
|
||||
|
||||
import type { ModelPanel, RegistryPanel } from '../model/panel';
|
||||
|
||||
export function makeNonce(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let nonce = '';
|
||||
@@ -66,6 +76,10 @@ function bridgeScript(options: PageOptions): string {
|
||||
state: options.state,
|
||||
notice: options.notice,
|
||||
defaultView: options.viewer3d?.defaultView,
|
||||
links: options.links ?? {},
|
||||
model: options.model,
|
||||
registry: options.registry,
|
||||
descriptions: options.descriptions || {},
|
||||
};
|
||||
return `(function () {
|
||||
var config = ${scriptJson(config)};
|
||||
@@ -162,10 +176,329 @@ function bridgeScript(options: PageOptions): string {
|
||||
(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);
|
||||
|
||||
// Collapsible panels of the model page; open/closed is kept in the webview state.
|
||||
function panelStates() { return (vscode.getState() || {}).modelPanels || {}; }
|
||||
function panel(key, className, title, count, defaultOpen) {
|
||||
// count: a number or a short text such as "3 files".
|
||||
var details = el('details', 'archify-model-panel ' + className);
|
||||
var saved = panelStates()[key];
|
||||
details.open = saved === undefined ? defaultOpen : saved;
|
||||
var summary = el('summary', 'archify-model-summary');
|
||||
summary.appendChild(el('span', 'archify-model-chevron'));
|
||||
summary.appendChild(el('span', 'archify-model-summary-title', title));
|
||||
if (count !== undefined) summary.appendChild(el('span', 'archify-model-count', String(count)));
|
||||
details.appendChild(summary);
|
||||
details.addEventListener('toggle', function () {
|
||||
var states = panelStates();
|
||||
states[key] = details.open;
|
||||
vscode.setState(Object.assign({}, vscode.getState() || {}, { modelPanels: states }));
|
||||
});
|
||||
return details;
|
||||
}
|
||||
|
||||
// One registry element as an Archify card. Clicking the card opens the
|
||||
// usage modal; the chips open the diagram directly.
|
||||
function elementCard(element) {
|
||||
var card = el('div', 'card archify-model-card');
|
||||
card.setAttribute('role', 'button');
|
||||
card.tabIndex = 0;
|
||||
card.title = 'Show where ' + (element.label || element.id) + ' is used';
|
||||
var header = el('div', 'card-header');
|
||||
header.appendChild(el('div', 'card-dot ' + dotForType(element.type)));
|
||||
header.appendChild(el('h3', '', element.label || element.id));
|
||||
card.appendChild(header);
|
||||
var list = el('ul');
|
||||
list.appendChild(el('li', 'archify-model-card-id', element.id + (element.type ? ' · ' + element.type : '') + (element.sublabel ? ' · ' + element.sublabel : '')));
|
||||
if (element.description) list.appendChild(el('li', '', element.description.trim()));
|
||||
var used = el('li', 'archify-model-card-used');
|
||||
if (element.usedIn.length) {
|
||||
element.usedIn.forEach(function (use) {
|
||||
var link = el('button', 'archify-model-used-link', use.path.replace(/\.archify$/, ''));
|
||||
link.type = 'button';
|
||||
link.title = 'Open ' + use.path;
|
||||
link.addEventListener('click', function (event) { event.stopPropagation(); post({ type: 'openDiagram', path: use.path }); });
|
||||
used.appendChild(link);
|
||||
});
|
||||
} else used.appendChild(el('span', 'archify-model-item-meta', 'not used yet'));
|
||||
list.appendChild(used);
|
||||
card.appendChild(list);
|
||||
card.addEventListener('click', function () { openElementModal(element); });
|
||||
card.addEventListener('keydown', function (event) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openElementModal(element); } });
|
||||
return card;
|
||||
}
|
||||
|
||||
// Modal: one element, where it is defined and the diagrams that use it.
|
||||
function openElementModal(element) {
|
||||
closeElementModal();
|
||||
var backdrop = el('div', 'archify-modal-backdrop');
|
||||
backdrop.id = 'archify-element-modal';
|
||||
var modal = el('div', 'archify-modal archify-model');
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
var head = el('div', 'archify-modal-head');
|
||||
head.appendChild(el('span', 'card-dot ' + dotForType(element.type)));
|
||||
var titles = el('div', 'archify-modal-titles');
|
||||
titles.appendChild(el('h2', 'archify-modal-title', element.label || element.id));
|
||||
titles.appendChild(el('div', 'archify-model-card-id', element.id + (element.type ? ' · ' + element.type : '') + (element.sublabel ? ' · ' + element.sublabel : '')));
|
||||
head.appendChild(titles);
|
||||
var close = el('button', 'archify-modal-close', '\u00d7');
|
||||
close.type = 'button';
|
||||
close.title = 'Close (Esc)';
|
||||
close.addEventListener('click', closeElementModal);
|
||||
head.appendChild(close);
|
||||
modal.appendChild(head);
|
||||
if (element.description) modal.appendChild(el('p', 'archify-modal-description', element.description.trim()));
|
||||
var defined = el('p', 'archify-modal-defined');
|
||||
defined.appendChild(document.createTextNode('Defined in '));
|
||||
var source = el('button', 'archify-model-used-link', element.file + ':' + (element.line + 1));
|
||||
source.type = 'button';
|
||||
source.title = 'Open the registry at this line';
|
||||
source.addEventListener('click', function () { post({ type: 'openRegistry', file: element.file, line: element.line }); });
|
||||
defined.appendChild(source);
|
||||
if (element.diagram) {
|
||||
defined.appendChild(document.createTextNode(' · explained by '));
|
||||
var explained = el('button', 'archify-model-used-link', element.diagram.replace(/\.archify$/, ''));
|
||||
explained.type = 'button';
|
||||
explained.addEventListener('click', function () { post({ type: 'openDiagram', path: element.diagram }); });
|
||||
defined.appendChild(explained);
|
||||
}
|
||||
modal.appendChild(defined);
|
||||
modal.appendChild(el('h3', 'archify-model-title', element.usedIn.length ? 'Used in ' + element.usedIn.length + (element.usedIn.length === 1 ? ' diagram' : ' diagrams') : 'Not used in any diagram yet'));
|
||||
if (element.usedIn.length) {
|
||||
var list = el('ul', 'archify-model-list');
|
||||
element.usedIn.forEach(function (use) {
|
||||
var item = el('li', 'archify-model-item');
|
||||
var button = el('button', 'archify-model-open');
|
||||
button.type = 'button';
|
||||
button.title = use.path;
|
||||
button.appendChild(el('span', 'archify-model-item-title', use.title || use.path.split('/').pop()));
|
||||
button.appendChild(el('span', 'archify-model-item-meta', (use.kind || 'diagram') + ' · ' + use.path));
|
||||
button.addEventListener('click', function () { post({ type: 'openDiagram', path: use.path }); });
|
||||
item.appendChild(button);
|
||||
list.appendChild(item);
|
||||
});
|
||||
modal.appendChild(list);
|
||||
}
|
||||
backdrop.appendChild(modal);
|
||||
backdrop.addEventListener('click', function (event) { if (event.target === backdrop) closeElementModal(); });
|
||||
document.body.appendChild(backdrop);
|
||||
close.focus();
|
||||
}
|
||||
function closeElementModal() {
|
||||
var existing = document.getElementById('archify-element-modal');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && document.getElementById('archify-element-modal')) { event.stopPropagation(); closeElementModal(); }
|
||||
}, true);
|
||||
|
||||
// The registry page: one .archify-registry file, its elements as cards.
|
||||
function renderRegistry(registry) {
|
||||
if (!registry) return;
|
||||
var head = el('section', 'archify-model archify-model-head');
|
||||
head.appendChild(el('div', 'archify-model-kicker', 'Registry' + (registry.model && registry.model.id ? ' · ' + registry.model.id : '')));
|
||||
head.appendChild(el('h1', 'archify-model-name', registry.file));
|
||||
if (registry.model && registry.model.name) head.appendChild(el('p', 'archify-model-description', 'Shared elements of ' + registry.model.name + '. Click an element to see where it is used.'));
|
||||
if (!registry.inModel) head.appendChild(el('p', 'archify-model-problem', 'This registry is not inside a model (no main.archify in a folder above it), so nothing can refer to its elements yet.'));
|
||||
registry.problems.forEach(function (problem) { head.appendChild(el('p', 'archify-model-problem', problem)); });
|
||||
document.body.appendChild(head);
|
||||
var body = el('section', 'archify-model archify-model-body');
|
||||
var elementsPanel = panel('elements', 'archify-model-registry', 'Elements', registry.elements.length, true);
|
||||
body.appendChild(elementsPanel);
|
||||
if (!registry.elements.length) elementsPanel.appendChild(el('p', 'archify-model-empty', 'No elements yet. Add them under "elements:" with a type, a label and a description.'));
|
||||
var cards = el('div', 'cards archify-model-cards');
|
||||
registry.elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
|
||||
elementsPanel.appendChild(cards);
|
||||
document.body.appendChild(body);
|
||||
}
|
||||
|
||||
// Elements: registry descriptions as tooltips on Archify's node index rows.
|
||||
function describeElements() {
|
||||
var items = document.querySelectorAll('#node-outline .node-outline-item[data-outline-node]');
|
||||
Array.prototype.forEach.call(items, function (item) {
|
||||
var text = config.descriptions[item.getAttribute('data-outline-node')];
|
||||
if (text) item.title = text;
|
||||
});
|
||||
}
|
||||
|
||||
// Connectors: a rail panel next to Archify's Elements index, read from the
|
||||
// SVG's edges. Hover highlights the connector, click keeps it highlighted.
|
||||
function renderConnectors() {
|
||||
var svg = document.querySelector('.diagram-container > svg');
|
||||
var outline = document.getElementById('node-outline');
|
||||
if (!svg || !outline || document.getElementById('archify-connectors')) return;
|
||||
var labels = {};
|
||||
Array.prototype.forEach.call(svg.querySelectorAll('[data-node-id][data-node-label]'), function (node) {
|
||||
labels[node.getAttribute('data-node-id')] = node.getAttribute('data-node-label');
|
||||
});
|
||||
var edges = [];
|
||||
var byKey = {};
|
||||
Array.prototype.forEach.call(svg.querySelectorAll('[data-edge-key]'), function (fragment) {
|
||||
var key = fragment.getAttribute('data-edge-key');
|
||||
if (!byKey[key]) {
|
||||
byKey[key] = { key: key, from: fragment.getAttribute('data-edge-from') || '', to: fragment.getAttribute('data-edge-to') || '', label: fragment.getAttribute('data-edge-label') || '', fragments: [] };
|
||||
edges.push(byKey[key]);
|
||||
}
|
||||
byKey[key].fragments.push(fragment);
|
||||
});
|
||||
if (!edges.length) return;
|
||||
var nav = el('nav', 'node-outline no-print archify-connectors');
|
||||
nav.id = 'archify-connectors';
|
||||
nav.setAttribute('aria-labelledby', 'archify-connectors-title');
|
||||
var head = el('div', 'node-outline-head');
|
||||
var title = el('strong', '', 'Connectors');
|
||||
title.id = 'archify-connectors-title';
|
||||
head.appendChild(title);
|
||||
head.appendChild(el('span', 'node-outline-count', String(edges.length)));
|
||||
nav.appendChild(head);
|
||||
var list = el('ol', 'node-outline-list');
|
||||
var group = el('li', 'node-outline-group');
|
||||
var items = el('ul');
|
||||
var selected = null;
|
||||
function mark(edge, attr, on) {
|
||||
edge.fragments.forEach(function (fragment) { if (on) fragment.setAttribute(attr, ''); else fragment.removeAttribute(attr); });
|
||||
}
|
||||
function select(edge, button) {
|
||||
if (selected) { mark(selected.edge, 'data-archify-selected', false); selected.button.removeAttribute('aria-current'); }
|
||||
if (selected && selected.edge === edge) { selected = null; return; }
|
||||
selected = { edge: edge, button: button };
|
||||
mark(edge, 'data-archify-selected', true);
|
||||
button.setAttribute('aria-current', 'true');
|
||||
var first = edge.fragments[0];
|
||||
if (first && typeof first.scrollIntoView === 'function') first.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
|
||||
}
|
||||
edges.forEach(function (edge) {
|
||||
var item = el('li');
|
||||
var button = el('button', 'node-outline-item');
|
||||
button.type = 'button';
|
||||
button.style.setProperty('--outline-tone', 'var(--text-muted)');
|
||||
button.title = (labels[edge.from] || edge.from) + ' \u2192 ' + (labels[edge.to] || edge.to) + (edge.label ? ' \u00b7 ' + edge.label : '');
|
||||
var dot = el('span', 'node-outline-dot');
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
button.appendChild(dot);
|
||||
button.appendChild(el('strong', '', (labels[edge.from] || edge.from) + ' \u2192 ' + (labels[edge.to] || edge.to)));
|
||||
if (edge.label) button.appendChild(el('small', '', edge.label));
|
||||
button.addEventListener('pointerenter', function () { mark(edge, 'data-archify-hit', true); });
|
||||
button.addEventListener('pointerleave', function () { mark(edge, 'data-archify-hit', false); });
|
||||
button.addEventListener('focus', function () { mark(edge, 'data-archify-hit', true); });
|
||||
button.addEventListener('blur', function () { mark(edge, 'data-archify-hit', false); });
|
||||
button.addEventListener('click', function () { select(edge, button); });
|
||||
item.appendChild(button);
|
||||
items.appendChild(item);
|
||||
});
|
||||
group.appendChild(items);
|
||||
list.appendChild(group);
|
||||
nav.appendChild(list);
|
||||
outline.insertAdjacentElement('afterend', nav);
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && selected) select(selected.edge, selected.button);
|
||||
});
|
||||
var archify = window.Archify;
|
||||
if (archify && archify.readerLayout && typeof archify.readerLayout.schedule === 'function') archify.readerLayout.schedule();
|
||||
}
|
||||
|
||||
var DOT_BY_TYPE = { frontend: 'cyan', backend: 'emerald', database: 'violet', cloud: 'amber', security: 'rose', messagebus: 'orange', external: 'slate' };
|
||||
function dotForType(type) { return DOT_BY_TYPE[type || ''] || 'slate'; }
|
||||
|
||||
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);
|
||||
var diagramsPanel = panel('diagrams', 'archify-model-diagrams', 'Diagrams', count, true);
|
||||
body.appendChild(diagramsPanel);
|
||||
if (!count) diagramsPanel.appendChild(el('p', 'archify-model-empty', 'No diagrams yet. Add .archify files next to main.archify or in subfolders.'));
|
||||
model.groups.forEach(function (group) {
|
||||
// Each folder is its own collapsible header; diagrams next to main.archify are listed directly.
|
||||
var holder = diagramsPanel;
|
||||
if (group.group) {
|
||||
holder = panel('folder:' + group.group, 'archify-model-folder', group.group, group.diagrams.length, true);
|
||||
diagramsPanel.appendChild(holder);
|
||||
}
|
||||
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);
|
||||
});
|
||||
holder.appendChild(list);
|
||||
});
|
||||
// Registry: one collapsible header per registry file, its elements as cards inside.
|
||||
if (model.registry.length) {
|
||||
var files = [];
|
||||
var byFile = {};
|
||||
model.registry.forEach(function (element) {
|
||||
if (!byFile[element.file]) { byFile[element.file] = []; files.push(element.file); }
|
||||
byFile[element.file].push(element);
|
||||
});
|
||||
files.sort();
|
||||
var registryPanel = panel('registry', 'archify-model-registry', 'Registry', files.length + (files.length === 1 ? ' file' : ' files'), true);
|
||||
body.appendChild(registryPanel);
|
||||
files.forEach(function (file) {
|
||||
var elements = byFile[file].slice().sort(function (a, b) { return a.line - b.line; });
|
||||
var filePanel = panel('registry:' + file, 'archify-model-folder archify-model-registry-file', file.replace(/\.archify-registry$/, ''), elements.length + (elements.length === 1 ? ' element' : ' elements'), false);
|
||||
var open = el('button', 'archify-model-used-link archify-model-open-file', 'Open');
|
||||
open.type = 'button';
|
||||
open.title = 'Open ' + file;
|
||||
open.addEventListener('click', function (event) { event.preventDefault(); event.stopPropagation(); post({ type: 'openRegistry', file: file, line: 0 }); });
|
||||
filePanel.firstChild.appendChild(open);
|
||||
var cards = el('div', 'cards archify-model-cards');
|
||||
elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
|
||||
filePanel.appendChild(cards);
|
||||
registryPanel.appendChild(filePanel);
|
||||
});
|
||||
}
|
||||
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
var message = event.data || {};
|
||||
if (message.type === 'notice') showNotice(message.notice);
|
||||
});
|
||||
function onReady(fn) {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
|
||||
else fn();
|
||||
}
|
||||
if (config.model) onReady(function () { renderModel(config.model); });
|
||||
if (config.registry) onReady(function () { renderRegistry(config.registry); });
|
||||
onReady(renderConnectors);
|
||||
onReady(describeElements);
|
||||
if (config.notice) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
||||
@@ -176,6 +509,87 @@ function bridgeScript(options: PageOptions): string {
|
||||
})();`;
|
||||
}
|
||||
|
||||
const MODEL_STYLE = `
|
||||
.archify-model { --m-text: var(--text, var(--vscode-foreground, CanvasText)); --m-muted: var(--text-muted, var(--vscode-descriptionForeground, GrayText)); --m-panel: var(--panel, var(--vscode-editorWidget-background, Canvas)); --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; }
|
||||
/* Collapsible panels: Diagrams and Registry, and one per folder inside Diagrams. */
|
||||
.archify-model-panel { margin: 1rem 0 0; border: 1px solid var(--m-border); border-radius: 0.75rem; background: var(--m-panel); }
|
||||
.archify-model-panel[open] { padding-bottom: 0.85rem; }
|
||||
.archify-model-summary { display: flex; align-items: center; gap: 0.6rem; padding: 0.75rem 0.9rem; cursor: pointer; list-style: none; user-select: none; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.02em; color: var(--m-text); }
|
||||
.archify-model-summary::-webkit-details-marker { display: none; }
|
||||
.archify-model-summary:hover { color: var(--m-accent); }
|
||||
.archify-model-summary:focus-visible { outline: 2px solid var(--m-accent); outline-offset: -2px; border-radius: 0.75rem; }
|
||||
.archify-model-chevron { flex: none; width: 0.5rem; height: 0.5rem; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(-45deg); transition: transform 0.15s; opacity: 0.7; margin-left: 0.1rem; }
|
||||
.archify-model-panel[open] > .archify-model-summary > .archify-model-chevron { transform: rotate(45deg); }
|
||||
.archify-model-count { margin-left: auto; font-size: 0.75rem; font-weight: 500; color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; padding: 0.05rem 0.5rem; border: 1px solid var(--m-border); border-radius: 999px; }
|
||||
.archify-model-panel > .archify-model-list, .archify-model-panel > .archify-model-cards, .archify-model-panel > .archify-model-empty { margin: 0 0.9rem; }
|
||||
.archify-model-folder { margin: 0.35rem 0.9rem 0; background: transparent; border-radius: 0.6rem; }
|
||||
.archify-model-folder[open] { padding-bottom: 0.7rem; }
|
||||
.archify-model-folder > .archify-model-summary { padding: 0.55rem 0.75rem; 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-folder > .archify-model-summary:hover { color: var(--m-accent); }
|
||||
.archify-model-folder > .archify-model-list { margin: 0 0.75rem; }
|
||||
.archify-model-registry-file > .archify-model-summary { text-transform: none; letter-spacing: 0; font-size: 0.8125rem; }
|
||||
.archify-model-registry-file > .archify-model-cards { margin: 0.25rem 0.75rem 0; }
|
||||
.archify-model-open-file { margin-left: 0.5rem; text-transform: none; letter-spacing: 0; font-family: inherit; }
|
||||
.archify-model-open-file:hover { color: var(--m-accent); }
|
||||
.archify-model-diagrams > .archify-model-list { margin-top: 0.25rem; }
|
||||
.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); }
|
||||
/* Element modal: where one element is defined and used. */
|
||||
.archify-modal-backdrop { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: flex-start; justify-content: center; padding: 6vh 1rem; background: rgba(0, 0, 0, 0.45); overflow: auto; }
|
||||
.archify-modal { width: min(44rem, 100%); max-height: 88vh; overflow: auto; padding: 1.25rem 1.5rem 1.5rem; border-radius: 0.9rem; border: 1px solid var(--m-border); background: var(--m-panel); color: var(--m-text); box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35); }
|
||||
.archify-modal-head { display: flex; align-items: flex-start; gap: 0.75rem; }
|
||||
.archify-modal-head .card-dot { flex: none; width: 12px; height: 12px; border-radius: 50%; margin-top: 0.45rem; }
|
||||
.archify-modal-titles { flex: 1 1 auto; min-width: 0; }
|
||||
.archify-modal-title { margin: 0; font-size: 1.25rem; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.archify-modal-close { flex: none; font: inherit; font-size: 1.25rem; line-height: 1; width: 2rem; height: 2rem; border-radius: 0.5rem; border: 1px solid var(--m-border); background: transparent; color: var(--m-muted); cursor: pointer; }
|
||||
.archify-modal-close:hover { color: var(--m-text); border-color: var(--m-accent); }
|
||||
.archify-modal-description { margin: 0.9rem 0 0; font-size: 0.9375rem; line-height: 1.55; color: var(--m-text); white-space: pre-line; }
|
||||
.archify-modal-defined { margin: 0.75rem 0 0; font-size: 0.8125rem; color: var(--m-muted); display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
|
||||
.archify-modal .archify-model-title { margin: 1.25rem 0 0.6rem; }
|
||||
.archify-modal .archify-model-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||
/* Registry cards: Archify's own .card look, with fallbacks for the page without a diagram. */
|
||||
.archify-model { --m-frontend: var(--frontend-stroke, #22d3ee); --m-backend: var(--backend-stroke, #34d399); --m-database: var(--database-stroke, #a78bfa); --m-cloud: var(--cloud-stroke, #fbbf24); --m-security: var(--security-stroke, #fb7185); --m-messagebus: var(--messagebus-stroke, #fb923c); --m-external: var(--external-stroke, #94a3b8); }
|
||||
.archify-model-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr)); gap: 1rem; margin-top: 0.25rem; }
|
||||
.archify-model-card { background: var(--m-panel); border: 1px solid var(--m-border); border-radius: 0.75rem; padding: 1.125rem 1.25rem; }
|
||||
.archify-model-card .card-header { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.6rem; }
|
||||
.archify-model-card .card-dot { flex: none; width: 10px; height: 10px; border-radius: 50%; }
|
||||
.archify-model-card .card-dot.cyan { background: var(--m-frontend); } .archify-model-card .card-dot.emerald { background: var(--m-backend); } .archify-model-card .card-dot.violet { background: var(--m-database); } .archify-model-card .card-dot.amber { background: var(--m-cloud); } .archify-model-card .card-dot.rose { background: var(--m-security); } .archify-model-card .card-dot.orange { background: var(--m-messagebus); } .archify-model-card .card-dot.slate { background: var(--m-external); }
|
||||
.archify-model-card h3 { margin: 0; font-size: 0.9375rem; font-weight: 600; line-height: 1.4; color: var(--m-text); }
|
||||
.archify-model-card ul { list-style: none; margin: 0; padding: 0; color: var(--m-muted); font-size: 0.8125rem; line-height: 1.6; }
|
||||
.archify-model-card li { margin-bottom: 0.3rem; }
|
||||
.archify-model-card li.archify-model-card-id, .archify-model-card li.archify-model-card-used { padding-left: 0; }
|
||||
.archify-model-card li.archify-model-card-id::before, .archify-model-card li.archify-model-card-used::before { display: none; }
|
||||
.archify-model-card-id { font-family: ui-monospace, Menlo, monospace; font-size: 0.75rem; }
|
||||
.archify-model-card-used { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.5rem; }
|
||||
.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); }
|
||||
`;
|
||||
|
||||
/* Connectors rail panel: highlight of the hovered or selected edge in the SVG. */
|
||||
const CONNECTOR_STYLE = `
|
||||
.diagram-container svg [data-edge-key][data-archify-hit] path, .diagram-container svg [data-edge-key][data-archify-selected] path { stroke-width: 3px; filter: drop-shadow(0 0 3px currentColor); }
|
||||
.diagram-container svg [data-edge-key][data-archify-selected] text { font-weight: 700; }
|
||||
.archify-connectors .node-outline-item strong { font-weight: 600; }
|
||||
/* Bottom rail: Elements and Connectors side by side; Connectors alone when Archify hides its index. */
|
||||
html[data-reader-rail="bottom"] .reader-rail:has(> .archify-connectors) { grid-template-areas: "cards cards" "index connectors"; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 1rem; }
|
||||
html[data-reader-rail="bottom"] .reader-rail:has(> .archify-connectors):has(> #node-outline[hidden]) { grid-template-areas: "cards" "connectors"; grid-template-columns: minmax(0, 1fr); }
|
||||
html[data-reader-rail="bottom"] .reader-rail > #archify-connectors { display: block; grid-area: connectors; padding: 0 1.25rem 0.75rem; border: 1px solid var(--panel-border); border-radius: 0.75rem; background: var(--panel); }
|
||||
html[data-reader-rail="bottom"] .reader-rail > #archify-connectors .node-outline-head { padding: 1rem 0.25rem 0.375rem; }
|
||||
html[data-reader-rail="bottom"] .reader-rail > #archify-connectors .node-outline-list { columns: 15rem; column-gap: 1.75rem; padding: 0; overflow: visible; -webkit-mask-image: none; mask-image: none; }
|
||||
`;
|
||||
|
||||
const NOTICE_STYLE = `
|
||||
.archify-vscode-notice {
|
||||
position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 2147483647;
|
||||
@@ -197,9 +611,12 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string {
|
||||
const head =
|
||||
`<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">\n` +
|
||||
`<script nonce="${options.nonce}">${bridgeScript(options)}</script>\n` +
|
||||
`<style>${NOTICE_STYLE}</style>\n`;
|
||||
`<style>${NOTICE_STYLE}${CONNECTOR_STYLE}${options.model ? MODEL_STYLE : ''}</style>\n`;
|
||||
|
||||
let page = html
|
||||
// Archify calls its node rail "Node index"; next to the registry it reads better as Elements.
|
||||
.replace(/(id="node-outline-title">)Node index(<)/, '$1Elements$2')
|
||||
.replace(/("viewer\.outline\.title":")Node index(")/, '$1Elements$2')
|
||||
// Drop any CSP the file brings along; ours is the one that applies.
|
||||
.replace(/<meta\s+http-equiv=["']Content-Security-Policy["'][^>]*>/gi, '')
|
||||
// Allow the page's own inline scripts to run under the nonce policy.
|
||||
@@ -220,6 +637,44 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string {
|
||||
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>`;
|
||||
}
|
||||
|
||||
/** The registry page for one .archify-registry file. */
|
||||
export function registryPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, registry: RegistryPanel): string {
|
||||
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, registry, 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. */
|
||||
export function messagePage(
|
||||
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,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: e-shop
|
||||
name: E-Shop
|
||||
description: >
|
||||
The web shop, its API and workers, and the stores behind them.
|
||||
Context, containers and components, C4 style.
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: E-Shop — overview
|
||||
output: main.html
|
||||
components:
|
||||
- ref: customer
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: shop
|
||||
pos: [250, 160]
|
||||
size: [150, 60]
|
||||
- ref: psp
|
||||
pos: [480, 160]
|
||||
size: [150, 60]
|
||||
connections:
|
||||
- { from: customer, to: shop, label: buys, variant: emphasis }
|
||||
- { from: shop, to: psp, label: charges }
|
||||
@@ -0,0 +1,37 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
web:
|
||||
type: frontend
|
||||
label: Web App
|
||||
sublabel: React
|
||||
brand: react
|
||||
description: The shop as customers see it; talks only to the API.
|
||||
api:
|
||||
type: backend
|
||||
label: API
|
||||
sublabel: Node.js
|
||||
brand: node-js
|
||||
description: Owns authentication, orders and the catalogue.
|
||||
diagram: 0300-components/api.archify
|
||||
worker:
|
||||
type: backend
|
||||
label: Worker
|
||||
sublabel: async jobs
|
||||
description: Picks jobs from the queue; emails, invoices, exports.
|
||||
queue:
|
||||
type: messagebus
|
||||
label: Queue
|
||||
sublabel: SQS
|
||||
description: Job queue between the API and the workers.
|
||||
db:
|
||||
type: database
|
||||
label: Database
|
||||
sublabel: PostgreSQL
|
||||
brand: postgresql
|
||||
description: Orders, customers and catalogue; the source of truth.
|
||||
cache:
|
||||
type: database
|
||||
label: Cache
|
||||
sublabel: Redis
|
||||
brand: redis
|
||||
description: Sessions and catalogue cache; nothing here is the source of truth.
|
||||
@@ -0,0 +1,12 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
customer:
|
||||
type: external
|
||||
label: Customer
|
||||
sublabel: web and mobile
|
||||
description: Buys products; signs in with email or a social account.
|
||||
support:
|
||||
type: external
|
||||
label: Support agent
|
||||
sublabel: back office
|
||||
description: Handles refunds and order problems in the admin console.
|
||||
@@ -0,0 +1,19 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
shop:
|
||||
type: cloud
|
||||
label: Online Shop
|
||||
sublabel: the system
|
||||
description: Everything we build and run; web app, API, workers and stores.
|
||||
diagram: 0200-containers/online-shop.archify
|
||||
psp:
|
||||
type: external
|
||||
label: Payment provider
|
||||
sublabel: Stripe
|
||||
brand: stripe
|
||||
description: Charges cards and pays out; we never see card numbers.
|
||||
email:
|
||||
type: external
|
||||
label: Email service
|
||||
sublabel: transactional mail
|
||||
description: Sends order confirmations and password resets.
|
||||
@@ -0,0 +1,26 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: System context
|
||||
output: context.html
|
||||
components:
|
||||
- ref: crm
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: erp
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: platform
|
||||
pos: [280, 160]
|
||||
size: [170, 60]
|
||||
- ref: warehouse
|
||||
pos: [540, 60]
|
||||
size: [150, 60]
|
||||
- ref: ops
|
||||
pos: [540, 260]
|
||||
size: [150, 60]
|
||||
connections:
|
||||
- { from: crm, to: platform, label: customer events, variant: emphasis }
|
||||
- { from: erp, to: platform, label: order events, variant: emphasis }
|
||||
- { from: platform, to: warehouse, label: pick lists }
|
||||
- { from: ops, to: platform, label: replays, variant: dashed }
|
||||
@@ -0,0 +1,46 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Containers
|
||||
output: containers.html
|
||||
components:
|
||||
- ref: crm
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: erp
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: ingest
|
||||
pos: [250, 160]
|
||||
size: [140, 60]
|
||||
- ref: bus
|
||||
pos: [470, 160]
|
||||
size: [140, 60]
|
||||
- ref: router
|
||||
pos: [690, 160]
|
||||
size: [150, 60]
|
||||
- ref: dlq
|
||||
pos: [690, 320]
|
||||
size: [150, 60]
|
||||
- ref: store
|
||||
pos: [920, 60]
|
||||
size: [140, 60]
|
||||
- ref: notifier
|
||||
pos: [920, 160]
|
||||
size: [140, 60]
|
||||
- ref: analytics
|
||||
pos: [920, 260]
|
||||
size: [140, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Integration platform
|
||||
wraps: [ingest, bus, router, dlq, store, notifier, analytics]
|
||||
connections:
|
||||
- { from: crm, to: ingest, label: HTTPS, variant: emphasis }
|
||||
- { from: erp, to: ingest, label: HTTPS, variant: emphasis }
|
||||
- { from: ingest, to: bus, label: publish }
|
||||
- { from: bus, to: router, label: consume }
|
||||
- { from: router, to: store, label: append }
|
||||
- { from: router, to: notifier, label: notify }
|
||||
- { from: router, to: analytics, label: flatten }
|
||||
- { from: router, to: dlq, label: failed, variant: dashed, labelDy: 24 }
|
||||
@@ -0,0 +1,46 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Event router components
|
||||
output: router.html
|
||||
components:
|
||||
- ref: bus
|
||||
pos: [40, 160]
|
||||
size: [140, 60]
|
||||
- id: validate
|
||||
type: security
|
||||
label: Validate
|
||||
sublabel: schema check
|
||||
pos: [270, 160]
|
||||
size: [130, 60]
|
||||
- id: enrich
|
||||
type: backend
|
||||
label: Enrich
|
||||
sublabel: reference data
|
||||
pos: [480, 160]
|
||||
size: [130, 60]
|
||||
- id: dispatch
|
||||
type: backend
|
||||
label: Dispatch
|
||||
pos: [690, 160]
|
||||
size: [130, 60]
|
||||
- ref: store
|
||||
pos: [920, 60]
|
||||
size: [140, 60]
|
||||
- ref: notifier
|
||||
pos: [920, 160]
|
||||
size: [140, 60]
|
||||
- ref: dlq
|
||||
pos: [270, 320]
|
||||
size: [150, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Event router
|
||||
wraps: [validate, enrich, dispatch]
|
||||
connections:
|
||||
- { from: bus, to: validate, label: event }
|
||||
- { from: validate, to: enrich, label: valid }
|
||||
- { from: enrich, to: dispatch, label: enriched }
|
||||
- { from: dispatch, to: store, label: append }
|
||||
- { from: dispatch, to: notifier, label: notify }
|
||||
- { from: validate, to: dlq, label: invalid, variant: dashed, labelDy: 24 }
|
||||
@@ -0,0 +1,29 @@
|
||||
schema_version: 1
|
||||
model:
|
||||
id: event-based-integration
|
||||
name: Event-Based Integration
|
||||
description: >
|
||||
Systems that never call each other directly; events go through a bus,
|
||||
a router validates and enriches them, consumers react. Context,
|
||||
containers, the router's components and one flow, C4 style.
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Event-Based Integration — overview
|
||||
output: main.html
|
||||
components:
|
||||
- ref: crm
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: erp
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: platform
|
||||
pos: [280, 160]
|
||||
size: [170, 60]
|
||||
- ref: warehouse
|
||||
pos: [540, 160]
|
||||
size: [150, 60]
|
||||
connections:
|
||||
- { from: crm, to: platform, label: customer events, variant: emphasis }
|
||||
- { from: erp, to: platform, label: order events, variant: emphasis }
|
||||
- { from: platform, to: warehouse, label: pick lists }
|
||||
@@ -0,0 +1,40 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
ingest:
|
||||
type: backend
|
||||
label: Ingest API
|
||||
sublabel: HTTPS in, events out
|
||||
description: Accepts events from systems that cannot publish to the bus themselves.
|
||||
bus:
|
||||
type: messagebus
|
||||
label: Event bus
|
||||
sublabel: Kafka
|
||||
brand: apache-kafka
|
||||
description: One topic per event type; retained for seven days.
|
||||
router:
|
||||
type: backend
|
||||
label: Event router
|
||||
sublabel: validate, enrich, dispatch
|
||||
description: Checks each event against its schema, adds reference data, hands it to the consumers.
|
||||
diagram: 0300-components/router.archify
|
||||
dlq:
|
||||
type: messagebus
|
||||
label: Dead-letter queue
|
||||
sublabel: failed events
|
||||
description: Events the router could not process; replayed by operations.
|
||||
store:
|
||||
type: database
|
||||
label: Event store
|
||||
sublabel: PostgreSQL
|
||||
brand: postgresql
|
||||
description: Every accepted event, for audit and replay.
|
||||
notifier:
|
||||
type: backend
|
||||
label: Notifier
|
||||
sublabel: email, chat
|
||||
description: Turns selected events into messages to people.
|
||||
analytics:
|
||||
type: database
|
||||
label: Analytics
|
||||
sublabel: warehouse tables
|
||||
description: Flattened events for reporting.
|
||||
@@ -0,0 +1,7 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
ops:
|
||||
type: external
|
||||
label: Operations
|
||||
sublabel: on-call
|
||||
description: Watches the dead-letter queue and replays failed events.
|
||||
@@ -0,0 +1,23 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
platform:
|
||||
type: cloud
|
||||
label: Integration platform
|
||||
sublabel: the system
|
||||
description: Receives events from the business systems, routes them, keeps a record.
|
||||
diagram: 0200-containers/platform.archify
|
||||
crm:
|
||||
type: external
|
||||
label: CRM
|
||||
sublabel: customers
|
||||
description: Source of customer events; created, updated, merged.
|
||||
erp:
|
||||
type: external
|
||||
label: ERP
|
||||
sublabel: orders and stock
|
||||
description: Source of order events and the target of stock updates.
|
||||
warehouse:
|
||||
type: external
|
||||
label: Warehouse system
|
||||
sublabel: fulfilment
|
||||
description: Receives pick lists; reports shipments back as events.
|
||||
@@ -0,0 +1,26 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: System context
|
||||
output: context.html
|
||||
components:
|
||||
- ref: user
|
||||
pos: [40, 60]
|
||||
size: [130, 60]
|
||||
- ref: admin
|
||||
pos: [40, 260]
|
||||
size: [130, 60]
|
||||
- ref: webapp
|
||||
pos: [280, 160]
|
||||
size: [160, 60]
|
||||
- ref: idp
|
||||
pos: [530, 60]
|
||||
size: [150, 60]
|
||||
- ref: email
|
||||
pos: [530, 260]
|
||||
size: [150, 60]
|
||||
connections:
|
||||
- { from: user, to: webapp, label: uses, variant: emphasis }
|
||||
- { from: admin, to: webapp, label: administers }
|
||||
- { from: webapp, to: idp, label: signs in via, variant: security }
|
||||
- { from: webapp, to: email, label: sends mail }
|
||||
@@ -0,0 +1,38 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Containers
|
||||
output: containers.html
|
||||
components:
|
||||
- ref: user
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: spa
|
||||
pos: [250, 160]
|
||||
size: [150, 60]
|
||||
- ref: api
|
||||
pos: [480, 160]
|
||||
size: [130, 60]
|
||||
- ref: idp
|
||||
pos: [480, 20]
|
||||
size: [130, 60]
|
||||
- ref: cache
|
||||
pos: [690, 60]
|
||||
size: [130, 60]
|
||||
- ref: db
|
||||
pos: [690, 160]
|
||||
size: [130, 60]
|
||||
- ref: storage
|
||||
pos: [690, 260]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: Web Application
|
||||
wraps: [spa, api, cache, db, storage]
|
||||
connections:
|
||||
- { from: user, to: spa, label: HTTPS, variant: emphasis }
|
||||
- { from: spa, to: api, label: JSON }
|
||||
- { from: api, to: idp, label: verify token, variant: security, labelAt: [531, 110] }
|
||||
- { from: api, to: cache, label: read }
|
||||
- { from: api, to: db, label: SQL }
|
||||
- { from: api, to: storage, label: files }
|
||||
@@ -0,0 +1,42 @@
|
||||
schema_version: 1
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: API components
|
||||
output: api.html
|
||||
components:
|
||||
- ref: spa
|
||||
pos: [40, 160]
|
||||
size: [150, 60]
|
||||
- id: auth
|
||||
type: security
|
||||
label: Auth
|
||||
sublabel: token check
|
||||
pos: [270, 40]
|
||||
size: [130, 60]
|
||||
- id: accounts
|
||||
type: backend
|
||||
label: Accounts
|
||||
pos: [270, 160]
|
||||
size: [130, 60]
|
||||
- id: content
|
||||
type: backend
|
||||
label: Content
|
||||
pos: [270, 280]
|
||||
size: [130, 60]
|
||||
- ref: db
|
||||
pos: [500, 160]
|
||||
size: [130, 60]
|
||||
- ref: storage
|
||||
pos: [500, 280]
|
||||
size: [130, 60]
|
||||
boundaries:
|
||||
- kind: region
|
||||
label: API
|
||||
wraps: [auth, accounts, content]
|
||||
connections:
|
||||
- { from: spa, to: auth, label: token, variant: security }
|
||||
- { from: spa, to: accounts, label: profile }
|
||||
- { from: spa, to: content, label: pages }
|
||||
- { from: accounts, to: db, label: SQL }
|
||||
- { from: content, to: db, label: SQL }
|
||||
- { from: content, to: storage, label: files }
|
||||
@@ -0,0 +1,29 @@
|
||||
schema_version: 1
|
||||
model:
|
||||
id: standard-web-app
|
||||
name: Standard Web App
|
||||
description: >
|
||||
A classic web application; a single-page app, an API, a database,
|
||||
a cache and file storage, with sign-in through an identity provider.
|
||||
Context, containers and the API's components, C4 style.
|
||||
diagram_type: architecture
|
||||
meta:
|
||||
title: Standard Web App — overview
|
||||
output: main.html
|
||||
components:
|
||||
- ref: user
|
||||
pos: [40, 160]
|
||||
size: [130, 60]
|
||||
- ref: webapp
|
||||
pos: [250, 160]
|
||||
size: [160, 60]
|
||||
- ref: idp
|
||||
pos: [490, 60]
|
||||
size: [150, 60]
|
||||
- ref: email
|
||||
pos: [490, 260]
|
||||
size: [150, 60]
|
||||
connections:
|
||||
- { from: user, to: webapp, label: uses, variant: emphasis }
|
||||
- { from: webapp, to: idp, label: signs in via, variant: security }
|
||||
- { from: webapp, to: email, label: sends mail }
|
||||
@@ -0,0 +1,32 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
spa:
|
||||
type: frontend
|
||||
label: Single-page app
|
||||
sublabel: React
|
||||
brand: react
|
||||
description: The user interface; talks only to the API.
|
||||
api:
|
||||
type: backend
|
||||
label: API
|
||||
sublabel: Node.js
|
||||
brand: node-js
|
||||
description: Owns accounts, content and files; checks tokens from the identity provider.
|
||||
diagram: 0300-components/api.archify
|
||||
db:
|
||||
type: database
|
||||
label: Database
|
||||
sublabel: PostgreSQL
|
||||
brand: postgresql
|
||||
description: Accounts and content; the source of truth.
|
||||
cache:
|
||||
type: database
|
||||
label: Cache
|
||||
sublabel: Redis
|
||||
brand: redis
|
||||
description: Sessions and hot content; safe to lose.
|
||||
storage:
|
||||
type: cloud
|
||||
label: Object storage
|
||||
sublabel: uploaded files
|
||||
description: User uploads and generated exports.
|
||||
@@ -0,0 +1,12 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
user:
|
||||
type: external
|
||||
label: User
|
||||
sublabel: browser
|
||||
description: Anyone with an account; uses the application from a browser.
|
||||
admin:
|
||||
type: external
|
||||
label: Administrator
|
||||
sublabel: back office
|
||||
description: Manages users and content from the same application with extra rights.
|
||||
@@ -0,0 +1,18 @@
|
||||
schema_version: 1
|
||||
elements:
|
||||
webapp:
|
||||
type: cloud
|
||||
label: Web Application
|
||||
sublabel: the system
|
||||
description: Everything we build and run; the single-page app, the API and its stores.
|
||||
diagram: 0200-containers/web-app.archify
|
||||
idp:
|
||||
type: external
|
||||
label: Identity provider
|
||||
sublabel: OpenID Connect
|
||||
description: Signs users in and issues tokens; we never store passwords.
|
||||
email:
|
||||
type: external
|
||||
label: Email service
|
||||
sublabel: transactional mail
|
||||
description: Sends sign-up confirmations and notifications.
|
||||
@@ -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,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { parse } from 'yaml';
|
||||
import { validate } from '../src/archify/cli';
|
||||
import { MODEL_EXAMPLES, mainFromExample } from '../src/language/examples';
|
||||
import { MAIN_FILE, REGISTRY_EXT, hasDiagramBody, mergeRegistries, parseRegistry, readModelMeta, resolveRefs } from '../src/model/format';
|
||||
import { readDiagramSource } from '../src/sources/detect';
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const examples = path.join(root, 'templates', 'models');
|
||||
const runtime = {
|
||||
cliPath: path.join(root, 'vendor', 'archify', 'bin', 'archify.mjs'),
|
||||
nodePath: process.execPath,
|
||||
runAsNode: false,
|
||||
workDir: fs.mkdtempSync(path.join(os.tmpdir(), 'archify-ext-examples-')),
|
||||
};
|
||||
|
||||
function walk(dir: string, rel = ''): string[] {
|
||||
return fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap((entry) => {
|
||||
const next = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
return entry.isDirectory() ? walk(dir, next) : [next];
|
||||
});
|
||||
}
|
||||
|
||||
test('every example folder is listed, every listed example exists, labels are the three C4 examples', () => {
|
||||
const folders = fs.readdirSync(examples, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
||||
assert.deepEqual(MODEL_EXAMPLES.map((e) => e.folder).sort(), folders);
|
||||
assert.deepEqual(
|
||||
MODEL_EXAMPLES.map((e) => e.label),
|
||||
['C4 - Standard Web App', 'C4 - E-Shop', 'C4 - Event-Based Integration'],
|
||||
);
|
||||
});
|
||||
|
||||
for (const example of MODEL_EXAMPLES) {
|
||||
test(`example ${example.folder} is a complete model that validates`, async () => {
|
||||
const dir = path.join(examples, example.folder);
|
||||
const files = walk(dir);
|
||||
const read = (rel: string) => fs.readFileSync(path.join(dir, rel), 'utf8');
|
||||
|
||||
// main.archify: model block with id/name/description and an overview diagram.
|
||||
assert.ok(files.includes(MAIN_FILE));
|
||||
const main = parse(read(MAIN_FILE));
|
||||
const meta = readModelMeta(main);
|
||||
assert.equal(meta?.id, example.folder.replace(/^c4-/, ''));
|
||||
assert.ok(meta?.name && meta.description);
|
||||
assert.ok(hasDiagramBody(main));
|
||||
|
||||
// Registry: at least people, systems and containers; no problems.
|
||||
const registryFiles = files.filter((f) => f.endsWith(REGISTRY_EXT)).sort();
|
||||
assert.ok(registryFiles.length >= 3, `registry files: ${registryFiles.join(', ')}`);
|
||||
const registry = mergeRegistries(registryFiles.map((file) => ({ file, ...parseRegistry(read(file), file) })));
|
||||
assert.deepEqual(registry.problems, []);
|
||||
for (const element of registry.elements.values()) {
|
||||
assert.ok(element.type && element.label && element.description, `${element.id} needs type, label and description`);
|
||||
if (element.diagram) assert.ok(files.includes(element.diagram), `${element.id} links to missing ${element.diagram}`);
|
||||
}
|
||||
|
||||
// Diagrams: the three C4 levels, each resolving without problems and valid for Archify.
|
||||
const diagrams = files.filter((f) => f.endsWith('.archify')).sort();
|
||||
for (const level of ['0100-context', '0200-containers', '0300-components']) {
|
||||
assert.ok(diagrams.some((d) => d.startsWith(`${level}/`)), `missing ${level}`);
|
||||
}
|
||||
const used = new Set<string>();
|
||||
for (const file of diagrams) {
|
||||
const source = readDiagramSource(read(file), file);
|
||||
assert.ok(source, `${file} is not a diagram`);
|
||||
const resolved = resolveRefs(source.diagram, { registry, fileName: file });
|
||||
assert.deepEqual(resolved.problems, [], file);
|
||||
for (const link of resolved.links.values()) assert.ok(files.includes(link), `${file} links to missing ${link}`);
|
||||
for (const id of resolved.used) used.add(id);
|
||||
const result = await validate(runtime, source.type, JSON.stringify(resolved.diagram));
|
||||
assert.deepEqual(result.diagnostics.filter((d) => d.severity === 'error').map((d) => `${file}: ${d.code}: ${d.message}`), []);
|
||||
assert.ok(result.ok, file);
|
||||
}
|
||||
// Every registry element is used by some diagram.
|
||||
for (const id of registry.elements.keys()) assert.ok(used.has(id), `element ${id} is never used`);
|
||||
});
|
||||
}
|
||||
|
||||
test('mainFromExample keeps the example as written, or renames it after the folder', () => {
|
||||
const example = MODEL_EXAMPLES[1];
|
||||
const text = fs.readFileSync(path.join(examples, example.folder, MAIN_FILE), 'utf8');
|
||||
assert.equal(mainFromExample(text, example.folder, example), text);
|
||||
const renamed = parse(mainFromExample(text, 'shop two', example));
|
||||
assert.equal(renamed.model.id, 'shop-two');
|
||||
assert.equal(renamed.model.name, 'Shop two');
|
||||
assert.equal(renamed.meta.title, 'Shop two — overview');
|
||||
assert.equal(renamed.model.description, parse(text).model.description);
|
||||
assert.deepEqual(renamed.components, parse(text).components);
|
||||
});
|
||||
@@ -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,155 @@ export async function run(): Promise<void> {
|
||||
'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('a .archify-registry file opens as the registry view and Show Source opens the YAML', async () => {
|
||||
const registry = workspaceFile('model/registry/people.archify-registry');
|
||||
await vscode.commands.executeCommand('vscode.open', registry);
|
||||
const tab = await waitFor('registry view tab', () =>
|
||||
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.registry' && t.input.uri.toString() === registry.toString()),
|
||||
);
|
||||
assert.ok(tab);
|
||||
await vscode.commands.executeCommand('archify.showSource');
|
||||
const editor = await waitFor('registry text editor', () => vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === registry.toString()));
|
||||
assert.equal(editor.document.languageId, 'yaml');
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
await check('Archify | New Model… creates a model folder from an example and opens its page', async () => {
|
||||
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes.commands as { command: string; title: string }[];
|
||||
assert.equal(commands.find((item) => item.command === 'archify.newModel')?.title, 'Archify | New Model…');
|
||||
const parent = workspaceFile('.');
|
||||
const window = vscode.window as unknown as Record<string, unknown>;
|
||||
const originalPick = window.showQuickPick;
|
||||
const originalInput = window.showInputBox;
|
||||
let labels: string[] = [];
|
||||
let existingNameMessage: unknown;
|
||||
window.showQuickPick = async (items: readonly { label: string }[]) => {
|
||||
labels = (await items).map((item) => item.label);
|
||||
return (await items).find((item) => item.label === 'C4 - E-Shop');
|
||||
};
|
||||
window.showInputBox = async (options: vscode.InputBoxOptions) => {
|
||||
existingNameMessage = await options.validateInput?.('new-shop');
|
||||
return 'shop-two';
|
||||
};
|
||||
try {
|
||||
await vscode.commands.executeCommand('archify.newModel', parent);
|
||||
} finally {
|
||||
window.showQuickPick = originalPick;
|
||||
window.showInputBox = originalInput;
|
||||
}
|
||||
assert.deepEqual(labels, ['C4 - Standard Web App', 'C4 - E-Shop', 'C4 - Event-Based Integration']);
|
||||
assert.match(String(existingNameMessage), /already exists/);
|
||||
const folder = vscode.Uri.joinPath(parent, 'shop-two');
|
||||
const main = vscode.Uri.joinPath(folder, 'main.archify');
|
||||
const content = new TextDecoder().decode(await vscode.workspace.fs.readFile(main));
|
||||
assert.match(content, /id: shop-two/);
|
||||
assert.match(content, /name: Shop two/);
|
||||
assert.match(content, /title: Shop two — overview/);
|
||||
const names = (await vscode.workspace.fs.readDirectory(folder)).map(([name]) => name).sort();
|
||||
assert.deepEqual(names, ['0100-context', '0200-containers', '0300-components', 'main.archify', 'registry']);
|
||||
const registry = (await vscode.workspace.fs.readDirectory(vscode.Uri.joinPath(folder, 'registry'))).map(([name]) => name).sort();
|
||||
assert.deepEqual(registry, ['containers.archify-registry', 'people.archify-registry', 'systems.archify-registry']);
|
||||
const tab = await waitFor('model page tab', () =>
|
||||
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.uri.toString() === main.toString()),
|
||||
);
|
||||
assert.ok(tab);
|
||||
// The new model is indexed: its diagrams resolve against the copied registry.
|
||||
const containers = await vscode.workspace.openTextDocument(vscode.Uri.joinPath(folder, '0200-containers', 'online-shop.archify'));
|
||||
await vscode.window.showTextDocument(containers);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const errors = vscode.languages.getDiagnostics(containers.uri).filter((d) => d.severity === vscode.DiagnosticSeverity.Error);
|
||||
assert.deepEqual(errors.map((d) => d.message), []);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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));
|
||||
// Descriptions travel with the node ids, for the Elements panel; nothing is added to the diagram itself.
|
||||
assert.ok(!('cards' in r.diagram));
|
||||
assert.equal(r.descriptions.get('customer'), 'Buys products; signs in with email or a social account.');
|
||||
assert.equal(r.descriptions.size, 7);
|
||||
const own = resolveRefs({ components: [{ ref: 'api', pos: [0, 0] }], cards: [{ dot: 'rose', title: 'Mine', items: ['kept'] }] }, { registry, fileName: 'x.archify' });
|
||||
assert.deepEqual(own.diagram.cards, [{ dot: 'rose', title: 'Mine', items: ['kept'] }], 'hand-written cards pass through');
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const result = await renderDiagram(runtime(), load(name), 'strict');
|
||||
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', () => {
|
||||
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')));
|
||||
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 { parse } from 'yaml';
|
||||
import { validate } from '../src/archify/cli';
|
||||
import { resolveRefs } from '../src/model/format';
|
||||
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES, titleFromName } from '../src/language/templates';
|
||||
import { readDiagramSource } from '../src/sources/detect';
|
||||
|
||||
@@ -29,7 +30,9 @@ for (const item of TEMPLATES) {
|
||||
assert.ok(source);
|
||||
assert.equal(source.type, item.type);
|
||||
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(
|
||||
result.diagnostics.filter((d) => d.severity === 'error').map((d) => `${d.code}: ${d.message}`),
|
||||
[],
|
||||
|
||||
Reference in new issue
Block a user