[Из песочницы] Angular schematics, или как я писал свой шаблон для angular cli
Здравствуйте, меня зовут Максим. Уже несколько лет я занимаюсь 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 меня ждет очередной увлекательный проект. До новых встреч!