@rse/specbook 0.9.2 → 0.9.4

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.
@@ -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>
@@ -144,6 +146,32 @@ const templates = {
144
146
  {% endfor %}
145
147
  </tbody>
146
148
  </table>
149
+ `,
150
+ /* <TableChunked/> */
151
+ "TableChunked": textframe `
152
+ <table class="objects">
153
+ <thead>
154
+ <tr>
155
+ <th class="object-kind" style="width: {{ Table.width }}%">{{ Table.head }}</th>
156
+ <th class="description">Properties</th>
157
+ </tr>
158
+ </thead>
159
+ <tbody>
160
+ {% for row in Table.rows %}
161
+ <tr id="{{ row.id }}">
162
+ <td>{{ row.name }}{% if row.primary %} <span class="primary-marker">&#x2318;</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">&#x2693;&#xFE0E;</span></a></td>
163
+ <td class="chunks">
164
+ <table class="chunks">
165
+ {% for chunk in row.chunks %}
166
+ <tr>{% for cell in chunk %}<th{% if not cell.desc %} class="property-name"{% endif %}{% if cell.span > 1 %} colspan="{{ cell.span }}"{% endif %}>{{ cell.key }}</th>{% endfor %}</tr>
167
+ <tr>{% for cell in chunk %}<td{% if cell.span > 1 %} colspan="{{ cell.span }}"{% endif %}>{{ cell.value }}</td>{% endfor %}</tr>
168
+ {% endfor %}
169
+ </table>
170
+ </td>
171
+ </tr>
172
+ {% endfor %}
173
+ </tbody>
174
+ </table>
147
175
  `
148
176
  };
149
177
  /* ==== Rendering ==== */
@@ -168,6 +196,10 @@ const quoteStyles = {
168
196
  };
169
197
  /* the active double quote style (set per document during rendering) */
170
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();
171
203
  /* improve the typography of all rendered Markdown text (curly quotes,
172
204
  "--" as the em dash, ellipsis); as smartypants requires unescaped text,
173
205
  the extension switches off the text escaping of marked, so the stray
@@ -183,14 +215,19 @@ const env = new nunjucks.Environment(null, { autoescape: true });
183
215
  nunjucksAddons(env);
184
216
  /* mark pre-rendered HTML as safe for template interpolation */
185
217
  const safe = (html) => new nunjucks.runtime.SafeString(html);
218
+ /* render one of the built-in Nunjucks templates with a context */
186
219
  const render = (name, context) => env.renderString(templates[name], context);
187
220
  /* the active per-document reference expander, fully-qualified
188
- anchor paths, enum/tags property value kinds, and pre-rendered
189
- diagram SVGs (all set during HTML rendering) */
221
+ anchor paths, enum/tags property value kinds, object schema nodes,
222
+ and pre-rendered diagram SVGs (all set during HTML rendering) */
190
223
  let linker = null;
191
224
  let anchors = null;
192
225
  let members = null;
226
+ let schemas = null;
193
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();
194
231
  /* determine the fully-qualified anchor path of an object */
195
232
  const anchorOf = (object) => anchors?.get(object) ?? object.id;
196
233
  /* expand the inline Markdown of a text (code spans, emphasis, etc.),
@@ -252,10 +289,36 @@ const collectMembers = (schemas, result) => {
252
289
  }
253
290
  return result;
254
291
  };
292
+ /* map the specification objects onto their schema configuration
293
+ nodes (the schema resolution mirrors the semantic validation) */
294
+ const collectSchemas = (specification, config) => {
295
+ const result = new Map();
296
+ const walk = (object, schema) => {
297
+ result.set(object, schema);
298
+ for (const child of object.childs) {
299
+ const childSchema = (schema.childs ?? []).find((c) => c.kind === child.kind);
300
+ if (childSchema !== undefined)
301
+ walk(child, childSchema);
302
+ }
303
+ };
304
+ for (const artifact of specification.artifacts) {
305
+ for (const object of artifact.objects) {
306
+ const schema = config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
307
+ config.find((s) => (s.name ?? "").toUpperCase() ===
308
+ object.name.replace(/`/g, "").toUpperCase());
309
+ if (schema !== undefined)
310
+ walk(object, schema);
311
+ }
312
+ }
313
+ return result;
314
+ };
315
+ /* strip a trailing parenthesized annotation from a
316
+ property key (e.g. "WHEN (Context)") */
317
+ const plainKey = (key) => key.replace(/\s*\([^)]*\)\s*$/, "").trim();
255
318
  /* render a property value, badging the individual members of an
256
319
  "enum(...)" (a single member) or "tags(...)" (a member set) value */
