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; 678async function readSearchData() { 679 // Initialize the search index on demand 680 if (fuseIndex === undefined) { 681 if (window.location.protocol === "file:" && !shownWarning) { 682 window.alert( 683 "Search requires JavaScript features disabled when running in file://... URLs. In order to use search, please run this document in a web server." 684 ); 685 shownWarning = true; 686 return; 687 } 688 // create fuse index 689 const options = { 690 keys: [ 691 { name: "title", weight: 20 }, 692 { name: "section", weight: 20 }, 693 { name: "text", weight: 10 }, 694 ], 695 ignoreLocation: true, 696 threshold: 0.1, 697 }; 698 const fuse = new window.Fuse([], options); 699 700 // fetch the main search.json 701 const response = await fetch(offsetURL("search.json")); 702 if (response.status == 200) { 703 return response.json().then(function (searchDocs) { 704 searchDocs.forEach(function (searchDoc) { 705 fuse.add(searchDoc); 706 }); 707 fuseIndex = fuse; 708 return fuseIndex; 709 }); 710 } else { 711 return Promise.reject( 712 new Error( 713 "Unexpected status from search index request: " + response.status 714 ) 715 ); 716 } 717 } 718 719 return fuseIndex; 720} 721 722function inputElement() { 723 return window.document.body.querySelector(".aa-Form .aa-Input"); 724} 725 726function focusSearchInput() { 727 setTimeout(() => { 728 const inputEl = inputElement(); 729 if (inputEl) { 730 inputEl.focus(); 731 } 732 }, 50); 733} 734 735/* Panels */ 736const kItemTypeDoc = "document"; 737const kItemTypeMore = "document-more"; 738const kItemTypeItem = "document-item"; 739const kItemTypeError = "error"; 740 741function renderItem( 742 item, 743 createElement, 744 state, 745 setActiveItemId, 746 setContext, 747 refresh, 748 quartoSearchOptions 749) { 750 switch (item.type) { 751 case kItemTypeDoc: 752 return createDocumentCard( 753 createElement, 754 "file-richtext", 755 item.title, 756 item.section, 757 item.text, 758 item.href, 759 item.crumbs, 760 quartoSearchOptions 761 ); 762 case kItemTypeMore: 763 return createMoreCard( 764 createElement, 765 item, 766 state,
767 setActiveItemId, 768 setContext, 769 refresh 770 ); 771 case kItemTypeItem: 772 return createSectionCard( 773 createElement, 774 item.section, 775 item.text, 776 item.href 777 ); 778 case kItemTypeError: 779 return createErrorCard(createElement, item.title, item.text); 780 default: 781 return undefined; 782 } 783} 784 785function createDocumentCard( 786 createElement, 787 icon, 788 title, 789 section, 790 text, 791 href, 792 crumbs, 793 quartoSearchOptions 794) { 795 const iconEl = createElement("i", { 796 class: `bi bi-${icon} search-result-icon`, 797 }); 798 const titleEl = createElement("p", { class: "search-result-title" }, title); 799 const titleContents = [iconEl, titleEl]; 800 const showParent = quartoSearchOptions["show-item-context"]; 801 if (crumbs && showParent) { 802 let crumbsOut = undefined; 803 const crumbClz = ["search-result-crumbs"]; 804 if (showParent === "root") { 805 crumbsOut = crumbs.length > 1 ? crumbs[0] : undefined; 806 } else if (showParent === "parent") { 807 crumbsOut = crumbs.length > 1 ? crumbs[crumbs.length - 2] : undefined; 808 } else { 809 crumbsOut = crumbs.length > 1 ? crumbs.join(" > ") : undefined; 810 crumbClz.push("search-result-crumbs-wrap"); 811 } 812 813 const crumbEl = createElement( 814 "p", 815 { class: crumbClz.join(" ") }, 816 crumbsOut 817 ); 818 titleContents.push(crumbEl); 819 } 820 821 const titleContainerEl = createElement( 822 "div", 823 { class: "search-result-title-container" }, 824 titleContents 825 ); 826 827 const textEls = []; 828 if (section) { 829 const sectionEl = createElement( 830 "p", 831 { class: "search-result-section" }, 832 section 833 ); 834 textEls.push(sectionEl); 835 } 836 const descEl = createElement("p", { 837 class: "search-result-text", 838 dangerouslySetInnerHTML: { 839 __html: text, 840 }, 841 }); 842 textEls.push(descEl); 843 844 const textContainerEl = createElement( 845 "div", 846 { class: "search-result-text-container" }, 847 textEls 848 ); 849 850 const containerEl = createElement( 851 "div", 852 { 853 class: "search-result-container", 854 }, 855 [titleContainerEl, textContainerEl] 856 ); 857 858 const linkEl = createElement( 859 "a", 860 { 861 href: offsetURL(href), 862 class: "search-result-link", 863 }, 864 containerEl 865 ); 866 867 const classes = ["search-result-doc", "search-item"]; 868 if (!section) { 869 classes.push("document-selectable"); 870 } 871 872 return createElement( 873 "div", 874 { 875 class: classes.join(" "), 876 }, 877 linkEl 878 ); 879} 880 881function createMoreCard( 882 createElement, 883 item, 884 state, 885 setActiveItemId, 886 setContext, 887 refresh 888) { 889 const moreCardEl = createElement( 890 "div", 891 { 892 class: "search-result-more search-item", 893 onClick: (e) => { 894 // Handle expanding the sections by adding the expanded 895 // section to the list of expanded sections 896 toggleExpanded(item, state, setContext, setActiveItemId, refresh); 897 e.stopPropagation(); 898 }, 899 }, 900 item.title 901 ); 902 903 return moreCardEl; 904} 905 906function toggleExpanded(item, state, setContext, setActiveItemId, refresh) { 907 const expanded = state.context.expanded || []; 908 if (expanded.includes(item.target)) { 909 setContext({ 910 expanded: expanded.filter((target) => target !== item.target), 911 }); 912 } else { 913 setContext({ expanded: [...expanded, item.target] }); 914 } 915 916 refresh(); 917 setActiveItemId(item.__autocomplete_id); 918} 919 920function createSectionCard(createElement, section, text, href) { 921 const sectionEl = createSection(createElement, section, text, href); 922 return createElement( 923 "div", 924 { 925 class: "search-result-doc-section search-item", 926 }, 927 sectionEl 928 ); 929} 930 931function createSection(createElement, title, text, href) { 932 const descEl = createElement("p", { 933 class: "search-result-text", 934 dangerouslySetInnerHTML: { 935 __html: text, 936 }, 937 }); 938 939 const titleEl = createElement("p", { class: "search-result-section" }, title); 940 const linkEl = createElement( 941 "a", 942 { 943 href: offsetURL(href), 944 class: "search-result-link", 945 }, 946 [titleEl, descEl] 947 ); 948 return linkEl; 949} 950 951function createErrorCard(createElement, title, text) { 952 const descEl = createElement("p", { 953 class: "search-error-text", 954 dangerouslySetInnerHTML: { 955 __html: text, 956 }, 957 }); 958 959 const titleEl = createElement("p", { 960 class: "search-error-title", 961 dangerouslySetInnerHTML: { 962 __html: `<i class="bi bi-exclamation-circle search-error-icon"></i> ${title}`, 963 }, 964 }); 965 const errorEl = createElement("div", { class: "search-error" }, [ 966 titleEl, 967 descEl, 968 ]); 969 return errorEl; 970} 971 972function positionPanel(pos) { 973 const panelEl = window.document.querySelector( 974 "#quarto-search-results .aa-Panel" 975 ); 976 const inputEl = window.document.querySelector( 977 "#quarto-search .aa-Autocomplete" 978 ); 979 980 if (panelEl && inputEl) { 981 panelEl.style.top = `${Math.round(panelEl.offsetTop)}px`; 982 if (pos === "start") { 983 panelEl.style.left = `${Math.round(inputEl.left)}px`; 984 } else { 985 panelEl.style.right = `${Math.round(inputEl.offsetRight)}px`; 986 } 987 } 988} 989 990/* Highlighting */ 991// highlighting functions 992function highlightMatch(query, text) { 993 if (text) { 994 const start = text.toLowerCase().indexOf(query.toLowerCase()); 995 if (start !== -1) { 996 const startMark = "<mark class='search-match'>"; 997 const endMark = "</mark>"; 998 999 const end = start + query.length; 1000 text = 1001 text.slice(0, start) + 1002 startMark + 1003 text.slice(start, end) + 1004 endMark + 1005 text.slice(end); 1006 const startInfo = clipStart(text, start); 1007 const endInfo = clipEnd( 1008 text, 1009 startInfo.position + startMark.length + endMark.length 1010 ); 1011 text = 1012 startInfo.prefix + 1013 text.slice(startInfo.position, endInfo.position) + 1014 endInfo.suffix; 1015 1016 return text; 1017 } else { 1018 return text; 1019 } 1020 } else { 1021 return text; 1022 } 1023} 1024 1025function clipStart(text, pos) { 1026 const clipStart = pos - 50; 1027 if (clipStart < 0) { 1028 // This will just return the start of the string 1029 return { 1030 position: 0, 1031 prefix: "", 1032 }; 1033 } else { 1034 // We're clipping before the start of the string, walk backwards to the first space. 1035 const spacePos = findSpace(text, pos, -1); 1036 return { 1037 position: spacePos.position, 1038 prefix: "", 1039 }; 1040 } 1041} 1042 1043function clipEnd(text, pos) { 1044 const clipEnd = pos + 200; 1045 if (clipEnd > text.length) { 1046 return { 1047 position: text.length, 1048 suffix: "", 1049 }; 1050 } else { 1051 const spacePos = findSpace(text, clipEnd, 1); 1052 return { 1053 position: spacePos.position, 1054 suffix: spacePos.clipped ? "â¦" : "", 1055 }; 1056 } 1057} 1058 1059function findSpace(text, start, step) { 1060 let stepPos = start; 1061 while (stepPos > -1 && stepPos < text.length) { 1062 const char = text[stepPos]; 1063 if (char === " " || char === "," || char === ":") { 1064 return { 1065 position: step === 1 ? stepPos : stepPos - step,
1066 clipped: stepPos > 1 && stepPos < text.length, 1067 }; 1068 } 1069 stepPos = stepPos + step; 1070 } 1071 1072 return { 1073 position: stepPos - step, 1074 clipped: false, 1075 }; 1076} 1077 1078// removes highlighting as implemented by the mark tag 1079function clearHighlight(searchterm, el) { 1080 const childNodes = el.childNodes; 1081 for (let i = childNodes.length - 1; i >= 0; i--) { 1082 const node = childNodes[i]; 1083 if (node.nodeType === Node.ELEMENT_NODE) { 1084 if ( 1085 node.tagName === "MARK" && 1086 node.innerText.toLowerCase() === searchterm.toLowerCase() 1087 ) { 1088 el.replaceChild(document.createTextNode(node.innerText), node); 1089 } else { 1090 clearHighlight(searchterm, node); 1091 } 1092 } 1093 } 1094} 1095 1096function escapeRegExp(string) { 1097 return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string 1098} 1099 1100// highlight matches 1101function highlight(term, el) { 1102 const termRegex = new RegExp(term, "ig"); 1103 const childNodes = el.childNodes; 1104 1105 // walk back to front avoid mutating elements in front of us 1106 for (let i = childNodes.length - 1; i >= 0; i--) { 1107 const node = childNodes[i]; 1108 1109 if (node.nodeType === Node.TEXT_NODE) { 1110 // Search text nodes for text to highlight 1111 const text = node.nodeValue; 1112 1113 let startIndex = 0; 1114 let matchIndex = text.search(termRegex); 1115 if (matchIndex > -1) { 1116 const markFragment = document.createDocumentFragment(); 1117 while (matchIndex > -1) { 1118 const prefix = text.slice(startIndex, matchIndex); 1119 markFragment.appendChild(document.createTextNode(prefix)); 1120 1121 const mark = document.createElement("mark"); 1122 mark.appendChild( 1123 document.createTextNode( 1124 text.slice(matchIndex, matchIndex + term.length) 1125 ) 1126 ); 1127 markFragment.appendChild(mark); 1128 1129 startIndex = matchIndex + term.length; 1130 matchIndex = text.slice(startIndex).search(new RegExp(term, "ig")); 1131 if (matchIndex > -1) { 1132 matchIndex = startIndex + matchIndex; 1133 } 1134 } 1135 if (startIndex < text.length) { 1136 markFragment.appendChild( 1137 document.createTextNode(text.slice(startIndex, text.length)) 1138 ); 1139 } 1140 1141 el.replaceChild(markFragment, node); 1142 } 1143 } else if (node.nodeType === Node.ELEMENT_NODE) { 1144 // recurse through elements 1145 highlight(term, node); 1146 } 1147 } 1148} 1149 1150/* Link Handling */ 1151// get the offset from this page for a given site root relative url 1152function offsetURL(url) { 1153 var offset = getMeta("quarto:offset"); 1154 return offset ? offset + url : url; 1155} 1156 1157// read a meta tag value 1158function getMeta(metaName) { 1159 var metas = window.document.getElementsByTagName("meta"); 1160 for (let i = 0; i < metas.length; i++) { 1161 if (metas[i].getAttribute("name") === metaName) { 1162 return metas[i].getAttribute("content"); 1163 } 1164 } 1165 return ""; 1166} 1167 1168function algoliaSearch(query, limit, algoliaOptions) { 1169 const { getAlgoliaResults } = window["@algolia/autocomplete-preset-algolia"]; 1170 1171 const applicationId = algoliaOptions["application-id"]; 1172 const searchOnlyApiKey = algoliaOptions["search-only-api-key"]; 1173 const indexName = algoliaOptions["index-name"]; 1174 const indexFields = algoliaOptions["index-fields"]; 1175 const searchClient = window.algoliasearch(applicationId, searchOnlyApiKey); 1176 const searchParams = algoliaOptions["params"]; 1177 const searchAnalytics = !!algoliaOptions["analytics-events"]; 1178 1179 return getAlgoliaResults({ 1180 searchClient, 1181 queries: [ 1182 { 1183 indexName: indexName, 1184 query, 1185 params: { 1186 hitsPerPage: limit, 1187 clickAnalytics: searchAnalytics, 1188 ...searchParams, 1189 }, 1190 }, 1191 ], 1192 transformResponse: (response) => { 1193 if (!indexFields) { 1194 return response.hits.map((hit) => { 1195 return hit.map((item) => { 1196 return { 1197 ...item, 1198 text: highlightMatch(query, item.text), 1199 }; 1200 }); 1201 }); 1202 } else { 1203 const remappedHits = response.hits.map((hit) => { 1204 return hit.map((item) => { 1205 const newItem = { ...item }; 1206 ["href", "section", "title", "text", "crumbs"].forEach( 1207 (keyName) => { 1208 const mappedName = indexFields[keyName]; 1209 if ( 1210 mappedName && 1211 item[mappedName] !== undefined && 1212 mappedName !== keyName 1213 ) { 1214 newItem[keyName] = item[mappedName]; 1215 delete newItem[mappedName]; 1216 } 1217 } 1218 ); 1219 newItem.text = highlightMatch(query, newItem.text); 1220 return newItem; 1221 }); 1222 }); 1223 return remappedHits; 1224 } 1225 }, 1226 }); 1227} 1228 1229function fuseSearch(query, fuse, fuseOptions) { 1230 return fuse.search(query, fuseOptions).map((result) => { 1231 const addParam = (url, name, value) => { 1232 const anchorParts = url.split("#"); 1233 const baseUrl = anchorParts[0]; 1234 const sep = baseUrl.search("\\?") > 0 ? "&" : "?"; 1235 anchorParts[0] = baseUrl + sep + name + "=" + value; 1236 return anchorParts.join("#"); 1237 }; 1238 1239 return { 1240 title: result.item.title, 1241 section: result.item.section, 1242 href: addParam(result.item.href, kQueryArg, query), 1243 text: highlightMatch(query, result.item.text), 1244 crumbs: result.item.crumbs, 1245 }; 1246 }); 1247}
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.