mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
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>
273 lines
7.3 KiB
TypeScript
273 lines
7.3 KiB
TypeScript
import EnhancedResolve from 'enhanced-resolve'
|
|
import { createJiti, type Jiti } from 'jiti'
|
|
import fs from 'node:fs'
|
|
import fsPromises from 'node:fs/promises'
|
|
import path, { dirname } from 'node:path'
|
|
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'
|
|
|
|
export { Features }
|
|
|
|
export type Resolver = (id: string, base: string) => Promise<string | false | undefined>
|
|
|
|
export interface CompileOptions {
|
|
base: string
|
|
onDependency: (path: string) => void
|
|
shouldRewriteUrls?: boolean
|
|
|
|
customCssResolver?: Resolver
|
|
customJsResolver?: Resolver
|
|
}
|
|
|
|
function createCompileOptions({
|
|
base,
|
|
onDependency,
|
|
shouldRewriteUrls,
|
|
|
|
customCssResolver,
|
|
customJsResolver,
|
|
}: CompileOptions) {
|
|
return {
|
|
base,
|
|
async loadModule(id: string, base: string) {
|
|
return loadModule(id, base, onDependency, customJsResolver)
|
|
},
|
|
async loadStylesheet(id: string, base: string) {
|
|
let sheet = await loadStylesheet(id, base, onDependency, customCssResolver)
|
|
|
|
if (shouldRewriteUrls) {
|
|
sheet.content = await rewriteUrls({
|
|
css: sheet.content,
|
|
root: base,
|
|
base: sheet.base,
|
|
})
|
|
}
|
|
|
|
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 = /[*{]/
|
|
let basePath = []
|
|
for (let segment of compiler.root.pattern.split('/')) {
|
|
if (globSymbols.test(segment)) {
|
|
break
|
|
}
|
|
|
|
basePath.push(segment)
|
|
}
|
|
|
|
let exists = await fsPromises
|
|
.stat(path.resolve(base, basePath.join('/')))
|
|
.then((stat) => stat.isDirectory())
|
|
.catch(() => false)
|
|
|
|
if (!exists) {
|
|
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
|
|
}
|
|
|
|
export async function __unstable__loadDesignSystem(css: string, { base }: { base: string }) {
|
|
return ___unstable__loadDesignSystem(css, {
|
|
base,
|
|
async loadModule(id, base) {
|
|
return loadModule(id, base, () => {})
|
|
},
|
|
async loadStylesheet(id, base) {
|
|
return loadStylesheet(id, base, () => {})
|
|
},
|
|
})
|
|
}
|
|
|
|
export async function loadModule(
|
|
id: string,
|
|
base: string,
|
|
onDependency: (path: string) => void,
|
|
customJsResolver?: Resolver,
|
|
) {
|
|
if (id[0] !== '.') {
|
|
let resolvedPath = await resolveJsId(id, base, customJsResolver)
|
|
if (!resolvedPath) {
|
|
throw new Error(`Could not resolve '${id}' from '${base}'`)
|
|
}
|
|
|
|
let module = await importModule(pathToFileURL(resolvedPath).href)
|
|
return {
|
|
base: dirname(resolvedPath),
|
|
module: module.default ?? module,
|
|
}
|
|
}
|
|
|
|
let resolvedPath = await resolveJsId(id, base, customJsResolver)
|
|
if (!resolvedPath) {
|
|
throw new Error(`Could not resolve '${id}' from '${base}'`)
|
|
}
|
|
|
|
let [module, moduleDependencies] = await Promise.all([
|
|
importModule(pathToFileURL(resolvedPath).href + '?id=' + Date.now()),
|
|
getModuleDependencies(resolvedPath),
|
|
])
|
|
|
|
for (let file of moduleDependencies) {
|
|
onDependency(file)
|
|
}
|
|
return {
|
|
base: dirname(resolvedPath),
|
|
module: module.default ?? module,
|
|
}
|
|
}
|
|
|
|
async function loadStylesheet(
|
|
id: string,
|
|
base: string,
|
|
onDependency: (path: string) => void,
|
|
cssResolver?: Resolver,
|
|
) {
|
|
let resolvedPath = await resolveCssId(id, base, cssResolver)
|
|
if (!resolvedPath) throw new Error(`Could not resolve '${id}' from '${base}'`)
|
|
|
|
onDependency(resolvedPath)
|
|
|
|
if (typeof globalThis.__tw_readFile === 'function') {
|
|
let file = await globalThis.__tw_readFile(resolvedPath, 'utf-8')
|
|
if (file) {
|
|
return {
|
|
base: path.dirname(resolvedPath),
|
|
content: file,
|
|
}
|
|
}
|
|
}
|
|
|
|
let file = await fsPromises.readFile(resolvedPath, 'utf-8')
|
|
return {
|
|
base: path.dirname(resolvedPath),
|
|
content: file,
|
|
}
|
|
}
|
|
|
|
// Attempts to import the module using the native `import()` function. If this
|
|
// fails, it sets up `jiti` and attempts to import this way so that `.ts` files
|
|
// can be resolved properly.
|
|
let jiti: null | Jiti = null
|
|
async function importModule(path: string): Promise<any> {
|
|
if (typeof globalThis.__tw_load === 'function') {
|
|
let module = await globalThis.__tw_load(path)
|
|
if (module) {
|
|
return module
|
|
}
|
|
}
|
|
|
|
try {
|
|
return await import(path)
|
|
} catch (error) {
|
|
jiti ??= createJiti(import.meta.url, { moduleCache: false, fsCache: false })
|
|
return await jiti.import(path)
|
|
}
|
|
}
|
|
|
|
const cssResolver = EnhancedResolve.ResolverFactory.createResolver({
|
|
fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000),
|
|
useSyncFileSystemCalls: true,
|
|
extensions: ['.css'],
|
|
mainFields: ['style'],
|
|
conditionNames: ['style'],
|
|
})
|
|
async function resolveCssId(
|
|
id: string,
|
|
base: string,
|
|
customCssResolver?: Resolver,
|
|
): Promise<string | false | undefined> {
|
|
if (typeof globalThis.__tw_resolve === 'function') {
|
|
let resolved = globalThis.__tw_resolve(id, base)
|
|
if (resolved) {
|
|
return Promise.resolve(resolved)
|
|
}
|
|
}
|
|
|
|
if (customCssResolver) {
|
|
let customResolution = await customCssResolver(id, base)
|
|
if (customResolution) {
|
|
return customResolution
|
|
}
|
|
}
|
|
|
|
return runResolver(cssResolver, id, base)
|
|
}
|
|
|
|
const esmResolver = EnhancedResolve.ResolverFactory.createResolver({
|
|
fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000),
|
|
useSyncFileSystemCalls: true,
|
|
extensions: ['.js', '.json', '.node', '.ts'],
|
|
conditionNames: ['node', 'import'],
|
|
})
|
|
|
|
const cjsResolver = EnhancedResolve.ResolverFactory.createResolver({
|
|
fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000),
|
|
useSyncFileSystemCalls: true,
|
|
extensions: ['.js', '.json', '.node', '.ts'],
|
|
conditionNames: ['node', 'require'],
|
|
})
|
|
|
|
async function resolveJsId(
|
|
id: string,
|
|
base: string,
|
|
customJsResolver?: Resolver,
|
|
): Promise<string | false | undefined> {
|
|
if (typeof globalThis.__tw_resolve === 'function') {
|
|
let resolved = globalThis.__tw_resolve(id, base)
|
|
if (resolved) {
|
|
return Promise.resolve(resolved)
|
|
}
|
|
}
|
|
|
|
if (customJsResolver) {
|
|
let customResolution = await customJsResolver(id, base)
|
|
if (customResolution) {
|
|
return customResolution
|
|
}
|
|
}
|
|
|
|
return runResolver(esmResolver, id, base).catch(() => runResolver(cjsResolver, id, base))
|
|
}
|
|
|
|
function runResolver(
|
|
resolver: EnhancedResolve.Resolver,
|
|
id: string,
|
|
base: string,
|
|
): Promise<string | false | undefined> {
|
|
return new Promise((resolve, reject) =>
|
|
resolver.resolve({}, base, id, {}, (err, result) => {
|
|
if (err) return reject(err)
|
|
resolve(result)
|
|
}),
|
|
)
|
|
}
|