@rse/specbook 1.1.0 → 1.2.1

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.
@@ -116,26 +116,34 @@ export const charsetCodepoints = (charset) => {
116
116
  "(expected US-ASCII, ISO-8859-1/ISO-Latin-1, ISO-8859-15/ISO-Latin-9, or UTF-8)");
117
117
  };
118
118
  /* the symbol glyphs used by the HTML/PDF rendering (kind and property
119
- bullets, link symbol, primary marker, theme switch icon, anchor
120
- symbol plus its text presentation variation selector, the search
121
- field clearing icon, the active entry pointer of the table of
122
- contents side panel, and the title path segment pointer of the
119
+ bullets, link symbol, primary marker, anchor symbol plus its text
120
+ presentation variation selector, the search field clearing icon,
121
+ the absent property marker, the active entry pointer of the table
122
+ of contents side panel, and the title path segment pointer of the
123
123
  description popups) */
124
- const symbolGlyphs = [0x25CF, 0x25CB, 0x26AD, 0x2318, 0x25D0, 0x2693, 0xFE0E, 0x00D7, 0x25C0, 0x25B7];
124
+ const symbolGlyphs = [0x25CF, 0x25CB, 0x26AD, 0x2318, 0x2693, 0xFE0E, 0x00D7, 0x2205, 0x25C0, 0x25B7];
125
125
  /* the typographic glyphs producible by the smart typography rendering
126
126
  (language-specific quotes, dashes, ellipsis, bullet, nbsp) */
127
127
  const typographyGlyphs = [
128
128
  0x00A0, 0x00AB, 0x00BB, 0x2013, 0x2014, 0x2018, 0x2019,
129
129
  0x201A, 0x201C, 0x201D, 0x201E, 0x2022, 0x2026, 0x2039, 0x203A
130
130
  ];
131
+ /* the memoized subsetted stylesheets, keyed by the (shared) codepoint
132
+ set of the charset, as the font subsetting is expensive and the
133
+ formats of a single export, the watch and preview re-exports, and
134
+ the MCP service request the very same subset over and over again */
135
+ const subsetCache = new Map();
131
136
  /* provide the stylesheet with its embedded fonts subsetted to the
132
137
  codepoints of a charset plus the always-used symbol and typography
133
138
  glyphs (no charset or a full Unicode charset keeps the fonts complete) */
134
139
  export const subsetStylesheet = async (charset) => {
135
- const css = stylesheet();
136
140
  const codepoints = charset !== undefined ? charsetCodepoints(charset) : undefined;
137
141
  if (codepoints === undefined)
138
- return css;
142
+ return stylesheet();
143
+ const cached = subsetCache.get(codepoints);
144
+ if (cached !== undefined)
145
+ return cached;
146
+ const css = stylesheet();
139
147
  const { default: subsetFont } = await import("subset-font");
140
148
  const text = String.fromCodePoint(...codepoints, ...symbolGlyphs, ...typographyGlyphs);
141
149
  let result = "";
@@ -146,14 +154,20 @@ export const subsetStylesheet = async (charset) => {
146
154
  `url("data:font/woff2;base64,${subset.toString("base64")}")`;
147
155
  last = m.index + m[0].length;
148
156
  }
149
- return result + css.slice(last);
157
+ const subsetted = result + css.slice(last);
158
+ subsetCache.set(codepoints, subsetted);
159
+ return subsetted;
160
+ };
161
+ /* determine the document title and subtitle from the title object (an
162
+ empty TITLE counts as absent, exactly like for the title page) */
163
+ export const documentTitle = (specification) => {
164
+ const title = titleProperty(specification, "TITLE")?.trim();
165
+ return {
166
+ title: title !== undefined && title !== "" ? title :
167
+ titleObject(specification)?.name ?? "Specification",
168
+ subtitle: titleProperty(specification, "SUBTITLE")?.trim()
169
+ };
150
170
  };
