@rse/specbook 1.2.14 → 1.2.16
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 +98 -11
- package/dst/specbook-api.d.ts +22 -21
- package/dst/specbook-api.js +80 -27
- package/dst/specbook-cli.js +50 -23
- package/dst/specbook-cmd-describe.js +8 -4
- package/dst/specbook-cmd-export.d.ts +4 -1
- package/dst/specbook-cmd-export.js +22 -12
- package/dst/specbook-cmd-init.js +2 -8
- package/dst/specbook-cmd-lint.d.ts +2 -1
- package/dst/specbook-cmd-lint.js +31 -28
- package/dst/specbook-config.d.ts +7 -0
- package/dst/specbook-config.js +6 -1
- package/dst/specbook-coverage.js +3 -4
- package/dst/specbook-diagram.d.ts +3 -1
- package/dst/specbook-diagram.js +78 -27
- package/dst/specbook-export-ast.d.ts +2 -1
- package/dst/specbook-export-ast.js +16 -8
- package/dst/specbook-export-common.d.ts +7 -0
- package/dst/specbook-export-common.js +36 -3
- package/dst/specbook-export-html-search.js +27 -24
- package/dst/specbook-export-html.css +64 -18
- package/dst/specbook-export-html.d.ts +2 -1
- package/dst/specbook-export-html.js +519 -277
- package/dst/specbook-export-image.d.ts +3 -0
- package/dst/specbook-export-image.js +121 -0
- package/dst/specbook-export-md.d.ts +8 -2
- package/dst/specbook-export-md.js +67 -6
- package/dst/specbook-export-pdf.js +7 -4
- package/dst/specbook-format.md +35 -8
- package/dst/specbook-format.yaml +12 -9
- package/dst/specbook-ignore.js +5 -5
- package/dst/specbook-link.js +1 -1
- package/dst/specbook-mcp.js +51 -34
- package/dst/specbook-parse-common.d.ts +4 -1
- package/dst/specbook-parse-common.js +15 -3
- package/dst/specbook-parse-semantic.d.ts +1 -1
- package/dst/specbook-parse-syntax.d.ts +1 -1
- package/dst/specbook-parse-syntax.js +28 -8
- package/dst/specbook-parse.js +6 -2
- package/dst/specbook-project.d.ts +7 -0
- package/dst/specbook-project.js +93 -0
- package/dst/specbook-theme.d.ts +1 -1
- package/dst/specbook-theme.js +9 -8
- package/dst/specbook-verbose.js +4 -9
- package/package.json +14 -6
package/dst/specbook-mcp.js
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
7
8
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
10
|
import { z } from "zod";
|
|
10
|
-
import { SpecBook, renderDiagnostic, renderVerbose, formats, parseOutputSpec, describeFormats, describeParts, compressLevels, version } from "./specbook-api.js";
|
|
11
|
+
import { SpecBook, renderDiagnostic, renderVerbose, formats, parseOutputSpec, describeFormats, describeParts, compressLevels, projectFile, omitAspects, version } from "./specbook-api.js";
|
|
11
12
|
/* render an error with its cause chain into a tool error result */
|
|
12
13
|
const errorResult = (err) => {
|
|
13
14
|
let msg = err instanceof Error ? err.message : String(err);
|
|
@@ -22,16 +23,31 @@ const errorResult = (err) => {
|
|
|
22
23
|
export const serveMcp = async (verbose) => {
|
|
23
24
|
const server = new McpServer({ name: "specbook", version });
|
|
24
25
|
const specbook = new SpecBook({ verbose });
|
|
26
|
+
/* the input schema fields shared by the specification processing tools */
|
|
27
|
+
const config = z.array(z.string()).optional().describe("YAML schema configuration files or glob " +
|
|
28
|
+
"patterns, merged in order (\"std\" for the bundled standard schema configuration; " +
|
|
29
|
+
"default: the SPECBOOK_CONFIG environment variable, else the \"config\" entry " +
|
|
30
|
+
"of the project configuration file, else the bundled standard " +
|
|
31
|
+
"schema configuration)");
|
|
32
|
+
const basedir = z.string().optional().describe("base directory of the specification Markdown files " +
|
|
33
|
+
"(default: the SPECBOOK_BASEDIR environment variable, else the \"basedir\" entry " +
|
|
34
|
+
"of the project configuration file, else \".\")");
|
|
35
|
+
const gitignore = z.boolean().optional().describe("skip the artifact files excluded by the Git " +
|
|
36
|
+
"exclude rules (the \".gitignore\" files, \"info/exclude\", and the global excludes " +
|
|
37
|
+
"file), treating such a file exactly like an absent one (default: false)");
|
|
38
|
+
/* the working directory of the client, which this server does not
|
|
39
|
+
necessarily share: it anchors the relative paths and starts the
|
|
40
|
+
upward search for the project configuration file */
|
|
41
|
+
const cwd = z.string().optional().describe("absolute working directory of the caller, against which " +
|
|
42
|
+
"the relative paths are resolved and from which the project configuration file " +
|
|
43
|
+
`"${projectFile}" is searched upwards (default: the working directory of the server)`);
|
|
44
|
+
/* anchor a relative output file path at the working directory of the caller */
|
|
45
|
+
const outputOf = (args, output) => args.cwd !== undefined ? path.resolve(args.cwd, output) : output;
|
|
25
46
|
server.registerTool("specbook_init", {
|
|
26
47
|
title: "Initialize Specification",
|
|
27
48
|
description: "Initialize the configured specification artifact files below the base directory " +
|
|
28
49
|
"with their frontmatter and artifact heading, skipping already existing files.",
|
|
29
|
-
inputSchema: {
|
|
30
|
-
config: z.array(z.string()).optional().describe("YAML schema configuration files or glob " +
|
|
31
|
-
"patterns, merged in order (\"std\" for the bundled standard schema configuration; " +
|
|
32
|
-
"default: the bundled standard schema configuration)"),
|
|
33
|
-
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")")
|
|
34
|
-
}
|
|
50
|
+
inputSchema: { config, basedir, cwd }
|
|
35
51
|
}, async (args) => {
|
|
36
52
|
try {
|
|
37
53
|
const created = await specbook.init(args);
|
|
@@ -47,15 +63,7 @@ export const serveMcp = async (verbose) => {
|
|
|
47
63
|
title: "Lint Specification",
|
|
48
64
|
description: "Lint the specification Markdown files the YAML schema configuration references " +
|
|
49
65
|
"below the base directory against this configuration and return all diagnostics.",
|
|
50
|
-
inputSchema: {
|
|
51
|
-
config: z.array(z.string()).optional().describe("YAML schema configuration files or glob " +
|
|
52
|
-
"patterns, merged in order (\"std\" for the bundled standard schema configuration; " +
|
|
53
|
-
"default: the bundled standard schema configuration)"),
|
|
54
|
-
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
|
|
55
|
-
gitignore: z.boolean().optional().describe("skip the artifact files excluded by the Git " +
|
|
56
|
-
"exclude rules (the \".gitignore\" files, \"info/exclude\", and the global excludes " +
|
|
57
|
-
"file), treating such a file exactly like an absent one (default: false)")
|
|
58
|
-
}
|
|
66
|
+
inputSchema: { config, basedir, gitignore, cwd }
|
|
59
67
|
}, async (args) => {
|
|
60
68
|
try {
|
|
61
69
|
const result = await specbook.lint(args);
|
|
@@ -73,18 +81,17 @@ export const serveMcp = async (verbose) => {
|
|
|
73
81
|
description: "Export the specification Markdown files the YAML schema configuration references " +
|
|
74
82
|
"below the base directory as JSON, JSON5, " +
|
|
75
83
|
"YAML, TOON, HTML, PDF, or normalized Markdown. The result is written to the output file " +
|
|
76
|
-
"if an output path is given, else it is returned directly (PDF as a base64-encoded resource)."
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
basedir: z.string().optional().describe("base directory of the specification Markdown files (default: \".\")"),
|
|
84
|
+
"if an output path is given, else it is returned directly (PDF as a base64-encoded resource). " +
|
|
85
|
+
"The normalized Markdown embeds its images as \"data:\" URL definitions (so it is large), and " +
|
|
86
|
+
"its remaining image references are re-based onto the directory of the " +
|
|
87
|
+
"output file, while a directly returned result keeps them unchanged.",
|
|
88
|
+
inputSchema: { config, basedir, gitignore, cwd,
|
|
82
89
|
format: z.enum(formats).optional().describe("output format (default: inferred from the " +
|
|
83
90
|
"output file extension, else json)"),
|
|
84
91
|
output: z.string().optional().describe("output file path (\"-\" or omitted returns the result directly)"),
|
|
85
|
-
|
|
86
|
-
"
|
|
87
|
-
|
|
92
|
+
omit: z.array(z.string()).optional().describe("content aspects to omit from the HTML, " +
|
|
93
|
+
"the PDF, and (the diagrams) the AST output, instead of leaving them foldable: \"diagram\" " +
|
|
94
|
+
`(all diagrams) or "${omitAspects.join("\", \"")}" (default: none)`)
|
|
88
95
|
}
|
|
89
96
|
}, async (args) => {
|
|
90
97
|
try {
|
|
@@ -94,10 +101,15 @@ export const serveMcp = async (verbose) => {
|
|
|
94
101
|
const spec = args.format !== undefined || args.output === undefined ?
|
|
95
102
|
{ format: args.format ?? "json", output: args.output } :
|
|
96
103
|
parseOutputSpec(args.output);
|
|
104
|
+
/* the image references of the normalized Markdown are re-based
|
|
105
|
+
onto the directory of the output file (anchored by the API) */
|
|
106
|
+
const rebase = spec.format === "md" && spec.output !== undefined && spec.output !== "-" ?
|
|
107
|
+
path.dirname(spec.output) : undefined;
|
|
97
108
|
const [data] = await specbook.export({ config: args.config, basedir: args.basedir,
|
|
98
|
-
formats: [spec.format],
|
|
109
|
+
cwd: args.cwd, formats: [spec.format], rebase: [rebase], omit: args.omit,
|
|
110
|
+
gitignore: args.gitignore });
|
|
99
111
|
if (spec.output !== undefined && spec.output !== "-") {
|
|
100
|
-
await fs.promises.writeFile(spec.output, data);
|
|
112
|
+
await fs.promises.writeFile(outputOf(args, spec.output), data);
|
|
101
113
|
return { content: [{ type: "text", text: `exported specification into "${spec.output}" (${data.length} bytes)` }] };
|
|
102
114
|
}
|
|
103
115
|
else if (spec.format === "pdf")
|
|
@@ -122,27 +134,32 @@ export const serveMcp = async (verbose) => {
|
|
|
122
134
|
inputSchema: {
|
|
123
135
|
config: z.array(z.string()).optional().describe("YAML schema configuration files or glob " +
|
|
124
136
|
"patterns, merged in order (\"std\" for the bundled standard schema configuration; " +
|
|
125
|
-
"default: the
|
|
126
|
-
|
|
137
|
+
"default: the SPECBOOK_CONFIG environment variable, else the \"config\" entry " +
|
|
138
|
+
"of the project configuration file, else the bundled standard " +
|
|
139
|
+
"schema configuration, embedded)"),
|
|
140
|
+
basedir: z.string().optional().describe("base directory of the specification Markdown files " +
|
|
141
|
+
"(default: the SPECBOOK_BASEDIR environment variable, else the \"basedir\" entry " +
|
|
142
|
+
"of the project configuration file)"),
|
|
127
143
|
embed: z.boolean().optional().describe("embed the given YAML schema configuration instead " +
|
|
128
144
|
"of just referencing it (default: false; the bundled standard one is always embedded)"),
|
|
129
145
|
compress: z.literal(compressLevels).optional().describe("compression level of the emitted " +
|
|
130
146
|
"YAML schema configuration (embedded or raw): 0 for verbatim, 1 for re-emitted with 2-space " +
|
|
131
|
-
"indentation and without comments, 2 for additionally without its \"refs\"
|
|
132
|
-
"additionally without its \"desc\" fields (default:
|
|
147
|
+
"indentation and without comments, 2 for additionally without its \"refs\", \"diagram\", and " +
|
|
148
|
+
"\"format\" fields, or 3 for additionally without its \"desc\" fields (default: 2)"),
|
|
133
149
|
format: z.enum(describeFormats).optional().describe("output format: \"md\" for Markdown or " +
|
|
134
150
|
"\"raw\" for the raw original file content of the part (default: \"md\")"),
|
|
135
151
|
part: z.enum(describeParts).optional().describe("document part: \"all\" for the entire " +
|
|
136
152
|
"description, \"meta\" for the generic models and formats, \"schema\" for the YAML schema " +
|
|
137
153
|
"configuration, or \"spec\" for the specification files (default: \"all\"; \"raw\" is " +
|
|
138
154
|
"available for \"meta\" and \"schema\" only)"),
|
|
139
|
-
output: z.string().optional().describe("output file path (\"-\" or omitted returns the description directly)")
|
|
155
|
+
output: z.string().optional().describe("output file path (\"-\" or omitted returns the description directly)"),
|
|
156
|
+
cwd
|
|
140
157
|
}
|
|
141
158
|
}, async (args) => {
|
|
142
159
|
try {
|
|
143
|
-
const text = await specbook.describe(args);
|
|
160
|
+
const text = await specbook.describe({ ...args, compress: args.compress ?? 2 });
|
|
144
161
|
if (args.output !== undefined && args.output !== "-") {
|
|
145
|
-
await fs.promises.writeFile(args.output, text);
|
|
162
|
+
await fs.promises.writeFile(outputOf(args, args.output), text);
|
|
146
163
|
return { content: [{ type: "text", text: `described specification format into "${args.output}"` }] };
|
|
147
164
|
}
|
|
148
165
|
return { content: [{ type: "text", text }] };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Spec, type SpecObject, type SpecProperty } from "./specbook-format-spec.js";
|
|
1
|
+
import { type Spec, type SpecArtifact, type SpecObject, type SpecProperty } from "./specbook-format-spec.js";
|
|
2
2
|
import { type Diagnostic, type DiagnosticSeverity } from "./specbook-diagnostic.js";
|
|
3
3
|
import { type LinkIndex } from "./specbook-link.js";
|
|
4
4
|
export interface SourceFile {
|
|
@@ -9,6 +9,7 @@ export interface ParseResult {
|
|
|
9
9
|
specification: Spec;
|
|
10
10
|
diagnostics: Diagnostic[];
|
|
11
11
|
assets: string[];
|
|
12
|
+
origins: Map<SpecArtifact, string>;
|
|
12
13
|
}
|
|
13
14
|
export interface ObjectMeta {
|
|
14
15
|
file: string;
|
|
@@ -31,3 +32,5 @@ export declare const embeddingRegex: RegExp;
|
|
|
31
32
|
export declare const embeddingThemes: readonly ["light", "dark"];
|
|
32
33
|
export declare const embeddingVariants: (reference: string) => string[];
|
|
33
34
|
export declare const embeddingMimeType: (reference: string) => string | undefined;
|
|
35
|
+
export declare const embeddingCount: (reference: string | undefined) => number;
|
|
36
|
+
export declare const embeddingDataRegex: RegExp;
|
|
@@ -28,8 +28,10 @@ export class ParseContext {
|
|
|
28
28
|
}
|
|
29
29
|
/* the marker separating a statement from its rationale */
|
|
30
30
|
export const becauseRegex = /,\s*(?:\*\*BECAUSE\*\*|BECAUSE)\s+/;
|
|
31
|
-
/* the Markdown image embedding syntax ("")
|
|
32
|
-
|
|
31
|
+
/* the Markdown image embedding syntax: inline ("") or
|
|
32
|
+
reference-style ("![alt][label]", the label naming a "[label]: data:..."
|
|
33
|
+
definition, as the normalized Markdown export embeds the images) */
|
|
34
|
+
export const embeddingRegex = /!\[([^\]]*)\](?:\(([^()]+)\)|\[([^[\]]+)\])/g;
|
|
33
35
|
/* the theme variants of a theme-aware image embedding */
|
|
34
36
|
export const embeddingThemes = ["light", "dark"];
|
|
35
37
|
/* expand an image embedding reference into its variants: a reference
|
|
@@ -43,7 +45,8 @@ const embeddingTypes = {
|
|
|
43
45
|
svg: "image/svg+xml",
|
|
44
46
|
png: "image/png",
|
|
45
47
|
jpg: "image/jpeg",
|
|
46
|
-
jpeg: "image/jpeg"
|
|
48
|
+
jpeg: "image/jpeg",
|
|
49
|
+
webp: "image/webp"
|
|
47
50
|
};
|
|
48
51
|
/* map a local image embedding reference onto its MIME type
|
|
49
52
|
(URLs and other file types are not embeddable) */
|
|
@@ -53,3 +56,12 @@ export const embeddingMimeType = (reference) => {
|
|
|
53
56
|
const extension = reference.match(/\.([a-z0-9]+)$/i)?.[1].toLowerCase();
|
|
54
57
|
return extension !== undefined ? embeddingTypes[extension] : undefined;
|
|
55
58
|
};
|
|
59
|
+
/* the number of embedding entries an image embedding markup occupies,
|
|
60
|
+
given its inline reference: one per variant of an embeddable one, none
|
|
61
|
+
for any other one, and one for a reference-style markup (no reference) */
|
|
62
|
+
export const embeddingCount = (reference) => reference === undefined ? 1 :
|
|
63
|
+
(embeddingMimeType(reference.trim()) !== undefined ?
|
|
64
|
+
embeddingVariants(reference.trim()).length : 0);
|
|
65
|
+
/* the image definition ("[label]: data:...") a reference-style image
|
|
66
|
+
embedding refers to: a base64 data: URL of an embeddable image type */
|
|
67
|
+
export const embeddingDataRegex = /^data:(image\/(?:svg\+xml|png|jpeg|webp));base64,([A-Za-z0-9+/=]+)$/;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Spec, type SpecObject, type SpecProperty } from "./specbook-format-spec.js";
|
|
2
2
|
import { type Schema, type SchemaObject } from "./specbook-format-schema.js";
|
|
3
|
-
import { ParseContext } from "./specbook-parse-common.js";
|
|
3
|
+
import { type ParseContext } from "./specbook-parse-common.js";
|
|
4
4
|
export declare const resolveArtifact: (config: Schema, object: SpecObject) => SchemaObject | undefined;
|
|
5
5
|
export declare const collectSchemas: (specification: Spec, config: Schema) => Map<SpecObject, SchemaObject>;
|
|
6
6
|
export declare const collectParenProps: (schemas: Map<SpecObject, SchemaObject>) => Map<SpecObject, SpecProperty>;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { type SpecArtifact } from "./specbook-format-spec.js";
|
|
2
|
-
import { ParseContext, type SourceFile } from "./specbook-parse-common.js";
|
|
2
|
+
import { type ParseContext, type SourceFile } from "./specbook-parse-common.js";
|
|
3
3
|
export declare const parseFile: (ctx: ParseContext, source: SourceFile) => SpecArtifact[];
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { marked } from "marked";
|
|
9
|
-
import { becauseRegex, embeddingRegex, embeddingMimeType, embeddingVariants } from "./specbook-parse-common.js";
|
|
9
|
+
import { becauseRegex, embeddingRegex, embeddingDataRegex, embeddingMimeType, embeddingVariants } from "./specbook-parse-common.js";
|
|
10
10
|
/* split a description text into statement and optional rationale */
|
|
11
11
|
const splitDescription = (text) => {
|
|
12
12
|
const m = text.match(becauseRegex);
|
|
@@ -98,6 +98,7 @@ const parseFrontmatter = (text) => {
|
|
|
98
98
|
const missing = { date: null, line: 1, value: null };
|
|
99
99
|
if (m === null)
|
|
100
100
|
return { present: false, created: missing, modified: missing, body: text, offset: 0 };
|
|
101
|
+
/* grab a single timestamp key with its raw value and source line */
|
|
101
102
|
const grab = (key) => {
|
|
102
103
|
const km = m[1].match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
|
|
103
104
|
if (km === null || km.index === undefined)
|
|
@@ -116,14 +117,24 @@ const parseFrontmatter = (text) => {
|
|
|
116
117
|
};
|
|
117
118
|
};
|
|
118
119
|
/* recursively load the image files embedded via "" into the
|
|
119
|
-
description and the property values of an object (SVG as-is, PNG/JPEG
|
|
120
|
+
description and the property values of an object (SVG as-is, PNG/JPEG/WebP
|
|
120
121
|
as base64 data: URLs), resolving the references relative to the source
|
|
121
122
|
file and expanding a "{theme}" reference into its theme variants,
|
|
122
123
|
which are loaded into consecutive embedding entries (an unreadable
|
|
123
|
-
file leaves an empty entry, keeping the positions aligned)
|
|
124
|
-
|
|
124
|
+
file leaves an empty entry, keeping the positions aligned), while a
|
|
125
|
+
reference-style "![alt][label]" takes the image of its definition */
|
|
126
|
+
const embed = (ctx, object, file, defs) => {
|
|
125
127
|
const load = (target, text, line) => {
|
|
126
128
|
for (const m of text.matchAll(embeddingRegex)) {
|
|
129
|
+
if (m[2] === undefined) {
|
|
130
|
+
const label = m[3].trim().toLowerCase().replace(/\s+/g, " ");
|
|
131
|
+
const image = defs.get(label);
|
|
132
|
+
target.embedding ??= [];
|
|
133
|
+
target.embedding.push(image ?? "");
|
|
134
|
+
if (image === undefined)
|
|
135
|
+
ctx.diagnose(file, line, `unresolvable image reference "${m[3].trim()}"`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
127
138
|
const reference = m[2].trim();
|
|
128
139
|
const type = embeddingMimeType(reference);
|
|
129
140
|
if (type === undefined)
|
|
@@ -145,13 +156,13 @@ const embed = (ctx, object, file) => {
|
|
|
145
156
|
}
|
|
146
157
|
}
|
|
147
158
|
};
|
|
148
|
-
const line = ctx.
|
|
159
|
+
const line = ctx.metaOf(object).line;
|
|
149
160
|
if (object.description !== undefined)
|
|
150
161
|
load(object.description, object.description.description, line);
|
|
151
162
|
for (const property of object.properties)
|
|
152
163
|
load(property, property.value, ctx.propMeta.get(property)?.line ?? line);
|
|
153
164
|
for (const child of object.children)
|
|
154
|
-
embed(ctx, child, file);
|
|
165
|
+
embed(ctx, child, file, defs);
|
|
155
166
|
};
|
|
156
167
|
/* parse an unordered list of key-values and/or concise-format objects */
|
|
157
168
|
const parseList = (ctx, list, object, file, line) => {
|
|
@@ -343,6 +354,7 @@ export const parseFile = (ctx, source) => {
|
|
|
343
354
|
};
|
|
344
355
|
let parts = new Array();
|
|
345
356
|
let partsLine = 1;
|
|
357
|
+
const defs = new Map();
|
|
346
358
|
/* collect a description part, remembering the line of the first one */
|
|
347
359
|
const collect = (raw, line) => {
|
|
348
360
|
if (parts.length === 0)
|
|
@@ -393,8 +405,15 @@ export const parseFile = (ctx, source) => {
|
|
|
393
405
|
collect(token.raw, line);
|
|
394
406
|
}
|
|
395
407
|
else {
|
|
408
|
+
/* an image definition of the Markdown renderer is the one
|
|
409
|
+
supported link definition (its SVG kept as-is again) */
|
|
410
|
+
const def = token.type === "def" ? token : undefined;
|
|
411
|
+
const m = def?.href.match(embeddingDataRegex) ?? null;
|
|
396
412
|
const unsupported = unsupportedTokens[token.type];
|
|
397
|
-
if (
|
|
413
|
+
if (def !== undefined && m !== null)
|
|
414
|
+
defs.set(def.tag, m[1] === "image/svg+xml" ?
|
|
415
|
+
Buffer.from(m[2], "base64").toString("utf8") : def.href);
|
|
416
|
+
else if (unsupported !== undefined)
|
|
398
417
|
ctx.diagnose(source.file, line, `unsupported ${unsupported} content ignored`);
|
|
399
418
|
}
|
|
400
419
|
line += (token.raw.match(/\n/g) ?? []).length;
|
|
@@ -403,7 +422,8 @@ export const parseFile = (ctx, source) => {
|
|
|
403
422
|
/* load the embedded image files of all fully parsed objects */
|
|
404
423
|
for (const artifact of state.artifacts)
|
|
405
424
|
for (const object of artifact.objects)
|
|
406
|
-
embed(ctx, object, source.file);
|
|
425
|
+
embed(ctx, object, source.file, defs);
|
|
426
|
+
/* report a source file carrying no artifact at all */
|
|
407
427
|
if (state.artifacts.length === 0)
|
|
408
428
|
ctx.diagnose(source.file, 1, "no artifact (level 1 heading) found");
|
|
409
429
|
return state.artifacts;
|
package/dst/specbook-parse.js
CHANGED
|
@@ -18,8 +18,12 @@ export const parseSpecification = (sources, config) => {
|
|
|
18
18
|
const ctx = new ParseContext();
|
|
19
19
|
/* parse all source files into their artifacts */
|
|
20
20
|
const artifacts = new Array();
|
|
21
|
+
const origins = new Map();
|
|
21
22
|
for (const source of sources)
|
|
22
|
-
|
|
23
|
+
for (const artifact of parseFile(ctx, source)) {
|
|
24
|
+
artifacts.push(artifact);
|
|
25
|
+
origins.set(artifact, source.file);
|
|
26
|
+
}
|
|
23
27
|
const specification = { artifacts };
|
|
24
28
|
/* validate the resulting specification AST */
|
|
25
29
|
if (artifacts.length > 0) {
|
|
@@ -36,7 +40,7 @@ export const parseSpecification = (sources, config) => {
|
|
|
36
40
|
ctx.diagnose(sources[0].file, 1, `internal AST invalid at "${path}": ${issue.message}`);
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
|
-
return { specification, diagnostics: ctx.diagnostics, assets: Array.from(ctx.assets) };
|
|
43
|
+
return { specification, diagnostics: ctx.diagnostics, assets: Array.from(ctx.assets), origins };
|
|
40
44
|
};
|
|
41
45
|
/* the statistics of a specification: the number of defined objects
|
|
42
46
|
(recursively) and of relationships, i.e. the Wiki-style references
|
|
@@ -0,0 +1,93 @@
|
|
|
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 * as path from "node:path";
|
|
8
|
+
import { parseDocument, LineCounter } from "yaml";
|
|
9
|
+
import * as v from "valibot";
|
|
10
|
+
import { lineColOfPath } from "./specbook-config.js";
|
|
11
|
+
import { renderDiagnostic } from "./specbook-diagnostic.js";
|
|
12
|
+
/* the name of the YAML project configuration file */
|
|
13
|
+
export const projectFile = ".specbook.yaml";
|
|
14
|
+
/* the schema of the YAML project configuration: the files or glob
|
|
15
|
+
patterns of the YAML schema configuration and the base directory
|
|
16
|
+
of the specification Markdown files */
|
|
17
|
+
const Project = v.strictObject({
|
|
18
|
+
config: v.optional(v.union([v.pipe(v.string(), v.minLength(1)),
|
|
19
|
+
v.pipe(v.array(v.pipe(v.string(), v.minLength(1))), v.minLength(1))])),
|
|
20
|
+
basedir: v.optional(v.pipe(v.string(), v.minLength(1)))
|
|
21
|
+
}, (issue) => issue.expected === "never" ? "unknown project configuration entry" :
|
|
22
|
+
"expected a map of project configuration entries");
|
|
23
|
+
/* find the project configuration file in the closest ancestor-or-self
|
|
24
|
+
of the directory (up to the filesystem root, as the file can reside
|
|
25
|
+
even above a nested Git working tree) */
|
|
26
|
+
const findProject = (dir) => {
|
|
27
|
+
let current = path.resolve(dir);
|
|
28
|
+
for (;;) {
|
|
29
|
+
const file = path.join(current, projectFile);
|
|
30
|
+
if (fs.statSync(file, { throwIfNoEntry: false })?.isFile() === true)
|
|
31
|
+
return file;
|
|
32
|
+
const parent = path.dirname(current);
|
|
33
|
+
if (parent === current)
|
|
34
|
+
return undefined;
|
|
35
|
+
current = parent;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/* read the project configuration file, reporting an unreadable file
|
|
39
|
+
as a positioned diagnostic instead of a raw I/O error */
|
|
40
|
+
const readProject = (file) => {
|
|
41
|
+
try {
|
|
42
|
+
return fs.readFileSync(file, "utf8");
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
throw new Error("invalid project configuration:\n" + renderDiagnostic({
|
|
46
|
+
file, line: 1, column: 1, severity: "error",
|
|
47
|
+
message: "cannot read project configuration file: " +
|
|
48
|
+
(err instanceof Error ? err.message : String(err))
|
|
49
|
+
}), { cause: err });
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/* find, load, and validate the project configuration, failing on any
|
|
53
|
+
problem, where its relative paths resolve against the directory of
|
|
54
|
+
the file itself (and not against the perhaps deeper directory the
|
|
55
|
+
search started at) and the literal "std" still names the bundled
|
|
56
|
+
standard schema configuration */
|
|
57
|
+
export const loadProject = (dir) => {
|
|
58
|
+
const file = findProject(dir);
|
|
59
|
+
if (file === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
const diagnostics = new Array();
|
|
62
|
+
const lines = new LineCounter();
|
|
63
|
+
const yaml = readProject(file);
|
|
64
|
+
const doc = parseDocument(yaml, { lineCounter: lines });
|
|
65
|
+
for (const err of doc.errors)
|
|
66
|
+
diagnostics.push({
|
|
67
|
+
file,
|
|
68
|
+
line: err.linePos?.[0].line ?? 1,
|
|
69
|
+
column: err.linePos?.[0].col ?? 1,
|
|
70
|
+
severity: "error",
|
|
71
|
+
message: "invalid YAML syntax: " + err.message.replace(/ at line \d+, column \d+(?::\n[\s\S]*)?$/, "")
|
|
72
|
+
});
|
|
73
|
+
/* an empty or comment-only file carries no entries at all */
|
|
74
|
+
const result = v.safeParse(Project, doc.errors.length === 0 ? (doc.toJS() ?? {}) : {});
|
|
75
|
+
if (!result.success)
|
|
76
|
+
for (const issue of result.issues) {
|
|
77
|
+
const keys = (issue.path ?? []).map((item) => item.key);
|
|
78
|
+
const where = keys.length > 0 ? `${keys.join(".")}: ` : "";
|
|
79
|
+
diagnostics.push({ file, ...lineColOfPath(doc, lines, keys), severity: "error",
|
|
80
|
+
message: `invalid project configuration: ${where}${issue.message}` });
|
|
81
|
+
}
|
|
82
|
+
if (diagnostics.length > 0 || !result.success)
|
|
83
|
+
throw new Error("invalid project configuration:\n" +
|
|
84
|
+
diagnostics.map(renderDiagnostic).join("\n"));
|
|
85
|
+
const base = path.dirname(file);
|
|
86
|
+
const { config, basedir } = result.output;
|
|
87
|
+
const patterns = typeof config === "string" ? [config] : config;
|
|
88
|
+
return {
|
|
89
|
+
file,
|
|
90
|
+
config: patterns?.map((pattern) => pattern === "std" ? pattern : path.resolve(base, pattern)),
|
|
91
|
+
basedir: basedir !== undefined ? path.resolve(base, basedir) : undefined
|
|
92
|
+
};
|
|
93
|
+
};
|
package/dst/specbook-theme.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type ThemeStyle = "light" | "dark";
|
|
|
2
2
|
export type ThemeColors = {
|
|
3
3
|
base: string[];
|
|
4
4
|
accent: string[];
|
|
5
|
-
|
|
5
|
+
signal: string[];
|
|
6
6
|
};
|
|
7
7
|
export declare const themeColors: (tone: string) => ThemeColors;
|
|
8
8
|
export declare const themeStylesheet: (colors: ThemeColors) => string;
|
package/dst/specbook-theme.js
CHANGED
|
@@ -4,23 +4,24 @@
|
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
6
|
import { parse, generate } from "@rse/mrcs";
|
|
7
|
-
/* generate the theme color spreads from the theme color tone:
|
|
8
|
-
a grey base spread,
|
|
9
|
-
spread derived from the
|
|
7
|
+
/* generate the theme color spreads from the theme color tone(s):
|
|
8
|
+
a grey base spread, an accent spread derived from the first tone,
|
|
9
|
+
and a signal spread derived from the second tone (if given) or
|
|
10
|
+
else from the complement-transposed first tone */
|
|
10
11
|
export const themeColors = (tone) => {
|
|
11
|
-
const
|
|
12
|
+
const [accent, signal = `${accent}^`] = tone.trim().split(/\s+/);
|
|
12
13
|
return {
|
|
13
14
|
base: generate(parse("#000000/32")),
|
|
14
|
-
accent: generate(parse(`${
|
|
15
|
-
|
|
15
|
+
accent: generate(parse(`${accent}+40-5/32`)),
|
|
16
|
+
signal: generate(parse(`${signal}+40-5/32`))
|
|
16
17
|
};
|
|
17
18
|
};
|
|
18
19
|
/* render the theme colors into the layer-1 ":root" CSS variable
|
|
19
|
-
block "--theme-color-{base,accent,
|
|
20
|
+
block "--theme-color-{base,accent,signal}-{1..32}" */
|
|
20
21
|
export const themeStylesheet = (colors) => ":root {\n" +
|
|
21
22
|
colors.base.map((color, i) => ` --theme-color-base-${i + 1}: ${color};\n`).join("") +
|
|
22
23
|
colors.accent.map((color, i) => ` --theme-color-accent-${i + 1}: ${color};\n`).join("") +
|
|
23
|
-
colors.
|
|
24
|
+
colors.signal.map((color, i) => ` --theme-color-signal-${i + 1}: ${color};\n`).join("") +
|
|
24
25
|
"}\n";
|
|
25
26
|
/* map the theme color spreads onto the semantic theme colors,
|
|
26
27
|
aligned with the layer-2 CSS mapping of the HTML rendering */
|
package/dst/specbook-verbose.js
CHANGED
|
@@ -11,15 +11,10 @@ export const verbosityOf = { none: 0, notice: 1, detail: 2, trace: 3 };
|
|
|
11
11
|
/* parse and validate a verbosity specification, where a bare flag (or
|
|
12
12
|
a boolean word) selects the regular processing information */
|
|
13
13
|
export const parseVerbosity = (value) => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
level = 1;
|
|
19
|
-
else if (typeof value === "string" && (/^(?:false|no|off)$/i).test(value))
|
|
20
|
-
level = 0;
|
|
21
|
-
else
|
|
22
|
-
level = typeof value === "string" && !(/^\d+$/).test(value) ? NaN : Number(value);
|
|
14
|
+
const text = String(value);
|
|
15
|
+
const level = (/^(?:true|yes|on)$/i).test(text) ? 1 :
|
|
16
|
+
(/^(?:false|no|off)$/i).test(text) ? 0 :
|
|
17
|
+
(/^\d+$/).test(text) ? Number(text) : NaN;
|
|
23
18
|
if (!verbosities.includes(level))
|
|
24
19
|
throw new Error(`unknown verbosity "${value}" ` +
|
|
25
20
|
`(supported: ${verbosities.join(", ")})`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rse/specbook",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.16",
|
|
4
4
|
"publishConfig": { "access": "public" },
|
|
5
5
|
"description": "Markdown-based Specification Format (API, CLI and MCP service)",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -23,8 +23,10 @@
|
|
|
23
23
|
"json5": "2.2.3",
|
|
24
24
|
"pdf-lib": "1.17.1",
|
|
25
25
|
"subset-font": "2.7.0",
|
|
26
|
+
"sharp": "0.35.4",
|
|
27
|
+
"svgo": "4.1.0",
|
|
26
28
|
"@rse/mrcs": "0.9.2",
|
|
27
|
-
"@rse/gradia": "1.2
|
|
29
|
+
"@rse/gradia": "1.4.2",
|
|
28
30
|
"@toon-format/toon": "4.1.1",
|
|
29
31
|
"valibot": "1.5.0",
|
|
30
32
|
"nunjucks": "3.2.4",
|
|
@@ -54,14 +56,14 @@
|
|
|
54
56
|
"neostandard": "0.14.0-next.1",
|
|
55
57
|
"globals": "17.12.0",
|
|
56
58
|
"markdownlint": "0.41.1",
|
|
57
|
-
"markdownlint-cli2": "0.23.
|
|
58
|
-
"@types/node": "26.6.
|
|
59
|
+
"markdownlint-cli2": "0.23.3",
|
|
60
|
+
"@types/node": "26.6.2",
|
|
59
61
|
"@types/picomatch": "4.0.3",
|
|
60
62
|
"@types/subset-font": "2.3.0",
|
|
61
63
|
"@types/nunjucks": "3.2.6",
|
|
62
64
|
"@types/ws": "8.18.1",
|
|
63
65
|
"typescript": "6.0.3",
|
|
64
|
-
"tsx": "4.23.
|
|
66
|
+
"tsx": "4.23.15",
|
|
65
67
|
"source-sans": "3.52.0",
|
|
66
68
|
"source-code-pro": "2.42.0",
|
|
67
69
|
"@fontsource/noto-sans-symbols": "5.3.0",
|
|
@@ -71,7 +73,13 @@
|
|
|
71
73
|
"stylus": "0.64.0",
|
|
72
74
|
"@rse/stx": "1.1.7",
|
|
73
75
|
"shx": "0.4.0",
|
|
74
|
-
"nodemon": "3.1.14"
|
|
76
|
+
"nodemon": "3.1.14",
|
|
77
|
+
"json-asty": "1.3.4",
|
|
78
|
+
"stdver": "0.9.17"
|
|
79
|
+
},
|
|
80
|
+
"engines": {
|
|
81
|
+
"npm": ">=10.0.0",
|
|
82
|
+
"node": ">=22.13.0"
|
|
75
83
|
},
|
|
76
84
|
"upd": [ "!eslint", "!@eslint/js", "!typescript" ],
|
|
77
85
|
"allowScripts": {
|