Пишем свой генератор для Yeoman

В настоящее время вариативность рабочих процессов в фронтенд-разработки достаточно большая: кто-то уже использует препроцессоры CSS, а кто-то им пока еще не доверяет; кто-то всем сердцем любит CoffeeScript, а кто-то — не очень; кто-то проверяет свой код перед релизом, а кто-то нет. С появлением инструментов автоматизации процесса разработки и управления компонентами, вроде Grunt, Bower и им подобных, я условно начал подразделять рабочие процессы на ручные и автоматизированные. Ручной процесс включает в себя только ручной скаффолдинг и более механическую работу с исходными файлами, а автоматизированный, в свою очередь, замешан на использовании вышеперечисленных инструментов автоматизации. Думаю, в этом разделении вы со мной согласитесь.

Конечно, видов рабочего процесса всего два, а вот его реализаций гораздо больше. Опять же, кто-то будет руками копировать HTML5 Boilerplate, кто-то будет использовать свою персональную заготовку, а кто-то и вовсе использует для скаффолдинга проекта Yeoman с навинченными на него Grunt и Bower. И эта статья как раз для тех, кто пользуется последним подходом или только хочет попробовать его применить.

Yeoman

Я уже писал ранее статью о том, что такое Yeoman и как его правильно использовать. В этой статье я буду полагать, что читатель уже ознакомился с ней. Как вы уже знаете, Yeoman – это не просто скаффолдер для проекта, он несет в себе целый рабочий процесс, конкретную реализацию которого вы задаёте, используя определённый генератор. К примеру, рассмотренный в предыдущей статье генератор webapp имеет наиболее общие задачи для Grunt, которые, скорее всего, вам придется дополнить своими. На текущий момент разработано уже достаточно много генераторов для Yeoman, чтобы попробовать подобрать решение для конкретного специфичного процесса. Но, если ничего подходящего не нашлось, а вы всё ещё чувствуете острую необходимость в более тонком инструменте и полны энтузиазма, то, возможно, имеет смысл написать свой собственный генератор.

Перед тем как мы начнем, я хотел бы акцентировать ваше внимание на некоторых условиях. Я считаю, что вам нужно писать свой генератор лишь в том случае, если:

  1. Ваш рабочий процесс настолько уникален, что требует того, что ещё не было разработано ранее и выложено на GitHub или в npm;
  2. Ваш рабочий процесс, используемый в новом проекте обязательно повторится в одном или нескольких следующих проектах, иначе работа над спецификой при написании генератора теряет всякий смысл;
  3. При всех вышеперечисленных причинах вы готовы потратить неопределённое количество времени на поиск и тестирование подходящих вам компонентов, таких как npm-пакеты и Bower-компоненты;
  4. У вас осталось желание сделать это, не смотря на перечисленные выше пункты. Иначе, советую не слишком утруждаться, и просто воспользоваться уже имеющимся генератором, подправив затем Gruntfile.js и npm-пакеты так, чтобы они подходили под ваш процесс.

Итак, вы всё же решили написать свой генератор. Замечательно, давайте приступим! Для начала, нужно понимать принцип работы любого скаффолдера. Скаффолдеру отдают на выполнение некий сценарий, содержащий описание процесса развёртки нового проекта в понятиях, определённых через его API.

Для извлечения максимальной пользы из статьи я буду показывать работающие примеры, которые в итоге будут собраны в мой персональный генератор. Для этого мне необходимо сформулировать требования к рабочему процессу, который я буду воспроизводить в генераторе. Рассмотренный рабочий процесс несколько упрощён относительно привычного мне, но вполне достаточен для демонстрации возможностей Yeoman. В статье не будут рассмотрены все «внутренности» Yeoman, но эта статья станет хорошим подспорьем для автоматизации механических рабочих процессов, а именно — процесса скаффолдинга. Обзор основных функциональных частей будет несколько разбросан относительно структуры самого скрипта генератора, и структурирован относительно процесса работы генератора в целом. Часть примеров будет представлена в статье. Слишком громоздкие примеры кода я выложу отдельно на GitHub и дам ссылку на исходники.

Требования:

Yo generator

Как бы забавно это ни звучало, но существует отдельный генератор генераторов для Yeoman. Это npm-пакет generator-generator, который на основе всё того же Yeoman разворачивает вам шаблон нового генератора. В целом, для начала работы над задуманным генератором вам необходимо установить yo и generator-generator. Делаем это следующей командой:

npm install -g yo generator-generator

Как известно, все генераторы для Yeoman должны иметь подобное именование: generator-name, где name — название генератора. В дальнейшем для использования генератора нужно будет выполнить yo name. Я веду к тому, что вам нужно определиться с названием вашего генератора и создать директорию вида generator-name (в моём случае это будет generator-frontender) и запустить из неё тот самый генератор для генераторов, выполнив следующую команду:

