Skip to content

Commit 8c140fb

Browse files
committed
Add q param to algolia search, add highlighting that works when search term is across multiple html nodes
1 parent fd2eafc commit 8c140fb

1 file changed

Lines changed: 166 additions & 156 deletions

File tree

src/resources/projects/website/search/quarto-search.js

Lines changed: 166 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -45,22 +45,7 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
4545
// highlight matches on the page
4646
if (query && mainEl) {
4747
// perform any highlighting
48-
highlight(escapeRegExp(query), mainEl);
49-
50-
// Activate tabs that contain highlighted matches on pageshow rather than
51-
// DOMContentLoaded. tabsets.js (loaded as a module) registers its pageshow
52-
// handler during module execution, before DOMContentLoaded. By registering
53-
// ours during DOMContentLoaded, listener ordering guarantees we run after
54-
// tabsets.js restores tab state from localStorage — so search activation
55-
// wins over stored tab preference.
56-
window.addEventListener("pageshow", function (event) {
57-
if (!event.persisted) {
58-
activateTabsWithMatches(mainEl);
59-
// Let the browser settle layout after Bootstrap tab transitions
60-
// before calculating scroll position.
61-
requestAnimationFrame(() => scrollToFirstMatch(mainEl));
62-
}
63-
}, { once: true });
48+
highlight(query, mainEl);
6449

6550
// fix up the URL to remove the q query param
6651
const replacementUrl = new URL(window.location);
@@ -359,22 +344,12 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
359344
},
360345

