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.