@rse/specbook 1.2.2 → 1.2.4
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 +112 -203
- package/dst/specbook-api.js +7 -3
- package/dst/specbook-cmd-describe.js +2 -2
- package/dst/specbook-cmd-export.js +4 -1
- 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 +37 -14
- package/dst/specbook-diagram.js +16 -10
- package/dst/specbook-export-ast.js +1 -1
- package/dst/specbook-export-common.js +34 -23
- package/dst/specbook-export-html.css +62 -0
- package/dst/specbook-export-html.d.ts +1 -1
- package/dst/specbook-export-html.js +145 -82
- package/dst/specbook-export-md.js +17 -15
- package/dst/specbook-export-pdf.js +11 -9
- package/dst/specbook-format-schema.d.ts +1 -1
- package/dst/specbook-format-schema.js +3 -1
- package/dst/specbook-format-spec.d.ts +1 -1
- package/dst/specbook-format-spec.js +3 -1
- package/dst/specbook-format.md +4 -4
- package/dst/specbook-format.yaml +240 -256
- package/dst/specbook-link.js +6 -6
- package/dst/specbook-parse-common.d.ts +1 -0
- package/dst/specbook-parse-common.js +5 -0
- package/dst/specbook-parse-semantic.js +31 -31
- package/dst/specbook-parse-syntax.js +21 -26
- package/dst/specbook-parse.d.ts +5 -0
- package/dst/specbook-parse.js +20 -1
- package/package.json +4 -4
|
@@ -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 %}
|
|
@@ -120,7 +122,7 @@ const templates = {
|
|
|
120
122
|
"TocPanelEntries": textframe `
|
|
121
123
|
<ul>
|
|
122
124
|
{% for entry in Entries %}
|
|
123
|
-
<li><a href="#{{ entry.id }}"><span class="entry"><span class="object-kind"{% if entry.info %} data-info="{{ entry.info }}" data-info-path="{{ entry.infopath }}"{% endif %}>{{ entry.kind }}:</span> <span class="object-name"{% if entry.spec %} data-info-spec="{{ entry.spec }}" data-info-path="{{ entry.specpath }}"{% endif %}>{{ entry.name }}</span></span></a>{{ entry.
|
|
125
|
+
<li><a href="#{{ entry.id }}"><span class="entry"><span class="object-kind"{% if entry.info %} data-info="{{ entry.info }}" data-info-path="{{ entry.infopath }}"{% endif %}>{{ entry.kind }}:</span> <span class="object-name"{% if entry.spec %} data-info-spec="{{ entry.spec }}" data-info-path="{{ entry.specpath }}"{% endif %}>{{ entry.name }}</span></span></a>{{ entry.children }}</li>
|
|
124
126
|
{% endfor %}
|
|
125
127
|
</ul>
|
|
126
128
|
`,
|
|
@@ -144,7 +146,7 @@ const templates = {
|
|
|
144
146
|
{{ Object.diagram }}
|
|
145
147
|
{{ Object.properties }}
|
|
146
148
|
{{ Object.description }}
|
|
147
|
-
{{ Object.
|
|
149
|
+
{{ Object.children }}
|
|
148
150
|
</section>
|
|
149
151
|
`,
|
|
150
152
|
/* <Properties/> */
|
|
@@ -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
|
|
@@ -739,7 +800,7 @@ const collectInfo = (nodes, prefix, keys, info) => {
|
|
|
739
800
|
}
|
|
740
801
|
if (entry.d !== undefined || entry.p !== undefined)
|
|
741
802
|
info[path] = entry;
|
|
742
|
-
collectInfo(schema.
|
|
803
|
+
collectInfo(schema.children ?? [], path, keys, info);
|
|
743
804
|
}
|
|
744
805
|
};
|
|
745
806
|
/* inject the description popup maps into their client-side script ("<"
|
|
@@ -827,7 +888,7 @@ const collectSpec = (objects, spec) => {
|
|
|
827
888
|
.trim();
|
|
828
889
|
if (text !== "")
|
|
829
890
|
spec[anchorOf(object)] = scoped(object, () => block(text, true).toString().trim());
|
|
830
|
-
collectSpec(object.
|
|
891
|
+
collectSpec(object.children, spec);
|
|
831
892
|
}
|
|
832
893
|
};
|
|
833
894
|
/* the literal members of an "enum(...)"/"tags(...)" expression, or
|
|
@@ -852,7 +913,7 @@ const collectMembers = (nodes, result) => {
|
|
|
852
913
|
/* an invalid expression is the concern of lint */
|
|
853
914
|
}
|
|
854
915
|
}
|
|
855
|
-
collectMembers(schema.
|
|
916
|
+
collectMembers(schema.children ?? [], result);
|
|
856
917
|
}
|
|
857
918
|
return result;
|
|
858
919
|
};
|
|
@@ -915,20 +976,20 @@ const effectiveProperties = (object) => {
|
|
|
915
976
|
order (unknown keys appended in occurrence order), with
|
|
916
977
|
"withUnusedProps" injecting the defined but still unused schema
|
|
917
978
|
properties as additional columns */
|
|
918
|
-
const tableShape = (
|
|
919
|
-
const used = [...new Set(
|
|
920
|
-
const schema = schemas?.get(
|
|
979
|
+
const tableShape = (children) => {
|
|
980
|
+
const used = [...new Set(children.flatMap((child) => child.properties.map((property) => property.key)))];
|
|
981
|
+
const schema = schemas?.get(children[0]);
|
|
921
982
|
const names = (schema?.props ?? []).map((prop) => prop.name);
|
|
922
983
|
const known = schema?.format?.withUnusedProps === true ?
|
|
923
984
|
names : names.filter((name) => used.includes(name));
|
|
924
985
|
const keys = [...known, ...used.filter((key) => !names.includes(key))];
|
|
925
|
-
return { keys, desc:
|
|
986
|
+
return { keys, desc: children.some((child) => child.description !== undefined || child.children.length > 0) };
|
|
926
987
|
};
|
|
927
|
-
/* provide the
|
|
988
|
+
/* provide the children of an object taking part in the regular document
|
|
928
989
|
flow: a nested title object leaves the flow, as it is rendered as the
|
|
929
990
|
title page instead (a top-level one instead removes its whole artifact) */
|
|
930
|
-
const
|
|
931
|
-
/* decide whether a single-kind group of
|
|
991
|
+
const flowChildren = (object) => object.children.filter((child) => !isTitleObject(child));
|
|
992
|
+
/* decide whether a single-kind group of children collapses into the
|
|
932
993
|
concise (tabular) rendering: an explicit "format" type of the kind
|
|
933
994
|
wins, "auto" collapses the deepest level only, and inside an already
|
|
934
995
|
concise rendering context "auto" groups implicitly stay concise, too */
|
|
@@ -936,12 +997,12 @@ const conciseGroup = (group, schemaMap, concise) => {
|
|
|
936
997
|
const type = schemaMap?.get(group[0])?.format?.type ?? "auto";
|
|
937
998
|
if (type !== "auto")
|
|
938
999
|
return type === "concise";
|
|
939
|
-
return concise || group.every((child) => child.
|
|
1000
|
+
return concise || group.every((child) => child.children.length === 0);
|
|
940
1001
|
};
|
|
941
|
-
/* group the
|
|
942
|
-
const
|
|
1002
|
+
/* group the children of an object by their kind, preserving order */
|
|
1003
|
+
const groupChildren = (children) => {
|
|
943
1004
|
const groups = new Map();
|
|
944
|
-
for (const child of
|
|
1005
|
+
for (const child of children) {
|
|
945
1006
|
const group = groups.get(child.kind);
|
|
946
1007
|
if (group === undefined)
|
|
947
1008
|
groups.set(child.kind, [child]);
|
|
@@ -950,21 +1011,21 @@ const groupChilds = (childs) => {
|
|
|
950
1011
|
}
|
|
951
1012
|
return [...groups.values()];
|
|
952
1013
|
};
|
|
953
|
-
/* render the
|
|
1014
|
+
/* render the children of an object group-wise by kind: a concise group
|
|
954
1015
|
as one compact table, a complex group as regular nested object
|
|
955
1016
|
renderings */
|
|
956
|
-
const
|
|
1017
|
+
const renderChildren = (object, level, concise) => groupChildren(flowChildren(object)).map((group) => conciseGroup(group, schemas, concise) ?
|
|
957
1018
|
renderTable(group, maxColumnsOf(group[0])) :
|
|
958
1019
|
group.map((child) => renderObject(child, level, concise)).join("")).join("");
|
|
959
1020
|
/* render the description cell of a table row: the description of the
|
|
960
|
-
object followed by its recursively rendered
|
|
1021
|
+
object followed by its recursively rendered children (implicitly or
|
|
961
1022
|
explicitly concise groups as nested sub-tables, explicitly complex
|
|
962
1023
|
ones as regular nested object renderings pressed into the cell);
|
|
963
1024
|
a cell left without any content renders as the absent marker,
|
|
964
1025
|
exactly like a property cell of a not given property */
|
|
965
1026
|
const renderCell = (child) => {
|
|
966
1027
|
let html = child.description !== undefined ? renderDescription(child.description) : "";
|
|
967
|
-
html +=
|
|
1028
|
+
html += renderChildren(child, 6, true);
|
|
968
1029
|
return html.trim() !== "" ? html : "<span class=\"value-absent\"></span>";
|
|
969
1030
|
};
|
|
970
1031
|
/* the pre-rendered diagram of an object as an embeddable block
|
|
@@ -973,27 +1034,27 @@ const diagramOf = (object) => {
|
|
|
973
1034
|
const diagram = diagrams?.get(object);
|
|
974
1035
|
return diagram !== undefined ? safe(`<div class="diagram">${diagram}</div>`) : "";
|
|
975
1036
|
};
|
|
976
|
-
/* render a single-kind group of
|
|
1037
|
+
/* render a single-kind group of children into one compact table:
|
|
977
1038
|
the name first, then the property columns, then the description;
|
|
978
1039
|
a group wider than maxColumns, or carrying diagrams, instead chunks
|
|
979
1040
|
the property and description cells (and the leading diagram) of
|
|
980
1041
|
every object into an embedded per-object table, as a diagram is
|
|
981
1042
|
identifiable only under a header of its own, not under the column
|
|
982
1043
|
headers of a plain table */
|
|
983
|
-
const renderTable = (
|
|
984
|
-
const { keys, desc } = tableShape(
|
|
985
|
-
const
|
|
986
|
-
if (!
|
|
1044
|
+
const renderTable = (children, maxColumns) => {
|
|
1045
|
+
const { keys, desc } = tableShape(children);
|
|
1046
|
+
const diagrammed = children.some((child) => diagramOf(child) !== "");
|
|
1047
|
+
if (!diagrammed && 1 + keys.length + (desc ? 1 : 0) <= maxColumns)
|
|
987
1048
|
return render("Table", { Table: {
|
|
988
|
-
head:
|
|
989
|
-
info: infoKeyOf(
|
|
990
|
-
infopath: infoPathOf(
|
|
1049
|
+
head: children[0].kind !== "" ? children[0].kind : "Name",
|
|
1050
|
+
info: infoKeyOf(children[0]),
|
|
1051
|
+
infopath: infoPathOf(children[0], false),
|
|
991
1052
|
keys,
|
|
992
1053
|
desc,
|
|
993
1054
|
/* under the fixed table layout the description column claims
|
|
994
1055
|
twice the share of a regular column, compressing the others */
|
|
995
1056
|
width: Math.round(200 / (keys.length + 3)),
|
|
996
|
-
rows:
|
|
1057
|
+
rows: children.map((child, i) => scoped(child, () => ({
|
|
997
1058
|
id: anchorOf(child),
|
|
998
1059
|
paren: child.paren,
|
|
999
1060
|
primary: child.primary,
|
|
@@ -1010,11 +1071,11 @@ const renderTable = (childs, maxColumns) => {
|
|
|
1010
1071
|
with the last cell spanning the leftover columns of the final row */
|
|
1011
1072
|
const size = Math.max(1, maxColumns - 1);
|
|
1012
1073
|
return render("TableChunked", { Table: {
|
|
1013
|
-
head:
|
|
1014
|
-
info: infoKeyOf(
|
|
1015
|
-
infopath: infoPathOf(
|
|
1074
|
+
head: children[0].kind !== "" ? children[0].kind : "Name",
|
|
1075
|
+
info: infoKeyOf(children[0]),
|
|
1076
|
+
infopath: infoPathOf(children[0], false),
|
|
1016
1077
|
width: Math.round(100 / maxColumns),
|
|
1017
|
-
rows:
|
|
1078
|
+
rows: children.map((child, i) => scoped(child, () => {
|
|
1018
1079
|
const cells = keys.map((key) => ({ key, desc: false, span: 1,
|
|
1019
1080
|
value: inlineValue(child.kind, child.properties.find((property) => property.key === key)) }));
|
|
1020
1081
|
if (desc)
|
|
@@ -1064,7 +1125,7 @@ const renderObject = (object, level, concise) => {
|
|
|
1064
1125
|
safe(render("Properties", { Properties: inlineProperties(object, properties) })) : "",
|
|
1065
1126
|
description: object.description !== undefined ?
|
|
1066
1127
|
safe(renderDescription(object.description)) : "",
|
|
1067
|
-
|
|
1128
|
+
children: safe(renderChildren(object, level + 1, concise))
|
|
1068
1129
|
} }));
|
|
1069
1130
|
};
|
|
1070
1131
|
/* format a timestamp as its calendar date in local time (the
|
|
@@ -1106,10 +1167,10 @@ const renderTitlePage = (object, created, modified) => {
|
|
|
1106
1167
|
const renderArtifact = (artifact) => render("Artifact", { Artifact: {
|
|
1107
1168
|
objects: safe(artifact.objects.map((object) => renderObject(object, 1, false)).join(""))
|
|
1108
1169
|
} });
|
|
1109
|
-
/* provide the
|
|
1170
|
+
/* provide the children of an object rendered with headings of their own
|
|
1110
1171
|
(skipping the child groups collapsing into compact tables), which
|
|
1111
1172
|
are the ones taking part in the table of contents and its side panel */
|
|
1112
|
-
const
|
|
1173
|
+
const headingChildren = (object) => groupChildren(flowChildren(object))
|
|
1113
1174
|
.filter((group) => !conciseGroup(group, schemas, false))
|
|
1114
1175
|
.flat();
|
|
1115
1176
|
/* flatten the hierarchy of the rendered object headings (exactly like
|
|
@@ -1124,7 +1185,7 @@ const tocEntries = (objects, pages) => {
|
|
|
1124
1185
|
info: infoKeyOf(object), infopath: infoPathOf(object, false),
|
|
1125
1186
|
spec: infoParents !== null ? id : undefined, specpath: infoPathOf(object),
|
|
1126
1187
|
name: inline(object.name), level: Math.min(level, 6), page: pages?.get(id) });
|
|
1127
|
-
collect(
|
|
1188
|
+
collect(headingChildren(object), level + 1);
|
|
1128
1189
|
}
|
|
1129
1190
|
};
|
|
1130
1191
|
collect(objects, 1);
|
|
@@ -1144,7 +1205,7 @@ const renderTocPanel = (objects, title, doc) => {
|
|
|
1144
1205
|
spec: infoParents !== null ? anchorOf(object) : undefined,
|
|
1145
1206
|
specpath: infoPathOf(object),
|
|
1146
1207
|
name: inline(object.name),
|
|
1147
|
-
|
|
1208
|
+
children: safe(entries(headingChildren(object)))
|
|
1148
1209
|
})) });
|
|
1149
1210
|
return render("TocPanel", { TocPanel: { title, doc, entries: safe(entries(objects)) } });
|
|
1150
1211
|
};
|
|
@@ -1156,7 +1217,7 @@ export const htmlOutline = (specification, config) => {
|
|
|
1156
1217
|
const entry = (object) => ({
|
|
1157
1218
|
title: (object.kind !== "" ? `${object.kind}: ` : "") + plainText(object.name),
|
|
1158
1219
|
anchor: paths.get(object) ?? object.id,
|
|
1159
|
-
|
|
1220
|
+
children: groupChildren(flowChildren(object))
|
|
1160
1221
|
.filter((group) => !conciseGroup(group, schemaMap, false))
|
|
1161
1222
|
.flatMap((group) => group.map(entry))
|
|
1162
1223
|
});
|
|
@@ -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,15 +33,17 @@ 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.children.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
|
-
given nesting depth, with its
|
|
46
|
+
given nesting depth, with its children as nested list items */
|
|
45
47
|
const renderConciseMd = (object, depth = 0) => {
|
|
46
48
|
const indent = " ".repeat(depth * 4);
|
|
47
49
|
const segments = [`${object.kind}: ${object.name}${nameSuffixMd(object)}`];
|
|
@@ -60,14 +62,14 @@ const renderConciseMd = (object, depth = 0) => {
|
|
|
60
62
|
`${indent}- ${segments.join("; ").replace(/\.?$/, ".")}` :
|
|
61
63
|
`${indent}- ${segments.join("; ")};`;
|
|
62
64
|
return [item, renderKeyValuesMd(wrapped, `${indent} `),
|
|
63
|
-
...object.
|
|
65
|
+
...object.children.map((child) => renderConciseMd(child, depth + 1))]
|
|
64
66
|
.filter((part) => part !== "").join("\n");
|
|
65
67
|
};
|
|
66
|
-
/* render an object in the Complex Format (levels 1-3), with
|
|
68
|
+
/* render an object in the Complex Format (levels 1-3), with children from
|
|
67
69
|
level 4 upwards in the Concise Format -- unless a descendant carries
|
|
68
|
-
a description no concise item can carry, so all
|
|
70
|
+
a description no concise item can carry, so all children (as siblings
|
|
69
71
|
have to share the format) stay in the Complex Format down to heading
|
|
70
|
-
level 6, the deepest one Markdown knows, below which the
|
|
72
|
+
level 6, the deepest one Markdown knows, below which the children are
|
|
71
73
|
concise regardless -- and the optionally derived Gradia diagram spec
|
|
72
74
|
embedded as a "gradia" fenced code block below the heading */
|
|
73
75
|
const renderObjectMd = (object, level, diagrams) => {
|
|
@@ -81,12 +83,12 @@ const renderObjectMd = (object, level, diagrams) => {
|
|
|
81
83
|
parts.push(renderKeyValuesMd(object.properties));
|
|
82
84
|
if (object.description !== undefined)
|
|
83
85
|
parts.push(renderDescriptionMd(object.description));
|
|
84
|
-
/* the
|
|
86
|
+
/* the children share one format, as a concise item following
|
|
85
87
|
a heading would attach to the heading's object instead */
|
|
86
88
|
if (level >= 6 || (level >= 3 && !complexBelowMd(object)))
|
|
87
|
-
parts.push(object.
|
|
89
|
+
parts.push(object.children.map((child) => renderConciseMd(child)).join("\n"));
|
|
88
90
|
else
|
|
89
|
-
parts.push(...object.
|
|
91
|
+
parts.push(...object.children.map((child) => renderObjectMd(child, level + 1, diagrams)));
|
|
90
92
|
return parts.filter((part) => part !== "").join("\n\n");
|
|
91
93
|
};
|
|
92
94
|
/* render the entire specification into normalized Markdown */
|
|
@@ -28,7 +28,7 @@ const anchorPages = async (pdf) => {
|
|
|
28
28
|
};
|
|
29
29
|
/* attach a hierarchical PDF outline (the "bookmarks" of the viewer
|
|
30
30
|
side-bar) to a document, resolving the entry anchors against the
|
|
31
|
-
named destinations and hoisting the
|
|
31
|
+
named destinations and hoisting the children of an unresolvable entry */
|
|
32
32
|
const addOutline = async (doc, entries) => {
|
|
33
33
|
const { PDFName, PDFDict, PDFArray, PDFRef, PDFHexString } = await import("pdf-lib");
|
|
34
34
|
const context = doc.context;
|
|
@@ -39,15 +39,17 @@ 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
|
}
|
|
47
49
|
const resolve = (entries) => entries.flatMap((entry) => {
|
|
48
50
|
const dest = targets.get(entry.anchor);
|
|
49
|
-
const
|
|
50
|
-
return dest !== undefined ? [{ title: entry.title, dest,
|
|
51
|
+
const children = resolve(entry.children);
|
|
52
|
+
return dest !== undefined ? [{ title: entry.title, dest, children }] : children;
|
|
51
53
|
});
|
|
52
54
|
const items = resolve(entries);
|
|
53
55
|
if (items.length === 0)
|
|
@@ -57,16 +59,16 @@ const addOutline = async (doc, entries) => {
|
|
|
57
59
|
const materialize = (items, parent) => {
|
|
58
60
|
const refs = items.map(() => context.nextRef());
|
|
59
61
|
items.forEach((item, i) => {
|
|
60
|
-
const
|
|
62
|
+
const children = item.children.length > 0 ? materialize(item.children, refs[i]) : undefined;
|
|
61
63
|
context.assign(refs[i], context.obj({
|
|
62
64
|
Title: PDFHexString.fromText(item.title),
|
|
63
65
|
Parent: parent,
|
|
64
66
|
Prev: i > 0 ? refs[i - 1] : undefined,
|
|
65
67
|
Next: i < refs.length - 1 ? refs[i + 1] : undefined,
|
|
66
|
-
First:
|
|
67
|
-
Last:
|
|
68
|
+
First: children?.first,
|
|
69
|
+
Last: children?.last,
|
|
68
70
|
/* a negative count keeps the sub-tree initially collapsed */
|
|
69
|
-
Count:
|
|
71
|
+
Count: children !== undefined ? -item.children.length : undefined,
|
|
70
72
|
Dest: item.dest
|
|
71
73
|
}));
|
|
72
74
|
});
|
|
@@ -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 children */
|
|
19
21
|
const SchemaProperty = v.strictObject({
|
|
20
22
|
name: v.string(),
|
|
21
23
|
desc: v.optional(v.string()),
|
|
@@ -85,6 +87,6 @@ const SchemaObject = v.strictObject({
|
|
|
85
87
|
diagram: v.optional(SchemaDiagram),
|
|
86
88
|
format: v.optional(SchemaFormat),
|
|
87
89
|
props: v.optional(v.array(SchemaProperty)),
|
|
88
|
-
|
|
90
|
+
children: v.optional(v.array(v.lazy(() => SchemaObject)))
|
|
89
91
|
});
|
|
90
92
|
export const Schema = v.pipe(v.array(SchemaObject), v.minLength(1));
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as v from "valibot";
|
|
7
7
|
/* ==== Schema ==== */
|
|
8
|
+
/* the Valibot schemas mirroring the types above, with the object
|
|
9
|
+
schema referencing itself lazily for the nested children */
|
|
8
10
|
const SpecProperty = v.object({
|
|
9
11
|
key: v.string(),
|
|
10
12
|
value: v.string(),
|
|
@@ -24,7 +26,7 @@ const SpecObject = v.object({
|
|
|
24
26
|
primary: v.optional(v.boolean()),
|
|
25
27
|
description: v.optional(SpecDescription),
|
|
26
28
|
properties: v.array(SpecProperty),
|
|
27
|
-
|
|
29
|
+
children: v.array(v.lazy(() => SpecObject))
|
|
28
30
|
});
|
|
29
31
|
const SpecArtifact = v.object({
|
|
30
32
|
created: v.date(),
|
package/dst/specbook-format.md
CHANGED
|
@@ -78,7 +78,7 @@ type SchemaObject = {
|
|
|
78
78
|
diagram?: SchemaDiagram
|
|
79
79
|
format?: SchemaFormat
|
|
80
80
|
props?: SchemaProperty[]
|
|
81
|
-
|
|
81
|
+
children?: SchemaObject[]
|
|
82
82
|
}
|
|
83
83
|
type SchemaAutomaton = {
|
|
84
84
|
nodes: string
|
|
@@ -251,7 +251,7 @@ type SchemaGradiaConfig = Partial<{
|
|
|
251
251
|
properties allowed on objects of this kind,
|
|
252
252
|
BECAUSE they carry the structured, machine-checkable content
|
|
253
253
|
|
|
254
|
-
- `SchemaObject.
|
|
254
|
+
- `SchemaObject.children?: SchemaObject[]`:
|
|
255
255
|
object kinds allowed one level below this object (RECURSION),
|
|
256
256
|
BECAUSE the nesting defines and bounds the document structure
|
|
257
257
|
|
|
@@ -579,7 +579,7 @@ type SpecObject = {
|
|
|
579
579
|
primary?: boolean
|
|
580
580
|
description?: SpecDescription
|
|
581
581
|
properties: SpecProperty[]
|
|
582
|
-
|
|
582
|
+
children: SpecObject[]
|
|
583
583
|
}
|
|
584
584
|
type SpecDescription = {
|
|
585
585
|
description: string
|
|
@@ -653,7 +653,7 @@ type SpecProperty = {
|
|
|
653
653
|
key/value properties of the object, in canonical order,
|
|
654
654
|
BECAUSE properties carry the structured, machine-checkable content
|
|
655
655
|
|
|
656
|
-
- `SpecObject.
|
|
656
|
+
- `SpecObject.children: SpecObject[]`:
|
|
657
657
|
child objects nested one level below this object (RECURSION),
|
|
658
658
|
BECAUSE a specification is a tree, not a flat list of statements
|
|
659
659
|
|