@rse/specbook 0.9.2 → 0.9.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.
@@ -4,6 +4,9 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import { referenceRegex, buildLinkIndex, resolveUnique, resolveSet, anchorPaths } from "./specbook-link.js";
7
+ /* the single Wiki-style reference match (the non-global sibling of
8
+ the imported, global "referenceRegex") */
9
+ const referenceOnce = /\[\[([^[\]]+)\]\]/;
7
10
  /* strip the inline code markup of a name or property value
8
11
  (preserved in the AST for rendering) for labeling purposes */
9
12
  const plainText = (text) => text.replace(/`/g, "");
@@ -27,7 +30,56 @@ const arityToken = (text) => /^[^\s[\]"]+$/.test(text) ? text : quoted(text);
27
30
  collapsed, as a directive is a single line) */
28
31
  const configValue = (value) => {
29
32
  const text = String(value).replace(/\s+/g, " ").trim();
30
- return /^\S+$/.test(text) ? text : quoted(text);
33
+ return /^[^\s"]+$/.test(text) ? text : quoted(text);
34
+ };
35
+ /* generate the Gradia spec text of a derived diagram, opened by a
36
+ "#type" directive and the configured "#config" directives so every
37
+ embedded spec is self-contained: one node statement per object (the
38
+ unique anchor path as the node id, the object name as the displayed
39
+ label) and one edge statement per derived edge */
40
+ const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
41
+ const lines = [`#type ${type}`];
42
+ const config = diagram.config ?? {};
43
+ for (const key of Object.keys(config)) {
44
+ const value = config[key];
45
+ if (value !== undefined)
46
+ lines.push(`#config ${key} ${configValue(value)}`);
47
+ }
48
+ lines.push("");
49
+ for (const node of nodes) {
50
+ const anchor = anchors.get(node) ?? node.id;
51
+ const attrs = [`url: ${atom(`#${anchor}`)}`];
52
+ if (diagram.qualified === true && node.kind !== "")
53
+ attrs.push(`type: ${atom(node.kind)}`);
54
+ if (type === "hub" ? node === center : node.primary === true)
55
+ attrs.push("primary: true");
56
+ /* attach the values of the configured properties as Gradia
57
+ key/value attributes (a node lacking a property is fine, as
58
+ the node set can mix objects of different kinds), with every
59
+ "[[...]]" reference stripped to its target object name */
60
+ for (const key of diagram.properties ?? []) {
61
+ const value = node.properties.find((property) => plainKey(property.key) === key)?.value;
62
+ if (value !== undefined) {
63
+ const text = value.replace(referenceRegex, (_, reference) => {
64
+ const target = resolveUnique(index, reference.trim()).target;
65
+ return target !== undefined ? target.name : reference.trim();
66
+ });
67
+ attrs.push(`${atom(key)}: ${atom(plainText(text))}`);
68
+ }
69
+ }
70
+ lines.push(`${atom(anchor)}: ${atom(plainText(node.name))} [ ${attrs.join(", ")} ]`);
71
+ }
72
+ if (edges.length > 0) {
73
+ lines.push("");
74
+ for (const edge of edges) {
75
+ const op = "--" +
76
+ (edge.name !== undefined && edge.name !== "" ? `(${nameToken(edge.name)})--` : "") + ">" +
77
+ (edge.arity !== undefined && edge.arity !== "" ? `[${arityToken(edge.arity)}]` : "");
78
+ lines.push(`${atom(anchors.get(edge.source) ?? edge.source.id)} ${op} ` +
79
+ atom(anchors.get(edge.target) ?? edge.target.id));
80
+ }
81
+ }
82
+ return lines.join("\n") + "\n";
31
83
  };
32
84
  /* derive the Gradia spec of a single object from its "diagram:"
33
85
  schema configuration (the returned spec is absent whenever the
@@ -69,7 +121,8 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
69
121
  walk(object);
70
122
  }
71
123
  const edgeObjects = diagram.edges !== undefined ? resolvePatterns(diagram.edges, "edges") : [];
72
- nodes = nodes.filter((node) => !edgeObjects.includes(node));
124
+ const edgeSet = new Set(edgeObjects);
125
+ nodes = nodes.filter((node) => !edgeSet.has(node));
73
126
  const nodeSet = new Set(nodes);
74
127
  /* derive the edges: from the "[[...]]" references of the node
75
128
  objects (per the "links" selection) and from the edge objects by
@@ -98,8 +151,8 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
98
151
  const value = diagram.edgeTarget !== undefined ?
99
152
  edgeObject.properties.find((property) => plainKey(property.key) === diagram.edgeTarget)?.value :
100
153
  edgeObject.properties.map((property) => property.value)
101
- .find((v) => /\[\[[^[\]]+\]\]/.test(v));
102
- const reference = value?.match(/\[\[([^[\]]+)\]\]/)?.[1].trim();
154
+ .find((v) => referenceOnce.test(v));
155
+ const reference = value?.match(referenceOnce)?.[1].trim();
103
156
  const target = reference !== undefined ? resolveUnique(index, reference).target : undefined;
104
157
  if (source === undefined) {
105
158
  errors.push(`diagram edge object "${edgeObject.name}" has no parent object as source`);
@@ -124,15 +177,18 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
124
177
  edges.push({ source: parent, target: node });
125
178
  }
126
179
  }
127
- else if (diagram.edges !== undefined)
128
- errors.push("\"grid\" diagram cannot carry an \"edges\" configuration");
129
- else if (diagram.hierarchy === true)
130
- errors.push("\"grid\" diagram cannot carry a \"hierarchy\" configuration");
180
+ else {
181
+ if (diagram.edges !== undefined)
182
+ errors.push("\"grid\" diagram cannot carry an \"edges\" configuration");
183
+ if (diagram.hierarchy === true)
184
+ errors.push("\"grid\" diagram cannot carry a \"hierarchy\" configuration");
185
+ }
131
186
  /* deduplicate the edges (the same reference can occur in
132
187
  multiple texts of the same node object) */
133
188
  const seenEdges = new Set();
134
189
  edges = edges.filter((edge) => {
135
- const key = `${anchors.get(edge.source)}\u0000${anchors.get(edge.target)}` +
190
+ const key = `${anchors.get(edge.source) ?? edge.source.id}` +
191
+ `\u0000${anchors.get(edge.target) ?? edge.target.id}` +
136
192
  `\u0000${edge.name ?? ""}\u0000${edge.arity ?? ""}`;
137
193
  if (seenEdges.has(key))
138
194
  return false;
@@ -144,7 +200,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
144
200
  resolved object of an explicit "[[...]]" reference otherwise */
145
201
  let center = object;
146
202
  if (type === "hub" && diagram.center !== undefined && diagram.center !== "self") {
147
- const reference = diagram.center.match(/\[\[([^[\]]+)\]\]/)?.[1].trim();
203
+ const reference = diagram.center.match(referenceOnce)?.[1].trim();
148
204
  const resolved = reference !== undefined ? resolveUnique(index, reference) : undefined;
149
205
  if (resolved?.target === undefined) {
150
206
  errors.push(`${resolved?.ambiguous === true ? "ambiguous" : "unresolvable"}` +
@@ -190,39 +246,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
190
246
  diagram carries no information beyond the object itself */
191
247
  if (diagram.collapse !== false && nodes.length === 1 && edges.length === 0)
192
248
  return { errors };
193
- /* generate the Gradia spec text, opened by a "#type" directive and
194
- the configured "#config" directives so every embedded spec is
195
- self-contained: one node statement per object (the unique anchor
196
- path as the node id, the object name as the displayed label)
197
- and one edge statement per derived edge */
198
- const lines = [`#type ${type}`];
199
- const config = diagram.config ?? {};
200
- for (const key of Object.keys(config)) {
201
- const value = config[key];
202
- if (value !== undefined)
203
- lines.push(`#config ${key} ${configValue(value)}`);
204
- }
205
- lines.push("");
206
- for (const node of nodes) {
207
- const anchor = anchors.get(node) ?? node.id;
208
- const attrs = [`url: ${atom(`#${anchor}`)}`];
209
- if (diagram.qualified === true && node.kind !== "")
210
- attrs.push(`type: ${atom(node.kind)}`);
211
- if (type === "hub" ? node === center : node.primary === true)
212
- attrs.push("primary: true");
213
- lines.push(`${atom(anchor)}: ${atom(plainText(node.name))} [ ${attrs.join(", ")} ]`);
214
- }
215
- if (edges.length > 0) {
216
- lines.push("");
217
- for (const edge of edges) {
218
- const op = "--" +
219
- (edge.name !== undefined && edge.name !== "" ? `(${nameToken(edge.name)})--` : "") + ">" +
220
- (edge.arity !== undefined && edge.arity !== "" ? `[${arityToken(edge.arity)}]` : "");
221
- lines.push(`${atom(anchors.get(edge.source) ?? edge.source.id)} ${op} ` +
222
- atom(anchors.get(edge.target) ?? edge.target.id));
223
- }
224
- }
225
- return { spec: lines.join("\n") + "\n", errors };
249
+ return { spec: renderSpec(diagram, type, center, nodes, edges, index, anchors), errors };
226
250
  };
227
251
  /* collect the objects whose schema configuration carries a "diagram:"
228
252
  field (the schema resolution mirrors the semantic validation) */
@@ -25,10 +25,13 @@ export const renderAst = async (specification, format, config) => {
25
25
  };
26
26
  specification.artifacts.forEach((artifact, i) => artifact.objects.forEach((object, j) => walk(object, plain.artifacts[i].objects[j])));
27
27
  }
28
+ /* serialize the plain specification into the target format
29
+ (JSON and JSON5 in compressed form, as they are intended
30
+ for machine consumption only) */
28
31
  if (format === "json")
29
- return Buffer.from(JSON.stringify(plain, null, 4) + "\n", "utf8");
32
+ return Buffer.from(JSON.stringify(plain) + "\n", "utf8");
30
33
  else if (format === "json5")
31
- return Buffer.from(JSON5.stringify(plain, null, 4) + "\n", "utf8");
34
+ return Buffer.from(JSON5.stringify(plain) + "\n", "utf8");
32
35
  else if (format === "yaml")
33
36
  return Buffer.from(stringifyYaml(plain), "utf8");
34
37
  else
@@ -18,7 +18,6 @@ export type PaperSetup = {
18
18
  right: number;
19
19
  };
20
20
  };
21
- export declare const paperSizes: string[];
22
21
  export declare const paperSizeDefault = "A4";
23
22
  export declare const paperSetup: (paper: string) => PaperSetup;
24
23
  export declare const paperLength: (setup: PaperSetup, value: number) => string;
@@ -6,8 +6,9 @@
6
6
  import path from "node:path";
7
7
  import fs from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
9
- /* escape a text for embedding into template HTML */
10
- export const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
9
+ /* escape a text for embedding into template HTML (text and attributes) */
10
+ export const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
11
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
11
12
  /* provide the build-time pre-assembled stylesheet (with the
12
13
  font faces already inlined as base64 data: URIs) */
13
14
  export const stylesheet = () => fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-export-html.css"), "utf8");
@@ -18,9 +19,10 @@ export const searchScript = () => fs.readFileSync(path.join(path.dirname(fileURL
18
19
  export const fallbackLogo = () => "data:image/svg+xml;base64," + fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-export-logo.svg")).toString("base64");
19
20
  /* check whether an object is the specification title object */
20
21
  export const isTitleObject = (object) => object.kind === "META" && object.name.toUpperCase() === "TITLE";
22
+ /* determine the title object of the specification */
23
+ const titleObject = (specification) => specification.artifacts.flatMap((artifact) => artifact.objects).find(isTitleObject);
21
24
  /* determine a property value of the title object */
22
- const titleProperty = (specification, name) => specification.artifacts.flatMap((artifact) => artifact.objects).find(isTitleObject)
23
- ?.properties.find((property) => property.key === name)?.value;
25
+ const titleProperty = (specification, name) => titleObject(specification)?.properties.find((property) => property.key === name)?.value;
24
26
  /* determine the document language (LANG) from the title object */
25
27
  export const documentLang = (specification) => titleProperty(specification, "LANG")?.trim();
26
28
  /* determine the document character set (CHARSET) from the title object */
@@ -37,28 +39,39 @@ const papers = {
37
39
  "Letter": { unit: "in", height: 11, margin: { top: 1, bottom: 1, left: 0.8, right: 0.8 } },
38
40
  "Legal": { unit: "in", height: 14, margin: { top: 1, bottom: 1, left: 0.8, right: 0.8 } }
39
41
  };
40
- export const paperSizes = Object.keys(papers);
42
+ const paperSizes = Object.keys(papers);
41
43
  export const paperSizeDefault = "A4";
42
44
  /* provide the setup of a paper size, falling back onto the default */
43
45
  export const paperSetup = (paper) => papers[paper] ?? papers[paperSizeDefault];
44
46
  /* render a paper dimension as its CSS length */
45
47
  export const paperLength = (setup, value) => `${value}${setup.unit}`;
46
48
  /* determine the document paper size (PAPER-SIZE) from the title object,
47
- matched case-insensitively and falling back onto the default */
49
+ matched case-insensitively, falling back onto the default if unset
50
+ and rejecting an unknown size */
48
51
  export const documentPaperSize = (specification) => {
49
- const value = titleProperty(specification, "PAPER-SIZE")?.trim().toLowerCase();
50
- return paperSizes.find((name) => name.toLowerCase() === value) ?? paperSizeDefault;
52
+ const value = titleProperty(specification, "PAPER-SIZE")?.trim();
53
+ if (value === undefined)
54
+ return paperSizeDefault;
55
+ const paper = paperSizes.find((name) => name.toLowerCase() === value.toLowerCase());
56
+ if (paper === undefined)
57
+ throw new Error(`unknown paper size "${value}" ` +
58
+ `(expected ${paperSizes.join(", ")})`);
59
+ return paper;
51
60
  };
52
61
  /* provide the paper-dependent print stylesheet: a diagram is scaled
53
62
  down to still fit onto a single page (the paper height less the
54
- print margins and the own vertical margins of the diagram) and is
55
- never broken across a page boundary */
63
+ print margins, the own vertical margins of the diagram, and the
64
+ room its introducing heading claims above it) and is never broken
65
+ across a page boundary; without the heading reserve a maximally
66
+ sized diagram could not share its page with the heading, which
67
+ would defeat the "break-after: avoid" bundling of the stylesheet */
68
+ const headingReserve = 6;
56
69
  export const paperStylesheet = (paper) => {
57
70
  const setup = paperSetup(paper);
58
71
  const avail = setup.height - setup.margin.top - setup.margin.bottom;
59
72
  return "@media print {\n" +
60
73
  "div.diagram { break-inside: avoid; }\n" +
61
- `div.diagram svg { max-height: calc(${paperLength(setup, avail)} - 2rem);` +
74
+ `div.diagram svg { max-height: calc(${paperLength(setup, avail)} - ${headingReserve}rem);` +
62
75
  " width: auto; height: auto; }\n" +
63
76
  "}\n";
64
77
  };
@@ -87,7 +100,7 @@ export const charsetCodepoints = (charset) => {
87
100
  else if (name === "utf8" || name === "utf16" || name === "unicode")
88
101
  return undefined;
89
102
  throw new Error(`unknown charset "${charset}" ` +
90
- "(expected US-ASCII, ISO-8859-1/ISO-Latin-1, ISO-8859-15/ISO-Latin-15, or UTF-8)");
103
+ "(expected US-ASCII, ISO-8859-1/ISO-Latin-1, ISO-8859-15/ISO-Latin-9, or UTF-8)");
91
104
  };
92
105
  /* the symbol glyphs used by the HTML/PDF rendering (kind and property
93
106
  bullets, link symbol, primary marker, theme switch icon, anchor
@@ -123,18 +136,18 @@ export const subsetStylesheet = async (charset) => {
123
136
  return result + css.slice(last);
124
137
  };
125
138
  /* determine the document title and subtitle from the title object */
126
- export const documentTitle = (specification) => {
127
- const object = specification.artifacts.flatMap((artifact) => artifact.objects).find(isTitleObject);
128
- const prop = (name) => object?.properties.find((property) => property.key === name)?.value;
129
- return { title: prop("TITLE") ?? object?.name ?? "Specification", subtitle: prop("SUBTITLE") };
130
- };
139
+ export const documentTitle = (specification) => ({
140
+ title: titleProperty(specification, "TITLE") ??
141
+ titleObject(specification)?.name ?? "Specification",
142
+ subtitle: titleProperty(specification, "SUBTITLE")
143
+ });
131
144
  /* determine the document logo as a self-contained data: URL, taken from the
132
145
  embedded image of the LOGO property of the title object, and falling back
133
146
  onto the built-in SpecBook logo (for use in isolated rendering contexts,
134
147
  like the print header/footer, which load no external resources) */
135
148
  export const documentLogo = (specification) => {
136
- const object = specification.artifacts.flatMap((artifact) => artifact.objects).find(isTitleObject);
137
- const content = object?.properties.find((property) => property.key === "LOGO")?.embedding?.[0];
149
+ const content = titleObject(specification)
150
+ ?.properties.find((property) => property.key === "LOGO")?.embedding?.[0];
138
151
  if (content === undefined)
139
152
  return fallbackLogo();
140
153
  return content.startsWith("data:") ? content :
@@ -45,7 +45,7 @@
45
45
  return
46
46
  parent = parent.parentElement
47
47
  }
48
- const text = (el.textContent || "").toLowerCase()
48
+ const text = (el.textContent ?? "").toLowerCase()
49
49
  const words = new Set(text.split(/[^\p{L}\p{N}]+/u).filter((w) => w !== ""))
50
50
  words.forEach((w) => { vocab.add(w) })
51
51
  units.push({ el, text, words, keep: false })
@@ -141,7 +141,7 @@
141
141
  keeps the tags and attributes untouched */
142
142
  const highlight = (el, regex) => {
143
143
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
144
- const nodes = []
144
+ const nodes = []
145
145
  let node
146
146
  while ((node = walker.nextNode()) !== null)
147
147
  nodes.push(node)
@@ -231,13 +231,15 @@
231
231
  })
232
232
 
233
233
  /* hide the units not kept, plus the objects and tables which
234
- ran entirely empty in the process */
234
+ ran entirely empty in the process (a table nested inside a
235
+ kept unit, like a chunked property table, stays visible) */
235
236
  units.forEach((unit) => {
236
237
  if (!unit.el.classList.contains("search-keep"))
237
238
  unit.el.classList.add("search-hide")
238
239
  })
239
240
  document.querySelectorAll("article section, article table").forEach((el) => {
240
- if (el.querySelector(".search-keep") === null)
241
+ if (el.querySelector(".search-keep") === null
242
+ && el.closest(".search-keep") === null)
241
243
  el.classList.add("search-hide")
242
244
  })
243
245
 
@@ -137,6 +137,13 @@ section { margin: 0.5rem 0; }
137
137
  h1 { margin: 3.0rem 0 2.0rem 0; border-bottom: 1px solid var(--theme-color-specbook-border); }
138
138
  h2, h3, h4 { margin: 2.0rem 0 1.0rem 0; }
139
139
 
140
+ /* bundle a heading with the content it introduces (diagram, property
141
+ table, description), so no heading is left orphaned at the very
142
+ bottom of a page while its content starts on the next one */
143
+ @media print {
144
+ h1, h2, h3, h4, h5, h6 { page-break-after: avoid; break-after: avoid; }
145
+ }
146
+
140
147
  body { counter-reset: sec1; }
141
148
  article h1 { counter-increment: sec1; counter-reset: sec2; }
142
149
  article h2 { counter-increment: sec2; counter-reset: sec3; }
@@ -192,6 +199,20 @@ table.objects div.description { margin: 0; }
192
199
  table.objects div.description > :first-child { margin-top: 0; }
193
200
  table.objects div.description > :last-child { margin-bottom: 0; }
194
201
 
202
+ /* the embedded per-object chunk tables of the over-wide compact tables */
203
+ table.objects td.chunks { padding: 0; }
204
+ table.chunks { border-collapse: collapse; table-layout: fixed;
205
+ width: 100%; }
206
+ table.objects table.chunks tr td { background-color: transparent; }
207
+ table.objects table.chunks td:first-child { font-weight: normal; }
208
+
209
+ /* the nested renderings inside the description cells of the compact
210
+ tables ("concise" sub-tables and pressed-in "complex" objects),
211
+ reduced in size to fit into the cell */
212
+ table.objects td table.objects { font-size: 85%; margin: 0.5rem 0; }
213
+ table.objects td section { font-size: 85%; margin: 0.5rem 0; }
214
+ table.objects td section h6 { margin: 0.5rem 0 0.25rem 0; }
215
+
195
216
  p.description,
196
217
  div.description { margin: 1.0rem 0 0.5rem 0; }
197
218
  div.description p { margin: 0.5rem 0; }
@@ -5,5 +5,5 @@ export type OutlineEntry = {
5
5
  anchor: string;
6
6
  childs: OutlineEntry[];
7
7
  };
8
- export declare const htmlOutline: (specification: Specification, maxColumns: number) => OutlineEntry[];
9
- export declare const renderHtml: (specification: Specification, maxColumns: number, config?: SchemaSpecification, tocPages?: Map<string, number>, css?: string) => Promise<string>;
8
+ export declare const htmlOutline: (specification: Specification, config?: SchemaSpecification) => OutlineEntry[];
9
+ export declare const renderHtml: (specification: Specification, config?: SchemaSpecification, tocPages?: Map<string, number>, css?: string) => Promise<string>;