kisenka/svg-sprite-loader

View on GitHub
lib/plugin.js

Summary

Maintainability
A
3 hrs
Test Coverage
/* eslint-disable import/no-extraneous-dependencies */
const merge = require('deepmerge');
const Promise = require('bluebird');
const SVGCompiler = require('svg-baker');
const spriteFactory = require('svg-baker/lib/sprite-factory');
const Sprite = require('svg-baker/lib/sprite');
const { NAMESPACE } = require('./config');
const {
  MappedList,
  replaceInModuleSource,
  replaceSpritePlaceholder,
  getMatchedRule
} = require('./utils');

const defaultConfig = {
  plainSprite: false,
  spriteAttrs: {}
};

class SVGSpritePlugin {
  constructor(cfg = {}) {
    const config = merge.all([defaultConfig, cfg]);
    this.config = config;

    const spriteFactoryOptions = {
      attrs: config.spriteAttrs
    };

    if (config.plainSprite) {
      spriteFactoryOptions.styles = false;
      spriteFactoryOptions.usages = false;
    }

    this.factory = ({ symbols }) => {
      const opts = merge.all([spriteFactoryOptions, { symbols }]);
      return spriteFactory(opts);
    };

    this.svgCompiler = new SVGCompiler();
    this.rules = {};
  }

  /**
   * This need to find plugin from loader context
   */
  // eslint-disable-next-line class-methods-use-this
  get NAMESPACE() {
    return NAMESPACE;
  }

  getReplacements() {
    const isPlainSprite = this.config.plainSprite === true;
    const replacements = this.map.groupItemsBySymbolFile((acc, item) => {
      acc[item.resource] = isPlainSprite ? item.url : item.useUrl;
    });
    return replacements;
  }

  // TODO optimize MappedList instantiation in each hook
  apply(compiler) {
    this.rules = getMatchedRule(compiler);

    const path = this.rules.outputPath ? this.rules.outputPath : this.rules.publicPath;
    this.filenamePrefix = path
      ? path.replace(/^\//, '')
      : '';

    if (compiler.hooks) {
      compiler.hooks
        .thisCompilation
        .tap(NAMESPACE, (compilation) => {
          try {
            // eslint-disable-next-line global-require
            const NormalModule = require('webpack/lib/NormalModule');
            NormalModule.getCompilationHooks(compilation).loader
              .tap(NAMESPACE, loaderContext => loaderContext[NAMESPACE] = this);
          } catch (e) {
            compilation.hooks
              .normalModuleLoader
              .tap(NAMESPACE, loaderContext => loaderContext[NAMESPACE] = this);
          }

          compilation.hooks
            .afterOptimizeChunks
            .tap(NAMESPACE, () => this.afterOptimizeChunks(compilation));

          if (compilation.hooks.optimizeExtractedChunks) {
            compilation.hooks
              .optimizeExtractedChunks
              .tap(NAMESPACE, chunks => this.optimizeExtractedChunks(chunks));
          }

          compilation.hooks
            .additionalAssets
            .tapPromise(NAMESPACE, () => {
              return this.additionalAssets(compilation);
            });
        });

      compiler.hooks
        .compilation
        .tap(NAMESPACE, (compilation) => {
          if (compilation.hooks.htmlWebpackPluginBeforeHtmlGeneration) {
            compilation.hooks
              .htmlWebpackPluginBeforeHtmlGeneration
              .tapAsync(NAMESPACE, (htmlPluginData, callback) => {
                htmlPluginData.assets.sprites = this.beforeHtmlGeneration(compilation);

                callback(null, htmlPluginData);
              });
          }

          if (compilation.hooks.htmlWebpackPluginBeforeHtmlProcessing) {
            compilation.hooks
              .htmlWebpackPluginBeforeHtmlProcessing
              .tapAsync(NAMESPACE, (htmlPluginData, callback) => {
                htmlPluginData.html = this.beforeHtmlProcessing(htmlPluginData);

                callback(null, htmlPluginData);
              });
          }
        });
    } else {
      // Handle only main compilation
      compiler.plugin('this-compilation', (compilation) => {
        // Share svgCompiler with loader
        compilation.plugin('normal-module-loader', (loaderContext) => {
          loaderContext[NAMESPACE] = this;
        });

        // Replace placeholders with real URL to symbol (in modules processed by svg-sprite-loader)
        compilation.plugin('after-optimize-chunks', () => this.afterOptimizeChunks(compilation));

        // Hook into extract-text-webpack-plugin to replace placeholders with real URL to symbol
        compilation.plugin('optimize-extracted-chunks', chunks => this.optimizeExtractedChunks(chunks));

        // Hook into html-webpack-plugin to add `sprites` variable into template context
        compilation.plugin('html-webpack-plugin-before-html-generation', (htmlPluginData, done) => {
          htmlPluginData.assets.sprites = this.beforeHtmlGeneration(compilation);

          done(null, htmlPluginData);
        });

        // Hook into html-webpack-plugin to replace placeholders with real URL to symbol
        compilation.plugin('html-webpack-plugin-before-html-processing', (htmlPluginData, done) => {
          htmlPluginData.html = this.beforeHtmlProcessing(htmlPluginData);
          done(null, htmlPluginData);
        });

        // Create sprite chunk
        compilation.plugin('additional-assets', (done) => {
          return this.additionalAssets(compilation)
            .then(() => {
              done();
              return true;
            })
            .catch(e => done(e));
        });
      });
    }
  }

  additionalAssets(compilation) {
    const itemsBySprite = this.map.groupItemsBySpriteFilename();
    const filenames = Object.keys(itemsBySprite);

    return Promise.map(filenames, (filename) => {
      const spriteSymbols = itemsBySprite[filename].map(item => item.symbol);

      return Sprite.create({
        symbols: spriteSymbols,
        factory: this.factory
      })
        .then((sprite) => {
          const content = sprite.render();

          compilation.assets[`${this.filenamePrefix}${filename}`] = {
            source() { return content; },
            size() { return content.length; },
            updateHash(bulkUpdateDecorator) { bulkUpdateDecorator.update(content); }
          };
        });
    });
  }

  afterOptimizeChunks(compilation) {
    const { symbols } = this.svgCompiler;
    this.map = new MappedList(symbols, compilation);
    const replacements = this.getReplacements();
    this.map.items.forEach(item => replaceInModuleSource(item.module, replacements));
  }

  optimizeExtractedChunks(chunks) {
    const replacements = this.getReplacements();

    chunks.forEach((chunk) => {
      let modules;

      if (chunk.modulesIterable) {
        modules = Array.from(chunk.modulesIterable);
      } else {
        modules = chunk.modules;
      }

      modules
        // dirty hack to identify modules extracted by extract-text-webpack-plugin
        // TODO refactor
        .filter(module => '_originalModule' in module)
        .forEach(module => replaceInModuleSource(module, replacements));
    });
  }

  beforeHtmlGeneration(compilation) {
    const itemsBySprite = this.map.groupItemsBySpriteFilename();

    const sprites = Object.keys(itemsBySprite).reduce((acc, filename) => {
      acc[this.filenamePrefix + filename] = compilation.assets[this.filenamePrefix + filename].source();
      return acc;
    }, {});

    return sprites;
  }

  beforeHtmlProcessing(htmlPluginData) {
    const replacements = this.getReplacements();
    return replaceSpritePlaceholder(htmlPluginData.html, replacements);
  }
}

module.exports = SVGSpritePlugin;