@rse/specbook 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +199 -0
  2. package/dst/specbook-api.d.ts +47 -0
  3. package/dst/specbook-api.js +77 -0
  4. package/dst/specbook-cli.d.ts +2 -0
  5. package/dst/specbook-cli.js +156 -0
  6. package/dst/specbook-cmd-describe.d.ts +2 -0
  7. package/dst/specbook-cmd-describe.js +97 -0
  8. package/dst/specbook-cmd-edit.d.ts +9 -0
  9. package/dst/specbook-cmd-edit.js +69 -0
  10. package/dst/specbook-cmd-export.d.ts +9 -0
  11. package/dst/specbook-cmd-export.js +78 -0
  12. package/dst/specbook-cmd-import.d.ts +9 -0
  13. package/dst/specbook-cmd-import.js +73 -0
  14. package/dst/specbook-cmd-init.d.ts +7 -0
  15. package/dst/specbook-cmd-init.js +37 -0
  16. package/dst/specbook-cmd-lint.d.ts +15 -0
  17. package/dst/specbook-cmd-lint.js +46 -0
  18. package/dst/specbook-config.d.ts +6 -0
  19. package/dst/specbook-config.js +91 -0
  20. package/dst/specbook-diagnostic.d.ts +8 -0
  21. package/dst/specbook-diagnostic.js +23 -0
  22. package/dst/specbook-diagram.d.ts +9 -0
  23. package/dst/specbook-diagram.js +271 -0
  24. package/dst/specbook-export-ast.d.ts +4 -0
  25. package/dst/specbook-export-ast.js +36 -0
  26. package/dst/specbook-export-common.d.ts +33 -0
  27. package/dst/specbook-export-common.js +142 -0
  28. package/dst/specbook-export-html-search.js +272 -0
  29. package/dst/specbook-export-html.css +259 -0
  30. package/dst/specbook-export-html.d.ts +9 -0
  31. package/dst/specbook-export-html.js +452 -0
  32. package/dst/specbook-export-logo.svg +35 -0
  33. package/dst/specbook-export-md.d.ts +3 -0
  34. package/dst/specbook-export-md.js +74 -0
  35. package/dst/specbook-export-pdf.d.ts +7 -0
  36. package/dst/specbook-export-pdf.js +198 -0
  37. package/dst/specbook-link.d.ts +17 -0
  38. package/dst/specbook-link.js +113 -0
  39. package/dst/specbook-llm.d.ts +13 -0
  40. package/dst/specbook-llm.js +61 -0
  41. package/dst/specbook-mcp.d.ts +1 -0
  42. package/dst/specbook-mcp.js +163 -0
  43. package/dst/specbook-parse-common.d.ts +26 -0
  44. package/dst/specbook-parse-common.js +33 -0
  45. package/dst/specbook-parse-semantic.d.ts +5 -0
  46. package/dst/specbook-parse-semantic.js +254 -0
  47. package/dst/specbook-parse-syntax.d.ts +3 -0
  48. package/dst/specbook-parse-syntax.js +363 -0
  49. package/dst/specbook-parse-value.d.ts +19 -0
  50. package/dst/specbook-parse-value.js +123 -0
  51. package/dst/specbook-parse.d.ts +8 -0
  52. package/dst/specbook-parse.js +43 -0
  53. package/dst/specbook-struct-schema.d.ts +35 -0
  54. package/dst/specbook-struct-schema.js +47 -0
  55. package/dst/specbook-struct-spec.d.ts +31 -0
  56. package/dst/specbook-struct-spec.js +36 -0
  57. package/dst/specbook-theme.d.ts +16 -0
  58. package/dst/specbook-theme.js +38 -0
  59. package/package.json +77 -0
