@rse/specbook 1.5.2 → 1.6.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.
Files changed (63) hide show
  1. package/LICENSE.txt +201 -0
  2. package/README.md +288 -43
  3. package/dst/specbook-api.d.ts +23 -3
  4. package/dst/specbook-api.js +133 -59
  5. package/dst/specbook-cli.js +93 -26
  6. package/dst/specbook-cmd-describe.js +4 -4
  7. package/dst/specbook-cmd-export-schema.d.ts +8 -0
  8. package/dst/specbook-cmd-export-schema.js +441 -0
  9. package/dst/specbook-cmd-export.js +23 -6
  10. package/dst/specbook-cmd-init-sample.d.ts +4 -0
  11. package/dst/specbook-cmd-init-sample.js +322 -0
  12. package/dst/specbook-cmd-init.d.ts +1 -0
  13. package/dst/specbook-cmd-init.js +42 -10
  14. package/dst/specbook-cmd-lint.js +46 -48
  15. package/dst/specbook-cmd-query.d.ts +30 -0
  16. package/dst/specbook-cmd-query.js +165 -0
  17. package/dst/specbook-config.d.ts +9 -1
  18. package/dst/specbook-config.js +135 -10
  19. package/dst/specbook-coverage.js +2 -2
  20. package/dst/specbook-diagram.d.ts +7 -5
  21. package/dst/specbook-diagram.js +546 -143
  22. package/dst/specbook-export-ast.d.ts +1 -1
  23. package/dst/specbook-export-ast.js +11 -8
  24. package/dst/specbook-export-code.js +3 -3
  25. package/dst/specbook-export-common.d.ts +1 -1
  26. package/dst/specbook-export-common.js +3 -2
  27. package/dst/specbook-export-diagram.d.ts +1 -1
  28. package/dst/specbook-export-diagram.js +25 -7
  29. package/dst/specbook-export-html-fold.d.ts +16 -6
  30. package/dst/specbook-export-html-fold.js +65 -44
  31. package/dst/specbook-export-html-render.js +107 -26
  32. package/dst/specbook-export-html-search.js +18 -20
  33. package/dst/specbook-export-html-template.js +38 -9
  34. package/dst/specbook-export-html-widget.d.ts +2 -0
  35. package/dst/specbook-export-html-widget.js +219 -19
  36. package/dst/specbook-export-html.css +208 -38
  37. package/dst/specbook-export-html.js +96 -53
  38. package/dst/specbook-export-md.d.ts +2 -1
  39. package/dst/specbook-export-md.js +7 -8
  40. package/dst/specbook-export-pdf.js +1 -1
  41. package/dst/specbook-files.d.ts +2 -0
  42. package/dst/specbook-files.js +41 -0
  43. package/dst/specbook-format-meta.yaml +401 -0
  44. package/dst/specbook-format-pm.yaml +1240 -0
  45. package/dst/specbook-format-schema.d.ts +33 -3
  46. package/dst/specbook-format-schema.js +49 -3
  47. package/dst/specbook-format.md +427 -47
  48. package/dst/specbook-format.yaml +524 -228
  49. package/dst/specbook-ignore.js +2 -2
  50. package/dst/specbook-link.d.ts +4 -0
  51. package/dst/specbook-link.js +61 -6
  52. package/dst/specbook-mcp.js +103 -19
  53. package/dst/specbook-parse-semantic.d.ts +1 -0
  54. package/dst/specbook-parse-semantic.js +11 -4
  55. package/dst/specbook-parse-syntax.d.ts +1 -0
  56. package/dst/specbook-parse-syntax.js +1 -1
  57. package/dst/specbook-parse.d.ts +1 -1
  58. package/dst/specbook-parse.js +3 -3
  59. package/dst/specbook-project.d.ts +2 -0
  60. package/dst/specbook-project.js +15 -5
  61. package/dst/specbook-remote.d.ts +28 -0
  62. package/dst/specbook-remote.js +429 -0
  63. package/package.json +32 -20
@@ -2,4 +2,4 @@ import type { Spec } from "./specbook-format-spec.js";
2
2
  import type { Schema } from "./specbook-format-schema.js";
3
3
  import type { Verbose } from "./specbook-verbose.js";
4
4
  export type AstFormat = "json" | "json5" | "yaml" | "toon";
5
- export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:code" | "diagram:image" | "diagram:listing" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Buffer>;
5
+ export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:state" | "diagram:layer" | "diagram:slice" | "diagram:tree" | "diagram:matrix" | "diagram:sequence" | "diagram:petri" | "diagram:gantt" | "diagram:code" | "diagram:image" | "diagram:listing" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Buffer>;
@@ -39,11 +39,12 @@ export const renderAst = async (specification, format, config, verbose, omit = n
39
39
  return sources.get(diagramKey(diagram.language, diagram.source))?.light ?? "";
40
40
  })));
41
41
  /* attach the Gradia specs of the diagram-configured objects as
42
- "diagram" fields onto the corresponding plain object nodes
43
- (an invalid diagram situation omits the diagram, as it is
44
- already reported as a lint diagnostic, the diagram of the
45
- title object is reserved for the HTML/PDF export, and the
46
- diagrams of the omitted aspects are left out), and
42
+ "diagram" fields onto the corresponding plain object nodes (a
43
+ single spec as a string, several ones as a list of strings; an
44
+ invalid diagram situation omits the diagram, as it is already
45
+ reported as a lint diagnostic, the diagram of the title object
46
+ is reserved for the HTML/PDF export, and the diagrams of the
47
+ omitted aspects are left out), and
47
48
  the reference coverage of the coverage-configured objects as
48
49
  "coverage" fields (the counts only, as the export is intended
49
50
  for machine consumption) */
