From 6d40b6a6cafcaeaa1b94e0627ccf69b9996e1343 Mon Sep 17 00:00:00 2001 From: Nicholas Cunningham Date: Wed, 26 Feb 2025 14:55:09 -0700 Subject: [PATCH] fix(nextjs): add setup generator for Tailwind CSS fixes absolute pathing (#30192) ## Current Behavior When we generate a Next.js application with tailwindcss running with `--turbo` fails due to how the globs are defined inside `tailwind.config.js`. ## Expected Behavior Relative paths a singular globs are required with this change tailwind should work with HMR using `--turbo` ## Related Issue(s) Fixes #29946 --- .../src/generators/application/application.ts | 2 +- .../files/postcss.config.js__tmpl__ | 15 ++ .../files/tailwind.config.js__tmpl__ | 23 +++ .../lib/add-tailwind-style-imports.ts | 50 +++++ .../setup-tailwind/lib/update-project.ts | 19 ++ .../src/generators/setup-tailwind/schema.json | 47 +++++ .../src/generators/setup-tailwind/schema.ts | 6 + .../setup-tailwind/setup-tailwind.spec.ts | 176 ++++++++++++++++++ .../setup-tailwind/setup-tailwind.ts | 68 +++++++ packages/next/tailwind.ts | 42 ++++- 10 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 packages/next/src/generators/setup-tailwind/files/postcss.config.js__tmpl__ create mode 100644 packages/next/src/generators/setup-tailwind/files/tailwind.config.js__tmpl__ create mode 100644 packages/next/src/generators/setup-tailwind/lib/add-tailwind-style-imports.ts create mode 100644 packages/next/src/generators/setup-tailwind/lib/update-project.ts create mode 100644 packages/next/src/generators/setup-tailwind/schema.json create mode 100644 packages/next/src/generators/setup-tailwind/schema.ts create mode 100644 packages/next/src/generators/setup-tailwind/setup-tailwind.spec.ts create mode 100644 packages/next/src/generators/setup-tailwind/setup-tailwind.ts diff --git a/packages/next/src/generators/application/application.ts b/packages/next/src/generators/application/application.ts index 0a7af6a9eb..a469625314 100644 --- a/packages/next/src/generators/application/application.ts +++ b/packages/next/src/generators/application/application.ts @@ -7,7 +7,7 @@ import { Tree, } from '@nx/devkit'; import { initGenerator as jsInitGenerator } from '@nx/js'; -import { setupTailwindGenerator } from '@nx/react'; +import { setupTailwindGenerator } from '../setup-tailwind/setup-tailwind'; import { testingLibraryDomVersion, testingLibraryReactVersion, diff --git a/packages/next/src/generators/setup-tailwind/files/postcss.config.js__tmpl__ b/packages/next/src/generators/setup-tailwind/files/postcss.config.js__tmpl__ new file mode 100644 index 0000000000..5a8ffc4899 --- /dev/null +++ b/packages/next/src/generators/setup-tailwind/files/postcss.config.js__tmpl__ @@ -0,0 +1,15 @@ +const { join } = require('path'); + +// Note: If you use library-specific PostCSS/Tailwind configuration then you should remove the `postcssConfig` build +// option from your application's configuration (i.e. project.json). +// +// See: https://nx.dev/guides/using-tailwind-css-in-react#step-4:-applying-configuration-to-libraries + +module.exports = { + plugins: { + tailwindcss: { + config: join(__dirname, 'tailwind.config.js'), + }, + autoprefixer: {}, + }, +} diff --git a/packages/next/src/generators/setup-tailwind/files/tailwind.config.js__tmpl__ b/packages/next/src/generators/setup-tailwind/files/tailwind.config.js__tmpl__ new file mode 100644 index 0000000000..690dca5cfc --- /dev/null +++ b/packages/next/src/generators/setup-tailwind/files/tailwind.config.js__tmpl__ @@ -0,0 +1,23 @@ +// const { createGlobPatternsForDependencies } = require('@nx/next/tailwind'); + +// The above utility import will not work if you are using Next.js' --turbo. +// Instead you will have to manually add the dependent paths to be included. +// For example +// ../libs/buttons/**/*.{ts,tsx,js,jsx,html}', <--- Adding a shared lib +// !../libs/buttons/**/*.{stories,spec}.{ts,tsx,js,jsx,html}', <--- Skip adding spec/stories files from shared lib + +// If you are **not** using `--turbo` you can uncomment both lines 1 & 19. +// A discussion of the issue can be found: https://github.com/nrwl/nx/issues/26510 + +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './{src,pages,components,app}/**/*.{ts,tsx,js,jsx,html}', + '!./{src,pages,components,app}/**/*.{stories,spec}.{ts,tsx,js,jsx,html}', +// ...createGlobPatternsForDependencies(__dirname) + ], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/packages/next/src/generators/setup-tailwind/lib/add-tailwind-style-imports.ts b/packages/next/src/generators/setup-tailwind/lib/add-tailwind-style-imports.ts new file mode 100644 index 0000000000..1c794e7fa4 --- /dev/null +++ b/packages/next/src/generators/setup-tailwind/lib/add-tailwind-style-imports.ts @@ -0,0 +1,50 @@ +import { + joinPathFragments, + logger, + ProjectConfiguration, + stripIndents, + Tree, +} from '@nx/devkit'; + +import { SetupTailwindOptions } from '../schema'; + +// base directories and file types to simplify locating the stylesheet +const baseDirs = ['src', 'pages', 'src/pages', 'src/app', 'app']; +const fileNames = ['styles', 'global']; +const extensions = ['.css', '.scss', '.less']; + +const knownLocations = baseDirs.flatMap((dir) => + fileNames.flatMap((name) => extensions.map((ext) => `${dir}/${name}${ext}`)) +); + +export function addTailwindStyleImports( + tree: Tree, + project: ProjectConfiguration, + _options: SetupTailwindOptions +) { + const candidates = knownLocations.map((currentPath) => + joinPathFragments(project.root, currentPath) + ); + const stylesPath = candidates.find((currentStylePath) => + tree.exists(currentStylePath) + ); + + if (stylesPath) { + const content = tree.read(stylesPath).toString(); + tree.write( + stylesPath, + `@tailwind base;\n@tailwind components;\n@tailwind utilities;\n${content}` + ); + } else { + logger.warn( + stripIndents` + Could not find stylesheet to update. Add the following imports to your stylesheet (e.g. styles.css): + + @tailwind base; + @tailwind components; + @tailwind utilities; + + See our guide for more details: https://nx.dev/guides/using-tailwind-css-in-react` + ); + } +} diff --git a/packages/next/src/generators/setup-tailwind/lib/update-project.ts b/packages/next/src/generators/setup-tailwind/lib/update-project.ts new file mode 100644 index 0000000000..2758bd5b67 --- /dev/null +++ b/packages/next/src/generators/setup-tailwind/lib/update-project.ts @@ -0,0 +1,19 @@ +import type { ProjectConfiguration, Tree } from '@nx/devkit'; +import { joinPathFragments, updateProjectConfiguration } from '@nx/devkit'; + +import { SetupTailwindOptions } from '../schema'; + +export function updateProject( + tree: Tree, + config: ProjectConfiguration, + options: SetupTailwindOptions +) { + if (config?.targets?.build?.executor === '@nx/webpack:webpack') { + config.targets.build.options ??= {}; + config.targets.build.options.postcssConfig = joinPathFragments( + config.root, + 'postcss.config.js' + ); + updateProjectConfiguration(tree, options.project, config); + } +} diff --git a/packages/next/src/generators/setup-tailwind/schema.json b/packages/next/src/generators/setup-tailwind/schema.json new file mode 100644 index 0000000000..3055e7d80c --- /dev/null +++ b/packages/next/src/generators/setup-tailwind/schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/schema", + "cli": "nx", + "$id": "NxNextTailwindSetupGenerator", + "title": "Configures Tailwind CSS for an application or a buildable/publishable library.", + "description": "Adds the Tailwind CSS configuration files for a Next.js project and installs, if needed, the packages required for Tailwind CSS to work.", + "type": "object", + "examples": [ + { + "command": "nx g setup-tailwind --project=my-app", + "description": "Initialize Tailwind configuration for the `my-app` project." + } + ], + "properties": { + "project": { + "type": "string", + "description": "The name of the project to add the Tailwind CSS setup for.", + "alias": "p", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-dropdown": "projects", + "x-prompt": "What project would you like to add the Tailwind CSS setup?", + "x-priority": "important" + }, + "buildTarget": { + "type": "string", + "description": "The name of the target used to build the project. This option is not needed in most cases.", + "default": "build", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Skips formatting the workspace after the generator completes.", + "x-priority": "internal" + }, + "skipPackageJson": { + "type": "boolean", + "default": false, + "description": "Do not add dependencies to `package.json`.", + "x-priority": "internal" + } + }, + "additionalProperties": false, + "required": ["project"] +} diff --git a/packages/next/src/generators/setup-tailwind/schema.ts b/packages/next/src/generators/setup-tailwind/schema.ts new file mode 100644 index 0000000000..58452c9634 --- /dev/null +++ b/packages/next/src/generators/setup-tailwind/schema.ts @@ -0,0 +1,6 @@ +export interface SetupTailwindOptions { + project: string; + buildTarget?: string; + skipFormat?: boolean; + skipPackageJson?: boolean; +} diff --git a/packages/next/src/generators/setup-tailwind/setup-tailwind.spec.ts b/packages/next/src/generators/setup-tailwind/setup-tailwind.spec.ts new file mode 100644 index 0000000000..690fe6d1e7 --- /dev/null +++ b/packages/next/src/generators/setup-tailwind/setup-tailwind.spec.ts @@ -0,0 +1,176 @@ +import { + addProjectConfiguration, + readJson, + readProjectConfiguration, + stripIndents, + writeJson, +} from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import update from './setup-tailwind'; + +describe('setup-tailwind', () => { + it.each` + stylesPath + ${`src/styles.css`} + ${`src/styles.scss`} + ${`src/styles.less`} + ${`pages/styles.css`} + ${`pages/styles.scss`} + ${`pages/styles.less`} + `('should update stylesheet', async ({ stylesPath }) => { + const tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'example', { + root: 'example', + sourceRoot: 'example/src', + targets: {}, + }); + tree.write(`example/${stylesPath}`, `/* existing content */`); + + await update(tree, { + project: 'example', + }); + + expect(tree.read(`example/${stylesPath}`).toString()).toContain( + stripIndents` + @tailwind base; + @tailwind components; + @tailwind utilities; + /* existing content */ + ` + ); + }); + + it('should add postcss and tailwind config files', async () => { + const tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'example', { + root: 'example', + sourceRoot: 'example/src', + targets: { + build: { + executor: '@nx/webpack:webpack', + options: {}, + }, + }, + }); + tree.write(`example/src/styles.css`, ``); + writeJson(tree, 'package.json', { + dependencies: { + react: '999.9.9', + }, + devDependencies: { + '@types/react': '999.9.9', + }, + }); + + await update(tree, { + project: 'example', + }); + + expect(tree.exists(`example/postcss.config.js`)).toBeTruthy(); + expect(tree.exists(`example/tailwind.config.js`)).toBeTruthy(); + expect( + readProjectConfiguration(tree, 'example').targets.build.options + .postcssConfig + ).toEqual('example/postcss.config.js'); + }); + + it('should skip update if postcss configuration already exists', async () => { + const tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'example', { + root: 'example', + sourceRoot: 'example/src', + targets: {}, + }); + tree.write(`example/src/styles.css`, ``); + tree.write('example/postcss.config.js', '// existing'); + + await update(tree, { project: 'example' }); + + expect(tree.read('example/postcss.config.js').toString()).toEqual( + '// existing' + ); + }); + + it('should skip update if tailwind configuration already exists', async () => { + const tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'example', { + root: 'example', + sourceRoot: 'example/src', + targets: {}, + }); + tree.write(`example/src/styles.css`, ``); + tree.write('example/tailwind.config.js', '// existing'); + + await update(tree, { project: 'example' }); + + expect(tree.read('example/tailwind.config.js').toString()).toEqual( + '// existing' + ); + }); + + it('should install packages', async () => { + const tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'example', { + root: 'example', + sourceRoot: 'example/src', + targets: {}, + }); + tree.write(`example/src/styles.css`, ``); + writeJson(tree, 'package.json', { + dependencies: { + react: '999.9.9', + }, + devDependencies: { + '@types/react': '999.9.9', + }, + }); + + await update(tree, { + project: 'example', + }); + + expect(readJson(tree, 'package.json')).toEqual({ + dependencies: { + react: '999.9.9', + }, + devDependencies: { + '@types/react': '999.9.9', + autoprefixer: expect.any(String), + postcss: expect.any(String), + tailwindcss: expect.any(String), + }, + }); + }); + + it('should support skipping package install', async () => { + const tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'example', { + root: 'example', + sourceRoot: 'example/src', + targets: {}, + }); + tree.write(`example/src/styles.css`, ``); + writeJson(tree, 'package.json', { + dependencies: { + react: '999.9.9', + }, + devDependencies: { + '@types/react': '999.9.9', + }, + }); + + await update(tree, { + project: 'example', + skipPackageJson: true, + }); + + expect(readJson(tree, 'package.json')).toEqual({ + dependencies: { + react: '999.9.9', + }, + devDependencies: { + '@types/react': '999.9.9', + }, + }); + }); +}); diff --git a/packages/next/src/generators/setup-tailwind/setup-tailwind.ts b/packages/next/src/generators/setup-tailwind/setup-tailwind.ts new file mode 100644 index 0000000000..cf53b58d86 --- /dev/null +++ b/packages/next/src/generators/setup-tailwind/setup-tailwind.ts @@ -0,0 +1,68 @@ +import type { GeneratorCallback, Tree } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + generateFiles, + joinPathFragments, + logger, + readProjectConfiguration, + runTasksInSerial, +} from '@nx/devkit'; + +import { + autoprefixerVersion, + postcssVersion, + tailwindcssVersion, +} from '@nx/react/src/utils/versions'; +import type { SetupTailwindOptions } from './schema'; +import { addTailwindStyleImports } from './lib/add-tailwind-style-imports'; +import { updateProject } from './lib/update-project'; +import { join } from 'path'; + +export async function setupTailwindGenerator( + tree: Tree, + options: SetupTailwindOptions +) { + const tasks: GeneratorCallback[] = []; + const project = readProjectConfiguration(tree, options.project); + + if ( + tree.exists(joinPathFragments(project.root, 'postcss.config.js')) || + tree.exists(joinPathFragments(project.root, 'tailwind.config.js')) + ) { + logger.info( + `Skipping setup since there are existing PostCSS or Tailwind configuration files. For manual setup instructions, see https://nx.dev/guides/using-tailwind-css-in-react.` + ); + return; + } + + generateFiles(tree, join(__dirname, './files'), project.root, { + tmpl: '', + }); + + addTailwindStyleImports(tree, project, options); + + updateProject(tree, project, options); + + if (!options.skipPackageJson) { + tasks.push( + addDependenciesToPackageJson( + tree, + {}, + { + autoprefixer: autoprefixerVersion, + postcss: postcssVersion, + tailwindcss: tailwindcssVersion, + } + ) + ); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return runTasksInSerial(...tasks); +} + +export default setupTailwindGenerator; diff --git a/packages/next/tailwind.ts b/packages/next/tailwind.ts index fc155407d5..f54a5b581d 100644 --- a/packages/next/tailwind.ts +++ b/packages/next/tailwind.ts @@ -1,3 +1,39 @@ -// Re-exporting for convenience and backwards compatibility. -import { createGlobPatternsForDependencies } from '@nx/react/tailwind'; -export { createGlobPatternsForDependencies }; +import { createGlobPatternsForDependencies as jsGenerateGlobs } from '@nx/js/src/utils/generate-globs'; +import { relative } from 'path'; + +/** + * Generates a set of glob patterns based off the source root of the app and its dependencies + * @param dirPath workspace relative directory path that will be used to infer the parent project and dependencies + * @param fileGlobPattern pass a custom glob pattern to be used + */ +export function createGlobPatternsForDependencies( + dirPath: string, + fileGlobPatternToInclude: string = '/**/*.{tsx,ts,jsx,js,html}', + fileGlobPatternToExclude: string = '/**/*.{stories,spec}.{tsx,ts,jsx,js,html}' +) { + try { + return [ + ...jsGenerateGlobs(dirPath, fileGlobPatternToInclude).map((glob) => + relative(dirPath, glob) + ), + ...jsGenerateGlobs(dirPath, fileGlobPatternToExclude).map( + (glob) => `!${relative(dirPath, glob)}` + ), + ]; + } catch (e) { + /** + * It should not be possible to reach this point when the utility is invoked as part of the normal + * lifecycle of Nx executors. However, other tooling, such as the VSCode Tailwind IntelliSense plugin + * or JetBrains editors such as WebStorm, may execute the tailwind.config.js file in order to provide + * autocomplete features, for example. + * + * In order to best support that use-case, we therefore do not hard error when the ProjectGraph is + * fundamently unavailable in this tailwind-specific context. + */ + console.warn( + '\nWARNING: There was an error creating glob patterns, returning an empty array\n' + + `${e.message}\n` + ); + return []; + } +}