Skip to content

Commit 693deb3

Browse files
authored
Merge pull request #53 from buggregator/feat/typesense-search
feat: add Typesense search with custom search modal
2 parents e33a1e6 + c63f6e2 commit 693deb3

File tree

8 files changed

+4250
-1098
lines changed

8 files changed

+4250
-1098
lines changed

docs/.vitepress/config.mts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,80 @@
1+
import { fileURLToPath } from 'node:url'
2+
import { dirname, join } from 'node:path'
13
import {defineConfig} from 'vitepress'
24
import {generateLlms, llmsPlugin} from './llms'
5+
import {TypesenseSearchPlugin} from 'vitepress-plugin-typesense'
6+
7+
const themeDir = dirname(fileURLToPath(import.meta.url)) + '/theme'
8+
9+
// Internal: for indexing at build time (Docker hostname)
10+
const typesenseHost = process.env.TYPESENSE_HOST || 'localhost'
11+
const typesensePort = process.env.TYPESENSE_PORT || '8108'
12+
const typesenseProtocol = process.env.TYPESENSE_PROTOCOL || 'http'
13+
const typesenseAdminKey = process.env.TYPESENSE_API_KEY || 'buggregator-search-key'
14+
15+
// Public: for browser search (accessible from user's browser)
16+
const typesensePublicHost = process.env.TYPESENSE_PUBLIC_HOST || typesenseHost
17+
const typesensePublicPort = process.env.TYPESENSE_PUBLIC_PORT || typesensePort
18+
const typesensePublicProtocol = process.env.TYPESENSE_PUBLIC_PROTOCOL || typesenseProtocol
19+
const typesenseSearchKey = process.env.TYPESENSE_SEARCH_KEY || typesenseAdminKey
20+
const typesenseCollection = process.env.TYPESENSE_COLLECTION || 'buggregator_docs'
21+
const docsHostname = process.env.DOCS_HOSTNAME || 'https://docs.buggregator.dev'
22+
const typesenseIndexingEnabled = process.env.TYPESENSE_INDEXING !== 'false'
323

