mirror of
https://github.com/aphrody-labs/aphrody-m3-next.git
synced 2026-10-13 19:55:43 +00:00
348 lines
14 KiB
TypeScript
348 lines
14 KiB
TypeScript
#!/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));
|
|
}
|