@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
package/README.md CHANGED
@@ -83,6 +83,8 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
83
83
  The HTML export is a self-contained single document with a title page,
84
84
  a table-of-contents (also as a slide-in side panel for quick navigation),
85
85
  a diagram-of-contents, a fuzzy full-text search, embedded images,
86
+ embedded Mermaid and D2 diagrams (inline or as files, in the theme colors),
87
+ embedded PDF pages,
86
88
  reference coverage tables (e.g. the share of the use cases, scenarios,
87
89
  requirements, and rules the test cases verify), a
88
90
  scroll progress meter (which also scrolls back to the top on click),
@@ -242,7 +244,8 @@ Options:
242
244
  Omit content aspects (comma-separated, repeatable) from the HTML and
243
245
  PDF outputs at generation time, instead of just leaving them foldable
244
246
  in the browser. The aspects match the folding controls of the HTML
245
- export: `diagram:graph`, `diagram:hub`, and `diagram:grid` omit all
247
+ export: `diagram:graph`, `diagram:hub`, `diagram:grid`,
248
+ `diagram:code` (Mermaid/D2), and `diagram:image` (images and PDF pages) omit all
246
249
  diagrams of a type, `diagram:1` (alias: `diagram`), `diagram:2`, and
247
250
  `diagram:3` omit all diagrams from an object tree nesting level on
248
251
  (level 1 hence all, including the "Diagram of Contents" page), and
@@ -321,6 +324,10 @@ validated (an unknown entry or a wrongly typed value fails the command),
321
324
  and both an explicitly supplied option and its environment variable
322
325
  win over its entry.
323
326
 
327
+ `specbook init` creates such a file in the current working directory
328
+ (unless one already applies), so the options of the initialization
329
+ stick for all subsequent commands.
330
+
324
331
  The default value of every CLI option `--xxx` can be overridden
