chore: migrate package sources from Aphrody

This commit is contained in:
aphrody-dev committed 2026-10-12 08:23:52 +02:00
1 parent 0238953800
commit 208651249c
53 files changed
+4072 -2

No files matched your search

+123 -2
View File
@@ -1,2 +1,123 @@
# aphrody-m3-next
Aphrody package @aphrody/m3-next
<!-- SPDX-License-Identifier: Apache-2.0 -->
# `@aphrody/m3-next`
Next.js 16 for the M3 stack: App Router, Cache Components, `proxy.ts`, React Compiler, typed routes, Tailwind v4 and Base UI,
run on Bun with no Node. `examples/app` is a working app (build and serve through yolo), `examples/tauri` the static export a
Tauri webview loads.
| Import | Runtime | Contents |
| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@aphrody/m3-next` | any (config, server, client, edge) | `withM3`, metadata and viewport helpers, scheme helpers, `colorSchemeScript`, `m3TypefaceCss`, crawler files (sitemap, robots, llms.txt, JSON-LD) |
| `@aphrody/m3-next/server` | Server Components | `M3Html`, `M3Root` (class, `color-scheme`, inline scheme, typefaces, pre-paint script), `readM3ThemeMode`, `m3Css` |
| `@aphrody/m3-next/client` | `'use client'` | `M3ThemeProvider`, `M3NextThemeProvider`, `useM3Theme`, `useM3ThemeToggle` |
| `@aphrody/m3-next/proxy` | `proxy.ts` | `createM3Proxy`: every page request carries a valid theme cookie |
| `@aphrody/m3-next/m3.css` | CSS | tokens, expressive tokens, colour roles and the Tailwind preset (`@aphrody/m3-tailwind`) |
| `m3-next` (bin) | Bun | runs the app's Next on Bun: `m3-next dev \| build \| start` (in the Aphrody checkout: `yolo m3 next`) |
## Set up
```ts
// next.config.ts
import { withM3 } from "@aphrody/m3-next";
export default withM3({}, { reactCompiler: true, cacheComponents: true, typedRoutes: true });
```
`withM3` transpiles the M3 packages (including `@aphrody/m3-baseui`, which ships source), keeps `@aphrody/bun` external on the
server, rewrites barrel imports and keeps strict mode on. Options never override a key the config sets.
```css
/* app/globals.css */
@import "tailwindcss";
@import "@aphrody/m3-next/m3.css";
@import "@aphrody/m3-baseui/m3-baseui.css"; /* or css/core.css and the sheets you use */
```
```tsx
// app/layout.tsx
import { M3Root } from "@aphrody/m3-next/server";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <M3Root readCookie={false}>{children}</M3Root>;
}
```
```ts
// proxy.ts (Next 16's name for middleware.ts)
import { createM3Proxy } from "@aphrody/m3-next/proxy";
export const proxy = createM3Proxy();
// `matcher` is parsed at compile time, so it must be a literal. Pages only:
export const config = { matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"] };
```
## Theme, SSR and Cache Components
- `M3Root` renders `<html>` with the mode class, `color-scheme` and the scheme's colour roles inline, so the first byte is themed.
- With `readCookie={false}` (or `mode="system"`) the shell is static: Cache Components and a static export need that. The inline
pre-paint script reads the `m3-theme` cookie in the browser and sets `.dark` / `.light` before first paint, so a saved
preference never flashes. Without `readCookie={false}` the layout reads the cookie on the server (dynamic).
- A Server Component that reads the cookie (`readM3ThemeMode()`) belongs behind `<Suspense>`; `proxy.ts` guarantees the cookie
exists on the first request, so the server and the client agree from the first byte (`examples/app/app/theme`).
- The client provider is a few hundred bytes (cookie, `.dark`, `.light`, `data-m3-theme`, `color-scheme`). The Material You
engine (material-color-utilities, about 90 KB) is a separate chunk, loaded only when the seed, variant or contrast differ from the
scheme the server inlined. It replaces the `@aphrody/m3-theme/react` provider: the client entry is 94 KB before and 3.3 KB now
(`docs/reports/M3-NEXT-BUNDLE.md`).
## Fonts
`next/font` needs literal arguments in the module that calls it, so a library cannot load fonts for you. Load them in
`layout.tsx` and hand the CSS variable to `M3Root`, which points the M3 reference typefaces at it:
```tsx
const roboto = localFont({
src: "./RobotoFlex.woff2",
variable: "--font-roboto-flex",
weight: "100 1000",
});
<M3Root
className={roboto.variable}
typefaces={{
brand: "var(--font-roboto-flex), sans-serif",
plain: "var(--font-roboto-flex), sans-serif",
}}
/>;
```
`@aphrody/m3-fonts/fonts.css` is the alternative: `@font-face` rules for the M3 typefaces, imported from the global stylesheet.
## Tauri static export
`withM3(config, { tauri: true })` (or `"auto"`: only when the Tauri CLI runs the build) sets `output: "export"`, unoptimized
images, trailing slashes, `cacheComponents: false` and `NEXT_PUBLIC_M3_STATIC=1`, so `M3Root` stops reading the cookie.
`TAURI_DEV_HOST` adds the dev `assetPrefix` a phone needs. A static export has no `proxy.ts`, route handlers (unless
`force-static`) or server cookies: the preference lives in the cookie the client writes. Point Tauri's `frontendDist` at `../out`.
`examples/tauri` imports only the sheets of the three components it renders.
## On Bun, no Node
`bun --bun next build` runs Next itself on Bun, but Turbopack spawns `node` for its PostCSS and loader workers and fails when that
is not Bun (`node: --bun is not allowed in NODE_OPTIONS`). `m3-next` puts a `node` that is Bun first on PATH, so the whole
build, workers included, runs on one runtime and Turbopack stays the bundler (no `--webpack` fallback: `examples/app`, cold, back to back on one machine: 4.5 s with
Turbopack, 15.3 s with `--webpack`). `m3-next dev`, `build` and `start` pass their arguments to `next`.
### Moved to aphrody-labs/bun
The runner (`bunNodeShim`, `nextBin`, `nextCommand`) and its tests now live in `@aphrody/next-bun`
(`packages/bun-next/lib/run.js` and `test/integration/next-bun/` of aphrody-labs/bun, also `next-bun dev|build|start`).
`bin/m3-next.ts` re-exports them and keeps the `m3-next` usage. The dependency is the published
`@aphrody/next-bun` (npm, exact pin `0.2.0-aphrody.1`).
A file named `proxy.ts` anywhere in a transpiled package is treated as the app's proxy by Next: that is why this package's helper
lives in `theme-proxy.ts` (exported as `@aphrody/m3-next/proxy`).
## Examples and checks
```sh
bun scripts/yolo-example.ts check # yolo web build, yolo web dev, next start, yolo tauri build; curl every route
bun scripts/yolo-example.ts bench # cold and warm yolo web build, three runs each
bun scripts/bundle-report.ts --examples # bundle sizes (docs/reports/M3-NEXT-BUNDLE.md)
bun run test # unit tests, then the DOM tests (happy-dom preload)
```
`yolo web build|dev` runs `bun run build|dev` in `$YOLO_HOME/m3/packages/app-ui`; `scripts/yolo-example.ts` points `YOLO_HOME` at a temporary
directory whose `m3/packages/app-ui` is the example (a symlink under the temp dir), so nothing is written into yolo or this repository.
Migrating an existing app to this stack: `@aphrody/m3-next-migrate` (`next-migrate codemod all <dir>`).
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bun
// SPDX-License-Identifier: Apache-2.0
// Run Next.js 16 on Bun with no Node on the machine:
//
// m3-next dev | build | start [next arguments] (in the Aphrody checkout: yolo m3 next ...)
//
// The runner lives in @aphrody/next-bun (aphrody-labs/bun, packages/bun-next): this Bun runs the app's `next` with a
// `node` that is Bun first on PATH, so Turbopack workers run on Bun too.
import { bunExecutable, bunNodeShim, nextBin, nextCommand, runNext } from "@aphrody/next-bun/run";
export { bunExecutable, bunNodeShim, nextBin, nextCommand };
/** `m3-next <argv>` (`yolo m3 next <argv>`): runs the app's Next and resolves to its exit status. */
export async function runM3Next(argv: string[]): Promise<number> {
if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
console.log(
"usage: m3-next <dev|build|start|...> [next arguments]\nRuns the app's Next.js on Bun, Turbopack workers included.",
);
return argv.length === 0 ? 2 : 0;
}
return await runNext(argv);
}
if (import.meta.main) process.exit(await runM3Next(process.argv.slice(2)));
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
import { m3Package } from "@aphrody/m3-config/bun-build";
// Four entries with four runtimes: `index` (config, metadata and scheme helpers, safe anywhere),
// `server` (React Server Components, node:fs, next/headers), `client` (the 'use client'
// boundary) and `theme-proxy` (the proxy.ts helper, next/server only; its file is not named proxy.ts because Next scans for that name). No code splitting: server.js reaches client.js through the package name
// (`@aphrody/m3-next/client`, external), so the client directive stays on a module of its own.
export default m3Package({
entry: ["src/index.ts", "src/server.tsx", "src/client.tsx", "src/theme-proxy.ts"],
target: "node",
splitting: false,
useClient: ["client.js"],
external: ["next", "next/*", "node:*"],
});
+4
View File
@@ -0,0 +1,4 @@
.next/
out/
next-env.d.ts
*.tsbuildinfo
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+23
View File
@@ -0,0 +1,23 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
# `@aphrody/m3-next-example`
A Next.js 16 app on the M3 stack: App Router, Cache Components (`use cache`, a static shell with a dynamic `/theme` behind
`<Suspense>`), `proxy.ts`, React Compiler, typed routes, Tailwind v4 (`@aphrody/m3-tailwind`), Base UI wrappers
(`@aphrody/m3-baseui`) rendered from a Server Component, `next/font/local` mapped onto the M3 typefaces, and a route handler.
```sh
bun run build # next build, on Bun (Turbopack workers included): `yolo m3 next build`
bun run start # next start
bun run dev # next dev
bun run test # configuration and files, without starting Next
```
Through yolo (`yolo web build|dev` runs these scripts), with curl on every route, and cold and warm build times:
```sh
bun ../../scripts/yolo-example.ts check
bun ../../scripts/yolo-example.ts bench
```
The static export for Tauri is `../tauri`. The `AGENTS.md` and `CLAUDE.md` here are written by `next dev`.
+4
View File
@@ -0,0 +1,4 @@
// SPDX-License-Identifier: Apache-2.0
export function GET() {
return Response.json({ ok: true, runtime: process.versions.bun ? "bun" : "node" });
}
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: Apache-2.0
"use client";
import { M3Button, M3Toast } from "@aphrody/m3-baseui";
import { useState } from "react";
function Toasts() {
const { toasts } = M3Toast.useToastManager();
return toasts.map((toast) => (
<M3Toast.Root key={toast.id} toast={toast}>
<M3Toast.Content>
<M3Toast.Title />
<M3Toast.Description />
</M3Toast.Content>
<M3Toast.Close aria-label="Dismiss">×</M3Toast.Close>
</M3Toast.Root>
));
}
function Trigger() {
const manager = M3Toast.useToastManager();
// The React Compiler memoises this component and its handler; there is no useCallback or useMemo.
const [count, setCount] = useState(0);
const notify = () => {
setCount(count + 1);
manager.add({ title: "Saved", description: `Toast number ${count + 1}` });
};
return (
<M3Button variant="elevated" onClick={notify} data-testid="notify">
Show a toast ({count})
</M3Button>
);
}
/** Client Component: a toast provider, its viewport and the button that adds a toast. */
export function Notifier() {
return (
<M3Toast.Provider>
<Trigger />
<M3Toast.Portal>
<M3Toast.Viewport>
<Toasts />
</M3Toast.Viewport>
</M3Toast.Portal>
</M3Toast.Provider>
);
}
+224
View File
@@ -0,0 +1,224 @@
// SPDX-License-Identifier: Apache-2.0
// A Server Component that renders Base UI parts styled with M3: the wrappers are 'use client' modules, so a server page
// composes them with plain props (render elements, items, values) and ships only their interactive code.
import {
M3Accordion,
M3Avatar,
M3Button,
M3Checkbox,
M3Dialog,
M3Field,
M3Input,
M3Menu,
M3Meter,
M3NumberField,
M3Popover,
M3Progress,
M3Radio,
M3RadioGroup,
M3Select,
M3Separator,
M3Slider,
M3Switch,
M3Tabs,
M3Toggle,
M3ToggleGroup,
M3Tooltip,
} from "@aphrody/m3-baseui";
import { Nav } from "../nav";
import { Notifier } from "./notifier";
const fruit = [
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
{ label: "Cherry", value: "cherry" },
];
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-3 rounded-extra-large bg-surface-container-low p-6">
<h2 className="text-title-medium text-on-surface-variant">{title}</h2>
<div className="flex flex-wrap items-center gap-4">{children}</div>
</section>
);
}
export default function ComponentsPage() {
return (
<>
<Nav />
<main className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
<h1 className="text-headline-medium">Base UI, styled by M3</h1>
<Section title="Button: five types">
<M3Button>Filled</M3Button>
<M3Button variant="tonal">Tonal</M3Button>
<M3Button variant="outlined">Outlined</M3Button>
<M3Button variant="text">Text</M3Button>
<M3Button variant="elevated">Elevated</M3Button>
<M3Button disabled>Disabled</M3Button>
</Section>
<Section title="Selection controls">
<M3Switch defaultChecked aria-label="Wi-Fi" />
<M3Checkbox defaultChecked aria-label="Accept" />
<M3Checkbox indeterminate aria-label="Some" />
<M3RadioGroup defaultValue="a" aria-label="Choice" className="flex-row">
<M3Radio value="a" aria-label="A" />
<M3Radio value="b" aria-label="B" />
</M3RadioGroup>
<M3ToggleGroup defaultValue={["bold"]} aria-label="Format">
<M3Toggle value="bold">Bold</M3Toggle>
<M3Toggle value="italic">Italic</M3Toggle>
</M3ToggleGroup>
</Section>
<Section title="Text fields">
<M3Field.Root className="min-w-64">
<M3Field.Label>Name</M3Field.Label>
<M3Field.Control placeholder="Ada Lovelace" />
<M3Field.Description>As on your passport.</M3Field.Description>
</M3Field.Root>
<M3Input variant="filled" aria-label="Email" placeholder="Filled field" />
<M3NumberField.Root defaultValue={3} min={0} max={9}>
<M3NumberField.Group>
<M3NumberField.Decrement aria-label="Less">-</M3NumberField.Decrement>
<M3NumberField.Input aria-label="Quantity" />
<M3NumberField.Increment aria-label="More">+</M3NumberField.Increment>
</M3NumberField.Group>
</M3NumberField.Root>
</Section>
<Section title="Select, menu, popover, tooltip, dialog">
<M3Select.Root items={fruit} defaultValue="banana">
<M3Select.Trigger aria-label="Fruit">
<M3Select.Value />
<M3Select.Icon>▾</M3Select.Icon>
</M3Select.Trigger>
<M3Select.Portal>
<M3Select.Positioner sideOffset={4}>
<M3Select.Popup>
<M3Select.List>
{fruit.map((item) => (
<M3Select.Item key={item.value} value={item.value}>
<M3Select.ItemIndicator>✓</M3Select.ItemIndicator>
<M3Select.ItemText>{item.label}</M3Select.ItemText>
</M3Select.Item>
))}
</M3Select.List>
</M3Select.Popup>
</M3Select.Positioner>
</M3Select.Portal>
</M3Select.Root>
<M3Menu.Root>
<M3Menu.Trigger render={<M3Button variant="outlined" />}>Menu</M3Menu.Trigger>
<M3Menu.Portal>
<M3Menu.Positioner sideOffset={4}>
<M3Menu.Popup>
<M3Menu.Item>Cut</M3Menu.Item>
<M3Menu.Item>Copy</M3Menu.Item>
<M3Menu.Separator />
<M3Menu.Item>Paste</M3Menu.Item>
</M3Menu.Popup>
</M3Menu.Positioner>
</M3Menu.Portal>
</M3Menu.Root>
<M3Popover.Root>
<M3Popover.Trigger render={<M3Button variant="tonal" />}>Popover</M3Popover.Trigger>
<M3Popover.Portal>
<M3Popover.Positioner sideOffset={8}>
<M3Popover.Popup>
<M3Popover.Title>Rich content</M3Popover.Title>
<M3Popover.Description>Anchored to its trigger.</M3Popover.Description>
</M3Popover.Popup>
</M3Popover.Positioner>
</M3Popover.Portal>
</M3Popover.Root>
<M3Tooltip.Provider>
<M3Tooltip.Root>
<M3Tooltip.Trigger render={<M3Button variant="text" />}>Tooltip</M3Tooltip.Trigger>
<M3Tooltip.Portal>
<M3Tooltip.Positioner sideOffset={6}>
<M3Tooltip.Popup>Plain tooltip</M3Tooltip.Popup>
</M3Tooltip.Positioner>
</M3Tooltip.Portal>
</M3Tooltip.Root>
</M3Tooltip.Provider>
<M3Dialog.Root>
<M3Dialog.Trigger render={<M3Button />}>Open dialog</M3Dialog.Trigger>
<M3Dialog.Portal>
<M3Dialog.Backdrop />
<M3Dialog.Popup>
<M3Dialog.Title>Discard draft?</M3Dialog.Title>
<M3Dialog.Description>
The draft is deleted and cannot be restored.
</M3Dialog.Description>
<div className="flex justify-end gap-2">
<M3Dialog.Close render={<M3Button variant="text" />}>Cancel</M3Dialog.Close>
<M3Dialog.Close render={<M3Button variant="text" />}>Discard</M3Dialog.Close>
</div>
</M3Dialog.Popup>
</M3Dialog.Portal>
</M3Dialog.Root>
</Section>
<Section title="Navigation and disclosure">
<M3Tabs.Root defaultValue="one" className="w-full">
<M3Tabs.List>
<M3Tabs.Tab value="one">One</M3Tabs.Tab>
<M3Tabs.Tab value="two">Two</M3Tabs.Tab>
<M3Tabs.Indicator />
</M3Tabs.List>
<M3Tabs.Panel value="one">First panel</M3Tabs.Panel>
<M3Tabs.Panel value="two">Second panel</M3Tabs.Panel>
</M3Tabs.Root>
<M3Accordion.Root className="w-full">
<M3Accordion.Item value="a">
<M3Accordion.Header>
<M3Accordion.Trigger>What is Cache Components?</M3Accordion.Trigger>
</M3Accordion.Header>
<M3Accordion.Panel>
`use cache`, `cacheLife` and `cacheTag`, with a static shell.
</M3Accordion.Panel>
</M3Accordion.Item>
</M3Accordion.Root>
</Section>
<Section title="Feedback">
<M3Slider.Root defaultValue={40} className="w-64">
<M3Slider.Label>Volume</M3Slider.Label>
<M3Slider.Value />
<M3Slider.Control>
<M3Slider.Track>
<M3Slider.Indicator />
<M3Slider.Thumb aria-label="Volume" />
</M3Slider.Track>
</M3Slider.Control>
</M3Slider.Root>
<M3Progress.Root value={65} className="w-64">
<M3Progress.Label>Upload</M3Progress.Label>
<M3Progress.Value />
<M3Progress.Track>
<M3Progress.Indicator />
</M3Progress.Track>
</M3Progress.Root>
<M3Meter.Root value={30} className="w-64">
<M3Meter.Label>Disk</M3Meter.Label>
<M3Meter.Track>
<M3Meter.Indicator />
</M3Meter.Track>
</M3Meter.Root>
<M3Avatar.Root>
<M3Avatar.Fallback>AL</M3Avatar.Fallback>
</M3Avatar.Root>
<M3Separator className="w-full" />
<Notifier />
</Section>
</main>
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
@import "tailwindcss";
@import "@aphrody/m3-next/m3.css";
@import "@aphrody/m3-baseui/m3-baseui.css";
body {
margin: 0;
background: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
}
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: Apache-2.0
import { generateM3Metadata, generateM3Viewport } from "@aphrody/m3-next";
import { M3Root } from "@aphrody/m3-next/server";
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import type { ReactNode } from "react";
import "./globals.css";
// next/font needs literal arguments in the module that calls it, so the font is loaded here and handed to
// M3Root, which points the M3 reference typefaces at it.
const roboto = localFont({
src: "../../../../m3-fonts/fonts/web/RobotoFlex-latin.woff2",
variable: "--font-roboto-flex",
weight: "100 1000",
display: "swap",
});
export const metadata: Metadata = generateM3Metadata({
name: "M3 on Next 16",
description: "Base UI, Tailwind v4 and Material 3 on Next.js 16, built and served on Bun.",
});
export const viewport: Viewport = generateM3Viewport();
export default function RootLayout({ children }: { children: ReactNode }) {
return (
// Cache Components: the shell is static. The theme preference is read in the browser (pre-paint script and client
// provider), and /theme reads the cookie on the server inside <Suspense>.
<M3Root
readCookie={false}
className={roboto.variable}
typefaces={{
brand: "var(--font-roboto-flex), system-ui, sans-serif",
plain: "var(--font-roboto-flex), system-ui, sans-serif",
}}
bodyClassName="min-h-screen bg-surface text-on-surface"
>
{children}
</M3Root>
);
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: Apache-2.0
import Link from "next/link";
import { ThemeToggle } from "./theme-toggle";
/** Server Component: typed `href`s (typedRoutes) and the client toggle. */
export function Nav() {
return (
<nav className="flex items-center gap-2 border-b border-outline-variant bg-surface-container px-4 py-2">
<Link
href="/"
className="state-layer rounded-full px-4 py-2 text-label-large text-on-surface-variant"
>
Home
</Link>
<Link
href="/components"
className="state-layer rounded-full px-4 py-2 text-label-large text-on-surface-variant"
>
Components
</Link>
<Link
href="/theme"
className="state-layer rounded-full px-4 py-2 text-label-large text-on-surface-variant"
>
Theme
</Link>
<span className="ms-auto">
<ThemeToggle />
</span>
</nav>
);
}
+44
View File
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: Apache-2.0
import { M3Button } from "@aphrody/m3-baseui";
import { cacheLife, cacheTag } from "next/cache";
import Link from "next/link";
import { Nav } from "./nav";
/** Cache Components: computed once at build and revalidated hourly (or by `revalidateTag("stamp")`). */
async function stamp() {
"use cache";
cacheLife("hours");
cacheTag("stamp");
const runtime = process.versions.bun ? `Bun ${process.versions.bun}` : `Node ${process.version}`;
return { at: new Date().toISOString(), runtime };
}
export default async function Home() {
const { at, runtime } = await stamp();
return (
<>
<Nav />
<main className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
<h1 className="text-display-small-emphasized text-primary">Material 3 on Next.js 16</h1>
<p className="text-body-large text-on-surface-variant">
App Router, Cache Components, proxy.ts, React Compiler and typed routes, with Base UI
parts styled by M3 tokens through a Tailwind v4 preset.
</p>
<section className="rounded-extra-large bg-surface-container-high p-6 shadow-elevation-1">
<h2 className="text-title-large">Cached on the server</h2>
<p className="text-body-medium text-on-surface-variant" data-testid="stamp">
Rendered {at} on {runtime}.
</p>
</section>
<div className="flex flex-wrap gap-3">
<M3Button render={<Link href="/components" />} nativeButton={false}>
Components
</M3Button>
<M3Button variant="outlined" render={<a href="/api/health" />} nativeButton={false}>
/api/health
</M3Button>
</div>
</main>
</>
);
}
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
"use client";
import { M3Button } from "@aphrody/m3-baseui";
import { useM3ThemeToggle } from "@aphrody/m3-next/client";
/** Client Component compiled by the React Compiler (no useCallback or useMemo needed). */
export function ThemeToggle() {
const { mode, resolved, toggle } = useM3ThemeToggle();
return (
<M3Button variant="tonal" size="xs" onClick={toggle} data-testid="theme-toggle">
Theme: {mode} ({resolved})
</M3Button>
);
}
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: Apache-2.0
import { readM3ThemeMode } from "@aphrody/m3-next/server";
import { Suspense } from "react";
import { Nav } from "../nav";
/** Reads the request cookie: dynamic, so it sits behind <Suspense> while the shell stays static. */
async function ServerMode() {
const mode = await readM3ThemeMode();
return <p data-testid="server-mode">The server saw the preference: {mode}</p>;
}
export default function ThemePage() {
return (
<>
<Nav />
<main className="mx-auto flex max-w-3xl flex-col gap-4 p-6">
<h1 className="text-headline-medium">Theme</h1>
<p className="text-body-large text-on-surface-variant">
proxy.ts writes the cookie on the first visit; the toggle in the bar changes it.
</p>
<Suspense fallback={<p>Reading the cookie...</p>}>
<ServerMode />
</Suspense>
</main>
</>
);
}
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: Apache-2.0
import { withM3 } from "@aphrody/m3-next";
import { resolve } from "node:path";
export default withM3(
{ poweredByHeader: false },
{
// Next 16: React Compiler and Cache Components are top-level keys; Turbopack is the default bundler.
reactCompiler: true,
cacheComponents: true,
typedRoutes: true,
// The workspace packages live outside this app: Turbopack's root is the repository root.
turbopackRoot: resolve(process.cwd(), "../../../.."),
},
);
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@aphrody/m3-next-example",
"version": "3.3.6",
"private": true,
"description": "Sample Next.js 16 app on the M3 stack: App Router, Cache Components, proxy.ts, React Compiler, typed routes, Tailwind v4 and Base UI, built and served on Bun through yolo.",
"license": "Apache-2.0",
"type": "module",
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "bun ../../../../../packages/engine/yolo/src/index.ts m3 next dev",
"build": "bun ../../../../../packages/engine/yolo/src/index.ts m3 next build",
"start": "bun ../../../../../packages/engine/yolo/src/index.ts m3 next start",
"typecheck": "bun check -p tsconfig.json --noEmit",
"test": "bun test ./test/"
},
"dependencies": {
"@aphrody/m3-baseui": "workspace:*",
"@aphrody/m3-next": "workspace:*",
"@aphrody/m3-tailwind": "workspace:*",
"@aphrody/m3-tokens": "workspace:*",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@tailwindcss/postcss": "catalog:",
"@types/bun": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"babel-plugin-react-compiler": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"engines": {
"bun": ">=1.4.2"
}
}
+1
View File
@@ -0,0 +1 @@
export default { plugins: { "@tailwindcss/postcss": {} } };
+8
View File
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: Apache-2.0
// Next 16's proxy.ts (it replaces middleware.ts): every page request carries a valid theme cookie, so the
// Server Component on /theme and the client provider agree from the first byte.
import { createM3Proxy } from "@aphrody/m3-next/proxy";
export const proxy = createM3Proxy();
// `matcher` is parsed at compile time: it must be a literal. Pages only: not /api, Next's static and image routes, or files.
export const config = { matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"] };
+57
View File
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: Apache-2.0
// The example's configuration and files, without starting Next: `scripts/yolo-example.ts` builds and serves it.
import { beforeAll, describe, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { NextRequest } from "next/server";
const root = join(import.meta.dir, "..");
const m3Next = join(root, "../..");
beforeAll(() => {
// `@aphrody/m3-next` resolves to its build output: make it when a fresh checkout has none.
if (!existsSync(join(m3Next, "dist/index.js"))) {
const build = Bun.spawnSync(
["bun", join(m3Next, "../../scripts/build.ts"), "build", "--only", "@aphrody/m3-next"],
{ windowsHide: true, cwd: m3Next },
);
if (build.exitCode !== 0) throw new Error(build.stderr.toString());
}
});
describe("examples/app", () => {
test("next.config.ts: the Next 16 stack, with the monorepo root for Turbopack", async () => {
const config = (await import("../next.config")).default;
expect(config.cacheComponents).toBe(true);
expect(config.reactCompiler).toBe(true);
expect(config.typedRoutes).toBe(true);
expect(config.output).toBeUndefined();
expect(config.transpilePackages).toContain("@aphrody/m3-baseui");
expect(config.turbopack?.root).toBe(join(root, "../../../.."));
});
test("proxy.ts: a function and a literal page matcher; a first visit gets the theme cookie", async () => {
const { proxy, config } = await import("../proxy");
expect(typeof proxy).toBe("function");
expect(config.matcher).toHaveLength(1);
const response = proxy(new NextRequest("http://localhost/theme"));
expect(response.cookies.get("m3-theme")?.value).toBe("system");
});
test("the health route answers", async () => {
const { GET } = await import("../app/api/health/route");
expect(await GET().json()).toMatchObject({ ok: true });
});
test("every route has its file", () => {
for (const file of [
"app/layout.tsx",
"app/page.tsx",
"app/components/page.tsx",
"app/theme/page.tsx",
"app/api/health/route.ts",
"app/globals.css",
])
expect(existsSync(join(root, file))).toBe(true);
});
});
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowJs": false,
"isolatedModules": true,
"incremental": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
},
"types": ["bun"]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
+4
View File
@@ -0,0 +1,4 @@
.next/
out/
next-env.d.ts
*.tsbuildinfo
+14
View File
@@ -0,0 +1,14 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
# `@aphrody/m3-next-tauri-example`
The static export a Tauri webview loads: `withM3(config, { tauri: true })` sets `output: "export"`, unoptimized images, trailing
slashes and `NEXT_PUBLIC_M3_STATIC=1`, so `M3Root` renders a static shell and the pre-paint script applies the saved theme from
the cookie the client writes. No `proxy.ts`, no route handlers, no server cookies. Only the sheets of the components in use are
imported (`core`, `button`, `switch`).
```sh
bun run build # writes out/; point Tauri's frontendDist at ../out
bun run test
bun ../../scripts/yolo-example.ts export # yolo tauri build, then checks out/index.html
```
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
import Link from "next/link";
export default function About() {
return (
<main className="flex flex-col items-start gap-4">
<h1 className="text-headline-medium">About</h1>
<Link href="/" className="state-layer rounded-full px-4 py-2 text-label-large text-primary">
Back
</Link>
</main>
);
}
+11
View File
@@ -0,0 +1,11 @@
@import "tailwindcss";
@import "@aphrody/m3-next/m3.css";
@import "@aphrody/m3-baseui/css/core.css";
@import "@aphrody/m3-baseui/css/button.css";
@import "@aphrody/m3-baseui/css/switch.css";
body {
margin: 0;
background: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
}
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: Apache-2.0
import { M3Root } from "@aphrody/m3-next/server";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata = { title: "M3 for Tauri" };
export default function RootLayout({ children }: { children: ReactNode }) {
// No `mode` and no `readCookie`: with `tauri: true` the shell is static and the pre-paint script applies the saved
// preference from the cookie the client provider writes.
return <M3Root bodyClassName="min-h-screen p-6">{children}</M3Root>;
}
+20
View File
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: Apache-2.0
import { M3Button, M3Switch } from "@aphrody/m3-baseui";
import Link from "next/link";
import { ThemeToggle } from "./theme-toggle";
export default function Home() {
return (
<main className="flex flex-col items-start gap-4">
<h1 className="text-headline-medium">Static export for Tauri</h1>
<p className="text-body-large text-on-surface-variant">
Only the sheets of the components used on the page are imported (core, button, switch).
</p>
<M3Switch defaultChecked aria-label="Notifications" />
<ThemeToggle />
<M3Button variant="outlined" render={<Link href="/about" />} nativeButton={false}>
About
</M3Button>
</main>
);
}
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
"use client";
import { M3Button } from "@aphrody/m3-baseui";
import { useM3ThemeToggle } from "@aphrody/m3-next/client";
export function ThemeToggle() {
const { mode, toggle } = useM3ThemeToggle();
return (
<M3Button variant="tonal" onClick={toggle}>
Theme: {mode}
</M3Button>
);
}
+16
View File
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: Apache-2.0
import { withM3 } from "@aphrody/m3-next";
import { resolve } from "node:path";
// `tauri: true` is the static export a Tauri webview loads: `output: "export"`, unoptimized images, trailing slashes,
// no Cache Components, and `M3Root` stops reading the request cookie (there is no request). With `tauri: "auto"` the
// same config also serves `next dev` from the browser and exports only when the Tauri CLI runs the build.
export default withM3(
{},
{
typedRoutes: true,
reactCompiler: false,
tauri: true,
turbopackRoot: resolve(process.cwd(), "../../../.."),
},
);
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@aphrody/m3-next-tauri-example",
"version": "3.3.6",
"private": true,
"description": "Static export of a Next.js 16 app for a Tauri webview: no server, no proxy, no cookies read on the server; the theme preference lives in the browser.",
"license": "Apache-2.0",
"type": "module",
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "bun ../../../../../packages/engine/yolo/src/index.ts m3 next dev",
"build": "bun ../../../../../packages/engine/yolo/src/index.ts m3 next build",
"typecheck": "bun check -p tsconfig.json --noEmit",
"test": "bun test ./test/"
},
"dependencies": {
"@aphrody/m3-baseui": "workspace:*",
"@aphrody/m3-next": "workspace:*",
"@aphrody/m3-tailwind": "workspace:*",
"@aphrody/m3-tokens": "workspace:*",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@tailwindcss/postcss": "catalog:",
"@types/bun": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"engines": {
"bun": ">=1.4.2"
}
}
+1
View File
@@ -0,0 +1 @@
export default { plugins: { "@tailwindcss/postcss": {} } };
+41
View File
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: Apache-2.0
// The static export's configuration and files, without starting Next: `scripts/yolo-example.ts export` builds it.
import { beforeAll, describe, expect, test } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
const root = join(import.meta.dir, "..");
const m3Next = join(root, "../..");
beforeAll(() => {
if (!existsSync(join(m3Next, "dist/index.js"))) {
const build = Bun.spawnSync(
["bun", join(m3Next, "../../scripts/build.ts"), "build", "--only", "@aphrody/m3-next"],
{ windowsHide: true, cwd: m3Next },
);
if (build.exitCode !== 0) throw new Error(build.stderr.toString());
}
});
describe("examples/tauri", () => {
test("next.config.ts is a static export for a webview", async () => {
const config = (await import("../next.config")).default;
expect(config.output).toBe("export");
expect(config.trailingSlash).toBe(true);
expect(config.images).toEqual({ unoptimized: true });
expect(config.cacheComponents).toBe(false);
expect(config.env).toEqual({ NEXT_PUBLIC_M3_STATIC: "1" });
});
test("there is no proxy.ts and no route handler: an export has no server", () => {
expect(existsSync(join(root, "proxy.ts"))).toBe(false);
expect(existsSync(join(root, "app/api"))).toBe(false);
});
test("the page imports the sheets of the components it uses, not the whole library", () => {
const css = readFileSync(join(root, "app/globals.css"), "utf8");
expect(css).toContain("@aphrody/m3-baseui/css/core.css");
expect(css).toContain("@aphrody/m3-baseui/css/button.css");
expect(css).not.toContain("m3-baseui.css");
});
});
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowJs": false,
"isolatedModules": true,
"incremental": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
},
"types": ["bun"]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
+23
View File
@@ -0,0 +1,23 @@
/* SPDX-License-Identifier: Apache-2.0 */
/*
* @aphrody/m3-next/m3.css: the Material 3 sheets for a Next.js + Tailwind v4 app.
* Import it from the app's global stylesheet, right after Tailwind:
*
* @import "tailwindcss";
* @import "@aphrody/m3-next/m3.css";
*
* - m3-tokens.css and expressive.css: shape, typescale (with the emphasized styles), motion (with the Expressive
* springs), elevation and state tokens as runtime vars;
* - m3-theme tokens.css: the M3 colour roles (light, dark via prefers-color-scheme and .dark) plus the
* shadcn/ui and Tailwind aliases; m3-theme tailwind.css keeps the shadcn utility names (bg-card, text-muted-foreground);
* - m3-tailwind preset.css: every M3 role, shape, type style, elevation, motion and window class as a Tailwind v4
* utility or variant (bg-surface-container, text-label-large, shape-large-top, state-layer, medium:, m3-open:, dark:, ...).
*
* `dark:` follows the class M3Html puts on <html> (cookie, or system resolved before paint), or the OS preference
* unless `.light` forces light, so `dark:` utilities and the M3 roles always agree.
*/
@import "@aphrody/m3-tokens/m3-tokens.css";
@import "@aphrody/m3-tokens/expressive.css";
@import "@aphrody/m3-theme/tokens.css";
@import "@aphrody/m3-theme/tailwind.css";
@import "@aphrody/m3-tailwind/preset.css";
+94
View File
@@ -0,0 +1,94 @@
{
"name": "@aphrody/m3-next",
"version": "1.4.4",
"description": "Next.js 16 App Router integration for the @aphrody Material Design 3 layer: withM3 config (React Compiler, Cache Components, typed routes, Tauri static export), M3Html/M3Root server components, a light 'use client' theme provider, a proxy.ts helper, metadata/viewport helpers, crawler files and the m3-next runner (Next on Bun, no Node; from @aphrody/next-bun).",
"keywords": [
"material-3",
"md3",
"next",
"nextjs",
"react-server-components",
"tailwind"
],
"homepage": "https://github.com/aphrody-labs/aphrody-m3/tree/main/packages/m3-next#readme",
"license": "Apache-2.0",
"author": "aphrody-code",
"repository": {
"type": "git",
"url": "git+https://github.com/aphrody-labs/aphrody-m3.git",
"directory": "packages/m3-next"
},
"bin": {
"m3-next": "./bin/m3-next.ts"
},
"files": [
"dist",
"bin",
"m3.css"
],
"type": "module",
"sideEffects": [
"*.css"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./server": {
"types": "./dist/server.d.ts",
"default": "./dist/server.js"
},
"./client": {
"types": "./dist/client.d.ts",
"default": "./dist/client.js"
},
"./proxy": {
"types": "./dist/theme-proxy.d.ts",
"default": "./dist/theme-proxy.js"
},
"./m3.css": "./m3.css",
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "bun ../../scripts/build.ts build --only $npm_package_name",
"typecheck": "bun check -p tsconfig.json --noEmit",
"test": "bun test ./test/ && bun test --preload @aphrody/m3-config/test/dom ./test-dom/"
},
"dependencies": {
"@aphrody/bun": "^3.3.0",
"@aphrody/m3-tailwind": "workspace:*",
"@aphrody/m3-theme": "workspace:*",
"@aphrody/m3-tokens": "workspace:*",
"@aphrody/next-bun": "0.2.0-aphrody.1"
},
"devDependencies": {
"@aphrody/bun-webview-page": "0.1.1-aphrody.2",
"@aphrody/m3-config": "workspace:*",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"peerDependencies": {
"next": ">=16.0.0",
"react": ">=19.0.0",
"react-dom": ">=19.0.0",
"tailwindcss": ">=4.1.0"
},
"peerDependenciesMeta": {
"tailwindcss": {
"optional": true
}
},
"engines": {
"bun": ">=1.4.4"
}
}
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bun
// SPDX-License-Identifier: Apache-2.0
// Drive the example app in headless Chromium: hydration without errors, Base UI behaviour, the M3 styles actually
// computed, and the theme round trip (client toggle -> cookie -> server render).
//
// bun scripts/browser-check.ts [--base http://localhost:3218] (server running: `bun run start` in examples/app)
// bun scripts/yolo-example.ts browser (builds with yolo, starts the server, runs this)
//
// The browser is Bun.WebView driven through @aphrody/bun-webview-page (Chrome, Chromium or Edge found on PATH or in
// BUN_CHROME_PATH); no Playwright and no browser download.
import { chromium } from "@aphrody/bun-webview-page";
export interface Check {
name: string;
ok: boolean;
detail?: string;
}
/** Run every check against a running server; never throws on a failed check. */
export async function browserCheck(base: string): Promise<Check[]> {
const browser = await chromium.launch({
headless: true,
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
});
const checks: Check[] = [];
const check = (name: string, ok: boolean, detail?: string) =>
checks.push({ name, ok, ...(detail ? { detail } : {}) });
try {
const page = await browser.newPage();
const problems: string[] = [];
// Headless Chromium without a display never fires requestAnimationFrame here, and the actionability wait of a
// selector click depends on it: clicks are forced (native events at the element centre; the assertions after each
// click replace the visibility and hit checks).
page.on("console", (message) => {
if (message.type() === "error") problems.push(`console: ${message.text()}`);
});
page.on("pageerror", (error) => problems.push(`pageerror: ${error.message}`));
// Home: the cached stamp, the typed links, the font variable.
await page.goto(`${base}/`, { waitUntil: "networkidle" });
check(
"home renders the cached stamp",
((await page.locator('[data-testid="stamp"]').textContent()) ?? "").startsWith("Rendered "),
);
check(
"html has lang, the font variable class and a colour scheme",
await page.evaluate(() => {
const html = document.documentElement;
return (
html.lang === "en" && /variable/.test(html.className) && html.style.colorScheme !== ""
);
}),
);
// Components: a Server Component rendered the client wrappers; they hydrate and work.
await page.goto(`${base}/components`, { waitUntil: "networkidle" });
const filled = page.locator(".m3-bui-button--filled").first();
check(
"filled button paints the primary colour",
await filled.evaluate((el) => {
const background = getComputedStyle(el).backgroundColor;
const primary = getComputedStyle(document.documentElement)
.getPropertyValue("--md-sys-color-primary")
.trim();
return background !== "rgba(0, 0, 0, 0)" && primary !== "";
}),
);
check(
"the button is a full pill (9999px radius token)",
await filled.evaluate((el) => parseFloat(getComputedStyle(el).borderTopLeftRadius) > 100),
);
await page.locator('[role="switch"].m3-bui-switch').first().click({ force: true });
check(
"a click flips the switch (Base UI data-checked)",
(await page.locator('[role="switch"].m3-bui-switch').first().getAttribute("data-checked")) ===
null,
);
await page.getByText("Open dialog").first().click({ force: true });
await page
.locator(".m3-bui-dialog__popup")
.first()
.waitFor({ state: "visible", timeout: 5000 });
check(
"the dialog opens with its M3 popup, backdrop and title",
(await page.locator(".m3-bui-dialog__backdrop").count()) === 1 &&
((await page.locator(".m3-bui-dialog__title").textContent()) ?? "").startsWith("Discard"),
);
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
check("Escape closes the dialog", (await page.locator(".m3-bui-dialog__popup").count()) === 0);
await page.locator(".m3-bui-select__trigger").first().click({ force: true });
await page
.locator(".m3-bui-select__popup")
.first()
.waitFor({ state: "visible", timeout: 5000 });
check(
"the select lists its items in the M3 popup",
(await page.locator(".m3-bui-select__item").count()) === 3,
);
await page.keyboard.press("Escape");
await page.locator('[role="tab"]').nth(1).click({ force: true });
check(
"tabs switch panels",
(
(await page.locator('[role="tabpanel"]:not([hidden])').first().textContent()) ?? ""
).includes("Second"),
);
await page.locator('[data-testid="notify"]').click({ force: true });
await page.locator(".m3-bui-toast__title").first().waitFor({ state: "visible", timeout: 5000 });
check(
"the toast manager shows a snackbar",
((await page.locator(".m3-bui-toast__title").first().textContent()) ?? "") === "Saved",
);
// Theme round trip.
await page.goto(`${base}/theme`, { waitUntil: "networkidle" });
check(
"the server saw the cookie proxy.ts wrote",
((await page.locator('[data-testid="server-mode"]').textContent()) ?? "").endsWith("system"),
);
const toggle = page.locator('[data-testid="theme-toggle"]');
await toggle.click({ force: true }); // system -> light
await page.waitForTimeout(200);
const cookies = await page.context().cookies();
check(
"the toggle writes the cookie and the .light class",
cookies.some((c) => c.name === "m3-theme" && c.value === "light") &&
(await page.evaluate(() => document.documentElement.classList.contains("light"))),
);
await page.reload({ waitUntil: "networkidle" });
check(
"a reload renders the saved preference on the server",
((await page.locator('[data-testid="server-mode"]').textContent()) ?? "").endsWith("light"),
);
await toggle.click({ force: true }); // light -> dark
await page.waitForTimeout(200);
check(
"dark sets .dark and the dark colour roles",
await page.evaluate(
() =>
document.documentElement.classList.contains("dark") &&
getComputedStyle(document.body).backgroundColor !== "rgb(255, 255, 255)",
),
);
check(
"no console errors, page errors or hydration warnings",
problems.length === 0,
problems.slice(0, 3).join(" | "),
);
} finally {
await browser.close();
}
return checks;
}
if (import.meta.main) {
const args = process.argv.slice(2);
const base = args[args.indexOf("--base") + 1] ?? "http://localhost:3218";
const checks = await browserCheck(args.includes("--base") ? base : "http://localhost:3218");
for (const c of checks)
console.log(`${c.ok ? "ok " : "FAIL"} ${c.name}${c.detail ? `: ${c.detail}` : ""}`);
const failed = checks.filter((c) => !c.ok).length;
console.log(
failed === 0 ? `pass ${checks.length} browser checks` : `FAIL ${failed} of ${checks.length}`,
);
process.exit(failed === 0 ? 0 : 1);
}
+347
View File
@@ -0,0 +1,347 @@
#!/usr/bin/env bun
// SPDX-License-Identifier: Apache-2.0
// Bundle-size report of the Base UI + Tailwind v4 + Next 16 layer (m3-baseui, m3-tailwind, m3-next).
//
// bun scripts/bundle-report.ts [--json] [--out file.json] [--before file.json] [--write file.md]
//
// Every number is measured with Bun's bundler (browser target, minified, react external) or with
// @tailwindcss/node plus Bun's CSS minifier, so a re-run on another commit is comparable.
// `--before` embeds the numbers of an earlier run next to the current ones; `--write` renders the Markdown report.
/* oxlint-disable eslint/no-await-in-loop -- measurements run one after another so they never compete for the machine */
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { gzipSync } from "node:zlib";
const PACKAGES = resolve(import.meta.dir, "../..");
/** `@tailwindcss/node` as m3-tailwind resolves it, so the report measures the preset against its own engine. */
const { compile } = (await import(
Bun.resolveSync("@tailwindcss/node", join(PACKAGES, "m3-tailwind"))
)) as {
compile(
css: string,
options: { base: string; onDependency(path: string): void },
): Promise<{ build(candidates: string[]): string }>;
};
export interface Measure {
bytes: number;
gzip: number;
}
export type Report = Record<string, Measure | number>;
const gz = (text: string | Uint8Array): number => gzipSync(text, { level: 9 }).length;
const size = (text: string): Measure => ({ bytes: Buffer.byteLength(text), gzip: gz(text) });
/** Entry file of a workspace package: the `.` export, falling back to `main`. */
function entryOf(pkg: string): string {
const dir = join(PACKAGES, pkg);
const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
exports?: Record<string, unknown>;
main?: string;
};
const root = manifest.exports?.["."];
const file =
typeof root === "string"
? root
: typeof root === "object" && root !== null
? ((root as Record<string, string>)["import"] ??
(root as Record<string, string>)["default"])
: manifest.main;
if (file === undefined) throw new Error(`${pkg}: no entry`);
return resolve(dir, file);
}
/** Minified browser bundle of `code` (react and react-dom stay external, as in an app that dedupes them). */
async function bundle(
code: string,
external: string[] = ["react", "react-dom", "react/jsx-runtime"],
): Promise<Measure> {
const entry = join(PACKAGES, "m3-next", "__bundle_entry__.ts");
const result = await Bun.build({
entrypoints: [entry],
files: { [entry]: code },
target: "browser",
format: "esm",
minify: true,
external,
define: { "process.env.NODE_ENV": '"production"' },
});
if (!result.success) throw new Error(result.logs.map(String).join("\n"));
const text = await result.outputs[0]!.text();
return size(text);
}
/**
* Like {@link bundle} with code splitting: the entry chunk (what a page downloads up front) and the dynamic-import
* chunks together (what is fetched on demand).
*/
async function bundleSplit(
code: string,
external: string[],
): Promise<{ entry: Measure; lazy: Measure }> {
const entry = join(PACKAGES, "m3-next", "__bundle_entry__.ts");
const result = await Bun.build({
entrypoints: [entry],
files: { [entry]: code },
target: "browser",
format: "esm",
minify: true,
splitting: true,
external,
define: { "process.env.NODE_ENV": '"production"' },
});
if (!result.success) throw new Error(result.logs.map(String).join("\n"));
let first = "";
let lazy = "";
for (const output of result.outputs) {
if (output.kind === "entry-point") first = await output.text();
else if (output.kind === "chunk") lazy += await output.text();
}
return { entry: size(first), lazy: size(lazy) };
}
/** Minified CSS through Bun's CSS pipeline. */
async function minifyCss(css: string): Promise<Measure> {
const entry = join(PACKAGES, "m3-next", "__bundle_entry__.css");
const result = await Bun.build({ entrypoints: [entry], files: { [entry]: css }, minify: true });
if (!result.success) throw new Error(result.logs.map(String).join("\n"));
return size(await result.outputs[0]!.text());
}
/** The classes of a typical M3 screen: every family of the preset appears at least once. */
export const TYPICAL_CANDIDATES = [
"bg-primary",
"text-on-primary",
"bg-surface-container",
"text-on-surface",
"text-on-surface-variant",
"bg-secondary-container",
"text-on-secondary-container",
"bg-error",
"border-outline-variant",
"rounded-full",
"rounded-large",
"rounded-extra-large",
"text-label-large",
"text-title-medium",
"text-body-large",
"text-headline-small-emphasized",
"font-brand",
"shadow-elevation-2",
"ease-emphasized",
"duration-300",
"p-4",
"flex",
"gap-2",
"hover:bg-on-surface/8",
"md:grid",
];
/** The classes only the extended preset has: shape, state layer, typescale, spring motion, adaptive and Base UI variants. */
export const M3_CANDIDATES = [
"shape-large-top",
"state-layer",
"state-layer-primary",
"typescale-label-large",
"motion-expressive-default-spatial",
"compact:hidden",
"h-medium:flex",
"m3-open:bg-primary",
"m3-on:text-on-primary",
"dark:bg-surface",
];
async function tailwindCss(): Promise<{ preset: Measure; app: Measure; m3: Measure }> {
const base = join(PACKAGES, "m3-tailwind");
const input = [
'@import "tailwindcss/theme.css" layer(theme);',
'@import "tailwindcss/utilities.css" layer(utilities);',
'@import "@aphrody/m3-tailwind/preset.css";',
].join("\n");
const compiler = await compile(input, { base, onDependency() {} });
const app = await minifyCss(compiler.build(TYPICAL_CANDIDATES));
const m3 = await minifyCss(compiler.build([...TYPICAL_CANDIDATES, ...M3_CANDIDATES]));
const preset = await minifyCss(readFileSync(join(base, "preset.css"), "utf8"));
return { preset, app, m3 };
}
export async function measure(): Promise<Report> {
const baseui = entryOf("m3-baseui");
const nextClient = join(PACKAGES, "m3-next/src/client.tsx");
const nextIndex = join(PACKAGES, "m3-next/src/index.ts");
const report: Report = {};
report["baseui.js.button"] = await bundle(
`import { M3Button } from ${JSON.stringify(baseui)}; globalThis.x = M3Button;`,
);
report["baseui.js.button+switch+tabs"] = await bundle(
`import { M3Button, M3Switch, M3Tabs } from ${JSON.stringify(baseui)}; globalThis.x = [M3Button, M3Switch, M3Tabs.Root];`,
);
report["baseui.js.everything"] = await bundle(
`import * as all from ${JSON.stringify(baseui)}; globalThis.x = all;`,
);
report["baseui.js.dialog"] = await bundle(
`import { M3Dialog } from ${JSON.stringify(baseui)}; globalThis.x = [M3Dialog.Root, M3Dialog.Popup, M3Dialog.Title];`,
);
report["baseui.js.select"] = await bundle(
`import { M3Select } from ${JSON.stringify(baseui)}; globalThis.x = [M3Select.Root, M3Select.Popup, M3Select.Item];`,
);
const baseuiDir = join(PACKAGES, "m3-baseui");
report["baseui.css.all"] = await minifyCss(
`@import ${JSON.stringify(join(baseuiDir, "m3-baseui.css"))};`,
);
if (existsSync(join(baseuiDir, "css/button.css"))) {
report["baseui.css.button"] = await minifyCss(
`@import ${JSON.stringify(join(baseuiDir, "css/core.css"))};\n@import ${JSON.stringify(join(baseuiDir, "css/button.css"))};`,
);
}
const tw = await tailwindCss();
report["tailwind.preset.css"] = tw.preset;
report["tailwind.app.css"] = tw.app;
report["tailwind.app.m3.css"] = tw.m3;
const clientExternal = ["react", "react-dom", "react/jsx-runtime", "next", "next/*"];
const client = await bundleSplit(
`import * as c from ${JSON.stringify(nextClient)}; globalThis.x = c;`,
clientExternal,
);
report["next.client.js"] = client.entry;
if (client.lazy.bytes > 0) report["next.client.lazy.js"] = client.lazy;
report["next.index.js"] = await bundle(
`import * as c from ${JSON.stringify(nextIndex)}; globalThis.x = c;`,
["react", "react-dom", "react/jsx-runtime", "next", "next/*"],
);
return report;
}
const LABELS: Record<string, string> = {
"baseui.js.button": "m3-baseui JS, `M3Button` only",
"baseui.js.button+switch+tabs": "m3-baseui JS, Button + Switch + Tabs",
"baseui.js.everything": "m3-baseui JS, `import *` (whole package)",
"baseui.js.dialog": "m3-baseui JS, Dialog (Root, Popup, Title)",
"baseui.js.select": "m3-baseui JS, Select (Root, Popup, Item)",
"baseui.css.all": "m3-baseui CSS, every component (minified)",
"baseui.css.button": "m3-baseui CSS, core + Button only (minified)",
"tailwind.preset.css": "m3-tailwind `preset.css` (minified)",
"tailwind.app.css": "Tailwind app CSS, 25 typical classes (minified)",
"tailwind.app.m3.css": "same 25 classes plus 10 of the new utilities and variants (minified)",
"next.client.js": "m3-next client entry JS (theme provider; downloaded up front)",
"next.client.lazy.js":
"m3-next colour engine chunk (loaded only for a custom seed, variant or contrast)",
"next.index.js": "m3-next config/scheme entry JS (server and build time only)",
};
const delta = (a: number, b: number): string =>
a === 0 ? "n/a" : `${(((b - a) / a) * 100).toFixed(1)}%`;
export interface ExampleSize {
js: Measure;
css: Measure;
files: number;
}
/** Client assets of a built example: `.next/static` (server app) or `out/_next/static` (static export). */
export function exampleSizes(dir: string): ExampleSize | undefined {
const root = [join(dir, "out/_next/static"), join(dir, ".next/static")].find((d) =>
existsSync(d),
);
if (!root) return undefined;
let js = Buffer.alloc(0);
let css = Buffer.alloc(0);
let files = 0;
const walkDir = (current: string): void => {
for (const entry of readdirSync(current, { withFileTypes: true })) {
const full = join(current, entry.name);
if (entry.isDirectory()) walkDir(full);
else if (entry.name.endsWith(".js")) {
js = Buffer.concat([js, readFileSync(full)]);
files += 1;
} else if (entry.name.endsWith(".css")) {
css = Buffer.concat([css, readFileSync(full)]);
files += 1;
}
}
};
walkDir(root);
return {
js: { bytes: js.length, gzip: gz(js) },
css: { bytes: css.length, gzip: gz(css) },
files,
};
}
export function markdown(after: Report, before: Report | undefined, extra: string): string {
const rows = Object.keys({ ...before, ...after }).map((key) => {
const a = after[key] as Measure | undefined;
const b = before?.[key] as Measure | undefined;
const cell = (m: Measure | undefined): string => (m ? `${m.bytes} (${m.gzip} gz)` : "n/a");
return `| ${LABELS[key] ?? key} | ${cell(b)} | ${cell(a)} | ${a && b ? delta(b.bytes, a.bytes) : "n/a"} |`;
});
return `<!-- SPDX-License-Identifier: Apache-2.0 -->
# M3 Next 16 stack: bundle and build numbers
Generated by \`bun packages/m3-next/scripts/bundle-report.ts --before <baseline.json> --write docs/reports/M3-NEXT-BUNDLE.md\`.
Bytes are minified output (gzip level 9 in parentheses), Bun ${Bun.version}, browser target, \`react\` and \`react-dom\`
external, Base UI bundled. "Before" is the tree at commit \`0c2cc62\` (the barrel with \`export * as BaseUI\`).
| Measure | Before (bytes) | After (bytes) | Delta |
| --- | --- | --- | --- |
${rows.join("\n")}
${extra}`;
}
if (import.meta.main) {
const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const after = await measure();
const out = flag("--out");
if (out) writeFileSync(out, JSON.stringify(after, null, 2) + "\n");
const beforePath = flag("--before");
const before = beforePath ? (JSON.parse(readFileSync(beforePath, "utf8")) as Report) : undefined;
const mdPath = flag("--write");
const extraPath = flag("--extra");
let extra = extraPath ? readFileSync(extraPath, "utf8") : "";
if (args.includes("--examples")) {
const examples = join(PACKAGES, "m3-next/examples");
const rows: string[] = [];
for (const name of ["app", "tauri"]) {
const sizes = exampleSizes(join(examples, name));
if (!sizes) continue;
const bench = flag(`--bench-${name}`);
const times =
bench && existsSync(bench)
? (JSON.parse(readFileSync(bench, "utf8")) as {
coldMedian: number;
warmMedian: number;
cold: number[];
warm: number[];
})
: undefined;
rows.push(
`| ${name === "app" ? "examples/app (server: Cache Components, proxy.ts, React Compiler)" : "examples/tauri (static export)"} | ${sizes.js.bytes} (${sizes.js.gzip} gz) | ${sizes.css.bytes} (${sizes.css.gzip} gz) | ${times ? `${times.coldMedian} ms (${times.cold.join(", ")})` : "n/a"} | ${times ? `${times.warmMedian} ms (${times.warm.join(", ")})` : "n/a"} |`,
);
}
if (rows.length > 0)
extra += `## Example apps, built and measured through yolo
\`yolo web build\` on Bun ${Bun.version}: Next 16.3.8 with Turbopack, Turbopack workers on Bun too (the \`m3-next\` runner puts a Bun \`node\` first on PATH). Client assets are the files under \`static/\` (JS and CSS, unminified sum of every chunk the build emits, all routes). Cold removes \`.next\` (and \`out\`); warm keeps the build cache. Times are the wall time of the yolo command, median of three (runs listed), on a shared WSL machine.
| App | Client JS | Client CSS | Cold build | Warm build |
| --- | --- | --- | --- | --- |
${rows.join("\n")}
`;
}
if (mdPath) {
writeFileSync(mdPath, markdown(after, before, extra));
console.log(`wrote ${mdPath}`);
}
if (args.includes("--json") || (!mdPath && !out)) console.log(JSON.stringify(after, null, 2));
}
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env bun
// SPDX-License-Identifier: Apache-2.0
// Build, serve and check the Next 16 examples through the yolo CLI.
//
// bun scripts/yolo-example.ts <build|serve|start|export|bench|check> [--example app|tauri] [--json]
//
// yolo's `web` verb runs `bun run build|dev` in `$YOLO_HOME/m3/packages/app-ui`. This script points YOLO_HOME at a directory
// whose `m3/packages/app-ui` is the example (a symlink under the temp dir; nothing is written into this repository) and runs
// `bun $YOLO_ROOT/packages/engine/yolo/src/index.ts web build|dev` (YOLO_ROOT: this Aphrody checkout by default). The example
// scripts run `yolo m3 next`, so Next, Turbopack workers included, runs on Bun; no Node is involved.
//
// build yolo web build (cold: `.next` removed first)
// serve yolo web dev (dev server, curl every route)
// start yolo web build, then `bun run start` (production server, curl every route)
// export yolo tauri build (the tauri example: static export in out/)
// bench cold and warm yolo web build, three runs each
// check build, serve, start, export in turn
/* oxlint-disable eslint/no-await-in-loop -- polling and benchmark runs are sequential on purpose */
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readlinkSync,
rmSync,
symlinkSync,
unlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const PKG = resolve(import.meta.dir, "..");
export const YOLO_ROOT = process.env["YOLO_ROOT"] ?? resolve(PKG, "../../..");
export const YOLO_CLI = join(YOLO_ROOT, "packages/engine/yolo/src/index.ts");
export type Example = "app" | "tauri";
export const exampleDir = (name: Example): string => join(PKG, "examples", name);
/** Routes that must answer, with the status each returns. */
export const ROUTES: Record<Example, [string, number][]> = {
app: [
["/", 200],
["/components", 200],
["/theme", 200],
["/api/health", 200],
["/missing", 404],
],
tauri: [
["/", 200],
["/about/", 200],
],
};
/** A YOLO_HOME whose `m3/packages/app-ui` (the `yolo web` app directory) is the example; created under the temp dir, repaired when it points elsewhere. */
export function yoloHome(
example: Example,
root: string = join(tmpdir(), `m3-next-yolo-${process.getuid?.() ?? 0}`),
): string {
const home = join(root, example);
const web = join(home, "m3", "packages", "app-ui");
mkdirSync(join(home, "m3", "packages"), { recursive: true });
const target = exampleDir(example);
try {
if (lstatSync(web).isSymbolicLink() && readlinkSync(web) === target) return home;
unlinkSync(web);
} catch {
// Absent: create it below.
}
symlinkSync(target, web);
return home;
}
/** The yolo invocation: `bun <cli> <args>` with YOLO_HOME at the example. */
export function yoloCommand(
example: Example,
...args: string[]
): { cmd: string[]; env: Record<string, string | undefined> } {
return {
cmd: ["bun", YOLO_CLI, ...args],
env: { ...process.env, YOLO_HOME: yoloHome(example), NEXT_TELEMETRY_DISABLED: "1" },
};
}
export interface Timed {
exitCode: number;
ms: number;
output: string;
}
/** Run a command to completion, capturing stdout and stderr. */
async function run(
cmd: string[],
env: Record<string, string | undefined>,
cwd?: string,
): Promise<Timed> {
const start = performance.now();
const proc = Bun.spawn(cmd, { windowsHide: true, env, cwd, stdout: "pipe", stderr: "pipe" });
const [out, err, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { exitCode, ms: Math.round(performance.now() - start), output: out + err };
}
export async function yoloBuild(
example: Example,
verb: "web" | "tauri" = "web",
cold = true,
): Promise<Timed> {
const dir = exampleDir(example);
if (cold)
for (const out of [".next", "out"]) rmSync(join(dir, out), { recursive: true, force: true });
const { cmd, env } = yoloCommand(example, verb, "build");
return run(cmd, env);
}
/** Wait for the URL to answer, up to `timeoutMs`. */
async function waitFor(url: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
if ((await fetch(url, { redirect: "manual" })).status > 0) return true;
} catch {
// Not up yet.
}
await Bun.sleep(250);
}
return false;
}
export interface RouteResult {
route: string;
expected: number;
status: number;
ok: boolean;
}
async function probe(base: string, routes: [string, number][]): Promise<RouteResult[]> {
const out: RouteResult[] = [];
for (const [route, expected] of routes) {
const status =
(await fetch(`${base}${route}`, { redirect: "manual" }).catch(() => undefined))?.status ?? 0;
out.push({ route, expected, status, ok: status === expected });
}
return out;
}
/** Run a server in its own process group and make sure it is gone afterwards. */
async function withServer<T>(
cmd: string[],
env: Record<string, string | undefined>,
cwd: string | undefined,
url: string,
body: () => Promise<T>,
): Promise<T> {
// A leftover server on the port would answer for ours: refuse to start.
if (
await fetch(url, { redirect: "manual" }).then(
() => true,
() => false,
)
)
throw new Error(`${url} is already served`);
const proc = Bun.spawn(cmd, {
windowsHide: true,
env,
cwd,
stdout: "ignore",
stderr: "ignore",
detached: true,
});
try {
if (!(await waitFor(url, 90_000))) throw new Error(`${url} did not come up`);
return await body();
} finally {
try {
process.kill(-proc.pid, "SIGTERM");
} catch {
proc.kill();
}
await Promise.race([proc.exited, Bun.sleep(5000)]);
try {
process.kill(-proc.pid, "SIGKILL");
} catch {
// Already gone.
}
}
}
const PORT_DEV = "3217";
const PORT_START = "3218";
/** `yolo web dev` serves the example; curl each route. */
export async function yoloServe(example: Example = "app"): Promise<RouteResult[]> {
const { cmd, env } = yoloCommand(example, "web", "dev");
const base = `http://localhost:${PORT_DEV}`;
return withServer(cmd, { ...env, PORT: PORT_DEV }, undefined, `${base}/`, () =>
probe(base, ROUTES[example]),
);
}
/** `yolo web build` then the production server (`bun run start`); curl each route. */
export async function yoloStart(
example: Example = "app",
): Promise<{ build: Timed; routes: RouteResult[] }> {
const build = await yoloBuild(example);
if (build.exitCode !== 0) return { build, routes: [] };
const base = `http://localhost:${PORT_START}`;
const { env } = yoloCommand(example, "web", "build");
const routes = await withServer(
["bun", "run", "start"],
{ ...env, PORT: PORT_START },
exampleDir(example),
`${base}/`,
() => probe(base, ROUTES[example]),
);
return { build, routes };
}
/** Median of the runs. */
const median = (values: number[]): number =>
// oxlint-disable-next-line unicorn/no-array-sort -- sorts a copy; ES2022 has no toSorted
[...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]!;
/** Cold (`.next` removed) and warm (`.next` kept) `yolo web build` times, `runs` each. */
export async function yoloBench(
example: Example = "app",
runs = 3,
): Promise<{ cold: number[]; warm: number[]; coldMedian: number; warmMedian: number }> {
const cold: number[] = [];
const warm: number[] = [];
for (let i = 0; i < runs; i += 1) {
const result = await yoloBuild(example, "web", true);
if (result.exitCode !== 0) throw new Error(result.output.slice(-2000));
cold.push(result.ms);
}
for (let i = 0; i < runs; i += 1) {
const result = await yoloBuild(example, "web", false);
if (result.exitCode !== 0) throw new Error(result.output.slice(-2000));
warm.push(result.ms);
}
return { cold, warm, coldMedian: median(cold), warmMedian: median(warm) };
}
function report(label: string, results: RouteResult[]): boolean {
for (const r of results)
console.log(
` ${r.ok ? "ok " : "FAIL"} ${r.route.padEnd(14)} ${r.status} (expected ${r.expected})`,
);
const ok = results.length > 0 && results.every((r) => r.ok);
console.log(`${ok ? "pass" : "FAIL"} ${label}`);
return ok;
}
if (import.meta.main) {
const args = process.argv.slice(2);
const [task = "check"] = args;
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const example = (flag("--example") ?? "app") as Example;
const json = args.includes("--json");
if (!existsSync(YOLO_CLI)) {
console.error(`yolo CLI not found at ${YOLO_CLI}; set YOLO_ROOT`);
process.exit(2);
}
let ok = true;
const doBuild = async (name: Example, verb: "web" | "tauri") => {
const result = await yoloBuild(name, verb);
console.log(
`${result.exitCode === 0 ? "pass" : "FAIL"} yolo ${verb} build (${name}) ${result.ms} ms`,
);
if (result.exitCode !== 0) console.log(result.output.slice(-1500));
return result.exitCode === 0;
};
const doExport = async () => {
if (!(await doBuild("tauri", "tauri"))) return false;
const out = join(exampleDir("tauri"), "out");
const html = existsSync(join(out, "index.html"))
? readFileSync(join(out, "index.html"), "utf8")
: "";
const good =
html.includes('id="m3-scheme"') &&
html.includes("document.cookie.match") &&
existsSync(join(out, "about", "index.html"));
console.log(
`${good ? "pass" : "FAIL"} static export: out/index.html, out/about/index.html, inline scheme and pre-paint script`,
);
return good;
};
switch (task) {
case "build":
ok = await doBuild(example, "web");
break;
case "serve":
ok = report(`yolo web dev (${example})`, await yoloServe(example));
break;
case "start": {
const { build, routes } = await yoloStart(example);
console.log(`yolo web build (${example}) ${build.ms} ms, exit ${build.exitCode}`);
ok = report(`next start (${example})`, routes);
break;
}
case "export":
ok = await doExport();
break;
case "bench": {
const bench = await yoloBench(example);
console.log(
json
? JSON.stringify(bench)
: `cold ${bench.cold.join(", ")} ms (median ${bench.coldMedian}); warm ${bench.warm.join(", ")} ms (median ${bench.warmMedian})`,
);
break;
}
case "check":
ok = (await doBuild("app", "web")) && report("yolo web dev (app)", await yoloServe("app"));
if (ok) {
const { routes } = await yoloStart("app");
ok = report("next start (app)", routes);
}
ok = (await doExport()) && ok;
break;
default:
console.error(`unknown task "${task}"`);
process.exit(2);
}
process.exit(ok ? 0 : 1);
}
+240
View File
@@ -0,0 +1,240 @@
// SPDX-License-Identifier: Apache-2.0
// Client boundary of @aphrody/m3-next (the build prepends the `'use client'` directive to dist/client.js; Bun keeps a
// source directive after the hoisted imports, where Next rejects it): the theme provider. It carries only the mode (light, dark, system), the cookie
// bridge to the server render and the class and `color-scheme` on <html>: a few hundred bytes. The Material You engine
// (material-color-utilities, tens of KB) is imported on demand, and only when the seed, variant or contrast differ from
// the scheme the server already inlined (M3Html does), so a typical page never downloads it.
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
import type { SchemeVariant } from "@aphrody/m3-tokens/dynamic-color";
import {
M3_BASELINE_SEED,
M3_THEME_COOKIE,
themeCookie,
validateThemeCookieName,
type M3ThemeMode,
} from "./constants";
export type { SchemeVariant };
/** `light`, `dark`, or `system` (follow the OS). */
export type ThemeMode = M3ThemeMode;
export interface M3ThemeContextType {
themeMode: ThemeMode;
seedColor: string;
resolvedTheme: "light" | "dark";
contrastLevel: number;
variant: SchemeVariant;
setThemeMode: (mode: ThemeMode) => void;
setSeedColor: (seed: string) => void;
setContrastLevel: (level: number) => void;
setVariant: (variant: SchemeVariant) => void;
}
const DARK_QUERY = "(prefers-color-scheme: dark)";
function subscribeToColorScheme(onChange: () => void): () => void {
const query = window.matchMedia(DARK_QUERY);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}
const prefersDark = (): boolean => window.matchMedia(DARK_QUERY).matches;
const serverPrefersDark = (): boolean => false;
/** The stored preference, or `undefined` when the cookie is absent or invalid. */
function readStoredMode(cookieName: string): ThemeMode | undefined {
const prefix = `${validateThemeCookieName(cookieName)}=`;
const value = document.cookie
.split(/;\s*/)
.find((cookie) => cookie.startsWith(prefix))
?.slice(prefix.length);
return value === "light" || value === "dark" || value === "system" ? value : undefined;
}
const M3ThemeContext = createContext<M3ThemeContextType | undefined>(undefined);
export interface M3ThemeProviderProps {
children?: ReactNode;
/** The mode the server rendered; with a static shell, the mode used until the cookie is read (default `system`). */
defaultThemeMode?: ThemeMode;
/** Source colour of the scheme in the document (default `#6750a4`). */
defaultSeedColor?: string;
defaultVariant?: SchemeVariant;
defaultContrastLevel?: number;
/**
* The scheme of the default seed, variant and contrast is already in the document (M3Html inlines it).
* Set `false` when it is not: the roles are then computed in the browser on mount.
*/
schemeInlined?: boolean;
/** Cookie that stores the preference (default `m3-theme`). */
cookieName?: string;
/** Element that receives a custom scheme (default `<html>`). */
target?: HTMLElement;
}
/**
* Theme state for the app. Writes the preference cookie, keeps `.dark`, `.light`, `data-m3-theme` and `color-scheme` on
* `<html>` in step, and adopts a saved preference after hydration when the server could not read it (static shell).
*/
export function M3ThemeProvider({
children,
defaultThemeMode = "system",
defaultSeedColor = M3_BASELINE_SEED,
defaultVariant = "tonalSpot",
defaultContrastLevel = 0,
schemeInlined = true,
cookieName = M3_THEME_COOKIE,
target,
}: M3ThemeProviderProps) {
const [themeMode, setThemeMode] = useState<ThemeMode>(defaultThemeMode);
const [seedColor, setSeedColor] = useState(defaultSeedColor);
const [contrastLevel, setContrastLevel] = useState(defaultContrastLevel);
const [variant, setVariant] = useState<SchemeVariant>(defaultVariant);
const initial = useRef({
seed: defaultSeedColor,
variant: defaultVariant,
contrast: defaultContrastLevel,
});
const systemDark = useSyncExternalStore(subscribeToColorScheme, prefersDark, serverPrefersDark);
const resolvedTheme: "light" | "dark" =
themeMode === "system" ? (systemDark ? "dark" : "light") : themeMode;
// Cookie and <html>. The first run adopts a saved preference the server did not see, and leaves <html> to the
// pre-paint script until the adopted mode renders; later runs write the cookie and the classes.
const adopted = useRef(false);
useEffect(() => {
if (!adopted.current) {
adopted.current = true;
const stored = readStoredMode(cookieName);
if (stored !== undefined && stored !== themeMode) {
// oxlint-disable-next-line react/set-state-in-effect -- adoption unique au premier montage de la préférence de thème enregistrée dans le cookie (synchronisation avec un stockage externe)
setThemeMode(stored);
return;
}
}
// The theme cookie is the documented bridge to the server render; Cookie Store is not in every browser yet.
// oxlint-disable-next-line unicorn/no-document-cookie
document.cookie = themeCookie(themeMode, cookieName);
const root = document.documentElement;
root.classList.toggle("light", themeMode === "light");
root.classList.toggle("dark", resolvedTheme === "dark");
root.dataset["m3Theme"] = themeMode;
root.style.colorScheme = resolvedTheme;
}, [themeMode, resolvedTheme, cookieName]);
// A scheme other than the inlined one is computed on demand: the engine is a separate chunk.
const applied = useRef(false);
useEffect(() => {
const custom =
seedColor !== initial.current.seed ||
variant !== initial.current.variant ||
contrastLevel !== initial.current.contrast;
if (schemeInlined && !custom && !applied.current) return;
let cancelled = false;
void (async () => {
const { applyDynamicColor, clearDynamicColor } =
await import("@aphrody/m3-tokens/dynamic-color");
if (cancelled) return;
const element = target ?? document.documentElement;
if (schemeInlined && !custom) {
clearDynamicColor(element);
applied.current = false;
return;
}
applyDynamicColor(seedColor, {
dark: resolvedTheme === "dark",
variant,
contrastLevel,
target: element,
});
applied.current = true;
})();
return () => {
cancelled = true;
};
}, [seedColor, variant, contrastLevel, resolvedTheme, schemeInlined, target]);
const value = useMemo<M3ThemeContextType>(
() => ({
themeMode,
seedColor,
resolvedTheme,
contrastLevel,
variant,
setThemeMode,
setSeedColor,
setContrastLevel,
setVariant,
}),
[themeMode, seedColor, resolvedTheme, contrastLevel, variant],
);
return <M3ThemeContext.Provider value={value}>{children}</M3ThemeContext.Provider>;
}
/** The theme state and its setters; throws outside {@link M3ThemeProvider}. */
export function useM3Theme(): M3ThemeContextType {
const context = useContext(M3ThemeContext);
if (context === undefined) throw new Error("useM3Theme must be used within an M3ThemeProvider");
return context;
}
export interface M3NextThemeProviderProps {
children?: ReactNode;
/** The mode the server rendered (from the cookie). */
initialMode?: M3ThemeMode;
/** Source colour (default `#6750a4`). */
seed?: string;
variant?: SchemeVariant;
contrast?: number;
cookieName?: string;
/** The server inlined the scheme of `seed` (default `true`; `M3Root` with `inlineScheme={false}` passes `false`). */
schemeInlined?: boolean;
}
/** {@link M3ThemeProvider} with the prop names of `M3Root` (`initialMode`, `seed`, `variant`, `contrast`). */
export function M3NextThemeProvider({
children,
initialMode,
seed,
variant,
contrast,
cookieName,
schemeInlined,
}: M3NextThemeProviderProps) {
return (
<M3ThemeProvider
defaultThemeMode={initialMode}
defaultSeedColor={seed}
defaultVariant={variant}
defaultContrastLevel={contrast}
cookieName={cookieName}
schemeInlined={schemeInlined}
>
{children}
</M3ThemeProvider>
);
}
const CYCLE: Record<M3ThemeMode, M3ThemeMode> = { light: "dark", dark: "system", system: "light" };
/** The current mode, the resolved light / dark value and a `light -> dark -> system` cycle. */
export function useM3ThemeToggle(): {
mode: M3ThemeMode;
resolved: "light" | "dark";
setMode: (mode: M3ThemeMode) => void;
toggle: () => void;
} {
const { themeMode, resolvedTheme, setThemeMode } = useM3Theme();
const toggle = useCallback(() => setThemeMode(CYCLE[themeMode]), [setThemeMode, themeMode]);
return { mode: themeMode, resolved: resolvedTheme, setMode: setThemeMode, toggle };
}
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: Apache-2.0
// Dependency-free constants shared by every entry (the client bundle imports only these).
/** Theme preference persisted in the cookie: a forced mode or the OS preference. */
export type M3ThemeMode = "light" | "dark" | "system";
/** Cookie that carries the theme preference between the client toggle and the server render. */
export const M3_THEME_COOKIE = "m3-theme";
/** The Material 3 baseline source colour. */
export const M3_BASELINE_SEED = "#6750a4";
/** One year, in seconds: the lifetime of the theme cookie. */
export const M3_THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
/** Reject names that cannot be represented by a single HTTP cookie. */
export function validateThemeCookieName(name: string): string {
if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name)) {
throw new Error("M3 theme cookie name must be an HTTP cookie token");
}
return name;
}
/** The `Set-Cookie`-style string the client toggle writes. */
export function themeCookie(mode: M3ThemeMode, name: string = M3_THEME_COOKIE): string {
return `${validateThemeCookieName(name)}=${mode}; path=/; max-age=${M3_THEME_COOKIE_MAX_AGE}; samesite=lax`;
}
/** Parse a cookie value into a theme mode; anything else yields `fallback`. */
export function parseThemeMode(
value: string | undefined | null,
fallback: M3ThemeMode = "system",
): M3ThemeMode {
return value === "light" || value === "dark" || value === "system" ? value : fallback;
}
+237
View File
@@ -0,0 +1,237 @@
// SPDX-License-Identifier: Apache-2.0
// Crawler-facing files for an App Router site: sitemap.xml, robots.txt, llms.txt and a
// script-safe JSON-LD serializer. Pure functions (no React, no Node, no Next): call them from a
// route handler (`app/sitemap.xml/route.ts`), from a build script, or from a test.
//
// export const GET = () => new Response(m3SitemapXml(routes, "https://example.com"), { headers: { "content-type": "application/xml" } });
const SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9";
/** The Sitemaps protocol limit for one file (a sitemap index is needed beyond it). */
export const M3_SITEMAP_MAX_URLS = 50_000;
export type M3ChangeFrequency =
| "always"
| "hourly"
| "daily"
| "weekly"
| "monthly"
| "yearly"
| "never";
export interface M3Route {
/** Path (`/docs`), or an absolute URL on the same origin. */
path: string;
/** `YYYY-MM-DD` or a full W3C datetime; a `Date` is serialized to ISO 8601. */
lastmod?: string | Date;
changefreq?: M3ChangeFrequency;
/** 0 to 1 (serialized with one decimal). */
priority?: number;
}
function xmlEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function parseOrigin(origin: string): URL {
let url: URL;
try {
url = new URL(origin);
} catch {
throw new TypeError(`origin ${JSON.stringify(origin)} is not an absolute URL`);
}
if (url.protocol !== "http:" && url.protocol !== "https:")
throw new TypeError(`origin ${origin} must be http or https`);
return url;
}
/** Absolute URL of a route on `origin`; throws when the route points at another origin. */
export function m3AbsoluteUrl(path: string, origin: string): string {
const base = parseOrigin(origin);
const url = new URL(path, base);
if (url.origin !== base.origin)
throw new RangeError(`route ${path} is not on origin ${base.origin}`);
url.hash = "";
return url.href;
}
function singleLine(value: string, what: string): string {
if (/[\r\n]/.test(value)) throw new TypeError(`${what} must not contain a line break`);
return value.trim();
}
function lastmodValue(value: string | Date): string {
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) throw new RangeError("lastmod is an invalid Date");
return value.toISOString();
}
if (!/^\d{4}-\d{2}-\d{2}(T[\d:.]+(Z|[+-]\d{2}:\d{2}))?$/.test(value))
throw new RangeError(`lastmod ${JSON.stringify(value)} is not a W3C datetime`);
return value;
}
/**
* A Sitemaps 0.9 document. Routes are resolved against `origin`, de-duplicated by absolute URL
* (the last definition wins) and sorted by URL so the output is stable whatever the input order.
*/
export function m3SitemapXml(routes: readonly (string | M3Route)[], origin: string): string {
const entries = new Map<string, M3Route>();
for (const route of routes) {
const item = typeof route === "string" ? { path: route } : route;
entries.set(m3AbsoluteUrl(item.path, origin), item);
}
if (entries.size > M3_SITEMAP_MAX_URLS)
throw new RangeError(
`a sitemap holds at most ${M3_SITEMAP_MAX_URLS} URLs, got ${entries.size}`,
);
const urls = [...entries]
// oxlint-disable-next-line unicorn/no-array-sort -- Sort the owned copy without requiring ES2023 in consumers.
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([loc, route]) => {
const lines = [` <loc>${xmlEscape(loc)}</loc>`];
if (route.lastmod !== undefined)
lines.push(` <lastmod>${lastmodValue(route.lastmod)}</lastmod>`);
if (route.changefreq !== undefined)
lines.push(` <changefreq>${route.changefreq}</changefreq>`);
if (route.priority !== undefined) {
if (!(route.priority >= 0 && route.priority <= 1))
throw new RangeError(`priority ${route.priority} is outside 0..1`);
lines.push(` <priority>${route.priority.toFixed(1)}</priority>`);
}
return ` <url>\n${lines.join("\n")}\n </url>`;
});
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="${SITEMAP_NS}">\n${urls.join("\n")}${urls.length ? "\n" : ""}</urlset>\n`;
}
export interface M3RobotsRule {
userAgent: string | readonly string[];
allow?: readonly string[];
/** An empty string allows everything for the group. */
disallow?: readonly string[];
/** Seconds between requests (honoured by some crawlers, ignored by Google). */
crawlDelay?: number;
}
export interface M3RobotsOptions {
origin: string;
/** Default: every crawler may fetch everything. */
rules?: readonly M3RobotsRule[];
/** Sitemap paths or absolute URLs; default `["/sitemap.xml"]`. Pass `[]` for none. */
sitemaps?: readonly string[];
}
/** A robots.txt (RFC 9309) with one group per rule and absolute `Sitemap:` lines. */
export function m3RobotsTxt(options: M3RobotsOptions): string {
const rules = options.rules ?? [{ userAgent: "*", allow: ["/"] }];
const groups = rules.map((rule) => {
const agents = (
typeof rule.userAgent === "string" ? [rule.userAgent] : [...rule.userAgent]
).map((agent) => singleLine(agent, "user agent"));
if (agents.length === 0 || agents.some((agent) => agent === ""))
throw new TypeError("a robots.txt group needs at least one non-empty user agent");
const lines = agents.map((agent) => `User-agent: ${agent}`);
for (const path of rule.allow ?? []) lines.push(`Allow: ${singleLine(path, "Allow path")}`);
for (const path of rule.disallow ?? [])
lines.push(`Disallow: ${singleLine(path, "Disallow path")}`);
if (rule.crawlDelay !== undefined) {
if (!(rule.crawlDelay >= 0))
throw new RangeError(`crawlDelay ${rule.crawlDelay} must be >= 0`);
lines.push(`Crawl-delay: ${rule.crawlDelay}`);
}
return lines.join("\n");
});
const sitemaps = (options.sitemaps ?? ["/sitemap.xml"]).map(
(path) => `Sitemap: ${m3AbsoluteUrl(path, options.origin)}`,
);
return `${[...groups, ...(sitemaps.length ? [sitemaps.join("\n")] : [])].join("\n\n")}\n`;
}
export interface M3LlmsLink {
title: string;
/** Path on `origin`, or an absolute URL (any origin). */
path: string;
/** Short description after the link. */
notes?: string;
}
export interface M3LlmsSection {
title: string;
links: readonly M3LlmsLink[];
/** Emitted under the reserved `## Optional` heading (readers may skip it for a shorter context). */
optional?: boolean;
}
export interface M3LlmsOptions {
origin: string;
/** The H1: the only required part of the format. */
name: string;
/** One-paragraph summary, emitted as a blockquote. */
summary?: string;
/** Free paragraphs after the summary (no headings). */
details?: string | readonly string[];
sections?: readonly M3LlmsSection[];
}
const markdownText = (value: string) =>
singleLine(value, "llms.txt text").replace(/([[\]\\])/g, "\\$1");
/** An `/llms.txt` following https://llmstxt.org: H1, blockquote summary, details, then `##` link lists. */
export function m3LlmsTxt(options: M3LlmsOptions): string {
const name = singleLine(options.name, "name");
if (name === "") throw new TypeError("llms.txt needs a non-empty name");
const blocks = [`# ${name}`];
if (options.summary)
blocks.push(
options.summary
.split(/\r?\n/)
.map((line) => `> ${line.trim()}`)
.join("\n"),
);
const details =
typeof options.details === "string" ? [options.details] : [...(options.details ?? [])];
for (const paragraph of details) {
if (/^\s*#/m.test(paragraph)) throw new TypeError("llms.txt details must not contain headings");
blocks.push(paragraph.trim());
}
const sections = options.sections ?? [];
const link = (item: M3LlmsLink) => {
const url = /^[a-z][a-z0-9+.-]*:/i.test(item.path)
? new URL(item.path).href
: m3AbsoluteUrl(item.path, options.origin);
return `- [${markdownText(item.title)}](${url})${item.notes ? `: ${singleLine(item.notes, "notes")}` : ""}`;
};
const ordered = [
...sections.filter((section) => !section.optional),
...sections.filter((section) => section.optional),
];
const optional = ordered.filter((section) => section.optional);
for (const section of ordered.filter((s) => !s.optional))
blocks.push(`## ${markdownText(section.title)}\n\n${section.links.map(link).join("\n")}`);
if (optional.length > 0)
blocks.push(
`## Optional\n\n${optional
.flatMap((section) => section.links)
.map(link)
.join("\n")}`,
);
return `${blocks.join("\n\n")}\n`;
}
/**
* JSON for an inline `<script type="application/ld+json">`: `<`, `>`, `&` and the JS line
* separators are escaped so the payload can never close the tag or be reparsed as markup.
*/
export function m3JsonLd(data: unknown): string {
const json = JSON.stringify(data);
if (json === undefined) throw new TypeError("m3JsonLd: value is not serializable");
return json
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
+333
View File
@@ -0,0 +1,333 @@
// SPDX-License-Identifier: Apache-2.0
// @aphrody/m3-next: Next.js 16 App Router integration for the Material Design 3 layer.
//
// This entry is safe in every runtime (next.config, Server Components, Client Components, edge):
// it holds the config wrapper, the metadata / viewport helpers and the pure scheme helpers.
// React Server Components live in `@aphrody/m3-next/server`, the client boundary in
// `@aphrody/m3-next/client`.
import type { Metadata, NextConfig, Viewport } from "next";
import {
schemeFromSeed,
type ColorRoleMap,
type SchemeVariant,
} from "@aphrody/m3-tokens/dynamic-color";
import {
M3_BASELINE_SEED,
M3_THEME_COOKIE,
validateThemeCookieName,
type M3ThemeMode,
} from "./constants";
export type { ColorRoleMap, SchemeVariant };
export * from "./constants";
// Crawler-facing files: sitemap.xml, robots.txt, llms.txt and a script-safe JSON-LD serializer.
export * from "./crawl";
/**
* The published M3 packages a Next app compiles from source. They ship modern ESM with
* `'use client'` boundaries and CSS side effects, so Next must bundle them (never externalise).
*/
export const M3_TRANSPILE_PACKAGES: readonly string[] = [
"@aphrody/m3-next",
"@aphrody/m3-baseui",
"@aphrody/m3-theme",
"@aphrody/m3-tokens",
"@aphrody/m3-motion",
"@aphrody/m3-primitives",
"@aphrody/m3-forms",
"@aphrody/m3-ai",
"@aphrody/m3-explorer",
"@aphrody/m3-react",
"@aphrody/material-web",
];
/** Barrel packages whose named imports Next rewrites to direct module imports. */
export const M3_OPTIMIZE_PACKAGE_IMPORTS: readonly string[] = [
"@aphrody/m3-baseui",
"@base-ui/react",
"@aphrody/m3-primitives",
"@aphrody/m3-forms",
"@aphrody/m3-ai",
"@aphrody/m3-explorer",
"@aphrody/m3-react",
];
/**
* Server-only packages Next must load with the runtime `require` instead of bundling:
* `@aphrody/bun` opens a native library through `bun:ffi`.
*/
export const M3_SERVER_EXTERNAL_PACKAGES: readonly string[] = ["@aphrody/bun"];
/** Tauri static export (`output: "export"`); see {@link WithM3Options.tauri}. */
export interface M3TauriOptions {
/** Dev server host the webview loads assets from; default `TAURI_DEV_HOST`, else none (desktop uses `localhost`). */
devHost?: string;
/** Dev server port (default 3000). */
devPort?: number;
}
export interface WithM3Options {
/** Extra packages to transpile next to {@link M3_TRANSPILE_PACKAGES}. */
transpilePackages?: readonly string[];
/** Rewrite barrel imports of the M3 component packages (default `true`). */
optimizePackageImports?: boolean;
/**
* Turbopack project root. Set it to the workspace root in a monorepo whose packages live
* outside the app directory (Next otherwise infers it from the nearest lockfile).
*/
turbopackRoot?: string;
/** React Compiler (`reactCompiler`); needs `babel-plugin-react-compiler` installed in the app. */
reactCompiler?: boolean;
/** Cache Components (`cacheComponents`): `use cache`, `cacheLife`, `cacheTag` and partial prerendering. */
cacheComponents?: boolean;
/** Typed routes (`typedRoutes`): `Link` hrefs and `redirect` are checked against `app/`. */
typedRoutes?: boolean;
/**
* Static export for a Tauri webview: `output: "export"`, unoptimized images, trailing slashes, no Cache
* Components, and `NEXT_PUBLIC_M3_STATIC=1` so `M3Html` / `M3Root` stop reading the request cookie (there is no server).
* `true` always, `"auto"` when the Tauri CLI runs the build (`TAURI_ENV_PLATFORM` is set), or the options.
* `proxy.ts`, route handlers and `cookies()` do not exist in an export; the theme preference lives in the cookie
* the client writes and the pre-paint script reads.
*/
tauri?: boolean | "auto" | M3TauriOptions;
}
function union(...lists: (readonly string[] | undefined)[]): string[] {
return [...new Set(lists.flatMap((list) => list ?? []))];
}
/** `true` when the Tauri CLI drives this process (it sets `TAURI_ENV_PLATFORM` for `beforeBuildCommand` and `beforeDevCommand`). */
export function isTauriBuild(env: Record<string, string | undefined> = process.env): boolean {
return env["TAURI_ENV_PLATFORM"] !== undefined || env["TAURI_ENV_ARCH"] !== undefined;
}
/**
* Wrap a Next config for the M3 packages: transpile them, keep `@aphrody/bun` external on the
* server, optimise barrel imports and enable React strict mode. User values are merged, never
* dropped (an option never overrides a key the config sets); Turbopack (the Next 16 default for dev and
* build) needs no loader for the M3 sheets because they are plain CSS processed by the app's PostCSS /
* Tailwind v4 pipeline.
*/
export function withM3(config: NextConfig = {}, options: WithM3Options = {}): NextConfig {
const { optimizePackageImports = true, turbopackRoot } = options;
const transpile = union(
config.transpilePackages,
M3_TRANSPILE_PACKAGES,
options.transpilePackages,
);
const external = union(config.serverExternalPackages, M3_SERVER_EXTERNAL_PACKAGES).filter(
(name) => !transpile.includes(name),
);
const next: NextConfig = {
reactStrictMode: true,
...config,
transpilePackages: transpile,
serverExternalPackages: external,
};
if (optimizePackageImports) {
next.experimental = {
...config.experimental,
optimizePackageImports: union(
config.experimental?.optimizePackageImports,
M3_OPTIMIZE_PACKAGE_IMPORTS,
),
};
}
if (turbopackRoot !== undefined || config.turbopack !== undefined) {
next.turbopack = {
...config.turbopack,
...(turbopackRoot !== undefined ? { root: turbopackRoot } : {}),
};
}
if (options.reactCompiler !== undefined) next.reactCompiler ??= options.reactCompiler;
if (options.typedRoutes !== undefined) next.typedRoutes ??= options.typedRoutes;
if (options.cacheComponents !== undefined) next.cacheComponents ??= options.cacheComponents;
const tauri = options.tauri === "auto" ? isTauriBuild() : options.tauri;
if (tauri !== undefined && tauri !== false) {
const { devHost = process.env["TAURI_DEV_HOST"], devPort = 3000 }: M3TauriOptions =
tauri === true ? {} : tauri;
next.output ??= "export";
next.trailingSlash ??= true;
next.images = { unoptimized: true, ...config.images };
// A static export has no server to render the shell on demand.
if (config.cacheComponents === undefined) next.cacheComponents = false;
next.env = { NEXT_PUBLIC_M3_STATIC: "1", ...config.env };
if (devHost !== undefined && process.env["NODE_ENV"] !== "production")
next.assetPrefix ??= `http://${devHost}:${devPort}`;
}
return next;
}
/** The `color-scheme` value for a mode: `system` lets the UA follow the OS. */
export function colorSchemeFor(mode: M3ThemeMode): "light" | "dark" | "light dark" {
return mode === "system" ? "light dark" : mode;
}
export interface M3SchemeOptions {
/** Source colour, `#rrggbb` (default {@link M3_BASELINE_SEED}). */
seed?: string;
/** Scheme variant (default `tonalSpot`). */
variant?: SchemeVariant;
/** Contrast level in `[-1, 1]` (default 0). */
contrast?: number;
}
/** The M3 colour roles of one scheme, computed in JS (material-color-utilities). */
export function m3SchemeColors(options: M3SchemeOptions & { dark?: boolean } = {}): ColorRoleMap {
const { seed = M3_BASELINE_SEED, variant = "tonalSpot", contrast = 0, dark = false } = options;
return schemeFromSeed(seed, { dark, variant, contrastLevel: contrast });
}
/** The `theme-color` pair of a scheme: the `surface` role in light and in dark. */
export function m3ThemeColors(options: M3SchemeOptions = {}): { light: string; dark: string } {
const surface = "--md-sys-color-surface";
const light = m3SchemeColors({ ...options, dark: false })[surface];
const dark = m3SchemeColors({ ...options, dark: true })[surface];
if (!light || !dark) throw new Error("m3ThemeColors: the scheme has no surface role");
return { light, dark };
}
export interface M3ViewportOptions extends M3SchemeOptions {
/** Forced mode; `system` (default) emits one theme-color per `prefers-color-scheme`. */
mode?: M3ThemeMode;
/** Merged over the generated viewport. */
viewport?: Viewport;
}
/**
* A Next `Viewport` whose `theme-color` is the scheme surface and whose `color-scheme` matches
* the mode. Use it as `export const viewport = generateM3Viewport({ seed })` or return it from
* `generateViewport()` after reading the theme cookie.
*/
export function generateM3Viewport(options: M3ViewportOptions = {}): Viewport {
const { mode = "system", viewport, ...scheme } = options;
const colors = m3ThemeColors(scheme);
const themeColor: Viewport["themeColor"] =
mode === "system"
? [
{ media: "(prefers-color-scheme: light)", color: colors.light },
{ media: "(prefers-color-scheme: dark)", color: colors.dark },
]
: colors[mode];
return {
width: "device-width",
initialScale: 1,
colorScheme: mode === "system" ? "light dark" : mode,
themeColor,
...viewport,
};
}
export interface M3MetadataOptions {
/** Application name: the default title, the title template suffix and `applicationName`. */
name: string;
description?: string;
/** Absolute site URL, the base of every relative metadata URL. */
url?: string | URL;
/** Merged over the generated metadata. */
metadata?: Metadata;
}
/**
* Baseline `Metadata` for an M3 app: `title.template` (`%s · name`), `applicationName`, the
* `metadataBase`, an installable web-app capable flag and no automatic telephone links.
*/
export function generateM3Metadata(options: M3MetadataOptions): Metadata {
const { name, description, url, metadata } = options;
return {
title: { default: name, template: `%s · ${name}` },
applicationName: name,
...(description !== undefined ? { description } : {}),
...(url !== undefined ? { metadataBase: typeof url === "string" ? new URL(url) : url } : {}),
appleWebApp: { capable: true, title: name, statusBarStyle: "default" },
formatDetection: { telephone: false },
...metadata,
};
}
function block(selector: string, vars: ColorRoleMap, colorScheme: string, indent = ""): string {
const body = Object.entries(vars)
.map(([name, value]) => `${indent} ${name}: ${value};`)
.join("\n");
return `${indent}${selector} {\n${indent} color-scheme: ${colorScheme};\n${body}\n${indent}}`;
}
/**
* The stylesheet M3Html inlines: light roles on `:root`, dark roles under
* `prefers-color-scheme: dark` unless `.light` forces light, dark roles on `:root.dark`, light
* roles on `:root.light`. The class selectors outrank the `:root` blocks of `tokens.css`, so a
* forced mode wins over the OS preference whatever the stylesheet order.
*/
export function m3SchemeStylesheet(light: ColorRoleMap, dark: ColorRoleMap): string {
return [
block(":root", light, "light"),
`@media (prefers-color-scheme: dark) {\n${block(":root:not(.light)", dark, "dark", " ")}\n}`,
block(":root.dark", dark, "dark"),
block(":root.light", light, "light"),
].join("\n\n");
}
/**
* Custom-property declarations of the first `:root { ... }` block of a sheet (the Rust `scheme`
* sheet is one such block). `color-scheme` is skipped: {@link m3SchemeStylesheet} writes it.
*/
export function rootDeclarations(css: string): ColorRoleMap {
const body = /:root\s*\{([^}]*)\}/.exec(css.replace(/\/\*[\s\S]*?\*\//g, ""))?.[1];
if (body === undefined) throw new Error("rootDeclarations: no :root block in the sheet");
const vars: ColorRoleMap = {};
for (const match of body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g))
vars[match[1]!] = match[2]!.trim();
return vars;
}
/**
* Inline script that applies the theme before first paint, so Tailwind `dark:` utilities and the M3 roles never
* flash. It reads the preference cookie (`light`, `dark` or `system`; absent means `system`) and sets the `.dark` /
* `.light` classes and `color-scheme` on `<html>`; `system` resolves `prefers-color-scheme`. Because the cookie is
* read in the browser, the shell can stay static (Cache Components, Tauri static export) and still honour a saved
* preference.
*/
export function colorSchemeScript(cookieName: string = M3_THEME_COOKIE): string {
const name = JSON.stringify(`${validateThemeCookieName(cookieName)}=`);
return (
"(function(){try{var d=document.documentElement," +
`p=${name},c=document.cookie.split(/;\\s*/).find(function(v){return v.indexOf(p)===0}),v=c?c.slice(p.length):"",m=/^(light|dark|system)$/.test(v)?v:"system";` +
"if(m==='system'){var s=window.matchMedia('(prefers-color-scheme: dark)').matches;" +
"d.classList.remove('light');d.classList.toggle('dark',s);d.style.colorScheme=s?'dark':'light'}" +
"else{d.classList.toggle('dark',m==='dark');d.classList.toggle('light',m==='light');d.style.colorScheme=m}" +
"}catch(e){}})();"
);
}
/** {@link colorSchemeScript} for the default cookie name. */
export const M3_COLOR_SCHEME_SCRIPT = colorSchemeScript();
/** Typefaces to map onto the M3 reference typefaces: a CSS `font-family` value or a `var(--font-...)` reference. */
export interface M3Typefaces {
/** `--md-ref-typeface-brand`: display, headline and title-large. */
brand?: string;
/** `--md-ref-typeface-plain`: title, body and label. */
plain?: string;
/** `--md-ref-typeface-code`. */
code?: string;
}
/**
* The `:root` rule that points the M3 reference typefaces at the fonts an app loads, typically `next/font` variables:
*
* const roboto = Roboto_Flex({ subsets: ["latin"], variable: "--font-roboto" }); // module scope, as next/font requires
* <M3Html className={roboto.variable} typefaces={{ brand: "var(--font-roboto)", plain: "var(--font-roboto)" }} />
*
* Values are CSS-escaped for a `<style>` element; an empty object yields an empty string.
*/
export function m3TypefaceCss(typefaces: M3Typefaces): string {
const rules = (["brand", "plain", "code"] as const)
.filter((role) => typefaces[role] !== undefined)
.map((role) => {
const value = typefaces[role]!.replace(/[<>{};]/g, "").trim();
return ` --md-ref-typeface-${role}: ${value};`;
});
return rules.length > 0 ? `:root {\n${rules.join("\n")}\n}` : "";
}
+364
View File
@@ -0,0 +1,364 @@
// SPDX-License-Identifier: Apache-2.0
// Server side of @aphrody/m3-next: the App Router root components and the m3Css sheet helper.
// Import this entry only from Server Components, route handlers or next.config (it reads files
// and request cookies).
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { HTMLAttributes, ReactNode } from "react";
import { cookies } from "next/headers";
import { M3NextThemeProvider } from "@aphrody/m3-next/client";
import {
M3_BASELINE_SEED,
M3_THEME_COOKIE,
colorSchemeFor,
colorSchemeScript,
m3SchemeColors,
m3SchemeStylesheet,
m3TypefaceCss,
parseThemeMode,
rootDeclarations,
type M3SchemeOptions,
type M3ThemeMode,
type M3Typefaces,
} from "./index";
export * from "./index";
/** A sheet `m3Css` can render. */
export type M3SheetName = "tokens" | "tailwind" | "spaceui" | "scheme";
export interface M3CssRequest extends M3SchemeOptions {
sheet: M3SheetName;
/** Dark mode, for the single-mode `scheme` sheet. */
dark?: boolean;
}
/** Where a sheet came from: Rust through `bun:ffi`, the pre-generated file, or JS (`scheme`). */
export type M3CssSource = "ffi" | "static" | "js";
export interface M3CssResult {
css: string;
source: M3CssSource;
}
export interface M3CssOptions {
/**
* Directory to resolve `@aphrody/m3-next` from (default: the process working directory, which
* is the app root under `next dev|build|start`, then this module's own location).
*/
root?: string;
/** Never load the native library, even under Bun (default `false`). */
noFfi?: boolean;
}
interface BunM3Module {
m3Css(request: {
sheet: string;
seed?: string;
variant?: string;
dark?: boolean;
contrast?: number;
}): string;
}
const STATIC_SHEETS: Record<Exclude<M3SheetName, "scheme">, string> = {
tokens: "@aphrody/m3-theme/tokens.css",
tailwind: "@aphrody/m3-theme/tailwind.css",
spaceui: "@aphrody/m3-theme/spaceui.css",
};
/** `true` when this process runs on Bun (`bun --bun next ...`, `bun test`). */
export function isBunRuntime(): boolean {
return typeof process !== "undefined" && typeof process.versions?.bun === "string";
}
/** A `require` rooted at the installed `@aphrody/m3-next`, so its own dependencies resolve. */
function packageRequire(root?: string): NodeRequire {
const bases = [root, typeof process !== "undefined" ? process.cwd() : undefined].filter(
(base): base is string => typeof base === "string",
);
for (const base of bases) {
try {
const manifest = createRequire(join(base, "package.json")).resolve(
"@aphrody/m3-next/package.json",
);
return createRequire(manifest);
} catch {
// Not installed under this base: try the next one.
}
}
return createRequire(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"));
}
let bunModule: Promise<BunM3Module | null> | undefined;
let bunModuleError: unknown;
/** Load `@aphrody/bun` at runtime (never bundled), or `null` off Bun or without the library. */
function loadBunModule(root?: string): Promise<BunM3Module | null> {
if (!isBunRuntime()) return Promise.resolve(null);
bunModule ??= (async () => {
try {
const entry = packageRequire(root).resolve("@aphrody/bun");
const specifier = pathToFileURL(entry).href;
const loaded = (await import(
/* turbopackIgnore: true */ /* webpackIgnore: true */ specifier
)) as BunM3Module;
return typeof loaded.m3Css === "function" ? loaded : null;
} catch (error) {
bunModuleError = error;
return null;
}
})();
return bunModule;
}
/** Why the native path is unavailable (after a failed load), for diagnostics pages and logs. */
export function m3FfiError(): unknown {
return bunModuleError;
}
function kebabVariant(variant: string): string {
return variant.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
}
const cache = new Map<string, M3CssResult>();
const CACHE_LIMIT = 64;
function remember(key: string, result: M3CssResult): M3CssResult {
if (cache.size >= CACHE_LIMIT) cache.delete(cache.keys().next().value!);
cache.set(key, result);
return result;
}
/**
* Render an M3 sheet on the server. Under Bun (`bun --bun next ...`) it calls the Rust renderer
* of `@aphrody/bun` (`crates/ui/m3-tokens/src/sheets.rs`), which honours every option.
* Under Node, where `bun:ffi` does not exist, `tokens` / `tailwind` / `spaceui` come from the
* pre-generated files of `@aphrody/m3-theme` (baseline seed) and `scheme` is computed in JS with
* material-color-utilities. Results are cached per request shape.
*/
export async function m3CssResult(
request: M3CssRequest,
options: M3CssOptions = {},
): Promise<M3CssResult> {
const {
sheet,
seed = M3_BASELINE_SEED,
variant = "tonalSpot",
contrast = 0,
dark = false,
} = request;
const key = JSON.stringify([
sheet,
seed.toLowerCase(),
variant,
contrast,
dark,
options.noFfi === true,
]);
const hit = cache.get(key);
if (hit) return hit;
const native = options.noFfi ? null : await loadBunModule(options.root);
if (native) {
const css = native.m3Css({ sheet, seed, variant: kebabVariant(variant), dark, contrast });
return remember(key, { css, source: "ffi" });
}
if (sheet === "scheme") {
const vars = m3SchemeColors({ seed, variant, contrast, dark });
const body = Object.entries(vars)
.map(([name, value]) => ` ${name}: ${value};`)
.join("\n");
const mode = dark ? "dark" : "light";
const css = `/* M3 ${variant} scheme, ${mode}, seed ${seed} (material-color-utilities). */\n:root {\n color-scheme: ${mode};\n${body}\n}\n`;
return remember(key, { css, source: "js" });
}
const file = STATIC_SHEETS[sheet];
if (!file) throw new Error(`m3Css: unknown sheet ${String(sheet)}`);
const css = readFileSync(packageRequire(options.root).resolve(file), "utf8");
return remember(key, { css, source: "static" });
}
/** {@link m3CssResult}, returning only the CSS text. */
export async function m3Css(request: M3CssRequest, options: M3CssOptions = {}): Promise<string> {
return (await m3CssResult(request, options)).css;
}
/**
* The inline stylesheet of M3Html: the light and dark roles of one scheme, wired to the
* `.light` / `.dark` classes and `prefers-color-scheme` (see `m3SchemeStylesheet`).
*/
export async function m3SchemeCss(
scheme: M3SchemeOptions = {},
options: M3CssOptions = {},
): Promise<M3CssResult> {
const [light, dark] = await Promise.all([
m3CssResult({ ...scheme, sheet: "scheme", dark: false }, options),
m3CssResult({ ...scheme, sheet: "scheme", dark: true }, options),
]);
return {
css: m3SchemeStylesheet(rootDeclarations(light.css), rootDeclarations(dark.css)),
source: light.source,
};
}
/** Read the theme preference from the request cookies (Server Components, route handlers). */
export async function readM3ThemeMode(
cookieName: string = M3_THEME_COOKIE,
fallback: M3ThemeMode = "system",
): Promise<M3ThemeMode> {
const jar = await cookies();
return parseThemeMode(jar.get(cookieName)?.value, fallback);
}
export interface M3DocumentProps {
/** Resolved theme mode: `light` / `dark` set the class and `color-scheme`, `system` defers to the OS. */
mode: M3ThemeMode;
children?: ReactNode;
lang?: string;
className?: string;
bodyClassName?: string;
/** Inline scheme stylesheet (from {@link m3SchemeCss}); omitted when undefined. */
schemeCss?: string;
/** Inline `:root` rule from {@link m3TypefaceCss}; omitted when undefined or empty. */
typefaceCss?: string;
/** Cookie the pre-paint script reads in `system` mode (default `m3-theme`). */
cookieName?: string;
/** Extra `<head>` children. */
head?: ReactNode;
/** Extra attributes for `<html>`. */
htmlProps?: Omit<HTMLAttributes<HTMLHtmlElement>, "className" | "lang" | "style" | "children">;
}
/**
* The synchronous document shell: `<html>` with the mode class, `color-scheme` and
* `data-m3-theme`, the inline scheme sheet, the pre-paint script in `system` mode, `<body>`.
*/
export function M3Document({
mode,
children,
lang = "en",
className,
bodyClassName,
schemeCss,
typefaceCss,
cookieName,
head,
htmlProps,
}: M3DocumentProps) {
const classes = [className, mode === "system" ? undefined : mode].filter(Boolean).join(" ");
return (
<html
{...htmlProps}
lang={lang}
className={classes || undefined}
style={{ colorScheme: colorSchemeFor(mode) }}
data-m3-theme={mode}
// The pre-paint script and the client provider adjust the class and style on <html>.
suppressHydrationWarning
>
<head>
{schemeCss !== undefined ? (
<style id="m3-scheme" dangerouslySetInnerHTML={{ __html: schemeCss }} />
) : null}
{typefaceCss ? (
<style id="m3-typefaces" dangerouslySetInnerHTML={{ __html: typefaceCss }} />
) : null}
{mode === "system" ? (
<script
id="m3-color-scheme"
dangerouslySetInnerHTML={{ __html: colorSchemeScript(cookieName) }}
/>
) : null}
{head}
</head>
<body className={bodyClassName}>{children}</body>
</html>
);
}
export interface M3HtmlProps extends Omit<M3DocumentProps, "mode" | "schemeCss">, M3SchemeOptions {
/** Force a mode instead of reading the cookie. */
mode?: M3ThemeMode;
/** Mode when the cookie is absent or invalid (default `system`). */
defaultMode?: M3ThemeMode;
/**
* Read the theme cookie on the server (default `true`). `false` keeps the shell static, as Cache Components and a
* static export require: the pre-paint script and the client provider apply the saved preference in the browser.
* Always `false` when `NEXT_PUBLIC_M3_STATIC=1` (`withM3(config, { tauri: true })`).
*/
readCookie?: boolean;
/** Cookie holding the preference (default `m3-theme`). */
cookieName?: string;
/** Inline the scheme roles into `<head>` (default `true`). */
inlineScheme?: boolean;
/** Point the M3 reference typefaces at fonts the app loads (see {@link m3TypefaceCss}). */
typefaces?: M3Typefaces;
/** Options for the sheet renderer. */
css?: M3CssOptions;
}
async function resolveDocument(props: M3HtmlProps): Promise<M3DocumentProps> {
const {
mode,
defaultMode = "system",
readCookie = process.env.NEXT_PUBLIC_M3_STATIC !== "1",
cookieName = M3_THEME_COOKIE,
inlineScheme = true,
typefaces,
seed,
variant,
contrast,
css,
...document
} = props;
const resolved =
mode ?? (readCookie ? await readM3ThemeMode(cookieName, defaultMode) : defaultMode);
const schemeCss = inlineScheme
? (await m3SchemeCss({ seed, variant, contrast }, css)).css
: undefined;
const typefaceCss = typefaces ? m3TypefaceCss(typefaces) : undefined;
return { ...document, mode: resolved, schemeCss, typefaceCss, cookieName };
}
/**
* Root layout `<html>` for the App Router (async Server Component): reads the theme cookie,
* sets the dark / light class and `color-scheme`, and inlines the scheme's M3 colour roles so
* the first HTML byte is already themed. The static M3 sheets come from
* `@aphrody/m3-next/m3.css` in the app's global stylesheet.
*/
export async function M3Html(props: M3HtmlProps) {
return <M3Document {...await resolveDocument(props)} />;
}
/**
* {@link M3Html} plus the client theme provider: children can call `useM3Theme()` and the
* preference is written back to the cookie on every change.
*/
export async function M3Root(props: M3HtmlProps) {
const {
children,
seed,
variant,
contrast,
cookieName = M3_THEME_COOKIE,
inlineScheme = true,
} = props;
const document = await resolveDocument(props);
return (
<M3Document {...document}>
<M3NextThemeProvider
initialMode={document.mode}
seed={seed}
variant={variant}
contrast={contrast}
cookieName={cookieName}
schemeInlined={inlineScheme}
>
{children}
</M3NextThemeProvider>
</M3Document>
);
}
+58
View File
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: Apache-2.0
// proxy.ts for Next 16 (the file convention that replaces middleware.ts). The M3 part: make sure every request
// carries a valid theme cookie, so Server Components that read it (M3Html) and the client provider agree from the first
// byte. Import this entry only from `proxy.ts`: it needs `next/server` and nothing heavier (no colour engine).
//
// // proxy.ts
// import { createM3Proxy } from "@aphrody/m3-next/proxy";
// export const proxy = createM3Proxy();
// // `matcher` must be a literal (Next parses it at compile time, so it cannot be imported). Pages only:
// export const config = { matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"] };
import { NextResponse, type NextRequest } from "next/server";
import {
M3_THEME_COOKIE,
M3_THEME_COOKIE_MAX_AGE,
parseThemeMode,
validateThemeCookieName,
type M3ThemeMode,
} from "./constants";
export interface M3ProxyOptions {
/** Cookie that stores the preference (default `m3-theme`). */
cookieName?: string;
/** Mode written when the cookie is absent or invalid (default `system`). */
defaultMode?: M3ThemeMode;
}
/**
* The proxy function: a request with a valid theme cookie passes through untouched; a request without one (or with an
* invalid value) is forwarded with the default mode in its `cookie` header, so the server render of the same request sees
* it, and the response sets the cookie for the next one.
*/
export function createM3Proxy(
options: M3ProxyOptions = {},
): (request: NextRequest) => NextResponse {
const { cookieName = M3_THEME_COOKIE, defaultMode = "system" } = options;
validateThemeCookieName(cookieName);
return (request) => {
const current = request.cookies.get(cookieName)?.value;
const mode = parseThemeMode(current, defaultMode);
if (current === mode) return NextResponse.next();
const headers = new Headers(request.headers);
const others = (headers.get("cookie") ?? "")
.split(/;\s*/)
.filter((cookie) => cookie !== "" && !cookie.startsWith(`${cookieName}=`));
headers.set("cookie", [...others, `${cookieName}=${mode}`].join("; "));
const response = NextResponse.next({ request: { headers } });
response.cookies.set({
name: cookieName,
value: mode,
path: "/",
maxAge: M3_THEME_COOKIE_MAX_AGE,
sameSite: "lax",
});
return response;
};
}
+207
View File
@@ -0,0 +1,207 @@
// SPDX-License-Identifier: Apache-2.0
// The light client provider: mode, cookie bridge, <html> classes, and the on-demand colour engine.
import { afterEach, describe, expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import {
M3NextThemeProvider,
M3ThemeProvider,
useM3Theme,
useM3ThemeToggle,
type M3ThemeContextType,
} from "../src/client";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
// happy-dom stores cookies only for http(s) documents.
(window as unknown as { happyDOM: { setURL(url: string): void } }).happyDOM.setURL(
"http://localhost/",
);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
let root: ReturnType<typeof createRoot> | undefined;
let theme: M3ThemeContextType;
let toggle: ReturnType<typeof useM3ThemeToggle>;
function Probe() {
// oxlint-disable-next-line react/globals -- test probe: publishes the live hook values to the test-scoped variables the assertions read
theme = useM3Theme();
// oxlint-disable-next-line react/globals -- same test probe capture as the theme line above
toggle = useM3ThemeToggle();
return (
<span id="probe">
{theme.themeMode}:{theme.resolvedTheme}
</span>
);
}
async function mount(node: React.ReactNode) {
const container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(node);
});
await act(async () => {
await sleep(20);
});
}
const html = document.documentElement;
const clearCookie = () => {
document.cookie = "m3-theme=; path=/; max-age=0";
document.cookie = "app-theme=; path=/; max-age=0";
document.cookie = "app.theme=; path=/; max-age=0";
};
afterEach(async () => {
await act(async () => {
root?.unmount();
});
root = undefined;
document.body.innerHTML = "";
html.className = "";
html.removeAttribute("style");
html.removeAttribute("data-m3-theme");
clearCookie();
});
describe("M3ThemeProvider", () => {
test("renders on the server without touching the DOM", () => {
expect(
renderToString(
<M3NextThemeProvider initialMode="dark">
<b>child</b>
</M3NextThemeProvider>,
),
).toBe("<b>child</b>");
});
test("useM3Theme outside the provider throws a clear error", () => {
expect(() => renderToString(<Probe />)).toThrow(
"useM3Theme must be used within an M3ThemeProvider",
);
});
test("a server-rendered mode is kept, the class and cookie follow", async () => {
await mount(
<M3NextThemeProvider initialMode="dark">
<Probe />
</M3NextThemeProvider>,
);
expect(theme.themeMode).toBe("dark");
expect(theme.resolvedTheme).toBe("dark");
expect(html.classList.contains("dark")).toBe(true);
expect(html.classList.contains("light")).toBe(false);
expect(html.dataset["m3Theme"]).toBe("dark");
expect(html.style.colorScheme).toBe("dark");
expect(document.cookie).toContain("m3-theme=dark");
});
test("a static shell adopts the saved preference after hydration, without overwriting it", async () => {
document.cookie = "m3-theme=light; path=/";
html.classList.add("light");
await mount(
<M3NextThemeProvider initialMode="system">
<Probe />
</M3NextThemeProvider>,
);
expect(theme.themeMode).toBe("light");
expect(document.cookie).toContain("m3-theme=light");
expect(html.classList.contains("light")).toBe(true);
expect(html.classList.contains("dark")).toBe(false);
});
test("setThemeMode writes the cookie and flips the classes; toggle cycles light, dark, system", async () => {
await mount(
<M3ThemeProvider defaultThemeMode="light">
<Probe />
</M3ThemeProvider>,
);
expect(html.classList.contains("light")).toBe(true);
await act(async () => theme.setThemeMode("dark"));
expect(html.classList.contains("dark")).toBe(true);
expect(html.classList.contains("light")).toBe(false);
expect(document.cookie).toContain("m3-theme=dark");
await act(async () => toggle.toggle());
expect(toggle.mode).toBe("system");
await act(async () => toggle.toggle());
expect(toggle.mode).toBe("light");
expect(document.cookie).toContain("m3-theme=light");
});
test("a custom cookie name is used for reading and writing", async () => {
document.cookie = "app-theme=dark; path=/";
await mount(
<M3ThemeProvider cookieName="app-theme">
<Probe />
</M3ThemeProvider>,
);
expect(theme.themeMode).toBe("dark");
expect(document.cookie).toContain("app-theme=dark");
expect(document.cookie).not.toContain("m3-theme=");
});
test("partial cookie values are ignored and punctuation preserves cookie identity", async () => {
document.cookie = "m3-theme=darkened; path=/";
await mount(
<M3ThemeProvider defaultThemeMode="light">
<Probe />
</M3ThemeProvider>,
);
expect(theme.themeMode).toBe("light");
await act(async () => root?.unmount());
root = undefined;
clearCookie();
document.cookie = "app.theme=dark; path=/";
await mount(
<M3ThemeProvider cookieName="app.theme">
<Probe />
</M3ThemeProvider>,
);
expect(theme.themeMode).toBe("dark");
expect(document.cookie).toContain("app.theme=dark");
expect(document.cookie).not.toContain("apptheme=");
});
test("the inlined scheme costs nothing: no inline roles, no colour engine", async () => {
await mount(
<M3NextThemeProvider initialMode="light" seed="#0b57d0">
<Probe />
</M3NextThemeProvider>,
);
expect(html.style.getPropertyValue("--md-sys-color-primary")).toBe("");
});
test("a new seed loads the engine on demand and sets the roles; the inlined seed clears them again", async () => {
await mount(
<M3ThemeProvider defaultThemeMode="light" defaultSeedColor="#6750a4">
<Probe />
</M3ThemeProvider>,
);
await act(async () => theme.setSeedColor("#b3261e"));
await act(async () => {
await sleep(50);
});
const custom = html.style.getPropertyValue("--md-sys-color-primary");
expect(custom).toMatch(/^#[0-9a-f]{6}$/i);
await act(async () => theme.setSeedColor("#6750a4"));
await act(async () => {
await sleep(50);
});
expect(html.style.getPropertyValue("--md-sys-color-primary")).toBe("");
});
test("schemeInlined={false} computes the roles on mount", async () => {
await mount(
<M3ThemeProvider defaultThemeMode="dark" defaultSeedColor="#0b57d0" schemeInlined={false}>
<Probe />
</M3ThemeProvider>,
);
await act(async () => {
await sleep(50);
});
expect(html.style.getPropertyValue("--md-sys-color-primary")).toMatch(/^#[0-9a-f]{6}$/i);
});
});
+207
View File
@@ -0,0 +1,207 @@
// SPDX-License-Identifier: Apache-2.0
// withM3 for Next 16: React Compiler, Cache Components, typed routes, Turbopack and the Tauri static export.
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
M3_OPTIMIZE_PACKAGE_IMPORTS,
M3_TRANSPILE_PACKAGES,
colorSchemeScript,
isTauriBuild,
m3TypefaceCss,
withM3,
} from "../src/index";
describe("withM3 options", () => {
test("transpiles and optimises the Base UI layer", () => {
const config = withM3({});
expect(config.transpilePackages).toContain("@aphrody/m3-baseui");
expect(config.experimental?.optimizePackageImports).toEqual(
expect.arrayContaining(["@aphrody/m3-baseui", "@base-ui/react"]),
);
expect(M3_TRANSPILE_PACKAGES).toContain("@aphrody/m3-baseui");
expect(M3_OPTIMIZE_PACKAGE_IMPORTS).toContain("@base-ui/react");
});
test("React Compiler, Cache Components and typed routes are Next 16 top-level keys", () => {
const config = withM3({}, { reactCompiler: true, cacheComponents: true, typedRoutes: true });
expect(config.reactCompiler).toBe(true);
expect(config.cacheComponents).toBe(true);
expect(config.typedRoutes).toBe(true);
expect(withM3({}).reactCompiler).toBeUndefined();
expect(withM3({}).cacheComponents).toBeUndefined();
});
test("an option never overrides a key the config sets", () => {
const config = withM3(
{ reactCompiler: false, typedRoutes: false, cacheComponents: false },
{ reactCompiler: true, typedRoutes: true, cacheComponents: true },
);
expect(config.reactCompiler).toBe(false);
expect(config.typedRoutes).toBe(false);
expect(config.cacheComponents).toBe(false);
});
});
describe("Tauri static export", () => {
test("`tauri: true` makes an export a webview can load", () => {
const config = withM3({}, { tauri: true, cacheComponents: true });
expect(config.output).toBe("export");
expect(config.trailingSlash).toBe(true);
expect(config.images).toEqual({ unoptimized: true });
expect(config.cacheComponents).toBe(false);
expect(config.env).toEqual({ NEXT_PUBLIC_M3_STATIC: "1" });
});
test("user values win", () => {
const config = withM3(
{
output: "standalone",
trailingSlash: false,
images: { unoptimized: true, formats: ["image/avif"] },
env: { A: "1" },
},
{ tauri: true },
);
expect(config.output).toBe("standalone");
expect(config.trailingSlash).toBe(false);
expect(config.images).toEqual({ unoptimized: true, formats: ["image/avif"] });
expect(config.env).toEqual({ NEXT_PUBLIC_M3_STATIC: "1", A: "1" });
});
test("`tauri: false` and no option leave the config a server config", () => {
for (const config of [withM3({}), withM3({}, { tauri: false })]) {
expect(config.output).toBeUndefined();
expect(config.env).toBeUndefined();
}
});
test("`auto` follows the Tauri CLI environment", () => {
expect(isTauriBuild({})).toBe(false);
expect(isTauriBuild({ TAURI_ENV_PLATFORM: "linux" })).toBe(true);
const before = process.env["TAURI_ENV_PLATFORM"];
try {
delete process.env["TAURI_ENV_PLATFORM"];
delete process.env["TAURI_ENV_ARCH"];
expect(withM3({}, { tauri: "auto" }).output).toBeUndefined();
process.env["TAURI_ENV_PLATFORM"] = "linux";
expect(withM3({}, { tauri: "auto" }).output).toBe("export");
} finally {
if (before === undefined) delete process.env["TAURI_ENV_PLATFORM"];
else process.env["TAURI_ENV_PLATFORM"] = before;
}
});
test("the dev asset prefix is set only for a dev host, and never in production", () => {
const env = process.env as Record<string, string | undefined>;
const saved = { host: env["TAURI_DEV_HOST"], node: env["NODE_ENV"] };
try {
delete env["TAURI_DEV_HOST"];
env["NODE_ENV"] = "development";
expect(withM3({}, { tauri: true }).assetPrefix).toBeUndefined();
expect(withM3({}, { tauri: { devHost: "192.168.1.5", devPort: 3100 } }).assetPrefix).toBe(
"http://192.168.1.5:3100",
);
env["TAURI_DEV_HOST"] = "10.0.0.2";
expect(withM3({}, { tauri: true }).assetPrefix).toBe("http://10.0.0.2:3000");
env["NODE_ENV"] = "production";
expect(withM3({}, { tauri: true }).assetPrefix).toBeUndefined();
} finally {
for (const [key, value] of [
["TAURI_DEV_HOST", saved.host],
["NODE_ENV", saved.node],
] as const) {
if (value === undefined) delete env[key];
else env[key] = value;
}
}
});
});
/** Run the script against a minimal document and report the classes and color-scheme it leaves. */
function run(cookie: string, osDark: boolean, initial: string[] = [], name?: string) {
const classes = new Set(initial);
const root = {
classList: {
add: (c: string) => classes.add(c),
remove: (c: string) => classes.delete(c),
toggle: (c: string, on?: boolean) => {
if (on ?? !classes.has(c)) classes.add(c);
else classes.delete(c);
},
},
style: { colorScheme: "" },
};
const script = colorSchemeScript(name);
new Function("document", "window", script)(
{ documentElement: root, cookie },
{ matchMedia: () => ({ matches: osDark }) },
);
// oxlint-disable-next-line unicorn/no-array-sort -- a copy: ES2022 has no toSorted
return { classes: [...classes].sort(), colorScheme: root.style.colorScheme };
}
describe("the pre-paint script", () => {
test("no cookie means system: the OS preference decides", () => {
expect(run("", true)).toEqual({ classes: ["dark"], colorScheme: "dark" });
expect(run("", false, ["dark"])).toEqual({ classes: [], colorScheme: "light" });
expect(run("other=1", true)).toEqual({ classes: ["dark"], colorScheme: "dark" });
});
test("a saved light or dark preference wins over the OS", () => {
expect(run("m3-theme=light", true)).toEqual({ classes: ["light"], colorScheme: "light" });
expect(run("a=b; m3-theme=dark", false)).toEqual({ classes: ["dark"], colorScheme: "dark" });
expect(run("m3-theme=system; x=y", true)).toEqual({ classes: ["dark"], colorScheme: "dark" });
});
test("system clears a stale .light", () => {
expect(run("m3-theme=system", false, ["light"])).toEqual({ classes: [], colorScheme: "light" });
});
test("an invalid value is ignored, a custom cookie name is honoured", () => {
expect(run("m3-theme=purple", true)).toEqual({ classes: ["dark"], colorScheme: "dark" });
expect(run("app-theme=light", true, [], "app-theme")).toEqual({
classes: ["light"],
colorScheme: "light",
});
expect(() => colorSchemeScript('we"ird;name')).toThrow("HTTP cookie token");
});
});
describe("typefaces", () => {
test("map the M3 reference typefaces onto the fonts an app loads", () => {
expect(m3TypefaceCss({})).toBe("");
expect(
m3TypefaceCss({ brand: "var(--font-roboto), sans-serif", code: "var(--font-mono)" }),
).toBe(
":root {\n --md-ref-typeface-brand: var(--font-roboto), sans-serif;\n --md-ref-typeface-code: var(--font-mono);\n}",
);
});
test("a value cannot escape the declaration", () => {
expect(m3TypefaceCss({ plain: "Inter; } body { display: none" })).not.toMatch(/[;}]\s*body/);
expect(m3TypefaceCss({ plain: "</style><script>" })).not.toContain("<");
});
});
describe("m3.css", () => {
const css = readFileSync(join(import.meta.dir, "..", "m3.css"), "utf8").replace(
/\/\*[\s\S]*?\*\//g,
"",
);
test("imports tokens, expressive tokens, the colour sheets and the Tailwind preset, in that order", () => {
const imports = [...css.matchAll(/@import "([^"]+)";/g)].map((m) => m[1]);
expect(imports).toEqual([
"@aphrody/m3-tokens/m3-tokens.css",
"@aphrody/m3-tokens/expressive.css",
"@aphrody/m3-theme/tokens.css",
"@aphrody/m3-theme/tailwind.css",
"@aphrody/m3-tailwind/preset.css",
]);
});
test("leaves the dark variant to the preset", () => {
expect(css).not.toContain("@custom-variant");
});
});
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, test } from "bun:test";
import { runInNewContext } from "node:vm";
import { colorSchemeScript, themeCookie } from "../src/index";
function modeFor(cookie: string, cookieName?: string): string {
const root = { classList: { remove() {}, toggle() {} }, style: { colorScheme: "" } };
runInNewContext(colorSchemeScript(cookieName), {
document: { cookie, documentElement: root },
window: { matchMedia: () => ({ matches: false }) },
});
return root.style.colorScheme;
}
describe("theme cookie identity", () => {
test("only a complete theme value is accepted before paint", () => {
expect(modeFor("m3-theme=darkened")).toBe("light");
expect(modeFor("other=x;m3-theme=dark")).toBe("dark");
expect(modeFor("m3-theme=dark%20")).toBe("light");
});
test("valid punctuation stays part of the cookie name", () => {
expect(modeFor("app.theme=dark", "app.theme")).toBe("dark");
expect(modeFor("apptheme=dark", "app.theme")).toBe("light");
expect(themeCookie("dark", "app.theme")).toStartWith("app.theme=dark;");
});
test("cookie delimiters and script markup cannot become a cookie name", () => {
for (const name of ["", "theme;path=/", "theme=dark", "</script>", "with space"]) {
expect(() => themeCookie("dark", name)).toThrow("HTTP cookie token");
expect(() => colorSchemeScript(name)).toThrow("HTTP cookie token");
}
});
});
+135
View File
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, test } from "bun:test";
import {
M3_SITEMAP_MAX_URLS,
m3AbsoluteUrl,
m3JsonLd,
m3LlmsTxt,
m3RobotsTxt,
m3SitemapXml,
} from "../src/crawl";
const origin = "https://example.com";
describe("m3SitemapXml", () => {
test("resolves, de-duplicates, sorts and escapes", () => {
const xml = m3SitemapXml(
[
"/b",
"/a?x=1&y=2",
{ path: "/b", lastmod: "2026-10-01", changefreq: "weekly", priority: 0.8 },
"https://example.com/c#frag",
],
origin,
);
expect(
xml.startsWith(
'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
),
).toBe(true);
const locs = [...xml.matchAll(/<loc>([^<]*)<\/loc>/g)].map((m) => m[1]);
expect(locs).toEqual([
"https://example.com/a?x=1&amp;y=2",
"https://example.com/b",
"https://example.com/c",
]);
expect(xml).toContain("<lastmod>2026-10-01</lastmod>");
expect(xml).toContain("<priority>0.8</priority>");
expect(m3SitemapXml(["/z", "/a"], origin)).toBe(m3SitemapXml(["/a", "/z"], origin));
});
test("rejects foreign origins, bad dates, bad priorities and oversized lists", () => {
expect(() => m3SitemapXml(["https://other.com/x"], origin)).toThrow(RangeError);
expect(() => m3SitemapXml([{ path: "/a", lastmod: "yesterday" }], origin)).toThrow(RangeError);
expect(() => m3SitemapXml([{ path: "/a", priority: 2 }], origin)).toThrow(RangeError);
expect(() =>
m3SitemapXml(
Array.from({ length: M3_SITEMAP_MAX_URLS + 1 }, (_, i) => `/p${i}`),
origin,
),
).toThrow(RangeError);
expect(() => m3AbsoluteUrl("/a", "ftp://x")).toThrow(TypeError);
});
test("an empty route list is still a valid urlset", () => {
expect(m3SitemapXml([], origin)).toContain("<urlset");
});
});
describe("m3RobotsTxt", () => {
test("defaults allow everything and reference the sitemap", () => {
expect(m3RobotsTxt({ origin })).toBe(
"User-agent: *\nAllow: /\n\nSitemap: https://example.com/sitemap.xml\n",
);
});
test("groups, crawl delay and custom sitemaps", () => {
const txt = m3RobotsTxt({
origin,
rules: [
{ userAgent: "*", disallow: ["/private/"] },
{ userAgent: ["GPTBot", "ClaudeBot"], disallow: ["/"], crawlDelay: 10 },
],
sitemaps: ["/sitemap.xml", "https://example.com/news.xml"],
});
expect(txt).toContain("User-agent: *\nDisallow: /private/\n");
expect(txt).toContain(
"User-agent: GPTBot\nUser-agent: ClaudeBot\nDisallow: /\nCrawl-delay: 10",
);
expect(txt.trimEnd().endsWith("Sitemap: https://example.com/news.xml")).toBe(true);
expect(m3RobotsTxt({ origin, sitemaps: [] })).not.toContain("Sitemap:");
});
test("rejects header injection", () => {
expect(() =>
m3RobotsTxt({ origin, rules: [{ userAgent: "*", disallow: ["/a\nAllow: /"] }] }),
).toThrow(TypeError);
expect(() => m3RobotsTxt({ origin, rules: [{ userAgent: [] }] })).toThrow(TypeError);
});
});
describe("m3LlmsTxt (llmstxt.org)", () => {
test("H1, blockquote summary, details, sections and Optional last", () => {
const txt = m3LlmsTxt({
origin,
name: "Example",
summary: "One line summary.",
details: ["First paragraph.", "Second paragraph."],
sections: [
{ title: "Extras", links: [{ title: "Changelog", path: "/changelog" }], optional: true },
{
title: "Docs",
links: [
{ title: "Guide [v2]", path: "/docs/guide", notes: "Start here" },
{ title: "API", path: "https://api.example.org/ref" },
],
},
],
});
expect(txt).toBe(
[
"# Example",
"> One line summary.",
"First paragraph.",
"Second paragraph.",
"## Docs\n\n- [Guide \\[v2\\]](https://example.com/docs/guide): Start here\n- [API](https://api.example.org/ref)",
"## Optional\n\n- [Changelog](https://example.com/changelog)",
].join("\n\n") + "\n",
);
});
test("requires a name and forbids headings in details", () => {
expect(() => m3LlmsTxt({ origin, name: " " })).toThrow(TypeError);
expect(() => m3LlmsTxt({ origin, name: "X", details: "# not allowed" })).toThrow(TypeError);
expect(m3LlmsTxt({ origin, name: "X" })).toBe("# X\n");
});
});
describe("m3JsonLd", () => {
test("escapes markup so the payload cannot close the script tag", () => {
const json = m3JsonLd({ name: "</script><b>&", sep: "\u2028" });
expect(json).not.toMatch(/[<>&\u2028]/);
expect(JSON.parse(json)).toEqual({ name: "</script><b>&", sep: "\u2028" });
expect(() => m3JsonLd(undefined)).toThrow(TypeError);
});
});
+184
View File
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, test } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";
import { renderToString } from "react-dom/server";
import { schemeFromSeed } from "@aphrody/m3-tokens/dynamic-color";
import {
M3_COLOR_SCHEME_SCRIPT,
M3_SERVER_EXTERNAL_PACKAGES,
M3_TRANSPILE_PACKAGES,
colorSchemeFor,
generateM3Metadata,
generateM3Viewport,
m3SchemeStylesheet,
m3ThemeColors,
parseThemeMode,
rootDeclarations,
themeCookie,
withM3,
} from "../src/index";
import {
M3Document,
isBunRuntime,
m3Css,
m3CssResult,
m3FfiError,
m3SchemeCss,
} from "../src/server";
const pkgDir = join(import.meta.dir, "..");
const require = createRequire(join(pkgDir, "package.json"));
const nativeLib = ["bun_rs.dll", "libbun_rs.so", "libbun_rs.dylib"].some((name) =>
existsSync(join(pkgDir, "../../target/release", name)),
);
describe("withM3", () => {
test("adds the M3 packages, keeps user values and strict mode", () => {
const config = withM3({
transpilePackages: ["my-lib"],
reactStrictMode: false,
poweredByHeader: false,
});
expect(config.transpilePackages).toContain("my-lib");
for (const name of M3_TRANSPILE_PACKAGES) expect(config.transpilePackages).toContain(name);
expect(config.reactStrictMode).toBe(false);
expect(config.poweredByHeader).toBe(false);
expect(config.serverExternalPackages).toEqual([...M3_SERVER_EXTERNAL_PACKAGES]);
expect(config.experimental?.optimizePackageImports).toContain("@aphrody/m3-primitives");
});
test("defaults strict mode on, deduplicates and never lists a package twice", () => {
const config = withM3({
transpilePackages: ["@aphrody/m3-theme"],
serverExternalPackages: ["@aphrody/m3-ai", "sharp"],
});
expect(config.reactStrictMode).toBe(true);
expect(config.transpilePackages!.filter((name) => name === "@aphrody/m3-theme")).toHaveLength(
1,
);
// A transpiled package can never be external at the same time (Next rejects the overlap).
expect(config.serverExternalPackages).toEqual(["sharp", "@aphrody/bun"]);
});
test("merges Turbopack options and the optional root", () => {
const config = withM3(
{ turbopack: { resolveExtensions: [".tsx", ".ts", ".js"] } },
{ turbopackRoot: "/repo" },
);
expect(config.turbopack).toEqual({ resolveExtensions: [".tsx", ".ts", ".js"], root: "/repo" });
expect(withM3({}).turbopack).toBeUndefined();
expect(withM3({}, { optimizePackageImports: false }).experimental).toBeUndefined();
});
});
describe("theme mode", () => {
test("parses cookie values", () => {
expect(parseThemeMode("dark")).toBe("dark");
expect(parseThemeMode("light")).toBe("light");
expect(parseThemeMode("bogus")).toBe("system");
expect(parseThemeMode(undefined, "dark")).toBe("dark");
expect(colorSchemeFor("system")).toBe("light dark");
expect(themeCookie("dark")).toBe("m3-theme=dark; path=/; max-age=31536000; samesite=lax");
});
});
describe("metadata and viewport", () => {
test("theme-color is the scheme surface in both modes", () => {
const colors = m3ThemeColors({ seed: "#0b57d0" });
expect(colors.light).toBe(schemeFromSeed("#0b57d0")["--md-sys-color-surface"]!);
expect(colors.dark).toBe(schemeFromSeed("#0b57d0", { dark: true })["--md-sys-color-surface"]!);
const viewport = generateM3Viewport({ seed: "#0b57d0" });
expect(viewport.colorScheme).toBe("light dark");
expect(viewport.themeColor).toEqual([
{ media: "(prefers-color-scheme: light)", color: colors.light },
{ media: "(prefers-color-scheme: dark)", color: colors.dark },
]);
expect(generateM3Viewport({ mode: "dark" }).themeColor).toBe(m3ThemeColors().dark);
});
test("metadata has a title template and merges overrides", () => {
const metadata = generateM3Metadata({
name: "M3",
description: "d",
url: "https://example.test",
metadata: { robots: "noindex" },
});
expect(metadata.title).toEqual({ default: "M3", template: "%s · M3" });
expect(String(metadata.metadataBase)).toBe("https://example.test/");
expect(metadata.robots).toBe("noindex");
});
});
describe("m3Css", () => {
test("static sheets match the files of @aphrody/m3-theme", async () => {
const result = await m3CssResult({ sheet: "tailwind" }, { noFfi: true });
expect(result.source).toBe("static");
expect(result.css).toBe(
readFileSync(require.resolve("@aphrody/m3-theme/tailwind.css"), "utf8"),
);
expect(await m3Css({ sheet: "tokens" }, { noFfi: true })).toContain("--md-sys-color-primary:");
});
test("the JS scheme fallback equals material-color-utilities", async () => {
const result = await m3CssResult(
{ sheet: "scheme", seed: "#b3261e", dark: true },
{ noFfi: true },
);
expect(result.source).toBe("js");
expect(rootDeclarations(result.css)).toEqual(schemeFromSeed("#b3261e", { dark: true }));
});
test.skipIf(!isBunRuntime() || !nativeLib)(
"under Bun the Rust renderer agrees with the JS scheme",
async () => {
const result = await m3CssResult({ sheet: "scheme", seed: "#b3261e" });
expect(m3FfiError()).toBeUndefined();
expect(result.source).toBe("ffi");
const rust = rootDeclarations(result.css);
for (const [role, value] of Object.entries(schemeFromSeed("#b3261e")))
expect(rust[role]).toBe(value);
const tokens = await m3CssResult({ sheet: "tokens" });
expect(tokens.source).toBe("ffi");
expect(tokens.css).toContain("--md-sys-color-primary:");
},
);
test("the inline scheme sheet wires both modes to the classes and the media query", async () => {
const { css } = await m3SchemeCss({ seed: "#6750a4" }, { noFfi: true });
expect(css).toContain(":root {");
expect(css).toContain("@media (prefers-color-scheme: dark) {\n :root:not(.light) {");
expect(css).toContain(":root.dark {");
expect(css).toContain(":root.light {");
expect(css.match(/--md-sys-color-primary:/g)).toHaveLength(4);
expect(m3SchemeStylesheet({ "--a": "#000" }, { "--a": "#fff" })).toContain(
"color-scheme: dark;\n --a: #fff;",
);
});
});
describe("M3Document", () => {
test("a forced mode sets the class and color-scheme, without the pre-paint script", () => {
const html = renderToString(
<M3Document mode="dark" className="font-plain" schemeCss=":root{--x:1}">
hi
</M3Document>,
);
expect(html).toContain('class="font-plain dark"');
expect(html).toContain("color-scheme:dark");
expect(html).toContain('data-m3-theme="dark"');
expect(html).toContain('<style id="m3-scheme">:root{--x:1}</style>');
expect(html).not.toContain("m3-color-scheme");
expect(html).toContain("<body>hi</body>");
});
test("system mode defers to the OS and resolves the class before paint", () => {
const html = renderToString(<M3Document mode="system" lang="fr" />);
expect(html).toContain('lang="fr"');
expect(html).toContain("color-scheme:light dark");
expect(html).not.toContain("class=");
expect(html).toContain('<script id="m3-color-scheme">');
expect(html).toContain(M3_COLOR_SCHEME_SCRIPT);
});
});
+48
View File
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, test } from "bun:test";
import { NextRequest } from "next/server";
import { createM3Proxy } from "../src/theme-proxy";
const request = (cookie?: string) =>
new NextRequest("http://localhost/docs", cookie === undefined ? {} : { headers: { cookie } });
describe("createM3Proxy", () => {
const proxy = createM3Proxy();
test("a request with a valid theme cookie passes through untouched", () => {
const response = proxy(request("m3-theme=dark"));
expect(response.headers.get("x-middleware-next")).toBe("1");
expect(response.cookies.get("m3-theme")).toBeUndefined();
expect(response.headers.get("x-middleware-request-cookie")).toBeNull();
});
test("a request without the cookie is forwarded with the default and sets it for the next one", () => {
const response = proxy(request("a=1; b=2"));
expect(response.headers.get("x-middleware-request-cookie")).toBe("a=1; b=2; m3-theme=system");
const set = response.cookies.get("m3-theme");
expect(set?.value).toBe("system");
expect(set?.path).toBe("/");
expect(set?.maxAge).toBe(31536000);
expect(set?.sameSite).toBe("lax");
});
test("an invalid value is replaced, not appended", () => {
const response = proxy(request("m3-theme=purple; a=1"));
expect(response.headers.get("x-middleware-request-cookie")).toBe("a=1; m3-theme=system");
expect(response.cookies.get("m3-theme")?.value).toBe("system");
});
test("no cookie header at all", () => {
const response = proxy(request());
expect(response.headers.get("x-middleware-request-cookie")).toBe("m3-theme=system");
});
test("a custom cookie name and default mode", () => {
const custom = createM3Proxy({ cookieName: "app-theme", defaultMode: "light" });
const response = custom(request("m3-theme=dark"));
expect(response.headers.get("x-middleware-request-cookie")).toBe(
"m3-theme=dark; app-theme=light",
);
expect(response.cookies.get("app-theme")?.value).toBe("light");
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": ["./tsconfig.json", "@aphrody/m3-config/tsconfig.build.json"],
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "@aphrody/m3-config/tsconfig.json",
"compilerOptions": {
"types": ["bun"],
"paths": {
"@aphrody/m3-next/client": ["./src/client.tsx"]
}
},
"include": ["src/**/*", "test/**/*", "test-dom/**/*", "bin/**/*", "scripts/**/*"]
}