Robin Malfait 408fa99849
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 <jordan@cryptica.me>
2024-12-04 15:43:59 +01:00

118 lines
2.9 KiB
TypeScript

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
}