151
- /* determine the document title and subtitle from the title object */
152
- export const documentTitle = (specification) => ({
153
- title: titleProperty(specification, "TITLE") ??
154
- titleObject(specification)?.name ?? "Specification",
155
- subtitle: titleProperty(specification, "SUBTITLE")
156
- });
157
171
  /* determine the document logo as a self-contained data: URL, taken from the
158
172
  first embedded image of the LOGO property of the title object -- the light
159
173
  variant of a "{theme}" reference -- and falling back onto the built-in
@@ -1,9 +1,11 @@
1
- import type { Spec } from "./specbook-format-spec.js";
1
+ import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
2
  import type { Schema } from "./specbook-format-schema.js";
3
+ import type { Verbose } from "./specbook-verbose.js";
3
4
  export type OutlineEntry = {
4
5
  title: string;
5
6
  anchor: string;
6
7
  childs: OutlineEntry[];
7
8
  };
8
9
  export declare const htmlOutline: (specification: Spec, config?: Schema) => OutlineEntry[];
9
- export declare const renderHtml: (specification: Spec, config?: Schema, tocPages?: Map<string, number>, css?: string, realtime?: boolean) => Promise<string>;
10
+ export declare const titlePageObject: (specification: Spec) => SpecObject | undefined;
11
+ export declare const renderHtml: (specification: Spec, config?: Schema, tocPages?: Map<string, number>, css?: string, realtime?: boolean, verbose?: Verbose) => Promise<string>;
@@ -14,7 +14,7 @@ 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
16
  import { collectSchemas } from "./specbook-parse-semantic.js";
17
- import { specDiagrams } from "./specbook-diagram.js";
17
+ import { renderDiagrams } from "./specbook-diagram.js";
18
18
  /* ==== Templates ==== */
19
19
  /* the built-in Nunjucks templates for the HTML export */
20
20
  const templates = {
@@ -238,7 +238,14 @@ const realtimeScript = textframe `
238
238
  }
239
239
  let blink = 0
240
240
  const update = async () => {
241
- const response = await fetch(window.location.pathname, { cache: "no-store" })
241
+ let response
242
+ try {
243
+ response = await fetch(window.location.pathname, { cache: "no-store" })
244
+ }
245
+ catch {
246
+ /* an unreachable server just skips the update */
247
+ return
248
+ }
242
249
  if (!response.ok)
243
250
  return
244
251
  const doc = new DOMParser().parseFromString(await response.text(), "text/html")
@@ -386,35 +393,18 @@ const tocPanelScript = textframe `
386
393
  })()
387
394
  `;
388
395
  /* the client-side script of the description popups: the info tab
389
- toggles the popups on and off (default), persisting the choice
390
- across page loads, and while they are on, hovering an element
391
- carrying a "data-info" key shows the schema description of its
392
- object kind (or, with "data-info-prop", of its property), while
393
- hovering an element carrying a "data-info-spec" key (an object
394
- name) shows the corpus description of the object instance, in a
395
- single shared popup -- a hovered diagram node box resolves through
396
- its hyperlinked object anchor to the instance popup attributes of
397
- the object's heading name or table row name -- opened only after
398
- the mouse rested 400ms on the element (so mere mouse movements
399
- across the page pop nothing up), titled with the "data-info-path"
400
- object path (one filled-circle "KIND: Name" segment per level,
401
- arbitrarily deep and separated by the icons alone, ending for
402
- a kind in the bare kind and extended for a property with the
403
- not-filled-circle property name, styled as in the document
404
- content: the kinds and the property bold in the path color, the
405
- names bold in the description color)
406
- and fed from the injected INFO
407
- map of pre-rendered schema description HTML or SPEC map of
408
- pre-rendered corpus description HTML (a missing entry pops up
409
- the title path alone), the description led by a bold "Schema:"
410
- or "Specification:" label naming its origin (a corpus-fed
411
- instance popup additionally carrying the page background colors
412
- instead of the accent), where the popup is
413
- capped at 40% viewport width and attached above or below the
414
- hovered element, whichever side offers more viewport space; the
396
+ toggles the popups (off by default, persisted across page loads),
397
+ and while they are on, a mouse resting 400ms on an element carrying
398
+ a "data-info" key (plus "data-info-prop" for a property) pops up the
399
+ schema description of its object kind (or property), while one
400
+ carrying a "data-info-spec" key pops up the corpus description of
401
+ the object instance (a diagram node box resolves through its
402
+ hyperlinked object anchor), titled with the "data-info-path" object
403
+ path and fed from the injected INFO/SPEC maps of pre-rendered
404
+ description HTML; the popup is capped at 40% viewport width and
405
+ attached above or below, whichever side offers more space; the
415
406
  script runs at the end of the body, so a live preview body swap
416
- replaces the popup element and the body-bound listeners along
417
- with the re-executed script */
407
+ replaces the popup and the body-bound listeners along with it */
418
408
  const infoPopupScript = textframe `
