Skip to content
Merged
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions scripts/generate-search-index.ts
Original file line number Diff line number Diff line change
@@ -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);
});
126 changes: 108 additions & 18 deletions src/components/SearchDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,144 @@
"use client";

import { useState } from "react";
import { MessageCircle } from "lucide-react";
import { useEffect, useEffectEvent, useState } from "react";
import {
SearchDialog,
SearchDialogOverlay,
SearchDialogContent,
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<string | undefined>();
const [scope, setScope] = useState<SearchScope | undefined>();
const tags = getSearchGroupsForScope(scope);
const client =
process.env.NODE_ENV === "production"
? createStaticSearchClient({
from: SEARCH_INDEX_PATH,
tags,
})
: fetchClient({
api: buildDocsApiPath("search"),
tag: tags,
});
Comment thread
dcrawbuck marked this conversation as resolved.
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 (
<SearchDialog search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}>
<SearchDialogOverlay />
<SearchDialogContent>
<SearchDialogHeader>
<SearchDialogIcon />
<SearchDialogInput />
<SearchDialogClose />
<button
type="button"
onClick={() => launchSuperchat()}
className="group relative inline-flex items-center gap-2 rounded-full border border-[#76f4f226] bg-[#111828] px-3 py-1.5 text-sm font-medium text-[#76f4f2] shadow-[0_16px_28px_-16px_rgba(0,0,0,0.3),0_0_0_1px_rgba(118,244,242,0.08)] transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.16,1,.3,1)] hover:scale-[1.02] hover:shadow-[0_20px_30px_-14px_rgba(0,0,0,0.3),0_0_20px_-4px_rgba(118,244,242,0.25),0_0_0_1px_rgba(118,244,242,0.15)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#76f4f240] focus-visible:ring-offset-2 focus-visible:ring-offset-fd-background active:scale-[0.98]"
title="Ask AI (Cmd/Ctrl+I)"
aria-label="Ask AI"
>
<MessageCircle className="size-4" strokeWidth={1.8} />
<span>Ask AI</span>
<span className="pointer-events-none absolute left-1/2 top-full z-10 mt-2 inline-flex -translate-x-1/2 translate-y-1 items-center gap-1 opacity-0 transition-all duration-150 group-hover:-translate-x-1/2 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:-translate-x-1/2 group-focus-visible:translate-y-0 group-focus-visible:opacity-100">
<kbd className="inline-flex min-w-5 items-center justify-center rounded border border-fd-border/80 bg-fd-secondary px-1.5 py-0.5 font-mono text-[11px] text-fd-muted-foreground shadow-sm">
</kbd>
<kbd className="inline-flex min-w-5 items-center justify-center rounded border border-fd-border/80 bg-fd-secondary px-1.5 py-0.5 font-mono text-[11px] text-fd-muted-foreground shadow-sm">
I
</kbd>
</span>
</button>
</SearchDialogHeader>
<SearchDialogList items={query.data !== "empty" ? query.data : undefined} />
<SearchDialogFooter>
<TagsList tag={tag} onTagChange={setTag} allowClear>
{tags.map((t) => (
<TagsListItem key={t.value} value={t.value}>
{t.name}
<TagsList
tag={scope}
onTagChange={(value) => setScope(value as SearchScope | undefined)}
allowClear
>
{SEARCH_SCOPE_OPTIONS.map((option) => (
<TagsListItem key={option.value} value={option.value}>
{option.name}
</TagsListItem>
))}
</TagsList>
Expand Down
Loading
Loading