@feathersdev/websites 0.0.1

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 (76) hide show
  1. package/app/assets/icons/feathers.svg +12 -0
  2. package/app/assets/icons/pinion.svg +3 -0
  3. package/app/assets/icons/talon.svg +3 -0
  4. package/app/components/CodePreview.vue +139 -0
  5. package/app/components/CodeSnippet.vue +119 -0
  6. package/app/components/Discord.vue +33 -0
  7. package/app/components/DocsPage.vue +11 -0
  8. package/app/components/DocsSearch.vue +62 -0
  9. package/app/components/DocsSearchModal.vue +831 -0
  10. package/app/components/DocsSidebar.vue +43 -0
  11. package/app/components/DocsTiles.vue +121 -0
  12. package/app/components/FooterMain.vue +46 -0
  13. package/app/components/HeroProduct.vue +148 -0
  14. package/app/components/MoonSurface.vue +1599 -0
  15. package/app/components/NewsletterSubscribe.vue +21 -0
  16. package/app/components/SidebarMenuSection.vue +45 -0
  17. package/app/components/TableOfContents.vue +221 -0
  18. package/app/components/ThemeToggle.vue +10 -0
  19. package/app/components/Titles.vue +39 -0
  20. package/app/components/TocTree.vue +49 -0
  21. package/app/components/content/BlueskyEmbed.vue +92 -0
  22. package/app/components/content/CodeWrapper.vue +10 -0
  23. package/app/components/content/ProseA.vue +19 -0
  24. package/app/components/content/ProseAlert.vue +11 -0
  25. package/app/components/content/ProseBlockquote.vue +11 -0
  26. package/app/components/content/ProseEm.vue +5 -0
  27. package/app/components/content/ProseH1.vue +17 -0
  28. package/app/components/content/ProseH2.vue +17 -0
  29. package/app/components/content/ProseH3.vue +17 -0
  30. package/app/components/content/ProseH4.vue +17 -0
  31. package/app/components/content/ProseH5.vue +17 -0
  32. package/app/components/content/ProseH6.vue +17 -0
  33. package/app/components/content/ProseHr.vue +3 -0
  34. package/app/components/content/ProseImg.vue +37 -0
  35. package/app/components/content/ProseLi.vue +3 -0
  36. package/app/components/content/ProseOl.vue +5 -0
  37. package/app/components/content/ProseP.vue +3 -0
  38. package/app/components/content/ProsePre.vue +49 -0
  39. package/app/components/content/ProsePre2.vue +69 -0
  40. package/app/components/content/ProseScript.vue +37 -0
  41. package/app/components/content/ProseStrong.vue +5 -0
  42. package/app/components/content/ProseTable.vue +7 -0
  43. package/app/components/content/ProseTbody.vue +5 -0
  44. package/app/components/content/ProseTd.vue +5 -0
  45. package/app/components/content/ProseTh.vue +5 -0
  46. package/app/components/content/ProseThead.vue +5 -0
  47. package/app/components/content/ProseTr.vue +5 -0
  48. package/app/components/content/ProseUl.vue +5 -0
  49. package/app/composables/useGlobalSearch.ts +22 -0
  50. package/app/composables/useTheme.ts +17 -0
  51. package/app/layouts/default.vue +46 -0
  52. package/app/layouts/docs.vue +62 -0
  53. package/app/layouts/page.vue +11 -0
  54. package/app/pages/help.vue +19 -0
  55. package/app/pages/privacy.vue +30 -0
  56. package/app/pages/tos.vue +28 -0
  57. package/content/pages/privacy.md +152 -0
  58. package/content/pages/tos.md +133 -0
  59. package/content/products/1-auth.yaml +13 -0
  60. package/content/products/2-feathers.yaml +13 -0
  61. package/content/products/3-pinion.yaml +13 -0
  62. package/content/products/4-daisyui-kit.yaml +14 -0
  63. package/content/products/lofi.yaml +14 -0
  64. package/content.config.ts +36 -0
  65. package/nuxt.config.ts +102 -0
  66. package/package.json +33 -0
  67. package/public/img/bird-comms.png +0 -0
  68. package/public/img/bird-yellow.svg +125 -0
  69. package/public/img/logo-auth-white.svg +22 -0
  70. package/public/img/logos/feathersdev-white.svg +24 -0
  71. package/public/img/logos/talon-auth-white.svg +19 -0
  72. package/public/img/planet-yellow.svg +31 -0
  73. package/public/img/rock-lg.svg +6 -0
  74. package/public/img/rock-md.svg +6 -0
  75. package/public/img/top_background.svg +56 -0
  76. package/vitest.config.ts +7 -0
