-
Notifications
You must be signed in to change notification settings - Fork 409
Add q param to algolia search, fix search term highlighting across html nodes #14080
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 bla<mark class='search-match'>def fiz</mark> bla bla" | ||
| const fullMatches = item.text.matchAll(/<mark class='search-match'>(.*?)<\/mark>/g) | ||
| // extract capture group with the search match | ||
| // result e.g. ["def fiz"] | ||
| const searchMatches = [...fullMatches].map(match => match[1]) | ||
| if (searchMatches[0]) { | ||
|
Comment on lines
-365
to
-369
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We no longer use the matching text in the search, like we did for text fragments. Instead, for the |
||
| 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 <mark> 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) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replaced with |
||
| 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 `<mark>${txt}</mark>` */ | ||
| 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 <span> rock</span>`, | ||
| * and would contribute the match | ||
| * `{ i:9, els: new Map([[textNode, {lo:0, hi:8}],[spanNode,{lo:0,hi:5}]]) }` | ||
| * | ||
| * @returns {Map<HTMLElement,{lo:number,hi:number}>[]} | ||
| */ | ||
| 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<HTMLElement,{lo:number,hi:number}>[]} */ | ||
| const matches = [] | ||
| /** @type {{i:number; els:Map<HTMLElement,{lo:number,hi:number}>}[]} */ | ||
| 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 */ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this was moved inside of
highlight