@rse/specbook 0.9.6 → 0.9.7

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 +60 -10
  2. package/dst/specbook-api.js +19 -18
  3. package/dst/specbook-cli.js +9 -6
  4. package/dst/specbook-cmd-describe.js +1 -3
  5. package/dst/specbook-cmd-export.js +19 -16
  6. package/dst/specbook-cmd-init.d.ts +1 -1
  7. package/dst/specbook-cmd-init.js +5 -4
  8. package/dst/specbook-cmd-lint.d.ts +0 -1
  9. package/dst/specbook-cmd-lint.js +29 -5
  10. package/dst/specbook-config.d.ts +1 -1
  11. package/dst/specbook-config.js +80 -71
  12. package/dst/specbook-diagnostic.js +4 -1
  13. package/dst/specbook-diagram.d.ts +3 -2
  14. package/dst/specbook-diagram.js +75 -91
  15. package/dst/specbook-export-ast.d.ts +1 -1
  16. package/dst/specbook-export-ast.js +1 -1
  17. package/dst/specbook-export-common.d.ts +2 -1
  18. package/dst/specbook-export-common.js +12 -14
  19. package/dst/specbook-export-html-search.js +10 -14
  20. package/dst/specbook-export-html.css +24 -29
  21. package/dst/specbook-export-html.js +100 -83
  22. package/dst/specbook-export-md.d.ts +1 -1
  23. package/dst/specbook-export-md.js +50 -24
  24. package/dst/specbook-export-pdf.d.ts +4 -2
  25. package/dst/specbook-export-pdf.js +48 -38
  26. package/dst/specbook-format-schema.js +6 -3
  27. package/dst/specbook-format.md +34 -18
  28. package/dst/specbook-link.d.ts +1 -0
  29. package/dst/specbook-link.js +7 -4
  30. package/dst/specbook-mcp.js +17 -10
  31. package/dst/specbook-parse-common.d.ts +1 -1
  32. package/dst/specbook-parse-semantic.d.ts +4 -2
  33. package/dst/specbook-parse-semantic.js +95 -42
  34. package/dst/specbook-parse-syntax.js +163 -136
  35. package/dst/specbook-parse-value.d.ts +1 -1
  36. package/dst/specbook-parse-value.js +6 -3
  37. package/dst/specbook-parse.d.ts +2 -5
  38. package/dst/specbook-parse.js +27 -34
  39. package/dst/specbook-verbose.js +2 -1
  40. package/package.json +1 -1
@@ -9,7 +9,8 @@ import sourceCodeError from "source-code-error";
9
9
  export const renderDiagnostic = (diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column}: ${diagnostic.message}`;
10
10
  /* render a diagnostic as a multi-line message with the affected
11
11
  source snippet, falling back to the single-line message when the
12
- source file is unreadable (e.g. the file is a directory) */
12
+ source file is unreadable (e.g. the file is a directory) or empty
13
+ (as there is no snippet to show and sourceCodeError rejects it) */
13
14
  export const renderDiagnosticVerbose = (diagnostic, colors = false) => {
14
15
  let code;
15
16
  try {
@@ -18,6 +19,8 @@ export const renderDiagnosticVerbose = (diagnostic, colors = false) => {
18
19
  catch {
19
20
  return `${renderDiagnostic(diagnostic)}\n`;
20
21
  }
22
+ if (code === "")
23
+ return `${renderDiagnostic(diagnostic)}\n`;
21
24
  return sourceCodeError({ message: diagnostic.message, filename: diagnostic.file,
22
25
  code, line: diagnostic.line, column: diagnostic.column, colors });
23
26
  };
@@ -1,8 +1,9 @@
1
1
  import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
- import type { Schema } from "./specbook-format-schema.js";
3
- import { type ParseContext } from "./specbook-parse-common.js";
2
+ import type { Schema, SchemaDiagram } from "./specbook-format-schema.js";
3
+ import type { ParseContext } from "./specbook-parse-common.js";
4
4
  export interface DiagramResult {
5
5
  spec?: string;
6
+ config?: SchemaDiagram["config"];
6
7
  errors: string[];
7
8
  }
8
9
  export declare const specDiagrams: (specification: Spec, config: Schema) => Map<SpecObject, DiagramResult>;
@@ -3,13 +3,11 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
- import { referenceRegex, buildLinkIndex, resolveUnique, resolveSet, anchorPaths } from "./specbook-link.js";
6
+ import { referenceRegex, buildLinkIndex, resolveUnique, resolveSet, anchorPaths, expandReferences, plainText } from "./specbook-link.js";
7
+ import { collectSchemas } from "./specbook-parse-semantic.js";
7
8
  /* the single Wiki-style reference match (the non-global sibling of
8
9
  the imported, global "referenceRegex") */
9
- const referenceOnce = /\[\[([^[\]]+)\]\]/;
10
- /* strip the inline code markup of a name or property value
11
- (preserved in the AST for rendering) for labeling purposes */
12
- const plainText = (text) => text.replace(/`/g, "");
10
+ const referenceOnce = new RegExp(referenceRegex.source);
13
11
  /* render a text as a Gradia atom: a bareword where possible
14
12
  (no whitespace, no special characters, no "--"), a quoted
15
13
  string otherwise */
