@rse/specbook 1.0.2 → 1.0.3

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.
@@ -2,8 +2,8 @@ import { renderDiagnostic, renderDiagnosticVerbose, type Diagnostic } from "./sp
2
2
  import { type LintResult } from "./specbook-cmd-lint.js";
3
3
  import { parseOutputSpec, formats, type ExportFormat } from "./specbook-cmd-export.js";
4
4
  import { describeFormats, describeParts, parseDescribeFormat, parseDescribePart, type DescribeFormat, type DescribePart } from "./specbook-cmd-describe.js";
5
- import { literal, renderVerbose } from "./specbook-verbose.js";
6
- export { literal, renderVerbose };
5
+ import { literal, renderVerbose, type Verbose, type VerboseLevel } from "./specbook-verbose.js";
6
+ export { literal, renderVerbose, type Verbose, type VerboseLevel };
7
7
  export { formats, parseOutputSpec, type ExportFormat };
8
8
  export { describeFormats, describeParts, parseDescribeFormat, parseDescribePart };
9
9
  export { type DescribeFormat, type DescribePart };
@@ -13,7 +13,7 @@ export type { Spec, SpecArtifact, SpecObject, SpecDescription, SpecProperty } fr
13
13
  export type { Schema, SchemaObject, SchemaProperty } from "./specbook-format-schema.js";
14
14
  export declare const version: string;
15
15
  export declare const standardConfig: string;
16
- export type VerboseSink = (cmd: string, msg: string) => void;
16
+ export type VerboseSink = (cmd: string, msg: string, level: VerboseLevel) => void;
17
17
  export interface SpecBookOptions {
18
18
  verbose?: VerboseSink;
19
19
  }
@@ -11,6 +11,7 @@ import { initSpecification } from "./specbook-cmd-init.js";
11
11
  import { lint } from "./specbook-cmd-lint.js";
12
12
  import { exportSpecification, parseOutputSpec, formats } from "./specbook-cmd-export.js";
13
13
  import { describeFormat, describeFormats, describeParts, parseDescribeFormat, parseDescribePart } from "./specbook-cmd-describe.js";
14
+ import { requireBrowser } from "./specbook-export-pdf.js";
14
15
  import { literal, renderVerbose } from "./specbook-verbose.js";
15
16
  /* re-export the central types for API consumers */
16
17
  export { literal, renderVerbose };
@@ -32,7 +33,7 @@ export class SpecBook {
32
33
  }
33
34
  /* bind the verbose sink to a particular command */
34
35
  verboseOf(cmd) {
35
- return (msg) => this.verbose(cmd, msg);
36
+ return (msg, level = "debug") => this.verbose(cmd, msg, level);
36
37
  }
37
38
  /* determine the file of the YAML schema configuration, falling
38
39
  back onto the bundled standard schema configuration */
@@ -68,6 +69,11 @@ export class SpecBook {
68
69
  as a partial or invalid specification must never be emitted) */
