diff --git a/docs/plugins/flexsearch.md b/docs/plugins/flexsearch.md
new file mode 100644
index 0000000000..bc9bcbc259
--- /dev/null
+++ b/docs/plugins/flexsearch.md
@@ -0,0 +1,170 @@
+# flexsearch
+
+
+
+Provide local search to your documentation site.
+
+Unlike the `search` plugin, the `flexsearch` plugin uses the [FlexSearch](https://github.com/nextapps-de/flexsearch) library and pre-indexes both the title and the content of the pages.
+
+::: tip
+Default theme will add search box to the navbar once you configure this plugin correctly.
+
+This plugin may not be used directly in other themes, so you'd better refer to the documentation of your theme for more details.
+:::
+
+## Usage
+
+```bash
+npm i -D @vuepress/plugin-flexsearch@next
+```
+
+```ts
+import { flexsearchPlugin } from '@vuepress/plugin-flexsearch'
+
+export default {
+ plugins: [
+ flexsearchPlugin({
+ // options
+ }),
+ ],
+}
+```
+
+## Local Search Index
+
+This plugin will generate search index from your pages locally, and load the search index file when users enter your site. In other words, this is a lightweight built-in search which does not require any external requests.
+
+While this plugin should be able to handle more pages than the `search` plugin, as it is using an index, the limit of how many pages it can support has not been tested yet. Please refer to [FlexSearch](https://github.com/nextapps-de/flexsearch) benchmarks for more detailed info.
+
+Another alternative to this plugin is to use a more professional solution - [docsearch](./docsearch.md).
+
+## Options
+
+### locales
+
+- Type: `Record`
+
+- Details:
+
+ The text of the search box in different locales.
+
+ If this option is not specified, it will fallback to default text.
+
+- Example:
+
+```ts
+export default {
+ plugins: [
+ flexsearchPlugin({
+ locales: {
+ '/': {
+ placeholder: 'Search',
+ },
+ '/zh/': {
+ placeholder: '搜索',
+ },
+ },
+ }),
+ ],
+}
+```
+
+- Also see:
+ - [Guide > I18n](https://vuejs.press/guide/i18n.html)
+
+### hotKeys
+
+- Type: `(string | HotKeyOptions)[]`
+
+@[code ts](@vuepress/plugin-flexsearch/src/shared/hotKey.ts)
+
+- Default: `['s', '/']`
+
+- Details:
+
+ Specify the [event.key](http://keycode.info/) of the hotkeys.
+
+ When hotkeys are pressed, the search box input will be focused.
+
+ Set to an empty array to disable hotkeys.
+
+### maxSuggestions
+
+- Type: `number`
+
+- Default: `5`
+
+- Details:
+
+ Specify the maximum number of search results.
+
+### isSearchable
+
+- Type: `(page: Page) => boolean`
+
+- Default: `() => true`
+
+- Details:
+
+ A function to determine whether a page should be included in the search index.
+
+ - Return `true` to include the page.
+ - Return `false` to exclude the page.
+
+- Example:
+
+```ts
+export default {
+ plugins: [
+ flexsearchPlugin({
+ // exclude the homepage
+ isSearchable: (page) => page.path !== '/',
+ }),
+ ],
+}
+```
+
+### getExtraFields
+
+- Type: `(page: Page) => string[]`
+
+- Default: `() => []`
+
+- Details:
+
+ A function to add extra fields to the search index of a page.
+
+ By default, this plugin will use page title and headers as the search index. This option could help you to add more searchable fields.
+
+- Example:
+
+```ts
+export default {
+ plugins: [
+ searchPlugin({
+ // allow searching the `tags` frontmatter
+ getExtraFields: (page) => page.frontmatter.tags ?? [],
+ }),
+ ],
+}
+```
+
+## Styles
+
+You can customize the style of the search box via CSS variables:
+
+@[code css](@vuepress/plugin-flexsearch/src/client/styles/vars.css)
+
+## Components
+
+### SearchBox
+
+- Details:
+
+ This plugin will register a `` component globally, and you can use it without any props.
+
+ Put this component to where you want to place the search box. For example, default theme puts this component to the end of the navbar.
+
+::: tip
+This component is mainly used for theme development. You don't need to use it directly in most cases.
+:::
diff --git a/plugins/plugin-flexsearch/CHANGELOG.md b/plugins/plugin-flexsearch/CHANGELOG.md
new file mode 100644
index 0000000000..e4d87c4d45
--- /dev/null
+++ b/plugins/plugin-flexsearch/CHANGELOG.md
@@ -0,0 +1,4 @@
+# Change Log
+
+All notable changes to this project will be documented in this file.
+See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
diff --git a/plugins/plugin-flexsearch/package.json b/plugins/plugin-flexsearch/package.json
new file mode 100644
index 0000000000..2d353c4c4b
--- /dev/null
+++ b/plugins/plugin-flexsearch/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@vuepress/plugin-flexsearch",
+ "version": "2.0.0-rc.18",
+ "description": "VuePress plugin - built-in search using flexsearch",
+ "keywords": [
+ "vuepress-plugin",
+ "vuepress",
+ "plugin",
+ "search"
+ ],
+ "homepage": "https://ecosystem.vuejs.press/plugins/flexsearch.html",
+ "bugs": {
+ "url": "https://github.com/vuepress/ecosystem/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/vuepress/ecosystem.git",
+ "directory": "plugins/plugin-flexsearch"
+ },
+ "license": "MIT",
+ "author": "svalaskevicius",
+ "type": "module",
+ "exports": {
+ ".": "./lib/node/index.js",
+ "./client": "./lib/client/index.js",
+ "./package.json": "./package.json"
+ },
+ "main": "./lib/node/index.js",
+ "types": "./lib/node/index.d.ts",
+ "files": [
+ "lib"
+ ],
+ "scripts": {
+ "build": "tsc -b tsconfig.build.json",
+ "clean": "rimraf --glob ./lib ./*.tsbuildinfo",
+ "copy": "cpx \"src/**/*.{d.ts,svg}\" lib",
+ "style": "sass src:lib --no-source-map"
+ },
+ "dependencies": {
+ "chokidar": "^3.6.0",
+ "flexsearch": "^0.6",
+ "he": "^1.2.0",
+ "vue": "^3.4.21"
+ },
+ "peerDependencies": {
+ "vuepress": "2.0.0-rc.8"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/plugins/plugin-flexsearch/src/client/components/SearchBox.ts b/plugins/plugin-flexsearch/src/client/components/SearchBox.ts
new file mode 100644
index 0000000000..c9ac06e22c
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/components/SearchBox.ts
@@ -0,0 +1,175 @@
+import { computed, defineComponent, h, ref, toRefs } from 'vue'
+import type { PropType } from 'vue'
+import { useRouteLocale, useRouter } from 'vuepress/client'
+import type { LocaleConfig } from 'vuepress/shared'
+import type { HotKeyOptions } from '../../shared/index.js'
+import {
+ findInIndex,
+ useHotKeys,
+ useSearchSuggestions,
+ useSuggestionsFocus,
+} from '../composables/index.js'
+
+export type SearchBoxLocales = LocaleConfig<{
+ placeholder: string
+}>
+
+export const SearchBox = defineComponent({
+ name: 'SearchBox',
+
+ props: {
+ locales: {
+ type: Object as PropType,
+ required: false,
+ default: () => ({}),
+ },
+
+ hotKeys: {
+ type: Array as PropType<(string | HotKeyOptions)[]>,
+ required: false,
+ default: () => [],
+ },
+
+ maxSuggestions: {
+ type: Number,
+ required: false,
+ default: 5,
+ },
+ },
+
+ setup(props) {
+ const { locales, hotKeys, maxSuggestions } = toRefs(props)
+
+ const router = useRouter()
+ const routeLocale = useRouteLocale()
+
+ const input = ref(null)
+ const isActive = ref(false)
+ const query = ref('')
+ const locale = computed(() => locales.value[routeLocale.value] ?? {})
+
+ const suggestions = useSearchSuggestions({
+ findInIndex,
+ routeLocale,
+ query,
+ maxSuggestions,
+ })
+ const { focusIndex, focusNext, focusPrev } =
+ useSuggestionsFocus(suggestions)
+ useHotKeys({ input, hotKeys })
+
+ const showSuggestions = computed(
+ () => isActive.value && !!suggestions.value.length,
+ )
+ const onArrowUp = (): void => {
+ if (!showSuggestions.value) {
+ return
+ }
+ focusPrev()
+ }
+ const onArrowDown = (): void => {
+ if (!showSuggestions.value) {
+ return
+ }
+ focusNext()
+ }
+ const goTo = (index: number): void => {
+ if (!showSuggestions.value) {
+ return
+ }
+
+ const suggestion = suggestions.value[index]
+ if (!suggestion) {
+ return
+ }
+
+ router.push(suggestion.link).then(() => {
+ query.value = ''
+ focusIndex.value = 0
+ })
+ }
+
+ return () =>
+ h(
+ 'form',
+ {
+ class: 'search-box',
+ role: 'search',
+ },
+ [
+ h('input', {
+ ref: input,
+ type: 'search',
+ placeholder: locale.value.placeholder,
+ autocomplete: 'off',
+ spellcheck: false,
+ value: query.value,
+ onFocus: () => (isActive.value = true),
+ onBlur: () => (isActive.value = false),
+ onInput: (event) =>
+ (query.value = (event.target as HTMLInputElement).value),
+ onKeydown: (event) => {
+ switch (event.key) {
+ case 'ArrowUp': {
+ onArrowUp()
+ break
+ }
+ case 'ArrowDown': {
+ onArrowDown()
+ break
+ }
+ case 'Enter': {
+ event.preventDefault()
+ goTo(focusIndex.value)
+ break
+ }
+ }
+ },
+ }),
+ showSuggestions.value &&
+ h(
+ 'ul',
+ {
+ class: 'suggestions',
+ onMouseleave: () => (focusIndex.value = -1),
+ },
+ suggestions.value.map(({ link, title, text }, index) =>
+ h(
+ 'li',
+ {
+ class: [
+ 'suggestion',
+ {
+ focus: focusIndex.value === index,
+ },
+ ],
+ onMouseenter: () => (focusIndex.value = index),
+ onMousedown: () => goTo(index),
+ },
+ h(
+ 'a',
+ {
+ href: link,
+ onClick: (event) => event.preventDefault(),
+ },
+ [
+ h(
+ 'span',
+ {
+ class: 'page-title',
+ },
+ title,
+ ),
+ h('span', {
+ class: 'suggestion-result',
+ innerHTML: text,
+ }),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ )
+ },
+})
diff --git a/plugins/plugin-flexsearch/src/client/components/index.ts b/plugins/plugin-flexsearch/src/client/components/index.ts
new file mode 100644
index 0000000000..b10d63682e
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/components/index.ts
@@ -0,0 +1 @@
+export * from './SearchBox.js'
diff --git a/plugins/plugin-flexsearch/src/client/composables/findInIndex.ts b/plugins/plugin-flexsearch/src/client/composables/findInIndex.ts
new file mode 100644
index 0000000000..2fde497f65
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/composables/findInIndex.ts
@@ -0,0 +1,30 @@
+import { searchIndex as searchIndexRaw } from '@internal/searchIndex'
+import FS from 'flexsearch'
+
+export interface SearchIndexRet {
+ path: string
+ title: string
+ content: string
+}
+
+export type ClientSideSearchIndex = (string, number) => SearchIndexRet[]
+
+const index = FS.create({
+ async: false,
+ doc: {
+ id: 'id',
+ field: ['title', 'content'],
+ },
+})
+index.import(searchIndexRaw.idx)
+
+export const findInIndex: ClientSideSearchIndex = (q: string, c: number) => {
+ const searchResult: any = index.search(q, c)
+ return searchResult.map((r) => {
+ return {
+ path: searchIndexRaw.paths[r.id],
+ title: r.title,
+ content: r.content,
+ }
+ })
+}
diff --git a/plugins/plugin-flexsearch/src/client/composables/index.ts b/plugins/plugin-flexsearch/src/client/composables/index.ts
new file mode 100644
index 0000000000..1ddd24edc0
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/composables/index.ts
@@ -0,0 +1,4 @@
+export * from './useHotKeys.js'
+export * from './findInIndex.js'
+export * from './useSearchSuggestions.js'
+export * from './useSuggestionsFocus.js'
diff --git a/plugins/plugin-flexsearch/src/client/composables/useHotKeys.ts b/plugins/plugin-flexsearch/src/client/composables/useHotKeys.ts
new file mode 100644
index 0000000000..58c66417f3
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/composables/useHotKeys.ts
@@ -0,0 +1,36 @@
+import { onBeforeUnmount, onMounted } from 'vue'
+import type { Ref } from 'vue'
+import type { HotKeyOptions } from '../../shared/index.js'
+import { isFocusingTextControl, isKeyMatched } from '../utils/index.js'
+
+export const useHotKeys = ({
+ input,
+ hotKeys,
+}: {
+ input: Ref
+ hotKeys: Ref<(string | HotKeyOptions)[]>
+}): void => {
+ if (hotKeys.value.length === 0) return
+
+ const onKeydown = (event: KeyboardEvent): void => {
+ if (!input.value) return
+ if (
+ // key matches
+ isKeyMatched(event, hotKeys.value) &&
+ // event does not come from the search box itself or
+ // user isn't focusing (and thus perhaps typing in) a text control
+ !isFocusingTextControl(event.target as EventTarget)
+ ) {
+ event.preventDefault()
+ input.value.focus()
+ }
+ }
+
+ onMounted(() => {
+ document.addEventListener('keydown', onKeydown)
+ })
+
+ onBeforeUnmount(() => {
+ document.removeEventListener('keydown', onKeydown)
+ })
+}
diff --git a/plugins/plugin-flexsearch/src/client/composables/useSearchSuggestions.ts b/plugins/plugin-flexsearch/src/client/composables/useSearchSuggestions.ts
new file mode 100644
index 0000000000..375ef4dc5c
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/composables/useSearchSuggestions.ts
@@ -0,0 +1,87 @@
+import { computed } from 'vue'
+import type { ComputedRef, Ref } from 'vue'
+import type { ClientSideSearchIndex } from './findInIndex.js'
+
+export interface SearchSuggestion {
+ link: string
+ title: string
+ text: string
+}
+
+const escapeRegExp = (input: string): string => {
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+const highlightText = (
+ fullText: string,
+ highlightTarget: string,
+ splitBy: string,
+): string => {
+ let result = fullText
+ const highlightWords = highlightTarget.split(splitBy).filter(Boolean)
+ if (highlightWords.length > 0) {
+ for (const word of highlightWords) {
+ result = result.replace(
+ new RegExp(escapeRegExp(word), 'ig'),
+ '$&',
+ )
+ }
+ } else {
+ result = fullText.replace(
+ new RegExp(escapeRegExp(highlightTarget), 'ig'),
+ '$&',
+ )
+ }
+
+ return result
+}
+
+const getSuggestionText = (
+ content: string,
+ query: string,
+ maxLen: number,
+): string => {
+ const queryIndex = content.toLowerCase().indexOf(query.toLowerCase())
+ const queryFirstWord = query.split(' ')[0]
+ let startIndex =
+ queryIndex === -1
+ ? content.toLowerCase().indexOf(queryFirstWord.toLowerCase())
+ : queryIndex
+ let prefix = ''
+ if (startIndex > 15) {
+ startIndex -= 15
+ prefix = '.. '
+ }
+ const text = content.substr(startIndex, maxLen)
+ return prefix + highlightText(text, query, ' ')
+}
+
+export const useSearchSuggestions = ({
+ findInIndex,
+ routeLocale,
+ query,
+ maxSuggestions,
+}: {
+ findInIndex: ClientSideSearchIndex
+ routeLocale: Ref
+ query: Ref
+ maxSuggestions: Ref
+}): ComputedRef => {
+ return computed(() => {
+ const searchStr = query.value.trim().toLowerCase()
+ if (!searchStr) return []
+
+ const suggestions: SearchSuggestion[] = findInIndex(
+ searchStr,
+ maxSuggestions.value,
+ ).map((r) => {
+ return {
+ link: r.path,
+ title: r.title,
+ text: getSuggestionText(r.content, searchStr, 30),
+ }
+ })
+
+ return suggestions
+ })
+}
diff --git a/plugins/plugin-flexsearch/src/client/composables/useSuggestionsFocus.ts b/plugins/plugin-flexsearch/src/client/composables/useSuggestionsFocus.ts
new file mode 100644
index 0000000000..cb4dcc4f00
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/composables/useSuggestionsFocus.ts
@@ -0,0 +1,32 @@
+import { ref } from 'vue'
+import type { Ref } from 'vue'
+
+export const useSuggestionsFocus = (
+ suggestions: Ref,
+): {
+ focusIndex: Ref
+ focusNext: () => void
+ focusPrev: () => void
+} => {
+ const focusIndex = ref(0)
+ const focusNext = (): void => {
+ if (focusIndex.value < suggestions.value.length - 1) {
+ focusIndex.value += 1
+ } else {
+ focusIndex.value = 0
+ }
+ }
+ const focusPrev = (): void => {
+ if (focusIndex.value > 0) {
+ focusIndex.value -= 1
+ } else {
+ focusIndex.value = suggestions.value.length - 1
+ }
+ }
+
+ return {
+ focusIndex,
+ focusNext,
+ focusPrev,
+ }
+}
diff --git a/plugins/plugin-flexsearch/src/client/config.ts b/plugins/plugin-flexsearch/src/client/config.ts
new file mode 100644
index 0000000000..3955bd816b
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/config.ts
@@ -0,0 +1,30 @@
+import { h } from 'vue'
+import { defineClientConfig } from 'vuepress/client'
+import type { ClientConfig } from 'vuepress/client'
+import { SearchBox } from './components/index.js'
+import type { SearchBoxLocales } from './components/index.js'
+
+import './styles/vars.css'
+import './styles/search.css'
+
+declare const __SEARCH_LOCALES__: SearchBoxLocales
+declare const __SEARCH_HOT_KEYS__: string[]
+declare const __SEARCH_MAX_SUGGESTIONS__: number
+
+const locales = __SEARCH_LOCALES__
+const hotKeys = __SEARCH_HOT_KEYS__
+const maxSuggestions = __SEARCH_MAX_SUGGESTIONS__
+
+export default defineClientConfig({
+ enhance({ app }) {
+ // wrap the `` component with plugin options
+ app.component('SearchBox', (props) =>
+ h(SearchBox, {
+ locales,
+ hotKeys,
+ maxSuggestions,
+ ...props,
+ }),
+ )
+ },
+}) as ClientConfig
diff --git a/plugins/plugin-flexsearch/src/client/index.ts b/plugins/plugin-flexsearch/src/client/index.ts
new file mode 100644
index 0000000000..ec537fba3c
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/index.ts
@@ -0,0 +1,3 @@
+export * from './components/index.js'
+export * from './composables/index.js'
+export * from './utils/index.js'
diff --git a/plugins/plugin-flexsearch/src/client/searchIndex.d.ts b/plugins/plugin-flexsearch/src/client/searchIndex.d.ts
new file mode 100644
index 0000000000..8d35edd494
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/searchIndex.d.ts
@@ -0,0 +1,5 @@
+import type { SearchIndex } from '../shared/index.js'
+
+declare module '@internal/searchIndex' {
+ export const searchIndex: SearchIndex
+}
diff --git a/plugins/plugin-flexsearch/src/client/styles/search.scss b/plugins/plugin-flexsearch/src/client/styles/search.scss
new file mode 100644
index 0000000000..6e6af22139
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/styles/search.scss
@@ -0,0 +1,96 @@
+.search-box {
+ display: inline-block;
+ position: relative;
+ margin-left: 1rem;
+
+ @media print {
+ display: none;
+ }
+
+ input {
+ -webkit-appearance: none;
+ appearance: none;
+ cursor: text;
+ width: var(--search-input-width);
+ height: 2rem;
+ color: var(--search-text-color);
+ display: inline-block;
+ border: 1px solid var(--search-border-color);
+ border-radius: 2rem;
+ font-size: 0.9rem;
+ line-height: 2rem;
+ padding: 0 0.5rem 0 2rem;
+ outline: none;
+ transition: all ease 0.3s;
+ background: var(--search-bg-color) url('search.svg') 0.6rem 0.5rem no-repeat;
+ background-size: 1rem;
+
+ @media (max-width: 719px) {
+ cursor: pointer;
+ width: 0;
+ border-color: transparent;
+ position: relative;
+ }
+
+ &:focus {
+ cursor: auto;
+ border-color: var(--search-accent-color);
+
+ @media (max-width: 719px) {
+ cursor: text;
+ left: 0;
+ width: 10rem;
+ }
+
+ @media (max-width: 419px) {
+ width: 8rem;
+ }
+ }
+ }
+
+ .suggestions {
+ background: var(--search-bg-color);
+ width: var(--search-result-width);
+ position: absolute;
+ top: 2rem;
+ right: 0;
+ border: 1px solid var(--search-border-color);
+ border-radius: 6px;
+ padding: 0.4rem;
+ list-style-type: none;
+
+ @media (max-width: 419px) {
+ width: calc(100vw - 4rem);
+ right: -0.5rem;
+ }
+ }
+
+ .suggestion {
+ line-height: 1.4;
+ padding: 0.4rem 0.6rem;
+ border-radius: 4px;
+ cursor: pointer;
+
+ a {
+ white-space: normal;
+ color: var(--search-item-text-color);
+ }
+
+ &.focus {
+ background-color: var(--search-item-focus-bg-color);
+
+ a {
+ color: var(--search-accent-color);
+ }
+ }
+
+ .page-title {
+ font-weight: 600;
+ }
+
+ .page-header {
+ font-size: 0.9em;
+ margin-left: 0.25em;
+ }
+ }
+}
diff --git a/plugins/plugin-flexsearch/src/client/styles/search.svg b/plugins/plugin-flexsearch/src/client/styles/search.svg
new file mode 100644
index 0000000000..03d83913e8
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/styles/search.svg
@@ -0,0 +1 @@
+
diff --git a/plugins/plugin-flexsearch/src/client/styles/vars.css b/plugins/plugin-flexsearch/src/client/styles/vars.css
new file mode 100644
index 0000000000..833dd7bd41
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/styles/vars.css
@@ -0,0 +1,12 @@
+:root {
+ --search-bg-color: #ffffff;
+ --search-accent-color: #3eaf7c;
+ --search-text-color: #2c3e50;
+ --search-border-color: #eaecef;
+
+ --search-item-text-color: #5d81a5;
+ --search-item-focus-bg-color: #f3f4f5;
+
+ --search-input-width: 8rem;
+ --search-result-width: 20rem;
+}
diff --git a/plugins/plugin-flexsearch/src/client/utils/index.ts b/plugins/plugin-flexsearch/src/client/utils/index.ts
new file mode 100644
index 0000000000..5568c0247b
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/utils/index.ts
@@ -0,0 +1,3 @@
+export * from './isFocusingTextControl.js'
+export * from './isKeyMatched.js'
+export * from './isQueryMatched.js'
diff --git a/plugins/plugin-flexsearch/src/client/utils/isFocusingTextControl.ts b/plugins/plugin-flexsearch/src/client/utils/isFocusingTextControl.ts
new file mode 100644
index 0000000000..b03189590d
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/utils/isFocusingTextControl.ts
@@ -0,0 +1,16 @@
+/**
+ * Determines whether the user is currently focusing a text control.
+ * In this case, the search plugin shouldn’t hijack any hotkeys because
+ * the user might be typing into a text field, using type-ahead search
+ * in a `select` element, etc.
+ */
+export const isFocusingTextControl = (target: EventTarget): boolean => {
+ if (!(target instanceof Element)) {
+ return false
+ }
+ return (
+ document.activeElement === target &&
+ (['TEXTAREA', 'SELECT', 'INPUT'].includes(target.tagName) ||
+ target.hasAttribute('contenteditable'))
+ )
+}
diff --git a/plugins/plugin-flexsearch/src/client/utils/isKeyMatched.ts b/plugins/plugin-flexsearch/src/client/utils/isKeyMatched.ts
new file mode 100644
index 0000000000..823bd5d650
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/utils/isKeyMatched.ts
@@ -0,0 +1,20 @@
+import { isString } from 'vuepress/shared'
+import type { HotKeyOptions } from '../../shared/index.js'
+
+export const isKeyMatched = (
+ event: KeyboardEvent,
+ hotKeys: (string | HotKeyOptions)[],
+): boolean =>
+ hotKeys.some((item) => {
+ if (isString(item)) {
+ return item === event.key
+ }
+
+ const { key, ctrl = false, shift = false, alt = false } = item
+ return (
+ key === event.key &&
+ ctrl === event.ctrlKey &&
+ shift === event.shiftKey &&
+ alt === event.altKey
+ )
+ })
diff --git a/plugins/plugin-flexsearch/src/client/utils/isQueryMatched.ts b/plugins/plugin-flexsearch/src/client/utils/isQueryMatched.ts
new file mode 100644
index 0000000000..6b1cc6d385
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/client/utils/isQueryMatched.ts
@@ -0,0 +1,38 @@
+// eslint-disable-next-line no-control-regex
+const nonASCIIRegExp = /[^\x00-\x7F]/
+
+const splitWords = (str: string): string[] =>
+ str
+ .split(/\s+/g)
+ .map((str) => str.trim())
+ .filter((str) => !!str)
+
+const escapeRegExp = (str: string): string =>
+ str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
+
+export const isQueryMatched = (query: string, toMatch: string[]): boolean => {
+ const toMatchStr = toMatch.join(' ')
+ const words = splitWords(query)
+
+ if (nonASCIIRegExp.test(query)) {
+ // if the query has non-ASCII chars, treat as other languages
+ return words.some((word) => toMatchStr.toLowerCase().indexOf(word) > -1)
+ }
+
+ // if the query only has ASCII chars, treat as English
+ const hasTrailingSpace = query.endsWith(' ')
+ const searchRegex = new RegExp(
+ words
+ .map((word, index) => {
+ if (words.length === index + 1 && !hasTrailingSpace) {
+ // The last word - ok with the word being "startsWith"-like
+ return `(?=.*\\b${escapeRegExp(word)})`
+ }
+ // Not the last word - expect the whole word exactly
+ return `(?=.*\\b${escapeRegExp(word)}\\b)`
+ })
+ .join('') + '.+',
+ 'gi',
+ )
+ return searchRegex.test(toMatchStr)
+}
diff --git a/plugins/plugin-flexsearch/src/node/flexsearchPlugin.ts b/plugins/plugin-flexsearch/src/node/flexsearchPlugin.ts
new file mode 100644
index 0000000000..455a8b03ea
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/node/flexsearchPlugin.ts
@@ -0,0 +1,90 @@
+import chokidar from 'chokidar'
+import type { Page, Plugin } from 'vuepress/core'
+import type { LocaleConfig } from 'vuepress/shared'
+import { getDirname, path } from 'vuepress/utils'
+import type { HotKeyOptions } from '../shared/index.js'
+import { prepareSearchIndex } from './prepareSearchIndex.js'
+
+const __dirname = getDirname(import.meta.url)
+
+/**
+ * Options for @vuepress/plugin-flexsearch
+ */
+export interface SearchPluginOptions {
+ /**
+ * Locales config for search box
+ */
+ locales?: LocaleConfig<{
+ placeholder: string
+ }>
+
+ /**
+ * Specify the [event.key](http://keycode.info/) of the hotkeys
+ *
+ * When hotkeys are pressed, the search box input will be focused
+ *
+ * Set to an empty array to disable hotkeys
+ *
+ * @default ['s', '/']
+ */
+ hotKeys?: (string | HotKeyOptions)[]
+
+ /**
+ * Specify the maximum number of search results
+ *
+ * @default 5
+ */
+ maxSuggestions?: number
+
+ /**
+ * A function to determine whether a page should be included in the search index
+ */
+ isSearchable?: (page: Page) => boolean
+
+ /**
+ * A function to add extra fields to the search index of a page
+ */
+ getExtraFields?: (page: Page) => string[]
+}
+
+export const flexsearchPlugin = ({
+ locales = {},
+ hotKeys = ['s', '/'],
+ maxSuggestions = 5,
+ isSearchable = () => true,
+ getExtraFields = () => [],
+}: SearchPluginOptions = {}): Plugin => ({
+ name: '@vuepress/plugin-flexsearch',
+
+ clientConfigFile: path.resolve(__dirname, '../client/config.js'),
+
+ define: {
+ __SEARCH_LOCALES__: locales,
+ __SEARCH_HOT_KEYS__: hotKeys,
+ __SEARCH_MAX_SUGGESTIONS__: maxSuggestions,
+ },
+
+ onPrepared: async (app) => {
+ await prepareSearchIndex({ app, isSearchable, getExtraFields })
+ },
+
+ onWatched: (app, watchers) => {
+ // here we only watch the page data files
+ // if the extra fields generated by `getExtraFields` are not included
+ // in the page data, the changes may not be watched
+ const searchIndexWatcher = chokidar.watch('pages/**/*.js', {
+ cwd: app.dir.temp(),
+ ignoreInitial: true,
+ })
+ searchIndexWatcher.on('add', () => {
+ prepareSearchIndex({ app, isSearchable, getExtraFields })
+ })
+ searchIndexWatcher.on('change', () => {
+ prepareSearchIndex({ app, isSearchable, getExtraFields })
+ })
+ searchIndexWatcher.on('unlink', () => {
+ prepareSearchIndex({ app, isSearchable, getExtraFields })
+ })
+ watchers.push(searchIndexWatcher)
+ },
+})
diff --git a/plugins/plugin-flexsearch/src/node/index.ts b/plugins/plugin-flexsearch/src/node/index.ts
new file mode 100644
index 0000000000..2780a7be54
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/node/index.ts
@@ -0,0 +1,3 @@
+export * from './prepareSearchIndex.js'
+export * from './flexsearchPlugin.js'
+export type * from '../shared/index.js'
diff --git a/plugins/plugin-flexsearch/src/node/prepareSearchIndex.ts b/plugins/plugin-flexsearch/src/node/prepareSearchIndex.ts
new file mode 100644
index 0000000000..cd1e194aae
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/node/prepareSearchIndex.ts
@@ -0,0 +1,75 @@
+import FS from 'flexsearch'
+import he from 'he/he.js'
+import type { App } from 'vuepress/core'
+import type { SearchPluginOptions } from './flexsearchPlugin.js'
+
+const HMR_CODE = `
+if (import.meta.webpackHot) {
+ import.meta.webpackHot.accept()
+ if (__VUE_HMR_RUNTIME__.updateSearchIndex) {
+ __VUE_HMR_RUNTIME__.updateSearchIndex(searchIndex)
+ }
+}
+
+if (import.meta.hot) {
+ import.meta.hot.accept(({ searchIndex }) => {
+ __VUE_HMR_RUNTIME__.updateSearchIndex(searchIndex)
+ })
+}
+`
+
+const prepContent = (html: string): string => {
+ const text = he.decode(
+ // decode HTML entities like "
+ html
+ .replace(/<[^>]*(>|$)/g, ' ') // remove HTML tag
+ .replace(/^\s*#\s/gm, ''), // remove header anchors inserted by vuepress
+ )
+ return text
+}
+
+export const prepareSearchIndex = async ({
+ app,
+ isSearchable,
+ getExtraFields,
+}: {
+ app: App
+ isSearchable: Required['isSearchable']
+ getExtraFields: Required['getExtraFields']
+}): Promise => {
+ // generate search index
+ const pages = app.pages.filter(isSearchable)
+
+ const index = FS.create({
+ doc: {
+ id: 'id',
+ field: ['title', 'content'],
+ },
+ })
+
+ const paths: string[] = []
+ let nextId = 0
+ pages.forEach((p) => {
+ paths.push(p.path)
+ const id = nextId++
+ const d = { id, title: p.title, content: prepContent(p.contentRendered) }
+ index.add(d)
+ })
+
+ const data = index.export()
+
+ // search index file content
+ let content = `\
+export const searchIndex = {
+ paths: ${JSON.stringify(paths, null, 2)},
+ idx: ${JSON.stringify(data, null, 2)}
+}
+`
+
+ // inject HMR code
+ if (app.env.isDev) {
+ content += HMR_CODE
+ }
+
+ return app.writeTemp('internal/searchIndex.js', content)
+}
diff --git a/plugins/plugin-flexsearch/src/shared/hotKey.ts b/plugins/plugin-flexsearch/src/shared/hotKey.ts
new file mode 100644
index 0000000000..b05311f798
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/shared/hotKey.ts
@@ -0,0 +1,27 @@
+export interface HotKeyOptions {
+ /**
+ * Value of `event.key` to trigger the hot key
+ */
+ key: string
+
+ /**
+ * Whether to press `event.altKey` at the same time
+ *
+ * @default false
+ */
+ alt?: boolean
+
+ /**
+ * Whether to press `event.ctrlKey` at the same time
+ *
+ * @default false
+ */
+ ctrl?: boolean
+
+ /**
+ * Whether to press `event.shiftKey` at the same time
+ *
+ * @default false
+ */
+ shift?: boolean
+}
diff --git a/plugins/plugin-flexsearch/src/shared/index.ts b/plugins/plugin-flexsearch/src/shared/index.ts
new file mode 100644
index 0000000000..3e3f1de2fe
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/shared/index.ts
@@ -0,0 +1,2 @@
+export * from './hotKey.js'
+export * from './searchIndex.js'
diff --git a/plugins/plugin-flexsearch/src/shared/searchIndex.ts b/plugins/plugin-flexsearch/src/shared/searchIndex.ts
new file mode 100644
index 0000000000..0c16bea121
--- /dev/null
+++ b/plugins/plugin-flexsearch/src/shared/searchIndex.ts
@@ -0,0 +1,4 @@
+export interface SearchIndex {
+ paths: string[]
+ idx: any
+}
diff --git a/plugins/plugin-flexsearch/tsconfig.build.json b/plugins/plugin-flexsearch/tsconfig.build.json
new file mode 100644
index 0000000000..9db5c70796
--- /dev/null
+++ b/plugins/plugin-flexsearch/tsconfig.build.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.build.json",
+ "compilerOptions": {
+ "rootDir": "./src",
+ "outDir": "./lib",
+ "baseUrl": ".",
+ "paths": {
+ "@internal/searchIndex": ["./src/client/searchIndex.d.ts"]
+ },
+ "types": ["vuepress/client-types", "vite/client", "webpack-env"]
+ },
+ "include": ["./src"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e4077487e4..ef195d2ae9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -413,6 +413,24 @@ importers:
specifier: workspace:*
version: link:../plugin-git
+ plugins/plugin-flexsearch:
+ dependencies:
+ chokidar:
+ specifier: ^3.6.0
+ version: 3.6.0
+ flexsearch:
+ specifier: ^0.6
+ version: 0.6.32
+ he:
+ specifier: ^1.2.0
+ version: 1.2.0
+ vue:
+ specifier: ^3.4.21
+ version: 3.4.21(typescript@5.4.2)
+ vuepress:
+ specifier: 2.0.0-rc.8
+ version: 2.0.0-rc.8(@vuepress/bundler-vite@2.0.0-rc.8)(@vuepress/bundler-webpack@2.0.0-rc.8)(typescript@5.4.2)(vue@3.4.21)
+
plugins/plugin-git:
dependencies:
execa:
@@ -6943,6 +6961,10 @@ packages:
resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==}
dev: true
+ /flexsearch@0.6.32:
+ resolution: {integrity: sha512-EF1BWkhwoeLtbIlDbY/vDSLBen/E5l/f1Vg7iX5CDymQCamcx1vhlc3tIZxIDplPjgi0jhG37c67idFbjg+v+Q==}
+ dev: false
+
/follow-redirects@1.15.6(debug@2.6.9):
resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==}
engines: {node: '>=4.0'}
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 44e2d38062..ab735250b7 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -23,6 +23,7 @@
{
"path": "./plugins/plugin-feed/tsconfig.build.json"
},
+ { "path": "./plugins/plugin-flexsearch/tsconfig.build.json" },
{ "path": "./plugins/plugin-git/tsconfig.build.json" },
{
"path": "./plugins/plugin-google-analytics/tsconfig.build.json"