feat: add plugin-search (power by minisearch)

This commit is contained in:
pengzhanbo 2024-02-20 01:40:11 +08:00
parent 7245a3a9c7
commit e409ece6fd
21 changed files with 1574 additions and 0 deletions

View 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.

View 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()
]
// ...
}
```

View 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"
]
}

View 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>

View 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>

View 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>

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from './searchIndex.js'

View 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
}

View 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
}
}

View 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

View File

@ -0,0 +1,7 @@
import SearchBox from './components/Search.vue'
import { useSearchIndex } from './composables/index.js'
export {
SearchBox,
useSearchIndex,
}

View 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
}

View 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()
}
}

View File

@ -0,0 +1,8 @@
import { searchPlugin } from './searchPlugin.js'
export { prepareSearchIndex } from './prepareSearchIndex.js'
export * from '../shared/index.js'
export {
searchPlugin,
}

View 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})
})
}
`
}

View 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)
},
})
}

View 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']
}
}

View 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"]
}