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}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.