import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { dirname, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { buildManifestV2, collectAndWriteRouteCss, discoverDefaultExtractModules, type TypestylesExtractManifestV1, type TypestylesExtractManifestV2, } from '@typestyles/build-runner'; import { collectStylesFromModules } from 'typestyles/build'; import type { NextConfig } from 'next'; /** Re-export shared convention paths (aligned with `@typestyles/vite`). */ export { DEFAULT_EXTRACT_MODULE_CANDIDATES, discoverDefaultExtractModules, verifyTypestylesBuild, VerifyTypestylesBuildError, getRouteCss, type TypestylesExtractManifestV1, type TypestylesExtractManifestV2, type VerifyTypestylesBuildOptions, type VerifyTypestylesBuildResult, type GetRouteCssOptions, } from '@typestyles/build-runner'; /** Shape of `require('webpack')` for `DefinePlugin` only (webpack is an optional peer). */ type WebpackWithDefinePlugin = { DefinePlugin: new (definitions: Record) => unknown; }; export interface BuildTypestylesForNextOptions { root: string; /** * Style entry modules relative to `root`. When omitted, uses the same convention discovery as * `@typestyles/vite` (see {@link discoverDefaultExtractModules}). */ modules?: string[]; /** Output CSS path relative to `root`. Defaults to `app/typestyles.css`. */ cssOutFile?: string; /** * Manifest path relative to `root`. Defaults to `app/typestyles.manifest.json`. * Pass `false` to skip writing a manifest. */ manifestOutFile?: string | false; /** Value stored in the manifest `css` field. Defaults to `cssOutFile`. */ manifestCssPath?: string; /** * Emit per-route critical CSS for App Router pages and include mappings in the manifest (v2). * Defaults to `true` when an `app/` directory exists under `root`. */ routeCss?: boolean; /** Output directory for per-route CSS relative to `root`. Default: `app/_typestyles/routes`. */ routeCssOutDir?: string; /** App Router directory relative to `root`. Default: `app`. */ appDir?: string; } function resolveExtractModulesForNext(root: string, modules?: string[]): string[] { if (modules !== undefined) { return modules; } const found = discoverDefaultExtractModules(root); if (found.length === 0) { throw new Error( `[typestyles] No extraction entry found under ${root}. Add a convention file (see DEFAULT_EXTRACT_MODULE_CANDIDATES from @typestyles/build-runner) or pass modules to buildTypestylesForNext.`, ); } return found; } /** * Extract TypeStyles CSS for a Next.js app (run from a script before `next build`, or in CI). * Loads each module path (relative to `root`) so side-effect registrations run, then writes CSS. * * Defaults mirror `@typestyles/vite`: discovers a convention entry when `modules` is omitted, * writes `app/typestyles.css` and `app/typestyles.manifest.json` unless overridden. * * When route CSS is enabled (default for App Router apps), the manifest is v2 and includes * per-route CSS paths under `app/_typestyles/routes/` (override with `routeCssOutDir`). */ export async function buildTypestylesForNext( options: BuildTypestylesForNextOptions, ): Promise { const { root, modules, cssOutFile = 'app/typestyles.css', manifestOutFile, manifestCssPath, routeCssOutDir = 'app/_typestyles/routes', appDir = 'app', } = options; const resolvedModules = resolveExtractModulesForNext(root, modules); const enableRouteCss = options.routeCss ?? discoverNextAppRoutesEnabled(root, appDir); const loaders = resolvedModules.map((mod) => { const abs = resolve(root, mod); const href = pathToFileURL(abs).href; return () => import(href); }); const css = await collectStylesFromModules(loaders); const outAbs = resolve(root, cssOutFile); await mkdir(dirname(outAbs), { recursive: true }); await writeFile(outAbs, css, 'utf8'); const manifestRel = manifestOutFile === false ? undefined : (manifestOutFile ?? 'app/typestyles.manifest.json'); if (manifestRel) { const cssPath = manifestCssPath ?? cssOutFile; let manifest: TypestylesExtractManifestV1 | TypestylesExtractManifestV2 = { version: 1, css: cssPath, }; if (enableRouteCss) { const { routes } = await collectAndWriteRouteCss({ root, sharedModules: resolvedModules, appDir, routeCssOutDir, collectCss: collectStylesFromModules, }); manifest = buildManifestV2(cssPath, routes); } const manAbs = resolve(root, manifestRel); await mkdir(dirname(manAbs), { recursive: true }); await writeFile(manAbs, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); } } function discoverNextAppRoutesEnabled(root: string, appDir: string): boolean { return existsSync(resolve(root, appDir)); } export interface WithTypestylesExtractOptions { /** * When true (default), client bundles skip DOM style injection (`__TYPESTYLES_RUNTIME_DISABLED__`). * Pair with a global stylesheet from {@link buildTypestylesForNext}. */ disableClientRuntime?: boolean; /** * Project root used for module resolution (e.g. loading `webpack`). Defaults to `process.cwd()`. */ root?: string; } export interface WithTypestylesOptions extends WithTypestylesExtractOptions { /** * Project root for convention discovery. Defaults to `process.cwd()` (the directory Next runs from). */ root?: string; } /** * Production Next.js config helper aligned with `@typestyles/vite`: when `NODE_ENV === 'production'` * and a convention entry file exists under `root`, applies {@link withTypestylesExtract}; otherwise * returns `nextConfig` unchanged (development keeps runtime injection). */ export function withTypestyles( nextConfig: NextConfig = {}, options: WithTypestylesOptions = {}, ): NextConfig { const projectRoot = options.root ?? process.cwd(); const hasConventionEntry = discoverDefaultExtractModules(projectRoot).length > 0; const prod = process.env.NODE_ENV === 'production'; if (!prod || !hasConventionEntry) { return nextConfig; } return withTypestylesExtract(nextConfig, options); } /** * Merge Next.js config so client bundles use pre-extracted CSS only (no runtime `