Path:
strictdoc/export/html/_static/static_html_search.js
Lines:
913
Non-empty lines:
791
Non-empty lines covered with requirements:
791 / 791 (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, {
247
userinput,
248
searchData,
249
documentLevel
250
}) {
251
this.userinput = userinput;
252
this.searchData = searchData;
253
this.documentLevel = documentLevel;
254
console.assert(
255
!isNaN(this.documentLevel),
256
"SearchResultsView: documentLevel must be a valid number."
257
);
258
259
this.searchBox = dom.searchBox;
260
this.searchResults = dom.searchResults;
261
this.resultsCount = dom.resultsCount;
262
this.suggestions = dom.suggestions;
263
264
this.navigationStart = dom.navigationStart;
265
this.navigationPrevious = dom.navigationPrevious;
266
this.navigationNext = dom.navigationNext;
267
this.navigationEnd = dom.navigationEnd;
268
269
this.navigationStart.addEventListener("click", () => this.displayPage(
270
1), true);
271
this.navigationPrevious.addEventListener("click", () => this
272
.displayPage(this.currentPage - 1), true);
273
this.navigationNext.addEventListener("click", () => this.displayPage(
274
this.currentPage + 1), true);
275
this.navigationEnd.addEventListener("click", () => this.displayPage(
276
Math.ceil(this.results.length / SearchResultsView.PAGE_SIZE)),
277
true);
278
279
this.selectedIndex = 0;
280
this.results = null;
281
this.highlightElements = null;
282
this.currentPage = 1;
283
this.suggestions.addEventListener("click", this.acceptSuggestion, true);
284
document.addEventListener("keydown", (event) => this.handleEscape(
285
event), true);
286
}
287
288
handleEscape(event) {
289
if (event.key === "Escape") {
290
this.hideResults();
291
// On Esc, remove focus from the input field.
292
// Otherwise the field remains focused and a subsequent click won't fire a 'focus' event,
293
// so the search won't restart. Blurring ensures the next refocus re‑triggers search with
294
// the existing text.
295
if (document.activeElement === this.userinput) {
296
this.userinput.blur();
297
}
298
}
299
}
300
301
hideResults() {
302
this.searchBox.removeAttribute("active");
303
this.resultsCount.innerHTML = "";
304
this.suggestions.replaceChildren();
305
this.selectedIndex = 0;
306
}
307
308
populateResults(results, highlightElements) {
309
const resultsLength = results.length;
310
311
if (resultsLength == 0) {
312
this.suggestions.replaceChildren();
313
this.resultsCount.innerHTML = `No results.`;
314
return;
315
}
316
317
this.results = results;
318
this.highlightElements = highlightElements;
319
320
this.displayPage(1);
321
322
this.searchBox.setAttribute("active", "");
323
}
324
325
displayPage(page) {
326
// Ignore requests that point outside the available pagination range.
327
if (page < 1 || page > Math.ceil(this.results.length /
328
SearchResultsView.PAGE_SIZE)) {
329
return;
330
}
331
332
// Slice the full result list down to the subset rendered on this page.
333
const pageResults = this.results.slice(
334
(page - 1) * SearchResultsView.PAGE_SIZE,
335
page * SearchResultsView.PAGE_SIZE
336
);
337
338
// Persist the current page so the navigation buttons can move relative to it.
339
this.currentPage = page;
340
341
// Reuse already rendered result containers where possible.
342
const children = this.suggestions.childNodes;
343
344
// Render each result entry for the requested page.
345
for (let i = 0; i < pageResults.length; i++) {
346
let nodeId = pageResults[i];
347
let resultElement = children[i];
348
349
// Create a result container only when the current page needs more rows
350
// than were already rendered for the previous page.
351
if (!resultElement) {
352
resultElement = document.createElement("div");
353
this.suggestions.appendChild(resultElement);
354
}
355
356
this.renderResultElement(resultElement, nodeId);
357
}
358
359
// Remove leftover DOM rows when the new page has fewer results than the previous one.
360
while (children.length > pageResults.length) {
361
this.suggestions.removeChild(this.suggestions.lastChild);
362
}
363
364
// Update pagination controls based on the current page position.
365
this.updatePaginationState(page);
366
367
// Refresh the result counter text for the currently visible range.
368
this.updateResultsCount(page);
369
370
// Reset keyboard selection to the first visible result on each page change.
371
this._selectResult(0);
372
}
373
374
updatePaginationState(page) {
375
if (this.results.length > SearchResultsView.PAGE_SIZE) {
376
if (page < 2) {
377
this.navigationStart.setAttribute("disabled", "");
378
this.navigationPrevious.setAttribute("disabled", "");
379
} else {
380
this.navigationStart.removeAttribute("disabled");
381
this.navigationPrevious.removeAttribute("disabled");
382
}
383
if (page >= Math.ceil(this.results.length / SearchResultsView
384
.PAGE_SIZE)) {
385
this.navigationNext.setAttribute("disabled", "");
386
this.navigationEnd.setAttribute("disabled", "");
387
} else {
388
this.navigationNext.removeAttribute("disabled");
389
this.navigationEnd.removeAttribute("disabled");
390
}
391
} else {
392
this.navigationStart.setAttribute("disabled", "");
393
this.navigationPrevious.setAttribute("disabled", "");
394
this.navigationNext.setAttribute("disabled", "");
395
this.navigationEnd.setAttribute("disabled", "");
396
}
397
}
398
399
updateResultsCount(page) {
400
// Compute the human-readable result range shown above the list.
401
const rangeStart = (page - 1) * SearchResultsView.PAGE_SIZE + 1;
402
const rangeEnd = Math.min(page * SearchResultsView.PAGE_SIZE, this
403
.results.length);
404
405
this.resultsCount.innerHTML = `\
406
Results: <b>${rangeStart}–${rangeEnd}</b> from ${this.results.length}
407
`;
408
}
409
410
renderResultElement(resultElement, nodeId) {
411
// Resolve the indexed node data behind the current search result id.
412
const node = this.searchData.nodesByMid[parseInt(nodeId, 10)];
413
console.assert(!!node, "node must be defined for result: " +
414
nodeId);
415
416
// Build the HTML fragment with node fields, applying term highlighting
417
// to every visible field except the navigation link field.
418
let nodeFieldsHtml = "";
419
Object.entries(node).forEach(([key, value]) => {
420
if (value === "" || key === "_LINK") {
421
return;
422
}
423
424
for (let i = 0; i < this.highlightElements.length; i++) {
425
const highlightElement = this.highlightElements[i];
426
value = highlightWord(value, highlightElement);
427
}
428
429
nodeFieldsHtml = nodeFieldsHtml +
430
`<div class="static_search-result-node-field"><span class="static_search-result-node-field-key">${key}:</span> ${value}</div>`;
431
});
432
433
const pathPrefix = (this.documentLevel === 0) ? "" : "../".repeat(
434
this.documentLevel);
435
436
// Render the visible result entry together with the deep link to the node.
437
const nodeLink = node["_LINK"];
438
439
resultElement.innerHTML = `<div class="static_search-result-node">
440
${nodeFieldsHtml}
441
<div class="static_search-result-node-link">442
<a href="${pathPrefix}index.html?a=${nodeLink}">Go to node →</a>
443
</div>444
</div>445
`;
446
}
447
448
selectNextResult() {
449
if (this.selectedIndex < (this.suggestions.childNodes.length - 1)) {
450
this._selectResult(this.selectedIndex + 1);
451
}
452
}
453
454
selectPreviousResult() {
455
if (this.selectedIndex > 0) {
456
this._selectResult(this.selectedIndex - 1);
457
}
458
}
459
460
acceptSuggestion(event) {
461
// Nothing for now.
462
}
463
464
_selectResult(index) {
465
let node = this.suggestions.childNodes[this.selectedIndex];
466
node && (node.style.backgroundColor = "");
467
468
this.selectedIndex = index;
469
470
node = this.suggestions.childNodes[index];
471
node && (node.style.backgroundColor = "rgba(0, 0, 255, 0.1)");
472
}
473
}
474
475
// Orchestrate user input events and translate them into search updates.
476
class SearchInputController {
477
constructor({
478
userinput,
479
searchData,
480
searchResultsView
481
}) {
482
this.userinput = userinput;
483
this.searchData = searchData;
484
this.searchResultsView = searchResultsView;
485
this.previousInputValue = "";
486
}
487
488
attachEventListeners() {
489
this.userinput.addEventListener("input", () => this.handleInput(), true);
490
this.userinput.addEventListener("keyup", (event) => this.handleKeyUp(
491
event), true);
492
this.userinput.addEventListener("keydown", (event) => this.handleKeyDown(
493
event), true);
494
this.userinput.addEventListener("focus", (event) => this.handleFocus(
495
event), true);
496
}
497
498
handleInput() {
499
// Wait until the search index and node lookup map are loaded.
500
// TODO: Replace this per-input guard with an explicit "search index ready"
501
// state and a visible UI signal for the user when live search is not ready yet.
502
if (!this.searchData.index || !this.searchData.nodesByMid) {
503
console.log(
504
"Search: Cannot perform search: Search index is not available yet.")
505
return;
506
}
507
508
// Reset the live search UI when the input becomes empty.
509
if (this.userinput.value === "") {
510
this.previousInputValue = "";
511
this.searchResultsView.hideResults();
512
return;
513
}
514
515
// Preserve the current live-input behavior for quote editing.
516
// FIXME
517
// If the previous input step already auto-inserted "" and the user has
518
// edited the field back down to a single quote, treat that as deleting
519
// the auto-completed quote pair and clear the field completely.
520
if (this.previousInputValue === '""' && this.userinput.value === '"') {
521
this.userinput.value = ""
522
// If the user has typed a single quote into an otherwise empty field,
523
// auto-insert the matching closing quote.
524
} else if (this.userinput.value === '"') {
525
const quote = this.userinput.value;
526
this.userinput.value = quote + quote;
527
528
// Place the cursor between the two quotes
529
// so the next typed characters become the quoted phrase.
530
this.userinput.setSelectionRange(1, 1);
531
}
532
533
// Persist the latest input value for the next edit step.
534
this.previousInputValue = this.userinput.value;
535
536
// Build the normalized search query from the current input.
537
const searchQuery = this.userinput.value.toLowerCase();
538
539
// Parse the query and build the view model shown in the live results list.
540
const parsedQuery = parseSearchQuery(searchQuery);
541
542
const searchViewModel = buildSearchViewModel(
543
parsedQuery,
544
searchQuery,
545
this.searchData.index,
546
this.searchData.nodesByMid
547
);
548
this.searchResultsView.populateResults(
549
searchViewModel.results,
550
searchViewModel.highlightElements
551
);
552
}
553
554
handleKeyDown(event) {
555
if (event && event.key === "Enter") {
556
event.preventDefault && event.preventDefault();
557
const searchQuery = this.userinput.value || "";
558
const encodedQuery = encodeURIComponent(searchQuery);
559
window.location.assign(`/search?q=${encodedQuery}`);
560
}
561
}
562
563
handleFocus(event) {
564
// FIXME: Nothing for now.
565
}
566
567
handleKeyUp(event) {
568
if (event) {
569
const key = event.key;
570
if (key === "ArrowUp") {
571
this.searchResultsView.selectPreviousResult();
572
event.preventDefault && event.preventDefault();
573
return;
574
}
575
if (key === "ArrowDown") {
576
this.searchResultsView.selectNextResult();
577
event.preventDefault && event.preventDefault();
578
return;
579
}
580
}
581
}
582
}
583
584
// =========================================================================
585
// Search index initialization
586
// =========================================================================
587
588
function setSearchIndexReady(isReady) {
589
strictDocSearch.isReady = isReady;
590
if (isReady) {
591
window.StrictDoc.bus.emit(
592
window.StrictDoc.events.STATIC_HTML_SEARCH_READY
593
);
594
}
595
}
596
597
// Open the IndexedDB database that caches the generated search index.
598
function openSearchIndexDB(name, version = 1) {
599
return new Promise((resolve, reject) => {
600
const request = indexedDB.open(name, version);
601
request.onupgradeneeded = (e) => {
602
const db = e.target.result;
603
db.createObjectStore("indexes", {
604
keyPath: "name"
605
});
606
};
607
request.onsuccess = () => resolve(request.result);
608
request.onerror = () => reject(request.error);
609
});
610
}
611
612
// Drop the cached search index when it becomes stale.
613
function deleteSearchIndexDB(name) {
614
return new Promise((resolve, reject) => {
615
const delReq = indexedDB.deleteDatabase(name);
616
delReq.onsuccess = () => resolve();
617
delReq.onerror = () => reject(delReq.error);
618
});
619
}
620
621
// Read a cached value from the search index store.
622
function getFromSearchIndexStore(db, storeName, key) {
623
return new Promise((resolve, reject) => {
624
const tx = db.transaction(storeName, "readonly");
625
const store = tx.objectStore(storeName);
626
const req = store.get(key);
627
req.onsuccess = () => resolve(req.result);
628
req.onerror = () => reject(req.error);
629
});
630
}
631
632
// Persist the current search index payload to the cache store.
633
function saveToSearchIndexStore(db, storeName, items) {
634
return new Promise((resolve, reject) => {
635
const tx = db.transaction(storeName, "readwrite");
636
const store = tx.objectStore(storeName);
637
items.forEach((item) => store.put(item));
638
tx.oncomplete = () => resolve();
639
tx.onerror = () => reject(tx.error);
640
});
641
}
642
643
// Load the generated search index JavaScript file into the page.
644
function loadScript(url) {
645
return new Promise((resolve, reject) => {
646
const script = document.createElement("script");
647
script.src = url;
648
script.onload = () => {
649
script.remove();
650
resolve();
651
};
652
script.onerror = () => reject(new Error(
653
`Failed to load script ${url}`));
654
document.head.appendChild(script);
655
});
656
}
657
658
// Load the generated search index, optionally bypassing the browser cache.
659
async function loadSearchIndexFromScript(pathToSearchIndex, cacheBusting) {
660
const searchIndexURL = new URL(pathToSearchIndex, window.location.href);
661
if (cacheBusting) {
662
searchIndexURL.searchParams.set("_refresh", Date.now().toString());
663
}
664
console.time("Search: LOAD_JS_INDEX");
665
await loadScript(searchIndexURL.href);
666
console.timeEnd("Search: LOAD_JS_INDEX");
667
console.log("Search: JS search index loaded successfully.");
668
}
669
670
// Save the in-memory search index into IndexedDB for faster reloads.
671
async function saveCurrentSearchIndexToDB({
672
dbName,
673
dbVersion,
674
timestampMeta,
675
searchData,
676
}) {
677
console.time("Search: SAVE_DB_INDEX");
678
const db = await openSearchIndexDB(dbName, dbVersion);
679
await saveToSearchIndexStore(db, "indexes", [{
680
name: "STRICTDOC_SEARCH_INDEX",
681
value: searchData.index
682
}, {
683
name: "STRICTDOC_SEARCH_NODES_BY_MID",
684
value: searchData.nodesByMid
685
}, {
686
name: "TIMESTAMP",
687
value: timestampMeta
688
}, ]);
689
db.close();
690
console.timeEnd("Search: SAVE_DB_INDEX");
691
}
692
693
// Refresh the cached search index after relevant Turbo stream updates.
694
function installSearchIndexRefreshHandler({
695
pathToSearchIndex,
696
dbName,
697
dbVersion,
698
timestampMeta,
699
searchData,
700
}) {
701
let refreshScheduled = false;
702
let refreshInProgress = false;
703
let refreshQueued = false;
704
705
const refreshSearchIndexFromServer = async () => {
706
if (refreshInProgress) {
707
refreshQueued = true;
708
return;
709
}
710
711
refreshInProgress = true;
712
try {
713
setSearchIndexReady(false);
714
await loadSearchIndexFromScript(pathToSearchIndex, true);
715
await saveCurrentSearchIndexToDB({
716
dbName,
717
dbVersion,
718
timestampMeta,
719
searchData,
720
});
721
setSearchIndexReady(true);
722
} catch (refreshError) {
723
console.error(
724
"Search: Failed to refresh search index after Turbo stream update:",
725
refreshError
726
);
727
} finally {
728
refreshInProgress = false;
729
if (refreshQueued) {
730
refreshQueued = false;
731
void refreshSearchIndexFromServer();
732
}
733
}
734
};
735
736
const scheduleRefreshSearchIndexFromServer = () => {
737
if (refreshScheduled) {
738
return;
739
}
740
refreshScheduled = true;
741
window.setTimeout(() => {
742
refreshScheduled = false;
743
void refreshSearchIndexFromServer();
744
}, 0);
745
};
746
747
document.addEventListener("turbo:before-stream-render", (event) => {
748
const streamElement = event.target;
749
if (!(streamElement instanceof HTMLElement)) {
750
return;
751
}
752
if (streamElement.tagName !== "TURBO-STREAM") {
753
return;
754
}
755
const target = streamElement.getAttribute("target");
756
// FIXME: HACK: For now we refresh the entire index on any update to the TOC.
757
// Ideally we should get a dedicated stream update for the search index
758
// and only refresh on that. But this will do for now.
759
if (target === "frame-toc") {
760
scheduleRefreshSearchIndexFromServer();
761
}
762
}, true);
763
}
764
765
// Initialize the search index from cache or from the generated script.
766
async function initializeSearchIndex({
767
projectHash,
768
pathToSearchIndex,
769
timestampMeta,
770
searchData,
771
}) {
772
const DB_VERSION = 1;
773
const dbName = "strictdoc_search_index_" + projectHash;
774
775
installSearchIndexRefreshHandler({
776
pathToSearchIndex,
777
dbName,
778
dbVersion: DB_VERSION,
779
timestampMeta,
780
searchData,
781
});
782
783
try {
784
console.log("Search: LOAD_DB_INDEX: Start");
785
const db = await openSearchIndexDB(dbName, DB_VERSION);
786
const tsEntry = await getFromSearchIndexStore(db, "indexes", "TIMESTAMP");
787
788
if (tsEntry && tsEntry.value === timestampMeta) {
789
console.time("Search: LOAD_DB_INDEX");
790
const lunrEntry = await getFromSearchIndexStore(
791
db,
792
"indexes",
793
"STRICTDOC_SEARCH_INDEX"
794
);
795
const nodesEntry = await getFromSearchIndexStore(
796
db,
797
"indexes",
798
"STRICTDOC_SEARCH_NODES_BY_MID"
799
);
800
console.timeEnd("Search: LOAD_DB_INDEX");
801
802
if (lunrEntry && nodesEntry) {
803
searchData.index = lunrEntry.value;
804
searchData.nodesByMid = nodesEntry.value;
805
db.close();
806
return;
807
}
808
}
809
810
db.close();
811
await deleteSearchIndexDB(dbName);
812
813
try {
814
await loadSearchIndexFromScript(pathToSearchIndex, false);
815
} catch (e) {
816
console.error("Search: Failed to load JS search index script:",
817
e);
818
return;
819
}
820
821
await saveCurrentSearchIndexToDB({
822
dbName,
823
dbVersion: DB_VERSION,
824
timestampMeta,
825
searchData,
826
});
827
828
} catch (err) {
829
console.error("Search: Error loading search index:", err);
830
831
try {
832
await loadSearchIndexFromScript(pathToSearchIndex, false);
833
console.log("Search: Script loaded without IndexedDB fallback");
834
} catch (e) {
835
console.error("Search: Failed to load search index script:", e);
836
}
837
}
838
}
839
840
// =========================================================================
841
// App initialization
842
// =========================================================================
843
844
// Initialize the UI controllers after all required DOM and meta are present.
845
const {
846
dom,
847
missingSelectors
848
} = collectRequiredDom();
849
const {
850
meta,
851
missingSelectors: missingMetaSelectors
852
} = collectRequiredMeta();
853
854
if (missingSelectors.length > 0) {
855
console.assert(
856
false,
857
`Search: initialization skipped because required DOM elements are missing: ${missingSelectors.join(", ")}`
858
);
859
return;
860
}
861
862
if (missingMetaSelectors.length > 0) {
863
console.assert(
864
false,
865
`Search: initialization skipped because required meta tags are missing: ${missingMetaSelectors.join(", ")}`
866
);
867
return;
868
}
869
870
const {
871
userinput
872
} = dom;
873
const documentLevel = parseInt(meta.documentLevel, 10);
874
875
// E2E tests rely on this startup event to type only after the async
876
// generated search index has been loaded.
877
window.StrictDoc.events.STATIC_HTML_SEARCH_READY =
878
"static-html-search:ready";
879
880
const searchResultsView = new SearchResultsView(dom, {
881
userinput,
882
searchData: strictDocSearch,
883
documentLevel,
884
});
885
const searchInputController = new SearchInputController({
886
userinput,
887
searchData: strictDocSearch,
888
searchResultsView,
889
});
890
searchInputController.attachEventListeners();
891
setSearchIndexReady(false);
892
893
// Defer search index initialization until the page and generated assets are ready.
894
window.addEventListener("load", async () => {
895
const timestampMeta = meta.searchIndexTimestamp;
896
const projectHash = meta.projectHash;
897
const pathToSearchIndex = meta.searchIndexPath;
898
899
if (!projectHash || !pathToSearchIndex || !timestampMeta) {
900
console.error("Search: Missing required meta tags!");
901
return;
902
}
903
await initializeSearchIndex({
904
projectHash,
905
pathToSearchIndex,
906
timestampMeta,
907
searchData: strictDocSearch,
908
});
909
if (strictDocSearch.index && strictDocSearch.nodesByMid) {
910
setSearchIndexReady(true);
911
}
912
});
913
})();