@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.
- package/LICENSE.txt +201 -0
- package/README.md +288 -43
- package/dst/specbook-api.d.ts +23 -3
- package/dst/specbook-api.js +133 -59
- package/dst/specbook-cli.js +93 -26
- package/dst/specbook-cmd-describe.js +4 -4
- package/dst/specbook-cmd-export-schema.d.ts +8 -0
- package/dst/specbook-cmd-export-schema.js +441 -0
- package/dst/specbook-cmd-export.js +23 -6
- package/dst/specbook-cmd-init-sample.d.ts +4 -0
- package/dst/specbook-cmd-init-sample.js +322 -0
- package/dst/specbook-cmd-init.d.ts +1 -0
- package/dst/specbook-cmd-init.js +42 -10
- package/dst/specbook-cmd-lint.js +46 -48
- package/dst/specbook-cmd-query.d.ts +30 -0
- package/dst/specbook-cmd-query.js +165 -0
- package/dst/specbook-config.d.ts +9 -1
- package/dst/specbook-config.js +135 -10
- package/dst/specbook-coverage.js +2 -2
- package/dst/specbook-diagram.d.ts +7 -5
- package/dst/specbook-diagram.js +546 -143
- package/dst/specbook-export-ast.d.ts +1 -1
- package/dst/specbook-export-ast.js +11 -8
- package/dst/specbook-export-code.js +3 -3
- package/dst/specbook-export-common.d.ts +1 -1
- package/dst/specbook-export-common.js +3 -2
- package/dst/specbook-export-diagram.d.ts +1 -1
- package/dst/specbook-export-diagram.js +25 -7
- package/dst/specbook-export-html-fold.d.ts +16 -6
- package/dst/specbook-export-html-fold.js +65 -44
- package/dst/specbook-export-html-render.js +107 -26
- package/dst/specbook-export-html-search.js +18 -20
- package/dst/specbook-export-html-template.js +38 -9
- package/dst/specbook-export-html-widget.d.ts +2 -0
- package/dst/specbook-export-html-widget.js +219 -19
- package/dst/specbook-export-html.css +208 -38
- package/dst/specbook-export-html.js +96 -53
- package/dst/specbook-export-md.d.ts +2 -1
- package/dst/specbook-export-md.js +7 -8
- package/dst/specbook-export-pdf.js +1 -1
- package/dst/specbook-files.d.ts +2 -0
- package/dst/specbook-files.js +41 -0
- package/dst/specbook-format-meta.yaml +401 -0
- package/dst/specbook-format-pm.yaml +1240 -0
- package/dst/specbook-format-schema.d.ts +33 -3
- package/dst/specbook-format-schema.js +49 -3
- package/dst/specbook-format.md +427 -47
- package/dst/specbook-format.yaml +524 -228
- package/dst/specbook-ignore.js +2 -2
- package/dst/specbook-link.d.ts +4 -0
- package/dst/specbook-link.js +61 -6
- package/dst/specbook-mcp.js +103 -19
- package/dst/specbook-parse-semantic.d.ts +1 -0
- package/dst/specbook-parse-semantic.js +11 -4
- package/dst/specbook-parse-syntax.d.ts +1 -0
- package/dst/specbook-parse-syntax.js +1 -1
- package/dst/specbook-parse.d.ts +1 -1
- package/dst/specbook-parse.js +3 -3
- package/dst/specbook-project.d.ts +2 -0
- package/dst/specbook-project.js +15 -5
- package/dst/specbook-remote.d.ts +28 -0
- package/dst/specbook-remote.js +429 -0
- 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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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)\"><<", `fill="${cls.title}"><<`)
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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",
|
|
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:
|
|
40
|
-
hub:
|
|
41
|
-
grid:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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: [],
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
|
396
|
-
const lengths = htmls.map((html) => (/<td[\s>]|<div class="diagram"/).test(html) ? 0 :
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
456
|
-
"\ndiv.realtime-status { top:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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((
|
|
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(
|
|
775
|
+
title: inline(title !== undefined && title !== "" ? title : "Specification"),
|
|
695
776
|
subtitle: inlineProp("SUBTITLE"),
|
|
696
777
|
author: inlineProp("AUTHOR"),
|
|
697
778
|
version: inlineProp("VERSION"),
|