Schematics: Add Tailwind CSS to your Angular project
Tags: angular
Published: 2019-08-27 20:06:35

This is part 3 of the series on Schematics.

## TailwindCSS
I've been hearing a lot lately about [Tailwind CSS](https://tailwindcss.com/) and I wanted to give it a try. Tailwind is a PostCSS plugin, and adding it to an Angular project is not a trivial task. It involves a few steps. 
Googling a little bit, I found a few examples of how to add it. Some options included using an extra CLI, or adding a script to run before building. But, I prefer to stick with what's already provided with the Angular CLI, and the possibilities to extend it.

> This example will work with Angular 8 or higher.

We'll need to do a couple of things to get this working.

- **create** webpack config files for dev and production builds.
- **create** a tailwind configuration file.
- **update** our global styles file and add tailwind styles
- **update** angular.json to use a custom builder.
- **update** package.json and add all required dependencies
- **install** dependencies


This is a great opportunity to create a schematics collection, and automate this process.

Create a new schematics collection, remove unnecessary files, and create two schematics named `ng-add` and `ng-add-setup`

```bash
schematics blank --name=tailwind-schematics
cd tailwind-schematics
rm -rf src/tailwind-schematics
schematics blank --name=ng-add
schematics blank --name=ng-add-setup
```

Clean `collection.json`, and remove references to the deleted tailwind schematic.

```json
{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "ng-add": {
      "description": "Adds tailwindCSS to an Angular project",
      "factory": "./ng-add/index#ngAdd",
      "schema": "./ng-add/schema.json"
    },
    "ng-add-setup": {
      "description": "Setups project before installing dependencies",
      "factory": "./ng-add-setup/index#ngAddSetup",
      "private": true,
      "schema": "./ng-add-setup/schema.json"
    }
  }
}
```

Notice that `ng-add-setup` is marked as private. We listed it in our collection, because we want it to be accessible by the `ng-add` schematic.

We will split the list of actions into two tasks: the first task is to create, and update files, and another task is to install our dependencies.
`ng-add` will be in charge of running the tasks in order. We will also add a flag to determine if we should run the installation after the changes are made.
Remember to add a schema to your schematic.

```json
{
  "$schema": "http://json-schema.org/schema",
  "id": "tailwind-ng-add-schematic",
  "title": "Add tailwind to angular project",
  "type": "object",
  "properties": {
    "project": {
      "type": "string",
      "description": "The name of the project."
    },
    "skipInstall": {
      "type": "boolean",
      "description": "Condition to run npm install"
    }
  },
  "required": [],
  "additionalProperties": false
}
```

```ts
export interface NgAddOptions {
  project: string;
  skipInstall: boolean;
}
```

```ts
export function ngAdd(options: NgAddOptions): Rule {
  return (tree: Tree, context: SchematicContext) => {
    // 1. Do some checks before starting
    const workspace = getWorkspace(tree);
    const packageJson = getPackageJson(tree);
    const projectName = options.project || workspace.defaultProject;

    const coreVersion: string = packageJson.dependencies['@angular/core'];

    if (!coreVersion) {
      throw new SchematicsException(
        'Could not find @angular/core version in package.json.',
      );
    }

    const majorVersion: number = parseInt(
      coreVersion.split('.')[0].replace(/\D/g, ''),
      10,
    );

    if (majorVersion < 8) {
      throw new SchematicsException('Minimum version requirement not met.');
    }

   // 2. run schematic that chain a set of schematics that will add and update files
    const setupId = context.addTask(
      new RunSchematicTask('ng-add-setup', { project: projectName }),
    );

   // 3. Install dependencies (or skip installation)
    if(!options.skipInstall){
      context.addTask(new NodePackageInstallTask(), [setupId]);
    }
  };
}
```

There a few utility functions that I'm using here to get, and parse `angular.json` and `package.json`. For version 0.0.1, we'll be supporting Angular 8+, and making sure that the project meets this requirement.
Then, we will run the `ng-add-setup` schematic, and after it finishes, we will optionally run the `NodePackageInstall` task.

Most of the work will be handled by `ng-add-setup`. Let's scaffold how it will work.


```ts
export interface NgAddSetupOptions {
  project: string;
}
```

