@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.
Files changed (45) hide show
  1. package/README.md +98 -11
  2. package/dst/specbook-api.d.ts +22 -21
  3. package/dst/specbook-api.js +80 -27
  4. package/dst/specbook-cli.js +50 -23
  5. package/dst/specbook-cmd-describe.js +8 -4
  6. package/dst/specbook-cmd-export.d.ts +4 -1
  7. package/dst/specbook-cmd-export.js +22 -12
  8. package/dst/specbook-cmd-init.js +2 -8
  9. package/dst/specbook-cmd-lint.d.ts +2 -1
  10. package/dst/specbook-cmd-lint.js +31 -28
  11. package/dst/specbook-config.d.ts +7 -0
  12. package/dst/specbook-config.js +6 -1
  13. package/dst/specbook-coverage.js +3 -4
  14. package/dst/specbook-diagram.d.ts +3 -1
  15. package/dst/specbook-diagram.js +78 -27
  16. package/dst/specbook-export-ast.d.ts +2 -1
  17. package/dst/specbook-export-ast.js +16 -8
  18. package/dst/specbook-export-common.d.ts +7 -0
  19. package/dst/specbook-export-common.js +36 -3
  20. package/dst/specbook-export-html-search.js +27 -24
  21. package/dst/specbook-export-html.css +64 -18
  22. package/dst/specbook-export-html.d.ts +2 -1
  23. package/dst/specbook-export-html.js +519 -277
  24. package/dst/specbook-export-image.d.ts +3 -0
  25. package/dst/specbook-export-image.js +121 -0
  26. package/dst/specbook-export-md.d.ts +8 -2
  27. package/dst/specbook-export-md.js +67 -6
  28. package/dst/specbook-export-pdf.js +7 -4
  29. package/dst/specbook-format.md +35 -8
  30. package/dst/specbook-format.yaml +12 -9
  31. package/dst/specbook-ignore.js +5 -5
  32. package/dst/specbook-link.js +1 -1
  33. package/dst/specbook-mcp.js +51 -34
  34. package/dst/specbook-parse-common.d.ts +4 -1
  35. package/dst/specbook-parse-common.js +15 -3
  36. package/dst/specbook-parse-semantic.d.ts +1 -1
  37. package/dst/specbook-parse-syntax.d.ts +1 -1
  38. package/dst/specbook-parse-syntax.js +28 -8
  39. package/dst/specbook-parse.js +6 -2
  40. package/dst/specbook-project.d.ts +7 -0
  41. package/dst/specbook-project.js +93 -0
  42. package/dst/specbook-theme.d.ts +1 -1
  43. package/dst/specbook-theme.js +9 -8
  44. package/dst/specbook-verbose.js +4 -9
  45. package/package.json +14 -6
@@ -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
- inputSchema: {
78
- config: z.array(z.string()).optional().describe("YAML schema configuration files or glob " +
79
- "patterns, merged in order (\"std\" for the bundled standard schema configuration; " +
80
- "default: the bundled standard schema configuration)"),
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
- gitignore: z.boolean().optional().describe("skip the artifact files excluded by the Git " +
86
- "exclude rules (the \".gitignore\" files, \"info/exclude\", and the global excludes " +
87
- "file), treating such a file exactly like an absent one (default: false)")
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], gitignore: args.gitignore });
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 bundled standard schema configuration, embedded)"),
126
- basedir: z.string().optional().describe("base directory of the specification Markdown files"),
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\" fields, or 3 for " +
132
- "additionally without its \"desc\" fields (default: 1)"),
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 ("![alt](file)") */
32
- export const embeddingRegex = /!\[([^\]]*)\]\(([^()]+)\)/g;
31
+ /* the Markdown image embedding syntax: inline ("![alt](file)") 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 "![alt](file)" 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
- const embed = (ctx, object, file) => {
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.objectMeta.get(object)?.line ?? 1;
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 (unsupported !== undefined)
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;
@@ -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
- artifacts.push(...parseFile(ctx, source));
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,7 @@
1
+ export declare const projectFile = ".specbook.yaml";
2
+ export interface Project {
3
+ file: string;
4
+ config?: string[];
5
+ basedir?: string;
6
+ }
7
+ export declare const loadProject: (dir: string) => Project | undefined;
@@ -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
+ };
@@ -2,7 +2,7 @@ export type ThemeStyle = "light" | "dark";
2
2
  export type ThemeColors = {
3
3
  base: string[];
4
4
  accent: string[];
5
- search: string[];
5
+ signal: string[];
6
6
  };
7
7
  export declare const themeColors: (tone: string) => ThemeColors;
8
8
  export declare const themeStylesheet: (colors: ThemeColors) => string;
@@ -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, a tone-derived accent spread, and a search
9
- spread derived from the complement-transposed tone */
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 transposed = tone.endsWith("^") ? tone.slice(0, -1) : `${tone}^`;
12
+ const [accent, signal = `${accent}^`] = tone.trim().split(/\s+/);
12
13
  return {
13
14
  base: generate(parse("#000000/32")),
14
- accent: generate(parse(`${tone}+40-5/32`)),
15
- search: generate(parse(`${transposed}+40-5/32`))
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,search}-{1..32}" */
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.search.map((color, i) => ` --theme-color-search-${i + 1}: ${color};\n`).join("") +
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 */
@@ -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
- let level;
15
- if (typeof value === "boolean")
16
- level = value ? 1 : 0;
17
- else if (typeof value === "string" && (/^(?:true|yes|on)$/i).test(value))
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.14",
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.3",
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.2",
58
- "@types/node": "26.6.1",
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.13",
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": {