Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/resources/projects/website/search/quarto-search.js
12923 views
1
const kQueryArg = "q";
2
const 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
9
const 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.
15
const currentUrl = new URL(window.location);
16
const kQuery = currentUrl.searchParams.get(kQueryArg);
17
if (kQuery) {
18
const replacementUrl = new URL(window.location);
19
replacementUrl.searchParams.delete(kQueryArg);
20
window.history.replaceState({}, "", replacementUrl);
21
}
22
23
window.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-after"];
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
464
function 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.
543
function 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
595
function 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>_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
630
let lastQuery = null;
631
function 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
700
var fuseIndex = undefined;
701
var shownWarning = false;
702
703
// fuse index options
704
const 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
714
async 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
748
function inputElement() {
749
return window.document.body.querySelector(".aa-Form .aa-Input");
750
}
751
752
function focusSearchInput() {
753
setTimeout(() => {
754
const inputEl = inputElement();
755
if (inputEl) {
756
inputEl.focus();
757
}
758
}, 50);
759
}
760
761
/* Panels */
762
const kItemTypeDoc = "document";
763
const kItemTypeMore = "document-more";
764
const kItemTypeItem = "document-item";
765
const kItemTypeError = "error";
766
767
function 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
811
function 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
907
function 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
932
function 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
946
function 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
957
function 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
977
function 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
998
function 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
1018
function 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
1051
function 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
1069
function 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
1085
function 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
1105
function 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. */
1123
function 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>` */
1138
const 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 */
1144
const 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
1153
const isWhitespace = s => s.trim().length === 0
1154
// =================
1155
// MATCHING CODE
1156
// =================
1157
const 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
*/
1165
const 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
*/
1185
function 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
*/
1234
function 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.
1259
function 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
1270
function 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
*/
1295
const 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.
1304
function 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
1321
function offsetURL(url) {
1322
var offset = getMeta("quarto:offset");
1323
return offset ? offset + url : url;
1324
}
1325
1326
// read a meta tag value
1327
function 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
1337
function 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
1398
let subSearchTerm = undefined;
1399
let subSearchFuse = undefined;
1400
const kFuseMaxWait = 125;
1401
1402
async 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
}
1458
1459