@rse/specbook 0.9.3 → 0.9.5

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.
@@ -6,8 +6,9 @@
6
6
  import path from "node:path";
7
7
  import fs from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
9
- /* escape a text for embedding into template HTML */
10
- export const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
9
+ /* escape a text for embedding into template HTML (text and attributes) */
10
+ export const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
11
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
11
12
  /* provide the build-time pre-assembled stylesheet (with the
12
13
  font faces already inlined as base64 data: URIs) */
13
14
  export const stylesheet = () => fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-export-html.css"), "utf8");
@@ -18,9 +19,10 @@ export const searchScript = () => fs.readFileSync(path.join(path.dirname(fileURL
18
19
  export const fallbackLogo = () => "data:image/svg+xml;base64," + fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-export-logo.svg")).toString("base64");
19
20
  /* check whether an object is the specification title object */
20
21
  export const isTitleObject = (object) => object.kind === "META" && object.name.toUpperCase() === "TITLE";
22
+ /* determine the title object of the specification */
23
+ const titleObject = (specification) => specification.artifacts.flatMap((artifact) => artifact.objects).find(isTitleObject);
21
24
  /* determine a property value of the title object */
22
- const titleProperty = (specification, name) => specification.artifacts.flatMap((artifact) => artifact.objects).find(isTitleObject)
23
- ?.properties.find((property) => property.key === name)?.value;
25
+ const titleProperty = (specification, name) => titleObject(specification)?.properties.find((property) => property.key === name)?.value;
24
26
  /* determine the document language (LANG) from the title object */
25
27
  export const documentLang = (specification) => titleProperty(specification, "LANG")?.trim();
26
28
  /* determine the document character set (CHARSET) from the title object */
@@ -37,28 +39,39 @@ const papers = {
37
39
  "Letter": { unit: "in", height: 11, margin: { top: 1, bottom: 1, left: 0.8, right: 0.8 } },
38
40
  "Legal": { unit: "in", height: 14, margin: { top: 1, bottom: 1, left: 0.8, right: 0.8 } }
39
41
  };
40
- export const paperSizes = Object.keys(papers);
42
+ const paperSizes = Object.keys(papers);
41
43
  export const paperSizeDefault = "A4";
42
44
  /* provide the setup of a paper size, falling back onto the default */
43
45
  export const paperSetup = (paper) => papers[paper] ?? papers[paperSizeDefault];
44
46
  /* render a paper dimension as its CSS length */
45
47
  export const paperLength = (setup, value) => `${value}${setup.unit}`;
46
48
  /* determine the document paper size (PAPER-SIZE) from the title object,
47
- matched case-insensitively and falling back onto the default */
49
+ matched case-insensitively, falling back onto the default if unset
50
+ and rejecting an unknown size */
48
51
  export const documentPaperSize = (specification) => {
49
- const value = titleProperty(specification, "PAPER-SIZE")?.trim().toLowerCase();
50
- return paperSizes.find((name) => name.toLowerCase() === value) ?? paperSizeDefault;
52
+ const value = titleProperty(specification, "PAPER-SIZE")?.trim();
53
+ if (value === undefined)
54
+ return paperSizeDefault;
55
+ const paper = paperSizes.find((name) => name.toLowerCase() === value.toLowerCase());
56
+ if (paper === undefined)
57
+ throw new Error(`unknown paper size "${value}" ` +
58
+ `(expected ${paperSizes.join(", ")})`);
59
+ return paper;
51
60
  };
52
61
  /* provide the paper-dependent print stylesheet: a diagram is scaled
53
62
  down to still fit onto a single page (the paper height less the
54
- print margins and the own vertical margins of the diagram) and is
55
- never broken across a page boundary */
63
+ print margins, the own vertical margins of the diagram, and the
64
+ room its introducing heading claims above it) and is never broken
65
+ across a page boundary; without the heading reserve a maximally
66
+ sized diagram could not share its page with the heading, which
67
+ would defeat the "break-after: avoid" bundling of the stylesheet */
68
+ const headingReserve = 6;
56
69
  export const paperStylesheet = (paper) => {
57
70
  const setup = paperSetup(paper);
58
71
  const avail = setup.height - setup.margin.top - setup.margin.bottom;
59
72
  return "@media print {\n" +
60
73
  "div.diagram { break-inside: avoid; }\n" +
61
- `div.diagram svg { max-height: calc(${paperLength(setup, avail)} - 2rem);` +
74
+ `div.diagram svg { max-height: calc(${paperLength(setup, avail)} - ${headingReserve}rem);` +
62
75
  " width: auto; height: auto; }\n" +
63
76
  "}\n";
64
77
  };
@@ -87,7 +100,7 @@ export const charsetCodepoints = (charset) => {
87
100
  else if (name === "utf8" || name === "utf16" || name === "unicode")
88
101
  return undefined;
89
102
  throw new Error(`unknown charset "${charset}" ` +
90
- "(expected US-ASCII, ISO-8859-1/ISO-Latin-1, ISO-8859-15/ISO-Latin-15, or UTF-8)");
103
+ "(expected US-ASCII, ISO-8859-1/ISO-Latin-1, ISO-8859-15/ISO-Latin-9, or UTF-8)");
91
104
  };
92
105
  /* the symbol glyphs used by the HTML/PDF rendering (kind and property
93
106
  bullets, link symbol, primary marker, theme switch icon, anchor
@@ -123,18 +136,18 @@ export const subsetStylesheet = async (charset) => {
123
136
  return result + css.slice(last);
124
137
  };
125
138
  /* determine the document title and subtitle from the title object */
126
- export const documentTitle = (specification) => {
127
- const object = specification.artifacts.flatMap((artifact) => artifact.objects).find(isTitleObject);
128
- const prop = (name) => object?.properties.find((property) => property.key === name)?.value;
129
- return { title: prop("TITLE") ?? object?.name ?? "Specification", subtitle: prop("SUBTITLE") };
130
- };
139
+ export const documentTitle = (specification) => ({
140
+ title: titleProperty(specification, "TITLE") ??
141
+ titleObject(specification)?.name ?? "Specification",
142
+ subtitle: titleProperty(specification, "SUBTITLE")
143
+ });
131
144
  /* determine the document logo as a self-contained data: URL, taken from the
132
145
  embedded image of the LOGO property of the title object, and falling back
133
146
  onto the built-in SpecBook logo (for use in isolated rendering contexts,
134
147
  like the print header/footer, which load no external resources) */
135
148
  export const documentLogo = (specification) => {
136
- const object = specification.artifacts.flatMap((artifact) => artifact.objects).find(isTitleObject);
137
- const content = object?.properties.find((property) => property.key === "LOGO")?.embedding?.[0];
149
+ const content = titleObject(specification)
150
+ ?.properties.find((property) => property.key === "LOGO")?.embedding?.[0];
138
151
  if (content === undefined)
139
152
  return fallbackLogo();
140
153
  return content.startsWith("data:") ? content :
@@ -45,7 +45,7 @@
45
45
  return
46
46
  parent = parent.parentElement
47
47
  }
48
- const text = (el.textContent || "").toLowerCase()
48
+ const text = (el.textContent ?? "").toLowerCase()
49
49
  const words = new Set(text.split(/[^\p{L}\p{N}]+/u).filter((w) => w !== ""))
50
50
  words.forEach((w) => { vocab.add(w) })
51
51
  units.push({ el, text, words, keep: false })
@@ -141,7 +141,7 @@
141
141
  keeps the tags and attributes untouched */
142
142
  const highlight = (el, regex) => {
143
143
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
144
- const nodes = []
144
+ const nodes = []
145
145
  let node
146
146
  while ((node = walker.nextNode()) !== null)
147
147
  nodes.push(node)
@@ -231,13 +231,15 @@
231
231
  })
