@rse/specbook 0.9.2
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 +199 -0
- package/dst/specbook-api.d.ts +47 -0
- package/dst/specbook-api.js +77 -0
- package/dst/specbook-cli.d.ts +2 -0
- package/dst/specbook-cli.js +156 -0
- package/dst/specbook-cmd-describe.d.ts +2 -0
- package/dst/specbook-cmd-describe.js +97 -0
- package/dst/specbook-cmd-edit.d.ts +9 -0
- package/dst/specbook-cmd-edit.js +69 -0
- package/dst/specbook-cmd-export.d.ts +9 -0
- package/dst/specbook-cmd-export.js +78 -0
- package/dst/specbook-cmd-import.d.ts +9 -0
- package/dst/specbook-cmd-import.js +73 -0
- package/dst/specbook-cmd-init.d.ts +7 -0
- package/dst/specbook-cmd-init.js +37 -0
- package/dst/specbook-cmd-lint.d.ts +15 -0
- package/dst/specbook-cmd-lint.js +46 -0
- package/dst/specbook-config.d.ts +6 -0
- package/dst/specbook-config.js +91 -0
- package/dst/specbook-diagnostic.d.ts +8 -0
- package/dst/specbook-diagnostic.js +23 -0
- package/dst/specbook-diagram.d.ts +9 -0
- package/dst/specbook-diagram.js +271 -0
- package/dst/specbook-export-ast.d.ts +4 -0
- package/dst/specbook-export-ast.js +36 -0
- package/dst/specbook-export-common.d.ts +33 -0
- package/dst/specbook-export-common.js +142 -0
- package/dst/specbook-export-html-search.js +272 -0
- package/dst/specbook-export-html.css +259 -0
- package/dst/specbook-export-html.d.ts +9 -0
- package/dst/specbook-export-html.js +452 -0
- package/dst/specbook-export-logo.svg +35 -0
- package/dst/specbook-export-md.d.ts +3 -0
- package/dst/specbook-export-md.js +74 -0
- package/dst/specbook-export-pdf.d.ts +7 -0
- package/dst/specbook-export-pdf.js +198 -0
- package/dst/specbook-link.d.ts +17 -0
- package/dst/specbook-link.js +113 -0
- package/dst/specbook-llm.d.ts +13 -0
- package/dst/specbook-llm.js +61 -0
- package/dst/specbook-mcp.d.ts +1 -0
- package/dst/specbook-mcp.js +163 -0
- package/dst/specbook-parse-common.d.ts +26 -0
- package/dst/specbook-parse-common.js +33 -0
- package/dst/specbook-parse-semantic.d.ts +5 -0
- package/dst/specbook-parse-semantic.js +254 -0
- package/dst/specbook-parse-syntax.d.ts +3 -0
- package/dst/specbook-parse-syntax.js +363 -0
- package/dst/specbook-parse-value.d.ts +19 -0
- package/dst/specbook-parse-value.js +123 -0
- package/dst/specbook-parse.d.ts +8 -0
- package/dst/specbook-parse.js +43 -0
- package/dst/specbook-struct-schema.d.ts +35 -0
- package/dst/specbook-struct-schema.js +47 -0
- package/dst/specbook-struct-spec.d.ts +31 -0
- package/dst/specbook-struct-spec.js +36 -0
- package/dst/specbook-theme.d.ts +16 -0
- package/dst/specbook-theme.js +38 -0
- package/package.json +77 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Specification Book (SpecBook)
|
|
3
|
+
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* the client-side fuzzy search over the rendered specification: the
|
|
8
|
+
whitespace-separated words of a comma-separated query part are
|
|
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 */
|
|
13
|
+
(function () {
|
|
14
|
+
const input = document.getElementById("search-input")
|
|
15
|
+
const clear = document.getElementById("search-clear")
|
|
16
|
+
if (input === null || clear === null)
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
/* shortest word still eligible for fuzzy matching: below it the
|
|
20
|
+
edit distance would equate it with almost any short word */
|
|
21
|
+
const FUZZY_MIN = 4
|
|
22
|
+
|
|
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 */
|
|
26
|
+
const units = []
|
|
27
|
+
const vocab = new Set()
|
|
28
|
+
const fuzzy = new Map()
|
|
29
|
+
let indexed = false
|
|
30
|
+
|
|
31
|
+
/* fill the unit and vocabulary indices once on first use: a unit
|
|
32
|
+
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) */
|
|
35
|
+
const index = () => {
|
|
36
|
+
if (indexed)
|
|
37
|
+
return
|
|
38
|
+
indexed = true
|
|
39
|
+
document.querySelectorAll("article").forEach((article) => {
|
|
40
|
+
article.querySelectorAll("p, li, tr, pre, blockquote, h1, h2, h3, h4, h5, h6")
|
|
41
|
+
.forEach((el) => {
|
|
42
|
+
let parent = el.parentElement
|
|
43
|
+
while (parent !== null && parent.tagName !== "ARTICLE") {
|
|
44
|
+
if (/^(TR|TD|TH|LI|P|BLOCKQUOTE)$/.test(parent.tagName))
|
|
45
|
+
return
|
|
46
|
+
parent = parent.parentElement
|
|
47
|
+
}
|
|
48
|
+
const text = (el.textContent || "").toLowerCase()
|
|
49
|
+
const words = new Set(text.split(/[^\p{L}\p{N}]+/u).filter((w) => w !== ""))
|
|
50
|
+
words.forEach((w) => { vocab.add(w) })
|
|
51
|
+
units.push({ el, text, words, keep: false })
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* the Levenshtein edit distance of two words */
|
|
57
|
+
const levenshtein = (a, b) => {
|
|
58
|
+
let prev = []
|
|
59
|
+
let curr = []
|
|
60
|
+
for (let j = 0; j <= b.length; j++)
|
|
61
|
+
prev[j] = j
|
|
62
|
+
for (let i = 1; i <= a.length; i++) {
|
|
63
|
+
curr[0] = i
|
|
64
|
+
for (let j = 1; j <= b.length; j++)
|
|
65
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1,
|
|
66
|
+
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
67
|
+
const swap = prev
|
|
68
|
+
prev = curr
|
|
69
|
+
curr = swap
|
|
70
|
+
}
|
|
71
|
+
return prev[b.length]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* the Sorensen-Dice bigram similarity coefficient of two words */
|
|
75
|
+
const diceCoefficient = (a, b) => {
|
|
76
|
+
if (a === b)
|
|
77
|
+
return 1
|
|
78
|
+
if (a.length < 2 || b.length < 2)
|
|
79
|
+
return 0
|
|
80
|
+
const bigrams = new Map()
|
|
81
|
+
for (let i = 0; i < a.length - 1; i++) {
|
|
82
|
+
const bigram = a.substring(i, i + 2)
|
|
83
|
+
bigrams.set(bigram, (bigrams.get(bigram) ?? 0) + 1)
|
|
84
|
+
}
|
|
85
|
+
let hits = 0
|
|
86
|
+
for (let i = 0; i < b.length - 1; i++) {
|
|
87
|
+
const bigram = b.substring(i, i + 2)
|
|
88
|
+
const count = bigrams.get(bigram) ?? 0
|
|
89
|
+
if (count > 0) {
|
|
90
|
+
bigrams.set(bigram, count - 1)
|
|
91
|
+
hits++
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return (2 * hits) / (a.length + b.length - 2)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* the fuzzy-equivalent vocabulary words of a query word, cached
|
|
98
|
+
across keystrokes, as this comparison is the expensive part */
|
|
99
|
+
const fuzzyWords = (word) => {
|
|
100
|
+
let result = fuzzy.get(word)
|
|
101
|
+
if (result !== undefined)
|
|
102
|
+
return result
|
|
103
|
+
result = []
|
|
104
|
+
if (word.length >= FUZZY_MIN)
|
|
105
|
+
vocab.forEach((given) => {
|
|
106
|
+
if (Math.abs(word.length - given.length) <= 1
|
|
107
|
+
&& (diceCoefficient(word, given) >= 0.50
|
|
108
|
+
|| levenshtein(word, given) <= 2))
|
|
109
|
+
result.push(given)
|
|
110
|
+
})
|
|
111
|
+
fuzzy.set(word, result)
|
|
112
|
+
return result
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* match a query word against a unit, as a literal substring or as
|
|
116
|
+
a fuzzy variant, remembering the variants for the highlighting */
|
|
117
|
+
const wordMatch = (word, unit, variants) => {
|
|
118
|
+
if (unit.text.indexOf(word) >= 0)
|
|
119
|
+
return true
|
|
120
|
+
const near = fuzzyWords(word).filter((v) => unit.words.has(v))
|
|
121
|
+
near.forEach((v) => { variants.add(v) })
|
|
122
|
+
return near.length > 0
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* the alternation matching everything worth highlighting: the
|
|
126
|
+
query words as plain substrings plus the fuzzy variants as
|
|
127
|
+
whole words, longest alternative first, so a variant is never
|
|
128
|
+
cut short by a query word nested inside it */
|
|
129
|
+
const escapeRegex = (s) =>
|
|
130
|
+
s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
131
|
+
const buildRegex = (words, variants) => {
|
|
132
|
+
const alts = words.map((w) => ({ len: w.length, src: escapeRegex(w) }))
|
|
133
|
+
.concat(variants.map((v) => ({ len: v.length,
|
|
134
|
+
src: "(?<![\\p{L}\\p{N}])" + escapeRegex(v) + "(?![\\p{L}\\p{N}])" })))
|
|
135
|
+
alts.sort((a, b) => b.len - a.len)
|
|
136
|
+
return new RegExp(alts.map((alt) => alt.src).join("|"), "giu")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* wrap every match in the text nodes of a unit into a <mark>;
|
|
140
|
+
walking the text nodes (instead of replacing in the raw HTML)
|
|
141
|
+
keeps the tags and attributes untouched */
|
|
142
|
+
const highlight = (el, regex) => {
|
|
143
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
|
144
|
+
const nodes = []
|
|
145
|
+
let node
|
|
146
|
+
while ((node = walker.nextNode()) !== null)
|
|
147
|
+
nodes.push(node)
|
|
148
|
+
nodes.forEach((text) => {
|
|
149
|
+
const value = text.nodeValue ?? ""
|
|
150
|
+
const frag = document.createDocumentFragment()
|
|
151
|
+
let last = 0
|
|
152
|
+
let m
|
|
153
|
+
regex.lastIndex = 0
|
|
154
|
+
while ((m = regex.exec(value)) !== null) {
|
|
155
|
+
if (m.index > last)
|
|
156
|
+
frag.appendChild(document.createTextNode(value.slice(last, m.index)))
|
|
157
|
+
const mark = document.createElement("mark")
|
|
158
|
+
mark.className = "search-hit"
|
|
159
|
+
mark.textContent = m[0]
|
|
160
|
+
frag.appendChild(mark)
|
|
161
|
+
last = m.index + m[0].length
|
|
162
|
+
}
|
|
163
|
+
if (last === 0)
|
|
164
|
+
return
|
|
165
|
+
if (last < value.length)
|
|
166
|
+
frag.appendChild(document.createTextNode(value.slice(last)))
|
|
167
|
+
if (text.parentNode !== null)
|
|
168
|
+
text.parentNode.replaceChild(frag, text)
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* undo a previous search: unwrap the injected <mark> elements
|
|
173
|
+
(re-normalizing the fragmented text nodes), drop the filtering
|
|
174
|
+
classes, and leave the search mode */
|
|
175
|
+
const unmark = () => {
|
|
176
|
+
document.querySelectorAll("mark.search-hit").forEach((mark) => {
|
|
177
|
+
const parent = mark.parentNode
|
|
178
|
+
while (mark.firstChild !== null)
|
|
179
|
+
parent.insertBefore(mark.firstChild, mark)
|
|
180
|
+
parent.removeChild(mark)
|
|
181
|
+
parent.normalize()
|
|
182
|
+
})
|
|
183
|
+
document.querySelectorAll(".search-keep").forEach((el) => { el.classList.remove("search-keep") })
|
|
184
|
+
document.querySelectorAll(".search-hide").forEach((el) => { el.classList.remove("search-hide") })
|
|
185
|
+
document.body.classList.remove("searching")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* apply the current query onto the units: each part matches in up
|
|
189
|
+
to two passes, literal substrings first and, only if those match
|
|
190
|
+
nothing at all, the fuzzy variants, as the edit-distance
|
|
191
|
+
neighborhood of a word is wide enough to dilute a correctly
|
|
192
|
+
spelled query and has to stay a fallback for misspelled ones */
|
|
193
|
+
const search = () => {
|
|
194
|
+
unmark()
|
|
195
|
+
const parts = input.value.toLowerCase().split(",")
|
|
196
|
+
.map((part) => part.split(/\s+/).filter((word) => word !== ""))
|
|
197
|
+
.filter((part) => part.length > 0)
|
|
198
|
+
if (parts.length === 0)
|
|
199
|
+
return
|
|
200
|
+
index()
|
|
201
|
+
document.body.classList.add("searching")
|
|
202
|
+
const variants = new Set()
|
|
203
|
+
units.forEach((unit) => { unit.keep = false })
|
|
204
|
+
parts.forEach((part) => {
|
|
205
|
+
let matched = units.filter((unit) =>
|
|
206
|
+
part.every((word) => unit.text.indexOf(word) >= 0))
|
|
207
|
+
if (matched.length === 0)
|
|
208
|
+
matched = units.filter((unit) =>
|
|
209
|
+
part.every((word) => wordMatch(word, unit, variants)))
|
|
210
|
+
matched.forEach((unit) => { unit.keep = true })
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
/* a kept table row also keeps the table header, and a kept
|
|
214
|
+
unit keeps the headings of all its enclosing objects */
|
|
215
|
+
units.forEach((unit) => {
|
|
216
|
+
if (!unit.keep)
|
|
217
|
+
return
|
|
218
|
+
unit.el.classList.add("search-keep")
|
|
219
|
+
if (unit.el.tagName === "TR") {
|
|
220
|
+
const table = unit.el.closest("table")
|
|
221
|
+
if (table !== null)
|
|
222
|
+
table.querySelectorAll("thead tr").forEach((tr) => { tr.classList.add("search-keep") })
|
|
223
|
+
}
|
|
224
|
+
let section = unit.el.closest("section")
|
|
225
|
+
while (section !== null) {
|
|
226
|
+
const heading = section.firstElementChild
|
|
227
|
+
if (heading !== null && /^H[1-6]$/.test(heading.tagName))
|
|
228
|
+
heading.classList.add("search-keep")
|
|
229
|
+
section = section.parentElement !== null ? section.parentElement.closest("section") : null
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
/* hide the units not kept, plus the objects and tables which
|
|
234
|
+
ran entirely empty in the process */
|
|
235
|
+
units.forEach((unit) => {
|
|
236
|
+
if (!unit.el.classList.contains("search-keep"))
|
|
237
|
+
unit.el.classList.add("search-hide")
|
|
238
|
+
})
|
|
239
|
+
document.querySelectorAll("article section, article table").forEach((el) => {
|
|
240
|
+
if (el.querySelector(".search-keep") === null)
|
|
241
|
+
el.classList.add("search-hide")
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
/* highlight every matched word within the kept units */
|
|
245
|
+
const words = []
|
|
246
|
+
parts.forEach((part) => {
|
|
247
|
+
part.forEach((word) => {
|
|
248
|
+
if (!words.includes(word))
|
|
249
|
+
words.push(word)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
const regex = buildRegex(words, Array.from(variants))
|
|
253
|
+
units.forEach((unit) => {
|
|
254
|
+
if (unit.el.classList.contains("search-keep"))
|
|
255
|
+
highlight(unit.el, regex)
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* run the search debounced on every keystroke and let the "X"
|
|
260
|
+
clear the input field and the search results again */
|
|
261
|
+
let debounce = 0
|
|
262
|
+
input.addEventListener("input", () => {
|
|
263
|
+
window.clearTimeout(debounce)
|
|
264
|
+
debounce = window.setTimeout(search, 200)
|
|
265
|
+
})
|
|
266
|
+
clear.addEventListener("click", () => {
|
|
267
|
+
window.clearTimeout(debounce)
|
|
268
|
+
input.value = ""
|
|
269
|
+
unmark()
|
|
270
|
+
input.focus()
|
|
271
|
+
})
|
|
272
|
+
})()
|