@rse/specbook 1.3.1 → 1.4.0

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 +5 -2
  2. package/dst/specbook-api.js +7 -5
  3. package/dst/specbook-cli.js +0 -0
  4. package/dst/specbook-cmd-export.js +2 -2
  5. package/dst/specbook-coverage.js +1 -1
  6. package/dst/specbook-diagram.d.ts +1 -1
  7. package/dst/specbook-diagram.js +9 -3
  8. package/dst/specbook-export-ast.d.ts +1 -1
  9. package/dst/specbook-export-ast.js +21 -4
  10. package/dst/specbook-export-code.d.ts +14 -0
  11. package/dst/specbook-export-code.js +155 -0
  12. package/dst/specbook-export-common.d.ts +4 -2
  13. package/dst/specbook-export-common.js +110 -6
  14. package/dst/specbook-export-diagram.d.ts +15 -0
  15. package/dst/specbook-export-diagram.js +211 -0
  16. package/dst/specbook-export-html-fold.d.ts +20 -0
  17. package/dst/specbook-export-html-fold.js +458 -0
  18. package/dst/specbook-export-html-render.d.ts +51 -0
  19. package/dst/specbook-export-html-render.js +749 -0
  20. package/dst/specbook-export-html-search.js +6 -5
  21. package/dst/specbook-export-html-template.d.ts +21 -0
  22. package/dst/specbook-export-html-template.js +282 -0
  23. package/dst/specbook-export-html-widget.d.ts +12 -0
  24. package/dst/specbook-export-html-widget.js +605 -0
  25. package/dst/specbook-export-html.css +325 -50
  26. package/dst/specbook-export-html.js +62 -1931
  27. package/dst/specbook-export-image.d.ts +2 -0
  28. package/dst/specbook-export-image.js +161 -9
  29. package/dst/specbook-export-md.js +60 -12
  30. package/dst/specbook-format-schema.d.ts +1 -1
  31. package/dst/specbook-format-schema.js +1 -1
  32. package/dst/specbook-format-spec.d.ts +1 -0
  33. package/dst/specbook-format-spec.js +1 -0
  34. package/dst/specbook-format.md +169 -17
  35. package/dst/specbook-format.yaml +224 -23
  36. package/dst/specbook-ignore.js +2 -0
  37. package/dst/specbook-link.js +21 -5
  38. package/dst/specbook-parse-common.d.ts +24 -0
  39. package/dst/specbook-parse-common.js +137 -10
  40. package/dst/specbook-parse-semantic.js +2 -0
  41. package/dst/specbook-parse-syntax.js +81 -20
  42. package/dst/specbook-parse.js +1 -1
  43. package/dst/specbook-theme.d.ts +1 -0
  44. package/dst/specbook-theme.js +2 -0
  45. package/package.json +17 -11