@@ -0,0 +1,831 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, nextTick } from 'vue'
3
+ import { useDebounceFn } from '@vueuse/core'
4
+
5
+ const props = defineProps<{
6
+ modelValue: boolean
7
+ /** Array of Nuxt Content collection names to search. If not provided, auto-detects from route. */
8
+ collections?: string[]
9
+ /** Display name for the search (e.g., "Feathers Docs"). If not provided, auto-generates. */
10
+ searchLabel?: string
11
+ /** Array of doc paths to show as popular/featured when search is empty */
12
+ popularPaths?: string[]
13
+ }>()
14
+
15
+ const emit = defineEmits<{
16
+ 'update:modelValue': [value: boolean]
17
+ }>()
18
+
19
+ const route = useRoute()
20
+
21
+ // Modal state
22
+ const isOpen = computed({
23
+ get: () => props.modelValue,
24
+ set: (val) => emit('update:modelValue', val),
25
+ })
26
+
27
+ // Search state
28
+ const searchQuery = ref('')
29
+ const searchResults = ref<SearchResult[]>([])
30
+ const isSearching = ref(false)
31
+ const selectedIndex = ref(0)
32
+ const searchInput = ref<HTMLInputElement>()
33
+ const resultsContainer = ref<HTMLElement>()
34
+
35
+ // Filter state
36
+ type FilterType = 'all' | 'heading' | 'code' | 'content'
37
+ const activeFilter = ref<FilterType>('all')
38
+
39
+ const filters: { value: FilterType; label: string; icon: string }[] = [
40
+ { value: 'all', label: 'All', icon: 'heroicons:squares-2x2' },
41
+ { value: 'heading', label: 'Headings', icon: 'heroicons:hashtag' },
42
+ { value: 'code', label: 'Code', icon: 'heroicons:code-bracket' },
43
+ { value: 'content', label: 'Content', icon: 'heroicons:document-text' },
44
+ ]
45
+
46
+ // Popular content
47
+ interface PopularDoc {
48
+ doc: Doc
49
+ collection: string
50
+ }
51
+ const popularDocs = ref<PopularDoc[]>([])
52
+ const isLoadingPopular = ref(false)
53
+
54
+ async function loadPopularDocs() {
55
+ if (popularDocs.value.length > 0) return // Already loaded
56
+
57
+ isLoadingPopular.value = true
58
+ try {
59
+ // If popularPaths provided, load those specific docs
60
+ if (props.popularPaths && props.popularPaths.length > 0) {
61
+ const docs: PopularDoc[] = []
62
+
63
+ // Search for each path across all collections
64
+ for (const path of props.popularPaths) {
65
+ for (const collectionName of docsCollections.value) {
66
+ try {
67
+ const allDocs = (await queryCollection(collectionName).all()) as Doc[]
68
+ const found = allDocs.find((doc) => doc.path === path)
69
+ if (found) {
70
+ docs.push({ doc: found, collection: collectionName })
71
+ break // Found it, move to next path
72
+ }
73
+ } catch {
74
+ // Collection might not exist, continue
75
+ }
76
+ }
77
+ }
78
+
79
+ popularDocs.value = docs
80
+ } else {
81
+ // Fallback: load first 6 docs from all collections
82
+ const allDocs: PopularDoc[] = []
83
+
84
+ for (const collectionName of docsCollections.value) {
85
+ const docs = (await queryCollection(collectionName).all()) as Doc[]
86
+ allDocs.push(...docs.map((doc) => ({ doc, collection: collectionName })))
87
+ }
88
+
89
+ popularDocs.value = allDocs.slice(0, 6)
90
+ }
91
+ } catch (error) {
92
+ console.error('Error loading popular docs:', error)
93
+ } finally {
94
+ isLoadingPopular.value = false
95
+ }
96
+ }
97
+
98
+ // Types
99
+ type MinimarkElement = string | [string, Record<string, unknown> | null, ...MinimarkElement[]]
100
+
101
+ interface DocBody {
102
+ type?: string
103
+ value?: MinimarkElement[]
104
+ }
105
+
106
+ interface Doc {
107
+ path: string
108
+ title: string
109
+ description?: string
110
+ body?: DocBody
111
+ }
112
+
113
+ interface SearchableContent {
114
+ type: 'title' | 'description' | 'heading' | 'code' | 'paragraph'
115
+ text: string
116
+ level?: number
117
+ language?: string
118
+ anchor?: string
119
+ }
120
+
121
+ interface SearchResult {
122
+ doc: Doc
123
+ matches: SearchMatch[]
124
+ score: number
125
+ collection: string
126
+ }
127
+
128
+ interface SearchMatch {
129
+ type: SearchableContent['type']
130
+ text: string
131
+ context: string
132
+ level?: number
133
+ language?: string
134
+ anchor?: string
135
+ }
136
+
137
+ // Determine which docs collections to search based on prop or current route
138
+ const docsCollections = computed(() => {
139
+ if (props.collections && props.collections.length > 0) return props.collections
140
+
141
+ // Auto-detect from route (single collection)
142
+ const productSlug = route.path.split('/')[1]
143
+ if (productSlug === 'feathers') return ['feathersDocs']
144
+ if (productSlug === 'pinion') return ['pinionDocs']
145
+ if (productSlug === 'auth') return ['authDocs']
146
+ if (productSlug === 'lofi') return ['lofiDocs']
147
+ return ['docs'] // default for talon.codes
148
+ })
149
+
150
+ // Map collection names to display labels
151
+ const collectionLabels: Record<string, string> = {
152
+ docs: 'Docs',
153
+ feathersDocs: 'Feathers',
154
+ pinionDocs: 'Pinion',
155
+ authDocs: 'Auth',
156
+ lofiDocs: 'LoFi',
157
+ }
158
+
159
+ function getCollectionLabel(collection: string): string {
160
+ return collectionLabels[collection] || collection
161
+ }
162
+
163
+ const productName = computed(() => {
164
+ if (props.searchLabel) return props.searchLabel
165
+
166
+ // Auto-generate from collections or route
167
+ if (docsCollections.value.length > 1) return 'All Docs'
168
+
169
+ const slug = route.path.split('/')[1]
170
+ if (!slug || slug === 'docs') return 'Docs'
171
+ return slug.charAt(0).toUpperCase() + slug.slice(1)
172
+ })
173
+
174
+ // Extract text from minimark node recursively
175
+ // Minimark format: [tag, props, ...children] or plain string
176
+ function extractTextFromMinimark(node: MinimarkElement): string {
177
+ if (!node) return ''
178
+ if (typeof node === 'string') return node
179
+
180
+ // Minimark array format: [tag, props, ...children]
181
+ if (Array.isArray(node)) {
182
+ const [, , ...children] = node
183
+ // Skip the tag and props, just extract text from children
184
+ return children.map(extractTextFromMinimark).join('')
185
+ }
186
+
187
+ return ''
188
+ }
189
+
190
+ // Parse minimark AST to extract searchable content with categories
191
+ // Minimark format: { type: "minimark", value: [...nodes] }
192
+ // Each node is: [tag, props, ...children] or a string
193
+ function extractSearchableContent(body: DocBody | undefined): SearchableContent[] {
194
+ const content: SearchableContent[] = []
195
+
196
+ function walkNode(node: MinimarkElement, parentTag?: string) {
197
+ if (!node) return
198
+
199
+ // Plain string content
200
+ if (typeof node === 'string') return
201
+
202
+ // Minimark array format: [tag, props, ...children]
203
+ if (Array.isArray(node)) {
204
+ const [tag, props, ...children] = node
205
+ const tagLower = typeof tag === 'string' ? tag.toLowerCase() : ''
206
+ const nodeProps = props as Record<string, string> | null
207
+
208
+ // Headings
209
+ if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagLower)) {
210
+ const level = parseInt(tagLower.charAt(1))
211
+ const text = children.map(extractTextFromMinimark).join('')
212
+ const anchor = nodeProps?.id || ''
213
+ if (text.trim()) {
214
+ content.push({ type: 'heading', text: text.trim(), level, anchor })
215
+ }
216
+ }
217
+ // Code blocks - pre tag has `code` property in props with raw content
218
+ else if (tagLower === 'pre') {
219
+ const codeText = nodeProps?.code || children.map(extractTextFromMinimark).join('')
220
+ const language = nodeProps?.language || ''
221
+ if (codeText.trim()) {
222
+ content.push({ type: 'code', text: codeText.trim(), language })
223
+ }
224
+ // Don't recurse into code blocks
225
+ return
226
+ }
227
+ // Inline code
228
+ else if (tagLower === 'code' && parentTag !== 'pre') {
229
+ const text = children.map(extractTextFromMinimark).join('')
230
+ if (text.trim()) {
231
+ content.push({ type: 'code', text: text.trim() })
232
+ }
233
+ return
234
+ }
235
+ // Paragraphs
236
+ else if (tagLower === 'p') {
237
+ const text = children.map(extractTextFromMinimark).join('')
238
+ if (text.trim()) {
239
+ content.push({ type: 'paragraph', text: text.trim() })
240
+ }
241
+ }
242
+ // List items
243
+ else if (tagLower === 'li') {
244
+ const text = children.map(extractTextFromMinimark).join('')
245
+ if (text.trim()) {
246
+ content.push({ type: 'paragraph', text: text.trim() })
247
+ }
248
+ }
249
+
250
+ // Recurse into children
251
+ children.forEach((child) => walkNode(child, tagLower))
252
+ }
253
+ }
254
+
255
+ // Minimark body structure: { type: "minimark", value: [...] }
256
+ if (body?.type === 'minimark' && Array.isArray(body.value)) {
257
+ body.value.forEach((node) => walkNode(node))
258
+ }
259
+
260
+ return content
261
+ }
262
+
263
+ // Calculate relevance score for a match
264
+ function calculateScore(matchType: SearchableContent['type'], level?: number): number {
265
+ const scores: Record<string, number> = {
266
+ title: 100,
267
+ description: 20,
268
+ heading: level && level <= 2 ? 40 : 30,
269
+ code: 25,
270
+ paragraph: 10,
271
+ }
272
+ return scores[matchType] || 5
273
+ }
274
+
275
+ // Fuzzy match function - returns score (0 = no match, higher = better match)
276
+ // Stricter matching: requires characters to be close together
277
+ function fuzzyMatch(text: string, query: string): { score: number; indices: number[] } {
278
+ const textLower = text.toLowerCase()
279
+ const queryLower = query.toLowerCase()
280
+
281
+ // Exact substring match gets highest score
282
+ if (textLower.includes(queryLower)) {
283
+ const index = textLower.indexOf(queryLower)
284
+ const indices = Array.from({ length: query.length }, (_, i) => index + i)
285
+ return { score: 100 + query.length * 10, indices }
286
+ }
287
+
288
+ // For fuzzy matching, require matches to be close together
289
+ // Max gap between consecutive matched characters
290
+ const maxGap = 8
291
+
292
+ let bestScore = 0
293
+ let bestIndices: number[] = []
294
+
295
+ // Try starting from different positions
296
+ for (let startPos = 0; startPos < textLower.length - query.length; startPos++) {
297
+ let score = 0
298
+ let textIndex = startPos
299
+ let lastMatchIndex = -1
300
+ const indices: number[] = []
301
+ let valid = true
302
+
303
+ for (let i = 0; i < queryLower.length; i++) {
304
+ const char = queryLower[i]
305
+ let found = false
306
+ const searchEnd =
307
+ lastMatchIndex === -1 ? textLower.length : Math.min(textLower.length, lastMatchIndex + maxGap + 1)
308
+
309
+ while (textIndex < searchEnd) {
310
+ if (textLower[textIndex] === char) {
311
+ indices.push(textIndex)
312
+ found = true
313
+
314
+ // Big bonus for consecutive matches
315
+ if (lastMatchIndex !== -1 && textIndex === lastMatchIndex + 1) {
316
+ score += 20
317
+ }
318
+ // Bonus for close matches
319
+ else if (lastMatchIndex !== -1 && textIndex <= lastMatchIndex + 3) {
320
+ score += 8
321
+ }
322
+
323
+ // Bonus for camelCase boundaries
324
+ if (
325
+ textIndex > 0 &&
326
+ text[textIndex - 1] === text[textIndex - 1].toLowerCase() &&
327
+ text[textIndex] === text[textIndex].toUpperCase()
328
+ ) {
329
+ score += 15
330
+ }
331
+ // Bonus for word boundaries
332
+ else if (textIndex === 0 || /[\s_\-.]/.test(text[textIndex - 1])) {
333
+ score += 12
334
+ }
335
+
336
+ score += 1
337
+ lastMatchIndex = textIndex
338
+ textIndex++
339
+ break
340
+ }
341
+ textIndex++
342
+ }
343
+
344
+ if (!found) {
345
+ valid = false
346
+ break
347
+ }
348
+ }
349
+
350
+ if (valid && score > bestScore) {
351
+ bestScore = score
352
+ bestIndices = indices
353
+ }
354
+ }
355
+
356
+ // Require minimum quality - most chars should be consecutive or very close
357
+ const minScore = query.length * 8
358
+ if (bestScore < minScore) {
359
+ return { score: 0, indices: [] }
360
+ }
361
+
362
+ return { score: bestScore, indices: bestIndices }
363
+ }
364
+
365
+ // Escape HTML characters
366
+ function escapeHtml(str: string): string {
367
+ const div = document.createElement('div')
368
+ div.textContent = str
369
+ return div.innerHTML
370
+ }
371
+
372
+ // Highlight matches in text (supports both exact and fuzzy)
373
+ function highlightMatch(text: string, query: string): string {
374
+ if (!query) return escapeHtml(text)
375
+
376
+ const { indices } = fuzzyMatch(text, query)
377
+
378
+ if (indices.length === 0) {
379
+ return escapeHtml(text)
380
+ }
381
+
382
+ // Build highlighted string by marking matched character positions
383
+ let result = ''
384
+ let inHighlight = false
385
+ for (let i = 0; i < text.length; i++) {
386
+ const isMatch = indices.includes(i)
387
+
388
+ if (isMatch && !inHighlight) {
389
+ result += '<mark class="bg-yellow-400/80 text-neutral rounded px-0.5">'
390
+ inHighlight = true
391
+ } else if (!isMatch && inHighlight) {
392
+ result += '</mark>'
393
+ inHighlight = false
394
+ }
395
+
396
+ result += escapeHtml(text[i])
397
+ }
398
+
399
+ if (inHighlight) {
400
+ result += '</mark>'
401
+ }
402
+
403
+ return result
404
+ }
405
+
406
+ // Get context around match
407
+ function getMatchContext(text: string, query: string, contextLength = 60): string {
408
+ const { indices } = fuzzyMatch(text, query)
409
+
410
+ if (indices.length === 0) return text.slice(0, contextLength * 2)
411
+
412
+ // Use the first matched character as the center point
413
+ const firstMatch = indices[0]
414
+ const lastMatch = indices[indices.length - 1]
415
+
416
+ const start = Math.max(0, firstMatch - contextLength)
417
+ const end = Math.min(text.length, lastMatch + contextLength)
418
+
419
+ let context = text.slice(start, end)
420
+ if (start > 0) context = '...' + context
421
+ if (end < text.length) context = context + '...'
422
+
423
+ return context
424
+ }
425
+
426
+ // Debounced search function
427
+ const searchDocs = useDebounceFn(async () => {
428
+ if (!searchQuery.value || searchQuery.value.length < 2) {
429
+ searchResults.value = []
430
+ return
431
+ }
432
+
433
+ isSearching.value = true
434
+ selectedIndex.value = 0
435
+
436
+ try {
437
+ const results: SearchResult[] = []
438
+
439
+ // Search across all configured collections
440
+ for (const collectionName of docsCollections.value) {
441
+ const allDocs = (await queryCollection(collectionName).all()) as Doc[]
442
+
443
+ for (const doc of allDocs) {
444
+ const matches: SearchMatch[] = []
445
+ let totalScore = 0
446
+
447
+ // Check title with fuzzy match
448
+ const titleMatch = doc.title ? fuzzyMatch(doc.title, searchQuery.value) : { score: 0, indices: [] }
449
+ if (titleMatch.score > 0) {
450
+ totalScore += titleMatch.score
451
+ matches.push({
452
+ type: 'title',
453
+ text: doc.title,
454
+ context: doc.title,
455
+ })
456
+ }
457
+
458
+ // Check description with fuzzy match
459
+ const descMatch = doc.description ? fuzzyMatch(doc.description, searchQuery.value) : { score: 0, indices: [] }
460
+ if (descMatch.score > 0) {
461
+ totalScore += descMatch.score * 0.5 // Weight description lower
462
+ matches.push({
463
+ type: 'description',
464
+ text: doc.description,
465
+ context: getMatchContext(doc.description, searchQuery.value),
466
+ })
467
+ }
468
+
469
+ // Parse body AST for categorized content
470
+ if (doc.body) {
471
+ const contentItems = extractSearchableContent(doc.body)
472
+
473
+ for (const item of contentItems) {
474
+ // Apply filter
475
+ if (activeFilter.value !== 'all') {
476
+ if (activeFilter.value === 'heading' && item.type !== 'heading') continue
477
+ if (activeFilter.value === 'code' && item.type !== 'code') continue
478
+ if (activeFilter.value === 'content' && item.type !== 'paragraph') continue
479
+ }
480
+
481
+ const itemMatch = fuzzyMatch(item.text, searchQuery.value)
482
+ if (itemMatch.score > 0) {
483
+ const typeScore = calculateScore(item.type, item.level)
484
+ totalScore += itemMatch.score * (typeScore / 100)
485
+
486
+ matches.push({
487
+ type: item.type,
488
+ text: item.text,
489
+ context: getMatchContext(item.text, searchQuery.value),
490
+ level: item.level,
491
+ language: item.language,
492
+ anchor: item.anchor,
493
+ })
494
+ }
495
+ }
496
+ }
497
+
498
+ // Add bonus for multiple matches
499
+ if (matches.length > 1) {
500
+ totalScore += matches.length * 5
501
+ }
502
+
503
+ if (matches.length > 0) {
504
+ results.push({
505
+ doc,
506
+ matches: matches.slice(0, 5), // Limit matches per doc
507
+ score: totalScore,
508
+ collection: collectionName,
509
+ })
510
+ }
511
+ }
512
+ }
513
+
514
+ // Sort by score descending
515
+ results.sort((a, b) => b.score - a.score)
516
+
517
+ searchResults.value = results.slice(0, 20) // Limit to 20 results
518
+ } catch (error) {
519
+ console.error('Search error:', error)
520
+ searchResults.value = []
521
+ } finally {
522
+ isSearching.value = false
523
+ }
524
+ }, 300)
525
+
526
+ // Watch for search query changes and filter changes
527
+ watch([searchQuery, activeFilter], () => {
528
+ searchDocs()
529
+ })
530
+
531
+ // Focus input when modal opens
532
+ watch(isOpen, (open) => {
533
+ if (open) {
534
+ nextTick(() => {
535
+ searchInput.value?.focus()
536
+ })
537
+ loadPopularDocs()
538
+ } else {
539
+ // Reset state when closing
540
+ searchQuery.value = ''
541
+ searchResults.value = []
542
+ selectedIndex.value = 0
543
+ activeFilter.value = 'all'
544
+ }
545
+ })
546
+
547
+ // Keyboard navigation
548
+ function handleKeydown(event: KeyboardEvent) {
549
+ if (event.key === 'Escape') {
550
+ isOpen.value = false
551
+ return
552
+ }
553
+
554
+ // Cmd/Ctrl + Left/Right to navigate filters
555
+ if ((event.metaKey || event.ctrlKey) && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
556
+ event.preventDefault()
557
+ const currentIndex = filters.findIndex((f) => f.value === activeFilter.value)
558
+ if (event.key === 'ArrowLeft' && currentIndex > 0) {
559
+ activeFilter.value = filters[currentIndex - 1].value
560
+ } else if (event.key === 'ArrowRight' && currentIndex < filters.length - 1) {
561
+ activeFilter.value = filters[currentIndex + 1].value
562
+ }
563
+ return
564
+ }
565
+
566
+ // Determine which list we're navigating (search results or popular docs)
567
+ const isShowingPopular = !searchQuery.value || searchQuery.value.length < 2
568
+ const currentList = isShowingPopular ? popularDocs.value : searchResults.value
569
+
570
+ if (event.key === 'ArrowDown') {
571
+ event.preventDefault()
572
+ if (selectedIndex.value < currentList.length - 1) {
573
+ selectedIndex.value++
574
+ scrollToSelected()
575
+ }
576
+ return
577
+ }
578
+
579
+ if (event.key === 'ArrowUp') {
580
+ event.preventDefault()
581
+ if (selectedIndex.value > 0) {
582
+ selectedIndex.value--
583
+ scrollToSelected()
584
+ }
585
+ return
586
+ }
587
+
588
+ if (event.key === 'Enter' && currentList.length > 0) {
589
+ event.preventDefault()
590
+ event.stopPropagation()
591
+ if (isShowingPopular) {
592
+ navigateToDoc(popularDocs.value[selectedIndex.value].doc.path)
593
+ } else {
594
+ navigateToResult(searchResults.value[selectedIndex.value])
595
+ }
596
+ }
597
+ }
598
+
599
+ function scrollToSelected() {
600
+ nextTick(() => {
601
+ const selected = resultsContainer.value?.querySelector('[data-selected="true"]')
602
+ selected?.scrollIntoView({ block: 'nearest' })
603
+ })
604
+ }
605
+
606
+ async function navigateToResult(result: SearchResult) {
607
+ isOpen.value = false
608
+ await nextTick()
609
+ // If there's a heading match with anchor, navigate to that section
610
+ const headingMatch = result.matches.find((m) => m.type === 'heading' && m.anchor)
611
+ const path = headingMatch?.anchor ? `${result.doc.path}#${headingMatch.anchor}` : result.doc.path
612
+ await navigateTo(path)
613
+ }
614
+
615
+ function closeModal() {
616
+ isOpen.value = false
617
+ }
618
+
619
+ async function navigateToDoc(path: string) {
620
+ isOpen.value = false
621
+ await nextTick()
622
+ await navigateTo(path)
623
+ }
624
+
625
+ // Filter to get only content matches (skip title/description since title is shown separately)
626
+ function getContentMatches(matches: SearchMatch[]): SearchMatch[] {
627
+ return matches.filter((m) => m.type !== 'title' && m.type !== 'description')
628
+ }
629
+
630
+ // Get icon for match type
631
+ function getMatchIcon(match: SearchMatch): string {
632
+ if (match.type === 'heading') {
633
+ // Different icons for different heading levels
634
+ if (match.level === 1) return 'heroicons:bookmark'
635
+ if (match.level === 2) return 'heroicons:hashtag'
636
+ return 'heroicons:minus'
637
+ }
638
+ if (match.type === 'code') return 'heroicons:code-bracket'
639
+ if (match.type === 'paragraph') return 'heroicons:bars-3-bottom-left'
640
+ return 'heroicons:document-text'
641
+ }
642
+ </script>
643
+
644
+ <template>
645
+ <Teleport to="body">
646
+ <Modal v-model="isOpen" @click.self="closeModal">
647
+ <ModalBox class="w-full max-w-2xl max-h-[80vh] flex flex-col p-0">
648
+ <!-- Search Header -->
649
+ <div v-auto-animate class="p-4 border-b border-base-300">
650
+ <div class="relative">
651
+ <Icon
652
+ name="heroicons:magnifying-glass"
653
+ class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-base-content/50"
654
+ />
655
+ <input
656
+ ref="searchInput"
657
+ v-model="searchQuery"
658
+ type="text"
659
+ :placeholder="`Search ${productName} docs...`"
660
+ class="input input-bordered w-full pl-10 pr-10"
661
+ @keydown="handleKeydown"
662
+ />
663
+ <div v-if="isSearching" class="absolute right-3 top-1/2 -translate-y-1/2">
664
+ <LoadingSpinner sm />
665
+ </div>
666
+ <Button
667
+ v-else-if="searchQuery"
668
+ ghost
669
+ xs
670
+ circle
671
+ class="absolute right-3 top-1/2 -translate-y-1/2"
672
+ @click="searchQuery = ''"
673
+ >
674
+ <Icon name="heroicons:x-mark" class="w-4 h-4" />
675
+ </Button>
676
+ </div>
677
+ <!-- Filters -->
678
+ <Flex v-if="searchQuery.length >= 2" class="gap-1 mt-3">
679
+ <Button
680
+ v-for="filter in filters"
681
+ :key="filter.value"
682
+ xs
683
+ :primary="activeFilter === filter.value"
684
+ :ghost="activeFilter !== filter.value"
685
+ class="gap-1"
686
+ @click="activeFilter = filter.value"
687
+ >
688
+ <Icon :name="filter.icon" class="w-3 h-3" />
689
+ {{ filter.label }}
690
+ </Button>
691
+ </Flex>
692
+
693
+ <Flex class="gap-3 mt-2">
694
+ <Text xs class="flex items-center gap-1 text-base-content/50">
695
+ <Kbd xs>↑</Kbd>
696
+ <Kbd xs>↓</Kbd>
697
+ navigate
698
+ </Text>
699
+ <Text xs class="flex items-center gap-1 text-base-content/50">
700
+ <Kbd xs>↵</Kbd>
701
+ select
702
+ </Text>
703
+ <Text v-if="searchQuery.length >= 2" xs class="flex items-center gap-1 text-base-content/50">
704
+ <Kbd xs>⌘</Kbd>
705
+ <Kbd xs>←</Kbd>
706
+ <Kbd xs>→</Kbd>
707
+ filter
708
+ </Text>
709
+ <Text xs class="flex items-center gap-1 text-base-content/50">
710
+ <Kbd xs>esc</Kbd>
711
+ close
712
+ </Text>
713
+ </Flex>
714
+ </div>
715
+
716
+ <!-- Search Results -->
717
+ <div ref="resultsContainer" class="flex-1 overflow-y-auto">
718
+ <!-- Popular content when no search query -->
719
+ <div v-if="!searchQuery || searchQuery.length < 2" class="p-4">
720
+ <Text xs semibold class="text-base-content/50 uppercase tracking-wide mb-3">Popular</Text>
721
+ <div v-if="isLoadingPopular" class="text-center py-4">
722
+ <LoadingSpinner sm />
723
+ </div>
724
+ <ul v-else class="space-y-1">
725
+ <li v-for="(item, index) in popularDocs" :key="item.doc.path" :data-selected="index === selectedIndex">
726
+ <a
727
+ :href="item.doc.path"
728
+ class="flex items-center gap-2 p-2 rounded-lg transition-colors cursor-pointer"
729
+ :class="index === selectedIndex ? 'bg-base-200' : 'hover:bg-base-200/50'"
730
+ @click.prevent="navigateToDoc(item.doc.path)"
731
+ @mouseenter="selectedIndex = index"
732
+ >
733
+ <Icon name="heroicons:document-text" class="w-4 h-4 text-base-content/50 shrink-0" />
734
+ <Text sm class="grow">{{ item.doc.title }}</Text>
735
+ <span
736
+ v-if="docsCollections.length > 1"
737
+ class="text-xs px-1.5 py-0.5 rounded bg-base-300 text-base-content/70"
738
+ >
739
+ {{ getCollectionLabel(item.collection) }}
740
+ </span>
741
+ </a>
742
+ </li>
743
+ </ul>
744
+ </div>
745
+
746
+ <!-- Loading state -->
747
+ <div v-else-if="isSearching && searchResults.length === 0" class="p-8 text-center">
748
+ <LoadingSpinner lg />
749
+ </div>
750
+
751
+ <!-- No results -->
752
+ <Flex
753
+ v-else-if="!isSearching && searchResults.length === 0 && searchQuery.length >= 2"
754
+ col
755
+ class="p-8 text-center text-base-content/50"
756
+ >
757
+ <Icon name="heroicons:face-frown" class="w-12 h-12 mx-auto mb-3 opacity-50" />
758
+ <Text>No results found for "{{ searchQuery }}"</Text>
759
+ </Flex>
760
+
761
+ <!-- Results list -->
762
+ <ul v-else class="py-2">
763
+ <li
764
+ v-for="(result, index) in searchResults"
765
+ :key="result.doc.path"
766
+ :data-selected="index === selectedIndex"
767
+ class="px-2"
768
+ >
769
+ <a
770
+ :href="result.doc.path"
771
+ class="block p-3 rounded-lg cursor-pointer transition-colors"
772
+ :class="index === selectedIndex ? 'bg-base-200' : 'hover:bg-base-200/50'"
773
+ @click.prevent="navigateToResult(result)"
774
+ @mouseenter="selectedIndex = index"
775
+ >
776
+ <!-- Document title -->
777
+ <Flex class="gap-2 mb-1 items-center">
778
+ <Icon
779
+ :name="getMatchIcon(getContentMatches(result.matches)[0] || result.matches[0])"
780
+ class="w-4 h-4 text-base-content/50 shrink-0"
781
+ />
782
+ <Text medium>{{ result.doc.title }}</Text>
783
+ <span
784
+ v-if="docsCollections.length > 1"
785
+ class="text-xs px-1.5 py-0.5 rounded bg-base-300 text-base-content/70"
786
+ >
787
+ {{ getCollectionLabel(result.collection) }}
788
+ </span>
789
+ </Flex>
790
+
791
+ <!-- Match previews (skip title/description, show content matches) -->
792
+ <div class="space-y-1 ml-6">
793
+ <template v-for="(match, mIndex) in getContentMatches(result.matches)" :key="mIndex">
794
+ <div
795
+ v-if="mIndex < 2"
796
+ class="text-sm text-base-content/70 line-clamp-1"
797
+ v-html="highlightMatch(match.context, searchQuery)"
798
+ />
799
+ </template>
800
+ <Text v-if="getContentMatches(result.matches).length > 2" xs class="text-base-content/50">
801
+ +{{ getContentMatches(result.matches).length - 2 }} more matches
802
+ </Text>
803
+ <!-- Show description as fallback if no content matches -->
804
+ <div
805
+ v-if="getContentMatches(result.matches).length === 0 && result.doc.description"
806
+ class="text-sm text-base-content/70 line-clamp-1"
807
+ v-html="highlightMatch(result.doc.description, searchQuery)"
808
+ />
809
+ </div>
810
+
811
+ <!-- Path -->
812
+ <Text xs class="text-base-content/40 mt-2 ml-6">
813
+ {{ result.doc.path }}
814
+ </Text>
815
+ </a>
816
+ </li>
817
+ </ul>
818
+ </div>
819
+ </ModalBox>
820
+ </Modal>
821
+ </Teleport>
822
+ </template>
823
+
824
+ <style scoped>
825
+ .line-clamp-1 {
826
+ display: -webkit-box;
827
+ -webkit-line-clamp: 1;
828
+ -webkit-box-orient: vertical;
829
+ overflow: hidden;
830
+ }
831
+ </style>