Nanny7's picture
Initial deploy with custom mobile UI
6efa67a
import { MacroParser } from './MacroParser.js';
import { MacroCstWalker } from './MacroCstWalker.js';
import { MacroRegistry } from './MacroRegistry.js';
import { logMacroGeneralError, logMacroInternalError, logMacroRuntimeWarning, logMacroSyntaxWarning } from './MacroDiagnostics.js';
/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
/**
* The singleton instance of the MacroEngine.
*
* @type {MacroEngine}
*/
let instance;
export { instance as MacroEngine };
class MacroEngine {
/** @type {MacroEngine} */ static #instance;
/** @type {MacroEngine} */ static get instance() { return MacroEngine.#instance ?? (MacroEngine.#instance = new MacroEngine()); }
constructor() { }
/**
* Evaluates a string containing macros and resolves them.
*
* @param {string} input - The input string to evaluate.
* @param {MacroEnv} env - The environment to pass to the macro handler.
* @returns {string} The resolved string.
*/
evaluate(input, env) {
if (!input) {
return '';
}
const safeEnv = Object.freeze({ ...env });
const preProcessed = this.#runPreProcessors(input, safeEnv);
const { cst, lexingErrors, parserErrors } = MacroParser.parseDocument(preProcessed);
// For now, we log and still try to process what we can.
if (lexingErrors && lexingErrors.length > 0) {
logMacroSyntaxWarning({ phase: 'lexing', input, errors: lexingErrors });
}
if (parserErrors && parserErrors.length > 0) {
logMacroSyntaxWarning({ phase: 'parsing', input, errors: parserErrors });
}
// If the parser did not produce a valid CST, fall back to the original input.
if (!cst || typeof cst !== 'object' || !cst.children) {
logMacroGeneralError({ message: 'Macro parser produced an invalid CST. Returning original input.', error: { input, lexingErrors, parserErrors } });
return input;
}
let evaluated;
try {
evaluated = MacroCstWalker.evaluateDocument({
text: preProcessed,
cst,
env: safeEnv,
resolveMacro: this.#resolveMacro.bind(this),
});
} catch (error) {
logMacroGeneralError({ message: 'Macro evaluation failed. Returning original input.', error: { input, error } });
return input;
}
const result = this.#runPostProcessors(evaluated, safeEnv);
return result;
}
/**
* Resolves a macro call.
*
* @param {MacroCall} call - The macro call to resolve.
* @returns {string} The resolved macro.
*/
#resolveMacro(call) {
const { name, env } = call;
const raw = `{{${call.rawInner}}}`;
if (!name) return raw;
// First check if this is a dynamic macro to use. If so, we will create a temporary macro definition for it and use that over any registered macro.
/** @type {MacroDefinition?} */
let defOverride = null;
if (Object.hasOwn(env.dynamicMacros, name)) {
const impl = env.dynamicMacros[name];
defOverride = {
name,
aliases: [],
category: 'dynamic',
description: 'Dynamic macro',
minArgs: 0,
maxArgs: 0,
unnamedArgDefs: [],
list: null,
strictArgs: true, // Fail dynamic macros if they are called with arguments
returns: null,
returnType: 'string',
displayOverride: null,
exampleUsage: [],
source: { name: 'dynamic', isExtension: false, isThirdParty: false },
aliasOf: null,
aliasVisible: null,
handler: typeof impl === 'function' ? impl : () => impl,
};
}
// If not, check if the macro exists and is registered
if (!defOverride && !MacroRegistry.hasMacro(name)) {
return raw; // Unknown macro: keep macro syntax, but nested macros inside rawInner are already resolved.
}
try {
const result = MacroRegistry.executeMacro(call, { defOverride });
try {
return call.env.functions.postProcess(result);
} catch (error) {
logMacroInternalError({ message: `Macro "${name}" postProcess function failed.`, call, error });
return result;
}
} catch (error) {
const isRuntimeError = !!(error && (error.name === 'MacroRuntimeError' || error.isMacroRuntimeError));
if (isRuntimeError) {
logMacroRuntimeWarning({ message: (error.message || `Macro "${name}" execution failed.`), call, error });
} else {
logMacroInternalError({ message: `Macro "${name}" internal execution error.`, call, error });
}
return raw;
}
}
/**
* Runs pre-processors on the input text, before the engine processes the input.
*
* @param {string} text - The input text to process.
* @param {MacroEnv} env - The environment to pass to the macro handler.
* @returns {string} The processed text.
*/
#runPreProcessors(text, env) {
let result = text;
// This legacy macro will not be supported by the new macro parser, but rather regex-replaced beforehand
// {{time_UTC-10}} => {{time::UTC-10}}
result = result.replace(/{{time_(UTC[+-]\d+)}}/gi, (_match, utcOffset) => {
return `{{time::${utcOffset}}}`;
});
// Legacy non-curly markers like <USER>, <BOT>, <GROUP>, etc.
// These are rewritten into their equivalent macro forms so they go through the normal engine pipeline.
result = result.replace(/<USER>/gi, '{{user}}');
result = result.replace(/<BOT>/gi, '{{char}}');
result = result.replace(/<CHAR>/gi, '{{char}}');
result = result.replace(/<GROUP>/gi, '{{group}}');
result = result.replace(/<CHARIFNOTGROUP>/gi, '{{charIfNotGroup}}');
return result;
}
/**
* Runs post-processors on the input text, after the engine finished processing the input.
*
* @param {string} text - The input text to process.
* @param {MacroEnv} env - The environment to pass to the macro handler.
* @returns {string} The processed text.
*/
#runPostProcessors(text, env) {
let result = text;
// Unescape braces: \{ → { and \} → }
// Since \{\{ doesn't match {{ (MacroStart), it passes through as plain text.
// We only need to remove the backslashes in post-processing.
result = result.replace(/\\([{}])/g, '$1');
// The original trim macro is reaching over the boundaries of the defined macro. This is not something the engine supports.
// To treat {{trim}} as it was before, we won't process it by the engine itself,
// but doing a regex replace on {{trim}} and the surrounding area, after all other macros have been processed.
result = result.replace(/(?:\r?\n)*{{trim}}(?:\r?\n)*/gi, '');
return result;
}
/**
* Normalizes macro results into a string.
* This mirrors the behavior of the legacy macro system in a simplified way.
*
* @param {any} value
* @returns {string}
*/
normalizeMacroResult(value) {
if (value === null || value === undefined) {
return '';
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === 'object' || Array.isArray(value)) {
try {
return JSON.stringify(value);
} catch (_error) {
return String(value);
}
}
return String(value);
}
}
instance = MacroEngine.instance;