From bc004455bce83c15ccbd7f8beb35825e3ef343b5 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 17 Oct 2022 12:38:21 +0200 Subject: [PATCH] Expose `context.getVariants` for intellisense (#9505) * add `context.getVariants` * use `modifier` instead of `label` * handle `modifySelectors` version * use reference * reverse engineer manual format strings if container was touched * use new positional API for `matchVariant` * update changelog --- CHANGELOG.md | 1 + src/corePlugins.js | 2 +- src/lib/generateRules.js | 2 +- src/lib/setupContextUtils.js | 191 ++++++++++++++++++++++++++++++----- tests/getVariants.test.js | 140 +++++++++++++++++++++++++ 5 files changed, 309 insertions(+), 27 deletions(-) create mode 100644 tests/getVariants.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b24a4c00..b4ecd1bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add aria variants ([#9557](https://github.com/tailwindlabs/tailwindcss/pull/9557)) - Add `data-*` variants ([#9559](https://github.com/tailwindlabs/tailwindcss/pull/9559)) - Upgrade to `postcss-nested` v6.0 ([#9546](https://github.com/tailwindlabs/tailwindcss/pull/9546)) +- Expose `context.getVariants` for intellisense ([#9505](https://github.com/tailwindlabs/tailwindcss/pull/9505)) ### Fixed diff --git a/src/corePlugins.js b/src/corePlugins.js index 119d2e645..e4a50cb62 100644 --- a/src/corePlugins.js +++ b/src/corePlugins.js @@ -375,7 +375,7 @@ export let variantPlugins = { check = `(${check})` } - return `@supports ${check} ` + return `@supports ${check}` }, { values: theme('supports') ?? {} } ) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 961b6fa5e..d56427896 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -18,7 +18,7 @@ let classNameParser = selectorParser((selectors) => { return selectors.first.filter(({ type }) => type === 'class').pop().value }) -function getClassNameFromSelector(selector) { +export function getClassNameFromSelector(selector) { return classNameParser.transformSync(selector) } diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index 55e8e0748..6abfa0e48 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -18,12 +18,21 @@ import { toPath } from '../util/toPath' import log from '../util/log' import negateValue from '../util/negateValue' import isValidArbitraryValue from '../util/isValidArbitraryValue' -import { generateRules } from './generateRules' +import { generateRules, getClassNameFromSelector } from './generateRules' import { hasContentChanged } from './cacheInvalidation.js' import { Offsets } from './offsets.js' import { flagEnabled } from '../featureFlags.js' +import { finalizeSelector, formatVariantSelector } from '../util/formatVariantSelector' -let MATCH_VARIANT = Symbol() +const VARIANT_TYPES = { + AddVariant: Symbol.for('ADD_VARIANT'), + MatchVariant: Symbol.for('MATCH_VARIANT'), +} + +const VARIANT_INFO = { + Base: 1 << 0, + Dynamic: 1 << 1, +} function prefix(context, selector) { let prefix = context.tailwindConfig.prefix @@ -524,7 +533,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs let result = variantFunction( Object.assign( { modifySelectors, container, separator }, - variantFunction[MATCH_VARIANT] && { args, wrap, format } + options.type === VARIANT_TYPES.MatchVariant && { args, wrap, format } ) ) @@ -570,33 +579,34 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs for (let [key, value] of Object.entries(options?.values ?? {})) { api.addVariant( isSpecial ? `${variant}${key}` : `${variant}-${key}`, - Object.assign( - ({ args, container }) => - variantFn( - value, - modifiersEnabled ? { modifier: args.modifier, container } : { container } - ), - { - [MATCH_VARIANT]: true, - } - ), - { ...options, value, id } + ({ args, container }) => + variantFn( + value, + modifiersEnabled ? { modifier: args.modifier, container } : { container } + ), + { + ...options, + value, + id, + type: VARIANT_TYPES.MatchVariant, + variantInfo: VARIANT_INFO.Base, + } ) } api.addVariant( variant, - Object.assign( - ({ args, container }) => - variantFn( - args.value, - modifiersEnabled ? { modifier: args.modifier, container } : { container } - ), - { - [MATCH_VARIANT]: true, - } - ), - { ...options, id } + ({ args, container }) => + variantFn( + args.value, + modifiersEnabled ? { modifier: args.modifier, container } : { container } + ), + { + ...options, + id, + type: VARIANT_TYPES.MatchVariant, + variantInfo: VARIANT_INFO.Dynamic, + } ) }, } @@ -948,6 +958,137 @@ function registerPlugins(plugins, context) { return output } + + // Generate a list of available variants with meta information of the type of variant. + context.getVariants = function getVariants() { + let result = [] + for (let [name, options] of context.variantOptions.entries()) { + if (options.variantInfo === VARIANT_INFO.Base) continue + + result.push({ + name, + isArbitrary: options.type === Symbol.for('MATCH_VARIANT'), + values: Object.keys(options.values ?? {}), + selectors({ modifier, value } = {}) { + let candidate = '__TAILWIND_PLACEHOLDER__' + + let rule = postcss.rule({ selector: `.${candidate}` }) + let container = postcss.root({ nodes: [rule.clone()] }) + + let before = container.toString() + + let fns = (context.variantMap.get(name) ?? []).flatMap(([_, fn]) => fn) + let formatStrings = [] + for (let fn of fns) { + let localFormatStrings = [] + + let api = { + args: { modifier, value: options.values?.[value] ?? value }, + separator: context.tailwindConfig.separator, + modifySelectors(modifierFunction) { + // Run the modifierFunction over each rule + container.each((rule) => { + if (rule.type !== 'rule') { + return + } + + rule.selectors = rule.selectors.map((selector) => { + return modifierFunction({ + get className() { + return getClassNameFromSelector(selector) + }, + selector, + }) + }) + }) + + return container + }, + format(str) { + localFormatStrings.push(str) + }, + wrap(wrapper) { + localFormatStrings.push(`@${wrapper.name} ${wrapper.params} { & }`) + }, + container, + } + + let ruleWithVariant = fn(api) + if (localFormatStrings.length > 0) { + formatStrings.push(localFormatStrings) + } + + if (Array.isArray(ruleWithVariant)) { + for (let variantFunction of ruleWithVariant) { + localFormatStrings = [] + variantFunction(api) + formatStrings.push(localFormatStrings) + } + } + } + + // Reverse engineer the result of the `container` + let manualFormatStrings = [] + let after = container.toString() + + if (before !== after) { + // Figure out all selectors + container.walkRules((rule) => { + let modified = rule.selector + + // Rebuild the base selector, this is what plugin authors would do + // as well. E.g.: `${variant}${separator}${className}`. + // However, plugin authors probably also prepend or append certain + // classes, pseudos, ids, ... + let rebuiltBase = selectorParser((selectors) => { + selectors.walkClasses((classNode) => { + classNode.value = `${name}${context.tailwindConfig.separator}${classNode.value}` + }) + }).processSync(modified) + + // Now that we know the original selector, the new selector, and + // the rebuild part in between, we can replace the part that plugin + // authors need to rebuild with `&`, and eventually store it in the + // collectedFormats. Similar to what `format('...')` would do. + // + // E.g.: + // variant: foo + // selector: .markdown > p + // modified (by plugin): .foo .foo\\:markdown > p + // rebuiltBase (internal): .foo\\:markdown > p + // format: .foo & + manualFormatStrings.push(modified.replace(rebuiltBase, '&').replace(candidate, '&')) + }) + + // Figure out all atrules + container.walkAtRules((atrule) => { + manualFormatStrings.push(`@${atrule.name} (${atrule.params}) { & }`) + }) + } + + let result = formatStrings.map((formatString) => + finalizeSelector(formatVariantSelector('&', ...formatString), { + selector: `.${candidate}`, + candidate, + context, + isArbitraryVariant: !(value in (options.values ?? {})), + }) + .replace(`.${candidate}`, '&') + .replace('{ & }', '') + .trim() + ) + + if (manualFormatStrings.length > 0) { + result.push(formatVariantSelector('&', ...manualFormatStrings)) + } + + return result + }, + }) + } + + return result + } } /** diff --git a/tests/getVariants.test.js b/tests/getVariants.test.js new file mode 100644 index 000000000..f1a3d9f61 --- /dev/null +++ b/tests/getVariants.test.js @@ -0,0 +1,140 @@ +import postcss from 'postcss' +import selectorParser from 'postcss-selector-parser' +import resolveConfig from '../src/public/resolve-config' +import { createContext } from '../src/lib/setupContextUtils' + +it('should return a list of variants with meta information about the variant', () => { + let config = {} + let context = createContext(resolveConfig(config)) + + let variants = context.getVariants() + + expect(variants).toContainEqual({ + name: 'hover', + isArbitrary: false, + values: [], + selectors: expect.any(Function), + }) + + expect(variants).toContainEqual({ + name: 'group', + isArbitrary: true, + values: expect.any(Array), + selectors: expect.any(Function), + }) + + // `group-hover` now belongs to the `group` variant. The information exposed for the `group` + // variant is all you need. + expect(variants.find((v) => v.name === 'group-hover')).toBeUndefined() +}) + +it('should provide selectors for simple variants', () => { + let config = {} + let context = createContext(resolveConfig(config)) + + let variants = context.getVariants() + + let variant = variants.find((v) => v.name === 'hover') + expect(variant.selectors()).toEqual(['&:hover']) +}) + +it('should provide selectors for parallel variants', () => { + let config = {} + let context = createContext(resolveConfig(config)) + + let variants = context.getVariants() + + let variant = variants.find((v) => v.name === 'marker') + expect(variant.selectors()).toEqual(['& *::marker', '&::marker']) +}) + +it('should provide selectors for complex matchVariant variants like `group`', () => { + let config = {} + let context = createContext(resolveConfig(config)) + + let variants = context.getVariants() + + let variant = variants.find((v) => v.name === 'group') + expect(variant.selectors()).toEqual(['.group &']) + expect(variant.selectors({})).toEqual(['.group &']) + expect(variant.selectors({ value: 'hover' })).toEqual(['.group:hover &']) + expect(variant.selectors({ value: '.foo_&' })).toEqual(['.foo .group &']) + expect(variant.selectors({ modifier: 'foo', value: 'hover' })).toEqual(['.group\\/foo:hover &']) + expect(variant.selectors({ modifier: 'foo', value: '.foo_&' })).toEqual(['.foo .group\\/foo &']) +}) + +it('should provide selectors for variants with atrules', () => { + let config = {} + let context = createContext(resolveConfig(config)) + + let variants = context.getVariants() + + let variant = variants.find((v) => v.name === 'supports') + expect(variant.selectors({ value: 'display:grid' })).toEqual(['@supports (display:grid)']) + expect(variant.selectors({ value: 'aspect-ratio' })).toEqual([ + '@supports (aspect-ratio: var(--tw))', + ]) +}) + +it('should provide selectors for custom plugins that do a combination of parallel variants with modifiers with arbitrary values and with atrules', () => { + let config = { + plugins: [ + function ({ matchVariant }) { + matchVariant('foo', (value, { modifier }) => { + return [ + ` + @supports (foo: ${modifier}) { + @media (width <= 400px) { + &:hover + } + } + `, + `.${modifier}\\/${value} &:focus`, + ] + }) + }, + ], + } + let context = createContext(resolveConfig(config)) + + let variants = context.getVariants() + + let variant = variants.find((v) => v.name === 'foo') + expect(variant.selectors({ modifier: 'bar', value: 'baz' })).toEqual([ + '@supports (foo: bar) { @media (width <= 400px) { &:hover } }', + '.bar\\/baz &:focus', + ]) +}) + +it('should work for plugins that still use the modifySelectors API', () => { + let config = { + plugins: [ + function ({ addVariant }) { + addVariant('foo', ({ modifySelectors, container }) => { + // Manually mutating the selector + modifySelectors(({ selector }) => { + return selectorParser((selectors) => { + selectors.walkClasses((classNode) => { + classNode.value = `foo:${classNode.value}` + classNode.parent.insertBefore(classNode, selectorParser().astSync(`.foo `)) + }) + }).processSync(selector) + }) + + // Manually wrap in supports query + let wrapper = postcss.atRule({ name: 'supports', params: 'display: grid' }) + let nodes = container.nodes + container.removeAll() + wrapper.append(nodes) + container.append(wrapper) + }) + }, + ], + } + let context = createContext(resolveConfig(config)) + + let variants = context.getVariants() + + let variant = variants.find((v) => v.name === 'foo') + expect(variant.selectors({})).toEqual(['@supports (display: grid) { .foo .foo\\:& }']) +})