yo generator

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

Инициализация нового генератора Yeoman

Инициализация нового генератора в Yeoman

По завершении опроса генератор создаст необходимые папки, файлы и установит npm-пакеты, необходимые для работы проектного генератора. В итоге вы получите некоторое начальное состояние будущего генератора с несколькими шаблонами и конфигурационными файлами. У вас получится приблизительно следующая структура файлов:

├── app
│   ├── index.js
│   └── templates
│       ├── _bower.json
│       ├── _package.json
│       ├── editorconfig
│       └── jshintrc
├── node_modules 
|	└──  /* Необходимые модули */
├── test
│   ├── test-creation.js
│   └── test-load.js
├── .editorconfig
├── .gitattributes
├── .gitignore
├── .jshintrc
├── .travis.yml
├── LICENSE
├── package.json
└── README.md

На первых порах наибольший интерес для нас представляют файл index.js и файлы из директории app/templates — шаблоны нашего генератора. Вполне логично, что главным файлом, описывающим логику работы вашего генератора является index.js в директории app. Он будет иметь схожее с приведенным содержание:

'use strict';
var util = require('util');
var path = require('path');
var yeoman = require('yeoman-generator');

var FrontenderGenerator = module.exports = function FrontenderGenerator(args, options, config) {
  yeoman.generators.Base.apply(this, arguments);

  this.on('end', function () {
    this.installDependencies({ skipInstall: options['skip-install'] });
  });

  this.pkg = JSON.parse(this.readFileAsString(path.join(__dirname, '../package.json')));
};

util.inherits(FrontenderGenerator, yeoman.generators.Base);

FrontenderGenerator.prototype.askFor = function askFor() {
  var cb = this.async();

  // have Yeoman greet the user.
  console.log(this.yeoman);

  var prompts = [{
    type: 'confirm',
    name: 'someOption',
    message: 'Would you like to enable this option?',
    default: true
  }];

  this.prompt(prompts, function (props) {
    this.someOption = props.someOption;

    cb();
  }.bind(this));
};

FrontenderGenerator.prototype.app = function app() {
  this.mkdir('app');
  this.mkdir('app/templates');

  this.copy('_package.json', 'package.json');
  this.copy('_bower.json', 'bower.json');
};

FrontenderGenerator.prototype.projectfiles = function projectfiles() {
  this.copy('editorconfig', '.editorconfig');
  this.copy('jshintrc', '.jshintrc');
};

Сейчас генератор обладает небольшим функционалом — он может создать несколько директорий и скопировать туда файлы из директории с шаблонами. Чтобы было удобнее продолжать процесс разработки генератора, советую выполнить команду npm link в его директории. Команда создаст символическую ссылку на генератор и поместит её в директорию глобальных npm-пакетов, что сделает ваш генератор доступным для выполнения из любого каталога на жёстком диске. Теперь директория генератора не обязательно должна будет находиться физически в директории для глобальных модулей.

Можете попробовать запустить генератор. Делается это, конечно же, с помощью команды yo name. У меня это будет yo frontender. Yeoman спросит вас «Would you like to enable this option?». Это тестовый вопрос, ответ на него ни на что не влияет. В ходе выполнения скаффолдинга наш генератор создаст директории app и app/templates, а также скопирует файлы шаблонов в корень и переименует их. В прочем, пока эти файлы и папки сами по себе бесполезны нам, мы вернёмся к ним немного позднее. А пока давайте повнимательнее рассмотрим скрипт app/index.js.

Вопросы к пользователю

В первую очередь рассмотрим вопросы, задаваемые пользователю в процессе скаффолдинга — это первое, что видит пользователь генератора. Как вы успели заметить, сейчас в сценарии содержится всего один вопрос, тестовый:

var prompts = [{
	type: 'confirm',
	name: 'someOption',
	message: 'Would you like to enable this option?',
	default: true
}];

Переменная prompts является массивом, который содержит отдельные объекты для каждого из вопросов, задаваемых пользователю. Для реализации вопросов к пользователю Yeoman использует библиотеку Inquirer.js. Она облегчает процесс опроса и обработки ответов в Node.js.

Каждый объект-вопрос в массиве prompts имеет следующие свойства:

Типы вопросов

Существует несколько типов вопросов к пользователю: list, rawlist, expand, checkbox, confirm, input и password. Все типы вопросов имеют обязательные и опциональные свойства. В нижеследующем описании вопросов я приведу опциональные свойства в квадратных скобках […].

List — { type: "list" }

