Здравствуйте, меня зовут Максим. Уже несколько лет я занимаюсь 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.
При создании схемы использовал статьи по данной теме, выражаю большую благодарность авторам.
- https://blog.angular.io/schematics-an-introduction-dc1dfbc2a2b2;
- https://medium.com/@tomastrajan/total-guide-to-custom-angular-schematics-5c50cf90cdb4;
- https://developer.okta.com/blog/2019/02/13/angular-schematics;
Спасибо за внимание, всем кто дочитал до конца, вы лучшие!
A меня ждет очередной увлекательный проект. До новых встреч!
