Angular schematics, or as I wrote my template for angular cli

Hello, my name is Maxim. For several years I have been doing front-end development. I often have to deal with the layout of various html templates. In my daily work, I usually use the webpack builder with a customized pug template engine, and I also use the BEM methodology. In order to make my life easier I use a wonderful package .







Recently I needed to make a small project on Angular, and since I was used to working with my favorite tools, I did not want to return to the bare html. In this connection, the problem arose of how to make bempug friends with an angular, and not just make friends, but also generate components from cli with the structure I needed.







Who cares how I did it all, welcome to cat.







To begin, create a test project on which we will test our template.







We execute at the command line:







ng g test-project



.







In the settings, I chose the scss preprocessor, since it is more convenient for me to work with it.







The project was created, but the default component templates in our html, now fix it. First of all, you need to make angular cli friends with the pug template engine, for this I used the ng-cli-pug-loader package







Install the package, for this, go to the project folder and execute:







ng add ng-cli-pug-loader



.







Now you can use pug template files. Next, we rewrite the root decorator of the AppComponent component to:







  @Component({ selector: 'app-root', templateUrl: './app.component.pug', styleUrls: ['./app.component.scss'] })
      
      





Accordingly, we change the file extension app.component.html to app.component.pug, and the content is written in the template syntax. In this file, I deleted everything except the router.







Finally, let's start creating our component generator!







To generate templates, we need to create our own scheme. I am using the schematics-cli package from @ angular-devkit. Install the package globally with the command:







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



.







I created the scheme in a separate directory outside the project with the command:







schematics blank --name=bempug-component



.







We go into the created scheme, we are now interested in the src / collection.json file. It looks like this:







  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json", "schematics": { "bempug-component": { "description": "A blank schematic.", "factory": "./bempug-component/index#bempugComponent" } } }
      
      





This is a description file of our scheme, where the parameter is "factory": "./bempug-component/index#bempugComponent": this is the description of the main function of the "factory" of our generator.







Initially, it looks something like this:







 import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; // You don't have to export the function as default. You can also have more than one rule factory // per file. export function bempugComponent(options: any): Rule { return (tree: Tree, _context: SchematicContext) => { return tree; }; }
      
      





You can make the function export by default, then the parameter "factory" can be rewritten as "./bempug-component/index".







Next, in the directory of our scheme, create the file schema.json, it will describe all the parameters of our scheme.







 { "$schema": "http://json-schema.org/schema", "id": "SchemanticsForMenu", "title": "Bempug Schema", "type": "object", "properties": { "name": { "type": "string", "$default": { "$source": "argv", "index": 0 } }, "path": { "type": "string", "format": "path", "description": "The path to create the component.", "visible": false }, "project": { "type": "string", "description": "The name of the project.", "$default": { "$source": "projectName" } } } }
      
      





Parameters are in properties, namely:









Add a few more parameters to the file that will be needed in the future.







 "module": { "type": "string", "description": "The declaring module.", "alias": "m" }, "componentModule": { "type": "boolean", "default": true, "description": "Patern module per Component", "alias": "mc" }, "export": { "type": "boolean", "default": false, "description": "Export component from module?" }
      
      







Next, we create an interface with the parameters of our component, the schema.d.ts file.







 export interface BemPugOptions { name: string; project?: string; path?: string; module?: string; componentModule?: boolean; module?: string; export?: boolean; bemPugMixinPath?: string; }
      
      





In it, properties duplicate properties from schema.json. Next, prepare our factory, go to the index.ts file. In it, we create two filterTemplates functions, which will be responsible for creating a module for a component depending on the value of componentModule, and setupOptions, which sets up the parameters necessary for the factory.







 function filterTemplates(options: BemPugOptions): Rule { if (!options.componentModule) { return filter(path => !path.match(/\.module\.ts$/) && !path.match(/-item\.ts$/) && !path.match(/\.bak$/)); } return filter(path => !path.match(/\.bak$/)); } function setupOptions(options: BemPugOptions, host: Tree): void { const workspace = getWorkspace(host); if (!options.project) { options.project = Object.keys(workspace.projects)[0]; } const project = workspace.projects[options.project]; if (options.path === undefined) { const projectDirName = project.projectType === 'application' ? 'app' : 'lib'; options.path = `/${project.root}/src/${projectDirName}`; } const parsedPath = parseName(options.path, options.name); options.name = parsedPath.name; options.path = parsedPath.path; }
      
      





