From 7e2f3bfd5ac1533e95b969b8a5aa866404c1f1e3 Mon Sep 17 00:00:00 2001 From: Sarunas Valaskevicius Date: Thu, 21 Mar 2024 09:51:56 +0000 Subject: [PATCH] feat: init flexsearch plugin --- plugins/plugin-flexsearch/CHANGELOG.md | 56 ++++++ plugins/plugin-flexsearch/package.json | 51 +++++ .../src/client/components/SearchBox.ts | 176 ++++++++++++++++++ .../src/client/components/index.ts | 1 + .../src/client/composables/index.ts | 4 + .../src/client/composables/useHotKeys.ts | 36 ++++ .../src/client/composables/useSearchIndex.ts | 30 +++ .../composables/useSearchSuggestions.ts | 81 ++++++++ .../client/composables/useSuggestionsFocus.ts | 32 ++++ .../plugin-flexsearch/src/client/config.ts | 30 +++ plugins/plugin-flexsearch/src/client/index.ts | 5 + .../src/client/searchIndex.d.ts | 5 + .../src/client/styles/search.css | 94 ++++++++++ .../src/client/styles/search.css.map | 1 + .../src/client/styles/search.scss | 96 ++++++++++ .../src/client/styles/search.svg | 1 + .../src/client/styles/vars.css | 12 ++ .../src/client/utils/index.ts | 3 + .../src/client/utils/isFocusingTextControl.ts | 16 ++ .../src/client/utils/isKeyMatched.ts | 20 ++ .../src/client/utils/isQueryMatched.ts | 38 ++++ plugins/plugin-flexsearch/src/node/index.ts | 7 + .../src/node/prepareSearchIndex.ts | 75 ++++++++ .../src/node/searchPlugin.ts | 90 +++++++++ .../plugin-flexsearch/src/shared/hotKey.ts | 27 +++ plugins/plugin-flexsearch/src/shared/index.ts | 3 + .../src/shared/searchIndex.ts | 4 + plugins/plugin-flexsearch/tsconfig.build.json | 13 ++ pnpm-lock.yaml | 22 +++ tsconfig.build.json | 1 + 30 files changed, 1030 insertions(+) create mode 100644 plugins/plugin-flexsearch/CHANGELOG.md create mode 100644 plugins/plugin-flexsearch/package.json create mode 100644 plugins/plugin-flexsearch/src/client/components/SearchBox.ts create mode 100644 plugins/plugin-flexsearch/src/client/components/index.ts create mode 100644 plugins/plugin-flexsearch/src/client/composables/index.ts create mode 100644 plugins/plugin-flexsearch/src/client/composables/useHotKeys.ts create mode 100644 plugins/plugin-flexsearch/src/client/composables/useSearchIndex.ts create mode 100644 plugins/plugin-flexsearch/src/client/composables/useSearchSuggestions.ts create mode 100644 plugins/plugin-flexsearch/src/client/composables/useSuggestionsFocus.ts create mode 100644 plugins/plugin-flexsearch/src/client/config.ts create mode 100644 plugins/plugin-flexsearch/src/client/index.ts create mode 100644 plugins/plugin-flexsearch/src/client/searchIndex.d.ts create mode 100644 plugins/plugin-flexsearch/src/client/styles/search.css create mode 100644 plugins/plugin-flexsearch/src/client/styles/search.css.map create mode 100644 plugins/plugin-flexsearch/src/client/styles/search.scss create mode 100644 plugins/plugin-flexsearch/src/client/styles/search.svg create mode 100644 plugins/plugin-flexsearch/src/client/styles/vars.css create mode 100644 plugins/plugin-flexsearch/src/client/utils/index.ts create mode 100644 plugins/plugin-flexsearch/src/client/utils/isFocusingTextControl.ts create mode 100644 plugins/plugin-flexsearch/src/client/utils/isKeyMatched.ts create mode 100644 plugins/plugin-flexsearch/src/client/utils/isQueryMatched.ts create mode 100644 plugins/plugin-flexsearch/src/node/index.ts create mode 100644 plugins/plugin-flexsearch/src/node/prepareSearchIndex.ts create mode 100644 plugins/plugin-flexsearch/src/node/searchPlugin.ts create mode 100644 plugins/plugin-flexsearch/src/shared/hotKey.ts create mode 100644 plugins/plugin-flexsearch/src/shared/index.ts create mode 100644 plugins/plugin-flexsearch/src/shared/searchIndex.ts create mode 100644 plugins/plugin-flexsearch/tsconfig.build.json diff --git a/plugins/plugin-flexsearch/CHANGELOG.md b/plugins/plugin-flexsearch/CHANGELOG.md new file mode 100644 index 0000000000..3b98bb3baa --- /dev/null +++ b/plugins/plugin-flexsearch/CHANGELOG.md @@ -0,0 +1,56 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [2.0.0-rc.18](https://github.com/vuepress/ecosystem/compare/v2.0.0-rc.17...v2.0.0-rc.18) (2024-02-29) + +**Note:** Version bump only for package @vuepress/plugin-search + +# [2.0.0-rc.15](https://github.com/vuepress/ecosystem/compare/v2.0.0-rc.14...v2.0.0-rc.15) (2024-02-19) + +**Note:** Version bump only for package @vuepress/plugin-search + +# [2.0.0-rc.14](https://github.com/vuepress/ecosystem/compare/v2.0.0-rc.13...v2.0.0-rc.14) (2024-02-08) + +**Note:** Version bump only for package @vuepress/plugin-search + +# [2.0.0-rc.12](https://github.com/vuepress/ecosystem/compare/v2.0.0-rc.11...v2.0.0-rc.12) (2024-02-06) + +**Note:** Version bump only for package @vuepress/plugin-search + +# [2.0.0-rc.11](https://github.com/vuepress/ecosystem/compare/v2.0.0-rc.10...v2.0.0-rc.11) (2024-02-05) + +**Note:** Version bump only for package @vuepress/plugin-search + +# [2.0.0-rc.10](https://github.com/vuepress/ecosystem/compare/v2.0.0-rc.9...v2.0.0-rc.10) (2024-02-05) + +### Bug Fixes + +- **plugin-search:** fix hot reload ([637db2c](https://github.com/vuepress/ecosystem/commit/637db2ccefd00bea7aee0ed74b829e079d37e2a2)) + +### Features + +- compatible with visual routes ([#57](https://github.com/vuepress/ecosystem/issues/57)) ([f1281be](https://github.com/vuepress/ecosystem/commit/f1281be141b9a5cb71d80048a2042b669cd4823e)) + +# [2.0.0-rc.9](https://github.com/vuepress/ecosystem/compare/v2.0.0-rc.8...v2.0.0-rc.9) (2024-02-03) + +**Note:** Version bump only for package @vuepress/plugin-search + +# [2.0.0-rc.8](https://github.com/vuepress/ecosystem/compare/v2.0.0-rc.7...v2.0.0-rc.8) (2024-02-03) + +**Note:** Version bump only for package @vuepress/plugin-search + +# [2.0.0-rc.7](https://github.com/vuepress/ecosystem/compare/v2.0.0-rc.6...v2.0.0-rc.7) (2024-02-02) + +**Note:** Version bump only for package @vuepress/plugin-search + +# [2.0.0-rc.3](https://github.com/vuepress/ecosystem/compare/v2.0.0-rc.2...v2.0.0-rc.3) (2024-01-31) + +**Note:** Version bump only for package @vuepress/plugin-search + +# 2.0.0-rc.1 (2024-01-26) + +### Features + +- bump to vp2rc1 and declare vuepress as peer ([af4f00b](https://github.com/vuepress/ecosystem/commit/af4f00b24dc64dfd3ec5f45053e78fdcf147da61)) diff --git a/plugins/plugin-flexsearch/package.json b/plugins/plugin-flexsearch/package.json new file mode 100644 index 0000000000..7835672474 --- /dev/null +++ b/plugins/plugin-flexsearch/package.json @@ -0,0 +1,51 @@ +{ + "name": "@vuepress/plugin-search", + "version": "2.0.0-rc.18", + "description": "VuePress plugin - built-in search", + "keywords": [ + "vuepress-plugin", + "vuepress", + "plugin", + "search" + ], + "homepage": "https://ecosystem.vuejs.press/plugins/search.html", + "bugs": { + "url": "https://github.com/vuepress/ecosystem/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vuepress/ecosystem.git", + "directory": "plugins/plugin-search" + }, + "license": "MIT", + "author": "meteorlxy", + "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..35719d4a85 --- /dev/null +++ b/plugins/plugin-flexsearch/src/client/components/SearchBox.ts @@ -0,0 +1,176 @@ +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 { + useHotKeys, + useSearchIndex, + 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 searchIndex = useSearchIndex + + const input = ref(null) + const isActive = ref(false) + const query = ref('') + const locale = computed(() => locales.value[routeLocale.value] ?? {}) + + const suggestions = useSearchSuggestions({ + searchIndex, + 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/index.ts b/plugins/plugin-flexsearch/src/client/composables/index.ts new file mode 100644 index 0000000000..e84a5d6188 --- /dev/null +++ b/plugins/plugin-flexsearch/src/client/composables/index.ts @@ -0,0 +1,4 @@ +export * from './useHotKeys.js' +export * from './useSearchIndex.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/useSearchIndex.ts b/plugins/plugin-flexsearch/src/client/composables/useSearchIndex.ts new file mode 100644 index 0000000000..d68a0f71b1 --- /dev/null +++ b/plugins/plugin-flexsearch/src/client/composables/useSearchIndex.ts @@ -0,0 +1,30 @@ +import { searchIndex as searchIndexRaw } from '@internal/searchIndex' +import FS from 'flexsearch' +import { ref } from 'vue' + +export interface SearchIndexRet { + path: [string, string] + title: string +} + +export type CSearchIndex = (string, number) => SearchIndexRet[] + +const index = FS.create({ + async: false, + doc: { + id: 'id', + field: ['title', 'content'], + }, +}) +index.import(searchIndexRaw.idx) + +export const useSearchIndex = ref((q: string, c: number) => { + const rr: any = index.search(q, c) + return rr.map((r) => { + return { + path: searchIndexRaw.paths[r.id], + title: r.title, + content: r.content, + } + }) +}) 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..8dec897845 --- /dev/null +++ b/plugins/plugin-flexsearch/src/client/composables/useSearchSuggestions.ts @@ -0,0 +1,81 @@ +import { computed } from 'vue' +import type { ComputedRef, Ref } from 'vue' +import { useSearchIndex } from './useSearchIndex.js' +import type { CSearchIndex } from './useSearchIndex.js' + +export interface SearchSuggestion { + link: string + title: string + text: string +} + +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function highlightText(fullText, highlightTarget, splitBy) { + let result = fullText + const highlightWords = highlightTarget + .split(splitBy) + .filter((word) => word.length > 0) + 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 +} + +function getSuggestionText(content: string, query: string, maxLen: number) { + 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 = ({ + searchIndex, + routeLocale, + query, + maxSuggestions, +}: { + searchIndex: Ref + routeLocale: Ref + query: Ref + maxSuggestions: Ref +}): ComputedRef => { + return computed(() => { + const searchStr = query.value.trim().toLowerCase() + if (!searchStr) return [] + + const suggestions: SearchSuggestion[] = useSearchIndex + .value(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..f152c4beb7 --- /dev/null +++ b/plugins/plugin-flexsearch/src/client/index.ts @@ -0,0 +1,5 @@ +import idx from '../shared/index.js' +export * from './components/index.js' +export * from './composables/index.js' +export * from './utils/index.js' +export default idx 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.css b/plugins/plugin-flexsearch/src/client/styles/search.css new file mode 100644 index 0000000000..c20adb76e2 --- /dev/null +++ b/plugins/plugin-flexsearch/src/client/styles/search.css @@ -0,0 +1,94 @@ +.search-box { + display: inline-block; + position: relative; + margin-left: 1rem; +} +@media print { + .search-box { + display: none; + } +} +.search-box 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) { + .search-box input { + cursor: pointer; + width: 0; + border-color: transparent; + position: relative; + } +} +.search-box input:focus { + cursor: auto; + border-color: var(--search-accent-color); +} +@media (max-width: 719px) { + .search-box input:focus { + cursor: text; + left: 0; + width: 10rem; + } +} +@media (max-width: 419px) { + .search-box input:focus { + width: 8rem; + } +} +.search-box .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) { + .search-box .suggestions { + width: calc(100vw - 4rem); + right: -0.5rem; + } +} +.search-box .suggestion { + line-height: 1.4; + padding: 0.4rem 0.6rem; + border-radius: 4px; + cursor: pointer; +} +.search-box .suggestion a { + white-space: normal; + color: var(--search-item-text-color); +} +.search-box .suggestion.focus { + background-color: var(--search-item-focus-bg-color); +} +.search-box .suggestion.focus a { + color: var(--search-accent-color); +} +.search-box .suggestion .page-title { + font-weight: 600; +} +.search-box .suggestion .page-header { + font-size: 0.9em; + margin-left: 0.25em; +} + +/*# sourceMappingURL=search.css.map */ diff --git a/plugins/plugin-flexsearch/src/client/styles/search.css.map b/plugins/plugin-flexsearch/src/client/styles/search.css.map new file mode 100644 index 0000000000..33477818e0 --- /dev/null +++ b/plugins/plugin-flexsearch/src/client/styles/search.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["search.scss"],"names":[],"mappings":"AAAA;EACE;EACA;EACA;;AAEA;EALF;IAMI;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EAlBF;IAmBI;IACA;IACA;IACA;;;AAGF;EACE;EACA;;AAEA;EAJF;IAKI;IACA;IACA;;;AAGF;EAVF;IAWI;;;AAKN;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EAXF;IAYI;IACA;;;AAIJ;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;;AAEA;EACE;;AAIJ;EACE;;AAGF;EACE;EACA","file":"search.css"} \ No newline at end of file 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/index.ts b/plugins/plugin-flexsearch/src/node/index.ts new file mode 100644 index 0000000000..7a54c1a633 --- /dev/null +++ b/plugins/plugin-flexsearch/src/node/index.ts @@ -0,0 +1,7 @@ +import { searchPlugin } from './searchPlugin.js' + +export * from './prepareSearchIndex.js' +export * from './searchPlugin.js' +export type * from '../shared/index.js' +/** @deprecated Use named export instead */ +export default searchPlugin diff --git a/plugins/plugin-flexsearch/src/node/prepareSearchIndex.ts b/plugins/plugin-flexsearch/src/node/prepareSearchIndex.ts new file mode 100644 index 0000000000..076c5a62e0 --- /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 './searchPlugin.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) => { + 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/node/searchPlugin.ts b/plugins/plugin-flexsearch/src/node/searchPlugin.ts new file mode 100644 index 0000000000..bcaf95ea5c --- /dev/null +++ b/plugins/plugin-flexsearch/src/node/searchPlugin.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-search + */ +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 searchPlugin = ({ + locales = {}, + hotKeys = ['s', '/'], + maxSuggestions = 5, + isSearchable = () => true, + getExtraFields = () => [], +}: SearchPluginOptions = {}): Plugin => ({ + name: '@vuepress/plugin-search', + + 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/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..224d79ae6f --- /dev/null +++ b/plugins/plugin-flexsearch/src/shared/index.ts @@ -0,0 +1,3 @@ +export * from './hotKey.js' +export * from './searchIndex.js' +export default {} 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 e6f84b26f8..c9da634eb7 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: @@ -7001,6 +7019,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..cf30f6f1eb 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -17,6 +17,7 @@ { "path": "./plugins/plugin-copy-code/tsconfig.build.json" }, { "path": "./plugins/plugin-copyright/tsconfig.build.json" }, { "path": "./plugins/plugin-docsearch/tsconfig.build.json" }, + { "path": "./plugins/plugin-flexsearch/tsconfig.build.json" }, { "path": "./plugins/plugin-external-link-icon/tsconfig.build.json" },