185 lines
8.7 KiB
JavaScript
185 lines
8.7 KiB
JavaScript
// @ts-check
|
|
import UglifyJS from 'uglify-js';
|
|
|
|
// https://stackoverflow.com/a/63838890
|
|
const escapeRegExp = (/** @type {string} */ string) => string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
export function minifyCode(/** @type {string} */ script) {
|
|
// "return" statements outside of a function throw a parse error
|
|
script = "()=>{" + script + "}";
|
|
const output = UglifyJS.minify(script, {
|
|
compress: false,
|
|
mangle: false
|
|
});
|
|
if (output.error) throw output.error;
|
|
if (output.warnings) throw (output.warnings);
|
|
if (output.warnings) console.warn(output.warnings);
|
|
let code = output.code;
|
|
if (code.endsWith(";")) code = code.slice(0, -1);
|
|
code = code.slice(5, -1); // unwrap from function
|
|
return code;
|
|
}
|
|
|
|
class ModUtils {
|
|
script = "";
|
|
/** @type {{[key: string]: string}} */
|
|
dictionary = {};
|
|
safeDictionary = new Proxy(this.dictionary, {
|
|
get(target, prop) {
|
|
if (typeof prop === 'symbol') prop = prop.toString();
|
|
if (prop in target) return target[prop];
|
|
throw new Error(`Property ${prop} is not defined in dictionary`);
|
|
}
|
|
});
|
|
/** @type {Function[]} */
|
|
postMinifyHandlers = [];
|
|
|
|
constructor(/** @type {string} */ script) {
|
|
this.script = script;
|
|
// Bind methods
|
|
this.matchDictionaryExpression = this.matchDictionaryExpression.bind(this);
|
|
this.generateRegularExpression = this.generateRegularExpression.bind(this);
|
|
this.replace = this.replace.bind(this);
|
|
this.replaceOne = this.replaceOne.bind(this);
|
|
this.replaceRawCode = this.replaceRawCode.bind(this);
|
|
this.matchOne = this.matchOne.bind(this);
|
|
this.matchRawCode = this.matchRawCode.bind(this);
|
|
this.replaceCode = this.replaceCode.bind(this);
|
|
this.waitForMinification = this.waitForMinification.bind(this);
|
|
this.matchCode = this.matchCode.bind(this);
|
|
this.insertCode = this.insertCode.bind(this);
|
|
}
|
|
|
|
/** @param {RegExp} expression */
|
|
matchDictionaryExpression(expression) {
|
|
const result = this.matchOne(expression);
|
|
// @ts-ignore
|
|
for (let [key, value] of Object.entries(result.groups)) this.addToDictionary(key, value);
|
|
}
|
|
replace(/** @type {Parameters<typeof String.prototype.replace>} */ ...args) {
|
|
return this.script = this.script.replace(...args);
|
|
};
|
|
/** Expressions passed to this function must have the global flag set. */
|
|
matchOne(/** @type {RegExp} */ expression) {
|
|
const result = expression.exec(this.script);
|
|
if (result === null) throw new Error("no match for: " + expression.toString());
|
|
if (expression.exec(this.script) !== null) throw new Error("more than one match for: " + expression.toString());
|
|
return result;
|
|
};
|
|
addToDictionary(/** @type {string} */ key, /** @type {string} */ value) {
|
|
if (this.dictionary[key] !== undefined && this.dictionary[key] !== value)
|
|
throw new Error("name different from existing one:\n KEY: " + key + "\n VALUE: " + value + "\n Value in dictionary: " + this.dictionary[key]);
|
|
this.dictionary[key] = value;
|
|
};
|
|
/**
|
|
* @param {RegExp} expression
|
|
* @param {string} replaceValue
|
|
*/
|
|
replaceOne(expression, replaceValue) {
|
|
const result = this.matchOne(expression);
|
|
// this (below) works correctly because expression.lastIndex gets reset above in matchOne when there is no match
|
|
this.script = this.script.replace(expression, replaceValue);
|
|
return result;
|
|
};
|
|
|
|
// Return value example:
|
|
// When replaceRawCode or matchRawCode are called with "var1=var2+1;" as the code
|
|
// and this matches "a=b+1;", the returned value will be the object: { var1: "a", var2: "b" }
|
|
/** @param {{ [x: string]: string; }} [nameMappings] */
|
|
replaceRawCode(/** @type {string} */ raw, /** @type {string} */ result, nameMappings) {
|
|
const { expression, groups } = this.generateRegularExpression(raw, false, nameMappings);
|
|
let localizerCount = 0;
|
|
let replacementString = result.replaceAll("$", "$$").replaceAll("__L()", "__L)").replaceAll("__L(", "__L,")
|
|
.replace(/\w+/g, match => {
|
|
if (nameMappings !== undefined && nameMappings.hasOwnProperty(match)) return nameMappings[match];
|
|
// these would get stored as "___localizer1", "___localizer2", ...
|
|
if (match === "__L") match = "___localizer" + (++localizerCount);
|
|
return groups.hasOwnProperty(match) ? "$" + groups[match] : match;
|
|
});
|
|
//console.log(replacementString);
|
|
let expressionMatchResult;
|
|
try { expressionMatchResult = this.replaceOne(expression, replacementString); }
|
|
catch (e) {
|
|
throw new Error("replaceRawCode match error:\n\n" + e + "\n\nRaw code: " + raw + "\n");
|
|
}
|
|
return Object.fromEntries(Object.entries(groups).map(([identifier, groupNumber]) => [identifier, expressionMatchResult[groupNumber]]));
|
|
}
|
|
matchRawCode(/** @type {string} */ raw, nameMappings) {
|
|
const { expression, groups } = this.generateRegularExpression(raw, false, nameMappings);
|
|
try {
|
|
const expressionMatchResult = this.matchOne(expression);
|
|
return Object.fromEntries(Object.entries(groups).map(
|
|
([identifier, groupNumber]) => [identifier, expressionMatchResult[groupNumber]]
|
|
));
|
|
} catch (e) {
|
|
throw new Error("matchRawCode match error:\n\n" + e + "\n\nRaw code: " + raw + "\n");
|
|
}
|
|
}
|
|
generateRegularExpression(/** @type {string} */ code, /** @type {boolean} */ isForDictionary, nameMappings) {
|
|
const groups = {};
|
|
let groupNumberCounter = 1;
|
|
let localizerCount = 0;
|
|
let raw = escapeRegExp(code).replaceAll("__L\\(\\)", "___localizer\\)")
|
|
// when there is a parameter, add a comma to separate it from the added number
|
|
.replaceAll("__L\\(", "___localizer,");
|
|
raw = raw.replace(isForDictionary ? /(?:@@)*(@?)(\w+)/g : /()(\w+)/g, (_match, modifier, word) => {
|
|
// if a substitution string for the "word" is specified in the nameMappings, use it
|
|
if (nameMappings && nameMappings.hasOwnProperty(word)) return nameMappings[word];
|
|
// if the "word" is a number or is one of these specific words, ingore it
|
|
if (/^\d/.test(word) || ["return", "this", "var", "function", "new", "Math", "WebSocket"].includes(word)) return word;
|
|
// for easy localizer function matching
|
|
else if (word === "___localizer") {
|
|
groups[word + (++localizerCount)] = groupNumberCounter++;
|
|
return "\\b(L\\(\\d+)"; // would match "L(123", "L(50" and etc. when using "__L("
|
|
}
|
|
else if (groups.hasOwnProperty(word)) return "\\" + groups[word]; // regex numeric reference to the group
|
|
else {
|
|
groups[word] = groupNumberCounter++;
|
|
return modifier === "@" ? `(?<${word}>\\w+)` : "(\\w+)";
|
|
}
|
|
});
|
|
let expression = new RegExp(isForDictionary ? raw.replaceAll("@@", "@") : raw, "g");
|
|
return { expression, groups };
|
|
}
|
|
|
|
/**
|
|
* @typedef {{ dictionary?: { [x: string]: string } }} BaseOptions
|
|
* @typedef {BaseOptions & { addToDictionary?: string[] }} MatchCodeOptions
|
|
*/
|
|
matchCode(code, /** @type {MatchCodeOptions=} */ options) {
|
|
const result = this.matchRawCode(minifyCode(code));
|
|
if (options?.addToDictionary !== undefined) {
|
|
options.addToDictionary.forEach(varName => {
|
|
if (result[varName] === undefined)
|
|
throw new Error(`matchCode addToDictionary error: ${varName} was undefined in the match results`)
|
|
this.addToDictionary(varName, result[varName]);
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
replaceCode(/** @type {string} */ code, /** @type {string} */ replacement, /** @type {BaseOptions=} */ options) {
|
|
return this.replaceRawCode(minifyCode(code), replacement, options?.dictionary);
|
|
}
|
|
|
|
/**
|
|
* @param {string} code
|
|
* @param {string} codeToInsert
|
|
* @param {BaseOptions} [options]
|
|
*/
|
|
insertCode(code, codeToInsert, options) {
|
|
const insertionPoint = "/* here */";
|
|
if (!code.includes(insertionPoint)) throw new Error("insertCode: No insertion point found");
|
|
return this.replaceCode(code.replace(insertionPoint, ""), code.replace(insertionPoint, codeToInsert), options);
|
|
}
|
|
|
|
waitForMinification(/** @type {Function} */ handler) {
|
|
this.postMinifyHandlers.push(handler);
|
|
}
|
|
executePostMinifyHandlers() {
|
|
this.postMinifyHandlers.forEach(handler => handler());
|
|
}
|
|
escapeRegExp = escapeRegExp
|
|
}
|
|
|
|
export default ModUtils; |