Improve URL Search (#4623)

* Add URL Searching

* increase limit

* add toggle for url searching

* switch search providers
This commit is contained in:
bread
2026-01-22 13:58:44 -08:00
committed by GitHub
parent 3d08ae273c
commit 1fd7db22ff
6 changed files with 328 additions and 15 deletions

View File

@@ -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:

View File

@@ -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(/<a[^>]+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: '<span class="i-twemoji:no-one-under-eighteen"></span> NSFW',
link: 'https://rentry.org/NSFW-Checkpoint'
}
text: '<span class="i-twemoji:no-one-under-eighteen"></span> NSFW',
link: 'https://rentry.org/NSFW-Checkpoint'
}
: {},
{
text: '<span class="i-twemoji:warning"></span> Unsafe Sites',

View File

@@ -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<string, Map<string, string>>(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<string>) {
return new RegExp(
[...terms]
@@ -505,6 +544,16 @@ function onMouseMove(e: MouseEvent) {
<span v-else class="exact-icon">=</span>
</button>
<button
class="toggle-url-search-button"
type="button"
:class="{ 'url-search-active': isUrlSearch }"
:title="isUrlSearch ? 'Disable URL Search' : 'Enable URL Search'"
@click="toggleUrlSearch"
>
<span class="url-icon">🔗</span>
</button>
<button
class="clear-button"
type="reset"
@@ -753,6 +802,27 @@ function onMouseMove(e: MouseEvent) {
background: var(--vp-c-bg-soft);
}
.toggle-url-search-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
font-size: 16px;
font-weight: bold;
border-radius: 4px;
transition: all 0.2s;
}
.toggle-url-search-button:hover {
background: var(--vp-c-bg-soft);
}
.toggle-url-search-button.url-search-active {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg-soft);
}
.search-keyboard-shortcuts {
font-size: 0.8rem;
opacity: 75%;
@@ -875,7 +945,9 @@ function onMouseMove(e: MouseEvent) {
}
.titles :deep(mark),
.excerpt :deep(mark) {
.excerpt :deep(mark),
.excerpt :deep(.search-match-url),
.excerpt :deep(.search-match-url-priority) {
background-color: var(--vp-local-search-highlight-bg);
color: var(--vp-local-search-highlight-text);
border-radius: 2px;

View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
import '@docsearch/css'
import { useLocalStorage } from '@vueuse/core'
import {
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { useData } from 'vitepress'
import VPNavBarSearchButton from 'vitepress/dist/client/theme-default/components/VPNavBarSearchButton.vue'
import VPLocalSearchBox from './VPLocalSearchBox.vue'
import VPAlgoliaSearchBox from 'vitepress/dist/client/theme-default/components/VPAlgoliaSearchBox.vue'
const { theme } = useData()
// State for search provider. Default to Algolia as requested ('first').
const provider = useLocalStorage('vitepress:search-provider', 'algolia')
// Toggle function
function toggleProvider() {
provider.value = provider.value === 'algolia' ? 'local' : 'algolia'
}
// Algolia preconnect logic
const loaded = ref(false)
const actuallyLoaded = ref(false)
const preconnect = () => {
const id = 'VPAlgoliaPreconnect'
const rIC = window.requestIdleCallback || setTimeout
rIC(() => {
const preconnect = document.createElement('link')
preconnect.id = id
preconnect.rel = 'preconnect'
const algoliaConfig = theme.value.search?.options ?? theme.value.algolia
if (algoliaConfig) {
preconnect.href = `https://${algoliaConfig.appId}-dsn.algolia.net`
preconnect.crossOrigin = ''
document.head.appendChild(preconnect)
}
})
}
onMounted(() => {
if (provider.value === 'algolia') {
preconnect()
}
})
// Hotkeys
const showSearch = ref(false)
const handleSearchHotKey = (event: KeyboardEvent) => {
if (
(event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) ||
(!isEditingContent(event) && event.key === '/')
) {
event.preventDefault()
if (provider.value === 'algolia') {
load()
} else {
showSearch.value = true
}
}
}
function load() {
if (!loaded.value) {
loaded.value = true
setTimeout(poll, 16)
}
}
function poll() {
if (provider.value !== 'algolia') return
const e = new Event('keydown') as any
e.key = 'k'
e.metaKey = true
window.dispatchEvent(e)
setTimeout(() => {
if (!document.querySelector('.DocSearch-Modal')) {
poll()
}
}, 16)
}
function isEditingContent(event: KeyboardEvent): boolean {
const element = event.target as HTMLElement
const tagName = element.tagName
return (
element.isContentEditable ||
tagName === 'INPUT' ||
tagName === 'SELECT' ||
tagName === 'TEXTAREA'
)
}
onMounted(() => {
window.addEventListener('keydown', handleSearchHotKey)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleSearchHotKey)
})
watch(provider, (newP) => {
if (newP === 'algolia') {
preconnect()
}
})
</script>
<template>
<div class="VPNavBarSearch">
<!-- Toggle Button -->
<div class="search-provider-toggle">
<button @click="toggleProvider" class="toggle-btn" :title="provider === 'algolia' ? 'Switch to Local Search' : 'Switch to Algolia'">
<span v-if="provider === 'algolia'" class="icon-algolia" aria-label="Algolia"></span>
<span v-else class="icon-local" aria-label="Local">L</span>
</button>
</div>
<template v-if="provider === 'local'">
<VPLocalSearchBox
v-if="showSearch"
@close="showSearch = false"
/>
<div id="local-search">
<VPNavBarSearchButton @click="showSearch = true" />
</div>
</template>
<template v-else-if="provider === 'algolia'">
<VPAlgoliaSearchBox
v-if="loaded"
:algolia="theme.search?.options ?? theme.algolia"
@vue:beforeMount="actuallyLoaded = true"
/>
<div v-if="!actuallyLoaded" id="docsearch">
<VPNavBarSearchButton @click="load" />
</div>
</template>
</div>
</template>
<style scoped>
.VPNavBarSearch {
display: flex;
align-items: center;
gap: 8px; /* Space between toggle and search button */
}
@media (min-width: 768px) {
.VPNavBarSearch {
flex-grow: 1;
padding-left: 24px;
}
}
@media (min-width: 960px) {
.VPNavBarSearch {
padding-left: 32px;
}
}
.search-provider-toggle button.toggle-btn {
opacity: 0.5;
padding: 6px;
border-radius: 4px;
cursor: pointer;
background: transparent;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.search-provider-toggle button.toggle-btn:hover {
opacity: 1;
background-color: var(--vp-c-bg-soft);
}
.icon-algolia {
display: block;
width: 18px;
height: 18px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%235468FF' d='M12.004 0C5.385 0 0 5.373 0 12c0 6.613 5.372 12 11.987 12h.017c6.613 0 12-5.373 12-12 0-6.613-5.373-12-12-12zm2.553 14.125h-1.67V8.583l-3.328 5.76H7.9l4.477-7.79h1.68v5.542l3.327-5.76h1.663l-4.49 7.79z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
.icon-local {
display: block;
width: 18px;
height: 18px;
text-align: center;
line-height: 18px;
font-weight: bold;
color: var(--vp-c-text-1);
font-family: var(--vp-font-family-mono);
}
</style>

View File

@@ -44,6 +44,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20251202.0",
"@docsearch/css": "^4.5.3",
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@iconify-json/carbon": "^1.2.15",
"@iconify-json/fluent": "^1.2.35",

8
pnpm-lock.yaml generated
View File

@@ -75,6 +75,9 @@ importers:
'@cloudflare/workers-types':
specifier: ^4.20251202.0
version: 4.20251202.0
'@docsearch/css':
specifier: ^4.5.3
version: 4.5.3
'@ianvs/prettier-plugin-sort-imports':
specifier: ^4.7.0
version: 4.7.0(@vue/compiler-sfc@3.5.27)(prettier@3.7.4)
@@ -810,6 +813,9 @@ packages:
'@docsearch/css@3.8.2':
resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==}
'@docsearch/css@4.5.3':
resolution: {integrity: sha512-kUpHaxn0AgI3LQfyzTYkNUuaFY4uEz/Ym9/N/FvyDE+PzSgZsCyDH9jE49B6N6f1eLCm9Yp64J9wENd6vypdxA==}
'@docsearch/js@3.8.2':
resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==}
@@ -5728,6 +5734,8 @@ snapshots:
'@docsearch/css@3.8.2': {}
'@docsearch/css@4.5.3': {}
'@docsearch/js@3.8.2(@algolia/client-search@5.46.0)':
dependencies:
'@docsearch/react': 3.8.2(@algolia/client-search@5.46.0)