@voidzero-dev/vitepress-theme 4.4.1 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidzero-dev/vitepress-theme",
3
- "version": "4.4.1",
3
+ "version": "4.5.0",
4
4
  "description": "Shared VitePress theme for VoidZero projects",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -15,12 +15,13 @@
15
15
  "*.css"
16
16
  ],
17
17
  "peerDependencies": {
18
- "vitepress": "^2.0.0-alpha.15",
18
+ "vitepress": "^2.0.0-alpha.16",
19
19
  "vue": "^3.5.0"
20
20
  },
21
21
  "dependencies": {
22
- "@docsearch/css": "^4.3.2",
23
- "@docsearch/js": "^4.3.2",
22
+ "@docsearch/css": "^4.5.4",
23
+ "@docsearch/js": "^4.5.4",
24
+ "@docsearch/sidepanel-js": "^4.5.4",
24
25
  "@rive-app/canvas-lite": "^2.33.3",
25
26
  "@tailwindcss/typography": "^0.5.19",
26
27
  "@tailwindcss/vite": "^4.1.18",
@@ -34,6 +35,7 @@
34
35
  "tailwindcss": "^4.1.18"
35
36
  },
36
37
  "devDependencies": {
38
+ "@types/mark.js": "^8.11.12",
37
39
  "typescript": "^5.9.3"
38
40
  }
39
41
  }
@@ -198,7 +198,7 @@ onUnmounted(() => {
198
198
  class="wrapper px-6 py-5 flex items-center justify-between relative bg-white dark:bg-primary border-b border-stroke dark:border-nickel">
199
199
  <!-- Left side: Logo + Nav -->
200
200
  <div class="flex gap-10 self-stretch">
201
- <a href="/" class="flex items-center -mx-2 px-2">
201
+ <a href="/" class="flex flex-col items-start justify-center -mx-2 px-2">
202
202
  <slot name="nav-bar-title-before" />
203
203
  <img class="h-4 block dark:hidden" :src="logoDark" :alt="logoAlt" />
204
204
  <img class="h-4 hidden dark:block" :src="logoLight" :alt="logoAlt" />
@@ -1,133 +1,238 @@
1
1
  <script setup lang="ts">
2
- /**
3
- * Forked from VitePress default theme with dynamic loading.
4
- *
5
- * Key changes from original:
6
- * 1. @docsearch/css and @docsearch/js are loaded dynamically (not static imports)
7
- * 2. This allows local search users to avoid installing docsearch packages
8
- * 3. Error handling prevents crashes when packages are missing or initialization fails
9
- */
10
- 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'
11
5
  import type { DefaultTheme } from 'vitepress/theme'
12
- import { nextTick, onMounted, ref, watch } from 'vue'
6
+ import { nextTick, onUnmounted, watch } from 'vue'
7
+ import type { DocSearchAskAi } from '../../types/docsearch'
13
8
  import { useData } from '@vp-composables/data'
14
- import { loadDocSearchCSS, loadDocSearchJS } from '@vp-support/docsearch-loader'
9
+ import { resolveMode, validateCredentials } from '@vp-support/docsearch'
10
+
11
+ import '../../styles/vitepress-default/docsearch.css'
15
12
 
16
13
  const props = defineProps<{
17
- algolia: DefaultTheme.AlgoliaSearchOptions
14
+ algoliaOptions: DefaultTheme.AlgoliaSearchOptions
15
+ openRequest?: {
16
+ target: 'search' | 'askAi' | 'toggleAskAi'
17
+ nonce: number
18
+ } | null
18
19
  }>()
19
20
 
20
21
  const router = useRouter()
21
- const { site, localeIndex, lang } = useData()
22
-
23
- // Loading state
24
- const isLoading = ref(true)
25
- const hasError = ref(false)
26
- let docsearchFn: ((props: any) => void) | null = null
27
-
28
- onMounted(async () => {
29
- // Load CSS first
30
- const cssLoaded = await loadDocSearchCSS()
31
- if (!cssLoaded) {
32
- hasError.value = true
33
- isLoading.value = false
34
- return
35
- }
22
+ const { site } = useData()
23
+
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
34
+
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'
65
+ }
66
+ }
67
+ },
68
+ { immediate: true }
69
+ )
70
+
71
+ async function update(options: DefaultTheme.AlgoliaSearchOptions) {
72
+ if (!inBrowser) return
73
+ await nextTick()
36
74
 
37
- // Load JS
38
- docsearchFn = await loadDocSearchJS()
39
- if (!docsearchFn) {
40
- hasError.value = true
41
- isLoading.value = false
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
81
+ })
82
+
83
+ if (!valid) {
84
+ console.warn('[vitepress] Algolia search cannot be initialized: missing appId/apiKey/indexName.')
42
85
  return
43
86
  }
44
87
 
45
- isLoading.value = false
46
- update()
47
- })
88
+ await initialize({ ...options, ...credentials })
89
+ }
48
90
 
