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