This article requires that you understand the basic terminologies and concepts used in Angular Schematics such as Collection, Rule, Tree, etc. If you want to learn more about it, check here and here.

Recently, I thought of giving tailwindcss a try (after hearing and reading so much about this utility-first CSS framework). My plan was to use tailwindcss with Angular.  So I started looking for documents on how to integrate tailwindcss with Angular and found couple of good articles. Check them here and here.

Going through these document, you will soon understand that integrating tailwindcss to Angular is not that straightforward (as in executing a single command) but involves a series of steps.

Luckily I also found this library that makes the integration really simple (yes, executing a single command!).  This library uses  Angular schematics to automate this process.

Now just to understand better, first I followed the manual process and then also tried the schematics library (@ngneat/tailwind) to integrate tailwindcss to Angular. My primary goal was completed.

However, few months back, I had started learning about Schematics in Angular and like many other learning and side projects, I had abandoned it in favor of something else (do not remember exactly what for). I thought of resuming it back and this time not only learn the concepts but actually create a useful (but simple) Angular schematic.

As mentioned before in this article, I will not be going into explaining concepts related to Angular Schematics. Please refer this and this for detailed explanation. Going through these articles and reading more about Schematics, I would say

Schematics are all about code generation and changing of existing files!

and the main files that we will work with are:

  • collection.json - schematic definition file.
  • index.ts - Schematic factory function file.
  • schema.json - Contains options to be passed to the factory function.
  • schema.ts - Contains interface for the schematic options
  • files - Directory that contains template file to be created.

With this understanding, I realized that creating a schematic for Angular + Tailwind CSS integration would be a good beginner learning experience for following reasons.

  • It involves code generation (Adding tailwindcss imports, etc.).
  • It involves updating existing files. (angular.json, etc)
  • It involves creating new files (webpack config, etc.).
  • It involves updating package.json file and installing libraries.

More or less it covers all the things that Angular schematics is designed to provide. Also I had couple of good blogs and an already implemented schematic library for my reference.

Thank you to all the wonderful people who have written about this topic and contributed to the development of libraries that uses Angular schematic.

So with the goal of learning and understanding the Angular Schematics and also working on a schematic library I jumped right on my next project - angular-tailwindcss-schematics.

In the next section, I am going to share my git commit timeline for this project and for each share, I am going to showcase/explain these things:

  • What's the step is about (What it does)?
  • Link to the generated code, related files and explain things (if required)
  • How it is related to Angular Schematics?

Plan is to start with very basic schematic concepts, implementation and slowly build the code for this complete angular-tailwindcss-schematics project.

If you want to take a look at code on GithubClick here

Click here to check this simple Angular Schematic on npmngx-tailwindcss-schematic

Setup

First things first, I created a git repository called angular-tailwindcss-schematics and cloned it on my local machine.

We have to install @angular-devkit/schematics-cli package to be able to to use schematics command in our terminal. This enables us to create a blank schematics project.

npm i -g @angular-devkit/schematics-cli

Create Project

Run: schematics blank angular-tailwindcss-schematics

  • This command creates a new schematic project mainly used to create an standalone angular schematic that can be run in any Angular Cli application.
  • Following files are generated:
CREATE angular-tailwindcss-schematics/README.md (639 bytes)
CREATE angular-tailwindcss-schematics/.gitignore (191 bytes)
CREATE angular-tailwindcss-schematics/.npmignore (64 bytes)
CREATE angular-tailwindcss-schematics/package.json (587 bytes)
CREATE angular-tailwindcss-schematics/tsconfig.json (656 bytes)
CREATE angular-tailwindcss-schematics/src/collection.json (284 bytes)
CREATE angular-tailwindcss-schematics/src/angular-tailwindcss-schematics/index.ts (335 bytes)
CREATE angular-tailwindcss-schematics/src/angular-tailwindcss-schematics/index_spec.ts (539 bytes)
✔ Packages installed successfully.

Among all files generated, we have two files that I want to mention about:

  • collection.json - This file contains information such as schematic name, description and path to our schema factory method.
  • angular-tailwindcss-schematics/src/angular-tailwindcss-schematics/index.ts - This is schema factory file. It contains a factory function that returns a Rule. We will be writing most of the code here.
Git commit at this stage:  Code

Rename to ng-add

In this commit, I renamed angular-tailwindcss-schematics to ng-add following standards as this will be going a schematic that you will add to you app.

Made the factory function in index.ts as default and accordingly made changes in the collection.json file.

Git commit at this stage: Code

Added Schema file

schema.json file is another important file that is used to provide additional schema options while running your schematic. It also adds a validation layer. We can also add schema.ts interface file for type safety with list of options that we want to provide.

Our default factory function in file index.ts, receives options:Schema as parameter. This options object contains schema options from command line.

