diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 68248161ff..bd0a2c9703 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -61,7 +61,9 @@ This repository powers the Langfuse website hosted on `langfuse.com`, including ## Key config files - `next.config.mjs` — Next.js config and redirects. -- `theme.config.tsx` — Nextra theme configuration. +- `source.config.ts` — declares all Fumadocs content collections (docs, blog, changelog, integrations, marketing, …). +- `lib/source.ts` — exports a `loader` for each collection. +- `lib/section-registry.ts` — maps URL slugs to layout types; all derived routing sets live here. Do not hardcode slugs elsewhere. - `tailwind.config.js` — Tailwind setup. - `components.json` — shadcn/ui component config. diff --git a/.agents/cursor/rules/_general-rules.mdc b/.agents/cursor/rules/_general-rules.mdc index 6f4a57e78e..811148478e 100644 --- a/.agents/cursor/rules/_general-rules.mdc +++ b/.agents/cursor/rules/_general-rules.mdc @@ -7,11 +7,12 @@ alwaysApply: true # General rules - This is the documentation for the Langfuse website. -- We use Nextra.site, v3, docs: https://nextra-v2-7hslbun8z-shud.vercel.app/ -- All pages are in the /pages folder and rendered by Nextra.site. +- We use Fumadocs (App Router). Docs: https://www.fumadocs.dev/docs +- Content lives in the /content folder, organized by section (docs, blog, changelog, guides, etc.). +- App routes are in the /app folder. - Reusable markdown components are in the /components-mdx folder. - The devserver is running on http://localhost:3333. ## Frontend -- When using tailwindcss, never use explicit colors, always use the default semantic color tokens introduced by shadcn/ui. +- When using tailwindcss, never use explicit colors, always use the default semantic color tokens. diff --git a/.agents/skills/customer-story-setup/SKILL.md b/.agents/skills/customer-story-setup/SKILL.md index 7d6482cd23..67aa8cb42e 100644 --- a/.agents/skills/customer-story-setup/SKILL.md +++ b/.agents/skills/customer-story-setup/SKILL.md @@ -1,7 +1,7 @@ --- name: customer-story-setup description: >- - Converts draft customer-story Markdown into Langfuse website MDX (Nextra), + Converts draft customer-story Markdown into Langfuse website MDX (Fumadocs), collects missing metadata and assets, wires meta.json and authors. Use when adding or converting a customer case study, user story, or /users page, or when the user mentions customer story setup, cresta/canva-style posts, or diff --git a/.github/workflows/nextjs_bundle_analysis.yml b/.github/workflows/nextjs_bundle_analysis.yml index 408075c232..94c59be5c4 100644 --- a/.github/workflows/nextjs_bundle_analysis.yml +++ b/.github/workflows/nextjs_bundle_analysis.yml @@ -38,19 +38,6 @@ jobs: - run: pnpm install - # DO NOT CACHE DUE TO NEXTRA CACHING ISSUES - # - name: Restore next build - # uses: actions/cache@v3 - # id: restore-build-cache - # env: - # cache-name: cache-next-build - # with: - # # if you use a custom build directory, replace all instances of `.next` in this file with your build directory - # # ex: if your app builds to `dist`, replace `.next` with `dist` - # path: .next/cache - # # change this if you prefer a more strict cache - # key: ${{ runner.os }}-build-${{ env.cache-name }} - - name: Build next.js app # change this if your site requires a custom build command run: ./node_modules/.bin/next build diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index 8ca85b4af7..0000000000 --- a/MIGRATION.md +++ /dev/null @@ -1,98 +0,0 @@ -# Nextra → Fumadocs + App Router Migration - -This document describes the migration from Nextra (Pages Router) to Fumadocs (App Router) and the remaining steps to complete it. - -## What’s done - -1. **Dependencies** - - Replaced `nextra` and `nextra-theme-docs` with `fumadocs-core`, `fumadocs-mdx`, and `fumadocs-ui`. - - Added `@types/mdx` in devDependencies. - -2. **Config** - - **`source.config.ts`** – Fumadocs MDX config: `defineDocs({ dir: 'content/docs' })`, `remarkGfm`, `providerImportSource: '@/mdx-components'`. - - **`lib/source.ts`** – Fumadocs source: `loader()` with `docs.toFumadocsSource()`, `baseUrl: '/docs'`. - - **`next.config.mjs`** – Removed Nextra; wrapped config with `createMDX()` from `fumadocs-mdx/next`. All existing options (headers, redirects, rewrites, images, etc.) are unchanged. - - **`tsconfig.json`** – Added path `"fumadocs-mdx:collections/*": [".source/*"]`. - -3. **App Router** - - **`app/layout.tsx`** – Root layout: `RootProvider` (from `fumadocs-ui/provider/next`), Geist fonts, global CSS (`style.css`, overrides, Vidstack). - - **`app/page.tsx`** – Temporary home: title + link to `/docs`. Replace with the real landing (e.g. current ``) when ready. - - **`app/docs/layout.tsx`** – `DocsLayout` with `source.getPageTree()`, nav title “Langfuse”, GitHub link. - - **`app/docs/[[...slug]]/page.tsx`** – Doc page: `source.getPage(slug)`, `page.data.load()`, `DocsPage` + `DocsBody` with MDX and `getMDXComponents()`; `generateMetadata` and `generateStaticParams`. - -4. **MDX** - - **`mdx-components.tsx`** – `getMDXComponents` / `useMDXComponents` using `fumadocs-ui/mdx`. - -5. **Content** - - **`content/docs/`** – New Fumadocs content root: - - `meta.json` – root meta with `pages: ["index", "observability"]`. - - `index.mdx` – overview doc. - - `observability/meta.json` and `observability/overview.mdx` – sample observability doc. - -## Install and peer deps - -- Fumadocs MDX 13 expects **Next 15.3+** and **fumadocs-core 15+**. If you stay on Next 15.2.x, use: - - `pnpm install --legacy-peer-deps` (or `npm install --legacy-peer-deps`). -- Optional: upgrade to Next `^15.3.0` to satisfy peer deps without `--legacy-peer-deps`. - -## Remaining steps - -### 1. Content migration (docs) - -- **Done.** Docs live in `content/docs/` with Fumadocs `meta.json`. The former `pages/docs/` content was migrated; `docs-nextra-backup` was removed after verification. - -### 2. Other sections (blog, changelog, guides, etc.) — done - -- **Done.** All main sections are on App Router: - - **Fumadocs collections** in `source.config.ts`: `selfHosting`, `blog`, `changelog`, `guides`, `faq`, `integrations`, `security`, `library`, `customers`, `handbook`. - - **Content** in `content/
/`. - - **App routes**: `app/docs/` (docs), `app/blog/`, `app/changelog/` (index + layout), and `app/[section]/[[...slug]]` for self-hosting, guides, faq, integrations, security, library, customers, handbook. - - **Client loaders** for section MDX: `lib/section-loaders.generated.ts` (generated in prebuild via `scripts/generate-section-loaders.js`). - - **Index components** (`BlogIndex`, `ChangelogIndex`) use `getPagesUnderRoute` from nextra-shim, which now reads from Fumadocs sources via `getPagesForRoute` in `lib/source.ts`. -- **Static marketing pages** are in `content/marketing/` and served via `app/[section]` (section = about, careers, pricing, etc.). - -### 3. Replace Nextra usage in components - -These (and any others that import from Nextra) need to be updated or replaced: - -| File | Nextra usage | Fumadocs / replacement | -|------|--------------|-------------------------| -| `theme.config.tsx` | Entire file | No longer used. Nav/sidebar/footer are in `app/docs/layout.tsx` and Fumadocs layout props. Replicate any custom nav/links in `DocsLayout` (e.g. `nav`, `sidebar`, `links`). | -| `components/*` using `getPagesUnderRoute`, `Page`, `useConfig`, `useTheme` | `nextra/context`, `nextra-theme-docs` | Use `source` from `@/lib/source`: e.g. `source.getPages()`, filter by path; or build custom indexes from `source.getPageTree()`. For theme, use `next-themes` or Fumadocs theme. | -| `nextra/components`: `Cards`, `Tabs`, `Callout` | Nextra components | Use Fumadocs UI: `fumadocs-ui/components/card`, `fumadocs-ui/components/tabs`, `fumadocs-ui/components/callout`, or keep local wrappers that match Fumadocs props. | - -Notable components to refactor: - -- `components/home/*` (Changelog, etc.) – replace `getPagesUnderRoute` with `source` + path filter. -- `components/blog/BlogIndex.tsx`, `components/changelog/ChangelogIndex.tsx` – same. -- `components/faq/FaqIndex.tsx`, `components/customers/CustomerIndex.tsx`, `components/CookbookIndex.tsx`, `components/VideoIndex.tsx`, `components/integrations/IntegrationIndex.tsx` – same. -- `components/MainContentWrapper.tsx`, `components/LangTabs.tsx`, etc. – remove `useConfig` / Nextra theme; use Fumadocs layout or local state. -- `components/not-found-animation.tsx`, `components/MetabaseDashboard.tsx`, `components/inkeep/useInkeepSettings.ts` – replace `useTheme` with `next-themes` or Fumadocs theme. - -### 4. Home and global providers — done - -- **`app/page.tsx`** – Uses real `` from `@/components/home`. -- **Analytics and scripts** – In `app/layout.tsx`: PostHog (via `@/components/analytics/PostHogProvider`), Hubspot, and Cookieyes scripts (production only). - -### 5. Remove Pages Router — done - -- **Done.** The `pages/` directory has been removed. The site uses **App Router only**. -- All routes are under `app/`; content lives under `content/`. API routes are under `app/api/`. - -### 6. Optional: Fumadocs search - -- Configure search (e.g. Fumadocs search or Inkeep) in the root layout or docs layout (e.g. `RootProvider` or `DocsLayout` search options), and remove any Nextra search wiring. - -## Running the project - -- Generate Fumadocs source (usually automatic with Next): - - `npx fumadocs-mdx` (or add to `postinstall` if you want types on install). -- Dev: `pnpm dev` (or `npm run dev`). -- Build: `pnpm build` (or `npm run build`). If you hit peer dependency errors, use `pnpm install --legacy-peer-deps` or upgrade Next as above. - -## Reference - -- [Fumadocs – Get started](https://fumadocs.dev/docs) -- [Fumadocs MDX – Next.js](https://fumadocs.dev/docs/mdx/next) -- [Fumadocs UI – Docs layout](https://fumadocs.dev/docs/ui/layouts/docs) -- [Fumadocs navigation / meta](https://fumadocs.dev/docs/navigation) diff --git a/app/(home)/layout.tsx b/app/(home)/layout.tsx index 223ef94391..d489bc29a0 100644 --- a/app/(home)/layout.tsx +++ b/app/(home)/layout.tsx @@ -1,9 +1,9 @@ -import { Layout } from "@/components/layout"; +import { HomeLayout } from "@/components/layout"; export default function HomeLayoutRoute({ children, }: { children: React.ReactNode; }) { - return {children}; + return {children}; } diff --git a/app/(home)/page.tsx b/app/(home)/page.tsx index f3144cbe77..fa04ff7c3d 100644 --- a/app/(home)/page.tsx +++ b/app/(home)/page.tsx @@ -1,46 +1,7 @@ import { Home } from "@/components/home"; -import { usersSource, changelogSource } from "@/lib/source"; -import { sortCustomerStoriesByMetaOrder } from "@/lib/sortCustomerStoriesByMeta"; -import type { CustomerStory } from "@/components/customers/CustomerCarousel"; -import type { ChangelogItem } from "@/components/home/Changelog"; export default function HomePage() { - const customerStories: CustomerStory[] = sortCustomerStoriesByMetaOrder( - usersSource.getPages().map((page) => ({ - route: page.url, - frontMatter: { - title: page.data.title, - description: page.data.description, - customerLogo: page.data.customerLogo ?? undefined, - customerLogoDark: page.data.customerLogoDark ?? undefined, - customerQuote: page.data.customerQuote ?? undefined, - quoteAuthor: page.data.quoteAuthor ?? undefined, - quoteRole: page.data.quoteRole ?? undefined, - quoteCompany: page.data.quoteCompany ?? undefined, - quoteAuthorImage: page.data.quoteAuthorImage ?? undefined, - showInCustomerIndex: page.data.showInCustomerIndex ?? undefined, - }, - })), - ); - - const changelogItems: ChangelogItem[] = changelogSource - .getPages() - .filter((page) => page.data.title && page.data.date) - .sort( - (a, b) => - new Date(a.data.date as string).getTime() - - new Date(b.data.date as string).getTime() - ) - .reverse() - .slice(0, 20) - .map((page) => ({ - route: page.url, - title: page.data.title ?? null, - author: (page.data.author as string | undefined) ?? null, - date: new Date(page.data.date as string).toISOString(), - })); - return ( - + ); } diff --git a/app/(pricing)/layout.tsx b/app/(pricing)/layout.tsx new file mode 100644 index 0000000000..b368d13866 --- /dev/null +++ b/app/(pricing)/layout.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; +import { HomeLayout } from "@/components/layout"; + +/** + * Shared layout for the pricing pages (/pricing and /pricing-self-host). + * Renders the default HomeLayout but without the right aside — the + * pricing content spans the full main area under the navbar. + */ +export default function PricingLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/(pricing)/pricing-self-host/page.tsx b/app/(pricing)/pricing-self-host/page.tsx new file mode 100644 index 0000000000..e2a0b0beb6 --- /dev/null +++ b/app/(pricing)/pricing-self-host/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import { PricingPage } from "@/components/home/pricing"; + +export const metadata: Metadata = { + title: "Self-Hosted Pricing", + description: + "Deploy Langfuse OSS today. Upgrade to Enterprise at any time.", +}; + +export default function PricingSelfHost() { + return ; +} diff --git a/app/(pricing)/pricing/page.tsx b/app/(pricing)/pricing/page.tsx new file mode 100644 index 0000000000..b9975229b6 --- /dev/null +++ b/app/(pricing)/pricing/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import { PricingPage } from "@/components/home/pricing"; + +export const metadata: Metadata = { + title: "Pricing", + description: + "Simple pricing for projects of all sizes. Get started on our Hobby plan without a credit card.", +}; + +export default function Pricing() { + return ; +} diff --git a/app/(wide)/WideSectionPage.tsx b/app/(wide)/WideSectionPage.tsx deleted file mode 100644 index d05f21e2a5..0000000000 --- a/app/(wide)/WideSectionPage.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { Metadata } from "next"; -import { notFound } from "next/navigation"; -import { SECTION_CONFIG, WIDE_SECTIONS } from "@/lib/source"; -import type { WideSectionSlug } from "@/lib/source"; -import { buildOgImageUrl, buildPageUrl } from "@/lib/og-url"; -import { getMDXComponents } from "@/mdx-components"; -import type { ComponentType } from "react"; - -export async function generateWideSectionMetadata(section: WideSectionSlug): Promise { - const config = SECTION_CONFIG[section as keyof typeof SECTION_CONFIG]; - const page = config.source.getPage([section]); - if (!page) return { title: "Not Found" }; - const pageData = page.data as typeof page.data & { - seoTitle?: string | null; - ogImage?: string | null; - }; - const canonicalUrl = buildPageUrl(`/${section}`); - const seoTitle = pageData.seoTitle || page.data.title; - const sectionTitle = config.title ?? seoTitle; - const ogImage = buildOgImageUrl({ - title: seoTitle, - description: page.data.description, - section: sectionTitle, - staticOgImage: pageData.ogImage, - }); - return { - title: seoTitle, - description: page.data.description ?? undefined, - alternates: { canonical: canonicalUrl }, - openGraph: { images: [{ url: ogImage }], url: canonicalUrl }, - twitter: { images: [{ url: ogImage }] }, - }; -} - -type Props = { section: WideSectionSlug }; - -export default async function WideSectionPage({ section }: Props) { - if (!WIDE_SECTIONS.has(section)) notFound(); - const config = SECTION_CONFIG[section as keyof typeof SECTION_CONFIG]; - const page = config.source.getPage([section]); - if (!page) notFound(); - - const data = page.data as { - load?: () => Promise<{ body: unknown }>; - body?: unknown; - }; - const body = - typeof data.load === "function" ? (await data.load()).body : data.body; - const MDX = body as ComponentType<{ components?: Record }>; - - const hasProse = section === "startups"; - - return ( -
- -
- ); -} diff --git a/app/(wide)/layout.tsx b/app/(wide)/layout.tsx deleted file mode 100644 index 1a0693d3ad..0000000000 --- a/app/(wide)/layout.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Layout } from "@/components/layout"; -import { MainContentWrapper } from "@/components/MainContentWrapper"; - -/** - * Layout for wide marketing sections (pricing, enterprise, etc.) that have - * their own route folders. No sidebar, no prose, max-w-7xl content. - */ -export default function WideSectionLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - -
- {children} -
-
- ); -} diff --git a/app/(wide)/pricing-self-host/page.tsx b/app/(wide)/pricing-self-host/page.tsx deleted file mode 100644 index 2df2f0e915..0000000000 --- a/app/(wide)/pricing-self-host/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Metadata } from "next"; -import WideSectionPage, { generateWideSectionMetadata } from "../WideSectionPage"; - -export async function generateMetadata(): Promise { - return generateWideSectionMetadata("pricing-self-host"); -} - -export default function Page() { - return ; -} diff --git a/app/(wide)/pricing/page.tsx b/app/(wide)/pricing/page.tsx deleted file mode 100644 index 4e2bcf7c86..0000000000 --- a/app/(wide)/pricing/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Metadata } from "next"; -import WideSectionPage, { generateWideSectionMetadata } from "../WideSectionPage"; - -export async function generateMetadata(): Promise { - return generateWideSectionMetadata("pricing"); -} - -export default function Page() { - return ; -} diff --git a/app/(wide)/startups/page.tsx b/app/(wide)/startups/page.tsx deleted file mode 100644 index 24f0a0b4f2..0000000000 --- a/app/(wide)/startups/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Metadata } from "next"; -import WideSectionPage, { generateWideSectionMetadata } from "../WideSectionPage"; - -export async function generateMetadata(): Promise { - return generateWideSectionMetadata("startups"); -} - -export default function Page() { - return ; -} diff --git a/app/(wide)/talk-to-us/page.tsx b/app/(wide)/talk-to-us/page.tsx deleted file mode 100644 index e5dde1897d..0000000000 --- a/app/(wide)/talk-to-us/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Metadata } from "next"; -import WideSectionPage, { generateWideSectionMetadata } from "../WideSectionPage"; - -export async function generateMetadata(): Promise { - return generateWideSectionMetadata("talk-to-us"); -} - -export default function Page() { - return ; -} diff --git a/app/(wide)/watch-demo/page.tsx b/app/(wide)/watch-demo/page.tsx deleted file mode 100644 index 1548880cd0..0000000000 --- a/app/(wide)/watch-demo/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Metadata } from "next"; -import WideSectionPage, { generateWideSectionMetadata } from "../WideSectionPage"; - -export async function generateMetadata(): Promise { - return generateWideSectionMetadata("watch-demo"); -} - -export default function Page() { - return ; -} diff --git a/app/[section]/SectionLayoutWrapper.tsx b/app/[section]/SectionLayoutWrapper.tsx deleted file mode 100644 index e304d4e7fa..0000000000 --- a/app/[section]/SectionLayoutWrapper.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import { SidebarProvider } from "fumadocs-ui/components/sidebar/base"; -import type { ReactNode } from "react"; - -/** - * Thin client wrapper that provides SidebarProvider. - * DocsLayout is passed as {children} (from the server layout), so it runs in the - * server context and its LayoutContextProvider propagates correctly to DocsPage. - */ -export function SectionLayoutWrapper({ children }: { children: ReactNode }) { - return ( -
- {children} -
- ); -} diff --git a/app/[section]/[[...slug]]/page.tsx b/app/[section]/[[...slug]]/page.tsx index 83714e835b..d529360dfd 100644 --- a/app/[section]/[[...slug]]/page.tsx +++ b/app/[section]/[[...slug]]/page.tsx @@ -1,21 +1,18 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { DocsPage, DocsBody } from "fumadocs-ui/page"; -import type { TOCItemType } from "fumadocs-core/toc"; -import { SECTION_CONFIG, SECTION_SLUGS, MARKETING_SECTION_SLUGS, WIDE_SECTIONS, DOCS_STYLE_APP_SECTIONS, POST_SECTIONS, CHANGELOG_SECTIONS } from "@/lib/source"; -import type { SectionSlug } from "@/lib/source"; -import { MARKETING_SLUGS, usersSource, changelogSource } from "@/lib/source"; -import { sortCustomerStoriesByMetaOrder } from "@/lib/sortCustomerStoriesByMeta"; -import { buildOgImageUrl, buildPageUrl } from "@/lib/og-url"; -import { DocsContributors } from "@/components/DocsContributors"; -import { DocBodyChrome } from "@/components/DocBodyChrome"; +import { + SECTION_CONFIG, + DEDICATED_APP_SECTIONS, + MARKETING_SLUGS, + SECTION_SLUGS, + MARKETING_SECTIONS, +} from "@/lib/section-registry"; +import { loadPage, buildSectionMetadata, primitiveOnly } from "@/lib/mdx-page"; import { getMDXComponents } from "@/mdx-components"; -import type { ComponentType } from "react"; -import { FaqPreview } from "@/components/faq/FaqPreview"; -import { formatTag } from "@/components/faq/FaqIndex"; -import { ChangelogFrontMatterProvider } from "@/components/changelog/ChangelogFrontMatterContext"; -import type { ChangelogFrontMatter } from "@/components/changelog/ChangelogFrontMatterContext"; import { WrappedDataProvider } from "@/components/wrapped/WrappedDataContext"; +import { DocBodyChrome } from "@/components/DocBodyChrome"; +import { usersSource, changelogSource } from "@/lib/source"; +import { sortCustomerStoriesByMetaOrder } from "@/lib/sortCustomerStoriesByMeta"; type PageProps = { params: Promise<{ section: string; slug?: string[] }>; @@ -25,121 +22,49 @@ export default async function SectionDocPage(props: PageProps) { const params = await props.params; const { section, slug: slugParam } = params; const slug = slugParam ?? []; - const isMarketing = MARKETING_SECTION_SLUGS.has(section as (typeof MARKETING_SLUGS)[number]); - const isPost = POST_SECTIONS.has(section); - const isChangelog = CHANGELOG_SECTIONS.has(section); - const isCollectionIndex = section === "users" && slug.length === 0; + const isMarketing = MARKETING_SECTIONS.has(section as (typeof MARKETING_SLUGS)[number]); const effectiveSlug = isMarketing ? [section] : slug; - if (!SECTION_SLUGS.includes(section as SectionSlug)) { - notFound(); - } - if (WIDE_SECTIONS.has(section)) { - notFound(); /* wide sections are served by app/(wide)/
/page.tsx */ - } - const config = SECTION_CONFIG[section as keyof typeof SECTION_CONFIG]; - const page = config.source.getPage(effectiveSlug); - - // Special case: /faq/tag/[tag] pages without dedicated content files - if (!page && section === "faq" && slug.length === 2 && slug[0] === "tag") { - const tag = decodeURIComponent(slug[1]); - return ( - - -

FAQ: {formatTag(tag)}

- -
-
- ); - } - - if (!page) notFound(); - - const data = page.data as typeof page.data & { - load?: () => Promise<{ body: unknown; toc: TOCItemType[] }>; - toc?: TOCItemType[]; - }; - const loaded = - typeof data.load === "function" - ? await data.load() - : { body: data.body, toc: data.toc ?? [] }; - const toc: TOCItemType[] = loaded.toc ?? []; - - const MDX = loaded.body as ComponentType<{ components?: Record }>; - const bodyClient = ( - - - - ); - - // Strip functions and non-serializable objects from page.data / frontMatter - // before passing to client component context providers. - function primitiveOnly(obj: Record): Record { - return Object.fromEntries( - Object.entries(obj).filter(([, v]) => - v === null || - typeof v === "string" || - typeof v === "number" || - typeof v === "boolean" || - (Array.isArray(v) && v.every((item) => typeof item !== "function")) - ) - ); - } + if (!SECTION_SLUGS.includes(section)) notFound(); + if (DEDICATED_APP_SECTIONS.has(section)) notFound(); - // Wrap with context providers so client components in MDX can receive - // server-fetched data without importing lib/source themselves. - const bodyWithContext = isChangelog ? ( - ) as ChangelogFrontMatter} - > - {bodyClient} - - ) : section === "wrapped" ? ( - ({ + const config = SECTION_CONFIG[section as keyof typeof SECTION_CONFIG]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await loadPage(config.source, effectiveSlug); + if (!result) notFound(); + const { MDX } = result; + + let bodyClient = ; + + if (section === "wrapped") { + bodyClient = ( + ({ + route: p.url, + name: p.data.title, + title: p.data.title, + frontMatter: primitiveOnly(p.data as unknown as Record), + })), + ), + changelogPages: changelogSource.getPages().map((p) => ({ route: p.url, name: p.data.title, title: p.data.title, frontMatter: primitiveOnly(p.data as unknown as Record), })), - ), - changelogPages: changelogSource.getPages().map((p) => ({ - route: p.url, - name: p.data.title, - title: p.data.title, - frontMatter: primitiveOnly(p.data as unknown as Record), - })), - }} - > - {bodyClient} - - ) : ( - bodyClient - ); + }} + > + {bodyClient} + + ); + } return ( - }} - > - {bodyWithContext} - +
+ {bodyClient} +
); } @@ -147,68 +72,25 @@ export async function generateMetadata(props: PageProps): Promise { const params = await props.params; const { section, slug: slugParam } = params; const slug = slugParam ?? []; - const isMarketing = MARKETING_SECTION_SLUGS.has(section as (typeof MARKETING_SLUGS)[number]); + const isMarketing = MARKETING_SECTIONS.has(section); const effectiveSlug = isMarketing ? [section] : slug; - if (!SECTION_SLUGS.includes(section as SectionSlug) || WIDE_SECTIONS.has(section)) { + if (!SECTION_SLUGS.includes(section)) { return { title: "Not Found" }; } - const config = SECTION_CONFIG[section as keyof typeof SECTION_CONFIG]; + const config = SECTION_CONFIG[section]; const page = config.source.getPage(effectiveSlug); - // Metadata for dynamic /faq/tag/[tag] pages - if (!page && section === "faq" && slug.length === 2 && slug[0] === "tag") { - const tag = decodeURIComponent(slug[1]); - const title = `FAQ: ${formatTag(tag)}`; - return { title, description: `Frequently asked questions about ${formatTag(tag)}.` }; - } - if (!page) return { title: "Not Found" }; - const pageData = page.data as typeof page.data & { - canonical?: string | null; - noindex?: boolean | null; - seoTitle?: string | null; - ogImage?: string | null; - ogVideo?: string | null; - }; - const pagePath = isMarketing - ? `/${section}` - : `/${section}${slug.length > 0 ? `/${slug.join("/")}` : ""}`; - const canonicalUrl = pageData.canonical ?? buildPageUrl(pagePath); - const seoTitle = pageData.seoTitle || page.data.title; - const ogImage = buildOgImageUrl({ - title: seoTitle, - description: page.data.description, - section: config.title, - staticOgImage: pageData.ogImage, - }); - // ogVideo may be an absolute URL (https://...) or a site-relative path (/images/...) - const ogVideoUrl = pageData.ogVideo - ? pageData.ogVideo.startsWith("http") - ? pageData.ogVideo - : buildPageUrl(pageData.ogVideo) - : null; - return { - title: seoTitle, - description: page.data.description ?? undefined, - alternates: { canonical: canonicalUrl }, - ...(pageData.noindex ? { robots: { index: false, follow: true } } : {}), - openGraph: { - images: [{ url: ogImage }], - url: canonicalUrl, - ...(ogVideoUrl ? { videos: [{ url: ogVideoUrl }] } : {}), - }, - twitter: { images: [{ url: ogImage }] }, - }; + return buildSectionMetadata(page as any, section, config.title, effectiveSlug); } export function generateStaticParams() { const params: { section: string; slug?: string[] }[] = []; for (const section of SECTION_SLUGS) { - if (DOCS_STYLE_APP_SECTIONS.has(section)) continue; - if (WIDE_SECTIONS.has(section)) continue; /* handled by app/(wide)/
/page.tsx */ + if (DEDICATED_APP_SECTIONS.has(section)) continue; const config = SECTION_CONFIG[section]; - const isMarketing = MARKETING_SECTION_SLUGS.has(section as (typeof MARKETING_SLUGS)[number]); + const isMarketing = MARKETING_SECTIONS.has(section); if (isMarketing) { params.push({ section }); } else { @@ -219,18 +101,5 @@ export function generateStaticParams() { } } - // Add dynamic /faq/tag/[tag] pages for tags without dedicated content files - const faqConfig = SECTION_CONFIG["faq"]; - const allTags = new Set(); - for (const p of faqConfig.source.getPages()) { - const tags = ((p.data as unknown as Record).tags as string[] | undefined) ?? []; - for (const t of tags) allTags.add(t); - } - for (const tag of Array.from(allTags)) { - if (!faqConfig.source.getPage(["tag", tag])) { - params.push({ section: "faq", slug: ["tag", tag] }); - } - } - return params; } diff --git a/app/[section]/layout.tsx b/app/[section]/layout.tsx index 5bb5e915b9..45783e385e 100644 --- a/app/[section]/layout.tsx +++ b/app/[section]/layout.tsx @@ -1,93 +1,31 @@ import { use } from "react"; import { notFound } from "next/navigation"; -import { Layout } from "@/components/layout"; -import { DocsLayout } from "fumadocs-ui/layouts/docs"; +import { HomeLayout } from "@/components/layout"; import { - SECTION_CONFIG, SECTION_SLUGS, - DOCS_STYLE_APP_SECTIONS, - MARKETING_SECTION_SLUGS, - WIDE_SECTIONS, - POST_SECTIONS, - CHANGELOG_SECTIONS, - getPageTreeWithShortTitles, -} from "@/lib/source"; -import { MenuSwitcher } from "@/components/MenuSwitcher"; -import { MainContentWrapper } from "@/components/MainContentWrapper"; -import { ThemeToggle } from "@/components/ThemeToggle"; -import { SectionLayoutWrapper } from "./SectionLayoutWrapper"; + DEDICATED_APP_SECTIONS, +} from "@/lib/section-registry"; type LayoutProps = { children: React.ReactNode; params: Promise<{ section: string }>; }; -const contentWrapperClass = "mx-auto w-full max-w-4xl"; - -// Synchronous server component — keeps the same RSC context-propagation behaviour -// as app/docs/layout.tsx (which is also sync). Using React.use() to unwrap the -// Next.js 15 params Promise without making the component async. +/** + * Catch-all section layout. + * Handles non-dedicated sections that still route through app/[section] + * (e.g. handbook + marketing MDX pages). Dedicated sections have their + * own app routes and are excluded here. + */ export default function SectionLayout({ children, params }: LayoutProps) { const { section } = use(params); - if (!SECTION_SLUGS.includes(section as (typeof SECTION_SLUGS)[number])) { + if (!SECTION_SLUGS.includes(section)) { notFound(); } - if (DOCS_STYLE_APP_SECTIONS.has(section)) { + if (DEDICATED_APP_SECTIONS.has(section)) { notFound(); } - if (WIDE_SECTIONS.has(section)) { - notFound(); /* wide sections are served by app/(wide)/
/page.tsx */ - } - - const config = SECTION_CONFIG[section as keyof typeof SECTION_CONFIG]; - const tree = getPageTreeWithShortTitles(config.source, `/${section}`); - - const isMarketing = MARKETING_SECTION_SLUGS.has( - section as Parameters[0] - ); - const isPost = POST_SECTIONS.has(section); - const isChangelog = CHANGELOG_SECTIONS.has(section); - // Render DocsLayout from the server component so its LayoutContextProvider - // correctly propagates context to DocsPage in the page component. - // SectionLayoutWrapper is a thin "use client" wrapper for SidebarProvider only. - return ( - - - } - } - themeSwitch={isMarketing || isPost ? { enabled: false } : { component:
}} - searchToggle={{ enabled: false }} - containerProps={ - isMarketing || isChangelog - ? // Force --fd-toc-width:0 so the docs grid doesn't reserve a phantom - // 268px TOC column (written to the grid by DocsPage's article via CSS :has()). - ({ style: { "--fd-toc-width": "0px" } } as React.ComponentProps< - typeof DocsLayout - >["containerProps"]) - : undefined - } - > - {isMarketing || isChangelog ? ( -
-
- {children} -
-
- ) : ( - children - )} -
-
-
- ); + return {children}; } diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000000..3971569496 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,120 @@ +import { ProvideLinksToolSchema } from "@/lib/ai/inkeep-qa-schema"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { convertToModelMessages, streamText, zodSchema } from "ai"; +import type { UIMessage } from "ai"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +export const maxDuration = 60; + +// Type assertions below work around pnpm resolving duplicate copies of +// @ai-sdk/provider and zod, which makes structurally identical types +// appear incompatible to TypeScript. + +const ChatBodySchema = z.object({ + messages: z.array(z.any()).max(80), +}); + +const RATE_WINDOW_MS = 60_000; +const RATE_MAX_REQUESTS = 40; +type Bucket = { count: number; windowStart: number }; +const rateBuckets = new Map(); + +function clientIp(req: Request): string { + const forwarded = req.headers.get("x-forwarded-for"); + if (forwarded) return forwarded.split(",")[0]?.trim() || "unknown"; + return req.headers.get("x-real-ip")?.trim() || "unknown"; +} + +function allowRequest(ip: string): boolean { + const now = Date.now(); + const b = rateBuckets.get(ip); + if (!b || now - b.windowStart > RATE_WINDOW_MS) { + rateBuckets.set(ip, { count: 1, windowStart: now }); + return true; + } + if (b.count >= RATE_MAX_REQUESTS) return false; + b.count += 1; + return true; +} + +const inkeep = createOpenAICompatible({ + name: "inkeep", + apiKey: process.env.INKEEP_API_KEY, + baseURL: "https://api.inkeep.com/v1", +}); + +export async function POST(req: Request) { + if (!process.env.INKEEP_API_KEY) { + return NextResponse.json( + { error: "Chat is not configured." }, + { status: 503 }, + ); + } + + const ip = clientIp(req); + if (!allowRequest(ip)) { + return NextResponse.json( + { error: "Too many requests. Try again in a minute." }, + { status: 429 }, + ); + } + + let json: unknown; + try { + json = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const parsed = ChatBodySchema.safeParse(json); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request.", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const messages = parsed.data.messages as UIMessage[]; + + let modelMessages: Awaited>; + try { + modelMessages = await (convertToModelMessages as Function)(messages, { + ignoreIncompleteToolCalls: true, + convertDataPart(part: { type: string; data: unknown }) { + if (part.type === "data-client") + return { + type: "text" as const, + text: `[Client Context: ${JSON.stringify(part.data)}]`, + }; + }, + }); + } catch { + return NextResponse.json( + { error: "Could not read message history." }, + { status: 400 }, + ); + } + + try { + const result = streamText({ + model: inkeep("inkeep-qa-sonnet-4") as unknown as Parameters< + typeof streamText + >[0]["model"], + tools: { + provideLinks: { + inputSchema: zodSchema(ProvideLinksToolSchema as any), + }, + }, + messages: modelMessages, + toolChoice: "auto", + }); + + return result.toUIMessageStreamResponse(); + } catch { + return NextResponse.json( + { error: "Failed to start the assistant." }, + { status: 502 }, + ); + } +} diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx index 4bb6a61fd1..8df9d71311 100644 --- a/app/api/og/route.tsx +++ b/app/api/og/route.tsx @@ -23,8 +23,8 @@ export async function GET(request: NextRequest) { const fontGeistMono = await fetch( new URL("/fonts/GeistMono-Medium.ttf", base) ).then((res) => res.arrayBuffer()); - const fontGeistSans = await fetch( - new URL("/fonts/Geist-Regular.ttf", base) + const fontInter = await fetch( + new URL("/fonts/Inter-Medium.ttf", base) ).then((res) => res.arrayBuffer()); const { searchParams } = new URL(request.url); @@ -51,7 +51,7 @@ export async function GET(request: NextRequest) { height: "100%", width: "100%", backgroundColor: "#000000", - fontFamily: "GeistSans", + fontFamily: "Inter", color: "#fff", padding: 0, fontWeight: 500, @@ -121,7 +121,7 @@ export async function GET(request: NextRequest) { height: 630, fonts: [ { name: "GeistMono", data: fontGeistMono, style: "normal" }, - { name: "GeistSans", data: fontGeistSans, style: "normal" }, + { name: "Inter", data: fontInter, style: "normal" }, ], } ); diff --git a/app/api/productUpdateSignup/route.ts b/app/api/productUpdateSignup/route.ts index 53b36a72a5..f2a2e7c28e 100644 --- a/app/api/productUpdateSignup/route.ts +++ b/app/api/productUpdateSignup/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import * as z from "zod/v3"; +import { z } from "zod"; export const runtime = "edge"; diff --git a/app/api/search-docs/route.ts b/app/api/search-docs/route.ts index fbc115dd21..721c88b399 100644 --- a/app/api/search-docs/route.ts +++ b/app/api/search-docs/route.ts @@ -1,5 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; -import { searchLangfuseDocsWithInkeep, isNonEmptyString } from "@/lib/inkeep-search"; +import { searchLangfuseDocsWithInkeep, isNonEmptyString } from "@/lib/inkeep-search-backend"; +import { PostHog } from "posthog-node"; +import { waitUntil } from "@vercel/functions"; + +const posthog = process.env.NEXT_PUBLIC_POSTHOG_KEY + ? new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, { + host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://eu.posthog.com", + flushAt: 1, + flushInterval: 0, + }) + : undefined; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -21,6 +31,25 @@ export async function GET(request: NextRequest) { ); } + // Fire PostHog event immediately so it has time to flush + waitUntil( + (async () => { + try { + posthog?.capture({ + distinctId: "docs-search-api", + event: "docs_search:query", + properties: { + query, + $process_person_profile: false, + }, + }); + await posthog?.flush(); + } catch (error) { + console.error("Error tracking PostHog event:", error); + } + })() + ); + try { const inkeepResult = await searchLangfuseDocsWithInkeep(query); return NextResponse.json( diff --git a/app/blog/[...slug]/page.tsx b/app/blog/[...slug]/page.tsx new file mode 100644 index 0000000000..a4d3900db8 --- /dev/null +++ b/app/blog/[...slug]/page.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { blogSource } from "@/lib/source"; +import { loadPage, buildSectionMetadata } from "@/lib/mdx-page"; +import { getBlogTagCounts } from "@/lib/blog-index"; +import { getMDXComponents } from "@/mdx-components"; +import { ContentColumns } from "@/components/layout"; +import { BlogPostSidebar } from "@/components/blog/BlogPostSidebar"; +import { DocBodyChrome } from "@/components/DocBodyChrome"; +import { MainContentWrapper } from "@/components/MainContentWrapper"; + +type PageProps = { + params: Promise<{ slug: string[] }>; +}; + +export default async function BlogPostPage(props: PageProps) { + const { slug } = await props.params; + const result = await loadPage(blogSource, slug); + if (!result) notFound(); + const { MDX } = result; + + const { tags, total } = getBlogTagCounts(); + + return ( + } + > +
+ + + + + +
+
+ ); +} + +export async function generateMetadata(props: PageProps): Promise { + const { slug } = await props.params; + const page = blogSource.getPage(slug); + if (!page) return { title: "Not Found" }; + return buildSectionMetadata(page, "blog", "Blog", slug); +} + +export function generateStaticParams() { + return blogSource.generateParams(); +} diff --git a/app/blog/layout.tsx b/app/blog/layout.tsx index 5400e27dc5..126de61cde 100644 --- a/app/blog/layout.tsx +++ b/app/blog/layout.tsx @@ -1,23 +1,9 @@ -import { blogSource } from "@/lib/source"; -import { Layout } from "@/components/layout"; -import { FluxLayoutNoPanel } from "@/components/layout/FluxLayoutNoPanel"; +import { PageChrome } from "@/components/home/layout/PageChrome"; export default function BlogLayout({ children, }: { children: React.ReactNode; }) { - return ( - - - {children} - - - ); + return {children}; } diff --git a/app/blog/page.tsx b/app/blog/page.tsx index d8fcbae507..377e7b06bb 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -1,59 +1,43 @@ -import { Suspense } from "react"; +import { getBlogIndexPages } from "@/lib/blog-index"; +import { ContentColumns } from "@/components/layout"; +import { BlogPageClient } from "@/components/blog/BlogPageClient"; +import { BlogSidebar } from "@/components/blog/BlogSidebar"; +import { BlogAside } from "@/components/blog/BlogAside"; +import { BlogHatchBackground } from "@/components/blog/BlogHatchBackground"; import { BlogIndex } from "@/components/blog/BlogIndex"; -import { Header } from "@/components/Header"; -import { ProductUpdateSignup } from "@/components/productUpdateSignup"; -import Link from "next/link"; -import { blogSource } from "@/lib/source"; -import type { BlogPageItem } from "@/components/blog/BlogIndex"; +import { TextHighlight } from "@/components/ui/text-highlight"; +import { Link } from "@/components/ui/link"; +import { Heading } from "@/components/ui/heading"; +import { Text } from "@/components/ui/text"; export default function BlogIndexPage() { - const pages: BlogPageItem[] = blogSource - .getPages() - .filter((p) => p.url !== "/blog" && p.data.showInBlogIndex !== false) - .sort( - (a, b) => - new Date((b.data.date as string) ?? 0).getTime() - - new Date((a.data.date as string) ?? 0).getTime() - ) - .map((p) => ({ - route: p.url, - name: p.data.title, - title: p.data.title, - frontMatter: { - title: p.data.title, - description: p.data.description as string | undefined, - date: p.data.date as string | undefined, - tag: p.data.tag as string | undefined, - ogImage: p.data.ogImage as string | undefined, - author: p.data.author as string | undefined, - showInBlogIndex: p.data.showInBlogIndex as boolean | undefined, - }, - })); + const pages = getBlogIndexPages(); return ( -
-
-
+ + } + rightSidebar={} + className="min-h-screen" + footerClassName="md:max-w-none xl:max-w-none px-6 sm:px-6 md:px-6" + > + +
+
+ + Langfuse Blog + + The latest updates from Langfuse. See{" "} - + Changelog {" "} for more product updates. - - } - className="mb-8" - h="h1" - /> -
- + +
+
-
- }> - - -
+ + ); } diff --git a/app/changelog/[...slug]/page.tsx b/app/changelog/[...slug]/page.tsx new file mode 100644 index 0000000000..56e9fa6e9d --- /dev/null +++ b/app/changelog/[...slug]/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { changelogSource } from "@/lib/source"; +import { loadPage, buildSectionMetadata, primitiveOnly } from "@/lib/mdx-page"; +import { getMDXComponents } from "@/mdx-components"; +import { ContentColumns } from "@/components/layout"; +import { DocBodyChrome } from "@/components/DocBodyChrome"; +import { ChangelogFrontMatterProvider } from "@/components/changelog/ChangelogFrontMatterContext"; +import type { ChangelogFrontMatter } from "@/components/changelog/ChangelogFrontMatterContext"; +import { MainContentWrapper } from "@/components/MainContentWrapper"; + +type PageProps = { + params: Promise<{ slug: string[] }>; +}; + +export default async function ChangelogPostPage(props: PageProps) { + const { slug } = await props.params; + const result = await loadPage(changelogSource, slug); + if (!result) notFound(); + const { page, MDX } = result; + + const frontMatter = primitiveOnly( + page.data as unknown as Record + ) as ChangelogFrontMatter; + + return ( + +
+ + + + + + + +
+
+ ); +} + +export async function generateMetadata(props: PageProps): Promise { + const { slug } = await props.params; + const page = changelogSource.getPage(slug); + if (!page) return { title: "Not Found" }; + return buildSectionMetadata(page, "changelog", "Changelog", slug); +} + +export function generateStaticParams() { + return changelogSource.generateParams(); +} diff --git a/app/changelog/layout.tsx b/app/changelog/layout.tsx index a500140f5a..27eb775c96 100644 --- a/app/changelog/layout.tsx +++ b/app/changelog/layout.tsx @@ -1,23 +1,9 @@ -import { changelogSource } from "@/lib/source"; -import { Layout } from "@/components/layout"; -import { FluxLayoutNoPanel } from "@/components/layout/FluxLayoutNoPanel"; +import { PageChrome } from "@/components/layout"; export default function ChangelogLayout({ children, }: { children: React.ReactNode; }) { - return ( - - - {children} - - - ); + return {children}; } diff --git a/app/changelog/page.tsx b/app/changelog/page.tsx index 49315289d5..14a16179a5 100644 --- a/app/changelog/page.tsx +++ b/app/changelog/page.tsx @@ -1,59 +1,87 @@ -import { Suspense } from "react"; +import type { Metadata } from "next"; import { ChangelogIndex } from "@/components/changelog/ChangelogIndex"; -import type { ChangelogPageItem } from "@/components/changelog/ChangelogIndex"; -import { Header } from "@/components/Header"; -import { ProductUpdateSignup } from "@/components/productUpdateSignup"; -import Link from "next/link"; -import { changelogSource } from "@/lib/source"; +import { ProductUpdateSignup } from "@/components/ProductUpdateSignup"; +import { + CHANGELOG_ITEMS_PER_PAGE, + changelogPageHref, + getChangelogIndexItems, + parseChangelogPageParam, +} from "@/lib/changelog-index"; +import { ContentColumns } from "@/components/layout"; +import { TextHighlight } from "@/components/ui/text-highlight"; +import { Link } from "@/components/ui/link"; +import { Heading } from "@/components/ui/heading"; +import { Text } from "@/components/ui/text"; -export default function ChangelogIndexPage() { - const pages: ChangelogPageItem[] = changelogSource - .getPages() - .filter((p) => p.url !== "/changelog") - .sort( - (a, b) => - new Date((b.data.date as string) ?? 0).getTime() - - new Date((a.data.date as string) ?? 0).getTime() - ) - .map((p) => ({ - route: p.url, - name: p.data.title, - title: p.data.title, - frontMatter: { - title: p.data.title, - description: p.data.description as string | undefined, - date: p.data.date as string | undefined, - ogImage: p.data.ogImage as string | undefined, - ogVideo: p.data.ogVideo as string | undefined, - gif: p.data.gif as string | undefined, - badge: p.data.badge as string | undefined, - }, - })); +type PageProps = { + searchParams: Promise<{ page?: string }>; +}; + +export async function generateMetadata({ + searchParams, +}: PageProps): Promise { + const sp = await searchParams; + const all = getChangelogIndexItems(); + const totalPages = Math.max( + 1, + Math.ceil(all.length / CHANGELOG_ITEMS_PER_PAGE) + ); + const currentPage = parseChangelogPageParam(sp.page, totalPages); + const canonical = changelogPageHref(currentPage, totalPages) ?? "/changelog"; + + return { + alternates: { + canonical, + }, + pagination: { + ...(currentPage > 1 && { + previous: changelogPageHref(currentPage - 1, totalPages), + }), + ...(currentPage < totalPages && { + next: changelogPageHref(currentPage + 1, totalPages), + }), + }, + }; +} + +export default async function ChangelogIndexPage({ searchParams }: PageProps) { + const sp = await searchParams; + const allPages = getChangelogIndexItems(); + const totalPages = Math.max( + 1, + Math.ceil(allPages.length / CHANGELOG_ITEMS_PER_PAGE) + ); + const currentPage = parseChangelogPageParam(sp.page, totalPages); + const sliceStart = (currentPage - 1) * CHANGELOG_ITEMS_PER_PAGE; + const pages = allPages.slice( + sliceStart, + sliceStart + CHANGELOG_ITEMS_PER_PAGE + ); return ( -
-
-
- Latest release updates from the Langfuse team. Check out our{" "} - - Roadmap - {" "} - to see what's next. - - } - className="mb-8" - h="h1" - /> + +
+
+ + Changelog + + + Latest release updates from the Langfuse team. Check out our{" "} + + Roadmap + {" "} + to see what's next. + +
+
- }> - - -
+ ); } diff --git a/app/docs/DocsLayoutWrapper.tsx b/app/docs/DocsLayoutWrapper.tsx index 79cdbe7790..3e94716941 100644 --- a/app/docs/DocsLayoutWrapper.tsx +++ b/app/docs/DocsLayoutWrapper.tsx @@ -1,17 +1,14 @@ "use client"; -import { SidebarProvider } from "fumadocs-ui/components/sidebar/base"; import type { ReactNode } from "react"; /** - * Wraps docs layout so SidebarTrigger has a SidebarProvider. - * Sidebar sticky top is handled via --fd-docs-row-1 override in overrides.css: - * calc(var(--fd-nav-height, 4rem) + var(--fd-banner-height, 0px)) + * Pattern-bg is applied to #nd-page directly via CSS + DocsPatternTracker. */ export function DocsLayoutWrapper({ children }: { children: ReactNode }) { return ( -
- {children} +
+ {children}
); } diff --git a/app/docs/SharedDocsLayout.tsx b/app/docs/SharedDocsLayout.tsx index bede7d2e8d..d83553547c 100644 --- a/app/docs/SharedDocsLayout.tsx +++ b/app/docs/SharedDocsLayout.tsx @@ -1,14 +1,24 @@ import type { ComponentProps, ReactNode } from "react"; import { DocsLayout } from "fumadocs-ui/layouts/docs"; import { DocsLayoutWrapper } from "./DocsLayoutWrapper"; -import { Layout } from "@/components/layout"; -import { MenuSwitcher } from "@/components/MenuSwitcher"; +import { NavbarDocs, DocsSecondaryNav, DocsSecondaryNavMobile } from "@/components/layout"; +import { DocsPatternTracker } from "@/components/layout/DocsContentArea"; import { ThemeToggle } from "@/components/ThemeToggle"; +import { AISearch, AISearchPanel, FloatingAskAIButton } from "@/components/inkeep/search"; +import { SidebarFolderItem } from "@/components/docs-sidebar/SidebarFolderItem"; +import { SidebarItem } from "@/components/docs-sidebar/SidebarItem"; +import { SidebarSeparatorItem } from "@/components/docs-sidebar/SidebarSeparatorItem"; /** * Shared wrapper used by all sidebar-based section layouts * (docs, guides, integrations, self-hosting, library). * Each layout only needs to pass the correct page tree. + * + * Renders two sticky headers: + * 1. NavbarDocs — 60px — logo + search + launch app + * 2. DocsSecondaryNav — 40px — section tabs + * Total header height is `calc(var(--lf-nav-primary-height) + var(--lf-nav-docs-secondary-height))` + * on `.docs-chrome` as `--fd-nav-height` (see `src/overrides.css`) so fumadocs sticky offsets stay correct. */ export function SharedDocsLayout({ tree, @@ -18,19 +28,30 @@ export function SharedDocsLayout({ children: ReactNode; }) { return ( - - - }} - searchToggle={{ enabled: false }} - themeSwitch={{ component:
}} - > - {children} -
-
-
+ +
+ + + + + }} + sidebar={{ + enabled: true, + collapsible: false, + components: { Item: SidebarItem, Separator: SidebarSeparatorItem, Folder: SidebarFolderItem }, + }} + searchToggle={{ enabled: false }} + themeSwitch={{ component:
}} + > + + {children} +
+
+
+ +
); } diff --git a/app/docs/[[...slug]]/not-found.tsx b/app/docs/[[...slug]]/not-found.tsx deleted file mode 100644 index f5ccb78f17..0000000000 --- a/app/docs/[[...slug]]/not-found.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import Link from "next/link"; - -export default function DocsNotFound() { - return ( -
-

Page not found

-

- The documentation page you’re looking for doesn’t exist or has moved. -

- - Back to Documentation - -
- ); -} diff --git a/app/docs/[[...slug]]/page.tsx b/app/docs/[[...slug]]/page.tsx index e4094bbdee..fcef4fb529 100644 --- a/app/docs/[[...slug]]/page.tsx +++ b/app/docs/[[...slug]]/page.tsx @@ -3,10 +3,11 @@ import { source } from "@/lib/source"; import { buildOgImageUrl, buildPageUrl } from "@/lib/og-url"; import { DocsPage } from "fumadocs-ui/page"; import { notFound } from "next/navigation"; -import { DocsContributors } from "@/components/DocsContributors"; +import { DocsTocFooter } from "@/components/DocsTocFooter"; import { DocBodyChrome } from "@/components/DocBodyChrome"; import { getMDXComponents } from "@/mdx-components"; import type { ComponentType } from "react"; +import { DocsAndPageFooter } from "@/components/DocsAndPageFooter"; type PageProps = { params: Promise<{ slug?: string[] }>; @@ -26,7 +27,8 @@ export default async function DocPage(props: PageProps) { }} + tableOfContent={{ footer: }} + footer={{ component: }} > diff --git a/app/docs/layout.tsx b/app/docs/layout.tsx index 9adb1a4097..bcf79b16dd 100644 --- a/app/docs/layout.tsx +++ b/app/docs/layout.tsx @@ -1,4 +1,4 @@ -import { source, getPageTreeWithShortTitles } from "@/lib/source"; +import { source } from "@/lib/source"; import { SharedDocsLayout } from "./SharedDocsLayout"; export default function DocsPageLayout({ @@ -6,5 +6,5 @@ export default function DocsPageLayout({ }: { children: React.ReactNode; }) { - return {children}; + return {children}; } diff --git a/app/faq/[[...slug]]/page.tsx b/app/faq/[[...slug]]/page.tsx new file mode 100644 index 0000000000..5ff16e1696 --- /dev/null +++ b/app/faq/[[...slug]]/page.tsx @@ -0,0 +1,139 @@ +import type { Metadata } from "next"; +import { faqSource } from "@/lib/source"; +import { buildOgImageUrl, buildPageUrl } from "@/lib/og-url"; +import { DocsPage } from "fumadocs-ui/page"; +import { notFound } from "next/navigation"; +import { DocsTocFooter } from "@/components/DocsTocFooter"; +import { DocBodyChrome } from "@/components/DocBodyChrome"; +import { getMDXComponents } from "@/mdx-components"; +import type { ComponentType } from "react"; +import { DocsAndPageFooter } from "@/components/DocsAndPageFooter"; +import { FaqPreview } from "@/components/faq/FaqPreview"; +import { formatTag } from "@/components/faq/FaqIndex"; + +type PageProps = { + params: Promise<{ slug?: string[] }>; +}; + +export default async function FaqPage(props: PageProps) { + const params = await props.params; + const slug = params.slug ?? []; + const page = faqSource.getPage(slug); + + if (!page && slug.length === 2 && slug[0] === "tag") { + const tag = decodeURIComponent(slug[1]); + return ( + + +

FAQ: {formatTag(tag)}

+ +
+
+ ); + } + + if (!page) notFound(); + + const { toc } = page.data; + const MDX = page.data.body as ComponentType<{ components?: Record }>; + + return ( + }} + footer={{ component: }} + > + + + + + ); +} + +export async function generateMetadata(props: PageProps): Promise { + const params = await props.params; + const slug = params.slug ?? []; + const page = faqSource.getPage(slug); + + if (!page && slug.length === 2 && slug[0] === "tag") { + const tag = decodeURIComponent(slug[1]); + const title = `FAQ: ${formatTag(tag)}`; + const canonicalUrl = buildPageUrl(`/faq/tag/${slug[1]}`); + const ogImage = buildOgImageUrl({ + title, + description: `Frequently asked questions about ${formatTag(tag)}.`, + section: "FAQ", + }); + return { + title, + description: `Frequently asked questions about ${formatTag(tag)}.`, + alternates: { canonical: canonicalUrl }, + openGraph: { images: [{ url: ogImage }], url: canonicalUrl }, + twitter: { images: [{ url: ogImage }] }, + }; + } + + if (!page) + return { + title: "Not Found", + }; + + const pageData = page.data as typeof page.data & { + canonical?: string | null; + noindex?: boolean | null; + seoTitle?: string | null; + ogImage?: string | null; + ogVideo?: string | null; + }; + const pagePath = `/faq${slug.length > 0 ? `/${slug.join("/")}` : ""}`; + const canonicalUrl = pageData.canonical ?? buildPageUrl(pagePath); + const seoTitle = pageData.seoTitle || page.data.title; + const ogImage = buildOgImageUrl({ + title: seoTitle, + description: page.data.description, + section: "FAQ", + staticOgImage: pageData.ogImage, + }); + const ogVideoUrl = pageData.ogVideo + ? pageData.ogVideo.startsWith("http") + ? pageData.ogVideo + : buildPageUrl(pageData.ogVideo) + : null; + return { + title: seoTitle, + description: page.data.description ?? undefined, + alternates: { canonical: canonicalUrl }, + ...(pageData.noindex ? { robots: { index: false, follow: true } } : {}), + openGraph: { + images: [{ url: ogImage }], + url: canonicalUrl, + ...(ogVideoUrl ? { videos: [{ url: ogVideoUrl }] } : {}), + }, + twitter: { images: [{ url: ogImage }] }, + }; +} + +export function generateStaticParams() { + const params = [...faqSource.generateParams()]; + const allTags = new Set(); + + for (const p of faqSource.getPages()) { + const tags = ((p.data as unknown as Record).tags as string[] | undefined) ?? []; + for (const tag of tags) { + allTags.add(tag); + } + } + + for (const tag of Array.from(allTags)) { + if (!faqSource.getPage(["tag", tag])) { + params.push({ slug: ["tag", tag] } as (typeof params)[number]); + } + } + + return params; +} diff --git a/app/faq/layout.tsx b/app/faq/layout.tsx new file mode 100644 index 0000000000..123eed1a07 --- /dev/null +++ b/app/faq/layout.tsx @@ -0,0 +1,10 @@ +import { faqSource } from "@/lib/source"; +import { SharedDocsLayout } from "@/app/docs/SharedDocsLayout"; + +export default function FaqLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/app/guides/[[...slug]]/not-found.tsx b/app/guides/[[...slug]]/not-found.tsx deleted file mode 100644 index 4411fd59ab..0000000000 --- a/app/guides/[[...slug]]/not-found.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import Link from "next/link"; - -export default function GuidesNotFound() { - return ( -
-

Page not found

-

- The guides page you're looking for doesn't exist or has moved. -

- - Back to Guides - -
- ); -} diff --git a/app/guides/[[...slug]]/page.tsx b/app/guides/[[...slug]]/page.tsx index d8019988a0..40bf30bcd6 100644 --- a/app/guides/[[...slug]]/page.tsx +++ b/app/guides/[[...slug]]/page.tsx @@ -1,12 +1,14 @@ import type { Metadata } from "next"; import { guidesSource } from "@/lib/source"; import { buildOgImageUrl, buildPageUrl } from "@/lib/og-url"; +import { COOKBOOK_ROUTE_MAPPING } from "@/lib/cookbook_route_mapping"; import { DocsPage } from "fumadocs-ui/page"; import { notFound } from "next/navigation"; -import { DocsContributors } from "@/components/DocsContributors"; +import { DocsTocFooter } from "@/components/DocsTocFooter"; import { DocBodyChrome } from "@/components/DocBodyChrome"; import { getMDXComponents } from "@/mdx-components"; import type { ComponentType } from "react"; +import { DocsAndPageFooter } from "@/components/DocsAndPageFooter"; type PageProps = { params: Promise<{ slug?: string[] }>; @@ -26,7 +28,8 @@ export default async function GuidesPage(props: PageProps) { }} + tableOfContent={{ footer: }} + footer={{ component: }} > @@ -49,7 +52,13 @@ export async function generateMetadata(props: PageProps): Promise { ogImage?: string | null; }; const pagePath = `/guides${slug.length > 0 ? `/${slug.join("/")}` : ""}`; - const canonicalUrl = pageData.canonical ?? buildPageUrl(pagePath); + const cookbookMapping = COOKBOOK_ROUTE_MAPPING.find( + (r) => r.path === pagePath && r.canonicalPath, + ); + const canonicalUrl = + pageData.canonical ?? + (cookbookMapping ? buildPageUrl(cookbookMapping.canonicalPath!) : null) ?? + buildPageUrl(pagePath); const seoTitle = pageData.seoTitle || page.data.title; const ogImage = buildOgImageUrl({ title: seoTitle, diff --git a/app/guides/layout.tsx b/app/guides/layout.tsx index cd6df660ff..4a6445d70e 100644 --- a/app/guides/layout.tsx +++ b/app/guides/layout.tsx @@ -1,4 +1,4 @@ -import { guidesSource, getPageTreeWithShortTitles } from "@/lib/source"; +import { guidesSource } from "@/lib/source"; import { SharedDocsLayout } from "@/app/docs/SharedDocsLayout"; export default function GuidesLayout({ @@ -6,5 +6,5 @@ export default function GuidesLayout({ }: { children: React.ReactNode; }) { - return {children}; + return {children}; } diff --git a/app/integrations/[[...slug]]/not-found.tsx b/app/integrations/[[...slug]]/not-found.tsx deleted file mode 100644 index 53e6937bae..0000000000 --- a/app/integrations/[[...slug]]/not-found.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import Link from "next/link"; - -export default function IntegrationsNotFound() { - return ( -
-

Page not found

-

- The integrations page you're looking for doesn't exist or has moved. -

- - Back to Integrations - -
- ); -} diff --git a/app/integrations/[[...slug]]/page.tsx b/app/integrations/[[...slug]]/page.tsx index a49ebf396b..8b8c8d2db8 100644 --- a/app/integrations/[[...slug]]/page.tsx +++ b/app/integrations/[[...slug]]/page.tsx @@ -3,10 +3,11 @@ import { integrationsSource } from "@/lib/source"; import { buildOgImageUrl, buildPageUrl } from "@/lib/og-url"; import { DocsPage } from "fumadocs-ui/page"; import { notFound } from "next/navigation"; -import { DocsContributors } from "@/components/DocsContributors"; +import { DocsTocFooter } from "@/components/DocsTocFooter"; import { DocBodyChrome } from "@/components/DocBodyChrome"; import { getMDXComponents } from "@/mdx-components"; import type { ComponentType } from "react"; +import { DocsAndPageFooter } from "@/components/DocsAndPageFooter"; type PageProps = { params: Promise<{ slug?: string[] }>; @@ -26,7 +27,8 @@ export default async function IntegrationsPage(props: PageProps) { }} + tableOfContent={{ footer: }} + footer={{ component: }} > diff --git a/app/integrations/layout.tsx b/app/integrations/layout.tsx index 7021aa4542..16214f77ae 100644 --- a/app/integrations/layout.tsx +++ b/app/integrations/layout.tsx @@ -1,4 +1,4 @@ -import { getIntegrationsPageTree } from "@/lib/source"; +import { integrationsSource } from "@/lib/source"; import { SharedDocsLayout } from "@/app/docs/SharedDocsLayout"; export default function IntegrationsLayout({ @@ -6,5 +6,5 @@ export default function IntegrationsLayout({ }: { children: React.ReactNode; }) { - return {children}; + return {children}; } diff --git a/app/layout.tsx b/app/layout.tsx index 36141bd7db..388a29715c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,14 +1,35 @@ import type { Metadata } from "next"; import Script from "next/script"; import { RootProvider } from "fumadocs-ui/provider/next"; -import { GeistSans } from "geist/font/sans"; -import { GeistMono } from "geist/font/mono"; +import { GoogleTagManager } from '@next/third-parties/google'; +import localFont from "next/font/local"; +import { Inter } from 'next/font/google' import { DevAriaHiddenConsoleFilter } from "@/components/DevAriaHiddenConsoleFilter"; import { buildDefaultSiteOgImageUrl, SITE_DEFAULT_OG_DESCRIPTION, } from "@/lib/og-url"; import { PostHogProvider } from "@/components/analytics/PostHogProvider"; + +const interVariable = Inter({ + subsets: ['latin'], + variable: "--font-inter", + display: "swap", +}); + +const geistMono = localFont({ + src: "../public/fonts/GeistMono-Medium.woff2", + variable: "--font-geist-mono", + display: "swap", + weight: "500", +}); + +const f37Analog = localFont({ + src: "../public/fonts/F37Analog-Medium.woff2", + variable: "--font-analog", + display: "swap", + weight: "500", +}); import { Hubspot } from "@/components/analytics/hubspot"; import "../style.css"; import "@vidstack/react/player/styles/base.css"; @@ -47,7 +68,7 @@ export default function RootLayout({ lang="en" dir="ltr" suppressHydrationWarning - className={`${GeistSans.variable} ${GeistMono.variable}`} + className={`${interVariable.variable} ${geistMono.variable} ${f37Analog.variable}`} > {process.env.NODE_ENV === "development" && } @@ -56,6 +77,7 @@ export default function RootLayout({ {process.env.NODE_ENV === "production" && ( <> + + + + + {children} + + + ); +} diff --git a/components/home/layout/SidebarShell.tsx b/components/home/layout/SidebarShell.tsx new file mode 100644 index 0000000000..5cdbbd7d8e --- /dev/null +++ b/components/home/layout/SidebarShell.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +/** + * Shared outer shell for left sidebars in HomeLayout. + * Provides the sticky positioning, fixed width, overflow scroll, + * and visual chrome (bg, border pixel) that all left sidebars share. + */ +export function SidebarShell({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( + + ); +} diff --git a/components/home/pricing/PricingCalculator.tsx b/components/home/pricing/PricingCalculator.tsx index b75ccfa3b1..4d9bdca1e1 100644 --- a/components/home/pricing/PricingCalculator.tsx +++ b/components/home/pricing/PricingCalculator.tsx @@ -12,6 +12,8 @@ import { TableRow, } from "@/components/ui/table"; import { Card, CardContent } from "@/components/ui/card"; +import { CornerBox } from "@/components/ui/corner-box"; +import { Heading } from "@/components/ui/heading"; import { Select, SelectContent, @@ -21,6 +23,7 @@ import { } from "@/components/ui/select"; import Link from "next/link"; import { InfoIcon } from "lucide-react"; +import { Text } from "@/components/ui"; // Graduated pricing tiers const pricingTiers = [ @@ -142,92 +145,94 @@ export function PricingCalculator({ }; return ( -
-

+
+ Pricing Calculator -

-

+ + Enter your monthly billable units to see the graduated pricing breakdown. - - -

+ - -
-
- - -
-
-
- - - - + +
+
+
+ + +
+
+
+ + + + +
+
-
-
-
- {currentBaseFee > 0 ? ( -
-
-
-
- {formatCurrency(currentBaseFee)} + + {currentBaseFee > 0 ? ( +
+
+
+
+ {formatCurrency(currentBaseFee)} +
+
+ {selectedPlan} Base +
-
- {selectedPlan} Base +
+ +
-
-
+
-
-
- {formatCurrency(calculatedPrice)} -
-
- Usage +
+
+ {formatCurrency(calculatedPrice)} +
+
+ Usage +
-
-
=
-
-
- {formatCurrency(calculatedPrice + currentBaseFee)} +
+ =
-
- Total / Month +
+
+ {formatCurrency(calculatedPrice + currentBaseFee)} +
+
+ Total / Month +
-
- ) : ( -
-
+ ) : ( +
{formatCurrency(calculatedPrice)}
@@ -235,74 +240,74 @@ export function PricingCalculator({ Total Usage Cost / Month
-
- )} + )} +
{/* Breakdown */}
Pricing tiers breakdown:
-
- - - - Tier - Rate - -
- Your Units - - - -
-
- Cost -
-
- - {pricingBreakdown.map((breakdown, index) => { - const { tier, eventsInTier, costForTier, tierRate } = - breakdown; +
+ + + Tier + Rate + +
+ Your Units + + + +
+
+ Cost +
+
+ + {pricingBreakdown.map((breakdown, index) => { + const { tier, eventsInTier, costForTier, tierRate } = + breakdown; - return ( - - - {tier.description} - - {tierRate} - - {eventsInTier > 0 ? formatNumber(eventsInTier) : "—"} - - - {eventsInTier > 0 ? formatCurrency(costForTier) : "—"} - - - ); - })} - {/* Total row */} - - Total - - {(() => { - const totalUnits = parseInt(monthlyEvents.replace(/,/g, "")) || 0; - if (totalUnits === 0) return "—"; - const avgRate = (calculatedPrice / totalUnits) * 100000; - return formatCurrency(avgRate) + "/100k"; - })()} - - - {monthlyEvents} - - - {formatCurrency(calculatedPrice)} - - - -
-
+ return ( + + + {tier.description} + + {tierRate} + + {eventsInTier > 0 ? formatNumber(eventsInTier) : "—"} + + + {eventsInTier > 0 ? formatCurrency(costForTier) : "—"} + + + ); + })} + {/* Total row */} + + Total + + {(() => { + const totalUnits = + parseInt(monthlyEvents.replace(/,/g, "")) || 0; + if (totalUnits === 0) return "—"; + const avgRate = (calculatedPrice / totalUnits) * 100000; + return formatCurrency(avgRate) + "/100k"; + })()} + + + {monthlyEvents} + + + {formatCurrency(calculatedPrice)} + + + +
diff --git a/components/home/pricing/PricingDiscounts.tsx b/components/home/pricing/PricingDiscounts.tsx index 9c4556a5dd..f2b554be53 100644 --- a/components/home/pricing/PricingDiscounts.tsx +++ b/components/home/pricing/PricingDiscounts.tsx @@ -1,4 +1,7 @@ import { Button } from "@/components/ui/button"; +import { CornerBox } from "@/components/ui/corner-box"; +import { Heading } from "@/components/ui/heading"; +import { Text } from "@/components/ui/text"; import Link from "next/link"; const discounts = [ @@ -33,34 +36,45 @@ const discounts = [ ]; export const PricingDiscounts = () => ( -
-
-

+
+
+ Discounts -

-
+ +
{discounts.map((discount) => ( -
-
+ {discount.name} -
-
+ + {discount.description} -
+ {discount.cta && ( - + +
)} -
+ ))}
-

+ Reach out to{" "} support@langfuse.com @@ -68,7 +82,7 @@ export const PricingDiscounts = () => ( to apply for a discount. We want all startups, educational users, non-profits and open source projects to build with Langfuse and are happy to work with you to make that happen. -

+
); diff --git a/components/home/pricing/PricingFAQ.tsx b/components/home/pricing/PricingFAQ.tsx index cfa7e4a6f0..d8e25b9dd0 100644 --- a/components/home/pricing/PricingFAQ.tsx +++ b/components/home/pricing/PricingFAQ.tsx @@ -1,31 +1,31 @@ -import { Plus, Minus } from "lucide-react"; -import { Disclosure } from "@headlessui/react"; +import { Heading } from "@/components/ui/heading"; +import { FAQAccordion, type FAQItem } from "@/components/shared/FAQAccordion"; -const faqs = [ +const faqs: FAQItem[] = [ { question: "What is the easiest way to try Langfuse?", answer: - "You can view the public example project or sign up for a free account to try Langfuse with your own data. The Hobby plan is completely free and does not require a credit card.", + "You can view the [public example project](/demo) or sign up for a [free account](https://cloud.langfuse.com) to try Langfuse with your own data. The Hobby plan is completely free and does not require a credit card.", }, { question: "Can I self-host Langfuse for free?", answer: - "Yes, Langfuse is open source and you can self-host Langfuse for free. Use docker compose to run Langfuse locally, or use one of our templates to self-host Langfuse in production on Kubernetes. Check out the self-hosting documentation to learn more.", + "Yes, Langfuse is open source and you can self-host Langfuse for free. Use docker compose to run Langfuse locally, or use one of our templates to self-host Langfuse in production on Kubernetes. Check out the [self-hosting documentation](/self-hosting) to learn more.", }, { question: "What is a billable unit?", answer: - "A billable unit in Langfuse is any tracing data point you send to our platform - this includes the trace (a complete application interaction), observations (individual steps within a trace: Spans, Events and Generations), and scores (evaluations of your AI outputs). For a detailed explanation and an example, see our Langfuse Billable Units docs.", + "A billable unit in Langfuse is any tracing data point you send to our platform — this includes the trace (a complete application interaction), observations (individual steps within a trace: Spans, Events and Generations), and scores (evaluations of your AI outputs). For a detailed explanation and an example, see our [Langfuse Billable Units docs](/docs/administration/billable-units).", }, { question: "How does the graduated pricing work?", answer: - "Our graduated pricing means you pay different rates for different volume tiers. The first 100k units are included in paid plans, then you pay $8/100k for units 100k-1M, $7/100k for 1M-10M units, $6.5/100k for 10M-50M units, and $6/100k for 50M+ units. This ensures you get better rates as you scale up your usage. Use the pricing calculator to estimate your bill.", + "Our graduated pricing means you pay different rates for different volume tiers. The first 100k units are included in paid plans, then you pay $8/100k for units 100k–1M, $7/100k for 1M–10M units, $6.5/100k for 10M–50M units, and $6/100k for 50M+ units. This ensures you get better rates as you scale up your usage. Use the pricing calculator to estimate your bill.", }, { question: "How can I reduce my Langfuse Cloud bill?", answer: - "The primary way to reduce your Langfuse Cloud bill is to reduce the number of billable units that you ingest. We have summarized how this can be done here. Additionally, with our new graduated pricing model, you automatically get lower rates per 100k units as your volume increases.", + "The primary way to reduce your Langfuse Cloud bill is to reduce the number of billable units that you ingest. We have summarized how this can be done [here](/faq/all/cutting-costs). Additionally, with our new graduated pricing model, you automatically get lower rates per 100k units as your volume increases.", }, { question: "When do I get billed?", @@ -35,12 +35,12 @@ const faqs = [ { question: "Can I set up alerts on the usage fees?", answer: - "Yes, you can configure spend alerts to receive email notifications when your organization's spending exceeds predefined monetary thresholds. This helps you monitor costs and take action before unexpected charges occur. Navigate to your organization settings and the Billing tab to configure spend alerts. Learn more in our spend alerts documentation.", + "Yes, you can configure spend alerts to receive email notifications when your organization's spending exceeds predefined monetary thresholds. This helps you monitor costs and take action before unexpected charges occur. Navigate to your organization settings and the Billing tab to configure spend alerts. Learn more in our [spend alerts documentation](/docs/administration/spend-alerts).", }, { question: "How can I manage my subscription?", answer: - "You can manage your subscription through the organization settings in Langfuse Cloud or by using this Customer Portal.", + "You can manage your subscription through the organization settings in Langfuse Cloud or by using this [Customer Portal](/billing-portal).", }, { question: "Can I redline the contracts?", @@ -50,56 +50,24 @@ const faqs = [ { question: "Where is the data stored?", answer: - "Langfuse Cloud is hosted on AWS and data is stored in the US or EU depending on your selection. See our security and privacy documentation for more details.", + "Langfuse Cloud is hosted on AWS and data is stored in the US or EU depending on your selection. See our [security and privacy documentation](/security) for more details.", }, { question: "I have security questions, where to start?", answer: - "We publish almost all of our security documentation and controls. Please refer to our security documentation for more details.", + "We publish almost all of our security documentation and controls. Please refer to our [security documentation](/security) for more details.", }, ]; export function PricingFAQ() { return (
-
-
-

- Frequently asked questions -

-
- {faqs.map((faq) => ( - - {({ open }) => ( - <> -
- - - {faq.question} - - - {open ? ( - - -
- -

- - - )} - - ))} -

+
+
+ + FAQ + +
diff --git a/components/home/pricing/PricingTable.tsx b/components/home/pricing/PricingTable.tsx index 08449f4158..66d717216e 100644 --- a/components/home/pricing/PricingTable.tsx +++ b/components/home/pricing/PricingTable.tsx @@ -10,9 +10,11 @@ import { CardTitle, } from "@/components/ui/card"; import { Check, ExternalLink, InfoIcon } from "lucide-react"; +import { CornerBox } from "@/components/ui/corner-box"; import Link from "next/link"; import { cn } from "@/lib/utils"; import { useState, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; import { Table, TableBody, @@ -51,7 +53,6 @@ type Tier = { id: string; href: string; featured: boolean; - cardClassName?: string; pillClassName?: string; description: string; pill?: React.ReactNode; @@ -235,8 +236,6 @@ const tiers: Record = { id: "tier-self-hosted-enterprise", href: "https://langfuse.app.n8n.cloud/form/edaa0e7f-0244-4b3e-92d6-870179e066f2", featured: false, - cardClassName: - "border-[#FAFF6A] bg-[#FAFF6A12] dark:bg-[#FAFF6A14] dark:border-[#FAFF6A]", pillClassName: "bg-[#FAFF6A] text-[#1A1A1A]", description: "Dedicated Langfuse deployment with enterprise capabilities and support.", @@ -1408,9 +1407,8 @@ export function PricingPlans({ variant }: { variant: DeploymentOption }) { return ( - -
- ) : ( - <> - +
+ ) : ( + )}
{/* Callouts for different tiers - always render container for alignment */} -
+
{tier.calloutLink ? ( -
+
-
+
)} -
+
    {tier.mainFeatures.map((feature, index) => ( @@ -1520,10 +1528,11 @@ export function PricingPlans({ variant }: { variant: DeploymentOption }) { ))}
{tier.addOn && ( -
-
+
+
+ optional
+
{tier.addOn.name} @@ -1544,14 +1553,13 @@ export function PricingPlans({ variant }: { variant: DeploymentOption }) { {tier.addOn.cta && ( )} {tier.addOn.calloutLink && ( @@ -1564,6 +1572,7 @@ export function PricingPlans({ variant }: { variant: DeploymentOption }) {
)} +
)} @@ -1584,9 +1593,17 @@ export function PricingTable({ }) { const [isHeaderFixed, setIsHeaderFixed] = useState(false); const [headerWidth, setHeaderWidth] = useState(0); + const [headerLeft, setHeaderLeft] = useState(0); + const [containerLeft, setContainerLeft] = useState(0); + const [containerWidth, setContainerWidth] = useState(0); const [columnWidths, setColumnWidths] = useState([]); + const [mounted, setMounted] = useState(false); const tableRef = useRef(null); const headerRef = useRef(null); + + useEffect(() => { + setMounted(true); + }, []); const selectedTiers = tiers[variant]; const visibleSections = sections.filter((section) => section.features.some((feature) => variant in feature.tiers), @@ -1595,13 +1612,24 @@ export function PricingTable({ useEffect(() => { if (!isPricingPage) return; + const getContainer = () => + document.getElementById("home-main-area") ?? + tableRef.current?.parentElement ?? + null; + const calculateWidths = () => { if (headerRef.current && tableRef.current) { - // Get the total width of the table - const tableWidth = tableRef.current.getBoundingClientRect().width; - setHeaderWidth(tableWidth); + const tableRect = tableRef.current.getBoundingClientRect(); + setHeaderWidth(tableRect.width); + setHeaderLeft(tableRect.left); + + const container = getContainer(); + if (container) { + const containerRect = container.getBoundingClientRect(); + setContainerLeft(containerRect.left); + setContainerWidth(containerRect.width); + } - // Get the widths of each column const headerCells = headerRef.current.querySelectorAll("th"); const widths = Array.from(headerCells).map( (cell) => (cell as HTMLElement).getBoundingClientRect().width, @@ -1618,9 +1646,21 @@ export function PricingTable({ if (!tableRef.current) return; const tableRect = tableRef.current.getBoundingClientRect(); - const navbarHeight = 64; // Approximate height of the navbar + /** Keep aligned with `--lf-nav-primary-height` in `src/overrides.css`. */ + const navbarHeight = 60; + + // Keep the sticky header aligned with the table / container horizontal + // position (layout may shift if content above the table changes size). + setHeaderLeft(tableRect.left); + setHeaderWidth(tableRect.width); + + const container = getContainer(); + if (container) { + const containerRect = container.getBoundingClientRect(); + setContainerLeft(containerRect.left); + setContainerWidth(containerRect.width); + } - // Check if we're within the table's vertical bounds const isWithinTableBounds = tableRect.top < navbarHeight && tableRect.bottom > navbarHeight; @@ -1660,10 +1700,7 @@ export function PricingTable({ )} > {selectedTiers.map((tier) => ( -
+

{tier.name} @@ -1676,7 +1713,7 @@ export function PricingTable({ {visibleSections.map((section) => ( - + - {isHeaderFixed && ( -
-
-
- - - - {columnWidths.length > 0 && ( - <> - - {selectedTiers.map((tier, index) => ( - - ))} - - )} - - -
- {tier.name} -
-
-
-
- )} + {mounted && + isHeaderFixed && + columnWidths.length > 0 && + createPortal( +
0 ? containerLeft : headerLeft, + width: containerWidth > 0 ? containerWidth : headerWidth, + }} + > + 0 + ? Math.max(0, headerLeft - containerLeft) + : 0, + marginRight: 0, + }} + > + + + + {selectedTiers.map((tier, index) => ( + + ))} + + +
+ {tier.name} +
+
, + document.body, + )}
- +
{visibleSections.map((section) => ( - + ); -} +} \ No newline at end of file diff --git a/components/home/pricing/index.tsx b/components/home/pricing/index.tsx index 919cd15f17..c3f7bc1a10 100644 --- a/components/home/pricing/index.tsx +++ b/components/home/pricing/index.tsx @@ -1,12 +1,14 @@ "use client"; import Link from "next/link"; -import { Header } from "../../Header"; -import { HomeSection } from "../components/HomeSection"; +import { HomeSection } from "../HomeSection"; import { cn } from "@/lib/utils"; import { Tabs, TabsList, TabsTrigger } from "../../ui/tabs"; import { useState } from "react"; import React from "react"; +import { Heading } from "@/components/ui/heading"; +import { Text } from "@/components/ui/text"; +import { TextHighlight } from "@/components/ui/text-highlight"; import { PricingCalculator } from "./PricingCalculator"; import { PricingFAQ } from "./PricingFAQ"; import { PricingDiscounts } from "./PricingDiscounts"; @@ -59,15 +61,23 @@ export function PricingPage({ const variant = isPricingPage ? initialVariant : localVariant; return ( - -
+ +
-
+
+ + {deploymentOptions[variant].title} + + {deploymentOptions[variant].subtitle} +
{/* Deployment Options Tabs */}
{variant === "cloud" && } diff --git a/components/icons/aws.tsx b/components/icons/aws.tsx deleted file mode 100644 index 09a0eaea72..0000000000 --- a/components/icons/aws.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// icon:aws | Ant Design Icons https://ant.design/components/icon/ | Ant Design -import * as React from "react"; - -function IconAWS(props: React.SVGProps) { - return ( - - AWS - - - ); -} - -export default IconAWS; diff --git a/components/icons/azure.tsx b/components/icons/azure.tsx deleted file mode 100644 index 42596f75f5..0000000000 --- a/components/icons/azure.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from "react"; - -function IconAzure(props: React.SVGProps) { - return ( - - Azure - - - - - - ); -} - -export default IconAzure; diff --git a/components/icons/book-bookmark.tsx b/components/icons/book-bookmark.tsx new file mode 100644 index 0000000000..05b32f4b85 --- /dev/null +++ b/components/icons/book-bookmark.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; + +function IconBookBookmark(props: React.SVGProps) { + return ( + + + + + + + + ); +} + +export default IconBookBookmark; diff --git a/components/icons/book.tsx b/components/icons/book.tsx new file mode 100644 index 0000000000..96600193ff --- /dev/null +++ b/components/icons/book.tsx @@ -0,0 +1,17 @@ +import * as React from "react"; + +function IconBook(props: React.SVGProps) { + return ( + + + + + + + + + + ); +} + +export default IconBook; diff --git a/components/icons/compass.tsx b/components/icons/compass.tsx new file mode 100644 index 0000000000..378d09ea22 --- /dev/null +++ b/components/icons/compass.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; + +function IconCompass(props: React.SVGProps) { + return ( + + + + + + + ); +} + +export default IconCompass; diff --git a/components/icons/desktop-tower.tsx b/components/icons/desktop-tower.tsx new file mode 100644 index 0000000000..731484a7f1 --- /dev/null +++ b/components/icons/desktop-tower.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; + +function IconDesktopTower(props: React.SVGProps) { + return ( + + + + + + + + + + + + ); +} + +export default IconDesktopTower; diff --git a/components/icons/docker.tsx b/components/icons/docker.tsx deleted file mode 100644 index b95cd3abba..0000000000 --- a/components/icons/docker.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// icon:docker | Ant Design Icons https://ant.design/components/icon/ | Ant Design -import * as React from "react"; - -function IconDocker(props: React.SVGProps) { - return ( - - Docker - - - ); -} - -export default IconDocker; diff --git a/components/icons/error.tsx b/components/icons/error.tsx new file mode 100644 index 0000000000..52d00078b1 --- /dev/null +++ b/components/icons/error.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; + +function IconError(props: React.SVGProps) { + return ( + + + + + + + + ); +} + +export default IconError; diff --git a/components/icons/gcp.tsx b/components/icons/gcp.tsx deleted file mode 100644 index 501ff96d3e..0000000000 --- a/components/icons/gcp.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from "react"; - -function IconGCP(props: React.SVGProps) { - return ( - - GCP - - - ); -} - -export default IconGCP; diff --git a/components/icons/github.tsx b/components/icons/github.tsx index ed49ddfaef..0b0cde80b6 100644 --- a/components/icons/github.tsx +++ b/components/icons/github.tsx @@ -4,14 +4,14 @@ import * as React from "react"; function IconGithub(props: React.SVGProps) { return ( GitHub - + ); } diff --git a/components/icons/idea.tsx b/components/icons/idea.tsx new file mode 100644 index 0000000000..677c9aa914 --- /dev/null +++ b/components/icons/idea.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; + +function IconIdea(props: React.SVGProps) { + return ( + + + + + + + + ); +} + +export default IconIdea; diff --git a/components/icons/info.tsx b/components/icons/info.tsx new file mode 100644 index 0000000000..2a5914f426 --- /dev/null +++ b/components/icons/info.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; + +function IconInfo(props: React.SVGProps) { + return ( + + + + + + + + ); +} + +export default IconInfo; diff --git a/components/icons/kubernetes.tsx b/components/icons/kubernetes.tsx deleted file mode 100644 index 7ea6a2ef23..0000000000 --- a/components/icons/kubernetes.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as React from "react"; - -function IconKubernetes(props: React.SVGProps) { - return ( - - - - - - - - - - ); -} - -export default IconKubernetes; diff --git a/components/icons/success.tsx b/components/icons/success.tsx new file mode 100644 index 0000000000..71cc29a9ec --- /dev/null +++ b/components/icons/success.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; + +function IconSuccess(props: React.SVGProps) { + return ( + + + + + + + ); +} + +export default IconSuccess; diff --git a/components/icons/warning.tsx b/components/icons/warning.tsx new file mode 100644 index 0000000000..d8cdda6495 --- /dev/null +++ b/components/icons/warning.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; + +function IconWarning(props: React.SVGProps) { + return ( + + + + + + + + ); +} + +export default IconWarning; diff --git a/components/icons/x.tsx b/components/icons/x.tsx index eb7812a501..4470d5ab8b 100644 --- a/components/icons/x.tsx +++ b/components/icons/x.tsx @@ -1,14 +1,15 @@ function IconX(props: React.SVGProps) { return ( ); } diff --git a/components/inkeep/InkeepChatButton.tsx b/components/inkeep/InkeepChatButton.tsx deleted file mode 100644 index 201be5d7e2..0000000000 --- a/components/inkeep/InkeepChatButton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import type { InkeepChatButtonProps } from "@inkeep/cxkit-react"; -import useInkeepSettings from "./useInkeepSettings"; - -const ChatButton = dynamic( - () => import("@inkeep/cxkit-react").then((mod) => mod.InkeepChatButton), - { - ssr: false, - } -); - -export default function InkeepChatButton() { - const { baseSettings, aiChatSettings, searchSettings, modalSettings } = - useInkeepSettings(); - - const chatButtonProps: InkeepChatButtonProps = { - baseSettings, - aiChatSettings, - searchSettings, - modalSettings, - label: "Ask AI", - avatar: "/langfuse-icon.svg", - }; - - return ( -
- -
- ); -} diff --git a/components/inkeep/InkeepCustomTrigger.tsx b/components/inkeep/InkeepCustomTrigger.tsx deleted file mode 100644 index 9049010741..0000000000 --- a/components/inkeep/InkeepCustomTrigger.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useEffect, useState } from "react"; -import useInkeepSettings from "./useInkeepSettings"; -import type { InkeepModalSearchAndChatProps } from "@inkeep/cxkit-react"; - -export default function InkeepCustomTrigger() { - const [isOpen, setIsOpen] = useState(false); - const [CustomTrigger, setCustomTrigger] = - useState<(e: InkeepModalSearchAndChatProps) => React.ReactElement>(); - - const { baseSettings, aiChatSettings, searchSettings, modalSettings } = - useInkeepSettings(); - - // Handle keyboard shortcuts - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const isMac = navigator.platform.toLowerCase().includes("mac"); - const modifier = isMac ? event.metaKey : event.ctrlKey; - - if (modifier && event.key.toLowerCase() === "k") { - event.preventDefault(); - setIsOpen(true); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, []); - - // load the library asynchronously - useEffect(() => { - const loadCustomTrigger = async () => { - try { - const { InkeepModalSearchAndChat } = await import("@inkeep/cxkit-react"); - setCustomTrigger(() => InkeepModalSearchAndChat); - } catch (error) { - console.error("Failed to load CustomTrigger:", error); - } - }; - - loadCustomTrigger(); - }, []); - - const customTriggerProps: InkeepModalSearchAndChatProps = { - baseSettings, - aiChatSettings, - searchSettings, - modalSettings: { - ...modalSettings, - isOpen, - onOpenChange: setIsOpen, - }, - }; - - return ( -
-
setIsOpen(true)} - className="relative flex items-center text-gray-900 dark:text-gray-300 contrast-more:text-gray-800 contrast-more:dark:text-gray-300 max-md:hidden hover:ring-2 hover:ring-gray-300 dark:hover:ring-gray-700 rounded-lg" - > -
- Search or ask... -
- - K - -
- {CustomTrigger && } -
- ); -} diff --git a/components/inkeep/InkeepEmbeddedChat.tsx b/components/inkeep/InkeepEmbeddedChat.tsx deleted file mode 100644 index f4bfed9270..0000000000 --- a/components/inkeep/InkeepEmbeddedChat.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import type { InkeepEmbeddedChatProps } from "@inkeep/cxkit-react"; -import useInkeepSettings from "./useInkeepSettings"; - -const EmbeddedChat = dynamic( - () => import("@inkeep/cxkit-react").then((mod) => mod.InkeepEmbeddedChat), - { - ssr: false, - loading: () =>
loading...
, // optional: loading animation component - } -); - -const css = String.raw; - -function InkeepEmbeddedChat() { - const { baseSettings, aiChatSettings } = useInkeepSettings(); - - const embeddedChatProps: InkeepEmbeddedChatProps = { - baseSettings: { - ...baseSettings, - theme: { - styles: [ - { - key: '1', - type: 'style', - value: css` - .ikp-ai-chat-wrapper { - width: 100%; - height: auto; - max-height: 100%; - } - `, - }, - ], - }, - }, - aiChatSettings, - }; - - return ; -} - -export default InkeepEmbeddedChat; diff --git a/components/inkeep/InkeepSearchBar.tsx b/components/inkeep/InkeepSearchBar.tsx index 6ceca52812..8e87efce8f 100644 --- a/components/inkeep/InkeepSearchBar.tsx +++ b/components/inkeep/InkeepSearchBar.tsx @@ -3,6 +3,8 @@ import dynamic from "next/dynamic"; import type { InkeepSearchBarProps } from "@inkeep/cxkit-react"; import useInkeepSettings from "./useInkeepSettings"; +import { Button } from "@/components/ui/button"; +import { Search } from "lucide-react"; const SearchBar = dynamic( () => import("@inkeep/cxkit-react").then((mod) => mod.InkeepSearchBar), @@ -11,7 +13,21 @@ const SearchBar = dynamic( const css = String.raw; -export default function InkeepSearchBar() { +type InkeepSearchProps = { + className?: string; +} + +type InkeepSearchButtonProps = { + className?: string; +} + +export function InkeepSearchButton({ className }: InkeepSearchButtonProps) { + return ( + + ))} +
+
+ ); +} diff --git a/components/inkeep/ask-ai-button.tsx b/components/inkeep/ask-ai-button.tsx new file mode 100644 index 0000000000..f657de2e0d --- /dev/null +++ b/components/inkeep/ask-ai-button.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { AISearchTrigger, useAISearchContext } from "@/components/inkeep/search"; +import { cn } from "@/lib/utils"; + +export const AskAIButton = () => { + return ( + + Ask AI + + ); +}; + +export const FloatingAskAIButton = () => { + const { open } = useAISearchContext(); + + return ( +
+ + Ask AI + +
+ ); +}; diff --git a/components/inkeep/embedded-chat.tsx b/components/inkeep/embedded-chat.tsx new file mode 100644 index 0000000000..50e320ea3a --- /dev/null +++ b/components/inkeep/embedded-chat.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { + type ComponentProps, + type SyntheticEvent, + useEffect, + useRef, + useState, +} from 'react'; +import { Loader2, RefreshCw, Send } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { Link } from '@/components/ui/link'; +import { AIChatEmptyState, AIChatMessage } from './ai-chat-shared'; +import { useChatContext, buildUserMessage } from './search-context'; + +function EmbeddedTextarea(props: ComponentProps<'textarea'>) { + const shared = cn('col-start-1 row-start-1', props.className); + + return ( +
+