diff options
Diffstat (limited to '@linaria/packages/babel/src/evaluators')
6 files changed, 561 insertions, 0 deletions
diff --git a/@linaria/packages/babel/src/evaluators/buildOptions.ts b/@linaria/packages/babel/src/evaluators/buildOptions.ts new file mode 100644 index 0000000..a938d4b --- /dev/null +++ b/@linaria/packages/babel/src/evaluators/buildOptions.ts @@ -0,0 +1,104 @@ +/** + * This file handles preparing babel config for Linaria preevaluation. + */ + +import type { PluginItem, TransformOptions } from '@babel/core'; +import type { StrictOptions } from '../types'; + +type DefaultOptions = Partial<TransformOptions> & { + plugins: PluginItem[]; + presets: PluginItem[]; + caller: { evaluate: boolean }; +}; + +export default function buildOptions( + filename: string, + options?: StrictOptions +): TransformOptions { + const plugins: Array<string | object> = [ + // Include these plugins to avoid extra config when using { module: false } for webpack + '@babel/plugin-transform-modules-commonjs', + '@babel/plugin-proposal-export-namespace-from', + ]; + + const defaults: DefaultOptions = { + caller: { name: 'linaria', evaluate: true }, + filename: filename, + presets: [ + [ + require.resolve('../index'), + { + ...(options || {}), + }, + ], + ], + plugins: [ + ...plugins.map((name) => require.resolve(name as string)), + // We don't support dynamic imports when evaluating, but don't wanna syntax error + // This will replace dynamic imports with an object that does nothing + require.resolve('../dynamic-import-noop'), + ], + }; + + const babelOptions = + // Shallow copy the babel options because we mutate it later + options?.babelOptions ? { ...options.babelOptions } : {}; + + // If we programmatically pass babel options while there is a .babelrc, babel might throw + // We need to filter out duplicate presets and plugins so that this doesn't happen + // This workaround isn't full proof, but it's still better than nothing + const keys: Array<keyof TransformOptions & ('presets' | 'plugins')> = [ + 'presets', + 'plugins', + ]; + keys.forEach((field) => { + babelOptions[field] = babelOptions[field] + ? babelOptions[field]!.filter((item: PluginItem) => { + // If item is an array it's a preset/plugin with options ([preset, options]) + // Get the first item to get the preset.plugin name + // Otherwise it's a plugin name (can be a function too) + const name = Array.isArray(item) ? item[0] : item; + + if ( + // In our case, a preset might also be referring to linaria/babel + // We require the file from internal path which is not the same one that we export + // This case won't get caught and the preset won't filtered, even if they are same + // So we add an extra check for top level linaria/babel + name === 'linaria/babel' || + name === '@linaria' || + name === '@linaria/babel-preset' || + name === require.resolve('../index') || + // Also add a check for the plugin names we include for bundler support + plugins.includes(name) + ) { + return false; + } + + // Loop through the default presets/plugins to see if it already exists + return !defaults[field].some((it) => + // The default presets/plugins can also have nested arrays, + Array.isArray(it) ? it[0] === name : it === name + ); + }) + : []; + }); + + return { + // Passed options shouldn't be able to override the options we pass + // Linaria's plugins rely on these (such as filename to generate consistent hash) + ...babelOptions, + ...defaults, + presets: [ + // Preset order is last to first, so add the extra presets to start + // This makes sure that our preset is always run first + ...babelOptions.presets!, + ...defaults.presets, + ], + plugins: [ + ...defaults.plugins, + // Plugin order is first to last, so add the extra presets to end + // This makes sure that the plugins we specify always run first + ...babelOptions.plugins!, + ], + }; +} diff --git a/@linaria/packages/babel/src/evaluators/index.ts b/@linaria/packages/babel/src/evaluators/index.ts new file mode 100644 index 0000000..1d24750 --- /dev/null +++ b/@linaria/packages/babel/src/evaluators/index.ts @@ -0,0 +1,22 @@ +/** + * This file is an entry point for module evaluation for getting lazy dependencies. + */ + +import Module from '../module'; +import type { StrictOptions } from '../types'; + +export default function evaluate( + code: string, + filename: string, + options: StrictOptions +) { + const m = new Module(filename, options); + + m.dependencies = []; + m.evaluate(code, ['__linariaPreval']); + + return { + value: m.exports, + dependencies: m.dependencies, + }; +} diff --git a/@linaria/packages/babel/src/evaluators/templateProcessor.ts b/@linaria/packages/babel/src/evaluators/templateProcessor.ts new file mode 100644 index 0000000..0c910d4 --- /dev/null +++ b/@linaria/packages/babel/src/evaluators/templateProcessor.ts @@ -0,0 +1,313 @@ +/** + * This file handles transforming template literals to class names or styled components and generates CSS content. + * It uses CSS code from template literals and evaluated values of lazy dependencies stored in ValueCache. + */ + +import type { Expression } from '@babel/types'; +import generator from '@babel/generator'; + +import type { StyledMeta } from '@linaria/core'; +import { debug } from '@linaria/logger'; +import { units } from '../units'; +import type { + State, + StrictOptions, + TemplateExpression, + ValueCache, +} from '../types'; + +import isSerializable from '../utils/isSerializable'; +import throwIfInvalid from '../utils/throwIfInvalid'; +import stripLines from '../utils/stripLines'; +import toCSS from '../utils/toCSS'; +import getLinariaComment from '../utils/getLinariaComment'; +import { Core } from '../babel'; + +// Match any valid CSS units followed by a separator such as ;, newline etc. +const unitRegex = new RegExp(`^(${units.join('|')})(;|,|\n| |\\))`); + +type Interpolation = { + id: string; + node: Expression; + source: string; + unit: string; +}; + +function hasMeta(value: any): value is StyledMeta { + return value && typeof value === 'object' && (value as any).__linaria; +} + +const processedPaths = new WeakSet(); + +export default function getTemplateProcessor( + { types: t }: Core, + options: StrictOptions +) { + return function process( + { styled, path }: TemplateExpression, + state: State, + valueCache: ValueCache + ) { + if (processedPaths.has(path)) { + // Do not process an expression + // if it is referenced in one template more than once + return; + } + + processedPaths.add(path); + + const { quasi } = path.node; + + const interpolations: Interpolation[] = []; + + // Check if the variable is referenced anywhere for basic DCE + // Only works when it's assigned to a variable + let isReferenced = true; + + const [, slug, displayName, className] = getLinariaComment(path); + + const parent = path.findParent( + (p) => + t.isObjectProperty(p) || + t.isJSXOpeningElement(p) || + t.isVariableDeclarator(p) + ); + + if (parent) { + const parentNode = parent.node; + if (t.isVariableDeclarator(parentNode) && t.isIdentifier(parentNode.id)) { + const { referencePaths } = path.scope.getBinding( + parentNode.id.name + ) || { referencePaths: [] }; + + isReferenced = referencePaths.length !== 0; + } + } + + // Serialize the tagged template literal to a string + let cssText = ''; + + const expressions = path.get('quasi').get('expressions'); + + quasi.quasis.forEach((el, i, self) => { + let appended = false; + + if (i !== 0 && el.value.cooked) { + // Check if previous expression was a CSS variable that we replaced + // If it has a unit after it, we need to move the unit into the interpolation + // e.g. `var(--size)px` should actually be `var(--size)` + // So we check if the current text starts with a unit, and add the unit to the previous interpolation + // Another approach would be `calc(var(--size) * 1px), but some browsers don't support all units + // https://bugzilla.mozilla.org/show_bug.cgi?id=956573 + const matches = el.value.cooked.match(unitRegex); + + if (matches) { + const last = interpolations[interpolations.length - 1]; + const [, unit] = matches; + + if (last && cssText.endsWith(`var(--${last.id})`)) { + last.unit = unit; + cssText += el.value.cooked.replace(unitRegex, '$2'); + appended = true; + } + } + } + + if (!appended) { + cssText += el.value.cooked; + } + + const ex = expressions[i]; + + if (ex && !ex.isExpression()) { + throw ex.buildCodeFrameError( + `The expression '${generator(ex.node).code}' is not supported.` + ); + } + + if (ex) { + const { end } = ex.node.loc!; + const result = ex.evaluate(); + const beforeLength = cssText.length; + + // The location will be end of the current string to start of next string + const next = self[i + 1]; + const loc = { + // +1 because the expressions location always shows 1 column before + start: { line: el.loc!.end.line, column: el.loc!.end.column + 1 }, + end: next + ? { line: next.loc!.start.line, column: next.loc!.start.column } + : { line: end.line, column: end.column + 1 }, + }; + + if (result.confident) { + throwIfInvalid(result.value, ex); + + if (isSerializable(result.value)) { + // If it's a plain object or an array, convert it to a CSS string + cssText += stripLines(loc, toCSS(result.value)); + } else { + cssText += stripLines(loc, result.value); + } + + state.replacements.push({ + original: loc, + length: cssText.length - beforeLength, + }); + } else { + // Try to preval the value + if ( + options.evaluate && + !(t.isFunctionExpression(ex) || t.isArrowFunctionExpression(ex)) + ) { + const value = valueCache.get(ex.node); + throwIfInvalid(value, ex); + + // Skip the blank string instead of throw ing an error + if (value === '') { + return; + } + + if (value && typeof value !== 'function') { + // Only insert text for non functions + // We don't touch functions because they'll be interpolated at runtime + + if (hasMeta(value)) { + // If it's an React component wrapped in styled, get the class name + // Useful for interpolating components + cssText += `.${value.__linaria.className}`; + } else if (isSerializable(value)) { + cssText += stripLines(loc, toCSS(value)); + } else { + // For anything else, assume it'll be stringified + cssText += stripLines(loc, value); + } + + state.replacements.push({ + original: loc, + length: cssText.length - beforeLength, + }); + + return; + } + } + + if (styled) { + const id = `${slug}-${i}`; + + interpolations.push({ + id, + node: ex.node, + source: ex.getSource() || generator(ex.node).code, + unit: '', + }); + + cssText += `var(--${id})`; + } else { + // CSS custom properties can't be used outside components + throw ex.buildCodeFrameError( + `The CSS cannot contain JavaScript expressions when using the 'css' tag. To evaluate the expressions at build time, pass 'evaluate: true' to the babel plugin.` + ); + } + } + } + }); + + let selector = `.${className}`; + + if (styled) { + // If `styled` wraps another component and not a primitive, + // get its class name to create a more specific selector + // it'll ensure that styles are overridden properly + if (options.evaluate && t.isIdentifier(styled.component.node)) { + let value = valueCache.get(styled.component.node.name); + while (hasMeta(value)) { + selector += `.${value.__linaria.className}`; + value = value.__linaria.extends; + } + } + + const props = []; + + props.push( + t.objectProperty(t.identifier('name'), t.stringLiteral(displayName!)) + ); + + props.push( + t.objectProperty(t.identifier('class'), t.stringLiteral(className!)) + ); + + // If we found any interpolations, also pass them so they can be applied + if (interpolations.length) { + // De-duplicate interpolations based on the source and unit + // If two interpolations have the same source code and same unit, + // we don't need to use 2 custom properties for them, we can use a single one + const result: { [key: string]: Interpolation } = {}; + + interpolations.forEach((it) => { + const key = it.source + it.unit; + + if (key in result) { + cssText = cssText.replace( + `var(--${it.id})`, + `var(--${result[key].id})` + ); + } else { + result[key] = it; + } + }); + + props.push( + t.objectProperty( + t.identifier('vars'), + t.objectExpression( + Object.keys(result).map((key) => { + const { id, node, unit } = result[key]; + const items = [node]; + + if (unit) { + items.push(t.stringLiteral(unit)); + } + + return t.objectProperty( + t.stringLiteral(id), + t.arrayExpression(items) + ); + }) + ) + ) + ); + } + + path.replaceWith( + t.callExpression( + t.callExpression( + t.identifier(state.file.metadata.localName || 'styled'), + [styled.component.node] + ), + [t.objectExpression(props)] + ) + ); + + path.addComment('leading', '#__PURE__'); + } else { + path.replaceWith(t.stringLiteral(className!)); + } + + if (!isReferenced && !cssText.includes(':global')) { + return; + } + + debug( + 'evaluator:template-processor:extracted-rule', + `\n${selector} {${cssText}\n}` + ); + + state.rules[selector] = { + cssText, + className: className!, + displayName: displayName!, + start: path.parent?.loc?.start ?? null, + }; + }; +} diff --git a/@linaria/packages/babel/src/evaluators/visitors/JSXElement.ts b/@linaria/packages/babel/src/evaluators/visitors/JSXElement.ts new file mode 100644 index 0000000..89a0ccd --- /dev/null +++ b/@linaria/packages/babel/src/evaluators/visitors/JSXElement.ts @@ -0,0 +1,56 @@ +import { types as t } from '@babel/core'; +import type { NodePath } from '@babel/traverse'; +import type { Function, JSXElement as JSXElementNode } from '@babel/types'; + +function getFunctionName(path: NodePath<Function>): string | null { + if (path.isClassMethod() && t.isIdentifier(path.node.key)) { + return path.node.key.name; + } + + return null; +} + +export default function JSXElement(path: NodePath<JSXElementNode>) { + // JSX can be safely replaced on an empty fragment because it is unnecessary for styles + const emptyFragment = t.jsxFragment( + t.jsxOpeningFragment(), + t.jsxClosingFragment(), + [] + ); + + // We can do even more + // If that JSX is a result of a function, we can replace the function body. + const scopePath = path.scope.path; + if (scopePath.isFunction()) { + const emptyBody = t.blockStatement([t.returnStatement(emptyFragment)]); + + // Is it not just a function, but a method `render`? + if (getFunctionName(scopePath) === 'render') { + const decl = scopePath.findParent((p) => p.isClassDeclaration()); + + // Replace the whole component + if (decl?.isClassDeclaration()) { + decl.replaceWith(t.functionDeclaration(decl.node.id, [], emptyBody)); + + return; + } + } + + const body = scopePath.get('body'); + if (Array.isArray(body)) { + throw new Error( + `A body of a function is expected to be a single element but an array was returned. It's possible if JS syntax has been changed since that code was written.` + ); + } + + const node: typeof scopePath.node = { + ...scopePath.node, + body: emptyBody, + params: [], + }; + + scopePath.replaceWith(node); + } else { + path.replaceWith(emptyFragment); + } +} diff --git a/@linaria/packages/babel/src/evaluators/visitors/ProcessCSS.ts b/@linaria/packages/babel/src/evaluators/visitors/ProcessCSS.ts new file mode 100644 index 0000000..6f737a1 --- /dev/null +++ b/@linaria/packages/babel/src/evaluators/visitors/ProcessCSS.ts @@ -0,0 +1,20 @@ +/** + * This visitor replaces css tag with the generated className + * + */ + +import { types as t } from '@babel/core'; +import type { NodePath } from '@babel/traverse'; +import type { TaggedTemplateExpression } from '@babel/types'; +import getLinariaComment from '../../utils/getLinariaComment'; + +export default function ProcessCSS(path: NodePath<TaggedTemplateExpression>) { + if (t.isIdentifier(path.node.tag) && path.node.tag.name === 'css') { + const [, , , className] = getLinariaComment(path); + if (!className) { + return; + } + + path.replaceWith(t.stringLiteral(className)); + } +} diff --git a/@linaria/packages/babel/src/evaluators/visitors/ProcessStyled.ts b/@linaria/packages/babel/src/evaluators/visitors/ProcessStyled.ts new file mode 100644 index 0000000..a9e38c5 --- /dev/null +++ b/@linaria/packages/babel/src/evaluators/visitors/ProcessStyled.ts @@ -0,0 +1,46 @@ +/** + * This visitor replaces styled components with metadata about them. + * CallExpression should be used to match styled components. + * Works out of the box for styled that wraps other component, + * styled.tagName are transformed to call expressions using @babel/plugin-transform-template-literals + * @babel/plugin-transform-template-literals is loaded as a prest, to force proper ordering. It has to run just after linaria. + * It is used explicitly in extractor, and loaded as a part of `prest-env` in shaker + */ + +import { types as t } from '@babel/core'; +import type { NodePath } from '@babel/traverse'; +import type { CallExpression } from '@babel/types'; +import { expression } from '@babel/template'; +import getLinariaComment from '../../utils/getLinariaComment'; + +const linariaComponentTpl = expression( + `{ + displayName: %%displayName%%, + __linaria: { + className: %%className%%, + extends: %%extends%% + } + }` +); + +export default function ProcessStyled(path: NodePath<CallExpression>) { + const [type, , displayName, className] = getLinariaComment(path); + if (!className) { + return; + } + + if (type === 'css') { + path.replaceWith(t.stringLiteral(className)); + return; + } + + path.replaceWith( + linariaComponentTpl({ + className: t.stringLiteral(className), + displayName: displayName ? t.stringLiteral(displayName) : null, + extends: t.isCallExpression(path.node.callee) + ? path.node.callee.arguments[0] + : t.nullLiteral(), + }) + ); +} |