vitepress 2.0.0-alpha.14 → 2.0.0-alpha.16

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.
Files changed (25) hide show
  1. package/dist/client/app/index.js +3 -1
  2. package/dist/client/theme-default/components/VPAlgoliaSearchBox.vue +214 -59
  3. package/dist/client/theme-default/components/VPBadge.vue +4 -5
  4. package/dist/client/theme-default/components/VPHero.vue +5 -2
  5. package/dist/client/theme-default/components/VPLocalSearchBox.vue +13 -18
  6. package/dist/client/theme-default/components/VPNavBar.vue +16 -19
  7. package/dist/client/theme-default/components/VPNavBarAskAiButton.vue +31 -0
  8. package/dist/client/theme-default/components/VPNavBarSearch.vue +120 -76
  9. package/dist/client/theme-default/components/VPNavBarSearchButton.vue +45 -125
  10. package/dist/client/theme-default/components/VPNavScreen.vue +1 -3
  11. package/dist/client/theme-default/components/VPNavScreenMenuGroupLink.vue +14 -1
  12. package/dist/client/theme-default/components/VPNavScreenMenuLink.vue +14 -1
  13. package/dist/client/theme-default/styles/base.css +213 -211
  14. package/dist/client/theme-default/styles/components/vp-doc.css +10 -0
  15. package/dist/client/theme-default/styles/docsearch.css +120 -0
  16. package/dist/client/theme-default/styles/icons.css +4 -1
  17. package/dist/client/theme-default/support/docsearch.js +169 -0
  18. package/dist/client/theme-default/support/reactivity.js +9 -0
  19. package/dist/node/{chunk-BvyEjT9v.js → chunk-CCYAX98b.js} +575 -1421
  20. package/dist/node/cli.js +3 -5
  21. package/dist/node/index.js +4 -6
  22. package/package.json +34 -33
  23. package/template/.vitepress/theme/style.css +0 -8
  24. package/types/default-theme.d.ts +5 -5
  25. package/types/docsearch.d.ts +51 -193
@@ -103,7 +103,9 @@ function newRouter() {
103
103
  pageFilePath = pageFilePath.replace(/\.js$/, '.lean.js');
104
104
  }
