@rse/specbook 1.3.2 → 1.4.1

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 (50) hide show
  1. package/README.md +9 -2
  2. package/dst/specbook-api.d.ts +5 -1
  3. package/dst/specbook-api.js +23 -9
  4. package/dst/specbook-cli.js +8 -5
  5. package/dst/specbook-cmd-export.js +2 -2
  6. package/dst/specbook-cmd-init.js +16 -2
  7. package/dst/specbook-coverage.js +1 -1
  8. package/dst/specbook-diagram.d.ts +1 -1
  9. package/dst/specbook-diagram.js +12 -10
  10. package/dst/specbook-export-ast.d.ts +1 -1
  11. package/dst/specbook-export-ast.js +21 -4
  12. package/dst/specbook-export-code.d.ts +14 -0
  13. package/dst/specbook-export-code.js +155 -0
  14. package/dst/specbook-export-common.d.ts +8 -2
  15. package/dst/specbook-export-common.js +126 -6
  16. package/dst/specbook-export-diagram.d.ts +15 -0
  17. package/dst/specbook-export-diagram.js +211 -0
  18. package/dst/specbook-export-html-fold.d.ts +20 -0
  19. package/dst/specbook-export-html-fold.js +458 -0
  20. package/dst/specbook-export-html-render.d.ts +51 -0
  21. package/dst/specbook-export-html-render.js +748 -0
  22. package/dst/specbook-export-html-search.js +6 -5
  23. package/dst/specbook-export-html-template.d.ts +21 -0
  24. package/dst/specbook-export-html-template.js +282 -0
  25. package/dst/specbook-export-html-widget.d.ts +12 -0
  26. package/dst/specbook-export-html-widget.js +605 -0
  27. package/dst/specbook-export-html.css +325 -50
  28. package/dst/specbook-export-html.js +62 -1931
  29. package/dst/specbook-export-image.d.ts +2 -0
  30. package/dst/specbook-export-image.js +161 -9
  31. package/dst/specbook-export-md.js +60 -12
  32. package/dst/specbook-format-schema.d.ts +1 -1
  33. package/dst/specbook-format-schema.js +1 -1
  34. package/dst/specbook-format-spec.d.ts +1 -0
  35. package/dst/specbook-format-spec.js +1 -0
  36. package/dst/specbook-format.md +173 -19
  37. package/dst/specbook-format.yaml +265 -24
  38. package/dst/specbook-ignore.js +2 -0
  39. package/dst/specbook-link.js +21 -5
  40. package/dst/specbook-mcp.js +9 -5
  41. package/dst/specbook-parse-common.d.ts +24 -0
  42. package/dst/specbook-parse-common.js +137 -10
  43. package/dst/specbook-parse-semantic.js +2 -0
  44. package/dst/specbook-parse-syntax.js +81 -20
  45. package/dst/specbook-parse.js +1 -1
  46. package/dst/specbook-project.d.ts +1 -0
  47. package/dst/specbook-project.js +10 -1
  48. package/dst/specbook-theme.d.ts +1 -0
  49. package/dst/specbook-theme.js +2 -0
  50. package/package.json +18 -12
@@ -1,3 +1,5 @@
1
1
  import type { Spec } from "./specbook-format-spec.js";
2
2
  import { type Verbose } from "./specbook-verbose.js";
3
+ export declare const isDocument: (content: string) => boolean;
3
4
  export declare const optimizeImages: (specification: Spec, print: boolean, verbose?: Verbose) => Promise<Map<string, string>>;
5
+ export declare const analyzeImages: (specification: Spec, verbose?: Verbose) => Promise<Map<string, boolean>>;
@@ -3,6 +3,13 @@
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 * as path from "node:path";
7
+ import { createRequire } from "node:module";
8
+ import { embeddedSource } from "./specbook-export-diagram.js";
9
+ import { embeddedListing } from "./specbook-export-code.js";
10
+ import { svgInvertible } from "./specbook-export-common.js";
11
+ import { darkMark, markDark, pdfType } from "./specbook-parse-common.js";
12
+ import { plainText } from "./specbook-link.js";
6
13
  import { literal } from "./specbook-verbose.js";
7
14
  /* the pixel width the raster images are capped to: the 60rem content
8
15
  width of the document (at 16px per rem) at a 2x device pixel ratio */
@@ -19,6 +26,73 @@ const imageCaches = {
19
26
  screen: new Map(),
20
27
  print: new Map()
21
28
  };
29
+ /* whether an embedded content is a page of a PDF document, which is
30
+ converted into an SVG image and then treated exactly like one */
31
+ export const isDocument = (content) => darkMark(content).content.startsWith(`data:${pdfType};`);
32
+ /* render the selected page of a PDF document (given as its embedding
33
+ data: URL carrying the 1-based "page" parameter, the first by default)
34
+ with PDF.js onto an SVG canvas of @napi-rs/canvas, i.e. as vector
35
+ graphics with the glyphs drawn as outlines, where PDF.js takes the
36
+ standard fonts, CMaps, ICC profiles, and WebAssembly decoders of
37
+ non-self-contained documents from its own package */
38
+ const renderPdf = async (content) => {
39
+ const page = Number(content.match(/^data:[^,]*?;page=(\d+)[;,]/)?.[1] ?? "1");
40
+ const data = content.slice(content.indexOf(",") + 1);
41
+ const { getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
42
+ const { createCanvas, SvgExportFlag } = await import("@napi-rs/canvas");
43
+ const dir = path.dirname(createRequire(import.meta.url).resolve("pdfjs-dist/package.json"));
44
+ const task = getDocument({ data: new Uint8Array(Buffer.from(data, "base64")), verbosity: 0,
45
+ standardFontDataUrl: `${dir}/standard_fonts/`, cMapUrl: `${dir}/cmaps/`,
46
+ iccUrl: `${dir}/iccs/`, wasmUrl: `${dir}/wasm/` });
47
+ try {
48
+ const doc = await task.promise;
49
+ if (page < 1 || page > doc.numPages)
50
+ throw new Error(`document has no page ${page}`);
51
+ const sheet = await doc.getPage(page);
52
+ const viewport = sheet.getViewport({ scale: 1 });
53
+ const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height), SvgExportFlag.NoPrettyXML);
54
+ await sheet.render({ viewport,
55
+ canvas: canvas,
56
+ canvasContext: canvas.getContext("2d") }).promise;
57
+ return canvas.getContent().toString("utf8");
58
+ }
59
+ finally {
60
+ await task.destroy();
61
+ }
62
+ };
63
+ /* the in-memory cache of the SVG renderings of the embedded PDF pages
64
+ (null for a failed one), keyed by their plain embedded content and
65
+ swept to the pages of the latest conversion, like the optimized images */
66
+ let documentCache = new Map();
67
+ /* convert the embedded PDF pages of a specification into SVG images,
68
+ served from the cache where possible, where a rendering failure (e.g.
69
+ an absent page) is surfaced as a notice only and omits the page */
70
+ const convertDocuments = async (specification, verbose) => {
71
+ const cache = new Map();
72
+ const walk = async (object) => {
73
+ const contents = [...object.description?.embedding ?? [],
74
+ ...object.properties.flatMap((property) => property.embedding ?? [])];
75
+ for (const plain of contents.filter(isDocument).map((content) => darkMark(content).content)) {
76
+ if (cache.has(plain))
77
+ continue;
78
+ let svg = documentCache.get(plain);
79
+ if (svg === undefined)
80
+ svg = await renderPdf(plain).catch((err) => {
81
+ verbose?.(`rendering PDF page of ${object.kind} "${literal(plainText(object.name))}" failed: ` +
82
+ (err instanceof Error ? err.message : String(err)));
83
+ return null;
84
+ });
85
+ cache.set(plain, svg);
86
+ }
87
+ for (const child of object.children)
88
+ await walk(child);
89
+ };
90
+ for (const artifact of specification.artifacts)
91
+ for (const object of artifact.objects)
92
+ await walk(object);
93
+ documentCache = cache;
94
+ return cache;
95
+ };
22
96
  /* optimize a single SVG image with SVGO (the identifiers and classes
23
97
  are free to be minified, as the image is rendered in isolation
24
98
  through an <img> element), after stripping the "content" attribute
@@ -63,19 +137,23 @@ const optimizeRaster = async (content, print) => {
63
137
  return `data:${type};base64,${data.toString("base64")}`;
64
138
  };
65
139
  /* collect the embedded image contents of an object and its descendants
66
- (the empty entries of the unreadable files left out) */
140
+ (the empty entries of the unreadable files, the embedded diagram
141
+ sources, which are rendered instead, and the embedded source code
142
+ files, which are listed instead, left out) */
143
+ const isImage = (content) => content !== "" && embeddedSource(content) === undefined && embeddedListing(content) === undefined;
67
144
  const collect = (object, contents) => {
68
145
  for (const content of object.description?.embedding ?? [])
69
- if (content !== "")
146
+ if (isImage(content))
70
147
  contents.add(content);
71
148
  for (const property of object.properties)
72
149
  for (const content of property.embedding ?? [])
73
- if (content !== "")
150
+ if (isImage(content))
74
151
  contents.add(content);
75
152
  for (const child of object.children)
76
153
  collect(child, contents);
77
154
  };
