@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.
Files changed (41) hide show
  1. package/README.md +65 -10
  2. package/dst/specbook-api.js +19 -18
  3. package/dst/specbook-cli.js +9 -6
  4. package/dst/specbook-cmd-describe.js +1 -3
  5. package/dst/specbook-cmd-export.js +24 -17
  6. package/dst/specbook-cmd-init.d.ts +1 -1
  7. package/dst/specbook-cmd-init.js +5 -4
  8. package/dst/specbook-cmd-lint.d.ts +0 -1
  9. package/dst/specbook-cmd-lint.js +29 -5
  10. package/dst/specbook-config.d.ts +1 -1
  11. package/dst/specbook-config.js +90 -71
  12. package/dst/specbook-diagnostic.js +4 -1
  13. package/dst/specbook-diagram.d.ts +8 -3
  14. package/dst/specbook-diagram.js +101 -105
  15. package/dst/specbook-export-ast.d.ts +1 -1
  16. package/dst/specbook-export-ast.js +1 -1
  17. package/dst/specbook-export-common.d.ts +2 -1
  18. package/dst/specbook-export-common.js +12 -14
  19. package/dst/specbook-export-html-search.js +10 -14
  20. package/dst/specbook-export-html.css +24 -29
  21. package/dst/specbook-export-html.js +101 -83
  22. package/dst/specbook-export-md.d.ts +1 -1
  23. package/dst/specbook-export-md.js +56 -23
  24. package/dst/specbook-export-pdf.d.ts +4 -2
  25. package/dst/specbook-export-pdf.js +48 -38
  26. package/dst/specbook-format-schema.js +6 -3
  27. package/dst/specbook-format.md +47 -20
  28. package/dst/specbook-link.d.ts +1 -0
  29. package/dst/specbook-link.js +7 -4
  30. package/dst/specbook-mcp.js +17 -10
  31. package/dst/specbook-parse-common.d.ts +2 -1
  32. package/dst/specbook-parse-common.js +2 -0
  33. package/dst/specbook-parse-semantic.d.ts +4 -2
  34. package/dst/specbook-parse-semantic.js +114 -43
  35. package/dst/specbook-parse-syntax.js +179 -137
  36. package/dst/specbook-parse-value.d.ts +1 -1
  37. package/dst/specbook-parse-value.js +9 -5
  38. package/dst/specbook-parse.d.ts +2 -5
  39. package/dst/specbook-parse.js +27 -34
  40. package/dst/specbook-verbose.js +2 -1
  41. 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(/,\s*(?:\*\*BECAUSE\*\*|BECAUSE)\s+/);
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
- /* 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 */
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 wm = text.match(/\{\{([^{}]+)\}\}\s*$/);
44
- if (wm !== null && wm.index !== undefined) {
45
- id ??= wm[1].trim();
46
- text = text.slice(0, wm.index).trim();
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
- const km = text.match(/^([^:]+):\s*(.*)$/);
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, paren, primary, malformed
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
- (excluding any nested lists) */
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 body = item.tokens.find((token) => token.type === "text");
76
- const text = body !== undefined ? body.text : item.text;
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].split("\n").length - 1
114
+ offset: (m[0].match(/\n/g) ?? []).length
100
115
  };
101
116
  };
102
117
  /* recursively load the image files embedded via "![alt](file)" 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(/;\s*/).filter((segment) => segment !== "");
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
- let anchor;
200
- let paren;
201
- let primary = false;
202
- for (;;) {
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
- for (const segment of segments) {
239
- const km = segment.match(/^([^:;]+):\s+(.+)$/);
240
- if (km !== null && !(/\s(?:\*\*BECAUSE\*\*|BECAUSE)\s/.test(segment))) {
241
- const property = { key: km[1].trim(), value: km[2].trim() };
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
- for (const sub of item.tokens)
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, line);
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 artifacts = new Array();
269
- const stack = new Array();
270
- let current = null;
271
- let group = null;
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
- /* flush the accumulated description parts into the current object */
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
- if (current !== null && parts.length > 0 && current.description === undefined)
276
- current.description = splitDescription(parts.join("\n\n").trim());
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
- group = null;
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
- parts.push(token.raw.trim());
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
- parts.push(token.raw.trim());
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: new RegExp(source), source };
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.reset().input(source).tokens();
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
- }).filter((part) => part !== "");
132
+ });
129
133
  };
@@ -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, embeddingRegex, embeddingMimeType, embeddingThemes, embeddingVariants } from "./specbook-parse-common.js";
4
- export declare class Parser {
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;
@@ -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
- /* re-export the public parsing types and embedding helpers */
14
- export { embeddingRegex, embeddingMimeType, embeddingThemes, embeddingVariants } from "./specbook-parse-common.js";
15
- /* parser for a set of specification Markdown files */
16
- export class Parser {
17
- ctx = new ParseContext();
18
- /* parse all source files into the specification AST and
19
- optionally validate the result against a configuration */
20
- parse(sources, config) {
21
- /* reset the parsing context to allow a reuse of the parser */
22
- this.ctx = new ParseContext();
23
- /* parse all source files into their artifacts */
24
- const artifacts = new Array();
25
- for (const source of sources)
26
- artifacts.push(...parseFile(this.ctx, source));
27
- const specification = { artifacts };
28
- /* validate the resulting specification AST */
29
- if (artifacts.length > 0) {
30
- this.ctx.linkIndex = buildLinkIndex(specification);
31
- if (config !== undefined)
32
- validate(this.ctx, specification, config);
33
- validateReferences(this.ctx, specification);
34
- if (config !== undefined)
35
- validateDiagrams(this.ctx, specification, config);
36
- const result = v.safeParse(Spec, specification);
37
- if (!result.success)
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
- /* convenience wrapper for one-shot parsing */
47
- export const parseSpecification = (sources, config) => new Parser().parse(sources, config);
39
+ return { specification, diagnostics: ctx.diagnostics };
40
+ };
@@ -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(/\uE000([^\uE001]*)\uE001/g, (_, value) => style !== undefined ? style(value) : value);
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.6",
3
+ "version": "0.9.8",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",