summaryrefslogtreecommitdiff
path: root/@linaria/packages/babel/src/evaluators
diff options
context:
space:
mode:
Diffstat (limited to '@linaria/packages/babel/src/evaluators')
-rw-r--r--@linaria/packages/babel/src/evaluators/buildOptions.ts104
-rw-r--r--@linaria/packages/babel/src/evaluators/index.ts22
-rw-r--r--@linaria/packages/babel/src/evaluators/templateProcessor.ts313
-rw-r--r--@linaria/packages/babel/src/evaluators/visitors/JSXElement.ts56
-rw-r--r--@linaria/packages/babel/src/evaluators/visitors/ProcessCSS.ts20
-rw-r--r--@linaria/packages/babel/src/evaluators/visitors/ProcessStyled.ts46
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(),
+ })
+ );
+}