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();
});
}
}