Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ tsconfig.tsbuildinfo
.idea

next-env.d.ts

# cloudflare
.wrangler
109 changes: 109 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<page>.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
`<img>` 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))
Expand Down
3 changes: 2 additions & 1 deletion app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
};

Expand Down
57 changes: 29 additions & 28 deletions app/colors/custom/color-theme-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Palette>) => {
setPalette((prev) => {
Expand All @@ -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();
Expand All @@ -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 }),
Expand Down
27 changes: 11 additions & 16 deletions app/colors/custom/page.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -123,25 +122,21 @@ export function BackgroundColorField({ id }: { id: string }) {
return <ColorField id={id} value={bgValue} onValueChange={setBgValue} />;
}

export function ThemeSwitcher({
initialAppearance,
...props
}: Omit<
React.ComponentPropsWithoutRef<typeof SegmentedControl.Root>,
"value" | "onValueChange" | "children"
> & {
initialAppearance?: Appearance;
}) {
export function ThemeSwitcher(
props: Omit<
React.ComponentPropsWithoutRef<typeof SegmentedControl.Root>,
"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 (
<SegmentedControl.Root
disabled={!initialAppearance}
value={initialAppearance}
onValueChange={() => void 0}
{...props}
>
<SegmentedControl.Root disabled value="light" onValueChange={() => void 0} {...props}>
<SegmentedControl.Item value="light">
<Flex as="span" align="center" gap="2" ml="-1">
<SunIcon />
Expand Down
33 changes: 5 additions & 28 deletions app/colors/custom/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand All @@ -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<Record<string, string | string[] | undefined>>;
}) {
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 (
<ColorThemeProvider initialPalette={initialPalette} initialAppearance={initialAppearance}>
<ColorThemeProvider>
<ColorStyles />
<RootTheme className="radix-themes-custom-fonts">
<Favicon />
Expand Down Expand Up @@ -83,11 +64,7 @@ export default async function CustomColorsPage({
Create a custom palette
</Heading>

<ThemeSwitcher
initialAppearance={initialAppearance}
style={{ backgroundColor: "transparent" }}
mt="5"
/>
<ThemeSwitcher style={{ backgroundColor: "transparent" }} mt="5" />
</Flex>

<Box mb="9">
Expand Down
38 changes: 14 additions & 24 deletions app/colors/custom/palette-cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 <style> (the value is later interpolated into CSS).
// into the rendered <style> (the value is later interpolated into CSS).
function sanitizeColor(value: unknown, fallback: string): string {
if (typeof value !== "string") {
return fallback;
Expand All @@ -67,8 +46,8 @@ function safeDecode(value: string): string | null {
}

function parseJson(value: string): unknown {
// Next decodes percent-encoding for us, but fall back to manual decoding in
// case the value arrives still-encoded.
// The cookie is written percent-encoded; also accept an already-decoded
// value in case it arrives that way.
for (const candidate of [value, safeDecode(value)]) {
if (candidate === null) {
continue;
Expand Down Expand Up @@ -101,6 +80,17 @@ export function parsePaletteCookie(value: string | undefined): Palette {
};
}

// The page is statically exported, so the saved palette is restored in the
// browser (see color-theme-provider.tsx) rather than by a server render.
export function readPaletteCookie(): Palette {
if (typeof document === "undefined") {
return defaultPalette;
}
const prefix = `${PALETTE_COOKIE}=`;
const entry = document.cookie.split("; ").find((cookie) => cookie.startsWith(prefix));
return parsePaletteCookie(entry?.slice(prefix.length));
}

export function writePaletteCookie(palette: Palette): void {
if (typeof document === "undefined") {
return;
Expand Down
Loading