diff --git a/e2e/vue/src/vue-tailwind.test.ts b/e2e/vue/src/vue-tailwind.test.ts new file mode 100644 index 00000000000000..daabb40e46d384 --- /dev/null +++ b/e2e/vue/src/vue-tailwind.test.ts @@ -0,0 +1,49 @@ +import { + cleanupProject, + listFiles, + newProject, + readFile, + runCLI, + uniq, + updateFile, +} from '@nx/e2e/utils'; + +describe('vue tailwind support', () => { + beforeAll(() => { + newProject({ unsetProjectNameAndRootFormat: false }); + }); + + afterAll(() => { + cleanupProject(); + }); + + it('should setup tailwind and build correctly', async () => { + const app = uniq('app'); + + runCLI(`generate @nx/vue:app ${app} --style=css --no-interactive`); + runCLI(`generate @nx/vue:setup-tailwind --project=${app}`); + + updateFile( + `${app}/src/App.vue`, + ` + + ` + ); + + runCLI(`build ${app}`); + + const fileArray = listFiles(`dist/${app}/assets`); + const stylesheet = fileArray.find((file) => file.endsWith('.css')); + const content = readFile(`dist/${app}/assets/${stylesheet}`); + + // used, not purged + expect(content).toContain('text-3xl'); + expect(content).toContain('font-bold'); + // unused, purged + expect(content).not.toContain('text-xl'); + }, 300_000); +}); diff --git a/packages/vue/generators.json b/packages/vue/generators.json index 1f247e517b147f..2b52cb5da9f5e8 100644 --- a/packages/vue/generators.json +++ b/packages/vue/generators.json @@ -28,6 +28,11 @@ "aliases": ["c"], "x-type": "component", "description": "Create a Vue component." + }, + "setup-tailwind": { + "factory": "./src/generators/setup-tailwind/setup-tailwind", + "schema": "./src/generators/setup-tailwind/schema.json", + "description": "Set up Tailwind configuration for a project." } } } diff --git a/packages/vue/package.json b/packages/vue/package.json index c1f8ecf1aaeab4..ecf257c8c5b698 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -40,12 +40,5 @@ "publishConfig": { "access": "public" }, - "peerDependencies": {}, - "exports": { - ".": "./index.js", - "./package.json": "./package.json", - "./migrations.json": "./migrations.json", - "./generators.json": "./generators.json", - "./executors.json": "./executors.json" - } + "peerDependencies": {} } diff --git a/packages/vue/project.json b/packages/vue/project.json index 012ce949398499..9c5d0856a9fdff 100644 --- a/packages/vue/project.json +++ b/packages/vue/project.json @@ -18,6 +18,11 @@ "outputPath": "build/packages/vue", "tsConfig": "packages/vue/tsconfig.lib.json", "main": "packages/vue/index.ts", + "generateExportsField": true, + "additionalEntryPoints": [ + "{projectRoot}/{executors,generators,migrations}.json", + "{projectRoot}/src/tailwind.ts" + ], "assets": [ { "input": "packages/vue", diff --git a/packages/vue/src/generators/setup-tailwind/files/postcss.config.js.template b/packages/vue/src/generators/setup-tailwind/files/postcss.config.js.template new file mode 100644 index 00000000000000..ce46f1ea75cbcc --- /dev/null +++ b/packages/vue/src/generators/setup-tailwind/files/postcss.config.js.template @@ -0,0 +1,10 @@ +const { join } = require('path'); + +module.exports = { + plugins: { + tailwindcss: { + config: join(__dirname, 'tailwind.config.js'), + }, + autoprefixer: {}, + }, +} diff --git a/packages/vue/src/generators/setup-tailwind/files/tailwind.config.js.template b/packages/vue/src/generators/setup-tailwind/files/tailwind.config.js.template new file mode 100644 index 00000000000000..df80cf5a06476c --- /dev/null +++ b/packages/vue/src/generators/setup-tailwind/files/tailwind.config.js.template @@ -0,0 +1,18 @@ +const { createGlobPatternsForDependencies } = require('@nx/vue/tailwind'); +const { join } = require('path'); + +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + join(__dirname, 'index.html'), + join( + __dirname, + 'src/**/*!(*.stories|*.spec).{vue,ts,tsx,js,jsx}' + ), + ...createGlobPatternsForDependencies(__dirname), + ], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/packages/vue/src/generators/setup-tailwind/lib/add-tailwind-style-imports.ts b/packages/vue/src/generators/setup-tailwind/lib/add-tailwind-style-imports.ts new file mode 100644 index 00000000000000..9bc7ee45be5fa6 --- /dev/null +++ b/packages/vue/src/generators/setup-tailwind/lib/add-tailwind-style-imports.ts @@ -0,0 +1,37 @@ +import { joinPathFragments, ProjectConfiguration, Tree } from '@nx/devkit'; + +import { SetupTailwindOptions } from '../schema'; + +const knownStylesheetLocations = [ + // What we generate by default + 'src/styles.css', + 'src/styles.scss', + 'src/styles.less', + + // Other common locations (e.g. what `npm create vue` does) + 'src/assets/styles.css', + 'src/assets/styles.scss', + 'src/assets/styles.less', +]; + +export function addTailwindStyleImports( + tree: Tree, + project: ProjectConfiguration, + _options: SetupTailwindOptions +) { + const stylesPath = knownStylesheetLocations + .map((file) => joinPathFragments(project.root, file)) + .find((file) => tree.exists(file)); + + if (!stylesPath) { + throw new Error( + `Could not find the stylesheet to update. Use --stylesheet to specify this path (relative to the workspace root).` + ); + } + + const content = tree.read(stylesPath).toString(); + tree.write( + stylesPath, + `@tailwind base;\n@tailwind components;\n@tailwind utilities;\n${content}` + ); +} diff --git a/packages/vue/src/generators/setup-tailwind/schema.d.ts b/packages/vue/src/generators/setup-tailwind/schema.d.ts new file mode 100644 index 00000000000000..4ba7f47fea32f6 --- /dev/null +++ b/packages/vue/src/generators/setup-tailwind/schema.d.ts @@ -0,0 +1,6 @@ +export interface SetupTailwindOptions { + project: string; + skipFormat?: boolean; + skipPackageJson?: boolean; + stylesheet?: string; +} diff --git a/packages/vue/src/generators/setup-tailwind/schema.json b/packages/vue/src/generators/setup-tailwind/schema.json new file mode 100644 index 00000000000000..bc56696ac858c3 --- /dev/null +++ b/packages/vue/src/generators/setup-tailwind/schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "$id": "NxVueTailwindSetupGenerator", + "title": "Configures Tailwind CSS for an application or a library.", + "description": "Adds the Tailwind CSS configuration files for a given Vue 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" + }, + "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" + }, + "stylesheet": { + "type": "string", + "description": "Path to the styles entry point relative to the workspace root. This option is only needed if the stylesheet location cannot be found automatically." + } + }, + "additionalProperties": false, + "required": ["project"] +} diff --git a/packages/vue/src/generators/setup-tailwind/setup-tailwind.spec.ts b/packages/vue/src/generators/setup-tailwind/setup-tailwind.spec.ts new file mode 100644 index 00000000000000..b7dc4309efbd82 --- /dev/null +++ b/packages/vue/src/generators/setup-tailwind/setup-tailwind.spec.ts @@ -0,0 +1,97 @@ +import { + addProjectConfiguration, + readJson, + stripIndents, + writeJson, +} from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import update from './setup-tailwind'; + +describe('vue setup-tailwind generator', () => { + it.each` + stylesPath + ${`src/styles.css`} + ${`src/styles.scss`} + ${`src/styles.less`} + ${`src/assets/styles.css`} + ${`src/assets/styles.scss`} + ${`src/assets/styles.less`} + `('should update existing 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 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: { + vue: '999.9.9', + }, + }); + + await update(tree, { + project: 'example', + }); + + expect(readJson(tree, 'package.json')).toEqual({ + dependencies: { + vue: '999.9.9', + }, + devDependencies: { + 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: { + vue: '999.9.9', + }, + }); + + await update(tree, { + project: 'example', + skipPackageJson: true, + }); + + expect(readJson(tree, 'package.json')).toEqual({ + dependencies: { + vue: '999.9.9', + }, + }); + }); +}); diff --git a/packages/vue/src/generators/setup-tailwind/setup-tailwind.ts b/packages/vue/src/generators/setup-tailwind/setup-tailwind.ts new file mode 100644 index 00000000000000..9c1dc0b28ddf95 --- /dev/null +++ b/packages/vue/src/generators/setup-tailwind/setup-tailwind.ts @@ -0,0 +1,46 @@ +import type { GeneratorCallback, Tree } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + generateFiles, + readProjectConfiguration, +} from '@nx/devkit'; + +import { + autoprefixerVersion, + postcssVersion, + tailwindcssVersion, +} from '../../utils/versions'; +import type { SetupTailwindOptions } from './schema'; +import { addTailwindStyleImports } from './lib/add-tailwind-style-imports'; +import { join } from 'path'; + +export async function setupTailwindGenerator( + tree: Tree, + options: SetupTailwindOptions +): Promise { + let installTask: GeneratorCallback | undefined = undefined; + const project = readProjectConfiguration(tree, options.project); + + generateFiles(tree, join(__dirname, './files'), project.root, {}); + + addTailwindStyleImports(tree, project, options); + + if (!options.skipPackageJson) { + installTask = addDependenciesToPackageJson( + tree, + {}, + { + autoprefixer: autoprefixerVersion, + postcss: postcssVersion, + tailwindcss: tailwindcssVersion, + } + ); + } + + if (!options.skipFormat) await formatFiles(tree); + + return installTask; +} + +export default setupTailwindGenerator; diff --git a/packages/vue/src/tailwind.ts b/packages/vue/src/tailwind.ts new file mode 100644 index 00000000000000..ef0dd75964c9e8 --- /dev/null +++ b/packages/vue/src/tailwind.ts @@ -0,0 +1,30 @@ +import { createGlobPatternsForDependencies as jsGenerateGlobs } from '@nx/js/src/utils/generate-globs'; + +/** + * 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, + fileGlobPattern: string = '/**/*!(*.stories|*.spec).{vue,tsx,ts,jsx,js}' +) { + try { + return jsGenerateGlobs(dirPath, fileGlobPattern); + } 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 + * fundamentally 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 []; + } +} diff --git a/packages/vue/src/utils/versions.ts b/packages/vue/src/utils/versions.ts index 8d01f547ac118a..9a8632fca91a7d 100644 --- a/packages/vue/src/utils/versions.ts +++ b/packages/vue/src/utils/versions.ts @@ -18,6 +18,11 @@ export const vueEslintConfigPrettierVersion = '^8.0.0'; export const vueEslintConfigTypescriptVersion = '^11.0.3'; export const eslintPluginVueVersion = '^9.16.1'; +// tailwindcss +export const postcssVersion = '8.4.21'; +export const tailwindcssVersion = '3.2.7'; +export const autoprefixerVersion = '10.4.13'; + // other deps export const sassVersion = '1.62.1'; export const lessVersion = '3.12.2';