@rse/specbook 1.2.2 → 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 +2 -1
- package/dst/specbook-api.js +7 -3
- package/dst/specbook-cmd-describe.js +2 -2
- package/dst/specbook-cmd-lint.js +10 -2
- package/dst/specbook-cmd-preview.js +7 -10
- package/dst/specbook-config.d.ts +4 -0
- package/dst/specbook-config.js +32 -9
- package/dst/specbook-diagram.js +13 -7
- package/dst/specbook-export-common.js +33 -22
- package/dst/specbook-export-html.css +62 -0
- package/dst/specbook-export-html.js +108 -45
- package/dst/specbook-export-md.js +9 -7
- package/dst/specbook-export-pdf.js +4 -2
- package/dst/specbook-format-schema.js +2 -0
- package/dst/specbook-format-spec.js +2 -0
- package/dst/specbook-format.yaml +200 -216
- package/dst/specbook-link.js +4 -4
- package/dst/specbook-parse-common.d.ts +1 -0
- package/dst/specbook-parse-common.js +5 -0
- package/dst/specbook-parse-semantic.js +9 -9
- package/dst/specbook-parse-syntax.js +16 -21
- package/dst/specbook-parse.d.ts +5 -0
- package/dst/specbook-parse.js +20 -1
- package/package.json +3 -3
|
@@ -43,12 +43,14 @@ const templates = {
|
|
|
43
43
|
<span class="search-clear" id="search-clear" title="clear search">×</span>
|
|
44
44
|
</div>
|
|
45
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>
|
|
46
47
|
{{ Document.tocpanel }}
|
|
47
48
|
{{ Document.titlepage }}
|
|
48
49
|
{{ Document.toc }}
|
|
49
50
|
{{ Document.doc }}
|
|
50
51
|
{{ Document.artifacts }}
|
|
51
52
|
{% if Document.search %}<script>{{ Document.search }}</script>{% endif %}
|
|
53
|
+
<script>{{ Document.progress }}</script>
|
|
52
54
|
{% if Document.tocpanel %}<script>{{ Document.tocscript }}</script>{% endif %}
|
|
53
55
|
{% if Document.info %}<script>{{ Document.info }}</script>{% endif %}
|
|
54
56
|
{% if Document.realtime %}<script class="realtime">{{ Document.realtime }}</script>{% endif %}
|
|
@@ -251,8 +253,8 @@ const placeholderStylesheet = textframe `
|
|
|
251
253
|
div.placeholder { position: absolute; top: 50%; left: 0; right: 0;
|
|
252
254
|
transform: translateY(-50%); text-align: center; opacity: 0.6 }
|
|
253
255
|
`;
|
|
254
|
-
/* the client-side script of the live preview: it connects back to
|
|
255
|
-
own page over WebSocket (the URL scheme "http"/"https" replaced with
|
|
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
|
|
256
258
|
"ws"/"wss"), re-connects every second after a lost connection, and
|
|
257
259
|
updates the page on a received "RELOAD" command as well as on every
|
|
258
260
|
re-established connection (as the server was restarted meanwhile).
|
|
@@ -275,7 +277,11 @@ const realtimeScript = textframe `
|
|
|
275
277
|
icon.className = "realtime-status " + state
|
|
276
278
|
}
|
|
277
279
|
let blink = 0
|
|
280
|
+
let seq = 0
|
|
278
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
|
|
279
285
|
let response
|
|
280
286
|
try {
|
|
281
287
|
response = await fetch(window.location.pathname, { cache: "no-store" })
|
|
@@ -286,7 +292,10 @@ const realtimeScript = textframe `
|
|
|
286
292
|
}
|
|
287
293
|
if (!response.ok)
|
|
288
294
|
return
|
|
289
|
-
const
|
|
295
|
+
const html = await response.text()
|
|
296
|
+
if (mine !== seq)
|
|
297
|
+
return
|
|
298
|
+
const doc = new DOMParser().parseFromString(html, "text/html")
|
|
290
299
|
const x = window.scrollX
|
|
291
300
|
const y = window.scrollY
|
|
292
301
|
document.title = doc.title
|
|
@@ -332,6 +341,47 @@ const realtimeScript = textframe `
|
|
|
332
341
|
connect()
|
|
333
342
|
})()
|
|
334
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
|
+
`;
|
|
335
385
|
/* the client-side script of the table of contents side panel: it
|
|
336
386
|
slides the panel out of and back into the viewport edge (closing it
|
|
337
387
|
also on a jump, on "Escape", and on a click outside), remembers the
|
|
@@ -656,8 +706,19 @@ const env = new nunjucks.Environment(null, { autoescape: true });
|
|
|
656
706
|
nunjucksAddons(env);
|
|
657
707
|
/* mark pre-rendered HTML as safe for template interpolation */
|
|
658
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();
|
|
659
713
|
/* render one of the built-in Nunjucks templates with a context */
|
|
660
|
-
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
|
+
};
|
|
661
722
|
/* the active per-document reference expander, fully-qualified
|
|
662
723
|
anchor paths, member-carrying property value constraints, object
|
|
663
724
|
schema nodes, pre-rendered diagram SVGs, description popup keys
|
|
@@ -982,8 +1043,8 @@ const diagramOf = (object) => {
|
|
|
982
1043
|
headers of a plain table */
|
|
983
1044
|
const renderTable = (childs, maxColumns) => {
|
|
984
1045
|
const { keys, desc } = tableShape(childs);
|
|
985
|
-
const
|
|
986
|
-
if (!
|
|
1046
|
+
const diagrammed = childs.some((child) => diagramOf(child) !== "");
|
|
1047
|
+
if (!diagrammed && 1 + keys.length + (desc ? 1 : 0) <= maxColumns)
|
|
987
1048
|
return render("Table", { Table: {
|
|
988
1049
|
head: childs[0].kind !== "" ? childs[0].kind : "Name",
|
|
989
1050
|
info: infoKeyOf(childs[0]),
|
|
@@ -1242,47 +1303,48 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
|
|
|
1242
1303
|
/* the document language selects the smart typography quote style */
|
|
1243
1304
|
const lang = documentLang(specification);
|
|
1244
1305
|
quotes = quoteStyles[lang?.toLowerCase().split(/[-_]/)[0] ?? "en"] ?? quoteStyles.en;
|
|
1245
|
-
/* 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) */
|
|
1246
1308
|
const index = buildLinkIndex(specification);
|
|
1247
|
-
anchors = anchorPaths(index);
|
|
1248
|
-
members = config !== undefined ? collectMembers(config, new Map()) : null;
|
|
1249
|
-
schemas = config !== undefined ? collectSchemas(specification, config) : null;
|
|
1250
|
-
diagrams = rendered;
|
|
1251
|
-
/* collect the schema descriptions for the description popups,
|
|
1252
|
-
plus the object parents composing their title paths */
|
|
1253
|
-
let info = null;
|
|
1254
|
-
if (config !== undefined) {
|
|
1255
|
-
infoKeys = new Map();
|
|
1256
|
-
info = {};
|
|
1257
|
-
collectInfo(config, "", infoKeys, info);
|
|
1258
|
-
const parents = new Map();
|
|
1259
|
-
for (const node of index)
|
|
1260
|
-
if (node.parent !== undefined)
|
|
1261
|
-
parents.set(node.object, node.parent.object);
|
|
1262
|
-
infoParents = parents;
|
|
1263
|
-
}
|
|
1264
|
-
linker = makeLinker(index);
|
|
1265
|
-
/* collect the corpus descriptions of the object instances for the
|
|
1266
|
-
description popups (after the linker is in place, as the
|
|
1267
|
-
pre-rendered descriptions expand their references, too) */
|
|
1268
|
-
let spec = null;
|
|
1269
|
-
if (info !== null) {
|
|
1270
|
-
spec = {};
|
|
1271
|
-
for (const artifact of specification.artifacts)
|
|
1272
|
-
collectSpec(artifact.objects, spec);
|
|
1273
|
-
}
|
|
1274
|
-
/* the artifact timestamps aggregate into the earliest creation
|
|
1275
|
-
and the latest modification timestamp of the document */
|
|
1276
|
-
const created = new Date(Math.min(...specification.artifacts.map((artifact) => artifact.created.getTime())));
|
|
1277
|
-
const modified = new Date(Math.max(...specification.artifacts.map((artifact) => artifact.modified.getTime())));
|
|
1278
|
-
/* a "META: Title" object becomes the title page and leaves the regular
|
|
1279
|
-
flow, while its diagram becomes the "Diagram of Contents" page */
|
|
1280
|
-
const title = titlePageObject(specification);
|
|
1281
|
-
const artifacts = specification.artifacts
|
|
1282
|
-
.filter((artifact) => !artifact.objects.some(isTitleObject));
|
|
1283
|
-
const meta = titleObject(specification);
|
|
1284
|
-
const doc = meta !== undefined ? rendered?.get(meta) : undefined;
|
|
1285
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;
|
|
1286
1348
|
const objects = artifacts.flatMap((artifact) => artifact.objects);
|
|
1287
1349
|
const entries = tocEntries(objects, tocPages);
|
|
1288
1350
|
return render("Document", { Document: {
|
|
@@ -1294,6 +1356,7 @@ export const renderHtml = async (specification, config, tocPages, css, realtime
|
|
|
1294
1356
|
titlepage: title !== undefined ?
|
|
1295
1357
|
safe(renderTitlePage(title, formatDate(created), formatDate(modified))) : "",
|
|
1296
1358
|
search: safe(searchScript()),
|
|
1359
|
+
progress: safe(scrollProgressScript),
|
|
1297
1360
|
info: info !== null ? safe(infoScript(info, spec ?? {})) : "",
|
|
1298
1361
|
realtime: realtime ? safe(realtimeScript) : "",
|
|
1299
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
|
}
|
|
@@ -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()),
|