next-yak
Version:
next-yak is a CSS-in-JS solution tailored for Next.js that seamlessly combines the expressive power of styled-components syntax with efficient build-time extraction of CSS using Next.js's built-in CSS configuration
329 lines (307 loc) • 10.1 kB
text/typescript
/// <reference types="node" />
import type { NextConfig } from "next";
import { existsSync } from "node:fs";
import path, { dirname } from "node:path";
import { fileURLToPath } from "node:url";
const currentDir =
typeof __dirname !== "undefined" ? __dirname : dirname(fileURLToPath(import.meta.url));
export type YakConfigOptions = {
/**
* Generate compact CSS class and variable names.
* @defaultValue
* enabled if NODE_ENV is set to `production`, otherwise disabled
*/
minify?: boolean;
contextPath?: string;
/**
* Optional prefix for generated CSS identifiers.
* This can be used to ensure unique class names across different applications
* or to add organization-specific prefixes.
*/
prefix?: string;
/**
* Adds `displayName` to each component for better React DevTools debugging
* - Enabled by default in development mode
* - Disabled by default in production
* - Increases bundle size slightly when enabled
*/
displayNames?: boolean;
experiments?: {
/**
* Debug logging for transformed files.
* - `true` - log all files
* - `object` - filter by pattern and/or output types (at least one required)
*/
debug?:
| true
| { pattern: string; types?: Array<"ts" | "css" | "css-resolved"> }
| { pattern?: string; types: Array<"ts" | "css" | "css-resolved"> };
transpilationMode?: "CssModule" | "Css";
/**
* Suppress deprecation warnings for :global() selectors during migration period
* @defaultValue false
*/
suppressDeprecationWarnings?: boolean;
};
};
const addYak = (yakOptions: YakConfigOptions, nextConfig: NextConfig) => {
const minify = yakOptions.minify ?? process.env.NODE_ENV === "production";
const yakPluginOptions = {
minify,
basePath: currentDir,
prefix: yakOptions.prefix,
displayNames: yakOptions.displayNames ?? !minify,
suppressDeprecationWarnings: yakOptions.experiments?.suppressDeprecationWarnings ?? false,
reactRefreshReg: true,
};
const transpilation = yakOptions.experiments?.transpilationMode ?? "CssModule";
const cssExtension = transpilation === "CssModule" ? ".yak.module.css" : ".yak.css";
if (process.env.TURBOPACK === "1" || process.env.TURBOPACK === "auto") {
addYakTurbopack(nextConfig, yakOptions, {
...yakPluginOptions,
importMode: {
value: "data:text/css;base64,",
transpilation: "Css",
encoding: "Base64",
},
});
} else {
addYakWebpack(nextConfig, yakOptions, {
...yakPluginOptions,
importMode: {
value: `./{{__BASE_NAME__}}${cssExtension}!=!./{{__BASE_NAME__}}?./{{__BASE_NAME__}}${cssExtension}`,
transpilation,
encoding: "None",
},
});
}
return nextConfig;
};
/**
* Configure Turbopack with yak loader for CSS-in-JS transformation
* @param nextConfig - Next.js configuration object
* @param yakOptions - Yak configuration options
* @param yakPluginOptions - Processed plugin options for yak-swc
*/
function addYakTurbopack(
nextConfig: NextConfig,
yakOptions: YakConfigOptions,
yakPluginOptions: {
minify: boolean;
basePath: string;
prefix?: string;
displayNames: boolean;
importMode: {
value: string;
transpilation: string;
encoding: string;
};
},
) {
// turbopack can't handle options with undefined values, so we remove them
const yakLoader = removeUndefinedRecursive({
loader: path.join(currentDir, "../loaders/turbo-loader.cjs"),
options: {
yakOptions: yakOptions,
yakPluginOptions: yakPluginOptions,
},
}) as { loader: string; options: {} };
nextConfig.turbopack ||= {};
nextConfig.turbopack.rules ||= {};
const ruleKey = "*.{js,jsx,cjs,mjs,ts,tsx,cts,mts}";
const rule = {
loaders: [] as { loader: string; options: {} }[],
...nextConfig.turbopack.rules[ruleKey],
};
rule.loaders.push(yakLoader);
nextConfig.turbopack.rules[ruleKey] = rule;
// Configure resolveAlias for custom yak context (similar to webpack)
// This allows users to provide a custom context file that will be used
// instead of the default baseContext
const yakContext = resolveYakContext(yakOptions.contextPath, process.cwd());
if (yakContext) {
nextConfig.turbopack.resolveAlias ||= {};
nextConfig.turbopack.resolveAlias["next-yak/context/baseContext"] =
// This is a hack around the fact that turbopack currently only supports relative paths
// turbopack: "server relative imports are not implemented yet"
// Relative is quite dangerous here as it relies on the cwd being the starting point
`./${path.relative(process.cwd(), yakContext)}`;
}
}
/**
* Configure Webpack with yak SWC plugin and webpack loader for CSS-in-JS transformation
* @param nextConfig - Next.js configuration object
* @param yakOptions - Yak configuration options
* @param yakPluginOptions - Processed plugin options for yak-swc
*/
function addYakWebpack(
nextConfig: NextConfig,
yakOptions: YakConfigOptions,
yakPluginOptions: {
minify: boolean;
basePath: string;
prefix?: string;
displayNames: boolean;
importMode: {
value: string;
transpilation: string;
encoding: string;
};
},
) {
// Add SWC plugin for Webpack
nextConfig.experimental ||= {};
nextConfig.experimental.swcPlugins ||= [];
nextConfig.experimental.swcPlugins.push(["yak-swc", yakPluginOptions]);
// Configure webpack loader
const previousConfig = nextConfig.webpack;
nextConfig.webpack = (webpackConfig, options) => {
if (previousConfig) {
webpackConfig = previousConfig(webpackConfig, options);
}
webpackConfig.module.rules.push({
test:
yakOptions.experiments?.transpilationMode === "Css" ? /\.yak\.css$/ : /\.yak\.module\.css$/,
loader: path.join(currentDir, "../loaders/webpack-loader.cjs"),
options: yakOptions,
});
// With the following alias the internal next-yak code
// is able to import a context which works for server components
const yakContext = resolveYakContext(
yakOptions.contextPath,
webpackConfig.context || process.cwd(),
);
if (yakContext) {
webpackConfig.resolve.alias["next-yak/context/baseContext"] = yakContext;
}
return webpackConfig;
};
}
/**
* Recursively removes undefined values from an object or array.
*
* This function deeply traverses the input object/array and creates a new structure
* with all undefined values filtered out. For objects, properties with undefined values
* are omitted. For arrays, undefined elements are removed from the result.
*
* @param obj - The object or array to process
* @returns A new object/array with undefined values removed, or the original value if no changes were needed
*/
function removeUndefinedRecursive<T>(obj: T): {} {
if (typeof obj !== "object" || obj === null) {
return obj as {};
}
if (Array.isArray(obj)) {
const filtered: unknown[] = [];
for (let i = 0; i < obj.length; i++) {
const processed = removeUndefinedRecursive(obj[i]);
if (processed !== undefined) {
filtered.push(processed);
}
}
return filtered as {};
}
const newObj: Record<string, unknown> = {};
let hasChanges = false;
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = removeUndefinedRecursive((obj as any)[key]);
if (value !== undefined) {
newObj[key] = value;
hasChanges = true;
}
}
}
return hasChanges ? (newObj as {}) : obj;
}
/**
* Try to resolve yak
*/
export function resolveYakContext(contextPath: string | undefined, cwd: string) {
const yakContext = contextPath
? path.resolve(cwd, contextPath)
: path.resolve(cwd, "yak.context");
const extensions = ["", ".ts", ".tsx", ".js", ".jsx"];
for (const extension in extensions) {
const fileName = yakContext + extensions[extension];
if (existsSync(fileName)) {
return fileName;
}
}
if (contextPath) {
throw new Error(`Could not find yak context file at ${yakContext}`);
}
}
// Wrapper to allow sync, async, and function configuration of Next.js
/**
* Add Yak to your Next.js app
*
* @usage
*
* ```ts
* // next.config.js
* const { withYak } = require("next-yak/withYak");
* const nextConfig = {
* // your next config here
* };
* module.exports = withYak(nextConfig);
* ```
*
* With a custom yakConfig
*
* ```ts
* // next.config.js
* const { withYak } = require("next-yak/withYak");
* const nextConfig = {
* // your next config here
* };
* const yakConfig = {
* // Optional prefix for generated CSS identifiers
* prefix: "my-app",
* // Other yak config options...
* };
* module.exports = withYak(yakConfig, nextConfig);
* ```
*/
export const withYak: {
<
T extends
| Record<string, any>
| ((...args: any[]) => Record<string, any>)
| ((...args: any[]) => Promise<Record<string, any>>),
>(
yakOptions: YakConfigOptions,
nextConfig: T,
): T;
// no yakConfig
<
T extends
| Record<string, any>
| ((...args: any[]) => Record<string, any>)
| ((...args: any[]) => Promise<Record<string, any>>),
>(
nextConfig: T,
_?: undefined,
): T;
} = (maybeYakOptions, nextConfig) => {
if (nextConfig === undefined) {
return withYak({}, maybeYakOptions);
}
// If the second parameter is present the first parameter must be a YakConfigOptions
const yakOptions = maybeYakOptions as YakConfigOptions;
if (typeof nextConfig === "function") {
/**
* A NextConfig can be a sync or async function
* https://nextjs.org/docs/pages/api-reference/next-config-js
* @param {any[]} args
*/
return (...args) => {
/** Dynamic Next Configs can be async or sync */
const config = nextConfig(...args) as NextConfig | Promise<NextConfig>;
return config instanceof Promise
? config.then((config) => addYak(yakOptions, config))
: addYak(yakOptions, config);
};
}
return addYak(yakOptions, nextConfig);
};