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