diff --git a/src/resources/projects/website/search/quarto-search.js b/src/resources/projects/website/search/quarto-search.js
index 1bd488f24b..c67d7b1033 100644
--- a/src/resources/projects/website/search/quarto-search.js
+++ b/src/resources/projects/website/search/quarto-search.js
@@ -45,22 +45,7 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
// highlight matches on the page
if (query && mainEl) {
// perform any highlighting
- highlight(escapeRegExp(query), mainEl);
-
- // Activate tabs that contain highlighted matches on pageshow rather than
- // DOMContentLoaded. tabsets.js (loaded as a module) registers its pageshow
- // handler during module execution, before DOMContentLoaded. By registering
- // ours during DOMContentLoaded, listener ordering guarantees we run after
- // tabsets.js restores tab state from localStorage — so search activation
- // wins over stored tab preference.
- window.addEventListener("pageshow", function (event) {
- if (!event.persisted) {
- activateTabsWithMatches(mainEl);
- // Let the browser settle layout after Bootstrap tab transitions
- // before calculating scroll position.
- requestAnimationFrame(() => scrollToFirstMatch(mainEl));
- }
- }, { once: true });
+ highlight(query, mainEl);
// fix up the URL to remove the q query param
const replacementUrl = new URL(window.location);
@@ -192,8 +177,8 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
title: isExpanded
? language["search-hide-matches-text"]
: remainingCount === 1
- ? `${remainingCount} ${language["search-more-match-text"]}`
- : `${remainingCount} ${language["search-more-matches-text"]}`,
+ ? `${remainingCount} ${language["search-more-match-text"]}`
+ : `${remainingCount} ${language["search-more-matches-text"]}`,
type: kItemTypeMore,
href: kItemTypeMoreHref,
});
@@ -311,9 +296,8 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
return createElement(
"div",
{
- class: `quarto-search-no-results${
- hasQuery ? "" : " no-query"
- }`,
+ class: `quarto-search-no-results${hasQuery ? "" : " no-query"
+ }`,
},
language["search-no-results-text"]
);
@@ -359,22 +343,12 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
},
item({ item, createElement }) {
- // process items to include text fragments as they are rendered
- if (item.text && item.href && !item.href.includes(':~:text=')) {
- // e.g. `item.text` for a search "def fiz": "bla bla bladef fiz bla bla"
- const fullMatches = item.text.matchAll(/(.*?)<\/mark>/g)
- // extract capture group with the search match
- // result e.g. ["def fiz"]
- const searchMatches = [...fullMatches].map(match => match[1])
- if (searchMatches[0]) {
- if (item.href.includes('#')) {
- item.href += ':~:text=' + encodeURIComponent(searchMatches[0])
- } else {
- item.href += '#:~:text=' + encodeURIComponent(searchMatches[0])
- }
- }
+ if (item.text && item.href && !item.href.includes('?q=')) {
+ const [main, hash] = item.href.split('#')
+ const hashAppend = hash ? '#' + hash : ''
+ item.href = main + '?q=' + encodeURIComponent(state.query) + hashAppend
}
-
+
return renderItem(
item,
createElement,
@@ -1123,151 +1097,189 @@ function clearHighlight(searchterm, el) {
}
}
-function escapeRegExp(string) {
- return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
-}
-
-// After search highlighting, activate any tabs whose panes contain matches.
-// This ensures that search results inside inactive Bootstrap tabs become visible.
-// Handles nested tabsets by walking up ancestor panes and activating outermost first.
-function activateTabsWithMatches(mainEl) {
- if (typeof bootstrap === "undefined") return;
-
- const marks = mainEl.querySelectorAll("mark");
- if (marks.length === 0) return;
-
- // Collect all tab panes that contain marks, including ancestor panes for nesting.
- // Group by their parent tabset (.tab-content container).
- const tabsetMatches = new Map();
-
- const recordPane = (pane) => {
- const tabContent = pane.closest(".tab-content");
- if (!tabContent) return;
- if (!tabsetMatches.has(tabContent)) {
- tabsetMatches.set(tabContent, { activeHasMatch: false, firstInactivePane: null });
- }
- const info = tabsetMatches.get(tabContent);
- if (pane.classList.contains("active")) {
- info.activeHasMatch = true;
- } else if (!info.firstInactivePane) {
- info.firstInactivePane = pane;
- }
- };
+/** Get all html nodes under the given `root` that don't have children. */
+function getLeafNodes(root) {
+ let leaves = [];
- for (const mark of marks) {
- // Walk up all ancestor tab panes (handles nested tabsets)
- let pane = mark.closest(".tab-pane");
- while (pane) {
- recordPane(pane);
- pane = pane.parentElement?.closest(".tab-pane") ?? null;
+ function traverse(node) {
+ if (node.childNodes.length === 0) {
+ leaves.push(node);
+ } else {
+ node.childNodes.forEach(traverse);
}
}
- // Sort tabsets by DOM depth (outermost first) so outer tabs activate before inner
- const sorted = [...tabsetMatches.entries()].sort((a, b) => {
- const depthA = ancestorCount(a[0], mainEl);
- const depthB = ancestorCount(b[0], mainEl);
- return depthA - depthB;
- });
+ traverse(root);
+ return leaves;
+}
+/** create and return `${txt}` */
+const markEl = txt => {
+ const el = document.createElement("mark");
+ el.appendChild(document.createTextNode(txt));
+ return el
+}
+/** get all ancestors of an element matching the given css selector */
+const matchAncestors = (el, selector) => {
+ let ancestors = [];
+ while (el) {
+ if (el.matches?.(selector)) ancestors.push(el);
+ el = el.parentNode;
+ }
+ return ancestors;
+};
- for (const [, info] of sorted) {
- if (info.activeHasMatch || !info.firstInactivePane) continue;
+const isWhitespace = s => s.trim().length === 0
+// =================
+// MATCHING CODE
+// =================
+const initMatch = () => ({
+ i: 0,
+ lohisByNode: new Map()
+})
+/**
+ * keeps track of the start (lo) and end (hi) index of the match per node (leaf)
+ * note: mutates the contents of `matchContext`
+ */
+const advanceMatch = (leaf, leafi, matchContext) => {
+ matchContext.i++
+
+ const curLoHi = matchContext.lohisByNode.get(leaf)
+
+ matchContext.lohisByNode.set(leaf, { lo: curLoHi?.lo ?? leafi, hi: leafi })
+}
+/**
+ * Finds all non-overlapping matches for a search string in the document.
+ * The search string may be split between multiple consecutive leaf nodes.
+ *
+ * Whitespace in the search string must be present in the document to match, but
+ * there may be addititional whitespace in the document that is ignored.
+ *
+ * e.g. searching for `dogs rock` would match `dogs \n rock`,
+ * and would contribute the match
+ * `{ i:9, els: new Map([[textNode, {lo:0, hi:8}],[spanNode,{lo:0,hi:5}]]) }`
+ *
+ * @returns {Map[]}
+ */
+function searchMatches(inSearch, el) {
+ // searchText has all sequences of whitespace replaced by a single space
+ const searchText = inSearch.toLowerCase().replace(/\s+/g, ' ')
+ const leafNodes = getLeafNodes(el)
+
+ /** @type {Map[]} */
+ const matches = []
+ /** @type {{i:number; els:Map}[]} */
+ let curMatchContext = initMatch()
+
+ for (leaf of leafNodes) {
+ const leafStr = leaf.textContent.toLowerCase()
+ // for each character in this leaf's text:
+ for (let leafi = 0; leafi < leafStr.length; leafi++) {
+
+ if (isWhitespace(leafStr[leafi])) {
+ // if there is at least one whitespace in the document
+ // we advance over a search text whitespace.
+ if (isWhitespace(searchText[curMatchContext.i])) advanceMatch(leaf, leafi, curMatchContext)
+ // all sequences of whitespace are otherwise ignored.
+ } else {
+ if (searchText[curMatchContext.i] === leafStr[leafi]) {
+ advanceMatch(leaf, leafi, curMatchContext)
+ } else {
+ curMatchContext = initMatch()
+ // if current character in the document did not match at i in the search text,
+ // reset the search and see if that character matches at 0 in the search text.
+ if (searchText[curMatchContext.i] === leafStr[leafi]) advanceMatch(leaf, leafi, curMatchContext)
+ }
+ }
- const escapedId = CSS.escape(info.firstInactivePane.id);
- const tabButton = mainEl.querySelector(
- `[data-bs-toggle="tab"][data-bs-target="#${escapedId}"]`
- );
- if (tabButton) {
- try {
- new bootstrap.Tab(tabButton).show();
- } catch (e) {
- console.debug("Failed to activate tab for search match:", e);
+ const isMatchComplete = curMatchContext.i === searchText.length
+ if (isMatchComplete) {
+ matches.push(curMatchContext.lohisByNode)
+ curMatchContext = initMatch()
}
}
}
+
+ return matches
}
-function ancestorCount(el, stopAt) {
- let count = 0;
- let node = el.parentElement;
- while (node && node !== stopAt) {
- count++;
- node = node.parentElement;
+/**
+ * e.g. `markMatches(myTextNode, [[0,5],[12,15]])` would wrap the
+ * character sequences in myTextNode from 0-5 and 12-15 in marks.
+ * Its important to mark all sequences in a text node at once
+ * because this function replaces the entire text node; so any
+ * other references to that text node will no longer be in the DOM.
+ */
+function markMatches(node, lohis) {
+ const text = node.nodeValue
+
+ const markFragment = document.createDocumentFragment();
+
+ let prevHi = 0
+ for (const [lo, hi] of lohis) {
+ markFragment.append(
+ document.createTextNode(text.slice(prevHi, lo)),
+ markEl(text.slice(lo, hi + 1))
+ )
+ prevHi = hi + 1
}
- return count;
+ markFragment.append(
+ document.createTextNode(text.slice(prevHi, text.length))
+ )
+
+ const parent = node.parentElement
+ parent?.replaceChild(markFragment, node)
+ return parent
}
-// After tab activation, scroll to the first visible search match so the user
-// sees the highlighted result without manually scrolling.
-// Only checks tab-pane visibility (not collapsed callouts, details/summary, etc.)
-// since this runs specifically after tab activation for search results.
-function scrollToFirstMatch(mainEl) {
- const marks = mainEl.querySelectorAll("mark");
- for (const mark of marks) {
- let hidden = false;
- let el = mark.parentElement;
- while (el && el !== mainEl) {
- if (el.classList.contains("tab-pane") && !el.classList.contains("active")) {
- hidden = true;
- break;
- }
- el = el.parentElement;
- }
- if (!hidden) {
- mark.scrollIntoView({ block: "center" });
- return;
- }
+function openAllTabsetsContainingEl(el) {
+ for (const tab of matchAncestors(el, '.tab-pane')) {
+ const tabButton = document.querySelector(`[data-bs-target="#${tab.id}"]`);
+ if (tabButton) new bootstrap.Tab(tabButton).show();
}
}
-// highlight matches
-function highlight(term, el) {
- const termRegex = new RegExp(term, "ig");
- const childNodes = el.childNodes;
-
- // walk back to front avoid mutating elements in front of us
- for (let i = childNodes.length - 1; i >= 0; i--) {
- const node = childNodes[i];
-
- if (node.nodeType === Node.TEXT_NODE) {
- // Search text nodes for text to highlight
- const text = node.nodeValue;
-
- let startIndex = 0;
- let matchIndex = text.search(termRegex);
- if (matchIndex > -1) {
- const markFragment = document.createDocumentFragment();
- while (matchIndex > -1) {
- const prefix = text.slice(startIndex, matchIndex);
- markFragment.appendChild(document.createTextNode(prefix));
-
- const mark = document.createElement("mark");
- mark.appendChild(
- document.createTextNode(
- text.slice(matchIndex, matchIndex + term.length)
- )
- );
- markFragment.appendChild(mark);
-
- startIndex = matchIndex + term.length;
- matchIndex = text.slice(startIndex).search(new RegExp(term, "ig"));
- if (matchIndex > -1) {
- matchIndex = startIndex + matchIndex;
- }
- }
- if (startIndex < text.length) {
- markFragment.appendChild(
- document.createTextNode(text.slice(startIndex, text.length))
- );
- }
+/**
+ * e.g.
+ * ```js
+ * const m = new Map()
+ *
+ * arrayMapPush(m, 'dog', 'Max')
+ * console.log(m) // Map { dog->['Max'] }
+ *
+ * arrayMapPush(m, 'dog', 'Samba')
+ * arrayMapPush(m, 'cat', 'Scruffle')
+ * console.log(m) // Map { dog->['Max', 'Samba'], cat->['Scruffle'] }
+ * ```
+ */
+const arrayMapPush = (map, key, item) => {
+ if (!map.has(key)) map.set(key, [])
+ map.set(key, [...map.get(key), item])
+}
- el.replaceChild(markFragment, node);
- }
- } else if (node.nodeType === Node.ELEMENT_NODE) {
- // recurse through elements
- highlight(term, node);
+// copy&paste any string from a quarto page and
+// this should find that string in the page and highlight it.
+// exception: text that starts outside/inside a tabset and ends
+// inside/outside that tabset.
+function highlight(searchStr, el) {
+ const matches = searchMatches(searchStr, el);
+
+ const matchesGroupedByNode = new Map()
+ for (const match of matches) {
+ for (const [mel, { lo, hi }] of match) {
+ arrayMapPush(matchesGroupedByNode, mel, [lo, hi])
}
}
+
+ const matchNodes = [...matchesGroupedByNode].map(([node, lohis]) => {
+ const matchNode = markMatches(node, lohis)
+ openAllTabsetsContainingEl(matchNode)
+ return matchNode
+ })
+ // let things settle before scrolling
+ setTimeout(() =>
+ matchNodes[0]?.scrollIntoView({ behavior: 'smooth', block: 'center' }),
+ 400
+ )
}
/* Link Handling */