```ts
export function ngAddSetup(options: NgAddSetupOptions): Rule {
    return chain([
      addWebpackConfigFiles(options),
      addTailwindConfigFile(options),
      updateStylesFile(options),
      updateAngularConfig(options),
      updatePackageJson(options),
    ])(tree, _context);
  };
}

function addWebpackConfigFiles(options): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    // ...create webpack config files (from template)
  }
}

function addTailwindConfigFile(options): Rule {
  return (tree: Tree, _context: SchematicContext) => {
   // ...create tailwind config files (from template)
  };
}

function updatePackageJson(options): Rule {
  return (tree: Tree, _context: SchematicContext): Tree => {
    // add dependencies and devdepencies to package.json
  };
}

function updateStylesFile(options): Rule {
  return (tree: Tree, _context: SchematicContext) => {
   // add @tailwind imports to global styles file
  };
}

function updateAngularConfig(options): Rule {
  return (tree: Tree, _context: SchematicContext) => {
   // modify angular.json to use custom webpack config and builders
  };
}
```

We will start with file creation. As we saw before, we'll create a folder with the files that will be added.

We'll name our files webpack.config.json, webpack-prod.config.json, and tailwind.config.js

I created these files inside their own folders `webpack` and `tailwind`. I prefer to keep it this way to make it easier later when reading these folders.

```js
// files/webpack/webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\<%= stylesExt %>$/,
        use: [
          {
            loader: "postcss-loader",
            options: {
              plugins: [
                require("tailwindcss")("./tailwind.config.js"),
                require("autoprefixer")
              ]
            }
          }
        ]
      }
    ]
  }
};
```

```js
// files/webpack/webpack-prod.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\<%= stylesExt %>$/,
        use: [
          {
            loader: "postcss-loader",
            options: {
              plugins: [
                require("tailwindcss")("./tailwind.config.js"),
                require("@fullhuman/postcss-purgecss")({
                  content: [
                    "./src/**/*.component.html",
                    "./src/**/*.component.ts"
                  ],
                  defaultExtractor: content =>
                    content.match(/[A-Za-z0-9-_:/]+/g) || []
                }),
                require("autoprefixer")
              ]
            }
          }
        ]
      }
    ]
  }
};
```
We are passing the styles extension to our config template to let it know what files to look for.
Both config files look very similar, but on production, we will be running PurgeCSS to remove all the code that we are not using on our application. This is an important step to follow. If you don't, your CSS file will be huge.

```ts
// ...
function addWebpackConfigFiles(stylesExt: string, projectSrcRoot: string): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const source: Source = url('files/webpack');
    return mergeWith(
      apply(source, [
        template({ stylesExt }),
        move(normalize(`${projectSrcRoot}/..`)),
      ]),
    )(tree, _context);
  };
}
// ...
```

Our function will read the files webpack folder, and apply the template rule to each file, passing the stylesExtension argument to be used in the template. Then, those files will be moved to our project root folder (A workspace can have more than one project and the project root is not necessarily the workspace root).

We will do the same for the tailwind config file.

```js
// files/tailwind/tailwind.config.js
module.exports = {
  theme: {
    extend: {}
  },
  variants: {},
  plugins: []
};
```

```ts
// ...
function addTailwindConfigFile(projectSrcRoot: string): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const source: Source = url('files/tailwind');
    return mergeWith(apply(source, [move(normalize(`${projectSrcRoot}/..`))]))(
      tree,
      _context,
    );
  };
}
// ...
```

We are using the default configuration for TailwindCSS. We could later use some parameters to adjust it.
That's it for file creation. Let's start with file updates.

```ts
// ...
function updatePackageJson(pkgJson: PackageJson): Rule {
  return (tree: Tree, _context: SchematicContext): Tree => {
    let customBuilderVersion: string = '';

    pkgJson.devDependencies = pkgJson.devDependencies || {};

    const builderVersion =
      pkgJson.devDependencies['@angular-devkit/build-angular'];


    if(builderVersion){
      const partialVersion = builderVersion
      .substring(
        builderVersion.indexOf('.') + 1,
        builderVersion.lastIndexOf('.'),
      );
      customBuilderVersion = `~${partialVersion[0]}${partialVersion[2]}.0`
    }

    pkgJson.devDependencies['@angular-builders/custom-webpack'] =
      pkgJson.devDependencies['@angular-builders/custom-webpack'] || customBuilderVersion || '~8.0.0';

    pkgJson.devDependencies['@angular-devkit/build-angular'] =
      builderVersion || '~0.800.0';

    pkgJson.devDependencies['@fullhuman/postcss-purgecss'] =
      pkgJson.devDependencies['@fullhuman/postcss-purgecss'] || '~1.2.0';

    pkgJson.devDependencies['tailwindcss'] =
      pkgJson.devDependencies['tailwindcss'] || '~1.1.2';

    tree.overwrite('package.json', JSON.stringify(pkgJson, null, 2));

    return tree;
  };
}
// ...
```

