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>
This commit is contained in:
@@ -465,6 +465,67 @@
|
||||
color: #F89406;
|
||||
}
|
||||
|
||||
/* Arity warning banner in details */
|
||||
.macro-ac-warning {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
padding: 0.5em 0.75em;
|
||||
background: linear-gradient(90deg, rgba(248, 148, 6, 0.2), transparent);
|
||||
border-left: 3px solid #F89406;
|
||||
border-radius: 0 4px 4px 0;
|
||||
margin-bottom: 0.5em;
|
||||
font-size: 0.9em;
|
||||
color: #F89406;
|
||||
}
|
||||
|
||||
.macro-ac-warning i {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Scoped content info banner in details */
|
||||
.macro-ac-scoped-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
padding: 0.5em 0.75em;
|
||||
background: linear-gradient(90deg, rgba(91, 192, 222, 0.2), transparent);
|
||||
border-left: 3px solid #5BC0DE;
|
||||
border-radius: 0 4px 4px 0;
|
||||
margin-bottom: 0.5em;
|
||||
font-size: 0.9em;
|
||||
color: #5BC0DE;
|
||||
}
|
||||
|
||||
.macro-ac-scoped-info i {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.macro-ac-scoped-info code {
|
||||
background: rgba(91, 192, 222, 0.15);
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Closing tag autocomplete option */
|
||||
.autoComplete > .item.macro-closing-tag-item > .type {
|
||||
color: var(--ac-color-matchedText, var(--SmartThemeBorderColor));
|
||||
}
|
||||
|
||||
.macro-closing-tag-details {
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.macro-closing-tag-details h3 {
|
||||
margin: 0 0 0.5em 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.macro-closing-tag-details p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Current argument hint banner in details */
|
||||
.macro-ac-arg-hint {
|
||||
display: flex;
|
||||
|
||||
@@ -311,8 +311,8 @@ export class AutoComplete {
|
||||
this.name = this.parserResult.name.toLowerCase() ?? '';
|
||||
|
||||
const isCursorInNamePart = this.textarea.selectionStart >= this.parserResult.start && this.textarea.selectionStart <= this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0);
|
||||
if (isForced || isInput) {
|
||||
// if forced (ctrl+space) or user input...
|
||||
if (isForced || isInput || isSelect) {
|
||||
// if forced (ctrl+space) or user input or just selected an option...
|
||||
if (isCursorInNamePart) {
|
||||
// ...and cursor is somewhere in the name part (including right behind the final char)
|
||||
// -> show autocomplete for the (partial if cursor in the middle) name
|
||||
@@ -393,8 +393,20 @@ export class AutoComplete {
|
||||
this.updateName(option);
|
||||
return option;
|
||||
})
|
||||
// sort by fuzzy score or alphabetical
|
||||
.toSorted(this.matchType == 'fuzzy' ? this.fuzzyScoreCompare : (a, b) => a.name.localeCompare(b.name));
|
||||
// sort by priority first, then by fuzzy score or alphabetical
|
||||
.toSorted((a, b) => {
|
||||
// First compare by sortPriority (lower = higher priority)
|
||||
const priorityA = a.sortPriority ?? 100;
|
||||
const priorityB = b.sortPriority ?? 100;
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB;
|
||||
}
|
||||
// Then by fuzzy score or alphabetical
|
||||
if (this.matchType == 'fuzzy') {
|
||||
return this.fuzzyScoreCompare(a, b);
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export class AutoCompleteNameResultBase {
|
||||
this.start = start;
|
||||
this.optionList = optionList;
|
||||
this.canBeQuoted = canBeQuoted;
|
||||
this.noMatchText = makeNoMatchText ?? this.makeNoMatchText;
|
||||
this.noOptionstext = makeNoOptionsText ?? this.makeNoOptionsText;
|
||||
if (makeNoMatchText) this.makeNoMatchText = makeNoMatchText;
|
||||
if (makeNoOptionsText) this.makeNoOptionsText = makeNoOptionsText;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ export class AutoCompleteOption {
|
||||
/** @type {(input:string)=>boolean} */ matchProvider;
|
||||
/** @type {(input:string)=>string} */ valueProvider;
|
||||
/** @type {boolean} */ makeSelectable = false;
|
||||
/**
|
||||
* Priority for sorting. Lower values = higher priority (sorted first).
|
||||
* Default is 100 (normal priority). Use lower values for items that should appear at the top.
|
||||
* @type {number}
|
||||
*/
|
||||
sortPriority = 100;
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
renderMacroDetails,
|
||||
} from '../macros/MacroBrowser.js';
|
||||
import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||
import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
|
||||
|
||||
/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
|
||||
|
||||
@@ -19,9 +20,27 @@ import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js'
|
||||
* @typedef {Object} MacroAutoCompleteContext
|
||||
* @property {string} fullText - The full macro text being typed (without {{ }}).
|
||||
* @property {number} cursorOffset - Cursor position within the macro text.
|
||||
* @property {string} paddingBefore - Padding before the macro identifier/flags.
|
||||
* @property {string} identifier - The macro identifier (name).
|
||||
* @property {number} identifierStart - Start position of the identifier within the macro text.
|
||||
* @property {string[]} flags - Array of flag symbols typed (e.g., ['!', '?']).
|
||||
* @property {string|null} currentFlag - The flag symbol cursor is currently on (last typed flag), or null.
|
||||
* @property {boolean} isInFlagsArea - Whether cursor is in the flags area (before identifier starts).
|
||||
* @property {string[]} args - Array of arguments typed so far.
|
||||
* @property {number} currentArgIndex - Index of the argument being typed (-1 if on identifier).
|
||||
* @property {boolean} isTypingSeparator - Whether cursor is on a partial separator (single ':').
|
||||
* @property {boolean} hasSpaceAfterIdentifier - Whether there's a space after the identifier (for space-separated args).
|
||||
* @property {boolean} hasSpaceArgContent - Whether there's actual content after the space (not just whitespace).
|
||||
* @property {number} separatorCount - Number of '::' separators found.
|
||||
* @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag).
|
||||
* @property {string} [scopedMacroName] - Name of the scoped macro if in scoped content.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} EnhancedMacroAutoCompleteOptions
|
||||
* @property {boolean} [noBraces=false] - If true, display without {{ }} braces (for use as values, e.g., in {{if}} conditions).
|
||||
* @property {string} [paddingAfter=''] - Whitespace to add before closing }} (for matching opening whitespace style).
|
||||
* @property {boolean} [closeWithBraces=false] - If true, the completion will add }} to close the macro.
|
||||
*/
|
||||
|
||||
export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
|
||||
@@ -31,17 +50,53 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
|
||||
/** @type {MacroAutoCompleteContext|null} */
|
||||
#context = null;
|
||||
|
||||
/** @type {boolean} */
|
||||
#noBraces = false;
|
||||
|
||||
/** @type {string} */
|
||||
#paddingAfter = '';
|
||||
|
||||
/**
|
||||
* @param {MacroDefinition} macro - The macro definition from MacroRegistry.
|
||||
* @param {MacroAutoCompleteContext} [context] - Optional context for argument hints.
|
||||
* @param {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} [contextOrOptions] - Context for argument hints, or options object.
|
||||
*/
|
||||
constructor(macro, context = null) {
|
||||
constructor(macro, contextOrOptions = null) {
|
||||
// Use the macro name as the autocomplete key
|
||||
super(macro.name, enumIcons.macro);
|
||||
this.#macro = macro;
|
||||
this.#context = context;
|
||||
|
||||
// Detect if second argument is context or options
|
||||
// Context has 'identifier' property, options may have 'noBraces'
|
||||
if (contextOrOptions && typeof contextOrOptions === 'object') {
|
||||
if ('noBraces' in contextOrOptions || 'paddingAfter' in contextOrOptions || 'closeWithBraces' in contextOrOptions) {
|
||||
// It's an options object
|
||||
const options = /** @type {EnhancedMacroAutoCompleteOptions} */ (contextOrOptions);
|
||||
this.#noBraces = options.noBraces ?? false;
|
||||
this.#paddingAfter = options.paddingAfter ?? '';
|
||||
|
||||
// If noBraces mode with closeWithBraces, complete with name + padding + }}
|
||||
if (options.closeWithBraces) {
|
||||
this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`;
|
||||
this.makeSelectable = true;
|
||||
}
|
||||
} else {
|
||||
// It's a context object
|
||||
this.#context = /** @type {MacroAutoCompleteContext} */ (contextOrOptions);
|
||||
}
|
||||
}
|
||||
|
||||
// nameOffset = 2 to skip the {{ prefix in the display (formatMacroSignature includes braces)
|
||||
this.nameOffset = 2;
|
||||
// When noBraces is true, nameOffset = 0 since we don't show braces
|
||||
this.nameOffset = this.#noBraces ? 0 : 2;
|
||||
|
||||
// For macros that take no arguments, auto-complete with closing }} (unless already set by options)
|
||||
if (!this.valueProvider) {
|
||||
const takesNoArgs = macro.minArgs === 0 && macro.maxArgs === 0 && macro.list === null;
|
||||
if (takesNoArgs) {
|
||||
this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`;
|
||||
this.makeSelectable = true; // Required when using valueProvider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {MacroDefinition} */
|
||||
@@ -74,8 +129,9 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.classList.add('name', 'monospace');
|
||||
|
||||
// Build signature with individual character spans (includes {{ }})
|
||||
const sigText = formatMacroSignature(this.#macro);
|
||||
// Build signature with individual character spans
|
||||
// When noBraces is true, show just the macro name without {{ }}
|
||||
const sigText = this.#noBraces ? this.#macro.name : formatMacroSignature(this.#macro);
|
||||
for (const char of sigText) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = char;
|
||||
@@ -121,17 +177,31 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
|
||||
renderDetails() {
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
// Check for arity warnings
|
||||
const warning = this.#getArityWarning();
|
||||
if (warning) {
|
||||
const warningEl = this.#renderWarning(warning);
|
||||
frag.append(warningEl);
|
||||
}
|
||||
|
||||
// Show scoped content info banner if we're in scoped content
|
||||
if (this.#context?.isInScopedContent) {
|
||||
const scopedInfo = this.#renderScopedContentInfo();
|
||||
if (scopedInfo) frag.append(scopedInfo);
|
||||
}
|
||||
|
||||
// Determine current argument index for highlighting
|
||||
const currentArgIndex = this.#context?.currentArgIndex ?? -1;
|
||||
|
||||
// Render argument hint banner if we're typing an argument
|
||||
if (currentArgIndex >= 0) {
|
||||
// Render argument hint banner if we're typing an argument (and no warning)
|
||||
if (!warning && currentArgIndex >= 0) {
|
||||
const hint = this.#renderArgumentHint();
|
||||
if (hint) frag.append(hint);
|
||||
}
|
||||
|
||||
// Reuse MacroBrowser's renderMacroDetails with options
|
||||
const details = renderMacroDetails(this.#macro, { currentArgIndex });
|
||||
// Don't highlight args if there's a warning
|
||||
const details = renderMacroDetails(this.#macro, { currentArgIndex: warning ? -1 : currentArgIndex });
|
||||
|
||||
// Add class for autocomplete-specific styling overrides
|
||||
details.classList.add('macro-ac-details');
|
||||
@@ -140,6 +210,85 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
|
||||
return frag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for arity-related warnings based on the current context.
|
||||
* @returns {string|null} Warning message, or null if no warning.
|
||||
*/
|
||||
#getArityWarning() {
|
||||
if (!this.#context) return null;
|
||||
|
||||
const argCount = this.#context.args.length;
|
||||
const maxArgs = this.#macro.maxArgs;
|
||||
//const minArgs = this.#macro.minArgs;
|
||||
const hasList = this.#macro.list !== null;
|
||||
|
||||
// Check for too many arguments (only if no list args)
|
||||
if (!hasList && argCount > maxArgs) {
|
||||
return `Too many arguments: this macro accepts ${maxArgs === 0 ? 'no arguments' : `up to ${maxArgs} argument${maxArgs === 1 ? '' : 's'}`}, but ${argCount} provided.`;
|
||||
}
|
||||
|
||||
// Check for space-separated arg on macro that doesn't support it
|
||||
// Space-separated syntax provides 1 arg; with scoped content you can provide a 2nd arg
|
||||
// So it's valid for macros with maxArgs <= 2 (or with list args)
|
||||
if (this.#context.hasSpaceArgContent) {
|
||||
if (maxArgs === 0) {
|
||||
return 'This macro does not accept any arguments. Remove the space or use a different macro.';
|
||||
}
|
||||
if (!hasList && maxArgs > 2) {
|
||||
return `Space-separated syntax only works for macros with up to 2 arguments. Use :: separators instead: {{${this.#macro.name}::arg1::arg2}}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if trying to add args to a no-arg macro via ::
|
||||
if (this.#context.separatorCount > 0 && maxArgs === 0) {
|
||||
return 'This macro does not accept any arguments.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a warning banner.
|
||||
* @param {string} message - The warning message.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
#renderWarning(message) {
|
||||
const warning = document.createElement('div');
|
||||
warning.classList.add('macro-ac-warning');
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.classList.add('fa-solid', 'fa-triangle-exclamation');
|
||||
warning.append(icon);
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = message;
|
||||
warning.append(text);
|
||||
|
||||
return warning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the scoped content info banner.
|
||||
* Shows when cursor is inside scoped content of an unclosed macro.
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
#renderScopedContentInfo() {
|
||||
if (!this.#context?.isInScopedContent) return null;
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.classList.add('macro-ac-scoped-info');
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.classList.add('fa-solid', 'fa-layer-group');
|
||||
info.append(icon);
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.innerHTML = `Typing <strong>scoped content</strong> for <code>{{${this.#context.scopedMacroName}}}</code>. Close with <code>{{/${this.#context.scopedMacroName}}}</code>`;
|
||||
info.append(text);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the current argument hint banner.
|
||||
* @returns {HTMLElement|null}
|
||||
@@ -209,52 +358,372 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete option for macro execution flags.
|
||||
* Shows flag symbol, name, and description.
|
||||
* Uses default AutoCompleteOption rendering for consistent styling.
|
||||
*/
|
||||
export class MacroFlagAutoCompleteOption extends AutoCompleteOption {
|
||||
/** @type {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} */
|
||||
#flagDef;
|
||||
|
||||
/**
|
||||
* @param {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} flagDef - The flag definition.
|
||||
*/
|
||||
constructor(flagDef) {
|
||||
// Use the flag symbol as the name, with a flag icon
|
||||
// Display name includes both symbol and name for clarity
|
||||
super(flagDef.type, '🚩');
|
||||
this.#flagDef = flagDef;
|
||||
}
|
||||
|
||||
/** @returns {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} */
|
||||
get flagDefinition() {
|
||||
return this.#flagDef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the autocomplete list item for this flag.
|
||||
* Uses the same structure as other autocomplete options for consistent styling.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
renderItem() {
|
||||
// Use base class makeItem for consistent styling
|
||||
const li = this.makeItem(
|
||||
`${this.#flagDef.type} ${this.#flagDef.name}`, // Display: "? Optional"
|
||||
'🚩',
|
||||
true, // noSlash
|
||||
[], // namedArguments
|
||||
[], // unnamedArguments
|
||||
'void', // returnType
|
||||
this.#flagDef.description + (this.#flagDef.implemented ? '' : ' (planned)'), // helpString
|
||||
);
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', 'flag');
|
||||
return li;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the details panel for this flag.
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
renderDetails() {
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.classList.add('macro-flag-details');
|
||||
|
||||
// Header with flag symbol and name
|
||||
const header = document.createElement('h3');
|
||||
header.classList.add('macro-flag-details-header');
|
||||
header.innerHTML = `<code>${this.#flagDef.type}</code> ${this.#flagDef.name} Flag`;
|
||||
details.append(header);
|
||||
|
||||
// Description
|
||||
const desc = document.createElement('p');
|
||||
desc.classList.add('macro-flag-details-desc');
|
||||
desc.textContent = this.#flagDef.description;
|
||||
details.append(desc);
|
||||
|
||||
// Status
|
||||
const status = document.createElement('p');
|
||||
status.classList.add('macro-flag-details-status');
|
||||
status.innerHTML = `<strong>Status:</strong> ${this.#flagDef.implemented ? 'Implemented' : 'Planned for future release'}`;
|
||||
details.append(status);
|
||||
|
||||
// Parser effect note
|
||||
if (this.#flagDef.affectsParser) {
|
||||
const parserNote = document.createElement('p');
|
||||
parserNote.classList.add('macro-flag-details-note');
|
||||
parserNote.innerHTML = '<em>This flag affects how the macro is parsed.</em>';
|
||||
details.append(parserNote);
|
||||
}
|
||||
|
||||
frag.append(details);
|
||||
return frag;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete option for closing a scoped macro.
|
||||
* Suggests {{/macroName}} to close an unclosed scoped macro.
|
||||
*/
|
||||
export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
|
||||
/** @type {string} */
|
||||
#macroName;
|
||||
|
||||
/**
|
||||
* @param {string} macroName - The name of the macro to close.
|
||||
*/
|
||||
constructor(macroName) {
|
||||
// The closing tag is what we're suggesting - use /macroName as the name for matching
|
||||
const closingTag = `/${macroName}`;
|
||||
super(closingTag, '{/');
|
||||
this.#macroName = macroName;
|
||||
|
||||
// Custom valueProvider to return the correct replacement text
|
||||
// Autocomplete REPLACES the typed identifier entirely, so return the full closing tag
|
||||
this.valueProvider = () => {
|
||||
// Return full closing tag content (without {{ since that's before the identifier)
|
||||
return `/${macroName}}}`;
|
||||
};
|
||||
|
||||
// Make selectable so TAB completion works (valueProvider alone makes it non-selectable)
|
||||
this.makeSelectable = true;
|
||||
|
||||
// Highest priority - closing tags should always appear at the very top
|
||||
this.sortPriority = 1;
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
get macroName() {
|
||||
return this.#macroName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the autocomplete list item for this closing tag.
|
||||
* Uses the same structure as other macro options for consistent styling.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
renderItem() {
|
||||
const li = document.createElement('li');
|
||||
li.classList.add('item', 'macro-ac-item');
|
||||
|
||||
// Type icon (same column as other macros)
|
||||
const type = document.createElement('span');
|
||||
type.classList.add('type', 'monospace');
|
||||
type.textContent = this.typeIcon;
|
||||
li.append(type);
|
||||
|
||||
// Specs container (for fuzzy highlight compatibility)
|
||||
const specs = document.createElement('span');
|
||||
specs.classList.add('specs');
|
||||
|
||||
// Name element with character spans
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.classList.add('name', 'monospace');
|
||||
// Display full closing tag like other macros show full syntax
|
||||
const displayName = `{{/${this.#macroName}}}`;
|
||||
for (const char of displayName) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = char;
|
||||
nameEl.append(span);
|
||||
}
|
||||
specs.append(nameEl);
|
||||
li.append(specs);
|
||||
|
||||
// Stopgap (spacer for flex layout)
|
||||
const stopgap = document.createElement('span');
|
||||
stopgap.classList.add('stopgap');
|
||||
li.append(stopgap);
|
||||
|
||||
// Help text (description)
|
||||
const help = document.createElement('span');
|
||||
help.classList.add('help');
|
||||
const content = document.createElement('span');
|
||||
content.classList.add('helpContent');
|
||||
content.textContent = `Close the {{${this.#macroName}}} scoped macro.`;
|
||||
help.append(content);
|
||||
li.append(help);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the details panel for this closing tag.
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
renderDetails() {
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.classList.add('macro-closing-tag-details');
|
||||
|
||||
// Header
|
||||
const header = document.createElement('h3');
|
||||
header.innerHTML = `Close <code>{{${this.#macroName}}}</code>`;
|
||||
details.append(header);
|
||||
|
||||
// Description
|
||||
const desc = document.createElement('p');
|
||||
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.`;
|
||||
details.append(desc);
|
||||
|
||||
frag.append(details);
|
||||
return frag;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the macro text to determine current argument context.
|
||||
* @param {string} macroText - The text inside {{ }}, e.g., "roll::1d20" or "random::a::b".
|
||||
* Handles leading whitespace and flags before the identifier.
|
||||
*
|
||||
* @param {string} macroText - The text inside {{ }}, e.g., "roll::1d20" or "!user" or " description ".
|
||||
* @param {number} cursorOffset - Cursor position within macroText.
|
||||
* @returns {MacroAutoCompleteContext}
|
||||
*/
|
||||
export function parseMacroContext(macroText, cursorOffset) {
|
||||
const parts = [];
|
||||
let currentPart = '';
|
||||
let partStart = 0;
|
||||
let i = 0;
|
||||
|
||||
// Skip leading whitespace
|
||||
while (i < macroText.length && /\s/.test(macroText[i])) {
|
||||
i++;
|
||||
}
|
||||
|
||||
// Extract flags (special symbols before the identifier)
|
||||
// Track position after each flag to determine which flag cursor is on
|
||||
// Special case: `/` followed by identifier chars is a closing tag, not a flag
|
||||
const flags = [];
|
||||
const flagEndPositions = []; // Position right after each flag (before any whitespace)
|
||||
while (i < macroText.length) {
|
||||
if (macroText[i] === ':' && macroText[i + 1] === ':') {
|
||||
parts.push({ text: currentPart, start: partStart, end: i });
|
||||
currentPart = '';
|
||||
i += 2;
|
||||
partStart = i;
|
||||
} else {
|
||||
currentPart += macroText[i];
|
||||
const char = macroText[i];
|
||||
// Check if this looks like a closing tag: `/` followed by an identifier character
|
||||
if (char === '/' && i + 1 < macroText.length && /[a-zA-Z_]/.test(macroText[i + 1])) {
|
||||
// This is a closing tag identifier, not a flag - stop parsing flags
|
||||
break;
|
||||
}
|
||||
if (ValidFlagSymbols.has(char)) {
|
||||
flags.push(char);
|
||||
i++;
|
||||
flagEndPositions.push(i); // Position right after this flag
|
||||
// Skip whitespace between flags
|
||||
while (i < macroText.length && /\s/.test(macroText[i])) {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which flag cursor is currently on (if any)
|
||||
// The "current" flag is the last one typed when cursor is still in the flags area
|
||||
// This ensures the last typed flag shows at the top of the autocomplete list
|
||||
let currentFlag = null;
|
||||
if (flags.length > 0) {
|
||||
// If cursor is at or after the last flag position but before identifier starts,
|
||||
// the last flag is the "current" one (just typed)
|
||||
const lastFlagEnd = flagEndPositions[flagEndPositions.length - 1];
|
||||
if (cursorOffset >= lastFlagEnd - 1) {
|
||||
currentFlag = flags[flags.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Now parse the identifier and arguments starting from position i
|
||||
const remainingText = macroText.slice(i);
|
||||
const parts = [];
|
||||
/** @type {{ start: number, end: number }[]} */
|
||||
const separatorPositions = []; // Track positions of :: separators
|
||||
let currentPart = '';
|
||||
let partStart = i;
|
||||
let j = 0;
|
||||
|
||||
while (j < remainingText.length) {
|
||||
if (remainingText[j] === ':' && remainingText[j + 1] === ':') {
|
||||
parts.push({ text: currentPart, start: partStart, end: i + j });
|
||||
separatorPositions.push({ start: i + j, end: i + j + 2 });
|
||||
currentPart = '';
|
||||
j += 2;
|
||||
partStart = i + j;
|
||||
} else {
|
||||
currentPart += remainingText[j];
|
||||
j++;
|
||||
}
|
||||
}
|
||||
// Push the last part
|
||||
parts.push({ text: currentPart, start: partStart, end: macroText.length });
|
||||
|
||||
// Determine which part the cursor is in
|
||||
let currentArgIndex = -1;
|
||||
for (let idx = 0; idx < parts.length; idx++) {
|
||||
const part = parts[idx];
|
||||
if (cursorOffset >= part.start && cursorOffset <= part.end) {
|
||||
currentArgIndex = idx - 1; // -1 because first part is identifier
|
||||
break;
|
||||
// Determine if cursor is in the flags area (at or before identifier starts)
|
||||
const identifierStartPos = parts[0]?.start ?? i;
|
||||
const isInFlagsArea = cursorOffset <= identifierStartPos;
|
||||
|
||||
// Check if cursor is on a partial separator (single ':' that might become '::')
|
||||
const isTypingSeparator = remainingText.length > 0 &&
|
||||
cursorOffset > identifierStartPos &&
|
||||
macroText[cursorOffset - 1] === ':' &&
|
||||
macroText[cursorOffset] !== ':' &&
|
||||
(cursorOffset < 2 || macroText[cursorOffset - 2] !== ':');
|
||||
|
||||
// Parse identifier and space-separated argument from the first part
|
||||
// "getvar myvar" -> identifier="getvar", spaceArg="myvar"
|
||||
// "setvar " -> identifier="setvar", spaceArg="" (just whitespace, no content yet)
|
||||
const firstPartText = parts[0]?.text || '';
|
||||
const trimmedFirstPart = firstPartText.trimStart();
|
||||
const firstSpaceInIdentifier = trimmedFirstPart.search(/\s/);
|
||||
|
||||
let identifierOnly;
|
||||
let spaceArgText = '';
|
||||
//let spaceArgStart = -1;
|
||||
let hasSpaceAfterIdentifier = false;
|
||||
|
||||
if (firstSpaceInIdentifier > 0 && separatorPositions.length === 0) {
|
||||
// There's whitespace inside the first part - split identifier from space-arg
|
||||
identifierOnly = trimmedFirstPart.slice(0, firstSpaceInIdentifier);
|
||||
const afterIdentifier = trimmedFirstPart.slice(firstSpaceInIdentifier);
|
||||
// Check if there's actual content after the whitespace (not just spaces or ::)
|
||||
const contentAfterSpace = afterIdentifier.trimStart();
|
||||
hasSpaceAfterIdentifier = afterIdentifier.length > 0; // Has at least a space
|
||||
|
||||
if (contentAfterSpace.length > 0 && !contentAfterSpace.startsWith(':')) {
|
||||
// There's actual argument content after the space
|
||||
spaceArgText = contentAfterSpace;
|
||||
//spaceArgStart = identifierStartPos + firstSpaceInIdentifier + (afterIdentifier.length - contentAfterSpace.length);
|
||||
}
|
||||
} else {
|
||||
identifierOnly = trimmedFirstPart.trimEnd();
|
||||
}
|
||||
|
||||
// If cursor is after all parts (at the end), we're in the last arg
|
||||
if (currentArgIndex === -1 && cursorOffset >= parts[parts.length - 1].end) {
|
||||
currentArgIndex = parts.length - 1;
|
||||
// Calculate identifier end position (for space-after-identifier detection)
|
||||
const identifierEndPos = identifierStartPos + (firstPartText.length - firstPartText.trimStart().length) + identifierOnly.length;
|
||||
|
||||
// Determine which part the cursor is in
|
||||
let currentArgIndex = -1;
|
||||
|
||||
// Only consider being in an argument if we've passed a separator
|
||||
if (separatorPositions.length > 0) {
|
||||
// Find which argument we're in based on separator positions
|
||||
for (let sepIdx = 0; sepIdx < separatorPositions.length; sepIdx++) {
|
||||
const sep = separatorPositions[sepIdx];
|
||||
if (cursorOffset >= sep.end) {
|
||||
// We're past this separator, so we're in at least this argument
|
||||
currentArgIndex = sepIdx;
|
||||
}
|
||||
}
|
||||
} else if (spaceArgText.length > 0 || (hasSpaceAfterIdentifier && cursorOffset > identifierEndPos)) {
|
||||
// Space-separated arg: either has content, or cursor is past identifier+space
|
||||
currentArgIndex = 0;
|
||||
}
|
||||
|
||||
// If typing a separator, we're still on identifier/previous arg, not the next one
|
||||
if (isTypingSeparator) {
|
||||
currentArgIndex = -1;
|
||||
}
|
||||
|
||||
const leftPadding = macroText.match(/^\s+/)?.[0] ?? '';
|
||||
|
||||
// Clean identifier: strip trailing colons (for partial :: typing)
|
||||
let cleanIdentifier = identifierOnly.replace(/:+$/, '');
|
||||
|
||||
// Build args array - include space-separated arg if present
|
||||
// Trim args like the macro engine does
|
||||
let args = parts.slice(1).map(p => p.text.trim());
|
||||
if (spaceArgText.length > 0) {
|
||||
args = [spaceArgText, ...args];
|
||||
}
|
||||
|
||||
return {
|
||||
fullText: macroText,
|
||||
cursorOffset,
|
||||
identifier: parts[0]?.text.trim() || '',
|
||||
args: parts.slice(1).map(p => p.text),
|
||||
paddingBefore: leftPadding,
|
||||
identifier: cleanIdentifier,
|
||||
identifierStart: identifierStartPos,
|
||||
isInFlagsArea,
|
||||
flags,
|
||||
currentFlag,
|
||||
args,
|
||||
currentArgIndex,
|
||||
isTypingSeparator,
|
||||
hasSpaceAfterIdentifier,
|
||||
hasSpaceArgContent: spaceArgText.length > 0,
|
||||
separatorCount: separatorPositions.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { onlyUnique, regexFromString, resetScrollHeight } from './utils.js';
|
||||
|
||||
/**
|
||||
* @type {any[]} Instruct mode presets.
|
||||
* @type {InstructSettings[]} Instruct mode presets.
|
||||
*/
|
||||
export let instruct_presets = [];
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { seedrandom, droll } from '../../../lib.js';
|
||||
import { chat_metadata, main_api, getMaxContextSize, extension_prompts, getCurrentChatId } from '../../../script.js';
|
||||
import { getStringHash } from '../../utils.js';
|
||||
import { getStringHash, isFalseBoolean } from '../../utils.js';
|
||||
import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js';
|
||||
import { inject_ids } from '../../constants.js';
|
||||
import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
|
||||
import { MacroEngine } from '../engine/MacroEngine.js';
|
||||
|
||||
/**
|
||||
* Marker used by {{else}} to split content in {{if}} blocks.
|
||||
* Uses control characters to minimize collision with real content.
|
||||
*
|
||||
* This marker is used internally by the macro engine to separate if/else branches.
|
||||
* It should never appear in user-generated content.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const ELSE_MARKER = '\u0000\u001FELSE\u001F\u0000';
|
||||
|
||||
/**
|
||||
* Registers SillyTavern's core built-in macros in the MacroRegistry.
|
||||
@@ -59,11 +71,98 @@ export function registerCoreMacros() {
|
||||
});
|
||||
|
||||
// {{trim}} -> macro will currently replace itself with itself. Trimming is handled in post-processing.
|
||||
// Scoped: {{trim}}content{{/trim}} -> trims whitespace from content (handled by engine auto-trim)
|
||||
MacroRegistry.registerMacro('trim', {
|
||||
category: MacroCategory.UTILITY,
|
||||
description: 'Trims all whitespaces around the trim macro.',
|
||||
description: 'Trims whitespace. Non-scoped: trims newlines around the macro (post-processing). Scoped: returns the content (auto-trimmed by the engine).',
|
||||
unnamedArgs: [
|
||||
{
|
||||
name: 'content',
|
||||
description: 'Content to trim (when used as scoped macro)',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
returns: '',
|
||||
handler: () => '{{trim}}',
|
||||
handler: ({ unnamedArgs: [content], isScoped }) => {
|
||||
// Scoped usage: return content (already auto-trimmed by the engine)
|
||||
if (isScoped) return content ?? '';
|
||||
// Non-scoped: return marker for post-processing regex
|
||||
return '{{trim}}';
|
||||
},
|
||||
});
|
||||
|
||||
// {{if condition}}content{{/if}} -> conditional content
|
||||
// {{if condition}}then-content{{else}}else-content{{/if}} -> conditional with else branch
|
||||
// {{if !condition}}content{{/if}} -> inverted conditional (negated)
|
||||
// Condition can be a macro name (resolved automatically) or any value
|
||||
MacroRegistry.registerMacro('if', {
|
||||
category: MacroCategory.UTILITY,
|
||||
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.',
|
||||
unnamedArgs: [
|
||||
{
|
||||
name: 'condition',
|
||||
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".',
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
description: 'The content to return if condition is truthy (typically provided as scoped content). May contain {{else}} to define an else branch.',
|
||||
},
|
||||
],
|
||||
displayOverride: '{{if condition}}then{{else}}other{{/if}}',
|
||||
exampleUsage: [
|
||||
'{{if description}}# Description\n{{description}}{{/if}}',
|
||||
'{{if charVersion}}{{charVersion}}{{else}}No version{{/if}}',
|
||||
'{{if !personality}}No personality defined{{/if}}',
|
||||
'{{if {{getvar::showHeader}}}}# Header{{/if}}',
|
||||
],
|
||||
returns: 'The content if condition is truthy, else branch or empty string otherwise.',
|
||||
handler: ({ unnamedArgs: [condition, content], rawArgs: [rawCondition], flags, env, trimContent }) => {
|
||||
// Check if the ORIGINAL condition (before macro resolution) starts with !
|
||||
// We use raw args to check this, as the resolved value might start with ! from a variable
|
||||
let inverted = false;
|
||||
if (/^\s*!/.test(rawCondition)) {
|
||||
inverted = true;
|
||||
// Strip the ! from the resolved condition if it was the prefix
|
||||
condition = condition.replace(/^!/, '');
|
||||
}
|
||||
|
||||
// Check if condition is a registered macro name (without braces)
|
||||
// If so, resolve it first (only for macros that accept 0 required args)
|
||||
const macroDef = MacroRegistry.getPrimaryMacro(condition);
|
||||
if (macroDef && macroDef.minArgs === 0) {
|
||||
// Use MacroEngine.evaluate to properly resolve the macro with full context
|
||||
// This ensures all handler args (cst, normalize, list, etc.) are correctly provided
|
||||
condition = MacroEngine.evaluate(`{{${condition}}}`, env);
|
||||
}
|
||||
|
||||
// Check if condition is falsy: empty string or isFalseBoolean
|
||||
let isFalsy = condition === '' || isFalseBoolean(condition);
|
||||
if (inverted) isFalsy = !isFalsy;
|
||||
|
||||
// Split content on else marker (if present)
|
||||
const [thenBranch, elseBranch] = content.split(ELSE_MARKER);
|
||||
const result = !isFalsy ? thenBranch : elseBranch;
|
||||
|
||||
// Trim branches unless # flag is set (preserveWhitespace)
|
||||
// The engine auto-trims the whole scoped content, but we still need to trim
|
||||
// around the {{else}} marker since that's internal to this macro
|
||||
if (flags.preserveWhitespace) {
|
||||
return result ?? '';
|
||||
}
|
||||
return trimContent(result ?? '');
|
||||
},
|
||||
});
|
||||
|
||||
// {{else}} -> marker for else branch inside {{if}} blocks
|
||||
// Only meaningful inside a scoped {{if}} macro
|
||||
MacroRegistry.registerMacro('else', {
|
||||
category: MacroCategory.UTILITY,
|
||||
description: 'Marks the else branch inside a scoped {{if}} block. Only works inside {{if}}...{{/if}}. If used outside, returns an invisible marker.',
|
||||
exampleUsage: [
|
||||
'{{if condition}}true branch{{else}}false branch{{/if}}',
|
||||
],
|
||||
returns: 'Invisible marker (consumed by the enclosing {{if}} macro).',
|
||||
handler: () => ELSE_MARKER,
|
||||
});
|
||||
|
||||
// {{input}} -> current textarea content
|
||||
@@ -101,7 +200,7 @@ export function registerCoreMacros() {
|
||||
|
||||
// Comment macro: {{// ...}} -> '' (consumes any arguments)
|
||||
MacroRegistry.registerMacro('//', {
|
||||
aliases: [{ alias: 'comment' }],
|
||||
aliases: [{ alias: 'comment', visible: false }],
|
||||
category: MacroCategory.UTILITY,
|
||||
list: true, // We consume any arguments as if this is a list, but we'll ignore them in the handler anyway
|
||||
strictArgs: false, // and we also always remove it, even if the parsing might say it's invalid
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
/** @typedef {import('chevrotain').CstNode} CstNode */
|
||||
/** @typedef {import('chevrotain').IToken} IToken */
|
||||
/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
|
||||
/** @typedef {import('./MacroFlags.js').MacroFlags} MacroFlags */
|
||||
|
||||
import { parseFlags, createEmptyFlags, MacroFlagType } from './MacroFlags.js';
|
||||
import { MacroParser } from './MacroParser.js';
|
||||
import { MacroRegistry } from './MacroRegistry.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} MacroCall
|
||||
* @property {string} name
|
||||
* @property {string[]} args
|
||||
* @property {MacroFlags} flags - Parsed macro execution flags.
|
||||
* @property {boolean} isScoped - Whether this macro was invoked using scoped syntax (opening + closing tags).
|
||||
* @property {MacroEnv} env
|
||||
* @property {string} rawInner
|
||||
* @property {string} rawWithBraces
|
||||
* @property {string[]} rawArgs
|
||||
* @property {{ startOffset: number, endOffset: number }} range
|
||||
* @property {CstNode} cstNode
|
||||
*/
|
||||
@@ -18,6 +26,7 @@
|
||||
* @property {string} text
|
||||
* @property {MacroEnv} env
|
||||
* @property {(call: MacroCall) => string} resolveMacro
|
||||
* @property {(content: string, options?: { trimIndent?: boolean }) => string} trimContent - Shared utility function that trims scoped content with optional indentation dedent.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -47,7 +56,7 @@ class MacroCstWalker {
|
||||
* @returns {string}
|
||||
*/
|
||||
evaluateDocument(options) {
|
||||
const { text, cst, env, resolveMacro } = options;
|
||||
const { text, cst, env, resolveMacro, trimContent } = options;
|
||||
|
||||
if (typeof text !== 'string') {
|
||||
throw new Error('MacroCstWalker.evaluateDocument: text must be a string');
|
||||
@@ -58,10 +67,16 @@ class MacroCstWalker {
|
||||
if (typeof resolveMacro !== 'function') {
|
||||
throw new Error('MacroCstWalker.evaluateDocument: resolveMacro must be a function');
|
||||
}
|
||||
if (typeof trimContent !== 'function') {
|
||||
throw new Error('MacroCstWalker.evaluateDocument: trimContent must be a function');
|
||||
}
|
||||
|
||||
/** @type {EvaluationContext} */
|
||||
const context = { text, env, resolveMacro };
|
||||
const items = this.#collectDocumentItems(cst);
|
||||
const context = { text, env, resolveMacro, trimContent };
|
||||
let items = this.#collectDocumentItems(cst);
|
||||
|
||||
// Process scoped macros: find opening/closing pairs and merge them
|
||||
items = this.#processScopedMacros(items, text);
|
||||
|
||||
if (items.length === 0) {
|
||||
return text;
|
||||
@@ -79,11 +94,20 @@ class MacroCstWalker {
|
||||
// Items can be either plaintext or macro nodes
|
||||
if (item.type === 'plaintext') {
|
||||
result += text.slice(item.startOffset, item.endOffset + 1);
|
||||
cursor = item.endOffset + 1;
|
||||
} else if (item.keepRaw) {
|
||||
// Unmatched closing macros stay as raw text
|
||||
result += text.slice(item.startOffset, item.endOffset + 1);
|
||||
cursor = item.endOffset + 1;
|
||||
} else {
|
||||
result += this.#evaluateMacroNode(item.node, context);
|
||||
result += this.#evaluateMacroNode(item.node, context, item.scopedContent);
|
||||
// If this macro has scoped content, skip past the closing macro
|
||||
if (item.scopedContent && item.scopedContent.closingEndOffset > item.endOffset) {
|
||||
cursor = item.scopedContent.closingEndOffset + 1;
|
||||
} else {
|
||||
cursor = item.endOffset + 1;
|
||||
}
|
||||
}
|
||||
|
||||
cursor = item.endOffset + 1;
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
@@ -93,8 +117,59 @@ class MacroCstWalker {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds unclosed scoped macros in a document CST.
|
||||
* Used by autocomplete to suggest closing tags.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.text - The document text.
|
||||
* @param {CstNode} options.cst - The parsed CST.
|
||||
* @returns {Array<{ name: string, startOffset: number, endOffset: number }>} - Array of unclosed macro info, innermost last.
|
||||
*/
|
||||
findUnclosedScopes(options) {
|
||||
const { text, cst } = options;
|
||||
|
||||
if (typeof text !== 'string' || !cst?.children) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let items = this.#collectDocumentItems(cst);
|
||||
// Don't process scoped macros - we want to find the raw opening/closing pairs
|
||||
// Just extract macro info and find unmatched openers
|
||||
|
||||
/** @type {Array<{ name: string, startOffset: number, endOffset: number }>} */
|
||||
const unclosedStack = [];
|
||||
|
||||
// Extract macro names and closing status
|
||||
for (const item of items) {
|
||||
if (item.type !== 'macro') continue;
|
||||
|
||||
const info = this.#extractMacroInfo(item.node);
|
||||
if (!info) continue;
|
||||
|
||||
if (info.isClosing) {
|
||||
// Closing tag - pop matching opener from stack
|
||||
if (unclosedStack.length > 0 && unclosedStack[unclosedStack.length - 1].name === info.name) {
|
||||
unclosedStack.pop();
|
||||
}
|
||||
// If no matching opener, ignore (orphan closing tag)
|
||||
} else {
|
||||
// Opening tag - check if this macro can accept scoped content
|
||||
if (this.#canAcceptScopedContent(item.node, info.name)) {
|
||||
unclosedStack.push({
|
||||
name: info.name,
|
||||
startOffset: item.startOffset,
|
||||
endOffset: item.endOffset,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unclosedStack;
|
||||
}
|
||||
|
||||
/** @typedef {{ type: 'plaintext', startOffset: number, endOffset: number, token: IToken }} DocumentItemPlaintext */
|
||||
/** @typedef {{ type: 'macro', startOffset: number, endOffset: number, node: CstNode }} DocumentItemMacro */
|
||||
/** @typedef {{ type: 'macro', startOffset: number, endOffset: number, node: CstNode, scopedContent?: { startOffset: number, endOffset: number, closingEndOffset: number }, keepRaw?: boolean }} DocumentItemMacro */
|
||||
/** @typedef {DocumentItemPlaintext | DocumentItemMacro} DocumentItem */
|
||||
|
||||
/**
|
||||
@@ -158,15 +233,21 @@ class MacroCstWalker {
|
||||
*
|
||||
* @param {CstNode} macroNode
|
||||
* @param {EvaluationContext} context
|
||||
* @param {{ startOffset: number, endOffset: number, closingEndOffset: number }} [scopedContent] - Optional scoped content range for block macros.
|
||||
* @returns {string}
|
||||
*/
|
||||
#evaluateMacroNode(macroNode, context) {
|
||||
const { text, env, resolveMacro } = context;
|
||||
#evaluateMacroNode(macroNode, context, scopedContent) {
|
||||
const { text, env, resolveMacro, trimContent } = context;
|
||||
|
||||
const children = macroNode.children || {};
|
||||
const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []);
|
||||
const name = identifierTokens[0]?.image || '';
|
||||
|
||||
// Extract flag tokens and parse them into a MacroFlags object
|
||||
const flagTokens = /** @type {IToken[]} */ (children['flags'] || []);
|
||||
const flagSymbols = flagTokens.map(token => token.image);
|
||||
const flags = flagSymbols.length > 0 ? parseFlags(flagSymbols) : createEmptyFlags();
|
||||
|
||||
const range = this.#getMacroRange(macroNode);
|
||||
const startToken = /** @type {IToken?} */ ((children['Macro.Start'] || [])[0]);
|
||||
const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]);
|
||||
@@ -182,6 +263,8 @@ class MacroCstWalker {
|
||||
const args = [];
|
||||
/** @type {({ value: string } & TokenRange)[]} */
|
||||
const evaluatedArguments = [];
|
||||
/** @type {string[]} */
|
||||
const rawArgs = [];
|
||||
|
||||
for (const argNode of argumentNodes) {
|
||||
const argValue = this.#evaluateArgumentNode(argNode, context);
|
||||
@@ -194,6 +277,32 @@ class MacroCstWalker {
|
||||
...location,
|
||||
});
|
||||
}
|
||||
|
||||
rawArgs.push(location ? text.slice(location.startOffset, location.endOffset + 1) : '');
|
||||
}
|
||||
|
||||
// If this macro has scoped content, evaluate it and append as the last argument
|
||||
if (scopedContent) {
|
||||
// Handle empty scoped content (when opening and closing are adjacent)
|
||||
if (scopedContent.startOffset > scopedContent.endOffset) {
|
||||
args.push('');
|
||||
} else {
|
||||
let scopedValue = this.#evaluateScopedContent(scopedContent, context);
|
||||
|
||||
// Auto-trim scoped content unless the '#' (preserveWhitespace) flag is set
|
||||
if (!flags.preserveWhitespace) {
|
||||
scopedValue = trimContent(scopedValue);
|
||||
}
|
||||
|
||||
args.push(scopedValue);
|
||||
|
||||
// Add to evaluated arguments for rawInner reconstruction
|
||||
evaluatedArguments.push({
|
||||
value: scopedValue,
|
||||
startOffset: scopedContent.startOffset,
|
||||
endOffset: scopedContent.endOffset,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
evaluatedArguments.sort((a, b) => a.startOffset - b.startOffset);
|
||||
@@ -223,8 +332,11 @@ class MacroCstWalker {
|
||||
const call = {
|
||||
name,
|
||||
args,
|
||||
flags,
|
||||
isScoped: scopedContent != null,
|
||||
rawInner,
|
||||
rawWithBraces: text.slice(range.startOffset, range.endOffset + 1),
|
||||
rawArgs,
|
||||
range,
|
||||
cstNode: macroNode,
|
||||
env,
|
||||
@@ -428,6 +540,307 @@ class MacroCstWalker {
|
||||
#isCstNode(value) {
|
||||
return !!value && typeof value === 'object' && 'name' in value && 'children' in value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates scoped content between an opening and closing macro tag.
|
||||
* This resolves any nested macros within the scoped content.
|
||||
*
|
||||
* @param {{ startOffset: number, endOffset: number }} scopedContent - The range of the scoped content.
|
||||
* @param {EvaluationContext} context - The evaluation context.
|
||||
* @returns {string} - The evaluated scoped content with nested macros resolved.
|
||||
*/
|
||||
#evaluateScopedContent(scopedContent, context) {
|
||||
const { text, env, resolveMacro, trimContent } = context;
|
||||
const { startOffset, endOffset } = scopedContent;
|
||||
|
||||
// Extract the raw content between opening and closing tags
|
||||
const rawContent = text.slice(startOffset, endOffset + 1);
|
||||
|
||||
// If empty, return empty string
|
||||
if (!rawContent) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Re-evaluate the scoped content to resolve any nested macros
|
||||
// We need to parse and evaluate this content as if it were a standalone document
|
||||
const { cst: scopedCst } = MacroParser.parseDocument(rawContent);
|
||||
|
||||
// If parsing fails, return the raw content
|
||||
if (!scopedCst || typeof scopedCst !== 'object' || !scopedCst.children) {
|
||||
return rawContent;
|
||||
}
|
||||
|
||||
// Create a new context with the scoped content text
|
||||
/** @type {EvaluationContext} */
|
||||
const scopedContext = { text: rawContent, env, resolveMacro, trimContent };
|
||||
|
||||
// Collect items from the scoped content CST
|
||||
let items = this.#collectDocumentItems(scopedCst);
|
||||
|
||||
// Process any nested scoped macros within this content
|
||||
items = this.#processScopedMacros(items, rawContent);
|
||||
|
||||
// Evaluate the items
|
||||
if (items.length === 0) {
|
||||
return rawContent;
|
||||
}
|
||||
|
||||
let result = '';
|
||||
let cursor = 0;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.startOffset > cursor) {
|
||||
result += rawContent.slice(cursor, item.startOffset);
|
||||
}
|
||||
|
||||
if (item.type === 'plaintext') {
|
||||
result += rawContent.slice(item.startOffset, item.endOffset + 1);
|
||||
cursor = item.endOffset + 1;
|
||||
} else if (item.keepRaw) {
|
||||
// Unmatched closing macros stay as raw text
|
||||
result += rawContent.slice(item.startOffset, item.endOffset + 1);
|
||||
cursor = item.endOffset + 1;
|
||||
} else {
|
||||
result += this.#evaluateMacroNode(item.node, scopedContext, item.scopedContent);
|
||||
// If this macro has scoped content, skip past the closing macro
|
||||
if (item.scopedContent && item.scopedContent.closingEndOffset > item.endOffset) {
|
||||
cursor = item.scopedContent.closingEndOffset + 1;
|
||||
} else {
|
||||
cursor = item.endOffset + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cursor < rawContent.length) {
|
||||
result += rawContent.slice(cursor);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Scoped Macro Processing
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Processes document items to find and merge scoped macro pairs.
|
||||
* A scoped macro is an opening macro followed by content and a closing macro.
|
||||
* Example: `{{setvar::myvar}}content{{/setvar}}` becomes `{{setvar::myvar::content}}`
|
||||
*
|
||||
* The closing macro has the `closingBlock` flag (`/`) and the same identifier.
|
||||
* Everything between the opening and closing macros becomes the last unnamed argument.
|
||||
*
|
||||
* @param {Array<DocumentItem>} items - The collected document items.
|
||||
* @param {string} text - The original document text.
|
||||
* @returns {Array<DocumentItem>} - The processed items with scoped macros merged.
|
||||
*/
|
||||
#processScopedMacros(items, text) {
|
||||
// Build a list of scoped macro info for each macro item
|
||||
/** @type {Array<{ index: number, item: DocumentItemMacro, name: string, isClosing: boolean, matched: boolean }>} */
|
||||
const macroInfos = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type !== 'macro') continue;
|
||||
|
||||
const info = this.#extractMacroInfo(item.node);
|
||||
if (!info) continue;
|
||||
|
||||
macroInfos.push({
|
||||
index: i,
|
||||
item,
|
||||
name: info.name,
|
||||
isClosing: info.isClosing,
|
||||
matched: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Find matching pairs - only process OUTERMOST scopes at this level
|
||||
// Nested scopes will be discovered when parent's scoped content is re-parsed
|
||||
/** @type {Array<{ openingIndex: number, closingIndex: number }>} */
|
||||
const pairs = [];
|
||||
|
||||
// Track ranges that are inside a scope (to skip nested openers)
|
||||
/** @type {Set<number>} */
|
||||
const insideScope = new Set();
|
||||
|
||||
for (let i = 0; i < macroInfos.length; i++) {
|
||||
const openInfo = macroInfos[i];
|
||||
|
||||
// Skip closing macros, already matched macros, or macros inside another scope
|
||||
if (openInfo.isClosing || openInfo.matched || insideScope.has(openInfo.index)) continue;
|
||||
|
||||
// Find the matching closing macro for this opening macro
|
||||
const closingIdx = this.#findMatchingClosingMacro(macroInfos, i);
|
||||
if (closingIdx === -1) continue;
|
||||
|
||||
// Check if the macro can accept scoped content (arity validation)
|
||||
if (!this.#canAcceptScopedContent(openInfo.item.node, openInfo.name)) {
|
||||
// Macro cannot accept scoped content - mark both as keepRaw
|
||||
openInfo.item.keepRaw = true;
|
||||
macroInfos[closingIdx].item.keepRaw = true;
|
||||
// Mark as matched so they won't be processed again
|
||||
openInfo.matched = true;
|
||||
macroInfos[closingIdx].matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark both as matched
|
||||
openInfo.matched = true;
|
||||
macroInfos[closingIdx].matched = true;
|
||||
|
||||
const closingIndex = macroInfos[closingIdx].index;
|
||||
|
||||
pairs.push({
|
||||
openingIndex: openInfo.index,
|
||||
closingIndex: closingIndex,
|
||||
});
|
||||
|
||||
// Mark all items between this pair as inside a scope
|
||||
// They will be processed when the scoped content is re-parsed
|
||||
for (let j = openInfo.index + 1; j < closingIndex; j++) {
|
||||
insideScope.add(j);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark unmatched closing macros as keepRaw so they stay as raw text
|
||||
for (const info of macroInfos) {
|
||||
if (info.isClosing && !info.matched) {
|
||||
info.item.keepRaw = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If no pairs found, return items (with unmatched closings marked as raw)
|
||||
if (pairs.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// Process pairs: merge content into opening macro's scopedContent field
|
||||
|
||||
// Track which items to remove (closing macros and intermediate content items)
|
||||
/** @type {Set<number>} */
|
||||
const itemsToRemove = new Set();
|
||||
|
||||
for (const pair of pairs) {
|
||||
const openingItem = /** @type {DocumentItemMacro} */ (items[pair.openingIndex]);
|
||||
const closingItem = /** @type {DocumentItemMacro} */ (items[pair.closingIndex]);
|
||||
|
||||
// Collect content between opening and closing (exclusive)
|
||||
const contentStart = openingItem.endOffset + 1;
|
||||
const contentEnd = closingItem.startOffset - 1;
|
||||
|
||||
// Store the scoped content range on the opening macro item
|
||||
// This will be used during macro evaluation to append the content as the last argument
|
||||
openingItem.scopedContent = {
|
||||
startOffset: contentStart,
|
||||
endOffset: contentEnd,
|
||||
closingEndOffset: closingItem.endOffset,
|
||||
};
|
||||
|
||||
// Mark closing macro for removal
|
||||
itemsToRemove.add(pair.closingIndex);
|
||||
|
||||
// Mark ALL intermediate items between opening and closing for removal
|
||||
// They will be captured as raw scoped content and re-parsed during evaluation
|
||||
for (let j = pair.openingIndex + 1; j < pair.closingIndex; j++) {
|
||||
itemsToRemove.add(j);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out removed items
|
||||
return items.filter((_, index) => !itemsToRemove.has(index));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts macro name and closing flag status from a macro node.
|
||||
*
|
||||
* @param {CstNode} macroNode
|
||||
* @returns {{ name: string, isClosing: boolean } | null}
|
||||
*/
|
||||
#extractMacroInfo(macroNode) {
|
||||
const children = macroNode.children || {};
|
||||
const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []);
|
||||
const name = identifierTokens[0]?.image || '';
|
||||
|
||||
if (!name) return null;
|
||||
|
||||
// Check for closing block flag
|
||||
const flagTokens = /** @type {IToken[]} */ (children['flags'] || []);
|
||||
const isClosing = flagTokens.some(token => token.image === MacroFlagType.CLOSING_BLOCK);
|
||||
|
||||
return { name, isClosing };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a macro can accept scoped content as an additional argument.
|
||||
* Returns true if adding one more argument would result in valid arity.
|
||||
*
|
||||
* @param {CstNode} macroNode - The macro CST node.
|
||||
* @param {string} macroName - The macro name.
|
||||
* @returns {boolean} - True if scoped content is allowed.
|
||||
*/
|
||||
#canAcceptScopedContent(macroNode, macroName) {
|
||||
const def = MacroRegistry.getPrimaryMacro(macroName);
|
||||
if (!def) {
|
||||
// Unknown macro - allow scoped content (will be handled as unknown macro later)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Count current arguments in the macro
|
||||
const children = macroNode.children || {};
|
||||
const argumentsNode = /** @type {CstNode?} */ ((children.arguments || [])[0]);
|
||||
const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
|
||||
const currentArgCount = argumentNodes.length;
|
||||
|
||||
// Check if adding 1 more argument (scoped content) would be valid
|
||||
const newArgCount = currentArgCount + 1;
|
||||
|
||||
// Macro must accept at least newArgCount arguments
|
||||
// For macros with list args, they can accept unlimited after maxArgs
|
||||
if (def.list) {
|
||||
// With list: valid if newArgCount >= minArgs (list can absorb extra)
|
||||
return newArgCount >= def.minArgs;
|
||||
}
|
||||
|
||||
// Without list: newArgCount must be between minArgs and maxArgs
|
||||
return newArgCount >= def.minArgs && newArgCount <= def.maxArgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the matching closing macro for an opening macro at the given index.
|
||||
* Handles nested scopes by tracking depth.
|
||||
*
|
||||
* @param {Array<{ index: number, item: DocumentItemMacro, name: string, isClosing: boolean, matched: boolean }>} macroInfos
|
||||
* @param {number} openingIdx - Index in macroInfos array of the opening macro.
|
||||
* @returns {number} - Index in macroInfos array of the matching closing macro, or -1 if not found.
|
||||
*/
|
||||
#findMatchingClosingMacro(macroInfos, openingIdx) {
|
||||
const openInfo = macroInfos[openingIdx];
|
||||
const targetName = openInfo.name;
|
||||
let depth = 1;
|
||||
|
||||
for (let i = openingIdx + 1; i < macroInfos.length; i++) {
|
||||
const info = macroInfos[i];
|
||||
|
||||
// Only consider macros with the same name
|
||||
if (info.name !== targetName) continue;
|
||||
|
||||
// Skip already matched macros
|
||||
if (info.matched) continue;
|
||||
|
||||
if (info.isClosing) {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return i;
|
||||
}
|
||||
} else {
|
||||
// Another opening macro with the same name increases depth
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
|
||||
return -1; // No matching closing macro found
|
||||
}
|
||||
}
|
||||
|
||||
instance = MacroCstWalker.instance;
|
||||
|
||||
@@ -2,11 +2,28 @@ import { MacroParser } from './MacroParser.js';
|
||||
import { MacroCstWalker } from './MacroCstWalker.js';
|
||||
import { MacroRegistry } from './MacroRegistry.js';
|
||||
import { logMacroGeneralError, logMacroInternalError, logMacroRuntimeWarning, logMacroSyntaxWarning } from './MacroDiagnostics.js';
|
||||
import { ELSE_MARKER } from '../definitions/core-macros.js';
|
||||
|
||||
/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
|
||||
/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
|
||||
/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
|
||||
|
||||
/**
|
||||
* A processor function that transforms text before or after macro evaluation.
|
||||
*
|
||||
* @callback MacroProcessor
|
||||
* @param {string} text - The text to process.
|
||||
* @param {MacroEnv} env - The macro environment.
|
||||
* @returns {string} The processed text.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RegisteredProcessor
|
||||
* @property {MacroProcessor} handler - The processor function.
|
||||
* @property {number} priority - Execution priority (lower = earlier).
|
||||
* @property {string} source - Identifier for debugging/tracking.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The singleton instance of the MacroEngine.
|
||||
*
|
||||
@@ -19,7 +36,71 @@ class MacroEngine {
|
||||
/** @type {MacroEngine} */ static #instance;
|
||||
/** @type {MacroEngine} */ static get instance() { return MacroEngine.#instance ?? (MacroEngine.#instance = new MacroEngine()); }
|
||||
|
||||
constructor() { }
|
||||
/** @type {RegisteredProcessor[]} */
|
||||
#preProcessors = [];
|
||||
/** @type {RegisteredProcessor[]} */
|
||||
#postProcessors = [];
|
||||
|
||||
constructor() {
|
||||
this.#registerCorePreProcessors();
|
||||
this.#registerCorePostProcessors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a pre-processor to run before macro evaluation.
|
||||
*
|
||||
* @param {MacroProcessor} handler - The processor function.
|
||||
* @param {Object} [options] - Configuration options.
|
||||
* @param {number} [options.priority=100] - Execution priority (lower = earlier).
|
||||
* @param {string} [options.source='unknown'] - Identifier for debugging.
|
||||
*/
|
||||
addPreProcessor(handler, { priority = 100, source = 'unknown' } = {}) {
|
||||
this.#preProcessors.push({ handler, priority, source });
|
||||
this.#preProcessors.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a previously registered pre-processor.
|
||||
*
|
||||
* @param {MacroProcessor} handler - The processor function to remove.
|
||||
* @returns {boolean} True if the processor was found and removed.
|
||||
*/
|
||||
removePreProcessor(handler) {
|
||||
const index = this.#preProcessors.findIndex(p => p.handler === handler);
|
||||
if (index !== -1) {
|
||||
this.#preProcessors.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a post-processor to run after macro evaluation.
|
||||
*
|
||||
* @param {MacroProcessor} handler - The processor function.
|
||||
* @param {Object} [options] - Configuration options.
|
||||
* @param {number} [options.priority=100] - Execution priority (lower = earlier).
|
||||
* @param {string} [options.source='unknown'] - Identifier for debugging.
|
||||
*/
|
||||
addPostProcessor(handler, { priority = 100, source = 'unknown' } = {}) {
|
||||
this.#postProcessors.push({ handler, priority, source });
|
||||
this.#postProcessors.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a previously registered post-processor.
|
||||
*
|
||||
* @param {MacroProcessor} handler - The processor function to remove.
|
||||
* @returns {boolean} True if the processor was found and removed.
|
||||
*/
|
||||
removePostProcessor(handler) {
|
||||
const index = this.#postProcessors.findIndex(p => p.handler === handler);
|
||||
if (index !== -1) {
|
||||
this.#postProcessors.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a string containing macros and resolves them.
|
||||
@@ -59,6 +140,7 @@ class MacroEngine {
|
||||
cst,
|
||||
env: safeEnv,
|
||||
resolveMacro: this.#resolveMacro.bind(this),
|
||||
trimContent: this.trimScopedContent.bind(this),
|
||||
});
|
||||
} catch (error) {
|
||||
logMacroGeneralError({ message: 'Macro evaluation failed. Returning original input.', error: { input, error } });
|
||||
@@ -142,21 +224,9 @@ class MacroEngine {
|
||||
*/
|
||||
#runPreProcessors(text, env) {
|
||||
let result = text;
|
||||
|
||||
// This legacy macro will not be supported by the new macro parser, but rather regex-replaced beforehand
|
||||
// {{time_UTC-10}} => {{time::UTC-10}}
|
||||
result = result.replace(/{{time_(UTC[+-]\d+)}}/gi, (_match, utcOffset) => {
|
||||
return `{{time::${utcOffset}}}`;
|
||||
});
|
||||
|
||||
// Legacy non-curly markers like <USER>, <BOT>, <GROUP>, etc.
|
||||
// These are rewritten into their equivalent macro forms so they go through the normal engine pipeline.
|
||||
result = result.replace(/<USER>/gi, '{{user}}');
|
||||
result = result.replace(/<BOT>/gi, '{{char}}');
|
||||
result = result.replace(/<CHAR>/gi, '{{char}}');
|
||||
result = result.replace(/<GROUP>/gi, '{{group}}');
|
||||
result = result.replace(/<CHARIFNOTGROUP>/gi, '{{charIfNotGroup}}');
|
||||
|
||||
for (const { handler } of this.#preProcessors) {
|
||||
result = handler(result, env);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -169,18 +239,65 @@ class MacroEngine {
|
||||
*/
|
||||
#runPostProcessors(text, env) {
|
||||
let result = text;
|
||||
for (const { handler } of this.#postProcessors) {
|
||||
result = handler(result, env);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the core pre/post processors that handle legacy syntax and cleanup.
|
||||
*/
|
||||
#registerCorePreProcessors() {
|
||||
// Pre-processors (priority 0-50 reserved for core)
|
||||
|
||||
// This legacy macro will not be supported by the new macro parser, but rather regex-replaced beforehand
|
||||
// {{time_UTC-10}} => {{time::UTC-10}}
|
||||
this.addPreProcessor(
|
||||
text => text.replace(/{{time_(UTC[+-]\d+)}}/gi, (_match, utcOffset) => `{{time::${utcOffset}}}`),
|
||||
{ priority: 10, source: 'core:legacy-time-syntax' },
|
||||
);
|
||||
|
||||
// Legacy non-curly markers like <USER>, <BOT>, <GROUP>, etc.
|
||||
// These are rewritten into their equivalent macro forms so they go through the normal engine pipeline.
|
||||
this.addPreProcessor(
|
||||
text => text
|
||||
.replace(/<USER>/gi, '{{user}}')
|
||||
.replace(/<BOT>/gi, '{{char}}')
|
||||
.replace(/<CHAR>/gi, '{{char}}')
|
||||
.replace(/<GROUP>/gi, '{{group}}')
|
||||
.replace(/<CHARIFNOTGROUP>/gi, '{{charIfNotGroup}}'),
|
||||
{ priority: 20, source: 'core:legacy-markers' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the core post-processors that handle legacy syntax and cleanup.
|
||||
*/
|
||||
#registerCorePostProcessors() {
|
||||
// Post-processors (priority 0-50 reserved for core)
|
||||
|
||||
// Unescape braces: \{ → { and \} → }
|
||||
// Since \{\{ doesn't match {{ (MacroStart), it passes through as plain text.
|
||||
// We only need to remove the backslashes in post-processing.
|
||||
result = result.replace(/\\([{}])/g, '$1');
|
||||
this.addPostProcessor(
|
||||
text => text.replace(/\\([{}])/g, '$1'),
|
||||
{ priority: 10, source: 'core:unescape-braces' },
|
||||
);
|
||||
|
||||
// The original trim macro is reaching over the boundaries of the defined macro. This is not something the engine supports.
|
||||
// To treat {{trim}} as it was before, we won't process it by the engine itself,
|
||||
// but doing a regex replace on {{trim}} and the surrounding area, after all other macros have been processed.
|
||||
result = result.replace(/(?:\r?\n)*{{trim}}(?:\r?\n)*/gi, '');
|
||||
this.addPostProcessor(
|
||||
text => text.replace(/(?:\r?\n)*{{trim}}(?:\r?\n)*/gi, ''),
|
||||
{ priority: 20, source: 'core:legacy-trim' },
|
||||
);
|
||||
|
||||
return result;
|
||||
// Remove any wrongly placed leftover ELSE_MARKER that might have been inserted during processing
|
||||
this.addPostProcessor(
|
||||
text => text.replaceAll(ELSE_MARKER, ''),
|
||||
{ priority: 30, source: 'core:cleanup-else-marker' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,6 +324,71 @@ class MacroEngine {
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims scoped content with optional indentation dedent.
|
||||
*
|
||||
* When trimIndent is true (default), this function:
|
||||
* 1. Trims leading and trailing whitespace (like String.trim())
|
||||
* 2. Finds the indentation of the first non-empty line
|
||||
* 3. Removes that amount of leading whitespace from all subsequent lines
|
||||
*
|
||||
* This allows neatly formatted scoped macros like:
|
||||
* ```
|
||||
* {{if condition}}
|
||||
* # Heading
|
||||
* Content here
|
||||
* {{/if}}
|
||||
* ```
|
||||
* To produce "# Heading\nContent here" instead of "# Heading\n Content here"
|
||||
*
|
||||
* @param {string} content - The content to trim
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {boolean} [options.trimIndent=true] - Whether to also dedent consistent indentation
|
||||
* @returns {string} The trimmed content
|
||||
*/
|
||||
trimScopedContent(content, { trimIndent = true } = {}) {
|
||||
if (!content) return '';
|
||||
|
||||
// If not dedenting, just do a basic trim
|
||||
if (!trimIndent) {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
// Split into lines BEFORE trimming to preserve indentation info
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the first non-empty line (has non-whitespace characters)
|
||||
let baseIndent = 0;
|
||||
for (const line of lines) {
|
||||
if (line.trim() !== '') {
|
||||
// Found first non-empty line - get its indentation
|
||||
const match = line.match(/^[ \t]*/);
|
||||
baseIndent = match ? match[0].length : 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no indentation to remove, just trim and return
|
||||
if (baseIndent === 0) {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
// Remove the base indentation from ALL lines
|
||||
const dedentedLines = lines.map(line => {
|
||||
// Only remove indentation if the line has enough leading whitespace
|
||||
const match = line.match(/^[ \t]*/);
|
||||
const lineIndent = match ? match[0].length : 0;
|
||||
if (lineIndent >= baseIndent) {
|
||||
return line.slice(baseIndent);
|
||||
}
|
||||
// Line has less indentation than base - just trim its leading whitespace
|
||||
return line.trimStart();
|
||||
});
|
||||
|
||||
// Join and trim the final result
|
||||
return dedentedLines.join('\n').trim();
|
||||
}
|
||||
}
|
||||
|
||||
instance = MacroEngine.instance;
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Macro Execution Flags - modifiers that change how macros are resolved at runtime.
|
||||
*
|
||||
* Flags are special symbols placed between the opening braces `{{` and the macro identifier.
|
||||
* Example: `{{!user}}` - the `!` is an "immediate resolve" flag.
|
||||
*
|
||||
* Multiple flags can be combined: `{{!?myMacro}}` or `{{ ! ? myMacro }}`
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} MacroFlags
|
||||
* @property {boolean} immediate - Whether the immediate (`!`) flag is set.
|
||||
* @property {boolean} delayed - Whether the delayed (`?`) flag is set.
|
||||
* @property {boolean} reevaluate - Whether the re-evaluate (`~`) flag is set.
|
||||
* @property {boolean} filter - Whether the filter (`>`) flag is set.
|
||||
* @property {boolean} closingBlock - Whether the closing block (`/`) flag is set.
|
||||
* @property {boolean} preserveWhitespace - Whether the preserve whitespace (`#`) flag is set.
|
||||
* @property {boolean} varDot - Whether the variable dot (`.`) flag is set.
|
||||
* @property {boolean} varDollar - Whether the variable dollar (`$`) flag is set.
|
||||
* @property {string[]} raw - The raw flag symbols in order of appearance.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Enum of all recognized macro execution flags.
|
||||
*
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const MacroFlagType = Object.freeze({
|
||||
/**
|
||||
* Immediate resolve flag (`!`).
|
||||
* This macro will be resolved first (in order of appearance) before "normal" macros.
|
||||
* @status TBD - Not implemented in v1
|
||||
*/
|
||||
IMMEDIATE: '!',
|
||||
|
||||
/**
|
||||
* Delayed resolve flag (`?`).
|
||||
* This macro will be resolved last (in order of appearance) after "normal" macros.
|
||||
* @status TBD - Not implemented in v1
|
||||
*/
|
||||
DELAYED: '?',
|
||||
|
||||
/**
|
||||
* Re-evaluate flag (`~`).
|
||||
* Marks a macro for potential re-evaluation.
|
||||
* @status TBD - Not implemented in v1
|
||||
*/
|
||||
REEVALUATE: '~',
|
||||
|
||||
/**
|
||||
* Filter/pipe flag (`>`).
|
||||
* Indicates that this macro should resolve `|` characters as output filters.
|
||||
* @status Parsed - Filter feature not yet implemented
|
||||
*/
|
||||
FILTER: '>',
|
||||
|
||||
/**
|
||||
* Closing block flag (`/`).
|
||||
* Marks this macro as the closing block of a scoped macro with the same identifier.
|
||||
* A closing block macro does not support arguments itself.
|
||||
* Example: `{{setvar::myvar}}long text{{/setvar}}`
|
||||
* @status Implemented - Content between opening and closing tags becomes the last unnamed argument
|
||||
*/
|
||||
CLOSING_BLOCK: '/',
|
||||
|
||||
/**
|
||||
* Preserve whitespace flag (`#`).
|
||||
* Prevents automatic trimming of scoped content.
|
||||
* By default, scoped macro content is trimmed. Use this flag to preserve leading/trailing whitespace.
|
||||
* Also provides backwards compatibility with legacy handlebars-style syntax like `{{#if ...}}`.
|
||||
* Example: `{{#setvar::myvar}} content with spaces {{/setvar}}`
|
||||
* @status Implemented - Prevents auto-trim on scoped content
|
||||
*/
|
||||
PRESERVE_WHITESPACE: '#',
|
||||
|
||||
/**
|
||||
* Variable shorthand flag (`.`).
|
||||
* Shorthand for variable access: `{{.myvar}}` equivalent to `{{getvar::myvar}}`.
|
||||
* @status TBD - Not implemented in v1
|
||||
*/
|
||||
VAR_DOT: '.',
|
||||
|
||||
/**
|
||||
* Variable shorthand flag (`$`).
|
||||
* Alternative shorthand for variable access: `{{$myvar}}`.
|
||||
* @status TBD - Not implemented in v1
|
||||
*/
|
||||
VAR_DOLLAR: '$',
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {Object} MacroFlagDefinition
|
||||
* @property {MacroFlagType} type - The flag type enum value (also the symbol).
|
||||
* @property {string} name - Human-readable name for the flag.
|
||||
* @property {string} description - Description of what the flag does.
|
||||
* @property {boolean} implemented - Whether this flag's behavior is implemented.
|
||||
* @property {boolean} affectsParser - Whether this flag changes parsing behavior (e.g., filter flag).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Definitions for all macro flags with metadata.
|
||||
*
|
||||
* @type {Map<string, MacroFlagDefinition>}
|
||||
*/
|
||||
export const MacroFlagDefinitions = new Map([
|
||||
[MacroFlagType.IMMEDIATE, {
|
||||
type: MacroFlagType.IMMEDIATE,
|
||||
name: 'Immediate',
|
||||
description: 'Resolve this macro before other macros in the same text.',
|
||||
implemented: false,
|
||||
affectsParser: false,
|
||||
}],
|
||||
[MacroFlagType.DELAYED, {
|
||||
type: MacroFlagType.DELAYED,
|
||||
name: 'Delayed',
|
||||
description: 'Resolve this macro after other macros in the same text.',
|
||||
implemented: false,
|
||||
affectsParser: false,
|
||||
}],
|
||||
[MacroFlagType.REEVALUATE, {
|
||||
type: MacroFlagType.REEVALUATE,
|
||||
name: 'Re-evaluate',
|
||||
description: 'Mark this macro for re-evaluation.',
|
||||
implemented: false,
|
||||
affectsParser: false,
|
||||
}],
|
||||
[MacroFlagType.FILTER, {
|
||||
type: MacroFlagType.FILTER,
|
||||
name: 'Filter',
|
||||
description: 'Enable pipe-based output filters for this macro.',
|
||||
implemented: false,
|
||||
affectsParser: true, // Changes how `|` is parsed
|
||||
}],
|
||||
[MacroFlagType.CLOSING_BLOCK, {
|
||||
type: MacroFlagType.CLOSING_BLOCK,
|
||||
name: 'Closing Block',
|
||||
description: 'Marks this as a closing block for a scoped macro.',
|
||||
implemented: true,
|
||||
affectsParser: false,
|
||||
}],
|
||||
[MacroFlagType.PRESERVE_WHITESPACE, {
|
||||
type: MacroFlagType.PRESERVE_WHITESPACE,
|
||||
name: 'Preserve Whitespace',
|
||||
description: 'Prevent automatic trimming of scoped content (legacy # syntax).',
|
||||
implemented: true,
|
||||
affectsParser: false,
|
||||
}],
|
||||
[MacroFlagType.VAR_DOT, {
|
||||
type: MacroFlagType.VAR_DOT,
|
||||
name: 'Variable (dot)',
|
||||
description: 'Shorthand for variable access using dot notation.',
|
||||
implemented: false,
|
||||
affectsParser: false,
|
||||
}],
|
||||
[MacroFlagType.VAR_DOLLAR, {
|
||||
type: MacroFlagType.VAR_DOLLAR,
|
||||
name: 'Variable (dollar)',
|
||||
description: 'Shorthand for variable access using dollar notation.',
|
||||
implemented: false,
|
||||
affectsParser: false,
|
||||
}],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Set of all valid flag symbols for quick lookup.
|
||||
*
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
export const ValidFlagSymbols = new Set(Object.values(MacroFlagType));
|
||||
|
||||
/**
|
||||
* Creates a default MacroFlags object with all flags set to false.
|
||||
*
|
||||
* @returns {MacroFlags}
|
||||
*/
|
||||
export function createEmptyFlags() {
|
||||
return {
|
||||
immediate: false,
|
||||
delayed: false,
|
||||
reevaluate: false,
|
||||
filter: false,
|
||||
closingBlock: false,
|
||||
preserveWhitespace: false,
|
||||
varDot: false,
|
||||
varDollar: false,
|
||||
raw: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an array of flag symbols into a MacroFlags object.
|
||||
*
|
||||
* @param {string[]} flagSymbols - Array of flag symbol strings (e.g., ['!', '?']).
|
||||
* @returns {MacroFlags}
|
||||
*/
|
||||
export function parseFlags(flagSymbols) {
|
||||
const flags = createEmptyFlags();
|
||||
|
||||
for (const symbol of flagSymbols) {
|
||||
switch (symbol) {
|
||||
case MacroFlagType.IMMEDIATE:
|
||||
flags.immediate = true;
|
||||
break;
|
||||
case MacroFlagType.DELAYED:
|
||||
flags.delayed = true;
|
||||
break;
|
||||
case MacroFlagType.REEVALUATE:
|
||||
flags.reevaluate = true;
|
||||
break;
|
||||
case MacroFlagType.FILTER:
|
||||
flags.filter = true;
|
||||
break;
|
||||
case MacroFlagType.CLOSING_BLOCK:
|
||||
flags.closingBlock = true;
|
||||
break;
|
||||
case MacroFlagType.PRESERVE_WHITESPACE:
|
||||
flags.preserveWhitespace = true;
|
||||
break;
|
||||
case MacroFlagType.VAR_DOT:
|
||||
flags.varDot = true;
|
||||
break;
|
||||
case MacroFlagType.VAR_DOLLAR:
|
||||
flags.varDollar = true;
|
||||
break;
|
||||
default:
|
||||
console.warn(`Can't parse unknown macro flag: ${symbol}`);
|
||||
}
|
||||
flags.raw.push(symbol);
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a MacroFlags object has any flags set.
|
||||
*
|
||||
* @param {MacroFlags} flags - The flags object to check.
|
||||
* @returns {boolean} True if at least one flag is set.
|
||||
*/
|
||||
export function hasAnyFlag(flags) {
|
||||
return flags.raw.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the flag definition for a given symbol.
|
||||
*
|
||||
* @param {string} symbol - The flag symbol (e.g., '!').
|
||||
* @returns {MacroFlagDefinition|undefined}
|
||||
*/
|
||||
export function getFlagDefinition(symbol) {
|
||||
return MacroFlagDefinitions.get(symbol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given symbol is a valid macro flag.
|
||||
*
|
||||
* @param {string} symbol - The symbol to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isValidFlag(symbol) {
|
||||
return ValidFlagSymbols.has(symbol);
|
||||
}
|
||||
@@ -3,6 +3,19 @@ const { createToken, Lexer } = chevrotain;
|
||||
|
||||
/** @typedef {import('chevrotain').TokenType} TokenType */
|
||||
|
||||
|
||||
/** Regex for lexer token matching (no anchors). */
|
||||
const IDENTIFIER_LEXER_PATTERN = /[a-zA-Z][\w-_]*/;
|
||||
|
||||
/**
|
||||
* Pattern for valid macro identifiers.
|
||||
* Must start with a letter, followed by word chars (letters, digits, underscore) or hyphens.
|
||||
* Used by both the lexer token and the validation regex.
|
||||
*
|
||||
* Regex for full-string validation (with anchors). Exported for macro registration.
|
||||
*/
|
||||
export const MACRO_IDENTIFIER_PATTERN = /^[a-zA-Z][\w-_]*$/;
|
||||
|
||||
/** @enum {string} */
|
||||
const modes = {
|
||||
plaintext: 'plaintext_mode',
|
||||
@@ -25,9 +38,24 @@ const Tokens = {
|
||||
Start: createToken({ name: 'Macro.Start', pattern: /\{\{/ }),
|
||||
// Separate macro identifier needed, that is similar to the global indentifier, but captures the actual macro "name"
|
||||
// We need this, because this token is going to switch lexer mode, while the general identifier does not.
|
||||
Flags: createToken({ name: 'Macro.Flag', pattern: /[!?#~/.$]/ }),
|
||||
/**
|
||||
* Macro execution flags - special symbols that modify macro resolution behavior.
|
||||
* - `!` = immediate resolve (TBD)
|
||||
* - `?` = delayed resolve (TBD)
|
||||
* - `~` = re-evaluate (TBD)
|
||||
* - `/` = closing block marker for scoped macros
|
||||
* - `#` = preserve whitespace (don't auto-trim scoped content), also legacy handlebars compatibility
|
||||
* - `.` = variable shorthand (TBD)
|
||||
* - `$` = variable shorthand alternative (TBD)
|
||||
*/
|
||||
Flags: createToken({ name: 'Macro.Flag', pattern: /[!?~#/.$]/ }),
|
||||
/**
|
||||
* Filter flag (`>`) - separate token because it changes parsing behavior.
|
||||
* When present, `|` characters inside the macro are treated as filter/pipe operators.
|
||||
*/
|
||||
FilterFlag: createToken({ name: 'Macro.FilterFlag', pattern: />/ }),
|
||||
DoubleSlash: createToken({ name: 'Macro.DoubleSlash', pattern: /\/\// }),
|
||||
Identifier: createToken({ name: 'Macro.Identifier', pattern: /[a-zA-Z][\w-_]*/ }),
|
||||
Identifier: createToken({ name: 'Macro.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
|
||||
// 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
|
||||
EndOfIdentifier: createToken({ name: 'Macro.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
|
||||
BeforeEnd: createToken({ name: 'Macro.BeforeEnd', pattern: /(?=\}\})/, group: Lexer.SKIPPED }),
|
||||
@@ -45,13 +73,13 @@ const Tokens = {
|
||||
Filter: {
|
||||
EscapedPipe: createToken({ name: 'Filter.EscapedPipe', pattern: /\\\|/ }),
|
||||
Pipe: createToken({ name: 'Filter.Pipe', pattern: /\|/ }),
|
||||
Identifier: createToken({ name: 'Filter.Identifier', pattern: /[a-zA-Z][\w-_]*/ }),
|
||||
Identifier: createToken({ name: 'Filter.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
|
||||
// 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
|
||||
EndOfIdentifier: createToken({ name: 'Filter.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
|
||||
},
|
||||
|
||||
// All tokens that can be captured inside a macro
|
||||
Identifier: createToken({ name: 'Identifier', pattern: /[a-zA-Z][\w-_]*/ }),
|
||||
Identifier: createToken({ name: 'Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
|
||||
WhiteSpace: createToken({ name: 'WhiteSpace', pattern: /\s+/, group: Lexer.SKIPPED }),
|
||||
|
||||
// Capture unknown characters one by one, to still allow other tokens being matched once they are there.
|
||||
@@ -84,6 +112,8 @@ const Def = {
|
||||
enter(Tokens.Macro.DoubleSlash, modes.macro_args),
|
||||
|
||||
using(Tokens.Macro.Flags),
|
||||
// Filter flag is separate because it affects parsing behavior for pipes
|
||||
using(Tokens.Macro.FilterFlag),
|
||||
|
||||
// We allow whitspaces inbetween flags or in front of the modifier
|
||||
using(Tokens.WhiteSpace),
|
||||
|
||||
@@ -46,7 +46,18 @@ class MacroParser extends CstParser {
|
||||
// Basic Macro Structure
|
||||
$.macro = $.RULE('macro', () => {
|
||||
$.CONSUME(Tokens.Macro.Start);
|
||||
$.OR([
|
||||
|
||||
// Optional flags before the identifier (e.g., {{!user}}, {{?~macro}}, {{>filtered}})
|
||||
// Both regular flags and filter flag are captured under the 'flags' label
|
||||
$.MANY(() => {
|
||||
$.OR1([
|
||||
{ ALT: () => $.CONSUME(Tokens.Macro.Flags, { LABEL: 'flags' }) },
|
||||
{ ALT: () => $.CONSUME(Tokens.Macro.FilterFlag, { LABEL: 'flags' }) },
|
||||
]);
|
||||
});
|
||||
|
||||
// Macro identifier (name)
|
||||
$.OR2([
|
||||
{ ALT: () => $.CONSUME(Tokens.Macro.DoubleSlash, { LABEL: 'Macro.identifier' }) },
|
||||
{ ALT: () => $.CONSUME(Tokens.Macro.Identifier, { LABEL: 'Macro.identifier' }) },
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/** @typedef {import('chevrotain').CstNode} CstNode */
|
||||
/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
|
||||
/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
|
||||
/** @typedef {import('./MacroFlags.js').MacroFlags} MacroFlags */
|
||||
|
||||
import { MACRO_IDENTIFIER_PATTERN } from './MacroLexer.js';
|
||||
|
||||
import { isFalseBoolean, isTrueBoolean } from '../../utils.js';
|
||||
import { MacroEngine } from './MacroEngine.js';
|
||||
@@ -34,6 +37,8 @@ export const MacroCategory = Object.freeze({
|
||||
STATE: 'state',
|
||||
/** Macros that don't fit in any of the other categories, but don't really need/deserve their own */
|
||||
MISC: 'misc',
|
||||
/** Macros that are registered but not assigned to a category (any macro should have a category, so let the extension author know...) */
|
||||
UNCATEGORIZED: 'uncategorized',
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -57,7 +62,7 @@ export const MacroValueType = Object.freeze({
|
||||
/**
|
||||
* @typedef {Object} MacroDefinitionOptions
|
||||
* @property {MacroAliasDef[]} [aliases] - Alternative names for this macro. Each alias creates a lookup entry pointing to the same definition.
|
||||
* @property {MacroCategory|string} category - Category for grouping in documentation/autocomplete. Use MacroCategory enum values or a custom string.
|
||||
* @property {MacroCategory|string} [category=MacroCategory.UNCATEGORIZED] - Category for grouping in documentation/autocomplete. Use MacroCategory enum values or a custom string.
|
||||
* @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.
|
||||
* @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.
|
||||
* @property {boolean} [strictArgs=true] - Whether the macro should be strict about its arguments.
|
||||
@@ -102,11 +107,16 @@ export const MacroValueType = Object.freeze({
|
||||
* @property {string[]} unnamedArgs - Unnamed positional arguments (both required and optional, up to the defined count).
|
||||
* @property {string[]|null} list - List arguments (after unnamed args), or null if list is not enabled.
|
||||
* @property {{ [key: string]: string }|null} namedArgs - Reserved for future named argument support.
|
||||
* @property {string} raw
|
||||
* @property {MacroFlags} flags - Macro execution flags that were applied to this macro invocation.
|
||||
* @property {boolean} isScoped - Whether this macro was invoked using scoped syntax (opening + closing tags).
|
||||
* @property {string} raw - The inner macro content with nested macros resolved.
|
||||
* @property {string} rawOriginal - The original full macro text including braces, before any resolution.
|
||||
* @property {string[]} rawArgs - The original arguments passed to the macro.
|
||||
* @property {MacroEnv} env
|
||||
* @property {CstNode|null} cstNode
|
||||
* @property {{ startOffset: number, endOffset: number }|null} range
|
||||
* @property {(value: any) => string} normalize - Normalize function to use on unsure macro results to make sure they return strings as expected.
|
||||
* @property {(content: string, options?: { trimIndent?: boolean }) => string} trimContent - Trims scoped content with optional indentation dedent. Defaults to trimming indentation.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -179,7 +189,7 @@ class MacroRegistry {
|
||||
name = typeof name === 'string' ? name.trim() : String(name);
|
||||
|
||||
try {
|
||||
if (typeof name !== 'string' || !name) throw new Error('Macro name must be a non-empty string');
|
||||
if (!isIdentifierValid(name)) throw new Error(`Macro name "${name}" is invalid. Must start with a letter, followed by alphanumeric characters or hyphens.`);
|
||||
if (!options || typeof options !== 'object') throw new Error(`Macro "${name}" options must be a non-null object.`);
|
||||
|
||||
const {
|
||||
@@ -206,14 +216,18 @@ class MacroRegistry {
|
||||
if (!aliasDef || typeof aliasDef !== 'object') throw new Error(`Macro "${name}" options.aliases[${i}] must be an object.`);
|
||||
if (typeof aliasDef.alias !== 'string' || !aliasDef.alias.trim()) throw new Error(`Macro "${name}" options.aliases[${i}].alias must be a non-empty string.`);
|
||||
const aliasName = aliasDef.alias.trim();
|
||||
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.`);
|
||||
if (aliasName === name) throw new Error(`Macro "${name}" options.aliases[${i}].alias cannot be the same as the macro name.`);
|
||||
const visible = aliasDef.visible !== false; // Default to true
|
||||
aliases.push({ alias: aliasName, visible });
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof rawCategory !== 'string' || !rawCategory.trim()) throw new Error(`Macro "${name}" options.category must be a non-empty string.`);
|
||||
const category = rawCategory.trim();
|
||||
/** @type {MacroCategory|string} */
|
||||
let category = MacroCategory.UNCATEGORIZED;
|
||||
if (typeof rawCategory === 'string' && rawCategory.trim()) {
|
||||
category = rawCategory.trim();
|
||||
}
|
||||
|
||||
let minArgs = 0;
|
||||
let maxArgs = 0;
|
||||
@@ -525,11 +539,16 @@ class MacroRegistry {
|
||||
unnamedArgs: unnamedArgsValues,
|
||||
list: listValues,
|
||||
namedArgs,
|
||||
flags: call.flags,
|
||||
isScoped: call.isScoped,
|
||||
raw: call.rawInner,
|
||||
rawOriginal: call.rawWithBraces,
|
||||
rawArgs: call.rawArgs,
|
||||
env: call.env,
|
||||
cstNode: call.cstNode,
|
||||
range: call.range,
|
||||
normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),
|
||||
trimContent: MacroEngine.trimScopedContent.bind(MacroEngine),
|
||||
};
|
||||
|
||||
const result = def.handler(executionContext);
|
||||
@@ -539,6 +558,20 @@ class MacroRegistry {
|
||||
|
||||
instance = MacroRegistry.instance;
|
||||
|
||||
/**
|
||||
* Validates a macro identifier.
|
||||
*
|
||||
* @param {string} name - The macro identifier to validate.
|
||||
* @param {Object} [options] - Validation options.
|
||||
* @param {boolean} [options.allowComment = true] - Whether return that the comment identifier '//' is valid.
|
||||
* @returns {boolean} True if the identifier is valid, false otherwise.
|
||||
*/
|
||||
function isIdentifierValid(name, { allowComment = true } = {}) {
|
||||
if (typeof name !== 'string' || !name.trim()) return false;
|
||||
if (allowComment && name === '//') return true;
|
||||
return MACRO_IDENTIFIER_PATTERN.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the arguments for a macro definition.
|
||||
* Supports required args (minArgs), optional args (up to maxArgs), and list tail.
|
||||
|
||||
@@ -348,6 +348,7 @@ export const power_user = {
|
||||
|
||||
let themes = [];
|
||||
let movingUIPresets = [];
|
||||
/** @type {ContextSettings[]} */
|
||||
export let context_presets = [];
|
||||
|
||||
const storage_keys = {
|
||||
|
||||
@@ -15,15 +15,21 @@ import { SlashCommandAbortController } from './SlashCommandAbortController.js';
|
||||
import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';
|
||||
import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
|
||||
import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
|
||||
import { EnhancedMacroAutoCompleteOption, parseMacroContext } from '../autocomplete/EnhancedMacroAutoCompleteOption.js';
|
||||
import { EnhancedMacroAutoCompleteOption, MacroFlagAutoCompleteOption, MacroClosingTagAutoCompleteOption, parseMacroContext } from '../autocomplete/EnhancedMacroAutoCompleteOption.js';
|
||||
import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';
|
||||
import { MacroParser } from '../macros/engine/MacroParser.js';
|
||||
import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';
|
||||
import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
|
||||
import { SlashCommandDebugController } from './SlashCommandDebugController.js';
|
||||
import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
|
||||
import { SlashCommandBreak } from './SlashCommandBreak.js';
|
||||
import { macros as macroSystem } from '../macros/macro-system.js';
|
||||
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
|
||||
|
||||
/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */
|
||||
/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */
|
||||
/** @typedef {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} MacroAutoCompleteContext */
|
||||
/** @typedef {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').EnhancedMacroAutoCompleteOptions} EnhancedMacroAutoCompleteOptions */
|
||||
|
||||
/**
|
||||
* @enum {Number}
|
||||
@@ -493,19 +499,138 @@ export class SlashCommandParser {
|
||||
const macroContent = text.slice(macro.start + 2, macro.end - (text.slice(macro.end - 2, macro.end) === '}}' ? 2 : 0));
|
||||
const context = parseMacroContext(macroContent, cursorInMacro);
|
||||
|
||||
// Extract just the identifier (strip trailing colons/whitespace/closing braces from macro.name)
|
||||
const identifier = macro.name.replace(/[\s:}]+$/, '').trim();
|
||||
// Check if cursor is at/after the closing }} - macro syntax is complete
|
||||
const macroEndsBrackets = text.slice(macro.end - 2, macro.end) === '}}';
|
||||
const isCursorAtClosing = macroEndsBrackets && index >= macro.end - 1;
|
||||
|
||||
if (isCursorAtClosing) {
|
||||
// Cursor is at the closing }} - check if this is an unclosed scoped macro
|
||||
const textUpToCursor = text.slice(0, index);
|
||||
const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);
|
||||
|
||||
if (unclosedScopes.length > 0) {
|
||||
const scopedMacro = unclosedScopes[unclosedScopes.length - 1];
|
||||
// Check if the current macro IS the unclosed scoped macro
|
||||
if (scopedMacro.startOffset === macro.start) {
|
||||
// Show scoped context - cursor is right at the end of the opening tag
|
||||
const scopedContext = {
|
||||
...context,
|
||||
currentArgIndex: context.args.length, // Next arg (scoped content)
|
||||
isInScopedContent: true,
|
||||
scopedMacroName: scopedMacro.name,
|
||||
};
|
||||
|
||||
const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name);
|
||||
if (macroDef) {
|
||||
const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext);
|
||||
scopedOption.valueProvider = () => '';
|
||||
|
||||
const result = new AutoCompleteNameResult(
|
||||
scopedMacro.name,
|
||||
macro.start + 2,
|
||||
[scopedOption],
|
||||
false,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not a scoped macro, just clear arg highlighting
|
||||
context.currentArgIndex = -1;
|
||||
}
|
||||
|
||||
// Use the identifier from context (handles whitespace and flags)
|
||||
// Start position must be where the identifier actually begins (after whitespace/flags)
|
||||
// so that the autocomplete range calculation works correctly
|
||||
const identifier = context.identifier;
|
||||
const identifierStartInText = macro.start + 2 + context.identifierStart;
|
||||
|
||||
// Use enhanced macro autocomplete when experimental engine is enabled
|
||||
const options = this.#buildEnhancedMacroOptions(context);
|
||||
// Pass full text up to cursor for unclosed scope detection
|
||||
const textUpToCursor = text.slice(0, index);
|
||||
|
||||
// Special case for {{if}} condition: use the condition text for matching/replacement
|
||||
const isTypingIfCondition = context.identifier === 'if' && context.currentArgIndex === 0;
|
||||
if (isTypingIfCondition) {
|
||||
// Get the typed condition text and calculate its start position
|
||||
const conditionText = context.args[0] || '';
|
||||
// Find where the condition argument starts in the macro text
|
||||
const separatorMatch = macroContent.match(/^.*?if\s*(?:::?)\s*/);
|
||||
const spaceMatch = macroContent.match(/^.*?if\s+/);
|
||||
let conditionStartOffset;
|
||||
if (separatorMatch) {
|
||||
conditionStartOffset = separatorMatch[0].length;
|
||||
} else if (spaceMatch) {
|
||||
conditionStartOffset = spaceMatch[0].length;
|
||||
} else {
|
||||
conditionStartOffset = context.identifierStart + identifier.length;
|
||||
}
|
||||
const conditionStartInText = macro.start + 2 + conditionStartOffset;
|
||||
|
||||
// Build if-condition options using macroContent for padding calculation
|
||||
const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
|
||||
const options = this.#buildIfConditionOptions(context, allMacros, macroContent);
|
||||
|
||||
const result = new AutoCompleteNameResult(
|
||||
conditionText,
|
||||
conditionStartInText,
|
||||
options,
|
||||
false,
|
||||
() => 'Use {{macro}} syntax for dynamic conditions',
|
||||
() => 'Enter a macro name or {{macro}} for the condition',
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
const options = this.#buildEnhancedMacroOptions(context, textUpToCursor);
|
||||
const result = new AutoCompleteNameResult(
|
||||
identifier,
|
||||
macro.start + 2,
|
||||
identifierStartInText,
|
||||
options,
|
||||
false,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if cursor is in scoped content of an unclosed macro
|
||||
const textUpToCursor = text.slice(0, index);
|
||||
const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);
|
||||
if (unclosedScopes.length > 0) {
|
||||
const scopedMacro = unclosedScopes[unclosedScopes.length - 1];
|
||||
// Find the original macro in macroIndex to get full info
|
||||
const originalMacro = this.macroIndex.find(it => it.start === scopedMacro.startOffset);
|
||||
if (originalMacro) {
|
||||
// Parse the original macro content to get base context
|
||||
const macroContent = text.slice(originalMacro.start + 2, originalMacro.end - 2);
|
||||
const baseContext = parseMacroContext(macroContent, macroContent.length);
|
||||
|
||||
// Create a scoped context - show next arg as current (the scoped content)
|
||||
const scopedContext = {
|
||||
...baseContext,
|
||||
currentArgIndex: baseContext.args.length, // Next arg index (the scoped one)
|
||||
isInScopedContent: true,
|
||||
scopedMacroName: scopedMacro.name,
|
||||
};
|
||||
|
||||
// Only show the scoped macro's details - no list of other macros
|
||||
// This creates a "details only" view showing the scoped arg being typed
|
||||
const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name);
|
||||
if (macroDef) {
|
||||
const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext);
|
||||
// Mark as non-insertable - we're just showing details
|
||||
scopedOption.valueProvider = () => '';
|
||||
|
||||
const result = new AutoCompleteNameResult(
|
||||
scopedMacro.name, // Use macro name so it shows as "match"
|
||||
originalMacro.start + 2, // Point to original macro
|
||||
[scopedOption],
|
||||
false,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (executor.name == ':') {
|
||||
const options = this.scopeIndex[this.commandIndex.indexOf(executor)]
|
||||
?.allVariableNames
|
||||
@@ -540,20 +665,84 @@ export class SlashCommandParser {
|
||||
|
||||
/**
|
||||
* Builds enhanced macro autocomplete options from the MacroRegistry.
|
||||
* When in the flags area (before identifier), includes flag options.
|
||||
* When typing arguments (after ::), prioritizes the exact macro match.
|
||||
* @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
|
||||
* @returns {EnhancedMacroAutoCompleteOption[]}
|
||||
* @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection.
|
||||
* @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption)[]}
|
||||
*/
|
||||
#buildEnhancedMacroOptions(context) {
|
||||
/** @type {EnhancedMacroAutoCompleteOption[]} */
|
||||
#buildEnhancedMacroOptions(context, textUpToCursor = '') {
|
||||
/** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption)[]} */
|
||||
const options = [];
|
||||
|
||||
// Check for unclosed scoped macros and suggest closing tags first
|
||||
const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);
|
||||
if (unclosedScopes.length > 0) {
|
||||
// Suggest closing the innermost (last) unclosed scope first
|
||||
const innermostScope = unclosedScopes[unclosedScopes.length - 1];
|
||||
const closingOption = new MacroClosingTagAutoCompleteOption(innermostScope.name);
|
||||
options.push(closingOption);
|
||||
|
||||
// If inside a scoped {{if}}, also suggest {{else}}
|
||||
if (innermostScope.name === 'if') {
|
||||
// TODO: TEsting
|
||||
const macroDef = macroSystem.registry.getPrimaryMacro('else');
|
||||
const elseOption = new EnhancedMacroAutoCompleteOption(macroDef);
|
||||
elseOption.sortPriority = 2;
|
||||
// const elseOption = new MacroElseAutoCompleteOption();
|
||||
options.push(elseOption);
|
||||
}
|
||||
}
|
||||
|
||||
// If cursor is in the flags area (before identifier starts), include flag options
|
||||
if (context.isInFlagsArea) {
|
||||
// Build flag options with priority-based sorting
|
||||
// Last typed flag has highest priority (1), other flags have lower priority (10)
|
||||
// Already-typed flags (except last) are hidden from the list
|
||||
const lastTypedFlag = context.flags.length > 0 ? context.flags[context.flags.length - 1] : null;
|
||||
|
||||
// Add last typed flag with high priority (so it appears at top)
|
||||
if (lastTypedFlag) {
|
||||
const lastFlagDef = MacroFlagDefinitions.get(lastTypedFlag);
|
||||
if (lastFlagDef) {
|
||||
const lastFlagOption = new MacroFlagAutoCompleteOption(lastFlagDef);
|
||||
// Mark as already typed - valueProvider returns empty so it doesn't re-insert
|
||||
lastFlagOption.valueProvider = () => '';
|
||||
// High priority to appear at top (after closing tags at 1)
|
||||
lastFlagOption.sortPriority = 2;
|
||||
options.push(lastFlagOption);
|
||||
}
|
||||
}
|
||||
|
||||
// Add flags that haven't been typed yet (skip already-typed ones except last)
|
||||
for (const [symbol, flagDef] of MacroFlagDefinitions) {
|
||||
// Skip the last typed flag (already added above) and other already-typed flags
|
||||
if (context.flags.includes(symbol)) {
|
||||
continue;
|
||||
}
|
||||
const flagOption = new MacroFlagAutoCompleteOption(flagDef);
|
||||
|
||||
// Define whether this flag is selectable (and at the top), based on being implemented, and closing actually being relevant
|
||||
let isSelectable = flagDef.implemented;
|
||||
if (flagDef.type === MacroFlagType.CLOSING_BLOCK && !unclosedScopes.length) isSelectable = false;
|
||||
if (!isSelectable) {
|
||||
flagOption.valueProvider = () => '';
|
||||
}
|
||||
// Normal flag priority
|
||||
flagOption.sortPriority = isSelectable ? 10 : 12;
|
||||
options.push(flagOption);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all macros from the registry (excluding hidden aliases)
|
||||
const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
|
||||
|
||||
// If we're typing arguments (after ::), only show the context to the matching macro
|
||||
const isTypingArgs = context.currentArgIndex >= 0;
|
||||
|
||||
// Check if we're inside a scoped {{if}} for {{else}} selectability
|
||||
const isInsideScopedIf = unclosedScopes.some(scope => scope.name === 'if');
|
||||
|
||||
for (const macro of allMacros) {
|
||||
// Check if this macro matches the typed identifier
|
||||
const isExactMatch = macro.name === context.identifier;
|
||||
@@ -561,10 +750,25 @@ export class SlashCommandParser {
|
||||
|
||||
// Only pass context to the macro that matches the identifier being typed
|
||||
// This ensures argument hints only show for the relevant macro
|
||||
const macroContext = (isExactMatch || isAliasMatch) ? context : null;
|
||||
/** @type {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} */
|
||||
let macroContext = (isExactMatch || isAliasMatch) ? context : null;
|
||||
|
||||
// If no context, we pass some options for additional details though
|
||||
if (!macroContext) {
|
||||
macroContext = /** @type {EnhancedMacroAutoCompleteOptions} */ ({
|
||||
paddingAfter: context.paddingBefore, // Match whitespace before the macro - will only be used if the macro gets auto-closed
|
||||
});
|
||||
}
|
||||
|
||||
const option = new EnhancedMacroAutoCompleteOption(macro, macroContext);
|
||||
|
||||
// {{else}} is only selectable inside a scoped {{if}} block
|
||||
// Outside of {{if}}, it should appear in the list but not be tab-completable
|
||||
if (macro.name === 'else' && !isInsideScopedIf) {
|
||||
option.valueProvider = () => '';
|
||||
option.makeSelectable = false;
|
||||
}
|
||||
|
||||
// When typing arguments, prioritize exact matches by putting them first
|
||||
if (isTypingArgs && (isExactMatch || isAliasMatch)) {
|
||||
options.unshift(option);
|
||||
@@ -576,6 +780,103 @@ export class SlashCommandParser {
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand.
|
||||
* @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
|
||||
* @param {import('../macros/engine/MacroRegistry.js').MacroDefinition[]} allMacros
|
||||
* @param {string} macroInnerText - The text inside the macro braces (e.g., " if pers" from "{{ if pers").
|
||||
* @returns {AutoCompleteOption[]}
|
||||
*/
|
||||
#buildIfConditionOptions(context, allMacros, macroInnerText) {
|
||||
/** @type {AutoCompleteOption[]} */
|
||||
const options = [];
|
||||
|
||||
// Calculate padding from the original macro text for matching whitespace on completion
|
||||
// e.g., " if pers" -> leading padding = " " (whitespace before 'if', used before '}}')
|
||||
const leadingMatch = macroInnerText.match(/^(\s*)/);
|
||||
const paddingAfter = leadingMatch ? leadingMatch[1] : '';
|
||||
|
||||
// Add zero-arg macros as condition shorthand options
|
||||
for (const macro of allMacros) {
|
||||
// Only include macros that require zero arguments (can be auto-resolved)
|
||||
if (macro.minArgs !== 0) continue;
|
||||
|
||||
// Skip internal/utility macros that don't make sense as conditions
|
||||
if (['else', 'noop', 'trim', '//'].includes(macro.name)) continue;
|
||||
|
||||
const option = new EnhancedMacroAutoCompleteOption(macro, {
|
||||
noBraces: true,
|
||||
paddingAfter,
|
||||
closeWithBraces: true,
|
||||
});
|
||||
options.push(option);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds unclosed scoped macros in the text up to cursor position.
|
||||
* Uses the MacroParser and MacroCstWalker for accurate analysis.
|
||||
*
|
||||
* @param {string} textUpToCursor - The document text up to the cursor position.
|
||||
* @returns {Array<{ name: string, startOffset: number, endOffset: number }>}
|
||||
*/
|
||||
#findUnclosedScopes(textUpToCursor) {
|
||||
if (!textUpToCursor) return [];
|
||||
|
||||
try {
|
||||
// Parse the document to get the CST
|
||||
const { cst } = MacroParser.parseDocument(textUpToCursor);
|
||||
if (!cst) return [];
|
||||
|
||||
// Use the CST walker to find unclosed scopes
|
||||
return MacroCstWalker.findUnclosedScopes({ text: textUpToCursor, cst });
|
||||
} catch {
|
||||
// If parsing fails (incomplete input), fall back to simple regex approach
|
||||
return this.#findUnclosedScopesRegex(textUpToCursor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback regex-based approach for finding unclosed scopes.
|
||||
* Used when the parser fails on incomplete input.
|
||||
*
|
||||
* @param {string} text - The text to analyze.
|
||||
* @returns {Array<{ name: string, startOffset: number, endOffset: number }>}
|
||||
*/
|
||||
#findUnclosedScopesRegex(text) {
|
||||
// Simple regex to find macro openings and closings
|
||||
// This is a fallback - less accurate but works on partial input
|
||||
const macroPattern = /\{\{(\/?)([\w-]+)/g;
|
||||
const stack = [];
|
||||
|
||||
let match;
|
||||
while ((match = macroPattern.exec(text)) !== null) {
|
||||
const isClosing = match[1] === '/';
|
||||
const name = match[2];
|
||||
|
||||
if (isClosing) {
|
||||
// Pop matching opener
|
||||
if (stack.length > 0 && stack[stack.length - 1].name === name) {
|
||||
stack.pop();
|
||||
}
|
||||
} else {
|
||||
// Check if macro can accept scoped content
|
||||
const macroDef = macroSystem.registry.getPrimaryMacro(name);
|
||||
if (macroDef && macroDef.maxArgs > 0) {
|
||||
stack.push({
|
||||
name,
|
||||
startOffset: match.index,
|
||||
endOffset: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the index <length> number of characters forward and returns the last character taken.
|
||||
* @param {number} length Number of characters to take.
|
||||
@@ -1286,18 +1587,66 @@ export class SlashCommandParser {
|
||||
}
|
||||
|
||||
indexMacros(offset, text) {
|
||||
const re = /{{(?:((?:(?!}})[^\s:])+[\s:]*)((?:(?!}}).)*)(}}|}$|$))?/s;
|
||||
let remaining = text;
|
||||
let localOffset = 0;
|
||||
while (remaining.length > 0 && re.test(remaining)) {
|
||||
const match = re.exec(remaining);
|
||||
this.macroIndex.push({
|
||||
start: offset + localOffset + match.index,
|
||||
end: offset + localOffset + match.index + (match[0]?.length ?? 0),
|
||||
name: match[1] ?? '',
|
||||
});
|
||||
localOffset += match.index + (match[0]?.length ?? 0);
|
||||
remaining = remaining.slice(match.index + (match[0]?.length ?? 0));
|
||||
// Index all macros including nested ones
|
||||
// We need to track brace depth to properly handle nested macros like {{reverse::Hey {{user}}}}
|
||||
let i = 0;
|
||||
while (i < text.length - 1) {
|
||||
// Look for macro start {{
|
||||
if (text[i] === '{' && text[i + 1] === '{') {
|
||||
const macroStart = i;
|
||||
i += 2; // Skip {{
|
||||
|
||||
// Find where this macro ends, tracking nested braces
|
||||
let depth = 1;
|
||||
let macroEnd = text.length; // Default to end if unclosed
|
||||
|
||||
while (i < text.length - 1 && depth > 0) {
|
||||
if (text[i] === '{' && text[i + 1] === '{') {
|
||||
// Nested macro start - recursively index it
|
||||
// The nested macro will be indexed in subsequent iterations
|
||||
depth++;
|
||||
i += 2;
|
||||
} else if (text[i] === '}' && text[i + 1] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
macroEnd = i + 2; // Include the closing }}
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract macro content (between {{ and }} or end)
|
||||
const contentEnd = macroEnd === text.length ? macroEnd : macroEnd - 2;
|
||||
const macroContent = text.slice(macroStart + 2, contentEnd);
|
||||
|
||||
// Use parseMacroContext to extract the identifier
|
||||
const context = parseMacroContext(macroContent, macroContent.length);
|
||||
|
||||
this.macroIndex.push({
|
||||
start: offset + macroStart,
|
||||
end: offset + macroEnd,
|
||||
name: context.identifier,
|
||||
});
|
||||
|
||||
// Continue from where we left off (don't skip ahead)
|
||||
// This ensures nested macros get their own index entries
|
||||
i = macroStart + 2; // Move past the opening {{ to look for nested macros
|
||||
// Skip to find nested {{ inside this macro's content
|
||||
while (i < contentEnd) {
|
||||
if (text[i] === '{' && i + 1 < text.length && text[i + 1] === '{') {
|
||||
break; // Found nested macro, outer loop will handle it
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (i >= contentEnd) {
|
||||
// No nested macro found, skip to end of this macro
|
||||
i = macroEnd;
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -653,6 +653,35 @@ test.describe('MacroLexer', () => {
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
// {{>filtered}}
|
||||
test('should support > filter flag as separate token', async ({ page }) => {
|
||||
const input = '{{>filtered}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Macro.FilterFlag', text: '>' },
|
||||
{ type: 'Macro.Identifier', text: 'filtered' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
// {{ ! > user }}
|
||||
test('should support filter flag combined with other flags', async ({ page }) => {
|
||||
const input = '{{ ! > user }}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Macro.Flag', text: '!' },
|
||||
{ type: 'Macro.FilterFlag', text: '>' },
|
||||
{ type: 'Macro.Identifier', text: 'user' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
// {{ a shaaark }}
|
||||
test('should not capture single letter as flag, but as macro identifiers', async ({ page }) => {
|
||||
const input = '{{ a shaaark }}';
|
||||
@@ -668,42 +697,35 @@ test.describe('MacroLexer', () => {
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
test.describe('Error Cases (Macro Execution Modifiers)', () => {
|
||||
test.describe('"Error" Cases (Macro Execution Modifiers)', () => {
|
||||
// {{ @unknown }}
|
||||
test('[Error] should not capture unknown special characters as flag', async ({ page }) => {
|
||||
test('should not capture unknown special characters as flag', async ({ page }) => {
|
||||
const input = '{{ @unknown }}';
|
||||
const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
|
||||
|
||||
const expectedErrors = [
|
||||
{ message: 'unexpected character: ->@<- at offset: 3, skipped 1 characters.' },
|
||||
];
|
||||
|
||||
expect(errors).toMatchObject(expectedErrors);
|
||||
// No errors expected, as lexer should not error out even on invalid macros
|
||||
expect(errors).toMatchObject([]);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
// Do not capture '@' as anything, as it's a lexer error
|
||||
{ type: 'Macro.Identifier', text: 'unknown' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
// Because '@' is invalid in lexer, it'll "pop out" and be captured as plaintext
|
||||
{ type: 'Plaintext', text: '@unknown }}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
// {{ 2 cents }}
|
||||
test('[Error] should not capture numbers as flag - they are also invalid macro identifiers', async ({ page }) => {
|
||||
test('should not capture numbers as flag - they are also invalid macro identifiers', async ({ page }) => {
|
||||
const input = '{{ 2 cents }}';
|
||||
const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
|
||||
|
||||
const expectedErrors = [
|
||||
{ message: 'unexpected character: ->2<- at offset: 3, skipped 1 characters.' },
|
||||
];
|
||||
expect(errors).toMatchObject(expectedErrors);
|
||||
// No errors expected, as lexer should not error out even on invalid macros
|
||||
expect(errors).toMatchObject([]);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
// Do not capture '2' as anything, as it's a lexer error
|
||||
{ type: 'Macro.Identifier', text: 'cents' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
// Because '2' is invalid in lexer, it'll "pop out" and be captured as plaintext
|
||||
{ type: 'Plaintext', text: '2 cents }}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
@@ -868,20 +890,16 @@ test.describe('MacroLexer', () => {
|
||||
|
||||
test.describe('Error Cases (Macro Output Modifiers)', () => {
|
||||
// {{|macro}}
|
||||
test('[Error] should not capture when starting the macro with a pipe', async ({ page }) => {
|
||||
test('should not capture when starting the macro with a pipe', async ({ page }) => {
|
||||
const input = '{{|macro}}';
|
||||
const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
|
||||
|
||||
const expectedErrors = [
|
||||
{ message: 'unexpected character: ->|<- at offset: 2, skipped 1 characters.' },
|
||||
];
|
||||
|
||||
expect(errors).toMatchObject(expectedErrors);
|
||||
// No errors expected, as lexer should not error out even on invalid macros
|
||||
expect(errors).toMatchObject([]);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Macro.Identifier', text: 'macro' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
{ type: 'Plaintext', text: '|macro}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
@@ -1052,18 +1070,14 @@ test.describe('MacroLexer', () => {
|
||||
const input = 'invalid {{ 000 }} followed by correct {{ macro }}';
|
||||
const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
|
||||
|
||||
const expectedErrors = [
|
||||
{ message: 'unexpected character: ->0<- at offset: 11, skipped 3 characters.' },
|
||||
];
|
||||
|
||||
expect(errors).toMatchObject(expectedErrors);
|
||||
// No errors expected, as lexer should not error out even on invalid macros
|
||||
expect(errors).toMatchObject([]);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Plaintext', text: 'invalid ' },
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
// Do not capture '000' as anything, as it's a lexer error
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
{ type: 'Plaintext', text: ' followed by correct ' },
|
||||
// '000' is invalid vor the lexer, so it is captured as plaintext
|
||||
{ type: 'Plaintext', text: '000 }} followed by correct ' },
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Macro.Identifier', text: 'macro' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
|
||||
@@ -61,15 +61,15 @@ test.describe('MacroParser', () => {
|
||||
expect(errors).toMatchObject(expectedErrors);
|
||||
expect(errors[0].message).toMatch(expectedMessage);
|
||||
});
|
||||
// {{§!#&blah}}
|
||||
// {{§%€blah}}
|
||||
test('[Error] should throw an error for invalid identifier', async ({ page }) => {
|
||||
const input = '{{§!#&blah}}';
|
||||
const input = '{{§%€blah}}';
|
||||
const { macroCst, errors } = await runParserAndGetErrors(page, input);
|
||||
|
||||
const expectedErrors = [
|
||||
{ name: 'NoViableAltException' },
|
||||
];
|
||||
const expectedMessage = /Expecting: one of these possible Token sequences:(.*?)\[Macro\.Identifier\](.*?)but found: '!'/gs;
|
||||
const expectedMessage = /Expecting: one of these possible Token sequences:(.*?)\[Macro\.Identifier\](.*?)but found: '§%€blah}}'/gs;
|
||||
|
||||
expect(macroCst).toBeUndefined();
|
||||
expect(errors).toMatchObject(expectedErrors);
|
||||
@@ -556,6 +556,144 @@ This is the second line
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Macro Flags', () => {
|
||||
// {{!user}}
|
||||
test('should parse macro with single flag', async ({ page }) => {
|
||||
const input = '{{!user}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'flags': '!',
|
||||
'Macro.identifier': 'user',
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{?delayed}}
|
||||
test('should parse macro with delayed flag', async ({ page }) => {
|
||||
const input = '{{?delayed}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'flags': '?',
|
||||
'Macro.identifier': 'delayed',
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{/closing}}
|
||||
test('should parse macro with closing block flag', async ({ page }) => {
|
||||
const input = '{{/closing}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'flags': '/',
|
||||
'Macro.identifier': 'closing',
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{>filtered}}
|
||||
test('should parse macro with filter flag', async ({ page }) => {
|
||||
const input = '{{>filtered}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'flags': '>',
|
||||
'Macro.identifier': 'filtered',
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{!?user}}
|
||||
test('should parse macro with multiple flags', async ({ page }) => {
|
||||
const input = '{{!?user}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'flags': ['!', '?'],
|
||||
'Macro.identifier': 'user',
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{ ! > macro }}
|
||||
test('should parse macro with flags and whitespace', async ({ page }) => {
|
||||
const input = '{{ ! > macro }}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'flags': ['!', '>'],
|
||||
'Macro.identifier': 'macro',
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{#legacy}}
|
||||
test('should parse macro with legacy hash flag', async ({ page }) => {
|
||||
const input = '{{#legacy}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'flags': '#',
|
||||
'Macro.identifier': 'legacy',
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{!setvar::value::test}}
|
||||
test('should parse macro with flag and arguments', async ({ page }) => {
|
||||
const input = '{{!setvar::value::test}}';
|
||||
const macroCst = await runParser(page, input, {
|
||||
flattenKeys: ['arguments.argument'],
|
||||
});
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'flags': '!',
|
||||
'Macro.identifier': 'setvar',
|
||||
'arguments': {
|
||||
'separator': '::',
|
||||
'argument': ['value', 'test'],
|
||||
},
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{.myvar}} - variable shorthand
|
||||
test('should parse macro with variable dot shorthand flag', async ({ page }) => {
|
||||
const input = '{{.myvar}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'flags': '.',
|
||||
'Macro.identifier': 'myvar',
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{$myvar}} - variable shorthand
|
||||
test('should parse macro with variable dollar shorthand flag', async ({ page }) => {
|
||||
const input = '{{$myvar}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'flags': '$',
|
||||
'Macro.identifier': 'myvar',
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,110 +44,313 @@ test.describe('MacroRegistry', () => {
|
||||
|
||||
test.describe('reject', () => {
|
||||
test('should reject invalid macro name', async ({ page }) => {
|
||||
await expect(page.evaluate(async () => {
|
||||
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
||||
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
||||
// Empty name
|
||||
MacroRegistry.registerMacro(' ', {
|
||||
handler: () => '',
|
||||
});
|
||||
})).rejects.toThrow(/Macro name must be a non-empty string/);
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: ' ',
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(result.registered).toBeNull();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError).toBeTruthy();
|
||||
expect(registrationError?.text).toContain('Failed to register macro ""');
|
||||
expect(registrationError?.errorMessage).toContain('Must start with a letter, followed by alphanumeric characters or hyphens.');
|
||||
});
|
||||
|
||||
test('should reject invalid options object', async ({ page }) => {
|
||||
await expect(page.evaluate(async () => {
|
||||
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
||||
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
||||
// Options must be object
|
||||
// @ts-expect-error intentionally wrong
|
||||
MacroRegistry.registerMacro('invalid-options', null);
|
||||
})).rejects.toThrow(/options must be a non-null object/);
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'invalid-options',
|
||||
options: null,
|
||||
});
|
||||
|
||||
expect(result.registered).toBeNull();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError).toBeTruthy();
|
||||
expect(registrationError?.text).toContain('Failed to register macro "invalid-options"');
|
||||
expect(registrationError?.errorMessage).toContain('options must be a non-null object');
|
||||
});
|
||||
|
||||
test('should reject invalid handler', async ({ page }) => {
|
||||
await expect(page.evaluate(async () => {
|
||||
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
||||
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
||||
// Handler must be function
|
||||
// @ts-expect-error intentionally wrong
|
||||
MacroRegistry.registerMacro('no-handler', { handler: null });
|
||||
})).rejects.toThrow(/options\.handler must be a function/);
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'no-handler',
|
||||
options: { handler: null },
|
||||
});
|
||||
|
||||
expect(result.registered).toBeNull();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError).toBeTruthy();
|
||||
expect(registrationError?.text).toContain('Failed to register macro "no-handler"');
|
||||
expect(registrationError?.errorMessage).toContain('options.handler must be a function');
|
||||
});
|
||||
|
||||
test('should reject invalid unnamedArgs', async ({ page }) => {
|
||||
await expect(page.evaluate(async () => {
|
||||
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
||||
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
||||
// unnamedArgs must be non-negative integer
|
||||
MacroRegistry.registerMacro('bad-required', {
|
||||
// @ts-expect-error intentionally wrong
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'bad-required',
|
||||
options: {
|
||||
unnamedArgs: -1,
|
||||
handler: () => '',
|
||||
});
|
||||
})).rejects.toThrow(/options\.unnamedArgs must be a non-negative integer/);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.registered).toBeNull();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError).toBeTruthy();
|
||||
expect(registrationError?.text).toContain('Failed to register macro "bad-required"');
|
||||
expect(registrationError?.errorMessage).toContain('options.unnamedArgs must be a non-negative integer');
|
||||
});
|
||||
|
||||
test('should reject invalid strictArgs', async ({ page }) => {
|
||||
await expect(page.evaluate(async () => {
|
||||
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
||||
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
||||
// strictArgs must be boolean
|
||||
MacroRegistry.registerMacro('bad-strict', {
|
||||
// @ts-expect-error intentionally wrong
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'bad-strict',
|
||||
options: {
|
||||
strictArgs: 'yes',
|
||||
handler: () => '',
|
||||
});
|
||||
})).rejects.toThrow(/options\.strictArgs must be a boolean/);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.registered).toBeNull();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError).toBeTruthy();
|
||||
expect(registrationError?.text).toContain('Failed to register macro "bad-strict"');
|
||||
expect(registrationError?.errorMessage).toContain('options.strictArgs must be a boolean');
|
||||
});
|
||||
|
||||
test('should reject invalid list configuration', async ({ page }) => {
|
||||
await expect(page.evaluate(async () => {
|
||||
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
||||
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
||||
// list must be boolean or object
|
||||
MacroRegistry.registerMacro('bad-list-type', {
|
||||
// @ts-expect-error intentionally wrong
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'bad-list-type',
|
||||
options: {
|
||||
list: 'invalid',
|
||||
handler: () => '',
|
||||
});
|
||||
})).rejects.toThrow(/options\.list must be a boolean or an object/);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.registered).toBeNull();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError).toBeTruthy();
|
||||
expect(registrationError?.text).toContain('Failed to register macro "bad-list-type"');
|
||||
expect(registrationError?.errorMessage).toContain('options.list must be a boolean');
|
||||
});
|
||||
|
||||
test('should reject invalid list.min', async ({ page }) => {
|
||||
await expect(page.evaluate(async () => {
|
||||
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
||||
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
||||
// list.min must be non-negative
|
||||
MacroRegistry.registerMacro('bad-list-min', {
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'bad-list-min',
|
||||
options: {
|
||||
list: { min: -1 },
|
||||
handler: () => '',
|
||||
});
|
||||
})).rejects.toThrow(/options\.list\.min must be a non-negative integer/);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.registered).toBeNull();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError).toBeTruthy();
|
||||
expect(registrationError?.text).toContain('Failed to register macro "bad-list-min"');
|
||||
expect(registrationError?.errorMessage).toContain('options.list.min must be a non-negative integer');
|
||||
});
|
||||
|
||||
test('should reject invalid list.max', async ({ page }) => {
|
||||
await expect(page.evaluate(async () => {
|
||||
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
||||
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
||||
// list.max must be >= min
|
||||
MacroRegistry.registerMacro('bad-list-max', {
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'bad-list-max',
|
||||
options: {
|
||||
list: { min: 2, max: 1 },
|
||||
handler: () => '',
|
||||
});
|
||||
})).rejects.toThrow(/options\.list\.max must be greater than or equal to options\.list\.min/);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.registered).toBeNull();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError).toBeTruthy();
|
||||
expect(registrationError?.text).toContain('Failed to register macro "bad-list-max"');
|
||||
expect(registrationError?.errorMessage).toContain('options.list.max must be greater than or equal to options.list.min');
|
||||
});
|
||||
|
||||
test('should reject invalid description', async ({ page }) => {
|
||||
await expect(page.evaluate(async () => {
|
||||
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
||||
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
||||
// description must be string
|
||||
MacroRegistry.registerMacro('bad-desc', {
|
||||
// @ts-expect-error intentionally wrong
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'bad-desc',
|
||||
options: {
|
||||
description: 123,
|
||||
handler: () => '',
|
||||
});
|
||||
})).rejects.toThrow(/options\.description must be a string/);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.registered).toBeNull();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError).toBeTruthy();
|
||||
expect(registrationError?.text).toContain('Failed to register macro "bad-desc"');
|
||||
expect(registrationError?.errorMessage).toContain('options.description must be a string');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('identifier validation', () => {
|
||||
test('should accept valid identifier with letters only', async ({ page }) => {
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'validMacro',
|
||||
options: {},
|
||||
});
|
||||
expect(result.registered).not.toBeNull();
|
||||
expect(result.errors.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should accept valid identifier with hyphens', async ({ page }) => {
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'my-macro-name',
|
||||
options: {},
|
||||
});
|
||||
expect(result.registered).not.toBeNull();
|
||||
expect(result.errors.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should accept valid identifier with underscores', async ({ page }) => {
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'my_macro_name',
|
||||
options: {},
|
||||
});
|
||||
expect(result.registered).not.toBeNull();
|
||||
expect(result.errors.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should accept valid identifier with digits after first char', async ({ page }) => {
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'macro123',
|
||||
options: {},
|
||||
});
|
||||
expect(result.registered).not.toBeNull();
|
||||
expect(result.errors.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should reject identifier starting with digit', async ({ page }) => {
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: '123macro',
|
||||
options: {},
|
||||
});
|
||||
expect(result.registered).toBeNull();
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError?.errorMessage).toContain('is invalid');
|
||||
});
|
||||
|
||||
test('should reject identifier starting with hyphen', async ({ page }) => {
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: '-macro',
|
||||
options: {},
|
||||
});
|
||||
expect(result.registered).toBeNull();
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError?.errorMessage).toContain('is invalid');
|
||||
});
|
||||
|
||||
test('should reject identifier with special characters', async ({ page }) => {
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'macro@name',
|
||||
options: {},
|
||||
});
|
||||
expect(result.registered).toBeNull();
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError?.errorMessage).toContain('is invalid');
|
||||
});
|
||||
|
||||
test('should reject identifier with spaces', async ({ page }) => {
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'macro name',
|
||||
options: {},
|
||||
});
|
||||
expect(result.registered).toBeNull();
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError?.errorMessage).toContain('is invalid');
|
||||
});
|
||||
|
||||
test('should accept valid alias identifier', async ({ page }) => {
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'primaryMacro',
|
||||
options: {
|
||||
aliases: [{ alias: 'valid-alias_123' }],
|
||||
},
|
||||
});
|
||||
expect(result.registered).not.toBeNull();
|
||||
expect(result.errors.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should reject invalid alias identifier', async ({ page }) => {
|
||||
const result = await registerMacroAndCaptureErrors(page, {
|
||||
macroName: 'primaryMacro2',
|
||||
options: {
|
||||
aliases: [{ alias: '123-invalid' }],
|
||||
},
|
||||
});
|
||||
expect(result.registered).toBeNull();
|
||||
const registrationError = result.errors.find(e => e.text.includes('[Macro] Registration Error:'));
|
||||
expect(registrationError?.errorMessage).toContain('is invalid');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {Object} CapturedConsoleError
|
||||
* @property {string} text
|
||||
* @property {string|null} errorMessage
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {{ macroName: string, options: import('../../public/scripts/macros/engine/MacroRegistry.js').MacroDefinitionOptions|null }} params
|
||||
* @returns {Promise<{ registered: unknown, errors: CapturedConsoleError[] }>}
|
||||
*/
|
||||
async function registerMacroAndCaptureErrors(page, { macroName, options }) {
|
||||
const result = await page.evaluate(async ({ macroName, options }) => {
|
||||
/** @type {CapturedConsoleError[]} */
|
||||
const errors = [];
|
||||
const originalError = console.error;
|
||||
|
||||
console.error = (...args) => {
|
||||
const text = args
|
||||
.map(a => (typeof a === 'string' ? a : (a instanceof Error ? `Error: ${a.message}` : '')))
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
/** @type {string|null} */
|
||||
let errorMessage = null;
|
||||
for (const a of args) {
|
||||
if (a instanceof Error) {
|
||||
errorMessage ??= a.message;
|
||||
continue;
|
||||
}
|
||||
if (a && typeof a === 'object' && 'error' in a && a.error instanceof Error) {
|
||||
errorMessage ??= a.error.message;
|
||||
}
|
||||
}
|
||||
|
||||
errors.push({ text, errorMessage });
|
||||
};
|
||||
|
||||
try {
|
||||
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
||||
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
||||
|
||||
/** @type {any} */
|
||||
let resolvedOptions = options;
|
||||
if (resolvedOptions && typeof resolvedOptions === 'object' && !('handler' in resolvedOptions)) {
|
||||
resolvedOptions = {
|
||||
...resolvedOptions,
|
||||
handler: () => '',
|
||||
};
|
||||
}
|
||||
|
||||
// Registering an invalid macro does not throw. It returns null and logs an error.
|
||||
const registered = MacroRegistry.registerMacro(macroName, resolvedOptions);
|
||||
return { registered, errors };
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
}, { macroName, options });
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { testSetup } from './frontent-test-utils.js';
|
||||
import { serverDirectory } from '../../src/server-directory.js';
|
||||
|
||||
test.describe('MacroStoryString', () => {
|
||||
test.beforeEach(testSetup.awaitST);
|
||||
|
||||
/** @type {any[]} */
|
||||
const defaultContextPresets = [];
|
||||
|
||||
test.beforeAll(() => {
|
||||
const contextPresetsPath = path.join(serverDirectory, 'default', 'content', 'presets', 'context');
|
||||
const files = fs.readdirSync(contextPresetsPath).filter(f => path.extname(f).toLowerCase() === '.json');
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(contextPresetsPath, file);
|
||||
const fileContent = fs.readFileSync(fullPath, 'utf-8');
|
||||
const preset = JSON.parse(fileContent);
|
||||
defaultContextPresets.push(preset);
|
||||
}
|
||||
});
|
||||
|
||||
test('should produce equivalent story strings with new macro engine', async ({ page }) => {
|
||||
const output = await page.evaluate(async ([defaultContextPresets]) => {
|
||||
const { substituteParams, extension_prompt_types } = await import('./script.js');
|
||||
const { power_user, renderStoryString } = await import('./scripts/power-user.js');
|
||||
|
||||
power_user.experimental_macro_engine = true;
|
||||
|
||||
const context = {
|
||||
description: 'character description',
|
||||
personality: 'character personality',
|
||||
persona: 'persona details',
|
||||
scenario: 'scenario setup',
|
||||
system: 'system instructions',
|
||||
char: 'character name',
|
||||
user: 'user name',
|
||||
wiBefore: 'world info before',
|
||||
wiAfter: 'world info after',
|
||||
loreBefore: 'lore before',
|
||||
loreAfter: 'lore after',
|
||||
anchorBefore: 'before anchor text',
|
||||
anchorAfter: 'after anchor text',
|
||||
mesExamples: 'example messages',
|
||||
mesExamplesRaw: 'raw example messages',
|
||||
};
|
||||
|
||||
const customInstructSettings = {
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
const customContextSettings = {
|
||||
story_string_position: extension_prompt_types.IN_PROMPT,
|
||||
};
|
||||
|
||||
const result = [];
|
||||
|
||||
function getMacroStoryString(templateString) {
|
||||
let output = substituteParams(templateString, { name1Override: context.user, name2Override: context.char, replaceCharacterCard: true, dynamicMacros: context });
|
||||
output = output.replace(/^\n+/, '');
|
||||
if (output.length > 0 && !output.endsWith('\n')) {
|
||||
output += '\n';
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
for (const template of defaultContextPresets) {
|
||||
const classicStoryString = renderStoryString(context, { customStoryString: template.story_string, customContextSettings, customInstructSettings });
|
||||
const macroStoryString = getMacroStoryString(template.story_string);
|
||||
result.push({ name: template.name, classicStoryString, macroStoryString });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [defaultContextPresets]);
|
||||
|
||||
for (const { classicStoryString, macroStoryString, name } of output) {
|
||||
expect(macroStoryString, `Mismatch in template: ${name}`).toBe(classicStoryString);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user