diff --git a/.changeset/align-status-and-sender-naming.md b/.changeset/align-status-and-sender-naming.md new file mode 100644 index 0000000..8fe1bf0 --- /dev/null +++ b/.changeset/align-status-and-sender-naming.md @@ -0,0 +1,19 @@ +--- +"@timonwa/firebase-hooks": minor +--- + +**Breaking: `useVerifyEmail`'s `status` uses the standard async vocabulary.** `"processing"` is now `"pending"` and `"failed"` is now `"error"`, matching TanStack Query's `QueryStatus` rather than spelling the same three states differently. + +```tsx +- if (status === 'processing') return ; +- if (status === 'failed') return ; ++ if (status === 'pending') return ; ++ if (status === 'error') return ; +``` + +**Breaking: `useEmailLinkSignIn`'s `sendLink` option is now `sendEmail`.** All three emailed-link hooks take the same option name. The returned `sendLink` function is unchanged. + +```tsx +-useEmailLinkSignIn({ sendLink: email => api.send(email) }); ++useEmailLinkSignIn({ sendEmail: ({ email }) => api.send(email) }); +``` diff --git a/.changeset/config.json b/.changeset/config.json index 7fb5a92..7f95297 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,6 +1,9 @@ { "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", - "changelog": ["@changesets/changelog-github", { "repo": "Timonwa/firebase-hooks" }], + "changelog": [ + "@changesets/changelog-github", + { "repo": "Timonwa/firebase-hooks" } + ], "commit": false, "access": "public", "baseBranch": "main", diff --git a/.changeset/delegate-email-sends.md b/.changeset/delegate-email-sends.md new file mode 100644 index 0000000..be144f7 --- /dev/null +++ b/.changeset/delegate-email-sends.md @@ -0,0 +1,14 @@ +--- +"@timonwa/firebase-hooks": minor +--- + +`useSendPasswordResetEmail` and `useSendEmailVerification` gain a `sendEmail` option, matching `useEmailLinkSignIn`'s `sendLink`. Both hooks previously sent from the browser with no way to delegate, which put an email-sending path outside an app's own rate limiter — awkward when the same app already rate-limits the sign-in link server-side. + +```tsx +const { send } = useSendPasswordResetEmail({ + sendEmail: ({ email, actionCodeSettings }) => + requestPasswordReset(email, actionCodeSettings), +}); +``` + +The hook keeps its own bookkeeping either way: `loading`, `error`, `success` and `resetState` behave identically, and a throwing sender surfaces as an ordinary failure result. The sender receives `{ email, actionCodeSettings }` — the same inputs Firebase's client send takes and the Admin SDK's `generate*Link` wants, so a provider-level `actionCodeSettings` still reaches your server. On `useSendEmailVerification`, `email` is the signed-in user's address, since `send()` takes no arguments; an account without one fails clearly rather than calling your sender with nothing. diff --git a/.changeset/export-result-types.md b/.changeset/export-result-types.md new file mode 100644 index 0000000..d443c19 --- /dev/null +++ b/.changeset/export-result-types.md @@ -0,0 +1,12 @@ +--- +"@timonwa/firebase-hooks": minor +--- + +Two result types are no longer module-private: + +- `CompleteSignInResult` — what `useEmailLinkSignIn`'s `completeSignIn` resolves to, so a callback page can annotate the function that handles it +- `VerifyEmailStatus` — `useVerifyEmail`'s status, instead of re-declaring the three states in app code + +Both ship from `@timonwa/firebase-hooks/auth`. + +The status vocabulary is also now shared. `AsyncStatus` (`"processing" | "success" | "failed"`) ships from the root entry, and `VerifyEmailStatus` is an alias of it, so hooks that act on mount report the same three states rather than each spelling them differently. There is no idle state: the work starts before a render, which is why `processing` is the initial value. diff --git a/.changeset/export-wrapper-types.md b/.changeset/export-wrapper-types.md new file mode 100644 index 0000000..f4944f6 --- /dev/null +++ b/.changeset/export-wrapper-types.md @@ -0,0 +1,18 @@ +--- +"@timonwa/firebase-hooks": minor +--- + +Every type needed to write a wrapper around a hook is now reachable from `@timonwa/firebase-hooks/auth`: + +- all 13 `Use*Options` interfaces, so a wrapper can accept and forward a hook's options without restating them +- `AuthProviderProps` and `UseAuthResult` (what `useAuth` returns), for an app provider layered over this one + +```tsx +import { useLogin, type UseLoginOptions } from "@timonwa/firebase-hooks/auth"; + +export function useAppLogin(options?: UseLoginOptions) { + return useLogin(options); +} +``` + +`HookResult`, `HookErrorOptions` and `HookErrorContext` are unchanged and still ship from the root entry — every service returns them, so they keep one home rather than being re-exported per service. diff --git a/.changeset/named-result-types.md b/.changeset/named-result-types.md new file mode 100644 index 0000000..83d9947 --- /dev/null +++ b/.changeset/named-result-types.md @@ -0,0 +1,19 @@ +--- +"@timonwa/firebase-hooks": minor +--- + +Every hook now exports the type it returns as `UseResult` — `UseLoginResult`, `UseSignupResult`, `UseVerifyEmailResult`, and so on, from `@timonwa/firebase-hooks/auth`. A wrapper can state its return type instead of re-deriving it with `ReturnType`. + +```tsx +import { + useLogin, + type UseLoginOptions, + type UseLoginResult, +} from "@timonwa/firebase-hooks/auth"; + +export function useAppLogin(options?: UseLoginOptions): UseLoginResult { + return useLogin(options); +} +``` + +Same shape as TanStack Query's `UseQueryResult` / `UseMutationResult`. `useAuth` already returned the named `UseAuthResult`. diff --git a/.changeset/provider-senders.md b/.changeset/provider-senders.md new file mode 100644 index 0000000..3f00cf5 --- /dev/null +++ b/.changeset/provider-senders.md @@ -0,0 +1,20 @@ +--- +"@timonwa/firebase-hooks": minor +--- + +`AuthProvider` gains `senders`, so an app that emails links from its own API configures that once instead of at every call site: + +```tsx + api.sendSignInLink(email, actionCodeSettings), + passwordReset: ({ email, actionCodeSettings }) => api.sendPasswordReset(email, actionCodeSettings), + emailVerification: ({ email, actionCodeSettings }) => api.sendVerification(email, actionCodeSettings), + }} +> +``` + +One key per flow rather than a single sender: the three send different emails, so one sender for all of them could mail a password reset to someone asking to verify an address. The shape follows the same convention as TanStack Query's `defaultOptions`, which namespaces defaults by operation kind so the per-call signature stays identical to the global one. + +Each key follows the existing rule — a hook's own `sendLink`/`sendEmail` overrides it, and `null` opts that one flow back to Firebase's client-side send. diff --git a/.changeset/status-instead-of-loading.md b/.changeset/status-instead-of-loading.md new file mode 100644 index 0000000..e3007d0 --- /dev/null +++ b/.changeset/status-instead-of-loading.md @@ -0,0 +1,20 @@ +--- +"@timonwa/firebase-hooks": minor +--- + +**Breaking: every action hook reports `status` with derived booleans, replacing `loading`, `success` and `resetState`.** + +```tsx +const { login, status, isIdle, isPending, isSuccess, isError, error, reset } = + useLogin(); +// ^ 'idle' | 'pending' | 'success' | 'error' +``` + +| Before | After | +| ------------------------------------------ | ------------------------------- | +| `loading` | `isPending` | +| `success` (six hooks, hand-rolled) | `isSuccess` — now on every hook | +| `resetState()` (two hooks) | `reset()` — now on every hook | +| `useEmailLinkSignIn`'s returned `setError` | removed; `reset()` covers it | + +The booleans are derived from `status`, so exactly one is ever true. This is TanStack Query's mutation result, field for field, and the new `ActionStatus` type ships from the root entry beside `AsyncStatus`. Hooks that act on mount — `useVerifyEmail` — keep `AsyncStatus` (no `idle`, since the work starts before you can render) and gain the same `isPending`/`isSuccess`/`isError`. diff --git a/.prettierignore b/.prettierignore index 8922ae3..1f80343 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,20 @@ node_modules dist -apps + +# Biome's territory — two formatters on one file fight forever. +packages/**/*.ts +packages/**/*.tsx +packages/**/*.json +/*.ts +/*.json + +# Next build output +**/.next +**/.source +**/next-env.d.ts # Generated by changesets — reformatting it would churn on every release. **/CHANGELOG.md + +# Machine-owned; pnpm writes it. +pnpm-lock.yaml diff --git a/.prettierrc.json b/.prettierrc.json index d25ee21..0b08905 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,5 +1,20 @@ { "$schema": "https://json.schemastore.org/prettierrc", - "printWidth": 90, - "proseWrap": "never" + "printWidth": 80, + "semi": true, + "trailingComma": "all", + "arrowParens": "avoid", + "bracketSameLine": true, + "proseWrap": "never", + "plugins": ["prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "apps/docs/**", + "options": { "tailwindStylesheet": "./apps/docs/app/global.css" } + }, + { + "files": "apps/playground/**", + "options": { "tailwindStylesheet": "./apps/playground/app/globals.css" } + } + ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03758f3..1a5c10b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ This is a pnpm workspace. The published package lives in `packages/firebase-hook - **One folder per service** (`src/core/`, `src/auth/`, later `src/firestore/`, `src/storage/`), each with its own `index.ts` entry barrel. **One file per hook**, kebab-cased after it — `src/auth/use-login.ts`. Start the file with a `"use client"` directive and a JSDoc block; the JSDoc is what editors show, so keep it agreeing with the README. Internals a service shares live in that folder's `_shared.ts` and never reach the barrel. - **Follow the shared contract.** `auth: Auth | null` first argument; actions resolve to `HookResult` and never throw (`useAuthTask` gives you the skeleton); sensitive operations reauthenticate first. -- **Options go in an exported `UseOptionsProps` interface** extending `HookErrorOptions`, with a TSDoc line on every field it declares itself (and `@defaultValue` where there is one). The docs site generates its options table from that interface, so an undocumented field ships an empty cell. Exported for the generator's sake — keep it out of the barrel, so the published types don't change. +- **Options go in an exported `UseOptions` interface** extending `HookErrorOptions`, with a TSDoc line on every field it declares itself (and `@defaultValue` where there is one). The docs site generates its options table from that interface, so an undocumented field ships an empty cell. Re-export it from the barrel too, so an app can type a wrapper around the hook without restating the shape. - **Export it explicitly** from its service's entry barrel (`src/auth/index.ts` for auth), one line per file, alphabetical. The root entry (`src/core/index.ts`) carries only the service-agnostic core — nothing service-specific is ever added to it; a new service gets a new folder + subpath entry. - **Document it in the same change** — add a page under `apps/docs/content/docs/auth/` and list it in that folder's `meta.json`. Follow the shape of the existing pages: prose intro, example, `## Returns`, then `` for the options. The table generates from the option interface's TSDoc, so document each field there rather than hand-writing a table. - **Add it to the playground in the same change** — a section component under `apps/playground/components//`, rendered from that service's page, and its name in the right group in `apps/playground/lib/hooks-map.ts` so it appears in the sidebar. See below. diff --git a/README.md b/README.md index 9c9aff5..d76a20b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ --- -Each hook runs one flow end to end. It holds its own loading, error, and success state, and sets up the browser pieces and callbacks the flow needs. Every action returns a result you can branch on: `{ success: true, … }` when it works, `{ success: false, error, code, cause }` when it doesn't. Firebase's own response is on both paths. +Each hook runs one flow end to end. It holds its own status and error, and sets up the browser pieces and callbacks the flow needs. Every action returns a result you can branch on: `{ success: true, … }` when it works, `{ success: false, error, code, cause }` when it doesn't. Firebase's own response is on both paths. Zero dependencies — `firebase` and `react` are peers. @@ -37,7 +37,7 @@ Create your `Auth` instance once with the Firebase SDK, then wrap your app: import { AuthProvider } from "@timonwa/firebase-hooks/auth"; import { auth } from "@/lib/firebase"; // getAuth(initializeApp(config)) - createSession(idToken)}> + createSession(idToken)}> {children} ; ``` @@ -48,7 +48,7 @@ Hooks below it need nothing passed: import { useLogin } from "@timonwa/firebase-hooks/auth"; function LoginForm() { - const { login, loading, error } = useLogin(); + const { login, isPending, error } = useLogin(); async function onSubmit(email: string, password: string) { const result = await login(email, password); @@ -74,7 +74,10 @@ The most-used services ship first; more (Realtime Database, Remote Config, Cloud Each service is its own import, so an app only carries the services it uses. The root holds what every service shares — `formatFirebaseError`, `getFirebaseErrorCode`, and the `HookResult` types. ```ts -import { formatFirebaseError, getFirebaseErrorCode } from "@timonwa/firebase-hooks"; +import { + formatFirebaseError, + getFirebaseErrorCode, +} from "@timonwa/firebase-hooks"; import { AuthProvider, useLogin, @@ -89,7 +92,7 @@ Every hook has its own page — signature, options, and a worked example — in One contract, so learning one hook is learning them all: - **Every hook needs an `Auth` instance** — taken from `AuthProvider`, or passed in as the first argument to override it. No global, no hidden singleton. Passing `null` means "not ready yet", never "use the provider's", so an action called before `auth` exists fails cleanly with `{ success: false, error }`. -- **Every action resolves to `HookResult`** — `{ success: true, ...data }` or `{ success: false, error, code, cause }`. The hook's `error` state carries the same message for rendering, and `loading` and `success` track the action. See [Error handling](#error-handling). +- **Every action resolves to `HookResult`** — `{ success: true, ...data }` or `{ success: false, error, code, cause }`. The hook's `status` — `idle`, `pending`, `success`, `error` — tracks the action, with `isPending`/`isSuccess`/`isError` derived from it and `reset()` to go back to idle; `error` carries the message for rendering. See [Error handling](#error-handling). - **`onIdToken(idToken, user)` runs after a successful sign-in**, with a freshly minted token. Throw inside it to abort the flow; the error surfaces like any other. - **`currentPassword` triggers reauthentication** in `useUpdatePassword`, `useUpdateEmail`, and `useDeleteAccount`. `useReauthenticate` exposes the same step for custom flows. - **Provider options are defaults, hook options win.** `onIdToken`, `onBeforeSignOut`, `actionCodeSettings`, `formatErrorMessage`, and the `onError` observer can all be set once on `AuthProvider`; a hook's own option overrides the provider, and an explicit `null` opts that flow out entirely: @@ -97,11 +100,12 @@ One contract, so learning one hook is learning them all: ```tsx createSession(idToken)} + onIdToken={idToken => createSession(idToken)} onBeforeSignOut={() => clearSession()} actionCodeSettings={{ url: `${origin}/auth/action`, handleCodeInApp: true }} - formatErrorMessage={(e) => formatFirebaseError(e, { messages: AUTH_ERROR_MESSAGES })} -> + formatErrorMessage={e => + formatFirebaseError(e, { messages: AUTH_ERROR_MESSAGES }) + }> {children} ; @@ -149,8 +153,7 @@ For logging and analytics, the provider's **`onError` observer** sees every fail ```tsx track("auth_error", { action, code })} -> + onError={(error, { action, code }) => track("auth_error", { action, code })}> {children} ``` diff --git a/apps/docs/.prettierignore b/apps/docs/.prettierignore deleted file mode 100644 index 324551b..0000000 --- a/apps/docs/.prettierignore +++ /dev/null @@ -1,4 +0,0 @@ -.next -.source -node_modules -next-env.d.ts diff --git a/apps/docs/.prettierrc.json b/apps/docs/.prettierrc.json deleted file mode 100644 index 7659439..0000000 --- a/apps/docs/.prettierrc.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/prettierrc", - "singleQuote": true, - "semi": true, - "trailingComma": "all", - "printWidth": 90, - "plugins": ["prettier-plugin-tailwindcss"], - "overrides": [ - { - "files": "*.mdx", - "options": { - "proseWrap": "never" - } - } - ] -} diff --git a/apps/docs/README.md b/apps/docs/README.md index cac2c1f..261aaaa 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -12,9 +12,9 @@ Open . ## Environment -| Variable | Required | What it's for | -| ---------------------- | -------- | ------------------------------------------------------------- | -| `NEXT_PUBLIC_SITE_URL` | No | Absolute origin for canonicals, the sitemap and OG image URLs | +| Variable | Required | What it's for | +| --- | --- | --- | +| `NEXT_PUBLIC_SITE_URL` | No | Absolute origin for canonicals, the sitemap and OG image URLs | **Nothing to configure on Vercel.** The origin resolves from `VERCEL_PROJECT_PRODUCTION_URL`, which Vercel sets automatically. Set `NEXT_PUBLIC_SITE_URL` only once a custom domain points at the site. @@ -22,17 +22,17 @@ Locally the origin falls back to `http://localhost:3000`. If your dev server pic ## Layout -| Path | What it is | -| ----------------------- | ---------------------------------------------------------------------------- | -| `content/docs/` | The MDX pages; `meta.json` per folder controls nav order | -| `lib/source.ts` | Content source adapter — the nav, sitemap and OG routes all read from it | -| `lib/site.ts` | Site config: origin, description, author, whether the env is indexable | -| `lib/seo.ts` | `buildMetadata()` — every page's canonical, OG, Twitter and robots | -| `lib/schema.ts` | JSON-LD graph, anchored by stable `@id` | -| `lib/layout.shared.tsx` | Nav title, version pill, and the nav links | -| `app/og/` | OG image routes — `/og` for the home page, `/og/docs/*` prerendered per page | -| `app/(home)/` | Landing page and its footer | -| `app/docs/` | Documentation layout and pages | +| Path | What it is | +| --- | --- | +| `content/docs/` | The MDX pages; `meta.json` per folder controls nav order | +| `lib/source.ts` | Content source adapter — the nav, sitemap and OG routes all read from it | +| `lib/site.ts` | Site config: origin, description, author, whether the env is indexable | +| `lib/seo.ts` | `buildMetadata()` — every page's canonical, OG, Twitter and robots | +| `lib/schema.ts` | JSON-LD graph, anchored by stable `@id` | +| `lib/layout.shared.tsx` | Nav title, version pill, and the nav links | +| `app/og/` | OG image routes — `/og` for the home page, `/og/docs/*` prerendered per page | +| `app/(home)/` | Landing page and its footer | +| `app/docs/` | Documentation layout and pages | ## Writing a page diff --git a/apps/docs/app/(home)/layout.tsx b/apps/docs/app/(home)/layout.tsx index 0ac81cb..96c68a6 100644 --- a/apps/docs/app/(home)/layout.tsx +++ b/apps/docs/app/(home)/layout.tsx @@ -1,8 +1,8 @@ -import { HomeLayout } from 'fumadocs-ui/layouts/home'; -import { SiteFooter } from '@/components/site-footer'; -import { baseOptions } from '@/lib/layout.shared'; +import { HomeLayout } from "fumadocs-ui/layouts/home"; +import { SiteFooter } from "@/components/site-footer"; +import { baseOptions } from "@/lib/layout.shared"; -export default function Layout({ children }: LayoutProps<'/'>) { +export default function Layout({ children }: LayoutProps<"/">) { return ( {children} diff --git a/apps/docs/app/(home)/page.tsx b/apps/docs/app/(home)/page.tsx index 4488cfe..abae1c7 100644 --- a/apps/docs/app/(home)/page.tsx +++ b/apps/docs/app/(home)/page.tsx @@ -10,21 +10,21 @@ import { SlidersHorizontal, UserCog, Workflow, -} from 'lucide-react'; -import Link from 'next/link'; -import type { ReactNode } from 'react'; -import { CodeSample } from '@/components/code-sample'; -import { CopyButton } from '@/components/copy-button'; -import type { Metadata } from 'next'; -import { buildMetadata } from '@/lib/seo'; -import { npmUrl, packageName, packageVersion } from '@/lib/shared'; +} from "lucide-react"; +import Link from "next/link"; +import type { ReactNode } from "react"; +import { CodeSample } from "@/components/code-sample"; +import { CopyButton } from "@/components/copy-button"; +import type { Metadata } from "next"; +import { buildMetadata } from "@/lib/seo"; +import { npmUrl, packageName, packageVersion } from "@/lib/shared"; export const metadata: Metadata = buildMetadata({ // No `title` key: the home page keeps the root layout's default title rather // than having the template append the package name a second time. description: - 'Typed React hooks for every Firebase Auth flow — email/password, OAuth, magic link, phone and anonymous sign-in, plus password, email, profile and provider linking. Zero dependencies.', - path: '/', + "Typed React hooks for every Firebase Auth flow — email/password, OAuth, magic link, phone and anonymous sign-in, plus password, email, profile and provider linking. Zero dependencies.", + path: "/", }); const INSTALL_COMMAND = `pnpm add ${packageName} firebase`; @@ -32,80 +32,92 @@ const INSTALL_COMMAND = `pnpm add ${packageName} firebase`; const FEATURES = [ { icon: Workflow, - title: 'Whole flows, not single calls', - body: 'usePhoneSignIn builds and tears down the reCAPTCHA verifier. useOAuthSignIn finishes a redirect when the page returns. useEmailLinkSignIn asks for the address instead of calling window.prompt.', + title: "Whole flows, not single calls", + body: "usePhoneSignIn builds and tears down the reCAPTCHA verifier. useOAuthSignIn finishes a redirect when the page returns. useEmailLinkSignIn asks for the address instead of calling window.prompt.", }, { icon: CircleAlert, - title: 'Failures are values', - body: 'Actions never throw. A failure carries Firebase’s own code and the untouched original error, so you branch on a result instead of wrapping every call in try/catch.', + title: "Failures are values", + body: "Actions never throw. A failure carries Firebase’s own code and the untouched original error, so you branch on a result instead of wrapping every call in try/catch.", }, { icon: KeyRound, - title: 'Server sessions built in', - body: 'onIdToken hands you a fresh ID token as part of the sign-in, not after it. Throw inside it and the sign-in aborts, so a user can’t land on a protected page without a server session.', + title: "Server sessions built in", + body: "onIdToken hands you a fresh ID token as part of the sign-in, not after it. Throw inside it and the sign-in aborts, so a user can’t land on a protected page without a server session.", }, { icon: PackageOpen, - title: 'Nothing withheld', - body: 'Sign-ins hand back Firebase’s raw UserCredential. Error messages stay exactly as Firebase wrote them unless you opt into formatting.', + title: "Nothing withheld", + body: "Sign-ins hand back Firebase’s raw UserCredential. Error messages stay exactly as Firebase wrote them unless you opt into formatting.", }, { icon: SlidersHorizontal, - title: 'Configure once, override anywhere', - body: 'Session callbacks, action-code settings and error wording live on the provider. Any hook can override them, or opt out entirely with null.', + title: "Configure once, override anywhere", + body: "Session callbacks, action-code settings and error wording live on the provider. Any hook can override them, or opt out entirely with null.", }, { icon: Fingerprint, - title: 'Reauthentication handled', - body: 'Pass currentPassword to a sensitive operation and the recent-sign-in check happens first. Omit it, and auth/requires-recent-login reaches you to handle your own way.', + title: "Reauthentication handled", + body: "Pass currentPassword to a sensitive operation and the recent-sign-in check happens first. Omit it, and auth/requires-recent-login reaches you to handle your own way.", }, ]; const GROUPS = [ { - label: 'Signing in and out', + label: "Signing in and out", icon: LogIn, hooks: [ - 'useLogin', - 'useSignup', - 'useLogout', - 'useOAuthSignIn', - 'useEmailLinkSignIn', - 'usePhoneSignIn', - 'useAnonymousSignIn', - 'useCustomTokenSignIn', + "useLogin", + "useSignup", + "useLogout", + "useOAuthSignIn", + "useEmailLinkSignIn", + "usePhoneSignIn", + "useAnonymousSignIn", + "useCustomTokenSignIn", ], }, { - label: 'Passwords', + label: "Passwords", icon: Lock, - hooks: ['useSendPasswordResetEmail', 'useConfirmPasswordReset', 'useUpdatePassword'], + hooks: [ + "useSendPasswordResetEmail", + "useConfirmPasswordReset", + "useUpdatePassword", + ], }, { - label: 'Email', + label: "Email", icon: Mail, - hooks: ['useSendEmailVerification', 'useVerifyEmail', 'useUpdateEmail'], + hooks: ["useSendEmailVerification", "useVerifyEmail", "useUpdateEmail"], }, { - label: 'Account and linking', + label: "Account and linking", icon: UserCog, hooks: [ - 'useUpdateProfile', - 'useDeleteAccount', - 'useReauthenticate', - 'useLinkProvider', - 'useUnlinkProvider', + "useUpdateProfile", + "useDeleteAccount", + "useReauthenticate", + "useLinkProvider", + "useUnlinkProvider", ], }, ]; const SERVICES = [ - { name: 'Core', entry: '@timonwa/firebase-hooks', ready: true }, - { name: 'Auth', entry: '@timonwa/firebase-hooks/auth', ready: true }, - { name: 'Firestore', entry: '@timonwa/firebase-hooks/firestore', ready: false }, - { name: 'Storage', entry: '@timonwa/firebase-hooks/storage', ready: false }, - { name: 'Cloud Functions', entry: '@timonwa/firebase-hooks/functions', ready: false }, + { name: "Core", entry: "@timonwa/firebase-hooks", ready: true }, + { name: "Auth", entry: "@timonwa/firebase-hooks/auth", ready: true }, + { + name: "Firestore", + entry: "@timonwa/firebase-hooks/firestore", + ready: false, + }, + { name: "Storage", entry: "@timonwa/firebase-hooks/storage", ready: false }, + { + name: "Cloud Functions", + entry: "@timonwa/firebase-hooks/functions", + ready: false, + }, ]; // Kept under ~56 columns so it fits a half-width column without scrolling. @@ -139,26 +151,30 @@ const result = await login(email, password); if (result.success) router.push("/dashboard");`; function toSlug(hook: string) { - return hook.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + return hook.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); } function Section({ children, - className = '', + className = "", }: { children: ReactNode; className?: string; }) { return ( -
{children}
+
+ {children} +
); } function SectionHeading({ title, lead }: { title: string; lead: string }) { return (
-

{title}

-

{lead}

+

+ {title} +

+

{lead}

); } @@ -168,50 +184,51 @@ export default function HomePage() {
{/* Hero */}
-
+
- - v{packageVersion} · - Auth available · Firestore next + + v + {packageVersion} · Auth available · Firestore next

- Typed React hooks for Firebase,{' '} + Typed React hooks for Firebase,{" "} one hook per flow

-

- Each hook runs a whole flow end to end and holds its own loading, error and - success state. Zero dependencies — firebase and{' '} - react stay peers. +

+ Each hook runs a whole flow end to end and holds its own loading, + error and success state. Zero dependencies — firebase{" "} + and react stay peers.

+ className="group inline-flex items-center gap-2 rounded-lg bg-fd-primary px-5 py-2.5 text-sm font-medium text-fd-primary-foreground transition-opacity hover:opacity-90"> Get started + className="surface px-5 py-2.5 text-sm font-medium transition-colors hover:bg-fd-accent"> Browse the hooks -
+
$ {INSTALL_COMMAND} - +
@@ -229,7 +246,7 @@ export default function HomePage() { only the label above and the note below — no second border. */}
-
+
Firebase directly 21 lines
@@ -237,18 +254,19 @@ export default function HomePage() {
-
+
With useLogin 6 lines
-

- And it does more: if createSession throws, the sign-in aborts - rather than leaving a signed-in user with no server session. +

+ And it does more: if createSession throws, the + sign-in aborts rather than leaving a signed-in user with no server + session.

@@ -263,7 +281,7 @@ export default function HomePage() { {/* One hairline grid rather than six outlined cards, so the icons stay the only accent in the section. */} -
+
{/* The grid is pulled 1px past the container so the last column's and last row's borders land under the container's own border and get clipped — otherwise the rounded corners sit on straight cell borders @@ -272,17 +290,18 @@ export default function HomePage() { {FEATURES.map(({ icon: Icon, title, body }) => (
+ className="group relative flex flex-col border-r border-b border-fd-border p-6"> - +

{title}

-

{body}

+

+ {body} +

))}
@@ -306,22 +325,23 @@ export default function HomePage() { {label} - + {hooks.length}
    - {hooks.map((hook) => ( + {hooks.map(hook => (
  • + className="inline-block rounded-md border border-fd-border px-2.5 py-1 font-mono text-xs transition-colors hover:border-fd-primary/40 hover:bg-fd-primary/5"> {/* Colouring the shared prefix carries the accent through twenty otherwise-grey chips, and shows the naming pattern at a glance. */} use - {hook.slice(3)} + + {hook.slice(3)} +
  • ))} @@ -339,29 +359,27 @@ export default function HomePage() { />
      - {SERVICES.map((service) => ( + {SERVICES.map(service => (
    • + className="flex flex-wrap items-center gap-x-4 gap-y-1 border-b py-3.5 last:border-b-0"> {service.name} - + {service.entry} - {service.ready ? 'Available' : 'Coming soon'} + ? "text-xs font-medium text-fd-primary" + : "text-xs text-fd-muted-foreground" + }> + {service.ready ? "Available" : "Coming soon"}
    • ))} @@ -370,10 +388,10 @@ export default function HomePage() { {/* Close */}
      -
      +

      Sign a user in, in about five lines @@ -381,16 +399,14 @@ export default function HomePage() {
      + className="group inline-flex items-center gap-2 rounded-lg bg-fd-primary px-5 py-2.5 text-sm font-medium text-fd-primary-foreground transition-opacity hover:opacity-90"> Get started + className="rounded-lg border px-5 py-2.5 text-sm font-medium transition-colors hover:bg-fd-accent"> View on npm ↗
      diff --git a/apps/docs/app/api/search/route.ts b/apps/docs/app/api/search/route.ts index df88962..d86bfc5 100644 --- a/apps/docs/app/api/search/route.ts +++ b/apps/docs/app/api/search/route.ts @@ -1,4 +1,4 @@ -import { source } from '@/lib/source'; -import { createFromSource } from 'fumadocs-core/search/server'; +import { source } from "@/lib/source"; +import { createFromSource } from "fumadocs-core/search/server"; export const { GET } = createFromSource(source); diff --git a/apps/docs/app/docs/[[...slug]]/page.tsx b/apps/docs/app/docs/[[...slug]]/page.tsx index 7bf8db8..9937fa6 100644 --- a/apps/docs/app/docs/[[...slug]]/page.tsx +++ b/apps/docs/app/docs/[[...slug]]/page.tsx @@ -1,4 +1,4 @@ -import { getPageImageUrl, getPageMarkdownUrl, source } from '@/lib/source'; +import { getPageImageUrl, getPageMarkdownUrl, source } from "@/lib/source"; import { DocsBody, DocsDescription, @@ -6,17 +6,17 @@ import { DocsTitle, MarkdownCopyButton, ViewOptionsPopover, -} from 'fumadocs-ui/layouts/docs/page'; -import { notFound } from 'next/navigation'; -import { getMDXComponents } from '@/components/mdx'; -import type { Metadata } from 'next'; -import { createRelativeLink } from 'fumadocs-ui/mdx'; -import { JsonLd } from '@/components/json-ld'; -import { breadcrumbSchema, techArticleSchema } from '@/lib/schema'; -import { buildMetadata } from '@/lib/seo'; -import { gitConfig } from '@/lib/shared'; +} from "fumadocs-ui/layouts/docs/page"; +import { notFound } from "next/navigation"; +import { getMDXComponents } from "@/components/mdx"; +import type { Metadata } from "next"; +import { createRelativeLink } from "fumadocs-ui/mdx"; +import { JsonLd } from "@/components/json-ld"; +import { breadcrumbSchema, techArticleSchema } from "@/lib/schema"; +import { buildMetadata } from "@/lib/seo"; +import { gitConfig } from "@/lib/shared"; -export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { +export default async function Page(props: PageProps<"/docs/[[...slug]]">) { const params = await props.params; const page = source.getPage(params.slug); if (!page) notFound(); @@ -26,11 +26,12 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { // Breadcrumbs mirror the URL, which is what Google expects them to. const trail = [ - { name: 'Docs', path: '/docs' }, + { name: "Docs", path: "/docs" }, ...page.slugs.map((_, index) => ({ name: - source.getPage(page.slugs.slice(0, index + 1))?.data.title ?? page.slugs[index], - path: `/docs/${page.slugs.slice(0, index + 1).join('/')}`, + source.getPage(page.slugs.slice(0, index + 1))?.data.title ?? + page.slugs[index], + path: `/docs/${page.slugs.slice(0, index + 1).join("/")}`, })), ]; @@ -47,7 +48,9 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { ]} /> {page.data.title} - {page.data.description} + + {page.data.description} +
      , + props: PageProps<"/docs/[[...slug]]">, ): Promise { const params = await props.params; const page = source.getPage(params.slug); @@ -86,6 +89,6 @@ export async function generateMetadata( path: page.url, imageUrl: getPageImageUrl(page).url, imageAlt: page.data.title, - type: 'article', + type: "article", }); } diff --git a/apps/docs/app/docs/layout.tsx b/apps/docs/app/docs/layout.tsx index a373143..1281dfd 100644 --- a/apps/docs/app/docs/layout.tsx +++ b/apps/docs/app/docs/layout.tsx @@ -1,8 +1,8 @@ -import { source } from '@/lib/source'; -import { DocsLayout } from 'fumadocs-ui/layouts/docs'; -import { baseOptions } from '@/lib/layout.shared'; +import { source } from "@/lib/source"; +import { DocsLayout } from "fumadocs-ui/layouts/docs"; +import { baseOptions } from "@/lib/layout.shared"; -export default function Layout({ children }: LayoutProps<'/docs'>) { +export default function Layout({ children }: LayoutProps<"/docs">) { return ( {children} diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 344f013..b860ff2 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -1,5 +1,5 @@ -@import 'tailwindcss'; -@import 'fumadocs-ui/css/preset.css'; +@import "tailwindcss"; +@import "fumadocs-ui/css/preset.css"; /* Colours are defined here rather than by importing a Fumadocs preset, so the whole palette is one violet-tinted ramp instead of a grey one with an accent @@ -107,7 +107,11 @@ html > body[data-scroll-locked] { height: 2.25rem; border-radius: 0.625rem; color: var(--color-fd-primary); - background-color: color-mix(in oklch, var(--color-fd-primary) 12%, transparent); + background-color: color-mix( + in oklch, + var(--color-fd-primary) 12%, + transparent + ); } /* The hero's dot grid — the one decorative signature on the page. Masked so it @@ -117,17 +121,32 @@ html > body[data-scroll-locked] { fainter than a light dot on a dark one at the same mix, so a single value leaves the grid invisible in light mode. */ :root { - --dot-grid-color: color-mix(in oklch, var(--color-fd-foreground) 30%, transparent); + --dot-grid-color: color-mix( + in oklch, + var(--color-fd-foreground) 30%, + transparent + ); } .dark { - --dot-grid-color: color-mix(in oklch, var(--color-fd-foreground) 20%, transparent); + --dot-grid-color: color-mix( + in oklch, + var(--color-fd-foreground) 20%, + transparent + ); } @utility dot-grid { - background-image: radial-gradient(var(--dot-grid-color) 1.2px, transparent 1.2px); + background-image: radial-gradient( + var(--dot-grid-color) 1.2px, + transparent 1.2px + ); background-size: 22px 22px; - mask-image: radial-gradient(ellipse 85% 70% at 50% 0%, black, transparent 80%); + mask-image: radial-gradient( + ellipse 85% 70% at 50% 0%, + black, + transparent 80% + ); } /* Geist Mono renders wide at the inherited size; the small step down keeps an diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx index 1bb8c47..880b000 100644 --- a/apps/docs/app/layout.tsx +++ b/apps/docs/app/layout.tsx @@ -1,16 +1,19 @@ -import { Analytics } from '@vercel/analytics/next'; -import { RootProvider } from 'fumadocs-ui/provider/next'; -import './global.css'; -import type { Metadata } from 'next'; -import { Geist, Geist_Mono } from 'next/font/google'; -import { JsonLd } from '@/components/json-ld'; -import { siteGraph } from '@/lib/schema'; -import { buildMetadata } from '@/lib/seo'; -import { packageName } from '@/lib/shared'; -import { siteConfig } from '@/lib/site'; +import { Analytics } from "@vercel/analytics/next"; +import { RootProvider } from "fumadocs-ui/provider/next"; +import "./global.css"; +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { JsonLd } from "@/components/json-ld"; +import { siteGraph } from "@/lib/schema"; +import { buildMetadata } from "@/lib/seo"; +import { packageName } from "@/lib/shared"; +import { siteConfig } from "@/lib/site"; -const geistSans = Geist({ subsets: ['latin'], variable: '--font-geist-sans' }); -const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-geist-mono' }); +const geistSans = Geist({ subsets: ["latin"], variable: "--font-geist-sans" }); +const geistMono = Geist_Mono({ + subsets: ["latin"], + variable: "--font-geist-mono", +}); // Spread first, explicit keys after, so the title template can't be wiped by // the spread. metadataBase resolves every relative canonical and OG path. @@ -26,26 +29,25 @@ export const metadata: Metadata = { // Google Search Console ownership check. Public by design — it proves control of the // site, it grants nothing. verification: { - google: 'O-sqozPAg0xCaOeVyHDEaf0hcHrCrMEkOK0E_0TGBCo', + google: "O-sqozPAg0xCaOeVyHDEaf0hcHrCrMEkOK0E_0TGBCo", }, keywords: [ - 'react', - 'firebase', - 'firebase auth', - 'react hooks', - 'typescript', - 'authentication', - 'nextjs', + "react", + "firebase", + "firebase auth", + "react hooks", + "typescript", + "authentication", + "nextjs", ], }; -export default function Layout({ children }: LayoutProps<'/'>) { +export default function Layout({ children }: LayoutProps<"/">) { return ( + suppressHydrationWarning> {children} diff --git a/apps/docs/app/llms-full.txt/route.ts b/apps/docs/app/llms-full.txt/route.ts index d494d2c..fcccaee 100644 --- a/apps/docs/app/llms-full.txt/route.ts +++ b/apps/docs/app/llms-full.txt/route.ts @@ -1,4 +1,4 @@ -import { getLLMText, source } from '@/lib/source'; +import { getLLMText, source } from "@/lib/source"; export const revalidate = false; @@ -6,5 +6,5 @@ export async function GET() { const scan = source.getPages().map(getLLMText); const scanned = await Promise.all(scan); - return new Response(scanned.join('\n\n')); + return new Response(scanned.join("\n\n")); } diff --git a/apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts b/apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts index 395e6b6..726ff58 100644 --- a/apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts +++ b/apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts @@ -1,11 +1,11 @@ -import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source'; -import { notFound } from 'next/navigation'; +import { getLLMText, getPageMarkdownUrl, source } from "@/lib/source"; +import { notFound } from "next/navigation"; export const revalidate = false; export async function GET( _req: Request, - { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>, + { params }: RouteContext<"/llms.mdx/docs/[[...slug]]">, ) { const { slug } = await params; const page = source.getPage(slug?.slice(0, -1)); @@ -13,13 +13,13 @@ export async function GET( return new Response(await getLLMText(page), { headers: { - 'Content-Type': 'text/markdown', + "Content-Type": "text/markdown", }, }); } export function generateStaticParams() { - return source.getPages().map((page) => ({ + return source.getPages().map(page => ({ lang: page.locale, slug: getPageMarkdownUrl(page).segments, })); diff --git a/apps/docs/app/llms.txt/route.ts b/apps/docs/app/llms.txt/route.ts index fc80cb6..f18f409 100644 --- a/apps/docs/app/llms.txt/route.ts +++ b/apps/docs/app/llms.txt/route.ts @@ -1,5 +1,5 @@ -import { source } from '@/lib/source'; -import { llms } from 'fumadocs-core/source'; +import { source } from "@/lib/source"; +import { llms } from "fumadocs-core/source"; export const revalidate = false; diff --git a/apps/docs/app/not-found.tsx b/apps/docs/app/not-found.tsx index ed8b21d..634ccac 100644 --- a/apps/docs/app/not-found.tsx +++ b/apps/docs/app/not-found.tsx @@ -1,27 +1,28 @@ -import { ArrowRight, BookOpen, Home } from 'lucide-react'; -import type { Metadata } from 'next'; -import Link from 'next/link'; -import { buildMetadata } from '@/lib/seo'; -import { appName, packageName } from '@/lib/shared'; +import { ArrowRight, BookOpen, Home } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { buildMetadata } from "@/lib/seo"; +import { appName, packageName } from "@/lib/shared"; export const metadata: Metadata = buildMetadata({ - title: 'Page not found', - description: 'That page does not exist. The hook reference and guides are still here.', + title: "Page not found", + description: + "That page does not exist. The hook reference and guides are still here.", noIndex: true, }); const SUGGESTIONS = [ { icon: BookOpen, - title: 'Getting started', - body: 'Install the package and sign a user in.', - href: '/docs/getting-started', + title: "Getting started", + body: "Install the package and sign a user in.", + href: "/docs/getting-started", }, { icon: ArrowRight, - title: 'Auth reference', - body: 'All twenty hooks, grouped by flow.', - href: '/docs/auth', + title: "Auth reference", + body: "All twenty hooks, grouped by flow.", + href: "/docs/auth", }, ]; @@ -33,19 +34,20 @@ export default function NotFound() { site, arriving from a stale link with no idea where they landed. */} - + className="inline-flex items-center gap-2 text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"> + {packageName} -

      404

      +

      + 404 +

      That page doesn’t exist

      -

      - The link may be out of date, or the page moved when the docs were reorganised. - You’re on the {appName} documentation — try one of these. +

      + The link may be out of date, or the page moved when the docs were + reorganised. You’re on the {appName} documentation — try one of these.

      @@ -53,24 +55,22 @@ export default function NotFound() { + className="group flex items-center gap-4 surface px-4 py-3 transition-colors hover:border-fd-primary/40"> {title} - {body} + {body} - + ))}
      + className="mt-8 inline-flex items-center gap-2 text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"> Back to the home page diff --git a/apps/docs/app/og/docs/[...slug]/route.tsx b/apps/docs/app/og/docs/[...slug]/route.tsx index 8e0fa60..706ad05 100644 --- a/apps/docs/app/og/docs/[...slug]/route.tsx +++ b/apps/docs/app/og/docs/[...slug]/route.tsx @@ -1,11 +1,14 @@ -import { notFound } from 'next/navigation'; -import { ImageResponse } from 'next/og'; -import { OgImage } from '@/components/og-image'; -import { getPageImageUrl, source } from '@/lib/source'; +import { notFound } from "next/navigation"; +import { ImageResponse } from "next/og"; +import { OgImage } from "@/components/og-image"; +import { getPageImageUrl, source } from "@/lib/source"; export const revalidate = false; -export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) { +export async function GET( + _req: Request, + { params }: RouteContext<"/og/docs/[...slug]">, +) { const { slug } = await params; const page = source.getPage(slug.slice(0, -1)); if (!page) notFound(); @@ -17,7 +20,7 @@ export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[... } export function generateStaticParams() { - return source.getPages().map((page) => ({ + return source.getPages().map(page => ({ lang: page.locale, slug: getPageImageUrl(page).segments, })); diff --git a/apps/docs/app/og/route.tsx b/apps/docs/app/og/route.tsx index cf81fc9..d4114d2 100644 --- a/apps/docs/app/og/route.tsx +++ b/apps/docs/app/og/route.tsx @@ -1,6 +1,6 @@ -import { ImageResponse } from 'next/og'; -import { OgImage } from '@/components/og-image'; -import { siteConfig } from '@/lib/site'; +import { ImageResponse } from "next/og"; +import { OgImage } from "@/components/og-image"; +import { siteConfig } from "@/lib/site"; /** * The default OG card, for the home page, 404 and anything without its own. @@ -11,8 +11,8 @@ export function GET(request: Request) { return new ImageResponse( , { width: 1200, height: 630 }, ); diff --git a/apps/docs/app/robots.ts b/apps/docs/app/robots.ts index 790aaba..8487c38 100644 --- a/apps/docs/app/robots.ts +++ b/apps/docs/app/robots.ts @@ -1,12 +1,12 @@ -import type { MetadataRoute } from 'next'; -import { isIndexableEnv, siteConfig } from '@/lib/site'; +import type { MetadataRoute } from "next"; +import { isIndexableEnv, siteConfig } from "@/lib/site"; export default function robots(): MetadataRoute.Robots { // Preview deployments get a blanket disallow so Vercel's per-commit URLs // never compete with the production site for the same content. if (!isIndexableEnv) { return { - rules: { userAgent: '*', disallow: '/' }, + rules: { userAgent: "*", disallow: "/" }, }; } @@ -14,9 +14,9 @@ export default function robots(): MetadataRoute.Robots { // AI crawlers are deliberately allowed: being cited by an answer engine is // how a library like this gets found, and the docs are public anyway. rules: { - userAgent: '*', - allow: '/', - disallow: ['/api/', '/og/'], + userAgent: "*", + allow: "/", + disallow: ["/api/", "/og/"], }, sitemap: `${siteConfig.url}/sitemap.xml`, host: siteConfig.url, diff --git a/apps/docs/app/sitemap.ts b/apps/docs/app/sitemap.ts index 673ff30..efbd259 100644 --- a/apps/docs/app/sitemap.ts +++ b/apps/docs/app/sitemap.ts @@ -1,6 +1,6 @@ -import type { MetadataRoute } from 'next'; -import { source } from '@/lib/source'; -import { siteConfig } from '@/lib/site'; +import type { MetadataRoute } from "next"; +import { source } from "@/lib/source"; +import { siteConfig } from "@/lib/site"; export const revalidate = false; @@ -8,15 +8,16 @@ export default function sitemap(): MetadataRoute.Sitemap { const url = (path: string) => new URL(path, siteConfig.url).toString(); return [ - { url: url('/'), changeFrequency: 'monthly', priority: 1 }, + { url: url("/"), changeFrequency: "monthly", priority: 1 }, // Every docs page, straight from the same source the navigation uses, so a // new page is listed the moment it exists — no second list to maintain. - ...source.getPages().map((page) => ({ + ...source.getPages().map(page => ({ url: url(page.url), - changeFrequency: 'monthly' as const, + changeFrequency: "monthly" as const, // The reference is the reason people arrive, but getting started should // outrank an individual hook page. - priority: page.url === '/docs' || page.url.split('/').length <= 3 ? 0.8 : 0.6, + priority: + page.url === "/docs" || page.url.split("/").length <= 3 ? 0.8 : 0.6, })), ]; } diff --git a/apps/docs/components/code-sample.tsx b/apps/docs/components/code-sample.tsx index 4281776..9ee986d 100644 --- a/apps/docs/components/code-sample.tsx +++ b/apps/docs/components/code-sample.tsx @@ -1,6 +1,6 @@ -import { highlight } from 'fumadocs-core/highlight'; -import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; -import type { ReactNode } from 'react'; +import { highlight } from "fumadocs-core/highlight"; +import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock"; +import type { ReactNode } from "react"; /** * Shiki-highlighted code for hand-written pages. MDX code fences get this from @@ -16,11 +16,11 @@ import type { ReactNode } from 'react'; * lines scroll; overriding to `w-full` is what gives `pre-wrap` something to * wrap against. Without the `pre` override the wrap silently does nothing. */ -const WRAP = '[&_pre]:w-full [&_code]:whitespace-pre-wrap [&_code]:break-words'; +const WRAP = "[&_pre]:w-full [&_code]:whitespace-pre-wrap [&_code]:break-words"; export async function CodeSample({ code, - lang = 'tsx', + lang = "tsx", title, className, wrap = false, @@ -33,18 +33,17 @@ export async function CodeSample({ }): Promise { return highlight(code, { lang, - themes: { light: 'github-light', dark: 'github-dark' }, + themes: { light: "github-light", dark: "github-dark" }, // Emit --shiki-light/--shiki-dark variables instead of a baked-in colour. // Fumadocs' stylesheet switches on those; without this the light theme is // hardcoded and the blocks stay light in dark mode. defaultColor: false, components: { - pre: (props) => ( + pre: props => ( + className={[wrap && WRAP, className].filter(Boolean).join(" ")} + keepBackground={false}>
               
             ),
      diff --git a/apps/docs/components/copy-button.tsx b/apps/docs/components/copy-button.tsx
      index ddbe37a..e2d47e4 100644
      --- a/apps/docs/components/copy-button.tsx
      +++ b/apps/docs/components/copy-button.tsx
      @@ -1,7 +1,7 @@
      -'use client';
      +"use client";
       
      -import { Check, Copy } from 'lucide-react';
      -import { useEffect, useState } from 'react';
      +import { Check, Copy } from "lucide-react";
      +import { useEffect, useState } from "react";
       
       /**
        * Copy-to-clipboard for the hero's install command, which is plain text rather
      @@ -30,10 +30,9 @@ export function CopyButton({ value, label }: { value: string; label: string }) {
                 () => {},
               );
             }}
      -      className="text-fd-muted-foreground hover:text-fd-foreground -mr-1 rounded p-1 transition-colors"
      -    >
      +      className="-mr-1 rounded p-1 text-fd-muted-foreground transition-colors hover:text-fd-foreground">
             {copied ? (
      -        
      +        
             ) : (
               
             )}
      diff --git a/apps/docs/components/json-ld.tsx b/apps/docs/components/json-ld.tsx
      index 466b10f..93bbdf4 100644
      --- a/apps/docs/components/json-ld.tsx
      +++ b/apps/docs/components/json-ld.tsx
      @@ -4,14 +4,17 @@
        */
       export function JsonLd({ data }: { data: object | object[] }) {
         const json = JSON.stringify(data)
      -    .replace(/&/g, '\\u0026')
      -    .replace(/
      +