Merge branch 'canary' into tunis

This commit is contained in:
Vadym Raksha
2026-03-09 16:23:23 +01:00
committed by GitHub
32 changed files with 1918 additions and 574 deletions
+1
View File
@@ -27,3 +27,4 @@ beka
guilhermejansen
iamjasonkendrick
ejirocodes
0-Sandy
+35 -9
View File
@@ -159,10 +159,15 @@ const authClient = createAuthClient({
When you call `auth.api.signInEmail` on the server, and the user has 2FA enabled, it will return an object where `twoFactorRedirect` is set to `true`. This behavior isn’t inferred in TypeScript, which can be misleading. You can check using `in` instead to check if `twoFactorRedirect` is set to `true`.
`authClient.twoFactor.*` handles cookies automatically in the browser. If you continue the 2FA flow with `auth.api.*` on the server, you must pass the relevant headers so Better Auth can read the current 2FA state and set the resulting 2FA/session cookies. The generated examples below use `await headers()` for this, but in other frameworks you should pass the equivalent incoming request headers.
If you chain multiple `auth.api.*` calls in the same server flow, make sure you forward the cookies from the previous auth response into the next call.
```ts title="sign-in.ts"
import { auth } from "@/lib/auth"
const response = await auth.api.signInEmail({
const { headers: responseHeaders, response } = await auth.api.signInEmail({
returnHeaders: true,
body: {
email: "test@test.com",
password: "test",
@@ -170,6 +175,7 @@ const response = await auth.api.signInEmail({
});
if ("twoFactorRedirect" in response) {
// Forward the cookies from responseHeaders into the next auth.api 2FA call.
// Handle the 2FA verification in place
}
```
@@ -252,7 +258,12 @@ By default the issuer for TOTP is set to the app name provided in the auth confi
After the user has entered their 2FA code, you can verify it using `twoFactor.verifyTotp` method. `Better Auth` follows standard practice by accepting TOTP codes from one period before and one after the current code, ensuring users can authenticate even with minor time delays on their end.
<APIMethod path="/two-factor/verify-totp" method="POST">
<APIMethod
path="/two-factor/verify-totp"
method="POST"
requireHeaders
headersComment="Pass the current request headers so Better Auth can read and set the 2FA/session cookies."
>
```ts
type verifyTOTP = {
/**
@@ -292,11 +303,16 @@ export const auth = betterAuth({
#### Sending OTP
Sending an OTP is done by calling the `twoFactor.sendOtp` function. This function will trigger your sendOTP implementation that you provided in the Better Auth configuration.
Sending an OTP is done by calling the `authClient.twoFactor.sendOtp` function on the client or `auth.api.sendTwoFactorOTP` on the server. This function will trigger your sendOTP implementation that you provided in the Better Auth configuration.
<APIMethod path="/two-factor/send-otp" method="POST">
<APIMethod
path="/two-factor/send-otp"
method="POST"
requireHeaders
headersComment="Pass the current request headers so Better Auth can read and set the 2FA/session cookies."
>
```ts
type send2FaOTP = {
type sendTwoFactorOTP = {
/**
* If true, the device will be trusted for 30 days. It'll be refreshed on every sign in request within this time.
*/
@@ -311,11 +327,16 @@ if (data) {
#### Verifying OTP
After the user has entered their OTP code, you can verify it
After the user has entered their OTP code, you can verify it using `authClient.twoFactor.verifyOtp` on the client or `auth.api.verifyTwoFactorOTP` on the server.
<APIMethod path="/two-factor/verify-otp" method="POST">
<APIMethod
path="/two-factor/verify-otp"
method="POST"
requireHeaders
headersComment="Pass the current request headers so Better Auth can read and set the 2FA/session cookies."
>
```ts
type verifyOTP = {
type verifyTwoFactorOTP = {
/**
* The otp code to verify.
*/
@@ -364,7 +385,12 @@ When you generate backup codes, the old backup codes will be deleted and new one
You can now allow users to provide a backup code as an account recovery method.
<APIMethod path="/two-factor/verify-backup-code" method="POST">
<APIMethod
path="/two-factor/verify-backup-code"
method="POST"
requireHeaders
headersComment="Pass the current request headers so Better Auth can read and set the 2FA/session cookies."
>
```ts
type verifyBackupCode = {
/**
+7 -2
View File
@@ -1,8 +1,13 @@
# GitHub API - community stats
GITHUB_TOKEN=
# Inkeep API - docs search & AI chat
# Inkeep API - AI chat
INKEEP_API_KEY=
# Typesense - Docs search
NEXT_PUBLIC_TYPESENSE_SERVER_URL=
NEXT_PUBLIC_TYPESENSE_SEARCH_API_KEY=
TYPESENSE_ADMIN_API_KEY=
# Next.js - public URL
NEXT_PUBLIC_URL=http://localhost:3000
NEXT_PUBLIC_URL=http://localhost:3000
-180
View File
@@ -1,180 +0,0 @@
interface SearchResult {
id: string;
url: string;
type: "page" | "heading" | "text";
content: string;
pageName?: string;
}
interface InkeepDocument {
type: string;
record_type: string;
url: string;
title: string;
source?: {
type: string;
content: { type: string; text?: string }[];
};
}
function toSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.trim();
}
function toRelativePath(fullUrl: string): string {
try {
const parsed = new URL(fullUrl);
return parsed.pathname;
} catch {
return fullUrl;
}
}
function extractHeadings(text: string): { level: number; title: string }[] {
const headings: { level: number; title: string }[] = [];
const regex = /^(#{1,6})\s+(.+)$/gm;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
headings.push({
level: match[1].length,
title: match[2].trim(),
});
}
return headings;
}
function extractMatchingSnippet(text: string, query: string): string | null {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
// Find the first paragraph-like block that contains a query term
const blocks = text.split(/\n{2,}/);
for (const block of blocks) {
const blockLower = block.toLowerCase();
// Skip headings and very short blocks
if (block.startsWith("#") || block.trim().length < 20) continue;
if (terms.some((t) => blockLower.includes(t))) {
// Return a trimmed snippet
const clean = block.replace(/[#*_`>\[\]]/g, "").trim();
if (clean.length > 120) return `${clean.slice(0, 120)}...`;
return clean;
}
}
return null;
}
export async function GET(request: Request) {
const url = new URL(request.url);
const query = url.searchParams.get("query");
if (!query) return Response.json([]);
try {
const response = await fetch("https://api.inkeep.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.INKEEP_API_KEY}`,
},
body: JSON.stringify({
model: "inkeep-rag",
messages: [{ role: "user", content: query }],
response_format: { type: "json_object" },
}),
});
if (!response.ok) {
console.error("Inkeep RAG API error:", response.status);
return Response.json([]);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content;
if (!content) return Response.json([]);
const parsed = JSON.parse(content);
const documents: InkeepDocument[] = parsed.content ?? [];
const results: SearchResult[] = [];
const seenUrls = new Set<string>();
for (const doc of documents) {
const docUrl = toRelativePath(doc.url);
// Add page-level result (dedupe by base URL)
const baseUrl = docUrl.split("#")[0];
if (!seenUrls.has(baseUrl)) {
seenUrls.add(baseUrl);
results.push({
id: baseUrl,
url: baseUrl,
type: "page",
content: doc.title,
});
}
// Extract full text from source content blocks
const fullText = (doc.source?.content ?? [])
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!)
.join("\n\n");
if (!fullText) continue;
// Extract headings and add as section results
const headings = extractHeadings(fullText);
const queryTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
for (const heading of headings) {
const headingLower = heading.title.toLowerCase();
const matches = queryTerms.some((t) => headingLower.includes(t));
if (!matches) continue;
const slug = toSlug(heading.title);
const headingUrl = `${baseUrl}#${slug}`;
if (seenUrls.has(headingUrl)) continue;
seenUrls.add(headingUrl);
results.push({
id: headingUrl,
url: headingUrl,
type: "heading",
content: heading.title,
pageName: doc.title,
});
}
// Add a text snippet match if we find relevant content
const snippet = extractMatchingSnippet(fullText, query);
if (snippet) {
const snippetId = `${baseUrl}:snippet`;
if (!seenUrls.has(snippetId)) {
seenUrls.add(snippetId);
// Link to the closest heading above the snippet, or the page
let bestAnchor = baseUrl;
for (const heading of headings) {
const slug = toSlug(heading.title);
bestAnchor = `${baseUrl}#${slug}`;
}
results.push({
id: snippetId,
url: bestAnchor,
type: "text",
content: snippet,
pageName: doc.title,
});
}
}
}
return Response.json(results.slice(0, 20));
} catch (error) {
console.error("Search error:", error);
return Response.json([]);
}
}
@@ -0,0 +1,7 @@
import { exportSearchIndexes } from "@/lib/export-search-indexes";
export const revalidate = false;
export async function GET() {
return Response.json(await exportSearchIndexes());
}
+11
View File
@@ -0,0 +1,11 @@
import { getRSS } from "@/lib/rss";
export const revalidate = false;
export function GET() {
return new Response(getRSS(), {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
},
});
}
+48 -40
View File
@@ -54,7 +54,7 @@ function ApplyDialog({
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" />
<div className="absolute inset-0 bg-black/40 backdrop-blur-md dark:bg-black/72" />
{/* Dialog */}
<motion.div
@@ -63,19 +63,19 @@ function ApplyDialog({
exit={{ opacity: 0, y: 10, scale: 0.98 }}
transition={{ duration: 0.2, ease: "easeOut" }}
onClick={(e) => e.stopPropagation()}
className="relative w-full max-w-md border border-dashed border-foreground/[0.08] bg-background overflow-hidden"
className="relative w-full max-w-md overflow-hidden border border-foreground/[0.14] bg-background/95 shadow-2xl shadow-black/12 ring-1 ring-black/5 backdrop-blur dark:border-white/[0.08] dark:bg-[#050505]/95 dark:shadow-black/65 dark:ring-white/[0.04]"
>
{/* Corner marks */}
<span className="absolute top-2 left-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
<span className="absolute top-2 left-2 text-[9px] font-mono text-foreground/25 dark:text-foreground/18 select-none leading-none">
+
</span>
<span className="absolute top-2 right-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
<span className="absolute top-2 right-2 text-[9px] font-mono text-foreground/25 dark:text-foreground/18 select-none leading-none">
+
</span>
<span className="absolute bottom-2 left-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
<span className="absolute bottom-2 left-2 text-[9px] font-mono text-foreground/25 dark:text-foreground/18 select-none leading-none">
+
</span>
<span className="absolute bottom-2 right-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
<span className="absolute bottom-2 right-2 text-[9px] font-mono text-foreground/25 dark:text-foreground/18 select-none leading-none">
+
</span>
@@ -84,13 +84,15 @@ function ApplyDialog({
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 text-foreground/30 hover:text-foreground/60 transition-colors"
aria-label="Close dialog"
className="absolute top-4 right-4 text-foreground/50 hover:text-foreground/80 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fill="currentColor"
@@ -106,15 +108,17 @@ function ApplyDialog({
transition={{ duration: 0.3 }}
className="text-center py-6 space-y-3"
>
<p className="text-sm text-foreground/80">Application sent</p>
<p className="text-[11px] text-foreground/40 leading-relaxed max-w-xs mx-auto">
<p className="text-sm text-foreground/92 dark:text-foreground/88">
Application sent
</p>
<p className="text-[11px] text-foreground/60 dark:text-foreground/52 leading-relaxed max-w-xs mx-auto">
Thanks for applying for {role.title}. We&apos;ll review your
application and get back to you soon.
</p>
<button
type="button"
onClick={onClose}
className="mt-4 inline-flex items-center px-4 py-2 border border-foreground/[0.12] text-foreground/40 hover:text-foreground/70 hover:border-foreground/25 transition-all font-mono text-[10px] uppercase tracking-widest"
className="mt-4 inline-flex items-center px-4 py-2 border border-foreground/[0.18] bg-foreground/[0.03] text-foreground/72 hover:text-foreground/90 hover:border-foreground/28 hover:bg-foreground/[0.06] transition-all font-mono text-[10px] uppercase tracking-widest dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/68 dark:hover:text-foreground/84 dark:hover:bg-white/[0.05]"
>
Close
</button>
@@ -122,15 +126,17 @@ function ApplyDialog({
) : (
<>
<div className="mb-6 space-y-1">
<p className="text-[10px] uppercase tracking-widest text-foreground/30 font-mono">
<p className="text-[10px] uppercase tracking-widest text-foreground/55 dark:text-foreground/50 font-mono">
# Apply
</p>
<h3 className="text-sm text-foreground/80">{role.title}</h3>
<h3 className="text-sm text-foreground/92 dark:text-foreground/86">
{role.title}
</h3>
<div className="flex items-center gap-2 pt-1">
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/40 border border-dashed border-foreground/[0.1] px-1.5 py-0.5 leading-none">
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/60 dark:text-foreground/50 border border-foreground/[0.15] bg-foreground/[0.03] px-1.5 py-0.5 leading-none dark:border-white/[0.08] dark:bg-white/[0.03]">
{role.type}
</span>
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/40 border border-dashed border-foreground/[0.1] px-1.5 py-0.5 leading-none">
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/60 dark:text-foreground/50 border border-foreground/[0.15] bg-foreground/[0.03] px-1.5 py-0.5 leading-none dark:border-white/[0.08] dark:bg-white/[0.03]">
{role.location}
</span>
</div>
@@ -146,7 +152,7 @@ function ApplyDialog({
<div className="space-y-1.5">
<label
htmlFor="careers-name"
className="text-[9px] text-foreground/30 uppercase tracking-widest font-mono"
className="text-[9px] text-foreground/58 dark:text-foreground/48 uppercase tracking-widest font-mono"
>
Name
</label>
@@ -154,7 +160,7 @@ function ApplyDialog({
id="careers-name"
type="text"
required
className="w-full bg-transparent border border-dashed border-foreground/[0.1] px-3 py-2 text-[12px] text-foreground/70 placeholder:text-foreground/20 focus:outline-none focus:border-foreground/25 transition-colors"
className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors"
placeholder="Your full name"
/>
</div>
@@ -162,7 +168,7 @@ function ApplyDialog({
<div className="space-y-1.5">
<label
htmlFor="careers-email"
className="text-[9px] text-foreground/30 uppercase tracking-widest font-mono"
className="text-[9px] text-foreground/58 dark:text-foreground/48 uppercase tracking-widest font-mono"
>
Email
</label>
@@ -170,7 +176,7 @@ function ApplyDialog({
id="careers-email"
type="email"
required
className="w-full bg-transparent border border-dashed border-foreground/[0.1] px-3 py-2 text-[12px] text-foreground/70 placeholder:text-foreground/20 focus:outline-none focus:border-foreground/25 transition-colors"
className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors"
placeholder="you@example.com"
/>
</div>
@@ -178,7 +184,7 @@ function ApplyDialog({
<div className="space-y-1.5">
<label
htmlFor="careers-portfolio"
className="text-[9px] text-foreground/30 uppercase tracking-widest font-mono"
className="text-[9px] text-foreground/58 dark:text-foreground/48 uppercase tracking-widest font-mono"
>
Portfolio / GitHub
</label>
@@ -186,7 +192,7 @@ function ApplyDialog({
id="careers-portfolio"
type="url"
required
className="w-full bg-transparent border border-dashed border-foreground/[0.1] px-3 py-2 text-[12px] text-foreground/70 placeholder:text-foreground/20 focus:outline-none focus:border-foreground/25 transition-colors"
className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors"
placeholder="https://"
/>
</div>
@@ -194,7 +200,7 @@ function ApplyDialog({
<div className="space-y-1.5">
<label
htmlFor="careers-why"
className="text-[9px] text-foreground/30 uppercase tracking-widest font-mono"
className="text-[9px] text-foreground/58 dark:text-foreground/48 uppercase tracking-widest font-mono"
>
Why Better Auth?
</label>
@@ -202,7 +208,7 @@ function ApplyDialog({
id="careers-why"
required
rows={3}
className="w-full bg-transparent border border-dashed border-foreground/[0.1] px-3 py-2 text-[12px] text-foreground/70 placeholder:text-foreground/20 focus:outline-none focus:border-foreground/25 transition-colors resize-none"
className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors resize-none"
placeholder="Tell us why you want to join..."
/>
</div>
@@ -210,13 +216,13 @@ function ApplyDialog({
<div className="pt-2">
<button
type="submit"
className="inline-flex items-center gap-1.5 px-4 py-2 border border-foreground/[0.12] text-foreground/40 hover:text-foreground/70 hover:border-foreground/25 hover:bg-foreground/[0.02] transition-all"
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background hover:opacity-90 transition-all"
>
<span className="font-mono text-[10px] uppercase tracking-widest">
Submit Application
</span>
<svg
className="h-2.5 w-2.5 opacity-50"
className="h-2.5 w-2.5 opacity-80"
viewBox="0 0 10 10"
fill="none"
>
@@ -326,58 +332,60 @@ function RoleCard({
delay: 0.2 + index * 0.08,
ease: "easeOut",
}}
className="relative border border-dashed border-foreground/[0.08] hover:border-foreground/[0.14] transition-all duration-300 group"
className="group relative overflow-hidden border border-foreground/[0.1] bg-foreground/[0.018] shadow-sm shadow-black/[0.03] transition-all duration-300 hover:z-10 hover:-translate-y-px hover:border-foreground/[0.18] hover:bg-foreground/[0.03] hover:shadow-lg hover:shadow-black/[0.06] dark:border-white/[0.07] dark:bg-white/[0.015] dark:shadow-black/[0.2] dark:hover:border-white/[0.12] dark:hover:bg-white/[0.03] dark:hover:shadow-black/[0.35]"
>
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-foreground/18 to-transparent dark:via-white/[0.14]" />
{/* Corner marks */}
<span className="absolute top-2 left-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
<span className="absolute top-2 left-2 text-[9px] font-mono text-foreground/22 dark:text-foreground/16 select-none leading-none">
+
</span>
<span className="absolute top-2 right-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
<span className="absolute top-2 right-2 text-[9px] font-mono text-foreground/22 dark:text-foreground/16 select-none leading-none">
+
</span>
<span className="absolute bottom-2 left-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
<span className="absolute bottom-2 left-2 text-[9px] font-mono text-foreground/22 dark:text-foreground/16 select-none leading-none">
+
</span>
<span className="absolute bottom-2 right-2 text-[9px] font-mono text-foreground/15 select-none leading-none">
<span className="absolute bottom-2 right-2 text-[9px] font-mono text-foreground/22 dark:text-foreground/16 select-none leading-none">
+
</span>
<div className="p-5 sm:p-6 space-y-4">
{/* Header */}
<div className="space-y-3">
<h3 className="text-sm sm:text-base text-foreground/90 tracking-tight">
<h3 className="text-sm sm:text-base text-foreground/92 dark:text-foreground/86 tracking-tight">
{role.title}
</h3>
<div className="flex items-center gap-2">
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/40 border border-dashed border-foreground/[0.1] px-1.5 py-0.5 leading-none">
<span className="border border-foreground/[0.14] bg-foreground/[0.03] px-1.5 py-0.5 text-[8px] font-mono uppercase tracking-widest text-foreground/58 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-foreground/48 leading-none">
{role.type}
</span>
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/40 border border-dashed border-foreground/[0.1] px-1.5 py-0.5 leading-none">
<span className="border border-foreground/[0.14] bg-foreground/[0.03] px-1.5 py-0.5 text-[8px] font-mono uppercase tracking-widest text-foreground/58 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-foreground/48 leading-none">
{role.location}
</span>
</div>
</div>
{/* Description */}
<p className="text-[11px] text-foreground/50 leading-relaxed max-w-lg">
<p className="max-w-lg text-[12px] leading-relaxed text-foreground/66 dark:text-foreground/56">
{role.description}
</p>
{/* Divider */}
<div className="border-t border-dashed border-foreground/[0.06]" />
<div className="border-t border-foreground/[0.08] dark:border-white/[0.06]" />
{/* Requirements */}
<div>
<p className="text-[9px] text-foreground/30 uppercase tracking-widest font-mono mb-3">
<p className="mb-3 text-[9px] font-mono uppercase tracking-[0.18em] text-foreground/52 dark:text-foreground/44">
Requirements
</p>
<ul className="space-y-2">
{role.requirements.map((req) => (
<li
key={req}
className="flex items-start gap-2 text-[11px] text-foreground/45 group-hover:text-foreground/55 transition-colors duration-300"
className="flex items-start gap-2 text-[11px] leading-relaxed text-foreground/56 transition-colors duration-300 group-hover:text-foreground/68 dark:text-foreground/48 dark:group-hover:text-foreground/60"
>
<span className="text-foreground/50 mt-px font-mono text-[9px] leading-none select-none shrink-0">
<span className="mt-px shrink-0 select-none font-mono text-[9px] leading-none text-foreground/60 dark:text-foreground/46">
+
</span>
<span>{req}</span>
@@ -391,13 +399,13 @@ function RoleCard({
<button
type="button"
onClick={onApply}
className="inline-flex items-center gap-1.5 px-3 py-1.5 border border-foreground/[0.12] text-foreground/40 hover:text-foreground/70 hover:border-foreground/25 hover:bg-foreground/[0.02] transition-all cursor-pointer"
className="inline-flex items-center gap-1.5 bg-foreground px-3.5 py-2 text-background transition-all hover:opacity-90 cursor-pointer"
>
<span className="font-mono text-[10px] uppercase tracking-widest">
Apply
</span>
<svg
className="h-2.5 w-2.5 opacity-50"
className="h-2.5 w-2.5 opacity-80"
viewBox="0 0 10 10"
fill="none"
>
@@ -503,7 +511,7 @@ export function CareersPageClient() {
# Open positions
</p>
<div className="space-y-0">
<div className="space-y-4">
{roles.map((role, i) => (
<RoleCard
key={role.title}
+20 -9
View File
@@ -1,18 +1,20 @@
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import { RootProvider } from "fumadocs-ui/provider/next";
import type { ReactNode } from "react";
import { Suspense } from "react";
import { AIChat, AIChatPanel, AIChatTrigger } from "@/components/ai-chat";
import { DocsSidebar } from "@/components/docs/docs-sidebar";
import { FloatingToolbar } from "@/components/floating-toolbar";
import { source } from "@/lib/source";
import type { PageEntry } from "./provider";
import { DocsProvider } from "./provider";
const allPages: PageEntry[] = source.getPages().map((page) => ({
name: page.data.title,
url: page.url,
}));
export default function Layout({ children }: { children: ReactNode }) {
return (
<RootProvider
search={{
enabled: false,
}}
>
<DocsProvider pages={allPages}>
<Suspense>
<DocsSidebar />
</Suspense>
@@ -27,8 +29,17 @@ export default function Layout({ children }: { children: ReactNode }) {
}}
>
{children}
<AIChat>
<AIChatPanel />
<AIChatTrigger>
<span className="text-sm text-muted-foreground">Ask AI</span>
<span className="h-5 w-px bg-foreground/10" />
<kbd className="inline-flex items-center gap-0.5 text-[10px] font-mono text-muted-foreground">
<span className="text-[11px]">&#8984;</span>I
</kbd>
</AIChatTrigger>
</AIChat>
</DocsLayout>
<FloatingToolbar />
</RootProvider>
</DocsProvider>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { RootProvider } from "fumadocs-ui/provider/next";
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
import { createContext, use } from "react";
const SearchDialog = dynamic(() => import("@/components/search-dialog"), {
ssr: false,
});
export interface PageEntry {
name: string;
url: string;
}
export const PagesContext = createContext<PageEntry[]>([]);
export function usePages() {
return use(PagesContext);
}
export function DocsProvider({
pages,
children,
}: {
pages: PageEntry[];
children: ReactNode;
}) {
return (
<PagesContext value={pages}>
<RootProvider
search={{
SearchDialog,
}}
>
{children}
</RootProvider>
</PagesContext>
);
}
+8
View File
@@ -674,6 +674,14 @@ html:not([data-anchor-scrolling]) {
}
}
/* Hide scrollbar in AI Chat panel */
.ai-chat-messages {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
/* Prevent scroll-chaining and hide scrollbars in TOC areas */
.docs-layout #nd-toc > div {
overscroll-behavior-y: contain;
+4 -7
View File
@@ -5,7 +5,6 @@ import { Analytics } from "@vercel/analytics/next";
import type { Metadata } from "next";
import Script from "next/script";
import type { ReactNode } from "react";
import { CommandMenuProvider } from "@/components/command-menu";
import { StaggeredNavFiles } from "@/components/landing/staggered-nav-files";
import { Providers } from "@/components/providers";
@@ -97,12 +96,10 @@ export default function RootLayout({ children }: { children: ReactNode }) {
suppressHydrationWarning
>
<Providers>
<CommandMenuProvider>
<div className="relative min-h-dvh">
<StaggeredNavFiles />
{children}
</div>
</CommandMenuProvider>
<div className="relative min-h-dvh">
<StaggeredNavFiles />
{children}
</div>
</Providers>
<Analytics />
</body>
+649
View File
@@ -0,0 +1,649 @@
"use client";
import type { UIMessage } from "@ai-sdk/react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Loader2, MessageCircleIcon, RefreshCw, Send, X } from "lucide-react";
import type { ComponentProps, ReactNode, SyntheticEvent } from "react";
import {
createContext,
use,
useCallback,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import { Markdown } from "./markdown";
import { MessageFeedback } from "./message-feedback";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "./ui/drawer";
// ─── Context ─────────────────────────────────────────────────────────────────
const AIChatContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
chat: ReturnType<typeof useChat>;
} | null>(null);
export function useAIChat() {
const ctx = use(AIChatContext);
if (!ctx) throw new Error("Missing <AIChat />");
return ctx;
}
function useChatContext() {
return useAIChat().chat;
}
// ─── Root ────────────────────────────────────────────────────────────────────
const DEFAULT_PANEL_WIDTH = 400;
const MIN_PANEL_WIDTH = 320;
const MAX_PANEL_WIDTH = 640;
const chatTransport = new DefaultChatTransport({
api: "/api/docs/chat",
});
export function AIChat({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const chat = useChat({
id: "ai-chat",
transport: chatTransport,
});
// Support ?askai= URL param
const handleAskAiParam = useEffectEvent(() => {
const params = new URLSearchParams(window.location.search);
const aiQuery = params.get("askai");
if (aiQuery) {
setOpen(true);
void chat.sendMessage({ text: aiQuery });
const newParams = new URLSearchParams(window.location.search);
newParams.delete("askai");
const newUrl = newParams.toString()
? `${window.location.pathname}?${newParams.toString()}`
: window.location.pathname;
window.history.replaceState({}, "", newUrl);
}
});
useEffect(() => {
handleAskAiParam();
}, []);
return (
<AIChatContext
value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}
>
{children}
</AIChatContext>
);
}
// ─── Trigger ─────────────────────────────────────────────────────────────────
export function AIChatTrigger({
className,
...props
}: ComponentProps<"button">) {
const { open, setOpen } = useAIChat();
return (
<button
type="button"
data-state={open ? "open" : "closed"}
className={cn(
"fixed bottom-5 end-5 z-20 flex items-center gap-2.5 px-3.5 py-1.5 border bg-secondary/70 backdrop-blur-md shadow-lg transition-[translate,opacity]",
open && "translate-y-10 opacity-0",
className,
)}
onClick={() => setOpen(!open)}
{...props}
/>
);
}
// ─── Panel ───────────────────────────────────────────────────────────────────
const LG_BREAKPOINT = 1024;
function useIsDesktop() {
const [isDesktop, setIsDesktop] = useState<boolean | undefined>(undefined);
useEffect(() => {
const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`);
const onChange = () => setIsDesktop(mql.matches);
mql.addEventListener("change", onChange);
setIsDesktop(mql.matches);
return () => mql.removeEventListener("change", onChange);
}, []);
return isDesktop;
}
export function AIChatPanel() {
const isDesktop = useIsDesktop();
useAIChatHotKey();
// SSR / hydration: render nothing until we know the viewport
if (isDesktop === undefined) return null;
return isDesktop ? <DesktopPanel /> : <MobileDrawerPanel />;
}
function useVisualViewportHeight() {
const [height, setHeight] = useState<number | undefined>(undefined);
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => setHeight(vv.height);
update();
vv.addEventListener("resize", update);
return () => vv.removeEventListener("resize", update);
}, []);
return height;
}
function MobileDrawerPanel() {
const { open, setOpen } = useAIChat();
const vvHeight = useVisualViewportHeight();
// 85% of the visual viewport (shrinks when keyboard opens)
const drawerHeight = vvHeight ? vvHeight * 0.85 : undefined;
return (
<Drawer
open={open}
onOpenChange={setOpen}
repositionInputs={false}
handleOnly
>
<DrawerContent
style={
drawerHeight
? { height: drawerHeight, maxHeight: drawerHeight }
: undefined
}
className={drawerHeight ? undefined : "h-[85dvh] max-h-[85dvh]"}
>
<DrawerHeader className="flex flex-row items-center gap-2 border-b">
<div className="flex-1 text-left">
<DrawerTitle className="text-xs font-medium">AI Chat</DrawerTitle>
<DrawerDescription className="text-[10px]">
Powered by{" "}
<a
href="https://inkeep.com"
target="_blank"
rel="noreferrer noopener"
className="underline hover:text-foreground transition-colors"
>
Inkeep AI
</a>
</DrawerDescription>
</div>
<DrawerClose
aria-label="Close"
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="size-4" />
</DrawerClose>
</DrawerHeader>
<div className="flex flex-col flex-1 w-full min-h-0 overflow-hidden px-2 pb-3">
<PanelMessages className="flex-1" />
<PanelInput autoFocus={false} />
</div>
</DrawerContent>
</Drawer>
);
}
function DesktopPanel() {
const { open } = useAIChat();
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
const [dragging, setDragging] = useState(false);
const startX = useRef(0);
const startWidth = useRef(panelWidth);
useEffect(() => {
if (!dragging) return;
const onMouseMove = (e: MouseEvent) => {
const delta = startX.current - e.clientX;
setPanelWidth(
Math.min(
MAX_PANEL_WIDTH,
Math.max(MIN_PANEL_WIDTH, startWidth.current + delta),
),
);
};
const onMouseUp = () => setDragging(false);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [dragging]);
if (!open) return null;
return (
<>
<div
className={cn(
"overflow-hidden overscroll-contain z-200 bg-background text-foreground",
"fixed top-0 inset-e-0 h-dvh border-s",
"shadow-[-8px_0_24px_-4px_rgba(0,0,0,0.1)] dark:shadow-[-8px_0_30px_-2px_rgba(0,0,0,0.7)]",
)}
style={{ width: panelWidth }}
>
{/* Resize handle */}
<div
className="absolute top-0 bottom-0 left-0 w-1 cursor-col-resize hover:bg-foreground/10 active:bg-foreground/15 transition-colors z-10"
onMouseDown={(e) => {
e.preventDefault();
startX.current = e.clientX;
startWidth.current = panelWidth;
setDragging(true);
}}
/>
<div
className="flex flex-col size-full min-h-0 overflow-hidden p-3"
style={{ width: panelWidth }}
>
<PanelHeader />
<PanelMessages className="flex-1" />
<PanelInput autoFocus />
</div>
</div>
{/* Full-screen overlay during drag */}
{dragging && <div className="fixed inset-0 z-[201] cursor-col-resize" />}
</>
);
}
// ─── Panel Header ────────────────────────────────────────────────────────────
function PanelHeader() {
const { setOpen } = useAIChat();
return (
<div className="flex items-center gap-2 border-b px-3 pb-3">
<div className="flex-1">
<p className="text-sm font-medium">AI Chat</p>
<p className="text-[10px] text-muted-foreground">
Powered by{" "}
<a
href="https://inkeep.com"
target="_blank"
rel="noreferrer noopener"
className="underline hover:text-foreground transition-colors"
>
Inkeep AI
</a>
</p>
</div>
<button
type="button"
aria-label="Close"
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setOpen(false)}
>
<X className="size-4" />
</button>
</div>
);
}
// ─── Panel Messages ──────────────────────────────────────────────────────────
function PanelMessages({ className, ...props }: ComponentProps<"div">) {
const { messages } = useChatContext();
const containerRef = useRef<HTMLDivElement>(null);
const filtered = messages.filter((msg) => msg.role !== "system");
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const scrollToBottom = () => {
container.scrollTo({
top: container.scrollHeight,
behavior: "instant",
});
};
// Observe size changes on all current and future children
const resizeObserver = new ResizeObserver(scrollToBottom);
const observeChildren = () => {
resizeObserver.disconnect();
for (const child of container.children) {
resizeObserver.observe(child);
}
};
observeChildren();
// Re-attach when children are added/removed
const mutationObserver = new MutationObserver(observeChildren);
mutationObserver.observe(container, { childList: true });
// Prevent scroll chaining to body on desktop
const onWheel = (e: WheelEvent) => {
const { scrollTop, scrollHeight, clientHeight } = container;
const atTop = scrollTop <= 0 && e.deltaY < 0;
const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0;
if (atTop || atBottom) {
e.preventDefault();
}
};
container.addEventListener("wheel", onWheel, { passive: false });
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
container.removeEventListener("wheel", onWheel);
};
}, []);
return (
<div
ref={containerRef}
className={cn(
"ai-chat-messages overflow-y-auto overscroll-contain min-w-0 min-h-0 flex flex-col py-4 select-text",
className,
)}
style={{
maskImage:
"linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)",
}}
{...props}
>
{filtered.length === 0 ? (
<div className="size-full flex flex-col items-center justify-center text-center gap-2 text-sm text-muted-foreground/80 py-6">
<MessageCircleIcon
fill="currentColor"
stroke="none"
className="size-5"
/>
<p>Start a new chat below.</p>
</div>
) : (
<div className="flex flex-col px-1 gap-4">
{filtered.map((item, index) => (
<Message
key={item.id}
message={item}
messages={filtered}
messageIndex={index}
/>
))}
</div>
)}
</div>
);
}
// ─── Panel Input ─────────────────────────────────────────────────────────────
function PanelInput({ autoFocus = false }: { autoFocus?: boolean }) {
const { status, sendMessage, stop, setMessages, messages, regenerate } =
useChatContext();
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isLoading = status === "streaming" || status === "submitted";
const inputDragRef = useRef<{ startY: number; startHeight: number } | null>(
null,
);
const [inputMinHeight, setInputMinHeight] = useState(38);
const adjustHeight = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
const maxH = window.innerHeight * 0.35;
el.style.height = "auto";
el.style.height = `${Math.min(maxH, Math.max(el.scrollHeight, inputMinHeight))}px`;
}, [inputMinHeight]);
useEffect(() => {
adjustHeight();
}, [adjustHeight]);
const onSubmit = (e?: SyntheticEvent) => {
e?.preventDefault();
if (!input.trim() || isLoading) return;
void sendMessage({ text: input });
setInput("");
requestAnimationFrame(adjustHeight);
};
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
inputDragRef.current = { startY: e.clientY, startHeight: inputMinHeight };
const onMove = (ev: MouseEvent) => {
if (!inputDragRef.current) return;
const delta = inputDragRef.current.startY - ev.clientY;
setInputMinHeight(
Math.max(38, Math.min(400, inputDragRef.current.startHeight + delta)),
);
};
const onUp = () => {
inputDragRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
document.body.style.userSelect = "none";
document.body.style.cursor = "row-resize";
},
[inputMinHeight],
);
return (
<div className="relative">
{/* Drag handle to resize input */}
<div
onMouseDown={handleDragStart}
className="absolute -top-1.5 left-1/2 -translate-x-1/2 z-10 hidden lg:flex items-center justify-center w-8 h-3 cursor-row-resize group/drag"
>
<div className="w-8 h-[2px] rounded-full bg-foreground/20 dark:bg-white/20 group-hover/drag:bg-foreground/40 transition-colors" />
</div>
<div
className={cn(
"rounded-xl border transition-all duration-200",
"border-border/60 dark:border-white/8",
"bg-card/50 dark:bg-white/2",
"focus-within:border-foreground/15 dark:focus-within:border-white/12",
"focus-within:bg-background dark:focus-within:bg-white/3",
"focus-within:shadow-[0_0_0_1px_rgba(0,0,0,0.04)] dark:focus-within:shadow-[0_0_0_1px_rgba(255,255,255,0.03)]",
)}
>
<form onSubmit={onSubmit}>
<div className="flex items-end">
<textarea
ref={textareaRef}
id="nd-ai-input"
value={input}
onChange={(e) => {
setInput(e.target.value);
adjustHeight();
}}
onKeyDown={(e) => {
if (!e.shiftKey && e.key === "Enter") {
e.preventDefault();
onSubmit();
}
}}
disabled={isLoading}
placeholder={isLoading ? "AI is answering..." : "Ask a question"}
autoFocus={autoFocus}
rows={1}
style={{
height: Math.max(inputMinHeight, 38),
}}
className="flex-1 resize-none text-base lg:text-[13px] bg-transparent pl-3.5 pr-1.5 py-2.5 placeholder:text-muted-foreground focus:outline-none overflow-y-auto"
/>
<div className="shrink-0 pb-1.5 pr-1.5">
{isLoading ? (
<button
type="button"
onClick={stop}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-border text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-150 cursor-pointer"
title="Stop generating"
>
<Loader2 className="size-3.5 animate-spin" />
</button>
) : (
<button
type="submit"
disabled={input.trim().length === 0}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-full transition-all duration-150",
input.trim()
? "bg-foreground text-background hover:bg-foreground/90 cursor-pointer"
: "bg-muted/50 dark:bg-white/5 text-muted-foreground cursor-default",
)}
title="Send (Enter)"
>
<Send className="size-3.5" />
</button>
)}
</div>
</div>
</form>
</div>
<div className="flex items-center gap-1.5 p-1 empty:hidden">
{!isLoading &&
messages.length > 0 &&
messages.at(-1)?.role === "assistant" && (
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1 text-xs border rounded-full text-muted-foreground hover:text-foreground transition-colors"
onClick={() => regenerate()}
>
<RefreshCw className="size-3" />
Retry
</button>
)}
{messages.length > 0 && !isLoading && (
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1 text-xs border rounded-full text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setMessages([])}
>
Clear Chat
</button>
)}
</div>
</div>
);
}
// ─── Message ─────────────────────────────────────────────────────────────────
const roleName: Record<string, string> = {
user: "you",
assistant: "BA bot",
};
function Message({
message,
messages,
messageIndex,
}: {
message: UIMessage;
messages: UIMessage[];
messageIndex: number;
}) {
const { status } = useChatContext();
const isLastAssistant =
message.role === "assistant" && messageIndex === messages.length - 1;
const isStreaming =
(status === "streaming" || status === "submitted") && isLastAssistant;
let markdown = "";
for (const part of message.parts ?? []) {
if (part.type === "text") {
markdown += part.text;
}
}
if (message.role === "assistant") {
// Remove Inkeep citation markers like (1), (2) etc.
markdown = markdown.replace(/\(\d+\)/g, "");
// Remove stray backslashes used as line breaks by Inkeep
markdown = markdown.replace(/^\s*\\\s*$/gm, "");
}
// Fix incomplete code blocks
const codeBlockCount = (markdown.match(/```/g) || []).length;
if (codeBlockCount % 2 !== 0) {
markdown += "\n```";
}
return (
<div>
<p
className={cn(
"mb-1 text-xs font-medium text-muted-foreground",
message.role === "assistant" && "text-foreground",
)}
>
{roleName[message.role] ?? "unknown"}
</p>
<div className="text-sm prose prose-sm max-w-none [&_pre]:!text-[11px]">
<Markdown text={markdown} />
</div>
{message.role === "assistant" && message.id && !isStreaming && (
<MessageFeedback
messageId={message.id}
userMessageId={
messageIndex > 0 ? messages[messageIndex - 1]?.id : undefined
}
content={markdown}
/>
)}
</div>
);
}
// ─── Hot Key ─────────────────────────────────────────────────────────────────
function useAIChatHotKey() {
const { open, setOpen } = useAIChat();
const onKeyPress = useEffectEvent((e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
setOpen(false);
e.preventDefault();
}
if (e.key === "i" && (e.metaKey || e.ctrlKey)) {
setOpen(!open);
e.preventDefault();
}
});
useEffect(() => {
window.addEventListener("keydown", onKeyPress);
return () => window.removeEventListener("keydown", onKeyPress);
}, []);
}
+23
View File
@@ -48,7 +48,9 @@ export const APIMethod = ({
children,
noResult,
requireSession,
requireHeaders,
requireBearerToken,
headersComment,
note,
clientOnlyNote,
serverOnlyNote,
@@ -73,6 +75,16 @@ export const APIMethod = ({
* @default false
*/
requireBearerToken?: boolean;
/**
* If enabled, we will add `headers` to the fetch options without assuming the request is backed by a session.
*
* @default false
*/
requireHeaders?: boolean;
/**
* A custom comment to display above the generated `headers` line when `requireHeaders` is enabled.
*/
headersComment?: string;
/**
* The HTTP method to the endpoint
*
@@ -152,7 +164,9 @@ export const APIMethod = ({
props,
method: method ?? "GET",
requireSession: requireSession ?? false,
requireHeaders: requireHeaders ?? false,
requireBearerToken: requireBearerToken ?? false,
headersComment,
forceAsQuery,
forceAsParam,
forceAsBody,
@@ -768,7 +782,9 @@ function shouldServerUseQueryParams(
function createServerBody({
props,
requireSession,
requireHeaders,
requireBearerToken,
headersComment,
method,
forceAsBody,
forceAsParam,
@@ -776,7 +792,9 @@ function createServerBody({
}: {
props: Property[];
requireSession: boolean;
requireHeaders: boolean;
requireBearerToken: boolean;
headersComment: string | undefined;
method: string;
forceAsQuery: boolean | undefined;
forceAsParam: boolean | undefined;
@@ -840,6 +858,11 @@ function createServerBody({
if (requireSession) {
fetchOptions +=
"\n // This endpoint requires session cookies.\n headers: await headers(),";
} else if (requireHeaders) {
fetchOptions += `\n // ${
headersComment ||
"Pass the current request headers so Better Auth can read and set cookies."
}\n headers: await headers(),`;
}
if (requireBearerToken) {
+1 -1
View File
@@ -181,7 +181,7 @@ export function BlogLeftPanel({ postCount, post }: BlogLeftPanelProps) {
</svg>
</a>
<a
href="/rss.xml"
href="/blog/rss.xml"
className="flex items-center gap-1.5 text-foreground/30 hover:text-foreground/70 transition-colors"
aria-label="RSS Feed"
>
@@ -290,6 +290,17 @@ export const communityPlugins: CommunityPlugin[] = [
avatar: "https://github.com/iamjasonkendrick.png",
},
},
{
name: "better-auth-invite-plugin",
url: "https://github.com/0-Sandy/better-auth-invite-plugin",
description:
"Easily create and manage user invitations, allowing you to invite users with customizable settings and track usage.",
author: {
name: "Sandy",
github: "0-Sandy",
avatar: "https://github.com/0-Sandy.png",
},
},
];
export function CommunityPluginsTable() {
const [sorting, setSorting] = useState<SortingState>([]);
+2 -2
View File
@@ -5,11 +5,11 @@ import type {
Item as PageTreeItem,
Separator as PageTreeSeparator,
} from "fumadocs-core/page-tree";
import { useSearchContext } from "fumadocs-ui/contexts/search";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { FC, ReactNode } from "react";
import { useState } from "react";
import { useCommandMenu } from "@/components/command-menu";
// ─── Item ───────────────────────────────────────────────────────────────────
@@ -111,7 +111,7 @@ export const CustomSeparator: FC<{ item: PageTreeSeparator }> = ({ item }) => {
// ─── Search Button (sidebar banner) ─────────────────────────────────────────
export const SearchButton: FC = () => {
const { setOpen } = useCommandMenu();
const { setOpenSearch: setOpen } = useSearchContext();
return (
<button
+42 -25
View File
@@ -1,14 +1,15 @@
"use client";
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
import { ChevronDownIcon, GitBranch, Search, Sparkles } from "lucide-react";
import { useSearchContext } from "fumadocs-ui/contexts/search";
import { ChevronDownIcon, GitBranch, Search } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { useCommandMenu } from "@/components/command-menu";
import type { ListItem } from "@/components/sidebar-content";
import { contents } from "@/components/sidebar-content";
import { ThemeToggle } from "@/components/theme-toggle";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
@@ -18,7 +19,7 @@ export function DocsSidebar() {
const pathname = usePathname();
const searchParams = useSearchParams();
const branch = searchParams.get("branch");
const { setOpen, openAI } = useCommandMenu();
const { setOpenSearch } = useSearchContext();
const [currentOpen, setCurrentOpen] = useState(0);
const navRef = useRef<HTMLElement>(null);
@@ -77,30 +78,24 @@ export function DocsSidebar() {
<button
type="button"
className="flex w-full items-center gap-2 px-4 lg:px-7 py-2.5 border-b border-foreground/5 text-[12px] uppercase tracking-wider text-foreground/55 hover:text-foreground/80 transition-colors"
onClick={() => setOpen(true)}
className="flex w-full items-center gap-2 px-4 py-2.5 border-b border-foreground/5 text-sm text-foreground/55 hover:text-foreground/80 hover:bg-foreground/3 transition-colors"
onClick={() => setOpenSearch(true)}
>
<Search className="size-3.5 shrink-0" />
<Search className="size-4 shrink-0" />
<span className="truncate">Search</span>
<span
className="ml-auto flex items-center gap-1 shrink-0 text-foreground/55 hover:text-foreground/70 transition-colors"
onClick={(e) => {
e.stopPropagation();
openAI();
}}
onKeyDown={() => {}}
role="button"
tabIndex={-1}
>
<Sparkles className="size-3" />
<span className="text-[10px] normal-case tracking-normal">AI</span>
</span>
<kbd className="ml-auto inline-flex items-center gap-0.5 shrink-0 text-[10px] font-mono text-foreground/40 border border-foreground/10 rounded-md px-1.5 py-0.5">
<span className="text-[11px]">&#8984;</span>K
</kbd>
</button>
{/* Scrollable navigation area */}
<nav
ref={navRef}
className="flex-1 overflow-y-auto overflow-x-hidden pb-3 sidebar-scroll"
style={{
maskImage:
"linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 2rem), transparent 100%)",
}}
>
<MotionConfig
transition={{ duration: 0.35, type: "spring", bounce: 0 }}
@@ -112,7 +107,6 @@ export function DocsSidebar() {
type="button"
className={cn(
"border-b border-foreground/6 w-full text-left flex gap-2 items-center px-4 py-2.5 transition-colors",
"lg:px-7",
"font-medium text-sm tracking-wider",
currentOpen === index
? "text-foreground bg-foreground/3"
@@ -154,6 +148,29 @@ export function DocsSidebar() {
</div>
</MotionConfig>
</nav>
{/* Footer: GitHub + Theme Toggle */}
<div className="flex items-center gap-1 p-2 border-t border-foreground/5 text-foreground/40">
<a
href="https://github.com/better-auth/better-auth"
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center justify-center size-8 hover:text-foreground/70 hover:bg-foreground/5 transition-colors"
aria-label="GitHub"
>
<svg
role="img"
viewBox="0 0 24 24"
fill="currentColor"
className="size-4"
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
</a>
<div className="ms-auto [&_button]:text-foreground/40 [&_button:hover]:text-foreground/70">
<ThemeToggle />
</div>
</div>
</motion.aside>
);
}
@@ -195,7 +212,7 @@ function BranchSwitcher() {
<div ref={ref} className="relative border-b border-foreground/10">
<button
type="button"
className="flex w-full items-center gap-2 px-4 lg:px-7 py-3 hover:bg-foreground/[0.02] transition-colors"
className="flex w-full items-center gap-2 px-4 py-3 hover:bg-foreground/[0.02] transition-colors"
onClick={() => setOpen((v) => !v)}
>
<GitBranch className="size-3.5 text-foreground/60 shrink-0" />
@@ -376,13 +393,13 @@ function SidebarItemWithSubpages({
transition={{ duration: 0.35, type: "spring", bounce: 0 }}
className="overflow-hidden"
>
<div className="relative before:absolute before:left-[calc(1.75rem+0.75rem)] lg:before:left-[calc(2.75rem+0.75rem)] before:top-0 before:bottom-0 before:w-px before:bg-foreground/20">
<div className="relative before:absolute before:left-[calc(1.75rem+0.75rem)] before:top-0 before:bottom-0 before:w-px before:bg-foreground/20">
{item.subpages.map((subpage, i) => {
if (subpage.group) {
return (
<div
key={`subgroup-${subpage.title}-${i}`}
className="flex flex-row items-center gap-2 pl-[calc(1.75rem+0.75rem+0.75rem)] lg:pl-[calc(2.75rem+0.75rem+0.75rem)] pr-4 lg:pr-7 py-1.5 mt-1 first:mt-0"
className="flex flex-row items-center gap-2 pl-[calc(1.75rem+0.75rem+0.75rem)] pr-4 py-1.5 mt-1 first:mt-0"
>
<p className="text-[10px] text-foreground/45 uppercase tracking-wider">
{subpage.title}
@@ -433,7 +450,7 @@ function SubpageLink({
href={href}
data-active={active || undefined}
className={cn(
"relative flex items-center gap-1 pl-[calc(1.75rem+0.75rem+0.75rem)] lg:pl-[calc(2.75rem+0.75rem+0.75rem)] pr-4 lg:pr-7 py-1 text-[13px] transition-all duration-150",
"relative flex items-center gap-1 pl-[calc(1.75rem+0.75rem+0.75rem)] pr-4 py-1 text-[13px] transition-all duration-150",
active
? "text-foreground bg-foreground/6"
: "text-foreground/55 hover:text-foreground/80 hover:bg-foreground/3",
@@ -474,7 +491,7 @@ function SidebarLink({
href={href}
data-active={active || undefined}
className={`
relative flex items-center gap-2.5 px-4 lg:px-7 py-1 text-[14px] transition-all duration-150
relative flex items-center gap-2.5 px-4 py-1 text-[14px] transition-all duration-150
${
active
? "text-foreground bg-foreground/6"
-33
View File
@@ -1,33 +0,0 @@
"use client";
import { motion } from "framer-motion";
import { useCommandMenu } from "@/components/command-menu";
import { ThemeToggle } from "@/components/theme-toggle";
export function FloatingToolbar() {
const { setOpen } = useCommandMenu();
return (
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, delay: 0.8, ease: "easeOut" }}
className="fixed bottom-5 right-5 z-[100] flex items-center border border-foreground/[0.06] bg-background/60 backdrop-blur-md"
>
<button
type="button"
onClick={() => setOpen(true)}
className="group flex items-center justify-center size-9 text-foreground/40 hover:text-foreground/70 transition-colors"
>
<kbd className="flex items-center gap-0.5 text-[10px] font-mono">
<span className="text-[11px]">&#8984;</span>K
</kbd>
<span className="sr-only">Command menu</span>
</button>
<span className="w-px h-3 bg-foreground/[0.08]" />
<div className="flex items-center justify-center size-9 [&_button]:text-foreground/40 [&_button:hover]:text-foreground/70">
<ThemeToggle />
</div>
</motion.div>
);
}
+308 -223
View File
@@ -1,15 +1,22 @@
"use client";
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
import { ChevronDownIcon } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronDownIcon, Search } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ThemeToggle } from "@/components/theme-toggle";
import { cn } from "@/lib/utils";
import DarkPng from "../../public/branding/better-auth-logo-dark.png";
import WhitePng from "../../public/branding/better-auth-logo-light.png";
import { Logo } from "../icons/logo";
import { contents } from "../sidebar-content";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../ui/accordion";
import { Badge } from "../ui/badge";
import LogoContextMenu from "./logo-context-menu";
@@ -37,10 +44,22 @@ const resourceFiles: NavFileItem[] = [
{ name: "careers", href: "/careers" },
];
interface MobileMenuSection {
name: string;
href?: string;
children?: NavFileItem[];
}
const mobileMenuSections: MobileMenuSection[] = [
{ name: "products", children: productFiles },
{ name: "resources", children: resourceFiles },
{ name: "enterprise", href: "/enterprise" },
];
function DropdownItem({ item }: { item: NavFileItem }) {
return (
<div className="group/item flex items-center gap-1.5 px-3 py-1.5 hover:bg-foreground/[0.06] transition-colors duration-150 cursor-pointer">
<span className="font-mono text-[10px] uppercase tracking-wider text-foreground/75 dark:text-foreground/60 group-hover/item:text-foreground transition-colors duration-150 whitespace-nowrap">
<div className="group/item flex w-full min-w-0 items-center gap-1.5 px-3 py-1.5 hover:bg-foreground/[0.06] transition-colors duration-150 cursor-pointer">
<span className="block min-w-0 truncate font-mono text-[10px] uppercase tracking-wider text-foreground/75 dark:text-foreground/60 group-hover/item:text-foreground transition-colors duration-150">
{item.name}
</span>
</div>
@@ -104,6 +123,24 @@ export function StaggeredNavFiles() {
const productsTimeout = useRef<NodeJS.Timeout>(undefined);
const resourcesTimeout = useRef<NodeJS.Timeout>(undefined);
useEffect(() => {
document.body.style.overflow = mobileMenuOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [mobileMenuOpen]);
useEffect(() => {
const mql = window.matchMedia("(min-width: 1024px)");
const handler = () => {
if (mql.matches) {
setMobileMenuOpen(false);
}
};
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);
const openProducts = () => {
clearTimeout(productsTimeout.current);
setProductsOpen(true);
@@ -119,6 +156,10 @@ export function StaggeredNavFiles() {
resourcesTimeout.current = setTimeout(() => setResourcesOpen(false), 150);
};
const isActive = useCallback((href: string) => pathname === href, [pathname]);
const isActivePrefix = useCallback(
(href: string) => pathname === href || pathname.startsWith(`${href}/`),
[pathname],
);
const isDocs = pathname.startsWith("/docs");
const isProductPage =
pathname === "/products" || pathname.startsWith("/products/");
@@ -161,7 +202,7 @@ export function StaggeredNavFiles() {
>
<Link
href="/"
className="flex items-center gap-1 px-4 lg:px-7 py-3 transition-colors duration-150"
className="flex items-center gap-1 px-4 py-3 transition-colors duration-150"
>
<div className="flex flex-col gap-2 w-full">
<LogoContextMenu
@@ -184,18 +225,39 @@ export function StaggeredNavFiles() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.28, ease: "easeOut" }}
className="lg:hidden flex items-center justify-between w-full pointer-events-auto bg-background border-b border-foreground/[0.06]"
className="lg:hidden flex items-center justify-between w-full h-[52px] pointer-events-auto bg-background border-b border-foreground/[0.06]"
>
<Link
href="/"
className="flex items-center gap-1 px-4 py-3 transition-colors duration-150"
>
<Logo />
<p className="select-none font-mono text-sm uppercase tracking-wider">
<p className="select-none font-mono text-base uppercase">
BETTER-AUTH.
</p>
</Link>
<div className="flex items-center">
<div className="flex items-center gap-1 pr-2">
{isDocs && (
<button
type="button"
onClick={() => {
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "k",
metaKey: true,
bubbles: true,
}),
);
}}
className="flex items-center justify-center size-8 text-foreground/50 hover:text-foreground/80 transition-colors"
aria-label="Search"
>
<Search className="size-4" />
</button>
)}
<div className="flex items-center justify-center size-8 text-foreground/50 [&_button]:text-foreground/50 [&_button:hover]:text-foreground/80">
<ThemeToggle />
</div>
<button
type="button"
onClick={() => {
@@ -216,7 +278,7 @@ export function StaggeredNavFiles() {
}
}
}}
className="flex items-center justify-center px-4 py-3 text-foreground/75 dark:text-foreground/60 hover:text-foreground/85 transition-colors"
className="flex items-center justify-center size-8 text-foreground/75 dark:text-foreground/60 hover:text-foreground/85 transition-colors"
>
{mobileMenuOpen ? (
<svg
@@ -490,12 +552,12 @@ export function StaggeredNavFiles() {
</Link>
</motion.div>
))}
<div className="border-t border-foreground/[0.06] mt-1 pt-1 flex items-center gap-0.5 px-2.5">
<div className="mt-1 grid w-full grid-cols-[repeat(auto-fit,minmax(1.75rem,1fr))] items-center justify-items-center gap-y-0.5 border-t border-foreground/[0.06] px-2 pt-1">
<a
href="https://github.com/better-auth/better-auth"
target="_blank"
rel="noreferrer"
className="p-1.5 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
className="flex items-center justify-center p-1 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
aria-label="GitHub"
>
<svg
@@ -514,7 +576,7 @@ export function StaggeredNavFiles() {
href="https://discord.gg/better-auth"
target="_blank"
rel="noreferrer"
className="p-1.5 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
className="flex items-center justify-center p-1 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
aria-label="Discord"
>
<svg
@@ -533,7 +595,7 @@ export function StaggeredNavFiles() {
href="https://reddit.com/r/better_auth"
target="_blank"
rel="noreferrer"
className="p-1.5 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
className="flex items-center justify-center p-1 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
aria-label="Reddit"
>
<svg
@@ -554,7 +616,7 @@ export function StaggeredNavFiles() {
href="https://x.com/better_auth"
target="_blank"
rel="noreferrer"
className="p-1.5 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
className="flex items-center justify-center p-1 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
aria-label="X"
>
<svg
@@ -573,7 +635,7 @@ export function StaggeredNavFiles() {
href="https://www.npmjs.com/package/better-auth"
target="_blank"
rel="noreferrer"
className="p-1.5 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
className="flex items-center justify-center p-1 text-foreground/55 dark:text-foreground/40 hover:text-foreground/75 transition-colors"
aria-label="npm"
>
<svg
@@ -633,37 +695,36 @@ export function StaggeredNavFiles() {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="lg:hidden fixed inset-0 z-[98] bg-background/95 backdrop-blur-sm pointer-events-auto"
className="lg:hidden fixed inset-0 z-[98] w-full bg-background/95 backdrop-blur-sm pointer-events-auto"
>
<div className="pt-[52px] flex flex-col h-full overflow-y-auto">
{isDocs && mobileView === "docs" ? (
<>
{/* Subtle back to nav button */}
<button
type="button"
onClick={() => setMobileView("nav")}
className="flex items-center gap-2 px-5 py-2.5 text-foreground/65 dark:text-foreground/45 hover:text-foreground/70 transition-colors border-b border-foreground/[0.06]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
<div className="pt-[52px] flex flex-col h-full">
<div className="flex-1 min-h-0 overflow-y-auto">
{isDocs && mobileView === "docs" ? (
<>
{/* Subtle back to nav button */}
<button
type="button"
onClick={() => setMobileView("nav")}
className="flex items-center gap-2 w-full px-5 py-2.5 text-foreground/65 dark:text-foreground/45 hover:text-foreground/70 transition-colors border-b border-foreground/6"
>
<path
fill="currentColor"
d="M3 18h18v-2H3zm0-5h18v-2H3zm0-7v2h18V6z"
/>
</svg>
<span className="font-mono text-[10px] uppercase tracking-wider">
Menu
</span>
</button>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M3 18h18v-2H3zm0-5h18v-2H3zm0-7v2h18V6z"
/>
</svg>
<span className="font-mono text-[10px] uppercase tracking-wider">
Menu
</span>
</button>
{/* Doc sidebar sections */}
{/* Doc sidebar sections */}
<MotionConfig
transition={{ duration: 0.35, type: "spring", bounce: 0 }}
>
<div className="flex flex-col">
{contents.map((section, index) => (
<div key={section.title}>
@@ -691,207 +752,231 @@ export function StaggeredNavFiles() {
)}
/>
</button>
<AnimatePresence initial={false}>
{mobileDocSection === index && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="relative overflow-hidden"
>
<div className="text-sm pt-0 pb-1">
{section.href && (
{mobileDocSection === index && (
<div className="relative overflow-hidden">
<div className="text-sm pt-0 pb-1">
{section.href && (
<Link
href={section.href}
onClick={() => setMobileMenuOpen(false)}
data-active={
pathname === section.href || undefined
}
className={cn(
"relative flex items-center gap-2.5 px-5 py-1.5 text-[14px] transition-all duration-150",
pathname === section.href
? "text-foreground bg-foreground/6"
: "text-foreground/75 dark:text-foreground/60 hover:text-foreground/90 hover:bg-foreground/3",
)}
>
<span className="truncate">Overview</span>
</Link>
)}
{section.list.map((item, i) => {
if (item.separator || item.group) {
return (
<div
key={`sep-${item.title}-${i}`}
className="flex flex-row items-center gap-2 mx-5 my-2"
>
<p className="text-[10px] text-foreground/65 dark:text-foreground/45 uppercase tracking-wider">
{item.title}
</p>
<div className="grow h-px bg-border" />
</div>
);
}
if (!item.href) return null;
const active =
pathname === item.href ||
(!!item.subpages?.length &&
pathname.startsWith(`${item.href}/`));
return (
<Link
href={section.href}
key={item.href}
href={item.href}
onClick={() => setMobileMenuOpen(false)}
data-active={
pathname === section.href || undefined
}
data-active={active || undefined}
className={cn(
"relative flex items-center gap-2.5 px-5 py-1.5 text-[14px] transition-all duration-150",
pathname === section.href
active
? "text-foreground bg-foreground/6"
: "text-foreground/75 dark:text-foreground/60 hover:text-foreground/90 hover:bg-foreground/3",
)}
>
<span className="truncate">Overview</span>
</Link>
)}
{section.list.map((item, i) => {
if (item.separator || item.group) {
return (
<div
key={`sep-${item.title}-${i}`}
className="flex flex-row items-center gap-2 mx-5 my-2"
>
<p className="text-[10px] text-foreground/65 dark:text-foreground/45 uppercase tracking-wider">
{item.title}
</p>
<div className="grow h-px bg-border" />
</div>
);
}
if (!item.href) return null;
const active =
pathname === item.href ||
(!!item.subpages?.length &&
pathname.startsWith(`${item.href}/`));
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileMenuOpen(false)}
data-active={active || undefined}
<span
className={cn(
"relative flex items-center gap-2.5 px-5 py-1.5 text-[14px] transition-all duration-150",
"min-w-5 [&>svg]:size-[14px] transition-colors duration-150",
active
? "text-foreground bg-foreground/6"
: "text-foreground/75 dark:text-foreground/60 hover:text-foreground/90 hover:bg-foreground/3",
? "text-foreground"
: "text-foreground/75 dark:text-foreground/60",
)}
>
<span
<item.icon className="text-foreground/75" />
</span>
<span className="truncate grow">
{item.title}
</span>
{item.isNew && (
<Badge
className={cn(
"min-w-5 [&>svg]:size-[14px] transition-colors duration-150",
"pointer-events-none border-dashed rounded-none px-1.5 py-0 text-[9px] uppercase tracking-wider",
active
? "text-foreground"
: "text-foreground/75 dark:text-foreground/60",
? "border-solid bg-foreground/10 text-foreground"
: "text-foreground/70 dark:text-foreground/55 border-foreground/25",
)}
variant="outline"
>
<item.icon className="text-foreground/75" />
</span>
<span className="truncate grow">
{item.title}
</span>
{item.isNew && (
<Badge
className={cn(
"pointer-events-none border-dashed rounded-none px-1.5 py-0 text-[9px] uppercase tracking-wider",
active
? "border-solid bg-foreground/10 text-foreground"
: "text-foreground/70 dark:text-foreground/55 border-foreground/25",
)}
variant="outline"
>
New
</Badge>
)}
</Link>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
New
</Badge>
)}
</Link>
);
})}
</div>
</div>
)}
</div>
))}
</div>
</MotionConfig>
</>
) : (
<>
{/* Back to docs button (when on docs page and switched to nav view) */}
{isDocs && mobileView === "nav" && (
<button
type="button"
onClick={() => setMobileView("docs")}
className="flex items-center gap-2 px-5 py-2.5 text-foreground/65 dark:text-foreground/45 hover:text-foreground/70 transition-colors border-b border-foreground/[0.06]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
</>
) : (
<>
{/* Back to docs button (when on docs page and switched to nav view) */}
{isDocs && mobileView === "nav" && (
<button
type="button"
onClick={() => setMobileView("docs")}
className="flex items-center gap-2 w-full px-5 py-2.5 text-foreground/65 dark:text-foreground/45 hover:text-foreground/70 transition-colors border-b border-foreground/6"
>
<path
fill="currentColor"
d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20z"
/>
</svg>
<span className="font-mono text-[10px] uppercase tracking-wider">
Docs
</span>
</button>
)}
{/* Default nav items */}
{[...navFiles, ...productFiles, ...resourceFiles].map(
(item, i) => (
<motion.div
key={item.name}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.15, delay: i * 0.03 }}
>
<Link
href={item.href}
target={item.external ? "_blank" : undefined}
rel={item.external ? "noreferrer" : undefined}
onClick={() => setMobileMenuOpen(false)}
className={`flex items-center gap-2.5 px-5 py-3.5 border-b border-foreground/[0.06] transition-colors ${
isActive(item.path || item.href) ||
(item.href === "/docs" && isDocs)
? "bg-foreground/[0.04]"
: "hover:bg-foreground/[0.03]"
}`}
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
>
<span
className={`font-mono text-sm uppercase tracking-wider ${
isActive(item.path || item.href) ||
(item.href === "/docs" && isDocs)
? "text-foreground"
: "text-foreground/75 dark:text-foreground/60"
}`}
>
{item.name}
</span>
{item.external && (
<svg
className="h-2.5 w-2.5 text-foreground/45 dark:text-foreground/30 ml-auto"
viewBox="0 0 10 10"
fill="none"
>
<path
d="M1 9L9 1M9 1H3M9 1V7"
stroke="currentColor"
strokeWidth="1.2"
/>
</svg>
)}
</Link>
</motion.div>
),
)}
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: 0.15,
delay:
[...navFiles, ...productFiles, ...resourceFiles]
.length * 0.03,
}}
className="px-5 pt-4"
>
<a
href="https://dash.better-auth.com/sign-in"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center justify-center gap-1.5 w-full py-3 bg-foreground text-background font-mono text-sm uppercase tracking-wider transition-opacity hover:opacity-90"
>
get-started
<svg
className="h-2.5 w-2.5 opacity-50"
viewBox="0 0 10 10"
fill="none"
<path
fill="currentColor"
d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20z"
/>
</svg>
<span className="font-mono text-[10px] uppercase tracking-wider">
Docs
</span>
</button>
)}
{/* Nav items */}
{navFiles.map((item) => (
<Link
key={item.name}
href={item.href}
onClick={() => setMobileMenuOpen(false)}
className={cn(
"flex items-center gap-2.5 px-5 py-3.5 border-b border-foreground/6 transition-colors font-mono text-base uppercase tracking-wider",
isActive(item.path || item.href) ||
(item.href === "/docs" && isDocs)
? "text-foreground bg-foreground/4"
: "text-foreground/75 dark:text-foreground/60 hover:bg-foreground/3",
)}
>
<path
d="M1 9L9 1M9 1H3M9 1V7"
stroke="currentColor"
strokeWidth="1.2"
/>
</svg>
</a>
</motion.div>
</>
{item.name}
</Link>
))}
{/* Accordion groups */}
<Accordion
type="multiple"
defaultValue={[
"products",
...mobileMenuSections
.filter((s) =>
s.children?.some((item) =>
isActivePrefix(item.path || item.href),
),
)
.map((s) => s.name),
]}
className="w-full"
>
{mobileMenuSections.map((section) => (
<AccordionItem
key={section.name}
value={section.name}
className="border-foreground/6"
>
{section.children ? (
<>
<AccordionTrigger className="px-5 py-3.5 font-mono text-base uppercase tracking-wider text-foreground/75 dark:text-foreground/60 hover:text-foreground hover:no-underline">
{section.name}
</AccordionTrigger>
<AccordionContent className="pb-0">
{section.children.map((item) => (
<Link
key={item.name}
href={item.href}
target={
item.external ? "_blank" : undefined
}
rel={
item.external ? "noreferrer" : undefined
}
onClick={() => setMobileMenuOpen(false)}
className={cn(
"flex items-center gap-2.5 pl-9 pr-5 py-2.5 transition-colors font-mono text-sm uppercase tracking-wider",
isActivePrefix(item.path || item.href)
? "text-foreground bg-foreground/4"
: "text-foreground/60 dark:text-foreground/45 hover:text-foreground hover:bg-foreground/3",
)}
>
{item.name}
</Link>
))}
</AccordionContent>
</>
) : (
<Link
href={section.href!}
onClick={() => setMobileMenuOpen(false)}
className={cn(
"flex items-center gap-2.5 px-5 py-3.5 transition-colors font-mono text-base uppercase tracking-wider",
isActive(section.href!)
? "text-foreground bg-foreground/4"
: "text-foreground/75 dark:text-foreground/60 hover:text-foreground",
)}
>
{section.name}
</Link>
)}
</AccordionItem>
))}
</Accordion>
</>
)}
</div>
{/* Sticky footer with sign-in CTA */}
{!(isDocs && mobileView === "docs") && (
<div className="shrink-0 border-t border-foreground/[0.06] bg-background px-5 py-4">
<a
href="https://dash.better-auth.com/sign-in"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center justify-center gap-1.5 w-full py-3 bg-foreground text-background font-mono text-sm uppercase tracking-wider transition-opacity hover:opacity-90"
>
sign-in
<svg
className="h-2.5 w-2.5 opacity-50"
viewBox="0 0 10 10"
fill="none"
>
<path
d="M1 9L9 1M9 1H3M9 1V7"
stroke="currentColor"
strokeWidth="1.2"
/>
</svg>
</a>
</div>
)}
</div>
</motion.div>
+163
View File
@@ -0,0 +1,163 @@
"use client";
import type {
SearchItemType,
SharedProps,
} from "fumadocs-ui/components/dialog/search";
import {
SearchDialog,
SearchDialogClose,
SearchDialogContent,
SearchDialogFooter,
SearchDialogHeader,
SearchDialogInput,
SearchDialogList,
SearchDialogListItem,
SearchDialogOverlay,
useSearch,
} from "fumadocs-ui/components/dialog/search";
import { ArrowRight, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { Client } from "typesense";
import { useTypesenseSearch } from "typesense-fumadocs-adapter/client";
import { usePages } from "@/app/docs/provider";
const typesenseClient = (() => {
const url = process.env.NEXT_PUBLIC_TYPESENSE_SERVER_URL;
const apiKey = process.env.NEXT_PUBLIC_TYPESENSE_SEARCH_API_KEY;
if (!url || !apiKey) {
// Return a dummy client so the app works without Typesense configured
return new Client({
nodes: [{ host: "localhost", port: 8108, protocol: "http" }],
apiKey: "dummy",
});
}
let serverUrl: URL;
try {
serverUrl = new URL(url);
} catch {
return new Client({
nodes: [{ host: "localhost", port: 8108, protocol: "http" }],
apiKey: "dummy",
});
}
return new Client({
nodes: [
{
host: serverUrl.hostname,
port:
Number(serverUrl.port) ||
(serverUrl.protocol === "https:" ? 443 : 80),
protocol: serverUrl.protocol.replace(":", ""),
},
],
apiKey,
});
})();
export default function CustomSearchDialog(props: SharedProps) {
const { search, setSearch, query } = useTypesenseSearch({
typesenseCollectionName: "better-auth-docs",
client: typesenseClient!,
/**
* Non-legacy mode leaves raw <mark> tags in content at the moment,
* which renders as plain text in fumadocs-ui
*/
legacy: true,
});
const pages = usePages();
const router = useRouter();
const pageTreeAction = useMemo<SearchItemType | undefined>(() => {
if (search.length === 0) return;
const normalized = search.toLowerCase();
for (const page of pages) {
if (!page.name.toLowerCase().includes(normalized)) continue;
return {
id: "quick-action",
type: "action",
node: (
<div className="inline-flex items-center gap-2 text-fd-muted-foreground">
<ArrowRight className="size-4" />
<p>
Jump to{" "}
<span className="font-medium text-fd-foreground">
{page.name}
</span>
</p>
</div>
),
onSelect: () => router.push(page.url),
};
}
}, [router, search, pages]);
return (
<SearchDialog
search={search}
onSearchChange={setSearch}
isLoading={query.isLoading}
{...props}
>
<SearchDialogOverlay className="z-200" />
<SearchDialogContent className="z-200">
<SearchDialogHeader>
<LoadingSearchIcon />
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
<SearchDialogList
items={
query.data !== "empty" || pageTreeAction
? [
...(pageTreeAction ? [pageTreeAction] : []),
...(Array.isArray(query.data) ? query.data : []),
]
: null
}
Item={({ item, onClick }) => (
<SearchDialogListItem
item={item}
onClick={onClick}
className={
item.type !== "action"
? "max-h-24 [&>div:last-child]:line-clamp-2"
: undefined
}
/>
)}
/>
<SearchDialogFooter>
<span className="text-xs text-fd-muted-foreground">
Search powered by{" "}
<a
href="https://typesense.org"
target="_blank"
rel="noreferrer noopener"
className="underline hover:text-fd-foreground transition-colors"
>
Typesense
</a>
</span>
</SearchDialogFooter>
</SearchDialogContent>
</SearchDialog>
);
}
function LoadingSearchIcon() {
const { isLoading } = useSearch();
return (
<Search
className={
isLoading
? "size-5 animate-pulse text-foreground duration-400"
: "size-5 text-fd-muted-foreground"
}
/>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import type * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-200 bg-background/50 backdrop-blur-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-200 flex h-auto flex-col bg-background",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className,
)}
{...props}
>
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
+20
View File
@@ -0,0 +1,20 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Textarea };
+45
View File
@@ -0,0 +1,45 @@
import { findPath } from "fumadocs-core/page-tree";
import type { DocumentRecord } from "typesense-fumadocs-adapter";
import { source } from "@/lib/source";
export async function exportSearchIndexes() {
const results: DocumentRecord[] = [];
function isBreadcrumbItem(item: unknown): item is string {
return typeof item === "string" && item.length > 0;
}
for (const page of source.getPages()) {
let breadcrumbs: string[] | undefined;
const pageTree = source.getPageTree(page.locale);
const path = findPath(
pageTree.children,
(node) => node.type === "page" && node.url === page.url,
);
if (path) {
breadcrumbs = [];
path.pop();
if (isBreadcrumbItem(pageTree.name)) {
breadcrumbs.push(pageTree.name);
}
for (const segment of path) {
if (!isBreadcrumbItem(segment.name)) continue;
breadcrumbs.push(segment.name);
}
}
const loaded = await page.data.load();
results.push({
_id: page.url,
structured: loaded.structuredData,
url: page.url,
title: page.data.title,
description: page.data.description,
breadcrumbs,
});
}
return results;
}
+45
View File
@@ -0,0 +1,45 @@
import { Feed } from "feed";
import { blogs } from "./source";
import { baseUrl } from "./utils";
export function getRSS() {
const feed = new Feed({
title: "Better Auth Blog",
description: "Latest updates, articles, and insights about Better Auth",
generator: "better-auth",
id: `${baseUrl}blog`,
link: `${baseUrl}blog`,
language: "en",
image: `${baseUrl}release-og/blogs.png`,
favicon: `${baseUrl}favicon/favicon-32x32.png`,
copyright: `All rights reserved ${new Date().getFullYear()}, Better Auth Inc.`,
});
for (const page of blogs.getPages().sort((a, b) => {
return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
})) {
const url = page.url.replace("blogs/", "blog/");
feed.addItem({
id: page.url,
title: page.data.title,
description: page.data.description,
image: page.data.image
? `${baseUrl}${page.data.image.startsWith("/") ? page.data.image.slice(1) : page.data.image}`
: undefined,
link: `${baseUrl}${url.startsWith("/") ? url.slice(1) : url}`,
date: new Date(page.data.lastModified ?? page.data.date),
author: page.data.author
? [
{
name: page.data.author.name,
avatar: page.data.author.avatar,
link: page.data.author.twitter,
},
]
: [],
});
}
return feed.rss2();
}
+8
View File
@@ -19,3 +19,11 @@ export function mergeRefs<T>(
});
};
}
export const baseUrl =
process.env.NODE_ENV === "development" ||
(!process.env.VERCEL_PROJECT_PRODUCTION_URL && !process.env.VERCEL_URL)
? new URL("http://localhost:3000")
: new URL(
`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL || process.env.VERCEL_URL}`,
);
+8 -1
View File
@@ -7,9 +7,11 @@
"postinstall": "fumadocs-mdx",
"dev": "next dev --turbopack --port 3000",
"build": "next build",
"postbuild": "pnpm sync-typesense",
"start": "next start",
"lint": "next lint",
"lint:fix": "biome check . --fix --unsafe"
"lint:fix": "biome check . --fix --unsafe",
"sync-typesense": "node scripts/sync-typesense.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.44",
@@ -42,6 +44,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"feed": "^5.2.0",
"foxact": "^0.2.52",
"framer-motion": "^12.23.12",
"fumadocs-core": "16.6.7",
@@ -71,7 +74,10 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"three": "^0.183.1",
"typesense": "^3.0.2",
"typesense-fumadocs-adapter": "^0.3.0",
"unist-util-visit": "^5.1.0",
"vaul": "^1.1.2",
"zod": "^4.1.5"
},
"devDependencies": {
@@ -81,6 +87,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/three": "^0.183.1",
"dotenv": "^17.3.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.7",
"typescript": "5.9.3"
+60
View File
@@ -0,0 +1,60 @@
import "dotenv/config";
import * as fs from "node:fs";
import { Client } from "typesense";
import type { DocumentRecord } from "typesense-fumadocs-adapter";
import { sync } from "typesense-fumadocs-adapter";
const url = process.env.NEXT_PUBLIC_TYPESENSE_SERVER_URL;
const adminKey = process.env.TYPESENSE_ADMIN_API_KEY;
if (!url || !adminKey) {
console.log("Typesense env vars not set, skipping sync.");
process.exit(0);
}
if (!process.env.VERCEL) {
console.log("Not running on Production build, skipping sync.");
process.exit(0);
}
if (process.env.VERCEL_GIT_COMMIT_REF !== "main") {
console.log(
`Branch is "${process.env.VERCEL_GIT_COMMIT_REF}", skipping sync.`,
);
process.exit(0);
}
const filePath = ".next/server/app/api/docs/static.json.body";
if (!fs.existsSync(filePath)) {
console.log("Build output not found, run 'pnpm build' first. Skipping sync.");
process.exit(0);
}
const content = fs.readFileSync(filePath);
const records = JSON.parse(content.toString()) as DocumentRecord[];
const serverUrl = new URL(url);
const client = new Client({
nodes: [
{
host: serverUrl.hostname,
port:
Number(serverUrl.port) || (serverUrl.protocol === "https:" ? 443 : 80),
protocol: serverUrl.protocol.replace(":", ""),
},
],
apiKey: adminKey,
connectionTimeoutSeconds: 30,
});
void sync(client, {
typesenseCollectionName: "better-auth-docs",
documents: records,
})
.then(() => {
console.log(`search updated: ${records.length} records`);
})
.catch((error) => {
console.error("Failed to sync Typesense index:", error);
process.exitCode = 1;
});
@@ -622,6 +622,109 @@ describe("session-refresh", () => {
vi.useRealTimers();
});
/**
* https://github.com/better-auth/better-auth/issues/8325
*/
it("should preserve custom session fields on poll refresh", async () => {
vi.useFakeTimers();
const sessionAtom: SessionAtom = atom({
data: {
user: { id: "1", email: "test@test.com" },
session: { id: "session-1" },
},
error: null,
isPending: false,
});
const sessionSignal = atom(false);
const customSessionData = {
user: { id: "1", email: "test@test.com" },
session: { id: "session-1" },
activationInfo: { isActivated: true, activatedAt: "2024-01-01" },
role: "admin",
};
const mockFetch = vi.fn(async () => ({
data: customSessionData,
error: null,
}));
const manager = createSessionRefreshManager({
sessionAtom,
sessionSignal,
$fetch: mockFetch as any,
options: {
sessionOptions: {
refetchInterval: 5,
},
},
});
manager.init();
await vi.advanceTimersByTimeAsync(5000);
const updatedSession = sessionAtom.get();
expect(updatedSession.data).toEqual(customSessionData);
expect((updatedSession.data as any)?.activationInfo).toEqual({
isActivated: true,
activatedAt: "2024-01-01",
});
expect((updatedSession.data as any)?.role).toBe("admin");
manager.cleanup();
vi.useRealTimers();
});
it("should preserve custom session fields on visibilitychange refresh", async () => {
vi.useFakeTimers();
const sessionAtom: SessionAtom = atom({
data: {
user: { id: "1", email: "test@test.com" },
session: { id: "session-1" },
},
error: null,
isPending: false,
});
const sessionSignal = atom(false);
const customSessionData = {
user: { id: "1", email: "test@test.com" },
session: { id: "session-1" },
customField: "custom value",
};
const mockFetch = vi.fn(async () => ({
data: customSessionData,
error: null,
}));
const manager = createSessionRefreshManager({
sessionAtom,
sessionSignal,
$fetch: mockFetch as any,
options: {
sessionOptions: {
refetchOnWindowFocus: true,
},
},
});
manager.init();
manager.triggerRefetch({ event: "visibilitychange" });
await vi.runAllTimersAsync();
const updatedSession = sessionAtom.get();
expect(updatedSession.data).toEqual(customSessionData);
expect((updatedSession.data as any)?.customField).toBe("custom value");
manager.cleanup();
vi.useRealTimers();
});
it("should broadcast session update when broadcastSessionUpdate is called with signout", () => {
const channel = getGlobalBroadcastChannel();
const postSpy = vi.spyOn(channel, "post");
@@ -15,10 +15,12 @@ const now = () => Math.floor(Date.now() / 1000);
const FOCUS_REFETCH_RATE_LIMIT_SECONDS = 5;
export interface SessionRefreshOptions {
sessionAtom: AuthQueryAtom<{
user: User;
session: Session;
}>;
sessionAtom: AuthQueryAtom<
{
user: User;
session: Session;
} & Record<string, any>
>;
sessionSignal: WritableAtom<boolean>;
$fetch: BetterFetch;
options?: BetterAuthClientOptions | undefined;
@@ -34,11 +36,19 @@ interface SessionRefreshState {
unsubscribeOnline?: (() => void) | undefined;
}
interface SessionResponse {
session: Session | null;
user: User | null;
needsRefresh?: boolean;
}
export type SessionResponse = (
| {
session: null;
user: null;
needsRefresh?: boolean;
}
| {
session: Session;
user: User;
needsRefresh?: boolean;
}
) &
Record<string, any>;
export function createSessionRefreshManager(opts: SessionRefreshOptions) {
const { sessionAtom, sessionSignal, $fetch, options = {} } = opts;
@@ -93,10 +103,7 @@ export function createSessionRefreshManager(opts: SessionRefreshOptions) {
} catch {}
}
const sessionData =
data?.session && data?.user
? { session: data.session, user: data.user }
: null;
const sessionData = data?.session && data?.user ? data : null;
sessionAtom.set({
...currentSession,
@@ -395,7 +395,7 @@ export const otp2fa = (options?: OTPOptions | undefined) => {
* ### API Methods
*
* **server:**
* `auth.api.send2FaOTP`
* `auth.api.sendTwoFactorOTP`
*
* **client:**
* `authClient.twoFactor.sendOtp`
@@ -411,7 +411,7 @@ export const otp2fa = (options?: OTPOptions | undefined) => {
* ### API Methods
*
* **server:**
* `auth.api.verifyOTP`
* `auth.api.verifyTwoFactorOTP`
*
* **client:**
* `authClient.twoFactor.verifyOtp`
+82 -27
View File
@@ -441,7 +441,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.45.1
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
mongodb:
specifier: ^7.1.0
version: 7.1.0(socks@2.8.7)
@@ -654,7 +654,7 @@ importers:
version: link:../../../../../packages/better-auth
drizzle-orm:
specifier: ^0.45.1
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
hono:
specifier: ^4.12.4
version: 4.12.5
@@ -844,6 +844,9 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
feed:
specifier: ^5.2.0
version: 5.2.0
foxact:
specifier: ^0.2.52
version: 0.2.53(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -931,9 +934,18 @@ importers:
three:
specifier: ^0.183.1
version: 0.183.2
typesense:
specifier: ^3.0.2
version: 3.0.2(@babel/runtime@7.28.6)
typesense-fumadocs-adapter:
specifier: ^0.3.0
version: 0.3.0(315bbd73201b0530b183e3c1250d4415)
unist-util-visit:
specifier: ^5.1.0
version: 5.1.0
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
zod:
specifier: ^4.1.5
version: 4.3.6
@@ -956,6 +968,9 @@ importers:
'@types/three':
specifier: ^0.183.1
version: 0.183.1
dotenv:
specifier: ^17.3.1
version: 17.3.1
tailwindcss:
specifier: ^4
version: 4.2.1
@@ -1034,7 +1049,7 @@ importers:
version: 0.31.9
drizzle-orm:
specifier: '>=0.41.0'
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
jose:
specifier: ^6.1.3
version: 6.1.3
@@ -1200,7 +1215,7 @@ importers:
version: 17.3.1
drizzle-orm:
specifier: ^0.41.0
version: 0.41.0(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
version: 0.41.0(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
open:
specifier: ^10.2.0
version: 10.2.0
@@ -1313,7 +1328,7 @@ importers:
version: 0.3.1
drizzle-orm:
specifier: ^0.45.1
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
version: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
tsdown:
specifier: 'catalog:'
version: 0.21.0-beta.2(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.19.0)(publint@0.3.17)(synckit@0.11.11)(typescript@5.9.3)
@@ -12482,6 +12497,10 @@ packages:
resolution: {integrity: sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==}
hasBin: true
loglevel@1.9.2:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
long@2.4.0:
resolution: {integrity: sha512-ijUtjmO/n2A5PaosNG9ZGDsQ3vxJg7ZW8vsY8Kp0f2yIZWhSJvjmegV7t+9RPQKxKrvj8yKGehhS+po14hPLGQ==}
engines: {node: '>=0.6'}
@@ -16037,6 +16056,22 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
typesense-fumadocs-adapter@0.3.0:
resolution: {integrity: sha512-IfvGUp4p8nmVa+jVE90C7OIGejGijByenaMl5aAe78iUEyxru2Kqv8sqV5BE8lL3YbUxPaIbKGOI7zUBB+dhfA==}
peerDependencies:
fumadocs-core: ^16.0.0
fumadocs-ui: ^16.0.0
react: '>=19.0.0'
react-dom: '>=19.0.0'
typescript: '>=5'
typesense: '>=2.1.0'
typesense@3.0.2:
resolution: {integrity: sha512-MNspMeIdhcyH0lDVwJX2rVBB3DSXbE1t3k318JC1e1AL1/sRyovi44Aze7n9JzRxnAoFnlhkIB3kWgWGIEozbQ==}
engines: {node: '>=18'}
peerDependencies:
'@babel/runtime': ^7.23.2
ua-parser-js@1.0.41:
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
hasBin: true
@@ -24887,14 +24922,14 @@ snapshots:
msw: 2.12.10(@types/node@25.3.2)(typescript@5.9.3)
vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/mocker@4.0.18(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
'@vitest/mocker@4.0.18(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.10(@types/node@25.3.3)(typescript@5.9.3)
vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/pretty-format@4.0.18':
dependencies:
@@ -26836,7 +26871,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.41.0(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)):
drizzle-orm@0.41.0(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)):
optionalDependencies:
'@cloudflare/workers-types': 4.20260226.1
'@electric-sql/pglite': 0.3.15
@@ -26872,24 +26907,6 @@ snapshots:
postgres: 3.4.8
prisma: 7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)):
optionalDependencies:
'@cloudflare/workers-types': 4.20260226.1
'@electric-sql/pglite': 0.3.15
'@libsql/client': 0.17.0(encoding@0.1.13)
'@opentelemetry/api': 1.9.0
'@prisma/client': 7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
'@types/better-sqlite3': 7.6.13
'@types/pg': 8.16.0
better-sqlite3: 12.6.2
bun-types: 1.3.9
gel: 2.2.0
kysely: 0.28.11
mysql2: 3.18.2(@types/node@25.3.3)
pg: 8.19.0
postgres: 3.4.8
prisma: 7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)):
optionalDependencies:
'@cloudflare/workers-types': 4.20260226.1
@@ -26909,6 +26926,24 @@ snapshots:
prisma: 7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
optional: true
drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)):
optionalDependencies:
'@cloudflare/workers-types': 4.20260226.1
'@electric-sql/pglite': 0.3.15
'@libsql/client': 0.17.0(encoding@0.1.13)
'@opentelemetry/api': 1.9.0
'@prisma/client': 7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
'@types/better-sqlite3': 7.6.13
'@types/pg': 8.16.0
better-sqlite3: 12.6.2
bun-types: 1.3.9
gel: 2.2.0
kysely: 0.28.11
mysql2: 3.18.2(@types/node@25.3.3)
pg: 8.19.0
postgres: 3.4.8
prisma: 7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
drizzle-zod@0.8.3(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.2))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)))(zod@4.3.6):
dependencies:
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.2))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))
@@ -29614,6 +29649,8 @@ snapshots:
yargs: 15.4.1
optional: true
loglevel@1.9.2: {}
long@2.4.0:
optional: true
@@ -35071,6 +35108,24 @@ snapshots:
typescript@5.9.3: {}
typesense-fumadocs-adapter@0.3.0(315bbd73201b0530b183e3c1250d4415):
dependencies:
fumadocs-core: 16.6.7(@mdx-js/mdx@3.1.1)(@oramacloud/client@2.1.4)(@tanstack/react-router@1.163.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(algoliasearch@5.46.2)(lucide-react@0.575.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)
fumadocs-ui: 16.6.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.7(@mdx-js/mdx@3.1.1)(@oramacloud/client@2.1.4)(@tanstack/react-router@1.163.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(algoliasearch@5.46.2)(lucide-react@0.575.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
typescript: 5.9.3
typesense: 3.0.2(@babel/runtime@7.28.6)
typesense@3.0.2(@babel/runtime@7.28.6):
dependencies:
'@babel/runtime': 7.28.6
axios: 1.13.5
loglevel: 1.9.2
tslib: 2.8.1
transitivePeerDependencies:
- debug
ua-parser-js@1.0.41:
optional: true
@@ -35697,7 +35752,7 @@ snapshots:
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18
+10
View File
@@ -16,6 +16,16 @@
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"docs#build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**"],
"passThroughEnv": [
"TYPESENSE_ADMIN_API_KEY",
"INKEEP_API_KEY",
"GITHUB_TOKEN"
]
},
"clean": {},
"format": {
"dependsOn": ["//#format"]