В прошлой статье я рассказал про новый бандлер 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 — превращает массив токенов в AST
  • pug-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
¦   L-- modules.d.ts
+-- tsconfig.json
L-- tslint.json

Файл index.ts будет являться точкой входа в наш плагин:


export = (bundler: any) => {
  bundler.addAssetType('pug', require.resolve('./PugAsset'));
  bundler.addAssetType('jade', require.resolve('./PugAsset'));
};

Для работы с ассетом нам понадобится базовый класс Asset. Напишем TypeScript-обвязку для нужных нам модулей:


modules.d.ts
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)() };
}

Если собрать все воедино мы получим следующее:


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


Ссылки


Комментарии (0)