We are adding required dependencies if they are not already added. `@angular-builders/custom-webpack` version depends on `@angular-devkit/build-angular`. Then, we are replacing package.json with our modified version. The packages will be installed (or not) when we run the last task on `ng-add`.


```ts
// ...
function updateStylesFile(projectSrcRoot: string, stylesExt: string): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const file = tree.read(`${projectSrcRoot}/styles.${stylesExt}`);

    if (!file) {
      throw new SchematicsException('Style file not found.');
    }

    const fileContent = file.toString();

    const imports = [
      '@tailwind base;',
      '@tailwind components;',
      '@tailwind utilities;',
    ];

    const recorder = tree.beginUpdate(`${projectSrcRoot}/styles.${stylesExt}`);
    imports.forEach(imported => {
      if (!fileContent.includes(imported)) {
        recorder.insertLeft(0, `${imported}\n`);
      }
    });
    tree.commitUpdate(recorder);
    return tree;
  };
}
```

To update our styles file, we are checking if the corresponding tailwind import exists, and adding it to the top of the file.

> We are relying that styles.\<stylesExt\> exists. This is the default when creating a project, and we can make sure that we are adding tailwind imports at the very top of the file.

The last file updated is `angular.json`. We are going to replace the project builder with the custom one, and provide the custom webpack configs depending on the environment.

> We won't be covering custom builders in this tutorial. We are using them to extend our `build` and `serve` commands, using the added webpack configurations, depending on the environment. This is required to be able to preprocess tailwind imports (and purge unused CSS on production).

```ts
// ...
function updateAngularConfig(
  workspace: Workspace,
  projectName: string,
  projectSrcRoot: string,
): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    workspace.projects[projectName].architect.build.builder =
      '@angular-builders/custom-webpack:browser';

    workspace.projects[
      projectName
    ].architect.build.options.customWebpackConfig = {
      path: path.join(projectSrcRoot, '..', 'webpack.config.js'),
    };

    workspace.projects[
      projectName
    ].architect.build.configurations.production.customWebpackConfig = {
      path: path.join(projectSrcRoot, '..', 'webpack-prod.config.js'),
    };

    workspace.projects[projectName].architect.serve.builder =
      '@angular-builders/custom-webpack:dev-server';
    tree.overwrite('angular.json', JSON.stringify(workspace, null, 2));
  };
}
// ...
```

 Our final `ng-add-setup` schematic.

