fix(nextjs): add setup generator for Tailwind CSS fixes absolute pathing (#30192)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->
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
<!-- This is the behavior we should expect with the changes in this PR
-->
Relative paths a singular globs are required with this change tailwind
should work with HMR using `--turbo`

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #29946
This commit is contained in:
Nicholas Cunningham 2025-02-26 14:55:09 -07:00 committed by GitHub
parent 321a63aac3
commit 6d40b6a6ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 444 additions and 4 deletions

View File

@ -7,7 +7,7 @@ import {
Tree, Tree,
} from '@nx/devkit'; } from '@nx/devkit';
import { initGenerator as jsInitGenerator } from '@nx/js'; import { initGenerator as jsInitGenerator } from '@nx/js';
import { setupTailwindGenerator } from '@nx/react'; import { setupTailwindGenerator } from '../setup-tailwind/setup-tailwind';
import { import {
testingLibraryDomVersion, testingLibraryDomVersion,
testingLibraryReactVersion, testingLibraryReactVersion,

View File

@ -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: {},
},
}

View File

@ -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: [],
};

View File

@ -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`
);
}
}

View File

@ -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);
}
}

View File

@ -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"]
}

View File

@ -0,0 +1,6 @@
export interface SetupTailwindOptions {
project: string;
buildTarget?: string;
skipFormat?: boolean;
skipPackageJson?: boolean;
}

View File

@ -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',
},
});
});
});

View File

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

View File

@ -1,3 +1,39 @@
// Re-exporting for convenience and backwards compatibility. import { createGlobPatternsForDependencies as jsGenerateGlobs } from '@nx/js/src/utils/generate-globs';
import { createGlobPatternsForDependencies } from '@nx/react/tailwind'; import { relative } from 'path';
export { createGlobPatternsForDependencies };
/**
* 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 [];
}
}