import * as webpack from "webpack"; import * as fs from "fs"; import * as path from "path"; import * as ejs from "ejs"; import { minify } from "html-minifier"; import { printError } from "./utils"; export type PreloadIncludeOption = "all" | string[]; export interface PreloadOption { rel: "preload" | "prefetch"; asValue?: "script" | "style" | "font"; includes: | PreloadIncludeOption | { [key: string]: PreloadIncludeOption; }; } export interface HtmlExternalOptions { /** * 指定输出的 html 模板,ejs 语法 */ template?: string; /** * 页面的 title */ title?: string; /** * map entry 对应 单个输出的 html 模板 */ entryTemplates?: { [key: string]: string; }; /** * 自动插入需要preload / prefetch 的脚本 */ preload?: PreloadOption; /** * 禁用html代码压缩 */ disableMinify?: boolean; /** * 自定义数据字段 */ customTplData: any; } export interface HtmlOptions extends HtmlExternalOptions { dllEntry: webpack.Entry; entry: webpack.Entry; isProd: boolean; } interface EntryAsset { name: string; content: string; } function getAssetName(rawName: string): string { let name = rawName; if (name.includes("/")) { let list = name.split("/"); name = list[list.length - 1]; } return name.split(".")[0]; } function readFileContent(fileName: string): string { return fs.readFileSync(fileName).toString(); } interface Chunk { id: number; parents: number[]; names: string[]; files: string[]; initial: boolean; } function insertPreload( { rel, asValue, includes }: PreloadOption, chunks: Chunk[], htmlContent: string, entryName: string ) { let preloadChunks: { file: string; name: string; }[] = []; chunks = chunks.filter(chunk => !chunk.files[0].endsWith(".map")); if (includes === "all" || (typeof includes === "object" && includes[entryName])) { const entryChunk = chunks.find(chunk => chunk.initial && chunk.names[0] === entryName); if (typeof entryChunk !== "undefined") { chunks = chunks.filter(chunk => chunk.parents.includes(entryChunk.id)); } else { chunks = []; } if (includes !== "all") { includes = includes[entryName]; } } if (includes === "all") { preloadChunks = chunks.map(chunk => ({ name: chunk.names[0], file: chunk.files[0] })); } else if (Array.isArray(includes)) { preloadChunks = chunks .map(chunk => ({ name: chunk.names[0], file: chunk.files[0] })) .filter(chunk => (includes as string[]).includes(chunk.name)); } if (preloadChunks.length === 0) { return htmlContent; } const appendHtml = preloadChunks .map(({ file, name }) => { if (rel === "preload") { if (typeof asValue === "undefined") { if (file.endsWith(".css")) { asValue = "style"; } else if (file.endsWith("woff2")) { asValue = "font"; } else { asValue = "script"; } } const crossOrigin = asValue === "font" ? `crossorigin="crossorigin" ` : ""; return ``; } return ``; }) .join("\n"); return htmlContent.replace("", appendHtml + ""); } export default class HtmlPlugin { options: HtmlOptions; constructor(options: HtmlOptions) { this.options = Object.assign, HtmlOptions>( { template: path.resolve(__dirname, "../config/index.html"), title: "ezbuy" }, options ); } apply(compiler) { compiler.plugin("emit", (compilation, cb) => { const { entry, template, entryTemplates, preload, disableMinify } = this.options; const { assets, chunks } = compilation.getStats().toJson(); const allAssets = chunks .filter(chunk => chunk.initial) .map(chunk => chunk.files[0]) as string[]; const entryAssets: EntryAsset[] = []; let shareAssets: string[] = []; allAssets.forEach(asset => { const name = getAssetName(asset); if (entry[name]) { entryAssets.push({ name, content: asset }); } else { // common entry assets shareAssets.push(asset); } }); const manifestIndex = shareAssets.findIndex(item => item.includes("manifest")); let manifestContent = ""; if (manifestIndex !== -1) { const manifestItem = shareAssets.splice(manifestIndex, 1)[0]; manifestContent = compilation.assets[manifestItem].source(); delete compilation.assets[manifestItem]; } // get dll entry const dllAssets = assets .filter(asset => asset.name.includes(".dll.js")) .map(asset => asset.name) .sort((a: string) => (a.startsWith("common") ? -1 : 1)); const shareScripts = shareAssets.concat(dllAssets); const shareStyles = assets .filter(asset => asset.name.includes(".css")) .map(asset => asset.name); let defaultTemplateContent = ""; if (template) { defaultTemplateContent = readFileContent(template); } else { // 正常的逻辑不会进入这里 printError("The default template is empty."); return; } const scripts = entryAssets.map(item => { const templateContent = typeof entryTemplates !== "undefined" && typeof entryTemplates[item.name] !== "undefined" ? readFileContent(entryTemplates[item.name]) : defaultTemplateContent; let htmlContent = ejs.render(templateContent, { title: this.options.title, styles: shareStyles, scripts: shareScripts.concat(item.content), manifestContent, customTplData: this.options.customTplData }); if (this.options.isProd && typeof preload !== "undefined") { htmlContent = insertPreload(preload, chunks, htmlContent, item.name); } return { name: item.name, content: disableMinify !== true && this.options.isProd ? minify(htmlContent, { removeAttributeQuotes: true, removeComments: true, collapseWhitespace: true }) : htmlContent } as EntryAsset; }); scripts.forEach(item => { compilation.assets[`${item.name}.html`] = { source: () => item.content, size: () => item.content.length }; }); return cb(); }); } }