loading...
This Dot

Schematics: Add Tailwind CSS to your Angular project

flakolefluk profile image Ignacio Le Fluk Updated on ・10 min read

This is part 3 of the series on Schematics.

TailwindCSS

I've been hearing a lot lately about Tailwind CSS 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

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.

{
  "$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.

{
  "$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
}
export interface NgAddOptions {
  project: string;
  skipInstall: boolean;
}
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.

export interface NgAddSetupOptions {
  project: string;
}
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.

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

// ...
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.

// files/tailwind/tailwind.config.js
module.exports = {
  theme: {
    extend: {}
  },
  variants: {},
  plugins: []
};
// ...
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.

// ...
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.

// ...
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).

// ...
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.

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.

npm i --save-dev @schematics/angular
// 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.

// 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
  • 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

This article was written by Ignacio Falk who is a software engineer at This Dot.

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.

Discussion

pic
Editor guide