78
- /* optimize the embedded images of a specification for the HTML export
155
+ /* optimize the embedded images of a specification (the PDF pages as
156
+ their converted SVGs, a failed one left out) for the HTML export
79
157
  (the screen) or the PDF export (print), yielding the map from the
80
158
  embedded image contents onto their optimized ones, served from the
81
159
  cache where possible: an optimization which fails (an image the
@@ -83,7 +161,9 @@ const collect = (object, contents) => {
83
161
  for) or which does not shrink the image keeps the original, so the
84
162
  export never depends on it */
85
163
  export const optimizeImages = async (specification, print, verbose) => {
86
- /* collect the distinct embedded image contents of the specification */
164
+ /* collect the distinct embedded image contents of the specification
165
+ and convert its PDF pages */
166
+ const documents = await convertDocuments(specification, verbose);
87
167
  const medium = print ? "print" : "screen";
88
168
  const contents = new Set();
89
169
  for (const artifact of specification.artifacts)
@@ -97,20 +177,29 @@ export const optimizeImages = async (specification, print, verbose) => {
97
177
  let before = 0;
98
178
  let after = 0;
99
179
  for (const content of contents) {
180
+ const plain = darkMark(content);
181
+ const document = isDocument(content) ? documents.get(plain.content) ?? null : undefined;
182
+ if (document === null)
183
+ continue;
100
184
  let optimized = imageCaches[medium].get(content);
101
185
  if (optimized !== undefined)
102
186
  cached++;
103
187
  else {
104
188
  try {
105
- optimized = content.startsWith("data:") ?
106
- await optimizeRaster(content, print) : await optimizeSvg(content);
189
+ /* an image marked with its treatment on the dark theme is
190
+ optimized plain and marked again, a PDF page as its SVG */
191
+ optimized = document !== undefined ? await optimizeSvg(document) :
192
+ plain.content.startsWith("data:") ?
193
+ await optimizeRaster(plain.content, print) : await optimizeSvg(plain.content);
194
+ if (plain.dark !== undefined)
195
+ optimized = markDark(optimized, plain.dark);
107
196
  }
108
197
  catch (err) {
109
198
  verbose?.("optimizing image failed (keeping the original): " +
110
199
  (err instanceof Error ? err.message : String(err)));
111
- optimized = content;
200
+ optimized = document !== undefined ? document : content;
112
201
  }
113
- if (optimized.length >= content.length)
202
+ if (document === undefined && optimized.length >= content.length)
114
203
  optimized = content;
115
204
  }
116
205
  cache.set(content, optimized);
@@ -127,3 +216,66 @@ export const optimizeImages = async (specification, print, verbose) => {
127
216
  `${literal(Math.round(before / 1024))} KB -> ${literal(Math.round(after / 1024))} KB`);
128
217
  return cache;
129
218
  };
219
+ /* the in-memory cache of the "auto" judgements of the raster images,
220
+ keyed by the embedded image content and swept to the images of the
221
+ latest analysis, exactly like the optimized images */
222
+ let rasterCache = new Map();
223
+ /* whether a raster image (given as a base64 data: URL) is most likely
224
+ dark ink on a transparent or light canvas and hence safe to invert on
225
+ the dark theme ("dark=auto"), judged on a downscaled copy of its
226
+ pixels: at least 60% of them are transparent or near-white (the
227
+ canvas), the others (the ink) exist, are dark on average, and consist
228
+ of a few colors only (the quantized colors holding at least 2% of the
229
+ ink cover at least 85% of it), unlike a photo or a colorful screenshot */
230
+ const rasterInvertible = async (content) => {
231
+ const m = content.match(/^data:image\/(?:png|jpeg|webp);base64,(.*)$/s);
232
+ if (m === null)
233
+ return false;
234
+ const { default: sharp } = await import("sharp");
235
+ const { data, info } = await sharp(Buffer.from(m[1], "base64"))
236
+ .resize({ width: 128, height: 128, fit: "inside", withoutEnlargement: true })
237
+ .ensureAlpha().raw().toBuffer({ resolveWithObject: true });
238
+ let canvas = 0;
239
+ let ink = 0;
240
+ let luma = 0;
241
+ const bins = new Map();
242
+ for (let p = 0; p < data.length; p += info.channels) {
243
+ const [r, g, b, a] = [data[p], data[p + 1], data[p + 2], data[p + 3]];
244
+ if (a < 32 || Math.min(r, g, b) >= 235)
245
+ canvas++;
246
+ else {
247
+ ink++;
248
+ luma += (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
249
+ const bin = ((r >> 5) << 6) | ((g >> 5) << 3) | (b >> 5);
250
+ bins.set(bin, (bins.get(bin) ?? 0) + 1);
251
+ }
252
+ }
253
+ const few = Array.from(bins.values()).filter((n) => n >= ink * 0.02).reduce((sum, n) => sum + n, 0);
254
+ return ink > 0 && canvas / (canvas + ink) >= 0.6 && luma / ink < 0.6 && few >= ink * 0.85;
255
+ };
256
+ /* judge the raster images and PDF pages of a specification without an
257
+ explicit treatment on the dark theme for the "auto" one, yielding the
258
+ map from their embedded contents onto whether they are invertible,
259
+ served from the cache where possible (an image Sharp cannot process
260
+ and a failed PDF page count as not invertible), where a PDF page is
261
+ judged on its converted SVG like an SVG image */
262
+ export const analyzeImages = async (specification, verbose) => {
263
+ const documents = await convertDocuments(specification, verbose);
264
+ const contents = new Set();
265
+ for (const artifact of specification.artifacts)
266
+ for (const object of artifact.objects)
267
+ collect(object, contents);
268
+ const cache = new Map();
269
+ for (const content of contents) {
270
+ if (!content.startsWith("data:") || darkMark(content).dark !== undefined)
271
+ continue;
272
+ if (isDocument(content)) {
273
+ const document = documents.get(content);
274
+ cache.set(content, typeof document === "string" && svgInvertible(document));
275
+ }
276
+ else
277
+ cache.set(content, rasterCache.get(content) ?? await rasterInvertible(content).catch(() => false));
278
+ }
279
+ rasterCache = new Map(Array.from(cache).filter(([content]) => !isDocument(content)));
280
+ return cache;
281
+ };
@@ -5,9 +5,11 @@
5
5
  */
6
6
  import * as path from "node:path";
7
7
  import { specDiagrams } from "./specbook-diagram.js";
8
- import { becauseRegex, embeddingRegex, embeddingCount } from "./specbook-parse-common.js";
9
- import { isTitleObject } from "./specbook-export-common.js";
10
- import { optimizeImages } from "./specbook-export-image.js";
8
+ import { becauseRegex, embeddingRegex, embeddingCount, darkMark, markDark } from "./specbook-parse-common.js";
9
+ import { isTitleObject, imageDark } from "./specbook-export-common.js";
10
+ import { optimizeImages, analyzeImages, isDocument } from "./specbook-export-image.js";
11
+ import { renderEmbeddedDiagrams, embeddedSource, diagramKey } from "./specbook-export-diagram.js";
12
+ import { embeddedListing, listingFence } from "./specbook-export-code.js";
11
13
  /* format a timestamp in the frontmatter format */
12
14
  export const formatTimestamp = (date) => {
13
15
  const pad = (n) => String(n).padStart(2, "0");
@@ -27,9 +29,11 @@ const renderKeyValuesMd = (properties, indent = "") => {
27
29
  a ";" would split the segment, and a value carrying the rationale
28
30
  marker would not be taken for a property at all on re-parsing */
29
31
  const segmentableMd = (property) => !property.value.includes(";") && !becauseRegex.test(property.value);
30
- /* render a description statement with its optional rationale */
32
+ /* render a description statement with its optional rationale and
33
+ its optional elaboration blocks */
31
34
  const renderDescriptionMd = (description) => description.description + (description.rationale !== undefined ?
32
- `, BECAUSE ${description.rationale}` : "");
35
+ `, BECAUSE ${description.rationale}` : "") + (description.elaboration !== undefined ?
36
+ `\n\n${description.elaboration}` : "");
33
37
  /* render the "(*)" primary marker plus the implicit "(xxx)"
34
38
  and/or explicit "{{xxx}}" anchor suffix */
35
39
  const nameSuffixMd = (object) => (object.primary === true ? " (*)" : "") +
@@ -119,10 +123,22 @@ export const renderMarkdown = async (specification, config, rebase, verbose) =>
119
123
  export: an embeddable "![alt](file)" becomes the reference-style
120
124
  "![alt][img-N]", whose "[img-N]: data:..." definition ends the
121
125
  document (a "{theme}" one takes its light variant, as Markdown
122
- knows no themes), and only the remaining ones are re-based */
126
+ knows no themes), an embedded diagram source file becomes its
127
+ rendered SVG (the light variant, too, marked as inverted on the
128
+ dark theme where the diagram is), where every emitted SVG is marked
129
+ with its resolved treatment on the dark theme (an adapting diagram
130
+ flattened onto its light variant as the "auto" detection judges
131
+ it), so a re-parse keeps it, even where the "auto" detection would
132
+ judge a re-optimized SVG differently, an embedded source code file
133
+ of a description leaves its markup for a fenced code block collected
134
+ into "fences" (and one of a property value becomes a "data:"
135
+ definition, too), and only the remaining ones (including a failed
136
+ diagram) are re-based */
123
137
  const optimized = await optimizeImages(specification, false, verbose);
138
+ const rasters = await analyzeImages(specification, verbose);
139
+ const sources = await renderEmbeddedDiagrams(specification, verbose);
124
140
  const labels = new Map();
125
- const embedMd = (text, embedding, from, to) => {
141
+ const embedMd = (text, embedding, from, to, fences) => {
126
142
  let i = 0;
127
143
  const embedded = text.replace(embeddingRegex, (match, alt, reference) => {
128
144
  const count = embeddingCount(reference);
@@ -130,9 +146,24 @@ export const renderMarkdown = async (specification, config, rebase, verbose) =>
130
146
  i += count;
131
147
  if (content === undefined || content === "")
132
148
  return match;
133
- const image = optimized.get(content) ?? content;
134
- const url = image.startsWith("data:") ? image :
135
- `data:image/svg+xml;base64,${Buffer.from(image, "utf8").toString("base64")}`;
149
+ const listing = embeddedListing(content);
150
+ if (listing !== undefined && fences !== undefined) {
151
+ fences.push(listingFence(listing));
152
+ return "";
153
+ }
154
+ else if (listing !== undefined) {
155
+ const label = labels.get(content) ?? `img-${labels.size + 1}`;
156
+ labels.set(content, label);
157
+ return `![${alt}][${label}]`;
158
+ }
159
+ const diagram = embeddedSource(content);
160
+ const variants = diagram !== undefined ? sources.get(diagramKey(diagram.language, diagram.source)) : undefined;
161
+ const light = diagram !== undefined ? variants?.light :
162
+ optimized.get(content) ?? (isDocument(content) ? undefined : content);
163
+ if (light === undefined)
164
+ return match;
165
+ const url = diagram !== undefined ? markDark(light, imageDark(light)) :
166
+ markDark(darkMark(light).content, imageDark(content, rasters));
136
167
  const label = labels.get(url) ?? `img-${labels.size + 1}`;
137
168
  labels.set(url, label);
138
169
  return `![${alt}][${label}]`;
@@ -148,9 +179,26 @@ export const renderMarkdown = async (specification, config, rebase, verbose) =>
148
179
  properties: object.properties.map((property) => ({ ...property, value: embedMd(property.value, property.embedding ?? [], from, to) })),
149
180
  children: object.children.map((child) => embedObjectMd(child, from, to))
150
181
  };
151
- if (object.description !== undefined)
182
+ if (object.description !== undefined) {
183
+ /* the embeddings of the elaboration follow those of the statement,
184
+ and the fenced code blocks of the embedded source code files
185
+ end the elaboration */
186
+ const embedding = object.description.embedding ?? [];
187
+ const skip = Array.from(object.description.description.matchAll(embeddingRegex))
188
+ .reduce((sum, m) => sum + embeddingCount(m[2]), 0);
189
+ const fences = new Array();
152
190
  clone.description = { ...object.description,
153
- description: embedMd(object.description.description, object.description.embedding ?? [], from, to) };
191
+ description: embedMd(object.description.description, embedding, from, to, fences) };
192
+ if (object.description.elaboration !== undefined)
193
+ clone.description.elaboration = embedMd(object.description.elaboration, embedding.slice(skip), from, to, fences);
194
+ if (fences.length > 0) {
195
+ /* the removed markups leave no empty paragraphs behind */
196
+ const collapse = (text) => text.replace(/\n[ \t]*(?:\n[ \t]*)+\n/g, "\n\n").trim();
197
+ clone.description.description = collapse(clone.description.description);
198
+ clone.description.elaboration = [collapse(clone.description.elaboration ?? ""), ...fences]
199
+ .filter((block) => block !== "").join("\n\n");
200
+ }
201
+ }
154
202
  const spec = diagrams.get(object);
155
203
  if (spec !== undefined)
156
204
  diagrams.set(clone, spec);
@@ -68,7 +68,7 @@ export type SchemaDiagram = {
68
68
  config?: Partial<GradiaConfig>;
69
69
  };
70
70
  export type SchemaFormat = {
71
- type?: "auto" | "complex" | "concise";
71
+ type?: "auto" | "complex" | "compact" | "concise";
72
72
  maxTableColumns?: number;
73
73
  maxCellHeight?: number;
74
74
  withUnusedProps?: boolean;
@@ -80,7 +80,7 @@ const SchemaDiagram = v.strictObject({
80
80
  config: v.optional(SchemaDiagramConfig)
81
81
  });
82
82
  const SchemaFormat = v.strictObject({
83
- type: v.optional(v.picklist(["auto", "complex", "concise"])),
83
+ type: v.optional(v.picklist(["auto", "complex", "compact", "concise"])),
84
84
  maxTableColumns: v.optional(v.pipe(v.number(), v.integer(), v.minValue(2))),
85
85
  maxCellHeight: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
86
86
  withUnusedProps: v.optional(v.boolean())
@@ -21,6 +21,7 @@ export type SpecObject = {
21
21
  export type SpecDescription = {
22
22
  description: string;
23
23
  rationale?: string;
24
+ elaboration?: string;
24
25
  embedding?: string[];
25
26
  };
26
27
  export type SpecProperty = {
@@ -15,6 +15,7 @@ const SpecProperty = v.object({
15
15
  const SpecDescription = v.object({
16
16
  description: v.string(),
17
17
  rationale: v.optional(v.string()),
18
+ elaboration: v.optional(v.string()),
18
19
  embedding: v.optional(v.array(v.string()))
19
20
  });
20
21
  const SpecObject = v.object({