@vuepress-plume/plugin-search 1.0.0-rc.212 → 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;
@@ -26,8 +26,7 @@ import {
26
26
  } from 'vue'
27
27
  import { usePageLang, useRouteLocale, useRouter, withBase } from 'vuepress/client'
28
28
  import { createTokenizer } from '../../shared/index.js'
29
- import { useLocale, useSearchIndex } from '../composables/index.js'
30
- import { LRUCache } from '../utils/index.js'
29
+ import { loadSearchIndexJSON, useLocale } from '../composables/index.js'
31
30
  import BackIcon from './icons/BackIcon.vue'
32
31
  import ClearIcon from './icons/ClearIcon.vue'
33
32
  import SearchIcon from './icons/SearchIcon.vue'
@@ -48,12 +47,9 @@ const lang = usePageLang()
48
47
  const el = shallowRef<HTMLElement>()
49
48
  const resultsEl = shallowRef<HTMLElement>()
50
49
 
51
- const searchIndexData = useSearchIndex()
52
-
53
50
  interface Result {
54
51
  title: string
55
52
  titles: string[]
56
- text?: string
57
53
  }
58
54
 
59
55
  const { activate } = useFocusTrap(el, { immediate: true })
@@ -105,19 +101,27 @@ const searchIndex = computedAsync(async (onCancel) => {
105
101
  // Use the same tokenizer as the node build side so query tokens always match the index.
106
102
  const tokenize = createTokenizer(lang.value)
107
103
 
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())
115
- }
116
-
117
104
  try {
118
- const json = (await loadIndex())?.default
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.
119
123
  return markRaw(
120
- MiniSearch.loadJSON<Result>(json, {
124
+ await MiniSearch.loadJSONAsync<Result>(indexJson, {
121
125
  fields: ['title', 'titles', 'text'],
122
126
  storeFields: ['title', 'titles'],
123
127
  searchOptions: {
@@ -162,16 +166,9 @@ const mark = computedAsync(async () => {
162
166
  return markRaw(new Mark(resultsEl.value))
163
167
  }, null)
164
168
 
165
- const cache = new LRUCache<string, Map<string, string>>(64) // 64 files
166
-
167
169
  watchDebounced(
168
170
  () => [searchIndex.value, filterText.value] as const,
169
- async ([index, filterTextValue], old, onCleanup) => {
170
- if (old?.[0] !== index) {
171
- // in case of hmr
172
- cache.clear()
173
- }
174
-
171
+ async ([index, filterTextValue], _old, onCleanup) => {
175
172
  let canceled = false
176
173
  onCleanup(() => {
177
174
  canceled = true
@@ -191,16 +188,10 @@ watchDebounced(
191
188
  enableNoResults.value = true
192
189
 
193
190
  const terms = new Set<string>()
194
-
195
- results.value = results.value.map((r) => {
196
- const [id, anchor] = r.id.split('#')
197
- const map = cache.get(id)
198
- const text = map?.get(anchor) ?? ''
199
- for (const term in r.match)
191
+ for (const result of results.value) {
192
+ for (const term in result.match)
200
193
  terms.add(term)
201
-
202
- return { ...r, text }
203
- })
194
+ }
204
195
 
205
196
  await nextTick()
206
197
  if (canceled)
@@ -306,6 +297,30 @@ onKeyStroke(['n', 'N'], (event) => {
306
297
 
307
298
  const router = useRouter()
308
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
+
309
324
  onKeyStroke('Enter', (e) => {
310
325
  if (e.isComposing)
311
326
  return
@@ -320,7 +335,11 @@ onKeyStroke('Enter', (e) => {
320
335
  }
321
336
 
322
337
  if (selectedPackage) {
323
- 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)
324
343
  emit('close')
325
344
  }
326
345
  })
@@ -338,6 +357,7 @@ onMounted(() => {
338
357
 
339
358
  useEventListener('popstate', (event) => {
340
359
  event.preventDefault()
360
+ closedByPopstate = true
341
361
  emit('close')
342
362
  })
343
363
 
@@ -353,6 +373,15 @@ onMounted(() => {
353
373
 
354
374
  onBeforeUnmount(() => {
355
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()
356
385
  })
357
386
 
358
387
  function resetSearch() {
@@ -376,7 +405,10 @@ function formMarkRegex(terms: Set<string>) {
376
405
 
377
406
  function selectedClick(e: MouseEvent, p: SearchResult & Result) {
378
407
  e.preventDefault()
379
- 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)
380
412
  emit('close')
381
413
  }
382
414
  </script>
@@ -389,7 +421,7 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
389
421
  :aria-owns="results?.length ? 'localsearch-list' : undefined"
390
422
  aria-expanded="true"
391
423
  aria-haspopup="listbox"
392
- aria-labelledby="mini-search-label"
424
+ aria-labelledby="localsearch-label"
393
425
  class="VPLocalSearchBox"
394
426
  >
395
427
  <div class="backdrop" @click="$emit('close')" />
@@ -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>
@@ -9,6 +9,7 @@ const defaultLocales = {
9
9
  backButtonTitle: 'Close search',
10
10
  noResultsText: 'No results for',
11
11
  searchIndexErrorText: 'Failed to load the search index.',
12
+ noscriptText: 'Search is unavailable without JavaScript.',
12
13
  footer: {
13
14
  selectText: 'to select',
14
15
  selectKeyAriaLabel: 'enter',
@@ -37,9 +38,28 @@ const searchIndexData = shallowRef(searchIndex)
37
38
  function useSearchIndex() {
38
39
  return searchIndexData
39
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
+ }
40
60
  if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot))
41
61
  __VUE_HMR_RUNTIME__.updateSearchIndex = (data) => {
42
62
  searchIndexData.value = data
43
63
  }
44
64
 
45
- 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
  /**
@@ -8,9 +8,15 @@ export * from '../shared/index.js'
8
8
 
9
9
  const SEARCH_INDEX_DIR = 'internal/minisearchIndex/'
10
10
 
11
- const indexByLocales = new Map()
11
+ const MAX_SEARCHABLE_TEXT_LENGTH = 8e3
12
12
 
13
- 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()
14
20
 
15
21
  function getIndexByLocale(locale, lang, options) {
16
22
  let index = indexByLocales.get(locale)
@@ -43,6 +49,7 @@ async function prepareSearchIndex({app, isSearchable, searchOptions}) {
43
49
  const start = performance.now()
44
50
  indexByLocales.clear()
45
51
  indexCache.clear()
52
+ writtenIndexFingerprints.clear()
46
53
  await pMap(app.pages, (p) => indexFile(p, searchOptions, isSearchable), {concurrency: 64})
47
54
  await writeTemp(app)
48
55
  if (app.env.isBuild) {
@@ -95,25 +102,62 @@ async function onSearchIndexRemoved(app, {page, isSearchable, searchOptions}) {
95
102
  })
96
103
  }
97
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
+ }
137
+
98
138
  async function writeTemp(app) {
99
139
  const records = []
100
140
  const promises = []
101
- for (const [locale] of indexByLocales) {
102
- const index = indexByLocales.get(locale)
103
- const filename = `searchBox-${locale.replace(/^\/|\/$/g, '').replace(/\//g, '_') || 'default'}.js`
104
- 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
105
144
  promises.push(
106
- app.writeTemp(
107
- `${SEARCH_INDEX_DIR}${filename}`,
108
- `export default ${JSON.stringify(JSON.stringify(index) ?? {})}`,
109
- ),
145
+ app.writeTemp(filePath, content).then(() => {
146
+ writtenIndexFingerprints.set(filePath, content)
147
+ }),
110
148
  )
111
149
  }
112
- promises.push(
113
- app.writeTemp(
114
- `${SEARCH_INDEX_DIR}index.js`,
115
- `export const searchIndex = {${records.join(',')}}${app.env.isDev ? `\n${genHmrCode('searchIndex')}` : ''}`,
116
- ),
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')`,
154
+ )
155
+ const files = buildIndexFiles(localeName, localeIndex.toJSON())
156
+ for (const [filename, content] of Object.entries(files)) writeIfChanged(filename, content)
157
+ }
158
+ writeIfChanged(
159
+ 'index.js',
160
+ `export const searchIndex = {${records.join(',')}}${app.env.isDev ? `\n${genHmrCode('searchIndex')}` : ''}`,
117
161
  )
118
162
  await Promise.all(promises)
119
163
  }
@@ -126,7 +170,12 @@ async function indexFile(page, options, isSearchable) {
126
170
  const cache = getIndexCache(page.filePathRelative ?? fileId)
127
171
  try {
128
172
  if (cache.length) index.removeAll(cache)
129
- } catch {}
173
+ } catch (error) {
174
+ logger.error(
175
+ `${colors.green('[@vuepress-plume/plugin-search]')} failed to remove stale index entries:`,
176
+ error,
177
+ )
178
+ }
130
179
  cache.length = 0
131
180
  for (const item of createPageSectionItems(page))
132
181
  if (index.has(item.id)) {
@@ -149,6 +198,7 @@ function* createPageSectionItems(page) {
149
198
  const fileId = page.path
150
199
  for (const section of splitPageIntoSections(html)) {
151
200
  const {anchor, text, titles} = section
201
+
152
202
  if (!text && !titles.length) continue
153
203
  yield {
154
204
  id: anchor ? `${fileId}#${anchor}` : fileId,
@@ -159,9 +209,9 @@ function* createPageSectionItems(page) {
159
209
  }
160
210
  }
161
211
 
162
- 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
163
213
 
164
- const headingContentRegex = /<a.*? href="#(.*?)".*?><span>([\s\S]*?)<\/span><\/a>/i
214
+ const headingContentRegex = /<a.*? href="#([^"]*)"[^>]*><span>([\s\S]*?)<\/span><\/a>/i
165
215
 
166
216
  const ignoreHeadingRegex = /<template[^>]*>[\s\S]*<\/template>/gi
167
217
 
@@ -178,7 +228,10 @@ function* splitPageIntoSections(html) {
178
228
  const content = result[i + 2]
179
229
  if (!title || !content) continue
180
230
  if (level === 0) parentTitles = [title]
181
- else parentTitles[level] = title
231
+ else {
232
+ parentTitles.length = level
233
+ parentTitles[level] = title
234
+ }
182
235
  let titles = parentTitles.slice(0, level)
183
236
  titles[level] = title
184
237
  titles = titles.filter(Boolean)
@@ -192,7 +245,9 @@ function* splitPageIntoSections(html) {
192
245
 
193
246
  function getSearchableText(content) {
194
247
  content = clearHtmlTags(content)
195
- return content
248
+ return content.length > MAX_SEARCHABLE_TEXT_LENGTH
249
+ ? content.slice(0, MAX_SEARCHABLE_TEXT_LENGTH)
250
+ : content
196
251
  }
197
252
 
198
253
  function clearHtmlTags(str) {
@@ -230,6 +285,7 @@ const SEARCH_LOCALES = [
230
285
  backButtonTitle: 'Close search',
231
286
  noResultsText: 'No results for',
232
287
  searchIndexErrorText: 'Failed to load the search index.',
288
+ noscriptText: 'Search is unavailable without JavaScript.',
233
289
  footer: {
234
290
  selectText: 'to select',
235
291
  selectKeyAriaLabel: 'enter',
@@ -249,6 +305,7 @@ const SEARCH_LOCALES = [
249
305
  backButtonTitle: '关闭',
250
306
  noResultsText: '无搜索结果:',
251
307
  searchIndexErrorText: '搜索索引加载失败。',
308
+ noscriptText: '未启用 JavaScript,搜索不可用。',
252
309
  footer: {
253
310
  selectText: '选择',
254
311
  selectKeyAriaLabel: '输入',
@@ -268,6 +325,7 @@ const SEARCH_LOCALES = [
268
325
  backButtonTitle: '關閉',
269
326
  noResultsText: '無搜尋結果:',
270
327
  searchIndexErrorText: '搜尋索引載入失敗。',
328
+ noscriptText: '未啟用 JavaScript,搜尋無法使用。',
271
329
  footer: {
272
330
  selectText: '選擇',
273
331
  selectKeyAriaLabel: '輸入',
@@ -287,6 +345,7 @@ const SEARCH_LOCALES = [
287
345
  backButtonTitle: 'Schließen',
288
346
  noResultsText: 'Keine Suchergebnisse:',
289
347
  searchIndexErrorText: 'Der Suchindex konnte nicht geladen werden.',
348
+ noscriptText: 'Die Suche ist ohne JavaScript nicht verfügbar.',
290
349
  footer: {
291
350
  selectText: 'Auswählen',
292
351
  selectKeyAriaLabel: 'Eingabe',
@@ -306,6 +365,7 @@ const SEARCH_LOCALES = [
306
365
  backButtonTitle: 'Fermer',
307
366
  noResultsText: 'Aucun résultat trouvé :',
308
367
  searchIndexErrorText: 'Échec du chargement de l’index de recherche.',
368
+ noscriptText: 'La recherche est indisponible sans JavaScript.',
309
369
  footer: {
310
370
  selectText: 'sélectionner',
311
371
  selectKeyAriaLabel: 'Entrée',
@@ -325,6 +385,7 @@ const SEARCH_LOCALES = [
325
385
  backButtonTitle: 'Закрыть',
326
386
  noResultsText: 'Нет результатов поиска:',
327
387
  searchIndexErrorText: 'Не удалось загрузить поисковый индекс.',
388
+ noscriptText: 'Поиск недоступен без JavaScript.',
328
389
  footer: {
329
390
  selectText: 'Выбрать',
330
391
  selectKeyAriaLabel: 'Ввод',
@@ -344,6 +405,7 @@ const SEARCH_LOCALES = [
344
405
  backButtonTitle: '閉じる',
345
406
  noResultsText: '検索結果がありません:',
346
407
  searchIndexErrorText: '検索インデックスの読み込みに失敗しました。',
408
+ noscriptText: 'JavaScript が無効のため、検索を利用できません。',
347
409
  footer: {
348
410
  selectText: '選択',
349
411
  selectKeyAriaLabel: '入力',
@@ -22,6 +22,19 @@ export interface SearchLocaleOptions {
22
22
  * 搜索索引缺失或加载失败时显示的文本。
23
23
  */
24
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
25
38
  /** Footer keyboard shortcut hints / 底部键盘快捷键提示 */
26
39
  footer: {
27
40
  /** Text for select action / 选择操作的文本 */
@@ -54,16 +67,18 @@ export type SearchBoxLocales = LocaleConfig<SearchLocaleOptions>
54
67
  * MiniSearch requires the tokenizer used to build the index to be identical to
55
68
  * the one used at query time. Defining it in one place guarantees both sides
56
69
  * use the same strategy: `Intl.Segmenter` when available, otherwise the same
57
- * deterministic whitespace fallback.
70
+ * deterministic whitespace fallback. Blank and punctuation-only tokens are
71
+ * discarded on both sides, so they never pollute the index or the query.
58
72
  *
59
73
  * MiniSearch 要求构建索引与查询时使用完全一致的分词器。集中定义可保证两端策略一致:
60
74
  * 支持 `Intl.Segmenter` 时使用它分词,否则统一退化为确定性的空白符分词。
75
+ * 两端都会丢弃空白与纯标点词元,避免污染索引与查询。
61
76
  *
62
77
  * @param lang - Language code for word segmentation / 用于分词的语言代码
63
78
  * @returns Tokenizer that splits a string into tokens / 将字符串切分为词元的函数
64
79
  * @example
65
80
  * const tokenize = createTokenizer('en')
66
- * tokenize('Hello world') // ['Hello', ' ', 'world'] with Intl.Segmenter
81
+ * tokenize('Hello world') // ['Hello', 'world'] with Intl.Segmenter
67
82
  */
68
83
  export declare function createTokenizer(lang: string): (text: string) => string[]
69
84
  /**
@@ -1,9 +1,16 @@
1
+ function isWordToken(token) {
2
+ return /\p{L}|\p{N}/u.test(token)
3
+ }
4
+
1
5
  function createTokenizer(lang) {
2
6
  if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
3
7
  const segmenter = new Intl.Segmenter(lang, {granularity: 'word'})
4
- return (text) => Array.from(segmenter.segment(text)).map((segment) => segment.segment)
8
+ return (text) =>
9
+ Array.from(segmenter.segment(text))
10
+ .map((segment) => segment.segment)
11
+ .filter(isWordToken)
5
12
  }
6
- return (text) => text.split(/\s+/).filter(Boolean)
13
+ return (text) => text.split(/\s+/).filter(isWordToken)
7
14
  }
8
15
 
9
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.212",
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",
@@ -42,19 +48,13 @@
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",
51
+ "sanitize-html": "^2.18.0",
46
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}