Browse Source

Add support for expressions within template literals (#80)

* 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
add-plugins-support
Giuseppe 8 years ago
committed by Guillermo Rauch
parent
commit
07aa8b5658
  1. 36
      benchmark/babel.js
  2. 0
      benchmark/fixtures/basic.js
  3. 41
      benchmark/fixtures/with-expressions.js
  4. 2
      package.json
  5. 182
      src/babel.js
  6. 22
      test/fixtures/expressions.js
  7. 15
      test/fixtures/expressions.out.js
  8. 8
      test/fixtures/invalid-expressions/1.js
  9. 9
      test/fixtures/invalid-expressions/2.js
  10. 8
      test/fixtures/invalid-expressions/3.js
  11. 20
      test/fixtures/invalid-expressions/4.js
  12. 24
      test/index.js

36
benchmark/babel.js

@ -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)

0
benchmark/fixtures/babel.js → benchmark/fixtures/basic.js

41
benchmark/fixtures/with-expressions.js

@ -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
package.json

@ -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",

182
src/babel.js

@ -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'
@ -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),
[
@ -57,7 +153,7 @@ export default function ({types: t}) {
),
t.JSXAttribute(
t.JSXIdentifier(STYLE_COMPONENT_CSS),
t.JSXExpressionContainer(t.stringLiteral(transformedCss))
t.JSXExpressionContainer(css)
)
],
true
@ -65,7 +161,7 @@ export default function ({types: t}) {
null,
[]
)
)
}
return {
inherits: jsx,
@ -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) {
@ -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
])
}
@ -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
}
@ -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: {

22
test/fixtures/expressions.js

@ -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
test/fixtures/expressions.out.js

@ -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>);

8
test/fixtures/invalid-expressions/1.js

@ -0,0 +1,8 @@
export const Test = (p) => {
return (
<div>
<p>test</p>
<style jsx>{`p { color: ${p.color} }`}</style>
</div>
)
}

9
test/fixtures/invalid-expressions/2.js

@ -0,0 +1,9 @@
export function Test(props) {
const {darken} = props
return (
<div>
<p>test</p>
<style jsx>{`p { color: ${darken(color)} }`}</style>
</div>
)
}

8
test/fixtures/invalid-expressions/3.js

@ -0,0 +1,8 @@
export function Test({color}) {
return (
<div>
<p>test</p>
<style jsx>{`p { color: ${color} }`}</style>
</div>
)
}

20
test/fixtures/invalid-expressions/4.js

@ -0,0 +1,20 @@
export class Test {
test() {
const aaaa = 'red'
return (
<div>
<p>test</p>
<style jsx>{`p { color: ${aaaa} }`}</style>
</div>
)
}
render() {
return (
<div>
<p>test</p>
<style jsx>{`p { color: ${this.props.color} }`}</style>
</div>
)
}
}

24
test/index.js

@ -82,8 +82,25 @@ test('should not add the data-jsx attribute to components instances', async t =>
t.is(code, out.trim())
})
test('works with expressions in template literals', async t => {
const {code} = await transform('./fixtures/expressions.js')
const out = await read('./fixtures/expressions.out.js')
t.is(code, out.trim())
})
test('throws when using `props` or constants ' +
'defined in the closest scope', async t => {
[1, 2, 3, 4].forEach(i => {
t.throws(
transform(`./fixtures/invalid-expressions/${i}.js`),
SyntaxError
)
})
})
test('server rendering', t => {
function App() {
const color = 'green'
return React.createElement('div', null,
React.createElement(JSXStyle, {
css: 'p { color: red }',
@ -92,13 +109,18 @@ test('server rendering', t => {
React.createElement(JSXStyle, {
css: 'div { color: blue }',
styleId: 2
}),
React.createElement(JSXStyle, {
css: `div { color: ${color} }`,
styleId: 3
})
)
}
// expected CSS
const expected = '<style id="__jsx-style-1">p { color: red }</style>' +
'<style id="__jsx-style-2">div { color: blue }</style>'
'<style id="__jsx-style-2">div { color: blue }</style>' +
'<style id="__jsx-style-3">div { color: green }</style>'
// render using react
ReactDOM.renderToString(React.createElement(App))

Loading…
Cancel
Save