@@ -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({
@@ -123,7 +123,7 @@ type SchemaDiagram = {
123
123
  config?: Partial<GradiaConfig>
124
124
  }
125
125
  type SchemaFormat = {
126
- type?: "auto" | "complex" | "concise"
126
+ type?: "auto" | "complex" | "compact" | "concise"
127
127
  maxTableColumns?: number
128
128
  maxCellHeight?: number
129
129
  withUnusedProps?: boolean
@@ -429,8 +429,9 @@ type SchemaGradiaConfig = Partial<{
429
429
  HTML/PDF rendering of the objects of an object kind among their siblings,
430
430
  BECAUSE one uniform rendering fits neither rich nor numerous objects
431
431
 
432
- - `SchemaFormat.type?: "auto" | "complex" | "concise"`:
433
- rendering: `complex` (sections), `concise` (tables), or `auto`,
432
+ - `SchemaFormat.type?: "auto" | "complex" | "compact" | "concise"`:
433
+ rendering: `complex` (sections), `concise` (tables), `compact`
434
+ (tables of section-like cells), or `auto`,
434
435
  BECAUSE sections bury small objects, tables truncate rich ones
435
436
 
436
437
  - `SchemaFormat.maxTableColumns?: number`:
@@ -649,15 +650,20 @@ how the HTML/PDF export renders the objects of that kind among their
649
650
  siblings, so that the sibling kinds below one parent can render
650
651
  differently (e.g. one kind as a table, another as sections). Its `type`
651
652
  selects the rendering: `complex` (nested sections), `concise` (one
652
- compact table of all sibling objects of the kind), or the default
653
- `auto`, which collapses only the deepest level into tables.
653
+ compact table of all sibling objects of the kind), `compact` (one
654
+ table of the name and a single cell per object, which renders the
655
+ object like `complex`, but without its heading: diagram, properties as
656
+ key/value lines, and description, for a kind whose many properties a
657
+ table row cannot hold), or the default `auto`, which collapses only the
658
+ deepest level into tables.
654
659
 
655
660
  Inside a `concise` table, unconfigured child kinds implicitly stay
656
661
  `concise` and render as sub-tables inside the description cells, while
657
662
  an explicitly configured `type` is always honored, even a `complex`
658
663
  rendering pressed into a cell. `maxTableColumns` (default: `4`) bounds
659
664
  the columns of the compact table of the kind (a wider group chunks
660
- its properties into embedded per-object tables), `maxCellHeight`
665
+ its properties into embedded per-object tables, while a `compact`
666
+ one ignores it), `maxCellHeight`
661
667
  (default: `40`) is the percentage a table cell of the kind may exceed
662
668
  the height of every other non-empty cell of its row before the HTML
663
669
  export folds its remaining text away, and `withUnusedProps`
@@ -702,6 +708,7 @@ type SpecObject = {
702
708
  type SpecDescription = {
703
709
  description: string
704
710
  rationale?: string
711
+ elaboration?: string
705
712
  embedding?: string[]
706
713
  }
707
714
  type SpecProperty = {
@@ -776,7 +783,7 @@ type SpecProperty = {
776
783
  BECAUSE a specification is a tree, not a flat list of statements
777
784
 
778
785
  - `SpecDescription`:
779
- description of an object: statement, rationale, and images,
786
+ description of an object: statement, rationale, elaboration, and images,
780
787
  BECAUSE the WHAT and the WHY are stored apart to be rendered apart
781
788
 
782
789
  - `SpecDescription.description: string`:
@@ -785,11 +792,18 @@ type SpecProperty = {
785
792
 
786
793
  - `SpecDescription.rationale?: string`:
787
794
  rationale text behind the `, BECAUSE ` split (the WHY),
795
+ up to the end of its paragraph,
788
796
  BECAUSE a statement without its WHY cannot be judged or revised
789
797
 
798
+ - `SpecDescription.elaboration?: string`:
799
+ blocks following the paragraph of the rationale (e.g. images or diagrams),
800
+ BECAUSE further content must not end up inside the WHY
801
+
790
802
  - `SpecDescription.embedding?: string[]`:
791
- image files embedded via `![xxx](yyy)`, inlined at parse time,
792
- one entry per file in markup order (empty for an unreadable file),
803
+ image, diagram source, and source code files embedded via `![xxx](yyy)`,
804
+ inlined at parse time, one entry per file in markup order (those of
805
+ the statement first, those of the elaboration second, and empty for
806
+ an unreadable file),
793
807
  BECAUSE the exports have to stand alone, without the image files
794
808
 
795
809
  - `SpecProperty`:
@@ -805,7 +819,8 @@ type SpecProperty = {
805
819
  BECAUSE constraints and references are checked against this text
806
820
 
807
821
  - `SpecProperty.embedding?: string[]`:
808
- image files embedded in the value, inlined at parse time,
822
+ image, diagram source, and source code files embedded in the value,
823
+ inlined at parse time,
809
824
  one entry per file in markup order (empty for an unreadable file),
810
825
  BECAUSE the exports have to stand alone, without the image files
811
826
 
@@ -992,10 +1007,15 @@ paragraphs, blockquotes, ordered lists (e.g. scenario steps or test
992
1007
  case procedures), and fenced code blocks. A fenced code block of
993
1008
  language `gradia` is skipped, as it is the derived diagram which
994
1009
  **SpecBook** itself emits into exported Markdown and which must not
995
- become authored content on a re-parse. In the Concise Format, the
996
- description is formed by the non-property segments of the item, so a
997
- description carrying a `;` cannot be a segment either and forces its
998
- object into the Complex Format.
1010
+ become authored content on a re-parse. A fenced code block of language
1011
+ `mermaid` (alias `mmd`) or `d2` is a [Mermaid](https://mermaid.js.org/)
1012
+ or [D2](https://d2lang.com/) diagram, which the HTML/PDF export renders
1013
+ as a diagram instead of as code (see "Diagram Embeddings" below). A
1014
+ fenced code block of any other known language is a syntax-highlighted
1015
+ code listing (see "Code Listings" below). In
1016
+ the Concise Format, the description is formed by the non-property
1017
+ segments of the item, so a description carrying a `;` cannot be a
1018
+ segment either and forces its object into the Complex Format.
999
1019
 
1000
1020
  A description is split into its *statement* and *rationale* at the first
1001
1021
  `, BECAUSE ` (or `, **BECAUSE** `) occurrence:
@@ -1005,10 +1025,25 @@ The event is created and configured but not visible to attendees,
1005
1025
  BECAUSE an event needs a private setup phase.
1006
1026
  ```
1007
1027
 
1028
+ The rationale ends with its paragraph: all blocks following it (further
1029
+ paragraphs, embedded images, and diagrams) form the *elaboration*, which
1030
+ is rendered below the statement and its rationale, so the description
1031
+ of a Complex Format object can state its WHAT and WHY first and
1032
+ illustrate them afterwards:
1033
+
1034
+ ````
1035
+ The event is created and configured but not visible to attendees,
1036
+ BECAUSE an event needs a private setup phase.
1037
+
1038
+ ![Event Lifecycle](event-lifecycle.mmd)
1039
+ ````
1040
+
1008
1041
  ### Wiki-Style References
1009
1042
 
1010
1043
  A `[[<reference/>]]` anywhere in a name, property value, description, or
1011
- rationale is a Wiki-style reference to another object, which is checked
1044
+ rationale (except inside a fenced code block, whose code stays verbatim,
1045
+ so e.g. a shell test `[[ -f x ]]` is no reference) is a Wiki-style
1046
+ reference to another object, which is checked
1012
1047
  for unique resolvability and rendered as a hyperlink in the HTML/PDF
1013
1048
  exports. A reference is a `.`-separated path of segments, where each
1014
1049
  segment matches a single object as:
@@ -1046,7 +1081,21 @@ SVG files are inlined as-is; PNG/JPEG/WebP files are embedded as base64
1046
1081
  `data:` URLs. URLs and other file types are left untouched. The
1047
1082
  HTML/PDF export optimizes the embedded images on its own (downscaled
1048
1083
  and re-encoded PNG/JPEG/WebP, minified SVG), so the files can stay in their
1049
- original resolution.
1084
+ original resolution. Like a diagram, an embedded image (except the logo
1085
+ of the title page) folds, maximizes, and is omitted (`-O diagram:image`,
1086
+ or by nesting level) in the HTML/PDF export, while the AST exports keep
1087
+ it.
1088
+
1089
+ A page of a PDF document is embedded like an image --
1090
+ `![<alt/>](<file/>.pdf)` for the first page, or
1091
+ `![<alt/>](<file/>.pdf#page=<n/>)` for the 1-based page number `<n/>` --
1092
+ where the document is inlined at parse time. The exports convert the
1093
+ page through `pdfjs-dist` into a vector SVG with its texts as glyph
1094
+ outlines and then treat it exactly like an embedded SVG image, while a
1095
+ page failing to render (e.g. an absent page number) is omitted with a
1096
+ notice. A presentation (e.g. PowerPoint), a spreadsheet (e.g. Excel), or
1097
+ any other office document is embedded through its PDF export, which
1098
+ carries its exact rendering and fonts.
1050
1099
 
1051
1100
  A reference carrying the `{theme}` placeholder -- e.g.
1052
1101
  `![Logo](logo-{theme}.svg)` -- is a *theme-aware* embedding: it expands
@@ -1056,10 +1105,113 @@ HTML export shows just the variant matching the color theme currently
1056
1105
  active in the document, while the PDF export (like print in general)
1057
1106
  always uses the `light` variant.
1058
1107
 
1108
+ An image without a dark variant is treated on the dark theme of the HTML
1109
+ export as its `dark` fragment parameter says (see "Fragment Parameters"
1110
+ below), while print always keeps it as it is. An image defaults to
1111
+ `dark=auto`, which inverts it (with its hues rotated back) if it most
1112
+ likely draws dark lines onto a transparent or light canvas: an SVG if it
1113
+ neither adapts itself (via `light-dark()` colors, `prefers-color-scheme`
1114
+ rules, or a `color-scheme` declaration, which follows the color theme of
1115
+ the document anyway), nor embeds a raster image, nor paints a background
1116
+ which is not light, and a raster image (PNG/JPEG/WebP) if at least 60%
1117
+ of its pixels are transparent or near-white and the others are dark on
1118
+ average and of a few colors only (unlike a photo or a colorful
1119
+ screenshot). As the detection cannot tell a logo or illustration with
1120
+ brand colors from a line drawing, `#dark=none` keeps such an image as it
1121
+ is, while `#dark=invert` forces the inversion (e.g.
1122
+ `![Overview](overview.svg#dark=invert)`). A
1123
+ [draw.io](https://www.drawio.com/) diagram is best embedded as its
1124
+ editable SVG (`.drawio.svg`, which draw.io keeps current on every save)
1125
+ with its "Adaptive Colors" page setting on "Automatic", as it then
1126
+ adapts all its colors itself. A theme-aware embedding ignores the
1127
+ fragment, as its `dark` variant already serves the dark theme.
1128
+
1059
1129
  The reference-style image `![<alt/>][<label/>]` embeds the image of its
1060
1130
  definition `[<label/>]: data:image/<type/>;base64,<data/>` (SVG, PNG,
1061
- JPEG, or WebP) in the same file, the only supported link definition.
1131
+ JPEG, or WebP, where a `;dark=invert` or `;dark=none` parameter behind
1132
+ the type carries the treatment on the dark theme, which the export
1133
+ always resolves for an SVG) in the same file, the only supported link
1134
+ definition.
1062
1135
  This is the form the normalized Markdown export emits for every embedded
1063
1136
  image (a theme-aware one by its `light` variant), with the definitions
1064
1137
  at the end of the document, so it stands alone without the image files.
1065
1138
  Author the inline form; the reference-style one is for re-parsing only.
1139
+
1140
+ ### Diagram Embeddings
1141
+
1142
+ A [Mermaid](https://mermaid.js.org/) or [D2](https://d2lang.com/)
1143
+ diagram is either written inline into a description as a fenced code
1144
+ block of language `mermaid` (alias `mmd`) or `d2`, or kept in a file of
1145
+ its own and embedded like an image -- `![<alt/>](<file/>.mmd)`,
1146
+ `![<alt/>](<file/>.mermaid)`, or `![<alt/>](<file/>.d2)` -- inside a
1147
+ description or property value, where its source is inlined at parse time.
1148
+ The HTML/PDF export renders every such diagram (Mermaid through
1149
+ `beautiful-mermaid`, D2 through `@terrastruct/d2`) in the theme colors of
1150
+ the document, once per color theme, like a theme-aware image. It folds,
1151
+ maximizes, and is omitted (`-O diagram:code`, for diagrams as code, or
1152
+ by nesting level) like a derived Gradia diagram. A diagram failing to render
1153
+ is omitted with a notice. The normalized Markdown and the AST exports
1154
+ carry the source of a fenced diagram as part of the description, and an
1155
+ embedded diagram file as its rendered SVG (the `light` variant).
1156
+
1157
+ ### Code Listings
1158
+
1159
+ A code snippet is either written inline into a description as a fenced
1160
+ code block of a known language (any language id or alias of
1161
+ [Shiki](https://shiki.style/), like `ts`, `java`, `css`, or `html`), or
1162
+ kept in a source code file of its own and embedded like an image --
1163
+ `![<alt/>](<file/>.<ext/>)`, where `<ext/>` is such a language id or
1164
+ alias (e.g. `.ts`, `.java`, `.css`, `.html`) -- inside a description or
1165
+ property value, where its source is inlined at parse time. A fenced code
1166
+ block of no or an unknown language stays plain preformatted text.
1167
+
1168
+ The HTML/PDF export renders every such snippet as a *code listing* in
1169
+ DejaVu Sans Mono, with line numbers and with its keywords, operators,
1170
+ and punctuation in the accent color and bold, its literals in the signal
1171
+ color, and its comments in the dim color and italic. A code listing
1172
+ folds, maximizes, and is omitted (`-O diagram:listing`, or by nesting
1173
+ level) like a diagram.
1174
+
1175
+ A fenced code listing takes whitespace-separated parameters behind its
1176
+ language, and an embedded one takes them as its fragment (see "Fragment
1177
+ Parameters" below):
1178
+
1179
+ - `start=<n/>` (fenced only): the number of the first line (default `1`).
1180
+ - `lines=<a/>-<b/>` or `lines=<a/>` (embedded only): the excerpt of the
1181
+ 1-based lines `<a/>` to `<b/>` (or line `<a/>` alone), numbered from
1182
+ `<a/>`, where an empty range is reported as an error.
1183
+ - `mark=<a/>[-<b/>][,...]`: the lines or line ranges to mark (in the
1184
+ shown line numbers) on a very light signal background.
1185
+
1186
+ ````
1187
+ ```ts start=10 mark=12-13
1188
+ const greeter = new Greeter("World")
1189
+ ```
1190
+
1191
+ ![Greeter Class](greeter.ts#lines=5-20&mark=8,12-14)
1192
+ ````
1193
+
1194
+ The AST exports carry an embedded source code file as its
1195
+ `data:text/x-code;lang=<lang/>;start=<n/>[;mark=<hunks/>];base64,<data/>`
1196
+ URL, while the normalized Markdown export turns one of a description into
1197
+ a fenced code block (with its `start` and `mark` parameters) at the end
1198
+ of the elaboration, and keeps one of a property value as a
1199
+ reference-style image definition of that URL.
1200
+
1201
+ #### Fragment Parameters
1202
+
1203
+ The fragment of an embedding reference is a list of `<key/>=<value/>`
1204
+ parameters joined by `&`, in any order and each at most once, with the
1205
+ values optionally URI-encoded (e.g. `%20` for a space): `page`
1206
+ (documents), `dark` (documents and images), and `lines` and `mark`
1207
+ (source code files, see "Code Listings" above). A fragment carrying any
1208
+ other key or an invalid value is reported as an error. The `dark`
1209
+ parameter selects the treatment on the dark theme of the HTML export,
1210
+ while print always shows the original: `dark=invert` inverts the
1211
+ embedding with its hues rotated back, `dark=none` shows it as it is, and
1212
+ `dark=auto` (the default) decides by the content of the image or the
1213
+ rendered document page (inverting a light canvas with dark ink, but
1214
+ neither a dark one nor one carrying a photo), as an image and a document
1215
+ page can be anything (line drawings, photos, logos); e.g.
1216
+ `![<alt/>](<file/>.pdf#page=3&dark=none)` keeps a page which the
1217
+ detection would invert, but whose colors have to stay.