Path:
strictdoc/export/html/_static/static_html_search.js
Lines:
877
Non-empty lines:
757
Non-empty lines covered with requirements:
757 / 757 (100.0%)
Functions:
0
Functions covered by requirements:
0 / 0 (0.0%)
1
/**2
* @relation(SDOC-SRS-155, scope=file)3
* @relation(SDOC-SRS-156, scope=file)4
*5
* Required DOM contract:6
* - #search7
* - #userinput8
* - #search_results9
* - #results_count10
* - #suggestions11
* - #results_navigation with #start, #previous, #next, #end12
* - meta[name="strictdoc-document-level"]13
* - meta[name="strictdoc-project-hash"]14
* - meta[name="strictdoc-search-index-timestamp"]15
* - meta[name="strictdoc-search-index-path"]16
*/17
18
(function() {
19
const strictDocSearch = window.StrictDoc?.search;
20
if (!strictDocSearch) {
21
throw new Error(
22
"static_html_search.js requires app_core.js to initialize StrictDoc.search."
23
);
24
}
25
26
// =========================================================================
27
// DOM and meta discovery
28
// =========================================================================
29
30
// Collect the DOM nodes that the static search UI depends on.
31
function collectRequiredDom() {
32
const selectorByRefKey = {
33
searchBox: "#search",
34
userinput: "#userinput",
35
searchResults: "#search_results",
36
resultsCount: "#results_count",
37
suggestions: "#suggestions",
38
navigationStart: "#results_navigation #start",
39
navigationPrevious: "#results_navigation #previous",
40
navigationNext: "#results_navigation #next",
41
navigationEnd: "#results_navigation #end",
42
};
43
44
const dom = Object.fromEntries(
45
Object.entries(selectorByRefKey).map(([key, selector]) => {
46
if (selector.startsWith("#") && !selector.includes(" ")) {
47
return [key, document.getElementById(selector.slice(1))];
48
}
49
return [key, document.querySelector(selector)];
50
})
51
);
52
53
const missingSelectors = Object.entries(dom)
54
.filter(([, element]) => !element)
55
.map(([key]) => selectorByRefKey[key]);
56
57
return {
58
dom,
59
missingSelectors
60
};
61
}
62
63
// Collect the meta tags that configure search rendering and index loading.
64
function collectRequiredMeta() {
65
const selectorByMetaKey = {
66
documentLevel: 'meta[name="strictdoc-document-level"]',
67
projectHash: 'meta[name="strictdoc-project-hash"]',
68
searchIndexTimestamp: 'meta[name="strictdoc-search-index-timestamp"]',
69
searchIndexPath: 'meta[name="strictdoc-search-index-path"]',
70
};
71
72
const meta = Object.fromEntries(
73
Object.entries(selectorByMetaKey).map(([key, selector]) => {
74
return [key, document.querySelector(selector)?.content];
75
})
76
);
77
78
const missingSelectors = Object.entries(meta)
79
.filter(([, content]) => !content)
80
.map(([key]) => selectorByMetaKey[key]);
81
82
return {
83
meta,
84
missingSelectors,
85
};
86
}
87
88
// =========================================================================
89
// Query parsing and result shaping
90
// =========================================================================
91
92
function escapeRegExp(text) {
93
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
94
}
95
96
// Highlight matched terms in result text before rendering the suggestion entry.
97
function highlightWord(text, word) {
98
let newStr = text.replace(new RegExp(escapeRegExp(word), "gi"), (match) => "<mark>" +
99
match + "</mark>");
100
return newStr;
101
}
102
103
// Intersect per-token result sets for AND-style queries.
104
function intersectSets(sets) {
105
if (sets.length === 0) return new Set();
106
107
let intersection = new Set(sets[0]);
108
109
for (const s of sets.slice(1)) {
110
intersection = new Set([...intersection].filter(x => s.has(x)));
111
}
112
113
return intersection;
114
}
115
116
// Parse the raw search text into a minimal query model used by the live search UI.
117
function parseSearchQuery(searchQuery) {
118
const regex = /"([^"]+)"|(\S+)/g;
119
const tokens = [];
120
let hasQuoted = false;
121
122
const matches = [...searchQuery.matchAll(regex)];
123
124
for (const match of matches) {
125
if (match[1]) {
126
// Quoted phrase → split into words
127
hasQuoted = true;
128
tokens.push(match[1].trim().split(/\s+/));
129
} else if (match[2]) {
130
// Single word
131
tokens.push([match[2]]);
132
}
133
}
134
135
if (hasQuoted) {
136
return {
137
mode: "AND",
138
terms: tokens.flat(),
139
};
140
}
141
142
return {
143
mode: "OR",
144
terms: tokens.flat(),
145
};
146
}
147
148
// Execute a token query by unioning all indexed token matches.
149
function executeOrQuery(parsedQuery, searchIndex) {
150
let uniqueResults = new Set();
151
for (const token of parsedQuery.terms) {
152
const tokenResults = searchIndex[token];
153
if (tokenResults) {
154
uniqueResults = new Set([...uniqueResults, ...tokenResults]);
155
}
156
}
157
return Array.from(uniqueResults);
158
}
159
160
// Execute a phrase query by intersecting the per-token index matches first.
161
function executeAndQuery(parsedQuery, searchIndex) {
162
const firstTerm = parsedQuery.terms[0];
163
const firstTermResults = searchIndex[firstTerm];
164
if (!firstTermResults || firstTermResults.length === 0) {
165
return [];
166
}
167
168
let uniqueResults = new Set(firstTermResults);
169
for (let i = 1; i < parsedQuery.terms.length; i++) {
170
const termResults = searchIndex[parsedQuery.terms[i]];
171
const termUniqueResults = new Set(termResults);
172
173
uniqueResults = intersectSets([uniqueResults, termUniqueResults]);
174
if (uniqueResults.size === 0) {
175
break;
176
}
177
}
178
179
return Array.from(uniqueResults);
180
}
181
182
// Refine AND-style results by verifying the combined phrase against node fields.
183
function refineAndQueryResults(results, parsedQuery, nodesByMid) {
184
const finalAndResults = [];
185
const finalUniqueResults = new Set();
186
const andQuery = parsedQuery.terms.join(" ");
187
188
for (const result of results) {
189
const node = nodesByMid[parseInt(result, 10)];
190
console.assert(!!node, "node must be defined for result: " + result);
191
192
Object.entries(node).forEach(([_, value]) => {
193
if (value === "") {
194
return;
195
}
196
197
if (!finalUniqueResults.has(result) && value.toLowerCase().includes(andQuery)) {
198
finalUniqueResults.add(result);
199
finalAndResults.push(result);
200
}
201
});
202
}
203
204
return {
205
results: finalAndResults,
206
highlightElements: [andQuery],
207
};
208
}
209
210
// Build the data needed by the results view: result ids plus highlight terms.
211
function buildSearchViewModel(parsedQuery, searchQuery, searchIndex, nodesByMid) {
212
// Keep the existing live-input behavior for search text that still contains
213
// a quote character but has not been parsed as a quoted phrase query.
214
if (parsedQuery.mode === "OR" && searchQuery.includes('"')) {
215
return {
216
results: [],
217
highlightElements: parsedQuery.terms,
218
};
219
}
220
221
// ** "AND"
222
// Quoted phrase queries first intersect token matches, then verify
223
// that the full phrase exists in the matched node fields.
224
if (parsedQuery.mode === "AND") {
225
const results = executeAndQuery(parsedQuery, searchIndex);
226
return refineAndQueryResults(results, parsedQuery, nodesByMid);
227
}
228
229
// ** "OR"
230
// Unquoted queries use the default token-based OR search path.
231
const results = executeOrQuery(parsedQuery, searchIndex);
232
return {
233
results,
234
highlightElements: parsedQuery.terms,
235
};
236
}
237
238
// =========================================================================
239
// Live search UI
240
// =========================================================================
241
242
// Render and paginate the live search result list.
243
class SearchResultsView {
244
static PAGE_SIZE = 5;
245
246
constructor(dom, { userinput, searchData, documentLevel }) {
247
this.userinput = userinput;
248
this.searchData = searchData;
249
this.documentLevel = documentLevel;
250
console.assert(
251
!isNaN(this.documentLevel),
252
"SearchResultsView: documentLevel must be a valid number."
253
);
254
255
this.searchBox = dom.searchBox;
256
this.searchResults = dom.searchResults;
257
this.resultsCount = dom.resultsCount;
258
this.suggestions = dom.suggestions;
259
260
this.navigationStart = dom.navigationStart;
261
this.navigationPrevious = dom.navigationPrevious;
262
this.navigationNext = dom.navigationNext;
263
this.navigationEnd = dom.navigationEnd;
264
265
this.navigationStart.addEventListener("click", () => this.displayPage(
266
1), true);
267
this.navigationPrevious.addEventListener("click", () => this
268
.displayPage(this.currentPage - 1), true);
269
this.navigationNext.addEventListener("click", () => this.displayPage(
270
this.currentPage + 1), true);
271
this.navigationEnd.addEventListener("click", () => this.displayPage(
272
Math.ceil(this.results.length / SearchResultsView.PAGE_SIZE)),
273
true);
274
275
this.selectedIndex = 0;
276
this.results = null;
277
this.highlightElements = null;
278
this.currentPage = 1;
279
this.suggestions.addEventListener("click", this.acceptSuggestion, true);
280
document.addEventListener("keydown", (event) => this.handleEscape(
281
event), true);
282
}
283
284
handleEscape(event) {
285
if (event.key === "Escape") {
286
this.hideResults();
287
// On Esc, remove focus from the input field.
288
// Otherwise the field remains focused and a subsequent click won't fire a 'focus' event,
289
// so the search won't restart. Blurring ensures the next refocus re‑triggers search with
290
// the existing text.
291
if (document.activeElement === this.userinput) {
292
this.userinput.blur();
293
}
294
}
295
}
296
297
hideResults() {
298
this.searchBox.removeAttribute("active");
299
this.resultsCount.innerHTML = "";
300
this.suggestions.replaceChildren();
301
this.selectedIndex = 0;
302
}
303
304
populateResults(results, highlightElements) {
305
const resultsLength = results.length;
306
307
if (resultsLength == 0) {
308
this.suggestions.replaceChildren();
309
this.resultsCount.innerHTML = `No results.`;
310
return;
311
}
312
313
this.results = results;
314
this.highlightElements = highlightElements;
315
316
this.displayPage(1);
317
318
this.searchBox.setAttribute("active", "");
319
}
320
321
displayPage(page) {
322
// Ignore requests that point outside the available pagination range.
323
if (page < 1 || page > Math.ceil(this.results.length /
324
SearchResultsView.PAGE_SIZE)) {
325
return;
326
}
327
328
// Slice the full result list down to the subset rendered on this page.
329
const pageResults = this.results.slice(
330
(page - 1) * SearchResultsView.PAGE_SIZE,
331
page * SearchResultsView.PAGE_SIZE
332
);
333
334
// Persist the current page so the navigation buttons can move relative to it.
335
this.currentPage = page;
336
337
// Reuse already rendered result containers where possible.
338
const children = this.suggestions.childNodes;
339
340
// Render each result entry for the requested page.
341
for (let i = 0; i < pageResults.length; i++) {
342
let nodeId = pageResults[i];
343
let resultElement = children[i];
344
345
// Create a result container only when the current page needs more rows
346
// than were already rendered for the previous page.
347
if (!resultElement) {
348
resultElement = document.createElement("div");
349
this.suggestions.appendChild(resultElement);
350
}
351
352
this.renderResultElement(resultElement, nodeId);
353
}
354
355
// Remove leftover DOM rows when the new page has fewer results than the previous one.
356
while (children.length > pageResults.length) {
357
this.suggestions.removeChild(this.suggestions.lastChild);
358
}
359
360
// Update pagination controls based on the current page position.
361
this.updatePaginationState(page);
362
363
// Refresh the result counter text for the currently visible range.
364
this.updateResultsCount(page);
365
366
// Reset keyboard selection to the first visible result on each page change.
367
this._selectResult(0);
368
}
369
370
updatePaginationState(page) {
371
if (this.results.length > SearchResultsView.PAGE_SIZE) {
372
if (page < 2) {
373
this.navigationStart.setAttribute("disabled", "");
374
this.navigationPrevious.setAttribute("disabled", "");
375
} else {
376
this.navigationStart.removeAttribute("disabled");
377
this.navigationPrevious.removeAttribute("disabled");
378
}
379
if (page >= Math.ceil(this.results.length / SearchResultsView
380
.PAGE_SIZE)) {
381
this.navigationNext.setAttribute("disabled", "");
382
this.navigationEnd.setAttribute("disabled", "");
383
} else {
384
this.navigationNext.removeAttribute("disabled");
385
this.navigationEnd.removeAttribute("disabled");
386
}
387
} else {
388
this.navigationStart.setAttribute("disabled", "");
389
this.navigationPrevious.setAttribute("disabled", "");
390
this.navigationNext.setAttribute("disabled", "");
391
this.navigationEnd.setAttribute("disabled", "");
392
}
393
}
394
395
updateResultsCount(page) {
396
// Compute the human-readable result range shown above the list.
397
const rangeStart = (page - 1) * SearchResultsView.PAGE_SIZE + 1;
398
const rangeEnd = Math.min(page * SearchResultsView.PAGE_SIZE, this
399
.results.length);
400
401
this.resultsCount.innerHTML = `\
402
Results: <b>${rangeStart}–${rangeEnd}</b> from ${this.results.length}
403
`;
404
}
405
406
renderResultElement(resultElement, nodeId) {
407
// Resolve the indexed node data behind the current search result id.
408
const node = this.searchData.nodesByMid[parseInt(nodeId, 10)];
409
console.assert(!!node, "node must be defined for result: " +
410
nodeId);
411
412
// Build the HTML fragment with node fields, applying term highlighting
413
// to every visible field except the navigation link field.
414
let nodeFieldsHtml = "";
415
Object.entries(node).forEach(([key, value]) => {
416
if (value === "" || key === "_LINK") {
417
return;
418
}
419
420
for (let i = 0; i < this.highlightElements.length; i++) {
421
const highlightElement = this.highlightElements[i];
422
value = highlightWord(value, highlightElement);
423
}
424
425
nodeFieldsHtml = nodeFieldsHtml +
426
`<div class="static_search-result-node-field"><span class="static_search-result-node-field-key">${key}:</span> ${value}</div>`;
427
});
428
429
const pathPrefix = (this.documentLevel === 0) ? "" : "../".repeat(
430
this.documentLevel);
431
432
// Render the visible result entry together with the deep link to the node.
433
const nodeLink = node["_LINK"];
434
435
resultElement.innerHTML = `<div class="static_search-result-node">
436
${nodeFieldsHtml}
437
<div class="static_search-result-node-link">438
<a href="${pathPrefix}index.html?a=${nodeLink}">Go to node →</a>
439
</div>440
</div>441
`;
442
}
443
444
selectNextResult() {
445
if (this.selectedIndex < (this.suggestions.childNodes.length - 1)) {
446
this._selectResult(this.selectedIndex + 1);
447
}
448
}
449
450
selectPreviousResult() {
451
if (this.selectedIndex > 0) {
452
this._selectResult(this.selectedIndex - 1);
453
}
454
}
455
456
acceptSuggestion(event) {
457
// Nothing for now.
458
}
459
460
_selectResult(index) {
461
let node = this.suggestions.childNodes[this.selectedIndex];
462
node && (node.style.backgroundColor = "");
463
464
this.selectedIndex = index;
465
466
node = this.suggestions.childNodes[index];
467
node && (node.style.backgroundColor = "rgba(0, 0, 255, 0.1)");
468
}
469
}
470
471
// Orchestrate user input events and translate them into search updates.
472
class SearchInputController {
473
constructor({ userinput, searchData, searchResultsView }) {
474
this.userinput = userinput;
475
this.searchData = searchData;
476
this.searchResultsView = searchResultsView;
477
this.previousInputValue = "";
478
}
479
480
attachEventListeners() {
481
this.userinput.addEventListener("input", () => this.handleInput(), true);
482
this.userinput.addEventListener("keyup", (event) => this.handleKeyUp(
483
event), true);
484
this.userinput.addEventListener("keydown", (event) => this.handleKeyDown(
485
event), true);
486
this.userinput.addEventListener("focus", (event) => this.handleFocus(
487
event), true);
488
}
489
490
handleInput() {
491
// Wait until the search index and node lookup map are loaded.
492
// TODO: Replace this per-input guard with an explicit "search index ready"
493
// state and a visible UI signal for the user when live search is not ready yet.
494
if (!this.searchData.index || !this.searchData.nodesByMid) {
495
console.log(
496
"Search: Cannot perform search: Search index is not available yet.")
497
return;
498
}
499
500
// Reset the live search UI when the input becomes empty.
501
if (this.userinput.value === "") {
502
this.previousInputValue = "";
503
this.searchResultsView.hideResults();
504
return;
505
}
506
507
// Preserve the current live-input behavior for quote editing.
508
// FIXME
509
// If the previous input step already auto-inserted "" and the user has
510
// edited the field back down to a single quote, treat that as deleting
511
// the auto-completed quote pair and clear the field completely.
512
if (this.previousInputValue === '""' && this.userinput.value === '"') {
513
this.userinput.value = ""
514
// If the user has typed a single quote into an otherwise empty field,
515
// auto-insert the matching closing quote.
516
} else if (this.userinput.value === '"') {
517
const quote = this.userinput.value;
518
this.userinput.value = quote + quote;
519
520
// Place the cursor between the two quotes
521
// so the next typed characters become the quoted phrase.
522
this.userinput.setSelectionRange(1, 1);
523
}
524
525
// Persist the latest input value for the next edit step.
526
this.previousInputValue = this.userinput.value;
527
528
// Build the normalized search query from the current input.
529
const searchQuery = this.userinput.value.toLowerCase();
530
531
// Parse the query and build the view model shown in the live results list.
532
const parsedQuery = parseSearchQuery(searchQuery);
533
534
const searchViewModel = buildSearchViewModel(
535
parsedQuery,
536
searchQuery,
537
this.searchData.index,
538
this.searchData.nodesByMid
539
);
540
this.searchResultsView.populateResults(
541
searchViewModel.results,
542
searchViewModel.highlightElements
543
);
544
}
545
546
handleKeyDown(event) {
547
if (event && event.key === "Enter") {
548
event.preventDefault && event.preventDefault();
549
const searchQuery = this.userinput.value || "";
550
const encodedQuery = encodeURIComponent(searchQuery);
551
window.location.assign(`/search?q=${encodedQuery}`);
552
}
553
}
554
555
handleFocus(event) {
556
// FIXME: Nothing for now.
557
}
558
559
handleKeyUp(event) {
560
if (event) {
561
const key = event.key;
562
if (key === "ArrowUp") {
563
this.searchResultsView.selectPreviousResult();
564
event.preventDefault && event.preventDefault();
565
return;
566
}
567
if (key === "ArrowDown") {
568
this.searchResultsView.selectNextResult();
569
event.preventDefault && event.preventDefault();
570
return;
571
}
572
}
573
}
574
}
575
576
// =========================================================================
577
// Search index initialization
578
// =========================================================================
579
580
// Open the IndexedDB database that caches the generated search index.
581
function openSearchIndexDB(name, version = 1) {
582
return new Promise((resolve, reject) => {
583
const request = indexedDB.open(name, version);
584
request.onupgradeneeded = (e) => {
585
const db = e.target.result;
586
db.createObjectStore("indexes", {
587
keyPath: "name"
588
});
589
};
590
request.onsuccess = () => resolve(request.result);
591
request.onerror = () => reject(request.error);
592
});
593
}
594
595
// Drop the cached search index when it becomes stale.
596
function deleteSearchIndexDB(name) {
597
return new Promise((resolve, reject) => {
598
const delReq = indexedDB.deleteDatabase(name);
599
delReq.onsuccess = () => resolve();
600
delReq.onerror = () => reject(delReq.error);
601
});
602
}
603
604
// Read a cached value from the search index store.
605
function getFromSearchIndexStore(db, storeName, key) {
606
return new Promise((resolve, reject) => {
607
const tx = db.transaction(storeName, "readonly");
608
const store = tx.objectStore(storeName);
609
const req = store.get(key);
610
req.onsuccess = () => resolve(req.result);
611
req.onerror = () => reject(req.error);
612
});
613
}
614
615
// Persist the current search index payload to the cache store.
616
function saveToSearchIndexStore(db, storeName, items) {
617
return new Promise((resolve, reject) => {
618
const tx = db.transaction(storeName, "readwrite");
619
const store = tx.objectStore(storeName);
620
items.forEach((item) => store.put(item));
621
tx.oncomplete = () => resolve();
622
tx.onerror = () => reject(tx.error);
623
});
624
}
625
626
// Load the generated search index JavaScript file into the page.
627
function loadScript(url) {
628
return new Promise((resolve, reject) => {
629
const script = document.createElement("script");
630
script.src = url;
631
script.onload = () => {
632
script.remove();
633
resolve();
634
};
635
script.onerror = () => reject(new Error(
636
`Failed to load script ${url}`));
637
document.head.appendChild(script);
638
});
639
}
640
641
// Load the generated search index, optionally bypassing the browser cache.
642
async function loadSearchIndexFromScript(pathToSearchIndex, cacheBusting) {
643
const searchIndexURL = new URL(pathToSearchIndex, window.location.href);
644
if (cacheBusting) {
645
searchIndexURL.searchParams.set("_refresh", Date.now().toString());
646
}
647
console.time("Search: LOAD_JS_INDEX");
648
await loadScript(searchIndexURL.href);
649
console.timeEnd("Search: LOAD_JS_INDEX");
650
console.log("Search: JS search index loaded successfully.");
651
}
652
653
// Save the in-memory search index into IndexedDB for faster reloads.
654
async function saveCurrentSearchIndexToDB({
655
dbName,
656
dbVersion,
657
timestampMeta,
658
searchData,
659
}) {
660
console.time("Search: SAVE_DB_INDEX");
661
const db = await openSearchIndexDB(dbName, dbVersion);
662
await saveToSearchIndexStore(db, "indexes", [{
663
name: "STRICTDOC_SEARCH_INDEX",
664
value: searchData.index
665
}, {
666
name: "STRICTDOC_SEARCH_NODES_BY_MID",
667
value: searchData.nodesByMid
668
}, {
669
name: "TIMESTAMP",
670
value: timestampMeta
671
}, ]);
672
db.close();
673
console.timeEnd("Search: SAVE_DB_INDEX");
674
}
675
676
// Refresh the cached search index after relevant Turbo stream updates.
677
function installSearchIndexRefreshHandler({
678
pathToSearchIndex,
679
dbName,
680
dbVersion,
681
timestampMeta,
682
searchData,
683
}) {
684
let refreshScheduled = false;
685
let refreshInProgress = false;
686
let refreshQueued = false;
687
688
const refreshSearchIndexFromServer = async () => {
689
if (refreshInProgress) {
690
refreshQueued = true;
691
return;
692
}
693
694
refreshInProgress = true;
695
try {
696
await loadSearchIndexFromScript(pathToSearchIndex, true);
697
await saveCurrentSearchIndexToDB({
698
dbName,
699
dbVersion,
700
timestampMeta,
701
searchData,
702
});
703
} catch (refreshError) {
704
console.error(
705
"Search: Failed to refresh search index after Turbo stream update:",
706
refreshError
707
);
708
} finally {
709
refreshInProgress = false;
710
if (refreshQueued) {
711
refreshQueued = false;
712
void refreshSearchIndexFromServer();
713
}
714
}
715
};
716
717
const scheduleRefreshSearchIndexFromServer = () => {
718
if (refreshScheduled) {
719
return;
720
}
721
refreshScheduled = true;
722
window.setTimeout(() => {
723
refreshScheduled = false;
724
void refreshSearchIndexFromServer();
725
}, 0);
726
};
727
728
document.addEventListener("turbo:before-stream-render", (event) => {
729
const streamElement = event.target;
730
if (!(streamElement instanceof HTMLElement)) {
731
return;
732
}
733
if (streamElement.tagName !== "TURBO-STREAM") {
734
return;
735
}
736
const target = streamElement.getAttribute("target");
737
// FIXME: HACK: For now we refresh the entire index on any update to the TOC.
738
// Ideally we should get a dedicated stream update for the search index
739
// and only refresh on that. But this will do for now.
740
if (target === "frame-toc") {
741
scheduleRefreshSearchIndexFromServer();
742
}
743
}, true);
744
}
745
746
// Initialize the search index from cache or from the generated script.
747
async function initializeSearchIndex({
748
projectHash,
749
pathToSearchIndex,
750
timestampMeta,
751
searchData,
752
}) {
753
const DB_VERSION = 1;
754
const dbName = "strictdoc_search_index_" + projectHash;
755
756
installSearchIndexRefreshHandler({
757
pathToSearchIndex,
758
dbName,
759
dbVersion: DB_VERSION,
760
timestampMeta,
761
searchData,
762
});
763
764
try {
765
console.log("Search: LOAD_DB_INDEX: Start");
766
const db = await openSearchIndexDB(dbName, DB_VERSION);
767
const tsEntry = await getFromSearchIndexStore(db, "indexes", "TIMESTAMP");
768
769
if (tsEntry && tsEntry.value === timestampMeta) {
770
console.time("Search: LOAD_DB_INDEX");
771
const lunrEntry = await getFromSearchIndexStore(
772
db,
773
"indexes",
774
"STRICTDOC_SEARCH_INDEX"
775
);
776
const nodesEntry = await getFromSearchIndexStore(
777
db,
778
"indexes",
779
"STRICTDOC_SEARCH_NODES_BY_MID"
780
);
781
console.timeEnd("Search: LOAD_DB_INDEX");
782
783
if (lunrEntry && nodesEntry) {
784
searchData.index = lunrEntry.value;
785
searchData.nodesByMid = nodesEntry.value;
786
db.close();
787
return;
788
}
789
}
790
791
db.close();
792
await deleteSearchIndexDB(dbName);
793
794
try {
795
await loadSearchIndexFromScript(pathToSearchIndex, false);
796
} catch (e) {
797
console.error("Search: Failed to load JS search index script:",
798
e);
799
return;
800
}
801
802
await saveCurrentSearchIndexToDB({
803
dbName,
804
dbVersion: DB_VERSION,
805
timestampMeta,
806
searchData,
807
});
808
809
} catch (err) {
810
console.error("Search: Error loading search index:", err);
811
812
try {
813
await loadSearchIndexFromScript(pathToSearchIndex, false);
814
console.log("Search: Script loaded without IndexedDB fallback");
815
} catch (e) {
816
console.error("Search: Failed to load search index script:", e);
817
}
818
}
819
}
820
821
// =========================================================================
822
// App initialization
823
// =========================================================================
824
825
// Initialize the UI controllers after all required DOM and meta are present.
826
const { dom, missingSelectors } = collectRequiredDom();
827
const { meta, missingSelectors: missingMetaSelectors } = collectRequiredMeta();
828
829
if (missingSelectors.length > 0) {
830
console.assert(
831
false,
832
`Search: initialization skipped because required DOM elements are missing: ${missingSelectors.join(", ")}`
833
);
834
return;
835
}
836
837
if (missingMetaSelectors.length > 0) {
838
console.assert(
839
false,
840
`Search: initialization skipped because required meta tags are missing: ${missingMetaSelectors.join(", ")}`
841
);
842
return;
843
}
844
845
const { userinput } = dom;
846
const documentLevel = parseInt(meta.documentLevel, 10);
847
848
const searchResultsView = new SearchResultsView(dom, {
849
userinput,
850
searchData: strictDocSearch,
851
documentLevel,
852
});
853
const searchInputController = new SearchInputController({
854
userinput,
855
searchData: strictDocSearch,
856
searchResultsView,
857
});
858
searchInputController.attachEventListeners();
859
860
// Defer search index initialization until the page and generated assets are ready.
861
window.addEventListener("load", async () => {
862
const timestampMeta = meta.searchIndexTimestamp;
863
const projectHash = meta.projectHash;
864
const pathToSearchIndex = meta.searchIndexPath;
865
866
if (!projectHash || !pathToSearchIndex || !timestampMeta) {
867
console.error("Search: Missing required meta tags!");
868
return;
869
}
870
await initializeSearchIndex({
871
projectHash,
872
pathToSearchIndex,
873
timestampMeta,
874
searchData: strictDocSearch,
875
});
876
});
877
})();