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, +3667 -214Ignore whitespace
public/css/macros.css+61 -0
@@ -465,6 +465,67 @@
465 color: #F89406;465 color: #F89406;
466}466}
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
468/* Current argument hint banner in details */529/* Current argument hint banner in details */
469.macro-ac-arg-hint {530.macro-ac-arg-hint {
470 display: flex;531 display: flex;
public/scripts/autocomplete/AutoComplete.js+16 -4
@@ -311,8 +311,8 @@ export class AutoComplete {
311 this.name = this.parserResult.name.toLowerCase() ?? '';311 this.name = this.parserResult.name.toLowerCase() ?? '';
312312
313 const isCursorInNamePart = this.textarea.selectionStart >= this.parserResult.start && this.textarea.selectionStart <= this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0);313 const isCursorInNamePart = this.textarea.selectionStart >= this.parserResult.start && this.textarea.selectionStart <= this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0);
314 if (isForced || isInput) {314 if (isForced || isInput || isSelect) {
315 // if forced (ctrl+space) or user input...315 // if forced (ctrl+space) or user input or just selected an option...
316 if (isCursorInNamePart) {316 if (isCursorInNamePart) {
317 // ...and cursor is somewhere in the name part (including right behind the final char)317 // ...and cursor is somewhere in the name part (including right behind the final char)
318 // -> show autocomplete for the (partial if cursor in the middle) name318 // -> show autocomplete for the (partial if cursor in the middle) name
@@ -393,8 +393,20 @@ export class AutoComplete {
393 this.updateName(option);393 this.updateName(option);
394 return option;394 return option;
395 })395 })
396 // sort by fuzzy score or alphabetical396 // 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 {
24 this.start = start;24 this.start = start;
25 this.optionList = optionList;25 this.optionList = optionList;
26 this.canBeQuoted = canBeQuoted;26 this.canBeQuoted = canBeQuoted;
27 this.noMatchText = makeNoMatchText ?? this.makeNoMatchText;27 if (makeNoMatchText) this.makeNoMatchText = makeNoMatchText;
28 this.noOptionstext = makeNoOptionsText ?? this.makeNoOptionsText;28 if (makeNoOptionsText) this.makeNoOptionsText = makeNoOptionsText;
29 }29 }
30}30}
public/scripts/autocomplete/AutoCompleteOption.js+6 -0
@@ -13,6 +13,12 @@ export class AutoCompleteOption {
13 /** @type {(input:string)=>boolean} */ matchProvider;13 /** @type {(input:string)=>boolean} */ matchProvider;
14 /** @type {(input:string)=>string} */ valueProvider;14 /** @type {(input:string)=>string} */ valueProvider;
15 /** @type {boolean} */ makeSelectable = false;15 /** @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
18 /**24 /**
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+498 -29
@@ -11,6 +11,7 @@ import {
11 renderMacroDetails,11 renderMacroDetails,
12} from '../macros/MacroBrowser.js';12} from '../macros/MacroBrowser.js';
13import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';13import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
14import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
1415
15/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */16/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
1617
@@ -19,9 +20,27 @@ import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js'
19 * @typedef {Object} MacroAutoCompleteContext20 * @typedef {Object} MacroAutoCompleteContext
20 * @property {string} fullText - The full macro text being typed (without {{ }}).21 * @property {string} fullText - The full macro text being typed (without {{ }}).
21 * @property {number} cursorOffset - Cursor position within the macro text.22 * @property {number} cursorOffset - Cursor position within the macro text.
23 * @property {string} paddingBefore - Padding before the macro identifier/flags.
22 * @property {string} identifier - The macro identifier (name).24 * @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).
23 * @property {string[]} args - Array of arguments typed so far.29 * @property {string[]} args - Array of arguments typed so far.
24 * @property {number} currentArgIndex - Index of the argument being typed (-1 if on identifier).30 * @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.
25 */44 */
2645
27export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {46export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
@@ -31,17 +50,53 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
31 /** @type {MacroAutoCompleteContext|null} */50 /** @type {MacroAutoCompleteContext|null} */
32 #context = null;51 #context = null;
3352
53 /** @type {boolean} */
54 #noBraces = false;
55
56 /** @type {string} */
57 #paddingAfter = '';
58
34 /**59 /**
35 * @param {MacroDefinition} macro - The macro definition from MacroRegistry.60 * @param {MacroDefinition} macro - The macro definition from MacroRegistry.
36 * @param {MacroAutoCompleteContext} [context] - Optional context for argument hints.61 * @param {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} [contextOrOptions] - Context for argument hints, or options object.
37 */62 */
38 constructor(macro, context = null) {63 constructor(macro, contextOrOptions = null) {
39 // Use the macro name as the autocomplete key64 // Use the macro name as the autocomplete key
40 super(macro.name, enumIcons.macro);65 super(macro.name, enumIcons.macro);
41 this.#macro = macro;66 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
43 // nameOffset = 2 to skip the {{ prefix in the display (formatMacroSignature includes braces)88 // 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 }
45 }100 }
46101
47 /** @returns {MacroDefinition} */102 /** @returns {MacroDefinition} */
@@ -74,8 +129,9 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
74 const nameEl = document.createElement('span');129 const nameEl = document.createElement('span');
75 nameEl.classList.add('name', 'monospace');130 nameEl.classList.add('name', 'monospace');
76131
77 // Build signature with individual character spans (includes {{ }})132 // Build signature with individual character spans
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);
79 for (const char of sigText) {135 for (const char of sigText) {
80 const span = document.createElement('span');136 const span = document.createElement('span');
81 span.textContent = char;137 span.textContent = char;
@@ -121,17 +177,31 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
121 renderDetails() {177 renderDetails() {
122 const frag = document.createDocumentFragment();178 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
124 // Determine current argument index for highlighting193 // Determine current argument index for highlighting
125 const currentArgIndex = this.#context?.currentArgIndex ?? -1;194 const currentArgIndex = this.#context?.currentArgIndex ?? -1;
126195
127 // Render argument hint banner if we're typing an argument196 // Render argument hint banner if we're typing an argument (and no warning)
128 if (currentArgIndex >= 0) {197 if (!warning && currentArgIndex >= 0) {
129 const hint = this.#renderArgumentHint();198 const hint = this.#renderArgumentHint();
130 if (hint) frag.append(hint);199 if (hint) frag.append(hint);
131 }200 }
132201
133 // Reuse MacroBrowser's renderMacroDetails with options202 // 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
136 // Add class for autocomplete-specific styling overrides206 // Add class for autocomplete-specific styling overrides
137 details.classList.add('macro-ac-details');207 details.classList.add('macro-ac-details');
@@ -141,6 +211,85 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
141 }211 }
142212
143 /**213 /**
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 /**
144 * Renders the current argument hint banner.293 * Renders the current argument hint banner.
145 * @returns {HTMLElement|null}294 * @returns {HTMLElement|null}
146 */295 */
@@ -210,51 +359,371 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
210}359}
211360
212/**361/**
362 * Autocomplete option for macro execution flags.
363 * Shows flag symbol, name, and description.
364 * Uses default AutoCompleteOption rendering for consistent styling.
365 */
366export 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 */
451export 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/**
213 * Parses the macro text to determine current argument context.558 * 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 ".
215 * @param {number} cursorOffset - Cursor position within macroText.562 * @param {number} cursorOffset - Cursor position within macroText.
216 * @returns {MacroAutoCompleteContext}563 * @returns {MacroAutoCompleteContext}
217 */564 */
218export function parseMacroContext(macroText, cursorOffset) {565export function parseMacroContext(macroText, cursorOffset) {
219 const parts = [];
220 let currentPart = '';
221 let partStart = 0;
222 let i = 0;566 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)
224 while (i < macroText.length) {578 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 });
227 currentPart = '';624 currentPart = '';
228 i += 2;625 j += 2;
229 partStart = i;626 partStart = i + j;
230 } else {627 } else {
231 currentPart += macroText[i];628 currentPart += remainingText[j];
232 i++;629 j++;
233 }630 }
234 }631 }
235 // Push the last part632 // Push the last part
236 parts.push({ text: currentPart, start: partStart, end: macroText.length });633 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
238 // Determine which part the cursor is in678 // Determine which part the cursor is in
239 let currentArgIndex = -1;679 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 identifier683 // 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;
689 }
245 }690 }
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;
246 }699 }
247700
248 // If cursor is after all parts (at the end), we're in the last arg701 const leftPadding = macroText.match(/^\s+/)?.[0] ?? '';
249 if (currentArgIndex === -1 && cursorOffset >= parts[parts.length - 1].end) {702
250 currentArgIndex = parts.length - 1;703 // Clean identifier: strip trailing colons (for partial :: typing)
704 let cleanIdentifier = identifierOnly.replace(/:+$/, '');
705
706 // Build args array - include space-separated arg if present
707 // Trim args like the macro engine does
708 let args = parts.slice(1).map(p => p.text.trim());
709 if (spaceArgText.length > 0) {
710 args = [spaceArgText, ...args];
251 }711 }
252712
253 return {713 return {
254 fullText: macroText,714 fullText: macroText,
255 cursorOffset,715 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,
258 currentArgIndex,723 currentArgIndex,
724 isTypingSeparator,
725 hasSpaceAfterIdentifier,
726 hasSpaceArgContent: spaceArgText.length > 0,
727 separatorCount: separatorPositions.length,
259 };728 };
260}729}
public/scripts/instruct-mode.js+1 -1
@@ -10,7 +10,7 @@ import {
10import { onlyUnique, regexFromString, resetScrollHeight } from './utils.js';10import { onlyUnique, regexFromString, resetScrollHeight } from './utils.js';
1111
12/**12/**
13 * @type {any[]} Instruct mode presets.13 * @type {InstructSettings[]} Instruct mode presets.
14 */14 */
15export let instruct_presets = [];15export let instruct_presets = [];
1616
public/scripts/macros/definitions/core-macros.js+103 -4
@@ -1,9 +1,21 @@
1import { seedrandom, droll } from '../../../lib.js';1import { seedrandom, droll } from '../../../lib.js';
2import { chat_metadata, main_api, getMaxContextSize, extension_prompts, getCurrentChatId } from '../../../script.js';2import { chat_metadata, main_api, getMaxContextSize, extension_prompts, getCurrentChatId } from '../../../script.js';
3import { getStringHash } from '../../utils.js';3import { getStringHash, isFalseBoolean } from '../../utils.js';
4import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js';4import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js';
5import { inject_ids } from '../../constants.js';5import { inject_ids } from '../../constants.js';
6import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';6import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
7import { 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 */
18export const ELSE_MARKER = '\u0000\u001FELSE\u001F\u0000';
719
8/**20/**
9 * Registers SillyTavern's core built-in macros in the MacroRegistry.21 * Registers SillyTavern's core built-in macros in the MacroRegistry.
@@ -59,11 +71,98 @@ export function registerCoreMacros() {
59 });71 });
6072
61 // {{trim}} -> macro will currently replace itself with itself. Trimming is handled in post-processing.73 // {{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)
62 MacroRegistry.registerMacro('trim', {75 MacroRegistry.registerMacro('trim', {
63 category: MacroCategory.UTILITY,76 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 ],
65 returns: '',85 returns: '',
66 handler: () => '{{trim}}',86 handler: ({ unnamedArgs: [content], isScoped }) => {
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,
67 });166 });
68167
69 // {{input}} -> current textarea content168 // {{input}} -> current textarea content
@@ -101,7 +200,7 @@ export function registerCoreMacros() {
101200
102 // Comment macro: {{// ...}} -> '' (consumes any arguments)201 // Comment macro: {{// ...}} -> '' (consumes any arguments)
103 MacroRegistry.registerMacro('//', {202 MacroRegistry.registerMacro('//', {
104 aliases: [{ alias: 'comment' }],203 aliases: [{ alias: 'comment', visible: false }],
105 category: MacroCategory.UTILITY,204 category: MacroCategory.UTILITY,
106 list: true, // We consume any arguments as if this is a list, but we'll ignore them in the handler anyway205 list: true, // We consume any arguments as if this is a list, but we'll ignore them in the handler anyway
107 strictArgs: false, // and we also always remove it, even if the parsing might say it's invalid206 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 @@
1/** @typedef {import('chevrotain').CstNode} CstNode */1/** @typedef {import('chevrotain').CstNode} CstNode */
2/** @typedef {import('chevrotain').IToken} IToken */2/** @typedef {import('chevrotain').IToken} IToken */
3/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */3/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
4/** @typedef {import('./MacroFlags.js').MacroFlags} MacroFlags */
5
6import { parseFlags, createEmptyFlags, MacroFlagType } from './MacroFlags.js';
7import { MacroParser } from './MacroParser.js';
8import { MacroRegistry } from './MacroRegistry.js';
49
5/**10/**
6 * @typedef {Object} MacroCall11 * @typedef {Object} MacroCall
7 * @property {string} name12 * @property {string} name
8 * @property {string[]} args13 * @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).
9 * @property {MacroEnv} env16 * @property {MacroEnv} env
10 * @property {string} rawInner17 * @property {string} rawInner
11 * @property {string} rawWithBraces18 * @property {string} rawWithBraces
19 * @property {string[]} rawArgs
12 * @property {{ startOffset: number, endOffset: number }} range20 * @property {{ startOffset: number, endOffset: number }} range
13 * @property {CstNode} cstNode21 * @property {CstNode} cstNode
14 */22 */
@@ -18,6 +26,7 @@
18 * @property {string} text26 * @property {string} text
19 * @property {MacroEnv} env27 * @property {MacroEnv} env
20 * @property {(call: MacroCall) => string} resolveMacro28 * @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.
21 */30 */
2231
23/**32/**
@@ -47,7 +56,7 @@ class MacroCstWalker {
47 * @returns {string}56 * @returns {string}
48 */57 */
49 evaluateDocument(options) {58 evaluateDocument(options) {
50 const { text, cst, env, resolveMacro } = options;59 const { text, cst, env, resolveMacro, trimContent } = options;
5160
52 if (typeof text !== 'string') {61 if (typeof text !== 'string') {
53 throw new Error('MacroCstWalker.evaluateDocument: text must be a string');62 throw new Error('MacroCstWalker.evaluateDocument: text must be a string');
@@ -58,10 +67,16 @@ class MacroCstWalker {
58 if (typeof resolveMacro !== 'function') {67 if (typeof resolveMacro !== 'function') {
59 throw new Error('MacroCstWalker.evaluateDocument: resolveMacro must be a function');68 throw new Error('MacroCstWalker.evaluateDocument: resolveMacro must be a function');
60 }69 }
70 if (typeof trimContent !== 'function') {
71 throw new Error('MacroCstWalker.evaluateDocument: trimContent must be a function');
72 }
6173
62 /** @type {EvaluationContext} */74 /** @type {EvaluationContext} */
63 const context = { text, env, resolveMacro };75 const context = { text, env, resolveMacro, trimContent };
64 const items = this.#collectDocumentItems(cst);76 let items = this.#collectDocumentItems(cst);
77
78 // Process scoped macros: find opening/closing pairs and merge them
79 items = this.#processScopedMacros(items, text);
6580
66 if (items.length === 0) {81 if (items.length === 0) {
67 return text;82 return text;
@@ -79,11 +94,20 @@ class MacroCstWalker {
79 // Items can be either plaintext or macro nodes94 // Items can be either plaintext or macro nodes
80 if (item.type === 'plaintext') {95 if (item.type === 'plaintext') {
81 result += text.slice(item.startOffset, item.endOffset + 1);96 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;
82 } else {102 } else {
83 result += this.#evaluateMacroNode(item.node, context);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;
107 } else {
108 cursor = item.endOffset + 1;
109 }
84 }110 }
85
86 cursor = item.endOffset + 1;
87 }111 }
88112
89 if (cursor < text.length) {113 if (cursor < text.length) {
@@ -93,8 +117,59 @@ class MacroCstWalker {
93 return result;117 return result;
94 }118 }
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
96 /** @typedef {{ type: 'plaintext', startOffset: number, endOffset: number, token: IToken }} DocumentItemPlaintext */171 /** @typedef {{ type: 'plaintext', startOffset: number, endOffset: number, token: IToken }} DocumentItemPlaintext */
97 /** @typedef {{ type: 'macro', startOffset: number, endOffset: number, node: CstNode }} DocumentItemMacro */172 /** @typedef {{ type: 'macro', startOffset: number, endOffset: number, node: CstNode, scopedContent?: { startOffset: number, endOffset: number, closingEndOffset: number }, keepRaw?: boolean }} DocumentItemMacro */
98 /** @typedef {DocumentItemPlaintext | DocumentItemMacro} DocumentItem */173 /** @typedef {DocumentItemPlaintext | DocumentItemMacro} DocumentItem */
99174
100 /**175 /**
@@ -158,15 +233,21 @@ class MacroCstWalker {
158 *233 *
159 * @param {CstNode} macroNode234 * @param {CstNode} macroNode
160 * @param {EvaluationContext} context235 * @param {EvaluationContext} context
236 * @param {{ startOffset: number, endOffset: number, closingEndOffset: number }} [scopedContent] - Optional scoped content range for block macros.
161 * @returns {string}237 * @returns {string}
162 */238 */
163 #evaluateMacroNode(macroNode, context) {239 #evaluateMacroNode(macroNode, context, scopedContent) {
164 const { text, env, resolveMacro } = context;240 const { text, env, resolveMacro, trimContent } = context;
165241
166 const children = macroNode.children || {};242 const children = macroNode.children || {};
167 const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []);243 const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []);
168 const name = identifierTokens[0]?.image || '';244 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
170 const range = this.#getMacroRange(macroNode);251 const range = this.#getMacroRange(macroNode);
171 const startToken = /** @type {IToken?} */ ((children['Macro.Start'] || [])[0]);252 const startToken = /** @type {IToken?} */ ((children['Macro.Start'] || [])[0]);
172 const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]);253 const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]);
@@ -182,6 +263,8 @@ class MacroCstWalker {
182 const args = [];263 const args = [];
183 /** @type {({ value: string } & TokenRange)[]} */264 /** @type {({ value: string } & TokenRange)[]} */
184 const evaluatedArguments = [];265 const evaluatedArguments = [];
266 /** @type {string[]} */
267 const rawArgs = [];
185268
186 for (const argNode of argumentNodes) {269 for (const argNode of argumentNodes) {
187 const argValue = this.#evaluateArgumentNode(argNode, context);270 const argValue = this.#evaluateArgumentNode(argNode, context);
@@ -194,6 +277,32 @@ class MacroCstWalker {
194 ...location,277 ...location,
195 });278 });
196 }279 }
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 }
197 }306 }
198307
199 evaluatedArguments.sort((a, b) => a.startOffset - b.startOffset);308 evaluatedArguments.sort((a, b) => a.startOffset - b.startOffset);
@@ -223,8 +332,11 @@ class MacroCstWalker {
223 const call = {332 const call = {
224 name,333 name,
225 args,334 args,
335 flags,
336 isScoped: scopedContent != null,
226 rawInner,337 rawInner,
227 rawWithBraces: text.slice(range.startOffset, range.endOffset + 1),338 rawWithBraces: text.slice(range.startOffset, range.endOffset + 1),
339 rawArgs,
228 range,340 range,
229 cstNode: macroNode,341 cstNode: macroNode,
230 env,342 env,
@@ -428,6 +540,307 @@ class MacroCstWalker {
428 #isCstNode(value) {540 #isCstNode(value) {
429 return !!value && typeof value === 'object' && 'name' in value && 'children' in value;541 return !!value && typeof value === 'object' && 'name' in value && 'children' in value;
430 }542 }
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 }
431}844}
432845
433instance = MacroCstWalker.instance;846instance = MacroCstWalker.instance;
public/scripts/macros/engine/MacroEngine.js+201 -19
@@ -2,12 +2,29 @@ import { MacroParser } from './MacroParser.js';
2import { MacroCstWalker } from './MacroCstWalker.js';2import { MacroCstWalker } from './MacroCstWalker.js';
3import { MacroRegistry } from './MacroRegistry.js';3import { MacroRegistry } from './MacroRegistry.js';
4import { logMacroGeneralError, logMacroInternalError, logMacroRuntimeWarning, logMacroSyntaxWarning } from './MacroDiagnostics.js';4import { logMacroGeneralError, logMacroInternalError, logMacroRuntimeWarning, logMacroSyntaxWarning } from './MacroDiagnostics.js';
5import { ELSE_MARKER } from '../definitions/core-macros.js';
56
6/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */7/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
7/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */8/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
8/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */9/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
910
10/**11/**
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/**
11 * The singleton instance of the MacroEngine.28 * The singleton instance of the MacroEngine.
12 *29 *
13 * @type {MacroEngine}30 * @type {MacroEngine}
@@ -19,7 +36,71 @@ class MacroEngine {
19 /** @type {MacroEngine} */ static #instance;36 /** @type {MacroEngine} */ static #instance;
20 /** @type {MacroEngine} */ static get instance() { return MacroEngine.#instance ?? (MacroEngine.#instance = new MacroEngine()); }37 /** @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
24 /**105 /**
25 * Evaluates a string containing macros and resolves them.106 * Evaluates a string containing macros and resolves them.
@@ -59,6 +140,7 @@ class MacroEngine {
59 cst,140 cst,
60 env: safeEnv,141 env: safeEnv,
61 resolveMacro: this.#resolveMacro.bind(this),142 resolveMacro: this.#resolveMacro.bind(this),
143 trimContent: this.trimScopedContent.bind(this),
62 });144 });
63 } catch (error) {145 } catch (error) {
64 logMacroGeneralError({ message: 'Macro evaluation failed. Returning original input.', error: { input, error } });146 logMacroGeneralError({ message: 'Macro evaluation failed. Returning original input.', error: { input, error } });
@@ -142,21 +224,9 @@ class MacroEngine {
142 */224 */
143 #runPreProcessors(text, env) {225 #runPreProcessors(text, env) {
144 let result = text;226 let result = text;
145227 for (const { handler } of this.#preProcessors) {
146 // This legacy macro will not be supported by the new macro parser, but rather regex-replaced beforehand228 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
160 return result;230 return result;
161 }231 }
162232
@@ -169,18 +239,65 @@ class MacroEngine {
169 */239 */
170 #runPostProcessors(text, env) {240 #runPostProcessors(text, env) {
171 let result = text;241 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
173 // Unescape braces: \{ → { and \} → }280 // Unescape braces: \{ → { and \} → }
174 // Since \{\{ doesn't match {{ (MacroStart), it passes through as plain text.281 // Since \{\{ doesn't match {{ (MacroStart), it passes through as plain text.
175 // We only need to remove the backslashes in post-processing.282 // 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
178 // The original trim macro is reaching over the boundaries of the defined macro. This is not something the engine supports.288 // The original trim macro is reaching over the boundaries of the defined macro. This is not something the engine supports.
179 // To treat {{trim}} as it was before, we won't process it by the engine itself,289 // To treat {{trim}} as it was before, we won't process it by the engine itself,
180 // but doing a regex replace on {{trim}} and the surrounding area, after all other macros have been processed.290 // 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 );
184 }301 }
185302
186 /**303 /**
@@ -207,6 +324,71 @@ class MacroEngine {
207324
208 return String(value);325 return String(value);
209 }326 }
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 }
210}392}
211393
212instance = MacroEngine.instance;394instance = 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 */
29export 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 */
106export 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 */
170export 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 */
177export 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 */
197export 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 */
241export 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 */
251export 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 */
261export 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
4/** @typedef {import('chevrotain').TokenType} TokenType */4/** @typedef {import('chevrotain').TokenType} TokenType */
55
6
7/** Regex for lexer token matching (no anchors). */
8const 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 */
17export const MACRO_IDENTIFIER_PATTERN = /^[a-zA-Z][\w-_]*$/;
18
6/** @enum {string} */19/** @enum {string} */
7const modes = {20const modes = {
8 plaintext: 'plaintext_mode',21 plaintext: 'plaintext_mode',
@@ -25,9 +38,24 @@ const Tokens = {
25 Start: createToken({ name: 'Macro.Start', pattern: /\{\{/ }),38 Start: createToken({ name: 'Macro.Start', pattern: /\{\{/ }),
26 // Separate macro identifier needed, that is similar to the global indentifier, but captures the actual macro "name"39 // Separate macro identifier needed, that is similar to the global indentifier, but captures the actual macro "name"
27 // We need this, because this token is going to switch lexer mode, while the general identifier does not.40 // 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: />/ }),
29 DoubleSlash: createToken({ name: 'Macro.DoubleSlash', pattern: /\/\// }),57 DoubleSlash: createToken({ name: 'Macro.DoubleSlash', pattern: /\/\// }),
30 Identifier: createToken({ name: 'Macro.Identifier', pattern: /[a-zA-Z][\w-_]*/ }),58 Identifier: createToken({ name: 'Macro.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
31 // 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 braces59 // 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
32 EndOfIdentifier: createToken({ name: 'Macro.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),60 EndOfIdentifier: createToken({ name: 'Macro.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
33 BeforeEnd: createToken({ name: 'Macro.BeforeEnd', pattern: /(?=\}\})/, group: Lexer.SKIPPED }),61 BeforeEnd: createToken({ name: 'Macro.BeforeEnd', pattern: /(?=\}\})/, group: Lexer.SKIPPED }),
@@ -45,13 +73,13 @@ const Tokens = {
45 Filter: {73 Filter: {
46 EscapedPipe: createToken({ name: 'Filter.EscapedPipe', pattern: /\\\|/ }),74 EscapedPipe: createToken({ name: 'Filter.EscapedPipe', pattern: /\\\|/ }),
47 Pipe: createToken({ name: 'Filter.Pipe', pattern: /\|/ }),75 Pipe: createToken({ name: 'Filter.Pipe', pattern: /\|/ }),
48 Identifier: createToken({ name: 'Filter.Identifier', pattern: /[a-zA-Z][\w-_]*/ }),76 Identifier: createToken({ name: 'Filter.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
49 // 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 braces77 // 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
50 EndOfIdentifier: createToken({ name: 'Filter.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),78 EndOfIdentifier: createToken({ name: 'Filter.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
51 },79 },
5280
53 // All tokens that can be captured inside a macro81 // All tokens that can be captured inside a macro
54 Identifier: createToken({ name: 'Identifier', pattern: /[a-zA-Z][\w-_]*/ }),82 Identifier: createToken({ name: 'Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
55 WhiteSpace: createToken({ name: 'WhiteSpace', pattern: /\s+/, group: Lexer.SKIPPED }),83 WhiteSpace: createToken({ name: 'WhiteSpace', pattern: /\s+/, group: Lexer.SKIPPED }),
5684
57 // Capture unknown characters one by one, to still allow other tokens being matched once they are there.85 // Capture unknown characters one by one, to still allow other tokens being matched once they are there.
@@ -84,6 +112,8 @@ const Def = {
84 enter(Tokens.Macro.DoubleSlash, modes.macro_args),112 enter(Tokens.Macro.DoubleSlash, modes.macro_args),
85113
86 using(Tokens.Macro.Flags),114 using(Tokens.Macro.Flags),
115 // Filter flag is separate because it affects parsing behavior for pipes
116 using(Tokens.Macro.FilterFlag),
87117
88 // We allow whitspaces inbetween flags or in front of the modifier118 // We allow whitspaces inbetween flags or in front of the modifier
89 using(Tokens.WhiteSpace),119 using(Tokens.WhiteSpace),
public/scripts/macros/engine/MacroParser.js+12 -1
@@ -46,7 +46,18 @@ class MacroParser extends CstParser {
46 // Basic Macro Structure46 // Basic Macro Structure
47 $.macro = $.RULE('macro', () => {47 $.macro = $.RULE('macro', () => {
48 $.CONSUME(Tokens.Macro.Start);48 $.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([
50 { ALT: () => $.CONSUME(Tokens.Macro.DoubleSlash, { LABEL: 'Macro.identifier' }) },61 { ALT: () => $.CONSUME(Tokens.Macro.DoubleSlash, { LABEL: 'Macro.identifier' }) },
51 { ALT: () => $.CONSUME(Tokens.Macro.Identifier, { LABEL: 'Macro.identifier' }) },62 { ALT: () => $.CONSUME(Tokens.Macro.Identifier, { LABEL: 'Macro.identifier' }) },
52 ]);63 ]);
public/scripts/macros/engine/MacroRegistry.js+38 -5
@@ -1,6 +1,9 @@
1/** @typedef {import('chevrotain').CstNode} CstNode */1/** @typedef {import('chevrotain').CstNode} CstNode */
2/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */2/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
3/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */3/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
4/** @typedef {import('./MacroFlags.js').MacroFlags} MacroFlags */
5
6import { MACRO_IDENTIFIER_PATTERN } from './MacroLexer.js';
47
5import { isFalseBoolean, isTrueBoolean } from '../../utils.js';8import { isFalseBoolean, isTrueBoolean } from '../../utils.js';
6import { MacroEngine } from './MacroEngine.js';9import { MacroEngine } from './MacroEngine.js';
@@ -34,6 +37,8 @@ export const MacroCategory = Object.freeze({
34 STATE: 'state',37 STATE: 'state',
35 /** Macros that don't fit in any of the other categories, but don't really need/deserve their own */38 /** Macros that don't fit in any of the other categories, but don't really need/deserve their own */
36 MISC: 'misc',39 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',
37});42});
3843
39/**44/**
@@ -57,7 +62,7 @@ export const MacroValueType = Object.freeze({
57/**62/**
58 * @typedef {Object} MacroDefinitionOptions63 * @typedef {Object} MacroDefinitionOptions
59 * @property {MacroAliasDef[]} [aliases] - Alternative names for this macro. Each alias creates a lookup entry pointing to the same definition.64 * @property {MacroAliasDef[]} [aliases] - Alternative names for this macro. Each alias creates a lookup entry pointing to the same definition.
60 * @property {MacroCategory|string} category - Category for grouping in documentation/autocomplete. Use MacroCategory enum values or a custom string.65 * @property {MacroCategory|string} [category=MacroCategory.UNCATEGORIZED] - Category for grouping in documentation/autocomplete. Use MacroCategory enum values or a custom string.
61 * @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.66 * @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.
62 * @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.67 * @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.
63 * @property {boolean} [strictArgs=true] - Whether the macro should be strict about its arguments.68 * @property {boolean} [strictArgs=true] - Whether the macro should be strict about its arguments.
@@ -102,11 +107,16 @@ export const MacroValueType = Object.freeze({
102 * @property {string[]} unnamedArgs - Unnamed positional arguments (both required and optional, up to the defined count).107 * @property {string[]} unnamedArgs - Unnamed positional arguments (both required and optional, up to the defined count).
103 * @property {string[]|null} list - List arguments (after unnamed args), or null if list is not enabled.108 * @property {string[]|null} list - List arguments (after unnamed args), or null if list is not enabled.
104 * @property {{ [key: string]: string }|null} namedArgs - Reserved for future named argument support.109 * @property {{ [key: string]: string }|null} namedArgs - Reserved for future named argument support.
105 * @property {string} raw110 * @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.
106 * @property {MacroEnv} env115 * @property {MacroEnv} env
107 * @property {CstNode|null} cstNode116 * @property {CstNode|null} cstNode
108 * @property {{ startOffset: number, endOffset: number }|null} range117 * @property {{ startOffset: number, endOffset: number }|null} range
109 * @property {(value: any) => string} normalize - Normalize function to use on unsure macro results to make sure they return strings as expected.118 * @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.
110 */120 */
111121
112/**122/**
@@ -179,7 +189,7 @@ class MacroRegistry {
179 name = typeof name === 'string' ? name.trim() : String(name);189 name = typeof name === 'string' ? name.trim() : String(name);
180190
181 try {191 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.`);
183 if (!options || typeof options !== 'object') throw new Error(`Macro "${name}" options must be a non-null object.`);193 if (!options || typeof options !== 'object') throw new Error(`Macro "${name}" options must be a non-null object.`);
184194
185 const {195 const {
@@ -206,14 +216,18 @@ class MacroRegistry {
206 if (!aliasDef || typeof aliasDef !== 'object') throw new Error(`Macro "${name}" options.aliases[${i}] must be an object.`);216 if (!aliasDef || typeof aliasDef !== 'object') throw new Error(`Macro "${name}" options.aliases[${i}] must be an object.`);
207 if (typeof aliasDef.alias !== 'string' || !aliasDef.alias.trim()) throw new Error(`Macro "${name}" options.aliases[${i}].alias must be a non-empty string.`);217 if (typeof aliasDef.alias !== 'string' || !aliasDef.alias.trim()) throw new Error(`Macro "${name}" options.aliases[${i}].alias must be a non-empty string.`);
208 const aliasName = aliasDef.alias.trim();218 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.`);
209 if (aliasName === name) throw new Error(`Macro "${name}" options.aliases[${i}].alias cannot be the same as the macro name.`);220 if (aliasName === name) throw new Error(`Macro "${name}" options.aliases[${i}].alias cannot be the same as the macro name.`);
210 const visible = aliasDef.visible !== false; // Default to true221 const visible = aliasDef.visible !== false; // Default to true
211 aliases.push({ alias: aliasName, visible });222 aliases.push({ alias: aliasName, visible });
212 }223 }
213 }224 }
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} */
216 const category = rawCategory.trim();227 let category = MacroCategory.UNCATEGORIZED;
228 if (typeof rawCategory === 'string' && rawCategory.trim()) {
229 category = rawCategory.trim();
230 }
217231
218 let minArgs = 0;232 let minArgs = 0;
219 let maxArgs = 0;233 let maxArgs = 0;
@@ -525,11 +539,16 @@ class MacroRegistry {
525 unnamedArgs: unnamedArgsValues,539 unnamedArgs: unnamedArgsValues,
526 list: listValues,540 list: listValues,
527 namedArgs,541 namedArgs,
542 flags: call.flags,
543 isScoped: call.isScoped,
528 raw: call.rawInner,544 raw: call.rawInner,
545 rawOriginal: call.rawWithBraces,
546 rawArgs: call.rawArgs,
529 env: call.env,547 env: call.env,
530 cstNode: call.cstNode,548 cstNode: call.cstNode,
531 range: call.range,549 range: call.range,
532 normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),550 normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),
551 trimContent: MacroEngine.trimScopedContent.bind(MacroEngine),
533 };552 };
534553
535 const result = def.handler(executionContext);554 const result = def.handler(executionContext);
@@ -540,6 +559,20 @@ class MacroRegistry {
540instance = MacroRegistry.instance;559instance = MacroRegistry.instance;
541560
542/**561/**
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 */
569function 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/**
543 * Validates the arguments for a macro definition.576 * Validates the arguments for a macro definition.
544 * Supports required args (minArgs), optional args (up to maxArgs), and list tail.577 * Supports required args (minArgs), optional args (up to maxArgs), and list tail.
545 *578 *
public/scripts/power-user.js+1 -0
@@ -348,6 +348,7 @@ export const power_user = {
348348
349let themes = [];349let themes = [];
350let movingUIPresets = [];350let movingUIPresets = [];
351/** @type {ContextSettings[]} */
351export let context_presets = [];352export let context_presets = [];
352353
353const storage_keys = {354const storage_keys = {
public/scripts/slash-commands/SlashCommandParser.js+370 -21
@@ -15,15 +15,21 @@ import { SlashCommandAbortController } from './SlashCommandAbortController.js';
15import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';15import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';
16import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';16import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
17import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';17import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
18import { EnhancedMacroAutoCompleteOption, parseMacroContext } from '../autocomplete/EnhancedMacroAutoCompleteOption.js';18import { EnhancedMacroAutoCompleteOption, MacroFlagAutoCompleteOption, MacroClosingTagAutoCompleteOption, parseMacroContext } from '../autocomplete/EnhancedMacroAutoCompleteOption.js';
19import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';
20import { MacroParser } from '../macros/engine/MacroParser.js';
21import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';
19import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';22import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
20import { SlashCommandDebugController } from './SlashCommandDebugController.js';23import { SlashCommandDebugController } from './SlashCommandDebugController.js';
21import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';24import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
22import { SlashCommandBreak } from './SlashCommandBreak.js';25import { SlashCommandBreak } from './SlashCommandBreak.js';
23import { macros as macroSystem } from '../macros/macro-system.js';26import { macros as macroSystem } from '../macros/macro-system.js';
27import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
2428
25/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */29/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */
26/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */30/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */
31/** @typedef {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} MacroAutoCompleteContext */
32/** @typedef {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').EnhancedMacroAutoCompleteOptions} EnhancedMacroAutoCompleteOptions */
2733
28/**34/**
29 * @enum {Number}35 * @enum {Number}
@@ -493,19 +499,138 @@ export class SlashCommandParser {
493 const macroContent = text.slice(macro.start + 2, macro.end - (text.slice(macro.end - 2, macro.end) === '}}' ? 2 : 0));499 const macroContent = text.slice(macro.start + 2, macro.end - (text.slice(macro.end - 2, macro.end) === '}}' ? 2 : 0));
494 const context = parseMacroContext(macroContent, cursorInMacro);500 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
499 // Use enhanced macro autocomplete when experimental engine is enabled549 // 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);
501 const result = new AutoCompleteNameResult(587 const result = new AutoCompleteNameResult(
502 identifier,588 identifier,
503 macro.start + 2,589 identifierStartInText,
504 options,590 options,
505 false,591 false,
506 );592 );
507 return result;593 return result;
508 }594 }
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 }
509 if (executor.name == ':') {634 if (executor.name == ':') {
510 const options = this.scopeIndex[this.commandIndex.indexOf(executor)]635 const options = this.scopeIndex[this.commandIndex.indexOf(executor)]
511 ?.allVariableNames636 ?.allVariableNames
@@ -540,20 +665,84 @@ export class SlashCommandParser {
540665
541 /**666 /**
542 * Builds enhanced macro autocomplete options from the MacroRegistry.667 * Builds enhanced macro autocomplete options from the MacroRegistry.
668 * When in the flags area (before identifier), includes flag options.
543 * When typing arguments (after ::), prioritizes the exact macro match.669 * When typing arguments (after ::), prioritizes the exact macro match.
544 * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context670 * @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)[]}
546 */673 */
547 #buildEnhancedMacroOptions(context) {674 #buildEnhancedMacroOptions(context, textUpToCursor = '') {
548 /** @type {EnhancedMacroAutoCompleteOption[]} */675 /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption)[]} */
549 const options = [];676 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
551 // Get all macros from the registry (excluding hidden aliases)737 // Get all macros from the registry (excluding hidden aliases)
552 const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });738 const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
553739
554 // If we're typing arguments (after ::), only show the context to the matching macro740 // If we're typing arguments (after ::), only show the context to the matching macro
555 const isTypingArgs = context.currentArgIndex >= 0;741 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
557 for (const macro of allMacros) {746 for (const macro of allMacros) {
558 // Check if this macro matches the typed identifier747 // Check if this macro matches the typed identifier
559 const isExactMatch = macro.name === context.identifier;748 const isExactMatch = macro.name === context.identifier;
@@ -561,10 +750,25 @@ export class SlashCommandParser {
561750
562 // Only pass context to the macro that matches the identifier being typed751 // Only pass context to the macro that matches the identifier being typed
563 // This ensures argument hints only show for the relevant macro752 // 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
566 const option = new EnhancedMacroAutoCompleteOption(macro, macroContext);763 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
568 // When typing arguments, prioritize exact matches by putting them first772 // When typing arguments, prioritize exact matches by putting them first
569 if (isTypingArgs && (isExactMatch || isAliasMatch)) {773 if (isTypingArgs && (isExactMatch || isAliasMatch)) {
570 options.unshift(option);774 options.unshift(option);
@@ -577,6 +781,103 @@ export class SlashCommandParser {
577 }781 }
578782
579 /**783 /**
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 /**
580 * Moves the index <length> number of characters forward and returns the last character taken.881 * Moves the index <length> number of characters forward and returns the last character taken.
581 * @param {number} length Number of characters to take.882 * @param {number} length Number of characters to take.
582 * @param {boolean} keep Whether to add the characters to the kept text.883 * @param {boolean} keep Whether to add the characters to the kept text.
@@ -1286,18 +1587,66 @@ export class SlashCommandParser {
1286 }1587 }
12871588
1288 indexMacros(offset, text) {1589 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}}}}
1291 let localOffset = 0;1592 let i = 0;
1292 while (remaining.length > 0 && re.test(remaining)) {1593 while (i < text.length - 1) {
1293 const match = re.exec(remaining);1594 // Look for macro start {{
1294 this.macroIndex.push({1595 if (text[i] === '{' && text[i + 1] === '{') {
1295 start: offset + localOffset + match.index,1596 const macroStart = i;
1296 end: offset + localOffset + match.index + (match[0]?.length ?? 0),1597 i += 2; // Skip {{
1297 name: match[1] ?? '',1598
1298 });1599 // Find where this macro ends, tracking nested braces
1299 localOffset += match.index + (match[0]?.length ?? 0);1600 let depth = 1;
1300 remaining = remaining.slice(match.index + (match[0]?.length ?? 0));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
1627 this.macroIndex.push({
1628 start: offset + macroStart,
1629 end: offset + macroEnd,
1630 name: context.identifier,
1631 });
1632
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 }
1301 }1650 }
1302 }1651 }
1303}1652}
tests/frontend/MacroEngine.e2e.js+1091 -4
@@ -126,6 +126,38 @@ test.describe('MacroEngine', () => {
126 });126 });
127 });127 });
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
129 test.describe('Legacy compatibility', () => {161 test.describe('Legacy compatibility', () => {
130 test('should strip trim macro and surrounding newlines (legacy behavior)', async ({ page }) => {162 test('should strip trim macro and surrounding newlines (legacy behavior)', async ({ page }) => {
131 const input = 'foo\n\n{{trim}}\n\nbar';163 const input = 'foo\n\n{{trim}}\n\nbar';
@@ -395,7 +427,7 @@ test.describe('MacroEngine', () => {
395 });427 });
396428
397 test.describe('Arity errors', () => {429 test.describe('Arity errors', () => {
398 test('should not resolve newline when called with arguments', async ({ page }) => {430 test('should not resolve macro without arguments when called with arguments', async ({ page }) => {
399 /** @type {string[]} */431 /** @type {string[]} */
400 const warnings = [];432 const warnings = [];
401 page.on('console', msg => {433 page.on('console', msg => {
@@ -404,14 +436,14 @@ test.describe('MacroEngine', () => {
404 }436 }
405 });437 });
406438
407 const input = 'Start {{newline::extra}} end.';439 const input = 'Start {{char::extra}} end.';
408 const output = await evaluateWithEngine(page, input);440 const output = await evaluateWithEngine(page, input);
409441
410 // Macro text should remain unchanged442 // Macro text should remain unchanged
411 expect(output).toBe(input);443 expect(output).toBe(input);
412444
413 // Should have logged an arity warning for newline445 // Should have logged an arity warning for char
414 expect(warnings.some(w => w.includes('Macro "newline"') && w.includes('unnamed arguments'))).toBeTruthy();446 expect(warnings.some(w => w.includes('Macro "char"') && w.includes('unnamed arguments'))).toBeTruthy();
415 });447 });
416448
417 test('should not resolve reverse when called without arguments', async ({ page }) => {449 test('should not resolve reverse when called without arguments', async ({ page }) => {
@@ -678,6 +710,1061 @@ test.describe('MacroEngine', () => {
678 expect(warnings.some(w => w.includes('Macro "dyn"') && w.includes('unnamed arguments'))).toBeTruthy();710 expect(warnings.some(w => w.includes('Macro "dyn"') && w.includes('unnamed arguments'))).toBeTruthy();
679 });711 });
680 });712 });
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 });
681});1768});
6821769
683/**1770/**
tests/frontend/MacroLexer.e2e.js+48 -34
@@ -653,6 +653,35 @@ test.describe('MacroLexer', () => {
653653
654 expect(tokens).toEqual(expectedTokens);654 expect(tokens).toEqual(expectedTokens);
655 });655 });
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 });
656 // {{ a shaaark }}685 // {{ a shaaark }}
657 test('should not capture single letter as flag, but as macro identifiers', async ({ page }) => {686 test('should not capture single letter as flag, but as macro identifiers', async ({ page }) => {
658 const input = '{{ a shaaark }}';687 const input = '{{ a shaaark }}';
@@ -668,42 +697,35 @@ test.describe('MacroLexer', () => {
668 expect(tokens).toEqual(expectedTokens);697 expect(tokens).toEqual(expectedTokens);
669 });698 });
670699
671 test.describe('Error Cases (Macro Execution Modifiers)', () => {700 test.describe('"Error" Cases (Macro Execution Modifiers)', () => {
672 // {{ @unknown }}701 // {{ @unknown }}
673 test('[Error] should not capture unknown special characters as flag', async ({ page }) => {702 test('should not capture unknown special characters as flag', async ({ page }) => {
674 const input = '{{ @unknown }}';703 const input = '{{ @unknown }}';
675 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);704 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
683 const expectedTokens = [709 const expectedTokens = [
684 { type: 'Macro.Start', text: '{{' },710 { type: 'Macro.Start', text: '{{' },
685 // Do not capture '@' as anything, as it's a lexer error711 // Because '@' is invalid in lexer, it'll "pop out" and be captured as plaintext
686 { type: 'Macro.Identifier', text: 'unknown' },712 { type: 'Plaintext', text: '@unknown }}' },
687 { type: 'Macro.End', text: '}}' },
688 ];713 ];
689714
690 expect(tokens).toEqual(expectedTokens);715 expect(tokens).toEqual(expectedTokens);
691 });716 });
692 // {{ 2 cents }}717 // {{ 2 cents }}
693 test('[Error] should not capture numbers as flag - they are also invalid macro identifiers', async ({ page }) => {718 test('should not capture numbers as flag - they are also invalid macro identifiers', async ({ page }) => {
694 const input = '{{ 2 cents }}';719 const input = '{{ 2 cents }}';
695 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);720 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
702 const expectedTokens = [725 const expectedTokens = [
703 { type: 'Macro.Start', text: '{{' },726 { type: 'Macro.Start', text: '{{' },
704 // Do not capture '2' as anything, as it's a lexer error727 // Because '2' is invalid in lexer, it'll "pop out" and be captured as plaintext
705 { type: 'Macro.Identifier', text: 'cents' },728 { type: 'Plaintext', text: '2 cents }}' },
706 { type: 'Macro.End', text: '}}' },
707 ];729 ];
708730
709 expect(tokens).toEqual(expectedTokens);731 expect(tokens).toEqual(expectedTokens);
@@ -868,20 +890,16 @@ test.describe('MacroLexer', () => {
868890
869 test.describe('Error Cases (Macro Output Modifiers)', () => {891 test.describe('Error Cases (Macro Output Modifiers)', () => {
870 // {{|macro}}892 // {{|macro}}
871 test('[Error] should not capture when starting the macro with a pipe', async ({ page }) => {893 test('should not capture when starting the macro with a pipe', async ({ page }) => {
872 const input = '{{|macro}}';894 const input = '{{|macro}}';
873 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);895 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
881 const expectedTokens = [900 const expectedTokens = [
882 { type: 'Macro.Start', text: '{{' },901 { type: 'Macro.Start', text: '{{' },
883 { type: 'Macro.Identifier', text: 'macro' },902 { type: 'Plaintext', text: '|macro}}' },
884 { type: 'Macro.End', text: '}}' },
885 ];903 ];
886904
887 expect(tokens).toEqual(expectedTokens);905 expect(tokens).toEqual(expectedTokens);
@@ -1052,18 +1070,14 @@ test.describe('MacroLexer', () => {
1052 const input = 'invalid {{ 000 }} followed by correct {{ macro }}';1070 const input = 'invalid {{ 000 }} followed by correct {{ macro }}';
1053 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);1071 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
1061 const expectedTokens = [1076 const expectedTokens = [
1062 { type: 'Plaintext', text: 'invalid ' },1077 { type: 'Plaintext', text: 'invalid ' },
1063 { type: 'Macro.Start', text: '{{' },1078 { type: 'Macro.Start', text: '{{' },
1064 // Do not capture '000' as anything, as it's a lexer error1079 // '000' is invalid vor the lexer, so it is captured as plaintext
1065 { type: 'Macro.End', text: '}}' },1080 { type: 'Plaintext', text: '000 }} followed by correct ' },
1066 { type: 'Plaintext', text: ' followed by correct ' },
1067 { type: 'Macro.Start', text: '{{' },1081 { type: 'Macro.Start', text: '{{' },
1068 { type: 'Macro.Identifier', text: 'macro' },1082 { type: 'Macro.Identifier', text: 'macro' },
1069 { type: 'Macro.End', text: '}}' },1083 { type: 'Macro.End', text: '}}' },
tests/frontend/MacroParser.e2e.js+141 -3
@@ -61,15 +61,15 @@ test.describe('MacroParser', () => {
61 expect(errors).toMatchObject(expectedErrors);61 expect(errors).toMatchObject(expectedErrors);
62 expect(errors[0].message).toMatch(expectedMessage);62 expect(errors[0].message).toMatch(expectedMessage);
63 });63 });
64 // {{§!#&blah}}64 // {{§%€blah}}
65 test('[Error] should throw an error for invalid identifier', async ({ page }) => {65 test('[Error] should throw an error for invalid identifier', async ({ page }) => {
66 const input = '{{§!#&blah}}';66 const input = '{{§%€blah}}';
67 const { macroCst, errors } = await runParserAndGetErrors(page, input);67 const { macroCst, errors } = await runParserAndGetErrors(page, input);
6868
69 const expectedErrors = [69 const expectedErrors = [
70 { name: 'NoViableAltException' },70 { name: 'NoViableAltException' },
71 ];71 ];
72 const expectedMessage = /Expecting: one of these possible Token sequences:(.*?)\[Macro\.Identifier\](.*?)but found: '!'/gs;72 const expectedMessage = /Expecting: one of these possible Token sequences:(.*?)\[Macro\.Identifier\](.*?)but found: '§%€blah}}'/gs;
7373
74 expect(macroCst).toBeUndefined();74 expect(macroCst).toBeUndefined();
75 expect(errors).toMatchObject(expectedErrors);75 expect(errors).toMatchObject(expectedErrors);
@@ -556,6 +556,144 @@ This is the second line
556556
557 });557 });
558 });558 });
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 });
559});697});
560698
561/**699/**
tests/frontend/MacroRegistry.e2e.js+277 -74
@@ -44,110 +44,313 @@ test.describe('MacroRegistry', () => {
4444
45 test.describe('reject', () => {45 test.describe('reject', () => {
46 test('should reject invalid macro name', async ({ page }) => {46 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 name50 });
51 MacroRegistry.registerMacro(' ', {51
52 handler: () => '',52 expect(result.registered).toBeNull();
53 });53 expect(result.errors.length).toBeGreaterThan(0);
54 })).rejects.toThrow(/Macro name must be a non-empty string/);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.');
55 });59 });
5660
57 test('should reject invalid options object', async ({ page }) => {61 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 object65 });
62 // @ts-expect-error intentionally wrong66
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');
65 });74 });
6675
67 test('should reject invalid handler', async ({ page }) => {76 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 function80 });
72 // @ts-expect-error intentionally wrong81
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');
75 });89 });
7690
77 test('should reject invalid unnamedArgs', async ({ page }) => {91 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
84 unnamedArgs: -1,95 unnamedArgs: -1,
85 handler: () => '',96 },
86 });97 });
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');
88 });106 });
89107
90 test('should reject invalid strictArgs', async ({ page }) => {108 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
97 strictArgs: 'yes',112 strictArgs: 'yes',
98 handler: () => '',113 },
99 });114 });
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');
101 });123 });
102124
103 test('should reject invalid list configuration', async ({ page }) => {125 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
110 list: 'invalid',129 list: 'invalid',
111 handler: () => '',130 },
112 });131 });
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');
114 });140 });
115141
116 test('should reject invalid list.min', async ({ page }) => {142 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', {
122 list: { min: -1 },146 list: { min: -1 },
123 handler: () => '',147 },
124 });148 });
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');
126 });157 });
127158
128 test('should reject invalid list.max', async ({ page }) => {159 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', {
134 list: { min: 2, max: 1 },163 list: { min: 2, max: 1 },
135 handler: () => '',164 },
136 });165 });
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');
138 });174 });
139175
140 test('should reject invalid description', async ({ page }) => {176 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
147 description: 123,180 description: 123,
148 handler: () => '',181 },
149 });182 });
150 })).rejects.toThrow(/options\.description must be a string/);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);
220 });
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: {},
226 });
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: {},
265 });
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');
269 });
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');
151 });292 });
152 });293 });
153});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 */
307async 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 @@
1import fs from 'node:fs';
2import path from 'node:path';
3
4import { test, expect } from '@playwright/test';
5import { testSetup } from './frontent-test-utils.js';
6import { serverDirectory } from '../../src/server-directory.js';
7
8test.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});