@rse/specbook 0.9.3 → 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.
- package/README.md +4 -4
- package/dst/specbook-api.d.ts +1 -0
- package/dst/specbook-api.js +10 -1
- package/dst/specbook-cli.js +14 -14
- package/dst/specbook-cmd-describe.js +3 -1
- package/dst/specbook-cmd-edit.js +6 -1
- package/dst/specbook-cmd-export.js +3 -2
- package/dst/specbook-cmd-import.js +6 -1
- package/dst/specbook-cmd-init.js +4 -2
- package/dst/specbook-cmd-lint.js +4 -2
- package/dst/specbook-config.js +22 -7
- package/dst/specbook-diagram.js +67 -57
- package/dst/specbook-export-ast.js +5 -2
- package/dst/specbook-export-common.d.ts +0 -1
- package/dst/specbook-export-common.js +32 -19
- package/dst/specbook-export-html-search.js +6 -4
- package/dst/specbook-export-html.css +7 -0
- package/dst/specbook-export-html.js +32 -13
- package/dst/specbook-export-md.js +10 -6
- package/dst/specbook-export-pdf.js +4 -3
- package/dst/specbook-llm.js +18 -28
- package/dst/specbook-mcp.js +2 -2
- package/dst/specbook-parse-semantic.js +73 -58
- package/dst/specbook-parse-syntax.js +17 -16
- package/dst/specbook-parse-value.js +7 -1
- package/dst/specbook-parse.js +4 -0
- package/dst/specbook-struct-schema.js +5 -5
- package/dst/specbook-theme.js +1 -1
- package/package.json +2 -2
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
9
|
+
/* escape a text for embedding into template HTML (text and attributes) */
|
|
10
|
+
export const escapeHtml = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
11
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
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.
|
|
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
|
-
|
|
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
|
|
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()
|
|
50
|
-
|
|
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
|
|
55
|
-
|
|
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)} -
|
|
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-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
137
|
-
|
|
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
|
|
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 {
|
|
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") }
|
|
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) }
|
|
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
|
|
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
|
-
|
|
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:
|
|
485
|
-
author:
|
|
486
|
-
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 (
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
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) =>
|
|
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
|
|
189
|
-
|
|
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) });
|
package/dst/specbook-llm.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
openai: "gpt-5.6-luna",
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
17
|
-
const
|
|
18
|
-
if (provider ===
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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;
|
package/dst/specbook-mcp.js
CHANGED
|
@@ -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
|
|
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
|
-
/*
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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) =>
|
|
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;
|