@rse/specbook 0.9.2

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 (59) hide show
  1. package/README.md +199 -0
  2. package/dst/specbook-api.d.ts +47 -0
  3. package/dst/specbook-api.js +77 -0
  4. package/dst/specbook-cli.d.ts +2 -0
  5. package/dst/specbook-cli.js +156 -0
  6. package/dst/specbook-cmd-describe.d.ts +2 -0
  7. package/dst/specbook-cmd-describe.js +97 -0
  8. package/dst/specbook-cmd-edit.d.ts +9 -0
  9. package/dst/specbook-cmd-edit.js +69 -0
  10. package/dst/specbook-cmd-export.d.ts +9 -0
  11. package/dst/specbook-cmd-export.js +78 -0
  12. package/dst/specbook-cmd-import.d.ts +9 -0
  13. package/dst/specbook-cmd-import.js +73 -0
  14. package/dst/specbook-cmd-init.d.ts +7 -0
  15. package/dst/specbook-cmd-init.js +37 -0
  16. package/dst/specbook-cmd-lint.d.ts +15 -0
  17. package/dst/specbook-cmd-lint.js +46 -0
  18. package/dst/specbook-config.d.ts +6 -0
  19. package/dst/specbook-config.js +91 -0
  20. package/dst/specbook-diagnostic.d.ts +8 -0
  21. package/dst/specbook-diagnostic.js +23 -0
  22. package/dst/specbook-diagram.d.ts +9 -0
  23. package/dst/specbook-diagram.js +271 -0
  24. package/dst/specbook-export-ast.d.ts +4 -0
  25. package/dst/specbook-export-ast.js +36 -0
  26. package/dst/specbook-export-common.d.ts +33 -0
  27. package/dst/specbook-export-common.js +142 -0
  28. package/dst/specbook-export-html-search.js +272 -0
  29. package/dst/specbook-export-html.css +259 -0
  30. package/dst/specbook-export-html.d.ts +9 -0
  31. package/dst/specbook-export-html.js +452 -0
  32. package/dst/specbook-export-logo.svg +35 -0
  33. package/dst/specbook-export-md.d.ts +3 -0
  34. package/dst/specbook-export-md.js +74 -0
  35. package/dst/specbook-export-pdf.d.ts +7 -0
  36. package/dst/specbook-export-pdf.js +198 -0
  37. package/dst/specbook-link.d.ts +17 -0
  38. package/dst/specbook-link.js +113 -0
  39. package/dst/specbook-llm.d.ts +13 -0
  40. package/dst/specbook-llm.js +61 -0
  41. package/dst/specbook-mcp.d.ts +1 -0
  42. package/dst/specbook-mcp.js +163 -0
  43. package/dst/specbook-parse-common.d.ts +26 -0
  44. package/dst/specbook-parse-common.js +33 -0
  45. package/dst/specbook-parse-semantic.d.ts +5 -0
  46. package/dst/specbook-parse-semantic.js +254 -0
  47. package/dst/specbook-parse-syntax.d.ts +3 -0
  48. package/dst/specbook-parse-syntax.js +363 -0
  49. package/dst/specbook-parse-value.d.ts +19 -0
  50. package/dst/specbook-parse-value.js +123 -0
  51. package/dst/specbook-parse.d.ts +8 -0
  52. package/dst/specbook-parse.js +43 -0
  53. package/dst/specbook-struct-schema.d.ts +35 -0
  54. package/dst/specbook-struct-schema.js +47 -0
  55. package/dst/specbook-struct-spec.d.ts +31 -0
  56. package/dst/specbook-struct-spec.js +36 -0
  57. package/dst/specbook-theme.d.ts +16 -0
  58. package/dst/specbook-theme.js +38 -0
  59. package/package.json +77 -0
