@rse/specbook 1.2.1 → 1.2.3
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 +6 -2
- package/dst/specbook-api.d.ts +1 -0
- package/dst/specbook-api.js +19 -5
- package/dst/specbook-cli.js +1 -1
- package/dst/specbook-cmd-describe.js +2 -2
- package/dst/specbook-cmd-lint.js +10 -2
- package/dst/specbook-cmd-preview.js +12 -9
- package/dst/specbook-config.d.ts +4 -0
- package/dst/specbook-config.js +48 -10
- package/dst/specbook-diagram.js +56 -37
- package/dst/specbook-export-common.js +33 -22
- package/dst/specbook-export-html.css +62 -0
- package/dst/specbook-export-html.d.ts +1 -0
- package/dst/specbook-export-html.js +178 -63
- package/dst/specbook-export-md.js +9 -7
- package/dst/specbook-export-pdf.js +22 -8
- package/dst/specbook-format-schema.js +2 -0
- package/dst/specbook-format-spec.js +2 -0
- package/dst/specbook-format.md +3 -2
- package/dst/specbook-format.yaml +200 -216
- package/dst/specbook-link.d.ts +1 -0
- package/dst/specbook-link.js +11 -4
- package/dst/specbook-parse-common.d.ts +1 -0
- package/dst/specbook-parse-common.js +5 -0
- package/dst/specbook-parse-semantic.d.ts +2 -1
- package/dst/specbook-parse-semantic.js +37 -25
- package/dst/specbook-parse-syntax.js +20 -23
- package/dst/specbook-parse.d.ts +5 -0
- package/dst/specbook-parse.js +20 -1
- package/package.json +3 -3
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
+
import { plainText } from "./specbook-link.js";
|
|
7
8
|
/* escape a text for embedding into template HTML (text and attributes) */
|
|
8
9
|
export const escapeHtml = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
9
10
|
.replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -128,24 +129,16 @@ const typographyGlyphs = [
|
|
|
128
129
|
0x00A0, 0x00AB, 0x00BB, 0x2013, 0x2014, 0x2018, 0x2019,
|
|
129
130
|
0x201A, 0x201C, 0x201D, 0x201E, 0x2022, 0x2026, 0x2039, 0x203A
|
|
130
131
|
];
|
|
131
|
-
/* the memoized subsetted stylesheets
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
the
|
|
132
|
+
/* the memoized subsetted stylesheets (as pending or settled promises,
|
|
133
|
+
so concurrent requests share a single subsetting), keyed by the
|
|
134
|
+
(shared) codepoint set of the charset, as the font subsetting is
|
|
135
|
+
expensive and the formats of a single export, the watch and preview
|
|
136
|
+
re-exports, and the MCP service request the very same subset over
|
|
137
|
+
and over again */
|
|
135
138
|
const subsetCache = new Map();
|
|
136
|
-
/*
|
|
137
|
-
|
|
138
|
-
glyphs (no charset or a full Unicode charset keeps the fonts complete) */
|
|
139
|
-
export const subsetStylesheet = async (charset) => {
|
|
140
|
-
const codepoints = charset !== undefined ? charsetCodepoints(charset) : undefined;
|
|
141
|
-
if (codepoints === undefined)
|
|
142
|
-
return stylesheet();
|
|
143
|
-
const cached = subsetCache.get(codepoints);
|
|
144
|
-
if (cached !== undefined)
|
|
145
|
-
return cached;
|
|
146
|
-
const css = stylesheet();
|
|
139
|
+
/* subset the embedded fonts of a stylesheet to the glyphs of a text */
|
|
140
|
+
const subsetFonts = async (css, text) => {
|
|
147
141
|
const { default: subsetFont } = await import("subset-font");
|
|
148
|
-
const text = String.fromCodePoint(...codepoints, ...symbolGlyphs, ...typographyGlyphs);
|
|
149
142
|
let result = "";
|
|
150
143
|
let last = 0;
|
|
151
144
|
for (const m of css.matchAll(/url\("data:font\/woff2;base64,([^"]+)"\)/g)) {
|
|
@@ -154,18 +147,36 @@ export const subsetStylesheet = async (charset) => {
|
|
|
154
147
|
`url("data:font/woff2;base64,${subset.toString("base64")}")`;
|
|
155
148
|
last = m.index + m[0].length;
|
|
156
149
|
}
|
|
157
|
-
|
|
158
|
-
|
|
150
|
+
return result + css.slice(last);
|
|
151
|
+
};
|
|
152
|
+
/* provide the stylesheet with its embedded fonts subsetted to the
|
|
153
|
+
codepoints of a charset plus the always-used symbol and typography
|
|
154
|
+
glyphs (no charset or a full Unicode charset keeps the fonts complete),
|
|
155
|
+
where a failed subsetting leaves the cache, so the next request retries */
|
|
156
|
+
export const subsetStylesheet = async (charset) => {
|
|
157
|
+
const codepoints = charset !== undefined ? charsetCodepoints(charset) : undefined;
|
|
158
|
+
if (codepoints === undefined)
|
|
159
|
+
return stylesheet();
|
|
160
|
+
let subsetted = subsetCache.get(codepoints);
|
|
161
|
+
if (subsetted === undefined) {
|
|
162
|
+
const text = String.fromCodePoint(...codepoints, ...symbolGlyphs, ...typographyGlyphs);
|
|
163
|
+
subsetted = subsetFonts(stylesheet(), text);
|
|
164
|
+
subsetted.catch(() => { subsetCache.delete(codepoints); });
|
|
165
|
+
subsetCache.set(codepoints, subsetted);
|
|
166
|
+
}
|
|
159
167
|
return subsetted;
|
|
160
168
|
};
|
|
161
169
|
/* determine the document title and subtitle from the title object (an
|
|
162
|
-
empty TITLE counts as absent, exactly like for the title page)
|
|
170
|
+
empty TITLE counts as absent, exactly like for the title page), with
|
|
171
|
+
the inline code markup stripped, as the plain-text targets (the HTML
|
|
172
|
+
<title>, the PDF metadata and page header) render no Markdown */
|
|
163
173
|
export const documentTitle = (specification) => {
|
|
164
174
|
const title = titleProperty(specification, "TITLE")?.trim();
|
|
175
|
+
const subtitle = titleProperty(specification, "SUBTITLE")?.trim();
|
|
165
176
|
return {
|
|
166
|
-
title: title !== undefined && title !== "" ? title :
|
|
167
|
-
titleObject(specification)?.name ?? "Specification",
|
|
168
|
-
subtitle:
|
|
177
|
+
title: plainText(title !== undefined && title !== "" ? title :
|
|
178
|
+
titleObject(specification)?.name ?? "Specification"),
|
|
179
|
+
subtitle: subtitle !== undefined ? plainText(subtitle) : undefined
|
|
169
180
|
};
|
|
170
181
|
};
|
|
171
182
|
/* determine the document logo as a self-contained data: URL, taken from the
|
|
@@ -81,6 +81,8 @@ html.theme-light {
|
|
|
81
81
|
--theme-color-specbook-popup-schema-desc: var(--theme-color-base-32);
|
|
82
82
|
--theme-color-specbook-popup-spec-path: var(--theme-color-accent-12);
|
|
83
83
|
--theme-color-specbook-popup-spec-desc: var(--theme-color-base-5);
|
|
84
|
+
--theme-color-specbook-progress-done: var(--theme-color-accent-12);
|
|
85
|
+
--theme-color-specbook-progress-todo: var(--theme-color-accent-31);
|
|
84
86
|
--gradia-font-family: "Source Sans 3";
|
|
85
87
|
--gradia-color-node-regular-name: var(--theme-color-accent-12);
|
|
86
88
|
--gradia-color-node-regular-box: var(--theme-color-accent-30);
|
|
@@ -122,6 +124,8 @@ html.theme-light {
|
|
|
122
124
|
--theme-color-specbook-popup-schema-desc: var(--theme-color-base-7);
|
|
123
125
|
--theme-color-specbook-popup-spec-path: var(--theme-color-accent-24);
|
|
124
126
|
--theme-color-specbook-popup-spec-desc: var(--theme-color-base-29);
|
|
127
|
+
--theme-color-specbook-progress-done: var(--theme-color-accent-24);
|
|
128
|
+
--theme-color-specbook-progress-todo: var(--theme-color-accent-4);
|
|
125
129
|
--gradia-color-node-regular-name: var(--theme-color-accent-21);
|
|
126
130
|
--gradia-color-node-regular-box: var(--theme-color-accent-3);
|
|
127
131
|
--gradia-color-node-regular-border: var(--theme-color-accent-7);
|
|
@@ -437,6 +441,64 @@ div.realtime-status.reloaded svg.realtime-icon {
|
|
|
437
441
|
display: none;
|
|
438
442
|
}
|
|
439
443
|
}
|
|
444
|
+
/* the scroll progress meter (screen-only): a ring at the bottom right
|
|
445
|
+
corner of the viewport (fixed independent of the content scrolling),
|
|
446
|
+
whose DONE part (the arc drawn clockwise from the top, together with
|
|
447
|
+
the upward arrow in its center, drawn like the tab icons) reflects
|
|
448
|
+
the scrolled fraction of the document over the TODO part (the full
|
|
449
|
+
circle in the lighter progress color beneath), which stays hidden
|
|
450
|
+
while the page is scrolled near the top (fading in once scrolled
|
|
451
|
+
beyond 400px) and scrolls the page back to the top on click, where
|
|
452
|
+
the DONE part and the arrow brighten on hover like the tabs */
|
|
453
|
+
div.scroll-progress {
|
|
454
|
+
position: fixed;
|
|
455
|
+
right: 1.5rem;
|
|
456
|
+
bottom: 1.5rem;
|
|
457
|
+
z-index: 30;
|
|
458
|
+
width: 3rem;
|
|
459
|
+
height: 3rem;
|
|
460
|
+
line-height: 0;
|
|
461
|
+
color: var(--theme-color-specbook-progress-done);
|
|
462
|
+
opacity: 0;
|
|
463
|
+
pointer-events: none;
|
|
464
|
+
transition: opacity 0.3s ease;
|
|
465
|
+
cursor: pointer;
|
|
466
|
+
-webkit-user-select: none;
|
|
467
|
+
user-select: none;
|
|
468
|
+
}
|
|
469
|
+
div.scroll-progress.shown {
|
|
470
|
+
opacity: 1;
|
|
471
|
+
pointer-events: auto;
|
|
472
|
+
}
|
|
473
|
+
div.scroll-progress:hover {
|
|
474
|
+
color: var(--theme-color-specbook-accent-soft);
|
|
475
|
+
}
|
|
476
|
+
div.scroll-progress svg.scroll-ring {
|
|
477
|
+
position: absolute;
|
|
478
|
+
inset: 0;
|
|
479
|
+
width: 100%;
|
|
480
|
+
height: 100%;
|
|
481
|
+
transform: rotate(-90deg);
|
|
482
|
+
}
|
|
483
|
+
div.scroll-progress svg.scroll-ring circle.scroll-todo {
|
|
484
|
+
stroke: var(--theme-color-specbook-progress-todo);
|
|
485
|
+
}
|
|
486
|
+
div.scroll-progress svg.scroll-ring circle.scroll-done {
|
|
487
|
+
stroke: currentColor;
|
|
488
|
+
}
|
|
489
|
+
div.scroll-progress svg.scroll-icon {
|
|
490
|
+
position: absolute;
|
|
491
|
+
top: 50%;
|
|
492
|
+
left: 50%;
|
|
493
|
+
width: 1.1rem;
|
|
494
|
+
height: 1.1rem;
|
|
495
|
+
transform: translate(-50%, -50%);
|
|
496
|
+
}
|
|
497
|
+
@media print {
|
|
498
|
+
div.scroll-progress {
|
|
499
|
+
display: none;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
440
502
|
/* the side panel of the table of contents (screen-only): a fixed
|
|
441
503
|
full-height drawer at the left viewport edge, carrying a replica of
|
|
442
504
|
the brand bar on its right edge, which exactly covers the brand bar
|
|
@@ -8,4 +8,5 @@ export type OutlineEntry = {
|
|
|
8
8
|
};
|
|
9
9
|
export declare const htmlOutline: (specification: Spec, config?: Schema) => OutlineEntry[];
|
|
10
10
|
export declare const titlePageObject: (specification: Spec) => SpecObject | undefined;
|
|
11
|
+
export declare const renderPlaceholder: (message: string) => string;
|
|
11
12
|
export declare const renderHtml: (specification: Spec, config?: Schema, tocPages?: Map<string, number>, css?: string, realtime?: boolean, verbose?: Verbose) => Promise<string>;
|
|
@@ -29,24 +29,7 @@ const templates = {
|
|
|
29
29
|
{{ Document.css }}
|
|
30
30
|
</style>
|
|
31
31
|
<script>
|
|
32
|
-
|
|
33
|
-
let style = null
|
|
34
|
-
try { style = localStorage.getItem("specbook-theme") }
|
|
35
|
-
catch { /* an inaccessible storage just means no stored choice */ }
|
|
36
|
-
if (style === null) {
|
|
37
|
-
const m = document.documentElement.className.match(/theme-(light|dark)/)
|
|
38
|
-
style = m !== null ? m[1] : null
|
|
39
|
-
}
|
|
40
|
-
if (style === null)
|
|
41
|
-
style = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
|
42
|
-
document.documentElement.className = "theme-" + style
|
|
43
|
-
})()
|
|
44
|
-
function themeSwitch () {
|
|
45
|
-
const style = document.documentElement.className === "theme-dark" ? "light" : "dark"
|
|
46
|
-
document.documentElement.className = "theme-" + style
|
|
47
|
-
try { localStorage.setItem("specbook-theme", style) }
|
|
48
|
-
catch { /* an inaccessible storage just loses the choice */ }
|
|
49
|
-
}
|
|
32
|
+
{{ Document.themescript }}
|
|
50
33
|
</script>
|
|
51
34
|
</head>
|
|
52
35
|
<body>
|
|
@@ -60,18 +43,40 @@ const templates = {
|
|
|
60
43
|
<span class="search-clear" id="search-clear" title="clear search">×</span>
|
|
61
44
|
</div>
|
|
62
45
|
</div>
|
|
46
|
+
<div class="scroll-progress" title="scroll to top"><svg class="scroll-ring" viewBox="0 0 44 44" fill="none" stroke-width="2.5"><circle class="scroll-todo" cx="22" cy="22" r="20"/><circle class="scroll-done" cx="22" cy="22" r="20" stroke-linecap="round" pathLength="1" stroke-dasharray="1" stroke-dashoffset="1"/></svg><svg class="scroll-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg></div>
|
|
63
47
|
{{ Document.tocpanel }}
|
|
64
48
|
{{ Document.titlepage }}
|
|
65
49
|
{{ Document.toc }}
|
|
66
50
|
{{ Document.doc }}
|
|
67
51
|
{{ Document.artifacts }}
|
|
68
52
|
{% if Document.search %}<script>{{ Document.search }}</script>{% endif %}
|
|
53
|
+
<script>{{ Document.progress }}</script>
|
|
69
54
|
{% if Document.tocpanel %}<script>{{ Document.tocscript }}</script>{% endif %}
|
|
70
55
|
{% if Document.info %}<script>{{ Document.info }}</script>{% endif %}
|
|
71
56
|
{% if Document.realtime %}<script class="realtime">{{ Document.realtime }}</script>{% endif %}
|
|
72
57
|
</body>
|
|
73
58
|
</html>
|
|
74
59
|
`,
|
|
60
|
+
/* <Placeholder/> */
|
|
61
|
+
"Placeholder": textframe `
|
|
62
|
+
<!DOCTYPE html>
|
|
63
|
+
<html>
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="utf-8"/>
|
|
66
|
+
<title>{{ Placeholder.title }}</title>
|
|
67
|
+
<style>
|
|
68
|
+
{{ Placeholder.css }}
|
|
69
|
+
</style>
|
|
70
|
+
<script>
|
|
71
|
+
{{ Placeholder.themescript }}
|
|
72
|
+
</script>
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<div class="placeholder">{{ Placeholder.message }}</div>
|
|
76
|
+
<script class="realtime">{{ Placeholder.realtime }}</script>
|
|
77
|
+
</body>
|
|
78
|
+
</html>
|
|
79
|
+
`,
|
|
75
80
|
/* <TitlePage/> */
|
|
76
81
|
"TitlePage": textframe `
|
|
77
82
|
<div class="titlepage" id="titlepage">
|
|
@@ -213,8 +218,43 @@ const templates = {
|
|
|
213
218
|
</table>
|
|
214
219
|
`
|
|
215
220
|
};
|
|
216
|
-
/* the client-side script of the
|
|
217
|
-
|
|
221
|
+
/* the client-side script of the color theme: it applies the stored
|
|
222
|
+
choice (or, without one, the document default and finally the system
|
|
223
|
+
preference) before the first paint and toggles it on demand. The
|
|
224
|
+
live preview placeholder page carries it, too, as an in-place
|
|
225
|
+
document update never re-executes a head script */
|
|
226
|
+
const themeScript = textframe `
|
|
227
|
+
(function () {
|
|
228
|
+
let style = null
|
|
229
|
+
try { style = localStorage.getItem("specbook-theme") }
|
|
230
|
+
catch { /* an inaccessible storage just means no stored choice */ }
|
|
231
|
+
if (style === null) {
|
|
232
|
+
const m = document.documentElement.className.match(/theme-(light|dark)/)
|
|
233
|
+
style = m !== null ? m[1] : null
|
|
234
|
+
}
|
|
235
|
+
if (style === null)
|
|
236
|
+
style = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
|
237
|
+
document.documentElement.className = "theme-" + style
|
|
238
|
+
})()
|
|
239
|
+
function themeSwitch () {
|
|
240
|
+
const style = document.documentElement.className === "theme-dark" ? "light" : "dark"
|
|
241
|
+
document.documentElement.className = "theme-" + style
|
|
242
|
+
try { localStorage.setItem("specbook-theme", style) }
|
|
243
|
+
catch { /* an inaccessible storage just loses the choice */ }
|
|
244
|
+
}
|
|
245
|
+
`;
|
|
246
|
+
/* the minimal stylesheet of the live preview placeholder page: it also
|
|
247
|
+
provides the <style> element the in-place document update swaps with
|
|
248
|
+
the real stylesheet, so it carries the centered message only */
|
|
249
|
+
const placeholderStylesheet = textframe `
|
|
250
|
+
html.theme-light body { color: #333333; background-color: #ffffff }
|
|
251
|
+
html.theme-dark body { color: #cccccc; background-color: #1a1a1a }
|
|
252
|
+
body { font-family: sans-serif; margin: 0 }
|
|
253
|
+
div.placeholder { position: absolute; top: 50%; left: 0; right: 0;
|
|
254
|
+
transform: translateY(-50%); text-align: center; opacity: 0.6 }
|
|
255
|
+
`;
|
|
256
|
+
/* the client-side script of the live preview: it connects back to
|
|
257
|
+
its own page over WebSocket (the URL scheme "http"/"https" replaced with
|
|
218
258
|
"ws"/"wss"), re-connects every second after a lost connection, and
|
|
219
259
|
updates the page on a received "RELOAD" command as well as on every
|
|
220
260
|
re-established connection (as the server was restarted meanwhile).
|
|
@@ -237,7 +277,11 @@ const realtimeScript = textframe `
|
|
|
237
277
|
icon.className = "realtime-status " + state
|
|
238
278
|
}
|
|
239
279
|
let blink = 0
|
|
280
|
+
let seq = 0
|
|
240
281
|
const update = async () => {
|
|
282
|
+
/* a superseded update (an overlapping newer one started
|
|
283
|
+
meanwhile) is dropped, as its stale page could arrive last */
|
|
284
|
+
const mine = ++seq
|
|
241
285
|
let response
|
|
242
286
|
try {
|
|
243
287
|
response = await fetch(window.location.pathname, { cache: "no-store" })
|
|
@@ -248,7 +292,10 @@ const realtimeScript = textframe `
|
|
|
248
292
|
}
|
|
249
293
|
if (!response.ok)
|
|
250
294
|
return
|
|
251
|
-
const
|
|
295
|
+
const html = await response.text()
|
|
296
|
+
if (mine !== seq)
|
|
297
|
+
return
|
|
298
|
+
const doc = new DOMParser().parseFromString(html, "text/html")
|
|
252
299
|
const x = window.scrollX
|
|
253
300
|
const y = window.scrollY
|
|
254
301
|
document.title = doc.title
|
|
@@ -294,6 +341,47 @@ const realtimeScript = textframe `
|
|
|
294
341
|
connect()
|
|
295
342
|
})()
|
|
296
343
|
`;
|
|
344
|
+
/* the client-side script of the scroll progress meter: it drives the
|
|
345
|
+
DONE arc of the ring (through the dash offset of its unit-length
|
|
346
|
+
circle) with the scrolled fraction of the document, shows the meter
|
|
347
|
+
once the page is scrolled beyond 400px, and scrolls the page back
|
|
348
|
+
to the top on a click (stripping the URL hash, so a later reload
|
|
349
|
+
stays at the top). The updates are throttled onto animation frames,
|
|
350
|
+
and the window-bound listeners retire themselves once a live preview
|
|
351
|
+
body swap replaced the meter (whose fresh script re-attaches) */
|
|
352
|
+
const scrollProgressScript = textframe `
|
|
353
|
+
(function () {
|
|
354
|
+
const meter = document.querySelector("div.scroll-progress")
|
|
355
|
+
const ring = meter.querySelector("circle.scroll-done")
|
|
356
|
+
let ticking = false
|
|
357
|
+
const update = () => {
|
|
358
|
+
ticking = false
|
|
359
|
+
if (!meter.isConnected) {
|
|
360
|
+
window.removeEventListener("scroll", schedule)
|
|
361
|
+
window.removeEventListener("resize", schedule)
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
const doc = document.documentElement
|
|
365
|
+
const max = doc.scrollHeight - doc.clientHeight
|
|
366
|
+
ring.setAttribute("stroke-dashoffset", String(1 - (max > 0 ? doc.scrollTop / max : 0)))
|
|
367
|
+
meter.classList.toggle("shown", doc.scrollTop > 400)
|
|
368
|
+
}
|
|
369
|
+
const schedule = () => {
|
|
370
|
+
if (!ticking) {
|
|
371
|
+
ticking = true
|
|
372
|
+
requestAnimationFrame(update)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
meter.addEventListener("click", () => {
|
|
376
|
+
window.scrollTo({ top: 0, behavior: "smooth" })
|
|
377
|
+
if (window.location.hash)
|
|
378
|
+
history.replaceState(null, "", window.location.pathname + window.location.search)
|
|
379
|
+
})
|
|
380
|
+
window.addEventListener("scroll", schedule, { passive: true })
|
|
381
|
+
window.addEventListener("resize", schedule)
|
|
382
|
+
update()
|
|
383
|
+
})()
|
|
384
|
+
`;
|
|
297
385
|
/* the client-side script of the table of contents side panel: it
|
|
298
386
|
slides the panel out of and back into the viewport edge (closing it
|
|
299
387
|
also on a jump, on "Escape", and on a click outside), remembers the
|
|
@@ -618,8 +706,19 @@ const env = new nunjucks.Environment(null, { autoescape: true });
|
|
|
618
706
|
nunjucksAddons(env);
|
|
619
707
|
/* mark pre-rendered HTML as safe for template interpolation */
|
|
620
708
|
const safe = (html) => new nunjucks.runtime.SafeString(html);
|
|
709
|
+
/* the built-in Nunjucks templates compiled on first use, as
|
|
710
|
+
renderString() would re-compile a template on every single call
|
|
711
|
+
(i.e. for every rendered object, property table, and description) */
|
|
712
|
+
const compiled = new Map();
|
|
621
713
|
/* render one of the built-in Nunjucks templates with a context */
|
|
622
|
-
const render = (name, context) =>
|
|
714
|
+
const render = (name, context) => {
|
|
715
|
+
let template = compiled.get(name);
|
|
716
|
+
if (template === undefined) {
|
|
717
|
+
template = new nunjucks.Template(templates[name], env, undefined, true);
|
|
718
|
+
compiled.set(name, template);
|
|
719
|
+
}
|
|
720
|
+
return template.render(context);
|
|
721
|
+
};
|
|
623
722
|
/* the active per-document reference expander, fully-qualified
|
|
624
723
|
anchor paths, member-carrying property value constraints, object
|
|
625
724
|
schema nodes, pre-rendered diagram SVGs, description popup keys
|
|
@@ -944,8 +1043,8 @@ const diagramOf = (object) => {
|
|
|
944
1043
|
headers of a plain table */
|
|
945
1044
|
const renderTable = (childs, maxColumns) => {
|
|
946
1045
|
const { keys, desc } = tableShape(childs);
|
|
947
|
-
const
|
|
948
|
-
if (!
|
|
1046
|
+
const diagrammed = childs.some((child) => diagramOf(child) !== "");
|
|
1047
|
+
if (!diagrammed && 1 + keys.length + (desc ? 1 : 0) <= maxColumns)
|
|
949
1048
|
return render("Table", { Table: {
|
|
950
1049
|
head: childs[0].kind !== "" ? childs[0].kind : "Name",
|
|
951
1050
|
info: infoKeyOf(childs[0]),
|
|
@@ -1179,6 +1278,19 @@ const makeLinker = (index) => (text, compact) => expandReferences(text, (referen
|
|
|
1179
1278
|
`<a href="#${escapeHtml(anchorOf(target))}" class="link-compact"${specAttr(target)}>${target.name}</a>` :
|
|
1180
1279
|
`<a href="#${escapeHtml(anchorOf(target))}" class="link-full">${full}</a>`;
|
|
1181
1280
|
});
|
|
1281
|
+
/* render the placeholder page of the live preview, served instead of
|
|
1282
|
+
the document before the first successful export: it carries the very
|
|
1283
|
+
same client-side script as the regular export, so the page recovers
|
|
1284
|
+
on its own -- as the usual in-place document update -- once the
|
|
1285
|
+
specification becomes exportable, plus a <style> element and the
|
|
1286
|
+
color theme script the update expects to find in the head */
|
|
1287
|
+
export const renderPlaceholder = (message) => render("Placeholder", { Placeholder: {
|
|
1288
|
+
title: "SpecBook",
|
|
1289
|
+
css: safe(placeholderStylesheet),
|
|
1290
|
+
themescript: safe(themeScript),
|
|
1291
|
+
realtime: safe(realtimeScript),
|
|
1292
|
+
message
|
|
1293
|
+
} });
|
|
1182
1294
|
/* render the entire specification into a self-contained HTML document,
|
|
1183
1295
|
with the build-time pre-assembled stylesheet embedded inline, the
|
|
1184
1296
|
artifact timestamps aggregated into min(Created)/max(Modified),
|
|
@@ -1191,47 +1303,48 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
|
|
|
1191
1303
|
/* the document language selects the smart typography quote style */
|
|
1192
1304
|
const lang = documentLang(specification);
|
|
1193
1305
|
quotes = quoteStyles[lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
|
|
1194
|
-
/* establish the per-document rendering state
|
|
1306
|
+
/* establish the per-document rendering state (released again
|
|
1307
|
+
below, also on a failure of the collecting or rendering) */
|
|
1195
1308
|
const index = buildLinkIndex(specification);
|
|
1196
|
-
anchors = anchorPaths(index);
|
|
1197
|
-
members = config !== undefined ? collectMembers(config, new Map()) : null;
|
|
1198
|
-
schemas = config !== undefined ? collectSchemas(specification, config) : null;
|
|
1199
|
-
diagrams = rendered;
|
|
1200
|
-
/* collect the schema descriptions for the description popups,
|
|
1201
|
-
plus the object parents composing their title paths */
|
|
1202
|
-
let info = null;
|
|
1203
|
-
if (config !== undefined) {
|
|
1204
|
-
infoKeys = new Map();
|
|
1205
|
-
info = {};
|
|
1206
|
-
collectInfo(config, "", infoKeys, info);
|
|
1207
|
-
const parents = new Map();
|
|
1208
|
-
for (const node of index)
|
|
1209
|
-
if (node.parent !== undefined)
|
|
1210
|
-
parents.set(node.object, node.parent.object);
|
|
1211
|
-
infoParents = parents;
|
|
1212
|
-
}
|
|
1213
|
-
linker = makeLinker(index);
|
|
1214
|
-
/* collect the corpus descriptions of the object instances for the
|
|
1215
|
-
description popups (after the linker is in place, as the
|
|
1216
|
-
pre-rendered descriptions expand their references, too) */
|
|
1217
|
-
let spec = null;
|
|
1218
|
-
if (info !== null) {
|
|
1219
|
-
spec = {};
|
|
1220
|
-
for (const artifact of specification.artifacts)
|
|
1221
|
-
collectSpec(artifact.objects, spec);
|
|
1222
|
-
}
|
|
1223
|
-
/* the artifact timestamps aggregate into the earliest creation
|
|
1224
|
-
and the latest modification timestamp of the document */
|
|
1225
|
-
const created = new Date(Math.min(...specification.artifacts.map((artifact) => artifact.created.getTime())));
|
|
1226
|
-
const modified = new Date(Math.max(...specification.artifacts.map((artifact) => artifact.modified.getTime())));
|
|
1227
|
-
/* a "META: Title" object becomes the title page and leaves the regular
|
|
1228
|
-
flow, while its diagram becomes the "Diagram of Contents" page */
|
|
1229
|
-
const title = titlePageObject(specification);
|
|
1230
|
-
const artifacts = specification.artifacts
|
|
1231
|
-
.filter((artifact) => !artifact.objects.some(isTitleObject));
|
|
1232
|
-
const meta = titleObject(specification);
|
|
1233
|
-
const doc = meta !== undefined ? rendered?.get(meta) : undefined;
|
|
1234
1309
|
try {
|
|
1310
|
+
anchors = anchorPaths(index);
|
|
1311
|
+
members = config !== undefined ? collectMembers(config, new Map()) : null;
|
|
1312
|
+
schemas = config !== undefined ? collectSchemas(specification, config) : null;
|
|
1313
|
+
diagrams = rendered;
|
|
1314
|
+
/* collect the schema descriptions for the description popups,
|
|
1315
|
+
plus the object parents composing their title paths */
|
|
1316
|
+
let info = null;
|
|
1317
|
+
if (config !== undefined) {
|
|
1318
|
+
infoKeys = new Map();
|
|
1319
|
+
info = {};
|
|
1320
|
+
collectInfo(config, "", infoKeys, info);
|
|
1321
|
+
const parents = new Map();
|
|
1322
|
+
for (const node of index)
|
|
1323
|
+
if (node.parent !== undefined)
|
|
1324
|
+
parents.set(node.object, node.parent.object);
|
|
1325
|
+
infoParents = parents;
|
|
1326
|
+
}
|
|
1327
|
+
linker = makeLinker(index);
|
|
1328
|
+
/* collect the corpus descriptions of the object instances for the
|
|
1329
|
+
description popups (after the linker is in place, as the
|
|
1330
|
+
pre-rendered descriptions expand their references, too) */
|
|
1331
|
+
let spec = null;
|
|
1332
|
+
if (info !== null) {
|
|
1333
|
+
spec = {};
|
|
1334
|
+
for (const artifact of specification.artifacts)
|
|
1335
|
+
collectSpec(artifact.objects, spec);
|
|
1336
|
+
}
|
|
1337
|
+
/* the artifact timestamps aggregate into the earliest creation
|
|
1338
|
+
and the latest modification timestamp of the document */
|
|
1339
|
+
const created = new Date(Math.min(...specification.artifacts.map((artifact) => artifact.created.getTime())));
|
|
1340
|
+
const modified = new Date(Math.max(...specification.artifacts.map((artifact) => artifact.modified.getTime())));
|
|
1341
|
+
/* a "META: Title" object becomes the title page and leaves the regular
|
|
1342
|
+
flow, while its diagram becomes the "Diagram of Contents" page */
|
|
1343
|
+
const title = titlePageObject(specification);
|
|
1344
|
+
const artifacts = specification.artifacts
|
|
1345
|
+
.filter((artifact) => !artifact.objects.some(isTitleObject));
|
|
1346
|
+
const meta = titleObject(specification);
|
|
1347
|
+
const doc = meta !== undefined ? rendered?.get(meta) : undefined;
|
|
1235
1348
|
const objects = artifacts.flatMap((artifact) => artifact.objects);
|
|
1236
1349
|
const entries = tocEntries(objects, tocPages);
|
|
1237
1350
|
return render("Document", { Document: {
|
|
@@ -1239,9 +1352,11 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
|
|
|
1239
1352
|
lang,
|
|
1240
1353
|
theme: documentThemeStyle(specification)?.toLowerCase(),
|
|
1241
1354
|
css: safe(css ?? stylesheet()),
|
|
1355
|
+
themescript: safe(themeScript),
|
|
1242
1356
|
titlepage: title !== undefined ?
|
|
1243
1357
|
safe(renderTitlePage(title, formatDate(created), formatDate(modified))) : "",
|
|
1244
1358
|
search: safe(searchScript()),
|
|
1359
|
+
progress: safe(scrollProgressScript),
|
|
1245
1360
|
info: info !== null ? safe(infoScript(info, spec ?? {})) : "",
|
|
1246
1361
|
realtime: realtime ? safe(realtimeScript) : "",
|
|
1247
1362
|
toc: entries.length > 0 ? safe(render("Toc", { Toc: { entries } })) : "",
|
|
@@ -33,13 +33,15 @@ const renderDescriptionMd = (description) => description.description + (descript
|
|
|
33
33
|
const nameSuffixMd = (object) => (object.primary === true ? " (*)" : "") +
|
|
34
34
|
(object.paren !== undefined ? ` (${object.paren})` : "") +
|
|
35
35
|
(object.anchor !== undefined ? ` {{${object.anchor}}}` : "");
|
|
36
|
-
/* whether any descendant of an object carries a
|
|
37
|
-
Concise Format item can carry -- a
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
/* whether any descendant of an object carries a name or a description
|
|
37
|
+
which no Concise Format item can carry -- a name carrying a ";" (it
|
|
38
|
+
would split the head segment), or a multi-line description (a fenced
|
|
39
|
+
code block, an ordered list, a blockquote, or multiple paragraphs) or
|
|
40
|
+
one carrying a ";", as only a ";"-free description stays a single
|
|
41
|
+
segment on re-parsing instead of splitting off a spurious property */
|
|
42
|
+
const complexBelowMd = (object) => object.childs.some((child) => child.name.includes(";")
|
|
43
|
+
|| (child.description !== undefined
|
|
44
|
+
&& /[\n;]/.test(renderDescriptionMd(child.description))) || complexBelowMd(child));
|
|
43
45
|
/* render an object in the Concise Format as a list item of the
|
|
44
46
|
given nesting depth, with its childs as nested list items */
|
|
45
47
|
const renderConciseMd = (object, depth = 0) => {
|
|
@@ -39,8 +39,10 @@ const addOutline = async (doc, entries) => {
|
|
|
39
39
|
if (dests !== undefined) {
|
|
40
40
|
for (const [name] of dests.entries()) {
|
|
41
41
|
const dest = dests.lookup(name);
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
if (!(dest instanceof PDFArray))
|
|
43
|
+
continue;
|
|
44
|
+
const page = dest.get(0);
|
|
45
|
+
if (page instanceof PDFRef && live.has(page.toString()))
|
|
44
46
|
targets.set(name.decodeText(), dest);
|
|
45
47
|
}
|
|
46
48
|
}
|
|
@@ -168,6 +170,10 @@ export const requireBrowser = async (verbose) => {
|
|
|
168
170
|
`"${literal("npx playwright install chromium")}" once to download the Playwright Chromium`);
|
|
169
171
|
return options;
|
|
170
172
|
};
|
|
173
|
+
/* the bound of the ToC page number fixpoint iteration, as the page
|
|
174
|
+
number column itself shifts the pagination and hence the iteration
|
|
175
|
+
can oscillate instead of ever reaching a stable rendering */
|
|
176
|
+
const tocPasses = 3;
|
|
171
177
|
/* render a self-contained HTML document into a PDF via Playwright,
|
|
172
178
|
re-rendering the HTML with the discovered ToC page numbers */
|
|
173
179
|
export const htmlToPdf = async (renderHtmlPass, heading, outline, titlePage, verbose, css, theme, paper = paperSizeDefault) => {
|
|
@@ -198,19 +204,27 @@ export const htmlToPdf = async (renderHtmlPass, heading, outline, titlePage, ver
|
|
|
198
204
|
};
|
|
199
205
|
/* determine the ToC page numbers via a fixpoint iteration:
|
|
200
206
|
paginate undecorated, extract the per-anchor pages, and
|
|
201
|
-
re-render until the HTML is stable (bounded to
|
|
207
|
+
re-render until the HTML is stable (bounded to "tocPasses"
|
|
202
208
|
passes), as the ToC page number column itself can shift
|
|
203
209
|
the pagination */
|
|
204
210
|
verbose("determining ToC page numbers");
|
|
205
211
|
let html = await renderHtmlPass();
|
|
206
212
|
let plain = await renderPdf(html);
|
|
207
|
-
|
|
213
|
+
let stable = false;
|
|
214
|
+
for (let i = 0; i < tocPasses && !stable; i++) {
|
|
208
215
|
const next = await renderHtmlPass(await anchorPages(plain));
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
216
|
+
stable = next === html;
|
|
217
|
+
if (!stable) {
|
|
218
|
+
html = next;
|
|
219
|
+
plain = await renderPdf(html);
|
|
220
|
+
}
|
|
213
221
|
}
|
|
222
|
+
/* report an unconverged iteration, as the ToC then carries the
|
|
223
|
+
page numbers of a pagination other than the final one and
|
|
224
|
+
would otherwise ship silently wrong page numbers */
|
|
225
|
+
if (!stable)
|
|
226
|
+
verbose(`the ToC page numbers did not stabilize within ${literal(tocPasses)} ` +
|
|
227
|
+
"passes -- they can be off by a page", "notice");
|
|
214
228
|
/* render the final document, decorated with header/footer */
|
|
215
229
|
const decorated = await renderPdf(html, {
|
|
216
230
|
displayHeaderFooter: true,
|
|
@@ -16,6 +16,8 @@ const GradiaConfigSchemas = Object.fromEntries(Object.entries(Gradia.config).map
|
|
|
16
16
|
typeof value === "number" ? v.pipe(v.number(), v.finite(), v.minValue(0)) : v.string()]));
|
|
17
17
|
const SchemaDiagramConfig = v.partial(v.strictObject(GradiaConfigSchemas, (issue) => issue.expected === "never" ? "unknown Gradia rendering option" :
|
|
18
18
|
"expected a map of Gradia rendering options"));
|
|
19
|
+
/* the Valibot schemas mirroring the types above, with the object
|
|
20
|
+
kind schema referencing itself lazily for the nested childs */
|
|
19
21
|
const SchemaProperty = v.strictObject({
|
|
20
22
|
name: v.string(),
|
|
21
23
|
desc: v.optional(v.string()),
|
package/dst/specbook-format.md
CHANGED
|
@@ -416,8 +416,9 @@ type SchemaGradiaConfig = Partial<{
|
|
|
416
416
|
|
|
417
417
|
- `SchemaProperty.present?: boolean | string`:
|
|
418
418
|
whether some value (`true`) or a value matching a regexp or enum
|
|
419
|
-
expression has to occur on at least one of the
|
|
420
|
-
|
|
419
|
+
expression has to occur on at least one sibling object of the kind,
|
|
420
|
+
so that `unique` plus `present` demand exactly one and `present` on
|
|
421
|
+
an optional kind demands at least one object of that kind,
|
|
421
422
|
BECAUSE some markers, like an initial state, are not just single but indispensable
|
|
422
423
|
|
|
423
424
|
- `SchemaProperty.local?: boolean`:
|