Another update that I did was to add a reference to this newly created schema.json file in our collection.json file.

Git commit at this stage: Code

Added files directory

In this commit, I created a files directory (src/ng-add/files). This is the location to store template files that can be used to generate files using the schematic.

You will notice usage of words such as dasherize, classify , etc. These are the helper functions provided by the schematic library (strings module) to transform names and are commonly used for templating purposes. Read more about them here.

I have updated index.ts file with basic code to generate a file based on the template from files directory. Added comments to help understand the code.

Git commit at this stage: Code

Building/Running Schematics

At this point, we have all the required building blocks to work on a schematic, though it does not do anything useful (yet!). You can build and test the schematic.

// Build
npm run build
// OR run in watch mode
npm run build:watch

// Run
// dry-run mode
schematics .:ng-add
// normal mode
schematics .:ng-add --debug false

At this point, we have a basic working Angular Schematic and now we will update/change these already generated files/code to create a schematic to add tailwindcss to any Angular Cli application or Nx workspace.

To add tailwindcss, we need to do following changes in our workspace:

  • Add tailwindcss dependencies to package.json file and install them.
tailwindcss
postcss-import
postcss-loader
@angular-builders/custom-webpack
postcss-scss (only needed if selected style type is scss)
  • Update Project default styles file with tailwindcss imports.
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
  • Create following config files:
    • tailwind.config.js
    • webpack.config.js
  • Update angular.json file with custom angular webpack builder.

Changes to index.ts file.

Before making above changes, I am going to briefly describe the changes I have done to default function in index.ts file.

// ng-add/index.ts (entry point for schematic execution)

import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { Schema } from './schema';

// You don't have to export the function as default. You can also have more than one rule factory per file.

/** Rule factory: returns a rule (function) */
export default function (options: Schema): Rule {

	// this is a rule. It takes a `tree`, apply changes and returns
	// updated tree for further processing by next rule.
	// this way the schematic rules are composable.
	return (tree: Tree, context: SchematicContext) => {
  
	// Read `angular.json` as buffer
	const workspaceConfigBuffer = tree.read('angular.json');
    
	if (!workspaceConfigBuffer) {
    throw new SchematicsException('Could not find an Angular workspace 	configuration'); 👈 (Make sure you are in Angular Cli workspace)
    }
    
	// parse config only when not null
	const workspaceConfig: workspace.WorkspaceSchema = JSON.parse(workspaceConfigBuffer.toString());
    
	// if project is not passed (--project), use default project name
	if (!options.project && workspaceConfig.defaultProject) {
      options.project = workspaceConfig.defaultProject;
    }
    
	const projectName = options.project as string;
    
	// elect project from projects array in `angular.json` file
	const project: workspace.WorkspaceProject = workspaceConfig.projects[projectName];
    
	if (!project) {
      throw new SchematicsException(`Project ${projectName} is not defined in this workspace.`);
    }
    
	// compose all rules using chain Rule.
	return chain([])(tree, context); 👈
  };
}

Our default factory function in index.ts file is the only exportable function in that file and is referenced in the collection.json .

In above code example, when you execute this schematic, I have added code to make sure that you are in a Angular Cli workspace and that we have a valid project. If any one fails, an exception is thrown.

Another important addition is chain Rule function. It returns a Rule by combining several other Rule's. Read more about it here. So basically for each of the task for tailwindcss we will be creating a function that will return a Rule and then we can call those function from this chain function as shown below:

// Example
// ng-add/index.ts

export default function (options: Schema): Rule {
	return (tree: Tree, context: SchematicContext) => {
        ...
        ...
        // compose all rules using chain Rule.
        return chain([addDependencies(options)])(tree, context);
	}
}

/**
 * Add required dependencies to package.json file.
 * @private
 */
function addDependencies(options: Schema): Rule {
	...
	...
}

So now our skeleton code is ready and we only have to perform 2 tasks:

  • Add a function (for any tailwindcss related tasks).
  • Update chain([]) function.
There are couple of ways to build and test the schematic from an Angular Cli workspace. Check the ReadMe

Let's do these changes one by one.

Add a function to update package.json file

We have seen above, the list of packages that is required as dependencies for tailwindcss and it also depends on CSS types or preprocessors (css, scss, sass, less). So in this commit the primary task was to:

  1. Give user's option to select default CSS type for the project.
  2. Take user selection and pass it to a method that will add the packages to package.json file.

For point 1, We need to update the schema.json to add the prompt that will provide with the CSS options. The schema.ts file also needs to be updated. Check code changes below

Git commit at this stage: Code

For point 2, I have added addDependencies(options: Schema): Rule method. This method takes options (it has all the custom options, like cssType, etc.) and updates the package.json file with the dependencies required for tailwindcss. They are added as Devdependencies.

