@vuepress-plume/plugin-search 1.0.0-rc.211 → 1.0.0-rc.213
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/components/Search.vue +43 -3
- package/dist/client/components/SearchBox.vue +133 -39
- package/dist/client/composables/index.d.ts +47 -9
- package/dist/client/composables/index.js +22 -1
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +2 -2
- package/dist/client/shim.d.ts +3 -1
- package/dist/node/index.d.ts +1 -0
- package/dist/node/index.js +180 -47
- package/dist/shared/index.d.ts +41 -0
- package/dist/shared/index.js +16 -1
- package/package.json +12 -12
- package/dist/client/utils/index.d.ts +0 -84
- package/dist/client/utils/index.js +0 -35
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import type { SearchBoxLocales, SearchOptions } from '../../shared/index.js'
|
|
3
3
|
import { onKeyStroke } from '@vueuse/core'
|
|
4
4
|
import {
|
|
5
|
+
computed,
|
|
5
6
|
defineAsyncComponent,
|
|
6
7
|
ref,
|
|
8
|
+
toRef,
|
|
7
9
|
} from 'vue'
|
|
10
|
+
import { useLocale } from '../composables/index.js'
|
|
8
11
|
import SearchButton from './SearchButton.vue'
|
|
9
12
|
|
|
10
|
-
defineProps<{
|
|
13
|
+
const props = defineProps<{
|
|
11
14
|
locales: SearchBoxLocales
|
|
12
15
|
options: SearchOptions
|
|
13
16
|
}>()
|
|
@@ -16,15 +19,42 @@ const SearchBox = defineAsyncComponent(() => import('./SearchBox.vue'))
|
|
|
16
19
|
|
|
17
20
|
const showSearch = ref(false)
|
|
18
21
|
|
|
22
|
+
const locale = useLocale(toRef(() => props.locales))
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Text shown when JavaScript is disabled.
|
|
26
|
+
*
|
|
27
|
+
* 禁用 JavaScript 时展示的提示文本。
|
|
28
|
+
*
|
|
29
|
+
* `<noscript>` 仅在脚本被禁用时渲染,且必须是纯文本:启用脚本时浏览器会把
|
|
30
|
+
* 其中的内容当作原始文本,若包含元素或 HTML 实体会造成水合不匹配。
|
|
31
|
+
* `<noscript>` only renders with scripting disabled, and must stay plain text:
|
|
32
|
+
* with scripting enabled browsers parse its content as raw text, so elements or
|
|
33
|
+
* HTML entities would cause a hydration mismatch.
|
|
34
|
+
*/
|
|
35
|
+
const noscriptText = computed(
|
|
36
|
+
() => locale.value.noscriptText || 'Search is unavailable without JavaScript.',
|
|
37
|
+
)
|
|
38
|
+
|
|
19
39
|
onKeyStroke('k', (event) => {
|
|
20
|
-
|
|
40
|
+
// 在输入框 / 可编辑区域内不劫持 Ctrl/Cmd + K(如 macOS 的删除至行尾)。
|
|
41
|
+
// Do not hijack Ctrl/Cmd + K while editing content (e.g. macOS delete-to-end-of-line).
|
|
42
|
+
if ((event.ctrlKey || event.metaKey) && !isEditingContent(event)) {
|
|
21
43
|
event.preventDefault()
|
|
22
44
|
showSearch.value = true
|
|
23
45
|
}
|
|
24
46
|
})
|
|
25
47
|
|
|
26
48
|
onKeyStroke('/', (event) => {
|
|
27
|
-
|
|
49
|
+
// 仅在无修饰键且不在编辑内容时劫持,避免与浏览器/系统组合键冲突。
|
|
50
|
+
// Only hijack without modifiers and outside editable content to avoid clashing
|
|
51
|
+
// with browser/system shortcuts.
|
|
52
|
+
if (
|
|
53
|
+
!event.ctrlKey
|
|
54
|
+
&& !event.metaKey
|
|
55
|
+
&& !event.altKey
|
|
56
|
+
&& !isEditingContent(event)
|
|
57
|
+
) {
|
|
28
58
|
event.preventDefault()
|
|
29
59
|
showSearch.value = true
|
|
30
60
|
}
|
|
@@ -55,6 +85,8 @@ function isEditingContent(event: KeyboardEvent): boolean {
|
|
|
55
85
|
<div id="local-search">
|
|
56
86
|
<SearchButton :locales="locales" @click="showSearch = true" />
|
|
57
87
|
</div>
|
|
88
|
+
|
|
89
|
+
<noscript>{{ noscriptText }}</noscript>
|
|
58
90
|
</div>
|
|
59
91
|
</template>
|
|
60
92
|
|
|
@@ -64,6 +96,14 @@ function isEditingContent(event: KeyboardEvent): boolean {
|
|
|
64
96
|
align-items: center;
|
|
65
97
|
}
|
|
66
98
|
|
|
99
|
+
/* 仅在禁用 JavaScript 时可见的降级提示。
|
|
100
|
+
Fallback hint that is only visible when JavaScript is disabled. */
|
|
101
|
+
.search-wrapper noscript {
|
|
102
|
+
font-size: 13px;
|
|
103
|
+
line-height: 1.4;
|
|
104
|
+
color: var(--vp-c-text-2);
|
|
105
|
+
}
|
|
106
|
+
|
|
67
107
|
@media (min-width: 768px) {
|
|
68
108
|
.search-wrapper {
|
|
69
109
|
flex-grow: 1;
|
|
@@ -25,8 +25,8 @@ import {
|
|
|
25
25
|
watch,
|
|
26
26
|
} from 'vue'
|
|
27
27
|
import { usePageLang, useRouteLocale, useRouter, withBase } from 'vuepress/client'
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
28
|
+
import { createTokenizer } from '../../shared/index.js'
|
|
29
|
+
import { loadSearchIndexJSON, useLocale } from '../composables/index.js'
|
|
30
30
|
import BackIcon from './icons/BackIcon.vue'
|
|
31
31
|
import ClearIcon from './icons/ClearIcon.vue'
|
|
32
32
|
import SearchIcon from './icons/SearchIcon.vue'
|
|
@@ -47,12 +47,9 @@ const lang = usePageLang()
|
|
|
47
47
|
const el = shallowRef<HTMLElement>()
|
|
48
48
|
const resultsEl = shallowRef<HTMLElement>()
|
|
49
49
|
|
|
50
|
-
const searchIndexData = useSearchIndex()
|
|
51
|
-
|
|
52
50
|
interface Result {
|
|
53
51
|
title: string
|
|
54
52
|
titles: string[]
|
|
55
|
-
text?: string
|
|
56
53
|
}
|
|
57
54
|
|
|
58
55
|
const { activate } = useFocusTrap(el, { immediate: true })
|
|
@@ -62,17 +59,69 @@ const isSearching = ref(false)
|
|
|
62
59
|
|
|
63
60
|
const showSearchSpinner = computed(() => isSearchIndexLoading.value || isSearching.value)
|
|
64
61
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Visible error message when the search index is missing or fails to load.
|
|
64
|
+
*
|
|
65
|
+
* 搜索索引缺失或加载失败时显示的可见提示。
|
|
66
|
+
*/
|
|
67
|
+
const searchIndexError = ref('')
|
|
68
|
+
|
|
69
|
+
const searchIndexErrorText = computed(() =>
|
|
70
|
+
locale.value.searchIndexErrorText || 'Failed to load the search index.',
|
|
71
|
+
)
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Create an empty index used as a fallback when the index is missing or fails
|
|
75
|
+
* to load, so searching returns an empty result set instead of throwing.
|
|
76
|
+
*
|
|
77
|
+
* 创建空索引,在索引缺失或加载失败时兜底,使检索返回空结果而不是抛出异常。
|
|
78
|
+
*/
|
|
79
|
+
function createEmptyIndex(): MiniSearch<Result> {
|
|
80
|
+
return new MiniSearch<Result>({
|
|
81
|
+
fields: ['title', 'titles', 'text'],
|
|
82
|
+
storeFields: ['title', 'titles'],
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const searchIndex = computedAsync(async (onCancel) => {
|
|
87
|
+
// 标记本次求值是否已被新一轮加载取代(例如 HMR 更新索引)。
|
|
88
|
+
// 过期任务不得再写入错误状态,否则有效结果旁会残留加载失败提示。
|
|
89
|
+
//
|
|
90
|
+
// Mark this evaluation as stale (e.g. superseded by a newer load during HMR).
|
|
91
|
+
// A stale task must not write the error state, otherwise a load failure message
|
|
92
|
+
// would persist next to valid results.
|
|
93
|
+
let canceled = false
|
|
94
|
+
onCancel(() => {
|
|
95
|
+
canceled = true
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
searchIndexError.value = ''
|
|
99
|
+
|
|
100
|
+
// 使用与 Node 端建索引时完全一致的分词策略,避免两端词元不匹配导致检索失效。
|
|
101
|
+
// Use the same tokenizer as the node build side so query tokens always match the index.
|
|
102
|
+
const tokenize = createTokenizer(lang.value)
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// 分片索引在客户端按片合并并让出主线程,避免大索引长时间阻塞渲染。
|
|
106
|
+
// Sharded indexes are merged piece by piece on the client, yielding to the main
|
|
107
|
+
// thread, so a large index never blocks rendering for long.
|
|
108
|
+
const indexJson = await loadSearchIndexJSON(routeLocale.value)
|
|
109
|
+
|
|
110
|
+
// 当前语言缺少索引文件时回退到空索引,并给出可见提示,避免静默无结果。
|
|
111
|
+
// Fall back to an empty index and show a visible message when the locale has no index.
|
|
112
|
+
if (!indexJson) {
|
|
113
|
+
if (!canceled)
|
|
114
|
+
searchIndexError.value = searchIndexErrorText.value
|
|
115
|
+
return markRaw(createEmptyIndex())
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 使用公开的异步反序列化 API(`loadJSAsync` 为 `@ignore` 的内部 API),
|
|
119
|
+
// 它在批次之间让出主线程,避免大索引同步解析长时间阻塞。
|
|
120
|
+
// Use the public async deserialization API (`loadJSAsync` is an `@ignore`d
|
|
121
|
+
// internal API). It yields to the main thread between batches, so a large index
|
|
122
|
+
// never blocks rendering for long.
|
|
123
|
+
return markRaw(
|
|
124
|
+
await MiniSearch.loadJSONAsync<Result>(indexJson, {
|
|
76
125
|
fields: ['title', 'titles', 'text'],
|
|
77
126
|
storeFields: ['title', 'titles'],
|
|
78
127
|
searchOptions: {
|
|
@@ -83,9 +132,15 @@ const searchIndex = computedAsync(async () => {
|
|
|
83
132
|
},
|
|
84
133
|
...options.miniSearch?.searchOptions,
|
|
85
134
|
...options.miniSearch?.options,
|
|
86
|
-
},
|
|
87
|
-
)
|
|
88
|
-
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
console.error('[plugin-search] failed to load the search index:', error)
|
|
140
|
+
if (!canceled)
|
|
141
|
+
searchIndexError.value = searchIndexErrorText.value
|
|
142
|
+
return markRaw(createEmptyIndex())
|
|
143
|
+
}
|
|
89
144
|
}, undefined, isSearchIndexLoading)
|
|
90
145
|
|
|
91
146
|
const disableQueryPersistence = computed(() =>
|
|
@@ -111,16 +166,9 @@ const mark = computedAsync(async () => {
|
|
|
111
166
|
return markRaw(new Mark(resultsEl.value))
|
|
112
167
|
}, null)
|
|
113
168
|
|
|
114
|
-
const cache = new LRUCache<string, Map<string, string>>(64) // 64 files
|
|
115
|
-
|
|
116
169
|
watchDebounced(
|
|
117
170
|
() => [searchIndex.value, filterText.value] as const,
|
|
118
|
-
async ([index, filterTextValue],
|
|
119
|
-
if (old?.[0] !== index) {
|
|
120
|
-
// in case of hmr
|
|
121
|
-
cache.clear()
|
|
122
|
-
}
|
|
123
|
-
|
|
171
|
+
async ([index, filterTextValue], _old, onCleanup) => {
|
|
124
172
|
let canceled = false
|
|
125
173
|
onCleanup(() => {
|
|
126
174
|
canceled = true
|
|
@@ -140,16 +188,10 @@ watchDebounced(
|
|
|
140
188
|
enableNoResults.value = true
|
|
141
189
|
|
|
142
190
|
const terms = new Set<string>()
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const [id, anchor] = r.id.split('#')
|
|
146
|
-
const map = cache.get(id)
|
|
147
|
-
const text = map?.get(anchor) ?? ''
|
|
148
|
-
for (const term in r.match)
|
|
191
|
+
for (const result of results.value) {
|
|
192
|
+
for (const term in result.match)
|
|
149
193
|
terms.add(term)
|
|
150
|
-
|
|
151
|
-
return { ...r, text }
|
|
152
|
-
})
|
|
194
|
+
}
|
|
153
195
|
|
|
154
196
|
await nextTick()
|
|
155
197
|
if (canceled)
|
|
@@ -255,6 +297,30 @@ onKeyStroke(['n', 'N'], (event) => {
|
|
|
255
297
|
|
|
256
298
|
const router = useRouter()
|
|
257
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Whether the browser back button already closed the search box.
|
|
302
|
+
*
|
|
303
|
+
* 浏览器后退键是否已关闭搜索框。
|
|
304
|
+
*
|
|
305
|
+
* When true the extra history entry created on mount has been consumed, so the
|
|
306
|
+
* unmount handler must not roll history back again.
|
|
307
|
+
*
|
|
308
|
+
* 为 true 时说明挂载时压入的额外历史条目已被消费,卸载时不应再回退历史。
|
|
309
|
+
*/
|
|
310
|
+
let closedByPopstate = false
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Whether the search box is being closed because a result is being opened.
|
|
314
|
+
*
|
|
315
|
+
* 搜索框是否因跳转到搜索结果而关闭。
|
|
316
|
+
*
|
|
317
|
+
* In that case the URL must stay on the target page, so history must not be
|
|
318
|
+
* rolled back on unmount.
|
|
319
|
+
*
|
|
320
|
+
* 此时 URL 必须停留在目标页面,卸载时不能回退历史。
|
|
321
|
+
*/
|
|
322
|
+
let navigatingToResult = false
|
|
323
|
+
|
|
258
324
|
onKeyStroke('Enter', (e) => {
|
|
259
325
|
if (e.isComposing)
|
|
260
326
|
return
|
|
@@ -269,7 +335,11 @@ onKeyStroke('Enter', (e) => {
|
|
|
269
335
|
}
|
|
270
336
|
|
|
271
337
|
if (selectedPackage) {
|
|
272
|
-
|
|
338
|
+
navigatingToResult = true
|
|
339
|
+
// 用 replace 顶替挂载时 pushState 的临时历史条目,避免从结果页返回时多一次 Back。
|
|
340
|
+
// Replace the temporary history entry pushed on mount, so returning from the
|
|
341
|
+
// result page does not require an extra Back press.
|
|
342
|
+
router.replace(selectedPackage.id)
|
|
273
343
|
emit('close')
|
|
274
344
|
}
|
|
275
345
|
})
|
|
@@ -287,6 +357,7 @@ onMounted(() => {
|
|
|
287
357
|
|
|
288
358
|
useEventListener('popstate', (event) => {
|
|
289
359
|
event.preventDefault()
|
|
360
|
+
closedByPopstate = true
|
|
290
361
|
emit('close')
|
|
291
362
|
})
|
|
292
363
|
|
|
@@ -302,6 +373,15 @@ onMounted(() => {
|
|
|
302
373
|
|
|
303
374
|
onBeforeUnmount(() => {
|
|
304
375
|
isLocked.value = false
|
|
376
|
+
// 配对挂载时的 pushState,避免反复开关搜索框累积历史条目。
|
|
377
|
+
// 若该条目已被浏览器后退键(popstate)消费,或正在跳转到搜索结果,则不能回退。
|
|
378
|
+
//
|
|
379
|
+
// Pair the pushState made on mount so repeated open/close does not pile up
|
|
380
|
+
// history entries. Skip the rollback when the entry was already consumed by
|
|
381
|
+
// the browser back button (popstate), or when opening a result (the URL must
|
|
382
|
+
// stay on the target page).
|
|
383
|
+
if (!closedByPopstate && !navigatingToResult)
|
|
384
|
+
window.history.back()
|
|
305
385
|
})
|
|
306
386
|
|
|
307
387
|
function resetSearch() {
|
|
@@ -325,7 +405,10 @@ function formMarkRegex(terms: Set<string>) {
|
|
|
325
405
|
|
|
326
406
|
function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
327
407
|
e.preventDefault()
|
|
328
|
-
|
|
408
|
+
navigatingToResult = true
|
|
409
|
+
// 同 Enter 键路径:replace 消费掉挂载时压入的临时历史条目。
|
|
410
|
+
// Same as the Enter path: replace consumes the temp entry pushed on mount.
|
|
411
|
+
router.replace(p.id)
|
|
329
412
|
emit('close')
|
|
330
413
|
}
|
|
331
414
|
</script>
|
|
@@ -338,7 +421,7 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
338
421
|
:aria-owns="results?.length ? 'localsearch-list' : undefined"
|
|
339
422
|
aria-expanded="true"
|
|
340
423
|
aria-haspopup="listbox"
|
|
341
|
-
aria-labelledby="
|
|
424
|
+
aria-labelledby="localsearch-label"
|
|
342
425
|
class="VPLocalSearchBox"
|
|
343
426
|
>
|
|
344
427
|
<div class="backdrop" @click="$emit('close')" />
|
|
@@ -447,7 +530,14 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
447
530
|
</a>
|
|
448
531
|
</li>
|
|
449
532
|
<li
|
|
450
|
-
v-if="
|
|
533
|
+
v-if="searchIndexError"
|
|
534
|
+
class="no-results error"
|
|
535
|
+
role="alert"
|
|
536
|
+
>
|
|
537
|
+
{{ searchIndexError }}
|
|
538
|
+
</li>
|
|
539
|
+
<li
|
|
540
|
+
v-else-if="filterText && !results.length && enableNoResults"
|
|
451
541
|
class="no-results"
|
|
452
542
|
>
|
|
453
543
|
{{ locale.noResultsText }} "<strong>{{ filterText }}</strong>"
|
|
@@ -815,4 +905,8 @@ svg {
|
|
|
815
905
|
font-size: 0.9rem;
|
|
816
906
|
text-align: center;
|
|
817
907
|
}
|
|
908
|
+
|
|
909
|
+
.no-results.error {
|
|
910
|
+
color: var(--vp-c-danger-1, #b8272c);
|
|
911
|
+
}
|
|
818
912
|
</style>
|
|
@@ -14,21 +14,36 @@ import {SearchBoxLocales, SearchLocaleOptions} from '../../shared/index.js'
|
|
|
14
14
|
export declare function useLocale(
|
|
15
15
|
locales: MaybeRef<SearchBoxLocales>,
|
|
16
16
|
): ComputedRef<Partial<SearchLocaleOptions>>
|
|
17
|
+
/**
|
|
18
|
+
* Shape of a dynamically imported index module.
|
|
19
|
+
*
|
|
20
|
+
* 动态导入的索引模块的形状。
|
|
21
|
+
*
|
|
22
|
+
* Large locales are split into shards: the default export then holds the index
|
|
23
|
+
* metadata (with an empty `index`), and `shards` exposes lazy loaders for the
|
|
24
|
+
* remaining term entries.
|
|
25
|
+
*
|
|
26
|
+
* 较大的语言会被拆分为分片:此时默认导出承载索引元数据(`index` 为空),
|
|
27
|
+
* `shards` 则提供其余词元条目的延迟加载函数。
|
|
28
|
+
*/
|
|
29
|
+
export interface SearchIndexModule {
|
|
30
|
+
/** Serialized index JSON, or the index metadata when sharded / 序列化索引 JSON;分片时为索引元数据 */
|
|
31
|
+
default: string
|
|
32
|
+
/** Lazy loaders of the remaining shards / 其余分片的延迟加载函数 */
|
|
33
|
+
shards?: (() => Promise<{
|
|
34
|
+
default: string
|
|
35
|
+
}>)[]
|
|
36
|
+
}
|
|
17
37
|
/**
|
|
18
38
|
* Type definition for search index data.
|
|
19
39
|
*
|
|
20
40
|
* 搜索索引数据的类型定义。
|
|
21
41
|
*
|
|
22
|
-
* Maps locale paths to functions that load the corresponding search index.
|
|
42
|
+
* Maps locale paths to functions that load the corresponding search index module.
|
|
23
43
|
*
|
|
24
|
-
*
|
|
44
|
+
* 将语言路径映射到加载相应搜索索引模块的函数。
|
|
25
45
|
*/
|
|
26
|
-
type SearchIndexData = Record<
|
|
27
|
-
string,
|
|
28
|
-
() => Promise<{
|
|
29
|
-
default: string
|
|
30
|
-
}>
|
|
31
|
-
>
|
|
46
|
+
export type SearchIndexData = Record<string, () => Promise<SearchIndexModule>>
|
|
32
47
|
/**
|
|
33
48
|
* Get the search index data for all locales.
|
|
34
49
|
*
|
|
@@ -37,6 +52,29 @@ type SearchIndexData = Record<
|
|
|
37
52
|
* @returns Shallow ref to the search index data / 搜索索引数据的浅层引用
|
|
38
53
|
* @example
|
|
39
54
|
* const indexData = useSearchIndex()
|
|
40
|
-
* const
|
|
55
|
+
* const loadModule = indexData.value['/zh/']
|
|
41
56
|
*/
|
|
42
57
|
export declare function useSearchIndex(): ShallowRef<SearchIndexData>
|
|
58
|
+
/**
|
|
59
|
+
* Load and assemble the JSON-serialized search index of a locale.
|
|
60
|
+
*
|
|
61
|
+
* 加载并组装某个语言的 JSON 序列化搜索索引。
|
|
62
|
+
*
|
|
63
|
+
* The returned string can be passed to `MiniSearch.loadJSONAsync()`, which
|
|
64
|
+
* deserializes the index in batches without blocking the main thread.
|
|
65
|
+
*
|
|
66
|
+
* 返回的字符串可直接交给 `MiniSearch.loadJSONAsync()`,
|
|
67
|
+
* 它会分批反序列化索引,避免阻塞主线程。
|
|
68
|
+
*
|
|
69
|
+
* Sharded indexes are loaded in parallel and then merged one shard at a time,
|
|
70
|
+
* yielding to the main thread between shards. This keeps the first paint and
|
|
71
|
+
* input handling responsive while a large index is being assembled.
|
|
72
|
+
*
|
|
73
|
+
* 分片索引会并行加载,再逐片合并,并在分片之间让出主线程。
|
|
74
|
+
* 这样在组装大索引时仍能保持首屏渲染与输入的响应性。
|
|
75
|
+
*
|
|
76
|
+
* @param locale - Locale path (e.g. `/`, `/zh/`) / 语言路径
|
|
77
|
+
* @returns Serialized index JSON, or `undefined` when the locale has no index /
|
|
78
|
+
* 序列化索引 JSON;该语言没有索引时返回 `undefined`
|
|
79
|
+
*/
|
|
80
|
+
export declare function loadSearchIndexJSON(locale: string): Promise<string | undefined>
|
|
@@ -8,6 +8,8 @@ const defaultLocales = {
|
|
|
8
8
|
resetButtonTitle: 'Reset search',
|
|
9
9
|
backButtonTitle: 'Close search',
|
|
10
10
|
noResultsText: 'No results for',
|
|
11
|
+
searchIndexErrorText: 'Failed to load the search index.',
|
|
12
|
+
noscriptText: 'Search is unavailable without JavaScript.',
|
|
11
13
|
footer: {
|
|
12
14
|
selectText: 'to select',
|
|
13
15
|
selectKeyAriaLabel: 'enter',
|
|
@@ -36,9 +38,28 @@ const searchIndexData = shallowRef(searchIndex)
|
|
|
36
38
|
function useSearchIndex() {
|
|
37
39
|
return searchIndexData
|
|
38
40
|
}
|
|
41
|
+
|
|
42
|
+
function yieldToMainThread() {
|
|
43
|
+
return new Promise((resolve) => setTimeout(resolve))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function loadSearchIndexJSON(locale) {
|
|
47
|
+
const loadIndexModule = searchIndexData.value[locale]
|
|
48
|
+
if (!loadIndexModule) return void 0
|
|
49
|
+
const indexModule = await loadIndexModule()
|
|
50
|
+
if (!indexModule.shards?.length) return indexModule.default
|
|
51
|
+
const serialized = JSON.parse(indexModule.default)
|
|
52
|
+
const shards = await Promise.all(indexModule.shards.map((loadShard) => loadShard()))
|
|
53
|
+
for (const shard of shards) {
|
|
54
|
+
const entries = JSON.parse(shard.default)
|
|
55
|
+
for (const entry of entries) serialized.index.push(entry)
|
|
56
|
+
await yieldToMainThread()
|
|
57
|
+
}
|
|
58
|
+
return JSON.stringify(serialized)
|
|
59
|
+
}
|
|
39
60
|
if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot))
|
|
40
61
|
__VUE_HMR_RUNTIME__.updateSearchIndex = (data) => {
|
|
41
62
|
searchIndexData.value = data
|
|
42
63
|
}
|
|
43
64
|
|
|
44
|
-
export {useLocale, useSearchIndex}
|
|
65
|
+
export {loadSearchIndexJSON, useLocale, useSearchIndex}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import SearchBox from './components/Search.vue'
|
|
2
|
-
import {useSearchIndex} from './composables/index.js'
|
|
3
|
-
export {SearchBox, useSearchIndex}
|
|
2
|
+
import {loadSearchIndexJSON, useSearchIndex} from './composables/index.js'
|
|
3
|
+
export {SearchBox, loadSearchIndexJSON, useSearchIndex}
|
package/dist/client/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import SearchBox from './components/Search.vue'
|
|
2
|
-
import {useSearchIndex} from './composables/index.js'
|
|
2
|
+
import {loadSearchIndexJSON, useSearchIndex} from './composables/index.js'
|
|
3
3
|
|
|
4
|
-
export {SearchBox, useSearchIndex}
|
|
4
|
+
export {SearchBox, loadSearchIndexJSON, useSearchIndex}
|
package/dist/client/shim.d.ts
CHANGED
|
@@ -6,7 +6,9 @@ declare module '*.vue' {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
declare module '@internal/minisearchIndex' {
|
|
9
|
-
|
|
9
|
+
import type { SearchIndexData } from './composables/searchIndex.js'
|
|
10
|
+
|
|
11
|
+
const searchIndex: SearchIndexData
|
|
10
12
|
export {
|
|
11
13
|
searchIndex,
|
|
12
14
|
}
|
package/dist/node/index.d.ts
CHANGED
package/dist/node/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {createTokenizer} from '../shared/index.js'
|
|
1
2
|
import MiniSearch from 'minisearch'
|
|
2
3
|
import pMap from 'p-map'
|
|
3
4
|
import sanitizeHTML from 'sanitize-html'
|
|
@@ -7,20 +8,23 @@ export * from '../shared/index.js'
|
|
|
7
8
|
|
|
8
9
|
const SEARCH_INDEX_DIR = 'internal/minisearchIndex/'
|
|
9
10
|
|
|
10
|
-
const
|
|
11
|
+
const MAX_SEARCHABLE_TEXT_LENGTH = 8e3
|
|
11
12
|
|
|
12
|
-
const
|
|
13
|
+
const SEARCH_INDEX_SHARD_SIZE = 5e4
|
|
14
|
+
|
|
15
|
+
const indexByLocales = /* @__PURE__ */ new Map()
|
|
16
|
+
|
|
17
|
+
const indexCache = /* @__PURE__ */ new Map()
|
|
18
|
+
|
|
19
|
+
const writtenIndexFingerprints = /* @__PURE__ */ new Map()
|
|
13
20
|
|
|
14
21
|
function getIndexByLocale(locale, lang, options) {
|
|
15
22
|
let index = indexByLocales.get(locale)
|
|
16
23
|
if (!index) {
|
|
17
|
-
const segmenter = new Intl.Segmenter(lang, {granularity: 'word'})
|
|
18
24
|
index = new MiniSearch({
|
|
19
25
|
fields: ['title', 'titles', 'text'],
|
|
20
26
|
storeFields: ['title', 'titles'],
|
|
21
|
-
tokenize(
|
|
22
|
-
return Array.from(segmenter.segment(text)).map((s) => s.segment)
|
|
23
|
-
},
|
|
27
|
+
tokenize: createTokenizer(lang),
|
|
24
28
|
...options.miniSearch?.options,
|
|
25
29
|
})
|
|
26
30
|
indexByLocales.set(locale, index)
|
|
@@ -45,6 +49,7 @@ async function prepareSearchIndex({app, isSearchable, searchOptions}) {
|
|
|
45
49
|
const start = performance.now()
|
|
46
50
|
indexByLocales.clear()
|
|
47
51
|
indexCache.clear()
|
|
52
|
+
writtenIndexFingerprints.clear()
|
|
48
53
|
await pMap(app.pages, (p) => indexFile(p, searchOptions, isSearchable), {concurrency: 64})
|
|
49
54
|
await writeTemp(app)
|
|
50
55
|
if (app.env.isBuild) {
|
|
@@ -56,27 +61,103 @@ async function prepareSearchIndex({app, isSearchable, searchOptions}) {
|
|
|
56
61
|
`\n[${colors.green('@vuepress-plume/plugin-search')}] prepare search time spent: ${(performance.now() - start).toFixed(2)}ms`,
|
|
57
62
|
)
|
|
58
63
|
}
|
|
59
|
-
|
|
64
|
+
|
|
65
|
+
let indexTaskQueue = Promise.resolve()
|
|
66
|
+
function runExclusive(task) {
|
|
67
|
+
const result = indexTaskQueue.then(task)
|
|
68
|
+
indexTaskQueue = result.then(
|
|
69
|
+
() => {},
|
|
70
|
+
() => {},
|
|
71
|
+
)
|
|
72
|
+
return result
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function prepareSearchIndexInBackground(options) {
|
|
76
|
+
runExclusive(() => prepareSearchIndex(options)).catch((error) => {
|
|
77
|
+
logger.error(
|
|
78
|
+
`${colors.green('[@vuepress-plume/plugin-search]')} failed to prepare search index:`,
|
|
79
|
+
error,
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function onSearchIndexUpdated(app, {page, isSearchable, searchOptions}) {
|
|
85
|
+
if (isSearchable && !isSearchable(page)) return
|
|
86
|
+
await runExclusive(async () => {
|
|
87
|
+
await indexFile(page, searchOptions, isSearchable)
|
|
88
|
+
await writeTemp(app)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function onSearchIndexRemoved(app, {page, isSearchable, searchOptions}) {
|
|
93
|
+
if (isSearchable && !isSearchable(page)) return
|
|
94
|
+
const cacheKey = page.filePathRelative
|
|
95
|
+
if (!cacheKey) return
|
|
96
|
+
await runExclusive(async () => {
|
|
97
|
+
const index = getIndexByLocale(page.pathLocale, page.lang, searchOptions)
|
|
98
|
+
const cache = indexCache.get(cacheKey)
|
|
99
|
+
if (cache?.length) index.removeAll(cache)
|
|
100
|
+
indexCache.delete(cacheKey)
|
|
101
|
+
await writeTemp(app)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function splitSearchIndex(serialized, shardSize = SEARCH_INDEX_SHARD_SIZE) {
|
|
106
|
+
const {index} = serialized
|
|
107
|
+
if (shardSize <= 0 || index.length <= shardSize)
|
|
108
|
+
return {
|
|
109
|
+
meta: serialized,
|
|
110
|
+
shards: [],
|
|
111
|
+
}
|
|
112
|
+
const shards = []
|
|
113
|
+
for (let i = 0; i < index.length; i += shardSize) shards.push(index.slice(i, i + shardSize))
|
|
114
|
+
return {
|
|
115
|
+
meta: {
|
|
116
|
+
...serialized,
|
|
117
|
+
index: [],
|
|
118
|
+
},
|
|
119
|
+
shards,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildIndexFiles(localeName, serialized, shardSize = SEARCH_INDEX_SHARD_SIZE) {
|
|
124
|
+
const {meta, shards} = splitSearchIndex(serialized, shardSize)
|
|
125
|
+
const files = {}
|
|
126
|
+
shards.forEach((entries, i) => {
|
|
127
|
+
files[`searchBox-${localeName}-${i}.js`] =
|
|
128
|
+
`export default ${JSON.stringify(JSON.stringify(entries))}`
|
|
129
|
+
})
|
|
130
|
+
const shardImports = shards.length
|
|
131
|
+
? `\nexport const shards = [${shards.map((_, i) => `() => import('@${SEARCH_INDEX_DIR}searchBox-${localeName}-${i}.js')`).join(', ')}]`
|
|
132
|
+
: ''
|
|
133
|
+
files[`searchBox-${localeName}.js`] =
|
|
134
|
+
`export default ${JSON.stringify(JSON.stringify(meta))}${shardImports}`
|
|
135
|
+
return files
|
|
136
|
+
}
|
|
60
137
|
|
|
61
138
|
async function writeTemp(app) {
|
|
62
139
|
const records = []
|
|
63
140
|
const promises = []
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
records.push(`${JSON.stringify(locale)}: () => import('@${SEARCH_INDEX_DIR}${filename}')`)
|
|
141
|
+
const writeIfChanged = (filename, content) => {
|
|
142
|
+
const filePath = `${SEARCH_INDEX_DIR}${filename}`
|
|
143
|
+
if (writtenIndexFingerprints.get(filePath) === content) return
|
|
68
144
|
promises.push(
|
|
69
|
-
app.writeTemp(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
145
|
+
app.writeTemp(filePath, content).then(() => {
|
|
146
|
+
writtenIndexFingerprints.set(filePath, content)
|
|
147
|
+
}),
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
for (const [locale, localeIndex] of indexByLocales) {
|
|
151
|
+
const localeName = locale.replace(/^\/|\/$/g, '').replace(/\//g, '_') || 'default'
|
|
152
|
+
records.push(
|
|
153
|
+
`${JSON.stringify(locale)}: () => import('@${SEARCH_INDEX_DIR}searchBox-${localeName}.js')`,
|
|
73
154
|
)
|
|
155
|
+
const files = buildIndexFiles(localeName, localeIndex.toJSON())
|
|
156
|
+
for (const [filename, content] of Object.entries(files)) writeIfChanged(filename, content)
|
|
74
157
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
`export const searchIndex = {${records.join(',')}}${app.env.isDev ? `\n${genHmrCode('searchIndex')}` : ''}`,
|
|
79
|
-
),
|
|
158
|
+
writeIfChanged(
|
|
159
|
+
'index.js',
|
|
160
|
+
`export const searchIndex = {${records.join(',')}}${app.env.isDev ? `\n${genHmrCode('searchIndex')}` : ''}`,
|
|
80
161
|
)
|
|
81
162
|
await Promise.all(promises)
|
|
82
163
|
}
|
|
@@ -85,45 +166,52 @@ async function indexFile(page, options, isSearchable) {
|
|
|
85
166
|
if (!page.filePath || page.frontmatter?.search === false) return
|
|
86
167
|
if (isSearchable && !isSearchable(page)) return
|
|
87
168
|
const fileId = page.path
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const index = getIndexByLocale(locale, lang, options)
|
|
91
|
-
const cache = getIndexCache(fileId)
|
|
92
|
-
const sections =
|
|
93
|
-
splitPageIntoSections(`<h1><a href="#"><span>${page.frontmatter.title || page.title}</span></a></h1>
|
|
94
|
-
${page.contentRendered}`)
|
|
169
|
+
const index = getIndexByLocale(page.pathLocale, page.lang, options)
|
|
170
|
+
const cache = getIndexCache(page.filePathRelative ?? fileId)
|
|
95
171
|
try {
|
|
96
|
-
if (cache
|
|
97
|
-
} catch {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
172
|
+
if (cache.length) index.removeAll(cache)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
logger.error(
|
|
175
|
+
`${colors.green('[@vuepress-plume/plugin-search]')} failed to remove stale index entries:`,
|
|
176
|
+
error,
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
cache.length = 0
|
|
180
|
+
for (const item of createPageSectionItems(page))
|
|
181
|
+
if (index.has(item.id)) {
|
|
182
|
+
if (item.id !== fileId)
|
|
104
183
|
logger.error(
|
|
105
|
-
`${colors.green('[@vuepress-plume/plugin-search]')} duplicate heading anchor : ${colors.cyan(titles.join(' >> '))} \n at ${colors.cyan(fileId)}`,
|
|
184
|
+
`${colors.green('[@vuepress-plume/plugin-search]')} duplicate heading anchor : ${colors.cyan([...item.titles, item.title].join(' >> '))} \n at ${colors.cyan(fileId)}`,
|
|
106
185
|
)
|
|
107
186
|
else
|
|
108
187
|
logger.error(
|
|
109
188
|
`${colors.green('[@vuepress-plume/plugin-search]')} duplicate page permalink : ${colors.cyan(fileId)}`,
|
|
110
189
|
)
|
|
111
190
|
} else {
|
|
112
|
-
const item = {
|
|
113
|
-
id,
|
|
114
|
-
text,
|
|
115
|
-
title: titles.at(-1),
|
|
116
|
-
titles: titles.slice(0, -1),
|
|
117
|
-
}
|
|
118
191
|
index.add(item)
|
|
119
192
|
cache.push(item)
|
|
120
193
|
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function* createPageSectionItems(page) {
|
|
197
|
+
const html = `<h1><a href="#"><span>${page.frontmatter.title || page.title || page.filePathRelative || ''}</span></a></h1>\n${page.contentRendered}`
|
|
198
|
+
const fileId = page.path
|
|
199
|
+
for (const section of splitPageIntoSections(html)) {
|
|
200
|
+
const {anchor, text, titles} = section
|
|
201
|
+
|
|
202
|
+
if (!text && !titles.length) continue
|
|
203
|
+
yield {
|
|
204
|
+
id: anchor ? `${fileId}#${anchor}` : fileId,
|
|
205
|
+
text,
|
|
206
|
+
title: titles.at(-1),
|
|
207
|
+
titles: titles.slice(0, -1),
|
|
208
|
+
}
|
|
121
209
|
}
|
|
122
210
|
}
|
|
123
211
|
|
|
124
|
-
const headingRegex = /<h(\d
|
|
212
|
+
const headingRegex = /<h(\d)[^>]*>(<a.*? href="#[^"]*"[^>]*>[\s\S]*?<\/a>)<\/h\1>/gi
|
|
125
213
|
|
|
126
|
-
const headingContentRegex = /<a.*? href="#(
|
|
214
|
+
const headingContentRegex = /<a.*? href="#([^"]*)"[^>]*><span>([\s\S]*?)<\/span><\/a>/i
|
|
127
215
|
|
|
128
216
|
const ignoreHeadingRegex = /<template[^>]*>[\s\S]*<\/template>/gi
|
|
129
217
|
|
|
@@ -140,7 +228,10 @@ function* splitPageIntoSections(html) {
|
|
|
140
228
|
const content = result[i + 2]
|
|
141
229
|
if (!title || !content) continue
|
|
142
230
|
if (level === 0) parentTitles = [title]
|
|
143
|
-
else
|
|
231
|
+
else {
|
|
232
|
+
parentTitles.length = level
|
|
233
|
+
parentTitles[level] = title
|
|
234
|
+
}
|
|
144
235
|
let titles = parentTitles.slice(0, level)
|
|
145
236
|
titles[level] = title
|
|
146
237
|
titles = titles.filter(Boolean)
|
|
@@ -154,7 +245,9 @@ function* splitPageIntoSections(html) {
|
|
|
154
245
|
|
|
155
246
|
function getSearchableText(content) {
|
|
156
247
|
content = clearHtmlTags(content)
|
|
157
|
-
return content
|
|
248
|
+
return content.length > MAX_SEARCHABLE_TEXT_LENGTH
|
|
249
|
+
? content.slice(0, MAX_SEARCHABLE_TEXT_LENGTH)
|
|
250
|
+
: content
|
|
158
251
|
}
|
|
159
252
|
|
|
160
253
|
function clearHtmlTags(str) {
|
|
@@ -191,6 +284,8 @@ const SEARCH_LOCALES = [
|
|
|
191
284
|
resetButtonTitle: 'Reset search',
|
|
192
285
|
backButtonTitle: 'Close search',
|
|
193
286
|
noResultsText: 'No results for',
|
|
287
|
+
searchIndexErrorText: 'Failed to load the search index.',
|
|
288
|
+
noscriptText: 'Search is unavailable without JavaScript.',
|
|
194
289
|
footer: {
|
|
195
290
|
selectText: 'to select',
|
|
196
291
|
selectKeyAriaLabel: 'enter',
|
|
@@ -209,6 +304,8 @@ const SEARCH_LOCALES = [
|
|
|
209
304
|
resetButtonTitle: '重置搜索',
|
|
210
305
|
backButtonTitle: '关闭',
|
|
211
306
|
noResultsText: '无搜索结果:',
|
|
307
|
+
searchIndexErrorText: '搜索索引加载失败。',
|
|
308
|
+
noscriptText: '未启用 JavaScript,搜索不可用。',
|
|
212
309
|
footer: {
|
|
213
310
|
selectText: '选择',
|
|
214
311
|
selectKeyAriaLabel: '输入',
|
|
@@ -227,6 +324,8 @@ const SEARCH_LOCALES = [
|
|
|
227
324
|
resetButtonTitle: '重設搜尋',
|
|
228
325
|
backButtonTitle: '關閉',
|
|
229
326
|
noResultsText: '無搜尋結果:',
|
|
327
|
+
searchIndexErrorText: '搜尋索引載入失敗。',
|
|
328
|
+
noscriptText: '未啟用 JavaScript,搜尋無法使用。',
|
|
230
329
|
footer: {
|
|
231
330
|
selectText: '選擇',
|
|
232
331
|
selectKeyAriaLabel: '輸入',
|
|
@@ -245,6 +344,8 @@ const SEARCH_LOCALES = [
|
|
|
245
344
|
resetButtonTitle: 'Suche zurücksetzen',
|
|
246
345
|
backButtonTitle: 'Schließen',
|
|
247
346
|
noResultsText: 'Keine Suchergebnisse:',
|
|
347
|
+
searchIndexErrorText: 'Der Suchindex konnte nicht geladen werden.',
|
|
348
|
+
noscriptText: 'Die Suche ist ohne JavaScript nicht verfügbar.',
|
|
248
349
|
footer: {
|
|
249
350
|
selectText: 'Auswählen',
|
|
250
351
|
selectKeyAriaLabel: 'Eingabe',
|
|
@@ -263,6 +364,8 @@ const SEARCH_LOCALES = [
|
|
|
263
364
|
resetButtonTitle: 'Réinitialiser la recherche',
|
|
264
365
|
backButtonTitle: 'Fermer',
|
|
265
366
|
noResultsText: 'Aucun résultat trouvé :',
|
|
367
|
+
searchIndexErrorText: 'Échec du chargement de l’index de recherche.',
|
|
368
|
+
noscriptText: 'La recherche est indisponible sans JavaScript.',
|
|
266
369
|
footer: {
|
|
267
370
|
selectText: 'sélectionner',
|
|
268
371
|
selectKeyAriaLabel: 'Entrée',
|
|
@@ -281,6 +384,8 @@ const SEARCH_LOCALES = [
|
|
|
281
384
|
resetButtonTitle: 'Сбросить поиск',
|
|
282
385
|
backButtonTitle: 'Закрыть',
|
|
283
386
|
noResultsText: 'Нет результатов поиска:',
|
|
387
|
+
searchIndexErrorText: 'Не удалось загрузить поисковый индекс.',
|
|
388
|
+
noscriptText: 'Поиск недоступен без JavaScript.',
|
|
284
389
|
footer: {
|
|
285
390
|
selectText: 'Выбрать',
|
|
286
391
|
selectKeyAriaLabel: 'Ввод',
|
|
@@ -299,6 +404,8 @@ const SEARCH_LOCALES = [
|
|
|
299
404
|
resetButtonTitle: '検索をリセット',
|
|
300
405
|
backButtonTitle: '閉じる',
|
|
301
406
|
noResultsText: '検索結果がありません:',
|
|
407
|
+
searchIndexErrorText: '検索インデックスの読み込みに失敗しました。',
|
|
408
|
+
noscriptText: 'JavaScript が無効のため、検索を利用できません。',
|
|
302
409
|
footer: {
|
|
303
410
|
selectText: '選択',
|
|
304
411
|
selectKeyAriaLabel: '入力',
|
|
@@ -343,13 +450,39 @@ function searchPlugin({locales = {}, isSearchable, ...searchOptions} = {}) {
|
|
|
343
450
|
})
|
|
344
451
|
else {
|
|
345
452
|
await prepareSearchIndexPlaceholder(app)
|
|
346
|
-
|
|
453
|
+
prepareSearchIndexInBackground({
|
|
347
454
|
app,
|
|
348
455
|
isSearchable,
|
|
349
456
|
searchOptions,
|
|
350
457
|
})
|
|
351
458
|
}
|
|
352
459
|
},
|
|
460
|
+
onPageUpdated: async (app, type, pageNew, pageOld) => {
|
|
461
|
+
if (type === 'delete') {
|
|
462
|
+
if (pageOld?.filePathRelative)
|
|
463
|
+
await onSearchIndexRemoved(app, {
|
|
464
|
+
page: pageOld,
|
|
465
|
+
isSearchable,
|
|
466
|
+
searchOptions,
|
|
467
|
+
})
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
const becameUnsearchable = !!isSearchable && !!pageNew && !isSearchable(pageNew)
|
|
471
|
+
const page = becameUnsearchable ? pageOld : pageNew
|
|
472
|
+
if (!page?.filePathRelative) return
|
|
473
|
+
if (becameUnsearchable)
|
|
474
|
+
await onSearchIndexRemoved(app, {
|
|
475
|
+
page,
|
|
476
|
+
isSearchable,
|
|
477
|
+
searchOptions,
|
|
478
|
+
})
|
|
479
|
+
else
|
|
480
|
+
await onSearchIndexUpdated(app, {
|
|
481
|
+
page,
|
|
482
|
+
isSearchable,
|
|
483
|
+
searchOptions,
|
|
484
|
+
})
|
|
485
|
+
},
|
|
353
486
|
})
|
|
354
487
|
}
|
|
355
488
|
|
package/dist/shared/index.d.ts
CHANGED
|
@@ -16,6 +16,25 @@ export interface SearchLocaleOptions {
|
|
|
16
16
|
backButtonTitle: string
|
|
17
17
|
/** Text shown when no results found / 无搜索结果时显示的文本 */
|
|
18
18
|
noResultsText: string
|
|
19
|
+
/**
|
|
20
|
+
* Text shown when the search index is missing or fails to load.
|
|
21
|
+
*
|
|
22
|
+
* 搜索索引缺失或加载失败时显示的文本。
|
|
23
|
+
*/
|
|
24
|
+
searchIndexErrorText?: string
|
|
25
|
+
/**
|
|
26
|
+
* Fallback text shown when JavaScript is disabled.
|
|
27
|
+
*
|
|
28
|
+
* 禁用 JavaScript 时显示的降级提示文本。
|
|
29
|
+
*
|
|
30
|
+
* 请注意:该文本会渲染在 `<noscript>` 中,必须为不含 `<`、`>`、`&`、引号等
|
|
31
|
+
* 需要 HTML 转义的纯文本,否则在启用脚本的浏览器中水合时会与原文不一致。
|
|
32
|
+
*
|
|
33
|
+
* Note: this text is rendered inside `<noscript>`, so it must be plain text
|
|
34
|
+
* without characters requiring HTML escaping (`<`, `>`, `&`, quotes), otherwise
|
|
35
|
+
* it would mismatch during hydration in browsers with scripting enabled.
|
|
36
|
+
*/
|
|
37
|
+
noscriptText?: string
|
|
19
38
|
/** Footer keyboard shortcut hints / 底部键盘快捷键提示 */
|
|
20
39
|
footer: {
|
|
21
40
|
/** Text for select action / 选择操作的文本 */
|
|
@@ -40,6 +59,28 @@ export interface SearchLocaleOptions {
|
|
|
40
59
|
* 搜索框的语言配置类型。
|
|
41
60
|
*/
|
|
42
61
|
export type SearchBoxLocales = LocaleConfig<SearchLocaleOptions>
|
|
62
|
+
/**
|
|
63
|
+
* Create a tokenizer shared by the node (index build) and client (query) sides.
|
|
64
|
+
*
|
|
65
|
+
* 创建由 Node 端(构建索引)与客户端(查询)共享的分词器。
|
|
66
|
+
*
|
|
67
|
+
* MiniSearch requires the tokenizer used to build the index to be identical to
|
|
68
|
+
* the one used at query time. Defining it in one place guarantees both sides
|
|
69
|
+
* use the same strategy: `Intl.Segmenter` when available, otherwise the same
|
|
70
|
+
* deterministic whitespace fallback. Blank and punctuation-only tokens are
|
|
71
|
+
* discarded on both sides, so they never pollute the index or the query.
|
|
72
|
+
*
|
|
73
|
+
* MiniSearch 要求构建索引与查询时使用完全一致的分词器。集中定义可保证两端策略一致:
|
|
74
|
+
* 支持 `Intl.Segmenter` 时使用它分词,否则统一退化为确定性的空白符分词。
|
|
75
|
+
* 两端都会丢弃空白与纯标点词元,避免污染索引与查询。
|
|
76
|
+
*
|
|
77
|
+
* @param lang - Language code for word segmentation / 用于分词的语言代码
|
|
78
|
+
* @returns Tokenizer that splits a string into tokens / 将字符串切分为词元的函数
|
|
79
|
+
* @example
|
|
80
|
+
* const tokenize = createTokenizer('en')
|
|
81
|
+
* tokenize('Hello world') // ['Hello', 'world'] with Intl.Segmenter
|
|
82
|
+
*/
|
|
83
|
+
export declare function createTokenizer(lang: string): (text: string) => string[]
|
|
43
84
|
/**
|
|
44
85
|
* Options for the search plugin.
|
|
45
86
|
*
|
package/dist/shared/index.js
CHANGED
|
@@ -1 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
function isWordToken(token) {
|
|
2
|
+
return /\p{L}|\p{N}/u.test(token)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function createTokenizer(lang) {
|
|
6
|
+
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
|
|
7
|
+
const segmenter = new Intl.Segmenter(lang, {granularity: 'word'})
|
|
8
|
+
return (text) =>
|
|
9
|
+
Array.from(segmenter.segment(text))
|
|
10
|
+
.map((segment) => segment.segment)
|
|
11
|
+
.filter(isWordToken)
|
|
12
|
+
}
|
|
13
|
+
return (text) => text.split(/\s+/).filter(isWordToken)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export {createTokenizer}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vuepress-plume/plugin-search",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.213",
|
|
5
5
|
"description": "The Plugin for VuePress 2 - local search",
|
|
6
6
|
"author": "pengzhanbo <volodymyr@foxmail.com>",
|
|
7
7
|
"license": "MIT",
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
"bugs": {
|
|
15
15
|
"url": "https://github.com/pengzhanbo/vuepress-theme-plume/issues"
|
|
16
16
|
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"VuePress",
|
|
19
|
+
"vuepress plugin",
|
|
20
|
+
"mini search",
|
|
21
|
+
"vuepress-plugin-search"
|
|
22
|
+
],
|
|
17
23
|
"exports": {
|
|
18
24
|
".": {
|
|
19
25
|
"types": "./dist/node/index.d.ts",
|
|
@@ -34,27 +40,21 @@
|
|
|
34
40
|
"vuepress": "2.0.0-rc.31"
|
|
35
41
|
},
|
|
36
42
|
"dependencies": {
|
|
37
|
-
"@vuepress/helper": "2.0.0-rc.
|
|
38
|
-
"@vueuse/core": "^
|
|
39
|
-
"@vueuse/integrations": "^
|
|
43
|
+
"@vuepress/helper": "2.0.0-rc.135",
|
|
44
|
+
"@vueuse/core": "^15.0.0",
|
|
45
|
+
"@vueuse/integrations": "^15.0.0",
|
|
40
46
|
"chokidar": "5.0.0",
|
|
41
47
|
"focus-trap": "^8.2.2",
|
|
42
48
|
"mark.js": "^8.11.1",
|
|
43
49
|
"minisearch": "^7.2.0",
|
|
44
50
|
"p-map": "^7.0.8",
|
|
45
|
-
"sanitize-html": "^2.
|
|
46
|
-
"vue": "^3.5.
|
|
51
|
+
"sanitize-html": "^2.18.0",
|
|
52
|
+
"vue": "^3.5.43"
|
|
47
53
|
},
|
|
48
54
|
"publishConfig": {
|
|
49
55
|
"access": "public",
|
|
50
56
|
"provenance": true
|
|
51
57
|
},
|
|
52
|
-
"keyword": [
|
|
53
|
-
"VuePress",
|
|
54
|
-
"vuepress plugin",
|
|
55
|
-
"mini search",
|
|
56
|
-
"vuepress-plugin-search"
|
|
57
|
-
],
|
|
58
58
|
"scripts": {
|
|
59
59
|
"build": "pnpm run tsdown && pnpm run copy",
|
|
60
60
|
"clean": "rimraf --glob ./dist",
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LRU (Least Recently Used) Cache Implementation
|
|
3
|
-
*
|
|
4
|
-
* LRU(最近最少使用)缓存实现
|
|
5
|
-
*
|
|
6
|
-
* Adapted from https://stackoverflow.com/a/46432113/11613622
|
|
7
|
-
*
|
|
8
|
-
* @module plugin-search/client/utils/lru
|
|
9
|
-
*/
|
|
10
|
-
/**
|
|
11
|
-
* Generic LRU Cache implementation using Map.
|
|
12
|
-
*
|
|
13
|
-
* 使用 Map 实现的通用 LRU 缓存。
|
|
14
|
-
*
|
|
15
|
-
* Automatically evicts the least recently used item when the cache reaches
|
|
16
|
-
* its maximum size.
|
|
17
|
-
*
|
|
18
|
-
* 当缓存达到最大容量时,自动淘汰最近最少使用的项。
|
|
19
|
-
*
|
|
20
|
-
* @template K - Key type / 键类型
|
|
21
|
-
* @template V - Value type / 值类型
|
|
22
|
-
* @example
|
|
23
|
-
* const cache = new LRUCache<string, number>(3)
|
|
24
|
-
* cache.set('a', 1)
|
|
25
|
-
* cache.set('b', 2)
|
|
26
|
-
* cache.set('c', 3)
|
|
27
|
-
* cache.set('d', 4) // 'a' is evicted
|
|
28
|
-
* cache.get('b') // returns 2, 'b' becomes most recent
|
|
29
|
-
*/
|
|
30
|
-
export declare class LRUCache<K, V> {
|
|
31
|
-
/** Maximum number of items in the cache / 缓存中的最大项数 */
|
|
32
|
-
private max
|
|
33
|
-
/** Internal Map storage / 内部 Map 存储 */
|
|
34
|
-
private cache
|
|
35
|
-
/**
|
|
36
|
-
* Create a new LRU Cache instance.
|
|
37
|
-
*
|
|
38
|
-
* 创建新的 LRU 缓存实例。
|
|
39
|
-
*
|
|
40
|
-
* @param max - Maximum cache size (default: 10) / 最大缓存大小(默认:10)
|
|
41
|
-
*/
|
|
42
|
-
constructor(max?: number)
|
|
43
|
-
/**
|
|
44
|
-
* Get a value from the cache.
|
|
45
|
-
*
|
|
46
|
-
* 从缓存中获取值。
|
|
47
|
-
*
|
|
48
|
-
* Accessing an item moves it to the end (most recently used position).
|
|
49
|
-
*
|
|
50
|
-
* 访问项会将其移动到末尾(最近使用的位置)。
|
|
51
|
-
*
|
|
52
|
-
* @param key - Cache key / 缓存键
|
|
53
|
-
* @returns Cached value or undefined if not found / 缓存值,未找到则返回 undefined
|
|
54
|
-
*/
|
|
55
|
-
get(key: K): V | undefined
|
|
56
|
-
/**
|
|
57
|
-
* Set a value in the cache.
|
|
58
|
-
*
|
|
59
|
-
* 在缓存中设置值。
|
|
60
|
-
*
|
|
61
|
-
* If the key already exists, it is moved to the end. If the cache is full,
|
|
62
|
-
* the oldest item is evicted.
|
|
63
|
-
*
|
|
64
|
-
* 如果键已存在,则将其移动到末尾。如果缓存已满,则淘汰最旧的项。
|
|
65
|
-
*
|
|
66
|
-
* @param key - Cache key / 缓存键
|
|
67
|
-
* @param val - Value to cache / 要缓存的值
|
|
68
|
-
*/
|
|
69
|
-
set(key: K, val: V): void
|
|
70
|
-
/**
|
|
71
|
-
* Get the first (oldest) key in the cache.
|
|
72
|
-
*
|
|
73
|
-
* 获取缓存中的第一个(最旧的)键。
|
|
74
|
-
*
|
|
75
|
-
* @returns The oldest key or undefined if cache is empty / 最旧的键,缓存为空则返回 undefined
|
|
76
|
-
*/
|
|
77
|
-
first(): K | undefined
|
|
78
|
-
/**
|
|
79
|
-
* Clear all items from the cache.
|
|
80
|
-
*
|
|
81
|
-
* 清空缓存中的所有项。
|
|
82
|
-
*/
|
|
83
|
-
clear(): void
|
|
84
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
var LRUCache = class {
|
|
2
|
-
max
|
|
3
|
-
|
|
4
|
-
cache
|
|
5
|
-
|
|
6
|
-
constructor(max = 10) {
|
|
7
|
-
this.max = max
|
|
8
|
-
this.cache = new Map()
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
get(key) {
|
|
12
|
-
const item = this.cache.get(key)
|
|
13
|
-
if (item !== void 0) {
|
|
14
|
-
this.cache.delete(key)
|
|
15
|
-
this.cache.set(key, item)
|
|
16
|
-
}
|
|
17
|
-
return item
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
set(key, val) {
|
|
21
|
-
if (this.cache.has(key)) this.cache.delete(key)
|
|
22
|
-
else if (this.cache.size === this.max) this.cache.delete(this.first())
|
|
23
|
-
this.cache.set(key, val)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
first() {
|
|
27
|
-
return this.cache.keys().next().value
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
clear() {
|
|
31
|
-
this.cache.clear()
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export {LRUCache}
|