@hyperse/html-webpack-plugin-loader
Version:
A custom template loader that parses HTML templates for the `html-webpack-plugin` package
583 lines (575 loc) • 18.2 kB
JavaScript
;
var parse5 = require('parse5');
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
}) : x)(function(x) {
if (typeof require !== "undefined") return require.apply(this, arguments);
throw Error('Dynamic require of "' + x + '" is not supported');
});
var parseDocument = (htmlSource) => {
const document = parse5.parse(htmlSource);
const html = document.childNodes.find(
(node) => node.nodeName === "html"
);
const head = html?.childNodes.find(
(node) => node.nodeName === "head"
);
const body = html?.childNodes.find(
(node) => node.nodeName === "body"
);
if (!head || !body) {
throw new Error("Invalid HTML template: missing <head> or <body> tags");
}
return { document, html, head, body };
};
var sortDocument = (document) => {
const sortedDocument = { ...document };
const html = document.childNodes.find(
(node) => node.nodeName === "html"
);
if (!html) {
return sortedDocument;
}
const head = html.childNodes.find(
(node) => node.nodeName === "head"
);
const body = html.childNodes.find(
(node) => node.nodeName === "body"
);
if (head) {
sortNodesByPosition(head, ["link", "style"], (node) => {
const elementNode = node;
const hasId = elementNode.attrs?.find((attr) => attr.name === "id");
const hasOrder = elementNode.attrs?.find(
(attr) => attr.name === "data-order"
);
const hasPosition = elementNode.attrs?.find(
(attr) => attr.name === "data-position"
);
if (!hasId || !hasOrder || !hasPosition) {
return false;
}
if (node.nodeName === "link") {
const isStylesheet = elementNode.attrs?.find(
(attr) => attr.name === "rel" && attr.value === "stylesheet"
);
return !!isStylesheet;
}
return true;
});
sortNodesByPosition(head, ["script"], (node) => {
const elementNode = node;
const hasId = elementNode.attrs?.find((attr) => attr.name === "id");
const hasOrder = elementNode.attrs?.find(
(attr) => attr.name === "data-order"
);
const hasPosition = elementNode.attrs?.find(
(attr) => attr.name === "data-position"
);
return !!(hasId && hasOrder && hasPosition);
});
cleanupSortingAttributes(head);
}
if (body) {
sortNodesByPosition(body, ["script"], (node) => {
const elementNode = node;
const hasId = elementNode.attrs?.find((attr) => attr.name === "id");
const hasOrder = elementNode.attrs?.find(
(attr) => attr.name === "data-order"
);
const hasPosition = elementNode.attrs?.find(
(attr) => attr.name === "data-position"
);
return !!(hasId && hasOrder && hasPosition);
});
cleanupSortingAttributes(body);
}
return sortedDocument;
};
var sortNodesByPosition = (element, nodeNames, filterFn) => {
const beginningNodes = [];
const endNodes = [];
element.childNodes.forEach((node) => {
if (nodeNames.includes(node.nodeName) && filterFn(node)) {
const elementNode = node;
const orderAttr = elementNode.attrs?.find(
(attr) => attr.name === "data-order"
);
const positionAttr = elementNode.attrs?.find(
(attr) => attr.name === "data-position"
);
if (orderAttr && positionAttr) {
const order = parseInt(orderAttr.value, 10) || 0;
const nodeInfo = { node: elementNode, order };
if (positionAttr.value === "beginning") {
beginningNodes.push(nodeInfo);
} else if (positionAttr.value === "end") {
endNodes.push(nodeInfo);
}
}
}
});
[...beginningNodes, ...endNodes].forEach(({ node }) => {
const index = element.childNodes.indexOf(node);
if (index > -1) {
element.childNodes.splice(index, 1);
}
});
beginningNodes.sort((a, b) => a.order - b.order);
endNodes.sort((a, b) => a.order - b.order);
beginningNodes.reverse().forEach(({ node }) => {
element.childNodes.unshift(node);
});
endNodes.forEach(({ node }) => {
element.childNodes.push(node);
});
};
var cleanupSortingAttributes = (element) => {
if (element.attrs) {
element.attrs = element.attrs.filter(
(attr) => attr.name !== "data-order" && attr.name !== "data-position"
);
}
element.childNodes.forEach((node) => {
if (node.nodeName && node.nodeName !== "#text" && node.nodeName !== "#comment") {
const childElement = node;
cleanupSortingAttributes(childElement);
}
});
};
var upsertScripts = (element, scripts) => {
const sortedScripts = [...scripts].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
);
sortedScripts.forEach((script) => {
const existingScriptIndex = element.childNodes.findIndex(
(node) => node.nodeName === "script" && node.attrs?.find(
(attr) => attr.name === "id" && attr.value === script.id
)
);
if (existingScriptIndex > -1) {
element.childNodes.splice(existingScriptIndex, 1);
}
});
const scriptTags = sortedScripts.map((script) => {
const scriptNode = parse5.parseFragment(
`<script id="${script.id}" src="${script.src}" data-order="${script.order}" data-position="${script.position}"></script>`
).childNodes[0];
if (script.type) {
scriptNode.attrs?.push({ name: "type", value: script.type });
}
if (script.async) {
scriptNode.attrs?.push({ name: "async", value: "true" });
}
if (script.defer) {
scriptNode.attrs?.push({ name: "defer", value: "true" });
}
if (script.crossOrigin) {
scriptNode.attrs?.push({
name: "crossorigin",
value: script.crossOrigin
});
}
if (script.integrity) {
scriptNode.attrs?.push({ name: "integrity", value: script.integrity });
}
if (script.nonce) {
scriptNode.attrs?.push({ name: "nonce", value: script.nonce });
}
return scriptNode;
});
const beginningScripts = sortedScripts.reduce((acc, script, index) => {
if (script.position === "beginning") {
acc.push(scriptTags[index]);
}
return acc;
}, []);
const endScripts = sortedScripts.reduce(
(acc, script, index) => {
if (script.position === "end") {
acc.push(scriptTags[index]);
}
return acc;
},
[]
);
beginningScripts.reverse().forEach((scriptNode) => {
element.childNodes.unshift(scriptNode);
});
endScripts.forEach((scriptNode) => {
element.childNodes.push(scriptNode);
});
};
// src/parser/upsertBodySctipts.ts
var upsertBodySctipts = (body, scripts) => {
return upsertScripts(body, scripts);
};
var upsertFavicon = (head, href, rel = "icon", attributes = {}) => {
const existingLinkIndex = head.childNodes.findIndex(
(node) => node.nodeName === "link" && node.attrs?.find(
(attr) => attr.name === "rel" && attr.value === rel
)
);
if (existingLinkIndex > -1) {
head.childNodes.splice(existingLinkIndex, 1);
}
const attributesString = Object.entries(attributes).map(([key, value]) => `${key}="${value}"`).join(" ");
const linkNode = parse5.parseFragment(
`<link rel="${rel}" href="${href}" ${attributesString}>`
).childNodes[0];
let insertIndex = 0;
let lastMetaIndex = -1;
for (let i = 0; i < head.childNodes.length; i++) {
if (head.childNodes[i].nodeName === "meta") {
lastMetaIndex = i;
}
}
if (lastMetaIndex >= 0) {
insertIndex = lastMetaIndex + 1;
} else {
insertIndex = 0;
}
head.childNodes.splice(insertIndex, 0, linkNode);
};
var upsertHeadInlineScripts = (head, scripts) => {
scripts.forEach((script) => {
const existingScriptIndex = head.childNodes.findIndex(
(node) => node.nodeName === "script" && node.attrs?.find(
(attr) => attr.name === "id" && attr.value === script.id
)
);
if (existingScriptIndex > -1) {
head.childNodes.splice(existingScriptIndex, 1);
}
});
const sortedScripts = [...scripts].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
);
const scriptTags = sortedScripts.map((script) => {
const scriptNode = parse5.parseFragment(
`<script id="${script.id}" data-order="${script.order}" data-position="${script.position}">${script.content}</script>`
).childNodes[0];
return scriptNode;
});
const beginningScripts = sortedScripts.reduce((acc, script, index) => {
if (script.position === "beginning") {
acc.push(scriptTags[index]);
}
return acc;
}, []);
const endScripts = sortedScripts.reduce(
(acc, script, index) => {
if (script.position === "end") {
acc.push(scriptTags[index]);
}
return acc;
},
[]
);
beginningScripts.reverse().forEach((scriptNode) => {
head.childNodes.unshift(scriptNode);
});
endScripts.forEach((scriptNode) => {
head.childNodes.push(scriptNode);
});
};
var upsertHeadInlineStyles = (head, styles) => {
const sortedStyles = [...styles].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
);
sortedStyles.forEach((style) => {
const existingStyleIndex = head.childNodes.findIndex(
(node) => node.nodeName === "style" && node.attrs?.find(
(attr) => attr.name === "id" && attr.value === style.id
)
);
if (existingStyleIndex > -1) {
head.childNodes.splice(existingStyleIndex, 1);
}
});
const styleTags = sortedStyles.map((style) => {
const styleNode = parse5.parseFragment(
`<style id="${style.id}" data-order="${style.order}" data-position="${style.position}">${style.content}</style>`
).childNodes[0];
return styleNode;
});
const beginningStyles = sortedStyles.reduce((acc, style, index) => {
if (style.position === "beginning") {
acc.push(styleTags[index]);
}
return acc;
}, []);
const endStyles = sortedStyles.reduce(
(acc, style, index) => {
if (style.position === "end") {
acc.push(styleTags[index]);
}
return acc;
},
[]
);
beginningStyles.reverse().forEach((styleNode) => {
head.childNodes.unshift(styleNode);
});
endStyles.forEach((styleNode) => {
head.childNodes.push(styleNode);
});
};
var upsertHeadMetaTags = (head, tags) => {
tags.forEach((tag) => {
const parsedTag = parse5.parseFragment(tag).childNodes[0];
if (parsedTag.nodeName !== "meta") return;
const parsedNameAttr = parsedTag.attrs?.find(
(attr) => attr.name === "name"
);
if (!parsedNameAttr) return;
const existingTagIndex = head.childNodes.findIndex((node) => {
if (node.nodeName !== "meta") return false;
const nodeElement = node;
const nodeNameAttr = nodeElement.attrs?.find(
(attr) => attr.name === "name"
);
return nodeNameAttr?.value === parsedNameAttr.value;
});
if (existingTagIndex > -1) {
head.childNodes.splice(existingTagIndex, 1);
}
});
const parsedTags = tags.map(
(tag) => parse5.parseFragment(tag).childNodes[0]
);
parsedTags.reverse().forEach((tag) => {
head.childNodes.unshift(tag);
});
};
var upsertHeadStyles = (head, styles) => {
const sortedStyles = [...styles].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
);
sortedStyles.forEach((style) => {
const existingStyleIndex = head.childNodes.findIndex(
(node) => node.nodeName === "link" && node.attrs?.find(
(attr) => attr.name === "rel" && attr.value === "stylesheet"
) && node.attrs?.find(
(attr) => attr.name === "id" && attr.value === style.id
)
);
if (existingStyleIndex > -1) {
head.childNodes.splice(existingStyleIndex, 1);
}
});
const styleTags = sortedStyles.map((style) => {
const styleNode = parse5.parseFragment(
`<link rel="stylesheet" href="${style.href}" id="${style.id}" data-order="${style.order}" data-position="${style.position}"></link>`
).childNodes[0];
return styleNode;
});
const beginningStyles = sortedStyles.reduce((acc, style, index) => {
if (style.position === "beginning") {
acc.push(styleTags[index]);
}
return acc;
}, []);
const endStyles = sortedStyles.reduce(
(acc, style, index) => {
if (style.position === "end") {
acc.push(styleTags[index]);
}
return acc;
},
[]
);
beginningStyles.reverse().forEach((styleNode) => {
head.childNodes.unshift(styleNode);
});
endStyles.forEach((styleNode) => {
head.childNodes.push(styleNode);
});
};
var upsertTitle = (head, title) => {
const titleNode = head.childNodes?.find(
(node) => node.nodeName === "title"
);
if (titleNode) {
titleNode.childNodes = [
{
nodeName: "#text",
value: title,
parentNode: titleNode
}
];
} else {
const newTitle = parse5.parseFragment("<title></title>").childNodes[0];
newTitle.childNodes = [
{
nodeName: "#text",
value: title,
parentNode: newTitle
}
];
head.childNodes.unshift(newTitle);
}
};
// src/parser/TemplateParser.ts
var TemplateParser = class {
constructor(htmlSource) {
const { document, head, body, html } = parseDocument(htmlSource);
this.document = document;
this.html = html;
this.head = head;
this.body = body;
}
/**
* Upsert the title tag - inserts at beginning of <head>
* @param title - The title to upsert
* @returns The TemplateParser instance
*/
upsertTitleTag(title) {
upsertTitle(this.head, title);
return this;
}
/**
* Upsert the favicon tag - inserts at beginning of <head> after title tag
* @param href - The favicon to upsert
* @param rel - The rel attribute of the favicon tag
* @param attributes - The attributes of the favicon tag
* @returns The TemplateParser instance
*/
upsertFaviconTag(href, rel = "icon", attributes = {}) {
upsertFavicon(this.head, href, rel, attributes);
return this;
}
/**
* Upsert meta tags in <head> - inserts at beginning for SEO priority
* @param tags - The meta tags to upsert
* @returns The TemplateParser instance
*/
upsertHeadMetaTags(tags) {
upsertHeadMetaTags(this.head, tags);
return this;
}
/**
* Upsert external stylesheets in <head> - supports position-based insertion
* @param styles - The external styles to upsert
* @returns The TemplateParser instance
*/
upsertHeadStyles(styles) {
upsertHeadStyles(this.head, styles);
return this;
}
/**
* Upsert inline styles in <head> - supports position-based insertion
* @param styles - The inline styles to upsert
* @returns The TemplateParser instance
*/
upsertHeadInlineStyles(styles) {
upsertHeadInlineStyles(this.head, styles);
return this;
}
/**
* Upsert external scripts in <head> - supports position-based insertion
* @param scripts - The external scripts to upsert
* @returns The TemplateParser instance
*/
upsertHeadScripts(scripts) {
upsertScripts(this.head, scripts);
return this;
}
/**
* Upsert inline scripts in <head> - supports position-based insertion
* @param scripts - The inline scripts to upsert
* @returns The TemplateParser instance
*/
upsertHeadInlineScripts(scripts) {
upsertHeadInlineScripts(this.head, scripts);
return this;
}
/**
* Upsert scripts in <body> - supports position-based insertion, typically at end for performance
* @param scripts - The scripts to upsert
* @returns The TemplateParser instance
*/
upsertBodyScripts(scripts) {
upsertBodySctipts(this.body, scripts);
return this;
}
/**
* Serialize the document to html string
* @returns The serialized html string
*/
serialize() {
const sortedDocument = sortDocument(this.document);
return parse5.serialize(sortedDocument);
}
/**
* Parse a fragment of html
* @param html - The html fragment to parse
* @returns The parsed document fragment
*/
parseFragment(html) {
return parse5.parseFragment(html);
}
};
// src/parser/parseTemplate.ts
var parseTemplate = (htmlSource, options = {}) => {
const parser = new TemplateParser(htmlSource);
if (options.headMetaTags?.length) {
parser.upsertHeadMetaTags(options.headMetaTags);
}
if (options.favicon) {
parser.upsertFaviconTag(
options.favicon.href,
options.favicon.rel,
options.favicon.attributes
);
}
if (options.title) {
parser.upsertTitleTag(options.title);
}
if (options.headStyles?.length) {
parser.upsertHeadStyles(options.headStyles);
}
if (options.headInlineStyles?.length) {
parser.upsertHeadInlineStyles(options.headInlineStyles);
}
if (options.headScripts?.length) {
parser.upsertHeadScripts(options.headScripts);
}
if (options.headInlineScripts?.length) {
parser.upsertHeadInlineScripts(options.headInlineScripts);
}
if (options.bodyScripts?.length) {
parser.upsertBodyScripts(options.bodyScripts);
}
return parser;
};
// src/loader/htmlLoader.ts
function htmlLoader(source) {
const options = this.getOptions();
const force = options.force || false;
const allLoadersButThisOne = this.loaders.filter(
(loader) => loader.normal !== module.exports
);
if (allLoadersButThisOne.length > 0 && !force) {
return source;
}
const htmlWebpackPluginLoaders = this.loaders.filter(
(loader) => loader.normal === module.exports
);
const lastHtmlWebpackPluginLoader = htmlWebpackPluginLoaders[htmlWebpackPluginLoaders.length - 1];
if (this.loaders[this.loaderIndex] !== lastHtmlWebpackPluginLoader) {
return source;
}
if (!/\.html$/.test(this.resourcePath)) {
return source;
}
return [
'const { TemplateParser } = eval("require")(' + JSON.stringify(__require.resolve("../index.cjs")) + ");",
"const parseTemplate = " + parseTemplate.toString() + ";",
"const source = " + JSON.stringify(source) + ";",
"module.exports = (function(templateParams) { ",
"return parseTemplate(source, templateParams || {}).serialize();",
"});"
].join("");
}
module.exports = htmlLoader;