Fix negative utility generation and detection when using a prefix (#7295)

* Add failing tests for negative utility detection

We're not generating them properly in all cases, when using at-apply we sometimes crash, and safelisting doesn't currently work as expected.

* Refactor

* Generate utilities for negatives before and after the prefix

* Properly detect negative utilities with prefixes in the safelist

* Refactor test a bit

* Add class list tests

* Update changelog
This commit is contained in:
Jordan Pittman 2022-02-07 10:24:30 -05:00 committed by GitHub
parent ab9fd951dd
commit 01fbe196c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 384 additions and 23 deletions

View File

@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Correctly parse shadow lengths without a leading zero ([#7289](https://github.com/tailwindlabs/tailwindcss/pull/7289))
- Don't crash when scanning extremely long class candidates ([#7331](https://github.com/tailwindlabs/tailwindcss/pull/7331))
- Use less hacky fix for urls detected as custom properties ([#7275](https://github.com/tailwindlabs/tailwindcss/pull/7275))
- Correctly generate negative utilities when dash is before the prefix ([#7295](https://github.com/tailwindlabs/tailwindcss/pull/7295))
- Detect prefixed negative utilities in the safelist ([#7295](https://github.com/tailwindlabs/tailwindcss/pull/7295))
## [3.0.18] - 2022-01-28

View File

@ -63,9 +63,23 @@ function applyPrefix(matches, context) {
let [meta] = match
if (meta.options.respectPrefix) {
let container = postcss.root({ nodes: [match[1].clone()] })
let classCandidate = match[1].raws.tailwind.classCandidate
container.walkRules((r) => {
r.selector = prefixSelector(context.tailwindConfig.prefix, r.selector)
// If this is a negative utility with a dash *before* the prefix we
// have to ensure that the generated selector matches the candidate
// Not doing this will cause `-tw-top-1` to generate the class `.tw--top-1`
// The disconnect between candidate <-> class can cause @apply to hard crash.
let shouldPrependNegative = classCandidate.startsWith('-')
r.selector = prefixSelector(
context.tailwindConfig.prefix,
r.selector,
shouldPrependNegative
)
})
match[1] = container.nodes[0]
}
}
@ -371,6 +385,14 @@ function splitWithSeparator(input, separator) {
return input.split(new RegExp(`\\${separator}(?![^[]*\\])`, 'g'))
}
function* recordCandidates(matches, classCandidate) {
for (const match of matches) {
match[1].raws.tailwind = { classCandidate }
yield match
}
}
function* resolveMatches(candidate, context) {
let separator = context.tailwindConfig.separator
let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse()
@ -482,7 +504,9 @@ function* resolveMatches(candidate, context) {
continue
}
matches = applyPrefix(matches.flat(), context)
matches = matches.flat()
matches = Array.from(recordCandidates(matches, classCandidate))
matches = applyPrefix(matches, context)
if (important) {
matches = applyImportant(matches, context)

View File

@ -666,17 +666,30 @@ function registerPlugins(plugins, context) {
if (checks.length > 0) {
let patternMatchingCount = new Map()
let prefixLength = context.tailwindConfig.prefix.length
for (let util of classList) {
let utils = Array.isArray(util)
? (() => {
let [utilName, options] = util
let classes = Object.keys(options?.values ?? {}).map((value) =>
formatClass(utilName, value)
)
let values = Object.keys(options?.values ?? {})
let classes = values.map((value) => formatClass(utilName, value))
if (options?.supportsNegativeValues) {
// This is the normal negated version
// e.g. `-inset-1` or `-tw-inset-1`
classes = [...classes, ...classes.map((cls) => '-' + cls)]
// This is the negated version *after* the prefix
// e.g. `tw--inset-1`
// The prefix is already attached to util name
// So we add the negative after the prefix
classes = [
...classes,
...classes.map(
(cls) => cls.slice(0, prefixLength) + '-' + cls.slice(prefixLength)
),
]
}
return classes

View File

@ -1,12 +1,14 @@
import parser from 'postcss-selector-parser'
import { tap } from './tap'
export default function (prefix, selector) {
export default function (prefix, selector, prependNegative = false) {
return parser((selectors) => {
selectors.walkClasses((classSelector) => {
tap(classSelector.value, (baseClass) => {
classSelector.value = `${prefix}${baseClass}`
})
let baseClass = classSelector.value
let shouldPlaceNegativeBeforePrefix = prependNegative && baseClass.startsWith('-')
classSelector.value = shouldPlaceNegativeBeforePrefix
? `-${prefix}${baseClass.slice(1)}`
: `${prefix}${baseClass}`
})
}).processSync(selector)
}

View File

@ -5,22 +5,57 @@ it('should generate every possible class, without variants', () => {
let config = {}
let context = createContext(resolveConfig(config))
expect(context.getClassList()).toBeInstanceOf(Array)
let classes = context.getClassList()
expect(classes).toBeInstanceOf(Array)
// Verify we have a `container` for the 'components' section.
expect(context.getClassList()).toContain('container')
expect(classes).toContain('container')
// Verify we handle the DEFAULT case correctly
expect(context.getClassList()).toContain('border')
expect(classes).toContain('border')
// Verify we handle negative values correctly
expect(context.getClassList()).toContain('-inset-1/4')
expect(context.getClassList()).toContain('-m-0')
expect(context.getClassList()).not.toContain('-uppercase')
expect(context.getClassList()).not.toContain('-opacity-50')
expect(
createContext(
resolveConfig({ theme: { extend: { margin: { DEFAULT: '5px' } } } })
).getClassList()
).not.toContain('-m-DEFAULT')
expect(classes).toContain('-inset-1/4')
expect(classes).toContain('-m-0')
expect(classes).not.toContain('-uppercase')
expect(classes).not.toContain('-opacity-50')
config = { theme: { extend: { margin: { DEFAULT: '5px' } } } }
context = createContext(resolveConfig(config))
classes = context.getClassList()
expect(classes).not.toContain('-m-DEFAULT')
})
it('should generate every possible class while handling negatives and prefixes', () => {
let config = { prefix: 'tw-' }
let context = createContext(resolveConfig(config))
let classes = context.getClassList()
expect(classes).toBeInstanceOf(Array)
// Verify we have a `container` for the 'components' section.
expect(classes).toContain('tw-container')
// Verify we handle the DEFAULT case correctly
expect(classes).toContain('tw-border')
// Verify we handle negative values correctly
expect(classes).toContain('-tw-inset-1/4')
expect(classes).toContain('-tw-m-0')
expect(classes).not.toContain('-tw-uppercase')
expect(classes).not.toContain('-tw-opacity-50')
// These utilities do work but there's no reason to generate
// them alongside the `-{prefix}-{utility}` versions
expect(classes).not.toContain('tw--inset-1/4')
expect(classes).not.toContain('tw--m-0')
config = {
prefix: 'tw-',
theme: { extend: { margin: { DEFAULT: '5px' } } },
}
context = createContext(resolveConfig(config))
classes = context.getClassList()
expect(classes).not.toContain('-tw-m-DEFAULT')
})

View File

@ -1,7 +1,7 @@
import fs from 'fs'
import path from 'path'
import { run, css } from './util/run'
import { run, html, css } from './util/run'
test('prefix', () => {
let config = {
@ -73,3 +73,288 @@ test('prefix', () => {
expect(result.css).toMatchFormattedCss(expected)
})
})
it('negative values: marker before prefix', async () => {
let config = {
prefix: 'tw-',
content: [{ raw: html`<div class="-tw-top-1"></div>` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind utilities;
`
await run(input, config)
const result = await run(input, config)
expect(result.css).toMatchFormattedCss(css`
.-tw-top-1 {
top: -0.25rem;
}
`)
})
it('negative values: marker after prefix', async () => {
let config = {
prefix: 'tw-',
content: [{ raw: html`<div class="tw--top-1"></div>` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind utilities;
`
await run(input, config)
const result = await run(input, config)
expect(result.css).toMatchFormattedCss(css`
.tw--top-1 {
top: -0.25rem;
}
`)
})
it('negative values: marker before prefix and arbitrary value', async () => {
let config = {
prefix: 'tw-',
content: [{ raw: html`<div class="-tw-top-[1px]"></div>` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind utilities;
`
await run(input, config)
const result = await run(input, config)
expect(result.css).toMatchFormattedCss(css`
.-tw-top-\[1px\] {
top: -1px;
}
`)
})
it('negative values: marker after prefix and arbitrary value', async () => {
let config = {
prefix: 'tw-',
content: [{ raw: html`<div class="tw--top-[1px]"></div>` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind utilities;
`
await run(input, config)
const result = await run(input, config)
expect(result.css).toMatchFormattedCss(css`
.tw--top-\[1px\] {
top: -1px;
}
`)
})
it('negative values: no marker and arbitrary value', async () => {
let config = {
prefix: 'tw-',
content: [{ raw: html`<div class="tw-top-[-1px]"></div>` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind utilities;
`
await run(input, config)
const result = await run(input, config)
expect(result.css).toMatchFormattedCss(css`
.tw-top-\[-1px\] {
top: -1px;
}
`)
})
it('negative values: variant versions', async () => {
let config = {
prefix: 'tw-',
content: [
{
raw: html`
<div class="hover:-tw-top-1 hover:tw--top-1"></div>
<div class="hover:-tw-top-[1px] hover:tw--top-[1px]"></div>
<div class="hover:tw-top-[-1px]"></div>
<!-- this one should not generate anything -->
<div class="-hover:tw-top-1"></div>
`,
},
],
corePlugins: { preflight: false },
}
let input = css`
@tailwind utilities;
`
await run(input, config)
const result = await run(input, config)
expect(result.css).toMatchFormattedCss(css`
.hover\:-tw-top-1:hover {
top: -0.25rem;
}
.hover\:tw--top-1:hover {
top: -0.25rem;
}
.hover\:-tw-top-\[1px\]:hover {
top: -1px;
}
.hover\:tw--top-\[1px\]:hover {
top: -1px;
}
.hover\:tw-top-\[-1px\]:hover {
top: -1px;
}
`)
})
it('negative values: prefix and apply', async () => {
let config = {
prefix: 'tw-',
content: [{ raw: html`` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind utilities;
.a {
@apply hover:tw--top-1;
}
.b {
@apply hover:-tw-top-1;
}
.c {
@apply hover:-tw-top-[1px];
}
.d {
@apply hover:tw--top-[1px];
}
.e {
@apply hover:tw-top-[-1px];
}
`
await run(input, config)
const result = await run(input, config)
expect(result.css).toMatchFormattedCss(css`
.a:hover {
top: -0.25rem;
}
.b:hover {
top: -0.25rem;
}
.c:hover {
top: -1px;
}
.d:hover {
top: -1px;
}
.e:hover {
top: -1px;
}
`)
})
it('negative values: prefix in the safelist', async () => {
let config = {
prefix: 'tw-',
safelist: [{ pattern: /-tw-top-1/g }, { pattern: /tw--top-1/g }],
theme: {
inset: {
1: '0.25rem',
},
},
content: [{ raw: html`` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind utilities;
`
await run(input, config)
const result = await run(input, config)
expect(result.css).toMatchFormattedCss(css`
.-tw-top-1 {
top: -0.25rem;
}
.tw--top-1 {
top: -0.25rem;
}
`)
})
it('prefix with negative values and variants in the safelist', async () => {
let config = {
prefix: 'tw-',
safelist: [
{ pattern: /-tw-top-1/, variants: ['hover', 'sm:hover'] },
{ pattern: /tw--top-1/, variants: ['hover', 'sm:hover'] },
],
theme: {
inset: {
1: '0.25rem',
},
},
content: [{ raw: html`` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind utilities;
`
await run(input, config)
const result = await run(input, config)
expect(result.css).toMatchFormattedCss(css`
.-tw-top-1 {
top: -0.25rem;
}
.tw--top-1 {
top: -0.25rem;
}
.hover\:-tw-top-1:hover {
top: -0.25rem;
}
.hover\:tw--top-1:hover {
top: -0.25rem;
}
@media (min-width: 640px) {
.sm\:hover\:-tw-top-1:hover {
top: -0.25rem;
}
.sm\:hover\:tw--top-1:hover {
top: -0.25rem;
}
}
`)
})