diff --git a/bun.lock b/bun.lock index 6045910..104868f 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@tanstack/react-start": "1.166.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "flexsearch": "^0.8.212", "fumadocs-core": "16.7.10", "fumadocs-mdx": "14.2.11", "fumadocs-ui": "16.7.10", @@ -45,6 +46,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", + "agentation": "^3.0.2", "next-validate-link": "^1.6.4", "oxfmt": "^0.36.0", "oxlint": "^1.51.0", @@ -690,6 +692,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agentation": ["agentation@3.0.2", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-iGzBxFVTuZEIKzLY6AExSLAQH6i6SwxV4pAu7v7m3X6bInZ7qlZXAwrEqyc4+EfP4gM7z2RXBF6SF4DeH0f2lA=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -938,6 +942,8 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "flexsearch": ["flexsearch@0.8.212", "", {}, "sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw=="], + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], diff --git a/package.json b/package.json index bb447a6..a719127 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,9 @@ "dev": "vite dev", "dev:port": "vite dev --port", "prebuild": "bun run generate:changelog && bun run scripts/copy-docs-images.cjs", - "build": "NODE_OPTIONS=--max-old-space-size=8192 vite build && bun run scripts/generate-static-cache.ts", + "build": "NODE_OPTIONS=--max-old-space-size=8192 vite build && bun run scripts/generate-static-cache.ts && bun run scripts/generate-search-index.ts", "build:cf": "bun run build", - "build:cf:staging": "NODE_OPTIONS=--max-old-space-size=8192 CLOUDFLARE_ENV=staging vite build", + "build:cf:staging": "NODE_OPTIONS=--max-old-space-size=8192 CLOUDFLARE_ENV=staging bun run build", "sync:mixedbread": "mxbai vs sync $MIXEDBREAD_STORE_ID './content/docs' --ci", "preview": "vite preview", "preview:cf": "bun run build:cf && vite preview", @@ -38,6 +38,7 @@ "@tanstack/react-start": "1.166.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "flexsearch": "^0.8.212", "fumadocs-core": "16.7.10", "fumadocs-mdx": "14.2.11", "fumadocs-ui": "16.7.10", @@ -69,6 +70,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", + "agentation": "^3.0.2", "next-validate-link": "^1.6.4", "oxfmt": "^0.36.0", "oxlint": "^1.51.0", diff --git a/scripts/generate-search-index.ts b/scripts/generate-search-index.ts new file mode 100644 index 0000000..4a5c214 --- /dev/null +++ b/scripts/generate-search-index.ts @@ -0,0 +1,61 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createServer } from "vite"; +import react from "@vitejs/plugin-react"; +import mdx from "fumadocs-mdx/vite"; +import tsConfigPaths from "vite-tsconfig-paths"; + +const DIST_CLIENT = path.join(process.cwd(), "dist/client"); + +async function main() { + console.log("Generating static search index…"); + + const server = await createServer({ + configFile: false, + logLevel: "error", + server: { port: 0, host: "127.0.0.1" }, + resolve: { + alias: { "@": path.resolve(process.cwd(), "./src") }, + }, + plugins: [ + mdx(await import("../source.config")), + tsConfigPaths({ projects: ["./tsconfig.json"] }), + react(), + ], + }); + + try { + const { buildDocsSearchDocuments } = await server.ssrLoadModule( + "./src/lib/search", + ); + const { SEARCH_INDEX_FILENAME } = await server.ssrLoadModule( + "./src/lib/search.shared", + ); + const { buildStaticSearchIndex, exportStaticSearchIndex } = await server.ssrLoadModule( + "./src/lib/search-index", + ); + + const outputDir = path.join(DIST_CLIENT, "docs"); + const outputPath = path.join(outputDir, SEARCH_INDEX_FILENAME); + const documents = await buildDocsSearchDocuments(); + + await fs.rm(path.join(outputDir, "assets", "search"), { force: true, recursive: true }); + await fs.mkdir(outputDir, { recursive: true }); + const index = buildStaticSearchIndex(documents); + const payload = JSON.stringify(await exportStaticSearchIndex(index)); + + await fs.writeFile(outputPath, payload); + + const bytes = Buffer.byteLength(payload); + console.log( + ` ✓ search index written to ${path.relative(process.cwd(), outputPath)} (${bytes.toLocaleString()} bytes)`, + ); + } finally { + await server.close(); + } +} + +main().catch((err) => { + console.error("Static search index generation failed:", err); + process.exit(1); +}); diff --git a/src/components/SearchDialog.tsx b/src/components/SearchDialog.tsx index ee214a4..203dab2 100644 --- a/src/components/SearchDialog.tsx +++ b/src/components/SearchDialog.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState } from "react"; +import { MessageCircle } from "lucide-react"; +import { useEffect, useEffectEvent, useState } from "react"; import { SearchDialog, SearchDialogOverlay, @@ -8,32 +9,100 @@ import { SearchDialogHeader, SearchDialogIcon, SearchDialogInput, - SearchDialogClose, SearchDialogList, SearchDialogFooter, TagsList, TagsListItem, } from "fumadocs-ui/components/dialog/search"; import { useDocsSearch } from "fumadocs-core/search/client"; +import { fetchClient } from "fumadocs-core/search/client/fetch"; import type { SharedProps } from "fumadocs-ui/contexts/search"; +import { + getSearchGroupsForScope, + SEARCH_INDEX_PATH, + SEARCH_SCOPE_OPTIONS, + type SearchScope, +} from "@/lib/search.shared"; +import { createStaticSearchClient, preloadStaticSearch } from "@/lib/static-search-client"; +import { closeSuperchat, isSuperchatOpen, openSuperchat } from "@/lib/superchat"; import { buildDocsApiPath } from "@/lib/url-base"; -const tags = [ - { name: "iOS", value: "ios" }, - { name: "Android", value: "android" }, - { name: "Flutter", value: "flutter" }, - { name: "Expo", value: "expo" }, -]; +function scheduleIdle(callback: () => void) { + if (typeof window === "undefined") return () => undefined; + + if ("requestIdleCallback" in window) { + const handle = window.requestIdleCallback(() => callback()); + return () => window.cancelIdleCallback(handle); + } + + const handle = window.setTimeout(callback, 1_200); + return () => window.clearTimeout(handle); +} export function CustomSearchDialog(props: SharedProps) { - const [tag, setTag] = useState(); + const [scope, setScope] = useState(); + const tags = getSearchGroupsForScope(scope); + const client = + process.env.NODE_ENV === "production" + ? createStaticSearchClient({ + from: SEARCH_INDEX_PATH, + tags, + }) + : fetchClient({ + api: buildDocsApiPath("search"), + tag: tags, + }); const { search, setSearch, query } = useDocsSearch({ - type: "fetch", - api: buildDocsApiPath("search"), - delayMs: 500, - tag, + client, + delayMs: 100, + }); + const launchSuperchat = useEffectEvent((message = search) => { + const seededMessage = message.trim(); + if (!openSuperchat(seededMessage)) return; + props.onOpenChange(false); }); + useEffect(() => { + if (process.env.NODE_ENV !== "production") return; + const saveData = Boolean( + ( + navigator as Navigator & { + connection?: { + saveData?: boolean; + }; + } + ).connection?.saveData, + ); + if (saveData) return; + + return scheduleIdle(() => { + void preloadStaticSearch(SEARCH_INDEX_PATH); + }); + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.isComposing) return; + if (!(event.metaKey || event.ctrlKey)) return; + const key = event.key.toLowerCase(); + if (key !== "i" && key !== "k") return; + + if (isSuperchatOpen()) { + event.preventDefault(); + closeSuperchat(); + return; + } + + if (key !== "i") return; + + event.preventDefault(); + launchSuperchat(); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [launchSuperchat]); + return ( @@ -41,14 +110,35 @@ export function CustomSearchDialog(props: SharedProps) { - + - - {tags.map((t) => ( - - {t.name} + setScope(value as SearchScope | undefined)} + allowClear + > + {SEARCH_SCOPE_OPTIONS.map((option) => ( + + {option.name} ))} diff --git a/src/lib/search-index.test.ts b/src/lib/search-index.test.ts new file mode 100644 index 0000000..f294cb7 --- /dev/null +++ b/src/lib/search-index.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { + buildStaticSearchIndex, + exportStaticSearchIndex, + importStaticSearchIndex, + searchStaticSearchIndex, + type StaticSearchDocument, +} from "./search-index"; + +const documents: StaticSearchDocument[] = [ + { + id: "/docs/ios/sdk-reference/PurchaseController", + page_id: "/docs/ios/sdk-reference/PurchaseController", + type: "page", + content: "PurchaseController", + breadcrumbs: ["Docs", "iOS"], + tags: ["ios"], + url: "/docs/ios/sdk-reference/PurchaseController", + }, + { + id: "/docs/ios/sdk-reference/PurchaseController-0", + page_id: "/docs/ios/sdk-reference/PurchaseController", + type: "heading", + content: "PurchaseController", + tags: ["ios"], + url: "/docs/ios/sdk-reference/PurchaseController#purchase-controller", + }, + { + id: "/docs/ios/sdk-reference/PurchaseController-1", + page_id: "/docs/ios/sdk-reference/PurchaseController", + type: "text", + content: "Use PurchaseController to manage purchases.", + tags: ["ios"], + url: "/docs/ios/sdk-reference/PurchaseController#purchase-controller", + }, + { + id: "/docs/android/sdk-reference/PurchaseController", + page_id: "/docs/android/sdk-reference/PurchaseController", + type: "page", + content: "PurchaseController", + breadcrumbs: ["Docs", "Android", "SDK Reference"], + tags: ["android"], + url: "/docs/android/sdk-reference/PurchaseController", + }, + { + id: "/docs/android/sdk-reference/PurchaseController-0", + page_id: "/docs/android/sdk-reference/PurchaseController", + type: "text", + content: "PurchaseController is the main Android purchase API.", + tags: ["android"], + url: "/docs/android/sdk-reference/PurchaseController#overview", + }, + { + id: "/docs/android/changelog", + page_id: "/docs/android/changelog", + type: "page", + content: "Changelog", + breadcrumbs: ["Docs", "Android"], + tags: ["android"], + url: "/docs/android/changelog", + }, + { + id: "/docs/android/changelog-0", + page_id: "/docs/android/changelog", + type: "text", + content: "Added PurchaseController improvements in version 2.0.", + tags: ["android"], + url: "/docs/android/changelog#v2", + }, +]; + +describe("static search index", () => { + test("round-trips exported indexes", async () => { + const index = buildStaticSearchIndex(documents); + const exported = await exportStaticSearchIndex(index); + const restored = await importStaticSearchIndex(exported); + const results = await searchStaticSearchIndex(restored, "purchase controller", "ios"); + + expect(exported.raw).not.toEqual({}); + expect(results.length).toBeGreaterThan(0); + expect(results[0]).toMatchObject({ + id: "/docs/ios/sdk-reference/PurchaseController", + breadcrumbs: ["Docs", "iOS"], + url: "/docs/ios/sdk-reference/PurchaseController", + }); + }); + + test("highlights matched content for exact queries", async () => { + const index = buildStaticSearchIndex(documents); + const results = await searchStaticSearchIndex(index, "purchase controller", "ios"); + + expect(results.length).toBeGreaterThan(0); + expect(results[0]?.content).toContain(""); + }); + + test("boosts exact SDK reference pages above changelog mentions", async () => { + const index = buildStaticSearchIndex(documents); + const results = await searchStaticSearchIndex(index, "purchase controller", "android"); + + expect(results[0]).toMatchObject({ + id: "/docs/android/sdk-reference/PurchaseController", + url: "/docs/android/sdk-reference/PurchaseController", + }); + }); + + test("supports scoped prefix queries under strict tokenization", async () => { + const index = buildStaticSearchIndex(documents); + const results = await searchStaticSearchIndex(index, "purchase con", "ios"); + + expect(results[0]).toMatchObject({ + id: "/docs/ios/sdk-reference/PurchaseController", + url: "/docs/ios/sdk-reference/PurchaseController", + }); + }); +}); diff --git a/src/lib/search-index.ts b/src/lib/search-index.ts new file mode 100644 index 0000000..b711b43 --- /dev/null +++ b/src/lib/search-index.ts @@ -0,0 +1,320 @@ +import Search from "flexsearch"; +import { createContentHighlighter } from "fumadocs-core/search"; + +export type StaticSearchDocument = { + id: string; + breadcrumbs?: string[]; + content: string; + page_id: string; + search_content?: string; + tags: string[]; + type: "page" | "heading" | "text"; + url: string; +}; + +export type StaticSearchExport = { + type: "default"; + raw: Record; +}; + +export type StaticSearchResult = { + id: string; + breadcrumbs?: string[]; + content: string; + score?: number; + type: "page" | "heading" | "text"; + url: string; +}; + +export type StaticSearchIndex = ReturnType; + +function normalizeTag(tag?: string | string[]): string[] { + if (!tag) return []; + return Array.isArray(tag) ? tag : [tag]; +} + +function getPrefixTokens(value: string): string[] { + const prefixes = new Set(); + const terms = tokenizeForMatch(value); + + for (const term of terms) { + for (let length = 3; length < term.length; length += 1) { + prefixes.add(term.slice(0, length)); + } + } + + return [...prefixes]; +} + +function expandSearchContent(document: StaticSearchDocument): string { + const value = document.content; + const normalized = value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_/.-]+/g, " ") + .trim() + .replace(/\s+/g, " "); + + const parts = [value]; + if (normalized && normalized !== value) { + parts.push(normalized); + } + + if (document.type !== "text") { + parts.push(...getPrefixTokens(normalized || value)); + } + + return [...new Set(parts)].join("\n"); +} + +function createSearchDocument() { + return new Search.Document({ + // "strict" keeps the static monolith small enough to ship as a single Cloudflare asset. + tokenize: "strict", + resolution: 1, + document: { + id: "id", + index: ["search_content"], + store: ["id", "page_id", "type", "content", "breadcrumbs", "tags", "url"], + }, + }); +} + +export function buildStaticSearchIndex(documents: StaticSearchDocument[]): StaticSearchIndex { + const index = createSearchDocument(); + for (const document of documents) { + index.add(document.id, { + ...document, + search_content: document.search_content ?? expandSearchContent(document), + }); + } + return index; +} + +function normalizeForMatch(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim() + .replace(/\s+/g, " "); +} + +function normalizeCompact(value: string): string { + return normalizeForMatch(value).replace(/\s+/g, ""); +} + +function tokenizeForMatch(value: string): string[] { + const normalized = normalizeForMatch(value); + return normalized ? normalized.split(" ") : []; +} + +function matchesTag(doc: StaticSearchDocument, tags: string[]): boolean { + return tags.length === 0 || doc.tags.some((tag) => tags.includes(tag)); +} + +function scorePrefixCoverage(queryTerms: string[], candidateTerms: string[]): number { + if (queryTerms.length === 0 || candidateTerms.length === 0) return 0; + if (!queryTerms.every((term) => candidateTerms.some((candidate) => candidate.startsWith(term)))) { + return 0; + } + + return 500; +} + +function scoreUrlMatch(query: string, url: string): number { + const path = url.split("#")[0] ?? url; + const lastSegment = decodeURIComponent(path.split("/").filter(Boolean).at(-1) ?? ""); + const normalizedSegment = normalizeForMatch(lastSegment); + const compactSegment = normalizeCompact(lastSegment); + const normalizedQuery = normalizeForMatch(query); + const compactQuery = normalizeCompact(query); + + if (!normalizedSegment) return 0; + if (compactSegment === compactQuery) return 900; + if (normalizedSegment === normalizedQuery) return 850; + if (compactSegment.startsWith(compactQuery)) return 800; + if (normalizedSegment.startsWith(normalizedQuery)) return 750; + if (compactSegment.includes(compactQuery)) return 500; + if (normalizedSegment.includes(normalizedQuery)) return 450; + return 0; +} + +type GroupedPage = { + items: StaticSearchDocument[]; + page: StaticSearchDocument; + score: number; +}; + +function scoreGroupedPage(query: string, page: StaticSearchDocument, items: StaticSearchDocument[], firstRank: number) { + const normalizedQuery = normalizeForMatch(query); + const compactQuery = normalizeCompact(query); + const queryTerms = tokenizeForMatch(query); + const normalizedTitle = normalizeForMatch(page.content); + const compactTitle = normalizeCompact(page.content); + const titleTerms = tokenizeForMatch(page.content); + + let score = Math.max(0, 800 - firstRank * 6); + + if (compactTitle === compactQuery) { + score += 4_000; + } else if (normalizedTitle === normalizedQuery) { + score += 3_500; + } else { + if (compactTitle.startsWith(compactQuery)) { + score += 2_600; + } else if (normalizedTitle.startsWith(normalizedQuery)) { + score += 2_200; + } else if (compactTitle.includes(compactQuery)) { + score += 1_500; + } else if (normalizedTitle.includes(normalizedQuery)) { + score += 1_200; + } + } + + score += scorePrefixCoverage(queryTerms, titleTerms); + score += scoreUrlMatch(query, page.url); + + const headingMatches = items.filter((item) => item.type === "heading"); + for (const heading of headingMatches) { + const normalizedHeading = normalizeForMatch(heading.content); + const compactHeading = normalizeCompact(heading.content); + const headingTerms = tokenizeForMatch(heading.content); + + if (compactHeading === compactQuery) { + score += 2_200; + continue; + } + + if (normalizedHeading === normalizedQuery) { + score += 1_900; + continue; + } + + if (compactHeading.startsWith(compactQuery)) { + score += 1_100; + } else if (normalizedHeading.startsWith(normalizedQuery)) { + score += 900; + } else if (compactHeading.includes(compactQuery)) { + score += 750; + } else if (normalizedHeading.includes(normalizedQuery)) { + score += 650; + } + score += scorePrefixCoverage(queryTerms, headingTerms); + } + + if (!normalizedQuery.includes("changelog") && page.url.endsWith("/changelog")) { + score -= 400; + } + + if (page.url.includes("/sdk-reference/") && scoreUrlMatch(query, page.url) > 0) { + score += 300; + } + + return score; +} + +export async function exportStaticSearchIndex(index: StaticSearchIndex): Promise { + const raw: StaticSearchExport["raw"] = {}; + const maybePromise = index.export((key, value) => { + raw[key] = value ?? null; + }); + await maybePromise; + return { + type: "default", + raw, + }; +} + +export async function importStaticSearchIndex(data: StaticSearchExport): Promise { + const index = createSearchDocument(); + const imports: Promise[] = []; + for (const [key, value] of Object.entries(data.raw)) { + const maybePromise = index.import(key, value ?? undefined); + if (maybePromise?.then) { + imports.push(maybePromise); + } + } + await Promise.all(imports); + return index; +} + +export async function searchStaticSearchIndex( + index: StaticSearchIndex, + query: string, + tag?: string | string[], + limit = 60, +): Promise { + const tags = normalizeTag(tag); + const rawLimit = tags.length > 0 ? Math.max(limit * 40, 2_000) : Math.max(limit * 8, 500); + const groups = await index.searchAsync(query, { + index: "search_content", + limit: rawLimit, + }); + + if (groups.length === 0) return []; + + const results = groups[0]?.result ?? []; + const highlighter = createContentHighlighter(query); + const grouped = new Map(); + + for (const [rank, id] of results.entries()) { + const doc = index.get(id) as StaticSearchDocument | null; + if (!doc) continue; + if (!matchesTag(doc, tags)) continue; + + const entry = grouped.get(doc.page_id) ?? { + firstRank: rank, + items: [], + }; + + entry.firstRank = Math.min(entry.firstRank, rank); + if (doc.type === "page") { + entry.page = doc; + } else { + entry.items.push(doc); + } + grouped.set(doc.page_id, entry); + } + + const rankedPages: GroupedPage[] = []; + for (const [pageId, entry] of grouped) { + const page = entry.page ?? (index.get(pageId) as StaticSearchDocument | null); + if (!page) continue; + + rankedPages.push({ + items: entry.items, + page, + score: scoreGroupedPage(query, page, entry.items, entry.firstRank), + }); + } + + rankedPages.sort((left, right) => right.score - left.score); + + const output: StaticSearchResult[] = []; + for (const group of rankedPages) { + output.push({ + id: group.page.id, + breadcrumbs: group.page.breadcrumbs, + content: highlighter.highlightMarkdown(group.page.content), + score: group.score, + type: "page", + url: group.page.url, + }); + + for (const [indexWithinPage, item] of group.items.entries()) { + output.push({ + id: item.id, + breadcrumbs: item.breadcrumbs, + content: highlighter.highlightMarkdown(item.content), + score: group.score - (indexWithinPage + 1) * 0.001, + type: item.type, + url: item.url, + }); + } + + if (output.length >= limit) { + break; + } + } + + return output.slice(0, limit); +} diff --git a/src/lib/search.shared.ts b/src/lib/search.shared.ts new file mode 100644 index 0000000..f6ebf57 --- /dev/null +++ b/src/lib/search.shared.ts @@ -0,0 +1,72 @@ +import { buildDocsPath } from "./url-base"; + +export const SEARCH_INDEX_FILENAME = "search-index.json"; +export const SEARCH_INDEX_PATH = buildDocsPath(SEARCH_INDEX_FILENAME); + +const DASHBOARD_SEARCH_GROUP = "dashboard"; +const WEB_CHECKOUT_SEARCH_GROUP = "web-checkout"; +const INTEGRATIONS_SEARCH_GROUP = "integrations"; +const SUPPORT_SEARCH_GROUP = "support"; +const GENERAL_SEARCH_GROUP = "general"; +const COMMUNITY_SEARCH_GROUP = "community"; +const SDK_SEARCH_GROUPS = [ + "ios", + "android", + "flutter", + "expo", + "react-native", +] as const; + +export type SearchScope = (typeof SDK_SEARCH_GROUPS)[number]; +export type SearchCommonGroup = + | typeof DASHBOARD_SEARCH_GROUP + | typeof WEB_CHECKOUT_SEARCH_GROUP + | typeof INTEGRATIONS_SEARCH_GROUP + | typeof SUPPORT_SEARCH_GROUP + | typeof GENERAL_SEARCH_GROUP; +export type SearchGroup = + | SearchCommonGroup + | typeof COMMUNITY_SEARCH_GROUP + | SearchScope; + +export const SEARCH_SCOPE_OPTIONS: Array<{ name: string; value: SearchScope }> = [ + { name: "iOS", value: "ios" }, + { name: "Android", value: "android" }, + { name: "Flutter", value: "flutter" }, + { name: "Expo", value: "expo" }, + { name: "React Native", value: "react-native" }, +]; + +const COMMON_SEARCH_GROUPS: SearchCommonGroup[] = [ + DASHBOARD_SEARCH_GROUP, + WEB_CHECKOUT_SEARCH_GROUP, + INTEGRATIONS_SEARCH_GROUP, + SUPPORT_SEARCH_GROUP, + GENERAL_SEARCH_GROUP, +]; + +const SDK_SEARCH_GROUP_SET = new Set(SDK_SEARCH_GROUPS); +const COMMON_SEARCH_GROUP_SET = new Set(COMMON_SEARCH_GROUPS); + +export function getSearchGroupsForScope(scope?: SearchScope): SearchGroup[] | undefined { + if (!scope) return undefined; + return [scope, ...COMMON_SEARCH_GROUPS]; +} + +export function getSearchGroupFromUrl(url: string): SearchGroup { + const segment = url.replace(/^\/docs\/?/, "").split("/").filter(Boolean)[0]; + + if (segment === COMMUNITY_SEARCH_GROUP) { + return COMMUNITY_SEARCH_GROUP; + } + + if (segment && SDK_SEARCH_GROUP_SET.has(segment)) { + return segment as SearchScope; + } + + if (segment && COMMON_SEARCH_GROUP_SET.has(segment)) { + return segment as SearchCommonGroup; + } + + return GENERAL_SEARCH_GROUP; +} diff --git a/src/lib/search.ts b/src/lib/search.ts new file mode 100644 index 0000000..e338b20 --- /dev/null +++ b/src/lib/search.ts @@ -0,0 +1,151 @@ +import { flexsearchFromSource } from "fumadocs-core/search/flexsearch"; +import { findPath } from "fumadocs-core/page-tree"; +import type { InferPageType } from "fumadocs-core/source"; +import { getSearchGroupFromUrl } from "./search.shared"; +import { source } from "./source"; +import type { StaticSearchDocument } from "./search-index"; + +function getSearchTags(page: InferPageType): string[] { + return [getSearchGroupFromUrl(page.url)]; +} + +async function getStructuredData(page: InferPageType) { + if ("structuredData" in page.data) { + return typeof page.data.structuredData === "function" + ? await page.data.structuredData() + : page.data.structuredData; + } + + if ("load" in page.data && typeof page.data.load === "function") { + return (await page.data.load()).structuredData; + } + + throw new Error(`Cannot build search index for ${page.url}: missing structured data.`); +} + +function isBreadcrumbItem(item: unknown): item is string { + return typeof item === "string" && item.length > 0; +} + +function getBreadcrumbs(page: InferPageType) { + const pageTree = source.getPageTree(page.locale); + const path = findPath(pageTree.children, (node) => node.type === "page" && node.url === page.url); + + if (!path) return undefined; + + const breadcrumbs: string[] = []; + path.pop(); + + if (isBreadcrumbItem(pageTree.name)) { + breadcrumbs.push(pageTree.name); + } + + for (const segment of path) { + if (isBreadcrumbItem(segment.name)) { + breadcrumbs.push(segment.name); + } + } + + return breadcrumbs; +} + +type SearchPageIndex = { + id: string; + breadcrumbs?: string[]; + description?: string; + structuredData: Awaited>; + tag?: string | string[]; + title: string; + url: string; +}; + +function buildSearchDocuments(indexes: SearchPageIndex[]): StaticSearchDocument[] { + const documents: StaticSearchDocument[] = []; + + for (const page of indexes) { + const tags = Array.isArray(page.tag) ? page.tag : page.tag ? [page.tag] : []; + let nextId = 0; + const createId = () => `${page.id}-${nextId++}`; + + documents.push({ + id: page.id, + breadcrumbs: page.breadcrumbs, + content: page.title, + page_id: page.id, + tags, + type: "page", + url: page.url, + }); + + if (page.description) { + documents.push({ + id: createId(), + content: page.description, + page_id: page.id, + tags, + type: "text", + url: page.url, + }); + } + + for (const heading of page.structuredData.headings) { + documents.push({ + id: createId(), + content: heading.content, + page_id: page.id, + tags, + type: "heading", + url: `${page.url}#${heading.id}`, + }); + } + + for (const content of page.structuredData.contents) { + documents.push({ + id: createId(), + content: content.content, + page_id: page.id, + tags, + type: "text", + url: content.heading ? `${page.url}#${content.heading}` : page.url, + }); + } + } + + return documents; +} + +export function createDocsSearchApi() { + return flexsearchFromSource(source, { + async buildIndex(page) { + const fileName = page.path.split("/").at(-1) ?? page.path; + return { + id: page.url, + title: page.data.title ?? fileName.replace(/\.[^.]+$/, ""), + description: page.data.description, + structuredData: await getStructuredData(page), + breadcrumbs: getBreadcrumbs(page), + tag: getSearchTags(page), + url: page.url, + }; + }, + }); +} + +export async function buildDocsSearchDocuments() { + const pages = await Promise.all( + source.getPages().map(async (page) => { + const fileName = page.path.split("/").at(-1) ?? page.path; + return { + id: page.url, + title: page.data.title ?? fileName.replace(/\.[^.]+$/, ""), + description: page.data.description, + structuredData: await getStructuredData(page), + breadcrumbs: getBreadcrumbs(page), + tag: getSearchTags(page), + url: page.url, + } satisfies SearchPageIndex; + }), + ); + + return buildSearchDocuments(pages); +} diff --git a/src/lib/static-search-client.ts b/src/lib/static-search-client.ts new file mode 100644 index 0000000..50f59e4 --- /dev/null +++ b/src/lib/static-search-client.ts @@ -0,0 +1,59 @@ +"use client"; + +import type { SearchClient } from "fumadocs-core/search/client"; +import { + importStaticSearchIndex, + searchStaticSearchIndex, + type StaticSearchIndex, + type StaticSearchExport, +} from "./search-index"; +import type { SearchGroup } from "./search.shared"; + +const indexCache = new Map>(); + +type StaticSearchClientOptions = { + from: string; + tags?: SearchGroup[]; +}; + +async function fetchIndexPayload(from: string): Promise { + const response = await fetch(from, { cache: "no-cache" }); + if (!response.ok) { + throw new Error(`failed to fetch search index from ${from}`); + } + + return response.json() as Promise; +} + +async function loadIndex(from: string) { + let promise = indexCache.get(from); + if (!promise) { + promise = fetchIndexPayload(from) + .then((payload) => importStaticSearchIndex(payload)) + .catch((error) => { + indexCache.delete(from); + throw error; + }); + indexCache.set(from, promise); + } + + return promise; +} + +export async function preloadStaticSearch(from: string): Promise { + await loadIndex(from); +} + +export function createStaticSearchClient(options: StaticSearchClientOptions): SearchClient { + const { from, tags } = options; + + return { + deps: [from, tags?.join(",")], + async search(query) { + if (!query) return []; + + const index = await loadIndex(from); + return searchStaticSearchIndex(index, query, tags); + }, + }; +} diff --git a/src/lib/superchat.ts b/src/lib/superchat.ts new file mode 100644 index 0000000..9c0dfcf --- /dev/null +++ b/src/lib/superchat.ts @@ -0,0 +1,54 @@ +"use client"; + +const SUPERCHAT_ORIGIN = "https://superchat-production-416f.up.railway.app"; +const SUPERCHAT_FRAME_ID = "arona-frame"; +const SUPERCHAT_CLOSED_WIDTH = "74px"; +const SUPERCHAT_CLOSED_HEIGHT = "74px"; + +function getSuperchatFrame(): HTMLIFrameElement | null { + const frame = document.getElementById(SUPERCHAT_FRAME_ID); + return frame instanceof HTMLIFrameElement ? frame : null; +} + +function getSuperchatWindow(): Window | null { + return getSuperchatFrame()?.contentWindow ?? null; +} + +export function isSuperchatOpen(): boolean { + const frame = getSuperchatFrame(); + if (!frame) return false; + + return ( + frame.style.width !== SUPERCHAT_CLOSED_WIDTH || + frame.style.height !== SUPERCHAT_CLOSED_HEIGHT + ); +} + +export function closeSuperchat(): boolean { + const chatWindow = getSuperchatWindow(); + if (!chatWindow) return false; + + chatWindow.postMessage( + { + type: "close", + }, + SUPERCHAT_ORIGIN, + ); + + return true; +} + +export function openSuperchat(message = ""): boolean { + const chatWindow = getSuperchatWindow(); + if (!chatWindow) return false; + + chatWindow.postMessage( + { + type: "ask", + message, + }, + SUPERCHAT_ORIGIN, + ); + + return true; +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 6428ed8..4c36d02 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -10,6 +10,7 @@ import { } from "@tanstack/react-router"; import * as React from "react"; import { useRef, useMemo } from "react"; +import { Agentation } from "agentation"; import appCss from "@/styles/app.css?url"; import { RootProvider as BaseRootProvider } from "fumadocs-ui/provider/base"; import { FrameworkProvider } from "fumadocs-core/framework"; @@ -207,6 +208,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { {children} + {import.meta.env.DEV ? : null} diff --git a/src/routes/api/search.ts b/src/routes/api/search.ts index e6167c4..caf64a1 100644 --- a/src/routes/api/search.ts +++ b/src/routes/api/search.ts @@ -1,17 +1,16 @@ import { createFileRoute } from "@tanstack/react-router"; -import { source } from "@/lib/source"; -import { createFromSource } from "fumadocs-core/search/server"; +import { createDocsSearchApi } from "@/lib/search"; let server: - | ReturnType + | ReturnType | null = null; function getServer() { if (server) return server; - // Building the local Orama index is expensive; keep it off the main dev - // startup path and only initialize it when /api/search is actually hit. - server = createFromSource(source, { language: "english" }); + // In development, the fetch-based search client still hits this route. + // The production client loads a prebuilt FlexSearch index instead. + server = createDocsSearchApi(); return server; }