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)
+ }
})