diff --git a/readme.md b/readme.md
index 7cff3195..f2c2119c 100644
--- a/readme.md
+++ b/readme.md
@@ -42,12 +42,18 @@ export default () => (
)
```
+### Options
+
+* `sourceMaps` generates source maps (default: `false`)
+* `vendorPrefix` turn on/off automatic vendor prefixing (default: `true`)
+
## Features
- Full CSS support, no tradeoffs in power
- Runtime size of just **2kb** (gzipped, from 6kb)
- Complete isolation: Selectors, animations, keyframes
- Built-in CSS vendor prefixing
+- CSS Preprocessing via Plugins
- Very fast, minimal and efficient transpilation (see below)
- High-performance runtime-CSS-injection when not server-rendering
- Future-proof: Equivalent to server-renderable "Shadow CSS"
@@ -113,6 +119,82 @@ module.exports = `div { color: green; }`
// module.exports = { styles: `div { color: green; }` }
```
+### CSS Preprocessing via Plugins
+
+Styles can be preprocessed via plugins.
+
+Plugins are regular JavaScript modules that export a simple function with the following signature:
+
+```js
+(css: string, settings: Object) => string
+```
+
+Basically they accept a CSS string in input, optionally modify it and finally return it.
+
+Plugins make it possible to use popular preprocessors like SASS, Less, Stylus, PostCSS or apply custom transformations to the styles.
+
+To register a plugin add an option `plugins` for `styled-jsx/babel` to your `.babelrc`. `plugins` must be an array of module names or *full* paths for local plugins.
+
+```json
+{
+ "plugins": [
+ [
+ "styled-jsx/babel",
+ { "plugins": ["my-styled-jsx-plugin-package", "/full/path/to/local/plugin"] }
+ ]
+ ]
+}
+```
+
+In order to resolve local plugins paths you can use NodeJS' [require.resolve](https://nodejs.org/api/globals.html#globals_require_resolve).
+
+Plugins are applied in definition order left to right before styles are scoped.
+
+N.B. when applying the plugins styled-jsx replaces template literals expressions with placeholders e.g. `%%styledjsxexpression_ExprNumber%%` because otherwise CSS parsers would get invalid CSS.
+
+#### Plugin options and settings
+
+Users can set plugin options by registering a plugin as an array that contains
+the plugin path and an options object.
+
+```json
+{
+ "plugins": [
+ [
+ "styled-jsx/babel",
+ {
+ "plugins": [
+ ["my-styled-jsx-plugin-package", { "exampleOption": true }]
+ ],
+ "sourceMaps": true
+ }
+ ]
+ ]
+}
+```
+
+Each plugin receives a `settings` object as second argument which contains
+the babel and user options:
+
+```js
+(css, settings) => { /* ... */ }
+```
+
+The `settings` object has the following shape:
+
+```js
+{
+ // babel options
+ sourceMaps: true,
+ vendorPrefix: true,
+
+ // user options
+ options: {
+ exampleOption: true
+ }
+}
+```
+
### Targeting The Root
Notice that the parent `
` above also gets a `data-jsx` attribute. We do this so that
diff --git a/src/_utils.js b/src/_utils.js
index e33467e8..c18d8f7c 100644
--- a/src/_utils.js
+++ b/src/_utils.js
@@ -258,3 +258,52 @@ export const addSourceMaps = (code, generator, filename) =>
convert.fromObject(generator).toComment({ multiline: true }),
`/*@ sourceURL=${filename} */`
].join('\n')
+
+export const combinePlugins = (plugins, opts) => {
+ if (!plugins) {
+ return css => css
+ }
+
+ if (
+ !Array.isArray(plugins) ||
+ plugins.some(p => !Array.isArray(p) && typeof p !== 'string')
+ ) {
+ throw new Error(
+ '`plugins` must be an array of plugins names (string) or an array `[plugin-name, {options}]`'
+ )
+ }
+
+ return plugins
+ .map((plugin, i) => {
+ let options = {}
+ if (Array.isArray(plugin)) {
+ options = plugin[1] || {}
+ plugin = plugin[0]
+ }
+
+ // eslint-disable-next-line import/no-dynamic-require
+ let p = require(plugin)
+ if (p.default) {
+ p = p.default
+ }
+
+ const type = typeof p
+ if (type !== 'function') {
+ throw new Error(
+ `Expected plugin ${plugins[i]} to be a function but instead got ${type}`
+ )
+ }
+ return {
+ plugin: p,
+ settings: {
+ ...opts,
+ options
+ }
+ }
+ })
+ .reduce(
+ (previous, { plugin, settings }) => css =>
+ plugin(previous ? previous(css) : css, settings),
+ null
+ )
+}
diff --git a/src/babel-external.js b/src/babel-external.js
index 14aecac9..c4bcc1b4 100644
--- a/src/babel-external.js
+++ b/src/babel-external.js
@@ -9,9 +9,11 @@ import {
makeStyledJsxCss,
isValidCss,
makeSourceMapGenerator,
- addSourceMaps
+ addSourceMaps,
+ combinePlugins
} from './_utils'
+let plugins
const getCss = (path, validate = false) => {
if (!path.isTemplateLiteral() && !path.isStringLiteral()) {
return
@@ -24,7 +26,6 @@ const getCss = (path, validate = false) => {
}
const getStyledJsx = (css, opts, path) => {
- const useSourceMaps = Boolean(opts.sourceMaps)
const commonHash = hash(css.modified || css)
const globalHash = `1${commonHash}`
const scopedHash = `2${commonHash}`
@@ -34,16 +35,17 @@ const getStyledJsx = (css, opts, path) => {
const prefix = `[${MARKUP_ATTRIBUTE_EXTERNAL}~="${scopedHash}"]`
const isTemplateLiteral = Boolean(css.modified)
- if (useSourceMaps) {
+ if (opts.sourceMaps) {
const generator = makeSourceMapGenerator(opts.file)
const filename = opts.sourceFileName
const offset = path.get('loc').node.start
compiledCss = [/* global */ '', prefix].map(prefix =>
addSourceMaps(
- transform(prefix, css.modified || css, {
+ transform(prefix, opts.plugins(css.modified || css), {
generator,
offset,
- filename
+ filename,
+ vendorPrefix: opts.vendorPrefix
}),
generator,
filename
@@ -51,7 +53,9 @@ const getStyledJsx = (css, opts, path) => {
)
} else {
compiledCss = ['', prefix].map(prefix =>
- transform(prefix, css.modified || css)
+ transform(prefix, opts.plugins(css.modified || css), {
+ vendorPrefix: opts.vendorPrefix
+ })
)
}
globalCss = compiledCss[0]
@@ -156,15 +160,28 @@ const callVisitor = (visitor, path, state) => {
const { opts } = file
visitor(path, {
validate: state.opts.validate || opts.validate,
- sourceMaps: opts.sourceMaps,
+ sourceMaps: state.opts.sourceMaps || opts.sourceMaps,
sourceFileName: opts.sourceFileName,
- file
+ file,
+ plugins,
+ vendorPrefix: state.opts.vendorPrefix
})
}
export default function() {
return {
visitor: {
+ Program(path, state) {
+ if (!plugins) {
+ const { sourceMaps, vendorPrefix } = state.opts
+ plugins = combinePlugins(state.opts.plugins, {
+ sourceMaps: sourceMaps || state.file.opts.sourceMaps,
+ vendorPrefix: typeof vendorPrefix === 'boolean'
+ ? vendorPrefix
+ : true
+ })
+ }
+ },
ExportDefaultDeclaration(path, state) {
callVisitor(exportDefaultDeclarationVisitor, path, state)
},
diff --git a/src/babel.js b/src/babel.js
index c90c55af..d4c8e37a 100644
--- a/src/babel.js
+++ b/src/babel.js
@@ -20,7 +20,8 @@ import {
validateExpression,
generateAttribute,
makeSourceMapGenerator,
- addSourceMaps
+ addSourceMaps,
+ combinePlugins
} from './_utils'
import {
@@ -29,15 +30,18 @@ import {
MARKUP_ATTRIBUTE_EXTERNAL
} from './_constants'
+let plugins
const getPrefix = id => `[${MARKUP_ATTRIBUTE}="${id}"]`
const callExternalVisitor = (visitor, path, state) => {
const { file } = state
const { opts } = file
visitor(path, {
validate: true,
- sourceMaps: opts.sourceMaps,
+ sourceMaps: state.opts.sourceMaps || opts.sourceMaps,
sourceFileName: opts.sourceFileName,
- file
+ file,
+ plugins,
+ vendorPrefix: state.opts.vendorPrefix
})
}
@@ -292,7 +296,9 @@ export default function({ types: t }) {
// We replace styles with the function call
const [id, css, loc] = state.styles.shift()
- const useSourceMaps = Boolean(state.file.opts.sourceMaps)
+ const useSourceMaps = Boolean(
+ state.opts.sourceMaps || state.file.opts.sourceMaps
+ )
let transformedCss
if (useSourceMaps) {
@@ -301,11 +307,12 @@ export default function({ types: t }) {
transformedCss = addSourceMaps(
transform(
isGlobal ? '' : getPrefix(state.jsxId),
- css.modified || css,
+ plugins(css.modified || css),
{
generator,
offset: loc.start,
- filename
+ filename,
+ vendorPrefix: state.opts.vendorPrefix
}
),
generator,
@@ -314,7 +321,10 @@ export default function({ types: t }) {
} else {
transformedCss = transform(
isGlobal ? '' : getPrefix(state.jsxId),
- css.modified || css
+ plugins(css.modified || css),
+ {
+ vendorPrefix: state.opts.vendorPrefix
+ }
)
}
@@ -334,6 +344,15 @@ export default function({ types: t }) {
state.ignoreClosing = null
state.file.hasJSXStyle = false
state.imports = []
+ if (!plugins) {
+ const { sourceMaps, vendorPrefix } = state.opts
+ plugins = combinePlugins(state.opts.plugins, {
+ sourceMaps: sourceMaps || state.file.opts.sourceMaps,
+ vendorPrefix: typeof vendorPrefix === 'boolean'
+ ? vendorPrefix
+ : true
+ })
+ }
},
exit({ node, scope }, state) {
if (!(state.file.hasJSXStyle && !scope.hasBinding(STYLE_COMPONENT))) {
diff --git a/src/lib/style-transform.js b/src/lib/style-transform.js
index 16a542d0..679cea8e 100644
--- a/src/lib/style-transform.js
+++ b/src/lib/style-transform.js
@@ -82,6 +82,11 @@ function transform(prefix, styles, settings = {}) {
generator = settings.generator
offset = settings.offset
filename = settings.filename
+ stylis.set({
+ prefix: typeof settings.vendorPrefix === 'boolean'
+ ? settings.vendorPrefix
+ : true
+ })
return stylis(prefix, styles)
}
diff --git a/test/__snapshots__/index.js.snap b/test/__snapshots__/index.js.snap
index 3c632c15..2040f3aa 100644
--- a/test/__snapshots__/index.js.snap
+++ b/test/__snapshots__/index.js.snap
@@ -1,5 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`generates source maps (babel options) 1`] = `
+"import _JSXStyle from 'styled-jsx/style';
+export default (() =>
+
test
+
woot
+ <_JSXStyle styleId={188072295} css={\\"p[data-jsx=\\\\\\"188072295\\\\\\"]{color:red}\\\\n/*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInNvdXJjZS1tYXBzLmpzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUlnQixBQUNjLFdBQUMiLCJmaWxlIjoic291cmNlLW1hcHMuanMiLCJzb3VyY2VzQ29udGVudCI6WyJleHBvcnQgZGVmYXVsdCAoKSA9PiAoXG4gIDxkaXY+XG4gICAgPHA+dGVzdDwvcD5cbiAgICA8cD53b290PC9wPlxuICAgIDxzdHlsZSBqc3g+eydwIHsgY29sb3I6IHJlZCB9J308L3N0eWxlPlxuICA8L2Rpdj5cbilcbiJdfQ== */\\\\n/*@ sourceURL=source-maps.js */\\"} />
+
);"
+`;
+
+exports[`generates source maps (plugin options) 1`] = `
+"import _JSXStyle from 'styled-jsx/style';
+export default (() =>
+
test
+
woot
+ <_JSXStyle styleId={188072295} css={\\"p[data-jsx=\\\\\\"188072295\\\\\\"]{color:red}\\\\n/*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInNvdXJjZS1tYXBzLmpzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUlnQixBQUNjLFdBQUMiLCJmaWxlIjoic291cmNlLW1hcHMuanMiLCJzb3VyY2VzQ29udGVudCI6WyJleHBvcnQgZGVmYXVsdCAoKSA9PiAoXG4gIDxkaXY+XG4gICAgPHA+dGVzdDwvcD5cbiAgICA8cD53b290PC9wPlxuICAgIDxzdHlsZSBqc3g+eydwIHsgY29sb3I6IHJlZCB9J308L3N0eWxlPlxuICA8L2Rpdj5cbilcbiJdfQ== */\\\\n/*@ sourceURL=source-maps.js */\\"} />
+
);"
+`;
+
exports[`generates source maps 1`] = `
"import _JSXStyle from 'styled-jsx/style';
export default (() =>
diff --git a/test/__snapshots__/plugins.js.snap b/test/__snapshots__/plugins.js.snap
new file mode 100644
index 00000000..d99457c3
--- /dev/null
+++ b/test/__snapshots__/plugins.js.snap
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`applies plugins 1`] = `
+"import _JSXStyle from 'styled-jsx/style';
+import styles from './styles';
+const color = 'red';
+
+export default (() =>
+
test
+ <_JSXStyle styleId={4216192053} css={\`span.\${color}[data-jsx=\\"4216192053\\"]{color:\${otherColor}}\`} />
+ <_JSXStyle styleId={styles.__scopedHash} css={styles.__scoped} />
+
);"
+`;
diff --git a/test/fixtures/plugins/another-plugin.js b/test/fixtures/plugins/another-plugin.js
new file mode 100644
index 00000000..c90c5381
--- /dev/null
+++ b/test/fixtures/plugins/another-plugin.js
@@ -0,0 +1 @@
+export default css => css.replace(/div/g, 'span')
diff --git a/test/fixtures/plugins/invalid-plugin.js b/test/fixtures/plugins/invalid-plugin.js
new file mode 100644
index 00000000..aa3b6937
--- /dev/null
+++ b/test/fixtures/plugins/invalid-plugin.js
@@ -0,0 +1 @@
+export default 'invalid'
diff --git a/test/fixtures/plugins/options.js b/test/fixtures/plugins/options.js
new file mode 100644
index 00000000..d0c4376d
--- /dev/null
+++ b/test/fixtures/plugins/options.js
@@ -0,0 +1 @@
+export default (css, settings) => settings.options.test
diff --git a/test/fixtures/plugins/plugin.js b/test/fixtures/plugins/plugin.js
new file mode 100644
index 00000000..ca989db6
--- /dev/null
+++ b/test/fixtures/plugins/plugin.js
@@ -0,0 +1 @@
+export default css => `TEST (${css}) EOTEST`
diff --git a/test/fixtures/with-plugins.js b/test/fixtures/with-plugins.js
new file mode 100644
index 00000000..29f8ace3
--- /dev/null
+++ b/test/fixtures/with-plugins.js
@@ -0,0 +1,10 @@
+import styles from './styles'
+const color = 'red'
+
+export default () => (
+
+)
diff --git a/test/fixtures/with-plugins.out.js b/test/fixtures/with-plugins.out.js
new file mode 100644
index 00000000..4b5c861a
--- /dev/null
+++ b/test/fixtures/with-plugins.out.js
@@ -0,0 +1,9 @@
+import _JSXStyle from 'styled-jsx/style';
+import styles from './styles';
+const color = 'red';
+
+export default (() =>
+
test
+ <_JSXStyle styleId={4216192053} css={`span.${color}[data-jsx="4216192053"] {color: ${otherColor}}`} />
+ <_JSXStyle styleId={styles.__scopedHash} css={styles.__scoped} />
+
);
diff --git a/test/index.js b/test/index.js
index 905a9b77..52668f5c 100644
--- a/test/index.js
+++ b/test/index.js
@@ -40,13 +40,20 @@ test('works with global styles', async t => {
t.snapshot(code)
})
-test('generates source maps', async t => {
+test('generates source maps (babel options)', async t => {
const { code } = await transform('./fixtures/source-maps.js', {
sourceMaps: true
})
t.snapshot(code)
})
+test('generates source maps (plugin options)', async t => {
+ const { code } = await _transform('./fixtures/source-maps.js', {
+ plugins: [[plugin, { sourceMaps: true }]]
+ })
+ t.snapshot(code)
+})
+
test('mixed global and scoped', async t => {
const { code } = await transform('./fixtures/mixed-global-scoped.js')
t.snapshot(code)
diff --git a/test/plugins.js b/test/plugins.js
new file mode 100644
index 00000000..09e9956e
--- /dev/null
+++ b/test/plugins.js
@@ -0,0 +1,80 @@
+// Packages
+import test from 'ava'
+
+// Ours
+import babelPlugin from '../src/babel'
+import { combinePlugins } from '../src/_utils'
+import _transform from './_transform'
+import testPlugin1 from './fixtures/plugins/plugin'
+import testPlugin2 from './fixtures/plugins/another-plugin'
+
+const transform = (file, opts = {}) =>
+ _transform(file, {
+ plugins: [
+ [
+ babelPlugin,
+ { plugins: [require.resolve('./fixtures/plugins/another-plugin')] }
+ ]
+ ],
+ ...opts
+ })
+
+test('combinePlugins returns an identity function when plugins is undefined', t => {
+ const test = 'test'
+ const plugins = combinePlugins()
+ t.is(plugins(test), test)
+})
+
+test('combinePlugins throws if plugins is not an array', t => {
+ t.throws(() => {
+ combinePlugins(2)
+ })
+})
+
+test('combinePlugins throws if plugins is not an array of strings', t => {
+ t.throws(() => {
+ combinePlugins(['test', 2])
+ })
+})
+
+test('combinePlugins throws if loaded plugins are not functions', t => {
+ t.throws(() => {
+ combinePlugins([
+ require.resolve('./fixtures/plugins/plugin'),
+ require.resolve('./fixtures/plugins/invalid-plugin')
+ ])
+ })
+})
+
+test('combinePlugins works with a single plugin', t => {
+ const plugins = combinePlugins([require.resolve('./fixtures/plugins/plugin')])
+
+ t.is(testPlugin1('test'), plugins('test'))
+})
+
+test('combinePlugins works with options', t => {
+ const expectedOption = 'my-test'
+ const plugins = combinePlugins([
+ [
+ require.resolve('./fixtures/plugins/options'),
+ {
+ test: expectedOption
+ }
+ ]
+ ])
+ t.is(plugins(''), expectedOption)
+})
+
+test('combinePlugins applies plugins left to right', t => {
+ const plugins = combinePlugins([
+ require.resolve('./fixtures/plugins/plugin'),
+ require.resolve('./fixtures/plugins/another-plugin')
+ ])
+
+ t.is(testPlugin2(testPlugin1('test')), plugins('test'))
+})
+
+test('applies plugins', async t => {
+ const { code } = await transform('./fixtures/with-plugins.js')
+ t.snapshot(code)
+})