
В прошлой статье я рассказал про новый бандлер 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иextendspug-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 вызовы функций и красиво печатает вылетающие ошибки.
