diff --git a/changelog.md b/changelog.md index e04290d0f..9ff552abd 100755 --- a/changelog.md +++ b/changelog.md @@ -3,9 +3,13 @@ ### Bug fixes - [jss-plugin-expand] Fix attributes spread for `border-bottom`, `border-top`, `border-left` and `border-right` ([#1083](https://github.com/cssinjs/jss/pull/1083)) -- [jss-plugin-props-sort] Fix sorting in Node 11 ([#1084](https://github.com/cssinjs/jss/pull/1083)) +- [jss-plugin-props-sort] Fix sorting in Node 11 ([#1085](https://github.com/cssinjs/jss/pull/1085)) - [jss] Fix escaping keyframes names ([#1100](https://github.com/cssinjs/jss/pull/1100)) +### Improvements + +- [jss-plugin-template] Add nesting support ([#1103](https://github.com/cssinjs/jss/pull/1103)) + ## 10.0.0-alpha.16 (2019-3-24) ### Bug fixes diff --git a/docs/jss-plugin-template.md b/docs/jss-plugin-template.md index 99f8467c9..37ad921f1 100644 --- a/docs/jss-plugin-template.md +++ b/docs/jss-plugin-template.md @@ -1,10 +1,17 @@ ## Enables string templates -Allows you to use string templates to declare CSS rules. It implements a **very naive** but **very fast (~42000 ops/sec)** runtime CSS parser, with certain limitations: +This parser is not meant to be a complete one but to enable authoring styles using a template string with nesting syntax support, fastest parse performance and small footprint. -- Supports only rule body (no selectors) -- Requires semicolon and a new line after the value (except the last line) -- No nested rules support +Design of this parser has two main principles: + +1. It does not parse entire CSS. It uses only specific markers to separate selectors from props and values. +1. It uses warnings to make sure expected syntax is used instead of supporting the full syntax. + +To do that it requires some constraints: + +- Parser expects a new line after each declaration (`color: red;\n`). +- Parser expects an ampersand, selector and opening curly brace for nesting syntax on a single line (`& selector {`). +- Parser expects a closing curly brace on a separate line. ```js const styles = { @@ -14,6 +21,9 @@ const styles = { color: red; margin: 20px 40px; padding: 10px; + &:hover span { + color: green; + } `, '@media print': { button: `color: black` @@ -24,3 +34,12 @@ const styles = { } } ``` + +### Benchmark + +``` +Chrome 74.0.3729 (Mac OS X 10.14.3) Parse: parse() at 122983 ops/sec +Chrome 74.0.3729 (Mac OS X 10.14.3) Parse: stylis() at 47582 ops/sec +Chrome 74.0.3729 (Mac OS X 10.14.3) + Parse: parse() at 122983 ops/sec (2.58x faster than stylis()) +``` diff --git a/karma.conf.js b/karma.conf.js index df9adc070..fcfb87c96 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -44,7 +44,7 @@ module.exports = config => { frameworks: ['benchmark'], // Using a fixed position for a file name, m.b. should use an args parser later. files: [process.argv[4] || 'packages/jss/benchmark/**/*.js'], - preprocessors: {'packages/jss/benchmark/**/*.js': ['webpack']}, + preprocessors: {'packages/**/benchmark/**/*.js': ['webpack']}, reporters: ['benchmark'], // Some tests are slow. browserNoActivityTimeout: 20000 diff --git a/packages/jss-plugin-template/.size-snapshot.json b/packages/jss-plugin-template/.size-snapshot.json index 285a135d6..192e75f62 100644 --- a/packages/jss-plugin-template/.size-snapshot.json +++ b/packages/jss-plugin-template/.size-snapshot.json @@ -1,23 +1,23 @@ { "dist/jss-plugin-template.js": { - "bundled": 1777, - "minified": 730, - "gzipped": 453 + "bundled": 3694, + "minified": 1128, + "gzipped": 603 }, "dist/jss-plugin-template.min.js": { - "bundled": 1418, - "minified": 564, - "gzipped": 355 + "bundled": 3066, + "minified": 820, + "gzipped": 474 }, "dist/jss-plugin-template.cjs.js": { - "bundled": 1341, - "minified": 686, - "gzipped": 423 + "bundled": 3289, + "minified": 1194, + "gzipped": 579 }, "dist/jss-plugin-template.esm.js": { - "bundled": 1123, - "minified": 518, - "gzipped": 341, + "bundled": 3066, + "minified": 1020, + "gzipped": 501, "treeshaked": { "rollup": { "code": 21, diff --git a/packages/jss-plugin-template/benchmark/tests/createSheet.js b/packages/jss-plugin-template/benchmark/tests/createSheet.js index 5adf1e0f3..f51984001 100644 --- a/packages/jss-plugin-template/benchmark/tests/createSheet.js +++ b/packages/jss-plugin-template/benchmark/tests/createSheet.js @@ -4,7 +4,7 @@ import template from '../../src/index' import parse from '../../src/parse' const options = {Renderer: null} -const jss = create(options).use(template()) +const jss = create(options).use(template({cache: false})) const css = ` color: rgb(77, 77, 77); diff --git a/packages/jss-plugin-template/benchmark/tests/parse.js b/packages/jss-plugin-template/benchmark/tests/parse.js index 9e125255a..da6149548 100644 --- a/packages/jss-plugin-template/benchmark/tests/parse.js +++ b/packages/jss-plugin-template/benchmark/tests/parse.js @@ -1,4 +1,5 @@ -import parse from '../../src/parse' +import stylis from 'stylis' +import parse, {parse2} from '../../src/parse' const css = ` color: rgb(77, 77, 77); @@ -37,8 +38,25 @@ const css = ` font-variant: normal normal; border-spacing: 0px; ` + +stylis.set({ + global: false, + keyframe: false, + prefix: false, + compress: false, + semicolon: true +}) + suite('Parse', () => { benchmark('parse()', () => { parse(css) }) + + benchmark('parse2()', () => { + parse2(css) + }) + + benchmark('stylis()', () => { + stylis('#id', css) + }) }) diff --git a/packages/jss-plugin-template/package.json b/packages/jss-plugin-template/package.json index 38ebc6937..636b08852 100644 --- a/packages/jss-plugin-template/package.json +++ b/packages/jss-plugin-template/package.json @@ -40,5 +40,9 @@ "@babel/runtime": "^7.3.1", "jss": "10.0.0-alpha.16", "tiny-warning": "^1.0.2" + }, + "devDependencies": { + "jss-plugin-nested": "^10.0.0-alpha.16", + "stylis": "^3.5.4" } } diff --git a/packages/jss-plugin-template/src/index.js b/packages/jss-plugin-template/src/index.js index bcff9739b..8bde25f9f 100644 --- a/packages/jss-plugin-template/src/index.js +++ b/packages/jss-plugin-template/src/index.js @@ -2,13 +2,27 @@ import {type Plugin} from 'jss' import parse from './parse' -const onProcessRule = rule => { - if (typeof rule.style === 'string') { - // $FlowFixMe: We can safely assume that rule has the style property - rule.style = parse(rule.style) +export const cache = {} + +type Options = {|cache: boolean|} + +export default function templatePlugin(options: Options = {cache: true}): Plugin { + const onProcessStyle = style => { + if (typeof style !== 'string') { + return style + } + + if (style in cache) { + return cache[style] + } + + if (options.cache) { + cache[style] = parse(style) + return cache[style] + } + + return parse(style) } -} -export default function templatePlugin(): Plugin { - return {onProcessRule} + return {onProcessStyle} } diff --git a/packages/jss-plugin-template/src/index.test.js b/packages/jss-plugin-template/src/index.test.js index 6b3115bd2..8b72633e7 100644 --- a/packages/jss-plugin-template/src/index.test.js +++ b/packages/jss-plugin-template/src/index.test.js @@ -3,118 +3,49 @@ import expect from 'expect.js' import {stripIndent} from 'common-tags' import {create} from 'jss' -import sinon from 'sinon' -import template from '.' +import nested from 'jss-plugin-nested' +import template, {cache} from '.' const settings = { createGenerateId: () => rule => `${rule.key}-id` } describe('jss-plugin-template', () => { - let spy let jss beforeEach(() => { - spy = sinon.spy(console, 'warn') - jss = create(settings).use(template()) + jss = create(settings).use(template(), nested()) }) - afterEach(() => { - console.warn.restore() + it('should cache parsed template', () => { + const a = `color: red` + jss.createStyleSheet({a}) + expect(cache[a]).to.eql({color: 'red'}) }) - describe('template literals', () => { - it('should convert a single single property/value', () => { - const sheet = jss.createStyleSheet({ - a: ` - color: red; - ` - }) - expect(sheet.toString()).to.be(stripIndent` - .a-id { - color: red; - } - `) - }) - - it('should parse multiple props/values', () => { - const sheet = jss.createStyleSheet({ - a: ` - color: red; - float: left; - ` - }) - expect(sheet.toString()).to.be(stripIndent` - .a-id { - color: red; - float: left; - } - `) - expect(spy.callCount).to.be(0) - }) - - it('should warn when there is no colon found', () => { - jss.createStyleSheet({ - a: 'color red;' - }) - - expect(spy.callCount).to.be(1) - expect(spy.calledWithExactly('Warning: [JSS] Malformed CSS string "color red;"')).to.be(true) - }) - - it('should strip spaces', () => { - const sheet = jss.createStyleSheet({ - a: ` - color: red ; - float: left ; - ` - }) - expect(sheet.toString()).to.be(stripIndent` - .a-id { - color: red; - float: left; - } - `) - }) - - it('should allow skiping last semicolon', () => { - const sheet = jss.createStyleSheet({ - a: ` - color: red; - float: left - ` - }) - expect(sheet.toString()).to.be(stripIndent` - .a-id { - color: red; - float: left; - } - `) + it('should support @media', () => { + const sheet = jss.createStyleSheet({ + '@media print': { + button: 'color: black;' + } }) - - it('should support @media', () => { - const sheet = jss.createStyleSheet({ - '@media print': { - button: 'color: black' - } - }) - expect(sheet.toString()).to.be(stripIndent` + expect(sheet.toString()).to.be(stripIndent` @media print { .button-id { color: black; } } `) - }) + }) - it('should support @keyframes', () => { - const sheet = jss.createStyleSheet({ - '@keyframes a': { - from: 'opacity: 0', - to: 'opacity: 1' - } - }) - expect(sheet.toString()).to.be(stripIndent` + it('should support @keyframes', () => { + const sheet = jss.createStyleSheet({ + '@keyframes a': { + from: 'opacity: 0;', + to: 'opacity: 1;' + } + }) + expect(sheet.toString()).to.be(stripIndent` @keyframes keyframes-a-id { from { opacity: 0; @@ -124,6 +55,5 @@ describe('jss-plugin-template', () => { } } `) - }) }) }) diff --git a/packages/jss-plugin-template/src/parse.js b/packages/jss-plugin-template/src/parse.js index f2368b31a..ee47feee7 100644 --- a/packages/jss-plugin-template/src/parse.js +++ b/packages/jss-plugin-template/src/parse.js @@ -1,29 +1,149 @@ // @flow import warning from 'tiny-warning' - -const semiWithNl = /;\n/ +import type {JssStyles} from 'jss' /** - * Naive CSS parser. - * - Supports only rule body (no selectors) - * - Requires semicolon and new line after the value (except of last line) - * - No nested rules support + * A simplified CSS parser. + * + * This parser is not meant to be a complete one but to enable authoring styles + * using a template string with nesting syntax support, fastest parse performance and small footprint. + * + * Design of this parser has two main principles: + * + * 1. It does not parse entire CSS. It uses only specific markers to separate selectors from props and values. + * 2. It uses warnings to make sure expected syntax is used instead of supporting the full syntax. + * + * To do that it requires some constraints: + * - Parser expects a new line after each declaration (`color: red;\n`). + * - Parser expects an ampersand, selector and opening curly brace for nesting syntax on a single line (`& selector {`). + * - Parser expects a closing curly brace on a separate line. + * + * Example + * + * ` + * color: red; + * &:hover { + * color: green; + * } + * ` */ -export default (cssText: string) => { + +// Test implementation without using .split() +const parse = (cssText: string): JssStyles => { + const style = {} + const rules = [style] + let line + let done + let prevNlIndex = 0 + + while (!done) { + const nextNlIndex = cssText.indexOf('\n', prevNlIndex) + + if (nextNlIndex === -1) { + done = true + line = cssText.substring(prevNlIndex).trim() + } else { + line = cssText.substring(prevNlIndex, nextNlIndex).trim() + prevNlIndex = nextNlIndex + 1 + } + + if (!line) continue + + const ampIndex = line.indexOf('&') + + if (ampIndex !== -1) { + const openCurlyIndex = line.indexOf('{') + if (openCurlyIndex === -1) { + warning(false, `[JSS] Missing opening curly brace in "${line}".`) + break + } + const key = line.substring(0, openCurlyIndex - 1).trim() + const nestedStyle = {} + rules[rules.length - 1][key] = nestedStyle + rules.push(nestedStyle) + continue + } + + // We are closing a nested rule. + if (line === '}') { + rules.pop() + continue + } + + // We are closing a nested rule, but the curly brace is not on a separate line. + if (process.env.NODE_ENV !== 'production' && line.indexOf('}') !== -1) { + warning(false, `[JSS] Missing closing curly brace in "${line}".`) + continue + } + + const colonIndex = line.indexOf(':') + + if (colonIndex === -1) { + warning(false, `[JSS] Missing colon in "${line}".`) + } + + const prop = line.substring(0, colonIndex).trim() + // We need to remove semicolon from value if there is one. + const semi = line[line.length - 1] === ';' ? 1 : 0 + const value = line.substring(colonIndex + 1, line.length - semi).trim() + rules[rules.length - 1][prop] = value + } + + return style +} + +export default parse + +// Temporarily here for comparison. +export const parse2 = (cssText: string): JssStyles => { const style = {} - const split = cssText.split(semiWithNl) - for (let i = 0; i < split.length; i++) { - const decl = (split[i] || '').trim() + const lines = cssText.split('\n') + const rules = [style] + + for (let i = 0; i < lines.length; i++) { + const decl = lines[i].trim() if (!decl) continue + + const ampIndex = decl.indexOf('&') + + if (ampIndex !== -1) { + const openCurlyIndex = decl.indexOf('{') + if (openCurlyIndex === -1) { + warning(false, `[JSS] Missing opening curly brace in "${decl}".`) + break + } + const key = decl.substring(0, openCurlyIndex - 1).trim() + const nestedStyle = {} + rules[rules.length - 1][key] = nestedStyle + rules.push(nestedStyle) + continue + } + + // We are closing a nested rule. + if (decl === '}') { + rules.pop() + continue + } + + // We are closing a nested rule, but the curly brace is not on a separate line. + if (process.env.NODE_ENV !== 'production' && decl.indexOf('}') !== -1) { + warning(false, `[JSS] Missing closing curly brace in "${decl}".`) + continue + } + const colonIndex = decl.indexOf(':') + if (colonIndex === -1) { - warning(false, `[JSS] Malformed CSS string "${decl}"`) - continue + warning(false, `[JSS] Missing colon in "${decl}".`) } - const prop = decl.substr(0, colonIndex).trim() - const value = decl.substr(colonIndex + 1).trim() - style[prop] = value + + const prop = decl.substring(0, colonIndex).trim() + // We need to remove semicolon from value if there is one. + const semi = decl[decl.length - 1] === ';' ? 1 : 0 + const value = decl.substring(colonIndex + 1, decl.length - semi).trim() + rules[rules.length - 1][prop] = value } + return style } diff --git a/packages/jss-plugin-template/src/parse.test.js b/packages/jss-plugin-template/src/parse.test.js new file mode 100644 index 000000000..9375fc464 --- /dev/null +++ b/packages/jss-plugin-template/src/parse.test.js @@ -0,0 +1,194 @@ +/* eslint-disable no-underscore-dangle */ + +import expect from 'expect.js' +import sinon from 'sinon' +import parse from './parse' + +describe('jss-plugin-template parse()', () => { + let warnSpy + + beforeEach(() => { + warnSpy = sinon.spy(console, 'warn') + }) + + afterEach(() => { + console.warn.restore() + }) + + it('should convert a single single property/value', () => { + const styles = parse(` + color: red; + `) + expect(styles).to.eql({ + color: 'red' + }) + expect(warnSpy.callCount).to.be(0) + }) + + it('should parse multiple props/values', () => { + const styles = parse(` + color: red; + float: left; + `) + expect(styles).to.eql({ + color: 'red', + float: 'left' + }) + expect(warnSpy.callCount).to.be(0) + }) + + it('should warn when there is no colon found', () => { + parse('color red;') + expect(warnSpy.callCount).to.be(1) + expect(warnSpy.args[0][0]).to.be('Warning: [JSS] Missing colon in "color red;".') + }) + + it('should strip spaces', () => { + const styles = parse(` + color: red ; + float: left ; + `) + expect(styles).to.eql({ + color: 'red', + float: 'left' + }) + expect(warnSpy.callCount).to.be(0) + }) + + it('should not require semicolon', () => { + const styles = parse(` + color: red + float: left + `) + expect(styles).to.eql({ + color: 'red', + float: 'left' + }) + expect(warnSpy.callCount).to.be(0) + }) + + it('should support nesting', () => { + const styles = parse(` + color: green; + & .b { + color: red; + } + `) + expect(styles).to.eql({ + color: 'green', + '& .b': { + color: 'red' + } + }) + expect(warnSpy.callCount).to.be(0) + }) + + it('should warn when opening curly brace is missing', () => { + const styles = parse(` + color: green; + & .b + color: red; + } + `) + expect(styles).to.eql({ + color: 'green' + }) + expect(warnSpy.args[0][0]).to.be('Warning: [JSS] Missing opening curly brace in "& .b".') + }) + + it('should warn when closing curly brace is not on an own line', () => { + const styles = parse(` + color: green; + & .b { + color: red; + } .a { color: blue; } + `) + expect(styles).to.eql({ + color: 'green', + '& .b': { + color: 'red' + } + }) + expect(warnSpy.args[0][0]).to.be( + 'Warning: [JSS] Missing closing curly brace in "} .a { color: blue; }".' + ) + }) + + it('should support multiple first level nested rules', () => { + const styles = parse(` + color: green; + & .b { + color: red; + } + & .c { + color: blue; + } + `) + expect(styles).to.eql({ + color: 'green', + '& .b': { + color: 'red' + }, + '& .c': { + color: 'blue' + } + }) + expect(warnSpy.callCount).to.be(0) + }) + + it('should support multiple deeply nested rules', () => { + const styles = parse(` + color: green; + & .b { + color: red; + & .c { + color: blue; + } + } + `) + expect(styles).to.eql({ + color: 'green', + '& .b': { + color: 'red', + '& .c': { + color: 'blue' + } + } + }) + expect(warnSpy.callCount).to.be(0) + }) + + it('should regular props after a nested rule', () => { + const styles = parse(` + color: green; + & .b { + color: red; + } + float: left; + `) + expect(styles).to.eql({ + color: 'green', + '& .b': { + color: 'red' + }, + float: 'left' + }) + expect(warnSpy.callCount).to.be(0) + }) + + it('should expect ampersand in the middle of the line', () => { + const styles = parse(` + color: green; + body & .b { + color: red; + } + `) + expect(styles).to.eql({ + color: 'green', + 'body & .b': { + color: 'red' + } + }) + expect(warnSpy.callCount).to.be(0) + }) +}) diff --git a/packages/jss-preset-default/.size-snapshot.json b/packages/jss-preset-default/.size-snapshot.json index 315e6f5df..1c650a98e 100644 --- a/packages/jss-preset-default/.size-snapshot.json +++ b/packages/jss-preset-default/.size-snapshot.json @@ -1,13 +1,13 @@ { "dist/jss-preset-default.js": { - "bundled": 54595, - "minified": 19525, - "gzipped": 6444 + "bundled": 56494, + "minified": 19941, + "gzipped": 6581 }, "dist/jss-preset-default.min.js": { - "bundled": 53841, - "minified": 19066, - "gzipped": 6229 + "bundled": 55471, + "minified": 19338, + "gzipped": 6341 }, "dist/jss-preset-default.cjs.js": { "bundled": 1329, diff --git a/packages/jss-starter-kit/.size-snapshot.json b/packages/jss-starter-kit/.size-snapshot.json index 09e23b690..d201904d9 100644 --- a/packages/jss-starter-kit/.size-snapshot.json +++ b/packages/jss-starter-kit/.size-snapshot.json @@ -1,13 +1,13 @@ { "dist/jss-starter-kit.js": { - "bundled": 70137, - "minified": 29563, - "gzipped": 9075 + "bundled": 72036, + "minified": 29950, + "gzipped": 9203 }, "dist/jss-starter-kit.min.js": { - "bundled": 69383, - "minified": 29105, - "gzipped": 8867 + "bundled": 71013, + "minified": 29350, + "gzipped": 8975 }, "dist/jss-starter-kit.cjs.js": { "bundled": 2592, diff --git a/packages/jss/src/index.js b/packages/jss/src/index.js index 51150eb81..5d474c88f 100644 --- a/packages/jss/src/index.js +++ b/packages/jss/src/index.js @@ -26,6 +26,7 @@ export type { JssValue, JssOptions, JssStyle, + JssStyles, Plugin, GenerateId, RuleListOptions, diff --git a/packages/react-jss/.size-snapshot.json b/packages/react-jss/.size-snapshot.json index a3e1662e2..a69318324 100644 --- a/packages/react-jss/.size-snapshot.json +++ b/packages/react-jss/.size-snapshot.json @@ -1,13 +1,13 @@ { "dist/react-jss.js": { - "bundled": 109904, - "minified": 37407, - "gzipped": 12068 + "bundled": 111803, + "minified": 37823, + "gzipped": 12217 }, "dist/react-jss.min.js": { - "bundled": 85345, - "minified": 30142, - "gzipped": 9883 + "bundled": 86975, + "minified": 30412, + "gzipped": 9985 }, "dist/react-jss.cjs.js": { "bundled": 15499, diff --git a/webpack.config.js b/webpack.config.js index 64c11910f..02753f8a6 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -11,15 +11,21 @@ const plugins = [ }) ] +const babelPlugins = ['@babel/proposal-class-properties', '@babel/proposal-object-rest-spread'] + +if (process.env.BENCHMARK) { + babelPlugins.push('dev-expression') +} + module.exports = { - mode: 'none', + mode: process.env.BENCHMARK ? 'production' : 'none', entry: './packages/jss/src/index', output: { library: 'jss', libraryTarget: 'umd' }, optimization: { - nodeEnv: false + nodeEnv: process.env.BENCHMARK ? 'production' : false }, plugins, module: { @@ -30,7 +36,7 @@ module.exports = { exclude: /node_modules/, options: { presets: ['@babel/react', '@babel/flow', '@babel/env'], - plugins: ['@babel/proposal-class-properties', '@babel/proposal-object-rest-spread'] + plugins: babelPlugins } } ] diff --git a/yarn.lock b/yarn.lock index da2b868c0..27975828b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8979,6 +8979,11 @@ strong-log-transformer@^2.0.0: minimist "^1.2.0" through "^2.3.4" +stylis@^3.5.4: + version "3.5.4" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" + integrity sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q== + supports-color@3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.1.2.tgz#72a262894d9d408b956ca05ff37b2ed8a6e2a2d5"