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