Next, we write in the main function:







 export function bempugComponent(options: BemPugOptions): Rule { return (host: Tree, context: SchematicContext) => { setupOptions(options, host); const templateSource = apply(url('./files'), [ filterTemplates(options), template({ ...strings, ...options }), move(options.path || '') ]); const rule = chain([ branchAndMerge(chain([ mergeWith(templateSource), ])) ]); return rule(host, context); } }
      
      





The factory is ready and it can already generate component files by processing templates from the files folder, which is not yet available. It doesnโ€™t matter, we create a bempug-component files folder in our scheme folder in my case. In the files folder, create the folder __name@dasherize__



, during generation, the factory will replace __name@dasherize__



with the name of the component.







Next, inside the __name@dasherize__



create files









Now we will add support for updating modules to our factory, for this we will create the add-to-module-context.ts file to store the parameters that the factory will need to work with the module.







 import * as ts from 'typescript'; export class AddToModuleContext { // source of the module file source: ts.SourceFile; // the relative path that points from // the module file to the component file relativePath: string; // name of the component class classifiedName: string; }
      
      





Add module support to the factory.







 const stringUtils = { dasherize, classify }; // You don't have to export the function as default. You can also have more than one rule factory // per file. function filterTemplates(options: BemPugOptions): Rule { if (!options.componentModule) { return filter(path => !path.match(/\.module\.ts$/) && !path.match(/-item\.ts$/) && !path.match(/\.bak$/)); } return filter(path => !path.match(/\.bak$/)); } function setupOptions(options: BemPugOptions, host: Tree): void { const workspace = getWorkspace(host); if (!options.project) { options.project = Object.keys(workspace.projects)[0]; } const project = workspace.projects[options.project]; if (options.path === undefined) { const projectDirName = project.projectType === 'application' ? 'app' : 'lib'; options.path = `/${project.root}/src/${projectDirName}`; } const parsedPath = parseName(options.path, options.name); options.name = parsedPath.name; options.path = parsedPath.path; options.module = options.module || findModuleFromOptions(host, options) || ''; } export function createAddToModuleContext(host: Tree, options: ModuleOptions, componentPath: string): AddToModuleContext { const result = new AddToModuleContext(); if (!options.module) { throw new SchematicsException(`Module not found.`); } // Reading the module file const text = host.read(options.module); if (text === null) { throw new SchematicsException(`File ${options.module} does not exist.`); } const sourceText = text.toString('utf-8'); result.source = ts.createSourceFile(options.module, sourceText, ts.ScriptTarget.Latest, true); result.relativePath = buildRelativePath(options.module, componentPath); result.classifiedName = stringUtils.classify(`${options.name}ComponentModule`); return result; } function addDeclaration(host: Tree, options: ModuleOptions, componentPath: string) { const context = createAddToModuleContext(host, options, componentPath); const modulePath = options.module || ''; const declarationChanges = addImportToModule( context.source, modulePath, context.classifiedName, context.relativePath); const declarationRecorder = host.beginUpdate(modulePath); for (const change of declarationChanges) { if (change instanceof InsertChange) { declarationRecorder.insertLeft(change.pos, change.toAdd); } } host.commitUpdate(declarationRecorder); }; function addExport(host: Tree, options: ModuleOptions, componentPath: string) { const context = createAddToModuleContext(host, options, componentPath); const modulePath = options.module || ''; const exportChanges = addExportToModule( context.source, modulePath, context.classifiedName, context.relativePath); const exportRecorder = host.beginUpdate(modulePath); for (const change of exportChanges) { if (change instanceof InsertChange) { exportRecorder.insertLeft(change.pos, change.toAdd); } } host.commitUpdate(exportRecorder); }; export function addDeclarationToNgModule(options: ModuleOptions, exports: boolean, componentPath: string): Rule { return (host: Tree) => { addDeclaration(host, options, componentPath); if (exports) { addExport(host, options, componentPath); } return host; }; } export function bempugComponent(options: BemPugOptions): Rule { return (host: Tree, context: SchematicContext) => { setupOptions(options, host); deleteCommon(host); const templateSource = apply(url('./files'), [ filterTemplates(options), template({ ...strings, ...options }), move(options.path || '') ]); const rule = chain([ branchAndMerge(chain([ mergeWith(templateSource), addDeclarationToNgModule(options, !!options.export, `${options.path}/${options.name}/${options.name}-component.module` || '') ])) ]); return rule(host, context); } }
      
      





Now, when adding the -m <module reference> parameter to the cli command, our component module will add import to the specified module and add the export from it when adding the โ€“export flag. Next, add BEM support. To do this, I took the sources for the npm bempug package and made the code in one bempugMixin.pug file, which I placed in the common folder and inside in another common folder so that the mixin is copied to the common folder in the project on the angular.







Our task is that this mixin is connected in each of our template files, and not duplicated when generating new components, for this we add this functionality to our factory.







 import { Rule, SchematicContext, Tree, filter, apply, template, move, chain, branchAndMerge, mergeWith, url, SchematicsException } from '@angular-devkit/schematics'; import {BemPugOptions} from "./schema"; import {getWorkspace} from "@schematics/angular/utility/config"; import {parseName} from "@schematics/angular/utility/parse-name"; import {normalize, strings} from "@angular-devkit/core"; import { AddToModuleContext } from './add-to-module-context'; import * as ts from 'typescript'; import {classify, dasherize} from "@angular-devkit/core/src/utils/strings"; import {buildRelativePath, findModuleFromOptions, ModuleOptions} from "@schematics/angular/utility/find-module"; import {addExportToModule, addImportToModule} from "@schematics/angular/utility/ast-utils"; import {InsertChange} from "@schematics/angular/utility/change"; const stringUtils = { dasherize, classify }; // You don't have to export the function as default. You can also have more than one rule factory // per file. function filterTemplates(options: BemPugOptions): Rule { if (!options.componentModule) { return filter(path => !path.match(/\.module\.ts$/) && !path.match(/-item\.ts$/) && !path.match(/\.bak$/)); } return filter(path => !path.match(/\.bak$/)); } function setupOptions(options: BemPugOptions, host: Tree): void { const workspace = getWorkspace(host); if (!options.project) { options.project = Object.keys(workspace.projects)[0]; } const project = workspace.projects[options.project]; if (options.path === undefined) { const projectDirName = project.projectType === 'application' ? 'app' : 'lib'; options.path = `/${project.root}/src/${projectDirName}`; } const parsedPath = parseName(options.path, options.name); options.name = parsedPath.name; options.path = parsedPath.path; options.module = options.module || findModuleFromOptions(host, options) || ''; options.bemPugMixinPath = buildRelativePath(`${options.path}/${options.name}/${options.name}.component.ts`, `/src/app/common/bempugMixin.pug`); } export function createAddToModuleContext(host: Tree, options: ModuleOptions, componentPath: string): AddToModuleContext { const result = new AddToModuleContext(); if (!options.module) { throw new SchematicsException(`Module not found.`); } // Reading the module file const text = host.read(options.module); if (text === null) { throw new SchematicsException(`File ${options.module} does not exist.`); } const sourceText = text.toString('utf-8'); result.source = ts.createSourceFile(options.module, sourceText, ts.ScriptTarget.Latest, true); result.relativePath = buildRelativePath(options.module, componentPath); result.classifiedName = stringUtils.classify(`${options.name}ComponentModule`); return result; } function addDeclaration(host: Tree, options: ModuleOptions, componentPath: string) { const context = createAddToModuleContext(host, options, componentPath); const modulePath = options.module || ''; const declarationChanges = addImportToModule( context.source, modulePath, context.classifiedName, context.relativePath); const declarationRecorder = host.beginUpdate(modulePath); for (const change of declarationChanges) { if (change instanceof InsertChange) { declarationRecorder.insertLeft(change.pos, change.toAdd); } } host.commitUpdate(declarationRecorder); }; function addExport(host: Tree, options: ModuleOptions, componentPath: string) { const context = createAddToModuleContext(host, options, componentPath); const modulePath = options.module || ''; const exportChanges = addExportToModule( context.source, modulePath, context.classifiedName, context.relativePath); const exportRecorder = host.beginUpdate(modulePath); for (const change of exportChanges) { if (change instanceof InsertChange) { exportRecorder.insertLeft(change.pos, change.toAdd); } } host.commitUpdate(exportRecorder); }; export function addDeclarationToNgModule(options: ModuleOptions, exports: boolean, componentPath: string): Rule { return (host: Tree) => { addDeclaration(host, options, componentPath); if (exports) { addExport(host, options, componentPath); } return host; }; } function deleteCommon(host: Tree) { const path = `/src/app/common/bempugMixin.pug`; if(host.exists(path)) { host.delete(`/src/app/common/bempugMixin.pug`); } } export function bempugComponent(options: BemPugOptions): Rule { return (host: Tree, context: SchematicContext) => { setupOptions(options, host); deleteCommon(host); const templateSource = apply(url('./files'), [ filterTemplates(options), template({ ...strings, ...options }), move(options.path || '') ]); const mixinSource = apply(url('./common'), [ template({ ...strings, ...options }), move('/src/app/' || '') ]); const rule = chain([ branchAndMerge(chain([ mergeWith(templateSource), mergeWith(mixinSource), addDeclarationToNgModule(options, !!options.export, `${options.path}/${options.name}/${options.name}-component.module` || '') ]), 14) ]); return rule(host, context); } }
      
      





It's time to fill in our template files.







__name@dasherize__.component.pug



:







 include <%= bemPugMixinPath %> +b('<%= name %>') +e('item', {m:'test'}) | <%= name %> works
      
      





What is specified in <% =%> during generation will be replaced by the name of the component.







__name@dasherize__.component.spec.ts:









 import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import { <%= classify(name) %>ComponentModule } from './<%= name %>-component.module'; import { <%= classify(name) %>Component } from './<%= name %>.component'; describe('<%= classify(name) %>Component', () => { let component: <%= classify(name) %>Component; let fixture: ComponentFixture<<%= classify(name) %>Component>; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [<%= classify(name) %>ComponentModule], declarations: [], schemas: [ NO_ERRORS_SCHEMA ] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(<%= classify(name) %>Component); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); });
      
      





