Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ NEXT_PUBLIC_MESH_SDK_KEY=<replace-with-mesh-sdk-key>
NEXT_PUBLIC_UNIFY_SCRIPT_SRC=<replace-with-unify-script-src>
NEXT_PUBLIC_UNIFY_API_KEY=<replace-with-unify-api-key>
NEXT_PUBLIC_RB2B_KEY=<replace-with-rb2b-key>

# Search mode: 'fumadocs' (default, uses Fumadocs built-in search) or 'rag' (uses RAG endpoint at mcp.superwall.com)
SEARCH_MODE=fumadocs
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ node_modules
wrangler.jsonc
wrangler.ci.jsonc
wrangler.local.jsonc
src/lib/title-map.json
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
86 changes: 86 additions & 0 deletions scripts/generate-title-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import fs from 'fs';
import path from 'path';
import { glob } from 'glob';

Comment on lines +1 to +4

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Declare dependency for glob used in title map script

The new title map generator imports glob but package.json still has no glob dependency. Because build:prep now runs tsx scripts/generate-title-map.ts before every build/dev, a fresh install will raise Cannot find module 'glob' under bun/pnpm (which do not allow undeclared transitive deps). Add glob (or switch to the existing fast-glob) to the manifest to avoid a build-prep failure.

Useful? React with 👍 / 👎.

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);
});
175 changes: 174 additions & 1 deletion src/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -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([]);
}
}
Loading