Blame Raw
· · · 416 lines (16.3 KB)
0 contributors
1import { MacroParser } from './MacroParser.js';
2import { MacroCstWalker } from './MacroCstWalker.js';
3import { MacroRegistry, MacroValueType } from './MacroRegistry.js';
4import { logMacroGeneralError, logMacroInternalError, logMacroRuntimeWarning, logMacroSyntaxWarning } from './MacroDiagnostics.js';
5import { ELSE_MARKER } from '../definitions/core-macros.js';
6
7/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
8/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
9/** @typedef {import('./MacroRegistry.js').MacroDefinitionOptions} MacroDefinitionOptions */
10/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
11
12/**
13 * A processor function that transforms text before or after macro evaluation.
14 *
15 * @callback MacroProcessor
16 * @param {string} text - The text to process.
17 * @param {MacroEnv} env - The macro environment.
18 * @returns {string} The processed text.
19 */
20
21/**
22 * @typedef {Object} RegisteredProcessor
23 * @property {MacroProcessor} handler - The processor function.
24 * @property {number} priority - Execution priority (lower = earlier).
25 * @property {string} source - Identifier for debugging/tracking.
26 */
27
28/**
29 * The singleton instance of the MacroEngine.
30 *
31 * @type {MacroEngine}
32 */
33let instance;
34export { instance as MacroEngine };
35
36class MacroEngine {
37 /** @type {MacroEngine} */ static #instance;
38 /** @type {MacroEngine} */ static get instance() { return MacroEngine.#instance ?? (MacroEngine.#instance = new MacroEngine()); }
39
40 /** @type {RegisteredProcessor[]} */
41 #preProcessors = [];
42 /** @type {RegisteredProcessor[]} */
43 #postProcessors = [];
44
45 constructor() {
46 this.#registerCorePreProcessors();
47 this.#registerCorePostProcessors();
48 }
49
50 /**
51 * Registers a pre-processor to run before macro evaluation.
52 *
53 * @param {MacroProcessor} handler - The processor function.
54 * @param {Object} [options] - Configuration options.
55 * @param {number} [options.priority=100] - Execution priority (lower = earlier).
56 * @param {string} [options.source='unknown'] - Identifier for debugging.
57 */
58 addPreProcessor(handler, { priority = 100, source = 'unknown' } = {}) {
59 this.#preProcessors.push({ handler, priority, source });
60 this.#preProcessors.sort((a, b) => a.priority - b.priority);
61 }
62
63 /**
64 * Removes a previously registered pre-processor.
65 *
66 * @param {MacroProcessor} handler - The processor function to remove.
67 * @returns {boolean} True if the processor was found and removed.
68 */
69 removePreProcessor(handler) {
70 const index = this.#preProcessors.findIndex(p => p.handler === handler);
71 if (index !== -1) {
72 this.#preProcessors.splice(index, 1);
73 return true;
74 }
75 return false;
76 }
77
78 /**
79 * Registers a post-processor to run after macro evaluation.
80 *
81 * @param {MacroProcessor} handler - The processor function.
82 * @param {Object} [options] - Configuration options.
83 * @param {number} [options.priority=100] - Execution priority (lower = earlier).
84 * @param {string} [options.source='unknown'] - Identifier for debugging.
85 */
86 addPostProcessor(handler, { priority = 100, source = 'unknown' } = {}) {
87 this.#postProcessors.push({ handler, priority, source });
88 this.#postProcessors.sort((a, b) => a.priority - b.priority);
89 }
90
91 /**
92 * Removes a previously registered post-processor.
93 *
94 * @param {MacroProcessor} handler - The processor function to remove.
95 * @returns {boolean} True if the processor was found and removed.
96 */
97 removePostProcessor(handler) {
98 const index = this.#postProcessors.findIndex(p => p.handler === handler);
99 if (index !== -1) {
100 this.#postProcessors.splice(index, 1);
101 return true;
102 }
103 return false;
104 }
105
106 /**
107 * Evaluates a string containing macros and resolves them.
108 *
109 * @param {string} input - The input string to evaluate.
110 * @param {MacroEnv} env - The environment to pass to the macro handler.
111 * @param {Object} [options={}] - Optional evaluation settings.
112 * @param {number} [options.contextOffset=0] - Base offset from the original top-level document.
113 * Used when evaluating nested content (via resolve() in handlers) to preserve global
114 * positioning for macros like {{pick}} that seed on position.
115 * @returns {string} The resolved string.
116 */
117 evaluate(input, env, { contextOffset = 0 } = {}) {
118 if (!input) {
119 return '';
120 }
121 const safeEnv = Object.freeze({ ...env });
122
123 const preProcessed = this.#runPreProcessors(input, safeEnv);
124
125 const { cst, lexingErrors, parserErrors } = MacroParser.parseDocument(preProcessed);
126
127 // For now, we log and still try to process what we can.
128 if (lexingErrors && lexingErrors.length > 0) {
129 logMacroSyntaxWarning({ phase: 'lexing', input, errors: lexingErrors });
130 }
131 if (parserErrors && parserErrors.length > 0) {
132 logMacroSyntaxWarning({ phase: 'parsing', input, errors: parserErrors });
133 }
134
135 // If the parser did not produce a valid CST, fall back to the original input.
136 if (!cst || typeof cst !== 'object' || !cst.children) {
137 logMacroGeneralError({ message: 'Macro parser produced an invalid CST. Returning original input.', error: { input, lexingErrors, parserErrors } });
138 return input;
139 }
140
141 let evaluated;
142 try {
143 evaluated = MacroCstWalker.evaluateDocument({
144 text: preProcessed,
145 contextOffset,
146 cst,
147 env: safeEnv,
148 resolveMacro: this.#resolveMacro.bind(this),
149 trimContent: this.trimScopedContent.bind(this),
150 });
151 } catch (error) {
152 logMacroGeneralError({ message: 'Macro evaluation failed. Returning original input.', error: { input, error } });
153 return input;
154 }
155
156 const result = this.#runPostProcessors(evaluated, safeEnv);
157
158 return result;
159 }
160
161 /**
162 * Resolves a macro call.
163 *
164 * @param {MacroCall} call - The macro call to resolve.
165 * @returns {string} The resolved macro.
166 */
167 #resolveMacro(call) {
168 const { name, env } = call;
169
170 const raw = `{{${call.rawInner}}}`;
171 if (!name) return raw;
172
173 // 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.
174 // Dynamic macro keys are normalized to lowercase for case-insensitive matching.
175 /** @type {MacroDefinition|null} */
176 let defOverride = null;
177 const nameLower = name.toLowerCase();
178 if (Object.hasOwn(env.dynamicMacros, nameLower)) {
179 const impl = env.dynamicMacros[nameLower];
180
181 // Dynamic macros support three formats:
182 // 1. string - direct value, no args allowed
183 // 2. function - handler function, no args allowed (legacy behavior)
184 // 3. MacroDefinitionOptions object - full definition with handler, args, type validation, etc.
185
186 // Check if this looks like a MacroDefinitionOptions object (has handler property)
187 const looksLikeOptions = impl && typeof impl === 'object' &&
188 'handler' in impl && typeof impl.handler === 'function';
189
190 if (looksLikeOptions) {
191 // Case 3: MacroDefinitionOptions - use the full definition builder
192 try {
193 const options = /** @type {MacroDefinitionOptions} */ (impl);
194 defOverride = MacroRegistry.buildMacroDefFromOptions(name, options);
195 } catch (error) {
196 // If building fails, log warning and fall through to check registered macros
197 logMacroRuntimeWarning({ message: `Dynamic macro "${name}" has invalid options: ${error.message}`, call });
198 }
199 } else if (['string', 'number', 'boolean', 'function'].includes((typeof impl))) {
200 // Case 1 & 2: string or handler function
201 if (['number', 'boolean'].includes(typeof impl)) {
202 logMacroRuntimeWarning({ message: `Dynamic macro "${name}" uses unsupported number/boolean format.`, call });
203 }
204 defOverride = MacroRegistry.buildMacroDefFromOptions(name, {
205 handler: typeof impl === 'function' ? impl : () => String(impl ?? ''),
206 category: 'dynamic',
207 description: 'Dynamic macro',
208 returnType: MacroValueType.STRING,
209 });
210 } else {
211 logMacroRuntimeWarning({ message: `Dynamic macro "${name}" is not defined correctly (must be string, a handler function, or a macro def options object with handler property).`, call });
212 }
213 }
214
215 // If not, check if the macro exists and is registered
216 if (!defOverride && !MacroRegistry.hasMacro(name)) {
217 return raw; // Unknown macro: keep macro syntax, but nested macros inside rawInner are already resolved.
218 }
219
220 try {
221 const result = MacroRegistry.executeMacro(call, { defOverride });
222
223 try {
224 return call.env.functions.postProcess(result);
225 } catch (error) {
226 logMacroInternalError({ message: `Macro "${name}" postProcess function failed.`, call, error });
227 return result;
228 }
229 } catch (error) {
230 const isRuntimeError = !!(error && (error.name === 'MacroRuntimeError' || error.isMacroRuntimeError));
231 if (isRuntimeError) {
232 logMacroRuntimeWarning({ message: (error.message || `Macro "${name}" execution failed.`), call, error });
233 } else {
234 logMacroInternalError({ message: `Macro "${name}" internal execution error.`, call, error });
235 }
236 return raw;
237 }
238 }
239
240 /**
241 * Runs pre-processors on the input text, before the engine processes the input.
242 *
243 * @param {string} text - The input text to process.
244 * @param {MacroEnv} env - The environment to pass to the macro handler.
245 * @returns {string} The processed text.
246 */
247 #runPreProcessors(text, env) {
248 let result = text;
249 for (const { handler } of this.#preProcessors) {
250 result = handler(result, env);
251 }
252 return result;
253 }
254
255 /**
256 * Runs post-processors on the input text, after the engine finished processing the input.
257 *
258 * @param {string} text - The input text to process.
259 * @param {MacroEnv} env - The environment to pass to the macro handler.
260 * @returns {string} The processed text.
261 */
262 #runPostProcessors(text, env) {
263 let result = text;
264 for (const { handler } of this.#postProcessors) {
265 result = handler(result, env);
266 }
267 return result;
268 }
269
270 /**
271 * Registers the core pre/post processors that handle legacy syntax and cleanup.
272 */
273 #registerCorePreProcessors() {
274 // Pre-processors (priority 0-50 reserved for core)
275
276 // This legacy macro will not be supported by the new macro parser, but rather regex-replaced beforehand
277 // {{time_UTC-10}} => {{time::UTC-10}}
278 this.addPreProcessor(
279 text => text.replace(/{{time_(UTC[+-]\d+)}}/gi, (_match, utcOffset) => `{{time::${utcOffset}}}`),
280 { priority: 10, source: 'core:legacy-time-syntax' },
281 );
282
283 // Legacy non-curly markers like <USER>, <BOT>, <GROUP>, etc.
284 // These are rewritten into their equivalent macro forms so they go through the normal engine pipeline.
285 this.addPreProcessor(
286 text => text
287 .replace(/<USER>/gi, '{{user}}')
288 .replace(/<BOT>/gi, '{{char}}')
289 .replace(/<CHAR>/gi, '{{char}}')
290 .replace(/<GROUP>/gi, '{{group}}')
291 .replace(/<CHARIFNOTGROUP>/gi, '{{charIfNotGroup}}'),
292 { priority: 20, source: 'core:legacy-markers' },
293 );
294 }
295
296 /**
297 * Registers the core post-processors that handle legacy syntax and cleanup.
298 */
299 #registerCorePostProcessors() {
300 // Post-processors (priority 0-50 reserved for core)
301
302 // Unescape braces: \{ → { and \} → }
303 // Since \{\{ doesn't match {{ (MacroStart), it passes through as plain text.
304 // We only need to remove the backslashes in post-processing.
305 this.addPostProcessor(
306 text => text.replace(/\\([{}])/g, '$1'),
307 { priority: 10, source: 'core:unescape-braces' },
308 );
309
310 // The original trim macro is reaching over the boundaries of the defined macro. This is not something the engine supports.
311 // To treat {{trim}} as it was before, we won't process it by the engine itself,
312 // but doing a regex replace on {{trim}} and the surrounding area, after all other macros have been processed.
313 this.addPostProcessor(
314 text => text.replace(/(?:\r?\n)*{{trim}}(?:\r?\n)*/gi, ''),
315 { priority: 20, source: 'core:legacy-trim' },
316 );
317
318 // Remove any wrongly placed leftover ELSE_MARKER that might have been inserted during processing
319 this.addPostProcessor(
320 text => text.replaceAll(ELSE_MARKER, ''),
321 { priority: 30, source: 'core:cleanup-else-marker' },
322 );
323 }
324
325 /**
326 * Normalizes macro results into a string.
327 * This mirrors the behavior of the legacy macro system in a simplified way.
328 *
329 * @param {any} value
330 * @returns {string}
331 */
332 normalizeMacroResult(value) {
333 if (value === null || value === undefined) {
334 return '';
335 }
336 if (value instanceof Date) {
337 return value.toISOString();
338 }
339 if (typeof value === 'object' || Array.isArray(value)) {
340 try {
341 return JSON.stringify(value);
342 } catch (_error) {
343 return String(value);
344 }
345 }
346
347 return String(value);
348 }
349
350 /**
351 * Trims scoped content with optional indentation dedent.
352 *
353 * When trimIndent is true (default), this function:
354 * 1. Trims leading and trailing whitespace (like String.trim())
355 * 2. Finds the indentation of the first non-empty line
356 * 3. Removes that amount of leading whitespace from all subsequent lines
357 *
358 * This allows neatly formatted scoped macros like:
359 * ```
360 * {{if condition}}
361 * # Heading
362 * Content here
363 * {{/if}}
364 * ```
365 * To produce "# Heading\nContent here" instead of "# Heading\n Content here"
366 *
367 * @param {string} content - The content to trim
368 * @param {Object} options - Configuration options
369 * @param {boolean} [options.trimIndent=true] - Whether to also dedent consistent indentation
370 * @returns {string} The trimmed content
371 */
372 trimScopedContent(content, { trimIndent = true } = {}) {
373 if (!content) return '';
374
375 // If not dedenting, just do a basic trim
376 if (!trimIndent) {
377 return content.trim();
378 }
379
380 // Split into lines BEFORE trimming to preserve indentation info
381 const lines = content.split('\n');
382
383 // Find the first non-empty line (has non-whitespace characters)
384 let baseIndent = 0;
385 for (const line of lines) {
386 if (line.trim() !== '') {
387 // Found first non-empty line - get its indentation
388 const match = line.match(/^[ \t]*/);
389 baseIndent = match ? match[0].length : 0;
390 break;
391 }
392 }
393
394 // If no indentation to remove, just trim and return
395 if (baseIndent === 0) {
396 return content.trim();
397 }
398
399 // Remove the base indentation from ALL lines
400 const dedentedLines = lines.map(line => {
401 // Only remove indentation if the line has enough leading whitespace
402 const match = line.match(/^[ \t]*/);
403 const lineIndent = match ? match[0].length : 0;
404 if (lineIndent >= baseIndent) {
405 return line.slice(baseIndent);
406 }
407 // Line has less indentation than base - just trim its leading whitespace
408 return line.trimStart();
409 });
410
411 // Join and trim the final result
412 return dedentedLines.join('\n').trim();
413 }
414}
415
416instance = MacroEngine.instance;