49
- watch(localeIndex, () => {
50
- if (!isLoading.value && !hasError.value) {
51
- update()
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
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)
52
126
  }
53
- })
54
127
 
55
- async function update() {
56
- if (!docsearchFn) return
57
-
58
- await nextTick()
59
128
  const options = {
60
- ...props.algolia,
61
- ...props.algolia.locales?.[localeIndex.value]
62
- }
63
- const rawFacetFilters = options.searchParameters?.facetFilters ?? []
64
- const facetFilters = [
65
- ...(Array.isArray(rawFacetFilters)
66
- ? rawFacetFilters
67
- : [rawFacetFilters]
68
- ).filter((f) => !f.startsWith('lang:')),
69
- `lang:${lang.value}`
70
- ]
71
-
72
- // Rebuild the askAi prop as an object:
73
- // If the askAi prop is a string, treat it as the assistantId and use
74
- // the default indexName, apiKey and appId from the main options.
75
- // If the askAi prop is an object, spread its explicit values.
76
- const askAiProp = options.askAi
77
- const isAskAiString = typeof askAiProp === 'string'
78
-
79
- const askAi = askAiProp
80
- ? {
81
- indexName: isAskAiString ? options.indexName : askAiProp.indexName,
82
- apiKey: isAskAiString ? options.apiKey : askAiProp.apiKey,
83
- appId: isAskAiString ? options.appId : askAiProp.appId,
84
- assistantId: isAskAiString ? askAiProp : askAiProp.assistantId,
85
- // Re-use the merged facetFilters from the search parameters so that
86
- // Ask AI uses the same language filtering as the regular search.
87
- searchParameters: facetFilters.length ? { facetFilters } : undefined
129
+ ...userOptions,
130
+ container: '#vp-docsearch',
131
+ navigator: {
132
+ navigate(item) {
133
+ router.go(item.itemUrl)
88
134
  }
89
- : undefined
90
-
91
- initialize({
92
- ...options,
93
- searchParameters: {
94
- ...options.searchParameters,
95
- facetFilters
96
135
  },
97
- askAi
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
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()
98
180
  })
99
181
  }
100
182
 
101
- function initialize(userOptions: DefaultTheme.AlgoliaSearchOptions) {
102
- if (!docsearchFn) {
103
- console.error('[VitePress Theme] DocSearch not loaded')
104
- return
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
+ }
105
202
  }
203
+ setTimeout(cb, 0)
204
+ }
106
205
 
107
- try {
108
- const options = Object.assign({}, userOptions, {
109
- container: '#docsearch',
110
-
111
- navigator: {
112
- navigate(item: { itemUrl: string }) {
113
- router.go(item.itemUrl)
114
- }
115
- },
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
+ }
116
223
 
117
- transformItems(items: { url: string }[]) {
118
- return items.map((item) => {
119
- return Object.assign({}, item, {
120
- url: getRelativePath(item.url)
121
- })
122
- })
123
- }
124
- })
224
+ function loadDocsearch() {
225
+ if (!docsearchLoader) {
226
+ docsearchLoader = import('@docsearch/js')
227
+ }
228
+ return docsearchLoader
229
+ }
125
230
 
126
- docsearchFn(options as any)
127
- } catch (error) {
128
- console.error('[VitePress Theme] Failed to initialize DocSearch:', error)
129
- hasError.value = true
231
+ function loadSidepanel() {
232
+ if (!sidepanelLoader) {
233
+ sidepanelLoader = import('@docsearch/sidepanel-js')
130
234
  }
235
+ return sidepanelLoader
131
236
  }
132
237
 
133
238
  function getRelativePath(url: string) {
@@ -137,6 +242,6 @@ function getRelativePath(url: string) {
137
242
  </script>
138
243
 
139
244
  <template>
140
- <!-- Render nothing on error - search fails silently -->
141
- <div v-if="!hasError" id="docsearch" />
245
+ <div id="vp-docsearch" />
246
+ <div id="vp-docsearch-sidepanel" />
142
247
  </template>
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import {
8
8
  computedAsync,
9
- debouncedWatch,
9
+ watchDebounced,
10
10
  onKeyStroke,
11
11
  useEventListener,
12
12
  useLocalStorage,
@@ -35,23 +35,7 @@ import { escapeRegExp } from '@vp-support/shared-utils'
35
35
  import { useData } from '@vp-composables/data'
36
36
  import { LRUCache } from '@vp-support/lru'
37
37
  import { createSearchTranslate } from '@vp-support/translation'
38
-
39
- // Modal translations type
40
- interface ModalTranslations {
41
- displayDetails?: string
42
- resetButtonTitle?: string
43
- backButtonTitle?: string
44
- noResultsText?: string
45
- footer?: {
46
- selectText?: string
47
- selectKeyAriaLabel?: string
48
- navigateText?: string
49
- navigateUpKeyAriaLabel?: string
50
- navigateDownKeyAriaLabel?: string
51
- closeText?: string
52
- closeKeyAriaLabel?: string
53
- }
54
- }
38
+ import type { LocalSearchTranslations } from '../../types/local-search'
55
39
 
56
40
  const emit = defineEmits<{
57
41
  (e: 'close'): void
@@ -177,16 +161,6 @@ const disableDetailedView = computed(() => {
177
161
  )
178
162
  })
179
163
 
180
- const buttonText = computed(() => {
181
- const options = theme.value.search?.options ?? theme.value.algolia
182
-
183
- return (
184
- options?.locales?.[localeIndex.value]?.translations?.button?.buttonText ||
185
- options?.translations?.button?.buttonText ||
186
- 'Search'
187
- )
188
- })
189
-
190
164
  watchEffect(() => {
191
165
  if (disableDetailedView.value) {
192
166
  showDetailedList.value = false
@@ -208,7 +182,7 @@ const mark = computedAsync(async () => {
208
182
 
209
183
  const cache = new LRUCache<string, Map<string, string>>(16) // 16 files
210
184
 
211
- debouncedWatch(
185
+ watchDebounced(
212
186
  () => [searchIndex.value, filterText.value, showDetailedList.value] as const,
213
187
  async ([index, filterTextValue, showDetailedListValue], old, onCleanup) => {
214
188
  if (old?.[0] !== index) {
@@ -404,7 +378,10 @@ onKeyStroke('Escape', () => {
404
378
  })
405
379
 
406
380
  // Translations
407
- const defaultTranslations: { modal: ModalTranslations } = {
381
+ const defaultTranslations: LocalSearchTranslations = {
382
+ button: {
383
+ buttonText: 'Search'
384
+ },
408
385
  modal: {
409
386
  displayDetails: 'Display detailed list',
410
387
  resetButtonTitle: 'Reset search',
@@ -424,7 +401,7 @@ const defaultTranslations: { modal: ModalTranslations } = {
424
401
 
425
402
  const translate = createSearchTranslate(defaultTranslations)
426
403
 
427
- // Back
404
+ /* Back */
428
405
 
429
406
  onMounted(() => {
430
407
  // Prevents going to previous site
@@ -437,6 +414,7 @@ useEventListener('popstate', (event) => {
437
414
  })
438
415
 
439
416
  /** Lock body */
417
+
440
418
  const isLocked = useScrollLock(inBrowser ? document.body : null)
441
419
 
442
420
  onMounted(() => {
@@ -514,7 +492,7 @@ function onMouseMove(e: MouseEvent) {
514
492
  @submit.prevent=""
515
493
  >
516
494
  <label
517
- :title="buttonText"
495
+ :title="translate('button.buttonText')"
518
496
  id="localsearch-label"
519
497
  for="localsearch-input"
520
498
  >
@@ -543,7 +521,7 @@ function onMouseMove(e: MouseEvent) {
543
521
  id="localsearch-input"
544
522
  enterkeyhint="go"
545
523
  maxlength="64"
546
- :placeholder="buttonText"
524
+ :placeholder="translate('button.buttonText')"
547
525
  spellcheck="false"
548
526
  type="search"
549
527
  />
@@ -7,6 +7,7 @@ import VPLink from './VPLink.vue'
7
7
 
8
8
  const props = defineProps<{
9
9
  item: T
10
+ rel?: string
10
11
  }>()
11
12
 
12
13
  const { page } = useData()
@@ -34,7 +35,7 @@ defineOptions({ inheritAttrs: false })
34
35
  }"
35
36
  :href
36
37
  :target="item.target"
37
- :rel="item.rel"
38
+ :rel="props.rel ?? item.rel"
38
39
  :no-icon="item.noIcon"
39
40
  >
40
41
  <span v-html="item.text"></span>
@@ -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 '@vp-composables/layout'
5
4
  import VPNavBarSearch from './VPNavBarSearch.vue'
6
5
  import VPNavBarAppearance from './VPNavBarAppearance.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>
@@ -203,12 +203,6 @@ watchPostEffect(() => {
203
203
  }
204
204
  }
205
205
 
206
- @media (max-width: 767px) {
207
- .content-body {
208
- column-gap: 0.5rem;
209
- }
210
- }
211
-
212
206
  .menu + .translations::before,
213
207
  .menu + .appearance::before,
214
208
  .menu + .social-links::before,
@@ -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>
@@ -34,7 +34,13 @@ const hasExtraContent = computed(
34
34
  <p class="trans-title">{{ currentLang.label }}</p>
35
35
 
36
36
  <template v-for="locale in localeLinks" :key="locale.link">
37
- <VPMenuLink :item="locale" :lang="locale.lang" :dir="locale.dir" />
37
+ <VPMenuLink
38
+ :item="locale"
39
+ :lang="locale.lang"
40
+ :hreflang="locale.lang"
41
+ rel="alternate"
42
+ :dir="locale.dir"
43
+ />
38
44
  </template>
39
45
  </div>
40
46