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>
This commit is contained in:
Robin Malfait 2024-12-04 15:43:59 +01:00 committed by GitHub
parent 536e11895e
commit 408fa99849
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 558 additions and 172 deletions

View File

@ -171,11 +171,18 @@ impl Scanner {
fn compute_candidates(&mut self) {
let mut changed_content = vec![];
for path in &self.files {
let current_time = fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::now());
let current_mtimes = self
.files
.par_iter()
.map(|path| {
fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::now())
})
.collect::<Vec<_>>();
for (idx, path) in self.files.iter().enumerate() {
let current_time = current_mtimes[idx];
let previous_time = self.mtimes.insert(path.clone(), current_time);
let should_scan_file = match previous_time {
@ -218,14 +225,21 @@ impl Scanner {
#[tracing::instrument(skip_all)]
fn check_for_new_files(&mut self) {
let current_mtimes = self
.dirs
.par_iter()
.map(|path| {
fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::now())
})
.collect::<Vec<_>>();
let mut modified_dirs: Vec<PathBuf> = vec![];
// Check all directories to see if they were modified
for path in &self.dirs {
let current_time = fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::now());
for (idx, path) in self.dirs.iter().enumerate() {
let current_time = current_mtimes[idx];
let previous_time = self.mtimes.insert(path.clone(), current_time);
let should_scan = match previous_time {

View File

@ -40,7 +40,6 @@ pub fn resolve_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
.filter_map(Result::ok)
}
#[tracing::instrument(skip_all)]
pub fn read_dir(root: &Path, depth: Option<usize>) -> impl Iterator<Item = DirEntry> {
WalkBuilder::new(root)
.hidden(false)

View File

@ -7,8 +7,10 @@ import { pathToFileURL } from 'node:url'
import {
__unstable__loadDesignSystem as ___unstable__loadDesignSystem,
compile as _compile,
compileAst as _compileAst,
Features,
} from 'tailwindcss'
import type { AstNode } from '../../tailwindcss/src/ast'
import { getModuleDependencies } from './get-module-dependencies'
import { rewriteUrls } from './urls'
@ -16,30 +18,29 @@ export { Features }
export type Resolver = (id: string, base: string) => Promise<string | false | undefined>
export async function compile(
css: string,
{
base,
onDependency,
shouldRewriteUrls,
export interface CompileOptions {
base: string
onDependency: (path: string) => void
shouldRewriteUrls?: boolean
customCssResolver,
customJsResolver,
}: {
base: string
onDependency: (path: string) => void
shouldRewriteUrls?: boolean
customCssResolver?: Resolver
customJsResolver?: Resolver
}
customCssResolver?: Resolver
customJsResolver?: Resolver
},
) {
let compiler = await _compile(css, {
function createCompileOptions({
base,
onDependency,
shouldRewriteUrls,
customCssResolver,
customJsResolver,
}: CompileOptions) {
return {
base,
async loadModule(id, base) {
async loadModule(id: string, base: string) {
return loadModule(id, base, onDependency, customJsResolver)
},
async loadStylesheet(id, base) {
async loadStylesheet(id: string, base: string) {
let sheet = await loadStylesheet(id, base, onDependency, customCssResolver)
if (shouldRewriteUrls) {
@ -52,8 +53,13 @@ export async function compile(
return sheet
},
})
}
}
async function ensureSourceDetectionRootExists(
compiler: { root: Awaited<ReturnType<typeof compile>>['root'] },
base: string,
) {
// Verify if the `source(…)` path exists (until the glob pattern starts)
if (compiler.root && compiler.root !== 'none') {
let globSymbols = /[*{]/
@ -75,7 +81,17 @@ export async function compile(
throw new Error(`The \`source(${compiler.root.pattern})\` does not exist`)
}
}
}
export async function compileAst(ast: AstNode[], options: CompileOptions) {
let compiler = await _compileAst(ast, createCompileOptions(options))
await ensureSourceDetectionRootExists(compiler, options.base)
return compiler
}
export async function compile(css: string, options: CompileOptions) {
let compiler = await _compile(css, createCompileOptions(options))
await ensureSourceDetectionRootExists(compiler, options.base)
return compiler
}

View File

@ -1,7 +1,7 @@
import * as Module from 'node:module'
import { pathToFileURL } from 'node:url'
import * as env from './env'
export { __unstable__loadDesignSystem, compile, Features } from './compile'
export { __unstable__loadDesignSystem, compile, compileAst, Features } from './compile'
export * from './normalize-path'
export { env }

View File

@ -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;
}
}
}"
`)
})

View File

@ -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
}

View File

@ -1,32 +1,38 @@
import QuickLRU from '@alloc/quick-lru'
import { compile, env, Features } from '@tailwindcss/node'
import { compileAst, env, Features } from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import { Features as LightningCssFeatures, transform } from 'lightningcss'
import fs from 'node:fs'
import path from 'node:path'
import postcss, { type AcceptedPlugin, type PluginCreator } from 'postcss'
import { toCss, type AstNode } from '../../tailwindcss/src/ast'
import { cssAstToPostCssAst, postCssAstToCssAst } from './ast'
import fixRelativePathsPlugin from './postcss-fix-relative-paths'
interface CacheEntry {
mtimes: Map<string, number>
compiler: null | Awaited<ReturnType<typeof compile>>
compiler: null | Awaited<ReturnType<typeof compileAst>>
scanner: null | Scanner
css: string
optimizedCss: string
tailwindCssAst: AstNode[]
cachedPostCssAst: postcss.Root
optimizedPostCssAst: postcss.Root
fullRebuildPaths: string[]
}
let cache = new QuickLRU<string, CacheEntry>({ maxSize: 50 })
function getContextFromCache(inputFile: string, opts: PluginOptions): CacheEntry {
let key = `${inputFile}:${opts.base ?? ''}:${opts.optimize ?? ''}`
let key = `${inputFile}:${opts.base ?? ''}:${JSON.stringify(opts.optimize)}`
if (cache.has(key)) return cache.get(key)!
let entry = {
mtimes: new Map<string, number>(),
compiler: null,
scanner: null,
css: '',
optimizedCss: '',
tailwindCssAst: [],
cachedPostCssAst: postcss.root(),
optimizedPostCssAst: postcss.root(),
fullRebuildPaths: [] as string[],
}
cache.set(key, entry)
@ -69,7 +75,7 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
context.fullRebuildPaths = []
let compiler = await compile(root.toString(), {
let compiler = await compileAst(postCssAstToCssAst(root), {
base: inputBasePath,
onDependency: (path) => {
context.fullRebuildPaths.push(path)
@ -128,8 +134,6 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
}
}
let css = ''
if (
rebuildStrategy === 'full' &&
// We can re-use the compiler if it was created during the
@ -205,23 +209,43 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
}
}
env.DEBUG && console.time('[@tailwindcss/postcss] Build CSS')
css = context.compiler.build(candidates)
env.DEBUG && console.timeEnd('[@tailwindcss/postcss] Build CSS')
env.DEBUG && console.time('[@tailwindcss/postcss] Build AST')
let tailwindCssAst = context.compiler.build(candidates)
env.DEBUG && console.timeEnd('[@tailwindcss/postcss] Build AST')
// Replace CSS
if (css !== context.css && optimize) {
env.DEBUG && console.time('[@tailwindcss/postcss] Optimize CSS')
context.optimizedCss = optimizeCss(css, {
minify: typeof optimize === 'object' ? optimize.minify : true,
})
env.DEBUG && console.timeEnd('[@tailwindcss/postcss] Optimize CSS')
if (context.tailwindCssAst !== tailwindCssAst) {
if (optimize) {
env.DEBUG && console.time('[@tailwindcss/postcss] Optimize CSS')
context.optimizedPostCssAst = postcss.parse(
optimizeCss(toCss(tailwindCssAst), {
minify: typeof optimize === 'object' ? optimize.minify : true,
}),
result.opts,
)
env.DEBUG && console.timeEnd('[@tailwindcss/postcss] Optimize CSS')
} else {
// Convert our AST to a PostCSS AST
env.DEBUG && console.time('[@tailwindcss/postcss] Transform CSS AST into PostCSS AST')
context.cachedPostCssAst = cssAstToPostCssAst(tailwindCssAst, root.source)
env.DEBUG &&
console.timeEnd('[@tailwindcss/postcss] Transform CSS AST into PostCSS AST')
}
}
context.css = css
context.tailwindCssAst = tailwindCssAst
env.DEBUG && console.time('[@tailwindcss/postcss] Update PostCSS AST')
root.removeAll()
root.append(postcss.parse(optimize ? context.optimizedCss : context.css, result.opts))
root.append(
optimize
? context.optimizedPostCssAst.clone().nodes
: context.cachedPostCssAst.clone().nodes,
)
// Trick PostCSS into thinking the indent is 2 spaces, so it uses that
// as the default instead of 4.
root.raws.indent = ' '
env.DEBUG && console.timeEnd('[@tailwindcss/postcss] Update PostCSS AST')
env.DEBUG && console.timeEnd('[@tailwindcss/postcss] Total time in @tailwindcss/postcss')
},

View File

@ -1,9 +1,10 @@
import { expect, it } from 'vitest'
import { context, decl, styleRule, toCss, walk, WalkAction } from './ast'
import { context, decl, optimizeAst, styleRule, toCss, walk, WalkAction } from './ast'
import * as CSS from './css-parser'
it('should pretty print an AST', () => {
expect(toCss(CSS.parse('.foo{color:red;&:hover{color:blue;}}'))).toMatchInlineSnapshot(`
expect(toCss(optimizeAst(CSS.parse('.foo{color:red;&:hover{color:blue;}}'))))
.toMatchInlineSnapshot(`
".foo {
color: red;
&:hover {
@ -51,7 +52,7 @@ it('allows the placement of context nodes', () => {
expect(blueContext).toEqual({ context: 'a' })
expect(greenContext).toEqual({ context: 'b' })
expect(toCss(ast)).toMatchInlineSnapshot(`
expect(toCss(optimizeAst(ast))).toMatchInlineSnapshot(`
".foo {
color: red;
}

View File

@ -66,12 +66,12 @@ export function rule(selector: string, nodes: AstNode[] = []): StyleRule | AtRul
return styleRule(selector, nodes)
}
export function decl(property: string, value: string | undefined): Declaration {
export function decl(property: string, value: string | undefined, important = false): Declaration {
return {
kind: 'declaration',
property,
value,
important: false,
important,
}
}
@ -208,18 +208,151 @@ export function walkDepth(
}
}
export function toCss(ast: AstNode[]) {
let atRoots: string = ''
// Optimize the AST for printing where all the special nodes that require custom
// handling are handled such that the printing is a 1-to-1 transformation.
export function optimizeAst(ast: AstNode[]) {
let atRoots: AstNode[] = []
let seenAtProperties = new Set<string>()
let propertyFallbacksRoot: Declaration[] = []
let propertyFallbacksUniversal: Declaration[] = []
function transform(
node: AstNode,
parent: Extract<AstNode, { nodes: AstNode[] }>['nodes'],
depth = 0,
) {
// Declaration
if (node.kind === 'declaration') {
if (node.property === '--tw-sort' || node.value === undefined || node.value === null) {
return
}
parent.push(node)
}
// Rule
else if (node.kind === 'rule') {
let copy = { ...node, nodes: [] }
for (let child of node.nodes) {
transform(child, copy.nodes, depth + 1)
}
parent.push(copy)
}
// AtRule `@property`
else if (node.kind === 'at-rule' && node.name === '@property' && depth === 0) {
// Don't output duplicate `@property` rules
if (seenAtProperties.has(node.params)) {
return
}
// Collect fallbacks for `@property` rules for Firefox support
// We turn these into rules on `:root` or `*` and some pseudo-elements
// based on the value of `inherits``
let property = node.params
let initialValue = null
let inherits = false
for (let prop of node.nodes) {
if (prop.kind !== 'declaration') continue
if (prop.property === 'initial-value') {
initialValue = prop.value
} else if (prop.property === 'inherits') {
inherits = prop.value === 'true'
}
}
if (inherits) {
propertyFallbacksRoot.push(decl(property, initialValue ?? 'initial'))
} else {
propertyFallbacksUniversal.push(decl(property, initialValue ?? 'initial'))
}
seenAtProperties.add(node.params)
let copy = { ...node, nodes: [] }
for (let child of node.nodes) {
transform(child, copy.nodes, depth + 1)
}
parent.push(copy)
}
// AtRule
else if (node.kind === 'at-rule') {
let copy = { ...node, nodes: [] }
for (let child of node.nodes) {
transform(child, copy.nodes, depth + 1)
}
parent.push(copy)
}
// AtRoot
else if (node.kind === 'at-root') {
for (let child of node.nodes) {
let newParent: AstNode[] = []
transform(child, newParent, 0)
for (let child of newParent) {
atRoots.push(child)
}
}
}
// Context
else if (node.kind === 'context') {
for (let child of node.nodes) {
transform(child, parent, depth)
}
}
// Comment
else if (node.kind === 'comment') {
parent.push(node)
}
// Unknown
else {
node satisfies never
}
}
let newAst: AstNode[] = []
for (let node of ast) {
transform(node, newAst, 0)
}
// Fallbacks
{
let fallbackAst = []
if (propertyFallbacksRoot.length > 0) {
fallbackAst.push(rule(':root', propertyFallbacksRoot))
}
if (propertyFallbacksUniversal.length > 0) {
fallbackAst.push(rule('*, ::before, ::after, ::backdrop', propertyFallbacksUniversal))
}
if (fallbackAst.length > 0) {
newAst.push(
atRule('@supports', '(-moz-orient: inline)', [atRule('@layer', 'base', fallbackAst)]),
)
}
}
return newAst.concat(atRoots)
}
export function toCss(ast: AstNode[]) {
function stringify(node: AstNode, depth = 0): string {
let css = ''
let indent = ' '.repeat(depth)
// Declaration
if (node.kind === 'declaration') {
css += `${indent}${node.property}: ${node.value}${node.important ? ' !important' : ''};\n`
}
// Rule
if (node.kind === 'rule') {
else if (node.kind === 'rule') {
css += `${indent}${node.selector} {\n`
for (let child of node.nodes) {
css += stringify(child, depth + 1)
@ -240,38 +373,6 @@ export function toCss(ast: AstNode[]) {
return `${indent}${node.name} ${node.params};\n`
}
//
else if (node.name === '@property' && depth === 0) {
// Don't output duplicate `@property` rules
if (seenAtProperties.has(node.params)) {
return ''
}
// Collect fallbacks for `@property` rules for Firefox support
// We turn these into rules on `:root` or `*` and some pseudo-elements
// based on the value of `inherits``
let property = node.params
let initialValue = null
let inherits = false
for (let prop of node.nodes) {
if (prop.kind !== 'declaration') continue
if (prop.property === 'initial-value') {
initialValue = prop.value
} else if (prop.property === 'inherits') {
inherits = prop.value === 'true'
}
}
if (inherits) {
propertyFallbacksRoot.push(decl(property, initialValue ?? 'initial'))
} else {
propertyFallbacksUniversal.push(decl(property, initialValue ?? 'initial'))
}
seenAtProperties.add(node.params)
}
css += `${indent}${node.name}${node.params ? ` ${node.params} ` : ' '}{\n`
for (let child of node.nodes) {
css += stringify(child, depth + 1)
@ -284,24 +385,16 @@ export function toCss(ast: AstNode[]) {
css += `${indent}/*${node.value}*/\n`
}
// Context Node
else if (node.kind === 'context') {
for (let child of node.nodes) {
css += stringify(child, depth)
}
// These should've been handled already by `prepareAstForPrinting` which
// means we can safely ignore them here. We return an empty string
// immediately to signal that something went wrong.
else if (node.kind === 'context' || node.kind === 'at-root') {
return ''
}
// AtRoot Node
else if (node.kind === 'at-root') {
for (let child of node.nodes) {
atRoots += stringify(child, 0)
}
return css
}
// Declaration
else if (node.property !== '--tw-sort' && node.value !== undefined && node.value !== null) {
css += `${indent}${node.property}: ${node.value}${node.important ? ' !important' : ''};\n`
// Unknown
else {
node satisfies never
}
return css
@ -316,23 +409,5 @@ export function toCss(ast: AstNode[]) {
}
}
let fallbackAst = []
if (propertyFallbacksRoot.length) {
fallbackAst.push(rule(':root', propertyFallbacksRoot))
}
if (propertyFallbacksUniversal.length) {
fallbackAst.push(rule('*, ::before, ::after, ::backdrop', propertyFallbacksUniversal))
}
let fallback = ''
if (fallbackAst.length) {
fallback = stringify(
atRule('@supports', '(-moz-orient: inline)', [atRule('@layer', 'base', fallbackAst)]),
)
}
return `${css}${fallback}${atRoots}`
return css
}

View File

@ -17,10 +17,10 @@ import * as SelectorParser from './selector-parser'
export type Config = UserConfig
export type PluginFn = (api: PluginAPI) => void
export type PluginWithConfig = {
handler: PluginFn;
config?: UserConfig;
export type PluginWithConfig = {
handler: PluginFn
config?: UserConfig
/** @internal */
reference?: boolean
}

View File

@ -1,6 +1,7 @@
import {
atRule,
comment,
decl,
rule,
type AstNode,
type AtRule,
@ -434,15 +435,7 @@ export function parse(input: string) {
// Attach the declaration to the parent.
if (parent) {
let importantIdx = buffer.indexOf('!important', colonIdx + 1)
parent.nodes.push({
kind: 'declaration',
property: buffer.slice(0, colonIdx).trim(),
value: buffer
.slice(colonIdx + 1, importantIdx === -1 ? buffer.length : importantIdx)
.trim(),
important: importantIdx !== -1,
} satisfies Declaration)
parent.nodes.push(parseDeclaration(buffer, colonIdx))
}
}
}
@ -552,10 +545,9 @@ export function parseAtRule(buffer: string, nodes: AstNode[] = []): AtRule {
function parseDeclaration(buffer: string, colonIdx: number = buffer.indexOf(':')): Declaration {
let importantIdx = buffer.indexOf('!important', colonIdx + 1)
return {
kind: 'declaration',
property: buffer.slice(0, colonIdx).trim(),
value: buffer.slice(colonIdx + 1, importantIdx === -1 ? buffer.length : importantIdx).trim(),
important: importantIdx !== -1,
}
return decl(
buffer.slice(0, colonIdx).trim(),
buffer.slice(colonIdx + 1, importantIdx === -1 ? buffer.length : importantIdx).trim(),
importantIdx !== -1,
)
}

View File

@ -6,6 +6,7 @@ import {
comment,
context as contextNode,
decl,
optimizeAst,
rule,
styleRule,
toCss,
@ -99,7 +100,7 @@ export const enum Features {
}
async function parseCss(
css: string,
ast: AstNode[],
{
base = '',
loadModule = throwOnLoadModule,
@ -107,7 +108,7 @@ async function parseCss(
}: CompileOptions = {},
) {
let features = Features.None
let ast = [contextNode({ base }, CSS.parse(css))] as AstNode[]
ast = [contextNode({ base }, ast)] as AstNode[]
features |= await substituteAtImports(ast, base, loadStylesheet)
@ -556,16 +557,16 @@ async function parseCss(
}
}
export async function compile(
css: string,
export async function compileAst(
input: AstNode[],
opts: CompileOptions = {},
): Promise<{
globs: { base: string; pattern: string }[]
root: Root
features: Features
build(candidates: string[]): string
build(candidates: string[]): AstNode[]
}> {
let { designSystem, ast, globs, root, utilitiesNode, features } = await parseCss(css, opts)
let { designSystem, ast, globs, root, utilitiesNode, features } = await parseCss(input, opts)
if (process.env.NODE_ENV !== 'test') {
ast.unshift(comment(`! tailwindcss v${version} | MIT License | https://tailwindcss.com `))
@ -580,7 +581,7 @@ export async function compile(
// resulted in a generated AST Node. All the other `rawCandidates` are invalid
// and should be ignored.
let allValidCandidates = new Set<string>()
let compiledCss = features !== Features.None ? toCss(ast) : css
let compiled = null as AstNode[] | null
let previousAstNodeCount = 0
return {
@ -588,6 +589,15 @@ export async function compile(
root,
features,
build(newRawCandidates: string[]) {
if (features === Features.None) {
return input
}
if (!utilitiesNode) {
compiled ??= optimizeAst(ast)
return compiled
}
let didChange = false
// Add all new candidates unless we know that they are invalid.
@ -602,26 +612,57 @@ export async function compile(
// If no new candidates were added, we can return the original CSS. This
// currently assumes that we only add new candidates and never remove any.
if (!didChange) {
compiled ??= optimizeAst(ast)
return compiled
}
let newNodes = compileCandidates(allValidCandidates, designSystem, {
onInvalidCandidate,
}).astNodes
// If no new ast nodes were generated, then we can return the original
// CSS. This currently assumes that we only add new ast nodes and never
// remove any.
if (previousAstNodeCount === newNodes.length) {
compiled ??= optimizeAst(ast)
return compiled
}
previousAstNodeCount = newNodes.length
utilitiesNode.nodes = newNodes
compiled = optimizeAst(ast)
return compiled
},
}
}
export async function compile(
css: string,
opts: CompileOptions = {},
): Promise<{
globs: { base: string; pattern: string }[]
root: Root
features: Features
build(candidates: string[]): string
}> {
let ast = CSS.parse(css)
let api = await compileAst(ast, opts)
let compiledAst = ast
let compiledCss = css
return {
...api,
build(newCandidates) {
let newAst = api.build(newCandidates)
if (newAst === compiledAst) {
return compiledCss
}
if (utilitiesNode) {
let newNodes = compileCandidates(allValidCandidates, designSystem, {
onInvalidCandidate,
}).astNodes
// If no new ast nodes were generated, then we can return the original
// CSS. This currently assumes that we only add new ast nodes and never
// remove any.
if (previousAstNodeCount === newNodes.length) {
return compiledCss
}
previousAstNodeCount = newNodes.length
utilitiesNode.nodes = newNodes
compiledCss = toCss(ast)
}
compiledCss = toCss(newAst)
compiledAst = newAst
return compiledCss
},
@ -629,7 +670,7 @@ export async function compile(
}
export async function __unstable__loadDesignSystem(css: string, opts: CompileOptions = {}) {
let result = await parseCss(css, opts)
let result = await parseCss(CSS.parse(css), opts)
return result.designSystem
}