@@ -0,0 +1,271 @@
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 { referenceRegex, buildLinkIndex, resolveUnique, resolveSet, anchorPaths } from "./specbook-link.js";
7
+ /* strip the inline code markup of a name or property value
8
+ (preserved in the AST for rendering) for labeling purposes */
9
+ const plainText = (text) => text.replace(/`/g, "");
10
+ /* strip a trailing parenthesized annotation from a
11
+ property key (e.g. "WHEN (Context)") */
12
+ const plainKey = (key) => key.replace(/\s*\([^)]*\)\s*$/, "").trim();
13
+ /* render a text as a Gradia atom: a bareword where possible
14
+ (no whitespace, no special characters, no "--"), a quoted
15
+ string otherwise */
16
+ const quoted = (text) => `"${text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
17
+ const atom = (text) => {
18
+ const plain = text.replace(/\s+/g, " ").trim();
19
+ return /^[^\s[\]():,">#]+$/.test(plain) && !plain.includes("--") ? plain : quoted(plain);
20
+ };
21
+ /* render a text as a Gradia edge name or edge arity token
22
+ (each with its own, more lenient character set) */
23
+ const nameToken = (text) => /^[^\s()"]+$/.test(text) ? text : quoted(text);
24
+ const arityToken = (text) => /^[^\s[\]"]+$/.test(text) ? text : quoted(text);
25
+ /* render a value as a Gradia "#config" directive value: a bareword
26
+ where possible, a quoted string otherwise (with the whitespace
27
+ collapsed, as a directive is a single line) */
28
+ const configValue = (value) => {
29
+ const text = String(value).replace(/\s+/g, " ").trim();
30
+ return /^\S+$/.test(text) ? text : quoted(text);
31
+ };
32
+ /* derive the Gradia spec of a single object from its "diagram:"
33
+ schema configuration (the returned spec is absent whenever the
34
+ configured diagram situation is invalid) */
35
+ const deriveDiagram = (object, diagram, index, anchors, parents) => {
36
+ const type = diagram.type ?? "graph";
37
+ const errors = new Array();
38
+ /* resolve a comma-separated "[[...]]" reference pattern string
39
+ into its (deduplicated, order-preserving) object match set */
40
+ const resolvePatterns = (value, field) => {
41
+ const matches = new Array();
42
+ const seen = new Set();
43
+ for (const m of value.matchAll(referenceRegex)) {
44
+ const pattern = m[1].trim();
45
+ const set = resolveSet(index, pattern);
46
+ if (set.length === 0)
47
+ errors.push(`unresolvable diagram "${field}" pattern "[[${pattern}]]"`);
48
+ for (const match of set) {
49
+ if (!seen.has(match)) {
50
+ seen.add(match);
51
+ matches.push(match);
52
+ }
53
+ }
54
+ }
55
+ return matches;
56
+ };
57
+ /* determine the node objects (default: the current object plus
58
+ all objects below it) and the edge objects (default: none),
59
+ with the edge objects never acting as nodes themselves */
60
+ let nodes;
61
+ if (diagram.nodes !== undefined)
62
+ nodes = resolvePatterns(diagram.nodes, "nodes");
63
+ else {
64
+ nodes = new Array();
65
+ const walk = (o) => {
66
+ nodes.push(o);
67
+ o.childs.forEach(walk);
68
+ };
69
+ walk(object);
70
+ }
71
+ const edgeObjects = diagram.edges !== undefined ? resolvePatterns(diagram.edges, "edges") : [];
72
+ nodes = nodes.filter((node) => !edgeObjects.includes(node));
73
+ const nodeSet = new Set(nodes);
74
+ /* derive the edges: from the "[[...]]" references of the node
75
+ objects (per the "links" selection) and from the edge objects by
76
+ convention (source: parent object, target: first reference in the
77
+ property values, name: object name, arity: "ARITY" property),
78
+ overridable via "edgeTarget"/"edgeArity" (a "grid" diagram is
79
+ edge-less by definition, so no edges are derived for it at all) */
80
+ let edges = new Array();
81
+ if (type !== "grid") {
82
+ for (const node of nodes) {
83
+ const texts = node.properties.map((property) => property.value);
84
+ if (diagram.links === "all" && node.description !== undefined) {
85
+ texts.push(node.description.description);
86
+ if (node.description.rationale !== undefined)
87
+ texts.push(node.description.rationale);
88
+ }
89
+ for (const text of texts)
90
+ for (const m of text.matchAll(referenceRegex)) {
91
+ const target = resolveUnique(index, m[1].trim()).target;
92
+ if (target !== undefined && target !== node && nodeSet.has(target))
93
+ edges.push({ source: node, target });
94
+ }
95
+ }
96
+ for (const edgeObject of edgeObjects) {
97
+ const source = parents.get(edgeObject);
98
+ const value = diagram.edgeTarget !== undefined ?
99
+ edgeObject.properties.find((property) => plainKey(property.key) === diagram.edgeTarget)?.value :
100
+ edgeObject.properties.map((property) => property.value)
101
+ .find((v) => /\[\[[^[\]]+\]\]/.test(v));
102
+ const reference = value?.match(/\[\[([^[\]]+)\]\]/)?.[1].trim();
103
+ const target = reference !== undefined ? resolveUnique(index, reference).target : undefined;
104
+ if (source === undefined) {
105
+ errors.push(`diagram edge object "${edgeObject.name}" has no parent object as source`);
106
+ continue;
107
+ }
108
+ if (target === undefined) {
109
+ errors.push(`diagram edge object "${edgeObject.name}" carries no resolvable target reference`);
110
+ continue;
111
+ }
112
+ if (!nodeSet.has(source) || !nodeSet.has(target))
113
+ continue;
114
+ const arity = edgeObject.properties.find((property) => plainKey(property.key) === (diagram.edgeArity ?? "ARITY"))?.value;
115
+ edges.push({ source, target, name: plainText(edgeObject.name),
116
+ arity: arity !== undefined ? plainText(arity) : undefined });
117
+ }
118
+ /* derive the containment edges from the object hierarchy, as
119
+ the nesting of the objects carries no "[[...]]" reference */
120
+ if (diagram.hierarchy === true)
121
+ for (const node of nodes) {
122
+ const parent = parents.get(node);
123
+ if (parent !== undefined && nodeSet.has(parent))
124
+ edges.push({ source: parent, target: node });
125
+ }
126
+ }
127
+ else if (diagram.edges !== undefined)
128
+ errors.push("\"grid\" diagram cannot carry an \"edges\" configuration");
129
+ else if (diagram.hierarchy === true)
130
+ errors.push("\"grid\" diagram cannot carry a \"hierarchy\" configuration");
131
+ /* deduplicate the edges (the same reference can occur in
132
+ multiple texts of the same node object) */
133
+ const seenEdges = new Set();
134
+ edges = edges.filter((edge) => {
135
+ const key = `${anchors.get(edge.source)}\u0000${anchors.get(edge.target)}` +
136
+ `\u0000${edge.name ?? ""}\u0000${edge.arity ?? ""}`;
137
+ if (seenEdges.has(key))
138
+ return false;
139
+ seenEdges.add(key);
140
+ return true;
141
+ });
142
+ /* determine the center object of a "hub" diagram: the current
143
+ object for the default "self" configuration, the uniquely
144
+ resolved object of an explicit "[[...]]" reference otherwise */
145
+ let center = object;
146
+ if (type === "hub" && diagram.center !== undefined && diagram.center !== "self") {
147
+ const reference = diagram.center.match(/\[\[([^[\]]+)\]\]/)?.[1].trim();
148
+ const resolved = reference !== undefined ? resolveUnique(index, reference) : undefined;
149
+ if (resolved?.target === undefined) {
150
+ errors.push(`${resolved?.ambiguous === true ? "ambiguous" : "unresolvable"}` +
151
+ ` diagram "center" reference "${diagram.center}"`);
152
+ return { errors };
153
+ }
154
+ center = resolved.target;
155
+ }
156
+ /* a "hub" diagram is the hub-projection onto its center object:
157
+ only the edges incident to the center and only the nodes
158
+ connected through them remain, with self-loops dropped, as
159
+ Gradia requires exactly this constrained topology */
160
+ if (type === "hub") {
161
+ if (!nodeSet.has(center))
162
+ errors.push(`"hub" diagram center "${center.name}" is not part of the node set`);
163
+ else {
164
+ edges = edges.filter((edge) => (edge.source === center || edge.target === center)
165
+ && edge.source !== edge.target);
166
+ const connected = new Set([center]);
167
+ for (const edge of edges) {
168
+ connected.add(edge.source);
169
+ connected.add(edge.target);
170
+ }
171
+ nodes = nodes.filter((node) => connected.has(node));
172
+ }
173
+ }
174
+ /* the "onlyConnected" filtering keeps only the nodes with at
175
+ least one incident edge (sensible for "graph" diagrams only) */
176
+ if (diagram.onlyConnected === true && type === "graph") {
177
+ const connected = new Set();
178
+ for (const edge of edges) {
179
+ connected.add(edge.source);
180
+ connected.add(edge.target);
181
+ }
182
+ nodes = nodes.filter((node) => connected.has(node));
183
+ }
184
+ if (nodes.length === 0)
185
+ errors.push("diagram yields no nodes");
186
+ if (errors.length > 0)
187
+ return { errors };
188
+ /* the "collapse" handling (enabled by default) silently omits a
189
+ degenerated diagram, consisting of a single node only, as such a
190
+ diagram carries no information beyond the object itself */
191
+ if (diagram.collapse !== false && nodes.length === 1 && edges.length === 0)
192
+ return { errors };
193
+ /* generate the Gradia spec text, opened by a "#type" directive and
194
+ the configured "#config" directives so every embedded spec is
195
+ self-contained: one node statement per object (the unique anchor
196
+ path as the node id, the object name as the displayed label)
197
+ and one edge statement per derived edge */
198
+ const lines = [`#type ${type}`];
199
+ const config = diagram.config ?? {};
200
+ for (const key of Object.keys(config)) {
201
+ const value = config[key];
202
+ if (value !== undefined)
203
+ lines.push(`#config ${key} ${configValue(value)}`);
204
+ }
205
+ lines.push("");
206
+ for (const node of nodes) {
207
+ const anchor = anchors.get(node) ?? node.id;
208
+ const attrs = [`url: ${atom(`#${anchor}`)}`];
209
+ if (diagram.qualified === true && node.kind !== "")
210
+ attrs.push(`type: ${atom(node.kind)}`);
211
+ if (type === "hub" ? node === center : node.primary === true)
212
+ attrs.push("primary: true");
213
+ lines.push(`${atom(anchor)}: ${atom(plainText(node.name))} [ ${attrs.join(", ")} ]`);
214
+ }
215
+ if (edges.length > 0) {
216
+ lines.push("");
217
+ for (const edge of edges) {
218
+ const op = "--" +
219
+ (edge.name !== undefined && edge.name !== "" ? `(${nameToken(edge.name)})--` : "") + ">" +
220
+ (edge.arity !== undefined && edge.arity !== "" ? `[${arityToken(edge.arity)}]` : "");
221
+ lines.push(`${atom(anchors.get(edge.source) ?? edge.source.id)} ${op} ` +
222
+ atom(anchors.get(edge.target) ?? edge.target.id));
223
+ }
224
+ }
225
+ return { spec: lines.join("\n") + "\n", errors };
226
+ };
227
+ /* collect the objects whose schema configuration carries a "diagram:"
228
+ field (the schema resolution mirrors the semantic validation) */
229
+ const collectDiagrams = (specification, config) => {
230
+ const diagrams = new Map();
231
+ const walk = (object, schema) => {
232
+ if (schema.diagram !== undefined)
233
+ diagrams.set(object, schema.diagram);
234
+ for (const child of object.childs) {
235
+ const childSchema = (schema.childs ?? []).find((c) => c.kind === child.kind);
236
+ if (childSchema !== undefined)
237
+ walk(child, childSchema);
238
+ }
239
+ };
240
+ for (const artifact of specification.artifacts) {
241
+ for (const object of artifact.objects) {
242
+ const schema = config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
243
+ config.find((s) => (s.name ?? "").toUpperCase() === plainText(object.name).toUpperCase());
244
+ if (schema !== undefined)
245
+ walk(object, schema);
246
+ }
247
+ }
248
+ return diagrams;
249
+ };
250
+ /* derive the Gradia specs of all diagram-configured objects
251
+ of a specification */
252
+ export const specDiagrams = (specification, config) => {
253
+ const index = buildLinkIndex(specification);
254
+ const anchors = anchorPaths(index);
255
+ const parents = new Map();
256
+ for (const node of index)
257
+ parents.set(node.object, node.parent?.object);
258
+ const results = new Map();
259
+ for (const [object, diagram] of collectDiagrams(specification, config))
260
+ results.set(object, deriveDiagram(object, diagram, index, anchors, parents));
261
+ return results;
262
+ };
263
+ /* validate all configured diagrams of a specification, reporting
264
+ every invalid diagram situation as a file/line-precise diagnostic */
265
+ export const validateDiagrams = (ctx, specification, config) => {
266
+ for (const [object, result] of specDiagrams(specification, config)) {
267
+ const meta = ctx.objectMeta.get(object) ?? { file: "", line: 1 };
268
+ for (const error of result.errors)
269
+ ctx.diagnose(meta.file, meta.line, `invalid diagram on ${object.kind} "${object.name}": ${error}`);
270
+ }
271
+ };
@@ -0,0 +1,4 @@
1
+ import type { Specification } from "./specbook-struct-spec.js";
2
+ import type { SchemaSpecification } from "./specbook-struct-schema.js";
3
+ export type AstFormat = "json" | "json5" | "yaml" | "toon";
4
+ export declare const renderAst: (specification: Specification, format: AstFormat, config?: SchemaSpecification) => Promise<Buffer>;
@@ -0,0 +1,36 @@
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 JSON5 from "json5";
7
+ import { stringify as stringifyYaml } from "yaml";
8
+ import { encode as encodeToon } from "@toon-format/toon";
9
+ import { specDiagrams } from "./specbook-diagram.js";
10
+ /* render the specification AST into a serialization format */
11
+ export const renderAst = async (specification, format, config) => {
12
+ /* reduce the specification to plain JSON values (ISO date strings) */
13
+ const plain = JSON.parse(JSON.stringify(specification));
14
+ /* attach the Gradia specs of the diagram-configured objects as
15
+ "diagram" fields onto the corresponding plain object nodes
16
+ (an invalid diagram situation omits the diagram, as it is
17
+ already reported as a lint diagnostic) */
18
+ if (config !== undefined) {
19
+ const diagrams = specDiagrams(specification, config);
20
+ const walk = (object, node) => {
21
+ const result = diagrams.get(object);
22
+ if (result?.spec !== undefined)
23
+ node.diagram = result.spec;
24
+ object.childs.forEach((child, i) => walk(child, node.childs[i]));
25
+ };
26
+ specification.artifacts.forEach((artifact, i) => artifact.objects.forEach((object, j) => walk(object, plain.artifacts[i].objects[j])));
27
+ }
28
+ if (format === "json")
29
+ return Buffer.from(JSON.stringify(plain, null, 4) + "\n", "utf8");
30
+ else if (format === "json5")
31
+ return Buffer.from(JSON5.stringify(plain, null, 4) + "\n", "utf8");
32
+ else if (format === "yaml")
33
+ return Buffer.from(stringifyYaml(plain), "utf8");
34
+ else
35
+ return Buffer.from(encodeToon(plain) + "\n", "utf8");
36
+ };
@@ -0,0 +1,33 @@
1
+ import type { Specification, Object as SpecObject } from "./specbook-struct-spec.js";
2
+ export declare const escapeHtml: (text: string) => string;
3
+ export declare const stylesheet: () => string;
4
+ export declare const searchScript: () => string;
5
+ export declare const fallbackLogo: () => string;
6
+ export declare const isTitleObject: (object: SpecObject) => boolean;
7
+ export declare const documentLang: (specification: Specification) => string | undefined;
8
+ export declare const documentCharset: (specification: Specification) => string | undefined;
9
+ export declare const documentThemeStyle: (specification: Specification) => string | undefined;
10
+ export declare const documentThemeTone: (specification: Specification) => string | undefined;
11
+ export type PaperSetup = {
12
+ unit: "mm" | "in";
13
+ height: number;
14
+ margin: {
15
+ top: number;
16
+ bottom: number;
17
+ left: number;
18
+ right: number;
19
+ };
20
+ };
21
+ export declare const paperSizes: string[];
22
+ export declare const paperSizeDefault = "A4";
23
+ export declare const paperSetup: (paper: string) => PaperSetup;
24
+ export declare const paperLength: (setup: PaperSetup, value: number) => string;
25
+ export declare const documentPaperSize: (specification: Specification) => string;
26
+ export declare const paperStylesheet: (paper: string) => string;
27
+ export declare const charsetCodepoints: (charset: string) => number[] | undefined;
28
+ export declare const subsetStylesheet: (charset?: string) => Promise<string>;
29
+ export declare const documentTitle: (specification: Specification) => {
30
+ title: string;
31
+ subtitle?: string;
32
+ };
33
+ export declare const documentLogo: (specification: Specification) => string;
@@ -0,0 +1,142 @@
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 path from "node:path";
7
+ import fs from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ /* escape a text for embedding into template HTML */
10
+ export const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
11
+ /* provide the build-time pre-assembled stylesheet (with the
12
+ font faces already inlined as base64 data: URIs) */
13
+ export const stylesheet = () => fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-export-html.css"), "utf8");
14
+ /* provide the build-time bundled client-side fuzzy search script */
15
+ export const searchScript = () => fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-export-html-search.js"), "utf8");
16
+ /* provide the build-time bundled fallback logo of SpecBook itself
17
+ (as a self-contained data: URL, to keep its styles isolated) */
18
+ export const fallbackLogo = () => "data:image/svg+xml;base64," + fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-export-logo.svg")).toString("base64");
19
+ /* check whether an object is the specification title object */
20
+ export const isTitleObject = (object) => object.kind === "META" && object.name.toUpperCase() === "TITLE";
21
+ /* determine a property value of the title object */
22
+ const titleProperty = (specification, name) => specification.artifacts.flatMap((artifact) => artifact.objects).find(isTitleObject)
23
+ ?.properties.find((property) => property.key === name)?.value;
24
+ /* determine the document language (LANG) from the title object */
25
+ export const documentLang = (specification) => titleProperty(specification, "LANG")?.trim();
26
+ /* determine the document character set (CHARSET) from the title object */
27
+ export const documentCharset = (specification) => titleProperty(specification, "CHARSET")?.trim();
28
+ /* determine the document theme style (THEME-STYLE) from the title object */
29
+ export const documentThemeStyle = (specification) => titleProperty(specification, "THEME-STYLE")?.trim();
30
+ /* determine the document theme color tone (THEME-TONE) from the title object */
31
+ export const documentThemeTone = (specification) => titleProperty(specification, "THEME-TONE")?.trim();
32
+ /* the supported paper sizes for print: ISO A4 in millimeters and the
33
+ two US sizes closest to it in inches, each with the default margins
34
+ of 1in (25mm) at the top/bottom and 0.8in (20mm) at the left/right */
35
+ const papers = {
36
+ "A4": { unit: "mm", height: 297, margin: { top: 25, bottom: 25, left: 20, right: 20 } },
37
+ "Letter": { unit: "in", height: 11, margin: { top: 1, bottom: 1, left: 0.8, right: 0.8 } },
38
+ "Legal": { unit: "in", height: 14, margin: { top: 1, bottom: 1, left: 0.8, right: 0.8 } }
39
+ };
40
+ export const paperSizes = Object.keys(papers);
41
+ export const paperSizeDefault = "A4";
42
+ /* provide the setup of a paper size, falling back onto the default */
43
+ export const paperSetup = (paper) => papers[paper] ?? papers[paperSizeDefault];
44
+ /* render a paper dimension as its CSS length */
45
+ export const paperLength = (setup, value) => `${value}${setup.unit}`;
46
+ /* determine the document paper size (PAPER-SIZE) from the title object,
47
+ matched case-insensitively and falling back onto the default */
48
+ export const documentPaperSize = (specification) => {
49
+ const value = titleProperty(specification, "PAPER-SIZE")?.trim().toLowerCase();
50
+ return paperSizes.find((name) => name.toLowerCase() === value) ?? paperSizeDefault;
51
+ };
52
+ /* provide the paper-dependent print stylesheet: a diagram is scaled
53
+ down to still fit onto a single page (the paper height less the
54
+ print margins and the own vertical margins of the diagram) and is
55
+ never broken across a page boundary */
56
+ export const paperStylesheet = (paper) => {
57
+ const setup = paperSetup(paper);
58
+ const avail = setup.height - setup.margin.top - setup.margin.bottom;
59
+ return "@media print {\n" +
60
+ "div.diagram { break-inside: avoid; }\n" +
61
+ `div.diagram svg { max-height: calc(${paperLength(setup, avail)} - 2rem);` +
62
+ " width: auto; height: auto; }\n" +
63
+ "}\n";
64
+ };
65
+ /* generate a contiguous codepoint range */
66
+ const range = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i);
67
+ /* the codepoints of US-ASCII and ISO-8859-1 (ISO Latin 1), plus the
68
+ ISO-8859-15 (ISO Latin 9) revision, which replaces eight Latin 1
69
+ codepoints with the Euro sign and the missing French/Finnish letters */
70
+ const codepointsAscii = range(0x20, 0x7E);
71
+ const codepointsLatin1 = [...codepointsAscii, ...range(0xA0, 0xFF)];
72
+ const codepointsLatin9 = [
73
+ ...codepointsLatin1.filter((cp) => ![0xA4, 0xA6, 0xA8, 0xB4, 0xB8, 0xBC, 0xBD, 0xBE].includes(cp)),
74
+ 0x20AC, 0x160, 0x161, 0x17D, 0x17E, 0x152, 0x153, 0x178
75
+ ];
76
+ /* map a charset name (under its usual aliases) onto its codepoints,
77
+ with the full Unicode charsets mapping onto undefined (no subsetting) */
78
+ export const charsetCodepoints = (charset) => {
79
+ const name = charset.toLowerCase().replace(/[^a-z0-9]+/g, "");
80
+ if (name === "ascii" || name === "usascii")
81
+ return codepointsAscii;
82
+ else if (name === "iso88591" || name === "isolatin1" || name === "latin1")
83
+ return codepointsLatin1;
84
+ else if (name === "iso885915" || name === "isolatin15" || name === "latin15"
85
+ || name === "isolatin9" || name === "latin9")
86
+ return codepointsLatin9;
87
+ else if (name === "utf8" || name === "utf16" || name === "unicode")
88
+ return undefined;
89
+ throw new Error(`unknown charset "${charset}" ` +
90
+ "(expected US-ASCII, ISO-8859-1/ISO-Latin-1, ISO-8859-15/ISO-Latin-15, or UTF-8)");
91
+ };
92
+ /* the symbol glyphs used by the HTML/PDF rendering (kind and property
93
+ bullets, link symbol, primary marker, theme switch icon, anchor
94
+ symbol plus its text presentation variation selector, and the
95
+ search field clearing icon) */
96
+ const symbolGlyphs = [0x25CF, 0x25CB, 0x26AD, 0x2318, 0x25D0, 0x2693, 0xFE0E, 0x00D7];
97
+ /* the typographic glyphs producible by the smart typography rendering
98
+ (language-specific quotes, dashes, ellipsis, bullet, nbsp) */
99
+ const typographyGlyphs = [
100
+ 0x00A0, 0x00AB, 0x00BB, 0x2013, 0x2014, 0x2018, 0x2019,
101
+ 0x201A, 0x201C, 0x201D, 0x201E, 0x2022, 0x2026, 0x2039, 0x203A
102
+ ];
103
+ /* provide the stylesheet with its embedded fonts subsetted to the
104
+ codepoints of a charset plus the always-used symbol and typography
105
+ glyphs (no charset or a full Unicode charset keeps the fonts complete) */
106
+ export const subsetStylesheet = async (charset) => {
107
+ const css = stylesheet();
108
+ if (charset === undefined)
109
+ return css;
110
+ const codepoints = charsetCodepoints(charset);
111
+ if (codepoints === undefined)
112
+ return css;
113
+ const { default: subsetFont } = await import("subset-font");
114
+ const text = String.fromCodePoint(...codepoints, ...symbolGlyphs, ...typographyGlyphs);
115
+ let result = "";
116
+ let last = 0;
117
+ for (const m of css.matchAll(/url\("data:font\/woff2;base64,([^"]+)"\)/g)) {
118
+ const subset = await subsetFont(Buffer.from(m[1], "base64"), text, { targetFormat: "woff2" });
119
+ result += css.slice(last, m.index) +
120
+ `url("data:font/woff2;base64,${subset.toString("base64")}")`;
121
+ last = m.index + m[0].length;
122
+ }
123
+ return result + css.slice(last);
124
+ };
125
+ /* determine the document title and subtitle from the title object */
126
+ export const documentTitle = (specification) => {
127
+ const object = specification.artifacts.flatMap((artifact) => artifact.objects).find(isTitleObject);
128
+ const prop = (name) => object?.properties.find((property) => property.key === name)?.value;
129
+ return { title: prop("TITLE") ?? object?.name ?? "Specification", subtitle: prop("SUBTITLE") };
130
+ };
131
+ /* determine the document logo as a self-contained data: URL, taken from the
132
+ embedded image of the LOGO property of the title object, and falling back
133
+ onto the built-in SpecBook logo (for use in isolated rendering contexts,
134
+ like the print header/footer, which load no external resources) */
135
+ export const documentLogo = (specification) => {
136
+ const object = specification.artifacts.flatMap((artifact) => artifact.objects).find(isTitleObject);
137
+ const content = object?.properties.find((property) => property.key === "LOGO")?.embedding?.[0];
138
+ if (content === undefined)
139
+ return fallbackLogo();
140
+ return content.startsWith("data:") ? content :
141
+ `data:image/svg+xml;base64,${Buffer.from(content, "utf8").toString("base64")}`;
142
+ };