From 007231fbfc9db0da2ce0bd60003d43c9af6ca181 Mon Sep 17 00:00:00 2001 From: Adam Wathan Date: Thu, 28 Feb 2019 10:17:09 -0500 Subject: [PATCH] Require plugin authors to manually escape variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not 100% convinced this is a net positive change, but I regret not having done things this way at the beginning. In 0.x, we pass the `separator` and `className` values already escaped, so `:` comes through as `\:` for example, and `w-1/2` comes through as `w-1\/2`. At first this sounds fine, less work for the plugin author right? But CSS escaping rules are kind of complicated and you have to escape characters differently depending on whether or not they are at the start of an identifier. For example, it's totally fine for a class to contain a zero (`0` ), but it can't _start_ with a zero. For a class to start with a zero, it needs to be escaped like this: `\30 ` This means that as a general rule, trying to escape the individual segments of a class separately is a bad idea — you should escape the class as a whole so only the necessary escaping is applied. We break this rule when we pre-escape the separator and className for plugin authors who use the `modifySelectors` function. We already require users to manually escape class names when they are using `addUtilities` or `addComponents`, so to me it feels more consistent for things to work this way and it's how they should have worked from day one. Basically this code: ```js function({ addVariant }) { addVariant('first-child', ({ modifySelectors, separator }) => { modifySelectors(({ className }) => { return `.first-child${separator}${className}:first-child` }) }) }, ``` ...would need to be re-written like this if I merge this change: ```js function({ addVariant, e }) { addVariant('first-child', ({ modifySelectors, separator }) => { modifySelectors(({ className }) => { return `.${e(`first-child${separator}${className}`)}:first-child` }) }) }, ``` Although I think this is the right way for this to work, I hesitate because it's a breaking change that makes any variant plugins authored for 0.x incompatible with 1.x. It's an easy fix on the plugin author's part, but it's still annoying. I'm leaning towards merging so I don't regret this even more later when the plugin ecosystem is a lot bigger. Anyone have any thoughts? --- __tests__/variantsAtRule.test.js | 41 +++++++++++++++++++++++++--- src/lib/substituteVariantsAtRules.js | 5 ++-- src/util/generateVariantFunction.js | 16 +++++++---- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/__tests__/variantsAtRule.test.js b/__tests__/variantsAtRule.test.js index d0b20e4fc..44411663f 100644 --- a/__tests__/variantsAtRule.test.js +++ b/__tests__/variantsAtRule.test.js @@ -300,10 +300,43 @@ test('plugin variants can modify selectors with a simplified API', () => { ...config, plugins: [ ...config.plugins, - function({ addVariant }) { + function({ addVariant, e }) { addVariant('first-child', ({ modifySelectors, separator }) => { modifySelectors(({ className }) => { - return `.first-child${separator}${className}:first-child` + return `.${e(`first-child${separator}${className}`)}:first-child` + }) + }) + }, + ], + }).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('plugin variants that use modify selectors need to manually escape the class name they are modifying', () => { + const input = ` + @variants first-child { + .banana-1\\/2 { color: yellow; } + .chocolate-1\\.5 { color: brown; } + } + ` + + const output = ` + .banana-1\\/2 { color: yellow; } + .chocolate-1\\.5 { color: brown; } + .first-child\\:banana-1\\/2:first-child { color: yellow; } + .first-child\\:chocolate-1\\.5:first-child { color: brown; } + ` + + return run(input, { + ...config, + plugins: [ + ...config.plugins, + function({ addVariant, e }) { + addVariant('first-child', ({ modifySelectors, separator }) => { + modifySelectors(({ className }) => { + return `.${e(`first-child${separator}${className}`)}:first-child` }) }) }, @@ -335,13 +368,13 @@ test('plugin variants can wrap rules in another at-rule using the raw PostCSS AP ...config, plugins: [ ...config.plugins, - function({ addVariant }) { + function({ addVariant, e }) { addVariant('supports-grid', ({ container, separator }) => { const supportsRule = postcss.atRule({ name: 'supports', params: '(display: grid)' }) supportsRule.nodes = container.nodes container.nodes = [supportsRule] supportsRule.walkRules(rule => { - rule.selector = `.supports-grid${separator}${rule.selector.slice(1)}` + rule.selector = `.${e(`supports-grid${separator}${rule.selector.slice(1)}`)}` }) }) }, diff --git a/src/lib/substituteVariantsAtRules.js b/src/lib/substituteVariantsAtRules.js index a586a66ec..73981d6c3 100644 --- a/src/lib/substituteVariantsAtRules.js +++ b/src/lib/substituteVariantsAtRules.js @@ -1,11 +1,12 @@ import _ from 'lodash' import postcss from 'postcss' import generateVariantFunction from '../util/generateVariantFunction' +import e from '../util/escapeClassName' function generatePseudoClassVariant(pseudoClass) { return generateVariantFunction(({ modifySelectors, separator }) => { return modifySelectors(({ className }) => { - return `.${pseudoClass}${separator}${className}:${pseudoClass}` + return `.${e(`${pseudoClass}${separator}${className}`)}:${pseudoClass}` }) }) } @@ -18,7 +19,7 @@ const defaultVariantGenerators = { default: generateVariantFunction(() => {}), 'group-hover': generateVariantFunction(({ modifySelectors, separator }) => { return modifySelectors(({ className }) => { - return `.group:hover .group-hover${separator}${className}` + return `.group:hover .${e(`group-hover${separator}${className}`)}` }) }), hover: generatePseudoClassVariant('hover'), diff --git a/src/util/generateVariantFunction.js b/src/util/generateVariantFunction.js index 616935894..b4db56559 100644 --- a/src/util/generateVariantFunction.js +++ b/src/util/generateVariantFunction.js @@ -1,6 +1,6 @@ import _ from 'lodash' import postcss from 'postcss' -import escapeClassName from './escapeClassName' +import selectorParser from 'postcss-selector-parser' export default function generateVariantFunction(generator) { return (container, config) => { @@ -10,15 +10,19 @@ export default function generateVariantFunction(generator) { _.defaultTo( generator({ container: cloned, - separator: escapeClassName(config.separator), + separator: config.separator, modifySelectors: modifierFunction => { cloned.walkRules(rule => { - rule.selectors = rule.selectors.map(selector => - modifierFunction({ - className: selector.slice(1), + rule.selectors = rule.selectors.map(selector => { + const className = selectorParser(selectors => { + return selectors.first.filter(({ type }) => type === 'class').pop().value + }).transformSync(selector) + + return modifierFunction({ + className, selector, }) - ) + }) }) return cloned },