Merge pull request #53 from pengzhanbo/plugin-search
feat: add plugin-search (power by minisearch)
This commit is contained in:
commit
79dd3488b1
@ -30,10 +30,10 @@ export const theme: Theme = themePlume({
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
docsearch: {
|
||||
apiKey: '111',
|
||||
appId: '111',
|
||||
indexName: '1234',
|
||||
},
|
||||
// docsearch: {
|
||||
// apiKey: '111',
|
||||
// appId: '111',
|
||||
// indexName: '1234',
|
||||
// },
|
||||
},
|
||||
})
|
||||
|
||||
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"]
|
||||
}
|
||||
@ -36,10 +36,10 @@
|
||||
"vuepress": "2.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@shikijs/transformers": "^1.1.3",
|
||||
"@shikijs/twoslash": "^1.1.3",
|
||||
"@shikijs/transformers": "^1.1.6",
|
||||
"@shikijs/twoslash": "^1.1.6",
|
||||
"nanoid": "^5.0.5",
|
||||
"shiki": "^1.1.3"
|
||||
"shiki": "^1.1.6"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@ -14,7 +14,8 @@
|
||||
{ "path": "./plugin-notes-data/tsconfig.build.json" },
|
||||
{ "path": "./plugin-page-collection/tsconfig.build.json" },
|
||||
{ "path": "./plugin-shikiji/tsconfig.build.json" },
|
||||
{ "path": "./plugin-content-update/tsconfig.build.json" }
|
||||
{ "path": "./plugin-content-update/tsconfig.build.json" },
|
||||
{ "path": "./plugin-search/tsconfig.build.json" }
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
|
||||
184
pnpm-lock.yaml
generated
184
pnpm-lock.yaml
generated
@ -274,20 +274,53 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../plugin-netlify-functions
|
||||
|
||||
plugins/plugin-search:
|
||||
dependencies:
|
||||
'@vuepress/helper':
|
||||
specifier: 2.0.0-rc.14
|
||||
version: 2.0.0-rc.14(typescript@5.3.3)(vuepress@2.0.0-rc.7)
|
||||
'@vueuse/core':
|
||||
specifier: ^10.7.2
|
||||
version: 10.7.2(vue@3.4.19)
|
||||
'@vueuse/integrations':
|
||||
specifier: ^10.7.2
|
||||
version: 10.7.2(focus-trap@7.5.4)(vue@3.4.19)
|
||||
chokidar:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
focus-trap:
|
||||
specifier: ^7.5.4
|
||||
version: 7.5.4
|
||||
mark.js:
|
||||
specifier: ^8.11.1
|
||||
version: 8.11.1
|
||||
minisearch:
|
||||
specifier: ^6.3.0
|
||||
version: 6.3.0
|
||||
p-map:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1
|
||||
vue:
|
||||
specifier: ^3.4.19
|
||||
version: 3.4.19(typescript@5.3.3)
|
||||
vuepress:
|
||||
specifier: 2.0.0-rc.7
|
||||
version: 2.0.0-rc.7(@vuepress/bundler-vite@2.0.0-rc.7)(@vuepress/bundler-webpack@2.0.0-rc.7)(typescript@5.3.3)(vue@3.4.19)
|
||||
|
||||
plugins/plugin-shikiji:
|
||||
dependencies:
|
||||
'@shikijs/transformers':
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3
|
||||
specifier: ^1.1.6
|
||||
version: 1.1.6
|
||||
'@shikijs/twoslash':
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3(typescript@5.3.3)
|
||||
specifier: ^1.1.6
|
||||
version: 1.1.6(typescript@5.3.3)
|
||||
nanoid:
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5
|
||||
shiki:
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3
|
||||
specifier: ^1.1.6
|
||||
version: 1.1.6
|
||||
vuepress:
|
||||
specifier: 2.0.0-rc.7
|
||||
version: 2.0.0-rc.7(@vuepress/bundler-vite@2.0.0-rc.7)(@vuepress/bundler-webpack@2.0.0-rc.7)(typescript@5.3.3)(vue@3.4.19)
|
||||
@ -321,6 +354,9 @@ importers:
|
||||
'@vuepress-plume/plugin-notes-data':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/plugin-notes-data
|
||||
'@vuepress-plume/plugin-search':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/plugin-search
|
||||
'@vuepress-plume/plugin-shikiji':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/plugin-shikiji
|
||||
@ -351,9 +387,6 @@ importers:
|
||||
'@vuepress/plugin-reading-time':
|
||||
specifier: 2.0.0-rc.14
|
||||
version: 2.0.0-rc.14(typescript@5.3.3)(vuepress@2.0.0-rc.7)
|
||||
'@vuepress/plugin-search':
|
||||
specifier: 2.0.0-rc.14
|
||||
version: 2.0.0-rc.14(typescript@5.3.3)(vuepress@2.0.0-rc.7)
|
||||
'@vuepress/plugin-seo':
|
||||
specifier: 2.0.0-rc.14
|
||||
version: 2.0.0-rc.14(typescript@5.3.3)(vuepress@2.0.0-rc.7)
|
||||
@ -3185,21 +3218,21 @@ packages:
|
||||
requiresBuild: true
|
||||
optional: true
|
||||
|
||||
/@shikijs/core@1.1.3:
|
||||
resolution: {integrity: sha512-1QWSvWcPbvZXsDxB1F7ejW+Kuxp3z/JHs944hp/f8BYOlFd5gplzseFIkE/GTu/qytFef3zNME4qw1oHbQ0j2A==}
|
||||
/@shikijs/core@1.1.6:
|
||||
resolution: {integrity: sha512-kt9hhvrWTm0EPtRDIsoAZnSsFlIDBVBBI5CQewpA/NZCPin+MOKRXg+JiWc4y+8fZ/v0HzfDhu/UC+OTZGMt7A==}
|
||||
dev: false
|
||||
|
||||
/@shikijs/transformers@1.1.3:
|
||||
resolution: {integrity: sha512-jv71dQFTucv2RK2pafAxca4hgKP6Uv5ukKrVjH/vGZ8jGH0j2AcLVCcM76ieamwJ1p5WkZcA0X/Bq2qpjhEUSg==}
|
||||
/@shikijs/transformers@1.1.6:
|
||||
resolution: {integrity: sha512-R+eI1I9sQv0MCJyfR4kAG1G1SKSctw5ILszP0tHVrAgzSHWTpaHbXreZrDueahqtUCNHjt+MKmKJ8EMFtiitOQ==}
|
||||
dependencies:
|
||||
shiki: 1.1.3
|
||||
shiki: 1.1.6
|
||||
dev: false
|
||||
|
||||
/@shikijs/twoslash@1.1.3(typescript@5.3.3):
|
||||
resolution: {integrity: sha512-7NUEqRasZ15wWdUIusm2rQMenV2dIpNNOoCiNx7GZ4VzJxy7AwULQxgDVtYfn0u4BCgiUWqtdMYuO9uts8JSmg==}
|
||||
/@shikijs/twoslash@1.1.6(typescript@5.3.3):
|
||||
resolution: {integrity: sha512-0HJK7Il7HevTpRvpPlnJ0ow8UyoQxhvxQ0/lhfw1C0xW/7eLtSAtZfSKdel5Nch6HgbbtfucEzuUFmRIRXUFUg==}
|
||||
dependencies:
|
||||
'@shikijs/core': 1.1.3
|
||||
twoslash: 0.2.3(typescript@5.3.3)
|
||||
'@shikijs/core': 1.1.6
|
||||
twoslash: 0.2.4(typescript@5.3.3)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
@ -3997,7 +4030,7 @@ packages:
|
||||
'@vue/shared': 3.4.19
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.7
|
||||
postcss: 8.4.33
|
||||
postcss: 8.4.35
|
||||
source-map-js: 1.0.2
|
||||
|
||||
/@vue/compiler-ssr@3.4.19:
|
||||
@ -4328,19 +4361,6 @@ packages:
|
||||
- typescript
|
||||
dev: false
|
||||
|
||||
/@vuepress/plugin-search@2.0.0-rc.14(typescript@5.3.3)(vuepress@2.0.0-rc.7):
|
||||
resolution: {integrity: sha512-os2Kzj1hePpX5rxmJUSLs783G0EVTuiwFT2gPPBw/3yZbRCXCTnAM6xAVK2vv36Ysa4fgfFETLG4omg1b3rOvQ==}
|
||||
peerDependencies:
|
||||
vuepress: 2.0.0-rc.7
|
||||
dependencies:
|
||||
chokidar: 3.6.0
|
||||
vue: 3.4.19(typescript@5.3.3)
|
||||
vue-router: 4.2.5(vue@3.4.19)
|
||||
vuepress: 2.0.0-rc.7(@vuepress/bundler-vite@2.0.0-rc.7)(@vuepress/bundler-webpack@2.0.0-rc.7)(typescript@5.3.3)(vue@3.4.19)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
dev: false
|
||||
|
||||
/@vuepress/plugin-seo@2.0.0-rc.14(typescript@5.3.3)(vuepress@2.0.0-rc.7):
|
||||
resolution: {integrity: sha512-ffYc6XObhei863X/g3BTlJrx+EIWRPmtv/NLp9EDKO72KJX3C+MsDKDJOx54iL3X0DT63F/PMTMg0Y+ZhJ4qCw==}
|
||||
peerDependencies:
|
||||
@ -4424,6 +4444,56 @@ packages:
|
||||
- vue
|
||||
dev: false
|
||||
|
||||
/@vueuse/integrations@10.7.2(focus-trap@7.5.4)(vue@3.4.19):
|
||||
resolution: {integrity: sha512-+u3RLPFedjASs5EKPc69Ge49WNgqeMfSxFn+qrQTzblPXZg6+EFzhjarS5edj2qAf6xQ93f95TUxRwKStXj/sQ==}
|
||||
peerDependencies:
|
||||
async-validator: '*'
|
||||
axios: '*'
|
||||
change-case: '*'
|
||||
drauu: '*'
|
||||
focus-trap: '*'
|
||||
fuse.js: '*'
|
||||
idb-keyval: '*'
|
||||
jwt-decode: '*'
|
||||
nprogress: '*'
|
||||
qrcode: '*'
|
||||
sortablejs: '*'
|
||||
universal-cookie: '*'
|
||||
peerDependenciesMeta:
|
||||
async-validator:
|
||||
optional: true
|
||||
axios:
|
||||
optional: true
|
||||
change-case:
|
||||
optional: true
|
||||
drauu:
|
||||
optional: true
|
||||
focus-trap:
|
||||
optional: true
|
||||
fuse.js:
|
||||
optional: true
|
||||
idb-keyval:
|
||||
optional: true
|
||||
jwt-decode:
|
||||
optional: true
|
||||
nprogress:
|
||||
optional: true
|
||||
qrcode:
|
||||
optional: true
|
||||
sortablejs:
|
||||
optional: true
|
||||
universal-cookie:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@vueuse/core': 10.7.2(vue@3.4.19)
|
||||
'@vueuse/shared': 10.7.2(vue@3.4.19)
|
||||
focus-trap: 7.5.4
|
||||
vue-demi: 0.14.6(vue@3.4.19)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
dev: false
|
||||
|
||||
/@vueuse/metadata@10.7.2:
|
||||
resolution: {integrity: sha512-kCWPb4J2KGrwLtn1eJwaJD742u1k5h6v/St5wFe8Quih90+k2a0JP8BS4Zp34XUuJqS2AxFYMb1wjUL8HfhWsQ==}
|
||||
dev: false
|
||||
@ -6989,8 +7059,8 @@ packages:
|
||||
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
||||
dependencies:
|
||||
is-url: 1.2.4
|
||||
postcss: 8.4.33
|
||||
postcss-values-parser: 6.0.2(postcss@8.4.33)
|
||||
postcss: 8.4.35
|
||||
postcss-values-parser: 6.0.2(postcss@8.4.35)
|
||||
dev: false
|
||||
|
||||
/detective-sass@5.0.0:
|
||||
@ -8453,6 +8523,12 @@ packages:
|
||||
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
|
||||
dev: false
|
||||
|
||||
/focus-trap@7.5.4:
|
||||
resolution: {integrity: sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==}
|
||||
dependencies:
|
||||
tabbable: 6.2.0
|
||||
dev: false
|
||||
|
||||
/folder-walker@3.2.0:
|
||||
resolution: {integrity: sha512-VjAQdSLsl6AkpZNyrQJfO7BXLo4chnStqb055bumZMbRUPpVuPN3a4ktsnRCmrFZjtMlYLkyXiR5rAs4WOpC4Q==}
|
||||
dependencies:
|
||||
@ -10765,6 +10841,10 @@ packages:
|
||||
object-visit: 1.0.1
|
||||
dev: false
|
||||
|
||||
/mark.js@8.11.1:
|
||||
resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==}
|
||||
dev: false
|
||||
|
||||
/markdown-it-anchor@8.6.7(@types/markdown-it@13.0.7)(markdown-it@14.0.0):
|
||||
resolution: {integrity: sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==}
|
||||
peerDependencies:
|
||||
@ -11095,6 +11175,10 @@ packages:
|
||||
resolution: {integrity: sha512-ibvbqeslVFur0IAvTxLMvsbtvVcMo6gwvOnj0YZHV7aeDLu091VQRrETT2QuiG9P6aZWRcxeNGJChRKVPCp9VQ==}
|
||||
dev: false
|
||||
|
||||
/minisearch@6.3.0:
|
||||
resolution: {integrity: sha512-ihFnidEeU8iXzcVHy74dhkxh/dn8Dc08ERl0xwoMMGqp4+LvRSCgicb+zGqWthVokQKvCSxITlh3P08OzdTYCQ==}
|
||||
dev: false
|
||||
|
||||
/minizlib@2.1.2:
|
||||
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
|
||||
engines: {node: '>= 8'}
|
||||
@ -11956,6 +12040,11 @@ packages:
|
||||
resolution: {integrity: sha512-T8BatKGY+k5rU+Q/GTYgrEf2r4xRMevAN5mtXc2aPc4rS1j3s+vWTaO2Wag94neXuCAUAs8cxBL9EeB5EA6diw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
/p-map@7.0.1:
|
||||
resolution: {integrity: sha512-2wnaR0XL/FDOj+TgpDuRb2KTjLnu3Fma6b1ZUwGY7LcqenMcvP/YFpjpbPKY6WVGsbuJZRuoUz8iPrt8ORnAFw==}
|
||||
engines: {node: '>=18'}
|
||||
dev: false
|
||||
|
||||
/p-reduce@3.0.0:
|
||||
resolution: {integrity: sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==}
|
||||
engines: {node: '>=12'}
|
||||
@ -12447,7 +12536,7 @@ packages:
|
||||
/postcss-value-parser@4.2.0:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
/postcss-values-parser@6.0.2(postcss@8.4.33):
|
||||
/postcss-values-parser@6.0.2(postcss@8.4.35):
|
||||
resolution: {integrity: sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
@ -12455,7 +12544,7 @@ packages:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
is-url-superb: 4.0.0
|
||||
postcss: 8.4.33
|
||||
postcss: 8.4.35
|
||||
quote-unquote: 1.0.0
|
||||
dev: false
|
||||
|
||||
@ -12466,6 +12555,7 @@ packages:
|
||||
nanoid: 3.3.7
|
||||
picocolors: 1.0.0
|
||||
source-map-js: 1.0.2
|
||||
dev: true
|
||||
|
||||
/postcss@8.4.35:
|
||||
resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==}
|
||||
@ -13217,7 +13307,7 @@ packages:
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
chokidar: 3.5.3
|
||||
chokidar: 3.6.0
|
||||
immutable: 4.1.0
|
||||
source-map-js: 1.0.2
|
||||
dev: false
|
||||
@ -13426,10 +13516,10 @@ packages:
|
||||
/shell-quote@1.8.1:
|
||||
resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==}
|
||||
|
||||
/shiki@1.1.3:
|
||||
resolution: {integrity: sha512-k/B4UvtWmGcHMLp6JnQminlex3Go5MHKXEiormmzTJECAiSQiwSon6USuwTyto8EMUQc9aYRJ7HojkfVLbBk+g==}
|
||||
/shiki@1.1.6:
|
||||
resolution: {integrity: sha512-j4pcpvaQWHb42cHeV+W6P+X/VcK7Y2ctvEham6zB8wsuRQroT6cEMIkiUmBU2Nqg2qnHZDH6ZyRdVldcy0l6xw==}
|
||||
dependencies:
|
||||
'@shikijs/core': 1.1.3
|
||||
'@shikijs/core': 1.1.6
|
||||
dev: false
|
||||
|
||||
/side-channel@1.0.4:
|
||||
@ -14192,6 +14282,10 @@ packages:
|
||||
picocolors: 1.0.0
|
||||
dev: false
|
||||
|
||||
/tabbable@6.2.0:
|
||||
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
|
||||
dev: false
|
||||
|
||||
/table@6.8.1:
|
||||
resolution: {integrity: sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@ -14603,17 +14697,17 @@ packages:
|
||||
safe-buffer: 5.2.1
|
||||
dev: false
|
||||
|
||||
/twoslash-protocol@0.2.3:
|
||||
resolution: {integrity: sha512-pvNVFaYvZ2S5AvG3dN91NSxTt+JxeoJju/1ezOGhmHB+Wpa5xZuWY5nuvOkeBpqcwvwTvpjtw5d/xSV19ZMzJA==}
|
||||
/twoslash-protocol@0.2.4:
|
||||
resolution: {integrity: sha512-AEGTJj4mFGfvQc/M6qi0+s82Zq+mxLcjWZU+EUHGG8LQElyHDs+uDR+/3+m1l+WP7WL+QmWrVzFXgFX+hBg+bg==}
|
||||
dev: false
|
||||
|
||||
/twoslash@0.2.3(typescript@5.3.3):
|
||||
resolution: {integrity: sha512-96vxkRz/IiOwrRDNniBuLLi29q9F5Vv0BAb0Jx/lhF3EhG71hcm0he4fvQllhf/ZPzUGMt/QfJX6o0cTwnZhJQ==}
|
||||
/twoslash@0.2.4(typescript@5.3.3):
|
||||
resolution: {integrity: sha512-hc3y11BjLHP4kV37TR6lUKksxpZp0LQi9kCy95ka6qobye/gV49PqXZIuWlRaRVGNvp4AJBMg8aiwkp0M8x/nQ==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
dependencies:
|
||||
'@typescript/vfs': 1.5.0
|
||||
twoslash-protocol: 0.2.3
|
||||
twoslash-protocol: 0.2.4
|
||||
typescript: 5.3.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@ -64,6 +64,7 @@
|
||||
"@vuepress-plume/plugin-copy-code": "workspace:*",
|
||||
"@vuepress-plume/plugin-iconify": "workspace:*",
|
||||
"@vuepress-plume/plugin-notes-data": "workspace:*",
|
||||
"@vuepress-plume/plugin-search": "workspace:*",
|
||||
"@vuepress-plume/plugin-shikiji": "workspace:*",
|
||||
"@vuepress/plugin-active-header-links": "2.0.0-rc.14",
|
||||
"@vuepress/plugin-container": "2.0.0-rc.12",
|
||||
@ -74,7 +75,6 @@
|
||||
"@vuepress/plugin-nprogress": "2.0.0-rc.14",
|
||||
"@vuepress/plugin-palette": "2.0.0-rc.14",
|
||||
"@vuepress/plugin-reading-time": "2.0.0-rc.14",
|
||||
"@vuepress/plugin-search": "2.0.0-rc.14",
|
||||
"@vuepress/plugin-seo": "2.0.0-rc.14",
|
||||
"@vuepress/plugin-sitemap": "2.0.0-rc.14",
|
||||
"@vuepress/plugin-theme-data": "2.0.0-rc.14",
|
||||
|
||||
@ -189,6 +189,7 @@ watchPostEffect(() => {
|
||||
.menu + .appearance::before,
|
||||
.menu + .social-links::before,
|
||||
.translations + .appearance::before,
|
||||
.translations + .social-links::before,
|
||||
.appearance + .social-links::before {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
|
||||
@ -6,7 +6,10 @@ const theme = useThemeLocaleData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="theme.appearance" class="navbar-appearance">
|
||||
<div
|
||||
v-if="theme.appearance && theme.appearance !== 'force-dark'"
|
||||
class="navbar-appearance"
|
||||
>
|
||||
<SwitchAppearance />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -30,7 +30,7 @@ const hasExtraContent = computed(
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="theme.appearance" class="group">
|
||||
<div v-if="theme.appearance && theme.appearance !== 'force-dark'" class="group">
|
||||
<div class="item appearance">
|
||||
<p class="label">
|
||||
{{ theme.appearanceText || 'Appearance' }}
|
||||
|
||||
@ -6,7 +6,10 @@ const theme = useThemeLocaleData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="theme.appearance" class="nav-screen-appearance">
|
||||
<div
|
||||
v-if="theme.appearance && theme.appearance !== 'force-dark'"
|
||||
class="nav-screen-appearance"
|
||||
>
|
||||
<p class="text">
|
||||
{{ theme.appearanceText ?? 'Appearance' }}
|
||||
</p>
|
||||
|
||||
@ -141,6 +141,11 @@
|
||||
font-size: 12px;
|
||||
color: var(--docsearch-muted-color);
|
||||
letter-spacing: normal;
|
||||
content: "Ctrl";
|
||||
}
|
||||
|
||||
.mac .DocSearch-Button .DocSearch-Button-Key:first-child::after {
|
||||
content: "\2318";
|
||||
}
|
||||
|
||||
.DocSearch-Button .DocSearch-Button-Key:first-child > * {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import containerPlugin from '@vuepress/plugin-container'
|
||||
import { containerPlugin } from '@vuepress/plugin-container'
|
||||
import type { Plugin } from 'vuepress/core'
|
||||
|
||||
export const customContainers: Plugin[] = [
|
||||
|
||||
@ -6,7 +6,6 @@ import { gitPlugin } from '@vuepress/plugin-git'
|
||||
import { mediumZoomPlugin } from '@vuepress/plugin-medium-zoom'
|
||||
import { nprogressPlugin } from '@vuepress/plugin-nprogress'
|
||||
import { palettePlugin } from '@vuepress/plugin-palette'
|
||||
import { searchPlugin } from '@vuepress/plugin-search'
|
||||
import { themeDataPlugin } from '@vuepress/plugin-theme-data'
|
||||
import { autoFrontmatterPlugin } from '@vuepress-plume/plugin-auto-frontmatter'
|
||||
import { baiduTongjiPlugin } from '@vuepress-plume/plugin-baidu-tongji'
|
||||
@ -22,6 +21,7 @@ import { readingTimePlugin } from '@vuepress/plugin-reading-time'
|
||||
import { seoPlugin } from '@vuepress/plugin-seo'
|
||||
import { sitemapPlugin } from '@vuepress/plugin-sitemap'
|
||||
import { contentUpdatePlugin } from '@vuepress-plume/plugin-content-update'
|
||||
import { searchPlugin } from '@vuepress-plume/plugin-search'
|
||||
import type {
|
||||
PlumeThemeLocaleOptions,
|
||||
PlumeThemePluginOptions,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DocsearchPluginOptions } from '@vuepress/plugin-docsearch'
|
||||
import type { SearchPluginOptions } from '@vuepress/plugin-search'
|
||||
import type { SearchPluginOptions } from '@vuepress-plume/plugin-search'
|
||||
import type { App } from 'vuepress/core'
|
||||
import { deepMerge } from '@pengzhanbo/utils'
|
||||
import { resolvedAppLocales } from './resolveLocaleOptions.js'
|
||||
@ -50,8 +50,36 @@ const defaultDocsearchLocales: NonNullable<DocsearchPluginOptions['locales']> =
|
||||
}
|
||||
|
||||
const defaultSearchLocales: NonNullable<SearchPluginOptions['locales']> = {
|
||||
'zh-CN': { placeholder: '搜索' },
|
||||
'en-US': { placeholder: 'Search' },
|
||||
'zh-CN': {
|
||||
placeholder: '搜索文档',
|
||||
resetButtonTitle: '重置搜索',
|
||||
backButtonTitle: '关闭',
|
||||
noResultsText: '无搜索结果:',
|
||||
footer: {
|
||||
selectText: '选择',
|
||||
selectKeyAriaLabel: '输入',
|
||||
navigateText: '切换',
|
||||
navigateUpKeyAriaLabel: '向上',
|
||||
navigateDownKeyAriaLabel: '向下',
|
||||
closeText: '关闭',
|
||||
closeKeyAriaLabel: '退出',
|
||||
},
|
||||
},
|
||||
'en-US': {
|
||||
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 resolvedDocsearchOption(app: App, options: DocsearchPluginOptions): DocsearchPluginOptions {
|
||||
|
||||
@ -24,7 +24,38 @@ export function plumeTheme({
|
||||
clientConfigFile: resolve('client/config.js'),
|
||||
plugins: setupPlugins(app, pluginsOptions, localeOptions),
|
||||
onInitialized: app => setupPage(app, localeOptions),
|
||||
extendsPage: page => extendsPageData(app, page as Page<PlumeThemePageData>, localeOptions),
|
||||
extendsPage: (page) => {
|
||||
extendsPageData(app, page as Page<PlumeThemePageData>, localeOptions)
|
||||
|
||||
page.frontmatter.head ??= []
|
||||
if (localeOptions.appearance ?? true) {
|
||||
const appearance = typeof localeOptions.appearance === 'string'
|
||||
? localeOptions.appearance
|
||||
: 'auto'
|
||||
|
||||
page.frontmatter.head.push([
|
||||
'script',
|
||||
{ id: 'check-dark-mode' },
|
||||
appearance === 'force-dark'
|
||||
? `document.documentElement.classList.add('dark')`
|
||||
: `;(function () {
|
||||
const um= localStorage.getItem('vuepress-theme-appearance') || '${appearance}';
|
||||
const sm =
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (um === 'dark' || (um !== 'light' && sm)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();`,
|
||||
])
|
||||
}
|
||||
|
||||
page.frontmatter.head?.push([
|
||||
'script',
|
||||
{ id: 'check-mac-os' },
|
||||
`document.documentElement.classList.toggle('mac', /Mac|iPhone|iPod|iPad/i.test(navigator.platform))`,
|
||||
])
|
||||
},
|
||||
templateBuildRenderer(template, context) {
|
||||
template = template
|
||||
.replace('{{ themeVersion }}', pkg.version || '')
|
||||
|
||||
@ -114,7 +114,7 @@ export interface PlumeThemeLocaleData extends LocaleData {
|
||||
/**
|
||||
* 是否启用深色模式切换按钮
|
||||
*/
|
||||
appearance?: boolean | 'dark'
|
||||
appearance?: boolean | 'dark' | 'force-dark'
|
||||
|
||||
/**
|
||||
* 深色模式切换按钮的文本
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DocsearchOptions } from '@vuepress/plugin-docsearch'
|
||||
import type { SearchPluginOptions } from '@vuepress/plugin-search'
|
||||
import type { SearchPluginOptions } from '@vuepress-plume/plugin-search'
|
||||
import type { AutoFrontmatterOptions } from '@vuepress-plume/plugin-auto-frontmatter'
|
||||
import type { BaiduTongjiOptions } from '@vuepress-plume/plugin-baidu-tongji'
|
||||
import type { CanIUsePluginOptions } from '@vuepress-plume/plugin-caniuse'
|
||||
|
||||
@ -5,17 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="generator" content="VuePress {{ version }}" />
|
||||
<meta name="theme" content="VuePress Theme Plume {{ themeVersion }}" />
|
||||
<script>
|
||||
;(function () {
|
||||
const um = localStorage.getItem('vuepress-theme-appearance');
|
||||
const sm =
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (um === 'dark' || (um !== 'light' && sm)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<!--vuepress-ssr-head-->
|
||||
<!--vuepress-ssr-styles-->
|
||||
<!--vuepress-ssr-preload-->
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user