mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
Alternative to #16425 Fixes #16585 Fixes #16389 Fixes #16252 Fixes #15794 Fixes #16646 Fixes #16358 This PR changes the Vite plugin to use the file-system to discover potential class names instead of relying on the module-graph. This comes after a lot of testing and various issue reports where builds that span different Vite instances were missing class names. Because we now scan for candidates using the file-system, we can also remove a lot of the bookkeeping necessary to make production builds and development builds work as we no longer have to change the resulting stylesheet based on the `transform` callbacks of other files that might happen later. This change comes at a small performance penalty that is noticeable especially on very large projects with many files to scan. However, we offset that change by fixing an issue that I found in the current Vite integration that did a needless rebuild of the whole Tailwind root whenever any source file changed. Because of how impactful this change is, I expect many normal to medium sized projects to actually see a performance improvement after these changes. Furthermore we do plan to continue to use the module-graph to further improve the performance in dev mode. ## Test plan - Added new integration tests with cases found across the issues above. - Manual testing by adding a local version of the Vite plugin to repos from the issue list above and the [tailwindcss playgrounds](https://github.com/philipp-spiess/tailwindcss-playgrounds).
351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
import { compile, env, Features, Instrumentation, normalizePath } 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/promises'
|
|
import path from 'node:path'
|
|
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
|
|
|
|
const DEBUG = env.DEBUG
|
|
const SPECIAL_QUERY_RE = /[?&](?:worker|sharedworker|raw|url)\b/
|
|
const COMMON_JS_PROXY_RE = /\?commonjs-proxy/
|
|
const INLINE_STYLE_ID_RE = /[?&]index\=\d+\.css$/
|
|
|
|
export default function tailwindcss(): Plugin[] {
|
|
let servers: ViteDevServer[] = []
|
|
let config: ResolvedConfig | null = null
|
|
|
|
let isSSR = false
|
|
let minify = false
|
|
|
|
let roots: DefaultMap<string, Root> = new DefaultMap((id) => {
|
|
let cssResolver = config!.createResolver({
|
|
...config!.resolve,
|
|
extensions: ['.css'],
|
|
mainFields: ['style'],
|
|
conditions: ['style', 'development|production'],
|
|
tryIndex: false,
|
|
preferRelative: true,
|
|
})
|
|
function customCssResolver(id: string, base: string) {
|
|
return cssResolver(id, base, true, isSSR)
|
|
}
|
|
|
|
let jsResolver = config!.createResolver(config!.resolve)
|
|
function customJsResolver(id: string, base: string) {
|
|
return jsResolver(id, base, true, isSSR)
|
|
}
|
|
return new Root(id, config!.root, customCssResolver, customJsResolver)
|
|
})
|
|
|
|
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
|
|
},
|
|
},
|
|
|
|
{
|
|
// Step 2 (serve mode): Generate CSS
|
|
name: '@tailwindcss/vite:generate:serve',
|
|
apply: 'serve',
|
|
enforce: 'pre',
|
|
|
|
async transform(src, id, options) {
|
|
if (!isPotentialCssRootFile(id)) return
|
|
|
|
using I = new Instrumentation()
|
|
DEBUG && I.start('[@tailwindcss/vite] Generate CSS (serve)')
|
|
|
|
let root = roots.get(id)
|
|
|
|
let generated = await root.generate(src, (file) => this.addWatchFile(file), I)
|
|
if (!generated) {
|
|
roots.delete(id)
|
|
return src
|
|
}
|
|
|
|
DEBUG && I.end('[@tailwindcss/vite] Generate CSS (serve)')
|
|
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
|
|
|
|
using I = new Instrumentation()
|
|
DEBUG && I.start('[@tailwindcss/vite] Generate CSS (build)')
|
|
|
|
let root = roots.get(id)
|
|
|
|
let generated = await root.generate(src, (file) => this.addWatchFile(file), I)
|
|
if (!generated) {
|
|
roots.delete(id)
|
|
return src
|
|
}
|
|
DEBUG && I.end('[@tailwindcss/vite] Generate CSS (build)')
|
|
|
|
DEBUG && I.start('[@tailwindcss/vite] Optimize CSS')
|
|
generated = optimizeCss(generated, { minify })
|
|
DEBUG && I.end('[@tailwindcss/vite] Optimize CSS')
|
|
|
|
return { code: generated }
|
|
},
|
|
},
|
|
] satisfies Plugin[]
|
|
}
|
|
|
|
function getExtension(id: string) {
|
|
let [filename] = id.split('?', 2)
|
|
return path.extname(filename).slice(1)
|
|
}
|
|
|
|
function isPotentialCssRootFile(id: string) {
|
|
if (id.includes('/.vite/')) return
|
|
let extension = getExtension(id)
|
|
let isCssFile =
|
|
(extension === 'css' || id.includes('&lang.css') || id.match(INLINE_STYLE_ID_RE)) &&
|
|
// Don't intercept special static asset resources
|
|
!SPECIAL_QUERY_RE.test(id) &&
|
|
!COMMON_JS_PROXY_RE.test(id)
|
|
return isCssFile
|
|
}
|
|
|
|
function optimizeCss(
|
|
input: string,
|
|
{ file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},
|
|
) {
|
|
function optimize(code: Buffer | Uint8Array | any) {
|
|
return transform({
|
|
filename: file,
|
|
code,
|
|
minify,
|
|
sourceMap: false,
|
|
drafts: {
|
|
customMedia: true,
|
|
},
|
|
nonStandard: {
|
|
deepSelectorCombinator: true,
|
|
},
|
|
include: LightningCssFeatures.Nesting,
|
|
exclude:
|
|
LightningCssFeatures.LogicalProperties |
|
|
LightningCssFeatures.DirSelector |
|
|
LightningCssFeatures.LightDark,
|
|
targets: {
|
|
safari: (16 << 16) | (4 << 8),
|
|
ios_saf: (16 << 16) | (4 << 8),
|
|
firefox: 128 << 16,
|
|
chrome: 111 << 16,
|
|
},
|
|
errorRecovery: true,
|
|
}).code
|
|
}
|
|
|
|
// Running Lightning CSS twice to ensure that adjacent rules are merged after
|
|
// nesting is applied. This creates a more optimized output.
|
|
return optimize(optimize(Buffer.from(input))).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 {
|
|
// 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>>
|
|
|
|
// The lazily-initialized Tailwind scanner.
|
|
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 build dependencies (e.g. imported stylesheets or plugins) and
|
|
// their last modification timestamp
|
|
private buildDependencies = new Map<string, number>()
|
|
|
|
constructor(
|
|
private id: string,
|
|
private base: string,
|
|
|
|
private customCssResolver: (id: string, base: string) => Promise<string | false | undefined>,
|
|
private customJsResolver: (id: string, base: string) => Promise<string | false | undefined>,
|
|
) {}
|
|
|
|
// 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,
|
|
I: Instrumentation,
|
|
): Promise<string | false> {
|
|
let requiresBuildPromise = this.requiresBuild()
|
|
let inputPath = idToPath(this.id)
|
|
let inputBase = path.dirname(path.resolve(inputPath))
|
|
|
|
if (!this.compiler || !this.scanner || (await requiresBuildPromise)) {
|
|
clearRequireCache(Array.from(this.buildDependencies.keys()))
|
|
this.buildDependencies.clear()
|
|
|
|
this.addBuildDependency(idToPath(inputPath))
|
|
|
|
DEBUG && I.start('Setup compiler')
|
|
let addBuildDependenciesPromises: Promise<void>[] = []
|
|
this.compiler = await compile(content, {
|
|
base: inputBase,
|
|
shouldRewriteUrls: true,
|
|
onDependency: (path) => {
|
|
addWatchFile(path)
|
|
addBuildDependenciesPromises.push(this.addBuildDependency(path))
|
|
},
|
|
|
|
customCssResolver: this.customCssResolver,
|
|
customJsResolver: this.customJsResolver,
|
|
})
|
|
await Promise.all(addBuildDependenciesPromises)
|
|
DEBUG && I.end('Setup compiler')
|
|
|
|
DEBUG && I.start('Setup scanner')
|
|
|
|
let sources = (() => {
|
|
// Disable auto source detection
|
|
if (this.compiler.root === 'none') {
|
|
return []
|
|
}
|
|
|
|
// No root specified, auto-detect based on the `**/*` pattern
|
|
if (this.compiler.root === null) {
|
|
return [{ base: this.base, pattern: '**/*' }]
|
|
}
|
|
|
|
// Use the specified root
|
|
return [this.compiler.root]
|
|
})().concat(this.compiler.globs)
|
|
|
|
this.scanner = new Scanner({ sources })
|
|
DEBUG && I.end('Setup scanner')
|
|
}
|
|
|
|
if (
|
|
!(
|
|
this.compiler.features &
|
|
(Features.AtApply | Features.JsPluginCompat | Features.ThemeFunction | Features.Utilities)
|
|
)
|
|
) {
|
|
return false
|
|
}
|
|
|
|
if (this.compiler.features & Features.Utilities) {
|
|
// 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).
|
|
DEBUG && I.start('Scan for candidates')
|
|
for (let candidate of this.scanner.scan()) {
|
|
this.candidates.add(candidate)
|
|
}
|
|
DEBUG && I.end('Scan for candidates')
|
|
}
|
|
|
|
if (this.compiler.features & Features.Utilities) {
|
|
// 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))
|
|
|
|
let root = this.compiler.root
|
|
|
|
if (root !== 'none' && root !== null) {
|
|
let basePath = normalizePath(path.resolve(root.base, root.pattern))
|
|
|
|
let isDir = await fs.stat(basePath).then(
|
|
(stats) => stats.isDirectory(),
|
|
() => false,
|
|
)
|
|
|
|
if (!isDir) {
|
|
throw new Error(
|
|
`The path given to \`source(…)\` must be a directory but got \`source(${basePath})\` instead.`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
DEBUG && I.start('Build CSS')
|
|
let result = this.compiler.build([...this.candidates])
|
|
DEBUG && I.end('Build CSS')
|
|
|
|
return result
|
|
}
|
|
|
|
private async addBuildDependency(path: string) {
|
|
let stat = await fs.stat(path)
|
|
this.buildDependencies.set(path, stat.mtimeMs)
|
|
}
|
|
|
|
private async requiresBuild(): Promise<boolean> {
|
|
for (let [path, mtime] of this.buildDependencies) {
|
|
let stat = await fs.stat(path)
|
|
if (stat.mtimeMs > mtime) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|