StrictDoc Documentation
strictdoc/export/html/_static/static_html_search.js
Source file coverage
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
 * - #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
  // 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
})();