- 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
277 lines
11 KiB
TypeScript
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)) }));
|
|
}
|