StrictDoc Documentation
strictdoc/export/html/_static/static_html_search.js
Source file coverage
Path:
strictdoc/export/html/_static/static_html_search.js
Lines:
897
Non-empty lines:
775
Non-empty lines covered with requirements:
775 / 775 (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
 * - #search
7
 * - #userinput
8
 * - #search_results
9
 * - #results_count
10
 * - #suggestions
11
 * - #results_navigation with #start, #previous, #next, #end
12
 * - 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
  function setSearchIndexReady(isReady) {
581
    strictDocSearch.isReady = isReady;
582
    if (isReady) {
583
      window.StrictDoc.bus.emit(
584
        window.StrictDoc.events.STATIC_HTML_SEARCH_READY
585
      );
586
    }
587
  }
588
 
589
  // Open the IndexedDB database that caches the generated search index.
590
  function openSearchIndexDB(name, version = 1) {
591
    return new Promise((resolve, reject) => {
592
      const request = indexedDB.open(name, version);
593
      request.onupgradeneeded = (e) => {
594
        const db = e.target.result;
595
        db.createObjectStore("indexes", {
596
          keyPath: "name"
597
        });
598
      };
599
      request.onsuccess = () => resolve(request.result);
600
      request.onerror = () => reject(request.error);
601
    });
602
  }
603
 
604
  // Drop the cached search index when it becomes stale.
605
  function deleteSearchIndexDB(name) {
606
    return new Promise((resolve, reject) => {
607
      const delReq = indexedDB.deleteDatabase(name);
608
      delReq.onsuccess = () => resolve();
609
      delReq.onerror = () => reject(delReq.error);
610
    });
611
  }
612
 
613
  // Read a cached value from the search index store.
614
  function getFromSearchIndexStore(db, storeName, key) {
615
    return new Promise((resolve, reject) => {
616
      const tx = db.transaction(storeName, "readonly");
617
      const store = tx.objectStore(storeName);
618
      const req = store.get(key);
619
      req.onsuccess = () => resolve(req.result);
620
      req.onerror = () => reject(req.error);
621
    });
622
  }
623
 
624
  // Persist the current search index payload to the cache store.
625
  function saveToSearchIndexStore(db, storeName, items) {
626
    return new Promise((resolve, reject) => {
627
      const tx = db.transaction(storeName, "readwrite");
628
      const store = tx.objectStore(storeName);
629
      items.forEach((item) => store.put(item));
630
      tx.oncomplete = () => resolve();
631
      tx.onerror = () => reject(tx.error);
632
    });
633
  }
634
 
635
  // Load the generated search index JavaScript file into the page.
636
  function loadScript(url) {
637
    return new Promise((resolve, reject) => {
638
      const script = document.createElement("script");
639
      script.src = url;
640
      script.onload = () => {
641
        script.remove();
642
        resolve();
643
      };
644
      script.onerror = () => reject(new Error(
645
        `Failed to load script ${url}`));
646
      document.head.appendChild(script);
647
    });
648
  }
649
 
650
  // Load the generated search index, optionally bypassing the browser cache.
651
  async function loadSearchIndexFromScript(pathToSearchIndex, cacheBusting) {
652
    const searchIndexURL = new URL(pathToSearchIndex, window.location.href);
653
    if (cacheBusting) {
654
      searchIndexURL.searchParams.set("_refresh", Date.now().toString());
655
    }
656
    console.time("Search: LOAD_JS_INDEX");
657
    await loadScript(searchIndexURL.href);
658
    console.timeEnd("Search: LOAD_JS_INDEX");
659
    console.log("Search: JS search index loaded successfully.");
660
  }
661
 
662
  // Save the in-memory search index into IndexedDB for faster reloads.
663
  async function saveCurrentSearchIndexToDB({
664
    dbName,
665
    dbVersion,
666
    timestampMeta,
667
    searchData,
668
  }) {
669
    console.time("Search: SAVE_DB_INDEX");
670
    const db = await openSearchIndexDB(dbName, dbVersion);
671
    await saveToSearchIndexStore(db, "indexes", [{
672
      name: "STRICTDOC_SEARCH_INDEX",
673
      value: searchData.index
674
    }, {
675
      name: "STRICTDOC_SEARCH_NODES_BY_MID",
676
      value: searchData.nodesByMid
677
    }, {
678
      name: "TIMESTAMP",
679
      value: timestampMeta
680
    }, ]);
681
    db.close();
682
    console.timeEnd("Search: SAVE_DB_INDEX");
683
  }
684
 
685
  // Refresh the cached search index after relevant Turbo stream updates.
686
  function installSearchIndexRefreshHandler({
687
    pathToSearchIndex,
688
    dbName,
689
    dbVersion,
690
    timestampMeta,
691
    searchData,
692
  }) {
693
    let refreshScheduled = false;
694
    let refreshInProgress = false;
695
    let refreshQueued = false;
696
 
697
    const refreshSearchIndexFromServer = async () => {
698
      if (refreshInProgress) {
699
        refreshQueued = true;
700
        return;
701
      }
702
 
703
      refreshInProgress = true;
704
      try {
705
        setSearchIndexReady(false);
706
        await loadSearchIndexFromScript(pathToSearchIndex, true);
707
        await saveCurrentSearchIndexToDB({
708
          dbName,
709
          dbVersion,
710
          timestampMeta,
711
          searchData,
712
        });
713
        setSearchIndexReady(true);
714
      } catch (refreshError) {
715
        console.error(
716
          "Search: Failed to refresh search index after Turbo stream update:",
717
          refreshError
718
        );
719
      } finally {
720
        refreshInProgress = false;
721
        if (refreshQueued) {
722
          refreshQueued = false;
723
          void refreshSearchIndexFromServer();
724
        }
725
      }
726
    };
727
 
728
    const scheduleRefreshSearchIndexFromServer = () => {
729
      if (refreshScheduled) {
730
        return;
731
      }
732
      refreshScheduled = true;
733
      window.setTimeout(() => {
734
        refreshScheduled = false;
735
        void refreshSearchIndexFromServer();
736
      }, 0);
737
    };
738
 
739
    document.addEventListener("turbo:before-stream-render", (event) => {
740
      const streamElement = event.target;
741
      if (!(streamElement instanceof HTMLElement)) {
742
        return;
743
      }
744
      if (streamElement.tagName !== "TURBO-STREAM") {
745
        return;
746
      }
747
      const target = streamElement.getAttribute("target");
748
      // FIXME: HACK: For now we refresh the entire index on any update to the TOC.
749
      // Ideally we should get a dedicated stream update for the search index
750
      // and only refresh on that. But this will do for now.
751
      if (target === "frame-toc") {
752
        scheduleRefreshSearchIndexFromServer();
753
      }
754
    }, true);
755
  }
756
 
757
  // Initialize the search index from cache or from the generated script.
758
  async function initializeSearchIndex({
759
    projectHash,
760
    pathToSearchIndex,
761
    timestampMeta,
762
    searchData,
763
  }) {
764
    const DB_VERSION = 1;
765
    const dbName = "strictdoc_search_index_" + projectHash;
766
 
767
    installSearchIndexRefreshHandler({
768
      pathToSearchIndex,
769
      dbName,
770
      dbVersion: DB_VERSION,
771
      timestampMeta,
772
      searchData,
773
    });
774
 
775
    try {
776
      console.log("Search: LOAD_DB_INDEX: Start");
777
      const db = await openSearchIndexDB(dbName, DB_VERSION);
778
      const tsEntry = await getFromSearchIndexStore(db, "indexes", "TIMESTAMP");
779
 
780
      if (tsEntry && tsEntry.value === timestampMeta) {
781
        console.time("Search: LOAD_DB_INDEX");
782
        const lunrEntry = await getFromSearchIndexStore(
783
          db,
784
          "indexes",
785
          "STRICTDOC_SEARCH_INDEX"
786
        );
787
        const nodesEntry = await getFromSearchIndexStore(
788
          db,
789
          "indexes",
790
          "STRICTDOC_SEARCH_NODES_BY_MID"
791
        );
792
        console.timeEnd("Search: LOAD_DB_INDEX");
793
 
794
        if (lunrEntry && nodesEntry) {
795
          searchData.index = lunrEntry.value;
796
          searchData.nodesByMid = nodesEntry.value;
797
          db.close();
798
          return;
799
        }
800
      }
801
 
802
      db.close();
803
      await deleteSearchIndexDB(dbName);
804
 
805
      try {
806
        await loadSearchIndexFromScript(pathToSearchIndex, false);
807
      } catch (e) {
808
        console.error("Search: Failed to load JS search index script:",
809
          e);
810
        return;
811
      }
812
 
813
      await saveCurrentSearchIndexToDB({
814
        dbName,
815
        dbVersion: DB_VERSION,
816
        timestampMeta,
817
        searchData,
818
      });
819
 
820
    } catch (err) {
821
      console.error("Search: Error loading search index:", err);
822
 
823
      try {
824
        await loadSearchIndexFromScript(pathToSearchIndex, false);
825
        console.log("Search: Script loaded without IndexedDB fallback");
826
      } catch (e) {
827
        console.error("Search: Failed to load search index script:", e);
828
      }
829
    }
830
  }
831
 
832
  // =========================================================================
833
  // App initialization
834
  // =========================================================================
835
 
836
  // Initialize the UI controllers after all required DOM and meta are present.
837
  const { dom, missingSelectors } = collectRequiredDom();
838
  const { meta, missingSelectors: missingMetaSelectors } = collectRequiredMeta();
839
 
840
  if (missingSelectors.length > 0) {
841
    console.assert(
842
      false,
843
      `Search: initialization skipped because required DOM elements are missing: ${missingSelectors.join(", ")}`
844
    );
845
    return;
846
  }
847
 
848
  if (missingMetaSelectors.length > 0) {
849
    console.assert(
850
      false,
851
      `Search: initialization skipped because required meta tags are missing: ${missingMetaSelectors.join(", ")}`
852
    );
853
    return;
854
  }
855
 
856
  const { userinput } = dom;
857
  const documentLevel = parseInt(meta.documentLevel, 10);
858
 
859
  // E2E tests rely on this startup event to type only after the async
860
  // generated search index has been loaded.
861
  window.StrictDoc.events.STATIC_HTML_SEARCH_READY =
862
    "static-html-search:ready";
863
 
864
  const searchResultsView = new SearchResultsView(dom, {
865
    userinput,
866
    searchData: strictDocSearch,
867
    documentLevel,
868
  });
869
  const searchInputController = new SearchInputController({
870
    userinput,
871
    searchData: strictDocSearch,
872
    searchResultsView,
873
  });
874
  searchInputController.attachEventListeners();
875
  setSearchIndexReady(false);
876
 
877
  // Defer search index initialization until the page and generated assets are ready.
878
  window.addEventListener("load", async () => {
879
    const timestampMeta = meta.searchIndexTimestamp;
880
    const projectHash = meta.projectHash;
881
    const pathToSearchIndex = meta.searchIndexPath;
882
 
883
    if (!projectHash || !pathToSearchIndex || !timestampMeta) {
884
      console.error("Search: Missing required meta tags!");
885
      return;
886
    }
887
    await initializeSearchIndex({
888
      projectHash,
889
      pathToSearchIndex,
890
      timestampMeta,
891
      searchData: strictDocSearch,
892
    });
893
    if (strictDocSearch.index && strictDocSearch.nodesByMid) {
894
      setSearchIndexReady(true);
895
    }
896
  });
897
})();