If the default project style is not css then we needed to add an extra dependency postcss-${options.cssType}.

The method is pretty much self explanatory and it uses couple of helper functions from @schematics/angular/utility/dependencies

Git commit at this stage: Code

The last thing was to update the chain([addDependencies(options)]) function in index.ts file.

Add a function to add config files for tailwindcss

As we have seen above, we need to add config files for tailwind and webpack when we run our schematic.

So in this commit, I have added the addTemplateFiles(options: Schema): Rule that goes through the files directory and these files to your project.

Git commit at this stage: Code

Similar to previous step, update the chain([addDependencies(options), addTemplateFiles(options)]) with this new method.

Add a function to add tailwindcss imports to default project style file

We need to update the project default style file (for e.g. styles.css or styles.scss) with the imports from tailwindcss. Below helper method gives us the import string.

export function getTailwindCSSImports(): string {
  return `
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
`;
}

Now in order to complete this requirement, we should know the default styles file for the project.

angular.json file has all these information, to be more specific, the architect[buildTarget].options object in this file has the styles array with default project style file path. I will use this to get the required information.

// angular.json
// architect['build'].options
...
...
"architect": {
        "build": { 👈 // buildTarget: 'build'
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist/sample-app",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.app.json",
            "aot": true,
            "assets": [
              "src/favicon.ico",
              "src/assets"
            ],
            "styles": [
              "src/styles.scss" 👈 // default style file
            ],
            "scripts": []
          },
					...
					...
...
...

So, I have added getProjectDefaultStyleFile(project: workspace.WorkspaceProject, fileExtension: string) helper method to return the the style path.

It takes the project (basically part angular.json file object) and css style file type (from the user input) and just iterate down the JSON object to get the path.

Git commit at this stage: Code

Now once we have the style path, we can go ahead and write our main method that will update the style file. I have added updateStylesFile(options: Schema, project: workspace.WorkspaceProject): Rule that updates the style file based on the default style path.

Git commit at this stage: Code

Similar to previous step, update the  chain([addDependencies(options), updateStylesFile(options, project), addTemplateFiles(options)]) with this new method.

Add a function to add update angular.json file

Next we need to update the angular.json file with custom webpack builder and  webpack config file. This is required because tailwindcss needs to be part of the build process (Read more here) so that it gets properly imported and we can take advantage of all the things it has to offer.

// Custom webpack builder
@angular-builders/custom-webpack
// webpack config file
webpack.config.js

Similar to previous task, we need to iterate through the JSON object in angular.json and this time look for architect[buildTarget].builder . We need to update the value for this key with the custom webpack builder and also update architect[buildTarget].options object with custom webpack file.

I have added a helper function to get us the target based on the builder name. It takes the project object and builderName (browser, devServer, etc.) and returns us the respective target.

// File: src/ng-add/utils.ts

/** Gets all targets from the given project that match the specified builder name. */
export function getTargetsByBuilderName(project: workspace.WorkspaceProject, builderName: string) {
  const targets = project.architect || {};
  return Object.keys(targets)
    .filter((name) => targets[name].builder === builderName)
    .map((name) => targets[name]);
}

Once we have the respective target, we can update the builder and options object as required.

Git commit at this stage: Code

Similar to previous step, update the  chain([addDependencies(options), updateStylesFile(options, project), addTemplateFiles(options), updateAngularJsonFile(workspaceConfig, project)]) with this new method.

Add a function to add install added dependencies

Last part is to add a method to install the dependencies that we had added in the packge.json file for tailwindcss. For this we will use builtin class NodePackageInstallTask from '@angular-devkit/schematics/tasks'.

Git commit at this stage: Code

and update the  chain([addDependencies(options), updateStylesFile(options, project), addTemplateFiles(options), updateAngularJsonFile(workspaceConfig, project), install()]) with this new method.

The complete chain([]) method has all the methods that returns a Rule and it composes all the   Rules one by one, passing along the updated tree from one method to another.

/** Rule factory: returns a rule (function) */
export default function (options: Schema): Rule {
  // this is a rule (function). It takes a `tree` and returns updated `tree`.
  return (tree: Tree, context: SchematicContext) => {
    ...
    
    ...
    // compose all rules using chain Rule.
    return chain([
      addDependencies(options),
      updateStylesFile(options, project),
      addTemplateFiles(options),
      updateAngularJsonFile(workspaceConfig, project),
      install(),
    ])(tree, context);
  };
}

Again to run and build, you can refer ReadMe

I want to Thanks Angular team for doing such awesome job. Keep up the good work!

Do let me know in comments if you have any feedback/suggestions or you find any errors. You can find me on Twitter (@esanjiv).

Keep Learning! Thanks!