419
409
  (function (INFO, SPEC) {
420
410
  const popup = document.createElement("div")
@@ -516,7 +506,7 @@ const infoPopupScript = textframe `
516
506
  title.appendChild(name)
517
507
  }
518
508
  popup.replaceChildren(title)
519
- const entry = INFO[key]
509
+ const entry = key !== null ? INFO[key] : undefined
520
510
  const desc = prop !== null ? entry?.p?.[prop] :
521
511
  (spec !== null ? SPEC[spec] : entry?.d)
522
512
  if (desc !== undefined) {
@@ -646,19 +636,16 @@ let infoParents = null;
646
636
  resolution of the references inside them (nearest object wins),
647
637
  established around every object rendering and restored afterwards */
648
638
  let scope = null;
649
- const scoped = (object, render) => {
639
+ const scoped = (object, body) => {
650
640
  const outer = scope;
651
641
  scope = object;
652
642
  try {
653
- return render();
643
+ return body();
654
644
  }
655
645
  finally {
656
646
  scope = outer;
657
647
  }
658
648
  };
659
- /* the cache of the pre-rendered diagram SVGs, keyed by specification,
660
- as the PDF export renders the very same document multiple times */
661
- const diagramCache = new WeakMap();
662
649
  /* determine the fully-qualified anchor path of an object */
663
650
  const anchorOf = (object) => anchors?.get(object) ?? object.id;
664
651
  /* determine the description popup key of an object (the qualified
@@ -942,6 +929,12 @@ const renderCell = (child) => {
942
929
  html += renderChilds(child, 6, true);
943
930
  return html.trim() !== "" ? html : "<span class=\"value-absent\"></span>";
944
931
  };
932
+ /* the pre-rendered diagram of an object as an embeddable block
933
+ (empty for an object without a configured or renderable diagram) */
934
+ const diagramOf = (object) => {
935
+ const diagram = diagrams?.get(object);
936
+ return diagram !== undefined ? safe(`<div class="diagram">${diagram}</div>`) : "";
937
+ };
945
938
  /* render a single-kind group of childs into one compact table:
946
939
  the name first, then the property columns, then the description;
947
940
  a group wider than maxColumns, or carrying diagrams, instead chunks
@@ -990,8 +983,8 @@ const renderTable = (childs, maxColumns) => {
990
983
  cells.push({ key: "Description", desc: true, span: 1,
991
984
  value: safe(renderCell(child)) });
992
985
  const chunks = new Array();
993
- for (let i = 0; i < cells.length; i += size)
994
- chunks.push(cells.slice(i, i + size));
986
+ for (let pos = 0; pos < cells.length; pos += size)
987
+ chunks.push(cells.slice(pos, pos + size));
995
988
  if (chunks.length > 0) {
996
989
  const last = chunks[chunks.length - 1];
997
990
  last[last.length - 1].span = size - last.length + 1;
@@ -1014,12 +1007,6 @@ const renderTable = (childs, maxColumns) => {
1014
1007
  }))
1015
1008
  } });
1016
1009
  };
1017
- /* the pre-rendered diagram of an object as an embeddable block
1018
- (empty for an object without a configured or renderable diagram) */
1019
- const diagramOf = (object) => {
1020
- const diagram = diagrams?.get(object);
1021
- return diagram !== undefined ? safe(`<div class="diagram">${diagram}</div>`) : "";
1022
- };
1023
1010
  /* recursively render an object into HTML */
1024
1011
  const renderObject = (object, level, concise) => {
1025
1012
  const properties = effectiveProperties(object);
@@ -1144,63 +1131,67 @@ export const htmlOutline = (specification, config) => {
1144
1131
  has to carry a non-empty "TITLE" property, as without it there is
1145
1132
  nothing to render a title page from (the object stays suppressed in
1146
1133
  the regular flow nevertheless) */
1147
- const titlePageObject = (specification) => {
1134
+ export const titlePageObject = (specification) => {
1148
1135
  const object = titleObject(specification);
1149
1136
  const title = object?.properties.find((property) => property.key === "TITLE");
1150
1137
  return title !== undefined && title.value.trim() !== "" ? object : undefined;
1151
1138
  };
1139
+ /* pre-render the configured diagrams as embeddable SVGs, displayed
1140
+ at a reduced coordinate scale, as the Gradia geometry (node
1141
+ boxes, font sizes) is dimensioned for a stand-alone canvas and
1142
+ would dwarf the document text at 1:1 */
1143
+ const scaledDiagrams = async (specification, config, verbose) => {
1144
+ const scale = 0.75;
1145
+ const rendered = new Map();
1146
+ for (const [object, result] of await renderDiagrams(specification, config, verbose)) {
1147
+ /* a "hub" diagram is capped to the width share it would
1148
+ occupy on its full three-column canvas (padded by the
1149
+ minimum widths of the absent columns and their channels),
1150
+ so all hub diagrams share the zoom level of the
1151
+ three-column ones and are centered in the leftover space */
1152
+ const absent = result.columns !== undefined ? 3 - result.columns : 0;
1153
+ const pad = absent * ((result.config?.["size-node-width-min"] ?? Gradia.config["size-node-width-min"]) +
1154
+ (result.config?.["hub-channel-width-min"] ?? Gradia.config["hub-channel-width-min"]));
1155
+ rendered.set(object, result.svg.replace(/(<svg[^>]*) width="([0-9.]+)" height="([0-9.]+)"/, (_, head, w, h) => `${head} width="${Number(w) * scale}" height="${Number(h) * scale}"` +
1156
+ (result.columns !== undefined ? " class=\"hub\"" : "") +
1157
+ (absent > 0 ? ` style="max-width: ${(Number(w) / (Number(w) + pad) * 100).toFixed(2)}%"` : "")));
1158
+ }
1159
+ return rendered;
1160
+ };
1161
+ /* create the reference expander of a document: "[[xxx]]" references
1162
+ expand into hyperlinks (an unresolvable or ambiguous reference stays
1163
+ literal, marked as broken), resolved from the object currently
1164
+ rendered and targeting the fully-qualified anchor paths of the
1165
+ objects: in the full form (kind, name, and link symbol), or in the
1166
+ compact form for prose (the object icon and the name only, via CSS
1167
+ and markup, with the full form shown by the description popups) */
1168
+ const makeLinker = (index) => (text, compact) => expandReferences(text, (reference) => {
1169
+ const target = resolveUnique(index, reference, scope ?? undefined).target;
1170
+ if (target === undefined)
1171
+ return `<span class="link-broken">[[${escapeHtml(reference)}]]</span>`;
1172
+ /* the full form carries the popup attributes on its kind and
1173
+ name, with the kind bold in the accent color and the name
1174
+ bold in the regular text color (via the stylesheet) */
1175
+ const full = `<strong class="object-kind"${infoAttr(target)}>${escapeHtml(target.kind)}:</strong>` +
1176
+ ` <span class="object-name"${specAttr(target)}>${target.name}</span>` +
1177
+ " <span class=\"link-symbol\">&#x26AD;</span>";
1178
+ return compact ?
1179
+ `<a href="#${escapeHtml(anchorOf(target))}" class="link-compact"${specAttr(target)}>${target.name}</a>` :
1180
+ `<a href="#${escapeHtml(anchorOf(target))}" class="link-full">${full}</a>`;
1181
+ });
1152
1182
  /* render the entire specification into a self-contained HTML document,
1153
1183
  with the build-time pre-assembled stylesheet embedded inline, the
1154
1184
  artifact timestamps aggregated into min(Created)/max(Modified),
1155
1185
  optional per-anchor page numbers attached to the ToC entries, and
1156
1186
  optionally the client-side script of the live preview injected */
1157
- export const renderHtml = async (specification, config, tocPages, css, realtime = false) => {
1158
- /* pre-render the configured diagrams as embeddable SVGs (a runtime
1159
- rendering failure omits the diagram, as the statically detectable
1160
- invalid situations are already reported as lint diagnostics),
1161
- displayed at a reduced coordinate scale, as the Gradia geometry
1162
- (node boxes, font sizes) is dimensioned for a stand-alone
1163
- canvas and would dwarf the document text at 1:1 */
1164
- const scale = 0.75;
1165
- const cached = diagramCache.get(specification);
1166
- let rendered = null;
1167
- if (cached !== undefined && cached.config === config)
1168
- rendered = cached.diagrams;
1169
- else if (config !== undefined) {
1170
- rendered = new Map();
1171
- for (const [object, result] of specDiagrams(specification, config)) {
1172
- if (result.spec === undefined)
1173
- continue;
1174
- try {
1175
- const svg = await Gradia.render(result.spec, { format: "svg:embedded", config: result.config });
1176
- /* a "hub" diagram is capped to the width share it would
1177
- occupy on its full three-column canvas (padded by the
1178
- minimum widths of the absent columns and their channels),
1179
- so all hub diagrams share the zoom level of the
1180
- three-column ones and are centered in the leftover space */
1181
- const absent = result.columns !== undefined ? 3 - result.columns : 0;
1182
- const pad = absent * ((result.config?.["size-node-width-min"] ?? Gradia.config["size-node-width-min"]) +
1183
- (result.config?.["hub-channel-width-min"] ?? Gradia.config["hub-channel-width-min"]));
1184
- rendered.set(object, svg.replace(/(<svg[^>]*) width="([0-9.]+)" height="([0-9.]+)"/, (_, head, w, h) => `${head} width="${Number(w) * scale}" height="${Number(h) * scale}"` +
1185
- (result.columns !== undefined ? " class=\"hub\"" : "") +
1186
- (absent > 0 ? ` style="max-width: ${(Number(w) / (Number(w) + pad) * 100).toFixed(2)}%"` : "")));
1187
- }
1188
- catch {
1189
- /* intentionally omitted */
1190
- }
1191
- }
1192
- diagramCache.set(specification, { config, diagrams: rendered });
1193
- }
1187
+ export const renderHtml = async (specification, config, tocPages, css, realtime = false, verbose) => {
1188
+ /* pre-render the configured diagrams as scaled embeddable SVGs */
1189
+ const rendered = config !== undefined ?
1190
+ await scaledDiagrams(specification, config, verbose) : null;
1194
1191
  /* the document language selects the smart typography quote style */
1195
1192
  const lang = documentLang(specification);
1196
1193
  quotes = quoteStyles[lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
1197
- /* expand "[[xxx]]" references into hyperlinks (an unresolvable or
1198
- ambiguous reference stays literal, marked as broken), resolved
1199
- from the object currently rendered and targeting the
1200
- fully-qualified anchor paths of the objects: in the full form
1201
- (kind, name, and link symbol), or in the compact form for prose
1202
- (the object icon and the name only, via CSS and markup, with
1203
- the full form shown by the description popups) */
1194
+ /* establish the per-document rendering state */
1204
1195
  const index = buildLinkIndex(specification);
1205
1196
  anchors = anchorPaths(index);
1206
1197
  members = config !== undefined ? collectMembers(config, new Map()) : null;
@@ -1214,30 +1205,12 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1214
1205
  info = {};
1215
1206
  collectInfo(config, "", infoKeys, info);
1216
1207
  const parents = new Map();
1217
- const link = (object) => {
1218
- for (const child of object.childs) {
1219
- parents.set(child, object);
1220
- link(child);
1221
- }
1222
- };
1223
- for (const artifact of specification.artifacts)
1224
- artifact.objects.forEach(link);
1208
+ for (const node of index)
1209
+ if (node.parent !== undefined)
1210
+ parents.set(node.object, node.parent.object);
1225
1211
  infoParents = parents;
1226
1212
  }
1227
- linker = (text, compact) => expandReferences(text, (reference) => {
1228
- const target = resolveUnique(index, reference, scope ?? undefined).target;
1229
- if (target === undefined)
1230
- return `<span class="link-broken">[[${escapeHtml(reference)}]]</span>`;
1231
- /* the full form carries the popup attributes on its kind and
1232
- name, with the kind bold in the accent color and the name
1233
- bold in the regular text color (via the stylesheet) */
1234
- const full = `<strong class="object-kind"${infoAttr(target)}>${escapeHtml(target.kind)}:</strong>` +
1235
- ` <span class="object-name"${specAttr(target)}>${target.name}</span>` +
1236
- " <span class=\"link-symbol\">&#x26AD;</span>";
1237
- return compact ?
1238
- `<a href="#${escapeHtml(anchorOf(target))}" class="link-compact"${specAttr(target)}>${target.name}</a>` :
1239
- `<a href="#${escapeHtml(anchorOf(target))}" class="link-full">${full}</a>`;
1240
- });
1213
+ linker = makeLinker(index);
1241
1214
  /* collect the corpus descriptions of the object instances for the
1242
1215
  description popups (after the linker is in place, as the
1243
1216
  pre-rendered descriptions expand their references, too) */
@@ -1247,6 +1220,8 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1247
1220
  for (const artifact of specification.artifacts)
1248
1221
  collectSpec(artifact.objects, spec);
1249
1222
  }
1223
+ /* the artifact timestamps aggregate into the earliest creation
1224
+ and the latest modification timestamp of the document */
1250
1225
  const created = new Date(Math.min(...specification.artifacts.map((artifact) => artifact.created.getTime())));
1251
1226
  const modified = new Date(Math.max(...specification.artifacts.map((artifact) => artifact.modified.getTime())));
1252
1227
  /* a "META: Title" object becomes the title page and leaves the regular
@@ -1257,7 +1232,8 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1257
1232
  const meta = titleObject(specification);
1258
1233
  const doc = meta !== undefined ? rendered?.get(meta) : undefined;
1259
1234
  try {
1260
- const entries = tocEntries(artifacts.flatMap((artifact) => artifact.objects), tocPages);
1235
+ const objects = artifacts.flatMap((artifact) => artifact.objects);
1236
+ const entries = tocEntries(objects, tocPages);
1261
1237
  return render("Document", { Document: {
1262
1238
  title: documentTitle(specification).title,
1263
1239
  lang,
@@ -1270,7 +1246,7 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1270
1246
  realtime: realtime ? safe(realtimeScript) : "",
1271
1247
  toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
1272
1248
  tocpanel: entries.length > 0 ?
1273
- safe(renderTocPanel(artifacts.flatMap((artifact) => artifact.objects), title !== undefined, doc !== undefined)) : "",
1249
+ safe(renderTocPanel(objects, title !== undefined, doc !== undefined)) : "",
1274
1250
  tocscript: entries.length > 0 ? safe(tocPanelScript) : "",
1275
1251
  doc: doc !== undefined ? safe(render("Doc", { Doc: { diagram: safe(doc) } })) : "",
1276
1252
  artifacts: safe(artifacts.map((artifact) => renderArtifact(artifact)).join(""))
@@ -67,8 +67,9 @@ const renderConciseMd = (object, depth = 0) => {
67
67
  level 4 upwards in the Concise Format -- unless a descendant carries
68
68
  a description no concise item can carry, so all childs (as siblings
69
69
  have to share the format) stay in the Complex Format down to heading
70
- level 6 -- and the optionally derived Gradia diagram spec embedded
71
- as a "gradia" fenced code block below the heading */
70
+ level 6, the deepest one Markdown knows, below which the childs are
71
+ concise regardless -- and the optionally derived Gradia diagram spec
72
+ embedded as a "gradia" fenced code block below the heading */
72
73
  const renderObjectMd = (object, level, diagrams) => {
73
74
  const heading = `${"#".repeat(level)}${" ".repeat(Math.max(1, 4 - level))}` +
74
75
  `${object.kind}: ${object.name}${nameSuffixMd(object)}`;
@@ -82,7 +83,7 @@ const renderObjectMd = (object, level, diagrams) => {
82
83
  parts.push(renderDescriptionMd(object.description));
83
84
  /* the childs share one format, as a concise item following
84
85
  a heading would attach to the heading's object instead */
85
- if (level >= 3 && !complexBelowMd(object))
86
+ if (level >= 6 || (level >= 3 && !complexBelowMd(object)))
86
87
  parts.push(object.childs.map((child) => renderConciseMd(child)).join("\n"));
87
88
  else
88
89
  parts.push(...object.childs.map((child) => renderObjectMd(child, level + 1, diagrams)));
@@ -8,5 +8,5 @@ type Heading = {
8
8
  logo: string;
9
9
  };
10
10
  export declare const requireBrowser: (verbose: Verbose) => Promise<LaunchOptions>;
11
- export declare const htmlToPdf: (renderHtmlPass: (tocPages?: Map<string, number>) => Promise<string>, heading: Heading, outline: OutlineEntry[], verbose: Verbose, css: string, theme: ThemeMapping, paper?: string) => Promise<Buffer>;
11
+ export declare const htmlToPdf: (renderHtmlPass: (tocPages?: Map<string, number>) => Promise<string>, heading: Heading, outline: OutlineEntry[], titlePage: boolean, verbose: Verbose, css: string, theme: ThemeMapping, paper?: string) => Promise<Buffer>;
12
12
  export {};
@@ -75,7 +75,10 @@ const addOutline = async (doc, entries) => {
75
75
  const root = context.nextRef();
76
76
  const top = materialize(items, root);
77
77
  context.assign(root, context.obj({
78
- Type: "Outlines", First: top.first, Last: top.last, Count: items.length
78
+ Type: "Outlines",
79
+ First: top.first,
80
+ Last: top.last,
81
+ Count: items.length
79
82
  }));
80
83
  doc.catalog.set(PDFName.of("Outlines"), root);
81
84
  doc.catalog.set(PDFName.of("PageMode"), PDFName.of("UseOutlines"));
@@ -167,7 +170,7 @@ export const requireBrowser = async (verbose) => {
167
170
  };
168
171
  /* render a self-contained HTML document into a PDF via Playwright,
169
172
  re-rendering the HTML with the discovered ToC page numbers */
170
- export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css, theme, paper = paperSizeDefault) => {
173
+ export const htmlToPdf = async (renderHtmlPass, heading, outline, titlePage, verbose, css, theme, paper = paperSizeDefault) => {
171
174
  const { chromium } = await import("playwright");
172
175
  /* the page geometry of the chosen paper size */
173
176
  const setup = paperSetup(paper);
@@ -195,8 +198,9 @@ export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css,
195
198
  };
196
199
  /* determine the ToC page numbers via a fixpoint iteration:
197
200
  paginate undecorated, extract the per-anchor pages, and
198
- re-render until the HTML is stable, as the ToC page number
199
- column itself can shift the pagination */
201
+ re-render until the HTML is stable (bounded to three
202
+ passes), as the ToC page number column itself can shift
203
+ the pagination */
200
204
  verbose("determining ToC page numbers");
201
205
  let html = await renderHtmlPass();
202
206
  let plain = await renderPdf(html);
@@ -220,11 +224,11 @@ export const htmlToPdf = async (renderHtmlPass, heading, outline, verbose, css,
220
224
  destinations (and hence the internal hyperlinks) intact */
221
225
  const { PDFDocument } = await import("pdf-lib");
222
226
  const merged = await PDFDocument.load(decorated);
223
- if (html.includes("class=\"titlepage\"")) {
227
+ if (titlePage) {
224
228
  const source = await PDFDocument.load(plain);
225
- const [titlePage] = await merged.copyPages(source, [0]);
229
+ const [first] = await merged.copyPages(source, [0]);
226
230
  merged.removePage(0);
227
- merged.insertPage(0, titlePage);
231
+ merged.insertPage(0, first);
228
232
  }
229
233
  /* attach the document outline for the viewer side-bar */
230
234
  verbose("attaching PDF outline (bookmarks)");
@@ -236,7 +236,8 @@ type SchemaGradiaConfig = Partial<{
236
236
 
237
237
  - `SchemaObject.automaton?: SchemaAutomaton`:
238
238
  finite state machine the child objects of every object of this kind form,
239
- BECAUSE a lifecycle is only trustworthy when its dead-ends and livelocks are ruled out
239
+ BECAUSE a lifecycle is only trustworthy when its dead-ends and
240
+ livelocks are ruled out
240
241
 
241
242
  - `SchemaObject.diagram?: SchemaDiagram`:
242
243
  diagram generated for every object of this kind,
@@ -279,11 +280,13 @@ type SchemaGradiaConfig = Partial<{
279
280
 
280
281
  - `SchemaAutomaton.initial: string`:
281
282
  property of a node object flagging (with the value `true`) an initial node,
282
- BECAUSE reachability needs a start (the check is skipped without any initial node)
283
+ BECAUSE reachability needs a start (the check is skipped without any
284
+ initial node)
283
285
 
284
286
  - `SchemaAutomaton.final: string`:
285
287
  property of a node object flagging (with the value `true`) a final node,
286
- BECAUSE resting is legal in a final node only (the livelock check is skipped without any final node)
288
+ BECAUSE resting is legal in a final node only (the livelock check is
289
+ skipped without any final node)
287
290
 
288
291
  - `SchemaDiagram`:
289
292
  diagram derived for every object of an object kind,
@@ -431,7 +434,8 @@ type SchemaGradiaConfig = Partial<{
431
434
  - `SchemaProperty.acyclic?: boolean`:
432
435
  whether following the reference-valued property from object to object
433
436
  must never return to an object already passed (default: `false`),
434
- BECAUSE a hierarchy with a loop has no root and an inclusion with a loop never ends
437
+ BECAUSE a hierarchy with a loop has no root and an inclusion with a
438
+ loop never ends
435
439
 
436
440
  - `SchemaGradiaConfig`:
437
441
  the Gradia rendering options, mirrored from `@rse/gradia`,
@@ -823,8 +827,8 @@ ordered list, a blockquote, or multiple paragraphs) or one carrying a
823
827
  `;` (which a re-parse would split into a spurious property plus a
824
828
  truncated description). All child objects of such an object then stay in
825
829
  the Complex Format (as sibling objects have to share the format), with
826
- headings reaching down to level 6. When **SpecBook** edits existing files, it mirrors the format each
827
- existing object already uses.
830
+ headings reaching down to level 6. When **SpecBook** edits existing
831
+ files, it mirrors the format each existing object already uses.
828
832
 
829
833
  ### Names, Anchors, and Ids
830
834
 
@@ -865,9 +869,9 @@ whole.
865
869
 
866
870
  The description of a Complex Format object is all block content below
867
871
  its heading (up to the next heading) except the property list:
868
- paragraphs, blockquotes, ordered lists (e.g. scenario steps or test case procedures), and
869
- fenced code blocks. A fenced code block
870
- of language `gradia` is skipped, as it is the derived diagram which
872
+ paragraphs, blockquotes, ordered lists (e.g. scenario steps or test
873
+ case procedures), and fenced code blocks. A fenced code block of
874
+ language `gradia` is skipped, as it is the derived diagram which
871
875
  **SpecBook** itself emits into exported Markdown and which must not
872
876
  become authored content on a re-parse. In the Concise Format, the
873
877
  description is formed by the non-property segments of the item, so a