@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.
@@ -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
- if (event.ctrlKey || event.metaKey) {
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
- if (!isEditingContent(event)) {
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 { useLocale, useSearchIndex } from '../composables/index.js'
29
- import { LRUCache } from '../utils/index.js'
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
- const searchIndex = computedAsync(async () => {
66
- let tokenize: ((str: string) => string[]) | undefined
67
- if (typeof Intl.Segmenter !== 'undefined') {
68
- const segmenter = new Intl.Segmenter(lang.value, { granularity: 'word' })
69
- tokenize = str => Array.from(segmenter.segment(str)).map(s => s.segment)
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
- return markRaw(
73
- MiniSearch.loadJSON<Result>(
74
- (await searchIndexData.value[routeLocale.value]?.())?.default,
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], old, onCleanup) => {
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
- results.value = results.value.map((r) => {
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
- router.push(selectedPackage.id)
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
- router.push(p.id)
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="mini-search-label"
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="filterText && !results.length && enableNoResults"
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 localeIndex = await indexData.value['/zh/']()
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}
@@ -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}
@@ -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}
@@ -6,7 +6,9 @@ declare module '*.vue' {
6
6
  }
7
7
 
8
8
  declare module '@internal/minisearchIndex' {
9
- const searchIndex: Record<string, () => Promise<{ default: string }>>
9
+ import type { SearchIndexData } from './composables/searchIndex.js'
10
+
11
+ const searchIndex: SearchIndexData
10
12
  export {
11
13
  searchIndex,
12
14
  }
@@ -1,4 +1,5 @@
1
1
  import {SearchOptions, SearchPluginOptions} from '../shared/index.js'
2
+ import 'minisearch'
2
3
  import {App, Plugin} from 'vuepress/core'
3
4
  export * from '../shared/index.js'
4
5
  /**
@@ -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 indexByLocales = new Map()
11
+ const MAX_SEARCHABLE_TEXT_LENGTH = 8e3
11
12
 
12
- const indexCache = new Map()
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(text) {
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
- Promise.resolve()
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
- for (const [locale] of indexByLocales) {
65
- const index = indexByLocales.get(locale)
66
- const filename = `searchBox-${locale.replace(/^\/|\/$/g, '').replace(/\//g, '_') || 'default'}.js`
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
- `${SEARCH_INDEX_DIR}${filename}`,
71
- `export default ${JSON.stringify(JSON.stringify(index) ?? {})}`,
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
- promises.push(
76
- app.writeTemp(
77
- `${SEARCH_INDEX_DIR}index.js`,
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 locale = page.pathLocale
89
- const lang = page.lang
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 && cache.length) index.removeAll(cache)
97
- } catch {}
98
- for await (const section of sections) {
99
- if (!section || !(section.text || section.titles)) break
100
- const {anchor, text, titles} = section
101
- const id = anchor ? [fileId, anchor].join('#') : fileId
102
- if (index.has(id)) {
103
- if (anchor)
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*).*?>(<a.*? href="#.*?".*?>[\s\S]*?<\/a>)<\/h\1>/gi
212
+ const headingRegex = /<h(\d)[^>]*>(<a.*? href="#[^"]*"[^>]*>[\s\S]*?<\/a>)<\/h\1>/gi
125
213
 
126
- const headingContentRegex = /<a.*? href="#(.*?)".*?><span>([\s\S]*?)<\/span><\/a>/i
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 parentTitles[level] = title
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
- prepareSearchIndex({
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
 
@@ -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
  *
@@ -1 +1,16 @@
1
- export {}
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.211",
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.134",
38
- "@vueuse/core": "^14.4.0",
39
- "@vueuse/integrations": "^14.4.0",
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.17.7",
46
- "vue": "^3.5.42"
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}