@vuepress-plume/plugin-search 1.0.0-rc.210 → 1.0.0-rc.212

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.
@@ -25,6 +25,7 @@ import {
25
25
  watch,
26
26
  } from 'vue'
27
27
  import { usePageLang, useRouteLocale, useRouter, withBase } from 'vuepress/client'
28
+ import { createTokenizer } from '../../shared/index.js'
28
29
  import { useLocale, useSearchIndex } from '../composables/index.js'
29
30
  import { LRUCache } from '../utils/index.js'
30
31
  import BackIcon from './icons/BackIcon.vue'
@@ -62,17 +63,61 @@ const isSearching = ref(false)
62
63
 
63
64
  const showSearchSpinner = computed(() => isSearchIndexLoading.value || isSearching.value)
64
65
 
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)
66
+ /**
67
+ * Visible error message when the search index is missing or fails to load.
68
+ *
69
+ * 搜索索引缺失或加载失败时显示的可见提示。
70
+ */
71
+ const searchIndexError = ref('')
72
+
73
+ const searchIndexErrorText = computed(() =>
74
+ locale.value.searchIndexErrorText || 'Failed to load the search index.',
75
+ )
76
+
77
+ /**
78
+ * Create an empty index used as a fallback when the index is missing or fails
79
+ * to load, so searching returns an empty result set instead of throwing.
80
+ *
81
+ * 创建空索引,在索引缺失或加载失败时兜底,使检索返回空结果而不是抛出异常。
82
+ */
83
+ function createEmptyIndex(): MiniSearch<Result> {
84
+ return new MiniSearch<Result>({
85
+ fields: ['title', 'titles', 'text'],
86
+ storeFields: ['title', 'titles'],
87
+ })
88
+ }
89
+
90
+ const searchIndex = computedAsync(async (onCancel) => {
91
+ // 标记本次求值是否已被新一轮加载取代(例如 HMR 更新索引)。
92
+ // 过期任务不得再写入错误状态,否则有效结果旁会残留加载失败提示。
93
+ //
94
+ // Mark this evaluation as stale (e.g. superseded by a newer load during HMR).
95
+ // A stale task must not write the error state, otherwise a load failure message
96
+ // would persist next to valid results.
97
+ let canceled = false
98
+ onCancel(() => {
99
+ canceled = true
100
+ })
101
+
102
+ searchIndexError.value = ''
103
+
104
+ // 使用与 Node 端建索引时完全一致的分词策略,避免两端词元不匹配导致检索失效。
105
+ // Use the same tokenizer as the node build side so query tokens always match the index.
106
+ const tokenize = createTokenizer(lang.value)
107
+
108
+ const loadIndex = searchIndexData.value[routeLocale.value]
109
+ // 当前语言缺少索引文件时回退到空索引,并给出可见提示,避免静默无结果。
110
+ // Fall back to an empty index and show a visible message when the locale has no index.
111
+ if (!loadIndex) {
112
+ if (!canceled)
113
+ searchIndexError.value = searchIndexErrorText.value
114
+ return markRaw(createEmptyIndex())
70
115
  }
71
116
 
72
- return markRaw(
73
- MiniSearch.loadJSON<Result>(
74
- (await searchIndexData.value[routeLocale.value]?.())?.default,
75
- {
117
+ try {
118
+ const json = (await loadIndex())?.default
119
+ return markRaw(
120
+ MiniSearch.loadJSON<Result>(json, {
76
121
  fields: ['title', 'titles', 'text'],
77
122
  storeFields: ['title', 'titles'],
78
123
  searchOptions: {
@@ -83,9 +128,15 @@ const searchIndex = computedAsync(async () => {
83
128
  },
84
129
  ...options.miniSearch?.searchOptions,
85
130
  ...options.miniSearch?.options,
86
- },
87
- ),
88
- )
131
+ }),
132
+ )
133
+ }
134
+ catch (error) {
135
+ console.error('[plugin-search] failed to load the search index:', error)
136
+ if (!canceled)
137
+ searchIndexError.value = searchIndexErrorText.value
138
+ return markRaw(createEmptyIndex())
139
+ }
89
140
  }, undefined, isSearchIndexLoading)
90
141
 
91
142
  const disableQueryPersistence = computed(() =>
@@ -447,7 +498,14 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
447
498
  </a>
448
499
  </li>
449
500
  <li
450
- v-if="filterText && !results.length && enableNoResults"
501
+ v-if="searchIndexError"
502
+ class="no-results error"
503
+ role="alert"
504
+ >
505
+ {{ searchIndexError }}
506
+ </li>
507
+ <li
508
+ v-else-if="filterText && !results.length && enableNoResults"
451
509
  class="no-results"
452
510
  >
453
511
  {{ locale.noResultsText }} "<strong>{{ filterText }}</strong>"
@@ -815,4 +873,8 @@ svg {
815
873
  font-size: 0.9rem;
816
874
  text-align: center;
817
875
  }
876
+
877
+ .no-results.error {
878
+ color: var(--vp-c-danger-1, #b8272c);
879
+ }
818
880
  </style>
@@ -8,6 +8,7 @@ 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.',
11
12
  footer: {
12
13
  selectText: 'to select',
13
14
  selectKeyAriaLabel: 'enter',
@@ -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'
@@ -14,13 +15,10 @@ const indexCache = new Map()
14
15
  function getIndexByLocale(locale, lang, options) {
15
16
  let index = indexByLocales.get(locale)
16
17
  if (!index) {
17
- const segmenter = new Intl.Segmenter(lang, {granularity: 'word'})
18
18
  index = new MiniSearch({
19
19
  fields: ['title', 'titles', 'text'],
20
20
  storeFields: ['title', 'titles'],
21
- tokenize(text) {
22
- return Array.from(segmenter.segment(text)).map((s) => s.segment)
23
- },
21
+ tokenize: createTokenizer(lang),
24
22
  ...options.miniSearch?.options,
25
23
  })
26
24
  indexByLocales.set(locale, index)
@@ -56,7 +54,46 @@ async function prepareSearchIndex({app, isSearchable, searchOptions}) {
56
54
  `\n[${colors.green('@vuepress-plume/plugin-search')}] prepare search time spent: ${(performance.now() - start).toFixed(2)}ms`,
57
55
  )
58
56
  }
59
- Promise.resolve()
57
+
58
+ let indexTaskQueue = Promise.resolve()
59
+ function runExclusive(task) {
60
+ const result = indexTaskQueue.then(task)
61
+ indexTaskQueue = result.then(
62
+ () => {},
63
+ () => {},
64
+ )
65
+ return result
66
+ }
67
+
68
+ function prepareSearchIndexInBackground(options) {
69
+ runExclusive(() => prepareSearchIndex(options)).catch((error) => {
70
+ logger.error(
71
+ `${colors.green('[@vuepress-plume/plugin-search]')} failed to prepare search index:`,
72
+ error,
73
+ )
74
+ })
75
+ }
76
+
77
+ async function onSearchIndexUpdated(app, {page, isSearchable, searchOptions}) {
78
+ if (isSearchable && !isSearchable(page)) return
79
+ await runExclusive(async () => {
80
+ await indexFile(page, searchOptions, isSearchable)
81
+ await writeTemp(app)
82
+ })
83
+ }
84
+
85
+ async function onSearchIndexRemoved(app, {page, isSearchable, searchOptions}) {
86
+ if (isSearchable && !isSearchable(page)) return
87
+ const cacheKey = page.filePathRelative
88
+ if (!cacheKey) return
89
+ await runExclusive(async () => {
90
+ const index = getIndexByLocale(page.pathLocale, page.lang, searchOptions)
91
+ const cache = indexCache.get(cacheKey)
92
+ if (cache?.length) index.removeAll(cache)
93
+ indexCache.delete(cacheKey)
94
+ await writeTemp(app)
95
+ })
96
+ }
60
97
 
61
98
  async function writeTemp(app) {
62
99
  const records = []
@@ -85,39 +122,40 @@ async function indexFile(page, options, isSearchable) {
85
122
  if (!page.filePath || page.frontmatter?.search === false) return
86
123
  if (isSearchable && !isSearchable(page)) return
87
124
  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}`)
125
+ const index = getIndexByLocale(page.pathLocale, page.lang, options)
126
+ const cache = getIndexCache(page.filePathRelative ?? fileId)
95
127
  try {
96
- if (cache && cache.length) index.removeAll(cache)
128
+ if (cache.length) index.removeAll(cache)
97
129
  } 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)
130
+ cache.length = 0
131
+ for (const item of createPageSectionItems(page))
132
+ if (index.has(item.id)) {
133
+ if (item.id !== fileId)
104
134
  logger.error(
105
- `${colors.green('[@vuepress-plume/plugin-search]')} duplicate heading anchor : ${colors.cyan(titles.join(' >> '))} \n at ${colors.cyan(fileId)}`,
135
+ `${colors.green('[@vuepress-plume/plugin-search]')} duplicate heading anchor : ${colors.cyan([...item.titles, item.title].join(' >> '))} \n at ${colors.cyan(fileId)}`,
106
136
  )
107
137
  else
108
138
  logger.error(
109
139
  `${colors.green('[@vuepress-plume/plugin-search]')} duplicate page permalink : ${colors.cyan(fileId)}`,
110
140
  )
111
141
  } else {
112
- const item = {
113
- id,
114
- text,
115
- title: titles.at(-1),
116
- titles: titles.slice(0, -1),
117
- }
118
142
  index.add(item)
119
143
  cache.push(item)
120
144
  }
145
+ }
146
+
147
+ function* createPageSectionItems(page) {
148
+ const html = `<h1><a href="#"><span>${page.frontmatter.title || page.title || page.filePathRelative || ''}</span></a></h1>\n${page.contentRendered}`
149
+ const fileId = page.path
150
+ for (const section of splitPageIntoSections(html)) {
151
+ const {anchor, text, titles} = section
152
+ if (!text && !titles.length) continue
153
+ yield {
154
+ id: anchor ? `${fileId}#${anchor}` : fileId,
155
+ text,
156
+ title: titles.at(-1),
157
+ titles: titles.slice(0, -1),
158
+ }
121
159
  }
122
160
  }
123
161
 
@@ -191,6 +229,7 @@ const SEARCH_LOCALES = [
191
229
  resetButtonTitle: 'Reset search',
192
230
  backButtonTitle: 'Close search',
193
231
  noResultsText: 'No results for',
232
+ searchIndexErrorText: 'Failed to load the search index.',
194
233
  footer: {
195
234
  selectText: 'to select',
196
235
  selectKeyAriaLabel: 'enter',
@@ -209,6 +248,7 @@ const SEARCH_LOCALES = [
209
248
  resetButtonTitle: '重置搜索',
210
249
  backButtonTitle: '关闭',
211
250
  noResultsText: '无搜索结果:',
251
+ searchIndexErrorText: '搜索索引加载失败。',
212
252
  footer: {
213
253
  selectText: '选择',
214
254
  selectKeyAriaLabel: '输入',
@@ -227,6 +267,7 @@ const SEARCH_LOCALES = [
227
267
  resetButtonTitle: '重設搜尋',
228
268
  backButtonTitle: '關閉',
229
269
  noResultsText: '無搜尋結果:',
270
+ searchIndexErrorText: '搜尋索引載入失敗。',
230
271
  footer: {
231
272
  selectText: '選擇',
232
273
  selectKeyAriaLabel: '輸入',
@@ -245,6 +286,7 @@ const SEARCH_LOCALES = [
245
286
  resetButtonTitle: 'Suche zurücksetzen',
246
287
  backButtonTitle: 'Schließen',
247
288
  noResultsText: 'Keine Suchergebnisse:',
289
+ searchIndexErrorText: 'Der Suchindex konnte nicht geladen werden.',
248
290
  footer: {
249
291
  selectText: 'Auswählen',
250
292
  selectKeyAriaLabel: 'Eingabe',
@@ -263,6 +305,7 @@ const SEARCH_LOCALES = [
263
305
  resetButtonTitle: 'Réinitialiser la recherche',
264
306
  backButtonTitle: 'Fermer',
265
307
  noResultsText: 'Aucun résultat trouvé :',
308
+ searchIndexErrorText: 'Échec du chargement de l’index de recherche.',
266
309
  footer: {
267
310
  selectText: 'sélectionner',
268
311
  selectKeyAriaLabel: 'Entrée',
@@ -281,6 +324,7 @@ const SEARCH_LOCALES = [
281
324
  resetButtonTitle: 'Сбросить поиск',
282
325
  backButtonTitle: 'Закрыть',
283
326
  noResultsText: 'Нет результатов поиска:',
327
+ searchIndexErrorText: 'Не удалось загрузить поисковый индекс.',
284
328
  footer: {
285
329
  selectText: 'Выбрать',
286
330
  selectKeyAriaLabel: 'Ввод',
@@ -299,6 +343,7 @@ const SEARCH_LOCALES = [
299
343
  resetButtonTitle: '検索をリセット',
300
344
  backButtonTitle: '閉じる',
301
345
  noResultsText: '検索結果がありません:',
346
+ searchIndexErrorText: '検索インデックスの読み込みに失敗しました。',
302
347
  footer: {
303
348
  selectText: '選択',
304
349
  selectKeyAriaLabel: '入力',
@@ -343,13 +388,39 @@ function searchPlugin({locales = {}, isSearchable, ...searchOptions} = {}) {
343
388
  })
344
389
  else {
345
390
  await prepareSearchIndexPlaceholder(app)
346
- prepareSearchIndex({
391
+ prepareSearchIndexInBackground({
347
392
  app,
348
393
  isSearchable,
349
394
  searchOptions,
350
395
  })
351
396
  }
352
397
  },
398
+ onPageUpdated: async (app, type, pageNew, pageOld) => {
399
+ if (type === 'delete') {
400
+ if (pageOld?.filePathRelative)
401
+ await onSearchIndexRemoved(app, {
402
+ page: pageOld,
403
+ isSearchable,
404
+ searchOptions,
405
+ })
406
+ return
407
+ }
408
+ const becameUnsearchable = !!isSearchable && !!pageNew && !isSearchable(pageNew)
409
+ const page = becameUnsearchable ? pageOld : pageNew
410
+ if (!page?.filePathRelative) return
411
+ if (becameUnsearchable)
412
+ await onSearchIndexRemoved(app, {
413
+ page,
414
+ isSearchable,
415
+ searchOptions,
416
+ })
417
+ else
418
+ await onSearchIndexUpdated(app, {
419
+ page,
420
+ isSearchable,
421
+ searchOptions,
422
+ })
423
+ },
353
424
  })
354
425
  }
355
426
 
@@ -16,6 +16,12 @@ 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
19
25
  /** Footer keyboard shortcut hints / 底部键盘快捷键提示 */
20
26
  footer: {
21
27
  /** Text for select action / 选择操作的文本 */
@@ -40,6 +46,26 @@ export interface SearchLocaleOptions {
40
46
  * 搜索框的语言配置类型。
41
47
  */
42
48
  export type SearchBoxLocales = LocaleConfig<SearchLocaleOptions>
49
+ /**
50
+ * Create a tokenizer shared by the node (index build) and client (query) sides.
51
+ *
52
+ * 创建由 Node 端(构建索引)与客户端(查询)共享的分词器。
53
+ *
54
+ * MiniSearch requires the tokenizer used to build the index to be identical to
55
+ * the one used at query time. Defining it in one place guarantees both sides
56
+ * use the same strategy: `Intl.Segmenter` when available, otherwise the same
57
+ * deterministic whitespace fallback.
58
+ *
59
+ * MiniSearch 要求构建索引与查询时使用完全一致的分词器。集中定义可保证两端策略一致:
60
+ * 支持 `Intl.Segmenter` 时使用它分词,否则统一退化为确定性的空白符分词。
61
+ *
62
+ * @param lang - Language code for word segmentation / 用于分词的语言代码
63
+ * @returns Tokenizer that splits a string into tokens / 将字符串切分为词元的函数
64
+ * @example
65
+ * const tokenize = createTokenizer('en')
66
+ * tokenize('Hello world') // ['Hello', ' ', 'world'] with Intl.Segmenter
67
+ */
68
+ export declare function createTokenizer(lang: string): (text: string) => string[]
43
69
  /**
44
70
  * Options for the search plugin.
45
71
  *
@@ -1 +1,9 @@
1
- export {}
1
+ function createTokenizer(lang) {
2
+ if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
3
+ const segmenter = new Intl.Segmenter(lang, {granularity: 'word'})
4
+ return (text) => Array.from(segmenter.segment(text)).map((segment) => segment.segment)
5
+ }
6
+ return (text) => text.split(/\s+/).filter(Boolean)
7
+ }
8
+
9
+ 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.210",
4
+ "version": "1.0.0-rc.212",
5
5
  "description": "The Plugin for VuePress 2 - local search",
6
6
  "author": "pengzhanbo <volodymyr@foxmail.com>",
7
7
  "license": "MIT",
@@ -34,16 +34,16 @@
34
34
  "vuepress": "2.0.0-rc.31"
35
35
  },
36
36
  "dependencies": {
37
- "@vuepress/helper": "2.0.0-rc.134",
38
- "@vueuse/core": "^14.4.0",
39
- "@vueuse/integrations": "^14.4.0",
37
+ "@vuepress/helper": "2.0.0-rc.135",
38
+ "@vueuse/core": "^15.0.0",
39
+ "@vueuse/integrations": "^15.0.0",
40
40
  "chokidar": "5.0.0",
41
41
  "focus-trap": "^8.2.2",
42
42
  "mark.js": "^8.11.1",
43
43
  "minisearch": "^7.2.0",
44
- "p-map": "^7.0.7",
44
+ "p-map": "^7.0.8",
45
45
  "sanitize-html": "^2.17.7",
46
- "vue": "^3.5.42"
46
+ "vue": "^3.5.43"
47
47
  },
48
48
  "publishConfig": {
49
49
  "access": "public",