@rse/specbook 1.5.2 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +201 -0
- package/README.md +288 -43
- package/dst/specbook-api.d.ts +23 -3
- package/dst/specbook-api.js +133 -59
- package/dst/specbook-cli.js +93 -26
- package/dst/specbook-cmd-describe.js +4 -4
- package/dst/specbook-cmd-export-schema.d.ts +8 -0
- package/dst/specbook-cmd-export-schema.js +441 -0
- package/dst/specbook-cmd-export.js +23 -6
- package/dst/specbook-cmd-init-sample.d.ts +4 -0
- package/dst/specbook-cmd-init-sample.js +322 -0
- package/dst/specbook-cmd-init.d.ts +1 -0
- package/dst/specbook-cmd-init.js +42 -10
- package/dst/specbook-cmd-lint.js +46 -48
- package/dst/specbook-cmd-query.d.ts +30 -0
- package/dst/specbook-cmd-query.js +165 -0
- package/dst/specbook-config.d.ts +9 -1
- package/dst/specbook-config.js +135 -10
- package/dst/specbook-coverage.js +2 -2
- package/dst/specbook-diagram.d.ts +7 -5
- package/dst/specbook-diagram.js +546 -143
- package/dst/specbook-export-ast.d.ts +1 -1
- package/dst/specbook-export-ast.js +11 -8
- package/dst/specbook-export-code.js +3 -3
- package/dst/specbook-export-common.d.ts +1 -1
- package/dst/specbook-export-common.js +3 -2
- package/dst/specbook-export-diagram.d.ts +1 -1
- package/dst/specbook-export-diagram.js +25 -7
- package/dst/specbook-export-html-fold.d.ts +16 -6
- package/dst/specbook-export-html-fold.js +65 -44
- package/dst/specbook-export-html-render.js +107 -26
- package/dst/specbook-export-html-search.js +18 -20
- package/dst/specbook-export-html-template.js +38 -9
- package/dst/specbook-export-html-widget.d.ts +2 -0
- package/dst/specbook-export-html-widget.js +219 -19
- package/dst/specbook-export-html.css +208 -38
- package/dst/specbook-export-html.js +96 -53
- package/dst/specbook-export-md.d.ts +2 -1
- package/dst/specbook-export-md.js +7 -8
- package/dst/specbook-export-pdf.js +1 -1
- package/dst/specbook-files.d.ts +2 -0
- package/dst/specbook-files.js +41 -0
- package/dst/specbook-format-meta.yaml +401 -0
- package/dst/specbook-format-pm.yaml +1240 -0
- package/dst/specbook-format-schema.d.ts +33 -3
- package/dst/specbook-format-schema.js +49 -3
- package/dst/specbook-format.md +427 -47
- package/dst/specbook-format.yaml +524 -228
- package/dst/specbook-ignore.js +2 -2
- package/dst/specbook-link.d.ts +4 -0
- package/dst/specbook-link.js +61 -6
- package/dst/specbook-mcp.js +103 -19
- package/dst/specbook-parse-semantic.d.ts +1 -0
- package/dst/specbook-parse-semantic.js +11 -4
- package/dst/specbook-parse-syntax.d.ts +1 -0
- package/dst/specbook-parse-syntax.js +1 -1
- package/dst/specbook-parse.d.ts +1 -1
- package/dst/specbook-parse.js +3 -3
- package/dst/specbook-project.d.ts +2 -0
- package/dst/specbook-project.js +15 -5
- package/dst/specbook-remote.d.ts +28 -0
- package/dst/specbook-remote.js +429 -0
- package/package.json +32 -20
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Specification Book (SpecBook)
|
|
3
|
+
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import { loadConfig, configStatistics, originKey } from "./specbook-config.js";
|
|
8
|
+
import { metaConfig, version, reportOverlaps } from "./specbook-remote.js";
|
|
9
|
+
import { literal } from "./specbook-verbose.js";
|
|
10
|
+
import { renderDiagnostic } from "./specbook-diagnostic.js";
|
|
11
|
+
import { parseSpecification, specStatistics } from "./specbook-parse.js";
|
|
12
|
+
import { compileValueExpr } from "./specbook-parse-value.js";
|
|
13
|
+
import { buildLinkIndex, resolveSet, referenceTo, outsideCode } from "./specbook-link.js";
|
|
14
|
+
import { slugify } from "./specbook-parse-syntax.js";
|
|
15
|
+
import { renderObjectMd, formatTimestamp } from "./specbook-export-md.js";
|
|
16
|
+
/* escape the Markdown-significant characters of a prose text (a
|
|
17
|
+
configuration file label), with its whitespace collapsed, so it
|
|
18
|
+
renders literally */
|
|
19
|
+
const escapeMd = (text) => text.replace(/\s+/g, " ").trim().replace(/[\\`*_[\]<>|]/g, "\\$&");
|
|
20
|
+
/* render a verbatim text (a value expression, a name pattern, a file
|
|
21
|
+
name, a configuration) as an inline code span, with its whitespace
|
|
22
|
+
collapsed, which renders it literally and in which its "[[...]]"
|
|
23
|
+
is plain code instead of a reference (the fence outgrows every
|
|
24
|
+
backtick run inside, padded where the text touches a backtick) */
|
|
25
|
+
const codeMd = (text) => {
|
|
26
|
+
const code = text.replace(/\s+/g, " ").trim();
|
|
27
|
+
const fence = "`".repeat(Math.max(0, ...Array.from(code.matchAll(/`+/g), (m) => m[0].length)) + 1);
|
|
28
|
+
const pad = code.startsWith("`") || code.endsWith("`") ? " " : "";
|
|
29
|
+
return `${fence}${pad}${code}${pad}${fence}`;
|
|
30
|
+
};
|
|
31
|
+
/* escape a description text taken from the schema configuration for
|
|
32
|
+
the generated corpus, outside of its fenced code blocks and inline
|
|
33
|
+
code spans (in which a "[[" is plain code): every "[[" and "![" is
|
|
34
|
+
escaped, a line-leading "#" would open a heading, and the unordered
|
|
35
|
+
list items become ordered ones, as the former would be taken for
|
|
36
|
+
properties, while an inline code span carrying an "![" becomes
|
|
37
|
+
escaped plain text, as the parser loads an embedding even there */
|
|
38
|
+
const escapeDesc = (text) => outsideCode(text.trim().replace(/`([^`\n]*!\[[^`\n]*)`/g, (_, code) => escapeMd(code)), (part) => {
|
|
39
|
+
let counter = 0;
|
|
40
|
+
return part
|
|
41
|
+
.replace(/\[\[/g, "\\[\\[")
|
|
42
|
+
.replace(/!\[/g, "!\\[")
|
|
43
|
+
.split("\n").map((line) => {
|
|
44
|
+
const item = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
|
45
|
+
if (item !== null)
|
|
46
|
+
return `${item[1]}${++counter}. ${item[2]}`;
|
|
47
|
+
if ((/^\S/).test(line))
|
|
48
|
+
counter = 0;
|
|
49
|
+
return line.replace(/^(\s*)#/, "$1\\#");
|
|
50
|
+
}).join("\n");
|
|
51
|
+
});
|
|
52
|
+
/* flatten a nested configuration value (an automaton, badge, multiple,
|
|
53
|
+
diagram, or format) into "key=value" pairs, with the nested keys dotted, the
|
|
54
|
+
lists of scalars bracketed, and the elements of the lists of objects
|
|
55
|
+
(the diagrams) indexed */
|
|
56
|
+
const flatten = (value, prefix = "") => {
|
|
57
|
+
if (Array.isArray(value) && value.some((item) => typeof item === "object" && item !== null))
|
|
58
|
+
return value.flatMap((item, i) => flatten(item, `${prefix}[${i}]`));
|
|
59
|
+
else if (Array.isArray(value))
|
|
60
|
+
return [`${prefix}=[${value.map((item) => String(item)).join(", ")}]`];
|
|
61
|
+
else if (typeof value === "object" && value !== null)
|
|
62
|
+
return Object.entries(value)
|
|
63
|
+
.filter(([, val]) => val !== undefined)
|
|
64
|
+
.flatMap(([key, val]) => flatten(val, prefix !== "" ? `${prefix}.${key}` : key));
|
|
65
|
+
else
|
|
66
|
+
return [`${prefix}=${String(value)}`];
|
|
67
|
+
};
|
|
68
|
+
/* the "[[...]]" reference patterns a property value expression carries */
|
|
69
|
+
const patternsOf = (prop) => {
|
|
70
|
+
if (prop.value === undefined)
|
|
71
|
+
return [];
|
|
72
|
+
const expr = compileValueExpr(prop.value);
|
|
73
|
+
return (expr.kind === "list" ? expr.alternatives : [expr])
|
|
74
|
+
.flatMap((alternative) => alternative.kind === "reference" ? [alternative.pattern] : []);
|
|
75
|
+
};
|
|
76
|
+
/* the cardinality of a reference-valued property: a single reference
|
|
77
|
+
or a "list" of them, required or optional */
|
|
78
|
+
const arityOf = (prop) => {
|
|
79
|
+
const many = prop.value !== undefined && compileValueExpr(prop.value).kind === "list";
|
|
80
|
+
return `${prop.optional === true ? "0.." : many ? "1.." : ""}${many ? "n" : "1"}`;
|
|
81
|
+
};
|
|
82
|
+
/* the value of a sibling marker flag: "true", or its expression */
|
|
83
|
+
const markerOf = (flag) => flag === true ? "true" : typeof flag === "string" ? codeMd(flag) : undefined;
|
|
84
|
+
/* a count with its noun */
|
|
85
|
+
const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
86
|
+
/* render a text as a Mermaid class diagram identifier (in the dotted
|
|
87
|
+
notation of the qualified references) */
|
|
88
|
+
const classId = (text) => text.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
89
|
+
/* render a non-reference property as a class attribute, typed by the
|
|
90
|
+
kind of its value constraint plus its multiplicity */
|
|
91
|
+
const classAttribute = (prop) => {
|
|
92
|
+
const kind = prop.value !== undefined ? compileValueExpr(prop.value).kind : "text";
|
|
93
|
+
return `${kind === "reference" ? "ref" : kind}[${prop.optional === true ? "0..1" : "1"}] ${classId(prop.name)}`;
|
|
94
|
+
};
|
|
95
|
+
/* generate the Markdown sources of the specification describing a
|
|
96
|
+
schema configuration (against the meta schema configuration): the
|
|
97
|
+
title artifact and one (recurring) ARTIFACT-KIND artifact per
|
|
98
|
+
artifact kind, holding one ARTIFACT object per artifact, the
|
|
99
|
+
OBJECT-KIND objects flat below their artifact (in
|
|
100
|
+
pre-order, named after their kind, qualified by the parent kind on
|
|
101
|
+
a collision), and the PROPERTY/REFERENCE objects below the artifacts
|
|
102
|
+
and object kinds, where the reference patterns of the configuration
|
|
103
|
+
are resolved at the kind level against a skeleton corpus */
|
|
104
|
+
const generateSources = (config, meta, files, labels, origins) => {
|
|
105
|
+
const now = new Date();
|
|
106
|
+
/* the skeleton corpus standing in for the configuration: one object
|
|
107
|
+
per schema node, the artifacts carrying their ids and names, so
|
|
108
|
+
the reference patterns resolve onto the kinds through the
|
|
109
|
+
regular resolution */
|
|
110
|
+
const chains = new Map();
|
|
111
|
+
const skeletons = new Map();
|
|
112
|
+
const skeletonOf = (schema, chain) => {
|
|
113
|
+
const artifact = chain.length === 1;
|
|
114
|
+
const object = { kind: schema.kind,
|
|
115
|
+
id: artifact ? (schema.id ?? slugify(schema.name ?? schema.kind)) : "",
|
|
116
|
+
name: artifact ? (schema.name ?? "") : "", properties: [], children: [] };
|
|
117
|
+
if (artifact && schema.id !== undefined)
|
|
118
|
+
object.paren = schema.id;
|
|
119
|
+
chains.set(schema, chain);
|
|
120
|
+
skeletons.set(object, schema);
|
|
121
|
+
object.children = (schema.children ?? []).map((child) => skeletonOf(child, [...chain, child]));
|
|
122
|
+
return object;
|
|
123
|
+
};
|
|
124
|
+
const skeleton = { artifacts: config.map((schema) => ({ created: now, modified: now, objects: [skeletonOf(schema, [schema])] })) };
|
|
125
|
+
const skeletonIndex = buildLinkIndex(skeleton);
|
|
126
|
+
/* the schema nodes a reference pattern matches at the kind level:
|
|
127
|
+
every name part is generalized to the wildcard, as the skeleton
|
|
128
|
+
knows the kinds only, and a pattern matching every node (like
|
|
129
|
+
"[[*]]") names no particular target */
|
|
130
|
+
const targetsOf = (pattern) => {
|
|
131
|
+
const generalized = pattern.replace(/:("(?:[^"\\]|\\.)*"|[^.":]+)/g, ":*");
|
|
132
|
+
const matches = resolveSet(skeletonIndex, generalized).flatMap((object) => skeletons.get(object) ?? []);
|
|
133
|
+
return matches.length === skeletons.size ? [] : matches;
|
|
134
|
+
};
|
|
135
|
+
const targetsOfAll = (patterns) => Array.from(new Set(patterns.flatMap(targetsOf)));
|
|
136
|
+
/* the meta objects: the title, the schema, the artifact kinds, the
|
|
137
|
+
artifacts, the object kinds, and the properties, where a property
|
|
138
|
+
whose value constraint references at least one kind is a REFERENCE */
|
|
139
|
+
const metaObject = (kind, name, paren) => {
|
|
140
|
+
const o = { kind, id: paren ?? slugify(name), name, properties: [], children: [] };
|
|
141
|
+
if (paren !== undefined)
|
|
142
|
+
o.paren = paren;
|
|
143
|
+
return o;
|
|
144
|
+
};
|
|
145
|
+
const title = metaObject("META", "Title", "TI");
|
|
146
|
+
const kinds = new Map();
|
|
147
|
+
const metas = new Map();
|
|
148
|
+
const props = new Map();
|
|
149
|
+
const propertyObjects = (owner, chain) => {
|
|
150
|
+
for (const prop of chain[chain.length - 1].props ?? []) {
|
|
151
|
+
const targets = targetsOfAll(patternsOf(prop));
|
|
152
|
+
const o = metaObject(targets.length > 0 ? "REFERENCE" : "PROPERTY", prop.name);
|
|
153
|
+
props.set(o, { chain, prop, targets });
|
|
154
|
+
owner.children.push(o);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
for (const schema of config) {
|
|
158
|
+
let kind = kinds.get(schema.kind);
|
|
159
|
+
if (kind === undefined) {
|
|
160
|
+
kind = metaObject("ARTIFACT-KIND", schema.kind);
|
|
161
|
+
kinds.set(schema.kind, kind);
|
|
162
|
+
}
|
|
163
|
+
const artifact = metaObject("ARTIFACT", schema.name ?? schema.id ?? schema.kind, schema.id);
|
|
164
|
+
metas.set(schema, artifact);
|
|
165
|
+
kind.children.push(artifact);
|
|
166
|
+
propertyObjects(artifact, [schema]);
|
|
167
|
+
/* the object kinds, flat below their artifact in pre-order */
|
|
168
|
+
const names = new Set();
|
|
169
|
+
const walk = (parent) => {
|
|
170
|
+
for (const child of parent.children ?? []) {
|
|
171
|
+
const name = names.has(child.kind) ? `${parent.kind}/${child.kind}` : child.kind;
|
|
172
|
+
names.add(name);
|
|
173
|
+
const o = metaObject("OBJECT-KIND", name);
|
|
174
|
+
metas.set(child, o);
|
|
175
|
+
artifact.children.push(o);
|
|
176
|
+
propertyObjects(o, chains.get(child) ?? [child]);
|
|
177
|
+
walk(child);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
walk(schema);
|
|
181
|
+
}
|
|
182
|
+
/* the references among the meta objects, shortest unique from
|
|
183
|
+
their referencing object (resolved through the final index) */
|
|
184
|
+
const spec = { artifacts: [title, ...kinds.values()].map((o) => ({ created: now, modified: now, objects: [o] })) };
|
|
185
|
+
const index = buildLinkIndex(spec);
|
|
186
|
+
const ref = (target, from) => `[[${referenceTo(index, target, from)}]]`;
|
|
187
|
+
const refs = (targets, from) => targets.flatMap((target) => metas.get(target) ?? []).map((target) => ref(target, from)).join(", ");
|
|
188
|
+
/* the display labels of the configuration files, and the origin of
|
|
189
|
+
a node (its contributing files, shown when several files are merged) */
|
|
190
|
+
const labelOf = (file) => labels.get(file) ?? file;
|
|
191
|
+
const labelsMd = files.map((file) => escapeMd(labelOf(file))).join(", ");
|
|
192
|
+
const filesOf = (chain, prop) => origins.get(originKey(chain, prop)) ?? [];
|
|
193
|
+
const originOf = (origin) => files.length > 1 && origin.length > 0 ? origin.map((file) => codeMd(labelOf(file))).join(", ") : undefined;
|
|
194
|
+
/* the property values of a meta object (the absent ones left out) */
|
|
195
|
+
const push = (o, entries) => {
|
|
196
|
+
for (const [key, value] of entries)
|
|
197
|
+
if (value !== undefined)
|
|
198
|
+
o.properties.push({ key, value });
|
|
199
|
+
};
|
|
200
|
+
const patternsMd = (patterns) => patterns !== undefined ? codeMd(patterns.join(", ")) : undefined;
|
|
201
|
+
const configMd = (value) => value !== undefined ? codeMd(flatten(value).join(", ")) : undefined;
|
|
202
|
+
/* the title: the document properties and the abstract */
|
|
203
|
+
const stats = configStatistics(config);
|
|
204
|
+
push(title, [
|
|
205
|
+
["TITLE", "Schema Configuration"],
|
|
206
|
+
["SUBTITLE", labelsMd],
|
|
207
|
+
["VERSION", `SpecBook ${version}`],
|
|
208
|
+
["LANG", "en"]
|
|
209
|
+
]);
|
|
210
|
+
title.description = {
|
|
211
|
+
description: `The schema configuration ${labelsMd} specifies ${plural(kinds.size, "artifact kind")} ` +
|
|
212
|
+
`with ${plural(config.length, "artifact")}, ${plural(stats.objects - config.length, "object kind")}, ` +
|
|
213
|
+
`and ${plural(stats.links, "reference relationship")}.`,
|
|
214
|
+
elaboration: "Every artifact and object kind lists its properties (`PROPERTY`) and its " +
|
|
215
|
+
"reference-valued properties (`REFERENCE`, the edges of the diagrams, leading to the " +
|
|
216
|
+
"artifacts and object kinds the value constraint references), and every object kind " +
|
|
217
|
+
"names the artifact or object kind it is nested directly below (`PARENT`). The value " +
|
|
218
|
+
"constraints, name patterns, and configurations are quoted verbatim from the schema " +
|
|
219
|
+
"configuration.\n\n" +
|
|
220
|
+
"The UML class diagram of an artifact shows the artifact (stereotyped `artifact`) and " +
|
|
221
|
+
"its object kinds as classes with their properties as attributes (typed by the kind of " +
|
|
222
|
+
"their value constraint and their multiplicity), their nesting as compositions, and " +
|
|
223
|
+
"their reference-valued properties as associations labeled with the property and its " +
|
|
224
|
+
"arity, where a referenced artifact or object kind of another artifact appears as a bare class."
|
|
225
|
+
};
|
|
226
|
+
/* the UML class diagram of an artifact and its object kinds -- the
|
|
227
|
+
artifact stereotyped, the non-reference properties as typed
|
|
228
|
+
attributes, the nesting as compositions, and the reference-valued
|
|
229
|
+
properties as labeled associations with their arity (none for a
|
|
230
|
+
degenerate diagram without any relationship, exactly like the
|
|
231
|
+
collapsed Gradia diagrams) */
|
|
232
|
+
const membersOf = (artifacts) => {
|
|
233
|
+
const members = new Array();
|
|
234
|
+
const collect = (schema) => {
|
|
235
|
+
members.push(schema);
|
|
236
|
+
for (const child of schema.children ?? [])
|
|
237
|
+
collect(child);
|
|
238
|
+
};
|
|
239
|
+
artifacts.forEach(collect);
|
|
240
|
+
return members;
|
|
241
|
+
};
|
|
242
|
+
const classDiagramOf = (artifacts) => {
|
|
243
|
+
const members = membersOf(artifacts);
|
|
244
|
+
/* the class names in the notation of the references: an artifact
|
|
245
|
+
as "<kind>.<id>", an object kind by its kind, qualified by the
|
|
246
|
+
id of its artifact (and then by its parent kind) only where
|
|
247
|
+
the name collides among the classes of the diagram */
|
|
248
|
+
const shown = new Set([...members,
|
|
249
|
+
...members.flatMap((schema) => (schema.props ?? []).flatMap((prop) => targetsOfAll(patternsOf(prop))))]);
|
|
250
|
+
const names = new Map();
|
|
251
|
+
const unique = (candidate, nodes) => {
|
|
252
|
+
const counts = new Map();
|
|
253
|
+
for (const schema of nodes)
|
|
254
|
+
counts.set(candidate(schema), (counts.get(candidate(schema)) ?? 0) + 1);
|
|
255
|
+
return nodes.filter((schema) => {
|
|
256
|
+
if (counts.get(candidate(schema)) !== 1)
|
|
257
|
+
return true;
|
|
258
|
+
names.set(schema, classId(candidate(schema)));
|
|
259
|
+
return false;
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
const artifactLabel = (schema) => schema.id ?? slugify(schema.name ?? schema.kind);
|
|
263
|
+
for (const schema of shown) {
|
|
264
|
+
const chain = chains.get(schema) ?? [schema];
|
|
265
|
+
if (chain.length === 1)
|
|
266
|
+
names.set(schema, classId(`${schema.kind}.${artifactLabel(schema)}`));
|
|
267
|
+
}
|
|
268
|
+
const qualified = (schema) => `${artifactLabel((chains.get(schema) ?? [schema])[0])}.${schema.kind}`;
|
|
269
|
+
const nested = (schema) => `${artifactLabel((chains.get(schema) ?? [schema])[0])}.` +
|
|
270
|
+
(metas.get(schema)?.name ?? schema.kind).replace(/\//g, ".");
|
|
271
|
+
let rest = Array.from(shown).filter((schema) => !names.has(schema));
|
|
272
|
+
rest = unique((schema) => schema.kind, rest);
|
|
273
|
+
rest = unique(qualified, rest);
|
|
274
|
+
for (const schema of rest)
|
|
275
|
+
names.set(schema, classId(nested(schema)));
|
|
276
|
+
const classOf = (schema) => names.get(schema) ?? classId(schema.kind);
|
|
277
|
+
const relations = new Array();
|
|
278
|
+
for (const schema of members) {
|
|
279
|
+
for (const child of schema.children ?? [])
|
|
280
|
+
relations.push(` ${classOf(schema)} *-- "${child.optional === true ? "0..n" : "1..n"}" ` +
|
|
281
|
+
classOf(child));
|
|
282
|
+
for (const prop of schema.props ?? [])
|
|
283
|
+
for (const target of targetsOfAll(patternsOf(prop)))
|
|
284
|
+
relations.push(` ${classOf(schema)} --> "${arityOf(prop)}" ${classOf(target)} : ${prop.name}`);
|
|
285
|
+
}
|
|
286
|
+
if (relations.length === 0)
|
|
287
|
+
return undefined;
|
|
288
|
+
const lines = ["classDiagram"];
|
|
289
|
+
for (const schema of members) {
|
|
290
|
+
lines.push(` class ${classOf(schema)} {`);
|
|
291
|
+
if ((chains.get(schema) ?? [schema]).length === 1)
|
|
292
|
+
lines.push(" <<artifact>>");
|
|
293
|
+
for (const prop of schema.props ?? [])
|
|
294
|
+
if (targetsOfAll(patternsOf(prop)).length === 0)
|
|
295
|
+
lines.push(` ${classAttribute(prop)}`);
|
|
296
|
+
lines.push(" }");
|
|
297
|
+
}
|
|
298
|
+
return [...lines, ...relations].join("\n") + "\n";
|
|
299
|
+
};
|
|
300
|
+
/* the artifact kinds: their artifacts */
|
|
301
|
+
for (const [name, o] of kinds) {
|
|
302
|
+
const artifacts = config.filter((schema) => schema.kind === name);
|
|
303
|
+
push(o, [["ORIGIN", originOf(Array.from(new Set(artifacts.flatMap((schema) => filesOf([schema])))))]]);
|
|
304
|
+
o.description = {
|
|
305
|
+
description: `The artifact kind **${escapeMd(name)}** comprises ${plural(artifacts.length, "artifact")}: ` +
|
|
306
|
+
`${refs(artifacts, o)}.`
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/* the class diagram of an artifact, introduced by its figures: its
|
|
310
|
+
object kinds and associations, plus the other artifacts the
|
|
311
|
+
associations reach into */
|
|
312
|
+
const classDiagramMd = (schema, o) => {
|
|
313
|
+
const uml = classDiagramOf([schema]);
|
|
314
|
+
if (uml === undefined)
|
|
315
|
+
return undefined;
|
|
316
|
+
const members = membersOf([schema]);
|
|
317
|
+
const targets = members.flatMap((member) => (member.props ?? []).flatMap((prop) => targetsOfAll(patternsOf(prop))));
|
|
318
|
+
const foreign = Array.from(new Set(targets.map((target) => (chains.get(target) ?? [target])[0])))
|
|
319
|
+
.filter((artifact) => artifact !== schema);
|
|
320
|
+
return `The class diagram shows ${plural(members.length - 1, "object kind")} of the artifact` +
|
|
321
|
+
(targets.length > 0 ? ` with ${plural(targets.length, "association")}` : "") +
|
|
322
|
+
(foreign.length > 0 ? `, reaching into ${refs(foreign, o)}` : "") +
|
|
323
|
+
".\n\n```mermaid\n" + uml + "```";
|
|
324
|
+
};
|
|
325
|
+
/* the artifacts and object kinds: their configuration, their
|
|
326
|
+
description, and the prose of their relationships */
|
|
327
|
+
const kindsMd = (patterns, o) => {
|
|
328
|
+
const inner = patterns.map((pattern) => compileValueExpr(pattern))
|
|
329
|
+
.flatMap((expr) => expr.kind === "reference" ? [expr.pattern] : []);
|
|
330
|
+
const targets = targetsOfAll(inner);
|
|
331
|
+
return targets.length > 0 ? `the ${refs(targets, o)} objects` :
|
|
332
|
+
inner.includes("*") ? "any object" : `the objects matching ${codeMd(patterns.join(", "))}`;
|
|
333
|
+
};
|
|
334
|
+
for (const [schema, o] of metas) {
|
|
335
|
+
const chain = chains.get(schema) ?? [schema];
|
|
336
|
+
const parent = chain.length >= 2 ? metas.get(chain[chain.length - 2]) : undefined;
|
|
337
|
+
push(o, [
|
|
338
|
+
["ORIGIN", originOf(filesOf(chain))],
|
|
339
|
+
["PARENT", parent !== undefined ? ref(parent, o) : undefined],
|
|
340
|
+
["FILE", chain.length === 1 && schema.file !== undefined ? codeMd(schema.file) : undefined],
|
|
341
|
+
["NAME", chain.length > 1 && schema.name !== undefined ? codeMd(schema.name) : undefined],
|
|
342
|
+
["OPTIONAL", schema.optional === true ? "true" : "false"],
|
|
343
|
+
["REFERENCED", patternsMd(schema.referenced)],
|
|
344
|
+
["COVERAGE", patternsMd(schema.coverage)],
|
|
345
|
+
["AUTOMATON", configMd(schema.automaton)],
|
|
346
|
+
["BADGE", typeof schema.badge === "string" ? codeMd(schema.badge) : configMd(schema.badge)],
|
|
347
|
+
["MULTIPLE", configMd(schema.multiple)],
|
|
348
|
+
["DIAGRAM", configMd(schema.diagram)],
|
|
349
|
+
["FORMAT", configMd(schema.format)]
|
|
350
|
+
]);
|
|
351
|
+
const parts = new Array();
|
|
352
|
+
const subject = `Every object of the kind **${escapeMd(schema.kind)}**`;
|
|
353
|
+
if (schema.referenced !== undefined)
|
|
354
|
+
parts.push(`${subject} has to be referenced from ${kindsMd(schema.referenced, o)}.`);
|
|
355
|
+
if (schema.coverage !== undefined)
|
|
356
|
+
parts.push(`${subject} reports the reference coverage of ${kindsMd(schema.coverage, o)}.`);
|
|
357
|
+
if (schema.automaton !== undefined) {
|
|
358
|
+
const automaton = schema.automaton;
|
|
359
|
+
const childMd = (kind) => {
|
|
360
|
+
const child = (schema.children ?? []).find((c) => c.kind === kind);
|
|
361
|
+
const target = child !== undefined ? metas.get(child) : undefined;
|
|
362
|
+
return target !== undefined ? ref(target, o) : `\`${kind}\``;
|
|
363
|
+
};
|
|
364
|
+
parts.push(`The ${childMd(automaton.nodes)} children form a finite state machine, whose ` +
|
|
365
|
+
`${childMd(automaton.edges)} children lead from the node their \`${automaton.source}\` names ` +
|
|
366
|
+
`to the node their \`${automaton.target}\` names, where \`${automaton.initial}\` flags the ` +
|
|
367
|
+
`initial and \`${automaton.final}\` the final nodes.`);
|
|
368
|
+
}
|
|
369
|
+
if (schema.refs !== undefined)
|
|
370
|
+
parts.push(`References:\n\n${escapeDesc(schema.refs)}`);
|
|
371
|
+
const uml = chain.length === 1 ? classDiagramMd(schema, o) : undefined;
|
|
372
|
+
if (uml !== undefined)
|
|
373
|
+
parts.push(uml);
|
|
374
|
+
o.description = { description: escapeDesc(schema.desc ?? `The objects of the kind \`${schema.kind}\`.`),
|
|
375
|
+
elaboration: parts.length > 0 ? parts.join("\n\n") : undefined };
|
|
376
|
+
}
|
|
377
|
+
/* the properties: their constraint, flags, and targets */
|
|
378
|
+
for (const [o, { chain, prop, targets }] of props) {
|
|
379
|
+
push(o, [
|
|
380
|
+
["ORIGIN", originOf(filesOf(chain, prop))],
|
|
381
|
+
["OPTIONAL", prop.optional === true ? "true" : "false"],
|
|
382
|
+
["VALUE", prop.value !== undefined ? codeMd(prop.value) : undefined],
|
|
383
|
+
["TARGET", targets.length > 0 ? refs(targets, o) : undefined],
|
|
384
|
+
["ARITY", targets.length > 0 ? arityOf(prop) : undefined],
|
|
385
|
+
["UNIQUE", markerOf(prop.unique)],
|
|
386
|
+
["PRESENT", markerOf(prop.present)],
|
|
387
|
+
["LOCAL", prop.local === true ? "true" : undefined],
|
|
388
|
+
["SYMMETRIC", prop.symmetric === true ? "true" : undefined],
|
|
389
|
+
["ACYCLIC", prop.acyclic === true ? "true" : undefined]
|
|
390
|
+
]);
|
|
391
|
+
o.description = { description: escapeDesc(prop.desc ?? `The property \`${prop.name}\`.`) };
|
|
392
|
+
}
|
|
393
|
+
/* render the title artifact and the (recurring) artifact kind
|
|
394
|
+
artifacts into the Markdown sources of their configured files,
|
|
395
|
+
timestamped with the oldest and the latest configuration file */
|
|
396
|
+
const times = files.map((file) => fs.statSync(file).mtime.getTime());
|
|
397
|
+
const created = formatTimestamp(new Date(Math.min(...times)));
|
|
398
|
+
const modified = formatTimestamp(new Date(Math.max(...times)));
|
|
399
|
+
const source = (objects) => ({
|
|
400
|
+
file: meta.find((schema) => schema.kind === objects[0].kind)?.file ?? `${objects[0].kind}.md`,
|
|
401
|
+
text: `---\nCreated: ${created}\nModified: ${modified}\n---\n\n` +
|
|
402
|
+
objects.map((o) => renderObjectMd(o, 1)).join("\n\n") + "\n"
|
|
403
|
+
});
|
|
404
|
+
return [source([title]), ...(kinds.size > 0 ? [source(Array.from(kinds.values()))] : [])];
|
|
405
|
+
};
|
|
406
|
+
/* generate the specification describing the effective schema
|
|
407
|
+
configuration and parse it against the bundled meta schema
|
|
408
|
+
configuration, exactly like "lint" parses the specification files,
|
|
409
|
+
so the result feeds the very same exports (the generated sources
|
|
410
|
+
carry no files to observe beyond the configuration ones) */
|
|
411
|
+
export const lintSchema = (options) => {
|
|
412
|
+
const { verbose } = options;
|
|
413
|
+
const diagnostics = new Array();
|
|
414
|
+
/* load the schema configuration to describe, merged out of its files */
|
|
415
|
+
verbose("loading schema configuration " +
|
|
416
|
+
options.config.map((file) => `"${literal(file)}"`).join(", "));
|
|
417
|
+
const loaded = loadConfig(options.config);
|
|
418
|
+
diagnostics.push(...loaded.diagnostics);
|
|
419
|
+
reportOverlaps(loaded.overlaps, verbose);
|
|
420
|
+
const config = loaded.config;
|
|
421
|
+
if (config === undefined)
|
|
422
|
+
return { specification: { artifacts: [] }, diagnostics, files: [], origins: new Map() };
|
|
423
|
+
const stats = configStatistics(config);
|
|
424
|
+
verbose(`loaded schema configuration specifying ${literal(stats.objects)} object kind(s) ` +
|
|
425
|
+
`and ${literal(stats.links)} link relationship(s)`);
|
|
426
|
+
/* load the bundled meta schema configuration the generated specification follows */
|
|
427
|
+
const meta = loadConfig([metaConfig]);
|
|
428
|
+
if (meta.config === undefined)
|
|
429
|
+
throw new Error("invalid meta schema configuration:\n" +
|
|
430
|
+
meta.diagnostics.map(renderDiagnostic).join("\n"));
|
|
431
|
+
/* generate and parse the specification describing the schema configuration */
|
|
432
|
+
const sources = generateSources(config, meta.config, options.config, options.labels, loaded.origins);
|
|
433
|
+
verbose(`parsing ${literal(sources.length)} generated specification file(s) describing the schema configuration`);
|
|
434
|
+
const result = parseSpecification(sources, meta.config);
|
|
435
|
+
diagnostics.push(...result.diagnostics);
|
|
436
|
+
const specStats = specStatistics(result.specification);
|
|
437
|
+
verbose(`parsed specification defining ${literal(specStats.objects)} object(s) ` +
|
|
438
|
+
`and ${literal(specStats.links)} link relationship(s)`);
|
|
439
|
+
return { specification: result.specification, diagnostics, config: meta.config,
|
|
440
|
+
files: [], origins: result.origins };
|
|
441
|
+
};
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
7
8
|
/* Chokidar is aliased in "package.json", as Nunjucks declares the
|
|
8
9
|
(unused) Chokidar 3 as an optional peer dependency and the current
|
|
9
10
|
Chokidar under its plain name would raise a peer warning on install */
|
|
@@ -16,6 +17,7 @@ import { renderMarkdown } from "./specbook-export-md.js";
|
|
|
16
17
|
import { renderHtml, htmlOutline, titlePageObject } from "./specbook-export-html.js";
|
|
17
18
|
import { htmlToPdf, requireBrowser } from "./specbook-export-pdf.js";
|
|
18
19
|
import { literal } from "./specbook-verbose.js";
|
|
20
|
+
import { specFiles } from "./specbook-files.js";
|
|
19
21
|
/* re-export the browser pre-check of the PDF renderer, so the API
|
|
20
22
|
facade reaches the renderers through this command module only */
|
|
21
23
|
export { requireBrowser };
|
|
@@ -66,12 +68,16 @@ const watchDelay = 1000;
|
|
|
66
68
|
const scanDelay = 30 * 1000;
|
|
67
69
|
/* take the modification time snapshot of the observed files, where an
|
|
68
70
|
absent file (a still missing artifact) is remembered as absent, too,
|
|
69
|
-
so its creation and removal are noticed like a modification
|
|
71
|
+
so its creation and removal are noticed like a modification, while a
|
|
72
|
+
directory is represented by the number of its specification files,
|
|
73
|
+
as its own modification time also changes with every unrelated file
|
|
74
|
+
created in it (like an editor swap file) */
|
|
70
75
|
const snapshot = (files) => {
|
|
71
76
|
const times = new Map();
|
|
72
77
|
for (const file of files) {
|
|
73
78
|
try {
|
|
74
|
-
|
|
79
|
+
const stats = fs.statSync(file);
|
|
80
|
+
times.set(file, stats.isDirectory() ? specFiles(file).length : stats.mtimeMs);
|
|
75
81
|
}
|
|
76
82
|
catch {
|
|
77
83
|
times.set(file, 0);
|
|
@@ -92,7 +98,15 @@ export const watchSpecification = async (run, verbose) => {
|
|
|
92
98
|
const started = Date.now();
|
|
93
99
|
let observed = await run();
|
|
94
100
|
let times = snapshot(observed);
|
|
95
|
-
|
|
101
|
+
/* the observed set carries the base directory as a whole (so the
|
|
102
|
+
added and removed specification files are noticed, too), below
|
|
103
|
+
which only the Markdown files and the explicitly observed files
|
|
104
|
+
count, as any other file written into it (like an export output)
|
|
105
|
+
would re-trigger the export endlessly, and whose "."-prefixed
|
|
106
|
+
directories (like ".git") the specification never spans */
|
|
107
|
+
const watcher = watch(observed, { ignoreInitial: true,
|
|
108
|
+
ignored: (file, stats) => stats !== undefined && !observed.includes(file)
|
|
109
|
+
&& (stats.isDirectory() ? path.basename(file).startsWith(".") : !file.endsWith(".md")) });
|
|
96
110
|
verbose(`observing ${literal(observed.length)} specification file(s) for changes`);
|
|
97
111
|
/* report the failures of the observation itself, as an unhandled
|
|
98
112
|
"error" event would otherwise terminate the process */
|
|
@@ -100,13 +114,16 @@ export const watchSpecification = async (run, verbose) => {
|
|
|
100
114
|
verbose("observing failed: " +
|
|
101
115
|
(err instanceof Error ? err.message : String(err)), "none");
|
|
102
116
|
});
|
|
103
|
-
/* perform a re-export and re-synchronize the observed files
|
|
117
|
+
/* perform a re-export and re-synchronize the observed files (the
|
|
118
|
+
observed set is replaced before the watcher is fed, as its filter
|
|
119
|
+
above consults it) */
|
|
104
120
|
const cycle = async () => {
|
|
105
121
|
const files = await run();
|
|
106
|
-
|
|
107
|
-
watcher.add(files.filter((file) => !observed.includes(file)));
|
|
122
|
+
const previous = observed;
|
|
108
123
|
observed = files;
|
|
109
124
|
times = snapshot(files);
|
|
125
|
+
watcher.unwatch(previous.filter((file) => !files.includes(file)));
|
|
126
|
+
watcher.add(files.filter((file) => !previous.includes(file)));
|
|
110
127
|
};
|
|
111
128
|
/* restart the quiet period on every change, so a burst collapses
|
|
112
129
|
into a single re-export, and chain the runs onto each other, so a
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type SchemaObject } from "./specbook-format-schema.js";
|
|
2
|
+
import { type SpecObject } from "./specbook-format-spec.js";
|
|
3
|
+
export declare const wrapSample: (text: string) => string;
|
|
4
|
+
export declare const sampleArtifacts: (artifacts: SchemaObject[]) => Map<SchemaObject, SpecObject>;
|