Models: main.archify, registries, refs, links and the model page; 0.7.0

A folder with main.archify at its top is a model, a set of diagrams
that belong together. Opening main.archify shows the model page: the
model's name and description, its own overview diagram when the file
has one, the diagrams grouped by folder (click to open), and the
registry with where each element is used. The page is built by
traversing the folder tree and refreshes when files change.

*.archify-registry files define shared elements once; diagrams draw
them with `ref: <id>` plus position and may override label, sublabel
and tag. References are resolved before Archify runs, so Archify is
unchanged. Hover, go-to-definition and completion on ref; unknown
names, forbidden overrides, duplicate ids and broken registries in
Problems; registry edits re-render open diagrams. `diagram:` links on
elements and components open the linked diagram on double-click.

New command Archify | Open Model, a Model template for New Diagram…,
editor schemas for the new keys and for registries, docs/model.md and
examples/online-shop. Single diagrams behave exactly as before.
This commit is contained in:
root-at-skic committed 2026-10-19 11:37:20 +03:00
1 parent 701ffc7607
commit 7a2b661afe
50 files changed
+2154 -47

No files matched your search

+272
View File
@@ -0,0 +1,272 @@
import { isMap, isScalar, parseDocument, parse as parseYaml } from 'yaml';
/**
* The model file formats (see docs/model.md): `main.archify` with a `model`
* block, `*.archify-registry` files with shared elements, and `ref`
* components in diagrams. Pure functions, no VS Code imports, so they run
* in unit tests and can be reused by other tools.
*/
export const MAIN_FILE = 'main.archify';
export const REGISTRY_EXT = '.archify-registry';
/** Component fields copied from a registry element into every diagram that refers to it. */
export const COPIED_FIELDS = ['type', 'label', 'sublabel', 'tag', 'icon', 'brand', 'sources'] as const;
/** The subset a diagram may override on a `ref` component. */
export const OVERRIDABLE_FIELDS = ['label', 'sublabel', 'tag'] as const;
export interface ModelMeta {
id?: string;
name?: string;
description?: string;
}
export interface RegistryElement {
id: string;
type?: string;
label?: string;
sublabel?: string;
tag?: string;
icon?: unknown;
brand?: string;
sources?: unknown;
description?: string;
/** Diagram that explains the element, relative to the model root. */
diagram?: string;
/** Registry file that defines it, and the 0-based line of its key. */
file: string;
line: number;
}
export interface ModelProblem {
message: string;
severity: 'error' | 'warning';
/** File the problem is about; defaults to the diagram being resolved. */
file?: string;
/** JSON pointer into that file's document, when it is a diagram. */
pointer?: string;
/** 0-based line, when known (registry files). */
line?: number;
}
export interface Registry {
elements: Map<string, RegistryElement>;
files: string[];
problems: ModelProblem[];
}
export function isMainFile(fileName: string): boolean {
return (fileName.split(/[\\/]/).pop() ?? '') === MAIN_FILE;
}
export function isRegistryFile(fileName: string): boolean {
return fileName.endsWith(REGISTRY_EXT);
}
type Obj = Record<string, unknown>;
const isObj = (v: unknown): v is Obj => v !== null && typeof v === 'object' && !Array.isArray(v);
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.length > 0 ? v : undefined);
/** The `model` block of a `main.archify` document, if present. */
export function readModelMeta(doc: unknown): ModelMeta | undefined {
if (!isObj(doc) || !isObj(doc.model)) return undefined;
const { id, name, description } = doc.model;
return { id: str(id), name: str(name), description: str(description) };
}
/** True when the document also carries a diagram (a `diagram_type`). */
export function hasDiagramBody(doc: unknown): boolean {
return isObj(doc) && typeof doc.diagram_type === 'string';
}
/** Parses one registry file. Line numbers come from the YAML document. */
export function parseRegistry(text: string, file: string): { elements: RegistryElement[]; problems: ModelProblem[] } {
const problems: ModelProblem[] = [];
const elements: RegistryElement[] = [];
const doc = parseDocument(text, { prettyErrors: false, uniqueKeys: false });
for (const error of doc.errors) {
problems.push({ message: `Registry is not valid YAML: ${error.message.split('\n')[0]}`, severity: 'error', file, line: error.linePos?.[0].line ? error.linePos[0].line - 1 : 0 });
}
if (doc.errors.length) return { elements, problems };
const lineOf = (offset: number | undefined) => (offset === undefined ? 0 : text.slice(0, offset).split('\n').length - 1);
const root = doc.contents;
if (!isMap(root)) {
problems.push({ message: 'A registry must be a mapping with an "elements" key.', severity: 'error', file, line: 0 });
return { elements, problems };
}
const elementsNode = root.get('elements', true);
if (!isMap(elementsNode)) {
problems.push({ message: 'A registry needs an "elements" mapping (id → element).', severity: 'error', file, line: 0 });
return { elements, problems };
}
for (const pair of elementsNode.items) {
const key = pair.key;
const id = isScalar(key) ? String(key.value) : undefined;
const line = isScalar(key) ? lineOf(key.range?.[0]) : 0;
if (!id) continue;
const value = isMap(pair.value) ? (pair.value.toJSON() as Obj) : undefined;
if (!value) {
problems.push({ message: `Element "${id}" must be a mapping (type, label, description, …).`, severity: 'error', file, line });
continue;
}
elements.push({
id,
type: str(value.type),
label: str(value.label),
sublabel: str(value.sublabel),
tag: str(value.tag),
icon: value.icon,
brand: str(value.brand),
sources: value.sources,
description: str(value.description),
diagram: str(value.diagram),
file,
line,
});
}
return { elements, problems };
}
/** Merges parsed registry files; an id defined twice is an error on the second definition. */
export function mergeRegistries(parts: { elements: RegistryElement[]; problems: ModelProblem[]; file: string }[]): Registry {
const elements = new Map<string, RegistryElement>();
const problems: ModelProblem[] = [];
for (const part of parts) {
problems.push(...part.problems);
for (const element of part.elements) {
const existing = elements.get(element.id);
if (existing) {
problems.push({
message: `Element "${element.id}" is defined twice (also in ${existing.file}, line ${existing.line + 1}).`,
severity: 'error',
file: element.file,
line: element.line,
});
continue;
}
elements.set(element.id, element);
}
}
return { elements, files: parts.map((p) => p.file), problems };
}
export interface ResolvedDiagram {
/** The diagram as Archify expects it: refs expanded, model block and links removed. */
diagram: Obj;
problems: ModelProblem[];
/** Component index → the registry element it came from. */
refs: Map<number, RegistryElement>;
/** Node id → diagram path (relative to the model root) it links to. */
links: Map<string, string>;
/** Registry element ids used by this diagram. */
used: Set<string>;
}
export interface ResolveOptions {
/** Undefined when the diagram is not inside a model. */
registry?: Registry;
fileName: string;
}
/**
* Expands `ref` components from the registry and strips what Archify does
* not know. Component order and indexes are kept, so Archify's diagnostics
* (JSON pointers) still point at the right component in the source file.
*/
export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiagram {
const problems: ModelProblem[] = [];
const refs = new Map<number, RegistryElement>();
const links = new Map<string, string>();
const used = new Set<string>();
if (!isObj(doc)) return { diagram: {}, problems, refs, links, used };
const diagram: Obj = { ...doc };
delete diagram.model;
const { registry } = options;
const components = Array.isArray(doc.components) ? doc.components : undefined;
if (!components) return { diagram, problems, refs, links, used };
diagram.components = components.map((raw, index) => {
if (!isObj(raw)) return raw;
const ref = str(raw.ref);
const linkFromComponent = str(raw.diagram);
if (!ref) {
const { diagram: _link, ...plain } = raw;
const id = str(plain.id);
if (id && linkFromComponent) links.set(id, linkFromComponent);
return plain;
}
const { ref: _ref, diagram: _link, ...own } = raw;
const element = registry?.elements.get(ref);
const id = str(own.id) ?? ref;
if (!element) {
problems.push({
message: registry
? `Unknown element "${ref}": no registry of this model defines it.`
: `"${ref}" cannot be resolved: ${options.fileName.split(/[\\/]/).pop()} is not inside a model (no ${MAIN_FILE} above it).`,
severity: 'error',
pointer: `/components/${index}/ref`,
});
return { type: 'external', label: ref, ...own, id };
}
used.add(ref);
refs.set(index, element);
for (const key of Object.keys(own)) {
if ((COPIED_FIELDS as readonly string[]).includes(key) && !(OVERRIDABLE_FIELDS as readonly string[]).includes(key)) {
problems.push({
message: `"${key}" comes from the registry element "${ref}" and cannot be changed in a diagram.`,
severity: 'error',
pointer: `/components/${index}/${key}`,
});
}
}
const merged: Obj = { id };
for (const key of COPIED_FIELDS) if (element[key] !== undefined) merged[key] = element[key];
for (const [key, value] of Object.entries(own)) {
if (key === 'id') continue;
if (!(COPIED_FIELDS as readonly string[]).includes(key) || (OVERRIDABLE_FIELDS as readonly string[]).includes(key)) merged[key] = value;
}
const link = linkFromComponent ?? element.diagram;
if (link) links.set(id, link);
return merged;
});
return { diagram, problems, refs, links, used };
}
/** Group of a diagram inside a model: '' for the root folder, else the folder path with '/' separators. */
export function groupOf(relativePath: string): string {
const parts = relativePath.split(/[\\/]/);
parts.pop();
return parts.join('/');
}
export interface DiagramSummary {
relativePath: string;
group: string;
kind?: string;
title?: string;
used: string[];
parseError?: string;
}
/** What the model page needs to know about one diagram file. */
export function summarizeDiagram(text: string, relativePath: string): DiagramSummary {
let doc: unknown;
try {
doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false });
} catch (error) {
return { relativePath, group: groupOf(relativePath), used: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) };
}
const obj = isObj(doc) ? doc : {};
const meta = isObj(obj.meta) ? obj.meta : {};
const components = Array.isArray(obj.components) ? obj.components : [];
const used = [...new Set(components.map((c) => (isObj(c) ? str(c.ref) : undefined)).filter((r): r is string => !!r))];
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used };
}
/** Groups in display order: the root group first, then folders in path order. */
export function sortedGroups(summaries: DiagramSummary[]): { group: string; diagrams: DiagramSummary[] }[] {
const byGroup = new Map<string, DiagramSummary[]>();
for (const s of summaries) byGroup.set(s.group, [...(byGroup.get(s.group) ?? []), s]);
const groups = [...byGroup.keys()].sort((a, b) => (a === '' ? -1 : b === '' ? 1 : a.localeCompare(b)));
return groups.map((group) => ({ group, diagrams: byGroup.get(group)!.sort((a, b) => a.relativePath.localeCompare(b.relativePath)) }));
}
+29
View File
@@ -0,0 +1,29 @@
import { ModelIndex } from './service';
/** What the model page shows; serialised into the webview. */
export interface ModelPanel {
meta: { id?: string; name?: string; description?: string };
/** True when main.archify also carries a diagram, shown between metadata and the list. */
hasDiagram: boolean;
groups: { group: string; diagrams: { path: string; title?: string; kind?: string; parseError?: string }[] }[];
registry: { id: string; label?: string; type?: string; description?: string; file: string; usedIn: string[] }[];
problems: string[];
nested: string[];
}
export function panelFromIndex(index: ModelIndex): ModelPanel {
const registry = [...index.registry.elements.values()]
.sort((a, b) => a.id.localeCompare(b.id))
.map((e) => ({ id: e.id, label: e.label, type: e.type, description: e.description, file: e.file, usedIn: index.usage.get(e.id) ?? [] }));
return {
meta: index.meta,
hasDiagram: index.mainHasDiagram,
groups: index.groups.map((g) => ({
group: g.group,
diagrams: g.diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind, parseError: d.parseError })),
})),
registry,
problems: index.registry.problems.map((p) => (p.file ? `${p.file}: ${p.message}` : p.message)),
nested: index.nested,
};
}
+37
View File
@@ -0,0 +1,37 @@
import * as vscode from 'vscode';
import { DiagramSource, readDiagramSource } from '../sources/detect';
import { ModelProblem, Registry, resolveRefs } from './format';
import { ModelService } from './service';
/**
* A diagram ready for Archify: the source as written, plus the resolved JSON
* with registry references expanded, the model problems found on the way,
* and the diagram links the viewer should offer.
*/
export interface PreparedDiagram {
source: DiagramSource;
/** Model root when the file is inside a model. */
root?: vscode.Uri;
registry?: Registry;
problems: ModelProblem[];
/** Node id → diagram path relative to the model root. */
links: Map<string, string>;
}
/**
* Reads a diagram and resolves it against its model, if any. Every place
* that hands a diagram to Archify (preview, Problems, render, browser) goes
* through here, so they all see the same resolved diagram.
*/
export async function prepareDiagram(models: ModelService, uri: vscode.Uri, text: string): Promise<PreparedDiagram | undefined> {
const source = readDiagramSource(text, uri.path);
if (!source) return undefined;
const found = await models.registryFor(uri);
const resolved = resolveRefs(source.diagram, { registry: found?.registry, fileName: uri.path });
// Only diagrams that use refs (or carry a model block) need the resolved form.
const changed = resolved.refs.size > 0 || resolved.problems.length > 0 || 'model' in source.diagram || resolved.links.size > 0;
const prepared: DiagramSource = changed
? { ...source, diagram: resolved.diagram, json: JSON.stringify(resolved.diagram, null, 2) }
: source;
return { source: prepared, root: found?.root, registry: found?.registry, problems: resolved.problems, links: resolved.links };
}
+184
View File
@@ -0,0 +1,184 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { log } from '../util/log';
import {
DiagramSummary,
MAIN_FILE,
ModelMeta,
REGISTRY_EXT,
Registry,
hasDiagramBody,
isMainFile,
isRegistryFile,
mergeRegistries,
parseRegistry,
readModelMeta,
sortedGroups,
summarizeDiagram,
} from './format';
import { parse as parseYaml } from 'yaml';
/**
* Finds models in the workspace and keeps what they contain. A model is the
* folder tree under a `main.archify`; this service answers "which model is
* this file in?", loads the model's registry, lists its diagrams, and tells
* listeners when any of that changes.
*/
export interface ModelIndex {
root: vscode.Uri;
main: vscode.Uri;
meta: ModelMeta;
/** True when main.archify also carries a diagram. */
mainHasDiagram: boolean;
registry: Registry;
diagrams: DiagramSummary[];
groups: { group: string; diagrams: DiagramSummary[] }[];
/** Element id → diagrams (relative paths) that refer to it. */
usage: Map<string, string[]>;
/** Nested main.archify files found below the root; not supported. */
nested: string[];
}
const SKIP = '{**/node_modules/**,**/.git/**}';
async function exists(uri: vscode.Uri): Promise<boolean> {
try {
await vscode.workspace.fs.stat(uri);
return true;
} catch {
return false;
}
}
function text(bytes: Uint8Array): string {
return new TextDecoder('utf-8').decode(bytes);
}
/** Open editor text when the file is open, else the file on disk. */
async function readText(uri: vscode.Uri): Promise<string> {
const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString());
if (open) return open.getText();
return text(await vscode.workspace.fs.readFile(uri));
}
export class ModelService implements vscode.Disposable {
private readonly roots = new Map<string, vscode.Uri | null>();
private readonly indexes = new Map<string, Promise<ModelIndex>>();
private readonly changeEmitter = new vscode.EventEmitter<{ root: vscode.Uri; file: vscode.Uri }>();
/** Fires with the model root and the file under it that changed. */
readonly onDidChangeModel = this.changeEmitter.event;
private readonly disposables: vscode.Disposable[];
constructor() {
const watcher = vscode.workspace.createFileSystemWatcher(`**/*{.archify,${REGISTRY_EXT}}`);
const changed = (uri: vscode.Uri) => void this.onFileChanged(uri);
this.disposables = [
watcher,
watcher.onDidCreate(changed),
watcher.onDidDelete(changed),
watcher.onDidChange(changed),
vscode.workspace.onDidChangeTextDocument((event) => {
// Registry edits in the editor should re-render open diagrams before a save.
if (isRegistryFile(event.document.fileName) || isMainFile(event.document.fileName)) changed(event.document.uri);
}),
this.changeEmitter,
];
}
private async onFileChanged(uri: vscode.Uri): Promise<void> {
const name = path.basename(uri.path);
if (name === MAIN_FILE) this.roots.clear(); // a model may have appeared or disappeared
const root = await this.rootOf(uri);
if (!root) return;
this.indexes.delete(root.toString());
this.changeEmitter.fire({ root, file: uri });
}
/** The model root (folder with main.archify) above a file, or undefined. */
async rootOf(uri: vscode.Uri): Promise<vscode.Uri | undefined> {
if (uri.scheme !== 'file') return undefined;
const dirKey = path.dirname(uri.fsPath);
const cached = this.roots.get(dirKey);
if (cached !== undefined) return cached ?? undefined;
let dir = dirKey;
const visited: string[] = [];
let found: vscode.Uri | null = null;
for (;;) {
visited.push(dir);
if (await exists(vscode.Uri.file(path.join(dir, MAIN_FILE)))) {
found = vscode.Uri.file(dir);
break;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
for (const d of visited) this.roots.set(d, found);
return found ?? undefined;
}
/** The registry of the model a file belongs to, or undefined outside a model. */
async registryFor(uri: vscode.Uri): Promise<{ root: vscode.Uri; registry: Registry } | undefined> {
const root = await this.rootOf(uri);
if (!root) return undefined;
const index = await this.index(root);
return { root, registry: index.registry };
}
/** Everything known about a model, cached until a file under it changes. */
index(root: vscode.Uri): Promise<ModelIndex> {
const key = root.toString();
let pending = this.indexes.get(key);
if (!pending) {
pending = this.build(root).catch((error) => {
this.indexes.delete(key);
throw error;
});
this.indexes.set(key, pending);
}
return pending;
}
private async build(root: vscode.Uri): Promise<ModelIndex> {
const main = vscode.Uri.joinPath(root, MAIN_FILE);
let meta: ModelMeta = {};
let mainHasDiagram = false;
try {
const doc = parseYaml(await readText(main), { prettyErrors: false, uniqueKeys: false });
meta = readModelMeta(doc) ?? {};
mainHasDiagram = hasDiagramBody(doc);
} catch (error) {
log().warn(`main.archify of ${root.fsPath} does not parse: ${error instanceof Error ? error.message : String(error)}`);
}
const registryFiles = await vscode.workspace.findFiles(new vscode.RelativePattern(root, `**/*${REGISTRY_EXT}`), SKIP);
const parts = await Promise.all(
registryFiles.sort((a, b) => a.fsPath.localeCompare(b.fsPath)).map(async (file) => {
const rel = path.relative(root.fsPath, file.fsPath);
return { file: rel, ...parseRegistry(await readText(file), rel) };
}),
);
const registry = mergeRegistries(parts);
const diagramFiles = await vscode.workspace.findFiles(new vscode.RelativePattern(root, '**/*.archify'), SKIP);
const nested: string[] = [];
const diagrams: DiagramSummary[] = [];
for (const file of diagramFiles) {
const rel = path.relative(root.fsPath, file.fsPath).split(path.sep).join('/');
if (rel === MAIN_FILE) continue;
if (path.basename(file.fsPath) === MAIN_FILE) {
nested.push(rel);
continue;
}
diagrams.push(summarizeDiagram(await readText(file), rel));
}
const usage = new Map<string, string[]>();
for (const d of diagrams) for (const id of d.used) usage.set(id, [...(usage.get(id) ?? []), d.relativePath]);
return { root, main, meta, mainHasDiagram, registry, diagrams, groups: sortedGroups(diagrams), usage, nested };
}
dispose(): void {
for (const d of this.disposables.splice(0)) d.dispose();
}
}