@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.
- package/README.md +5 -2
- package/dst/specbook-api.js +7 -5
- package/dst/specbook-cli.js +0 -0
- package/dst/specbook-cmd-export.js +2 -2
- package/dst/specbook-coverage.js +1 -1
- package/dst/specbook-diagram.d.ts +1 -1
- package/dst/specbook-diagram.js +9 -3
- 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 +4 -2
- package/dst/specbook-export-common.js +110 -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 +749 -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 +169 -17
- package/dst/specbook-format.yaml +224 -23
- package/dst/specbook-ignore.js +2 -0
- package/dst/specbook-link.js +21 -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-theme.d.ts +1 -0
- package/dst/specbook-theme.js +2 -0
- package/package.json +17 -11
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
|
|
@@ -419,7 +422,7 @@ application, based on **SpecBook**'s built-in "standard" schema:
|
|
|
419
422
|
### Sources
|
|
420
423
|
|
|
421
424
|
- [Specification](smp/broadcast/)
|
|
422
|
-
- [Schema](src/specbook-format.d/)
|
|
425
|
+
- [Schema](src/specbook-format-schema.d/)
|
|
423
426
|
|
|
424
427
|
### Generation
|
|
425
428
|
|
package/dst/specbook-api.js
CHANGED
|
@@ -125,10 +125,11 @@ export class SpecBook {
|
|
|
125
125
|
/* render an already parsed specification into the requested formats
|
|
126
126
|
(strict: any error diagnostic prevents the export, as a partial or
|
|
127
127
|
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
|
-
|
|
128
|
+
are just surfaced as unconditional verbose messages), where
|
|
129
|
+
"rendering" carries the rendering options (the live preview
|
|
130
|
+
script, the omitted aspects) and "rebase" names, per requested
|
|
131
|
+
format, the directory the image references of the normalized
|
|
132
|
+
Markdown are re-based onto */
|
|
132
133
|
async renderFormats(result, requested, verbose, rendering, rebase) {
|
|
133
134
|
if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error"))
|
|
134
135
|
throw new Error("invalid specification:\n" +
|
|
@@ -163,11 +164,12 @@ export class SpecBook {
|
|
|
163
164
|
const requested = options.formats ?? ["json"];
|
|
164
165
|
const omit = parseOmit(options.omit);
|
|
165
166
|
const project = this.project(options, verbose);
|
|
167
|
+
const config = await this.configFiles(project.config);
|
|
166
168
|
/* a missing browser is an environment problem, so let the PDF
|
|
167
169
|
export fail before the specification is even parsed */
|
|
168
170
|
if (requested.includes("pdf"))
|
|
169
171
|
await requireBrowser(verbose);
|
|
170
|
-
const result = lint({ config
|
|
172
|
+
const result = lint({ config,
|
|
171
173
|
basedir: project.basedir ?? ".", gitignore: options.gitignore === true, verbose });
|
|
172
174
|
return this.renderFormats(result, requested, verbose, { realtime: options.realtime === true, omit }, this.rebaseOf(options));
|
|
173
175
|
}
|
package/dst/specbook-cli.js
CHANGED
|
File without changes
|
|
@@ -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-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)) {
|
|
@@ -512,7 +514,8 @@ const deriveNodes = (index, object, diagram, errors) => {
|
|
|
512
514
|
const nodes = new Array();
|
|
513
515
|
const walk = (o) => {
|
|
514
516
|
nodes.push(o);
|
|
515
|
-
o.children
|
|
517
|
+
for (const child of o.children)
|
|
518
|
+
walk(child);
|
|
516
519
|
};
|
|
517
520
|
walk(object);
|
|
518
521
|
return nodes;
|
|
@@ -600,14 +603,17 @@ const deriveDiagram = (ctx, object, diagram) => {
|
|
|
600
603
|
}
|
|
601
604
|
/* the "onlyConnected" filtering keeps only the nodes with at
|
|
602
605
|
least one incident edge (sensible for "graph" diagrams only),
|
|
603
|
-
and the
|
|
606
|
+
and the containers enclosing such a node */
|
|
604
607
|
if (diagram.onlyConnected === true && type === "graph") {
|
|
605
608
|
const connected = new Set();
|
|
606
609
|
for (const edge of edges) {
|
|
607
610
|
connected.add(edge.source);
|
|
608
611
|
connected.add(edge.target);
|
|
609
612
|
}
|
|
610
|
-
|
|
613
|
+
for (const node of Array.from(connected))
|
|
614
|
+
for (let p = nesting.parentOf.get(node); p !== undefined; p = nesting.parentOf.get(p))
|
|
615
|
+
connected.add(p);
|
|
616
|
+
nodes = nodes.filter((node) => connected.has(node));
|
|
611
617
|
}
|
|
612
618
|
if (errors.length > 0)
|
|
613
619
|
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 {
|
|
@@ -38,4 +38,6 @@ export declare const documentTitle: (specification: Spec) => {
|
|
|
38
38
|
title: string;
|
|
39
39
|
subtitle?: string;
|
|
40
40
|
};
|
|
41
|
+
export declare const svgInvertible: (svg: string) => boolean;
|
|
42
|
+
export declare const imageDark: (content: string, rasters?: Map<string, boolean>) => DarkMark;
|
|
41
43
|
export declare const documentLogo: (specification: Spec) => string;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
-
import { embeddingThemes } from "./specbook-parse-common.js";
|
|
7
|
+
import { embeddingThemes, darkMark } from "./specbook-parse-common.js";
|
|
8
8
|
import { plainText } from "./specbook-link.js";
|
|
9
9
|
/* escape a text for embedding into template HTML (text and attributes) */
|
|
10
10
|
export const escapeHtml = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
@@ -19,9 +19,13 @@ export const searchScript = () => fs.readFileSync(new URL("specbook-export-html-
|
|
|
19
19
|
export const fallbackLogo = (theme) => "data:image/svg+xml;base64," +
|
|
20
20
|
fs.readFileSync(new URL(`specbook-export-logo-${theme}.svg`, import.meta.url)).toString("base64");
|
|
21
21
|
/* the content aspects an export can omit, each matching the set of a
|
|
22
|
-
folding control of the HTML export: the diagrams of a type
|
|
23
|
-
|
|
22
|
+
folding control of the HTML export: the diagrams of a type (the
|
|
23
|
+
derived Gradia ones, the embedded Mermaid/D2 ones as "code", the
|
|
24
|
+
embedded images and PDF pages as "image", and the code listings as
|
|
25
|
+
"listing"), the diagrams from an object tree nesting level on, and the
|
|
26
|
+
long texts */
|
|
24
27
|
export const omitAspects = ["diagram:graph", "diagram:hub", "diagram:grid",
|
|
28
|
+
"diagram:code", "diagram:image", "diagram:listing",
|
|
25
29
|
"diagram:1", "diagram:2", "diagram:3", "text:long"];
|
|
26
30
|
/* parse the (comma-separated) aspects to omit, where the
|
|
27
31
|
plain "diagram" is the alias of "diagram:1", hence of all diagrams */
|
|
@@ -129,7 +133,7 @@ export const paperStylesheet = (paper) => {
|
|
|
129
133
|
const avail = setup.height - setup.margin.top - setup.margin.bottom;
|
|
130
134
|
return "@media print {\n" +
|
|
131
135
|
"div.diagram { break-inside: avoid; }\n" +
|
|
132
|
-
`div.diagram svg { max-height: calc(${paperLength(setup, avail)} - ${headingReserve}rem);` +
|
|
136
|
+
`div.diagram svg, div.diagram img { max-height: calc(${paperLength(setup, avail)} - ${headingReserve}rem);` +
|
|
133
137
|
" width: auto; height: auto; }\n" +
|
|
134
138
|
`nav.doc div.diagram svg { max-height: calc(${paperLength(setup, avail)} - ${docHeadingReserve}rem); }\n` +
|
|
135
139
|
"}\n";
|
|
@@ -223,6 +227,105 @@ export const documentTitle = (specification) => {
|
|
|
223
227
|
subtitle: subtitle !== undefined ? plainText(subtitle) : undefined
|
|
224
228
|
};
|
|
225
229
|
};
|
|
230
|
+
/* the luma (0-1, of the gamma-encoded channels) of a CSS color given as
|
|
231
|
+
"#rgb", "#rrggbb", "rgb(r, g, b)", "white", or "black" (undefined for
|
|
232
|
+
any other one) */
|
|
233
|
+
const luminance = (color) => {
|
|
234
|
+
const value = color.trim().toLowerCase();
|
|
235
|
+
const named = value === "white" ? "#ffffff" : value === "black" ? "#000000" : value;
|
|
236
|
+
const hex = named.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
|
|
237
|
+
const rgb = named.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
|
|
238
|
+
const parts = hex !== null ?
|
|
239
|
+
(hex[1].length === 3 ? hex[1].split("").map((c) => c + c) : hex[1].match(/../g) ?? [])
|
|
240
|
+
.map((c) => parseInt(c, 16)) :
|
|
241
|
+
rgb !== null ? rgb.slice(1, 4).map(Number) : undefined;
|
|
242
|
+
return parts !== undefined ? (0.2126 * parts[0] + 0.7152 * parts[1] + 0.0722 * parts[2]) / 255 : undefined;
|
|
243
|
+
};
|
|
244
|
+
/* whether an SVG image is most likely dark ink on a transparent or light
|
|
245
|
+
canvas and hence safe to invert on the dark theme ("dark=auto"): it
|
|
246
|
+
neither adapts itself (via "light-dark()" colors, "prefers-color-scheme"
|
|
247
|
+
rules, or a "color-scheme" declaration), nor embeds a raster image
|
|
248
|
+
(which would turn into a negative), nor paints a background of its own
|
|
249
|
+
which is not light (by the style of its root element or by the topmost
|
|
250
|
+
of the leading shapes covering the whole canvas -- rectangles or
|
|
251
|
+
rectangular paths, as a rendered PDF page paints its background over
|
|
252
|
+
the white paper of PDF.js --, an unknown color counting as not light) */
|
|
253
|
+
export const svgInvertible = (svg) => {
|
|
254
|
+
if (/light-dark\(|prefers-color-scheme|color-scheme|<image\b/.test(svg))
|
|
255
|
+
return false;
|
|
256
|
+
const root = svg.match(/<svg\b[^>]*>/)?.[0] ?? "";
|
|
257
|
+
const light = (color) => (luminance(color ?? "#000000") ?? 0) >= 0.5;
|
|
258
|
+
const back = root.match(/background(?:-color)?\s*:\s*([^;"]+)/)?.[1];
|
|
259
|
+
if (back !== undefined && !/^\s*(?:none|transparent)\s*$/.test(back) && !light(back))
|
|
260
|
+
return false;
|
|
261
|
+
/* the canvas size (in the user units of the viewBox, if any) */
|
|
262
|
+
const attr = (element, name) => element.match(new RegExp(`\\s${name}="([^"]*)"`))?.[1] ??
|
|
263
|
+
element.match(new RegExp(`[\\s;"]${name}\\s*:\\s*([^;"]+)`))?.[1];
|
|
264
|
+
const box = attr(root, "viewBox")?.trim().split(/[\s,]+/).map(Number) ?? [];
|
|
265
|
+
const width = box[2] || parseFloat(attr(root, "width") ?? "");
|
|
266
|
+
const height = box[3] || parseFloat(attr(root, "height") ?? "");
|
|
267
|
+
/* whether a shape covers the whole canvas: the corner points of a
|
|
268
|
+
rectangle or of a path of straight absolute segments only, mapped
|
|
269
|
+
through its "scale()" or "matrix()" transform (if any) */
|
|
270
|
+
const covers = (kind, element) => {
|
|
271
|
+
const num = (name) => parseFloat(attr(element, name) ?? "0") || 0;
|
|
272
|
+
let points;
|
|
273
|
+
if (kind === "rect") {
|
|
274
|
+
if (attr(element, "width") === "100%" && attr(element, "height") === "100%")
|
|
275
|
+
return true;
|
|
276
|
+
const [x, y] = [num("x"), num("y")];
|
|
277
|
+
points = [[x, y], [x + num("width"), y + num("height")]];
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
const d = attr(element, "d") ?? "";
|
|
281
|
+
if (kind !== "path" || !/^[\sMLHVZ\d.,-]*$/.test(d))
|
|
282
|
+
return false;
|
|
283
|
+
points = [];
|
|
284
|
+
for (const [, cmd, args] of d.matchAll(/([MLHVZ])([^MLHVZ]*)/g)) {
|
|
285
|
+
const n = args.trim().split(/[\s,]+/).filter((a) => a !== "").map(Number);
|
|
286
|
+
const last = points.at(-1) ?? [0, 0];
|
|
287
|
+
if (cmd === "H")
|
|
288
|
+
points.push(...n.map((x) => [x, last[1]]));
|
|
289
|
+
else if (cmd === "V")
|
|
290
|
+
points.push(...n.map((y) => [last[0], y]));
|
|
291
|
+
else
|
|
292
|
+
for (let k = 0; k + 1 < n.length; k += 2)
|
|
293
|
+
points.push([n[k], n[k + 1]]);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const t = attr(element, "transform")?.trim() ?? "";
|
|
297
|
+
const f = t.match(/^(matrix|scale)\(([^)]*)\)$/);
|
|
298
|
+
const v = f?.[2].trim().split(/[\s,]+/).map(Number) ?? [];
|
|
299
|
+
const m = t === "" ? [1, 0, 0, 1, 0, 0] :
|
|
300
|
+
f?.[1] === "matrix" && v.length === 6 ? v :
|
|
301
|
+
f?.[1] === "scale" ? [v[0], 0, 0, v[1] ?? v[0], 0, 0] : undefined;
|
|
302
|
+
if (m === undefined || points.length === 0)
|
|
303
|
+
return false;
|
|
304
|
+
const xs = points.map(([x, y]) => m[0] * x + m[2] * y + m[4]);
|
|
305
|
+
const ys = points.map(([x, y]) => m[1] * x + m[3] * y + m[5]);
|
|
306
|
+
return Math.min(...xs) <= 1 && Math.min(...ys) <= 1
|
|
307
|
+
&& Math.max(...xs) >= width - 1 && Math.max(...ys) >= height - 1;
|
|
308
|
+
};
|
|
309
|
+
/* the topmost leading canvas-covering shape (outside of any
|
|
310
|
+
definition) forms the background */
|
|
311
|
+
let fill;
|
|
312
|
+
const body = svg.slice(svg.indexOf(root) + root.length)
|
|
313
|
+
.replace(/<(clipPath|defs|mask|pattern|symbol)\b[\s\S]*?<\/\1>/g, "");
|
|
314
|
+
for (const shape of body.matchAll(/<(rect|path|circle|ellipse|line|polyline|polygon|text|use)\b[^>]*>/g)) {
|
|
315
|
+
if (!covers(shape[1], shape[0]))
|
|
316
|
+
break;
|
|
317
|
+
fill = attr(shape[0], "fill") ?? "#000000";
|
|
318
|
+
}
|
|
319
|
+
return fill === undefined || fill === "none" || fill === "transparent" || light(fill);
|
|
320
|
+
};
|
|
321
|
+
/* the treatment of an embedded image on the dark theme: its explicit
|
|
322
|
+
mark, else the "auto" detection (for a raster image or a PDF page the
|
|
323
|
+
judgement of "analyzeImages", none where absent) */
|
|
324
|
+
export const imageDark = (content, rasters) => {
|
|
325
|
+
const { content: plain, dark } = darkMark(content);
|
|
326
|
+
return dark ?? ((plain.startsWith("data:") ? rasters?.get(plain) === true : svgInvertible(plain)) ?
|
|
327
|
+
"invert" : "none");
|
|
328
|
+
};
|
|
226
329
|
/* determine the document logo as a self-contained data: URL, taken from the
|
|
227
330
|
first embedded image of the LOGO property of the title object -- the light
|
|
228
331
|
variant of a "{theme}" reference -- and falling back onto the built-in
|
|
@@ -233,6 +336,7 @@ export const documentLogo = (specification) => {
|
|
|
233
336
|
?.properties.find((property) => property.key === "LOGO")?.embedding?.[0];
|
|
234
337
|
if (content === undefined || content === "")
|
|
235
338
|
return fallbackLogo("light");
|
|
236
|
-
|
|
237
|
-
|
|
339
|
+
const plain = darkMark(content).content;
|
|
340
|
+
return plain.startsWith("data:") ? plain :
|
|
341
|
+
`data:image/svg+xml;base64,${Buffer.from(plain, "utf8").toString("base64")}`;
|
|
238
342
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Spec, SpecObject } from "./specbook-format-spec.js";
|
|
2
|
+
import { type OmitAspect } from "./specbook-export-common.js";
|
|
3
|
+
import { type ThemeStyle } from "./specbook-theme.js";
|
|
4
|
+
import { type Verbose } from "./specbook-verbose.js";
|
|
5
|
+
export type DiagramLanguage = "mermaid" | "d2";
|
|
6
|
+
export type DiagramVariants = Record<ThemeStyle, string>;
|
|
7
|
+
export declare const fenceLanguage: (lang: string | undefined) => DiagramLanguage | undefined;
|
|
8
|
+
export declare const embeddedSource: (content: string) => {
|
|
9
|
+
language: DiagramLanguage;
|
|
10
|
+
source: string;
|
|
11
|
+
} | undefined;
|
|
12
|
+
export declare const diagramKey: (language: DiagramLanguage, source: string) => string;
|
|
13
|
+
export declare const omittedDiagram: (language: DiagramLanguage | "image" | "listing", level: number, omit: Set<OmitAspect>) => boolean;
|
|
14
|
+
export declare const objectLevels: (specification: Spec) => Map<SpecObject, number>;
|
|
15
|
+
export declare const renderEmbeddedDiagrams: (specification: Spec, 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<string, DiagramVariants>>;
|