@rse/specbook 1.3.2 → 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 +13 -7
|
@@ -0,0 +1,749 @@
|
|
|
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 { markedSmartypants } from "marked-smartypants";
|
|
8
|
+
import { resolveUnique, expandReferences } from "./specbook-link.js";
|
|
9
|
+
import { compileValueExpr, splitItems } from "./specbook-parse-value.js";
|
|
10
|
+
import { embeddingRegex, embeddingCount, embeddingThemes, darkMark } from "./specbook-parse-common.js";
|
|
11
|
+
import { escapeHtml, fallbackLogo, imageDark, isTitleObject } from "./specbook-export-common.js";
|
|
12
|
+
import { isDocument } from "./specbook-export-image.js";
|
|
13
|
+
import { embeddedSource, fenceLanguage, diagramKey, omittedDiagram } from "./specbook-export-diagram.js";
|
|
14
|
+
import { embeddedListing, fencedListing } from "./specbook-export-code.js";
|
|
15
|
+
import { coverageRatio } from "./specbook-coverage.js";
|
|
16
|
+
import { safe, render } from "./specbook-export-html-template.js";
|
|
17
|
+
import { omitLong } from "./specbook-export-html-fold.js";
|
|
18
|
+
/* ==== Rendering ==== */
|
|
19
|
+
/* the per-language double quote styles for the smart typography
|
|
20
|
+
(single quotes stay curly, as smartypants cannot distinguish a
|
|
21
|
+
closing single quote from an apostrophe) */
|
|
22
|
+
const quoteStyles = {
|
|
23
|
+
"en": ["“", "”"], /* “...” */
|
|
24
|
+
"nl": ["“", "”"], /* “...” */
|
|
25
|
+
"de": ["„", "“"], /* „...“ */
|
|
26
|
+
"cs": ["„", "“"], /* „...“ */
|
|
27
|
+
"sk": ["„", "“"], /* „...“ */
|
|
28
|
+
"pl": ["„", "”"], /* „...” */
|
|
29
|
+
"fr": ["« ", " »"], /* « ... » */
|
|
30
|
+
"it": ["«", "»"], /* «...» */
|
|
31
|
+
"es": ["«", "»"], /* «...» */
|
|
32
|
+
"pt": ["«", "»"], /* «...» */
|
|
33
|
+
"no": ["«", "»"], /* «...» */
|
|
34
|
+
"da": ["»", "«"], /* »...« */
|
|
35
|
+
"sv": ["”", "”"], /* ”...” */
|
|
36
|
+
"fi": ["”", "”"] /* ”...” */
|
|
37
|
+
};
|
|
38
|
+
/* the active double quote style (set per document during rendering) */
|
|
39
|
+
let quotes = quoteStyles.en;
|
|
40
|
+
/* the private Markdown renderer instance, as the hooks below must not
|
|
41
|
+
leak into the "marked" singleton shared with the parser and with the
|
|
42
|
+
application embedding SpecBook as a library */
|
|
43
|
+
const marked = new Marked();
|
|
44
|
+
/* improve the typography of all rendered Markdown text (curly quotes,
|
|
45
|
+
"--" as the em dash, ellipsis); as smartypants requires unescaped text,
|
|
46
|
+
the extension switches off the text escaping of marked, so the stray
|
|
47
|
+
ampersands left behind have to be re-escaped afterwards; the English
|
|
48
|
+
double quotes of smartypants are remapped to the document language;
|
|
49
|
+
as marked runs later-registered pass-through hooks first, the
|
|
50
|
+
post-processing hook has to be registered before smartypants */
|
|
51
|
+
marked.use({ hooks: { postprocess: (html) => html.replace(/“/g, quotes[0]).replace(/”/g, quotes[1])
|
|
52
|
+
.replace(/&(?![a-zA-Z][a-zA-Z0-9]*;|#\d+;|#x[0-9a-fA-F]+;)/g, "&") } });
|
|
53
|
+
marked.use(markedSmartypants({ config: 1 }));
|
|
54
|
+
/* the switched-off text escaping of marked also leaves the angle
|
|
55
|
+
brackets of the inline text unescaped, which a browser then takes
|
|
56
|
+
for tags ("Map<String, Object>"), so they are escaped upfront in
|
|
57
|
+
the inline text tokenizer (registered after smartypants, as the
|
|
58
|
+
later-registered tokenizer takes precedence) */
|
|
59
|
+
marked.use({ tokenizer: { inlineText(src) {
|
|
60
|
+
const cap = this.rules.inline.text.exec(src);
|
|
61
|
+
if (cap === null)
|
|
62
|
+
return undefined;
|
|
63
|
+
return { type: "text", raw: cap[0], escaped: true,
|
|
64
|
+
text: cap[0].replace(/</g, "<").replace(/>/g, ">") };
|
|
65
|
+
} } });
|
|
66
|
+
/* the active per-document reference expander, fully-qualified
|
|
67
|
+
anchor paths, member-carrying property value constraints, object
|
|
68
|
+
schema nodes, pre-rendered diagram blocks, optimized embedded images,
|
|
69
|
+
dark theme judgements of the images, pre-rendered embedded diagrams,
|
|
70
|
+
code listing renderer, object nesting levels, reference coverages,
|
|
71
|
+
description popup keys of the schema nodes, description popup keys
|
|
72
|
+
of the objects, and omitted aspects (all set by "withDocument") */
|
|
73
|
+
let linker = null;
|
|
74
|
+
let anchors = null;
|
|
75
|
+
let members = null;
|
|
76
|
+
let schemas = null;
|
|
77
|
+
let diagrams = null;
|
|
78
|
+
let images = null;
|
|
79
|
+
let rasters = null;
|
|
80
|
+
let embedded = null;
|
|
81
|
+
let listings = null;
|
|
82
|
+
let levels = null;
|
|
83
|
+
let coverages = null;
|
|
84
|
+
let infoKeys = null;
|
|
85
|
+
let infoObjects = null;
|
|
86
|
+
let omits = null;
|
|
87
|
+
/* establish the per-document rendering state around the rendering of a
|
|
88
|
+
document and release it again afterwards (also on a rendering failure),
|
|
89
|
+
as it would otherwise retain the specification (and its embedded
|
|
90
|
+
images) until the next rendering */
|
|
91
|
+
export const withDocument = (state, body) => {
|
|
92
|
+
quotes = quoteStyles[state.lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
|
|
93
|
+
linker = state.linker;
|
|
94
|
+
anchors = state.anchors;
|
|
95
|
+
members = state.members;
|
|
96
|
+
schemas = state.schemas;
|
|
97
|
+
diagrams = state.diagrams;
|
|
98
|
+
images = state.images;
|
|
99
|
+
rasters = state.rasters;
|
|
100
|
+
embedded = state.embedded;
|
|
101
|
+
listings = state.listings;
|
|
102
|
+
levels = state.levels;
|
|
103
|
+
coverages = state.coverages;
|
|
104
|
+
infoKeys = state.infoKeys;
|
|
105
|
+
infoObjects = state.infoObjects;
|
|
106
|
+
omits = state.omits;
|
|
107
|
+
try {
|
|
108
|
+
return body();
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
linker = null;
|
|
112
|
+
anchors = null;
|
|
113
|
+
members = null;
|
|
114
|
+
schemas = null;
|
|
115
|
+
diagrams = null;
|
|
116
|
+
images = null;
|
|
117
|
+
rasters = null;
|
|
118
|
+
embedded = null;
|
|
119
|
+
listings = null;
|
|
120
|
+
levels = null;
|
|
121
|
+
coverages = null;
|
|
122
|
+
infoKeys = null;
|
|
123
|
+
infoObjects = null;
|
|
124
|
+
omits = null;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
/* the object whose texts are currently rendered, scoping the
|
|
128
|
+
resolution of the references inside them (nearest object wins),
|
|
129
|
+
established around every object rendering and restored afterwards */
|
|
130
|
+
let scope = null;
|
|
131
|
+
const scoped = (object, body) => {
|
|
132
|
+
const outer = scope;
|
|
133
|
+
scope = object;
|
|
134
|
+
try {
|
|
135
|
+
return body();
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
scope = outer;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
/* determine the fully-qualified anchor path of an object */
|
|
142
|
+
const anchorOf = (object) => anchors?.get(object) ?? object.id;
|
|
143
|
+
/* determine the schema description popup key of an object (the
|
|
144
|
+
INFO table index of its schema node) */
|
|
145
|
+
const infoKeyOf = (object) => {
|
|
146
|
+
const schema = schemas?.get(object);
|
|
147
|
+
return schema !== undefined ? infoKeys?.get(schema) : undefined;
|
|
148
|
+
};
|
|
149
|
+
/* determine the corpus description popup key of an object (its SPEC
|
|
150
|
+
table index), which a schema popup references for its title path,
|
|
151
|
+
too: "named = false" keeps the last path segment kind-only there,
|
|
152
|
+
as the kind popups (and the group headers of the compact tables)
|
|
153
|
+
describe the object class instead of the single instance */
|
|
154
|
+
const infoRefOf = (object, named = true) => {
|
|
155
|
+
const key = infoObjects?.get(object);
|
|
156
|
+
return key !== undefined ? key + (named ? "" : "^") : undefined;
|
|
157
|
+
};
|
|
158
|
+
/* render the schema description popup key and kind-ending title path
|
|
159
|
+
reference of an object as "data-info" attributes for the manually
|
|
160
|
+
assembled hyperlink markup (the kind popup of the full hyperlinks) */
|
|
161
|
+
const infoAttr = (object) => {
|
|
162
|
+
const key = infoKeyOf(object);
|
|
163
|
+
const ref = infoRefOf(object, false);
|
|
164
|
+
return key !== undefined && ref !== undefined ?
|
|
165
|
+
` data-info="${key}" data-info-path="${ref}"` : "";
|
|
166
|
+
};
|
|
167
|
+
/* render the corpus description popup key of an object as the
|
|
168
|
+
"data-info-spec" attribute for the manually assembled hyperlink
|
|
169
|
+
markup (the instance popup of the compact and full hyperlinks) */
|
|
170
|
+
const specAttr = (object) => {
|
|
171
|
+
const ref = infoRefOf(object);
|
|
172
|
+
return ref !== undefined ? ` data-info-spec="${ref}"` : "";
|
|
173
|
+
};
|
|
174
|
+
/* create the reference expander of a document: "[[xxx]]" references
|
|
175
|
+
expand into hyperlinks (an unresolvable or ambiguous reference stays
|
|
176
|
+
literal, marked as broken), resolved from the object currently
|
|
177
|
+
rendered and targeting the fully-qualified anchor paths of the
|
|
178
|
+
objects: in the full form (kind, name, and link symbol), or in the
|
|
179
|
+
compact form for prose (the object icon and the name only, via CSS
|
|
180
|
+
and markup, with the full form shown by the description popups) */
|
|
181
|
+
export const makeLinker = (index) => (text, compact) => expandReferences(text, (reference) => {
|
|
182
|
+
const target = resolveUnique(index, reference, scope ?? undefined).target;
|
|
183
|
+
if (target === undefined)
|
|
184
|
+
return `<span class="link-broken">[[${escapeHtml(reference)}]]</span>`;
|
|
185
|
+
/* the full form carries the popup attributes on its kind and
|
|
186
|
+
name, with the kind bold in the accent color and the name
|
|
187
|
+
bold in the regular text color (via the stylesheet) */
|
|
188
|
+
const full = `<strong class="object-kind"${infoAttr(target)}>${escapeHtml(target.kind)}:</strong>` +
|
|
189
|
+
` <span class="object-name"${specAttr(target)}>${target.name}</span>` +
|
|
190
|
+
" <span class=\"link-symbol\">⚭</span>";
|
|
191
|
+
return compact ?
|
|
192
|
+
`<a href="#${escapeHtml(anchorOf(target))}" class="link-compact"${specAttr(target)}>${target.name}</a>` :
|
|
193
|
+
`<a href="#${escapeHtml(anchorOf(target))}" class="link-full">${full}</a>`;
|
|
194
|
+
});
|
|
195
|
+
/* collect the description popup table of the schema nodes and their
|
|
196
|
+
keys (the table indices), with the description Markdown of the
|
|
197
|
+
objects and properties pre-rendered to HTML */
|
|
198
|
+
export const collectInfo = (nodes, keys, info) => {
|
|
199
|
+
for (const schema of nodes) {
|
|
200
|
+
keys.set(schema, String(info.length));
|
|
201
|
+
const entry = {};
|
|
202
|
+
if (schema.desc !== undefined)
|
|
203
|
+
entry.d = marked.parse(schema.desc, { async: false }).trim();
|
|
204
|
+
for (const property of schema.props ?? []) {
|
|
205
|
+
if (property.desc === undefined)
|
|
206
|
+
continue;
|
|
207
|
+
entry.p ??= {};
|
|
208
|
+
entry.p[property.name] = marked.parse(property.desc, { async: false }).trim();
|
|
209
|
+
}
|
|
210
|
+
info.push(entry);
|
|
211
|
+
collectInfo(schema.children ?? [], keys, info);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
/* expand the inline Markdown of a text (code spans, emphasis, etc.),
|
|
215
|
+
with Wiki-style references expanded upfront (in their compact form
|
|
216
|
+
for prose, i.e. descriptions, in their full form otherwise) */
|
|
217
|
+
const inline = (text, compact = false) => safe(marked.parseInline(linker !== null ? linker(text, compact) : text, { async: false }));
|
|
218
|
+
/* expand the full Markdown of a text, keeping its block-level
|
|
219
|
+
constructs (lists, quotes, code blocks, tables) intact */
|
|
220
|
+
const block = (text, compact = false) => safe(marked.parse(linker !== null ? linker(text, compact) : text, { async: false }));
|
|
221
|
+
/* check whether a text carries any block-level Markdown, i.e. is
|
|
222
|
+
anything other than the single paragraph a description usually is */
|
|
223
|
+
const isBlock = (text) => {
|
|
224
|
+
const tokens = marked.lexer(text).filter((token) => token.type !== "space");
|
|
225
|
+
return tokens.length > 0 && !(tokens.length === 1 && tokens[0].type === "paragraph");
|
|
226
|
+
};
|
|
227
|
+
/* render a single embedded image file (in its optimized form) onto an
|
|
228
|
+
<img> tag with a self-contained data: URL (converting the SVG text
|
|
229
|
+
into one, as an SVG inlined as-is would leak its document-global
|
|
230
|
+
<style> rules into all other inlined SVGs sharing the same class names),
|
|
231
|
+
where an image inverted on the dark theme (as given for a diagram or a
|
|
232
|
+
theme variant, else as the image itself says) is marked by a class */
|
|
233
|
+
const renderImage = (original, alt, dark = imageDark(original, rasters ?? undefined)) => {
|
|
234
|
+
const content = darkMark(images?.get(original) ?? original).content;
|
|
235
|
+
const url = content.startsWith("data:") ? content :
|
|
236
|
+
`data:image/svg+xml;base64,${Buffer.from(content, "utf8").toString("base64")}`;
|
|
237
|
+
return `<img src="${url}"${dark === "invert" ? " class=\"dark-invert\"" : ""} alt="${escapeHtml(alt)}"/>`;
|
|
238
|
+
};
|
|
239
|
+
/* wrap the theme variants of an image into their layout-neutral theme
|
|
240
|
+
containers, of which the stylesheet shows just the one matching the
|
|
241
|
+
color theme currently active in the document */
|
|
242
|
+
const renderThemed = (variants) => variants.map((variant, i) => `<span class="theme-${embeddingThemes[i]}-only">${variant}</span>`).join("");
|
|
243
|
+
/* render an embedded diagram (a fenced code block of a diagram language
|
|
244
|
+
or an embedded diagram source file) into a diagram block like the one
|
|
245
|
+
of a Gradia diagram (of the type "code" and carrying the nesting level
|
|
246
|
+
of the object rendered), so it folds and maximizes alike, with its
|
|
247
|
+
pre-rendered theme variants as images (as an SVG inlined as-is would
|
|
248
|
+
leak its document-global <style> rules); an omitted or failed diagram
|
|
249
|
+
leaves nothing behind */
|
|
250
|
+
const renderEmbedded = (language, source, alt) => {
|
|
251
|
+
const variants = embedded?.get(diagramKey(language, source));
|
|
252
|
+
const level = (scope !== null ? levels?.get(scope) : undefined) ?? 1;
|
|
253
|
+
if (variants === undefined || (omits !== null && omittedDiagram(language, level, omits)))
|
|
254
|
+
return "";
|
|
255
|
+
return `<div class="diagram" data-type="code" data-level="${level}">` +
|
|
256
|
+
renderThemed(embeddingThemes.map((theme) => renderImage(variants[theme], alt, "none"))) + "</div>";
|
|
257
|
+
};
|
|
258
|
+
/* render a code listing (a fenced code block of a known language or an
|
|
259
|
+
embedded source code file) into a diagram block (of the type "listing"
|
|
260
|
+
and carrying the nesting level of the object rendered), so it folds,
|
|
261
|
+
maximizes, and is omitted like a diagram */
|
|
262
|
+
const renderListingBlock = (renderer, listing) => {
|
|
263
|
+
const level = (scope !== null ? levels?.get(scope) : undefined) ?? 1;
|
|
264
|
+
if (omits !== null && omittedDiagram("listing", level, omits))
|
|
265
|
+
return "";
|
|
266
|
+
return `<div class="diagram" data-type="listing" data-level="${level}">${renderer(listing)}</div>`;
|
|
267
|
+
};
|
|
268
|
+
/* render a fenced code block of a diagram language as its embedded
|
|
269
|
+
diagram and one of a known language as its code listing instead of
|
|
270
|
+
as plain code (outside of an HTML rendering, too) */
|
|
271
|
+
marked.use({ renderer: { code(token) {
|
|
272
|
+
const language = fenceLanguage(token.lang);
|
|
273
|
+
if (language !== undefined)
|
|
274
|
+
return renderEmbedded(language, token.text, `${language} diagram`);
|
|
275
|
+
const listing = fencedListing(token);
|
|
276
|
+
return listing !== undefined && listings !== null ? renderListingBlock(listings, listing) : false;
|
|
277
|
+
} } });
|
|
278
|
+
/* strip the fenced code blocks of a diagram language or a code listing
|
|
279
|
+
off a text */
|
|
280
|
+
const stripDiagrams = (text) => marked.lexer(text)
|
|
281
|
+
.filter((token) => !(token.type === "code" && (fenceLanguage(token.lang) !== undefined
|
|
282
|
+
|| fencedListing(token) !== undefined)))
|
|
283
|
+
.map((token) => token.raw).join("");
|
|
284
|
+
/* render the embedded image files of a text into HTML, taking the image
|
|
285
|
+
alternate texts from the corresponding "" markups and
|
|
286
|
+
pairing up the consecutive theme variants of a "{theme}" markup, where
|
|
287
|
+
an embedded diagram source file renders as its diagram block, an
|
|
288
|
+
embedded source code file as its code listing block, and an
|
|
289
|
+
image or PDF page (unless unframed, like the title page logo) sits in a
|
|
290
|
+
diagram block of type "image", so it folds, maximizes, and is omitted
|
|
291
|
+
like a diagram (the empty entries of unreadable files, the failed PDF
|
|
292
|
+
pages, and the omitted diagrams and images are skipped) */
|
|
293
|
+
const renderEmbeddings = (text, embedding, framed = true) => {
|
|
294
|
+
const result = new Array();
|
|
295
|
+
const level = (scope !== null ? levels?.get(scope) : undefined) ?? 1;
|
|
296
|
+
const omitted = framed && omits !== null && omittedDiagram("image", level, omits);
|
|
297
|
+
const frame = (html) => framed ?
|
|
298
|
+
`<div class="diagram" data-type="image" data-level="${level}">${html}</div>` : html;
|
|
299
|
+
let i = 0;
|
|
300
|
+
for (const m of text.matchAll(embeddingRegex)) {
|
|
301
|
+
const count = embeddingCount(m[2]);
|
|
302
|
+
if (count === 0)
|
|
303
|
+
continue;
|
|
304
|
+
const contents = embedding.slice(i, i + count).filter((content) => content !== "");
|
|
305
|
+
i += count;
|
|
306
|
+
if (contents.some((content) => embeddedSource(content) !== undefined || embeddedListing(content) !== undefined))
|
|
307
|
+
result.push(...contents.map((content) => {
|
|
308
|
+
const diagram = embeddedSource(content);
|
|
309
|
+
const listing = embeddedListing(content);
|
|
310
|
+
return diagram !== undefined ? renderEmbedded(diagram.language, diagram.source, m[1].trim()) :
|
|
311
|
+
listing !== undefined && listings !== null ? renderListingBlock(listings, listing) : "";
|
|
312
|
+
}).filter((html) => html !== ""));
|
|
313
|
+
else if (!omitted) {
|
|
314
|
+
const variants = contents.filter((content) => !isDocument(content) || images?.has(content) === true)
|
|
315
|
+
.map((content) => renderImage(content, m[1].trim(), count > 1 ? "none" : undefined));
|
|
316
|
+
result.push(...(count > 1 && variants.length === count ? [renderThemed(variants)] : variants).map(frame));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return result;
|
|
320
|
+
};
|
|
321
|
+
/* the image embedding markup including the horizontal whitespace
|
|
322
|
+
before it, so that its removal leaves no double space behind */
|
|
323
|
+
const embeddingMarkup = new RegExp(`[ \\t]*${embeddingRegex.source}`, "g");
|
|
324
|
+
/* strip the markup of the file embeddings off a description text */
|
|
325
|
+
const stripEmbeddings = (text) => text.replace(embeddingMarkup, (markup, _alt, reference) => embeddingCount(reference) > 0 ? "" : markup).trim();
|
|
326
|
+
/* render a description into HTML, expanding its inline Markdown,
|
|
327
|
+
rendering its elaboration as blocks below the statement and the
|
|
328
|
+
rationale, and moving the file embeddings to the end of it all */
|
|
329
|
+
const renderDescription = (description) => {
|
|
330
|
+
const text = stripEmbeddings(description.description);
|
|
331
|
+
const elaboration = stripEmbeddings(description.elaboration ?? "");
|
|
332
|
+
const embeddings = renderEmbeddings(`${description.description}\n\n${description.elaboration ?? ""}`, description.embedding ?? []).map((content) => safe(content));
|
|
333
|
+
const blocked = isBlock(text);
|
|
334
|
+
return render("Description", { Description: {
|
|
335
|
+
block: blocked,
|
|
336
|
+
description: text !== "" ? (blocked ? block(text, true) : inline(text, true)) : "",
|
|
337
|
+
rationale: description.rationale !== undefined ?
|
|
338
|
+
inline(description.rationale, true) : undefined,
|
|
339
|
+
elaboration: elaboration !== "" ? block(elaboration, true) : undefined,
|
|
340
|
+
embeddings
|
|
341
|
+
} });
|
|
342
|
+
};
|
|
343
|
+
/* collect the description popups of the object instances: the corpus
|
|
344
|
+
description Markdown of every object, pre-rendered to HTML into
|
|
345
|
+
its SPEC table entry (the embedded images and diagrams and the
|
|
346
|
+
rationale are left out, as the popup shows the prose alone) */
|
|
347
|
+
export const collectSpec = (objects, spec) => {
|
|
348
|
+
for (const object of objects) {
|
|
349
|
+
const text = stripEmbeddings(stripDiagrams([object.description?.description ?? "", object.description?.elaboration ?? ""].join("\n\n")));
|
|
350
|
+
const key = infoObjects?.get(object);
|
|
351
|
+
if (key !== undefined && text !== "")
|
|
352
|
+
spec[Number(key)][4] = scoped(object, () => block(text, true).toString().trim());
|
|
353
|
+
collectSpec(object.children, spec);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
/* the literal members of an "enum(...)"/"tags(...)" expression, or
|
|
357
|
+
of the "enum(...)"/"tags(...)" alternatives of a "list(...)" one
|
|
358
|
+
(empty for every other expression) */
|
|
359
|
+
const literalMembers = (expr) => expr.kind === "enum" || expr.kind === "tags" ? expr.members :
|
|
360
|
+
expr.kind === "list" ? expr.alternatives.flatMap(literalMembers) : [];
|
|
361
|
+
/* collect the properties of the schema configuration constrained by
|
|
362
|
+
literal members (an "enum(...)", a "tags(...)", or a "list(...)"
|
|
363
|
+
with such alternatives), keyed by object kind and property name */
|
|
364
|
+
export const collectMembers = (nodes, result) => {
|
|
365
|
+
for (const schema of nodes) {
|
|
366
|
+
for (const property of schema.props ?? []) {
|
|
367
|
+
if (property.value === undefined)
|
|
368
|
+
continue;
|
|
369
|
+
try {
|
|
370
|
+
const expr = compileValueExpr(property.value);
|
|
371
|
+
if (literalMembers(expr).length > 0)
|
|
372
|
+
result.set(`${schema.kind} ${property.name}`, expr);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
/* an invalid expression is the concern of lint */
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
collectMembers(schema.children ?? [], result);
|
|
379
|
+
}
|
|
380
|
+
return result;
|
|
381
|
+
};
|
|
382
|
+
/* render a property value, badging the individual members of an
|
|
383
|
+
"enum(...)" (a single member) or "tags(...)" (a member set) value
|
|
384
|
+
and the literal member items of a "list(...)" value (the other items,
|
|
385
|
+
like references, stay prose), moving its image embeddings behind the
|
|
386
|
+
value, rendered from the embedded image contents (as for a
|
|
387
|
+
description), not from the markup; an absent property renders as the
|
|
388
|
+
marker telling it apart from a property given with an empty value */
|
|
389
|
+
const inlineValue = (kind, property) => {
|
|
390
|
+
if (property === undefined)
|
|
391
|
+
return safe("<span class=\"value-absent\"></span>");
|
|
392
|
+
const { key, value, embedding } = property;
|
|
393
|
+
const expr = members?.get(`${kind} ${key}`);
|
|
394
|
+
const text = stripEmbeddings(value);
|
|
395
|
+
const embeddings = renderEmbeddings(value, embedding ?? [])
|
|
396
|
+
.map((content) => `<div class="embedding">${content}</div>`).join("");
|
|
397
|
+
if (expr === undefined || text === "")
|
|
398
|
+
return safe(`${inline(text)}${embeddings}`);
|
|
399
|
+
const badge = (item) => `<span class="value-member">${inline(item)}</span>`;
|
|
400
|
+
if (expr.kind === "enum")
|
|
401
|
+
return safe(badge(text) + embeddings);
|
|
402
|
+
const items = splitItems(text).filter((item) => item !== "");
|
|
403
|
+
if (expr.kind === "tags")
|
|
404
|
+
return safe(items.map(badge).join(" ") + embeddings);
|
|
405
|
+
const literals = new Set(literalMembers(expr));
|
|
406
|
+
return safe(items.map((item) => literals.has(item) ? badge(item) : inline(item)).join(", ") + embeddings);
|
|
407
|
+
};
|
|
408
|
+
/* expand the inline Markdown of the property values, with the
|
|
409
|
+
entries injected for unused properties (foreign to the object)
|
|
410
|
+
rendered as absent */
|
|
411
|
+
const inlineProperties = (object, properties) => properties.map((property) => ({ key: property.key,
|
|
412
|
+
info: infoKeyOf(object), infopath: infoRefOf(object),
|
|
413
|
+
value: inlineValue(object.kind, object.properties.includes(property) ? property : undefined) }));
|
|
414
|
+
/* resolve the format configuration of the kind of an object */
|
|
415
|
+
const formatOf = (object) => schemas?.get(object)?.format;
|
|
416
|
+
/* determine the maximum table columns configured on the kind of an
|
|
417
|
+
object (at least two, as a single column cannot carry a name and a
|
|
418
|
+
value) */
|
|
419
|
+
const maxColumnsOf = (object) => Math.max(2, formatOf(object)?.maxTableColumns ?? 4);
|
|
420
|
+
/* determine the effective properties of an object in schema order
|
|
421
|
+
(unknown keys appended in document order), with "withUnusedProps"
|
|
422
|
+
injecting the defined but still unused schema properties as empty
|
|
423
|
+
key/value entries */
|
|
424
|
+
const effectiveProperties = (object) => {
|
|
425
|
+
const schema = schemas?.get(object);
|
|
426
|
+
if (schema === undefined)
|
|
427
|
+
return object.properties;
|
|
428
|
+
const unused = schema.format?.withUnusedProps === true;
|
|
429
|
+
const merged = (schema.props ?? []).flatMap((prop) => {
|
|
430
|
+
const present = object.properties.filter((property) => property.key === prop.name);
|
|
431
|
+
return present.length > 0 ? present : (unused ? [{ key: prop.name, value: "" }] : []);
|
|
432
|
+
});
|
|
433
|
+
return [...merged, ...object.properties.filter((property) => !merged.includes(property))];
|
|
434
|
+
};
|
|
435
|
+
/* determine the column shape of a potential compact table in schema
|
|
436
|
+
order (unknown keys appended in occurrence order), with
|
|
437
|
+
"withUnusedProps" injecting the defined but still unused schema
|
|
438
|
+
properties as additional columns */
|
|
439
|
+
const tableShape = (children) => {
|
|
440
|
+
const used = [...new Set(children.flatMap((child) => child.properties.map((property) => property.key)))];
|
|
441
|
+
const schema = schemas?.get(children[0]);
|
|
442
|
+
const names = (schema?.props ?? []).map((prop) => prop.name);
|
|
443
|
+
const known = schema?.format?.withUnusedProps === true ?
|
|
444
|
+
names : names.filter((name) => used.includes(name));
|
|
445
|
+
const keys = [...known, ...used.filter((key) => !names.includes(key))];
|
|
446
|
+
return { keys, desc: children.some((child) => child.description !== undefined || child.children.length > 0) };
|
|
447
|
+
};
|
|
448
|
+
/* provide the children of an object taking part in the regular document
|
|
449
|
+
flow: a nested title object leaves the flow, as it is rendered as the
|
|
450
|
+
title page instead (a top-level one instead removes its whole artifact) */
|
|
451
|
+
export const flowChildren = (object) => object.children.filter((child) => !isTitleObject(child));
|
|
452
|
+
/* decide whether a single-kind group of children collapses into the
|
|
453
|
+
concise or compact (tabular) rendering: an explicit "format" type of
|
|
454
|
+
the kind wins, "auto" collapses the deepest level only, and inside an
|
|
455
|
+
already concise rendering context "auto" groups implicitly stay concise, too */
|
|
456
|
+
export const conciseGroup = (group, schemaMap, concise) => {
|
|
457
|
+
const type = schemaMap?.get(group[0])?.format?.type ?? "auto";
|
|
458
|
+
if (type !== "auto")
|
|
459
|
+
return type !== "complex";
|
|
460
|
+
return concise || group.every((child) => child.children.length === 0);
|
|
461
|
+
};
|
|
462
|
+
/* group the children of an object by their kind, preserving order */
|
|
463
|
+
export const groupChildren = (children) => {
|
|
464
|
+
const groups = new Map();
|
|
465
|
+
for (const child of children) {
|
|
466
|
+
const group = groups.get(child.kind);
|
|
467
|
+
if (group === undefined)
|
|
468
|
+
groups.set(child.kind, [child]);
|
|
469
|
+
else
|
|
470
|
+
group.push(child);
|
|
471
|
+
}
|
|
472
|
+
return [...groups.values()];
|
|
473
|
+
};
|
|
474
|
+
/* render the children of an object group-wise by kind: a concise group
|
|
475
|
+
as one compact table (a "compact" one with a single content column),
|
|
476
|
+
a complex group as regular nested object renderings */
|
|
477
|
+
const renderChildren = (object, level, concise) => groupChildren(flowChildren(object)).map((group) => !conciseGroup(group, schemas, concise) ?
|
|
478
|
+
group.map((child) => renderObject(child, level, concise)).join("") :
|
|
479
|
+
formatOf(group[0])?.type === "compact" ?
|
|
480
|
+
renderCompact(group) :
|
|
481
|
+
renderTable(group, maxColumnsOf(group[0]))).join("");
|
|
482
|
+
/* render the description cell of a table row: the description of the
|
|
483
|
+
object followed by its recursively rendered children (implicitly or
|
|
484
|
+
explicitly concise groups as nested sub-tables, explicitly complex
|
|
485
|
+
ones as regular nested object renderings pressed into the cell);
|
|
486
|
+
a cell left without any content renders as the absent marker,
|
|
487
|
+
exactly like a property cell of a not given property */
|
|
488
|
+
const renderCell = (child) => {
|
|
489
|
+
let html = child.description !== undefined ? renderDescription(child.description) : "";
|
|
490
|
+
html += renderChildren(child, 6, true);
|
|
491
|
+
return html.trim() !== "" ? html : "<span class=\"value-absent\"></span>";
|
|
492
|
+
};
|
|
493
|
+
/* the pre-rendered diagram of an object as an embeddable block
|
|
494
|
+
(empty for an object without a configured or renderable diagram) */
|
|
495
|
+
const diagramOf = (object) => {
|
|
496
|
+
const diagram = diagrams?.get(object);
|
|
497
|
+
return diagram !== undefined ? safe(diagram) : "";
|
|
498
|
+
};
|
|
499
|
+
/* the reference coverage of an object as a table: one row per
|
|
500
|
+
configured pattern, labeled by the kinds of the matching objects,
|
|
501
|
+
marked as object kinds and carrying the description popup of the
|
|
502
|
+
first matching object of each kind (by the bare pattern, if none
|
|
503
|
+
matches), with the counts and the ratio as a bar (empty for an
|
|
504
|
+
object without a configured coverage) */
|
|
505
|
+
const coverageOf = (object) => {
|
|
506
|
+
const coverage = coverages?.get(object);
|
|
507
|
+
if (coverage === undefined)
|
|
508
|
+
return "";
|
|
509
|
+
const kindLabel = (entry, kind) => {
|
|
510
|
+
const first = [...entry.covered, ...entry.uncovered].find((target) => target.kind === kind);
|
|
511
|
+
return `<span class="object-kind"${first !== undefined ? infoAttr(first) : ""}>${escapeHtml(kind)}</span>`;
|
|
512
|
+
};
|
|
513
|
+
return safe(render("Coverage", { Coverage: coverage.map((entry, i) => ({
|
|
514
|
+
label: entry.kinds.length > 0 ?
|
|
515
|
+
safe(entry.kinds.map((kind) => kindLabel(entry, kind)).join(", ")) :
|
|
516
|
+
entry.pattern.replace(/^\[\[(.*)\]\]$/s, "$1"),
|
|
517
|
+
covered: entry.covered.length,
|
|
518
|
+
total: entry.covered.length + entry.uncovered.length,
|
|
519
|
+
ratio: coverageRatio(entry.covered.length, entry.covered.length + entry.uncovered.length),
|
|
520
|
+
even: i % 2 === 1
|
|
521
|
+
})) }));
|
|
522
|
+
};
|
|
523
|
+
/* render a single-kind group of children into one compact table:
|
|
524
|
+
the name first, then the property columns, then the description;
|
|
525
|
+
a group wider than maxColumns, or carrying diagrams, instead chunks
|
|
526
|
+
the property and description cells (and the leading diagram) of
|
|
527
|
+
every object into an embedded per-object table, as a diagram is
|
|
528
|
+
identifiable only under a header of its own, not under the column
|
|
529
|
+
headers of a plain table */
|
|
530
|
+
const renderTable = (children, maxColumns) => {
|
|
531
|
+
const { keys, desc } = tableShape(children);
|
|
532
|
+
const diagrammed = children.some((child) => diagrams?.has(child) === true);
|
|
533
|
+
const fold = formatOf(children[0])?.maxCellHeight;
|
|
534
|
+
if (!diagrammed && 1 + keys.length + (desc ? 1 : 0) <= maxColumns)
|
|
535
|
+
return render("Table", { Table: {
|
|
536
|
+
head: children[0].kind !== "" ? children[0].kind : "Name",
|
|
537
|
+
info: infoKeyOf(children[0]),
|
|
538
|
+
infopath: infoRefOf(children[0], false),
|
|
539
|
+
keys,
|
|
540
|
+
desc,
|
|
541
|
+
fold,
|
|
542
|
+
/* under the fixed table layout the name column takes a fixed
|
|
543
|
+
20% (via CSS) and the description column claims twice the
|
|
544
|
+
share of a property column of the remaining 80% */
|
|
545
|
+
width: Math.round(1600 / (keys.length + 2)) / 10,
|
|
546
|
+
rows: children.map((child, i) => scoped(child, () => {
|
|
547
|
+
const values = keys.map((key) => inlineValue(child.kind, child.properties.find((property) => property.key === key)));
|
|
548
|
+
const shares = desc ? [...keys.map(() => 1), 2] : keys.map(() => 1);
|
|
549
|
+
const cells = omitLong(omits, desc ? [...values, safe(renderCell(child))] : values, shares.map((share) => 0.8 * share / (keys.length + (desc ? 2 : 0))), fold);
|
|
550
|
+
return {
|
|
551
|
+
id: anchorOf(child),
|
|
552
|
+
anchor: child.anchor,
|
|
553
|
+
paren: child.paren,
|
|
554
|
+
primary: child.primary,
|
|
555
|
+
spec: infoRefOf(child),
|
|
556
|
+
name: inline(child.name),
|
|
557
|
+
even: i % 2 === 1,
|
|
558
|
+
values: cells.slice(0, keys.length),
|
|
559
|
+
description: cells[keys.length]
|
|
560
|
+
};
|
|
561
|
+
}))
|
|
562
|
+
} });
|
|
563
|
+
/* the embedded rows hold at most maxColumns - 1 cells (of the
|
|
564
|
+
group-wide union of property keys, plus the trailing description),
|
|
565
|
+
with the last cell spanning the leftover columns of the final row,
|
|
566
|
+
all of them sharing the 80% beside the fixed 20% name column */
|
|
567
|
+
const size = Math.max(1, maxColumns - 1);
|
|
568
|
+
return render("TableChunked", { Table: {
|
|
569
|
+
head: children[0].kind !== "" ? children[0].kind : "Name",
|
|
570
|
+
info: infoKeyOf(children[0]),
|
|
571
|
+
infopath: infoRefOf(children[0], false),
|
|
572
|
+
desc,
|
|
573
|
+
fold,
|
|
574
|
+
rows: children.map((child, i) => scoped(child, () => {
|
|
575
|
+
const cells = keys.map((key) => ({ key, desc: false, span: 1,
|
|
576
|
+
value: inlineValue(child.kind, child.properties.find((property) => property.key === key)) }));
|
|
577
|
+
if (desc)
|
|
578
|
+
cells.push({ key: "Description", desc: true, span: 1,
|
|
579
|
+
value: safe(renderCell(child)) });
|
|
580
|
+
const chunks = new Array();
|
|
581
|
+
for (let pos = 0; pos < cells.length; pos += size)
|
|
582
|
+
chunks.push(cells.slice(pos, pos + size));
|
|
583
|
+
if (chunks.length > 0) {
|
|
584
|
+
const last = chunks[chunks.length - 1];
|
|
585
|
+
last[last.length - 1].span = size - last.length + 1;
|
|
586
|
+
}
|
|
587
|
+
for (const chunk of chunks)
|
|
588
|
+
omitLong(omits, chunk.map((cell) => cell.value), chunk.map((cell) => 0.8 * cell.span / size), fold)
|
|
589
|
+
.forEach((value, k) => { chunk[k].value = value; });
|
|
590
|
+
/* the diagram leads the chunks as a full-width chunk of its
|
|
591
|
+
own, headed like the description */
|
|
592
|
+
const diagram = diagramOf(child);
|
|
593
|
+
if (diagram !== "")
|
|
594
|
+
chunks.unshift([{ key: "Diagram", desc: true, span: size, value: diagram }]);
|
|
595
|
+
return {
|
|
596
|
+
id: anchorOf(child),
|
|
597
|
+
anchor: child.anchor,
|
|
598
|
+
paren: child.paren,
|
|
599
|
+
primary: child.primary,
|
|
600
|
+
spec: infoRefOf(child),
|
|
601
|
+
name: inline(child.name),
|
|
602
|
+
even: i % 2 === 1,
|
|
603
|
+
chunks
|
|
604
|
+
};
|
|
605
|
+
}))
|
|
606
|
+
} });
|
|
607
|
+
};
|
|
608
|
+
/* render a single-kind group of children into one compact table of the
|
|
609
|
+
"compact" format: the name first, then a single cell rendering the
|
|
610
|
+
object like a complex one, but without its heading (diagram,
|
|
611
|
+
key/value properties, description, coverage, and children), for the
|
|
612
|
+
objects whose many properties a table row could not hold, the first
|
|
613
|
+
three led by sub-headers like the chunk headers of "TableChunked" */
|
|
614
|
+
const renderCompact = (children) => {
|
|
615
|
+
const { keys, desc } = tableShape(children);
|
|
616
|
+
const head = (label, html) => html.trim() !== "" ? `<div class="compact-head">${label}</div>${html}` : "";
|
|
617
|
+
return render("TableCompact", { Table: {
|
|
618
|
+
head: children[0].kind !== "" ? children[0].kind : "Name",
|
|
619
|
+
info: infoKeyOf(children[0]),
|
|
620
|
+
infopath: infoRefOf(children[0], false),
|
|
621
|
+
label: keys.length > 0 && desc ? "Properties & Description" :
|
|
622
|
+
(desc ? "Description" : "Properties"),
|
|
623
|
+
fold: formatOf(children[0])?.maxCellHeight,
|
|
624
|
+
rows: children.map((child, i) => scoped(child, () => {
|
|
625
|
+
const properties = effectiveProperties(child);
|
|
626
|
+
let html = head("Diagram", diagramOf(child).toString());
|
|
627
|
+
if (properties.length > 0)
|
|
628
|
+
html += head("Properties", render("Properties", { Properties: inlineProperties(child, properties),
|
|
629
|
+
Fold: formatOf(child)?.maxCellHeight }));
|
|
630
|
+
if (child.description !== undefined)
|
|
631
|
+
html += head("Description", renderDescription(child.description));
|
|
632
|
+
html += coverageOf(child).toString();
|
|
633
|
+
html += renderChildren(child, 6, true);
|
|
634
|
+
return {
|
|
635
|
+
id: anchorOf(child),
|
|
636
|
+
anchor: child.anchor,
|
|
637
|
+
paren: child.paren,
|
|
638
|
+
primary: child.primary,
|
|
639
|
+
spec: infoRefOf(child),
|
|
640
|
+
name: inline(child.name),
|
|
641
|
+
even: i % 2 === 1,
|
|
642
|
+
content: safe(html.trim() !== "" ? html : "<span class=\"value-absent\"></span>")
|
|
643
|
+
};
|
|
644
|
+
}))
|
|
645
|
+
} });
|
|
646
|
+
};
|
|
647
|
+
/* recursively render an object into HTML */
|
|
648
|
+
const renderObject = (object, level, concise) => {
|
|
649
|
+
const properties = effectiveProperties(object);
|
|
650
|
+
return scoped(object, () => render("Object", { Object: {
|
|
651
|
+
level: Math.min(level, 6),
|
|
652
|
+
kind: object.kind,
|
|
653
|
+
info: infoKeyOf(object),
|
|
654
|
+
infopath: infoRefOf(object, false),
|
|
655
|
+
spec: infoRefOf(object),
|
|
656
|
+
id: anchorOf(object),
|
|
657
|
+
anchor: object.anchor,
|
|
658
|
+
paren: object.paren,
|
|
659
|
+
primary: object.primary,
|
|
660
|
+
name: inline(object.name),
|
|
661
|
+
diagram: diagramOf(object),
|
|
662
|
+
properties: properties.length > 0 ?
|
|
663
|
+
safe(render("Properties", { Properties: inlineProperties(object, properties),
|
|
664
|
+
Fold: formatOf(object)?.maxCellHeight })) : "",
|
|
665
|
+
description: object.description !== undefined ?
|
|
666
|
+
safe(renderDescription(object.description)) : "",
|
|
667
|
+
coverage: coverageOf(object),
|
|
668
|
+
children: safe(renderChildren(object, level + 1, concise))
|
|
669
|
+
} }));
|
|
670
|
+
};
|
|
671
|
+
/* format a timestamp as its calendar date in local time (the
|
|
672
|
+
frontmatter timestamps are parsed in local time, too, so the UTC
|
|
673
|
+
date of toISOString() could shift the day) */
|
|
674
|
+
export const formatDate = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-` +
|
|
675
|
+
String(date.getDate()).padStart(2, "0");
|
|
676
|
+
/* render the title object into a title page */
|
|
677
|
+
export const renderTitlePage = (object, created, modified) => {
|
|
678
|
+
const prop = (name) => object.properties.find((property) => property.key === name)?.value;
|
|
679
|
+
const inlineProp = (name) => {
|
|
680
|
+
const value = prop(name);
|
|
681
|
+
return value !== undefined && value.trim() !== "" ? inline(value) : "";
|
|
682
|
+
};
|
|
683
|
+
const rest = object.properties.filter((property) => !["LOGO", "TITLE", "SUBTITLE", "AUTHOR", "VERSION",
|
|
684
|
+
"LANG", "CHARSET", "PAPER-SIZE", "THEME-STYLE", "THEME-TONE"].includes(property.key));
|
|
685
|
+
/* the logo is rendered above the title, from the embedded image content
|
|
686
|
+
of the LOGO property or, for a non-embeddable reference, as its inline
|
|
687
|
+
Markdown; without a (non-empty) LOGO property the built-in SpecBook
|
|
688
|
+
logo is used, in both its theme variants */
|
|
689
|
+
const logo = object.properties.find((property) => property.key === "LOGO" && property.value.trim() !== "");
|
|
690
|
+
const image = logo !== undefined ? renderEmbeddings(logo.value, logo.embedding ?? [], false) : [];
|
|
691
|
+
return scoped(object, () => render("TitlePage", { TitlePage: {
|
|
692
|
+
logo: logo === undefined ?
|
|
693
|
+
safe(renderThemed(embeddingThemes.map((theme) => `<img src="${fallbackLogo(theme)}" alt="SpecBook"/>`))) :
|
|
694
|
+
(image.length > 0 ? safe(image.join("")) : inline(logo.value)),
|
|
695
|
+
title: inline(prop("TITLE") ?? object.name),
|
|
696
|
+
subtitle: inlineProp("SUBTITLE"),
|
|
697
|
+
author: inlineProp("AUTHOR"),
|
|
698
|
+
version: inlineProp("VERSION"),
|
|
699
|
+
description: object.description !== undefined ?
|
|
700
|
+
safe(renderDescription(object.description)) : "",
|
|
701
|
+
properties: rest.length > 0 ?
|
|
702
|
+
safe(render("Properties", { Properties: inlineProperties(object, rest) })) : "",
|
|
703
|
+
created, modified
|
|
704
|
+
} }));
|
|
705
|
+
};
|
|
706
|
+
/* render an artifact into HTML */
|
|
707
|
+
export const renderArtifact = (artifact) => render("Artifact", { Artifact: {
|
|
708
|
+
objects: safe(artifact.objects.map((object) => renderObject(object, 1, false)).join(""))
|
|
709
|
+
} });
|
|
710
|
+
/* provide the children of an object rendered with headings of their own
|
|
711
|
+
(skipping the child groups collapsing into compact tables), which
|
|
712
|
+
are the ones taking part in the table of contents and its side panel */
|
|
713
|
+
const headingChildren = (object) => groupChildren(flowChildren(object))
|
|
714
|
+
.filter((group) => !conciseGroup(group, schemas, false))
|
|
715
|
+
.flat();
|
|
716
|
+
/* flatten the hierarchy of the rendered object headings (exactly like
|
|
717
|
+
the PDF outline) into the entries of the table of contents, each
|
|
718
|
+
carrying its nesting level and its optional page number */
|
|
719
|
+
export const tocEntries = (objects, pages) => {
|
|
720
|
+
const entries = [];
|
|
721
|
+
const collect = (objects, level) => {
|
|
722
|
+
for (const object of objects) {
|
|
723
|
+
const id = anchorOf(object);
|
|
724
|
+
entries.push({ id, kind: object.kind,
|
|
725
|
+
info: infoKeyOf(object), infopath: infoRefOf(object, false), spec: infoRefOf(object),
|
|
726
|
+
name: inline(object.name), level: Math.min(level, 6), page: pages?.get(id) });
|
|
727
|
+
collect(headingChildren(object), level + 1);
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
collect(objects, 1);
|
|
731
|
+
return entries;
|
|
732
|
+
};
|
|
733
|
+
/* render the side panel of the table of contents (its behavior is
|
|
734
|
+
driven by its client-side script): the unnumbered entries of
|
|
735
|
+
the front matter (title page, table and diagram of contents, as far
|
|
736
|
+
as present) are followed by the hierarchical entries of the rendered
|
|
737
|
+
object headings, exactly like the table of contents itself */
|
|
738
|
+
export const renderTocPanel = (objects, title, doc) => {
|
|
739
|
+
const entries = (objects) => objects.length === 0 ? "" : render("TocPanelEntries", { Entries: objects.map((object) => ({
|
|
740
|
+
id: anchorOf(object),
|
|
741
|
+
kind: object.kind,
|
|
742
|
+
info: infoKeyOf(object),
|
|
743
|
+
infopath: infoRefOf(object, false),
|
|
744
|
+
spec: infoRefOf(object),
|
|
745
|
+
name: inline(object.name),
|
|
746
|
+
children: safe(entries(headingChildren(object)))
|
|
747
|
+
})) });
|
|
748
|
+
return render("TocPanel", { TocPanel: { title, doc, entries: safe(entries(objects)) } });
|
|
749
|
+
};
|