У этого типа вопроса следующие свойства: type, name, message, choices, [default, filter]. Помните, что значением по умолчанию (default) должен быть индекс элемента в массиве пунктов списка choices.

Пример вопроса типа «List»

Пример вопроса типа «List»

Raw List - { type: "rawlist" }

У этого типа вопроса следующие свойства: type, name, message, choices, [default, filter]. Точно так же, как и с предыдущим типом, значением по умолчанию (default) должен быть индекс элемента в массиве пунктов списка choices.

Пример вопроса типа «Rawlist»

Пример вопроса типа «Rawlist»

Expand - { type: “expand” }

У этого типа вопроса следующие свойства: type, name, message, choices, [default, filter]. Значение по умолчанию (default) — индекс элемента в массиве пунктов списка choices. Также имейте ввиду, что объект массива choices должен иметь свойство key. Это свойство должно содержать один печатный символ в нижнем регистре. При этом свойство с символом h уже добавлено по умолчанию и не следует его определять самостоятельно.

Пример вопроса типа «Expand»

Пример вопроса типа «Expand»

Checkbox - { type: "checkbox" }

У этого типа вопроса следующие свойства: type, name, message, choices, [default, filter, validate]. В данном типе свойство default должно быть массивом с элементами, указывающими на элементы массива пунктов списка choices, которые по умолчанию будут считаться выбранными. Также элементы массива списка пунктов со свойством checked: true будут отмечены автоматически.

Пример вопроса типа «Checkbox»

Пример вопроса типа «Checkbox»

Confirm - { type: "confirm" }

У этого типа вопроса следующие свойства: type, name, message, [default]. Предполагается, что если свойство default определено, оно имеет булево значение.

Пример вопроса типа «Confirm»

Пример вопроса типа «Confirm»

Input - { type: "input" }

У этого типа вопроса следующие свойства: type, name, message, [default, filter, validate].

Пример вопроса типа «Input»

Пример вопроса типа «Input»

Password - { type: “password” }

У этого типа вопроса следующие свойства: type, name, message, [default, filter, validate].

Пример вопроса типа «Password»

Пример вопроса типа «Password»

Более подробные нюансы использования этого модуля вы можете найти в его официальной документации на английском языке.

Согласно требованиям к рабочему процессу, который будет реализовывать мой генератор, я составил массив вопросов, необходимых в сценарии:

var prompts = [{
	type: 'list',
	name: 'framework',
	message: 'Какой фреймворк будем использовать?',
	choices: [{
	    name: 'Foundation 5',
	    value: 'foundation'
	  }, {
	    name: 'Twitter Bootstrap 3',
	    value: 'bootstrap'
	}],
	default: 0
}, {
	type: 'confirm',
	name: 'sass',
	message: 'Будем использовать Sass?',
	default: true
}, {
	type: 'checkbox',
	name: 'features',
	message: 'Выбери дополнительные компоненты:',
	choices: [{
		name: 'Modernizr',
		value: 'modernizr',
		checked: true
	}, {
		name: 'Autoprefixer',
		value: 'autoprefixer',
		checked: true
	}]
}];

Ответы

Рядом с массивом вопросов можно обнаружить функцию обработки ответов:

this.prompt(prompts, function (props) {
	this.someOption = props.someOption;

	cb();
}.bind(this));

Метод prompt принимает в качестве аргументов массив с вопросами prompts и функцию, которая описывает, как поступить с полученными ответами. Объект, который принимает эта функция, содержит ответы на вопросы. Ответы — это свойства этого объекта, которые носят имена, соответствующие свойству name объекта вопроса. Используя значения по умолчанию к вопросам генератора frontender, внутри функции будет получен следующий объект props:

{
	framework: 'foundation',
	sass: true,
	features: ['modernizr', 'autoprefixer']
}

Метод получения ответов заключается в вынесении этих значений в глобальную область видимости для дальнейшего использования. Этим и займёмся:

this.prompt(prompts, function (props) {

	function hasFeature(feat) { return props.features.indexOf(feat) !== -1; }

	this.framework = props.framework;
	this.sass = props.sass;
	this.modernizr = hasFeature('modernizr');
	this.autoprefixer = hasFeature('autoprefixer');

	cb();
}.bind(this));

Таким образом, мы получаем ответы от пользователя для дальнейшего использования их в сценарии и при обработке шаблонов. Функция hasFeature — простой способ выяснить, присутствует ли каждый из дополнительных компонентов в ответах.

Работа с файлами

Теперь мы можем должным образом развернуть проект, опираясь на ответы, полученные от пользователя. Наверняка вы уже заметили в файле app/index.js подобные объявления:

FrontenderGenerator.prototype.askFor = function askFor() {
 	// …
};

FrontenderGenerator.prototype.app = function app() {
	// …
};

В качестве методов прототипа объявляются функции, описывающие действия, которые должен выполнять генератор. Важным нюансом является то, что объявленные методы будут вызываться в том порядке, в котором они описаны в сценарии. Никто не запрещает создать всего один метод, но обычно, действия разделяют на отдельные методы для удобного представления. Например, в скрипте пока ещё имеется пара методов прототипа:

FrontenderGenerator.prototype.app = function app() {
	this.mkdir('app');
	this.mkdir('app/templates');
	this.copy('_package.json', 'package.json');
	this.copy('_bower.json', 'bower.json');
};

FrontenderGenerator.prototype.projectfiles = function projectfiles() {
	this.copy('editorconfig', '.editorconfig');
	this.copy('jshintrc', '.jshintrc');
};

В примерах выше приведены два метода работы с файлами в определениях Yeoman:

Это две наиболее часто употребляемые команды при работе со статичными файлами и директориями. Вам, возможно, ничего другого больше и не понадобится, но на всякий случай, остальные команды вы можете найти в документация к API.

Работа с шаблонами

Первым делом стоит отметить, что в официальной документации предлагается именовать файлы-шаблоны с использованием знака подчеркивания в начале. Это довольно символично, поскольку в Yeoman за обработку шаблонов отвечает шаблонизатор из Lo-Dash.

Сам шаблон какого-либо файла будет иметь в коде директивы и операторы, воспринимаемые шаблонизатором, вроде этой — <%= pkg.version %>. Также, из сценариев вам доступны другие функциональные возможности Lo-Dash, вроде этой — <%= _.slugify(someVar) %>. Обработка шаблонов закреплена за методом template:

FrontenderGenerator.prototype.app = function app() {
	//…
	this.template('Gruntfile.js', 'Gruntfile.js');
	this.template('index.html', 'index.html');
	//…
};

Аналогично с функцией this.copy, template в качестве первого параметра принимает имя файла-шаблона в директории templates, в качестве второго — имя выходного файла, обработанного шаблонизатором.

Шаблонами могут быть любые текстовые файлы как то: package.json, bower.json, Gruntfile.js и другие конфигурационные файлы. Если вам не нравится концепция шаблонов, можете писать в файлы непосредственно из генератора при помощи метода this.write().

Применив вышеперечисленные методы для работы с файлами к генератору frontender, я получил довольно объёмные функции.

Завершение сценария

Ключевой особенностью скаффолдера Yeoman является то, что он тесно взаимодействует с Grunt и Bower в момент инициализации проекта. В index.js вы найдёте метод установки зависимостей, вызываемый по завершении выполнения всех функций сценария:

this.on('end', function () {
	this.installDependencies({ skipInstall: options['skip-install'] });
});

Метод installDependencies запускает загрузку и установку как npm-пакетов, так и Bower-компонентов в случае, если загрузка не отменена опцией skip-install при запуске скаффолдера. Если установка модулей была отменена использованием опции skip-install, пользователь вашего генератора сможет установить их позднее, запустив самостоятельно команды установки пакетов для npm и Bower.Как я и говорил, package.json, bower.json и Gruntfile.js также могут быть шаблонами, и в них аналогично можно использовать ответы пользователя. В генераторе frontender они также являются шаблонами, вы можете изучить эти шаблоны более подробно на GitHub.

Суб-генераторы

Суб-генераторы — это самостоятельные генераторы, не имеющие логического предназначения без основного генератора. Отличный пример — генератор и суб-генераторы AngularJS. В нём суб-генераторы отвечают за добавление компонентов приложения, таких как маршруты, контроллеры, представления и т.д. Также суб-генераторы используются для разбиения логики генерации приложения на более мелкие самостоятельные части, вызываемые основным генератором. Пример такого использования можно найти в генераторе Backbone.js. Итак, если вашему генератору требуются суб-генераторы, нижеследующие действия приведут к желаемому результату.

  1. Из директории с вашим генератором выполните: yo generator:subgenerator "name", где name — имя суб-генератора.
  2. Откройте {name}/index.js. Структура файла аналогична основному генератору, поэтому, написав основной генератор, вам не составит труда написать логику суб-генератора. Все правила написания логики генератора, описанные ранее, работают и для суб-генераторов.
  3. Вызов команды yo {your_generator}:{your_subgenerator} "args" приведёт к запуску созданного вами суб-генератора your_subgenerator генератора your_generator с аргументами args.

Yo frontender

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

Генератор выложен на GitHub и опубликован в npm. Для его установки выполните:

npm install -g generator-frontender

Для запуска выполните yo frontender.

Что можно еще прочесть и посмотреть по этой теме