PageSourceSearch

https://joeornstein.github.io/site_libs/quarto-search/quarto-search.js

js joeornstein.github.io collected 2026-10-03 09:24:26 UTC 43,121 bytes, 1,457 lines download raw bytes

1const kQueryArg = "q";
2const kResultsArg = "show-results";
3
4// If items don't provide a URL, then both the navigator and the onSelect
5// function aren't called (and therefore, the default implementation is used)
6//
7// We're using this sentinel URL to signal to those handlers that this
8// item is a more item (along with the type) and can be handled appropriately
9const kItemTypeMoreHref = "0767FDFD-0422-4E5A-BC8A-3BE11E5BBA05";
10
11// Capture search params and clean ?q= from URL at module load time, before
12// any DOMContentLoaded handlers run. quarto-nav.js resolves all <a> hrefs
13// against window.location during DOMContentLoaded — if ?q= is still present,
14// every link on the page gets the query param baked into its href.
15const currentUrl = new URL(window.location);
16const kQuery = currentUrl.searchParams.get(kQueryArg);
17if (kQuery) {
18  const replacementUrl = new URL(window.location);
19  replacementUrl.searchParams.delete(kQueryArg);
20  window.history.replaceState({}, "", replacementUrl);
21}
22
23window.document.addEventListener("DOMContentLoaded", function (_event) {
24  // Ensure that search is available on this page. If it isn't,
25  // should return early and not do anything
26  var searchEl = window.document.getElementById("quarto-search");
27  if (!searchEl) return;
28
29  const { autocomplete } = window["@algolia/autocomplete-js"];
30
31  let quartoSearchOptions = {};
32  let language = {};
33  const searchOptionEl = window.document.getElementById(
34    "quarto-search-options"
35  );
36  if (searchOptionEl) {
37    const jsonStr = searchOptionEl.textContent;
38    quartoSearchOptions = JSON.parse(jsonStr);
39    language = quartoSearchOptions.language;
40  }
41
42  // note the search mode
43  if (quartoSearchOptions.type === "overlay") {
44    searchEl.classList.add("type-overlay");
45  } else {
46    searchEl.classList.add("type-textbox");
47  }
48
49  // Used to determine highlighting behavior for this page
50  // A `q` query param is expected when the user follows a search
51  // to this page
52  const query = kQuery;
53  const showSearchResults = currentUrl.searchParams.get(kResultsArg);
54  const mainEl = window.document.querySelector("main");
55
56  // highlight matches on the page
57  if (query && mainEl) {
58    highlight(query, mainEl);
59
60    // Activate tabs on pageshow — after tabsets.js restores localStorage state.
61    // tabsets.js registers its pageshow handler during module execution (before
62    // DOMContentLoaded). By registering ours during DOMContentLoaded, listener
63    // ordering guarantees we run after tabsets.js — so search activation wins.
64    window.addEventListener("pageshow", function (event) {
65      if (!event.persisted) {
66        for (const mark of mainEl.querySelectorAll("mark")) {
67          openAllTabsetsContainingEl(mark);
68        }
69        // Only scroll to first match when there's no hash fragment.
70        // With a hash, the browser already scrolled to the target section.
71        if (!currentUrl.hash) {
72          requestAnimationFrame(() => scrollToFirstVisibleMatch(mainEl));
73        }
74      }
75    }, { once: true });
76  }
77
78  // function to clear highlighting on the page when the search query changes
79  // (e.g. if the user edits the query or clears it)
80  let highlighting = true;
81  const resetHighlighting = (searchTerm) => {
82    if (mainEl && highlighting && query && searchTerm !== query) {
83      clearHighlight(query, mainEl);
84      highlighting = false;
85    }
86  };
87
88  // Responsively switch to overlay mode if the search is present on the navbar
89  // Note that switching the sidebar to overlay mode requires more coordinate (not just
90  // the media query since we generate different HTML for sidebar overlays than we do
91  // for sidebar input UI)
92  const detachedMediaQuery =
93    quartoSearchOptions.type === "overlay" ? "all" : "(max-width: 991px)";
94
95  // If configured, include the analytics client to send insights
96  const plugins = configurePlugins(quartoSearchOptions);
97
98  let lastState = null;
99  const { setIsOpen, setQuery, setCollections } = autocomplete({
100    container: searchEl,
101    detachedMediaQuery: detachedMediaQuery,
102    defaultActiveItemId: 0,
103    panelContainer: "#quarto-search-results",
104    panelPlacement: quartoSearchOptions["panel-placement"],
105    debug: false,
106    openOnFocus: true,
107    plugins,
108    classNames: {
109      form: "d-flex",
110    },
111    placeholder: language["search-text-placeholder"],
112    translations: {
113      clearButtonTitle: language["search-clear-button-title"],
114      detachedCancelButtonText: language["search-detached-cancel-button-title"],
115      submitButtonTitle: language["search-submit-button-title"],
116    },
117    initialState: {
118      query,
119    },
120    getItemUrl({ item }) {
121      return item.href;
122    },
123    onStateChange({ state }) {
124      // If this is a file URL, note that
125
126      // Perhaps reset highlighting
127      resetHighlighting(state.query);
128
129      // If the panel just opened, ensure the panel is positioned properly
130      if (state.isOpen) {
131        if (lastState && !lastState.isOpen) {
132          setTimeout(() => {
133            positionPanel(quartoSearchOptions["panel-placement"]);
134          }, 150);
135        }
136      }
137
138      // Perhaps show the copy link
139      showCopyLink(state.query, quartoSearchOptions);
140
141      lastState = state;
142    },
143    reshape({ sources, state }) {
144      return sources.map((source) => {
145        try {
146          const items = source.getItems();
147
148          // Validate the items
149          validateItems(items);
150
151          // group the items by document
152          const groupedItems = new Map();
153          items.forEach((item) => {
154            const hrefParts = item.href.split("#");
155            const baseHref = hrefParts[0];
156            const isDocumentItem = hrefParts.length === 1;
157
158            const items = groupedItems.get(baseHref);
159            if (!items) {
160              groupedItems.set(baseHref, [item]);
161            } else {
162              // If the href for this item matches the document
163              // exactly, place this item first as it is the item that represents
164              // the document itself
165              if (isDocumentItem) {
166                items.unshift(item);
167              } else {
168                items.push(item);
169              }
170              groupedItems.set(baseHref, items);
171            }
172          });
173
174          const reshapedItems = [];
175          let count = 1;
176          for (const [_key, value] of groupedItems) {
177            const firstItem = value[0];
178            reshapedItems.push({
179              ...firstItem,
180              type: kItemTypeDoc,
181            });
182
183            const collapseMatches = quartoSearchOptions["collapse-
183after"];
184            const collapseCount =
185              typeof collapseMatches === "number" ? collapseMatches : 1;
186
187            if (value.length > 1) {
188              const target = `search-more-${count}`;
189              const isExpanded =
190                state.context.expanded &&
191                state.context.expanded.includes(target);
192
193              const remainingCount = value.length - collapseCount;
194
195              for (let i = 1; i < value.length; i++) {
196                if (collapseMatches && i === collapseCount) {
197                  reshapedItems.push({
198                    target,
199                    title: isExpanded
200                      ? language["search-hide-matches-text"]
201                      : remainingCount === 1
202                        ? `${remainingCount} ${language["search-more-match-text"]}`
203                        : `${remainingCount} ${language["search-more-matches-text"]}`,
204                    type: kItemTypeMore,
205                    href: kItemTypeMoreHref,
206                  });
207                }
208
209                if (isExpanded || !collapseMatches || i < collapseCount) {
210                  reshapedItems.push({
211                    ...value[i],
212                    type: kItemTypeItem,
213                    target,
214                  });
215                }
216              }
217            }
218            count += 1;
219          }
220
221          return {
222            ...source,
223            getItems() {
224              return reshapedItems;
225            },
226          };
227        } catch (error) {
228          // Some form of error occurred
229          return {
230            ...source,
231            getItems() {
232              return [
233                {
234                  title: error.name || "An Error Occurred While Searching",
235                  text:
236                    error.message ||
237                    "An unknown error occurred while attempting to perform the requested search.",
238                  type: kItemTypeError,
239                },
240              ];
241            },
242          };
243        }
244      });
245    },
246    navigator: {
247      navigate({ itemUrl }) {
248        if (itemUrl !== offsetURL(kItemTypeMoreHref)) {
249          window.location.assign(itemUrl);
250        }
251      },
252      navigateNewTab({ itemUrl }) {
253        if (itemUrl !== offsetURL(kItemTypeMoreHref)) {
254          const windowReference = window.open(itemUrl, "_blank", "noopener");
255          if (windowReference) {
256            windowReference.focus();
257          }
258        }
259      },
260      navigateNewWindow({ itemUrl }) {
261        if (itemUrl !== offsetURL(kItemTypeMoreHref)) {
262          window.open(itemUrl, "_blank", "noopener");
263        }
264      },
265    },
266    getSources({ state, setContext, setActiveItemId, refresh }) {
267      return [
268        {
269          sourceId: "documents",
270          getItemUrl({ item }) {
271            if (item.href) {
272              return offsetURL(item.href);
273            } else {
274              return undefined;
275            }
276          },
277          onSelect({
278            item,
279            state,
280            setContext,
281            setIsOpen,
282            setActiveItemId,
283            refresh,
284          }) {
285            if (item.type === kItemTypeMore) {
286              toggleExpanded(item, state, setContext, setActiveItemId, refresh);
287
288              // Toggle more
289              setIsOpen(true);
290            }
291          },
292          getItems({ query }) {
293            if (query === null || query === "") {
294              return [];
295            }
296
297            const limit = quartoSearchOptions.limit;
298            if (quartoSearchOptions.algolia) {
299              return algoliaSearch(query, limit, quartoSearchOptions.algolia);
300            } else {
301              // Fuse search options
302              const fuseSearchOptions = {
303                isCaseSensitive: false,
304                shouldSort: true,
305                minMatchCharLength: 2,
306                limit: limit,
307              };
308
309              return readSearchData().then(function (fuse) {
310                return fuseSearch(query, fuse, fuseSearchOptions);
311              });
312            }
313          },
314          templates: {
315            noResults({ createElement }) {
316              const hasQuery = lastState.query;
317
318              return createElement(
319                "div",
320                {
321                  class: `quarto-search-no-results${hasQuery ? "" : " no-query"
322                    }`,
323                },
324                language["search-no-results-text"]
325              );
326            },
327            header({ items, createElement }) {
328              // count the documents
329              const count = items.filter((item) => {
330                return item.type === kItemTypeDoc;
331              }).length;
332
333              if (count > 0) {
334                return createElement(
335                  "div",
336                  { class: "search-result-header" },
337                  `${count} ${language["search-matching-documents-text"]}`
338                );
339              } else {
340                return createElement(
341                  "div",
342                  { class: "search-result-header-no-results" },
343                  ``
344                );
345              }
346            },
347            footer({ _items, createElement }) {
348              if (
349                quartoSearchOptions.algolia &&
350                quartoSearchOptions.algolia["show-logo"]
351              ) {
352                const libDir = quartoSearchOptions.algolia["libDir"];
353                const logo = createElement("img", {
354                  src: offsetURL(
355                    `${libDir}/quarto-search/search-by-algolia.svg`
356                  ),
357                  class: "algolia-search-logo",
358                });
359                return createElement(
360                  "a",
361                  { href: "http://www.algolia.com/" },
362                  logo
363                );
364              }
365            },
366
367            item({ item, createElement }) {
368              if (item.text && item.href && !item.href.includes('?q=')) {
369                const [main, hash] = item.href.split('#')
370                const hashAppend = hash ? '#' + hash : ''
371                item.href = main + '?q=' + encodeURIComponent(state.query) + hashAppend
372              }
373
374              return renderItem(
375                item,
376                createElement,
377                state,
378                setActiveItemId,
379                setContext,
380                refresh,
381                quartoSearchOptions
382              );
383            },
384          },
385        },
386      ];
387    },
388  });
389
390  window.quartoOpenSearch = () => {
391    setIsOpen(false);
392    setIsOpen(true);
393    focusSearchInput();
394  };
395
396  document.addEventListener("keyup", (event) => {
397    const { key } = event;
398    const kbds = quartoSearchOptions["keyboard-shortcut"];
399    const focusedEl = document.activeElement;
400
401    const isFormElFocused = [
402      "input",
403      "select",
404      "textarea",
405      "button",
406      "option",
407    ].find((tag) => {
408      return focusedEl.tagName.toLowerCase() === tag;
409    });
410
411    if (
412      kbds &&
413      kbds.includes(key) &&
414      !isFormElFocused &&
415      !document.activeElement.isContentEditable
416    ) {
417      event.preventDefault();
418      window.quartoOpenSearch();
419    }
420  });
421
422  // Remove the labeleledby attribute since it is pointing
423  // to a non-existent label
424  if (quartoSearchOptions.type === "overlay") {
425    const inputEl = window.document.querySelector(
426      "#quarto-search .aa-Autocomplete"
427    );
428    if (inputEl) {
429      inputEl.removeAttribute("aria-labelledby");
430    }
431  }
432
433  function throttle(func, wait) {
434    let waiting = false;
435    return function () {
436      if (!waiting) {
437        func.apply(this, arguments);
438        waiting = true;
439        setTimeout(function () {
440          waiting = false;
441        }, wait);
442      }
443    };
444  }
445
446  // If the main document scrolls dismiss the search results
447  // (otherwise, since they're floating in the document they can scroll with the document)
448  window.document.body.onscroll = throttle(() => {
449    // Only do this if we're not detached
450    // Bug #7117
451    // This will happen when the keyboard is shown on ios (resulting in a scroll)
452    // which then closed the search UI
453    if (!window.matchMedia(detachedMediaQuery).matches) {
454      setIsOpen(false);
455    }
456  }, 50);
457
458  if (showSearchResults) {
459    setIsOpen(true);
460    focusSearchInput();
461  }
462});
463
464function configurePlugins(quartoSearchOptions) {
465  const autocompletePlugins = [];
466  const algoliaOptions = quartoSearchOptions.algolia;
467  if (
468    algoliaOptions &&
469    algoliaOptions["analytics-events"] &&
470    algoliaOptions["search-only-api-key"] &&
471    algoliaOptions["application-id"]
472  ) {
473    const apiKey = algoliaOptions["search-only-api-key"];
474    const appId = algoliaOptions["application-id"];
475
476    // Aloglia insights may not be loaded because they require cookie consent
477    // Use deferred loading so events will start being recorded when/if consent
478    // is granted.
479    const algoliaInsightsDeferredPlugin = deferredLoadPlugin(() => {
480      if (
481        window.aa &&
482        window["@algolia/autocomplete-plugin-algolia-insights"]
483      ) {
484        // Check if cookie consent is enabled from search options
485        const cookieConsentEnabled = algoliaOptions["cookie-consent-enabled"] || false;
486
487        // Generate random session token only when cookies are disabled
488        const userToken = cookieConsentEnabled ? undefined : Array.from(Array(20), () =>
489          Math.floor(Math.random() * 36).toString(36)
490        ).join("");
491
492        window.aa("init", {
493          appId,
494          apiKey,
495          useCookie: cookieConsentEnabled,
496          userToken: userToken,
497        });
498
499        const { createAlgoliaInsightsPlugin } =
500          window["@algolia/autocomplete-plugin-algolia-insights"];
501        // Register the insights client
502        const algoliaInsightsPlugin = createAlgoliaInsightsPlugin({
503          insightsClient: window.aa,
504          onItemsChange({ insights, insightsEvents }) {
505            const events = insightsEvents.flatMap((event) => {
506              // This API limits the number of items per event to 20
507              const chunkSize = 20;
508              const itemChunks = [];
509              const eventItems = event.items;
510              for (let i = 0; i < eventItems.length; i += chunkSize) {
511                itemChunks.push(eventItems.slice(i, i + chunkSize));
512              }
513              // Split the items into multiple events that can be sent
514              const events = itemChunks.map((items) => {
515                return {
516                  ...event,
517                  items,
518                };
519              });
520              return events;
521            });
522
523            for (const event of events) {
524              insights.viewedObjectIDs(event);
525            }
526          },
527        });
528        return algoliaInsightsPlugin;
529      }
530    });
531
532    // Add the plugin
533    autocompletePlugins.push(algoliaInsightsDeferredPlugin);
534    return autocompletePlugins;
535  }
536}
537
538// For plugins that may not load immediately, create a wrapper
539// plugin and forward events and plugin data once the plugin
540// is initialized. This is useful for cases like cookie consent
541// which may prevent the analytics insights event plugin from initializing
542// immediately.
543function deferredLoadPlugin(createPlugin) {
544  let plugin = undefined;
545  let subscribeObj = undefined;
546  const wrappedPlugin = () => {
547    if (!plugin && subscribeObj) {
548      plugin = createPlugin();
549      if (plugin && plugin.subscribe) {
550        plugin.subscribe(subscribeObj);
551      }
552    }
553    return plugin;
554  };
555
556  return {
557    subscribe: (obj) => {
558      subscribeObj = obj;
559    },
560    onStateChange: (obj) => {
561      const plugin = wrappedPlugin();
562      if (plugin && plugin.onStateChange) {
563        plugin.onStateChange(obj);
564      }
565    },
566    onSubmit: (obj) => {
567      const plugin = wrappedPlugin();
568      if (plugin && plugin.onSubmit) {
569        plugin.onSubmit(obj);
570      }
571    },
572    onReset: (obj) => {
573      const plugin = wrappedPlugin();
574      if (plugin && plugin.onReset) {
575        plugin.onReset(obj);
576      }
577    },
578    getSources: (obj) => {
579      const plugin = wrappedPlugin();
580      if (plugin && plugin.getSources) {
581        return plugin.getSources(obj);
582      } else {
583        return Promise.resolve([]);
584      }
585    },
586    data: (obj) => {
587      const plugin = wrappedPlugin();
588      if (plugin && plugin.data) {
589        plugin.data(obj);
590      }
591    },
592  };
593}
594
595function validateItems(items) {
596  // Validate the first item
597  if (items.length > 0) {
598    const item = items[0];
599    const missingFields = [];
600    if (item.href == undefined) {
601      missingFields.push("href");
602    }
603    if (!item.title == undefined) {
604      missingFields.push("title");
605    }
606    if (!item.text == undefined) {
607      missingFields.push("text");
608    }
609
610    if (missingFields.length === 1) {
611      throw {
612        name: `Error: Search index is missing the <code>${missingFields[0]}</code> field.`,
613        message: `The items being returned for this search do not include all the required fields. Please ensure that your index items include the <code>${missingFields[0]}</code> field or use <code>index-fields</code> in your <code>
613_quarto.yml</code> file to specify the field names.`,
614      };
615    } else if (missingFields.length > 1) {
616      const missingFieldList = missingFields
617        .map((field) => {
618          return `<code>${field}</code>`;
619        })
620        .join(", ");
621
622      throw {
623        name: `Error: Search index is missing the following fields: ${missingFieldList}.`,
624        message: `The items being returned for this search do not include all the required fields. Please ensure that your index items includes the following fields: ${missingFieldList}, or use <code>index-fields</code> in your <code>_quarto.yml</code> file to specify the field names.`,
625      };
626    }
627  }
628}
629
630let lastQuery = null;
631function showCopyLink(query, options) {
632  const language = options.language;
633  lastQuery = query;
634  // Insert share icon
635  const inputSuffixEl = window.document.body.querySelector(
636    ".aa-Form .aa-InputWrapperSuffix"
637  );
638
639  if (inputSuffixEl) {
640    let copyButtonEl = window.document.body.querySelector(
641      ".aa-Form .aa-InputWrapperSuffix .aa-CopyButton"
642    );
643
644    if (copyButtonEl === null) {
645      copyButtonEl = window.document.createElement("button");
646      copyButtonEl.setAttribute("class", "aa-CopyButton");
647      copyButtonEl.setAttribute("type", "button");
648      copyButtonEl.setAttribute("title", language["search-copy-link-title"]);
649      copyButtonEl.onmousedown = (e) => {
650        e.preventDefault();
651        e.stopPropagation();
652      };
653
654      const linkIcon = "bi-clipboard";
655      const checkIcon = "bi-check2";
656
657      const shareIconEl = window.document.createElement("i");
658      shareIconEl.setAttribute("class", `bi ${linkIcon}`);
659      copyButtonEl.appendChild(shareIconEl);
660      inputSuffixEl.prepend(copyButtonEl);
661
662      const clipboard = new window.ClipboardJS(".aa-CopyButton", {
663        text: function (_trigger) {
664          const copyUrl = new URL(window.location);
665          copyUrl.searchParams.set(kQueryArg, lastQuery);
666          copyUrl.searchParams.set(kResultsArg, "1");
667          return copyUrl.toString();
668        },
669      });
670      clipboard.on("success", function (e) {
671        // Focus the input
672
673        // button target
674        const button = e.trigger;
675        const icon = button.querySelector("i.bi");
676
677        // flash "checked"
678        icon.classList.add(checkIcon);
679        icon.classList.remove(linkIcon);
680        setTimeout(function () {
681          icon.classList.remove(checkIcon);
682          icon.classList.add(linkIcon);
683        }, 1000);
684      });
685    }
686
687    // If there is a query, show the link icon
688    if (copyButtonEl) {
689      if (lastQuery && options["copy-button"]) {
690        copyButtonEl.style.display = "flex";
691      } else {
692        copyButtonEl.style.display = "none";
693      }
694    }
695  }
696}
697
698/* Search Index Handling */
699// create the index
700var fuseIndex = undefined;
701var shownWarning = false;
702
703// fuse index options
704const kFuseIndexOptions = {
705  keys: [
706    { name: "title", weight: 20 },
707    { name: "section", weight: 20 },
708    { name: "text", weight: 10 },
709  ],
710  ignoreLocation: true,
711  threshold: 0.1,
712};
713
714async function readSearchData() {
715  // Initialize the search index on demand
716  if (fuseIndex === undefined) {
717    if (window.location.protocol === "file:" && !shownWarning) {
718      window.alert(
719        "Search requires JavaScript features disabled when running in file://... URLs. In order to use search, please run this document in a web server."
720      );
721      shownWarning = true;
722      return;
723    }
724    const fuse = new window.Fuse([], kFuseIndexOptions);
725
726    // fetch the main search.json
727    const response = await fetch(offsetURL("search.json"));
728    if (response.status == 200) {
729      return response.json().then(function (searchDocs) {
730        searchDocs.forEach(function (searchDoc) {
731          fuse.add(searchDoc);
732        });
733        fuseIndex = fuse;
734        return fuseIndex;
735      });
736    } else {
737      return Promise.reject(
738        new Error(
739          "Unexpected status from search index request: " + response.status
740        )
741      );
742    }
743  }
744
745  return fuseIndex;
746}
747
748function inputElement() {
749  return window.document.body.querySelector(".aa-Form .aa-Input");
750}
751
752function focusSearchInput() {
753  setTimeout(() => {
754    const inputEl = inputElement();
755    if (inputEl) {
756      inputEl.focus();
757    }
758  }, 50);
759}
760
761/* Panels */
762const kItemTypeDoc = "document";
763const kItemTypeMore = "document-more";
764const kItemTypeItem = "document-item";
765const kItemTypeError = "error";
766
767function renderItem(
768  item,
769  createElement,
770  state,
771  setActiveItemId,
772  setContext,
773  refresh,
774  quartoSearchOptions
775) {
776  switch (item.type) {
777    case kItemTypeDoc:
778      return createDocumentCard(
779        createElement,
780        "file-richtext",
781        item.title,
782        item.section,
783        item.text,
784        item.href,
785        item.crumbs,
786        quartoSearchOptions
787      );
788    case kItemTypeMore:
789      return createMoreCard(
790        createElement,
791        item,
792        state,
793        setActiveItemId,
794        setContext,
795        refresh
796      );
797    case kItemTypeItem:
798      return createSectionCard(
799        createElement,
800        item.section,
801        item.text,
802        item.href
803      );
804    case kItemTypeError:
805      return createErrorCard(createElement, item.title, item.text);
806    default:
807      return undefined;
808  }
809}
810
811function createDocumentCard(
812  createElement,
813  icon,
814  title,
815  section,
816  text,
817  href,
818  crumbs,
819  quartoSearchOptions
820) {
821  const iconEl = createElement("i", {
822    class: `bi bi-${icon} search-result-icon`,
823  });
824  const titleEl = createElement("p", { class: "search-result-title" }, title);
825  const titleContents = [iconEl, titleEl];
826  const showParent = quartoSearchOptions["show-item-context"];
827  if (crumbs && showParent) {
828    let crumbsOut = undefined;
829    const crumbClz = ["search-result-crumbs"];
830    if (showParent === "root") {
831      crumbsOut = crumbs.length > 1 ? crumbs[0] : undefined;
832    } else if (showParent === "parent") {
833      crumbsOut = crumbs.length > 1 ? crumbs[crumbs.length - 2] : undefined;
834    } else {
835      crumbsOut = crumbs.length > 1 ? crumbs.join(" > ") : undefined;
836      crumbClz.push("search-result-crumbs-wrap");
837    }
838
839    const crumbEl = createElement(
840      "p",
841      { class: crumbClz.join(" ") },
842      crumbsOut
843    );
844    titleContents.push(crumbEl);
845  }
846
847  const titleContainerEl = createElement(
848    "div",
849    { class: "search-result-title-container" },
850    titleContents
851  );
852
853  const textEls = [];
854  if (section) {
855    const sectionEl = createElement(
856      "p",
857      { class: "search-result-section" },
858      section
859    );
860    textEls.push(sectionEl);
861  }
862  const descEl = createElement("p", {
863    class: "search-result-text",
864    dangerouslySetInnerHTML: {
865      __html: text,
866    },
867  });
868  textEls.push(descEl);
869
870  const textContainerEl = createElement(
871    "div",
872    { class: "search-result-text-container" },
873    textEls
874  );
875
876  const containerEl = createElement(
877    "div",
878    {
879      class: "search-result-container",
880    },
881    [titleContainerEl, textContainerEl]
882  );
883
884  const linkEl = createElement(
885    "a",
886    {
887      href: offsetURL(href),
888      class: "search-result-link",
889    },
890    containerEl
891  );
892
893  const classes = ["search-result-doc", "search-item"];
894  if (!section) {
895    classes.push("document-selectable");
896  }
897
898  return createElement(
899    "div",
900    {
901      class: classes.join(" "),
902    },
903    linkEl
904  );
905}
906
907function createMoreCard(
908  createElement,
909  item,
910  state,
911  setActiveItemId,
912  setContext,
913  refresh
914) {
915  const moreCardEl = createElement(
916    "div",
917    {
918      class: "search-result-more search-item",
919      onClick: (e) => {
920        // Handle expanding the sections by adding the expanded
921        // section to the list of expanded sections
922        toggleExpanded(item, state, setContext, setActiveItemId, refresh);
923        e.stopPropagation();
924      },
925    },
926    item.title
927  );
928
929  return moreCardEl;
930}
931
932function toggleExpanded(item, state, setContext, setActiveItemId, refresh) {
933  const expanded = state.context.expanded || [];
934  if (expanded.includes(item.target)) {
935    setContext({
936      expanded: expanded.filter((target) => target !== item.target),
937    });
938  } else {
939    setContext({ expanded: [...expanded, item.target] });
940  }
941
942  refresh();
943  setActiveItemId(item.__autocomplete_id);
944}
945
946function createSectionCard(createElement, section, text, href) {
947  const sectionEl = createSection(createElement, section, text, href);
948  return createElement(
949    "div",
950    {
951      class: "search-result-doc-section search-item",
952    },
953    sectionEl
954  );
955}
956
957function createSection(createElement, title, text, href) {
958  const descEl = createElement("p", {
959    class: "search-result-text",
960    dangerouslySetInnerHTML: {
961      __html: text,
962    },
963  });
964
965  const titleEl = createElement("p", { class: "search-result-section" }, title);
966  const linkEl = createElement(
967    "a",
968    {
969      href: offsetURL(href),
970      class: "search-result-link",
971    },
972    [titleEl, descEl]
973  );
974  return linkEl;
975}
976
977function createErrorCard(createElement, title, text) {
978  const descEl = createElement("p", {
979    class: "search-error-text",
980    dangerouslySetInnerHTML: {
981      __html: text,
982    },
983  });
984
985  const titleEl = createElement("p", {
986    class: "search-error-title",
987    dangerouslySetInnerHTML: {
988      __html: `<i class="bi bi-exclamation-circle search-error-icon"></i> ${title}`,
989    },
990  });
991  const errorEl = createElement("div", { class: "search-error" }, [
992    titleEl,
993    descEl,
994  ]);
995  return errorEl;
996}
997
998function positionPanel(pos) {
999  const panelEl = window.document.querySelector(
1000    "#quarto-search-results .aa-Panel"
1001  );
1002  const inputEl = window.document.querySelector(
1003    "#quarto-search .aa-Autocomplete"
1004  );
1005
1006  if (panelEl && inputEl) {
1007    panelEl.style.top = `${Math.round(panelEl.offsetTop)}px`;
1008    if (pos === "start") {
1009      panelEl.style.left = `${Math.round(inputEl.left)}px`;
1010    } else {
1011      panelEl.style.right = `${Math.round(inputEl.offsetRight)}px`;
1012    }
1013  }
1014}
1015
1016/* Highlighting */
1017// highlighting functions
1018function highlightMatch(query, text) {
1019  if (text) {
1020    const start = text.toLowerCase().indexOf(query.toLowerCase());
1021    if (start !== -1) {
1022      const startMark = "<mark class='search-match'>";
1023      const endMark = "</mark>";
1024
1025      const end = start + query.length;
1026      text =
1027        text.slice(0, start) +
1028        startMark +
1029        text.slice(start, end) +
1030        endMark +
1031        text.slice(end);
1032      const startInfo = clipStart(text, start);
1033      const endInfo = clipEnd(
1034        text,
1035        startInfo.position + startMark.length + endMark.length
1036      );
1037      text =
1038        startInfo.prefix +
1039        text.slice(startInfo.position, endInfo.position) +
1040        endInfo.suffix;
1041
1042      return text;
1043    } else {
1044      return text;
1045    }
1046  } else {
1047    return text;
1048  }
1049}
1050
1051function clipStart(text, pos) {
1052  const clipStart = pos - 50;
1053  if (clipStart < 0) {
1054    // This will just return the start of the string
1055    return {
1056      position: 0,
1057      prefix: "",
1058    };
1059  } else {
1060    // We're clipping before the start of the string, walk backwards to the first space.
1061    const spacePos = findSpace(text, pos, -1);
1062    return {
1063      position: spacePos.position,
1064      prefix: "",
1065    };
1066  }
1067}
1068
1069function clipEnd(text, pos) {
1070  const clipEnd = pos + 200;
1071  if (clipEnd > text.length) {
1072    return {
1073      position: text.length,
1074      suffix: "",
1075    };
1076  } else {
1077    const spacePos = findSpace(text, clipEnd, 1);
1078    return {
1079      position: spacePos.position,
1080      suffix: spacePos.clipped ? "…" : "",
1081    };
1082  }
1083}
1084
1085function findSpace(text, start, step) {
1086  let stepPos = start;
1087  while (stepPos > -1 && stepPos < text.length) {
1088    const char = text[stepPos];
1089    if (char === " " || char === "," || char === ":") {
1090      return {
1091        position: step === 1 ? stepPos : stepPos - step,
1092        clipped: stepPos > 1 && stepPos < text.length,
1093      };
1094    }
1095    stepPos = stepPos + step;
1096  }
1097
1098  return {
1099    position: stepPos - step,
1100    clipped: false,
1101  };
1102}
1103
1104// removes highlighting as implemented by the mark tag
1105function clearHighlight(searchterm, el) {
1106  const childNodes = el.childNodes;
1107  for (let i = childNodes.length - 1; i >= 0; i--) {
1108    const node = childNodes[i];
1109    if (node.nodeType === Node.ELEMENT_NODE) {
1110      if (
1111        node.tagName === "MARK" &&
1112        node.innerText.toLowerCase() === searchterm.toLowerCase()
1113      ) {
1114        el.replaceChild(document.createTextNode(node.innerText), node);
1115      } else {
1116        clearHighlight(searchterm, node);
1117      }
1118    }
1119  }
1120}
1121
1122/** Get all html nodes under the given `root` that don't have children. */
1123function getLeafNodes(root) {
1124  let leaves = [];
1125
1126  function traverse(node) {
1127    if (node.childNodes.length === 0) {
1128      leaves.push(node);
1129    } else {
1130      node.childNodes.forEach(traverse);
1131    }
1132  }
1133
1134  traverse(root);
1135  return leaves;
1136}
1137/** create and return `<mark>${txt}</mark>` */
1138const markEl = txt => {
1139  const el = document.createElement("mark");
1140  el.appendChild(document.createTextNode(txt));
1141  return el
1142}
1143/** get all ancestors of an element matching the given css selector */
1144const matchAncestors = (el, selector) => {
1145  let ancestors = [];
1146  while (el) {
1147    if (el.matches?.(selector)) ancestors.push(el);
1148    el = el.parentNode;
1149  }
1150  return ancestors;
1151};
1152
1153const isWhitespace = s => s.trim().length === 0
1154// =================
1155// MATCHING CODE
1156// =================
1157const initMatch = () => ({
1158  i: 0,
1159  lohisByNode: new Map()
1160})
1161/** 
1162 * keeps track of the start (lo) and end (hi) index of the match per node (leaf)
1163 * note: mutates the contents of `matchContext`
1164 */
1165const advanceMatch = (leaf, leafi, matchContext) => {
1166  matchContext.i++
1167
1168  const curLoHi = matchContext.lohisByNode.get(leaf)
1169
1170  matchContext.lohisByNode.set(leaf, { lo: curLoHi?.lo ?? leafi, hi: leafi })
1171}
1172/**
1173 * Finds all non-overlapping matches for a search string in the document. 
1174 * The search string may be split between multiple consecutive leaf nodes.
1175 * 
1176 * Whitespace in the search string must be present in the document to match, but
1177 * there may be addititional whitespace in the document that is ignored.
1178 * 
1179 * e.g. searching for `dogs rock` would match `dogs  \n <span> rock</span>`,
1180 * and would contribute the match 
1181 * `{ i:9, els: new Map([[textNode, {lo:0, hi:8}],[spanNode,{lo:0,hi:5}]]) }`
1182 * 
1183 * @returns {Map<HTMLElement,{lo:number,hi:number}>[]}
1184 */
1185function searchMatches(inSearch, el) {
1186  // searchText has all sequences of whitespace replaced by a single space
1187  const searchText = inSearch.toLowerCase().replace(/\s+/g, ' ')
1188  const leafNodes = getLeafNodes(el)
1189
1190  /** @type {Map<HTMLElement,{lo:number,hi:number}>[]} */
1191  const matches = []
1192  /** @type {{i:number; els:Map<HTMLElement,{lo:number,hi:number}>}[]} */
1193  let curMatchContext = initMatch()
1194
1195  for (const leaf of leafNodes) {
1196    const leafStr = leaf.textContent.toLowerCase()
1197    // for each character in this leaf's text:
1198    for (let leafi = 0; leafi < leafStr.length; leafi++) {
1199
1200      if (isWhitespace(leafStr[leafi])) {
1201        // if there is at least one whitespace in the document
1202        // we advance over a search text whitespace.
1203        if (isWhitespace(searchText[curMatchContext.i])) advanceMatch(leaf, leafi, curMatchContext)
1204        // all sequences of whitespace are otherwise ignored.
1205      } else {
1206        if (searchText[curMatchContext.i] === leafStr[leafi]) {
1207          advanceMatch(leaf, leafi, curMatchContext)
1208        } else {
1209          curMatchContext = initMatch()
1210          // if current character in the document did not match at i in the search text,
1211          // reset the search and see if that character matches at 0 in the search text.
1212          if (searchText[curMatchContext.i] === leafStr[leafi]) advanceMatch(leaf, leafi, curMatchContext)
1213        }
1214      }
1215
1216      const isMatchComplete = curMatchContext.i === searchText.length
1217      if (isMatchComplete) {
1218        matches.push(curMatchContext.lohisByNode)
1219        curMatchContext = initMatch()
1220      }
1221    }
1222  }
1223
1224  return matches
1225}
1226
1227/** 
1228 * e.g. `markMatches(myTextNode, [[0,5],[12,15]])` would wrap the
1229 * character sequences in myTextNode from 0-5 and 12-15 in marks.
1230 * Its important to mark all sequences in a text node at once
1231 * because this function replaces the entire text node; so any
1232 * other references to that text node will no longer be in the DOM.
1233 */
1234function markMatches(node, lohis) {
1235  const text = node.nodeValue
1236
1237  const markFragment = document.createDocumentFragment();
1238
1239  let prevHi = 0
1240  for (const [lo, hi] of lohis) {
1241    markFragment.append(
1242      document.createTextNode(text.slice(prevHi, lo)),
1243      markEl(text.slice(lo, hi + 1))
1244    )
1245    prevHi = hi + 1
1246  }
1247  markFragment.append(
1248    document.createTextNode(text.slice(prevHi, text.length))
1249  )
1250
1251  const parent = node.parentElement
1252  parent?.replaceChild(markFragment, node)
1253  return parent
1254}
1255
1256// Activate ancestor tabs so a search match inside an inactive pane becomes visible.
1257// When multiple panes in the same tabset contain matches, avoid switching away from
1258// the currently active pane — the user already sees a match there.
1259function openAllTabsetsContainingEl(el) {
1260  for (const pane of matchAncestors(el, '.tab-pane')) {
1261    const tabContent = pane.closest('.tab-content');
1262    if (!tabContent) continue;
1263    const activePane = tabContent.querySelector(':scope > .tab-pane.active');
1264    if (activePane?.querySelector('mark')) continue;
1265    const tabButton = document.querySelector(`[data-bs-target="#${pane.id}"]`);
1266    if (tabButton) new bootstrap.Tab(tabButton).show();
1267  }
1268}
1269
1270function scrollToFirstVisibleMatch(mainEl) {
1271  for (const mark of mainEl.querySelectorAll("mark")) {
1272    const isMarkVisible = matchAncestors(mark, '.tab-pane').every(markTabPane =>
1273      markTabPane.classList.contains("active")
1274    )
1275    if (isMarkVisible) {
1276      mark.scrollIntoView({ behavior: "smooth", block: "center" });
1277      return;
1278    }
1279  }
1280}
1281
1282/**
1283 * e.g.
1284 * ```js
1285 * const m = new Map()
1286 *
1287 * arrayMapPush(m, 'dog', 'Max')
1288 * console.log(m) // Map { dog->['Max'] }
1289 * 
1290 * arrayMapPush(m, 'dog', 'Samba')
1291 * arrayMapPush(m, 'cat', 'Scruffle')
1292 * console.log(m) // Map { dog->['Max', 'Samba'], cat->['Scruffle'] }
1293 * ```
1294 */
1295const arrayMapPush = (map, key, item) => {
1296  if (!map.has(key)) map.set(key, [])
1297  map.set(key, [...map.get(key), item])
1298}
1299
1300// copy&paste any string from a quarto page and
1301// this should find that string in the page and highlight it.
1302// exception: text that starts outside/inside a tabset and ends
1303// inside/outside that tabset. 
1304function highlight(searchStr, el) {
1305  const matches = searchMatches(searchStr, el);
1306
1307  const matchesGroupedByNode = new Map()
1308  for (const match of matches) {
1309    for (const [mel, { lo, hi }] of match) {
1310      arrayMapPush(matchesGroupedByNode, mel, [lo, hi])
1311    }
1312  }
1313
1314  for (const [node, lohis] of matchesGroupedByNode) {
1315    markMatches(node, lohis)
1316  }
1317}
1318
1319/* Link Handling */
1320// get the offset from this page for a given site root relative url
1321function offsetURL(url) {
1322  var offset = getMeta("quarto:offset");
1323  return offset ? offset + url : url;
1324}
1325
1326// read a meta tag value
1327function getMeta(metaName) {
1328  var metas = window.document.getElementsByTagName("meta");
1329  for (let i = 0; i < metas.length; i++) {
1330    if (metas[i].getAttribute("name") === metaName) {
1331      return metas[i].getAttribute("content");
1332    }
1333  }
1334  return "";
1335}
1336
1337function algoliaSearch(query, limit, algoliaOptions) {
1338  const { getAlgoliaResults } = window["@algolia/autocomplete-preset-algolia"];
1339
1340  const applicationId = algoliaOptions["application-id"];
1341  const searchOnlyApiKey = algoliaOptions["search-only-api-key"];
1342  const indexName = algoliaOptions["index-name"];
1343  const indexFields = algoliaOptions["index-fields"];
1344  const searchClient = window.algoliasearch(applicationId, searchOnlyApiKey);
1345  const searchParams = algoliaOptions["params"];
1346  const searchAnalytics = !!algoliaOptions["analytics-events"];
1347
1348  return getAlgoliaResults({
1349    searchClient,
1350    queries: [
1351      {
1352        indexName: indexName,
1353        query,
1354        params: {
1355          hitsPerPage: limit,
1356          clickAnalytics: searchAnalytics,
1357          ...searchParams,
1358        },
1359      },
1360    ],
1361    transformResponse: (response) => {
1362      if (!indexFields) {
1363        return response.hits.map((hit) => {
1364          return hit.map((item) => {
1365            return {
1366              ...item,
1367              text: highlightMatch(query, item.text),
1368            };
1369          });
1370        });
1371      } else {
1372        const remappedHits = response.hits.map((hit) => {
1373          return hit.map((item) => {
1374            const newItem = { ...item };
1375            ["href", "section", "title", "text", "crumbs"].forEach(
1376              (keyName) => {
1377                const mappedName = indexFields[keyName];
1378                if (
1379                  mappedName &&
1380                  item[mappedName] !== undefined &&
1381                  mappedName !== keyName
1382                ) {
1383                  newItem[keyName] = item[mappedName];
1384                  delete newItem[mappedName];
1385                }
1386              }
1387            );
1388            newItem.text = highlightMatch(query, newItem.text);
1389            return newItem;
1390          });
1391        });
1392        return remappedHits;
1393      }
1394    },
1395  });
1396}
1397
1398let subSearchTerm = undefined;
1399let subSearchFuse = undefined;
1400const kFuseMaxWait = 125;
1401
1402async function fuseSearch(query, fuse, fuseOptions) {
1403  let index = fuse;
1404  // Fuse.js using the Bitap algorithm for text matching which runs in
1405  // O(nm) time (no matter the structure of the text). In our case this
1406  // means that long search terms mixed with large index gets very slow
1407  //
1408  // This injects a subIndex that will be used once the terms get long enough
1409  // Usually making this subindex is cheap since there will typically be
1410  // a subset of results matching the existing query
1411  if (subSearchFuse !== undefined && query.startsWith(subSearchTerm)) {
1412    // Use the existing subSearchFuse
1413    index = subSearchFuse;
1414  } else if (subSearchFuse !== undefined) {
1415    // The term changed, discard the existing fuse
1416    subSearchFuse = undefined;
1417    subSearchTerm = undefined;
1418  }
1419
1420  // Search using the active fuse
1421  const then = performance.now();
1422  const resultsRaw = await index.search(query, fuseOptions);
1423  const now = performance.now();
1424
1425  const results = resultsRaw.map((result) => {
1426    const addParam = (url, name, value) => {
1427      const anchorParts = url.split("#");
1428      const baseUrl = anchorParts[0];
1429      const sep = baseUrl.search("\\?") > 0 ? "&" : "?";
1430      anchorParts[0] = baseUrl + sep + name + "=" + value;
1431      return anchorParts.join("#");
1432    };
1433
1434    return {
1435      title: result.item.title,
1436      section: result.item.section,
1437      href: addParam(result.item.href, kQueryArg, query),
1438      text: highlightMatch(query, result.item.text),
1439      crumbs: result.item.crumbs,
1440    };
1441  });
1442
1443  // If we don't have a subfuse and the query is long enough, go ahead
1444  // and create a subfuse to use for subsequent queries
1445  if (
1446    now - then > kFuseMaxWait &&
1447    subSearchFuse === undefined &&
1448    resultsRaw.length < fuseOptions.limit
1449  ) {
1450    subSearchTerm = query;
1451    subSearchFuse = new window.Fuse([], kFuseIndexOptions);
1452    resultsRaw.forEach((rr) => {
1453      subSearchFuse.add(rr.item);
1454    });
1455  }
1456  return results;
1457}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.