diff --git a/.gitignore b/.gitignore index 5acd18560..3eb8612b1 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ tsconfig.tsbuildinfo .idea next-env.d.ts + +# cloudflare +.wrangler diff --git a/README.md b/README.md index 8917adb68..4f8134aaf 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,115 @@ pnpm dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Run `pnpm build` to produce the static export in `out/`, and `pnpm preview` to serve it the way production does (see [Hosting](#hosting)). + +## Hosting + +The site is a Next.js static export (`output: "export"`) served from +[Cloudflare Workers static assets](https://developers.cloudflare.com/workers/static-assets/) +in the **WorkOS Marketing Sites** Cloudflare account: Worker `radix-website`, +hostname `www.radix-ui.com`, config in `wrangler.jsonc`. The only Worker code +is `worker/`, which runs for the paths listed in `run_worker_first`: it +answers the legacy redirects (`worker/redirects.ts`) and, for docs and blog +pages, `Accept: text/markdown` requests with the Markdown version of a page; +every other request is served straight from `out/`. + +### Build + +`pnpm build` runs four steps and writes the site to `out/`: + +1. `pnpm build:search` — the client-side search index (`public/search-index.json`). +2. `next build` — the static export. +3. `pnpm build:markdown` — a Markdown twin (`.md`) next to every exported + page, using the same conversion the former `/api/markdown` route did at + request time. `/primitives/docs/components/dialog.md` and friends are plain + static files. +4. `pnpm check:export` — fails the build if `out/404.html`, `_headers`, the + search index, or a Markdown twin of a docs page is missing, or if a + `_redirects` file has crept back in (see below). + +`pnpm test` runs the Worker's unit tests (Node's test runner via `tsx`): +every legacy redirect, query-string preservation, and that no real page is +caught by a redirect rule. + +**Redirects** are the former `redirects()` from `next.config.js`, which the +export ignores. They live in `worker/redirects.ts`, not in a `_redirects` +file: several dynamic rules overlap (`/docs/colors/getting-started/*` is also +matched by `/docs/colors/*`), and the platform's `_redirects` evaluation did +not honor file order for overlapping splat rules, so the broader rule won. +In the Worker the first matching rule wins, in the order written. The +`run_worker_first` list in `wrangler.jsonc` must cover every redirect source +(`REDIRECT_PATHS`, checked by the test). Response headers live in +`public/_headers`. Unknown paths get Next's not-found page as `out/404.html` +with a 404 status. + +Small behavior differences from Vercel, all harmless: trailing-slash +redirects (`/blog/` → `/blog`) and `.html` URLs (`/blog.html` → `/blog`) are +answered by the platform with a 307 (Vercel: 308 and 404), and there is no +`/robots.txt` or `/sitemap.xml` on the custom domain, as before. On +`workers.dev` hostnames Cloudflare injects a managed `robots.txt` and the +`_headers` file adds `X-Robots-Tag: noindex`, so previews are never indexed. + +**Production vs preview.** `next.config.js` decides the build mode: Workers +Builds injects `WORKERS_CI_BRANCH`, and any branch other than `main` is a +preview build; `NEXT_PUBLIC_SITE_ENV=preview` forces preview mode for manual +builds, and `next dev` is always preview. Preview builds ship no Google +Analytics snippet and a `robots: noindex` meta tag; production builds ship +both as before. There are no other build variables: the site uses plain +`` tags, so it needs neither the Next image optimizer nor Cloudflare +Image Transformations. + +### Deploy + +- `pnpm deploy` (`wrangler deploy`) builds and deploys; `pnpm preview` + (`wrangler dev` in preview mode) builds and serves locally on + http://localhost:8787. Both need a Cloudflare login with access to the + Marketing Sites account. +- Production deploys run through **Workers Builds** (Cloudflare GitHub App) on + push to `main`: + - Build command: _empty_ (wrangler runs `pnpm build` itself via + `build.command` in `wrangler.jsonc`). + - Deploy command: `npx wrangler deploy`. + - Non-production branch deploy command: `npx wrangler versions upload`, with + _non-production branch builds_ enabled to get a preview URL and a PR + comment for every branch. Those builds are detected as previews + automatically (see above), so there are no per-branch variables to keep in + sync. + - Build variables: none required. + - Node version comes from `.nvmrc`, the package manager from + `packageManager` in `package.json` (pnpm). + +### Hostnames + +The Worker serves `www.radix-ui.com` (custom domain). The apex and the legacy +icons hostname are redirects only, best done with **Redirect Rules** in the +`radix-ui.com` zone (Rules → Redirect Rules, one rule each, no Worker +involved), matching what Vercel does today: + +| Rule | When incoming requests match (custom filter expression) | Then | Status | +| ----------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------ | +| Apex to www | `(http.host eq "radix-ui.com")` | Dynamic redirect, expression `concat("https://www.radix-ui.com", http.request.uri.path)`, _preserve query string_ on | 308 | +| Icons to the icons page | `(http.host eq "icons.radix-ui.com")` | Static redirect to `https://www.radix-ui.com/icons`, _preserve query string_ on | 308 | + +Both hostnames need a proxied DNS record in the zone for the rules to run +(e.g. an `AAAA` record with the value `100::` for each; the record only has to +exist and be proxied). This replaces the Vercel project `radix-icons` (the +`workos/radix-icons-redirect` repository), whose only job was the second rule. + +### Cutover checklist + +1. Move the `radix-ui.com` nameservers to the Cloudflare zone in the + Marketing Sites account (records currently mirror Vercel). +2. Connect this repository to Workers Builds with the settings above and let + `main` deploy to `radix-website.workos-sites.workers.dev`; check a few + pages, `/…/dialog.md`, an `Accept: text/markdown` request and a 404. +3. Delete the interim `www`, apex and `icons` records that point at Vercel; + add `www.radix-ui.com` as the Worker's custom domain (Settings → Domains & + Routes) and create the two Redirect Rules above with their placeholder + records. +4. Delete the Vercel projects `radix-website` and `radix-icons` in the + `workos` team. + ## Authors - Pedro Duarte ([@peduarte](https://twitter.com/peduarte)) diff --git a/app/blog/page.tsx b/app/blog/page.tsx index f6d97e7c7..f6b25afdc 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -26,7 +26,8 @@ export const metadata: Metadata = { title: "Blog – Radix UI", description, openGraph: { - images: ["https://radix-ui.com/social/themes.png"], + // Resolved against `metadataBase`. + images: ["/social/themes.png"], }, }; diff --git a/app/colors/custom/color-theme-provider.tsx b/app/colors/custom/color-theme-provider.tsx index 9987a21b7..5bf92cf38 100644 --- a/app/colors/custom/color-theme-provider.tsx +++ b/app/colors/custom/color-theme-provider.tsx @@ -4,41 +4,39 @@ import { useTheme } from "next-themes"; import * as React from "react"; import { ColorThemeContext } from "./color-theme-context"; import { - type Appearance, type Palette, - writeAppearanceCookie, + defaultPalette, + readPaletteCookie, writePaletteCookie, } from "./palette-cookie"; -import { buildPaletteSearchParams } from "./palette-url"; +import { applyPaletteParams, buildPaletteSearchParams } from "./palette-url"; import { getColorName } from "./utils"; import { useIsHydrated } from "@utils/use-is-hydrated"; -export function ColorThemeProvider({ - initialPalette, - initialAppearance, - children, -}: { - initialPalette: Palette; - initialAppearance?: Appearance; - children: React.ReactNode; -}) { +export function ColorThemeProvider({ children }: { children: React.ReactNode }) { const { resolvedTheme } = useTheme(); const isHydrated = useIsHydrated(); - const [palette, setPalette] = React.useState(initialPalette); + // The page is a static export, so the HTML always carries the default + // palette. The visitor's saved palette (cookie) and any shared palette + // (URL) are restored right after hydration; until then the URL must not be + // touched, or the shared params would be wiped before they are read. + const [palette, setPalette] = React.useState(defaultPalette); + const [isRestored, setIsRestored] = React.useState(false); - // Which theme's values to show in the inputs. Next themes only knows the - // resolved theme on the client, so before hydration we trust the appearance - // cookie, ensuring the SSR markup and the first client render agree. After - // hydration we follow Next Themes. - const isDark = isHydrated ? resolvedTheme === "dark" : initialAppearance === "dark"; - - // Mirror the resolved appearance into a cookie so the next SSR render starts - // with the correct theme already selected. React.useEffect(() => { - if (resolvedTheme === "light" || resolvedTheme === "dark") { - writeAppearanceCookie(resolvedTheme); - } - }, [resolvedTheme]); + // The URL is the source of truth for sharing. Any palette params layer on + // top of the cookie-restored palette, so a shared link reproduces exactly + // what the sharer saw regardless of the visitor's own saved palette. + setPalette( + applyPaletteParams(readPaletteCookie(), new URLSearchParams(window.location.search)), + ); + setIsRestored(true); + }, []); + + // Which theme's values to show in the inputs. Next themes only knows the + // resolved theme on the client, so the hydration render shows the light + // values, like the static HTML. After hydration we follow Next Themes. + const isDark = isHydrated && resolvedTheme === "dark"; const updatePalette = React.useCallback((patch: Partial) => { setPalette((prev) => { @@ -50,9 +48,12 @@ export function ColorThemeProvider({ // Keep the URL in sync with the palette so the page is always shareable and // survives a reload. We use history.replaceState rather than the Next router - // to avoid a server round-trip and re-render — the client already holds the - // authoritative state, the URL is just a reflection of it. + // to avoid a re-render — the client already holds the authoritative state, + // the URL is just a reflection of it. React.useEffect(() => { + if (!isRestored) { + return; + } const current = new URLSearchParams(window.location.search); const next = buildPaletteSearchParams(palette, current); const nextSearch = next.toString(); @@ -62,7 +63,7 @@ export function ColorThemeProvider({ const url = window.location.pathname + (nextSearch ? `?${nextSearch}` : "") + window.location.hash; window.history.replaceState(window.history.state, "", url); - }, [palette]); + }, [palette, isRestored]); const setAccentValue = React.useCallback( (value: string) => updatePalette(isDark ? { darkAccent: value } : { lightAccent: value }), diff --git a/app/colors/custom/page.client.tsx b/app/colors/custom/page.client.tsx index 0ace3557b..03ca3a1be 100644 --- a/app/colors/custom/page.client.tsx +++ b/app/colors/custom/page.client.tsx @@ -72,7 +72,6 @@ import { getPeopleForColor } from "@utils/people"; import { ToggleGroup } from "radix-ui"; import styles from "./page.module.css"; import { useIsHydrated } from "@utils/use-is-hydrated"; -import { Appearance } from "./palette-cookie"; export function ColorStyles() { const { lightModeResult, darkModeResult } = useColorThemeContext(); @@ -123,25 +122,21 @@ export function BackgroundColorField({ id }: { id: string }) { return ; } -export function ThemeSwitcher({ - initialAppearance, - ...props -}: Omit< - React.ComponentPropsWithoutRef, - "value" | "onValueChange" | "children" -> & { - initialAppearance?: Appearance; -}) { +export function ThemeSwitcher( + props: Omit< + React.ComponentPropsWithoutRef, + "value" | "onValueChange" | "children" + >, +) { const { resolvedTheme, setTheme } = useTheme(); const isHydrated = useIsHydrated(); if (!isHydrated) { + // The resolved theme is only known on the client; render the control + // inert until hydration so the markup matches the static HTML. It stays + // controlled ("light", like the values shown before hydration) so it + // doesn't flip from uncontrolled to controlled once the theme is known. return ( - void 0} - {...props} - > + void 0} {...props}> diff --git a/app/colors/custom/page.tsx b/app/colors/custom/page.tsx index d62c50d66..357a715da 100644 --- a/app/colors/custom/page.tsx +++ b/app/colors/custom/page.tsx @@ -1,4 +1,3 @@ -import { cookies } from "next/headers"; import NextLink from "next/link"; import { ColorsHeader } from "@components/colors-header"; import { ColorsMobileMenu } from "@components/colors-mobile-menu"; @@ -19,13 +18,6 @@ import { Swatch, ThemeSwitcher, } from "./page.client"; -import { - APPEARANCE_COOKIE, - PALETTE_COOKIE, - parseAppearanceCookie, - parsePaletteCookie, -} from "./palette-cookie"; -import { applyPaletteParams } from "./palette-url"; import { baseMetadata } from "@utils/metadata"; import type { Metadata } from "next"; @@ -35,22 +27,11 @@ export const metadata: Metadata = { description: "An open-source color system for designing beautiful, accessible websites and apps.", }; -export default async function CustomColorsPage({ - searchParams, -}: { - searchParams: Promise>; -}) { - const cookieStore = await cookies(); - // The URL is the source of truth for sharing. Any palette params layer on top - // of the cookie-restored palette, so a shared link reproduces exactly what - // the sharer saw regardless of the visitor's own saved palette. - const initialPalette = applyPaletteParams( - parsePaletteCookie(cookieStore.get(PALETTE_COOKIE)?.value), - await searchParams, - ); - const initialAppearance = parseAppearanceCookie(cookieStore.get(APPEARANCE_COOKIE)?.value); +// Statically exported: the saved (cookie) and shared (URL) palette are restored +// on the client by ColorThemeProvider. +export default function CustomColorsPage() { return ( - + @@ -83,11 +64,7 @@ export default async function CustomColorsPage({ Create a custom palette - + diff --git a/app/colors/custom/palette-cookie.ts b/app/colors/custom/palette-cookie.ts index 211a01030..4347483fe 100644 --- a/app/colors/custom/palette-cookie.ts +++ b/app/colors/custom/palette-cookie.ts @@ -4,27 +4,6 @@ export const PALETTE_COOKIE = "colors-custom-palette"; export const PALETTE_MAX_AGE_SECONDS = 60 * 60 * 2; -// The resolved appearance ("light"/"dark") is mirrored to a cookie so the -// server can render the correct theme's input values on the first paint. It's -// a non-sensitive UI hint, so we keep it around long enough that returning -// visitors don't flash. -export const APPEARANCE_COOKIE = "colors-custom-appearance"; -export const APPEARANCE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; - -export type Appearance = "light" | "dark"; - -export function parseAppearanceCookie(value: string | undefined): Appearance | undefined { - return value === "light" || value === "dark" ? value : undefined; -} - -export function writeAppearanceCookie(appearance: Appearance): void { - if (typeof document === "undefined") { - return; - } - const secure = location.protocol === "https:" ? "; Secure" : ""; - document.cookie = `${APPEARANCE_COOKIE}=${appearance}; path=/colors/custom; max-age=${APPEARANCE_MAX_AGE_SECONDS}; SameSite=Lax${secure}`; -} - export interface Palette { lightAccent: string; lightGray: string; @@ -45,7 +24,7 @@ export const defaultPalette: Palette = { // Only accept strings that parse as a real color. This both repairs corrupt // cookies and prevents a hand-crafted cookie from injecting arbitrary text -// into the server-rendered