This PR brings `@import` resolution into Tailwind CSS core. This means that our clients (PostCSS, Vite, and CLI) no longer need to depend on `postcss` and `postcss-import` to resolve `@import`. Furthermore this simplifies the handling of relative paths for `@source`, `@plugin`, or `@config` in transitive CSS files (where the relative root should always be relative to the CSS file that contains the directive). This PR also fixes a plugin resolution bug where non-relative imports (e.g. directly importing node modules like `@plugin '@tailwindcss/typography';`) would not work in CSS files that are based in a different npm package. ### Resolving `@import` The core of the `@import` resolution is inside `packages/tailwindcss/src/at-import.ts`. There, to keep things performant, we do a two-step process to resolve imports. Imagine the following input CSS file: ```css @import "tailwindcss/theme.css"; @import "tailwindcss/utilities.css"; ``` Since our AST walks are synchronous, we will do a first traversal where we start a loading request for each `@import` directive. Once all loads are started, we will await the promise and do a second walk where we actually replace the AST nodes with their resolved stylesheets. All of this is recursive, so that `@import`-ed files can again `@import` other files. The core `@import` resolver also includes extensive test cases for [various combinations of media query and supports conditionals as well als layered imports](https://developer.mozilla.org/en-US/docs/Web/CSS/@import). When the same file is imported multiple times, the AST nodes are duplicated but duplicate I/O is avoided on a per-file basis, so this will only load one file, but include the `@theme` rules twice: ```css @import "tailwindcss/theme.css"; @import "tailwindcss/theme.css"; ``` ### Adding a new `context` node to the AST One limitation we had when working with the `postcss-import` plugin was the need to do an additional traversal to rewrite relative `@source`, `@plugin`, and `@config` directives. This was needed because we want these paths to be relative to the CSS file that defines the directive but when flattening a CSS file, this information is no longer part of the stringifed CSS representation. We worked around this by rewriting the content of these directives to be relative to the input CSS file, which resulted in added complexity and caused a lot of issues with Windows paths in the beginning. Now that we are doing the `@import` resolution in core, we can use a different data structure to persist this information. This PR adds a new `context` node so that we can store arbitrary context like this inside the Ast directly. This allows us to share information with the sub tree _while doing the Ast walk_. Here's an example of how the new `context` node can be used to share information with subtrees: ```ts const ast = [ rule('.foo', [decl('color', 'red')]), context({ value: 'a' }, [ rule('.bar', [ decl('color', 'blue'), context({ value: 'b' }, [ rule('.baz', [decl('color', 'green')]), ]), ]), ]), ] walk(ast, (node, { context }) => { if (node.kind !== 'declaration') return switch (node.value) { case 'red': assert(context.value === undefined) case 'blue': assert(context.value === 'a') case 'green': assert(context.value === 'b') } }) ``` In core, we use this new Ast node specifically to persist the `base` path of the current CSS file. We put the input CSS file `base` at the root of the Ast and then overwrite the `base` on every `@import` substitution. ### Removing the dependency on `postcss-import` Now that we support `@import` resolution in core, our clients no longer need a dependency on `postcss-import`. Furthermore, most dependencies also don't need to know about `postcss` at all anymore (except the PostCSS client, of course!). This also means that our workaround for rewriting `@source`, the `postcss-fix-relative-paths` plugin, can now go away as a shared dependency between all of our clients. Note that we still have it for the PostCSS plugin only, where it's possible that users already have `postcss-import` running _before_ the `@tailwindcss/postcss` plugin. Here's an example of the changes to the dependencies for our Vite client ✨ : <img width="854" alt="Screenshot 2024-09-19 at 16 59 45" src="https://github.com/user-attachments/assets/ae1f9d5f-d93a-4de9-9244-61af3aff1237"> ### Performance Since our Vite and CLI clients now no longer need to use `postcss` at all, we have also measured a significant improvement to the initial build times. For a small test setup that contains only a hand full of files (nothing super-complex), we measured an improvement in the **3.5x** range: <img width="1334" alt="Screenshot 2024-09-19 at 14 52 49" src="https://github.com/user-attachments/assets/06071fb0-7f2a-4de6-8ec8-f202d2cc78e5"> The code for this is in the commit history if you want to reproduce the results. The test was based on the Vite client. ### Caveats One thing to note is that we previously relied on finding specific symbols in the input CSS to _bail out of Tailwind processing completely_. E.g. if a file does not contain a `@tailwind` or `@apply` directive, it can never be a Tailwind file. Since we no longer have a string representation of the flattened CSS file, we can no longer do this check. However, the current implementation was already inconsistent with differences on the allowed symbol list between our clients. Ideally, Tailwind CSS should figure out wether a CSS file is a Tailwind CSS file. This, however, is left as an improvement for a future API since it goes hand-in-hand with our planned API changes for the core `tailwindcss` package. --------- Co-authored-by: Jordan Pittman <jordan@cryptica.me>
198 lines
5.9 KiB
TypeScript
198 lines
5.9 KiB
TypeScript
import { compile } from '@tailwindcss/node'
|
|
import { clearRequireCache } from '@tailwindcss/node/require-cache'
|
|
import { Scanner } from '@tailwindcss/oxide'
|
|
import fs from 'fs'
|
|
import { Features, transform } from 'lightningcss'
|
|
import path from 'path'
|
|
import postcss, { type AcceptedPlugin, type PluginCreator } from 'postcss'
|
|
import fixRelativePathsPlugin from './postcss-fix-relative-paths'
|
|
|
|
/**
|
|
* A Map that can generate default values for keys that don't exist.
|
|
* Generated default values are added to the map to avoid recomputation.
|
|
*/
|
|
class DefaultMap<T = string, V = any> extends Map<T, V> {
|
|
constructor(private factory: (key: T, self: DefaultMap<T, V>) => V) {
|
|
super()
|
|
}
|
|
|
|
get(key: T): V {
|
|
let value = super.get(key)
|
|
|
|
if (value === undefined) {
|
|
value = this.factory(key, this)
|
|
this.set(key, value)
|
|
}
|
|
|
|
return value
|
|
}
|
|
}
|
|
|
|
export type PluginOptions = {
|
|
// The base directory to scan for class candidates.
|
|
base?: string
|
|
|
|
// Optimize and minify the output CSS.
|
|
optimize?: boolean | { minify?: boolean }
|
|
}
|
|
|
|
function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
|
|
let base = opts.base ?? process.cwd()
|
|
let optimize = opts.optimize ?? process.env.NODE_ENV === 'production'
|
|
|
|
let cache = new DefaultMap(() => {
|
|
return {
|
|
mtimes: new Map<string, number>(),
|
|
compiler: null as null | Awaited<ReturnType<typeof compile>>,
|
|
css: '',
|
|
optimizedCss: '',
|
|
fullRebuildPaths: [] as string[],
|
|
}
|
|
})
|
|
|
|
return {
|
|
postcssPlugin: '@tailwindcss/postcss',
|
|
plugins: [
|
|
// We need to handle the case where `postcss-import` might have run before the Tailwind CSS
|
|
// plugin is run. In this case, we need to manually fix relative paths before processing it
|
|
// in core.
|
|
fixRelativePathsPlugin(),
|
|
|
|
{
|
|
postcssPlugin: 'tailwindcss',
|
|
async OnceExit(root, { result }) {
|
|
let inputFile = result.opts.from ?? ''
|
|
let context = cache.get(inputFile)
|
|
let inputBasePath = path.dirname(path.resolve(inputFile))
|
|
|
|
async function createCompiler() {
|
|
clearRequireCache(context.fullRebuildPaths)
|
|
|
|
context.fullRebuildPaths = []
|
|
|
|
return compile(root.toString(), {
|
|
base: inputBasePath,
|
|
onDependency: (path) => {
|
|
context.fullRebuildPaths.push(path)
|
|
},
|
|
})
|
|
}
|
|
|
|
// Setup the compiler if it doesn't exist yet. This way we can
|
|
// guarantee a `build()` function is available.
|
|
context.compiler ??= await createCompiler()
|
|
|
|
let rebuildStrategy: 'full' | 'incremental' = 'incremental'
|
|
|
|
// Track file modification times to CSS files
|
|
{
|
|
for (let file of context.fullRebuildPaths) {
|
|
result.messages.push({
|
|
type: 'dependency',
|
|
plugin: '@tailwindcss/postcss',
|
|
file,
|
|
parent: result.opts.from,
|
|
})
|
|
}
|
|
|
|
let files = result.messages.flatMap((message) => {
|
|
if (message.type !== 'dependency') return []
|
|
return message.file
|
|
})
|
|
files.push(inputFile)
|
|
|
|
for (let file of files) {
|
|
let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null
|
|
if (changedTime === null) {
|
|
if (file === inputFile) {
|
|
rebuildStrategy = 'full'
|
|
}
|
|
continue
|
|
}
|
|
|
|
let prevTime = context.mtimes.get(file)
|
|
if (prevTime === changedTime) continue
|
|
|
|
rebuildStrategy = 'full'
|
|
context.mtimes.set(file, changedTime)
|
|
}
|
|
}
|
|
|
|
let css = ''
|
|
|
|
// Look for candidates used to generate the CSS
|
|
let scanner = new Scanner({
|
|
detectSources: { base },
|
|
sources: context.compiler.globs,
|
|
})
|
|
|
|
let candidates = scanner.scan()
|
|
|
|
// Add all found files as direct dependencies
|
|
for (let file of scanner.files) {
|
|
result.messages.push({
|
|
type: 'dependency',
|
|
plugin: '@tailwindcss/postcss',
|
|
file,
|
|
parent: result.opts.from,
|
|
})
|
|
}
|
|
|
|
// Register dependencies so changes in `base` cause a rebuild while
|
|
// giving tools like Vite or Parcel a glob that can be used to limit
|
|
// the files that cause a rebuild to only those that match it.
|
|
for (let { base, pattern } of scanner.globs) {
|
|
result.messages.push({
|
|
type: 'dir-dependency',
|
|
plugin: '@tailwindcss/postcss',
|
|
dir: base,
|
|
glob: pattern,
|
|
parent: result.opts.from,
|
|
})
|
|
}
|
|
|
|
if (rebuildStrategy === 'full') {
|
|
context.compiler = await createCompiler()
|
|
}
|
|
css = context.compiler.build(candidates)
|
|
|
|
// Replace CSS
|
|
if (css !== context.css && optimize) {
|
|
context.optimizedCss = optimizeCss(css, {
|
|
minify: typeof optimize === 'object' ? optimize.minify : true,
|
|
})
|
|
}
|
|
context.css = css
|
|
root.removeAll()
|
|
root.append(postcss.parse(optimize ? context.optimizedCss : context.css, result.opts))
|
|
},
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
function optimizeCss(
|
|
input: string,
|
|
{ file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},
|
|
) {
|
|
return transform({
|
|
filename: file,
|
|
code: Buffer.from(input),
|
|
minify,
|
|
sourceMap: false,
|
|
drafts: {
|
|
customMedia: true,
|
|
},
|
|
nonStandard: {
|
|
deepSelectorCombinator: true,
|
|
},
|
|
include: Features.Nesting,
|
|
exclude: Features.LogicalProperties,
|
|
targets: {
|
|
safari: (16 << 16) | (4 << 8),
|
|
},
|
|
errorRecovery: true,
|
|
}).code.toString()
|
|
}
|
|
|
|
export default Object.assign(tailwindcss, { postcss: true }) as PluginCreator<PluginOptions>
|