-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Use AST transformations in
@tailwindcss/postcss
(#15297)
This PR improves the `@tailwindcss/postcss` integration by using direct AST transformations between our own AST and PostCSS's AST. This allows us to skip a step where we convert our AST into a string, then parse it back into a PostCSS AST. The only downside is that we still have to print the AST into a string if we want to optimize the CSS using Lightning CSS. Luckily this only happens in production (`NODE_ENV=production`). This also introduces a new private `compileAst` API, that allows us to accept an AST as the input. This allows us to skip the PostCSS AST -> string -> parse into our own AST step. To summarize: Instead of: - Input: `PostCSS AST` -> `.toString()` -> `CSS.parse(…)` -> `Tailwind CSS AST` - Output: `Tailwind CSS AST` -> `toCSS(ast)` -> `postcss.parse(…)` -> `PostCSS AST` We will now do this instead: - Input: `PostCSS AST` -> `transform(…)` -> `Tailwind CSS AST` - Output: `Tailwind CSS AST` -> `transform(…)` -> `PostCSS AST` --- Running this on Catalyst, the time spent in the `@tailwindcss/postcss` looks like this: - Before: median time per run: 19.407687 ms - After: median time per run: 11.8796455 ms This is tested on Catalyst which roughly generates ~208kb worth of CSS in dev mode. While it's not a lot, skipping the stringification and parsing seems to improve this step by ~40%. Note: these times exclude scanning the actual candidates and only time the work needed for parsing/stringifying the CSS from and into ASTs. The actual numbers are a bit higher because of the Oxide scanner reading files from disk. But since that part is going to be there no matter what, it's not fair to include it in this benchmark. --------- Co-authored-by: Jordan Pittman <[email protected]>
- Loading branch information
1 parent
536e118
commit 408fa99
Showing
12 changed files
with
558 additions
and
172 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
import dedent from 'dedent' | ||
import postcss from 'postcss' | ||
import { expect, it } from 'vitest' | ||
import { toCss } from '../../tailwindcss/src/ast' | ||
import { parse } from '../../tailwindcss/src/css-parser' | ||
import { cssAstToPostCssAst, postCssAstToCssAst } from './ast' | ||
|
||
let css = dedent | ||
|
||
it('should convert a PostCSS AST into a Tailwind CSS AST', () => { | ||
let input = css` | ||
@charset "UTF-8"; | ||
@layer foo, bar, baz; | ||
@import 'tailwindcss'; | ||
.foo { | ||
color: red; | ||
&:hover { | ||
color: blue; | ||
} | ||
.bar { | ||
color: green !important; | ||
background-color: yellow; | ||
@media (min-width: 640px) { | ||
color: orange; | ||
} | ||
} | ||
} | ||
` | ||
|
||
let ast = postcss.parse(input) | ||
let transformedAst = postCssAstToCssAst(ast) | ||
|
||
expect(toCss(transformedAst)).toMatchInlineSnapshot(` | ||
"@charset "UTF-8"; | ||
@layer foo, bar, baz; | ||
@import 'tailwindcss'; | ||
.foo { | ||
color: red; | ||
&:hover { | ||
color: blue; | ||
} | ||
.bar { | ||
color: green !important; | ||
background-color: yellow; | ||
@media (min-width: 640px) { | ||
color: orange; | ||
} | ||
} | ||
} | ||
" | ||
`) | ||
}) | ||
|
||
it('should convert a Tailwind CSS AST into a PostCSS AST', () => { | ||
let input = css` | ||
@charset "UTF-8"; | ||
@layer foo, bar, baz; | ||
@import 'tailwindcss'; | ||
.foo { | ||
color: red; | ||
&:hover { | ||
color: blue; | ||
} | ||
.bar { | ||
color: green !important; | ||
background-color: yellow; | ||
@media (min-width: 640px) { | ||
color: orange; | ||
} | ||
} | ||
} | ||
` | ||
|
||
let ast = parse(input) | ||
let transformedAst = cssAstToPostCssAst(ast) | ||
|
||
expect(transformedAst.toString()).toMatchInlineSnapshot(` | ||
"@charset "UTF-8"; | ||
@layer foo, bar, baz; | ||
@import 'tailwindcss'; | ||
.foo { | ||
color: red; | ||
&:hover { | ||
color: blue; | ||
} | ||
.bar { | ||
color: green !important; | ||
background-color: yellow; | ||
@media (min-width: 640px) { | ||
color: orange; | ||
} | ||
} | ||
}" | ||
`) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import postcss, { | ||
type ChildNode as PostCssChildNode, | ||
type Container as PostCssContainerNode, | ||
type Root as PostCssRoot, | ||
type Source as PostcssSource, | ||
} from 'postcss' | ||
import { atRule, comment, decl, rule, type AstNode } from '../../tailwindcss/src/ast' | ||
|
||
const EXCLAMATION_MARK = 0x21 | ||
|
||
export function cssAstToPostCssAst(ast: AstNode[], source: PostcssSource | undefined): PostCssRoot { | ||
let root = postcss.root() | ||
root.source = source | ||
|
||
function transform(node: AstNode, parent: PostCssContainerNode) { | ||
// Declaration | ||
if (node.kind === 'declaration') { | ||
let astNode = postcss.decl({ | ||
prop: node.property, | ||
value: node.value ?? '', | ||
important: node.important, | ||
}) | ||
astNode.source = source | ||
parent.append(astNode) | ||
} | ||
|
||
// Rule | ||
else if (node.kind === 'rule') { | ||
let astNode = postcss.rule({ selector: node.selector }) | ||
astNode.source = source | ||
astNode.raws.semicolon = true | ||
parent.append(astNode) | ||
for (let child of node.nodes) { | ||
transform(child, astNode) | ||
} | ||
} | ||
|
||
// AtRule | ||
else if (node.kind === 'at-rule') { | ||
let astNode = postcss.atRule({ name: node.name.slice(1), params: node.params }) | ||
astNode.source = source | ||
astNode.raws.semicolon = true | ||
parent.append(astNode) | ||
for (let child of node.nodes) { | ||
transform(child, astNode) | ||
} | ||
} | ||
|
||
// Comment | ||
else if (node.kind === 'comment') { | ||
let astNode = postcss.comment({ text: node.value }) | ||
// Spaces are encoded in our node.value already, no need to add additional | ||
// spaces. | ||
astNode.raws.left = '' | ||
astNode.raws.right = '' | ||
astNode.source = source | ||
parent.append(astNode) | ||
} | ||
|
||
// AtRoot & Context should not happen | ||
else if (node.kind === 'at-root' || node.kind === 'context') { | ||
} | ||
|
||
// Unknown | ||
else { | ||
node satisfies never | ||
} | ||
} | ||
|
||
for (let node of ast) { | ||
transform(node, root) | ||
} | ||
|
||
return root | ||
} | ||
|
||
export function postCssAstToCssAst(root: PostCssRoot): AstNode[] { | ||
function transform( | ||
node: PostCssChildNode, | ||
parent: Extract<AstNode, { nodes: AstNode[] }>['nodes'], | ||
) { | ||
// Declaration | ||
if (node.type === 'decl') { | ||
parent.push(decl(node.prop, node.value, node.important)) | ||
} | ||
|
||
// Rule | ||
else if (node.type === 'rule') { | ||
let astNode = rule(node.selector) | ||
node.each((child) => transform(child, astNode.nodes)) | ||
parent.push(astNode) | ||
} | ||
|
||
// AtRule | ||
else if (node.type === 'atrule') { | ||
let astNode = atRule(`@${node.name}`, node.params) | ||
node.each((child) => transform(child, astNode.nodes)) | ||
parent.push(astNode) | ||
} | ||
|
||
// Comment | ||
else if (node.type === 'comment') { | ||
if (node.text.charCodeAt(0) !== EXCLAMATION_MARK) return | ||
parent.push(comment(node.text)) | ||
} | ||
|
||
// Unknown | ||
else { | ||
node satisfies never | ||
} | ||
} | ||
|
||
let ast: AstNode[] = [] | ||
root.each((node) => transform(node, ast)) | ||
|
||
return ast | ||
} |
Oops, something went wrong.