diff --git a/CHANGELOG.md b/CHANGELOG.md
index 204dccc5d7d1..38324dbd1726 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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))
diff --git a/integrations/cli/plugins.test.ts b/integrations/cli/plugins.test.ts
index 8d889b7b9a62..833b0f6779a6 100644
--- a/integrations/cli/plugins.test.ts
+++ b/integrations/cli/plugins.test.ts
@@ -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`
+
+
+
+ `,
+ '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',
{
diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts
index 6aabab529865..a74e6cdc7c3f 100644
--- a/packages/tailwindcss/src/compat/plugin-api.test.ts
+++ b/packages/tailwindcss/src/compat/plugin-api.test.ts
@@ -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 {
+ }"
+ `,
+ )
})
})
diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts
index 9afb93140092..9e85001a8e8d 100644
--- a/packages/tailwindcss/src/compat/plugin-api.ts
+++ b/packages/tailwindcss/src/compat/plugin-api.ts
@@ -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 = {}
+ let utils = new DefaultMap(() => [])
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\`.`,
)
}
+ }
+
+ 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(name.slice(1), () => {
- let ast = objectToAst(css)
- substituteAtApply(ast, designSystem)
- return ast
+ designSystem.utilities.static(className, () => {
+ let clonedAst = structuredClone(ast)
+ substituteAtApply(clonedAst, designSystem)
+ return clonedAst
})
}
},
diff --git a/packages/tailwindcss/src/compat/selector-parser.test.ts b/packages/tailwindcss/src/compat/selector-parser.test.ts
new file mode 100644
index 000000000000..1c0b808b08d1
--- /dev/null
+++ b/packages/tailwindcss/src/compat/selector-parser.test.ts
@@ -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')
+ })
+})
diff --git a/packages/tailwindcss/src/compat/selector-parser.ts b/packages/tailwindcss/src/compat/selector-parser.ts
new file mode 100644
index 000000000000..f2ff408d9294
--- /dev/null
+++ b/packages/tailwindcss/src/compat/selector-parser.ts
@@ -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
+}