[Из песочницы] Angular schematics, или как я писал свой шаблон для angular cli

?v=1

Здравствуйте, меня зовут Максим. Уже несколько лет я занимаюсь front-end разработкой. Мне часто приходится иметь дело с версткой различных html шаблонов. В своей повседневной работе я обычно пользуюсь сборщиком webpack c настроенным шаблонизатором pug, а также использую методологию BEM. Для того чтобы облегчить себе жизнь использую замечательный пакет.

Недавно нужно было сделать небольшой проект на Angular, а так как я привык работать со своими любимыми инструментами, возвращаться на голый html не хотелось. В связи с чем появилась задача как подружить bempug с ангуляром, и не просто подружить, но еще и генерировать компоненты из cli с необходимой мне структурой.

Кому интересно как я все это провернул добро пожаловать под кат.

Для начала создадим тестовый проект, на котором будем тестировать наш шаблон.

Выполняем в командной строке:

ng g test-project.

В настройках я выбрал препроцессор scss, так как мне с ним удобнее работать.

Проект создался, но шаблоны компонентов по умолчанию у нас в html, сейчас поправим. Первым делом, нужно подружить angular cli с шаблонизатором pug, для этого я использовал пакет ng-cli-pug-loader

Установим пакет, для этого заходим в папку проекта и выполняем:

ng add ng-cli-pug-loader.

Теперь можно использовать pug файлы шаблонов. Далее переписываем декоратор root компонента AppComponent на:

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

Соответственно меняем расширение файла app.component.html на app.component.pug, и содержание прописываем в синтаксисе шаблонизатора. В данном файле я удалил все кроме роутера.

Займемся наконец созданием нашего генератора компонентов!

Для генерации шаблонов нам необходимо создать свою схему. Я использую пакет schematics-cli из @angular-devkit. Устанавливаем пакет глобально командой:

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

Схему я создал в отдельной дирректории вне проекта командой:

schematics blank --name=bempug-component.

Заходим в созданную схему, нас сейчас интересует файл src/collection.json. Выглядит он так:

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

Это файл описания нашей схемы, где параметр «factory»:»./bempug-component/index#bempugComponent»: это описание основной функции «фабрики» нашего генератора.

Изначально он выглядит как то так:

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;
    };
}

Можно сделать у функции экспорт по умолчанию, тогда параметр «factory» можно переписать как »./bempug-component/index ».

Далее в директории нашей схемы создаем файл schema.json, он будет описывать все параметры нашей схемы.

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

Параметры находятся в properties, a именно:


  • name имя сущности (в нашем случае это будет компонент);
  • Path это путь по которому генератор создаст файлы компонента ;
  • Project это сам проект, в котором будет сгенерирован компонент;

Добавим в файл еще несколько параметров, которые понадобятся в дальнейшем.

"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?"
}


  • module тут будет хранится ссылка на модуль в который будет включатся компонент, а точнее модуль компонента ;
  • componentModule тут флаг создавать ли для компонента собственный модуль (дальше я пришел к выводу что он будет создаваться всегда и установил его в true);
  • export: это флаг экспортировать ли из модуля в который мы делам импорт нашего модуля компонента;

Дальше создаем интерфейс с параметрами нашего компонента файл schema.d.ts.

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

В нем свойства дублируют свойства из schema.json. Далее подготовим нашу фабрику, переходим в файл index.ts. В нем создаем две функции filterTemplates, которая будет отвечать за то создавать ли модуль для компонента в зависимости от значения componentModule, и setupOptions, которая настраивает параметры необходимые для фабрики.

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;

}

Далее в основную функцию прописываем:

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);
  }
}

Фабрика готова и она уже может генерировать файлы компонентов обрабатывая шаблоны из папки files, которой пока нет. Это не беда, создаем в папке нашей схемы в моем случае это bempug-component папку files. В папке files создаем папку __name@dasherize__, при генерации фабрика заменит __name@dasherize__ на имя компонента.

Далее внутри папки __name@dasherize__ создаем файлы


  • __name@dasherize__.component.pug pug шаблон компонента
  • __name@dasherize__.component.spec.ts файл юнит теста для компонента
  • __name@dasherize__.component.ts файл самого компонента
  • __name@dasherize__-component.module.ts модуль компонента
  • __name@dasherize__-component.scss файл стилей компонента

Теперь добавим в нашу фабрику поддержку обновления модулей, для этого создадим файл add-to-module-context.ts, для хранения параметров, которые понадобятся фабрике для работы с модулем.

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;
}

Добавляем поддержку модулей в фабрику.

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);
  }
}

Теперь при добавлении параметра -m <ссылка на модуль> к сli команде, наш модуль компонента будет добавлять импорт в указанный модуль, а при добавлении флага –export добавлять экспорт из него. Дальше добавим поддержку BEM. Для этого я взял исходники npm пакета bempug и сделал код в одном файле bempugMixin.pug, который поместил в папку common и внутри в еще одну папку common, чтобы миксин копировался в папку common в проекте на ангуляр.

Наша задача, чтобы данный миксин подключался в каждом нашем файле шаблона, и не дублировался при генерации новых компонентов, для этого добавим в нашу фабрику этот функционал.

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);
  }
}

Самое время заняться наполнением наших файлов шаблонов.

__name@dasherize__.component.pug:

include <%= bemPugMixinPath %>

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

То что указанно в <% = %> при генерации заменится на имя компонента .

__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();
    });
});

В данном случае <%= classify(name) %> применяется для приведения имени к 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 %>{

}

Делаем билд нашей схемы командой ``npm run build```.

Все готово для генерации компонентов в проекте!

Для проверки заходим обратно в наш Angular проект создаем модуль.
ng g m test-schema
Далее делаем ``npm link <абсолютный путь к папке проекта с нашей схемой>```, для того чтобы добавить нашу схему в node_modules проекта.

И пробуем схему командой ng g bempug-component:bempug-component test -m /src/app/test-schema/test-schema.module.ts –export.
Наша схема создаст компонент и добавит его в указанный модуль с экспортом.
Схема готова, можно начинать делать приложение на привычных технологиях.

Посмотреть итоговую версию можно вот тут, а также пакет доступен в npm.

При создании схемы использовал статьи по данной теме, выражаю большую благодарность авторам.

Спасибо за внимание, всем кто дочитал до конца, вы лучшие!
A меня ждет очередной увлекательный проект. До новых встреч!

© Habrahabr.ru