From e427d26ff8183ddb38b422aaef79577ee13c56aa Mon Sep 17 00:00:00 2001 From: Moritz Petersen Date: Wed, 9 Sep 2026 16:17:43 +0200 Subject: [PATCH 1/2] Serve www.radix-ui.com from Cloudflare Workers static assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vercel is no longer a deploy target for this site. The site is a Next.js static export (`output: "export"`) served by Cloudflare Workers static assets in the WorkOS Marketing Sites account (wrangler.jsonc, Worker `radix-website`). Everything except three pieces was already static (generateStaticParams everywhere, client-side MiniSearch, static og images, no next/image): - The /api/markdown route and proxy.ts converted rendered pages to Markdown at request time (rehype-remark + oxfmt, a native binary). The conversion moves to scripts/html-to-markdown.ts and scripts/build-markdown.ts writes a `.md` twin next to every exported page after `next build`, so `/…/dialog.md` is a static file. worker/index.ts, scoped with `assets.run_worker_first` to the former proxy matcher, keeps answering `Accept: text/markdown` with the twin. - /colors/custom read cookies() and searchParams on the server. The saved and shared palette are now restored on the client after hydration; the appearance cookie that only informed SSR is gone. - next.config redirects()/rewrites() are ignored by an export: redirects move to public/_redirects (same status codes), headers to public/_headers (immutable /_next/static, nosniff on .md, noindex on workers.dev hosts). Build-time SITE_ENV (production only for the `main` branch on Workers Builds; never for `next dev` or NEXT_PUBLIC_SITE_ENV=preview) gates Google Analytics and adds robots noindex to previews, so a workers.dev preview can't report or be indexed as the live site. metadataBase is the canonical www host so og:image URLs resolve without a redirect. scripts/check-export.ts fails the build if the export is missing the files the hosting relies on. README documents build, Workers Builds settings, the apex/icons redirect rules and the cutover. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 3 + README.md | 91 ++ app/blog/page.tsx | 3 +- app/colors/custom/color-theme-provider.tsx | 57 +- app/colors/custom/page.client.tsx | 25 +- app/colors/custom/page.tsx | 33 +- app/colors/custom/palette-cookie.ts | 38 +- next.config.js | 119 +-- package.json | 14 +- pnpm-lock.yaml | 827 +++++++++++++++++- proxy.ts | 46 - public/_headers | 16 + public/_redirects | 29 + scripts/build-markdown.ts | 55 ++ scripts/check-export.ts | 87 ++ .../route.ts => scripts/html-to-markdown.ts | 73 +- utils/analytics.ts | 8 +- utils/metadata.ts | 11 +- worker/index.ts | 55 ++ wrangler.jsonc | 35 + 20 files changed, 1310 insertions(+), 315 deletions(-) delete mode 100644 proxy.ts create mode 100644 public/_headers create mode 100644 public/_redirects create mode 100644 scripts/build-markdown.ts create mode 100644 scripts/check-export.ts rename app/api/markdown/[...path]/route.ts => scripts/html-to-markdown.ts (62%) create mode 100644 worker/index.ts create mode 100644 wrangler.jsonc 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..81a6b77d9 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,97 @@ 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/index.ts`, which runs for docs and blog paths to answer +`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`, `_redirects`, + `_headers`, the search index, or a Markdown twin of a docs page is missing, + or a `_redirects` rule is malformed. + +Redirects live in `public/_redirects` and response headers in +`public/_headers`; `redirects()`, `rewrites()` and `headers()` in +`next.config.js` would be ignored by the export. Unknown paths get Next's +not-found page as `out/404.html` with a 404 status. + +**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..b7c0d900b 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,19 @@ 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. 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