@@ -37,11 +35,9 @@ const configValue = (value) => {
37
35
  const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
38
36
  const lines = [`#type ${type}`];
39
37
  const config = diagram.config ?? {};
40
- for (const key of Object.keys(config)) {
41
- const value = config[key];
38
+ for (const [key, value] of Object.entries(config))
42
39
  if (value !== undefined)
43
40
  lines.push(`#config ${key} ${configValue(value)}`);
44
- }
45
41
  lines.push("");
46
42
  for (const node of nodes) {
47
43
  const anchor = anchors.get(node) ?? node.id;
@@ -57,10 +53,7 @@ const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
57
53
  for (const key of diagram.properties ?? []) {
58
54
  const value = node.properties.find((property) => property.key === key)?.value;
59
55
  if (value !== undefined) {
60
- const text = value.replace(referenceRegex, (_, reference) => {
61
- const target = resolveUnique(index, reference.trim()).target;
62
- return target !== undefined ? target.name : reference.trim();
63
- });
56
+ const text = expandReferences(value, (reference) => resolveUnique(index, reference).target?.name ?? reference);
64
57
  attrs.push(`${atom(key)}: ${atom(plainText(text))}`);
65
58
  }
66
59
  }
@@ -78,56 +71,14 @@ const renderSpec = (diagram, type, center, nodes, edges, index, anchors) => {
78
71
  }
79
72
  return lines.join("\n") + "\n";
80
73
  };
81
- /* derive the Gradia spec of a single object from its "diagram:"
82
- schema configuration (the returned spec is absent whenever the
83
- configured diagram situation is invalid) */
84
- const deriveDiagram = (object, diagram, index, anchors, parents) => {
85
- const type = diagram.type ?? "graph";
86
- const errors = new Array();
87
- /* resolve a comma-separated "[[...]]" reference pattern string
88
- into its (deduplicated, order-preserving) object match set */
89
- const resolvePatterns = (value, field) => {
90
- const matches = new Array();
91
- const seen = new Set();
92
- for (const m of value.matchAll(referenceRegex)) {
93
- const pattern = m[1].trim();
94
- const set = resolveSet(index, pattern);
95
- if (set.length === 0)
96
- errors.push(`unresolvable diagram "${field}" pattern "[[${pattern}]]"`);
97
- for (const match of set) {
98
- if (!seen.has(match)) {
99
- seen.add(match);
100
- matches.push(match);
101
- }
102
- }
103
- }
104
- return matches;
105
- };
106
- /* determine the node objects (default: the current object plus
107
- all objects below it) and the edge objects (default: none),
108
- with the edge objects never acting as nodes themselves */
109
- let nodes;
110
- if (diagram.nodes !== undefined)
111
- nodes = resolvePatterns(diagram.nodes, "nodes");
112
- else {
113
- nodes = new Array();
114
- const walk = (o) => {
115
- nodes.push(o);
116
- o.childs.forEach(walk);
117
- };
118
- walk(object);
119
- }
120
- const edgeObjects = diagram.edges !== undefined ? resolvePatterns(diagram.edges, "edges") : [];
121
- const edgeSet = new Set(edgeObjects);
122
- nodes = nodes.filter((node) => !edgeSet.has(node));
123
- const nodeSet = new Set(nodes);
124
- /* derive the edges: from the "[[...]]" references of the node
125
- objects (per the "links" selection) and from the edge objects by
126
- convention (source: parent object, target: first reference in the
127
- property values, name: object name, arity: "ARITY" property),
128
- overridable via "edgeSource"/"edgeTarget"/"edgeArity" (a "grid"
129
- diagram is edge-less by definition, so no edges are derived at all) */
130
- let edges = new Array();
74
+ /* derive the edges of a diagram: from the "[[...]]" references of the
75
+ node objects (per the "links" selection) and from the edge objects by
76
+ convention (source: parent object, target: first reference in the
77
+ property values, name: object name, arity: "ARITY" property),
78
+ overridable via "edgeSource"/"edgeTarget"/"edgeArity" (a "grid"
79
+ diagram is edge-less by definition, so no edges are derived at all) */
80
+ const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, errors) => {
81
+ const edges = new Array();
131
82
  if (type !== "grid") {
132
83
  for (const node of nodes) {
133
84
  const texts = node.properties.map((property) => property.value);
@@ -188,11 +139,13 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
188
139
  errors.push("\"grid\" diagram cannot carry an \"edges\" configuration");
189
140
  if (diagram.hierarchy === true)
190
141
  errors.push("\"grid\" diagram cannot carry a \"hierarchy\" configuration");
142
+ if (diagram.onlyConnected === true)
143
+ errors.push("\"grid\" diagram cannot carry an \"onlyConnected\" configuration");
191
144
  }
192
145
  /* deduplicate the edges (the same reference can occur in
193
146
  multiple texts of the same node object) */
194
147
  const seenEdges = new Set();
195
- edges = edges.filter((edge) => {
148
+ return edges.filter((edge) => {
196
149
  const key = `${anchors.get(edge.source) ?? edge.source.id}` +
197
150
  `\u0000${anchors.get(edge.target) ?? edge.target.id}` +
198
151
  `\u0000${edge.name ?? ""}\u0000${edge.arity ?? ""}`;
@@ -201,15 +154,68 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
201
154
  seenEdges.add(key);
202
155
  return true;
203
156
  });
157
+ };
158
+ /* resolve a comma-separated "[[...]]" reference pattern string into
159
+ its (deduplicated, order-preserving) object match set, reporting
160
+ every unresolvable pattern as an error */
161
+ const resolvePatterns = (index, errors, value, field) => {
162
+ const matches = new Array();
163
+ const seen = new Set();
164
+ let found = false;
165
+ for (const m of value.matchAll(referenceRegex)) {
166
+ found = true;
167
+ const pattern = m[1].trim();
168
+ const set = resolveSet(index, pattern);
169
+ if (set.length === 0)
170
+ errors.push(`unresolvable diagram "${field}" pattern "[[${pattern}]]"`);
171
+ for (const match of set) {
172
+ if (!seen.has(match)) {
173
+ seen.add(match);
174
+ matches.push(match);
175
+ }
176
+ }
177
+ }
178
+ if (!found)
179
+ errors.push(`diagram "${field}" carries no "[[...]]" reference pattern`);
180
+ return matches;
181
+ };
182
+ /* derive the Gradia spec of a single object from its "diagram:"
183
+ schema configuration (the returned spec is absent whenever the
184
+ configured diagram situation is invalid) */
185
+ const deriveDiagram = (object, diagram, index, anchors, parents) => {
186
+ const type = diagram.type ?? "graph";
187
+ const errors = new Array();
188
+ /* determine the node objects (default: the current object plus
189
+ all objects below it) and the edge objects (default: none),
190
+ with the edge objects never acting as nodes themselves */
191
+ let nodes;
192
+ if (diagram.nodes !== undefined)
193
+ nodes = resolvePatterns(index, errors, diagram.nodes, "nodes");
194
+ else {
195
+ nodes = new Array();
196
+ const walk = (o) => {
197
+ nodes.push(o);
198
+ o.childs.forEach(walk);
199
+ };
200
+ walk(object);
201
+ }
202
+ const edgeObjects = diagram.edges !== undefined ? resolvePatterns(index, errors, diagram.edges, "edges") : [];
203
+ const edgeSet = new Set(edgeObjects);
204
+ nodes = nodes.filter((node) => !edgeSet.has(node));
205
+ const nodeSet = new Set(nodes);
206
+ /* derive the (deduplicated) edges of the diagram */
207
+ let edges = deriveEdges(diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, errors);
204
208
  /* determine the center object of a "hub" diagram: the current
205
209
  object for the default "self" configuration, the uniquely
206
210
  resolved object of an explicit "[[...]]" reference otherwise */
207
211
  let center = object;
212
+ if (type !== "hub" && diagram.center !== undefined)
213
+ errors.push(`"${type}" diagram cannot carry a "center" configuration`);
208
214
  if (type === "hub" && diagram.center !== undefined && diagram.center !== "self") {
209
215
  const reference = diagram.center.match(referenceOnce)?.[1].trim();
210
216
  const resolved = reference !== undefined ? resolveUnique(index, reference) : undefined;
211
217
  if (resolved?.target === undefined) {
212
- errors.push(`${resolved?.ambiguous === true ? "ambiguous" : "unresolvable"}` +
218
+ errors.push((resolved?.ambiguous === true ? "ambiguous" : "unresolvable") +
213
219
  ` diagram "center" reference "${diagram.center}"`);
214
220
  return { errors };
215
221
  }
@@ -248,34 +254,11 @@ const deriveDiagram = (object, diagram, index, anchors, parents) => {
248
254
  if (errors.length > 0)
249
255
  return { errors };
250
256
  /* the "collapse" handling (enabled by default) silently omits a
251
- degenerated diagram, consisting of a single node only, as such a
257
+ degenerate diagram, consisting of a single node only, as such a
252
258
  diagram carries no information beyond the object itself */
253
259
  if (diagram.collapse !== false && nodes.length === 1 && edges.length === 0)
254
260
  return { errors };
255
- return { spec: renderSpec(diagram, type, center, nodes, edges, index, anchors), errors };
256
- };
257
- /* collect the objects whose schema configuration carries a "diagram:"
258
- field (the schema resolution mirrors the semantic validation) */
259
- const collectDiagrams = (specification, config) => {
260
- const diagrams = new Map();
261
- const walk = (object, schema) => {
262
- if (schema.diagram !== undefined)
263
- diagrams.set(object, schema.diagram);
264
- for (const child of object.childs) {
265
- const childSchema = (schema.childs ?? []).find((c) => c.kind === child.kind);
266
- if (childSchema !== undefined)
267
- walk(child, childSchema);
268
- }
269
- };
270
- for (const artifact of specification.artifacts) {
271
- for (const object of artifact.objects) {
272
- const schema = config.find((s) => (s.kind === object.kind && s.id === object.id) || `${s.kind}-${s.id}` === object.id) ??
273
- config.find((s) => (s.name ?? "").toUpperCase() === plainText(object.name).toUpperCase());
274
- if (schema !== undefined)
275
- walk(object, schema);
276
- }
277
- }
278
- return diagrams;
261
+ return { spec: renderSpec(diagram, type, center, nodes, edges, index, anchors), config: diagram.config, errors };
279
262
  };
280
263
  /* derive the Gradia specs of all diagram-configured objects
281
264
  of a specification */
@@ -286,8 +269,9 @@ export const specDiagrams = (specification, config) => {
286
269
  for (const node of index)
287
270
  parents.set(node.object, node.parent?.object);
288
271
  const results = new Map();
289
- for (const [object, diagram] of collectDiagrams(specification, config))
290
- results.set(object, deriveDiagram(object, diagram, index, anchors, parents));
272
+ for (const [object, schema] of collectSchemas(specification, config))
273
+ if (schema.diagram !== undefined)
274
+ results.set(object, deriveDiagram(object, schema.diagram, index, anchors, parents));
291
275
  return results;
292
276
  };
293
277
  /* validate all configured diagrams of a specification, reporting
@@ -1,4 +1,4 @@
1
1
  import type { Spec } from "./specbook-format-spec.js";
2
2
  import type { Schema } from "./specbook-format-schema.js";
3
3
  export type AstFormat = "json" | "json5" | "yaml" | "toon";
4
- export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema) => Promise<Buffer>;
4
+ export declare const renderAst: (specification: Spec, format: AstFormat, config?: Schema) => Buffer;
@@ -8,7 +8,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
10
  /* render the specification AST into a serialization format */
11
- export const renderAst = async (specification, format, config) => {
11
+ export const renderAst = (specification, format, config) => {
12
12
  /* reduce the specification to plain JSON values (ISO date strings) */
13
13
  const plain = JSON.parse(JSON.stringify(specification));
14
14
  /* attach the Gradia specs of the diagram-configured objects as
@@ -1,8 +1,9 @@
1
1
  import type { Spec, SpecObject } from "./specbook-format-spec.js";
2
+ import type { embeddingThemes } from "./specbook-parse-common.js";
2
3
  export declare const escapeHtml: (text: string) => string;
3
4
  export declare const stylesheet: () => string;
4
5
  export declare const searchScript: () => string;
5
- export declare const fallbackLogo: (theme: string) => string;
6
+ export declare const fallbackLogo: (theme: (typeof embeddingThemes)[number]) => string;
6
7
  export declare const isTitleObject: (object: SpecObject) => boolean;
7
8
  export declare const titleObject: (specification: Spec) => SpecObject | undefined;
8
9
  export declare const documentLang: (specification: Spec) => string | undefined;
@@ -3,20 +3,19 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
- import path from "node:path";
7
6
  import fs from "node:fs";
8
- import { fileURLToPath } from "node:url";
9
7
  /* escape a text for embedding into template HTML (text and attributes) */
10
8
  export const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
11
9
  .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
12
10
  /* provide the build-time pre-assembled stylesheet (with the
13
11
  font faces already inlined as base64 data: URIs) */
14
- export const stylesheet = () => fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-export-html.css"), "utf8");
12
+ export const stylesheet = () => fs.readFileSync(new URL("specbook-export-html.css", import.meta.url), "utf8");
15
13
  /* provide the build-time bundled client-side fuzzy search script */
16
- export const searchScript = () => fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "specbook-export-html-search.js"), "utf8");
14
+ export const searchScript = () => fs.readFileSync(new URL("specbook-export-html-search.js", import.meta.url), "utf8");
17
15
  /* provide a theme variant of the build-time bundled fallback logo of
18
16
  SpecBook itself (as a self-contained data: URL, to keep its styles isolated) */
19
- export const fallbackLogo = (theme) => "data:image/svg+xml;base64," + fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), `specbook-export-logo-${theme}.svg`)).toString("base64");
17
+ export const fallbackLogo = (theme) => "data:image/svg+xml;base64," +
18
+ fs.readFileSync(new URL(`specbook-export-logo-${theme}.svg`, import.meta.url)).toString("base64");
20
19
  /* check whether an object is the specification title object */
21
20
  export const isTitleObject = (object) => object.kind === "META" && object.name.toUpperCase() === "TITLE";
22
21
  /* determine the title object of the specification, searched at any
@@ -71,14 +70,15 @@ export const documentPaperSize = (specification) => {
71
70
  `(expected ${paperSizes.join(", ")})`);
72
71
  return paper;
73
72
  };
74
- /* provide the paper-dependent print stylesheet: a diagram is scaled
75
- down to still fit onto a single page (the paper height less the
76
- print margins, the own vertical margins of the diagram, and the
77
- room its introducing heading claims above it) and is never broken
78
- across a page boundary; without the heading reserve a maximally
73
+ /* the vertical room (in rem) the introducing heading of a diagram
74
+ claims above it on the same page: without this reserve a maximally
79
75
  sized diagram could not share its page with the heading, which
80
76
  would defeat the "break-after: avoid" bundling of the stylesheet */
81
77
  const headingReserve = 6;
78
+ /* provide the paper-dependent print stylesheet: a diagram is scaled
79
+ down to still fit onto a single page (the paper height less the
80
+ print margins, the own vertical margins of the diagram, and the
81
+ heading reserve above it) and is never broken across a page boundary */
82
82
  export const paperStylesheet = (paper) => {
83
83
  const setup = paperSetup(paper);
84
84
  const avail = setup.height - setup.margin.top - setup.margin.bottom;
@@ -131,9 +131,7 @@ const typographyGlyphs = [
131
131
  glyphs (no charset or a full Unicode charset keeps the fonts complete) */
132
132
  export const subsetStylesheet = async (charset) => {
133
133
  const css = stylesheet();
134
- if (charset === undefined)
135
- return css;
136
- const codepoints = charsetCodepoints(charset);
134
+ const codepoints = charset !== undefined ? charsetCodepoints(charset) : undefined;
137
135
  if (codepoints === undefined)
138
136
  return css;
139
137
  const { default: subsetFont } = await import("subset-font");
@@ -162,7 +160,7 @@ export const documentTitle = (specification) => ({
162
160
  export const documentLogo = (specification) => {
163
161
  const content = titleObject(specification)
164
162
  ?.properties.find((property) => property.key === "LOGO")?.embedding?.[0];
165
- if (content === undefined)
163
+ if (content === undefined || content === "")
166
164
  return fallbackLogo("light");
167
165
  return content.startsWith("data:") ? content :
168
166
  `data:image/svg+xml;base64,${Buffer.from(content, "utf8").toString("base64")}`;
@@ -18,7 +18,7 @@
18
18
 
19
19
  /* shortest word still eligible for fuzzy matching: below it the
20
20
  edit distance would equate it with almost any short word */
21
- const FUZZY_MIN = 4
21
+ const fuzzyMin = 4
22
22
 
23
23
  /* the searchable units (paragraphs, list items, table rows, and
24
24
  headings), the corpus vocabulary, and the per-word cache of the
@@ -101,7 +101,7 @@
101
101
  if (result !== undefined)
102
102
  return result
103
103
  result = []
104
- if (word.length >= FUZZY_MIN)
104
+ if (word.length >= fuzzyMin)
105
105
  vocab.forEach((given) => {
106
106
  if (Math.abs(word.length - given.length) <= 1
107
107
  && (diceCoefficient(word, given) >= 0.50
@@ -115,19 +115,21 @@
115
115
  /* match a query word against a unit, as a literal substring or as
116
116
  a fuzzy variant, remembering the variants for the highlighting */
117
117
  const wordMatch = (word, unit, variants) => {
118
- if (unit.text.indexOf(word) >= 0)
118
+ if (unit.text.includes(word))
119
119
  return true
120
120
  const near = fuzzyWords(word).filter((v) => unit.words.has(v))
121
121
  near.forEach((v) => { variants.add(v) })
122
122
  return near.length > 0
123
123
  }
124
124
 
125
+ /* escape a text for its literal use inside a regular expression */
126
+ const escapeRegex = (s) =>
127
+ s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
128
+
125
129
  /* the alternation matching everything worth highlighting: the
126
130
  query words as plain substrings plus the fuzzy variants as
127
131
  whole words, longest alternative first, so a variant is never
128
132
  cut short by a query word nested inside it */
129
- const escapeRegex = (s) =>
130
- s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
131
133
  const buildRegex = (words, variants) => {
132
134
  const alts = words.map((w) => ({ len: w.length, src: escapeRegex(w) }))
133
135
  .concat(variants.map((v) => ({ len: v.length,
@@ -203,7 +205,7 @@
203
205
  units.forEach((unit) => { unit.keep = false })
204
206
  parts.forEach((part) => {
205
207
  let matched = units.filter((unit) =>
206
- part.every((word) => unit.text.indexOf(word) >= 0))
208
+ part.every((word) => unit.text.includes(word)))
207
209
  if (matched.length === 0)
208
210
  matched = units.filter((unit) =>
209
211
  part.every((word) => wordMatch(word, unit, variants)))
@@ -226,7 +228,7 @@
226
228
  const heading = section.firstElementChild
227
229
  if (heading !== null && /^H[1-6]$/.test(heading.tagName))
228
230
  heading.classList.add("search-keep")
229
- section = section.parentElement !== null ? section.parentElement.closest("section") : null
231
+ section = section.parentElement?.closest("section") ?? null
230
232
  }
231
233
  })
232
234
 
@@ -244,13 +246,7 @@
244
246
  })
245
247
 
246
248
  /* highlight every matched word within the kept units */
247
- const words = []
248
- parts.forEach((part) => {
249
- part.forEach((word) => {
250
- if (!words.includes(word))
251
- words.push(word)
252
- })
253
- })
249
+ const words = Array.from(new Set(parts.flat()))
254
250
  const regex = buildRegex(words, Array.from(variants))
255
251
  units.forEach((unit) => {
256
252
  if (unit.el.classList.contains("search-keep"))