diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6573eddbb7cf..7bbe6816b6f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add new standalone builds of Tailwind CSS v4 ([#14270](https://github.com/tailwindlabs/tailwindcss/pull/14270))
+- Support JavaScript configuration files using `@config` ([#14239](https://github.com/tailwindlabs/tailwindcss/pull/14239))
### Fixed
diff --git a/integrations/cli/config.test.ts b/integrations/cli/config.test.ts
new file mode 100644
index 000000000000..54ca8c0cab91
--- /dev/null
+++ b/integrations/cli/config.test.ts
@@ -0,0 +1,191 @@
+import { candidate, css, html, js, json, test } from '../utils'
+
+test(
+ 'Config files (CJS)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/cli": "workspace:^"
+ }
+ }
+ `,
+ 'index.html': html`
+
+ `,
+ 'tailwind.config.js': js`
+ module.exports = {
+ theme: {
+ extend: {
+ colors: {
+ primary: 'blue',
+ },
+ },
+ },
+ }
+ `,
+ 'src/index.css': css`
+ @import 'tailwindcss';
+ @config '../tailwind.config.js';
+ `,
+ },
+ },
+ async ({ fs, exec }) => {
+ await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
+
+ await fs.expectFileToContain('dist/out.css', [
+ //
+ candidate`text-primary`,
+ ])
+ },
+)
+
+test(
+ 'Config files (ESM)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/cli": "workspace:^"
+ }
+ }
+ `,
+ 'index.html': html`
+
+ `,
+ 'tailwind.config.mjs': js`
+ export default {
+ theme: {
+ extend: {
+ colors: {
+ primary: 'blue',
+ },
+ },
+ },
+ }
+ `,
+ 'src/index.css': css`
+ @import 'tailwindcss';
+ @config '../tailwind.config.mjs';
+ `,
+ },
+ },
+ async ({ fs, exec }) => {
+ await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
+
+ await fs.expectFileToContain('dist/out.css', [
+ //
+ candidate`text-primary`,
+ ])
+ },
+)
+
+test(
+ 'Config files (CJS, watch mode)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/cli": "workspace:^"
+ }
+ }
+ `,
+ 'index.html': html`
+
+ `,
+ 'tailwind.config.js': js`
+ const myColor = require('./my-color')
+ module.exports = {
+ theme: {
+ extend: {
+ colors: {
+ primary: myColor,
+ },
+ },
+ },
+ }
+ `,
+ 'my-color.js': js`module.exports = 'blue'`,
+ 'src/index.css': css`
+ @import 'tailwindcss';
+ @config '../tailwind.config.js';
+ `,
+ },
+ },
+ async ({ fs, spawn }) => {
+ await spawn('pnpm tailwindcss --input src/index.css --output dist/out.css --watch')
+
+ await fs.expectFileToContain('dist/out.css', [
+ //
+ candidate`text-primary`,
+ 'color: blue',
+ ])
+
+ await fs.write('my-color.js', js`module.exports = 'red'`)
+
+ await fs.expectFileToContain('dist/out.css', [
+ //
+ candidate`text-primary`,
+ 'color: red',
+ ])
+ },
+)
+
+test(
+ 'Config files (MJS, watch mode)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/cli": "workspace:^"
+ }
+ }
+ `,
+ 'index.html': html`
+
+ `,
+ 'tailwind.config.mjs': js`
+ import myColor from './my-color.mjs'
+ export default {
+ theme: {
+ extend: {
+ colors: {
+ primary: myColor,
+ },
+ },
+ },
+ }
+ `,
+ 'my-color.mjs': js`export default 'blue'`,
+ 'src/index.css': css`
+ @import 'tailwindcss';
+ @config '../tailwind.config.mjs';
+ `,
+ },
+ },
+ async ({ fs, spawn }) => {
+ await spawn('pnpm tailwindcss --input src/index.css --output dist/out.css --watch')
+
+ await fs.expectFileToContain('dist/out.css', [
+ //
+ candidate`text-primary`,
+ 'color: blue',
+ ])
+
+ await fs.write('my-color.mjs', js`export default 'red'`)
+
+ await fs.expectFileToContain('dist/out.css', [
+ //
+ candidate`text-primary`,
+ 'color: red',
+ ])
+ },
+)
diff --git a/integrations/postcss/config.test.ts b/integrations/postcss/config.test.ts
new file mode 100644
index 000000000000..30da4219d9ab
--- /dev/null
+++ b/integrations/postcss/config.test.ts
@@ -0,0 +1,245 @@
+import { candidate, css, html, js, json, test } from '../utils'
+
+test(
+ 'Config files (CJS)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "postcss": "^8",
+ "postcss-cli": "^10",
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/postcss": "workspace:^"
+ }
+ }
+ `,
+ 'postcss.config.js': js`
+ /** @type {import('postcss-load-config').Config} */
+ module.exports = {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+ }
+ `,
+ 'index.html': html`
+
+ `,
+ 'tailwind.config.js': js`
+ module.exports = {
+ theme: {
+ extend: {
+ colors: {
+ primary: 'blue',
+ },
+ },
+ },
+ }
+ `,
+ 'src/index.css': css`
+ @import 'tailwindcss';
+ @config '../tailwind.config.js';
+ `,
+ },
+ },
+ async ({ fs, exec }) => {
+ await exec('pnpm postcss src/index.css --output dist/out.css')
+
+ await fs.expectFileToContain('dist/out.css', [
+ //
+ candidate`text-primary`,
+ ])
+ },
+)
+
+test(
+ 'Config files (ESM)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "postcss": "^8",
+ "postcss-cli": "^10",
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/postcss": "workspace:^"
+ }
+ }
+ `,
+ 'postcss.config.mjs': js`
+ import tailwindcss from '@tailwindcss/postcss'
+ /** @type {import('postcss-load-config').Config} */
+ export default {
+ plugins: [tailwindcss()],
+ }
+ `,
+ 'index.html': html`
+
+ `,
+ 'tailwind.config.mjs': js`
+ export default {
+ theme: {
+ extend: {
+ colors: {
+ primary: 'blue',
+ },
+ },
+ },
+ }
+ `,
+ 'src/index.css': css`
+ @import 'tailwindcss';
+ @config '../tailwind.config.mjs';
+ `,
+ },
+ },
+ async ({ fs, exec }) => {
+ await exec('pnpm postcss src/index.css --output dist/out.css')
+
+ await fs.expectFileToContain('dist/out.css', [
+ //
+ candidate`text-primary`,
+ ])
+ },
+)
+
+test(
+ 'Config files (CJS, watch mode)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "postcss": "^8",
+ "postcss-cli": "^10",
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/postcss": "workspace:^"
+ }
+ }
+ `,
+ 'postcss.config.js': js`
+ /** @type {import('postcss-load-config').Config} */
+ module.exports = {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+ }
+ `,
+ 'index.html': html`
+
+ `,
+ 'tailwind.config.js': js`
+ let myColor = require('./my-color.js')
+ module.exports = {
+ theme: {
+ extend: {
+ colors: {
+ primary: myColor,
+ },
+ },
+ },
+ }
+ `,
+ 'my-color.js': js`module.exports = 'blue'`,
+ 'src/index.css': css`
+ @import 'tailwindcss';
+ @config '../tailwind.config.js';
+ `,
+ },
+ },
+ async ({ fs, spawn }) => {
+ await spawn('pnpm postcss src/index.css --output dist/out.css --watch --verbose')
+
+ await fs.expectFileToContain('dist/out.css', [
+ //
+ candidate`text-primary`,
+ 'color: blue',
+ ])
+
+ // While working on this test we noticed that it was failing in about 1-2%
+ // of the runs. We tracked this down to being a proper `delete
+ // require.cache` call for the `my-color.js` file but for some reason
+ // reading it will result in the previous contents.
+ //
+ // To work around this, we give postcss some time to stabilize.
+ await new Promise((resolve) => setTimeout(resolve, 500))
+
+ await fs.write('my-color.js', js`module.exports = 'red'`)
+
+ await fs.expectFileToContain('dist/out.css', [
+ //
+ candidate`text-primary`,
+ 'color: red',
+ ])
+ },
+)
+
+test(
+ 'Config files (ESM, watch mode)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "postcss": "^8",
+ "postcss-cli": "^10",
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/postcss": "workspace:^"
+ }
+ }
+ `,
+ 'postcss.config.mjs': js`
+ import tailwindcss from '@tailwindcss/postcss'
+ /** @type {import('postcss-load-config').Config} */
+ export default {
+ plugins: [tailwindcss()],
+ }
+ `,
+ 'index.html': html`
+
+ `,
+ 'tailwind.config.mjs': js`
+ import myColor from './my-color.mjs'
+ export default {
+ theme: {
+ extend: {
+ colors: {
+ primary: myColor,
+ },
+ },
+ },
+ }
+ `,
+ 'my-color.mjs': js`export default 'blue'`,
+ 'src/index.css': css`
+ @import 'tailwindcss';
+ @config '../tailwind.config.mjs';
+ `,
+ },
+ },
+ async ({ fs, spawn }) => {
+ await spawn('pnpm postcss src/index.css --output dist/out.css --watch --verbose')
+
+ await fs.expectFileToContain('dist/out.css', [
+ //
+ candidate`text-primary`,
+ 'color: blue',
+ ])
+
+ // While working on this test we noticed that it was failing in about 1-2%
+ // of the runs. We tracked this down to being a proper reset of ESM imports
+ // for the `my-color.js` file but for some reason reading it will result in
+ // the previous contents.
+ //
+ // To work around this, we give postcss some time to stabilize.
+ await new Promise((resolve) => setTimeout(resolve, 500))
+
+ await fs.write('my-color.mjs', js`export default 'red'`)
+
+ await fs.expectFileToContain('dist/out.css', [
+ //
+ candidate`text-primary`,
+ 'color: red',
+ ])
+ },
+)
diff --git a/integrations/vite/config.test.ts b/integrations/vite/config.test.ts
new file mode 100644
index 000000000000..9ee774e00d58
--- /dev/null
+++ b/integrations/vite/config.test.ts
@@ -0,0 +1,272 @@
+import { expect } from 'vitest'
+import { candidate, css, fetchStyles, html, js, json, retryAssertion, test, ts } from '../utils'
+
+test(
+ 'Config files (CJS)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "type": "module",
+ "dependencies": {
+ "@tailwindcss/vite": "workspace:^",
+ "tailwindcss": "workspace:^"
+ },
+ "devDependencies": {
+ "vite": "^5.3.5"
+ }
+ }
+ `,
+ 'vite.config.ts': ts`
+ import tailwindcss from '@tailwindcss/vite'
+ import { defineConfig } from 'vite'
+
+ export default defineConfig({
+ build: { cssMinify: false },
+ plugins: [tailwindcss()],
+ })
+ `,
+ 'index.html': html`
+
+
+
+
+
+
+ `,
+ 'tailwind.config.cjs': js`
+ module.exports = {
+ theme: {
+ extend: {
+ colors: {
+ primary: 'blue',
+ },
+ },
+ },
+ }
+ `,
+ 'src/index.css': css`
+ @import 'tailwindcss';
+ @config '../tailwind.config.cjs';
+ `,
+ },
+ },
+ async ({ fs, exec }) => {
+ await exec('pnpm vite build')
+
+ let files = await fs.glob('dist/**/*.css')
+ expect(files).toHaveLength(1)
+ let [filename] = files[0]
+
+ await fs.expectFileToContain(filename, [
+ //
+ candidate`text-primary`,
+ ])
+ },
+)
+
+test(
+ 'Config files (ESM)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "type": "module",
+ "dependencies": {
+ "@tailwindcss/vite": "workspace:^",
+ "tailwindcss": "workspace:^"
+ },
+ "devDependencies": {
+ "vite": "^5.3.5"
+ }
+ }
+ `,
+ 'vite.config.ts': ts`
+ import tailwindcss from '@tailwindcss/vite'
+ import { defineConfig } from 'vite'
+
+ export default defineConfig({
+ build: { cssMinify: false },
+ plugins: [tailwindcss()],
+ })
+ `,
+ 'index.html': html`
+
+
+
+
+
+
+ `,
+ 'tailwind.config.js': js`
+ export default {
+ theme: {
+ extend: {
+ colors: {
+ primary: 'blue',
+ },
+ },
+ },
+ }
+ `,
+ 'src/index.css': css`
+ @import 'tailwindcss';
+ @config '../tailwind.config.js';
+ `,
+ },
+ },
+ async ({ fs, exec }) => {
+ await exec('pnpm vite build')
+
+ let files = await fs.glob('dist/**/*.css')
+ expect(files).toHaveLength(1)
+ let [filename] = files[0]
+
+ await fs.expectFileToContain(filename, [
+ //
+ candidate`text-primary`,
+ ])
+ },
+)
+
+test(
+ 'Config files (CJS, dev mode)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "type": "module",
+ "dependencies": {
+ "@tailwindcss/vite": "workspace:^",
+ "tailwindcss": "workspace:^"
+ },
+ "devDependencies": {
+ "vite": "^5.3.5"
+ }
+ }
+ `,
+ 'vite.config.ts': ts`
+ import tailwindcss from '@tailwindcss/vite'
+ import { defineConfig } from 'vite'
+
+ export default defineConfig({
+ build: { cssMinify: false },
+ plugins: [tailwindcss()],
+ })
+ `,
+ 'index.html': html`
+
+
+
+
+
+
+ `,
+ 'tailwind.config.cjs': js`
+ const myColor = require('./my-color.cjs')
+ module.exports = {
+ theme: {
+ extend: {
+ colors: {
+ primary: myColor,
+ },
+ },
+ },
+ }
+ `,
+ 'my-color.cjs': js`module.exports = 'blue'`,
+ 'src/index.css': css`
+ @import 'tailwindcss';
+ @config '../tailwind.config.cjs';
+ `,
+ },
+ },
+ async ({ fs, getFreePort, spawn }) => {
+ let port = await getFreePort()
+ await spawn(`pnpm vite dev --port ${port}`)
+
+ await retryAssertion(async () => {
+ let css = await fetchStyles(port, '/index.html')
+ expect(css).toContain(candidate`text-primary`)
+ expect(css).toContain('color: blue')
+ })
+
+ await fs.write('my-color.cjs', js`module.exports = 'red'`)
+ await retryAssertion(async () => {
+ let css = await fetchStyles(port, '/index.html')
+ expect(css).toContain(candidate`text-primary`)
+ expect(css).toContain('color: red')
+ })
+ },
+)
+
+test(
+ 'Config files (ESM, dev mode)',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "type": "module",
+ "dependencies": {
+ "@tailwindcss/vite": "workspace:^",
+ "tailwindcss": "workspace:^"
+ },
+ "devDependencies": {
+ "vite": "^5.3.5"
+ }
+ }
+ `,
+ 'vite.config.ts': ts`
+ import tailwindcss from '@tailwindcss/vite'
+ import { defineConfig } from 'vite'
+
+ export default defineConfig({
+ build: { cssMinify: false },
+ plugins: [tailwindcss()],
+ })
+ `,
+ 'index.html': html`
+
+
+
+
+
+
+ `,
+ 'tailwind.config.mjs': js`
+ import myColor from './my-color.mjs'
+ export default {
+ theme: {
+ extend: {
+ colors: {
+ primary: myColor,
+ },
+ },
+ },
+ }
+ `,
+ 'my-color.mjs': js`export default 'blue'`,
+ 'src/index.css': css`
+ @import 'tailwindcss';
+ @config '../tailwind.config.mjs';
+ `,
+ },
+ },
+ async ({ fs, getFreePort, spawn }) => {
+ let port = await getFreePort()
+ await spawn(`pnpm vite dev --port ${port}`)
+
+ await retryAssertion(async () => {
+ let css = await fetchStyles(port, '/index.html')
+ expect(css).toContain(candidate`text-primary`)
+ expect(css).toContain('color: blue')
+ })
+
+ await fs.write('my-color.mjs', js`export default 'red'`)
+ await retryAssertion(async () => {
+ let css = await fetchStyles(port, '/index.html')
+ expect(css).toContain(candidate`text-primary`)
+ expect(css).toContain('color: red')
+ })
+ },
+)
diff --git a/packages/@tailwindcss-cli/package.json b/packages/@tailwindcss-cli/package.json
index 4a5310492b88..ded459fb879c 100644
--- a/packages/@tailwindcss-cli/package.json
+++ b/packages/@tailwindcss-cli/package.json
@@ -30,13 +30,14 @@
},
"dependencies": {
"@parcel/watcher": "^2.4.1",
+ "@tailwindcss/node": "workspace:^",
"@tailwindcss/oxide": "workspace:^",
"enhanced-resolve": "^5.17.1",
"lightningcss": "catalog:",
"mri": "^1.2.0",
"picocolors": "^1.0.1",
- "postcss": "^8.4.41",
"postcss-import": "^16.1.0",
+ "postcss": "^8.4.41",
"tailwindcss": "workspace:^"
},
"devDependencies": {
diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts
index 473d525ce443..afa62ae04714 100644
--- a/packages/@tailwindcss-cli/src/commands/build/index.ts
+++ b/packages/@tailwindcss-cli/src/commands/build/index.ts
@@ -1,14 +1,14 @@
import watcher from '@parcel/watcher'
+import { compile } from '@tailwindcss/node'
+import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner, type ChangedContent } from '@tailwindcss/oxide'
import fixRelativePathsPlugin from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
import { existsSync, readFileSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
-import { pathToFileURL } from 'node:url'
import postcss from 'postcss'
import atImport from 'postcss-import'
-import * as tailwindcss from 'tailwindcss'
import type { Arg, Result } from '../../utils/args'
import { Disposables } from '../../utils/disposables'
import {
@@ -128,23 +128,19 @@ export async function handle(args: Result>) {
let inputFile = args['--input'] && args['--input'] !== '-' ? args['--input'] : process.cwd()
let inputBasePath = path.dirname(path.resolve(inputFile))
+ let fullRebuildPaths: string[] = cssImportPaths.slice()
- function compile(css: string) {
- return tailwindcss.compile(css, {
- loadPlugin: async (pluginPath) => {
- if (pluginPath[0] === '.') {
- return import(pathToFileURL(path.resolve(inputBasePath, pluginPath)).href).then(
- (m) => m.default ?? m,
- )
- }
-
- return import(pluginPath).then((m) => m.default ?? m)
+ function createCompiler(css: string) {
+ return compile(css, {
+ base: inputBasePath,
+ onDependency(path) {
+ fullRebuildPaths.push(path)
},
})
}
// Compile the input
- let compiler = await compile(input)
+ let compiler = await createCompiler(input)
let scanner = new Scanner({
detectSources: { base },
sources: compiler.globs.map((pattern) => ({
@@ -166,10 +162,13 @@ export async function handle(args: Result>) {
let changedFiles: ChangedContent[] = []
let rebuildStrategy: 'incremental' | 'full' = 'incremental'
+ let resolvedFullRebuildPaths = fullRebuildPaths
+
for (let file of files) {
- // If one of the changed files is related to the input CSS files, then
- // we need to do a full rebuild because the theme might have changed.
- if (cssImportPaths.includes(file)) {
+ // If one of the changed files is related to the input CSS or JS
+ // config/plugin files, then we need to do a full rebuild because
+ // the theme might have changed.
+ if (resolvedFullRebuildPaths.includes(file)) {
rebuildStrategy = 'full'
// No need to check the rest of the events, because we already know we
@@ -204,9 +203,11 @@ export async function handle(args: Result>) {
`,
args['--input'] ?? base,
)
+ clearRequireCache(resolvedFullRebuildPaths)
+ fullRebuildPaths = cssImportPaths.slice()
// Create a new compiler, given the new `input`
- compiler = await compile(input)
+ compiler = await createCompiler(input)
// Re-scan the directory to get the new `candidates`
scanner = new Scanner({
diff --git a/packages/@tailwindcss-node/README.md b/packages/@tailwindcss-node/README.md
new file mode 100644
index 000000000000..95ec9d87ddcc
--- /dev/null
+++ b/packages/@tailwindcss-node/README.md
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+ A utility-first CSS framework for rapidly building custom user interfaces.
+
+
+
+
+
+
+
+
+
+---
+
+## Documentation
+
+For full documentation, visit [tailwindcss.com](https://tailwindcss.com).
+
+## Community
+
+For help, discussion about best practices, or any other conversation that would benefit from being searchable:
+
+[Discuss Tailwind CSS on GitHub](https://github.com/tailwindcss/tailwindcss/discussions)
+
+For chatting with others using the framework:
+
+[Join the Tailwind CSS Discord Server](https://discord.gg/7NF8GNe)
+
+## Contributing
+
+If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindcss/tailwindcss/blob/next/.github/CONTRIBUTING.md) **before submitting a pull request**.
diff --git a/packages/@tailwindcss-node/package.json b/packages/@tailwindcss-node/package.json
new file mode 100644
index 000000000000..6df4e663cd44
--- /dev/null
+++ b/packages/@tailwindcss-node/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "@tailwindcss/node",
+ "version": "4.0.0-alpha.20",
+ "description": "A utility-first CSS framework for rapidly building custom user interfaces.",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/tailwindlabs/tailwindcss.git",
+ "directory": "packages/@tailwindcss-node"
+ },
+ "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.mjs",
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.js"
+ },
+ "./require-cache": {
+ "types": "./dist/require-cache.d.ts",
+ "default": "./dist/require-cache.js"
+ },
+ "./esm-cache-loader": {
+ "types": "./dist/esm-cache.loader.d.mts",
+ "default": "./dist/esm-cache.loader.mjs"
+ }
+ },
+ "devDependencies": {
+ "tailwindcss": "workspace:^"
+ }
+}
diff --git a/packages/@tailwindcss-node/src/compile.ts b/packages/@tailwindcss-node/src/compile.ts
new file mode 100644
index 000000000000..a5e4eb0d8d5a
--- /dev/null
+++ b/packages/@tailwindcss-node/src/compile.ts
@@ -0,0 +1,47 @@
+import path from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { compile as _compile } from 'tailwindcss'
+import { getModuleDependencies } from './get-module-dependencies'
+
+export async function compile(
+ css: string,
+ { base, onDependency }: { base: string; onDependency: (path: string) => void },
+) {
+ return await _compile(css, {
+ loadPlugin: async (pluginPath) => {
+ if (pluginPath[0] !== '.') {
+ return import(pluginPath).then((m) => m.default ?? m)
+ }
+
+ let resolvedPath = path.resolve(base, pluginPath)
+ let [module, moduleDependencies] = await Promise.all([
+ import(pathToFileURL(resolvedPath).href + '?id=' + Date.now()),
+ getModuleDependencies(resolvedPath),
+ ])
+
+ onDependency(resolvedPath)
+ for (let file of moduleDependencies) {
+ onDependency(file)
+ }
+ return module.default ?? module
+ },
+
+ loadConfig: async (configPath) => {
+ if (configPath[0] !== '.') {
+ return import(configPath).then((m) => m.default ?? m)
+ }
+
+ let resolvedPath = path.resolve(base, configPath)
+ let [module, moduleDependencies] = await Promise.all([
+ import(pathToFileURL(resolvedPath).href + '?id=' + Date.now()),
+ getModuleDependencies(resolvedPath),
+ ])
+
+ onDependency(resolvedPath)
+ for (let file of moduleDependencies) {
+ onDependency(file)
+ }
+ return module.default ?? module
+ },
+ })
+}
diff --git a/packages/@tailwindcss-node/src/esm-cache.loader.mts b/packages/@tailwindcss-node/src/esm-cache.loader.mts
new file mode 100644
index 000000000000..edcfc8d778ba
--- /dev/null
+++ b/packages/@tailwindcss-node/src/esm-cache.loader.mts
@@ -0,0 +1,22 @@
+import { isBuiltin, type ResolveHook } from 'node:module'
+
+export let resolve: ResolveHook = async (specifier, context, nextResolve) => {
+ let result = await nextResolve(specifier, context)
+
+ if (result.url === import.meta.url) return result
+ if (isBuiltin(result.url)) return result
+ if (!context.parentURL) return result
+
+ let parent = new URL(context.parentURL)
+
+ let id = parent.searchParams.get('id')
+ if (id === null) return result
+
+ let url = new URL(result.url)
+ url.searchParams.set('id', id)
+
+ return {
+ ...result,
+ url: `${url}`,
+ }
+}
diff --git a/packages/@tailwindcss-node/src/get-module-dependencies.ts b/packages/@tailwindcss-node/src/get-module-dependencies.ts
new file mode 100644
index 000000000000..34dedff20c3f
--- /dev/null
+++ b/packages/@tailwindcss-node/src/get-module-dependencies.ts
@@ -0,0 +1,106 @@
+import fs from 'node:fs/promises'
+import path from 'node:path'
+
+// Patterns we use to match dependencies in a file whether in CJS, ESM, or TypeScript
+const DEPENDENCY_PATTERNS = [
+ /import[\s\S]*?['"](.{3,}?)['"]/gi,
+ /import[\s\S]*from[\s\S]*?['"](.{3,}?)['"]/gi,
+ /export[\s\S]*from[\s\S]*?['"](.{3,}?)['"]/gi,
+ /require\(['"`](.+)['"`]\)/gi,
+]
+
+// Given the current file `a.ts`, we want to make sure that when importing `b` that we resolve
+// `b.ts` before `b.js`
+//
+// E.g.:
+//
+// a.ts
+// b // .ts
+// c // .ts
+// a.js
+// b // .js or .ts
+const JS_EXTENSIONS = ['.js', '.cjs', '.mjs']
+const JS_RESOLUTION_ORDER = ['', '.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.jsx', '.tsx']
+const TS_RESOLUTION_ORDER = ['', '.ts', '.cts', '.mts', '.tsx', '.js', '.cjs', '.mjs', '.jsx']
+
+async function resolveWithExtension(file: string, extensions: string[]) {
+ // Try to find `./a.ts`, `./a.cts`, ... from `./a`
+ for (let ext of extensions) {
+ let full = `${file}${ext}`
+
+ let stats = await fs.stat(full).catch(() => null)
+ if (stats?.isFile()) return full
+ }
+
+ // Try to find `./a/index.js` from `./a`
+ for (let ext of extensions) {
+ let full = `${file}/index${ext}`
+
+ let exists = await fs.access(full).then(
+ () => true,
+ () => false,
+ )
+ if (exists) {
+ return full
+ }
+ }
+
+ return null
+}
+
+async function traceDependencies(
+ seen: Set,
+ filename: string,
+ base: string,
+ ext: string,
+): Promise {
+ // Try to find the file
+ let extensions = JS_EXTENSIONS.includes(ext) ? JS_RESOLUTION_ORDER : TS_RESOLUTION_ORDER
+ let absoluteFile = await resolveWithExtension(path.resolve(base, filename), extensions)
+ if (absoluteFile === null) return // File doesn't exist
+
+ // Prevent infinite loops when there are circular dependencies
+ if (seen.has(absoluteFile)) return // Already seen
+
+ // Mark the file as a dependency
+ seen.add(absoluteFile)
+
+ // Resolve new base for new imports/requires
+ base = path.dirname(absoluteFile)
+ ext = path.extname(absoluteFile)
+
+ let contents = await fs.readFile(absoluteFile, 'utf-8')
+
+ // Recursively trace dependencies in parallel
+ let promises = []
+
+ for (let pattern of DEPENDENCY_PATTERNS) {
+ for (let match of contents.matchAll(pattern)) {
+ // Bail out if it's not a relative file
+ if (!match[1].startsWith('.')) continue
+
+ promises.push(traceDependencies(seen, match[1], base, ext))
+ }
+ }
+
+ await Promise.all(promises)
+}
+
+/**
+ * Trace all dependencies of a module recursively
+ *
+ * The result is an unordered set of absolute file paths. Meaning that the order
+ * is not guaranteed to be equal to source order or across runs.
+ **/
+export async function getModuleDependencies(absoluteFilePath: string) {
+ let seen = new Set()
+
+ await traceDependencies(
+ seen,
+ absoluteFilePath,
+ path.dirname(absoluteFilePath),
+ path.extname(absoluteFilePath),
+ )
+
+ return Array.from(seen)
+}
diff --git a/packages/@tailwindcss-node/src/index.cts b/packages/@tailwindcss-node/src/index.cts
new file mode 100644
index 000000000000..bb1fabe852ae
--- /dev/null
+++ b/packages/@tailwindcss-node/src/index.cts
@@ -0,0 +1,9 @@
+import * as Module from 'node:module'
+import { pathToFileURL } from 'node:url'
+export * from './compile'
+
+// In Bun, ESM modules will also populate `require.cache`, so the module hook is
+// not necessary.
+if (!process.versions.bun) {
+ Module.register(pathToFileURL(require.resolve('@tailwindcss/node/esm-cache-loader')))
+}
diff --git a/packages/@tailwindcss-node/src/index.ts b/packages/@tailwindcss-node/src/index.ts
new file mode 100644
index 000000000000..2fcbfa89b4a2
--- /dev/null
+++ b/packages/@tailwindcss-node/src/index.ts
@@ -0,0 +1,10 @@
+import * as Module from 'node:module'
+import { pathToFileURL } from 'node:url'
+export * from './compile'
+
+// In Bun, ESM modules will also populate `require.cache`, so the module hook is
+// not necessary.
+if (!process.versions.bun) {
+ let localRequire = Module.createRequire(import.meta.url)
+ Module.register(pathToFileURL(localRequire.resolve('@tailwindcss/node/esm-cache-loader')))
+}
diff --git a/packages/@tailwindcss-node/src/require-cache.cts b/packages/@tailwindcss-node/src/require-cache.cts
new file mode 100644
index 000000000000..ea0562019468
--- /dev/null
+++ b/packages/@tailwindcss-node/src/require-cache.cts
@@ -0,0 +1,5 @@
+export function clearRequireCache(files: string[]) {
+ for (let key of files) {
+ delete require.cache[key]
+ }
+}
diff --git a/packages/@tailwindcss-node/tsconfig.json b/packages/@tailwindcss-node/tsconfig.json
new file mode 100644
index 000000000000..6ae022f65bf0
--- /dev/null
+++ b/packages/@tailwindcss-node/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../tsconfig.base.json",
+}
diff --git a/packages/@tailwindcss-node/tsup.config.ts b/packages/@tailwindcss-node/tsup.config.ts
new file mode 100644
index 000000000000..0f2ac5855b19
--- /dev/null
+++ b/packages/@tailwindcss-node/tsup.config.ts
@@ -0,0 +1,28 @@
+import { defineConfig } from 'tsup'
+
+export default defineConfig([
+ {
+ format: ['cjs'],
+ minify: true,
+ dts: true,
+ entry: ['src/index.cts'],
+ },
+ {
+ format: ['esm'],
+ minify: true,
+ dts: true,
+ entry: ['src/index.ts'],
+ },
+ {
+ format: ['esm'],
+ minify: true,
+ dts: true,
+ entry: ['src/esm-cache.loader.mts'],
+ },
+ {
+ format: ['cjs'],
+ minify: true,
+ dts: true,
+ entry: ['src/require-cache.cts'],
+ },
+])
diff --git a/packages/@tailwindcss-postcss/package.json b/packages/@tailwindcss-postcss/package.json
index f5799c934bc6..0258f48b658d 100644
--- a/packages/@tailwindcss-postcss/package.json
+++ b/packages/@tailwindcss-postcss/package.json
@@ -30,6 +30,7 @@
}
},
"dependencies": {
+ "@tailwindcss/node": "workspace:^",
"@tailwindcss/oxide": "workspace:^",
"lightningcss": "catalog:",
"postcss-import": "^16.1.0",
diff --git a/packages/@tailwindcss-postcss/src/index.ts b/packages/@tailwindcss-postcss/src/index.ts
index 2e2641adaff6..2660d372365d 100644
--- a/packages/@tailwindcss-postcss/src/index.ts
+++ b/packages/@tailwindcss-postcss/src/index.ts
@@ -1,12 +1,12 @@
+import { compile } from '@tailwindcss/node'
+import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import fs from 'fs'
import fixRelativePathsPlugin from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
-import { pathToFileURL } from 'node:url'
import path from 'path'
import postcss, { AtRule, type AcceptedPlugin, type PluginCreator } from 'postcss'
import postcssImport from 'postcss-import'
-import { compile } from 'tailwindcss'
/**
* A Map that can generate default values for keys that don't exist.
@@ -47,6 +47,7 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
compiler: null as null | Awaited>,
css: '',
optimizedCss: '',
+ fullRebuildPaths: [] as string[],
}
})
@@ -79,16 +80,15 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
let context = cache.get(inputFile)
let inputBasePath = path.dirname(path.resolve(inputFile))
- function createCompiler() {
- return compile(root.toString(), {
- loadPlugin: async (pluginPath) => {
- if (pluginPath[0] === '.') {
- return import(pathToFileURL(path.resolve(inputBasePath, pluginPath)).href).then(
- (m) => m.default ?? m,
- )
- }
+ async function createCompiler() {
+ clearRequireCache(context.fullRebuildPaths)
- return import(pluginPath).then((m) => m.default ?? m)
+ context.fullRebuildPaths = []
+
+ return compile(root.toString(), {
+ base: inputBasePath,
+ onDependency: (path) => {
+ context.fullRebuildPaths.push(path)
},
})
}
@@ -101,11 +101,21 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
// 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) {
diff --git a/packages/@tailwindcss-vite/package.json b/packages/@tailwindcss-vite/package.json
index 806d43590185..cd3aa761e042 100644
--- a/packages/@tailwindcss-vite/package.json
+++ b/packages/@tailwindcss-vite/package.json
@@ -28,6 +28,7 @@
}
},
"dependencies": {
+ "@tailwindcss/node": "workspace:^",
"@tailwindcss/oxide": "workspace:^",
"lightningcss": "catalog:",
"postcss-load-config": "^6.0.1",
diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts
index 25d07dbfd3c3..51dff81c073c 100644
--- a/packages/@tailwindcss-vite/src/index.ts
+++ b/packages/@tailwindcss-vite/src/index.ts
@@ -1,9 +1,11 @@
+import { compile } from '@tailwindcss/node'
+import { clearRequireCache } from '@tailwindcss/node/require-cache'
+
import { Scanner } from '@tailwindcss/oxide'
import fixRelativePathsPlugin, { normalizePath } from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
import path from 'path'
import postcssrc from 'postcss-load-config'
-import { compile } from 'tailwindcss'
import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
export default function tailwindcss(): Plugin[] {
@@ -12,6 +14,7 @@ export default function tailwindcss(): Plugin[] {
let scanner: Scanner | null = null
let changedContent: { content: string; extension: string }[] = []
let candidates = new Set()
+ let fullRebuildPaths: string[] = []
// In serve mode this is treated as a set — the content doesn't matter.
// In build mode, we store file contents to use them in renderChunk.
@@ -79,13 +82,13 @@ export default function tailwindcss(): Plugin[] {
async function generateCss(css: string, inputPath: string, addWatchFile: (file: string) => void) {
let inputBasePath = path.dirname(path.resolve(inputPath))
+ clearRequireCache(fullRebuildPaths)
+ fullRebuildPaths = []
let { build, globs } = await compile(css, {
- loadPlugin: async (pluginPath) => {
- if (pluginPath[0] === '.') {
- return import(path.resolve(inputBasePath, pluginPath)).then((m) => m.default ?? m)
- }
-
- return import(pluginPath).then((m) => m.default ?? m)
+ base: inputBasePath,
+ onDependency(path) {
+ addWatchFile(path)
+ fullRebuildPaths.push(path)
},
})
diff --git a/packages/internal-postcss-fix-relative-paths/src/index.ts b/packages/internal-postcss-fix-relative-paths/src/index.ts
index 3f35f4f9f88d..7a0f92da714a 100644
--- a/packages/internal-postcss-fix-relative-paths/src/index.ts
+++ b/packages/internal-postcss-fix-relative-paths/src/index.ts
@@ -72,6 +72,7 @@ export default function fixRelativePathsPlugin(): Plugin {
AtRule: {
source: fixRelativePath,
plugin: fixRelativePath,
+ config: fixRelativePath,
},
}
}
diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts
new file mode 100644
index 000000000000..6627fd8680aa
--- /dev/null
+++ b/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts
@@ -0,0 +1,43 @@
+import { test } from 'vitest'
+import { buildDesignSystem } from '../design-system'
+import { Theme } from '../theme'
+import { applyConfigToTheme } from './apply-config-to-theme'
+
+test('Config values can be merged into the theme', ({ expect }) => {
+ let theme = new Theme()
+ let design = buildDesignSystem(theme)
+
+ applyConfigToTheme(design, [
+ {
+ config: {
+ theme: {
+ colors: {
+ primary: '#c0ffee',
+ red: {
+ 500: 'red',
+ },
+ },
+
+ fontSize: {
+ sm: '0.875rem',
+ base: [
+ '1rem',
+ {
+ lineHeight: '1.5',
+ },
+ ],
+ },
+ },
+ },
+ },
+ ])
+
+ expect(theme.resolve('primary', ['--color'])).toEqual('#c0ffee')
+ expect(theme.resolve('red-500', ['--color'])).toEqual('red')
+ expect(theme.resolve('sm', ['--font-size'])).toEqual('0.875rem')
+ expect(theme.resolve('base', ['--font-size'])).toEqual('1rem')
+ expect(theme.resolveWith('base', ['--font-size'], ['--line-height'])).toEqual([
+ '1rem',
+ { '--line-height': '1.5' },
+ ])
+})
diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.ts
new file mode 100644
index 000000000000..e62b2b741441
--- /dev/null
+++ b/packages/tailwindcss/src/compat/apply-config-to-theme.ts
@@ -0,0 +1,129 @@
+import type { DesignSystem } from '../design-system'
+import { resolveConfig, type ConfigFile } from './config/resolve-config'
+import type { ResolvedConfig } from './config/types'
+
+export function applyConfigToTheme(designSystem: DesignSystem, configs: ConfigFile[]) {
+ let theme = resolveConfig(designSystem, configs).theme
+
+ for (let [path, value] of themeableValues(theme)) {
+ let name = keyPathToCssProperty(path)
+
+ designSystem.theme.add(`--${name}`, value as any, {
+ isInline: true,
+ isReference: false,
+ })
+ }
+
+ return theme
+}
+
+function themeableValues(config: ResolvedConfig['theme']): [string[], unknown][] {
+ let toAdd: [string[], unknown][] = []
+
+ walk(config as any, [], (value, path) => {
+ if (isValidThemePrimitive(value)) {
+ toAdd.push([path, value])
+
+ return WalkAction.Skip
+ }
+
+ if (isValidThemeTuple(value)) {
+ toAdd.push([path, value[0]])
+
+ for (let key of Reflect.ownKeys(value[1]) as string[]) {
+ toAdd.push([[...path, `-${key}`], value[1][key]])
+ }
+
+ return WalkAction.Skip
+ }
+ })
+
+ return toAdd
+}
+
+function keyPathToCssProperty(path: string[]) {
+ if (path[0] === 'colors') {
+ path[0] = 'color'
+ }
+
+ return (
+ path
+ // [1] should move into the nested object tuple. To create the CSS variable
+ // name for this, we replace it with an empty string that will result in two
+ // subsequent dashes when joined.
+ .map((path) => (path === '1' ? '' : path))
+
+ // Resolve the key path to a CSS variable segment
+ .map((part) =>
+ part
+ .replaceAll('.', '_')
+ .replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`),
+ )
+
+ // Remove the `DEFAULT` key at the end of a path
+ // We're reading from CSS anyway so it'll be a string
+ .filter((part, index) => part !== 'DEFAULT' || index !== path.length - 1)
+ .join('-')
+ )
+}
+
+function isValidThemePrimitive(value: unknown) {
+ return typeof value === 'number' || typeof value === 'string'
+}
+
+function isValidThemeTuple(value: unknown): value is [string, Record] {
+ // Check for tuple values of the form
+ // `[string, Record]`
+ if (!Array.isArray(value)) return false
+ if (value.length !== 2) return false
+
+ // A string or number as the "value"
+ if (typeof value[0] !== 'string' && typeof value[0] !== 'number') return false
+
+ // An object as the nested theme values
+ if (value[1] === undefined || value[1] === null) return false
+ if (typeof value[1] !== 'object') return false
+
+ for (let key of Reflect.ownKeys(value[1])) {
+ if (typeof key !== 'string') return false
+ if (typeof value[1][key] !== 'string' && typeof value[1][key] !== 'number') return false
+ }
+
+ return true
+}
+
+enum WalkAction {
+ /** Continue walking, which is the default */
+ Continue,
+
+ /** Skip visiting the children of this node */
+ Skip,
+
+ /** Stop the walk entirely */
+ Stop,
+}
+
+function walk(
+ obj: Record,
+ path: string[] = [],
+ callback: (value: unknown, path: string[]) => WalkAction | void,
+) {
+ for (let key of Reflect.ownKeys(obj) as string[]) {
+ let value = obj[key]
+
+ if (value === undefined || value === null) {
+ continue
+ }
+
+ let keyPath = [...path, key]
+
+ let result = callback(value, keyPath) ?? WalkAction.Continue
+
+ if (result === WalkAction.Skip) continue
+ if (result === WalkAction.Stop) break
+
+ if (!Array.isArray(value) && typeof value !== 'object') continue
+
+ walk(value as any, keyPath, callback)
+ }
+}
diff --git a/packages/tailwindcss/src/compat/config.test.ts b/packages/tailwindcss/src/compat/config.test.ts
new file mode 100644
index 000000000000..911cadaa1410
--- /dev/null
+++ b/packages/tailwindcss/src/compat/config.test.ts
@@ -0,0 +1,231 @@
+import { test } from 'vitest'
+import { compile } from '..'
+import plugin from '../plugin'
+
+const css = String.raw
+
+test('Config files can add content', async ({ expect }) => {
+ let input = css`
+ @tailwind utilities;
+ @config "./config.js";
+ `
+
+ let compiler = await compile(input, {
+ loadConfig: async () => ({ content: ['./file.txt'] }),
+ })
+
+ expect(compiler.globs).toEqual(['./file.txt'])
+})
+
+test('Config files can change dark mode (media)', async ({ expect }) => {
+ let input = css`
+ @tailwind utilities;
+ @config "./config.js";
+ `
+
+ let compiler = await compile(input, {
+ loadConfig: async () => ({ darkMode: 'media' }),
+ })
+
+ expect(compiler.build(['dark:underline'])).toMatchInlineSnapshot(`
+ ".dark\\:underline {
+ @media (prefers-color-scheme: dark) {
+ text-decoration-line: underline;
+ }
+ }
+ "
+ `)
+})
+
+test('Config files can change dark mode (selector)', async ({ expect }) => {
+ let input = css`
+ @tailwind utilities;
+ @config "./config.js";
+ `
+
+ let compiler = await compile(input, {
+ loadConfig: async () => ({ darkMode: 'selector' }),
+ })
+
+ expect(compiler.build(['dark:underline'])).toMatchInlineSnapshot(`
+ ".dark\\:underline {
+ &:where(.dark, .dark *) {
+ text-decoration-line: underline;
+ }
+ }
+ "
+ `)
+})
+
+test('Config files can change dark mode (variant)', async ({ expect }) => {
+ let input = css`
+ @tailwind utilities;
+ @config "./config.js";
+ `
+
+ let compiler = await compile(input, {
+ loadConfig: async () => ({ darkMode: ['variant', '&:where(:not(.light))'] }),
+ })
+
+ expect(compiler.build(['dark:underline'])).toMatchInlineSnapshot(`
+ ".dark\\:underline {
+ &:where(:not(.light)) {
+ text-decoration-line: underline;
+ }
+ }
+ "
+ `)
+})
+
+test('Config files can add plugins', async ({ expect }) => {
+ let input = css`
+ @tailwind utilities;
+ @config "./config.js";
+ `
+
+ let compiler = await compile(input, {
+ loadConfig: async () => ({
+ plugins: [
+ plugin(function ({ addUtilities }) {
+ addUtilities({
+ '.no-scrollbar': {
+ 'scrollbar-width': 'none',
+ },
+ })
+ }),
+ ],
+ }),
+ })
+
+ expect(compiler.build(['no-scrollbar'])).toMatchInlineSnapshot(`
+ ".no-scrollbar {
+ scrollbar-width: none;
+ }
+ "
+ `)
+})
+
+test('Plugins loaded from config files can contribute to the config', async ({ expect }) => {
+ let input = css`
+ @tailwind utilities;
+ @config "./config.js";
+ `
+
+ let compiler = await compile(input, {
+ loadConfig: async () => ({
+ plugins: [
+ plugin(() => {}, {
+ darkMode: ['variant', '&:where(:not(.light))'],
+ }),
+ ],
+ }),
+ })
+
+ expect(compiler.build(['dark:underline'])).toMatchInlineSnapshot(`
+ ".dark\\:underline {
+ &:where(:not(.light)) {
+ text-decoration-line: underline;
+ }
+ }
+ "
+ `)
+})
+
+test('Config file presets can contribute to the config', async ({ expect }) => {
+ let input = css`
+ @tailwind utilities;
+ @config "./config.js";
+ `
+
+ let compiler = await compile(input, {
+ loadConfig: async () => ({
+ presets: [
+ {
+ darkMode: ['variant', '&:where(:not(.light))'],
+ },
+ ],
+ }),
+ })
+
+ expect(compiler.build(['dark:underline'])).toMatchInlineSnapshot(`
+ ".dark\\:underline {
+ &:where(:not(.light)) {
+ text-decoration-line: underline;
+ }
+ }
+ "
+ `)
+})
+
+test('Config files can affect the theme', async ({ expect }) => {
+ let input = css`
+ @tailwind utilities;
+ @config "./config.js";
+ `
+
+ let compiler = await compile(input, {
+ loadConfig: async () => ({
+ theme: {
+ extend: {
+ colors: {
+ primary: '#c0ffee',
+ },
+ },
+ },
+
+ plugins: [
+ plugin(function ({ addUtilities, theme }) {
+ addUtilities({
+ '.scrollbar-primary': {
+ scrollbarColor: theme('colors.primary'),
+ },
+ })
+ }),
+ ],
+ }),
+ })
+
+ expect(compiler.build(['bg-primary', 'scrollbar-primary'])).toMatchInlineSnapshot(`
+ ".bg-primary {
+ background-color: #c0ffee;
+ }
+ .scrollbar-primary {
+ scrollbar-color: #c0ffee;
+ }
+ "
+ `)
+})
+
+test('Variants in CSS overwrite variants from plugins', async ({ expect }) => {
+ let input = css`
+ @tailwind utilities;
+ @config "./config.js";
+ @variant dark (&:is(.my-dark));
+ @variant light (&:is(.my-light));
+ `
+
+ let compiler = await compile(input, {
+ loadConfig: async () => ({
+ darkMode: ['variant', '&:is(.dark)'],
+ plugins: [
+ plugin(function ({ addVariant }) {
+ addVariant('light', '&:is(.light)')
+ }),
+ ],
+ }),
+ })
+
+ expect(compiler.build(['dark:underline', 'light:underline'])).toMatchInlineSnapshot(`
+ ".dark\\:underline {
+ &:is(.my-dark) {
+ text-decoration-line: underline;
+ }
+ }
+ .light\\:underline {
+ &:is(.my-light) {
+ text-decoration-line: underline;
+ }
+ }
+ "
+ `)
+})
diff --git a/packages/tailwindcss/src/compat/config/resolve-config.test.ts b/packages/tailwindcss/src/compat/config/resolve-config.test.ts
index 4669107bb5ad..2ce588f4dd1d 100644
--- a/packages/tailwindcss/src/compat/config/resolve-config.test.ts
+++ b/packages/tailwindcss/src/compat/config/resolve-config.test.ts
@@ -8,27 +8,33 @@ test('top level theme keys are replaced', ({ expect }) => {
let config = resolveConfig(design, [
{
- theme: {
- colors: {
- red: 'red',
- },
+ config: {
+ theme: {
+ colors: {
+ red: 'red',
+ },
- fontFamily: {
- sans: 'SF Pro Display',
+ fontFamily: {
+ sans: 'SF Pro Display',
+ },
},
},
},
{
- theme: {
- colors: {
- green: 'green',
+ config: {
+ theme: {
+ colors: {
+ green: 'green',
+ },
},
},
},
{
- theme: {
- colors: {
- blue: 'blue',
+ config: {
+ theme: {
+ colors: {
+ blue: 'blue',
+ },
},
},
},
@@ -51,21 +57,25 @@ test('theme can be extended', ({ expect }) => {
let config = resolveConfig(design, [
{
- theme: {
- colors: {
- red: 'red',
- },
+ config: {
+ theme: {
+ colors: {
+ red: 'red',
+ },
- fontFamily: {
- sans: 'SF Pro Display',
+ fontFamily: {
+ sans: 'SF Pro Display',
+ },
},
},
},
{
- theme: {
- extend: {
- colors: {
- blue: 'blue',
+ config: {
+ theme: {
+ extend: {
+ colors: {
+ blue: 'blue',
+ },
},
},
},
@@ -92,31 +102,37 @@ test('theme keys can reference other theme keys using the theme function regardl
let config = resolveConfig(design, [
{
- theme: {
- colors: {
- red: 'red',
- },
- placeholderColor: {
- green: 'green',
+ config: {
+ theme: {
+ colors: {
+ red: 'red',
+ },
+ placeholderColor: {
+ green: 'green',
+ },
},
},
},
{
- theme: {
- extend: {
- colors: ({ theme }) => ({
- ...theme('placeholderColor'),
- blue: 'blue',
- }),
+ config: {
+ theme: {
+ extend: {
+ colors: ({ theme }) => ({
+ ...theme('placeholderColor'),
+ blue: 'blue',
+ }),
+ },
},
},
},
{
- theme: {
- extend: {
- caretColor: ({ theme }) => theme('accentColor'),
- accentColor: ({ theme }) => theme('backgroundColor'),
- backgroundColor: ({ theme }) => theme('colors'),
+ config: {
+ theme: {
+ extend: {
+ caretColor: ({ theme }) => theme('accentColor'),
+ accentColor: ({ theme }) => theme('backgroundColor'),
+ backgroundColor: ({ theme }) => theme('colors'),
+ },
},
},
},
@@ -156,23 +172,25 @@ test('theme keys can read from the CSS theme', ({ expect }) => {
let config = resolveConfig(design, [
{
- theme: {
- colors: ({ theme }) => ({
- // Reads from the --color-* namespace
- ...theme('color'),
- red: 'red',
- }),
- accentColor: ({ theme }) => ({
- // Reads from the --color-* namespace through `colors`
- ...theme('colors'),
- }),
- placeholderColor: ({ theme }) => ({
- // Reads from the --color-* namespace through `colors`
- primary: theme('colors.green'),
-
- // Reads from the --color-* namespace directly
- secondary: theme('color.green'),
- }),
+ config: {
+ theme: {
+ colors: ({ theme }) => ({
+ // Reads from the --color-* namespace
+ ...theme('color'),
+ red: 'red',
+ }),
+ accentColor: ({ theme }) => ({
+ // Reads from the --color-* namespace through `colors`
+ ...theme('colors'),
+ }),
+ placeholderColor: ({ theme }) => ({
+ // Reads from the --color-* namespace through `colors`
+ primary: theme('colors.green'),
+
+ // Reads from the --color-* namespace directly
+ secondary: theme('color.green'),
+ }),
+ },
},
},
])
diff --git a/packages/tailwindcss/src/compat/config/resolve-config.ts b/packages/tailwindcss/src/compat/config/resolve-config.ts
index c119b29e4317..85dcb03762b7 100644
--- a/packages/tailwindcss/src/compat/config/resolve-config.ts
+++ b/packages/tailwindcss/src/compat/config/resolve-config.ts
@@ -1,29 +1,47 @@
import type { DesignSystem } from '../../design-system'
+import type { PluginWithConfig } from '../../plugin-api'
import { createThemeFn } from '../../theme-fn'
import { deepMerge, isPlainObject } from './deep-merge'
import {
type ResolvedConfig,
+ type ResolvedContentConfig,
type ResolvedThemeValue,
type ThemeValue,
type UserConfig,
} from './types'
+export interface ConfigFile {
+ path?: string
+ config: UserConfig
+}
+
interface ResolutionContext {
design: DesignSystem
configs: UserConfig[]
+ plugins: PluginWithConfig[]
+ content: ResolvedContentConfig
theme: Record
extend: Record
result: ResolvedConfig
}
let minimal: ResolvedConfig = {
+ darkMode: null,
theme: {},
+ plugins: [],
+ content: {
+ files: [],
+ },
}
-export function resolveConfig(design: DesignSystem, configs: UserConfig[]): ResolvedConfig {
+export function resolveConfig(design: DesignSystem, files: ConfigFile[]): ResolvedConfig {
let ctx: ResolutionContext = {
design,
- configs,
+ configs: [],
+ plugins: [],
+ content: {
+ files: [],
+ },
theme: {},
extend: {},
@@ -31,11 +49,25 @@ export function resolveConfig(design: DesignSystem, configs: UserConfig[]): Reso
result: structuredClone(minimal),
}
+ for (let file of files) {
+ extractConfigs(ctx, file)
+ }
+
+ // Merge dark mode
+ for (let config of ctx.configs) {
+ if ('darkMode' in config && config.darkMode !== undefined) {
+ ctx.result.darkMode = config.darkMode ?? null
+ }
+ }
+
// Merge themes
mergeTheme(ctx)
return {
+ ...ctx.result,
+ content: ctx.content,
theme: ctx.theme as ResolvedConfig['theme'],
+ plugins: ctx.plugins,
}
}
@@ -63,7 +95,7 @@ function mergeThemeExtension(
return extensionValue
}
- // Execute default behaviour
+ // Execute default behavior
return undefined
}
@@ -71,6 +103,60 @@ export interface PluginUtils {
theme(keypath: string, defaultValue?: any): any
}
+function extractConfigs(ctx: ResolutionContext, { config, path }: ConfigFile): void {
+ let plugins: PluginWithConfig[] = []
+
+ // Normalize plugins so they share the same shape
+ for (let plugin of config.plugins ?? []) {
+ if ('__isOptionsFunction' in plugin) {
+ // Happens with `plugin.withOptions()` when no options were passed:
+ // e.g. `require("my-plugin")` instead of `require("my-plugin")(options)`
+ plugins.push(plugin())
+ } else if ('handler' in plugin) {
+ // Happens with `plugin(…)`:
+ // e.g. `require("my-plugin")`
+ //
+ // or with `plugin.withOptions()` when the user passed options:
+ // e.g. `require("my-plugin")(options)`
+ plugins.push(plugin)
+ } else {
+ // Just a plain function without using the plugin(…) API
+ plugins.push({ handler: plugin })
+ }
+ }
+
+ // Apply configs from presets
+ if (Array.isArray(config.presets) && config.presets.length === 0) {
+ throw new Error(
+ 'Error in the config file/plugin/preset. An empty preset (`preset: []`) is not currently supported.',
+ )
+ }
+
+ for (let preset of config.presets ?? []) {
+ extractConfigs(ctx, { path, config: preset })
+ }
+
+ // Apply configs from plugins
+ for (let plugin of plugins) {
+ ctx.plugins.push(plugin)
+
+ if (plugin.config) {
+ extractConfigs(ctx, { path, config: plugin.config })
+ }
+ }
+
+ // Merge in content paths from multiple configs
+ let content = config.content ?? []
+ let files = Array.isArray(content) ? content : content.files
+
+ for (let file of files) {
+ ctx.content.files.push(typeof file === 'object' ? file : { base: path!, pattern: file })
+ }
+
+ // Then apply the "user" config
+ ctx.configs.push(config)
+}
+
function mergeTheme(ctx: ResolutionContext) {
let api: PluginUtils = {
theme: createThemeFn(ctx.design, () => ctx.theme, resolveValue),
@@ -91,8 +177,7 @@ function mergeTheme(ctx: ResolutionContext) {
// Shallow merge themes so latest "group" wins
Object.assign(ctx.theme, theme)
- // Collect extensions by key so each
- // group can be lazily deep merged
+ // Collect extensions by key so each group can be lazily deep merged
for (let key in extend) {
ctx.extend[key] ??= []
ctx.extend[key].push(extend[key])
diff --git a/packages/tailwindcss/src/compat/config/types.ts b/packages/tailwindcss/src/compat/config/types.ts
index db28725d7956..593930240659 100644
--- a/packages/tailwindcss/src/compat/config/types.ts
+++ b/packages/tailwindcss/src/compat/config/types.ts
@@ -1,9 +1,12 @@
+import type { Plugin, PluginWithConfig } from '../../plugin-api'
import type { PluginUtils } from './resolve-config'
export type ResolvableTo = T | ((utils: PluginUtils) => T)
export interface UserConfig {
+ presets?: UserConfig[]
theme?: ThemeConfig
+ plugins?: Plugin[]
}
export type ThemeValue = ResolvableTo> | null | undefined
@@ -15,4 +18,54 @@ export type ThemeConfig = Record & {
export interface ResolvedConfig {
theme: Record>
+ plugins: PluginWithConfig[]
+}
+
+// Content support
+type ContentFile = string | { raw: string; extension?: string }
+
+export interface UserConfig {
+ content?: ContentFile[] | { files: ContentFile[] }
+}
+
+type ResolvedContent = { base: string; pattern: string } | { raw: string; extension?: string }
+
+export interface ResolvedContentConfig {
+ files: ResolvedContent[]
+}
+
+export interface ResolvedConfig {
+ content: ResolvedContentConfig
+}
+
+// Dark Mode support
+type DarkModeStrategy =
+ // No dark mode support
+ | false
+
+ // Use the `media` query strategy.
+ | 'media'
+
+ // Use the `class` strategy, which requires a `.dark` class on the `html`.
+ | 'class'
+
+ // Use the `class` strategy with a custom class instead of `.dark`.
+ | ['class', string]
+
+ // Use the `selector` strategy — same as `class` but uses `:where()` for more predicable behavior
+ | 'selector'
+
+ // Use the `selector` strategy with a custom selector instead of `.dark`.
+ | ['selector', string]
+
+ // Use the `variant` strategy, which allows you to completely customize the selector
+ // It takes a string or an array of strings, which are passed directly to `addVariant()`
+ | ['variant', string | string[]]
+
+export interface UserConfig {
+ darkMode?: DarkModeStrategy
+}
+
+export interface ResolvedConfig {
+ darkMode: DarkModeStrategy | null
}
diff --git a/packages/tailwindcss/src/compat/dark-mode.ts b/packages/tailwindcss/src/compat/dark-mode.ts
new file mode 100644
index 000000000000..42061a0d674e
--- /dev/null
+++ b/packages/tailwindcss/src/compat/dark-mode.ts
@@ -0,0 +1,51 @@
+import type { PluginAPI } from '../plugin-api'
+import type { ResolvedConfig } from './config/types'
+
+export function darkModePlugin({ addVariant, config }: PluginAPI) {
+ let darkMode = config('darkMode', null) as ResolvedConfig['darkMode']
+ let [mode, selector = '.dark'] = Array.isArray(darkMode) ? darkMode : [darkMode]
+
+ if (mode === 'variant') {
+ let formats
+
+ if (Array.isArray(selector)) {
+ formats = selector
+ } else if (typeof selector === 'function') {
+ formats = selector
+ } else if (typeof selector === 'string') {
+ formats = [selector]
+ }
+
+ if (Array.isArray(formats)) {
+ for (let format of formats) {
+ if (format === '.dark') {
+ mode = false
+ console.warn(
+ 'When using `variant` for `darkMode`, you must provide a selector.\nExample: `darkMode: ["variant", ".your-selector &"]`',
+ )
+ } else if (!format.includes('&')) {
+ mode = false
+ console.warn(
+ 'When using `variant` for `darkMode`, your selector must contain `&`.\nExample `darkMode: ["variant", ".your-selector &"]`',
+ )
+ }
+ }
+ }
+
+ selector = formats as any
+ }
+
+ if (mode === null) {
+ // Do nothing
+ } else if (mode === 'selector') {
+ // New preferred behavior
+ addVariant('dark', `&:where(${selector}, ${selector} *)`)
+ } else if (mode === 'media') {
+ addVariant('dark', '@media (prefers-color-scheme: dark)')
+ } else if (mode === 'variant') {
+ addVariant('dark', selector)
+ } else if (mode === 'class') {
+ // Old behavior
+ addVariant('dark', `&:is(${selector} *)`)
+ }
+}
diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts
index 7fcf97a07e78..c7f3916c8ad3 100644
--- a/packages/tailwindcss/src/index.ts
+++ b/packages/tailwindcss/src/index.ts
@@ -1,6 +1,7 @@
import { version } from '../package.json'
import { substituteAtApply } from './apply'
import { comment, decl, rule, toCss, walk, WalkAction, type Rule } from './ast'
+import type { UserConfig } from './compat/config/types'
import { compileCandidates } from './compile'
import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
@@ -13,12 +14,17 @@ const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
type CompileOptions = {
loadPlugin?: (path: string) => Promise
+ loadConfig?: (path: string) => Promise
}
function throwOnPlugin(): never {
throw new Error('No `loadPlugin` function provided to `compile`')
}
+function throwOnConfig(): never {
+ throw new Error('No `loadConfig` function provided to `compile`')
+}
+
function parseThemeOptions(selector: string) {
let isReference = false
let isInline = false
@@ -34,12 +40,16 @@ function parseThemeOptions(selector: string) {
return { isReference, isInline }
}
-async function parseCss(css: string, { loadPlugin = throwOnPlugin }: CompileOptions = {}) {
+async function parseCss(
+ css: string,
+ { loadPlugin = throwOnPlugin, loadConfig = throwOnConfig }: CompileOptions = {},
+) {
let ast = CSS.parse(css)
// Find all `@theme` declarations
let theme = new Theme()
let pluginPaths: string[] = []
+ let configPaths: string[] = []
let customVariants: ((designSystem: DesignSystem) => void)[] = []
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
let firstThemeRule: Rule | null = null
@@ -64,6 +74,21 @@ async function parseCss(css: string, { loadPlugin = throwOnPlugin }: CompileOpti
return
}
+ // Collect paths from `@config` at-rules
+ if (node.selector === '@config' || node.selector.startsWith('@config ')) {
+ if (node.nodes.length > 0) {
+ throw new Error('`@config` cannot have a body.')
+ }
+
+ if (parent !== null) {
+ throw new Error('`@config` cannot be nested.')
+ }
+
+ configPaths.push(node.selector.slice(9, -1))
+ replaceWith([])
+ return
+ }
+
// Collect custom `@utility` at-rules
if (node.selector.startsWith('@utility ')) {
let name = node.selector.slice(9).trim()
@@ -272,6 +297,17 @@ async function parseCss(css: string, { loadPlugin = throwOnPlugin }: CompileOpti
let designSystem = buildDesignSystem(theme)
+ let configs = await Promise.all(
+ configPaths.map(async (configPath) => ({
+ path: configPath,
+ config: await loadConfig(configPath),
+ })),
+ )
+
+ let plugins = await Promise.all(pluginPaths.map(loadPlugin))
+
+ let { pluginApi, resolvedConfig } = registerPlugins(plugins, designSystem, ast, configs)
+
for (let customVariant of customVariants) {
customVariant(designSystem)
}
@@ -280,10 +316,6 @@ async function parseCss(css: string, { loadPlugin = throwOnPlugin }: CompileOpti
customUtility(designSystem)
}
- let plugins = await Promise.all(pluginPaths.map(loadPlugin))
-
- let pluginApi = registerPlugins(plugins, designSystem, ast)
-
// Replace `@apply` rules with the actual utility classes.
if (css.includes('@apply')) {
substituteAtApply(ast, designSystem)
@@ -308,6 +340,16 @@ async function parseCss(css: string, { loadPlugin = throwOnPlugin }: CompileOpti
return WalkAction.Skip
})
+ for (let file of resolvedConfig.content.files) {
+ if ('raw' in file) {
+ throw new Error(
+ `Error in the config file/plugin/preset. The \`content\` key contains a \`raw\` entry:\n\n${JSON.stringify(file, null, 2)}\n\nThis feature is not currently supported.`,
+ )
+ }
+
+ globs.push(file.pattern)
+ }
+
return {
designSystem,
pluginApi,
diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts
index 9cb2aece334d..6f86f30b00fe 100644
--- a/packages/tailwindcss/src/plugin-api.ts
+++ b/packages/tailwindcss/src/plugin-api.ts
@@ -1,14 +1,17 @@
import { substituteAtApply } from './apply'
import { decl, rule, type AstNode } from './ast'
import type { Candidate, NamedUtilityValue } from './candidate'
+import { applyConfigToTheme } from './compat/apply-config-to-theme'
import { createCompatConfig } from './compat/config/create-compat-config'
-import { resolveConfig } from './compat/config/resolve-config'
-import type { UserConfig } from './compat/config/types'
+import { resolveConfig, type ConfigFile } from './compat/config/resolve-config'
+import type { ResolvedConfig, UserConfig } from './compat/config/types'
+import { darkModePlugin } from './compat/dark-mode'
import type { DesignSystem } from './design-system'
import { createThemeFn } from './theme-fn'
import { withAlpha, withNegative } from './utilities'
import { inferDataType } from './utils/infer-data-type'
import { segment } from './utils/segment'
+import { toKeyPath } from './utils/to-key-path'
export type Config = UserConfig
export type PluginFn = (api: PluginAPI) => void
@@ -57,6 +60,7 @@ export type PluginAPI = {
): void
theme(path: string, defaultValue?: any): any
+ config(path: string, defaultValue?: any): any
prefix(className: string): string
}
@@ -65,7 +69,7 @@ const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
function buildPluginApi(
designSystem: DesignSystem,
ast: AstNode[],
- resolvedConfig: { theme?: Record },
+ resolvedConfig: ResolvedConfig,
): PluginAPI {
let api: PluginAPI = {
addBase(css) {
@@ -281,6 +285,22 @@ function buildPluginApi(
prefix(className) {
return className
},
+
+ config(path, defaultValue) {
+ let obj: Record = resolvedConfig
+
+ let keypath = toKeyPath(path)
+
+ for (let i = 0; i < keypath.length; ++i) {
+ let key = keypath[i]
+
+ if (obj[key] === undefined) return defaultValue
+
+ obj = obj[key]
+ }
+
+ return obj ?? defaultValue
+ },
}
// Bind these functions so they can use `this`
@@ -326,39 +346,33 @@ function objectToAst(rules: CssInJs | CssInJs[]): AstNode[] {
return ast
}
-export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, ast: AstNode[]) {
- let pluginObjects = []
-
- for (let plugin of plugins) {
- if ('__isOptionsFunction' in plugin) {
- // Happens with `plugin.withOptions()` when no options were passed:
- // e.g. `require("my-plugin")` instead of `require("my-plugin")(options)`
- pluginObjects.push(plugin())
- } else if ('handler' in plugin) {
- // Happens with `plugin(…)`:
- // e.g. `require("my-plugin")`
- //
- // or with `plugin.withOptions()` when the user passed options:
- // e.g. `require("my-plugin")(options)`
- pluginObjects.push(plugin)
- } else {
- // Just a plain function without using the plugin(…) API
- pluginObjects.push({ handler: plugin, config: {} as UserConfig })
- }
- }
+export function registerPlugins(
+ plugins: Plugin[],
+ designSystem: DesignSystem,
+ ast: AstNode[],
+ configs: ConfigFile[],
+) {
+ let userConfig = [{ config: { plugins } }, ...configs]
- // Now merge all the configs and make all that crap work
let resolvedConfig = resolveConfig(designSystem, [
- createCompatConfig(designSystem.theme),
- ...pluginObjects.map(({ config }) => config ?? {}),
+ { config: createCompatConfig(designSystem.theme) },
+ ...userConfig,
+ { config: { plugins: [darkModePlugin] } },
])
let pluginApi = buildPluginApi(designSystem, ast, resolvedConfig)
- // Loop over the handlers and run them all with the resolved config + CSS theme probably somehow
- for (let { handler } of pluginObjects) {
+ for (let { handler } of resolvedConfig.plugins) {
handler(pluginApi)
}
- return pluginApi
+ // Merge the user-configured theme keys into the design system. The compat
+ // config would otherwise expand into namespaces like `background-color` which
+ // core utilities already read from.
+ applyConfigToTheme(designSystem, userConfig)
+
+ return {
+ pluginApi,
+ resolvedConfig,
+ }
}
diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts
index da90aff4b468..178472b96975 100644
--- a/packages/tailwindcss/src/theme.ts
+++ b/packages/tailwindcss/src/theme.ts
@@ -121,12 +121,23 @@ export class Theme {
let extra = {} as Record
for (let name of nestedKeys) {
- let nestedValue = this.#var(`${themeKey}${name}`)
- if (nestedValue) {
- extra[name] = nestedValue
+ let nestedKey = `${themeKey}${name}`
+ let nestedValue = this.values.get(nestedKey)!
+ if (!nestedValue) continue
+
+ if (nestedValue.isInline) {
+ extra[name] = nestedValue.value
+ } else {
+ extra[name] = this.#var(nestedKey)!
}
}
+ let value = this.values.get(themeKey)!
+
+ if (value.isInline) {
+ return [value.value, extra]
+ }
+
return [this.#var(themeKey)!, extra]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f65ad6b58498..b55fb9616b11 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -51,7 +51,7 @@ importers:
version: 4.0.0(prettier@3.3.3)(typescript@5.5.4)
tsup:
specifier: ^8.2.4
- version: 8.2.4(postcss@8.4.41)(typescript@5.5.4)
+ version: 8.2.4(jiti@1.21.6)(postcss@8.4.41)(typescript@5.5.4)(yaml@2.5.0)
turbo:
specifier: ^2.0.12
version: 2.0.12
@@ -138,6 +138,9 @@ importers:
'@parcel/watcher':
specifier: ^2.4.1
version: 2.4.1(patch_hash=pnjyuz76kbyy7yxsvyvmenfmha)
+ '@tailwindcss/node':
+ specifier: workspace:^
+ version: link:../@tailwindcss-node
'@tailwindcss/oxide':
specifier: workspace:^
version: link:../../crates/node
@@ -170,8 +173,17 @@ importers:
specifier: workspace:^
version: link:../internal-postcss-fix-relative-paths
+ packages/@tailwindcss-node:
+ devDependencies:
+ tailwindcss:
+ specifier: workspace:^
+ version: link:../tailwindcss
+
packages/@tailwindcss-postcss:
dependencies:
+ '@tailwindcss/node':
+ specifier: workspace:^
+ version: link:../@tailwindcss-node
'@tailwindcss/oxide':
specifier: workspace:^
version: link:../../crates/node
@@ -267,6 +279,9 @@ importers:
packages/@tailwindcss-vite:
dependencies:
+ '@tailwindcss/node':
+ specifier: workspace:^
+ version: link:../@tailwindcss-node
'@tailwindcss/oxide':
specifier: workspace:^
version: link:../../crates/node
@@ -275,7 +290,7 @@ importers:
version: 1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4)
postcss-load-config:
specifier: ^6.0.1
- version: 6.0.1(postcss@8.4.41)
+ version: 6.0.1(jiti@1.21.6)(postcss@8.4.41)(yaml@2.5.0)
tailwindcss:
specifier: workspace:^
version: link:../tailwindcss
@@ -2108,6 +2123,10 @@ packages:
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
+ jiti@1.21.6:
+ resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==}
+ hasBin: true
+
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
@@ -3075,6 +3094,11 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+ yaml@2.5.0:
+ resolution: {integrity: sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==}
+ engines: {node: '>= 14'}
+ hasBin: true
+
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -4865,6 +4889,9 @@ snapshots:
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
+ jiti@1.21.6:
+ optional: true
+
joycon@3.1.1: {}
js-tokens@4.0.0: {}
@@ -5200,11 +5227,13 @@ snapshots:
read-cache: 1.0.0
resolve: 1.22.8
- postcss-load-config@6.0.1(postcss@8.4.41):
+ postcss-load-config@6.0.1(jiti@1.21.6)(postcss@8.4.41)(yaml@2.5.0):
dependencies:
lilconfig: 3.1.2
optionalDependencies:
+ jiti: 1.21.6
postcss: 8.4.41
+ yaml: 2.5.0
postcss-value-parser@4.2.0: {}
@@ -5563,7 +5592,7 @@ snapshots:
tslib@2.6.3: {}
- tsup@8.2.4(postcss@8.4.41)(typescript@5.5.4):
+ tsup@8.2.4(jiti@1.21.6)(postcss@8.4.41)(typescript@5.5.4)(yaml@2.5.0):
dependencies:
bundle-require: 5.0.0(esbuild@0.23.0)
cac: 6.7.14
@@ -5575,7 +5604,7 @@ snapshots:
globby: 11.1.0
joycon: 3.1.1
picocolors: 1.0.1
- postcss-load-config: 6.0.1(postcss@8.4.41)
+ postcss-load-config: 6.0.1(jiti@1.21.6)(postcss@8.4.41)(yaml@2.5.0)
resolve-from: 5.0.0
rollup: 4.20.0
source-map: 0.8.0-beta.0
@@ -5831,4 +5860,7 @@ snapshots:
yallist@3.1.1: {}
+ yaml@2.5.0:
+ optional: true
+
yocto-queue@0.1.0: {}