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 () => ( +
+

test

+ + +
+) 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) +})