diff --git a/CHANGELOG.md b/CHANGELOG.md index 614b082..61bb0ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: ` 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`. diff --git a/README.md b/README.md index 6f29047..003c518 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/model.md b/docs/model.md index 3d6489d..f5d5c1b 100644 --- a/docs/model.md +++ b/docs/model.md @@ -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: diff --git a/src/model/format.ts b/src/model/format.ts index 7267859..2022e5d 100644 --- a/src/model/format.ts +++ b/src/model/format.ts @@ -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 = { + 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): 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(); + 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 }; } diff --git a/src/webview/page.ts b/src/webview/page.ts index 2bf30e8..6066a41 100644 --- a/src/webview/page.ts +++ b/src/webview/page.ts @@ -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); } `; diff --git a/test/model.test.ts b/test/model.test.ts index 1cd7166..af88282 100644 --- a/test/model.test.ts +++ b/test/model.test.ts @@ -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', () => {