Path: blob/main/src/resources/projects/website/search/quarto-search.js
12923 views
const kQueryArg = "q";1const kResultsArg = "show-results";23// If items don't provide a URL, then both the navigator and the onSelect4// function aren't called (and therefore, the default implementation is used)5//6// We're using this sentinel URL to signal to those handlers that this7// item is a more item (along with the type) and can be handled appropriately8const kItemTypeMoreHref = "0767FDFD-0422-4E5A-BC8A-3BE11E5BBA05";910// Capture search params and clean ?q= from URL at module load time, before11// any DOMContentLoaded handlers run. quarto-nav.js resolves all <a> hrefs12// against window.location during DOMContentLoaded — if ?q= is still present,13// every link on the page gets the query param baked into its href.14const currentUrl = new URL(window.location);15const kQuery = currentUrl.searchParams.get(kQueryArg);16if (kQuery) {17const replacementUrl = new URL(window.location);18replacementUrl.searchParams.delete(kQueryArg);19window.history.replaceState({}, "", replacementUrl);20}2122window.document.addEventListener("DOMContentLoaded", function (_event) {23// Ensure that search is available on this page. If it isn't,24// should return early and not do anything25var searchEl = window.document.getElementById("quarto-search");26if (!searchEl) return;2728const { autocomplete } = window["@algolia/autocomplete-js"];2930let quartoSearchOptions = {};31let language = {};32const searchOptionEl = window.document.getElementById(33"quarto-search-options"34);35if (searchOptionEl) {36const jsonStr = searchOptionEl.textContent;37quartoSearchOptions = JSON.parse(jsonStr);38language = quartoSearchOptions.language;39}4041// note the search mode42if (quartoSearchOptions.type === "overlay") {43searchEl.classList.add("type-overlay");44} else {45searchEl.classList.add("type-textbox");46}4748// Used to determine highlighting behavior for this page49// A `q` query param is expected when the user follows a search50// to this page51const query = kQuery;52const showSearchResults = currentUrl.searchParams.get(kResultsArg);53const mainEl = window.document.querySelector("main");5455// highlight matches on the page56if (query && mainEl) {57highlight(query, mainEl);5859// Activate tabs on pageshow — after tabsets.js restores localStorage state.60// tabsets.js registers its pageshow handler during module execution (before61// DOMContentLoaded). By registering ours during DOMContentLoaded, listener62// ordering guarantees we run after tabsets.js — so search activation wins.63window.addEventListener("pageshow", function (event) {64if (!event.persisted) {65for (const mark of mainEl.querySelectorAll("mark")) {66openAllTabsetsContainingEl(mark);67}68// Only scroll to first match when there's no hash fragment.69// With a hash, the browser already scrolled to the target section.70if (!currentUrl.hash) {71requestAnimationFrame(() => scrollToFirstVisibleMatch(mainEl));72}73}74}, { once: true });75}7677// function to clear highlighting on the page when the search query changes78// (e.g. if the user edits the query or clears it)79let highlighting = true;80const resetHighlighting = (searchTerm) => {81if (mainEl && highlighting && query && searchTerm !== query) {82clearHighlight(query, mainEl);83highlighting = false;84}85};8687// Responsively switch to overlay mode if the search is present on the navbar88// Note that switching the sidebar to overlay mode requires more coordinate (not just89// the media query since we generate different HTML for sidebar overlays than we do90// for sidebar input UI)91const detachedMediaQuery =92quartoSearchOptions.type === "overlay" ? "all" : "(max-width: 991px)";9394// If configured, include the analytics client to send insights95const plugins = configurePlugins(quartoSearchOptions);9697let lastState = null;98const { setIsOpen, setQuery, setCollections } = autocomplete({99container: searchEl,100detachedMediaQuery: detachedMediaQuery,101defaultActiveItemId: 0,102panelContainer: "#quarto-search-results",103panelPlacement: quartoSearchOptions["panel-placement"],104debug: false,105openOnFocus: true,106plugins,107classNames: {108form: "d-flex",109},110placeholder: language["search-text-placeholder"],111translations: {112clearButtonTitle: language["search-clear-button-title"],113detachedCancelButtonText: language["search-detached-cancel-button-title"],114submitButtonTitle: language["search-submit-button-title"],115},116initialState: {117query,118},119getItemUrl({ item }) {120return item.href;121},122onStateChange({ state }) {123// If this is a file URL, note that124125// Perhaps reset highlighting126resetHighlighting(state.query);127128// If the panel just opened, ensure the panel is positioned properly129if (state.isOpen) {130if (lastState && !lastState.isOpen) {131setTimeout(() => {132positionPanel(quartoSearchOptions["panel-placement"]);133}, 150);134}135}136137// Perhaps show the copy link138showCopyLink(state.query, quartoSearchOptions);139140lastState = state;141},142reshape({ sources, state }) {143return sources.map((source) => {144try {145const items = source.getItems();146147// Validate the items148validateItems(items);149150// group the items by document151const groupedItems = new Map();152items.forEach((item) => {153const hrefParts = item.href.split("#");154const baseHref = hrefParts[0];155const isDocumentItem = hrefParts.length === 1;156157const items = groupedItems.get(baseHref);158if (!items) {159groupedItems.set(baseHref, [item]);160} else {161// If the href for this item matches the document162// exactly, place this item first as it is the item that represents163// the document itself164if (isDocumentItem) {165items.unshift(item);166} else {167items.push(item);168}169groupedItems.set(baseHref, items);170}171});172173const reshapedItems = [];174let count = 1;175for (const [_key, value] of groupedItems) {176const firstItem = value[0];177reshapedItems.push({178...firstItem,179type: kItemTypeDoc,180});181182const collapseMatches = quartoSearchOptions["collapse-after"];183const collapseCount =184typeof collapseMatches === "number" ? collapseMatches : 1;185186if (value.length > 1) {187const target = `search-more-${count}`;188const isExpanded =189state.context.expanded &&190state.context.expanded.includes(target);191192const remainingCount = value.length - collapseCount;193194for (let i = 1; i < value.length; i++) {195if (collapseMatches && i === collapseCount) {196reshapedItems.push({197target,198title: isExpanded199? language["search-hide-matches-text"]200: remainingCount === 1201? `${remainingCount} ${language["search-more-match-text"]}`202: `${remainingCount} ${language["search-more-matches-text"]}`,203type: kItemTypeMore,204href: kItemTypeMoreHref,205});206}207208if (isExpanded || !collapseMatches || i < collapseCount) {209reshapedItems.push({210...value[i],211type: kItemTypeItem,212target,213});214}215}216}217count += 1;218}219220return {221...source,222getItems() {223return reshapedItems;224},225};226} catch (error) {227// Some form of error occurred228return {229...source,230getItems() {231return [232{233title: error.name || "An Error Occurred While Searching",234text:235error.message ||236"An unknown error occurred while attempting to perform the requested search.",237type: kItemTypeError,238},239];240},241};242}243});244},245navigator: {246navigate({ itemUrl }) {247if (itemUrl !== offsetURL(kItemTypeMoreHref)) {248window.location.assign(itemUrl);249}250},251navigateNewTab({ itemUrl }) {252if (itemUrl !== offsetURL(kItemTypeMoreHref)) {253const windowReference = window.open(itemUrl, "_blank", "noopener");254if (windowReference) {255windowReference.focus();256}257}258},259navigateNewWindow({ itemUrl }) {260if (itemUrl !== offsetURL(kItemTypeMoreHref)) {261window.open(itemUrl, "_blank", "noopener");262}263},264},265getSources({ state, setContext, setActiveItemId, refresh }) {266return [267{268sourceId: "documents",269getItemUrl({ item }) {270if (item.href) {271return offsetURL(item.href);272} else {273return undefined;274}275},276onSelect({277item,278state,279setContext,280setIsOpen,281setActiveItemId,282refresh,283}) {284if (item.type === kItemTypeMore) {285toggleExpanded(item, state, setContext, setActiveItemId, refresh);286287// Toggle more288setIsOpen(true);289}290},291getItems({ query }) {292if (query === null || query === "") {293return [];294}295296const limit = quartoSearchOptions.limit;297if (quartoSearchOptions.algolia) {298return algoliaSearch(query, limit, quartoSearchOptions.algolia);299} else {300// Fuse search options301const fuseSearchOptions = {302isCaseSensitive: false,303shouldSort: true,304minMatchCharLength: 2,305limit: limit,306};307308return readSearchData().then(function (fuse) {309return fuseSearch(query, fuse, fuseSearchOptions);310});311}312},313templates: {314noResults({ createElement }) {315const hasQuery = lastState.query;316317return createElement(318"div",319{320class: `quarto-search-no-results${hasQuery ? "" : " no-query"321}`,322},323language["search-no-results-text"]324);325},326header({ items, createElement }) {327// count the documents328const count = items.filter((item) => {329return item.type === kItemTypeDoc;330}).length;331332if (count > 0) {333return createElement(334"div",335{ class: "search-result-header" },336`${count} ${language["search-matching-documents-text"]}`337);338} else {339return createElement(340"div",341{ class: "search-result-header-no-results" },342``343);344}345},346footer({ _items, createElement }) {347if (348quartoSearchOptions.algolia &&349quartoSearchOptions.algolia["show-logo"]350) {351const libDir = quartoSearchOptions.algolia["libDir"];352const logo = createElement("img", {353src: offsetURL(354`${libDir}/quarto-search/search-by-algolia.svg`355),356class: "algolia-search-logo",357});358return createElement(359"a",360{ href: "http://www.algolia.com/" },361logo362);363}364},365366item({ item, createElement }) {367if (item.text && item.href && !item.href.includes('?q=')) {368const [main, hash] = item.href.split('#')369const hashAppend = hash ? '#' + hash : ''370item.href = main + '?q=' + encodeURIComponent(state.query) + hashAppend371}372373return renderItem(374item,375createElement,376state,377setActiveItemId,378setContext,379refresh,380quartoSearchOptions381);382},383},384},385];386},387});388389window.quartoOpenSearch = () => {390setIsOpen(false);391setIsOpen(true);392focusSearchInput();393};394395document.addEventListener("keyup", (event) => {396const { key } = event;397const kbds = quartoSearchOptions["keyboard-shortcut"];398const focusedEl = document.activeElement;399400const isFormElFocused = [401"input",402"select",403"textarea",404"button",405"option",406].find((tag) => {407return focusedEl.tagName.toLowerCase() === tag;408});409410if (411kbds &&412kbds.includes(key) &&413!isFormElFocused &&414!document.activeElement.isContentEditable415) {416event.preventDefault();417window.quartoOpenSearch();418}419});420421// Remove the labeleledby attribute since it is pointing422// to a non-existent label423if (quartoSearchOptions.type === "overlay") {424const inputEl = window.document.querySelector(425"#quarto-search .aa-Autocomplete"426);427if (inputEl) {428inputEl.removeAttribute("aria-labelledby");429}430}431432function throttle(func, wait) {433let waiting = false;434return function () {435if (!waiting) {436func.apply(this, arguments);437waiting = true;438setTimeout(function () {439waiting = false;440}, wait);441}442};443}444445// If the main document scrolls dismiss the search results446// (otherwise, since they're floating in the document they can scroll with the document)447window.document.body.onscroll = throttle(() => {448// Only do this if we're not detached449// Bug #7117450// This will happen when the keyboard is shown on ios (resulting in a scroll)451// which then closed the search UI452if (!window.matchMedia(detachedMediaQuery).matches) {453setIsOpen(false);454}455}, 50);456457if (showSearchResults) {458setIsOpen(true);459focusSearchInput();460}461});462463function configurePlugins(quartoSearchOptions) {464const autocompletePlugins = [];465const algoliaOptions = quartoSearchOptions.algolia;466if (467algoliaOptions &&468algoliaOptions["analytics-events"] &&469algoliaOptions["search-only-api-key"] &&470algoliaOptions["application-id"]471) {472const apiKey = algoliaOptions["search-only-api-key"];473const appId = algoliaOptions["application-id"];474475// Aloglia insights may not be loaded because they require cookie consent476// Use deferred loading so events will start being recorded when/if consent477// is granted.478const algoliaInsightsDeferredPlugin = deferredLoadPlugin(() => {479if (480window.aa &&481window["@algolia/autocomplete-plugin-algolia-insights"]482) {483// Check if cookie consent is enabled from search options484const cookieConsentEnabled = algoliaOptions["cookie-consent-enabled"] || false;485486// Generate random session token only when cookies are disabled487const userToken = cookieConsentEnabled ? undefined : Array.from(Array(20), () =>488Math.floor(Math.random() * 36).toString(36)489).join("");490491window.aa("init", {492appId,493apiKey,494useCookie: cookieConsentEnabled,495userToken: userToken,496});497498const { createAlgoliaInsightsPlugin } =499window["@algolia/autocomplete-plugin-algolia-insights"];500// Register the insights client501const algoliaInsightsPlugin = createAlgoliaInsightsPlugin({502insightsClient: window.aa,503onItemsChange({ insights, insightsEvents }) {504const events = insightsEvents.flatMap((event) => {505// This API limits the number of items per event to 20506const chunkSize = 20;507const itemChunks = [];508const eventItems = event.items;509for (let i = 0; i < eventItems.length; i += chunkSize) {510itemChunks.push(eventItems.slice(i, i + chunkSize));511}512// Split the items into multiple events that can be sent513const events = itemChunks.map((items) => {514return {515...event,516items,517};518});519return events;520});521522for (const event of events) {523insights.viewedObjectIDs(event);524}525},526});527return algoliaInsightsPlugin;528}529});530531// Add the plugin532autocompletePlugins.push(algoliaInsightsDeferredPlugin);533return autocompletePlugins;534}535}536537// For plugins that may not load immediately, create a wrapper538// plugin and forward events and plugin data once the plugin539// is initialized. This is useful for cases like cookie consent540// which may prevent the analytics insights event plugin from initializing541// immediately.542function deferredLoadPlugin(createPlugin) {543let plugin = undefined;544let subscribeObj = undefined;545const wrappedPlugin = () => {546if (!plugin && subscribeObj) {547plugin = createPlugin();548if (plugin && plugin.subscribe) {549plugin.subscribe(subscribeObj);550}551}552return plugin;553};554555return {556subscribe: (obj) => {557subscribeObj = obj;558},559onStateChange: (obj) => {560const plugin = wrappedPlugin();561if (plugin && plugin.onStateChange) {562plugin.onStateChange(obj);563}564},565onSubmit: (obj) => {566const plugin = wrappedPlugin();567if (plugin && plugin.onSubmit) {568plugin.onSubmit(obj);569}570},571onReset: (obj) => {572const plugin = wrappedPlugin();573if (plugin && plugin.onReset) {574plugin.onReset(obj);575}576},577getSources: (obj) => {578const plugin = wrappedPlugin();579if (plugin && plugin.getSources) {580return plugin.getSources(obj);581} else {582return Promise.resolve([]);583}584},585data: (obj) => {586const plugin = wrappedPlugin();587if (plugin && plugin.data) {588plugin.data(obj);589}590},591};592}593594function validateItems(items) {595// Validate the first item596if (items.length > 0) {597const item = items[0];598const missingFields = [];599if (item.href == undefined) {600missingFields.push("href");601}602if (!item.title == undefined) {603missingFields.push("title");604}605if (!item.text == undefined) {606missingFields.push("text");607}608609if (missingFields.length === 1) {610throw {611name: `Error: Search index is missing the <code>${missingFields[0]}</code> field.`,612message: `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>_quarto.yml</code> file to specify the field names.`,613};614} else if (missingFields.length > 1) {615const missingFieldList = missingFields616.map((field) => {617return `<code>${field}</code>`;618})619.join(", ");620621throw {622name: `Error: Search index is missing the following fields: ${missingFieldList}.`,623message: `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.`,624};625}626}627}628629let lastQuery = null;630function showCopyLink(query, options) {631const language = options.language;632lastQuery = query;633// Insert share icon634const inputSuffixEl = window.document.body.querySelector(635".aa-Form .aa-InputWrapperSuffix"636);637638if (inputSuffixEl) {639let copyButtonEl = window.document.body.querySelector(640".aa-Form .aa-InputWrapperSuffix .aa-CopyButton"641);642643if (copyButtonEl === null) {644copyButtonEl = window.document.createElement("button");645copyButtonEl.setAttribute("class", "aa-CopyButton");646copyButtonEl.setAttribute("type", "button");647copyButtonEl.setAttribute("title", language["search-copy-link-title"]);648copyButtonEl.onmousedown = (e) => {649e.preventDefault();650e.stopPropagation();651};652653const linkIcon = "bi-clipboard";654const checkIcon = "bi-check2";655656const shareIconEl = window.document.createElement("i");657shareIconEl.setAttribute("class", `bi ${linkIcon}`);658copyButtonEl.appendChild(shareIconEl);659inputSuffixEl.prepend(copyButtonEl);660661const clipboard = new window.ClipboardJS(".aa-CopyButton", {662text: function (_trigger) {663const copyUrl = new URL(window.location);664copyUrl.searchParams.set(kQueryArg, lastQuery);665copyUrl.searchParams.set(kResultsArg, "1");666return copyUrl.toString();667},668});669clipboard.on("success", function (e) {670// Focus the input671672// button target673const button = e.trigger;674const icon = button.querySelector("i.bi");675676// flash "checked"677icon.classList.add(checkIcon);678icon.classList.remove(linkIcon);679setTimeout(function () {680icon.classList.remove(checkIcon);681icon.classList.add(linkIcon);682}, 1000);683});684}685686// If there is a query, show the link icon687if (copyButtonEl) {688if (lastQuery && options["copy-button"]) {689copyButtonEl.style.display = "flex";690} else {691copyButtonEl.style.display = "none";692}693}694}695}696697/* Search Index Handling */698// create the index699var fuseIndex = undefined;700var shownWarning = false;701702// fuse index options703const kFuseIndexOptions = {704keys: [705{ name: "title", weight: 20 },706{ name: "section", weight: 20 },707{ name: "text", weight: 10 },708],709ignoreLocation: true,710threshold: 0.1,711};712713async function readSearchData() {714// Initialize the search index on demand715if (fuseIndex === undefined) {716if (window.location.protocol === "file:" && !shownWarning) {717window.alert(718"Search requires JavaScript features disabled when running in file://... URLs. In order to use search, please run this document in a web server."719);720shownWarning = true;721return;722}723const fuse = new window.Fuse([], kFuseIndexOptions);724725// fetch the main search.json726const response = await fetch(offsetURL("search.json"));727if (response.status == 200) {728return response.json().then(function (searchDocs) {729searchDocs.forEach(function (searchDoc) {730fuse.add(searchDoc);731});732fuseIndex = fuse;733return fuseIndex;734});735} else {736return Promise.reject(737new Error(738"Unexpected status from search index request: " + response.status739)740);741}742}743744return fuseIndex;745}746747function inputElement() {748return window.document.body.querySelector(".aa-Form .aa-Input");749}750751function focusSearchInput() {752setTimeout(() => {753const inputEl = inputElement();754if (inputEl) {755inputEl.focus();756}757}, 50);758}759760/* Panels */761const kItemTypeDoc = "document";762const kItemTypeMore = "document-more";763const kItemTypeItem = "document-item";764const kItemTypeError = "error";765766function renderItem(767item,768createElement,769state,770setActiveItemId,771setContext,772refresh,773quartoSearchOptions774) {775switch (item.type) {776case kItemTypeDoc:777return createDocumentCard(778createElement,779"file-richtext",780item.title,781item.section,782item.text,783item.href,784item.crumbs,785quartoSearchOptions786);787case kItemTypeMore:788return createMoreCard(789createElement,790item,791state,792setActiveItemId,793setContext,794refresh795);796case kItemTypeItem:797return createSectionCard(798createElement,799item.section,800item.text,801item.href802);803case kItemTypeError:804return createErrorCard(createElement, item.title, item.text);805default:806return undefined;807}808}809810function createDocumentCard(811createElement,812icon,813title,814section,815text,816href,817crumbs,818quartoSearchOptions819) {820const iconEl = createElement("i", {821class: `bi bi-${icon} search-result-icon`,822});823const titleEl = createElement("p", { class: "search-result-title" }, title);824const titleContents = [iconEl, titleEl];825const showParent = quartoSearchOptions["show-item-context"];826if (crumbs && showParent) {827let crumbsOut = undefined;828const crumbClz = ["search-result-crumbs"];829if (showParent === "root") {830crumbsOut = crumbs.length > 1 ? crumbs[0] : undefined;831} else if (showParent === "parent") {832crumbsOut = crumbs.length > 1 ? crumbs[crumbs.length - 2] : undefined;833} else {834crumbsOut = crumbs.length > 1 ? crumbs.join(" > ") : undefined;835crumbClz.push("search-result-crumbs-wrap");836}837838const crumbEl = createElement(839"p",840{ class: crumbClz.join(" ") },841crumbsOut842);843titleContents.push(crumbEl);844}845846const titleContainerEl = createElement(847"div",848{ class: "search-result-title-container" },849titleContents850);851852const textEls = [];853if (section) {854const sectionEl = createElement(855"p",856{ class: "search-result-section" },857section858);859textEls.push(sectionEl);860}861const descEl = createElement("p", {862class: "search-result-text",863dangerouslySetInnerHTML: {864__html: text,865},866});867textEls.push(descEl);868869const textContainerEl = createElement(870"div",871{ class: "search-result-text-container" },872textEls873);874875const containerEl = createElement(876"div",877{878class: "search-result-container",879},880[titleContainerEl, textContainerEl]881);882883const linkEl = createElement(884"a",885{886href: offsetURL(href),887class: "search-result-link",888},889containerEl890);891892const classes = ["search-result-doc", "search-item"];893if (!section) {894classes.push("document-selectable");895}896897return createElement(898"div",899{900class: classes.join(" "),901},902linkEl903);904}905906function createMoreCard(907createElement,908item,909state,910setActiveItemId,911setContext,912refresh913) {914const moreCardEl = createElement(915"div",916{917class: "search-result-more search-item",918onClick: (e) => {919// Handle expanding the sections by adding the expanded920// section to the list of expanded sections921toggleExpanded(item, state, setContext, setActiveItemId, refresh);922e.stopPropagation();923},924},925item.title926);927928return moreCardEl;929}930931function toggleExpanded(item, state, setContext, setActiveItemId, refresh) {932const expanded = state.context.expanded || [];933if (expanded.includes(item.target)) {934setContext({935expanded: expanded.filter((target) => target !== item.target),936});937} else {938setContext({ expanded: [...expanded, item.target] });939}940941refresh();942setActiveItemId(item.__autocomplete_id);943}944945function createSectionCard(createElement, section, text, href) {946const sectionEl = createSection(createElement, section, text, href);947return createElement(948"div",949{950class: "search-result-doc-section search-item",951},952sectionEl953);954}955956function createSection(createElement, title, text, href) {957const descEl = createElement("p", {958class: "search-result-text",959dangerouslySetInnerHTML: {960__html: text,961},962});963964const titleEl = createElement("p", { class: "search-result-section" }, title);965const linkEl = createElement(966"a",967{968href: offsetURL(href),969class: "search-result-link",970},971[titleEl, descEl]972);973return linkEl;974}975976function createErrorCard(createElement, title, text) {977const descEl = createElement("p", {978class: "search-error-text",979dangerouslySetInnerHTML: {980__html: text,981},982});983984const titleEl = createElement("p", {985class: "search-error-title",986dangerouslySetInnerHTML: {987__html: `<i class="bi bi-exclamation-circle search-error-icon"></i> ${title}`,988},989});990const errorEl = createElement("div", { class: "search-error" }, [991titleEl,992descEl,993]);994return errorEl;995}996997function positionPanel(pos) {998const panelEl = window.document.querySelector(999"#quarto-search-results .aa-Panel"1000);1001const inputEl = window.document.querySelector(1002"#quarto-search .aa-Autocomplete"1003);10041005if (panelEl && inputEl) {1006panelEl.style.top = `${Math.round(panelEl.offsetTop)}px`;1007if (pos === "start") {1008panelEl.style.left = `${Math.round(inputEl.left)}px`;1009} else {1010panelEl.style.right = `${Math.round(inputEl.offsetRight)}px`;1011}1012}1013}10141015/* Highlighting */1016// highlighting functions1017function highlightMatch(query, text) {1018if (text) {1019const start = text.toLowerCase().indexOf(query.toLowerCase());1020if (start !== -1) {1021const startMark = "<mark class='search-match'>";1022const endMark = "</mark>";10231024const end = start + query.length;1025text =1026text.slice(0, start) +1027startMark +1028text.slice(start, end) +1029endMark +1030text.slice(end);1031const startInfo = clipStart(text, start);1032const endInfo = clipEnd(1033text,1034startInfo.position + startMark.length + endMark.length1035);1036text =1037startInfo.prefix +1038text.slice(startInfo.position, endInfo.position) +1039endInfo.suffix;10401041return text;1042} else {1043return text;1044}1045} else {1046return text;1047}1048}10491050function clipStart(text, pos) {1051const clipStart = pos - 50;1052if (clipStart < 0) {1053// This will just return the start of the string1054return {1055position: 0,1056prefix: "",1057};1058} else {1059// We're clipping before the start of the string, walk backwards to the first space.1060const spacePos = findSpace(text, pos, -1);1061return {1062position: spacePos.position,1063prefix: "",1064};1065}1066}10671068function clipEnd(text, pos) {1069const clipEnd = pos + 200;1070if (clipEnd > text.length) {1071return {1072position: text.length,1073suffix: "",1074};1075} else {1076const spacePos = findSpace(text, clipEnd, 1);1077return {1078position: spacePos.position,1079suffix: spacePos.clipped ? "…" : "",1080};1081}1082}10831084function findSpace(text, start, step) {1085let stepPos = start;1086while (stepPos > -1 && stepPos < text.length) {1087const char = text[stepPos];1088if (char === " " || char === "," || char === ":") {1089return {1090position: step === 1 ? stepPos : stepPos - step,1091clipped: stepPos > 1 && stepPos < text.length,1092};1093}1094stepPos = stepPos + step;1095}10961097return {1098position: stepPos - step,1099clipped: false,1100};1101}11021103// removes highlighting as implemented by the mark tag1104function clearHighlight(searchterm, el) {1105const childNodes = el.childNodes;1106for (let i = childNodes.length - 1; i >= 0; i--) {1107const node = childNodes[i];1108if (node.nodeType === Node.ELEMENT_NODE) {1109if (1110node.tagName === "MARK" &&1111node.innerText.toLowerCase() === searchterm.toLowerCase()1112) {1113el.replaceChild(document.createTextNode(node.innerText), node);1114} else {1115clearHighlight(searchterm, node);1116}1117}1118}1119}11201121/** Get all html nodes under the given `root` that don't have children. */1122function getLeafNodes(root) {1123let leaves = [];11241125function traverse(node) {1126if (node.childNodes.length === 0) {1127leaves.push(node);1128} else {1129node.childNodes.forEach(traverse);1130}1131}11321133traverse(root);1134return leaves;1135}1136/** create and return `<mark>${txt}</mark>` */1137const markEl = txt => {1138const el = document.createElement("mark");1139el.appendChild(document.createTextNode(txt));1140return el1141}1142/** get all ancestors of an element matching the given css selector */1143const matchAncestors = (el, selector) => {1144let ancestors = [];1145while (el) {1146if (el.matches?.(selector)) ancestors.push(el);1147el = el.parentNode;1148}1149return ancestors;1150};11511152const isWhitespace = s => s.trim().length === 01153// =================1154// MATCHING CODE1155// =================1156const initMatch = () => ({1157i: 0,1158lohisByNode: new Map()1159})1160/**1161* keeps track of the start (lo) and end (hi) index of the match per node (leaf)1162* note: mutates the contents of `matchContext`1163*/1164const advanceMatch = (leaf, leafi, matchContext) => {1165matchContext.i++11661167const curLoHi = matchContext.lohisByNode.get(leaf)11681169matchContext.lohisByNode.set(leaf, { lo: curLoHi?.lo ?? leafi, hi: leafi })1170}1171/**1172* Finds all non-overlapping matches for a search string in the document.1173* The search string may be split between multiple consecutive leaf nodes.1174*1175* Whitespace in the search string must be present in the document to match, but1176* there may be addititional whitespace in the document that is ignored.1177*1178* e.g. searching for `dogs rock` would match `dogs \n <span> rock</span>`,1179* and would contribute the match1180* `{ i:9, els: new Map([[textNode, {lo:0, hi:8}],[spanNode,{lo:0,hi:5}]]) }`1181*1182* @returns {Map<HTMLElement,{lo:number,hi:number}>[]}1183*/1184function searchMatches(inSearch, el) {1185// searchText has all sequences of whitespace replaced by a single space1186const searchText = inSearch.toLowerCase().replace(/\s+/g, ' ')1187const leafNodes = getLeafNodes(el)11881189/** @type {Map<HTMLElement,{lo:number,hi:number}>[]} */1190const matches = []1191/** @type {{i:number; els:Map<HTMLElement,{lo:number,hi:number}>}[]} */1192let curMatchContext = initMatch()11931194for (const leaf of leafNodes) {1195const leafStr = leaf.textContent.toLowerCase()1196// for each character in this leaf's text:1197for (let leafi = 0; leafi < leafStr.length; leafi++) {11981199if (isWhitespace(leafStr[leafi])) {1200// if there is at least one whitespace in the document1201// we advance over a search text whitespace.1202if (isWhitespace(searchText[curMatchContext.i])) advanceMatch(leaf, leafi, curMatchContext)1203// all sequences of whitespace are otherwise ignored.1204} else {1205if (searchText[curMatchContext.i] === leafStr[leafi]) {1206advanceMatch(leaf, leafi, curMatchContext)1207} else {1208curMatchContext = initMatch()1209// if current character in the document did not match at i in the search text,1210// reset the search and see if that character matches at 0 in the search text.1211if (searchText[curMatchContext.i] === leafStr[leafi]) advanceMatch(leaf, leafi, curMatchContext)1212}1213}12141215const isMatchComplete = curMatchContext.i === searchText.length1216if (isMatchComplete) {1217matches.push(curMatchContext.lohisByNode)1218curMatchContext = initMatch()1219}1220}1221}12221223return matches1224}12251226/**1227* e.g. `markMatches(myTextNode, [[0,5],[12,15]])` would wrap the1228* character sequences in myTextNode from 0-5 and 12-15 in marks.1229* Its important to mark all sequences in a text node at once1230* because this function replaces the entire text node; so any1231* other references to that text node will no longer be in the DOM.1232*/1233function markMatches(node, lohis) {1234const text = node.nodeValue12351236const markFragment = document.createDocumentFragment();12371238let prevHi = 01239for (const [lo, hi] of lohis) {1240markFragment.append(1241document.createTextNode(text.slice(prevHi, lo)),1242markEl(text.slice(lo, hi + 1))1243)1244prevHi = hi + 11245}1246markFragment.append(1247document.createTextNode(text.slice(prevHi, text.length))1248)12491250const parent = node.parentElement1251parent?.replaceChild(markFragment, node)1252return parent1253}12541255// Activate ancestor tabs so a search match inside an inactive pane becomes visible.1256// When multiple panes in the same tabset contain matches, avoid switching away from1257// the currently active pane — the user already sees a match there.1258function openAllTabsetsContainingEl(el) {1259for (const pane of matchAncestors(el, '.tab-pane')) {1260const tabContent = pane.closest('.tab-content');1261if (!tabContent) continue;1262const activePane = tabContent.querySelector(':scope > .tab-pane.active');1263if (activePane?.querySelector('mark')) continue;1264const tabButton = document.querySelector(`[data-bs-target="#${pane.id}"]`);1265if (tabButton) new bootstrap.Tab(tabButton).show();1266}1267}12681269function scrollToFirstVisibleMatch(mainEl) {1270for (const mark of mainEl.querySelectorAll("mark")) {1271const isMarkVisible = matchAncestors(mark, '.tab-pane').every(markTabPane =>1272markTabPane.classList.contains("active")1273)1274if (isMarkVisible) {1275mark.scrollIntoView({ behavior: "smooth", block: "center" });1276return;1277}1278}1279}12801281/**1282* e.g.1283* ```js1284* const m = new Map()1285*1286* arrayMapPush(m, 'dog', 'Max')1287* console.log(m) // Map { dog->['Max'] }1288*1289* arrayMapPush(m, 'dog', 'Samba')1290* arrayMapPush(m, 'cat', 'Scruffle')1291* console.log(m) // Map { dog->['Max', 'Samba'], cat->['Scruffle'] }1292* ```1293*/1294const arrayMapPush = (map, key, item) => {1295if (!map.has(key)) map.set(key, [])1296map.set(key, [...map.get(key), item])1297}12981299// copy&paste any string from a quarto page and1300// this should find that string in the page and highlight it.1301// exception: text that starts outside/inside a tabset and ends1302// inside/outside that tabset.1303function highlight(searchStr, el) {1304const matches = searchMatches(searchStr, el);13051306const matchesGroupedByNode = new Map()1307for (const match of matches) {1308for (const [mel, { lo, hi }] of match) {1309arrayMapPush(matchesGroupedByNode, mel, [lo, hi])1310}1311}13121313for (const [node, lohis] of matchesGroupedByNode) {1314markMatches(node, lohis)1315}1316}13171318/* Link Handling */1319// get the offset from this page for a given site root relative url1320function offsetURL(url) {1321var offset = getMeta("quarto:offset");1322return offset ? offset + url : url;1323}13241325// read a meta tag value1326function getMeta(metaName) {1327var metas = window.document.getElementsByTagName("meta");1328for (let i = 0; i < metas.length; i++) {1329if (metas[i].getAttribute("name") === metaName) {1330return metas[i].getAttribute("content");1331}1332}1333return "";1334}13351336function algoliaSearch(query, limit, algoliaOptions) {1337const { getAlgoliaResults } = window["@algolia/autocomplete-preset-algolia"];13381339const applicationId = algoliaOptions["application-id"];1340const searchOnlyApiKey = algoliaOptions["search-only-api-key"];1341const indexName = algoliaOptions["index-name"];1342const indexFields = algoliaOptions["index-fields"];1343const searchClient = window.algoliasearch(applicationId, searchOnlyApiKey);1344const searchParams = algoliaOptions["params"];1345const searchAnalytics = !!algoliaOptions["analytics-events"];13461347return getAlgoliaResults({1348searchClient,1349queries: [1350{1351indexName: indexName,1352query,1353params: {1354hitsPerPage: limit,1355clickAnalytics: searchAnalytics,1356...searchParams,1357},1358},1359],1360transformResponse: (response) => {1361if (!indexFields) {1362return response.hits.map((hit) => {1363return hit.map((item) => {1364return {1365...item,1366text: highlightMatch(query, item.text),1367};1368});1369});1370} else {1371const remappedHits = response.hits.map((hit) => {1372return hit.map((item) => {1373const newItem = { ...item };1374["href", "section", "title", "text", "crumbs"].forEach(1375(keyName) => {1376const mappedName = indexFields[keyName];1377if (1378mappedName &&1379item[mappedName] !== undefined &&1380mappedName !== keyName1381) {1382newItem[keyName] = item[mappedName];1383delete newItem[mappedName];1384}1385}1386);1387newItem.text = highlightMatch(query, newItem.text);1388return newItem;1389});1390});1391return remappedHits;1392}1393},1394});1395}13961397let subSearchTerm = undefined;1398let subSearchFuse = undefined;1399const kFuseMaxWait = 125;14001401async function fuseSearch(query, fuse, fuseOptions) {1402let index = fuse;1403// Fuse.js using the Bitap algorithm for text matching which runs in1404// O(nm) time (no matter the structure of the text). In our case this1405// means that long search terms mixed with large index gets very slow1406//1407// This injects a subIndex that will be used once the terms get long enough1408// Usually making this subindex is cheap since there will typically be1409// a subset of results matching the existing query1410if (subSearchFuse !== undefined && query.startsWith(subSearchTerm)) {1411// Use the existing subSearchFuse1412index = subSearchFuse;1413} else if (subSearchFuse !== undefined) {1414// The term changed, discard the existing fuse1415subSearchFuse = undefined;1416subSearchTerm = undefined;1417}14181419// Search using the active fuse1420const then = performance.now();1421const resultsRaw = await index.search(query, fuseOptions);1422const now = performance.now();14231424const results = resultsRaw.map((result) => {1425const addParam = (url, name, value) => {1426const anchorParts = url.split("#");1427const baseUrl = anchorParts[0];1428const sep = baseUrl.search("\\?") > 0 ? "&" : "?";1429anchorParts[0] = baseUrl + sep + name + "=" + value;1430return anchorParts.join("#");1431};14321433return {1434title: result.item.title,1435section: result.item.section,1436href: addParam(result.item.href, kQueryArg, query),1437text: highlightMatch(query, result.item.text),1438crumbs: result.item.crumbs,1439};1440});14411442// If we don't have a subfuse and the query is long enough, go ahead1443// and create a subfuse to use for subsequent queries1444if (1445now - then > kFuseMaxWait &&1446subSearchFuse === undefined &&1447resultsRaw.length < fuseOptions.limit1448) {1449subSearchTerm = query;1450subSearchFuse = new window.Fuse([], kFuseIndexOptions);1451resultsRaw.forEach((rr) => {1452subSearchFuse.add(rr.item);1453});1454}1455return results;1456}145714581459