@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.
Files changed (59) hide show
  1. package/README.md +199 -0
  2. package/dst/specbook-api.d.ts +47 -0
  3. package/dst/specbook-api.js +77 -0
  4. package/dst/specbook-cli.d.ts +2 -0
  5. package/dst/specbook-cli.js +156 -0
  6. package/dst/specbook-cmd-describe.d.ts +2 -0
  7. package/dst/specbook-cmd-describe.js +97 -0
  8. package/dst/specbook-cmd-edit.d.ts +9 -0
  9. package/dst/specbook-cmd-edit.js +69 -0
  10. package/dst/specbook-cmd-export.d.ts +9 -0
  11. package/dst/specbook-cmd-export.js +78 -0
  12. package/dst/specbook-cmd-import.d.ts +9 -0
  13. package/dst/specbook-cmd-import.js +73 -0
  14. package/dst/specbook-cmd-init.d.ts +7 -0
  15. package/dst/specbook-cmd-init.js +37 -0
  16. package/dst/specbook-cmd-lint.d.ts +15 -0
  17. package/dst/specbook-cmd-lint.js +46 -0
  18. package/dst/specbook-config.d.ts +6 -0
  19. package/dst/specbook-config.js +91 -0
  20. package/dst/specbook-diagnostic.d.ts +8 -0
  21. package/dst/specbook-diagnostic.js +23 -0
  22. package/dst/specbook-diagram.d.ts +9 -0
  23. package/dst/specbook-diagram.js +271 -0
  24. package/dst/specbook-export-ast.d.ts +4 -0
  25. package/dst/specbook-export-ast.js +36 -0
  26. package/dst/specbook-export-common.d.ts +33 -0
  27. package/dst/specbook-export-common.js +142 -0
  28. package/dst/specbook-export-html-search.js +272 -0
  29. package/dst/specbook-export-html.css +259 -0
  30. package/dst/specbook-export-html.d.ts +9 -0
  31. package/dst/specbook-export-html.js +452 -0
  32. package/dst/specbook-export-logo.svg +35 -0
  33. package/dst/specbook-export-md.d.ts +3 -0
  34. package/dst/specbook-export-md.js +74 -0
  35. package/dst/specbook-export-pdf.d.ts +7 -0
  36. package/dst/specbook-export-pdf.js +198 -0
  37. package/dst/specbook-link.d.ts +17 -0
  38. package/dst/specbook-link.js +113 -0
  39. package/dst/specbook-llm.d.ts +13 -0
  40. package/dst/specbook-llm.js +61 -0
  41. package/dst/specbook-mcp.d.ts +1 -0
  42. package/dst/specbook-mcp.js +163 -0
  43. package/dst/specbook-parse-common.d.ts +26 -0
  44. package/dst/specbook-parse-common.js +33 -0
  45. package/dst/specbook-parse-semantic.d.ts +5 -0
  46. package/dst/specbook-parse-semantic.js +254 -0
  47. package/dst/specbook-parse-syntax.d.ts +3 -0
  48. package/dst/specbook-parse-syntax.js +363 -0
  49. package/dst/specbook-parse-value.d.ts +19 -0
  50. package/dst/specbook-parse-value.js +123 -0
  51. package/dst/specbook-parse.d.ts +8 -0
  52. package/dst/specbook-parse.js +43 -0
  53. package/dst/specbook-struct-schema.d.ts +35 -0
  54. package/dst/specbook-struct-schema.js +47 -0
  55. package/dst/specbook-struct-spec.d.ts +31 -0
  56. package/dst/specbook-struct-spec.js +36 -0
  57. package/dst/specbook-theme.d.ts +16 -0
  58. package/dst/specbook-theme.js +38 -0
  59. 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
+ })()