@rse/specbook 0.9.5 → 0.9.7
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 +108 -328
- package/dst/specbook-api.d.ts +13 -20
- package/dst/specbook-api.js +43 -39
- package/dst/specbook-cli.js +42 -55
- package/dst/specbook-cmd-describe.d.ts +5 -2
- package/dst/specbook-cmd-describe.js +30 -91
- package/dst/specbook-cmd-export.d.ts +3 -3
- package/dst/specbook-cmd-export.js +23 -19
- package/dst/specbook-cmd-init.d.ts +3 -3
- package/dst/specbook-cmd-init.js +38 -15
- package/dst/specbook-cmd-lint.d.ts +5 -6
- package/dst/specbook-cmd-lint.js +61 -24
- package/dst/specbook-config.d.ts +2 -2
- package/dst/specbook-config.js +82 -73
- package/dst/specbook-diagnostic.js +4 -1
- package/dst/specbook-diagram.d.ts +6 -5
- package/dst/specbook-diagram.js +94 -104
- package/dst/specbook-export-ast.d.ts +3 -3
- package/dst/specbook-export-ast.js +1 -1
- package/dst/specbook-export-common.d.ts +11 -9
- package/dst/specbook-export-common.js +34 -22
- package/dst/specbook-export-html-search.js +10 -14
- package/dst/specbook-export-html.css +37 -32
- package/dst/specbook-export-html.d.ts +4 -4
- package/dst/specbook-export-html.js +153 -107
- package/dst/specbook-export-logo-dark.svg +35 -0
- package/dst/specbook-export-logo-light.svg +35 -0
- package/dst/specbook-export-md.d.ts +3 -3
- package/dst/specbook-export-md.js +50 -24
- package/dst/specbook-export-pdf.d.ts +4 -2
- package/dst/specbook-export-pdf.js +48 -38
- package/dst/{specbook-struct-schema.d.ts → specbook-format-schema.d.ts} +3 -2
- package/dst/{specbook-struct-schema.js → specbook-format-schema.js} +8 -4
- package/dst/specbook-format-spec.d.ts +31 -0
- package/dst/{specbook-struct-spec.js → specbook-format-spec.js} +10 -10
- package/dst/specbook-format.md +750 -0
- package/dst/specbook-link.d.ts +3 -2
- package/dst/specbook-link.js +7 -4
- package/dst/specbook-mcp.d.ts +2 -1
- package/dst/specbook-mcp.js +30 -62
- package/dst/specbook-parse-common.d.ts +5 -3
- package/dst/specbook-parse-common.js +8 -0
- package/dst/specbook-parse-semantic.d.ts +6 -4
- package/dst/specbook-parse-semantic.js +98 -106
- package/dst/specbook-parse-syntax.d.ts +2 -2
- package/dst/specbook-parse-syntax.js +175 -144
- package/dst/specbook-parse-value.d.ts +1 -1
- package/dst/specbook-parse-value.js +6 -3
- package/dst/specbook-parse.d.ts +4 -7
- package/dst/specbook-parse.js +28 -35
- package/dst/specbook-verbose.d.ts +2 -0
- package/dst/specbook-verbose.js +17 -0
- package/package.json +3 -7
- package/dst/specbook-cmd-edit.d.ts +0 -9
- package/dst/specbook-cmd-edit.js +0 -74
- package/dst/specbook-cmd-import.d.ts +0 -9
- package/dst/specbook-cmd-import.js +0 -78
- package/dst/specbook-export-logo.svg +0 -35
- package/dst/specbook-llm.d.ts +0 -13
- package/dst/specbook-llm.js +0 -51
- package/dst/specbook-struct-spec.d.ts +0 -31
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { marked } from "marked";
|
|
9
|
-
import { embeddingRegex, embeddingMimeType } from "./specbook-parse-common.js";
|
|
9
|
+
import { embeddingRegex, embeddingMimeType, embeddingVariants } from "./specbook-parse-common.js";
|
|
10
|
+
/* the marker separating a statement from its rationale */
|
|
11
|
+
const becauseRegex = /,\s*(?:\*\*BECAUSE\*\*|BECAUSE)\s+/;
|
|
10
12
|
/* split a description text into statement and optional rationale */
|
|
11
13
|
const splitDescription = (text) => {
|
|
12
|
-
const m = text.match(
|
|
14
|
+
const m = text.match(becauseRegex);
|
|
13
15
|
if (m === null || m.index === undefined)
|
|
14
16
|
return { description: text };
|
|
15
17
|
return {
|
|
@@ -17,33 +19,19 @@ const splitDescription = (text) => {
|
|
|
17
19
|
rationale: text.slice(m.index + m[0].length)
|
|
18
20
|
};
|
|
19
21
|
};
|
|
20
|
-
/*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 */
|
|
22
|
+
/* strip the trailing explicit Wiki-style anchor ("{{xxx}}"), the
|
|
23
|
+
primary marker ("(*)"), and/or the implicit parenthesized anchor
|
|
24
|
+
token ("(xxx)") off a name, in any order */
|
|
25
|
+
const parseMarkers = (raw) => {
|
|
26
|
+
let text = raw;
|
|
27
|
+
let anchor;
|
|
40
28
|
let paren;
|
|
41
29
|
let primary = false;
|
|
42
30
|
for (;;) {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
text = text.slice(0,
|
|
31
|
+
const am = text.match(/\{\{([^{}]+)\}\}\s*$/);
|
|
32
|
+
if (am !== null && am.index !== undefined) {
|
|
33
|
+
anchor ??= am[1].trim();
|
|
34
|
+
text = text.slice(0, am.index).trim();
|
|
47
35
|
continue;
|
|
48
36
|
}
|
|
49
37
|
const sm = text.match(/\(\*\)\s*$/);
|
|
@@ -60,20 +48,49 @@ const parseHeadingText = (raw) => {
|
|
|
60
48
|
}
|
|
61
49
|
break;
|
|
62
50
|
}
|
|
63
|
-
|
|
51
|
+
return { text, anchor, paren, primary };
|
|
52
|
+
};
|
|
53
|
+
/* parse a heading text of the form "<KIND>: <name> [(<token>)] [<a id="..."></a>]" */
|
|
54
|
+
const parseHeadingText = (raw) => {
|
|
55
|
+
let text = raw.trim();
|
|
56
|
+
let id;
|
|
57
|
+
const am = text.match(/<a\s+id="([^"]+)"\s*>\s*<\/a>\s*$/);
|
|
58
|
+
if (am !== null && am.index !== undefined) {
|
|
59
|
+
id = am[1];
|
|
60
|
+
text = text.slice(0, am.index).trim();
|
|
61
|
+
}
|
|
62
|
+
/* detect a malformed trailing anchor fragment, still salvaging its id */
|
|
63
|
+
let malformed;
|
|
64
|
+
const fm = text.match(/<a\b[^>]*>?\s*(?:<\/a>)?\s*$/);
|
|
65
|
+
if (fm !== null && fm.index !== undefined) {
|
|
66
|
+
malformed = fm[0].trim();
|
|
67
|
+
id ??= malformed.match(/id="([^"]+)"/)?.[1];
|
|
68
|
+
text = text.slice(0, fm.index).trim();
|
|
69
|
+
}
|
|
70
|
+
/* detect the trailing markers (an HTML anchor id takes
|
|
71
|
+
precedence over the explicit Wiki-style anchor) */
|
|
72
|
+
const markers = parseMarkers(text);
|
|
73
|
+
const km = markers.text.match(/^([^:]+):\s*(.*)$/);
|
|
64
74
|
return {
|
|
65
|
-
kind: km !== null ? km[1].trim() : text,
|
|
75
|
+
kind: km !== null ? km[1].trim() : markers.text,
|
|
66
76
|
name: km !== null ? km[2].trim() : "",
|
|
67
|
-
id
|
|
77
|
+
id: id ?? markers.anchor,
|
|
78
|
+
paren: markers.paren,
|
|
79
|
+
primary: markers.primary,
|
|
80
|
+
malformed
|
|
68
81
|
};
|
|
69
82
|
};
|
|
70
83
|
/* derive a stable slug identifier from an object name */
|
|
71
84
|
const slugify = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
72
|
-
/* extract the plain text lines of a Markdown list item
|
|
73
|
-
|
|
85
|
+
/* extract the plain text lines of a Markdown list item (excluding any
|
|
86
|
+
nested lists), whose body is a "text" token in a tight list, but a
|
|
87
|
+
"paragraph" token in a loose list (and never "item.text", as this
|
|
88
|
+
still contains the source of the nested lists) */
|
|
74
89
|
const itemLines = (item) => {
|
|
75
|
-
const
|
|
76
|
-
|
|
90
|
+
const text = item.tokens
|
|
91
|
+
.filter((token) => token.type === "text" || token.type === "paragraph")
|
|
92
|
+
.map((token) => token.text)
|
|
93
|
+
.join("\n");
|
|
77
94
|
return text.split("\n").map((line) => line.trim());
|
|
78
95
|
};
|
|
79
96
|
/* parse the optional Markdown frontmatter with Created/Modified timestamps */
|
|
@@ -96,12 +113,15 @@ const parseFrontmatter = (text) => {
|
|
|
96
113
|
created: grab("Created"),
|
|
97
114
|
modified: grab("Modified"),
|
|
98
115
|
body: text.slice(m[0].length),
|
|
99
|
-
offset: m[0].
|
|
116
|
+
offset: (m[0].match(/\n/g) ?? []).length
|
|
100
117
|
};
|
|
101
118
|
};
|
|
102
119
|
/* recursively load the image files embedded via "" into the
|
|
103
120
|
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
|
|
121
|
+
as base64 data: URLs), resolving the references relative to the source
|
|
122
|
+
file and expanding a "{theme}" reference into its theme variants,
|
|
123
|
+
which are loaded into consecutive embedding entries (an unreadable
|
|
124
|
+
file leaves an empty entry, keeping the positions aligned) */
|
|
105
125
|
const embed = (ctx, object, file) => {
|
|
106
126
|
const load = (target, text, line) => {
|
|
107
127
|
for (const m of text.matchAll(embeddingRegex)) {
|
|
@@ -109,15 +129,18 @@ const embed = (ctx, object, file) => {
|
|
|
109
129
|
const type = embeddingMimeType(reference);
|
|
110
130
|
if (type === undefined)
|
|
111
131
|
continue;
|
|
112
|
-
|
|
113
|
-
const data = fs.readFileSync(path.resolve(path.dirname(file), reference));
|
|
132
|
+
for (const variant of embeddingVariants(reference)) {
|
|
114
133
|
target.embedding ??= [];
|
|
115
|
-
|
|
116
|
-
data
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
134
|
+
try {
|
|
135
|
+
const data = fs.readFileSync(path.resolve(path.dirname(file), variant));
|
|
136
|
+
target.embedding.push(type === "image/svg+xml" ?
|
|
137
|
+
data.toString("utf8") : `data:${type};base64,${data.toString("base64")}`);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
target.embedding.push("");
|
|
141
|
+
ctx.diagnose(file, line, `unreadable embedding file "${variant}": ` +
|
|
142
|
+
(err instanceof Error ? err.message : String(err)));
|
|
143
|
+
}
|
|
121
144
|
}
|
|
122
145
|
}
|
|
123
146
|
};
|
|
@@ -148,6 +171,8 @@ const parseList = (ctx, list, object, file, line) => {
|
|
|
148
171
|
const property = { key: km[1].trim(), value };
|
|
149
172
|
ctx.propMeta.set(property, { line });
|
|
150
173
|
object.properties.push(property);
|
|
174
|
+
if (item.tokens.some((sub) => sub.type === "list"))
|
|
175
|
+
ctx.diagnose(file, line, `nested list below property "${property.key}" ignored`);
|
|
151
176
|
}
|
|
152
177
|
else
|
|
153
178
|
ctx.diagnose(file, line, `unrecognized list item "${first}"`);
|
|
@@ -170,7 +195,7 @@ const parseGrouped = (ctx, list, group, file, line) => {
|
|
|
170
195
|
omits the leading "<kind>: " and receives its kind from the group */
|
|
171
196
|
const parseConcise = (ctx, item, parent, file, line, group) => {
|
|
172
197
|
const text = itemLines(item).join(" ").trim();
|
|
173
|
-
const segments = text.split(
|
|
198
|
+
const segments = text.split(/\s*;\s*/).filter((segment) => segment !== "");
|
|
174
199
|
const head = segments.shift() ?? "";
|
|
175
200
|
let kind;
|
|
176
201
|
let name;
|
|
@@ -192,30 +217,10 @@ const parseConcise = (ctx, item, parent, file, line, group) => {
|
|
|
192
217
|
kind = km[1].trim();
|
|
193
218
|
name = km[2].trim();
|
|
194
219
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const am = name.match(/\{\{([^{}]+)\}\}\s*$/);
|
|
200
|
-
if (am !== null && am.index !== undefined) {
|
|
201
|
-
anchor ??= am[1].trim();
|
|
202
|
-
name = name.slice(0, am.index).trim();
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
const sm = name.match(/\(\*\)\s*$/);
|
|
206
|
-
if (sm !== null && sm.index !== undefined) {
|
|
207
|
-
primary = true;
|
|
208
|
-
name = name.slice(0, sm.index).trim();
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
const pm = name.match(/\(([^()]+)\)\s*$/);
|
|
212
|
-
if (pm !== null && pm.index !== undefined && paren === undefined) {
|
|
213
|
-
paren = pm[1].trim();
|
|
214
|
-
name = name.slice(0, pm.index).trim();
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
break;
|
|
218
|
-
}
|
|
220
|
+
/* detect the trailing markers of the name */
|
|
221
|
+
const { text: plain, anchor, paren, primary } = parseMarkers(name);
|
|
222
|
+
name = plain;
|
|
223
|
+
/* create the object with its markers, registering its source location */
|
|
219
224
|
const object = {
|
|
220
225
|
kind,
|
|
221
226
|
id: anchor ?? slugify(name),
|
|
@@ -230,11 +235,16 @@ const parseConcise = (ctx, item, parent, file, line, group) => {
|
|
|
230
235
|
if (primary)
|
|
231
236
|
object.primary = true;
|
|
232
237
|
ctx.objectMeta.set(object, { file, line });
|
|
238
|
+
/* split the remaining segments into properties (with a possibly
|
|
239
|
+
empty value) and statements, where the last segment of a "."-
|
|
240
|
+
terminated item is always a statement, as only this lets a
|
|
241
|
+
"<key>: <value>"-shaped description round-trip on export */
|
|
233
242
|
const statements = new Array();
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
243
|
+
const last = text.endsWith(".") ? segments.length - 1 : -1;
|
|
244
|
+
for (const [i, segment] of segments.entries()) {
|
|
245
|
+
const km = segment.match(/^([^:;]+):(?:\s+(.+))?$/);
|
|
246
|
+
if (km !== null && !becauseRegex.test(segment) && i !== last) {
|
|
247
|
+
const property = { key: km[1].trim(), value: (km[2] ?? "").trim() };
|
|
238
248
|
ctx.propMeta.set(property, { line });
|
|
239
249
|
object.properties.push(property);
|
|
240
250
|
}
|
|
@@ -243,12 +253,67 @@ const parseConcise = (ctx, item, parent, file, line, group) => {
|
|
|
243
253
|
}
|
|
244
254
|
if (statements.length > 0)
|
|
245
255
|
object.description = splitDescription(statements.join("; ").replace(/\.\s*$/, ""));
|
|
246
|
-
/* recurse into nested concise-format child objects
|
|
247
|
-
|
|
256
|
+
/* recurse into nested concise-format child objects, tracking the
|
|
257
|
+
source line of the nested list within the item (an ordered
|
|
258
|
+
nested list is not supported, so it is reported, not lost) */
|
|
259
|
+
let subLine = line;
|
|
260
|
+
for (const sub of item.tokens) {
|
|
248
261
|
if (sub.type === "list" && !sub.ordered)
|
|
249
|
-
parseList(ctx, sub, object, file,
|
|
262
|
+
parseList(ctx, sub, object, file, subLine);
|
|
263
|
+
else if (sub.type === "list")
|
|
264
|
+
ctx.diagnose(file, subLine, `nested ordered list below ${kind} "${name}" ignored`);
|
|
265
|
+
subLine += (sub.raw.match(/\n/g) ?? []).length;
|
|
266
|
+
}
|
|
250
267
|
parent.childs.push(object);
|
|
251
268
|
};
|
|
269
|
+
/* parse a heading token into either a grouping container (e.g. "### STATE",
|
|
270
|
+
collecting objects of its kind below the parent object) or a new object
|
|
271
|
+
(an artifact on level 1, a child of the enclosing object below) */
|
|
272
|
+
const parseHeading = (ctx, token, state, stamps, file, line) => {
|
|
273
|
+
const { depth, text } = token;
|
|
274
|
+
const heading = parseHeadingText(text);
|
|
275
|
+
state.group = null;
|
|
276
|
+
if (heading.malformed !== undefined)
|
|
277
|
+
ctx.diagnose(file, line, `malformed anchor "${heading.malformed}" in heading`);
|
|
278
|
+
if (heading.name === "" && depth > 1) {
|
|
279
|
+
const parent = state.stack[depth - 2];
|
|
280
|
+
if (parent === undefined)
|
|
281
|
+
ctx.diagnose(file, line, `heading level ${depth} without parent object`);
|
|
282
|
+
else {
|
|
283
|
+
state.group = { parent, kind: heading.kind };
|
|
284
|
+
state.current = parent;
|
|
285
|
+
state.stack.length = depth - 1;
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const object = {
|
|
290
|
+
kind: heading.kind,
|
|
291
|
+
id: heading.id ?? (depth === 1 ? heading.paren : undefined) ?? slugify(heading.name),
|
|
292
|
+
name: heading.name,
|
|
293
|
+
properties: [],
|
|
294
|
+
childs: []
|
|
295
|
+
};
|
|
296
|
+
if (heading.id !== undefined)
|
|
297
|
+
object.anchor = heading.id;
|
|
298
|
+
if (heading.paren !== undefined)
|
|
299
|
+
object.paren = heading.paren;
|
|
300
|
+
if (heading.primary)
|
|
301
|
+
object.primary = true;
|
|
302
|
+
ctx.objectMeta.set(object, { file, line });
|
|
303
|
+
if (depth === 1)
|
|
304
|
+
state.artifacts.push({ ...stamps, objects: [object] });
|
|
305
|
+
else {
|
|
306
|
+
const parent = state.stack[depth - 2];
|
|
307
|
+
if (parent === undefined) {
|
|
308
|
+
ctx.diagnose(file, line, `heading level ${depth} without parent object`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
parent.childs.push(object);
|
|
312
|
+
}
|
|
313
|
+
state.stack.length = depth - 1;
|
|
314
|
+
state.stack[depth - 1] = object;
|
|
315
|
+
state.current = object;
|
|
316
|
+
};
|
|
252
317
|
/* parse a single source file into its artifacts */
|
|
253
318
|
export const parseFile = (ctx, source) => {
|
|
254
319
|
const { present, created, modified, body, offset } = parseFrontmatter(source.text);
|
|
@@ -261,15 +326,30 @@ export const parseFile = (ctx, source) => {
|
|
|
261
326
|
`missing "${key}:" frontmatter key` :
|
|
262
327
|
`invalid "${key}:" frontmatter timestamp "${info.value}"`);
|
|
263
328
|
/* the state tracked while walking the Markdown tokens */
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
329
|
+
const state = { artifacts: [], stack: [], current: null, group: null };
|
|
330
|
+
const stamps = {
|
|
331
|
+
created: created.date ?? new Date(),
|
|
332
|
+
modified: modified.date ?? new Date()
|
|
333
|
+
};
|
|
268
334
|
let parts = new Array();
|
|
269
|
-
|
|
335
|
+
let partsLine = 1;
|
|
336
|
+
/* collect a description part, remembering the line of the first one */
|
|
337
|
+
const collect = (raw, line) => {
|
|
338
|
+
if (parts.length === 0)
|
|
339
|
+
partsLine = line;
|
|
340
|
+
parts.push(raw.trim());
|
|
341
|
+
};
|
|
342
|
+
/* flush the accumulated description parts into the current object
|
|
343
|
+
(a grouping container re-targets the parent, whose description
|
|
344
|
+
is already flushed, so further content is reported, not lost) */
|
|
270
345
|
const flush = () => {
|
|
271
|
-
|
|
272
|
-
|
|
346
|
+
const current = state.current;
|
|
347
|
+
if (current !== null && parts.length > 0) {
|
|
348
|
+
if (current.description === undefined)
|
|
349
|
+
current.description = splitDescription(parts.join("\n\n").trim());
|
|
350
|
+
else
|
|
351
|
+
ctx.diagnose(source.file, partsLine, `content ignored, as ${current.kind} "${current.name}" already carries a description`);
|
|
352
|
+
}
|
|
273
353
|
parts = [];
|
|
274
354
|
};
|
|
275
355
|
/* walk the top-level Markdown tokens, tracking source lines */
|
|
@@ -277,88 +357,39 @@ export const parseFile = (ctx, source) => {
|
|
|
277
357
|
for (const token of marked.lexer(body)) {
|
|
278
358
|
if (token.type === "heading") {
|
|
279
359
|
flush();
|
|
280
|
-
|
|
281
|
-
const { depth, text } = token;
|
|
282
|
-
const heading = parseHeadingText(text);
|
|
283
|
-
if (heading.malformed !== undefined)
|
|
284
|
-
ctx.diagnose(source.file, line, `malformed anchor "${heading.malformed}" in heading`);
|
|
285
|
-
if (heading.name === "" && depth > 1) {
|
|
286
|
-
/* a grouping container heading (e.g. "### STATES") collects
|
|
287
|
-
objects of its singular kind below the parent object */
|
|
288
|
-
const parent = stack[depth - 2];
|
|
289
|
-
if (parent === undefined)
|
|
290
|
-
ctx.diagnose(source.file, line, `heading level ${depth} without parent object`);
|
|
291
|
-
else {
|
|
292
|
-
group = { parent, kind: heading.kind.replace(/S$/, "") };
|
|
293
|
-
current = parent;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
else {
|
|
297
|
-
const object = {
|
|
298
|
-
kind: heading.kind,
|
|
299
|
-
id: heading.id ?? (depth === 1 ? heading.paren : undefined) ?? slugify(heading.name),
|
|
300
|
-
name: heading.name,
|
|
301
|
-
properties: [],
|
|
302
|
-
childs: []
|
|
303
|
-
};
|
|
304
|
-
if (heading.id !== undefined)
|
|
305
|
-
object.anchor = heading.id;
|
|
306
|
-
if (heading.paren !== undefined)
|
|
307
|
-
object.paren = heading.paren;
|
|
308
|
-
if (heading.primary)
|
|
309
|
-
object.primary = true;
|
|
310
|
-
ctx.objectMeta.set(object, { file: source.file, line });
|
|
311
|
-
if (depth === 1) {
|
|
312
|
-
artifacts.push({
|
|
313
|
-
created: created.date ?? new Date(),
|
|
314
|
-
modified: modified.date ?? new Date(),
|
|
315
|
-
objects: [object]
|
|
316
|
-
});
|
|
317
|
-
stack.length = 0;
|
|
318
|
-
}
|
|
319
|
-
else {
|
|
320
|
-
const parent = stack[depth - 2];
|
|
321
|
-
if (parent === undefined) {
|
|
322
|
-
ctx.diagnose(source.file, line, `heading level ${depth} without parent object`);
|
|
323
|
-
line += (token.raw.match(/\n/g) ?? []).length;
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
parent.childs.push(object);
|
|
327
|
-
}
|
|
328
|
-
stack.length = depth - 1;
|
|
329
|
-
stack[depth - 1] = object;
|
|
330
|
-
current = object;
|
|
331
|
-
}
|
|
360
|
+
parseHeading(ctx, token, state, stamps, source.file, line);
|
|
332
361
|
}
|
|
333
362
|
else if (token.type === "list") {
|
|
334
363
|
const list = token;
|
|
335
|
-
if (current === null)
|
|
364
|
+
if (state.current === null)
|
|
336
365
|
ctx.diagnose(source.file, line, "content outside of any object");
|
|
337
|
-
else if (!list.ordered && group !== null)
|
|
338
|
-
parseGrouped(ctx, list, group, source.file, line);
|
|
366
|
+
else if (!list.ordered && state.group !== null)
|
|
367
|
+
parseGrouped(ctx, list, state.group, source.file, line);
|
|
339
368
|
else if (!list.ordered)
|
|
340
|
-
parseList(ctx, list, current, source.file, line);
|
|
369
|
+
parseList(ctx, list, state.current, source.file, line);
|
|
341
370
|
else
|
|
342
|
-
|
|
371
|
+
collect(token.raw, line);
|
|
343
372
|
}
|
|
344
373
|
else if (token.type === "paragraph" || token.type === "blockquote"
|
|
345
374
|
|| (token.type === "code" && token.lang !== "gradia")) {
|
|
346
375
|
/* notice: a "gradia" code block is skipped entirely, as it is
|
|
347
376
|
the derived diagram of the Markdown renderer and hence must
|
|
348
377
|
not become authored content on a re-parse of exported files */
|
|
349
|
-
if (current === null)
|
|
378
|
+
if (state.current === null)
|
|
350
379
|
ctx.diagnose(source.file, line, "content outside of any object");
|
|
351
380
|
else
|
|
352
|
-
|
|
381
|
+
collect(token.raw, line);
|
|
353
382
|
}
|
|
383
|
+
else if (token.type === "table")
|
|
384
|
+
ctx.diagnose(source.file, line, "unsupported table content ignored");
|
|
354
385
|
line += (token.raw.match(/\n/g) ?? []).length;
|
|
355
386
|
}
|
|
356
387
|
flush();
|
|
357
388
|
/* load the embedded image files of all fully parsed objects */
|
|
358
|
-
for (const artifact of artifacts)
|
|
389
|
+
for (const artifact of state.artifacts)
|
|
359
390
|
for (const object of artifact.objects)
|
|
360
391
|
embed(ctx, object, source.file);
|
|
361
|
-
if (artifacts.length === 0)
|
|
392
|
+
if (state.artifacts.length === 0)
|
|
362
393
|
ctx.diagnose(source.file, 1, "no artifact (level 1 heading) found");
|
|
363
|
-
return artifacts;
|
|
394
|
+
return state.artifacts;
|
|
364
395
|
};
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export type ValueExpr = {
|
|
2
2
|
kind: "regex";
|
|
3
3
|
regex: RegExp;
|
|
4
|
-
source: string;
|
|
5
4
|
} | {
|
|
6
5
|
kind: "reference";
|
|
7
6
|
pattern: string;
|
|
@@ -15,5 +14,6 @@ export type ValueExpr = {
|
|
|
15
14
|
kind: "list";
|
|
16
15
|
alternatives: ValueExpr[];
|
|
17
16
|
};
|
|
17
|
+
export declare const anchored: (pattern: string) => RegExp;
|
|
18
18
|
export declare const compileValueExpr: (source: string) => ValueExpr;
|
|
19
19
|
export declare const splitItems: (text: string) => string[];
|
|
@@ -33,13 +33,16 @@ lexer.rule(/\s+/, (ctx) => {
|
|
|
33
33
|
});
|
|
34
34
|
/* describe a token for error messages */
|
|
35
35
|
const tokenName = (token) => token.type === "EOF" ? "end of expression" : `"${token.text}"`;
|
|
36
|
+
/* compile a configured pattern (object name or value regex) into an
|
|
37
|
+
anchored regular expression, so it has to match a text as a whole */
|
|
38
|
+
export const anchored = (pattern) => new RegExp(`^(?:${pattern})$`);
|
|
36
39
|
/* parse a single expression from the token stream */
|
|
37
40
|
const parseValueExpr = (tokens, pos, nested) => {
|
|
38
41
|
const token = tokens[pos.i++];
|
|
39
42
|
if (token.type === "regex") {
|
|
40
43
|
const source = String(token.value);
|
|
41
44
|
try {
|
|
42
|
-
return { kind: "regex", regex:
|
|
45
|
+
return { kind: "regex", regex: anchored(source) };
|
|
43
46
|
}
|
|
44
47
|
catch (err) {
|
|
45
48
|
throw new Error(`invalid regular expression "/${source}/": ` +
|
|
@@ -85,7 +88,7 @@ const cache = new Map();
|
|
|
85
88
|
export const compileValueExpr = (source) => {
|
|
86
89
|
let expr = cache.get(source);
|
|
87
90
|
if (expr === undefined) {
|
|
88
|
-
const tokens = lexer.
|
|
91
|
+
const tokens = lexer.input(source).tokens();
|
|
89
92
|
const pos = { i: 0 };
|
|
90
93
|
expr = parseValueExpr(tokens, pos, false);
|
|
91
94
|
if (tokens[pos.i].type !== "EOF")
|
|
@@ -102,7 +105,7 @@ export const splitItems = (text) => {
|
|
|
102
105
|
let bracketed = false;
|
|
103
106
|
for (let i = 0; i < text.length; i++) {
|
|
104
107
|
const char = text[i];
|
|
105
|
-
if (char === "\"")
|
|
108
|
+
if (char === "\"" && text[i - 1] !== "\\")
|
|
106
109
|
quoted = !quoted;
|
|
107
110
|
if (!quoted && !bracketed && char === "[" && text[i + 1] === "[") {
|
|
108
111
|
parts[parts.length - 1] += "[[";
|
package/dst/specbook-parse.d.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Schema } from "./specbook-format-schema.js";
|
|
2
2
|
import { type SourceFile, type ParseResult } from "./specbook-parse-common.js";
|
|
3
|
-
export { type SourceFile, type ParseResult
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
parse(sources: SourceFile[], config?: SchemaSpecification): ParseResult;
|
|
7
|
-
}
|
|
8
|
-
export declare const parseSpecification: (sources: SourceFile[], config?: SchemaSpecification) => ParseResult;
|
|
3
|
+
export { type SourceFile, type ParseResult } from "./specbook-parse-common.js";
|
|
4
|
+
export { resolveArtifact } from "./specbook-parse-semantic.js";
|
|
5
|
+
export declare const parseSpecification: (sources: SourceFile[], config?: Schema) => ParseResult;
|
package/dst/specbook-parse.js
CHANGED
|
@@ -4,44 +4,37 @@
|
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
6
|
import * as v from "valibot";
|
|
7
|
-
import {
|
|
7
|
+
import { Spec } from "./specbook-format-spec.js";
|
|
8
8
|
import { buildLinkIndex } from "./specbook-link.js";
|
|
9
9
|
import { ParseContext } from "./specbook-parse-common.js";
|
|
10
10
|
import { parseFile } from "./specbook-parse-syntax.js";
|
|
11
11
|
import { validate, validateReferences } from "./specbook-parse-semantic.js";
|
|
12
12
|
import { validateDiagrams } from "./specbook-diagram.js";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
for (const issue of result.issues) {
|
|
39
|
-
const path = (issue.path ?? []).map((item) => String(item.key)).join(".");
|
|
40
|
-
this.ctx.diagnose(sources[0]?.file ?? "", 1, `internal AST invalid at "${path}": ${issue.message}`);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return { specification, diagnostics: this.ctx.diagnostics };
|
|
13
|
+
export { resolveArtifact } from "./specbook-parse-semantic.js";
|
|
14
|
+
/* parse a set of specification Markdown files into the specification
|
|
15
|
+
AST and optionally validate the result against a configuration */
|
|
16
|
+
export const parseSpecification = (sources, config) => {
|
|
17
|
+
/* create a fresh parsing context for this parsing run */
|
|
18
|
+
const ctx = new ParseContext();
|
|
19
|
+
/* parse all source files into their artifacts */
|
|
20
|
+
const artifacts = new Array();
|
|
21
|
+
for (const source of sources)
|
|
22
|
+
artifacts.push(...parseFile(ctx, source));
|
|
23
|
+
const specification = { artifacts };
|
|
24
|
+
/* validate the resulting specification AST */
|
|
25
|
+
if (artifacts.length > 0) {
|
|
26
|
+
ctx.linkIndex = buildLinkIndex(specification);
|
|
27
|
+
if (config !== undefined)
|
|
28
|
+
validate(ctx, specification, config);
|
|
29
|
+
validateReferences(ctx, specification);
|
|
30
|
+
if (config !== undefined)
|
|
31
|
+
validateDiagrams(ctx, specification, config);
|
|
32
|
+
const result = v.safeParse(Spec, specification);
|
|
33
|
+
if (!result.success)
|
|
34
|
+
for (const issue of result.issues) {
|
|
35
|
+
const path = v.getDotPath(issue) ?? "";
|
|
36
|
+
ctx.diagnose(sources[0]?.file ?? "", 1, `internal AST invalid at "${path}": ${issue.message}`);
|
|
37
|
+
}
|
|
44
38
|
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export const parseSpecification = (sources, config) => new Parser().parse(sources, config);
|
|
39
|
+
return { specification, diagnostics: ctx.diagnostics };
|
|
40
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
/* the sentinel characters delimiting a marked literal value, taken
|
|
7
|
+
from the Unicode private use area, so they never clash with the
|
|
8
|
+
regular content of a verbose message */
|
|
9
|
+
const begin = "\uE000";
|
|
10
|
+
const end = "\uE001";
|
|
11
|
+
const marker = new RegExp(`${begin}([^${end}]*)${end}`, "g");
|
|
12
|
+
/* mark a literal value (path, number, format, ...) inside a verbose
|
|
13
|
+
message, so the output sink can style it without having to guess */
|
|
14
|
+
export const literal = (value) => `${begin}${value}${end}`;
|
|
15
|
+
/* render a verbose message by either styling its marked literal values
|
|
16
|
+
with the given styler or just stripping their markers away */
|
|
17
|
+
export const renderVerbose = (msg, style) => msg.replace(marker, (_, value) => style !== undefined ? style(value) : value);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rse/specbook",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"publishConfig": { "access": "public" },
|
|
5
5
|
"description": "Markdown-based Specification Format (API, CLI and MCP service)",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"pdf-lib": "1.17.1",
|
|
25
25
|
"subset-font": "2.5.0",
|
|
26
26
|
"@rse/mrcs": "0.9.2",
|
|
27
|
-
"@rse/gradia": "1.
|
|
27
|
+
"@rse/gradia": "1.1.4",
|
|
28
28
|
"@toon-format/toon": "4.1.1",
|
|
29
29
|
"valibot": "1.4.2",
|
|
30
30
|
"nunjucks": "3.2.4",
|
|
@@ -33,13 +33,9 @@
|
|
|
33
33
|
"@swc/html": "1.16.1",
|
|
34
34
|
"tokenizr": "2.2.1",
|
|
35
35
|
"playwright": "1.62.1",
|
|
36
|
-
"ai": "7.0.69",
|
|
37
|
-
"@ai-sdk/anthropic": "4.0.39",
|
|
38
|
-
"@ai-sdk/openai": "4.0.43",
|
|
39
|
-
"@openrouter/ai-sdk-provider": "3.0.0",
|
|
40
|
-
"ollama-ai-provider-v2": "4.0.1",
|
|
41
36
|
"source-code-error": "1.2.0",
|
|
42
37
|
"commander": "15.0.0",
|
|
38
|
+
"chalk": "6.0.0",
|
|
43
39
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
44
40
|
"zod": "4.4.3"
|
|
45
41
|
},
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { type SchemaSpecification } from "./specbook-struct-schema.js";
|
|
2
|
-
import { type AiOptions } from "./specbook-llm.js";
|
|
3
|
-
export interface EditOptions extends AiOptions {
|
|
4
|
-
config: SchemaSpecification;
|
|
5
|
-
basedir?: string;
|
|
6
|
-
query: string;
|
|
7
|
-
verbose: (msg: string) => void;
|
|
8
|
-
}
|
|
9
|
-
export declare const editSpecification: (options: EditOptions) => Promise<string[]>;
|