In this case, <% = classify (name)%> is used to cast the name to CamelCase.







__name@dasherize__.component.ts:









 import { Component, OnInit, ViewEncapsulation} from '@angular/core'; @Component({ selector: 'app-<%=dasherize(name)%>-component', templateUrl: '<%=dasherize(name)%>.component.pug', styleUrls: ['./<%=dasherize(name)%>-component.scss'], encapsulation: ViewEncapsulation.None }) export class <%= classify(name) %>Component implements OnInit { constructor() {} ngOnInit(): void { } }
      
      





__name@dasherize__-component.module.ts:









 import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import {<%= classify(name) %>Component} from './<%= name %>.component'; @NgModule({ declarations: [ <%= classify(name) %>Component, ], imports: [ CommonModule ], exports: [ <%= classify(name) %>Component, ] }) export class <%= classify(name) %>ComponentModule { }
      
      





__name@dasherize__-component.scss:









 .<%= name %>{ }
      
      





We make the build of our scheme with the command `` npm run build``.







Everything is ready to generate components in the project!







To check, go back to our Angular project and create a module.

ng gm test-schema





Next, we do `` npm link <absolute path to the project folder with our scheme> '', in order to add our scheme to the node_modules of the project.







And we try the circuit with the ng g bempug-component:bempug-component test -m /src/app/test-schema/test-schema.module.ts โ€“export



.

Our scheme will create a component and add it to the specified module with export.

The scheme is ready, you can start making the application on familiar technologies.







You can see the final version here , and also the package is available in npm .







When creating the scheme, I used articles on this topic, I express my gratitude to the authors.









Thank you for your attention, everyone who read to the end, you are the best!

And another exciting project awaits me. See you soon!








All Articles