424
// https://vitepress.dev/reference/site-config
525
export default defineConfig({
626
ignoreDeadLinks: true,
727
title: "Buggregator docs",
828
description: "Buggregator docs",
929
vite: {
10-
plugins: [llmsPlugin()],
30+
define: {
31+
'process.env': {},
32+
},
33+
plugins: [
34+
llmsPlugin(),
35+
TypesenseSearchPlugin({
36+
typesenseCollectionName: typesenseCollection,
37+
typesenseServerConfig: {
38+
apiKey: typesenseSearchKey,
39+
nodes: [{
40+
host: typesensePublicHost,
41+
port: typesensePublicPort,
42+
protocol: typesensePublicProtocol,
43+
}],
44+
},
45+
indexing: {
46+
enabled: typesenseIndexingEnabled,
47+
hostname: docsHostname,
48+
typesenseServerConfig: {
49+
apiKey: typesenseAdminKey,
50+
nodes: [{
51+
host: typesenseHost,
52+
port: typesensePort,
53+
protocol: typesenseProtocol,
54+
}],
55+
},
56+
},
57+
}),
58+
// Override the plugin's Search.vue with our custom DocSearch component
59+
{
60+
name: 'custom-search-override',
61+
config() {
62+
return {
63+
resolve: {
64+
alias: {
65+
'./VPNavBarSearch.vue': join(themeDir, 'DocSearch.vue'),
66+
'./VPNavBarSearchButton.vue': join(themeDir, 'EmptySearch.vue'),
67+
},
68+
},
69+
}
70+
},
71+
},
72+
],
1173
},
1274
buildEnd: async (config) => {
1375
await generateLlms(config)
1476
},
1577
themeConfig: {
16-
// https://vitepress.dev/reference/default-theme-config
17-
search: {
18-
provider: 'local'
19-
},
20-
2178
nav: [
2279
{text: 'Docs', link: '/'},
2380
],
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
<script setup lang="ts">
2+
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
3+
// @ts-ignore — virtual module from vitepress-plugin-typesense
4+
import getConfig from 'virtual:typesense-config'
5+
6+
const isOpen = ref(false)
7+
const query = ref('')
8+
const results = ref<SearchHit[]>([])
9+
const isLoading = ref(false)
10+
const selectedIndex = ref(0)
11+
const inputRef = ref<HTMLInputElement | null>(null)
12+
const resultsRef = ref<HTMLElement | null>(null)
13+
14+
interface SearchHit {
15+
url: string
16+
breadcrumbs: string[]
17+
contentHighlight: string
18+
}
19+
20+
let debounceTimer: ReturnType<typeof setTimeout> | null = null
21+
22+
const cfg = computed(() => (typeof getConfig === 'function' ? getConfig() : getConfig))
23+
24+
const typesenseUrl = computed(() => {
25+
const node = cfg.value.typesenseServerConfig?.nodes?.[0]
26+
if (!node) return ''
27+
const port = (node.protocol === 'https' && node.port === '443') || (node.protocol === 'http' && node.port === '80') ? '' : `:${node.port}`
28+
return `${node.protocol}://${node.host}${port}`
29+
})
30+
31+
const open = () => { isOpen.value = true; nextTick(() => inputRef.value?.focus()) }
32+
const close = () => { isOpen.value = false; query.value = ''; results.value = []; selectedIndex.value = 0 }
33+
34+
const search = async (q: string) => {
35+
if (!q.trim()) { results.value = []; return }
36+
isLoading.value = true
37+
try {
38+
const params = new URLSearchParams({
39+
q, query_by: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,content',
40+
highlight_full_fields: 'content', highlight_start_tag: '<mark>', highlight_end_tag: '</mark>',
41+
per_page: '20', prioritize_exact_match: 'false',
42+
})
43+
const res = await fetch(
44+
`${typesenseUrl.value}/collections/${cfg.value.typesenseCollectionName}/documents/search?${params}`,
45+
{ headers: { 'X-TYPESENSE-API-KEY': cfg.value.typesenseServerConfig.apiKey } },
46+
)
47+
if (!res.ok) throw new Error(`${res.status}`)
48+
const data = await res.json()
49+
results.value = (data.hits || []).map((hit: any) => {
50+
const doc = hit.document; const hl = hit.highlights || []
51+
const contentHl = hl.find((h: any) => h.field === 'content')
52+
const crumbs: string[] = []
53+
if (doc['hierarchy.lvl1']) crumbs.push(doc['hierarchy.lvl1'])
54+
if (doc['hierarchy.lvl2']) crumbs.push(doc['hierarchy.lvl2'])
55+
if (doc['hierarchy.lvl3']) crumbs.push(doc['hierarchy.lvl3'])
56+
return { url: doc.url || '', breadcrumbs: crumbs, contentHighlight: contentHl?.snippet || '' }
57+
})
58+
selectedIndex.value = 0
59+
} catch (err) { console.error('[Search]', err); results.value = [] }
60+
finally { isLoading.value = false }
61+
}
62+
63+
const onInput = () => { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => search(query.value), 200) }
64+
65+
const onKeydown = (e: KeyboardEvent) => {
66+
if (e.key === 'ArrowDown') { e.preventDefault(); selectedIndex.value = Math.min(selectedIndex.value + 1, results.value.length - 1); scrollTo() }
67+
else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIndex.value = Math.max(selectedIndex.value - 1, 0); scrollTo() }
68+
else if (e.key === 'Enter' && results.value[selectedIndex.value]) { e.preventDefault(); go(results.value[selectedIndex.value].url) }
69+
else if (e.key === 'Escape') close()
70+
}
71+
72+
const scrollTo = () => nextTick(() => resultsRef.value?.querySelector(`[data-index="${selectedIndex.value}"]`)?.scrollIntoView({ block: 'nearest' }))
73+
74+
const go = (url: string) => { close(); window.location.href = url }
75+
76+
const handleGlobalKey = (e: KeyboardEvent) => {
77+
const el = e.target as HTMLElement
78+
if (el.isContentEditable || ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName)) return
79+
if ((e.key?.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) || e.key === '/') { e.preventDefault(); open() }
80+
}
81+
82+
onMounted(() => window.addEventListener('keydown', handleGlobalKey))
83+
onUnmounted(() => window.removeEventListener('keydown', handleGlobalKey))
84+
</script>
85+
86+
<template>
87+
<div class="ts-search-wrapper">
88+
<!-- Trigger -->
89+
<button class="ts-search-btn" aria-label="Search" @click="open">
90+
<svg class="ts-search-btn__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
91+
<circle cx="11" cy="11" r="8" /><path stroke-linecap="round" d="m21 21-4.3-4.3" />
92+
</svg>
93+
<span class="ts-search-btn__text">Search</span>
94+
<span class="ts-search-btn__keys"><kbd>⌘</kbd><kbd>K</kbd></span>
95+
</button>
96+
97+
<!-- Modal -->
98+
<Teleport to="body">
99+
<Transition name="ts-fade">
100+
<div v-if="isOpen" class="ts-overlay" @click.self="close">
101+
<div class="ts-modal">
102+
<div class="ts-header">
103+
<svg class="ts-header__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
104+
<circle cx="11" cy="11" r="8" /><path stroke-linecap="round" d="m21 21-4.3-4.3" />
105+
</svg>
106+
<input ref="inputRef" v-model="query" class="ts-header__input" type="text" placeholder="Search documentation..." autocomplete="off" @input="onInput" @keydown="onKeydown" />
107+
<button class="ts-header__close" @click="close"><kbd>esc</kbd></button>
108+
</div>
109+
<div ref="resultsRef" class="ts-results">
110+
<div v-if="isLoading && !results.length" class="ts-empty">Searching...</div>
111+
<div v-else-if="query && !results.length && !isLoading" class="ts-empty">No results for "{{ query }}"</div>
112+
<div v-else-if="!query" class="ts-empty">Type to search documentation</div>
113+
<a v-for="(hit, i) in results" :key="hit.url + i" :href="hit.url" class="ts-hit" :class="{ 'ts-hit--sel': i === selectedIndex }" :data-index="i" @click.prevent="go(hit.url)" @mouseenter="selectedIndex = i">
114+
<div class="ts-hit__crumbs">
115+
<template v-for="(c, ci) in hit.breadcrumbs" :key="ci">
116+
<span v-if="ci > 0" class="ts-hit__sep">›</span><span>{{ c }}</span>
117+
</template>
118+
</div>
119+
<div v-if="hit.contentHighlight" class="ts-hit__content" v-html="hit.contentHighlight" />
120+
</a>
121+
</div>
122+
<div class="ts-footer">
123+
<span class="ts-footer__hint"><kbd>↑</kbd> <kbd>↓</kbd> navigate</span>
124+
<span class="ts-footer__hint"><kbd>↵</kbd> open</span>
125+
<span class="ts-footer__hint"><kbd>esc</kbd> close</span>
126+
</div>
127+
</div>
128+
</div>
129+
</Transition>
130+
</Teleport>
131+
</div>
132+
</template>
133+
134+
<style>
135+
/* Wrapper — matches VPNavBarSearch layout */
136+
.ts-search-wrapper {
137+
display: flex;
138+
align-items: center;
139+
}
140+
141+
@media (min-width: 768px) {
142+
.ts-search-wrapper {
143+
flex-grow: 1;
144+
padding-left: 24px;
145+
}
146+
}
147+
148+
@media (min-width: 960px) {
149+
.ts-search-wrapper {
150+
padding-left: 32px;
151+
}
152+
}
153+
154+
/* Button */
155+
.ts-search-btn {
156+
display: flex; align-items: center; gap: 6px; padding: 0 10px; height: 36px;
157+
border-radius: 8px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg-alt);
158+
color: var(--vp-c-text-3); font-size: 13px; cursor: pointer; transition: border-color 0.2s;
159+
}
160+
.ts-search-btn:hover { border-color: var(--vp-c-brand-1); }
161+
.ts-search-btn__icon { width: 15px; height: 15px; flex-shrink: 0; }
162+
.ts-search-btn__text { font-size: 13px; }
163+
.ts-search-btn__keys { display: none; }
164+
@media (min-width: 768px) { .ts-search-btn__keys { display: flex; align-items: center; gap: 2px; margin-left: 8px; } }
165+
.ts-search-btn__keys kbd {
166+
font-size: 11px; padding: 1px 4px; border-radius: 3px;
167+
border: 1px solid var(--vp-c-divider); color: var(--vp-c-text-3); font-family: inherit; background: none;
168+
}
169+
170+
/* Overlay */
171+
.ts-overlay {
172+
position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.55);
173+
display: flex; align-items: flex-start; justify-content: center; padding-top: 10vh;
174+
}
175+
.ts-modal {
176+
width: 100%; max-width: 680px; max-height: 75vh; margin: 0 16px;
177+
background: var(--vp-c-bg-soft, #1e293b); border: 1px solid var(--vp-c-divider);
178+
border-radius: 12px; display: flex; flex-direction: column;
179+
box-shadow: 0 25px 60px -12px rgba(0,0,0,0.5);
180+
}
181+
182+
/* Header */
183+
.ts-header {
184+
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
185+
border-bottom: 1px solid var(--vp-c-divider); flex-shrink: 0;
186+
}
187+
.ts-header__icon { width: 18px; height: 18px; color: var(--vp-c-brand-1); flex-shrink: 0; }
188+
.ts-header__input { flex: 1; background: none; border: none; outline: none; font-size: 15px; color: var(--vp-c-text-1); font-family: inherit; }
189+
.ts-header__input::placeholder { color: var(--vp-c-text-3); }
190+
.ts-header__close { flex-shrink: 0; cursor: pointer; background: none; border: none; }
191+
.ts-header__close kbd { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--vp-c-bg-alt); border: 1px solid var(--vp-c-divider); color: var(--vp-c-text-3); font-family: inherit; }
192+
193+
/* Results */
194+
.ts-results { flex: 1; overflow-y: auto; padding: 8px; }
195+
.ts-empty { padding: 32px 16px; text-align: center; color: var(--vp-c-text-3); font-size: 14px; }
196+
197+
/* Hit */
198+
.ts-hit {
199+
display: block; padding: 12px 14px; border-radius: 8px; cursor: pointer;
200+
text-decoration: none; color: inherit; border: 1px solid transparent; transition: background 0.1s;
201+
}
202+
.ts-hit:hover, .ts-hit--sel {
203+
background: var(--vp-c-bg-alt); border-color: rgba(99,102,241,0.3);
204+
}
205+
.ts-hit__crumbs {
206+
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
207+
font-size: 13px; font-weight: 500; color: var(--vp-c-text-1); margin-bottom: 4px;
208+
}
209+
.ts-hit__sep { color: var(--vp-c-text-3); font-size: 12px; }
210+
.ts-hit__content {
211+
font-size: 13px; line-height: 1.6; color: var(--vp-c-text-2);
212+
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
213+
}
214+
.ts-hit__content mark {
215+
background: rgba(99,102,241,0.25); color: var(--vp-c-brand-1); padding: 1px 2px; border-radius: 2px;
216+
}
217+
218+
/* Footer */
219+
.ts-footer {
220+
display: flex; align-items: center; gap: 16px; padding: 10px 16px;
221+
border-top: 1px solid var(--vp-c-divider); flex-shrink: 0;
222+
}
223+
.ts-footer__hint { font-size: 12px; color: var(--vp-c-text-3); }
224+
.ts-footer__hint kbd { font-size: 11px; padding: 1px 4px; border-radius: 3px; background: var(--vp-c-bg-alt); border: 1px solid var(--vp-c-divider); color: var(--vp-c-text-3); font-family: inherit; margin-right: 2px; }
225+
226+
/* Transition */
227+
.ts-fade-enter-active { transition: opacity 0.15s ease; }
228+
.ts-fade-leave-active { transition: opacity 0.1s ease; }
229+
.ts-fade-enter-from, .ts-fade-leave-to { opacity: 0; }
230+
</style>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<template>
2+
<div />
3+
</template>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/* DocSearch dark theme for VitePress
2+
Modal renders as portal in <body>, so we target html.dark */
3+
html.dark {
4+
--docsearch-primary-color: var(--vp-c-brand-1, #6366f1);
5+
--docsearch-text-color: #f1f5f9;
6+
--docsearch-container-background: rgba(0, 0, 0, 0.6);
7+
--docsearch-modal-background: #1e293b;
8+
--docsearch-modal-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
9+
--docsearch-searchbox-background: #0f172a;
10+
--docsearch-searchbox-focus-background: #0f172a;
11+
--docsearch-searchbox-shadow: inset 0 0 0 2px var(--vp-c-brand-1, #6366f1);
12+
--docsearch-hit-background: #334155;
13+
--docsearch-hit-color: #e2e8f0;
14+
--docsearch-hit-active-color: #fff;
15+
--docsearch-highlight-color: var(--vp-c-brand-1, #6366f1);
16+
--docsearch-muted-color: #94a3b8;
17+
--docsearch-logo-color: #94a3b8;
18+
--docsearch-footer-background: #1e293b;
19+
--docsearch-footer-shadow: 0 -1px 0 #334155;
20+
--docsearch-key-gradient: linear-gradient(-26.5deg, #334155 0%, #475569 100%);
21+
--docsearch-key-shadow: none;
22+
--docsearch-icon-color: #94a3b8;
23+
--docsearch-hit-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
24+
}
25+
26+
/* Also style the navbar search button in dark mode */
27+
html.dark .DocSearch-Button {
28+
background: var(--vp-c-bg-alt);
29+
border-color: var(--vp-c-divider);
30+
color: var(--vp-c-text-2);
31+
}
32+
33+
html.dark .DocSearch-Button:hover {
34+
border-color: var(--vp-c-brand-1, #6366f1);
35+
}
36+
37+
html.dark .DocSearch-Button .DocSearch-Search-Icon {
38+
color: var(--vp-c-text-3);
39+
}

docs/.vitepress/theme/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import DefaultTheme from 'vitepress/theme'
2+
import './docsearch-dark.css'
23
import Layout from './Layout.vue'
34

45
export default {

0 commit comments

Comments
 (0)