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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/p2-15-per-route-critical-css.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@typestyles/build-runner': minor
'@typestyles/next': minor
---

Per-route critical CSS for Next.js App Router (P2.15): `buildTypestylesForNext` emits route-scoped stylesheets and manifest v2 with a `routes` map; `getRouteCss` reads them at request time instead of the full `getRegisteredCss()` buffer.
2 changes: 1 addition & 1 deletion IMPROVEMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Bugs and credibility issues that lose evaluations on contact. Do these first.
variants with JSX call-site rewrites.
- Parse `@media` (and other at-rules) in static templates into nested style objects.

- [ ] **P2.15 — Per-route critical CSS** (PR: )
- [x] **P2.15 — Per-route critical CSS** (PR: #100)
- `getRegisteredCss()` returns everything ever registered. Use the extraction
manifest in `@typestyles/next/build` to emit route-level CSS.

Expand Down
36 changes: 35 additions & 1 deletion docs/content/docs/ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,41 @@ TypeStyles automatically deduplicates CSS during collection. If multiple compone

### Critical CSS

All CSS is included by default. For large applications, you might want to implement critical CSS extraction (only including styles for above-the-fold content). This isn't built into TypeStyles—you'd need to implement it at the framework level.
By default, `getRegisteredCss()` returns every rule registered in the process. For large apps, prefer **build-time per-route CSS** on Next.js: `buildTypestylesForNext` writes route-scoped stylesheets and a v2 manifest (see [zero-runtime — Next.js](/docs/zero-runtime#nextjs) and [per-route critical CSS](#per-route-critical-css-nextjs) below).

### Per-route critical CSS (Next.js)

`buildTypestylesForNext` traces each App Router `page` plus its layout chain, extracts CSS for modules that import `typestyles`, and writes self-contained files under `app/_typestyles/routes/` (default). The manifest maps route paths to those files:

```json
{
"version": 2,
"css": "app/typestyles.css",
"routes": {
"/": { "css": "app/_typestyles/routes/index.css" },
"/about": { "css": "app/_typestyles/routes/about.css" }
}
}
```

At request time, read the pre-built CSS for the current route instead of the full global sheet:

```tsx
// app/about/layout.tsx (Server Component)
import { getRouteCss } from '@typestyles/next/server';

export default function AboutLayout({ children }: { children: React.ReactNode }) {
const css = getRouteCss('/about', { root: process.cwd() });
return (
<>
<style id="typestyles-about" dangerouslySetInnerHTML={{ __html: css }} />
{children}
</>
);
}
```

Keep your convention entry minimal (tokens, reset, fonts). Route-specific `styles.*` modules imported only from that page's tree stay out of other routes' CSS files. Pass `routeCss: false` to `buildTypestylesForNext` when you only want the monolithic `typestyles.css`.

### Client-side hydration

Expand Down
7 changes: 5 additions & 2 deletions docs/content/docs/zero-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,16 @@ export default withTypestyles({

Run extraction before `next build` (CI or a `prebuild` script). **Defaults** match the Vite story: `buildTypestylesForNext({ root })` discovers a convention entry, then writes **`app/typestyles.css`** and **`app/typestyles.manifest.json`** (override with `cssOutFile`, `manifestOutFile`, or `modules` when you need a custom layout).

When an **`app/`** directory exists, extraction also emits **per-route critical CSS** under **`app/_typestyles/routes/`** and upgrades the manifest to **version 2** with a `routes` map. Disable with `routeCss: false` or customize `routeCssOutDir`.

```ts
// scripts/typestyles-build.mts
import { buildTypestylesForNext } from '@typestyles/next/build';

await buildTypestylesForNext({ root: process.cwd() });
```

Import the emitted CSS from your App Router layout (`import './typestyles.css'`).
Import the emitted CSS from your App Router layout (`import './typestyles.css'`), or load route-scoped CSS at request time with **`getRouteCss`** (see [SSR — per-route CSS](/docs/ssr#per-route-critical-css-nextjs)).

For full manual control, **`withTypestylesExtract`** is still available (always merges production defines). **`discoverDefaultExtractModules`** and **`DEFAULT_EXTRACT_MODULE_CANDIDATES`** are re-exported from `@typestyles/next/build` (same as `@typestyles/build-runner` / `@typestyles/vite`).

Expand Down Expand Up @@ -258,7 +260,8 @@ Checks performed:

- CSS file exists and meets `minBytes` (defaults to non-empty when omitted)
- Optional `requiredCssSubstrings` — catch missing imports from the entry barrel
- Optional manifest exists with `version: 1` and `css` pointing at your stylesheet path
- Optional manifest exists with `version: 1` or `version: 2` and `css` pointing at your stylesheet path
- For v2 manifests, optional `minRouteEntries` and per-route CSS file checks

Wire it into `package.json` before `next build` (see `@examples/next`):

Expand Down
4 changes: 3 additions & 1 deletion examples/next-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pnpm build # typestyles:build → typestyles:verify → next build
| ----------------------------------- | ----------------------------------------------------------------- |
| Convention entry (no `src/` prefix) | `styles/typestyles-entry.ts` |
| Pre-build extraction | `scripts/typestyles-build.mts` → `buildTypestylesForNext` |
| Per-route critical CSS | manifest v2 `routes` map + `app/_typestyles/routes/` |
| CI verification | `scripts/verify-typestyles.mts` → `verifyTypestylesBuild` |
| Static CSS in layout | `app/layout.tsx` imports `./typestyles.css` |
| Prod runtime disabled | `next.config.mjs` → `withTypestyles` |
Expand All @@ -49,7 +50,8 @@ styles/site.ts # app shell (scoped createTypeStyles)
scripts/typestyles-build.mts # extraction before next build
scripts/verify-typestyles.mts
app/typestyles.css # generated — do not hand-edit
app/typestyles.manifest.json # generated manifest for verify step
app/typestyles.manifest.json # generated manifest (v2 with routes map)
app/_typestyles/routes/ # per-route critical CSS (when app/ exists)
app/layout.tsx # links extracted CSS
next.config.mjs # withTypestyles()
```
Expand Down
2 changes: 2 additions & 0 deletions examples/next-app/scripts/verify-typestyles.mts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ try {
root,
cssFile: 'app/typestyles.css',
manifestFile: 'app/typestyles.manifest.json',
manifestVersion: 2,
minRouteEntries: 1,
minBytes: 500,
// Semantic class names are prefixed with the sanitized scopeId
// (`example-ds` / `example-app`), matching how tokens are scoped.
Expand Down
5 changes: 5 additions & 0 deletions packages/build-runner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ Throws `VerifyTypestylesBuildError` with a `code` on failure.
| `manifestCssPath` | Expected `css` field in manifest |
| `minBytes` | Minimum file size (default: non-empty) |
| `requiredCssSubstrings` | Sanity-check strings that must appear in CSS |
| `minRouteEntries` | For manifest v2: minimum route CSS entries |

### Per-route CSS (Next.js)

`collectAndWriteRouteCss`, `discoverNextAppRoutes`, `traceTypestylesModules`, and `getRouteCss` support App Router critical CSS. `@typestyles/next/build` wires these into `buildTypestylesForNext` by default when `app/` exists.

### `discoverDefaultExtractModules(root)`

Expand Down
21 changes: 21 additions & 0 deletions packages/build-runner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,27 @@ export {
type VerifyTypestylesBuildOptions,
type VerifyTypestylesBuildResult,
} from './verify';
export {
isManifestV2,
normalizeRoutePath,
type TypestylesExtractManifest,
type TypestylesExtractManifestV2,
type TypestylesRouteCssEntry,
} from './manifest';
export { discoverNextAppRoutes, pageRelPathToRoutePath, type NextAppRoute } from './next-routes';
export { createModuleLoader, traceTypestylesModules } from './trace-imports';
export {
buildManifestV2,
collectAndWriteRouteCss,
type CollectRouteCssOptions,
type CollectRouteCssResult,
} from './route-css';
export {
getRouteCss,
readCssFile,
readTypestylesManifest,
type GetRouteCssOptions,
} from './route-css-read';

import { build as esbuildBuild } from 'esbuild';
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
Expand Down
31 changes: 31 additions & 0 deletions packages/build-runner/src/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export interface TypestylesExtractManifestV1 {
version: 1;
css: string;
}

export interface TypestylesRouteCssEntry {
css: string;
}

export interface TypestylesExtractManifestV2 {
version: 2;
/** Full-app CSS (union of all registered styles). */
css: string;
/** Per-route critical CSS paths relative to project root. */
routes: Record<string, TypestylesRouteCssEntry>;
}

export type TypestylesExtractManifest = TypestylesExtractManifestV1 | TypestylesExtractManifestV2;

export function isManifestV2(
manifest: TypestylesExtractManifest,
): manifest is TypestylesExtractManifestV2 {
return manifest.version === 2;
}

/** Normalize App Router paths for manifest lookup (`/about`, no trailing slash). */
export function normalizeRoutePath(routePath: string): string {
if (!routePath || routePath === '/') return '/';
const trimmed = routePath.replace(/\/+$/, '');
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
}
55 changes: 55 additions & 0 deletions packages/build-runner/src/next-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { describe, expect, it } from 'vitest';
import { discoverNextAppRoutes, pageRelPathToRoutePath } from './next-routes';

describe('pageRelPathToRoutePath', () => {
it('maps root page to /', () => {
expect(pageRelPathToRoutePath('page.tsx')).toBe('/');
});

it('maps nested pages and omits route groups', () => {
expect(pageRelPathToRoutePath('about/page.tsx')).toBe('/about');
expect(pageRelPathToRoutePath('(marketing)/pricing/page.tsx')).toBe('/pricing');
expect(pageRelPathToRoutePath('blog/[slug]/page.tsx')).toBe('/blog/[slug]');
});
});

describe('discoverNextAppRoutes', () => {
it('discovers pages and layout chains', () => {
const root = mkdtempSync(join(tmpdir(), 'typestyles-next-routes-'));
mkdirSync(join(root, 'app/about'), { recursive: true });
writeFileSync(
join(root, 'app/layout.tsx'),
'export default function Layout() { return null; }',
);
writeFileSync(join(root, 'app/page.tsx'), 'export default function Home() { return null; }');
writeFileSync(
join(root, 'app/about/layout.tsx'),
'export default function AboutLayout() { return null; }',
);
writeFileSync(
join(root, 'app/about/page.tsx'),
'export default function About() { return null; }',
);

try {
const routes = discoverNextAppRoutes(root);
expect(routes).toEqual([
{
routePath: '/',
pageFile: 'app/page.tsx',
layoutFiles: ['app/layout.tsx'],
},
{
routePath: '/about',
pageFile: 'app/about/page.tsx',
layoutFiles: ['app/layout.tsx', 'app/about/layout.tsx'],
},
]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
117 changes: 117 additions & 0 deletions packages/build-runner/src/next-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { existsSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';

const PAGE_BASENAMES = new Set(['page.tsx', 'page.ts', 'page.jsx', 'page.js']);
const LAYOUT_BASENAMES = new Set(['layout.tsx', 'layout.ts', 'layout.jsx', 'layout.js']);

export interface NextAppRoute {
/** App Router path, e.g. `/`, `/about`, `/blog/[slug]`. */
routePath: string;
/** Page file relative to project root with `/` separators. */
pageFile: string;
/** Layout files from root to route segment, relative to project root. */
layoutFiles: string[];
}

function normalizeRelPath(root: string, absPath: string): string {
return relative(root, absPath).replace(/\\/g, '/');
}

function isRouteGroupSegment(segment: string): boolean {
return segment.startsWith('(') && segment.endsWith(')');
}

/**
* Convert `app/about/page.tsx` (relative to `appDir`) to `/about`.
* Route groups `(marketing)` are omitted from the URL.
*/
export function pageRelPathToRoutePath(pageRelPath: string): string {
const dir = dirname(pageRelPath).replace(/\\/g, '/');
if (dir === '.' || dir === '') return '/';

const segments = dir
.split('/')
.filter((segment) => segment.length > 0 && !isRouteGroupSegment(segment));

return segments.length === 0 ? '/' : `/${segments.join('/')}`;
}

function walkForPages(appAbs: string, currentRel: string, pages: string[]): void {
const currentAbs = join(appAbs, currentRel);
let entries: string[];
try {
entries = readdirSync(currentAbs);
} catch {
return;
}

for (const entry of entries) {
const entryRel = currentRel ? join(currentRel, entry) : entry;
const entryAbs = join(appAbs, entryRel);
let stat;
try {
stat = statSync(entryAbs);
} catch {
continue;
}

if (stat.isDirectory()) {
walkForPages(appAbs, entryRel, pages);
continue;
}

if (PAGE_BASENAMES.has(entry)) {
pages.push(entryRel.replace(/\\/g, '/'));
}
}
}

function collectLayoutChain(appAbs: string, pageRelPath: string): string[] {
const layouts: string[] = [];
const segments = dirname(pageRelPath).replace(/\\/g, '/').split('/');
const chainDirs: string[] = [''];

for (const segment of segments) {
if (!segment || segment === '.') continue;
const parent = chainDirs[chainDirs.length - 1];
chainDirs.push(parent ? `${parent}/${segment}` : segment);
}

for (const dir of chainDirs) {
const dirAbs = dir ? join(appAbs, dir) : appAbs;
for (const layoutName of LAYOUT_BASENAMES) {
const layoutAbs = join(dirAbs, layoutName);
if (existsSync(layoutAbs)) {
layouts.push(join(dir, layoutName).replace(/\\/g, '/'));
break;
}
}
}

return layouts;
}

/**
* Discover App Router pages under `appDir` (default `app`).
*/
export function discoverNextAppRoutes(root: string, appDir = 'app'): NextAppRoute[] {
const appAbs = resolve(root, appDir);
if (!existsSync(appAbs)) return [];

const pageRelPaths: string[] = [];
walkForPages(appAbs, '', pageRelPaths);

return pageRelPaths
.map((pageRelPath) => {
const pageFile = normalizeRelPath(root, join(appAbs, pageRelPath));
const layoutFiles = collectLayoutChain(appAbs, pageRelPath).map((layoutRel) =>
normalizeRelPath(root, join(appAbs, layoutRel)),
);
return {
routePath: pageRelPathToRoutePath(pageRelPath),
pageFile,
layoutFiles,
};
})
.sort((a, b) => a.routePath.localeCompare(b.routePath));
}
Loading
Loading