diff --git a/.env.example b/.env.example index e8a655e8..b8cfc8fa 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,6 @@ NEXT_PUBLIC_MESH_SDK_KEY= NEXT_PUBLIC_UNIFY_SCRIPT_SRC= NEXT_PUBLIC_UNIFY_API_KEY= NEXT_PUBLIC_RB2B_KEY= + +# Search mode: 'fumadocs' (default, uses Fumadocs built-in search) or 'rag' (uses RAG endpoint at mcp.superwall.com) +SEARCH_MODE=fumadocs diff --git a/.gitignore b/.gitignore index cab441a0..1d4cfd15 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ node_modules wrangler.jsonc wrangler.ci.jsonc wrangler.local.jsonc +src/lib/title-map.json diff --git a/package.json b/package.json index 45335186..84b9572f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "copy:docs-images": "node scripts/copy-docs-images.cjs", "watch:images": "tsx scripts/watch-docs-images.ts", - "build:prep": "tsx scripts/generate-llm-files.ts && tsx scripts/generate-md-files.ts && bun run copy:docs-images", + "build:prep": "tsx scripts/generate-title-map.ts && tsx scripts/generate-llm-files.ts && tsx scripts/generate-md-files.ts && bun run copy:docs-images", "build": "bun run build:prep && fumadocs-mdx && bun run build:next", "build:next": "next build", "build:cf": "bun run build:prep && opennextjs-cloudflare build", diff --git a/scripts/generate-title-map.ts b/scripts/generate-title-map.ts new file mode 100644 index 00000000..ad150ce5 --- /dev/null +++ b/scripts/generate-title-map.ts @@ -0,0 +1,86 @@ +import fs from 'fs'; +import path from 'path'; +import { glob } from 'glob'; + +interface TitleMap { + [filepath: string]: string; +} + +/** + * Extract title from MDX content + * Priority: frontmatter title > first # heading > filename + */ +function extractTitle(content: string, filepath: string): string { + // Try frontmatter title first + const frontmatterMatch = content.match(/^---\s*\n.*?title:\s*["'](.+?)["']/ms); + if (frontmatterMatch) { + return frontmatterMatch[1]; + } + + // Try first markdown heading + const headingMatch = content.match(/^#\s+(.+)$/m); + if (headingMatch) { + return headingMatch[1].trim(); + } + + // Fallback to filename + const filename = path.basename(filepath, path.extname(filepath)); + return filename + .split(/[-_]/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +/** + * Generate a map of relative file paths to their titles + */ +async function generateTitleMap() { + const contentDir = path.join(process.cwd(), 'content/docs'); + const outputFile = path.join(process.cwd(), 'src/lib/title-map.json'); + + console.log('🔍 Scanning for MDX files in:', contentDir); + + // Find all .mdx and .md files + const files = await glob('**/*.{md,mdx}', { + cwd: contentDir, + absolute: false, + }); + + console.log(`📄 Found ${files.length} markdown files`); + + const titleMap: TitleMap = {}; + + for (const file of files) { + const fullPath = path.join(contentDir, file); + const content = fs.readFileSync(fullPath, 'utf-8'); + const title = extractTitle(content, file); + + // Store with original extension for matching with RAG responses + titleMap[file] = title; + + // Also store without extension (for .md vs .mdx matching) + const withoutExt = file.replace(/\.mdx?$/, '') + '.md'; + if (withoutExt !== file) { + titleMap[withoutExt] = title; + } + } + + console.log(`✅ Generated title map with ${Object.keys(titleMap).length} entries`); + + // Create output directory if it doesn't exist + const outputDir = path.dirname(outputFile); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Write the map to a JSON file + fs.writeFileSync(outputFile, JSON.stringify(titleMap, null, 2)); + + console.log(`💾 Saved title map to: ${outputFile}`); +} + +// Run the script +generateTitleMap().catch(error => { + console.error('❌ Error generating title map:', error); + process.exit(1); +}); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 9ecfc623..6fa657f2 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,4 +1,177 @@ import { source } from "@/lib/source" import { createFromSource } from "fumadocs-core/search/server" +import type { SortedResult } from 'fumadocs-core/server'; +import { NextRequest, NextResponse } from "next/server"; +import titleMap from '@/lib/title-map.json'; -export const { GET } = createFromSource(source) +// Validate and default search mode +const getSearchMode = () => { + const mode = process.env.SEARCH_MODE; + if (mode === 'rag' || mode === 'fumadocs') { + return mode; + } + return 'fumadocs'; // Default to fumadocs for any invalid/undefined value +}; + +const SEARCH_MODE = getSearchMode(); +const RAG_ENDPOINT = 'https://mcp.superwall.com/docs-search'; +const IS_DEV = process.env.NODE_ENV === 'development' || process.env.NEXTJS_ENV === 'development'; + +// Fumadocs search implementation +const fumadocsSearch = createFromSource(source); + +// Helper to get title from title map or fallback to filename +function getTitle(filepath: string): string { + // Try exact match first + if (titleMap[filepath as keyof typeof titleMap]) { + return titleMap[filepath as keyof typeof titleMap]; + } + + // Try with different extension + const withMd = filepath.replace(/\.mdx?$/, '.md'); + if (titleMap[withMd as keyof typeof titleMap]) { + return titleMap[withMd as keyof typeof titleMap]; + } + + const withMdx = filepath.replace(/\.mdx?$/, '.mdx'); + if (titleMap[withMdx as keyof typeof titleMap]) { + return titleMap[withMdx as keyof typeof titleMap]; + } + + // Fallback: convert filename to readable title + const filename = filepath.split('/').pop() || filepath; + const withoutExt = filename.replace(/\.mdx?$/, ''); + const words = withoutExt.split(/[-_]/).map(word => + word.charAt(0).toUpperCase() + word.slice(1) + ); + return words.join(' '); +} + +// Transform RAG response to Fumadocs format +function transformRagResults(ragResponse: any): SortedResult[] { + if (!ragResponse?.content || !Array.isArray(ragResponse.content)) { + return []; + } + + const results: SortedResult[] = []; + + for (const item of ragResponse.content) { + if (item.type !== 'text' || !item.text) continue; + + // Extract filename from "File: path/to/file.md" format + const fileMatch = item.text.match(/^File:\s*([^\n]+)/); + if (!fileMatch) continue; + + const filePath = fileMatch[1].trim(); + + // Remove .md extension and construct URL + let urlPath = filePath.replace(/\.mdx?$/, ''); + + // Strip trailing /index for directory landing pages (e.g., ios/index -> ios) + // Also handle root index (index -> empty string) + if (urlPath.endsWith('/index')) { + urlPath = urlPath.slice(0, -6); // Remove '/index' + } else if (urlPath === 'index') { + urlPath = ''; // Root index becomes empty path + } + + const url = urlPath ? `/docs/${urlPath}` : '/docs'; + + // Extract tag from URL path (first segment after /docs/) + const pathParts = urlPath.split('/'); + const tag = pathParts[0] || ''; + + // Get title from title map + const title = getTitle(filePath); + + if (IS_DEV) { + console.log(`\n[Transform] Processing: ${filePath}`); + console.log(`[Transform] URL: ${url}`); + console.log(`[Transform] Title: "${title}"`); + } + + results.push({ + id: url, + url, + type: 'page', + content: title, + ...(tag && { tag }) + } as SortedResult); + } + + if (IS_DEV) { + console.log(`[Transform] Total results: ${results.length}`); + } + + return results; +} + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const query = searchParams.get('query'); + const sdk = searchParams.get('sdk') || searchParams.get('tag'); + + if (!query) { + return NextResponse.json({ error: 'Missing query parameter' }, { status: 400 }); + } + + const startTime = Date.now(); + + try { + if (SEARCH_MODE === 'rag') { + if (IS_DEV) { + console.log(`search (rag) query: "${query}"${sdk ? ` sdk: "${sdk}"` : ''} received...`); + } + + // Call RAG endpoint with POST (endpoint requires POST, not GET) + const response = await fetch(RAG_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + ...(sdk && { sdk }), + }), + }); + + if (!response.ok) { + console.error('RAG endpoint error:', response.status, response.statusText); + // Fallback to empty results on error + return NextResponse.json([]); + } + + const ragData = await response.json(); + const results = transformRagResults(ragData); + + const duration = ((Date.now() - startTime) / 1000).toFixed(1); + if (IS_DEV) { + const resultTitles = results.slice(0, 3).map(r => `"${r.content.slice(0, 30)}..."`).join(', '); + console.log(`search (rag) query: "${query}" took ${duration}s, results: ${resultTitles}${results.length > 3 ? `, +${results.length - 3} more` : ''}`); + } + + return NextResponse.json(results); + } else { + if (IS_DEV) { + console.log(`search (fumadocs) query: "${query}"${sdk ? ` sdk: "${sdk}"` : ''} received...`); + } + + // Use Fumadocs search + const response = await fumadocsSearch.GET(request); + const data = await response.json(); + + const duration = ((Date.now() - startTime) / 1000).toFixed(1); + if (IS_DEV) { + const results = Array.isArray(data) ? data : []; + const resultTitles = results.slice(0, 3).map((r: any) => `"${r.content?.slice(0, 30) || r.id}..."`).join(', '); + console.log(`search (fumadocs) query: "${query}" took ${duration}s, results: ${resultTitles}${results.length > 3 ? `, +${results.length - 3} more` : ''}`); + } + + return NextResponse.json(data); + } + } catch (error) { + console.error('Search error:', error); + // Return empty results on error instead of failing + return NextResponse.json([]); + } +} diff --git a/src/components/SearchDialog.tsx b/src/components/SearchDialog.tsx index 93523d86..c49823c1 100644 --- a/src/components/SearchDialog.tsx +++ b/src/components/SearchDialog.tsx @@ -10,8 +10,7 @@ import { Sparkles, CornerDownLeft, Text, - Filter, - RotateCcw, + ChevronDown, } from 'lucide-react'; import { type ComponentProps, @@ -20,6 +19,7 @@ import { useEffect, useMemo, useState, + useRef, } from 'react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useI18n } from 'fumadocs-ui/contexts/i18n'; @@ -36,9 +36,12 @@ import type { SortedResult } from 'fumadocs-core/server'; import { cva } from 'class-variance-authority'; import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event'; import { createContext } from 'fumadocs-core/framework'; -import { useRouter, usePathname } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import { useDocsSearch } from 'fumadocs-core/search/client'; +// Search debounce delay in milliseconds - increase to reduce API calls +const SEARCH_DEBOUNCE_MS = 500; + export type SearchLink = [name: string, href: string]; type ReactSortedResult = Omit & { @@ -66,28 +69,148 @@ interface SearchDialogProps extends SharedProps { search: string; onSearchChange: (v: string) => void; isLoading?: boolean; + isDebouncing?: boolean; hideResults?: boolean; results: ReactSortedResult[] | 'empty'; - enabledTags?: string[]; - availableTags?: { id: string; label: string }[]; - onToggleTag?: (tagId: string) => void; footer?: ReactNode; } +const SDK_OPTIONS = [ + { value: '', label: 'None' }, + { value: 'ios', label: 'iOS' }, + { value: 'android', label: 'Android' }, + { value: 'flutter', label: 'Flutter' }, + { value: 'expo', label: 'Expo' }, +] as const; + export function SearchDialogWrapper(props: SharedProps) { - const { search, setSearch, query } = useDocsSearch({ - type: 'fetch', + // Use same localStorage key as AskAI + const [selectedSdk, setSelectedSdk] = useLocalStorage('superwall-ai-selected-sdk', ''); + const [isDebouncing, setIsDebouncing] = useState(false); + + const { search, setSearch, query } = useDocsSearch({ + type: 'fetch', api: '/docs/api/search' - }); - + }, undefined, selectedSdk || undefined, SEARCH_DEBOUNCE_MS); + + // Track debouncing state in development + useEffect(() => { + if (search.length > 0) { + setIsDebouncing(true); + const timer = setTimeout(() => { + setIsDebouncing(false); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + } else { + setIsDebouncing(false); + } + }, [search]); + + // Debug logging in development + useEffect(() => { + if (process.env.NODE_ENV === 'development' && query.data && query.data !== 'empty') { + console.log(`[SearchDialog] Received ${Array.isArray(query.data) ? query.data.length : 0} results for query: "${search}"`); + } + }, [query.data, search]); + return ( - + ); +} + +interface SearchDialogWrapperInnerProps extends SharedProps { + search: string; + onSearchChange: (v: string) => void; + results: ReactSortedResult[] | 'empty'; + isLoading: boolean; + isDebouncing: boolean; + selectedSdk: string; + onSdkChange: (sdk: string) => void; +} + +function SearchDialogWrapperInner(props: SearchDialogWrapperInnerProps) { + const { selectedSdk, onSdkChange, ...dialogProps } = props; + const [showSdkDropdown, setShowSdkDropdown] = useState(false); + const dropdownRef = useRef(null); + + const selectSdk = (sdkValue: string) => { + onSdkChange(sdkValue); + setShowSdkDropdown(false); + }; + + const getSelectedSdk = () => { + const found = SDK_OPTIONS.find(opt => opt.value === selectedSdk); + return found || SDK_OPTIONS[0]; // Default to "None" + }; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setShowSdkDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( + + + + {showSdkDropdown && ( +
+ {SDK_OPTIONS.map((option) => ( + + ))} +
+ )} + + } /> ); } @@ -99,7 +222,6 @@ interface AIPrompt { content: string; } - export function SearchDialog({ open, onOpenChange, @@ -108,88 +230,21 @@ export function SearchDialog({ search: propSearch, onSearchChange: propOnSearchChange, isLoading: propIsLoading, + isDebouncing: propIsDebouncing, results: propResults, -}: SearchDialogProps) { + sdkSelector, +}: SearchDialogProps & { sdkSelector?: ReactNode }) { const { text } = useI18n(); const [active, setActive] = useState(); - const [showFilters, setShowFilters] = useLocalStorage('search-show-filters', false); - const pathname = usePathname(); - - // Tag filter state with local storage (excluding 'general') - const [enabledTags, setEnabledTags] = useLocalStorage('search-enabled-tags', ['ios', 'android', 'flutter', 'expo', 'dashboard']); - - // Track explicitly active tags (vs implicitly active = all enabled) - const [explicitlyActiveTags, setExplicitlyActiveTags] = useLocalStorage('search-explicitly-active-tags', []); - - // Available tags (excluding 'general') - const availableTags = [ - { id: 'ios', label: 'iOS' }, - { id: 'android', label: 'Android' }, - { id: 'flutter', label: 'Flutter' }, - { id: 'expo', label: 'Expo' }, - { id: 'dashboard', label: 'Dashboard' }, - ]; - - // Toggle tag filter with explicit/implicit state tracking - const toggleTag = (tagId: string) => { - setEnabledTags(prev => { - // If all tags are enabled (implicitly active), select only this tag - if (prev.length === availableTags.length && explicitlyActiveTags.length === 0) { - setExplicitlyActiveTags([tagId]); - return [tagId]; - } - // If this tag is explicitly active and it's the only one, reset to all - if (prev.length === 1 && prev.includes(tagId) && explicitlyActiveTags.includes(tagId)) { - setExplicitlyActiveTags([]); - return availableTags.map(tag => tag.id); - } - // Otherwise toggle normally and update explicit state - const newEnabled = prev.includes(tagId) - ? prev.filter(id => id !== tagId) - : [...prev, tagId]; - - // Update explicitly active tags - if (newEnabled.includes(tagId)) { - setExplicitlyActiveTags(prevExplicit => - prevExplicit.includes(tagId) ? prevExplicit : [...prevExplicit, tagId] - ); - } else { - setExplicitlyActiveTags(prevExplicit => prevExplicit.filter(id => id !== tagId)); - } - - return newEnabled; - }); - }; - - // Reset all filters - const resetFilters = () => { - setEnabledTags(availableTags.map(tag => tag.id)); - setExplicitlyActiveTags([]); - }; - - // Check if any filters are explicitly active - const hasExplicitlyActiveFilters = explicitlyActiveTags.length > 0; - - // Check if any filters are active (not all enabled or has explicit filters) - const hasActiveFilters = enabledTags.length < availableTags.length || hasExplicitlyActiveFilters; - - // Extract current SDK root from pathname - recalculate when dialog opens - const currentRoot = useMemo(() => { - if (!open) return 'general'; // Don't calculate when closed - const parts = pathname.split('/').filter(Boolean); - const knownRoots = ['dashboard', 'ios', 'android', 'expo', 'flutter']; - const firstPart = parts[0] || 'general'; - const root = knownRoots.includes(firstPart) ? firstPart : 'general'; - return root; - }, [pathname, open]); // Add default search functionality const { search: defaultSearch, setSearch: defaultSetSearch, query } = useDocsSearch({ type: 'fetch', api: '/docs/api/search' }); - + // Use provided values or defaults const search = propSearch ?? defaultSearch; const onSearchChange = propOnSearchChange ?? defaultSetSearch; const isLoading = propIsLoading ?? query.isLoading; + const isDebouncing = propIsDebouncing ?? false; const results = propResults ?? (query.data ?? 'empty'); // Debounced search loading indicator @@ -213,67 +268,16 @@ export function SearchDialog({ const displayLinks = links.length > 0 ? links : defaultLinks; - // Client-side tagging and filtering - const taggedAndFilteredResults = useMemo(() => { - if (!results || results === 'empty') return results; - - // Add tags to results based on URL - const taggedResults = results.map(item => { - const url = (item as ReactSortedResult).url || ''; - const urlParts = url.split('/').filter(Boolean); - const knownRoots = ['dashboard', 'ios', 'android', 'expo', 'flutter']; - const firstPart = urlParts[0] || 'general'; - const tag = knownRoots.includes(firstPart) ? firstPart : 'general'; - - return { - ...item, - tag - } as ReactSortedResult; - }); - - // Filter by enabled tags - include general when all tags are enabled - const filteredResults = taggedResults.filter(item => { - const itemTag = item.tag || 'general'; - if (itemTag === 'general') { - // Include general only when all non-general tags are enabled (reset state) - return enabledTags.length === availableTags.length && explicitlyActiveTags.length === 0; - } - return enabledTags.includes(itemTag); - }); - - // Sort by priority - const sdkRoots = ['ios', 'android', 'flutter', 'expo']; - - return filteredResults.sort((a, b) => { - const aTag = a.tag || 'general'; - const bTag = b.tag || 'general'; - - // Priority 1: Current root folder - if (aTag === currentRoot && bTag !== currentRoot) return -1; - if (bTag === currentRoot && aTag !== currentRoot) return 1; - - // Priority 2: Other root folders (non-SDK) - const aIsSDK = sdkRoots.includes(aTag); - const bIsSDK = sdkRoots.includes(bTag); - - if (!aIsSDK && bIsSDK) return -1; - if (!bIsSDK && aIsSDK) return 1; - - // Priority 3: Other SDK root folders - return 0; - }); - }, [results, enabledTags, currentRoot]); - // Add AI prompt to the items list const allItems = useMemo(() => { - const items = taggedAndFilteredResults === 'empty' + const items = results === 'empty' ? displayLinks.map(([name, link]) => ({ type: 'page' as const, id: name, content: name, url: link, })) - : taggedAndFilteredResults; + : results; // Show the AI prompt at the top const aiPrompt: AIPrompt = { @@ -288,7 +292,7 @@ export function SearchDialog({ const { push } = useRouter(); const handleAiSearch = () => { if (!search.trim()) return; - + const encodedQuery = encodeURIComponent(search.trim()); onOpenChange(false); // Close search dialog push(`/ai?search=${encodedQuery}`); @@ -303,7 +307,7 @@ export function SearchDialog({ > {text.search}
- + { @@ -321,20 +325,7 @@ export function SearchDialog({ placeholder={text.search} className="w-0 flex-1 bg-transparent py-3 text-base placeholder:text-fd-muted-foreground focus-visible:outline-none" /> - + {sdkSelector}
- - {/* Tag Filters */} - {showFilters && ( - <> -
-
-
-
- {availableTags.map(tag => { - const isEnabled = enabledTags.includes(tag.id); - const isExplicitlyActive = explicitlyActiveTags.includes(tag.id); - const isImplicitlyActive = isEnabled && !hasExplicitlyActiveFilters; - - return ( - - ); - })} -
- -
-
- - )} + {allItems.length > 0 ? ( , - heading: , - page: , +const getIcon = (type: 'text' | 'heading' | 'page', isActive: boolean) => { + const iconClass = type === 'page' && isActive + ? 'size-4 text-[#74F8F0]' + : 'size-4 text-fd-muted-foreground'; + + switch (type) { + case 'text': + return ; + case 'heading': + return ; + case 'page': + return ; + } }; function SearchResults({ @@ -457,7 +410,7 @@ function SearchResults({ if (e.key === 'Enter') { const selected = items.find((item) => item.id === active); - + if (selected) { if (selected.type === 'ai-prompt') { onAiSearch(); @@ -480,7 +433,7 @@ function SearchResults({
@@ -513,8 +466,8 @@ function SearchResults({ } const resultItem = item as ReactSortedResult; - const rootFolder = resultItem.tag || 'general'; - + const rootFolder = resultItem.tag || ''; + // Format root folder name for display const formatRootFolder = (folder: string) => { switch (folder) { @@ -523,8 +476,7 @@ function SearchResults({ case 'flutter': return 'Flutter'; case 'expo': return 'Expo'; case 'dashboard': return 'Dashboard'; - case 'general': return 'General'; - default: return folder.charAt(0).toUpperCase() + folder.slice(1); + default: return folder ? folder.charAt(0).toUpperCase() + folder.slice(1) : ''; } }; @@ -541,18 +493,23 @@ function SearchResults({ className="ms-2 h-full min-h-10 w-px bg-fd-border" /> ) : null} - {icons[item.type]} -
-

{item.content}

-
- {/* Only show tag on page results, not headers or content, and not for general */} - {item.type === 'page' && rootFolder !== 'general' && ( + {getIcon(item.type, active === item.id)} + {/* Show tag badge for SDK results - moved to left */} + {item.type === 'page' && rootFolder && (
- + {formatRootFolder(rootFolder)}
)} +
+

{item.content}

+
{active === item.id && ( )} @@ -563,21 +520,31 @@ function SearchResults({ ); } -function LoadingIndicator({ isLoading }: { isLoading: boolean }) { +function LoadingIndicator({ isLoading, isDebouncing }: { isLoading: boolean; isDebouncing?: boolean }) { + const isDev = process.env.NODE_ENV === 'development'; + const showDebugState = isDev && (isLoading || isDebouncing); + return ( -
- - +
+
+ + +
+ {showDebugState && ( + + {isLoading ? 'Searching...' : isDebouncing ? 'Debouncing...' : ''} + + )}
); } @@ -704,4 +671,4 @@ export const buttonVariants = cva( color: 'default', }, } -); \ No newline at end of file +);