mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
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>
410 lines
13 KiB
TypeScript
410 lines
13 KiB
TypeScript
import { compile, normalizePath } from '@tailwindcss/node'
|
|
import { clearRequireCache } from '@tailwindcss/node/require-cache'
|
|
|
|
import { Scanner } from '@tailwindcss/oxide'
|
|
import { Features, transform } from 'lightningcss'
|
|
import path from 'path'
|
|
import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
|
|
|
|
export default function tailwindcss(): Plugin[] {
|
|
let servers: ViteDevServer[] = []
|
|
let config: ResolvedConfig | null = null
|
|
|
|
let isSSR = false
|
|
let minify = false
|
|
|
|
// A list of css plugins defined in the Vite config. We need to retain these
|
|
// so that we can rerun the right transformations in build mode where we have
|
|
// to manually rebuild the css file after the compilation is done.
|
|
let cssPlugins: readonly Plugin[] = []
|
|
|
|
// The Vite extension has two types of sources for candidates:
|
|
//
|
|
// 1. The module graph: These are all modules that vite transforms and we want
|
|
// them to be automatically scanned for candidates.
|
|
// 2. Root defined `@source`s
|
|
//
|
|
// Module graph candidates are global to the Vite extension since we do not
|
|
// know which CSS roots will be used for the modules. We are using a custom
|
|
// scanner instance with auto source discovery disabled to parse these.
|
|
//
|
|
// For candidates coming from custom `@source` directives of the CSS roots, we
|
|
// create an individual scanner for each root.
|
|
//
|
|
// Note: To improve performance, we do not remove candidates from this set.
|
|
// This means a longer-ongoing dev mode session might contain candidates that
|
|
// are no longer referenced in code.
|
|
let moduleGraphCandidates = new Set<string>()
|
|
let moduleGraphScanner = new Scanner({})
|
|
|
|
let roots: DefaultMap<string, Root> = new DefaultMap(
|
|
(id) => new Root(id, () => moduleGraphCandidates, config!.base),
|
|
)
|
|
|
|
function scanFile(id: string, content: string, extension: string, isSSR: boolean) {
|
|
let updated = false
|
|
for (let candidate of moduleGraphScanner.scanFiles([{ content, extension }])) {
|
|
updated = true
|
|
moduleGraphCandidates.add(candidate)
|
|
}
|
|
|
|
if (updated) {
|
|
invalidateAllRoots(isSSR)
|
|
}
|
|
}
|
|
|
|
function invalidateAllRoots(isSSR: boolean) {
|
|
for (let server of servers) {
|
|
let updates: Update[] = []
|
|
for (let id of roots.keys()) {
|
|
let module = server.moduleGraph.getModuleById(id)
|
|
if (!module) {
|
|
// Note: Removing this during SSR is not safe and will produce
|
|
// inconsistent results based on the timing of the removal and
|
|
// the order / timing of transforms.
|
|
if (!isSSR) {
|
|
// It is safe to remove the item here since we're iterating on a copy
|
|
// of the keys.
|
|
roots.delete(id)
|
|
}
|
|
continue
|
|
}
|
|
|
|
roots.get(id).requiresRebuild = false
|
|
server.moduleGraph.invalidateModule(module)
|
|
updates.push({
|
|
type: `${module.type}-update`,
|
|
path: module.url,
|
|
acceptedPath: module.url,
|
|
timestamp: Date.now(),
|
|
})
|
|
}
|
|
|
|
if (updates.length > 0) {
|
|
server.hot.send({ type: 'update', updates })
|
|
}
|
|
}
|
|
}
|
|
|
|
async function regenerateOptimizedCss(root: Root, addWatchFile: (file: string) => void) {
|
|
let content = root.lastContent
|
|
let generated = await root.generate(content, addWatchFile)
|
|
if (generated === false) {
|
|
return
|
|
}
|
|
return optimizeCss(generated, { minify })
|
|
}
|
|
|
|
// Manually run the transform functions of non-Tailwind plugins on the given CSS
|
|
async function transformWithPlugins(context: Rollup.PluginContext, id: string, css: string) {
|
|
let transformPluginContext = {
|
|
...context,
|
|
getCombinedSourcemap: () => {
|
|
throw new Error('getCombinedSourcemap not implemented')
|
|
},
|
|
}
|
|
|
|
for (let plugin of cssPlugins) {
|
|
if (!plugin.transform) continue
|
|
let transformHandler =
|
|
'handler' in plugin.transform! ? plugin.transform.handler : plugin.transform!
|
|
|
|
try {
|
|
// Directly call the plugin's transform function to process the
|
|
// generated CSS. In build mode, this updates the chunks later used to
|
|
// generate the bundle. In serve mode, the transformed source should be
|
|
// applied in transform.
|
|
let result = await transformHandler.call(transformPluginContext, css, id)
|
|
if (!result) continue
|
|
if (typeof result === 'string') {
|
|
css = result
|
|
} else if (result.code) {
|
|
css = result.code
|
|
}
|
|
} catch (e) {
|
|
console.error(`Error running ${plugin.name} on Tailwind CSS output. Skipping.`)
|
|
}
|
|
}
|
|
return css
|
|
}
|
|
|
|
return [
|
|
{
|
|
// Step 1: Scan source files for candidates
|
|
name: '@tailwindcss/vite:scan',
|
|
enforce: 'pre',
|
|
|
|
configureServer(server) {
|
|
servers.push(server)
|
|
},
|
|
|
|
async configResolved(_config) {
|
|
config = _config
|
|
minify = config.build.cssMinify !== false
|
|
isSSR = config.build.ssr !== false && config.build.ssr !== undefined
|
|
|
|
let allowedPlugins = [
|
|
// Apply the vite:css plugin to generated CSS for transformations like
|
|
// URL path rewriting and image inlining.
|
|
'vite:css',
|
|
|
|
// In build mode, since renderChunk runs after all transformations, we
|
|
// need to also apply vite:css-post.
|
|
...(config.command === 'build' ? ['vite:css-post'] : []),
|
|
]
|
|
|
|
cssPlugins = config.plugins.filter((plugin) => {
|
|
return allowedPlugins.includes(plugin.name)
|
|
})
|
|
},
|
|
|
|
// Scan all non-CSS files for candidates
|
|
transformIndexHtml(html, { path }) {
|
|
scanFile(path, html, 'html', isSSR)
|
|
},
|
|
transform(src, id, options) {
|
|
let extension = getExtension(id)
|
|
if (isPotentialCssRootFile(id)) return
|
|
scanFile(id, src, extension, options?.ssr ?? false)
|
|
},
|
|
},
|
|
|
|
{
|
|
// Step 2 (serve mode): Generate CSS
|
|
name: '@tailwindcss/vite:generate:serve',
|
|
apply: 'serve',
|
|
enforce: 'pre',
|
|
|
|
async transform(src, id, options) {
|
|
if (!isPotentialCssRootFile(id)) return
|
|
|
|
let root = roots.get(id)
|
|
|
|
if (!options?.ssr) {
|
|
// Wait until all other files have been processed, so we can extract
|
|
// all candidates before generating CSS. This must not be called
|
|
// during SSR or it will block the server.
|
|
//
|
|
// The reason why we can not rely on the invalidation here is that the
|
|
// users would otherwise see a flicker in the styles as the CSS might
|
|
// be loaded with an invalid set of candidates first.
|
|
await Promise.all(servers.map((server) => server.waitForRequestsIdle(id)))
|
|
}
|
|
|
|
let generated = await root.generate(src, (file) => this.addWatchFile(file))
|
|
if (!generated) {
|
|
roots.delete(id)
|
|
return src
|
|
}
|
|
return { code: generated }
|
|
},
|
|
},
|
|
|
|
{
|
|
// Step 2 (full build): Generate CSS
|
|
name: '@tailwindcss/vite:generate:build',
|
|
apply: 'build',
|
|
enforce: 'pre',
|
|
|
|
async transform(src, id) {
|
|
if (!isPotentialCssRootFile(id)) return
|
|
|
|
let root = roots.get(id)
|
|
|
|
// We do a first pass to generate valid CSS for the downstream plugins.
|
|
// However, since not all candidates are guaranteed to be extracted by
|
|
// this time, we have to re-run a transform for the root later.
|
|
let generated = await root.generate(src, (file) => this.addWatchFile(file))
|
|
if (!generated) {
|
|
roots.delete(id)
|
|
return src
|
|
}
|
|
return { code: generated }
|
|
},
|
|
|
|
// `renderStart` runs in the bundle generation stage after all transforms.
|
|
// We must run before `enforce: post` so the updated chunks are picked up
|
|
// by vite:css-post.
|
|
async renderStart() {
|
|
for (let [id, root] of roots.entries()) {
|
|
let generated = await regenerateOptimizedCss(
|
|
root,
|
|
// During the renderStart phase, we can not add watch files since
|
|
// those would not be causing a refresh of the right CSS file. This
|
|
// should not be an issue since we did already process the CSS file
|
|
// before and the dependencies should not be changed (only the
|
|
// candidate list might have)
|
|
() => {},
|
|
)
|
|
if (!generated) {
|
|
roots.delete(id)
|
|
continue
|
|
}
|
|
|
|
// These plugins have side effects which, during build, results in CSS
|
|
// being written to the output dir. We need to run them here to ensure
|
|
// the CSS is written before the bundle is generated.
|
|
await transformWithPlugins(this, id, generated)
|
|
}
|
|
},
|
|
},
|
|
] satisfies Plugin[]
|
|
}
|
|
|
|
function getExtension(id: string) {
|
|
let [filename] = id.split('?', 2)
|
|
return path.extname(filename).slice(1)
|
|
}
|
|
|
|
function isPotentialCssRootFile(id: string) {
|
|
let extension = getExtension(id)
|
|
let isCssFile =
|
|
extension === 'css' ||
|
|
(extension === 'vue' && id.includes('&lang.css')) ||
|
|
(extension === 'astro' && id.includes('&lang.css'))
|
|
return isCssFile
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
function idToPath(id: string) {
|
|
return path.resolve(id.replace(/\?.*$/, ''))
|
|
}
|
|
|
|
/**
|
|
* 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<K, V> extends Map<K, V> {
|
|
constructor(private factory: (key: K, self: DefaultMap<K, V>) => V) {
|
|
super()
|
|
}
|
|
|
|
get(key: K): V {
|
|
let value = super.get(key)
|
|
|
|
if (value === undefined) {
|
|
value = this.factory(key, this)
|
|
this.set(key, value)
|
|
}
|
|
|
|
return value
|
|
}
|
|
}
|
|
|
|
class Root {
|
|
// Content is only used in serve mode where we need to capture the initial
|
|
// contents of the root file so that we can restore it during the
|
|
// `renderStart` hook.
|
|
public lastContent: string = ''
|
|
|
|
// The lazily-initialized Tailwind compiler components. These are persisted
|
|
// throughout rebuilds but will be re-initialized if the rebuild strategy is
|
|
// set to `full`.
|
|
private compiler?: Awaited<ReturnType<typeof compile>>
|
|
|
|
public requiresRebuild: boolean = true
|
|
|
|
// This is the compiler-specific scanner instance that is used only to scan
|
|
// files for custom @source paths. All other modules we scan for candidates
|
|
// will use the shared moduleGraphScanner instance.
|
|
private scanner?: Scanner
|
|
|
|
// List of all candidates that were being returned by the root scanner during
|
|
// the lifetime of the root.
|
|
private candidates: Set<string> = new Set<string>()
|
|
|
|
// List of all file dependencies that were captured while generating the root.
|
|
// These are retained so we can clear the require cache when we rebuild the
|
|
// root.
|
|
private dependencies = new Set<string>()
|
|
|
|
constructor(
|
|
private id: string,
|
|
private getSharedCandidates: () => Set<string>,
|
|
private base: string,
|
|
) {}
|
|
|
|
// Generate the CSS for the root file. This can return false if the file is
|
|
// not considered a Tailwind root. When this happened, the root can be GCed.
|
|
public async generate(
|
|
content: string,
|
|
addWatchFile: (file: string) => void,
|
|
): Promise<string | false> {
|
|
this.lastContent = content
|
|
|
|
let inputPath = idToPath(this.id)
|
|
let inputBase = path.dirname(path.resolve(inputPath))
|
|
|
|
if (!this.compiler || !this.scanner || this.requiresRebuild) {
|
|
clearRequireCache(Array.from(this.dependencies))
|
|
this.dependencies = new Set([idToPath(inputPath)])
|
|
|
|
this.compiler = await compile(content, {
|
|
base: inputBase,
|
|
onDependency: (path) => {
|
|
addWatchFile(path)
|
|
this.dependencies.add(path)
|
|
},
|
|
})
|
|
|
|
this.scanner = new Scanner({
|
|
sources: this.compiler.globs,
|
|
})
|
|
}
|
|
|
|
// This should not be here, but right now the Vite plugin is setup where we
|
|
// setup a new scanner and compiler every time we request the CSS file
|
|
// (regardless whether it actually changed or not).
|
|
for (let candidate of this.scanner.scan()) {
|
|
this.candidates.add(candidate)
|
|
}
|
|
|
|
// Watch individual files found via custom `@source` paths
|
|
for (let file of this.scanner.files) {
|
|
addWatchFile(file)
|
|
}
|
|
|
|
// Watch globs found via custom `@source` paths
|
|
for (let glob of this.scanner.globs) {
|
|
if (glob.pattern[0] === '!') continue
|
|
|
|
let relative = path.relative(this.base, glob.base)
|
|
if (relative[0] !== '.') {
|
|
relative = './' + relative
|
|
}
|
|
// Ensure relative is a posix style path since we will merge it with the
|
|
// glob.
|
|
relative = normalizePath(relative)
|
|
|
|
addWatchFile(path.posix.join(relative, glob.pattern))
|
|
}
|
|
|
|
this.requiresRebuild = true
|
|
|
|
return this.compiler.build([...this.getSharedCandidates(), ...this.candidates])
|
|
}
|
|
}
|