105
105
  if (import.meta.env.DEV) {
106
- pageModule = import(/*@vite-ignore*/ pageFilePath).catch(() => {
106
+ pageModule = import(/*@vite-ignore*/ pageFilePath).catch((e) => {
107
+ // page load could fail for other reasons, don't swallow
108
+ console.error(e);
107
109
  // try with/without trailing slash
108
110
  // in prod this is handled in src/client/app/utils.ts#pathToFile
109
111
  const url = new URL(pageFilePath, 'http://a.com');
@@ -1,84 +1,238 @@
1
1
  <script setup lang="ts">
2
- import docsearch from '@docsearch/js'
3
- import { useRouter } from 'vitepress'
2
+ import type { DocSearchInstance, DocSearchProps } from '@docsearch/js'
3
+ import type { SidepanelInstance, SidepanelProps } from '@docsearch/sidepanel-js'
4
+ import { inBrowser, useRouter } from 'vitepress'
4
5
  import type { DefaultTheme } from 'vitepress/theme'
5
- import { nextTick, onMounted, watch } from 'vue'
6
+ import { nextTick, onUnmounted, watch } from 'vue'
7
+ import type { DocSearchAskAi } from '../../../../types/docsearch'
6
8
  import { useData } from '../composables/data'
9
+ import { resolveMode, validateCredentials } from '../support/docsearch'
10
+
11
+ import '../styles/docsearch.css'
7
12
 
8
13
  const props = defineProps<{
9
- algolia: DefaultTheme.AlgoliaSearchOptions
14
+ algoliaOptions: DefaultTheme.AlgoliaSearchOptions
15
+ openRequest?: {
16
+ target: 'search' | 'askAi' | 'toggleAskAi'
17
+ nonce: number
18
+ } | null
10
19
  }>()
11
20
 
12
21
  const router = useRouter()
13
- const { site, localeIndex, lang } = useData()
22
+ const { site } = useData()
14
23
 
15
- onMounted(update)
16
- watch(localeIndex, update)
24
+ let cleanup = () => {}
25
+ let docsearchInstance: DocSearchInstance | undefined
26
+ let sidepanelInstance: SidepanelInstance | undefined
27
+ let openOnReady: 'search' | 'askAi' | null = null
28
+ let initializeCount = 0
29
+ let docsearchLoader: Promise<typeof import('@docsearch/js')> | undefined
30
+ let sidepanelLoader: Promise<typeof import('@docsearch/sidepanel-js')> | undefined
31
+ let lastFocusedElement: HTMLElement | null = null
32
+ let skipEventDocsearch = false
33
+ let skipEventSidepanel = false
17
34
 
18
- async function update() {
19
- await nextTick()
20
- const options = {
21
- ...props.algolia,
22
- ...props.algolia.locales?.[localeIndex.value]
23
- }
24
- const rawFacetFilters = options.searchParameters?.facetFilters ?? []
25
- const facetFilters = [
26
- ...(Array.isArray(rawFacetFilters)
27
- ? rawFacetFilters
28
- : [rawFacetFilters]
29
- ).filter((f) => !f.startsWith('lang:')),
30
- `lang:${lang.value}`
31
- ]
32
-
33
- // Rebuild the askAi prop as an object:
34
- // If the askAi prop is a string, treat it as the assistantId and use
35
- // the default indexName, apiKey and appId from the main options.
36
- // If the askAi prop is an object, spread its explicit values.
37
- const askAiProp = options.askAi
38
- const isAskAiString = typeof askAiProp === 'string'
39
-
40
- const askAi = askAiProp
41
- ? {
42
- indexName: isAskAiString ? options.indexName : askAiProp.indexName,
43
- apiKey: isAskAiString ? options.apiKey : askAiProp.apiKey,
44
- appId: isAskAiString ? options.appId : askAiProp.appId,
45
- assistantId: isAskAiString ? askAiProp : askAiProp.assistantId,
46
- // Re-use the merged facetFilters from the search parameters so that
47
- // Ask AI uses the same language filtering as the regular search.
48
- searchParameters: facetFilters.length ? { facetFilters } : undefined
35
+ watch(() => props.algoliaOptions, update, { immediate: true })
36
+ onUnmounted(cleanup)
37
+
38
+ watch(
39
+ () => props.openRequest?.nonce,
40
+ () => {
41
+ const req = props.openRequest
42
+ if (!req) return
43
+ if (req.target === 'search') {
44
+ if (docsearchInstance?.isReady) {
45
+ onBeforeOpen('docsearch', () => docsearchInstance?.open())
46
+ } else {
47
+ openOnReady = 'search'
48
+ }
49
+ } else if (req.target === 'toggleAskAi') {
50
+ if (sidepanelInstance?.isOpen) {
51
+ sidepanelInstance.close()
52
+ } else {
53
+ onBeforeOpen('sidepanel', () => sidepanelInstance?.open())
54
+ }
55
+ } else {
56
+ // askAi - open sidepanel or fallback to docsearch modal
57
+ if (sidepanelInstance?.isReady) {
58
+ onBeforeOpen('sidepanel', () => sidepanelInstance?.open())
59
+ } else if (sidepanelInstance) {
60
+ openOnReady = 'askAi'
61
+ } else if (docsearchInstance?.isReady) {
62
+ onBeforeOpen('docsearch', () => docsearchInstance?.openAskAi())
63
+ } else {
64
+ openOnReady = 'askAi'
49
65
  }
50
- : undefined
66
+ }
67
+ },
68
+ { immediate: true }
69
+ )
51
70
 
52
- initialize({
53
- ...options,
54
- searchParameters: {
55
- ...options.searchParameters,
56
- facetFilters
57
- },
58
- askAi
71
+ async function update(options: DefaultTheme.AlgoliaSearchOptions) {
72
+ if (!inBrowser) return
73
+ await nextTick()
74
+
75
+ const askAi = options.askAi as DocSearchAskAi | undefined
76
+
77
+ const { valid, ...credentials } = validateCredentials({
78
+ appId: options.appId ?? askAi?.appId,
79
+ apiKey: options.apiKey ?? askAi?.apiKey,
80
+ indexName: options.indexName ?? askAi?.indexName
59
81
  })
82
+
83
+ if (!valid) {
84
+ console.warn('[vitepress] Algolia search cannot be initialized: missing appId/apiKey/indexName.')
85
+ return
86
+ }
87
+
88
+ await initialize({ ...options, ...credentials })
60
89
  }
61
90
 
62
- function initialize(userOptions: DefaultTheme.AlgoliaSearchOptions) {
63
- const options = Object.assign({}, userOptions, {
64
- container: '#docsearch',
91
+ async function initialize(userOptions: DefaultTheme.AlgoliaSearchOptions) {
92
+ const currentInitialize = ++initializeCount
93
+
94
+ // Always tear down previous instances first (e.g. on locale changes)
95
+ cleanup()
96
+
97
+ const { useSidePanel } = resolveMode(userOptions)
98
+ const askAi = userOptions.askAi as DocSearchAskAi | undefined
99
+
100
+ const { default: docsearch } = await loadDocsearch()
101
+ if (currentInitialize !== initializeCount) return
102
+
103
+ if (useSidePanel && askAi?.sidePanel) {
104
+ const { default: sidepanel } = await loadSidepanel()
105
+ if (currentInitialize !== initializeCount) return
65
106
 
107
+ sidepanelInstance = sidepanel({
108
+ ...(askAi.sidePanel === true ? {} : askAi.sidePanel),
109
+ container: '#vp-docsearch-sidepanel',
110
+ indexName: askAi.indexName ?? userOptions.indexName,
111
+ appId: askAi.appId ?? userOptions.appId,
112
+ apiKey: askAi.apiKey ?? userOptions.apiKey,
113
+ assistantId: askAi.assistantId,
114
+ onOpen: focusInput,
115
+ onClose: onClose.bind(null, 'sidepanel'),
116
+ onReady: () => {
117
+ if (openOnReady === 'askAi') {
118
+ openOnReady = null
119
+ onBeforeOpen('sidepanel', () => sidepanelInstance?.open())
120
+ }
121
+ },
122
+ keyboardShortcuts: {
123
+ 'Ctrl/Cmd+I': false
124
+ }
125
+ } as SidepanelProps)
126
+ }
127
+
128
+ const options = {
129
+ ...userOptions,
130
+ container: '#vp-docsearch',
66
131
  navigator: {
67
- navigate(item: { itemUrl: string }) {
132
+ navigate(item) {
68
133
  router.go(item.itemUrl)
69
134
  }
70
135
  },
71
-
72
- transformItems(items: { url: string }[]) {
73
- return items.map((item) => {
74
- return Object.assign({}, item, {
75
- url: getRelativePath(item.url)
76
- })
77
- })
136
+ transformItems: (items) => items.map((item) => ({ ...item, url: getRelativePath(item.url) })),
137
+ // When sidepanel is enabled, intercept Ask AI events to open it instead (hybrid mode)
138
+ ...(useSidePanel && sidepanelInstance && {
139
+ interceptAskAiEvent: (initialMessage) => {
140
+ onBeforeOpen('sidepanel', () => sidepanelInstance?.open(initialMessage))
141
+ return true
142
+ }
143
+ }),
144
+ onOpen: focusInput,
145
+ onClose: onClose.bind(null, 'docsearch'),
146
+ onReady: () => {
147
+ if (openOnReady === 'search') {
148
+ openOnReady = null
149
+ onBeforeOpen('docsearch', () => docsearchInstance?.open())
150
+ } else if (openOnReady === 'askAi' && !sidepanelInstance) {
151
+ // No sidepanel configured, use docsearch modal for askAi
152
+ openOnReady = null
153
+ onBeforeOpen('docsearch', () => docsearchInstance?.openAskAi())
154
+ }
155
+ },
156
+ keyboardShortcuts: {
157
+ '/': false,
158
+ 'Ctrl/Cmd+K': false
78
159
  }
160
+ } as DocSearchProps
161
+
162
+ docsearchInstance = docsearch(options)
163
+
164
+ cleanup = () => {
165
+ docsearchInstance?.destroy()
166
+ sidepanelInstance?.destroy()
167
+ docsearchInstance = undefined
168
+ sidepanelInstance = undefined
169
+ openOnReady = null
170
+ lastFocusedElement = null
171
+ }
172
+ }
173
+
174
+ function focusInput() {
175
+ requestAnimationFrame(() => {
176
+ const input =
177
+ document.querySelector<HTMLInputElement>('#docsearch-input') ||
178
+ document.querySelector<HTMLInputElement>('#docsearch-sidepanel textarea')
179
+ input?.focus()
79
180
  })
181
+ }
80
182
 
81
- docsearch(options as any)
183
+ function onBeforeOpen(target: 'docsearch' | 'sidepanel', cb: () => void) {
184
+ if (target === 'docsearch') {
185
+ if (sidepanelInstance?.isOpen) {
186
+ skipEventSidepanel = true
187
+ sidepanelInstance.close()
188
+ } else if (!docsearchInstance?.isOpen) {
189
+ if (document.activeElement instanceof HTMLElement) {
190
+ lastFocusedElement = document.activeElement
191
+ }
192
+ }
193
+ } else if (target === 'sidepanel') {
194
+ if (docsearchInstance?.isOpen) {
195
+ skipEventDocsearch = true
196
+ docsearchInstance.close()
197
+ } else if (!sidepanelInstance?.isOpen) {
198
+ if (document.activeElement instanceof HTMLElement) {
199
+ lastFocusedElement = document.activeElement
200
+ }
201
+ }
202
+ }
203
+ setTimeout(cb, 0)
204
+ }
205
+
206
+ function onClose(target: 'docsearch' | 'sidepanel') {
207
+ if (target === 'docsearch') {
208
+ if (skipEventDocsearch) {
209
+ skipEventDocsearch = false
210
+ return
211
+ }
212
+ } else if (target === 'sidepanel') {
213
+ if (skipEventSidepanel) {
214
+ skipEventSidepanel = false
215
+ return
216
+ }
217
+ }
218
+ if (lastFocusedElement) {
219
+ lastFocusedElement.focus()
220
+ lastFocusedElement = null
221
+ }
222
+ }
223
+
224
+ function loadDocsearch() {
225
+ if (!docsearchLoader) {
226
+ docsearchLoader = import('@docsearch/js')
227
+ }
228
+ return docsearchLoader
229
+ }
230
+
231
+ function loadSidepanel() {
232
+ if (!sidepanelLoader) {
233
+ sidepanelLoader = import('@docsearch/sidepanel-js')
234
+ }
235
+ return sidepanelLoader
82
236
  }
83
237
 
84
238
  function getRelativePath(url: string) {
@@ -88,5 +242,6 @@ function getRelativePath(url: string) {
88
242
  </script>
89
243
 
90
244
  <template>
91
- <div id="docsearch" />
245
+ <div id="vp-docsearch" />
246
+ <div id="vp-docsearch-sidepanel" />
92
247
  </template>
@@ -39,15 +39,14 @@ withDefaults(defineProps<Props>(), {
39
39
  display: none;
40
40
  }
41
41
 
42
- .vp-doc h1 > .VPBadge {
43
- margin-top: 4px;
44
- vertical-align: top;
42
+ .vp-doc h1 > .VPBadge,
43
+ .vp-doc h2 > .VPBadge {
44
+ margin: 0 0 0 2px;
45
+ vertical-align: middle;
45
46
  }
46
47
 
47
48
  .vp-doc h2 > .VPBadge {
48
- margin-top: 3px;
49
49
  padding: 0 8px;
50
- vertical-align: top;
51
50
  }
52
51
 
53
52
  .vp-doc h3 > .VPBadge {
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { DefaultTheme } from 'vitepress/theme'
3
- import { inject } from 'vue'
3
+ import { computed, inject } from 'vue'
4
4
  import { layoutInfoInjectionKey } from '../composables/layout'
5
5
  import VPButton from './VPButton.vue'
6
6
  import VPImage from './VPImage.vue'
@@ -21,7 +21,10 @@ defineProps<{
21
21
  actions?: HeroAction[]
22
22
  }>()
23
23
 
24
- const { heroImageSlotExists } = inject(layoutInfoInjectionKey)!
24
+ const { heroImageSlotExists } = inject(
25
+ layoutInfoInjectionKey,
26
+ { heroImageSlotExists: computed(() => false) }
27
+ )
25
28
  </script>
26
29
 
27
30
  <template>
@@ -2,7 +2,7 @@
2
2
  import localSearchIndex from '@localSearchIndex'
3
3
  import {
4
4
  computedAsync,
5
- debouncedWatch,
5
+ watchDebounced,
6
6
  onKeyStroke,
7
7
  useEventListener,
8
8
  useLocalStorage,
@@ -26,7 +26,7 @@ import {
26
26
  watchEffect,
27
27
  type Ref
28
28
  } from 'vue'
29
- import type { ModalTranslations } from '../../../../types/local-search'
29
+ import type { LocalSearchTranslations } from '../../../../types/local-search'
30
30
  import { pathToFile } from '../../app/utils'
31
31
  import { escapeRegExp } from '../../shared'
32
32
  import { useData } from '../composables/data'
@@ -113,16 +113,6 @@ const disableDetailedView = computed(() => {
113
113
  )
114
114
  })
115
115
 
116
- const buttonText = computed(() => {
117
- const options = theme.value.search?.options ?? theme.value.algolia
118
-
119
- return (
120
- options?.locales?.[localeIndex.value]?.translations?.button?.buttonText ||
121
- options?.translations?.button?.buttonText ||
122
- 'Search'
123
- )
124
- })
125
-
126
116
  watchEffect(() => {
127
117
  if (disableDetailedView.value) {
128
118
  showDetailedList.value = false
@@ -144,7 +134,7 @@ const mark = computedAsync(async () => {
144
134
 
145
135
  const cache = new LRUCache<string, Map<string, string>>(16) // 16 files
146
136
 
147
- debouncedWatch(
137
+ watchDebounced(
148
138
  () => [searchIndex.value, filterText.value, showDetailedList.value] as const,
149
139
  async ([index, filterTextValue, showDetailedListValue], old, onCleanup) => {
150
140
  if (old?.[0] !== index) {
@@ -339,8 +329,12 @@ onKeyStroke('Escape', () => {
339
329
  emit('close')
340
330
  })
341
331
 
342
- // Translations
343
- const defaultTranslations: { modal: ModalTranslations } = {
332
+ /* Translations */
333
+
334
+ const defaultTranslations: LocalSearchTranslations = {
335
+ button: {
336
+ buttonText: 'Search'
337
+ },
344
338
  modal: {
345
339
  displayDetails: 'Display detailed list',
346
340
  resetButtonTitle: 'Reset search',
@@ -360,7 +354,7 @@ const defaultTranslations: { modal: ModalTranslations } = {
360
354
 
361
355
  const translate = createSearchTranslate(defaultTranslations)
362
356
 
363
- // Back
357
+ /* Back */
364
358
 
365
359
  onMounted(() => {
366
360
  // Prevents going to previous site
@@ -373,6 +367,7 @@ useEventListener('popstate', (event) => {
373
367
  })
374
368
 
375
369
  /** Lock body */
370
+
376
371
  const isLocked = useScrollLock(inBrowser ? document.body : null)
377
372
 
378
373
  onMounted(() => {
@@ -432,7 +427,7 @@ function onMouseMove(e: MouseEvent) {
432
427
  @submit.prevent=""
433
428
  >
434
429
  <label
435
- :title="buttonText"
430
+ :title="translate('button.buttonText')"
436
431
  id="localsearch-label"
437
432
  for="localsearch-input"
438
433
  >
@@ -461,7 +456,7 @@ function onMouseMove(e: MouseEvent) {
461
456
  id="localsearch-input"
462
457
  enterkeyhint="go"
463
458
  maxlength="64"
464
- :placeholder="buttonText"
459
+ :placeholder="translate('button.buttonText')"
465
460
  spellcheck="false"
466
461
  type="search"
467
462
  />
@@ -1,6 +1,5 @@
1
1
  <script lang="ts" setup>
2
2
  import { useWindowScroll } from '@vueuse/core'
3
- import { ref, watchPostEffect } from 'vue'
4
3
  import { useLayout } from '../composables/layout'
5
4
  import VPNavBarAppearance from './VPNavBarAppearance.vue'
6
5
  import VPNavBarExtra from './VPNavBarExtra.vue'
@@ -21,21 +20,18 @@ defineEmits<{
21
20
 
22
21
  const { y } = useWindowScroll()
23
22
  const { isHome, hasSidebar } = useLayout()
24
-
25
- const classes = ref<Record<string, boolean>>({})
26
-
27
- watchPostEffect(() => {
28
- classes.value = {
29
- 'has-sidebar': hasSidebar.value,
30
- 'home': isHome.value,
31
- 'top': y.value === 0,
32
- 'screen-open': props.isScreenOpen
33
- }
34
- })
35
23
  </script>
36
24
 
37
25
  <template>
38
- <div class="VPNavBar" :class="classes">
26
+ <div
27
+ class="VPNavBar"
28
+ :class="{
29
+ 'has-sidebar': hasSidebar,
30
+ 'home': isHome,
31
+ 'top': y === 0,
32
+ 'screen-open': isScreenOpen
33
+ }"
34
+ >
39
35
  <div class="wrapper">
40
36
  <div class="container">
41
37
  <div class="title">
@@ -55,7 +51,11 @@ watchPostEffect(() => {
55
51
  <VPNavBarSocialLinks class="social-links" />
56
52
  <VPNavBarExtra class="extra" />
57
53
  <slot name="nav-bar-content-after" />
58
- <VPNavBarHamburger class="hamburger" :active="isScreenOpen" @click="$emit('toggle-screen')" />
54
+ <VPNavBarHamburger
55
+ class="hamburger"
56
+ :active="isScreenOpen"
57
+ @click="$emit('toggle-screen')"
58
+ />
59
59
  </div>
60
60
  </div>
61
61
  </div>
@@ -187,8 +187,6 @@ watchPostEffect(() => {
187
187
  justify-content: flex-end;
188
188
  align-items: center;
189
189
  height: var(--vp-nav-height);
190
- margin-right: -100vw;
191
- padding-right: 100vw;
192
190
  transition: background-color 0.5s;
193
191
  }
194
192
 
@@ -201,11 +199,10 @@ watchPostEffect(() => {
201
199
  .VPNavBar:not(.has-sidebar):not(.home.top) .content-body {
202
200
  background-color: transparent;
203
201
  }
204
- }
205
202
 
206
- @media (max-width: 767px) {
207
203
  .content-body {
208
- column-gap: 0.5rem;
204
+ margin-right: -100vw;
205
+ padding-right: 100vw;
209
206
  }
210
207
  }
211
208
 
@@ -0,0 +1,31 @@
1
+ <template>
2
+ <button type="button" class="VPNavBarAskAiButton">
3
+ <span class="vpi-sparkles" aria-hidden="true"></span>
4
+ </button>
5
+ </template>
6
+
7
+ <style scoped>
8
+ .VPNavBarAskAiButton {
9
+ display: flex;
10
+ align-items: center;
11
+ height: var(--vp-nav-height);
12
+ padding: 8px 14px;
13
+ font-size: 20px;
14
+ }
15
+
16
+ @media (min-width: 768px) {
17
+ .VPNavBarAskAiButton {
18
+ height: auto;
19
+ padding: 11.5px;
20
+ transition: color 0.3s ease;
21
+ background-color: var(--vp-c-bg-alt);
22
+ border-radius: 8px;
23
+ font-size: 15px;
24
+ color: var(--vp-c-text-2);
25
+ }
26
+
27
+ .VPNavBarAskAiButton:hover {
28
+ color: var(--vp-c-brand-1);
29
+ }
30
+ }
31
+ </style>