UNPKG

html-bundler-webpack-plugin

Version:

Generates complete single-page or multi-page website from source assets. Built-in support for Markdown, Eta, EJS, Handlebars, Nunjucks, Pug. Alternative to html-webpack-plugin.

347 lines (290 loc) 10.8 kB
const path = require('path'); const { detectIndent, getFileExtension } = require('../Common/Helpers'); const { HtmlParser } = require('../Common/HtmlParser'); const { optionPreloadAsException } = require('./Messages/Exception'); /** * Media types that differ from file extension. * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types */ const mimeType = { audio: { oga: 'audio/ogg', mp3: 'audio/mpeg', weba: 'audio/webm', }, video: { ogv: 'video/ogg', webm: 'video/webm', }, image: { svg: 'image/svg+xml', jpg: 'image/jpeg', tif: 'image/tiff', }, font: { svg: 'image/svg+xml', }, }; /** * The valid type values of content for 'as' attribute. * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link * * @type {Array<string>} */ const contentType = [ 'audio', 'document', 'embed', 'font', 'image', 'object', 'script', 'style', 'track', 'video', 'worker', ]; /** * Content types whose 'type' attribute can be omitted. * * @type {Set<string>} */ const optionalTypeBy = new Set(['script', 'style']); class Preload { pluginOption = null; constructor(pluginOption) { this.pluginOption = pluginOption; } /** * @param {AssetEntryOptions} entry The entry where is specified the resource. * @param {FileInfo|null} issuer The issuer file info. * @param {string} assetFile The asset output filename. * @return {string} */ getPreloadFile(entry, issuer, assetFile) { if ( (this.pluginOption.autoPublicPath || this.pluginOption.isRelativePublicPath) && issuer && issuer.resource !== entry.resource ) { // recovery preload output file of an asset relative by entry point const issuerDir = path.dirname(issuer.filename); const webRootPath = path.posix.join(issuerDir, assetFile); assetFile = this.pluginOption.getOutputFilename(webRootPath, entry.filename); } return assetFile; } /** * Generates and injects preload tags in the head for all matching source files resolved in templates and styles. * * @param {string} content The template content. * @param {string} entryAsset The output filename of template. * @param {Map} collection The reference to `Collection data`. * @throws */ insertPreloadAssets(content, entryAsset, collection) { const data = collection.get(entryAsset); if (!data) return; const options = this.pluginOption.getPreload(); if (!options || !content) return; const insertPos = this.#findInsertPos(content); if (insertPos < 0) { // TODO: show warning - the template must contain the <head></head> tag to inject preloading assets. return; } const crossorigin = this.pluginOption.getCrossorigin(); const preloadAssets = new Map(); const LF = this.pluginOption.getLF(); const indent = LF + detectIndent(content, insertPos - 1); const groupBy = {}; let hasImage = false; // normalize preload attributes and create sorted groups in the order of the specified 'preload' options for (const conf of options) { const as = conf.as || conf.attributes?.as; // note: `as` property is a required valid value if (!as || contentType.indexOf(as) < 0) { optionPreloadAsException(conf, as, contentType); } // determine the order of the attributes in the link tag const attrs = { rel: 'preload', href: undefined, as, type: undefined, ...(conf.attributes || {}) }; // override attributes with the main properties if (conf.rel) attrs.rel = conf.rel; if (conf.type) attrs.type = conf.type; // for the `font` type, the `crossorigin` attribute is mandatory, if it is not defined, set to default value if (as === 'font' && !('crossorigin' in attrs)) attrs.crossorigin = true; if (as === 'image') hasImage = true; // whether the 'type' property exist regardless of a value; // if the property exists and have the undefined value, exclude this attribute in generating preload tag const hasType = 'type' in conf || (conf.attributes && 'type' in conf.attributes) || optionalTypeBy.has(attrs.as); // filter const filter = this.pluginOption.normalizeAdvancedFiler(conf.test, conf.filter); // save normalized options conf._opts = { attrs, hasType, filter }; groupBy[as] = []; } // pick up image attributes to preload const imageAttributes = hasImage ? this.#getImageAttributes(content) : new Map(); // prepare a flat array with preload assets for (let item of data.assets) { if (item.inline) continue; const assets = item.assets; const conf = options.find(({ test }) => test.test(item.resource)); if (conf) { if (Array.isArray(item.chunks)) { // js for (let { chunkFile, assetFile, integrity } of item.chunks) { // sourceFiles contain only one file let sourceFiles = [item.resource]; let outputFile = assetFile; if (this.pluginOption.applyAdvancedFiler({ sourceFiles, outputFile }, conf._opts.filter)) { let props = this.createPreloadAttributes(conf, { integrity: integrity, crossorigin }); preloadAssets.set(assetFile, props); } } } else { // css, images, fonts, etc // sourceFiles may contain many file, when many style files are imported in a JS file, // then all generated CSS contents will be squashed into one CSS file let sourceFiles = Array.isArray(item.resource) ? item.resource : [item.resource]; let outputFile = item.assetFile; if (this.pluginOption.applyAdvancedFiler({ sourceFiles, outputFile }, conf._opts.filter)) { let props = this.createPreloadAttributes(conf, { integrity: item.integrity, crossorigin }); let imgAttrs = imageAttributes.get(item.assetFile); if (imgAttrs) { props.attrs = Object.assign(props.attrs, imgAttrs); } preloadAssets.set(item.assetFile, props); } } // dynamic imported modules, asyncChunks if (Array.isArray(item.children)) { for (let { chunkFile, assetFile, sourceFile, integrity } of item.children) { // sourceFiles contain only one file let sourceFiles = [sourceFile]; let outputFile = assetFile; if (this.pluginOption.applyAdvancedFiler({ sourceFiles, outputFile }, conf._opts.filter)) { let props = this.createPreloadAttributes(conf, { integrity: integrity, crossorigin }); preloadAssets.set(assetFile, props); } } } } // assets in css if (Array.isArray(assets)) { for (const assetItem of assets) { if (assetItem.inline) continue; const conf = options.find(({ test }) => test.test(assetItem.resource)); if (!conf) continue; // sourceFiles contain only one file let sourceFiles = [assetItem.resource]; let outputFile = assetItem.issuer ? this.getPreloadFile(data.entry, assetItem.issuer, assetItem.assetFile) : assetItem.assetFile; if (this.pluginOption.applyAdvancedFiler({ sourceFiles, outputFile }, conf._opts.filter)) { let props = this.createPreloadAttributes(conf); preloadAssets.set(outputFile, props); } } } } // save generated preload tags for verbose data.preloads = []; for (const [filename, props] of preloadAssets.entries()) { const { attrs } = props; attrs.href = filename; if (!props.hasType) { const ext = getFileExtension(filename); attrs.type = mimeType[attrs.as]?.[ext] || attrs.as + '/' + ext; } let tag = `<link`; for (const [key, value] of Object.entries(attrs)) { if (value === true) tag += ` ${key}`; else if (value != null) tag += ` ${key}="${value}"`; } tag += '>'; data.preloads.push({ filename, type: attrs.as || attrs.type, tag, }); groupBy[attrs.as].push(tag); } // inject preload tags into the template const tags = Object.values(groupBy).flat(); if (tags.length) { const str = tags.join(indent) + indent; return content.slice(0, insertPos) + str + content.slice(insertPos); } } /** * @param {Object} conf The source options from configuration. * @param {string|undefined} integrity The integrity, used by script and style only. * @param {string} crossorigin The attribute required for integrity, used by script and style only. * @return {Object} Returns new object of preload attributes. It may contains integrity if exists. */ createPreloadAttributes(conf, { integrity, crossorigin } = {}) { let props = { ...conf._opts }; // create new object for attributes, to keep original config unchanged props.attrs = { ...props.attrs }; if (integrity) { props.attrs.integrity = integrity; props.attrs.crossorigin = crossorigin; } return props; } /** * Pick up image attributes to preload. * * @param {string} content * @return {Map<string, any>} */ #getImageAttributes(content) { const imageAttributes = new Map(); const parseOptions = { tag: 'img', attributes: ['srcset', 'sizes'], }; const imageTags = HtmlParser.parseTag(content, parseOptions); for (const tag of imageTags) { const attrs = {}; let hasAttrs = false; if (tag.attrs.srcset) { attrs.imagesrcset = tag.attrs.srcset.replaceAll(/\n|\b|\s{2,}/g, ''); hasAttrs = true; } if (tag.attrs.sizes) { attrs.imagesizes = tag.attrs.sizes.replaceAll(/\n|\b|\s{2,}/g, ''); hasAttrs = true; } if (hasAttrs) { imageAttributes.set(tag.attrs.src, attrs); } } return imageAttributes; } /** * Find start position in the content to insert generated tags. * * @param {string} content * @return {number} Returns the position to insert place or -1 if the head tag not found. */ #findInsertPos(content) { let headStartPos = content.indexOf('<head'); if (headStartPos < 0) { return -1; } let headEndPos = content.indexOf('</head>', headStartPos); if (headEndPos < 0) { return -1; } let startPos = content.indexOf('<link', headStartPos); if (startPos < 0 || startPos > headEndPos) { startPos = content.indexOf('<script', headStartPos); } if (startPos < 0 || startPos > headEndPos) { startPos = headEndPos; } return startPos; } } module.exports = Preload;