Build your own Angular code generator

Using TypeScript to build your own Angular code generation templates, an alternative to the Angular (2+) CLI code scaffolding feature.

Introduction

As you might know, the Angular CLI has a nice code scaffolding feature. For example, adding a new component to your app is as simple as running the following command:

ng generate component component-name

This will generate a component class, a component style sheet and a unit test. However, the simplicity of ng generate also comes with a tradeoff: The CLI provides little control over what code is generated. And there are many reasons why you might need more control: What if you want to inject default dependencies into components or services? Or always wish to inherit your component class from a custom base class? Or you have your own naming conventions or directory names you want to adhere to?

This tutorial shows an alternative - and easily extensible - way to scaffold parts of your Angular application using Yellicode, giving you full control over every line of code that is generated. We will create some TypeScript templates that generate components and services from a JSON model. As a bonus, we will generate routes and a navigation component as well.

You can find the final source code for this tutorial on GitHub.

Setting things up

Before we delve into code, let's first install everything we need and then create an empty Angular app. The first steps are similar to the steps in the official Angular QuickStart.

Prerequisites

  1. Install Node.js® and npm if they are not already on your machine.
  2. Install the Angular CLI globally
    npm install -g @angular/cli
  3. Then, install the Yellicode CLI if it isn't already on your machine.
    npm install -g @yellicode/cli

Now that we've got things installed, create a new Angular project using the following CLI command (this can take some time, so grab a coffee or read ahead...).

ng new yellicode-angular

If all went well, you can now serve the application by going to the project directory and serving the application.

cd yellicode-angular
ng serve --open

Creating a model for our app

First, we need a place to define the structure of our application, so that our code generator knows what components and services to generate. One way would be to create a UML model using Yellicode Modeler, but let's stick to an arbitrary JSON file for now. This will be our "application model", I think the structure speaks for itself.

{
  "services": [
    {
      "name": "User"     
    }
  ],
  "components": [
    {
      "name": "Home",
      "title": "Home page",
      "createRoute": true,
      "routeUrl": "",
      "showInSideNav": true
    },
    {
      "name": "Account",
      "title": "Your account",
      "createRoute": true,
      "showInSideNav": true,
      "useServices": [
        "User"
      ]
    }
  ]
}

Let's keep things organized and create a new directory for our code generation templates inside the project directory.

mkdir codegen

Inside this directory, create a new file named app-model.json and paste the above JSON code into it. This file will be the input for our TypeScript code generation code. And because TypeScript is strongly typed, why not create some TypeScript interfaces that correspond to our model? Create a new file named app-model.ts and add the following contents.

export interface AppModel {
    services: any[];
    components: ComponentModel[];
}

export interface ComponentModel {
    name: string;
    title?: string;
    useServices?: string[];
    createRoute?: boolean;
    routeUrl?: string;
    showInSideNav?: boolean;
}

export interface ServiceModel {
    name: string;
}

Setting up the code generator

Now it's time to create our first code generation template, and to tell Yellicode where to find it.

  1. Install the @yellicode/templating extension.
    npm install @yellicode/templating --save-dev
  2. Install the @yellicode/typescript extension. If this confuses you: TypeScript is Yellicode's templating language, but - in the case of Angular - also the output language of our code generator.
    npm install @yellicode/typescript --save-dev
  3. Add a new file named components.template.ts to the codegen directory.
import * as path from 'path';
import { Generator, NameUtility } from '@yellicode/templating';
import { TypeScriptWriter } from '@yellicode/typescript';
import { AppModel } from './app-model';

Generator.getModel<AppModel>().then((model: AppModel) => {

  // Generate a file for each component in the model
  model.components.forEach((component) => {
    // Let's assume a CamelCase component name in the model. By convention, make it kebab-case for use in file paths.
    const kebabCaseComponentName = NameUtility.camelToKebabCase(component.name);
    const componentDir = path.join('../src/app/components', kebabCaseComponentName); // puts each component in its own sub directory
    const fileBaseName = `${kebabCaseComponentName}.component`;

    // 1. Generate the component class
    Generator.generate({ outputFile: `${path.join(componentDir, fileBaseName)}.ts` }, (output) => {
      const typeScript = new TypeScriptWriter(output);
      typeScript.writeLine(`/* Component code for the '${component.name}' component */`);
    });

    // 2. Generate the HTML template
    Generator.generate({ outputFile: `${path.join(componentDir, fileBaseName)}.html` }, (output) => {
      output.writeLine(`<!-- HTML template for the '${component.name}' component. -->`);
      // If a title is configured, write it.
      if (component.title) {
        output.writeLine(`<h1>${component.title}</h1>`)
      }
    });

    // 3. Generate the stylesheet (feel free to change to '.scss' or '.less')
    Generator.generate({ outputFile: `${path.join(componentDir, fileBaseName)}.css` }, (output) => {
      output.writeLine(`/* Component stylesheet for the '${component.name}' component. */`);
    });
  });
})    

This is our initial template that scaffolds the Angular components that we defined in our model. The template doesn't do much yet, other than generating a few empty component files. We will add more power soon, but let's first get this template up and running.

In the root of the project directory, create a new file named codegenconfig.json and add the following contents:

{
  "templates": [
    {
      "templateFile": "./codegen/components.template.ts",
      "modelFile": "./codegen/app-model.json"
    }
  ],
  "compileTypeScript": true
}    

Now run Yellicode from the command prompt and wait a few seconds.

yellicode

You should now see 2 new subdirectories named 'account' and 'home' in the 'src/app' directory with your first generated files in them! Catch: if you are using VS Code, you may need to refresh the Explorer to see the changes. At this point, the generated files are empty, but we've got our code generation configuration up and running. Now let's add some actual code.

Building the component class generator

Now that we've got all the bits and pieces together, it's time to generate some actual TypeScript code. The first code we will generate is the Angular component class. We will make use of another extension named @yellicode/angular.

npm install @yellicode/angular --save-dev

In the codegen directory, create a new file named component-class.template.ts and insert the following contents:

import { NameUtility } from '@yellicode/templating';
import { TypeScriptWriter } from '@yellicode/typescript';
import { AngularWriter, ComponentConfig } from '@yellicode/angular';
import { ComponentModel } from './app-model';

export const writeComponentClass = (writer: TypeScriptWriter, model: ComponentModel, fileBaseName: string) => {
  const implement: string[] = ['OnInit']; // Whatever interfaces you might want to implement
  const extend: string[] = []; // If you have your own component base class, add it here
  const angularCoreImports: string[] = ['Component', 'OnInit']; // Add default Angular imports here

  // 1. Imports
  writer.writeImports('@angular/core', angularCoreImports); 
  writer.writeLine();

  // 2. Write the component
  const componentConfig: ComponentConfig = {
    selector: NameUtility.camelToKebabCase(model.name),
    templateUrl: `./${fileBaseName}.html`,
    styleUrls:  [`./${fileBaseName}.css`]
  };

  // 2.1 First write the @Component(...) class decorator with the configuration
  AngularWriter.writeComponentDecorator(writer, componentConfig);
  // 2.2 Then write the class itself
  writer.writeClassBlock({ name: `${model.name}Component`, export: true, extends: extend, implements: implement }, () => {
    // Class constructor
    writer.writeIndent();
    writer.write('constructor() {');
    if (extend && extend.length) {
      writer.writeLineIndented('super()');
    }
    writer.writeLine('}');
    writer.writeLine();
    // Class contents 
    writer.writeLine('public ngOnInit() {');
    writer.writeLine('}');
  })
}    

This code should be easy to grasp. It generates a component almost class that is almost similar to how the Angular CLI would do, but now we can modify it to our needs.

There is one thing we should do before we can run this code: importing it into components.template.ts. This requires the following 2 changes to components.template.ts:

  1. Add an import statement
    import { writeComponentClass } from './component-class.template';
  2. Replace the line
  3. typeScript.writeLine(`/* Component code for the '${component.name}' component */`);
    with the following line
    writeComponentClass(typeScript, component, fileBaseName);

Make sure you saved everything and start code generation again:

yellicode

If you now inspect the generated ...component.ts files again, you should see your newly generated component classes.

Registering our generated components

As an Angular developer, you know that you cannot use a component before registering it inside the AppModule. We can do this by hand, but it would be better to generate this registration code too. And now we are at it, why not generate the routes too?

Add new file named component-registration.template.ts to the codegen directory and add the following contents.

import * as path from 'path';
import { Generator, TextWriter, NameUtility } from '@yellicode/templating';
import { TypeScriptWriter } from '@yellicode/typescript';
import { AngularWriter } from '@yellicode/angular';
import { AppModel } from './app-model';

Generator.generateFromModel<AppModel>({ outputFile: path.join('../src/app', 'app.component-registration.ts')}, (textWriter: TextWriter, model: AppModel) => {
    const writer = new TypeScriptWriter(textWriter);
    const componentNames = model.components.map(c => c.name);

    // Write imports
    componentNames.forEach(name => {
        const kebabCaseComponentName = NameUtility.camelToKebabCase(name);
        writer.writeImports(`./components/${kebabCaseComponentName}/${kebabCaseComponentName}.component`, [`${name}Component`]);
    });
    writer.writeLine();

    // Declarations
    writer.writeLine('export const declarations = [')
    writer.increaseIndent();
    writer.writeLines(componentNames.map(n => `${n}Component`), ',');
    writer.decreaseIndent();
    writer.writeLine(']');
    writer.writeLine();

    // Routes
    writer.writeLine('export const routes = [')
    writer.increaseIndent();
    model.components
      .filter(c => c.createRoute)
      .forEach(c => {
        const path = (c.routeUrl == undefined) ? NameUtility.camelToKebabCase(c.name) : c.routeUrl; // allow a routeUrl of ''!
        AngularWriter.writeRoute(writer, {path: path, componentName: `${c.name}Component`});
    });
    writer.decreaseIndent();
    writer.writeLine(']');
});

I think the code speaks for itself: it creates two arrays, one with component declarations and one with the routes that we defined in the application model. In order to use this template, add a new entry to the 'templates' section of the codegenconfig.json file and run yellicode.

{
    "modelFile": "./codegen/app-model.json",
    "templateFile": "./codegen/component-registration.template.ts"
}    
yellicode

Now we've generated a new file named app.component-registration.ts inside the application directory. However, in order to use it, we need to import it into our AppModule. Replace the contents of app.module.ts with the following.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';

import { AppComponent } from './app.component';
import * as componentRegistration from './app.component-registration';

// Component declarations
const declarations: any[] = [AppComponent]; // add any manual components here
// Add generated components
declarations.push(...componentRegistration.declarations);

// Routes
const routes: {path:string, component: any}[] = []; // off course, you can add manual routes too 
routes.push(...componentRegistration.routes);

