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
343 lines (319 loc) • 11.8 kB
text/typescript
import { Options, transform as swcTransform } from "@swc/core";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, relative, resolve } from "node:path";
import { normalizePath, type Plugin } from "vite";
import { parseModule } from "../cross-file-resolver/parseModule.js";
import { resolveCrossFileConstant } from "../cross-file-resolver/resolveCrossFileConstant.js";
import { createEvaluator, type Evaluator } from "../isolated-source-eval/index.js";
import { resolveYakContext, YakConfigOptions } from "../withYak/index.js";
import { createDebugLogger } from "./lib/debugLogger.js";
import { extractCss } from "./lib/extractCss.js";
import { parseExports } from "./lib/resolveCrossFileSelectors.js";
const require = createRequire(import.meta.url);
type ViteYakPluginOptions = YakConfigOptions & {
/**
* Base path for resolving CSS virtual module paths.
* Relative paths are resolved from Vite's project root.
* In monorepo setups where source files live outside the Vite project root,
* set this to the monorepo root to avoid broken CSS imports.
* @defaultValue Vite's resolved `root`
*/
basePath?: string;
swcOptions?: Omit<
Options,
"filename" | "sourceFileName" | "inputSourceMap" | "sourceMaps" | "sourceRoot"
>;
};
const defaultSwcOptions: ViteYakPluginOptions["swcOptions"] = {
jsc: {
parser: {
syntax: "typescript",
tsx: true,
decorators: false,
dynamicImport: true,
},
transform: {
react: {
runtime: "preserve",
},
},
target: "es2022",
loose: false,
minify: {
compress: false,
mangle: false,
},
preserveAllComments: true,
},
minify: false,
isModule: true,
};
export async function viteYak(userOptions: ViteYakPluginOptions = {}): Promise<Plugin> {
const yakOptions: ViteYakPluginOptions = {
experiments: {
transpilationMode: "Css",
suppressDeprecationWarnings: false,
...userOptions.experiments,
},
minify: userOptions.minify ?? process.env.NODE_ENV === "production",
prefix: userOptions.prefix,
contextPath: userOptions.contextPath,
swcOptions: deepMerge(defaultSwcOptions!, userOptions.swcOptions ?? {}),
};
yakOptions.displayNames =
userOptions.displayNames ?? yakOptions.displayNames ?? !yakOptions.minify;
let basePath = userOptions.basePath ?? "";
let hasWarnedAboutBasePath = false;
let debugLog: ReturnType<typeof createDebugLogger> = () => {};
let isServe = false;
const sourceFileRegex = /\.(tsx?|m?jsx?)\??/;
const virtualModuleRegex = /^virtual:yak-css:/;
const virtualCssModuleRegex = /^\0virtual:yak-css:/;
const yakSwcPath = await findYakSwcPlugin();
const evaluator: Evaluator = await createEvaluator();
return {
name: "vite-plugin-yak:css:pre",
enforce: "pre",
config: (config) => {
const context = resolveYakContext(yakOptions.contextPath, config.root ?? process.cwd());
if (!context) {
return;
}
config.resolve ||= {};
if (Array.isArray(config.resolve.alias)) {
config.resolve.alias.push({
find: "next-yak/context/baseContext",
replacement: context,
});
} else {
config.resolve.alias = {
...config.resolve.alias,
"next-yak/context/baseContext": context,
};
}
},
configResolved(config) {
basePath = basePath ? resolve(config.root, basePath) : config.root;
debugLog = createDebugLogger(yakOptions.experiments?.debug, basePath);
isServe = config.command === "serve";
},
resolveId: {
filter: {
id: virtualModuleRegex,
},
handler(id) {
return "\0" + id;
},
},
load: {
filter: {
id: virtualCssModuleRegex,
},
async handler(id) {
// remove \0virtual:yak-css: (17 chars) from the beginning and .css (4 chars) from the end
// The path is relative to basePath — resolve to absolute for Vite's file APIs
const queryStringStart = id.indexOf("?");
const queryString = queryStringStart === -1 ? "" : id.slice(queryStringStart);
const relativeId = id.slice(17, -4 - queryString.length);
const originalId = resolve(basePath, relativeId);
this.addWatchFile(originalId);
const sourceContent = await this.fs.readFile(originalId, {
encoding: "utf8",
});
const code = await transform(sourceContent, originalId, basePath, yakSwcPath, yakOptions);
const extractedCss = extractCss(code.code, "Css");
debugLog("css", extractedCss, originalId);
const { resolved } = await resolveCrossFileConstant(
{
parse: (modulePath) => {
return parseModule(
{
transpilationMode: "Css",
extractExports: async (modulePath) => {
const sourceContent = await this.fs.readFile(modulePath, {
encoding: "utf8",
});
this.addWatchFile(modulePath);
return parseExports(sourceContent);
},
getTransformed: async (modulePath) => {
const sourceContent = await this.fs.readFile(modulePath, {
encoding: "utf8",
});
return transform(sourceContent, modulePath, basePath, yakSwcPath, yakOptions);
},
evaluateYakModule: async (modulePath: string) => {
this.addWatchFile(modulePath);
const result = await evaluator.evaluate(modulePath);
if (!result.ok) {
throw new Error(result.error.message);
}
for (const dep of result.dependencies) {
this.addWatchFile(dep);
}
return result.value;
},
},
modulePath,
);
},
resolve: async (moduleSpecifier: string, context: string) => {
let importer = context;
const resolved = await this.resolve(moduleSpecifier, importer);
if (!resolved) {
throw new Error(`Could not resolve ${moduleSpecifier} from ${context}`);
}
return resolved.id;
},
},
originalId + queryString,
extractedCss,
);
debugLog("css-resolved", resolved, originalId);
return resolved;
},
},
configureServer(server) {
server.watcher.on("change", (file) => {
evaluator.invalidate(file);
});
},
transform: {
filter: {
id: {
include: sourceFileRegex,
exclude: [/packages\/next-yak/],
},
code: "next-yak",
},
async handler(code, id) {
try {
const filePath = id.split("?")[0];
if (!hasWarnedAboutBasePath) {
const relPath = relative(basePath, filePath);
if (relPath.startsWith("..")) {
hasWarnedAboutBasePath = true;
console.warn(
`[next-yak] Source file "${filePath}" is outside the project root "${basePath}".\n` +
`This may cause CSS resolution issues in monorepo setups.\n` +
`Set the "basePath" option to your monorepo root:\n\n` +
` viteYak({ basePath: "/absolute/path/to/monorepo/root" })\n`,
);
}
}
const result = await transform(code, filePath, basePath, yakSwcPath, yakOptions, isServe);
debugLog("ts", result.code, id);
return {
code: result.code,
map: result.map,
};
} catch (error) {
this.error(`[YAK Plugin] Error transforming ${id}: ${(error as Error).message}`);
}
},
},
// Vite's default HMR only updates the JS module when a source file changes.
// The extracted CSS lives in a separate virtual module (virtual:yak-css:...)
// which Vite doesn't know is derived from the source file. Without explicit
// invalidation here, the browser keeps stale CSS after edits.
hotUpdate({ modules, file, type }) {
if (type !== "update" && type !== "create") return;
if (!sourceFileRegex.test(file)) return;
// The SWC plugin generates virtual module paths relative to basePath
// (via {{__MODULE_PATH__}}), so we must match that format.
const relativePath = normalizePath(relative(basePath, file));
const virtualId = "\0virtual:yak-css:" + relativePath + ".css";
const mod = this.environment.moduleGraph.getModuleById(virtualId);
if (mod) {
this.environment.moduleGraph.invalidateModule(mod);
return [...modules, mod];
}
},
};
}
/**
* This function finds the path to the yak-swc plugin because it is most of the time a transitive dependency
* and the resolver of SWC only resolves the main node_modules directory.
* @returns The path to the yak-swc wasm plugin.
*/
async function findYakSwcPlugin() {
try {
const packageJsonPath = require.resolve("yak-swc/package.json");
const packageRoot = dirname(packageJsonPath);
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
// Resolve the main field which points to the WASM file
const wasmPath = resolve(packageRoot, packageJson.main);
return wasmPath;
} catch (e) {
throw new Error(`Could not resolve yak-swc plugin: ${e}`);
}
}
function transform(
data: string,
modulePath: string,
rootPath: string,
yakSwcPath: string,
yakOptions: ViteYakPluginOptions,
reactRefreshReg?: boolean,
) {
// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/loaders/next-swc-loader.ts#L143
return swcTransform(data, {
filename: modulePath,
inputSourceMap: undefined,
sourceMaps: true,
sourceFileName: modulePath,
sourceRoot: rootPath,
...yakOptions.swcOptions,
jsc: {
...yakOptions.swcOptions?.jsc,
experimental: {
plugins: [
[
yakSwcPath,
{
minify: yakOptions.minify,
basePath: rootPath,
prefix: yakOptions.prefix,
displayNames: yakOptions.displayNames,
suppressDeprecationWarnings: yakOptions.experiments?.suppressDeprecationWarnings,
...(reactRefreshReg ? { reactRefreshReg: true } : {}),
importMode: {
value: "virtual:yak-css:{{__MODULE_PATH__}}.css",
transpilation: "Css",
encoding: "None",
},
},
],
],
},
},
});
}
/**
* Deep merge two objects, with source values overriding target values.
*/
function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
const result = { ...target };
for (const key of Object.keys(source) as Array<keyof T>) {
const sourceValue = source[key];
const targetValue = target[key];
if (
sourceValue !== undefined &&
typeof sourceValue === "object" &&
sourceValue !== null &&
!Array.isArray(sourceValue) &&
typeof targetValue === "object" &&
targetValue !== null &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>,
) as T[keyof T];
} else if (sourceValue !== undefined) {
result[key] = sourceValue as T[keyof T];
}
}
return result;
}