Skip to content

Commit 880ecc1

Browse files
feat: init flexsearch plugin
1 parent 5993529 commit 880ecc1

30 files changed

+978
-0
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Change Log
2+
3+
All notable changes to this project will be documented in this file.
4+
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"name": "@vuepress/plugin-search",
3+
"version": "2.0.0-rc.18",
4+
"description": "VuePress plugin - built-in search",
5+
"keywords": [
6+
"vuepress-plugin",
7+
"vuepress",
8+
"plugin",
9+
"search"
10+
],
11+
"homepage": "https://ecosystem.vuejs.press/plugins/search.html",
12+
"bugs": {
13+
"url": "https://github.com/vuepress/ecosystem/issues"
14+
},
15+
"repository": {
16+
"type": "git",
17+
"url": "git+https://github.com/vuepress/ecosystem.git",
18+
"directory": "plugins/plugin-search"
19+
},
20+
"license": "MIT",
21+
"author": "meteorlxy",
22+
"type": "module",
23+
"exports": {
24+
".": "./lib/node/index.js",
25+
"./client": "./lib/client/index.js",
26+
"./package.json": "./package.json"
27+
},
28+
"main": "./lib/node/index.js",
29+
"types": "./lib/node/index.d.ts",
30+
"files": [
31+
"lib"
32+
],
33+
"scripts": {
34+
"build": "tsc -b tsconfig.build.json",
35+
"clean": "rimraf --glob ./lib ./*.tsbuildinfo",
36+
"copy": "cpx \"src/**/*.{d.ts,svg}\" lib",
37+
"style": "sass src:lib --no-source-map"
38+
},
39+
"dependencies": {
40+
"chokidar": "^3.6.0",
41+
"flexsearch": "^0.6",
42+
"he": "^1.2.0",
43+
"vue": "^3.4.21"
44+
},
45+
"peerDependencies": {
46+
"vuepress": "2.0.0-rc.8"
47+
},
48+
"publishConfig": {
49+
"access": "public"
50+
}
51+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { computed, defineComponent, h, ref, toRefs } from 'vue'
2+
import type { PropType } from 'vue'
3+
import { useRouteLocale, useRouter } from 'vuepress/client'
4+
import type { LocaleConfig } from 'vuepress/shared'
5+
import type { HotKeyOptions } from '../../shared/index.js'
6+
import {
7+
useHotKeys,
8+
useSearchIndex,
9+
useSearchSuggestions,
10+
useSuggestionsFocus,
11+
} from '../composables/index.js'
12+
13+
export type SearchBoxLocales = LocaleConfig<{
14+
placeholder: string
15+
}>
16+
17+
export const SearchBox = defineComponent({
18+
name: 'SearchBox',
19+
20+
props: {
21+
locales: {
22+
type: Object as PropType<SearchBoxLocales>,
23+
required: false,
24+
default: () => ({}),
25+
},
26+
27+
hotKeys: {
28+
type: Array as PropType<(string | HotKeyOptions)[]>,
29+
required: false,
30+
default: () => [],
31+
},
32+
33+
maxSuggestions: {
34+
type: Number,
35+
required: false,
36+
default: 5,
37+
},
38+
},
39+
40+
setup(props) {
41+
const { locales, hotKeys, maxSuggestions } = toRefs(props)
42+
43+
const router = useRouter()
44+
const routeLocale = useRouteLocale()
45+
const searchIndex = useSearchIndex
46+
47+
const input = ref<HTMLInputElement | null>(null)
48+
const isActive = ref(false)
49+
const query = ref('')
50+
const locale = computed(() => locales.value[routeLocale.value] ?? {})
51+
52+
const suggestions = useSearchSuggestions({
53+
searchIndex,
54+
routeLocale,
55+
query,
56+
maxSuggestions,
57+
})
58+
const { focusIndex, focusNext, focusPrev } =
59+
useSuggestionsFocus(suggestions)
60+
useHotKeys({ input, hotKeys })
61+
62+
const showSuggestions = computed(
63+
() => isActive.value && !!suggestions.value.length,
64+
)
65+
const onArrowUp = (): void => {
66+
if (!showSuggestions.value) {
67+
return
68+
}
69+
focusPrev()
70+
}
71+
const onArrowDown = (): void => {
72+
if (!showSuggestions.value) {
73+
return
74+
}
75+
focusNext()
76+
}
77+
const goTo = (index: number): void => {
78+
if (!showSuggestions.value) {
79+
return
80+
}
81+
82+
const suggestion = suggestions.value[index]
83+
if (!suggestion) {
84+
return
85+
}
86+
87+
router.push(suggestion.link).then(() => {
88+
query.value = ''
89+
focusIndex.value = 0
90+
})
91+
}
92+
93+
return () =>
94+
h(
95+
'form',
96+
{
97+
class: 'search-box',
98+
role: 'search',
99+
},
100+
[
101+
h('input', {
102+
ref: input,
103+
type: 'search',
104+
placeholder: locale.value.placeholder,
105+
autocomplete: 'off',
106+
spellcheck: false,
107+
value: query.value,
108+
onFocus: () => (isActive.value = true),
109+
onBlur: () => (isActive.value = false),
110+
onInput: (event) =>
111+
(query.value = (event.target as HTMLInputElement).value),
112+
onKeydown: (event) => {
113+
switch (event.key) {
114+
case 'ArrowUp': {
115+
onArrowUp()
116+
break
117+
}
118+
case 'ArrowDown': {
119+
onArrowDown()
120+
break
121+
}
122+
case 'Enter': {
123+
event.preventDefault()
124+
goTo(focusIndex.value)
125+
break
126+
}
127+
}
128+
},
129+
}),
130+
showSuggestions.value &&
131+
h(
132+
'ul',
133+
{
134+
class: 'suggestions',
135+
onMouseleave: () => (focusIndex.value = -1),
136+
},
137+
suggestions.value.map(({ link, title, text }, index) =>
138+
h(
139+
'li',
140+
{
141+
class: [
142+
'suggestion',
143+
{
144+
focus: focusIndex.value === index,
145+
},
146+
],
147+
onMouseenter: () => (focusIndex.value = index),
148+
onMousedown: () => goTo(index),
149+
},
150+
h(
151+
'a',
152+
{
153+
href: link,
154+
onClick: (event) => event.preventDefault(),
155+
},
156+
[
157+
h(
158+
'span',
159+
{
160+
class: 'page-title',
161+
},
162+
title,
163+
),
164+
h('span', {
165+
class: 'suggestion-result',
166+
innerHTML: text,
167+
}),
168+
],
169+
),
170+
),
171+
),
172+
),
173+
],
174+
)
175+
},
176+
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './SearchBox.js'
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './useHotKeys.js'
2+
export * from './useSearchIndex.js'
3+
export * from './useSearchSuggestions.js'
4+
export * from './useSuggestionsFocus.js'
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { onBeforeUnmount, onMounted } from 'vue'
2+
import type { Ref } from 'vue'
3+
import type { HotKeyOptions } from '../../shared/index.js'
4+
import { isFocusingTextControl, isKeyMatched } from '../utils/index.js'
5+
6+
export const useHotKeys = ({
7+
input,
8+
hotKeys,
9+
}: {
10+
input: Ref<HTMLInputElement | null>
11+
hotKeys: Ref<(string | HotKeyOptions)[]>
12+
}): void => {
13+
if (hotKeys.value.length === 0) return
14+
15+
const onKeydown = (event: KeyboardEvent): void => {
16+
if (!input.value) return
17+
if (
18+
// key matches
19+
isKeyMatched(event, hotKeys.value) &&
20+
// event does not come from the search box itself or
21+
// user isn't focusing (and thus perhaps typing in) a text control
22+
!isFocusingTextControl(event.target as EventTarget)
23+
) {
24+
event.preventDefault()
25+
input.value.focus()
26+
}
27+
}
28+
29+
onMounted(() => {
30+
document.addEventListener('keydown', onKeydown)
31+
})
32+
33+
onBeforeUnmount(() => {
34+
document.removeEventListener('keydown', onKeydown)
35+
})
36+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { searchIndex as searchIndexRaw } from '@internal/searchIndex'
2+
import FS from 'flexsearch'
3+
import { ref } from 'vue'
4+
5+
export interface SearchIndexRet {
6+
path: [string, string]
7+
title: string
8+
}
9+
10+
export type CSearchIndex = (string, number) => SearchIndexRet[]
11+
12+
const index = FS.create({
13+
async: false,
14+
doc: {
15+
id: 'id',
16+
field: ['title', 'content'],
17+
},
18+
})
19+
index.import(searchIndexRaw.idx)
20+
21+
export const useSearchIndex = ref((q: string, c: number) => {
22+
const rr: any = index.search(q, c)
23+
return rr.map((r) => {
24+
return {
25+
path: searchIndexRaw.paths[r.id],
26+
title: r.title,
27+
content: r.content,
28+
}
29+
})
30+
})
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { computed } from 'vue'
2+
import type { ComputedRef, Ref } from 'vue'
3+
import { useSearchIndex } from './useSearchIndex.js'
4+
import type { CSearchIndex } from './useSearchIndex.js'
5+
6+
export interface SearchSuggestion {
7+
link: string
8+
title: string
9+
text: string
10+
}
11+
12+
function escapeRegExp(string) {
13+
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
14+
}
15+
16+
function highlightText(fullText, highlightTarget, splitBy) {
17+
let result = fullText
18+
const highlightWords = highlightTarget
19+
.split(splitBy)
20+
.filter((word) => word.length > 0)
21+
if (highlightWords.length > 0) {
22+
for (const word of highlightWords) {
23+
result = result.replace(
24+
new RegExp(escapeRegExp(word), 'ig'),
25+
'<em>$&</em>',
26+
)
27+
}
28+
} else {
29+
result = fullText.replace(
30+
new RegExp(escapeRegExp(highlightTarget), 'ig'),
31+
'<em>$&</em>',
32+
)
33+
}
34+
35+
return result
36+
}
37+
38+
function getSuggestionText(content: string, query: string, maxLen: number) {
39+
const queryIndex = content.toLowerCase().indexOf(query.toLowerCase())
40+
const queryFirstWord = query.split(' ')[0]
41+
let startIndex =
42+
queryIndex === -1
43+
? content.toLowerCase().indexOf(queryFirstWord.toLowerCase())
44+
: queryIndex
45+
let prefix = ''
46+
if (startIndex > 15) {
47+
startIndex -= 15
48+
prefix = '.. '
49+
}
50+
const text = content.substr(startIndex, maxLen)
51+
return prefix + highlightText(text, query, ' ')
52+
}
53+
54+
export const useSearchSuggestions = ({
55+
searchIndex,
56+
routeLocale,
57+
query,
58+
maxSuggestions,
59+
}: {
60+
searchIndex: Ref<CSearchIndex>
61+
routeLocale: Ref<string>
62+
query: Ref<string>
63+
maxSuggestions: Ref<number>
64+
}): ComputedRef<SearchSuggestion[]> => {
65+
return computed(() => {
66+
const searchStr = query.value.trim().toLowerCase()
67+
if (!searchStr) return []
68+
69+
const suggestions: SearchSuggestion[] = useSearchIndex
70+
.value(searchStr, maxSuggestions.value)
71+
.map((r) => {
72+
return {
73+
link: r.path,
74+
title: r.title,
75+
text: getSuggestionText(r.content, searchStr, 30),
76+
}
77+
})
78+
79+
return suggestions
80+
})
81+
}

0 commit comments

Comments
 (0)