@rse/specbook 0.9.2
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/README.md +199 -0
- package/dst/specbook-api.d.ts +47 -0
- package/dst/specbook-api.js +77 -0
- package/dst/specbook-cli.d.ts +2 -0
- package/dst/specbook-cli.js +156 -0
- package/dst/specbook-cmd-describe.d.ts +2 -0
- package/dst/specbook-cmd-describe.js +97 -0
- package/dst/specbook-cmd-edit.d.ts +9 -0
- package/dst/specbook-cmd-edit.js +69 -0
- package/dst/specbook-cmd-export.d.ts +9 -0
- package/dst/specbook-cmd-export.js +78 -0
- package/dst/specbook-cmd-import.d.ts +9 -0
- package/dst/specbook-cmd-import.js +73 -0
- package/dst/specbook-cmd-init.d.ts +7 -0
- package/dst/specbook-cmd-init.js +37 -0
- package/dst/specbook-cmd-lint.d.ts +15 -0
- package/dst/specbook-cmd-lint.js +46 -0
- package/dst/specbook-config.d.ts +6 -0
- package/dst/specbook-config.js +91 -0
- package/dst/specbook-diagnostic.d.ts +8 -0
- package/dst/specbook-diagnostic.js +23 -0
- package/dst/specbook-diagram.d.ts +9 -0
- package/dst/specbook-diagram.js +271 -0
- package/dst/specbook-export-ast.d.ts +4 -0
- package/dst/specbook-export-ast.js +36 -0
- package/dst/specbook-export-common.d.ts +33 -0
- package/dst/specbook-export-common.js +142 -0
- package/dst/specbook-export-html-search.js +272 -0
- package/dst/specbook-export-html.css +259 -0
- package/dst/specbook-export-html.d.ts +9 -0
- package/dst/specbook-export-html.js +452 -0
- package/dst/specbook-export-logo.svg +35 -0
- package/dst/specbook-export-md.d.ts +3 -0
- package/dst/specbook-export-md.js +74 -0
- package/dst/specbook-export-pdf.d.ts +7 -0
- package/dst/specbook-export-pdf.js +198 -0
- package/dst/specbook-link.d.ts +17 -0
- package/dst/specbook-link.js +113 -0
- package/dst/specbook-llm.d.ts +13 -0
- package/dst/specbook-llm.js +61 -0
- package/dst/specbook-mcp.d.ts +1 -0
- package/dst/specbook-mcp.js +163 -0
- package/dst/specbook-parse-common.d.ts +26 -0
- package/dst/specbook-parse-common.js +33 -0
- package/dst/specbook-parse-semantic.d.ts +5 -0
- package/dst/specbook-parse-semantic.js +254 -0
- package/dst/specbook-parse-syntax.d.ts +3 -0
- package/dst/specbook-parse-syntax.js +363 -0
- package/dst/specbook-parse-value.d.ts +19 -0
- package/dst/specbook-parse-value.js +123 -0
- package/dst/specbook-parse.d.ts +8 -0
- package/dst/specbook-parse.js +43 -0
- package/dst/specbook-struct-schema.d.ts +35 -0
- package/dst/specbook-struct-schema.js +47 -0
- package/dst/specbook-struct-spec.d.ts +31 -0
- package/dst/specbook-struct-spec.js +36 -0
- package/dst/specbook-theme.d.ts +16 -0
- package/dst/specbook-theme.js +38 -0
- package/package.json +77 -0
|
@@ -0,0 +1,254 @@
|
|
|
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 { referenceRegex, resolveUnique, resolveSet } from "./specbook-link.js";
|
|
7
|
+
import { compileValueExpr, splitItems } from "./specbook-parse-value.js";
|
|
8
|
+
/* strip a trailing parenthesized annotation from a
|
|
9
|
+
property key (e.g. "WHEN (Context)") */
|
|
10
|
+
const plainKey = (key) => key.replace(/\s*\([^)]*\)\s*$/, "").trim();
|
|
11
|
+
/* strip the inline code markup of a name or property value
|
|
12
|
+
(preserved in the AST for rendering) for matching purposes */
|
|
13
|
+
const plainText = (text) => text.replace(/`/g, "");
|
|
14
|
+
/* compile a configured pattern into an anchored regular expression */
|
|
15
|
+
const anchored = (pattern, flags = "") => new RegExp(`^(?:${pattern})$`, flags);
|
|
16
|
+
/* match a single inline token against a value expression
|
|
17
|
+
(case-insensitively, as inline tokens are decorations) */
|
|
18
|
+
const tokenMatches = (expr, token) => expr.kind === "regex" ?
|
|
19
|
+
anchored(expr.source, "i").test(token) :
|
|
20
|
+
expr.kind === "enum" ?
|
|
21
|
+
expr.members.some((member) => member.toLowerCase() === token.toLowerCase()) :
|
|
22
|
+
false;
|
|
23
|
+
/* match a full property value directly against a value expression
|
|
24
|
+
(defined for the regex and enum kinds only) */
|
|
25
|
+
const directMatches = (expr, text) => expr.kind === "regex" ?
|
|
26
|
+
expr.regex.test(text) :
|
|
27
|
+
expr.kind === "enum" ?
|
|
28
|
+
expr.members.includes(text) :
|
|
29
|
+
false;
|
|
30
|
+
/* match a single list item against the alternatives of a list
|
|
31
|
+
constraint (a reference item resolves leniently, as unresolvable
|
|
32
|
+
and ambiguous ones are already reported by the reference pass) */
|
|
33
|
+
const matchAlternatives = (ctx, alternatives, item) => {
|
|
34
|
+
const rm = item.match(/^\[\[([^[\]]+)\]\]$/);
|
|
35
|
+
for (const alternative of alternatives) {
|
|
36
|
+
if (alternative.kind === "reference") {
|
|
37
|
+
if (rm === null)
|
|
38
|
+
continue;
|
|
39
|
+
const target = resolveUnique(ctx.linkIndex, rm[1].trim()).target;
|
|
40
|
+
if (target === undefined
|
|
41
|
+
|| resolveSet(ctx.linkIndex, alternative.pattern).includes(target))
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
else if (alternative.kind === "tags") {
|
|
45
|
+
if (alternative.members.includes(plainText(item)))
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
else if (directMatches(alternative, plainText(item)))
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
};
|
|
53
|
+
/* validate a single object (and recursively its childs) against its schema */
|
|
54
|
+
const validateObject = (ctx, object, schema, level) => {
|
|
55
|
+
const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
|
|
56
|
+
const props = schema.props ?? [];
|
|
57
|
+
/* check the name convention (the configured name of non-artifact
|
|
58
|
+
objects is a regular expression pattern) */
|
|
59
|
+
if (level > 1 && schema.name !== undefined && !anchored(schema.name).test(plainText(object.name)))
|
|
60
|
+
ctx.diagnose(meta.file, meta.line, `${object.kind} name "${object.name}" does not match pattern "${schema.name}"`);
|
|
61
|
+
/* a configured id has to be explicitly specified 1:1 in the
|
|
62
|
+
input, via either "{{<id>}}" or "(<id>)" */
|
|
63
|
+
if (schema.id !== undefined && (object.anchor ?? object.paren) !== schema.id)
|
|
64
|
+
ctx.diagnose(meta.file, meta.line, `configured id "${schema.id}" not explicitly specified on ${object.kind} "${object.name}"`);
|
|
65
|
+
/* find a property by its key (case-sensitive) */
|
|
66
|
+
const findProp = (name) => object.properties.find((p) => plainKey(p.key) === name);
|
|
67
|
+
/* the tokens split off multi-token property values below, assigned
|
|
68
|
+
to the still unset configured properties by matching their patterns */
|
|
69
|
+
const tokens = new Array();
|
|
70
|
+
const tokenAssigned = new Set();
|
|
71
|
+
/* explode a multi-token property value whose direct pattern check
|
|
72
|
+
fails: keep the token matching the own pattern and distribute
|
|
73
|
+
the remaining tokens across the sibling properties */
|
|
74
|
+
for (const prop of props) {
|
|
75
|
+
if (prop.value === undefined)
|
|
76
|
+
continue;
|
|
77
|
+
const expr = compileValueExpr(prop.value);
|
|
78
|
+
if (expr.kind !== "regex" && expr.kind !== "enum")
|
|
79
|
+
continue;
|
|
80
|
+
const match = findProp(prop.name);
|
|
81
|
+
if (match === undefined || directMatches(expr, plainText(match.value)))
|
|
82
|
+
continue;
|
|
83
|
+
const parts = plainText(match.value).split(/\s+/);
|
|
84
|
+
if (parts.length < 2)
|
|
85
|
+
continue;
|
|
86
|
+
const idx = parts.findIndex((part) => tokenMatches(expr, part));
|
|
87
|
+
if (idx < 0)
|
|
88
|
+
continue;
|
|
89
|
+
match.value = parts[idx];
|
|
90
|
+
tokenAssigned.add(prop.name);
|
|
91
|
+
parts.splice(idx, 1);
|
|
92
|
+
tokens.push(...parts);
|
|
93
|
+
}
|
|
94
|
+
for (const prop of props) {
|
|
95
|
+
if (prop.value === undefined || findProp(prop.name) !== undefined)
|
|
96
|
+
continue;
|
|
97
|
+
const expr = compileValueExpr(prop.value);
|
|
98
|
+
if (expr.kind !== "regex" && expr.kind !== "enum")
|
|
99
|
+
continue;
|
|
100
|
+
const idx = tokens.findIndex((token) => tokenMatches(expr, token));
|
|
101
|
+
if (idx >= 0) {
|
|
102
|
+
object.properties.push({ key: prop.name, value: tokens[idx] });
|
|
103
|
+
tokenAssigned.add(prop.name);
|
|
104
|
+
tokens.splice(idx, 1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const token of tokens)
|
|
108
|
+
ctx.diagnose(meta.file, meta.line, `unassignable inline token "${token}" on ${object.kind} "${object.name}"`);
|
|
109
|
+
/* check the configured properties */
|
|
110
|
+
let parenConsumed = false;
|
|
111
|
+
for (const prop of props) {
|
|
112
|
+
const expr = prop.value !== undefined ? compileValueExpr(prop.value) : undefined;
|
|
113
|
+
const match = findProp(prop.name);
|
|
114
|
+
if (match === undefined) {
|
|
115
|
+
/* accept a trailing parenthesized name token as the value of a
|
|
116
|
+
still missing property when it matches the property pattern */
|
|
117
|
+
if (object.paren !== undefined && expr !== undefined
|
|
118
|
+
&& directMatches(expr, object.paren)) {
|
|
119
|
+
parenConsumed = true;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (prop.optional !== true)
|
|
123
|
+
ctx.diagnose(meta.file, meta.line, `required property "${prop.name}" missing on ${object.kind} "${object.name}"`);
|
|
124
|
+
}
|
|
125
|
+
else if (expr !== undefined) {
|
|
126
|
+
const line = ctx.propMeta.get(match)?.line ?? meta.line;
|
|
127
|
+
if (expr.kind === "reference") {
|
|
128
|
+
/* a reference constraint: the value has to be exactly one
|
|
129
|
+
reference resolving into the constraint's wildcard match set */
|
|
130
|
+
if (!/^\[\[[^[\]]+\]\]$/.test(match.value.trim()))
|
|
131
|
+
ctx.diagnose(meta.file, line, `property "${prop.name}" value "${match.value}" is not a single link reference`);
|
|
132
|
+
else {
|
|
133
|
+
const ref = match.value.trim().slice(2, -2).trim();
|
|
134
|
+
const target = resolveUnique(ctx.linkIndex, ref).target;
|
|
135
|
+
if (target !== undefined && !resolveSet(ctx.linkIndex, expr.pattern).includes(target))
|
|
136
|
+
ctx.diagnose(meta.file, line, `link reference "[[${ref}]]" does not match constraint "${prop.value}"`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else if (expr.kind === "tags") {
|
|
140
|
+
/* a tags constraint: the value is a comma-separated set of
|
|
141
|
+
configured tags, each occurring at most once */
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
for (const item of splitItems(plainText(match.value))) {
|
|
144
|
+
if (!expr.members.includes(item))
|
|
145
|
+
ctx.diagnose(meta.file, line, `tag "${item}" of property "${prop.name}" not allowed by constraint "${prop.value}"`);
|
|
146
|
+
else if (seen.has(item))
|
|
147
|
+
ctx.diagnose(meta.file, line, `duplicate tag "${item}" on property "${prop.name}"`);
|
|
148
|
+
seen.add(item);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else if (expr.kind === "list") {
|
|
152
|
+
/* a list constraint: the value is a comma-separated list
|
|
153
|
+
of items, each matching at least one alternative */
|
|
154
|
+
for (const item of splitItems(match.value))
|
|
155
|
+
if (!matchAlternatives(ctx, expr.alternatives, item))
|
|
156
|
+
ctx.diagnose(meta.file, line, `list item "${item}" of property "${prop.name}" does not match constraint "${prop.value}"`);
|
|
157
|
+
}
|
|
158
|
+
else if (!tokenAssigned.has(prop.name) && !directMatches(expr, plainText(match.value)))
|
|
159
|
+
ctx.diagnose(meta.file, line, expr.kind === "enum" ?
|
|
160
|
+
`property "${prop.name}" value "${match.value}" is not a member of "${prop.value}"` :
|
|
161
|
+
`property "${prop.name}" value "${match.value}" does not match pattern "${prop.value}"`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const property of object.properties)
|
|
165
|
+
if (!props.some((p) => p.name === plainKey(property.key)))
|
|
166
|
+
ctx.diagnose(meta.file, ctx.propMeta.get(property)?.line ?? meta.line, `unknown property "${property.key}" on ${object.kind} "${object.name}"`);
|
|
167
|
+
/* a parenthesized token not consumed as a property value acts as
|
|
168
|
+
the implicit anchor (an explicit "{{...}}" anchor takes precedence) */
|
|
169
|
+
if (object.paren !== undefined && !parenConsumed && object.anchor === undefined)
|
|
170
|
+
object.id = object.paren;
|
|
171
|
+
/* check the configured child objects */
|
|
172
|
+
const childs = schema.childs ?? [];
|
|
173
|
+
for (const child of object.childs) {
|
|
174
|
+
const childMeta = ctx.objectMeta.get(child) ?? { file: "", line: 1 };
|
|
175
|
+
const childSchema = childs.find((c) => c.kind === child.kind);
|
|
176
|
+
if (childSchema === undefined) {
|
|
177
|
+
ctx.diagnose(childMeta.file, childMeta.line, `unknown object kind "${child.kind}" below ${object.kind} "${object.name}"`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
validateObject(ctx, child, childSchema, level + 1);
|
|
181
|
+
}
|
|
182
|
+
for (const child of childs)
|
|
183
|
+
if (child.optional !== true
|
|
184
|
+
&& !object.childs.some((c) => c.kind === child.kind))
|
|
185
|
+
ctx.diagnose(meta.file, meta.line, `required object kind "${child.kind}" missing below ${object.kind} "${object.name}"`);
|
|
186
|
+
/* order the childs and properties exactly along the schema
|
|
187
|
+
definition (the sort is stable, so objects of the same kind
|
|
188
|
+
and unknown items keep their document order) */
|
|
189
|
+
const kindPos = (kind) => {
|
|
190
|
+
const i = childs.findIndex((c) => c.kind === kind);
|
|
191
|
+
return i >= 0 ? i : childs.length;
|
|
192
|
+
};
|
|
193
|
+
object.childs.sort((a, b) => kindPos(a.kind) - kindPos(b.kind));
|
|
194
|
+
const propPos = (key) => {
|
|
195
|
+
const i = props.findIndex((p) => p.name === plainKey(key));
|
|
196
|
+
return i >= 0 ? i : props.length;
|
|
197
|
+
};
|
|
198
|
+
object.properties.sort((a, b) => propPos(a.key) - propPos(b.key));
|
|
199
|
+
};
|
|
200
|
+
/* validate the parsed specification against the configuration */
|
|
201
|
+
export const validate = (ctx, specification, config) => {
|
|
202
|
+
const position = new Map();
|
|
203
|
+
for (const artifact of specification.artifacts) {
|
|
204
|
+
for (const object of artifact.objects) {
|
|
205
|
+
const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
|
|
206
|
+
const schema = config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
|
|
207
|
+
config.find((s) => (s.name ?? "").toUpperCase() === plainText(object.name).toUpperCase());
|
|
208
|
+
if (schema === undefined) {
|
|
209
|
+
ctx.diagnose(meta.file, meta.line, `unknown artifact "${object.kind}: ${object.name}" (id "${object.id}")`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
/* the artifact heading must carry the configured kind and
|
|
213
|
+
name verbatim (the resolution above is deliberately
|
|
214
|
+
lenient, so deviations are still precisely reported) */
|
|
215
|
+
if (object.kind !== schema.kind)
|
|
216
|
+
ctx.diagnose(meta.file, meta.line, `artifact kind "${object.kind}" does not match configured kind "${schema.kind}"`);
|
|
217
|
+
if (schema.name !== undefined && plainText(object.name) !== schema.name)
|
|
218
|
+
ctx.diagnose(meta.file, meta.line, `artifact name "${object.name}" does not match configured name "${schema.name}"`);
|
|
219
|
+
if (!position.has(artifact))
|
|
220
|
+
position.set(artifact, config.indexOf(schema));
|
|
221
|
+
validateObject(ctx, object, schema, 1);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/* order the artifacts exactly along the schema definition */
|
|
225
|
+
specification.artifacts.sort((a, b) => (position.get(a) ?? config.length) - (position.get(b) ?? config.length));
|
|
226
|
+
};
|
|
227
|
+
/* validate every Wiki-style reference for unique resolvability,
|
|
228
|
+
independent of any configuration */
|
|
229
|
+
export const validateReferences = (ctx, specification) => {
|
|
230
|
+
const check = (text, file, line) => {
|
|
231
|
+
for (const m of text.matchAll(referenceRegex)) {
|
|
232
|
+
const ref = m[1].trim();
|
|
233
|
+
const resolution = resolveUnique(ctx.linkIndex, ref);
|
|
234
|
+
if (resolution.ambiguous)
|
|
235
|
+
ctx.diagnose(file, line, `ambiguous link reference "[[${ref}]]"`);
|
|
236
|
+
else if (resolution.target === undefined)
|
|
237
|
+
ctx.diagnose(file, line, `unresolvable link reference "[[${ref}]]"`);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const walk = (object) => {
|
|
241
|
+
const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
|
|
242
|
+
check(object.name, meta.file, meta.line);
|
|
243
|
+
for (const property of object.properties)
|
|
244
|
+
check(property.value, meta.file, ctx.propMeta.get(property)?.line ?? meta.line);
|
|
245
|
+
if (object.description !== undefined) {
|
|
246
|
+
check(object.description.description, meta.file, meta.line);
|
|
247
|
+
if (object.description.rationale !== undefined)
|
|
248
|
+
check(object.description.rationale, meta.file, meta.line);
|
|
249
|
+
}
|
|
250
|
+
object.childs.forEach(walk);
|
|
251
|
+
};
|
|
252
|
+
for (const artifact of specification.artifacts)
|
|
253
|
+
artifact.objects.forEach(walk);
|
|
254
|
+
};
|
|
@@ -0,0 +1,363 @@
|
|
|
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 fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { marked } from "marked";
|
|
9
|
+
import { embeddingRegex, embeddingMimeType } from "./specbook-parse-common.js";
|
|
10
|
+
/* split a description text into statement and optional rationale */
|
|
11
|
+
const splitDescription = (text) => {
|
|
12
|
+
const m = text.match(/,\s*(?:\*\*BECAUSE\*\*|BECAUSE)\s+/);
|
|
13
|
+
if (m === null || m.index === undefined)
|
|
14
|
+
return { description: text };
|
|
15
|
+
return {
|
|
16
|
+
description: text.slice(0, m.index),
|
|
17
|
+
rationale: text.slice(m.index + m[0].length)
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
/* parse a heading text of the form "<KIND>: <name> [(<token>)] [<a id="..."></a>]" */
|
|
21
|
+
const parseHeadingText = (raw) => {
|
|
22
|
+
let text = raw.trim();
|
|
23
|
+
let id;
|
|
24
|
+
const am = text.match(/<a\s+id="([^"]+)"\s*>\s*<\/a>\s*$/);
|
|
25
|
+
if (am !== null && am.index !== undefined) {
|
|
26
|
+
id = am[1];
|
|
27
|
+
text = text.slice(0, am.index).trim();
|
|
28
|
+
}
|
|
29
|
+
/* detect a malformed trailing anchor fragment, still salvaging its id */
|
|
30
|
+
let malformed;
|
|
31
|
+
const fm = text.match(/<a\b[^>]*>?\s*(?:<\/a>)?\s*$/);
|
|
32
|
+
if (fm !== null && fm.index !== undefined) {
|
|
33
|
+
malformed = fm[0].trim();
|
|
34
|
+
id ??= malformed.match(/id="([^"]+)"/)?.[1];
|
|
35
|
+
text = text.slice(0, fm.index).trim();
|
|
36
|
+
}
|
|
37
|
+
/* detect the trailing explicit Wiki-style anchor ("{{xxx}}"), the
|
|
38
|
+
primary marker ("(*)"), and/or the implicit parenthesized anchor
|
|
39
|
+
token ("(xxx)"), in any order */
|
|
40
|
+
let paren;
|
|
41
|
+
let primary = false;
|
|
42
|
+
for (;;) {
|
|
43
|
+
const wm = text.match(/\{\{([^{}]+)\}\}\s*$/);
|
|
44
|
+
if (wm !== null && wm.index !== undefined) {
|
|
45
|
+
id ??= wm[1].trim();
|
|
46
|
+
text = text.slice(0, wm.index).trim();
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const sm = text.match(/\(\*\)\s*$/);
|
|
50
|
+
if (sm !== null && sm.index !== undefined) {
|
|
51
|
+
primary = true;
|
|
52
|
+
text = text.slice(0, sm.index).trim();
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const pm = text.match(/\(([^()]+)\)\s*$/);
|
|
56
|
+
if (pm !== null && pm.index !== undefined && paren === undefined) {
|
|
57
|
+
paren = pm[1].trim();
|
|
58
|
+
text = text.slice(0, pm.index).trim();
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
const km = text.match(/^([^:]+):\s*(.*)$/);
|
|
64
|
+
return {
|
|
65
|
+
kind: km !== null ? km[1].trim() : text,
|
|
66
|
+
name: km !== null ? km[2].trim() : "",
|
|
67
|
+
id, paren, primary, malformed
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
/* derive a stable slug identifier from an object name */
|
|
71
|
+
const slugify = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
72
|
+
/* extract the plain text lines of a Markdown list item
|
|
73
|
+
(excluding any nested lists) */
|
|
74
|
+
const itemLines = (item) => {
|
|
75
|
+
const body = item.tokens.find((token) => token.type === "text");
|
|
76
|
+
const text = body !== undefined ? body.text : item.text;
|
|
77
|
+
return text.split("\n").map((line) => line.trim());
|
|
78
|
+
};
|
|
79
|
+
/* parse the optional Markdown frontmatter with Created/Modified timestamps */
|
|
80
|
+
const parseFrontmatter = (text) => {
|
|
81
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
82
|
+
const missing = { date: null, line: 1, value: null };
|
|
83
|
+
if (m === null)
|
|
84
|
+
return { present: false, created: missing, modified: missing, body: text, offset: 0 };
|
|
85
|
+
const grab = (key) => {
|
|
86
|
+
const km = m[1].match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
|
|
87
|
+
if (km === null || km.index === undefined)
|
|
88
|
+
return missing;
|
|
89
|
+
const line = 2 + (m[1].slice(0, km.index).match(/\n/g) ?? []).length;
|
|
90
|
+
const value = km[1].trim();
|
|
91
|
+
const date = new Date(value);
|
|
92
|
+
return { date: isNaN(date.getTime()) ? null : date, line, value };
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
present: true,
|
|
96
|
+
created: grab("Created"),
|
|
97
|
+
modified: grab("Modified"),
|
|
98
|
+
body: text.slice(m[0].length),
|
|
99
|
+
offset: m[0].split("\n").length - 1
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
/* recursively load the image files embedded via "" into the
|
|
103
|
+
description and the property values of an object (SVG as-is, PNG/JPEG
|
|
104
|
+
as base64 data: URLs), resolving the references relative to the source file */
|
|
105
|
+
const embed = (ctx, object, file) => {
|
|
106
|
+
const load = (target, text, line) => {
|
|
107
|
+
for (const m of text.matchAll(embeddingRegex)) {
|
|
108
|
+
const reference = m[2].trim();
|
|
109
|
+
const type = embeddingMimeType(reference);
|
|
110
|
+
if (type === undefined)
|
|
111
|
+
continue;
|
|
112
|
+
try {
|
|
113
|
+
const data = fs.readFileSync(path.resolve(path.dirname(file), reference));
|
|
114
|
+
target.embedding ??= [];
|
|
115
|
+
target.embedding.push(type === "image/svg+xml" ?
|
|
116
|
+
data.toString("utf8") : `data:${type};base64,${data.toString("base64")}`);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
ctx.diagnose(file, line, `unreadable embedding file "${reference}": ` +
|
|
120
|
+
(err instanceof Error ? err.message : String(err)));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const line = ctx.objectMeta.get(object)?.line ?? 1;
|
|
125
|
+
if (object.description !== undefined)
|
|
126
|
+
load(object.description, object.description.description, line);
|
|
127
|
+
for (const property of object.properties)
|
|
128
|
+
load(property, property.value, ctx.propMeta.get(property)?.line ?? line);
|
|
129
|
+
for (const child of object.childs)
|
|
130
|
+
embed(ctx, child, file);
|
|
131
|
+
};
|
|
132
|
+
/* parse an unordered list of key-values and/or concise-format objects */
|
|
133
|
+
const parseList = (ctx, list, object, file, line) => {
|
|
134
|
+
for (const item of list.items) {
|
|
135
|
+
const lines = itemLines(item);
|
|
136
|
+
const first = lines[0];
|
|
137
|
+
/* a concise-format item carries a further segment on its first
|
|
138
|
+
line, or ends it with the segment terminator (the case of an
|
|
139
|
+
object without properties, whose description follows below) */
|
|
140
|
+
const cm = first.match(/^([^:;]+):\s+(.+;.*)$/);
|
|
141
|
+
if (cm !== null)
|
|
142
|
+
parseConcise(ctx, item, object, file, line);
|
|
143
|
+
else {
|
|
144
|
+
const km = first.match(/^([^:;]+):\s*(.*)$/);
|
|
145
|
+
if (km !== null) {
|
|
146
|
+
/* a key/value property, whose value may continue
|
|
147
|
+
on the following lines of the item */
|
|
148
|
+
const value = [km[2], ...lines.slice(1)].join(" ").trim();
|
|
149
|
+
const property = { key: km[1].trim(), value };
|
|
150
|
+
ctx.propMeta.set(property, { line });
|
|
151
|
+
object.properties.push(property);
|
|
152
|
+
}
|
|
153
|
+
else
|
|
154
|
+
ctx.diagnose(file, line, `unrecognized list item "${first}"`);
|
|
155
|
+
}
|
|
156
|
+
line += (item.raw.match(/\n/g) ?? []).length;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
/* parse a grouped list of concise-format object items of the form
|
|
160
|
+
"`<name>`; <key>: <value>; ...; <statement>, BECAUSE <rationale>.",
|
|
161
|
+
whose kind is given by the enclosing grouping container heading */
|
|
162
|
+
const parseGrouped = (ctx, list, group, file, line) => {
|
|
163
|
+
for (const item of list.items) {
|
|
164
|
+
parseConcise(ctx, item, group.parent, file, line, group.kind);
|
|
165
|
+
line += (item.raw.match(/\n/g) ?? []).length;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
/* parse a concise-format object item of the form
|
|
169
|
+
"<kind>: <name>; <key>: <value>; ...; <statement>, BECAUSE <rationale>.",
|
|
170
|
+
which may continue on the following lines of the item; a grouped item
|
|
171
|
+
omits the leading "<kind>: " and receives its kind from the group */
|
|
172
|
+
const parseConcise = (ctx, item, parent, file, line, group) => {
|
|
173
|
+
const text = itemLines(item).join(" ").trim();
|
|
174
|
+
const segments = text.split(/;\s*/).filter((segment) => segment !== "");
|
|
175
|
+
const head = segments.shift() ?? "";
|
|
176
|
+
let kind;
|
|
177
|
+
let name;
|
|
178
|
+
if (group !== undefined) {
|
|
179
|
+
const nm = head.match(/^`([^`]+)`$|^([^`:]+)$/);
|
|
180
|
+
if (nm === null) {
|
|
181
|
+
ctx.diagnose(file, line, `unrecognized ${group} item "${text}"`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
kind = group;
|
|
185
|
+
name = (nm[1] ?? nm[2]).trim();
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const km = head.match(/^([^:]+):\s*(.*)$/);
|
|
189
|
+
if (km === null) {
|
|
190
|
+
ctx.diagnose(file, line, `invalid concise object item "${text}"`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
kind = km[1].trim();
|
|
194
|
+
name = km[2].trim();
|
|
195
|
+
}
|
|
196
|
+
let anchor;
|
|
197
|
+
let paren;
|
|
198
|
+
let primary = false;
|
|
199
|
+
for (;;) {
|
|
200
|
+
const am = name.match(/\{\{([^{}]+)\}\}\s*$/);
|
|
201
|
+
if (am !== null && am.index !== undefined) {
|
|
202
|
+
anchor ??= am[1].trim();
|
|
203
|
+
name = name.slice(0, am.index).trim();
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const sm = name.match(/\(\*\)\s*$/);
|
|
207
|
+
if (sm !== null && sm.index !== undefined) {
|
|
208
|
+
primary = true;
|
|
209
|
+
name = name.slice(0, sm.index).trim();
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const pm = name.match(/\(([^()]+)\)\s*$/);
|
|
213
|
+
if (pm !== null && pm.index !== undefined && paren === undefined) {
|
|
214
|
+
paren = pm[1].trim();
|
|
215
|
+
name = name.slice(0, pm.index).trim();
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
const object = {
|
|
221
|
+
kind,
|
|
222
|
+
id: anchor ?? slugify(name),
|
|
223
|
+
name,
|
|
224
|
+
properties: [],
|
|
225
|
+
childs: []
|
|
226
|
+
};
|
|
227
|
+
if (anchor !== undefined)
|
|
228
|
+
object.anchor = anchor;
|
|
229
|
+
if (paren !== undefined)
|
|
230
|
+
object.paren = paren;
|
|
231
|
+
if (primary)
|
|
232
|
+
object.primary = true;
|
|
233
|
+
ctx.objectMeta.set(object, { file, line });
|
|
234
|
+
const statements = new Array();
|
|
235
|
+
for (const segment of segments) {
|
|
236
|
+
const km = segment.match(/^([^:;]+):\s+(.+)$/);
|
|
237
|
+
if (km !== null && !(/\sBECAUSE\s/.test(segment))) {
|
|
238
|
+
const property = { key: km[1].trim(), value: km[2].trim() };
|
|
239
|
+
ctx.propMeta.set(property, { line });
|
|
240
|
+
object.properties.push(property);
|
|
241
|
+
}
|
|
242
|
+
else
|
|
243
|
+
statements.push(segment);
|
|
244
|
+
}
|
|
245
|
+
if (statements.length > 0)
|
|
246
|
+
object.description = splitDescription(statements.join("; ").replace(/\.\s*$/, ""));
|
|
247
|
+
/* recurse into nested concise-format child objects */
|
|
248
|
+
for (const sub of item.tokens)
|
|
249
|
+
if (sub.type === "list" && !sub.ordered)
|
|
250
|
+
parseList(ctx, sub, object, file, line);
|
|
251
|
+
parent.childs.push(object);
|
|
252
|
+
};
|
|
253
|
+
/* parse a single source file into its artifacts */
|
|
254
|
+
export const parseFile = (ctx, source) => {
|
|
255
|
+
const { present, created, modified, body, offset } = parseFrontmatter(source.text);
|
|
256
|
+
if (!present)
|
|
257
|
+
ctx.diagnose(source.file, 1, "missing frontmatter (\"Created:\"/\"Modified:\" block)");
|
|
258
|
+
else
|
|
259
|
+
for (const [key, info] of [["Created", created], ["Modified", modified]])
|
|
260
|
+
if (info.date === null)
|
|
261
|
+
ctx.diagnose(source.file, info.line, info.value === null ?
|
|
262
|
+
`missing "${key}:" frontmatter key` :
|
|
263
|
+
`invalid "${key}:" frontmatter timestamp "${info.value}"`);
|
|
264
|
+
const artifacts = new Array();
|
|
265
|
+
const stack = new Array();
|
|
266
|
+
let current = null;
|
|
267
|
+
let group = null;
|
|
268
|
+
let parts = new Array();
|
|
269
|
+
/* flush the accumulated description parts into the current object */
|
|
270
|
+
const flush = () => {
|
|
271
|
+
if (current !== null && parts.length > 0 && current.description === undefined)
|
|
272
|
+
current.description = splitDescription(parts.join("\n\n").trim());
|
|
273
|
+
parts = [];
|
|
274
|
+
};
|
|
275
|
+
/* walk the top-level Markdown tokens, tracking source lines */
|
|
276
|
+
let line = offset + 1;
|
|
277
|
+
for (const token of marked.lexer(body)) {
|
|
278
|
+
if (token.type === "heading") {
|
|
279
|
+
flush();
|
|
280
|
+
group = null;
|
|
281
|
+
const heading = parseHeadingText(token.text);
|
|
282
|
+
if (heading.malformed !== undefined)
|
|
283
|
+
ctx.diagnose(source.file, line, `malformed anchor "${heading.malformed}" in heading`);
|
|
284
|
+
if (heading.name === "" && token.depth > 1) {
|
|
285
|
+
/* a grouping container heading (e.g. "### STATES") collects
|
|
286
|
+
objects of its singular kind below the parent object */
|
|
287
|
+
const parent = stack[token.depth - 2];
|
|
288
|
+
if (parent === undefined)
|
|
289
|
+
ctx.diagnose(source.file, line, `heading level ${token.depth} without parent object`);
|
|
290
|
+
else {
|
|
291
|
+
group = { parent, kind: heading.kind.replace(/S$/, "") };
|
|
292
|
+
current = parent;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
const object = {
|
|
297
|
+
kind: heading.kind,
|
|
298
|
+
id: heading.id ?? (token.depth === 1 ? heading.paren : undefined) ?? slugify(heading.name),
|
|
299
|
+
name: heading.name,
|
|
300
|
+
properties: [],
|
|
301
|
+
childs: []
|
|
302
|
+
};
|
|
303
|
+
if (heading.id !== undefined)
|
|
304
|
+
object.anchor = heading.id;
|
|
305
|
+
if (heading.paren !== undefined)
|
|
306
|
+
object.paren = heading.paren;
|
|
307
|
+
if (heading.primary)
|
|
308
|
+
object.primary = true;
|
|
309
|
+
ctx.objectMeta.set(object, { file: source.file, line });
|
|
310
|
+
if (token.depth === 1) {
|
|
311
|
+
artifacts.push({
|
|
312
|
+
created: created.date ?? new Date(),
|
|
313
|
+
modified: modified.date ?? new Date(),
|
|
314
|
+
objects: [object]
|
|
315
|
+
});
|
|
316
|
+
stack.length = 0;
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const parent = stack[token.depth - 2];
|
|
320
|
+
if (parent === undefined) {
|
|
321
|
+
ctx.diagnose(source.file, line, `heading level ${token.depth} without parent object`);
|
|
322
|
+
line += (token.raw.match(/\n/g) ?? []).length;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
parent.childs.push(object);
|
|
326
|
+
}
|
|
327
|
+
stack.length = token.depth - 1;
|
|
328
|
+
stack[token.depth - 1] = object;
|
|
329
|
+
current = object;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
else if (token.type === "list") {
|
|
333
|
+
const list = token;
|
|
334
|
+
if (current === null)
|
|
335
|
+
ctx.diagnose(source.file, line, "content outside of any object");
|
|
336
|
+
else if (!list.ordered && group !== null)
|
|
337
|
+
parseGrouped(ctx, list, group, source.file, line);
|
|
338
|
+
else if (!list.ordered)
|
|
339
|
+
parseList(ctx, list, current, source.file, line);
|
|
340
|
+
else
|
|
341
|
+
parts.push(token.raw.trim());
|
|
342
|
+
}
|
|
343
|
+
else if (token.type === "paragraph" || token.type === "blockquote"
|
|
344
|
+
|| (token.type === "code" && token.lang !== "gradia")) {
|
|
345
|
+
/* notice: a "gradia" code block is skipped entirely, as it is
|
|
346
|
+
the derived diagram of the Markdown renderer and hence must
|
|
347
|
+
not become authored content on a re-parse of exported files */
|
|
348
|
+
if (current === null)
|
|
349
|
+
ctx.diagnose(source.file, line, "content outside of any object");
|
|
350
|
+
else
|
|
351
|
+
parts.push(token.raw.trim());
|
|
352
|
+
}
|
|
353
|
+
line += (token.raw.match(/\n/g) ?? []).length;
|
|
354
|
+
}
|
|
355
|
+
flush();
|
|
356
|
+
/* load the embedded image files of all fully parsed objects */
|
|
357
|
+
for (const artifact of artifacts)
|
|
358
|
+
for (const object of artifact.objects)
|
|
359
|
+
embed(ctx, object, source.file);
|
|
360
|
+
if (artifacts.length === 0)
|
|
361
|
+
ctx.diagnose(source.file, 1, "no artifact (level 1 heading) found");
|
|
362
|
+
return artifacts;
|
|
363
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ValueExpr = {
|
|
2
|
+
kind: "regex";
|
|
3
|
+
regex: RegExp;
|
|
4
|
+
source: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: "reference";
|
|
7
|
+
pattern: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: "enum";
|
|
10
|
+
members: string[];
|
|
11
|
+
} | {
|
|
12
|
+
kind: "tags";
|
|
13
|
+
members: string[];
|
|
14
|
+
} | {
|
|
15
|
+
kind: "list";
|
|
16
|
+
alternatives: ValueExpr[];
|
|
17
|
+
};
|
|
18
|
+
export declare const compileValueExpr: (source: string) => ValueExpr;
|
|
19
|
+
export declare const splitItems: (text: string) => string[];
|