361346
item({ item, createElement }) {
362-
// process items to include text fragments as they are rendered
363-
if (item.text && item.href && !item.href.includes(':~:text=')) {
364-
// e.g. `item.text` for a search "def fiz": "bla bla bla<mark class='search-match'>def fiz</mark> bla bla"
365-
const fullMatches = item.text.matchAll(/<mark class='search-match'>(.*?)<\/mark>/g)
366-
// extract capture group with the search match
367-
// result e.g. ["def fiz"]
368-
const searchMatches = [...fullMatches].map(match => match[1])
369-
if (searchMatches[0]) {
370-
if (item.href.includes('#')) {
371-
item.href += ':~:text=' + encodeURIComponent(searchMatches[0])
372-
} else {
373-
item.href += '#:~:text=' + encodeURIComponent(searchMatches[0])
374-
}
375-
}
347+
if (item.text && item.href && !item.href.includes('?q=')) {
348+
const [main, hash] = item.href.split('#')
349+
const hashAppend = hash ? '#' + hash : ''
350+
item.href = main + '?q=' + encodeURIComponent(state.query) + hashAppend
376351
}
377-
352+
378353
return renderItem(
379354
item,
380355
createElement,
@@ -1123,151 +1098,186 @@ function clearHighlight(searchterm, el) {
11231098
}
11241099
}
11251100

1126-
function escapeRegExp(string) {
1127-
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
1128-
}
1129-
1130-
// After search highlighting, activate any tabs whose panes contain <mark> matches.
1131-
// This ensures that search results inside inactive Bootstrap tabs become visible.
1132-
// Handles nested tabsets by walking up ancestor panes and activating outermost first.
1133-
function activateTabsWithMatches(mainEl) {
1134-
if (typeof bootstrap === "undefined") return;
1135-
1136-
const marks = mainEl.querySelectorAll("mark");
1137-
if (marks.length === 0) return;
1138-
1139-
// Collect all tab panes that contain marks, including ancestor panes for nesting.
1140-
// Group by their parent tabset (.tab-content container).
1141-
const tabsetMatches = new Map();
1142-
1143-
const recordPane = (pane) => {
1144-
const tabContent = pane.closest(".tab-content");
1145-
if (!tabContent) return;
1146-
if (!tabsetMatches.has(tabContent)) {
1147-
tabsetMatches.set(tabContent, { activeHasMatch: false, firstInactivePane: null });
1148-
}
1149-
const info = tabsetMatches.get(tabContent);
1150-
if (pane.classList.contains("active")) {
1151-
info.activeHasMatch = true;
1152-
} else if (!info.firstInactivePane) {
1153-
info.firstInactivePane = pane;
1154-
}
1155-
};
1101+
/** Get all html nodes under the given `root` that don't have children. */
1102+
function getLeafNodes(root) {
1103+
let leaves = [];
11561104

1157-
for (const mark of marks) {
1158-
// Walk up all ancestor tab panes (handles nested tabsets)
1159-
let pane = mark.closest(".tab-pane");
1160-
while (pane) {
1161-
recordPane(pane);
1162-
pane = pane.parentElement?.closest(".tab-pane") ?? null;
1105+
function traverse(node) {
1106+
if (node.childNodes.length === 0) {
1107+
leaves.push(node);
1108+
} else {
1109+
node.childNodes.forEach(traverse);
11631110
}
11641111
}
11651112

1166-
// Sort tabsets by DOM depth (outermost first) so outer tabs activate before inner
1167-
const sorted = [...tabsetMatches.entries()].sort((a, b) => {
1168-
const depthA = ancestorCount(a[0], mainEl);
1169-
const depthB = ancestorCount(b[0], mainEl);
1170-
return depthA - depthB;
1171-
});
1113+
traverse(root);
1114+
return leaves;
1115+
}
11721116

1173-
for (const [, info] of sorted) {
1174-
if (info.activeHasMatch || !info.firstInactivePane) continue;
1117+
const isWhitespace = s => s.trim().length === 0
1118+
const initMatch = () => ({
1119+
i: 0,
1120+
lohisByNode: new Map()
1121+
})
1122+
/**
1123+
* keeps track of the start (lo) and end (hi) index of the match per node (leaf)
1124+
* note: mutates the contents of `matchContext`
1125+
*/
1126+
const advanceMatch = (leaf, leafi, matchContext) => {
1127+
matchContext.i++
1128+
1129+
const curLoHi = matchContext.lohisByNode.get(leaf)
1130+
1131+
matchContext.lohisByNode.set(leaf, { lo: curLoHi?.lo ?? leafi, hi: leafi })
1132+
}
1133+
/**
1134+
* Finds all non-overlapping matches for a search string in the document.
1135+
* The search string may be split between multiple consecutive leaf nodes.
1136+
*
1137+
* Whitespace in the search string must be present in the document to match, but
1138+
* there may be addititional whitespace in the document that is ignored.
1139+
*
1140+
* e.g. searching for `dogs rock` would match `dogs \n <span> rock</span>`,
1141+
* and would contribute the match
1142+
* `{ i:9, els: new Map([[textNode, {lo:0, hi:8}],[spanNode,{lo:0,hi:5}]]) }`
1143+
*
1144+
* @returns {Map<HTMLElement,{lo:number,hi:number}>[]}
1145+
*/
1146+
function searchMatches(inSearch, el) {
1147+
// searchText has all sequences of whitespace replaced by a single space
1148+
const searchText = inSearch.toLowerCase().replace(/\s+/g, ' ')
1149+
const leafNodes = getLeafNodes(el)
1150+
1151+
/** @type {Map<HTMLElement,{lo:number,hi:number}>[]} */
1152+
const matches = []
1153+
/** @type {{i:number; els:Map<HTMLElement,{lo:number,hi:number}>}[]} */
1154+
let curMatchContext = initMatch()
1155+
1156+
for (leaf of leafNodes) {
1157+
const leafStr = leaf.textContent.toLowerCase()
1158+
// for each character in this leaf's text:
1159+
for (let leafi = 0; leafi < leafStr.length; leafi++) {
1160+
1161+
if (isWhitespace(leafStr[leafi])) {
1162+
// if there is at least one whitespace in the document
1163+
// we advance over a search text whitespace.
1164+
if (isWhitespace(searchText[curMatchContext.i])) advanceMatch(leaf, leafi, curMatchContext)
1165+
// all sequences of whitespace are otherwise ignored.
1166+
} else {
1167+
if (searchText[curMatchContext.i] === leafStr[leafi]) {
1168+
advanceMatch(leaf, leafi, curMatchContext)
1169+
} else {
1170+
curMatchContext = initMatch()
1171+
// if current character in the document did not match at i in the search text,
1172+
// reset the search and see if that character matches at 0 in the search text.
1173+
if (searchText[curMatchContext.i] === leafStr[leafi]) advanceMatch(leaf, leafi, curMatchContext)
1174+
}
1175+
}
11751176

1176-
const escapedId = CSS.escape(info.firstInactivePane.id);
1177-
const tabButton = mainEl.querySelector(
1178-
`[data-bs-toggle="tab"][data-bs-target="#${escapedId}"]`
1179-
);
1180-
if (tabButton) {
1181-
try {
1182-
new bootstrap.Tab(tabButton).show();
1183-
} catch (e) {
1184-
console.debug("Failed to activate tab for search match:", e);
1177+
const isMatchComplete = curMatchContext.i === searchText.length
1178+
if (isMatchComplete) {
1179+
matches.push(curMatchContext.lohisByNode)
1180+
curMatchContext = initMatch()
11851181
}
11861182
}
11871183
}
1184+
1185+
return matches
11881186
}
11891187

1190-
function ancestorCount(el, stopAt) {
1191-
let count = 0;
1192-
let node = el.parentElement;
1193-
while (node && node !== stopAt) {
1194-
count++;
1195-
node = node.parentElement;
1188+
/** create and return `<mark>${txt}</mark>` */
1189+
const markEl = txt => {
1190+
const el = document.createElement("mark");
1191+
el.appendChild(document.createTextNode(txt));
1192+
return el
1193+
}
1194+
/**
1195+
* e.g. `markMatches(myTextNode, [[0,5],[12,15]])` would wrap the
1196+
* character sequences in myTextNode from 0-5 and 12-15 in marks.
1197+
* Its important to mark all sequences in a text node at once
1198+
* because this function replaces the entire text node; so any
1199+
* other references to that text node will no longer be in the DOM.
1200+
*/
1201+
function markMatches(node, lohis) {
1202+
const text = node.nodeValue
1203+
1204+
const markFragment = document.createDocumentFragment();
1205+
1206+
let prevHi = 0
1207+
for (const [lo, hi] of lohis) {
1208+
markFragment.append(
1209+
document.createTextNode(text.slice(prevHi, lo)),
1210+
markEl(text.slice(lo, hi + 1))
1211+
)
1212+
prevHi = hi + 1
11961213
}
1197-
return count;
1214+
markFragment.append(
1215+
document.createTextNode(text.slice(prevHi, text.length))
1216+
)
1217+
1218+
const parent = node.parentElement
1219+
parent?.replaceChild(markFragment, node)
1220+
return parent
11981221
}
11991222

1200-
// After tab activation, scroll to the first visible search match so the user
1201-
// sees the highlighted result without manually scrolling.
1202-
// Only checks tab-pane visibility (not collapsed callouts, details/summary, etc.)
1203-
// since this runs specifically after tab activation for search results.
1204-
function scrollToFirstMatch(mainEl) {
1205-
const marks = mainEl.querySelectorAll("mark");
1206-
for (const mark of marks) {
1207-
let hidden = false;
1208-
let el = mark.parentElement;
1209-
while (el && el !== mainEl) {
1210-
if (el.classList.contains("tab-pane") && !el.classList.contains("active")) {
1211-
hidden = true;
1212-
break;
1213-
}
1214-
el = el.parentElement;
1215-
}
1216-
if (!hidden) {
1217-
mark.scrollIntoView({ block: "center" });
1218-
return;
1219-
}
1223+
/** get all ancestors of an element matching the given css selector */
1224+
const matchAncestors = (el, selector) => {
1225+
let ancestors = [];
1226+
while (el) {
1227+
if (el.matches?.(selector)) ancestors.push(el);
1228+
el = el.parentNode;
1229+
}
1230+
return ancestors;
1231+
};
1232+
const openAllTabsetsContainingEl = el => {
1233+
for (const tab of matchAncestors(el, '.tab-pane')) {
1234+
const tabButton = document.querySelector(`[data-bs-target="#${tab.id}"]`);
1235+
if (tabButton) new bootstrap.Tab(tabButton).show();
12201236
}
12211237
}
12221238

1223-
// highlight matches
1224-
function highlight(term, el) {
1225-
const termRegex = new RegExp(term, "ig");
1226-
const childNodes = el.childNodes;
1227-
1228-
// walk back to front avoid mutating elements in front of us
1229-
for (let i = childNodes.length - 1; i >= 0; i--) {
1230-
const node = childNodes[i];
1231-
1232-
if (node.nodeType === Node.TEXT_NODE) {
1233-
// Search text nodes for text to highlight
1234-
const text = node.nodeValue;
1235-
1236-
let startIndex = 0;
1237-
let matchIndex = text.search(termRegex);
1238-
if (matchIndex > -1) {
1239-
const markFragment = document.createDocumentFragment();
1240-
while (matchIndex > -1) {
1241-
const prefix = text.slice(startIndex, matchIndex);
1242-
markFragment.appendChild(document.createTextNode(prefix));
1243-
1244-
const mark = document.createElement("mark");
1245-
mark.appendChild(
1246-
document.createTextNode(
1247-
text.slice(matchIndex, matchIndex + term.length)
1248-
)
1249-
);
1250-
markFragment.appendChild(mark);
1251-
1252-
startIndex = matchIndex + term.length;
1253-
matchIndex = text.slice(startIndex).search(new RegExp(term, "ig"));
1254-
if (matchIndex > -1) {
1255-
matchIndex = startIndex + matchIndex;
1256-
}
1257-
}
1258-
if (startIndex < text.length) {
1259-
markFragment.appendChild(
1260-
document.createTextNode(text.slice(startIndex, text.length))
1261-
);
1262-
}
1239+
/**
1240+
* e.g.
1241+
* ```js
1242+
* const m = new Map()
1243+
*
1244+
* arrayMapPush(m, 'dog', 'Max')
1245+
* console.log(m) // Map { dog->['Max'] }
1246+
*
1247+
* arrayMapPush(m, 'dog', 'Samba')
1248+
* arrayMapPush(m, 'cat', 'Scruffle')
1249+
* console.log(m) // Map { dog->['Max', 'Samba'], cat->['Scruffle'] }
1250+
* ```
1251+
*/
1252+
const arrayMapPush = (map, key, item) => {
1253+
if (!map.has(key)) map.set(key, [])
1254+
map.set(key, [...map.get(key), item])
1255+
}
12631256

1264-
el.replaceChild(markFragment, node);
1265-
}
1266-
} else if (node.nodeType === Node.ELEMENT_NODE) {
1267-
// recurse through elements
1268-
highlight(term, node);
1257+
// copy&paste any string from a quarto page and
1258+
// this should find that string in the page and highlight it.
1259+
// exception: text that starts outside/inside a tabset and ends
1260+
// inside/outside that tabset.
1261+
function highlight(searchStr, el) {
1262+
const matches = searchMatches(searchStr, el);
1263+
1264+
const matchesGroupedByNode = new Map()
1265+
for (const match of matches) {
1266+
for (const [mel, { lo, hi }] of match) {
1267+
arrayMapPush(matchesGroupedByNode, mel, [lo, hi])
12691268
}
12701269
}
1270+
1271+
const matchNodes = [...matchesGroupedByNode].map(([node, lohis]) => {
1272+
const matchNode = markMatches(node, lohis)
1273+
openAllTabsetsContainingEl(matchNode)
1274+
return matchNode
1275+
})
1276+
// let things settle before scrolling
1277+
setTimeout(() =>
1278+
matchNodes[0]?.scrollIntoView({ behavior: 'smooth', block: 'center' }),
1279+
400
1280+
)
12711281
}
12721282

12731283
/* Link Handling */

0 commit comments

Comments
 (0)