69
70
  async export(options) {
70
71
  const verbose = this.verboseOf("export");
72
+ const requested = options.formats ?? ["json"];
73
+ /* a missing browser is an environment problem, so let the PDF
74
+ export fail before the specification is even parsed */
75
+ if (requested.includes("pdf"))
76
+ await requireBrowser(verbose);
71
77
  const result = lint({ config: this.configFile(options.config),
72
78
  basedir: options.basedir ?? ".", verbose });
73
79
  if (result.diagnostics.length > 0)
@@ -76,7 +82,7 @@ export class SpecBook {
76
82
  if (result.specification.artifacts.length === 0)
77
83
  throw new Error("unexportable specification: no artifacts found");
78
84
  const buffers = new Array();
79
- for (const format of options.formats ?? ["json"])
85
+ for (const format of requested)
80
86
  buffers.push(await exportSpecification(result.specification, format, verbose, result.config));
81
87
  return buffers;
82
88
  }
@@ -12,9 +12,11 @@ import { serveMcp } from "./specbook-mcp.js";
12
12
  /* route verbose messages to stderr, keeping stdout reserved for the
13
13
  command outputs and the MCP protocol, and qualify every message with
14
14
  the tool name plus the scope path of the emitting command, where
15
- Chalk styles the output only if the terminal supports colors */
16
- const verboseOf = (opts, ...scope) => (cmd, msg) => {
17
- if (opts.verbose) {
15
+ Chalk styles the output only if the terminal supports colors; the
16
+ "notice" messages pass regardless of the verbose option, as they
17
+ report environment problems the user has to see */
18
+ const verboseOf = (opts, ...scope) => (cmd, msg, level) => {
19
+ if (opts.verbose || level === "notice") {
18
20
  const scopePath = [...scope, cmd].map((segment) => chalk.bold(segment)).join(": ");
19
21
  process.stderr.write(`specbook: ${scopePath}: ${renderVerbose(msg, chalk.blue)}\n`);
20
22
  }
@@ -38,7 +40,7 @@ const writeOutput = async (output, data, cmd, verbose) => {
38
40
  await writeStdout(data);
39
41
  else {
40
42
  await fs.promises.writeFile(output, data);
41
- verbose(cmd, `wrote "${literal(output)}" (${literal(Buffer.byteLength(data))} bytes)`);
43
+ verbose(cmd, `wrote "${literal(output)}" (${literal(Buffer.byteLength(data))} bytes)`, "debug");
42
44
  }
43
45
  };
44
46
  /* determine an option default value, overridable via a
@@ -71,7 +73,7 @@ program.name("specbook")
71
73
  withVerboseOption(program.command("mcp"))
72
74
  .description("run as MCP stdio server")
73
75
  .action(async (opts) => {
74
- verboseOf(opts)("mcp", "starting MCP server on stdio");
76
+ verboseOf(opts)("mcp", "starting MCP server on stdio", "debug");
75
77
  await serveMcp(verboseOf(opts, "mcp"));
76
78
  });
77
79
  /* the init command creates the configured artifact files */
@@ -97,7 +99,7 @@ withCommonOptions(program.command("lint"))
97
99
  if (result.diagnostics.length > 0)
98
100
  process.exitCode = 1;
99
101
  else
100
- verboseOf(opts)("lint", "specification valid");
102
+ verboseOf(opts)("lint", "specification valid", "debug");
101
103
  });
102
104
  /* the export command parses the input once and writes every output */
103
105
  withCommonOptions(program.command("export"))
@@ -143,5 +145,5 @@ catch (err) {
143
145
  let msg = err instanceof Error ? err.message : String(err);
144
146
  for (let cause = err instanceof Error ? err.cause : undefined; cause instanceof Error; cause = cause.cause)
145
147
  msg += `: ${cause.message}`;
146
- process.stderr.write(`specbook: ERROR: ${msg}\n`, () => process.exit(1));
148
+ process.stderr.write(`specbook: ${chalk.red("ERROR:")} ${renderVerbose(msg, chalk.blue)}\n`, () => process.exit(1));
147
149
  }
@@ -1,9 +1,10 @@
1
1
  import type { Spec } from "./specbook-format-spec.js";
2
2
  import type { Schema } from "./specbook-format-schema.js";
3
+ import type { Verbose } from "./specbook-verbose.js";
3
4
  export declare const formats: readonly ["json", "json5", "yaml", "toon", "html", "pdf", "md"];
4
5
  export type ExportFormat = typeof formats[number];
5
6
  export declare const parseOutputSpec: (spec: string) => {
6
7
  format: ExportFormat;
7
8
  output: string;
8
9
  };
9
- export declare const exportSpecification: (specification: Spec, format: ExportFormat, verbose: (msg: string) => void, config?: Schema) => Promise<Buffer>;
10
+ export declare const exportSpecification: (specification: Spec, format: ExportFormat, verbose: Verbose, config?: Schema) => Promise<Buffer>;
@@ -1,7 +1,8 @@
1
+ import { type Verbose } from "./specbook-verbose.js";
1
2
  import { type Schema } from "./specbook-format-schema.js";
2
3
  export interface InitOptions {
3
4
  config: Schema;
4
5
  basedir: string;
5
- verbose: (msg: string) => void;
6
+ verbose: Verbose;
6
7
  }
7
8
  export declare const initSpecification: (options: InitOptions) => string[];
@@ -1,10 +1,11 @@
1
+ import { type Verbose } from "./specbook-verbose.js";
1
2
  import { type Diagnostic } from "./specbook-diagnostic.js";
2
3
  import { type Spec } from "./specbook-format-spec.js";
3
4
  import { type Schema } from "./specbook-format-schema.js";
4
5
  export interface LintOptions {
5
6
  config: string;
6
7
  basedir: string;
7
- verbose: (msg: string) => void;
8
+ verbose: Verbose;
8
9
  }
9
10
  export interface LintResult {
10
11
  specification: Spec;
@@ -1,9 +1,12 @@
1
+ import type { LaunchOptions } from "playwright";
1
2
  import type { OutlineEntry } from "./specbook-export-html.js";
2
3
  import type { ThemeMapping } from "./specbook-theme.js";
4
+ import { type Verbose } from "./specbook-verbose.js";
3
5
  type Heading = {
4
6
  title: string;
5
7
  subtitle?: string;
6
8
  logo: string;
7
9
  };
8
- export declare const htmlToPdf: (renderHtmlPass: (tocPages?: Map<string, number>) => Promise<string>, heading: Heading, outline: OutlineEntry[], verbose: (msg: string) => void, css: string, theme: ThemeMapping, paper?: string) => Promise<Buffer>;
10
+ export declare const requireBrowser: (verbose: Verbose) => Promise<LaunchOptions>;
11
+ export declare const htmlToPdf: (renderHtmlPass: (tocPages?: Map<string, number>) => Promise<string>, heading: Heading, outline: OutlineEntry[], verbose: Verbose, css: string, theme: ThemeMapping, paper?: string) => Promise<Buffer>;
9
12
  export {};
@@ -3,7 +3,9 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
+ import fs from "node:fs";
6
7
  import { escapeHtml, paperSetup, paperLength, paperSizeDefault } from "./specbook-export-common.js";
8
+ import { literal } from "./specbook-verbose.js";
7
9
  /* extract the per-anchor page numbers from a Chromium-generated PDF,
8
10
  which records the internal link targets as named PDF destinations */
9
11
  const anchorPages = async (pdf) => {
@@ -121,6 +123,48 @@ const drawBrandBar = async (doc, accent) => {
121
123
  page.drawRectangle({ x: 0, y: 0, width: 5.4,
122
124
  height: page.getHeight(), color: rgb(r, g, b) });
123
125
  };
126
+ /* the launch options of the Chromium-class browser printing the PDF,
127
+ resolved just once per process, as probing a system-installed Google
128
+ Chrome has to actually launch it and as the export preflight and the
129
+ renderer both ask for the very same browser */
130
+ let browserOptions;
131
+ /* resolve the browser printing the PDF: the downloaded Playwright
132
+ Chromium is a plain file check, while a system-installed Google
133
+ Chrome is only detectable by launching it */
134
+ const resolveBrowser = async (verbose) => {
135
+ const { chromium } = await import("playwright");
136
+ let executable = "";
137
+ try {
138
+ executable = chromium.executablePath();
139
+ }
140
+ catch {
141
+ /* Playwright knows no Chromium distribution at all */
142
+ }
143
+ if (executable !== "" && fs.existsSync(executable))
144
+ return {};
145
+ try {
146
+ const chrome = await chromium.launch({ channel: "chrome" });
147
+ await chrome.close();
148
+ verbose("Playwright Chromium unavailable -- falling back to the " +
149
+ "system-installed Google Chrome", "notice");
150
+ return { channel: "chrome" };
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ };
156
+ /* ensure a browser printing the PDF is available, so a missing browser
157
+ fails the export early and with an actionable remedy instead of deep
158
+ inside the rendering and with a Playwright-internal message */
159
+ export const requireBrowser = async (verbose) => {
160
+ browserOptions ??= resolveBrowser(verbose);
161
+ const options = await browserOptions;
162
+ if (options === undefined)
163
+ throw new Error("the PDF export requires a Chromium-class browser, but neither the " +
164
+ "Playwright Chromium nor a system-installed Google Chrome was found -- run " +
165
+ `"${literal("npx playwright install chromium")}" once to download the Playwright Chromium`);
166
+ return options;
167
+ };
124
168
  /* render a self-contained HTML document into a PDF via Playwright,
125
169
  re-rendering the HTML with the discovered ToC page numbers */
126
170
  export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css, theme, paper = paperSizeDefault) => {
@@ -128,22 +172,8 @@ export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css,
128
172
  /* the page geometry of the chosen paper size */
129
173
  const setup = paperSetup(paper);
130
174
  const margin = setup.margin;
131
- /* launch the Playwright Chromium browser, falling back to a
132
- system-installed Google Chrome if the Chromium download is missing */
133
- let browser;
134
- try {
135
- browser = await chromium.launch();
136
- }
137
- catch (err) {
138
- verbose("Playwright Chromium unavailable -- falling back to installed Google Chrome");
139
- try {
140
- browser = await chromium.launch({ channel: "chrome" });
141
- }
142
- catch {
143
- /* report the original, more instructive error */
144
- throw err;
145
- }
146
- }
175
+ /* launch the browser resolved for this process */
176
+ const browser = await chromium.launch(await requireBrowser(verbose));
147
177
  try {
148
178
  const page = await browser.newPage();
149
179
  /* render an HTML document into a paginated PDF */
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { z } from "zod";
10
- import { SpecBook, renderDiagnostic, formats, parseOutputSpec, describeFormats, describeParts, version } from "./specbook-api.js";
10
+ import { SpecBook, renderDiagnostic, renderVerbose, formats, parseOutputSpec, describeFormats, describeParts, version } from "./specbook-api.js";
11
11
  /* render an error with its cause chain into a tool error result */
12
12
  const errorResult = (err) => {
13
13
  let msg = err instanceof Error ? err.message : String(err);
@@ -15,7 +15,7 @@ const errorResult = (err) => {
15
15
  msg += `: ${cause.message}`;
16
16
  return {
17
17
  isError: true,
18
- content: [{ type: "text", text: `ERROR: ${msg}` }]
18
+ content: [{ type: "text", text: `ERROR: ${renderVerbose(msg)}` }]
19
19
  };
20
20
  };
21
21
  /* serve the SpecBook functionality as "specbook_<cmd>" MCP tools over stdio */
@@ -1,2 +1,4 @@
1
+ export type VerboseLevel = "debug" | "notice";
2
+ export type Verbose = (msg: string, level?: VerboseLevel) => void;
1
3
  export declare const literal: (value: string | number) => string;
2
4
  export declare const renderVerbose: (msg: string, style?: (value: string) => string) => string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rse/specbook",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "Markdown-based Specification Format (API, CLI and MCP service)",
6
6
  "license": "Apache-2.0",