@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.
Files changed (41) hide show
  1. package/README.md +65 -10
  2. package/dst/specbook-api.js +19 -18
  3. package/dst/specbook-cli.js +9 -6
  4. package/dst/specbook-cmd-describe.js +1 -3
  5. package/dst/specbook-cmd-export.js +24 -17
  6. package/dst/specbook-cmd-init.d.ts +1 -1
  7. package/dst/specbook-cmd-init.js +5 -4
  8. package/dst/specbook-cmd-lint.d.ts +0 -1
  9. package/dst/specbook-cmd-lint.js +29 -5
  10. package/dst/specbook-config.d.ts +1 -1
  11. package/dst/specbook-config.js +90 -71
  12. package/dst/specbook-diagnostic.js +4 -1
  13. package/dst/specbook-diagram.d.ts +8 -3
  14. package/dst/specbook-diagram.js +101 -105
  15. package/dst/specbook-export-ast.d.ts +1 -1
  16. package/dst/specbook-export-ast.js +1 -1
  17. package/dst/specbook-export-common.d.ts +2 -1
  18. package/dst/specbook-export-common.js +12 -14
  19. package/dst/specbook-export-html-search.js +10 -14
  20. package/dst/specbook-export-html.css +24 -29
  21. package/dst/specbook-export-html.js +101 -83
  22. package/dst/specbook-export-md.d.ts +1 -1
  23. package/dst/specbook-export-md.js +56 -23
  24. package/dst/specbook-export-pdf.d.ts +4 -2
  25. package/dst/specbook-export-pdf.js +48 -38
  26. package/dst/specbook-format-schema.js +6 -3
  27. package/dst/specbook-format.md +47 -20
  28. package/dst/specbook-link.d.ts +1 -0
  29. package/dst/specbook-link.js +7 -4
  30. package/dst/specbook-mcp.js +17 -10
  31. package/dst/specbook-parse-common.d.ts +2 -1
  32. package/dst/specbook-parse-common.js +2 -0
  33. package/dst/specbook-parse-semantic.d.ts +4 -2
  34. package/dst/specbook-parse-semantic.js +114 -43
  35. package/dst/specbook-parse-syntax.js +179 -137
  36. package/dst/specbook-parse-value.d.ts +1 -1
  37. package/dst/specbook-parse-value.js +9 -5
  38. package/dst/specbook-parse.d.ts +2 -5
  39. package/dst/specbook-parse.js +27 -34
  40. package/dst/specbook-verbose.js +2 -1
  41. 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">&mdash; <span class="keyword">BECAUSE</span> {{ Description.rationale }}</p>{% endif %}</div>
120
- {%- else %}
121
+ {%- elif Description.description or Description.rationale %}
121
122
  <p class="description">{{ Description.description }}{% if Description.rationale %}