232
232
 
233
233
  /* hide the units not kept, plus the objects and tables which
234
- ran entirely empty in the process */
234
+ ran entirely empty in the process (a table nested inside a
235
+ kept unit, like a chunked property table, stays visible) */
235
236
  units.forEach((unit) => {
236
237
  if (!unit.el.classList.contains("search-keep"))
237
238
  unit.el.classList.add("search-hide")
238
239
  })
239
240
  document.querySelectorAll("article section, article table").forEach((el) => {
240
- if (el.querySelector(".search-keep") === null)
241
+ if (el.querySelector(".search-keep") === null
242
+ && el.closest(".search-keep") === null)
241
243
  el.classList.add("search-hide")
242
244
  })
243
245
 
@@ -137,6 +137,13 @@ section { margin: 0.5rem 0; }
137
137
  h1 { margin: 3.0rem 0 2.0rem 0; border-bottom: 1px solid var(--theme-color-specbook-border); }
138
138
  h2, h3, h4 { margin: 2.0rem 0 1.0rem 0; }
139
139
 
140
+ /* bundle a heading with the content it introduces (diagram, property
141
+ table, description), so no heading is left orphaned at the very
142
+ bottom of a page while its content starts on the next one */
143
+ @media print {
144
+ h1, h2, h3, h4, h5, h6 { page-break-after: avoid; break-after: avoid; }
145
+ }
146
+
140
147
  body { counter-reset: sec1; }
