@rse/specbook 1.0.3 → 1.0.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.
Files changed (40) hide show
  1. package/README.md +106 -19
  2. package/dst/specbook-api.d.ts +29 -9
  3. package/dst/specbook-api.js +108 -33
  4. package/dst/specbook-cli.js +84 -25
  5. package/dst/specbook-cmd-describe.d.ts +7 -1
  6. package/dst/specbook-cmd-describe.js +56 -11
  7. package/dst/specbook-cmd-export.d.ts +2 -1
  8. package/dst/specbook-cmd-export.js +46 -3
  9. package/dst/specbook-cmd-lint.d.ts +2 -1
  10. package/dst/specbook-cmd-lint.js +12 -8
  11. package/dst/specbook-cmd-preview.d.ts +12 -0
  12. package/dst/specbook-cmd-preview.js +57 -0
  13. package/dst/specbook-config.d.ts +1 -1
  14. package/dst/specbook-config.js +187 -37
  15. package/dst/specbook-diagnostic.d.ts +2 -0
  16. package/dst/specbook-diagnostic.js +4 -2
  17. package/dst/specbook-diagram.d.ts +1 -0
  18. package/dst/specbook-diagram.js +0 -0
  19. package/dst/specbook-export-ast.js +4 -2
  20. package/dst/specbook-export-common.js +5 -3
  21. package/dst/specbook-export-html-search.js +118 -30
  22. package/dst/specbook-export-html.css +1140 -233
  23. package/dst/specbook-export-html.d.ts +1 -1
  24. package/dst/specbook-export-html.js +764 -120
  25. package/dst/specbook-export-md.js +4 -2
  26. package/dst/specbook-format-schema.d.ts +33 -1
  27. package/dst/specbook-format-schema.js +34 -2
  28. package/dst/specbook-format.md +193 -25
  29. package/dst/specbook-format.yaml +2291 -299
  30. package/dst/specbook-link.d.ts +2 -1
  31. package/dst/specbook-link.js +43 -3
  32. package/dst/specbook-mcp.js +19 -10
  33. package/dst/specbook-parse-common.d.ts +4 -2
  34. package/dst/specbook-parse-common.js +6 -3
  35. package/dst/specbook-parse-semantic.js +290 -30
  36. package/dst/specbook-parse-syntax.js +3 -1
  37. package/dst/specbook-parse-value.d.ts +1 -0
  38. package/dst/specbook-parse-value.js +17 -3
  39. package/dst/specbook-parse.js +1 -1
  40. package/package.json +14 -6
@@ -6,8 +6,9 @@
6
6
  import * as fs from "node:fs";
7
7
  import { parseDocument, isNode, LineCounter } from "yaml";
8
8
  import * as v from "valibot";
9
+ import { mergeWith, isPlainObject } from "es-toolkit";
9
10
  import { Schema } from "./specbook-format-schema.js";
10
- import { compileValueExpr, anchored } from "./specbook-parse-value.js";
11
+ import { compileValueExpr, anchored, admitsReferences } from "./specbook-parse-value.js";
11
12
  /* determine line/column of a YAML document path via the node ranges
12
13
  of the parsed document, falling back to the closest ancestor node */
13
14
  const lineColOfPath = (doc, lines, path) => {
@@ -21,17 +22,86 @@ const lineColOfPath = (doc, lines, path) => {
21
22
  }
22
23
  return { line: 1, column: 1 };
23
24
  };
