diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e940623fa51..9d3cca93636c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `backdrop` variant ([#7924](https://github.com/tailwindlabs/tailwindcss/pull/7924)) - Add `grid-flow-dense` utility ([#8193](https://github.com/tailwindlabs/tailwindcss/pull/8193)) - Add `mix-blend-plus-lighter` utility ([#8288](https://github.com/tailwindlabs/tailwindcss/pull/8288)) +- Add arbitrary variants ([#8299](https://github.com/tailwindlabs/tailwindcss/pull/8299)) ## [3.0.24] - 2022-04-12 diff --git a/src/lib/defaultExtractor.js b/src/lib/defaultExtractor.js index 523d9bab3dad..ff8c6dd50dc3 100644 --- a/src/lib/defaultExtractor.js +++ b/src/lib/defaultExtractor.js @@ -1,25 +1,34 @@ import * as regex from './regex' -let patterns = Array.from(buildRegExps()) - -/** - * @param {string} content - */ -export function defaultExtractor(content) { - /** @type {(string|string)[]} */ - let results = [] +export function defaultExtractor(context) { + let patterns = Array.from(buildRegExps(context)) + + /** + * @param {string} content + */ + return (content) => { + /** @type {(string|string)[]} */ + let results = [] + + for (let pattern of patterns) { + results.push(...(content.match(pattern) ?? [])) + } - for (let pattern of patterns) { - results.push(...(content.match(pattern) ?? [])) + return results.filter((v) => v !== undefined).map(clipAtBalancedParens) } - - return results.filter((v) => v !== undefined).map(clipAtBalancedParens) } -function* buildRegExps() { +function* buildRegExps(context) { + let separator = context.tailwindConfig.separator + yield regex.pattern([ // Variants - /((?=([^\s"'\\\[]+:))\2)?/, + '((?=((', + regex.any( + [regex.pattern([/\[[^\s"'\\]+\]/, separator]), regex.pattern([/[^\s"'\[\\]+/, separator])], + true + ), + ')+))\\2)?', // Important (optional) /!?/, diff --git a/src/lib/expandTailwindAtRules.js b/src/lib/expandTailwindAtRules.js index 64ba07131e3e..a0c1635a0fba 100644 --- a/src/lib/expandTailwindAtRules.js +++ b/src/lib/expandTailwindAtRules.js @@ -24,7 +24,7 @@ function getExtractor(context, fileExtension) { extractors[fileExtension] || extractors.DEFAULT || builtInExtractors[fileExtension] || - builtInExtractors.DEFAULT + builtInExtractors.DEFAULT(context) ) } diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index bbed6812eb48..a14f7163a076 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -9,7 +9,9 @@ import * as sharedState from './sharedState' import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector' import { asClass } from '../util/nameClass' import { normalize } from '../util/dataTypes' +import { parseVariant } from './setupContextUtils' import isValidArbitraryValue from '../util/isValidArbitraryValue' +import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js' let classNameParser = selectorParser((selectors) => { return selectors.first.filter(({ type }) => type === 'class').pop().value @@ -125,6 +127,17 @@ function applyVariant(variant, matches, context) { return matches } + // Register arbitrary variants + if (isArbitraryValue(variant) && !context.variantMap.has(variant)) { + let selector = normalize(variant.slice(1, -1)) + + let fn = parseVariant(selector) + + let sort = Array.from(context.variantOrder.values()).pop() << 1n + context.variantMap.set(variant, [[sort, fn]]) + context.variantOrder.set(variant, sort) + } + if (context.variantMap.has(variant)) { let variantFunctionTuples = context.variantMap.get(variant) let result = [] @@ -407,7 +420,7 @@ function splitWithSeparator(input, separator) { return [sharedState.NOT_ON_DEMAND] } - return input.split(new RegExp(`\\${separator}(?![^[]*\\])`, 'g')) + return Array.from(splitAtTopLevelOnly(input, separator)) } function* recordCandidates(matches, classCandidate) { diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index f2dc6a516b2e..573341a6c488 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -170,6 +170,30 @@ function withIdentifiers(styles) { }) } +export function parseVariant(variant) { + variant = variant + .replace(/\n+/g, '') + .replace(/\s{1,}/g, ' ') + .trim() + + let fns = parseVariantFormatString(variant) + .map((str) => { + if (!str.startsWith('@')) { + return ({ format }) => format(str) + } + + let [, name, params] = /@(.*?)( .+|[({].*)/g.exec(str) + return ({ wrap }) => wrap(postcss.atRule({ name, params: params.trim() })) + }) + .reverse() + + return (api) => { + for (let fn of fns) { + fn(api) + } + } +} + function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets, classList }) { function getConfigValue(path, defaultValue) { return path ? dlv(tailwindConfig, path, defaultValue) : tailwindConfig @@ -201,27 +225,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs } } - variantFunction = variantFunction - .replace(/\n+/g, '') - .replace(/\s{1,}/g, ' ') - .trim() - - let fns = parseVariantFormatString(variantFunction) - .map((str) => { - if (!str.startsWith('@')) { - return ({ format }) => format(str) - } - - let [, name, params] = /@(.*?) (.*)/g.exec(str) - return ({ wrap }) => wrap(postcss.atRule({ name, params })) - }) - .reverse() - - return (api) => { - for (let fn of fns) { - fn(api) - } - } + return parseVariant(variantFunction) }) insertInto(variantList, variantName, options) diff --git a/src/util/parseBoxShadowValue.js b/src/util/parseBoxShadowValue.js index 0806ec699b88..16fc8eb1b03a 100644 --- a/src/util/parseBoxShadowValue.js +++ b/src/util/parseBoxShadowValue.js @@ -1,58 +1,11 @@ +import { splitAtTopLevelOnly } from './splitAtTopLevelOnly' + let KEYWORDS = new Set(['inset', 'inherit', 'initial', 'revert', 'unset']) let SPACE = /\ +(?![^(]*\))/g // Similar to the one above, but with spaces instead. let LENGTH = /^-?(\d+|\.\d+)(.*?)$/g -let SPECIALS = /[(),]/g - -/** - * This splits a string on top-level commas. - * - * Regex doesn't support recursion (at least not the JS-flavored version). - * So we have to use a tiny state machine to keep track of paren vs comma - * placement. Before we'd only exclude commas from the inner-most nested - * set of parens rather than any commas that were not contained in parens - * at all which is the intended behavior here. - * - * Expected behavior: - * var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0) - * ─┬─ ┬ ┬ ┬ - * x x x ╰──────── Split because top-level - * ╰──────────────┴──┴───────────── Ignored b/c inside >= 1 levels of parens - * - * @param {string} input - */ -function* splitByTopLevelCommas(input) { - SPECIALS.lastIndex = -1 - - let depth = 0 - let lastIndex = 0 - let found = false - - // Find all parens & commas - // And only split on commas if they're top-level - for (let match of input.matchAll(SPECIALS)) { - if (match[0] === '(') depth++ - if (match[0] === ')') depth-- - if (match[0] === ',' && depth === 0) { - found = true - - yield input.substring(lastIndex, match.index) - lastIndex = match.index + match[0].length - } - } - - // Provide the last segment of the string if available - // Otherwise the whole string since no commas were found - // This mirrors the behavior of string.split() - if (found) { - yield input.substring(lastIndex) - } else { - yield input - } -} - export function parseBoxShadowValue(input) { - let shadows = Array.from(splitByTopLevelCommas(input)) + let shadows = Array.from(splitAtTopLevelOnly(input, ',')) return shadows.map((shadow) => { let value = shadow.trim() let result = { raw: value } diff --git a/src/util/splitAtTopLevelOnly.js b/src/util/splitAtTopLevelOnly.js new file mode 100644 index 000000000000..8297a6feca4e --- /dev/null +++ b/src/util/splitAtTopLevelOnly.js @@ -0,0 +1,71 @@ +import * as regex from '../lib/regex' + +/** + * This splits a string on a top-level character. + * + * Regex doesn't support recursion (at least not the JS-flavored version). + * So we have to use a tiny state machine to keep track of paren placement. + * + * Expected behavior using commas: + * var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0) + * ─┬─ ┬ ┬ ┬ + * x x x ╰──────── Split because top-level + * ╰──────────────┴──┴───────────── Ignored b/c inside >= 1 levels of parens + * + * @param {string} input + * @param {string} separator + */ +export function* splitAtTopLevelOnly(input, separator) { + let SPECIALS = new RegExp(`[(){}\\[\\]${regex.escape(separator)}]`, 'g') + + let depth = 0 + let lastIndex = 0 + let found = false + let separatorIndex = 0 + let separatorStart = 0 + let separatorLength = separator.length + + // Find all paren-like things & character + // And only split on commas if they're top-level + for (let match of input.matchAll(SPECIALS)) { + let matchesSeparator = match[0] === separator[separatorIndex] + let atEndOfSeparator = separatorIndex === separatorLength - 1 + let matchesFullSeparator = matchesSeparator && atEndOfSeparator + + if (match[0] === '(') depth++ + if (match[0] === ')') depth-- + if (match[0] === '[') depth++ + if (match[0] === ']') depth-- + if (match[0] === '{') depth++ + if (match[0] === '}') depth-- + + if (matchesSeparator && depth === 0) { + if (separatorStart === 0) { + separatorStart = match.index + } + + separatorIndex++ + } + + if (matchesFullSeparator && depth === 0) { + found = true + + yield input.substring(lastIndex, separatorStart) + lastIndex = separatorStart + separatorLength + } + + if (separatorIndex === separatorLength) { + separatorIndex = 0 + separatorStart = 0 + } + } + + // Provide the last segment of the string if available + // Otherwise the whole string since no `char`s were found + // This mirrors the behavior of string.split() + if (found) { + yield input.substring(lastIndex) + } else { + yield input + } +} diff --git a/tests/arbitrary-variants.test.js b/tests/arbitrary-variants.test.js new file mode 100644 index 000000000000..45dbf70c5a34 --- /dev/null +++ b/tests/arbitrary-variants.test.js @@ -0,0 +1,379 @@ +import { run, html, css, defaults } from './util/run' + +test('basic arbitrary variants', () => { + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + .\[\&\>\*\]\:underline > * { + text-decoration-line: underline; + } + `) + }) +}) + +test('spaces in selector (using _)', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + .a.b .\[\.a\.b_\&\]\:underline { + text-decoration-line: underline; + } + `) + }) +}) + +test('arbitrary variants with modifiers', () => { + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + @media (prefers-color-scheme: dark) { + @media (min-width: 1024px) { + .dark\:lg\:hover\:\[\&\>\*\]\:underline > *:hover { + text-decoration-line: underline; + } + } + } + `) + }) +}) + +test('arbitrary variants are sorted after other variants', () => { + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + .underline { + text-decoration-line: underline; + } + + @media (min-width: 1024px) { + .lg\:underline { + text-decoration-line: underline; + } + } + + .\[\&\>\*\]\:underline > * { + text-decoration-line: underline; + } + `) + }) +}) + +test('using the important modifier', () => { + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + .\[\&\>\*\]\:\!underline > * { + text-decoration-line: underline !important; + } + `) + }) +}) + +test('at-rules', () => { + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + @supports (what: ever) { + .\[\@supports\(what\:ever\)\]\:underline { + text-decoration-line: underline; + } + } + `) + }) +}) + +test('nested at-rules', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + @media screen { + @media (hover: hover) { + .\[\@media_screen\{\@media\(hover\:hover\)\}\]\:underline { + text-decoration-line: underline; + } + } + } + `) + }) +}) + +test('at-rules with selector modifications', () => { + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + @media (hover: hover) { + .\[\@media\(hover\:hover\)\{\&\:hover\}\]\:underline:hover { + text-decoration-line: underline; + } + } + `) + }) +}) + +test('nested at-rules with selector modifications', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + @media screen { + @media (hover: hover) { + .\[\@media_screen\{\@media\(hover\:hover\)\{\&\:hover\}\}\]\:underline:hover { + text-decoration-line: underline; + } + } + } + `) + }) +}) + +test('attribute selectors', () => { + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + .\[\&\[data-open\]\]\:underline[data-open] { + text-decoration-line: underline; + } + `) + }) +}) + +test('multiple attribute selectors', () => { + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + .\[\&\[data-foo\]\[data-bar\]\:not\(\[data-baz\]\)\]\:underline[data-foo][data-bar]:not([data-baz]) { + text-decoration-line: underline; + } + `) + }) +}) + +test('multiple attribute selectors with custom separator (1)', () => { + let config = { + separator: '__', + content: [ + { raw: html`
` }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + .\[\&\[data-foo\]\[data-bar\]\:not\(\[data-baz\]\)\]__underline[data-foo][data-bar]:not([data-baz]) { + text-decoration-line: underline; + } + `) + }) +}) + +test('multiple attribute selectors with custom separator (2)', () => { + let config = { + separator: '_@', + content: [ + { raw: html`
` }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + .\[\&\[data-foo\]\[data-bar\]\:not\(\[data-baz\]\)\]_\@underline[data-foo][data-bar]:not([data-baz]) { + text-decoration-line: underline; + } + `) + }) +}) + +test('with @apply', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = ` + @tailwind base; + @tailwind components; + @tailwind utilities; + + .foo { + @apply [@media_screen{@media(hover:hover){&:hover}}]:underline; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + ${defaults} + + @media screen { + @media (hover: hover) { + .foo:hover { + text-decoration-line: underline; + } + } + } + `) + }) +}) diff --git a/tests/default-extractor.test.js b/tests/default-extractor.test.js index b6e0b6789d4a..789f2eb5d685 100644 --- a/tests/default-extractor.test.js +++ b/tests/default-extractor.test.js @@ -1,5 +1,5 @@ import { html } from './util/run' -import { defaultExtractor } from '../src/lib/defaultExtractor' +import { defaultExtractor as createDefaultExtractor } from '../src/lib/defaultExtractor' const jsExamples = ` document.body.classList.add(["pl-1.5"].join(" ")); @@ -163,6 +163,13 @@ const excludes = [ `test`, ] +let defaultExtractor + +beforeEach(() => { + let context = { tailwindConfig: { separator: ':' } } + defaultExtractor = createDefaultExtractor(context) +}) + test('The default extractor works as expected', async () => { const extractions = defaultExtractor([jsExamples, jsxExamples, htmlExamples].join('\n').trim())