Add @tailwindcss/turbopack loader

This commit is contained in:
Tim Neutkens 2026-07-30 15:45:51 -04:00 • committed by Robin Malfait
parent d494a78532
commit c9689a44a6
9 changed files with 486 additions and 0 deletions

View file

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Add `@tailwindcss/turbopack` loader for Tailwind CSS v4
### Fixed
- Ensure watch mode detects changes to symlinked `@source` files whose real paths aren't otherwise scanned ([#20356](https://github.com/tailwindlabs/tailwindcss/pull/20356))

View file

@ -612,6 +612,7 @@ function overwriteVersionsInPnpmWorkspace(content: string): string {
workspace.overrides['@tailwindcss/upgrade>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/cli>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/postcss>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/turbopack>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/vite>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/webpack>tailwindcss'] = resolveVersion(pkg)
} else {

View file

@ -0,0 +1,99 @@
<p align="center">
<a href="https://tailwindcss.com" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tailwindlabs/tailwindcss/HEAD/.github/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/tailwindlabs/tailwindcss/HEAD/.github/logo-light.svg">
<img alt="Tailwind CSS" src="https://raw.githubusercontent.com/tailwindlabs/tailwindcss/HEAD/.github/logo-light.svg" width="350" height="70" style="max-width: 100%;">
</picture>
</a>
</p>
<p align="center">
A utility-first CSS framework for rapidly building custom user interfaces.
</p>
<p align="center">
<a href="https://github.com/tailwindlabs/tailwindcss/actions"><img src="https://img.shields.io/github/actions/workflow/status/tailwindlabs/tailwindcss/ci.yml?branch=main" alt="Build Status"></a>
<a href="https://www.npmjs.com/package/tailwindcss"><img src="https://img.shields.io/npm/dt/tailwindcss.svg" alt="Total Downloads"></a>
<a href="https://github.com/tailwindlabs/tailwindcss/releases"><img src="https://img.shields.io/npm/v/tailwindcss.svg" alt="Latest Release"></a>
<a href="https://github.com/tailwindlabs/tailwindcss/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/tailwindcss.svg" alt="License"></a>
</p>
---
## Documentation
For full documentation, visit [tailwindcss.com](https://tailwindcss.com).
## Community
For help, discussion about best practices, or feature ideas:
[Discuss Tailwind CSS on GitHub](https://github.com/tailwindlabs/tailwindcss/discussions)
## Contributing
If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**.
---
## @tailwindcss/turbopack
A Turbopack loader for Tailwind CSS v4.
## Installation
```sh
npm install @tailwindcss/turbopack
```
### Usage
```javascript
// next.config.js
module.exports = {
turbopack: {
rules: {
'*.css': {
loaders: ['@tailwindcss/turbopack'],
as: '*.css',
},
},
},
}
```
Then create a CSS file that imports Tailwind:
```css
/* src/index.css */
@import 'tailwindcss';
```
### Options
#### `base`
The base directory to scan for class candidates. Defaults to the current working directory.
```javascript
{
loader: '@tailwindcss/turbopack',
options: {
base: process.cwd(),
},
}
```
#### `optimize`
Whether to optimize and minify the output CSS. Defaults to `true` in production mode.
```javascript
{
loader: '@tailwindcss/turbopack',
options: {
optimize: true, // or { minify: true }
},
}
```

View file

@ -0,0 +1,43 @@
{
"name": "@tailwindcss/turbopack",
"version": "4.3.3",
"description": "A Turbopack loader for Tailwind CSS v4.",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tailwindlabs/tailwindcss.git",
"directory": "packages/@tailwindcss-turbopack"
},
"bugs": "https://github.com/tailwindlabs/tailwindcss/issues",
"homepage": "https://tailwindcss.com",
"scripts": {
"build": "tsup-node",
"dev": "pnpm run build -- --watch"
},
"files": [
"dist/"
],
"publishConfig": {
"provenance": true,
"access": "public"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "workspace:*",
"@tailwindcss/oxide": "workspace:*",
"tailwindcss": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"webpack": "catalog:"
}
}

View file

@ -0,0 +1,5 @@
import tailwindLoader from './index.ts'
// CommonJS export for webpack loaders - must be the function directly
// @ts-ignore
export = tailwindLoader

View file

@ -0,0 +1,287 @@
import QuickLRU from '@alloc/quick-lru'
import {
compile,
env,
Features,
Instrumentation,
normalizePath,
optimize,
Polyfills,
} from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import fs from 'node:fs'
import path from 'node:path'
import type { LoaderContext } from 'webpack'
const DEBUG = env.DEBUG
export interface LoaderOptions {
/**
* The base directory to scan for class candidates.
*
* Defaults to the current working directory.
*/
base?: string
/**
* Optimize and minify the output CSS.
*/
optimize?: boolean | { minify?: boolean }
}
interface CacheEntry {
mtimes: Map<string, number>
compiler: null | Awaited<ReturnType<typeof compile>>
scanner: null | Scanner
candidates: Set<string>
fullRebuildPaths: string[]
}
const cache = new QuickLRU<string, CacheEntry>({ maxSize: 50 })
function getCacheKey(resourceId: string, opts: LoaderOptions): string {
return `${resourceId}:${opts.base ?? ''}:${JSON.stringify(opts.optimize)}`
}
function getContextFromCache(resourceId: string, opts: LoaderOptions): CacheEntry {
let key = getCacheKey(resourceId, opts)
if (cache.has(key)) return cache.get(key)!
let entry: CacheEntry = {
mtimes: new Map<string, number>(),
compiler: null,
scanner: null,
candidates: new Set<string>(),
fullRebuildPaths: [],
}
cache.set(key, entry)
return entry
}
export default async function tailwindLoader(
this: LoaderContext<LoaderOptions>,
source: string,
): Promise<void> {
let callback = this.async()
let options = this.getOptions() ?? {}
let inputFile = this.resourcePath
let resourceId = this.resource
let base = options.base ?? process.cwd()
let shouldOptimize = options.optimize ?? process.env.NODE_ENV === 'production'
let isCSSModuleFile = inputFile.endsWith('.module.css')
using I = new Instrumentation()
DEBUG && I.start(`[@tailwindcss/webpack] ${path.relative(base, inputFile)}`)
// Bail out early if this is guaranteed to be a non-Tailwind CSS file.
{
DEBUG && I.start('Quick bail check')
let canBail = !/@(import|reference|theme|variant|config|plugin|apply|tailwind)\b/.test(source)
if (canBail) {
DEBUG && I.end('Quick bail check')
DEBUG && I.end(`[@tailwindcss/webpack] ${path.relative(base, inputFile)}`)
callback(null, source)
return
}
DEBUG && I.end('Quick bail check')
}
try {
let context = getContextFromCache(resourceId, options)
let inputBasePath = path.dirname(path.resolve(inputFile))
// Whether this is the first build or not
let isInitialBuild = context.compiler === null
async function createCompiler() {
DEBUG && I.start('Setup compiler')
if (context.fullRebuildPaths.length > 0 && !isInitialBuild) {
clearRequireCache(context.fullRebuildPaths)
}
context.fullRebuildPaths = []
DEBUG && I.start('Create compiler')
let compiler = await compile(source, {
from: inputFile,
base: inputBasePath,
shouldRewriteUrls: true,
onDependency: (depPath) => context.fullRebuildPaths.push(depPath),
// In CSS Module files, we have to disable the `@property` polyfill since these will
// emit global `*` rules which are considered to be non-pure and will cause builds
// to fail.
polyfills: isCSSModuleFile ? Polyfills.All ^ Polyfills.AtProperty : Polyfills.All,
})
DEBUG && I.end('Create compiler')
DEBUG && I.end('Setup compiler')
return compiler
}
// Setup the compiler if it doesn't exist yet
context.compiler ??= await createCompiler()
// Early exit if no Tailwind features are used
if (context.compiler.features === Features.None) {
DEBUG && I.end(`[@tailwindcss/webpack] ${path.relative(base, inputFile)}`)
callback(null, source)
return
}
let rebuildStrategy: 'full' | 'incremental' = 'incremental'
// Track file modification times to CSS files
DEBUG && I.start('Register full rebuild paths')
{
// Report dependencies for config files, plugins, etc.
for (let file of context.fullRebuildPaths) {
this.addDependency(path.resolve(file))
}
let files = [...context.fullRebuildPaths, inputFile]
for (let file of files) {
let changedTime: number | null = null
try {
changedTime = fs.statSync(file)?.mtimeMs ?? null
} catch {
// File might not exist
}
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)
}
}
DEBUG && I.end('Register full rebuild paths')
if (rebuildStrategy === 'full' && !isInitialBuild) {
context.compiler = await createCompiler()
}
let compiler = context.compiler
// Check if we need to process this file at all
if (
!(
compiler.features &
(Features.AtApply | Features.JsPluginCompat | Features.ThemeFunction | Features.Utilities)
)
) {
DEBUG && I.end(`[@tailwindcss/webpack] ${path.relative(base, inputFile)}`)
callback(null, source)
return
}
// Setup or update scanner if needed
if (context.scanner === null || rebuildStrategy === 'full') {
DEBUG && I.start('Setup scanner')
let sources = (() => {
// Disable auto source detection
if (compiler.root === 'none') {
return []
}
// No root specified, use the base directory
if (compiler.root === null) {
return [{ base, pattern: '**/*', negated: false }]
}
// Use the specified root
return [{ ...compiler.root, negated: false }]
})().concat(compiler.sources)
context.scanner = new Scanner({ sources })
DEBUG && I.end('Setup scanner')
}
// Scan for candidates if utilities are used
if (compiler.features & Features.Utilities) {
DEBUG && I.start('Scan for candidates')
for (let candidate of context.scanner.scan()) {
context.candidates.add(candidate)
}
DEBUG && I.end('Scan for candidates')
DEBUG && I.start('Register dependency messages')
// Add all found files as direct dependencies
let resolvedInputFile = path.resolve(base, inputFile)
for (let file of context.scanner.files) {
let absolutePath = path.resolve(file)
// The CSS file cannot be a dependency of itself
if (absolutePath === resolvedInputFile) {
continue
}
this.addDependency(absolutePath)
}
// Register context dependencies for glob patterns
for (let glob of context.scanner.globs) {
// Skip negated patterns
if (glob.pattern[0] === '!') continue
// Avoid adding a dependency on the base directory itself
if (glob.pattern === '*' && base === glob.base) {
continue
}
this.addContextDependency(path.resolve(glob.base))
}
// Validate that source(...) paths are directories
let root = compiler.root
if (root !== 'none' && root !== null) {
let basePath = normalizePath(path.resolve(root.base, root.pattern))
try {
let stats = fs.statSync(basePath)
if (!stats.isDirectory()) {
throw new Error(
`The path given to \`source(…)\` must be a directory but got \`source(${basePath})\` instead.`,
)
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err
}
// Directory doesn't exist yet, which is fine
}
}
DEBUG && I.end('Register dependency messages')
}
DEBUG && I.start('Build utilities')
let css = compiler.build([...context.candidates])
DEBUG && I.end('Build utilities')
// Optionally optimize the output
let result = css
if (shouldOptimize) {
DEBUG && I.start('Optimization')
let optimized = optimize(css, {
minify: typeof shouldOptimize === 'object' ? shouldOptimize.minify : true,
})
result = optimized.code
DEBUG && I.end('Optimization')
}
DEBUG && I.end(`[@tailwindcss/webpack] ${path.relative(base, inputFile)}`)
callback(null, result)
} catch (error) {
// Clear the cache entry on error to force a full rebuild next time
let key = getCacheKey(resourceId, options)
cache.delete(key)
DEBUG && I.end(`[@tailwindcss/webpack] ${path.relative(base, inputFile)}`)
callback(error as Error)
}
}

View file

@ -0,0 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"verbatimModuleSyntax": false,
},
}

View file

@ -0,0 +1,19 @@
import { defineConfig } from 'tsup'
export default defineConfig([
{
format: ['esm'],
clean: true,
minify: true,
cjsInterop: true,
dts: true,
entry: ['src/index.ts'],
},
{
format: ['cjs'],
minify: true,
cjsInterop: true,
dts: true,
entry: ['src/index.cts'],
},
])

22
pnpm-lock.yaml generated
View file

@ -431,6 +431,28 @@ importers:
specifier: ^7.7.1
version: 7.7.1
packages/@tailwindcss-turbopack:
dependencies:
'@alloc/quick-lru':
specifier: ^5.2.0
version: 5.2.0
'@tailwindcss/node':
specifier: workspace:*
version: link:../@tailwindcss-node
'@tailwindcss/oxide':
specifier: workspace:*
version: link:../../crates/node
tailwindcss:
specifier: workspace:*
version: link:../tailwindcss
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 22.19.21
webpack:
specifier: 'catalog:'
version: 5.108.3(esbuild@0.27.7)(postcss@8.5.16)
packages/@tailwindcss-vite:
dependencies:
'@tailwindcss/node':