UNPKG

html-bundler-webpack-plugin

Version:

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

312 lines (266 loc) 9.08 kB
/** * @param {{key: string, value: string}} attrs * @param {Array<string>} exclude The list of excluded attributes from the result. * @return {string} */ const attrsToString = (attrs, exclude = []) => { let res = ''; for (const key in attrs) { if (exclude.indexOf(key) < 0) { res += ` ${key}="${attrs[key]}"`; } } return res; }; /** * Parse tag attributes in a tag string. * * @param {string} string * @returns {Object<key: string, value: string>} The parsed attributes as the object key:value. */ const parseAttributes = (string) => { let attrs = {}; const matches = string.matchAll(/(\S+)="(.+?)"/gm); for (const [, key, val] of matches) { attrs[key] = val; } return attrs; }; /** * Parse values in HTML. * * @param {string} content The HTML content. * @param {string} value The value to parse. * @return {Array<{value: string, attrs: {}, tagStartPos: number, tagEndPos: number, valueStartPos: number, valueEndPos: number}>} */ const parseValues = (content, value) => { const valueLen = value.length; let valueStartPos = 0; let valueEndPos = 0; let result = []; while ((valueStartPos = content.indexOf(value, valueStartPos)) >= 0) { valueEndPos = valueStartPos + valueLen; let tagStartPos = valueStartPos; let tagEndPos = content.indexOf('>', valueEndPos) + 1; while (tagStartPos >= 0 && content.charAt(--tagStartPos) !== '<') {} result.push({ value, attrs: parseAttributes(content.slice(tagStartPos, tagEndPos)), tagStartPos, tagEndPos, valueStartPos, valueEndPos, }); valueStartPos = valueEndPos; } return result; }; /** * @param {string} svg The SVG content. * @return {{dataUrl: string, svgAttrs: Object<key:string, value:string>, innerSVG: string}} */ const parseSvg = (svg) => { const svgOpenTag = '<svg'; const svgCloseTag = '</svg>'; const svgOpenTagStartPos = svg.indexOf(svgOpenTag); const svgCloseTagPos = svg.indexOf(svgCloseTag, svgOpenTagStartPos); if (svgOpenTagStartPos > 0) { // extract SVG content only, ignore xml tag and comments before SVG tag svg = svg.slice(svgOpenTagStartPos, svgCloseTagPos + svgCloseTag.length); } // parse SVG attributes and extract inner content of SVG const svgAttrsStartPos = svgOpenTag.length; const svgAttrsEndPos = svg.indexOf('>', svgAttrsStartPos); const svgAttrsString = svg.slice(svgAttrsStartPos, svgAttrsEndPos); const svgAttrs = parseAttributes(svgAttrsString); const innerSVG = svg.slice(svgAttrsEndPos + 1, svgCloseTagPos - svgOpenTagStartPos); // encode reserved chars in data URL for IE 9-11 (enable if needed) // const reservedChars = /["#%{}<>]/g; // const charReplacements = { // '"': "'", // '#': '%23', // '%': '%25', // '{': '%7B', // '}': '%7D', // '<': '%3C', // '>': '%3E', // }; // encode reserved chars in data URL for modern browsers const reservedChars = /["#]/g; const charReplacements = { '"': "'", '#': '%23', }; const replacer = (char) => charReplacements[char]; // note: don't have to encode as base64, pure svg is smaller const dataUrl = 'data:image/svg+xml,' + svg.replace(/\s+/g, ' ').replace(reservedChars, replacer); return { svgAttrs, innerSVG, dataUrl, }; }; const comparePos = (a, b) => a.valueStartPos - b.valueStartPos; class AssetInline { data = new Map(); constructor() {} /** * @param {string} request * @return {boolean} */ isSvgFile(request) { const [file] = request.split('?', 1); return file.endsWith('.svg'); } /** * Whether the request is data-URL. * * @param {string} request The request of asset. * @returns {boolean} */ isDataUrl(request) { return request.startsWith('data:'); } /** * @param {string} sourceFile * @param {string} issuer * @returns {boolean} */ isInlineSvg(sourceFile, issuer) { const item = this.data.get(sourceFile); return item != null && item.source != null && item.inlineSvg?.issuerResources.has(issuer); } /** * @param {string} sourceFile * @param {string} issuer * @returns {string|null} */ getDataUrl(sourceFile, issuer) { const item = this.data.get(sourceFile); return item != null && item.source != null && item.dataUrl.issuers.has(issuer) ? item.source.dataUrl : null; } /** * @param {string} resource The resource file, including a query. * @param {string} issuer The source file of the issuer. * @param {boolean} isEntry Whether the issuer is an entry file. */ add(resource, issuer, isEntry) { let item = this.data.get(resource); if (!item) { item = { source: null, }; this.data.set(resource, item); } item.isSvg = this.isSvgFile(resource); if (isEntry && item.isSvg) { // SVG can only be inlined in HTML, but in CSS it's a data URL if (!item.inlineSvg) { item.inlineSvg = { issuerResources: new Set(), issuerFilenames: new Set(), }; } item.inlineSvg.issuerResources.add(issuer); } else { if (!item.dataUrl) { item.dataUrl = { issuers: new Set(), }; } item.dataUrl.issuers.add(issuer); } } /** * @param {AssetEntryOptions} entry The entry where is specified the resource. * @param {Chunk} chunk The Webpack chunk. * @param {Module} module The Webpack module. * @param {CodeGenerationResults|Object} codeGenerationResults Code generation results of resource modules. */ saveData(entry, chunk, module, codeGenerationResults) { const sourceFile = module.resource; const item = this.data.get(sourceFile); if (!item) return; if (item.isSvg) { // extract SVG content from the processed source via a loader like svgo-loader const svg = module.originalSource().source().toString(); if (item.inlineSvg) { item.inlineSvg.issuerFilenames.add(entry.filename); } if (item.source == null) { item.source = parseSvg(svg); } } else if (item.source == null) { // data URL for binary resource const data = codeGenerationResults.getData(module, chunk.runtime, 'url'); let dataUrl; // note: webpack has introduced a braking change by 5.95.0 -> 5.96.0 if (!data?.javascript) { // webpack <= 5.95.x: data is Buffer dataUrl = data.toString(); } else if (data?.javascript) { // webpack => 5.96.0: data contains `javascript` key dataUrl = data?.javascript; // remove quotes in value like "data:image/..." if (dataUrl.at(0) === '"') { dataUrl = dataUrl.slice(1, -1); } } else { // warning as svg const warning1 = 'Downgrade your webpack.'; const warning2 = 'This version is not compatible.'; dataUrl = `data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='40' viewBox='0 0 200 40'><text y='15'>${warning1}</text><text dy='30'>${warning2}</text></svg>`; } item.source = { dataUrl }; } } /** * Insert inline SVG in HTML. * Replacing a tag containing the svg source file with the svg element. * * @param {string} content The template content. * @param {string} entryFilename The output filename of template. * @return {string} */ inlineSvg(content, entryFilename) { const headStartPos = content.indexOf('<head'); const headEndPos = content.indexOf('</head>', headStartPos); const hasHead = headStartPos >= 0 && headEndPos > headStartPos; let results = []; // parse all inline SVG images in HTML for (let [sourceFile, item] of this.data) { if (item?.inlineSvg?.issuerFilenames.has(entryFilename)) results.push(...parseValues(content, sourceFile)); } results.sort(comparePos); // compile parsed data to HTML with inlined SVG const excludeAttrs = ['src', 'href', 'xmlns', 'alt']; let output = ''; let pos = 0; for (let { value, attrs, tagStartPos, tagEndPos, valueStartPos, valueEndPos } of results) { const { source } = this.data.get(value); if (hasHead && tagStartPos < headEndPos) { // in head inline as data URL output += content.slice(pos, valueStartPos) + source.dataUrl; pos = valueEndPos; } else { // in body inline as SVG tag attrs = { ...source.svgAttrs, ...attrs }; const attrsString = attrsToString(attrs, [...excludeAttrs, 'title']); const titleStr = 'title' in attrs ? attrs['title'] : 'alt' in attrs ? attrs['alt'] : null; const title = titleStr ? `<title>${titleStr}</title>` : ''; const inlineSvg = `<svg${attrsString}>${title}${source.innerSVG}</svg>`; output += content.slice(pos, tagStartPos) + inlineSvg; pos = tagEndPos; } } return output + content.slice(pos); } /** * Clear cache. * Called only once when the plugin is applied. */ clear() { this.data.clear(); } } module.exports = AssetInline;