feat: add plugin-search (power by minisearch)
This commit is contained in:
parent
7245a3a9c7
commit
e409ece6fd
21
plugins/plugin-search/LICENSE
Normal file
21
plugins/plugin-search/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (C) 2021 - PRESENT by pengzhanbo
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
26
plugins/plugin-search/README.md
Normal file
26
plugins/plugin-search/README.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# `@vuepress-plume/plugin-search`
|
||||||
|
|
||||||
|
使用 [`minisearch`](https://lucaong.github.io/minisearch/) 实现的本地 全文模糊搜索 插件。
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install @vuepress-plume/plugin-search
|
||||||
|
# or
|
||||||
|
pnpm add @vuepress-plume/plugin-search
|
||||||
|
# or
|
||||||
|
yarn add @vuepress-plume/plugin-search
|
||||||
|
```
|
||||||
|
## Usage
|
||||||
|
``` js
|
||||||
|
// .vuepress/config.[jt]s
|
||||||
|
import { searchPlugin } from '@vuepress-plume/plugin-search'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// ...
|
||||||
|
plugins: [
|
||||||
|
searchPlugin()
|
||||||
|
]
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
63
plugins/plugin-search/package.json
Normal file
63
plugins/plugin-search/package.json
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "@vuepress-plume/plugin-search",
|
||||||
|
"type": "module",
|
||||||
|
"version": "1.0.0-rc.35",
|
||||||
|
"description": "The Plugin for VuePres 2 - mini search",
|
||||||
|
"author": "pengzhanbo <volodymyr@foxmail.com>",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://github.com/pengzhanbo/vuepress-theme-plume#readme",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/pengzhanbo/vuepress-theme-plume.git",
|
||||||
|
"directory": "plugins/plugin-search"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/pengzhanbo/vuepress-theme-plume/issues"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./lib/node/index.d.ts",
|
||||||
|
"import": "./lib/node/index.js"
|
||||||
|
},
|
||||||
|
"./client": {
|
||||||
|
"types": "./lib/client/index.d.ts",
|
||||||
|
"import": "./lib/client/index.js"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"main": "lib/node/index.js",
|
||||||
|
"types": "./lib/node/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"lib"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "pnpm run clean && pnpm run copy && pnpm run ts",
|
||||||
|
"dev": "pnpm copy --watch",
|
||||||
|
"clean": "rimraf --glob ./lib ./*.tsbuildinfo",
|
||||||
|
"copy": "cpx \"src/**/*.{d.ts,vue,css,scss,jpg,png}\" lib",
|
||||||
|
"ts": "tsc -b tsconfig.build.json"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vuepress": "2.0.0-rc.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vuepress/helper": "2.0.0-rc.14",
|
||||||
|
"@vueuse/core": "^10.7.2",
|
||||||
|
"@vueuse/integrations": "^10.7.2",
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"focus-trap": "^7.5.4",
|
||||||
|
"mark.js": "^8.11.1",
|
||||||
|
"minisearch": "^6.3.0",
|
||||||
|
"p-map": "^7.0.1",
|
||||||
|
"vue": "^3.4.19"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"keyword": [
|
||||||
|
"VuePress",
|
||||||
|
"vuepress plugin",
|
||||||
|
"mini search",
|
||||||
|
"vuepress-plugin-search"
|
||||||
|
]
|
||||||
|
}
|
||||||
72
plugins/plugin-search/src/client/components/Search.vue
Normal file
72
plugins/plugin-search/src/client/components/Search.vue
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onKeyStroke } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
defineAsyncComponent,
|
||||||
|
ref,
|
||||||
|
} from 'vue'
|
||||||
|
import type { SearchBoxLocales, SearchOptions } from '../../shared/index.js'
|
||||||
|
import SearchButton from './SearchButton.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
locales: SearchBoxLocales
|
||||||
|
options: SearchOptions
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const SearchBox = defineAsyncComponent(() => import('./SearchBox.vue'))
|
||||||
|
|
||||||
|
const showSearch = ref(false)
|
||||||
|
|
||||||
|
onKeyStroke('k', (event) => {
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
showSearch.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onKeyStroke('/', (event) => {
|
||||||
|
if (!isEditingContent(event)) {
|
||||||
|
event.preventDefault()
|
||||||
|
showSearch.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function isEditingContent(event: KeyboardEvent): boolean {
|
||||||
|
const element = event.target as HTMLElement
|
||||||
|
const tagName = element.tagName
|
||||||
|
|
||||||
|
return (
|
||||||
|
element.isContentEditable
|
||||||
|
|| tagName === 'INPUT'
|
||||||
|
|| tagName === 'SELECT'
|
||||||
|
|| tagName === 'TEXTAREA'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<SearchBox
|
||||||
|
v-if="showSearch"
|
||||||
|
:locales="locales"
|
||||||
|
:options="options"
|
||||||
|
@close="showSearch = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div id="local-search">
|
||||||
|
<SearchButton :locales="locales" @click="showSearch = true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.search-wrapper {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
703
plugins/plugin-search/src/client/components/SearchBox.vue
Normal file
703
plugins/plugin-search/src/client/components/SearchBox.vue
Normal file
@ -0,0 +1,703 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
type Ref,
|
||||||
|
computed,
|
||||||
|
markRaw,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
shallowRef,
|
||||||
|
toRef,
|
||||||
|
watch,
|
||||||
|
} from 'vue'
|
||||||
|
import { useRouteLocale, useRouter } from 'vuepress/client'
|
||||||
|
import {
|
||||||
|
computedAsync,
|
||||||
|
debouncedWatch,
|
||||||
|
onKeyStroke,
|
||||||
|
useEventListener,
|
||||||
|
useScrollLock,
|
||||||
|
useSessionStorage,
|
||||||
|
} from '@vueuse/core'
|
||||||
|
import Mark from 'mark.js/src/vanilla.js'
|
||||||
|
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
||||||
|
import MiniSearch, { type SearchResult } from 'minisearch'
|
||||||
|
import { useSearchIndex } from '../composables/index.js'
|
||||||
|
import type { SearchBoxLocales, SearchOptions } from '../../shared/index.js'
|
||||||
|
import { LRUCache } from '../utils/lru.js'
|
||||||
|
import { useLocale } from '../composables/locale.js'
|
||||||
|
import SearchIcon from './icons/SearchIcon.vue'
|
||||||
|
import ClearIcon from './icons/ClearIcon.vue'
|
||||||
|
import BackIcon from './icons/BackIcon.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
locales: SearchBoxLocales
|
||||||
|
options: SearchOptions
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const routeLocale = useRouteLocale()
|
||||||
|
const locale = useLocale(toRef(props.locales))
|
||||||
|
|
||||||
|
const el = shallowRef<HTMLElement>()
|
||||||
|
const resultsEl = shallowRef<HTMLElement>()
|
||||||
|
|
||||||
|
const searchIndexData = useSearchIndex()
|
||||||
|
|
||||||
|
interface Result {
|
||||||
|
title: string
|
||||||
|
titles: string[]
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { activate } = useFocusTrap(el, {
|
||||||
|
immediate: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchIndex = computedAsync(async () =>
|
||||||
|
markRaw(
|
||||||
|
MiniSearch.loadJSON<Result>(
|
||||||
|
(await searchIndexData.value[routeLocale.value]?.())?.default,
|
||||||
|
{
|
||||||
|
fields: ['title', 'titles', 'text'],
|
||||||
|
storeFields: ['title', 'titles'],
|
||||||
|
searchOptions: {
|
||||||
|
fuzzy: 0.2,
|
||||||
|
prefix: true,
|
||||||
|
boost: { title: 4, text: 2, titles: 1 },
|
||||||
|
},
|
||||||
|
...props.options.miniSearch?.searchOptions,
|
||||||
|
...props.options.miniSearch?.options,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const disableQueryPersistence = computed(() =>
|
||||||
|
props.options?.disableQueryPersistence === true,
|
||||||
|
)
|
||||||
|
const filterText = disableQueryPersistence.value
|
||||||
|
? ref('')
|
||||||
|
: useSessionStorage('vuepress-plume:mini-search-filter', '')
|
||||||
|
|
||||||
|
const buttonText = computed(() => locale.value.buttonText || locale.value.placeholder || 'Search')
|
||||||
|
|
||||||
|
const results: Ref<(SearchResult & Result)[]> = shallowRef([])
|
||||||
|
|
||||||
|
const enableNoResults = ref(false)
|
||||||
|
|
||||||
|
watch(filterText, () => {
|
||||||
|
enableNoResults.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const mark = computedAsync(async () => {
|
||||||
|
if (!resultsEl.value)
|
||||||
|
return
|
||||||
|
return markRaw(new Mark(resultsEl.value))
|
||||||
|
}, null)
|
||||||
|
|
||||||
|
const cache = new LRUCache<string, Map<string, string>>(16) // 16 files
|
||||||
|
|
||||||
|
debouncedWatch(
|
||||||
|
() => [searchIndex.value, filterText.value] as const,
|
||||||
|
async ([index, filterTextValue], old, onCleanup) => {
|
||||||
|
if (old?.[0] !== index) {
|
||||||
|
// in case of hmr
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
let canceled = false
|
||||||
|
onCleanup(() => {
|
||||||
|
canceled = true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!index)
|
||||||
|
return
|
||||||
|
|
||||||
|
// Search
|
||||||
|
results.value = index
|
||||||
|
.search(filterTextValue)
|
||||||
|
.slice(0, 16)
|
||||||
|
.map((r) => {
|
||||||
|
r.titles = r.titles?.filter(Boolean) || []
|
||||||
|
return r
|
||||||
|
}) as (SearchResult & Result)[]
|
||||||
|
enableNoResults.value = true
|
||||||
|
|
||||||
|
const terms = new Set<string>()
|
||||||
|
|
||||||
|
results.value = results.value.map((r) => {
|
||||||
|
const [id, anchor] = r.id.split('#')
|
||||||
|
const map = cache.get(id)
|
||||||
|
const text = map?.get(anchor) ?? ''
|
||||||
|
for (const term in r.match)
|
||||||
|
terms.add(term)
|
||||||
|
|
||||||
|
return { ...r, text }
|
||||||
|
})
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
if (canceled)
|
||||||
|
return
|
||||||
|
|
||||||
|
await new Promise((r) => {
|
||||||
|
mark.value?.unmark({
|
||||||
|
done: () => {
|
||||||
|
mark.value?.markRegExp(formMarkRegex(terms), { done: r })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ debounce: 200, immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
/* Search input focus */
|
||||||
|
|
||||||
|
const searchInput = ref<HTMLInputElement>()
|
||||||
|
const disableReset = computed(() => {
|
||||||
|
return filterText.value?.length <= 0
|
||||||
|
})
|
||||||
|
function focusSearchInput(select = true) {
|
||||||
|
searchInput.value?.focus()
|
||||||
|
select && searchInput.value?.select()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
focusSearchInput()
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSearchBarClick(event: PointerEvent) {
|
||||||
|
if (event.pointerType === 'mouse')
|
||||||
|
focusSearchInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search keyboard selection */
|
||||||
|
|
||||||
|
const selectedIndex = ref(-1)
|
||||||
|
const disableMouseOver = ref(false)
|
||||||
|
|
||||||
|
watch(results, (r) => {
|
||||||
|
selectedIndex.value = r.length ? 0 : -1
|
||||||
|
scrollToSelectedResult()
|
||||||
|
})
|
||||||
|
|
||||||
|
function scrollToSelectedResult() {
|
||||||
|
nextTick(() => {
|
||||||
|
const selectedEl = document.querySelector('.result.selected')
|
||||||
|
if (selectedEl) {
|
||||||
|
selectedEl.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyStroke('ArrowUp', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value--
|
||||||
|
if (selectedIndex.value < 0)
|
||||||
|
selectedIndex.value = results.value.length - 1
|
||||||
|
|
||||||
|
disableMouseOver.value = true
|
||||||
|
scrollToSelectedResult()
|
||||||
|
})
|
||||||
|
|
||||||
|
onKeyStroke('ArrowDown', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value++
|
||||||
|
if (selectedIndex.value >= results.value.length)
|
||||||
|
selectedIndex.value = 0
|
||||||
|
|
||||||
|
disableMouseOver.value = true
|
||||||
|
scrollToSelectedResult()
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onKeyStroke('Enter', (e) => {
|
||||||
|
if (e.isComposing)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (e.target instanceof HTMLButtonElement && e.target.type !== 'submit')
|
||||||
|
return
|
||||||
|
|
||||||
|
const selectedPackage = results.value[selectedIndex.value]
|
||||||
|
if (e.target instanceof HTMLInputElement && !selectedPackage) {
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedPackage) {
|
||||||
|
router.go(selectedPackage.id)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onKeyStroke('Escape', () => {
|
||||||
|
emit('close')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Back
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Prevents going to previous site
|
||||||
|
window.history.pushState(null, '', null)
|
||||||
|
})
|
||||||
|
|
||||||
|
useEventListener('popstate', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('close')
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Lock body */
|
||||||
|
const isLocked = useScrollLock(typeof document !== 'undefined' ? document.body : null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
isLocked.value = true
|
||||||
|
nextTick().then(() => activate())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
isLocked.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetSearch() {
|
||||||
|
filterText.value = ''
|
||||||
|
nextTick().then(() => focusSearchInput(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(str: string) {
|
||||||
|
return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formMarkRegex(terms: Set<string>) {
|
||||||
|
return new RegExp(
|
||||||
|
[...terms]
|
||||||
|
.sort((a, b) => b.length - a.length)
|
||||||
|
.map(term => `(${escapeRegExp(term)})`)
|
||||||
|
.join('|'),
|
||||||
|
'gi',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
ref="el"
|
||||||
|
role="button"
|
||||||
|
:aria-owns="results?.length ? 'localsearch-list' : undefined"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-labelledby="mini-search-label"
|
||||||
|
class="VPLocalSearchBox"
|
||||||
|
>
|
||||||
|
<div class="backdrop" @click="$emit('close')" />
|
||||||
|
|
||||||
|
<div class="shell">
|
||||||
|
<form
|
||||||
|
class="search-bar"
|
||||||
|
@pointerup="onSearchBarClick($event)"
|
||||||
|
@submit.prevent=""
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
id="localsearch-label"
|
||||||
|
:title="buttonText"
|
||||||
|
for="localsearch-input"
|
||||||
|
>
|
||||||
|
<SearchIcon class="search-icon" />
|
||||||
|
</label>
|
||||||
|
<div class="search-actions before">
|
||||||
|
<button
|
||||||
|
class="back-button"
|
||||||
|
:title="locale.backButtonTitle"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="localsearch-input"
|
||||||
|
ref="searchInput"
|
||||||
|
v-model="filterText"
|
||||||
|
:placeholder="buttonText"
|
||||||
|
aria-labelledby="localsearch-label"
|
||||||
|
class="search-input"
|
||||||
|
>
|
||||||
|
<div class="search-actions">
|
||||||
|
<button
|
||||||
|
class="clear-button"
|
||||||
|
type="reset"
|
||||||
|
:disabled="disableReset"
|
||||||
|
:title="locale.resetButtonTitle"
|
||||||
|
@click="resetSearch"
|
||||||
|
>
|
||||||
|
<ClearIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
:id="results?.length ? 'localsearch-list' : undefined"
|
||||||
|
ref="resultsEl"
|
||||||
|
:role="results?.length ? 'listbox' : undefined"
|
||||||
|
:aria-labelledby="results?.length ? 'localsearch-label' : undefined"
|
||||||
|
class="results"
|
||||||
|
@mousemove="disableMouseOver = false"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(p, index) in results"
|
||||||
|
:key="p.id"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="selectedIndex === index ? 'true' : 'false'"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="p.id"
|
||||||
|
class="result"
|
||||||
|
:class="{
|
||||||
|
selected: selectedIndex === index,
|
||||||
|
}"
|
||||||
|
:aria-label="[...p.titles, p.title].join(' > ')"
|
||||||
|
@mouseenter="!disableMouseOver && (selectedIndex = index)"
|
||||||
|
@focusin="selectedIndex = index"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="titles">
|
||||||
|
<span class="title-icon">#</span>
|
||||||
|
<span
|
||||||
|
v-for="(t, i) in p.titles"
|
||||||
|
:key="i"
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
<span class="text" v-html="t" />
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="m9 18l6-6l-6-6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="title main">
|
||||||
|
<span class="text" v-html="p.title" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-if="filterText && !results.length && enableNoResults"
|
||||||
|
class="no-results"
|
||||||
|
>
|
||||||
|
{{ locale.noResultsText }} "<strong>{{ filterText }}</strong>"
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="search-keyboard-shortcuts">
|
||||||
|
<span>
|
||||||
|
<kbd :aria-label="locale.footer?.navigateUpKeyAriaLabel ?? ''">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 19V5m-7 7l7-7l7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</kbd>
|
||||||
|
<kbd :aria-label="locale.footer?.navigateDownKeyAriaLabel ?? ''">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 5v14m7-7l-7 7l-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</kbd>
|
||||||
|
{{ locale.footer?.navigateText ?? '' }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd :aria-label="locale.footer?.selectKeyAriaLabel ?? ''">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24">
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentcolor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="m9 10l-5 5l5 5" />
|
||||||
|
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</kbd>
|
||||||
|
{{ locale.footer?.selectText ?? '' }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd :aria-label="locale.footer?.closeKeyAriaLabel ?? ''">esc</kbd>
|
||||||
|
{{ locale.footer?.closeText ?? '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--vp-mini-search-bg: var(--vp-c-bg);
|
||||||
|
--vp-mini-search-result-bg: var(--vp-c-bg);
|
||||||
|
--vp-mini-search-result-border: var(--vp-c-divider);
|
||||||
|
--vp-mini-search-result-selected-bg: var(--vp-c-bg);
|
||||||
|
--vp-mini-search-result-selected-border: var(--vp-c-brand-1);
|
||||||
|
--vp-mini-search-highlight-bg: var(--vp-c-brand-1);
|
||||||
|
--vp-mini-search-highlight-text: var(--vp-c-neutral-inverse);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
svg {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.VPLocalSearchBox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--vp-backdrop-bg-color);
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
width: min(100vw - 60px, 900px);
|
||||||
|
height: min-content;
|
||||||
|
max-height: min(100vh - 128px, 900px);
|
||||||
|
padding: 12px;
|
||||||
|
margin: 64px auto;
|
||||||
|
background: var(--vp-mini-search-bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.shell {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
max-height: none;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: text;
|
||||||
|
border: 1px solid var(--vp-c-divider);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.search-bar {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar:focus-within {
|
||||||
|
border-color: var(--vp-c-brand-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
margin: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.search-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.search-input {
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (any-pointer: coarse) {
|
||||||
|
.search-actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.search-actions.before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-actions button {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-actions button:not([disabled]):hover,
|
||||||
|
.toggle-layout-button.detailed-list {
|
||||||
|
color: var(--vp-c-brand-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-actions button.clear-button:disabled {
|
||||||
|
opacity: 0.37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-keyboard-shortcuts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 14px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-keyboard-shortcuts span {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.search-keyboard-shortcuts {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-keyboard-shortcuts kbd {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 24px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: rgba(128, 128, 128, 0.1);
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
overflow: hidden auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1rem;
|
||||||
|
border: solid 2px var(--vp-mini-search-result-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result > div {
|
||||||
|
width: 100%;
|
||||||
|
margin: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.result > div {
|
||||||
|
margin: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.titles {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1001;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title.main {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-icon {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vp-c-brand-1);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title :deep(svg) {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.selected {
|
||||||
|
--vp-mini-search-result-bg: var(--vp-mini-search-result-selected-bg);
|
||||||
|
|
||||||
|
border-color: var(--vp-mini-search-result-selected-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titles :deep(mark) {
|
||||||
|
padding: 0 2px;
|
||||||
|
color: var(--vp-mini-search-highlight-text);
|
||||||
|
background-color: var(--vp-mini-search-highlight-bg);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.selected .titles,
|
||||||
|
.result.selected .title-icon {
|
||||||
|
color: var(--vp-c-brand-1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
188
plugins/plugin-search/src/client/components/SearchButton.vue
Normal file
188
plugins/plugin-search/src/client/components/SearchButton.vue
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { toRef } from 'vue'
|
||||||
|
import type { SearchBoxLocales } from '../../shared/index.js'
|
||||||
|
import { useLocale } from '../composables/locale.js'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
locales: SearchBoxLocales
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const locale = useLocale(toRef(props.locales))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button type="button" class="mini-search mini-search-button" :aria-label="locale.placeholder">
|
||||||
|
<span class="mini-search-button-container">
|
||||||
|
<svg class="mini-search-search-icon" width="20" height="20" viewBox="0 0 20 20" aria-label="search icon">
|
||||||
|
<path
|
||||||
|
d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
|
||||||
|
stroke="currentColor" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="mini-search-button-placeholder">{{ locale.placeholder }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="mini-search-button-keys">
|
||||||
|
<kbd class="mini-search-button-key" />
|
||||||
|
<kbd class="mini-search-button-key">K</kbd>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mini-search-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 55px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
transition: border-color 0.25s, background-color 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button:focus {
|
||||||
|
outline: 1px dotted;
|
||||||
|
outline: 5px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button:focus:not(:focus-visible) {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.mini-search-button {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 10px 0 12px;
|
||||||
|
background-color: var(--vp-c-bg-alt);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button:hover {
|
||||||
|
background: var(--vp-c-bg-alt);
|
||||||
|
border-color: var(--vp-c-brand-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button .mini-search-button-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button .mini-search-search-icon {
|
||||||
|
position: relative;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--vp-c-text-1);
|
||||||
|
fill: currentcolor;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button:hover .mini-search-search-icon {
|
||||||
|
color: var(--vp-c-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.mini-search-button .mini-search-search-icon {
|
||||||
|
top: 1px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-right: 8px;
|
||||||
|
color: var(--vp-c-text-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button .mini-search-button-placeholder {
|
||||||
|
display: none;
|
||||||
|
padding: 0 16px 0 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vp-c-text-2);
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button:hover .mini-search-button-placeholder {
|
||||||
|
color: var(--vp-c-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.mini-search-button .mini-search-button-placeholder {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button .mini-search-button-keys {
|
||||||
|
display: none;
|
||||||
|
min-width: auto;
|
||||||
|
|
||||||
|
/* rtl:ignore */
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.mini-search-button .mini-search-button-keys {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button .mini-search-button-key {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
|
||||||
|
/* rtl:end:ignore */
|
||||||
|
min-width: 0;
|
||||||
|
height: 22px;
|
||||||
|
padding-left: 6px;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-family: var(--vp-font-family-base);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 22px;
|
||||||
|
border: 1px solid var(--vp-c-divider);
|
||||||
|
|
||||||
|
/* rtl:begin:ignore */
|
||||||
|
border-right: none;
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
transition: color 0.3s, border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button .mini-search-button-key + .mini-search-button-key {
|
||||||
|
padding-right: 6px;
|
||||||
|
padding-left: 2px;
|
||||||
|
|
||||||
|
/* rtl:begin:ignore */
|
||||||
|
border-right: 1px solid var(--vp-c-divider);
|
||||||
|
border-left: none;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
|
||||||
|
/* rtl:end:ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button .mini-search-button-key:first-child {
|
||||||
|
font-size: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button .mini-search-button-key:first-child::after {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mini-search-muted-color);
|
||||||
|
letter-spacing: normal;
|
||||||
|
content: "Ctrl";
|
||||||
|
}
|
||||||
|
|
||||||
|
.mac .mini-search-button .mini-search-button-key:first-child::after {
|
||||||
|
content: "\2318";
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-search-button .mini-search-button-key:first-child > * {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 12H5m7 7l-7-7l7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M20 5H9l-7 7l7 7h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2Zm-2 4l-6 6m0-6l6 6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21l-4.35-4.35" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
1
plugins/plugin-search/src/client/composables/index.ts
Normal file
1
plugins/plugin-search/src/client/composables/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './searchIndex.js'
|
||||||
31
plugins/plugin-search/src/client/composables/locale.ts
Normal file
31
plugins/plugin-search/src/client/composables/locale.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { MaybeRef } from 'vue'
|
||||||
|
import { useRouteLocale } from 'vuepress/client'
|
||||||
|
import { computed, toRef } from 'vue'
|
||||||
|
import type { SearchBoxLocales } from '../../shared/index.js'
|
||||||
|
|
||||||
|
const defaultLocales: SearchBoxLocales = {
|
||||||
|
'/': {
|
||||||
|
placeholder: 'Search',
|
||||||
|
resetButtonTitle: 'Reset search',
|
||||||
|
backButtonTitle: 'Close search',
|
||||||
|
noResultsText: 'No results for',
|
||||||
|
footer: {
|
||||||
|
selectText: 'to select',
|
||||||
|
selectKeyAriaLabel: 'enter',
|
||||||
|
navigateText: 'to navigate',
|
||||||
|
navigateUpKeyAriaLabel: 'up arrow',
|
||||||
|
navigateDownKeyAriaLabel: 'down arrow',
|
||||||
|
closeText: 'to close',
|
||||||
|
closeKeyAriaLabel: 'escape',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocale(locales: MaybeRef<SearchBoxLocales>) {
|
||||||
|
const localesRef = toRef(locales)
|
||||||
|
const routeLocale = useRouteLocale()
|
||||||
|
|
||||||
|
const locale = computed(() => localesRef.value[routeLocale.value] ?? defaultLocales[routeLocale.value] ?? defaultLocales['/'])
|
||||||
|
|
||||||
|
return locale
|
||||||
|
}
|
||||||
19
plugins/plugin-search/src/client/composables/searchIndex.ts
Normal file
19
plugins/plugin-search/src/client/composables/searchIndex.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { searchIndex } from '@internal/minisearchIndex'
|
||||||
|
import { shallowRef } from 'vue'
|
||||||
|
|
||||||
|
declare const __VUE_HMR_RUNTIME__: Record<string, any>
|
||||||
|
|
||||||
|
const searchIndexData = shallowRef(searchIndex)
|
||||||
|
|
||||||
|
export function useSearchIndex() {
|
||||||
|
return searchIndexData
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) {
|
||||||
|
__VUE_HMR_RUNTIME__.updateSearchIndex = (data) => {
|
||||||
|
searchIndexData.value = data
|
||||||
|
}
|
||||||
|
__VUE_HMR_RUNTIME__.updateSearchIndex = (data) => {
|
||||||
|
searchIndexData.value = data
|
||||||
|
}
|
||||||
|
}
|
||||||
21
plugins/plugin-search/src/client/config.ts
Normal file
21
plugins/plugin-search/src/client/config.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { defineClientConfig } from 'vuepress/client'
|
||||||
|
import type { ClientConfig } from 'vuepress/client'
|
||||||
|
import { h } from 'vue'
|
||||||
|
import type { SearchBoxLocales, SearchOptions } from '../shared/index.js'
|
||||||
|
import Search from './components/Search.vue'
|
||||||
|
|
||||||
|
declare const __SEARCH_LOCALES__: SearchBoxLocales
|
||||||
|
declare const __SEARCH_OPTIONS__: SearchOptions
|
||||||
|
|
||||||
|
const locales = __SEARCH_LOCALES__
|
||||||
|
const searchOptions = __SEARCH_OPTIONS__
|
||||||
|
|
||||||
|
export default defineClientConfig({
|
||||||
|
enhance({ app }) {
|
||||||
|
app.component('SearchBox', props => h(Search, {
|
||||||
|
locales,
|
||||||
|
options: searchOptions,
|
||||||
|
...props,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}) as ClientConfig
|
||||||
7
plugins/plugin-search/src/client/index.ts
Normal file
7
plugins/plugin-search/src/client/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import SearchBox from './components/Search.vue'
|
||||||
|
import { useSearchIndex } from './composables/index.js'
|
||||||
|
|
||||||
|
export {
|
||||||
|
SearchBox,
|
||||||
|
useSearchIndex,
|
||||||
|
}
|
||||||
20
plugins/plugin-search/src/client/shim.d.ts
vendored
Normal file
20
plugins/plugin-search/src/client/shim.d.ts
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
declare module '*.vue' {
|
||||||
|
import type { ComponentOptions } from 'vue'
|
||||||
|
|
||||||
|
const comp: ComponentOptions
|
||||||
|
export default comp
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@internal/minisearchIndex' {
|
||||||
|
const searchIndex: Record<string, () => Promise<{ default: string }>>
|
||||||
|
export {
|
||||||
|
searchIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'mark.js/src/vanilla.js' {
|
||||||
|
import type Mark from 'mark.js'
|
||||||
|
|
||||||
|
const mark: typeof Mark
|
||||||
|
export default mark
|
||||||
|
}
|
||||||
39
plugins/plugin-search/src/client/utils/lru.ts
Normal file
39
plugins/plugin-search/src/client/utils/lru.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// adapted from https://stackoverflow.com/a/46432113/11613622
|
||||||
|
|
||||||
|
export class LRUCache<K, V> {
|
||||||
|
private max: number
|
||||||
|
private cache: Map<K, V>
|
||||||
|
|
||||||
|
constructor(max: number = 10) {
|
||||||
|
this.max = max
|
||||||
|
this.cache = new Map<K, V>()
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
const item = this.cache.get(key)
|
||||||
|
if (item !== undefined) {
|
||||||
|
// refresh key
|
||||||
|
this.cache.delete(key)
|
||||||
|
this.cache.set(key, item)
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: K, val: V): void {
|
||||||
|
// refresh key
|
||||||
|
if (this.cache.has(key))
|
||||||
|
this.cache.delete(key)
|
||||||
|
// evict oldest
|
||||||
|
else if (this.cache.size === this.max)
|
||||||
|
this.cache.delete(this.first()!)
|
||||||
|
this.cache.set(key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
first(): K | undefined {
|
||||||
|
return this.cache.keys().next().value
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
8
plugins/plugin-search/src/node/index.ts
Normal file
8
plugins/plugin-search/src/node/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { searchPlugin } from './searchPlugin.js'
|
||||||
|
|
||||||
|
export { prepareSearchIndex } from './prepareSearchIndex.js'
|
||||||
|
export * from '../shared/index.js'
|
||||||
|
|
||||||
|
export {
|
||||||
|
searchPlugin,
|
||||||
|
}
|
||||||
196
plugins/plugin-search/src/node/prepareSearchIndex.ts
Normal file
196
plugins/plugin-search/src/node/prepareSearchIndex.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import type { App, Page } from 'vuepress/core'
|
||||||
|
import MiniSearch from 'minisearch'
|
||||||
|
import pMap from 'p-map'
|
||||||
|
import type { SearchOptions, SearchPluginOptions } from '../shared/index.js'
|
||||||
|
|
||||||
|
export interface SearchIndexOptions {
|
||||||
|
app: App
|
||||||
|
searchOptions: SearchOptions
|
||||||
|
isSearchable: SearchPluginOptions['isSearchable']
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IndexObject {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
title: string
|
||||||
|
titles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_INDEX_DIR = 'internal/minisearchIndex/'
|
||||||
|
const indexByLocales = new Map<string, MiniSearch<IndexObject>>()
|
||||||
|
const indexCache = new Map<string, IndexObject[]>()
|
||||||
|
|
||||||
|
function getIndexByLocale(locale: string, options: SearchIndexOptions['searchOptions']) {
|
||||||
|
let index = indexByLocales.get(locale)
|
||||||
|
if (!index) {
|
||||||
|
index = new MiniSearch<IndexObject>({
|
||||||
|
fields: ['title', 'titles', 'text'],
|
||||||
|
storeFields: ['title', 'titles'],
|
||||||
|
...options.miniSearch?.options,
|
||||||
|
})
|
||||||
|
indexByLocales.set(locale, index)
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIndexCache(filepath: string) {
|
||||||
|
let index = indexCache.get(filepath)
|
||||||
|
if (!index) {
|
||||||
|
index = []
|
||||||
|
indexCache.set(filepath, index)
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareSearchIndex({
|
||||||
|
app,
|
||||||
|
isSearchable,
|
||||||
|
searchOptions,
|
||||||
|
}: SearchIndexOptions) {
|
||||||
|
const pages = isSearchable ? app.pages.filter(isSearchable) : app.pages
|
||||||
|
await pMap(pages, p => indexFile(p, searchOptions), {
|
||||||
|
concurrency: 64,
|
||||||
|
})
|
||||||
|
await writeTemp(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onSearchIndexUpdated(
|
||||||
|
filepath: string,
|
||||||
|
{
|
||||||
|
app,
|
||||||
|
isSearchable,
|
||||||
|
searchOptions,
|
||||||
|
}: SearchIndexOptions,
|
||||||
|
) {
|
||||||
|
const pages = isSearchable ? app.pages.filter(isSearchable) : app.pages
|
||||||
|
if (pages.some(p => p.filePathRelative?.endsWith(filepath))) {
|
||||||
|
await indexFile(app.pages.find(p => p.filePathRelative?.endsWith(filepath))!, searchOptions)
|
||||||
|
await writeTemp(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onSearchIndexRemoved(
|
||||||
|
filepath: string,
|
||||||
|
{
|
||||||
|
app,
|
||||||
|
isSearchable,
|
||||||
|
searchOptions,
|
||||||
|
}: SearchIndexOptions,
|
||||||
|
) {
|
||||||
|
const pages = isSearchable ? app.pages.filter(isSearchable) : app.pages
|
||||||
|
if (pages.some(p => p.filePathRelative?.endsWith(filepath))) {
|
||||||
|
const page = app.pages.find(p => p.filePathRelative?.endsWith(filepath))!
|
||||||
|
const fileId = page.path
|
||||||
|
const locale = page.pathLocale
|
||||||
|
const index = getIndexByLocale(locale, searchOptions)
|
||||||
|
const cache = getIndexCache(fileId)
|
||||||
|
index.removeAll(cache)
|
||||||
|
await writeTemp(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeTemp(app: App) {
|
||||||
|
const records: string[] = []
|
||||||
|
for (const [locale] of indexByLocales) {
|
||||||
|
const index = indexByLocales.get(locale)!
|
||||||
|
const localeName = locale.replace(/^\/|\/$/g, '').replace(/\//g, '_') || 'default'
|
||||||
|
const filename = `searchBox-${localeName}.js`
|
||||||
|
records.push(`${JSON.stringify(locale)}: () => import('@${SEARCH_INDEX_DIR}${filename}')`)
|
||||||
|
await app.writeTemp(
|
||||||
|
`${SEARCH_INDEX_DIR}${filename}`,
|
||||||
|
`export default ${JSON.stringify(
|
||||||
|
JSON.stringify(index) ?? {},
|
||||||
|
)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await app.writeTemp(
|
||||||
|
`${SEARCH_INDEX_DIR}index.js`,
|
||||||
|
`export const searchIndex = {${records.join(',')}}${app.env.isDev ? `\n${genHmrCode('searchIndex')}` : ''}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function indexFile(page: Page, options: SearchIndexOptions['searchOptions']) {
|
||||||
|
// get file metadata
|
||||||
|
const fileId = page.path
|
||||||
|
const locale = page.pathLocale
|
||||||
|
const index = getIndexByLocale(locale, options)
|
||||||
|
const cache = getIndexCache(fileId)
|
||||||
|
// retrieve file and split into "sections"
|
||||||
|
const html = page.contentRendered
|
||||||
|
const sections = splitPageIntoSections(html)
|
||||||
|
|
||||||
|
if (cache && cache.length)
|
||||||
|
index.removeAll(cache)
|
||||||
|
|
||||||
|
// add sections to the locale index
|
||||||
|
for await (const section of sections) {
|
||||||
|
if (!section || !(section.text || section.titles))
|
||||||
|
break
|
||||||
|
const { anchor, text, titles } = section
|
||||||
|
const id = anchor ? [fileId, anchor].join('#') : fileId
|
||||||
|
const item = {
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
title: titles.at(-1)!,
|
||||||
|
titles: titles.slice(0, -1),
|
||||||
|
}
|
||||||
|
index.add(item)
|
||||||
|
cache.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headingRegex = /<h(\d*).*?>(<a.*? href="#.*?".*?>.*?<\/a>)<\/h\1>/gi
|
||||||
|
const headingContentRegex = /<a.*? href="#(.*?)".*?>(.*?)<\/a>/i
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits HTML into sections based on headings
|
||||||
|
*/
|
||||||
|
function* splitPageIntoSections(html: string) {
|
||||||
|
const result = html.split(headingRegex)
|
||||||
|
result.shift()
|
||||||
|
let parentTitles: string[] = []
|
||||||
|
for (let i = 0; i < result.length; i += 3) {
|
||||||
|
const level = Number.parseInt(result[i]) - 1
|
||||||
|
const heading = result[i + 1]
|
||||||
|
const headingResult = headingContentRegex.exec(heading)
|
||||||
|
const title = clearHtmlTags(headingResult?.[2] ?? '').trim()
|
||||||
|
const anchor = headingResult?.[1] ?? ''
|
||||||
|
const content = result[i + 2]
|
||||||
|
if (!title || !content)
|
||||||
|
continue
|
||||||
|
const titles = parentTitles.slice(0, level)
|
||||||
|
titles[level] = title
|
||||||
|
yield { anchor, titles, text: getSearchableText(content) }
|
||||||
|
if (level === 0)
|
||||||
|
parentTitles = [title]
|
||||||
|
else
|
||||||
|
parentTitles[level] = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSearchableText(content: string) {
|
||||||
|
content = clearHtmlTags(content)
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHtmlTags(str: string) {
|
||||||
|
return str.replace(/<[^>]*>/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function genHmrCode(m: string) {
|
||||||
|
const func = `update${m[0].toUpperCase()}${m.slice(1)}`
|
||||||
|
return `
|
||||||
|
if (import.meta.webpackHot) {
|
||||||
|
import.meta.webpackHot.accept()
|
||||||
|
if (__VUE_HMR_RUNTIME__.${m}) {
|
||||||
|
__VUE_HMR_RUNTIME__.${func}(${m})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.accept(({ ${m} }) => {
|
||||||
|
__VUE_HMR_RUNTIME__.${func}(${m})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
45
plugins/plugin-search/src/node/searchPlugin.ts
Normal file
45
plugins/plugin-search/src/node/searchPlugin.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import chokidar from 'chokidar'
|
||||||
|
import type { Plugin } from 'vuepress/core'
|
||||||
|
import { getDirname, path } from 'vuepress/utils'
|
||||||
|
import { addViteOptimizeDepsInclude } from '@vuepress/helper'
|
||||||
|
import type { SearchPluginOptions } from '../shared/index.js'
|
||||||
|
import { onSearchIndexRemoved, onSearchIndexUpdated, prepareSearchIndex } from './prepareSearchIndex.js'
|
||||||
|
|
||||||
|
const __dirname = getDirname(import.meta.url)
|
||||||
|
|
||||||
|
export function searchPlugin({
|
||||||
|
locales = {},
|
||||||
|
isSearchable,
|
||||||
|
...searchOptions
|
||||||
|
}: SearchPluginOptions = {}): Plugin {
|
||||||
|
return app => ({
|
||||||
|
name: '@vuepress-plume/plugin-search',
|
||||||
|
clientConfigFile: path.resolve(__dirname, '../client/config.js'),
|
||||||
|
define: {
|
||||||
|
__SEARCH_LOCALES__: locales,
|
||||||
|
__SEARCH_OPTIONS__: searchOptions,
|
||||||
|
},
|
||||||
|
extendsBundlerOptions(bundlerOptions) {
|
||||||
|
addViteOptimizeDepsInclude(bundlerOptions, app, ['mark.js/src/vanilla.js', '@vueuse/integrations/useFocusTrap', 'minisearch'])
|
||||||
|
},
|
||||||
|
onPrepared: async (app) => {
|
||||||
|
await prepareSearchIndex({ app, isSearchable, searchOptions })
|
||||||
|
},
|
||||||
|
onWatched: async (app, watchers) => {
|
||||||
|
const searchIndexWatcher = chokidar.watch('pages/**/*.js', {
|
||||||
|
cwd: app.dir.temp(),
|
||||||
|
ignoreInitial: true,
|
||||||
|
})
|
||||||
|
searchIndexWatcher.on('add', (filepath) => {
|
||||||
|
onSearchIndexUpdated(filepath, { app, isSearchable, searchOptions })
|
||||||
|
})
|
||||||
|
searchIndexWatcher.on('change', (filepath) => {
|
||||||
|
onSearchIndexUpdated(filepath, { app, isSearchable, searchOptions })
|
||||||
|
})
|
||||||
|
searchIndexWatcher.on('unlink', (filepath) => {
|
||||||
|
onSearchIndexRemoved(filepath, { app, isSearchable, searchOptions })
|
||||||
|
})
|
||||||
|
watchers.push(searchIndexWatcher)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
47
plugins/plugin-search/src/shared/index.ts
Normal file
47
plugins/plugin-search/src/shared/index.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import type { LocaleConfig, Page } from 'vuepress/core'
|
||||||
|
import type { Options as MiniSearchOptions } from 'minisearch'
|
||||||
|
|
||||||
|
export type SearchBoxLocales = LocaleConfig<{
|
||||||
|
placeholder: string
|
||||||
|
buttonText: string
|
||||||
|
resetButtonTitle: string
|
||||||
|
backButtonTitle: string
|
||||||
|
noResultsText: string
|
||||||
|
footer: {
|
||||||
|
selectText: string
|
||||||
|
selectKeyAriaLabel: string
|
||||||
|
navigateText: string
|
||||||
|
navigateUpKeyAriaLabel: string
|
||||||
|
navigateDownKeyAriaLabel: string
|
||||||
|
closeText: string
|
||||||
|
closeKeyAriaLabel: string
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
|
||||||
|
export interface SearchPluginOptions extends SearchOptions {
|
||||||
|
locales?: SearchBoxLocales
|
||||||
|
|
||||||
|
isSearchable?: (page: Page) => boolean
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchOptions {
|
||||||
|
/**
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
disableQueryPersistence?: boolean
|
||||||
|
|
||||||
|
miniSearch?: {
|
||||||
|
/**
|
||||||
|
* @see https://lucaong.github.io/minisearch/modules/_minisearch_.html#options
|
||||||
|
*/
|
||||||
|
options?: Pick<
|
||||||
|
MiniSearchOptions,
|
||||||
|
'extractField' | 'tokenize' | 'processTerm'
|
||||||
|
>
|
||||||
|
/**
|
||||||
|
* @see https://lucaong.github.io/minisearch/modules/_minisearch_.html#searchoptions-1
|
||||||
|
*/
|
||||||
|
searchOptions?: MiniSearchOptions['searchOptions']
|
||||||
|
}
|
||||||
|
}
|
||||||
14
plugins/plugin-search/tsconfig.build.json
Normal file
14
plugins/plugin-search/tsconfig.build.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.build.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"types": [
|
||||||
|
"vuepress/client-types",
|
||||||
|
"vite/client",
|
||||||
|
"webpack-env"
|
||||||
|
],
|
||||||
|
"outDir": "./lib"
|
||||||
|
},
|
||||||
|
"include": ["./src"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user