Skip to content

Commit

Permalink
feat: add support for custom css properties (#348)
Browse files Browse the repository at this point in the history
  • Loading branch information
jquense committed Aug 13, 2019
1 parent b579973 commit 6801760
Show file tree
Hide file tree
Showing 29 changed files with 474 additions and 99 deletions.
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
"git add"
]
},
"importSort": {
".js, .ts, .tsx": {
"style": "@4c/import-sort"
}
},
"prettier": {
"printWidth": 79,
"singleQuote": true,
Expand Down
73 changes: 55 additions & 18 deletions src/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const buildComponent = template(
`styled(ELEMENTTYPE, OPTIONS, {
displayName: DISPLAYNAME,
styles: IMPORT,
attrs: ATTRS
attrs: ATTRS,
vars: VARS
})`,
);

Expand Down Expand Up @@ -84,6 +85,22 @@ const isStyledTagShorthand = (tagPath, { styledTag, allowGlobal }) => {
);
};

const trimExprs = interpolations =>
Array.from(interpolations, ({ expr: _, ...i }) => i);

const toVarsArray = interpolations =>
t.ArrayExpression(
Array.from(interpolations, i =>
t.ArrayExpression(
[
t.StringLiteral(i.id),
i.expr.node,
i.unit && t.StringLiteral(i.unit),
].filter(Boolean),
),
),
);

