Ещё один пост о сборке front-end проекта

от автора

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

Что умеет делать сборщик:
Собирать front-end проект для development & production окружений. Собирать по несколько js/css бандлов на проект. Использовать стиль CommonJS модулей в браузере. Использовать ES6-синтаксис. Спрайты, картинки и многое другое

Вступительное
Чтобы было удобней следить за мыслью, сразу кидаю ссылку на репозиторий с шаблоном проекта: github.com/alexfedoseev/js-app-starter

Как его завестиУбедитесь, что установлен npm.
npm -v

Установите необходимые глобальные модули (если ещё не установлены):
npm install -g gulp browserify babel jade stylus http-server

Сделайте форк репозитория.
git clone https://github.com/alexfedoseev/js-app-starter.git

Установите зависимости проекта (исполнять в корне репозитория):
npm install

Соберите проект в development окружении и запустите локальный сервер:
npm start

Откройте браузер и перейдите на lvh.me:3500

В качестве сборщика будем использовать Gulp.
Что включает процесс сборки и какие технологии используются:
Сборка HTML
Шаблонизатор: Jade Сборка CSS
Препроцессор: Stylus
Префиксер: Autoprefixer Сборка JS
Модульная система: Browserify + Babel (ES6 transpiler)
Проверка качества кода: jsHint Оптимизация изображений
Оптимизатор: Imagemin При необходимости: сборка спрайтов, обработка json, копирование фонтов и прочих файлов в public папку
Сборщик спрайтов: Spritesmith
Обработка json: gulp-json-editor
Вобще я люблю Slim и Sass, но Ruby к Ruby, a JS к JS: для frontend-проекта будем использовать только штуки из npm. При желании любой инструмент можно заменить.

Структура проекта
| dist/ | lib/ |— gulp/ |— helpers/ |— tasks/ |— config.js | node_modules/ | public/ |— css/ |— files/ |— fonts/ |— img/ |— js/ |— json/ |— favicon.ico |— index.html | src/ |— css/ |— files/ |— fonts/ |— html/ |— img/ |— js/ |— json/ |— sprite/ |— favicon.ico | .gitignore | .npmignore | gulpfile.js | npm-shrinkwrap.json | package.json Github

.gitignore & .npmignore
Внутри этих файлов находится список того, что будет игнорироваться git и npm при коммитах/паблишах.

node_modules/
В эту директорию падают все модули, которые мы установим через npm.

npm-shrinkwrap.json
Я не держу в репозитории содержимое node_modules/. Вместо этого лочу все зависимости через этот файл. Он генерируется автоматически командой: `npm shrinkwrap`.

package.json
Это файл с глобальными настройками проекта. К нему ещё вернемся.

gulpfile.js
Обычно тут хранятся все таски для сборки проекта, но в нашем случае он просто определяет значение переменной окружения и пробрасывает нас дальше в папку с gulp-тасками.

lib/gulp/
Здесь храним все настройки и задачи сборщика.

|— config.js
Выносим настройки для всех тасков в отдельный файл, чтобы минимизировать правку самих тасков.

|— helpers/
Вспомогательные методы сборщика.

|— tasks/
И сами gulp-таски.

src/
Исходники проекта.

public/
Результат сборки. Абсолютно всё содержимое этой папки генерируется сборщиком и перед каждой новой сборкой она полностью очищается, поэтому тут никогда и ничего не храним.

dist/
Иногда я пишу opensource-модули. В этой папке после сборки оказываются обычная и минифицированная версии написанной js-библиотеки. При этом директория public/ используется как хранилище для демки. Если вы делаете обычный сайт или страницу приземления, то оно не понадобится.

Настройка проекта
package.json
Это файл, в котором хранятся глобальные настройки проекта.
Подробное описание его внутренностей можно посмотреть тут: browsenpm.org/package.json
Ниже я остановлюсь только на некоторых важных частях.

