Skip to content

Commit

Permalink
Add support for expressions within template literals (#80)
Browse files Browse the repository at this point in the history
* Add support for expressions within template literals

* Add support for expressions in selectors and media queries

* Replace longer expressions first to avoid substring replacements

* Destructure param in findStyles

* Throw upon usage of vars from the closure

* Use babylon and babel-traverse instead of babel-core

* findStyles should return a path

* Refactor benchmark and add expressions

* Fix typo
  • Loading branch information
giuseppeg authored and rauchg committed Jan 21, 2017
1 parent ea41e59 commit 07aa8b5
Show file tree
Hide file tree
Showing 12 changed files with 327 additions and 40 deletions.
36 changes: 23 additions & 13 deletions benchmark/babel.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import {readFileSync} from 'fs'
import {resolve} from 'path'
import Benchmark from 'benchmark'
import {Suite} from 'benchmark'
import {transform as babel} from 'babel-core'

import plugin from '../src/babel'

const read = path => readFileSync(resolve(__dirname, path), 'utf8')
const fixture = read('./fixtures/babel.js')
const makeTransform = fixturePath => {
const fixture = readFileSync(
resolve(__dirname, fixturePath),
'utf8'
)

module.exports = new Benchmark({
name: 'Babel transform',
minSamples: 500,
fn: () => {
babel(fixture, {
babelrc: false,
plugins: [plugin]
})
}
})
return () => babel(fixture, {
babelrc: false,
plugins: [plugin]
})
}

const benchs = {
basic: makeTransform('./fixtures/basic.js'),
withExpressions: makeTransform('./fixtures/with-expressions.js')
}

const suite = new Suite('styled-jsx Babel transform')

module.exports =
suite
.add('basic', benchs.basic)
.add('with expressions', benchs.withExpressions)
File renamed without changes.
41 changes: 41 additions & 0 deletions benchmark/fixtures/with-expressions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const c = 'red'
const color = i => i

export const Test1 = () => (
<div>
<span>test</span>
<span>test</span>
<p></p><p></p><p></p><p></p><p></p><p></p><p></p>
<p><span></span></p><p><span></span></p><p><span></span></p>
<p></p><p></p><p></p><p></p><p></p><p></p><p></p>
<p><span></span></p><p><span></span></p><p><span></span></p>
<p></p><p></p><p></p><p></p><p></p><p></p><p></p>
<p><span></span></p><p><span></span></p><p><span></span></p>
<Component />
<style jsx>{`
span { color: red; }
p { color: ${c}; }
`}</style>
</div>
)

export const Test2 = () => <span>test</span>

export default class {
render() {
return (
<div>
<p>test</p>
<style jsx>{`
p { color: ${color(c)}; }
`}</style>
<style jsx>{`
p { color: red; }
`}</style>
<style jsx>{`
p { color: red; }
`}</style>
</div>
)
}
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
],
"dependencies": {
"babel-plugin-syntax-jsx": "^6.18.0",
"babel-traverse": "^6.21.0",
"babylon": "^6.14.1",
"convert-source-map": "^1.3.0",
"object.entries": "^1.0.4",
"source-map": "^0.5.6",
Expand Down
182 changes: 156 additions & 26 deletions src/babel.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import jsx from 'babel-plugin-syntax-jsx'
import hash from 'string-hash'
import {SourceMapGenerator} from 'source-map'
import convert from 'convert-source-map'
import traverse from 'babel-traverse'
import {parse} from 'babylon'

// Ours
import transform from '../lib/style-transform'
Expand Down Expand Up @@ -31,23 +33,117 @@ export default function ({types: t}) {
if (isStyledJsx(path)) {
const {node} = path
return isGlobalEl(node.openingElement) ?
[node] : []
[path] : []
}

return path.get('children')
.filter(isStyledJsx)
.map(({node}) => node)
return path.get('children').filter(isStyledJsx)
}

const getExpressionText = expr => (
t.isTemplateLiteral(expr) ?
expr.quasis[0].value.raw :
// assume string literal
expr.value
)
// We only allow constants to be used in template literals.
// The following visitor ensures that MemberExpressions and Identifiers
// are not in the scope of the current Method (render) or function (Component).
const validateExpressionVisitor = {
MemberExpression(path) {
const {node} = path
if (
t.isThisExpression(node.object) &&
t.isIdentifier(node.property) &&
(
node.property.name === 'props' ||
node.property.name === 'state'
)
) {
throw path.buildCodeFrameError(
`Expected a constant ` +
`as part of the template literal expression ` +
`(eg: <style jsx>{\`p { color: $\{myColor}\`}</style>), ` +
`but got a MemberExpression: this.${node.property.name}`)
}
},
Identifier(path, scope) {
const {name} = path.node
if (scope.hasOwnBinding(name)) {
throw path.buildCodeFrameError(
`Expected \`${name}\` ` +
`to not come from the closest scope.\n` +
`Styled JSX encourages the use of constants ` +
`instead of \`props\` or dynamic values ` +
`which are better set via inline styles or \`className\` toggling. ` +
`See https://github.com/zeit/styled-jsx#dynamic-styles`)
}
}
}

const getExpressionText = expr => {
const node = expr.node

const makeStyledJsxTag = (id, transformedCss) => (
t.JSXElement(
// assume string literal
if (t.isStringLiteral(node)) {
return node.value
}

const expressions = expr.get('expressions')

// simple template literal without expressions
if (expressions.length === 0) {
return node.quasis[0].value.cooked
}

// Special treatment for template literals that contain expressions:
//
// Expressions are replaced with a placeholder
// so that the CSS compiler can parse and
// transform the css source string
// without having to know about js literal expressions.
// Later expressions are restored
// by doing a replacement on the transformed css string.
//
// e.g.
// p { color: ${myConstant}; }
// becomes
// p { color: ___styledjsxexpression0___; }

const replacements = expressions.map((e, id) => ({
replacement: `___styledjsxexpression_${id}___`,
initial: `$\{${e.getSource()}}`
})).sort((a, b) => a.initial.length < b.initial.length)

const source = expr.getSource().slice(1, -1)

const modified = replacements.reduce((source, currentReplacement) => {
source = source.replace(
currentReplacement.initial,
currentReplacement.replacement
)
return source
}, source)

return {
source,
modified,
replacements
}
}

const makeStyledJsxTag = (id, transformedCss, isTemplateLiteral) => {
let css
if (isTemplateLiteral) {
// build the expression from transformedCss
traverse(
parse(`\`${transformedCss}\``),
{
TemplateLiteral(path) {
if (!css) {
css = path.node
}
}
}
)
} else {
css = t.stringLiteral(transformedCss)
}

return t.JSXElement(
t.JSXOpeningElement(
t.JSXIdentifier(STYLE_COMPONENT),
[
Expand All @@ -57,15 +153,15 @@ export default function ({types: t}) {
),
t.JSXAttribute(
t.JSXIdentifier(STYLE_COMPONENT_CSS),
t.JSXExpressionContainer(t.stringLiteral(transformedCss))
t.JSXExpressionContainer(css)
)
],
true
),
null,
[]
)
)
}

return {
inherits: jsx,
Expand Down Expand Up @@ -127,12 +223,18 @@ export default function ({types: t}) {

state.styles = []

const scope = (path.findParent(path => (
path.isFunctionDeclaration() ||
path.isArrowFunctionExpression() ||
path.isClassMethod()
)) || path).scope

for (const style of styles) {
// compute children excluding whitespace
const children = style.children.filter(c => (
t.isJSXExpressionContainer(c) ||
const children = style.get('children').filter(c => (
t.isJSXExpressionContainer(c.node) ||
// ignore whitespace around the expression container
(t.isJSXText(c) && c.value.trim() !== '')
(t.isJSXText(c.node) && c.node.value.trim() !== '')
))

if (children.length !== 1) {
Expand All @@ -149,23 +251,27 @@ export default function ({types: t}) {
`(eg: <style jsx>{\`hi\`}</style>), got ${child.type}`)
}

const expression = child.expression
const expression = child.get('expression')

if (!t.isTemplateLiteral(child.expression) &&
!t.isStringLiteral(child.expression)) {
if (!t.isTemplateLiteral(expression) &&
!t.isStringLiteral(expression)) {
throw path.buildCodeFrameError(`Expected a template ` +
`literal or String literal as the child of the ` +
`JSX Style tag (eg: <style jsx>{\`some css\`}</style>),` +
` but got ${expression.type}`)
}

// Validate MemberExpressions and Identifiers
// to ensure that are constants not defined in the closest scope
child.get('expression').traverse(validateExpressionVisitor, scope)

const styleText = getExpressionText(expression)
const styleId = hash(styleText)
const styleId = hash(styleText.source || styleText)

state.styles.push([
styleId,
styleText,
expression.loc
expression.node.loc
])
}

Expand All @@ -190,7 +296,7 @@ export default function ({types: t}) {
const [id, css, loc] = state.styles.shift()

if (isGlobal) {
path.replaceWith(makeStyledJsxTag(id, css))
path.replaceWith(makeStyledJsxTag(id, css.source || css, css.modified))
return
}

Expand All @@ -205,17 +311,41 @@ export default function ({types: t}) {
})
generator.setSourceContent(filename, state.file.code)
transformedCss = [
transform(String(state.jsxId), css, generator, loc.start, filename),
transform(
String(state.jsxId),
css.modified || css,
generator,
loc.start,
filename
),
convert
.fromObject(generator)
.toComment({multiline: true}),
`/*@ sourceURL=${filename} */`
].join('\n')
} else {
transformedCss = transform(String(state.jsxId), css)
transformedCss = transform(
String(state.jsxId),
css.modified || css
)
}

path.replaceWith(makeStyledJsxTag(id, transformedCss))
if (css.modified) {
transformedCss = css.replacements.reduce(
(transformedCss, currentReplacement) => {
transformedCss = transformedCss.replace(
currentReplacement.replacement,
currentReplacement.initial
)
return transformedCss
},
transformedCss
)
}

path.replaceWith(
makeStyledJsxTag(id, transformedCss, css.modified)
)
}
},
Program: {
Expand Down
22 changes: 22 additions & 0 deletions test/fixtures/expressions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const color = 'red'
const otherColor = 'green'
const mediumScreen = '680px'

export default () => (
<div>
<p>test</p>
<style jsx>{`p.${color} { color: ${otherColor} }`}</style>
<style jsx>{'p { color: red }'}</style>
<style jsx global>{`body { background: ${color} }`}</style>
<style jsx>{`p { color: ${color} }`}</style>
<style jsx>{`p { color: ${darken(color)} }`}</style>
<style jsx>{`p { color: ${darken(color) + 2} }`}</style>
<style jsx>{`
@media (min-width: ${mediumScreen}) {
p { color: green }
p { color ${`red`}}
}
p { color: red }`
}</style>
</div>
)
15 changes: 15 additions & 0 deletions test/fixtures/expressions.out.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import _JSXStyle from 'styled-jsx/style';
const color = 'red';
const otherColor = 'green';
const mediumScreen = '680px';

export default (() => <div data-jsx={2520901095}>
<p data-jsx={2520901095}>test</p>
<_JSXStyle styleId={414042974} css={`p.${ color }[data-jsx="2520901095"] {color: ${ otherColor } }`} />
<_JSXStyle styleId={188072295} css={"p[data-jsx=\"2520901095\"] {color: red }"} />
<_JSXStyle styleId={806016056} css={`body { background: ${ color } }`} />
<_JSXStyle styleId={924167211} css={`p[data-jsx="2520901095"] {color: ${ color } }`} />
<_JSXStyle styleId={3469794077} css={`p[data-jsx="2520901095"] {color: ${ darken(color) } }`} />
<_JSXStyle styleId={945380644} css={`p[data-jsx="2520901095"] {color: ${ darken(color) + 2 } }`} />
<_JSXStyle styleId={4106311606} css={`@media (min-width: ${ mediumScreen }) {p[data-jsx="2520901095"] {color: green }p[data-jsx="2520901095"] {color ${ `red` }}}p[data-jsx="2520901095"] {color: red }`} />
</div>);
Loading

0 comments on commit 07aa8b5

Please sign in to comment.