Сегодня я предлагаю окунуться в мир сборщика Grunt и попробовать написать для него какой-нибудь простенький плагин, а также тесты к нему. Для тех, кто не в курсе дел, Grunt.js — это сборщик проектов, написанный на JavaScript под Node.js.

В этой статье я не буду пользоваться генератором плагинов (npm grunt-init), который рекомендует официальная документация, так как пользы от него практически никакой нет.

Самое время подумать о функционале плагина. Мне кажется, что лучше всего для примера подойдёт компиляция markdown-файлов в HTML — это довольно простая и понятная задача: нужно прочитать указанный md-файл, скомпилировать его и записать на диск html-файл.

Документация

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

К слову, Gulp в этом ключе просто разбит вдребезги, так как та документация, что лежит на GitHub, подходит скорее не большому проекту, а скромному проектику от неизвестного до сих пор автора. Но это легко объясняется тем, что у Gulp практически нет API, а работу с потоками хорошо объясняет документация самого Node.js. Хотя, стоит признать, что хорошо оформленная документация — это полшага до победы.

Структура проекта и зависимости

Ничего сверхъестественного здесь не будет. Это просто ещё один пакет в npm, который может взаимодействовать с API, предоставленным Grunt.

grunt-plugin/ ├── tasks/ │ └── plugin.js ├── test/ │ ├── expected/ │ ├── fixtures/ │ └── test.js ├── Gruntfile.js └── package.json

В директории tasks будут находиться: файл плагина, который будет подключаться к Grunt, когда тот его вызовет; директория test, как не сложно догадаться, будет содержать файлы тестов; файл Gruntfile.js — это обычный Grunt файл, который может запускать, по необходимости, ваш плагин.

Первым делом сгенерируем файл package.json, выполнив в директории плагина команду npm init и ответив на несколько вопросов. Затем установим зависимости, необходимые для последующей разработки:

$ npm i -D grunt mocha rimraf

Для того, чтобы компилировать markdown в HTML будет использоваться пакет marked, так как он работает несколько быстрее, чем основной пакет markdown-js:

$ npm i -S marked

Внимание

Флаг -D является короткой записью --save-dev, а -S — это альтернатива --save.

Базовые моменты

Обратимся к сердцу нашего плагина — файлу tasks/plugin.js. Первое, что здесь необходимо уяснить, плагин для Grunt — это всё тот же Node.js, всё тот же JavaScript и всё тот же модуль. Поэтому первые три строчки будут стандартными для любого модуля:

'use strict';

module.exports = function(grunt) {
  // Тут что-то ещё появится
};

Как известно, Grunt выполняет задачи. Вся работа Grunt всегда основана на задачах. Любой распространяемый в сети плагин для Grunt — это задача, оформленная как пакет для npm. Наша задача — создать задачу.

grunt.registerMultiTask(taskName, [description, ] taskFunction)

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

'use strict';

module.exports = function(grunt) {
  grunt.registerMultiTask('plugin', function() {
    console.log('Plugin!');
  });
};

Практически, это весь код, что отличает плагин для Grunt от любого другого пакета, распространяемого через npm.

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

module.exports = function (grunt) {
  grunt.initConfig({
    plugin: {
      taskName: {
        src: 'test/fixtures/',
        dest: '.tmp/'
      }
    }
  });

  grunt.loadTasks('tasks');
  grunt.registerTask('default', ['plugin']);
};

Обратите внимание на то, что вместо метода loadNpmTasks(), который ищет задачу в директории node_modules, здесь используется метод loadTasks, производящий поиск в указанной директории. В данном случае это директория tasks.

Таким образом можно сделать одно очень важное заключение: любая задача, зарегистрированная вручную (с помощью Grunt API), автоматически является плагином. Поэтому ничто не мешает вам создавать в Grunt дополнительные задачи вручную, используя API.

Окей, давайте запустим наше творение:

$ grunt

В консоль будет выведено:

Создание плагина для Grunt: первый запуск

Работа с Grunt API

Разрабатывая плагин для Grunt, стоит максимально использовать доступный для разработчика API.

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

  • grunt.config — работа с конфигурацией (объектами)
  • grunt.event — обработка событий
  • grunt.fail — генерация ошибок
  • grunt.file — работа с файловой системой
  • grunt.log — вывод информации в консоль
  • grunt.util — различные вспомогательные утилиты

После регистрации задачи, следует обработать переданные в неё настройки из конфигурационного файла. Для этого у this есть специальный метод:

var options = this.options({
  tables: false
});

Эта функция позволяет объединить уже существующий объект конфигурации, заданный по умолчанию, с тем, что был создан пользователем в файле Gruntfile.js. Причем делается это, конечно же, с перезаписью уже существующих свойств.

Вообще, this предоставляет некоторые вспомогательные возможности для выполняемой задачи:

{ 
  nameArgs: 'plugin:taskName',
  name: 'plugin',
  args: [],
  flags: {},
  async: [Function],
  errorCount: [Getter],
  requires: [Function: bound ],
  requiresConfig: [Function],
  options: [Function],
  target: 'taskName',
  data: { 
    src: 'test/fixtures/',
    dest: '.tmp/'
  },
  files: [ 
    { 
      src: [Getter],
      dest: '.tmp/',
      orig: [Object]
    }
  ],
  filesSrc: [Getter]
}

Наиболее интересными для нас свойствами будут: files и async.

Свойство files представляет собой массив объектов, содержащих информацию об обрабатываемых этой задачей файлах:

  • src — массив путей до файлов, которые нужно обработать
  • dest — директория, в которую нужно записать обработанный файл
  • orig — это оригинальный путь или выражение glob, с помощью которого был вычислен путь до файла

Внимание

Свойство filesSrc является альтернативой свойства files, но представляет собой не массив объектов, а массив путей до файлов, которые необходимо обработать. Это свойство может пригодиться в том случае, если плагину не требуется ничего записывать на диск. Также, оно может быть полезно в *Lint- или *Hint-плагинах, чтобы избежать лишнего цикла.

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

var done = this.async();

fs.readFile('path/path.txt', function(err, data) {
  done();
});

Грубо говоря, done здесь является маячком того, что Grunt должен подождать завершения асинхронной функции. В противном случае ваша задача завершится ещё до того, как, допустим, файл будет прочитан.

Создадим два тестовых файла и сконфигурируем Grunt так, чтобы он находил их. Я не буду вдаваться в подробности конфигурации плагинов, так как раз вы решились написать плагин для Grunt, то вы в состоянии понять, что здесь написано. Пример такой конфигурации рассматривается в документации как «динамическое построение объекта файлов».

plugin: {
  taskName: {
    options: {
      tables: true
    },
    files: [{
      expand: true,
      cwd: 'test/fixtures',
      src: '*.md',
      ext: '.html',
      dest: '.tmp'
    }]
  }
}

Полный листинг кода плагина:

'use strict';

module.exports = function(grunt) {
  var marked = require('marked');

  grunt.registerMultiTask('plugin', function() {
    // Обработка объекта конфигурации
    var options = this.options({
      tables: false
    });

    // Конфигурация модуля `marked`
    marked.setOptions(options);

    this.files.forEach(function(f) {
      var src = f.src.map(function(filepath) {
        // Чтение файла с диска
        var data = grunt.file.read(filepath);
        return marked(data);
      });

      // Запись всех файлов на диск
      grunt.file.write(f.dest, src);
    });
  });
};

Первый цикл проходится по массиву объектов, содержащих информацию о файлах задачи (парах src:dest). Второй цикл проходится по массиву файлов, которые необходимо этой задаче обработать. Напомню, что метод Array.map() позволяет перебирать массив и возвращать обратно изменённое значение для каждого элемента. В переменную src записываются данные уже обработанных файлов. После обработки всех файлов вызывается метод grunt.file.write, который автоматически распределяет данные по файлам и записывает их на диск, позволяя разработчику не тратить время на вычисление пути, по которому необходимо записать файл.

Тестирование

Любой код должен тестироваться. Лучше всего, если вы будете делать это автоматически. Ранее я рассказывал как тестировать код, используя менеджер тестов AVA. К сожалению, начиная с версии 0.9.0 AVA стал слишком крутым и занимать на диске что-то около 30Мб. Всё бы ничего, но при этом установка через npm происходит дольше, чем выполняются все тесты в проекте. Поэтому место AVA для меня занял менеджер Mocha. Такое решение позволило снизить время тестов (для некоторых пакетов) с 5-6 минут до 3-х.

Как тестировать плагин? Очень просто — нужно запустить плагин, прочитать его результат работы (html-файл(ы)) и сравнить их с тем, что должно получиться. Для нашего плагина тесты пишутся очень просто. Простейший пример теста:

var assert = require('assert');
var grunt = require('grunt');

it('Default test', function() {
  // Читаем результат работы плагина
  var result = grunt.file.read('.tmp/test.html');
  // Читаем файл, содержащий ожидаемый результат
  var expected = grunt.file.read('test/expected/test.html');
  // Сравниваем оба файла
  assert.equal(result, expected);
});

Теперь необходимо правильно запустить тесты. В файле package.json есть секция scripts, в которую нужно написать следующее:

"scripts": {
  "test": "rimraf .tmp && grunt && mocha test/test.js"
},

Модуль rimraf удалит результаты прошлых тестов, grunt запустит наш плагин, а mocha протестирует результат его работы.

Пишем ваш первый плагин для Grunt: Запуск тестов

Запустить тест можно командой $ npm test или $ npm t.

Внимание

Пожалуйста, не забудьте исключить тестовые файлы из пакета, распространяемого через npm. Это можно сделать, используя секцию "files": [] в package.json, которая представляет собой «белый лист». Например, так: "files": ["tasks"]. Файл README.md и LICENSE автоматически будут включены в пакет. Подробнее об этом можно узнать в документации к npm.

Весь код, описанный в этой статье доступен в репозитории на GitHub.

Мои плагины

Что почитать?