From 208651249cb801932708e9981b73ad55c84f330d Mon Sep 17 00:00:00 2001 From: aphrody-dev Date: Mon, 12 Oct 2026 08:23:52 +0200 Subject: [PATCH] chore: migrate package sources from Aphrody --- README.md | 125 +++++++- bin/m3-next.ts | 24 ++ build.config.ts | 14 + examples/app/.gitignore | 4 + examples/app/AGENTS.md | 9 + examples/app/CLAUDE.md | 1 + examples/app/README.md | 23 ++ examples/app/app/api/health/route.ts | 4 + examples/app/app/components/notifier.tsx | 46 +++ examples/app/app/components/page.tsx | 224 ++++++++++++++ examples/app/app/globals.css | 9 + examples/app/app/layout.tsx | 40 +++ examples/app/app/nav.tsx | 32 ++ examples/app/app/page.tsx | 44 +++ examples/app/app/theme-toggle.tsx | 14 + examples/app/app/theme/page.tsx | 27 ++ examples/app/next.config.ts | 15 + examples/app/package.json | 39 +++ examples/app/postcss.config.mjs | 1 + examples/app/proxy.ts | 8 + examples/app/test/example.test.ts | 57 ++++ examples/app/tsconfig.json | 34 +++ examples/tauri/.gitignore | 4 + examples/tauri/README.md | 14 + examples/tauri/app/about/page.tsx | 13 + examples/tauri/app/globals.css | 11 + examples/tauri/app/layout.tsx | 12 + examples/tauri/app/page.tsx | 20 ++ examples/tauri/app/theme-toggle.tsx | 13 + examples/tauri/next.config.ts | 16 + examples/tauri/package.json | 37 +++ examples/tauri/postcss.config.mjs | 1 + examples/tauri/test/example.test.ts | 41 +++ examples/tauri/tsconfig.json | 34 +++ m3.css | 23 ++ package.json | 94 ++++++ scripts/browser-check.ts | 173 +++++++++++ scripts/bundle-report.ts | 347 +++++++++++++++++++++ scripts/yolo-example.ts | 331 +++++++++++++++++++++ src/client.tsx | 240 +++++++++++++++ src/constants.ts | 33 ++ src/crawl.ts | 237 +++++++++++++++ src/index.ts | 333 +++++++++++++++++++++ src/server.tsx | 364 +++++++++++++++++++++++ src/theme-proxy.ts | 58 ++++ test-dom/client.test.tsx | 207 +++++++++++++ test/config.test.ts | 207 +++++++++++++ test/cookie-contract.test.ts | 32 ++ test/crawl.test.ts | 135 +++++++++ test/m3-next.test.tsx | 184 ++++++++++++ test/theme-proxy.test.ts | 48 +++ tsconfig.build.json | 8 + tsconfig.json | 10 + 53 files changed, 4072 insertions(+), 2 deletions(-) create mode 100644 bin/m3-next.ts create mode 100644 build.config.ts create mode 100644 examples/app/.gitignore create mode 100644 examples/app/AGENTS.md create mode 100644 examples/app/CLAUDE.md create mode 100644 examples/app/README.md create mode 100644 examples/app/app/api/health/route.ts create mode 100644 examples/app/app/components/notifier.tsx create mode 100644 examples/app/app/components/page.tsx create mode 100644 examples/app/app/globals.css create mode 100644 examples/app/app/layout.tsx create mode 100644 examples/app/app/nav.tsx create mode 100644 examples/app/app/page.tsx create mode 100644 examples/app/app/theme-toggle.tsx create mode 100644 examples/app/app/theme/page.tsx create mode 100644 examples/app/next.config.ts create mode 100644 examples/app/package.json create mode 100644 examples/app/postcss.config.mjs create mode 100644 examples/app/proxy.ts create mode 100644 examples/app/test/example.test.ts create mode 100644 examples/app/tsconfig.json create mode 100644 examples/tauri/.gitignore create mode 100644 examples/tauri/README.md create mode 100644 examples/tauri/app/about/page.tsx create mode 100644 examples/tauri/app/globals.css create mode 100644 examples/tauri/app/layout.tsx create mode 100644 examples/tauri/app/page.tsx create mode 100644 examples/tauri/app/theme-toggle.tsx create mode 100644 examples/tauri/next.config.ts create mode 100644 examples/tauri/package.json create mode 100644 examples/tauri/postcss.config.mjs create mode 100644 examples/tauri/test/example.test.ts create mode 100644 examples/tauri/tsconfig.json create mode 100644 m3.css create mode 100644 package.json create mode 100644 scripts/browser-check.ts create mode 100644 scripts/bundle-report.ts create mode 100644 scripts/yolo-example.ts create mode 100644 src/client.tsx create mode 100644 src/constants.ts create mode 100644 src/crawl.ts create mode 100644 src/index.ts create mode 100644 src/server.tsx create mode 100644 src/theme-proxy.ts create mode 100644 test-dom/client.test.tsx create mode 100644 test/config.test.ts create mode 100644 test/cookie-contract.test.ts create mode 100644 test/crawl.test.ts create mode 100644 test/m3-next.test.tsx create mode 100644 test/theme-proxy.test.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/README.md b/README.md index 953cd86..ada9bbf 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,123 @@ -# aphrody-m3-next -Aphrody package @aphrody/m3-next + + +# `@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 {children}; +} +``` + +```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 `` 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 ``; `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", +}); +; +``` + +`@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 `). diff --git a/bin/m3-next.ts b/bin/m3-next.ts new file mode 100644 index 0000000..5d24859 --- /dev/null +++ b/bin/m3-next.ts @@ -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 ` (`yolo m3 next `): runs the app's Next and resolves to its exit status. */ +export async function runM3Next(argv: string[]): Promise { + if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") { + console.log( + "usage: m3-next [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))); diff --git a/build.config.ts b/build.config.ts new file mode 100644 index 0000000..470d0ba --- /dev/null +++ b/build.config.ts @@ -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:*"], +}); diff --git a/examples/app/.gitignore b/examples/app/.gitignore new file mode 100644 index 0000000..fa8bdda --- /dev/null +++ b/examples/app/.gitignore @@ -0,0 +1,4 @@ +.next/ +out/ +next-env.d.ts +*.tsbuildinfo diff --git a/examples/app/AGENTS.md b/examples/app/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/examples/app/AGENTS.md @@ -0,0 +1,9 @@ + + +# 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. + + diff --git a/examples/app/CLAUDE.md b/examples/app/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/examples/app/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/examples/app/README.md b/examples/app/README.md new file mode 100644 index 0000000..202c64c --- /dev/null +++ b/examples/app/README.md @@ -0,0 +1,23 @@ + + +# `@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 +``), `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`. diff --git a/examples/app/app/api/health/route.ts b/examples/app/app/api/health/route.ts new file mode 100644 index 0000000..a5e2d3b --- /dev/null +++ b/examples/app/app/api/health/route.ts @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 +export function GET() { + return Response.json({ ok: true, runtime: process.versions.bun ? "bun" : "node" }); +} diff --git a/examples/app/app/components/notifier.tsx b/examples/app/app/components/notifier.tsx new file mode 100644 index 0000000..5f22534 --- /dev/null +++ b/examples/app/app/components/notifier.tsx @@ -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) => ( + + + + + + × + + )); +} + +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 ( + + Show a toast ({count}) + + ); +} + +/** Client Component: a toast provider, its viewport and the button that adds a toast. */ +export function Notifier() { + return ( + + + + + + + + + ); +} diff --git a/examples/app/app/components/page.tsx b/examples/app/app/components/page.tsx new file mode 100644 index 0000000..d7a06f8 --- /dev/null +++ b/examples/app/app/components/page.tsx @@ -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 ( +
+

{title}

+
{children}
+
+ ); +} + +export default function ComponentsPage() { + return ( + <> +