@@ -52,9 +53,11 @@ export const renderAst = async (specification, format, config, verbose, omit = n
52
53
  const omitted = omittedDiagrams(specification, config, omit);
53
54
  const coverages = specCoverage(buildLinkIndex(specification), collectSchemas(specification, config));
54
55
  const walk = (object, node) => {
55
- const result = diagrams.get(object);
56
- if (result?.spec !== undefined && !isTitleObject(object) && !omitted.has(object))
57
- node.diagram = result.spec;
56
+ const specs = isTitleObject(object) ? [] : (diagrams.get(object) ?? [])
57
+ .filter((result) => !omitted.has(result))
58
+ .flatMap((result) => result.spec !== undefined ? [result.spec] : []);
59
+ if (specs.length > 0)
60
+ node.diagram = specs.length === 1 ? specs[0] : specs;
58
61
  const coverage = coverages.get(object);
59
62
  if (coverage !== undefined)
60
63
  node.coverage = coverage.map(({ pattern, covered, uncovered }) => ({ pattern, covered: covered.length, total: covered.length + uncovered.length }));
@@ -101,7 +101,7 @@ const renderListing = (shiki, listing) => {
101
101
  descendants: the fenced code blocks of a known language inside its
102
102
  description statement and elaboration and the source code files
103
103
  embedded into its description and property values */
104
- const collect = (object, langs) => {
104
+ const collectLanguages = (object, langs) => {
105
105
  for (const text of [object.description?.description ?? "", object.description?.elaboration ?? ""])
106
106
  marked.walkTokens(marked.lexer(text), (token) => {
107
107
  const listing = token.type === "code" ? fencedListing(token) : undefined;
@@ -116,7 +116,7 @@ const collect = (object, langs) => {
116
116
  langs.add(listing.lang);
117
117
  }
118
118
  for (const child of object.children)
119
- collect(child, langs);
119
+ collectLanguages(child, langs);
120
120
  };
121
121
  /* prepare the rendering of the code listings of a specification: the
122
122
  syntax highlighter is loaded (on first use) together with the grammars
@@ -127,7 +127,7 @@ export const prepareListings = async (specification, verbose) => {
127
127
  const langs = new Set();
128
128
  for (const artifact of specification.artifacts)
129
129
  for (const object of artifact.objects)
130
- collect(object, langs);
130
+ collectLanguages(object, langs);
131
131
  if (langs.size === 0)
132
132
  return (listing) => renderListing(null, listing);
133
133
  let shiki = null;
@@ -4,7 +4,7 @@ export declare const escapeHtml: (text: string) => string;
4
4
  export declare const stylesheet: () => string;
5
5
  export declare const searchScript: () => string;
6
6
  export declare const fallbackLogo: (theme: (typeof embeddingThemes)[number]) => string;
7
- export declare const omitAspects: readonly ["diagram:graph", "diagram:hub", "diagram:grid", "diagram:code", "diagram:image", "diagram:listing", "diagram:1", "diagram:2", "diagram:3", "text:long"];
7
+ export declare const omitAspects: readonly ["diagram:graph", "diagram:hub", "diagram:grid", "diagram:state", "diagram:layer", "diagram:slice", "diagram:tree", "diagram:matrix", "diagram:sequence", "diagram:petri", "diagram:gantt", "diagram:code", "diagram:image", "diagram:listing", "diagram:1", "diagram:2", "diagram:3", "text:long"];
8
8
  export type OmitAspect = typeof omitAspects[number];
9
9
  export declare const parseOmit: (omit?: string[]) => Set<OmitAspect>;
10
10
  export interface ExportOptions {
@@ -25,8 +25,9 @@ export const fallbackLogo = (theme) => "data:image/svg+xml;base64," +
25
25
  embedded images and PDF pages as "image", and the code listings as
26
26
  "listing"), the diagrams from an object tree nesting level on, and the
27
27
  long texts */
28
- export const omitAspects = ["diagram:graph", "diagram:hub", "diagram:grid",
29
- "diagram:code", "diagram:image", "diagram:listing",
28
+ export const omitAspects = ["diagram:graph", "diagram:hub", "diagram:grid", "diagram:state",
29
+ "diagram:layer", "diagram:slice", "diagram:tree", "diagram:matrix", "diagram:sequence", "diagram:petri",
30
+ "diagram:gantt", "diagram:code", "diagram:image", "diagram:listing",
30
31
  "diagram:1", "diagram:2", "diagram:3", "text:long"];
31
32
  /* parse the (comma-separated) aspects to omit, where the
32
33
  plain "diagram" is the alias of "diagram:1", hence of all diagrams */
@@ -12,4 +12,4 @@ export declare const embeddedSource: (content: string) => {
12
12
  export declare const diagramKey: (language: DiagramLanguage, source: string) => string;
13
13
  export declare const omittedDiagram: (language: DiagramLanguage | "image" | "listing", level: number, omit: Set<OmitAspect>) => boolean;
14
14
  export declare const objectLevels: (specification: Spec) => Map<SpecObject, number>;
15
- export declare const renderEmbeddedDiagrams: (specification: Spec, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:code" | "diagram:image" | "diagram:listing" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Map<string, DiagramVariants>>;
15
+ export declare const renderEmbeddedDiagrams: (specification: Spec, verbose?: Verbose, omit?: Set<"diagram:graph" | "diagram:hub" | "diagram:grid" | "diagram:state" | "diagram:layer" | "diagram:slice" | "diagram:tree" | "diagram:matrix" | "diagram:sequence" | "diagram:petri" | "diagram:gantt" | "diagram:code" | "diagram:image" | "diagram:listing" | "diagram:1" | "diagram:2" | "diagram:3" | "text:long">) => Promise<Map<string, DiagramVariants>>;
@@ -96,13 +96,30 @@ const d2Colors = (colors, style) => {
96
96
  AA2: s[13], AA4: s[28], AA5: s[30], AB4: s[28], AB5: s[30]
97
97
  };
98
98
  };
99
+ const mermaidClassColors = (colors, style) => {
100
+ const [b, a] = [colors.base, colors.accent];
101
+ return style === "dark" ?
102
+ { header: a[3], title: b[31], body: b[9], name: a[27], type: a[22], mark: a[16] } :
103
+ { header: a[7], title: b[31], name: a[5], type: a[11], mark: a[17] };
104
+ };
99
105
  /* render a Mermaid diagram, stripping the web font import the SVG
100
106
  carries, as an SVG embedded through an <img> never loads it and it
101
- would be the only external reference of the exported document */
102
- const renderMermaid = async (source, colors) => {
107
+ would be the only external reference of the exported document, and
108
+ recoloring the class boxes of a class diagram (their header with its
109
+ stereotype and name, their body, and their members, inside every
110
+ "class-node" group) */
111
+ const renderMermaid = async (source, colors, cls) => {
103
112
  const { renderMermaidSVG } = await import("beautiful-mermaid");
104
113
  return renderMermaidSVG(source, { ...colors, transparent: true, padding: 4 })
105
- .replace(/^\s*@import url\([^)]*\);\s*$/m, "");
114
+ .replace(/^\s*@import url\([^)]*\);\s*$/m, "")
115
+ .replace(/<g class="class-node"[^>]*>[\s\S]*?<\/g>/g, (group) => group
116
+ .replace("fill=\"var(--_node-fill)\"", `fill="${cls.body ?? "var(--_node-fill)"}"`)
117
+ .replace("fill=\"var(--_group-hdr)\"", `fill="${cls.header}"`)
118
+ .replace("fill=\"var(--_text-muted)\">&lt;&lt;", `fill="${cls.title}">&lt;&lt;`)
119
+ .replace("font-weight=\"700\" fill=\"var(--_text)\"", `font-weight="700" fill="${cls.title}"`)
120
+ .replaceAll("<tspan fill=\"var(--_text-sec)\">", `<tspan fill="${cls.name}">`)
121
+ .replaceAll("<tspan fill=\"var(--_text-muted)\">", `<tspan fill="${cls.type}">`)
122
+ .replaceAll("<tspan fill=\"var(--_text-faint)\">", `<tspan fill="${cls.mark}">`));
106
123
  };
107
124
  /* the D2 compiler, created on first use (as it spins up its WebAssembly
108
125
  build in a worker thread), and the chain of its requests, as it
@@ -180,14 +197,15 @@ export const renderEmbeddedDiagrams = async (specification, verbose, omit = new
180
197
  const key = diagramKey(language, source);
181
198
  if (rendered.has(key) || failed.has(key) || omittedDiagram(language, level, omit))
182
199
  continue;
183
- let variants = diagramCache.get(`${tone}\u0000${key}`) ?? cache.get(`${tone}\u0000${key}`);
200
+ const toned = `${tone}\u0000${key}`;
201
+ let variants = diagramCache.get(toned);
184
202
  if (variants !== undefined)
185
203
  cached++;
186
204
  else {
187
205
  try {
188
206
  variants = language === "mermaid" ? {
189
- light: await renderMermaid(source, mermaidColors(colors, "light")),
190
- dark: await renderMermaid(source, mermaidColors(colors, "dark"))
207
+ light: await renderMermaid(source, mermaidColors(colors, "light"), mermaidClassColors(colors, "light")),
208
+ dark: await renderMermaid(source, mermaidColors(colors, "dark"), mermaidClassColors(colors, "dark"))
191
209
  } : {
192
210
  light: await renderD2(source, d2Colors(colors, "light"), "light"),
193
211
  dark: await renderD2(source, d2Colors(colors, "dark"), "dark")
@@ -201,7 +219,7 @@ export const renderEmbeddedDiagrams = async (specification, verbose, omit = new
201
219
  continue;
202
220
  }
203
221
  }
204
- cache.set(`${tone}\u0000${key}`, variants);
222
+ cache.set(toned, variants);
205
223
  rendered.set(key, variants);
206
224
  }
207
225
  diagramCache = cache;
@@ -1,20 +1,30 @@
1
1
  import type nunjucks from "nunjucks";
2
2
  import type { OmitAspect } from "./specbook-export-common.js";
3
3
  export declare const foldScript: (standins: boolean, textless: boolean) => string;
4
+ export declare const lineChars = 140;
4
5
  export declare const omitLong: (omit: Set<OmitAspect> | null, cells: nunjucks.runtime.SafeString[], shares: number[], percent?: number) => nunjucks.runtime.SafeString[];
5
6
  export declare const omittedControls: (omit: Set<OmitAspect>) => {
6
7
  omitted: {
7
- graph: boolean;
8
- hub: boolean;
9
- grid: boolean;
10
- code: boolean;
11
- image: boolean;
12
- listing: boolean;
8
+ gradia: boolean;
13
9
  level1: boolean;
14
10
  level2: boolean;
15
11
  level3: boolean;
16
12
  text: boolean;
17
13
  all: boolean;
14
+ code: boolean;
15
+ layer: boolean;
16
+ slice: boolean;
17
+ state: boolean;
18
+ petri: boolean;
19
+ graph: boolean;
20
+ hub: boolean;
21
+ grid: boolean;
22
+ tree: boolean;
23
+ matrix: boolean;
24
+ sequence: boolean;
25
+ gantt: boolean;
26
+ image: boolean;
27
+ listing: boolean;
18
28
  };
19
29
  css: string;
20
30
  };
@@ -18,7 +18,8 @@ const cellGainMin = 25;
18
18
  left corner, folds the text of every table cell towering over the
19
19
  other cells of its row behind a chevron mark of its own, and lets
20
20
  the controls of the fold tab fold and unfold all diagrams of a type
21
- ("graph", "hub", "grid", plus the embedded Mermaid/D2 ones as "code",
21
+ ("graph", "hub", "grid", "state", "layer", "slice", "tree", "matrix", "sequence", "petri", "gantt",
22
+ plus the embedded Mermaid/D2 ones as "code",
22
23
  the embedded images and PDF pages as "image", and the code listings
23
24
  as "listing"), all diagrams from an object tree nesting level on
24
25
  (1, 2, 3), and all cell texts at once, with their state
@@ -36,16 +37,24 @@ export const foldScript = (standins, textless) => textframe `
36
37
  if (tab === null)
37
38
  return
38
39
  const controls = {
39
- graph: tab.querySelector("div.fold-graphs"),
40
- hub: tab.querySelector("div.fold-hubs"),
41
- grid: tab.querySelector("div.fold-grids"),
42
- code: tab.querySelector("div.fold-codes"),
43
- image: tab.querySelector("div.fold-images"),
44
- listing: tab.querySelector("div.fold-listings"),
45
- level1: tab.querySelector("div.fold-level1"),
46
- level2: tab.querySelector("div.fold-level2"),
47
- level3: tab.querySelector("div.fold-level3"),
48
- text: tab.querySelector("div.fold-texts")
40
+ graph: tab.querySelector("div.fold-graphs"),
41
+ hub: tab.querySelector("div.fold-hubs"),
42
+ grid: tab.querySelector("div.fold-grids"),
43
+ state: tab.querySelector("div.fold-states"),
44
+ layer: tab.querySelector("div.fold-layers"),
45
+ slice: tab.querySelector("div.fold-slices"),
46
+ tree: tab.querySelector("div.fold-trees"),
47
+ matrix: tab.querySelector("div.fold-matrices"),
48
+ sequence: tab.querySelector("div.fold-sequences"),
49
+ petri: tab.querySelector("div.fold-petris"),
50
+ gantt: tab.querySelector("div.fold-gantts"),
51
+ code: tab.querySelector("div.fold-codes"),
52
+ image: tab.querySelector("div.fold-images"),
53
+ listing: tab.querySelector("div.fold-listings"),
54
+ level1: tab.querySelector("div.fold-level1"),
55
+ level2: tab.querySelector("div.fold-level2"),
56
+ level3: tab.querySelector("div.fold-level3"),
57
+ text: tab.querySelector("div.fold-texts")
49
58
  }${standins ? `
50
59
  for (const kind of Object.keys(controls))
51
60
  controls[kind] ??= document.createElement("div")` : ""}
@@ -93,7 +102,8 @@ export const foldScript = (standins, textless) => textframe `
93
102
  control; a diagram joins the set of its type plus the sets
94
103
  of all nesting levels up to its own one (3 standing for all
95
104
  deeper ones), so the sets of the controls overlap */
96
- const folds = { graph: [], hub: [], grid: [], code: [], image: [], listing: [],
105
+ const folds = { graph: [], hub: [], grid: [], state: [], layer: [], slice: [], tree: [], matrix: [], sequence: [],
106
+ petri: [], gantt: [], code: [], image: [], listing: [],
97
107
  level1: [], level2: [], level3: [], text: [] }
98
108
  document.querySelectorAll("article div.diagram, nav.doc div.diagram").forEach((el) => {
99
109
  const type = el.getAttribute("data-type") ?? "graph"
@@ -230,7 +240,10 @@ export const foldScript = (standins, textless) => textframe `
230
240
  word position still fitting is found by a binary search over
231
241
  the measured word positions, the text node is split there and
232
242
  the chevron mark takes its place, followed by the remainder
233
- hidden up the ancestor chain of the cell */
243
+ hidden up the ancestor chain of the cell. All cells are
244
+ measured before the first cut and all cuts are applied before
245
+ their gain is measured, as every measurement after a change
246
+ of the document forces the browser to lay it out again */
234
247
  for (const cut of cuts) {
235
248
  const list = positions(cut.cell)
236
249
  if (list.length === 0)
@@ -244,10 +257,14 @@ export const foldScript = (standins, textless) => textframe `
244
257
  else
245
258
  hi = mid - 1
246
259
  }
247
- const tail = list[lo].node.splitText(list[lo].offset)
248
- const chevron = mark("fold-more", "fold/unfold the remaining text")
249
- tail.parentNode.insertBefore(chevron, tail)
250
- let current = chevron
260
+ cut.word = list[lo]
261
+ }
262
+ const applied = cuts.filter((cut) => cut.word !== undefined)
263
+ for (const cut of applied) {
264
+ const tail = cut.word.node.splitText(cut.word.offset)
265
+ cut.chevron = mark("fold-more", "fold/unfold the remaining text")
266
+ tail.parentNode.insertBefore(cut.chevron, tail)
267
+ let current = cut.chevron
251
268
  while (current !== cut.cell) {
252
269
  let next = current.nextSibling
253
270
  while (next !== null) {
@@ -257,20 +274,22 @@ export const foldScript = (standins, textless) => textframe `
257
274
  }
258
275
  current = current.parentNode
259
276
  }
260
-
261
- /* the gain is measured on the applied fold, as only that
262
- one tells the real one, and a fold gaining less than the
263
- minimum share is taken back again, while a kept fold
264
- sheds the class again, as every cell starts out unfolded */
265
277
  cut.cell.classList.add("folded")
266
- const gained = height(cut.cell) <= cut.full * (1 - cellGain / 100)
267
- if (!gained) {
278
+ }
279
+
280
+ /* the gain is measured on the applied fold, as only that
281
+ one tells the real one, and a fold gaining less than the
282
+ minimum share is taken back again, while a kept fold
283
+ sheds the class again, as every cell starts out unfolded */
284
+ const gained = applied.map((cut) => height(cut.cell) <= cut.full * (1 - cellGain / 100))
285
+ applied.forEach((cut, i) => {
286
+ if (!gained[i]) {
268
287
  uncut(cut.cell)
269
- continue
288
+ return
270
289
  }
271
290
  cut.cell.classList.toggle("folded", state.get(cut.cell) ?? all)
272
291
  folds.text.push(cut.cell)
273
- chevron.addEventListener("click", (event) => {
292
+ cut.chevron.addEventListener("click", (event) => {
274
293
  /* the cut can land inside a hyperlink, whose navigation
275
294
  the chevron has to suppress for its own click alone,
276
295
  so the remaining link text still follows the link */
@@ -278,7 +297,7 @@ export const foldScript = (standins, textless) => textframe `
278
297
  cut.cell.classList.toggle("folded")
279
298
  sync()
280
299
  })
281
- }
300
+ })
282
301
  }
283
302
  layout()
284
303
 
@@ -297,8 +316,9 @@ export const foldScript = (standins, textless) => textframe `
297
316
 
298
317
  /* fold exactly the diagrams at least one active control covers */
299
318
  const apply = () => {
319
+ const covered = new Set(Array.from(active).flatMap((kind) => folds[kind]))
300
320
  folds.level1.forEach((fold) => {
301
- fold.classList.toggle("folded", Array.from(active).some((kind) => folds[kind].includes(fold)))
321
+ fold.classList.toggle("folded", covered.has(fold))
302
322
  })
303
323
  }
304
324
 
@@ -376,7 +396,7 @@ export const foldScript = (standins, textless) => textframe `
376
396
  `;
377
397
  /* the estimated number of characters a text line spanning the
378
398
  entire content width holds (60rem at the document font) */
379
- const lineChars = 140;
399
+ export const lineChars = 140;
380
400
  /* omit the long texts of the comparable cells of a table row, the
381
401
  server-side heuristic counterpart of the client-side cell text
382
402
  folding, judging a cell by its estimated number of text lines (its
@@ -392,14 +412,14 @@ export const omitLong = (omit, cells, shares, percent = 0) => {
392
412
  if (omit?.has("text:long") !== true)
393
413
  return cells;
394
414
  const htmls = cells.map(String);
395
- const plain = (html) => html.replace(/<[^>]*>/g, "").trim().length;
396
- const lengths = htmls.map((html) => (/<td[\s>]|<div class="diagram"/).test(html) ? 0 : plain(html));
415
+ const textLen = (html) => html.replace(/<[^>]*>/g, "").trim().length;
416
+ const lengths = htmls.map((html) => (/<td[\s>]|<div class="diagram"/).test(html) ? 0 : textLen(html));
397
417
  if (lengths.filter((length) => length > 0).length < 2)
398
418
  return cells;
399
419
  const chars = shares.map((share) => Math.max(1, Math.floor(lineChars * share)));
400
420
  const lines = htmls.map((html, i) => lengths[i] === 0 ? 0 :
401
421
  html.split(/<\/(?:p|li|div|pre)>|<br\s*\/?>/)
402
- .reduce((sum, block) => sum + Math.ceil(plain(block) / chars[i]), 0));
422
+ .reduce((sum, block) => sum + Math.ceil(textLen(block) / chars[i]), 0));
403
423
  return htmls.map((html, i) => {
404
424
  const other = Math.max(...lines.filter((_, j) => j !== i)) *
405
425
  (1 + (percent > 0 ? percent : cellHeightDefault) / 100);
@@ -428,6 +448,10 @@ export const omitLong = (omit, cells, shares, percent = 0) => {
428
448
  return cells[i];
429
449
  });
430
450
  };
451
+ /* the diagram types of the fold controls, the Gradia ones first */
452
+ const gradiaTypes = ["graph", "hub", "grid", "state", "layer", "slice",
453
+ "tree", "matrix", "sequence", "petri", "gantt"];
454
+ const diagramTypes = [...gradiaTypes, "code", "image", "listing"];
431
455
  /* the fold controls the omitted aspects drop, along with the implied
432
456
  ones (no type control without diagrams of that type, no level
433
457
  control without diagrams from that level on, no fold tab without
@@ -435,24 +459,21 @@ export const omitLong = (omit, cells, shares, percent = 0) => {
435
459
  style rules the omission brings (the "[...]" mark of the omitted
436
460
  long texts and the moved-up tabs of a dropped fold tab) */
437
461
  export const omittedControls = (omit) => {
438
- const none = omit.has("diagram:1")
439
- || (omit.has("diagram:graph") && omit.has("diagram:hub") && omit.has("diagram:grid")
440
- && omit.has("diagram:code") && omit.has("diagram:image") && omit.has("diagram:listing"));
462
+ const none = omit.has("diagram:1") || diagramTypes.every((type) => omit.has(`diagram:${type}`));
463
+ const types = Object.fromEntries(diagramTypes.map((type) => [type, none || omit.has(`diagram:${type}`)]));
441
464
  const omitted = {
442
- graph: none || omit.has("diagram:graph"),
443
- hub: none || omit.has("diagram:hub"),
444
- grid: none || omit.has("diagram:grid"),
445
- code: none || omit.has("diagram:code"),
446
- image: none || omit.has("diagram:image"),
447
- listing: none || omit.has("diagram:listing"),
465
+ ...types,
448
466
  level1: none,
449
467
  level2: none || omit.has("diagram:2"),
450
468
  level3: none || omit.has("diagram:2") || omit.has("diagram:3"),
451
469
  text: omit.has("text:long"),
452
470
  all: none && omit.has("text:long")
453
471
  };
472
+ /* the first row of the fold panel (the Gradia diagram types) is
473
+ dropped once all of its controls are */
474
+ const gradia = gradiaTypes.every((type) => omitted[type]);
454
475
  const css = (omitted.text ? "\nspan.omit { color: var(--theme-color-specbook-muted) }" : "") +
455
- (omitted.all ? "\nnav.toc-panel div.toc-tab { top: 10.66rem }" +
456
- "\ndiv.realtime-status { top: 13.89rem }" : "");
457
- return { omitted, css };
476
+ (omitted.all ? "\nnav.toc-panel div.toc-tab { top: 13.89rem }" +
477
+ "\ndiv.realtime-status { top: 17.12rem }" : "");
478
+ return { omitted: { ...omitted, gradia }, css };
458
479
  };
@@ -14,7 +14,7 @@ import { embeddedSource, fenceLanguage, diagramKey, omittedDiagram } from "./spe
14
14
  import { embeddedListing, fencedListing } from "./specbook-export-code.js";
15
15
  import { coverageRatio } from "./specbook-coverage.js";
16
16
  import { safe, render } from "./specbook-export-html-template.js";
17
- import { omitLong } from "./specbook-export-html-fold.js";
17
+ import { omitLong, lineChars } from "./specbook-export-html-fold.js";
18
18
  /* ==== Rendering ==== */
19
19
  /* the per-language double quote styles for the smart typography
20
20
  (single quotes stay curly, as smartypants cannot distinguish a
@@ -37,6 +37,35 @@ const quoteStyles = {
37
37
  };
38
38
  /* the active double quote style (set per document during rendering) */
39
39
  let quotes = quoteStyles.en;
40
+ /* the per-language translations of the rationale keyword, rendered
41
+ behind the always English "BECAUSE" of the source syntax */
42
+ const rationaleKeywords = {
43
+ "de": "weil",
44
+ "nl": "omdat",
45
+ "fr": "parce que",
46
+ "it": "perché",
47
+ "es": "porque",
48
+ "pt": "porque",
49
+ "da": "fordi",
50
+ "no": "fordi",
51
+ "sv": "eftersom",
52
+ "fi": "koska",
53
+ "pl": "ponieważ",
54
+ "cs": "protože",
55
+ "sk": "pretože",
56
+ "hu": "mert",
57
+ "ro": "deoarece",
58
+ "tr": "çünkü",
59
+ "ru": "потому что",
60
+ "uk": "тому що",
61
+ "el": "επειδή",
62
+ "ja": "なぜなら",
63
+ "zh": "因为",
64
+ "ko": "왜냐하면"
65
+ };
66
+ /* the active rationale keyword translation (set per document during
67
+ rendering, none for English and the unknown languages) */
68
+ let keyword;
40
69
  /* the private Markdown renderer instance, as the hooks below must not
41
70
  leak into the "marked" singleton shared with the parser and with the
42
71
  application embedding SpecBook as a library */
@@ -89,7 +118,9 @@ let omits = null;
89
118
  as it would otherwise retain the specification (and its embedded
90
119
  images) until the next rendering */
91
120
  export const withDocument = (state, body) => {
92
- quotes = quoteStyles[state.lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
121
+ const code = state.lang?.toLowerCase().split(/[-_]/)[0] ?? "en";
122
+ quotes = quoteStyles[code] ?? quoteStyles.en;
123
+ keyword = rationaleKeywords[code];
93
124
  linker = state.linker;
94
125
  anchors = state.anchors;
95
126
  members = state.members;
@@ -275,6 +306,13 @@ marked.use({ renderer: { code(token) {
275
306
  const listing = fencedListing(token);
276
307
  return listing !== undefined && listings !== null ? renderListingBlock(listings, listing) : false;
277
308
  } } });
309
+ /* render an inline code span with a line break opportunity behind every
310
+ separator (comma, semicolon, pipe), so an overlong span without any
311
+ space (like a long value expression) breaks there, ahead of breaking
312
+ inside a word (the last resort of the stylesheet) */
313
+ marked.use({ renderer: { codespan(token) {
314
+ return `<code>${escapeHtml(token.text).replace(/([,;|])(?=.)/g, "$1<wbr>")}</code>`;
315
+ } } });
278
316
  /* strip the fenced code blocks of a diagram language or a code listing
279
317
  off a text */
280
318
  const stripDiagrams = (text) => marked.lexer(text)
@@ -336,6 +374,7 @@ const renderDescription = (description) => {
336
374
  description: text !== "" ? (blocked ? block(text, true) : inline(text, true)) : "",
337
375
  rationale: description.rationale !== undefined ?
338
376
  inline(description.rationale, true) : undefined,
377
+ keyword,
339
378
  elaboration: elaboration !== "" ? block(elaboration, true) : undefined,
340
379
  embeddings
341
380
  } });
@@ -520,6 +559,15 @@ const coverageOf = (object) => {
520
559
  even: i % 2 === 1
521
560
  })) }));
522
561
  };
562
+ /* the estimated width of the longest word of an HTML text, in average
563
+ characters, a capital counting double (as the property names and the
564
+ bold kind labels of the references are set in capitals) */
565
+ const longestWord = (html) => Math.max(0, ...html.replace(/<[^>]*>/g, " ").replace(/&[#a-z0-9]+;/gi, "x").split(/\s+/)
566
+ .map((text) => text.length + (text.match(/[A-Z]/g) ?? []).length));
567
+ /* the width share of a table column, for the width of its longest word
568
+ and the characters one share holds: one share, or up to two and a
569
+ half where the word needs more, so it never breaks inside */
570
+ const columnShare = (need, base) => Math.min(2.5, Math.max(1, (need + 5) / base));
523
571
  /* render a single-kind group of children into one compact table:
524
572
  the name first, then the property columns, then the description;
525
573
  a group wider than maxColumns, or carrying diagrams, instead chunks
@@ -531,7 +579,18 @@ const renderTable = (children, maxColumns) => {
531
579
  const { keys, desc } = tableShape(children);
532
580
  const diagrammed = children.some((child) => diagrams?.has(child) === true);
533
581
  const fold = formatOf(children[0])?.maxCellHeight;
534
- if (!diagrammed && 1 + keys.length + (desc ? 1 : 0) <= maxColumns)
582
+ if (!diagrammed && 1 + keys.length + (desc ? 1 : 0) <= maxColumns) {
583
+ /* under the fixed table layout the name column takes a fixed
584
+ 20% (via CSS), while the property columns and the description
585
+ column (two shares) split the remaining 80%, a property column
586
+ by the longest word of its values and its header, so a column
587
+ of short values (like a kind) yields room */
588
+ const values = children.map((child) => scoped(child, () => keys.map((key) => inlineValue(child.kind, child.properties.find((property) => property.key === key)))));
589
+ const base = 0.8 * lineChars / (keys.length + (desc ? 2 : 0));
590
+ const shares = keys.map((key, k) => columnShare(Math.max(longestWord(key), ...values.map((row) => longestWord(String(row[k])))), base));
591
+ if (desc)
592
+ shares.push(2);
593
+ const total = shares.reduce((sum, share) => sum + share, 0);
535
594
  return render("Table", { Table: {
536
595
  head: children[0].kind !== "" ? children[0].kind : "Name",
537
596
  info: infoKeyOf(children[0]),
@@ -539,14 +598,9 @@ const renderTable = (children, maxColumns) => {
539
598
  keys,
540
599
  desc,
541
600
  fold,
542
- /* under the fixed table layout the name column takes a fixed
543
- 20% (via CSS) and the description column claims twice the
544
- share of a property column of the remaining 80% */
545
- width: Math.round(1600 / (keys.length + 2)) / 10,
601
+ widths: shares.map((share) => Math.round(800 * share / total) / 10),
546
602
  rows: children.map((child, i) => scoped(child, () => {
547
- const values = keys.map((key) => inlineValue(child.kind, child.properties.find((property) => property.key === key)));
548
- const shares = desc ? [...keys.map(() => 1), 2] : keys.map(() => 1);
549
- const cells = omitLong(omits, desc ? [...values, safe(renderCell(child))] : values, shares.map((share) => 0.8 * share / (keys.length + (desc ? 2 : 0))), fold);
603
+ const cells = omitLong(omits, desc ? [...values[i], safe(renderCell(child))] : values[i], shares.map((share) => 0.8 * share / total), fold);
550
604
  return {
551
605
  id: anchorOf(child),
552
606
  anchor: child.anchor,
@@ -560,33 +614,59 @@ const renderTable = (children, maxColumns) => {
560
614
  };
561
615
  }))
562
616
  } });
617
+ }
563
618
  /* the embedded rows hold at most maxColumns - 1 cells (of the
564
619
  group-wide union of property keys, plus the trailing description),
565
620
  with the last cell spanning the leftover columns of the final row,
566
621
  all of them sharing the 80% beside the fixed 20% name column */
567
622
  const size = Math.max(1, maxColumns - 1);
623
+ const parts = children.map((child) => scoped(child, () => {
624
+ const cells = keys.map((key) => ({ key, desc: false, span: 1,
625
+ value: inlineValue(child.kind, child.properties.find((property) => property.key === key)) }));
626
+ if (desc)
627
+ cells.push({ key: "Description", desc: true, span: 1,
628
+ value: safe(renderCell(child)) });
629
+ const chunks = new Array();
630
+ for (let pos = 0; pos < cells.length; pos += size)
631
+ chunks.push(cells.slice(pos, pos + size));
632
+ if (chunks.length > 0) {
633
+ const last = chunks[chunks.length - 1];
634
+ last[last.length - 1].span = size - last.length + 1;
635
+ }
636
+ return chunks;
637
+ }));
638
+ /* the columns of the embedded rows take their shares by the longest
639
+ word of their single property cells over all objects of the group,
640
+ exactly like the columns of a plain table, so the embedded rows
641
+ of all objects keep one common column grid */
642
+ const needs = new Array(size).fill(0);
643
+ for (const chunks of parts)
644
+ for (const chunk of chunks)
645
+ chunk.forEach((cell, k) => {
646
+ if (!cell.desc && cell.span === 1)
647
+ needs[k] = Math.max(needs[k], longestWord(cell.key), longestWord(String(cell.value)));
648
+ });
649
+ const shares = needs.map((need) => columnShare(need, 0.8 * lineChars / size));
650
+ const total = shares.reduce((sum, share) => sum + share, 0);
568
651
  return render("TableChunked", { Table: {
569
652
  head: children[0].kind !== "" ? children[0].kind : "Name",
570
653
  info: infoKeyOf(children[0]),
571
654
  infopath: infoRefOf(children[0], false),
572
655
  desc,
573
656
  fold,
657
+ widths: shares.map((share) => Math.round(1000 * share / total) / 10),
574
658
  rows: children.map((child, i) => scoped(child, () => {
575
- const cells = keys.map((key) => ({ key, desc: false, span: 1,
576
- value: inlineValue(child.kind, child.properties.find((property) => property.key === key)) }));
577
- if (desc)
578
- cells.push({ key: "Description", desc: true, span: 1,
579
- value: safe(renderCell(child)) });
580
- const chunks = new Array();
581
- for (let pos = 0; pos < cells.length; pos += size)
582
- chunks.push(cells.slice(pos, pos + size));
583
- if (chunks.length > 0) {
584
- const last = chunks[chunks.length - 1];
585
- last[last.length - 1].span = size - last.length + 1;
586
- }
587
- for (const chunk of chunks)
588
- omitLong(omits, chunk.map((cell) => cell.value), chunk.map((cell) => 0.8 * cell.span / size), fold)
659
+ const chunks = parts[i];
660
+ for (const chunk of chunks) {
661
+ let pos = 0;
662
+ const spans = chunk.map((cell) => {
663
+ const share = shares.slice(pos, pos + cell.span).reduce((sum, s) => sum + s, 0);
664
+ pos += cell.span;
665
+ return 0.8 * share / total;
666
+ });
667
+ omitLong(omits, chunk.map((cell) => cell.value), spans, fold)
589
668
  .forEach((value, k) => { chunk[k].value = value; });
669
+ }
590
670
  /* the diagram leads the chunks as a full-width chunk of its
591
671
  own, headed like the description */
592
672
  const diagram = diagramOf(child);
@@ -680,18 +760,19 @@ export const renderTitlePage = (object, created, modified) => {
680
760
  const value = prop(name);
681
761
  return value !== undefined && value.trim() !== "" ? inline(value) : "";
682
762
  };
683
- const rest = object.properties.filter((property) => !titleProperties.some((prop) => prop.name === property.key));
763
+ const rest = object.properties.filter((property) => !titleProperties.some((entry) => entry.name === property.key));
684
764
  /* the logo is rendered above the title, from the embedded image content
685
765
  of the LOGO property or, for a non-embeddable reference, as its inline
686
766
  Markdown; without a (non-empty) LOGO property the built-in SpecBook
687
767
  logo is used, in both its theme variants */
688
768
  const logo = object.properties.find((property) => property.key === "LOGO" && property.value.trim() !== "");
689
769
  const image = logo !== undefined ? renderEmbeddings(logo.value, logo.embedding ?? [], false) : [];
770
+ const title = prop("TITLE")?.trim();
690
771
  return scoped(object, () => render("TitlePage", { TitlePage: {
691
772
  logo: logo === undefined ?
692
773
  safe(renderThemed(embeddingThemes.map((theme) => `<img src="${fallbackLogo(theme)}" alt="SpecBook"/>`))) :
693
774
  (image.length > 0 ? safe(image.join("")) : inline(logo.value)),
694
- title: inline(prop("TITLE") ?? object.name),
775
+ title: inline(title !== undefined && title !== "" ? title : "Specification"),
695
776
  subtitle: inlineProp("SUBTITLE"),
696
777
  author: inlineProp("AUTHOR"),
697
778
  version: inlineProp("VERSION"),