@rse/specbook 0.9.6 → 0.9.8
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 +65 -10
- package/dst/specbook-api.js +19 -18
- package/dst/specbook-cli.js +9 -6
- package/dst/specbook-cmd-describe.js +1 -3
- package/dst/specbook-cmd-export.js +24 -17
- package/dst/specbook-cmd-init.d.ts +1 -1
- package/dst/specbook-cmd-init.js +5 -4
- package/dst/specbook-cmd-lint.d.ts +0 -1
- package/dst/specbook-cmd-lint.js +29 -5
- package/dst/specbook-config.d.ts +1 -1
- package/dst/specbook-config.js +90 -71
- package/dst/specbook-diagnostic.js +4 -1
- package/dst/specbook-diagram.d.ts +8 -3
- package/dst/specbook-diagram.js +101 -105
- package/dst/specbook-export-ast.d.ts +1 -1
- package/dst/specbook-export-ast.js +1 -1
- package/dst/specbook-export-common.d.ts +2 -1
- package/dst/specbook-export-common.js +12 -14
- package/dst/specbook-export-html-search.js +10 -14
- package/dst/specbook-export-html.css +24 -29
- package/dst/specbook-export-html.js +101 -83
- package/dst/specbook-export-md.d.ts +1 -1
- package/dst/specbook-export-md.js +56 -23
- package/dst/specbook-export-pdf.d.ts +4 -2
- package/dst/specbook-export-pdf.js +48 -38
- package/dst/specbook-format-schema.js +6 -3
- package/dst/specbook-format.md +47 -20
- package/dst/specbook-link.d.ts +1 -0
- package/dst/specbook-link.js +7 -4
- package/dst/specbook-mcp.js +17 -10
- package/dst/specbook-parse-common.d.ts +2 -1
- package/dst/specbook-parse-common.js +2 -0
- package/dst/specbook-parse-semantic.d.ts +4 -2
- package/dst/specbook-parse-semantic.js +114 -43
- package/dst/specbook-parse-syntax.js +179 -137
- package/dst/specbook-parse-value.d.ts +1 -1
- package/dst/specbook-parse-value.js +9 -5
- package/dst/specbook-parse.d.ts +2 -5
- package/dst/specbook-parse.js +27 -34
- package/dst/specbook-verbose.js +2 -1
- package/package.json +1 -1
|
@@ -6,10 +6,10 @@
|
|
|
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, embeddingVariants } from "./specbook-parse-common.js";
|
|
9
|
+
import { becauseRegex, embeddingRegex, embeddingMimeType, embeddingVariants } from "./specbook-parse-common.js";
|
|
10
10
|
/* split a description text into statement and optional rationale */
|
|
11
11
|
const splitDescription = (text) => {
|
|
12
|
-
const m = text.match(
|
|
12
|
+
const m = text.match(becauseRegex);
|
|
13
13
|
if (m === null || m.index === undefined)
|
|
14
14
|
return { description: text };
|
|
15
15
|
return {
|
|
@@ -17,33 +17,19 @@ const splitDescription = (text) => {
|
|
|
17
17
|
rationale: text.slice(m.index + m[0].length)
|
|
18
18
|
};
|
|
19
19
|
};
|
|
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 */
|
|
20
|
+
/* strip the trailing explicit Wiki-style anchor ("{{xxx}}"), the
|
|
21
|
+
primary marker ("(*)"), and/or the implicit parenthesized anchor
|
|
22
|
+
token ("(xxx)") off a name, in any order */
|
|
23
|
+
const parseMarkers = (raw) => {
|
|
24
|
+
let text = raw;
|
|
25
|
+
let anchor;
|
|
40
26
|
let paren;
|
|
41
27
|
let primary = false;
|
|
42
28
|
for (;;) {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
text = text.slice(0,
|
|
29
|
+
const am = text.match(/\{\{([^{}]+)\}\}\s*$/);
|
|
30
|
+
if (am !== null && am.index !== undefined) {
|
|
31
|
+
anchor ??= am[1].trim();
|
|
32
|
+
text = text.slice(0, am.index).trim();
|
|
47
33
|
continue;
|
|
48
34
|
}
|
|
49
35
|
const sm = text.match(/\(\*\)\s*$/);
|
|
@@ -60,20 +46,49 @@ const parseHeadingText = (raw) => {
|
|
|
60
46
|
}
|
|
61
47
|
break;
|
|
62
48
|
}
|
|
63
|
-
|
|
49
|
+
return { text, anchor, paren, primary };
|
|
50
|
+
};
|
|
51
|
+
/* parse a heading text of the form "<KIND>: <name> [(<token>)] [<a id="..."></a>]" */
|
|
52
|
+
const parseHeadingText = (raw) => {
|
|
53
|
+
let text = raw.trim();
|
|
54
|
+
let id;
|
|
55
|
+
const am = text.match(/<a\s+id="([^"]+)"\s*>\s*<\/a>\s*$/);
|
|
56
|
+
if (am !== null && am.index !== undefined) {
|
|
57
|
+
id = am[1];
|
|
58
|
+
text = text.slice(0, am.index).trim();
|
|
59
|
+
}
|
|
60
|
+
/* detect a malformed trailing anchor fragment, still salvaging its id */
|
|
61
|
+
let malformed;
|
|
62
|
+
const fm = text.match(/<a\b[^>]*>?\s*(?:<\/a>)?\s*$/);
|
|
63
|
+
if (fm !== null && fm.index !== undefined) {
|
|
64
|
+
malformed = fm[0].trim();
|
|
65
|
+
id ??= malformed.match(/id="([^"]+)"/)?.[1];
|
|
66
|
+
text = text.slice(0, fm.index).trim();
|
|
67
|
+
}
|
|
68
|
+
/* detect the trailing markers (an HTML anchor id takes
|
|
69
|
+
precedence over the explicit Wiki-style anchor) */
|
|
70
|
+
const markers = parseMarkers(text);
|
|
71
|
+
const km = markers.text.match(/^([^:]+):\s*(.*)$/);
|
|
64
72
|
return {
|
|
65
|
-
kind: km !== null ? km[1].trim() : text,
|
|
73
|
+
kind: km !== null ? km[1].trim() : markers.text,
|
|
66
74
|
name: km !== null ? km[2].trim() : "",
|
|
67
|
-
id
|
|
75
|
+
id: id ?? markers.anchor,
|
|
76
|
+
paren: markers.paren,
|
|
77
|
+
primary: markers.primary,
|
|
78
|
+
malformed
|
|
68
79
|
};
|
|
69
80
|
};
|
|
70
81
|
/* derive a stable slug identifier from an object name */
|
|
71
82
|
const slugify = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
72
|
-
/* extract the plain text lines of a Markdown list item
|
|
73
|
-
|
|
83
|
+
/* extract the plain text lines of a Markdown list item (excluding any
|
|
84
|
+
nested lists), whose body is a "text" token in a tight list, but a
|
|
85
|
+
"paragraph" token in a loose list (and never "item.text", as this
|
|
86
|
+
still contains the source of the nested lists) */
|
|
74
87
|
const itemLines = (item) => {
|
|
75
|
-
const
|
|
76
|
-
|
|
88
|
+
const text = item.tokens
|
|
89
|
+
.filter((token) => token.type === "text" || token.type === "paragraph")
|
|
90
|
+
.map((token) => token.text)
|
|
91
|
+
.join("\n");
|
|
77
92
|
return text.split("\n").map((line) => line.trim());
|
|
78
93
|
};
|
|
79
94
|
/* parse the optional Markdown frontmatter with Created/Modified timestamps */
|
|
@@ -96,14 +111,15 @@ const parseFrontmatter = (text) => {
|
|
|
96
111
|
created: grab("Created"),
|
|
97
112
|
modified: grab("Modified"),
|
|
98
113
|
body: text.slice(m[0].length),
|
|
99
|
-
offset: m[0].
|
|
114
|
+
offset: (m[0].match(/\n/g) ?? []).length
|
|
100
115
|
};
|
|
101
116
|
};
|
|
102
117
|
/* recursively load the image files embedded via "" into the
|
|
103
118
|
description and the property values of an object (SVG as-is, PNG/JPEG
|
|
104
119
|
as base64 data: URLs), resolving the references relative to the source
|
|
105
120
|
file and expanding a "{theme}" reference into its theme variants,
|
|
106
|
-
which are loaded into consecutive embedding entries
|
|
121
|
+
which are loaded into consecutive embedding entries (an unreadable
|
|
122
|
+
file leaves an empty entry, keeping the positions aligned) */
|
|
107
123
|
const embed = (ctx, object, file) => {
|
|
108
124
|
const load = (target, text, line) => {
|
|
109
125
|
for (const m of text.matchAll(embeddingRegex)) {
|
|
@@ -112,13 +128,14 @@ const embed = (ctx, object, file) => {
|
|
|
112
128
|
if (type === undefined)
|
|
113
129
|
continue;
|
|
114
130
|
for (const variant of embeddingVariants(reference)) {
|
|
131
|
+
target.embedding ??= [];
|
|
115
132
|
try {
|
|
116
133
|
const data = fs.readFileSync(path.resolve(path.dirname(file), variant));
|
|
117
|
-
target.embedding ??= [];
|
|
118
134
|
target.embedding.push(type === "image/svg+xml" ?
|
|
119
135
|
data.toString("utf8") : `data:${type};base64,${data.toString("base64")}`);
|
|
120
136
|
}
|
|
121
137
|
catch (err) {
|
|
138
|
+
target.embedding.push("");
|
|
122
139
|
ctx.diagnose(file, line, `unreadable embedding file "${variant}": ` +
|
|
123
140
|
(err instanceof Error ? err.message : String(err)));
|
|
124
141
|
}
|
|
@@ -152,6 +169,8 @@ const parseList = (ctx, list, object, file, line) => {
|
|
|
152
169
|
const property = { key: km[1].trim(), value };
|
|
153
170
|
ctx.propMeta.set(property, { line });
|
|
154
171
|
object.properties.push(property);
|
|
172
|
+
if (item.tokens.some((sub) => sub.type === "list"))
|
|
173
|
+
ctx.diagnose(file, line, `nested list below property "${property.key}" ignored`);
|
|
155
174
|
}
|
|
156
175
|
else
|
|
157
176
|
ctx.diagnose(file, line, `unrecognized list item "${first}"`);
|
|
@@ -174,7 +193,7 @@ const parseGrouped = (ctx, list, group, file, line) => {
|
|
|
174
193
|
omits the leading "<kind>: " and receives its kind from the group */
|
|
175
194
|
const parseConcise = (ctx, item, parent, file, line, group) => {
|
|
176
195
|
const text = itemLines(item).join(" ").trim();
|
|
177
|
-
const segments = text.split(
|
|
196
|
+
const segments = text.split(/\s*;\s*/).filter((segment) => segment !== "");
|
|
178
197
|
const head = segments.shift() ?? "";
|
|
179
198
|
let kind;
|
|
180
199
|
let name;
|
|
@@ -196,30 +215,10 @@ const parseConcise = (ctx, item, parent, file, line, group) => {
|
|
|
196
215
|
kind = km[1].trim();
|
|
197
216
|
name = km[2].trim();
|
|
198
217
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const am = name.match(/\{\{([^{}]+)\}\}\s*$/);
|
|
204
|
-
if (am !== null && am.index !== undefined) {
|
|
205
|
-
anchor ??= am[1].trim();
|
|
206
|
-
name = name.slice(0, am.index).trim();
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
const sm = name.match(/\(\*\)\s*$/);
|
|
210
|
-
if (sm !== null && sm.index !== undefined) {
|
|
211
|
-
primary = true;
|
|
212
|
-
name = name.slice(0, sm.index).trim();
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
const pm = name.match(/\(([^()]+)\)\s*$/);
|
|
216
|
-
if (pm !== null && pm.index !== undefined && paren === undefined) {
|
|
217
|
-
paren = pm[1].trim();
|
|
218
|
-
name = name.slice(0, pm.index).trim();
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
|
-
break;
|
|
222
|
-
}
|
|
218
|
+
/* detect the trailing markers of the name */
|
|
219
|
+
const { text: plain, anchor, paren, primary } = parseMarkers(name);
|
|
220
|
+
name = plain;
|
|
221
|
+
/* create the object with its markers, registering its source location */
|
|
223
222
|
const object = {
|
|
224
223
|
kind,
|
|
225
224
|
id: anchor ?? slugify(name),
|
|
@@ -234,11 +233,16 @@ const parseConcise = (ctx, item, parent, file, line, group) => {
|
|
|
234
233
|
if (primary)
|
|
235
234
|
object.primary = true;
|
|
236
235
|
ctx.objectMeta.set(object, { file, line });
|
|
236
|
+
/* split the remaining segments into properties (with a possibly
|
|
237
|
+
empty value) and statements, where the last segment of a "."-
|
|
238
|
+
terminated item is always a statement, as only this lets a
|
|
239
|
+
"<key>: <value>"-shaped description round-trip on export */
|
|
237
240
|
const statements = new Array();
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
241
|
+
const last = text.endsWith(".") ? segments.length - 1 : -1;
|
|
242
|
+
for (const [i, segment] of segments.entries()) {
|
|
243
|
+
const km = segment.match(/^([^:;]+):(?:\s+(.+))?$/);
|
|
244
|
+
if (km !== null && !becauseRegex.test(segment) && i !== last) {
|
|
245
|
+
const property = { key: km[1].trim(), value: (km[2] ?? "").trim() };
|
|
242
246
|
ctx.propMeta.set(property, { line });
|
|
243
247
|
object.properties.push(property);
|
|
244
248
|
}
|
|
@@ -247,12 +251,81 @@ const parseConcise = (ctx, item, parent, file, line, group) => {
|
|
|
247
251
|
}
|
|
248
252
|
if (statements.length > 0)
|
|
249
253
|
object.description = splitDescription(statements.join("; ").replace(/\.\s*$/, ""));
|
|
250
|
-
/* recurse into nested concise-format child objects
|
|
251
|
-
|
|
254
|
+
/* recurse into nested concise-format child objects, tracking the
|
|
255
|
+
source line of the nested list within the item (an ordered
|
|
256
|
+
nested list is not supported, so it is reported, not lost) */
|
|
257
|
+
let subLine = line;
|
|
258
|
+
for (const sub of item.tokens) {
|
|
252
259
|
if (sub.type === "list" && !sub.ordered)
|
|
253
|
-
parseList(ctx, sub, object, file,
|
|
260
|
+
parseList(ctx, sub, object, file, subLine);
|
|
261
|
+
else if (sub.type === "list")
|
|
262
|
+
ctx.diagnose(file, subLine, `nested ordered list below ${kind} "${name}" ignored`);
|
|
263
|
+
subLine += (sub.raw.match(/\n/g) ?? []).length;
|
|
264
|
+
}
|
|
254
265
|
parent.childs.push(object);
|
|
255
266
|
};
|
|
267
|
+
/* parse a heading token into either a grouping container (e.g. "### STATE",
|
|
268
|
+
collecting objects of its kind below the parent object) or a new object
|
|
269
|
+
(an artifact on level 1, a child of the enclosing object below); a heading
|
|
270
|
+
without a parent object detaches the current object, so the content below
|
|
271
|
+
it is reported, too, instead of being absorbed by the previous object */
|
|
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
|
+
state.current = null;
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
state.group = { parent, kind: heading.kind };
|
|
286
|
+
state.current = parent;
|
|
287
|
+
state.stack.length = depth - 1;
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const object = {
|
|
292
|
+
kind: heading.kind,
|
|
293
|
+
id: heading.id ?? (depth === 1 ? heading.paren : undefined) ?? slugify(heading.name),
|
|
294
|
+
name: heading.name,
|
|
295
|
+
properties: [],
|
|
296
|
+
childs: []
|
|
297
|
+
};
|
|
298
|
+
if (heading.id !== undefined)
|
|
299
|
+
object.anchor = heading.id;
|
|
300
|
+
if (heading.paren !== undefined)
|
|
301
|
+
object.paren = heading.paren;
|
|
302
|
+
if (heading.primary)
|
|
303
|
+
object.primary = true;
|
|
304
|
+
ctx.objectMeta.set(object, { file, line });
|
|
305
|
+
if (depth === 1)
|
|
306
|
+
state.artifacts.push({ ...stamps, objects: [object] });
|
|
307
|
+
else {
|
|
308
|
+
const parent = state.stack[depth - 2];
|
|
309
|
+
if (parent === undefined) {
|
|
310
|
+
ctx.diagnose(file, line, `heading level ${depth} without parent object`);
|
|
311
|
+
state.current = null;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
parent.childs.push(object);
|
|
315
|
+
}
|
|
316
|
+
state.stack.length = depth - 1;
|
|
317
|
+
state.stack[depth - 1] = object;
|
|
318
|
+
state.current = object;
|
|
319
|
+
};
|
|
320
|
+
/* the block-level token types which carry content no description can
|
|
321
|
+
hold, mapped onto their diagnostic name (the only other unhandled
|
|
322
|
+
type is "space", which carries no content at all) */
|
|
323
|
+
const unsupportedTokens = {
|
|
324
|
+
table: "table",
|
|
325
|
+
html: "raw HTML",
|
|
326
|
+
hr: "horizontal rule",
|
|
327
|
+
def: "link definition"
|
|
328
|
+
};
|
|
256
329
|
/* parse a single source file into its artifacts */
|
|
257
330
|
export const parseFile = (ctx, source) => {
|
|
258
331
|
const { present, created, modified, body, offset } = parseFrontmatter(source.text);
|
|
@@ -265,15 +338,30 @@ export const parseFile = (ctx, source) => {
|
|
|
265
338
|
`missing "${key}:" frontmatter key` :
|
|
266
339
|
`invalid "${key}:" frontmatter timestamp "${info.value}"`);
|
|
267
340
|
/* the state tracked while walking the Markdown tokens */
|
|
268
|
-
const
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
341
|
+
const state = { artifacts: [], stack: [], current: null, group: null };
|
|
342
|
+
const stamps = {
|
|
343
|
+
created: created.date ?? new Date(),
|
|
344
|
+
modified: modified.date ?? new Date()
|
|
345
|
+
};
|
|
272
346
|
let parts = new Array();
|
|
273
|
-
|
|
347
|
+
let partsLine = 1;
|
|
348
|
+
/* collect a description part, remembering the line of the first one */
|
|
349
|
+
const collect = (raw, line) => {
|
|
350
|
+
if (parts.length === 0)
|
|
351
|
+
partsLine = line;
|
|
352
|
+
parts.push(raw.trim());
|
|
353
|
+
};
|
|
354
|
+
/* flush the accumulated description parts into the current object
|
|
355
|
+
(a grouping container re-targets the parent, whose description
|
|
356
|
+
is already flushed, so further content is reported, not lost) */
|
|
274
357
|
const flush = () => {
|
|
275
|
-
|
|
276
|
-
|
|
358
|
+
const current = state.current;
|
|
359
|
+
if (current !== null && parts.length > 0) {
|
|
360
|
+
if (current.description === undefined)
|
|
361
|
+
current.description = splitDescription(parts.join("\n\n").trim());
|
|
362
|
+
else
|
|
363
|
+
ctx.diagnose(source.file, partsLine, `content ignored, as ${current.kind} "${current.name}" already carries a description`);
|
|
364
|
+
}
|
|
277
365
|
parts = [];
|
|
278
366
|
};
|
|
279
367
|
/* walk the top-level Markdown tokens, tracking source lines */
|
|
@@ -281,88 +369,42 @@ export const parseFile = (ctx, source) => {
|
|
|
281
369
|
for (const token of marked.lexer(body)) {
|
|
282
370
|
if (token.type === "heading") {
|
|
283
371
|
flush();
|
|
284
|
-
|
|
285
|
-
const { depth, text } = token;
|
|
286
|
-
const heading = parseHeadingText(text);
|
|
287
|
-
if (heading.malformed !== undefined)
|
|
288
|
-
ctx.diagnose(source.file, line, `malformed anchor "${heading.malformed}" in heading`);
|
|
289
|
-
if (heading.name === "" && depth > 1) {
|
|
290
|
-
/* a grouping container heading (e.g. "### STATE") collects
|
|
291
|
-
objects of its kind below the parent object */
|
|
292
|
-
const parent = stack[depth - 2];
|
|
293
|
-
if (parent === undefined)
|
|
294
|
-
ctx.diagnose(source.file, line, `heading level ${depth} without parent object`);
|
|
295
|
-
else {
|
|
296
|
-
group = { parent, kind: heading.kind };
|
|
297
|
-
current = parent;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
else {
|
|
301
|
-
const object = {
|
|
302
|
-
kind: heading.kind,
|
|
303
|
-
id: heading.id ?? (depth === 1 ? heading.paren : undefined) ?? slugify(heading.name),
|
|
304
|
-
name: heading.name,
|
|
305
|
-
properties: [],
|
|
306
|
-
childs: []
|
|
307
|
-
};
|
|
308
|
-
if (heading.id !== undefined)
|
|
309
|
-
object.anchor = heading.id;
|
|
310
|
-
if (heading.paren !== undefined)
|
|
311
|
-
object.paren = heading.paren;
|
|
312
|
-
if (heading.primary)
|
|
313
|
-
object.primary = true;
|
|
314
|
-
ctx.objectMeta.set(object, { file: source.file, line });
|
|
315
|
-
if (depth === 1) {
|
|
316
|
-
artifacts.push({
|
|
317
|
-
created: created.date ?? new Date(),
|
|
318
|
-
modified: modified.date ?? new Date(),
|
|
319
|
-
objects: [object]
|
|
320
|
-
});
|
|
321
|
-
stack.length = 0;
|
|
322
|
-
}
|
|
323
|
-
else {
|
|
324
|
-
const parent = stack[depth - 2];
|
|
325
|
-
if (parent === undefined) {
|
|
326
|
-
ctx.diagnose(source.file, line, `heading level ${depth} without parent object`);
|
|
327
|
-
line += (token.raw.match(/\n/g) ?? []).length;
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
parent.childs.push(object);
|
|
331
|
-
}
|
|
332
|
-
stack.length = depth - 1;
|
|
333
|
-
stack[depth - 1] = object;
|
|
334
|
-
current = object;
|
|
335
|
-
}
|
|
372
|
+
parseHeading(ctx, token, state, stamps, source.file, line);
|
|
336
373
|
}
|
|
337
374
|
else if (token.type === "list") {
|
|
338
375
|
const list = token;
|
|
339
|
-
if (current === null)
|
|
376
|
+
if (state.current === null)
|
|
340
377
|
ctx.diagnose(source.file, line, "content outside of any object");
|
|
341
|
-
else if (!list.ordered && group !== null)
|
|
342
|
-
parseGrouped(ctx, list, group, source.file, line);
|
|
378
|
+
else if (!list.ordered && state.group !== null)
|
|
379
|
+
parseGrouped(ctx, list, state.group, source.file, line);
|
|
343
380
|
else if (!list.ordered)
|
|
344
|
-
parseList(ctx, list, current, source.file, line);
|
|
381
|
+
parseList(ctx, list, state.current, source.file, line);
|
|
345
382
|
else
|
|
346
|
-
|
|
383
|
+
collect(token.raw, line);
|
|
347
384
|
}
|
|
348
385
|
else if (token.type === "paragraph" || token.type === "blockquote"
|
|
349
386
|
|| (token.type === "code" && token.lang !== "gradia")) {
|
|
350
387
|
/* notice: a "gradia" code block is skipped entirely, as it is
|
|
351
388
|
the derived diagram of the Markdown renderer and hence must
|
|
352
389
|
not become authored content on a re-parse of exported files */
|
|
353
|
-
if (current === null)
|
|
390
|
+
if (state.current === null)
|
|
354
391
|
ctx.diagnose(source.file, line, "content outside of any object");
|
|
355
392
|
else
|
|
356
|
-
|
|
393
|
+
collect(token.raw, line);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
const unsupported = unsupportedTokens[token.type];
|
|
397
|
+
if (unsupported !== undefined)
|
|
398
|
+
ctx.diagnose(source.file, line, `unsupported ${unsupported} content ignored`);
|
|
357
399
|
}
|
|
358
400
|
line += (token.raw.match(/\n/g) ?? []).length;
|
|
359
401
|
}
|
|
360
402
|
flush();
|
|
361
403
|
/* load the embedded image files of all fully parsed objects */
|
|
362
|
-
for (const artifact of artifacts)
|
|
404
|
+
for (const artifact of state.artifacts)
|
|
363
405
|
for (const object of artifact.objects)
|
|
364
406
|
embed(ctx, object, source.file);
|
|
365
|
-
if (artifacts.length === 0)
|
|
407
|
+
if (state.artifacts.length === 0)
|
|
366
408
|
ctx.diagnose(source.file, 1, "no artifact (level 1 heading) found");
|
|
367
|
-
return artifacts;
|
|
409
|
+
return state.artifacts;
|
|
368
410
|
};
|
|
@@ -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")
|
|
@@ -95,14 +98,15 @@ export const compileValueExpr = (source) => {
|
|
|
95
98
|
return expr;
|
|
96
99
|
};
|
|
97
100
|
/* split a property value at top-level commas, honoring double-quoted
|
|
98
|
-
sections and "[[...]]" reference bracketing
|
|
101
|
+
sections and "[[...]]" reference bracketing (empty items are kept,
|
|
102
|
+
as only the caller can decide whether to report or skip them) */
|
|
99
103
|
export const splitItems = (text) => {
|
|
100
104
|
const parts = [""];
|
|
101
105
|
let quoted = false;
|
|
102
106
|
let bracketed = false;
|
|
103
107
|
for (let i = 0; i < text.length; i++) {
|
|
104
108
|
const char = text[i];
|
|
105
|
-
if (char === "\"")
|
|
109
|
+
if (char === "\"" && text[i - 1] !== "\\")
|
|
106
110
|
quoted = !quoted;
|
|
107
111
|
if (!quoted && !bracketed && char === "[" && text[i + 1] === "[") {
|
|
108
112
|
parts[parts.length - 1] += "[[";
|
|
@@ -125,5 +129,5 @@ export const splitItems = (text) => {
|
|
|
125
129
|
const item = part.trim();
|
|
126
130
|
const m = item.match(/^"((?:\\"|[^"])*)"$/);
|
|
127
131
|
return m !== null ? m[1].replace(/\\"/g, "\"") : item;
|
|
128
|
-
})
|
|
132
|
+
});
|
|
129
133
|
};
|
package/dst/specbook-parse.d.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
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
|
-
private ctx;
|
|
6
|
-
parse(sources: SourceFile[], config?: Schema): ParseResult;
|
|
7
|
-
}
|
|
3
|
+
export { type SourceFile, type ParseResult } from "./specbook-parse-common.js";
|
|
4
|
+
export { resolveArtifact } from "./specbook-parse-semantic.js";
|
|
8
5
|
export declare const parseSpecification: (sources: SourceFile[], config?: Schema) => ParseResult;
|
package/dst/specbook-parse.js
CHANGED
|
@@ -10,38 +10,31 @@ 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
|
+
};
|
package/dst/specbook-verbose.js
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
regular content of a verbose message */
|
|
9
9
|
const begin = "\uE000";
|
|
10
10
|
const end = "\uE001";
|
|
11
|
+
const marker = new RegExp(`${begin}([^${end}]*)${end}`, "g");
|
|
11
12
|
/* mark a literal value (path, number, format, ...) inside a verbose
|
|
12
13
|
message, so the output sink can style it without having to guess */
|
|
13
14
|
export const literal = (value) => `${begin}${value}${end}`;
|
|
14
15
|
/* render a verbose message by either styling its marked literal values
|
|
15
16
|
with the given styler or just stripping their markers away */
|
|
16
|
-
export const renderVerbose = (msg, style) => msg.replace(
|
|
17
|
+
export const renderVerbose = (msg, style) => msg.replace(marker, (_, value) => style !== undefined ? style(value) : value);
|