25
+ /* the identity by which the elements of the lists of the configuration
26
+ are matched on merging: the artifacts by their kind plus id or name
27
+ and the nested objects by their kind alone (exactly like the sibling
28
+ resolution), the properties by their name, and the scalar entries
29
+ by their value */
30
+ const identityOf = (item, list) => {
31
+ if (!isPlainObject(item))
32
+ return String(item);
33
+ const object = item;
34
+ if (list === "props")
35
+ return String(object.name ?? "");
36
+ else if (list === "childs")
37
+ return String(object.kind ?? "");
38
+ else
39
+ return `${String(object.kind ?? "")}:${String(object.id ?? object.name ?? "")}`;
40
+ };
41
+ /* merge a source configuration (or a nested part of it) into a target
42
+ one: the objects merge deeply, while the elements of the lists are
43
+ matched by their identity, so a matching element is merged into its
44
+ counterpart and an unmatched one is appended */
45
+ const mergeConfig = (target, source, list) => {
46
+ if (Array.isArray(target) && Array.isArray(source)) {
47
+ for (const item of source) {
48
+ const i = target.findIndex((existing) => identityOf(existing, list) === identityOf(item, list));
49
+ if (i < 0)
50
+ target.push(item);
51
+ else
52
+ target[i] = mergeConfig(target[i], item, list);
53
+ }
54
+ return target;
55
+ }
56
+ else if (isPlainObject(target) && isPlainObject(source))
57
+ return mergeWith(target, source, (t, s, key) => Array.isArray(t) && Array.isArray(s) ? mergeConfig(t, s, key) : undefined);
58
+ else
59
+ return source;
60
+ };
61
+ /* determine file and line/column of a path into the merged
62
+ configuration by translating the path into every document (from the
63
+ last to the first one, as a later document overrides an earlier one),
64
+ where a list index is translated through the identity of the merged
65
+ element, and by taking the deepest reaching translation, whose path
66
+ is returned, too (or the original one if it does not reach fully) */
67
+ const posOfMergedPath = (docs, merged, path) => {
68
+ const at = (value, key) => value?.[key];
69
+ let best = { file: docs[docs.length - 1].file, line: 1, column: 1, path, depth: -1 };
70
+ for (const { file, doc, lines, raw } of docs.slice().reverse()) {
71
+ const translated = [];
72
+ let node = merged;
73
+ let own = raw;
74
+ let list;
75
+ for (const segment of path) {
76
+ const key = typeof segment === "number" && Array.isArray(own) ?
77
+ own.findIndex((item) => identityOf(item, list) === identityOf(at(node, segment), list)) : segment;
78
+ if (typeof key === "number" ? !Array.isArray(own) || key < 0 : !isPlainObject(own) || !(key in own))
79
+ break;
80
+ translated.push(key);
81
+ node = at(node, segment);
82
+ own = at(own, key);
83
+ if (typeof segment === "string")
84
+ list = segment;
85
+ }
86
+ if (translated.length > best.depth)
87
+ best = { file, ...lineColOfPath(doc, lines, translated), path: translated, depth: translated.length };
88
+ }
89
+ return { file: best.file, line: best.line, column: best.column,
90
+ path: best.depth === path.length ? best.path : path };
91
+ };
24
92
  /* check the constraints of a structurally valid configuration which
25
93
  are beyond its schema: sibling objects have to stay distinctly
26
94
  resolvable, "file" fields are allowed on the first (artifact) level
27
- only, property value expressions have to be syntactically valid, and
28
- the names of non-artifact objects have to be valid patterns (as they
29
- are compiled into regular expressions) */
30
- const checkConstraints = (config, file, posOfPath) => {
95
+ only, property value and marker expressions have to be syntactically
96
+ valid, "referenced" entries have to be reference expressions, an
97
+ "automaton" has to name existing child kinds and properties, and the
98
+ names of non-artifact objects have to be valid patterns (as they are
99
+ compiled into regular expressions) */
100
+ const checkConstraints = (config, posOfPath) => {
31
101
  const diagnostics = new Array();
32
102
  const diagnose = (path, message) => {
33
- const pos = posOfPath(path);
34
- diagnostics.push({ file, line: pos.line, column: pos.column, message });
103
+ const { file, line, column } = posOfPath(path);
104
+ diagnostics.push({ file, line, column, severity: "error", message });
35
105
  };
36
106
  const check = (objects, path, depth) => {
37
107
  /* the sibling objects are resolved by kind and id on the first
@@ -48,15 +118,86 @@ const checkConstraints = (config, file, posOfPath) => {
48
118
  if (depth > 1 && object.file !== undefined)
49
119
  diagnose([...at, "file"], `"file" field is only allowed on the first (artifact) level (found on level ${depth})`);
50
120
  for (const [j, prop] of (object.props ?? []).entries()) {
51
- if (prop.value === undefined)
52
- continue;
121
+ /* the relation shape flags apply to reference-valued
122
+ properties only (an invalid expression is reported on
123
+ its own, without a cascading flag diagnostic) */
124
+ let expr;
125
+ if (prop.value !== undefined) {
126
+ try {
127
+ expr = compileValueExpr(prop.value);
128
+ }
129
+ catch (err) {
130
+ diagnose([...at, "props", j, "value"], `invalid value expression "${prop.value}": ` +
131
+ (err instanceof Error ? err.message : String(err)));
132
+ continue;
133
+ }
134
+ }
135
+ for (const flag of ["local", "symmetric", "acyclic"])
136
+ if (prop[flag] === true && (expr === undefined || !admitsReferences(expr)))
137
+ diagnose([...at, "props", j, flag], `"${flag}" flag requires a reference-valued property`);
138
+ /* the sibling marker flags optionally carry a regexp or
139
+ enum expression selecting the values they apply to */
140
+ for (const flag of ["unique", "present"]) {
141
+ const marker = prop[flag];
142
+ if (typeof marker !== "string")
143
+ continue;
144
+ let kind;
145
+ try {
146
+ kind = compileValueExpr(marker).kind;
147
+ }
148
+ catch (err) {
149
+ diagnose([...at, "props", j, flag], `invalid "${flag}" expression "${marker}": ` +
150
+ (err instanceof Error ? err.message : String(err)));
151
+ }
152
+ if (kind !== undefined && kind !== "regex" && kind !== "enum")
153
+ diagnose([...at, "props", j, flag], `"${flag}" expression "${marker}" is neither a regexp nor an enum`);
154
+ }
155
+ }
156
+ if (object.automaton !== undefined) {
157
+ /* an automaton names the child kinds acting as nodes and
158
+ edges, the reference-valued edge properties referencing
159
+ the source and target nodes, and the node properties
160
+ flagging the initial and final nodes */
161
+ const automaton = object.automaton;
162
+ const childOf = (kind) => (object.childs ?? []).find((c) => c.kind === kind);
163
+ const propOf = (child, name) => (child?.props ?? []).find((p) => p.name === name);
164
+ const isReference = (prop) => {
165
+ try {
166
+ return prop.value !== undefined && admitsReferences(compileValueExpr(prop.value));
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ };
172
+ const nodes = childOf(automaton.nodes);
173
+ const edges = childOf(automaton.edges);
174
+ for (const [field, child] of [["nodes", nodes], ["edges", edges]])
175
+ if (child === undefined)
176
+ diagnose([...at, "automaton", field], `"automaton" ${field} kind "${automaton[field]}" is not a child object kind`);
177
+ for (const field of ["source", "target"]) {
178
+ const prop = propOf(edges, automaton[field]);
179
+ if (edges !== undefined && prop === undefined)
180
+ diagnose([...at, "automaton", field], `"automaton" ${field} property "${automaton[field]}" is not a property of "${automaton.edges}"`);
181
+ else if (prop !== undefined && !isReference(prop))
182
+ diagnose([...at, "automaton", field], `"automaton" ${field} property "${automaton[field]}" is not reference-valued`);
183
+ }
184
+ for (const field of ["initial", "final"])
185
+ if (nodes !== undefined && propOf(nodes, automaton[field]) === undefined)
186
+ diagnose([...at, "automaton", field], `"automaton" ${field} property "${automaton[field]}" is not a property of "${automaton.nodes}"`);
187
+ }
188
+ for (const [j, entry] of (object.referenced ?? []).entries()) {
189
+ /* a reference coverage entry has to be a single
190
+ (usually wildcard) reference expression */
191
+ let expr;
53
192
  try {
54
- compileValueExpr(prop.value);
193
+ expr = compileValueExpr(entry);
55
194
  }
56
195
  catch (err) {
57
- diagnose([...at, "props", j, "value"], `invalid value expression "${prop.value}": ` +
196
+ diagnose([...at, "referenced", j], `invalid "referenced" entry "${entry}": ` +
58
197
  (err instanceof Error ? err.message : String(err)));
59
198
  }
199
+ if (expr !== undefined && expr.kind !== "reference")
200
+ diagnose([...at, "referenced", j], `"referenced" entry "${entry}" is not a single "[[xxx]]" reference`);
60
201
  }
61
202
  if (depth > 1 && object.name !== undefined) {
62
203
  try {
@@ -74,25 +215,28 @@ const checkConstraints = (config, file, posOfPath) => {
74
215
  check(config, [], 1);
75
216
  return diagnostics;
76
217
  };
77
- /* load and validate a YAML schema configuration file */
78
- export const loadConfig = (file) => {
218
+ /* load and validate the YAML schema configuration, merged in order out
219
+ of its files (the later ones into the earlier ones), where every file
220
+ has to be readable and syntactically valid on its own, while the
221
+ merged result alone is validated */
222
+ export const loadConfig = (files) => {
79
223
  const diagnostics = new Array();
80
- /* read the configuration file */
81
- let yaml;
82
- try {
83
- yaml = fs.readFileSync(file, "utf8");
84
- }
85
- catch (err) {
86
- diagnostics.push({ file, line: 1, column: 1,
87
- message: "cannot read configuration file: " +
88
- (err instanceof Error ? err.message : String(err)) });
89
- return { diagnostics };
90
- }
91
- /* syntactically parse the YAML content into its document, keeping
92
- the line positions for the diagnostics */
93
- const lines = new LineCounter();
94
- const doc = parseDocument(yaml, { lineCounter: lines });
95
- if (doc.errors.length > 0) {
224
+ /* read and syntactically parse every configuration file into its
225
+ document, keeping the line positions for the diagnostics */
226
+ const docs = new Array();
227
+ for (const file of files) {
228
+ let yaml;
229
+ try {
230
+ yaml = fs.readFileSync(file, "utf8");
231
+ }
232
+ catch (err) {
233
+ diagnostics.push({ file, line: 1, column: 1, severity: "error",
234
+ message: "cannot read configuration file: " +
235
+ (err instanceof Error ? err.message : String(err)) });
236
+ continue;
237
+ }
238
+ const lines = new LineCounter();
239
+ const doc = parseDocument(yaml, { lineCounter: lines });
96
240
  /* strip the position and the source snippet the YAML parser
97
241
  appends to its message, as the diagnostic carries the
98
242
  position itself */
@@ -101,25 +245,31 @@ export const loadConfig = (file) => {
101
245
  file,
102
246
  line: err.linePos?.[0].line ?? 1,
103
247
  column: err.linePos?.[0].col ?? 1,
248
+ severity: "error",
104
249
  message: "invalid YAML syntax: " + err.message.replace(/ at line \d+, column \d+(?::\n[\s\S]*)?$/, "")
105
250
  });
106
- return { diagnostics };
251
+ if (doc.errors.length === 0)
252
+ docs.push({ file, doc, lines, raw: doc.toJS() });
107
253
  }
108
- const obj = doc.toJS();
109
- /* semantically validate against the schema of the configuration */
110
- const posOfPath = (path) => lineColOfPath(doc, lines, path);
254
+ if (diagnostics.length > 0)
255
+ return { diagnostics };
256
+ /* merge the documents in order */
257
+ const obj = docs.map(({ doc }) => doc.toJS())
258
+ .reduce((target, source) => mergeConfig(target, source));
259
+ /* semantically validate the merged result against the schema of the configuration */
260
+ const posOfPath = (path) => posOfMergedPath(docs, obj, path);
111
261
  const result = v.safeParse(Schema, obj);
112
262
  if (!result.success) {
113
263
  for (const issue of result.issues) {
114
- const path = (issue.path ?? []).map((item) => item.key);
115
- const pos = posOfPath(path);
264
+ /* the path is rendered as it is in the positioned file */
265
+ const { file, line, column, path } = posOfPath((issue.path ?? []).map((item) => item.key));
116
266
  const where = path.length > 0 ? `${path.join(".")}: ` : "";
117
- diagnostics.push({ file, line: pos.line, column: pos.column,
267
+ diagnostics.push({ file, line, column, severity: "error",
118
268
  message: `invalid configuration: ${where}${issue.message}` });
119
269
  }
120
270
  return { diagnostics };
121
271
  }
122
272
  /* check the constraints beyond the structural schema */
123
- diagnostics.push(...checkConstraints(result.output, file, posOfPath));
273
+ diagnostics.push(...checkConstraints(result.output, posOfPath));
124
274
  return { config: diagnostics.length === 0 ? result.output : undefined, diagnostics };
125
275
  };
@@ -1,7 +1,9 @@
1
+ export type DiagnosticSeverity = "error" | "warning";
1
2
  export interface Diagnostic {
2
3
  file: string;
3
4
  line: number;
4
5
  column: number;
6
+ severity: DiagnosticSeverity;
5
7
  message: string;
6
8
  }
7
9
  export declare const renderDiagnostic: (diagnostic: Diagnostic) => string;
@@ -5,8 +5,10 @@
5
5
  */
6
6
  import * as fs from "node:fs";
7
7
  import sourceCodeError from "source-code-error";
8
+ /* render the message of a diagnostic, qualifying a warning as such */
9
+ const renderMessage = (diagnostic) => diagnostic.severity === "warning" ? `warning: ${diagnostic.message}` : diagnostic.message;
8
10
  /* render a diagnostic as a standard single-line message */
9
- export const renderDiagnostic = (diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column}: ${diagnostic.message}`;
11
+ export const renderDiagnostic = (diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column}: ${renderMessage(diagnostic)}`;
10
12
  /* render a diagnostic as a multi-line message with the affected
11
13
  source snippet, falling back to the single-line message when the
12
14
  source file is unreadable (e.g. the file is a directory) or empty
@@ -21,6 +23,6 @@ export const renderDiagnosticVerbose = (diagnostic, colors = false) => {
21
23
  }
22
24
  if (code === "")
23
25
  return `${renderDiagnostic(diagnostic)}\n`;
24
- return sourceCodeError({ message: diagnostic.message, filename: diagnostic.file,
26
+ return sourceCodeError({ message: renderMessage(diagnostic), filename: diagnostic.file,
25
27
  code, line: diagnostic.line, column: diagnostic.column, colors });
26
28
  };
@@ -8,6 +8,7 @@ export interface DiagramError {
8
8
  export interface DiagramResult {
9
9
  spec?: string;
10
10
  config?: SchemaDiagram["config"];
11
+ columns?: number;
11
12
  errors: DiagramError[];
12
13
  }
13
14
  export declare const specDiagrams: (specification: Spec, config: Schema) => Map<SpecObject, DiagramResult>;
Binary file
@@ -7,6 +7,7 @@ import JSON5 from "json5";
7
7
  import { stringify as stringifyYaml } from "yaml";
8
8
  import { encode as encodeToon } from "@toon-format/toon";
9
9
  import { specDiagrams } from "./specbook-diagram.js";
10
+ import { isTitleObject } from "./specbook-export-common.js";
10
11
  /* render the specification AST into a serialization format */
11
12
  export const renderAst = (specification, format, config) => {
12
13
  /* reduce the specification to plain JSON values (ISO date strings) */
@@ -14,12 +15,13 @@ export const renderAst = (specification, format, config) => {
14
15
  /* attach the Gradia specs of the diagram-configured objects as
15
16
  "diagram" fields onto the corresponding plain object nodes
16
17
  (an invalid diagram situation omits the diagram, as it is
17
- already reported as a lint diagnostic) */
18
+ already reported as a lint diagnostic, and the diagram of
19
+ the title object is reserved for the HTML/PDF export) */
18
20
  if (config !== undefined) {
19
21
  const diagrams = specDiagrams(specification, config);
20
22
  const walk = (object, node) => {
21
23
  const result = diagrams.get(object);
22
- if (result?.spec !== undefined)
24
+ if (result?.spec !== undefined && !isTitleObject(object))
23
25
  node.diagram = result.spec;
24
26
  object.childs.forEach((child, i) => walk(child, node.childs[i]));
25
27
  };
@@ -117,9 +117,11 @@ export const charsetCodepoints = (charset) => {
117
117
  };
118
118
  /* the symbol glyphs used by the HTML/PDF rendering (kind and property
119
119
  bullets, link symbol, primary marker, theme switch icon, anchor
120
- symbol plus its text presentation variation selector, and the
121
- search field clearing icon) */
122
- const symbolGlyphs = [0x25CF, 0x25CB, 0x26AD, 0x2318, 0x25D0, 0x2693, 0xFE0E, 0x00D7];
120
+ symbol plus its text presentation variation selector, the search
121
+ field clearing icon, the active entry pointer of the table of
122
+ contents side panel, and the title path segment pointer of the
123
+ description popups) */
124
+ const symbolGlyphs = [0x25CF, 0x25CB, 0x26AD, 0x2318, 0x25D0, 0x2693, 0xFE0E, 0x00D7, 0x25C0, 0x25B7];
123
125
  /* the typographic glyphs producible by the smart typography rendering
124
126
  (language-specific quotes, dashes, ellipsis, bullet, nbsp) */
125
127
  const typographyGlyphs = [
@@ -7,22 +7,41 @@
7
7
  /* the client-side fuzzy search over the rendered specification: the
8
8
  whitespace-separated words of a comma-separated query part are
9
9
  (fuzzy) matched and AND-combined, while the parts are OR-combined;
10
- a match keeps its complete paragraph or table row, plus the table
11
- header and the headings of all enclosing objects, and every
12
- matched word is highlighted with a <mark> element */
10
+ a match keeps its complete paragraph, table row, or diagram, plus
11
+ the table header and the headings of all enclosing objects, and
12
+ every matched word is highlighted with a <mark> element (an SVG
13
+ <tspan> element underlaid with a <rect> inside a diagram), while
14
+ the table of contents (page and side panel) is filtered along */
13
15
  (function () {
14
- const input = document.getElementById("search-input")
15
- const clear = document.getElementById("search-clear")
16
- if (input === null || clear === null)
16
+ const tab = document.getElementById("search")
17
+ const toggle = document.getElementById("search-toggle")
18
+ const input = document.getElementById("search-input")
19
+ const clear = document.getElementById("search-clear")
20
+ if (tab === null || toggle === null || input === null || clear === null)
17
21
  return
18
22
 
23
+ /* let the search icon slide the input field out of the tab (and
24
+ back in again), remembering the choice across page loads */
25
+ const slide = (open) => {
26
+ tab.classList.toggle("open", open)
27
+ if (tab.classList.contains("open"))
28
+ input.focus()
29
+ else
30
+ input.blur()
31
+ try { localStorage.setItem("specbook-search", tab.classList.contains("open") ? "open" : "closed") }
32
+ catch { /* an inaccessible storage just loses the state */ }
33
+ }
34
+ try { if (localStorage.getItem("specbook-search") === "open") tab.classList.add("open") }
35
+ catch { /* an inaccessible storage just means no stored state */ }
36
+ toggle.addEventListener("click", () => { slide() })
37
+
19
38
  /* shortest word still eligible for fuzzy matching: below it the
20
39
  edit distance would equate it with almost any short word */
21
40
  const fuzzyMin = 4
22
41
 
23
- /* the searchable units (paragraphs, list items, table rows, and
24
- headings), the corpus vocabulary, and the per-word cache of the
25
- fuzzy-equivalent vocabulary words */
42
+ /* the searchable units (paragraphs, list items, table rows,
43
+ headings, and diagrams), the corpus vocabulary, and the per-word
44
+ cache of the fuzzy-equivalent vocabulary words */
26
45
  const units = []
27
46
  const vocab = new Set()
28
47
  const fuzzy = new Map()
@@ -30,14 +49,16 @@
30
49
 
31
50
  /* fill the unit and vocabulary indices once on first use: a unit
32
51
  is an outermost paragraph-level element within an article, i.e.
33
- one not nested inside another unit (like a paragraph in a table
34
- cell, which is covered by its table row) */
52
+ one not nested inside another unit (like a paragraph or diagram
53
+ in a table cell, which is covered by its table row), where a
54
+ diagram is searched by its SVG text labels, joined by spaces,
55
+ as its raw text content would run them together */
35
56
  const index = () => {
36
57
  if (indexed)
37
58
  return
38
59
  indexed = true
39
60
  document.querySelectorAll("article").forEach((article) => {
40
- article.querySelectorAll("p, li, tr, pre, blockquote, h1, h2, h3, h4, h5, h6")
61
+ article.querySelectorAll("p, li, tr, pre, blockquote, h1, h2, h3, h4, h5, h6, div.diagram")
41
62
  .forEach((el) => {
42
63
  let parent = el.parentElement
43
64
  while (parent !== null && parent.tagName !== "ARTICLE") {
@@ -45,7 +66,9 @@
45
66
  return
46
67
  parent = parent.parentElement
47
68
  }
48
- const text = (el.textContent ?? "").toLowerCase()
69
+ const text = (el.classList.contains("diagram") ?
70
+ Array.from(el.querySelectorAll("text")).map((t) => t.textContent ?? "").join(" ") :
71
+ (el.textContent ?? "")).toLowerCase()
49
72
  const words = new Set(text.split(/[^\p{L}\p{N}]+/u).filter((w) => w !== ""))
50
73
  words.forEach((w) => { vocab.add(w) })
51
74
  units.push({ el, text, words, keep: false })
@@ -138,16 +161,27 @@
138
161
  return new RegExp(alts.map((alt) => alt.src).join("|"), "giu")
139
162
  }
140
163
 
141
- /* wrap every match in the text nodes of a unit into a <mark>;
142
- walking the text nodes (instead of replacing in the raw HTML)
143
- keeps the tags and attributes untouched */
164
+ /* wrap every match in the text nodes of a unit into a <mark>, or
165
+ into a <tspan> inside the SVG text labels of a diagram (as a
166
+ foreign <mark> renders nothing there), leaving all other SVG
167
+ text nodes (like an embedded font style) untouched; walking the
168
+ text nodes (instead of replacing in the raw HTML) keeps the tags
169
+ and attributes untouched */
170
+ const svgNS = "http://www.w3.org/2000/svg"
144
171
  const highlight = (el, regex) => {
145
172
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
146
173
  const nodes = []
147
174
  let node
148
175
  while ((node = walker.nextNode()) !== null)
149
176
  nodes.push(node)
177
+ const spans = []
150
178
  nodes.forEach((text) => {
179
+ const parent = text.parentNode
180
+ if (parent === null)
181
+ return
182
+ const svg = parent.namespaceURI === svgNS
183
+ if (svg && !/^(text|tspan)$/.test(parent.localName))
184
+ return
151
185
  const value = text.nodeValue ?? ""
152
186
  const frag = document.createDocumentFragment()
153
187
  let last = 0
@@ -156,26 +190,49 @@
156
190
  while ((m = regex.exec(value)) !== null) {
157
191
  if (m.index > last)
158
192
  frag.appendChild(document.createTextNode(value.slice(last, m.index)))
159
- const mark = document.createElement("mark")
160
- mark.className = "search-hit"
193
+ const mark = svg ?
194
+ document.createElementNS(svgNS, "tspan") :
195
+ document.createElement("mark")
196
+ mark.classList.add("search-hit")
161
197
  mark.textContent = m[0]
162
198
  frag.appendChild(mark)
199
+ if (svg)
200
+ spans.push(mark)
163
201
  last = m.index + m[0].length
164
202
  }
165
203
  if (last === 0)
166
204
  return
167
205
  if (last < value.length)
168
206
  frag.appendChild(document.createTextNode(value.slice(last)))
169
- if (text.parentNode !== null)
170
- text.parentNode.replaceChild(frag, text)
207
+ parent.replaceChild(frag, text)
208
+ })
209
+
210
+ /* underlay every <tspan> with a rounded <rect> (placed below
211
+ its <text> label, padded and rounded relative to the label
212
+ height like a <mark>), as SVG text has no background of its
213
+ own; all extents are measured up-front, as every insertion
214
+ would invalidate the layout again */
215
+ const boxes = spans.map((span) => span.getBBox())
216
+ spans.forEach((span, i) => {
217
+ const label = span.closest("text")
218
+ const box = boxes[i]
219
+ const pad = box.height * 0.1
220
+ const rect = document.createElementNS(svgNS, "rect")
221
+ rect.classList.add("search-hit")
222
+ rect.setAttribute("x", String(box.x - pad))
223
+ rect.setAttribute("y", String(box.y))
224
+ rect.setAttribute("width", String(box.width + 2 * pad))
225
+ rect.setAttribute("height", String(box.height))
226
+ rect.setAttribute("rx", String(box.height * 0.2))
227
+ label.parentNode.insertBefore(rect, label)
171
228
  })
172
229
  }
173
230
 
174
- /* undo a previous search: unwrap the injected <mark> elements
175
- (re-normalizing the fragmented text nodes), drop the filtering
176
- classes, and leave the search mode */
231
+ /* undo a previous search: unwrap the injected <mark>, <tspan>,
232
+ and <rect> elements (re-normalizing the fragmented text nodes),
233
+ drop the filtering classes, and leave the search mode */
177
234
  const unmark = () => {
178
- document.querySelectorAll("mark.search-hit").forEach((mark) => {
235
+ document.querySelectorAll(".search-hit").forEach((mark) => {
179
236
  const parent = mark.parentNode
180
237
  while (mark.firstChild !== null)
181
238
  parent.insertBefore(mark.firstChild, mark)
@@ -252,19 +309,50 @@
252
309
  if (unit.el.classList.contains("search-keep"))
253
310
  highlight(unit.el, regex)
254
311
  })
312
+
313
+ /* filter the table of contents (its page rows and its side
314
+ panel entries) along: an entry stays visible only while its
315
+ target still is, which covers the front matter entries of
316
+ the panel, too, as their targets are suppressed by the
317
+ search mode anyway */
318
+ document.querySelectorAll("nav.toc table tr, nav.toc-panel div.toc-list li").forEach((entry) => {
319
+ const link = entry.querySelector(":scope > a, :scope > td > a")
320
+ const target = link !== null ?
321
+ document.getElementById(decodeURIComponent(link.hash.slice(1))) : null
322
+ if (target === null || target.getClientRects().length === 0)
323
+ entry.classList.add("search-hide")
324
+ })
255
325
  }
256
326
 
257
- /* run the search debounced on every keystroke and let the "X"
258
- clear the input field and the search results again */
327
+ /* run the search debounced on every keystroke (750ms after the
328
+ last one, as re-filtering the whole document is not cheap), let
329
+ the "X" clear the input field and the search results again, let
330
+ the Escape key additionally slide the input field back in, and
331
+ let the Enter key slide it back in with the query kept (and
332
+ applied immediately) */
259
333
  let debounce = 0
334
+ const reset = () => {
335
+ window.clearTimeout(debounce)
336
+ input.value = ""
337
+ unmark()
338
+ }
260
339
  input.addEventListener("input", () => {
261
340
  window.clearTimeout(debounce)
262
- debounce = window.setTimeout(search, 200)
341
+ debounce = window.setTimeout(search, 750)
342
+ })
343
+ input.addEventListener("keydown", (event) => {
344
+ if (event.key === "Escape")
345
+ reset()
346
+ else if (event.key === "Enter") {
347
+ window.clearTimeout(debounce)
348
+ search()
349
+ }
350
+ else
351
+ return
352
+ slide(false)
263
353
  })
264
354
  clear.addEventListener("click", () => {
265
- window.clearTimeout(debounce)
266
- input.value = ""
267
- unmark()
355
+ reset()
268
356
  input.focus()
269
357
  })
270
358
  })()