Files
archify-vscode-ext/src/model/format.ts
T
root-at-skic 094ca2925f Drop generated cards; registry descriptions become Elements tooltips
- resolveRefs no longer adds cards to a diagram; it returns node id →
  description, which the page sets as the tooltip of the matching row in
  Archify's Elements panel
- hand-written cards pass through unchanged; docs and changelog
2026-10-19 20:44:09 +03:00

277 lines
11 KiB
TypeScript

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>;
/** Node id → registry description, for the Elements panel. */
descriptions: Map<string, 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>();
const descriptions = new Map<string, string>();
if (!isObj(doc)) return { diagram: {}, problems, refs, links, used, descriptions };
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, descriptions };
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 };
}
/** 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)) }));
}