UNPKG

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
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 };