This PR introduces a few more nodes in the `SelectorParser`: - A `list` node - A `complex` node - A `compound` node These names are closer to the CSS Selector AST names (https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Selectors/Selector_structure), and are also used in other libraries. The problem today is that there are situations where we parse a selector like: `#a.b > .c, .d` as: ```ts [ { kind: 'selector', value: '#a' }, { kind: 'selector', value: '.b' }, { kind: 'combinator', value: ' > ' }, { kind: 'selector', value: '.c' }, { kind: 'separator', value: ', ' }, { kind: 'selector', value: '.d' } ] ``` Which is a very simple structure, but this contains a flaw that is annoying to deal with in practice: In order to determine that we are dealing with multiple selectors, we have to loop through the nodes and see if a separator occurs somewhere. The other fun thing is that we already know the difference between selectors, combinators and separators. So if we tweak this structure a little bit during parsing, then we can answer the question from above in a much simpler way: With this PR, we will parse the selector as: ```ts [ { kind: 'list', nodes: [ { kind: 'complex', nodes: [ { kind: 'compound', nodes: [ { kind: 'selector', value: '#a' }, { kind: 'selector', value: '.b' } ] }, { kind: 'combinator', value: '>' }, { kind: 'selector', value: '.c' } ] }, { kind: 'selector', value: '.d' } ] } ] ``` It definitely looks more complex, but now that we have a `list` node, we already know that we are dealing with multiple selectors. If you squint your eyes, in the inner part there is a `compound` selector. This is essentially a node where each sub-node can be squished together with no spaces whatsoever. The `complex` selector is there just to group everything together. In other tools, a complex selector is often represented as: ```ts { kind: 'complex', combinator: '>', lhs: { … }, rhs: { … }, } ``` While I want to have the concept of a `complex` node, I didn't go with this syntax just because I want to keep the concept of `nodes` which means that we don't need any special handling when using `walk` (which loops over `.nodes` internally). The reason this complex node exists is because otherwise you would end up with this structure: ```ts [ { kind: 'list', nodes: [ { kind: 'compound', nodes: [ { kind: 'selector', value: '#a' }, { kind: 'selector', value: '.b' } ] }, { kind: 'combinator', value: '>' }, { kind: 'selector', value: '.c' } { kind: 'selector', value: '.d' } ] } ] ``` But if you look at the `list` node now, it's not clear that we are dealing with `2` selectors since there are 4 nodes. We could solve this by re-introducing the separator node (`,`). The fact that the `list` exists tells us that we're dealing with `n` selectors. But to know which selectors we're dealing with, then we have to look for that `,` node again, which introduces the original problem. This is just an internal refactor to make future changes easier. ## Test plan 1. Everything still works as expected (all tests pass) 2. No public API breaking changes, this parser was never exposed
294 lines
7.5 KiB
TypeScript
294 lines
7.5 KiB
TypeScript
import { candidate, css, html, json, test, ts } from '../utils'
|
|
|
|
test(
|
|
'builds the `@tailwindcss/typography` plugin utilities',
|
|
{
|
|
fs: {
|
|
'package.json': json`
|
|
{
|
|
"dependencies": {
|
|
"@tailwindcss/typography": "^0.5.14",
|
|
"tailwindcss": "workspace:^",
|
|
"@tailwindcss/cli": "workspace:^"
|
|
}
|
|
}
|
|
`,
|
|
'index.html': html`
|
|
<div className="prose prose-stone prose-invert">
|
|
<h1>Headline</h1>
|
|
<p>
|
|
Until now, trying to style an article, document, or blog post with Tailwind has been a
|
|
tedious task that required a keen eye for typography and a lot of complex custom CSS.
|
|
</p>
|
|
</div>
|
|
`,
|
|
'src/index.css': css`
|
|
@import 'tailwindcss';
|
|
@plugin '@tailwindcss/typography';
|
|
`,
|
|
},
|
|
},
|
|
async ({ fs, exec, expect }) => {
|
|
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
|
|
|
|
// Verify that `prose-stone` is defined before `prose-invert`
|
|
{
|
|
let contents = await fs.read('dist/out.css')
|
|
let proseInvertIdx = contents.indexOf('.prose-invert')
|
|
let proseStoneIdx = contents.indexOf('.prose-stone')
|
|
|
|
expect(proseStoneIdx).toBeLessThan(proseInvertIdx)
|
|
}
|
|
|
|
await fs.expectFileToContain('dist/out.css', [
|
|
candidate`prose`,
|
|
':where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *))',
|
|
':where(tbody td, tfoot td):not(:where([class~="not-prose"], [class~="not-prose"] *))',
|
|
])
|
|
},
|
|
)
|
|
|
|
test(
|
|
'builds the `@tailwindcss/forms` plugin utilities',
|
|
{
|
|
fs: {
|
|
'package.json': json`
|
|
{
|
|
"dependencies": {
|
|
"@tailwindcss/forms": "^0.5.7",
|
|
"tailwindcss": "workspace:^",
|
|
"@tailwindcss/cli": "workspace:^"
|
|
}
|
|
}
|
|
`,
|
|
'index.html': html`
|
|
<input type="text" class="form-input" />
|
|
<textarea class="form-textarea"></textarea>
|
|
`,
|
|
'src/index.css': css`
|
|
@import 'tailwindcss';
|
|
@plugin '@tailwindcss/forms';
|
|
`,
|
|
},
|
|
},
|
|
async ({ fs, exec }) => {
|
|
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
|
|
|
|
await fs.expectFileToContain('dist/out.css', [
|
|
//
|
|
candidate`form-input`,
|
|
candidate`form-textarea`,
|
|
])
|
|
await fs.expectFileNotToContain('dist/out.css', [
|
|
//
|
|
candidate`form-radio`,
|
|
])
|
|
},
|
|
)
|
|
|
|
test(
|
|
'builds the `@tailwindcss/forms` plugin utilities (with options)',
|
|
{
|
|
fs: {
|
|
'package.json': json`
|
|
{
|
|
"dependencies": {
|
|
"@tailwindcss/forms": "^0.5.7",
|
|
"tailwindcss": "workspace:^",
|
|
"@tailwindcss/cli": "workspace:^"
|
|
}
|
|
}
|
|
`,
|
|
'index.html': html`
|
|
<input type="text" class="form-input" />
|
|
<textarea class="form-textarea"></textarea>
|
|
`,
|
|
'src/index.css': css`
|
|
@import 'tailwindcss';
|
|
@plugin '@tailwindcss/forms' {
|
|
strategy: base;
|
|
}
|
|
`,
|
|
},
|
|
},
|
|
async ({ fs, exec }) => {
|
|
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
|
|
|
|
await fs.expectFileToContain('dist/out.css', [
|
|
//
|
|
`::-webkit-date-and-time-value`,
|
|
`input:where([type='checkbox']):indeterminate`,
|
|
])
|
|
|
|
// No classes are included even though they are used in the HTML
|
|
// because the `base` strategy is used
|
|
await fs.expectFileNotToContain('dist/out.css', [
|
|
//
|
|
candidate`form-input`,
|
|
candidate`form-textarea`,
|
|
candidate`form-radio`,
|
|
])
|
|
},
|
|
)
|
|
|
|
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',
|
|
{
|
|
fs: {
|
|
'package.json': json`
|
|
{
|
|
"dependencies": {
|
|
"tailwindcss-animate": "^1.0.7",
|
|
"tailwindcss": "workspace:^",
|
|
"@tailwindcss/cli": "workspace:^"
|
|
}
|
|
}
|
|
`,
|
|
'index.html': html`
|
|
<div class="animate-in fade-in zoom-in duration-350"></div>
|
|
`,
|
|
'src/index.css': css`
|
|
@import 'tailwindcss';
|
|
@plugin 'tailwindcss-animate';
|
|
`,
|
|
},
|
|
},
|
|
async ({ fs, exec }) => {
|
|
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
|
|
|
|
await fs.expectFileToContain('dist/out.css', [
|
|
candidate`animate-in`,
|
|
candidate`fade-in`,
|
|
candidate`zoom-in`,
|
|
candidate`duration-350`,
|
|
'transition-duration: 350ms',
|
|
'animation-duration: 350ms',
|
|
'@keyframes enter {',
|
|
])
|
|
},
|
|
)
|
|
|
|
// https://github.com/tailwindlabs/tailwindcss/issues/15844
|
|
test(
|
|
'builds CSS with a custom plugin compiled from TypeScript',
|
|
{
|
|
fs: {
|
|
'package.json': json`
|
|
{
|
|
"type": "module",
|
|
"dependencies": {
|
|
"tailwindcss": "workspace:^",
|
|
"@tailwindcss/cli": "workspace:^"
|
|
},
|
|
"devDependencies": {
|
|
"typescript": "^5.7.2"
|
|
}
|
|
}
|
|
`,
|
|
'tsconfig.json': json`
|
|
{
|
|
"compilerOptions": {
|
|
"target": "ES2022",
|
|
"module": "NodeNext",
|
|
"moduleResolution": "NodeNext",
|
|
"declaration": true,
|
|
"composite": true,
|
|
"rootDir": "./src",
|
|
"outDir": "./.build",
|
|
"skipLibCheck": true
|
|
},
|
|
"include": ["src/**/*.ts"]
|
|
}
|
|
`,
|
|
'index.html': html`
|
|
<div class="test-red"></div>
|
|
`,
|
|
'src/index.css': css`
|
|
@import 'tailwindcss';
|
|
@plugin '../.build/plugin.js';
|
|
`,
|
|
'src/plugin.ts': ts`
|
|
import plugin from 'tailwindcss/plugin'
|
|
|
|
export const typedPlugin = plugin(() => {
|
|
return ({ matchComponents }) => {
|
|
matchComponents(
|
|
{
|
|
test: (content: string) => ({
|
|
color: content,
|
|
}),
|
|
},
|
|
{
|
|
values: {
|
|
red: 'red',
|
|
},
|
|
},
|
|
)
|
|
}
|
|
})
|
|
|
|
export default plugin(({ matchComponents }) => {
|
|
matchComponents(
|
|
{
|
|
test: (content: string) => ({
|
|
color: content,
|
|
}),
|
|
},
|
|
{
|
|
values: {
|
|
red: 'red',
|
|
},
|
|
},
|
|
)
|
|
})
|
|
`,
|
|
},
|
|
},
|
|
async ({ fs, exec }) => {
|
|
// We expect that these commands don't crash:
|
|
await exec('pnpm tsc -b')
|
|
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
|
|
|
|
await fs.expectFileToContain('dist/out.css', [candidate`test-red`])
|
|
},
|
|
)
|