Registry as Archify cards on the model page; cards under model diagrams from registry descriptions

- model page: registry elements are rendered as Archify info cards (type
  colour dot, label, id · type, description, used-in chips that open the
  diagram), replacing the table
- resolveRefs: a diagram without cards of its own gets one card per drawn
  shared element that has a description, so the registry text shows under
  every model diagram as it does on hand-written diagrams
- unit tests for generated cards, own cards winning and no cards without
  refs; docs and changelog
This commit is contained in:
2026-10-19 12:53:45 +03:00
parent 57914d435c
commit 38cdc7604f
6 changed files with 102 additions and 17 deletions
+1
View File
@@ -4,6 +4,7 @@
- **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.
- **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`.
+1
View File
@@ -119,6 +119,7 @@ online-shop/
- **The model page.** Click `main.archify` and you get the model's page: its name and description, the overview diagram if the file has one, the diagrams of the model grouped by folder (click one to open it), and the registry with "used in N diagrams".
- **Shared elements.** A registry file defines an element once (`type`, `label`, `sublabel`, `brand`, `description`, …). A diagram draws it with `ref: api` plus `pos` and `size`; it may override `label`, `sublabel` and `tag`, and may still define unshared elements inline. Hover a `ref` for the description, F12 jumps to the registry, completion after `ref:` lists the elements, unknown names appear in Problems.
- **Links.** A registry element (or a component) with `diagram: 0300-components/api.archify` points at the diagram that explains it: double-click the node in the 2D view to open it.
- **Cards.** A model diagram without `cards` of its own shows one card per shared element it draws, from the registry descriptions. The model page shows the registry as the same cards, each with the diagrams it is used in.
- **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.
+4
View File
@@ -64,6 +64,10 @@ components:
- 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.
## Cards under a model diagram
Archify shows `cards` (a coloured dot, a title, a few lines) below a diagram. A diagram inside a model that draws shared elements and defines no `cards` of its own gets one card per drawn element that has a `description` in the registry, in drawing order, with the dot colour of the element's type. Write your own `cards:` in the diagram to replace them.
## Links between diagrams
A diagram may point at another diagram from a component, whether shared or inline:
+45
View File
@@ -55,6 +55,42 @@ export interface Registry {
problems: ModelProblem[];
}
/** Archify card dot colour for a component type (the colours Archify gives the nodes). */
const DOT_BY_TYPE: Record<string, string> = {
frontend: 'cyan',
backend: 'emerald',
database: 'violet',
cloud: 'amber',
security: 'rose',
messagebus: 'orange',
external: 'slate',
};
export function dotForType(type?: string): string {
return DOT_BY_TYPE[type ?? ''] ?? 'slate';
}
export interface Card {
dot: string;
title: string;
items: string[];
}
/**
* Archify info cards for registry elements: one per element that has a
* description, titled by its label. What a diagram gets below itself when
* it draws shared elements and defines no cards of its own.
*/
export function cardsFromElements(elements: Iterable<RegistryElement>): Card[] {
const cards: Card[] = [];
for (const element of elements) {
if (!element.description) continue;
const items = [element.description.trim()];
cards.push({ dot: dotForType(element.type), title: element.label ?? element.id, items });
}
return cards;
}
export function isMainFile(fileName: string): boolean {
return (fileName.split(/[\\/]/).pop() ?? '') === MAIN_FILE;
}
@@ -172,6 +208,8 @@ export interface ResolveOptions {
* 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.
* A diagram without `cards` of its own gets cards from the descriptions of
* the shared elements it draws, so the registry text shows under the diagram.
*/
export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiagram {
const problems: ModelProblem[] = [];
@@ -229,6 +267,13 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag
if (link) links.set(id, link);
return merged;
});
if (diagram.cards === undefined && refs.size) {
const seen = new Set<string>();
const elements: RegistryElement[] = [];
for (const element of refs.values()) if (!seen.has(element.id)) { seen.add(element.id); elements.push(element); }
const cards = cardsFromElements(elements);
if (cards.length) diagram.cards = cards;
}
return { diagram, problems, refs, links, used };
}
+33 -17
View File
@@ -183,6 +183,9 @@ function bridgeScript(options: PageOptions): string {
}
}, true);
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;
@@ -223,30 +226,34 @@ function bridgeScript(options: PageOptions): string {
});
body.appendChild(list);
});
// Registry elements as Archify info cards: the same cards a diagram shows below itself.
if (model.registry.length) {
body.appendChild(el('h2', 'archify-model-title', 'Registry · ' + model.registry.length));
var table = el('table', 'archify-model-registry');
var cards = el('div', 'cards archify-model-cards');
model.registry.forEach(function (element) {
var row = el('tr');
var name = el('td', 'archify-model-element');
name.appendChild(el('strong', '', element.label || element.id));
name.appendChild(el('span', 'archify-model-item-meta', element.id + (element.type ? ' · ' + element.type : '')));
row.appendChild(name);
row.appendChild(el('td', 'archify-model-element-description', element.description || ''));
var used = el('td', 'archify-model-used');
var card = el('div', 'card archify-model-card');
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 : '')));
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 (path) {
var link = el('button', 'archify-model-used-link', path.replace(/\.archify$/, ''));
link.type = 'button';
link.title = path;
link.title = 'Open ' + path;
link.addEventListener('click', function () { post({ type: 'openDiagram', path: path }); });
used.appendChild(link);
});
} else used.appendChild(el('span', 'archify-model-item-meta', 'not used yet'));
row.appendChild(used);
table.appendChild(row);
list.appendChild(used);
card.appendChild(list);
cards.appendChild(card);
});
body.appendChild(table);
body.appendChild(cards);
}
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
}
@@ -290,11 +297,20 @@ const MODEL_STYLE = `
.archify-model-item-title { font-weight: 600; font-size: 0.9375rem; }
.archify-model-item-meta { font-size: 0.75rem; color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; }
.archify-model-empty { color: var(--m-muted); }
.archify-model-registry { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.archify-model-registry td { padding: 0.55rem 0.6rem; border-top: 1px solid var(--m-border); vertical-align: top; }
.archify-model-element { width: 22%; } .archify-model-element strong { display: block; }
.archify-model-element-description { color: var(--m-muted); line-height: 1.45; }
.archify-model-used { width: 28%; display: flex; flex-wrap: wrap; gap: 0.3rem; }
/* 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.75rem; }
.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); }
`;
+18
View File
@@ -3,6 +3,8 @@ import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import {
cardsFromElements,
dotForType,
groupOf,
hasDiagramBody,
isMainFile,
@@ -81,6 +83,22 @@ test('resolve: refs become full components, links and usage are collected', () =
assert.deepEqual([...r.used].sort(), ['api', 'cache', 'customer', 'db', 'queue', 'web', 'worker']);
assert.equal(r.refs.get(2)?.id, 'api');
assert.ok(!('model' in r.diagram));
// Cards from the registry descriptions, in drawing order, one per element.
const cards = r.diagram.cards as { dot: string; title: string; items: string[] }[];
assert.equal(cards.length, 7);
assert.deepEqual(cards[0], { dot: 'slate', title: 'Customer', items: ['Buys products; signs in with email or a social account.'] });
assert.equal(cards.find((c) => c.title === 'API')?.dot, 'emerald');
});
test('resolve: own cards win, no cards without refs or descriptions', () => {
const registry = registryOfModel();
const own = resolveRefs({ components: [{ ref: 'api', pos: [0, 0] }], cards: [{ dot: 'rose', title: 'Mine', items: ['kept'] }] }, { registry, fileName: 'x.archify' });
assert.deepEqual(own.diagram.cards, [{ dot: 'rose', title: 'Mine', items: ['kept'] }]);
const inline = resolveRefs({ components: [{ id: 'a', type: 'backend', label: 'A', pos: [0, 0] }] }, { registry, fileName: 'x.archify' });
assert.ok(!('cards' in inline.diagram));
assert.equal(cardsFromElements([{ id: 'x', type: 'cloud', label: 'X', file: 'r', line: 0 }]).length, 0);
assert.equal(dotForType('messagebus'), 'orange');
assert.equal(dotForType(undefined), 'slate');
});
test('resolve: overrides, second id for the same element, inline elements and links', () => {