{ // Название проекта "name": "js-app-starter", // Версия проекта // Использую для версионирования модулей / обновления js+css в кэше браузера при обновлении версии сборки "version": "0.0.1", // Если вы пишете js-библиотеку, то тут указываем путь к файлу, // который будет отзываться на `require(‘your-lib’)` "main": "./dist/app.js", // Настойки browserify // В данном случае говорим, что нужно перед сборкой превратить ES6 в ES5 "browserify": { "transform": [ "babelify" ] }, // Консольные команды (подробнее ниже) "scripts": { "start": "NODE_ENV=development http-server -a lvh.me -p 3500 & gulp", "build": "NODE_ENV=production gulp build" }, // Настройки jshint (проверка качества кода) "lintOptions": { "esnext": true … }, // Frontend зависимости "dependencies": { "jquery": "^2.1.3" … }, // Development зависимости "devDependencies": { "gulp": "^3.8.11" … } } Github

Консольные команды
В package.json мы можем прописать алиасы для консольных команд, которые будем часто выполнять в процессе разработки.

"scripts": { "start": "NODE_ENV=development http-server -a lvh.me -p 3500 & gulp", "build": "NODE_ENV=production gulp build" }

Development сборка
Перед началом работы с проектом нам нужно:
собрать его из исходников (с sourcemaps для дебага) запустить «наблюдателей», которые будут пересобирать проект при изменении исходных файлов запустить локальный сервер
# команда, которую исполняем npm start # что исполняется на самом деле NODE_ENV=development http-server -a lvh.me -p 3500 & gulp
Разбираем по частям# устанавливаем переменную окружения NODE_ENV=development # запускаем локальный сервер на домене lvh.me и порте 3500 http-server -a lvh.me -p 3500 # запускаем gulp таски gulp

Production сборка
Когда мы готовы релизить проект — делаем production-сборку.

# нажмите Ctrl+C, чтобы остановить локальный сервер и наблюдателей, если они запущены # команда, которую исполняем npm run build # что исполняется на самом деле NODE_ENV=production gulp build
Разбираем по частям# устанавливаем переменную окружения NODE_ENV=production # запускаем gulp-таск `build` gulp build

Gulp
Переходим к Gulp. Структура тасков взята из сборщика от Dan Tello.

Перед тем, как нырнуть, небольшой комментарий по порядку выполнения обычного gulp-таска:

var gulp = require(‘gulp’); gulp.task(‘task_1’, [‘pre_task_1’, ‘pre_task_2’], function() { console.log(‘task_1 is done’); }); // Здесь мы объявили `task_1`, который выводит в консоль сообщение `task_1 is done` // Запускается он командой `gulp task_1` // Но перед выполнением основного `task_1` должны выполниться задачи `[‘pre_task_1’, ‘pre_task_2’]` // Важно понимать, что ‘pre_task_1’ & ‘pre_task_2’ — выполняются асинхронно, // то есть порядок выполнения не зависит от позиции задачи в массиве, // а `task_1` стартует только после того, как отработали 2 pre-задачи — то есть синхронно

Теперь разберемся что и в каком порядке будем собирать.

Development сборка
`npm start` запускает команду `gulp`. Что происходит дальше:

Gulp ищет в текущей директории gulpfile.js. Обычно в него складываются все таски, но здесь он просто определит значение переменной окружения и пробросит нас дальше в папку с gulp-тасками.

Код с комментариями/* file: gulpfile.js */ // модуль, позволяющий включать таски из вложенных директорий var requireDir = require(‘require-dir’); // устанавливаем значение глобальной переменной, // позволяющей различать в тасках development & production окружения global.devBuild = process.env.NODE_ENV !== ‘production’; // пробрасываем сборщик в папку с тасками и конфигом requireDir(‘./lib/gulp/tasks’, { recurse: true }); Github

После того, как нас пробросило в директорию, сборщик ищет таск с названием `default`, который сначала запускает «наблюдателей» над исходниками, потом:
очищает папки `public/` & `dist/` линтит js-файлы и собирает спрайты
После этого собирается проект (html, css, js и всё остальное).

Код с комментариямиdefault

/* file: lib/gulp/tasks/default.js */ var gulp = require(‘gulp’); // Запускаем пустой таск `default`, но предварительно исполняем таск `watch` gulp.task(‘default’, [‘watch’]); Github

watch

/* file: lib/gulp/tasks/watch.js */ var gulp = require(‘gulp’), finder = require(‘../helpers/finder’), // хелпер для поиска файлов config = require(‘../config’); // конфиг // Запускаем таск `watch`, перед ним исполняем таски `watching` & `build` gulp.task(‘watch’, [‘watching’, ‘build’], function() { // Вешаем наблюдателей на все файлы в директориях `css`, `images` & `html` // При изменении одного из файлов в указанной директории gulp выполнит соответствующий таск gulp.watch(finder(config.css.src), [‘css’]); gulp.watch(finder(config.images.src), [‘images’]); gulp.watch(finder(config.html.src), [‘html’]); }); gulp.task(‘watching’, function() { // Объявляем глобальную переменную `isWatching`, // которая сигнализирует, что наблюдатели запущены global.isWatching = true; }); Github

build

/* file: lib/gulp/tasks/build.js */ var gulp = require(‘gulp’); // Запускаем таск `build`, перед ним исполняем таски: // `clean` — перед сборкой очищаем директории `public/` & `dist/` // `lint` — проходимся jshint по js-файлам (проверка качества кода) // `sprite` — собираем спрайты gulp.task(‘build’, [‘clean’, ‘lint’, ‘sprite’], function() { // После того, как отработали три таска выше, запускается таск `bundle` // Вобще метод `gulp.start` deprecated, // но нормальное управление sync/async задачами появится только в Gulp 4.0, // поэтому используем пока его gulp.start(‘bundle’); }); // Собираем проект gulp.task(‘bundle’, [‘scripts’, ‘css’, ‘images’, ‘html’, ‘copy’], function() { // Если мы в dev-окружении, то после сборки выставляем значение переменной `doBeep` = true // `notifier` хелпер покажет нам уведомления об ошибках или окончании работы тасков // (в консоли и всплывающим баннером) if (devBuild) global.doBeep = true; }); Github
Production сборка
С ней всё проще. `npm run build` запускает команду `gulp build`, которая очищает целевые папки, линтит js-код, собирает спрайты и после этого собирет проект (без sourcemaps). Код с комментариями выше.

Файл конфигураций gulp-тасков
Все основные конфигурации тасков вынесены в отдельный файл lib/gulp/config.js:

/* file: lib/gulp/config.js */ var pkg = require(‘../../package.json’), // импортируем package.json bundler = require(‘./helpers/bundler’); // импортируем хелпер для созлания бандлов /* Настраиваем пути */ var _src = ‘./src/’, // путь до исходников _dist = ‘./dist/’, // куда будем сохранять дистрибутив будущей библиотеки _public = ‘./public/’; // куда будем сохранять сайт или примеры использования библиотеки var _js = ‘js/’, // папка с javascript файлами _css = ‘css/’, // папка с css _img = ‘img/’, // папка с картинками _html = ‘html/’; // папка с html /* * Настраиваем js / css бандлы * * Пример: app.js, app.css — сайт * admin.js, admin.css — админка * * Пример: your-lib.js — модуль без зависимостей * your-lib.jquery.js — модуль в формате jquery-плагина * */ var bundles = [ { name : ‘app’, // название бандла global : ‘app’, // если пишем модуль, это имя объекта, экспортируемого в глобальное пространство имён compress : true, // минифицируем? saveToDist : true // сохраняем в папку `/dist`? (true — если пишем модуль, false — если делаем сайт) } ]; module.exports = { /* тут настройки тасков */ }; Github

Сборка HTML
Для шаблонизации используем Jade. Он позволяет делать вставки партиалов, использовать inline-javascript, переменные, миксины и ещё много разных крутых штук.

GulpКонфиг

/* file: lib/gulp/config.js */ html: { src: _src + _html, // путь до jade-исходников dest: _public, // куда сохраняем собранное params: { // параметры для jade pretty: devBuild, // убиваем отступы в html? locals: { // переменные, которые мы передаем в шаблоны pkgVersion: pkg.version // сохраняем версию релиза в переменную `pkgVersion` } } } Github

Таск

/* file: lib/gulp/tasks/html.js */ var gulp = require(‘gulp’), jade = require(‘gulp-jade’), jadeInherit = require(‘gulp-jade-inheritance’), gulpif = require(‘gulp-if’), changed = require(‘gulp-changed’), filter = require(‘gulp-filter’), notifier = require(‘../helpers/notifier’), config = require(‘../config’).html; gulp.task(‘html’, function(cb) { // берём все jade-файлы из директории src/html gulp.src(config.src + ‘*.jade’) // если dev-сборка, то watcher пересобирает только изменённые файлы .pipe(gulpif(devBuild, changed(config.dest))) // корректно обрабатываем зависимости .pipe(jadeInherit({basedir: config.src})) // отфильтровываем не-партиалы (без `_` вначале) .pipe(filter(function(file) { return !/\/_/.test(file.path) || !/^_/.test(file.relative); })) // преобразуем jade в html .pipe(jade(config.params)) // пишем html-файлы .pipe(gulp.dest(config.dest)) // по окончании запускаем функцию .on(‘end’, function() { notifier(‘html’); // уведомление (в консоли + всплывашка) cb(); // gulp-callback, сигнализирующий о завершении таска }); }); Github
ИсходникиСтруктура папки src/html

| src |— html |— index.jade # скелет страницы |— components/ # компоненты страницы |— _header.jade |— helpers/ # переменные, миксины |— _params.jade |— _mixins.jade |— meta/ # содержимое head, коды аналитики и пр. |— _head.jade Github

Все партиалы снабжаем префиксом `_` (нижнее подчеркивание), чтобы при сборке мы могли их отфильтровать и игнорировать.

helpers/_variables.jade
Сохраняем необходимые параметры в переменные. Например, если у нас телефон стоит в нескольких местах страницы, то его лучше сохранить в переменную и в шаблонах использовать именно её.

/* file: src/html/helpers/_variables.jade */ — var release = pkgVersion // переменная из gulp-конфига — var phone = ‘8 800 CALL-ME-NOW’ // телефон Github

helpers/_mixins.jade
Часто используемые блоки можно обернуть в mixin.

/* file: src/html/helpers/_mixins.jade */ mixin phoneLink(phoneString) — var cleanPhone = phoneString.replace(/\(|\)|\s|\-/g, ») a(href="tel:#{cleanPhone}")= phoneString // в верстке вставляем // +phoneLink(phone) Github

index.jade
Скелет главной страницы.

/* file: src/html/index.jade */ include helpers/_variables // импортируем переменные include helpers/_mixins // импортируем миксины doctype html html head include meta/_head body include components/_header include components/_some_component include components/_footer Github

meta/_head.jade
Содержимое head.

/* file: src/html/meta/_head.jade */ meta(charset="utf-8") … // Используем версию сборки, если нужно обновить js/css в кэше браузеров link(rel="stylesheet" href="css/app.min.css?v=#{release}") script(src="js/app.min.js?v=#{release}") … Github

Сборка JavaScript
В качестве модульной системы используем Browserify. C ним мы можем использовать стиль подключения CommonJS модулей непосредственно в браузере. Кроме этого мы теперь можем использовать ES6-синтаксис: Babel преобразует его в ES5 перед тем, как Browserify соберет js. И перед сборкой мы проходимся jsHint для проверки качества кода.

У Browserify есть один минус: если вы пишете библиотеку с внешними зависимостями (например jQuery-плагин), то он не сможет сделать правильную UMD-обертку. В этом случае я заменяю Browserify на конкатенацию и пишу обёртку руками.

О бандлахНа проекте может возникнуть необходимость формировать несколько наборов js/css.

Например вы пишите фронт + админку. Или библиотеку в 2 вариантах: без зависимостей и в формате jQuery-плагина. Эти сборки нужно разделять. Для этого в настройках сборщика мы создаем массив:

/* file: lib/gulp/config.js */ /* Для библиотеки */ var bundles = [ { name : ‘myLib’, // название бандла global : ‘myLib’, // это имя объекта, экспортируемого в глобальное пространство имён compress : true, // минифицируем? (неминифицированная версия сохранятся всегда) saveToDist : true // сохраняем в папку `/dist`? } ]; /* Для сайта / страницы приземления */ var bundles = [ { name : ‘app’, // название бандла global : false, // ничем отсвечивать не надо compress : true, // минифицируем? saveToDist : false // сохраняем в папку `/dist`? }, name : ‘admin’, global : false, compress : true, saveToDist : false } ]; Github

js/css cборщики будут искать в папке с js/css исходниками соответствующий end-point файл (`app.js` или `app.styl`). Через этот end-point файл мы управляем всеми зависимостями бандла. Их структуру я покажу чуть ниже.

Перед передачей бандлов сборщику, мы предварительно пропускаем массив через хелпер `bundler`, который формирует объект с настройками.GulpКонфиг

/* file: lib/gulp/config.js */ scripts: { bundles: bundler(bundles, _js, _src, _dist, _public), // пакуем бандлы banner: ‘/** ‘ + pkg.name + ‘ v’ + pkg.version + ‘ **/\n’, // задаем формат баннера для min.js extensions: [‘.jsx’], // указываем дополнительные расширения lint: { // параметры для jshint options: pkg.lintOptions, dir: _src + _js } } Github

Таск

/* file: lib/gulp/tasks/scripts.js */ var gulp = require(‘gulp’), browserify = require(‘browserify’), watchify = require(‘watchify’), uglify = require(‘gulp-uglify’), sourcemaps = require(‘gulp-sourcemaps’), derequire = require(‘gulp-derequire’), source = require(‘vinyl-source-stream’), buffer = require(‘vinyl-buffer’), rename = require(‘gulp-rename’), header = require(‘gulp-header’), gulpif = require(‘gulp-if’), notifier = require(‘../helpers/notifier’), config = require(‘../config’).scripts; gulp.task(‘scripts’, function(cb) { // считаем кол-во бандлов var queue = config.bundles.length; // поскольку бандлов может быть несколько, оборачиваем сборщик в функцию, // которая в качестве аргумента принимает bundle-объект с параметрами // позже запустим её в цикл var buildThis = function(bundle) { // отдаем bundle browserify var pack = browserify({ // это для sourcemaps cache: {}, packageCache: {}, fullPaths: devBuild, // путь до end-point (app.js) entries: bundle.src, // если пишем модуль, то через этот параметр // browserify обернет всё в UMD-обертку // и при подключении объект будет доступен как bundle.global standalone: bundle.global, // дополнительные расширения extensions: config.extensions, // пишем sourcemaps? debug: devBuild }); // сборка var build = function() { return ( // browserify-сборка pack.bundle() // превращаем browserify-сборку в vinyl .pipe(source(bundle.destFile)) // эта штука нужна, чтобы нормально работал `require` собранной библиотеки .pipe(derequire()) // если dev-окружение, то сохрани неминифицированную версию в `public/` (зачем — не помню)) .pipe(gulpif(devBuild, gulp.dest(bundle.destPublicDir))) // если сохраняем в папку `dist` — сохраняем .pipe(gulpif(bundle.saveToDist, gulp.dest(bundle.destDistDir))) // это для нормальной работы sourcemaps при минификации .pipe(gulpif(bundle.compress, buffer())) // если dev-окружение и нужна минификация — инициализируем sourcemaps .pipe(gulpif(bundle.compress && devBuild, sourcemaps.init({loadMaps: true}))) // минифицируем .pipe(gulpif(bundle.compress, uglify())) // к минифицированной версии добавляем суффикс `.min` .pipe(gulpif(bundle.compress, rename({suffix: ‘.min’}))) // если собираем для production — добавляем баннер с названием и версией релиза .pipe(gulpif(!devBuild, header(config.banner))) // пишем sourcemaps .pipe(gulpif(bundle.compress && devBuild, sourcemaps.write(‘./’))) // сохраняем минифицированную версию в `/dist` .pipe(gulpif(bundle.saveToDist, gulp.dest(bundle.destDistDir))) // и в `public` .pipe(gulp.dest(bundle.destPublicDir)) // в конце исполняем callback handleQueue (определен ниже) .on(‘end’, handleQueue) ); }; // если нужны watchers if (global.isWatching) { // оборачиваем browserify-сборку в watchify pack = watchify(pack); // при обновлении файлов из сборки — пересобираем бандл pack.on(‘update’, build); } // в конце сборки бандла var handleQueue = function() { // сообщаем, что всё собрали notifier(bundle.destFile); // если есть очередь if (queue) { // уменьшаем на 1 queue—; // если бандлов больше нет, то сообщаем, что таск завершен if (queue === 0) cb(); } }; return build(); }; // запускаем массив бандлов в цикл config.bundles.forEach(buildThis); }); Github
ИсходникиСтруктура папки src/js

| src/ |— js/ |— components/ # код компонентов |— helpers/ # js-хелперы |— app.js # end-point бандла Github

app.js
Через этот файл мы рулим всеми зависимостями и порядком исполнения js-компонентов. Имя файла должно совпадать с именем бандла.

/* file: src/js/app.js */ /* Vendor */ import $ from ‘jquery’; /* Components */ import myComponent from ‘./components/my-component’; /* App */ $(document).ready(() => { myComponent(); }); Github
Что делать, если зависимости нет в npmВ таких случаях используем browserify-shim: плагин, который позволяет превращать обычные библиотеки в CommonJS-совместимые модули. Итак, у нас есть jQuery-плагин `maskedinput`, которого нет в npm.

Добавляем в `package.json` преобразование и выставляем настройки для зависимости:

/* file: package.json */ "browserify": { "transform": [ "babelify", "browserify-shim" // добавляем преобразование ] }, // у `browserify-shim` много вариантов подключения библиотек // смотрите доки на github: https://github.com/thlorenz/browserify-shim "browser": { "maskedinput": "./path/to/jquery.maskedinput.js" }, "browserify-shim": { "maskedinput": { "exports": "maskedinput", "depends": [ "jquery:jQuery" ] } }

После этого мы можем подключать модуль:
require(‘maskedinput’);

Сборка CSS
В качестве препроцессора используем Stylus. Плюс проходимся по css автопрефиксером, чтобы не прописывать вендорные префиксы руками.

GulpКонфиг

/* file: lib/gulp/config.js */ css: { bundles: bundler(bundles, _css, _src, _dist, _public), // пакуем бандлы src: _src + _css, // указываем где лежать исходники для watcher params: {}, // если нужны настройки для stylus — указываем тут autoprefixer: { // настраиваем autoprefixer browsers: [‘> 1%’, ‘last 2 versions’], // подо что ставим префиксы cascade: false // красиво не надо, всё равно минифицируем }, compress: {} // если нужны настройки минификации — указываем тут } Github

Таск

/* file: lib/gulp/tasks/css.js */ var gulp = require(‘gulp’), process = require(‘gulp-stylus’), prefix = require(‘gulp-autoprefixer’), compress = require(‘gulp-minify-css’), gulpif = require(‘gulp-if’), rename = require(‘gulp-rename’), notifier = require(‘../helpers/notifier’), config = require(‘../config’).css; /* Логика css-таска повторяет логику js-таска */ gulp.task(‘css’, function(cb) { var queue = config.bundles.length; var buildThis = function(bundle) { var build = function() { return ( gulp.src(bundle.src) .pipe(process(config.params)) .pipe(prefix(config.autoprefixer)) .pipe(gulpif(bundle.compress, compress(config.compress))) .pipe(gulpif(bundle.compress, rename({suffix: ‘.min’}))) .pipe(gulp.dest(bundle.destPublicDir)) .on(‘end’, handleQueue) ); }; var handleQueue = function() { notifier(bundle.destFile); if (queue) { queue—; if (queue === 0) cb(); } }; return build(); }; config.bundles.forEach(buildThis); }); Github
ИсходникиСтруктура папки src/css

| src/ |— css/ |— components/ # стили компонентов |— header.styl |— footer.styl |— globals/ |— fonts.styl # подключаем фонты |— global.styl # глобальные настройки проекта |— normalize.styl # нормализуем / ресетим |— variables.styl # переменные |— z-index.styl # z-индексы проекта |— helpers/ |— classes.styl # вспомогательные классы |— mixins.styl # и миксины |— sprite/ |— sprite.json # json, генерируемый gulp.spritesmith |— sprite.styl # создаем из json css-классы |— vendor/ # вендорные css складываем сюда |— app.styl # end-point бандла Github

app.styl
Через этот файл мы рулим порядком подключения css-компонентов. Имя файла должно совпадать с именем бандла.

/* file: src/css/app.styl */ @import "helpers/mixins" @import "helpers/classes" @import "globals/variables" @import "globals/normalize" @import "globals/z-index" @import "globals/fonts" @import "globals/global" @import "sprite/sprite" @import "vendor/*" @import "components/*" Github

Все остальные таски — картинки, спрайты, очистка и пр. — не требуют дополнительных комментариев (на самом деле я просто устал уже строчить). Исходники лежат в репозитории: github.com/alexfedoseev/js-app-starter

Если есть косяки или дополнения — буду рад обратной связи через комментарии тут или issues / pull requests на Github. Удач! http://habrahabr.ru/post/251807/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *