В прошлой статье я рассказал про новый бандлер Parcel, который не требует конфигурирования и готов к бою сразу после установки. Но что делать, если вдруг стандартного набора ассетов не хватает? Ответ прост — написать свой плагин.
Напомню, что из коробки нам доступны следующие ассеты:
- JavaScript (Babel), CoffeeScript, TypeScript
- CSS, LESS, SASS, Stylus
- HTML
- JSON, YAML
- Glob, Raw
Для создания своего ассета мы можем выбрать два пути — использовать существующий (JSAsset, HTMLAsset и т.д.), в котором переписать или дописать часть логики, или написать с нуля, взяв за основу класс Asset.
В качестве примера я расскажу, как был написан плагин для Pug.
Немного теории
Для начала нужно разобраться, каким образом Parcel работает с плагинами и что они вообще могут делать?
При инициализации бандлера (Bundler) происходит поиск пакетов в package.json, начинающихся с parcel-plugin-
. Каждый найденый пакет бандлер подключает и вызывает экспортированную функцию, передавая ей свой контекст. В этой функции мы и можем зарегистрировать свой ассет.
Наш ассет должен реализовать следующие методы:
/**
* Преобразует содержимое файла в AST
*/
parse(code: string): Ast
/**
* Находит пути подключения других ассетов и модифицирует их
*/
collectDependencies(): void
/**
* Преобразует итоговый AST в строку
*/
generate(): Object
А так же можно реализовать необязательные методы:
/**
* Вызывается перед применением трансформаций AST
*/
pretransform(): void
/**
* Метод для внесения изменений в AST
*/
transform(): void
Как работать с Pug AST
Для работы с AST есть несколько официальных пакетов:
pug-load
— загружает текст и отдает его лексеру и парсеруpug-lexer
— разбирает текст на токеныpug-parser
— превращает массив токенов в ASTpug-linker
— склеивает несколько AST вместе, нужен для работыinclude
иextends
pug-walk
— позволяет ходить по AST и модифицировать егоpug-сode-gen
— генерирует HTML при помощи JavaScript-функцииpug-runtime
— содержит функциюwrap
, которая позволяет обернуть и выполнить функцию, возвращаемую отpug-сode-gen
Плагин
Создадим следующую структуру проекта:
parcel-plugin-pug
├── package.json
├── src
│ ├── PugAsset.ts
│ ├── index.ts
│ └── modules.d.ts
├── tsconfig.json
└── tslint.json
Файл index.ts
будет являться точкой входа в наш плагин:
export = (bundler: any) => {
bundler.addAssetType('pug', require.resolve('./PugAsset'));
bundler.addAssetType('jade', require.resolve('./PugAsset'));
};
Для работы с ассетом нам понадобится базовый класс Asset
. Напишем TypeScript-обвязку для нужных нам модулей:
declare module 'parcel-bundler/src/Asset' {
class Asset {
constructor(name: string, pkg: string, options: any);
parse(code: string): any;
addDependency(path: string, options: Object): any;
addURLDependency(url: string): string;
name: string;
isAstDirty: boolean;
contents: string;
ast: any;
options: any;
dependencies: Set<Object>;
}
export = Asset;
}
declare module 'parcel-bundler/src/utils/is-url' {
function isURL(url: string): boolean;
export = isURL;
}
declare module 'pug-load' {
class load {
static string(str: string, options?: any): any;
}
export = load;
}
declare module 'pug-lexer' {
class Lexer {}
export = Lexer;
}
declare module 'pug-parser' {
class Parser {}
export = Parser;
}
declare module 'pug-walk' {
function walkAST(ast: any, before?: (node: any, replace?: any) => void, after?: (node: any, replace?: any) => void, options?: any): void;
export = walkAST;
}
declare module 'pug-linker' {
function link(ast: any): any;
export = link;
}
declare module 'pug-code-gen' {
function generateCode(ast: any, options: any): string;
export = generateCode;
}
declare module 'pug-runtime/wrap' {
function wrap(template: string, templateName?: string): Function;
export = wrap;
}
Файл PugAsset.ts
— наш ассет для преобразования файлов шаблонизатора в HTML.
import Asset = require('parcel-bundler/src/Asset');
export = class PugAsset extends Asset {
public type = 'html';
constructor(name: string, pkg: string, options: any) {
super(name, pkg, options);
}
public parse(code: string) {
}
public collectDependencies(): void {
}
public generate() {
}
};
Начнем с превращения текста шаблона в AST. Как я уже говорил, когда бандлеру попадается какой-либо файл, он пытается найти его ассет. Если он был найден — происходит цепочка вызовов parse -> pretransform -> collectDependencies -> transform -> generate
. Наш первый шаг — реализовать метод parse
:
public parse(code: string) {
let ast = load.string(code, {
// Передаем лексер
lex: lexer,
// Передаем парсер
parse: parser,
// Передаем имя файла, нужно для относительных путей и показа ошибок
filename: this.name
});
// Линкер разберет вложенные AST (если в шаблоне используется include или extends)
ast = linker(ast);
return ast;
}
Далее нам нужно пройтись по построенному дереву и найти любые элементы, в которых могут находиться ссылки. Механизм работы достаточно прост и был подсмотрен в стандартном HTMLAsset
. Суть — составить словарь с атрибутами HTML-узла, которые могут содержать ссылки. При прохождении по дереву нужно найти подходящие узлы и скормить содержимое атрибута со ссылкой в метод addURLDependency
, который попробует найти необходимый ассет в зависимости от расширения файла. Если ассет найден — метод вернет новое название файла, попутно добавив этот файл в дерево сборки (таким образом и происходит вложенное преобразование других ассетов). Это название нам нужно подставить вместо старого пути. Так же нам нужно учесть то, что все подключенные файлы (include
и extends
) нам нужно добавить как зависимости данного ассета, в противном случае при изменении подключаемого или базового файла у нас не будет происходить пересборка всего шаблона.
interface Dictionary<T> {
[key: string]: T;
}
const ATTRS: Dictionary<string[]> = {
src: [
'script',
'img',
'audio',
'video',
'source',
'track',
'iframe',
'embed'
],
href: ['link', 'a'],
poster: ['video']
};
public collectDependencies(): void {
walk(this.ast, node => {
// Проверяем, что нам попался узел из другого файла, которого нет в зависимостях
if (node.filename !== this.name && !this.dependencies.has(node.filename)) {
// Добавляем файл в зависимости
this.addDependency(node.filename, {
name: node.filename, // Полный путь файла
includedInParent: true // Для данной зависимости не нужно создавать ассет
});
}
// Проверяем, что данный узел имеет аттрибуты
if (node.attrs) {
// Пробегаем по всем атрибутам
for (const attr of node.attrs) {
const elements = ATTRS[attr.name];
// Если наш узел - тэг и он находится в словаре
if (node.type === 'Tag' && elements && elements.indexOf(node.name) > -1) {
// Pug отдает URL в кавычках, которые мы убираем
let assetPath = attr.val.substring(1, attr.val.length - 1);
// Пробуем подобрать ассет для подключаемого файла
assetPath = this.addURLDependency(assetPath);
// Если нам вернули путь к файлу - нормализуем его
if (!isURL(assetPath)) {
// Use url.resolve to normalize path for windows
// from \path\to\res.js to /path/to/res.js
assetPath = url.resolve(path.join(this.options.publicURL, assetPath), '');
}
// Заменяем старый путь
attr.val = `'${assetPath}'`;
}
}
}
return node;
});
}
Финальный штрих — получение итогового HTML. Это — обязанность метода generate
:
public generate() {
const result = generateCode(this.ast, {
// Вывод отладочной информации
compileDebug: false,
// Нужно ли форматировать итоговую строку
pretty: !this.options.minify
});
return { html: wrap(result)() };
}
Если собрать все воедино мы получим следующее:
import url = require('url');
import path = require('path');
import Asset = require('parcel-bundler/src/Asset');
import isURL = require('parcel-bundler/src/utils/is-url');
import load = require('pug-load');
import lexer = require('pug-lexer');
import parser = require('pug-parser');
import walk = require('pug-walk');
import linker = require('pug-linker');
import generateCode = require('pug-code-gen');
import wrap = require('pug-runtime/wrap');
interface Dictionary<T> {
[key: string]: T;
}
// A list of all attributes that should produce a dependency
// Based on https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes
const ATTRS: Dictionary<string[]> = {
src: [
'script',
'img',
'audio',
'video',
'source',
'track',
'iframe',
'embed'
],
href: ['link', 'a'],
poster: ['video']
};
export = class PugAsset extends Asset {
public type = 'html';
constructor(name: string, pkg: string, options: any) {
super(name, pkg, options);
}
public parse(code: string) {
let ast = load.string(code, {
lex: lexer,
parse: parser,
filename: this.name
});
ast = linker(ast);
return ast;
}
public collectDependencies(): void {
walk(this.ast, node => {
if (node.filename !== this.name && !this.dependencies.has(node.filename)) {
this.addDependency(node.filename, {
name: node.filename,
includedInParent: true
});
}
if (node.attrs) {
for (const attr of node.attrs) {
const elements = ATTRS[attr.name];
if (node.type === 'Tag' && elements && elements.indexOf(node.name) > -1) {
let assetPath = attr.val.substring(1, attr.val.length - 1);
assetPath = this.addURLDependency(assetPath);
if (!isURL(assetPath)) {
// Use url.resolve to normalize path for windows
// from \path\to\res.js to /path/to/res.js
assetPath = url.resolve(path.join(this.options.publicURL, assetPath), '');
}
attr.val = `'${assetPath}'`;
}
}
}
return node;
});
}
public generate() {
const result = generateCode(this.ast, {
compileDebug: false,
pretty: !this.options.minify
});
return { html: wrap(result)() };
}
};
Наш плагин готов. Он умеет принимать на вход шаблоны, превращать текст в AST, разрешать все внутренние зависимости и выдавать на выходе готовый HTML, корректно распознает встроенные конструкции include
и extends
, а так же умеет пересобирать весь шаблон, в котором присутствуют данные конструкции.
Из мелких недоработок — при возникновении ошибки ее текст дублируется, что является особенностью вывода Parcel, который оборачивает в try catch
вызовы функций и красиво печатает вылетающие ошибки.