Support complex addUtilities() configs (#15029)
This PR adds support for complex `addUtilities()` configuration objects
that use child combinators and other features.
For example, in v3 it was possible to add a utility that changes the
behavior of all children of the utility class node by doing something
like this:
```ts
addUtilities({
'.red-children > *': {
color: 'red',
},
});
```
This is a pattern that was used by first-party plugins like
`@tailwindcss/aspect-ratio` but that we never made working in v4, since
it requires parsing the selector and properly extracting all utility
candidates.
While working on the codemod that can transform `@layer utilities`
scoped declarations like the above, we found out a pretty neat
heuristics on how to migrate these cases. We're basically finding all
class selectors and replace them with `&`. Then we create a nested CSS
structure like this:
```css
.red-children {
& > * {
color: red;
}
}
```
Due to first party support for nesting, this works as expected in v4.
## Test Plan
We added unit tests to ensure the rewriting works in some edge cases.
Furthermore we added an integration test running the
`@tailwindcss/aspect-ratio` plugin. We've also installed the tarballs in
the Remix example from the
[playgrounds](https://github.com/philipp-spiess/tailwindcss-playgrounds)
and ensure we can use the `@tailwindcss/aspect-ratio` plugin just like
we could in v3:
<img width="2560" alt="Screenshot 2024-11-18 at 13 44 52"
src="https://github.com/user-attachments/assets/31889131-fad0-4c37-b574-cfac2b99f786">
---------
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
parent
93f9c99027
commit
ab9e2b716b
6 changed files with 811 additions and 31 deletions
|
|
@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Reintroduce `max-w-screen-*` utilities that read from the `--breakpoint` namespace as deprecated utilities ([#15013](https://github.com/tailwindlabs/tailwindcss/pull/15013))
|
||||
- Support using CSS variables as arbitrary values without `var(…)` by using parentheses instead of square brackets (e.g. `bg-(--my-color)`) ([#15020](https://github.com/tailwindlabs/tailwindcss/pull/15020))
|
||||
- Add new `in-*` variant ([#15025](https://github.com/tailwindlabs/tailwindcss/pull/15025))
|
||||
- Allow `addUtilities()` and `addComponents()` to work with child combinators and other complex selectors ([#15029](https://github.com/tailwindlabs/tailwindcss/pull/15029))
|
||||
- _Upgrade (experimental)_: Migrate `[&>*]` to the `*` variant ([#15022](https://github.com/tailwindlabs/tailwindcss/pull/15022))
|
||||
- _Upgrade (experimental)_: Migrate `[&_*]` to the `**` variant ([#15022](https://github.com/tailwindlabs/tailwindcss/pull/15022))
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,46 @@ test(
|
|||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'builds the `@tailwindcss/aspect-ratio` plugin utilities',
|
||||
{
|
||||
fs: {
|
||||
'package.json': json`
|
||||
{
|
||||
"dependencies": {
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"tailwindcss": "workspace:^",
|
||||
"@tailwindcss/cli": "workspace:^"
|
||||
}
|
||||
}
|
||||
`,
|
||||
'index.html': html`
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
`,
|
||||
'src/index.css': css`
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/aspect-ratio';
|
||||
`,
|
||||
},
|
||||
},
|
||||
async ({ fs, exec }) => {
|
||||
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
|
||||
|
||||
await fs.expectFileToContain('dist/out.css', [
|
||||
//
|
||||
candidate`aspect-w-16`,
|
||||
candidate`aspect-h-9`,
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'builds the `tailwindcss-animate` plugin utilities',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2760,7 +2760,7 @@ describe('addUtilities()', () => {
|
|||
base,
|
||||
module: ({ addUtilities }: PluginAPI) => {
|
||||
addUtilities({
|
||||
'.text-trim > *': {
|
||||
':hover > *': {
|
||||
'text-box-trim': 'both',
|
||||
'text-box-edge': 'cap alphabetic',
|
||||
},
|
||||
|
|
@ -2842,18 +2842,171 @@ describe('addUtilities()', () => {
|
|||
},
|
||||
)
|
||||
|
||||
expect(optimizeCss(compiled.build(['form-input', 'lg:form-textarea'])).trim())
|
||||
.toMatchInlineSnapshot(`
|
||||
".form-input, .form-input::placeholder {
|
||||
expect(compiled.build(['form-input', 'lg:form-textarea']).trim()).toMatchInlineSnapshot(`
|
||||
".form-input {
|
||||
background-color: red;
|
||||
&::placeholder {
|
||||
background-color: red;
|
||||
}
|
||||
|
||||
}
|
||||
.lg\\:form-textarea {
|
||||
@media (width >= 1024px) {
|
||||
.lg\\:form-textarea:hover:focus {
|
||||
&:hover:focus {
|
||||
background-color: red;
|
||||
}
|
||||
}"
|
||||
`)
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
test('nests complex utility names', async () => {
|
||||
let compiled = await compile(
|
||||
css`
|
||||
@plugin "my-plugin";
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
`,
|
||||
{
|
||||
async loadModule(id, base) {
|
||||
return {
|
||||
base,
|
||||
module: ({ addUtilities }: PluginAPI) => {
|
||||
addUtilities({
|
||||
'.a .b:hover .c': {
|
||||
color: 'red',
|
||||
},
|
||||
'.d > *': {
|
||||
color: 'red',
|
||||
},
|
||||
'.e .bar:not(.f):has(.g)': {
|
||||
color: 'red',
|
||||
},
|
||||
'.h~.i': {
|
||||
color: 'red',
|
||||
},
|
||||
'.j.j': {
|
||||
color: 'red',
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(
|
||||
compiled.build(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']).trim(),
|
||||
).toMatchInlineSnapshot(
|
||||
`
|
||||
"@layer utilities {
|
||||
.a {
|
||||
& .b:hover .c {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
.b {
|
||||
.a &:hover .c {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
.c {
|
||||
.a .b:hover & {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
.d {
|
||||
& > * {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
.e {
|
||||
& .bar:not(.f):has(.g) {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
.g {
|
||||
.e .bar:not(.f):has(&) {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
.h {
|
||||
&~.i {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
.i {
|
||||
.h~& {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
.j {
|
||||
&.j {
|
||||
color: red;
|
||||
}
|
||||
.j& {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
}"
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
test('prefixes nested class names with the configured theme prefix', async () => {
|
||||
let compiled = await compile(
|
||||
css`
|
||||
@plugin "my-plugin";
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
||||
@theme prefix(tw) {
|
||||
}
|
||||
`,
|
||||
{
|
||||
async loadModule(id, base) {
|
||||
return {
|
||||
base,
|
||||
module: ({ addUtilities }: PluginAPI) => {
|
||||
addUtilities({
|
||||
'.a .b:hover .c.d': {
|
||||
color: 'red',
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(compiled.build(['tw:a', 'tw:b', 'tw:c', 'tw:d']).trim()).toMatchInlineSnapshot(
|
||||
`
|
||||
"@layer utilities {
|
||||
.tw\\:a {
|
||||
& .tw\\:b:hover .tw\\:c.tw\\:d {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
.tw\\:b {
|
||||
.tw\\:a &:hover .tw\\:c.tw\\:d {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
.tw\\:c {
|
||||
.tw\\:a .tw\\:b:hover &.tw\\:d {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
.tw\\:d {
|
||||
.tw\\:a .tw\\:b:hover .tw\\:c& {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
:root {
|
||||
}"
|
||||
`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import { substituteAtApply } from '../apply'
|
||||
import { atRule, decl, rule, type AstNode } from '../ast'
|
||||
import { atRule, decl, rule, walk, type AstNode } from '../ast'
|
||||
import type { Candidate, CandidateModifier, NamedUtilityValue } from '../candidate'
|
||||
import { substituteFunctions } from '../css-functions'
|
||||
import * as CSS from '../css-parser'
|
||||
import type { DesignSystem } from '../design-system'
|
||||
import { withAlpha } from '../utilities'
|
||||
import { DefaultMap } from '../utils/default-map'
|
||||
import { inferDataType } from '../utils/infer-data-type'
|
||||
import { segment } from '../utils/segment'
|
||||
import { toKeyPath } from '../utils/to-key-path'
|
||||
import { compoundsForSelectors, substituteAtSlot } from '../variants'
|
||||
import type { ResolvedConfig, UserConfig } from './config/types'
|
||||
import { createThemeFn } from './plugin-functions'
|
||||
import * as SelectorParser from './selector-parser'
|
||||
|
||||
export type Config = UserConfig
|
||||
export type PluginFn = (api: PluginAPI) => void
|
||||
|
|
@ -198,40 +200,68 @@ export function buildPluginApi(
|
|||
)
|
||||
|
||||
// Merge entries for the same class
|
||||
let utils: Record<string, CssInJs[]> = {}
|
||||
let utils = new DefaultMap<string, AstNode[]>(() => [])
|
||||
|
||||
for (let [name, css] of entries) {
|
||||
let [className, ...parts] = segment(name, ':')
|
||||
|
||||
// Modify classes using pseudo-classes or pseudo-elements to use nested rules
|
||||
if (parts.length > 0) {
|
||||
let pseudos = parts.map((p) => `:${p.trim()}`).join('')
|
||||
css = {
|
||||
[`&${pseudos}`]: css,
|
||||
}
|
||||
}
|
||||
|
||||
utils[className] ??= []
|
||||
css = Array.isArray(css) ? css : [css]
|
||||
utils[className].push(...css)
|
||||
}
|
||||
|
||||
for (let [name, css] of Object.entries(utils)) {
|
||||
if (name.startsWith('@keyframes ')) {
|
||||
ast.push(rule(name, objectToAst(css)))
|
||||
continue
|
||||
}
|
||||
|
||||
if (name[0] !== '.' || !IS_VALID_UTILITY_NAME.test(name.slice(1))) {
|
||||
let selectorAst = SelectorParser.parse(name)
|
||||
let foundValidUtility = false
|
||||
|
||||
SelectorParser.walk(selectorAst, (node) => {
|
||||
if (
|
||||
node.kind === 'selector' &&
|
||||
node.value[0] === '.' &&
|
||||
IS_VALID_UTILITY_NAME.test(node.value.slice(1))
|
||||
) {
|
||||
let value = node.value
|
||||
node.value = '&'
|
||||
let selector = SelectorParser.toCss(selectorAst)
|
||||
|
||||
let className = value.slice(1)
|
||||
let contents = selector === '&' ? objectToAst(css) : [rule(selector, objectToAst(css))]
|
||||
utils.get(className).push(...contents)
|
||||
foundValidUtility = true
|
||||
|
||||
node.value = value
|
||||
return
|
||||
}
|
||||
|
||||
if (node.kind === 'function' && node.value === ':not') {
|
||||
return SelectorParser.SelectorWalkAction.Skip
|
||||
}
|
||||
})
|
||||
|
||||
if (!foundValidUtility) {
|
||||
throw new Error(
|
||||
`\`addUtilities({ '${name}' : … })\` defines an invalid utility selector. Utilities must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
designSystem.utilities.static(name.slice(1), () => {
|
||||
let ast = objectToAst(css)
|
||||
substituteAtApply(ast, designSystem)
|
||||
return ast
|
||||
for (let [className, ast] of utils) {
|
||||
// Prefix all class selector with the configured theme prefix
|
||||
if (designSystem.theme.prefix) {
|
||||
walk(ast, (node) => {
|
||||
if (node.kind === 'rule') {
|
||||
let selectorAst = SelectorParser.parse(node.selector)
|
||||
SelectorParser.walk(selectorAst, (node) => {
|
||||
if (node.kind === 'selector' && node.value[0] === '.') {
|
||||
node.value = `.${designSystem.theme.prefix}\\:${node.value.slice(1)}`
|
||||
}
|
||||
})
|
||||
node.selector = SelectorParser.toCss(selectorAst)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
designSystem.utilities.static(className, () => {
|
||||
let clonedAst = structuredClone(ast)
|
||||
substituteAtApply(clonedAst, designSystem)
|
||||
return clonedAst
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
|
|||
122
packages/tailwindcss/src/compat/selector-parser.test.ts
Normal file
122
packages/tailwindcss/src/compat/selector-parser.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { parse, toCss, walk } from './selector-parser'
|
||||
|
||||
describe('parse', () => {
|
||||
it('should parse a simple selector', () => {
|
||||
expect(parse('.foo')).toEqual([{ kind: 'selector', value: '.foo' }])
|
||||
})
|
||||
|
||||
it('should parse a compound selector', () => {
|
||||
expect(parse('.foo.bar:hover#id')).toEqual([
|
||||
{ kind: 'selector', value: '.foo' },
|
||||
{ kind: 'selector', value: '.bar' },
|
||||
{ kind: 'selector', value: ':hover' },
|
||||
{ kind: 'selector', value: '#id' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should parse a selector list', () => {
|
||||
expect(parse('.foo,.bar')).toEqual([
|
||||
{ kind: 'selector', value: '.foo' },
|
||||
{ kind: 'separator', value: ',' },
|
||||
{ kind: 'selector', value: '.bar' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should combine everything within attribute selectors', () => {
|
||||
expect(parse('.foo[bar="baz"]')).toEqual([
|
||||
{ kind: 'selector', value: '.foo' },
|
||||
{ kind: 'selector', value: '[bar="baz"]' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should parse functions', () => {
|
||||
expect(parse('.foo:hover:not(.bar:focus)')).toEqual([
|
||||
{ kind: 'selector', value: '.foo' },
|
||||
{ kind: 'selector', value: ':hover' },
|
||||
{
|
||||
kind: 'function',
|
||||
nodes: [
|
||||
{
|
||||
kind: 'selector',
|
||||
value: '.bar',
|
||||
},
|
||||
{
|
||||
kind: 'selector',
|
||||
value: ':focus',
|
||||
},
|
||||
],
|
||||
value: ':not',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle next-children combinator', () => {
|
||||
expect(parse('.foo + p')).toEqual([
|
||||
{ kind: 'selector', value: '.foo' },
|
||||
{ kind: 'combinator', value: ' + ' },
|
||||
{ kind: 'selector', value: 'p' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle escaped characters', () => {
|
||||
expect(parse('foo\\.bar')).toEqual([{ kind: 'selector', value: 'foo\\.bar' }])
|
||||
})
|
||||
|
||||
it('parses :nth-child()', () => {
|
||||
expect(parse(':nth-child(n+1)')).toEqual([
|
||||
{
|
||||
kind: 'function',
|
||||
value: ':nth-child',
|
||||
nodes: [
|
||||
{
|
||||
kind: 'value',
|
||||
value: 'n+1',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('toCss', () => {
|
||||
it('should print a simple selector', () => {
|
||||
expect(toCss(parse('.foo'))).toBe('.foo')
|
||||
})
|
||||
|
||||
it('should print a compound selector', () => {
|
||||
expect(toCss(parse('.foo.bar:hover#id'))).toBe('.foo.bar:hover#id')
|
||||
})
|
||||
|
||||
it('should print a selector list', () => {
|
||||
expect(toCss(parse('.foo,.bar'))).toBe('.foo,.bar')
|
||||
})
|
||||
|
||||
it('should print an attribute selectors', () => {
|
||||
expect(toCss(parse('.foo[bar="baz"]'))).toBe('.foo[bar="baz"]')
|
||||
})
|
||||
|
||||
it('should print a function', () => {
|
||||
expect(toCss(parse('.foo:hover:not(.bar:focus)'))).toBe('.foo:hover:not(.bar:focus)')
|
||||
})
|
||||
|
||||
it('should print escaped characters', () => {
|
||||
expect(toCss(parse('foo\\.bar'))).toBe('foo\\.bar')
|
||||
})
|
||||
|
||||
it('should print :nth-child()', () => {
|
||||
expect(toCss(parse(':nth-child(n+1)'))).toBe(':nth-child(n+1)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('walk', () => {
|
||||
it('can be used to replace a function call', () => {
|
||||
const ast = parse('.foo:hover:not(.bar:focus)')
|
||||
walk(ast, (node, { replaceWith }) => {
|
||||
if (node.kind === 'function' && node.value === ':not') {
|
||||
replaceWith({ kind: 'selector', value: '.inverted-bar' })
|
||||
}
|
||||
})
|
||||
expect(toCss(ast)).toBe('.foo:hover.inverted-bar')
|
||||
})
|
||||
})
|
||||
434
packages/tailwindcss/src/compat/selector-parser.ts
Normal file
434
packages/tailwindcss/src/compat/selector-parser.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
export type SelectorCombinatorNode = {
|
||||
kind: 'combinator'
|
||||
value: string
|
||||
}
|
||||
|
||||
export type SelectorFunctionNode = {
|
||||
kind: 'function'
|
||||
value: string
|
||||
nodes: SelectorAstNode[]
|
||||
}
|
||||
|
||||
export type SelectorNode = {
|
||||
kind: 'selector'
|
||||
value: string
|
||||
}
|
||||
|
||||
export type SelectorValueNode = {
|
||||
kind: 'value'
|
||||
value: string
|
||||
}
|
||||
|
||||
export type SelectorSeparatorNode = {
|
||||
kind: 'separator'
|
||||
value: string
|
||||
}
|
||||
|
||||
export type SelectorAstNode =
|
||||
| SelectorCombinatorNode
|
||||
| SelectorFunctionNode
|
||||
| SelectorNode
|
||||
| SelectorSeparatorNode
|
||||
| SelectorValueNode
|
||||
type SelectorParentNode = SelectorFunctionNode | null
|
||||
|
||||
function combinator(value: string): SelectorCombinatorNode {
|
||||
return {
|
||||
kind: 'combinator',
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
function fun(value: string, nodes: SelectorAstNode[]): SelectorFunctionNode {
|
||||
return {
|
||||
kind: 'function',
|
||||
value: value,
|
||||
nodes,
|
||||
}
|
||||
}
|
||||
|
||||
function selector(value: string): SelectorNode {
|
||||
return {
|
||||
kind: 'selector',
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
function separator(value: string): SelectorSeparatorNode {
|
||||
return {
|
||||
kind: 'separator',
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
function value(value: string): SelectorValueNode {
|
||||
return {
|
||||
kind: 'value',
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
export enum SelectorWalkAction {
|
||||
/** Continue walking, which is the default */
|
||||
Continue,
|
||||
|
||||
/** Skip visiting the children of this node */
|
||||
Skip,
|
||||
|
||||
/** Stop the walk entirely */
|
||||
Stop,
|
||||
}
|
||||
|
||||
export function walk(
|
||||
ast: SelectorAstNode[],
|
||||
visit: (
|
||||
node: SelectorAstNode,
|
||||
utils: {
|
||||
parent: SelectorParentNode
|
||||
replaceWith(newNode: SelectorAstNode | SelectorAstNode[]): void
|
||||
},
|
||||
) => void | SelectorWalkAction,
|
||||
parent: SelectorParentNode = null,
|
||||
) {
|
||||
for (let i = 0; i < ast.length; i++) {
|
||||
let node = ast[i]
|
||||
let status =
|
||||
visit(node, {
|
||||
parent,
|
||||
replaceWith(newNode) {
|
||||
ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode]))
|
||||
// We want to visit the newly replaced node(s), which start at the
|
||||
// current index (i). By decrementing the index here, the next loop
|
||||
// will process this position (containing the replaced node) again.
|
||||
i--
|
||||
},
|
||||
}) ?? SelectorWalkAction.Continue
|
||||
|
||||
// Stop the walk entirely
|
||||
if (status === SelectorWalkAction.Stop) return
|
||||
|
||||
// Skip visiting the children of this node
|
||||
if (status === SelectorWalkAction.Skip) continue
|
||||
|
||||
if (node.kind === 'function') {
|
||||
walk(node.nodes, visit, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function toCss(ast: SelectorAstNode[]) {
|
||||
let css = ''
|
||||
for (const node of ast) {
|
||||
switch (node.kind) {
|
||||
case 'combinator':
|
||||
case 'selector':
|
||||
case 'separator':
|
||||
case 'value': {
|
||||
css += node.value
|
||||
break
|
||||
}
|
||||
case 'function': {
|
||||
css += node.value + '(' + toCss(node.nodes) + ')'
|
||||
}
|
||||
}
|
||||
}
|
||||
return css
|
||||
}
|
||||
|
||||
const BACKSLASH = 0x5c
|
||||
const CLOSE_BRACKET = 0x5d
|
||||
const CLOSE_PAREN = 0x29
|
||||
const COLON = 0x3a
|
||||
const COMMA = 0x2c
|
||||
const DOUBLE_QUOTE = 0x22
|
||||
const FULL_STOP = 0x2e
|
||||
const GREATER_THAN = 0x3e
|
||||
const NEWLINE = 0x0a
|
||||
const NUMBER_SIGN = 0x23
|
||||
const OPEN_BRACKET = 0x5b
|
||||
const OPEN_PAREN = 0x28
|
||||
const PLUS = 0x2b
|
||||
const SINGLE_QUOTE = 0x27
|
||||
const SPACE = 0x20
|
||||
const TAB = 0x09
|
||||
const TILDE = 0x7e
|
||||
|
||||
export function parse(input: string) {
|
||||
input = input.replaceAll('\r\n', '\n')
|
||||
|
||||
let ast: SelectorAstNode[] = []
|
||||
|
||||
let stack: (SelectorFunctionNode | null)[] = []
|
||||
|
||||
let parent = null as SelectorFunctionNode | null
|
||||
|
||||
let buffer = ''
|
||||
|
||||
let peekChar
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
let currentChar = input.charCodeAt(i)
|
||||
|
||||
switch (currentChar) {
|
||||
// E.g.:
|
||||
//
|
||||
// ```css
|
||||
// .foo .bar
|
||||
// ^
|
||||
//
|
||||
// .foo > .bar
|
||||
// ^^^
|
||||
// ```
|
||||
case COMMA:
|
||||
case GREATER_THAN:
|
||||
case NEWLINE:
|
||||
case SPACE:
|
||||
case PLUS:
|
||||
case TAB:
|
||||
case TILDE: {
|
||||
// 1. Handle everything before the combinator as a selector
|
||||
if (buffer.length > 0) {
|
||||
let node = selector(buffer)
|
||||
if (parent) {
|
||||
parent.nodes.push(node)
|
||||
} else {
|
||||
ast.push(node)
|
||||
}
|
||||
buffer = ''
|
||||
}
|
||||
|
||||
// 2. Look ahead and find the end of the combinator
|
||||
let start = i
|
||||
let end = i + 1
|
||||
for (; end < input.length; end++) {
|
||||
peekChar = input.charCodeAt(end)
|
||||
if (
|
||||
peekChar !== COMMA &&
|
||||
peekChar !== GREATER_THAN &&
|
||||
peekChar !== NEWLINE &&
|
||||
peekChar !== SPACE &&
|
||||
peekChar !== PLUS &&
|
||||
peekChar !== TAB &&
|
||||
peekChar !== TILDE
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
i = end - 1
|
||||
|
||||
let contents = input.slice(start, end)
|
||||
let node = contents.trim() === ',' ? separator(contents) : combinator(contents)
|
||||
if (parent) {
|
||||
parent.nodes.push(node)
|
||||
} else {
|
||||
ast.push(node)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
// Start of a function call.
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
// ```css
|
||||
// .foo:not(.bar)
|
||||
// ^
|
||||
// ```
|
||||
case OPEN_PAREN: {
|
||||
let node = fun(buffer, [])
|
||||
buffer = ''
|
||||
|
||||
// If the function is not one of the following, we combine all it's
|
||||
// contents into a single value node
|
||||
if (
|
||||
node.value !== ':not' &&
|
||||
node.value !== ':where' &&
|
||||
node.value !== ':has' &&
|
||||
node.value !== ':is'
|
||||
) {
|
||||
// Find the end of the function call
|
||||
let start = i + 1
|
||||
let nesting = 0
|
||||
|
||||
// Find the closing bracket.
|
||||
for (let j = i + 1; j < input.length; j++) {
|
||||
peekChar = input.charCodeAt(j)
|
||||
if (peekChar === OPEN_PAREN) {
|
||||
nesting++
|
||||
continue
|
||||
}
|
||||
if (peekChar === CLOSE_PAREN) {
|
||||
if (nesting === 0) {
|
||||
i = j
|
||||
break
|
||||
}
|
||||
nesting--
|
||||
}
|
||||
}
|
||||
let end = i
|
||||
|
||||
node.nodes.push(value(input.slice(start, end)))
|
||||
buffer = ''
|
||||
i = end
|
||||
|
||||
ast.push(node)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
parent.nodes.push(node)
|
||||
} else {
|
||||
ast.push(node)
|
||||
}
|
||||
stack.push(node)
|
||||
parent = node
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
// End of a function call.
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
// ```css
|
||||
// foo(bar, baz)
|
||||
// ^
|
||||
// ```
|
||||
case CLOSE_PAREN: {
|
||||
let tail = stack.pop()
|
||||
|
||||
// Handle everything before the closing paren a selector
|
||||
if (buffer.length > 0) {
|
||||
let node = selector(buffer)
|
||||
tail!.nodes.push(node)
|
||||
buffer = ''
|
||||
}
|
||||
|
||||
if (stack.length > 0) {
|
||||
parent = stack[stack.length - 1]
|
||||
} else {
|
||||
parent = null
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
// Split compound selectors.
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
// ```css
|
||||
// .foo.bar
|
||||
// ^
|
||||
// ```
|
||||
case FULL_STOP:
|
||||
case COLON:
|
||||
case NUMBER_SIGN: {
|
||||
// Handle everything before the combinator as a selector and
|
||||
// start a new selector
|
||||
if (buffer.length > 0) {
|
||||
let node = selector(buffer)
|
||||
if (parent) {
|
||||
parent.nodes.push(node)
|
||||
} else {
|
||||
ast.push(node)
|
||||
}
|
||||
}
|
||||
buffer = String.fromCharCode(currentChar)
|
||||
break
|
||||
}
|
||||
|
||||
// Start of an attribute selector.
|
||||
case OPEN_BRACKET: {
|
||||
// Handle everything before the combinator as a selector
|
||||
if (buffer.length > 0) {
|
||||
let node = selector(buffer)
|
||||
if (parent) {
|
||||
parent.nodes.push(node)
|
||||
} else {
|
||||
ast.push(node)
|
||||
}
|
||||
}
|
||||
buffer = ''
|
||||
|
||||
let start = i
|
||||
let nesting = 0
|
||||
|
||||
// Find the closing bracket.
|
||||
for (let j = i + 1; j < input.length; j++) {
|
||||
peekChar = input.charCodeAt(j)
|
||||
if (peekChar === OPEN_BRACKET) {
|
||||
nesting++
|
||||
continue
|
||||
}
|
||||
if (peekChar === CLOSE_BRACKET) {
|
||||
if (nesting === 0) {
|
||||
i = j
|
||||
break
|
||||
}
|
||||
nesting--
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust `buffer` to include the string.
|
||||
buffer += input.slice(start, i + 1)
|
||||
break
|
||||
}
|
||||
|
||||
// Start of a string.
|
||||
case SINGLE_QUOTE:
|
||||
case DOUBLE_QUOTE: {
|
||||
let start = i
|
||||
|
||||
// We need to ensure that the closing quote is the same as the opening
|
||||
// quote.
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
// ```css
|
||||
// "This is a string with a 'quote' in it"
|
||||
// ^ ^ -> These are not the end of the string.
|
||||
// ```
|
||||
for (let j = i + 1; j < input.length; j++) {
|
||||
peekChar = input.charCodeAt(j)
|
||||
// Current character is a `\` therefore the next character is escaped.
|
||||
if (peekChar === BACKSLASH) {
|
||||
j += 1
|
||||
}
|
||||
|
||||
// End of the string.
|
||||
else if (peekChar === currentChar) {
|
||||
i = j
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust `buffer` to include the string.
|
||||
buffer += input.slice(start, i + 1)
|
||||
break
|
||||
}
|
||||
|
||||
// Escaped characters.
|
||||
case BACKSLASH: {
|
||||
let nextChar = input.charCodeAt(i + 1)
|
||||
buffer += String.fromCharCode(currentChar) + String.fromCharCode(nextChar)
|
||||
i += 1
|
||||
break
|
||||
}
|
||||
|
||||
// Everything else will be collected in the buffer
|
||||
default: {
|
||||
buffer += String.fromCharCode(currentChar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect the remainder as a word
|
||||
if (buffer.length > 0) {
|
||||
ast.push(selector(buffer))
|
||||
}
|
||||
|
||||
return ast
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue