diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 38bf5b9ec..59cf8ebb9 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -12,6 +12,7 @@ import { meta, nav, search, + algolia, sidebar, socialLinks } from './constants' @@ -105,6 +106,12 @@ export default defineConfig({ replacement: fileURLToPath( new URL('./theme/components/VPLocalSearchBox.vue', import.meta.url) ) + }, + { + find: /^.*VPNavBarSearch\.vue$/, + replacement: fileURLToPath( + new URL('./theme/components/VPNavBarSearch.vue', import.meta.url) + ) } ] }, @@ -130,6 +137,7 @@ export default defineConfig({ VitePWA({ registerType: 'autoUpdate', workbox: { + maximumFileSizeToCacheInBytes: 4000000, globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], runtimeCaching: [ { @@ -214,6 +222,7 @@ export default defineConfig({ }, themeConfig: { search, + algolia, footer: { message: `${feedback} (rev: ${commitRef})`, copyright: diff --git a/docs/.vitepress/constants.ts b/docs/.vitepress/constants.ts index 108d4359d..b81afc228 100644 --- a/docs/.vitepress/constants.ts +++ b/docs/.vitepress/constants.ts @@ -66,11 +66,11 @@ export const search: DefaultTheme.Config['search'] = { _render(src, env, md) { // Check if current file should be excluded from search const relativePath = env.relativePath || env.path || '' - const shouldExclude = excluded.some(excludedFile => - relativePath.includes(excludedFile) || + const shouldExclude = excluded.some(excludedFile => + relativePath.includes(excludedFile) || relativePath.endsWith(excludedFile) ) - + // Return empty content for excluded files so they don't appear in search if (shouldExclude) { return '' @@ -82,7 +82,15 @@ export const search: DefaultTheme.Config['search'] = { contents = transformGuide(contents) contents = transform(contents) const html = md.render(contents, env) - return html + + // Append the URL to the indexed text content so search works on links too. + return html.replace(/]+href="([^"]+)"[^>]*>/g, (match, href) => { + try { + return match + ' ' + decodeURI(href) + ' ' + } catch { + return match + ' ' + href + ' ' + } + }) }, miniSearch: { options: { @@ -148,6 +156,15 @@ export const search: DefaultTheme.Config['search'] = { provider: 'local' } +export const algolia: DefaultTheme.Config['algolia'] = { + appId: 'R21YQW149I', + apiKey: 'b573aa609857d05f2327598413f9566', + indexName: 'fmhy', + searchParameters: { + facetFilters: ['lang:en-US'] + } +} + export const socialLinks: DefaultTheme.SocialLink[] = [ { icon: 'github', link: 'https://github.com/fmhy/edit' }, { icon: 'discord', link: 'https://github.com/fmhy/FMHY/wiki/FMHY-Discord' }, @@ -319,9 +336,9 @@ export const sidebar: DefaultTheme.Sidebar | DefaultTheme.NavItemWithLink[] = [ items: [ meta.build.nsfw ? { - text: ' NSFW', - link: 'https://rentry.org/NSFW-Checkpoint' - } + text: ' NSFW', + link: 'https://rentry.org/NSFW-Checkpoint' + } : {}, { text: ' Unsafe Sites', diff --git a/docs/.vitepress/theme/components/VPLocalSearchBox.vue b/docs/.vitepress/theme/components/VPLocalSearchBox.vue index 4fcaa9bf6..a4d9d60db 100644 --- a/docs/.vitepress/theme/components/VPLocalSearchBox.vue +++ b/docs/.vitepress/theme/components/VPLocalSearchBox.vue @@ -72,6 +72,7 @@ const { localeIndex, theme } = vitePressData // Fuzzy search toggle state (default: false = exact search) const isFuzzySearch = useLocalStorage('vitepress:local-search-fuzzy', false) +const isUrlSearch = useLocalStorage('vitepress:local-search-url', true) const searchIndex = computedAsync(async () => markRaw( @@ -151,8 +152,8 @@ const mark = computedAsync(async () => { const cache = new LRUCache>(16) // 16 files debouncedWatch( - () => [searchIndex.value, filterText.value, showDetailedList.value, isFuzzySearch.value] as const, - async ([index, filterTextValue, showDetailedListValue, fuzzySearchValue], old, onCleanup) => { + () => [searchIndex.value, filterText.value, showDetailedList.value, isFuzzySearch.value, isUrlSearch.value] as const, + async ([index, filterTextValue, showDetailedListValue, fuzzySearchValue, urlSearchValue], old, onCleanup) => { if (old?.[0] !== index) { // in case of hmr cache.clear() @@ -166,9 +167,12 @@ debouncedWatch( if (!index) return // Search with dynamic fuzzy option + // Prefix matching is always enabled to allow partial word matches (e.g. "Spot" -> "Spotify"). const searchOptions = { - fuzzy: isFuzzySearch.value ? 0.2 : false + fuzzy: isFuzzySearch.value ? 0.2 : false, + prefix: true } + results.value = index .search(filterTextValue, searchOptions) .slice(0, 16) as (SearchResult & Result)[] @@ -246,10 +250,40 @@ debouncedWatch( }) const excerpts = el.value?.querySelectorAll('.result .excerpt') ?? [] + const lowerCaseFilterText = filterTextValue.trim().toLowerCase() + for (const excerpt of excerpts) { - excerpt - .querySelector('mark[data-markjs="true"]') - ?.scrollIntoView({ block: 'center' }) + const anchors = excerpt.querySelectorAll('a') + for (const anchor of anchors) { + const href = anchor.getAttribute('href') + // URL Highlighting Logic + // We use the raw search query (lowerCaseFilterText) to check for matches in the link's href. + // Highlight hyperlinks that contain the entire search term in their href + if ( + urlSearchValue && + href && + lowerCaseFilterText.length > 0 && + href.toLowerCase().includes(lowerCaseFilterText) + ) { + anchor.classList.add('search-match-url') + + // Add priority class for precise scrolling if the query is specific enough (> 2 chars). + // This prioritizes showing the exact link match over a generic page match at the top. + if (lowerCaseFilterText.length > 2) { + anchor.classList.add('search-match-url-priority') + } + } + } + + // Scroll to the first match, prioritizing exact URL matches + const priorityMatch = excerpt.querySelector('.search-match-url-priority') + if (priorityMatch) { + priorityMatch.scrollIntoView({ block: 'center' }) + } else { + excerpt + .querySelector('mark[data-markjs="true"], .search-match-url') + ?.scrollIntoView({ block: 'center' }) + } } // FIXME: without this whole page scrolls to the bottom resultsEl.value?.firstElementChild?.scrollIntoView({ block: 'start' }) @@ -285,7 +319,8 @@ onMounted(() => { function onSearchBarClick(event: PointerEvent) { if (event.pointerType === 'mouse') { - focusSearchInput() + // Disable auto-select on click so user can edit query easily without clearing it + focusSearchInput(false) } } @@ -406,6 +441,10 @@ function toggleFuzzySearch() { isFuzzySearch.value = !isFuzzySearch.value } +function toggleUrlSearch() { + isUrlSearch.value = !isUrlSearch.value +} + function formMarkRegex(terms: Set) { return new RegExp( [...terms] @@ -505,6 +544,16 @@ function onMouseMove(e: MouseEvent) { = + +