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 @@ +

+ + + + + Tailwind CSS + + +

+ +

+ A utility-first CSS framework for rapidly building custom user interfaces. +

+ +

+ Build Status + Total Downloads + Latest Release + License +

+ +--- + +## 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: {}