mirror of
https://github.com/fmhy/edit.git
synced 2026-01-23 08:21:02 +00:00
Improve URL Search (#4623)
* Add URL Searching * increase limit * add toggle for url searching * switch search providers
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
206
docs/.vitepress/theme/components/VPNavBarSearch.vue
Normal file
206
docs/.vitepress/theme/components/VPNavBarSearch.vue
Normal 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>
|
||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user