141
148
  article h1 { counter-increment: sec1; counter-reset: sec2; }
142
149
  article h2 { counter-increment: sec2; counter-reset: sec3; }
@@ -3,7 +3,7 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
- import { marked } from "marked";
6
+ import { Marked } from "marked";
7
7
  import { markedSmartypants } from "marked-smartypants";
8
8
  import nunjucks from "nunjucks";
9
9
  import nunjucksAddons from "@rse/nunjucks-addons";
@@ -30,7 +30,8 @@ const templates = {
30
30
  <script>
31
31
  (function () {
32
32
  let style = null
33
- try { style = localStorage.getItem("specbook-theme") } catch (ex) {}
33
+ try { style = localStorage.getItem("specbook-theme") }
34
+ catch { /* an inaccessible storage just means no stored choice */ }
34
35
  if (style === null) {
35
36
  const m = document.documentElement.className.match(/theme-(light|dark)/)
36
37
  style = m !== null ? m[1] : null
@@ -42,7 +43,8 @@ const templates = {
42
43
  function themeSwitch () {
43
44
  const style = document.documentElement.className === "theme-dark" ? "light" : "dark"
44
45
  document.documentElement.className = "theme-" + style
45
- try { localStorage.setItem("specbook-theme", style) } catch (ex) {}
46
+ try { localStorage.setItem("specbook-theme", style) }
47
+ catch { /* an inaccessible storage just loses the choice */ }
46
48
  }
47
49
  </script>
48
50
  </head>
@@ -194,6 +196,10 @@ const quoteStyles = {
194
196
  };
195
197
  /* the active double quote style (set per document during rendering) */
196
198
  let quotes = quoteStyles.en;
199
+ /* the private Markdown renderer instance, as the hooks below must not
200
+ leak into the "marked" singleton shared with the parser and with the
201
+ application embedding SpecBook as a library */
202
+ const marked = new Marked();
197
203
  /* improve the typography of all rendered Markdown text (curly quotes,
198
204
  "--" as the em dash, ellipsis); as smartypants requires unescaped text,
199
205
  the extension switches off the text escaping of marked, so the stray
@@ -209,6 +215,7 @@ const env = new nunjucks.Environment(null, { autoescape: true });
209
215
  nunjucksAddons(env);
210
216
  /* mark pre-rendered HTML as safe for template interpolation */
211
217
  const safe = (html) => new nunjucks.runtime.SafeString(html);
218
+ /* render one of the built-in Nunjucks templates with a context */
212
219
  const render = (name, context) => env.renderString(templates[name], context);
213
220
  /* the active per-document reference expander, fully-qualified
214
221
  anchor paths, enum/tags property value kinds, object schema nodes,
@@ -218,6 +225,9 @@ let anchors = null;
218
225
  let members = null;
219
226
  let schemas = null;
220
227
  let diagrams = null;
228
+ /* the cache of the pre-rendered diagram SVGs, keyed by specification,
229
+ as the PDF export renders the very same document multiple times */
230
+ const diagramCache = new WeakMap();
221
231
  /* determine the fully-qualified anchor path of an object */
222
232
  const anchorOf = (object) => anchors?.get(object) ?? object.id;
223
233
  /* expand the inline Markdown of a text (code spans, emphasis, etc.),
@@ -308,7 +318,7 @@ const plainKey = (key) => key.replace(/\s*\([^)]*\)\s*$/, "").trim();
308
318
  /* render a property value, badging the individual members of an
309
319
  "enum(...)" (a single member) or "tags(...)" (a member set) value */
310
320
  const inlineValue = (kind, key, value) => {
311
- const member = members?.get(`${kind}${key.replace(/\s*\([^)]*\)\s*$/, "").trim()}`);
321
+ const member = members?.get(`${kind}${plainKey(key)}`);
312
322
  if (member === undefined)
313
323
  return inline(value);
314
324
  const items = member === "tags" ? splitItems(value) : [value.trim()];
@@ -319,8 +329,9 @@ const inlineProperties = (kind, properties) => properties.map((property) => ({ k
319
329
  value: inlineValue(kind, property.key, property.value) }));
320
330
  /* resolve the format configuration of an object */
321
331
  const formatOf = (object) => schemas?.get(object)?.format;
322
- /* determine the maximum table columns configured on an object */
323
- const maxColumnsOf = (object) => formatOf(object)?.maxTableColumns ?? 4;
332
+ /* determine the maximum table columns configured on an object (at
333
+ least two, as a single column cannot carry a name and a value) */
334
+ const maxColumnsOf = (object) => Math.max(2, formatOf(object)?.maxTableColumns ?? 4);
324
335
  /* determine the effective properties of an object, with
325
336
  "withUnusedProps" injecting the defined but still unused schema
326
337
  properties (in schema order) as empty key/value entries */
@@ -470,6 +481,10 @@ const renderObject = (object, level, concise) => {
470
481
  /* render the title object into a title page */
471
482
  const renderTitlePage = (object, created, modified) => {
472
483
  const prop = (name) => object.properties.find((property) => property.key === name)?.value;
484
+ const inlineProp = (name) => {
485
+ const value = prop(name);
486
+ return value !== undefined ? inline(value) : "";
487
+ };
473
488
  const rest = object.properties.filter((property) => !["LOGO", "TITLE", "SUBTITLE", "AUTHOR", "VERSION",
474
489
  "LANG", "CHARSET", "PAPER-SIZE", "THEME-STYLE", "THEME-TONE"].includes(property.key));
475
490
  /* the logo is rendered above the title, from the embedded image content
@@ -481,9 +496,9 @@ const renderTitlePage = (object, created, modified) => {
481
496
  logo: logo === undefined ? safe(`<img src="${fallbackLogo()}" alt="SpecBook"/>`) :
482
497
  (image.length > 0 ? safe(image.join("")) : inline(logo.value)),
483
498
  title: inline(prop("TITLE") ?? object.name),
484
- subtitle: prop("SUBTITLE") !== undefined ? inline(prop("SUBTITLE") ?? "") : "",
485
- author: prop("AUTHOR") !== undefined ? inline(prop("AUTHOR") ?? "") : "",
486
- version: prop("VERSION") !== undefined ? inline(prop("VERSION") ?? "") : "",
499
+ subtitle: inlineProp("SUBTITLE"),
500
+ author: inlineProp("AUTHOR"),
501
+ version: inlineProp("VERSION"),
487
502
  description: object.description !== undefined ?
488
503
  safe(renderDescription(object.description)) : "",
489
504
  properties: rest.length > 0 ?
@@ -515,9 +530,6 @@ export const htmlOutline = (specification, config) => {
515
530
  artifact timestamps aggregated into min(Created)/max(Modified), and
516
531
  optional per-anchor page numbers attached to the ToC entries */
517
532
  export const renderHtml = async (specification, config, tocPages, css) => {
518
- /* the document language selects the smart typography quote style */
519
- const lang = documentLang(specification);
520
- quotes = quoteStyles[lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
521
533
  /* pre-render the configured diagrams as embeddable SVGs (a runtime
522
534
  rendering failure omits the diagram, as the statically detectable
523
535
  invalid situations are already reported as lint diagnostics),
@@ -525,8 +537,11 @@ export const renderHtml = async (specification, config, tocPages, css) => {
525
537
  (node boxes, font sizes) is dimensioned for a stand-alone
526
538
  canvas and would dwarf the document text at 1:1 */
527
539
  const scale = 0.75;
540
+ const cached = diagramCache.get(specification);
528
541
  diagrams = null;
529
- if (config !== undefined) {
542
+ if (cached !== undefined && cached.config === config)
543
+ diagrams = cached.diagrams;
544
+ else if (config !== undefined) {
530
545
  diagrams = new Map();
531
546
  for (const [object, result] of specDiagrams(specification, config)) {
532
547
  if (result.spec === undefined)
@@ -539,7 +554,11 @@ export const renderHtml = async (specification, config, tocPages, css) => {
539
554
  /* intentionally omitted */
540
555
  }
541
556
  }
557
+ diagramCache.set(specification, { config, diagrams });
542
558
  }
559
+ /* the document language selects the smart typography quote style */
560
+ const lang = documentLang(specification);
561
+ quotes = quoteStyles[lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
543
562
  /* expand "[[xxx]]" references into hyperlinks (an unresolvable or
544
563
  ambiguous reference stays literal, marked as broken), targeting
545
564
  the fully-qualified anchor paths of the objects */
@@ -31,7 +31,12 @@ const renderConciseMd = (object, level) => {
31
31
  segments.push(...object.properties.map((property) => `${property.key}: ${property.value}`));
32
32
  if (object.description !== undefined)
33
33
  segments.push(renderDescriptionMd(object.description));
34
- const item = `${indent}- ${segments.join("; ").replace(/\.?$/, ".")}`;
34
+ /* a description terminates the item with ".", while without one the
35
+ ";" segment terminator has to remain, as only it lets the item be
36
+ recognized as a concise object again on re-parsing */
37
+ const item = object.description !== undefined ?
38
+ `${indent}- ${segments.join("; ").replace(/\.?$/, ".")}` :
39
+ `${indent}- ${segments.join("; ")};`;
35
40
  return [item, ...object.childs.map((child) => renderConciseMd(child, level + 1))].join("\n");
36
41
  };
37
42
  /* render an object in the Complex Format (levels 1-3),
@@ -39,9 +44,8 @@ const renderConciseMd = (object, level) => {
39
44
  and the optionally derived Gradia diagram spec embedded
40
45
  as a "gradia" fenced code block below the heading */
41
46
  const renderObjectMd = (object, level, diagrams) => {
42
- const heading = level === 1 ?
43
- `# ${object.kind}: ${object.name}${nameSuffixMd(object)}` :
44
- `${"#".repeat(level)}${" ".repeat(4 - level)}${object.kind}: ${object.name}${nameSuffixMd(object)}`;
47
+ const heading = `${"#".repeat(level)}${" ".repeat(4 - level)}` +
48
+ `${object.kind}: ${object.name}${nameSuffixMd(object)}`;
45
49
  const parts = [heading];
46
50
  const spec = diagrams?.get(object);
47
51
  if (spec !== undefined)
@@ -66,9 +70,9 @@ export const renderMarkdown = async (specification, config) => {
66
70
  for (const [object, result] of specDiagrams(specification, config))
67
71
  if (result.spec !== undefined)
68
72
  diagrams.set(object, result.spec);
69
- return specification.artifacts.map((artifact) => artifact.objects.map((object) => "---\n" +
73
+ return specification.artifacts.map((artifact) => "---\n" +
70
74
  `Created: ${formatTimestamp(artifact.created)}\n` +
71
75
  `Modified: ${formatTimestamp(artifact.modified)}\n` +
72
76
  "---\n\n" +
73
- renderObjectMd(object, 1, diagrams)).join("\n\n")).join("\n\n") + "\n";
77
+ artifact.objects.map((object) => renderObjectMd(object, 1, diagrams)).join("\n\n")).join("\n\n") + "\n";
74
78
  };
@@ -153,7 +153,7 @@ export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css,
153
153
  "padding-bottom: 1mm; display: flex; justify-content: space-between; " +
154
154
  "align-items: flex-end;\">" +
155
155
  `<span>${headText}</span>` +
156
- `<img src="${heading.logo}" alt="" style="height: 3.5mm;"/>` +
156
+ `<img src="${escapeHtml(heading.logo)}" alt="" style="height: 3.5mm;"/>` +
157
157
  "</div></div>",
158
158
  footerTemplate: `<style>${fontFace}</style>` +
159
159
  "<div style=\"width: 100%; margin-bottom: 0.8cm;\">" +
@@ -185,8 +185,9 @@ export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css,
185
185
  /* draw the vertical brand bar onto the left edge of the
186
186
  physical paper of every page, as Chromium clips print
187
187
  content to the page box (0.6rem at the 9pt print root) */
188
- const [r, g, b] = (theme.accent.match(/[0-9a-f]{2}/gi) ?? [])
189
- .map((hex) => parseInt(hex, 16) / 255);
188
+ const hex = theme.accent.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
189
+ const [r, g, b] = (hex !== null ? hex.slice(1) : ["00", "00", "00"])
190
+ .map((component) => parseInt(component, 16) / 255);
190
191
  for (const pdfPage of merged.getPages())
191
192
  pdfPage.drawRectangle({ x: 0, y: 0, width: 5.4,
192
193
  height: pdfPage.getHeight(), color: rgb(r, g, b) });
@@ -3,40 +3,30 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
- import { generateText } from "ai";
7
- /* the default model per supported provider */
8
- const defaults = {
9
- anthropic: "claude-sonnet-5",
10
- openai: "gpt-5.6-luna",
11
- openrouter: "anthropic/claude-sonnet-5",
12
- ollama: "qwen3"
6
+ /* the supported AI providers */
7
+ const providers = {
8
+ anthropic: { model: "claude-sonnet-5",
9
+ create: async (id) => (await import("@ai-sdk/anthropic")).createAnthropic()(id) },
10
+ openai: { model: "gpt-5.6-luna",
11
+ create: async (id) => (await import("@ai-sdk/openai")).createOpenAI()(id) },
12
+ openrouter: { model: "anthropic/claude-sonnet-5",
13
+ create: async (id) => (await import("@openrouter/ai-sdk-provider")).createOpenRouter()(id) },
14
+ ollama: { model: "qwen3",
15
+ create: async (id) => (await import("ollama-ai-provider-v2")).createOllama()(id) }
13
16
  };
14
17
  /* resolve the AI provider and model into a language model handle */
15
18
  export const resolveModel = async (options) => {
16
- const provider = options.provider ?? process.env.SPECBOOK_AI_PROVIDER ?? "anthropic";
17
- const model = options.model ?? process.env.SPECBOOK_AI_MODEL ?? defaults[provider];
18
- if (provider === "anthropic") {
19
- const { createAnthropic } = await import("@ai-sdk/anthropic");
20
- return createAnthropic()(model);
21
- }
22
- else if (provider === "openai") {
23
- const { createOpenAI } = await import("@ai-sdk/openai");
24
- return createOpenAI()(model);
25
- }
26
- else if (provider === "openrouter") {
27
- const { createOpenRouter } = await import("@openrouter/ai-sdk-provider");
28
- return createOpenRouter()(model);
29
- }
30
- else if (provider === "ollama") {
31
- const { createOllama } = await import("ollama-ai-provider-v2");
32
- return createOllama()(model);
33
- }
34
- else
35
- throw new Error(`unknown AI provider "${provider}" ` +
36
- "(expected \"anthropic\", \"openai\", \"openrouter\" or \"ollama\")");
19
+ const name = options.provider ?? process.env.SPECBOOK_AI_PROVIDER ?? "anthropic";
20
+ const provider = providers[name];
21
+ if (provider === undefined)
22
+ throw new Error(`unknown AI provider "${name}" (expected ` +
23
+ Object.keys(providers).map((n) => `"${n}"`).join(", ") + ")");
24
+ const model = options.model ?? process.env.SPECBOOK_AI_MODEL ?? provider.model;
25
+ return provider.create(model);
37
26
  };
38
27
  /* perform a single LLM completion */
39
28
  export const complete = async (options, system, prompt) => {
29
+ const { generateText } = await import("ai");
40
30
  const model = await resolveModel(options);
41
31
  const result = await generateText({ model, system, prompt });
42
32
  return result.text;
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { z } from "zod";
10
- import { SpecBook, renderDiagnostic, formats } from "./specbook-api.js";
10
+ import { SpecBook, renderDiagnostic, formats, version } from "./specbook-api.js";
11
11
  /* render an error with its cause chain into a tool error result */
12
12
  const errorResult = (err) => {
13
13
  let msg = err instanceof Error ? err.message : String(err);
@@ -20,7 +20,7 @@ const errorResult = (err) => {
20
20
  };
21
21
  /* serve the SpecBook functionality as "specbook_<cmd>" MCP tools over stdio */
22
22
  export const serveMcp = async (verbose) => {
23
- const server = new McpServer({ name: "specbook", version: "0.9.0" });
23
+ const server = new McpServer({ name: "specbook", version });
24
24
  const specbook = new SpecBook({ verbose });
25
25
  server.registerTool("specbook_init", {
26
26
  title: "Initialize Specification",
@@ -50,22 +50,12 @@ const matchAlternatives = (ctx, alternatives, item) => {
50
50
  }
51
51
  return false;
52
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 */
53
+ /* find a property of an object by its key (case-sensitive) */
54
+ const findProp = (object, name) => object.properties.find((p) => plainKey(p.key) === name);
55
+ /* distribute the inline tokens split off the multi-token property values
56
+ of an object across its configured properties and report the
57
+ unassignable ones (the properties assigned a token are returned) */
58
+ const assignInlineTokens = (ctx, object, props, meta) => {
69
59
  const tokens = new Array();
70
60
  const tokenAssigned = new Set();
71
61
  /* explode a multi-token property value whose direct pattern check
@@ -77,7 +67,7 @@ const validateObject = (ctx, object, schema, level) => {
77
67
  const expr = compileValueExpr(prop.value);
78
68
  if (expr.kind !== "regex" && expr.kind !== "enum")
79
69
  continue;
80
- const match = findProp(prop.name);
70
+ const match = findProp(object, prop.name);
81
71
  if (match === undefined || directMatches(expr, plainText(match.value)))
82
72
  continue;
83
73
  const parts = plainText(match.value).split(/\s+/);
@@ -91,8 +81,10 @@ const validateObject = (ctx, object, schema, level) => {
91
81
  parts.splice(idx, 1);
92
82
  tokens.push(...parts);
93
83
  }
84
+ /* assign the collected tokens to the still unset configured
85
+ properties whose pattern they match */
94
86
  for (const prop of props) {
95
- if (prop.value === undefined || findProp(prop.name) !== undefined)
87
+ if (prop.value === undefined || findProp(object, prop.name) !== undefined)
96
88
  continue;
97
89
  const expr = compileValueExpr(prop.value);
98
90
  if (expr.kind !== "regex" && expr.kind !== "enum")
@@ -104,13 +96,69 @@ const validateObject = (ctx, object, schema, level) => {
104
96
  tokens.splice(idx, 1);
105
97
  }
106
98
  }
99
+ /* report the tokens which no configured property accepts */
107
100
  for (const token of tokens)
108
101
  ctx.diagnose(meta.file, meta.line, `unassignable inline token "${token}" on ${object.kind} "${object.name}"`);
102
+ return tokenAssigned;
103
+ };
104
+ /* check a property value against the compiled value expression of its
105
+ configured schema property */
106
+ const checkPropValue = (ctx, prop, expr, property, meta) => {
107
+ if (expr.kind === "reference") {
108
+ /* a reference constraint: the value has to be exactly one
109
+ reference resolving into the constraint's wildcard match set */
110
+ if (!/^\[\[[^[\]]+\]\]$/.test(property.value.trim()))
111
+ ctx.diagnose(meta.file, meta.line, `property "${prop.name}" value "${property.value}" is not a single link reference`);
112
+ else {
113
+ const ref = property.value.trim().slice(2, -2).trim();
114
+ const target = resolveUnique(ctx.linkIndex, ref).target;
115
+ if (target !== undefined && !resolveSet(ctx.linkIndex, expr.pattern).includes(target))
116
+ ctx.diagnose(meta.file, meta.line, `link reference "[[${ref}]]" does not match constraint "${prop.value}"`);
117
+ }
118
+ }
119
+ else if (expr.kind === "tags") {
120
+ /* a tags constraint: the value is a comma-separated set of
121
+ configured tags, each occurring at most once */
122
+ const seen = new Set();
123
+ for (const item of splitItems(plainText(property.value))) {
124
+ if (!expr.members.includes(item))
125
+ ctx.diagnose(meta.file, meta.line, `tag "${item}" of property "${prop.name}" not allowed by constraint "${prop.value}"`);
126
+ else if (seen.has(item))
127
+ ctx.diagnose(meta.file, meta.line, `duplicate tag "${item}" on property "${prop.name}"`);
128
+ seen.add(item);
129
+ }
130
+ }
131
+ else if (expr.kind === "list") {
132
+ /* a list constraint: the value is a comma-separated list
133
+ of items, each matching at least one alternative */
134
+ for (const item of splitItems(property.value))
135
+ if (!matchAlternatives(ctx, expr.alternatives, item))
136
+ ctx.diagnose(meta.file, meta.line, `list item "${item}" of property "${prop.name}" does not match constraint "${prop.value}"`);
137
+ }
138
+ else if (!directMatches(expr, plainText(property.value)))
139
+ ctx.diagnose(meta.file, meta.line, expr.kind === "enum" ?
140
+ `property "${prop.name}" value "${property.value}" is not a member of "${prop.value}"` :
141
+ `property "${prop.name}" value "${property.value}" does not match pattern "${prop.value}"`);
142
+ };
143
+ /* validate a single object (and recursively its childs) against its schema */
144
+ const validateObject = (ctx, object, schema, level) => {
145
+ const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
146
+ const props = schema.props ?? [];
147
+ /* check the name convention (the configured name of non-artifact
148
+ objects is a regular expression pattern) */
149
+ if (level > 1 && schema.name !== undefined && !anchored(schema.name).test(plainText(object.name)))
150
+ ctx.diagnose(meta.file, meta.line, `${object.kind} name "${object.name}" does not match pattern "${schema.name}"`);
151
+ /* a configured id has to be explicitly specified 1:1 in the
152
+ input, via either "{{<id>}}" or "(<id>)" */
153
+ if (schema.id !== undefined && (object.anchor ?? object.paren) !== schema.id)
154
+ ctx.diagnose(meta.file, meta.line, `configured id "${schema.id}" not explicitly specified on ${object.kind} "${object.name}"`);
155
+ /* distribute the inline tokens split off the multi-token property values */
156
+ const tokenAssigned = assignInlineTokens(ctx, object, props, meta);
109
157
  /* check the configured properties */
110
158
  let parenConsumed = false;
111
159
  for (const prop of props) {
112
160
  const expr = prop.value !== undefined ? compileValueExpr(prop.value) : undefined;
113
- const match = findProp(prop.name);
161
+ const match = findProp(object, prop.name);
114
162
  if (match === undefined) {
115
163
  /* accept a trailing parenthesized name token as the value of a
116
164
  still missing property when it matches the property pattern */
@@ -122,45 +170,10 @@ const validateObject = (ctx, object, schema, level) => {
122
170
  if (prop.optional !== true)
123
171
  ctx.diagnose(meta.file, meta.line, `required property "${prop.name}" missing on ${object.kind} "${object.name}"`);
124
172
  }
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
- }
173
+ else if (expr !== undefined && !tokenAssigned.has(prop.name))
174
+ checkPropValue(ctx, prop, expr, match, { file: meta.file, line: ctx.propMeta.get(match)?.line ?? meta.line });
163
175
  }
176
+ /* report the properties not configured by the schema */
164
177
  for (const property of object.properties)
165
178
  if (!props.some((p) => p.name === plainKey(property.key)))
166
179
  ctx.diagnose(meta.file, ctx.propMeta.get(property)?.line ?? meta.line, `unknown property "${property.key}" on ${object.kind} "${object.name}"`);
@@ -179,6 +192,7 @@ const validateObject = (ctx, object, schema, level) => {
179
192
  }
180
193
  validateObject(ctx, child, childSchema, level + 1);
181
194
  }
195
+ /* report the configured child object kinds which are missing */
182
196
  for (const child of childs)
183
197
  if (child.optional !== true
184
198
  && !object.childs.some((c) => c.kind === child.kind))
@@ -204,7 +218,8 @@ export const validate = (ctx, specification, config) => {
204
218
  for (const object of artifact.objects) {
205
219
  const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
206
220
  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());
221
+ config.find((s) => s.name !== undefined
222
+ && s.name.toUpperCase() === plainText(object.name).toUpperCase());
208
223
  if (schema === undefined) {
209
224
  ctx.diagnose(meta.file, meta.line, `unknown artifact "${object.kind}: ${object.name}" (id "${object.id}")`);
210
225
  continue;