From 8c140fb151fb7a7e161cab245bab8b39d5a77ce2 Mon Sep 17 00:00:00 2001 From: elliot Date: Fri, 20 Feb 2026 14:34:44 -0500 Subject: [PATCH 1/2] Add q param to algolia search, add highlighting that works when search term is across multiple html nodes --- .../projects/website/search/quarto-search.js | 322 +++++++++--------- 1 file changed, 166 insertions(+), 156 deletions(-) diff --git a/src/resources/projects/website/search/quarto-search.js b/src/resources/projects/website/search/quarto-search.js index 1bd488f24b..0e305dec7b 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); @@ -359,22 +344,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 +1098,186 @@ 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; +} - for (const [, info] of sorted) { - if (info.activeHasMatch || !info.firstInactivePane) continue; +const isWhitespace = s => s.trim().length === 0 +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; +/** create and return `${txt}` */ +const markEl = txt => { + const el = document.createElement("mark"); + el.appendChild(document.createTextNode(txt)); + return el +} +/** + * 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; - } +/** 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; +}; +const 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 */ From ed9f95264c6d87047b474df5c8803d0a22dc477d Mon Sep 17 00:00:00 2001 From: elliot Date: Fri, 20 Feb 2026 14:50:42 -0500 Subject: [PATCH 2/2] Reorganize a lil --- .../projects/website/search/quarto-search.js | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/src/resources/projects/website/search/quarto-search.js b/src/resources/projects/website/search/quarto-search.js index 0e305dec7b..c67d7b1033 100644 --- a/src/resources/projects/website/search/quarto-search.js +++ b/src/resources/projects/website/search/quarto-search.js @@ -177,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, }); @@ -296,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"] ); @@ -1113,8 +1112,26 @@ function getLeafNodes(root) { 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; +}; const isWhitespace = s => s.trim().length === 0 +// ================= +// MATCHING CODE +// ================= const initMatch = () => ({ i: 0, lohisByNode: new Map() @@ -1185,12 +1202,6 @@ function searchMatches(inSearch, el) { return matches } -/** create and return `${txt}` */ -const markEl = txt => { - const el = document.createElement("mark"); - el.appendChild(document.createTextNode(txt)); - return el -} /** * e.g. `markMatches(myTextNode, [[0,5],[12,15]])` would wrap the * character sequences in myTextNode from 0-5 and 12-15 in marks. @@ -1220,16 +1231,7 @@ function markMatches(node, lohis) { return parent } -/** 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; -}; -const openAllTabsetsContainingEl = el => { +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();