@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
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { Spec } from "./specbook-format-spec.js";
|
|
2
2
|
import { type Verbose } from "./specbook-verbose.js";
|
|
3
|
+
export declare const isDocument: (content: string) => boolean;
|
|
3
4
|
export declare const optimizeImages: (specification: Spec, print: boolean, verbose?: Verbose) => Promise<Map<string, string>>;
|
|
5
|
+
export declare const analyzeImages: (specification: Spec, verbose?: Verbose) => Promise<Map<string, boolean>>;
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { embeddedSource } from "./specbook-export-diagram.js";
|
|
9
|
+
import { embeddedListing } from "./specbook-export-code.js";
|
|
10
|
+
import { svgInvertible } from "./specbook-export-common.js";
|
|
11
|
+
import { darkMark, markDark, pdfType } from "./specbook-parse-common.js";
|
|
12
|
+
import { plainText } from "./specbook-link.js";
|
|
6
13
|
import { literal } from "./specbook-verbose.js";
|
|
7
14
|
/* the pixel width the raster images are capped to: the 60rem content
|
|
8
15
|
width of the document (at 16px per rem) at a 2x device pixel ratio */
|
|
@@ -19,6 +26,73 @@ const imageCaches = {
|
|
|
19
26
|
screen: new Map(),
|
|
20
27
|
print: new Map()
|
|
21
28
|
};
|
|
29
|
+
/* whether an embedded content is a page of a PDF document, which is
|
|
30
|
+
converted into an SVG image and then treated exactly like one */
|
|
31
|
+
export const isDocument = (content) => darkMark(content).content.startsWith(`data:${pdfType};`);
|
|
32
|
+
/* render the selected page of a PDF document (given as its embedding
|
|
33
|
+
data: URL carrying the 1-based "page" parameter, the first by default)
|
|
34
|
+
with PDF.js onto an SVG canvas of @napi-rs/canvas, i.e. as vector
|
|
35
|
+
graphics with the glyphs drawn as outlines, where PDF.js takes the
|
|
36
|
+
standard fonts, CMaps, ICC profiles, and WebAssembly decoders of
|
|
37
|
+
non-self-contained documents from its own package */
|
|
38
|
+
const renderPdf = async (content) => {
|
|
39
|
+
const page = Number(content.match(/^data:[^,]*?;page=(\d+)[;,]/)?.[1] ?? "1");
|
|
40
|
+
const data = content.slice(content.indexOf(",") + 1);
|
|
41
|
+
const { getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
42
|
+
const { createCanvas, SvgExportFlag } = await import("@napi-rs/canvas");
|
|
43
|
+
const dir = path.dirname(createRequire(import.meta.url).resolve("pdfjs-dist/package.json"));
|
|
44
|
+
const task = getDocument({ data: new Uint8Array(Buffer.from(data, "base64")), verbosity: 0,
|
|
45
|
+
standardFontDataUrl: `${dir}/standard_fonts/`, cMapUrl: `${dir}/cmaps/`,
|
|
46
|
+
iccUrl: `${dir}/iccs/`, wasmUrl: `${dir}/wasm/` });
|
|
47
|
+
try {
|
|
48
|
+
const doc = await task.promise;
|
|
49
|
+
if (page < 1 || page > doc.numPages)
|
|
50
|
+
throw new Error(`document has no page ${page}`);
|
|
51
|
+
const sheet = await doc.getPage(page);
|
|
52
|
+
const viewport = sheet.getViewport({ scale: 1 });
|
|
53
|
+
const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height), SvgExportFlag.NoPrettyXML);
|
|
54
|
+
await sheet.render({ viewport,
|
|
55
|
+
canvas: canvas,
|
|
56
|
+
canvasContext: canvas.getContext("2d") }).promise;
|
|
57
|
+
return canvas.getContent().toString("utf8");
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await task.destroy();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
/* the in-memory cache of the SVG renderings of the embedded PDF pages
|
|
64
|
+
(null for a failed one), keyed by their plain embedded content and
|
|
65
|
+
swept to the pages of the latest conversion, like the optimized images */
|
|
66
|
+
let documentCache = new Map();
|
|
67
|
+
/* convert the embedded PDF pages of a specification into SVG images,
|
|
68
|
+
served from the cache where possible, where a rendering failure (e.g.
|
|
69
|
+
an absent page) is surfaced as a notice only and omits the page */
|
|
70
|
+
const convertDocuments = async (specification, verbose) => {
|
|
71
|
+
const cache = new Map();
|
|
72
|
+
const walk = async (object) => {
|
|
73
|
+
const contents = [...object.description?.embedding ?? [],
|
|
74
|
+
...object.properties.flatMap((property) => property.embedding ?? [])];
|
|
75
|
+
for (const plain of contents.filter(isDocument).map((content) => darkMark(content).content)) {
|
|
76
|
+
if (cache.has(plain))
|
|
77
|
+
continue;
|
|
78
|
+
let svg = documentCache.get(plain);
|
|
79
|
+
if (svg === undefined)
|
|
80
|
+
svg = await renderPdf(plain).catch((err) => {
|
|
81
|
+
verbose?.(`rendering PDF page of ${object.kind} "${literal(plainText(object.name))}" failed: ` +
|
|
82
|
+
(err instanceof Error ? err.message : String(err)));
|
|
83
|
+
return null;
|
|
84
|
+
});
|
|
85
|
+
cache.set(plain, svg);
|
|
86
|
+
}
|
|
87
|
+
for (const child of object.children)
|
|
88
|
+
await walk(child);
|
|
89
|
+
};
|
|
90
|
+
for (const artifact of specification.artifacts)
|
|
91
|
+
for (const object of artifact.objects)
|
|
92
|
+
await walk(object);
|
|
93
|
+
documentCache = cache;
|
|
94
|
+
return cache;
|
|
95
|
+
};
|
|
22
96
|
/* optimize a single SVG image with SVGO (the identifiers and classes
|
|
23
97
|
are free to be minified, as the image is rendered in isolation
|
|
24
98
|
through an <img> element), after stripping the "content" attribute
|
|
@@ -63,19 +137,23 @@ const optimizeRaster = async (content, print) => {
|
|
|
63
137
|
return `data:${type};base64,${data.toString("base64")}`;
|
|
64
138
|
};
|
|
65
139
|
/* collect the embedded image contents of an object and its descendants
|
|
66
|
-
(the empty entries of the unreadable files
|
|
140
|
+
(the empty entries of the unreadable files, the embedded diagram
|
|
141
|
+
sources, which are rendered instead, and the embedded source code
|
|
142
|
+
files, which are listed instead, left out) */
|
|
143
|
+
const isImage = (content) => content !== "" && embeddedSource(content) === undefined && embeddedListing(content) === undefined;
|
|
67
144
|
const collect = (object, contents) => {
|
|
68
145
|
for (const content of object.description?.embedding ?? [])
|
|
69
|
-
if (content
|
|
146
|
+
if (isImage(content))
|
|
70
147
|
contents.add(content);
|
|
71
148
|
for (const property of object.properties)
|
|
72
149
|
for (const content of property.embedding ?? [])
|
|
73
|
-
if (content
|
|
150
|
+
if (isImage(content))
|
|
74
151
|
contents.add(content);
|
|
75
152
|
for (const child of object.children)
|
|
76
153
|
collect(child, contents);
|
|
77
154
|
};
|
|
78
|
-
/* optimize the embedded images of a specification
|
|
155
|
+
/* optimize the embedded images of a specification (the PDF pages as
|
|
156
|
+
their converted SVGs, a failed one left out) for the HTML export
|
|
79
157
|
(the screen) or the PDF export (print), yielding the map from the
|
|
80
158
|
embedded image contents onto their optimized ones, served from the
|
|
81
159
|
cache where possible: an optimization which fails (an image the
|
|
@@ -83,7 +161,9 @@ const collect = (object, contents) => {
|
|
|
83
161
|
for) or which does not shrink the image keeps the original, so the
|
|
84
162
|
export never depends on it */
|
|
85
163
|
export const optimizeImages = async (specification, print, verbose) => {
|
|
86
|
-
/* collect the distinct embedded image contents of the specification
|
|
164
|
+
/* collect the distinct embedded image contents of the specification
|
|
165
|
+
and convert its PDF pages */
|
|
166
|
+
const documents = await convertDocuments(specification, verbose);
|
|
87
167
|
const medium = print ? "print" : "screen";
|
|
88
168
|
const contents = new Set();
|
|
89
169
|
for (const artifact of specification.artifacts)
|
|
@@ -97,20 +177,29 @@ export const optimizeImages = async (specification, print, verbose) => {
|
|
|
97
177
|
let before = 0;
|
|
98
178
|
let after = 0;
|
|
99
179
|
for (const content of contents) {
|
|
180
|
+
const plain = darkMark(content);
|
|
181
|
+
const document = isDocument(content) ? documents.get(plain.content) ?? null : undefined;
|
|
182
|
+
if (document === null)
|
|
183
|
+
continue;
|
|
100
184
|
let optimized = imageCaches[medium].get(content);
|
|
101
185
|
if (optimized !== undefined)
|
|
102
186
|
cached++;
|
|
103
187
|
else {
|
|
104
188
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
189
|
+
/* an image marked with its treatment on the dark theme is
|
|
190
|
+
optimized plain and marked again, a PDF page as its SVG */
|
|
191
|
+
optimized = document !== undefined ? await optimizeSvg(document) :
|
|
192
|
+
plain.content.startsWith("data:") ?
|
|
193
|
+
await optimizeRaster(plain.content, print) : await optimizeSvg(plain.content);
|
|
194
|
+
if (plain.dark !== undefined)
|
|
195
|
+
optimized = markDark(optimized, plain.dark);
|
|
107
196
|
}
|
|
108
197
|
catch (err) {
|
|
109
198
|
verbose?.("optimizing image failed (keeping the original): " +
|
|
110
199
|
(err instanceof Error ? err.message : String(err)));
|
|
111
|
-
optimized = content;
|
|
200
|
+
optimized = document !== undefined ? document : content;
|
|
112
201
|
}
|
|
113
|
-
if (optimized.length >= content.length)
|
|
202
|
+
if (document === undefined && optimized.length >= content.length)
|
|
114
203
|
optimized = content;
|
|
115
204
|
}
|
|
116
205
|
cache.set(content, optimized);
|
|
@@ -127,3 +216,66 @@ export const optimizeImages = async (specification, print, verbose) => {
|
|
|
127
216
|
`${literal(Math.round(before / 1024))} KB -> ${literal(Math.round(after / 1024))} KB`);
|
|
128
217
|
return cache;
|
|
129
218
|
};
|
|
219
|
+
/* the in-memory cache of the "auto" judgements of the raster images,
|
|
220
|
+
keyed by the embedded image content and swept to the images of the
|
|
221
|
+
latest analysis, exactly like the optimized images */
|
|
222
|
+
let rasterCache = new Map();
|
|
223
|
+
/* whether a raster image (given as a base64 data: URL) is most likely
|
|
224
|
+
dark ink on a transparent or light canvas and hence safe to invert on
|
|
225
|
+
the dark theme ("dark=auto"), judged on a downscaled copy of its
|
|
226
|
+
pixels: at least 60% of them are transparent or near-white (the
|
|
227
|
+
canvas), the others (the ink) exist, are dark on average, and consist
|
|
228
|
+
of a few colors only (the quantized colors holding at least 2% of the
|
|
229
|
+
ink cover at least 85% of it), unlike a photo or a colorful screenshot */
|
|
230
|
+
const rasterInvertible = async (content) => {
|
|
231
|
+
const m = content.match(/^data:image\/(?:png|jpeg|webp);base64,(.*)$/s);
|
|
232
|
+
if (m === null)
|
|
233
|
+
return false;
|
|
234
|
+
const { default: sharp } = await import("sharp");
|
|
235
|
+
const { data, info } = await sharp(Buffer.from(m[1], "base64"))
|
|
236
|
+
.resize({ width: 128, height: 128, fit: "inside", withoutEnlargement: true })
|
|
237
|
+
.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
238
|
+
let canvas = 0;
|
|
239
|
+
let ink = 0;
|
|
240
|
+
let luma = 0;
|
|
241
|
+
const bins = new Map();
|
|
242
|
+
for (let p = 0; p < data.length; p += info.channels) {
|
|
243
|
+
const [r, g, b, a] = [data[p], data[p + 1], data[p + 2], data[p + 3]];
|
|
244
|
+
if (a < 32 || Math.min(r, g, b) >= 235)
|
|
245
|
+
canvas++;
|
|
246
|
+
else {
|
|
247
|
+
ink++;
|
|
248
|
+
luma += (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
249
|
+
const bin = ((r >> 5) << 6) | ((g >> 5) << 3) | (b >> 5);
|
|
250
|
+
bins.set(bin, (bins.get(bin) ?? 0) + 1);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const few = Array.from(bins.values()).filter((n) => n >= ink * 0.02).reduce((sum, n) => sum + n, 0);
|
|
254
|
+
return ink > 0 && canvas / (canvas + ink) >= 0.6 && luma / ink < 0.6 && few >= ink * 0.85;
|
|
255
|
+
};
|
|
256
|
+
/* judge the raster images and PDF pages of a specification without an
|
|
257
|
+
explicit treatment on the dark theme for the "auto" one, yielding the
|
|
258
|
+
map from their embedded contents onto whether they are invertible,
|
|
259
|
+
served from the cache where possible (an image Sharp cannot process
|
|
260
|
+
and a failed PDF page count as not invertible), where a PDF page is
|
|
261
|
+
judged on its converted SVG like an SVG image */
|
|
262
|
+
export const analyzeImages = async (specification, verbose) => {
|
|
263
|
+
const documents = await convertDocuments(specification, verbose);
|
|
264
|
+
const contents = new Set();
|
|
265
|
+
for (const artifact of specification.artifacts)
|
|
266
|
+
for (const object of artifact.objects)
|
|
267
|
+
collect(object, contents);
|
|
268
|
+
const cache = new Map();
|
|
269
|
+
for (const content of contents) {
|
|
270
|
+
if (!content.startsWith("data:") || darkMark(content).dark !== undefined)
|
|
271
|
+
continue;
|
|
272
|
+
if (isDocument(content)) {
|
|
273
|
+
const document = documents.get(content);
|
|
274
|
+
cache.set(content, typeof document === "string" && svgInvertible(document));
|
|
275
|
+
}
|
|
276
|
+
else
|
|
277
|
+
cache.set(content, rasterCache.get(content) ?? await rasterInvertible(content).catch(() => false));
|
|
278
|
+
}
|
|
279
|
+
rasterCache = new Map(Array.from(cache).filter(([content]) => !isDocument(content)));
|
|
280
|
+
return cache;
|
|
281
|
+
};
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import { specDiagrams } from "./specbook-diagram.js";
|
|
8
|
-
import { becauseRegex, embeddingRegex, embeddingCount } from "./specbook-parse-common.js";
|
|
9
|
-
import { isTitleObject } from "./specbook-export-common.js";
|
|
10
|
-
import { optimizeImages } from "./specbook-export-image.js";
|
|
8
|
+
import { becauseRegex, embeddingRegex, embeddingCount, darkMark, markDark } from "./specbook-parse-common.js";
|
|
9
|
+
import { isTitleObject, imageDark } from "./specbook-export-common.js";
|
|
10
|
+
import { optimizeImages, analyzeImages, isDocument } from "./specbook-export-image.js";
|
|
11
|
+
import { renderEmbeddedDiagrams, embeddedSource, diagramKey } from "./specbook-export-diagram.js";
|
|
12
|
+
import { embeddedListing, listingFence } from "./specbook-export-code.js";
|
|
11
13
|
/* format a timestamp in the frontmatter format */
|
|
12
14
|
export const formatTimestamp = (date) => {
|
|
13
15
|
const pad = (n) => String(n).padStart(2, "0");
|
|
@@ -27,9 +29,11 @@ const renderKeyValuesMd = (properties, indent = "") => {
|
|
|
27
29
|
a ";" would split the segment, and a value carrying the rationale
|
|
28
30
|
marker would not be taken for a property at all on re-parsing */
|
|
29
31
|
const segmentableMd = (property) => !property.value.includes(";") && !becauseRegex.test(property.value);
|
|
30
|
-
/* render a description statement with its optional rationale
|
|
32
|
+
/* render a description statement with its optional rationale and
|
|
33
|
+
its optional elaboration blocks */
|
|
31
34
|
const renderDescriptionMd = (description) => description.description + (description.rationale !== undefined ?
|
|
32
|
-
`, BECAUSE ${description.rationale}` : "")
|
|
35
|
+
`, BECAUSE ${description.rationale}` : "") + (description.elaboration !== undefined ?
|
|
36
|
+
`\n\n${description.elaboration}` : "");
|
|
33
37
|
/* render the "(*)" primary marker plus the implicit "(xxx)"
|
|
34
38
|
and/or explicit "{{xxx}}" anchor suffix */
|
|
35
39
|
const nameSuffixMd = (object) => (object.primary === true ? " (*)" : "") +
|
|
@@ -119,10 +123,22 @@ export const renderMarkdown = async (specification, config, rebase, verbose) =>
|
|
|
119
123
|
export: an embeddable "" becomes the reference-style
|
|
120
124
|
"![alt][img-N]", whose "[img-N]: data:..." definition ends the
|
|
121
125
|
document (a "{theme}" one takes its light variant, as Markdown
|
|
122
|
-
knows no themes),
|
|
126
|
+
knows no themes), an embedded diagram source file becomes its
|
|
127
|
+
rendered SVG (the light variant, too, marked as inverted on the
|
|
128
|
+
dark theme where the diagram is), where every emitted SVG is marked
|
|
129
|
+
with its resolved treatment on the dark theme (an adapting diagram
|
|
130
|
+
flattened onto its light variant as the "auto" detection judges
|
|
131
|
+
it), so a re-parse keeps it, even where the "auto" detection would
|
|
132
|
+
judge a re-optimized SVG differently, an embedded source code file
|
|
133
|
+
of a description leaves its markup for a fenced code block collected
|
|
134
|
+
into "fences" (and one of a property value becomes a "data:"
|
|
135
|
+
definition, too), and only the remaining ones (including a failed
|
|
136
|
+
diagram) are re-based */
|
|
123
137
|
const optimized = await optimizeImages(specification, false, verbose);
|
|
138
|
+
const rasters = await analyzeImages(specification, verbose);
|
|
139
|
+
const sources = await renderEmbeddedDiagrams(specification, verbose);
|
|
124
140
|
const labels = new Map();
|
|
125
|
-
const embedMd = (text, embedding, from, to) => {
|
|
141
|
+
const embedMd = (text, embedding, from, to, fences) => {
|
|
126
142
|
let i = 0;
|
|
127
143
|
const embedded = text.replace(embeddingRegex, (match, alt, reference) => {
|
|
128
144
|
const count = embeddingCount(reference);
|
|
@@ -130,9 +146,24 @@ export const renderMarkdown = async (specification, config, rebase, verbose) =>
|
|
|
130
146
|
i += count;
|
|
131
147
|
if (content === undefined || content === "")
|
|
132
148
|
return match;
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
149
|
+
const listing = embeddedListing(content);
|
|
150
|
+
if (listing !== undefined && fences !== undefined) {
|
|
151
|
+
fences.push(listingFence(listing));
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
154
|
+
else if (listing !== undefined) {
|
|
155
|
+
const label = labels.get(content) ?? `img-${labels.size + 1}`;
|
|
156
|
+
labels.set(content, label);
|
|
157
|
+
return `![${alt}][${label}]`;
|
|
158
|
+
}
|
|
159
|
+
const diagram = embeddedSource(content);
|
|
160
|
+
const variants = diagram !== undefined ? sources.get(diagramKey(diagram.language, diagram.source)) : undefined;
|
|
161
|
+
const light = diagram !== undefined ? variants?.light :
|
|
162
|
+
optimized.get(content) ?? (isDocument(content) ? undefined : content);
|
|
163
|
+
if (light === undefined)
|
|
164
|
+
return match;
|
|
165
|
+
const url = diagram !== undefined ? markDark(light, imageDark(light)) :
|
|
166
|
+
markDark(darkMark(light).content, imageDark(content, rasters));
|
|
136
167
|
const label = labels.get(url) ?? `img-${labels.size + 1}`;
|
|
137
168
|
labels.set(url, label);
|
|
138
169
|
return `![${alt}][${label}]`;
|
|
@@ -148,9 +179,26 @@ export const renderMarkdown = async (specification, config, rebase, verbose) =>
|
|
|
148
179
|
properties: object.properties.map((property) => ({ ...property, value: embedMd(property.value, property.embedding ?? [], from, to) })),
|
|
149
180
|
children: object.children.map((child) => embedObjectMd(child, from, to))
|
|
150
181
|
};
|
|
151
|
-
if (object.description !== undefined)
|
|
182
|
+
if (object.description !== undefined) {
|
|
183
|
+
/* the embeddings of the elaboration follow those of the statement,
|
|
184
|
+
and the fenced code blocks of the embedded source code files
|
|
185
|
+
end the elaboration */
|
|
186
|
+
const embedding = object.description.embedding ?? [];
|
|
187
|
+
const skip = Array.from(object.description.description.matchAll(embeddingRegex))
|
|
188
|
+
.reduce((sum, m) => sum + embeddingCount(m[2]), 0);
|
|
189
|
+
const fences = new Array();
|
|
152
190
|
clone.description = { ...object.description,
|
|
153
|
-
description: embedMd(object.description.description,
|
|
191
|
+
description: embedMd(object.description.description, embedding, from, to, fences) };
|
|
192
|
+
if (object.description.elaboration !== undefined)
|
|
193
|
+
clone.description.elaboration = embedMd(object.description.elaboration, embedding.slice(skip), from, to, fences);
|
|
194
|
+
if (fences.length > 0) {
|
|
195
|
+
/* the removed markups leave no empty paragraphs behind */
|
|
196
|
+
const collapse = (text) => text.replace(/\n[ \t]*(?:\n[ \t]*)+\n/g, "\n\n").trim();
|
|
197
|
+
clone.description.description = collapse(clone.description.description);
|
|
198
|
+
clone.description.elaboration = [collapse(clone.description.elaboration ?? ""), ...fences]
|
|
199
|
+
.filter((block) => block !== "").join("\n\n");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
154
202
|
const spec = diagrams.get(object);
|
|
155
203
|
if (spec !== undefined)
|
|
156
204
|
diagrams.set(clone, spec);
|
|
@@ -68,7 +68,7 @@ export type SchemaDiagram = {
|
|
|
68
68
|
config?: Partial<GradiaConfig>;
|
|
69
69
|
};
|
|
70
70
|
export type SchemaFormat = {
|
|
71
|
-
type?: "auto" | "complex" | "concise";
|
|
71
|
+
type?: "auto" | "complex" | "compact" | "concise";
|
|
72
72
|
maxTableColumns?: number;
|
|
73
73
|
maxCellHeight?: number;
|
|
74
74
|
withUnusedProps?: boolean;
|
|
@@ -80,7 +80,7 @@ const SchemaDiagram = v.strictObject({
|
|
|
80
80
|
config: v.optional(SchemaDiagramConfig)
|
|
81
81
|
});
|
|
82
82
|
const SchemaFormat = v.strictObject({
|
|
83
|
-
type: v.optional(v.picklist(["auto", "complex", "concise"])),
|
|
83
|
+
type: v.optional(v.picklist(["auto", "complex", "compact", "concise"])),
|
|
84
84
|
maxTableColumns: v.optional(v.pipe(v.number(), v.integer(), v.minValue(2))),
|
|
85
85
|
maxCellHeight: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
|
|
86
86
|
withUnusedProps: v.optional(v.boolean())
|
package/dst/specbook-format.md
CHANGED
|
@@ -123,7 +123,7 @@ type SchemaDiagram = {
|
|
|
123
123
|
config?: Partial<GradiaConfig>
|
|
124
124
|
}
|
|
125
125
|
type SchemaFormat = {
|
|
126
|
-
type?: "auto" | "complex" | "concise"
|
|
126
|
+
type?: "auto" | "complex" | "compact" | "concise"
|
|
127
127
|
maxTableColumns?: number
|
|
128
128
|
maxCellHeight?: number
|
|
129
129
|
withUnusedProps?: boolean
|
|
@@ -429,8 +429,9 @@ type SchemaGradiaConfig = Partial<{
|
|
|
429
429
|
HTML/PDF rendering of the objects of an object kind among their siblings,
|
|
430
430
|
BECAUSE one uniform rendering fits neither rich nor numerous objects
|
|
431
431
|
|
|
432
|
-
- `SchemaFormat.type?: "auto" | "complex" | "concise"`:
|
|
433
|
-
rendering: `complex` (sections), `concise` (tables),
|
|
432
|
+
- `SchemaFormat.type?: "auto" | "complex" | "compact" | "concise"`:
|
|
433
|
+
rendering: `complex` (sections), `concise` (tables), `compact`
|
|
434
|
+
(tables of section-like cells), or `auto`,
|
|
434
435
|
BECAUSE sections bury small objects, tables truncate rich ones
|
|
435
436
|
|
|
436
437
|
- `SchemaFormat.maxTableColumns?: number`:
|
|
@@ -649,15 +650,20 @@ how the HTML/PDF export renders the objects of that kind among their
|
|
|
649
650
|
siblings, so that the sibling kinds below one parent can render
|
|
650
651
|
differently (e.g. one kind as a table, another as sections). Its `type`
|
|
651
652
|
selects the rendering: `complex` (nested sections), `concise` (one
|
|
652
|
-
compact table of all sibling objects of the kind),
|
|
653
|
-
|
|
653
|
+
compact table of all sibling objects of the kind), `compact` (one
|
|
654
|
+
table of the name and a single cell per object, which renders the
|
|
655
|
+
object like `complex`, but without its heading: diagram, properties as
|
|
656
|
+
key/value lines, and description, for a kind whose many properties a
|
|
657
|
+
table row cannot hold), or the default `auto`, which collapses only the
|
|
658
|
+
deepest level into tables.
|
|
654
659
|
|
|
655
660
|
Inside a `concise` table, unconfigured child kinds implicitly stay
|
|
656
661
|
`concise` and render as sub-tables inside the description cells, while
|
|
657
662
|
an explicitly configured `type` is always honored, even a `complex`
|
|
658
663
|
rendering pressed into a cell. `maxTableColumns` (default: `4`) bounds
|
|
659
664
|
the columns of the compact table of the kind (a wider group chunks
|
|
660
|
-
its properties into embedded per-object tables
|
|
665
|
+
its properties into embedded per-object tables, while a `compact`
|
|
666
|
+
one ignores it), `maxCellHeight`
|
|
661
667
|
(default: `40`) is the percentage a table cell of the kind may exceed
|
|
662
668
|
the height of every other non-empty cell of its row before the HTML
|
|
663
669
|
export folds its remaining text away, and `withUnusedProps`
|
|
@@ -702,6 +708,7 @@ type SpecObject = {
|
|
|
702
708
|
type SpecDescription = {
|
|
703
709
|
description: string
|
|
704
710
|
rationale?: string
|
|
711
|
+
elaboration?: string
|
|
705
712
|
embedding?: string[]
|
|
706
713
|
}
|
|
707
714
|
type SpecProperty = {
|
|
@@ -776,7 +783,7 @@ type SpecProperty = {
|
|
|
776
783
|
BECAUSE a specification is a tree, not a flat list of statements
|
|
777
784
|
|
|
778
785
|
- `SpecDescription`:
|
|
779
|
-
description of an object: statement, rationale, and images,
|
|
786
|
+
description of an object: statement, rationale, elaboration, and images,
|
|
780
787
|
BECAUSE the WHAT and the WHY are stored apart to be rendered apart
|
|
781
788
|
|
|
782
789
|
- `SpecDescription.description: string`:
|
|
@@ -785,11 +792,18 @@ type SpecProperty = {
|
|
|
785
792
|
|
|
786
793
|
- `SpecDescription.rationale?: string`:
|
|
787
794
|
rationale text behind the `, BECAUSE ` split (the WHY),
|
|
795
|
+
up to the end of its paragraph,
|
|
788
796
|
BECAUSE a statement without its WHY cannot be judged or revised
|
|
789
797
|
|
|
798
|
+
- `SpecDescription.elaboration?: string`:
|
|
799
|
+
blocks following the paragraph of the rationale (e.g. images or diagrams),
|
|
800
|
+
BECAUSE further content must not end up inside the WHY
|
|
801
|
+
|
|
790
802
|
- `SpecDescription.embedding?: string[]`:
|
|
791
|
-
image files embedded via ``,
|
|
792
|
-
one entry per file in markup order (
|
|
803
|
+
image, diagram source, and source code files embedded via ``,
|
|
804
|
+
inlined at parse time, one entry per file in markup order (those of
|
|
805
|
+
the statement first, those of the elaboration second, and empty for
|
|
806
|
+
an unreadable file),
|
|
793
807
|
BECAUSE the exports have to stand alone, without the image files
|
|
794
808
|
|
|
795
809
|
- `SpecProperty`:
|
|
@@ -805,7 +819,8 @@ type SpecProperty = {
|
|
|
805
819
|
BECAUSE constraints and references are checked against this text
|
|
806
820
|
|
|
807
821
|
- `SpecProperty.embedding?: string[]`:
|
|
808
|
-
image files embedded in the value,
|
|
822
|
+
image, diagram source, and source code files embedded in the value,
|
|
823
|
+
inlined at parse time,
|
|
809
824
|
one entry per file in markup order (empty for an unreadable file),
|
|
810
825
|
BECAUSE the exports have to stand alone, without the image files
|
|
811
826
|
|
|
@@ -992,10 +1007,15 @@ paragraphs, blockquotes, ordered lists (e.g. scenario steps or test
|
|
|
992
1007
|
case procedures), and fenced code blocks. A fenced code block of
|
|
993
1008
|
language `gradia` is skipped, as it is the derived diagram which
|
|
994
1009
|
**SpecBook** itself emits into exported Markdown and which must not
|
|
995
|
-
become authored content on a re-parse.
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1010
|
+
become authored content on a re-parse. A fenced code block of language
|
|
1011
|
+
`mermaid` (alias `mmd`) or `d2` is a [Mermaid](https://mermaid.js.org/)
|
|
1012
|
+
or [D2](https://d2lang.com/) diagram, which the HTML/PDF export renders
|
|
1013
|
+
as a diagram instead of as code (see "Diagram Embeddings" below). A
|
|
1014
|
+
fenced code block of any other known language is a syntax-highlighted
|
|
1015
|
+
code listing (see "Code Listings" below). In
|
|
1016
|
+
the Concise Format, the description is formed by the non-property
|
|
1017
|
+
segments of the item, so a description carrying a `;` cannot be a
|
|
1018
|
+
segment either and forces its object into the Complex Format.
|
|
999
1019
|
|
|
1000
1020
|
A description is split into its *statement* and *rationale* at the first
|
|
1001
1021
|
`, BECAUSE ` (or `, **BECAUSE** `) occurrence:
|
|
@@ -1005,10 +1025,25 @@ The event is created and configured but not visible to attendees,
|
|
|
1005
1025
|
BECAUSE an event needs a private setup phase.
|
|
1006
1026
|
```
|
|
1007
1027
|
|
|
1028
|
+
The rationale ends with its paragraph: all blocks following it (further
|
|
1029
|
+
paragraphs, embedded images, and diagrams) form the *elaboration*, which
|
|
1030
|
+
is rendered below the statement and its rationale, so the description
|
|
1031
|
+
of a Complex Format object can state its WHAT and WHY first and
|
|
1032
|
+
illustrate them afterwards:
|
|
1033
|
+
|
|
1034
|
+
````
|
|
1035
|
+
The event is created and configured but not visible to attendees,
|
|
1036
|
+
BECAUSE an event needs a private setup phase.
|
|
1037
|
+
|
|
1038
|
+

|
|
1039
|
+
````
|
|
1040
|
+
|
|
1008
1041
|
### Wiki-Style References
|
|
1009
1042
|
|
|
1010
1043
|
A `[[<reference/>]]` anywhere in a name, property value, description, or
|
|
1011
|
-
rationale
|
|
1044
|
+
rationale (except inside a fenced code block, whose code stays verbatim,
|
|
1045
|
+
so e.g. a shell test `[[ -f x ]]` is no reference) is a Wiki-style
|
|
1046
|
+
reference to another object, which is checked
|
|
1012
1047
|
for unique resolvability and rendered as a hyperlink in the HTML/PDF
|
|
1013
1048
|
exports. A reference is a `.`-separated path of segments, where each
|
|
1014
1049
|
segment matches a single object as:
|
|
@@ -1046,7 +1081,21 @@ SVG files are inlined as-is; PNG/JPEG/WebP files are embedded as base64
|
|
|
1046
1081
|
`data:` URLs. URLs and other file types are left untouched. The
|
|
1047
1082
|
HTML/PDF export optimizes the embedded images on its own (downscaled
|
|
1048
1083
|
and re-encoded PNG/JPEG/WebP, minified SVG), so the files can stay in their
|
|
1049
|
-
original resolution.
|
|
1084
|
+
original resolution. Like a diagram, an embedded image (except the logo
|
|
1085
|
+
of the title page) folds, maximizes, and is omitted (`-O diagram:image`,
|
|
1086
|
+
or by nesting level) in the HTML/PDF export, while the AST exports keep
|
|
1087
|
+
it.
|
|
1088
|
+
|
|
1089
|
+
A page of a PDF document is embedded like an image --
|
|
1090
|
+
`` for the first page, or
|
|
1091
|
+
`` for the 1-based page number `<n/>` --
|
|
1092
|
+
where the document is inlined at parse time. The exports convert the
|
|
1093
|
+
page through `pdfjs-dist` into a vector SVG with its texts as glyph
|
|
1094
|
+
outlines and then treat it exactly like an embedded SVG image, while a
|
|
1095
|
+
page failing to render (e.g. an absent page number) is omitted with a
|
|
1096
|
+
notice. A presentation (e.g. PowerPoint), a spreadsheet (e.g. Excel), or
|
|
1097
|
+
any other office document is embedded through its PDF export, which
|
|
1098
|
+
carries its exact rendering and fonts.
|
|
1050
1099
|
|
|
1051
1100
|
A reference carrying the `{theme}` placeholder -- e.g.
|
|
1052
1101
|
`` -- is a *theme-aware* embedding: it expands
|
|
@@ -1056,10 +1105,113 @@ HTML export shows just the variant matching the color theme currently
|
|
|
1056
1105
|
active in the document, while the PDF export (like print in general)
|
|
1057
1106
|
always uses the `light` variant.
|
|
1058
1107
|
|
|
1108
|
+
An image without a dark variant is treated on the dark theme of the HTML
|
|
1109
|
+
export as its `dark` fragment parameter says (see "Fragment Parameters"
|
|
1110
|
+
below), while print always keeps it as it is. An image defaults to
|
|
1111
|
+
`dark=auto`, which inverts it (with its hues rotated back) if it most
|
|
1112
|
+
likely draws dark lines onto a transparent or light canvas: an SVG if it
|
|
1113
|
+
neither adapts itself (via `light-dark()` colors, `prefers-color-scheme`
|
|
1114
|
+
rules, or a `color-scheme` declaration, which follows the color theme of
|
|
1115
|
+
the document anyway), nor embeds a raster image, nor paints a background
|
|
1116
|
+
which is not light, and a raster image (PNG/JPEG/WebP) if at least 60%
|
|
1117
|
+
of its pixels are transparent or near-white and the others are dark on
|
|
1118
|
+
average and of a few colors only (unlike a photo or a colorful
|
|
1119
|
+
screenshot). As the detection cannot tell a logo or illustration with
|
|
1120
|
+
brand colors from a line drawing, `#dark=none` keeps such an image as it
|
|
1121
|
+
is, while `#dark=invert` forces the inversion (e.g.
|
|
1122
|
+
``). A
|
|
1123
|
+
[draw.io](https://www.drawio.com/) diagram is best embedded as its
|
|
1124
|
+
editable SVG (`.drawio.svg`, which draw.io keeps current on every save)
|
|
1125
|
+
with its "Adaptive Colors" page setting on "Automatic", as it then
|
|
1126
|
+
adapts all its colors itself. A theme-aware embedding ignores the
|
|
1127
|
+
fragment, as its `dark` variant already serves the dark theme.
|
|
1128
|
+
|
|
1059
1129
|
The reference-style image `![<alt/>][<label/>]` embeds the image of its
|
|
1060
1130
|
definition `[<label/>]: data:image/<type/>;base64,<data/>` (SVG, PNG,
|
|
1061
|
-
JPEG, or WebP
|
|
1131
|
+
JPEG, or WebP, where a `;dark=invert` or `;dark=none` parameter behind
|
|
1132
|
+
the type carries the treatment on the dark theme, which the export
|
|
1133
|
+
always resolves for an SVG) in the same file, the only supported link
|
|
1134
|
+
definition.
|
|
1062
1135
|
This is the form the normalized Markdown export emits for every embedded
|
|
1063
1136
|
image (a theme-aware one by its `light` variant), with the definitions
|
|
1064
1137
|
at the end of the document, so it stands alone without the image files.
|
|
1065
1138
|
Author the inline form; the reference-style one is for re-parsing only.
|
|
1139
|
+
|
|
1140
|
+
### Diagram Embeddings
|
|
1141
|
+
|
|
1142
|
+
A [Mermaid](https://mermaid.js.org/) or [D2](https://d2lang.com/)
|
|
1143
|
+
diagram is either written inline into a description as a fenced code
|
|
1144
|
+
block of language `mermaid` (alias `mmd`) or `d2`, or kept in a file of
|
|
1145
|
+
its own and embedded like an image -- ``,
|
|
1146
|
+
``, or `` -- inside a
|
|
1147
|
+
description or property value, where its source is inlined at parse time.
|
|
1148
|
+
The HTML/PDF export renders every such diagram (Mermaid through
|
|
1149
|
+
`beautiful-mermaid`, D2 through `@terrastruct/d2`) in the theme colors of
|
|
1150
|
+
the document, once per color theme, like a theme-aware image. It folds,
|
|
1151
|
+
maximizes, and is omitted (`-O diagram:code`, for diagrams as code, or
|
|
1152
|
+
by nesting level) like a derived Gradia diagram. A diagram failing to render
|
|
1153
|
+
is omitted with a notice. The normalized Markdown and the AST exports
|
|
1154
|
+
carry the source of a fenced diagram as part of the description, and an
|
|
1155
|
+
embedded diagram file as its rendered SVG (the `light` variant).
|
|
1156
|
+
|
|
1157
|
+
### Code Listings
|
|
1158
|
+
|
|
1159
|
+
A code snippet is either written inline into a description as a fenced
|
|
1160
|
+
code block of a known language (any language id or alias of
|
|
1161
|
+
[Shiki](https://shiki.style/), like `ts`, `java`, `css`, or `html`), or
|
|
1162
|
+
kept in a source code file of its own and embedded like an image --
|
|
1163
|
+
``, where `<ext/>` is such a language id or
|
|
1164
|
+
alias (e.g. `.ts`, `.java`, `.css`, `.html`) -- inside a description or
|
|
1165
|
+
property value, where its source is inlined at parse time. A fenced code
|
|
1166
|
+
block of no or an unknown language stays plain preformatted text.
|
|
1167
|
+
|
|
1168
|
+
The HTML/PDF export renders every such snippet as a *code listing* in
|
|
1169
|
+
DejaVu Sans Mono, with line numbers and with its keywords, operators,
|
|
1170
|
+
and punctuation in the accent color and bold, its literals in the signal
|
|
1171
|
+
color, and its comments in the dim color and italic. A code listing
|
|
1172
|
+
folds, maximizes, and is omitted (`-O diagram:listing`, or by nesting
|
|
1173
|
+
level) like a diagram.
|
|
1174
|
+
|
|
1175
|
+
A fenced code listing takes whitespace-separated parameters behind its
|
|
1176
|
+
language, and an embedded one takes them as its fragment (see "Fragment
|
|
1177
|
+
Parameters" below):
|
|
1178
|
+
|
|
1179
|
+
- `start=<n/>` (fenced only): the number of the first line (default `1`).
|
|
1180
|
+
- `lines=<a/>-<b/>` or `lines=<a/>` (embedded only): the excerpt of the
|
|
1181
|
+
1-based lines `<a/>` to `<b/>` (or line `<a/>` alone), numbered from
|
|
1182
|
+
`<a/>`, where an empty range is reported as an error.
|
|
1183
|
+
- `mark=<a/>[-<b/>][,...]`: the lines or line ranges to mark (in the
|
|
1184
|
+
shown line numbers) on a very light signal background.
|
|
1185
|
+
|
|
1186
|
+
````
|
|
1187
|
+
```ts start=10 mark=12-13
|
|
1188
|
+
const greeter = new Greeter("World")
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+

|
|
1192
|
+
````
|
|
1193
|
+
|
|
1194
|
+
The AST exports carry an embedded source code file as its
|
|
1195
|
+
`data:text/x-code;lang=<lang/>;start=<n/>[;mark=<hunks/>];base64,<data/>`
|
|
1196
|
+
URL, while the normalized Markdown export turns one of a description into
|
|
1197
|
+
a fenced code block (with its `start` and `mark` parameters) at the end
|
|
1198
|
+
of the elaboration, and keeps one of a property value as a
|
|
1199
|
+
reference-style image definition of that URL.
|
|
1200
|
+
|
|
1201
|
+
#### Fragment Parameters
|
|
1202
|
+
|
|
1203
|
+
The fragment of an embedding reference is a list of `<key/>=<value/>`
|
|
1204
|
+
parameters joined by `&`, in any order and each at most once, with the
|
|
1205
|
+
values optionally URI-encoded (e.g. `%20` for a space): `page`
|
|
1206
|
+
(documents), `dark` (documents and images), and `lines` and `mark`
|
|
1207
|
+
(source code files, see "Code Listings" above). A fragment carrying any
|
|
1208
|
+
other key or an invalid value is reported as an error. The `dark`
|
|
1209
|
+
parameter selects the treatment on the dark theme of the HTML export,
|
|
1210
|
+
while print always shows the original: `dark=invert` inverts the
|
|
1211
|
+
embedding with its hues rotated back, `dark=none` shows it as it is, and
|
|
1212
|
+
`dark=auto` (the default) decides by the content of the image or the
|
|
1213
|
+
rendered document page (inverting a light canvas with dark ink, but
|
|
1214
|
+
neither a dark one nor one carrying a photo), as an image and a document
|
|
1215
|
+
page can be anything (line drawings, photos, logos); e.g.
|
|
1216
|
+
`` keeps a page which the
|
|
1217
|
+
detection would invert, but whose colors have to stay.
|