Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf320a023 | ||
|
|
33ebdd6328 | ||
|
|
9684464630 | ||
|
|
094ca2925f | ||
|
|
de1a57e4c6 | ||
|
|
4382730343 | ||
|
|
b9676ece83 | ||
|
|
36bccb69a5 | ||
|
|
38cdc7604f | ||
|
|
57914d435c | ||
|
|
7a2b661afe | ||
|
|
701ffc7607 | ||
|
|
17ca07d9ba | ||
|
|
66a60bada6 | ||
|
|
c71d2e40b9 | ||
|
|
fb7d7706aa | ||
|
|
97b3b5f685 | ||
|
|
20786f4b5a | ||
|
|
0b536f3e10 | ||
|
|
bc294dc6bd | ||
|
|
1a5482b4bf | ||
|
|
ac9200ccdd | ||
|
|
9871f774f4 | ||
|
|
ca3f08bac9 | ||
|
|
86f6d5c668 | ||
|
|
789ccb2d5c | ||
|
|
adc5cdbe97 | ||
|
|
e9da971523 | ||
|
|
8d8fc6f65e | ||
|
|
6f6ffae96f |
No files matched your search
@@ -1,5 +1,70 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.8.0 — 2026-10-19
|
||||||
|
|
||||||
|
- **Architecture decisions.** A model can hold decisions: one `*.archify-decision` file each, anywhere under the model root, with an id (unique in the model), title, status, date, the registry `elements` and `diagrams` it is about, `supersedes`, and prose for context, decision and consequences.
|
||||||
|
- **Diagrams point at decisions** with `decisions: [<id>]` on the diagram, on a component, node or participant, and on a connection, edge, flow, transition or message. The key is stripped before Archify renders. Unknown ids, and the key outside a model, are reported in Problems. Completion, hover and go-to-definition for decision ids; in decision files the same for `elements`, `diagrams` and `supersedes`.
|
||||||
|
- **Where they show.** A **Decisions** panel on the `main.archify` page (one list, live decisions first, superseded and rejected dimmed and last). A **Decisions** rail panel under a diagram with the decisions it points at or that name it; hovering a row lights up the node or connection it is attached to, clicking opens the decision. A decision view when a `.archify-decision` file is opened: status, date, context, decision, consequences, related decisions, the elements as cards, the diagrams with where the decision is attached. Decisions also appear on element cards and in the element dialog.
|
||||||
|
- **Authoring.** *Archify | New Decision…* on a folder inside a model creates a decision file from a template, with the id taken from the title. Editor schema for decision files and the new `decisions` key. A decision outside a model opens, but the page and Problems say it has lost its model.
|
||||||
|
- The three New Model examples carry decisions. Model page: the registry files panel is titled **Registries**.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- The 3D-only controls moved to the bottom-right dock next to **PATH** and **LENS**: **STACK** (levels by kind, on or off), **TOP** (from above), and the zoom stepper's middle button reads **RESET** in 3D instead of a 2D zoom level.
|
||||||
|
- Nothing is greyed out any more, and no menu stays open over the diagram after a choice.
|
||||||
|
|
||||||
|
## 0.6.0 — 2026-10-11
|
||||||
|
|
||||||
|
- **3D view:** a **3D ▾** menu in the diagram toolbar, styled like Archify's own menus, switches any diagram to 3D. Nodes become raised blocks topped with the node as Archify draws it, relationships become lit pipes along Archify's routes, and the canvas, frames, lanes and legend form the floor. Works for all five diagram kinds, in every theme and style.
|
||||||
|
- The menu also holds **Levels** (*Flat*, or *Stacked* by kind) and **Camera** (*Reset view*, *From above*).
|
||||||
|
- **Animation follows 2D:** the 3D view mirrors Archify's own animations live: the trace when the diagram opens (replayed when 3D is chosen), node pulses, the hover trace, and the Semantic Passport, Path and Lens flows. *Live* off and reduced motion keep it still.
|
||||||
|
- In 3D, clicking a block focuses it as in 2D (Semantic Passport, fading); hover shows the one-hop trace; trackpad scrolling pans, pinch and the mouse wheel zoom; the zoom buttons and <kbd>+</kbd>/<kbd>-</kbd>/<kbd>0</kbd> move the camera; **Export → 3D view** saves a PNG.
|
||||||
|
- **Performance:** the 3D view draws only when something moves (no GPU use while idle), uses lighter textures and pixel density, and the hidden 2D diagram is no longer painted. Hover and animations never rebuild textures.
|
||||||
|
- New setting `archify.defaultView` (`2d` or `3d`). Each open diagram remembers its view, camera and levels.
|
||||||
|
- The source code is reorganised by job (`commands/`, `views/`, `webview/`, `archify/`, `sources/`, `language/`, `util/`); no change in behaviour.
|
||||||
|
|
||||||
|
## 0.5.2 — 2026-10-10
|
||||||
|
|
||||||
|
- **Right-click menus:** the Archify commands (Archify | New Diagram…, Archify | Open Preview, Archify | Render to HTML File…, Archify | Open in HTML Viewer) now sit in their own section at the very bottom of the Explorer and editor-tab menus, instead of between New File, New Folder and the other built-in entries.
|
||||||
|
- **All commands are named "Archify | …"**, the same in right-click menus, the editor title bar and the Command Palette: Archify | New Diagram… (was *New Diagram Archify…*), Archify | Open Preview, Archify | Open Preview to the Side, Archify | Render to HTML File…, Archify | Open in HTML Viewer, Archify | Open in Browser, Archify | Refresh Preview, Archify | Show Source.
|
||||||
|
|
||||||
|
## 0.5.1 — 2026-10-07
|
||||||
|
|
||||||
|
- **Clearer descriptions:** the Marketplace description, README and messages now say plainly that diagrams are `.archify` files in YAML. Archify's own JSON files (`*.architecture.json`, …) and rendered HTML are described as also supported, in their own section.
|
||||||
|
- The rendered-HTML editor is now called **Archify HTML Viewer** (was *Archify Diagram Viewer*, the same name as the extension), and its command **Open in Archify HTML Viewer**.
|
||||||
|
- Messages: "open a .archify diagram first"; an empty or broken `.archify` file asks for valid YAML, not JSON.
|
||||||
|
|
||||||
|
## 0.5.0 — 2026-10-04
|
||||||
|
|
||||||
|
- **Brand completion:** after `brand:` in `.archify` files and Archify JSON sources, the editor suggests the 107 built-in brand IDs with their product names. Aliases and product names match too (`kafka` → `apache-kafka`, `postgres` → `postgresql`).
|
||||||
|
- **Unknown brands are underlined on their own line.** Archify reports these without a location, so they used to appear at the top of the file.
|
||||||
|
|
||||||
|
## 0.4.1 — 2026-10-04
|
||||||
|
|
||||||
|
- The new-diagram command is now titled **New Diagram Archify…** everywhere, including the Explorer folder menu, where it read *New Diagram…*.
|
||||||
|
|
||||||
## 0.4.0 — 2026-10-04
|
## 0.4.0 — 2026-10-04
|
||||||
|
|
||||||
- **New Diagram…** (Explorer folder menu, *File → New File…*, Command Palette): pick Architecture, Workflow, Sequence, Data Flow, Lifecycle or Everyday Plan, enter a name, and a small valid `.archify` (YAML) file is created from a template and opened as a diagram. Title and output file follow the name.
|
- **New Diagram…** (Explorer folder menu, *File → New File…*, Command Palette): pick Architecture, Workflow, Sequence, Data Flow, Lifecycle or Everyday Plan, enter a name, and a small valid `.archify` (YAML) file is created from a template and opened as a diagram. Title and output file follow the name.
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
# Archify Diagram Viewer
|
# Archify Diagram Viewer
|
||||||
|
|
||||||
View [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. Click a `.archify` file (YAML) and the interactive diagram opens straight away; open its source beside it and the diagram follows your edits. Rendered Archify HTML files open in a viewer tab instead of a browser.
|
Create and view [Archify](https://github.com/tt-a1i/archify) diagrams inside VS Code. You write a diagram as a **`.archify` file in YAML**; clicking it opens the interactive diagram, and the diagram follows your edits as you type.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i) and runs it on VS Code's own Node runtime, so nothing else needs to be installed.
|
This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i) and runs it on VS Code's own Node runtime, so nothing else needs to be installed.
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|---|---|---|
|
||||||
|
| **`name.archify`** | **YAML** (the main format) | Opens as the interactive diagram. Show Source, live updates, validation, completion, templates. |
|
||||||
|
| `name.architecture.json`, `name.workflow.json`, `name.sequence.json`, `name.dataflow.json`, `name.lifecycle.json` | JSON (Archify's own CLI format) | Opens as text, with a preview beside it and validation. For files produced by the Archify CLI or AI agents. |
|
||||||
|
| Rendered Archify `.html` | HTML | Read-only viewer instead of a browser. |
|
||||||
|
|
||||||
|
New diagrams should be `.archify` files; everything below describes them unless it says otherwise.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
The fastest start: right-click a folder in the Explorer and choose **New Archify 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.
|
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:
|
To write one by hand:
|
||||||
|
|
||||||
@@ -36,36 +50,98 @@ The same file is in [examples/hello.archify](examples/hello.archify).
|
|||||||
|
|
||||||
## The `.archify` format
|
## The `.archify` format
|
||||||
|
|
||||||
A `.archify` file is an Archify diagram source written in **YAML**. It has the same fields as the JSON the Archify CLI renders, and plain JSON content still works, since JSON is valid YAML. `diagram_type` selects the kind of diagram: `architecture`, `workflow`, `sequence`, `dataflow` or `lifecycle`.
|
A `.archify` file is an Archify diagram written in **YAML**. Its fields are those of Archify's diagram schema; the extension converts the YAML for the renderer, so you never handle JSON yourself. `diagram_type` selects the kind of diagram: `architecture`, `workflow`, `sequence`, `dataflow` or `lifecycle`.
|
||||||
|
|
||||||
- **Opening:** the **Archify Diagram** editor is the default for `.archify`. Use *Reopen Editor With… → Text Editor* to edit the YAML full-screen.
|
- **Opening:** the **Archify Diagram** editor is the default for `.archify`. Use *Reopen Editor With… → Text Editor* to edit the YAML full-screen.
|
||||||
- **Completion:** with the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) (`redhat.vscode-yaml`) installed, `.archify` files get completion and hover from Archify's schemas. The schema follows `diagram_type`.
|
- **Completion:** with the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) (`redhat.vscode-yaml`) installed, `.archify` files get completion and hover from Archify's schemas. The schema follows `diagram_type`.
|
||||||
|
|
||||||
Archify's own JSON naming, `name.architecture.json`, `name.workflow.json` and so on, works too. Those open as text with a preview button, and get schema help from VS Code's built-in JSON support.
|
`.archify` files from extension version 0.2.0 were written in JSON. They still open, because JSON is valid YAML, but new files should use YAML.
|
||||||
|
|
||||||
|
## Archify's JSON files
|
||||||
|
|
||||||
|
The Archify CLI and the AI agents that use it write diagrams as JSON, named `name.architecture.json`, `name.workflow.json` and so on. The extension supports these too: they open as text with a preview button in the editor title (<kbd>Cmd/Ctrl+K V</kbd> to the side, <kbd>Cmd/Ctrl+Shift+V</kbd> in place), get validation in Problems, and get schema completion from VS Code's built-in JSON support. Any other `.json` file that declares an Archify `diagram_type` previews the same way.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **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 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.
|
- **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live.
|
||||||
- **Live preview**: for Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, <kbd>Cmd/Ctrl+K V</kbd> (to the side) or <kbd>Cmd/Ctrl+Shift+V</kbd>. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders.
|
- **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 **Open in Archify Viewer**, or use *Reopen Editor With… → Archify Diagram Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk.
|
- **HTML viewer**: right-click a rendered `.html` file and choose **Archify | Open in HTML Viewer**, or use *Reopen Editor With… → Archify HTML Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk.
|
||||||
- **Problems**: YAML syntax errors, plus `archify validate`'s schema, layout and evidence problems, appear at the matching line of the source.
|
- **Problems**: YAML syntax errors, plus `archify validate`'s schema, layout and evidence problems, appear at the matching line of the `.archify` file.
|
||||||
- **Schema help**: completion and hover come from Archify's own schemas: built in for the JSON files, and through the YAML extension for `.archify` files.
|
- **Brand logos**: after `brand:` the editor suggests Archify's built-in brand IDs (PostgreSQL, Redis, Kafka, Docker, …). Typing an alias or product name finds them too, so `kafka` offers `apache-kafka`. An unknown brand is reported on its own line.
|
||||||
|
- **Schema help**: completion and hover from Archify's own schemas, through the YAML extension for `.archify` files.
|
||||||
- **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead.
|
- **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead.
|
||||||
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
|
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
|
||||||
- **Commands**: *Archify: 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 | New Decision…*, *Archify | Open Model*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*.
|
||||||
|
|
||||||
|
## 3D view
|
||||||
|
|
||||||
|
Every diagram can also be seen in 3D. The **2D | 3D** switch next to **Export** in the diagram's toolbar shows which view you are in; click it to flip.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The 3D view is built from the diagram Archify draws, so it looks the same: each node becomes a raised block whose top is the node as Archify draws it, relationships become pipes along Archify's routes with their colours, dashes and arrowheads, and the canvas, frames, lanes and legend form the floor. It works for all five diagram kinds, in every theme and style (Classic, Signal Flow, Blueprint, Editorial).
|
||||||
|
|
||||||
|
While 3D is on, three controls join the bottom-right dock next to **PATH** and **LENS**:
|
||||||
|
|
||||||
|
- **STACK** lifts nodes into levels by kind: clients and external systems on top, security below them, services in the middle, data on the floor. Click again for the flat layout.
|
||||||
|
- **TOP** looks straight down, like 2D.
|
||||||
|
- **RESET** (the middle of the zoom stepper, or <kbd>0</kbd>) flies back to the whole diagram, wherever you have moved to.
|
||||||
|
|
||||||
|
Moving around:
|
||||||
|
|
||||||
|
- Drag to turn, right-drag to move. On a trackpad, scroll with two fingers to move in any direction and pinch to zoom; a mouse wheel zooms. The dock's **−** and **+** and the <kbd>-</kbd> and <kbd>+</kbd> keys work too, beyond the 2D limits.
|
||||||
|
- Click a block to focus it, as in 2D: the Semantic Passport opens and unrelated parts fade. Hover a block to see its one-hop trace. Double-click a block to fly to it, or empty space to show everything.
|
||||||
|
|
||||||
|
Animation follows 2D exactly, because the 3D view reads Archify's own animations as they play: the trace (relationships drawing in, nodes pulsing) when the diagram opens or 3D is chosen, the hover trace, and the flows from the Semantic Passport, Path and Lens. *Live* off, or reduced motion, keeps it still.
|
||||||
|
|
||||||
|
**Export → 3D view** saves the current 3D view as a PNG. Each open diagram remembers whether it was in 3D, its camera and its levels; set `archify.defaultView` to `3d` to open diagrams in 3D.
|
||||||
|
|
||||||
|
The 3D view draws only when something moves, so an idle diagram costs no GPU time.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
decisions/
|
||||||
|
0002-queue-between-api-and-worker.archify-decision
|
||||||
|
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 **Registries**, 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.
|
||||||
|
- **Decisions.** One `*.archify-decision` file per architecture decision: id, title, status, date, the registry elements and diagrams it is about, and prose for context, decision and consequences. Diagrams point at decisions with `decisions: [<id>]` on the diagram, a component or a connection. They show as a **Decisions** panel on the `main.archify` page, a **Decisions** rail panel under each diagram (hover lights up what the decision is attached to), on element cards and in the element dialog, and a decision opens as its own page. *Archify | New Decision…* creates one; links are checked in Problems.
|
||||||
|
- **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
|
## Source evidence
|
||||||
|
|
||||||
A diagram that declares `meta.repository` and `sources` is verified against a local Git checkout whose `origin` and pinned commit match. The extension uses the Git top-level directory of the diagram file, or `archify.repoRoot` when set.
|
A diagram that declares `meta.repository` and `sources` is verified against a local Git checkout whose `origin` and pinned commit match. The extension uses the Git top-level directory of the diagram file, or `archify.repoRoot` when set.
|
||||||
|
|
||||||
When verification fails, the default (`archify.sourceEvidence: "fallback"`) still shows the diagram without source links, explains why in a notice, and reports the reason as a warning. Set it to `"strict"` to treat this as an error, as the Archify CLI does. *Render to HTML File…* always verifies strictly and asks before rendering without source links.
|
When verification fails, the default (`archify.sourceEvidence: "fallback"`) still shows the diagram without source links, explains why in a notice, and reports the reason as a warning. Set it to `"strict"` to treat this as an error, as the Archify CLI does. *Archify | Render to HTML File…* always verifies strictly and asks before rendering without source links.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
| Setting | Default | Description |
|
| Setting | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `archify.theme` | `vscode` | `vscode` follows the color theme; `dark` or `light` force one; `diagram` keeps the diagram's own choice. |
|
| `archify.theme` | `vscode` | `vscode` follows the color theme; `dark` or `light` force one; `diagram` keeps the diagram's own choice. |
|
||||||
|
| `archify.defaultView` | `2d` | `3d` opens diagrams in the 3D view. Each open diagram remembers its own choice. |
|
||||||
| `archify.preview.liveUpdate` | `true` | Re-render while typing; when off, re-render on save. |
|
| `archify.preview.liveUpdate` | `true` | Re-render while typing; when off, re-render on save. |
|
||||||
| `archify.preview.debounceMs` | `400` | Delay before re-rendering after an edit. |
|
| `archify.preview.debounceMs` | `400` | Delay before re-rendering after an edit. |
|
||||||
| `archify.validation.enabled` | `true` | Report validation results in Problems. |
|
| `archify.validation.enabled` | `true` | Report validation results in Problems. |
|
||||||
@@ -76,7 +152,7 @@ When verification fails, the default (`archify.sourceEvidence: "fallback"`) stil
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Diagrams run in a webview with a strict content security policy. Only the page's own inline scripts run (via a per-render nonce), there is no network access for scripts or fetches, and links open through VS Code.
|
Diagrams run in a webview with a strict content security policy. Only the page's own inline scripts and the extension's bundled 3D viewer run (via a per-render nonce), there is no network access for scripts or fetches, and links open through VS Code.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -90,6 +166,20 @@ npm run package # build archify-vscode-ext-<version>.vsix
|
|||||||
|
|
||||||
Press <kbd>F5</kbd> to launch an Extension Development Host with the test fixtures.
|
Press <kbd>F5</kbd> to launch an Extension Development Host with the test fixtures.
|
||||||
|
|
||||||
|
### Code layout
|
||||||
|
|
||||||
|
| Folder | What it holds |
|
||||||
|
|---|---|
|
||||||
|
| `src/extension.ts` | Activation: creates the views and registers everything |
|
||||||
|
| `src/commands/` | Command handlers, grouped by job, and the editor context keys |
|
||||||
|
| `src/views/` | The diagram view (editor for `.archify`, side preview for JSON) and the HTML viewer |
|
||||||
|
| `src/webview/` | The page the views show: CSP, the bridge script, messages from the page |
|
||||||
|
| `src/archify/` | Running the bundled Archify CLI: render, validate, runtime settings |
|
||||||
|
| `src/sources/` | Reading diagram files: type detection, YAML/JSON, source evidence |
|
||||||
|
| `src/language/` | Editor help: Problems, brand completion, templates |
|
||||||
|
| `src/util/` | Logging, settings, file reading |
|
||||||
|
| `src/viewer3d/` | The 3D view; runs inside the Archify page and is bundled to `dist/viewer3d.js` with three.js |
|
||||||
|
|
||||||
### Updating the bundled renderer
|
### Updating the bundled renderer
|
||||||
|
|
||||||
`vendor/archify/` and `schemas/` are generated from an Archify checkout:
|
`vendor/archify/` and `schemas/` are generated from an Archify checkout:
|
||||||
@@ -101,6 +191,14 @@ npm test
|
|||||||
|
|
||||||
`vendor/archify.json` records the upstream version and commit.
|
`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
|
## 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`.
|
MIT. See [LICENSE](LICENSE). The bundled renderer is MIT-licensed by tt-a1i; see `vendor/archify/LICENSE` and `vendor/archify/THIRD_PARTY_NOTICES.md`.
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
An architecture decision is one `*.archify-decision` file, anywhere under the model root (`decisions/` is the convention):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
schema_version: 1
|
||||||
|
decision:
|
||||||
|
id: queue-between-api-and-worker # unique in the model, any short string
|
||||||
|
title: A queue between the API and the worker
|
||||||
|
status: accepted # proposed | accepted | superseded | rejected
|
||||||
|
date: 2026-09-16
|
||||||
|
elements: [api, queue, worker] # registry ids
|
||||||
|
diagrams: [0200-containers/online-shop.archify]
|
||||||
|
supersedes: [] # ids of older decisions this one replaces
|
||||||
|
context: >
|
||||||
|
Why a decision was needed.
|
||||||
|
decision: >
|
||||||
|
What was decided.
|
||||||
|
consequences: >
|
||||||
|
What becomes easier, harder, or must change.
|
||||||
|
```
|
||||||
|
|
||||||
|
Ids are strings; an unquoted number such as `0001` is refused because YAML reads it as 1. A decision another decision supersedes shows as superseded whatever its own status says. Unknown elements, missing diagrams, duplicate ids, unknown statuses and dangling `supersedes` are problems on the decision file.
|
||||||
|
|
||||||
|
Diagrams point back at decisions with a `decisions` list of ids on the diagram itself, on a component (or node, participant, state) and on a connection (or edge, flow, transition, message):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
decisions: [postgres-as-source-of-truth]
|
||||||
|
components:
|
||||||
|
- ref: queue
|
||||||
|
pos: [460, 360]
|
||||||
|
size: [130, 60]
|
||||||
|
decisions: [queue-between-api-and-worker]
|
||||||
|
connections:
|
||||||
|
- { from: api, to: queue, label: enqueue, decisions: [queue-between-api-and-worker] }
|
||||||
|
```
|
||||||
|
|
||||||
|
The key is stripped before Archify renders. An unknown id is a problem at its line; outside a model the key is an error, like `ref`. The `main.archify` page lists all decisions; a diagram page lists the ones it points at or that name it; an element's card and dialog list the decisions about it. A `.archify-decision` file opens as its own page. Outside a model it still opens, with a note that it has lost its model.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
+16
-5
@@ -33,16 +33,27 @@ if (tests) {
|
|||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const ctx = await esbuild.context({
|
const contexts = await Promise.all([
|
||||||
|
esbuild.context({
|
||||||
...common,
|
...common,
|
||||||
entryPoints: ['src/extension.ts'],
|
entryPoints: ['src/extension.ts'],
|
||||||
outfile: 'dist/extension.js',
|
outfile: 'dist/extension.js',
|
||||||
external: ['vscode'],
|
external: ['vscode'],
|
||||||
});
|
}),
|
||||||
|
// Runs inside the Archify page in the webview; three.js is bundled in.
|
||||||
|
esbuild.context({
|
||||||
|
...common,
|
||||||
|
format: 'iife',
|
||||||
|
platform: 'browser',
|
||||||
|
target: 'es2022',
|
||||||
|
entryPoints: ['src/viewer3d/index.ts'],
|
||||||
|
outfile: 'dist/viewer3d.js',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
if (watch) {
|
if (watch) {
|
||||||
await ctx.watch();
|
await Promise.all(contexts.map((ctx) => ctx.watch()));
|
||||||
} else {
|
} else {
|
||||||
await ctx.rebuild();
|
await Promise.all(contexts.map((ctx) => ctx.rebuild()));
|
||||||
await ctx.dispose();
|
await Promise.all(contexts.map((ctx) => ctx.dispose()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,41 @@
|
|||||||
|
schema_version: 1
|
||||||
|
diagram_type: architecture
|
||||||
|
meta:
|
||||||
|
title: Containers
|
||||||
|
output: containers.html
|
||||||
|
decisions: [postgres-as-source-of-truth]
|
||||||
|
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]
|
||||||
|
decisions: [queue-between-api-and-worker]
|
||||||
|
- 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], decisions: [queue-between-api-and-worker] }
|
||||||
|
- { 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,20 @@
|
|||||||
|
schema_version: 1
|
||||||
|
decision:
|
||||||
|
id: postgres-as-source-of-truth
|
||||||
|
title: PostgreSQL as the single source of truth
|
||||||
|
status: accepted
|
||||||
|
date: 2026-09-02
|
||||||
|
elements: [db, api, worker]
|
||||||
|
diagrams: [0200-containers/online-shop.archify]
|
||||||
|
context: >
|
||||||
|
Orders, customers and the catalogue were spread over the
|
||||||
|
shop database and two spreadsheets kept by support.
|
||||||
|
Refunds were reconciled by hand every Monday.
|
||||||
|
decision: >
|
||||||
|
One PostgreSQL database owns orders, customers and the
|
||||||
|
catalogue. The API is the only writer. Everything else
|
||||||
|
(cache, search, reports) is derived and may be rebuilt.
|
||||||
|
consequences: >
|
||||||
|
Support loses the spreadsheets and gets the admin console.
|
||||||
|
Any new store must be declared derived or get its own
|
||||||
|
decision. Backups and migrations are the API team's job.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
schema_version: 1
|
||||||
|
decision:
|
||||||
|
id: queue-between-api-and-worker
|
||||||
|
title: A queue between the API and the worker
|
||||||
|
status: accepted
|
||||||
|
date: 2026-09-16
|
||||||
|
elements: [api, queue, worker]
|
||||||
|
diagrams: [0200-containers/online-shop.archify]
|
||||||
|
context: >
|
||||||
|
Order confirmation emails and invoices were sent inside
|
||||||
|
the checkout request. A slow mail provider made checkout
|
||||||
|
slow; a failing one made checkout fail.
|
||||||
|
decision: >
|
||||||
|
The API enqueues a job per side effect on SQS. One worker
|
||||||
|
service consumes the queue and does the slow work.
|
||||||
|
Checkout returns as soon as the order is stored.
|
||||||
|
consequences: >
|
||||||
|
Emails arrive seconds later, not instantly. Jobs must be
|
||||||
|
idempotent because SQS delivers at least once. The worker
|
||||||
|
is the second place that writes to the database.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
schema_version: 1
|
||||||
|
decision:
|
||||||
|
id: opensearch-for-product-search
|
||||||
|
title: OpenSearch for product search
|
||||||
|
status: proposed
|
||||||
|
date: 2026-10-19
|
||||||
|
elements: [api, worker, db]
|
||||||
|
diagrams: [0200-containers/online-shop.archify]
|
||||||
|
context: >
|
||||||
|
Catalogue search runs as SQL LIKE queries on the
|
||||||
|
database. Typos find nothing and the catalogue page is
|
||||||
|
the slowest page in the shop.
|
||||||
|
decision: >
|
||||||
|
Add a dedicated search service on OpenSearch. The API
|
||||||
|
queries it; the worker rebuilds the index from the
|
||||||
|
database after every catalogue change.
|
||||||
|
consequences: >
|
||||||
|
The index is derived data under decision 0001 and may be
|
||||||
|
rebuilt at any time. One more service to run. Search
|
||||||
|
results can lag the database by the rebuild interval.
|
||||||
@@ -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
+67
-2
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"version": "0.4.0",
|
"version": "0.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"version": "0.4.0",
|
"version": "0.8.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"three": "^0.186.1",
|
||||||
"yaml": "^2.9.1"
|
"yaml": "^2.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/three": "^0.186.0",
|
||||||
"@types/vscode": "~1.100.0",
|
"@types/vscode": "~1.100.0",
|
||||||
"@vscode/test-electron": "^3.1.0",
|
"@vscode/test-electron": "^3.1.0",
|
||||||
"@vscode/vsce": "^3.0.0",
|
"@vscode/vsce": "^3.0.0",
|
||||||
@@ -246,6 +248,13 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -999,6 +1008,13 @@
|
|||||||
"@textlint/ast-node-types": "15.8.0"
|
"@textlint/ast-node-types": "15.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.20.4",
|
"version": "22.20.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz",
|
||||||
@@ -1023,6 +1039,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.186.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz",
|
||||||
|
"integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": ">=0.5.17",
|
||||||
|
"fflate": "~0.8.3",
|
||||||
|
"meshoptimizer": "~1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/vscode": {
|
"node_modules/@types/vscode": {
|
||||||
"version": "1.100.0",
|
"version": "1.100.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.100.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.100.0.tgz",
|
||||||
@@ -1030,6 +1068,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typespec/ts-http-runtime": {
|
"node_modules/@typespec/ts-http-runtime": {
|
||||||
"version": "0.3.9",
|
"version": "0.3.9",
|
||||||
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz",
|
||||||
@@ -2162,6 +2207,13 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -3097,6 +3149,13 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meshoptimizer": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
@@ -4423,6 +4482,12 @@
|
|||||||
"url": "https://bevry.me/fund"
|
"url": "https://bevry.me/fund"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.186.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
|
||||||
|
"integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tmp": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||||
|
|||||||
+126
-31
@@ -1,10 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "archify-vscode-ext",
|
"name": "archify-vscode-ext",
|
||||||
"displayName": "Archify Diagram Viewer",
|
"displayName": "Archify Diagram Viewer",
|
||||||
"description": "Visualize Archify diagrams: .archify files (YAML) open straight into the interactive diagram; also previews Archify JSON sources and rendered Archify HTML inside VS Code.",
|
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.",
|
||||||
"version": "0.4.0",
|
"version": "0.8.0",
|
||||||
"publisher": "root-at-skic",
|
"publisher": "root-at-skic",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"sponsor": {
|
||||||
|
"url": "https://buymeacoffee.com/root.at.skic"
|
||||||
|
},
|
||||||
"icon": "media/icon.png",
|
"icon": "media/icon.png",
|
||||||
"galleryBanner": {
|
"galleryBanner": {
|
||||||
"color": "#0f172a",
|
"color": "#0f172a",
|
||||||
@@ -23,10 +26,16 @@
|
|||||||
],
|
],
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"archify",
|
"archify",
|
||||||
"diagram",
|
|
||||||
"architecture",
|
"architecture",
|
||||||
|
"c4",
|
||||||
|
"diagram",
|
||||||
|
"model",
|
||||||
|
"registry",
|
||||||
"sequence",
|
"sequence",
|
||||||
"workflow"
|
"workflow",
|
||||||
|
"yaml",
|
||||||
|
"adr",
|
||||||
|
"decisions"
|
||||||
],
|
],
|
||||||
"main": "./dist/extension.js",
|
"main": "./dist/extension.js",
|
||||||
"activationEvents": [
|
"activationEvents": [
|
||||||
@@ -34,57 +43,66 @@
|
|||||||
"onLanguage:jsonc",
|
"onLanguage:jsonc",
|
||||||
"onLanguage:html",
|
"onLanguage:html",
|
||||||
"workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}",
|
"workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}",
|
||||||
"onWebviewPanel:archify.preview"
|
"onWebviewPanel:archify.preview",
|
||||||
|
"workspaceContains:**/*.archify-registry",
|
||||||
|
"workspaceContains:**/*.archify-decision"
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"command": "archify.newDiagram",
|
"command": "archify.newDiagram",
|
||||||
"title": "New Diagram\u2026",
|
"title": "Archify | New Diagram…",
|
||||||
"category": "Archify",
|
|
||||||
"icon": "$(new-file)"
|
"icon": "$(new-file)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.newModel",
|
||||||
|
"title": "Archify | New Model…",
|
||||||
|
"icon": "$(new-folder)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.newDecision",
|
||||||
|
"title": "Archify | New Decision…",
|
||||||
|
"icon": "$(law)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.showPreview",
|
"command": "archify.showPreview",
|
||||||
"title": "Open Preview",
|
"title": "Archify | Open Preview",
|
||||||
"category": "Archify",
|
|
||||||
"icon": "$(open-preview)"
|
"icon": "$(open-preview)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.showPreviewToSide",
|
"command": "archify.showPreviewToSide",
|
||||||
"title": "Open Preview to the Side",
|
"title": "Archify | Open Preview to the Side",
|
||||||
"category": "Archify",
|
|
||||||
"icon": "$(open-preview)"
|
"icon": "$(open-preview)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.openHtmlViewer",
|
"command": "archify.openHtmlViewer",
|
||||||
"title": "Open in Archify Viewer",
|
"title": "Archify | Open in HTML Viewer",
|
||||||
"category": "Archify",
|
|
||||||
"icon": "$(open-preview)"
|
"icon": "$(open-preview)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.renderToHtml",
|
"command": "archify.renderToHtml",
|
||||||
"title": "Render to HTML File\u2026",
|
"title": "Archify | Render to HTML File…",
|
||||||
"category": "Archify",
|
|
||||||
"icon": "$(file-code)"
|
"icon": "$(file-code)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.openInBrowser",
|
"command": "archify.openInBrowser",
|
||||||
"title": "Open in Browser",
|
"title": "Archify | Open in Browser",
|
||||||
"category": "Archify",
|
|
||||||
"icon": "$(globe)"
|
"icon": "$(globe)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.refreshPreview",
|
"command": "archify.refreshPreview",
|
||||||
"title": "Refresh Preview",
|
"title": "Archify | Refresh Preview",
|
||||||
"category": "Archify",
|
|
||||||
"icon": "$(refresh)"
|
"icon": "$(refresh)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.showSource",
|
"command": "archify.showSource",
|
||||||
"title": "Show Source",
|
"title": "Archify | Show Source",
|
||||||
"category": "Archify",
|
|
||||||
"icon": "$(go-to-file)"
|
"icon": "$(go-to-file)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.openModel",
|
||||||
|
"title": "Archify | Open Model",
|
||||||
|
"icon": "$(symbol-structure)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
@@ -115,7 +133,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.showSource",
|
"command": "archify.showSource",
|
||||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'"
|
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.openModel",
|
||||||
|
"when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor/title": [
|
"editor/title": [
|
||||||
@@ -136,7 +158,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.showSource",
|
"command": "archify.showSource",
|
||||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram'",
|
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'",
|
||||||
"group": "navigation"
|
"group": "navigation"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -148,41 +170,69 @@
|
|||||||
"command": "archify.renderToHtml",
|
"command": "archify.renderToHtml",
|
||||||
"when": "archify.isDiagramSource || archifyPreviewFocus",
|
"when": "archify.isDiagramSource || archifyPreviewFocus",
|
||||||
"group": "1_archify"
|
"group": "1_archify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.openModel",
|
||||||
|
"when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision') && !notebookEditorFocused",
|
||||||
|
"group": "navigation@60"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"explorer/context": [
|
"explorer/context": [
|
||||||
{
|
{
|
||||||
"command": "archify.newDiagram",
|
"command": "archify.newDiagram",
|
||||||
"when": "explorerResourceIsFolder",
|
"when": "explorerResourceIsFolder",
|
||||||
"group": "navigation@10"
|
"group": "zzz_archify@1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.newModel",
|
||||||
|
"when": "explorerResourceIsFolder",
|
||||||
|
"group": "zzz_archify@2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.newDecision",
|
||||||
|
"when": "explorerResourceIsFolder",
|
||||||
|
"group": "zzz_archify@2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.showPreview",
|
"command": "archify.showPreview",
|
||||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
"when": "resourceFilename =~ /(\\.archify|\\.archify-registry|\\.archify-decision|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||||
"group": "navigation@20"
|
"group": "zzz_archify@3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.renderToHtml",
|
"command": "archify.renderToHtml",
|
||||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||||
"group": "navigation@21"
|
"group": "zzz_archify@4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "archify.openHtmlViewer",
|
"command": "archify.openHtmlViewer",
|
||||||
"when": "resourceExtname == .html",
|
"when": "resourceExtname == .html",
|
||||||
"group": "navigation@20"
|
"group": "zzz_archify@3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.openModel",
|
||||||
|
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
|
||||||
|
"group": "zzz_archify@5"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor/title/context": [
|
"editor/title/context": [
|
||||||
{
|
{
|
||||||
"command": "archify.showPreview",
|
"command": "archify.showPreview",
|
||||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||||
"group": "1_open"
|
"group": "zzz_archify@1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"file/newFile": [
|
"file/newFile": [
|
||||||
{
|
{
|
||||||
"command": "archify.newDiagram",
|
"command": "archify.newDiagram",
|
||||||
"group": "file"
|
"group": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.newModel",
|
||||||
|
"group": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "archify.newDecision",
|
||||||
|
"group": "file"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -211,9 +261,29 @@
|
|||||||
],
|
],
|
||||||
"priority": "default"
|
"priority": "default"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"viewType": "archify.registry",
|
||||||
|
"displayName": "Archify Registry",
|
||||||
|
"selector": [
|
||||||
|
{
|
||||||
|
"filenamePattern": "*.archify-registry"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"priority": "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"viewType": "archify.decision",
|
||||||
|
"displayName": "Archify Decision",
|
||||||
|
"selector": [
|
||||||
|
{
|
||||||
|
"filenamePattern": "*.archify-decision"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"priority": "default"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"viewType": "archify.htmlViewer",
|
"viewType": "archify.htmlViewer",
|
||||||
"displayName": "Archify Diagram Viewer",
|
"displayName": "Archify HTML Viewer",
|
||||||
"selector": [
|
"selector": [
|
||||||
{
|
{
|
||||||
"filenamePattern": "*.html"
|
"filenamePattern": "*.html"
|
||||||
@@ -264,6 +334,19 @@
|
|||||||
"default": "vscode",
|
"default": "vscode",
|
||||||
"description": "Theme used when showing Archify diagrams."
|
"description": "Theme used when showing Archify diagrams."
|
||||||
},
|
},
|
||||||
|
"archify.defaultView": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"2d",
|
||||||
|
"3d"
|
||||||
|
],
|
||||||
|
"enumDescriptions": [
|
||||||
|
"Open diagrams as Archify draws them.",
|
||||||
|
"Open diagrams in the 3D view. The 3D button in the diagram toolbar switches back."
|
||||||
|
],
|
||||||
|
"default": "2d",
|
||||||
|
"description": "How diagrams open. Each open diagram remembers its own choice."
|
||||||
|
},
|
||||||
"archify.preview.liveUpdate": {
|
"archify.preview.liveUpdate": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"default": true,
|
||||||
@@ -324,7 +407,9 @@
|
|||||||
{
|
{
|
||||||
"id": "yaml",
|
"id": "yaml",
|
||||||
"extensions": [
|
"extensions": [
|
||||||
".archify"
|
".archify",
|
||||||
|
".archify-registry",
|
||||||
|
".archify-decision"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -332,6 +417,14 @@
|
|||||||
{
|
{
|
||||||
"fileMatch": "*.archify",
|
"fileMatch": "*.archify",
|
||||||
"url": "./schemas/archify.schema.json"
|
"url": "./schemas/archify.schema.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fileMatch": "*.archify-registry",
|
||||||
|
"url": "./schemas/archify-registry.schema.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fileMatch": "*.archify-decision",
|
||||||
|
"url": "./schemas/archify-decision.schema.json"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -347,6 +440,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/three": "^0.186.0",
|
||||||
"@types/vscode": "~1.100.0",
|
"@types/vscode": "~1.100.0",
|
||||||
"@vscode/test-electron": "^3.1.0",
|
"@vscode/test-electron": "^3.1.0",
|
||||||
"@vscode/vsce": "^3.0.0",
|
"@vscode/vsce": "^3.0.0",
|
||||||
@@ -360,6 +454,7 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme",
|
"homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"three": "^0.186.1",
|
||||||
"yaml": "^2.9.1"
|
"yaml": "^2.9.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "Archify architecture decision",
|
||||||
|
"description": "One architecture decision of a model. Diagrams point at it with `decisions: [<id>]`.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"decision"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"decision": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What was decided."
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The situation and what forced a decision."
|
||||||
|
},
|
||||||
|
"consequences": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What becomes easier, harder, or must change."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "Archify model registry",
|
||||||
|
"description": "Shared elements of a model: one name for one thing. Diagrams refer to an element with `ref: <id>`.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"elements"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"elements": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Element id → element. Ids are unique across all registry files of the model.",
|
||||||
|
"propertyNames": {
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$"
|
||||||
|
},
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"label"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"frontend",
|
||||||
|
"backend",
|
||||||
|
"database",
|
||||||
|
"cloud",
|
||||||
|
"security",
|
||||||
|
"messagebus",
|
||||||
|
"external"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"sublabel": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"description": "Decorative corner icon. Omit the icon field for the type default; none hides it. Does not change type, color, or legend grouping.",
|
||||||
|
"enum": [
|
||||||
|
"calendar",
|
||||||
|
"clock",
|
||||||
|
"person",
|
||||||
|
"briefcase",
|
||||||
|
"flag",
|
||||||
|
"moon",
|
||||||
|
"frontend",
|
||||||
|
"backend",
|
||||||
|
"database",
|
||||||
|
"cloud",
|
||||||
|
"security",
|
||||||
|
"messagebus",
|
||||||
|
"external",
|
||||||
|
"start",
|
||||||
|
"active",
|
||||||
|
"waiting",
|
||||||
|
"success",
|
||||||
|
"failure",
|
||||||
|
"neutral",
|
||||||
|
"none"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"brand": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 2048,
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 80,
|
||||||
|
"pattern": "^[^\\r\\n]+$"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "^https?://"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"url",
|
||||||
|
"sha256"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 8,
|
||||||
|
"maxLength": 2048,
|
||||||
|
"pattern": "^https?://"
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-f0-9]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 3,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 240
|
||||||
|
},
|
||||||
|
"line": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"end_line": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 48
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The one description of this thing; shown on hover and on the model page."
|
||||||
|
},
|
||||||
|
"diagram": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,11 +158,6 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": [
|
|
||||||
"id",
|
|
||||||
"type",
|
|
||||||
"label"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
"$ref": "common.schema.json#/$defs/id"
|
"$ref": "common.schema.json#/$defs/id"
|
||||||
@@ -215,8 +210,39 @@
|
|||||||
"items": false,
|
"items": false,
|
||||||
"minItems": 2,
|
"minItems": 2,
|
||||||
"maxItems": 2
|
"maxItems": 2
|
||||||
|
},
|
||||||
|
"ref": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||||
|
"description": "A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds pos/size and may override label, sublabel and tag."
|
||||||
|
},
|
||||||
|
"diagram": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it."
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"required": [
|
||||||
|
"ref"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"type",
|
||||||
|
"label"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"boundaries": {
|
"boundaries": {
|
||||||
@@ -314,12 +340,53 @@
|
|||||||
},
|
},
|
||||||
"width": {
|
"width": {
|
||||||
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"$ref": "common.schema.json#/$defs/cards"
|
"$ref": "common.schema.json#/$defs/cards"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||||
|
"description": "Stable id of the model."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Name shown on the model page."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What the model covers; shown on the model page."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +180,14 @@
|
|||||||
},
|
},
|
||||||
"yOffset": {
|
"yOffset": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,12 +264,53 @@
|
|||||||
},
|
},
|
||||||
"width": {
|
"width": {
|
||||||
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"$ref": "common.schema.json#/$defs/cards"
|
"$ref": "common.schema.json#/$defs/cards"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||||
|
"description": "Stable id of the model."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Name shown on the model page."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What the model covers; shown on the model page."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,6 +208,14 @@
|
|||||||
},
|
},
|
||||||
"yOffset": {
|
"yOffset": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,12 +296,53 @@
|
|||||||
},
|
},
|
||||||
"width": {
|
"width": {
|
||||||
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"$ref": "common.schema.json#/$defs/cards"
|
"$ref": "common.schema.json#/$defs/cards"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||||
|
"description": "Stable id of the model."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Name shown on the model page."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What the model covers; shown on the model page."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +144,14 @@
|
|||||||
},
|
},
|
||||||
"sources": {
|
"sources": {
|
||||||
"$ref": "common.schema.json#/$defs/sourceReferences"
|
"$ref": "common.schema.json#/$defs/sourceReferences"
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,6 +221,14 @@
|
|||||||
},
|
},
|
||||||
"note": {
|
"note": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,6 +261,39 @@
|
|||||||
},
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"$ref": "common.schema.json#/$defs/cards"
|
"$ref": "common.schema.json#/$defs/cards"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||||
|
"description": "Stable id of the model."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Name shown on the model page."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What the model covers; shown on the model page."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,6 +331,14 @@
|
|||||||
},
|
},
|
||||||
"yOffset": {
|
"yOffset": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -419,12 +427,53 @@
|
|||||||
"width": {
|
"width": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"minimum": 0.5
|
"minimum": 0.5
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cards": {
|
"cards": {
|
||||||
"$ref": "common.schema.json#/$defs/cards"
|
"$ref": "common.schema.json#/$defs/cards"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Only in main.archify: the entry point of a model (a folder of diagrams that belong together).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
|
||||||
|
"description": "Stable id of the model."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Name shown on the model page."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What the model covers; shown on the model page."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"$defs": {
|
"$defs": {
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
#!/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.' };
|
||||||
|
const DECISIONS = { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram.' };
|
||||||
|
const NODE_COLLECTIONS = ['components', 'nodes', 'participants', 'states'];
|
||||||
|
const EDGE_COLLECTIONS = ['connections', 'edges', 'flows', 'transitions', 'messages'];
|
||||||
|
|
||||||
|
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;
|
||||||
|
schema.properties.decisions = DECISIONS;
|
||||||
|
for (const key of [...NODE_COLLECTIONS, ...EDGE_COLLECTIONS]) {
|
||||||
|
const items = schema.properties[key]?.items;
|
||||||
|
if (items?.properties) items.properties.decisions = DECISIONS;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|
||||||
|
const decision = {
|
||||||
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||||
|
title: 'Archify architecture decision',
|
||||||
|
description: 'One architecture decision of a model. Diagrams point at it with `decisions: [<id>]`.',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['decision'],
|
||||||
|
properties: {
|
||||||
|
schema_version: { const: 1 },
|
||||||
|
decision: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['id', 'title'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', minLength: 1, description: 'Unique in the model. Any short string.' },
|
||||||
|
title: { type: 'string', minLength: 1 },
|
||||||
|
status: { enum: ['proposed', 'accepted', 'superseded', 'rejected'], description: 'A decision another one supersedes shows as superseded whatever this says.' },
|
||||||
|
date: { type: 'string', description: 'YYYY-MM-DD.' },
|
||||||
|
elements: { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Registry element ids the decision is about.' },
|
||||||
|
diagrams: { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Diagram paths (from the model root) the decision names.' },
|
||||||
|
supersedes: { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Ids of older decisions this one replaces.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
context: { type: 'string', description: 'The situation and what forced a decision.' },
|
||||||
|
decision: { type: 'string', description: 'What was decided.' },
|
||||||
|
consequences: { type: 'string', description: 'What becomes easier, harder, or must change.' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
write('archify-decision.schema.json', decision);
|
||||||
|
console.log('Patched editor schemas: model, ref, diagram, decisions; wrote archify-registry.schema.json and archify-decision.schema.json');
|
||||||
@@ -92,3 +92,4 @@ fs.writeFileSync(
|
|||||||
`${JSON.stringify({ upstream: 'https://github.com/tt-a1i/archify', version, revision }, null, 2)}\n`,
|
`${JSON.stringify({ upstream: 'https://github.com/tt-a1i/archify', version, revision }, null, 2)}\n`,
|
||||||
);
|
);
|
||||||
console.log(`Synced archify ${version} (${revision.slice(0, 12)}) from ${source}`);
|
console.log(`Synced archify ${version} (${revision.slice(0, 12)}) from ${source}`);
|
||||||
|
await import('./patch-schemas.mjs');
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { DiagramSource, DiagramType, hasSourceEvidence, stripSourceEvidence } from './detect';
|
import { DiagramSource, DiagramType } from '../sources/detect';
|
||||||
|
import { hasSourceEvidence, stripSourceEvidence } from '../sources/evidence';
|
||||||
|
|
||||||
export interface RuntimeOptions {
|
export interface RuntimeOptions {
|
||||||
/** Absolute path to vendor/archify/bin/archify.mjs. */
|
/** Absolute path to vendor/archify/bin/archify.mjs. */
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { RuntimeOptions } from './renderer';
|
import { log } from '../util/log';
|
||||||
import { ViewerTheme } from './webviewHtml';
|
import { RuntimeOptions } from './cli';
|
||||||
|
|
||||||
let output: vscode.LogOutputChannel | undefined;
|
|
||||||
|
|
||||||
export function log(): vscode.LogOutputChannel {
|
|
||||||
output ??= vscode.window.createOutputChannel('Archify', { log: true });
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
const gitRoots = new Map<string, Promise<string | undefined>>();
|
const gitRoots = new Map<string, Promise<string | undefined>>();
|
||||||
|
|
||||||
@@ -60,18 +53,3 @@ export async function runtimeFor(context: vscode.ExtensionContext, uri: vscode.U
|
|||||||
export function evidenceMode(uri: vscode.Uri | undefined): 'fallback' | 'strict' {
|
export function evidenceMode(uri: vscode.Uri | undefined): 'fallback' | 'strict' {
|
||||||
return vscode.workspace.getConfiguration('archify', uri).get<'fallback' | 'strict'>('sourceEvidence', 'fallback');
|
return vscode.workspace.getConfiguration('archify', uri).get<'fallback' | 'strict'>('sourceEvidence', 'fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function viewerTheme(): ViewerTheme {
|
|
||||||
const setting = vscode.workspace.getConfiguration('archify').get<string>('theme', 'vscode');
|
|
||||||
if (setting === 'dark' || setting === 'light') return setting;
|
|
||||||
if (setting === 'diagram') return undefined;
|
|
||||||
const kind = vscode.window.activeColorTheme.kind;
|
|
||||||
return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? 'light' : 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Current text of a document: the open editor buffer, else the file on disk. */
|
|
||||||
export async function readText(uri: vscode.Uri): Promise<string> {
|
|
||||||
const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString());
|
|
||||||
if (open) return open.getText();
|
|
||||||
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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';
|
||||||
|
import { DecisionViewProvider } from '../views/decisionView';
|
||||||
|
|
||||||
|
/** What command handlers share: the extension context and the open views. */
|
||||||
|
export interface CommandContext {
|
||||||
|
extension: vscode.ExtensionContext;
|
||||||
|
views: DiagramViewManager;
|
||||||
|
htmlViewer: HtmlViewerProvider;
|
||||||
|
registry: RegistryViewProvider;
|
||||||
|
decision: DecisionViewProvider;
|
||||||
|
models: ModelService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHtmlUri(uri: vscode.Uri): boolean {
|
||||||
|
return /\.html?$/i.test(uri.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
|
||||||
|
export function targetUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
|
||||||
|
if (arg instanceof vscode.Uri) return arg;
|
||||||
|
return ctx.views.activePreview?.uri ?? ctx.registry.activeUri ?? ctx.decision.activeUri ?? ctx.htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
|
||||||
|
const uri = targetUri(ctx, arg);
|
||||||
|
if (!uri) vscode.window.showWarningMessage('Archify: open a .archify diagram first.');
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
|
||||||
|
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
|
||||||
|
|
||||||
|
export async function exists(uri: vscode.Uri): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await vscode.workspace.fs.stat(uri);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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, 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) ||
|
||||||
|
(JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)))),
|
||||||
|
);
|
||||||
|
const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
|
||||||
|
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
|
||||||
|
void vscode.commands.executeCommand('setContext', 'archify.isArchifyHtml', html);
|
||||||
|
};
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.window.onDidChangeActiveTextEditor(updateContext),
|
||||||
|
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||||
|
if (event.document !== vscode.window.activeTextEditor?.document) return;
|
||||||
|
clearTimeout(contextTimer);
|
||||||
|
contextTimer = setTimeout(updateContext, 300);
|
||||||
|
}),
|
||||||
|
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||||
|
if (event.affectsConfiguration('archify.repoRoot')) clearGitRootCache();
|
||||||
|
}),
|
||||||
|
{ dispose: () => clearTimeout(contextTimer) },
|
||||||
|
);
|
||||||
|
updateContext();
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { MODEL_EXAMPLES, mainFromExample } from '../language/examples';
|
||||||
|
import { diagramName, invalidNameReason } from '../language/templates';
|
||||||
|
import { DECISION_EXT, slugify } from '../model/decisions';
|
||||||
|
import { MAIN_FILE } from '../model/format';
|
||||||
|
import { DECISION_VIEW_TYPE } from '../views/decisionView';
|
||||||
|
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 decision file in the clicked folder of a model and opens it.
|
||||||
|
vscode.commands.registerCommand('archify.newDecision', async (arg?: unknown) => {
|
||||||
|
const folder = await parentFolder(arg);
|
||||||
|
if (!folder) return;
|
||||||
|
const root = await ctx.models.rootOf(vscode.Uri.joinPath(folder, 'x'));
|
||||||
|
if (!root) {
|
||||||
|
vscode.window.showWarningMessage(`Archify: ${vscode.workspace.asRelativePath(folder, true)} is not inside a model (no ${MAIN_FILE} in it or a folder above it). Decisions belong to a model.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = await ctx.models.index(root);
|
||||||
|
const title = await vscode.window.showInputBox({
|
||||||
|
title: 'Archify | New Decision',
|
||||||
|
prompt: `Title of the decision (file goes into ${vscode.workspace.asRelativePath(folder, true)})`,
|
||||||
|
placeHolder: 'OpenSearch for product search',
|
||||||
|
validateInput: async (value) => {
|
||||||
|
if (!value.trim()) return 'Enter a title.';
|
||||||
|
const id = slugify(value);
|
||||||
|
if (index.decisions.decisions.has(id)) return `A decision with id "${id}" already exists in this model.`;
|
||||||
|
if (await exists(vscode.Uri.joinPath(folder, `${id}${DECISION_EXT}`))) return `${id}${DECISION_EXT} already exists.`;
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (title === undefined) return;
|
||||||
|
const id = slugify(title);
|
||||||
|
const target = vscode.Uri.joinPath(folder, `${id}${DECISION_EXT}`);
|
||||||
|
const template = await readText(vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', 'decision.archify-decision'));
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const text = template.replace('__ID__', id).replace('__TITLE__', JSON.stringify(title.trim())).replace('__DATE__', today);
|
||||||
|
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
|
||||||
|
await vscode.commands.executeCommand('vscode.openWith', target, DECISION_VIEW_TYPE);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
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';
|
||||||
|
import { CommandContext, exists } from './context';
|
||||||
|
|
||||||
|
/** Folder for a new diagram: the clicked folder (or a file's folder), else a workspace folder. */
|
||||||
|
async function newDiagramFolder(arg?: unknown): Promise<vscode.Uri | undefined> {
|
||||||
|
if (arg instanceof vscode.Uri) {
|
||||||
|
const stat = await vscode.workspace.fs.stat(arg);
|
||||||
|
return stat.type & vscode.FileType.Directory ? arg : vscode.Uri.joinPath(arg, '..');
|
||||||
|
}
|
||||||
|
const folders = vscode.workspace.workspaceFolders ?? [];
|
||||||
|
if (folders.length <= 1) return folders[0]?.uri;
|
||||||
|
return (await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Folder for the new diagram' }))?.uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newDiagramCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||||
|
return [
|
||||||
|
vscode.commands.registerCommand('archify.newDiagram', async (arg?: unknown) => {
|
||||||
|
const template = await vscode.window.showQuickPick(
|
||||||
|
TEMPLATES.map((item) => ({ label: item.label, description: item.description, template: item })),
|
||||||
|
{ title: 'Archify | New Diagram', placeHolder: 'Kind of diagram' },
|
||||||
|
);
|
||||||
|
if (!template) return;
|
||||||
|
const { file, defaultName, fixedName } = template.template;
|
||||||
|
|
||||||
|
const folder = await newDiagramFolder(arg);
|
||||||
|
let target: vscode.Uri | undefined;
|
||||||
|
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)}`,
|
||||||
|
value: defaultName,
|
||||||
|
validateInput: async (value) =>
|
||||||
|
invalidNameReason(value) ??
|
||||||
|
((await exists(vscode.Uri.joinPath(folder, `${diagramName(value)}.archify`))) ? `${diagramName(value)}.archify already exists.` : undefined),
|
||||||
|
});
|
||||||
|
if (name === undefined) return;
|
||||||
|
target = vscode.Uri.joinPath(folder, `${diagramName(name)}.archify`);
|
||||||
|
} else {
|
||||||
|
// No workspace: let the user choose where to save.
|
||||||
|
target = await vscode.window.showSaveDialog({
|
||||||
|
title: 'Archify | New Diagram',
|
||||||
|
filters: { 'Archify Diagram': ['archify'] },
|
||||||
|
saveLabel: 'Create',
|
||||||
|
});
|
||||||
|
if (!target) return;
|
||||||
|
if (!isArchifyFile(target.path)) target = target.with({ path: `${target.path}.archify` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateText = await readText(vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', file));
|
||||||
|
const text = 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);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { isEvidenceError, renderDiagram, renderToFile } from '../archify/cli';
|
||||||
|
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||||
|
import { 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';
|
||||||
|
|
||||||
|
export function renderCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||||
|
return [
|
||||||
|
vscode.commands.registerCommand('archify.renderToHtml', async (arg?: unknown) => {
|
||||||
|
const uri = sourceUri(ctx, arg);
|
||||||
|
if (!uri) return;
|
||||||
|
const text = await readText(uri);
|
||||||
|
const source = (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;
|
||||||
|
}
|
||||||
|
const meta = (source.diagram.meta ?? {}) as { output?: unknown };
|
||||||
|
const authored = typeof meta.output === 'string' && !/[\\/]/.test(meta.output) ? meta.output : undefined;
|
||||||
|
const fileName = authored ?? `${diagramBaseName(uri.path)}.html`;
|
||||||
|
const defaultUri = uri.scheme === 'file' ? vscode.Uri.joinPath(uri, '..', fileName) : undefined;
|
||||||
|
const target = await vscode.window.showSaveDialog({
|
||||||
|
defaultUri,
|
||||||
|
filters: { HTML: ['html'] },
|
||||||
|
saveLabel: 'Render',
|
||||||
|
});
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const runtime = await runtimeFor(ctx.extension, uri);
|
||||||
|
let result = await vscode.window.withProgress(
|
||||||
|
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
||||||
|
() => renderToFile(runtime, source, target.fsPath),
|
||||||
|
);
|
||||||
|
if (!result.ok && isEvidenceError(result.error)) {
|
||||||
|
const choice = await vscode.window.showWarningMessage(
|
||||||
|
'Archify could not verify the diagram\'s source evidence against a local checkout.',
|
||||||
|
{ modal: true, detail: result.error },
|
||||||
|
'Render Without Source Links',
|
||||||
|
);
|
||||||
|
if (choice) result = await renderToFile(runtime, source, target.fsPath, true);
|
||||||
|
else return;
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
log().error(result.error ?? 'render failed');
|
||||||
|
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const choice = await vscode.window.showInformationMessage(
|
||||||
|
`Rendered ${path.basename(target.fsPath)}`,
|
||||||
|
'Open in Viewer',
|
||||||
|
'Open in Browser',
|
||||||
|
);
|
||||||
|
if (choice === 'Open in Viewer') await openHtmlViewer(target);
|
||||||
|
if (choice === 'Open in Browser') await vscode.env.openExternal(target);
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand('archify.openInBrowser', async (arg?: unknown) => {
|
||||||
|
const uri = sourceUri(ctx, arg);
|
||||||
|
if (!uri) return;
|
||||||
|
if (isHtmlUri(uri)) {
|
||||||
|
await vscode.env.openExternal(uri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = await readText(uri);
|
||||||
|
const source = (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;
|
||||||
|
}
|
||||||
|
const runtime = await runtimeFor(ctx.extension, uri);
|
||||||
|
const result = await vscode.window.withProgress(
|
||||||
|
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
||||||
|
() => renderDiagram(runtime, source, evidenceMode(uri)),
|
||||||
|
);
|
||||||
|
if (!result.ok || !result.html) {
|
||||||
|
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = path.join(ctx.extension.globalStorageUri.fsPath, 'browser');
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
const file = path.join(dir, `${diagramBaseName(uri.path)}.html`);
|
||||||
|
await fs.writeFile(file, result.html, 'utf8');
|
||||||
|
await vscode.env.openExternal(vscode.Uri.file(file));
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { isDecisionFile } from '../model/decisions';
|
||||||
|
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 { DECISION_VIEW_TYPE } from '../views/decisionView';
|
||||||
|
import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context';
|
||||||
|
|
||||||
|
export function viewCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||||
|
return [
|
||||||
|
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
|
||||||
|
const uri = sourceUri(ctx, arg);
|
||||||
|
if (!uri) return;
|
||||||
|
if (isHtmlUri(uri)) return openHtmlViewer(uri);
|
||||||
|
if (isRegistryFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, REGISTRY_VIEW_TYPE);
|
||||||
|
if (isDecisionFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DECISION_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);
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand('archify.showPreviewToSide', (arg?: unknown) => {
|
||||||
|
const uri = sourceUri(ctx, arg);
|
||||||
|
if (!uri) return;
|
||||||
|
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside);
|
||||||
|
if (isArchifyFile(uri.path)) {
|
||||||
|
return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE, {
|
||||||
|
viewColumn: vscode.ViewColumn.Beside,
|
||||||
|
preserveFocus: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ctx.views.show(uri, vscode.ViewColumn.Beside, true);
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand('archify.openHtmlViewer', (arg?: unknown) => {
|
||||||
|
const uri = sourceUri(ctx, arg);
|
||||||
|
if (uri) return openHtmlViewer(uri);
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand('archify.refreshPreview', () => ctx.views.activePreview?.update(true)),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand('archify.showSource', async () => {
|
||||||
|
const preview = ctx.views.activePreview;
|
||||||
|
if (preview?.isEditor) {
|
||||||
|
// The diagram is the editor; open the YAML text beside it.
|
||||||
|
await vscode.commands.executeCommand('vscode.openWith', preview.uri, 'default', vscode.ViewColumn.Beside);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (preview) {
|
||||||
|
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
||||||
|
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = ctx.registry.activeUri ?? ctx.decision.activeUri;
|
||||||
|
if (page) {
|
||||||
|
await vscode.commands.executeCommand('vscode.openWith', page, 'default', vscode.ViewColumn.Beside);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = ctx.htmlViewer.activeUri;
|
||||||
|
if (html) await vscode.commands.executeCommand('vscode.openWith', html, 'default');
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
+45
-238
@@ -1,247 +1,54 @@
|
|||||||
import fs from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { diagramBaseName, isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect';
|
import { CommandContext } from './commands/context';
|
||||||
import { DiagnosticsController } from './diagnostics';
|
import { registerContextKeys } from './commands/contextKeys';
|
||||||
import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
|
import { newDiagramCommands } from './commands/newDiagram';
|
||||||
import { DIAGRAM_EDITOR_VIEW_TYPE, PreviewManager } from './preview';
|
import { renderCommands } from './commands/renderCommands';
|
||||||
import { isEvidenceError, renderDiagram, renderToFile } from './renderer';
|
import { viewCommands } from './commands/viewCommands';
|
||||||
import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime';
|
import { modelCommands } from './commands/modelCommands';
|
||||||
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES } from './templates';
|
import { BRAND_COMPLETION_SELECTOR, BrandCompletionProvider } from './language/brandCompletion';
|
||||||
|
import { REF_SELECTOR, RefLanguage } from './language/refLanguage';
|
||||||
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
import { ModelService } from './model/service';
|
||||||
|
import { DiagnosticsController } from './language/diagnostics';
|
||||||
function isHtmlUri(uri: vscode.Uri): boolean {
|
import { log } from './util/log';
|
||||||
return /\.html?$/i.test(uri.path);
|
import { HtmlViewerProvider } from './views/htmlViewer';
|
||||||
}
|
import { DiagramViewManager } from './views/diagramView';
|
||||||
|
import { RegistryViewProvider } from './views/registryView';
|
||||||
|
import { DecisionViewProvider } from './views/decisionView';
|
||||||
|
import { DECISION_SELECTOR, DecisionLanguage } from './language/decisionLanguage';
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): void {
|
export function activate(context: vscode.ExtensionContext): void {
|
||||||
const previews = new PreviewManager(context);
|
const models = new ModelService();
|
||||||
const htmlViewer = new HtmlViewerProvider();
|
const ctx: CommandContext = {
|
||||||
context.subscriptions.push(previews, htmlViewer, new DiagnosticsController(context), log());
|
extension: context,
|
||||||
|
views: new DiagramViewManager(context, models),
|
||||||
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
|
htmlViewer: new HtmlViewerProvider(context.extensionUri),
|
||||||
function targetUri(arg?: unknown): vscode.Uri | undefined {
|
registry: new RegistryViewProvider(models),
|
||||||
if (arg instanceof vscode.Uri) return arg;
|
decision: new DecisionViewProvider(models),
|
||||||
return previews.activePreview?.uri ?? htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
|
models,
|
||||||
}
|
|
||||||
|
|
||||||
function sourceUri(arg?: unknown): vscode.Uri | undefined {
|
|
||||||
const uri = targetUri(arg);
|
|
||||||
if (!uri) vscode.window.showWarningMessage('Archify: open a diagram JSON source first.');
|
|
||||||
return uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Context keys for editor title buttons and keybindings.
|
|
||||||
let contextTimer: NodeJS.Timeout | undefined;
|
|
||||||
const updateContext = () => {
|
|
||||||
const doc = vscode.window.activeTextEditor?.document;
|
|
||||||
const diagram = Boolean(
|
|
||||||
doc &&
|
|
||||||
(isArchifyFile(doc.fileName) ||
|
|
||||||
(JSON_LANGUAGES.has(doc.languageId) && (isDiagramFileName(doc.fileName) || readDiagramSource(doc.getText(), doc.fileName)))),
|
|
||||||
);
|
|
||||||
const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
|
|
||||||
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
|
|
||||||
void vscode.commands.executeCommand('setContext', 'archify.isArchifyHtml', html);
|
|
||||||
};
|
};
|
||||||
|
const refs = new RefLanguage(models);
|
||||||
|
const decisions = new DecisionLanguage(models);
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.window.onDidChangeActiveTextEditor(updateContext),
|
models,
|
||||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
ctx.views,
|
||||||
if (event.document !== vscode.window.activeTextEditor?.document) return;
|
ctx.htmlViewer,
|
||||||
clearTimeout(contextTimer);
|
ctx.registry,
|
||||||
contextTimer = setTimeout(updateContext, 300);
|
ctx.decision,
|
||||||
}),
|
new DiagnosticsController(context, models),
|
||||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
log(),
|
||||||
if (event.affectsConfiguration('archify.repoRoot')) clearGitRootCache();
|
vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'),
|
||||||
}),
|
vscode.languages.registerCompletionItemProvider(REF_SELECTOR, refs, ' ', '"'),
|
||||||
{ dispose: () => clearTimeout(contextTimer) },
|
vscode.languages.registerHoverProvider(REF_SELECTOR, refs),
|
||||||
);
|
vscode.languages.registerDefinitionProvider(REF_SELECTOR, refs),
|
||||||
updateContext();
|
vscode.languages.registerCompletionItemProvider(DECISION_SELECTOR, decisions, ' ', '[', ',', '"'),
|
||||||
|
vscode.languages.registerHoverProvider(DECISION_SELECTOR, decisions),
|
||||||
const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
|
vscode.languages.registerDefinitionProvider(DECISION_SELECTOR, decisions),
|
||||||
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
|
...viewCommands(ctx),
|
||||||
|
...newDiagramCommands(ctx),
|
||||||
const exists = async (uri: vscode.Uri) => {
|
...renderCommands(ctx),
|
||||||
try {
|
...modelCommands(ctx),
|
||||||
await vscode.workspace.fs.stat(uri);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Folder for a new diagram: the clicked folder (or a file's folder), else a workspace folder. */
|
|
||||||
async function newDiagramFolder(arg?: unknown): Promise<vscode.Uri | undefined> {
|
|
||||||
if (arg instanceof vscode.Uri) {
|
|
||||||
const stat = await vscode.workspace.fs.stat(arg);
|
|
||||||
return stat.type & vscode.FileType.Directory ? arg : vscode.Uri.joinPath(arg, '..');
|
|
||||||
}
|
|
||||||
const folders = vscode.workspace.workspaceFolders ?? [];
|
|
||||||
if (folders.length <= 1) return folders[0]?.uri;
|
|
||||||
return (await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Folder for the new diagram' }))?.uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.subscriptions.push(
|
|
||||||
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
|
|
||||||
const uri = sourceUri(arg);
|
|
||||||
if (!uri) return;
|
|
||||||
if (isHtmlUri(uri)) return openHtmlViewer(uri);
|
|
||||||
if (isArchifyFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE);
|
|
||||||
previews.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
|
|
||||||
}),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.showPreviewToSide', (arg?: unknown) => {
|
|
||||||
const uri = sourceUri(arg);
|
|
||||||
if (!uri) return;
|
|
||||||
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside);
|
|
||||||
if (isArchifyFile(uri.path)) {
|
|
||||||
return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE, {
|
|
||||||
viewColumn: vscode.ViewColumn.Beside,
|
|
||||||
preserveFocus: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
previews.show(uri, vscode.ViewColumn.Beside, true);
|
|
||||||
}),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.openHtmlViewer', (arg?: unknown) => {
|
|
||||||
const uri = sourceUri(arg);
|
|
||||||
if (uri) return openHtmlViewer(uri);
|
|
||||||
}),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.newDiagram', async (arg?: unknown) => {
|
|
||||||
const template = await vscode.window.showQuickPick(
|
|
||||||
TEMPLATES.map((item) => ({ label: item.label, description: item.description, template: item })),
|
|
||||||
{ title: 'New Archify Diagram', placeHolder: 'Kind of diagram' },
|
|
||||||
);
|
|
||||||
if (!template) return;
|
|
||||||
const { file, defaultName } = template.template;
|
|
||||||
|
|
||||||
const folder = await newDiagramFolder(arg);
|
|
||||||
let target: vscode.Uri | undefined;
|
|
||||||
if (folder) {
|
|
||||||
const name = await vscode.window.showInputBox({
|
|
||||||
title: 'New Archify Diagram',
|
|
||||||
prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}`,
|
|
||||||
value: defaultName,
|
|
||||||
validateInput: async (value) =>
|
|
||||||
invalidNameReason(value) ??
|
|
||||||
((await exists(vscode.Uri.joinPath(folder, `${diagramName(value)}.archify`))) ? `${diagramName(value)}.archify already exists.` : undefined),
|
|
||||||
});
|
|
||||||
if (name === undefined) return;
|
|
||||||
target = vscode.Uri.joinPath(folder, `${diagramName(name)}.archify`);
|
|
||||||
} else {
|
|
||||||
// No workspace: let the user choose where to save.
|
|
||||||
target = await vscode.window.showSaveDialog({
|
|
||||||
title: 'New Archify Diagram',
|
|
||||||
filters: { 'Archify Diagram': ['archify'] },
|
|
||||||
saveLabel: 'Create',
|
|
||||||
});
|
|
||||||
if (!target) return;
|
|
||||||
if (!isArchifyFile(target.path)) target = target.with({ path: `${target.path}.archify` });
|
|
||||||
}
|
|
||||||
|
|
||||||
const templateText = await readText(vscode.Uri.joinPath(context.extensionUri, 'templates', file));
|
|
||||||
const text = diagramFromTemplate(templateText, diagramBaseName(target.path));
|
|
||||||
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
|
|
||||||
await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE);
|
|
||||||
}),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.refreshPreview', () => previews.activePreview?.update(true)),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.showSource', async () => {
|
|
||||||
const preview = previews.activePreview;
|
|
||||||
if (preview?.isEditor) {
|
|
||||||
// The diagram is the editor; open the YAML text beside it.
|
|
||||||
await vscode.commands.executeCommand('vscode.openWith', preview.uri, 'default', vscode.ViewColumn.Beside);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (preview) {
|
|
||||||
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
|
||||||
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const html = htmlViewer.activeUri;
|
|
||||||
if (html) await vscode.commands.executeCommand('vscode.openWith', html, 'default');
|
|
||||||
}),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.renderToHtml', async (arg?: unknown) => {
|
|
||||||
const uri = sourceUri(arg);
|
|
||||||
if (!uri) return;
|
|
||||||
const text = await readText(uri);
|
|
||||||
const source = readDiagramSource(text, uri.path);
|
|
||||||
if (!source) {
|
|
||||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const meta = (source.diagram.meta ?? {}) as { output?: unknown };
|
|
||||||
const authored = typeof meta.output === 'string' && !/[\\/]/.test(meta.output) ? meta.output : undefined;
|
|
||||||
const fileName = authored ?? `${diagramBaseName(uri.path)}.html`;
|
|
||||||
const defaultUri = uri.scheme === 'file' ? vscode.Uri.joinPath(uri, '..', fileName) : undefined;
|
|
||||||
const target = await vscode.window.showSaveDialog({
|
|
||||||
defaultUri,
|
|
||||||
filters: { HTML: ['html'] },
|
|
||||||
saveLabel: 'Render',
|
|
||||||
});
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const runtime = await runtimeFor(context, uri);
|
|
||||||
let result = await vscode.window.withProgress(
|
|
||||||
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
|
||||||
() => renderToFile(runtime, source, target.fsPath),
|
|
||||||
);
|
|
||||||
if (!result.ok && isEvidenceError(result.error)) {
|
|
||||||
const choice = await vscode.window.showWarningMessage(
|
|
||||||
'Archify could not verify the diagram\'s source evidence against a local checkout.',
|
|
||||||
{ modal: true, detail: result.error },
|
|
||||||
'Render Without Source Links',
|
|
||||||
);
|
|
||||||
if (choice) result = await renderToFile(runtime, source, target.fsPath, true);
|
|
||||||
else return;
|
|
||||||
}
|
|
||||||
if (!result.ok) {
|
|
||||||
log().error(result.error ?? 'render failed');
|
|
||||||
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const choice = await vscode.window.showInformationMessage(
|
|
||||||
`Rendered ${path.basename(target.fsPath)}`,
|
|
||||||
'Open in Viewer',
|
|
||||||
'Open in Browser',
|
|
||||||
);
|
|
||||||
if (choice === 'Open in Viewer') await openHtmlViewer(target);
|
|
||||||
if (choice === 'Open in Browser') await vscode.env.openExternal(target);
|
|
||||||
}),
|
|
||||||
|
|
||||||
vscode.commands.registerCommand('archify.openInBrowser', async (arg?: unknown) => {
|
|
||||||
const uri = sourceUri(arg);
|
|
||||||
if (!uri) return;
|
|
||||||
if (isHtmlUri(uri)) {
|
|
||||||
await vscode.env.openExternal(uri);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const text = await readText(uri);
|
|
||||||
const source = readDiagramSource(text, uri.path);
|
|
||||||
if (!source) {
|
|
||||||
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} has no "diagram_type" (architecture, workflow, sequence, dataflow or lifecycle).`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const runtime = await runtimeFor(context, uri);
|
|
||||||
const result = await vscode.window.withProgress(
|
|
||||||
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
|
|
||||||
() => renderDiagram(runtime, source, evidenceMode(uri)),
|
|
||||||
);
|
|
||||||
if (!result.ok || !result.html) {
|
|
||||||
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dir = path.join(context.globalStorageUri.fsPath, 'browser');
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
|
||||||
const file = path.join(dir, `${diagramBaseName(uri.path)}.html`);
|
|
||||||
await fs.writeFile(file, result.html, 'utf8');
|
|
||||||
await vscode.env.openExternal(vscode.Uri.file(file));
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
registerContextKeys(context, models);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deactivate(): void {}
|
export function deactivate(): void {}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { isArchifyFile } from '../sources/detect';
|
||||||
|
import { log } from '../util/log';
|
||||||
|
import { Brand, brandValueAt, loadBrandCatalog } from './brands';
|
||||||
|
|
||||||
|
const JSON_SOURCES = '**/*.{architecture,workflow,sequence,dataflow,lifecycle}.json';
|
||||||
|
|
||||||
|
export const BRAND_COMPLETION_SELECTOR: vscode.DocumentSelector = [
|
||||||
|
{ pattern: '**/*.archify' },
|
||||||
|
{ language: 'json', pattern: JSON_SOURCES },
|
||||||
|
{ language: 'jsonc', pattern: JSON_SOURCES },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Suggests Archify's built-in brand IDs for `brand` values. */
|
||||||
|
export class BrandCompletionProvider implements vscode.CompletionItemProvider {
|
||||||
|
private brands: Brand[] | undefined;
|
||||||
|
|
||||||
|
constructor(private readonly extensionPath: string) {}
|
||||||
|
|
||||||
|
private catalog(): Brand[] {
|
||||||
|
if (!this.brands) {
|
||||||
|
try {
|
||||||
|
this.brands = loadBrandCatalog(path.join(this.extensionPath, 'vendor', 'archify', 'brand-marks', 'catalog.json'));
|
||||||
|
} catch (error) {
|
||||||
|
log().error(`Could not read the Archify brand catalog: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
this.brands = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.brands;
|
||||||
|
}
|
||||||
|
|
||||||
|
provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): vscode.CompletionItem[] | undefined {
|
||||||
|
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||||
|
const value = brandValueAt(linePrefix, isArchifyFile(document.fileName) ? 'yaml' : 'json');
|
||||||
|
if (!value) return undefined;
|
||||||
|
const range = new vscode.Range(position.translate(0, -value.typed.length), position);
|
||||||
|
return this.catalog().map((brand) => {
|
||||||
|
const item = new vscode.CompletionItem({ label: brand.id, description: brand.title }, vscode.CompletionItemKind.Value);
|
||||||
|
item.detail = `${brand.title} · ${brand.category}`;
|
||||||
|
if (brand.aliases.length) item.documentation = `Also matches: ${brand.aliases.join(', ')}`;
|
||||||
|
// Typing an alias or the product name finds the brand too.
|
||||||
|
item.filterText = [brand.id, ...brand.aliases, brand.title].join(' ');
|
||||||
|
item.range = range;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
export interface Brand {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
aliases: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archify's built-in brand marks, read from brand-marks/catalog.json. */
|
||||||
|
export function loadBrandCatalog(file: string): Brand[] {
|
||||||
|
const catalog = JSON.parse(fs.readFileSync(file, 'utf8')) as { marks?: Partial<Brand>[] };
|
||||||
|
return (catalog.marks ?? [])
|
||||||
|
.filter((mark): mark is Partial<Brand> & { id: string } => typeof mark.id === 'string')
|
||||||
|
.map((mark) => ({ id: mark.id, title: mark.title ?? mark.id, category: mark.category ?? '', aliases: mark.aliases ?? [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandValue {
|
||||||
|
/** What has been typed of the value so far. */
|
||||||
|
typed: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the text before the cursor ends inside a `brand` value:
|
||||||
|
* `brand: post` or `{ id: db, brand: "post` in YAML, `"brand": "post` in JSON.
|
||||||
|
*/
|
||||||
|
export function brandValueAt(linePrefix: string, format: 'yaml' | 'json'): BrandValue | undefined {
|
||||||
|
const match =
|
||||||
|
format === 'yaml'
|
||||||
|
? /(?:^|[\s{,])brand:\s*["']?([\w.-]*)$/.exec(linePrefix)
|
||||||
|
: /"brand"\s*:\s*"([\w.-]*)$/.exec(linePrefix);
|
||||||
|
return match ? { typed: match[1] } : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { Decision, DECISION_STATUSES, effectiveStatus, isDecisionFile } from '../model/decisions';
|
||||||
|
import { ModelIndex, ModelService } from '../model/service';
|
||||||
|
|
||||||
|
/** Diagrams (decisions: […]) and decision files (elements: […], diagrams: […], supersedes: […]). */
|
||||||
|
export const DECISION_SELECTOR: vscode.DocumentSelector = [{ pattern: '**/*.archify' }, { pattern: '**/*.archify-decision' }];
|
||||||
|
|
||||||
|
type ListKey = 'decisions' | 'elements' | 'diagrams' | 'supersedes';
|
||||||
|
|
||||||
|
/** Which list the cursor is in on a `key: [a, b, …` line, and what has been typed of the current item. */
|
||||||
|
export function listValueAt(linePrefix: string, fileName: string): { key: ListKey; typed: string } | undefined {
|
||||||
|
const keys = isDecisionFile(fileName) ? ['elements', 'diagrams', 'supersedes'] : ['decisions'];
|
||||||
|
const match = new RegExp(`(?:^|[\\s{,])(${keys.join('|')}):\\s*\\[([^\\]]*)$`).exec(linePrefix);
|
||||||
|
if (match) {
|
||||||
|
const items = match[2].split(',');
|
||||||
|
return { key: match[1] as ListKey, typed: items[items.length - 1].replace(/^[\s"']+/, '') };
|
||||||
|
}
|
||||||
|
// Block list item under the key: " - 00" (the key is on a line above; the caller checks).
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The list item under a position on a flow-list line, with its range. */
|
||||||
|
export function listItemAt(line: string, character: number, fileName: string): { key: ListKey; value: string; start: number; end: number } | undefined {
|
||||||
|
const keys = isDecisionFile(fileName) ? ['elements', 'diagrams', 'supersedes'] : ['decisions'];
|
||||||
|
const match = new RegExp(`(?:^|[\\s{,])(${keys.join('|')}):\\s*\\[([^\\]]*)\\]?`).exec(line);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const listStart = match.index + match[0].indexOf('[') + 1;
|
||||||
|
const re = /[^,\s"'\]]+/g;
|
||||||
|
const inner = match[2];
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(inner))) {
|
||||||
|
const start = listStart + m.index;
|
||||||
|
const end = start + m[0].length;
|
||||||
|
if (character >= start && character <= end) return { key: match[1] as ListKey, value: m[0], start, end };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeDecision(d: Decision, index: ModelIndex): vscode.MarkdownString {
|
||||||
|
const md = new vscode.MarkdownString();
|
||||||
|
const { status, supersededBy } = effectiveStatus(d, index.decisions.decisions);
|
||||||
|
md.appendMarkdown(`**${d.id}**${d.title ? ` · ${d.title}` : ''} \`${status}\`${supersededBy ? ` by ${supersededBy}` : ''}${d.date ? ` · ${d.date}` : ''}\n\n`);
|
||||||
|
if (d.decision) md.appendMarkdown(`${d.decision.trim()}\n\n`);
|
||||||
|
md.appendMarkdown(`*${d.file}*`);
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Completion, hover and go-to-definition for decision ids in diagrams and for links inside decision files. */
|
||||||
|
export class DecisionLanguage 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 at = listValueAt(document.lineAt(position.line).text.slice(0, position.character), document.fileName);
|
||||||
|
if (!at) return undefined;
|
||||||
|
const index = await this.models.indexFor(document.uri);
|
||||||
|
if (!index) return undefined;
|
||||||
|
const range = new vscode.Range(position.translate(0, -at.typed.length), position);
|
||||||
|
const item = (label: string, description: string | undefined, detail: string, doc?: vscode.MarkdownString) => {
|
||||||
|
const it = new vscode.CompletionItem({ label, description }, vscode.CompletionItemKind.Reference);
|
||||||
|
it.detail = detail;
|
||||||
|
it.documentation = doc;
|
||||||
|
it.filterText = `${label} ${description ?? ''}`;
|
||||||
|
it.range = range;
|
||||||
|
return it;
|
||||||
|
};
|
||||||
|
if (at.key === 'decisions' || at.key === 'supersedes') {
|
||||||
|
return [...index.decisions.decisions.values()].map((d) => item(d.id, d.title, effectiveStatus(d, index.decisions.decisions).status, describeDecision(d, index)));
|
||||||
|
}
|
||||||
|
if (at.key === 'elements') {
|
||||||
|
return [...index.registry.elements.values()].map((e) => item(e.id, e.label, [e.type, e.sublabel].filter(Boolean).join(' · ')));
|
||||||
|
}
|
||||||
|
return index.diagrams.map((d) => item(d.relativePath, d.title, d.kind ?? 'diagram'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async provideHover(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Hover | undefined> {
|
||||||
|
const hit = listItemAt(document.lineAt(position.line).text, position.character, document.fileName);
|
||||||
|
if (!hit || (hit.key !== 'decisions' && hit.key !== 'supersedes')) return undefined;
|
||||||
|
const index = await this.models.indexFor(document.uri);
|
||||||
|
const range = new vscode.Range(position.line, hit.start, position.line, hit.end);
|
||||||
|
const d = index?.decisions.decisions.get(hit.value);
|
||||||
|
if (!index) return new vscode.Hover(`\`${hit.value}\` cannot be resolved: this file is not inside a model.`, range);
|
||||||
|
if (!d) return new vscode.Hover(`Unknown decision \`${hit.value}\`: no .archify-decision file of this model defines it.`, range);
|
||||||
|
return new vscode.Hover(describeDecision(d, index), range);
|
||||||
|
}
|
||||||
|
|
||||||
|
async provideDefinition(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Location | undefined> {
|
||||||
|
const hit = listItemAt(document.lineAt(position.line).text, position.character, document.fileName);
|
||||||
|
if (!hit) return undefined;
|
||||||
|
const index = await this.models.indexFor(document.uri);
|
||||||
|
if (!index) return undefined;
|
||||||
|
if (hit.key === 'decisions' || hit.key === 'supersedes') {
|
||||||
|
const d = index.decisions.decisions.get(hit.value);
|
||||||
|
return d ? new vscode.Location(vscode.Uri.joinPath(index.root, ...d.file.split('/')), new vscode.Position(d.lines.id, 2)) : undefined;
|
||||||
|
}
|
||||||
|
if (hit.key === 'elements') {
|
||||||
|
const e = index.registry.elements.get(hit.value);
|
||||||
|
return e ? new vscode.Location(vscode.Uri.joinPath(index.root, ...e.file.split(/[\\/]/)), new vscode.Position(e.line, 2)) : undefined;
|
||||||
|
}
|
||||||
|
const diagram = index.diagrams.find((d) => d.relativePath === hit.value);
|
||||||
|
return diagram ? new vscode.Location(vscode.Uri.joinPath(index.root, ...diagram.relativePath.split('/')), new vscode.Position(0, 0)) : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { parse, ParseError } from 'jsonc-parser';
|
import { parse, ParseError } from 'jsonc-parser';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { isArchifyFile, readDiagramSource } from './detect';
|
import { ArchifyDiagnostic, isEvidenceError, validate } from '../archify/cli';
|
||||||
import { rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
|
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||||
import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer';
|
import { ModelProblem, isRegistryFile, parseRegistry } from '../model/format';
|
||||||
import { evidenceMode, log, runtimeFor } from './runtime';
|
import { isDecisionFile, parseDecision } from '../model/decisions';
|
||||||
|
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']);
|
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
|
||||||
|
|
||||||
@@ -28,9 +34,13 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
private readonly sequence = new Map<string, number>();
|
private readonly sequence = new Map<string, number>();
|
||||||
private readonly disposables: vscode.Disposable[];
|
private readonly disposables: vscode.Disposable[];
|
||||||
|
|
||||||
constructor(private readonly context: vscode.ExtensionContext) {
|
constructor(
|
||||||
|
private readonly context: vscode.ExtensionContext,
|
||||||
|
private readonly models: ModelService,
|
||||||
|
) {
|
||||||
this.disposables = [
|
this.disposables = [
|
||||||
this.collection,
|
this.collection,
|
||||||
|
models.onDidChangeModel(() => this.revalidateAll()),
|
||||||
vscode.workspace.onDidOpenTextDocument((doc) => this.schedule(doc, 0)),
|
vscode.workspace.onDidOpenTextDocument((doc) => this.schedule(doc, 0)),
|
||||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||||
if (event.contentChanges.length) this.schedule(event.document, 700);
|
if (event.contentChanges.length) this.schedule(event.document, 700);
|
||||||
@@ -61,7 +71,7 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private schedule(doc: vscode.TextDocument, delay: number): void {
|
private schedule(doc: vscode.TextDocument, delay: number): void {
|
||||||
if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName)) return;
|
if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName) && !isRegistryFile(doc.fileName) && !isDecisionFile(doc.fileName)) return;
|
||||||
const key = doc.uri.toString();
|
const key = doc.uri.toString();
|
||||||
clearTimeout(this.timers.get(key));
|
clearTimeout(this.timers.get(key));
|
||||||
this.timers.set(key, setTimeout(() => void this.validate(doc), delay));
|
this.timers.set(key, setTimeout(() => void this.validate(doc), delay));
|
||||||
@@ -78,6 +88,14 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
this.collection.delete(doc.uri);
|
this.collection.delete(doc.uri);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isRegistryFile(doc.fileName)) {
|
||||||
|
await this.validateRegistry(doc, seq);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isDecisionFile(doc.fileName)) {
|
||||||
|
await this.validateDecision(doc, seq);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isArchifyFile(doc.fileName)) {
|
if (isArchifyFile(doc.fileName)) {
|
||||||
// Nothing else reports YAML syntax errors in .archify files, and a file
|
// Nothing else reports YAML syntax errors in .archify files, and a file
|
||||||
// that does not parse has no diagram to validate yet.
|
// that does not parse has no diagram to validate yet.
|
||||||
@@ -98,11 +116,16 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const source = readDiagramSource(text, doc.fileName);
|
const prepared = await prepareDiagram(this.models, doc.uri, text);
|
||||||
if (!source) {
|
if (seq !== this.sequence.get(key) || doc.isClosed) return;
|
||||||
|
if (!prepared) {
|
||||||
this.collection.delete(doc.uri);
|
this.collection.delete(doc.uri);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const source = prepared.source;
|
||||||
|
const modelDiagnostics = prepared.problems
|
||||||
|
.filter((problem) => !problem.file)
|
||||||
|
.map((problem) => this.modelDiagnostic(doc, text, source.format, problem));
|
||||||
if (source.format === 'json') {
|
if (source.format === 'json') {
|
||||||
// Leave JSON syntax errors to the JSON language service.
|
// Leave JSON syntax errors to the JSON language service.
|
||||||
const syntax: ParseError[] = [];
|
const syntax: ParseError[] = [];
|
||||||
@@ -130,10 +153,11 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const downgradeEvidence = evidenceMode(doc.uri) === 'fallback';
|
const downgradeEvidence = evidenceMode(doc.uri) === 'fallback';
|
||||||
this.collection.set(
|
this.collection.set(doc.uri, [
|
||||||
doc.uri,
|
...modelDiagnostics,
|
||||||
result.diagnostics.map((item) => {
|
...result.diagnostics.map((item) => {
|
||||||
const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, item.subject?.path) : rangeForPointer(text, item.subject?.path);
|
const pointer = diagnosticPointer(item);
|
||||||
|
const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, pointer) : rangeForPointer(text, pointer);
|
||||||
const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
|
const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
|
||||||
const fix = item.supportedFixes?.[0];
|
const fix = item.supportedFixes?.[0];
|
||||||
const diagnostic = new vscode.Diagnostic(
|
const diagnostic = new vscode.Diagnostic(
|
||||||
@@ -145,12 +169,63 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
if (item.code) diagnostic.code = item.code;
|
if (item.code) diagnostic.code = item.code;
|
||||||
return diagnostic;
|
return diagnostic;
|
||||||
}),
|
}),
|
||||||
);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log().error(`Validation failed for ${doc.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
log().error(`Validation failed for ${doc.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private modelDiagnostic(doc: vscode.TextDocument, text: string, format: 'yaml' | 'json', problem: ModelProblem): vscode.Diagnostic {
|
||||||
|
const offsets = format === 'yaml' ? rangeForPointerYaml(text, problem.pointer) : rangeForPointer(text, problem.pointer);
|
||||||
|
const range = problem.line !== undefined && !problem.pointer ? doc.lineAt(Math.min(problem.line, doc.lineCount - 1)).range : new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
|
||||||
|
const diagnostic = new vscode.Diagnostic(range, problem.message, problem.severity === 'warning' ? vscode.DiagnosticSeverity.Warning : vscode.DiagnosticSeverity.Error);
|
||||||
|
diagnostic.source = 'archify';
|
||||||
|
return diagnostic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A registry file: its own YAML and element problems, plus duplicates across the model. */
|
||||||
|
private async validateDecision(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).split(path.sep).join('/') : path.basename(doc.uri.fsPath);
|
||||||
|
const problems: ModelProblem[] = [...parseDecision(text, rel).problems];
|
||||||
|
if (root) {
|
||||||
|
try {
|
||||||
|
const index = await this.models.index(root);
|
||||||
|
for (const problem of index.decisions.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 decision has lost its model: no main.archify above ${path.basename(doc.uri.fsPath)}, so its elements and diagrams cannot be resolved.`, 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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateRegistry(doc: vscode.TextDocument, seq: number): Promise<void> {
|
||||||
|
const text = doc.getText();
|
||||||
|
const root = await this.models.rootOf(doc.uri);
|
||||||
|
const rel = root ? path.relative(root.fsPath, doc.uri.fsPath) : path.basename(doc.uri.fsPath);
|
||||||
|
const problems: ModelProblem[] = [...parseRegistry(text, rel).problems];
|
||||||
|
if (root) {
|
||||||
|
try {
|
||||||
|
const index = await this.models.index(root);
|
||||||
|
for (const problem of index.registry.problems) {
|
||||||
|
if (problem.file === rel && !problems.some((p) => p.message === problem.message)) problems.push(problem);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The index reports its own failures when the model page opens.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
problems.push({ message: `This registry is not inside a model: no main.archify above ${path.basename(doc.uri.fsPath)}.`, severity: 'warning', line: 0 });
|
||||||
|
}
|
||||||
|
if (seq !== this.sequence.get(doc.uri.toString()) || doc.isClosed) return;
|
||||||
|
this.collection.set(doc.uri, problems.map((problem) => this.modelDiagnostic(doc, text, 'yaml', problem)));
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
for (const timer of this.timers.values()) clearTimeout(timer);
|
for (const timer of this.timers.values()) clearTimeout(timer);
|
||||||
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
for (const disposable of this.disposables.splice(0)) disposable.dispose();
|
||||||
@@ -0,0 +1,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 });
|
||||||
|
}
|
||||||
@@ -6,6 +6,14 @@ export interface OffsetRange {
|
|||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JSON pointer an Archify diagnostic is about. Most diagnostics carry it
|
||||||
|
* in subject.path; brand diagnostics only start their message with it.
|
||||||
|
*/
|
||||||
|
export function diagnosticPointer(diagnostic: { message: string; subject?: { path?: string } }): string | undefined {
|
||||||
|
return diagnostic.subject?.path ?? /^(\/[^\s"]+)\s/.exec(diagnostic.message)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
/** Splits an RFC 6901 JSON pointer into path segments. */
|
/** Splits an RFC 6901 JSON pointer into path segments. */
|
||||||
export function pointerSegments(pointer: string): (string | number)[] {
|
export function pointerSegments(pointer: string): (string | number)[] {
|
||||||
if (!pointer || pointer === '/') return [];
|
if (!pointer || pointer === '/') return [];
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isMap, parseDocument } from 'yaml';
|
import { isMap, parseDocument } from 'yaml';
|
||||||
import { DiagramType } from './detect';
|
import { DiagramType } from '../sources/detect';
|
||||||
|
|
||||||
export interface DiagramTemplate {
|
export interface DiagramTemplate {
|
||||||
/** File name under templates/. */
|
/** File name under templates/. */
|
||||||
@@ -9,6 +9,8 @@ export interface DiagramTemplate {
|
|||||||
description: string;
|
description: string;
|
||||||
/** Suggested name for the new file, without .archify. */
|
/** Suggested name for the new file, without .archify. */
|
||||||
defaultName: string;
|
defaultName: string;
|
||||||
|
/** Reserved file name; the user is not asked for one. */
|
||||||
|
fixedName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TEMPLATES: readonly DiagramTemplate[] = [
|
export const TEMPLATES: readonly DiagramTemplate[] = [
|
||||||
@@ -18,6 +20,7 @@ export const TEMPLATES: readonly DiagramTemplate[] = [
|
|||||||
{ file: 'dataflow.archify', type: 'dataflow', label: 'Data Flow', description: 'Where data comes from and where it goes', defaultName: 'dataflow' },
|
{ file: 'dataflow.archify', type: 'dataflow', label: 'Data Flow', description: 'Where data comes from and where it goes', defaultName: 'dataflow' },
|
||||||
{ file: 'lifecycle.archify', type: 'lifecycle', label: 'Lifecycle', description: 'States something moves through', defaultName: 'lifecycle' },
|
{ file: 'lifecycle.archify', type: 'lifecycle', label: 'Lifecycle', description: 'States something moves through', defaultName: 'lifecycle' },
|
||||||
{ file: 'everyday-plan.archify', type: 'workflow', label: 'Everyday Plan', description: 'A plan or process from daily life', defaultName: 'plan' },
|
{ file: 'everyday-plan.archify', type: 'workflow', label: 'Everyday Plan', description: 'A plan or process from daily life', defaultName: 'plan' },
|
||||||
|
{ file: 'model.archify', type: 'architecture', label: 'Model (main.archify)', description: 'Entry point of a folder of diagrams that belong together', defaultName: 'main', fixedName: 'main.archify' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Strips a typed .archify suffix and surrounding spaces. */
|
/** Strips a typed .archify suffix and surrounding spaces. */
|
||||||
@@ -40,6 +43,21 @@ export function titleFromName(name: string): string {
|
|||||||
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
|
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "online-shop" → "online-shop"; "My Shop 2" → "my-shop-2": a model id from a folder name. */
|
||||||
|
export function modelIdFromName(name: string): string {
|
||||||
|
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
return /^[a-z]/.test(id) ? id : `m-${id || 'model'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The model template with id, name and overview title taken from the folder name. */
|
||||||
|
export function modelFromTemplate(templateText: string, folderName: string): string {
|
||||||
|
const doc = parseDocument(templateText);
|
||||||
|
doc.setIn(['model', 'id'], modelIdFromName(folderName));
|
||||||
|
doc.setIn(['model', 'name'], titleFromName(folderName));
|
||||||
|
doc.setIn(['meta', 'title'], `${titleFromName(folderName)} overview`);
|
||||||
|
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template YAML with meta.title and meta.output set for the new file.
|
* Template YAML with meta.title and meta.output set for the new file.
|
||||||
* Works on the YAML document so the template's comments and layout stay.
|
* Works on the YAML document so the template's comments and layout stay.
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
||||||
|
import { ModelProblem, Registry } from './format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Architecture decisions of a model (see docs/model.md): one
|
||||||
|
* `*.archify-decision` file per decision, anywhere under the model root.
|
||||||
|
* Pure functions, no VS Code imports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DECISION_EXT = '.archify-decision';
|
||||||
|
export const DECISION_STATUSES = ['proposed', 'accepted', 'superseded', 'rejected'] as const;
|
||||||
|
export type DecisionStatus = (typeof DECISION_STATUSES)[number];
|
||||||
|
|
||||||
|
export interface Decision {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
status?: string;
|
||||||
|
date?: string;
|
||||||
|
/** Registry element ids the decision is about. */
|
||||||
|
elements: string[];
|
||||||
|
/** Diagram paths (relative to the model root) the decision names. */
|
||||||
|
diagrams: string[];
|
||||||
|
/** Ids of older decisions this one replaces. */
|
||||||
|
supersedes: string[];
|
||||||
|
context?: string;
|
||||||
|
decision?: string;
|
||||||
|
consequences?: string;
|
||||||
|
/** Decision file relative to the model root, and 0-based lines for diagnostics. */
|
||||||
|
file: string;
|
||||||
|
line: number;
|
||||||
|
lines: { id: number; status: number; elements: number[]; diagrams: number[]; supersedes: number[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDecisionFile(fileName: string): boolean {
|
||||||
|
return fileName.endsWith(DECISION_EXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim().length > 0 ? v : v !== undefined && v !== null && typeof v !== 'object' ? String(v) : undefined);
|
||||||
|
|
||||||
|
/** "OpenSearch for product search" → "opensearch-for-product-search". */
|
||||||
|
export function slugify(title: string): string {
|
||||||
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
return slug || 'decision';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses one decision file. Problems carry 0-based lines. */
|
||||||
|
export function parseDecision(text: string, file: string): { decision?: Decision; problems: ModelProblem[] } {
|
||||||
|
const problems: ModelProblem[] = [];
|
||||||
|
const doc = parseDocument(text, { prettyErrors: false, uniqueKeys: false });
|
||||||
|
for (const error of doc.errors) {
|
||||||
|
problems.push({ message: `Decision 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 { 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 decision file is a mapping with a "decision" block and context, decision and consequences.', severity: 'error', file, line: 0 });
|
||||||
|
return { problems };
|
||||||
|
}
|
||||||
|
const headNode = root.get('decision', true);
|
||||||
|
if (!isMap(headNode)) {
|
||||||
|
problems.push({ message: 'A decision needs a "decision" block with id, title and status.', severity: 'error', file, line: 0, pointer: '/decision' });
|
||||||
|
return { problems };
|
||||||
|
}
|
||||||
|
const headPair = root.items.find((pair) => isScalar(pair.key) && pair.key.value === 'decision');
|
||||||
|
const headLine = headPair && isScalar(headPair.key) ? lineOf(headPair.key.range?.[0]) : 0;
|
||||||
|
const keyLine = (key: string) => {
|
||||||
|
const pair = headNode.items.find((p) => isScalar(p.key) && p.key.value === key);
|
||||||
|
return pair && isScalar(pair.key) ? lineOf(pair.key.range?.[0]) : headLine;
|
||||||
|
};
|
||||||
|
const list = (key: string): { values: string[]; lines: number[] } => {
|
||||||
|
const node = headNode.get(key, true);
|
||||||
|
const values: string[] = [];
|
||||||
|
const lines: number[] = [];
|
||||||
|
if (isSeq(node)) {
|
||||||
|
for (const item of node.items) {
|
||||||
|
if (!isScalar(item)) continue;
|
||||||
|
if (typeof item.value === 'number') {
|
||||||
|
problems.push({ message: `"${item.value}" in ${key} is a number to YAML. Quote it or use words.`, severity: 'error', file, line: lineOf(item.range?.[0]), pointer: `/decision/${key}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str(item.value)) {
|
||||||
|
values.push(String(item.value));
|
||||||
|
lines.push(lineOf(item.range?.[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node !== undefined) {
|
||||||
|
problems.push({ message: `"${key}" must be a list.`, severity: 'error', file, line: keyLine(key), pointer: `/decision/${key}` });
|
||||||
|
}
|
||||||
|
return { values, lines };
|
||||||
|
};
|
||||||
|
const head = headNode.toJSON() as Record<string, unknown>;
|
||||||
|
if (typeof head.id === 'number') {
|
||||||
|
problems.push({ message: `decision.id ${head.id} is a number to YAML (0001 becomes 1). Quote it ("${head.id}") or use words.`, severity: 'error', file, line: keyLine('id'), pointer: '/decision/id' });
|
||||||
|
return { problems };
|
||||||
|
}
|
||||||
|
const id = str(head.id);
|
||||||
|
if (!id) {
|
||||||
|
problems.push({ message: 'decision.id is missing.', severity: 'error', file, line: headLine, pointer: '/decision/id' });
|
||||||
|
return { problems };
|
||||||
|
}
|
||||||
|
const status = str(head.status);
|
||||||
|
if (status && !(DECISION_STATUSES as readonly string[]).includes(status)) {
|
||||||
|
problems.push({ message: `Unknown status "${status}"; one of ${DECISION_STATUSES.join(', ')}.`, severity: 'error', file, line: keyLine('status'), pointer: '/decision/status' });
|
||||||
|
}
|
||||||
|
const elements = list('elements');
|
||||||
|
const diagrams = list('diagrams');
|
||||||
|
const supersedes = list('supersedes');
|
||||||
|
const body = root.toJSON() as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
problems,
|
||||||
|
decision: {
|
||||||
|
id,
|
||||||
|
title: str(head.title),
|
||||||
|
status,
|
||||||
|
date: str(head.date),
|
||||||
|
elements: elements.values,
|
||||||
|
diagrams: diagrams.values,
|
||||||
|
supersedes: supersedes.values,
|
||||||
|
context: str(body.context),
|
||||||
|
decision: str(body.decision),
|
||||||
|
consequences: str(body.consequences),
|
||||||
|
file,
|
||||||
|
line: headLine,
|
||||||
|
lines: { id: keyLine('id'), status: keyLine('status'), elements: elements.lines, diagrams: diagrams.lines, supersedes: supersedes.lines },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecisionSet {
|
||||||
|
decisions: Map<string, Decision>;
|
||||||
|
files: string[];
|
||||||
|
problems: ModelProblem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges parsed decision files; an id used twice is an error on the second file. */
|
||||||
|
export function mergeDecisions(parts: { file: string; decision?: Decision; problems: ModelProblem[] }[]): DecisionSet {
|
||||||
|
const decisions = new Map<string, Decision>();
|
||||||
|
const problems: ModelProblem[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
problems.push(...part.problems);
|
||||||
|
if (!part.decision) continue;
|
||||||
|
const existing = decisions.get(part.decision.id);
|
||||||
|
if (existing) {
|
||||||
|
problems.push({ message: `Decision "${part.decision.id}" is defined twice (also in ${existing.file}).`, severity: 'error', file: part.file, line: part.decision.lines.id });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
decisions.set(part.decision.id, part.decision);
|
||||||
|
}
|
||||||
|
return { decisions, files: parts.map((p) => p.file), problems };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The status the pages show: a decision another one supersedes is superseded, whatever its file says. */
|
||||||
|
export function effectiveStatus(decision: Decision, all: Map<string, Decision>): { status: string; supersededBy?: string } {
|
||||||
|
for (const other of all.values()) {
|
||||||
|
if (other !== decision && other.supersedes.includes(decision.id) && other.status !== 'rejected') return { status: 'superseded', supersededBy: other.id };
|
||||||
|
}
|
||||||
|
return { status: decision.status ?? 'proposed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link checks across the model: elements, diagrams and supersedes must exist. */
|
||||||
|
export function validateDecisions(set: DecisionSet, registry: Registry | undefined, diagramPaths: Set<string>): ModelProblem[] {
|
||||||
|
const problems: ModelProblem[] = [];
|
||||||
|
for (const d of set.decisions.values()) {
|
||||||
|
d.elements.forEach((id, i) => {
|
||||||
|
if (!registry?.elements.has(id)) problems.push({ message: `Unknown element "${id}": no registry of this model defines it.`, severity: 'error', file: d.file, line: d.lines.elements[i], pointer: `/decision/elements/${i}` });
|
||||||
|
});
|
||||||
|
d.diagrams.forEach((path, i) => {
|
||||||
|
if (!diagramPaths.has(path)) problems.push({ message: `Diagram "${path}" does not exist in this model.`, severity: 'error', file: d.file, line: d.lines.diagrams[i], pointer: `/decision/diagrams/${i}` });
|
||||||
|
});
|
||||||
|
d.supersedes.forEach((id, i) => {
|
||||||
|
if (!set.decisions.has(id)) problems.push({ message: `Decision "${id}" (in supersedes) does not exist in this model.`, severity: 'error', file: d.file, line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` });
|
||||||
|
else if (id === d.id) problems.push({ message: 'A decision cannot supersede itself.', severity: 'error', file: d.file, line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort for lists: live decisions newest first, superseded and rejected last. */
|
||||||
|
export function sortDecisions(decisions: Decision[], all: Map<string, Decision>): Decision[] {
|
||||||
|
const rank = (d: Decision) => (['superseded', 'rejected'].includes(effectiveStatus(d, all).status) ? 1 : 0);
|
||||||
|
return [...decisions].sort((a, b) => rank(a) - rank(b) || (b.date ?? '').localeCompare(a.date ?? '') || a.id.localeCompare(b.id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a diagram attaches a decision: the diagram itself, a node or an edge. */
|
||||||
|
export type DecisionAttachment = { kind: 'diagram' } | { kind: 'node'; id: string } | { kind: 'edge'; from: string; to: string; label?: string };
|
||||||
|
|
||||||
|
export interface DecisionRef {
|
||||||
|
id: string;
|
||||||
|
pointer: string;
|
||||||
|
attachedTo: DecisionAttachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collections whose items may carry `decisions` (and `diagram` links), per diagram kind. */
|
||||||
|
const NODE_COLLECTIONS = ['components', 'nodes', 'participants', 'states'] as const;
|
||||||
|
const EDGE_COLLECTIONS = ['connections', 'edges', 'flows', 'transitions', 'messages'] as const;
|
||||||
|
|
||||||
|
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>;
|
||||||
|
/** Decisions the diagram points at, with where they are attached. */
|
||||||
|
decisionRefs: DecisionRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveOptions {
|
||||||
|
/** Undefined when the diagram is not inside a model. */
|
||||||
|
registry?: Registry;
|
||||||
|
/** Known decision ids of the model; undefined outside a model or when unknown. */
|
||||||
|
decisions?: Set<string>;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips `decisions` keys from a diagram and collects what they point at. Mutates `diagram`. */
|
||||||
|
function takeDecisionRefs(diagram: Obj, options: ResolveOptions, problems: ModelProblem[]): DecisionRef[] {
|
||||||
|
const refs: DecisionRef[] = [];
|
||||||
|
const take = (owner: Obj, pointer: string, attachedTo: DecisionAttachment) => {
|
||||||
|
if (!('decisions' in owner)) return;
|
||||||
|
const raw = owner.decisions;
|
||||||
|
delete owner.decisions;
|
||||||
|
const ids = Array.isArray(raw) ? raw : [raw];
|
||||||
|
ids.forEach((value, i) => {
|
||||||
|
const id = value === undefined || value === null ? undefined : String(value);
|
||||||
|
if (!id) return;
|
||||||
|
const at = `${pointer}/decisions/${i}`;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
problems.push({ message: `Decision id ${value} is a number to YAML (0001 becomes 1). Quote it ("${value}") or use words.`, severity: 'error', pointer: at });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!options.registry) {
|
||||||
|
problems.push({ message: `Decision "${id}" cannot be resolved: ${options.fileName.split(/[\\/]/).pop()} is not inside a model (no ${MAIN_FILE} above it).`, severity: 'error', pointer: at });
|
||||||
|
} else if (options.decisions && !options.decisions.has(id)) {
|
||||||
|
problems.push({ message: `Unknown decision "${id}": no .archify-decision file of this model defines it.`, severity: 'error', pointer: at });
|
||||||
|
}
|
||||||
|
refs.push({ id, pointer: at, attachedTo });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
take(diagram, '', { kind: 'diagram' });
|
||||||
|
for (const key of NODE_COLLECTIONS) {
|
||||||
|
const items = diagram[key];
|
||||||
|
if (!Array.isArray(items)) continue;
|
||||||
|
diagram[key] = items.map((item, index) => {
|
||||||
|
if (!isObj(item)) return item;
|
||||||
|
const copy = { ...item };
|
||||||
|
take(copy, `/${key}/${index}`, { kind: 'node', id: str(copy.id) ?? str(copy.ref) ?? String(index) });
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const key of EDGE_COLLECTIONS) {
|
||||||
|
const items = diagram[key];
|
||||||
|
if (!Array.isArray(items)) continue;
|
||||||
|
diagram[key] = items.map((item, index) => {
|
||||||
|
if (!isObj(item)) return item;
|
||||||
|
const copy = { ...item };
|
||||||
|
take(copy, `/${key}/${index}`, { kind: 'edge', from: str(copy.from) ?? '', to: str(copy.to) ?? '', label: str(copy.label) });
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, decisionRefs: [] };
|
||||||
|
const diagram: Obj = { ...doc };
|
||||||
|
delete diagram.model;
|
||||||
|
const decisionRefs = takeDecisionRefs(diagram, options, problems);
|
||||||
|
const { registry } = options;
|
||||||
|
const components = Array.isArray(diagram.components) ? diagram.components : undefined;
|
||||||
|
if (!components) return { diagram, problems, refs, links, used, descriptions, decisionRefs };
|
||||||
|
|
||||||
|
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, decisionRefs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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[];
|
||||||
|
/** Decisions the diagram points at. */
|
||||||
|
decisions: DecisionRef[];
|
||||||
|
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: [], decisions: [], 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))];
|
||||||
|
const decisions = takeDecisionRefs({ ...obj }, { registry: { elements: new Map(), files: [], problems: [] }, fileName: relativePath }, []);
|
||||||
|
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used, decisions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,194 @@
|
|||||||
|
import { Decision, effectiveStatus, sortDecisions } from './decisions';
|
||||||
|
import { DecisionAttachment, DiagramSummary, RegistryElement } from './format';
|
||||||
|
import { ModelIndex } from './service';
|
||||||
|
|
||||||
|
/** A decision as the lists show it. */
|
||||||
|
export interface DecisionRow {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
/** Status shown: superseded when another decision supersedes it. */
|
||||||
|
status: string;
|
||||||
|
supersededBy?: string;
|
||||||
|
date?: string;
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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[];
|
||||||
|
/** Decisions about this element, from the decision files and from diagram attachments. */
|
||||||
|
decisions: DecisionRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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[];
|
||||||
|
decisions: DecisionRow[];
|
||||||
|
problems: string[];
|
||||||
|
nested: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the decision page (one .archify-decision file) shows. */
|
||||||
|
export interface DecisionPanel {
|
||||||
|
file: string;
|
||||||
|
inModel: boolean;
|
||||||
|
model?: { id?: string; name?: string };
|
||||||
|
decision?: DecisionRow & { context?: string; decision?: string; consequences?: string; supersedes: DecisionRow[] };
|
||||||
|
/** Decisions that supersede this one. */
|
||||||
|
supersededBy: DecisionRow[];
|
||||||
|
elements: ElementCard[];
|
||||||
|
/** Diagrams the decision names. */
|
||||||
|
diagrams: UsedIn[];
|
||||||
|
/** Diagrams that point at the decision, with where. */
|
||||||
|
referencedBy: { path: string; title?: string; kind?: string; attachedTo: DecisionAttachment }[];
|
||||||
|
problems: 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 decisionRow(d: Decision, all: Map<string, Decision>): DecisionRow {
|
||||||
|
const { status, supersededBy } = effectiveStatus(d, all);
|
||||||
|
return { id: d.id, title: d.title, status, supersededBy, date: d.date, file: d.file };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decisions about an element: named by the decision, or attached to its node in some diagram. */
|
||||||
|
function decisionsOfElement(id: string, index: Pick<ModelIndex, 'decisions' | 'decisionUsage'> | undefined): DecisionRow[] {
|
||||||
|
if (!index) return [];
|
||||||
|
const all = index.decisions.decisions;
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const d of all.values()) if (d.elements.includes(id)) ids.add(d.id);
|
||||||
|
for (const [decisionId, uses] of index.decisionUsage) if (uses.some((u) => u.attachedTo.kind === 'node' && u.attachedTo.id === id) && all.has(decisionId)) ids.add(decisionId);
|
||||||
|
return sortDecisions([...ids].map((x) => all.get(x)!), all).map((d) => decisionRow(d, all));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elementCard(e: RegistryElement, usage: Map<string, string[]>, diagrams: DiagramSummary[], index?: Pick<ModelIndex, 'decisions' | 'decisionUsage'>): 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),
|
||||||
|
decisions: decisionsOfElement(e.id, index),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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, index));
|
||||||
|
const all = index.decisions.decisions;
|
||||||
|
return {
|
||||||
|
meta: index.meta,
|
||||||
|
decisions: sortDecisions([...all.values()], all).map((d) => decisionRow(d, all)),
|
||||||
|
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, ...index.decisions.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, index));
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The decision page of one file inside a model. */
|
||||||
|
export function decisionPanelFromIndex(index: ModelIndex, file: string): DecisionPanel {
|
||||||
|
const all = index.decisions.decisions;
|
||||||
|
const d = [...all.values()].find((x) => x.file === file);
|
||||||
|
const problems = index.decisions.problems.filter((p) => p.file === file).map((p) => p.message);
|
||||||
|
const base = { file, inModel: true, model: { id: index.meta.id, name: index.meta.name }, problems };
|
||||||
|
if (!d) return { ...base, supersededBy: [], elements: [], diagrams: [], referencedBy: [] };
|
||||||
|
const rows = (ids: string[]) => ids.filter((id) => all.has(id)).map((id) => decisionRow(all.get(id)!, all));
|
||||||
|
const elements = d.elements.filter((id) => index.registry.elements.has(id)).map((id) => elementCard(index.registry.elements.get(id)!, index.usage, index.diagrams, index));
|
||||||
|
const summary = (path: string) => index.diagrams.find((x) => x.relativePath === path);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
decision: { ...decisionRow(d, all), context: d.context, decision: d.decision, consequences: d.consequences, supersedes: rows(d.supersedes) },
|
||||||
|
supersededBy: [...all.values()].filter((x) => x.supersedes.includes(d.id)).map((x) => decisionRow(x, all)),
|
||||||
|
elements,
|
||||||
|
diagrams: d.diagrams.map((path) => ({ path, title: summary(path)?.title, kind: summary(path)?.kind })),
|
||||||
|
referencedBy: (index.decisionUsage.get(d.id) ?? []).map((u) => ({ path: u.path, title: summary(u.path)?.title, kind: summary(u.path)?.kind, attachedTo: u.attachedTo })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The decision page of a file outside a model: the text only, and a note that it has lost its model. */
|
||||||
|
export function decisionPanelStandalone(file: string, parsed: { decision?: Decision; problems: { message: string }[] }): DecisionPanel {
|
||||||
|
const d = parsed.decision;
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
inModel: false,
|
||||||
|
decision: d ? { id: d.id, title: d.title, status: d.status ?? 'proposed', date: d.date, file, context: d.context, decision: d.decision, consequences: d.consequences, supersedes: d.supersedes.map((id) => ({ id, status: 'unknown', file: '' })) } : undefined,
|
||||||
|
supersededBy: [],
|
||||||
|
elements: [],
|
||||||
|
diagrams: d ? d.diagrams.map((path) => ({ path })) : [],
|
||||||
|
referencedBy: [],
|
||||||
|
problems: parsed.problems.map((p) => p.message),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { DiagramSource, readDiagramSource } from '../sources/detect';
|
||||||
|
import { DecisionAttachment, ModelProblem, Registry, resolveRefs } from './format';
|
||||||
|
import { effectiveStatus } from './decisions';
|
||||||
|
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>;
|
||||||
|
/** Decisions of this diagram for the Decisions rail panel: pointed at by the diagram, or naming it. */
|
||||||
|
decisions: DiagramDecision[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagramDecision {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
status: string;
|
||||||
|
file: string;
|
||||||
|
attachedTo: DecisionAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 index = await models.indexFor(uri);
|
||||||
|
const found = index ? { root: index.root, registry: index.registry } : undefined;
|
||||||
|
const resolved = resolveRefs(source.diagram, { registry: found?.registry, decisions: index ? new Set(index.decisions.decisions.keys()) : undefined, 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 || resolved.decisionRefs.length > 0;
|
||||||
|
const prepared: DiagramSource = changed
|
||||||
|
? { ...source, diagram: resolved.diagram, json: JSON.stringify(resolved.diagram, null, 2) }
|
||||||
|
: source;
|
||||||
|
// Decisions for the rail: the ones this diagram points at, plus the ones that name this diagram.
|
||||||
|
const decisions: DiagramDecision[] = [];
|
||||||
|
if (index) {
|
||||||
|
const rel = path.relative(index.root.fsPath, uri.fsPath).split(path.sep).join('/');
|
||||||
|
const byId = new Map<string, DiagramDecision>();
|
||||||
|
const add = (id: string, attachedTo?: DecisionAttachment) => {
|
||||||
|
const d = index.decisions.decisions.get(id);
|
||||||
|
if (!d) return;
|
||||||
|
let row = byId.get(id);
|
||||||
|
if (!row) {
|
||||||
|
row = { id, title: d.title, status: effectiveStatus(d, index.decisions.decisions).status, file: d.file, attachedTo: [] };
|
||||||
|
byId.set(id, row);
|
||||||
|
decisions.push(row);
|
||||||
|
}
|
||||||
|
if (attachedTo && !row.attachedTo.some((a) => JSON.stringify(a) === JSON.stringify(attachedTo))) row.attachedTo.push(attachedTo);
|
||||||
|
};
|
||||||
|
for (const ref of resolved.decisionRefs) add(ref.id, ref.attachedTo);
|
||||||
|
for (const d of index.decisions.decisions.values()) if (d.diagrams.includes(rel)) add(d.id, { kind: 'diagram' });
|
||||||
|
}
|
||||||
|
return { source: prepared, root: found?.root, registry: found?.registry, problems: resolved.problems, links: resolved.links, descriptions: resolved.descriptions, decisions };
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { log } from '../util/log';
|
||||||
|
import {
|
||||||
|
DecisionAttachment,
|
||||||
|
DiagramSummary,
|
||||||
|
MAIN_FILE,
|
||||||
|
ModelMeta,
|
||||||
|
REGISTRY_EXT,
|
||||||
|
Registry,
|
||||||
|
hasDiagramBody,
|
||||||
|
isMainFile,
|
||||||
|
isRegistryFile,
|
||||||
|
mergeRegistries,
|
||||||
|
parseRegistry,
|
||||||
|
readModelMeta,
|
||||||
|
sortedGroups,
|
||||||
|
summarizeDiagram,
|
||||||
|
} from './format';
|
||||||
|
import { parse as parseYaml } from 'yaml';
|
||||||
|
import { DECISION_EXT, DecisionSet, isDecisionFile, mergeDecisions, parseDecision, validateDecisions } from './decisions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[]>;
|
||||||
|
/** The model's decisions, with link problems already checked. */
|
||||||
|
decisions: DecisionSet;
|
||||||
|
/** Decision id → diagrams that point at it, with where. */
|
||||||
|
decisionUsage: Map<string, { path: string; attachedTo: DecisionAttachment }[]>;
|
||||||
|
/** 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},${DECISION_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) || isDecisionFile(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 model index of the model a file belongs to, or undefined outside a model. */
|
||||||
|
async indexFor(uri: vscode.Uri): Promise<ModelIndex | undefined> {
|
||||||
|
const root = await this.rootOf(uri);
|
||||||
|
return root ? this.index(root) : 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]);
|
||||||
|
|
||||||
|
const decisionFiles = await vscode.workspace.findFiles(new vscode.RelativePattern(root, `**/*${DECISION_EXT}`), SKIP);
|
||||||
|
const decisionParts = await Promise.all(
|
||||||
|
decisionFiles.sort((a, b) => a.fsPath.localeCompare(b.fsPath)).map(async (file) => {
|
||||||
|
const rel = path.relative(root.fsPath, file.fsPath).split(path.sep).join('/');
|
||||||
|
return { file: rel, ...parseDecision(await readText(file), rel) };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const decisions = mergeDecisions(decisionParts);
|
||||||
|
decisions.problems.push(...validateDecisions(decisions, registry, new Set(diagrams.map((d) => d.relativePath))));
|
||||||
|
const decisionUsage = new Map<string, { path: string; attachedTo: DecisionAttachment }[]>();
|
||||||
|
for (const d of diagrams) for (const ref of d.decisions) decisionUsage.set(ref.id, [...(decisionUsage.get(ref.id) ?? []), { path: d.relativePath, attachedTo: ref.attachedTo }]);
|
||||||
|
return { root, main, meta, mainHasDiagram, registry, diagrams, groups: sortedGroups(diagrams), usage, decisions, decisionUsage, nested };
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const d of this.disposables.splice(0)) d.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,6 @@ import { parse as parseYaml } from 'yaml';
|
|||||||
export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const;
|
export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const;
|
||||||
export type DiagramType = (typeof DIAGRAM_TYPES)[number];
|
export type DiagramType = (typeof DIAGRAM_TYPES)[number];
|
||||||
|
|
||||||
/** Node collection that may carry `sources` evidence, per diagram type. */
|
|
||||||
export const EVIDENCE_COLLECTIONS: Record<DiagramType, string> = {
|
|
||||||
architecture: 'components',
|
|
||||||
workflow: 'nodes',
|
|
||||||
sequence: 'participants',
|
|
||||||
dataflow: 'nodes',
|
|
||||||
lifecycle: 'states',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FILENAME_RE = /\.(architecture|workflow|sequence|dataflow|lifecycle)\.json$/i;
|
const FILENAME_RE = /\.(architecture|workflow|sequence|dataflow|lifecycle)\.json$/i;
|
||||||
const ARCHIFY_EXT_RE = /\.archify$/i;
|
const ARCHIFY_EXT_RE = /\.archify$/i;
|
||||||
const MAX_SNIFF_BYTES = 8 * 1024 * 1024;
|
const MAX_SNIFF_BYTES = 8 * 1024 * 1024;
|
||||||
@@ -92,24 +83,3 @@ export function isArchifyHtml(text: string): boolean {
|
|||||||
const head = text.slice(0, 4096);
|
const head = text.slice(0, 4096);
|
||||||
return /<meta\s+name=["']generator["']\s+content=["']archify\b/i.test(head);
|
return /<meta\s+name=["']generator["']\s+content=["']archify\b/i.test(head);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): boolean {
|
|
||||||
const meta = diagram.meta as Record<string, unknown> | undefined;
|
|
||||||
if (meta && meta.repository) return true;
|
|
||||||
const nodes = diagram[EVIDENCE_COLLECTIONS[type]];
|
|
||||||
return Array.isArray(nodes) && nodes.some((node) => Array.isArray(node?.sources) && node.sources.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns a copy of the diagram with repository evidence removed. */
|
|
||||||
export function stripSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): Record<string, unknown> {
|
|
||||||
const copy = structuredClone(diagram);
|
|
||||||
const meta = copy.meta as Record<string, unknown> | undefined;
|
|
||||||
if (meta) delete meta.repository;
|
|
||||||
const nodes = copy[EVIDENCE_COLLECTIONS[type]];
|
|
||||||
if (Array.isArray(nodes)) {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node && typeof node === 'object') delete node.sources;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { DiagramType } from './detect';
|
||||||
|
|
||||||
|
/** Node collection that may carry `sources` evidence, per diagram type. */
|
||||||
|
export const EVIDENCE_COLLECTIONS: Record<DiagramType, string> = {
|
||||||
|
architecture: 'components',
|
||||||
|
workflow: 'nodes',
|
||||||
|
sequence: 'participants',
|
||||||
|
dataflow: 'nodes',
|
||||||
|
lifecycle: 'states',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hasSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): boolean {
|
||||||
|
const meta = diagram.meta as Record<string, unknown> | undefined;
|
||||||
|
if (meta && meta.repository) return true;
|
||||||
|
const nodes = diagram[EVIDENCE_COLLECTIONS[type]];
|
||||||
|
return Array.isArray(nodes) && nodes.some((node) => Array.isArray(node?.sources) && node.sources.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a copy of the diagram with repository evidence removed. */
|
||||||
|
export function stripSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const copy = structuredClone(diagram);
|
||||||
|
const meta = copy.meta as Record<string, unknown> | undefined;
|
||||||
|
if (meta) delete meta.repository;
|
||||||
|
const nodes = copy[EVIDENCE_COLLECTIONS[type]];
|
||||||
|
if (Array.isArray(nodes)) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node && typeof node === 'object') delete node.sources;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
|
/** Current text of a document: the open editor buffer, else the file on disk. */
|
||||||
|
export async function readText(uri: vscode.Uri): Promise<string> {
|
||||||
|
const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString());
|
||||||
|
if (open) return open.getText();
|
||||||
|
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
|
let output: vscode.LogOutputChannel | undefined;
|
||||||
|
|
||||||
|
export function log(): vscode.LogOutputChannel {
|
||||||
|
output ??= vscode.window.createOutputChannel('Archify', { log: true });
|
||||||
|
return output;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { ViewerTheme } from '../webview/page';
|
||||||
|
|
||||||
|
/** Diagram theme from archify.theme: forced, VS Code's, or the diagram's own. */
|
||||||
|
export function viewerTheme(): ViewerTheme {
|
||||||
|
const setting = vscode.workspace.getConfiguration('archify').get<string>('theme', 'vscode');
|
||||||
|
if (setting === 'dark' || setting === 'light') return setting;
|
||||||
|
if (setting === 'diagram') return undefined;
|
||||||
|
const kind = vscode.window.activeColorTheme.kind;
|
||||||
|
return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? 'light' : 'dark';
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
import { layerMarkup, rasterize } from './raster';
|
||||||
|
import { CameraState, Layers, Stage } from './stage';
|
||||||
|
import { DiagramModel, readModel } from './svgModel';
|
||||||
|
import { STYLE } from './style';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3D mode for the Archify viewer. It adds a 2D | 3D switch to Archify's
|
||||||
|
* toolbar and, when on, replaces the 2D canvas with a three.js stage built
|
||||||
|
* from the live SVG. Archify keeps owning the diagram: clicks in 3D go to the SVG
|
||||||
|
* nodes, so focus, Semantic Passport, reach and lens work as in 2D; the
|
||||||
|
* stage redraws whenever Archify changes the SVG, theme or preset, and
|
||||||
|
* follows Archify's animations as they play.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Host {
|
||||||
|
defaultView: '2d' | '3d';
|
||||||
|
post(message: unknown): void;
|
||||||
|
getState(): Record<string, unknown>;
|
||||||
|
setState(patch: Record<string, unknown>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SavedView {
|
||||||
|
active?: boolean;
|
||||||
|
layered?: boolean;
|
||||||
|
camera?: CameraState;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__archifyHost?: Host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REDRAW_MS = 220;
|
||||||
|
const MARGIN = 80;
|
||||||
|
/** Texture pixels per SVG unit; more costs memory and upload time for little gain. */
|
||||||
|
const TEXTURE_SCALE = 2;
|
||||||
|
|
||||||
|
|
||||||
|
class Viewer3D {
|
||||||
|
private stage: Stage | undefined;
|
||||||
|
private model: DiagramModel | undefined;
|
||||||
|
private active = false;
|
||||||
|
private redrawTimer: number | undefined;
|
||||||
|
private drawing: Promise<void> | undefined;
|
||||||
|
private pendingRedraw = false;
|
||||||
|
private readonly observer = new MutationObserver((records) => this.onMutation(records));
|
||||||
|
/** Keeps Archify's zoom buttons usable in 3D (see holdZoomButtons). */
|
||||||
|
private readonly zoomObserver = new MutationObserver(() => this.enableZoomButtons());
|
||||||
|
private zoomDisabled = new Map<HTMLButtonElement, boolean>();
|
||||||
|
private readonly toggle: HTMLButtonElement;
|
||||||
|
private readonly stackButton: HTMLButtonElement;
|
||||||
|
private readonly topButton: HTMLButtonElement;
|
||||||
|
private readonly resetLabel: HTMLSpanElement;
|
||||||
|
private ambientReplayed = false;
|
||||||
|
private exportItem: HTMLButtonElement | undefined;
|
||||||
|
private saved: SavedView;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly host: Host,
|
||||||
|
private readonly container: HTMLElement,
|
||||||
|
private readonly svg: SVGSVGElement,
|
||||||
|
) {
|
||||||
|
this.saved = (host.getState().view3d as SavedView | undefined) ?? {};
|
||||||
|
this.toggle = this.buildSwitch();
|
||||||
|
({ stack: this.stackButton, top: this.topButton, reset: this.resetLabel } = this.buildNavControls());
|
||||||
|
this.addExportItem();
|
||||||
|
this.interceptViewerControls();
|
||||||
|
if (this.saved.active ?? host.defaultView === '3d') this.setActive(true);
|
||||||
|
else this.syncButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- chrome
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 2D | 3D switch: Export's twin in the toolbar. Both states are always
|
||||||
|
* visible, the filled half is the current one, a click flips it.
|
||||||
|
*/
|
||||||
|
private buildSwitch(): HTMLButtonElement {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.id = 'btn-3d';
|
||||||
|
button.type = 'button';
|
||||||
|
button.setAttribute('role', 'switch');
|
||||||
|
button.setAttribute('aria-checked', 'false');
|
||||||
|
button.innerHTML =
|
||||||
|
'<span class="archify-3d-seg" data-seg="2d" aria-hidden="true">2D</span>' +
|
||||||
|
'<span class="archify-3d-seg" data-seg="3d" aria-hidden="true">3D</span>';
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.setActive(!this.active);
|
||||||
|
});
|
||||||
|
const exportWrap = document.querySelector('.toolbar .export-wrap');
|
||||||
|
if (exportWrap?.parentElement) exportWrap.parentElement.insertBefore(button, exportWrap);
|
||||||
|
else document.querySelector('.toolbar')?.appendChild(button);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3D-only controls in Archify's bottom-right dock, where its own view
|
||||||
|
* controls live: Stacked (toggle) and From above, before the zoom stepper;
|
||||||
|
* the stepper's "100%" (a 2D zoom level) reads "Reset" while 3D is on.
|
||||||
|
*/
|
||||||
|
private buildNavControls(): { stack: HTMLButtonElement; top: HTMLButtonElement; reset: HTMLSpanElement } {
|
||||||
|
const nav = this.container.querySelector<HTMLElement>('.diagram-nav');
|
||||||
|
const make = (id: string, label: string, title: string, pressed: boolean, action: () => void) => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.id = id;
|
||||||
|
button.type = 'button';
|
||||||
|
button.title = title;
|
||||||
|
button.setAttribute('aria-label', title);
|
||||||
|
if (pressed) button.setAttribute('aria-pressed', 'false');
|
||||||
|
button.textContent = label;
|
||||||
|
button.hidden = true;
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
action();
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
const stack = make('btn-3d-stack', 'STACK', 'Stacked: clients on top, data at the bottom', true, () => {
|
||||||
|
this.stage?.setLayered(!this.stage.layered);
|
||||||
|
this.syncButtons();
|
||||||
|
this.save();
|
||||||
|
});
|
||||||
|
const top = make('btn-3d-top', 'TOP', 'From above: look straight down, like 2D', false, () => this.stage?.top());
|
||||||
|
const reset = document.createElement('span');
|
||||||
|
reset.className = 'archify-3d-reset';
|
||||||
|
reset.textContent = 'RESET';
|
||||||
|
const zoomOut = nav?.querySelector('[data-view="out"]') ?? null;
|
||||||
|
nav?.insertBefore(stack, zoomOut);
|
||||||
|
nav?.insertBefore(top, zoomOut);
|
||||||
|
const resetButton = nav?.querySelector<HTMLButtonElement>('[data-view="reset"]');
|
||||||
|
if (resetButton) {
|
||||||
|
resetButton.appendChild(reset);
|
||||||
|
resetButton.dataset.archify3dTitle = resetButton.title;
|
||||||
|
}
|
||||||
|
return { stack, top, reset };
|
||||||
|
}
|
||||||
|
|
||||||
|
private addExportItem(): void {
|
||||||
|
const png = document.querySelector('.export-menu button[data-format="png"]');
|
||||||
|
if (!png?.parentElement) return;
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.type = 'button';
|
||||||
|
item.setAttribute('role', 'menuitem');
|
||||||
|
item.tabIndex = -1;
|
||||||
|
item.hidden = true;
|
||||||
|
item.dataset.archify3dExport = '';
|
||||||
|
item.innerHTML = '<span class="export-item-copy"><strong>3D view</strong><small class="hint">PNG of the current 3D view</small></span>';
|
||||||
|
item.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
document.getElementById('btn-export')?.click();
|
||||||
|
this.exportPng();
|
||||||
|
});
|
||||||
|
png.parentElement.insertBefore(item, png);
|
||||||
|
this.exportItem = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archify's zoom buttons and keys drive the 3D camera while 3D is on. */
|
||||||
|
private interceptViewerControls(): void {
|
||||||
|
this.container.addEventListener(
|
||||||
|
'click',
|
||||||
|
(event) => {
|
||||||
|
if (!this.active || !this.stage) return;
|
||||||
|
const button = (event.target as Element | null)?.closest?.('[data-view]');
|
||||||
|
const view = button?.getAttribute('data-view');
|
||||||
|
if (!view) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
if (view === 'in') this.stage.zoom(1.25);
|
||||||
|
else if (view === 'out') this.stage.zoom(0.8);
|
||||||
|
else if (view === 'reset') this.stage.fit();
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
'keydown',
|
||||||
|
(event) => {
|
||||||
|
if (!this.active || !this.stage || event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
|
||||||
|
const actions: Record<string, () => void> = {
|
||||||
|
'0': () => this.stage!.fit(),
|
||||||
|
'+': () => this.stage!.zoom(1.25),
|
||||||
|
'=': () => this.stage!.zoom(1.25),
|
||||||
|
'-': () => this.stage!.zoom(0.8),
|
||||||
|
};
|
||||||
|
const action = actions[event.key];
|
||||||
|
if (!action) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
action();
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncButtons(): void {
|
||||||
|
const on = this.active;
|
||||||
|
this.toggle.setAttribute('aria-checked', String(on));
|
||||||
|
this.toggle.setAttribute('aria-label', on ? '3D view on; switch to 2D' : '2D view; switch to 3D');
|
||||||
|
this.toggle.title = on ? 'Switch to 2D' : 'Switch to 3D';
|
||||||
|
for (const seg of this.toggle.querySelectorAll<HTMLElement>('.archify-3d-seg')) seg.classList.toggle('on', (seg.dataset.seg === '3d') === on);
|
||||||
|
this.stackButton.hidden = !on;
|
||||||
|
this.stackButton.setAttribute('aria-pressed', String(!!this.stage?.layered));
|
||||||
|
this.topButton.hidden = !on;
|
||||||
|
const resetButton = this.resetLabel.parentElement as HTMLButtonElement | null;
|
||||||
|
if (resetButton) resetButton.title = on ? 'Reset view: show the whole diagram (0)' : resetButton.dataset.archify3dTitle ?? resetButton.title;
|
||||||
|
if (this.exportItem) this.exportItem.hidden = !on;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- mode
|
||||||
|
|
||||||
|
setActive(on: boolean): void {
|
||||||
|
this.active = on;
|
||||||
|
document.documentElement.classList.toggle('archify-3d-on', on);
|
||||||
|
if (on) {
|
||||||
|
if (!this.stage) this.createStage();
|
||||||
|
this.container.appendChild(this.stage!.element);
|
||||||
|
this.stage!.start();
|
||||||
|
this.observer.observe(this.svg, { subtree: true, attributes: true, childList: true, characterData: true });
|
||||||
|
this.observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-preset'] });
|
||||||
|
this.holdZoomButtons(true);
|
||||||
|
void this.redraw().then(() => this.replayAmbient());
|
||||||
|
} else {
|
||||||
|
this.holdZoomButtons(false);
|
||||||
|
this.observer.disconnect();
|
||||||
|
this.stage?.stop();
|
||||||
|
this.stage?.element.remove();
|
||||||
|
}
|
||||||
|
this.syncButtons();
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoomButtons(): HTMLButtonElement[] {
|
||||||
|
return [...this.container.querySelectorAll<HTMLButtonElement>('button[data-view="in"], button[data-view="out"]')];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archify disables its zoom buttons at the 2D limits (100% and 300%). The
|
||||||
|
* 3D camera has its own range, so the buttons stay enabled while 3D is on,
|
||||||
|
* and get Archify's state back when 3D is turned off.
|
||||||
|
*/
|
||||||
|
private holdZoomButtons(on: boolean): void {
|
||||||
|
if (on) {
|
||||||
|
this.zoomDisabled = new Map(this.zoomButtons().map((b) => [b, b.disabled]));
|
||||||
|
for (const button of this.zoomButtons()) this.zoomObserver.observe(button, { attributes: true, attributeFilter: ['disabled'] });
|
||||||
|
this.enableZoomButtons();
|
||||||
|
} else {
|
||||||
|
this.zoomObserver.disconnect();
|
||||||
|
for (const [button, disabled] of this.zoomDisabled) button.disabled = disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enableZoomButtons(): void {
|
||||||
|
for (const button of this.zoomButtons()) if (button.disabled) button.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createStage(): void {
|
||||||
|
const stage = new Stage();
|
||||||
|
stage.onPick = (id) => this.pick(id);
|
||||||
|
stage.onHover = (id) => this.hover(id);
|
||||||
|
stage.onCameraChange = () => this.save();
|
||||||
|
if (this.saved.layered) stage.setLayered(true, false);
|
||||||
|
if (this.saved.camera) stage.restoreCamera(this.saved.camera);
|
||||||
|
this.stage = stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archify plays its ambient trace once, when the page opens. Opening 3D
|
||||||
|
* later replays it once, so the 3D view starts the way 2D did. Readers who
|
||||||
|
* paused motion (Live off) or prefer reduced motion get no replay.
|
||||||
|
*/
|
||||||
|
private replayAmbient(): void {
|
||||||
|
const html = document.documentElement;
|
||||||
|
if (this.ambientReplayed || !this.active) return;
|
||||||
|
// The load-time pass may still be playing (usually under the not-yet-built
|
||||||
|
// 3D view): wait for it to settle, then replay it in 3D.
|
||||||
|
if (html.getAttribute('data-ambient-motion') === 'running') {
|
||||||
|
const wait = new MutationObserver(() => {
|
||||||
|
if (html.getAttribute('data-ambient-motion') === 'running') return;
|
||||||
|
wait.disconnect();
|
||||||
|
this.replayAmbient();
|
||||||
|
});
|
||||||
|
wait.observe(html, { attributes: true, attributeFilter: ['data-ambient-motion'] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ambientReplayed = true;
|
||||||
|
if (html.getAttribute('data-ambient-motion') !== 'settled' || html.getAttribute('data-motion') === 'still') return;
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
if (!this.svg.querySelector('[data-animate]')) return;
|
||||||
|
// Restart Archify's CSS trace: the "settled" state must be applied
|
||||||
|
// (style flushed) before "running" again, or the animations do not restart.
|
||||||
|
html.setAttribute('data-ambient-motion', 'settled');
|
||||||
|
void getComputedStyle(this.svg.querySelector('[data-animate]')!).animationName;
|
||||||
|
html.setAttribute('data-ambient-motion', 'running');
|
||||||
|
const settle = () => html.setAttribute('data-ambient-motion', 'settled');
|
||||||
|
const done = () => {
|
||||||
|
if (this.svg.getAnimations({ subtree: true }).some((anim) => anim.playState === 'running')) return;
|
||||||
|
this.svg.removeEventListener('animationend', done, true);
|
||||||
|
this.svg.removeEventListener('animationcancel', done, true);
|
||||||
|
settle();
|
||||||
|
};
|
||||||
|
this.svg.addEventListener('animationend', done, true);
|
||||||
|
this.svg.addEventListener('animationcancel', done, true);
|
||||||
|
// Safety net if an animation never reports its end.
|
||||||
|
window.setTimeout(settle, 12000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private save(): void {
|
||||||
|
this.saved = { active: this.active, layered: this.stage?.layered ?? this.saved.layered, camera: this.stage?.cameraState() ?? this.saved.camera };
|
||||||
|
this.host.setState({ view3d: this.saved });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- picking
|
||||||
|
|
||||||
|
/** Hands a 3D click to Archify, as if the reader had clicked the 2D node. */
|
||||||
|
private pick(id: string | undefined): void {
|
||||||
|
const target = id ? this.svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`) : this.svg;
|
||||||
|
target?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private hovered: Element | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands 3D hover to Archify as pointer events on the SVG node, so its
|
||||||
|
* intent trace (one-hop flows, fading the rest) plays as in 2D.
|
||||||
|
*/
|
||||||
|
private hover(id: string | undefined): void {
|
||||||
|
const next = id ? this.svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`) ?? undefined : undefined;
|
||||||
|
if (next === this.hovered) return;
|
||||||
|
const init = { bubbles: true, cancelable: true, pointerType: 'mouse', isPrimary: true, view: window };
|
||||||
|
this.hovered?.dispatchEvent(new PointerEvent('pointerout', init));
|
||||||
|
next?.dispatchEvent(new PointerEvent('pointerover', init));
|
||||||
|
this.hovered = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- redraw
|
||||||
|
|
||||||
|
private onMutation(records: MutationRecord[]): void {
|
||||||
|
if (!records.some((r) => this.changesDrawing(r))) return;
|
||||||
|
window.clearTimeout(this.redrawTimer);
|
||||||
|
// Let Archify's 140–200ms state transitions finish before sampling styles.
|
||||||
|
this.redrawTimer = window.setTimeout(() => void this.redraw(), REDRAW_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a mutation changes what the textures show. Archify's camera
|
||||||
|
* (the SVG root's style), its temporary overlays and the ambient-motion
|
||||||
|
* flag only change motion, which the stage follows live.
|
||||||
|
*/
|
||||||
|
private changesDrawing(record: MutationRecord): boolean {
|
||||||
|
const target = record.target as Element;
|
||||||
|
if (target === this.svg && record.attributeName === 'style') return false;
|
||||||
|
if (target === document.documentElement) return record.attributeName === 'data-theme' || record.attributeName === 'data-preset';
|
||||||
|
// Intent trace only fades and draws flows; the stage mirrors both live.
|
||||||
|
if (record.attributeName?.startsWith('data-intent-trace')) return false;
|
||||||
|
const inOverlay = (node: Node) => node instanceof Element && !!node.closest('[data-relationship-pulse-overlay], [data-intent-trace-overlay], [data-route-probe-overlay], [data-route-journey-overlay], [data-semantic-lens-overlay], [data-story-overlay], [data-story-carrier-overlay], [data-chapter-handoff-overlay], [data-relationship-hit-overlay]');
|
||||||
|
if (record.type === 'childList') {
|
||||||
|
const nodes = [...record.addedNodes, ...record.removedNodes].filter((n) => n.nodeType === Node.ELEMENT_NODE);
|
||||||
|
if (nodes.length && nodes.every((n) => inOverlay(n) || [...(n as Element).getAttributeNames()].some((a) => a.endsWith('-overlay')))) return false;
|
||||||
|
return nodes.length > 0 || !inOverlay(target);
|
||||||
|
}
|
||||||
|
return !inOverlay(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async redraw(): Promise<void> {
|
||||||
|
if (this.drawing) {
|
||||||
|
this.pendingRedraw = true;
|
||||||
|
return this.drawing;
|
||||||
|
}
|
||||||
|
this.drawing = this.draw().finally(() => {
|
||||||
|
this.drawing = undefined;
|
||||||
|
if (this.pendingRedraw) {
|
||||||
|
this.pendingRedraw = false;
|
||||||
|
void this.redraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this.drawing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async draw(): Promise<void> {
|
||||||
|
if (!this.active || !this.stage) return;
|
||||||
|
await document.fonts?.ready;
|
||||||
|
const model = readModel(this.svg);
|
||||||
|
this.model = model;
|
||||||
|
const { viewBox } = model;
|
||||||
|
const area = { x: viewBox.x - MARGIN, y: viewBox.y - MARGIN, w: viewBox.w + MARGIN * 2, h: viewBox.h + MARGIN * 2 };
|
||||||
|
const scale = Math.min(TEXTURE_SCALE, 8192 / Math.max(area.w, area.h));
|
||||||
|
const root = getComputedStyle(document.documentElement);
|
||||||
|
const containerStyle = getComputedStyle(this.container);
|
||||||
|
const background = opaque(containerStyle.backgroundColor) ?? (root.getPropertyValue('--bg').trim() || '#020617');
|
||||||
|
const dot = root.getPropertyValue('--canvas-dot').trim();
|
||||||
|
|
||||||
|
const paintFloor = (ctx: CanvasRenderingContext2D) => {
|
||||||
|
ctx.fillStyle = background;
|
||||||
|
ctx.fillRect(area.x, area.y, area.w, area.h);
|
||||||
|
if (dot) {
|
||||||
|
ctx.fillStyle = dot;
|
||||||
|
for (let x = Math.ceil(area.x / 20) * 20; x < area.x + area.w; x += 20) {
|
||||||
|
for (let y = Math.ceil(area.y / 20) * 20; y < area.y + area.h; y += 20) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 0.9, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [floor, nodes, labels] = await Promise.all([
|
||||||
|
rasterize(layerMarkup(this.svg, viewBox, { keep: ['floor'] }, scale), viewBox, area, scale, paintFloor),
|
||||||
|
rasterize(layerMarkup(this.svg, viewBox, { keep: ['node'], opaqueNodes: true }, scale), viewBox, area, scale),
|
||||||
|
rasterize(layerMarkup(this.svg, viewBox, { keep: ['label'] }, scale), viewBox, area, scale),
|
||||||
|
]);
|
||||||
|
if (!this.active || this.model !== model) return;
|
||||||
|
const layers: Layers = { floor, nodes, labels };
|
||||||
|
const mask = root.getPropertyValue('--mask').trim() || '#0f172a';
|
||||||
|
this.stage.build(model, layers, { mask, dark: document.documentElement.getAttribute('data-theme') !== 'light' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- export
|
||||||
|
|
||||||
|
private exportPng(): void {
|
||||||
|
if (!this.stage) return;
|
||||||
|
const shot = new Image();
|
||||||
|
shot.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = shot.width;
|
||||||
|
canvas.height = shot.height;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.fillStyle = opaque(getComputedStyle(this.container).backgroundColor) ?? getComputedStyle(document.documentElement).getPropertyValue('--bg');
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.drawImage(shot, 0, 0);
|
||||||
|
const data = canvas.toDataURL('image/png');
|
||||||
|
const title = (document.querySelector('.header h1')?.textContent ?? 'archify').trim().replace(/[^\w.-]+/g, '-').toLowerCase();
|
||||||
|
this.host.post({ type: 'save', filename: `${title || 'archify'}-3d.png`, base64: data.slice(data.indexOf(',') + 1) });
|
||||||
|
};
|
||||||
|
shot.src = this.stage.snapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A CSS colour if it is not fully transparent. */
|
||||||
|
function opaque(value: string): string | undefined {
|
||||||
|
if (!value || value === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(value)) return undefined;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(): void {
|
||||||
|
const host = window.__archifyHost;
|
||||||
|
const container = document.querySelector<HTMLElement>('.diagram-container');
|
||||||
|
const svg = container?.querySelector<SVGSVGElement>(':scope > svg');
|
||||||
|
if (!host || !container || !svg) return;
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = STYLE;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
new Viewer3D(host, container, svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'complete') start();
|
||||||
|
else window.addEventListener('load', start, { once: true });
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Box, Role, roleOf } from './svgModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns parts of the live Archify SVG into images. Each element's computed
|
||||||
|
* style is written inline into a standalone copy, so the image carries the
|
||||||
|
* current theme, preset, focus and dimming exactly as the 2D view shows them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Presentation properties copied from computed style. */
|
||||||
|
const PROPS = [
|
||||||
|
'fill', 'fill-opacity', 'fill-rule',
|
||||||
|
'stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'stroke-dashoffset',
|
||||||
|
'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit',
|
||||||
|
// visibility is left out: the 3D view hides the 2D SVG with it.
|
||||||
|
'opacity', 'display', 'filter', 'paint-order',
|
||||||
|
'font-family', 'font-size', 'font-weight', 'font-style', 'letter-spacing', 'text-anchor',
|
||||||
|
'dominant-baseline', 'text-transform',
|
||||||
|
];
|
||||||
|
|
||||||
|
let fontCss: string | undefined;
|
||||||
|
|
||||||
|
/** The page's @font-face rules (Archify embeds JetBrains Mono as data URIs). */
|
||||||
|
function fontFaces(): string {
|
||||||
|
if (fontCss !== undefined) return fontCss;
|
||||||
|
const rules: string[] = [];
|
||||||
|
for (const sheet of [...document.styleSheets]) {
|
||||||
|
let list: CSSRuleList;
|
||||||
|
try {
|
||||||
|
list = sheet.cssRules;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const rule of [...list]) if (rule instanceof CSSFontFaceRule) rules.push(rule.cssText);
|
||||||
|
}
|
||||||
|
fontCss = rules.join('\n');
|
||||||
|
return fontCss;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayerOptions {
|
||||||
|
/** Roles whose elements are drawn; everything else is hidden. */
|
||||||
|
keep: Role[];
|
||||||
|
/** Draw kept nodes fully opaque; the 3D view applies their dimming itself. */
|
||||||
|
opaqueNodes?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies computed styles from the live tree onto a clone with the same shape. */
|
||||||
|
function inlineStyles(live: Element, copy: Element, options: LayerOptions, inherited: Role | undefined, root: boolean): void {
|
||||||
|
const role = inherited ?? roleOf(live);
|
||||||
|
const isDefs = live.tagName === 'defs' || !!live.closest('defs');
|
||||||
|
const style = getComputedStyle(live);
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const prop of PROPS) {
|
||||||
|
const value = style.getPropertyValue(prop);
|
||||||
|
if (value) parts.push(`${prop}:${value}`);
|
||||||
|
}
|
||||||
|
let hidden = false;
|
||||||
|
if (root) {
|
||||||
|
// The root carries viewer state (camera transform, our fade); keep only
|
||||||
|
// what its children inherit.
|
||||||
|
copy.setAttribute('style', parts.filter((p) => /^(font|letter|text)/.test(p)).join(';'));
|
||||||
|
} else {
|
||||||
|
if (!isDefs) {
|
||||||
|
if (role) hidden = !options.keep.includes(role);
|
||||||
|
else if (live.children.length === 0) hidden = !options.keep.includes('floor');
|
||||||
|
}
|
||||||
|
if (options.opaqueNodes && role === 'node' && !inherited) parts.push('opacity:1');
|
||||||
|
if (hidden) parts.push('display:none');
|
||||||
|
copy.setAttribute('style', parts.join(';'));
|
||||||
|
}
|
||||||
|
copy.removeAttribute('class');
|
||||||
|
if (hidden) return;
|
||||||
|
const liveKids = live.children;
|
||||||
|
const copyKids = copy.children;
|
||||||
|
for (let i = 0; i < liveKids.length && i < copyKids.length; i++) {
|
||||||
|
inlineStyles(liveKids[i], copyKids[i], options, role, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone SVG markup for one layer of the diagram, sized at `scale`
|
||||||
|
* pixels per user unit so the browser rasterizes it sharply.
|
||||||
|
*/
|
||||||
|
export function layerMarkup(svg: SVGSVGElement, viewBox: Box, options: LayerOptions, scale: number): string {
|
||||||
|
const copy = svg.cloneNode(true) as SVGSVGElement;
|
||||||
|
inlineStyles(svg, copy, options, undefined, true);
|
||||||
|
// Viewer-only children (focus overlays, hit areas) carry no data roles but
|
||||||
|
// are not part of the authored drawing.
|
||||||
|
copy.querySelectorAll('[data-viewer-only], .no-export').forEach((el) => el.remove());
|
||||||
|
copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
|
copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`);
|
||||||
|
copy.setAttribute('width', String(Math.round(viewBox.w * scale)));
|
||||||
|
copy.setAttribute('height', String(Math.round(viewBox.h * scale)));
|
||||||
|
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||||||
|
style.textContent = fontFaces();
|
||||||
|
copy.insertBefore(style, copy.firstChild);
|
||||||
|
return new XMLSerializer().serializeToString(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Raster {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
/** Pixels per SVG user unit. */
|
||||||
|
scale: number;
|
||||||
|
/** SVG area the canvas covers (the view box plus a margin). */
|
||||||
|
area: Box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rasterizes SVG markup over `area` (in user units) at `scale` pixels per unit. */
|
||||||
|
export async function rasterize(markup: string, viewBox: Box, area: Box, scale: number, paint?: (ctx: CanvasRenderingContext2D) => void): Promise<Raster> {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.round(area.w * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(area.h * scale));
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
ctx.translate(-area.x, -area.y);
|
||||||
|
paint?.(ctx);
|
||||||
|
const url = URL.createObjectURL(new Blob([markup], { type: 'image/svg+xml' }));
|
||||||
|
try {
|
||||||
|
const image = new Image();
|
||||||
|
image.src = url;
|
||||||
|
await image.decode();
|
||||||
|
ctx.drawImage(image, viewBox.x, viewBox.y, viewBox.w, viewBox.h);
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
return { canvas, scale, area };
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,41 @@
|
|||||||
|
/** Styles for the 3D mode; they reuse Archify's tokens so every preset fits. */
|
||||||
|
export const STYLE = `
|
||||||
|
.diagram-container { position: relative; }
|
||||||
|
/* The 2D SVG stays in the page (its styles and animations drive the 3D
|
||||||
|
view) but is not painted while 3D is on. */
|
||||||
|
html.archify-3d-on .diagram-container > svg { visibility: hidden !important; pointer-events: none !important; }
|
||||||
|
html.archify-3d-on .diagram-container { min-height: max(420px, 62vh); }
|
||||||
|
.archify-3d-stage { position: absolute; inset: 0; z-index: 1; border-radius: inherit; overflow: hidden; }
|
||||||
|
.archify-3d-stage canvas { display: block; touch-action: none; cursor: grab; }
|
||||||
|
.archify-3d-stage canvas:active { cursor: grabbing; }
|
||||||
|
html.archify-3d-on .diagram-container > :not(svg):not(.archify-3d-stage) { z-index: 2; }
|
||||||
|
|
||||||
|
/* 2D | 3D switch: Export's twin. Both states visible, the filled half is current. */
|
||||||
|
.toolbar #btn-3d { padding: 0 0.25rem; gap: 0; color: var(--text-muted); }
|
||||||
|
.toolbar #btn-3d::before { inset: 0.25rem 0; border-radius: 0.6875rem; background: color-mix(in srgb, var(--panel) 78%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--panel-border) 85%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); backdrop-filter: blur(12px); }
|
||||||
|
.toolbar #btn-3d:hover::before { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 22%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
|
||||||
|
.toolbar #btn-3d[aria-checked] { color: var(--text-muted); }
|
||||||
|
.archify-3d-seg { display: inline-flex; align-items: center; height: 1.75rem; padding: 0 0.7rem; border-radius: 0.5rem; font-weight: 550; transition: background 0.15s, color 0.15s; }
|
||||||
|
.archify-3d-seg.on { color: var(--bg); background: var(--text); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
|
||||||
|
.toolbar #btn-3d:hover .archify-3d-seg:not(.on) { color: var(--text); }
|
||||||
|
|
||||||
|
/* 3D-only controls in the bottom-right dock, in Archify's mode-button style. */
|
||||||
|
.diagram-nav #btn-3d-stack,
|
||||||
|
.diagram-nav #btn-3d-top { display: inline-flex; align-items: center; gap: 0.375rem; min-width: auto; padding-inline: 0.55rem 0.65rem; color: var(--text-muted); font-weight: 500; }
|
||||||
|
.diagram-nav #btn-3d-stack[hidden],
|
||||||
|
.diagram-nav #btn-3d-top[hidden] { display: none; }
|
||||||
|
.diagram-nav #btn-3d-stack { position: relative; margin-left: 0.375rem; }
|
||||||
|
.diagram-nav #btn-3d-stack::after { position: absolute; top: 0.55rem; bottom: 0.55rem; left: -0.19rem; width: 1px; background: color-mix(in srgb, var(--panel-border) 90%, transparent); content: ""; pointer-events: none; }
|
||||||
|
.diagram-nav #btn-3d-stack::before,
|
||||||
|
.diagram-nav #btn-3d-top::before { flex: none; width: 0.875rem; height: 0.875rem; background: currentColor; content: ""; opacity: 0.85; -webkit-mask: var(--nav-glyph) center / contain no-repeat; mask: var(--nav-glyph) center / contain no-repeat; }
|
||||||
|
.diagram-nav #btn-3d-stack { --nav-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linejoin='round'%3E%3Cpath d='M10 2.5 17.5 6.3 10 10 2.5 6.3z'/%3E%3Cpath d='m2.5 10 7.5 3.8 7.5-3.8M2.5 13.7 10 17.5l7.5-3.8'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||||
|
.diagram-nav #btn-3d-top { --nav-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5v9M6.5 8l3.5 3.5L13.5 8'/%3E%3Cpath d='M3 14.5h14M5 17.5h10'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||||
|
.diagram-nav #btn-3d-stack[aria-pressed="true"] { color: var(--text); background: color-mix(in srgb, var(--frontend-stroke) 14%, transparent); }
|
||||||
|
/* The stepper's middle button shows the 2D zoom level; in 3D it is the reset. */
|
||||||
|
.archify-3d-reset { display: none; color: var(--text-muted); font-size: 0.75rem; font-weight: 500; }
|
||||||
|
html.archify-3d-on .diagram-nav [data-view="reset"] > :not(.archify-3d-reset) { display: none !important; }
|
||||||
|
html.archify-3d-on .archify-3d-reset { display: inline; }
|
||||||
|
.toolbar .export-menu button[data-archify3d-export]::before { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||||
|
|
||||||
|
`;
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Reads the semantic parts of a rendered Archify SVG: nodes, routed
|
||||||
|
* relationships, their labels and structural frames. Archify marks them
|
||||||
|
* with the same data attributes in all five diagram types, so the 3D view
|
||||||
|
* needs no per-type code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Box {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeShape extends Box {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
rx: number;
|
||||||
|
el: SVGGElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeShape {
|
||||||
|
key: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
path: SVGPathElement;
|
||||||
|
/** Points along the route, every SAMPLE_STEP user units. */
|
||||||
|
points: [number, number][];
|
||||||
|
length: number;
|
||||||
|
animated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabelShape extends Box {
|
||||||
|
key: string;
|
||||||
|
el: SVGGElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrameShape extends Box {
|
||||||
|
kind: string;
|
||||||
|
rx: number;
|
||||||
|
el: SVGRectElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagramModel {
|
||||||
|
svg: SVGSVGElement;
|
||||||
|
viewBox: Box;
|
||||||
|
nodes: NodeShape[];
|
||||||
|
edges: EdgeShape[];
|
||||||
|
labels: LabelShape[];
|
||||||
|
frames: FrameShape[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_STEP = 3;
|
||||||
|
|
||||||
|
/** Role of an element for the 3D view; descendants share their ancestor's role. */
|
||||||
|
export type Role = 'node' | 'edge' | 'label' | 'floor' | 'overlay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archify's temporary viewer layers (focus pulse, intent trace, route probe,
|
||||||
|
* journey, lens, story) are marked data-…-overlay. They are animated in 3D
|
||||||
|
* from the live SVG and never baked into the textures.
|
||||||
|
*/
|
||||||
|
function isOverlay(el: Element): boolean {
|
||||||
|
for (const name of el.getAttributeNames()) if (name.startsWith('data-') && name.endsWith('-overlay')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flow paths inside those overlays: clones of a relationship path with pathLength="1". */
|
||||||
|
export const FLOW_SELECTOR = [
|
||||||
|
'relationship-pulse',
|
||||||
|
'intent-trace',
|
||||||
|
'route-probe',
|
||||||
|
'route-journey',
|
||||||
|
'semantic-lens',
|
||||||
|
'story',
|
||||||
|
'story-carrier',
|
||||||
|
'chapter-handoff',
|
||||||
|
].map((name) => `[data-${name}-overlay] path[pathLength]`).join(', ');
|
||||||
|
|
||||||
|
export function roleOf(el: Element): Role | undefined {
|
||||||
|
if (isOverlay(el)) return 'overlay';
|
||||||
|
if (el.hasAttribute('data-node-id')) return 'node';
|
||||||
|
if (el.getAttribute('data-graph-role') === 'automatic-crossover') return 'edge';
|
||||||
|
if (el.tagName === 'path' && el.hasAttribute('data-edge-from')) return 'edge';
|
||||||
|
if (el.getAttribute('data-graph-role') === 'automatic-crossover-underlay') return 'edge';
|
||||||
|
if (el.tagName === 'g' && el.hasAttribute('data-edge-key') && el.querySelector('text')) return 'label';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(el: Element, name: string): number {
|
||||||
|
return Number(el.getAttribute(name) ?? 0) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectBox(rect: SVGRectElement): Box & { rx: number } {
|
||||||
|
return { x: num(rect, 'x'), y: num(rect, 'y'), w: num(rect, 'width'), h: num(rect, 'height'), rx: num(rect, 'rx') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeShape(el: SVGGElement): NodeShape | undefined {
|
||||||
|
const id = el.getAttribute('data-node-id');
|
||||||
|
if (!id) return undefined;
|
||||||
|
const kind = el.getAttribute('data-node-kind') ?? 'backend';
|
||||||
|
// The node body is the semantic rect (c-<kind>); c-mask sits under it.
|
||||||
|
const rect =
|
||||||
|
el.querySelector<SVGRectElement>(`:scope > rect.c-${CSS.escape(kind)}`) ??
|
||||||
|
el.querySelector<SVGRectElement>(':scope > rect:not(.c-mask)') ??
|
||||||
|
el.querySelector<SVGRectElement>(':scope > rect');
|
||||||
|
if (rect) return { id, kind, el, ...rectBox(rect) };
|
||||||
|
const bbox = el.getBBox();
|
||||||
|
return { id, kind, el, x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height, rx: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function samplePath(path: SVGPathElement): { points: [number, number][]; length: number } {
|
||||||
|
const length = path.getTotalLength();
|
||||||
|
const steps = Math.max(2, Math.ceil(length / SAMPLE_STEP));
|
||||||
|
const points: [number, number][] = [];
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const p = path.getPointAtLength((length * i) / steps);
|
||||||
|
points.push([p.x, p.y]);
|
||||||
|
}
|
||||||
|
return { points, length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readModel(svg: SVGSVGElement): DiagramModel {
|
||||||
|
const vb = svg.viewBox.baseVal;
|
||||||
|
const viewBox = vb && vb.width > 0 ? { x: vb.x, y: vb.y, w: vb.width, h: vb.height } : { x: 0, y: 0, w: svg.clientWidth, h: svg.clientHeight };
|
||||||
|
|
||||||
|
const nodes = [...svg.querySelectorAll<SVGGElement>('g[data-node-id]')]
|
||||||
|
.map(nodeShape)
|
||||||
|
.filter((n): n is NodeShape => !!n && n.w > 0 && n.h > 0);
|
||||||
|
|
||||||
|
const edges: EdgeShape[] = [];
|
||||||
|
svg.querySelectorAll<SVGPathElement>('path[data-edge-from]:not([data-graph-role])').forEach((path, index) => {
|
||||||
|
const from = path.getAttribute('data-edge-from') ?? '';
|
||||||
|
const to = path.getAttribute('data-edge-to') ?? '';
|
||||||
|
const group = path.parentElement;
|
||||||
|
const { points, length } = samplePath(path);
|
||||||
|
if (length <= 0) return;
|
||||||
|
edges.push({
|
||||||
|
key: path.getAttribute('data-edge-key') ?? `e${index}`,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
path,
|
||||||
|
points,
|
||||||
|
length,
|
||||||
|
animated: path.hasAttribute('data-animate') || !!group?.hasAttribute('data-animate'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels: LabelShape[] = [];
|
||||||
|
svg.querySelectorAll<SVGGElement>('g[data-edge-key]').forEach((el) => {
|
||||||
|
if (roleOf(el) !== 'label') return;
|
||||||
|
const rect = el.querySelector<SVGRectElement>(':scope > rect');
|
||||||
|
const box = rect ? rectBox(rect) : (() => {
|
||||||
|
const b = el.getBBox();
|
||||||
|
return { x: b.x, y: b.y, w: b.width, h: b.height, rx: 0 };
|
||||||
|
})();
|
||||||
|
if (box.w > 0 && box.h > 0) labels.push({ key: el.getAttribute('data-edge-key') ?? '', el, ...box });
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = [...svg.querySelectorAll<SVGRectElement>('rect[data-graph-role="structural-frame"]')].map((el) => ({
|
||||||
|
kind: el.getAttribute('data-composition-frame-kind') ?? 'region',
|
||||||
|
el,
|
||||||
|
...rectBox(el),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { svg, viewBox, nodes, edges, labels, frames };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opacity of an element including its ancestors below the SVG root. The
|
||||||
|
* root is left out: the 3D view hides the 2D SVG by fading the root.
|
||||||
|
*/
|
||||||
|
export function effectiveOpacity(el: Element, root: Element): number {
|
||||||
|
let value = 1;
|
||||||
|
for (let cur: Element | null = el; cur && cur !== root; cur = cur.parentElement) {
|
||||||
|
const style = getComputedStyle(cur);
|
||||||
|
if (style.display === 'none') return 0;
|
||||||
|
value *= Number(style.opacity || 1);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { isDecisionFile, parseDecision } from '../model/decisions';
|
||||||
|
import { isMainFile, isRegistryFile } from '../model/format';
|
||||||
|
import { DecisionPanel, decisionPanelFromIndex, decisionPanelStandalone } 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 { decisionPage, makeNonce } from '../webview/page';
|
||||||
|
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||||
|
|
||||||
|
export { DECISION_VIEW_TYPE };
|
||||||
|
|
||||||
|
class DecisionDocument implements vscode.CustomDocument {
|
||||||
|
constructor(readonly uri: vscode.Uri) {}
|
||||||
|
dispose(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DecisionView 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);
|
||||||
|
if (message.type === 'openDecision') return void this.openInModel(message.file, DECISION_VIEW_TYPE);
|
||||||
|
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> {
|
||||||
|
if (!this.root) return;
|
||||||
|
const target = vscode.Uri.joinPath(this.root, ...file.split('/'));
|
||||||
|
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 decision: DecisionPanel;
|
||||||
|
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('/');
|
||||||
|
decision = decisionPanelFromIndex(await this.models.index(this.root), rel);
|
||||||
|
} else {
|
||||||
|
const name = path.basename(this.uri.path);
|
||||||
|
decision = decisionPanelStandalone(name, parseDecision(await readText(this.uri), name));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log().warn(`Decision view failed for ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seq !== this.seq) return;
|
||||||
|
const fingerprint = JSON.stringify(decision);
|
||||||
|
if (fingerprint === this.last) return;
|
||||||
|
this.last = fingerprint;
|
||||||
|
this.panel.webview.html = decisionPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce() }, decision);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
for (const d of this.disposables.splice(0)) d.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only custom editor for .archify-decision files: the decision as a page, with its elements and diagrams. */
|
||||||
|
export class DecisionViewProvider implements vscode.CustomReadonlyEditorProvider<DecisionDocument>, vscode.Disposable {
|
||||||
|
private readonly views = new Set<DecisionView>();
|
||||||
|
private active: DecisionView | undefined;
|
||||||
|
private readonly disposables: vscode.Disposable[];
|
||||||
|
|
||||||
|
constructor(private readonly models: ModelService) {
|
||||||
|
this.disposables = [
|
||||||
|
vscode.window.registerCustomEditorProvider(DECISION_VIEW_TYPE, this, {
|
||||||
|
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||||
|
supportsMultipleEditorsPerDocument: true,
|
||||||
|
}),
|
||||||
|
models.onDidChangeModel(({ root }) => {
|
||||||
|
for (const view of this.views) if (view.root?.toString() === root.toString()) view.scheduleUpdate();
|
||||||
|
}),
|
||||||
|
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||||
|
if (!isDecisionFile(event.document.fileName) && !isRegistryFile(event.document.fileName) && !isMainFile(event.document.fileName)) return;
|
||||||
|
for (const view of this.views) if (!view.root && view.uri.toString() === event.document.uri.toString()) view.scheduleUpdate();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File shown in the focused decision view, if any. */
|
||||||
|
get activeUri(): vscode.Uri | undefined {
|
||||||
|
return this.active?.uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
openCustomDocument(uri: vscode.Uri): DecisionDocument {
|
||||||
|
return new DecisionDocument(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveCustomEditor(document: DecisionDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||||
|
panel.webview.options = { enableScripts: true };
|
||||||
|
const view = new DecisionView(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { readDiagramSource } from './detect';
|
import { renderDiagram } from '../archify/cli';
|
||||||
import { renderDiagram } from './renderer';
|
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||||
import { evidenceMode, log, readText, runtimeFor, viewerTheme } from './runtime';
|
import { isMainFile, isRegistryFile } from '../model/format';
|
||||||
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
|
import { ModelPanel, panelFromIndex } from '../model/panel';
|
||||||
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
|
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, modelPage, prepareDiagramHtml } from '../webview/page';
|
||||||
|
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||||
|
|
||||||
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||||
/** Default editor for .archify files: the rendered diagram. */
|
|
||||||
export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram';
|
export { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE };
|
||||||
|
|
||||||
interface PreviewState {
|
interface PreviewState {
|
||||||
uri: string;
|
uri: string;
|
||||||
@@ -24,9 +32,12 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
private readonly onDisposeEmitter = new vscode.EventEmitter<void>();
|
private readonly onDisposeEmitter = new vscode.EventEmitter<void>();
|
||||||
readonly onDidDispose = this.onDisposeEmitter.event;
|
readonly onDidDispose = this.onDisposeEmitter.event;
|
||||||
|
/** Root of the model this file belongs to, once known. */
|
||||||
|
root: vscode.Uri | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly context: vscode.ExtensionContext,
|
private readonly context: vscode.ExtensionContext,
|
||||||
|
private readonly models: ModelService,
|
||||||
readonly panel: vscode.WebviewPanel,
|
readonly panel: vscode.WebviewPanel,
|
||||||
readonly uri: vscode.Uri,
|
readonly uri: vscode.Uri,
|
||||||
state?: PreviewState,
|
state?: PreviewState,
|
||||||
@@ -34,7 +45,7 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
readonly isEditor = false,
|
readonly isEditor = false,
|
||||||
) {
|
) {
|
||||||
this.hash = state?.hash;
|
this.hash = state?.hash;
|
||||||
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
|
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
|
||||||
if (!isEditor) {
|
if (!isEditor) {
|
||||||
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
|
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
|
||||||
panel.title = `Preview ${path.basename(uri.path)}`;
|
panel.title = `Preview ${path.basename(uri.path)}`;
|
||||||
@@ -47,6 +58,18 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
this.hash = message.hash;
|
this.hash = message.hash;
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
|
if (message.type === 'openDecision') {
|
||||||
|
void this.openLinked(message.file, DECISION_VIEW_TYPE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void handleWebviewMessage(message, this.uri, this.panel);
|
void handleWebviewMessage(message, this.uri, this.panel);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -80,6 +103,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 {
|
scheduleUpdate(): void {
|
||||||
const delay = vscode.workspace.getConfiguration('archify', this.uri).get<number>('preview.debounceMs', 400);
|
const delay = vscode.workspace.getConfiguration('archify', this.uri).get<number>('preview.debounceMs', 400);
|
||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
@@ -96,14 +140,37 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
this.showMessage('Cannot read diagram', this.uri.fsPath, error instanceof Error ? error.message : String(error));
|
this.showMessage('Cannot read diagram', this.uri.fsPath, error instanceof Error ? error.message : String(error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const source = readDiagramSource(text, this.uri.path);
|
// main.archify: the model page wraps the diagram, or stands alone.
|
||||||
if (!source) {
|
const main = isMainFile(this.uri.path) && this.uri.scheme === 'file';
|
||||||
|
let model: ModelPanel | undefined;
|
||||||
|
if (main) {
|
||||||
|
this.root = vscode.Uri.file(path.dirname(this.uri.fsPath));
|
||||||
|
try {
|
||||||
|
model = panelFromIndex(await this.models.index(this.root));
|
||||||
|
} catch (error) {
|
||||||
|
log().warn(`Model index failed for ${this.root.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let prepared;
|
||||||
|
try {
|
||||||
|
prepared = await prepareDiagram(this.models, this.uri, text);
|
||||||
|
} catch (error) {
|
||||||
|
log().warn(`Could not resolve ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
if (seq !== this.seq) return;
|
||||||
|
if (!main) this.root = prepared?.root;
|
||||||
|
if (!prepared) {
|
||||||
|
if (model) {
|
||||||
|
this.showModelPage(model);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.showMessage(
|
this.showMessage(
|
||||||
'Nothing to show yet',
|
'Nothing to show yet',
|
||||||
'Set "diagram_type" to architecture, workflow, sequence, dataflow or lifecycle, and make sure the file is valid JSON.',
|
`Set "diagram_type" to architecture, workflow, sequence, dataflow or lifecycle, and make sure the file is valid ${isArchifyFile(this.uri.path) ? 'YAML' : 'JSON'}.`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const source = prepared.source;
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
@@ -134,9 +201,14 @@ class DiagramPreview implements vscode.Disposable {
|
|||||||
hash: this.hash,
|
hash: this.hash,
|
||||||
state: this.state(),
|
state: this.state(),
|
||||||
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
|
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
|
||||||
|
viewer3d: viewer3dOption(this.panel.webview, this.context.extensionUri),
|
||||||
|
links: Object.fromEntries(prepared.links),
|
||||||
|
descriptions: Object.fromEntries(prepared.descriptions),
|
||||||
|
decisions: prepared.decisions,
|
||||||
|
model,
|
||||||
});
|
});
|
||||||
// Nonces differ per render, so compare the renderer output instead.
|
// Nonces differ per render, so compare the renderer output instead.
|
||||||
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${result.html}`;
|
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${JSON.stringify([model ?? null, [...prepared.links], [...prepared.descriptions], prepared.decisions])}\u0000${result.html}`;
|
||||||
if (!force && this.rendered && fingerprint === this.lastHtml) {
|
if (!force && this.rendered && fingerprint === this.lastHtml) {
|
||||||
void this.panel.webview.postMessage({ type: 'notice', notice: undefined });
|
void this.panel.webview.postMessage({ type: 'notice', notice: undefined });
|
||||||
return;
|
return;
|
||||||
@@ -158,7 +230,7 @@ function firstLines(text: string | undefined, count = 6): string {
|
|||||||
return (text ?? '').split('\n').slice(0, count).join('\n');
|
return (text ?? '').split('\n').slice(0, count).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PreviewManager
|
export class DiagramViewManager
|
||||||
implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState>, vscode.CustomTextEditorProvider
|
implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState>, vscode.CustomTextEditorProvider
|
||||||
{
|
{
|
||||||
/** Side previews, one per source file. */
|
/** Side previews, one per source file. */
|
||||||
@@ -168,8 +240,20 @@ export class PreviewManager
|
|||||||
private active: DiagramPreview | undefined;
|
private active: DiagramPreview | undefined;
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
|
|
||||||
constructor(private readonly context: vscode.ExtensionContext) {
|
constructor(
|
||||||
|
private readonly context: vscode.ExtensionContext,
|
||||||
|
private readonly models: ModelService,
|
||||||
|
) {
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
|
// A registry or main.archify change re-renders every diagram of the model;
|
||||||
|
// any other file change only refreshes the model page's list.
|
||||||
|
models.onDidChangeModel(({ root, file }) => {
|
||||||
|
const whole = isRegistryFile(file.path) || isMainFile(file.path);
|
||||||
|
for (const preview of [...this.previews.values(), ...this.editors]) {
|
||||||
|
if (preview.root?.toString() !== root.toString()) continue;
|
||||||
|
if (whole || isMainFile(preview.uri.path)) preview.scheduleUpdate();
|
||||||
|
}
|
||||||
|
}),
|
||||||
vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this),
|
vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this),
|
||||||
vscode.window.registerCustomEditorProvider(DIAGRAM_EDITOR_VIEW_TYPE, this, {
|
vscode.window.registerCustomEditorProvider(DIAGRAM_EDITOR_VIEW_TYPE, this, {
|
||||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||||
@@ -204,8 +288,8 @@ export class PreviewManager
|
|||||||
|
|
||||||
/** Opening a .archify file lands here: the diagram is the editor. */
|
/** Opening a .archify file lands here: the diagram is the editor. */
|
||||||
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
|
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||||
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
|
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
|
||||||
this.track(new DiagramPreview(this.context, panel, document.uri, undefined, true));
|
this.track(new DiagramPreview(this.context, this.models, panel, document.uri, undefined, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
|
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
|
||||||
@@ -218,9 +302,9 @@ export class PreviewManager
|
|||||||
PREVIEW_VIEW_TYPE,
|
PREVIEW_VIEW_TYPE,
|
||||||
`Preview ${path.basename(uri.path)}`,
|
`Preview ${path.basename(uri.path)}`,
|
||||||
{ viewColumn, preserveFocus },
|
{ viewColumn, preserveFocus },
|
||||||
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: [] },
|
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) },
|
||||||
);
|
);
|
||||||
this.track(new DiagramPreview(this.context, panel, uri));
|
this.track(new DiagramPreview(this.context, this.models, panel, uri));
|
||||||
}
|
}
|
||||||
|
|
||||||
async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> {
|
async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> {
|
||||||
@@ -228,7 +312,7 @@ export class PreviewManager
|
|||||||
panel.dispose();
|
panel.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.track(new DiagramPreview(this.context, panel, vscode.Uri.parse(state.uri), state));
|
this.track(new DiagramPreview(this.context, this.models, panel, vscode.Uri.parse(state.uri), state));
|
||||||
}
|
}
|
||||||
|
|
||||||
private track(preview: DiagramPreview): void {
|
private track(preview: DiagramPreview): void {
|
||||||
@@ -242,6 +326,7 @@ export class PreviewManager
|
|||||||
if (preview.panel.active) this.active = preview;
|
if (preview.panel.active) this.active = preview;
|
||||||
else if (this.active === preview) this.active = undefined;
|
else if (this.active === preview) this.active = undefined;
|
||||||
void vscode.commands.executeCommand('setContext', 'archifyPreviewFocus', Boolean(this.active));
|
void vscode.commands.executeCommand('setContext', 'archifyPreviewFocus', Boolean(this.active));
|
||||||
|
if (preview.panel.active) void vscode.commands.executeCommand('setContext', 'archify.inModel', Boolean(preview.root));
|
||||||
};
|
};
|
||||||
preview.panel.onDidChangeViewState(onViewState);
|
preview.panel.onDidChangeViewState(onViewState);
|
||||||
onViewState();
|
onViewState();
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { isArchifyHtml } from './detect';
|
import { isArchifyHtml } from '../sources/detect';
|
||||||
import { viewerTheme } from './runtime';
|
import { viewerTheme } from '../util/settings';
|
||||||
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
|
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||||
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
|
import { makeNonce, messagePage, prepareDiagramHtml } from '../webview/page';
|
||||||
|
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||||
|
|
||||||
export const HTML_VIEW_TYPE = 'archify.htmlViewer';
|
export const HTML_VIEW_TYPE = 'archify.htmlViewer';
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<H
|
|||||||
private active: { uri: vscode.Uri; panel: vscode.WebviewPanel } | undefined;
|
private active: { uri: vscode.Uri; panel: vscode.WebviewPanel } | undefined;
|
||||||
private readonly disposables: vscode.Disposable[];
|
private readonly disposables: vscode.Disposable[];
|
||||||
|
|
||||||
constructor() {
|
constructor(private readonly extensionUri: vscode.Uri) {
|
||||||
this.disposables = [
|
this.disposables = [
|
||||||
vscode.window.registerCustomEditorProvider(HTML_VIEW_TYPE, this, {
|
vscode.window.registerCustomEditorProvider(HTML_VIEW_TYPE, this, {
|
||||||
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
|
||||||
@@ -47,7 +48,7 @@ export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<H
|
|||||||
async resolveCustomEditor(document: HtmlDocument, panel: vscode.WebviewPanel): Promise<void> {
|
async resolveCustomEditor(document: HtmlDocument, panel: vscode.WebviewPanel): Promise<void> {
|
||||||
const uri = document.uri;
|
const uri = document.uri;
|
||||||
let hash: string | undefined;
|
let hash: string | undefined;
|
||||||
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
|
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.extensionUri) };
|
||||||
|
|
||||||
const reload = async () => {
|
const reload = async () => {
|
||||||
let text: string;
|
let text: string;
|
||||||
@@ -70,6 +71,7 @@ export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<H
|
|||||||
notice: isArchifyHtml(text)
|
notice: isArchifyHtml(text)
|
||||||
? undefined
|
? undefined
|
||||||
: { kind: 'info', text: 'This file was not generated by Archify. It is shown in a sandbox without network access.' },
|
: { kind: 'info', text: 'This file was not generated by Archify. It is shown in a sandbox without network access.' },
|
||||||
|
viewer3d: isArchifyHtml(text) ? viewer3dOption(panel.webview, this.extensionUri) : undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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 { DECISION_VIEW_TYPE, 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);
|
||||||
|
if (message.type === 'openDecision') return void this.openInModel(message.file, DECISION_VIEW_TYPE);
|
||||||
|
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,8 @@
|
|||||||
|
/** 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';
|
||||||
|
/** Default editor for .archify-decision files: the decision as a page. */
|
||||||
|
export const DECISION_VIEW_TYPE = 'archify.decision';
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { log, repoRootFor } from './runtime';
|
import { repoRootFor } from '../archify/runtime';
|
||||||
|
import { log } from '../util/log';
|
||||||
|
|
||||||
export type WebviewMessage =
|
export type WebviewMessage =
|
||||||
| { type: 'hash'; hash: string }
|
| { type: 'hash'; hash: string }
|
||||||
@@ -8,6 +9,9 @@ export type WebviewMessage =
|
|||||||
| { type: 'openExternal'; href: string }
|
| { type: 'openExternal'; href: string }
|
||||||
| { type: 'openRelative'; href: string }
|
| { type: 'openRelative'; href: string }
|
||||||
| { type: 'openSource'; path: string; line?: number; endLine?: number; href?: string }
|
| { type: 'openSource'; path: string; line?: number; endLine?: number; href?: string }
|
||||||
|
| { type: 'openDiagram'; path: string }
|
||||||
|
| { type: 'openRegistry'; file: string; line?: number }
|
||||||
|
| { type: 'openDecision'; file: string }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
/** Column for opening files next to a webview without covering it. */
|
/** Column for opening files next to a webview without covering it. */
|
||||||
@@ -91,6 +95,8 @@ export async function handleWebviewMessage(
|
|||||||
vscode.window.showErrorMessage(`Archify: ${message.message}`);
|
vscode.window.showErrorMessage(`Archify: ${message.message}`);
|
||||||
break;
|
break;
|
||||||
case 'hash':
|
case 'hash':
|
||||||
|
case 'openDiagram':
|
||||||
|
// Handled by the view, which knows the model root.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -0,0 +1,935 @@
|
|||||||
|
export type ViewerTheme = 'dark' | 'light' | undefined;
|
||||||
|
|
||||||
|
export interface PageOptions {
|
||||||
|
/** webview.cspSource */
|
||||||
|
cspSource: string;
|
||||||
|
nonce: string;
|
||||||
|
/** Forced diagram theme; undefined keeps the diagram's own choice. */
|
||||||
|
theme: ViewerTheme;
|
||||||
|
/** Location hash to restore (Archify keeps focus/route state there). */
|
||||||
|
hash?: string;
|
||||||
|
/** Opaque state handed to vscode.setState for panel restore. */
|
||||||
|
state?: unknown;
|
||||||
|
/** Banner shown on top of the diagram. */
|
||||||
|
notice?: { text: string; kind: 'info' | 'warning' | 'error' };
|
||||||
|
/** The 3D viewer script (a webview URI), added to Archify pages. */
|
||||||
|
viewer3d?: { scriptUri: string; defaultView: '2d' | '3d' };
|
||||||
|
/** 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;
|
||||||
|
/** Decision page: one .archify-decision file. */
|
||||||
|
decision?: DecisionPanel;
|
||||||
|
/** Diagram page: decisions of this diagram, for the Decisions rail panel. */
|
||||||
|
decisions?: DiagramDecision[];
|
||||||
|
}
|
||||||
|
|
||||||
|
import type { DecisionPanel, ModelPanel, RegistryPanel } from '../model/panel';
|
||||||
|
import type { DiagramDecision } from '../model/prepare';
|
||||||
|
|
||||||
|
export function makeNonce(): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let nonce = '';
|
||||||
|
for (let i = 0; i < 32; i += 1) nonce += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
return nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON that is safe to embed inside an inline <script>. */
|
||||||
|
function scriptJson(value: unknown): string {
|
||||||
|
return JSON.stringify(value ?? null)
|
||||||
|
.replace(/</g, '\\u003c')
|
||||||
|
.replace(/>/g, '\\u003e')
|
||||||
|
.replace(/\u2028/g, '\\u2028')
|
||||||
|
.replace(/\u2029/g, '\\u2029');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contentSecurityPolicy(cspSource: string, nonce: string): string {
|
||||||
|
return [
|
||||||
|
"default-src 'none'",
|
||||||
|
`img-src ${cspSource} https: data: blob:`,
|
||||||
|
`media-src ${cspSource} data: blob:`,
|
||||||
|
`font-src ${cspSource} data:`,
|
||||||
|
`style-src ${cspSource} 'unsafe-inline'`,
|
||||||
|
`script-src ${cspSource} 'nonce-${nonce}'`,
|
||||||
|
'connect-src data: blob:',
|
||||||
|
'worker-src blob:',
|
||||||
|
].join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs before any page script: pins the theme, restores the hash, and
|
||||||
|
* routes links, window.open and export downloads to the extension, since a
|
||||||
|
* webview cannot navigate or download on its own.
|
||||||
|
*/
|
||||||
|
function bridgeScript(options: PageOptions): string {
|
||||||
|
const config = {
|
||||||
|
theme: options.theme,
|
||||||
|
hash: options.hash,
|
||||||
|
state: options.state,
|
||||||
|
notice: options.notice,
|
||||||
|
defaultView: options.viewer3d?.defaultView,
|
||||||
|
links: options.links ?? {},
|
||||||
|
model: options.model,
|
||||||
|
registry: options.registry,
|
||||||
|
decision: options.decision,
|
||||||
|
decisions: options.decisions || [],
|
||||||
|
descriptions: options.descriptions || {},
|
||||||
|
};
|
||||||
|
return `(function () {
|
||||||
|
var config = ${scriptJson(config)};
|
||||||
|
var vscode = acquireVsCodeApi();
|
||||||
|
if (config.state) vscode.setState(Object.assign({}, vscode.getState() || {}, config.state));
|
||||||
|
if (config.theme) {
|
||||||
|
try { localStorage.setItem('archify-theme', config.theme); } catch (_) {}
|
||||||
|
}
|
||||||
|
if (config.hash && !location.hash) {
|
||||||
|
try { history.replaceState(null, '', config.hash); } catch (_) {}
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', function () {
|
||||||
|
if (config.state) vscode.setState(Object.assign({}, vscode.getState() || {}, config.state, { hash: location.hash }));
|
||||||
|
vscode.postMessage({ type: 'hash', hash: location.hash });
|
||||||
|
});
|
||||||
|
|
||||||
|
function post(message) { vscode.postMessage(message); }
|
||||||
|
|
||||||
|
// For the 3D viewer, which cannot call acquireVsCodeApi a second time.
|
||||||
|
window.__archifyHost = {
|
||||||
|
defaultView: config.defaultView || '2d',
|
||||||
|
post: post,
|
||||||
|
getState: function () { return vscode.getState() || {}; },
|
||||||
|
setState: function (patch) { vscode.setState(Object.assign({}, vscode.getState() || {}, patch)); }
|
||||||
|
};
|
||||||
|
|
||||||
|
function saveBlobUrl(url, filename) {
|
||||||
|
fetch(url).then(function (response) { return response.blob(); }).then(function (blob) {
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function () {
|
||||||
|
var data = String(reader.result);
|
||||||
|
post({ type: 'save', filename: filename || 'archify-export', base64: data.slice(data.indexOf(',') + 1) });
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
}).catch(function (error) {
|
||||||
|
post({ type: 'error', message: 'Export failed: ' + (error && error.message || error) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
var target = event.target;
|
||||||
|
var anchor = target && target.closest ? target.closest('a[href]') : null;
|
||||||
|
if (!anchor) return;
|
||||||
|
var href = anchor.getAttribute('href') || '';
|
||||||
|
if (anchor.hasAttribute('download') && /^(blob|data):/i.test(anchor.href)) {
|
||||||
|
event.preventDefault();
|
||||||
|
saveBlobUrl(anchor.href, anchor.getAttribute('download'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (href.charAt(0) === '#') return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (anchor.classList.contains('semantic-passport-source')) {
|
||||||
|
var pathNode = anchor.querySelector('small');
|
||||||
|
var match = /#L(\\d+)(?:-L(\\d+))?$/.exec(anchor.href);
|
||||||
|
post({
|
||||||
|
type: 'openSource',
|
||||||
|
path: pathNode ? pathNode.textContent : '',
|
||||||
|
line: match ? Number(match[1]) : undefined,
|
||||||
|
endLine: match && match[2] ? Number(match[2]) : undefined,
|
||||||
|
href: anchor.href
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/^(https?|mailto):/i.test(href)) post({ type: 'openExternal', href: href });
|
||||||
|
else post({ type: 'openRelative', href: href });
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
var nativeOpen = window.open;
|
||||||
|
window.open = function (url) {
|
||||||
|
if (typeof url === 'string' && /^(https?|mailto):/i.test(url)) {
|
||||||
|
post({ type: 'openExternal', href: url });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return nativeOpen ? nativeOpen.apply(window, arguments) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function showNotice(notice) {
|
||||||
|
var existing = document.getElementById('archify-vscode-notice');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
if (!notice || !notice.text) return;
|
||||||
|
var bar = document.createElement('div');
|
||||||
|
bar.id = 'archify-vscode-notice';
|
||||||
|
bar.setAttribute('role', notice.kind === 'error' ? 'alert' : 'status');
|
||||||
|
bar.className = 'archify-vscode-notice archify-vscode-notice-' + notice.kind;
|
||||||
|
var text = document.createElement('span');
|
||||||
|
text.textContent = notice.text;
|
||||||
|
var close = document.createElement('button');
|
||||||
|
close.type = 'button';
|
||||||
|
close.textContent = '\\u00d7';
|
||||||
|
close.title = 'Dismiss';
|
||||||
|
close.addEventListener('click', function () { bar.remove(); });
|
||||||
|
bar.appendChild(text);
|
||||||
|
bar.appendChild(close);
|
||||||
|
(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);
|
||||||
|
if (element.decisions && element.decisions.length) {
|
||||||
|
var decided = el('li', 'archify-model-card-used');
|
||||||
|
element.decisions.forEach(function (row) { decided.appendChild(decisionChip(row)); });
|
||||||
|
list.appendChild(decided);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A decision as a chip (id, title, status) that opens its page.
|
||||||
|
function decisionChip(row) {
|
||||||
|
var chip = el('button', 'archify-model-used-link archify-decision-chip is-' + row.status, '');
|
||||||
|
chip.type = 'button';
|
||||||
|
chip.appendChild(el('span', 'archify-decision-dot'));
|
||||||
|
chip.appendChild(document.createTextNode(row.id + (row.title ? ' · ' + row.title : '')));
|
||||||
|
chip.title = 'Decision ' + row.id + (row.title ? ': ' + row.title : '') + ' (' + row.status + ')';
|
||||||
|
chip.addEventListener('click', function (event) { event.stopPropagation(); if (row.file) post({ type: 'openDecision', file: row.file }); });
|
||||||
|
return chip;
|
||||||
|
}
|
||||||
|
function statusBadge(row) {
|
||||||
|
var badge = el('span', 'archify-decision-status is-' + row.status, row.status + (row.supersededBy ? ' by ' + row.supersededBy : ''));
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
// A decision as a row in a list: id, title, status, date; click opens it.
|
||||||
|
function decisionRowItem(row) {
|
||||||
|
var item = el('li', 'archify-model-item archify-decision-item is-' + row.status);
|
||||||
|
var button = el('button', 'archify-model-open archify-decision-open');
|
||||||
|
button.type = 'button';
|
||||||
|
button.title = row.file || row.id;
|
||||||
|
var line = el('span', 'archify-decision-line');
|
||||||
|
line.appendChild(el('span', 'archify-decision-id', row.id));
|
||||||
|
line.appendChild(el('span', 'archify-model-item-title', row.title || ''));
|
||||||
|
line.appendChild(statusBadge(row));
|
||||||
|
if (row.date) line.appendChild(el('span', 'archify-model-item-meta', row.date));
|
||||||
|
button.appendChild(line);
|
||||||
|
button.addEventListener('click', function () { if (row.file) post({ type: 'openDecision', file: row.file }); });
|
||||||
|
item.appendChild(button);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
if (element.decisions && element.decisions.length) {
|
||||||
|
modal.appendChild(el('h3', 'archify-model-title', 'Decisions · ' + element.decisions.length));
|
||||||
|
var decisions = el('ul', 'archify-model-list archify-decision-list');
|
||||||
|
element.decisions.forEach(function (row) { decisions.appendChild(decisionRowItem(row)); });
|
||||||
|
modal.appendChild(decisions);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decision page: one .archify-decision file.
|
||||||
|
function renderDecision(page) {
|
||||||
|
if (!page) return;
|
||||||
|
var d = page.decision;
|
||||||
|
var head = el('section', 'archify-model archify-model-head');
|
||||||
|
head.appendChild(el('div', 'archify-model-kicker', 'Decision' + (page.model && page.model.id ? ' · ' + page.model.id : '') + (d ? ' · ' + d.id : '')));
|
||||||
|
head.appendChild(el('h1', 'archify-model-name', d && d.title ? d.title : page.file));
|
||||||
|
if (d) {
|
||||||
|
var meta = el('p', 'archify-decision-meta');
|
||||||
|
meta.appendChild(statusBadge(d));
|
||||||
|
if (d.date) meta.appendChild(el('span', 'archify-model-item-meta', d.date));
|
||||||
|
meta.appendChild(el('span', 'archify-model-item-meta', page.file));
|
||||||
|
head.appendChild(meta);
|
||||||
|
}
|
||||||
|
if (!page.inModel) head.appendChild(el('p', 'archify-model-problem', 'This decision has lost its model: there is no main.archify in a folder above it, so its elements and diagrams cannot be resolved.'));
|
||||||
|
page.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');
|
||||||
|
if (d) {
|
||||||
|
[['Context', d.context], ['Decision', d.decision], ['Consequences', d.consequences]].forEach(function (pair) {
|
||||||
|
if (!pair[1]) return;
|
||||||
|
var section = el('section', 'archify-decision-section');
|
||||||
|
section.appendChild(el('h2', 'archify-model-title', pair[0]));
|
||||||
|
section.appendChild(el('p', 'archify-decision-text', pair[1].trim()));
|
||||||
|
body.appendChild(section);
|
||||||
|
});
|
||||||
|
if (d.supersedes.length || page.supersededBy.length) {
|
||||||
|
var rel = el('section', 'archify-decision-section');
|
||||||
|
rel.appendChild(el('h2', 'archify-model-title', 'Related decisions'));
|
||||||
|
var relList = el('ul', 'archify-model-list archify-decision-list');
|
||||||
|
d.supersedes.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'supersedes'), item.firstChild.firstChild); relList.appendChild(item); });
|
||||||
|
page.supersededBy.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'superseded by'), item.firstChild.firstChild); relList.appendChild(item); });
|
||||||
|
rel.appendChild(relList);
|
||||||
|
body.appendChild(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (page.elements.length) {
|
||||||
|
var elementsPanel = panel('decision:elements', 'archify-model-registry', 'Elements', page.elements.length, true);
|
||||||
|
var cards = el('div', 'cards archify-model-cards');
|
||||||
|
page.elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
|
||||||
|
elementsPanel.appendChild(cards);
|
||||||
|
body.appendChild(elementsPanel);
|
||||||
|
}
|
||||||
|
var diagramRows = [];
|
||||||
|
page.diagrams.forEach(function (use) { diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: 'named by the decision' }); });
|
||||||
|
page.referencedBy.forEach(function (use) {
|
||||||
|
var how = use.attachedTo.kind === 'node' ? 'on ' + use.attachedTo.id : use.attachedTo.kind === 'edge' ? 'on ' + use.attachedTo.from + ' \u2192 ' + use.attachedTo.to : 'on the diagram';
|
||||||
|
var existing = diagramRows.filter(function (r) { return r.path === use.path; })[0];
|
||||||
|
if (existing) existing.how += ', ' + how; else diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: how });
|
||||||
|
});
|
||||||
|
if (diagramRows.length) {
|
||||||
|
var diagramsPanel = panel('decision:diagrams', 'archify-model-diagrams', 'Diagrams', diagramRows.length, true);
|
||||||
|
var list = el('ul', 'archify-model-list');
|
||||||
|
diagramRows.forEach(function (row) {
|
||||||
|
var item = el('li', 'archify-model-item');
|
||||||
|
var button = el('button', 'archify-model-open');
|
||||||
|
button.type = 'button';
|
||||||
|
button.title = row.path;
|
||||||
|
button.appendChild(el('span', 'archify-model-item-title', row.title || row.path.split('/').pop()));
|
||||||
|
button.appendChild(el('span', 'archify-model-item-meta', (row.kind || 'diagram') + ' \u00b7 ' + row.path + ' \u00b7 ' + row.how));
|
||||||
|
button.addEventListener('click', function () { if (page.inModel) post({ type: 'openDiagram', path: row.path }); });
|
||||||
|
item.appendChild(button);
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
diagramsPanel.appendChild(list);
|
||||||
|
body.appendChild(diagramsPanel);
|
||||||
|
}
|
||||||
|
document.body.appendChild(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decisions rail panel on a diagram page: the decisions this diagram points
|
||||||
|
// at or that name it. Hover lights up what the decision is attached to.
|
||||||
|
function renderDecisionsRail() {
|
||||||
|
var rows = config.decisions || [];
|
||||||
|
var outline = document.getElementById('node-outline');
|
||||||
|
var svg = document.querySelector('.diagram-container > svg');
|
||||||
|
if (!rows.length || !outline || !svg || document.getElementById('archify-decisions')) return;
|
||||||
|
var nav = el('nav', 'node-outline no-print archify-connectors archify-decisions');
|
||||||
|
nav.id = 'archify-decisions';
|
||||||
|
var head = el('div', 'node-outline-head');
|
||||||
|
head.appendChild(el('strong', '', 'Decisions'));
|
||||||
|
head.appendChild(el('span', 'node-outline-count', String(rows.length)));
|
||||||
|
nav.appendChild(head);
|
||||||
|
var list = el('ol', 'node-outline-list');
|
||||||
|
var group = el('li', 'node-outline-group');
|
||||||
|
var items = el('ul');
|
||||||
|
function targets(row) {
|
||||||
|
var found = [];
|
||||||
|
row.attachedTo.forEach(function (a) {
|
||||||
|
if (a.kind === 'node') Array.prototype.push.apply(found, svg.querySelectorAll('[data-node-id="' + a.id + '"]'));
|
||||||
|
if (a.kind === 'edge') Array.prototype.push.apply(found, svg.querySelectorAll('[data-edge-from="' + a.from + '"][data-edge-to="' + a.to + '"]'));
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
function mark(row, on) { targets(row).forEach(function (t) { if (on) t.setAttribute('data-archify-hit', ''); else t.removeAttribute('data-archify-hit'); }); }
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
var item = el('li');
|
||||||
|
var button = el('button', 'node-outline-item archify-decision-rail-item is-' + row.status);
|
||||||
|
button.type = 'button';
|
||||||
|
button.style.setProperty('--outline-tone', 'var(--text-muted)');
|
||||||
|
var where = row.attachedTo.map(function (a) { return a.kind === 'node' ? a.id : a.kind === 'edge' ? a.from + ' \u2192 ' + a.to : 'diagram'; }).join(', ');
|
||||||
|
button.title = row.id + (row.title ? ': ' + row.title : '') + ' (' + row.status + ')' + (where ? ' \u00b7 ' + where : '');
|
||||||
|
var dot = el('span', 'node-outline-dot');
|
||||||
|
dot.setAttribute('aria-hidden', 'true');
|
||||||
|
button.appendChild(dot);
|
||||||
|
button.appendChild(el('strong', '', row.id + (row.title ? ' \u00b7 ' + row.title : '')));
|
||||||
|
button.appendChild(el('small', '', row.status + (where ? ' \u00b7 ' + where : '')));
|
||||||
|
button.addEventListener('pointerenter', function () { mark(row, true); });
|
||||||
|
button.addEventListener('pointerleave', function () { mark(row, false); });
|
||||||
|
button.addEventListener('focus', function () { mark(row, true); });
|
||||||
|
button.addEventListener('blur', function () { mark(row, false); });
|
||||||
|
button.addEventListener('click', function () { post({ type: 'openDecision', file: row.file }); });
|
||||||
|
item.appendChild(button);
|
||||||
|
items.appendChild(item);
|
||||||
|
});
|
||||||
|
group.appendChild(items);
|
||||||
|
list.appendChild(group);
|
||||||
|
nav.appendChild(list);
|
||||||
|
var connectors = document.getElementById('archify-connectors');
|
||||||
|
(connectors || outline).insertAdjacentElement('afterend', nav);
|
||||||
|
var archify = window.Archify;
|
||||||
|
if (archify && archify.readerLayout && typeof archify.readerLayout.schedule === 'function') archify.readerLayout.schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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', 'Registries', 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Decisions: one list, live ones first, superseded and rejected dimmed and last.
|
||||||
|
if (model.decisions && model.decisions.length) {
|
||||||
|
var decisionsPanel = panel('decisions', 'archify-model-decisions', 'Decisions', model.decisions.length, true);
|
||||||
|
body.appendChild(decisionsPanel);
|
||||||
|
var decisionList = el('ul', 'archify-model-list archify-decision-list');
|
||||||
|
model.decisions.forEach(function (row) { decisionList.appendChild(decisionRowItem(row)); });
|
||||||
|
decisionsPanel.appendChild(decisionList);
|
||||||
|
}
|
||||||
|
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); });
|
||||||
|
if (config.decision) onReady(function () { renderDecision(config.decision); });
|
||||||
|
onReady(renderConnectors);
|
||||||
|
onReady(renderDecisionsRail);
|
||||||
|
onReady(describeElements);
|
||||||
|
if (config.notice) {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
||||||
|
} else {
|
||||||
|
showNotice(config.notice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)); }
|
||||||
|
/* Decisions: rows, chips and status badges. */
|
||||||
|
.archify-decision-list { grid-template-columns: 1fr; gap: 0.35rem; }
|
||||||
|
.archify-decision-open { padding: 0.55rem 0.85rem; }
|
||||||
|
.archify-decision-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; }
|
||||||
|
.archify-decision-id { font-family: ui-monospace, Menlo, monospace; font-size: 0.8125rem; color: var(--m-muted); }
|
||||||
|
.archify-decision-status { font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; padding: 0.05rem 0.45rem; border-radius: 999px; border: 1px solid var(--m-border); color: var(--m-muted); }
|
||||||
|
.archify-decision-status.is-accepted { color: var(--m-backend); border-color: var(--m-backend); }
|
||||||
|
.archify-decision-status.is-proposed { color: var(--m-cloud); border-color: var(--m-cloud); }
|
||||||
|
.archify-decision-status.is-rejected { color: var(--m-security); border-color: var(--m-security); }
|
||||||
|
.archify-decision-item.is-superseded .archify-model-open, .archify-decision-item.is-rejected .archify-model-open { opacity: 0.6; }
|
||||||
|
.archify-decision-chip { display: inline-flex; align-items: center; gap: 0.35rem; font-family: inherit; }
|
||||||
|
.archify-decision-dot { width: 0.4rem; height: 0.4rem; border-radius: 50%; background: var(--m-muted); }
|
||||||
|
.archify-decision-chip.is-accepted .archify-decision-dot { background: var(--m-backend); }
|
||||||
|
.archify-decision-chip.is-proposed .archify-decision-dot { background: var(--m-cloud); }
|
||||||
|
.archify-decision-chip.is-rejected .archify-decision-dot { background: var(--m-security); }
|
||||||
|
.archify-decision-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin: 0.4rem 0 0; }
|
||||||
|
.archify-decision-section { margin-top: 0.5rem; }
|
||||||
|
.archify-decision-text { margin: 0; max-width: 72ch; font-size: 0.9375rem; line-height: 1.6; color: var(--m-text); white-space: pre-line; }
|
||||||
|
/* 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; }
|
||||||
|
.diagram-container svg [data-node-id][data-archify-hit] { filter: drop-shadow(0 0 6px var(--frontend-stroke)); }
|
||||||
|
/* Bottom rail with a Decisions panel: three columns. */
|
||||||
|
html[data-reader-rail="bottom"] .reader-rail:has(> #archify-decisions) { grid-template-areas: "cards cards cards" "index connectors decisions"; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
html[data-reader-rail="bottom"] .reader-rail:has(> #archify-decisions):has(> #node-outline[hidden]) { grid-template-areas: "cards cards" "connectors decisions"; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
html[data-reader-rail="bottom"] .reader-rail > #archify-decisions { display: block; grid-area: decisions; 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-decisions .node-outline-head { padding: 1rem 0.25rem 0.375rem; }
|
||||||
|
html[data-reader-rail="bottom"] .reader-rail > #archify-decisions .node-outline-list { columns: 15rem; column-gap: 1.75rem; padding: 0; overflow: visible; -webkit-mask-image: none; mask-image: none; }
|
||||||
|
.archify-decision-rail-item.is-superseded, .archify-decision-rail-item.is-rejected { opacity: 0.6; }
|
||||||
|
/* 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;
|
||||||
|
display: flex; gap: 12px; align-items: flex-start; justify-content: space-between;
|
||||||
|
padding: 8px 12px; border-radius: 6px; font: 12px/1.45 var(--vscode-font-family, system-ui, sans-serif);
|
||||||
|
color: var(--vscode-editorWidget-foreground, #ddd); background: var(--vscode-editorWidget-background, #252526);
|
||||||
|
border: 1px solid var(--vscode-editorWidget-border, #454545); box-shadow: 0 4px 16px rgba(0,0,0,.35);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.archify-vscode-notice-warning { border-left: 4px solid var(--vscode-editorWarning-foreground, #cca700); }
|
||||||
|
.archify-vscode-notice-error { border-left: 4px solid var(--vscode-editorError-foreground, #f14c4c); }
|
||||||
|
.archify-vscode-notice-info { border-left: 4px solid var(--vscode-editorInfo-foreground, #3794ff); }
|
||||||
|
.archify-vscode-notice button {
|
||||||
|
background: none; border: 0; color: inherit; cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/** Prepares Archify HTML for a VS Code webview. */
|
||||||
|
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}${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.
|
||||||
|
.replace(/<script\b(?![^>]*\bnonce=)/gi, `<script nonce="${options.nonce}"`);
|
||||||
|
|
||||||
|
if (options.viewer3d) {
|
||||||
|
const tag = `<script nonce="${options.nonce}" defer src="${escapeHtml(options.viewer3d.scriptUri)}"></script>\n`;
|
||||||
|
page = /<\/head>/i.test(page) ? page.replace(/<\/head>/i, `${tag}</head>`) : `${tag}${page}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headMatch = /<head\b[^>]*>/i.exec(page);
|
||||||
|
if (headMatch) {
|
||||||
|
const at = headMatch.index + headMatch[0].length;
|
||||||
|
page = `${page.slice(0, at)}\n${head}${page.slice(at)}`;
|
||||||
|
} else {
|
||||||
|
page = `<!DOCTYPE html><html><head>${head}</head><body>${page}</body></html>`;
|
||||||
|
}
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The decision page for one .archify-decision file. */
|
||||||
|
export function decisionPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, decision: DecisionPanel): string {
|
||||||
|
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, decision, 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'>,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
detail?: string,
|
||||||
|
): string {
|
||||||
|
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}">(function () { var vscode = acquireVsCodeApi(); var state = ${scriptJson(options.state)}; if (state) vscode.setState(state); })();</script>
|
||||||
|
<style>
|
||||||
|
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 24px 28px; }
|
||||||
|
h2 { font-weight: 600; font-size: 15px; margin: 0 0 8px; }
|
||||||
|
p { margin: 0 0 12px; color: var(--vscode-descriptionForeground); }
|
||||||
|
pre { font-family: var(--vscode-editor-font-family); font-size: 12px; white-space: pre-wrap; word-break: break-word;
|
||||||
|
background: var(--vscode-textCodeBlock-background); padding: 12px 14px; border-radius: 4px;
|
||||||
|
border-left: 3px solid var(--vscode-editorError-foreground); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>${escapeHtml(title)}</h2>
|
||||||
|
<p>${escapeHtml(body)}</p>
|
||||||
|
${detail ? `<pre>${escapeHtml(detail)}</pre>` : ''}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
|
/** Folder of the bundled webview scripts; webviews may load files from it. */
|
||||||
|
export function viewerRoots(extensionUri: vscode.Uri): vscode.Uri[] {
|
||||||
|
return [vscode.Uri.joinPath(extensionUri, 'dist')];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page option that adds the 3D viewer to an Archify page. */
|
||||||
|
export function viewer3dOption(webview: vscode.Webview, extensionUri: vscode.Uri): { scriptUri: string; defaultView: '2d' | '3d' } {
|
||||||
|
const defaultView = vscode.workspace.getConfiguration('archify').get<string>('defaultView', '2d') === '3d' ? '3d' : '2d';
|
||||||
|
return {
|
||||||
|
scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'viewer3d.js')).toString(),
|
||||||
|
defaultView,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
export type ViewerTheme = 'dark' | 'light' | undefined;
|
|
||||||
|
|
||||||
export interface PageOptions {
|
|
||||||
/** webview.cspSource */
|
|
||||||
cspSource: string;
|
|
||||||
nonce: string;
|
|
||||||
/** Forced diagram theme; undefined keeps the diagram's own choice. */
|
|
||||||
theme: ViewerTheme;
|
|
||||||
/** Location hash to restore (Archify keeps focus/route state there). */
|
|
||||||
hash?: string;
|
|
||||||
/** Opaque state handed to vscode.setState for panel restore. */
|
|
||||||
state?: unknown;
|
|
||||||
/** Banner shown on top of the diagram. */
|
|
||||||
notice?: { text: string; kind: 'info' | 'warning' | 'error' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeNonce(): string {
|
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
||||||
let nonce = '';
|
|
||||||
for (let i = 0; i < 32; i += 1) nonce += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
return nonce;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escapeHtml(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** JSON that is safe to embed inside an inline <script>. */
|
|
||||||
function scriptJson(value: unknown): string {
|
|
||||||
return JSON.stringify(value ?? null)
|
|
||||||
.replace(/</g, '\\u003c')
|
|
||||||
.replace(/>/g, '\\u003e')
|
|
||||||
.replace(/\u2028/g, '\\u2028')
|
|
||||||
.replace(/\u2029/g, '\\u2029');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function contentSecurityPolicy(cspSource: string, nonce: string): string {
|
|
||||||
return [
|
|
||||||
"default-src 'none'",
|
|
||||||
`img-src ${cspSource} https: data: blob:`,
|
|
||||||
`media-src ${cspSource} data: blob:`,
|
|
||||||
`font-src ${cspSource} data:`,
|
|
||||||
`style-src ${cspSource} 'unsafe-inline'`,
|
|
||||||
`script-src 'nonce-${nonce}'`,
|
|
||||||
'connect-src data: blob:',
|
|
||||||
'worker-src blob:',
|
|
||||||
].join('; ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs before any page script: pins the theme, restores the hash, and
|
|
||||||
* routes links, window.open and export downloads to the extension, since a
|
|
||||||
* webview cannot navigate or download on its own.
|
|
||||||
*/
|
|
||||||
function bridgeScript(options: PageOptions): string {
|
|
||||||
const config = { theme: options.theme, hash: options.hash, state: options.state, notice: options.notice };
|
|
||||||
return `(function () {
|
|
||||||
var config = ${scriptJson(config)};
|
|
||||||
var vscode = acquireVsCodeApi();
|
|
||||||
if (config.state) vscode.setState(config.state);
|
|
||||||
if (config.theme) {
|
|
||||||
try { localStorage.setItem('archify-theme', config.theme); } catch (_) {}
|
|
||||||
}
|
|
||||||
if (config.hash && !location.hash) {
|
|
||||||
try { history.replaceState(null, '', config.hash); } catch (_) {}
|
|
||||||
}
|
|
||||||
window.addEventListener('hashchange', function () {
|
|
||||||
if (config.state) vscode.setState(Object.assign({}, config.state, { hash: location.hash }));
|
|
||||||
vscode.postMessage({ type: 'hash', hash: location.hash });
|
|
||||||
});
|
|
||||||
|
|
||||||
function post(message) { vscode.postMessage(message); }
|
|
||||||
|
|
||||||
function saveBlobUrl(url, filename) {
|
|
||||||
fetch(url).then(function (response) { return response.blob(); }).then(function (blob) {
|
|
||||||
var reader = new FileReader();
|
|
||||||
reader.onload = function () {
|
|
||||||
var data = String(reader.result);
|
|
||||||
post({ type: 'save', filename: filename || 'archify-export', base64: data.slice(data.indexOf(',') + 1) });
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
}).catch(function (error) {
|
|
||||||
post({ type: 'error', message: 'Export failed: ' + (error && error.message || error) });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', function (event) {
|
|
||||||
var target = event.target;
|
|
||||||
var anchor = target && target.closest ? target.closest('a[href]') : null;
|
|
||||||
if (!anchor) return;
|
|
||||||
var href = anchor.getAttribute('href') || '';
|
|
||||||
if (anchor.hasAttribute('download') && /^(blob|data):/i.test(anchor.href)) {
|
|
||||||
event.preventDefault();
|
|
||||||
saveBlobUrl(anchor.href, anchor.getAttribute('download'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (href.charAt(0) === '#') return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (anchor.classList.contains('semantic-passport-source')) {
|
|
||||||
var pathNode = anchor.querySelector('small');
|
|
||||||
var match = /#L(\\d+)(?:-L(\\d+))?$/.exec(anchor.href);
|
|
||||||
post({
|
|
||||||
type: 'openSource',
|
|
||||||
path: pathNode ? pathNode.textContent : '',
|
|
||||||
line: match ? Number(match[1]) : undefined,
|
|
||||||
endLine: match && match[2] ? Number(match[2]) : undefined,
|
|
||||||
href: anchor.href
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (/^(https?|mailto):/i.test(href)) post({ type: 'openExternal', href: href });
|
|
||||||
else post({ type: 'openRelative', href: href });
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
var nativeOpen = window.open;
|
|
||||||
window.open = function (url) {
|
|
||||||
if (typeof url === 'string' && /^(https?|mailto):/i.test(url)) {
|
|
||||||
post({ type: 'openExternal', href: url });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return nativeOpen ? nativeOpen.apply(window, arguments) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function showNotice(notice) {
|
|
||||||
var existing = document.getElementById('archify-vscode-notice');
|
|
||||||
if (existing) existing.remove();
|
|
||||||
if (!notice || !notice.text) return;
|
|
||||||
var bar = document.createElement('div');
|
|
||||||
bar.id = 'archify-vscode-notice';
|
|
||||||
bar.setAttribute('role', notice.kind === 'error' ? 'alert' : 'status');
|
|
||||||
bar.className = 'archify-vscode-notice archify-vscode-notice-' + notice.kind;
|
|
||||||
var text = document.createElement('span');
|
|
||||||
text.textContent = notice.text;
|
|
||||||
var close = document.createElement('button');
|
|
||||||
close.type = 'button';
|
|
||||||
close.textContent = '\\u00d7';
|
|
||||||
close.title = 'Dismiss';
|
|
||||||
close.addEventListener('click', function () { bar.remove(); });
|
|
||||||
bar.appendChild(text);
|
|
||||||
bar.appendChild(close);
|
|
||||||
(document.body || document.documentElement).appendChild(bar);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('message', function (event) {
|
|
||||||
var message = event.data || {};
|
|
||||||
if (message.type === 'notice') showNotice(message.notice);
|
|
||||||
});
|
|
||||||
if (config.notice) {
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
|
||||||
} else {
|
|
||||||
showNotice(config.notice);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NOTICE_STYLE = `
|
|
||||||
.archify-vscode-notice {
|
|
||||||
position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 2147483647;
|
|
||||||
display: flex; gap: 12px; align-items: flex-start; justify-content: space-between;
|
|
||||||
padding: 8px 12px; border-radius: 6px; font: 12px/1.45 var(--vscode-font-family, system-ui, sans-serif);
|
|
||||||
color: var(--vscode-editorWidget-foreground, #ddd); background: var(--vscode-editorWidget-background, #252526);
|
|
||||||
border: 1px solid var(--vscode-editorWidget-border, #454545); box-shadow: 0 4px 16px rgba(0,0,0,.35);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
.archify-vscode-notice-warning { border-left: 4px solid var(--vscode-editorWarning-foreground, #cca700); }
|
|
||||||
.archify-vscode-notice-error { border-left: 4px solid var(--vscode-editorError-foreground, #f14c4c); }
|
|
||||||
.archify-vscode-notice-info { border-left: 4px solid var(--vscode-editorInfo-foreground, #3794ff); }
|
|
||||||
.archify-vscode-notice button {
|
|
||||||
background: none; border: 0; color: inherit; cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
/** Prepares Archify HTML for a VS Code webview. */
|
|
||||||
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`;
|
|
||||||
|
|
||||||
let page = html
|
|
||||||
// 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.
|
|
||||||
.replace(/<script\b(?![^>]*\bnonce=)/gi, `<script nonce="${options.nonce}"`);
|
|
||||||
|
|
||||||
const headMatch = /<head\b[^>]*>/i.exec(page);
|
|
||||||
if (headMatch) {
|
|
||||||
const at = headMatch.index + headMatch[0].length;
|
|
||||||
page = `${page.slice(0, at)}\n${head}${page.slice(at)}`;
|
|
||||||
} else {
|
|
||||||
page = `<!DOCTYPE html><html><head>${head}</head><body>${page}</body></html>`;
|
|
||||||
}
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A small VS Code-styled page for loading and error states. */
|
|
||||||
export function messagePage(
|
|
||||||
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>,
|
|
||||||
title: string,
|
|
||||||
body: string,
|
|
||||||
detail?: string,
|
|
||||||
): string {
|
|
||||||
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}">(function () { var vscode = acquireVsCodeApi(); var state = ${scriptJson(options.state)}; if (state) vscode.setState(state); })();</script>
|
|
||||||
<style>
|
|
||||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 24px 28px; }
|
|
||||||
h2 { font-weight: 600; font-size: 15px; margin: 0 0 8px; }
|
|
||||||
p { margin: 0 0 12px; color: var(--vscode-descriptionForeground); }
|
|
||||||
pre { font-family: var(--vscode-editor-font-family); font-size: 12px; white-space: pre-wrap; word-break: break-word;
|
|
||||||
background: var(--vscode-textCodeBlock-background); padding: 12px 14px; border-radius: 4px;
|
|
||||||
border-left: 3px solid var(--vscode-editorError-foreground); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h2>${escapeHtml(title)}</h2>
|
|
||||||
<p>${escapeHtml(body)}</p>
|
|
||||||
${detail ? `<pre>${escapeHtml(detail)}</pre>` : ''}
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# An architecture decision of this model. The id is unique in the model;
|
||||||
|
# elements are registry ids, diagrams are paths from the model root.
|
||||||
|
schema_version: 1
|
||||||
|
decision:
|
||||||
|
id: __ID__
|
||||||
|
title: __TITLE__
|
||||||
|
status: proposed # proposed | accepted | superseded | rejected
|
||||||
|
date: __DATE__
|
||||||
|
elements: []
|
||||||
|
diagrams: []
|
||||||
|
supersedes: []
|
||||||
|
context: >
|
||||||
|
What was the situation, and what forced a decision.
|
||||||
|
decision: >
|
||||||
|
What was decided, in one or two sentences.
|
||||||
|
consequences: >
|
||||||
|
What becomes easier, what becomes harder, what must change.
|
||||||
@@ -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,41 @@
|
|||||||
|
schema_version: 1
|
||||||
|
diagram_type: architecture
|
||||||
|
meta:
|
||||||
|
title: Containers
|
||||||
|
output: containers.html
|
||||||
|
decisions: [postgres-as-source-of-truth]
|
||||||
|
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]
|
||||||
|
decisions: [queue-between-api-and-worker]
|
||||||
|
- 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], decisions: [queue-between-api-and-worker] }
|
||||||
|
- { 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 }
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
schema_version: 1
|
||||||
|
decision:
|
||||||
|
id: postgres-as-source-of-truth
|
||||||
|
title: PostgreSQL as the single source of truth
|
||||||
|
status: accepted
|
||||||
|
date: 2026-09-02
|
||||||
|
elements: [db, api, worker]
|
||||||
|
diagrams: [0200-containers/online-shop.archify]
|
||||||
|
context: >
|
||||||
|
Orders, customers and the catalogue were spread over the
|
||||||
|
shop database and two spreadsheets kept by support.
|
||||||
|
Refunds were reconciled by hand every Monday.
|
||||||
|
decision: >
|
||||||
|
One PostgreSQL database owns orders, customers and the
|
||||||
|
catalogue. The API is the only writer. Everything else
|
||||||
|
(cache, search, reports) is derived and may be rebuilt.
|
||||||
|
consequences: >
|
||||||
|
Support loses the spreadsheets and gets the admin console.
|
||||||
|
Any new store must be declared derived or get its own
|
||||||
|
decision. Backups and migrations are the API team's job.
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
schema_version: 1
|
||||||
|
decision:
|
||||||
|
id: queue-between-api-and-worker
|
||||||
|
title: A queue between the API and the worker
|
||||||
|
status: accepted
|
||||||
|
date: 2026-09-16
|
||||||
|
elements: [api, queue, worker]
|
||||||
|
diagrams: [0200-containers/online-shop.archify]
|
||||||
|
context: >
|
||||||
|
Order confirmation emails and invoices were sent inside
|
||||||
|
the checkout request. A slow mail provider made checkout
|
||||||
|
slow; a failing one made checkout fail.
|
||||||
|
decision: >
|
||||||
|
The API enqueues a job per side effect on SQS. One worker
|
||||||
|
service consumes the queue and does the slow work.
|
||||||
|
Checkout returns as soon as the order is stored.
|
||||||
|
consequences: >
|
||||||
|
Emails arrive seconds later, not instantly. Jobs must be
|
||||||
|
idempotent because SQS delivers at least once. The worker
|
||||||
|
is the second place that writes to the database.
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
schema_version: 1
|
||||||
|
decision:
|
||||||
|
id: opensearch-for-product-search
|
||||||
|
title: OpenSearch for product search
|
||||||
|
status: proposed
|
||||||
|
date: 2026-10-19
|
||||||
|
elements: [api, worker, db]
|
||||||
|
diagrams: [0200-containers/online-shop.archify]
|
||||||
|
context: >
|
||||||
|
Catalogue search runs as SQL LIKE queries on the
|
||||||
|
database. Typos find nothing and the catalogue page is
|
||||||
|
the slowest page in the shop.
|
||||||
|
decision: >
|
||||||
|
Add a dedicated search service on OpenSearch. The API
|
||||||
|
queries it; the worker rebuilds the index from the
|
||||||
|
database after every catalogue change.
|
||||||
|
consequences: >
|
||||||
|
The index is derived data under decision 0001 and may be
|
||||||
|
rebuilt at any time. One more service to run. Search
|
||||||
|
results can lag the database by the rebuild interval.
|
||||||
@@ -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, decisions: [dead-letter-queue] }
|
||||||
@@ -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 }
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
schema_version: 1
|
||||||
|
decision:
|
||||||
|
id: dead-letter-queue
|
||||||
|
title: Failed events go to a dead-letter queue, never dropped
|
||||||
|
status: accepted
|
||||||
|
date: 2026-09-24
|
||||||
|
elements: [router, dlq, ops]
|
||||||
|
diagrams: [0200-containers/platform.archify, 0300-components/router.archify]
|
||||||
|
context: >
|
||||||
|
Events that failed schema validation were logged and lost.
|
||||||
|
Nobody noticed until a customer asked where an order went.
|
||||||
|
decision: >
|
||||||
|
The router moves every event it cannot process to a
|
||||||
|
dead-letter queue with the reason. Operations replays
|
||||||
|
them after the cause is fixed.
|
||||||
|
consequences: >
|
||||||
|
Nothing is lost silently. Someone has to watch the queue;
|
||||||
|
that is an on-call duty for operations.
|
||||||
@@ -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], decisions: [external-identity-provider] }
|
||||||
|
- { 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 }
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
schema_version: 1
|
||||||
|
decision:
|
||||||
|
id: external-identity-provider
|
||||||
|
title: Sign in through an identity provider, never store passwords
|
||||||
|
status: accepted
|
||||||
|
date: 2026-09-10
|
||||||
|
elements: [idp, api, spa]
|
||||||
|
diagrams: [0200-containers/web-app.archify]
|
||||||
|
context: >
|
||||||
|
The first version kept password hashes in the database and
|
||||||
|
had to handle resets, lockouts and two-factor codes itself.
|
||||||
|
decision: >
|
||||||
|
Users sign in through an OpenID Connect identity provider.
|
||||||
|
The single-page app gets a token; the API only verifies it.
|
||||||
|
consequences: >
|
||||||
|
No password tables, no reset flows. The identity provider
|
||||||
|
is a hard dependency: when it is down, nobody can sign in.
|
||||||
@@ -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,44 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { brandValueAt, loadBrandCatalog } from '../src/language/brands';
|
||||||
|
import { diagnosticPointer } from '../src/language/jsonPointer';
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..', '..');
|
||||||
|
const catalogFile = path.join(root, 'vendor', 'archify', 'brand-marks', 'catalog.json');
|
||||||
|
|
||||||
|
test('loads the bundled brand catalog', () => {
|
||||||
|
const brands = loadBrandCatalog(catalogFile);
|
||||||
|
assert.ok(brands.length >= 100, `${brands.length} brands`);
|
||||||
|
const postgres = brands.find((brand) => brand.id === 'postgresql');
|
||||||
|
assert.ok(postgres);
|
||||||
|
assert.ok(postgres.aliases.includes('postgres'));
|
||||||
|
assert.ok(brands.every((brand) => brand.id && brand.title));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds a brand value being typed in YAML', () => {
|
||||||
|
assert.deepEqual(brandValueAt(' brand: ', 'yaml'), { typed: '' });
|
||||||
|
assert.deepEqual(brandValueAt(' brand: post', 'yaml'), { typed: 'post' });
|
||||||
|
assert.deepEqual(brandValueAt(' - brand: apache-ka', 'yaml'), { typed: 'apache-ka' });
|
||||||
|
assert.deepEqual(brandValueAt(' brand: "red', 'yaml'), { typed: 'red' });
|
||||||
|
assert.deepEqual(brandValueAt(' - { id: db, brand: post', 'yaml'), { typed: 'post' });
|
||||||
|
assert.equal(brandValueAt(' label: post', 'yaml'), undefined);
|
||||||
|
assert.equal(brandValueAt(' subbrand: post', 'yaml'), undefined);
|
||||||
|
assert.equal(brandValueAt(' brand: postgresql ', 'yaml'), undefined, 'value already finished');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds a brand value being typed in JSON', () => {
|
||||||
|
assert.deepEqual(brandValueAt(' "brand": "', 'json'), { typed: '' });
|
||||||
|
assert.deepEqual(brandValueAt('{ "id": "db", "brand": "post', 'json'), { typed: 'post' });
|
||||||
|
assert.equal(brandValueAt(' "brand": ', 'json'), undefined, 'not inside the string yet');
|
||||||
|
assert.equal(brandValueAt(' "label": "post', 'json'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diagnostic pointer comes from subject.path, else from a leading pointer in the message', () => {
|
||||||
|
assert.equal(diagnosticPointer({ message: 'x', subject: { path: '/components/0' } }), '/components/0');
|
||||||
|
assert.equal(
|
||||||
|
diagnosticPointer({ message: '/components/0/brand "aws" is not a built-in brand; closest IDs: airtable' }),
|
||||||
|
'/components/0/brand',
|
||||||
|
);
|
||||||
|
assert.equal(diagnosticPointer({ message: 'Label "x" is wider than node' }), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { parse } from 'yaml';
|
||||||
|
import { effectiveStatus, mergeDecisions, parseDecision, slugify, sortDecisions, validateDecisions } from '../src/model/decisions';
|
||||||
|
import { mergeRegistries, parseRegistry, resolveRefs, summarizeDiagram } from '../src/model/format';
|
||||||
|
|
||||||
|
const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model');
|
||||||
|
const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8');
|
||||||
|
const DECISION_FILES = ['decisions/0001-postgres-as-source-of-truth.archify-decision', 'decisions/0002-queue-between-api-and-worker.archify-decision', 'decisions/0003-opensearch-for-product-search.archify-decision'];
|
||||||
|
|
||||||
|
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) })));
|
||||||
|
}
|
||||||
|
function decisionsOfModel() {
|
||||||
|
return mergeDecisions(DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('decision files parse with lines for every linked id', () => {
|
||||||
|
const { decision, problems } = parseDecision(read(DECISION_FILES[1]), 'd2');
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
assert.ok(decision);
|
||||||
|
assert.equal(decision.id, 'queue-between-api-and-worker');
|
||||||
|
assert.equal(decision.title, 'A queue between the API and the worker');
|
||||||
|
assert.equal(decision.status, 'accepted');
|
||||||
|
assert.deepEqual(decision.elements, ['api', 'queue', 'worker']);
|
||||||
|
assert.deepEqual(decision.diagrams, ['0200-containers/online-shop.archify']);
|
||||||
|
assert.equal(decision.lines.elements.length, 3);
|
||||||
|
assert.ok(decision.context && decision.decision && decision.consequences);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decision files: broken YAML, missing block, unknown status', () => {
|
||||||
|
assert.equal(parseDecision('decision: {\n', 'x').problems[0].severity, 'error');
|
||||||
|
assert.match(parseDecision('context: only text\n', 'x').problems[0].message, /"decision" block/);
|
||||||
|
const bad = parseDecision('decision:\n id: a\n title: A\n status: maybe\n elements: api\n', 'x');
|
||||||
|
assert.deepEqual(bad.problems.map((p) => p.message.split(';')[0]), ['Unknown status "maybe"', '"elements" must be a list.']);
|
||||||
|
assert.equal(bad.problems[0].line, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decisions merge, validate links, and the superseded rule applies', () => {
|
||||||
|
const set = decisionsOfModel();
|
||||||
|
assert.deepEqual(set.problems, []);
|
||||||
|
assert.equal(set.decisions.size, 3);
|
||||||
|
const registry = registryOfModel();
|
||||||
|
const diagrams = new Set(['0100-context/online-shop.archify', '0200-containers/online-shop.archify', '0300-components/api.archify']);
|
||||||
|
assert.deepEqual(validateDecisions(set, registry, diagrams), []);
|
||||||
|
// Duplicate id, unknown element, missing diagram, dangling supersedes.
|
||||||
|
const extra = parseDecision('decision:\n id: queue-between-api-and-worker\n title: Dup\n elements: [ghost]\n diagrams: [0900/x.archify]\n supersedes: [nope]\n', 'dup');
|
||||||
|
const merged = mergeDecisions([...DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })), { file: 'dup', ...extra }]);
|
||||||
|
assert.match(merged.problems[0].message, /defined twice/);
|
||||||
|
const newer = parseDecision('decision:\n id: one-database\n title: Replace the first\n status: accepted\n supersedes: [postgres-as-source-of-truth]\n', 'd4');
|
||||||
|
const withNewer = mergeDecisions([...DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })), { file: 'd4', ...newer }]);
|
||||||
|
const problems = validateDecisions(mergeDecisions([{ file: 'dup', ...extra }]), registry, diagrams);
|
||||||
|
assert.deepEqual(problems.map((p) => p.message.split(':')[0]), ['Unknown element "ghost"', 'Diagram "0900/x.archify" does not exist in this model.', 'Decision "nope" (in supersedes) does not exist in this model.']);
|
||||||
|
assert.deepEqual(effectiveStatus(withNewer.decisions.get('postgres-as-source-of-truth')!, withNewer.decisions), { status: 'superseded', supersededBy: 'one-database' });
|
||||||
|
assert.deepEqual(effectiveStatus(withNewer.decisions.get('queue-between-api-and-worker')!, withNewer.decisions), { status: 'accepted' });
|
||||||
|
const order = sortDecisions([...withNewer.decisions.values()], withNewer.decisions).map((d) => d.id);
|
||||||
|
assert.equal(order[order.length - 1], 'postgres-as-source-of-truth', 'superseded sorts last');
|
||||||
|
assert.equal(slugify('OpenSearch for product search!'), 'opensearch-for-product-search');
|
||||||
|
// Numeric ids are a YAML trap and are refused on both sides.
|
||||||
|
assert.match(parseDecision('decision:\n id: 0001\n title: N\n', 'n').problems[0].message, /is a number to YAML/);
|
||||||
|
assert.match(parseDecision('decision:\n id: ok\n title: N\n supersedes: [0001]\n', 'n').problems[0].message, /is a number to YAML/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diagrams: decisions keys are stripped, collected and checked', () => {
|
||||||
|
const registry = registryOfModel();
|
||||||
|
const known = new Set(decisionsOfModel().decisions.keys());
|
||||||
|
const doc = parse(read('0200-containers/online-shop.archify'));
|
||||||
|
const r = resolveRefs(doc, { registry, decisions: known, fileName: 'c.archify' });
|
||||||
|
assert.deepEqual(r.problems, []);
|
||||||
|
assert.ok(!('decisions' in r.diagram));
|
||||||
|
const components = r.diagram.components as Record<string, unknown>[];
|
||||||
|
const connections = r.diagram.connections as Record<string, unknown>[];
|
||||||
|
assert.ok(components.every((c) => !('decisions' in c)) && connections.every((c) => !('decisions' in c)));
|
||||||
|
assert.deepEqual(r.decisionRefs.map((d) => [d.id, d.attachedTo.kind, d.pointer]), [
|
||||||
|
['postgres-as-source-of-truth', 'diagram', '/decisions/0'],
|
||||||
|
['queue-between-api-and-worker', 'node', '/components/5/decisions/0'],
|
||||||
|
['queue-between-api-and-worker', 'edge', '/connections/4/decisions/0'],
|
||||||
|
]);
|
||||||
|
const numeric = resolveRefs({ decisions: [1], components: [] }, { registry, decisions: known, fileName: 'x.archify' });
|
||||||
|
assert.match(numeric.problems[0].message, /is a number to YAML/);
|
||||||
|
const unknown = resolveRefs({ components: [{ id: 'a', type: 'backend', label: 'A', pos: [0, 0], decisions: ['nope'] }] }, { registry, decisions: known, fileName: 'x.archify' });
|
||||||
|
assert.equal(unknown.problems[0].pointer, '/components/0/decisions/0');
|
||||||
|
assert.match(unknown.problems[0].message, /Unknown decision "nope"/);
|
||||||
|
const outside = resolveRefs({ decisions: ['postgres-as-source-of-truth'], components: [] }, { fileName: '/w/loose.archify' });
|
||||||
|
assert.match(outside.problems[0].message, /not inside a model/);
|
||||||
|
// Other kinds: nodes and edges carry the key too.
|
||||||
|
const workflow = resolveRefs({ nodes: [{ id: 'n', type: 'process', label: 'N', decisions: ['postgres-as-source-of-truth'] }], edges: [{ from: 'n', to: 'n', decisions: ['queue-between-api-and-worker'] }] }, { registry, decisions: known, fileName: 'w.archify' });
|
||||||
|
assert.deepEqual(workflow.decisionRefs.map((d) => d.attachedTo.kind), ['node', 'edge']);
|
||||||
|
assert.ok(!('decisions' in (workflow.diagram.nodes as Record<string, unknown>[])[0]));
|
||||||
|
const summary = summarizeDiagram(read('0200-containers/online-shop.archify'), '0200-containers/online-shop.archify');
|
||||||
|
assert.deepEqual(summary.decisions.map((d) => d.id), ['postgres-as-source-of-truth', 'queue-between-api-and-worker', 'queue-between-api-and-worker']);
|
||||||
|
});
|
||||||
+4
-3
@@ -1,7 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { hasSourceEvidence, isArchifyHtml, readDiagramSource, stripSourceEvidence, typeFromFilename } from '../src/detect';
|
import { pointerSegments, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from '../src/language/jsonPointer';
|
||||||
import { pointerSegments, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from '../src/jsonPointer';
|
import { isArchifyHtml, readDiagramSource, typeFromFilename } from '../src/sources/detect';
|
||||||
|
import { hasSourceEvidence, stripSourceEvidence } from '../src/sources/evidence';
|
||||||
|
|
||||||
test('reads diagram_type from content', () => {
|
test('reads diagram_type from content', () => {
|
||||||
const source = readDiagramSource('{"schema_version":1,"diagram_type":"sequence","meta":{}}', 'plain.json');
|
const source = readDiagramSource('{"schema_version":1,"diagram_type":"sequence","meta":{}}', 'plain.json');
|
||||||
@@ -49,7 +50,7 @@ test('json pointer ranges point at keys for containers and values for leaves', (
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('.archify files are diagram sources typed by their content', async () => {
|
test('.archify files are diagram sources typed by their content', async () => {
|
||||||
const { diagramBaseName, isArchifyFile, isDiagramFileName } = await import('../src/detect');
|
const { diagramBaseName, isArchifyFile, isDiagramFileName } = await import('../src/sources/detect');
|
||||||
assert.ok(isArchifyFile('/w/My Platform.archify'));
|
assert.ok(isArchifyFile('/w/My Platform.archify'));
|
||||||
assert.ok(isDiagramFileName('/w/x.ARCHIFY'));
|
assert.ok(isDiagramFileName('/w/x.ARCHIFY'));
|
||||||
assert.ok(isDiagramFileName('/w/x.workflow.json'));
|
assert.ok(isDiagramFileName('/w/x.workflow.json'));
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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 { DECISION_EXT, mergeDecisions, parseDecision, validateDecisions } from '../src/model/decisions';
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decisions: at least one, all links resolve.
|
||||||
|
const decisionFiles = files.filter((f) => f.endsWith(DECISION_EXT));
|
||||||
|
assert.ok(decisionFiles.length >= 1, 'an example carries at least one decision');
|
||||||
|
const decisions = mergeDecisions(decisionFiles.map((file) => ({ file, ...parseDecision(read(file), file) })));
|
||||||
|
assert.deepEqual(decisions.problems, []);
|
||||||
|
const diagramPaths = new Set(files.filter((f) => f.endsWith('.archify') && f !== MAIN_FILE));
|
||||||
|
assert.deepEqual(validateDecisions(decisions, registry, diagramPaths), []);
|
||||||
|
for (const d of decisions.decisions.values()) assert.ok(d.title && d.status && d.context && d.decision && d.consequences, `${d.id} is complete`);
|
||||||
|
|
||||||
|
// 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, decisions: new Set(decisions.decisions.keys()), 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);
|
||||||
|
});
|
||||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user