-
Notifications
You must be signed in to change notification settings - Fork 49
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
5993529
commit 0cc1529
Showing
28 changed files
with
873 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
} | ||
} |
175 changes: 175 additions & 0 deletions
175
plugins/plugin-flexsearch/src/client/components/SearchBox.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<SearchBoxLocales>, | ||
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<HTMLInputElement | null>(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, | ||
}), | ||
], | ||
), | ||
), | ||
), | ||
), | ||
], | ||
) | ||
}, | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './SearchBox.js' |
30 changes: 30 additions & 0 deletions
30
plugins/plugin-flexsearch/src/client/composables/findInIndex.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
} | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export * from './useHotKeys.js' | ||
export * from './findInIndex.js' | ||
export * from './useSearchSuggestions.js' | ||
export * from './useSuggestionsFocus.js' |
36 changes: 36 additions & 0 deletions
36
plugins/plugin-flexsearch/src/client/composables/useHotKeys.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<HTMLInputElement | null> | ||
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) | ||
}) | ||
} |
79 changes: 79 additions & 0 deletions
79
plugins/plugin-flexsearch/src/client/composables/useSearchSuggestions.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
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 | ||
} | ||
|
||
function escapeRegExp(string) { | ||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | ||
} | ||
|
||
function highlightText(fullText, highlightTarget, splitBy) { | ||
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'), | ||
'<em>$&</em>', | ||
) | ||
} | ||
} else { | ||
result = fullText.replace( | ||
new RegExp(escapeRegExp(highlightTarget), 'ig'), | ||
'<em>$&</em>', | ||
) | ||
} | ||
|
||
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 = ({ | ||
findInIndex, | ||
routeLocale, | ||
query, | ||
maxSuggestions, | ||
}: { | ||
findInIndex: ClientSideSearchIndex | ||
routeLocale: Ref<string> | ||
query: Ref<string> | ||
maxSuggestions: Ref<number> | ||
}): ComputedRef<SearchSuggestion[]> => { | ||
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 | ||
}) | ||
} |
Oops, something went wrong.