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.
399 lines (352 loc) • 10.1 kB
JavaScript
const path = require('path');
const { green, yellow, red } = require('ansis');
// constants used for imported styles in JavaScript
const baseUri = 'webpack://';
const urlPathPrefix = '/__HTML_BUNDLER_PLUGIN__/';
const cssLoaderName = 'HTMLBundlerCSSLoader';
const labelInfo = (loaderName, label) => `\n${green`[${loaderName}${label ? ':' + label : ''}]`}`;
const labelWarn = (loaderName, label) => `\n${yellow`[${loaderName}${label ? ':' + label : ''}]`}`;
const labelError = (loaderName, label) => `\n${red`[${loaderName}${label ? ':' + label : ''}]`}`;
/**
* Resolve absolute path to node module main file what can be dynamically required anywhere in code.
*
* @param {string} moduleName The resolving module name.
* @param {string} context The current working directory where is the node_modules folder.
* @return {string | false} If module exists return resolved module path otherwise false.
* @throws
*/
const resolveModule = (moduleName, context = process.cwd()) => {
let moduleFile;
try {
moduleFile = require.resolve(moduleName, { paths: [context] });
} catch (error) {
if (error.code === 'MODULE_NOT_FOUND') {
return false;
}
throw error;
}
return moduleFile;
};
/**
* Call an async function for the each array item.
*
* @param {Array<any>} data The array of data.
* @param {Function: Promise} fn The async function to handle the data.
* @return {Promise<Promise<Awaited<unknown>[]>|Promise<void>>}
*/
const eachAsync = async (data, fn) => (Array.isArray(data) ? Promise.all(data.map(fn)) : Promise.resolve());
/**
* Make template ID as relative posix path.
*
* @param {string} context
* @param {string} filePath
* @return {string}
*/
const makeTemplateId = (context, filePath) => path.relative(context, filePath).replace(/\\/g, '/');
/**
* Inject a string before closing </head> tag.
*
* @param {string} content
* @param {string} string
* @return {string}
*/
const injectBeforeEndHead = (content, string) => injectBefore(content, string, ['</head>', '</body>', '</html>']);
/**
* Inject a string before closing </body> tag.
*
* @param {string} content
* @param {string} string
* @return {string}
*/
const injectBeforeEndBody = (content, string) => injectBefore(content, string, ['</body>', '</html>']);
/**
* Inject a string before one of founded strings
*
* @param {string} content Where should be injected a string.
* @param {string} string The string to inject in content.
* @param {Array<string>} before The string will be injected before the first found entry.
* If no entry is found, the string will be added to the end of the content.
* @return string
*/
const injectBefore = (content, string, before = []) => {
let pos = content.length;
for (let str of before) {
let currentPos = content.indexOf(str);
if (currentPos >= 0) {
pos = currentPos;
break;
}
}
return content.slice(0, pos) + string + content.slice(pos);
};
/**
* Encode reserved HTML chars.
*
* @param {string} str
* @return {string}
*/
const encodeReservedChars = (str) => {
if (str.indexOf('?') < 0) return str;
const match = /[&'"]/g;
const replacements = { '&': '\\u0026', "'": '\\u0060', '"': '\\u0060' };
const replacer = (value) => replacements[value];
return str.replace(match, replacer);
};
/**
* Decode/encode reserved HTML chars.
*
* @param {string} str
* @return {string}
*/
const decodeReservedChars = (str) => {
const match = /('|\\u0026|\\u0027|\\u0060|\n|\r|\\)/g;
const replacements = {
'\\u0026': '&',
'\\u0027': "'",
'\\u0060': "\\'",
"'": "\\'",
'\n': '\\n',
'\r': '\\r',
'\\': '\\\\',
};
const replacer = (value) => replacements[value];
return str.replace(match, replacer);
};
/**
* Transform escape sequences to printing strings.
*
* @param {string} str
* @return {string}
*/
const escapeSequences = (str) => {
const match = /(\n|\r|\t)/g;
const replacements = {
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
};
const replacer = (value) => replacements[value];
return str.replace(match, replacer);
};
/**
* Escape codes in stringify JSON.
*
* @param {string} str
* @return {string}
*/
const escapeCodesForJSON = (str) => {
const match = /(`|\\)/g;
const replacements = {
'`': '\\`',
'\\': '\\\\',
};
const replacer = (value) => replacements[value];
return str.replace(match, replacer);
};
/**
* Stringify JSON data.
*
* @note The quality of function source code defined in the data limited by function.toString().
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/toString
* TODO: for complex data structure should be implemented ideas from https://github.com/yahoo/serialize-javascript/blob/main/index.js
*
* @param {Object} data The JSON data.
* @return {string}
*/
const stringifyJSON = (data) => {
const quoteMark = '__REMOVE_QUOTE__';
let hasQuoteMarks = false;
let json = JSON.stringify(data, (key, value) => {
if (typeof value === 'function') {
let source = stringifyFn(value);
if (source == null) {
throw new Error(`A function cannot be stringified in JSON data at key: '${key}'.`);
}
value = quoteMark + source + quoteMark;
hasQuoteMarks = true;
}
return value;
});
return hasQuoteMarks
? // remove the quotes around the function body
json.replace(/("__REMOVE_QUOTE__|__REMOVE_QUOTE__")/g, '')
: json || '{}';
};
/**
* Stringify any JavaScript function.
*
* @param {Function} fn - The function to stringify.
* @returns {string|null} The cleaned stringified function or null if not a function or native.
*/
const stringifyFn = (fn) => {
if (typeof fn !== 'function') return null;
try {
const raw = fn.toString();
// skip native or bound functions
if (raw.includes('[native code]') || raw.includes('[object Function]')) {
return null;
}
// safe comment removal
let cleaned = stripComments(raw);
// remove leading indent and join into one line
cleaned = cleaned
.split('\n')
.map((line) => line.trimStart())
.join(' ')
.trim();
// check if it is top-level arrow function
const isArrowFunction = /^(\(?[^=(){};]*\)?)\s*=>/.test(cleaned);
// replace method shorthand to function expression
// Example: getFoo(a, b) { ... } -> function(a, b) { ... }
if (!isArrowFunction) {
const pos = cleaned.indexOf('(');
if (pos > 0 && cleaned.slice(0, pos).trim() !== 'function') {
cleaned = 'function' + cleaned.slice(pos);
}
}
return cleaned;
} catch {
return null;
}
};
/**
* Removes all JavaScript comments (single-line and multi-line) from code,
* while preserving strings and regex literals with comment-like patterns.
*
* @param {string} code The source code.
* @returns {string} The code with all comments removed.
*/
function stripComments(code) {
let out = '';
let inStr = null; // current quote: ', ", or `
let inRegex = false; // inside a /.../ regex literal
let inRegexClass = false; // inside a [...] character class within regex
let inLineComment = false; // inside //
let inBlockComment = false; // inside /* */
let prev = '';
let i = 0;
const len = code.length;
while (i < len) {
const char = code[i];
const next = code[i + 1];
// line comment end
if (inLineComment) {
if (char === '\n' || char === '\r') {
inLineComment = false;
out += char;
}
i++;
continue;
}
// block comment end
if (inBlockComment) {
if (char === '*' && next === '/') {
inBlockComment = false;
i += 2;
} else {
i++;
}
continue;
}
// string start
if (!inStr && !inRegex && (char === '"' || char === "'" || char === '`')) {
inStr = char;
out += char;
i++;
continue;
}
// string end
if (inStr) {
out += char;
if (char === '\\') {
// copy escaped char
if (i + 1 < len) {
out += code[i + 1];
i += 2;
} else {
i++;
}
continue;
} else if (char === inStr) {
inStr = null;
}
i++;
continue;
}
// RegExp start
if (!inRegex && char === '/' && next !== '/' && next !== '*') {
// scan backward for prev non-whitespace char
let j = i - 1;
while (j >= 0 && /\s/.test(code[j])) j--;
const prevChar = j >= 0 ? code[j] : '';
// treat as regex if after certain tokens or start of input
if (j < 0 || /[=({\[:;,!&|?~%^<>*/]/.test(prevChar)) {
inRegex = true;
out += char;
i++;
continue;
}
// otherwise treat as division
}
// RegExp end
if (inRegex) {
out += char;
if (inRegexClass) {
if (char === ']' && prev !== '\\') inRegexClass = false;
} else {
if (char === '[' && prev !== '\\') {
inRegexClass = true;
} else if (char === '/' && prev !== '\\') {
inRegex = false;
// copy regex flags
let k = i + 1;
while (k < len && /[a-z]/i.test(code[k])) {
out += code[k];
k++;
}
i = k;
continue;
}
}
prev = char;
i++;
continue;
}
// line comment start
if (char === '/' && next === '/') {
inLineComment = true;
i += 2;
continue;
}
// block comment start
if (char === '/' && next === '*') {
inBlockComment = true;
i += 2;
continue;
}
out += char;
i++;
}
return out;
}
module.exports = {
baseUri,
urlPathPrefix,
cssLoaderName,
labelInfo,
labelWarn,
labelError,
hotUpdateFile: path.join(__dirname, 'Hmr/hot-update.js'),
resolveModule,
eachAsync,
makeTemplateId,
injectBeforeEndHead,
injectBeforeEndBody,
stripComments,
encodeReservedChars,
decodeReservedChars,
escapeSequences,
escapeCodesForJSON,
stringifyJSON,
stringifyFn,
};