@@ -0,0 +1,198 @@
1
+ /*
2
+ ** Specification Book (SpecBook)
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import { escapeHtml, paperSetup, paperLength, paperSizeDefault } from "./specbook-export-common.js";
7
+ /* extract the per-anchor page numbers from a Chromium-generated PDF,
8
+ which records the internal link targets as named PDF destinations */
9
+ const anchorPages = async (pdf) => {
10
+ const { PDFDocument, PDFName, PDFDict, PDFArray, PDFRef } = await import("pdf-lib");
11
+ const doc = await PDFDocument.load(pdf);
12
+ const indexes = new Map();
13
+ doc.getPages().forEach((page, index) => indexes.set(page.ref.toString(), index + 1));
14
+ const pages = new Map();
15
+ const dests = doc.catalog.lookupMaybe(PDFName.of("Dests"), PDFDict);
16
+ if (dests !== undefined) {
17
+ for (const [name] of dests.entries()) {
18
+ const dest = dests.lookup(name);
19
+ const target = dest instanceof PDFArray ? dest.get(0) : undefined;
20
+ const page = target instanceof PDFRef ? indexes.get(target.toString()) : undefined;
21
+ if (page !== undefined)
22
+ pages.set(name.decodeText(), page);
23
+ }
24
+ }
25
+ return pages;
26
+ };
27
+ /* attach a hierarchical PDF outline (the "bookmarks" of the viewer
28
+ side-bar) to a document, resolving the entry anchors against the
29
+ named destinations and hoisting the childs of an unresolvable entry */
30
+ const addOutline = async (doc, entries) => {
31
+ const { PDFName, PDFDict, PDFArray, PDFRef, PDFHexString } = await import("pdf-lib");
32
+ const context = doc.context;
33
+ /* collect the still intact named destinations */
34
+ const live = new Set(doc.getPages().map((page) => page.ref.toString()));
35
+ const targets = new Map();
36
+ const dests = doc.catalog.lookupMaybe(PDFName.of("Dests"), PDFDict);
37
+ if (dests !== undefined) {
38
+ for (const [name] of dests.entries()) {
39
+ const dest = dests.lookup(name);
40
+ const page = dest instanceof PDFArray ? dest.get(0) : undefined;
41
+ if (dest instanceof PDFArray && page instanceof PDFRef && live.has(page.toString()))
42
+ targets.set(name.decodeText(), dest);
43
+ }
44
+ }
45
+ const resolve = (entries) => entries.flatMap((entry) => {
46
+ const dest = targets.get(entry.anchor);
47
+ const childs = resolve(entry.childs);
48
+ return dest !== undefined ? [{ title: entry.title, dest, childs }] : childs;
49
+ });
50
+ const items = resolve(entries);
51
+ if (items.length === 0)
52
+ return;
53
+ /* recursively materialize the outline items, wiring up the
54
+ Parent/Prev/Next/First/Last cross-references of the tree */
55
+ const materialize = (items, parent) => {
56
+ const refs = items.map(() => context.nextRef());
57
+ items.forEach((item, i) => {
58
+ const childs = item.childs.length > 0 ? materialize(item.childs, refs[i]) : undefined;
59
+ context.assign(refs[i], context.obj({
60
+ Title: PDFHexString.fromText(item.title),
61
+ Parent: parent,
62
+ Prev: i > 0 ? refs[i - 1] : undefined,
63
+ Next: i < refs.length - 1 ? refs[i + 1] : undefined,
64
+ First: childs?.first,
65
+ Last: childs?.last,
66
+ /* a negative count keeps the sub-tree initially collapsed */
67
+ Count: childs !== undefined ? -item.childs.length : undefined,
68
+ Dest: item.dest
69
+ }));
70
+ });
71
+ return { first: refs[0], last: refs[refs.length - 1] };
72
+ };
73
+ const root = context.nextRef();
74
+ const top = materialize(items, root);
75
+ context.assign(root, context.obj({
76
+ Type: "Outlines", First: top.first, Last: top.last, Count: items.length
77
+ }));
78
+ doc.catalog.set(PDFName.of("Outlines"), root);
79
+ doc.catalog.set(PDFName.of("PageMode"), PDFName.of("UseOutlines"));
80
+ };
81
+ /* render a self-contained HTML document into a PDF via Playwright,
82
+ re-rendering the HTML with the discovered ToC page numbers */
83
+ export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css, theme, paper = paperSizeDefault) => {
84
+ const { chromium } = await import("playwright");
85
+ /* the page geometry of the chosen paper size */
86
+ const setup = paperSetup(paper);
87
+ const margin = setup.margin;
88
+ /* the regular font face and the title/subtitle
89
+ text for the header/footer templates */
90
+ const fontFace = css.match(/@font-face\s*\{[^}]*\}/)?.[0] ?? "";
91
+ const headText = escapeHtml(heading.title) +
92
+ (heading.subtitle !== undefined ? ` &mdash; ${escapeHtml(heading.subtitle)}` : "");
93
+ /* launch the Playwright Chromium browser, falling back to a
94
+ system-installed Google Chrome if the Chromium download is missing */
95
+ let browser;
96
+ try {
97
+ browser = await chromium.launch();
98
+ }
99
+ catch (err) {
100
+ verbose("Playwright Chromium unavailable -- falling back to installed Google Chrome");
101
+ try {
102
+ browser = await chromium.launch({ channel: "chrome" });
103
+ }
104
+ catch {
105
+ /* report the original, more instructive error */
106
+ throw err;
107
+ }
108
+ }
109
+ try {
110
+ const page = await browser.newPage();
111
+ /* render an HTML document into a paginated PDF */
112
+ const renderPdf = async (html, options = {}) => {
113
+ await page.setContent(html, { waitUntil: "networkidle" });
114
+ await page.evaluate(() => document.fonts.ready);
115
+ return page.pdf({
116
+ format: paper,
117
+ margin: {
118
+ top: paperLength(setup, margin.top),
119
+ right: paperLength(setup, margin.right),
120
+ bottom: paperLength(setup, margin.bottom),
121
+ left: paperLength(setup, margin.left)
122
+ },
123
+ printBackground: true,
124
+ preferCSSPageSize: true,
125
+ ...options
126
+ });
127
+ };
128
+ /* determine the ToC page numbers via a fixpoint iteration:
129
+ paginate undecorated, extract the per-anchor pages, and
130
+ re-render until the HTML is stable, as the ToC page number
131
+ column itself can shift the pagination */
132
+ verbose("determining ToC page numbers");
133
+ let html = await renderHtmlPass();
134
+ let plain = await renderPdf(html);
135
+ for (let i = 0; i < 3; i++) {
136
+ const next = await renderHtmlPass(await anchorPages(plain));
137
+ if (next === html)
138
+ break;
139
+ html = next;
140
+ plain = await renderPdf(html);
141
+ }
142
+ const decorated = await renderPdf(html, {
143
+ /* the header/footer templates render in an isolated context,
144
+ so they need their own inline styling, the regular font face
145
+ embedded as a data: URI, and the special "title"/"pageNumber"
146
+ classes for the injected values */
147
+ displayHeaderFooter: true,
148
+ headerTemplate: `<style>${fontFace}</style>` +
149
+ "<div style=\"width: 100%; margin-top: 0.8cm;\">" +
150
+ `<div style="margin: 0 ${paperLength(setup, margin.left)}; ` +
151
+ "font-family: 'Source Sans 3', sans-serif; " +
152
+ `font-size: 8pt; color: ${theme.symbol}; border-bottom: 1px solid ${theme.border}; ` +
153
+ "padding-bottom: 1mm; display: flex; justify-content: space-between; " +
154
+ "align-items: flex-end;\">" +
155
+ `<span>${headText}</span>` +
156
+ `<img src="${heading.logo}" alt="" style="height: 3.5mm;"/>` +
157
+ "</div></div>",
158
+ footerTemplate: `<style>${fontFace}</style>` +
159
+ "<div style=\"width: 100%; margin-bottom: 0.8cm;\">" +
160
+ `<div style="margin: 0 ${paperLength(setup, margin.left)}; ` +
161
+ "font-family: 'Source Sans 3', sans-serif; " +
162
+ `font-size: 8pt; color: ${theme.text}; display: flex; ` +
163
+ `justify-content: space-between; border-top: 1px solid ${theme.border}; ` +
164
+ "padding-top: 1mm;\">" +
165
+ `<span style="color: ${theme.symbol}">${headText}</span>` +
166
+ `<span style="color: ${theme.muted}; font-weight: bold;" class="pageNumber"></span></div></div>`
167
+ });
168
+ /* a title page carries no header/footer: as Chromium decorates
169
+ all pages unconditionally, swap the first page of the
170
+ identically paginated undecorated document (left over from
171
+ the ToC page number iteration) into the decorated document
172
+ *in place*, which keeps the decorated document's link
173
+ destinations (and hence the internal hyperlinks) intact */
174
+ const { PDFDocument, rgb } = await import("pdf-lib");
175
+ const merged = await PDFDocument.load(decorated);
176
+ if (html.includes("class=\"titlepage\"")) {
177
+ const source = await PDFDocument.load(plain);
178
+ const [titlePage] = await merged.copyPages(source, [0]);
179
+ merged.removePage(0);
180
+ merged.insertPage(0, titlePage);
181
+ }
182
+ /* attach the document outline for the viewer side-bar */
183
+ verbose("attaching PDF outline (bookmarks)");
184
+ await addOutline(merged, outline);
185
+ /* draw the vertical brand bar onto the left edge of the
186
+ physical paper of every page, as Chromium clips print
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);
190
+ for (const pdfPage of merged.getPages())
191
+ pdfPage.drawRectangle({ x: 0, y: 0, width: 5.4,
192
+ height: pdfPage.getHeight(), color: rgb(r, g, b) });
193
+ return Buffer.from(await merged.save());
194
+ }
195
+ finally {
196
+ await browser.close();
197
+ }
198
+ };
@@ -0,0 +1,17 @@
1
+ import type { Specification, Object as SpecObject } from "./specbook-struct-spec.js";
2
+ export declare const referenceRegex: RegExp;
3
+ interface LinkNode {
4
+ object: SpecObject;
5
+ parent?: LinkNode;
6
+ }
7
+ export type LinkIndex = LinkNode[];
8
+ export interface LinkTarget {
9
+ target?: SpecObject;
10
+ ambiguous: boolean;
11
+ }
12
+ export declare const buildLinkIndex: (specification: Specification) => LinkIndex;
13
+ export declare const resolveSet: (index: LinkIndex, reference: string) => SpecObject[];
14
+ export declare const resolveUnique: (index: LinkIndex, reference: string) => LinkTarget;
15
+ export declare const anchorPaths: (index: LinkIndex) => Map<SpecObject, string>;
16
+ export declare const expandReferences: (text: string, replace: (reference: string) => string) => string;
17
+ export {};
@@ -0,0 +1,113 @@
1
+ /*
2
+ ** Specification Book (SpecBook)
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ /* the Wiki-style reference syntax ("[[xxx]]") */
7
+ export const referenceRegex = /\[\[([^[\]]+)\]\]/g;
8
+ /* build the resolution index over all objects of a specification */
9
+ export const buildLinkIndex = (specification) => {
10
+ const index = [];
11
+ const walk = (object, parent) => {
12
+ const node = { object, parent };
13
+ index.push(node);
14
+ for (const child of object.childs)
15
+ walk(child, node);
16
+ };
17
+ for (const artifact of specification.artifacts)
18
+ for (const object of artifact.objects)
19
+ walk(object);
20
+ return index;
21
+ };
22
+ /* split a text at a separator character, honoring double-quoted
23
+ sections (the quotes are retained for a later unquote()) */
24
+ const splitQuoted = (text, separator) => {
25
+ const parts = [""];
26
+ let quoted = false;
27
+ for (const char of text) {
28
+ if (char === "\"")
29
+ quoted = !quoted;
30
+ if (!quoted && char === separator)
31
+ parts.push("");
32
+ else
33
+ parts[parts.length - 1] += char;
34
+ }
35
+ return parts;
36
+ };
37
+ /* strip the optional surrounding double quotes of a part */
38
+ const unquote = (part) => part.replace(/^"(.*)"$/s, "$1");
39
+ /* match a name-or-id part against an object (with "*" wildcard) */
40
+ const matchesPart = (object, part) => part === "*"
41
+ || object.id === part
42
+ || object.anchor === part
43
+ || object.name.replace(/`/g, "") === part;
44
+ /* match a single reference segment ("id", "name", "KIND:name-or-id",
45
+ "KIND:*", or "*", each part optionally double-quoted to allow
46
+ spaces, dots, and colons) against a single object */
47
+ const matchesSegment = (object, segment) => {
48
+ const parts = splitQuoted(segment, ":");
49
+ if (parts.length >= 2)
50
+ return object.kind === unquote(parts[0].trim())
51
+ && matchesPart(object, unquote(parts.slice(1).join(":").trim()));
52
+ return matchesPart(object, unquote(segment.trim()));
53
+ };
54
+ /* split a reference into its hierarchical path segments,
55
+ honoring double-quoted parts */
56
+ const segmentsOf = (reference) => splitQuoted(reference, ".").map((segment) => segment.trim()).filter((segment) => segment !== "");
57
+ /* resolve a reference into the set of all matching objects: a path
58
+ matches object chains connected by direct parent-to-child steps,
59
+ with the leading segments up to the root freely omittable */
60
+ export const resolveSet = (index, reference) => {
61
+ const segments = segmentsOf(reference);
62
+ if (segments.length === 0)
63
+ return [];
64
+ return index.filter((node) => {
65
+ let current = node;
66
+ for (let i = segments.length - 1; i >= 0; i--) {
67
+ if (current === undefined || !matchesSegment(current.object, segments[i]))
68
+ return false;
69
+ current = current.parent;
70
+ }
71
+ return true;
72
+ }).map((node) => node.object);
73
+ };
74
+ /* resolve a reference into a unique target: a single segment tries the
75
+ ordered variants (1) id/anchor, (2) name, (3) "KIND:name-or-id", where
76
+ the first variant yielding matches decides (several matches are an
77
+ ambiguity), while a hierarchical path resolves via its full match set */
78
+ export const resolveUnique = (index, reference) => {
79
+ const segments = segmentsOf(reference);
80
+ let matches = [];
81
+ if (segments.length === 1) {
82
+ const segment = segments[0];
83
+ const plain = unquote(segment);
84
+ const variants = [
85
+ (object) => object.id === plain || object.anchor === plain,
86
+ (object) => object.name.replace(/`/g, "") === plain,
87
+ (object) => matchesSegment(object, segment)
88
+ ];
89
+ for (const variant of variants) {
90
+ matches = index.filter((node) => variant(node.object)).map((node) => node.object);
91
+ if (matches.length > 0)
92
+ break;
93
+ }
94
+ }
95
+ else
96
+ matches = resolveSet(index, reference);
97
+ return { target: matches.length === 1 ? matches[0] : undefined, ambiguous: matches.length > 1 };
98
+ };
99
+ /* derive the fully-qualified anchor path of every object: the
100
+ "<kind>-<id>" segments (whitespace in kinds dashed) of the ancestor
101
+ chain joined with "." (the index lists parents before childs) */
102
+ export const anchorPaths = (index) => {
103
+ const paths = new Map();
104
+ for (const node of index) {
105
+ const kind = node.object.kind.replace(/\s+/g, "-");
106
+ const segment = kind !== "" ? `${kind}-${node.object.id}` : node.object.id;
107
+ const parent = node.parent !== undefined ? paths.get(node.parent.object) : undefined;
108
+ paths.set(node.object, parent !== undefined ? `${parent}.${segment}` : segment);
109
+ }
110
+ return paths;
111
+ };
112
+ /* expand all references of a text via a per-reference replacer */
113
+ export const expandReferences = (text, replace) => text.replace(referenceRegex, (_, reference) => replace(reference.trim()));
@@ -0,0 +1,13 @@
1
+ import { type LanguageModel } from "ai";
2
+ export interface AiOptions {
3
+ provider?: string;
4
+ model?: string;
5
+ }
6
+ export declare const resolveModel: (options: AiOptions) => Promise<LanguageModel>;
7
+ export declare const complete: (options: AiOptions, system: string, prompt: string) => Promise<string>;
8
+ export declare const timestamp: () => string;
9
+ export declare const renderFileBlock: (name: string, content: string) => string;
10
+ export declare const parseFileBlocks: (text: string) => {
11
+ name: string;
12
+ content: string;
13
+ }[];
@@ -0,0 +1,61 @@
1
+ /*
2
+ ** Specification Book (SpecBook)
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
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"
13
+ };
14
+ /* resolve the AI provider and model into a language model handle */
15
+ 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\")");
37
+ };
38
+ /* perform a single LLM completion */
39
+ export const complete = async (options, system, prompt) => {
40
+ const model = await resolveModel(options);
41
+ const result = await generateText({ model, system, prompt });
42
+ return result.text;
43
+ };
44
+ /* the current time in the frontmatter timestamp format */
45
+ export const timestamp = () => {
46
+ const d = new Date();
47
+ const pad = (n) => String(n).padStart(2, "0");
48
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
49
+ `${pad(d.getHours())}:${pad(d.getMinutes())}`;
50
+ };
51
+ /* render a named file as a block of the LLM file exchange protocol */
52
+ export const renderFileBlock = (name, content) => `<<<FILE: ${name}>>>\n${content.replace(/\n$/, "")}\n<<<END-FILE>>>`;
53
+ /* parse all file blocks of the LLM file exchange protocol */
54
+ export const parseFileBlocks = (text) => {
55
+ const blocks = new Array();
56
+ const re = /<<<FILE:\s*([^>]+?)\s*>>>\r?\n([\s\S]*?)<<<END-FILE>>>/g;
57
+ let m;
58
+ while ((m = re.exec(text)) !== null)
59
+ blocks.push({ name: m[1], content: m[2].replace(/\n*$/, "\n") });
60
+ return blocks;
61
+ };
@@ -0,0 +1 @@
1
+ export declare const serveMcp: (verbose: (msg: string) => void) => Promise<void>;
@@ -0,0 +1,163 @@
1
+ /*
2
+ ** Specification Book (SpecBook)
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import * as fs from "node:fs";
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { z } from "zod";
10
+ import { SpecBook, renderDiagnostic, formats } from "./specbook-api.js";
11
+ /* render an error with its cause chain into a tool error result */
12
+ const errorResult = (err) => {
13
+ let msg = err instanceof Error ? err.message : String(err);
14
+ for (let cause = err instanceof Error ? err.cause : undefined; cause instanceof Error; cause = cause.cause)
15
+ msg += `: ${cause.message}`;
16
+ return {
17
+ isError: true,
18
+ content: [{ type: "text", text: `ERROR: ${msg}` }]
19
+ };
20
+ };
21
+ /* serve the SpecBook functionality as "specbook_<cmd>" MCP tools over stdio */
22
+ export const serveMcp = async (verbose) => {
23
+ const server = new McpServer({ name: "specbook", version: "0.9.0" });
24
+ const specbook = new SpecBook({ verbose });
25
+ server.registerTool("specbook_init", {
26
+ title: "Initialize Specification",
27
+ description: "Initialize the configured specification artifact files below the base directory " +
28
+ "with their frontmatter and artifact heading, skipping already existing files.",
29
+ inputSchema: {
30
+ config: z.string().describe("YAML schema configuration file"),
31
+ basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")")
32
+ }
33
+ }, async (args) => {
34
+ try {
35
+ const created = await specbook.init(args);
36
+ return { content: [{ type: "text", text: created.length > 0 ?
37
+ `initialized artifact file(s): ${created.join(", ")}` :
38
+ "no artifact files were created" }] };
39
+ }
40
+ catch (err) {
41
+ return errorResult(err);
42
+ }
43
+ });
44
+ server.registerTool("specbook_lint", {
45
+ title: "Lint Specification",
46
+ description: "Lint the specification Markdown files below the base directory against the " +
47
+ "optional YAML schema configuration and return all diagnostics.",
48
+ inputSchema: {
49
+ config: z.string().optional().describe("YAML schema configuration file"),
50
+ basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")")
51
+ }
52
+ }, async (args) => {
53
+ try {
54
+ const result = await specbook.lint(args);
55
+ const text = result.diagnostics.length > 0 ?
56
+ result.diagnostics.map(renderDiagnostic).join("\n") :
57
+ "specification valid";
58
+ return { content: [{ type: "text", text }] };
59
+ }
60
+ catch (err) {
61
+ return errorResult(err);
62
+ }
63
+ });
64
+ server.registerTool("specbook_export", {
65
+ title: "Export Specification",
66
+ description: "Export the specification Markdown files below the base directory as JSON, JSON5, " +
67
+ "YAML, TOON, HTML, PDF, or normalized Markdown. The result is written to the output file " +
68
+ "if an output path is given, else it is returned directly (PDF as a base64-encoded resource).",
69
+ inputSchema: {
70
+ config: z.string().optional().describe("YAML schema configuration file"),
71
+ basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
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)")
76
+ }
77
+ }, async (args) => {
78
+ try {
79
+ const [data] = await specbook.export({ config: args.config, basedir: args.basedir,
80
+ formats: [args.format ?? "json"], maxTableColumns: args.maxTableColumns });
81
+ if (args.output !== undefined) {
82
+ await fs.promises.writeFile(args.output, data);
83
+ return { content: [{ type: "text", text: `exported specification into "${args.output}" (${data.length} bytes)` }] };
84
+ }
85
+ else if ((args.format ?? "json") === "pdf")
86
+ return { content: [{
87
+ type: "resource",
88
+ resource: { uri: "specbook:export.pdf", mimeType: "application/pdf", blob: data.toString("base64") }
89
+ }] };
90
+ else
91
+ return { content: [{ type: "text", text: data.toString("utf8") }] };
92
+ }
93
+ catch (err) {
94
+ return errorResult(err);
95
+ }
96
+ });
97
+ server.registerTool("specbook_describe", {
98
+ title: "Describe Specification Format",
99
+ description: "Describe the specification format configured by the YAML schema configuration " +
100
+ "as a Markdown document.",
101
+ inputSchema: {
102
+ config: z.string().describe("YAML schema configuration file"),
103
+ output: z.string().optional().describe("output file path (default: return description)")
104
+ }
105
+ }, async (args) => {
106
+ try {
107
+ const text = await specbook.describe(args);
108
+ if (args.output !== undefined) {
109
+ await fs.promises.writeFile(args.output, text, "utf8");
110
+ return { content: [{ type: "text", text: `described specification format into "${args.output}"` }] };
111
+ }
112
+ return { content: [{ type: "text", text }] };
113
+ }
114
+ catch (err) {
115
+ return errorResult(err);
116
+ }
117
+ });
118
+ server.registerTool("specbook_import", {
119
+ title: "Import Foreign Sources",
120
+ description: "Import the information of foreign source files into the configured specification " +
121
+ "artifact files below the base directory, using an LLM.",
122
+ inputSchema: {
123
+ config: z.string().describe("YAML schema configuration file"),
124
+ basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
125
+ inputs: z.array(z.string()).describe("foreign source files to import"),
126
+ provider: z.string().optional().describe("AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")"),
127
+ model: z.string().optional().describe("AI model")
128
+ }
129
+ }, async (args) => {
130
+ try {
131
+ const written = await specbook.import(args);
132
+ return { content: [{ type: "text", text: written.length > 0 ?
133
+ `imported into artifact file(s): ${written.join(", ")}` :
134
+ "no artifact files were changed" }] };
135
+ }
136
+ catch (err) {
137
+ return errorResult(err);
138
+ }
139
+ });
140
+ server.registerTool("specbook_edit", {
141
+ title: "Edit Specification",
142
+ description: "Apply a free-text edit request to the configured specification artifact files " +
143
+ "below the base directory, using an LLM.",
144
+ inputSchema: {
145
+ config: z.string().describe("YAML schema configuration file"),
146
+ basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
147
+ query: z.string().describe("free-text edit request"),
148
+ provider: z.string().optional().describe("AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")"),
149
+ model: z.string().optional().describe("AI model")
150
+ }
151
+ }, async (args) => {
152
+ try {
153
+ const written = await specbook.edit(args);
154
+ return { content: [{ type: "text", text: written.length > 0 ?
155
+ `edited artifact file(s): ${written.join(", ")}` :
156
+ "no artifact files were changed" }] };
157
+ }
158
+ catch (err) {
159
+ return errorResult(err);
160
+ }
161
+ });
162
+ await server.connect(new StdioServerTransport());
163
+ };
@@ -0,0 +1,26 @@
1
+ import { type Specification, type Object as SpecObject, type Property } from "./specbook-struct-spec.js";
2
+ import { type Diagnostic } from "./specbook-diagnostic.js";
3
+ import { type LinkIndex } from "./specbook-link.js";
4
+ export interface SourceFile {
5
+ file: string;
6
+ text: string;
7
+ }
8
+ export interface ParseResult {
9
+ specification: Specification;
10
+ diagnostics: Diagnostic[];
11
+ }
12
+ export interface ObjectMeta {
13
+ file: string;
14
+ line: number;
15
+ }
16
+ export declare class ParseContext {
17
+ diagnostics: Diagnostic[];
18
+ objectMeta: WeakMap<SpecObject, ObjectMeta>;
19
+ propMeta: WeakMap<Property, {
20
+ line: number;
21
+ }>;
22
+ linkIndex: LinkIndex;
23
+ diagnose(file: string, line: number, message: string): void;
24
+ }
25
+ export declare const embeddingRegex: RegExp;
26
+ export declare const embeddingMimeType: (reference: string) => string | undefined;
@@ -0,0 +1,33 @@
1
+ /*
2
+ ** Specification Book (SpecBook)
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ /* the state shared between the syntactic and semantic parsing phases */
7
+ export class ParseContext {
8
+ diagnostics = new Array();
9
+ objectMeta = new WeakMap();
10
+ propMeta = new WeakMap();
11
+ linkIndex = [];
12
+ /* record a single diagnostic */
13
+ diagnose(file, line, message) {
14
+ this.diagnostics.push({ file, line, column: 1, message });
15
+ }
16
+ }
17
+ /* the Markdown image embedding syntax ("![alt](file)") */
18
+ export const embeddingRegex = /!\[([^\]]*)\]\(([^()]+)\)/g;
19
+ /* the embeddable file types and their MIME types */
20
+ const embeddingTypes = {
21
+ svg: "image/svg+xml",
22
+ png: "image/png",
23
+ jpg: "image/jpeg",
24
+ jpeg: "image/jpeg"
25
+ };
26
+ /* map a local image embedding reference onto its MIME type
27
+ (URLs and other file types are not embeddable) */
28
+ export const embeddingMimeType = (reference) => {
29
+ if (/^[a-z][a-z0-9+.-]+:/i.test(reference))
30
+ return undefined;
31
+ const extension = reference.match(/\.([a-z0-9]+)$/i)?.[1].toLowerCase();
32
+ return extension !== undefined ? embeddingTypes[extension] : undefined;
33
+ };
@@ -0,0 +1,5 @@
1
+ import { type Specification } from "./specbook-struct-spec.js";
2
+ import { type SchemaSpecification } from "./specbook-struct-schema.js";
3
+ import { ParseContext } from "./specbook-parse-common.js";
4
+ export declare const validate: (ctx: ParseContext, specification: Specification, config: SchemaSpecification) => void;
5
+ export declare const validateReferences: (ctx: ParseContext, specification: Specification) => void;