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
696 lines (656 loc) • 21.9 kB
text/typescript
import babel from "@babel/core";
import path from "path";
// @ts-expect-error - this is used by babel directly so we ignore that it is not typed
import babelPlugin from "@babel/plugin-syntax-typescript";
import type { Compilation, LoaderContext } from "webpack";
const yakCssImportRegex =
// Make mixin and selector non optional once we dropped support for the babel plugin
/--yak-css-import\:\s*url\("([^"]+)",?(|mixin|selector)\)(;?)/g;
const compilationCache = new WeakMap<
Compilation,
{
parsedFiles: Map<string, ParsedFile>;
}
>();
const getCompilationCache = (loader: LoaderContext<{}>) => {
const compilation = loader._compilation;
if (!compilation) {
throw new Error("Webpack compilation object not available");
}
let cache = compilationCache.get(compilation);
if (!cache) {
cache = {
parsedFiles: new Map(),
};
compilationCache.set(compilation, cache);
}
return cache;
};
/**
* Resolves cross-file selectors in css files
*
* e.g.:
* theme.ts:
* ```ts
* export const colors = {
* primary: "#ff0000",
* secondary: "#00ff00",
* };
* ```
*
* styles.ts:
* ```ts
* import { colors } from "./theme";
* export const button = css`
* background-color: ${colors.primary};
* `;
*/
export async function resolveCrossFileConstant(
loader: LoaderContext<{}>,
pathContext: string,
css: string,
): Promise<string> {
// Search for --yak-css-import: url("path/to/module") in the css
const matches = [...css.matchAll(yakCssImportRegex)].map((match) => {
const [fullMatch, encodedArguments, importKind, semicolon] = match;
const [moduleSpecifier, ...specifier] = encodedArguments
.split(":")
.map((entry) => decodeURIComponent(entry));
return {
encodedArguments,
moduleSpecifier,
specifier,
importKind,
semicolon,
position: match.index!,
size: fullMatch.length,
};
});
if (matches.length === 0) return css;
try {
// Resolve all imports concurrently
const resolvedValues = await Promise.all(
matches.map(async ({ moduleSpecifier, specifier }) => {
const parsedModule = await parseModule(
loader,
moduleSpecifier,
pathContext,
);
const resolvedValue = await resolveModuleSpecifierRecursively(
loader,
parsedModule,
specifier,
);
return resolvedValue;
}),
);
// Replace the imports with the resolved values
let result = css;
for (let i = matches.length - 1; i >= 0; i--) {
const { position, size, importKind, specifier, semicolon } = matches[i];
const resolved = resolvedValues[i];
if (importKind === "selector") {
if (
resolved.type !== "styled-component" &&
resolved.type !== "constant"
) {
throw new Error(
`Found ${
resolved.type
} but expected a selector - did you forget a semicolon after \`${specifier.join(
".",
)}\`?`,
);
}
}
const replacement =
resolved.type === "styled-component"
? resolved.value
: resolved.value +
// resolved.value can be of two different types:
// - mixin:
// ${mixinName};
// - constant:
// color: ${value};
// For mixins the semicolon is already included in the value
// but for constants it has to be added manually
(["}", ";"].includes(String(resolved.value).trimEnd().slice(-1))
? ""
: semicolon);
result =
result.slice(0, position) +
String(replacement) +
result.slice(position + size);
}
return result;
} catch (error) {
throw new Error(
`Error resolving cross-file selectors: ${
(error as Error).message
}\nFile: ${loader.resourcePath}`,
);
}
}
/**
* Resolves a module by wrapping loader.resolve in a promise
*/
export async function resolveModule(
loader: LoaderContext<{}>,
moduleSpecifier: string,
context: string,
): Promise<string> {
return new Promise<string>((resolve, reject) => {
loader.resolve(context, moduleSpecifier, (err, result) => {
if (err) return reject(err);
if (!result)
return reject(new Error(`Could not resolve ${moduleSpecifier}`));
resolve(result);
});
});
}
/**
* Resolves a module specifier to a parsed file
*
* e.g.:
* ```
* parseModule(loader, "./theme", "/path/to/styles.ts")
* // -> { type: 'regular', secondary: { type: 'constant', value: '#00ff00' } } }, filePath: '/path/to/theme.ts' }
* ```
*/
async function parseModule(
loader: LoaderContext<{}>,
moduleSpecifier: string,
context: string,
): Promise<ParsedFile> {
const cache = getCompilationCache(loader).parsedFiles;
// The cache key is valid for the entire project so it can be reused
// for different source files
const resolvedModule = await resolveModule(loader, moduleSpecifier, context);
let parsedFile = cache.get(resolvedModule);
if (!parsedFile) {
parsedFile = await parseFile(loader, resolvedModule);
// We cache the parsed file to avoid re-parsing it.
// It's ok, that initial parallel requests to the same file will parse it multiple times.
// This avoid deadlocks do to the fact that we load multiple modules in the chain for cross file references.
cache.set(resolvedModule, parsedFile);
}
// on file change, invalidate the cache
loader.addDependency(parsedFile.filePath);
return parsedFile;
}
async function parseFile(
loader: LoaderContext<{}>,
filePath: string,
): Promise<ParsedFile> {
const isYak =
filePath.endsWith(".yak.ts") ||
filePath.endsWith(".yak.tsx") ||
filePath.endsWith(".yak.js") ||
filePath.endsWith(".yak.jsx");
const isTSX = filePath.endsWith(".tsx");
try {
if (isYak) {
const module: Record<string, unknown> =
await loader.importModule(filePath);
const mappedModule = Object.fromEntries(
Object.entries(module).map(([key, value]): [string, ParsedExport] => {
if (typeof value === "string" || typeof value === "number") {
return [key, { type: "constant" as const, value }];
} else if (
value &&
(typeof value === "object" || Array.isArray(value))
) {
return [key, { type: "record" as const, value }];
} else {
return [key, { type: "unsupported" as const, hint: String(value) }];
}
}),
);
return { type: "yak", exports: mappedModule, filePath };
}
const sourceContents = new Promise<string>((resolve, reject) =>
loader.fs.readFile(filePath, "utf-8", (err, result) => {
if (err) return reject(err);
resolve(result || "");
}),
);
const tranformedSource = new Promise<string>((resolve, reject) => {
loader.loadModule(filePath, (err, source) => {
if (err) return reject(err);
let sourceString: string;
if (typeof source === "string") {
sourceString = source;
} else if (source instanceof Buffer) {
sourceString = source.toString("utf-8");
} else if (source instanceof ArrayBuffer) {
sourceString = new TextDecoder("utf-8").decode(source);
} else {
throw new Error(
"Invalid input type: code must be string, Buffer, or ArrayBuffer",
);
}
resolve(sourceString || "");
});
});
const exports = await parseExports(await sourceContents, isTSX);
const mixins = parseMixins(await tranformedSource);
Object.assign(exports, parseStyledComponents(await tranformedSource));
// Recursively resolve cross-file constants in mixins
// e.g. cross file mixins inside a cross file mixin
// or a cross file selector inside a cross file mixin
await Promise.all(
Object.entries(mixins).map(async ([name, { value, nameParts }]) => {
const resolvedValue = await resolveCrossFileConstant(
loader,
path.dirname(filePath),
value,
);
if (nameParts.length === 1) {
exports[name] = { type: "mixin", value: resolvedValue };
} else {
let exportEntry: undefined | ParsedExport = exports[nameParts[0]];
if (!exportEntry) {
exportEntry = { type: "record", value: {} };
exports[nameParts[0]] = exportEntry;
} else if (exportEntry.type !== "record") {
throw new Error(
`Error parsing file ${filePath}: ${nameParts[0]} is not a record`,
);
}
let current = exportEntry.value as Record<any, ParsedExport>;
for (let i = 1; i < nameParts.length - 1; i++) {
let next = current[nameParts[i]];
if (!next) {
next = { type: "record", value: {} };
current[nameParts[i]] = next;
} else if (next.type !== "record") {
throw new Error(
`Error parsing file ${filePath}: ${nameParts[i]} is not a record`,
);
}
current = next.value;
}
current[nameParts[nameParts.length - 1]] = {
type: "mixin",
value: resolvedValue,
};
}
}),
);
return {
type: "regular",
exports,
filePath,
};
} catch (error) {
throw new Error(
`Error parsing file ${filePath}: ${(error as Error).message}`,
);
}
}
async function parseExports(
sourceContents: string,
isTSX: boolean,
): Promise<Record<string, ParsedExport>> {
let exports: Record<string, ParsedExport> = {};
try {
babel.transformSync(sourceContents, {
configFile: false,
plugins: [
[babelPlugin, { isTSX }],
[
(): babel.PluginObj => ({
visitor: {
ExportNamedDeclaration({ node }) {
if (node.source) {
node.specifiers.forEach((specifier) => {
if (
specifier.type === "ExportSpecifier" &&
specifier.exported.type === "Identifier" &&
specifier.local.type === "Identifier"
) {
exports[specifier.exported.name] = {
type: "re-export",
from: node.source!.value,
imported: specifier.local.name,
};
}
});
} else if (node.declaration?.type === "VariableDeclaration") {
node.declaration.declarations.forEach((declaration) => {
if (
declaration.id.type === "Identifier" &&
declaration.init
) {
const parsed = parseExportValueExpression(
declaration.init,
);
if (parsed) {
exports[declaration.id.name] = parsed;
}
}
});
}
},
ExportDeclaration({ node }) {
if ("specifiers" in node && node.source) {
const { specifiers, source } = node;
specifiers.forEach((specifier) => {
// export * as color from "./colors";
if (
specifier.type === "ExportNamespaceSpecifier" &&
specifier.exported.type === "Identifier"
) {
exports[specifier.exported.name] = {
type: "star-export",
from: [source.value],
};
}
});
}
},
ExportAllDeclaration({ node }) {
if (Object.keys(exports).length === 0) {
exports["*"] ||= {
type: "star-export",
from: [],
};
if (exports["*"].type !== "star-export") {
throw new Error("Invalid star export state");
}
exports["*"].from.push(node.source.value);
}
},
},
}),
],
],
});
return exports;
} catch (error) {
throw new Error(`Error parsing exports: ${(error as Error).message}`);
}
}
function parseMixins(
sourceContents: string,
): Record<string, { type: "mixin"; value: string; nameParts: string[] }> {
// Mixins are always in the following format:
// /*YAK EXPORTED MIXIN:fancy:aspectRatio:16:9
// css
// */
const mixinParts = sourceContents.split("/*YAK EXPORTED MIXIN:");
let mixins: Record<
string,
{ type: "mixin"; value: string; nameParts: string[] }
> = {};
for (let i = 1; i < mixinParts.length; i++) {
const [comment] = mixinParts[i].split("*/", 1);
const position = comment.indexOf("\n");
const name = comment.slice(0, position);
const value = comment.slice(position + 1);
mixins[name] = {
type: "mixin",
value,
nameParts: name.split(":").map((part) => decodeURIComponent(part)),
};
}
return mixins;
}
function parseStyledComponents(
sourceContents: string,
): Record<string, { type: "styled-component"; value: string }> {
// cross-file Styled Components are always in the following format:
// /*YAK EXPORTED STYLED:ComponentName:ClassName*/
const styledParts = sourceContents.split("/*YAK EXPORTED STYLED:");
let styledComponents: Record<
string,
{ type: "styled-component"; value: string }
> = {};
for (let i = 1; i < styledParts.length; i++) {
const [comment] = styledParts[i].split("*/", 1);
const [componentName, className] = comment.split(":");
styledComponents[componentName] = {
type: "styled-component",
value: `:global(.${className})`,
};
}
return styledComponents;
}
/**
* Unpacks a TSAsExpression to its expression value
*/
function unpackTSAsExpression(
node: babel.types.TSAsExpression | babel.types.Expression,
): babel.types.Expression {
if (node.type === "TSAsExpression") {
return unpackTSAsExpression(node.expression);
}
return node;
}
function parseExportValueExpression(
node: babel.types.Expression,
): ParsedExport {
// ignores `as` casts so it doesn't interfere with the ast node type detection
const expression = unpackTSAsExpression(node);
if (
expression.type === "CallExpression" ||
expression.type === "TaggedTemplateExpression"
) {
// The value will be set by parseStyledComponents
return { type: "styled-component", value: undefined };
} else if (
expression.type === "StringLiteral" ||
expression.type === "NumericLiteral"
) {
return { type: "constant", value: expression.value };
} else if (
expression.type === "UnaryExpression" &&
expression.operator === "-" &&
expression.argument.type === "NumericLiteral"
) {
return { type: "constant", value: -expression.argument.value };
} else if (
expression.type === "TemplateLiteral" &&
expression.quasis.length === 1
) {
return { type: "constant", value: expression.quasis[0].value.raw };
} else if (expression.type === "ObjectExpression") {
return { type: "record", value: parseObjectExpression(expression) };
}
return { type: "unsupported", hint: expression.type };
}
function parseObjectExpression(
node: babel.types.ObjectExpression,
): Record<string, ParsedExport> {
let result: Record<string, ParsedExport> = {};
for (const property of node.properties) {
if (
property.type === "ObjectProperty" &&
property.key.type === "Identifier"
) {
const key = property.key.name;
const parsed = parseExportValueExpression(
property.value as babel.types.Expression,
);
if (parsed) {
result[key] = parsed;
}
}
}
return result;
}
/**
* Follows a specifier recursively until it finds its constant value
* for example here it follows "colors.primary"
*
* ```
* resolveModuleSpecifierRecursively(loader, "@/theme", ["colors", "primary"], "colors:primary")`
* // -> { type: 'constant', value: '#ff0000' }
* ```
*
* example structure:
*
* styles.ts:
* ```
* import { colors } from "@/theme";
* export const button = css`color: ${colors.primary}`;
* ```
*
* theme.ts:
* ```
* export { colors } from "./colors";
* ```
*
* colors.ts:
* ```
* export const colors = { primary: "#ff0000" };
* ```
*
*/
async function resolveModuleSpecifierRecursively(
loader: LoaderContext<{}>,
module: ParsedFile,
specifier: string[],
): Promise<ResolvedExport> {
try {
const exportName = specifier[0];
let exportValue = module.exports[exportName];
// Follow star exports if there is only a single one
// and the export does not exist in the current module
if (exportValue === undefined) {
const starExport = module.exports["*"];
if (starExport?.type === "star-export") {
if (starExport.from.length > 1) {
throw new Error(
`Could not resolve ${specifier.join(".")} in module ${
module.filePath
} - Multiple star exports are not supported for performance reasons`,
);
}
exportValue = {
type: "re-export" as const,
from: starExport.from[0],
imported: exportName,
};
} else {
throw new Error(
`Could not resolve "${specifier.join(".")}" in module ${
module.filePath
}`,
);
}
}
// Follow reexport
// e.g. export { colors as primaryColors } from "./colors";
if (exportValue.type === "re-export") {
const importedModule = await parseModule(
loader,
exportValue.from,
path.dirname(module.filePath),
);
return resolveModuleSpecifierRecursively(loader, importedModule, [
exportValue.imported,
...specifier.slice(1),
]);
}
// Namespace export
// e.g. export * as colors from "./colors";
else if (exportValue.type === "star-export") {
const importedModule = await parseModule(
loader,
exportValue.from[0],
path.dirname(module.filePath),
);
return resolveModuleSpecifierRecursively(
loader,
importedModule,
specifier.slice(1),
);
}
if (exportValue.type === "styled-component") {
return {
type: "styled-component",
from: module.filePath,
name: specifier[specifier.length - 1],
value: exportValue.value,
};
} else if (exportValue.type === "constant") {
return { type: "constant", value: exportValue.value };
} else if (exportValue.type === "record") {
let current: any = exportValue.value;
let depth = 0;
/// Drill down the specifier e.g. colors.primary
do {
if (typeof current === "string" || typeof current === "number") {
return {
type: "constant" as const,
value: current,
};
} else if (
!current ||
(typeof current !== "object" && !Array.isArray(current))
) {
throw new Error(
`Error unpacking Record/Array "${exportName}".\nKey "${
specifier[depth]
}" was of type "${typeof current}" but only String and Number are supported`,
);
}
depth++;
// mixins in .yak files are wrapped inside an object with a __yak key
if (depth === specifier.length && "__yak" in current) {
return { type: "mixin", value: current["__yak"] };
} else if (depth === specifier.length && "value" in current) {
return { type: "constant", value: current["value"] };
} else {
current = current[specifier[depth]];
}
} while (current);
if (specifier[depth] === undefined) {
throw new Error(
`Error unpacking Record/Array - could not extract \`${specifier
.slice(0, depth)
.join(".")}\` is not a string or number`,
);
}
throw new Error(
`Error unpacking Record/Array - could not extract \`${
specifier[depth]
}\` from \`${specifier.slice(0, depth).join(".")}\``,
);
} else if (exportValue.type === "mixin") {
return { type: "mixin", value: exportValue.value };
}
throw new Error(
`Error unpacking Record/Array - unexpected exportValue "${
exportValue.type
}" for specifier "${specifier.join(".")}"`,
);
} catch (error) {
throw new Error(
`Error resolving from module ${module.filePath}: ${
(error as Error).message
}\nExtracted values: ${JSON.stringify(module.exports, null, 2)}`,
);
}
}
type ParsedFile =
| { type: "regular"; exports: Record<string, ParsedExport>; filePath: string }
| { type: "yak"; exports: Record<string, ParsedExport>; filePath: string };
type ParsedExport =
| { type: "styled-component"; value: string | undefined }
| { type: "mixin"; value: string }
| { type: "constant"; value: string | number }
| { type: "record"; value: Record<any, ParsedExport> | {} }
| { type: "unsupported"; hint?: string }
| { type: "re-export"; from: string; imported: string }
| { type: "star-export"; from: string[] };
type ResolvedExport =
| {
type: "styled-component";
from: string;
name: string;
value: string | undefined;
}
| { type: "mixin"; value: string | number }
| { type: "constant"; value: string | number };