@rse/specbook 1.2.4 → 1.2.5

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.
@@ -43,6 +43,13 @@ const templates = {
43
43
  <span class="search-clear" id="search-clear" title="clear search">&#x00D7;</span>
44
44
  </div>
45
45
  </div>
46
+ <div class="fold-switch">
47
+ <div class="fold-toggle" title="toggle folding controls"><svg class="fold-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3.5l5 5 5-5"/><path d="M7 20.5l5-5 5 5"/></svg></div>
48
+ <div class="fold-controls">
49
+ <div class="fold-diagrams" title="fold/unfold all diagrams"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="5.5" cy="5.5" r="3"/><circle cx="18.5" cy="5.5" r="3"/><circle cx="12" cy="18.5" r="3"/><path d="M7.3 8.1 10.6 15.9"/><path d="M16.7 8.1 13.4 15.9"/></svg></div>
50
+ <div class="fold-texts" title="fold/unfold all cell texts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 7V4.5h15V7"/><path d="M12 4.5v15"/><path d="M8.5 19.5h7"/></svg></div>
51
+ </div>
52
+ </div>
46
53
  <div class="scroll-progress" title="scroll to top"><svg class="scroll-ring" viewBox="0 0 44 44" fill="none" stroke-width="2.5"><circle class="scroll-todo" cx="22" cy="22" r="20"/><circle class="scroll-done" cx="22" cy="22" r="20" stroke-linecap="round" pathLength="1" stroke-dasharray="1" stroke-dashoffset="1"/></svg><svg class="scroll-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg></div>
47
54
  {{ Document.tocpanel }}
48
55
  {{ Document.titlepage }}
@@ -51,6 +58,7 @@ const templates = {
51
58
  {{ Document.artifacts }}
52
59
  {% if Document.search %}<script>{{ Document.search }}</script>{% endif %}
53
60
  <script>{{ Document.progress }}</script>
61
+ <script>{{ Document.fold }}</script>
54
62
  {% if Document.tocpanel %}<script>{{ Document.tocscript }}</script>{% endif %}
55
63
  {% if Document.info %}<script>{{ Document.info }}</script>{% endif %}
56
64
  {% if Document.realtime %}<script class="realtime">{{ Document.realtime }}</script>{% endif %}
@@ -151,7 +159,7 @@ const templates = {
151
159
  `,
152
160
  /* <Properties/> */
153
161
  "Properties": textframe `
154
- <table class="props">
162
+ <table class="props"{% if Fold %} data-fold-height="{{ Fold }}"{% endif %}>
155
163
  {% for property in Properties %}
156
164
  <tr><td class="key property-name"><span{% if property.info %} data-info="{{ property.info }}" data-info-path="{{ property.infopath }}" data-info-prop="{{ property.key }}"{% endif %}>{{ property.key }}</span></td><td>{{ property.value }}</td></tr>
157
165
  {% endfor %}
@@ -172,7 +180,7 @@ const templates = {
172
180
  `,
173
181
  /* <Table/> */
174
182
  "Table": textframe `
175
- <table class="objects">
183
+ <table class="objects"{% if Table.fold %} data-fold-height="{{ Table.fold }}"{% endif %}>
176
184
  <thead>
177
185
  <tr>
178
186
  <th class="object-kind"><span{% if Table.info %} data-info="{{ Table.info }}" data-info-path="{{ Table.infopath }}"{% endif %}>{{ Table.head }}</span></th>
@@ -193,7 +201,7 @@ const templates = {
193
201
  `,
194
202
  /* <TableChunked/> */
195
203
  "TableChunked": textframe `
196
- <table class="objects">
204
+ <table class="objects"{% if Table.fold %} data-fold-height="{{ Table.fold }}"{% endif %}>
197
205
  <thead>
198
206
  <tr>
199
207
  <th class="object-kind" style="width: {{ Table.width }}%"><span{% if Table.info %} data-info="{{ Table.info }}" data-info-path="{{ Table.infopath }}"{% endif %}>{{ Table.head }}</span></th>
@@ -382,6 +390,255 @@ const scrollProgressScript = textframe `
382
390
  update()
383
391
  })()
384
392
  `;
393
+ /* the client-side script of the folding: it wraps every diagram of the
394
+ content into a fold container carrying a chevron mark at its top
395
+ left corner, folds the
396
+ text of every table cell towering over the other cells of its row
397
+ behind a chevron mark of its own, and lets the two
398
+ controls of the fold tab fold and unfold all diagrams and all cell
399
+ texts at once, with their state persisted across page loads and
400
+ their icons (plus the tab icon) marked while anything of their kind
401
+ is folded. The script
402
+ runs at the end of the body, as the content it wraps has to exist
403
+ already, and a live preview body swap replaces the containers and
404
+ their listeners along with the body */
405
+ const foldScript = textframe `
406
+ (function () {
407
+ const tab = document.querySelector("div.fold-switch")
408
+ if (tab === null)
409
+ return
410
+ const controls = { diagram: tab.querySelector("div.fold-diagrams"),
411
+ text: tab.querySelector("div.fold-texts") }
412
+
413
+ /* let the fold icon slide the two controls out of the tab (and
414
+ back in again), remembering the choice across page loads */
415
+ try { if (localStorage.getItem("specbook-fold") === "open") tab.classList.add("open") }
416
+ catch { /* an inaccessible storage just means no stored state */ }
417
+ tab.querySelector("div.fold-toggle").addEventListener("click", () => {
418
+ tab.classList.toggle("open")
419
+ try { localStorage.setItem("specbook-fold", tab.classList.contains("open") ? "open" : "closed") }
420
+ catch { /* an inaccessible storage just loses the state */ }
421
+ })
422
+
423
+ /* the default percentage by which the text of a table cell may
424
+ exceed the height of every other cell of its row before it
425
+ folds, overridden per object kind by the "data-fold-height"
426
+ attribute the schema configuration puts onto its table */
427
+ const cellHeight = 40
428
+
429
+ /* the chevron mark of a foldable element, rotated by the
430
+ stylesheet while the element is folded */
431
+ const svgNS = "http://www.w3.org/2000/svg"
432
+ const mark = (name, title) => {
433
+ const span = document.createElement("span")
434
+ span.className = name
435
+ span.title = title
436
+ const svg = document.createElementNS(svgNS, "svg")
437
+ svg.setAttribute("viewBox", "0 0 24 24")
438
+ svg.setAttribute("fill", "none")
439
+ svg.setAttribute("stroke", "currentColor")
440
+ svg.setAttribute("stroke-width", "2.5")
441
+ svg.setAttribute("stroke-linecap", "round")
442
+ svg.setAttribute("stroke-linejoin", "round")
443
+ const path = document.createElementNS(svgNS, "path")
444
+ path.setAttribute("d", "M5 9l7 7 7-7")
445
+ svg.appendChild(path)
446
+ span.appendChild(svg)
447
+ return span
448
+ }
449
+
450
+ /* wrap every diagram into its fold container, carrying the
451
+ chevron mark plus the muted placeholder the diagram leaves
452
+ behind while folded, which is the very icon of its tab
453
+ control */
454
+ const folds = { diagram: [], text: [] }
455
+ document.querySelectorAll("article div.diagram, nav.doc div.diagram").forEach((el) => {
456
+ const fold = document.createElement("div")
457
+ fold.className = "fold"
458
+ el.parentNode.insertBefore(fold, el)
459
+ const chevron = mark("fold-mark", "fold/unfold this diagram")
460
+ const label = document.createElement("span")
461
+ label.className = "fold-label"
462
+ label.appendChild(controls.diagram.querySelector("svg").cloneNode(true))
463
+ fold.append(chevron, label, el)
464
+ chevron.addEventListener("click", () => {
465
+ fold.classList.toggle("folded")
466
+ sync()
467
+ })
468
+ folds.diagram.push(fold)
469
+ })
470
+
471
+ /* the rendered height of the content of a table cell, or of its
472
+ leading part up to a word position: a cell box itself always
473
+ spans the whole row, so only a range over the content
474
+ measures the text */
475
+ const range = document.createRange()
476
+ const height = (cell, upto) => {
477
+ if (upto === undefined)
478
+ range.selectNodeContents(cell)
479
+ else {
480
+ range.setStart(cell, 0)
481
+ range.setEnd(upto.node, upto.offset)
482
+ }
483
+ return range.getBoundingClientRect().height
484
+ }
485
+
486
+ /* the word positions of a table cell, each right behind one word */
487
+ const positions = (cell) => {
488
+ const walker = document.createTreeWalker(cell, NodeFilter.SHOW_TEXT)
489
+ const list = []
490
+ let node
491
+ while ((node = walker.nextNode()) !== null) {
492
+ const regex = /\\S+/g
493
+ let m
494
+ while ((m = regex.exec(node.nodeValue ?? "")) !== null)
495
+ list.push({ node, offset: m.index + m[0].length })
496
+ }
497
+ return list
498
+ }
499
+
500
+ /* a cell taking part in the comparison of its row: one with
501
+ content of its own, i.e. neither empty (a not given property
502
+ draws its marker through the stylesheet alone) nor holding
503
+ further cells (a chunked or nested table, whose own cells are
504
+ compared instead) nor a diagram (which folds on its own and
505
+ whose SVG text cannot carry the wrapper of a hidden
506
+ remainder) */
507
+ const candidate = (cell) =>
508
+ cell.querySelector("td") === null
509
+ && cell.querySelector("div.diagram") === null
510
+ && (cell.textContent.trim() !== "" || cell.querySelector("img") !== null)
511
+
512
+ /* decide which table cells fold: one which is taller than the
513
+ configured percentage above every other cell of its row (the
514
+ leading name column of a compact table excluded, as it is no
515
+ text of its own). All rows are measured before the first cut,
516
+ as every cut shifts the layout of the ones still to come */
517
+ const cuts = []
518
+ document.querySelectorAll("article table tr").forEach((row) => {
519
+ const table = row.closest("table")
520
+ let cells = Array.from(row.children).filter((el) => el.tagName === "TD")
521
+ if (!table.classList.contains("chunks"))
522
+ cells = cells.slice(1)
523
+ cells = cells.filter(candidate)
524
+ if (cells.length < 2)
525
+ return
526
+ const configured = row.closest("table[data-fold-height]")
527
+ const percent = Number(configured?.getAttribute("data-fold-height")) || cellHeight
528
+ const heights = cells.map((cell) => height(cell))
529
+ cells.forEach((cell, i) => {
530
+ const other = Math.max(...heights.filter((_, j) => j !== i))
531
+ const limit = other * (1 + percent / 100)
532
+ if (heights[i] > limit)
533
+ cuts.push({ cell, limit, full: heights[i] })
534
+ })
535
+ })
536
+
537
+ /* hide a node of the folded remainder of a table cell: an
538
+ element carries the class itself, a text node gets wrapped */
539
+ const rest = (node) => {
540
+ if (node.nodeType === Node.ELEMENT_NODE)
541
+ node.classList.add("fold-rest")
542
+ else if (node.nodeType === Node.TEXT_NODE) {
543
+ const span = document.createElement("span")
544
+ span.className = "fold-rest"
545
+ node.parentNode.insertBefore(span, node)
546
+ span.appendChild(node)
547
+ }
548
+ }
549
+
550
+ /* the minimum percentage of its own height a cell has to gain
551
+ by folding, as hiding less than that is no visible relief
552
+ and only costs the reader a chevron to click (measured after
553
+ the cut, as the chevron itself can claim a line of its own) */
554
+ const cellGain = 25
555
+
556
+ /* fold the decided cells back onto their height limit: the last
557
+ word position still fitting is found by a binary search over
558
+ the measured word positions, the text node is split there and
559
+ the chevron mark takes its place, followed by the remainder
560
+ hidden up the ancestor chain of the cell */
561
+ for (const cut of cuts) {
562
+ const list = positions(cut.cell)
563
+ if (list.length === 0)
564
+ continue
565
+ let lo = 0
566
+ let hi = list.length - 1
567
+ while (lo < hi) {
568
+ const mid = Math.ceil((lo + hi) / 2)
569
+ if (height(cut.cell, list[mid]) <= cut.limit)
570
+ lo = mid
571
+ else
572
+ hi = mid - 1
573
+ }
574
+ const tail = list[lo].node.splitText(list[lo].offset)
575
+ const chevron = mark("fold-more", "fold/unfold the remaining text")
576
+ tail.parentNode.insertBefore(chevron, tail)
577
+ let current = chevron
578
+ while (current !== cut.cell) {
579
+ let next = current.nextSibling
580
+ while (next !== null) {
581
+ const following = next.nextSibling
582
+ rest(next)
583
+ next = following
584
+ }
585
+ current = current.parentNode
586
+ }
587
+ /* the gain is measured on the applied fold, as only that
588
+ one tells the real one, and a fold gaining less than the
589
+ minimum share is taken back again, which the chevron
590
+ alone has to leave behind, as the hidden remainder stays
591
+ inert without the class -- which a kept fold sheds
592
+ again, too, as every cell starts out unfolded */
593
+ cut.cell.classList.add("folded")
594
+ const gained = height(cut.cell) <= cut.full * (1 - cellGain / 100)
595
+ cut.cell.classList.remove("folded")
596
+ if (!gained) {
597
+ chevron.remove()
598
+ continue
599
+ }
600
+ folds.text.push(cut.cell)
601
+ chevron.addEventListener("click", (event) => {
602
+ /* the cut can land inside a hyperlink, whose navigation
603
+ the chevron has to suppress for its own click alone,
604
+ so the remaining link text still follows the link */
605
+ event.preventDefault()
606
+ cut.cell.classList.toggle("folded")
607
+ sync()
608
+ })
609
+ }
610
+
611
+ /* mark the control of a kind while anything of that kind is
612
+ folded, and the tab itself while any kind at all is */
613
+ const sync = () => {
614
+ let any = false
615
+ for (const kind of Object.keys(folds)) {
616
+ const folded = folds[kind].some((fold) => fold.classList.contains("folded"))
617
+ controls[kind].classList.toggle("folded", folded)
618
+ any ||= folded
619
+ }
620
+ tab.classList.toggle("folded", any)
621
+ }
622
+ for (const kind of Object.keys(folds)) {
623
+ /* without a stored state the rendered one stands, which is
624
+ everything unfolded */
625
+ let stored = null
626
+ try { stored = localStorage.getItem("specbook-fold-" + kind) }
627
+ catch { /* an inaccessible storage just means no stored state */ }
628
+ if (stored !== null)
629
+ folds[kind].forEach((fold) => { fold.classList.toggle("folded", stored === "folded") })
630
+ controls[kind].addEventListener("click", () => {
631
+ const all = !folds[kind].every((fold) => fold.classList.contains("folded"))
632
+ folds[kind].forEach((fold) => { fold.classList.toggle("folded", all) })
633
+ try { localStorage.setItem("specbook-fold-" + kind, all ? "folded" : "unfolded") }
634
+ catch { /* an inaccessible storage just loses the state */ }
635
+ sync()
636
+ })
637
+ }
638
+ sync()
639
+
640
+ })()
641
+ `;
385
642
  /* the client-side script of the table of contents side panel: it
386
643
  slides the panel out of and back into the viewport edge (closing it
387
644
  also on a jump, on "Escape", and on a click outside), remembers the
@@ -470,6 +727,7 @@ const tocPanelScript = textframe `
470
727
  && event.target.closest("div.search") === null
471
728
  && event.target.closest("div.theme-switch") === null
472
729
  && event.target.closest("div.info-switch") === null
730
+ && event.target.closest("div.fold-switch") === null
473
731
  && event.target.closest("div.realtime-status") === null)
474
732
  toggle(false)
475
733
  }
@@ -1051,6 +1309,7 @@ const renderTable = (children, maxColumns) => {
1051
1309
  infopath: infoPathOf(children[0], false),
1052
1310
  keys,
1053
1311
  desc,
1312
+ fold: formatOf(children[0])?.maxCellHeight,
1054
1313
  /* under the fixed table layout the description column claims
1055
1314
  twice the share of a regular column, compressing the others */
1056
1315
  width: Math.round(200 / (keys.length + 3)),
@@ -1074,6 +1333,7 @@ const renderTable = (children, maxColumns) => {
1074
1333
  head: children[0].kind !== "" ? children[0].kind : "Name",
1075
1334
  info: infoKeyOf(children[0]),
1076
1335
  infopath: infoPathOf(children[0], false),
1336
+ fold: formatOf(children[0])?.maxCellHeight,
1077
1337
  width: Math.round(100 / maxColumns),
1078
1338
  rows: children.map((child, i) => scoped(child, () => {
1079
1339
  const cells = keys.map((key) => ({ key, desc: false, span: 1,
@@ -1122,7 +1382,8 @@ const renderObject = (object, level, concise) => {
1122
1382
  name: inline(object.name),
1123
1383
  diagram: diagramOf(object),
1124
1384
  properties: properties.length > 0 ?
1125
- safe(render("Properties", { Properties: inlineProperties(object, properties) })) : "",
1385
+ safe(render("Properties", { Properties: inlineProperties(object, properties),
1386
+ Fold: formatOf(object)?.maxCellHeight })) : "",
1126
1387
  description: object.description !== undefined ?
1127
1388
  safe(renderDescription(object.description)) : "",
1128
1389
  children: safe(renderChildren(object, level + 1, concise))
@@ -1357,6 +1618,7 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
1357
1618
  safe(renderTitlePage(title, formatDate(created), formatDate(modified))) : "",
1358
1619
  search: safe(searchScript()),
1359
1620
  progress: safe(scrollProgressScript),
1621
+ fold: safe(foldScript),
1360
1622
  info: info !== null ? safe(infoScript(info, spec ?? {})) : "",
1361
1623
  realtime: realtime ? safe(realtimeScript) : "",
1362
1624
  toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
@@ -133,11 +133,36 @@ const drawBrandBar = async (doc, accent) => {
133
133
  Chrome has to actually launch it and as the export preflight and the
134
134
  renderer both ask for the very same browser */
135
135
  let browserOptions;
136
- /* resolve the browser printing the PDF: the downloaded Playwright
137
- Chromium is a plain file check, while a system-installed Google
138
- Chrome is only detectable by launching it */
136
+ /* the remedy of an unavailable Playwright Chromium, shared by the
137
+ fallback notice and the missing-browser error, as both have to point
138
+ at the very same one-time command */
139
+ const chromiumRemedy = `run "${literal("npx playwright install chromium")}" once to download the Playwright Chromium`;
140
+ /* resolve the browser printing the PDF: an explicitly configured one
141
+ wins, then the downloaded Playwright Chromium as a plain file check,
142
+ while a system-installed Google Chrome is only detectable by
143
+ launching it */
139
144
  const resolveBrowser = async (verbose) => {
140
145
  const { chromium } = await import("playwright");
146
+ /* an explicitly configured browser is taken as an executable path
147
+ once it carries a path separator and as a Playwright channel
148
+ name otherwise, and it never falls back onto another browser, as
149
+ an explicit choice has to be either honored or reported */
150
+ const configured = process.env.SPECBOOK_BROWSER ?? "";
151
+ if (configured !== "") {
152
+ const options = /[\\/]/.test(configured) ?
153
+ { executablePath: configured } : { channel: configured };
154
+ try {
155
+ const browser = await chromium.launch(options);
156
+ await browser.close();
157
+ }
158
+ catch (err) {
159
+ throw new Error(`the browser "${literal(configured)}" configured by ` +
160
+ `"${literal("SPECBOOK_BROWSER")}" failed to launch`, { cause: err });
161
+ }
162
+ verbose(`using the browser "${literal(configured)}" configured by ` +
163
+ `"${literal("SPECBOOK_BROWSER")}"`);
164
+ return options;
165
+ }
141
166
  let executable = "";
142
167
  try {
143
168
  executable = chromium.executablePath();
@@ -151,7 +176,7 @@ const resolveBrowser = async (verbose) => {
151
176
  const chrome = await chromium.launch({ channel: "chrome" });
152
177
  await chrome.close();
153
178
  verbose("Playwright Chromium unavailable -- falling back to the " +
154
- "system-installed Google Chrome", "notice");
179
+ `system-installed Google Chrome (${chromiumRemedy})`, "notice");
155
180
  return { channel: "chrome" };
156
181
  }
157
182
  catch {
@@ -166,8 +191,7 @@ export const requireBrowser = async (verbose) => {
166
191
  const options = await browserOptions;
167
192
  if (options === undefined)
168
193
  throw new Error("the PDF export requires a Chromium-class browser, but neither the " +
169
- "Playwright Chromium nor a system-installed Google Chrome was found -- run " +
170
- `"${literal("npx playwright install chromium")}" once to download the Playwright Chromium`);
194
+ `Playwright Chromium nor a system-installed Google Chrome was found -- ${chromiumRemedy}`);
171
195
  return options;
172
196
  };
173
197
  /* the bound of the ToC page number fixpoint iteration, as the page
@@ -37,6 +37,13 @@ export type SchemaDiagramCenterEdges = {
37
37
  both?: string;
38
38
  labeled?: string;
39
39
  };
40
+ export type SchemaDiagramNest = {
41
+ properties?: string[];
42
+ parent?: boolean;
43
+ kind?: boolean;
44
+ layout?: "graph" | "hub" | "grid";
45
+ crossing?: "nodes" | "target" | "both";
46
+ };
40
47
  export type SchemaDiagram = {
41
48
  type?: "graph" | "hub" | "grid";
42
49
  nodes?: string;
@@ -53,12 +60,15 @@ export type SchemaDiagram = {
53
60
  onlyConnected?: boolean;
54
61
  collapse?: boolean;
55
62
  qualified?: boolean;
63
+ ordered?: boolean;
64
+ nest?: SchemaDiagramNest;
56
65
  properties?: string[];
57
66
  config?: Partial<GradiaConfig>;
58
67
  };
59
68
  export type SchemaFormat = {
60
69
  type?: "auto" | "complex" | "concise";
61
70
  maxTableColumns?: number;
71
+ maxCellHeight?: number;
62
72
  withUnusedProps?: boolean;
63
73
  };
64
74
  export type SchemaProperty = {
@@ -50,6 +50,13 @@ const SchemaDiagramCenterEdges = v.strictObject({
50
50
  both: v.optional(v.string()),
51
51
  labeled: v.optional(v.string())
52
52
  });
53
+ const SchemaDiagramNest = v.strictObject({
54
+ properties: v.optional(v.array(v.string())),
55
+ parent: v.optional(v.boolean()),
56
+ kind: v.optional(v.boolean()),
57
+ layout: v.optional(v.picklist(["graph", "hub", "grid"])),
58
+ crossing: v.optional(v.picklist(["nodes", "target", "both"]))
59
+ });
53
60
  const SchemaDiagram = v.strictObject({
54
61
  type: v.optional(v.picklist(["graph", "hub", "grid"])),
55
62
  nodes: v.optional(v.string()),
@@ -66,12 +73,15 @@ const SchemaDiagram = v.strictObject({
66
73
  onlyConnected: v.optional(v.boolean()),
67
74
  collapse: v.optional(v.boolean()),
68
75
  qualified: v.optional(v.boolean()),
76
+ ordered: v.optional(v.boolean()),
77
+ nest: v.optional(SchemaDiagramNest),
69
78
  properties: v.optional(v.array(v.string())),
70
79
  config: v.optional(SchemaDiagramConfig)
71
80
  });
72
81
  const SchemaFormat = v.strictObject({
73
82
  type: v.optional(v.picklist(["auto", "complex", "concise"])),
74
83
  maxTableColumns: v.optional(v.pipe(v.number(), v.integer(), v.minValue(2))),
84
+ maxCellHeight: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
75
85
  withUnusedProps: v.optional(v.boolean())
76
86
  });
77
87
  const SchemaObject = v.strictObject({
@@ -123,6 +123,7 @@ type SchemaDiagram = {
123
123
  type SchemaFormat = {
124
124
  type?: "auto" | "complex" | "concise"
125
125
  maxTableColumns?: number
126
+ maxCellHeight?: number
126
127
  withUnusedProps?: boolean
127
128
  }
128
129
  type SchemaProperty = {
@@ -350,7 +351,7 @@ type SchemaGradiaConfig = Partial<{
350
351
 
351
352
  - `SchemaDiagram.deep?: boolean`:
352
353
  whether the references of the descendants of a node count as its own,
353
- lifted to their nearest node and counted as the arity (default: `false`),
354
+ counted as the arity (default: `false`),
354
355
  BECAUSE the relations between coarse objects live in their fine parts
355
356
 
356
357
  - `SchemaDiagram.onlyConnected?: boolean`:
@@ -365,6 +366,40 @@ type SchemaGradiaConfig = Partial<{
365
366
  whether every node is labeled with its kind (default: `false`),
366
367
  BECAUSE names alone are ambiguous across multiple object kinds
367
368
 
369
+ - `SchemaDiagram.ordered?: boolean`:
370
+ whether the nodes are placed in document order instead of by the
371
+ layered layout, the top-level ones stacked top-down and the members
372
+ of a container lined up left-to-right (default: `false`),
373
+ BECAUSE in a coarse-to-fine specification the references then all
374
+ point backward, i.e. upward
375
+
376
+ - `SchemaDiagram.nest?: SchemaDiagramNest`:
377
+ nesting of the nodes into container nodes,
378
+ BECAUSE a containment drawn as a box beats one drawn as an edge
379
+
380
+ - `SchemaDiagramNest.properties?: string[]`:
381
+ properties whose single referenced object is the container of a node,
382
+ BECAUSE a `PART-OF` or `TIER` reference is a containment, not a relation
383
+
384
+ - `SchemaDiagramNest.parent?: boolean`:
385
+ whether the parent object is the container of a node (default: `false`),
386
+ BECAUSE the nesting of the objects is a containment, too
387
+
388
+ - `SchemaDiagramNest.kind?: boolean`:
389
+ whether a synthetic container per object kind holds the nodes
390
+ (default: `false`),
391
+ BECAUSE a kind is a grouping no object models
392
+
393
+ - `SchemaDiagramNest.layout?: "graph" | "hub" | "grid"`:
394
+ shape the members of every container are laid out with
395
+ (default: the diagram type),
396
+ BECAUSE the inside of a container may tell a different story than the whole
397
+
398
+ - `SchemaDiagramNest.crossing?: "nodes" | "target" | "both"`:
399
+ ends of a boundary-crossing edge lifted onto the containers
400
+ (default: `nodes`, i.e. none),
401
+ BECAUSE a bundle of references into a container reads as one edge onto it
402
+
368
403
  - `SchemaDiagram.properties?: string[]`:
369
404
  property names attached to the nodes as key/value annotations,
370
405
  BECAUSE the node name alone rarely makes the nodes comparable
@@ -385,6 +420,10 @@ type SchemaGradiaConfig = Partial<{
385
420
  maximum column count of the table of the kind (default: `4`),
386
421
  BECAUSE the fixed print/PDF page width bounds the table width
387
422
 
423
+ - `SchemaFormat.maxCellHeight?: number`:
424
+ percent a table cell may tower over its row before it folds (default: `40`),
425
+ BECAUSE a cell of rich prose otherwise dwarfs the whole table
426
+
388
427
  - `SchemaFormat.withUnusedProps?: boolean`:
389
428
  whether unused properties still render (default: `false`),
390
429
  BECAUSE empty entries are noise, unless the gap is the message
@@ -474,9 +513,10 @@ type SchemaGradiaConfig = Partial<{
474
513
 
475
514
  The edges are derived from the `[[xxx]]` references of the node objects
476
515
  -- from their property values only, or, with `links: all`, from their
477
- descriptions as well. With `deep: true` the references of all
478
- descendants of a node object count as its own, every referenced object
479
- is lifted to its nearest ancestor-or-self within the node set, and the
516
+ descriptions as well. Every referenced object is lifted to its nearest
517
+ ancestor-or-self within the node set, so a reference onto the scenario
518
+ of a use case yields an edge onto the use case. With `deep: true` the
519
+ references of all descendants of a node object count as its own and the
480
520
  number of references behind an edge becomes its arity, so a diagram of
481
521
  coarse objects (like the artifacts of a whole specification) shows the
482
522
  relations living in their fine-grained parts. With `labeled: true` an
@@ -527,6 +567,34 @@ nodes as key/value annotations (with every `[[xxx]]` reference stripped
527
567
  to its target object name), and `config` passes arbitrary Gradia
528
568
  rendering options (e.g. `grid-columns-max: 5`) through to the diagram.
529
569
 
570
+ `ordered` replaces the layered layout of a `graph` by the document
571
+ order of the objects: the top-level nodes stack top-down in that order
572
+ (a synthetic container at the position of its first member), while the
573
+ members of a container line up left-to-right in it, wrapped by the
574
+ Gradia option `graph-columns-max`, so the references of a coarse-to-fine
575
+ specification all point backward, i.e. upward or leftward.
576
+
577
+ `nest` nests the nodes into *container* nodes, drawn as boxes around
578
+ their members with the edges crossing the box borders: the first of its
579
+ `properties` carrying exactly one resolvable reference names the
580
+ container of a node (a container outside the node set is added to it,
581
+ labeled and linked like a node, and nested on its own in turn, so a
582
+ `NODE` sits in its `TIER` and the tier in the tier it is `PART-OF`),
583
+ else with `parent: true` the parent object is the container when part
584
+ of the node set, else with `kind: true` a synthetic container per object
585
+ kind is. An edge between a container and one of its own members is
586
+ dropped, as the nesting already shows it, `layout` selects the shape
587
+ the members of every container are laid out with (default: the diagram
588
+ type), a `hub` carries no containers, as every container would need a
589
+ primary node of its own, and a node nested into itself or into a cycle
590
+ is reported. With `crossing: target` an edge crossing a container
591
+ boundary ends at the outermost container of its target not enclosing
592
+ its source, too, and with `crossing: both` it also starts at the
593
+ corresponding container of its source, where the edges coinciding
594
+ afterwards merge into one with their arities summed, so the edges
595
+ inside a container stay node-to-node while the bundles of references
596
+ into a container become single edges onto it.
597
+
530
598
  ### Format
531
599
 
532
600
  An object kind can furthermore carry a `format` field, which controls
@@ -542,7 +610,10 @@ Inside a `concise` table, unconfigured child kinds implicitly stay
542
610
  an explicitly configured `type` is always honored, even a `complex`
543
611
  rendering pressed into a cell. `maxTableColumns` (default: `4`) bounds
544
612
  the columns of the compact table of the kind (a wider group chunks
545
- its properties into embedded per-object tables), and `withUnusedProps`
613
+ its properties into embedded per-object tables), `maxCellHeight`
614
+ (default: `40`) is the percentage a table cell of the kind may exceed
615
+ the height of every other non-empty cell of its row before the HTML
616
+ export folds its remaining text away, and `withUnusedProps`
546
617
  (default: `false`) unconditionally renders the defined but unused
547
618
  properties of that kind, as property table lines or table columns. In
548
619
  the HTML/PDF export, a property absent from an object shows an "empty