325
332
  by a corresponding `SPECBOOK_XXX` environment variable (e.g.
326
333
  `SPECBOOK_BASEDIR`, `SPECBOOK_CONFIG`, `SPECBOOK_OUTPUT`,
@@ -419,7 +426,7 @@ application, based on **SpecBook**'s built-in "standard" schema:
419
426
  ### Sources
420
427
 
421
428
  - [Specification](smp/broadcast/)
422
- - [Schema](src/specbook-format.d/)
429
+ - [Schema](src/specbook-format-schema.d/)
423
430
 
424
431
  ### Generation
425
432
 
@@ -28,6 +28,10 @@ export interface ProjectOptions {
28
28
  basedir?: string;
29
29
  cwd?: string;
30
30
  }
31
+ export interface InitResult {
32
+ files: string[];
33
+ project?: string;
34
+ }
31
35
  export interface ExportRequest extends ProjectOptions {
32
36
  formats?: ExportFormat[];
33
37
  realtime?: boolean;
@@ -46,7 +50,7 @@ export declare class SpecBook {
46
50
  private project;
47
51
  private configFiles;
48
52
  private requireConfig;
49
- init(options: ProjectOptions): Promise<string[]>;
53
+ init(options: ProjectOptions): Promise<InitResult>;
50
54
  lint(options: ProjectOptions & {
51
55
  gitignore?: boolean;
52
56
  }): Promise<LintResult>;
@@ -8,7 +8,7 @@ import * as path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { glob } from "glob";
10
10
  import { loadConfig, configStatistics } from "./specbook-config.js";
11
- import { loadProject, projectFile } from "./specbook-project.js";
11
+ import { loadProject, writeProject, projectFile } from "./specbook-project.js";
12
12
  import { renderDiagnostic, renderDiagnosticVerbose } from "./specbook-diagnostic.js";
13
13
  import { initSpecification } from "./specbook-cmd-init.js";
14
14
  import { lint } from "./specbook-cmd-lint.js";
@@ -62,6 +62,7 @@ export class SpecBook {
62
62
  const given = (value) => options.cwd !== undefined ? path.resolve(cwd, value) : value;
63
63
  const derived = (value) => options.cwd !== undefined ? value : path.relative(cwd, value) || ".";
64
64
  return {
65
+ file: project?.file,
65
66
  config: config !== undefined && config.length > 0 ?
66
67
  config.map((pattern) => pattern === "std" ? pattern : given(pattern)) :
67
68
  project?.config?.map((pattern) => pattern === "std" ? pattern : derived(pattern)),
@@ -105,13 +106,24 @@ export class SpecBook {
105
106
  `and ${literal(stats.links)} link relationship(s)`);
106
107
  return config;
107
108
  }
108
- /* initialize the configured specification artifact files
109
- below the base directory */
109
+ /* initialize the configured specification artifact files below the
110
+ base directory and persist the effective project options into a
111
+ new project configuration file in the working directory, unless
112
+ one already applies (as a new one would shadow it) */
110
113
  async init(options) {
111
114
  const verbose = this.verboseOf("init");
112
115
  const project = this.project(options, verbose);
113
- return initSpecification({ config: this.requireConfig(await this.configFiles(project.config), verbose),
116
+ const files = initSpecification({ config: this.requireConfig(await this.configFiles(project.config), verbose),
114
117
  basedir: project.basedir ?? ".", verbose });
118
+ if (project.file !== undefined) {
119
+ verbose(`skipping existing project configuration "${literal(project.file)}"`);
120
+ return { files };
121
+ }
122
+ const cwd = path.resolve(options.cwd ?? ".");
123
+ const relative = (value) => path.relative(cwd, path.resolve(cwd, value)) || ".";
124
+ const file = writeProject(cwd, project.config?.map((pattern) => pattern === "std" ? pattern : relative(pattern)) ?? ["std"], relative(project.basedir ?? "."));
125
+ verbose(`created project configuration "${literal(file)}"`);
126
+ return { files, project: options.cwd !== undefined ? file : path.relative(process.cwd(), file) };
115
127
  }
116
128
  /* lint the specification Markdown files below the base directory,
117
129
  where "gitignore" treats an artifact file Git excludes from its
@@ -125,10 +137,11 @@ export class SpecBook {
125
137
  /* render an already parsed specification into the requested formats
126
138
  (strict: any error diagnostic prevents the export, as a partial or
127
139
  invalid specification must never be emitted, while the warnings
128
- are just surfaced as notices), where "rendering" carries the
129
- rendering options (the live preview script, the omitted aspects)
130
- and "rebase" names, per requested format, the directory the image
131
- references of the normalized Markdown are re-based onto */
140
+ are just surfaced as unconditional verbose messages), where
141
+ "rendering" carries the rendering options (the live preview
142
+ script, the omitted aspects) and "rebase" names, per requested
143
+ format, the directory the image references of the normalized
144
+ Markdown are re-based onto */
132
145
  async renderFormats(result, requested, verbose, rendering, rebase) {
133
146
  if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error"))
134
147
  throw new Error("invalid specification:\n" +
@@ -163,11 +176,12 @@ export class SpecBook {
163
176
  const requested = options.formats ?? ["json"];
164
177
  const omit = parseOmit(options.omit);
165
178
  const project = this.project(options, verbose);
179
+ const config = await this.configFiles(project.config);
166
180
  /* a missing browser is an environment problem, so let the PDF
167
181
  export fail before the specification is even parsed */
168
182
  if (requested.includes("pdf"))
169
183
  await requireBrowser(verbose);
170
- const result = lint({ config: await this.configFiles(project.config),
184
+ const result = lint({ config,
171
185
  basedir: project.basedir ?? ".", gitignore: options.gitignore === true, verbose });
172
186
  return this.renderFormats(result, requested, verbose, { realtime: options.realtime === true, omit }, this.rebaseOf(options));
173
187
  }
@@ -130,15 +130,18 @@ withVerboseOption(program.command("mcp"))
130
130
  verboseOf(opts)("mcp", "starting MCP server on stdio", "notice");
131
131
  await serveMcp(verboseOf(opts, "mcp"));
132
132
  });
133
- /* the init command creates the configured artifact files */
133
+ /* the init command creates the configured artifact files and,
134
+ unless one already applies, the project configuration file */
134
135
  withCommonOptions(program.command("init"))
135
136
  .description("initialize the configured specification artifact files below the base directory")
136
137
  .action(async (opts) => {
137
138
  const specbook = new SpecBook({ verbose: verboseOf(opts) });
138
- const created = await specbook.init({ config: configOf(opts), basedir: opts.basedir });
139
- await writeStdout(created.length > 0 ?
140
- `initialized artifact file(s): ${created.join(", ")}\n` :
141
- "no artifact files were created\n");
139
+ const result = await specbook.init({ config: configOf(opts), basedir: opts.basedir });
140
+ await writeStdout((result.files.length > 0 ?
141
+ `initialized artifact file(s): ${result.files.join(", ")}\n` :
142
+ "no artifact files were created\n") +
143
+ (result.project !== undefined ?
144
+ `created project configuration file: ${result.project}\n` : ""));
142
145
  });
143
146
  /* the lint command reports all diagnostics and fails on any error */
144
147
  withGitignoreOption(withCommonOptions(program.command("lint")))
@@ -10,7 +10,7 @@ import * as fs from "node:fs";
10
10
  import { watch } from "chokidar5";
11
11
  import { minify } from "@swc/html";
12
12
  import { documentTitle, documentLogo, documentCharset, documentThemeTone, subsetStylesheet, documentPaperSize, paperStylesheet, charsetCodepoints, parseOmit, omitAspects } from "./specbook-export-common.js";
13
- import { themeColors, themeStylesheet, themeMapping } from "./specbook-theme.js";
13
+ import { themeColors, themeToneDefault, themeStylesheet, themeMapping } from "./specbook-theme.js";
14
14
  import { renderAst } from "./specbook-export-ast.js";
15
15
  import { renderMarkdown } from "./specbook-export-md.js";
16
16
  import { renderHtml, htmlOutline, titlePageObject } from "./specbook-export-html.js";
@@ -169,7 +169,7 @@ const renderFormat = async (specification, format, verbose, config, options = {}
169
169
  verbose(`subsetting embedded fonts to charset "${literal(charset)}"`);
170
170
  /* the theme tone (THEME-TONE) drives the layer-1 color spread
171
171
  variables and the PDF decoration colors */
172
- const tone = documentThemeTone(specification) ?? "#336699";
172
+ const tone = documentThemeTone(specification) ?? themeToneDefault;
173
173
  verbose(`generating theme color spreads (tone "${literal(tone)}")`);
174
174
  const colors = themeColors(tone);
175
175
  /* the paper size (PAPER-SIZE) drives the PDF page setup and the
@@ -7,8 +7,22 @@ import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
8
  import { literal } from "./specbook-verbose.js";
9
9
  import { formatTimestamp } from "./specbook-export-md.js";
10
+ import { titleProperties } from "./specbook-export-common.js";
11
+ /* render the property list of the title artifact, seeding the properties
12
+ the exports interpret themselves with their rendering defaults (as far
13
+ as the schema configures them, as any other is reported as unknown) */
14
+ const titleList = (artifact) => {
15
+ if (artifact.kind !== "META" || artifact.name?.toUpperCase() !== "TITLE")
16
+ return "";
17
+ const props = titleProperties.filter((prop) => artifact.props?.some((p) => p.name === prop.name) === true);
18
+ if (props.length === 0)
19
+ return "";
20
+ const width = Math.max(...props.map((prop) => prop.name.length)) + 2;
21
+ return props.map((prop) => `- ${`${prop.name}:`.padEnd(width)}${prop.init}`.trimEnd() + "\n").join("") + "\n";
22
+ };
10
23
  /* initialize the configured specification artifact files below the
11
- base directory with their frontmatter and artifact headings, where
24
+ base directory with their frontmatter and artifact headings (plus
25
+ the title properties), where
12
26
  all artifacts configured onto the same file reside in it side by
13
27
  side, following each other on level 1 */
14
28
  export const initSpecification = (options) => {
@@ -38,7 +52,7 @@ export const initSpecification = (options) => {
38
52
  const headings = artifacts.map((artifact) => {
39
53
  const name = artifact.name ?? "";
40
54
  const paren = artifact.id !== undefined ? ` (${artifact.id})` : "";
41
- return `# ${artifact.kind}: ${name}${paren}\n\n`;
55
+ return `# ${artifact.kind}: ${name}${paren}\n\n` + titleList(artifact);
42
56
  }).join("");
43
57
  const text = "---\n" +
44
58
  `Created: ${now}\n` +
@@ -17,7 +17,7 @@ const referrerIndex = (index) => {
17
17
  const chain = chainOf(index, object);
18
18
  const texts = [object.name, ...object.properties.map((p) => p.value)];
19
19
  if (object.description !== undefined)
20
- texts.push(object.description.description, object.description.rationale ?? "");
20
+ texts.push(object.description.description, object.description.rationale ?? "", object.description.elaboration ?? "");
21
21
  for (const text of texts)
22
22
  for (const m of plainText(text).matchAll(referenceRegex)) {
23
23
  const target = resolveUnique(index, m[1].trim(), object).target;
@@ -15,7 +15,7 @@ export interface DiagramResult {
15
15
  }
16
16
  export declare const specDiagrams: (specification: Spec, config: Schema) => Map<SpecObject, DiagramResult>;
17
17
  export declare const omittedDiagrams: (specification: Spec, config: Schema, omit: Set<OmitAspect>) => Set<SpecObject>;
18
- export declare const renderDiagrams: (specification: Spec, config: Schema, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Map<SpecObject, DiagramResult & {
18
+ export declare const renderDiagrams: (specification: Spec, config: Schema, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:code" | "diagram:image" | "diagram:listing" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Map<SpecObject, DiagramResult & {
19
19
  svg: string;
20
20
  }>>;
21
21
  export declare const validateDiagrams: (ctx: ParseContext, specification: Spec, config: Schema) => void;
@@ -180,6 +180,8 @@ const deriveReferenceEdges = (ctx, diagram, nodes, nodeSet) => {
180
180
  texts.push({ text: object.description.description });
181
181
  if (object.description.rationale !== undefined)
182
182
  texts.push({ text: object.description.rationale });
183
+ if (object.description.elaboration !== undefined)
184
+ texts.push({ text: object.description.elaboration });
183
185
  }
184
186
  for (const { text, name } of texts)
185
187
  for (const m of plainText(text).matchAll(referenceRegex)) {
@@ -342,14 +344,10 @@ const deriveCenter = (ctx, object, diagram, nodes, nodeSet, errors) => {
342
344
  return undefined;
343
345
  }
344
346
  centerUrl = `#${anchors.get(source) ?? source.id}`;
345
- if (centerCfg.property !== undefined) {
346
- const value = propValue(parenProps, source, centerCfg.property);
347
- if (value === undefined) {
348
- errors.push({ reason: `diagram "center" source lacks the property "${centerCfg.property}"` });
349
- return undefined;
350
- }
347
+ const value = centerCfg.property !== undefined ?
348
+ propValue(parenProps, source, centerCfg.property) : undefined;
349
+ if (value !== undefined)
351
350
  label = plainText(expandReferences(value, (ref) => resolveUnique(index, ref, source).target?.name ?? ref));
352
- }
353
351
  else if (label === undefined)
354
352
  label = plainText(source.name);
355
353
  }
@@ -512,7 +510,8 @@ const deriveNodes = (index, object, diagram, errors) => {
512
510
  const nodes = new Array();
513
511
  const walk = (o) => {
514
512
  nodes.push(o);
515
- o.children.forEach(walk);
513
+ for (const child of o.children)
514
+ walk(child);
516
515
  };
517
516
  walk(object);
518
517
  return nodes;
@@ -600,14 +599,17 @@ const deriveDiagram = (ctx, object, diagram) => {
600
599
  }
601
600
  /* the "onlyConnected" filtering keeps only the nodes with at
602
601
  least one incident edge (sensible for "graph" diagrams only),
603
- and the container nodes, whose members connect them */
602
+ and the containers enclosing such a node */
604
603
  if (diagram.onlyConnected === true && type === "graph") {
605
604
  const connected = new Set();
606
605
  for (const edge of edges) {
607
606
  connected.add(edge.source);
608
607
  connected.add(edge.target);
609
608
  }
610
- nodes = nodes.filter((node) => connected.has(node) || nesting.containers.has(node));
609
+ for (const node of Array.from(connected))
610
+ for (let p = nesting.parentOf.get(node); p !== undefined; p = nesting.parentOf.get(p))
611
+ connected.add(p);
612
+ nodes = nodes.filter((node) => connected.has(node));
611
613
  }
612
614
  if (errors.length > 0)
613
615
  return { errors };
@@ -2,4 +2,4 @@ import type { Spec } from "./specbook-format-spec.js";
2
2
  import type { Schema } from "./specbook-format-schema.js";
3
3
  import type { Verbose } from "./specbook-verbose.js";
4
4
  export type AstFormat = "json" | "json5" | "yaml" | "toon";
5
- export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Buffer>;
5
+ export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:code" | "diagram:image" | "diagram:listing" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Buffer>;
@@ -10,17 +10,34 @@ import { specDiagrams, omittedDiagrams } from "./specbook-diagram.js";
10
10
  import { specCoverage } from "./specbook-coverage.js";
11
11
  import { buildLinkIndex } from "./specbook-link.js";
12
12
  import { collectSchemas } from "./specbook-parse-semantic.js";
13
- import { isTitleObject } from "./specbook-export-common.js";
14
- import { optimizeImages } from "./specbook-export-image.js";
13
+ import { isTitleObject, imageDark } from "./specbook-export-common.js";
14
+ import { optimizeImages, analyzeImages, isDocument } from "./specbook-export-image.js";
15
+ import { renderEmbeddedDiagrams, embeddedSource, diagramKey } from "./specbook-export-diagram.js";
16
+ import { darkMark, markDark } from "./specbook-parse-common.js";
15
17
  /* render the specification AST into a serialization format,
16
18
  where "omit" names the aspects whose diagrams are left out */
17
19
  export const renderAst = async (specification, format, config, verbose, omit = new Set()) => {
18
20
  /* reduce the specification to plain JSON values (ISO date strings),
19
21
  with the embedded images (by far the bulk of the export)
20
- optimized like the ones of the HTML export */
22
+ optimized like the ones of the HTML export and the embedded
23
+ diagram source files replaced by their rendered SVGs (the light
24
+ variant), an image (a PDF page as its converted SVG) marked as
25
+ inverted where the "auto" detection inverts it, and an omitted or
26
+ failed diagram or PDF page leaving an empty entry, like an
27
+ unreadable file) */
21
28
  const optimized = await optimizeImages(specification, false, verbose);
29
+ const rasters = await analyzeImages(specification, verbose);
30
+ const sources = await renderEmbeddedDiagrams(specification, verbose, omit);
22
31
  const plain = JSON.parse(JSON.stringify(specification, (key, value) => key !== "embedding" ? value :
23
- value.map((content) => optimized.get(content) ?? content)));
32
+ value.map((content) => {
33
+ const diagram = embeddedSource(content);
34
+ if (diagram === undefined) {
35
+ const image = optimized.get(content) ?? (isDocument(content) ? "" : content);
36
+ return image !== "" && darkMark(image).dark === undefined
37
+ && imageDark(content, rasters) === "invert" ? markDark(image, "invert") : image;
38
+ }
39
+ return sources.get(diagramKey(diagram.language, diagram.source))?.light ?? "";
40
+ })));
24
41
  /* attach the Gradia specs of the diagram-configured objects as
25
42
  "diagram" fields onto the corresponding plain object nodes
26
43
  (an invalid diagram situation omits the diagram, as it is
@@ -0,0 +1,14 @@
1
+ import { type Tokens } from "marked";
2
+ import type { Spec } from "./specbook-format-spec.js";
3
+ import { type Verbose } from "./specbook-verbose.js";
4
+ export interface Listing {
5
+ lang: string;
6
+ source: string;
7
+ start: number;
8
+ mark: string;
9
+ }
10
+ export type ListingRenderer = (listing: Listing) => string;
11
+ export declare const embeddedListing: (content: string) => Listing | undefined;
12
+ export declare const fencedListing: (token: Tokens.Code) => Listing | undefined;
13
+ export declare const listingFence: (listing: Listing) => string;
14
+ export declare const prepareListings: (specification: Spec, verbose?: Verbose) => Promise<ListingRenderer>;
@@ -0,0 +1,155 @@
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 { marked } from "marked";
7
+ import { codeLanguage, fenceInfo, markedLines } from "./specbook-parse-common.js";
8
+ import { escapeHtml } from "./specbook-export-common.js";
9
+ import { literal } from "./specbook-verbose.js";
10
+ /* decode an embedded source code file, which the parser loads as a base64
11
+ data: URL of its code MIME type (undefined for any other content) */
12
+ export const embeddedListing = (content) => {
13
+ const m = content.match(/^data:text\/x-code;lang=([^;,]+);start=(\d+)(?:;mark=([^;,]+))?;base64,(.*)$/s);
14
+ return m !== null ? {
15
+ lang: m[1],
16
+ source: Buffer.from(m[4], "base64").toString("utf8"),
17
+ start: Number(m[2]),
18
+ mark: decodeURIComponent(m[3] ?? "")
19
+ } : undefined;
20
+ };
21
+ /* determine the code listing of a fenced code block (undefined for a
22
+ block of no or an unknown language, and for invalid parameters, which
23
+ the parser reports) */
24
+ export const fencedListing = (token) => {
25
+ const info = fenceInfo(token.lang);
26
+ const lang = codeLanguage(info.lang);
27
+ if (lang === undefined || info.error !== undefined)
28
+ return undefined;
29
+ return { lang, source: token.text, start: Number(info.params.start ?? "1"), mark: info.params.mark ?? "" };
30
+ };
31
+ /* render a code listing back into a fenced code block, with its
32
+ parameters deviating from the defaults (the fence outgrowing the
33
+ longest backtick run of the source code) */
34
+ export const listingFence = (listing) => {
35
+ const run = Math.max(2, ...Array.from(listing.source.matchAll(/`+/g), (m) => m[0].length));
36
+ const fence = "`".repeat(run + 1);
37
+ const params = [listing.start !== 1 ? ` start=${listing.start}` : "",
38
+ listing.mark !== "" ? ` mark=${listing.mark}` : ""].join("");
39
+ return `${fence}${listing.lang}${params}\n${listing.source}\n${fence}`;
40
+ };
41
+ /* the marker theme of the syntax highlighting: it maps the TextMate
42
+ scopes onto marker colors, which the rendering turns into the token
43
+ classes, so no color enters the HTML and the stylesheet alone colors
44
+ the tokens (the more specific scope wins, e.g. the string quotes
45
+ over the other punctuation, and the code embedded into a string
46
+ turns regular again) */
47
+ const tokenClasses = {
48
+ "#000001": "code-keyword",
49
+ "#000002": "code-literal",
50
+ "#000003": "code-comment"
51
+ };
52
+ const markerTheme = {
53
+ name: "specbook",
54
+ type: "light",
55
+ settings: [
56
+ { settings: { foreground: "#000000", background: "#ffffff" } },
57
+ { scope: ["keyword", "storage", "punctuation", "meta.brace", "variable.language", "entity.name.tag"],
58
+ settings: { foreground: "#000001" } },
59
+ { scope: ["string", "constant", "support.constant", "punctuation.definition.string"],
60
+ settings: { foreground: "#000002" } },
61
+ { scope: ["comment", "punctuation.definition.comment"],
62
+ settings: { foreground: "#000003" } },
63
+ { scope: ["meta.template.expression", "meta.embedded"],
64
+ settings: { foreground: "#000000" } }
65
+ ]
66
+ };
67
+ /* the syntax highlighter, created on first use (with the JavaScript
68
+ regular expression engine, so no WebAssembly is needed) and loading
69
+ the grammars of the languages on demand */
70
+ let highlighter = null;
71
+ /* render a code listing into a "pre" block: one block-level line per
72
+ source line (marked ones carrying a class), whose number the stylesheet
73
+ draws from a counter (so it neither is copied nor searched), and the
74
+ highlighted tokens of a loaded language as token class spans (adjacent
75
+ tokens of the same class merged) */
76
+ const renderListing = (shiki, listing) => {
77
+ const tokens = shiki !== null && shiki.getLoadedLanguages().includes(listing.lang) ?
78
+ shiki.codeToTokensBase(listing.source, { lang: listing.lang, theme: markerTheme.name })
79
+ .map((line) => line.map((token) => ({ content: token.content, cls: tokenClasses[(token.color ?? "").toLowerCase()] }))) :
80
+ listing.source.split(/\r?\n/).map((line) => [{ content: line, cls: undefined }]);
81
+ const marks = markedLines(listing.mark);
82
+ const digits = String(listing.start + tokens.length - 1).length;
83
+ const lines = tokens.map((line, i) => {
84
+ const spans = new Array();
85
+ for (const token of line) {
86
+ const prev = spans.at(-1);
87
+ if (prev !== undefined && prev.cls === token.cls)
88
+ prev.content += token.content;
89
+ else
90
+ spans.push({ ...token });
91
+ }
92
+ return `<span class="line${marks.has(listing.start + i) ? " marked" : ""}">` +
93
+ spans.map((span) => span.cls !== undefined ?
94
+ `<span class="${span.cls}">${escapeHtml(span.content)}</span>` : escapeHtml(span.content)).join("") +
95
+ "</span>";
96
+ });
97
+ return `<pre class="listing" style="--listing-start: ${listing.start}; --listing-digits: ${digits}">` +
98
+ `<code>${lines.join("")}</code></pre>`;
99
+ };
100
+ /* collect the languages of the code listings of an object and its
101
+ descendants: the fenced code blocks of a known language inside its
102
+ description statement and elaboration and the source code files
103
+ embedded into its description and property values */
104
+ const collect = (object, langs) => {
105
+ for (const text of [object.description?.description ?? "", object.description?.elaboration ?? ""])
106
+ marked.walkTokens(marked.lexer(text), (token) => {
107
+ const listing = token.type === "code" ? fencedListing(token) : undefined;
108
+ if (listing !== undefined)
109
+ langs.add(listing.lang);
110
+ });
111
+ const contents = [...object.description?.embedding ?? [],
112
+ ...object.properties.flatMap((property) => property.embedding ?? [])];
113
+ for (const content of contents) {
114
+ const listing = embeddedListing(content);
115
+ if (listing !== undefined)
116
+ langs.add(listing.lang);
117
+ }
118
+ for (const child of object.children)
119
+ collect(child, langs);
120
+ };
121
+ /* prepare the rendering of the code listings of a specification: the
122
+ syntax highlighter is loaded (on first use) together with the grammars
123
+ of all languages used, as the Markdown rendering is synchronous, and
124
+ a failure to load is surfaced as a notice only, leaving the listings
125
+ of the affected languages unhighlighted */
126
+ export const prepareListings = async (specification, verbose) => {
127
+ const langs = new Set();
128
+ for (const artifact of specification.artifacts)
129
+ for (const object of artifact.objects)
130
+ collect(object, langs);
131
+ if (langs.size === 0)
132
+ return (listing) => renderListing(null, listing);
133
+ let shiki = null;
134
+ try {
135
+ highlighter ??= Promise.all([import("shiki/core"), import("shiki/engine/javascript")])
136
+ .then(([{ createHighlighterCore }, { createJavaScriptRegexEngine }]) => createHighlighterCore({ themes: [markerTheme], langs: [], engine: createJavaScriptRegexEngine() }));
137
+ shiki = await highlighter;
138
+ }
139
+ catch (err) {
140
+ highlighter = null;
141
+ verbose?.("loading syntax highlighter failed: " + (err instanceof Error ? err.message : String(err)));
142
+ }
143
+ if (shiki !== null) {
144
+ const { bundledLanguages } = await import("shiki/langs");
145
+ const missing = Array.from(langs).filter((lang) => !shiki.getLoadedLanguages().includes(lang));
146
+ for (const lang of missing)
147
+ await shiki.loadLanguage(bundledLanguages[lang]).catch((err) => {
148
+ verbose?.(`loading syntax highlighting of language "${literal(lang)}" failed: ` +
149
+ (err instanceof Error ? err.message : String(err)));
150
+ });
151
+ verbose?.(`highlighting code listings of ${literal(langs.size)} language(s) ` +
152
+ `(${literal(missing.length)} newly loaded)`);
153
+ }
154
+ return (listing) => renderListing(shiki, listing);
155
+ };
@@ -1,10 +1,10 @@
1
1
  import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
- import { embeddingThemes } from "./specbook-parse-common.js";
2
+ import { embeddingThemes, type DarkMark } from "./specbook-parse-common.js";
3
3
  export declare const escapeHtml: (text: string) => string;
4
4
  export declare const stylesheet: () => string;
5
5
  export declare const searchScript: () => string;
6
6
  export declare const fallbackLogo: (theme: (typeof embeddingThemes)[number]) => string;
7
- export declare const omitAspects: readonly ["diagram:graph", "diagram:hub", "diagram:grid", "diagram:1", "diagram:2", "diagram:3", "text:long"];
7
+ export declare const omitAspects: readonly ["diagram:graph", "diagram:hub", "diagram:grid", "diagram:code", "diagram:image", "diagram:listing", "diagram:1", "diagram:2", "diagram:3", "text:long"];
8
8
  export type OmitAspect = typeof omitAspects[number];
9
9
  export declare const parseOmit: (omit?: string[]) => Set<OmitAspect>;
10
10
  export interface ExportOptions {
@@ -31,6 +31,10 @@ export declare const paperSizeDefault = "A4";
31
31
  export declare const paperSetup: (paper: string) => PaperSetup;
32
32
  export declare const paperLength: (setup: PaperSetup, value: number) => string;
33
33
  export declare const documentPaperSize: (specification: Spec) => string;
34
+ export declare const titleProperties: {
35
+ name: string;
36
+ init: string;
37
+ }[];
34
38
  export declare const paperStylesheet: (paper: string) => string;
35
39
  export declare const charsetCodepoints: (charset: string) => number[] | undefined;
36
40
  export declare const subsetStylesheet: (charset?: string) => Promise<string>;
@@ -38,4 +42,6 @@ export declare const documentTitle: (specification: Spec) => {
38
42
  title: string;
39
43
  subtitle?: string;
40
44
  };
45
+ export declare const svgInvertible: (svg: string) => boolean;
46
+ export declare const imageDark: (content: string, rasters?: Map<string, boolean>) => DarkMark;
41
47
  export declare const documentLogo: (specification: Spec) => string;