122
123
  <span class="rationale">&mdash; <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(/&#8220;/g, quotes[0]).replace(/&#8221;/g, quotes[1])
210
211
  .replace(/&(?![a-zA-Z][a-zA-Z0-9]*;|#\d+;|#x[0-9a-fA-F]+;)/g, "&amp;") } });
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, "&lt;").replace(/>/g, "&gt;") };
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 into HTML (SVG inlined as-is,
245
- PNG/JPEG placed onto an <img> tag) */
246
- const renderImage = (content, alt) => content.startsWith("data:") ?
247
- `<img src="${content}" alt="${escapeHtml(alt)}"/>` :
248
- content.replace(/^\s*<\?xml[^>]*\?>\s*(?:<!DOCTYPE[^>]*>\s*)?/i, "");
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 "![alt](file)" 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 images = embedding.slice(i, i + variants.length)
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(embeddingRegex, (markup, _alt, reference) => embeddingMimeType(reference.trim()) !== undefined ? "" : markup)
279
- .replace(/(?<=\S)[ \t]{2,}/g, " ").trim();
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 = (schemas, result) => {
293
- for (const schema of schemas) {
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
- const inlineValue = (kind, key, value) => {
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
- if (member === undefined)
338
- return inline(value);
339
- const items = member === "tags" ? splitItems(value) : [value.trim()];
340
- return safe(items.map((item) => `<span class="value-member">${inline(item)}</span>`).join(" "));
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.key, property.value) }));
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 Array();
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 merged = (schema.props ?? []).flatMap((prop) => {
375
- const present = keys.filter((key) => key === prop.name);
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 value = child.properties.find((property) => property.key === key)?.value;
445
- return value !== undefined ? inlineValue(child.kind, key, value) : "";
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 value = child.properties.find((property) => property.key === key)?.value;
459
+ const property = child.properties.find((property) => property.key === key);
460
460
  return { key, desc: false, span: 1,
461
- value: value !== undefined ? inlineValue(child.kind, key, value) : safe("") };
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: diagrams?.has(object) === true ?
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
- diagrams = null;
578
+ let rendered = null;
574
579
  if (cached !== undefined && cached.config === config)
575
- diagrams = cached.diagrams;
580
+ rendered = cached.diagrams;
576
581
  else if (config !== undefined) {
577
- diagrams = new Map();
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
- diagrams.set(object, svg.replace(/(<svg[^>]*) width="([0-9.]+)" height="([0-9.]+)"/, (_, head, w, h) => `${head} width="${Number(w) * scale}" height="${Number(h) * scale}"`));
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
- const entries = artifacts.flatMap((artifact) => artifact.objects)
616
- .map((object) => ({ id: anchorOf(object), kind: object.kind, name: inline(object.name),
617
- page: tocPages?.get(anchorOf(object)) }));
618
- return render("Document", { Document: {
619
- title: documentTitle(specification).title,
620
- lang,
621
- theme: documentThemeStyle(specification)?.toLowerCase(),
622
- css: safe(css ?? stylesheet()),
623
- titlepage: title !== undefined ?
624
- safe(renderTitlePage(title, created.toISOString().slice(0, 10), modified.toISOString().slice(0, 10))) : "",
625
- search: title !== undefined ? safe(searchScript()) : "",
626
- toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
627
- artifacts: safe(artifacts.map((artifact) => renderArtifact(artifact)).join(""))
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) => Promise<string>;
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
- const renderKeyValuesMd = (properties) => {
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) => `- ${`${property.key}:`.padEnd(width)} ${property.value}`).join("\n");
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
- /* render an object in the Concise Format (level 4 and deeper),
27
- with its childs as nested list items */
28
- const renderConciseMd = (object, level) => {
29
- const indent = " ".repeat((level - 4) * 4);
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
- segments.push(...object.properties.map((property) => `${property.key}: ${property.value}`));
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 ".", while without one the
35
- ";" segment terminator has to remain, as only it lets the item be
36
- recognized as a concise object again on re-parsing */
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, ...object.childs.map((child) => renderConciseMd(child, level + 1))].join("\n");
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
- with childs from level 4 upwards in the Concise Format
44
- and the optionally derived Gradia diagram spec embedded
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
- if (level >= 3)
58
- parts.push(object.childs.map((child) => renderConciseMd(child, level + 1)).join("\n"));
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 = async (specification, config) => {
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
- return specification.artifacts.map((artifact) => "---\n" +
74
- `Created: ${formatTimestamp(artifact.created)}\n` +
75
- `Modified: ${formatTimestamp(artifact.modified)}\n` +
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
- artifact.objects.map((object) => renderObjectMd(object, 1, diagrams)).join("\n\n")).join("\n\n") + "\n";
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
- export declare const htmlToPdf: (renderHtmlPass: (tocPages?: Map<string, number>) => Promise<string>, heading: {
3
+ type Heading = {
4
4
  title: string;
5
5
  subtitle?: string;
6
6
  logo: string;
7
- }, outline: OutlineEntry[], verbose: (msg: string) => void, css: string, theme: ThemeMapping, paper?: string) => Promise<Buffer>;
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 !== "" ? ` &mdash; ${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 ? ` &mdash; ${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
- headerTemplate: `<style>${fontFace}</style>` +
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, rgb } = await import("pdf-lib");
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
- /* draw the vertical brand bar onto the left edge of the
186
- physical paper of every page, as Chromium clips print
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 {