UNPKG

svg-spritemap-webpack-plugin

Version:

Generates symbol-based SVG spritemap from all .svg files in a directory

481 lines (393 loc) 19.7 kB
const fs = require('fs'); const path = require('path'); const glob = require('glob'); const svgo = require('svgo'); const mkdirp = require('mkdirp'); const loaderUtils = require('loader-utils'); const webpack = require('webpack'); const RawModule = require('webpack/lib/RawModule'); const { intersection } = require('lodash'); const { RawSource } = webpack.sources || require('webpack-sources'); const SingleEntryPlugin = webpack.EntryPlugin || webpack.SingleEntryPlugin; // Helpers const formatOptions = require('./options-formatter'); const generateSVG = require('./generate-svg'); const generateStyles = require('./generate-styles'); const generateSVGOConfig = require('./helpers/generate-svgo-config'); const { stripVariables, findVariables } = require('./variable-parser'); // Errors & Warnings const { OptionsMismatchWarning, VariablesNotSupportedInLanguageWarning, VariablesNotSupportedWithFragmentsWarning, NoSourceFilesWarning } = require('./errors'); const plugin = { name: 'SVGSpritemapPlugin' }; module.exports = class SVGSpritemapPlugin { constructor(pattern, options) { this.options = formatOptions(pattern, options); this.warnings = []; // Dependencies this.files = []; this.directories = []; } apply(compiler) { compiler.hooks.entryOption.tap(plugin, (context, entry) => { const { output: outputOptions } = this.options; if ( !outputOptions.svg4everybody ) { return; } // This is a little hacky but there's no other way since Webpack // doesn't support virtual files (https://github.com/rmarscher/virtual-module-webpack-plugin) const helper = { file: fs.readFileSync(path.join(__dirname, 'templates/svg4everybody.template.js'), 'utf8'), path: path.join(__dirname, '../svg4everybody-helper.js') }; // Write the helper file to disk fs.writeFileSync(helper.path, helper.file.replace('/* PLACEHOLDER */', JSON.stringify(outputOptions.svg4everybody)), 'utf8'); // Append helper to entry const applyEntryPlugin = (entries, name = 'main') => { entries.forEach((entry) => { const plugin = new SingleEntryPlugin(context, entry, name); plugin.apply(compiler); }); }; if ( typeof entry === 'string' ) { applyEntryPlugin([entry, helper.path]); } else if ( Array.isArray(entry) ) { if ( !entry.includes(helper.path) ) { applyEntryPlugin([...entry, helper.path]); } } else if ( typeof entry === 'object' && entry !== null ) { Object.keys(entry).forEach((name) => { if ( typeof entry[name] === 'string' ) { applyEntryPlugin([entry[name], helper.path], name); } else if ( entry[name].hasOwnProperty('import') ) { applyEntryPlugin([...entry[name].import, helper.path], name); } else if ( Array.isArray(entry[name]) ) { if ( !entry[name].includes(helper.path) ) { applyEntryPlugin([...entry[name], helper.path], name); } } else { // Webpack currently doesn't allow this configuration (object with values other than string/array or descriptors) // but let's make sure we throw if this ever gets added throw new Error(`Unsupported sub-entry type for svg4everybody helper: '${typeof entry[name]}'`); } }); } else { throw new Error(`Unsupported entry type for svg4everybody helper: '${typeof entry}'`); } }); // Update dependencies when needed compiler.hooks.environment.tap(plugin, this.updateDependencies.bind(this)); compiler.hooks.watchRun.tap(plugin, this.updateDependencies.bind(this)); // Generate spritemap compiler.hooks.run.tapAsync(plugin, this.generateSpritemap.bind(this)); compiler.hooks.watchRun.tapAsync(plugin, this.generateSpritemap.bind(this)); compiler.hooks.afterCompile.tap(plugin, (compilation) => { compilation.warnings = [...compilation.warnings, ...this.warnings]; }); compiler.hooks.make.tap(plugin, (compilation) => { const { output: outputOptions, styles: stylesOptions } = this.options; this.filenames = { spritemap: this.options.output.filename, styles: this.options.styles.filename }; if (this.spritemap) { compilation.emitAsset(outputOptions.filename, new RawSource(this.spritemap), { immutable: this.spritemapCache === this.spritemap, development: false, javascriptModule: false }); // Store spritemap in cache to make sure we can check for immutability in the next compilation this.spritemapCache = this.spritemap; // Update filename now that the contenthash is known this.updateFilename('spritemap', compilation); this.generateStyles(compilation); } // Generate styles compilation.hooks.afterHash.tap(plugin, () => { this.updateFilename('spritemap', compilation); if ( typeof this.styles !== 'undefined' && this.stylesType === 'asset' ) { compilation.emitAsset(stylesOptions.filename, new RawSource(this.styles.content)) } }); compilation.hooks.beforeChunks.tap(plugin, () => { // Set up a dummy module that we can add to our source chunk // To prevent it from getting cleaned up, this seems to be required // to correctly link the spritemap asset to the chunk const chunk = compilation.addChunk(outputOptions.chunk.name); chunk.reason = 'svg-spritemap-webpack-plugin dummy module'; const module = new RawModule('', `${outputOptions.chunk.name}-dummy-module`); module.buildInfo = {}; module.buildMeta = {}; compilation.modules.add(module); compilation.chunkGraph.connectChunkAndModule(chunk, module); }) // Optimize spritemap SVG/filename compilation.hooks.processAssets.tap({ name: this.constructor.name, stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE }, (assets) => { const { output: outputOptions, sprite: spriteOptions } = this.options; const asset = compilation.getAsset(this.filenames.spritemap); if (!asset) { return; } // Strip variables from spritemap SVG const stripped = stripVariables(asset.source); compilation.updateAsset(this.filenames.spritemap, new RawSource(stripped)); if ( outputOptions.svgo === false ) { return; } const config = generateSVGOConfig(outputOptions.svgo, [{ name: 'removeTitle', active: !spriteOptions.generate.title // Disable the removeTitle plugin when title elements should be generated }], [{ name: 'cleanupIDs', active: false // Force disable cleanupIDs as the identifiers are to be used to target a specific sprite }], [{ name: 'removeDimensions', active: !spriteOptions.generate.dimensions // Disable the removeDimensions plugin when dimensions should be generated }]); const output = svgo.optimize(assets[this.filenames.spritemap].source(), config); compilation.updateAsset(this.filenames.spritemap, new RawSource(output.data), { minimized: true }); compilation.chunks.forEach((chunk) => { if ( chunk.name !== outputOptions.chunk.name ) { return; } chunk.files.add(this.filenames.spritemap); }); }); }); // Clean up the spritemap chunk compiler.hooks.thisCompilation.tap(plugin, (compilation) => { compilation.hooks.processAssets.tap({ name: this.constructor.name, stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE }, () => { this.cleanUpChunk(compilation); }); }); // Add context dependencies to webpack compilation to make sure watching works correctly compiler.hooks.afterCompile.tap(plugin, (compilation) => { this.directories.forEach((directory) => { compilation.contextDependencies.add(directory); }); }); } generateSpritemap(compiler, callback) { const { sprite: spriteOptions, input: inputOptions, output: outputOptions } = this.options; if (typeof compiler.modifiedFiles !== 'undefined' && !intersection([...this.files, ...this.directories], Array.from(compiler.modifiedFiles)).length) { callback(); return; } Promise.all(this.files.map((file) => { return new Promise((resolve) => { return fs.readFile(file, { encoding: 'utf-8' }, (error, content) => { resolve({ path: file, content: content }); }); }); })).then((sources) => { if ( !sources.length ) { this.warnings.push(new NoSourceFilesWarning(inputOptions.patterns)); } if ( spriteOptions.generate.view && !spriteOptions.generate.use ) { this.warnings.push(new OptionsMismatchWarning(`Using sprite.generate.view requires sprite.generate.use to be enabled`)); } if ( spriteOptions.generate.use && !spriteOptions.generate.symbol ) { this.warnings.push(new OptionsMismatchWarning(`Using sprite.generate.use requires sprite.generate.symbol to be enabled`)); } if ( spriteOptions.generate.title && !spriteOptions.generate.symbol ) { this.warnings.push(new OptionsMismatchWarning(`Using sprite.generate.title requires sprite.generate.symbol to be enabled`)); } if ( spriteOptions.generate.symbol === true && spriteOptions.generate.view === true ) { this.warnings.push(new OptionsMismatchWarning(`Both sprite.generate.symbol and sprite.generate.view are set to true which will cause identifier conflicts, use a string value (postfix) for either of these options`)); } // Generate SVG this.sources = sources; this.spritemap = generateSVG(sources, { sprite: spriteOptions, output: outputOptions, input: inputOptions }, this.warnings); callback(); }); } generateStyles(compilation) { const { output: outputOptions, sprite: spriteOptions, styles: stylesOptions } = this.options; if ( !stylesOptions ) { return; } const extension = path.extname(stylesOptions.filename).substring(1).toLowerCase(); this.styles = generateStyles(this.spritemap, { extension: extension, keepAttributes: stylesOptions.keepAttributes, prefix: spriteOptions.prefix, prefixStylesSelectors: spriteOptions.prefixStylesSelectors, postfix: { symbol: spriteOptions.generate.symbol, view: spriteOptions.generate.view }, format: { type: stylesOptions.format, publicPath: (() => { const publicPath = compilation.options.output.publicPath; if ( typeof publicPath === 'undefined' || publicPath === 'auto' ) { return `/${this.filenames.spritemap}`; } return `${publicPath.replace(/\/$/, '')}/${this.filenames.spritemap}`; })() }, variables: stylesOptions.variables, callback: stylesOptions.callback }, this.sources); // Emit a warning when variables are detected while the language doesn't support it if ( !['scss', 'sass'].includes(extension) && findVariables(this.spritemap).length ) { this.warnings.push(new VariablesNotSupportedInLanguageWarning(extension)); } // Emit a warning when variables are detected while the stylesOptions.format is set to 'fragment' if ( stylesOptions.format === 'fragment' && findVariables(this.spritemap).length ) { this.warnings.push(new VariablesNotSupportedWithFragmentsWarning()); } // Emit a warning when using 'fragment' for styles.format without enabling sprite.generate.view if ( stylesOptions.format === 'fragment' && !spriteOptions.generate.view ) { this.warnings.push(new OptionsMismatchWarning(`Using styles.format with value 'fragment' in combination with sprite.generate.view with value false will result in CSS fragments not working correctly`)); } // Emit a warning when using [hash] in filename while using 'fragment' for styles.format if ( stylesOptions.format === 'fragment' && outputOptions.filename.includes('[hash]') ) { this.warnings.push(new OptionsMismatchWarning(`Using styles.format with value 'fragment' in combination with [hash] in output.filename will results in incorrect fragment URLs`)); } // Include warnings received from the style formatters if ( this.styles.warnings ) { this.warnings = [...this.warnings, ...this.styles.warnings]; } // Write the styles file before compilation starts to make sure the files // are written to disk before other plugins/loaders (e.g. mini-css-extract-plugin) can use them this.stylesType = this.getStylesType(this.styles.content, stylesOptions.filename); this.writeStylesToDisk(this.styles.content, this.stylesType); } cleanUpChunk(compilation) { const { output: outputOptions } = this.options; if ( outputOptions.chunk.keep ) { return; } // Fetch existing filenames from all instances of this plugin const filenames = compilation.options.plugins.filter((plugin) => { return plugin instanceof SVGSpritemapPlugin; }).map((plugin) => { return Object.values(plugin.filenames); }).reduce((filenames, values) => { return filenames.concat(values); }, []); Array.from(compilation.chunks).filter((chunk) => { if ( !chunk.name ) { return false; } if ( chunk.name === outputOptions.chunk.name ) { return true; } if ( chunk.name.startsWith(`${outputOptions.chunk.name}${compilation.options.optimization.splitChunks.automaticNameDelimiter}`) ) { return true; } }).forEach((chunk) => { Array.from(chunk.files).filter((file) => { return !filenames.includes(file); }).forEach((file) => { delete compilation.assets[file]; }); }); } updateDependencies() { const { input: inputOptions } = this.options; this.files = []; this.directories = []; inputOptions.patterns.forEach((pattern) => { const root = path.resolve(pattern.replace(/\*.*/, '')); if (!path.basename(root).includes('.')) { this.directories.push(root); } glob.sync(pattern, inputOptions.options).map((match) => { const pathname = path.resolve(match); const stats = fs.lstatSync(pathname); if (stats.isFile()) { if (inputOptions.allowDuplicates) { this.files.push(pathname); } else { this.files = [...new Set([...this.files, pathname])]; } // Add parent directories of files to directory watch list // to ensure new files in these directories are watched as well this.directories = [...new Set([...this.directories, pathname.substring(0, pathname.lastIndexOf(path.sep))])]; } else if (stats.isDirectory()) { this.directories = [...new Set([...this.directories, pathname])]; } }); }); } getStylesType(styles, filename = '') { if ( !styles || !filename ) { return; } if ( path.parse(filename).dir ) { return 'dir'; } if ( filename.startsWith('~') ) { return 'module'; } return 'asset'; } writeStylesToDisk(styles, type) { const { styles: stylesOptions } = this.options; const location = { 'dir': stylesOptions.filename, 'module': path.resolve(__dirname, '../', stylesOptions.filename.replace('~', '')) }[type]; if ( typeof location === 'undefined' ) { return; } // check is location exist const locationExists = fs.existsSync(location); // Make sure we don't rewrite the file when it's correct already const contents = locationExists && fs.readFileSync(location, 'utf8'); if ( contents === styles ) { return; } if ( type === 'dir' && !locationExists ) { const dirname = path.dirname(location); if ( !fs.existsSync(dirname) ) { mkdirp.sync(dirname); } } fs.writeFileSync(location, styles, 'utf8'); } updateFilename(identifier, compilation) { const oldFilename = this.filenames[identifier]; const asset = compilation.getAsset(oldFilename); if (!asset) { return oldFilename; } const source = typeof asset.source === 'string' ? asset.source : asset.source._value; const hash = compilation.hash; const contenthash = asset && loaderUtils.getHashDigest(source, 'sha1', 'hex', compilation.options.output.hashDigestLength || 16); const newFilename = [{ pattern: /\[hash]/, value: hash || '[hash]' }, { pattern: /\[contenthash]/, value: contenthash || '[contenthash]' }].reduce((filename, item) => { const pattern = new RegExp(item.pattern, 'ig'); return filename.replace(pattern, item.value); }, this.filenames[identifier]); compilation.renameAsset(oldFilename, newFilename); compilation.updateAsset(newFilename, source, { contenthash: contenthash }); this.filenames[identifier] = newFilename; } };