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

512 lines (510 loc) 18.2 kB
// loaders/css-loader.ts import { relative } from "path"; // loaders/lib/resolveCrossFileSelectors.ts import babel from "@babel/core"; import path from "path"; import babelPlugin from "@babel/plugin-syntax-typescript"; var yakCssImportRegex = ( // Make mixin and selector non optional once we dropped support for the babel plugin /--yak-css-import\:\s*url\("([^"]+)",?(|mixin|selector)\)(;?)/g ); var compilationCache = /* @__PURE__ */ new WeakMap(); var getCompilationCache = (loader) => { const compilation = loader._compilation; if (!compilation) { throw new Error("Webpack compilation object not available"); } let cache = compilationCache.get(compilation); if (!cache) { cache = { parsedFiles: /* @__PURE__ */ new Map() }; compilationCache.set(compilation, cache); } return cache; }; async function resolveCrossFileConstant(loader, pathContext, 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 { 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; }) ); 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.message} File: ${loader.resourcePath}` ); } } async function resolveModule(loader, moduleSpecifier, context) { return new Promise((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); }); }); } async function parseModule(loader, moduleSpecifier, context) { const cache = getCompilationCache(loader).parsedFiles; const resolvedModule = await resolveModule(loader, moduleSpecifier, context); let parsedFile = cache.get(resolvedModule); if (!parsedFile) { parsedFile = await parseFile(loader, resolvedModule); cache.set(resolvedModule, parsedFile); } loader.addDependency(parsedFile.filePath); return parsedFile; } async function parseFile(loader, filePath) { 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 = await loader.importModule(filePath); const mappedModule = Object.fromEntries( Object.entries(module).map(([key, value]) => { if (typeof value === "string" || typeof value === "number") { return [key, { type: "constant", value }]; } else if (value && (typeof value === "object" || Array.isArray(value))) { return [key, { type: "record", value }]; } else { return [key, { type: "unsupported", hint: String(value) }]; } }) ); return { type: "yak", exports: mappedModule, filePath }; } const sourceContents = new Promise( (resolve, reject) => loader.fs.readFile(filePath, "utf-8", (err, result) => { if (err) return reject(err); resolve(result || ""); }) ); const tranformedSource = new Promise((resolve, reject) => { loader.loadModule(filePath, (err, source) => { if (err) return reject(err); let sourceString; 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)); 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 = 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; 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.message}` ); } } async function parseExports(sourceContents, isTSX) { let exports = {}; try { babel.transformSync(sourceContents, { configFile: false, plugins: [ [babelPlugin, { isTSX }], [ () => ({ 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) => { 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.message}`); } } function parseMixins(sourceContents) { const mixinParts = sourceContents.split("/*YAK EXPORTED MIXIN:"); let mixins = {}; 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) { const styledParts = sourceContents.split("/*YAK EXPORTED STYLED:"); let styledComponents = {}; 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; } function unpackTSAsExpression(node) { if (node.type === "TSAsExpression") { return unpackTSAsExpression(node.expression); } return node; } function parseExportValueExpression(node) { const expression = unpackTSAsExpression(node); if (expression.type === "CallExpression" || expression.type === "TaggedTemplateExpression") { return { type: "styled-component", value: void 0 }; } 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) { let result = {}; for (const property of node.properties) { if (property.type === "ObjectProperty" && property.key.type === "Identifier") { const key = property.key.name; const parsed = parseExportValueExpression( property.value ); if (parsed) { result[key] = parsed; } } } return result; } async function resolveModuleSpecifierRecursively(loader, module, specifier) { try { const exportName = specifier[0]; let exportValue = module.exports[exportName]; if (exportValue === void 0) { 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", from: starExport.from[0], imported: exportName }; } else { throw new Error( `Could not resolve "${specifier.join(".")}" in module ${module.filePath}` ); } } 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) ]); } 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 = exportValue.value; let depth = 0; do { if (typeof current === "string" || typeof current === "number") { return { type: "constant", value: current }; } else if (!current || typeof current !== "object" && !Array.isArray(current)) { throw new Error( `Error unpacking Record/Array "${exportName}". Key "${specifier[depth]}" was of type "${typeof current}" but only String and Number are supported` ); } depth++; 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] === void 0) { 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.message} Extracted values: ${JSON.stringify(module.exports, null, 2)}` ); } } // loaders/css-loader.ts async function cssExtractLoader(_code, sourceMap) { const callback = this.async(); return this.loadModule(this.resourcePath, (err, source) => { if (err) { return callback(err); } if (!source) { return callback( new Error(`Source code for ${this.resourcePath} is empty`) ); } const { experiments } = this.getOptions(); const debugLog = createDebugLogger(this, experiments?.debug); debugLog("ts", source); const css = extractCss(source); debugLog("css", css); return resolveCrossFileConstant(this, this.context, css).then((result) => { debugLog("css resolved", css); return callback(null, result, sourceMap); }, callback); }); } function extractCss(code) { let codeString; if (typeof code === "string") { codeString = code; } else if (code instanceof Buffer) { codeString = code.toString("utf-8"); } else if (code instanceof ArrayBuffer) { codeString = new TextDecoder("utf-8").decode(code); } else { throw new Error( "Invalid input type: code must be string, Buffer, or ArrayBuffer" ); } const codeParts = codeString.split("/*YAK Extracted CSS:\n"); let result = ""; for (let i = 1; i < codeParts.length; i++) { const codeUntilEnd = codeParts[i].split("*/")[0]; result += codeUntilEnd; } if (result) { result = "/* cssmodules-pure-no-check */\n" + result; } return result; } function createDebugLogger(loaderContext, debugOptions) { if (!debugOptions || debugOptions !== true && debugOptions.filter && !debugOptions.filter(loaderContext.resourcePath)) { return () => { }; } const debugType = debugOptions === true ? "ts" : debugOptions.type; return (messageType, message) => { if (messageType === debugType || debugType === "all") { console.log( "\u{1F42E} Yak", messageType, "\n", loaderContext._compiler ? relative( loaderContext._compiler.context, loaderContext.resourcePath ) : loaderContext.resourcePath, "\n\n", message ); } }; } export { cssExtractLoader as default }; //# sourceMappingURL=css-loader.js.map