diff --git a/src/lib/components/Search.svelte b/src/lib/components/Search.svelte index 35890626..da4bd3e9 100644 --- a/src/lib/components/Search.svelte +++ b/src/lib/components/Search.svelte @@ -11,6 +11,43 @@ }[] } + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + + const escapeHtml = (value: string) => + value.replace(/&/g, '&').replace(//g, '>') + + const unicodeWordCharClass = '\\p{L}\\p{N}\\p{M}_' + + const buildHighlightRegex = (terms: string[]) => { + // This uses a Unicode-aware approximation of Pagefind's title matching, but may + // not exactly match Pagefind's own normalization behavior for every locale or query. + const termPattern = terms + .map((term) => `${escapeRegExp(term)}[${unicodeWordCharClass}]*`) + .join('|') + + return new RegExp(`(^|[^${unicodeWordCharClass}])(${termPattern})`, 'giu') + } + + const highlightTitle = (text: string, regex: RegExp) => { + regex.lastIndex = 0 + let highlighted = '' + let lastIndex = 0 + + for (const match of text.matchAll(regex)) { + const prefix = match[1] ?? '' + const matchedTerm = match[2] + const matchIndex = match.index ?? 0 + const termIndex = matchIndex + prefix.length + + highlighted += escapeHtml(text.slice(lastIndex, matchIndex)) + highlighted += escapeHtml(prefix) + highlighted += `${escapeHtml(matchedTerm)}` + lastIndex = termIndex + matchedTerm.length + } + + return highlighted + escapeHtml(text.slice(lastIndex)) + } + onMount(() => { new PagefindUI({ element: '#search', @@ -19,12 +56,47 @@ processResult: function (result: Result) { result.url = result.url.replace(/(.*)\.html/, '$1') for (const subResult of result.sub_results) { - subResult.url = subResult.url.replace(/(.*).html(#.*)?/, '$1$2') + subResult.url = subResult.url.replace(/(.*)\.html(#.*)?/, '$1$2') } } }) - const input = document.getElementsByClassName('pagefind-ui__search-input')[0] as HTMLElement + + const container = document.getElementById('search')! + const input = container.getElementsByClassName( + 'pagefind-ui__search-input' + )[0] as HTMLInputElement input.focus() + + // Pagefind highlights matches in excerpts but not in result titles. + // Re-runs on query changes and after Pagefind updates the results DOM. + const updateTitleHighlights = () => { + const query = input.value.trim() + const terms = query.split(/\s+/).filter(Boolean) + const regex = terms.length ? buildHighlightRegex(terms) : null + for (const link of container.querySelectorAll( + '.pagefind-ui__result-link' + )) { + const text = link.textContent ?? '' + if (!regex) { + const plainText = escapeHtml(text) + if (link.innerHTML !== plainText) link.innerHTML = plainText + continue + } + + const highlighted = highlightTitle(text, regex) + if (link.innerHTML !== highlighted) link.innerHTML = highlighted + } + } + + const observer = new MutationObserver(updateTitleHighlights) + input.addEventListener('input', updateTitleHighlights) + + observer.observe(container, { childList: true, subtree: true }) + + return () => { + observer.disconnect() + input.removeEventListener('input', updateTitleHighlights) + } })