@NgModule({
  declarations: declarations,
  imports: [
    BrowserModule,
    RouterModule.forRoot(routes)
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }    

This structure will keep our AppModule code clean, separating automatically generated components and routes from manual declarations. There is just one thing to be done: adding a <router-outlet> element. The router outlet is a special element that tells the Angular router where to display routed views.

Open up app.component.html and add the <router-outlet> element (for example, after the first </div> closing tag):

<router-outlet></router-outlet>

Time to serve

This completes the most fundamental part of this tutorial: generating components and routes from a custom application model. Let's serve the app and see our code in action.

ng serve --open

You should now see a title "Home page" at the place where you added the router outlet. And if you navigate to http://localhost:4200/account you should see that the account component is loaded instead.

Adding side navigation

Now you've seen how to generate routes, let's take this a step further and generate a navigation component that uses the same application model as input. In addition to generating a component class, we are also going to generate a bit of HTML. We could to this with some basic writeLine(...) statements, but there is also a handy extension that makes generating HTML code more fun.

npm install @yellicode/html --save-dev

Create a new file named side-nav.template.ts in the codegen directory.

import * as path from 'path';
import { Generator, NameUtility } from '@yellicode/templating';
import { TypeScriptWriter } from '@yellicode/typescript';
import { AngularWriter, ComponentConfig } from '@yellicode/angular';
import { HtmlWriter } from '@yellicode/html';
import { AppModel } from './app-model';

const componentBaseName = 'SideNav'; // feel free to make this a TopNav, BottomNav or SomeWhereElseNav...
const kebabCaseComponentName = NameUtility.camelToKebabCase(componentBaseName);
const componentSubDir = path.join('../src/app/components', kebabCaseComponentName);
const fileBaseName = `${kebabCaseComponentName}.component`;

Generator.getModel<AppModel>().then((model: AppModel) => {

  // 1. Generate the component class
  Generator.generate({ outputFile: `${path.join(componentSubDir, fileBaseName)}.ts` }, (output) => {
    const writer = new TypeScriptWriter(output);

    // Import Angular components    
    writer.writeImports('@angular/core', ['Component']);
    writer.writeLine();

    // Write the component class with a @Component decorator
    const componentConfig: ComponentConfig = {
      selector: kebabCaseComponentName,
      templateUrl: `./${fileBaseName}.html`     
    };

    AngularWriter.writeComponentDecorator(writer, componentConfig);    
    writer.writeClassBlock({ name: `${componentBaseName}Component`, export: true }, () => {
      // Nothing here yet
    });
  });

  // 2. Generate the template
  Generator.generate({ outputFile: `${path.join(componentSubDir, fileBaseName)}.html` }, (output) => {
    const writer = new HtmlWriter(output);    
    writer.writeElement('ul', { classNames: 'nav-items', attributes: { style: 'width: 150px;' } }, () => {
      model.components
        .filter(c => c.createRoute && c.showInSideNav)
        .forEach(c => {
          writer.writeElement('li', {}, () => {
            const path = (c.routeUrl == undefined) ? NameUtility.camelToKebabCase(c.name) : c.routeUrl; 
            writer.writeElement('a', { attributes: { routerLink: path } }, c.title || c.name)
          });
        });
    });
  });
});

This code creates a HTML list with a Angular 'routerLink' for each route. It has little styling, so I leave that as an exercise for the reader. In order to use this component, lookup the following line of code in component-registration.template.ts:

const componentNames = model.components.map(c => c.name);

And add the following line below it:

componentNames.push('SideNav'); // add our custom side navigation

Then, add a new entry to the 'templates' section of the codegenconfig.json file and run yellicode. This time we will start yellicode in --watch mode so that changes we make are picked up automatically.

{
    "modelFile": "./codegen/app-model.json",
    "templateFile": "./codegen/side-nav.template.ts"
}    
yellicode --watch

Inspect the 'src/app/components' directory and notice our new SideNav component. The component is already registered automatically, so all that remains is adding the side nav to the application. Open up app.component.html and add replace the default Angular links with a <side-nav> element, making the file look like this:

<div style="text-align:center">
  <h1>
    Welcome to {{ title }}!
  </h1>
  <img width="300" alt="Angular Logo" src="data:image/svg+xml;....">
  <router-outlet></router-outlet>
  <side-nav></side-nav>
</div>    

If Angular is not still running, start it using the ng serve --open command and notice the new navigation.

Adding more components

Now that both Angular and Yellicode are running in the background, let's see the effect of updating our model. For example, add a few components to app-model.json and save it when you are done:

{
    "name": "About",
    "title": "About us",
    "createRoute": true,
    "showInSideNav": true
},
{
    "name": "Contact",
    "title": "Contact us",
    "createRoute": true,
    "showInSideNav": true
},
{
    "name": "Register",
    "title": "Register for free!",
    "createRoute": true,
    "showInSideNav": true
}

Wait a few seconds... Yellicode will generate 3 new components, Angular will detect those changes, recompile and refresh the browser. You've just added 3 pages without writing a single line of Angular code!

Making changes to generated code

Each time you run a code generation template, all output files are truncated and overwritten. This is nice for code scaffolding, but will break your application after you hand-edited these files: you don't want your own changes to be touched by a code generator anymore. To avoid yellicode overwriting component code, edit the codegenconfig.json file and set the outputMode of the components template entry to once:

{
    "templateFile": "./codegen/components.template.ts",
    "modelFile": "./codegen/app-model.json",
    "outputMode": "once"
}    

Generating (and injecting) services

This tutorial focused on generating components and routes, but we haven't discussed services yet. As you may have noticed, the application model also contains a "services" section and describes dependencies between components and services.

I won't discuss how to scaffold new services because the process is similar to (or, actually simpler than) the process for components. You will find the code generation template for this in the git repo for this tutorial.

So, what's next?

I hope you are impressed with the results so far. We've defined a custom meta model for our application and used it to generate components, routes and services. But so far, we haven't added much domain knowledge to the application. What if we wanted to generate services that perform CRUD operations against a REST API? How would we, for example, generate the TypeScript interfaces for the objects that these services need to deal with?

In the next post, I will show you how to achieve this with a simple but powerful UML model (use the box below to get notified)!

Comments powered by Talkyard.