@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.
- package/app/assets/icons/feathers.svg +12 -0
- package/app/assets/icons/pinion.svg +3 -0
- package/app/assets/icons/talon.svg +3 -0
- package/app/components/CodePreview.vue +139 -0
- package/app/components/CodeSnippet.vue +119 -0
- package/app/components/Discord.vue +33 -0
- package/app/components/DocsPage.vue +11 -0
- package/app/components/DocsSearch.vue +62 -0
- package/app/components/DocsSearchModal.vue +831 -0
- package/app/components/DocsSidebar.vue +43 -0
- package/app/components/DocsTiles.vue +121 -0
- package/app/components/FooterMain.vue +46 -0
- package/app/components/HeroProduct.vue +148 -0
- package/app/components/MoonSurface.vue +1599 -0
- package/app/components/NewsletterSubscribe.vue +21 -0
- package/app/components/SidebarMenuSection.vue +45 -0
- package/app/components/TableOfContents.vue +221 -0
- package/app/components/ThemeToggle.vue +10 -0
- package/app/components/Titles.vue +39 -0
- package/app/components/TocTree.vue +49 -0
- package/app/components/content/BlueskyEmbed.vue +92 -0
- package/app/components/content/CodeWrapper.vue +10 -0
- package/app/components/content/ProseA.vue +19 -0
- package/app/components/content/ProseAlert.vue +11 -0
- package/app/components/content/ProseBlockquote.vue +11 -0
- package/app/components/content/ProseEm.vue +5 -0
- package/app/components/content/ProseH1.vue +17 -0
- package/app/components/content/ProseH2.vue +17 -0
- package/app/components/content/ProseH3.vue +17 -0
- package/app/components/content/ProseH4.vue +17 -0
- package/app/components/content/ProseH5.vue +17 -0
- package/app/components/content/ProseH6.vue +17 -0
- package/app/components/content/ProseHr.vue +3 -0
- package/app/components/content/ProseImg.vue +37 -0
- package/app/components/content/ProseLi.vue +3 -0
- package/app/components/content/ProseOl.vue +5 -0
- package/app/components/content/ProseP.vue +3 -0
- package/app/components/content/ProsePre.vue +49 -0
- package/app/components/content/ProsePre2.vue +69 -0
- package/app/components/content/ProseScript.vue +37 -0
- package/app/components/content/ProseStrong.vue +5 -0
- package/app/components/content/ProseTable.vue +7 -0
- package/app/components/content/ProseTbody.vue +5 -0
- package/app/components/content/ProseTd.vue +5 -0
- package/app/components/content/ProseTh.vue +5 -0
- package/app/components/content/ProseThead.vue +5 -0
- package/app/components/content/ProseTr.vue +5 -0
- package/app/components/content/ProseUl.vue +5 -0
- package/app/composables/useGlobalSearch.ts +22 -0
- package/app/composables/useTheme.ts +17 -0
- package/app/layouts/default.vue +46 -0
- package/app/layouts/docs.vue +62 -0
- package/app/layouts/page.vue +11 -0
- package/app/pages/help.vue +19 -0
- package/app/pages/privacy.vue +30 -0
- package/app/pages/tos.vue +28 -0
- package/content/pages/privacy.md +152 -0
- package/content/pages/tos.md +133 -0
- package/content/products/1-auth.yaml +13 -0
- package/content/products/2-feathers.yaml +13 -0
- package/content/products/3-pinion.yaml +13 -0
- package/content/products/4-daisyui-kit.yaml +14 -0
- package/content/products/lofi.yaml +14 -0
- package/content.config.ts +36 -0
- package/nuxt.config.ts +102 -0
- package/package.json +33 -0
- package/public/img/bird-comms.png +0 -0
- package/public/img/bird-yellow.svg +125 -0
- package/public/img/logo-auth-white.svg +22 -0
- package/public/img/logos/feathersdev-white.svg +24 -0
- package/public/img/logos/talon-auth-white.svg +19 -0
- package/public/img/planet-yellow.svg +31 -0
- package/public/img/rock-lg.svg +6 -0
- package/public/img/rock-md.svg +6 -0
- package/public/img/top_background.svg +56 -0
- 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>
|