@rse/specbook 0.9.6 → 0.9.8
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 +65 -10
- package/dst/specbook-api.js +19 -18
- package/dst/specbook-cli.js +9 -6
- package/dst/specbook-cmd-describe.js +1 -3
- package/dst/specbook-cmd-export.js +24 -17
- package/dst/specbook-cmd-init.d.ts +1 -1
- package/dst/specbook-cmd-init.js +5 -4
- package/dst/specbook-cmd-lint.d.ts +0 -1
- package/dst/specbook-cmd-lint.js +29 -5
- package/dst/specbook-config.d.ts +1 -1
- package/dst/specbook-config.js +90 -71
- package/dst/specbook-diagnostic.js +4 -1
- package/dst/specbook-diagram.d.ts +8 -3
- package/dst/specbook-diagram.js +101 -105
- package/dst/specbook-export-ast.d.ts +1 -1
- package/dst/specbook-export-ast.js +1 -1
- package/dst/specbook-export-common.d.ts +2 -1
- package/dst/specbook-export-common.js +12 -14
- package/dst/specbook-export-html-search.js +10 -14
- package/dst/specbook-export-html.css +24 -29
- package/dst/specbook-export-html.js +101 -83
- package/dst/specbook-export-md.d.ts +1 -1
- package/dst/specbook-export-md.js +56 -23
- package/dst/specbook-export-pdf.d.ts +4 -2
- package/dst/specbook-export-pdf.js +48 -38
- package/dst/specbook-format-schema.js +6 -3
- package/dst/specbook-format.md +47 -20
- package/dst/specbook-link.d.ts +1 -0
- package/dst/specbook-link.js +7 -4
- package/dst/specbook-mcp.js +17 -10
- package/dst/specbook-parse-common.d.ts +2 -1
- package/dst/specbook-parse-common.js +2 -0
- package/dst/specbook-parse-semantic.d.ts +4 -2
- package/dst/specbook-parse-semantic.js +114 -43
- package/dst/specbook-parse-syntax.js +179 -137
- package/dst/specbook-parse-value.d.ts +1 -1
- package/dst/specbook-parse-value.js +9 -5
- package/dst/specbook-parse.d.ts +2 -5
- package/dst/specbook-parse.js +27 -34
- package/dst/specbook-verbose.js +2 -1
- package/package.json +1 -1
|
@@ -9,10 +9,11 @@ import nunjucks from "nunjucks";
|
|
|
9
9
|
import nunjucksAddons from "@rse/nunjucks-addons";
|
|
10
10
|
import textframe from "textframe";
|
|
11
11
|
import { Gradia } from "@rse/gradia";
|
|
12
|
-
import { buildLinkIndex, resolveUnique, expandReferences, anchorPaths } from "./specbook-link.js";
|
|
12
|
+
import { buildLinkIndex, resolveUnique, expandReferences, anchorPaths, plainText } from "./specbook-link.js";
|
|
13
13
|
import { compileValueExpr, splitItems } from "./specbook-parse-value.js";
|
|
14
14
|
import { embeddingRegex, embeddingMimeType, embeddingThemes, embeddingVariants } from "./specbook-parse-common.js";
|
|
15
15
|
import { escapeHtml, stylesheet, searchScript, fallbackLogo, isTitleObject, titleObject, documentTitle, documentLang, documentThemeStyle } from "./specbook-export-common.js";
|
|
16
|
+
import { collectSchemas } from "./specbook-parse-semantic.js";
|
|
16
17
|
import { specDiagrams } from "./specbook-diagram.js";
|
|
17
18
|
/* ==== Templates ==== */
|
|
18
19
|
/* the built-in Nunjucks templates for the HTML export */
|
|
@@ -117,7 +118,7 @@ const templates = {
|
|
|
117
118
|
{%- if Description.block %}
|
|
118
119
|
<div class="description">{{ Description.description }}{% if Description.rationale %}
|
|
119
120
|
<p class="rationale">— <span class="keyword">BECAUSE</span> {{ Description.rationale }}</p>{% endif %}</div>
|
|
120
|
-
{%-
|
|
121
|
+
{%- elif Description.description or Description.rationale %}
|
|
121
122
|
<p class="description">{{ Description.description }}{% if Description.rationale %}
|
|
122
123
|
<span class="rationale">— <span class="keyword">BECAUSE</span> {{ Description.rationale }}</span>{% endif %}</p>
|
|
123
124
|
{%- endif %}
|
|
@@ -209,6 +210,18 @@ const marked = new Marked();
|
|
|
209
210
|
marked.use({ hooks: { postprocess: (html) => html.replace(/“/g, quotes[0]).replace(/”/g, quotes[1])
|
|
210
211
|
.replace(/&(?![a-zA-Z][a-zA-Z0-9]*;|#\d+;|#x[0-9a-fA-F]+;)/g, "&") } });
|
|
211
212
|
marked.use(markedSmartypants({ config: 1 }));
|
|
213
|
+
/* the switched-off text escaping of marked also leaves the angle
|
|
214
|
+
brackets of the inline text unescaped, which a browser then takes
|
|
215
|
+
for tags ("Map<String, Object>"), so they are escaped upfront in
|
|
216
|
+
the inline text tokenizer (registered after smartypants, as the
|
|
217
|
+
later-registered tokenizer takes precedence) */
|
|
218
|
+
marked.use({ tokenizer: { inlineText(src) {
|
|
219
|
+
const cap = this.rules.inline.text.exec(src);
|
|
220
|
+
if (cap === null)
|
|
221
|
+
return undefined;
|
|
222
|
+
return { type: "text", raw: cap[0], escaped: true,
|
|
223
|
+
text: cap[0].replace(/</g, "<").replace(/>/g, ">") };
|
|
224
|
+
} } });
|
|
212
225
|
/* the Nunjucks environment with the @rse/nunjucks-addons extensions */
|
|
213
226
|
const env = new nunjucks.Environment(null, { autoescape: true });
|
|
214
227
|
nunjucksAddons(env);
|
|
@@ -231,28 +244,33 @@ const diagramCache = new WeakMap();
|
|
|
231
244
|
const anchorOf = (object) => anchors?.get(object) ?? object.id;
|
|
232
245
|
/* expand the inline Markdown of a text (code spans, emphasis, etc.),
|
|
233
246
|
with Wiki-style references expanded upfront */
|
|
234
|
-
const inline = (text) => safe(marked.parseInline(linker !== null ? linker(text) : text));
|
|
247
|
+
const inline = (text) => safe(marked.parseInline(linker !== null ? linker(text) : text, { async: false }));
|
|
235
248
|
/* expand the full Markdown of a text, keeping its block-level
|
|
236
249
|
constructs (lists, quotes, code blocks, tables) intact */
|
|
237
|
-
const block = (text) => safe(marked.parse(linker !== null ? linker(text) : text));
|
|
250
|
+
const block = (text) => safe(marked.parse(linker !== null ? linker(text) : text, { async: false }));
|
|
238
251
|
/* check whether a text carries any block-level Markdown, i.e. is
|
|
239
252
|
anything else than the single paragraph a description usually is */
|
|
240
253
|
const isBlock = (text) => {
|
|
241
254
|
const tokens = marked.lexer(text).filter((token) => token.type !== "space");
|
|
242
255
|
return tokens.length > 0 && !(tokens.length === 1 && tokens[0].type === "paragraph");
|
|
243
256
|
};
|
|
244
|
-
/* render a single embedded image file
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
257
|
+
/* render a single embedded image file onto an <img> tag with a
|
|
258
|
+
self-contained data: URL (converting the SVG text into one, as an
|
|
259
|
+
SVG inlined as-is would leak its document-global <style> rules
|
|
260
|
+
into all other inlined SVGs sharing the same class names) */
|
|
261
|
+
const renderImage = (content, alt) => {
|
|
262
|
+
const url = content.startsWith("data:") ? content :
|
|
263
|
+
`data:image/svg+xml;base64,${Buffer.from(content, "utf8").toString("base64")}`;
|
|
264
|
+
return `<img src="${url}" alt="${escapeHtml(alt)}"/>`;
|
|
265
|
+
};
|
|
249
266
|
/* wrap the theme variants of an image into their layout-neutral theme
|
|
250
267
|
containers, of which the stylesheet shows just the one matching the
|
|
251
268
|
color theme currently active in the document */
|
|
252
269
|
const renderThemed = (images) => images.map((image, i) => `<span class="theme-${embeddingThemes[i]}-only">${image}</span>`).join("");
|
|
253
270
|
/* render the embedded image files of a text into HTML, taking the image
|
|
254
271
|
alternate texts from the corresponding "" markups and
|
|
255
|
-
pairing up the consecutive theme variants of a "{theme}" markup
|
|
272
|
+
pairing up the consecutive theme variants of a "{theme}" markup
|
|
273
|
+
(the empty entries of unreadable files are skipped) */
|
|
256
274
|
const renderEmbeddings = (text, embedding) => {
|
|
257
275
|
const result = new Array();
|
|
258
276
|
let i = 0;
|
|
@@ -261,7 +279,8 @@ const renderEmbeddings = (text, embedding) => {
|
|
|
261
279
|
if (embeddingMimeType(reference) === undefined)
|
|
262
280
|
continue;
|
|
263
281
|
const variants = embeddingVariants(reference);
|
|
264
|
-
const
|
|
282
|
+
const contents = embedding.slice(i, i + variants.length);
|
|
283
|
+
const images = contents.filter((content) => content !== "")
|
|
265
284
|
.map((content) => renderImage(content, m[1].trim()));
|
|
266
285
|
i += variants.length;
|
|
267
286
|
if (variants.length > 1 && images.length === variants.length)
|
|
@@ -271,17 +290,20 @@ const renderEmbeddings = (text, embedding) => {
|
|
|
271
290
|
}
|
|
272
291
|
return result;
|
|
273
292
|
};
|
|
293
|
+
/* the image embedding markup including the horizontal whitespace
|
|
294
|
+
before it, so that its removal leaves no double space behind */
|
|
295
|
+
const embeddingMarkup = new RegExp(`[ \\t]*${embeddingRegex.source}`, "g");
|
|
274
296
|
/* render a description into HTML, expanding its inline Markdown and
|
|
275
297
|
moving the file embeddings to the end of the description */
|
|
276
298
|
const renderDescription = (description) => {
|
|
277
299
|
const text = description.description
|
|
278
|
-
.replace(
|
|
279
|
-
.
|
|
300
|
+
.replace(embeddingMarkup, (markup, _alt, reference) => embeddingMimeType(reference.trim()) !== undefined ? "" : markup)
|
|
301
|
+
.trim();
|
|
280
302
|
const embeddings = renderEmbeddings(description.description, description.embedding ?? []).map((content) => safe(content));
|
|
281
303
|
const blocked = isBlock(text);
|
|
282
304
|
return render("Description", { Description: {
|
|
283
305
|
block: blocked,
|
|
284
|
-
description: blocked ? block(text) : inline(text),
|
|
306
|
+
description: text !== "" ? (blocked ? block(text) : inline(text)) : "",
|
|
285
307
|
rationale: description.rationale !== undefined ?
|
|
286
308
|
inline(description.rationale) : undefined,
|
|
287
309
|
embeddings
|
|
@@ -289,8 +311,8 @@ const renderDescription = (description) => {
|
|
|
289
311
|
};
|
|
290
312
|
/* collect the "enum(...)"/"tags(...)" constrained properties of the
|
|
291
313
|
schema configuration, keyed by object kind and property name */
|
|
292
|
-
const collectMembers = (
|
|
293
|
-
for (const schema of
|
|
314
|
+
const collectMembers = (nodes, result) => {
|
|
315
|
+
for (const schema of nodes) {
|
|
294
316
|
for (const property of schema.props ?? []) {
|
|
295
317
|
if (property.value === undefined)
|
|
296
318
|
continue;
|
|
@@ -307,41 +329,26 @@ const collectMembers = (schemas, result) => {
|
|
|
307
329
|
}
|
|
308
330
|
return result;
|
|
309
331
|
};
|
|
310
|
-
/* map the specification objects onto their schema configuration
|
|
311
|
-
nodes (the schema resolution mirrors the semantic validation) */
|
|
312
|
-
const collectSchemas = (specification, config) => {
|
|
313
|
-
const result = new Map();
|
|
314
|
-
const walk = (object, schema) => {
|
|
315
|
-
result.set(object, schema);
|
|
316
|
-
for (const child of object.childs) {
|
|
317
|
-
const childSchema = (schema.childs ?? []).find((c) => c.kind === child.kind);
|
|
318
|
-
if (childSchema !== undefined)
|
|
319
|
-
walk(child, childSchema);
|
|
320
|
-
}
|
|
321
|
-
};
|
|
322
|
-
for (const artifact of specification.artifacts) {
|
|
323
|
-
for (const object of artifact.objects) {
|
|
324
|
-
const schema = config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
|
|
325
|
-
config.find((s) => (s.name ?? "").toUpperCase() ===
|
|
326
|
-
object.name.replace(/`/g, "").toUpperCase());
|
|
327
|
-
if (schema !== undefined)
|
|
328
|
-
walk(object, schema);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
return result;
|
|
332
|
-
};
|
|
333
332
|
/* render a property value, badging the individual members of an
|
|
334
|
-
"enum(...)" (a single member) or "tags(...)" (a member set) value
|
|
335
|
-
|
|
333
|
+
"enum(...)" (a single member) or "tags(...)" (a member set) value
|
|
334
|
+
and moving its image embeddings behind the value, rendered from the
|
|
335
|
+
embedded image contents (as for a description), not from the markup */
|
|
336
|
+
const inlineValue = (kind, { key, value, embedding }) => {
|
|
336
337
|
const member = members?.get(`${kind}${key}`);
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
338
|
+
const text = value
|
|
339
|
+
.replace(embeddingMarkup, (markup, _alt, reference) => embeddingMimeType(reference.trim()) !== undefined ? "" : markup)
|
|
340
|
+
.trim();
|
|
341
|
+
const embeddings = renderEmbeddings(value, embedding ?? [])
|
|
342
|
+
.map((content) => `<div class="embedding">${content}</div>`).join("");
|
|
343
|
+
if (member === undefined || text === "")
|
|
344
|
+
return safe(`${inline(text)}${embeddings}`);
|
|
345
|
+
const items = member === "tags" ?
|
|
346
|
+
splitItems(text).filter((item) => item !== "") : [text];
|
|
347
|
+
return safe(items.map((item) => `<span class="value-member">${inline(item)}</span>`).join(" ") + embeddings);
|
|
341
348
|
};
|
|
342
349
|
/* expand the inline Markdown of the property values */
|
|
343
350
|
const inlineProperties = (kind, properties) => properties.map((property) => ({ key: property.key,
|
|
344
|
-
value: inlineValue(kind, property
|
|
351
|
+
value: inlineValue(kind, property) }));
|
|
345
352
|
/* resolve the format configuration of an object */
|
|
346
353
|
const formatOf = (object) => schemas?.get(object)?.format;
|
|
347
354
|
/* determine the maximum table columns configured on an object (at
|
|
@@ -364,18 +371,11 @@ const effectiveProperties = (object) => {
|
|
|
364
371
|
"withUnusedProps" injecting the defined but still unused schema
|
|
365
372
|
properties (in schema order) as additional columns */
|
|
366
373
|
const tableShape = (childs) => {
|
|
367
|
-
let keys = new
|
|
368
|
-
for (const child of childs)
|
|
369
|
-
for (const property of child.properties)
|
|
370
|
-
if (!keys.includes(property.key))
|
|
371
|
-
keys.push(property.key);
|
|
374
|
+
let keys = [...new Set(childs.flatMap((child) => child.properties.map((property) => property.key)))];
|
|
372
375
|
const schema = schemas?.get(childs[0]);
|
|
373
376
|
if (schema?.format?.withUnusedProps === true) {
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
return present.length > 0 ? present : [prop.name];
|
|
377
|
-
});
|
|
378
|
-
keys = [...merged, ...keys.filter((key) => !merged.includes(key))];
|
|
377
|
+
const names = (schema.props ?? []).map((prop) => prop.name);
|
|
378
|
+
keys = [...names, ...keys.filter((key) => !names.includes(key))];
|
|
379
379
|
}
|
|
380
380
|
return { keys, desc: childs.some((child) => child.description !== undefined || child.childs.length > 0) };
|
|
381
381
|
};
|
|
@@ -441,8 +441,8 @@ const renderTable = (childs, maxColumns) => {
|
|
|
441
441
|
primary: child.primary,
|
|
442
442
|
name: inline(child.name),
|
|
443
443
|
values: keys.map((key) => {
|
|
444
|
-
const
|
|
445
|
-
return
|
|
444
|
+
const property = child.properties.find((property) => property.key === key);
|
|
445
|
+
return property !== undefined ? inlineValue(child.kind, property) : "";
|
|
446
446
|
}),
|
|
447
447
|
description: safe(renderCell(child))
|
|
448
448
|
}))
|
|
@@ -456,9 +456,9 @@ const renderTable = (childs, maxColumns) => {
|
|
|
456
456
|
width: Math.round(100 / maxColumns),
|
|
457
457
|
rows: childs.map((child) => {
|
|
458
458
|
const cells = keys.map((key) => {
|
|
459
|
-
const
|
|
459
|
+
const property = child.properties.find((property) => property.key === key);
|
|
460
460
|
return { key, desc: false, span: 1,
|
|
461
|
-
value:
|
|
461
|
+
value: property !== undefined ? inlineValue(child.kind, property) : "" };
|
|
462
462
|
});
|
|
463
463
|
if (desc)
|
|
464
464
|
cells.push({ key: "Description", desc: true, span: 1,
|
|
@@ -481,6 +481,7 @@ const renderTable = (childs, maxColumns) => {
|
|
|
481
481
|
/* recursively render an object into HTML */
|
|
482
482
|
const renderObject = (object, level, concise) => {
|
|
483
483
|
const properties = effectiveProperties(object);
|
|
484
|
+
const diagram = diagrams?.get(object);
|
|
484
485
|
return render("Object", { Object: {
|
|
485
486
|
level: Math.min(level, 6),
|
|
486
487
|
kind: object.kind,
|
|
@@ -488,8 +489,7 @@ const renderObject = (object, level, concise) => {
|
|
|
488
489
|
paren: object.paren,
|
|
489
490
|
primary: object.primary,
|
|
490
491
|
name: inline(object.name),
|
|
491
|
-
diagram:
|
|
492
|
-
safe(`<div class="diagram">${diagrams.get(object) ?? ""}</div>`) : "",
|
|
492
|
+
diagram: diagram !== undefined ? safe(`<div class="diagram">${diagram}</div>`) : "",
|
|
493
493
|
properties: properties.length > 0 ?
|
|
494
494
|
safe(render("Properties", { Properties: inlineProperties(object.kind, properties) })) : "",
|
|
495
495
|
description: object.description !== undefined ?
|
|
@@ -499,6 +499,11 @@ const renderObject = (object, level, concise) => {
|
|
|
499
499
|
safe(flowChilds(object).map((child) => renderObject(child, level + 1, concise)).join(""))
|
|
500
500
|
} });
|
|
501
501
|
};
|
|
502
|
+
/* format a timestamp as its calendar date in local time (the
|
|
503
|
+
frontmatter timestamps are parsed in local time, too, so the UTC
|
|
504
|
+
date of toISOString() could shift the day) */
|
|
505
|
+
const formatDate = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-` +
|
|
506
|
+
String(date.getDate()).padStart(2, "0");
|
|
502
507
|
/* render the title object into a title page */
|
|
503
508
|
const renderTitlePage = (object, created, modified) => {
|
|
504
509
|
const prop = (name) => object.properties.find((property) => property.key === name)?.value;
|
|
@@ -539,7 +544,7 @@ export const htmlOutline = (specification, config) => {
|
|
|
539
544
|
const paths = anchorPaths(buildLinkIndex(specification));
|
|
540
545
|
const schemaMap = config !== undefined ? collectSchemas(specification, config) : null;
|
|
541
546
|
const entry = (object) => ({
|
|
542
|
-
title: (object.kind !== "" ? `${object.kind}: ` : "") + object.name,
|
|
547
|
+
title: (object.kind !== "" ? `${object.kind}: ` : "") + plainText(object.name),
|
|
543
548
|
anchor: paths.get(object) ?? object.id,
|
|
544
549
|
childs: conciseChilds(object, schemaMap, false) ? [] : flowChilds(object).map(entry)
|
|
545
550
|
});
|
|
@@ -570,23 +575,23 @@ export const renderHtml = async (specification, config, tocPages, css) => {
|
|
|
570
575
|
canvas and would dwarf the document text at 1:1 */
|
|
571
576
|
const scale = 0.75;
|
|
572
577
|
const cached = diagramCache.get(specification);
|
|
573
|
-
|
|
578
|
+
let rendered = null;
|
|
574
579
|
if (cached !== undefined && cached.config === config)
|
|
575
|
-
|
|
580
|
+
rendered = cached.diagrams;
|
|
576
581
|
else if (config !== undefined) {
|
|
577
|
-
|
|
582
|
+
rendered = new Map();
|
|
578
583
|
for (const [object, result] of specDiagrams(specification, config)) {
|
|
579
584
|
if (result.spec === undefined)
|
|
580
585
|
continue;
|
|
581
586
|
try {
|
|
582
|
-
const svg = await Gradia.render(result.spec, { format: "svg:embedded" });
|
|
583
|
-
|
|
587
|
+
const svg = await Gradia.render(result.spec, { format: "svg:embedded", config: result.config });
|
|
588
|
+
rendered.set(object, svg.replace(/(<svg[^>]*) width="([0-9.]+)" height="([0-9.]+)"/, (_, head, w, h) => `${head} width="${Number(w) * scale}" height="${Number(h) * scale}"`));
|
|
584
589
|
}
|
|
585
590
|
catch {
|
|
586
591
|
/* intentionally omitted */
|
|
587
592
|
}
|
|
588
593
|
}
|
|
589
|
-
diagramCache.set(specification, { config, diagrams });
|
|
594
|
+
diagramCache.set(specification, { config, diagrams: rendered });
|
|
590
595
|
}
|
|
591
596
|
/* the document language selects the smart typography quote style */
|
|
592
597
|
const lang = documentLang(specification);
|
|
@@ -598,6 +603,7 @@ export const renderHtml = async (specification, config, tocPages, css) => {
|
|
|
598
603
|
anchors = anchorPaths(index);
|
|
599
604
|
members = config !== undefined ? collectMembers(config, new Map()) : null;
|
|
600
605
|
schemas = config !== undefined ? collectSchemas(specification, config) : null;
|
|
606
|
+
diagrams = rendered;
|
|
601
607
|
linker = (text) => expandReferences(text, (reference) => {
|
|
602
608
|
const target = resolveUnique(index, reference).target;
|
|
603
609
|
if (target === undefined)
|
|
@@ -612,18 +618,30 @@ export const renderHtml = async (specification, config, tocPages, css) => {
|
|
|
612
618
|
const title = titlePageObject(specification);
|
|
613
619
|
const artifacts = specification.artifacts
|
|
614
620
|
.filter((artifact) => !artifact.objects.some(isTitleObject));
|
|
615
|
-
|
|
616
|
-
.
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
621
|
+
try {
|
|
622
|
+
const entries = artifacts.flatMap((artifact) => artifact.objects)
|
|
623
|
+
.map((object) => ({ id: anchorOf(object), kind: object.kind, name: inline(object.name),
|
|
624
|
+
page: tocPages?.get(anchorOf(object)) }));
|
|
625
|
+
return render("Document", { Document: {
|
|
626
|
+
title: documentTitle(specification).title,
|
|
627
|
+
lang,
|
|
628
|
+
theme: documentThemeStyle(specification)?.toLowerCase(),
|
|
629
|
+
css: safe(css ?? stylesheet()),
|
|
630
|
+
titlepage: title !== undefined ?
|
|
631
|
+
safe(renderTitlePage(title, formatDate(created), formatDate(modified))) : "",
|
|
632
|
+
search: title !== undefined ? safe(searchScript()) : "",
|
|
633
|
+
toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
|
|
634
|
+
artifacts: safe(artifacts.map((artifact) => renderArtifact(artifact)).join(""))
|
|
635
|
+
} });
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
/* release the per-document state (also on a rendering failure),
|
|
639
|
+
as it would otherwise retain the specification (and its
|
|
640
|
+
embedded images) until the next rendering */
|
|
641
|
+
linker = null;
|
|
642
|
+
anchors = null;
|
|
643
|
+
members = null;
|
|
644
|
+
schemas = null;
|
|
645
|
+
diagrams = null;
|
|
646
|
+
}
|
|
629
647
|
};
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { Spec } from "./specbook-format-spec.js";
|
|
2
2
|
import type { Schema } from "./specbook-format-schema.js";
|
|
3
|
-
export declare const renderMarkdown: (specification: Spec, config?: Schema) =>
|
|
3
|
+
export declare const renderMarkdown: (specification: Spec, config?: Schema) => string;
|
|
@@ -4,17 +4,26 @@
|
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
6
|
import { specDiagrams } from "./specbook-diagram.js";
|
|
7
|
+
import { becauseRegex } from "./specbook-parse-common.js";
|
|
7
8
|
/* format a timestamp in the frontmatter format */
|
|
8
9
|
const formatTimestamp = (date) => {
|
|
9
10
|
const pad = (n) => String(n).padStart(2, "0");
|
|
10
11
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
|
11
12
|
`${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
12
13
|
};
|
|
13
|
-
/* render the properties as a value-aligned key/value list
|
|
14
|
-
|
|
14
|
+
/* render the properties as a value-aligned key/value list, wrapping a
|
|
15
|
+
value carrying ";" onto a continuation line, as only a ";"-free first
|
|
16
|
+
line lets the item be recognized as a property again on re-parsing */
|
|
17
|
+
const renderKeyValuesMd = (properties, indent = "") => {
|
|
15
18
|
const width = Math.max(...properties.map((property) => property.key.length)) + 1;
|
|
16
|
-
return properties.map((property) =>
|
|
19
|
+
return properties.map((property) => property.value.includes(";") ?
|
|
20
|
+
`${indent}- ${property.key}:\n${indent} ${property.value}` :
|
|
21
|
+
`${indent}- ${`${property.key}:`.padEnd(width)} ${property.value}`.trimEnd()).join("\n");
|
|
17
22
|
};
|
|
23
|
+
/* whether a property can be a Concise Format segment: a value carrying
|
|
24
|
+
a ";" would split the segment, and a value carrying the rationale
|
|
25
|
+
marker would not be taken for a property at all on re-parsing */
|
|
26
|
+
const segmentableMd = (property) => !property.value.includes(";") && !becauseRegex.test(property.value);
|
|
18
27
|
/* render a description statement with its optional rationale */
|
|
19
28
|
const renderDescriptionMd = (description) => description.description + (description.rationale !== undefined ?
|
|
20
29
|
`, BECAUSE ${description.rationale}` : "");
|
|
@@ -23,28 +32,44 @@ const renderDescriptionMd = (description) => description.description + (descript
|
|
|
23
32
|
const nameSuffixMd = (object) => (object.primary === true ? " (*)" : "") +
|
|
24
33
|
(object.paren !== undefined ? ` (${object.paren})` : "") +
|
|
25
34
|
(object.anchor !== undefined ? ` {{${object.anchor}}}` : "");
|
|
26
|
-
/*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
/* whether any descendant of an object carries a description which no
|
|
36
|
+
Concise Format item can carry -- a multi-line one (a fenced code
|
|
37
|
+
block, an ordered list, a blockquote, or multiple paragraphs) or one
|
|
38
|
+
carrying a ";", as only a ";"-free description stays a single segment
|
|
39
|
+
on re-parsing instead of splitting off a spurious property */
|
|
40
|
+
const complexBelowMd = (object) => object.childs.some((child) => (child.description !== undefined
|
|
41
|
+
&& /[\n;]/.test(renderDescriptionMd(child.description))) || complexBelowMd(child));
|
|
42
|
+
/* render an object in the Concise Format as a list item of the
|
|
43
|
+
given nesting depth, with its childs as nested list items */
|
|
44
|
+
const renderConciseMd = (object, depth = 0) => {
|
|
45
|
+
const indent = " ".repeat(depth * 4);
|
|
30
46
|
const segments = [`${object.kind}: ${object.name}${nameSuffixMd(object)}`];
|
|
31
|
-
|
|
47
|
+
/* a property which cannot be a segment has to become a nested
|
|
48
|
+
key/value item below the item instead */
|
|
49
|
+
const wrapped = object.properties.filter((property) => !segmentableMd(property));
|
|
50
|
+
const inline = object.properties.filter((property) => segmentableMd(property));
|
|
51
|
+
segments.push(...inline.map((property) => `${property.key}: ${property.value}`));
|
|
32
52
|
if (object.description !== undefined)
|
|
33
53
|
segments.push(renderDescriptionMd(object.description));
|
|
34
|
-
/* a description terminates the item with ".",
|
|
35
|
-
|
|
36
|
-
|
|
54
|
+
/* a description terminates the item with ".", which on re-parsing
|
|
55
|
+
marks the last segment as the description (even one shaped like
|
|
56
|
+
"<key>: <value>"), while without one the ";" segment terminator
|
|
57
|
+
has to remain, as only it lets the item be recognized as concise */
|
|
37
58
|
const item = object.description !== undefined ?
|
|
38
59
|
`${indent}- ${segments.join("; ").replace(/\.?$/, ".")}` :
|
|
39
60
|
`${indent}- ${segments.join("; ")};`;
|
|
40
|
-
return [item,
|
|
61
|
+
return [item, renderKeyValuesMd(wrapped, `${indent} `),
|
|
62
|
+
...object.childs.map((child) => renderConciseMd(child, depth + 1))]
|
|
63
|
+
.filter((part) => part !== "").join("\n");
|
|
41
64
|
};
|
|
42
|
-
/* render an object in the Complex Format (levels 1-3),
|
|
43
|
-
|
|
44
|
-
|
|
65
|
+
/* render an object in the Complex Format (levels 1-3), with childs from
|
|
66
|
+
level 4 upwards in the Concise Format -- unless a descendant carries
|
|
67
|
+
a description no concise item can carry, so all childs (as siblings
|
|
68
|
+
have to share the format) stay in the Complex Format down to heading
|
|
69
|
+
level 6 --, and the optionally derived Gradia diagram spec embedded
|
|
45
70
|
as a "gradia" fenced code block below the heading */
|
|
46
71
|
const renderObjectMd = (object, level, diagrams) => {
|
|
47
|
-
const heading = `${"#".repeat(level)}${" ".repeat(4 - level)}` +
|
|
72
|
+
const heading = `${"#".repeat(level)}${" ".repeat(Math.max(1, 4 - level))}` +
|
|
48
73
|
`${object.kind}: ${object.name}${nameSuffixMd(object)}`;
|
|
49
74
|
const parts = [heading];
|
|
50
75
|
const spec = diagrams?.get(object);
|
|
@@ -54,14 +79,16 @@ const renderObjectMd = (object, level, diagrams) => {
|
|
|
54
79
|
parts.push(renderKeyValuesMd(object.properties));
|
|
55
80
|
if (object.description !== undefined)
|
|
56
81
|
parts.push(renderDescriptionMd(object.description));
|
|
57
|
-
|
|
58
|
-
|
|
82
|
+
/* the childs share one format, as a concise item following
|
|
83
|
+
a heading would attach to the heading's object instead */
|
|
84
|
+
if (level >= 3 && !complexBelowMd(object))
|
|
85
|
+
parts.push(object.childs.map((child) => renderConciseMd(child)).join("\n"));
|
|
59
86
|
else
|
|
60
87
|
parts.push(...object.childs.map((child) => renderObjectMd(child, level + 1, diagrams)));
|
|
61
88
|
return parts.filter((part) => part !== "").join("\n\n");
|
|
62
89
|
};
|
|
63
90
|
/* render the entire specification into normalized Markdown */
|
|
64
|
-
export const renderMarkdown =
|
|
91
|
+
export const renderMarkdown = (specification, config) => {
|
|
65
92
|
/* derive the Gradia specs of the diagram-configured objects
|
|
66
93
|
(an invalid diagram situation omits the diagram, as it is
|
|
67
94
|
already reported as a lint diagnostic) */
|
|
@@ -70,9 +97,15 @@ export const renderMarkdown = async (specification, config) => {
|
|
|
70
97
|
for (const [object, result] of specDiagrams(specification, config))
|
|
71
98
|
if (result.spec !== undefined)
|
|
72
99
|
diagrams.set(object, result.spec);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
100
|
+
/* the single frontmatter block (only recognized at the start of a
|
|
101
|
+
file on re-parse) carries the earliest creation and the latest
|
|
102
|
+
modification timestamp of all artifacts */
|
|
103
|
+
const created = new Date(Math.min(...specification.artifacts.map((artifact) => artifact.created.getTime())));
|
|
104
|
+
const modified = new Date(Math.max(...specification.artifacts.map((artifact) => artifact.modified.getTime())));
|
|
105
|
+
return "---\n" +
|
|
106
|
+
`Created: ${formatTimestamp(created)}\n` +
|
|
107
|
+
`Modified: ${formatTimestamp(modified)}\n` +
|
|
76
108
|
"---\n\n" +
|
|
77
|
-
|
|
109
|
+
specification.artifacts.flatMap((artifact) => artifact.objects)
|
|
110
|
+
.map((object) => renderObjectMd(object, 1, diagrams)).join("\n\n") + "\n";
|
|
78
111
|
};
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { OutlineEntry } from "./specbook-export-html.js";
|
|
2
2
|
import type { ThemeMapping } from "./specbook-theme.js";
|
|
3
|
-
|
|
3
|
+
type Heading = {
|
|
4
4
|
title: string;
|
|
5
5
|
subtitle?: string;
|
|
6
6
|
logo: string;
|
|
7
|
-
}
|
|
7
|
+
};
|
|
8
|
+
export declare const htmlToPdf: (renderHtmlPass: (tocPages?: Map<string, number>) => Promise<string>, heading: Heading, outline: OutlineEntry[], verbose: (msg: string) => void, css: string, theme: ThemeMapping, paper?: string) => Promise<Buffer>;
|
|
9
|
+
export {};
|
|
@@ -78,6 +78,49 @@ const addOutline = async (doc, entries) => {
|
|
|
78
78
|
doc.catalog.set(PDFName.of("Outlines"), root);
|
|
79
79
|
doc.catalog.set(PDFName.of("PageMode"), PDFName.of("UseOutlines"));
|
|
80
80
|
};
|
|
81
|
+
/* render the header/footer templates for the page decoration: they
|
|
82
|
+
render in an isolated context, so they need their own inline styling,
|
|
83
|
+
the regular font face embedded as a data: URI, and the special
|
|
84
|
+
"pageNumber" class for the injected page number */
|
|
85
|
+
const decorationTemplates = (heading, css, theme, left, right) => {
|
|
86
|
+
const fontFace = css.match(/@font-face\s*\{[^}]*\}/)?.[0] ?? "";
|
|
87
|
+
const subtitle = heading.subtitle?.trim();
|
|
88
|
+
const headText = escapeHtml(heading.title) +
|
|
89
|
+
(subtitle !== undefined && subtitle !== "" ? ` — ${escapeHtml(subtitle)}` : "");
|
|
90
|
+
return {
|
|
91
|
+
headerTemplate: `<style>${fontFace}</style>` +
|
|
92
|
+
"<div style=\"width: 100%; margin-top: 0.8cm;\">" +
|
|
93
|
+
`<div style="margin: 0 ${right} 0 ${left}; ` +
|
|
94
|
+
"font-family: 'Source Sans 3', sans-serif; " +
|
|
95
|
+
`font-size: 8pt; color: ${theme.symbol}; border-bottom: 1px solid ${theme.border}; ` +
|
|
96
|
+
"padding-bottom: 1mm; display: flex; justify-content: space-between; " +
|
|
97
|
+
"align-items: flex-end;\">" +
|
|
98
|
+
`<span>${headText}</span>` +
|
|
99
|
+
`<img src="${escapeHtml(heading.logo)}" alt="" style="height: 3.5mm;"/>` +
|
|
100
|
+
"</div></div>",
|
|
101
|
+
footerTemplate: `<style>${fontFace}</style>` +
|
|
102
|
+
"<div style=\"width: 100%; margin-bottom: 0.8cm;\">" +
|
|
103
|
+
`<div style="margin: 0 ${right} 0 ${left}; ` +
|
|
104
|
+
"font-family: 'Source Sans 3', sans-serif; " +
|
|
105
|
+
`font-size: 8pt; color: ${theme.symbol}; display: flex; ` +
|
|
106
|
+
`justify-content: space-between; border-top: 1px solid ${theme.border}; ` +
|
|
107
|
+
"padding-top: 1mm;\">" +
|
|
108
|
+
`<span>${headText}</span>` +
|
|
109
|
+
`<span style="color: ${theme.muted}; font-weight: bold;" class="pageNumber"></span></div></div>`
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
/* draw the vertical brand bar onto the left edge of the physical
|
|
113
|
+
paper of every page, as Chromium clips print content to the page
|
|
114
|
+
box (0.6rem at the 9pt print root) */
|
|
115
|
+
const drawBrandBar = async (doc, accent) => {
|
|
116
|
+
const { rgb } = await import("pdf-lib");
|
|
117
|
+
const hex = accent.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
118
|
+
const [r, g, b] = (hex !== null ? hex.slice(1) : ["00", "00", "00"])
|
|
119
|
+
.map((component) => parseInt(component, 16) / 255);
|
|
120
|
+
for (const page of doc.getPages())
|
|
121
|
+
page.drawRectangle({ x: 0, y: 0, width: 5.4,
|
|
122
|
+
height: page.getHeight(), color: rgb(r, g, b) });
|
|
123
|
+
};
|
|
81
124
|
/* render a self-contained HTML document into a PDF via Playwright,
|
|
82
125
|
re-rendering the HTML with the discovered ToC page numbers */
|
|
83
126
|
export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css, theme, paper = paperSizeDefault) => {
|
|
@@ -85,11 +128,6 @@ export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css,
|
|
|
85
128
|
/* the page geometry of the chosen paper size */
|
|
86
129
|
const setup = paperSetup(paper);
|
|
87
130
|
const margin = setup.margin;
|
|
88
|
-
/* the regular font face and the title/subtitle
|
|
89
|
-
text for the header/footer templates */
|
|
90
|
-
const fontFace = css.match(/@font-face\s*\{[^}]*\}/)?.[0] ?? "";
|
|
91
|
-
const headText = escapeHtml(heading.title) +
|
|
92
|
-
(heading.subtitle !== undefined ? ` — ${escapeHtml(heading.subtitle)}` : "");
|
|
93
131
|
/* launch the Playwright Chromium browser, falling back to a
|
|
94
132
|
system-installed Google Chrome if the Chromium download is missing */
|
|
95
133
|
let browser;
|
|
@@ -139,31 +177,10 @@ export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css,
|
|
|
139
177
|
html = next;
|
|
140
178
|
plain = await renderPdf(html);
|
|
141
179
|
}
|
|
180
|
+
/* render the final document, decorated with header/footer */
|
|
142
181
|
const decorated = await renderPdf(html, {
|
|
143
|
-
/* the header/footer templates render in an isolated context,
|
|
144
|
-
so they need their own inline styling, the regular font face
|
|
145
|
-
embedded as a data: URI, and the special "title"/"pageNumber"
|
|
146
|
-
classes for the injected values */
|
|
147
182
|
displayHeaderFooter: true,
|
|
148
|
-
|
|
149
|
-
"<div style=\"width: 100%; margin-top: 0.8cm;\">" +
|
|
150
|
-
`<div style="margin: 0 ${paperLength(setup, margin.left)}; ` +
|
|
151
|
-
"font-family: 'Source Sans 3', sans-serif; " +
|
|
152
|
-
`font-size: 8pt; color: ${theme.symbol}; border-bottom: 1px solid ${theme.border}; ` +
|
|
153
|
-
"padding-bottom: 1mm; display: flex; justify-content: space-between; " +
|
|
154
|
-
"align-items: flex-end;\">" +
|
|
155
|
-
`<span>${headText}</span>` +
|
|
156
|
-
`<img src="${escapeHtml(heading.logo)}" alt="" style="height: 3.5mm;"/>` +
|
|
157
|
-
"</div></div>",
|
|
158
|
-
footerTemplate: `<style>${fontFace}</style>` +
|
|
159
|
-
"<div style=\"width: 100%; margin-bottom: 0.8cm;\">" +
|
|
160
|
-
`<div style="margin: 0 ${paperLength(setup, margin.left)}; ` +
|
|
161
|
-
"font-family: 'Source Sans 3', sans-serif; " +
|
|
162
|
-
`font-size: 8pt; color: ${theme.text}; display: flex; ` +
|
|
163
|
-
`justify-content: space-between; border-top: 1px solid ${theme.border}; ` +
|
|
164
|
-
"padding-top: 1mm;\">" +
|
|
165
|
-
`<span style="color: ${theme.symbol}">${headText}</span>` +
|
|
166
|
-
`<span style="color: ${theme.muted}; font-weight: bold;" class="pageNumber"></span></div></div>`
|
|
183
|
+
...decorationTemplates(heading, css, theme, paperLength(setup, margin.left), paperLength(setup, margin.right))
|
|
167
184
|
});
|
|
168
185
|
/* a title page carries no header/footer: as Chromium decorates
|
|
169
186
|
all pages unconditionally, swap the first page of the
|
|
@@ -171,7 +188,7 @@ export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css,
|
|
|
171
188
|
the ToC page number iteration) into the decorated document
|
|
172
189
|
*in place*, which keeps the decorated document's link
|
|
173
190
|
destinations (and hence the internal hyperlinks) intact */
|
|
174
|
-
const { PDFDocument
|
|
191
|
+
const { PDFDocument } = await import("pdf-lib");
|
|
175
192
|
const merged = await PDFDocument.load(decorated);
|
|
176
193
|
if (html.includes("class=\"titlepage\"")) {
|
|
177
194
|
const source = await PDFDocument.load(plain);
|
|
@@ -182,15 +199,8 @@ export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css,
|
|
|
182
199
|
/* attach the document outline for the viewer side-bar */
|
|
183
200
|
verbose("attaching PDF outline (bookmarks)");
|
|
184
201
|
await addOutline(merged, outline);
|
|
185
|
-
/*
|
|
186
|
-
|
|
187
|
-
content to the page box (0.6rem at the 9pt print root) */
|
|
188
|
-
const hex = theme.accent.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
189
|
-
const [r, g, b] = (hex !== null ? hex.slice(1) : ["00", "00", "00"])
|
|
190
|
-
.map((component) => parseInt(component, 16) / 255);
|
|
191
|
-
for (const pdfPage of merged.getPages())
|
|
192
|
-
pdfPage.drawRectangle({ x: 0, y: 0, width: 5.4,
|
|
193
|
-
height: pdfPage.getHeight(), color: rgb(r, g, b) });
|
|
202
|
+
/* decorate every page with the vertical brand bar */
|
|
203
|
+
await drawBrandBar(merged, theme.accent);
|
|
194
204
|
return Buffer.from(await merged.save());
|
|
195
205
|
}
|
|
196
206
|
finally {
|