```ts
import {
  Rule,
  SchematicContext,
  Tree,
  chain,
  url,
  mergeWith,
  Source,
  template,
  apply,
  SchematicsException,
  move,
} from '@angular-devkit/schematics';

import * as path from 'path';

import { NgAddSetupOptions } from './schema';
import { normalize } from '@angular-devkit/core';
import { PackageJson, Workspace } from '../common/models';
import {
  getWorkspace,
  getProject,
  getProjectSrcRoot,
  getProjectStylesExt,
  getPackageJson,
} from '../common/utils';

export function ngAddSetup(options: NgAddSetupOptions): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const workspace = getWorkspace(tree);
    const project = getProject(workspace, options.project);
    const root = getProjectSrcRoot(project);
    const stylesExt = getProjectStylesExt(project);
    const packageJson = getPackageJson(tree);

    return chain([
      addWebpackConfigFiles(stylesExt, root),
      addTailwindConfigFile(root),
      updateStylesFile(root, stylesExt),
      updateAngularConfig(workspace, options.project, root),
      updatePackageJson(packageJson),
    ])(tree, _context);
  };
}

function addWebpackConfigFiles(
  stylesExt: string,
  projectSrcRoot: string,
): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const source: Source = url('files/webpack');
    return mergeWith(
      apply(source, [
        template({ stylesExt }),
        move(normalize(`${projectSrcRoot}/..`)),
      ]),
    )(tree, _context);
  };
}

function addTailwindConfigFile(projectSrcRoot: string): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const source: Source = url('files/tailwind');
    return mergeWith(apply(source, [move(normalize(`${projectSrcRoot}/..`))]))(
      tree,
      _context,
    );
  };
}

function updatePackageJson(pkgJson: PackageJson): Rule {
  return (tree: Tree, _context: SchematicContext): Tree => {
    let customBuilderVersion: string = '';

    pkgJson.devDependencies = pkgJson.devDependencies || {};

    const builderVersion =
      pkgJson.devDependencies['@angular-devkit/build-angular'];

    if (builderVersion) {
      const partialVersion = builderVersion.substring(
        builderVersion.indexOf('.') + 1,
        builderVersion.lastIndexOf('.'),
      );
      customBuilderVersion = `~${partialVersion[0]}.${partialVersion[2]}.0`;
    }

    pkgJson.devDependencies['@angular-builders/custom-webpack'] =
      pkgJson.devDependencies['@angular-builders/custom-webpack'] ||
      customBuilderVersion ||
      '~8.0.0';

    pkgJson.devDependencies['@angular-devkit/build-angular'] =
      builderVersion || '~0.800.0';

    pkgJson.devDependencies['@fullhuman/postcss-purgecss'] =
      pkgJson.devDependencies['@fullhuman/postcss-purgecss'] || '~1.2.0';

    pkgJson.devDependencies['tailwindcss'] =
      pkgJson.devDependencies['tailwindcss'] || '~1.1.2';

    tree.overwrite('package.json', JSON.stringify(pkgJson, null, 2));

    return tree;
  };
}

function updateStylesFile(projectSrcRoot: string, stylesExt: string): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const file = tree.read(`${projectSrcRoot}/styles.${stylesExt}`);

    if (!file) {
      throw new SchematicsException('Style file not found.');
    }

    const fileContent = file.toString();

    const imports = [
      '@tailwind base;',
      '@tailwind components;',
      '@tailwind utilities;',
    ];

    const recorder = tree.beginUpdate(`${projectSrcRoot}/styles.${stylesExt}`);
    imports.forEach(imported => {
      if (!fileContent.includes(imported)) {
        recorder.insertLeft(0, `${imported}\n`);
      }
    });
    tree.commitUpdate(recorder);
    return tree;
  };
}

function updateAngularConfig(
  workspace: Workspace,
  projectName: string,
  projectSrcRoot: string,
): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    workspace.projects[projectName].architect.build.builder =
      '@angular-builders/custom-webpack:browser';

    workspace.projects[
      projectName
    ].architect.build.options.customWebpackConfig = {
      path: path.join(projectSrcRoot, '..', 'webpack.config.js'),
    };

    workspace.projects[
      projectName
    ].architect.build.configurations.production.customWebpackConfig = {
      path: path.join(projectSrcRoot, '..', 'webpack-prod.config.js'),
    };

    workspace.projects[projectName].architect.serve.builder =
      '@angular-builders/custom-webpack:dev-server';
    tree.overwrite('angular.json', JSON.stringify(workspace, null, 2));
  };
}
```


## Unit tests
We need unit tests to check that our schematics are working properly.
Remember from our previous tutorial, that we need to create the environment first to run the schematic.
Let's start by adding the `@schematics/angular` package to our devDependencies. We will use it to create a workspace and a default project.

```bash
npm i --save-dev @schematics/angular
```

```ts
// ng-add/index.spec.ts
import {
  SchematicTestRunner,
  UnitTestTree,
} from '@angular-devkit/schematics/testing';
import * as path from 'path';
import { Tree } from '@angular-devkit/schematics';

const collectionPath = path.join(__dirname, '../collection.json');
const runner = new SchematicTestRunner('schematics', collectionPath);

describe('ng-add', () => {
  const workspaceOptions = {
    name: 'workspace',
    newProjectRoot: 'projects',
    version: '8.0.0',
  };

  const appOptions = {
    name: 'testApp',
    inlineStyle: false,
    inlineTemplate: false,
    routing: false,
    style: 'css',
    skipTests: false,
    skipPackageJson: false,
  };

  let sourceTree: UnitTestTree;

  describe('when in an angular workspace', () => {
    beforeEach(async () => {
      sourceTree = await runner
        .runExternalSchematicAsync(
          '@schematics/angular',
          'workspace',
          workspaceOptions,
        )
        .toPromise();
      sourceTree = await runner
        .runExternalSchematicAsync(
          '@schematics/angular',
          'application',
          appOptions,
          sourceTree,
        )
        .toPromise();
    });

    it('schedules two tasks by default', () => {
      runner.runSchematic('ng-add', { project: 'testApp' }, sourceTree);
      expect(runner.tasks.length).toBe(2);
      expect(runner.tasks.some(task => task.name === 'run-schematic')).toBe(
        true,
      );
      expect(runner.tasks.some(task => task.name === 'node-package')).toBe(
        true,
      );
    });

    it('will not install dependencies if skipInstall is true', () => {
      runner.runSchematic(
        'ng-add',
        { project: 'testApp', skipInstall: true },
        sourceTree,
      );
      expect(runner.tasks.length).toBe(1);
      expect(runner.tasks.some(task => task.name === 'run-schematic')).toBe(
        true,
      );
    });
  });

  describe('when not in an angular workspace', () => {
    it('should throw', () => {
      let errorMessage;
      try {
        runner.runSchematic('ng-add', {}, Tree.empty());
      } catch (e) {
        errorMessage = e.message;
      }
      expect(errorMessage).toMatch(/Could not find Angular workspace configuration/);
    });
  });
});
```

