@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.
- package/README.md +9 -2
- package/dst/specbook-api.d.ts +5 -1
- package/dst/specbook-api.js +23 -9
- package/dst/specbook-cli.js +8 -5
- package/dst/specbook-cmd-export.js +2 -2
- package/dst/specbook-cmd-init.js +16 -2
- package/dst/specbook-coverage.js +1 -1
- package/dst/specbook-diagram.d.ts +1 -1
- package/dst/specbook-diagram.js +12 -10
- package/dst/specbook-export-ast.d.ts +1 -1
- package/dst/specbook-export-ast.js +21 -4
- package/dst/specbook-export-code.d.ts +14 -0
- package/dst/specbook-export-code.js +155 -0
- package/dst/specbook-export-common.d.ts +8 -2
- package/dst/specbook-export-common.js +126 -6
- package/dst/specbook-export-diagram.d.ts +15 -0
- package/dst/specbook-export-diagram.js +211 -0
- package/dst/specbook-export-html-fold.d.ts +20 -0
- package/dst/specbook-export-html-fold.js +458 -0
- package/dst/specbook-export-html-render.d.ts +51 -0
- package/dst/specbook-export-html-render.js +748 -0
- package/dst/specbook-export-html-search.js +6 -5
- package/dst/specbook-export-html-template.d.ts +21 -0
- package/dst/specbook-export-html-template.js +282 -0
- package/dst/specbook-export-html-widget.d.ts +12 -0
- package/dst/specbook-export-html-widget.js +605 -0
- package/dst/specbook-export-html.css +325 -50
- package/dst/specbook-export-html.js +62 -1931
- package/dst/specbook-export-image.d.ts +2 -0
- package/dst/specbook-export-image.js +161 -9
- package/dst/specbook-export-md.js +60 -12
- package/dst/specbook-format-schema.d.ts +1 -1
- package/dst/specbook-format-schema.js +1 -1
- package/dst/specbook-format-spec.d.ts +1 -0
- package/dst/specbook-format-spec.js +1 -0
- package/dst/specbook-format.md +173 -19
- package/dst/specbook-format.yaml +265 -24
- package/dst/specbook-ignore.js +2 -0
- package/dst/specbook-link.js +21 -5
- package/dst/specbook-mcp.js +9 -5
- package/dst/specbook-parse-common.d.ts +24 -0
- package/dst/specbook-parse-common.js +137 -10
- package/dst/specbook-parse-semantic.js +2 -0
- package/dst/specbook-parse-syntax.js +81 -20
- package/dst/specbook-parse.js +1 -1
- package/dst/specbook-project.d.ts +1 -0
- package/dst/specbook-project.js +10 -1
- package/dst/specbook-theme.d.ts +1 -0
- package/dst/specbook-theme.js +2 -0
- 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`,
|
|
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
|
|
package/dst/specbook-api.d.ts
CHANGED
|
@@ -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<
|
|
53
|
+
init(options: ProjectOptions): Promise<InitResult>;
|
|
50
54
|
lint(options: ProjectOptions & {
|
|
51
55
|
gitignore?: boolean;
|
|
52
56
|
}): Promise<LintResult>;
|
package/dst/specbook-api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
129
|
-
rendering options (the live preview
|
|
130
|
-
and "rebase" names, per requested
|
|
131
|
-
|
|
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
|
|
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
|
}
|
package/dst/specbook-cli.js
CHANGED
|
@@ -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
|
|
139
|
-
await writeStdout(
|
|
140
|
-
`initialized artifact file(s): ${
|
|
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) ??
|
|
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
|
package/dst/specbook-cmd-init.js
CHANGED
|
@@ -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
|
|
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` +
|
package/dst/specbook-coverage.js
CHANGED
|
@@ -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;
|
package/dst/specbook-diagram.js
CHANGED
|
@@ -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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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;
|