Blame Raw
· · · 1872 lines (75.1 KB)
0 contributors
1/**
2 * Enhanced macro autocomplete option for the new MacroRegistry-based system.
3 * Reuses rendering logic from MacroBrowser for consistency and DRY.
4 */
5
6import { AutoCompleteOption } from './AutoCompleteOption.js';
7import {
8 formatMacroSignature,
9 createSourceIndicator,
10 createAliasIndicator,
11 renderMacroDetails,
12} from '../macros/engine/MacroBrowser.js';
13import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
14import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
15import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../macros/engine/MacroLexer.js';
16import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnostics.js';
17
18/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
19
20/**
21 * Macro context passed from the parser to provide cursor position info.
22 * @typedef {Object} MacroAutoCompleteContext
23 * @property {string} fullText - The full macro text being typed (without {{ }}).
24 * @property {number} cursorOffset - Cursor position within the macro text.
25 * @property {string} paddingBefore - Padding before the macro identifier/flags.
26 * @property {string} identifier - The macro identifier (name).
27 * @property {number} identifierStart - Start position of the identifier within the macro text.
28 * @property {string[]} flags - Array of flag symbols typed (e.g., ['!', '?']).
29 * @property {string|null} currentFlag - The flag symbol cursor is currently on (last typed flag), or null.
30 * @property {boolean} isInFlagsArea - Whether cursor is in the flags area (before identifier starts).
31 * @property {string[]} args - Array of arguments typed so far.
32 * @property {number} currentArgIndex - Index of the argument being typed (-1 if on identifier).
33 * @property {boolean} isTypingSeparator - Whether cursor is on a partial separator (single ':').
34 * @property {boolean} isTypingClosingBrace - Whether cursor is typing the first closing brace on a standalone macro.
35 * @property {boolean} hasSpaceAfterIdentifier - Whether there's a space after the identifier (for space-separated args).
36 * @property {boolean} hasSpaceArgContent - Whether there's actual content after the space (not just whitespace).
37 * @property {number} separatorCount - Number of '::' separators found.
38 * @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag).
39 * @property {boolean} [isScopedContentOptional] - Whether the scoped content is optional (for display purposes).
40 * @property {string} [scopedMacroName] - Name of the scoped macro if in scoped content.
41 * @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $).
42 * @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null.
43 * @property {string} variableName - The variable name being typed (after the prefix).
44 * @property {number} variableNameEnd - The end of the variable name (for partial matches).
45 * @property {string|null} variableOperator - The operator typed (=, ++, --, +=), or null.
46 * @property {number} variableOperatorEnd - The end of the variable operator (for partial matches).
47 * @property {string} variableValue - The value after the operator (for = and +=).
48 * @property {boolean} isTypingVariableName - Whether cursor is in the variable name area.
49 * @property {boolean} isTypingOperator - Whether cursor is at/after variable name, ready for operator.
50 * @property {boolean} isTypingValue - Whether cursor is after an operator that requires a value.
51 * @property {boolean} [hasInvalidTrailingChars] - Whether there are invalid characters after the variable name.
52 * @property {string} [invalidTrailingChars] - The invalid trailing characters (for error display).
53 * @property {string} [partialOperator] - Partial operator prefix being typed ('+' or '-').
54 * @property {boolean} [isOperatorComplete] - Whether a complete operator (++ or --) was typed that doesn't need a value.
55 */
56
57/**
58 * @typedef {Object} EnhancedMacroAutoCompleteOptions
59 * @property {boolean} [noBraces=false] - If true, display without {{ }} braces (for use as values, e.g., in {{if}} conditions).
60 * @property {string} [paddingAfter=''] - Whitespace to add before closing }} (for matching opening whitespace style).
61 * @property {boolean} [closeWithBraces=false] - If true, the completion will add }} to close the macro.
62 * @property {string[]} [flags=[]] - The currently already written flags for this autocomplete.
63 * @property {string} [currentFlag] - The current flag that is present, if any.
64 * @property {string} [fullText] - The currently written full text.
65 */
66
67export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
68 /** @type {MacroDefinition} */
69 #macro;
70
71 /** @type {MacroAutoCompleteContext|null} */
72 #context = null;
73
74 /** @type {EnhancedMacroAutoCompleteOptions|null} */
75 #options = null;
76
77 /** @type {boolean} */
78 #noBraces = false;
79
80 /** @type {string} */
81 #paddingAfter = '';
82
83 /**
84 * @param {MacroDefinition} macro - The macro definition from MacroRegistry.
85 * @param {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} [contextOrOptions] - Context for argument hints, or options object.
86 */
87 constructor(macro, contextOrOptions = null) {
88 // Use the macro name as the autocomplete key
89 super(macro.name, enumIcons.macro);
90 this.#macro = macro;
91
92 // Detect if second argument is context or options
93 // Context has 'identifier' property, options may have 'noBraces'
94 if (contextOrOptions && typeof contextOrOptions === 'object') {
95 if ('noBraces' in contextOrOptions || 'paddingAfter' in contextOrOptions || 'closeWithBraces' in contextOrOptions) {
96 // It's an options object
97 this.#options = /** @type {EnhancedMacroAutoCompleteOptions} */ (contextOrOptions);
98 this.#noBraces = this.#options.noBraces ?? false;
99 this.#paddingAfter = this.#options.paddingAfter ?? '';
100
101 // If noBraces mode with closeWithBraces, complete with name + padding + }}
102 if (this.#options.closeWithBraces) {
103 this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`;
104 this.makeSelectable = true;
105 }
106 } else {
107 // It's a context object
108 this.#context = /** @type {MacroAutoCompleteContext} */ (contextOrOptions);
109 }
110 }
111
112 // nameOffset = 2 to skip the {{ prefix in the display (formatMacroSignature includes braces)
113 // When noBraces is true, nameOffset = 0 since we don't show braces
114 this.nameOffset = this.#noBraces ? 0 : 2;
115
116 // For macros that take no arguments, auto-complete with closing }} (unless already set by options)
117 if (!this.valueProvider) {
118 const takesNoArgs = macro.minArgs === 0 && macro.maxArgs === 0 && macro.list === null;
119 if (takesNoArgs) {
120 this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`;
121 this.makeSelectable = true; // Required when using valueProvider
122 }
123 }
124
125 // {{//}} needs special handling. If we autocomplete right after **one** slash is already typed, we need to replace that, as it's treated as a flag otherwise.
126 const fullText = this.#options?.fullText ?? this.#context?.fullText ?? '';
127 if (macro.name === '//' && fullText.endsWith('/')) {
128 this.replacementStartOffset = (this.replacementStartOffset ?? 0) - 1; // Cut the leading slash
129 }
130 }
131
132 /** @returns {MacroDefinition} */
133 get macro() {
134 return this.#macro;
135 }
136
137 /**
138 * Renders the list item for the autocomplete dropdown.
139 * Tight display: [icon] [signature] [description] [alias icon?] [source icon]
140 * @returns {HTMLElement}
141 */
142 renderItem() {
143 const li = document.createElement('li');
144 li.classList.add('item', 'macro-ac-item');
145 li.setAttribute('data-name', this.name);
146 li.setAttribute('data-option-type', 'macro');
147
148 // Type icon
149 const type = document.createElement('span');
150 type.classList.add('type', 'monospace');
151 type.textContent = '{}';
152 li.append(type);
153
154 // Specs container (for fuzzy highlight compatibility)
155 const specs = document.createElement('span');
156 specs.classList.add('specs');
157
158 // Name with character spans for fuzzy highlighting
159 const nameEl = document.createElement('span');
160 nameEl.classList.add('name', 'monospace');
161
162 // Build signature with individual character spans
163 // When noBraces is true, show just the macro name without {{ }}
164 const sigText = this.#noBraces ? this.#macro.name : formatMacroSignature(this.#macro);
165 for (const char of sigText) {
166 const span = document.createElement('span');
167 span.textContent = char;
168 nameEl.append(span);
169 }
170 specs.append(nameEl);
171 li.append(specs);
172
173 // Stopgap (spacer for flex layout)
174 const stopgap = document.createElement('span');
175 stopgap.classList.add('stopgap');
176 li.append(stopgap);
177
178 // Help text (description)
179 const help = document.createElement('span');
180 help.classList.add('help');
181 const content = document.createElement('span');
182 content.classList.add('helpContent');
183 content.textContent = this.#macro.description || '';
184 help.append(content);
185 li.append(help);
186
187 // Alias indicator icon (if this is an alias)
188 const aliasIcon = createAliasIndicator(this.#macro);
189 if (aliasIcon) {
190 aliasIcon.classList.add('macro-ac-indicator');
191 li.append(aliasIcon);
192 }
193
194 // Source indicator icon
195 const sourceIcon = createSourceIndicator(this.#macro);
196 sourceIcon.classList.add('macro-ac-indicator');
197 li.append(sourceIcon);
198
199 return li;
200 }
201
202 /**
203 * Renders the details panel content.
204 * Reuses renderMacroDetails from MacroBrowser with autocomplete-specific options.
205 * @returns {DocumentFragment}
206 */
207 renderDetails() {
208 const frag = document.createDocumentFragment();
209
210 // Check for arity warnings
211 const warning = this.#getArityWarning();
212 if (warning) {
213 const warningEl = this.#renderWarning(warning);
214 frag.append(warningEl);
215 }
216
217 // Show scoped content info banner if we're in scoped content
218 if (this.#context?.isInScopedContent) {
219 const scopedInfo = this.#renderScopedContentInfo();
220 if (scopedInfo) frag.append(scopedInfo);
221 }
222
223 // Determine current argument index for highlighting
224 const currentArgIndex = this.#context?.currentArgIndex ?? -1;
225
226 // For most warnings, we can still highlight which argument we are currently at.
227 // This even goes for "too many arguments" when navigating the cursor back to
228 // a valid argument.
229 // Extend this in the future, if *some* warnings don't make sense to still highlight args.
230 const hightlightArgsHint = currentArgIndex >= 0;
231
232 // Render argument hint banner if we're typing an argument
233 if (hightlightArgsHint && currentArgIndex >= 0) {
234 const hint = this.#renderArgumentHint();
235 if (hint) frag.append(hint);
236 }
237
238 // Reuse MacroBrowser's renderMacroDetails with options
239 const details = renderMacroDetails(this.#macro, { currentArgIndex: hightlightArgsHint ? currentArgIndex : -1 });
240
241 // Add class for autocomplete-specific styling overrides
242 details.classList.add('macro-ac-details');
243 frag.append(details);
244
245 return frag;
246 }
247
248 /**
249 * Checks for arity-related warnings based on the current context.
250 * @returns {string|null} Warning message, or null if no warning.
251 */
252 #getArityWarning() {
253 if (!this.#context) return null;
254
255 const argCount = this.#context.args.length;
256 const maxArgs = this.#macro.maxArgs;
257 //const minArgs = this.#macro.minArgs;
258 const hasList = this.#macro.list !== null;
259
260 // Check for too many arguments (only if no list args)
261 if (!hasList && argCount > maxArgs) {
262 return `Too many arguments: this macro accepts ${maxArgs === 0 ? 'no arguments' : `up to ${maxArgs} argument${maxArgs === 1 ? '' : 's'}`}, but ${argCount} provided.`;
263 }
264
265 // Check for space-separated arg on macro that doesn't support it
266 // Space-separated syntax provides 1 arg; with scoped content you can provide a 2nd arg
267 // So it's valid for macros with maxArgs <= 2 (or with list args)
268 if (this.#context.hasSpaceArgContent) {
269 if (maxArgs === 0 && !hasList) {
270 return 'This macro does not accept any arguments. Remove the space or use a different macro.';
271 }
272 if (!hasList && maxArgs > 2) {
273 return `Space-separated syntax only works for macros with up to 2 arguments. Use :: separators instead: {{${this.#macro.name}::arg1::arg2}}`;
274 }
275 }
276
277 // Check if trying to add args to a no-arg macro via ::
278 // List-arg macros can accept args even if maxArgs === 0
279 if (this.#context.separatorCount > 0 && maxArgs === 0 && !hasList) {
280 return 'This macro does not accept any arguments.';
281 }
282
283 // Check list bounds (min/max) if the macro has a list with constraints
284 if (hasList && typeof this.#macro.list === 'object') {
285 const listItemCount = Math.max(0, argCount - maxArgs);
286 const listMin = this.#macro.list.min ?? 0;
287 const listMax = this.#macro.list.max ?? null;
288
289 if (listItemCount < listMin) {
290 const needed = listMin - listItemCount;
291 return `Not enough list items yet: this macro requires at least ${listMin} item${listMin === 1 ? '' : 's'}, but only ${listItemCount} provided. Add ${needed} more.`;
292 }
293
294 if (listMax !== null && listItemCount > listMax) {
295 return `Too many list items: this macro accepts at most ${listMax} item${listMax === 1 ? '' : 's'}, but ${listItemCount} provided.`;
296 }
297 }
298
299 return null;
300 }
301
302 /**
303 * Renders a warning banner.
304 * @param {string} message - The warning message.
305 * @returns {HTMLElement}
306 */
307 #renderWarning(message) {
308 const warning = document.createElement('div');
309 warning.classList.add('macro-ac-warning');
310
311 const icon = document.createElement('i');
312 icon.classList.add('fa-solid', 'fa-triangle-exclamation');
313 warning.append(icon);
314
315 const text = document.createElement('span');
316 text.textContent = message;
317 warning.append(text);
318
319 return warning;
320 }
321
322 /**
323 * Renders the scoped content info banner.
324 * Shows when cursor is inside scoped content of an unclosed macro.
325 * @returns {HTMLElement|null}
326 */
327 #renderScopedContentInfo() {
328 if (!this.#context?.isInScopedContent) return null;
329
330 const info = document.createElement('div');
331 info.classList.add('macro-ac-scoped-info');
332
333 // If the scoped content is optional, show a prominent OPTIONAL badge
334 if (this.#context.isScopedContentOptional) {
335 const optionalBadge = document.createElement('span');
336 optionalBadge.classList.add('macro-ac-optional-badge');
337 optionalBadge.textContent = 'OPTIONAL';
338 info.append(optionalBadge);
339 }
340
341 const icon = document.createElement('i');
342 icon.classList.add('fa-solid', 'fa-layer-group');
343 info.append(icon);
344
345 const text = document.createElement('span');
346 const closingHint = this.#context.isScopedContentOptional
347 ? `Can optionally close with <code>{{/${this.#context.scopedMacroName}}}</code>`
348 : `Close with <code>{{/${this.#context.scopedMacroName}}}</code>`;
349 text.innerHTML = `Typing <strong>scoped content</strong> for <code>{{${this.#context.scopedMacroName}}}</code>. ${closingHint}`;
350 info.append(text);
351
352 return info;
353 }
354
355 /**
356 * Renders the current argument hint banner.
357 * @returns {HTMLElement|null}
358 */
359 #renderArgumentHint() {
360 if (!this.#context || this.#context.currentArgIndex < 0) return null;
361
362 const argIndex = this.#context.currentArgIndex;
363 const isListArg = argIndex >= this.#macro.maxArgs;
364
365 // If we're beyond unnamed args and there's no list, no hint
366 if (isListArg && !this.#macro.list) return null;
367
368 const hint = document.createElement('div');
369 hint.classList.add('macro-ac-arg-hint');
370
371 const icon = document.createElement('i');
372 icon.classList.add('fa-solid', 'fa-arrow-right');
373 hint.append(icon);
374
375 if (isListArg) {
376 // List argument hint
377 const listIndex = argIndex - this.#macro.maxArgs + 1;
378 const totalListItems = this.#context.args.length - this.#macro.maxArgs;
379
380 const text = document.createElement('span');
381 text.innerHTML = `<strong>List item ${listIndex}</strong>${(listIndex < totalListItems ? ` (of ${totalListItems})` : '')}`;
382
383 const listInfo = document.createElement('span');
384 listInfo.classList.add('macro-ac-arg-hint-small');
385 const minMax = [];
386 if (this.#macro.list.min > 0) minMax.push(`min: ${this.#macro.list.min}`);
387 if (this.#macro.list.max !== null) minMax.push(`max: ${this.#macro.list.max}`);
388 if (minMax.length > 0) {
389 listInfo.textContent = ` (list, ${minMax.join(', ')})`;
390 } else {
391 listInfo.textContent = ' (variable-length list)';
392 }
393 text.appendChild(listInfo);
394
395 hint.append(text);
396 } else {
397 // Unnamed argument hint (required or optional)
398 const argDef = this.#macro.unnamedArgDefs[argIndex];
399 let optionalLabel = '';
400 if (argDef?.optional) {
401 optionalLabel = argDef.defaultValue !== undefined
402 ? ` <em>(optional, default: ${argDef.defaultValue === '' ? '<empty string>' : argDef.defaultValue})</em>`
403 : ' <em>(optional)</em>';
404 }
405 const text = document.createElement('span');
406 text.innerHTML = `<strong>${argDef?.name || `Argument ${argIndex + 1}`}</strong>${optionalLabel}`;
407 if (argDef?.type) {
408 const typeSpan = document.createElement('code');
409 typeSpan.classList.add('macro-ac-hint-type');
410 if (Array.isArray(argDef.type)) {
411 typeSpan.textContent = argDef.type.join(' | ');
412 typeSpan.title = `Accepts: ${argDef.type.join(', ')}`;
413 } else {
414 typeSpan.textContent = argDef.type;
415 }
416 text.append(' ', typeSpan);
417 }
418 hint.append(text);
419
420 if (argDef?.description) {
421 const descSpan = document.createElement('span');
422 descSpan.classList.add('macro-ac-hint-desc');
423 descSpan.textContent = ` — ${argDef.description}`;
424 hint.append(descSpan);
425 }
426
427 if (argDef?.sampleValue) {
428 const sampleSpan = document.createElement('span');
429 sampleSpan.classList.add('macro-ac-hint-sample');
430 sampleSpan.textContent = ` (e.g. ${argDef.sampleValue})`;
431 hint.append(sampleSpan);
432 }
433 }
434
435 return hint;
436 }
437}
438
439/**
440 * Autocomplete option for macro execution flags.
441 * Shows flag symbol, name, and description.
442 * Uses default AutoCompleteOption rendering for consistent styling.
443 */
444export class MacroFlagAutoCompleteOption extends AutoCompleteOption {
445 /** @type {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} */
446 #flagDef;
447
448 /**
449 * @param {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} flagDef - The flag definition.
450 */
451 constructor(flagDef) {
452 // Use the flag symbol as the name, with a flag icon
453 // Display name includes both symbol and name for clarity
454 super(flagDef.type, '🚩');
455 this.#flagDef = flagDef;
456 }
457
458 /** @returns {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} */
459 get flagDefinition() {
460 return this.#flagDef;
461 }
462
463 /**
464 * Renders the autocomplete list item for this flag.
465 * Uses the same structure as other autocomplete options for consistent styling.
466 * @returns {HTMLElement}
467 */
468 renderItem() {
469 // Use base class makeItem for consistent styling
470 const li = this.makeItem(
471 `${this.#flagDef.type} ${this.#flagDef.name}`, // Display: "? Optional"
472 '🚩',
473 true, // noSlash
474 [], // namedArguments
475 [], // unnamedArguments
476 'void', // returnType
477 this.#flagDef.description + (this.#flagDef.implemented ? '' : ' (planned)'), // helpString
478 );
479 li.setAttribute('data-name', this.name);
480 li.setAttribute('data-option-type', 'flag');
481 return li;
482 }
483
484 /**
485 * Renders the details panel for this flag.
486 * @returns {DocumentFragment}
487 */
488 renderDetails() {
489 const frag = document.createDocumentFragment();
490
491 const details = document.createElement('div');
492 details.classList.add('macro-flag-details');
493
494 // Header with flag symbol and name
495 const header = document.createElement('h3');
496 header.classList.add('macro-flag-details-header');
497 header.innerHTML = `<code>${this.#flagDef.type}</code> ${this.#flagDef.name} Flag`;
498 details.append(header);
499
500 // Description
501 const desc = document.createElement('p');
502 desc.classList.add('macro-flag-details-desc');
503 desc.textContent = this.#flagDef.description;
504 details.append(desc);
505
506 // Status
507 const status = document.createElement('p');
508 status.classList.add('macro-flag-details-status');
509 status.innerHTML = `<strong>Status:</strong> ${this.#flagDef.implemented ? 'Implemented' : 'Planned for future release'}`;
510 details.append(status);
511
512 // Parser effect note
513 if (this.#flagDef.affectsParser) {
514 const parserNote = document.createElement('p');
515 parserNote.classList.add('macro-flag-details-note');
516 parserNote.innerHTML = '<em>This flag affects how the macro is parsed.</em>';
517 details.append(parserNote);
518 }
519
520 frag.append(details);
521 return frag;
522 }
523}
524
525/**
526 * Enum of variable shorthand prefix types.
527 * @readonly
528 * @enum {string}
529 */
530export const VariableShorthandType = Object.freeze({
531 /** Local variable prefix (`.`) */
532 LOCAL: '.',
533 /** Global variable prefix (`$`) */
534 GLOBAL: '$',
535});
536
537/**
538 * @typedef {Object} VariableShorthandDefinition
539 * @property {VariableShorthandType} type - The prefix symbol.
540 * @property {string} name - Human-readable name.
541 * @property {string} description - Description of what this prefix does.
542 * @property {string[]} operations - List of supported operations.
543 */
544
545/**
546 * Definitions for variable shorthand prefixes.
547 * @type {Map<string, VariableShorthandDefinition>}
548 */
549export const VariableShorthandDefinitions = new Map([
550 [VariableShorthandType.LOCAL, {
551 type: VariableShorthandType.LOCAL,
552 name: 'Local Variable',
553 description: 'Access or modify a local variable (scoped to current chat).',
554 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)', 'greater than (>)', 'greater than or equal (>=)', 'less than (<)', 'less than or equal (<=)'],
555 }],
556 [VariableShorthandType.GLOBAL, {
557 type: VariableShorthandType.GLOBAL,
558 name: 'Global Variable',
559 description: 'Access or modify a global variable (shared across all chats).',
560 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)', 'greater than (>)', 'greater than or equal (>=)', 'less than (<)', 'less than or equal (<=)'],
561 }],
562]);
563
564/**
565 * Set of valid variable shorthand prefix symbols.
566 * @type {Set<string>}
567 */
568export const ValidVariableShorthandSymbols = new Set(Object.values(VariableShorthandType));
569
570/**
571 * Regex pattern for valid variable shorthand names.
572 * Must start with a letter, can contain word chars, underscores and hyphens, but must not end with an underscore or hyphen.
573 * Examples: myVar, my-var, my_var, myVar123, my-long-var-name
574 * Invalid: my-, my--, -var, 123var
575 * @type {RegExp}
576 */
577const VARIABLE_SHORTHAND_NAME_PATTERN = new RegExp(`^${MACRO_VARIABLE_SHORTHAND_PATTERN.source}`);
578
579/**
580 * Checks if a variable name is valid for use with variable shorthand syntax.
581 * @param {string} name - The variable name to validate.
582 * @returns {boolean} True if the name is valid for shorthand syntax.
583 */
584export function isValidVariableShorthandName(name) {
585 if (!name || typeof name !== 'string') return false;
586 return VARIABLE_SHORTHAND_NAME_PATTERN.test(name);
587}
588
589/**
590 * Autocomplete option for variable shorthand prefixes.
591 * Shows prefix symbol, name, and description.
592 * This provides entry into the variable shorthand syntax ({{.varName}} or {{$varName}}).
593 */
594export class VariableShorthandAutoCompleteOption extends AutoCompleteOption {
595 /** @type {VariableShorthandDefinition} */
596 #varDef;
597
598 /**
599 * @param {VariableShorthandDefinition} varDef - The variable shorthand definition.
600 */
601 constructor(varDef) {
602 // Use the prefix symbol as the name, with a variable icon
603 super(varDef.type, '📦');
604 this.#varDef = varDef;
605 }
606
607 /** @returns {VariableShorthandDefinition} */
608 get variableDefinition() {
609 return this.#varDef;
610 }
611
612 /**
613 * Renders the autocomplete list item for this variable shorthand.
614 * @returns {HTMLElement}
615 */
616 renderItem() {
617 const li = this.makeItem(
618 `${this.#varDef.type} ${this.#varDef.name}`,
619 '📦',
620 true, // noSlash
621 [], // namedArguments
622 [], // unnamedArguments
623 'any', // returnType
624 this.#varDef.description,
625 );
626 li.setAttribute('data-name', this.name);
627 li.setAttribute('data-option-type', 'variable-shorthand');
628 return li;
629 }
630
631 /**
632 * Renders the details panel for this variable shorthand.
633 * @returns {DocumentFragment}
634 */
635 renderDetails() {
636 const frag = document.createDocumentFragment();
637
638 const details = document.createElement('div');
639 details.classList.add('macro-variable-details');
640
641 // Header with prefix symbol and name
642 const header = document.createElement('h3');
643 header.classList.add('macro-variable-details-header');
644 header.innerHTML = `<code>${this.#varDef.type}</code> ${this.#varDef.name}`;
645 details.append(header);
646
647 // Description
648 const desc = document.createElement('p');
649 desc.classList.add('macro-variable-details-desc');
650 desc.textContent = this.#varDef.description;
651 details.append(desc);
652
653 // Supported operations
654 const opsHeader = document.createElement('p');
655 opsHeader.innerHTML = '<strong>Supported Operations:</strong>';
656 details.append(opsHeader);
657
658 const opsList = document.createElement('ul');
659 opsList.classList.add('macro-variable-details-ops');
660 for (const op of this.#varDef.operations) {
661 const li = document.createElement('li');
662 li.textContent = op;
663 opsList.append(li);
664 }
665 details.append(opsList);
666
667 // Examples
668 const exampleHeader = document.createElement('p');
669 exampleHeader.innerHTML = '<strong>Examples:</strong>';
670 details.append(exampleHeader);
671
672 const exampleList = document.createElement('ul');
673 exampleList.classList.add('macro-variable-details-examples');
674 const prefix = this.#varDef.type;
675 const examples = [
676 `{{${prefix}myvar}} - Get variable value`,
677 `{{${prefix}myvar = value}} - Set variable (returns nothing)`,
678 `{{${prefix}counter++}} - Increment and get value`,
679 `{{${prefix}counter--}} - Decrement and get value`,
680 `{{${prefix}myvar += text}} - Append/add (returns nothing)`,
681 `{{${prefix}score -= 5}} - Subtract (returns nothing)`,
682 `{{${prefix}myvar || default}} - Get with fallback if falsy`,
683 `{{${prefix}myvar ?? default}} - Get with fallback if undefined`,
684 `{{${prefix}myvar ||= value}} - Set if falsy, get value`,
685 `{{${prefix}myvar ??= value}} - Set if undefined, get value`,
686 `{{${prefix}myvar == test}} - Compare (returns true/false)`,
687 `{{${prefix}myvar != test}} - Compare not equal (returns true/false)`,
688 `{{${prefix}score > 10}} - Greater than (numeric, returns true/false)`,
689 `{{${prefix}score >= 10}} - Greater than or equal (numeric)`,
690 `{{${prefix}score < 10}} - Less than (numeric, returns true/false)`,
691 `{{${prefix}score <= 10}} - Less than or equal (numeric)`,
692 ];
693 for (const ex of examples) {
694 const li = document.createElement('li');
695 li.innerHTML = `<code>${ex.split(' - ')[0]}</code> - ${ex.split(' - ')[1]}`;
696 exampleList.append(li);
697 }
698 details.append(exampleList);
699
700 frag.append(details);
701 return frag;
702 }
703}
704
705/**
706 * Autocomplete option for a specific variable name.
707 * Shows variable name with scope indicator (local/global).
708 */
709export class VariableNameAutoCompleteOption extends AutoCompleteOption {
710 /** @type {string} */
711 #varName;
712
713 /** @type {'local'|'global'} */
714 #scope;
715
716 /** @type {boolean} */
717 #isNewVariable;
718
719 /** @type {boolean} */
720 #isInvalidName;
721
722 /**
723 * @param {string} varName - The variable name.
724 * @param {'local'|'global'} scope - Whether this is a local or global variable.
725 * @param {boolean} [isNewVariable=false] - Whether this is a "create new variable" option.
726 * @param {boolean} [isInvalidName=false] - Whether this name is invalid for shorthand syntax.
727 */
728 constructor(varName, scope, isNewVariable = false, isInvalidName = false) {
729 const icon = scope === 'local' ? 'L' : 'G';
730 super(varName, icon);
731 this.#varName = varName;
732 this.#scope = scope;
733 this.#isNewVariable = isNewVariable;
734 this.#isInvalidName = isInvalidName;
735 }
736
737 /** @returns {string} */
738 get variableName() {
739 return this.#varName;
740 }
741
742 /** @returns {'local'|'global'} */
743 get scope() {
744 return this.#scope;
745 }
746
747 /** @returns {boolean} */
748 get isNewVariable() {
749 return this.#isNewVariable;
750 }
751
752 /** @returns {boolean} */
753 get isInvalidName() {
754 return this.#isInvalidName;
755 }
756
757 /**
758 * Renders the autocomplete list item for this variable.
759 * @returns {HTMLElement}
760 */
761 renderItem() {
762 const scopeLabel = this.#scope === 'local' ? 'Local' : 'Global';
763 let description;
764 if (this.#isInvalidName) {
765 description = '⚠️ Invalid variable name for shorthand';
766 } else if (this.#isNewVariable) {
767 description = `Define new ${scopeLabel.toLowerCase()} variable`;
768 } else {
769 description = `${scopeLabel} variable`;
770 }
771
772 const li = this.makeItem(
773 this.#varName,
774 this.typeIcon,
775 true, // noSlash
776 [], // namedArguments
777 [], // unnamedArguments
778 'any', // returnType
779 description,
780 );
781 li.setAttribute('data-name', this.name);
782 li.setAttribute('data-option-type', 'variable-name');
783 if (this.#isNewVariable) {
784 li.classList.add('variable-new');
785 }
786 if (this.#isInvalidName) {
787 li.classList.add('variable-invalid');
788 }
789 return li;
790 }
791
792 /**
793 * Renders the details panel for this variable.
794 * @returns {DocumentFragment}
795 */
796 renderDetails() {
797 const frag = document.createDocumentFragment();
798
799 const details = document.createElement('div');
800 details.classList.add('macro-variable-name-details');
801
802 const scopeLabel = this.#scope === 'local' ? 'Local' : 'Global';
803 const prefix = this.#scope === 'local' ? '.' : '$';
804
805 // Show big warning for invalid names
806 if (this.#isInvalidName) {
807 const warningBox = document.createElement('div');
808 warningBox.classList.add('variable-invalid-warning');
809 warningBox.style.cssText = 'background: #ff000033; border: 2px solid #ff0000; border-radius: 4px; padding: 10px; margin-bottom: 10px;';
810
811 const warningHeader = document.createElement('h3');
812 warningHeader.style.cssText = 'color: #ff6b6b; margin: 0 0 8px 0;';
813 warningHeader.textContent = '⚠️ Invalid Variable Name';
814 warningBox.append(warningHeader);
815
816 const warningText = document.createElement('p');
817 warningText.style.cssText = 'margin: 0 0 8px 0;';
818 warningText.innerHTML = `The name <code>${this.#varName}</code> cannot be used with variable shorthand syntax.`;
819 warningBox.append(warningText);
820
821 const rulesText = document.createElement('p');
822 rulesText.style.cssText = 'margin: 0; font-size: 0.9em;';
823 rulesText.innerHTML = '<strong>Valid names must:</strong><br>• Start with a letter (a-z, A-Z)<br>• Contain only letters, numbers, underscores, or hyphens<br>• Not end with an underscore or hyphen';
824 warningBox.append(rulesText);
825
826 details.append(warningBox);
827 frag.append(details);
828 return frag;
829 }
830
831 // Header
832 const header = document.createElement('h3');
833 header.innerHTML = this.#isNewVariable
834 ? `<code>${prefix}${this.#varName}</code> (New ${scopeLabel} Variable)`
835 : `<code>${prefix}${this.#varName}</code> ${scopeLabel} Variable`;
836 details.append(header);
837
838 // Description
839 const desc = document.createElement('p');
840 const variableSuggestion = this.#scope === 'local'
841 ? 'Local variables are scoped to the current chat.'
842 : 'Global variables are shared across all chats.';
843 if (this.#isNewVariable) {
844 desc.textContent = `Creates a new ${scopeLabel.toLowerCase()} variable named "${this.#varName}". ${variableSuggestion}`;
845 } else {
846 desc.textContent = `Access or modify the ${scopeLabel.toLowerCase()} variable "${this.#varName}". ${variableSuggestion}`;
847 }
848 details.append(desc);
849
850 // Usage examples
851 const usageHeader = document.createElement('p');
852 usageHeader.innerHTML = '<strong>Usage:</strong>';
853 details.append(usageHeader);
854
855 const usageList = document.createElement('ul');
856 const examples = [
857 `{{${prefix}${this.#varName}}} - Get value`,
858 `{{${prefix}${this.#varName} = value}} - Set value`,
859 `{{${prefix}${this.#varName}++}} - Increment`,
860 `{{${prefix}${this.#varName}--}} - Decrement`,
861 `{{${prefix}${this.#varName} += text}} - Append/add`,
862 `{{${prefix}${this.#varName} -= 5}} - Subtract`,
863 `{{${prefix}${this.#varName} || default}} - Get with fallback if falsy`,
864 `{{${prefix}${this.#varName} ?? default}} - Get with fallback if undefined`,
865 `{{${prefix}${this.#varName} ||= value}} - Set if falsy, get value`,
866 `{{${prefix}${this.#varName} ??= value}} - Set if undefined, get value`,
867 `{{${prefix}${this.#varName} == test}} - Compare (returns true/false)`,
868 `{{${prefix}${this.#varName} != test}} - Compare not equal (returns true/false)`,
869 `{{${prefix}${this.#varName} > 10}} - Greater than (numeric)`,
870 `{{${prefix}${this.#varName} >= 10}} - Greater than or equal (numeric)`,
871 `{{${prefix}${this.#varName} < 10}} - Less than (numeric)`,
872 `{{${prefix}${this.#varName} <= 10}} - Less than or equal (numeric)`,
873 ];
874 for (const ex of examples) {
875 const li = document.createElement('li');
876 li.innerHTML = `<code>${ex.split(' - ')[0]}</code> - ${ex.split(' - ')[1]}`;
877 usageList.append(li);
878 }
879 details.append(usageList);
880
881 frag.append(details);
882 return frag;
883 }
884}
885
886/**
887 * Checks if an operator is a short one that could be a prefix of a longer operator.
888 * For example, '>' is a prefix of '>=', '<' is a prefix of '<='.
889 * @param {string} op - The operator to check.
890 * @returns {boolean} True if the operator could be a prefix of a longer operator.
891 */
892function isShortOperatorPrefix(op) {
893 // These operators could have longer variants typed after them
894 const shortPrefixes = ['>', '<', '=', '|', '?', '+', '-', '!'];
895 return shortPrefixes.includes(op);
896}
897
898/**
899 * Variable shorthand operators with metadata.
900 * @type {Map<string, { symbol: string, name: string, description: string, needsValue: boolean }>}
901 */
902export const VariableOperatorDefinitions = new Map([
903 ['=', {
904 symbol: '=',
905 name: 'Set',
906 description: 'Set the variable to a new value. Returns nothing.',
907 needsValue: true,
908 }],
909 ['++', {
910 symbol: '++',
911 name: 'Increment',
912 description: 'Increment the variable by 1 (numeric). Returns the new value.',
913 needsValue: false,
914 }],
915 ['--', {
916 symbol: '--',
917 name: 'Decrement',
918 description: 'Decrement the variable by 1 (numeric). Returns the new value.',
919 needsValue: false,
920 }],
921 ['+=', {
922 symbol: '+=',
923 name: 'Add',
924 description: 'Add to the variable (numeric addition or string concatenation). Returns nothing.',
925 needsValue: true,
926 }],
927 ['-=', {
928 symbol: '-=',
929 name: 'Subtract',
930 description: 'Subtract a numeric value from the variable. Returns nothing.',
931 needsValue: true,
932 }],
933 ['||', {
934 symbol: '||',
935 name: 'Logical Or',
936 description: 'Return the fallback value if the variable is falsy, otherwise return the variable value.',
937 needsValue: true,
938 }],
939 ['??', {
940 symbol: '??',
941 name: 'Nullish Coalescing',
942 description: 'Return the fallback value only if the variable does not exist, otherwise return the variable value (even if falsy).',
943 needsValue: true,
944 }],
945 ['||=', {
946 symbol: '||=',
947 name: 'Logical Or Assign',
948 description: 'If the variable is falsy, set it to the value and return it; otherwise return the current value.',
949 needsValue: true,
950 }],
951 ['??=', {
952 symbol: '??=',
953 name: 'Nullish Coalescing Assign',
954 description: 'If the variable does not exist, set it to the value and return it; otherwise return the current value.',
955 needsValue: true,
956 }],
957 ['==', {
958 symbol: '==',
959 name: 'Equals',
960 description: 'Compare the variable value to another value. Returns "true" or "false".',
961 needsValue: true,
962 }],
963 ['!=', {
964 symbol: '!=',
965 name: 'Not Equals',
966 description: 'Compare the variable value to another value. Returns "true" if not equal, "false" if equal.',
967 needsValue: true,
968 }],
969 ['>', {
970 symbol: '>',
971 name: 'Greater Than',
972 description: 'Numeric comparison. Returns "true" if variable is greater than value, "false" otherwise.',
973 needsValue: true,
974 }],
975 ['>=', {
976 symbol: '>=',
977 name: 'Greater Than or Equal',
978 description: 'Numeric comparison. Returns "true" if variable is greater than or equal to value, "false" otherwise.',
979 needsValue: true,
980 }],
981 ['<', {
982 symbol: '<',
983 name: 'Less Than',
984 description: 'Numeric comparison. Returns "true" if variable is less than value, "false" otherwise.',
985 needsValue: true,
986 }],
987 ['<=', {
988 symbol: '<=',
989 name: 'Less Than or Equal',
990 description: 'Numeric comparison. Returns "true" if variable is less than or equal to value, "false" otherwise.',
991 needsValue: true,
992 }],
993]);
994
995/**
996 * Autocomplete option for a variable operator.
997 * Shows operator symbol, name, and description.
998 */
999export class VariableOperatorAutoCompleteOption extends AutoCompleteOption {
1000 /** @type {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1001 #operatorDef;
1002
1003 /**
1004 * @param {{ symbol: string, name: string, description: string, needsValue: boolean }} operatorDef - The operator definition.
1005 */
1006 constructor(operatorDef) {
1007 super(operatorDef.symbol, '⚡');
1008 this.#operatorDef = operatorDef;
1009 }
1010
1011 /** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1012 get operatorDefinition() {
1013 return this.#operatorDef;
1014 }
1015
1016 /**
1017 * Renders the autocomplete list item for this operator.
1018 * @returns {HTMLElement}
1019 */
1020 renderItem() {
1021 const li = this.makeItem(
1022 `${this.#operatorDef.symbol} ${this.#operatorDef.name}`,
1023 '⚡',
1024 true, // noSlash
1025 [], // namedArguments
1026 [], // unnamedArguments
1027 'void', // returnType
1028 this.#operatorDef.description,
1029 );
1030 li.setAttribute('data-name', this.name);
1031 li.setAttribute('data-option-type', 'variable-operator');
1032 return li;
1033 }
1034
1035 /**
1036 * Renders the details panel for this operator.
1037 * @returns {DocumentFragment}
1038 */
1039 renderDetails() {
1040 const frag = document.createDocumentFragment();
1041
1042 const details = document.createElement('div');
1043 details.classList.add('macro-variable-operator-details');
1044
1045 // Header
1046 const header = document.createElement('h3');
1047 header.innerHTML = `<code>${this.#operatorDef.symbol}</code> ${this.#operatorDef.name}`;
1048 details.append(header);
1049
1050 // Description
1051 const desc = document.createElement('p');
1052 desc.textContent = this.#operatorDef.description;
1053 details.append(desc);
1054
1055 // Value note
1056 const valueNote = document.createElement('p');
1057 valueNote.innerHTML = this.#operatorDef.needsValue
1058 ? '<em>This operator requires a value after it.</em>'
1059 : '<em>This operator does not take a value.</em>';
1060 details.append(valueNote);
1061
1062 frag.append(details);
1063 return frag;
1064 }
1065}
1066
1067/**
1068 * Non-selectable autocomplete option that shows context about the value being typed.
1069 * Displays info about what value is expected based on the operator.
1070 */
1071export class VariableValueContextAutoCompleteOption extends AutoCompleteOption {
1072 /** @type {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1073 #operatorDef;
1074
1075 /** @type {string} */
1076 #currentValue;
1077
1078 /**
1079 * @param {{ symbol: string, name: string, description: string, needsValue: boolean }} operatorDef - The operator definition.
1080 * @param {string} [currentValue=''] - The value currently being typed.
1081 */
1082 constructor(operatorDef, currentValue = '') {
1083 super('value', '📝');
1084 this.#operatorDef = operatorDef;
1085 this.#currentValue = currentValue;
1086 this.forceFullNameMatch = true;
1087 }
1088
1089 /** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1090 get operatorDefinition() {
1091 return this.#operatorDef;
1092 }
1093
1094 /**
1095 * Renders the autocomplete list item for this value context.
1096 * @returns {HTMLElement}
1097 */
1098 renderItem() {
1099 const li = this.makeItem(
1100 '<value>',
1101 '📝',
1102 true, // noSlash
1103 [], // namedArguments
1104 [], // unnamedArguments
1105 'any', // returnType
1106 `${this.#operatorDef.name} (${this.#operatorDef.symbol}) expects a value`,
1107 );
1108 li.setAttribute('data-name', this.name);
1109 li.setAttribute('data-option-type', 'variable-value-context');
1110 return li;
1111 }
1112
1113 /**
1114 * Renders the details panel for this value context.
1115 * @returns {DocumentFragment}
1116 */
1117 renderDetails() {
1118 const frag = document.createDocumentFragment();
1119
1120 const details = document.createElement('div');
1121 details.classList.add('macro-variable-value-context-details');
1122
1123 // Header
1124 const header = document.createElement('h3');
1125 header.innerHTML = `Value for <code>${this.#operatorDef.symbol}</code> (${this.#operatorDef.name})`;
1126 details.append(header);
1127
1128 // Description of what value is expected
1129 const desc = document.createElement('p');
1130 desc.textContent = this.#operatorDef.description;
1131 details.append(desc);
1132
1133 // Current value being typed
1134 if (this.#currentValue) {
1135 const currentNote = document.createElement('p');
1136 currentNote.innerHTML = `<em>Currently typing:</em> <code>${this.#currentValue}</code>`;
1137 details.append(currentNote);
1138 }
1139
1140 // Hint
1141 const hint = document.createElement('p');
1142 hint.classList.add('hint');
1143 hint.innerHTML = '<em>Type your value and close with <code>}}</code> to complete the macro.</em>';
1144 details.append(hint);
1145
1146 frag.append(details);
1147 return frag;
1148 }
1149}
1150
1151/**
1152 * Autocomplete option for closing a scoped macro.
1153 * Suggests {{/macroName}} to close an unclosed scoped macro.
1154 */
1155export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
1156 /** @type {string} */
1157 #macroName;
1158
1159 /** @type {string} */
1160 #paddingBefore;
1161
1162 /** @type {string} */
1163 #paddingAfter;
1164
1165 /** @type {boolean} */
1166 #isOptional;
1167
1168 /** @type {number} */
1169 #nestingLevel;
1170
1171 /**
1172 * @param {string} macroName - The name of the macro to close.
1173 * @param {Object} [options] - Optional configuration.
1174 * @param {string} [options.paddingBefore=''] - Whitespace after {{ in opening tag (target padding).
1175 * @param {string} [options.paddingAfter=''] - Whitespace before }} in opening tag (target padding).
1176 * @param {string} [options.currentPadding=''] - Whitespace the user has already typed after {{.
1177 * @param {boolean} [options.isOptional=false] - Whether this closing tag is for an optional scope.
1178 * @param {number} [options.nestingLevel=0] - Nesting level (0 = innermost).
1179 */
1180 constructor(macroName, options = {}) {
1181 // The closing tag is what we're suggesting - use /macroName as the name for matching
1182 const closingTag = `/${macroName}`;
1183 super(closingTag, '{/');
1184 this.#macroName = macroName;
1185 this.#paddingBefore = options.paddingBefore ?? '';
1186 this.#paddingAfter = options.paddingAfter ?? '';
1187 this.#isOptional = options.isOptional ?? false;
1188 this.#nestingLevel = options.nestingLevel ?? 0;
1189
1190 // Calculate the replacement offset to replace any existing whitespace the user typed
1191 // This allows us to normalize the whitespace to match the opening tag's style
1192 const currentPadding = options.currentPadding ?? '';
1193 // Negative offset to start replacement earlier (eating the user's whitespace)
1194 this.replacementStartOffset = -currentPadding.length;
1195
1196 // Custom valueProvider to return the correct replacement text
1197 // Includes the target paddingBefore from the opening tag, replacing any user-typed whitespace
1198 this.valueProvider = () => {
1199 // Return: paddingBefore + /macroName + paddingAfter + }}
1200 return `${this.#paddingBefore}/${macroName}${this.#paddingAfter}}}`;
1201 };
1202
1203 // Make selectable so TAB completion works (valueProvider alone makes it non-selectable)
1204 this.makeSelectable = true;
1205
1206 // nameOffset = 2 to skip the {{ prefix in the display for fuzzy highlighting
1207 // The name is /macroName but display shows {{/macroName}}
1208 this.nameOffset = 2;
1209
1210 // Highest priority - closing tags should always appear at the very top
1211 this.sortPriority = 1;
1212 }
1213
1214 /** @returns {string} */
1215 get macroName() {
1216 return this.#macroName;
1217 }
1218
1219 /**
1220 * Renders the autocomplete list item for this closing tag.
1221 * Uses the same structure as other macro options for consistent styling.
1222 * @returns {HTMLElement}
1223 */
1224 renderItem() {
1225 const li = document.createElement('li');
1226 li.classList.add('item', 'macro-ac-item');
1227
1228 // Type icon (same column as other macros)
1229 const type = document.createElement('span');
1230 type.classList.add('type', 'monospace');
1231 type.textContent = this.typeIcon;
1232 li.append(type);
1233
1234 // Specs container (for fuzzy highlight compatibility)
1235 const specs = document.createElement('span');
1236 specs.classList.add('specs');
1237
1238 // Name element with character spans
1239 const nameEl = document.createElement('span');
1240 nameEl.classList.add('name', 'monospace');
1241 // Display full closing tag like other macros show full syntax
1242 const displayName = `{{/${this.#macroName}}}`;
1243 for (const char of displayName) {
1244 const span = document.createElement('span');
1245 span.textContent = char;
1246 nameEl.append(span);
1247 }
1248 specs.append(nameEl);
1249 li.append(specs);
1250
1251 // Stopgap (spacer for flex layout)
1252 const stopgap = document.createElement('span');
1253 stopgap.classList.add('stopgap');
1254 li.append(stopgap);
1255
1256 // Help text (description)
1257 const help = document.createElement('span');
1258 help.classList.add('help');
1259 const content = document.createElement('span');
1260 content.classList.add('helpContent');
1261
1262 // Build description based on optional status and nesting
1263 if (this.#isOptional) {
1264 const optionalBadge = document.createElement('span');
1265 optionalBadge.classList.add('macro-ac-optional-badge', 'macro-ac-optional-badge-small');
1266 optionalBadge.textContent = 'OPTIONAL';
1267 content.append(optionalBadge);
1268 content.append(' ');
1269
1270 const nestingInfo = this.#nestingLevel > 0 ? ` (nested ${this.#nestingLevel} level${this.#nestingLevel > 1 ? 's' : ''} deep)` : '';
1271 content.append(document.createTextNode(`Optionally close {{${this.#macroName}}}${nestingInfo}`));
1272 } else {
1273 content.textContent = `Close the {{${this.#macroName}}} scoped macro.`;
1274 }
1275
1276 help.append(content);
1277 li.append(help);
1278
1279 return li;
1280 }
1281
1282 /**
1283 * Renders the details panel for this closing tag.
1284 * @returns {DocumentFragment}
1285 */
1286 renderDetails() {
1287 const frag = document.createDocumentFragment();
1288
1289 const details = document.createElement('div');
1290 details.classList.add('macro-closing-tag-details');
1291
1292 // If optional, show badge at the top
1293 if (this.#isOptional) {
1294 const optionalBadge = document.createElement('span');
1295 optionalBadge.classList.add('macro-ac-optional-badge');
1296 optionalBadge.textContent = 'OPTIONAL';
1297 details.append(optionalBadge);
1298 }
1299
1300 // Header
1301 const header = document.createElement('h3');
1302 header.innerHTML = `Close <code>{{${this.#macroName}}}</code>`;
1303 details.append(header);
1304
1305 // Description
1306 const desc = document.createElement('p');
1307 if (this.#isOptional) {
1308 const nestingInfo = this.#nestingLevel > 0 ? ` This scope is nested ${this.#nestingLevel} level${this.#nestingLevel > 1 ? 's' : ''} deep.` : '';
1309 desc.textContent = `Optionally inserts the closing tag {{/${this.#macroName}}}. The scoped content for this macro is optional - you can close it or leave it open.${nestingInfo}`;
1310 } else {
1311 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.`;
1312 }
1313 details.append(desc);
1314
1315 frag.append(details);
1316 return frag;
1317 }
1318}
1319
1320/**
1321 * Parses the macro text to determine current argument context.
1322 * Handles leading whitespace and flags before the identifier.
1323 *
1324 * @param {string} macroText - The text inside {{ }}, e.g., "roll::1d20" or "!user" or " description ".
1325 * @param {number} cursorOffset - Cursor position within macroText.
1326 * @returns {MacroAutoCompleteContext}
1327 */
1328export function parseMacroContext(macroText, cursorOffset) {
1329 let i = 0;
1330
1331 // Skip leading whitespace (but NOT newlines - those stop macro parsing for autocomplete)
1332 while (i < macroText.length && /[ \t]/.test(macroText[i])) {
1333 i++;
1334 }
1335
1336 // Extract flags (special symbols before the identifier)
1337 // Track position after each flag to determine which flag cursor is on
1338 // Special case: `/` followed by identifier chars is a closing tag, not a flag
1339 const flags = [];
1340 const flagEndPositions = []; // Position right after each flag (before any whitespace)
1341 while (i < macroText.length) {
1342 const char = macroText[i];
1343 // Check if this looks like a closing tag: `/` followed by an identifier character
1344 if (char === '/' && i + 1 < macroText.length && /[a-zA-Z/]/.test(macroText[i + 1])) {
1345 // This is a closing tag identifier, not a flag - stop parsing flags
1346 break;
1347 }
1348 if (ValidFlagSymbols.has(char)) {
1349 flags.push(char);
1350 i++;
1351 flagEndPositions.push(i); // Position right after this flag
1352 // Skip whitespace between flags (but NOT newlines - those stop macro parsing for autocomplete)
1353 while (i < macroText.length && /[ \t]/.test(macroText[i])) {
1354 i++;
1355 }
1356 } else {
1357 break;
1358 }
1359 }
1360
1361 // Determine which flag cursor is currently on (if any)
1362 // The "current" flag is the last one typed when cursor is still in the flags area
1363 // This ensures the last typed flag shows at the top of the autocomplete list
1364 let currentFlag = null;
1365 if (flags.length > 0) {
1366 // If cursor is at or after the last flag position but before identifier starts,
1367 // the last flag is the "current" one (just typed)
1368 const lastFlagEnd = flagEndPositions[flagEndPositions.length - 1];
1369 if (cursorOffset >= lastFlagEnd - 1) {
1370 currentFlag = flags[flags.length - 1];
1371 }
1372 }
1373
1374 if (flags.length > 0) {
1375 void onboardingExperimentalMacroEngine('macro flags');
1376 }
1377
1378 // Check for variable shorthand prefix (. or $)
1379 // These trigger variable expression mode instead of regular macro parsing
1380 /** @type {'.'|'$'|null} */
1381 let variablePrefix = null;
1382 let variableName = '';
1383 /** @type {string|null} */
1384 let variableOperator = null;
1385 let variableValue = '';
1386 let isVariableShorthand = false;
1387 let isTypingVariableName = false;
1388 let isTypingOperator = false;
1389 let isTypingValue = false;
1390 let variableNameEnd = i;
1391
1392 const remainingAfterFlags = macroText.slice(i);
1393 if (remainingAfterFlags.startsWith('.') || remainingAfterFlags.startsWith('$')) {
1394 isVariableShorthand = true;
1395 variablePrefix = /** @type {'.'|'$'} */ (remainingAfterFlags[0]);
1396 i++; // Move past the prefix
1397
1398 // Variable names: start with letter, can have hyphens inside, must not end with hyphen
1399 const varNameMatch = macroText.slice(i).match(VARIABLE_SHORTHAND_NAME_PATTERN);
1400 if (varNameMatch) {
1401 variableName = varNameMatch[0];
1402 i += variableName.length;
1403 }
1404 variableNameEnd = i;
1405
1406 // Skip whitespace before operator
1407 while (i < macroText.length && /\s/.test(macroText[i])) {
1408 i++;
1409 }
1410
1411 // Check for operators: ++, --, +=, -=, ||=, ??=, ||, ??, ==, =
1412 // Order matters: longer operators must be checked before shorter ones
1413 // Also track partial operator prefixes for autocomplete
1414 const operatorText = macroText.slice(i);
1415 let hasInvalidTrailingChars = false;
1416 let invalidTrailingChars = '';
1417 let partialOperator = '';
1418 if (operatorText.startsWith('++')) {
1419 variableOperator = '++';
1420 i += 2;
1421 } else if (operatorText.startsWith('--')) {
1422 variableOperator = '--';
1423 i += 2;
1424 } else if (operatorText.startsWith('||=')) {
1425 variableOperator = '||=';
1426 i += 3;
1427 } else if (operatorText.startsWith('??=')) {
1428 variableOperator = '??=';
1429 i += 3;
1430 } else if (operatorText.startsWith('||')) {
1431 variableOperator = '||';
1432 i += 2;
1433 } else if (operatorText.startsWith('??')) {
1434 variableOperator = '??';
1435 i += 2;
1436 } else if (operatorText.startsWith('+=')) {
1437 variableOperator = '+=';
1438 i += 2;
1439 } else if (operatorText.startsWith('-=')) {
1440 variableOperator = '-=';
1441 i += 2;
1442 } else if (operatorText.startsWith('==')) {
1443 variableOperator = '==';
1444 i += 2;
1445 } else if (operatorText.startsWith('!=')) {
1446 variableOperator = '!=';
1447 i += 2;
1448 } else if (operatorText.startsWith('>=')) {
1449 variableOperator = '>=';
1450 i += 2;
1451 } else if (operatorText.startsWith('>')) {
1452 variableOperator = '>';
1453 i += 1;
1454 } else if (operatorText.startsWith('<=')) {
1455 variableOperator = '<=';
1456 i += 2;
1457 } else if (operatorText.startsWith('<')) {
1458 variableOperator = '<';
1459 i += 1;
1460 } else if (operatorText.startsWith('=')) {
1461 variableOperator = '=';
1462 i += 1;
1463 } else if (operatorText.startsWith('+') || operatorText.startsWith('-') || operatorText.startsWith('|') || operatorText.startsWith('?') || operatorText.startsWith('!') || operatorText.startsWith('>') || operatorText.startsWith('<')) {
1464 // Partial operator prefix - user is typing an operator
1465 partialOperator = operatorText[0];
1466 } else if (operatorText.length > 0 && !/^\s/.test(operatorText) && !operatorText.startsWith('}')) {
1467 // There's non-whitespace after the variable name that isn't a valid operator
1468 // This is an invalid trailing character (e.g., $my$ or .var@test)
1469 // Exception: } is the closing brace, not an invalid char
1470 hasInvalidTrailingChars = true;
1471 invalidTrailingChars = operatorText.trim();
1472 }
1473
1474 // Track where the operator ends (for cursor position checks)
1475 const variableOperatorEnd = i;
1476
1477 // Check if operator requires a value
1478 const operatorDef = variableOperator ? VariableOperatorDefinitions.get(variableOperator) : null;
1479 const operatorNeedsValue = operatorDef?.needsValue ?? false;
1480
1481 // If operator requires a value, parse the value
1482 // Do this BEFORE isTypingClosingBrace detection so we can check for } in value area
1483 // let valueStartPos = i;
1484 if (operatorNeedsValue) {
1485 // Skip whitespace after operator
1486 while (i < macroText.length && /\s/.test(macroText[i])) {
1487 i++;
1488 }
1489 // valueStartPos = i;
1490 variableValue = macroText.slice(i).trimEnd();
1491 }
1492
1493 // Detect if typing first closing brace on a variable shorthand
1494 // This happens when operatorText is just "}" or when cursor is beyond content (after }})
1495 let isTypingClosingBrace = false;
1496 if (operatorText.startsWith('}') && !variableOperator) {
1497 // Typing first } on a standalone variable shorthand like {{.Lila}
1498 isTypingClosingBrace = true;
1499 } else if (cursorOffset > macroText.length && !variableOperator) {
1500 // Cursor is after }} on a standalone variable shorthand like {{.Lila}}|
1501 isTypingClosingBrace = true;
1502 } else if (cursorOffset > macroText.length && variableOperator) {
1503 // Cursor is after }} on any operator shorthand like {{.Lila++}}| or {{.Lila+=4}}|
1504 isTypingClosingBrace = true;
1505 } else if (cursorOffset >= macroText.length && variableOperator && !operatorNeedsValue) {
1506 // Cursor at end of complete operator (++ or --) like {{.Lila++ or {{.Lila++ (with trailing space)
1507 isTypingClosingBrace = true;
1508 } else if (cursorOffset >= macroText.length && !variableOperator && variableName.length > 0) {
1509 // Cursor at end of standalone variable (with or without trailing whitespace) like {{.Lila or {{ .Lila
1510 isTypingClosingBrace = true;
1511 } else if (operatorNeedsValue && variableValue.length > 0 && variableValue.endsWith('}')) {
1512 // Typing first } after a value like {{.Lila+=4}
1513 isTypingClosingBrace = true;
1514 // Strip the } from the value
1515 variableValue = variableValue.slice(0, -1);
1516 } else if (operatorNeedsValue && cursorOffset >= macroText.length && variableValue.length > 0) {
1517 // Cursor at end after typing a value (including trailing whitespace) like {{.Lila+=4
1518 // This means the shorthand is "complete" and ready to close
1519 isTypingClosingBrace = true;
1520 }
1521
1522 // Determine cursor position context for autocomplete
1523 // Note: isTypingClosingBrace takes precedence - if we're typing a closing brace,
1524 // we don't want to show operator suggestions, just the current state
1525 const prefixEnd = (macroText.indexOf(variablePrefix) ?? 0) + 1;
1526 if (cursorOffset < prefixEnd) {
1527 // Cursor is before the prefix - still in flags area conceptually
1528 isTypingVariableName = false;
1529 } else if (cursorOffset <= variableNameEnd) {
1530 // Cursor is in the variable name area (including at the end)
1531 isTypingVariableName = true;
1532 } else if (variableName.length > 0 && !variableOperator && !hasInvalidTrailingChars && !isTypingClosingBrace) {
1533 // Cursor is after variable name but no operator yet (and no invalid chars)
1534 // This includes partial operator prefixes like '+', '-', '|', '?', '>', '<'
1535 // But NOT when typing a closing brace - that takes precedence
1536 isTypingOperator = true;
1537 } else if (variableName.length > 0 && variableOperator && isShortOperatorPrefix(variableOperator) && cursorOffset <= variableOperatorEnd) {
1538 // Short operator that could be prefix of longer one (e.g., > could become >=)
1539 // But ONLY if cursor is still in the operator area, not past it into value
1540 isTypingOperator = true;
1541 } else if (operatorNeedsValue) {
1542 // Operator that requires value - cursor is in value area
1543 isTypingValue = true;
1544 }
1545 // For ++ and --, the operator is complete (no value needed)
1546 // For invalid trailing chars, none of the typing flags will be true
1547 const isOperatorComplete = (variableOperator === '++' || variableOperator === '--');
1548
1549 void onboardingExperimentalMacroEngine('variable shorthands');
1550
1551 // Return early for variable shorthand - different structure than regular macros
1552 return {
1553 fullText: macroText,
1554 cursorOffset,
1555 paddingBefore: macroText.match(/^\s+/)?.[0] ?? '',
1556 identifier: '', // No macro identifier for variable shorthand
1557 identifierStart: -1,
1558 isInFlagsArea: false,
1559 flags,
1560 currentFlag,
1561 args: [],
1562 currentArgIndex: -1,
1563 isTypingSeparator: false,
1564 isTypingClosingBrace,
1565 hasSpaceAfterIdentifier: false,
1566 hasSpaceArgContent: false,
1567 separatorCount: 0,
1568 // Variable shorthand specific properties
1569 isVariableShorthand,
1570 variablePrefix,
1571 variableName,
1572 variableNameEnd,
1573 variableOperator,
1574 variableOperatorEnd,
1575 variableValue,
1576 isTypingVariableName,
1577 isTypingOperator,
1578 isTypingValue,
1579 isOperatorComplete,
1580 hasInvalidTrailingChars,
1581 invalidTrailingChars,
1582 partialOperator,
1583 };
1584 }
1585
1586 // Regular macro parsing (not variable shorthand)
1587 // Now parse the identifier and arguments starting from position i
1588 const remainingText = macroText.slice(i);
1589 const parts = [];
1590 /** @type {{ start: number, end: number }[]} */
1591 const separatorPositions = []; // Track positions of :: separators
1592 let currentPart = '';
1593 let partStart = i;
1594 let j = 0;
1595
1596 // Track nesting depth to skip :: inside nested macros
1597 let nestedDepth = 0;
1598 // Track if we've seen a :: separator - newlines before first :: should stop parsing
1599 let hasSeenSeparator = false;
1600 // Track if we broke early (e.g., at a newline)
1601 let brokeEarly = false;
1602 while (j < remainingText.length) {
1603 // Before the first :: separator, newlines should stop parsing
1604 // This prevents text on the next line from being considered part of the identifier/space-arg
1605 if (!hasSeenSeparator && nestedDepth === 0 && (remainingText[j] === '\n' || remainingText[j] === '\r')) {
1606 // Stop parsing here - don't include the newline or anything after
1607 brokeEarly = true;
1608 break;
1609 }
1610 // Track nested macro braces
1611 if (remainingText[j] === '{' && remainingText[j + 1] === '{') {
1612 nestedDepth++;
1613 currentPart += '{{';
1614 j += 2;
1615 continue;
1616 }
1617 if (remainingText[j] === '}' && remainingText[j + 1] === '}') {
1618 nestedDepth = Math.max(0, nestedDepth - 1);
1619 currentPart += '}}';
1620 j += 2;
1621 continue;
1622 }
1623 // Only count :: as separator when not inside nested macros
1624 if (nestedDepth === 0 && remainingText[j] === ':' && remainingText[j + 1] === ':') {
1625 parts.push({ text: currentPart, start: partStart, end: i + j });
1626 separatorPositions.push({ start: i + j, end: i + j + 2 });
1627 currentPart = '';
1628 j += 2;
1629 partStart = i + j;
1630 hasSeenSeparator = true;
1631 } else {
1632 currentPart += remainingText[j];
1633 j++;
1634 }
1635 }
1636 // Push the last part - use correct end position if we broke early.
1637 // If we broke early (at a newline) AND cursor is past that point, don't push -
1638 // this filters out text on the next line from being considered part of this macro.
1639 // But if we didn't break early (cursor at end of closed macro), always push.
1640 const lastPartEnd = brokeEarly ? i + j : macroText.length;
1641 const shouldPushLastPart = !brokeEarly || cursorOffset <= lastPartEnd;
1642 if (shouldPushLastPart) {
1643 parts.push({ text: currentPart, start: partStart, end: lastPartEnd });
1644 }
1645
1646 // Determine if cursor is in the flags area (at or before identifier starts)
1647 const identifierStartPos = parts[0]?.start ?? i;
1648 const isInFlagsArea = cursorOffset <= identifierStartPos;
1649
1650 // Check if cursor is on a partial separator (single ':' that might become '::')
1651 const isTypingSeparator = remainingText.length > 0 &&
1652 cursorOffset > identifierStartPos &&
1653 macroText[cursorOffset - 1] === ':' &&
1654 macroText[cursorOffset] !== ':' &&
1655 (cursorOffset < 2 || macroText[cursorOffset - 2] !== ':');
1656
1657 // Parse identifier and space-separated argument from the first part
1658 // "getvar myvar" -> identifier="getvar", spaceArg="myvar"
1659 // "setvar " -> identifier="setvar", spaceArg="" (just whitespace, no content yet)
1660 const firstPartText = parts[0]?.text || '';
1661 const trimmedFirstPart = firstPartText.trimStart();
1662 const firstSpaceInIdentifier = trimmedFirstPart.search(/\s/);
1663
1664 let identifierOnly;
1665 let spaceArgText = '';
1666 //let spaceArgStart = -1;
1667 let hasSpaceAfterIdentifier = false;
1668
1669 if (firstSpaceInIdentifier > 0 && separatorPositions.length === 0) {
1670 // There's whitespace inside the first part - split identifier from space-arg
1671 identifierOnly = trimmedFirstPart.slice(0, firstSpaceInIdentifier);
1672 const afterIdentifier = trimmedFirstPart.slice(firstSpaceInIdentifier);
1673 // Check if there's actual content after the whitespace (not just spaces or ::)
1674 const contentAfterSpace = afterIdentifier.trimStart();
1675 hasSpaceAfterIdentifier = afterIdentifier.length > 0; // Has at least a space
1676
1677 if (contentAfterSpace.length > 0 && !contentAfterSpace.startsWith(':')) {
1678 // There's actual argument content after the space
1679 spaceArgText = contentAfterSpace;
1680 //spaceArgStart = identifierStartPos + firstSpaceInIdentifier + (afterIdentifier.length - contentAfterSpace.length);
1681 }
1682 } else {
1683 identifierOnly = trimmedFirstPart.trimEnd();
1684 }
1685
1686 // Calculate identifier end position (for space-after-identifier detection)
1687 const identifierEndPos = identifierStartPos + (firstPartText.length - firstPartText.trimStart().length) + identifierOnly.length;
1688
1689 // Determine which part the cursor is in
1690 let currentArgIndex = -1;
1691
1692 // Only consider being in an argument if we've passed a separator
1693 if (separatorPositions.length > 0) {
1694 // Find which argument we're in based on separator positions
1695 for (let sepIdx = 0; sepIdx < separatorPositions.length; sepIdx++) {
1696 const sep = separatorPositions[sepIdx];
1697 if (cursorOffset >= sep.end) {
1698 // We're past this separator, so we're in at least this argument
1699 currentArgIndex = sepIdx;
1700 }
1701 }
1702 } else if (spaceArgText.length > 0 || (hasSpaceAfterIdentifier && cursorOffset > identifierEndPos)) {
1703 // Space-separated arg: either has content, or cursor is past identifier+space
1704 currentArgIndex = 0;
1705 }
1706
1707 // If typing a separator, we're still on identifier/previous arg, not the next one
1708 if (isTypingSeparator) {
1709 currentArgIndex = -1;
1710 }
1711
1712 const leftPadding = macroText.match(/^\s+/)?.[0] ?? '';
1713
1714 if (leftPadding) {
1715 void onboardingExperimentalMacroEngine('leading whitespace');
1716 }
1717
1718 // Clean identifier: strip trailing colons (for partial :: typing)
1719 // Also strip trailing single } (for partial }} typing) - but only if no separators/args
1720 let cleanIdentifier = identifierOnly.replace(/:+$/, '');
1721 let isTypingClosingBrace = false;
1722 if (separatorPositions.length === 0 && !hasSpaceAfterIdentifier && cleanIdentifier.endsWith('}')) {
1723 // Typing first closing brace on a standalone macro like {{char}
1724 cleanIdentifier = cleanIdentifier.slice(0, -1);
1725 isTypingClosingBrace = true;
1726 }
1727
1728 // Build args array - include space-separated arg if present
1729 // Trim args like the macro engine does
1730 let args = parts.slice(1).map(p => p.text.trim());
1731 if (spaceArgText.length > 0) {
1732 args = [spaceArgText, ...args];
1733 }
1734
1735 return {
1736 fullText: macroText,
1737 cursorOffset,
1738 paddingBefore: leftPadding,
1739 identifier: cleanIdentifier,
1740 identifierStart: identifierStartPos,
1741 isInFlagsArea,
1742 flags,
1743 currentFlag,
1744 args,
1745 currentArgIndex,
1746 isTypingSeparator,
1747 isTypingClosingBrace,
1748 hasSpaceAfterIdentifier,
1749 hasSpaceArgContent: spaceArgText.length > 0,
1750 separatorCount: separatorPositions.length,
1751 // Default variable shorthand properties (not a variable shorthand)
1752 isVariableShorthand: false,
1753 variablePrefix: null,
1754 variableName: '',
1755 variableNameEnd: null,
1756 variableOperator: null,
1757 variableOperatorEnd: null,
1758 variableValue: '',
1759 isTypingVariableName: false,
1760 isTypingOperator: false,
1761 isTypingValue: false,
1762 };
1763}
1764
1765/**
1766 * A simple, generic autocomplete option for displaying basic items with name, symbol, and description.
1767 * Useful for simple options like inversion markers, prefixes, etc. without needing a full custom class.
1768 *
1769 * @extends AutoCompleteOption
1770 */
1771export class SimpleAutoCompleteOption extends AutoCompleteOption {
1772 /** @type {string} */
1773 #description;
1774
1775 /** @type {string|null} */
1776 #detailedDescription;
1777
1778 /**
1779 * @param {Object} config - Configuration for the option.
1780 * @param {string} config.name - The option name/key (used for matching).
1781 * @param {string} [config.symbol=' '] - Icon/symbol shown in the type column.
1782 * @param {string} [config.description=''] - Short description shown inline.
1783 * @param {string} [config.detailedDescription] - Longer description for details panel (supports HTML). Falls back to description if not provided.
1784 * @param {string} [config.type='simple'] - Type identifier for CSS/data attributes.
1785 */
1786 constructor({ name, symbol = ' ', description = '', detailedDescription = null, type = 'simple' }) {
1787 super(name, symbol, type);
1788 this.#description = description;
1789 this.#detailedDescription = detailedDescription;
1790 }
1791
1792 /** @returns {string} */
1793 get description() {
1794 return this.#description;
1795 }
1796
1797 /** @returns {string} */
1798 get detailedDescription() {
1799 return this.#detailedDescription ?? this.#description;
1800 }
1801
1802 /**
1803 * @returns {HTMLElement}
1804 */
1805 renderItem() {
1806 const li = document.createElement('li');
1807 li.classList.add('item');
1808 li.setAttribute('data-name', this.name);
1809 li.setAttribute('data-option-type', this.type);
1810
1811 // Type icon
1812 const typeSpan = document.createElement('span');
1813 typeSpan.classList.add('type', 'monospace');
1814 typeSpan.textContent = this.typeIcon;
1815 li.append(typeSpan);
1816
1817 // Name
1818 const specs = document.createElement('span');
1819 specs.classList.add('specs');
1820 const nameSpan = document.createElement('span');
1821 nameSpan.classList.add('name', 'monospace');
1822 this.name.split('').forEach(char => {
1823 const span = document.createElement('span');
1824 span.textContent = char;
1825 nameSpan.append(span);
1826 });
1827 specs.append(nameSpan);
1828 li.append(specs);
1829
1830 // Stopgap
1831 const stopgap = document.createElement('span');
1832 stopgap.classList.add('stopgap');
1833 li.append(stopgap);
1834
1835 // Help/description
1836 const help = document.createElement('span');
1837 help.classList.add('help');
1838 const content = document.createElement('span');
1839 content.classList.add('helpContent');
1840 content.textContent = this.#description;
1841 help.append(content);
1842 li.append(help);
1843
1844 return li;
1845 }
1846
1847 /**
1848 * @returns {DocumentFragment}
1849 */
1850 renderDetails() {
1851 const frag = document.createDocumentFragment();
1852
1853 // Header with name
1854 const specs = document.createElement('div');
1855 specs.classList.add('specs');
1856 const nameDiv = document.createElement('div');
1857 nameDiv.classList.add('name', 'monospace');
1858 nameDiv.textContent = this.name;
1859 specs.append(nameDiv);
1860 frag.append(specs);
1861
1862 // Description
1863 if (this.detailedDescription) {
1864 const helpDiv = document.createElement('div');
1865 helpDiv.classList.add('help');
1866 helpDiv.innerHTML = this.detailedDescription;
1867 frag.append(helpDiv);
1868 }
1869
1870 return frag;
1871 }
1872}