We are testing the number of tasks, and which tasks are running. We will also test that the schematic will throw if `angular.json` is not found.

We should also test that the files are created, and updated, by `ng-add-setup`.

```ts
// ng-add-setup/index.spec.ts
const collectionPath = path.join(__dirname, '../collection.json');

describe('ng-add-setup', () => {
  const workspaceOptions = {
    name: 'workspace',
    newProjectRoot: 'projects',
    version: '8.0.0',
  };

  const appOptions = {
    name: 'testApp',
    inlineStyle: false,
    inlineTemplate: false,
    routing: false,
    style: 'css',
    skipTests: false,
    skipPackageJson: false,
  };

  const runner = new SchematicTestRunner('schematics', collectionPath);

  let sourceTree: UnitTestTree;

  describe('CSS', () => {
    beforeEach(async () => {
      sourceTree = await runner
        .runExternalSchematicAsync(
          '@schematics/angular',
          'workspace',
          workspaceOptions,
        )
        .toPromise();
      sourceTree = await runner
        .runExternalSchematicAsync(
          '@schematics/angular',
          'application',
          appOptions,
          sourceTree,
        )
        .toPromise();
    });

    it('adds and updates files', () => {
      const tree = runner.runSchematic(
        'ng-add-setup',
        { project: 'testApp' },
        sourceTree,
      );

      expect(tree.files).toContain('/projects/testApp/tailwind.config.js');
      expect(tree.files).toContain('/projects/testApp/webpack-prod.config.js');
      expect(tree.files).toContain('/projects/testApp/webpack.config.js');
      expect(tree.files).toContain('/projects/testApp/src/styles.css');

      const stylesFile = tree.readContent('/projects/testApp/src/styles.css');

      expect(stylesFile).toContain('@tailwind base;');
      expect(stylesFile).toContain('@tailwind components;');
      expect(stylesFile).toContain('@tailwind utilities;');

      const workspace = JSON.parse(tree.readContent('angular.json'));
      const app = workspace.projects.testApp;

      expect(app.architect.build.builder).toBe(
        '@angular-builders/custom-webpack:browser',
      );

      expect(app.architect.build.options.customWebpackConfig.path).toBe(
        '/projects/testApp/webpack.config.js',
      );

      expect(
        app.architect.build.configurations.production.customWebpackConfig.path,
      ).toBe('/projects/testApp/webpack-prod.config.js');

      expect(app.architect.serve.builder).toBe(
        '@angular-builders/custom-webpack:dev-server',
      );
    });
  });

// test SCSS, SASS, etc
});
```

We are creating a new project, and running our schematic. Then we test that the files have been created, and updated, as expected.

## Testing locally
To test the schematic locally, we can create a new angular project. (`ng new <project_name>`). Once we are in our project, we should run `ng add <path_to_collection>`. This command will install the collection, and start executing the `ng-add` schematic.
`

# Final words
* You can find the code in [this repository](https://github.com/flakolefluk/tailwindcss-schematics)
* You can try it today!. (Pre-release, use at your own risk) 
* `ng add @flakolefuk/tailwind-schematics.`
* Feel free to contribute

## Related blog posts / Resources
* https://medium.com/@bajzat.tamas/setup-tailwind-css-in-angular-8-project-6e9264e97329
* https://medium.com/@mallim/tailwindcss-on-angular-8-a8a96fef7581
* https://tailwindcss.com/docs/using-with-preprocessors

*This article was written by Ignacio Falk who is a software engineer at [This Dot](https://thisdot.co).*   

*You can follow him on Twitter at @flakolefluk.*

*Need JavaScript consulting, mentoring, or training help? Check out our list of services at [This Dot Labs](https://thisdot.co).*