diff --git a/integrations/cli/config.test.ts b/integrations/cli/config.test.ts index 2780e9aca..54ca8c0ca 100644 --- a/integrations/cli/config.test.ts +++ b/integrations/cli/config.test.ts @@ -83,3 +83,109 @@ test( ]) }, ) + +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/packages/@tailwindcss-cli/hook/esm.hook.mjs b/packages/@tailwindcss-cli/hook/esm.hook.mjs index 6f693b817..bde8538b0 100644 --- a/packages/@tailwindcss-cli/hook/esm.hook.mjs +++ b/packages/@tailwindcss-cli/hook/esm.hook.mjs @@ -1,3 +1,3 @@ import { register } from 'node:module' -register(import.meta.resolve('./esm.loader.mjs')) +register(import.meta.resolve('@tailwindcss/cli/esm-loader')) diff --git a/packages/@tailwindcss-cli/hook/esm.loader.mjs b/packages/@tailwindcss-cli/hook/esm.loader.mjs index b21920a52..b7508c778 100644 --- a/packages/@tailwindcss-cli/hook/esm.loader.mjs +++ b/packages/@tailwindcss-cli/hook/esm.loader.mjs @@ -13,9 +13,10 @@ export async function resolve(specifier, context, nextResolve) { if (id === null) return result let url = new URL(result.url) - url.searchParams.set('id', id) + console.log({ url }) + return { ...result, url: `${url}`, diff --git a/packages/@tailwindcss-cli/hook/require-cache.cjs b/packages/@tailwindcss-cli/hook/require-cache.cjs new file mode 100644 index 000000000..d073d8701 --- /dev/null +++ b/packages/@tailwindcss-cli/hook/require-cache.cjs @@ -0,0 +1,5 @@ +module.exports = function clearRequireCache() { + for (const key in require.cache) { + delete require.cache[key] + } +} diff --git a/packages/@tailwindcss-cli/package.json b/packages/@tailwindcss-cli/package.json index 4a5310492..944fdb2ca 100644 --- a/packages/@tailwindcss-cli/package.json +++ b/packages/@tailwindcss-cli/package.json @@ -19,10 +19,14 @@ "tailwindcss": "./dist/index.mjs" }, "exports": { - "./package.json": "./package.json" + "./package.json": "./package.json", + "./esm-hook": "./hook/esm.hook.mjs", + "./esm-loader": "./hook/esm.loader.mjs", + "./require-cache": "./hook/require-cache.cjs" }, "files": [ - "dist" + "dist", + "hook" ], "publishConfig": { "provenance": true, diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index 84e17ee71..c5db54c46 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -11,6 +11,7 @@ import atImport from 'postcss-import' import * as tailwindcss from 'tailwindcss' import type { Arg, Result } from '../../utils/args' import { Disposables } from '../../utils/disposables' +import { getModuleDependencies } from '../../utils/get-module-dependencies' import { eprintln, formatDuration, @@ -22,6 +23,10 @@ import { import { resolveCssId } from '../../utils/resolve' import { drainStdin, outputFile } from './utils' +import '@tailwindcss/cli/esm-hook' +// @ts-ignore +import clearRequireCache from '@tailwindcss/cli/require-cache' + const css = String.raw export function options() { @@ -128,27 +133,35 @@ 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 = [...cssImportPaths] 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, - ) + if (pluginPath[0] !== '.') { + return import(pluginPath).then((m) => m.default ?? m) } - return import(pluginPath).then((m) => m.default ?? m) + let resolvedPath = path.resolve(inputBasePath, pluginPath) + fullRebuildPaths.push(resolvedPath) + fullRebuildPaths.push(...getModuleDependencies(resolvedPath)) + return import(pathToFileURL(resolvedPath).href + '?id=' + Date.now()).then( + (m) => m.default ?? m, + ) }, loadConfig: async (configPath) => { - if (configPath[0] === '.') { - return import(pathToFileURL(path.resolve(inputBasePath, configPath)).href).then( - (m) => m.default ?? m, - ) + if (configPath[0] !== '.') { + return import(configPath).then((m) => m.default ?? m) } - return import(configPath).then((m) => m.default ?? m) + console.log('RELOAD') + let resolvedPath = path.resolve(inputBasePath, configPath) + fullRebuildPaths.push(resolvedPath) + fullRebuildPaths.push(...getModuleDependencies(resolvedPath)) + return import(pathToFileURL(resolvedPath).href + '?id=' + Date.now()).then( + (m) => m.default ?? m, + ) }, }) } @@ -177,9 +190,11 @@ export async function handle(args: Result>) { let rebuildStrategy: 'incremental' | 'full' = 'incremental' 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 (fullRebuildPaths.includes(file)) { + console.log('#############') rebuildStrategy = 'full' // No need to check the rest of the events, because we already know we @@ -214,6 +229,8 @@ export async function handle(args: Result>) { `, args['--input'] ?? base, ) + fullRebuildPaths = cssImportPaths + clearRequireCache() // Create a new compiler, given the new `input` compiler = await compile(input) diff --git a/packages/@tailwindcss-cli/src/utils/get-module-dependencies.ts b/packages/@tailwindcss-cli/src/utils/get-module-dependencies.ts new file mode 100644 index 000000000..592306641 --- /dev/null +++ b/packages/@tailwindcss-cli/src/utils/get-module-dependencies.ts @@ -0,0 +1,85 @@ +import fs from 'fs' +import path from 'path' + +let jsExtensions = ['.js', '.cjs', '.mjs'] + +// 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 + +let jsResolutionOrder = ['', '.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.jsx', '.tsx'] +let tsResolutionOrder = ['', '.ts', '.cts', '.mts', '.tsx', '.js', '.cjs', '.mjs', '.jsx'] + +function resolveWithExtension(file: string, extensions: string[]) { + // Try to find `./a.ts`, `./a.cts`, ... from `./a` + for (let ext of extensions) { + let full = `${file}${ext}` + if (fs.existsSync(full) && fs.statSync(full).isFile()) { + return full + } + } + + // Try to find `./a/index.js` from `./a` + for (let ext of extensions) { + let full = `${file}/index${ext}` + if (fs.existsSync(full)) { + return full + } + } + + return null +} + +function* _getModuleDependencies( + filename: string, + base: string, + seen: Set, + ext = path.extname(filename), +): Iterable { + // Try to find the file + let absoluteFile = resolveWithExtension( + path.resolve(base, filename), + jsExtensions.includes(ext) ? jsResolutionOrder : tsResolutionOrder, + ) + if (absoluteFile === null) return // File doesn't exist + + // Prevent infinite loops when there are circular dependencies + if (seen.has(absoluteFile)) return // Already seen + seen.add(absoluteFile) + + // Mark the file as a dependency + yield absoluteFile + + // Resolve new base for new imports/requires + base = path.dirname(absoluteFile) + ext = path.extname(absoluteFile) + + let contents = fs.readFileSync(absoluteFile, 'utf-8') + + // Find imports/requires + for (let match of [ + ...contents.matchAll(/import[\s\S]*?['"](.{3,}?)['"]/gi), + ...contents.matchAll(/import[\s\S]*from[\s\S]*?['"](.{3,}?)['"]/gi), + ...contents.matchAll(/export[\s\S]*from[\s\S]*?['"](.{3,}?)['"]/gi), + ...contents.matchAll(/require\(['"`](.+)['"`]\)/gi), + ]) { + // Bail out if it's not a relative file + if (!match[1].startsWith('.')) continue + + yield* _getModuleDependencies(match[1], base, seen, ext) + } +} + +export function getModuleDependencies(absoluteFilePath: string) { + if (absoluteFilePath === null) return new Set() + return new Set( + _getModuleDependencies(absoluteFilePath, path.dirname(absoluteFilePath), new Set()), + ) +} diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 99324b070..4b74811e0 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -366,8 +366,9 @@ export function registerPlugins( handler(pluginApi) } - // Theme keys don't need to read from the compat config because core plugins - // _already_ look through "fallback" theme keys + // 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. mergeIntoTheme(designSystem, userConfig) return {