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
JavaScript
// 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