export default function plugin() {
function createStyleNode(path, identifier, { pluginOptions, file }) {
const { start, end } = path.node;
Expand Down Expand Up @@ -112,12 +129,13 @@ export default function plugin() {

style.code = `require('${style.relativeFilePath}')`;

const { text, imports } = buildTaggedTemplate(
path.get('quasi'),
const { text, imports } = buildTaggedTemplate({
quasiPath: path.get('quasi'),
nodeMap,
style,
opts.pluginOptions,
);
useCssProperties: false,
...opts.pluginOptions,
});

style.value = `${imports}${text}`;

Expand Down Expand Up @@ -155,20 +173,24 @@ export default function plugin() {

style.isStyledComponent = true;

const { text, imports } = buildTaggedTemplate(
path.get('quasi'),
nodeMap,
const { text, dynamicInterpolations, imports } = buildTaggedTemplate({
style,
opts.pluginOptions,
);
nodeMap,
...opts.pluginOptions,
quasiPath: path.get('quasi'),
useCssProperties: pluginOptions.customCssProperties === true,
});

style.imports = imports;
style.interpolations = trimExprs(dynamicInterpolations);
style.value = imports + wrapInClass(text);

const runtimeNode = buildComponent({
ELEMENTTYPE: elementType,
ATTRS: normalizeAttrs(styledAttrs),
OPTIONS: styledOptions || t.NullLiteral(),
DISPLAYNAME: t.StringLiteral(displayName),
VARS: toVarsArray(dynamicInterpolations),
IMPORT: buildImport({
FILENAME: t.StringLiteral(style.relativeFilePath),
}).expression,
Expand Down Expand Up @@ -245,6 +267,7 @@ export default function plugin() {
tagName: 'css',
allowGlobal: true,
styledTag: 'styled',
customCssProperties: 'cssProp', // or: true, false
});
},
exit(path, state) {
Expand Down Expand Up @@ -302,6 +325,7 @@ export default function plugin() {
path.findParent(p => p.isJSXOpeningElement).get('name'),
)}`;

let vars;
const style = createStyleNode(valuePath, displayName, {
pluginOptions,
file: state.file,
Expand All @@ -317,29 +341,42 @@ export default function plugin() {
(exprPath.isTaggedTemplateExpression() &&
isCssTag(exprPath.get('tag'), pluginOptions))
) {
const { text, imports } = buildTaggedTemplate(
exprPath.isTemplateLiteral() ? exprPath : exprPath.get('quasi'),
nodeMap,
const {
text,
imports,
dynamicInterpolations,
} = buildTaggedTemplate({
style,
pluginOptions,
);
nodeMap,
...pluginOptions,
quasiPath: exprPath.isTemplateLiteral()
? exprPath
: exprPath.get('quasi'),
useCssProperties: !!pluginOptions.customCssProperties,
});

vars = toVarsArray(dynamicInterpolations);

style.imports = imports;
style.interpolations = trimExprs(dynamicInterpolations);
style.value = imports + wrapInClass(text);
}
}

if (style.value == null) return;

const importId = addDefault(valuePath, style.relativeFilePath);
const runtimeNode = t.jsxExpressionContainer(
addDefault(valuePath, style.relativeFilePath),
t.arrayExpression([importId, vars].filter(Boolean)),
);

cssState.styles.set(style.absoluteFilePath, style);

if (pluginOptions.generateInterpolations)
style.code = `{${runtimeNode.expression.name}}`;
style.code = `{${importId.name}}`;

cssState.changeset.push({
code: `const ${runtimeNode.expression.name} = require('${style.relativeFilePath}');\n`,
code: `const ${importId.name} = require('${style.relativeFilePath}');\n`,
});

nodeMap.set(runtimeNode.expression, style);
Expand Down
17 changes: 14 additions & 3 deletions src/runtime/styled.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ const camelCase = str =>
'',
);

function varsToStyles(props, vars) {
if (!vars || !vars.length) return props.style;
const style = { ...props.style };
vars.forEach(([id, value, unit = '']) => {
const result = typeof value === 'function' ? value(props) : value;
style[`--${id}`] = `${result}${unit}`;
});
return style;
}

function propsToStyles(props, styles, hasModifiers) {
const componentClassName = styles.cls2 || styles.cls1;
let className = props.className
Expand Down Expand Up @@ -71,7 +81,7 @@ function styled(type, options, settings) {
'ensure that your versions are properly deduped and upgraded. ',
);
}
const { displayName, attrs, styles } = settings;
const { displayName, attrs, vars, styles } = settings;

options = options || { allowAs: typeof type === 'string' };

Expand All @@ -87,7 +97,7 @@ function styled(type, options, settings) {
const childProps = { ...props, ref };

if (allowAs) delete childProps.as;

childProps.style = varsToStyles(childProps, vars);
childProps.className = propsToStyles(childProps, styles, hasModifiers);

return React.createElement(
Expand All @@ -112,7 +122,8 @@ function styled(type, options, settings) {
function jsx(type, props, ...children) {
if (props && props.css) {
const { css, ...childProps } = props;
childProps.className = propsToStyles(childProps, css, true);
childProps.style = varsToStyles(childProps, css[1]);
childProps.className = propsToStyles(childProps, css[0] || css, true);
props = childProps;
}
return React.createElement(type, props, ...children);
Expand Down
99 changes: 69 additions & 30 deletions src/utils/buildTaggedTemplate.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import groupBy from 'lodash/groupBy';
import uniq from 'lodash/uniq';
import resolve from 'resolve';

import cssUnits from './cssUnits';
import getNameFromPath from './getNameFromPath';
import hash from './murmurHash';

const rComposes = /\b(?:composes\s*?:\s*([^;>]*?)(?:from\s(.+?))?(?=[;}/\n\r]))/gim;
const rPlaceholder = /###ASTROTURF_PLACEHOLDER_\d*?###/g;
// Match any valid CSS units followed by a separator such as ;, newline etc.
const rUnit = new RegExp(`^(${cssUnits.join('|')})(;|,|\n| |\\))`);

function defaultResolveDependency({ request }, localStyle) {
const source = resolve.sync(request, {
Expand Down Expand Up @@ -49,7 +53,7 @@ function resolveImport(path) {
return { identifier, request, type: importPath.node.type };
}

function resolveInterpolation(
function resolveStyleInterpolation(
path,
nodeMap,
localStyle,
Expand Down Expand Up @@ -99,60 +103,94 @@ function resolveInterpolation(

const getPlaceholder = idx => `###ASTROTURF_PLACEHOLDER_${idx}###`;

export default (
export default ({
quasiPath,
nodeMap,
localStyle,
{ tagName, resolveDependency },
) => {
tagName,
resolveDependency,
useCssProperties,
style: localStyle,
}) => {
const quasi = quasiPath.node;

const interpolations = new Map();
const styleInterpolations = new Map();
const dynamicInterpolations = new Set();
const expressions = quasiPath.get('expressions');

let text = '';
let lastDynamic = null;

quasi.quasis.forEach((tmplNode, idx) => {
const { cooked } = tmplNode.value;
const expr = expressions[idx];

text += cooked;

if (expr) {
const result = expr.evaluate();
let matches;

// If the last quasi is a replaced dynamic import then see if there
// was a trailing css unit and extract it as part of the interpolation
// eslint-disable-next-line no-cond-assign
if (
lastDynamic &&
text.endsWith(`var(--${lastDynamic.id})`) &&
(matches = cooked.match(rUnit))
) {
const [, unit] = matches;

lastDynamic.unit = unit;
text += cooked.replace(rUnit, '$2');
} else {
text += cooked;
}

if (result.confident) {
text += result.value;
return;
}
if (!expr) {
return;
}

// TODO: dedupe the same expressions in a tag
const interpolation = resolveInterpolation(
expr,
nodeMap,
localStyle,
resolveDependency,
);
const result = expr.evaluate();
if (result.confident) {
text += result.value;
return;
}

if (!interpolation) {
throw expr.buildCodeFrameError(
`Could not resolve interpolation to a value, ${tagName} returned class name, or styled component. ` +
'All interpolated styled components must be in the same file and values must be statically determinable at compile time.',
);
}
// TODO: dedupe the same expressions in a tag
const interpolation = resolveStyleInterpolation(
expr,
nodeMap,
localStyle,
resolveDependency,
);

if (interpolation) {
interpolation.expr = expr;
const ph = getPlaceholder(idx);
interpolations.set(ph, interpolation);
styleInterpolations.set(ph, interpolation);
text += ph;

return;
}

if (!useCssProperties) {
throw expr.buildCodeFrameError(
`Could not resolve interpolation to a value, ${tagName} returned class name, or styled component. ` +
'All interpolated styled components must be in the same file and values must be statically determinable at compile time.',
);
}

// custom properties need to start with a letter
const id = `a${hash(`${localStyle.identifier}-${idx}`)}`;

lastDynamic = { id, expr, unit: '' };
dynamicInterpolations.add(lastDynamic);

text += `var(--${id})`;
});

// Replace references in `composes` rules
text = text.replace(rComposes, (composes, classNames, fromPart) => {
const classList = classNames.replace(/(\n|\r|\n\r)/, '').split(/\s+/);

const composed = classList
.map(className => interpolations.get(className))
.map(className => styleInterpolations.get(className))
.filter(Boolean);

if (!composed.length) return composes;
Expand Down Expand Up @@ -184,7 +222,7 @@ export default (
let id = 0;
let imports = '';
text = text.replace(rPlaceholder, match => {
const { imported, source } = interpolations.get(match);
const { imported, source } = styleInterpolations.get(match);
const localName = `a${id++}`;

imports += `@value ${imported} as ${localName} from "${source}";\n`;
Expand All @@ -196,5 +234,6 @@ export default (
return {
text,
imports,
dynamicInterpolations,
};
};
Loading

0 comments on commit 6801760

Please sign in to comment.