257
320
  const inlineValue = (kind, key, value) => {
258
- const member = members?.get(`${kind}${key.replace(/\s*\([^)]*\)\s*$/, "").trim()}`);
321
+ const member = members?.get(`${kind}${plainKey(key)}`);
259
322
  if (member === undefined)
260
323
  return inline(value);
261
324
  const items = member === "tags" ? splitItems(value) : [value.trim()];
@@ -264,26 +327,54 @@ const inlineValue = (kind, key, value) => {
264
327
  /* expand the inline Markdown of the property values */
265
328
  const inlineProperties = (kind, properties) => properties.map((property) => ({ key: property.key,
266
329
  value: inlineValue(kind, property.key, property.value) }));
267
- /* determine the column shape of a potential compact table */
330
+ /* resolve the format configuration of an object */
331
+ const formatOf = (object) => schemas?.get(object)?.format;
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);
335
+ /* determine the effective properties of an object, with
336
+ "withUnusedProps" injecting the defined but still unused schema
337
+ properties (in schema order) as empty key/value entries */
338
+ const effectiveProperties = (object) => {
339
+ const schema = schemas?.get(object);
340
+ if (schema?.format?.withUnusedProps !== true)
341
+ return object.properties;
342
+ const merged = (schema.props ?? []).flatMap((prop) => {
343
+ const present = object.properties.filter((property) => plainKey(property.key) === prop.name);
344
+ return present.length > 0 ? present : [{ key: prop.name, value: "" }];
345
+ });
346
+ return [...merged, ...object.properties.filter((property) => !merged.includes(property))];
347
+ };
348
+ /* determine the column shape of a potential compact table, with
349
+ "withUnusedProps" injecting the defined but still unused schema
350
+ properties (in schema order) as additional columns */
268
351
  const tableShape = (childs) => {
269
- const keys = new Array();
352
+ let keys = new Array();
270
353
  for (const child of childs)
271
354
  for (const property of child.properties)
272
355
  if (!keys.includes(property.key))
273
356
  keys.push(property.key);
274
- return { keys, desc: childs.some((child) => child.description !== undefined) };
357
+ const schema = schemas?.get(childs[0]);
358
+ if (schema?.format?.withUnusedProps === true) {
359
+ const merged = (schema.props ?? []).flatMap((prop) => {
360
+ const present = keys.filter((key) => plainKey(key) === prop.name);
361
+ return present.length > 0 ? present : [prop.name];
362
+ });
363
+ keys = [...merged, ...keys.filter((key) => !merged.includes(key))];
364
+ }
365
+ return { keys, desc: childs.some((child) => child.description !== undefined || child.childs.length > 0) };
275
366
  };
276
- /* check whether the childs of an object form the deepest level and every
277
- per-kind group is compact enough (at most maxColumns name/property/
278
- description columns) for a tabular rendering */
279
- const tabularChilds = (object, maxColumns) => {
280
- if (object.childs.length === 0
281
- || !object.childs.every((child) => child.childs.length === 0))
367
+ /* decide whether the childs of an object collapse into the concise
368
+ (tabular) rendering: an explicit "format" type wins, "auto" collapses
369
+ the deepest level only, and inside an already concise rendering
370
+ context "auto" childs implicitly stay concise, too */
371
+ const conciseChilds = (object, schemaMap, concise) => {
372
+ if (object.childs.length === 0)
282
373
  return false;
283
- return groupChilds(object.childs).every((group) => {
284
- const { keys, desc } = tableShape(group);
285
- return 1 + keys.length + (desc ? 1 : 0) <= maxColumns;
286
- });
374
+ const type = schemaMap?.get(object)?.format?.type ?? "auto";
375
+ if (type !== "auto")
376
+ return type === "concise";
377
+ return concise || object.childs.every((child) => child.childs.length === 0);
287
378
  };
288
379
  /* group the childs of an object by their kind, preserving order */
289
380
  const groupChilds = (childs) => {
@@ -297,52 +388,103 @@ const groupChilds = (childs) => {
297
388
  }
298
389
  return [...groups.values()];
299
390
  };
300
- /* render a single-kind group of leaf childs into one compact table:
301
- the name first, then the property columns, then the description */
302
- const renderTable = (childs) => {
391
+ /* render the description cell of a table row: the description of the
392
+ object followed by its recursively rendered childs (implicitly or
393
+ explicitly concise childs as nested sub-tables, explicitly complex
394
+ ones as regular nested object renderings pressed into the cell) */
395
+ const renderCell = (child) => {
396
+ let html = child.description !== undefined ? renderDescription(child.description) : "";
397
+ if (child.childs.length > 0)
398
+ html += conciseChilds(child, schemas, true) ?
399
+ groupChilds(child.childs).map((group) => renderTable(group, maxColumnsOf(child))).join("") :
400
+ child.childs.map((sub) => renderObject(sub, 6, true)).join("");
401
+ return html;
402
+ };
403
+ /* render a single-kind group of childs into one compact table:
404
+ the name first, then the property columns, then the description;
405
+ a group wider than maxColumns instead chunks the property and
406
+ description cells of every object into an embedded per-object table */
407
+ const renderTable = (childs, maxColumns) => {
303
408
  const { keys, desc } = tableShape(childs);
304
- return render("Table", { Table: {
409
+ if (1 + keys.length + (desc ? 1 : 0) <= maxColumns)
410
+ return render("Table", { Table: {
411
+ head: childs[0].kind !== "" ? childs[0].kind : "Name",
412
+ keys,
413
+ desc,
414
+ /* under the fixed table layout the description column claims
415
+ twice the share of a regular column, compressing the others */
416
+ width: Math.round(200 / (keys.length + 3)),
417
+ rows: childs.map((child) => ({
418
+ id: anchorOf(child),
419
+ paren: child.paren,
420
+ primary: child.primary,
421
+ name: inline(child.name),
422
+ values: keys.map((key) => {
423
+ const value = child.properties.find((property) => property.key === key)?.value;
424
+ return value !== undefined ? inlineValue(child.kind, key, value) : "";
425
+ }),
426
+ description: safe(renderCell(child))
427
+ }))
428
+ } });
429
+ /* the embedded rows hold at most maxColumns - 1 cells (of the
430
+ group-wide union of property keys, plus the trailing description),
431
+ with the last cell spanning the leftover columns of the final row */
432
+ const size = Math.max(1, maxColumns - 1);
433
+ return render("TableChunked", { Table: {
305
434
  head: childs[0].kind !== "" ? childs[0].kind : "Name",
306
- keys,
307
- desc,
308
- /* under the fixed table layout the description column claims
309
- twice the share of a regular column, compressing the others */
310
- width: Math.round(200 / (keys.length + 3)),
311
- rows: childs.map((child) => ({
312
- id: anchorOf(child),
313
- paren: child.paren,
314
- primary: child.primary,
315
- name: inline(child.name),
316
- values: keys.map((key) => {
435
+ width: Math.round(100 / maxColumns),
436
+ rows: childs.map((child) => {
437
+ const cells = keys.map((key) => {
317
438
  const value = child.properties.find((property) => property.key === key)?.value;
318
- return value !== undefined ? inlineValue(child.kind, key, value) : "";
319
- }),
320
- description: child.description !== undefined ?
321
- safe(renderDescription(child.description)) : ""
322
- }))
439
+ return { key, desc: false, span: 1,
440
+ value: value !== undefined ? inlineValue(child.kind, key, value) : safe("") };
441
+ });
442
+ if (desc)
443
+ cells.push({ key: "Description", desc: true, span: 1,
444
+ value: safe(renderCell(child)) });
445
+ const chunks = new Array();
446
+ for (let i = 0; i < cells.length; i += size)
447
+ chunks.push(cells.slice(i, i + size));
448
+ const last = chunks[chunks.length - 1];
449
+ last[last.length - 1].span = size - last.length + 1;
450
+ return {
451
+ id: anchorOf(child),
452
+ paren: child.paren,
453
+ primary: child.primary,
454
+ name: inline(child.name),
455
+ chunks
456
+ };
457
+ })
323
458
  } });
324
459
  };
325
460
  /* recursively render an object into HTML */
326
- const renderObject = (object, level, maxColumns) => render("Object", { Object: {
327
- level: Math.min(level, 6),
328
- kind: object.kind,
329
- id: anchorOf(object),
330
- paren: object.paren,
331
- primary: object.primary,
332
- name: inline(object.name),
333
- diagram: diagrams?.has(object) === true ?
334
- safe(`<div class="diagram">${diagrams.get(object) ?? ""}</div>`) : "",
335
- properties: object.properties.length > 0 ?
336
- safe(render("Properties", { Properties: inlineProperties(object.kind, object.properties) })) : "",
337
- description: object.description !== undefined ?
338
- safe(renderDescription(object.description)) : "",
339
- childs: tabularChilds(object, maxColumns) ?
340
- safe(groupChilds(object.childs).map(renderTable).join("")) :
341
- safe(object.childs.map((child) => renderObject(child, level + 1, maxColumns)).join(""))
342
- } });
461
+ const renderObject = (object, level, concise) => {
462
+ const properties = effectiveProperties(object);
463
+ return render("Object", { Object: {
464
+ level: Math.min(level, 6),
465
+ kind: object.kind,
466
+ id: anchorOf(object),
467
+ paren: object.paren,
468
+ primary: object.primary,
469
+ name: inline(object.name),
470
+ diagram: diagrams?.has(object) === true ?
471
+ safe(`<div class="diagram">${diagrams.get(object) ?? ""}</div>`) : "",
472
+ properties: properties.length > 0 ?
473
+ safe(render("Properties", { Properties: inlineProperties(object.kind, properties) })) : "",
474
+ description: object.description !== undefined ?
475
+ safe(renderDescription(object.description)) : "",
476
+ childs: conciseChilds(object, schemas, concise) ?
477
+ safe(groupChilds(object.childs).map((group) => renderTable(group, maxColumnsOf(object))).join("")) :
478
+ safe(object.childs.map((child) => renderObject(child, level + 1, concise)).join(""))
479
+ } });
480
+ };
343
481
  /* render the title object into a title page */
344
482
  const renderTitlePage = (object, created, modified) => {
345
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
+ };
346
488
  const rest = object.properties.filter((property) => !["LOGO", "TITLE", "SUBTITLE", "AUTHOR", "VERSION",
347
489
  "LANG", "CHARSET", "PAPER-SIZE", "THEME-STYLE", "THEME-TONE"].includes(property.key));
348
490
  /* the logo is rendered above the title, from the embedded image content
@@ -354,9 +496,9 @@ const renderTitlePage = (object, created, modified) => {
354
496
  logo: logo === undefined ? safe(`<img src="${fallbackLogo()}" alt="SpecBook"/>`) :
355
497
  (image.length > 0 ? safe(image.join("")) : inline(logo.value)),
356
498
  title: inline(prop("TITLE") ?? object.name),
357
- subtitle: prop("SUBTITLE") !== undefined ? inline(prop("SUBTITLE") ?? "") : "",
358
- author: prop("AUTHOR") !== undefined ? inline(prop("AUTHOR") ?? "") : "",
359
- version: prop("VERSION") !== undefined ? inline(prop("VERSION") ?? "") : "",
499
+ subtitle: inlineProp("SUBTITLE"),
500
+ author: inlineProp("AUTHOR"),
501
+ version: inlineProp("VERSION"),
360
502
  description: object.description !== undefined ?
361
503
  safe(renderDescription(object.description)) : "",
362
504
  properties: rest.length > 0 ?
@@ -365,17 +507,18 @@ const renderTitlePage = (object, created, modified) => {
365
507
  } });
366
508
  };
367
509
  /* render an artifact into HTML */
368
- const renderArtifact = (artifact, maxColumns) => render("Artifact", { Artifact: {
369
- objects: safe(artifact.objects.map((object) => renderObject(object, 1, maxColumns)).join(""))
510
+ const renderArtifact = (artifact) => render("Artifact", { Artifact: {
511
+ objects: safe(artifact.objects.map((object) => renderObject(object, 1, false)).join(""))
370
512
  } });
371
513
  /* derive the hierarchy of the rendered object headings, skipping the
372
- title page object and the leaf childs collapsing into compact tables */
373
- export const htmlOutline = (specification, maxColumns) => {
514
+ title page object and the childs collapsing into compact tables */
515
+ export const htmlOutline = (specification, config) => {
374
516
  const paths = anchorPaths(buildLinkIndex(specification));
517
+ const schemaMap = config !== undefined ? collectSchemas(specification, config) : null;
375
518
  const entry = (object) => ({
376
519
  title: (object.kind !== "" ? `${object.kind}: ` : "") + object.name,
377
520
  anchor: paths.get(object) ?? object.id,
378
- childs: tabularChilds(object, maxColumns) ? [] : object.childs.map(entry)
521
+ childs: conciseChilds(object, schemaMap, false) ? [] : object.childs.map(entry)
379
522
  });
380
523
  return specification.artifacts
381
524
  .filter((artifact) => !artifact.objects.some(isTitleObject))
@@ -386,10 +529,7 @@ export const htmlOutline = (specification, maxColumns) => {
386
529
  with the build-time pre-assembled stylesheet embedded inline, the
387
530
  artifact timestamps aggregated into min(Created)/max(Modified), and
388
531
  optional per-anchor page numbers attached to the ToC entries */
389
- export const renderHtml = async (specification, maxColumns, config, tocPages, css) => {
390
- /* the document language selects the smart typography quote style */
391
- const lang = documentLang(specification);
392
- quotes = quoteStyles[lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
532
+ export const renderHtml = async (specification, config, tocPages, css) => {
393
533
  /* pre-render the configured diagrams as embeddable SVGs (a runtime
394
534
  rendering failure omits the diagram, as the statically detectable
395
535
  invalid situations are already reported as lint diagnostics),
@@ -397,8 +537,11 @@ export const renderHtml = async (specification, maxColumns, config, tocPages, cs
397
537
  (node boxes, font sizes) is dimensioned for a stand-alone
398
538
  canvas and would dwarf the document text at 1:1 */
399
539
  const scale = 0.75;
540
+ const cached = diagramCache.get(specification);
400
541
  diagrams = null;
401
- if (config !== undefined) {
542
+ if (cached !== undefined && cached.config === config)
543
+ diagrams = cached.diagrams;
544
+ else if (config !== undefined) {
402
545
  diagrams = new Map();
403
546
  for (const [object, result] of specDiagrams(specification, config)) {
404
547
  if (result.spec === undefined)
@@ -411,13 +554,18 @@ export const renderHtml = async (specification, maxColumns, config, tocPages, cs
411
554
  /* intentionally omitted */
412
555
  }
413
556
  }
557
+ diagramCache.set(specification, { config, diagrams });
414
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;
415
562
  /* expand "[[xxx]]" references into hyperlinks (an unresolvable or
416
563
  ambiguous reference stays literal, marked as broken), targeting
417
564
  the fully-qualified anchor paths of the objects */
418
565
  const index = buildLinkIndex(specification);
419
566
  anchors = anchorPaths(index);
420
567
  members = config !== undefined ? collectMembers(config, new Map()) : null;
568
+ schemas = config !== undefined ? collectSchemas(specification, config) : null;
421
569
  linker = (text) => expandReferences(text, (reference) => {
422
570
  const target = resolveUnique(index, reference).target;
423
571
  if (target === undefined)
@@ -447,6 +595,6 @@ export const renderHtml = async (specification, maxColumns, config, tocPages, cs
447
595
  safe(renderTitlePage(title, created.toISOString().slice(0, 10), modified.toISOString().slice(0, 10))) : "",
448
596
  search: title !== undefined ? safe(searchScript()) : "",
449
597
  toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
450
- artifacts: safe(artifacts.map((artifact) => renderArtifact(artifact, maxColumns)).join(""))
598
+ artifacts: safe(artifacts.map((artifact) => renderArtifact(artifact)).join(""))
451
599
  } });
452
600
  };
@@ -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",
@@ -70,14 +70,12 @@ export const serveMcp = async (verbose) => {
70
70
  config: z.string().optional().describe("YAML schema configuration file"),
71
71
  basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
72
72
  format: z.enum(formats).optional().describe("output format (default: json)"),
73
- output: z.string().optional().describe("output file path"),
74
- maxTableColumns: z.number().optional().describe("maximum name/property/description " +
75
- "columns for the compact table rendering (default: 4)")
73
+ output: z.string().optional().describe("output file path")
76
74
  }
77
75
  }, async (args) => {
78
76
  try {
79
77
  const [data] = await specbook.export({ config: args.config, basedir: args.basedir,
80
- formats: [args.format ?? "json"], maxTableColumns: args.maxTableColumns });
78
+ formats: [args.format ?? "json"] });
81
79
  if (args.output !== undefined) {
82
80
  await fs.promises.writeFile(args.output, data);
83
81
  return { content: [{ type: "text", text: `exported specification into "${args.output}" (${data.length} bytes)` }] };