Skip to content

Commit b2c7c9d

Browse files
authored
feat: Documentation search (#6)
1 parent 426e53e commit b2c7c9d

3 files changed

Lines changed: 200 additions & 2 deletions

File tree

app/components/DocsSearch.vue

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
<script setup lang="ts">
2+
import { useDebounceFn } from '@vueuse/core'
3+
4+
const route = useRoute()
5+
const searchQuery = ref('')
6+
const searchResults = ref([])
7+
const isSearching = ref(false)
8+
const showResults = ref(false)
9+
const searchInput = ref<HTMLInputElement>()
10+
11+
// Determine which docs collection to search based on current route
12+
const docsCollection = computed(() => {
13+
const productSlug = route.path.split('/')[1]
14+
if (productSlug === 'feathers') return 'feathersDocs'
15+
if (productSlug === 'pinion') return 'pinionDocs'
16+
if (productSlug === 'auth') return 'authDocs'
17+
if (productSlug === 'lofi') return 'lofiDocs'
18+
return 'feathersDocs' // default
19+
})
20+
21+
const productName = computed(() => {
22+
const slug = route.path.split('/')[1]
23+
return slug.charAt(0).toUpperCase() + slug.slice(1)
24+
})
25+
26+
// Debounced search function
27+
const searchDocs = useDebounceFn(async () => {
28+
if (!searchQuery.value || searchQuery.value.length < 2) {
29+
searchResults.value = []
30+
showResults.value = false
31+
return
32+
}
33+
34+
isSearching.value = true
35+
showResults.value = true
36+
37+
try {
38+
const query = searchQuery.value.toLowerCase()
39+
40+
// Get all docs for the current collection and filter client-side
41+
const allDocs = await queryCollection(docsCollection.value).all()
42+
43+
// Search through title, description, path, and content
44+
const results = allDocs.filter((doc: any) => {
45+
const titleMatch = doc.title?.toLowerCase().includes(query)
46+
const descriptionMatch = doc.description?.toLowerCase().includes(query)
47+
const pathMatch = doc.path?.toLowerCase().includes(query)
48+
const stemMatch = doc.stem?.toLowerCase().includes(query)
49+
50+
// Search through the actual document content
51+
let contentMatch = false
52+
if (doc.body) {
53+
// Convert body to string for searching (body might be structured)
54+
const bodyText = JSON.stringify(doc.body).toLowerCase()
55+
contentMatch = bodyText.includes(query)
56+
}
57+
58+
return titleMatch || descriptionMatch || pathMatch || stemMatch || contentMatch
59+
})
60+
61+
// Sort results by relevance (title matches first, then description, then content)
62+
const sortedResults = results.sort((a: any, b: any) => {
63+
const aTitle = a.title?.toLowerCase().includes(query) ? 3 : 0
64+
const aDescription = a.description?.toLowerCase().includes(query) ? 2 : 0
65+
const aPath = a.path?.toLowerCase().includes(query) ? 1 : 0
66+
const scoreA = aTitle + aDescription + aPath
67+
68+
const bTitle = b.title?.toLowerCase().includes(query) ? 3 : 0
69+
const bDescription = b.description?.toLowerCase().includes(query) ? 2 : 0
70+
const bPath = b.path?.toLowerCase().includes(query) ? 1 : 0
71+
const scoreB = bTitle + bDescription + bPath
72+
73+
return scoreB - scoreA
74+
})
75+
76+
searchResults.value = sortedResults.slice(0, 10) // Limit to 10 results
77+
} catch (error) {
78+
console.error('Search error:', error)
79+
searchResults.value = []
80+
} finally {
81+
isSearching.value = false
82+
}
83+
}, 300)
84+
85+
// Watch for search query changes
86+
watch(searchQuery, () => {
87+
searchDocs()
88+
})
89+
90+
// Handle keyboard navigation
91+
const handleKeydown = (event: KeyboardEvent) => {
92+
if (event.key === 'Escape') {
93+
showResults.value = false
94+
searchInput.value?.blur()
95+
}
96+
}
97+
98+
// Close results when clicking outside
99+
const handleClickOutside = () => {
100+
setTimeout(() => {
101+
showResults.value = false
102+
}, 200)
103+
}
104+
105+
const navigateToResult = (path: string) => {
106+
showResults.value = false
107+
searchQuery.value = ''
108+
navigateTo(path)
109+
}
110+
</script>
111+
112+
<template>
113+
<div class="relative w-full max-w-md">
114+
<div class="relative">
115+
<Input
116+
ref="searchInput"
117+
v-model="searchQuery"
118+
type="text"
119+
placeholder="Search docs..."
120+
class="w-full pr-10"
121+
@keydown="handleKeydown"
122+
@focus="showResults = searchQuery.length >= 2"
123+
@blur="handleClickOutside"
124+
/>
125+
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
126+
<Icon
127+
v-if="!isSearching"
128+
name="heroicons:magnifying-glass"
129+
class="w-5 h-5 text-base-content/50"
130+
/>
131+
<span
132+
v-else
133+
class="loading loading-spinner loading-sm"
134+
/>
135+
</div>
136+
</div>
137+
138+
<!-- Search Results Dropdown -->
139+
<Transition
140+
enter-active-class="transition ease-out duration-100"
141+
enter-from-class="transform opacity-0 scale-95"
142+
enter-to-class="transform opacity-100 scale-100"
143+
leave-active-class="transition ease-in duration-75"
144+
leave-from-class="transform opacity-100 scale-100"
145+
leave-to-class="transform opacity-0 scale-95"
146+
>
147+
<div
148+
v-if="showResults && searchQuery.length >= 2"
149+
class="absolute z-50 w-full mt-2 bg-base-100 rounded-lg shadow-xl border border-base-300 max-h-96 overflow-y-auto"
150+
>
151+
<div v-if="isSearching" class="p-4 text-center text-base-content/70">
152+
<span class="loading loading-spinner loading-md" />
153+
</div>
154+
155+
<div v-else-if="searchResults.length === 0" class="p-4 text-center text-base-content/70">
156+
No results found for "{{ searchQuery }}" in {{ productName }} docs
157+
</div>
158+
159+
<ul v-else class="py-2">
160+
<li
161+
v-for="result in searchResults"
162+
:key="result.path"
163+
class="hover:bg-base-200 transition-colors"
164+
>
165+
<a
166+
:href="result.path"
167+
class="block px-4 py-3 cursor-pointer"
168+
@click.prevent="navigateToResult(result.path)"
169+
>
170+
<div class="font-medium text-base-content">
171+
{{ result.title }}
172+
</div>
173+
<div v-if="result.description" class="text-sm text-base-content/70 mt-1 line-clamp-2">
174+
{{ result.description }}
175+
</div>
176+
<div class="text-xs text-base-content/50 mt-1">
177+
{{ result.path }}
178+
</div>
179+
</a>
180+
</li>
181+
</ul>
182+
</div>
183+
</Transition>
184+
</div>
185+
</template>
186+
187+
<style scoped>
188+
.line-clamp-2 {
189+
display: -webkit-box;
190+
-webkit-line-clamp: 2;
191+
-webkit-box-orient: vertical;
192+
overflow: hidden;
193+
}
194+
</style>

app/components/DocsSidebar.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ const { data } = await useAsyncData(
2828
</Flex>
2929

3030
<Flex col class="pb-12 pl-2">
31+
<div class="px-2 pb-4 pt-2">
32+
<DocsSearch />
33+
</div>
34+
3135
<template v-for="link in data.items" :key="link.path">
3236
<template v-if="link.children">
3337
<SidebarMenuSection :section="link" />

app/layouts/docs.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ const dropdownId = useId()
1414

1515
<DrawerContent name="docs">
1616
<Flex class="sticky top-0 h-16 p-2 md:px-4 z-10 w-full items-center lg:hidden">
17-
<div class="flex-grow">
17+
<div class="flex-grow flex items-center gap-2">
1818
<Button square class="lg:hidden" @click="() => toggleDrawer()">
1919
<span class="sr-only">Open sidebar</span>
2020
<Icon name="heroicons-outline:menu-alt-2" aria-hidden="true" class="w-5 h-5" />
2121
</Button>
2222

23-
<!-- <Search /> -->
23+
<DocsSearch class="flex-grow" />
2424
</div>
2525

2626
<div class="flex items-center ml-2 md:ml-6">

0 commit comments

Comments
 (0)