Skip to content

Commit d676086

Browse files
Rewrite default class extractor (#8204)
* Rewrite default extractor * Eliminate lookbehind assertions in expand apply at rules * Update changelog
1 parent bb0ab67 commit d676086

8 files changed

+293
-47
lines changed

CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- Support PostCSS config options in config file in CLI ([#8226](https://github.com/tailwindlabs/tailwindcss/pull/8226))
2020
- Remove default `[hidden]` style in preflight ([#8248](https://github.com/tailwindlabs/tailwindcss/pull/8248))
2121
- Only check selectors containing base apply candidates for circular dependencies ([#8222](https://github.com/tailwindlabs/tailwindcss/pull/8222))
22-
- Handle utilities with multiple and/or grouped selectors better ([#8262](https://github.com/tailwindlabs/tailwindcss/pull/8262))
22+
- Rewrite default class extractor ([#8204](https://github.com/tailwindlabs/tailwindcss/pull/8204))
2323

2424
### Added
2525

src/lib/defaultExtractor.js

+151-33
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,160 @@
1-
const PATTERNS = [
2-
/(?:\['([^'\s]+[^<>"'`\s:\\])')/.source, // ['text-lg' -> text-lg
3-
/(?:\["([^"\s]+[^<>"'`\s:\\])")/.source, // ["text-lg" -> text-lg
4-
/(?:\[`([^`\s]+[^<>"'`\s:\\])`)/.source, // [`text-lg` -> text-lg
5-
/([^${(<>"'`\s]*\[\w*'[^"`\s]*'?\])/.source, // font-['some_font',sans-serif]
6-
/([^${(<>"'`\s]*\[\w*"[^'`\s]*"?\])/.source, // font-["some_font",sans-serif]
7-
/([^<>"'`\s]*\[\w*\('[^"'`\s]*'\)\])/.source, // bg-[url('...')]
8-
/([^<>"'`\s]*\[\w*\("[^"'`\s]*"\)\])/.source, // bg-[url("...")]
9-
/([^<>"'`\s]*\[\w*\('[^"`\s]*'\)\])/.source, // bg-[url('...'),url('...')]
10-
/([^<>"'`\s]*\[\w*\("[^'`\s]*"\)\])/.source, // bg-[url("..."),url("...")]
11-
/([^<>"'`\s]*\[[^<>"'`\s]*\('[^"`\s]*'\)+\])/.source, // h-[calc(100%-theme('spacing.1'))]
12-
/([^<>"'`\s]*\[[^<>"'`\s]*\("[^'`\s]*"\)+\])/.source, // h-[calc(100%-theme("spacing.1"))]
13-
/([^${(<>"'`\s]*\['[^"'`\s]*'\])/.source, // `content-['hello']` but not `content-['hello']']`
14-
/([^${(<>"'`\s]*\["[^"'`\s]*"\])/.source, // `content-["hello"]` but not `content-["hello"]"]`
15-
/([^<>"'`\s]*\[[^<>"'`\s]*:[^\]\s]*\])/.source, // `[attr:value]`
16-
/([^<>"'`\s]*\[[^<>"'`\s]*:'[^"'`\s]*'\])/.source, // `[content:'hello']` but not `[content:"hello"]`
17-
/([^<>"'`\s]*\[[^<>"'`\s]*:"[^"'`\s]*"\])/.source, // `[content:"hello"]` but not `[content:'hello']`
18-
/([^<>"'`\s]*\[[^"'`\s]+\][^<>"'`\s]*)/.source, // `fill-[#bada55]`, `fill-[#bada55]/50`
19-
/([^"'`\s]*[^<>"'`\s:\\])/.source, // `<sm:underline`, `md>:font-bold`
20-
/([^<>"'`\s]*[^"'`\s:\\])/.source, // `px-1.5`, `uppercase` but not `uppercase:`
21-
22-
// Arbitrary properties
23-
// /([^"\s]*\[[^\s]+?\][^"\s]*)/.source,
24-
// /([^'\s]*\[[^\s]+?\][^'\s]*)/.source,
25-
// /([^`\s]*\[[^\s]+?\][^`\s]*)/.source,
26-
].join('|')
27-
28-
const BROAD_MATCH_GLOBAL_REGEXP = new RegExp(PATTERNS, 'g')
29-
const INNER_MATCH_GLOBAL_REGEXP = /[^<>"'`\s.(){}[\]#=%$]*[^<>"'`\s.(){}[\]#=%:$]/g
1+
import * as regex from './regex'
2+
3+
let patterns = Array.from(buildRegExps())
304

315
/**
326
* @param {string} content
337
*/
348
export function defaultExtractor(content) {
35-
let broadMatches = content.matchAll(BROAD_MATCH_GLOBAL_REGEXP)
36-
let innerMatches = content.match(INNER_MATCH_GLOBAL_REGEXP) || []
37-
let results = [...broadMatches, ...innerMatches].flat().filter((v) => v !== undefined)
9+
/** @type {(string|string)[]} */
10+
let results = []
11+
12+
for (let pattern of patterns) {
13+
results.push(...(content.match(pattern) ?? []))
14+
}
15+
16+
return results.filter((v) => v !== undefined).map(clipAtBalancedParens)
17+
}
18+
19+
function* buildRegExps() {
20+
yield regex.pattern([
21+
// Variants
22+
/((?=([^\s"'\\\[]+:))\2)?/,
23+
24+
// Important (optional)
25+
/!?/,
26+
27+
regex.any([
28+
// Arbitrary properties
29+
/\[[^\s:'"]+:[^\s\]]+\]/,
30+
31+
// Utilities
32+
regex.pattern([
33+
// Utility Name / Group Name
34+
/-?(?:\w+)/,
35+
36+
// Normal/Arbitrary values
37+
regex.optional(
38+
regex.any([
39+
regex.pattern([
40+
// Arbitrary values
41+
/-\[[^\s:]+\]/,
42+
43+
// Not immediately followed by an `{[(`
44+
/(?![{([]])/,
45+
46+
// optionally followed by an opacity modifier
47+
/(?:\/[^\s'"\\$]*)?/,
48+
]),
49+
50+
regex.pattern([
51+
// Arbitrary values
52+
/-\[[^\s]+\]/,
53+
54+
// Not immediately followed by an `{[(`
55+
/(?![{([]])/,
56+
57+
// optionally followed by an opacity modifier
58+
/(?:\/[^\s'"\\$]*)?/,
59+
]),
60+
61+
// Normal values w/o quotes — may include an opacity modifier
62+
/[-\/][^\s'"\\$={]*/,
63+
])
64+
),
65+
]),
66+
]),
67+
])
68+
69+
// 5. Inner matches
70+
// yield /[^<>"'`\s.(){}[\]#=%$]*[^<>"'`\s.(){}[\]#=%:$]/g
71+
}
72+
73+
// We want to capture any "special" characters
74+
// AND the characters immediately following them (if there is one)
75+
let SPECIALS = /([\[\]'"`])([^\[\]'"`])?/g
76+
let ALLOWED_CLASS_CHARACTERS = /[^"'`\s<>\]]+/
77+
78+
/**
79+
* Clips a string ensuring that parentheses, quotes, etc… are balanced
80+
* Used for arbitrary values only
81+
*
82+
* We will go past the end of the balanced parens until we find a non-class character
83+
*
84+
* Depth matching behavior:
85+
* w-[calc(100%-theme('spacing[some_key][1.5]'))]']
86+
* ┬ ┬ ┬┬ ┬ ┬┬ ┬┬┬┬┬┬┬
87+
* 1 2 3 4 34 3 210 END
88+
* ╰────┴──────────┴────────┴────────┴┴───┴─┴┴┴
89+
*
90+
* @param {string} input
91+
*/
92+
function clipAtBalancedParens(input) {
93+
// We are care about this for arbitrary values
94+
if (!input.includes('-[')) {
95+
return input
96+
}
97+
98+
let depth = 0
99+
let openStringTypes = []
100+
101+
// Find all parens, brackets, quotes, etc
102+
// Stop when we end at a balanced pair
103+
// This is naive and will treat mismatched parens as balanced
104+
// This shouldn't be a problem in practice though
105+
let matches = input.matchAll(SPECIALS)
106+
107+
// We can't use lookbehind assertions because we have to support Safari
108+
// So, instead, we've emulated it using capture groups and we'll re-work the matches to accommodate
109+
matches = Array.from(matches).flatMap((match) => {
110+
const [, ...groups] = match
111+
112+
return groups.map((group, idx) =>
113+
Object.assign([], match, {
114+
index: match.index + idx,
115+
0: group,
116+
})
117+
)
118+
})
119+
120+
for (let match of matches) {
121+
let char = match[0]
122+
let inStringType = openStringTypes[openStringTypes.length - 1]
123+
124+
if (char === inStringType) {
125+
openStringTypes.pop()
126+
} else if (char === "'" || char === '"' || char === '`') {
127+
openStringTypes.push(char)
128+
}
129+
130+
if (inStringType) {
131+
continue
132+
} else if (char === '[') {
133+
depth++
134+
continue
135+
} else if (char === ']') {
136+
depth--
137+
continue
138+
}
139+
140+
// We've gone one character past the point where we should stop
141+
// This means that there was an extra closing `]`
142+
// We'll clip to just before it
143+
if (depth < 0) {
144+
return input.substring(0, match.index)
145+
}
146+
147+
// We've finished balancing the brackets but there still may be characters that can be included
148+
// For example in the class `text-[#336699]/[.35]`
149+
// The depth goes to `0` at the closing `]` but goes up again at the `[`
150+
151+
// If we're at zero and encounter a non-class character then we clip the class there
152+
if (depth === 0 && !ALLOWED_CLASS_CHARACTERS.test(char)) {
153+
return input.substring(0, match.index)
154+
}
155+
}
38156

39-
return results
157+
return input
40158
}
41159

42160
// Regular utilities

src/lib/expandApplyAtRules.js

+11-3
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ function extractClasses(node) {
3434
return Object.assign(classes, { groups: normalizedGroups })
3535
}
3636

37+
let selectorExtractor = parser((root) => root.nodes.map((node) => node.toString()))
38+
39+
/**
40+
* @param {string} ruleSelectors
41+
*/
42+
function extractSelectors(ruleSelectors) {
43+
return selectorExtractor.transformSync(ruleSelectors)
44+
}
45+
3746
function extractBaseCandidates(candidates, separator) {
3847
let baseClasses = new Set()
3948

@@ -295,10 +304,9 @@ function processApply(root, context, localCache) {
295304
function replaceSelector(selector, utilitySelectors, candidate) {
296305
let needle = `.${escapeClassName(candidate)}`
297306
let needles = [...new Set([needle, needle.replace(/\\2c /g, '\\,')])]
298-
let utilitySelectorsList = utilitySelectors.split(/\s*(?<!\\)\,(?![^(]*\))\s*/g)
307+
let utilitySelectorsList = extractSelectors(utilitySelectors)
299308

300-
return selector
301-
.split(/\s*(?<!\\)\,(?![^(]*\))\s*/g)
309+
return extractSelectors(selector)
302310
.map((s) => {
303311
let replaced = []
304312

src/lib/expandTailwindAtRules.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ const builtInTransformers = {
1717
svelte: (content) => content.replace(/(?:^|\s)class:/g, ' '),
1818
}
1919

20-
function getExtractor(tailwindConfig, fileExtension) {
21-
let extractors = tailwindConfig.content.extract
20+
function getExtractor(context, fileExtension) {
21+
let extractors = context.tailwindConfig.content.extract
2222

2323
return (
2424
extractors[fileExtension] ||
@@ -165,7 +165,7 @@ export default function expandTailwindAtRules(context) {
165165

166166
for (let { content, extension } of context.changedContent) {
167167
let transformer = getTransformer(context.tailwindConfig, extension)
168-
let extractor = getExtractor(context.tailwindConfig, extension)
168+
let extractor = getExtractor(context, extension)
169169
getClassCandidates(transformer(content), extractor, candidates, seen)
170170
}
171171

src/lib/regex.js

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
const REGEX_SPECIAL = /[\\^$.*+?()[\]{}|]/g
2+
const REGEX_HAS_SPECIAL = RegExp(REGEX_SPECIAL.source)
3+
4+
/**
5+
* @param {string|RegExp|Array<string|RegExp>} source
6+
*/
7+
function toSource(source) {
8+
source = Array.isArray(source) ? source : [source]
9+
10+
source = source.map((item) => (item instanceof RegExp ? item.source : item))
11+
12+
return source.join('')
13+
}
14+
15+
/**
16+
* @param {string|RegExp|Array<string|RegExp>} source
17+
*/
18+
export function pattern(source) {
19+
return new RegExp(toSource(source), 'g')
20+
}
21+
22+
/**
23+
* @param {string|RegExp|Array<string|RegExp>} source
24+
*/
25+
export function withoutCapturing(source) {
26+
return new RegExp(`(?:${toSource(source)})`, 'g')
27+
}
28+
29+
/**
30+
* @param {Array<string|RegExp>} sources
31+
*/
32+
export function any(sources) {
33+
return `(?:${sources.map(toSource).join('|')})`
34+
}
35+
36+
/**
37+
* @param {string|RegExp} source
38+
*/
39+
export function optional(source) {
40+
return `(?:${toSource(source)})?`
41+
}
42+
43+
/**
44+
* @param {string|RegExp|Array<string|RegExp>} source
45+
*/
46+
export function zeroOrMore(source) {
47+
return `(?:${toSource(source)})*`
48+
}
49+
50+
/**
51+
* Generate a RegExp that matches balanced brackets for a given depth
52+
* We have to specify a depth because JS doesn't support recursive groups using ?R
53+
*
54+
* Based on https://stackoverflow.com/questions/17759004/how-to-match-string-within-parentheses-nested-in-java/17759264#17759264
55+
*
56+
* @param {string|RegExp|Array<string|RegExp>} source
57+
*/
58+
export function nestedBrackets(open, close, depth = 1) {
59+
return withoutCapturing([
60+
escape(open),
61+
/[^\s]*/,
62+
depth === 1
63+
? `[^${escape(open)}${escape(close)}\s]*`
64+
: any([`[^${escape(open)}${escape(close)}\s]*`, nestedBrackets(open, close, depth - 1)]),
65+
/[^\s]*/,
66+
escape(close),
67+
])
68+
}
69+
70+
export function escape(string) {
71+
return string && REGEX_HAS_SPECIAL.test(string)
72+
? string.replace(REGEX_SPECIAL, '\\$&')
73+
: string || ''
74+
}

tests/arbitrary-values.test.css

+3
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,9 @@
316316
.cursor-\[url\(hand\.cur\)_2_2\2c pointer\] {
317317
cursor: url(hand.cur) 2 2, pointer;
318318
}
319+
.cursor-\[url\(\'\.\/path_to_hand\.cur\'\)_2_2\2c pointer\] {
320+
cursor: url("./path_to_hand.cur") 2 2, pointer;
321+
}
319322
.cursor-\[var\(--value\)\] {
320323
cursor: var(--value);
321324
}

tests/basic-usage.test.js

+29
Original file line numberDiff line numberDiff line change
@@ -401,3 +401,32 @@ it('should generate styles using :not(.unknown-class) even if `.unknown-class` d
401401
`)
402402
})
403403
})
404+
405+
it('supports multiple backgrounds as arbitrary values even if only some are quoted', () => {
406+
let config = {
407+
content: [
408+
{
409+
raw: html`<div
410+
class="bg-[url('/images/one-two-three.png'),linear-gradient(to_right,_#eeeeee,_#000000)]"
411+
></div>`,
412+
},
413+
],
414+
corePlugins: { preflight: false },
415+
}
416+
417+
let input = css`
418+
@tailwind utilities;
419+
`
420+
421+
return run(input, config).then((result) => {
422+
expect(result.css).toMatchFormattedCss(css`
423+
.bg-\[url\(\'\/images\/one-two-three\.png\'\)\2c
424+
linear-gradient\(to_right\2c
425+
_\#eeeeee\2c
426+
_\#000000\)\] {
427+
background-image: url('/images/one-two-three.png'),
428+
linear-gradient(to right, #eeeeee, #000000);
429+
}
430+
`)
431+
})
432+
})

0 commit comments

Comments
 (0)