From 492b9a595ec756af9a82989f9e06c6e8d390a10c Mon Sep 17 00:00:00 2001 From: Sandy Date: Sat, 7 Mar 2026 13:11:19 -0300 Subject: [PATCH 01/14] blog: add RSS feed endpoint and generator (#8479) --- landing/app/blog/rss.xml/route.ts | 11 ++++ landing/components/blog/blog-left-panel.tsx | 2 +- landing/lib/rss.ts | 45 ++++++++++++++++ landing/lib/utils.ts | 8 +++ landing/package.json | 1 + pnpm-lock.yaml | 57 +++++++++++---------- 6 files changed, 96 insertions(+), 28 deletions(-) create mode 100644 landing/app/blog/rss.xml/route.ts create mode 100644 landing/lib/rss.ts diff --git a/landing/app/blog/rss.xml/route.ts b/landing/app/blog/rss.xml/route.ts new file mode 100644 index 0000000000..2c7245cdf9 --- /dev/null +++ b/landing/app/blog/rss.xml/route.ts @@ -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", + }, + }); +} diff --git a/landing/components/blog/blog-left-panel.tsx b/landing/components/blog/blog-left-panel.tsx index b1b49f1351..bfcdb6a240 100644 --- a/landing/components/blog/blog-left-panel.tsx +++ b/landing/components/blog/blog-left-panel.tsx @@ -181,7 +181,7 @@ export function BlogLeftPanel({ postCount, post }: BlogLeftPanelProps) { diff --git a/landing/lib/rss.ts b/landing/lib/rss.ts new file mode 100644 index 0000000000..32111fd9f1 --- /dev/null +++ b/landing/lib/rss.ts @@ -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(); +} diff --git a/landing/lib/utils.ts b/landing/lib/utils.ts index 7d63e21d50..8a90058be7 100644 --- a/landing/lib/utils.ts +++ b/landing/lib/utils.ts @@ -19,3 +19,11 @@ export function mergeRefs( }); }; } + +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}`, + ); diff --git a/landing/package.json b/landing/package.json index 48ac0fc880..50a13bc975 100644 --- a/landing/package.json +++ b/landing/package.json @@ -42,6 +42,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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c78bc2487..e46e6d7bfd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) @@ -1034,7 +1037,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 +1203,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 +1316,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) @@ -24887,14 +24890,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 +26839,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 +26875,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 +26894,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)) @@ -35697,7 +35700,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 From 7ffd3b28639807cf8862dbce7602c2fcfe767783 Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Sat, 7 Mar 2026 21:42:13 +0530 Subject: [PATCH 02/14] docs(2fa): fix OTP server API method names (#8477) --- docs/content/docs/plugins/2fa.mdx | 44 +++++++++++++++---- landing/components/api-method.tsx | 23 ++++++++++ .../src/plugins/two-factor/otp/index.ts | 4 +- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/docs/content/docs/plugins/2fa.mdx b/docs/content/docs/plugins/2fa.mdx index 3e1676bbc7..b03c655644 100644 --- a/docs/content/docs/plugins/2fa.mdx +++ b/docs/content/docs/plugins/2fa.mdx @@ -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. - + ```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. - + ```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. - + ```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. - + ```ts type verifyBackupCode = { /** diff --git a/landing/components/api-method.tsx b/landing/components/api-method.tsx index fa2961d5b3..aafee659eb 100644 --- a/landing/components/api-method.tsx +++ b/landing/components/api-method.tsx @@ -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) { diff --git a/packages/better-auth/src/plugins/two-factor/otp/index.ts b/packages/better-auth/src/plugins/two-factor/otp/index.ts index 332370f382..2d394c5a6d 100644 --- a/packages/better-auth/src/plugins/two-factor/otp/index.ts +++ b/packages/better-auth/src/plugins/two-factor/otp/index.ts @@ -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` From 134207ea6001a8c18cde68b02c2c91e25b545db6 Mon Sep 17 00:00:00 2001 From: Sandy Date: Sat, 7 Mar 2026 21:15:12 -0300 Subject: [PATCH 03/14] docs: add better-auth-invite-plugin to community plugins (#7948) --- .cspell/names.txt | 1 + landing/components/community-plugins-table.tsx | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/.cspell/names.txt b/.cspell/names.txt index 56ae105e1e..385f71ca08 100644 --- a/.cspell/names.txt +++ b/.cspell/names.txt @@ -27,3 +27,4 @@ beka guilhermejansen iamjasonkendrick ejirocodes +0-Sandy diff --git a/landing/components/community-plugins-table.tsx b/landing/components/community-plugins-table.tsx index 86f71dccd3..ca83e65ab2 100644 --- a/landing/components/community-plugins-table.tsx +++ b/landing/components/community-plugins-table.tsx @@ -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([]); From 6220a5606ae95cdd13820a08896fd5bbbb108a6f Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Sun, 8 Mar 2026 10:59:33 +0900 Subject: [PATCH 04/14] docs: migrate search to Typesense and redesign docs (#8487) --- landing/.env.example | 9 +- landing/app/api/docs/search/route.ts | 180 ------ landing/app/api/docs/static.json/route.ts | 7 + landing/app/docs/layout.tsx | 29 +- landing/app/docs/provider.tsx | 41 ++ landing/app/globals.css | 8 + landing/app/layout.tsx | 11 +- landing/components/ai-chat.tsx | 573 ++++++++++++++++++ landing/components/docs/custom-sidebar.tsx | 4 +- landing/components/docs/docs-sidebar.tsx | 67 +- landing/components/floating-toolbar.tsx | 33 - .../landing/staggered-nav-files.tsx | 32 +- landing/components/search-dialog.tsx | 158 +++++ landing/components/ui/textarea.tsx | 20 + landing/lib/export-search-indexes.ts | 45 ++ landing/package.json | 7 +- landing/scripts/sync-typesense.ts | 60 ++ pnpm-lock.yaml | 49 ++ 18 files changed, 1069 insertions(+), 264 deletions(-) delete mode 100644 landing/app/api/docs/search/route.ts create mode 100644 landing/app/api/docs/static.json/route.ts create mode 100644 landing/app/docs/provider.tsx create mode 100644 landing/components/ai-chat.tsx delete mode 100644 landing/components/floating-toolbar.tsx create mode 100644 landing/components/search-dialog.tsx create mode 100644 landing/components/ui/textarea.tsx create mode 100644 landing/lib/export-search-indexes.ts create mode 100644 landing/scripts/sync-typesense.ts diff --git a/landing/.env.example b/landing/.env.example index 8f83961f71..9abf53a3f2 100644 --- a/landing/.env.example +++ b/landing/.env.example @@ -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 \ No newline at end of file +NEXT_PUBLIC_URL=http://localhost:3000 diff --git a/landing/app/api/docs/search/route.ts b/landing/app/api/docs/search/route.ts deleted file mode 100644 index fd70234260..0000000000 --- a/landing/app/api/docs/search/route.ts +++ /dev/null @@ -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(); - - 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([]); - } -} diff --git a/landing/app/api/docs/static.json/route.ts b/landing/app/api/docs/static.json/route.ts new file mode 100644 index 0000000000..54bea910d1 --- /dev/null +++ b/landing/app/api/docs/static.json/route.ts @@ -0,0 +1,7 @@ +import { exportSearchIndexes } from "@/lib/export-search-indexes"; + +export const revalidate = false; + +export async function GET() { + return Response.json(await exportSearchIndexes()); +} diff --git a/landing/app/docs/layout.tsx b/landing/app/docs/layout.tsx index 083ea5a7b3..f074f0ccbc 100644 --- a/landing/app/docs/layout.tsx +++ b/landing/app/docs/layout.tsx @@ -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 ( - + @@ -27,8 +29,17 @@ export default function Layout({ children }: { children: ReactNode }) { }} > {children} + + + + Ask AI + + + ⌘I + + + - - + ); } diff --git a/landing/app/docs/provider.tsx b/landing/app/docs/provider.tsx new file mode 100644 index 0000000000..a2684d3fc5 --- /dev/null +++ b/landing/app/docs/provider.tsx @@ -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([]); + +export function usePages() { + return use(PagesContext); +} + +export function DocsProvider({ + pages, + children, +}: { + pages: PageEntry[]; + children: ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/landing/app/globals.css b/landing/app/globals.css index fb3f54bdc0..f57e028190 100644 --- a/landing/app/globals.css +++ b/landing/app/globals.css @@ -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; diff --git a/landing/app/layout.tsx b/landing/app/layout.tsx index 0df9cf4cd1..8d78b488b9 100644 --- a/landing/app/layout.tsx +++ b/landing/app/layout.tsx @@ -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 > - -
- - {children} -
-
+
+ + {children} +
diff --git a/landing/components/ai-chat.tsx b/landing/components/ai-chat.tsx new file mode 100644 index 0000000000..882a56f665 --- /dev/null +++ b/landing/components/ai-chat.tsx @@ -0,0 +1,573 @@ +"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"; + +// ─── Context ───────────────────────────────────────────────────────────────── + +const AIChatContext = createContext<{ + open: boolean; + setOpen: (open: boolean) => void; + chat: ReturnType; +} | null>(null); + +export function useAIChat() { + const ctx = use(AIChatContext); + if (!ctx) throw new Error("Missing "); + 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 ( + ({ chat, open, setOpen }), [chat, open])} + > + {children} + + ); +} + +// ─── Trigger ───────────────────────────────────────────────────────────────── + +export function AIChatTrigger({ + className, + ...props +}: ComponentProps<"button">) { + const { open, setOpen } = useAIChat(); + + return ( +
+ + ); +} + +// ─── Panel Messages ────────────────────────────────────────────────────────── + +function PanelMessages({ className, ...props }: ComponentProps<"div">) { + const { messages } = useChatContext(); + const containerRef = useRef(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 ( +
+ {filtered.length === 0 ? ( +
+ +

Start a new chat below.

+
+ ) : ( +
+ {filtered.map((item, index) => ( + + ))} +
+ )} +
+ ); +} + +// ─── Panel Input ───────────────────────────────────────────────────────────── + +function PanelInput() { + const { status, sendMessage, stop, setMessages, messages, regenerate } = + useChatContext(); + const [input, setInput] = useState(""); + const textareaRef = useRef(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 ( +
+ {/* Drag handle to resize input */} +
+
+
+
+
+
+