@rse/specbook 1.0.2 → 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.
- package/README.md +106 -19
- package/dst/specbook-api.d.ts +32 -12
- package/dst/specbook-api.js +114 -33
- package/dst/specbook-cli.js +92 -31
- package/dst/specbook-cmd-describe.d.ts +7 -1
- package/dst/specbook-cmd-describe.js +56 -11
- package/dst/specbook-cmd-export.d.ts +3 -1
- package/dst/specbook-cmd-export.js +46 -3
- package/dst/specbook-cmd-init.d.ts +2 -1
- package/dst/specbook-cmd-lint.d.ts +4 -2
- package/dst/specbook-cmd-lint.js +12 -8
- package/dst/specbook-cmd-preview.d.ts +12 -0
- package/dst/specbook-cmd-preview.js +57 -0
- package/dst/specbook-config.d.ts +1 -1
- package/dst/specbook-config.js +187 -37
- package/dst/specbook-diagnostic.d.ts +2 -0
- package/dst/specbook-diagnostic.js +4 -2
- package/dst/specbook-diagram.d.ts +1 -0
- package/dst/specbook-diagram.js +0 -0
- package/dst/specbook-export-ast.js +4 -2
- package/dst/specbook-export-common.js +5 -3
- package/dst/specbook-export-html-search.js +118 -30
- package/dst/specbook-export-html.css +1140 -233
- package/dst/specbook-export-html.d.ts +1 -1
- package/dst/specbook-export-html.js +764 -120
- package/dst/specbook-export-md.js +4 -2
- package/dst/specbook-export-pdf.d.ts +4 -1
- package/dst/specbook-export-pdf.js +46 -16
- package/dst/specbook-format-schema.d.ts +33 -1
- package/dst/specbook-format-schema.js +34 -2
- package/dst/specbook-format.md +193 -25
- package/dst/specbook-format.yaml +2291 -299
- package/dst/specbook-link.d.ts +2 -1
- package/dst/specbook-link.js +43 -3
- package/dst/specbook-mcp.js +20 -11
- package/dst/specbook-parse-common.d.ts +4 -2
- package/dst/specbook-parse-common.js +6 -3
- package/dst/specbook-parse-semantic.js +290 -30
- package/dst/specbook-parse-syntax.js +3 -1
- package/dst/specbook-parse-value.d.ts +1 -0
- package/dst/specbook-parse-value.js +17 -3
- package/dst/specbook-parse.js +1 -1
- package/dst/specbook-verbose.d.ts +2 -0
- package/package.json +14 -6
package/dst/specbook-config.js
CHANGED
|
@@ -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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
34
|
-
diagnostics.push({ file, line
|
|
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
|
-
|
|
52
|
-
|
|
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(
|
|
193
|
+
expr = compileValueExpr(entry);
|
|
55
194
|
}
|
|
56
195
|
catch (err) {
|
|
57
|
-
diagnose([...at, "
|
|
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
|
|
78
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
251
|
+
if (doc.errors.length === 0)
|
|
252
|
+
docs.push({ file, doc, lines, raw: doc.toJS() });
|
|
107
253
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
const
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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>;
|
package/dst/specbook-diagram.js
CHANGED
|
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,
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
11
|
-
header and the headings of all enclosing objects, and
|
|
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
|
|
15
|
-
const
|
|
16
|
-
|
|
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,
|
|
24
|
-
headings), the corpus vocabulary, and the per-word
|
|
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
|
|
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.
|
|
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
|
-
|
|
143
|
-
|
|
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 =
|
|
160
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
|
175
|
-
(re-normalizing the fragmented text nodes),
|
|
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("
|
|
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
|
|
258
|
-
|
|
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,
|
|
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
|
-
|
|
266
|
-
input.value = ""
|
|
267
|
-
unmark()
|
|
355
|
+
reset()
|
|
268
356
|
input.focus()
|
|
269
357
|
})
|
|
270
358
|
})()
|