@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.
- package/README.md +195 -14
- package/dst/specbook-api.d.ts +1 -1
- package/dst/specbook-api.js +11 -2
- package/dst/specbook-cli.js +15 -16
- package/dst/specbook-cmd-describe.js +3 -1
- package/dst/specbook-cmd-edit.js +6 -1
- package/dst/specbook-cmd-export.d.ts +1 -1
- package/dst/specbook-cmd-export.js +6 -5
- 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 -43
- 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 +21 -0
- package/dst/specbook-export-html.d.ts +2 -2
- package/dst/specbook-export-html.js +218 -70
- 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 +4 -6
- 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.d.ts +7 -0
- package/dst/specbook-struct-schema.js +10 -3
- package/dst/specbook-theme.js +1 -1
- package/package.json +4 -4
|
@@ -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>
|
|
@@ -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">⌘</span>{% endif %}{% if row.paren %} <span class="anchor-paren">({{ row.paren }})</span>{% endif %} <a href="#{{ row.id }}"><span class="anchor-symbol">⚓︎</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,
|
|
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
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
return
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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,
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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:
|
|
358
|
-
author:
|
|
359
|
-
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
|
|
369
|
-
objects: safe(artifact.objects.map((object) => renderObject(object, 1,
|
|
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
|
|
373
|
-
export const htmlOutline = (specification,
|
|
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:
|
|
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,
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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",
|
|
@@ -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"]
|
|
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)` }] };
|