mirror of
https://github.com/aphrody-labs/aphrody-m3-next.git
synced 2026-10-13 19:55:43 +00:00
chore: migrate package sources from Aphrody
This commit is contained in:
1 parent
0238953800
commit
208651249c
53 files changed
+4072
-2
No files matched your search
@@ -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>`).
|
||||
@@ -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)));
|
||||
@@ -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:*"],
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
.next/
|
||||
out/
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,4 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
export function GET() {
|
||||
return Response.json({ ok: true, runtime: process.versions.bun ? "bun" : "node" });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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(), "../../../.."),
|
||||
},
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { "@tailwindcss/postcss": {} } };
|
||||
@@ -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|.*\\..*).*)"] };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.next/
|
||||
out/
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(), "../../../.."),
|
||||
},
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { "@tailwindcss/postcss": {} } };
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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&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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": ["./tsconfig.json", "@aphrody/m3-config/tsconfig.build.json"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
Reference in new issue
Block a user