Macros 2.0 (v0.4) - Add scoped macros (last arg can be scoped), `{{if}}` macro and macro flags (baseline implementation) (#4913) * Macros: make category optional, default to UNCATEGORIZED * Tests: Update macro tests to match new error handling behavior - Change MacroRegistry tests from expecting thrown errors to capturing console.error logs - Update MacroLexer tests to expect plaintext fallback instead of lexer errors for invalid tokens - Fix MacroEngine test to use `char` macro instead of `newline` for arity validation - Update MacroParser test with corrected expected error message for invalid identifiers - Remove "[Error]" prefixes from test descriptions where lexer no longer errors * Macros: Implement macro execution flags system - Add MacroFlags module with flag parsing and validation (!, ?, ~, /, >, ., $, #) - Update lexer to tokenize flags as separate tokens before macro identifier - Modify parser to capture flags in CST under 'flags' label - Update CST walker to parse flag tokens into MacroFlags object - Pass parsed flags to macro handlers via MacroCall and MacroContext - Update autocomplete parser to handle flags before identifier - Add comprehensive tests for flag parsing, * Macros: Fix autocomplete positioning for macros with flags and whitespace - Add identifierStart to parseMacroContext to track where identifier begins in macro text - Update autocomplete to use identifierStart for correct range calculation - Simplify indexMacros regex to use global flag instead of manual loop - Use parseMacroContext in indexMacros to extract identifier (handles flags/whitespace) - Fix autocomplete range starting at wrong position when flags or whitespace present * Macros: Add autocomplete support for macro execution flags - Add MacroFlagAutoCompleteOption class for rendering flag options in autocomplete - Extend parseMacroContext to track currentFlag (flag cursor is on) and isInFlagsArea - Track flagEndPositions to determine which flag cursor is currently typing - Update #buildEnhancedMacroOptions to show flag options when cursor is in flags area - Show current flag first if cursor just typed it, then show remaining available flags - Add renderItem and renderDetails methods to MacroFlag * Macros: Implement scoped macro syntax with opening and closing tags - Add scoped macro processing to MacroCstWalker to find and merge opening/closing pairs - Parse closing block flag (/) to identify closing macros and match with opening tags - Extract content between opening and closing tags as the last unnamed argument - Add `isScoped` property to MacroCall and MacroContext to track scoped invocations - Implement `#processScopedMacros` to find outermost matching pairs and handle nesting * Macros: Add autocomplete warnings, scoped content info, and closing tag suggestions - Add arity warning banners in autocomplete details for invalid argument counts - Show warning when using space-separated args on multi-arg or no-arg macros - Add scoped content info banner when cursor is inside unclosed scoped macro - Implement MacroClosingTagAutoCompleteOption to suggest closing tags for scoped macros - Add sortPriority property to AutoCompleteOption for controlling sort order * SlashCommands: Disable unimplemented flags and closing flag when no unclosed scopes in autocomplete - Set closing block flag as non-selectable when no unclosed scopes exist - Set unimplemented flags as non-selectable with empty valueProvider - Lower sort priority (12) for non-selectable flags vs selectable flags (10) * Autocomplete: Show scoped content info and auto-close no-arg macros - Show scoped content info when cursor is at closing }} of unclosed scoped macro - Auto-complete no-arg macros with closing }} using valueProvider - Trigger autocomplete on select (isSelect) to refresh after choosing an option - Simplify MacroFlagAutoCompleteOption to use base makeItem for consistent styling - Change closing tag icon from '{/}' to '{/' for better visual consistency * Macros: Add scoped trim macro to trim content inside opening/closing tags - Add scoped usage for {{trim}}content{{/trim}} to trim whitespace from content - Keep non-scoped {{trim}} behavior (post-processing marker) for backward compatibility - Add optional unnamed 'content' argument for scoped usage - Update description to explain both scoped and non-scoped behavior - Handler checks isScoped flag to determine which behavior to use * Autocomplete: Fix closing tag parsing to prevent `/` being treated as flag - Add special case in parseMacroContext to detect closing tags (`/` + identifier char) - Stop flag parsing when `/` is followed by identifier character (closing tag syntax) - Simplify MacroClosingTagAutoCompleteOption valueProvider to return full closing tag - Remove input-based logic since autocomplete replaces entire identifier * Macros: Add {{if}} conditional macro with auto-resolution of macro names - Add {{if condition}}content{{/if}} macro to conditionally show content - Auto-resolve condition if it matches a registered macro name (0 required args) - Support both scoped content ({{if x}}...{{/if}}) and explicit args ({{if::x::content}}) - Treat empty string, "false", "off", "0" as falsy conditions - Inherit environment context when resolving macro names - Update autocomplete warning to allow space-separated syntax * Macros: Add centralized identifier validation with pattern enforcement - Export MACRO_IDENTIFIER_PATTERN from MacroLexer for reuse across modules - Add isIdentifierValid() helper function to validate macro names and aliases - Enforce identifier pattern: must start with letter, followed by word chars or hyphens - Update macro registration to validate both primary names and alias identifiers - Improve error messages to explain identifier requirements - Add comprehensive e2e tests for valid/invalid identifier patterns * Macros: Add tests for scoped {{trim}} macro functionality * SlashCommands: Fix macro indexing to properly handle nested macros with brace depth tracking - Replace regex-based macro detection with manual brace depth tracking - Track opening/closing brace pairs to correctly identify macro boundaries - Ensure nested macros like {{reverse::Hey {{user}}}} are properly indexed - Index both outer and inner macros by scanning content recursively - Handle unclosed macros by defaulting to end of text * Macros: Add {{else}} branch support to {{if}} conditional macro - Add {{else}} macro as marker to split then/else branches in {{if}} blocks - Use control character sequence (\u0000\u001FELSE\u001F\u0000) as internal marker - Split scoped content on else marker and trim both branches independently - Return then-branch if condition is truthy, else-branch if falsy - Auto-suggest {{else}} in autocomplete when inside scoped {{if}} block - Make {{else}} non-selectable in autocomplete when outside {{if}} scope * Macros: Add negation support to {{if}} conditional macro with ! prefix - Add ! prefix support to invert condition evaluation in {{if}} macro - Parse original macro text to detect ! prefix before macro resolution - Strip ! from condition after detecting inversion to avoid double-negation - Invert isFalsy result when ! prefix is detected in original condition - Prevent ! in resolved values from triggering inversion (only original syntax) * Autocomplete: Add {{if}} condition autocomplete with zero-arg macro suggestions - Add EnhancedMacroAutoCompleteOptions typedef for noBraces/paddingAfter/closeWithBraces options - Support options object in EnhancedMacroAutoCompleteOption constructor alongside context - Add noBraces mode to display macro names without {{ }} braces (for use as values) - Add paddingAfter option to match opening whitespace style before closing }} * Autocomplete: Match opening whitespace padding when auto-closing macros * Fix `{{if}}` example usages * Macros: Hide `comment` alias from autocomplete suggestions for `//` macro * Macros: Simplify {{trim}} handler with destructured parameters and clearer content check * Macros: Use MacroEngine.evaluate for zero-arg macro resolution in {{if}} condition handler * Macros: Add auto-trim for scoped content with # flag to preserve whitespace - Auto-trim scoped content by default in MacroCstWalker before passing to handlers - Add preserveWhitespace flag (# symbol) to prevent auto-trimming when needed - Rename legacyHash flag to preserveWhitespace across engine and definitions - Update {{trim}} handler to rely on engine auto-trim for scoped content - Update {{if}} handler to respect # flag when trimming branches around {{else}} marker * Macros: Clarify macro name validation error message to use "alphanumeric characters" instead of "word chars" * Add 'setspriteoverride' optional 'name' argument * Refactor ElevenLabs TTS API key handling (#4906) * Refactor ElevenLabs TTS API key handling #4483 * Remove unused connection button and related event handler from ElevenLabs TTS provider * Add ElevenLabs STT endpoint * Add caching system prompt feature for OpenRouter Gemini (#4903) * feat: add caching system prompt for OpenRouter Gemini * fix: resolve reviews * Update GitHub links to llama.cpp * Add model selection support for llama.cpp router mode (#4910) * Add model selection support for llama.cpp router mode - Add llamacpp_model setting to textgen-settings.js - Implement loadLlamaCppModels() function to fetch and populate models - Add onLlamaCppModelSelect() handler for model selection - Update status check to load llama.cpp models when connecting - Update getTextGenModel() to return selected llama.cpp model - Add model dropdown to HTML UI in llama.cpp section - Initialize event handlers and Select2 for better UX - Add llamacpp_model to preset manager for save/load support - Add llamacpp_model to slash commands support This implements model selection for llama.cpp router mode, allowing users to select from multiple models without restarting the server. Follows the same pattern as Ollama, Tabby, and vLLM implementations. * Correct spelling * Fix clear selection position --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com> * Add glm-4.7 model option and context mapping for Z.AI * Add select2 style for NanoGPT list Closes #4911 * Disable macro engine init traces * Update type annotations for instruct presets and context presets * Fix test for new error text * Add MacroStoryString tests * Add trimContent utility for consistent indentation dedenting in scoped macros - Add trimScopedContent method to MacroEngine that trims and dedents scoped content based on first non-empty line indentation - Pass trimContent utility through evaluation context to all macro handlers - Update {{if}} macro to use trimContent instead of direct trim() call - Update auto-trim logic in MacroCstWalker to use trimContent for consistent dedenting - Add trimContent to MacroExecutionContext type definitions * Add ELSE_MARKER export and cleanup leftover markers in macro processing * Update trimContent parameter to use options object pattern in JSDoc * Add processor registration system to MacroEngine with priority-based execution - Add MacroProcessor callback and RegisteredProcessor typedef for pre/post processors - Add addPreProcessor/removePreProcessor and addPostProcessor/removePostProcessor methods with priority-based sorting - Refactor core legacy syntax handling into registered processors with reserved priorities (0-50) - Move legacy time syntax, marker replacements, brace unescaping, trim macro, and ELSE_MARKER cleanup to registered processors * Split core processor registration into separate pre and post processor methods --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

e9bedadc0bbf71585a8bfe6258b297ae7da7a948

Wolfsblvt <wolfsblvt@gmail.com>

Signed
20 files changed, +3658 -205Showing whitespace changes
public/css/macros.css+61 -0
@@ -465,6 +465,67 @@
465465 color: #F89406;
466466}
467467
468+/* Arity warning banner in details */
469+.macro-ac-warning {
470+ display: flex;
471+ align-items: baseline;
472+ gap: 0.5em;
473+ padding: 0.5em 0.75em;
474+ background: linear-gradient(90deg, rgba(248, 148, 6, 0.2), transparent);
475+ border-left: 3px solid #F89406;
476+ border-radius: 0 4px 4px 0;
477+ margin-bottom: 0.5em;
478+ font-size: 0.9em;
479+ color: #F89406;
480+}
481+
482+.macro-ac-warning i {
483+ font-size: 0.9em;
484+}
485+
486+/* Scoped content info banner in details */
487+.macro-ac-scoped-info {
488+ display: flex;
489+ align-items: baseline;
490+ gap: 0.5em;
491+ padding: 0.5em 0.75em;
492+ background: linear-gradient(90deg, rgba(91, 192, 222, 0.2), transparent);
493+ border-left: 3px solid #5BC0DE;
494+ border-radius: 0 4px 4px 0;
495+ margin-bottom: 0.5em;
496+ font-size: 0.9em;
497+ color: #5BC0DE;
498+}
499+
500+.macro-ac-scoped-info i {
501+ font-size: 0.9em;
502+}
503+
504+.macro-ac-scoped-info code {
505+ background: rgba(91, 192, 222, 0.15);
506+ padding: 0.1em 0.3em;
507+ border-radius: 3px;
508+}
509+
510+/* Closing tag autocomplete option */
511+.autoComplete > .item.macro-closing-tag-item > .type {
512+ color: var(--ac-color-matchedText, var(--SmartThemeBorderColor));
513+}
514+
515+.macro-closing-tag-details {
516+ padding: 0.5em;
517+}
518+
519+.macro-closing-tag-details h3 {
520+ margin: 0 0 0.5em 0;
521+ font-size: 1.1em;
522+}
523+
524+.macro-closing-tag-details p {
525+ margin: 0;
526+ opacity: 0.9;
527+}
528+
468529/* Current argument hint banner in details */
469530.macro-ac-arg-hint {
470531 display: flex;
public/scripts/autocomplete/AutoComplete.js+16 -4
@@ -311,8 +311,8 @@ export class AutoComplete {
311311 this.name = this.parserResult.name.toLowerCase() ?? '';
312312
313313 const isCursorInNamePart = this.textarea.selectionStart >= this.parserResult.start && this.textarea.selectionStart <= this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0);
314314 if (isForced || isInput || isSelect) {
315315 // if forced (ctrl+space) or user input or just selected an option...
316316 if (isCursorInNamePart) {
317317 // ...and cursor is somewhere in the name part (including right behind the final char)
318318 // -> show autocomplete for the (partial if cursor in the middle) name
@@ -393,8 +393,20 @@ export class AutoComplete {
393393 this.updateName(option);
394394 return option;
395395 })
396396 // sort by priority first, then by fuzzy score or alphabetical
397- .toSorted(this.matchType == 'fuzzy' ? this.fuzzyScoreCompare : (a, b) => a.name.localeCompare(b.name));
397+ .toSorted((a, b) => {
398+ // First compare by sortPriority (lower = higher priority)
399+ const priorityA = a.sortPriority ?? 100;
400+ const priorityB = b.sortPriority ?? 100;
401+ if (priorityA !== priorityB) {
402+ return priorityA - priorityB;
403+ }
404+ // Then by fuzzy score or alphabetical
405+ if (this.matchType == 'fuzzy') {
406+ return this.fuzzyScoreCompare(a, b);
407+ }
408+ return a.name.localeCompare(b.name);
409+ });
398410
399411
400412
public/scripts/autocomplete/AutoCompleteNameResultBase.js+2 -2
@@ -24,7 +24,7 @@ export class AutoCompleteNameResultBase {
2424 this.start = start;
2525 this.optionList = optionList;
2626 this.canBeQuoted = canBeQuoted;
2727 this.noMatchText =if (makeNoMatchText ??) this.makeNoMatchText = makeNoMatchText;
2828 this.noOptionstext =if (makeNoOptionsText ??) this.makeNoOptionsText = makeNoOptionsText;
2929 }
3030}
public/scripts/autocomplete/AutoCompleteOption.js+6 -0
@@ -13,6 +13,12 @@ export class AutoCompleteOption {
1313 /** @type {(input:string)=>boolean} */ matchProvider;
1414 /** @type {(input:string)=>string} */ valueProvider;
1515 /** @type {boolean} */ makeSelectable = false;
16+ /**
17+ * Priority for sorting. Lower values = higher priority (sorted first).
18+ * Default is 100 (normal priority). Use lower values for items that should appear at the top.
19+ * @type {number}
20+ */
21+ sortPriority = 100;
1622
1723
1824 /**
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+498 -29
@@ -11,6 +11,7 @@ import {
1111 renderMacroDetails,
1212} from '../macros/MacroBrowser.js';
1313import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
14+import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
1415
1516/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
1617
@@ -19,9 +20,27 @@ import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js'
1920 * @typedef {Object} MacroAutoCompleteContext
2021 * @property {string} fullText - The full macro text being typed (without {{ }}).
2122 * @property {number} cursorOffset - Cursor position within the macro text.
23+ * @property {string} paddingBefore - Padding before the macro identifier/flags.
2224 * @property {string} identifier - The macro identifier (name).
25+ * @property {number} identifierStart - Start position of the identifier within the macro text.
26+ * @property {string[]} flags - Array of flag symbols typed (e.g., ['!', '?']).
27+ * @property {string|null} currentFlag - The flag symbol cursor is currently on (last typed flag), or null.
28+ * @property {boolean} isInFlagsArea - Whether cursor is in the flags area (before identifier starts).
2329 * @property {string[]} args - Array of arguments typed so far.
2430 * @property {number} currentArgIndex - Index of the argument being typed (-1 if on identifier).
31+ * @property {boolean} isTypingSeparator - Whether cursor is on a partial separator (single ':').
32+ * @property {boolean} hasSpaceAfterIdentifier - Whether there's a space after the identifier (for space-separated args).
33+ * @property {boolean} hasSpaceArgContent - Whether there's actual content after the space (not just whitespace).
34+ * @property {number} separatorCount - Number of '::' separators found.
35+ * @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag).
36+ * @property {string} [scopedMacroName] - Name of the scoped macro if in scoped content.
37+ */
38+
39+/**
40+ * @typedef {Object} EnhancedMacroAutoCompleteOptions
41+ * @property {boolean} [noBraces=false] - If true, display without {{ }} braces (for use as values, e.g., in {{if}} conditions).
42+ * @property {string} [paddingAfter=''] - Whitespace to add before closing }} (for matching opening whitespace style).
43+ * @property {boolean} [closeWithBraces=false] - If true, the completion will add }} to close the macro.
2544 */
2645
2746export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
@@ -31,17 +50,53 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
3150 /** @type {MacroAutoCompleteContext|null} */
3251 #context = null;
3352
53+ /** @type {boolean} */
54+ #noBraces = false;
55+
56+ /** @type {string} */
57+ #paddingAfter = '';
58+
3459 /**
3560 * @param {MacroDefinition} macro - The macro definition from MacroRegistry.
3661 * @param {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} [contextcontextOrOptions] - Optional contextContext for argument hints, or options object.
3762 */
3863 constructor(macro, contextcontextOrOptions = null) {
3964 // Use the macro name as the autocomplete key
4065 super(macro.name, enumIcons.macro);
4166 this.#macro = macro;
42- this.#context = context;
67+
68+ // Detect if second argument is context or options
69+ // Context has 'identifier' property, options may have 'noBraces'
70+ if (contextOrOptions && typeof contextOrOptions === 'object') {
71+ if ('noBraces' in contextOrOptions || 'paddingAfter' in contextOrOptions || 'closeWithBraces' in contextOrOptions) {
72+ // It's an options object
73+ const options = /** @type {EnhancedMacroAutoCompleteOptions} */ (contextOrOptions);
74+ this.#noBraces = options.noBraces ?? false;
75+ this.#paddingAfter = options.paddingAfter ?? '';
76+
77+ // If noBraces mode with closeWithBraces, complete with name + padding + }}
78+ if (options.closeWithBraces) {
79+ this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`;
80+ this.makeSelectable = true;
81+ }
82+ } else {
83+ // It's a context object
84+ this.#context = /** @type {MacroAutoCompleteContext} */ (contextOrOptions);
85+ }
86+ }
87+
4388 // nameOffset = 2 to skip the {{ prefix in the display (formatMacroSignature includes braces)
44- this.nameOffset = 2;
89+ // When noBraces is true, nameOffset = 0 since we don't show braces
90+ this.nameOffset = this.#noBraces ? 0 : 2;
91+
92+ // For macros that take no arguments, auto-complete with closing }} (unless already set by options)
93+ if (!this.valueProvider) {
94+ const takesNoArgs = macro.minArgs === 0 && macro.maxArgs === 0 && macro.list === null;
95+ if (takesNoArgs) {
96+ this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`;
97+ this.makeSelectable = true; // Required when using valueProvider
98+ }
99+ }
45100 }
46101
47102 /** @returns {MacroDefinition} */
@@ -74,8 +129,9 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
74129 const nameEl = document.createElement('span');
75130 nameEl.classList.add('name', 'monospace');
76131
77132 // Build signature with individual character spans (includes {{ }})
78- const sigText = formatMacroSignature(this.#macro);
133+ // When noBraces is true, show just the macro name without {{ }}
134+ const sigText = this.#noBraces ? this.#macro.name : formatMacroSignature(this.#macro);
79135 for (const char of sigText) {
80136 const span = document.createElement('span');
81137 span.textContent = char;
@@ -121,17 +177,31 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
121177 renderDetails() {
122178 const frag = document.createDocumentFragment();
123179
180+ // Check for arity warnings
181+ const warning = this.#getArityWarning();
182+ if (warning) {
183+ const warningEl = this.#renderWarning(warning);
184+ frag.append(warningEl);
185+ }
186+
187+ // Show scoped content info banner if we're in scoped content
188+ if (this.#context?.isInScopedContent) {
189+ const scopedInfo = this.#renderScopedContentInfo();
190+ if (scopedInfo) frag.append(scopedInfo);
191+ }
192+
124193 // Determine current argument index for highlighting
125194 const currentArgIndex = this.#context?.currentArgIndex ?? -1;
126195
127196 // Render argument hint banner if we're typing an argument (and no warning)
128197 if (!warning && currentArgIndex >= 0) {
129198 const hint = this.#renderArgumentHint();
130199 if (hint) frag.append(hint);
131200 }
132201
133202 // Reuse MacroBrowser's renderMacroDetails with options
134- const details = renderMacroDetails(this.#macro, { currentArgIndex });
203+ // Don't highlight args if there's a warning
204+ const details = renderMacroDetails(this.#macro, { currentArgIndex: warning ? -1 : currentArgIndex });
135205
136206 // Add class for autocomplete-specific styling overrides
137207 details.classList.add('macro-ac-details');
@@ -141,6 +211,85 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
141211 }
142212
143213 /**
214+ * Checks for arity-related warnings based on the current context.
215+ * @returns {string|null} Warning message, or null if no warning.
216+ */
217+ #getArityWarning() {
218+ if (!this.#context) return null;
219+
220+ const argCount = this.#context.args.length;
221+ const maxArgs = this.#macro.maxArgs;
222+ //const minArgs = this.#macro.minArgs;
223+ const hasList = this.#macro.list !== null;
224+
225+ // Check for too many arguments (only if no list args)
226+ if (!hasList && argCount > maxArgs) {
227+ return `Too many arguments: this macro accepts ${maxArgs === 0 ? 'no arguments' : `up to ${maxArgs} argument${maxArgs === 1 ? '' : 's'}`}, but ${argCount} provided.`;
228+ }
229+
230+ // Check for space-separated arg on macro that doesn't support it
231+ // Space-separated syntax provides 1 arg; with scoped content you can provide a 2nd arg
232+ // So it's valid for macros with maxArgs <= 2 (or with list args)
233+ if (this.#context.hasSpaceArgContent) {
234+ if (maxArgs === 0) {
235+ return 'This macro does not accept any arguments. Remove the space or use a different macro.';
236+ }
237+ if (!hasList && maxArgs > 2) {
238+ return `Space-separated syntax only works for macros with up to 2 arguments. Use :: separators instead: {{${this.#macro.name}::arg1::arg2}}`;
239+ }
240+ }
241+
242+ // Check if trying to add args to a no-arg macro via ::
243+ if (this.#context.separatorCount > 0 && maxArgs === 0) {
244+ return 'This macro does not accept any arguments.';
245+ }
246+
247+ return null;
248+ }
249+
250+ /**
251+ * Renders a warning banner.
252+ * @param {string} message - The warning message.
253+ * @returns {HTMLElement}
254+ */
255+ #renderWarning(message) {
256+ const warning = document.createElement('div');
257+ warning.classList.add('macro-ac-warning');
258+
259+ const icon = document.createElement('i');
260+ icon.classList.add('fa-solid', 'fa-triangle-exclamation');
261+ warning.append(icon);
262+
263+ const text = document.createElement('span');
264+ text.textContent = message;
265+ warning.append(text);
266+
267+ return warning;
268+ }
269+
270+ /**
271+ * Renders the scoped content info banner.
272+ * Shows when cursor is inside scoped content of an unclosed macro.
273+ * @returns {HTMLElement|null}
274+ */
275+ #renderScopedContentInfo() {
276+ if (!this.#context?.isInScopedContent) return null;
277+
278+ const info = document.createElement('div');
279+ info.classList.add('macro-ac-scoped-info');
280+
281+ const icon = document.createElement('i');
282+ icon.classList.add('fa-solid', 'fa-layer-group');
283+ info.append(icon);
284+
285+ const text = document.createElement('span');
286+ text.innerHTML = `Typing <strong>scoped content</strong> for <code>{{${this.#context.scopedMacroName}}}</code>. Close with <code>{{/${this.#context.scopedMacroName}}}</code>`;
287+ info.append(text);
288+
289+ return info;
290+ }
291+
292+ /**
144293 * Renders the current argument hint banner.
145294 * @returns {HTMLElement|null}
146295 */
@@ -210,51 +359,371 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
210359}
211360
212361/**
362+ * Autocomplete option for macro execution flags.
363+ * Shows flag symbol, name, and description.
364+ * Uses default AutoCompleteOption rendering for consistent styling.
365+ */
366+export class MacroFlagAutoCompleteOption extends AutoCompleteOption {
367+ /** @type {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} */
368+ #flagDef;
369+
370+ /**
371+ * @param {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} flagDef - The flag definition.
372+ */
373+ constructor(flagDef) {
374+ // Use the flag symbol as the name, with a flag icon
375+ // Display name includes both symbol and name for clarity
376+ super(flagDef.type, '🚩');
377+ this.#flagDef = flagDef;
378+ }
379+
380+ /** @returns {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} */
381+ get flagDefinition() {
382+ return this.#flagDef;
383+ }
384+
385+ /**
386+ * Renders the autocomplete list item for this flag.
387+ * Uses the same structure as other autocomplete options for consistent styling.
388+ * @returns {HTMLElement}
389+ */
390+ renderItem() {
391+ // Use base class makeItem for consistent styling
392+ const li = this.makeItem(
393+ `${this.#flagDef.type} ${this.#flagDef.name}`, // Display: "? Optional"
394+ '🚩',
395+ true, // noSlash
396+ [], // namedArguments
397+ [], // unnamedArguments
398+ 'void', // returnType
399+ this.#flagDef.description + (this.#flagDef.implemented ? '' : ' (planned)'), // helpString
400+ );
401+ li.setAttribute('data-name', this.name);
402+ li.setAttribute('data-option-type', 'flag');
403+ return li;
404+ }
405+
406+ /**
407+ * Renders the details panel for this flag.
408+ * @returns {DocumentFragment}
409+ */
410+ renderDetails() {
411+ const frag = document.createDocumentFragment();
412+
413+ const details = document.createElement('div');
414+ details.classList.add('macro-flag-details');
415+
416+ // Header with flag symbol and name
417+ const header = document.createElement('h3');
418+ header.classList.add('macro-flag-details-header');
419+ header.innerHTML = `<code>${this.#flagDef.type}</code> ${this.#flagDef.name} Flag`;
420+ details.append(header);
421+
422+ // Description
423+ const desc = document.createElement('p');
424+ desc.classList.add('macro-flag-details-desc');
425+ desc.textContent = this.#flagDef.description;
426+ details.append(desc);
427+
428+ // Status
429+ const status = document.createElement('p');
430+ status.classList.add('macro-flag-details-status');
431+ status.innerHTML = `<strong>Status:</strong> ${this.#flagDef.implemented ? 'Implemented' : 'Planned for future release'}`;
432+ details.append(status);
433+
434+ // Parser effect note
435+ if (this.#flagDef.affectsParser) {
436+ const parserNote = document.createElement('p');
437+ parserNote.classList.add('macro-flag-details-note');
438+ parserNote.innerHTML = '<em>This flag affects how the macro is parsed.</em>';
439+ details.append(parserNote);
440+ }
441+
442+ frag.append(details);
443+ return frag;
444+ }
445+}
446+
447+/**
448+ * Autocomplete option for closing a scoped macro.
449+ * Suggests {{/macroName}} to close an unclosed scoped macro.
450+ */
451+export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
452+ /** @type {string} */
453+ #macroName;
454+
455+ /**
456+ * @param {string} macroName - The name of the macro to close.
457+ */
458+ constructor(macroName) {
459+ // The closing tag is what we're suggesting - use /macroName as the name for matching
460+ const closingTag = `/${macroName}`;
461+ super(closingTag, '{/');
462+ this.#macroName = macroName;
463+
464+ // Custom valueProvider to return the correct replacement text
465+ // Autocomplete REPLACES the typed identifier entirely, so return the full closing tag
466+ this.valueProvider = () => {
467+ // Return full closing tag content (without {{ since that's before the identifier)
468+ return `/${macroName}}}`;
469+ };
470+
471+ // Make selectable so TAB completion works (valueProvider alone makes it non-selectable)
472+ this.makeSelectable = true;
473+
474+ // Highest priority - closing tags should always appear at the very top
475+ this.sortPriority = 1;
476+ }
477+
478+ /** @returns {string} */
479+ get macroName() {
480+ return this.#macroName;
481+ }
482+
483+ /**
484+ * Renders the autocomplete list item for this closing tag.
485+ * Uses the same structure as other macro options for consistent styling.
486+ * @returns {HTMLElement}
487+ */
488+ renderItem() {
489+ const li = document.createElement('li');
490+ li.classList.add('item', 'macro-ac-item');
491+
492+ // Type icon (same column as other macros)
493+ const type = document.createElement('span');
494+ type.classList.add('type', 'monospace');
495+ type.textContent = this.typeIcon;
496+ li.append(type);
497+
498+ // Specs container (for fuzzy highlight compatibility)
499+ const specs = document.createElement('span');
500+ specs.classList.add('specs');
501+
502+ // Name element with character spans
503+ const nameEl = document.createElement('span');
504+ nameEl.classList.add('name', 'monospace');
505+ // Display full closing tag like other macros show full syntax
506+ const displayName = `{{/${this.#macroName}}}`;
507+ for (const char of displayName) {
508+ const span = document.createElement('span');
509+ span.textContent = char;
510+ nameEl.append(span);
511+ }
512+ specs.append(nameEl);
513+ li.append(specs);
514+
515+ // Stopgap (spacer for flex layout)
516+ const stopgap = document.createElement('span');
517+ stopgap.classList.add('stopgap');
518+ li.append(stopgap);
519+
520+ // Help text (description)
521+ const help = document.createElement('span');
522+ help.classList.add('help');
523+ const content = document.createElement('span');
524+ content.classList.add('helpContent');
525+ content.textContent = `Close the {{${this.#macroName}}} scoped macro.`;
526+ help.append(content);
527+ li.append(help);
528+
529+ return li;
530+ }
531+
532+ /**
533+ * Renders the details panel for this closing tag.
534+ * @returns {DocumentFragment}
535+ */
536+ renderDetails() {
537+ const frag = document.createDocumentFragment();
538+
539+ const details = document.createElement('div');
540+ details.classList.add('macro-closing-tag-details');
541+
542+ // Header
543+ const header = document.createElement('h3');
544+ header.innerHTML = `Close <code>{{${this.#macroName}}}</code>`;
545+ details.append(header);
546+
547+ // Description
548+ const desc = document.createElement('p');
549+ desc.textContent = `Inserts the closing tag {{/${this.#macroName}}} to complete the scoped macro. The content between the opening and closing tags will be passed as the last argument.`;
550+ details.append(desc);
551+
552+ frag.append(details);
553+ return frag;
554+ }
555+}
556+
557+/**
213558 * Parses the macro text to determine current argument context.
214- * @param {string} macroText - The text inside {{ }}, e.g., "roll::1d20" or "random::a::b".
559+ * Handles leading whitespace and flags before the identifier.
560+ *
561+ * @param {string} macroText - The text inside {{ }}, e.g., "roll::1d20" or "!user" or " description ".
215562 * @param {number} cursorOffset - Cursor position within macroText.
216563 * @returns {MacroAutoCompleteContext}
217564 */
218565export function parseMacroContext(macroText, cursorOffset) {
219- const parts = [];
220- let currentPart = '';
221- let partStart = 0;
222566 let i = 0;
223567
568+ // Skip leading whitespace
569+ while (i < macroText.length && /\s/.test(macroText[i])) {
570+ i++;
571+ }
572+
573+ // Extract flags (special symbols before the identifier)
574+ // Track position after each flag to determine which flag cursor is on
575+ // Special case: `/` followed by identifier chars is a closing tag, not a flag
576+ const flags = [];
577+ const flagEndPositions = []; // Position right after each flag (before any whitespace)
224578 while (i < macroText.length) {
225- if (macroText[i] === ':' && macroText[i + 1] === ':') {
579+ const char = macroText[i];
226- parts.push({ text: currentPart, start: partStart, end: i });
580+ // Check if this looks like a closing tag: `/` followed by an identifier character
581+ if (char === '/' && i + 1 < macroText.length && /[a-zA-Z_]/.test(macroText[i + 1])) {
582+ // This is a closing tag identifier, not a flag - stop parsing flags
583+ break;
584+ }
585+ if (ValidFlagSymbols.has(char)) {
586+ flags.push(char);
587+ i++;
588+ flagEndPositions.push(i); // Position right after this flag
589+ // Skip whitespace between flags
590+ while (i < macroText.length && /\s/.test(macroText[i])) {
591+ i++;
592+ }
593+ } else {
594+ break;
595+ }
596+ }
597+
598+ // Determine which flag cursor is currently on (if any)
599+ // The "current" flag is the last one typed when cursor is still in the flags area
600+ // This ensures the last typed flag shows at the top of the autocomplete list
601+ let currentFlag = null;
602+ if (flags.length > 0) {
603+ // If cursor is at or after the last flag position but before identifier starts,
604+ // the last flag is the "current" one (just typed)
605+ const lastFlagEnd = flagEndPositions[flagEndPositions.length - 1];
606+ if (cursorOffset >= lastFlagEnd - 1) {
607+ currentFlag = flags[flags.length - 1];
608+ }
609+ }
610+
611+ // Now parse the identifier and arguments starting from position i
612+ const remainingText = macroText.slice(i);
613+ const parts = [];
614+ /** @type {{ start: number, end: number }[]} */
615+ const separatorPositions = []; // Track positions of :: separators
616+ let currentPart = '';
617+ let partStart = i;
618+ let j = 0;
619+
620+ while (j < remainingText.length) {
621+ if (remainingText[j] === ':' && remainingText[j + 1] === ':') {
622+ parts.push({ text: currentPart, start: partStart, end: i + j });
623+ separatorPositions.push({ start: i + j, end: i + j + 2 });
227624 currentPart = '';
228625 ij += 2;
229626 partStart = i + j;
230627 } else {
231628 currentPart += macroTextremainingText[ij];
232629 ij++;
233630 }
234631 }
235632 // Push the last part
236633 parts.push({ text: currentPart, start: partStart, end: macroText.length });
237634
635+ // Determine if cursor is in the flags area (at or before identifier starts)
636+ const identifierStartPos = parts[0]?.start ?? i;
637+ const isInFlagsArea = cursorOffset <= identifierStartPos;
638+
639+ // Check if cursor is on a partial separator (single ':' that might become '::')
640+ const isTypingSeparator = remainingText.length > 0 &&
641+ cursorOffset > identifierStartPos &&
642+ macroText[cursorOffset - 1] === ':' &&
643+ macroText[cursorOffset] !== ':' &&
644+ (cursorOffset < 2 || macroText[cursorOffset - 2] !== ':');
645+
646+ // Parse identifier and space-separated argument from the first part
647+ // "getvar myvar" -> identifier="getvar", spaceArg="myvar"
648+ // "setvar " -> identifier="setvar", spaceArg="" (just whitespace, no content yet)
649+ const firstPartText = parts[0]?.text || '';
650+ const trimmedFirstPart = firstPartText.trimStart();
651+ const firstSpaceInIdentifier = trimmedFirstPart.search(/\s/);
652+
653+ let identifierOnly;
654+ let spaceArgText = '';
655+ //let spaceArgStart = -1;
656+ let hasSpaceAfterIdentifier = false;
657+
658+ if (firstSpaceInIdentifier > 0 && separatorPositions.length === 0) {
659+ // There's whitespace inside the first part - split identifier from space-arg
660+ identifierOnly = trimmedFirstPart.slice(0, firstSpaceInIdentifier);
661+ const afterIdentifier = trimmedFirstPart.slice(firstSpaceInIdentifier);
662+ // Check if there's actual content after the whitespace (not just spaces or ::)
663+ const contentAfterSpace = afterIdentifier.trimStart();
664+ hasSpaceAfterIdentifier = afterIdentifier.length > 0; // Has at least a space
665+
666+ if (contentAfterSpace.length > 0 && !contentAfterSpace.startsWith(':')) {
667+ // There's actual argument content after the space
668+ spaceArgText = contentAfterSpace;
669+ //spaceArgStart = identifierStartPos + firstSpaceInIdentifier + (afterIdentifier.length - contentAfterSpace.length);
670+ }
671+ } else {
672+ identifierOnly = trimmedFirstPart.trimEnd();
673+ }
674+
675+ // Calculate identifier end position (for space-after-identifier detection)
676+ const identifierEndPos = identifierStartPos + (firstPartText.length - firstPartText.trimStart().length) + identifierOnly.length;
677+
238678 // Determine which part the cursor is in
239679 let currentArgIndex = -1;
240- for (let idx = 0; idx < parts.length; idx++) {
680+
241- const part = parts[idx];
681+ // Only consider being in an argument if we've passed a separator
242- if (cursorOffset >= part.start && cursorOffset <= part.end) {
682+ if (separatorPositions.length > 0) {
243- currentArgIndex = idx - 1; // -1 because first part is identifier
683+ // Find which argument we're in based on separator positions
244- break;
684+ for (let sepIdx = 0; sepIdx < separatorPositions.length; sepIdx++) {
685+ const sep = separatorPositions[sepIdx];
686+ if (cursorOffset >= sep.end) {
687+ // We're past this separator, so we're in at least this argument
688+ currentArgIndex = sepIdx;
245689 }
246690 }
691+ } else if (spaceArgText.length > 0 || (hasSpaceAfterIdentifier && cursorOffset > identifierEndPos)) {
692+ // Space-separated arg: either has content, or cursor is past identifier+space
693+ currentArgIndex = 0;
694+ }
695+
696+ // If typing a separator, we're still on identifier/previous arg, not the next one
697+ if (isTypingSeparator) {
698+ currentArgIndex = -1;
699+ }
700+
701+ const leftPadding = macroText.match(/^\s+/)?.[0] ?? '';
702+
703+ // Clean identifier: strip trailing colons (for partial :: typing)
704+ let cleanIdentifier = identifierOnly.replace(/:+$/, '');
247705
248- // If cursor is after all parts (at the end), we're in the last arg
706+ // Build args array - include space-separated arg if present
249- if (currentArgIndex === -1 && cursorOffset >= parts[parts.length - 1].end) {
707+ // Trim args like the macro engine does
250- currentArgIndex = parts.length - 1;
708+ let args = parts.slice(1).map(p => p.text.trim());
709+ if (spaceArgText.length > 0) {
710+ args = [spaceArgText, ...args];
251711 }
252712
253713 return {
254714 fullText: macroText,
255715 cursorOffset,
256- identifier: parts[0]?.text.trim() || '',
716+ paddingBefore: leftPadding,
257- args: parts.slice(1).map(p => p.text),
717+ identifier: cleanIdentifier,
718+ identifierStart: identifierStartPos,
719+ isInFlagsArea,
720+ flags,
721+ currentFlag,
722+ args,
258723 currentArgIndex,
724+ isTypingSeparator,
725+ hasSpaceAfterIdentifier,
726+ hasSpaceArgContent: spaceArgText.length > 0,
727+ separatorCount: separatorPositions.length,
259728 };
260729}
public/scripts/instruct-mode.js+1 -1
@@ -10,7 +10,7 @@ import {
1010import { onlyUnique, regexFromString, resetScrollHeight } from './utils.js';
1111
1212/**
1313 * @type {anyInstructSettings[]} Instruct mode presets.
1414 */
1515export let instruct_presets = [];
1616
public/scripts/macros/definitions/core-macros.js+103 -4
@@ -1,9 +1,21 @@
11import { seedrandom, droll } from '../../../lib.js';
22import { chat_metadata, main_api, getMaxContextSize, extension_prompts, getCurrentChatId } from '../../../script.js';
33import { getStringHash, isFalseBoolean } from '../../utils.js';
44import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js';
55import { inject_ids } from '../../constants.js';
66import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
7+import { MacroEngine } from '../engine/MacroEngine.js';
8+
9+/**
10+ * Marker used by {{else}} to split content in {{if}} blocks.
11+ * Uses control characters to minimize collision with real content.
12+ *
13+ * This marker is used internally by the macro engine to separate if/else branches.
14+ * It should never appear in user-generated content.
15+ *
16+ * @type {string}
17+ */
18+export const ELSE_MARKER = '\u0000\u001FELSE\u001F\u0000';
719
820/**
921 * Registers SillyTavern's core built-in macros in the MacroRegistry.
@@ -59,11 +71,98 @@ export function registerCoreMacros() {
5971 });
6072
6173 // {{trim}} -> macro will currently replace itself with itself. Trimming is handled in post-processing.
74+ // Scoped: {{trim}}content{{/trim}} -> trims whitespace from content (handled by engine auto-trim)
6275 MacroRegistry.registerMacro('trim', {
6376 category: MacroCategory.UTILITY,
64- description: 'Trims all whitespaces around the trim macro.',
77+ description: 'Trims whitespace. Non-scoped: trims newlines around the macro (post-processing). Scoped: returns the content (auto-trimmed by the engine).',
78+ unnamedArgs: [
79+ {
80+ name: 'content',
81+ description: 'Content to trim (when used as scoped macro)',
82+ optional: true,
83+ },
84+ ],
6585 returns: '',
6686 handler: ({ unnamedArgs: [content], isScoped }) => '{{trim}}',
87+ // Scoped usage: return content (already auto-trimmed by the engine)
88+ if (isScoped) return content ?? '';
89+ // Non-scoped: return marker for post-processing regex
90+ return '{{trim}}';
91+ },
92+ });
93+
94+ // {{if condition}}content{{/if}} -> conditional content
95+ // {{if condition}}then-content{{else}}else-content{{/if}} -> conditional with else branch
96+ // {{if !condition}}content{{/if}} -> inverted conditional (negated)
97+ // Condition can be a macro name (resolved automatically) or any value
98+ MacroRegistry.registerMacro('if', {
99+ category: MacroCategory.UTILITY,
100+ description: 'Conditional macro. Returns the content if the condition is truthy, otherwise returns nothing (or the else branch if present). Prefix the condition with ! to invert. If the condition is a registered macro name (without braces), it will be resolved first.',
101+ unnamedArgs: [
102+ {
103+ name: 'condition',
104+ description: 'The condition to evaluate. Prefix with ! to invert. Can be a macro name (auto-resolved) or a value. Falsy: empty string, "false", "off", "0".',
105+ },
106+ {
107+ name: 'content',
108+ description: 'The content to return if condition is truthy (typically provided as scoped content). May contain {{else}} to define an else branch.',
109+ },
110+ ],
111+ displayOverride: '{{if condition}}then{{else}}other{{/if}}',
112+ exampleUsage: [
113+ '{{if description}}# Description\n{{description}}{{/if}}',
114+ '{{if charVersion}}{{charVersion}}{{else}}No version{{/if}}',
115+ '{{if !personality}}No personality defined{{/if}}',
116+ '{{if {{getvar::showHeader}}}}# Header{{/if}}',
117+ ],
118+ returns: 'The content if condition is truthy, else branch or empty string otherwise.',
119+ handler: ({ unnamedArgs: [condition, content], rawArgs: [rawCondition], flags, env, trimContent }) => {
120+ // Check if the ORIGINAL condition (before macro resolution) starts with !
121+ // We use raw args to check this, as the resolved value might start with ! from a variable
122+ let inverted = false;
123+ if (/^\s*!/.test(rawCondition)) {
124+ inverted = true;
125+ // Strip the ! from the resolved condition if it was the prefix
126+ condition = condition.replace(/^!/, '');
127+ }
128+
129+ // Check if condition is a registered macro name (without braces)
130+ // If so, resolve it first (only for macros that accept 0 required args)
131+ const macroDef = MacroRegistry.getPrimaryMacro(condition);
132+ if (macroDef && macroDef.minArgs === 0) {
133+ // Use MacroEngine.evaluate to properly resolve the macro with full context
134+ // This ensures all handler args (cst, normalize, list, etc.) are correctly provided
135+ condition = MacroEngine.evaluate(`{{${condition}}}`, env);
136+ }
137+
138+ // Check if condition is falsy: empty string or isFalseBoolean
139+ let isFalsy = condition === '' || isFalseBoolean(condition);
140+ if (inverted) isFalsy = !isFalsy;
141+
142+ // Split content on else marker (if present)
143+ const [thenBranch, elseBranch] = content.split(ELSE_MARKER);
144+ const result = !isFalsy ? thenBranch : elseBranch;
145+
146+ // Trim branches unless # flag is set (preserveWhitespace)
147+ // The engine auto-trims the whole scoped content, but we still need to trim
148+ // around the {{else}} marker since that's internal to this macro
149+ if (flags.preserveWhitespace) {
150+ return result ?? '';
151+ }
152+ return trimContent(result ?? '');
153+ },
154+ });
155+
156+ // {{else}} -> marker for else branch inside {{if}} blocks
157+ // Only meaningful inside a scoped {{if}} macro
158+ MacroRegistry.registerMacro('else', {
159+ category: MacroCategory.UTILITY,
160+ description: 'Marks the else branch inside a scoped {{if}} block. Only works inside {{if}}...{{/if}}. If used outside, returns an invisible marker.',
161+ exampleUsage: [
162+ '{{if condition}}true branch{{else}}false branch{{/if}}',
163+ ],
164+ returns: 'Invisible marker (consumed by the enclosing {{if}} macro).',
165+ handler: () => ELSE_MARKER,
67166 });
68167
69168 // {{input}} -> current textarea content
@@ -101,7 +200,7 @@ export function registerCoreMacros() {
101200
102201 // Comment macro: {{// ...}} -> '' (consumes any arguments)
103202 MacroRegistry.registerMacro('//', {
104203 aliases: [{ alias: 'comment', visible: false }],
105204 category: MacroCategory.UTILITY,
106205 list: true, // We consume any arguments as if this is a list, but we'll ignore them in the handler anyway
107206 strictArgs: false, // and we also always remove it, even if the parsing might say it's invalid
public/scripts/macros/engine/MacroCstWalker.js+422 -9
@@ -1,14 +1,22 @@
11/** @typedef {import('chevrotain').CstNode} CstNode */
22/** @typedef {import('chevrotain').IToken} IToken */
33/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
4+/** @typedef {import('./MacroFlags.js').MacroFlags} MacroFlags */
5+
6+import { parseFlags, createEmptyFlags, MacroFlagType } from './MacroFlags.js';
7+import { MacroParser } from './MacroParser.js';
8+import { MacroRegistry } from './MacroRegistry.js';
49
510/**
611 * @typedef {Object} MacroCall
712 * @property {string} name
813 * @property {string[]} args
14+ * @property {MacroFlags} flags - Parsed macro execution flags.
15+ * @property {boolean} isScoped - Whether this macro was invoked using scoped syntax (opening + closing tags).
916 * @property {MacroEnv} env
1017 * @property {string} rawInner
1118 * @property {string} rawWithBraces
19+ * @property {string[]} rawArgs
1220 * @property {{ startOffset: number, endOffset: number }} range
1321 * @property {CstNode} cstNode
1422 */
@@ -18,6 +26,7 @@
1826 * @property {string} text
1927 * @property {MacroEnv} env
2028 * @property {(call: MacroCall) => string} resolveMacro
29+ * @property {(content: string, options?: { trimIndent?: boolean }) => string} trimContent - Shared utility function that trims scoped content with optional indentation dedent.
2130 */
2231
2332/**
@@ -47,7 +56,7 @@ class MacroCstWalker {
4756 * @returns {string}
4857 */
4958 evaluateDocument(options) {
5059 const { text, cst, env, resolveMacro, trimContent } = options;
5160
5261 if (typeof text !== 'string') {
5362 throw new Error('MacroCstWalker.evaluateDocument: text must be a string');
@@ -58,10 +67,16 @@ class MacroCstWalker {
5867 if (typeof resolveMacro !== 'function') {
5968 throw new Error('MacroCstWalker.evaluateDocument: resolveMacro must be a function');
6069 }
70+ if (typeof trimContent !== 'function') {
71+ throw new Error('MacroCstWalker.evaluateDocument: trimContent must be a function');
72+ }
6173
6274 /** @type {EvaluationContext} */
6375 const context = { text, env, resolveMacro, trimContent };
6476 constlet items = this.#collectDocumentItems(cst);
77+
78+ // Process scoped macros: find opening/closing pairs and merge them
79+ items = this.#processScopedMacros(items, text);
6580
6681 if (items.length === 0) {
6782 return text;
@@ -79,12 +94,21 @@ class MacroCstWalker {
7994 // Items can be either plaintext or macro nodes
8095 if (item.type === 'plaintext') {
8196 result += text.slice(item.startOffset, item.endOffset + 1);
97+ cursor = item.endOffset + 1;
98+ } else if (item.keepRaw) {
99+ // Unmatched closing macros stay as raw text
100+ result += text.slice(item.startOffset, item.endOffset + 1);
101+ cursor = item.endOffset + 1;
102+ } else {
103+ result += this.#evaluateMacroNode(item.node, context, item.scopedContent);
104+ // If this macro has scoped content, skip past the closing macro
105+ if (item.scopedContent && item.scopedContent.closingEndOffset > item.endOffset) {
106+ cursor = item.scopedContent.closingEndOffset + 1;
82107 } else {
83- result += this.#evaluateMacroNode(item.node, context);
84- }
85-
86108 cursor = item.endOffset + 1;
87109 }
110+ }
111+ }
88112
89113 if (cursor < text.length) {
90114 result += text.slice(cursor);
@@ -93,8 +117,59 @@ class MacroCstWalker {
93117 return result;
94118 }
95119
120+ /**
121+ * Finds unclosed scoped macros in a document CST.
122+ * Used by autocomplete to suggest closing tags.
123+ *
124+ * @param {Object} options
125+ * @param {string} options.text - The document text.
126+ * @param {CstNode} options.cst - The parsed CST.
127+ * @returns {Array<{ name: string, startOffset: number, endOffset: number }>} - Array of unclosed macro info, innermost last.
128+ */
129+ findUnclosedScopes(options) {
130+ const { text, cst } = options;
131+
132+ if (typeof text !== 'string' || !cst?.children) {
133+ return [];
134+ }
135+
136+ let items = this.#collectDocumentItems(cst);
137+ // Don't process scoped macros - we want to find the raw opening/closing pairs
138+ // Just extract macro info and find unmatched openers
139+
140+ /** @type {Array<{ name: string, startOffset: number, endOffset: number }>} */
141+ const unclosedStack = [];
142+
143+ // Extract macro names and closing status
144+ for (const item of items) {
145+ if (item.type !== 'macro') continue;
146+
147+ const info = this.#extractMacroInfo(item.node);
148+ if (!info) continue;
149+
150+ if (info.isClosing) {
151+ // Closing tag - pop matching opener from stack
152+ if (unclosedStack.length > 0 && unclosedStack[unclosedStack.length - 1].name === info.name) {
153+ unclosedStack.pop();
154+ }
155+ // If no matching opener, ignore (orphan closing tag)
156+ } else {
157+ // Opening tag - check if this macro can accept scoped content
158+ if (this.#canAcceptScopedContent(item.node, info.name)) {
159+ unclosedStack.push({
160+ name: info.name,
161+ startOffset: item.startOffset,
162+ endOffset: item.endOffset,
163+ });
164+ }
165+ }
166+ }
167+
168+ return unclosedStack;
169+ }
170+
96171 /** @typedef {{ type: 'plaintext', startOffset: number, endOffset: number, token: IToken }} DocumentItemPlaintext */
97172 /** @typedef {{ type: 'macro', startOffset: number, endOffset: number, node: CstNode, scopedContent?: { startOffset: number, endOffset: number, closingEndOffset: number }, keepRaw?: boolean }} DocumentItemMacro */
98173 /** @typedef {DocumentItemPlaintext | DocumentItemMacro} DocumentItem */
99174
100175 /**
@@ -158,15 +233,21 @@ class MacroCstWalker {
158233 *
159234 * @param {CstNode} macroNode
160235 * @param {EvaluationContext} context
236+ * @param {{ startOffset: number, endOffset: number, closingEndOffset: number }} [scopedContent] - Optional scoped content range for block macros.
161237 * @returns {string}
162238 */
163239 #evaluateMacroNode(macroNode, context, scopedContent) {
164240 const { text, env, resolveMacro, trimContent } = context;
165241
166242 const children = macroNode.children || {};
167243 const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []);
168244 const name = identifierTokens[0]?.image || '';
169245
246+ // Extract flag tokens and parse them into a MacroFlags object
247+ const flagTokens = /** @type {IToken[]} */ (children['flags'] || []);
248+ const flagSymbols = flagTokens.map(token => token.image);
249+ const flags = flagSymbols.length > 0 ? parseFlags(flagSymbols) : createEmptyFlags();
250+
170251 const range = this.#getMacroRange(macroNode);
171252 const startToken = /** @type {IToken?} */ ((children['Macro.Start'] || [])[0]);
172253 const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]);
@@ -182,6 +263,8 @@ class MacroCstWalker {
182263 const args = [];
183264 /** @type {({ value: string } & TokenRange)[]} */
184265 const evaluatedArguments = [];
266+ /** @type {string[]} */
267+ const rawArgs = [];
185268
186269 for (const argNode of argumentNodes) {
187270 const argValue = this.#evaluateArgumentNode(argNode, context);
@@ -194,6 +277,32 @@ class MacroCstWalker {
194277 ...location,
195278 });
196279 }
280+
281+ rawArgs.push(location ? text.slice(location.startOffset, location.endOffset + 1) : '');
282+ }
283+
284+ // If this macro has scoped content, evaluate it and append as the last argument
285+ if (scopedContent) {
286+ // Handle empty scoped content (when opening and closing are adjacent)
287+ if (scopedContent.startOffset > scopedContent.endOffset) {
288+ args.push('');
289+ } else {
290+ let scopedValue = this.#evaluateScopedContent(scopedContent, context);
291+
292+ // Auto-trim scoped content unless the '#' (preserveWhitespace) flag is set
293+ if (!flags.preserveWhitespace) {
294+ scopedValue = trimContent(scopedValue);
295+ }
296+
297+ args.push(scopedValue);
298+
299+ // Add to evaluated arguments for rawInner reconstruction
300+ evaluatedArguments.push({
301+ value: scopedValue,
302+ startOffset: scopedContent.startOffset,
303+ endOffset: scopedContent.endOffset,
304+ });
305+ }
197306 }
198307
199308 evaluatedArguments.sort((a, b) => a.startOffset - b.startOffset);
@@ -223,8 +332,11 @@ class MacroCstWalker {
223332 const call = {
224333 name,
225334 args,
335+ flags,
336+ isScoped: scopedContent != null,
226337 rawInner,
227338 rawWithBraces: text.slice(range.startOffset, range.endOffset + 1),
339+ rawArgs,
228340 range,
229341 cstNode: macroNode,
230342 env,
@@ -428,6 +540,307 @@ class MacroCstWalker {
428540 #isCstNode(value) {
429541 return !!value && typeof value === 'object' && 'name' in value && 'children' in value;
430542 }
543+
544+ /**
545+ * Evaluates scoped content between an opening and closing macro tag.
546+ * This resolves any nested macros within the scoped content.
547+ *
548+ * @param {{ startOffset: number, endOffset: number }} scopedContent - The range of the scoped content.
549+ * @param {EvaluationContext} context - The evaluation context.
550+ * @returns {string} - The evaluated scoped content with nested macros resolved.
551+ */
552+ #evaluateScopedContent(scopedContent, context) {
553+ const { text, env, resolveMacro, trimContent } = context;
554+ const { startOffset, endOffset } = scopedContent;
555+
556+ // Extract the raw content between opening and closing tags
557+ const rawContent = text.slice(startOffset, endOffset + 1);
558+
559+ // If empty, return empty string
560+ if (!rawContent) {
561+ return '';
562+ }
563+
564+ // Re-evaluate the scoped content to resolve any nested macros
565+ // We need to parse and evaluate this content as if it were a standalone document
566+ const { cst: scopedCst } = MacroParser.parseDocument(rawContent);
567+
568+ // If parsing fails, return the raw content
569+ if (!scopedCst || typeof scopedCst !== 'object' || !scopedCst.children) {
570+ return rawContent;
571+ }
572+
573+ // Create a new context with the scoped content text
574+ /** @type {EvaluationContext} */
575+ const scopedContext = { text: rawContent, env, resolveMacro, trimContent };
576+
577+ // Collect items from the scoped content CST
578+ let items = this.#collectDocumentItems(scopedCst);
579+
580+ // Process any nested scoped macros within this content
581+ items = this.#processScopedMacros(items, rawContent);
582+
583+ // Evaluate the items
584+ if (items.length === 0) {
585+ return rawContent;
586+ }
587+
588+ let result = '';
589+ let cursor = 0;
590+
591+ for (const item of items) {
592+ if (item.startOffset > cursor) {
593+ result += rawContent.slice(cursor, item.startOffset);
594+ }
595+
596+ if (item.type === 'plaintext') {
597+ result += rawContent.slice(item.startOffset, item.endOffset + 1);
598+ cursor = item.endOffset + 1;
599+ } else if (item.keepRaw) {
600+ // Unmatched closing macros stay as raw text
601+ result += rawContent.slice(item.startOffset, item.endOffset + 1);
602+ cursor = item.endOffset + 1;
603+ } else {
604+ result += this.#evaluateMacroNode(item.node, scopedContext, item.scopedContent);
605+ // If this macro has scoped content, skip past the closing macro
606+ if (item.scopedContent && item.scopedContent.closingEndOffset > item.endOffset) {
607+ cursor = item.scopedContent.closingEndOffset + 1;
608+ } else {
609+ cursor = item.endOffset + 1;
610+ }
611+ }
612+ }
613+
614+ if (cursor < rawContent.length) {
615+ result += rawContent.slice(cursor);
616+ }
617+
618+ return result;
619+ }
620+
621+ // ========================================================================
622+ // Scoped Macro Processing
623+ // ========================================================================
624+
625+ /**
626+ * Processes document items to find and merge scoped macro pairs.
627+ * A scoped macro is an opening macro followed by content and a closing macro.
628+ * Example: `{{setvar::myvar}}content{{/setvar}}` becomes `{{setvar::myvar::content}}`
629+ *
630+ * The closing macro has the `closingBlock` flag (`/`) and the same identifier.
631+ * Everything between the opening and closing macros becomes the last unnamed argument.
632+ *
633+ * @param {Array<DocumentItem>} items - The collected document items.
634+ * @param {string} text - The original document text.
635+ * @returns {Array<DocumentItem>} - The processed items with scoped macros merged.
636+ */
637+ #processScopedMacros(items, text) {
638+ // Build a list of scoped macro info for each macro item
639+ /** @type {Array<{ index: number, item: DocumentItemMacro, name: string, isClosing: boolean, matched: boolean }>} */
640+ const macroInfos = [];
641+
642+ for (let i = 0; i < items.length; i++) {
643+ const item = items[i];
644+ if (item.type !== 'macro') continue;
645+
646+ const info = this.#extractMacroInfo(item.node);
647+ if (!info) continue;
648+
649+ macroInfos.push({
650+ index: i,
651+ item,
652+ name: info.name,
653+ isClosing: info.isClosing,
654+ matched: false,
655+ });
656+ }
657+
658+ // Find matching pairs - only process OUTERMOST scopes at this level
659+ // Nested scopes will be discovered when parent's scoped content is re-parsed
660+ /** @type {Array<{ openingIndex: number, closingIndex: number }>} */
661+ const pairs = [];
662+
663+ // Track ranges that are inside a scope (to skip nested openers)
664+ /** @type {Set<number>} */
665+ const insideScope = new Set();
666+
667+ for (let i = 0; i < macroInfos.length; i++) {
668+ const openInfo = macroInfos[i];
669+
670+ // Skip closing macros, already matched macros, or macros inside another scope
671+ if (openInfo.isClosing || openInfo.matched || insideScope.has(openInfo.index)) continue;
672+
673+ // Find the matching closing macro for this opening macro
674+ const closingIdx = this.#findMatchingClosingMacro(macroInfos, i);
675+ if (closingIdx === -1) continue;
676+
677+ // Check if the macro can accept scoped content (arity validation)
678+ if (!this.#canAcceptScopedContent(openInfo.item.node, openInfo.name)) {
679+ // Macro cannot accept scoped content - mark both as keepRaw
680+ openInfo.item.keepRaw = true;
681+ macroInfos[closingIdx].item.keepRaw = true;
682+ // Mark as matched so they won't be processed again
683+ openInfo.matched = true;
684+ macroInfos[closingIdx].matched = true;
685+ continue;
686+ }
687+
688+ // Mark both as matched
689+ openInfo.matched = true;
690+ macroInfos[closingIdx].matched = true;
691+
692+ const closingIndex = macroInfos[closingIdx].index;
693+
694+ pairs.push({
695+ openingIndex: openInfo.index,
696+ closingIndex: closingIndex,
697+ });
698+
699+ // Mark all items between this pair as inside a scope
700+ // They will be processed when the scoped content is re-parsed
701+ for (let j = openInfo.index + 1; j < closingIndex; j++) {
702+ insideScope.add(j);
703+ }
704+ }
705+
706+ // Mark unmatched closing macros as keepRaw so they stay as raw text
707+ for (const info of macroInfos) {
708+ if (info.isClosing && !info.matched) {
709+ info.item.keepRaw = true;
710+ }
711+ }
712+
713+ // If no pairs found, return items (with unmatched closings marked as raw)
714+ if (pairs.length === 0) {
715+ return items;
716+ }
717+
718+ // Process pairs: merge content into opening macro's scopedContent field
719+
720+ // Track which items to remove (closing macros and intermediate content items)
721+ /** @type {Set<number>} */
722+ const itemsToRemove = new Set();
723+
724+ for (const pair of pairs) {
725+ const openingItem = /** @type {DocumentItemMacro} */ (items[pair.openingIndex]);
726+ const closingItem = /** @type {DocumentItemMacro} */ (items[pair.closingIndex]);
727+
728+ // Collect content between opening and closing (exclusive)
729+ const contentStart = openingItem.endOffset + 1;
730+ const contentEnd = closingItem.startOffset - 1;
731+
732+ // Store the scoped content range on the opening macro item
733+ // This will be used during macro evaluation to append the content as the last argument
734+ openingItem.scopedContent = {
735+ startOffset: contentStart,
736+ endOffset: contentEnd,
737+ closingEndOffset: closingItem.endOffset,
738+ };
739+
740+ // Mark closing macro for removal
741+ itemsToRemove.add(pair.closingIndex);
742+
743+ // Mark ALL intermediate items between opening and closing for removal
744+ // They will be captured as raw scoped content and re-parsed during evaluation
745+ for (let j = pair.openingIndex + 1; j < pair.closingIndex; j++) {
746+ itemsToRemove.add(j);
747+ }
748+ }
749+
750+ // Filter out removed items
751+ return items.filter((_, index) => !itemsToRemove.has(index));
752+ }
753+
754+ /**
755+ * Extracts macro name and closing flag status from a macro node.
756+ *
757+ * @param {CstNode} macroNode
758+ * @returns {{ name: string, isClosing: boolean } | null}
759+ */
760+ #extractMacroInfo(macroNode) {
761+ const children = macroNode.children || {};
762+ const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []);
763+ const name = identifierTokens[0]?.image || '';
764+
765+ if (!name) return null;
766+
767+ // Check for closing block flag
768+ const flagTokens = /** @type {IToken[]} */ (children['flags'] || []);
769+ const isClosing = flagTokens.some(token => token.image === MacroFlagType.CLOSING_BLOCK);
770+
771+ return { name, isClosing };
772+ }
773+
774+ /**
775+ * Checks if a macro can accept scoped content as an additional argument.
776+ * Returns true if adding one more argument would result in valid arity.
777+ *
778+ * @param {CstNode} macroNode - The macro CST node.
779+ * @param {string} macroName - The macro name.
780+ * @returns {boolean} - True if scoped content is allowed.
781+ */
782+ #canAcceptScopedContent(macroNode, macroName) {
783+ const def = MacroRegistry.getPrimaryMacro(macroName);
784+ if (!def) {
785+ // Unknown macro - allow scoped content (will be handled as unknown macro later)
786+ return true;
787+ }
788+
789+ // Count current arguments in the macro
790+ const children = macroNode.children || {};
791+ const argumentsNode = /** @type {CstNode?} */ ((children.arguments || [])[0]);
792+ const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
793+ const currentArgCount = argumentNodes.length;
794+
795+ // Check if adding 1 more argument (scoped content) would be valid
796+ const newArgCount = currentArgCount + 1;
797+
798+ // Macro must accept at least newArgCount arguments
799+ // For macros with list args, they can accept unlimited after maxArgs
800+ if (def.list) {
801+ // With list: valid if newArgCount >= minArgs (list can absorb extra)
802+ return newArgCount >= def.minArgs;
803+ }
804+
805+ // Without list: newArgCount must be between minArgs and maxArgs
806+ return newArgCount >= def.minArgs && newArgCount <= def.maxArgs;
807+ }
808+
809+ /**
810+ * Finds the matching closing macro for an opening macro at the given index.
811+ * Handles nested scopes by tracking depth.
812+ *
813+ * @param {Array<{ index: number, item: DocumentItemMacro, name: string, isClosing: boolean, matched: boolean }>} macroInfos
814+ * @param {number} openingIdx - Index in macroInfos array of the opening macro.
815+ * @returns {number} - Index in macroInfos array of the matching closing macro, or -1 if not found.
816+ */
817+ #findMatchingClosingMacro(macroInfos, openingIdx) {
818+ const openInfo = macroInfos[openingIdx];
819+ const targetName = openInfo.name;
820+ let depth = 1;
821+
822+ for (let i = openingIdx + 1; i < macroInfos.length; i++) {
823+ const info = macroInfos[i];
824+
825+ // Only consider macros with the same name
826+ if (info.name !== targetName) continue;
827+
828+ // Skip already matched macros
829+ if (info.matched) continue;
830+
831+ if (info.isClosing) {
832+ depth--;
833+ if (depth === 0) {
834+ return i;
835+ }
836+ } else {
837+ // Another opening macro with the same name increases depth
838+ depth++;
839+ }
840+ }
841+
842+ return -1; // No matching closing macro found
843+ }
431844}
432845
433846instance = MacroCstWalker.instance;
public/scripts/macros/engine/MacroEngine.js+201 -19
@@ -2,12 +2,29 @@ import { MacroParser } from './MacroParser.js';
22import { MacroCstWalker } from './MacroCstWalker.js';
33import { MacroRegistry } from './MacroRegistry.js';
44import { logMacroGeneralError, logMacroInternalError, logMacroRuntimeWarning, logMacroSyntaxWarning } from './MacroDiagnostics.js';
5+import { ELSE_MARKER } from '../definitions/core-macros.js';
56
67/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
78/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
89/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
910
1011/**
12+ * A processor function that transforms text before or after macro evaluation.
13+ *
14+ * @callback MacroProcessor
15+ * @param {string} text - The text to process.
16+ * @param {MacroEnv} env - The macro environment.
17+ * @returns {string} The processed text.
18+ */
19+
20+/**
21+ * @typedef {Object} RegisteredProcessor
22+ * @property {MacroProcessor} handler - The processor function.
23+ * @property {number} priority - Execution priority (lower = earlier).
24+ * @property {string} source - Identifier for debugging/tracking.
25+ */
26+
27+/**
1128 * The singleton instance of the MacroEngine.
1229 *
1330 * @type {MacroEngine}
@@ -19,7 +36,71 @@ class MacroEngine {
1936 /** @type {MacroEngine} */ static #instance;
2037 /** @type {MacroEngine} */ static get instance() { return MacroEngine.#instance ?? (MacroEngine.#instance = new MacroEngine()); }
2138
22- constructor() { }
39+ /** @type {RegisteredProcessor[]} */
40+ #preProcessors = [];
41+ /** @type {RegisteredProcessor[]} */
42+ #postProcessors = [];
43+
44+ constructor() {
45+ this.#registerCorePreProcessors();
46+ this.#registerCorePostProcessors();
47+ }
48+
49+ /**
50+ * Registers a pre-processor to run before macro evaluation.
51+ *
52+ * @param {MacroProcessor} handler - The processor function.
53+ * @param {Object} [options] - Configuration options.
54+ * @param {number} [options.priority=100] - Execution priority (lower = earlier).
55+ * @param {string} [options.source='unknown'] - Identifier for debugging.
56+ */
57+ addPreProcessor(handler, { priority = 100, source = 'unknown' } = {}) {
58+ this.#preProcessors.push({ handler, priority, source });
59+ this.#preProcessors.sort((a, b) => a.priority - b.priority);
60+ }
61+
62+ /**
63+ * Removes a previously registered pre-processor.
64+ *
65+ * @param {MacroProcessor} handler - The processor function to remove.
66+ * @returns {boolean} True if the processor was found and removed.
67+ */
68+ removePreProcessor(handler) {
69+ const index = this.#preProcessors.findIndex(p => p.handler === handler);
70+ if (index !== -1) {
71+ this.#preProcessors.splice(index, 1);
72+ return true;
73+ }
74+ return false;
75+ }
76+
77+ /**
78+ * Registers a post-processor to run after macro evaluation.
79+ *
80+ * @param {MacroProcessor} handler - The processor function.
81+ * @param {Object} [options] - Configuration options.
82+ * @param {number} [options.priority=100] - Execution priority (lower = earlier).
83+ * @param {string} [options.source='unknown'] - Identifier for debugging.
84+ */
85+ addPostProcessor(handler, { priority = 100, source = 'unknown' } = {}) {
86+ this.#postProcessors.push({ handler, priority, source });
87+ this.#postProcessors.sort((a, b) => a.priority - b.priority);
88+ }
89+
90+ /**
91+ * Removes a previously registered post-processor.
92+ *
93+ * @param {MacroProcessor} handler - The processor function to remove.
94+ * @returns {boolean} True if the processor was found and removed.
95+ */
96+ removePostProcessor(handler) {
97+ const index = this.#postProcessors.findIndex(p => p.handler === handler);
98+ if (index !== -1) {
99+ this.#postProcessors.splice(index, 1);
100+ return true;
101+ }
102+ return false;
103+ }
23104
24105 /**
25106 * Evaluates a string containing macros and resolves them.
@@ -59,6 +140,7 @@ class MacroEngine {
59140 cst,
60141 env: safeEnv,
61142 resolveMacro: this.#resolveMacro.bind(this),
143+ trimContent: this.trimScopedContent.bind(this),
62144 });
63145 } catch (error) {
64146 logMacroGeneralError({ message: 'Macro evaluation failed. Returning original input.', error: { input, error } });
@@ -142,21 +224,9 @@ class MacroEngine {
142224 */
143225 #runPreProcessors(text, env) {
144226 let result = text;
145-
227+ for (const { handler } of this.#preProcessors) {
146- // This legacy macro will not be supported by the new macro parser, but rather regex-replaced beforehand
228+ result = handler(result, env);
147- // {{time_UTC-10}} => {{time::UTC-10}}
229+ }
148- result = result.replace(/{{time_(UTC[+-]\d+)}}/gi, (_match, utcOffset) => {
149- return `{{time::${utcOffset}}}`;
150- });
151-
152- // Legacy non-curly markers like <USER>, <BOT>, <GROUP>, etc.
153- // These are rewritten into their equivalent macro forms so they go through the normal engine pipeline.
154- result = result.replace(/<USER>/gi, '{{user}}');
155- result = result.replace(/<BOT>/gi, '{{char}}');
156- result = result.replace(/<CHAR>/gi, '{{char}}');
157- result = result.replace(/<GROUP>/gi, '{{group}}');
158- result = result.replace(/<CHARIFNOTGROUP>/gi, '{{charIfNotGroup}}');
159-
160230 return result;
161231 }
162232
@@ -169,18 +239,65 @@ class MacroEngine {
169239 */
170240 #runPostProcessors(text, env) {
171241 let result = text;
242+ for (const { handler } of this.#postProcessors) {
243+ result = handler(result, env);
244+ }
245+ return result;
246+ }
247+
248+ /**
249+ * Registers the core pre/post processors that handle legacy syntax and cleanup.
250+ */
251+ #registerCorePreProcessors() {
252+ // Pre-processors (priority 0-50 reserved for core)
253+
254+ // This legacy macro will not be supported by the new macro parser, but rather regex-replaced beforehand
255+ // {{time_UTC-10}} => {{time::UTC-10}}
256+ this.addPreProcessor(
257+ text => text.replace(/{{time_(UTC[+-]\d+)}}/gi, (_match, utcOffset) => `{{time::${utcOffset}}}`),
258+ { priority: 10, source: 'core:legacy-time-syntax' },
259+ );
260+
261+ // Legacy non-curly markers like <USER>, <BOT>, <GROUP>, etc.
262+ // These are rewritten into their equivalent macro forms so they go through the normal engine pipeline.
263+ this.addPreProcessor(
264+ text => text
265+ .replace(/<USER>/gi, '{{user}}')
266+ .replace(/<BOT>/gi, '{{char}}')
267+ .replace(/<CHAR>/gi, '{{char}}')
268+ .replace(/<GROUP>/gi, '{{group}}')
269+ .replace(/<CHARIFNOTGROUP>/gi, '{{charIfNotGroup}}'),
270+ { priority: 20, source: 'core:legacy-markers' },
271+ );
272+ }
273+
274+ /**
275+ * Registers the core post-processors that handle legacy syntax and cleanup.
276+ */
277+ #registerCorePostProcessors() {
278+ // Post-processors (priority 0-50 reserved for core)
172279
173280 // Unescape braces: \{ → { and \} → }
174281 // Since \{\{ doesn't match {{ (MacroStart), it passes through as plain text.
175282 // We only need to remove the backslashes in post-processing.
176- result = result.replace(/\\([{}])/g, '$1');
283+ this.addPostProcessor(
284+ text => text.replace(/\\([{}])/g, '$1'),
285+ { priority: 10, source: 'core:unescape-braces' },
286+ );
177287
178288 // The original trim macro is reaching over the boundaries of the defined macro. This is not something the engine supports.
179289 // To treat {{trim}} as it was before, we won't process it by the engine itself,
180290 // but doing a regex replace on {{trim}} and the surrounding area, after all other macros have been processed.
181- result = result.replace(/(?:\r?\n)*{{trim}}(?:\r?\n)*/gi, '');
291+ this.addPostProcessor(
292+ text => text.replace(/(?:\r?\n)*{{trim}}(?:\r?\n)*/gi, ''),
293+ { priority: 20, source: 'core:legacy-trim' },
294+ );
182295
183- return result;
296+ // Remove any wrongly placed leftover ELSE_MARKER that might have been inserted during processing
297+ this.addPostProcessor(
298+ text => text.replaceAll(ELSE_MARKER, ''),
299+ { priority: 30, source: 'core:cleanup-else-marker' },
300+ );
184301 }
185302
186303 /**
@@ -207,6 +324,71 @@ class MacroEngine {
207324
208325 return String(value);
209326 }
327+
328+ /**
329+ * Trims scoped content with optional indentation dedent.
330+ *
331+ * When trimIndent is true (default), this function:
332+ * 1. Trims leading and trailing whitespace (like String.trim())
333+ * 2. Finds the indentation of the first non-empty line
334+ * 3. Removes that amount of leading whitespace from all subsequent lines
335+ *
336+ * This allows neatly formatted scoped macros like:
337+ * ```
338+ * {{if condition}}
339+ * # Heading
340+ * Content here
341+ * {{/if}}
342+ * ```
343+ * To produce "# Heading\nContent here" instead of "# Heading\n Content here"
344+ *
345+ * @param {string} content - The content to trim
346+ * @param {Object} options - Configuration options
347+ * @param {boolean} [options.trimIndent=true] - Whether to also dedent consistent indentation
348+ * @returns {string} The trimmed content
349+ */
350+ trimScopedContent(content, { trimIndent = true } = {}) {
351+ if (!content) return '';
352+
353+ // If not dedenting, just do a basic trim
354+ if (!trimIndent) {
355+ return content.trim();
356+ }
357+
358+ // Split into lines BEFORE trimming to preserve indentation info
359+ const lines = content.split('\n');
360+
361+ // Find the first non-empty line (has non-whitespace characters)
362+ let baseIndent = 0;
363+ for (const line of lines) {
364+ if (line.trim() !== '') {
365+ // Found first non-empty line - get its indentation
366+ const match = line.match(/^[ \t]*/);
367+ baseIndent = match ? match[0].length : 0;
368+ break;
369+ }
370+ }
371+
372+ // If no indentation to remove, just trim and return
373+ if (baseIndent === 0) {
374+ return content.trim();
375+ }
376+
377+ // Remove the base indentation from ALL lines
378+ const dedentedLines = lines.map(line => {
379+ // Only remove indentation if the line has enough leading whitespace
380+ const match = line.match(/^[ \t]*/);
381+ const lineIndent = match ? match[0].length : 0;
382+ if (lineIndent >= baseIndent) {
383+ return line.slice(baseIndent);
384+ }
385+ // Line has less indentation than base - just trim its leading whitespace
386+ return line.trimStart();
387+ });
388+
389+ // Join and trim the final result
390+ return dedentedLines.join('\n').trim();
391+ }
210392}
211393
212394instance = MacroEngine.instance;
public/scripts/macros/engine/MacroFlags.js+263 -0
@@ -0,0 +1,263 @@
1+/**
2+ * Macro Execution Flags - modifiers that change how macros are resolved at runtime.
3+ *
4+ * Flags are special symbols placed between the opening braces `{{` and the macro identifier.
5+ * Example: `{{!user}}` - the `!` is an "immediate resolve" flag.
6+ *
7+ * Multiple flags can be combined: `{{!?myMacro}}` or `{{ ! ? myMacro }}`
8+ */
9+
10+/**
11+ * @typedef {Object} MacroFlags
12+ * @property {boolean} immediate - Whether the immediate (`!`) flag is set.
13+ * @property {boolean} delayed - Whether the delayed (`?`) flag is set.
14+ * @property {boolean} reevaluate - Whether the re-evaluate (`~`) flag is set.
15+ * @property {boolean} filter - Whether the filter (`>`) flag is set.
16+ * @property {boolean} closingBlock - Whether the closing block (`/`) flag is set.
17+ * @property {boolean} preserveWhitespace - Whether the preserve whitespace (`#`) flag is set.
18+ * @property {boolean} varDot - Whether the variable dot (`.`) flag is set.
19+ * @property {boolean} varDollar - Whether the variable dollar (`$`) flag is set.
20+ * @property {string[]} raw - The raw flag symbols in order of appearance.
21+ */
22+
23+/**
24+ * Enum of all recognized macro execution flags.
25+ *
26+ * @readonly
27+ * @enum {string}
28+ */
29+export const MacroFlagType = Object.freeze({
30+ /**
31+ * Immediate resolve flag (`!`).
32+ * This macro will be resolved first (in order of appearance) before "normal" macros.
33+ * @status TBD - Not implemented in v1
34+ */
35+ IMMEDIATE: '!',
36+
37+ /**
38+ * Delayed resolve flag (`?`).
39+ * This macro will be resolved last (in order of appearance) after "normal" macros.
40+ * @status TBD - Not implemented in v1
41+ */
42+ DELAYED: '?',
43+
44+ /**
45+ * Re-evaluate flag (`~`).
46+ * Marks a macro for potential re-evaluation.
47+ * @status TBD - Not implemented in v1
48+ */
49+ REEVALUATE: '~',
50+
51+ /**
52+ * Filter/pipe flag (`>`).
53+ * Indicates that this macro should resolve `|` characters as output filters.
54+ * @status Parsed - Filter feature not yet implemented
55+ */
56+ FILTER: '>',
57+
58+ /**
59+ * Closing block flag (`/`).
60+ * Marks this macro as the closing block of a scoped macro with the same identifier.
61+ * A closing block macro does not support arguments itself.
62+ * Example: `{{setvar::myvar}}long text{{/setvar}}`
63+ * @status Implemented - Content between opening and closing tags becomes the last unnamed argument
64+ */
65+ CLOSING_BLOCK: '/',
66+
67+ /**
68+ * Preserve whitespace flag (`#`).
69+ * Prevents automatic trimming of scoped content.
70+ * By default, scoped macro content is trimmed. Use this flag to preserve leading/trailing whitespace.
71+ * Also provides backwards compatibility with legacy handlebars-style syntax like `{{#if ...}}`.
72+ * Example: `{{#setvar::myvar}} content with spaces {{/setvar}}`
73+ * @status Implemented - Prevents auto-trim on scoped content
74+ */
75+ PRESERVE_WHITESPACE: '#',
76+
77+ /**
78+ * Variable shorthand flag (`.`).
79+ * Shorthand for variable access: `{{.myvar}}` equivalent to `{{getvar::myvar}}`.
80+ * @status TBD - Not implemented in v1
81+ */
82+ VAR_DOT: '.',
83+
84+ /**
85+ * Variable shorthand flag (`$`).
86+ * Alternative shorthand for variable access: `{{$myvar}}`.
87+ * @status TBD - Not implemented in v1
88+ */
89+ VAR_DOLLAR: '$',
90+});
91+
92+/**
93+ * @typedef {Object} MacroFlagDefinition
94+ * @property {MacroFlagType} type - The flag type enum value (also the symbol).
95+ * @property {string} name - Human-readable name for the flag.
96+ * @property {string} description - Description of what the flag does.
97+ * @property {boolean} implemented - Whether this flag's behavior is implemented.
98+ * @property {boolean} affectsParser - Whether this flag changes parsing behavior (e.g., filter flag).
99+ */
100+
101+/**
102+ * Definitions for all macro flags with metadata.
103+ *
104+ * @type {Map<string, MacroFlagDefinition>}
105+ */
106+export const MacroFlagDefinitions = new Map([
107+ [MacroFlagType.IMMEDIATE, {
108+ type: MacroFlagType.IMMEDIATE,
109+ name: 'Immediate',
110+ description: 'Resolve this macro before other macros in the same text.',
111+ implemented: false,
112+ affectsParser: false,
113+ }],
114+ [MacroFlagType.DELAYED, {
115+ type: MacroFlagType.DELAYED,
116+ name: 'Delayed',
117+ description: 'Resolve this macro after other macros in the same text.',
118+ implemented: false,
119+ affectsParser: false,
120+ }],
121+ [MacroFlagType.REEVALUATE, {
122+ type: MacroFlagType.REEVALUATE,
123+ name: 'Re-evaluate',
124+ description: 'Mark this macro for re-evaluation.',
125+ implemented: false,
126+ affectsParser: false,
127+ }],
128+ [MacroFlagType.FILTER, {
129+ type: MacroFlagType.FILTER,
130+ name: 'Filter',
131+ description: 'Enable pipe-based output filters for this macro.',
132+ implemented: false,
133+ affectsParser: true, // Changes how `|` is parsed
134+ }],
135+ [MacroFlagType.CLOSING_BLOCK, {
136+ type: MacroFlagType.CLOSING_BLOCK,
137+ name: 'Closing Block',
138+ description: 'Marks this as a closing block for a scoped macro.',
139+ implemented: true,
140+ affectsParser: false,
141+ }],
142+ [MacroFlagType.PRESERVE_WHITESPACE, {
143+ type: MacroFlagType.PRESERVE_WHITESPACE,
144+ name: 'Preserve Whitespace',
145+ description: 'Prevent automatic trimming of scoped content (legacy # syntax).',
146+ implemented: true,
147+ affectsParser: false,
148+ }],
149+ [MacroFlagType.VAR_DOT, {
150+ type: MacroFlagType.VAR_DOT,
151+ name: 'Variable (dot)',
152+ description: 'Shorthand for variable access using dot notation.',
153+ implemented: false,
154+ affectsParser: false,
155+ }],
156+ [MacroFlagType.VAR_DOLLAR, {
157+ type: MacroFlagType.VAR_DOLLAR,
158+ name: 'Variable (dollar)',
159+ description: 'Shorthand for variable access using dollar notation.',
160+ implemented: false,
161+ affectsParser: false,
162+ }],
163+]);
164+
165+/**
166+ * Set of all valid flag symbols for quick lookup.
167+ *
168+ * @type {Set<string>}
169+ */
170+export const ValidFlagSymbols = new Set(Object.values(MacroFlagType));
171+
172+/**
173+ * Creates a default MacroFlags object with all flags set to false.
174+ *
175+ * @returns {MacroFlags}
176+ */
177+export function createEmptyFlags() {
178+ return {
179+ immediate: false,
180+ delayed: false,
181+ reevaluate: false,
182+ filter: false,
183+ closingBlock: false,
184+ preserveWhitespace: false,
185+ varDot: false,
186+ varDollar: false,
187+ raw: [],
188+ };
189+}
190+
191+/**
192+ * Parses an array of flag symbols into a MacroFlags object.
193+ *
194+ * @param {string[]} flagSymbols - Array of flag symbol strings (e.g., ['!', '?']).
195+ * @returns {MacroFlags}
196+ */
197+export function parseFlags(flagSymbols) {
198+ const flags = createEmptyFlags();
199+
200+ for (const symbol of flagSymbols) {
201+ switch (symbol) {
202+ case MacroFlagType.IMMEDIATE:
203+ flags.immediate = true;
204+ break;
205+ case MacroFlagType.DELAYED:
206+ flags.delayed = true;
207+ break;
208+ case MacroFlagType.REEVALUATE:
209+ flags.reevaluate = true;
210+ break;
211+ case MacroFlagType.FILTER:
212+ flags.filter = true;
213+ break;
214+ case MacroFlagType.CLOSING_BLOCK:
215+ flags.closingBlock = true;
216+ break;
217+ case MacroFlagType.PRESERVE_WHITESPACE:
218+ flags.preserveWhitespace = true;
219+ break;
220+ case MacroFlagType.VAR_DOT:
221+ flags.varDot = true;
222+ break;
223+ case MacroFlagType.VAR_DOLLAR:
224+ flags.varDollar = true;
225+ break;
226+ default:
227+ console.warn(`Can't parse unknown macro flag: ${symbol}`);
228+ }
229+ flags.raw.push(symbol);
230+ }
231+
232+ return flags;
233+}
234+
235+/**
236+ * Checks if a MacroFlags object has any flags set.
237+ *
238+ * @param {MacroFlags} flags - The flags object to check.
239+ * @returns {boolean} True if at least one flag is set.
240+ */
241+export function hasAnyFlag(flags) {
242+ return flags.raw.length > 0;
243+}
244+
245+/**
246+ * Gets the flag definition for a given symbol.
247+ *
248+ * @param {string} symbol - The flag symbol (e.g., '!').
249+ * @returns {MacroFlagDefinition|undefined}
250+ */
251+export function getFlagDefinition(symbol) {
252+ return MacroFlagDefinitions.get(symbol);
253+}
254+
255+/**
256+ * Checks if a given symbol is a valid macro flag.
257+ *
258+ * @param {string} symbol - The symbol to check.
259+ * @returns {boolean}
260+ */
261+export function isValidFlag(symbol) {
262+ return ValidFlagSymbols.has(symbol);
263+}
public/scripts/macros/engine/MacroLexer.js+34 -4
@@ -3,6 +3,19 @@ const { createToken, Lexer } = chevrotain;
33
44/** @typedef {import('chevrotain').TokenType} TokenType */
55
6+
7+/** Regex for lexer token matching (no anchors). */
8+const IDENTIFIER_LEXER_PATTERN = /[a-zA-Z][\w-_]*/;
9+
10+/**
11+ * Pattern for valid macro identifiers.
12+ * Must start with a letter, followed by word chars (letters, digits, underscore) or hyphens.
13+ * Used by both the lexer token and the validation regex.
14+ *
15+ * Regex for full-string validation (with anchors). Exported for macro registration.
16+ */
17+export const MACRO_IDENTIFIER_PATTERN = /^[a-zA-Z][\w-_]*$/;
18+
619/** @enum {string} */
720const modes = {
821 plaintext: 'plaintext_mode',
@@ -25,9 +38,24 @@ const Tokens = {
2538 Start: createToken({ name: 'Macro.Start', pattern: /\{\{/ }),
2639 // Separate macro identifier needed, that is similar to the global indentifier, but captures the actual macro "name"
2740 // We need this, because this token is going to switch lexer mode, while the general identifier does not.
28- Flags: createToken({ name: 'Macro.Flag', pattern: /[!?#~/.$]/ }),
41+ /**
42+ * Macro execution flags - special symbols that modify macro resolution behavior.
43+ * - `!` = immediate resolve (TBD)
44+ * - `?` = delayed resolve (TBD)
45+ * - `~` = re-evaluate (TBD)
46+ * - `/` = closing block marker for scoped macros
47+ * - `#` = preserve whitespace (don't auto-trim scoped content), also legacy handlebars compatibility
48+ * - `.` = variable shorthand (TBD)
49+ * - `$` = variable shorthand alternative (TBD)
50+ */
51+ Flags: createToken({ name: 'Macro.Flag', pattern: /[!?~#/.$]/ }),
52+ /**
53+ * Filter flag (`>`) - separate token because it changes parsing behavior.
54+ * When present, `|` characters inside the macro are treated as filter/pipe operators.
55+ */
56+ FilterFlag: createToken({ name: 'Macro.FilterFlag', pattern: />/ }),
2957 DoubleSlash: createToken({ name: 'Macro.DoubleSlash', pattern: /\/\// }),
3058 Identifier: createToken({ name: 'Macro.Identifier', pattern: /[a-zA-Z][\w-_]*/IDENTIFIER_LEXER_PATTERN }),
3159 // At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces
3260 EndOfIdentifier: createToken({ name: 'Macro.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
3361 BeforeEnd: createToken({ name: 'Macro.BeforeEnd', pattern: /(?=\}\})/, group: Lexer.SKIPPED }),
@@ -45,13 +73,13 @@ const Tokens = {
4573 Filter: {
4674 EscapedPipe: createToken({ name: 'Filter.EscapedPipe', pattern: /\\\|/ }),
4775 Pipe: createToken({ name: 'Filter.Pipe', pattern: /\|/ }),
4876 Identifier: createToken({ name: 'Filter.Identifier', pattern: /[a-zA-Z][\w-_]*/IDENTIFIER_LEXER_PATTERN }),
4977 // At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces
5078 EndOfIdentifier: createToken({ name: 'Filter.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
5179 },
5280
5381 // All tokens that can be captured inside a macro
5482 Identifier: createToken({ name: 'Identifier', pattern: /[a-zA-Z][\w-_]*/IDENTIFIER_LEXER_PATTERN }),
5583 WhiteSpace: createToken({ name: 'WhiteSpace', pattern: /\s+/, group: Lexer.SKIPPED }),
5684
5785 // Capture unknown characters one by one, to still allow other tokens being matched once they are there.
@@ -84,6 +112,8 @@ const Def = {
84112 enter(Tokens.Macro.DoubleSlash, modes.macro_args),
85113
86114 using(Tokens.Macro.Flags),
115+ // Filter flag is separate because it affects parsing behavior for pipes
116+ using(Tokens.Macro.FilterFlag),
87117
88118 // We allow whitspaces inbetween flags or in front of the modifier
89119 using(Tokens.WhiteSpace),
public/scripts/macros/engine/MacroParser.js+12 -1
@@ -46,7 +46,18 @@ class MacroParser extends CstParser {
4646 // Basic Macro Structure
4747 $.macro = $.RULE('macro', () => {
4848 $.CONSUME(Tokens.Macro.Start);
49- $.OR([
49+
50+ // Optional flags before the identifier (e.g., {{!user}}, {{?~macro}}, {{>filtered}})
51+ // Both regular flags and filter flag are captured under the 'flags' label
52+ $.MANY(() => {
53+ $.OR1([
54+ { ALT: () => $.CONSUME(Tokens.Macro.Flags, { LABEL: 'flags' }) },
55+ { ALT: () => $.CONSUME(Tokens.Macro.FilterFlag, { LABEL: 'flags' }) },
56+ ]);
57+ });
58+
59+ // Macro identifier (name)
60+ $.OR2([
5061 { ALT: () => $.CONSUME(Tokens.Macro.DoubleSlash, { LABEL: 'Macro.identifier' }) },
5162 { ALT: () => $.CONSUME(Tokens.Macro.Identifier, { LABEL: 'Macro.identifier' }) },
5263 ]);
public/scripts/macros/engine/MacroRegistry.js+38 -5
@@ -1,6 +1,9 @@
11/** @typedef {import('chevrotain').CstNode} CstNode */
22/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
33/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
4+/** @typedef {import('./MacroFlags.js').MacroFlags} MacroFlags */
5+
6+import { MACRO_IDENTIFIER_PATTERN } from './MacroLexer.js';
47
58import { isFalseBoolean, isTrueBoolean } from '../../utils.js';
69import { MacroEngine } from './MacroEngine.js';
@@ -34,6 +37,8 @@ export const MacroCategory = Object.freeze({
3437 STATE: 'state',
3538 /** Macros that don't fit in any of the other categories, but don't really need/deserve their own */
3639 MISC: 'misc',
40+ /** Macros that are registered but not assigned to a category (any macro should have a category, so let the extension author know...) */
41+ UNCATEGORIZED: 'uncategorized',
3742});
3843
3944/**
@@ -57,7 +62,7 @@ export const MacroValueType = Object.freeze({
5762/**
5863 * @typedef {Object} MacroDefinitionOptions
5964 * @property {MacroAliasDef[]} [aliases] - Alternative names for this macro. Each alias creates a lookup entry pointing to the same definition.
6065 * @property {MacroCategory|string} [category=MacroCategory.UNCATEGORIZED] - Category for grouping in documentation/autocomplete. Use MacroCategory enum values or a custom string.
6166 * @property {number|MacroUnnamedArgDef[]} [unnamedArgs=0] - Specifies the macro's unnamed positional arguments. Can be a number (all required) or an array of definitions (supports optional args). Optional args must be a suffix.
6267 * @property {boolean|MacroListSpec} [list] - Whether the macro allows a list of arguments (optional min and max values can be set). These arguments will be added AFTER the unnamed args.
6368 * @property {boolean} [strictArgs=true] - Whether the macro should be strict about its arguments.
@@ -102,11 +107,16 @@ export const MacroValueType = Object.freeze({
102107 * @property {string[]} unnamedArgs - Unnamed positional arguments (both required and optional, up to the defined count).
103108 * @property {string[]|null} list - List arguments (after unnamed args), or null if list is not enabled.
104109 * @property {{ [key: string]: string }|null} namedArgs - Reserved for future named argument support.
105- * @property {string} raw
110+ * @property {MacroFlags} flags - Macro execution flags that were applied to this macro invocation.
111+ * @property {boolean} isScoped - Whether this macro was invoked using scoped syntax (opening + closing tags).
112+ * @property {string} raw - The inner macro content with nested macros resolved.
113+ * @property {string} rawOriginal - The original full macro text including braces, before any resolution.
114+ * @property {string[]} rawArgs - The original arguments passed to the macro.
106115 * @property {MacroEnv} env
107116 * @property {CstNode|null} cstNode
108117 * @property {{ startOffset: number, endOffset: number }|null} range
109118 * @property {(value: any) => string} normalize - Normalize function to use on unsure macro results to make sure they return strings as expected.
119+ * @property {(content: string, options?: { trimIndent?: boolean }) => string} trimContent - Trims scoped content with optional indentation dedent. Defaults to trimming indentation.
110120 */
111121
112122/**
@@ -179,7 +189,7 @@ class MacroRegistry {
179189 name = typeof name === 'string' ? name.trim() : String(name);
180190
181191 try {
182- if (typeof name !== 'string' || !name) throw new Error('Macro name must be a non-empty string');
192+ if (!isIdentifierValid(name)) throw new Error(`Macro name "${name}" is invalid. Must start with a letter, followed by alphanumeric characters or hyphens.`);
183193 if (!options || typeof options !== 'object') throw new Error(`Macro "${name}" options must be a non-null object.`);
184194
185195 const {
@@ -206,14 +216,18 @@ class MacroRegistry {
206216 if (!aliasDef || typeof aliasDef !== 'object') throw new Error(`Macro "${name}" options.aliases[${i}] must be an object.`);
207217 if (typeof aliasDef.alias !== 'string' || !aliasDef.alias.trim()) throw new Error(`Macro "${name}" options.aliases[${i}].alias must be a non-empty string.`);
208218 const aliasName = aliasDef.alias.trim();
219+ if (!isIdentifierValid(aliasName)) throw new Error(`Macro "${name}" options.aliases[${i}].alias "${aliasName}" is invalid. Must start with a letter, followed by word chars or hyphens.`);
209220 if (aliasName === name) throw new Error(`Macro "${name}" options.aliases[${i}].alias cannot be the same as the macro name.`);
210221 const visible = aliasDef.visible !== false; // Default to true
211222 aliases.push({ alias: aliasName, visible });
212223 }
213224 }
214225
215- if (typeof rawCategory !== 'string' || !rawCategory.trim()) throw new Error(`Macro "${name}" options.category must be a non-empty string.`);
226+ /** @type {MacroCategory|string} */
216227 constlet category = rawCategoryMacroCategory.trim()UNCATEGORIZED;
228+ if (typeof rawCategory === 'string' && rawCategory.trim()) {
229+ category = rawCategory.trim();
230+ }
217231
218232 let minArgs = 0;
219233 let maxArgs = 0;
@@ -525,11 +539,16 @@ class MacroRegistry {
525539 unnamedArgs: unnamedArgsValues,
526540 list: listValues,
527541 namedArgs,
542+ flags: call.flags,
543+ isScoped: call.isScoped,
528544 raw: call.rawInner,
545+ rawOriginal: call.rawWithBraces,
546+ rawArgs: call.rawArgs,
529547 env: call.env,
530548 cstNode: call.cstNode,
531549 range: call.range,
532550 normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),
551+ trimContent: MacroEngine.trimScopedContent.bind(MacroEngine),
533552 };
534553
535554 const result = def.handler(executionContext);
@@ -540,6 +559,20 @@ class MacroRegistry {
540559instance = MacroRegistry.instance;
541560
542561/**
562+ * Validates a macro identifier.
563+ *
564+ * @param {string} name - The macro identifier to validate.
565+ * @param {Object} [options] - Validation options.
566+ * @param {boolean} [options.allowComment = true] - Whether return that the comment identifier '//' is valid.
567+ * @returns {boolean} True if the identifier is valid, false otherwise.
568+ */
569+function isIdentifierValid(name, { allowComment = true } = {}) {
570+ if (typeof name !== 'string' || !name.trim()) return false;
571+ if (allowComment && name === '//') return true;
572+ return MACRO_IDENTIFIER_PATTERN.test(name);
573+}
574+
575+/**
543576 * Validates the arguments for a macro definition.
544577 * Supports required args (minArgs), optional args (up to maxArgs), and list tail.
545578 *
public/scripts/power-user.js+1 -0
@@ -348,6 +348,7 @@ export const power_user = {
348348
349349let themes = [];
350350let movingUIPresets = [];
351+/** @type {ContextSettings[]} */
351352export let context_presets = [];
352353
353354const storage_keys = {
public/scripts/slash-commands/SlashCommandParser.js+368 -19
@@ -15,15 +15,21 @@ import { SlashCommandAbortController } from './SlashCommandAbortController.js';
1515import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';
1616import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
1717import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
1818import { EnhancedMacroAutoCompleteOption, MacroFlagAutoCompleteOption, MacroClosingTagAutoCompleteOption, parseMacroContext } from '../autocomplete/EnhancedMacroAutoCompleteOption.js';
19+import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';
20+import { MacroParser } from '../macros/engine/MacroParser.js';
21+import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';
1922import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
2023import { SlashCommandDebugController } from './SlashCommandDebugController.js';
2124import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
2225import { SlashCommandBreak } from './SlashCommandBreak.js';
2326import { macros as macroSystem } from '../macros/macro-system.js';
27+import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
2428
2529/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */
2630/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */
31+/** @typedef {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} MacroAutoCompleteContext */
32+/** @typedef {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').EnhancedMacroAutoCompleteOptions} EnhancedMacroAutoCompleteOptions */
2733
2834/**
2935 * @enum {Number}
@@ -493,19 +499,138 @@ export class SlashCommandParser {
493499 const macroContent = text.slice(macro.start + 2, macro.end - (text.slice(macro.end - 2, macro.end) === '}}' ? 2 : 0));
494500 const context = parseMacroContext(macroContent, cursorInMacro);
495501
496- // Extract just the identifier (strip trailing colons/whitespace/closing braces from macro.name)
502+ // Check if cursor is at/after the closing }} - macro syntax is complete
497- const identifier = macro.name.replace(/[\s:}]+$/, '').trim();
503+ const macroEndsBrackets = text.slice(macro.end - 2, macro.end) === '}}';
504+ const isCursorAtClosing = macroEndsBrackets && index >= macro.end - 1;
505+
506+ if (isCursorAtClosing) {
507+ // Cursor is at the closing }} - check if this is an unclosed scoped macro
508+ const textUpToCursor = text.slice(0, index);
509+ const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);
510+
511+ if (unclosedScopes.length > 0) {
512+ const scopedMacro = unclosedScopes[unclosedScopes.length - 1];
513+ // Check if the current macro IS the unclosed scoped macro
514+ if (scopedMacro.startOffset === macro.start) {
515+ // Show scoped context - cursor is right at the end of the opening tag
516+ const scopedContext = {
517+ ...context,
518+ currentArgIndex: context.args.length, // Next arg (scoped content)
519+ isInScopedContent: true,
520+ scopedMacroName: scopedMacro.name,
521+ };
522+
523+ const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name);
524+ if (macroDef) {
525+ const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext);
526+ scopedOption.valueProvider = () => '';
527+
528+ const result = new AutoCompleteNameResult(
529+ scopedMacro.name,
530+ macro.start + 2,
531+ [scopedOption],
532+ false,
533+ );
534+ return result;
535+ }
536+ }
537+ }
538+
539+ // Not a scoped macro, just clear arg highlighting
540+ context.currentArgIndex = -1;
541+ }
542+
543+ // Use the identifier from context (handles whitespace and flags)
544+ // Start position must be where the identifier actually begins (after whitespace/flags)
545+ // so that the autocomplete range calculation works correctly
546+ const identifier = context.identifier;
547+ const identifierStartInText = macro.start + 2 + context.identifierStart;
498548
499549 // Use enhanced macro autocomplete when experimental engine is enabled
500- const options = this.#buildEnhancedMacroOptions(context);
550+ // Pass full text up to cursor for unclosed scope detection
551+ const textUpToCursor = text.slice(0, index);
552+
553+ // Special case for {{if}} condition: use the condition text for matching/replacement
554+ const isTypingIfCondition = context.identifier === 'if' && context.currentArgIndex === 0;
555+ if (isTypingIfCondition) {
556+ // Get the typed condition text and calculate its start position
557+ const conditionText = context.args[0] || '';
558+ // Find where the condition argument starts in the macro text
559+ const separatorMatch = macroContent.match(/^.*?if\s*(?:::?)\s*/);
560+ const spaceMatch = macroContent.match(/^.*?if\s+/);
561+ let conditionStartOffset;
562+ if (separatorMatch) {
563+ conditionStartOffset = separatorMatch[0].length;
564+ } else if (spaceMatch) {
565+ conditionStartOffset = spaceMatch[0].length;
566+ } else {
567+ conditionStartOffset = context.identifierStart + identifier.length;
568+ }
569+ const conditionStartInText = macro.start + 2 + conditionStartOffset;
570+
571+ // Build if-condition options using macroContent for padding calculation
572+ const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
573+ const options = this.#buildIfConditionOptions(context, allMacros, macroContent);
574+
575+ const result = new AutoCompleteNameResult(
576+ conditionText,
577+ conditionStartInText,
578+ options,
579+ false,
580+ () => 'Use {{macro}} syntax for dynamic conditions',
581+ () => 'Enter a macro name or {{macro}} for the condition',
582+ );
583+ return result;
584+ }
585+
586+ const options = this.#buildEnhancedMacroOptions(context, textUpToCursor);
501587 const result = new AutoCompleteNameResult(
502588 identifier,
503- macro.start + 2,
589+ identifierStartInText,
504590 options,
505591 false,
506592 );
507593 return result;
508594 }
595+
596+ // Check if cursor is in scoped content of an unclosed macro
597+ const textUpToCursor = text.slice(0, index);
598+ const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);
599+ if (unclosedScopes.length > 0) {
600+ const scopedMacro = unclosedScopes[unclosedScopes.length - 1];
601+ // Find the original macro in macroIndex to get full info
602+ const originalMacro = this.macroIndex.find(it => it.start === scopedMacro.startOffset);
603+ if (originalMacro) {
604+ // Parse the original macro content to get base context
605+ const macroContent = text.slice(originalMacro.start + 2, originalMacro.end - 2);
606+ const baseContext = parseMacroContext(macroContent, macroContent.length);
607+
608+ // Create a scoped context - show next arg as current (the scoped content)
609+ const scopedContext = {
610+ ...baseContext,
611+ currentArgIndex: baseContext.args.length, // Next arg index (the scoped one)
612+ isInScopedContent: true,
613+ scopedMacroName: scopedMacro.name,
614+ };
615+
616+ // Only show the scoped macro's details - no list of other macros
617+ // This creates a "details only" view showing the scoped arg being typed
618+ const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name);
619+ if (macroDef) {
620+ const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext);
621+ // Mark as non-insertable - we're just showing details
622+ scopedOption.valueProvider = () => '';
623+
624+ const result = new AutoCompleteNameResult(
625+ scopedMacro.name, // Use macro name so it shows as "match"
626+ originalMacro.start + 2, // Point to original macro
627+ [scopedOption],
628+ false,
629+ );
630+ return result;
631+ }
632+ }
633+ }
509634 if (executor.name == ':') {
510635 const options = this.scopeIndex[this.commandIndex.indexOf(executor)]
511636 ?.allVariableNames
@@ -540,20 +665,84 @@ export class SlashCommandParser {
540665
541666 /**
542667 * Builds enhanced macro autocomplete options from the MacroRegistry.
668+ * When in the flags area (before identifier), includes flag options.
543669 * When typing arguments (after ::), prioritizes the exact macro match.
544670 * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
545- * @returns {EnhancedMacroAutoCompleteOption[]}
671+ * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection.
672+ * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption)[]}
546673 */
547674 #buildEnhancedMacroOptions(context, textUpToCursor = '') {
548675 /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption)[]} */
549676 const options = [];
550677
678+ // Check for unclosed scoped macros and suggest closing tags first
679+ const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);
680+ if (unclosedScopes.length > 0) {
681+ // Suggest closing the innermost (last) unclosed scope first
682+ const innermostScope = unclosedScopes[unclosedScopes.length - 1];
683+ const closingOption = new MacroClosingTagAutoCompleteOption(innermostScope.name);
684+ options.push(closingOption);
685+
686+ // If inside a scoped {{if}}, also suggest {{else}}
687+ if (innermostScope.name === 'if') {
688+ // TODO: TEsting
689+ const macroDef = macroSystem.registry.getPrimaryMacro('else');
690+ const elseOption = new EnhancedMacroAutoCompleteOption(macroDef);
691+ elseOption.sortPriority = 2;
692+ // const elseOption = new MacroElseAutoCompleteOption();
693+ options.push(elseOption);
694+ }
695+ }
696+
697+ // If cursor is in the flags area (before identifier starts), include flag options
698+ if (context.isInFlagsArea) {
699+ // Build flag options with priority-based sorting
700+ // Last typed flag has highest priority (1), other flags have lower priority (10)
701+ // Already-typed flags (except last) are hidden from the list
702+ const lastTypedFlag = context.flags.length > 0 ? context.flags[context.flags.length - 1] : null;
703+
704+ // Add last typed flag with high priority (so it appears at top)
705+ if (lastTypedFlag) {
706+ const lastFlagDef = MacroFlagDefinitions.get(lastTypedFlag);
707+ if (lastFlagDef) {
708+ const lastFlagOption = new MacroFlagAutoCompleteOption(lastFlagDef);
709+ // Mark as already typed - valueProvider returns empty so it doesn't re-insert
710+ lastFlagOption.valueProvider = () => '';
711+ // High priority to appear at top (after closing tags at 1)
712+ lastFlagOption.sortPriority = 2;
713+ options.push(lastFlagOption);
714+ }
715+ }
716+
717+ // Add flags that haven't been typed yet (skip already-typed ones except last)
718+ for (const [symbol, flagDef] of MacroFlagDefinitions) {
719+ // Skip the last typed flag (already added above) and other already-typed flags
720+ if (context.flags.includes(symbol)) {
721+ continue;
722+ }
723+ const flagOption = new MacroFlagAutoCompleteOption(flagDef);
724+
725+ // Define whether this flag is selectable (and at the top), based on being implemented, and closing actually being relevant
726+ let isSelectable = flagDef.implemented;
727+ if (flagDef.type === MacroFlagType.CLOSING_BLOCK && !unclosedScopes.length) isSelectable = false;
728+ if (!isSelectable) {
729+ flagOption.valueProvider = () => '';
730+ }
731+ // Normal flag priority
732+ flagOption.sortPriority = isSelectable ? 10 : 12;
733+ options.push(flagOption);
734+ }
735+ }
736+
551737 // Get all macros from the registry (excluding hidden aliases)
552738 const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
553739
554740 // If we're typing arguments (after ::), only show the context to the matching macro
555741 const isTypingArgs = context.currentArgIndex >= 0;
556742
743+ // Check if we're inside a scoped {{if}} for {{else}} selectability
744+ const isInsideScopedIf = unclosedScopes.some(scope => scope.name === 'if');
745+
557746 for (const macro of allMacros) {
558747 // Check if this macro matches the typed identifier
559748 const isExactMatch = macro.name === context.identifier;
@@ -561,10 +750,25 @@ export class SlashCommandParser {
561750
562751 // Only pass context to the macro that matches the identifier being typed
563752 // This ensures argument hints only show for the relevant macro
564- const macroContext = (isExactMatch || isAliasMatch) ? context : null;
753+ /** @type {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} */
754+ let macroContext = (isExactMatch || isAliasMatch) ? context : null;
755+
756+ // If no context, we pass some options for additional details though
757+ if (!macroContext) {
758+ macroContext = /** @type {EnhancedMacroAutoCompleteOptions} */ ({
759+ paddingAfter: context.paddingBefore, // Match whitespace before the macro - will only be used if the macro gets auto-closed
760+ });
761+ }
565762
566763 const option = new EnhancedMacroAutoCompleteOption(macro, macroContext);
567764
765+ // {{else}} is only selectable inside a scoped {{if}} block
766+ // Outside of {{if}}, it should appear in the list but not be tab-completable
767+ if (macro.name === 'else' && !isInsideScopedIf) {
768+ option.valueProvider = () => '';
769+ option.makeSelectable = false;
770+ }
771+
568772 // When typing arguments, prioritize exact matches by putting them first
569773 if (isTypingArgs && (isExactMatch || isAliasMatch)) {
570774 options.unshift(option);
@@ -577,6 +781,103 @@ export class SlashCommandParser {
577781 }
578782
579783 /**
784+ * Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand.
785+ * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
786+ * @param {import('../macros/engine/MacroRegistry.js').MacroDefinition[]} allMacros
787+ * @param {string} macroInnerText - The text inside the macro braces (e.g., " if pers" from "{{ if pers").
788+ * @returns {AutoCompleteOption[]}
789+ */
790+ #buildIfConditionOptions(context, allMacros, macroInnerText) {
791+ /** @type {AutoCompleteOption[]} */
792+ const options = [];
793+
794+ // Calculate padding from the original macro text for matching whitespace on completion
795+ // e.g., " if pers" -> leading padding = " " (whitespace before 'if', used before '}}')
796+ const leadingMatch = macroInnerText.match(/^(\s*)/);
797+ const paddingAfter = leadingMatch ? leadingMatch[1] : '';
798+
799+ // Add zero-arg macros as condition shorthand options
800+ for (const macro of allMacros) {
801+ // Only include macros that require zero arguments (can be auto-resolved)
802+ if (macro.minArgs !== 0) continue;
803+
804+ // Skip internal/utility macros that don't make sense as conditions
805+ if (['else', 'noop', 'trim', '//'].includes(macro.name)) continue;
806+
807+ const option = new EnhancedMacroAutoCompleteOption(macro, {
808+ noBraces: true,
809+ paddingAfter,
810+ closeWithBraces: true,
811+ });
812+ options.push(option);
813+ }
814+
815+ return options;
816+ }
817+
818+ /**
819+ * Finds unclosed scoped macros in the text up to cursor position.
820+ * Uses the MacroParser and MacroCstWalker for accurate analysis.
821+ *
822+ * @param {string} textUpToCursor - The document text up to the cursor position.
823+ * @returns {Array<{ name: string, startOffset: number, endOffset: number }>}
824+ */
825+ #findUnclosedScopes(textUpToCursor) {
826+ if (!textUpToCursor) return [];
827+
828+ try {
829+ // Parse the document to get the CST
830+ const { cst } = MacroParser.parseDocument(textUpToCursor);
831+ if (!cst) return [];
832+
833+ // Use the CST walker to find unclosed scopes
834+ return MacroCstWalker.findUnclosedScopes({ text: textUpToCursor, cst });
835+ } catch {
836+ // If parsing fails (incomplete input), fall back to simple regex approach
837+ return this.#findUnclosedScopesRegex(textUpToCursor);
838+ }
839+ }
840+
841+ /**
842+ * Fallback regex-based approach for finding unclosed scopes.
843+ * Used when the parser fails on incomplete input.
844+ *
845+ * @param {string} text - The text to analyze.
846+ * @returns {Array<{ name: string, startOffset: number, endOffset: number }>}
847+ */
848+ #findUnclosedScopesRegex(text) {
849+ // Simple regex to find macro openings and closings
850+ // This is a fallback - less accurate but works on partial input
851+ const macroPattern = /\{\{(\/?)([\w-]+)/g;
852+ const stack = [];
853+
854+ let match;
855+ while ((match = macroPattern.exec(text)) !== null) {
856+ const isClosing = match[1] === '/';
857+ const name = match[2];
858+
859+ if (isClosing) {
860+ // Pop matching opener
861+ if (stack.length > 0 && stack[stack.length - 1].name === name) {
862+ stack.pop();
863+ }
864+ } else {
865+ // Check if macro can accept scoped content
866+ const macroDef = macroSystem.registry.getPrimaryMacro(name);
867+ if (macroDef && macroDef.maxArgs > 0) {
868+ stack.push({
869+ name,
870+ startOffset: match.index,
871+ endOffset: match.index + match[0].length,
872+ });
873+ }
874+ }
875+ }
876+
877+ return stack;
878+ }
879+
880+ /**
580881 * Moves the index <length> number of characters forward and returns the last character taken.
581882 * @param {number} length Number of characters to take.
582883 * @param {boolean} keep Whether to add the characters to the kept text.
@@ -1286,18 +1587,66 @@ export class SlashCommandParser {
12861587 }
12871588
12881589 indexMacros(offset, text) {
1289- const re = /{{(?:((?:(?!}})[^\s:])+[\s:]*)((?:(?!}}).)*)(}}|}$|$))?/s;
1590+ // Index all macros including nested ones
1290- let remaining = text;
1591+ // We need to track brace depth to properly handle nested macros like {{reverse::Hey {{user}}}}
12911592 let localOffseti = 0;
12921593 while (remaining.lengthi >< 0text.length &&- re.test(remaining)1) {
1293- const match = re.exec(remaining);
1594+ // Look for macro start {{
1595+ if (text[i] === '{' && text[i + 1] === '{') {
1596+ const macroStart = i;
1597+ i += 2; // Skip {{
1598+
1599+ // Find where this macro ends, tracking nested braces
1600+ let depth = 1;
1601+ let macroEnd = text.length; // Default to end if unclosed
1602+
1603+ while (i < text.length - 1 && depth > 0) {
1604+ if (text[i] === '{' && text[i + 1] === '{') {
1605+ // Nested macro start - recursively index it
1606+ // The nested macro will be indexed in subsequent iterations
1607+ depth++;
1608+ i += 2;
1609+ } else if (text[i] === '}' && text[i + 1] === '}') {
1610+ depth--;
1611+ if (depth === 0) {
1612+ macroEnd = i + 2; // Include the closing }}
1613+ }
1614+ i += 2;
1615+ } else {
1616+ i++;
1617+ }
1618+ }
1619+
1620+ // Extract macro content (between {{ and }} or end)
1621+ const contentEnd = macroEnd === text.length ? macroEnd : macroEnd - 2;
1622+ const macroContent = text.slice(macroStart + 2, contentEnd);
1623+
1624+ // Use parseMacroContext to extract the identifier
1625+ const context = parseMacroContext(macroContent, macroContent.length);
1626+
12941627 this.macroIndex.push({
12951628 start: offset + localOffset + match.indexmacroStart,
1296- end: offset + localOffset + match.index + (match[0]?.length ?? 0),
1629+ end: offset + macroEnd,
1297- name: match[1] ?? '',
1630+ name: context.identifier,
12981631 });
1299- localOffset += match.index + (match[0]?.length ?? 0);
1632+
1300- remaining = remaining.slice(match.index + (match[0]?.length ?? 0));
1633+ // Continue from where we left off (don't skip ahead)
1634+ // This ensures nested macros get their own index entries
1635+ i = macroStart + 2; // Move past the opening {{ to look for nested macros
1636+ // Skip to find nested {{ inside this macro's content
1637+ while (i < contentEnd) {
1638+ if (text[i] === '{' && i + 1 < text.length && text[i + 1] === '{') {
1639+ break; // Found nested macro, outer loop will handle it
1640+ }
1641+ i++;
1642+ }
1643+ if (i >= contentEnd) {
1644+ // No nested macro found, skip to end of this macro
1645+ i = macroEnd;
1646+ }
1647+ } else {
1648+ i++;
1649+ }
13011650 }
13021651 }
13031652}
tests/frontend/MacroEngine.e2e.js+1091 -4
@@ -126,6 +126,38 @@ test.describe('MacroEngine', () => {
126126 });
127127 });
128128
129+ test.describe('Trim macro', () => {
130+ test('should trim content inside scoped trim macro', async ({ page }) => {
131+ const input = '{{trim}} hello world {{/trim}}';
132+ const output = await evaluateWithEngine(page, input);
133+ expect(output).toBe('hello world');
134+ });
135+
136+ test('should trim leading whitespace in scoped trim', async ({ page }) => {
137+ const input = '{{trim}}\n\n content{{/trim}}';
138+ const output = await evaluateWithEngine(page, input);
139+ expect(output).toBe('content');
140+ });
141+
142+ test('should trim trailing whitespace in scoped trim', async ({ page }) => {
143+ const input = '{{trim}}content \n\n{{/trim}}';
144+ const output = await evaluateWithEngine(page, input);
145+ expect(output).toBe('content');
146+ });
147+
148+ test('should handle scoped trim with macros inside', async ({ page }) => {
149+ const input = '{{trim}} Hello {{user}} {{/trim}}';
150+ const output = await evaluateWithEngine(page, input);
151+ expect(output).toBe('Hello User');
152+ });
153+
154+ test('should handle nested scoped trim', async ({ page }) => {
155+ const input = '{{trim}} outer {{trim}} inner {{/trim}} outer {{/trim}}';
156+ const output = await evaluateWithEngine(page, input);
157+ expect(output).toBe('outer inner outer');
158+ });
159+ });
160+
129161 test.describe('Legacy compatibility', () => {
130162 test('should strip trim macro and surrounding newlines (legacy behavior)', async ({ page }) => {
131163 const input = 'foo\n\n{{trim}}\n\nbar';
@@ -395,7 +427,7 @@ test.describe('MacroEngine', () => {
395427 });
396428
397429 test.describe('Arity errors', () => {
398430 test('should not resolve newlinemacro without arguments when called with arguments', async ({ page }) => {
399431 /** @type {string[]} */
400432 const warnings = [];
401433 page.on('console', msg => {
@@ -404,14 +436,14 @@ test.describe('MacroEngine', () => {
404436 }
405437 });
406438
407439 const input = 'Start {{newlinechar::extra}} end.';
408440 const output = await evaluateWithEngine(page, input);
409441
410442 // Macro text should remain unchanged
411443 expect(output).toBe(input);
412444
413445 // Should have logged an arity warning for newlinechar
414446 expect(warnings.some(w => w.includes('Macro "newlinechar"') && w.includes('unnamed arguments'))).toBeTruthy();
415447 });
416448
417449 test('should not resolve reverse when called without arguments', async ({ page }) => {
@@ -678,6 +710,1061 @@ test.describe('MacroEngine', () => {
678710 expect(warnings.some(w => w.includes('Macro "dyn"') && w.includes('unnamed arguments'))).toBeTruthy();
679711 });
680712 });
713+
714+ test.describe('Macro flags', () => {
715+ test('should resolve macro with legacy hash flag (no effect)', async ({ page }) => {
716+ // Legacy hash flag should be parsed but have no effect
717+ const input = 'Hello {{#user}}!';
718+ const output = await evaluateWithEngine(page, input);
719+ expect(output).toBe('Hello User!');
720+ });
721+
722+ test('should keep unmatched closing block macro as raw text', async ({ page }) => {
723+ // Closing block without matching opening should be kept as raw
724+ const input = '{{/unknown}}';
725+ const output = await evaluateWithEngine(page, input);
726+ expect(output).toBe('{{/unknown}}');
727+ });
728+
729+ test('should keep unmatched closing block macro for existing macro as raw text', async ({ page }) => {
730+ // Closing block for a known macro (user) without matching opening should stay raw
731+ const input = '{{/user}}';
732+ const output = await evaluateWithEngine(page, input);
733+ expect(output).toBe('{{/user}}');
734+ });
735+
736+ test('should keep unmatched closing block macro with arguments as raw text', async ({ page }) => {
737+ // Closing block with arguments should stay raw (closing macros don't take args anyway)
738+ const input = '{{/getvar::test}}';
739+ const output = await evaluateWithEngine(page, input);
740+ expect(output).toBe('{{/getvar::test}}');
741+ });
742+
743+ test('should keep closing macro raw when surrounded by other content', async ({ page }) => {
744+ // Closing macro in middle of text should stay raw, other macros should resolve
745+ const input = 'Hello {{user}}, this {{/char}} is raw, bye {{char}}!';
746+ const output = await evaluateWithEngine(page, input);
747+ expect(output).toBe('Hello User, this {{/char}} is raw, bye Character!');
748+ });
749+
750+ test('should resolve scoped macro while keeping unrelated closing raw', async ({ page }) => {
751+ // Scoped macro resolves normally, unrelated closing stays raw
752+ const input = '{{setvar::x}}value{{/setvar}}{{/user}}{{getvar::x}}';
753+ const output = await evaluateWithEngine(page, input);
754+ expect(output).toBe('{{/user}}value');
755+ });
756+
757+ test('should pass flags to macro handler', async ({ page }) => {
758+ // Register a test macro that returns its flags
759+ const output = await page.evaluate(async () => {
760+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
761+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
762+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
763+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
764+ /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
765+ const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
766+
767+ MacroRegistry.unregisterMacro('test-flags');
768+ MacroRegistry.registerMacro('test-flags', {
769+ description: 'Test macro that returns its flags.',
770+ handler: ({ flags }) => {
771+ const activeFlags = flags.raw.join(',') || 'none';
772+ return `[${activeFlags}]`;
773+ },
774+ });
775+
776+ const rawEnv = { content: '' };
777+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
778+
779+ return MacroEngine.evaluate('{{test-flags}} / {{!test-flags}} / {{!?test-flags}}', env);
780+ });
781+
782+ expect(output).toBe('[none] / [!] / [!,?]');
783+ });
784+
785+ test('should correctly identify individual flags in handler', async ({ page }) => {
786+ const output = await page.evaluate(async () => {
787+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
788+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
789+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
790+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
791+ /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
792+ const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
793+
794+ MacroRegistry.unregisterMacro('test-flag-check');
795+ MacroRegistry.registerMacro('test-flag-check', {
796+ description: 'Test macro that checks specific flags.',
797+ handler: ({ flags }) => {
798+ const parts = [];
799+ if (flags.immediate) parts.push('immediate');
800+ if (flags.delayed) parts.push('delayed');
801+ if (flags.filter) parts.push('filter');
802+ if (flags.closingBlock) parts.push('closingBlock');
803+ if (flags.preserveWhitespace) parts.push('preserveWhitespace');
804+ return parts.join('+') || 'noflags';
805+ },
806+ });
807+
808+ const rawEnv = { content: '' };
809+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
810+
811+ const results = [
812+ MacroEngine.evaluate('{{test-flag-check}}', env),
813+ MacroEngine.evaluate('{{!test-flag-check}}', env),
814+ MacroEngine.evaluate('{{?test-flag-check}}', env),
815+ MacroEngine.evaluate('{{>test-flag-check}}', env),
816+ // Note: {{/test-flag-check}} would stay raw (unmatched closing macro)
817+ MacroEngine.evaluate('{{#test-flag-check}}', env),
818+ MacroEngine.evaluate('{{!?>test-flag-check}}', env),
819+ ];
820+ return results.join(' | ');
821+ });
822+
823+ // Closing flag (/) is not tested here as standalone closing macros stay raw
824+ expect(output).toBe('noflags | immediate | delayed | filter | preserveWhitespace | immediate+delayed+filter');
825+ });
826+
827+ test('should handle flags with arguments correctly', async ({ page }) => {
828+ const input = '{{!reverse::hello}}';
829+ const output = await evaluateWithEngine(page, input);
830+ // The flag should not affect the macro resolution
831+ expect(output).toBe('olleh');
832+ });
833+
834+ test('should handle multiple flags with whitespace', async ({ page }) => {
835+ const output = await page.evaluate(async () => {
836+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
837+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
838+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
839+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
840+ /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
841+ const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
842+
843+ MacroRegistry.unregisterMacro('test-flags-ws');
844+ MacroRegistry.registerMacro('test-flags-ws', {
845+ description: 'Test macro for flags with whitespace.',
846+ handler: ({ flags }) => flags.raw.length.toString(),
847+ });
848+
849+ const rawEnv = { content: '' };
850+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
851+
852+ return MacroEngine.evaluate('{{ ! ? > test-flags-ws }}', env);
853+ });
854+
855+ expect(output).toBe('3');
856+ });
857+ });
858+
859+ test.describe('Scoped macros', () => {
860+ test('should merge scoped content as last unnamed argument', async ({ page }) => {
861+ const input = '{{setvar::myvar}}Hello World{{/setvar}}{{getvar::myvar}}';
862+ const output = await evaluateWithEngine(page, input);
863+ expect(output).toBe('Hello World');
864+ });
865+
866+ test('should be equivalent to inline argument syntax', async ({ page }) => {
867+ const input1 = '{{setvar::myvar::test value}}{{getvar::myvar}}';
868+ const input2 = '{{setvar::myvar}}test value{{/setvar}}{{getvar::myvar}}';
869+
870+ const output1 = await evaluateWithEngine(page, input1);
871+ const output2 = await evaluateWithEngine(page, input2);
872+
873+ expect(output1).toBe(output2);
874+ });
875+
876+ test('should resolve nested macros inside scoped content', async ({ page }) => {
877+ const input = '{{setvar::myvar}}Hello {{user}}!{{/setvar}}{{getvar::myvar}}';
878+ const output = await evaluateWithEngine(page, input);
879+ expect(output).toBe('Hello User!');
880+ });
881+
882+ test('should handle nested scoped macros with same name', async ({ page }) => {
883+ // Outer scope sets 'outer', inner scope sets 'inner'
884+ // Since setvar returns '', the inner macro contributes nothing to outer's content
885+ const input = '{{setvar::outer}}before {{setvar::inner}}nested{{/setvar}} after{{/setvar}}{{getvar::outer}} | {{getvar::inner}}';
886+ const output = await evaluateWithEngine(page, input);
887+ expect(output).toBe('before after | nested'); // Note: double space where inner setvar was
888+ });
889+
890+ test('should handle multiple independent scoped macros', async ({ page }) => {
891+ const input = '{{setvar::a}}first{{/setvar}}{{setvar::b}}second{{/setvar}}[{{getvar::a}}][{{getvar::b}}]';
892+ const output = await evaluateWithEngine(page, input);
893+ expect(output).toBe('[first][second]');
894+ });
895+
896+ test('should keep unmatched closing tag as raw text', async ({ page }) => {
897+ const input = 'Before {{/setvar}} After';
898+ const output = await evaluateWithEngine(page, input);
899+ expect(output).toBe('Before {{/setvar}} After');
900+ });
901+
902+ test('should keep second closing tag as raw when already closed', async ({ page }) => {
903+ const input = '{{setvar::myvar}}content{{/setvar}}{{/setvar}}{{getvar::myvar}}';
904+ const output = await evaluateWithEngine(page, input);
905+ expect(output).toBe('{{/setvar}}content');
906+ });
907+
908+ test('should work with empty scoped content', async ({ page }) => {
909+ const input = '{{setvar::empty}}{{/setvar}}[{{getvar::empty}}]';
910+ const output = await evaluateWithEngine(page, input);
911+ expect(output).toBe('[]');
912+ });
913+
914+ test('should work with multi-line scoped content', async ({ page }) => {
915+ const input = '{{setvar::multi}}Line 1\nLine 2\nLine 3{{/setvar}}{{getvar::multi}}';
916+ const output = await evaluateWithEngine(page, input);
917+ expect(output).toBe('Line 1\nLine 2\nLine 3');
918+ });
919+
920+ test('should preserve plaintext around scoped macros', async ({ page }) => {
921+ const input = 'Before {{setvar::x}}value{{/setvar}} After {{getvar::x}}';
922+ const output = await evaluateWithEngine(page, input);
923+ expect(output).toBe('Before After value');
924+ });
925+
926+ test('should handle deeply nested scoped macros', async ({ page }) => {
927+ // Since setvar returns '', nested setvars contribute nothing to parent content
928+ // l3 = "C", l2 = "B" + "" + "B" = "BB", l1 = "A" + "" + "A" = "AA"
929+ const input = '{{setvar::l1}}A{{setvar::l2}}B{{setvar::l3}}C{{/setvar}}B{{/setvar}}A{{/setvar}}{{getvar::l1}}|{{getvar::l2}}|{{getvar::l3}}';
930+ const output = await evaluateWithEngine(page, input);
931+ expect(output).toBe('AA|BB|C');
932+ });
933+
934+ test('should handle scoped macro with existing arguments', async ({ page }) => {
935+ // reverse takes 1 arg; scoped content becomes the only arg
936+ const input = '{{reverse}}hello{{/reverse}}';
937+ const output = await evaluateWithEngine(page, input);
938+ expect(output).toBe('olleh');
939+ });
940+
941+ test('should not match closing tag for different macro name', async ({ page }) => {
942+ // Opening setvar, closing getvar - should not match
943+ const input = '{{setvar::x}}content{{/getvar}}{{getvar::x}}';
944+ const output = await evaluateWithEngine(page, input);
945+ // setvar without proper closing keeps looking, finds none, so it stays as is
946+ // getvar closing has no opener, stays as raw
947+ expect(output).toBe('{{setvar::x}}content{{/getvar}}');
948+ });
949+
950+ test('should handle scoped content with special characters', async ({ page }) => {
951+ const input = '{{setvar::special}}Hello { world } :: test{{/setvar}}{{getvar::special}}';
952+ const output = await evaluateWithEngine(page, input);
953+ expect(output).toBe('Hello { world } :: test');
954+ });
955+
956+ test('should set isScoped to true for scoped macro invocation', async ({ page }) => {
957+ const output = await page.evaluate(async () => {
958+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
959+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
960+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
961+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
962+ /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
963+ const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
964+
965+ MacroRegistry.unregisterMacro('test-isscoped');
966+ MacroRegistry.registerMacro('test-isscoped', {
967+ description: 'Test macro that reports isScoped value.',
968+ unnamedArgs: [{ name: 'content', type: 'string', description: 'Content' }],
969+ handler: ({ isScoped }) => `isScoped:${isScoped}`,
970+ });
971+
972+ const rawEnv = { content: '' };
973+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
974+ return MacroEngine.evaluate('{{test-isscoped}}content{{/test-isscoped}}', env);
975+ });
976+ expect(output).toBe('isScoped:true');
977+ });
978+
979+ test('should set isScoped to false for inline argument syntax', async ({ page }) => {
980+ const output = await page.evaluate(async () => {
981+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
982+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
983+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
984+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
985+ /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
986+ const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
987+
988+ MacroRegistry.unregisterMacro('test-isscoped');
989+ MacroRegistry.registerMacro('test-isscoped', {
990+ description: 'Test macro that reports isScoped value.',
991+ unnamedArgs: [{ name: 'content', type: 'string', description: 'Content' }],
992+ handler: ({ isScoped }) => `isScoped:${isScoped}`,
993+ });
994+
995+ const rawEnv = { content: '' };
996+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
997+ return MacroEngine.evaluate('{{test-isscoped::content}}', env);
998+ });
999+ expect(output).toBe('isScoped:false');
1000+ });
1001+
1002+ test('should keep scoped macro raw when macro accepts no arguments', async ({ page }) => {
1003+ // {{user}} takes no arguments, so {{user}}content{{/user}} should stay raw
1004+ // But content inside should still resolve
1005+ const input = '{{user}}Hello {{char}}!{{/user}}';
1006+ const output = await evaluateWithEngine(page, input);
1007+ expect(output).toBe('{{user}}Hello Character!{{/user}}');
1008+ });
1009+
1010+ test('should keep scoped macro raw when argument count exceeds maximum', async ({ page }) => {
1011+ // setvar takes 2 args (name, value). With scoped content as 3rd arg, it exceeds max.
1012+ // When already at max args, scoped content would be extra - should stay raw
1013+ const input = '{{setvar::myvar::existing}}extra{{/setvar}}{{getvar::myvar}}';
1014+ const output = await evaluateWithEngine(page, input);
1015+ expect(output).toBe('{{setvar::myvar::existing}}extra{{/setvar}}');
1016+ });
1017+
1018+ test('should keep scoped macro raw when argument count is below minimum', async ({ page }) => {
1019+ const output = await page.evaluate(async () => {
1020+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1021+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1022+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1023+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1024+ /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
1025+ const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
1026+
1027+ // Register a macro that requires exactly 3 arguments
1028+ MacroRegistry.unregisterMacro('test-3args');
1029+ MacroRegistry.registerMacro('test-3args', {
1030+ description: 'Test macro requiring 3 arguments.',
1031+ unnamedArgs: [
1032+ { name: 'a', type: 'string', description: 'First' },
1033+ { name: 'b', type: 'string', description: 'Second' },
1034+ { name: 'c', type: 'string', description: 'Third' },
1035+ ],
1036+ handler: ({ unnamedArgs: [a, b, c] }) => `${a}-${b}-${c}`,
1037+ });
1038+
1039+ const rawEnv = { content: '' };
1040+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1041+ // Only 2 args (1 inline + 1 scoped), but needs 3 - should stay raw
1042+ return MacroEngine.evaluate('{{test-3args::first}}second{{/test-3args}}', env);
1043+ });
1044+ expect(output).toBe('{{test-3args::first}}second{{/test-3args}}');
1045+ });
1046+
1047+ test('should evaluate inner macros before outer macro in scoped content', async ({ page }) => {
1048+ const output = await page.evaluate(async () => {
1049+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1050+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1051+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1052+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1053+ /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
1054+ const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
1055+
1056+ // Track evaluation order
1057+ const evalOrder = [];
1058+
1059+ MacroRegistry.unregisterMacro('test-outer');
1060+ MacroRegistry.registerMacro('test-outer', {
1061+ description: 'Outer test macro.',
1062+ unnamedArgs: [{ name: 'content', type: 'string', description: 'Content' }],
1063+ handler: ({ unnamedArgs: [content] }) => {
1064+ evalOrder.push('outer');
1065+ return `[outer:${content}]`;
1066+ },
1067+ });
1068+
1069+ MacroRegistry.unregisterMacro('test-inner');
1070+ MacroRegistry.registerMacro('test-inner', {
1071+ description: 'Inner test macro.',
1072+ handler: () => {
1073+ evalOrder.push('inner');
1074+ return 'INNER';
1075+ },
1076+ });
1077+
1078+ const rawEnv = { content: '' };
1079+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1080+ const result = MacroEngine.evaluate('{{test-outer}}before {{test-inner}} after{{/test-outer}}', env);
1081+ return { result, order: evalOrder.join(',') };
1082+ });
1083+ expect(output.result).toBe('[outer:before INNER after]');
1084+ expect(output.order).toBe('inner,outer');
1085+ });
1086+
1087+ test('should handle scoped macro inside another scoped macro content', async ({ page }) => {
1088+ // Both scoped macros should resolve, inner first
1089+ const input = '{{setvar::outer}}A{{setvar::inner}}B{{/setvar}}C{{/setvar}}{{getvar::outer}}|{{getvar::inner}}';
1090+ const output = await evaluateWithEngine(page, input);
1091+ // inner = "B", outer = "A" + "" + "C" = "AC" (setvar returns empty string)
1092+ expect(output).toBe('AC|B');
1093+ });
1094+
1095+ test('should auto-trim whitespace-only scoped content to empty', async ({ page }) => {
1096+ const input = '{{setvar::ws}} {{/setvar}}[{{getvar::ws}}]';
1097+ const output = await evaluateWithEngine(page, input);
1098+ expect(output).toBe('[]');
1099+ });
1100+
1101+ test('should preserve whitespace-only scoped content with # flag', async ({ page }) => {
1102+ const input = '{{#setvar::ws}} {{/setvar}}[{{getvar::ws}}]';
1103+ const output = await evaluateWithEngine(page, input);
1104+ expect(output).toBe('[ ]');
1105+ });
1106+
1107+ test('should handle scoped macro at start of input', async ({ page }) => {
1108+ const input = '{{setvar::x}}value{{/setvar}}result:{{getvar::x}}';
1109+ const output = await evaluateWithEngine(page, input);
1110+ expect(output).toBe('result:value');
1111+ });
1112+
1113+ test('should handle scoped macro at end of input', async ({ page }) => {
1114+ const input = 'prefix {{setvar::x}}value{{/setvar}}';
1115+ const output = await evaluateWithEngine(page, input);
1116+ expect(output).toBe('prefix ');
1117+ });
1118+
1119+ test('should handle consecutive scoped macros', async ({ page }) => {
1120+ const input = '{{setvar::a}}1{{/setvar}}{{setvar::b}}2{{/setvar}}{{setvar::c}}3{{/setvar}}{{getvar::a}}{{getvar::b}}{{getvar::c}}';
1121+ const output = await evaluateWithEngine(page, input);
1122+ expect(output).toBe('123');
1123+ });
1124+
1125+ test('should handle scoped macro with only macro content (no plaintext)', async ({ page }) => {
1126+ const input = '{{setvar::x}}{{user}}{{/setvar}}{{getvar::x}}';
1127+ const output = await evaluateWithEngine(page, input);
1128+ expect(output).toBe('User');
1129+ });
1130+
1131+ test('should not match closing tag across different macro instances', async ({ page }) => {
1132+ // Two separate setvar macros - second closing should not match first opening
1133+ const input = '{{setvar::a}}first{{/setvar}}middle{{setvar::b}}second{{/setvar}}[{{getvar::a}}][{{getvar::b}}]';
1134+ const output = await evaluateWithEngine(page, input);
1135+ expect(output).toBe('middle[first][second]');
1136+ });
1137+ });
1138+
1139+ test.describe('{{if}} conditional macro', () => {
1140+ test.describe('with literal values', () => {
1141+ test('should return content when condition is truthy string', async ({ page }) => {
1142+ const input = '{{if::hello::shown}}';
1143+ const output = await evaluateWithEngine(page, input);
1144+ expect(output).toBe('shown');
1145+ });
1146+
1147+ test('should return empty when condition is empty string', async ({ page }) => {
1148+ const input = '{{if::::hidden}}';
1149+ const output = await evaluateWithEngine(page, input);
1150+ expect(output).toBe('');
1151+ });
1152+
1153+ test('should return empty when condition is "false"', async ({ page }) => {
1154+ const input = '{{if::false::hidden}}';
1155+ const output = await evaluateWithEngine(page, input);
1156+ expect(output).toBe('');
1157+ });
1158+
1159+ test('should return empty when condition is "off"', async ({ page }) => {
1160+ const input = '{{if::off::hidden}}';
1161+ const output = await evaluateWithEngine(page, input);
1162+ expect(output).toBe('');
1163+ });
1164+
1165+ test('should return empty when condition is "0"', async ({ page }) => {
1166+ const input = '{{if::0::hidden}}';
1167+ const output = await evaluateWithEngine(page, input);
1168+ expect(output).toBe('');
1169+ });
1170+
1171+ test('should return content when condition is "true"', async ({ page }) => {
1172+ const input = '{{if::true::shown}}';
1173+ const output = await evaluateWithEngine(page, input);
1174+ expect(output).toBe('shown');
1175+ });
1176+
1177+ test('should return content when condition is "1"', async ({ page }) => {
1178+ const input = '{{if::1::shown}}';
1179+ const output = await evaluateWithEngine(page, input);
1180+ expect(output).toBe('shown');
1181+ });
1182+ });
1183+
1184+ test.describe('with macro name resolution', () => {
1185+ test('should resolve macro name and return content when macro returns truthy', async ({ page }) => {
1186+ // {{char}} returns "Character" (set in test env)
1187+ const input = '{{if char}}Name: {{char}}{{/if}}';
1188+ const output = await evaluateWithEngine(page, input);
1189+ expect(output).toBe('Name: Character');
1190+ });
1191+
1192+ test('should resolve macro name and return empty when macro returns empty', async ({ page }) => {
1193+ // {{noop}} is a registered macro that always returns empty string
1194+ const input = '{{if noop}}should not show{{/if}}[end]';
1195+ const output = await evaluateWithEngine(page, input);
1196+ expect(output).toBe('[end]');
1197+ });
1198+
1199+ test('should not resolve non-existent macro names (treat as literal)', async ({ page }) => {
1200+ // "notamacro" is not registered, so it's truthy as a literal string
1201+ const input = '{{if::notamacro::shown}}';
1202+ const output = await evaluateWithEngine(page, input);
1203+ expect(output).toBe('shown');
1204+ });
1205+
1206+ test('should resolve user macro and show content', async ({ page }) => {
1207+ // {{user}} returns "User" (set in test env)
1208+ const input = '{{if user}}Hello {{user}}{{/if}}';
1209+ const output = await evaluateWithEngine(page, input);
1210+ expect(output).toBe('Hello User');
1211+ });
1212+ });
1213+
1214+ test.describe('with nested macros in condition', () => {
1215+ test('should evaluate nested macro in condition (truthy)', async ({ page }) => {
1216+ const input = '{{setvar::flag::yes}}{{if {{getvar::flag}}}}shown{{/if}}';
1217+ const output = await evaluateWithEngine(page, input);
1218+ expect(output).toBe('shown');
1219+ });
1220+
1221+ test('should evaluate nested macro in condition (falsy)', async ({ page }) => {
1222+ const input = '{{setvar::flag::}}{{if {{getvar::flag}}}}hidden{{/if}}[end]';
1223+ const output = await evaluateWithEngine(page, input);
1224+ expect(output).toBe('[end]');
1225+ });
1226+
1227+ test('should evaluate nested macro in condition (false string)', async ({ page }) => {
1228+ const input = '{{setvar::flag::false}}{{if {{getvar::flag}}}}hidden{{/if}}[end]';
1229+ const output = await evaluateWithEngine(page, input);
1230+ expect(output).toBe('[end]');
1231+ });
1232+ });
1233+
1234+ test.describe('scoped usage', () => {
1235+ test('should work with scoped content (truthy)', async ({ page }) => {
1236+ const input = '{{if yes}}This is the content{{/if}}';
1237+ const output = await evaluateWithEngine(page, input);
1238+ expect(output).toBe('This is the content');
1239+ });
1240+
1241+ test('should work with scoped content (falsy)', async ({ page }) => {
1242+ const input = '{{if::}}This should not show{{/if}}[after]';
1243+ const output = await evaluateWithEngine(page, input);
1244+ expect(output).toBe('[after]');
1245+ });
1246+
1247+ test('should handle macros inside scoped content', async ({ page }) => {
1248+ const input = '{{if yes}}Hello {{user}}!{{/if}}';
1249+ const output = await evaluateWithEngine(page, input);
1250+ expect(output).toBe('Hello User!');
1251+ });
1252+
1253+ test('should handle nested if macros', async ({ page }) => {
1254+ const input = '{{if yes}}outer{{if yes}}inner{{/if}}{{/if}}';
1255+ const output = await evaluateWithEngine(page, input);
1256+ expect(output).toBe('outerinner');
1257+ });
1258+
1259+ test('should handle nested if with outer false', async ({ page }) => {
1260+ const input = '{{if::}}outer{{if yes}}inner{{/if}}{{/if}}[end]';
1261+ const output = await evaluateWithEngine(page, input);
1262+ expect(output).toBe('[end]');
1263+ });
1264+
1265+ test('should handle nested if with inner false', async ({ page }) => {
1266+ const input = '{{if yes}}outer{{if::}}inner{{/if}}end{{/if}}';
1267+ const output = await evaluateWithEngine(page, input);
1268+ expect(output).toBe('outerend');
1269+ });
1270+ });
1271+
1272+ test.describe('with space-separated condition', () => {
1273+ test('should work with space-separated condition (truthy)', async ({ page }) => {
1274+ const input = '{{if something}}content{{/if}}';
1275+ const output = await evaluateWithEngine(page, input);
1276+ expect(output).toBe('content');
1277+ });
1278+
1279+ test('should resolve macro name with space-separated syntax', async ({ page }) => {
1280+ const input = '{{if char}}{{char}} exists{{/if}}';
1281+ const output = await evaluateWithEngine(page, input);
1282+ expect(output).toBe('Character exists');
1283+ });
1284+ });
1285+
1286+ test.describe('with {{else}} branch', () => {
1287+ test('should return then-branch when condition is truthy', async ({ page }) => {
1288+ const input = '{{if yes}}then{{else}}else{{/if}}';
1289+ const output = await evaluateWithEngine(page, input);
1290+ expect(output).toBe('then');
1291+ });
1292+
1293+ test('should return else-branch when condition is falsy', async ({ page }) => {
1294+ const input = '{{if::}}then{{else}}else{{/if}}';
1295+ const output = await evaluateWithEngine(page, input);
1296+ expect(output).toBe('else');
1297+ });
1298+
1299+ test('should return else-branch when condition is "false"', async ({ page }) => {
1300+ const input = '{{if::false}}yes{{else}}no{{/if}}';
1301+ const output = await evaluateWithEngine(page, input);
1302+ expect(output).toBe('no');
1303+ });
1304+
1305+ test('should handle macros in both branches', async ({ page }) => {
1306+ const input = '{{if yes}}Hello {{user}}{{else}}Goodbye {{char}}{{/if}}';
1307+ const output = await evaluateWithEngine(page, input);
1308+ expect(output).toBe('Hello User');
1309+ });
1310+
1311+ test('should handle macros in else branch when falsy', async ({ page }) => {
1312+ const input = '{{if::}}Hello {{user}}{{else}}Goodbye {{char}}{{/if}}';
1313+ const output = await evaluateWithEngine(page, input);
1314+ expect(output).toBe('Goodbye Character');
1315+ });
1316+
1317+ test('should handle nested if-else in then-branch', async ({ page }) => {
1318+ const input = '{{if yes}}outer-then{{if yes}}inner-then{{else}}inner-else{{/if}}{{else}}outer-else{{/if}}';
1319+ const output = await evaluateWithEngine(page, input);
1320+ expect(output).toBe('outer-theninner-then');
1321+ });
1322+
1323+ test('should handle nested if-else in else-branch', async ({ page }) => {
1324+ const input = '{{if::}}outer-then{{else}}outer-else{{if yes}}inner-then{{else}}inner-else{{/if}}{{/if}}';
1325+ const output = await evaluateWithEngine(page, input);
1326+ expect(output).toBe('outer-elseinner-then');
1327+ });
1328+
1329+ test('should handle deeply nested if-else', async ({ page }) => {
1330+ const input = '{{if::}}A{{else}}B{{if::}}C{{else}}D{{/if}}{{/if}}';
1331+ const output = await evaluateWithEngine(page, input);
1332+ expect(output).toBe('BD');
1333+ });
1334+
1335+ test('should return empty else-branch if not provided', async ({ page }) => {
1336+ const input = '{{if::}}content{{/if}}[end]';
1337+ const output = await evaluateWithEngine(page, input);
1338+ expect(output).toBe('[end]');
1339+ });
1340+
1341+ test('should trim whitespace from branches', async ({ page }) => {
1342+ const input = '{{if yes}} then {{else}} else {{/if}}';
1343+ const output = await evaluateWithEngine(page, input);
1344+ expect(output).toBe('then');
1345+ });
1346+
1347+ test('should trim newlines from branches', async ({ page }) => {
1348+ const input = '{{if yes}}\n then\n{{else}}\n else\n{{/if}}';
1349+ const output = await evaluateWithEngine(page, input);
1350+ expect(output).toBe('then');
1351+ });
1352+
1353+ test('should trim else branch when selected', async ({ page }) => {
1354+ const input = '{{if::}}\n then\n{{else}}\n else\n{{/if}}';
1355+ const output = await evaluateWithEngine(page, input);
1356+ expect(output).toBe('else');
1357+ });
1358+
1359+ test('should resolve macro name in condition with else branch', async ({ page }) => {
1360+ const input = '{{if char}}Has char{{else}}No char{{/if}}';
1361+ const output = await evaluateWithEngine(page, input);
1362+ expect(output).toBe('Has char');
1363+ });
1364+
1365+ test('should handle empty macro returning else branch', async ({ page }) => {
1366+ const input = '{{if noop}}Has value{{else}}Empty{{/if}}';
1367+ const output = await evaluateWithEngine(page, input);
1368+ expect(output).toBe('Empty');
1369+ });
1370+ });
1371+
1372+ test.describe('with inverted condition (!)', () => {
1373+ test('should invert truthy condition to falsy', async ({ page }) => {
1374+ const input = '{{if !yes}}shown{{/if}}[end]';
1375+ const output = await evaluateWithEngine(page, input);
1376+ expect(output).toBe('[end]');
1377+ });
1378+
1379+ test('should invert falsy condition to truthy', async ({ page }) => {
1380+ const input = '{{if !false}}shown{{/if}}';
1381+ const output = await evaluateWithEngine(page, input);
1382+ expect(output).toBe('shown');
1383+ });
1384+
1385+ test('should invert empty string to truthy', async ({ page }) => {
1386+ const input = '{{if::!}}not shown{{else}}shown{{/if}}';
1387+ const output = await evaluateWithEngine(page, input);
1388+ // Note: "!" is not empty, so it's truthy - but this tests literal ! as value
1389+ expect(output).toBe('not shown');
1390+ });
1391+
1392+ test('should work with ! prefix and macro name', async ({ page }) => {
1393+ // noop returns empty string, so !noop should be truthy
1394+ const input = '{{if !noop}}No value{{/if}}';
1395+ const output = await evaluateWithEngine(page, input);
1396+ expect(output).toBe('No value');
1397+ });
1398+
1399+ test('should work with ! prefix and truthy macro', async ({ page }) => {
1400+ // char returns "Character", so !char should be falsy
1401+ const input = '{{if !char}}No char{{else}}Has char{{/if}}';
1402+ const output = await evaluateWithEngine(page, input);
1403+ expect(output).toBe('Has char');
1404+ });
1405+
1406+ test('should work with ! prefix and nested macro', async ({ page }) => {
1407+ // Set a variable to empty, then check !{{getvar}}
1408+ const input = '{{setvar::emptyVar::}}{{if !{{getvar::emptyVar}}}}Empty var{{/if}}';
1409+ const output = await evaluateWithEngine(page, input);
1410+ expect(output).toBe('Empty var');
1411+ });
1412+
1413+ test('should NOT invert when ! comes from resolved value', async ({ page }) => {
1414+ // Set a variable starting with !, then check without ! prefix
1415+ // The ! in the value should NOT cause inversion
1416+ const input = '{{setvar::bangVar::!hello}}{{if {{getvar::bangVar}}}}Has value{{else}}No value{{/if}}';
1417+ const output = await evaluateWithEngine(page, input);
1418+ expect(output).toBe('Has value');
1419+ });
1420+
1421+ test('should work with else branch on inverted condition', async ({ page }) => {
1422+ const input = '{{if !yes}}then{{else}}else{{/if}}';
1423+ const output = await evaluateWithEngine(page, input);
1424+ expect(output).toBe('else');
1425+ });
1426+
1427+ test('should work with separator syntax', async ({ page }) => {
1428+ const input = '{{if::!something}}shown{{/if}}[end]';
1429+ const output = await evaluateWithEngine(page, input);
1430+ expect(output).toBe('[end]');
1431+ });
1432+ });
1433+ });
1434+
1435+ test.describe('scoped content auto-trim', () => {
1436+ test('should auto-trim scoped content by default', async ({ page }) => {
1437+ const input = '{{setvar::myvar}}\n content with whitespace \n{{/setvar}}[{{getvar::myvar}}]';
1438+ const output = await evaluateWithEngine(page, input);
1439+ expect(output).toBe('[content with whitespace]');
1440+ });
1441+
1442+ test('should auto-trim leading newlines in scoped content', async ({ page }) => {
1443+ const input = '{{setvar::myvar}}\n\n\ntext{{/setvar}}[{{getvar::myvar}}]';
1444+ const output = await evaluateWithEngine(page, input);
1445+ expect(output).toBe('[text]');
1446+ });
1447+
1448+ test('should auto-trim trailing newlines in scoped content', async ({ page }) => {
1449+ const input = '{{setvar::myvar}}text\n\n\n{{/setvar}}[{{getvar::myvar}}]';
1450+ const output = await evaluateWithEngine(page, input);
1451+ expect(output).toBe('[text]');
1452+ });
1453+
1454+ test('should dedent consistent indentation when auto-trimming', async ({ page }) => {
1455+ // Both lines have 2-space indent, so dedent removes it from both
1456+ const input = '{{setvar::myvar}}\n line1\n line2 \n{{/setvar}}[{{getvar::myvar}}]';
1457+ const output = await evaluateWithEngine(page, input);
1458+ expect(output).toBe('[line1\nline2]');
1459+ });
1460+
1461+ test('should preserve whitespace with # flag', async ({ page }) => {
1462+ const input = '{{#setvar::myvar}}\n content \n{{/setvar}}[{{getvar::myvar}}]';
1463+ const output = await evaluateWithEngine(page, input);
1464+ expect(output).toBe('[\n content \n]');
1465+ });
1466+
1467+ test('should preserve leading newlines with # flag', async ({ page }) => {
1468+ const input = '{{#setvar::myvar}}\n\ntext{{/setvar}}[{{getvar::myvar}}]';
1469+ const output = await evaluateWithEngine(page, input);
1470+ expect(output).toBe('[\n\ntext]');
1471+ });
1472+
1473+ test('should preserve trailing newlines with # flag', async ({ page }) => {
1474+ const input = '{{#setvar::myvar}}text\n\n{{/setvar}}[{{getvar::myvar}}]';
1475+ const output = await evaluateWithEngine(page, input);
1476+ expect(output).toBe('[text\n\n]');
1477+ });
1478+
1479+ test('should work with # flag and nested macros', async ({ page }) => {
1480+ const input = '{{#setvar::myvar}}\n {{char}} \n{{/setvar}}[{{getvar::myvar}}]';
1481+ const output = await evaluateWithEngine(page, input);
1482+ expect(output).toBe('[\n Character \n]');
1483+ });
1484+
1485+ test('should auto-trim with nested macros by default', async ({ page }) => {
1486+ const input = '{{setvar::myvar}}\n {{char}} \n{{/setvar}}[{{getvar::myvar}}]';
1487+ const output = await evaluateWithEngine(page, input);
1488+ expect(output).toBe('[Character]');
1489+ });
1490+
1491+ test('should auto-trim {{if}} scoped content', async ({ page }) => {
1492+ const input = '{{if yes}}\n trimmed \n{{/if}}';
1493+ const output = await evaluateWithEngine(page, input);
1494+ expect(output).toBe('trimmed');
1495+ });
1496+
1497+ test('should preserve {{if}} whitespace with # flag', async ({ page }) => {
1498+ const input = '{{#if yes}}\n preserved \n{{/if}}';
1499+ const output = await evaluateWithEngine(page, input);
1500+ // With # flag, both outer content AND branch trimming is skipped
1501+ expect(output).toBe('\n preserved \n');
1502+ });
1503+
1504+ test('should auto-trim {{reverse}} scoped content', async ({ page }) => {
1505+ const input = '{{reverse}}\n abc \n{{/reverse}}';
1506+ const output = await evaluateWithEngine(page, input);
1507+ expect(output).toBe('cba');
1508+ });
1509+
1510+ test('should preserve {{reverse}} whitespace with # flag', async ({ page }) => {
1511+ const input = '{{#reverse}}\n abc \n{{/reverse}}';
1512+ const output = await evaluateWithEngine(page, input);
1513+ expect(output).toBe('\n cba \n');
1514+ });
1515+
1516+ test('should dedent consistent indentation from multiline content', async ({ page }) => {
1517+ const input = '{{setvar::myvar}}\n # Heading\n Content here\n{{/setvar}}[{{getvar::myvar}}]';
1518+ const output = await evaluateWithEngine(page, input);
1519+ expect(output).toBe('[# Heading\nContent here]');
1520+ });
1521+
1522+ test('should dedent based on first non-empty line indentation', async ({ page }) => {
1523+ const input = '{{setvar::myvar}}\n line1\n line2\n line3\n{{/setvar}}[{{getvar::myvar}}]';
1524+ const output = await evaluateWithEngine(page, input);
1525+ expect(output).toBe('[line1\nline2\nline3]');
1526+ });
1527+
1528+ test('should preserve relative indentation when dedenting', async ({ page }) => {
1529+ const input = '{{setvar::myvar}}\n parent\n child\n sibling\n{{/setvar}}[{{getvar::myvar}}]';
1530+ const output = await evaluateWithEngine(page, input);
1531+ expect(output).toBe('[parent\n child\nsibling]');
1532+ });
1533+
1534+ test('should handle mixed indentation levels correctly', async ({ page }) => {
1535+ const input = '{{setvar::myvar}}\n # Header\n - item1\n - item2\n Paragraph\n{{/setvar}}[{{getvar::myvar}}]';
1536+ const output = await evaluateWithEngine(page, input);
1537+ expect(output).toBe('[# Header\n - item1\n - item2\nParagraph]');
1538+ });
1539+
1540+ test('should dedent {{if}} branches with indentation', async ({ page }) => {
1541+ const input = '{{if yes}}\n # Title\n Body text\n{{/if}}';
1542+ const output = await evaluateWithEngine(page, input);
1543+ expect(output).toBe('# Title\nBody text');
1544+ });
1545+
1546+ test('should dedent {{if}} else branch with indentation', async ({ page }) => {
1547+ const input = '{{if false}}\n Then branch\n{{else}}\n # Else Title\n Else body\n{{/if}}';
1548+ const output = await evaluateWithEngine(page, input);
1549+ expect(output).toBe('# Else Title\nElse body');
1550+ });
1551+
1552+ test('should not dedent when # flag is set', async ({ page }) => {
1553+ const input = '{{#setvar::myvar}}\n # Heading\n Content\n{{/setvar}}[{{getvar::myvar}}]';
1554+ const output = await evaluateWithEngine(page, input);
1555+ expect(output).toBe('[\n # Heading\n Content\n]');
1556+ });
1557+
1558+ test('should handle single line content without dedent issues', async ({ page }) => {
1559+ const input = '{{setvar::myvar}}\n single line\n{{/setvar}}[{{getvar::myvar}}]';
1560+ const output = await evaluateWithEngine(page, input);
1561+ expect(output).toBe('[single line]');
1562+ });
1563+
1564+ test('should handle empty lines in multiline content', async ({ page }) => {
1565+ const input = '{{setvar::myvar}}\n line1\n\n line2\n{{/setvar}}[{{getvar::myvar}}]';
1566+ const output = await evaluateWithEngine(page, input);
1567+ expect(output).toBe('[line1\n\nline2]');
1568+ });
1569+
1570+ test('should dedent based on first non-empty line and preserve relative indentation', async ({ page }) => {
1571+ // First non-empty line has 2-space indent, subsequent lines have varying indentation
1572+ // The 2-space base indent should be removed, preserving relative indentation
1573+ const input = '{{setvar::myvar}}\n First Line\n Second Line, more indented\n Third line\n Fourth line, also more indented\n{{/setvar}}[{{getvar::myvar}}]';
1574+ const output = await evaluateWithEngine(page, input);
1575+ expect(output).toBe('[First Line\n Second Line, more indented\nThird line\n Fourth line, also more indented]');
1576+ });
1577+ });
1578+
1579+ test.describe('Pre/Post Processor Registration', () => {
1580+ test('should run custom pre-processor before macro evaluation', async ({ page }) => {
1581+ const output = await page.evaluate(async () => {
1582+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1583+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1584+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1585+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1586+
1587+ // Add a pre-processor that replaces [[USER]] with {{user}}
1588+ const handler = (text) => text.replace(/\[\[USER\]\]/g, '{{user}}');
1589+ MacroEngine.addPreProcessor(handler, { priority: 100, source: 'test:custom-user-marker' });
1590+
1591+ try {
1592+ const input = 'Hello [[USER]]!';
1593+ const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name1Override: 'TestUser' });
1594+ return MacroEngine.evaluate(input, env);
1595+ } finally {
1596+ MacroEngine.removePreProcessor(handler);
1597+ }
1598+ });
1599+
1600+ expect(output).toBe('Hello TestUser!');
1601+ });
1602+
1603+ test('should run custom post-processor after macro evaluation', async ({ page }) => {
1604+ const output = await page.evaluate(async () => {
1605+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1606+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1607+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1608+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1609+
1610+ // Add a post-processor that wraps output in brackets
1611+ const handler = (text) => `[${text}]`;
1612+ MacroEngine.addPostProcessor(handler, { priority: 100, source: 'test:bracket-wrapper' });
1613+
1614+ try {
1615+ const input = 'Hello {{user}}!';
1616+ const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name1Override: 'TestUser' });
1617+ return MacroEngine.evaluate(input, env);
1618+ } finally {
1619+ MacroEngine.removePostProcessor(handler);
1620+ }
1621+ });
1622+
1623+ expect(output).toBe('[Hello TestUser!]');
1624+ });
1625+
1626+ test('should execute pre-processors in priority order (lower first)', async ({ page }) => {
1627+ const output = await page.evaluate(async () => {
1628+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1629+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1630+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1631+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1632+
1633+ // First handler (priority 200) appends 'B'
1634+ const handlerB = (text) => text + 'B';
1635+ // Second handler (priority 100) appends 'A' - should run first despite being registered second
1636+ const handlerA = (text) => text + 'A';
1637+
1638+ MacroEngine.addPreProcessor(handlerB, { priority: 200, source: 'test:append-b' });
1639+ MacroEngine.addPreProcessor(handlerA, { priority: 100, source: 'test:append-a' });
1640+
1641+ try {
1642+ const input = 'X';
1643+ const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
1644+ return MacroEngine.evaluate(input, env);
1645+ } finally {
1646+ MacroEngine.removePreProcessor(handlerA);
1647+ MacroEngine.removePreProcessor(handlerB);
1648+ }
1649+ });
1650+
1651+ // Priority 100 (A) runs before priority 200 (B), so: X -> XA -> XAB
1652+ expect(output).toBe('XAB');
1653+ });
1654+
1655+ test('should execute post-processors in priority order (lower first)', async ({ page }) => {
1656+ const output = await page.evaluate(async () => {
1657+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1658+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1659+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1660+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1661+
1662+ // First handler (priority 200) wraps with ()
1663+ const handlerParen = (text) => `(${text})`;
1664+ // Second handler (priority 100) wraps with [] - should run first
1665+ const handlerBracket = (text) => `[${text}]`;
1666+
1667+ MacroEngine.addPostProcessor(handlerParen, { priority: 200, source: 'test:wrap-paren' });
1668+ MacroEngine.addPostProcessor(handlerBracket, { priority: 100, source: 'test:wrap-bracket' });
1669+
1670+ try {
1671+ const input = 'X';
1672+ const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
1673+ return MacroEngine.evaluate(input, env);
1674+ } finally {
1675+ MacroEngine.removePostProcessor(handlerBracket);
1676+ MacroEngine.removePostProcessor(handlerParen);
1677+ }
1678+ });
1679+
1680+ // Priority 100 ([]) runs before priority 200 (()), so: X -> [X] -> ([X])
1681+ expect(output).toBe('([X])');
1682+ });
1683+
1684+ test('should successfully remove a registered pre-processor', async ({ page }) => {
1685+ const output = await page.evaluate(async () => {
1686+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1687+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1688+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1689+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1690+
1691+ const handler = (text) => text + '-ADDED';
1692+ MacroEngine.addPreProcessor(handler, { priority: 100, source: 'test:to-remove' });
1693+
1694+ // Remove it immediately
1695+ const removed = MacroEngine.removePreProcessor(handler);
1696+
1697+ const input = 'Test';
1698+ const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
1699+ const result = MacroEngine.evaluate(input, env);
1700+
1701+ return { result, removed };
1702+ });
1703+
1704+ expect(output.removed).toBe(true);
1705+ expect(output.result).toBe('Test'); // No '-ADDED' suffix
1706+ });
1707+
1708+ test('should return false when removing non-existent processor', async ({ page }) => {
1709+ const removed = await page.evaluate(async () => {
1710+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1711+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1712+
1713+ const handler = () => 'never registered';
1714+ return MacroEngine.removePreProcessor(handler);
1715+ });
1716+
1717+ expect(removed).toBe(false);
1718+ });
1719+
1720+ test('should pass env to pre-processor handlers', async ({ page }) => {
1721+ const output = await page.evaluate(async () => {
1722+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1723+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1724+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1725+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1726+
1727+ // Pre-processor that uses env to get the user name
1728+ /** @param {string} text @param {import('../../public/scripts/macros/engine/MacroEnv.types.js').MacroEnv} env */
1729+ const handler = (text, env) => text.replace('__NAME__', env.names.user);
1730+ MacroEngine.addPreProcessor(handler, { priority: 100, source: 'test:env-access' });
1731+
1732+ try {
1733+ const input = 'Hello __NAME__!';
1734+ const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name1Override: 'EnvUser' });
1735+ return MacroEngine.evaluate(input, env);
1736+ } finally {
1737+ MacroEngine.removePreProcessor(handler);
1738+ }
1739+ });
1740+
1741+ expect(output).toBe('Hello EnvUser!');
1742+ });
1743+
1744+ test('should pass env to post-processor handlers', async ({ page }) => {
1745+ const output = await page.evaluate(async () => {
1746+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1747+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1748+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1749+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1750+
1751+ // Post-processor that appends the character name from env
1752+ /** @param {string} text @param {import('../../public/scripts/macros/engine/MacroEnv.types.js').MacroEnv} env */
1753+ const handler = (text, env) => `${text} (by ${env.names.char})`;
1754+ MacroEngine.addPostProcessor(handler, { priority: 100, source: 'test:env-access-post' });
1755+
1756+ try {
1757+ const input = 'Message';
1758+ const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name2Override: 'EnvChar' });
1759+ return MacroEngine.evaluate(input, env);
1760+ } finally {
1761+ MacroEngine.removePostProcessor(handler);
1762+ }
1763+ });
1764+
1765+ expect(output).toBe('Message (by EnvChar)');
1766+ });
1767+ });
6811768});
6821769
6831770/**
tests/frontend/MacroLexer.e2e.js+48 -34
@@ -653,6 +653,35 @@ test.describe('MacroLexer', () => {
653653
654654 expect(tokens).toEqual(expectedTokens);
655655 });
656+ // {{>filtered}}
657+ test('should support > filter flag as separate token', async ({ page }) => {
658+ const input = '{{>filtered}}';
659+ const tokens = await runLexerGetTokens(page, input);
660+
661+ const expectedTokens = [
662+ { type: 'Macro.Start', text: '{{' },
663+ { type: 'Macro.FilterFlag', text: '>' },
664+ { type: 'Macro.Identifier', text: 'filtered' },
665+ { type: 'Macro.End', text: '}}' },
666+ ];
667+
668+ expect(tokens).toEqual(expectedTokens);
669+ });
670+ // {{ ! > user }}
671+ test('should support filter flag combined with other flags', async ({ page }) => {
672+ const input = '{{ ! > user }}';
673+ const tokens = await runLexerGetTokens(page, input);
674+
675+ const expectedTokens = [
676+ { type: 'Macro.Start', text: '{{' },
677+ { type: 'Macro.Flag', text: '!' },
678+ { type: 'Macro.FilterFlag', text: '>' },
679+ { type: 'Macro.Identifier', text: 'user' },
680+ { type: 'Macro.End', text: '}}' },
681+ ];
682+
683+ expect(tokens).toEqual(expectedTokens);
684+ });
656685 // {{ a shaaark }}
657686 test('should not capture single letter as flag, but as macro identifiers', async ({ page }) => {
658687 const input = '{{ a shaaark }}';
@@ -668,42 +697,35 @@ test.describe('MacroLexer', () => {
668697 expect(tokens).toEqual(expectedTokens);
669698 });
670699
671700 test.describe('"Error" Cases (Macro Execution Modifiers)', () => {
672701 // {{ @unknown }}
673702 test('[Error] should not capture unknown special characters as flag', async ({ page }) => {
674703 const input = '{{ @unknown }}';
675704 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
676705
677- const expectedErrors = [
706+ // No errors expected, as lexer should not error out even on invalid macros
678- { message: 'unexpected character: ->@<- at offset: 3, skipped 1 characters.' },
707+ expect(errors).toMatchObject([]);
679- ];
680-
681- expect(errors).toMatchObject(expectedErrors);
682708
683709 const expectedTokens = [
684710 { type: 'Macro.Start', text: '{{' },
685711 // Do not captureBecause '@' asis anything,invalid asin lexer, it'sll a"pop lexerout" errorand be captured as plaintext
686712 { type: 'Macro.IdentifierPlaintext', text: '@unknown }}' },
687- { type: 'Macro.End', text: '}}' },
688713 ];
689714
690715 expect(tokens).toEqual(expectedTokens);
691716 });
692717 // {{ 2 cents }}
693718 test('[Error] should not capture numbers as flag - they are also invalid macro identifiers', async ({ page }) => {
694719 const input = '{{ 2 cents }}';
695720 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
696721
697- const expectedErrors = [
722+ // No errors expected, as lexer should not error out even on invalid macros
698- { message: 'unexpected character: ->2<- at offset: 3, skipped 1 characters.' },
723+ expect(errors).toMatchObject([]);
699- ];
700- expect(errors).toMatchObject(expectedErrors);
701724
702725 const expectedTokens = [
703726 { type: 'Macro.Start', text: '{{' },
704727 // Do not captureBecause '2' asis anything,invalid asin lexer, it'sll a"pop lexerout" errorand be captured as plaintext
705728 { type: 'Macro.IdentifierPlaintext', text: '2 cents }}' },
706- { type: 'Macro.End', text: '}}' },
707729 ];
708730
709731 expect(tokens).toEqual(expectedTokens);
@@ -868,20 +890,16 @@ test.describe('MacroLexer', () => {
868890
869891 test.describe('Error Cases (Macro Output Modifiers)', () => {
870892 // {{|macro}}
871893 test('[Error] should not capture when starting the macro with a pipe', async ({ page }) => {
872894 const input = '{{|macro}}';
873895 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
874896
875- const expectedErrors = [
897+ // No errors expected, as lexer should not error out even on invalid macros
876- { message: 'unexpected character: ->|<- at offset: 2, skipped 1 characters.' },
898+ expect(errors).toMatchObject([]);
877- ];
878-
879- expect(errors).toMatchObject(expectedErrors);
880899
881900 const expectedTokens = [
882901 { type: 'Macro.Start', text: '{{' },
883902 { type: 'Macro.IdentifierPlaintext', text: '|macro}}' },
884- { type: 'Macro.End', text: '}}' },
885903 ];
886904
887905 expect(tokens).toEqual(expectedTokens);
@@ -1052,18 +1070,14 @@ test.describe('MacroLexer', () => {
10521070 const input = 'invalid {{ 000 }} followed by correct {{ macro }}';
10531071 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
10541072
1055- const expectedErrors = [
1073+ // No errors expected, as lexer should not error out even on invalid macros
1056- { message: 'unexpected character: ->0<- at offset: 11, skipped 3 characters.' },
1074+ expect(errors).toMatchObject([]);
1057- ];
1058-
1059- expect(errors).toMatchObject(expectedErrors);
10601075
10611076 const expectedTokens = [
10621077 { type: 'Plaintext', text: 'invalid ' },
10631078 { type: 'Macro.Start', text: '{{' },
10641079 // Do not capture '000' asis anythinginvalid vor the lexer, asso it's ais lexercaptured erroras plaintext
10651080 { type: 'Macro.EndPlaintext', text: '000 }} followed by correct ' },
1066- { type: 'Plaintext', text: ' followed by correct ' },
10671081 { type: 'Macro.Start', text: '{{' },
10681082 { type: 'Macro.Identifier', text: 'macro' },
10691083 { type: 'Macro.End', text: '}}' },
tests/frontend/MacroParser.e2e.js+141 -3
@@ -61,15 +61,15 @@ test.describe('MacroParser', () => {
6161 expect(errors).toMatchObject(expectedErrors);
6262 expect(errors[0].message).toMatch(expectedMessage);
6363 });
6464 // {{§!#&%€blah}}
6565 test('[Error] should throw an error for invalid identifier', async ({ page }) => {
6666 const input = '{{§!#&%€blah}}';
6767 const { macroCst, errors } = await runParserAndGetErrors(page, input);
6868
6969 const expectedErrors = [
7070 { name: 'NoViableAltException' },
7171 ];
7272 const expectedMessage = /Expecting: one of these possible Token sequences:(.*?)\[Macro\.Identifier\](.*?)but found: '!§%€blah}}'/gs;
7373
7474 expect(macroCst).toBeUndefined();
7575 expect(errors).toMatchObject(expectedErrors);
@@ -556,6 +556,144 @@ This is the second line
556556
557557 });
558558 });
559+
560+ test.describe('Macro Flags', () => {
561+ // {{!user}}
562+ test('should parse macro with single flag', async ({ page }) => {
563+ const input = '{{!user}}';
564+ const macroCst = await runParser(page, input);
565+
566+ expect(macroCst).toEqual({
567+ 'Macro.Start': '{{',
568+ 'flags': '!',
569+ 'Macro.identifier': 'user',
570+ 'Macro.End': '}}',
571+ });
572+ });
573+
574+ // {{?delayed}}
575+ test('should parse macro with delayed flag', async ({ page }) => {
576+ const input = '{{?delayed}}';
577+ const macroCst = await runParser(page, input);
578+
579+ expect(macroCst).toEqual({
580+ 'Macro.Start': '{{',
581+ 'flags': '?',
582+ 'Macro.identifier': 'delayed',
583+ 'Macro.End': '}}',
584+ });
585+ });
586+
587+ // {{/closing}}
588+ test('should parse macro with closing block flag', async ({ page }) => {
589+ const input = '{{/closing}}';
590+ const macroCst = await runParser(page, input);
591+
592+ expect(macroCst).toEqual({
593+ 'Macro.Start': '{{',
594+ 'flags': '/',
595+ 'Macro.identifier': 'closing',
596+ 'Macro.End': '}}',
597+ });
598+ });
599+
600+ // {{>filtered}}
601+ test('should parse macro with filter flag', async ({ page }) => {
602+ const input = '{{>filtered}}';
603+ const macroCst = await runParser(page, input);
604+
605+ expect(macroCst).toEqual({
606+ 'Macro.Start': '{{',
607+ 'flags': '>',
608+ 'Macro.identifier': 'filtered',
609+ 'Macro.End': '}}',
610+ });
611+ });
612+
613+ // {{!?user}}
614+ test('should parse macro with multiple flags', async ({ page }) => {
615+ const input = '{{!?user}}';
616+ const macroCst = await runParser(page, input);
617+
618+ expect(macroCst).toEqual({
619+ 'Macro.Start': '{{',
620+ 'flags': ['!', '?'],
621+ 'Macro.identifier': 'user',
622+ 'Macro.End': '}}',
623+ });
624+ });
625+
626+ // {{ ! > macro }}
627+ test('should parse macro with flags and whitespace', async ({ page }) => {
628+ const input = '{{ ! > macro }}';
629+ const macroCst = await runParser(page, input);
630+
631+ expect(macroCst).toEqual({
632+ 'Macro.Start': '{{',
633+ 'flags': ['!', '>'],
634+ 'Macro.identifier': 'macro',
635+ 'Macro.End': '}}',
636+ });
637+ });
638+
639+ // {{#legacy}}
640+ test('should parse macro with legacy hash flag', async ({ page }) => {
641+ const input = '{{#legacy}}';
642+ const macroCst = await runParser(page, input);
643+
644+ expect(macroCst).toEqual({
645+ 'Macro.Start': '{{',
646+ 'flags': '#',
647+ 'Macro.identifier': 'legacy',
648+ 'Macro.End': '}}',
649+ });
650+ });
651+
652+ // {{!setvar::value::test}}
653+ test('should parse macro with flag and arguments', async ({ page }) => {
654+ const input = '{{!setvar::value::test}}';
655+ const macroCst = await runParser(page, input, {
656+ flattenKeys: ['arguments.argument'],
657+ });
658+
659+ expect(macroCst).toEqual({
660+ 'Macro.Start': '{{',
661+ 'flags': '!',
662+ 'Macro.identifier': 'setvar',
663+ 'arguments': {
664+ 'separator': '::',
665+ 'argument': ['value', 'test'],
666+ },
667+ 'Macro.End': '}}',
668+ });
669+ });
670+
671+ // {{.myvar}} - variable shorthand
672+ test('should parse macro with variable dot shorthand flag', async ({ page }) => {
673+ const input = '{{.myvar}}';
674+ const macroCst = await runParser(page, input);
675+
676+ expect(macroCst).toEqual({
677+ 'Macro.Start': '{{',
678+ 'flags': '.',
679+ 'Macro.identifier': 'myvar',
680+ 'Macro.End': '}}',
681+ });
682+ });
683+
684+ // {{$myvar}} - variable shorthand
685+ test('should parse macro with variable dollar shorthand flag', async ({ page }) => {
686+ const input = '{{$myvar}}';
687+ const macroCst = await runParser(page, input);
688+
689+ expect(macroCst).toEqual({
690+ 'Macro.Start': '{{',
691+ 'flags': '$',
692+ 'Macro.identifier': 'myvar',
693+ 'Macro.End': '}}',
694+ });
695+ });
696+ });
559697});
560698
561699/**
tests/frontend/MacroRegistry.e2e.js+270 -67
@@ -44,110 +44,313 @@ test.describe('MacroRegistry', () => {
4444
4545 test.describe('reject', () => {
4646 test('should reject invalid macro name', async ({ page }) => {
47- await expect(page.evaluate(async () => {
47+ const result = await registerMacroAndCaptureErrors(page, {
48- /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
48+ macroName: ' ',
49- const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
49+ options: {},
50- // Empty name
51- MacroRegistry.registerMacro(' ', {
52- handler: () => '',
5350 });
54- })).rejects.toThrow(/Macro name must be a non-empty string/);
51+
52+ expect(result.registered).toBeNull();
53+ expect(result.errors.length).toBeGreaterThan(0);
54+
55+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
56+ expect(registrationError).toBeTruthy();
57+ expect(registrationError?.text).toContain('Failed to register macro ""');
58+ expect(registrationError?.errorMessage).toContain('Must start with a letter, followed by alphanumeric characters or hyphens.');
5559 });
5660
5761 test('should reject invalid options object', async ({ page }) => {
58- await expect(page.evaluate(async () => {
62+ const result = await registerMacroAndCaptureErrors(page, {
59- /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
63+ macroName: 'invalid-options',
60- const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
64+ options: null,
61- // Options must be object
65+ });
62- // @ts-expect-error intentionally wrong
66+
63- MacroRegistry.registerMacro('invalid-options', null);
67+ expect(result.registered).toBeNull();
64- })).rejects.toThrow(/options must be a non-null object/);
68+ expect(result.errors.length).toBeGreaterThan(0);
69+
70+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
71+ expect(registrationError).toBeTruthy();
72+ expect(registrationError?.text).toContain('Failed to register macro "invalid-options"');
73+ expect(registrationError?.errorMessage).toContain('options must be a non-null object');
6574 });
6675
6776 test('should reject invalid handler', async ({ page }) => {
68- await expect(page.evaluate(async () => {
77+ const result = await registerMacroAndCaptureErrors(page, {
69- /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
78+ macroName: 'no-handler',
70- const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
79+ options: { handler: null },
71- // Handler must be function
80+ });
72- // @ts-expect-error intentionally wrong
81+
73- MacroRegistry.registerMacro('no-handler', { handler: null });
82+ expect(result.registered).toBeNull();
74- })).rejects.toThrow(/options\.handler must be a function/);
83+ expect(result.errors.length).toBeGreaterThan(0);
84+
85+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
86+ expect(registrationError).toBeTruthy();
87+ expect(registrationError?.text).toContain('Failed to register macro "no-handler"');
88+ expect(registrationError?.errorMessage).toContain('options.handler must be a function');
7589 });
7690
7791 test('should reject invalid unnamedArgs', async ({ page }) => {
78- await expect(page.evaluate(async () => {
92+ const result = await registerMacroAndCaptureErrors(page, {
79- /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
93+ macroName: 'bad-required',
80- const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
94+ options: {
81- // unnamedArgs must be non-negative integer
82- MacroRegistry.registerMacro('bad-required', {
83- // @ts-expect-error intentionally wrong
8495 unnamedArgs: -1,
85- handler: () => '',
96+ },
8697 });
87- })).rejects.toThrow(/options\.unnamedArgs must be a non-negative integer/);
98+
99+ expect(result.registered).toBeNull();
100+ expect(result.errors.length).toBeGreaterThan(0);
101+
102+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
103+ expect(registrationError).toBeTruthy();
104+ expect(registrationError?.text).toContain('Failed to register macro "bad-required"');
105+ expect(registrationError?.errorMessage).toContain('options.unnamedArgs must be a non-negative integer');
88106 });
89107
90108 test('should reject invalid strictArgs', async ({ page }) => {
91- await expect(page.evaluate(async () => {
109+ const result = await registerMacroAndCaptureErrors(page, {
92- /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
110+ macroName: 'bad-strict',
93- const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
111+ options: {
94- // strictArgs must be boolean
95- MacroRegistry.registerMacro('bad-strict', {
96- // @ts-expect-error intentionally wrong
97112 strictArgs: 'yes',
98- handler: () => '',
113+ },
99114 });
100- })).rejects.toThrow(/options\.strictArgs must be a boolean/);
115+
116+ expect(result.registered).toBeNull();
117+ expect(result.errors.length).toBeGreaterThan(0);
118+
119+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
120+ expect(registrationError).toBeTruthy();
121+ expect(registrationError?.text).toContain('Failed to register macro "bad-strict"');
122+ expect(registrationError?.errorMessage).toContain('options.strictArgs must be a boolean');
101123 });
102124
103125 test('should reject invalid list configuration', async ({ page }) => {
104- await expect(page.evaluate(async () => {
126+ const result = await registerMacroAndCaptureErrors(page, {
105- /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
127+ macroName: 'bad-list-type',
106- const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
128+ options: {
107- // list must be boolean or object
108- MacroRegistry.registerMacro('bad-list-type', {
109- // @ts-expect-error intentionally wrong
110129 list: 'invalid',
111- handler: () => '',
130+ },
112131 });
113- })).rejects.toThrow(/options\.list must be a boolean or an object/);
132+
133+ expect(result.registered).toBeNull();
134+ expect(result.errors.length).toBeGreaterThan(0);
135+
136+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
137+ expect(registrationError).toBeTruthy();
138+ expect(registrationError?.text).toContain('Failed to register macro "bad-list-type"');
139+ expect(registrationError?.errorMessage).toContain('options.list must be a boolean');
114140 });
115141
116142 test('should reject invalid list.min', async ({ page }) => {
117- await expect(page.evaluate(async () => {
143+ const result = await registerMacroAndCaptureErrors(page, {
118- /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
144+ macroName: 'bad-list-min',
119- const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
145+ options: {
120- // list.min must be non-negative
121- MacroRegistry.registerMacro('bad-list-min', {
122146 list: { min: -1 },
123- handler: () => '',
147+ },
124148 });
125- })).rejects.toThrow(/options\.list\.min must be a non-negative integer/);
149+
150+ expect(result.registered).toBeNull();
151+ expect(result.errors.length).toBeGreaterThan(0);
152+
153+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
154+ expect(registrationError).toBeTruthy();
155+ expect(registrationError?.text).toContain('Failed to register macro "bad-list-min"');
156+ expect(registrationError?.errorMessage).toContain('options.list.min must be a non-negative integer');
126157 });
127158
128159 test('should reject invalid list.max', async ({ page }) => {
129- await expect(page.evaluate(async () => {
160+ const result = await registerMacroAndCaptureErrors(page, {
130- /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
161+ macroName: 'bad-list-max',
131- const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
162+ options: {
132- // list.max must be >= min
133- MacroRegistry.registerMacro('bad-list-max', {
134163 list: { min: 2, max: 1 },
135- handler: () => '',
164+ },
136165 });
137- })).rejects.toThrow(/options\.list\.max must be greater than or equal to options\.list\.min/);
166+
167+ expect(result.registered).toBeNull();
168+ expect(result.errors.length).toBeGreaterThan(0);
169+
170+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
171+ expect(registrationError).toBeTruthy();
172+ expect(registrationError?.text).toContain('Failed to register macro "bad-list-max"');
173+ expect(registrationError?.errorMessage).toContain('options.list.max must be greater than or equal to options.list.min');
138174 });
139175
140176 test('should reject invalid description', async ({ page }) => {
141- await expect(page.evaluate(async () => {
177+ const result = await registerMacroAndCaptureErrors(page, {
142- /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
178+ macroName: 'bad-desc',
143- const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
179+ options: {
144- // description must be string
145- MacroRegistry.registerMacro('bad-desc', {
146- // @ts-expect-error intentionally wrong
147180 description: 123,
148- handler: () => '',
181+ },
182+ });
183+
184+ expect(result.registered).toBeNull();
185+ expect(result.errors.length).toBeGreaterThan(0);
186+
187+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
188+ expect(registrationError).toBeTruthy();
189+ expect(registrationError?.text).toContain('Failed to register macro "bad-desc"');
190+ expect(registrationError?.errorMessage).toContain('options.description must be a string');
191+ });
192+ });
193+
194+ test.describe('identifier validation', () => {
195+ test('should accept valid identifier with letters only', async ({ page }) => {
196+ const result = await registerMacroAndCaptureErrors(page, {
197+ macroName: 'validMacro',
198+ options: {},
199+ });
200+ expect(result.registered).not.toBeNull();
201+ expect(result.errors.length).toBe(0);
202+ });
203+
204+ test('should accept valid identifier with hyphens', async ({ page }) => {
205+ const result = await registerMacroAndCaptureErrors(page, {
206+ macroName: 'my-macro-name',
207+ options: {},
208+ });
209+ expect(result.registered).not.toBeNull();
210+ expect(result.errors.length).toBe(0);
211+ });
212+
213+ test('should accept valid identifier with underscores', async ({ page }) => {
214+ const result = await registerMacroAndCaptureErrors(page, {
215+ macroName: 'my_macro_name',
216+ options: {},
217+ });
218+ expect(result.registered).not.toBeNull();
219+ expect(result.errors.length).toBe(0);
149220 });
150- })).rejects.toThrow(/options\.description must be a string/);
221+
222+ test('should accept valid identifier with digits after first char', async ({ page }) => {
223+ const result = await registerMacroAndCaptureErrors(page, {
224+ macroName: 'macro123',
225+ options: {},
151226 });
227+ expect(result.registered).not.toBeNull();
228+ expect(result.errors.length).toBe(0);
229+ });
230+
231+ test('should reject identifier starting with digit', async ({ page }) => {
232+ const result = await registerMacroAndCaptureErrors(page, {
233+ macroName: '123macro',
234+ options: {},
235+ });
236+ expect(result.registered).toBeNull();
237+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
238+ expect(registrationError?.errorMessage).toContain('is invalid');
239+ });
240+
241+ test('should reject identifier starting with hyphen', async ({ page }) => {
242+ const result = await registerMacroAndCaptureErrors(page, {
243+ macroName: '-macro',
244+ options: {},
245+ });
246+ expect(result.registered).toBeNull();
247+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
248+ expect(registrationError?.errorMessage).toContain('is invalid');
249+ });
250+
251+ test('should reject identifier with special characters', async ({ page }) => {
252+ const result = await registerMacroAndCaptureErrors(page, {
253+ macroName: 'macro@name',
254+ options: {},
255+ });
256+ expect(result.registered).toBeNull();
257+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
258+ expect(registrationError?.errorMessage).toContain('is invalid');
259+ });
260+
261+ test('should reject identifier with spaces', async ({ page }) => {
262+ const result = await registerMacroAndCaptureErrors(page, {
263+ macroName: 'macro name',
264+ options: {},
152265 });
266+ expect(result.registered).toBeNull();
267+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
268+ expect(registrationError?.errorMessage).toContain('is invalid');
153269 });
270+
271+ test('should accept valid alias identifier', async ({ page }) => {
272+ const result = await registerMacroAndCaptureErrors(page, {
273+ macroName: 'primaryMacro',
274+ options: {
275+ aliases: [{ alias: 'valid-alias_123' }],
276+ },
277+ });
278+ expect(result.registered).not.toBeNull();
279+ expect(result.errors.length).toBe(0);
280+ });
281+
282+ test('should reject invalid alias identifier', async ({ page }) => {
283+ const result = await registerMacroAndCaptureErrors(page, {
284+ macroName: 'primaryMacro2',
285+ options: {
286+ aliases: [{ alias: '123-invalid' }],
287+ },
288+ });
289+ expect(result.registered).toBeNull();
290+ const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
291+ expect(registrationError?.errorMessage).toContain('is invalid');
292+ });
293+ });
294+});
295+
296+/**
297+ * @typedef {Object} CapturedConsoleError
298+ * @property {string} text
299+ * @property {string|null} errorMessage
300+ */
301+
302+/**
303+ * @param {import('@playwright/test').Page} page
304+ * @param {{ macroName: string, options: import('../../public/scripts/macros/engine/MacroRegistry.js').MacroDefinitionOptions|null }} params
305+ * @returns {Promise<{ registered: unknown, errors: CapturedConsoleError[] }>}
306+ */
307+async function registerMacroAndCaptureErrors(page, { macroName, options }) {
308+ const result = await page.evaluate(async ({ macroName, options }) => {
309+ /** @type {CapturedConsoleError[]} */
310+ const errors = [];
311+ const originalError = console.error;
312+
313+ console.error = (...args) => {
314+ const text = args
315+ .map(a => (typeof a === 'string' ? a : (a instanceof Error ? `Error: ${a.message}` : '')))
316+ .filter(Boolean)
317+ .join(' ');
318+
319+ /** @type {string|null} */
320+ let errorMessage = null;
321+ for (const a of args) {
322+ if (a instanceof Error) {
323+ errorMessage ??= a.message;
324+ continue;
325+ }
326+ if (a && typeof a === 'object' && 'error' in a && a.error instanceof Error) {
327+ errorMessage ??= a.error.message;
328+ }
329+ }
330+
331+ errors.push({ text, errorMessage });
332+ };
333+
334+ try {
335+ /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
336+ const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
337+
338+ /** @type {any} */
339+ let resolvedOptions = options;
340+ if (resolvedOptions && typeof resolvedOptions === 'object' && !('handler' in resolvedOptions)) {
341+ resolvedOptions = {
342+ ...resolvedOptions,
343+ handler: () => '',
344+ };
345+ }
346+
347+ // Registering an invalid macro does not throw. It returns null and logs an error.
348+ const registered = MacroRegistry.registerMacro(macroName, resolvedOptions);
349+ return { registered, errors };
350+ } finally {
351+ console.error = originalError;
352+ }
353+ }, { macroName, options });
354+
355+ return result;
356+}
tests/frontend/MacroStoryString.e2e.js+82 -0
@@ -0,0 +1,82 @@
1+import fs from 'node:fs';
2+import path from 'node:path';
3+
4+import { test, expect } from '@playwright/test';
5+import { testSetup } from './frontent-test-utils.js';
6+import { serverDirectory } from '../../src/server-directory.js';
7+
8+test.describe('MacroStoryString', () => {
9+ test.beforeEach(testSetup.awaitST);
10+
11+ /** @type {any[]} */
12+ const defaultContextPresets = [];
13+
14+ test.beforeAll(() => {
15+ const contextPresetsPath = path.join(serverDirectory, 'default', 'content', 'presets', 'context');
16+ const files = fs.readdirSync(contextPresetsPath).filter(f => path.extname(f).toLowerCase() === '.json');
17+ for (const file of files) {
18+ const fullPath = path.join(contextPresetsPath, file);
19+ const fileContent = fs.readFileSync(fullPath, 'utf-8');
20+ const preset = JSON.parse(fileContent);
21+ defaultContextPresets.push(preset);
22+ }
23+ });
24+
25+ test('should produce equivalent story strings with new macro engine', async ({ page }) => {
26+ const output = await page.evaluate(async ([defaultContextPresets]) => {
27+ const { substituteParams, extension_prompt_types } = await import('./script.js');
28+ const { power_user, renderStoryString } = await import('./scripts/power-user.js');
29+
30+ power_user.experimental_macro_engine = true;
31+
32+ const context = {
33+ description: 'character description',
34+ personality: 'character personality',
35+ persona: 'persona details',
36+ scenario: 'scenario setup',
37+ system: 'system instructions',
38+ char: 'character name',
39+ user: 'user name',
40+ wiBefore: 'world info before',
41+ wiAfter: 'world info after',
42+ loreBefore: 'lore before',
43+ loreAfter: 'lore after',
44+ anchorBefore: 'before anchor text',
45+ anchorAfter: 'after anchor text',
46+ mesExamples: 'example messages',
47+ mesExamplesRaw: 'raw example messages',
48+ };
49+
50+ const customInstructSettings = {
51+ enabled: false,
52+ };
53+
54+ const customContextSettings = {
55+ story_string_position: extension_prompt_types.IN_PROMPT,
56+ };
57+
58+ const result = [];
59+
60+ function getMacroStoryString(templateString) {
61+ let output = substituteParams(templateString, { name1Override: context.user, name2Override: context.char, replaceCharacterCard: true, dynamicMacros: context });
62+ output = output.replace(/^\n+/, '');
63+ if (output.length > 0 && !output.endsWith('\n')) {
64+ output += '\n';
65+ }
66+ return output;
67+ }
68+
69+ for (const template of defaultContextPresets) {
70+ const classicStoryString = renderStoryString(context, { customStoryString: template.story_string, customContextSettings, customInstructSettings });
71+ const macroStoryString = getMacroStoryString(template.story_string);
72+ result.push({ name: template.name, classicStoryString, macroStoryString });
73+ }
74+
75+ return result;
76+ }, [defaultContextPresets]);
77+
78+ for (const { classicStoryString, macroStoryString, name } of output) {
79+ expect(macroStoryString, `Mismatch in template: ${name}`).toBe(classicStoryString);
80+ }
81+ });
82+});