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)) }));
}