migrate
This commit is contained in:
36
public/scripts/slash-commands/AbstractEventTarget.js
Normal file
36
public/scripts/slash-commands/AbstractEventTarget.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @abstract
|
||||
* @implements {EventTarget}
|
||||
*/
|
||||
export class AbstractEventTarget {
|
||||
constructor() {
|
||||
this.listeners = {};
|
||||
}
|
||||
|
||||
addEventListener(type, callback, _options) {
|
||||
if (!this.listeners[type]) {
|
||||
this.listeners[type] = [];
|
||||
}
|
||||
this.listeners[type].push(callback);
|
||||
}
|
||||
|
||||
dispatchEvent(event) {
|
||||
if (!this.listeners[event.type] || this.listeners[event.type].length === 0) {
|
||||
return true;
|
||||
}
|
||||
this.listeners[event.type].forEach(listener => {
|
||||
listener(event);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
removeEventListener(type, callback, _options) {
|
||||
if (!this.listeners[type]) {
|
||||
return;
|
||||
}
|
||||
const index = this.listeners[type].indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.listeners[type].splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
429
public/scripts/slash-commands/SlashCommand.js
Normal file
429
public/scripts/slash-commands/SlashCommand.js
Normal file
@@ -0,0 +1,429 @@
|
||||
import { hljs } from '../../lib.js';
|
||||
import { SlashCommandAbortController } from './SlashCommandAbortController.js';
|
||||
import { SlashCommandArgument, SlashCommandNamedArgument } from './SlashCommandArgument.js';
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
import { SlashCommandDebugController } from './SlashCommandDebugController.js';
|
||||
import { SlashCommandScope } from './SlashCommandScope.js';
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* _scope:SlashCommandScope,
|
||||
* _parserFlags:import('./SlashCommandParser.js').ParserFlags,
|
||||
* _abortController:SlashCommandAbortController,
|
||||
* _debugController:SlashCommandDebugController,
|
||||
* _hasUnnamedArgument:boolean,
|
||||
* [id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined,
|
||||
* }} NamedArguments
|
||||
*/
|
||||
|
||||
/**
|
||||
* Alternative object for local JSDocs, where you don't need existing pipe, scope, etc. arguments
|
||||
* @typedef {{[id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined}} NamedArgumentsCapture
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {string|SlashCommandClosure|(string|SlashCommandClosure)[]} UnnamedArguments
|
||||
*/
|
||||
|
||||
|
||||
|
||||
export class SlashCommand {
|
||||
/**
|
||||
* Creates a SlashCommand from a properties object.
|
||||
* @param {Object} props
|
||||
* @param {string} [props.name]
|
||||
* @param {(namedArguments:NamedArguments|NamedArgumentsCapture, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>} [props.callback]
|
||||
* @param {string} [props.helpString]
|
||||
* @param {boolean} [props.splitUnnamedArgument]
|
||||
* @param {Number} [props.splitUnnamedArgumentCount]
|
||||
* @param {string[]} [props.aliases]
|
||||
* @param {string} [props.returns]
|
||||
* @param {SlashCommandNamedArgument[]} [props.namedArgumentList]
|
||||
* @param {SlashCommandArgument[]} [props.unnamedArgumentList]
|
||||
*/
|
||||
static fromProps(props) {
|
||||
const instance = Object.assign(new this(), props);
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**@type {string}*/ name;
|
||||
/**@type {(namedArguments:{_scope:SlashCommandScope, _abortController:SlashCommandAbortController, [id:string]:string|SlashCommandClosure}, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>}*/ callback;
|
||||
/**@type {string}*/ helpString;
|
||||
/**@type {boolean}*/ splitUnnamedArgument = false;
|
||||
/**@type {Number}*/ splitUnnamedArgumentCount;
|
||||
/**@type {string[]}*/ aliases = [];
|
||||
/**@type {string}*/ returns;
|
||||
/**@type {SlashCommandNamedArgument[]}*/ namedArgumentList = [];
|
||||
/**@type {SlashCommandArgument[]}*/ unnamedArgumentList = [];
|
||||
|
||||
/**@type {Object.<string, HTMLElement>}*/ helpCache = {};
|
||||
/**@type {Object.<string, DocumentFragment>}*/ helpDetailsCache = {};
|
||||
|
||||
/**@type {boolean}*/ isExtension = false;
|
||||
/**@type {boolean}*/ isThirdParty = false;
|
||||
/**@type {string}*/ source;
|
||||
|
||||
renderHelpItem(key = null) {
|
||||
key = key ?? this.name;
|
||||
if (!this.helpCache[key]) {
|
||||
const typeIcon = '[/]';
|
||||
const li = document.createElement('li'); {
|
||||
li.classList.add('item');
|
||||
const type = document.createElement('span'); {
|
||||
type.classList.add('type');
|
||||
type.classList.add('monospace');
|
||||
type.textContent = typeIcon;
|
||||
li.append(type);
|
||||
}
|
||||
const specs = document.createElement('span'); {
|
||||
specs.classList.add('specs');
|
||||
const name = document.createElement('span'); {
|
||||
name.classList.add('name');
|
||||
name.classList.add('monospace');
|
||||
name.textContent = '/';
|
||||
key.split('').forEach(char=>{
|
||||
const span = document.createElement('span'); {
|
||||
span.textContent = char;
|
||||
name.append(span);
|
||||
}
|
||||
});
|
||||
specs.append(name);
|
||||
}
|
||||
const body = document.createElement('span'); {
|
||||
body.classList.add('body');
|
||||
const args = document.createElement('span'); {
|
||||
args.classList.add('arguments');
|
||||
for (const arg of this.namedArgumentList) {
|
||||
const argItem = document.createElement('span'); {
|
||||
argItem.classList.add('argument');
|
||||
argItem.classList.add('namedArgument');
|
||||
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
||||
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
||||
const name = document.createElement('span'); {
|
||||
name.classList.add('argument-name');
|
||||
name.textContent = arg.name;
|
||||
argItem.append(name);
|
||||
}
|
||||
if (arg.enumList.length > 0) {
|
||||
const enums = document.createElement('span'); {
|
||||
enums.classList.add('argument-enums');
|
||||
for (const e of arg.enumList) {
|
||||
const enumItem = document.createElement('span'); {
|
||||
enumItem.classList.add('argument-enum');
|
||||
enumItem.textContent = e.value;
|
||||
enums.append(enumItem);
|
||||
}
|
||||
}
|
||||
argItem.append(enums);
|
||||
}
|
||||
} else {
|
||||
const types = document.createElement('span'); {
|
||||
types.classList.add('argument-types');
|
||||
for (const t of arg.typeList) {
|
||||
const type = document.createElement('span'); {
|
||||
type.classList.add('argument-type');
|
||||
type.textContent = t;
|
||||
types.append(type);
|
||||
}
|
||||
}
|
||||
argItem.append(types);
|
||||
}
|
||||
}
|
||||
args.append(argItem);
|
||||
}
|
||||
}
|
||||
for (const arg of this.unnamedArgumentList) {
|
||||
const argItem = document.createElement('span'); {
|
||||
argItem.classList.add('argument');
|
||||
argItem.classList.add('unnamedArgument');
|
||||
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
||||
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
||||
if (arg.enumList.length > 0) {
|
||||
const enums = document.createElement('span'); {
|
||||
enums.classList.add('argument-enums');
|
||||
for (const e of arg.enumList) {
|
||||
const enumItem = document.createElement('span'); {
|
||||
enumItem.classList.add('argument-enum');
|
||||
enumItem.textContent = e.value;
|
||||
enums.append(enumItem);
|
||||
}
|
||||
}
|
||||
argItem.append(enums);
|
||||
}
|
||||
} else {
|
||||
const types = document.createElement('span'); {
|
||||
types.classList.add('argument-types');
|
||||
for (const t of arg.typeList) {
|
||||
const type = document.createElement('span'); {
|
||||
type.classList.add('argument-type');
|
||||
type.textContent = t;
|
||||
types.append(type);
|
||||
}
|
||||
}
|
||||
argItem.append(types);
|
||||
}
|
||||
}
|
||||
args.append(argItem);
|
||||
}
|
||||
}
|
||||
body.append(args);
|
||||
}
|
||||
const returns = document.createElement('span'); {
|
||||
returns.classList.add('returns');
|
||||
returns.textContent = this.returns ?? 'void';
|
||||
body.append(returns);
|
||||
}
|
||||
specs.append(body);
|
||||
}
|
||||
li.append(specs);
|
||||
}
|
||||
const stopgap = document.createElement('span'); {
|
||||
stopgap.classList.add('stopgap');
|
||||
stopgap.textContent = '';
|
||||
li.append(stopgap);
|
||||
}
|
||||
const help = document.createElement('span'); {
|
||||
help.classList.add('help');
|
||||
const content = document.createElement('span'); {
|
||||
content.classList.add('helpContent');
|
||||
content.innerHTML = this.helpString;
|
||||
const text = content.textContent;
|
||||
content.innerHTML = '';
|
||||
content.textContent = text;
|
||||
help.append(content);
|
||||
}
|
||||
li.append(help);
|
||||
}
|
||||
if (this.aliases.length > 0) {
|
||||
const aliases = document.createElement('span'); {
|
||||
aliases.classList.add('aliases');
|
||||
aliases.append(' (alias: ');
|
||||
for (const aliasName of this.aliases) {
|
||||
const alias = document.createElement('span'); {
|
||||
alias.classList.add('monospace');
|
||||
alias.textContent = `/${aliasName}`;
|
||||
aliases.append(alias);
|
||||
}
|
||||
}
|
||||
aliases.append(')');
|
||||
// li.append(aliases);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.helpCache[key] = li;
|
||||
}
|
||||
return /**@type {HTMLElement}*/(this.helpCache[key].cloneNode(true));
|
||||
}
|
||||
|
||||
renderHelpDetails(key = null) {
|
||||
key = key ?? this.name;
|
||||
if (!this.helpDetailsCache[key]) {
|
||||
const frag = document.createDocumentFragment();
|
||||
const cmd = this;
|
||||
const namedArguments = cmd.namedArgumentList ?? [];
|
||||
const unnamedArguments = cmd.unnamedArgumentList ?? [];
|
||||
const returnType = cmd.returns ?? 'void';
|
||||
const helpString = cmd.helpString ?? 'NO DETAILS';
|
||||
const aliasList = [cmd.name, ...(cmd.aliases ?? [])].filter(it=>it != key);
|
||||
const specs = document.createElement('div'); {
|
||||
specs.classList.add('specs');
|
||||
const head = document.createElement('div'); {
|
||||
head.classList.add('head');
|
||||
const name = document.createElement('div'); {
|
||||
name.classList.add('name');
|
||||
name.classList.add('monospace');
|
||||
name.title = 'command name';
|
||||
name.textContent = `/${key}`;
|
||||
head.append(name);
|
||||
}
|
||||
const src = document.createElement('div'); {
|
||||
src.classList.add('source');
|
||||
src.classList.add('fa-solid');
|
||||
if (this.isExtension) {
|
||||
src.classList.add('isExtension');
|
||||
src.classList.add('fa-cubes');
|
||||
if (this.isThirdParty) src.classList.add('isThirdParty');
|
||||
else src.classList.add('isCore');
|
||||
} else {
|
||||
src.classList.add('isCore');
|
||||
src.classList.add('fa-star-of-life');
|
||||
}
|
||||
src.title = [
|
||||
this.isExtension ? 'Extension' : 'Core',
|
||||
this.isThirdParty ? 'Third Party' : (this.isExtension ? 'Core' : null),
|
||||
this.source,
|
||||
].filter(it=>it).join('\n');
|
||||
head.append(src);
|
||||
}
|
||||
specs.append(head);
|
||||
}
|
||||
const body = document.createElement('div'); {
|
||||
body.classList.add('body');
|
||||
const args = document.createElement('ul'); {
|
||||
args.classList.add('arguments');
|
||||
for (const arg of namedArguments) {
|
||||
const listItem = document.createElement('li'); {
|
||||
listItem.classList.add('argumentItem');
|
||||
const argSpec = document.createElement('div'); {
|
||||
argSpec.classList.add('argumentSpec');
|
||||
const argItem = document.createElement('div'); {
|
||||
argItem.classList.add('argument');
|
||||
argItem.classList.add('namedArgument');
|
||||
argItem.title = `${arg.isRequired ? '' : 'optional '}named argument`;
|
||||
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
||||
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
||||
const name = document.createElement('span'); {
|
||||
name.classList.add('argument-name');
|
||||
name.title = `${argItem.title} - name`;
|
||||
name.textContent = arg.name;
|
||||
argItem.append(name);
|
||||
}
|
||||
if (arg.enumList.length > 0) {
|
||||
const enums = document.createElement('span'); {
|
||||
enums.classList.add('argument-enums');
|
||||
enums.title = `${argItem.title} - accepted values`;
|
||||
for (const e of arg.enumList) {
|
||||
const enumItem = document.createElement('span'); {
|
||||
enumItem.classList.add('argument-enum');
|
||||
enumItem.textContent = e.value;
|
||||
enums.append(enumItem);
|
||||
}
|
||||
}
|
||||
argItem.append(enums);
|
||||
}
|
||||
} else {
|
||||
const types = document.createElement('span'); {
|
||||
types.classList.add('argument-types');
|
||||
types.title = `${argItem.title} - accepted types`;
|
||||
for (const t of arg.typeList) {
|
||||
const type = document.createElement('span'); {
|
||||
type.classList.add('argument-type');
|
||||
type.textContent = t;
|
||||
types.append(type);
|
||||
}
|
||||
}
|
||||
argItem.append(types);
|
||||
}
|
||||
}
|
||||
argSpec.append(argItem);
|
||||
}
|
||||
if (arg.defaultValue !== null) {
|
||||
const argDefault = document.createElement('div'); {
|
||||
argDefault.classList.add('argument-default');
|
||||
argDefault.title = 'default value';
|
||||
argDefault.textContent = arg.defaultValue.toString();
|
||||
argSpec.append(argDefault);
|
||||
}
|
||||
}
|
||||
listItem.append(argSpec);
|
||||
}
|
||||
const desc = document.createElement('div'); {
|
||||
desc.classList.add('argument-description');
|
||||
desc.innerHTML = arg.description;
|
||||
listItem.append(desc);
|
||||
}
|
||||
args.append(listItem);
|
||||
}
|
||||
}
|
||||
for (const arg of unnamedArguments) {
|
||||
const listItem = document.createElement('li'); {
|
||||
listItem.classList.add('argumentItem');
|
||||
const argSpec = document.createElement('div'); {
|
||||
argSpec.classList.add('argumentSpec');
|
||||
const argItem = document.createElement('div'); {
|
||||
argItem.classList.add('argument');
|
||||
argItem.classList.add('unnamedArgument');
|
||||
argItem.title = `${arg.isRequired ? '' : 'optional '}unnamed argument`;
|
||||
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
||||
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
||||
if (arg.enumList.length > 0) {
|
||||
const enums = document.createElement('span'); {
|
||||
enums.classList.add('argument-enums');
|
||||
enums.title = `${argItem.title} - accepted values`;
|
||||
for (const e of arg.enumList) {
|
||||
const enumItem = document.createElement('span'); {
|
||||
enumItem.classList.add('argument-enum');
|
||||
enumItem.textContent = e.value;
|
||||
enums.append(enumItem);
|
||||
}
|
||||
}
|
||||
argItem.append(enums);
|
||||
}
|
||||
} else {
|
||||
const types = document.createElement('span'); {
|
||||
types.classList.add('argument-types');
|
||||
types.title = `${argItem.title} - accepted types`;
|
||||
for (const t of arg.typeList) {
|
||||
const type = document.createElement('span'); {
|
||||
type.classList.add('argument-type');
|
||||
type.textContent = t;
|
||||
types.append(type);
|
||||
}
|
||||
}
|
||||
argItem.append(types);
|
||||
}
|
||||
}
|
||||
argSpec.append(argItem);
|
||||
}
|
||||
if (arg.defaultValue !== null) {
|
||||
const argDefault = document.createElement('div'); {
|
||||
argDefault.classList.add('argument-default');
|
||||
argDefault.title = 'default value';
|
||||
argDefault.textContent = arg.defaultValue.toString();
|
||||
argSpec.append(argDefault);
|
||||
}
|
||||
}
|
||||
listItem.append(argSpec);
|
||||
}
|
||||
const desc = document.createElement('div'); {
|
||||
desc.classList.add('argument-description');
|
||||
desc.innerHTML = arg.description;
|
||||
listItem.append(desc);
|
||||
}
|
||||
args.append(listItem);
|
||||
}
|
||||
}
|
||||
body.append(args);
|
||||
}
|
||||
const returns = document.createElement('span'); {
|
||||
returns.classList.add('returns');
|
||||
returns.title = [null, undefined, 'void'].includes(returnType) ? 'command does not return anything' : 'return value';
|
||||
returns.textContent = returnType ?? 'void';
|
||||
body.append(returns);
|
||||
}
|
||||
specs.append(body);
|
||||
}
|
||||
frag.append(specs);
|
||||
}
|
||||
const help = document.createElement('span'); {
|
||||
help.classList.add('help');
|
||||
help.innerHTML = helpString;
|
||||
for (const code of help.querySelectorAll('pre > code')) {
|
||||
code.classList.add('language-stscript');
|
||||
hljs.highlightElement(code);
|
||||
}
|
||||
frag.append(help);
|
||||
}
|
||||
if (aliasList.length > 0) {
|
||||
const aliases = document.createElement('span'); {
|
||||
aliases.classList.add('aliases');
|
||||
for (const aliasName of aliasList) {
|
||||
const alias = document.createElement('span'); {
|
||||
alias.classList.add('alias');
|
||||
alias.textContent = `/${aliasName}`;
|
||||
aliases.append(alias);
|
||||
}
|
||||
}
|
||||
frag.append(aliases);
|
||||
}
|
||||
}
|
||||
this.helpDetailsCache[key] = frag;
|
||||
}
|
||||
const frag = document.createDocumentFragment();
|
||||
frag.append(this.helpDetailsCache[key].cloneNode(true));
|
||||
return frag;
|
||||
}
|
||||
}
|
34
public/scripts/slash-commands/SlashCommandAbortController.js
Normal file
34
public/scripts/slash-commands/SlashCommandAbortController.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { AbstractEventTarget } from './AbstractEventTarget.js';
|
||||
|
||||
export class SlashCommandAbortController extends AbstractEventTarget {
|
||||
/**@type {SlashCommandAbortSignal}*/ signal;
|
||||
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.signal = new SlashCommandAbortSignal();
|
||||
}
|
||||
abort(reason = 'No reason.', isQuiet = false) {
|
||||
this.signal.isQuiet = isQuiet;
|
||||
this.signal.aborted = true;
|
||||
this.signal.reason = reason;
|
||||
this.dispatchEvent(new Event('abort'));
|
||||
}
|
||||
pause(reason = 'No reason.') {
|
||||
this.signal.paused = true;
|
||||
this.signal.reason = reason;
|
||||
this.dispatchEvent(new Event('pause'));
|
||||
}
|
||||
continue(reason = 'No reason.') {
|
||||
this.signal.paused = false;
|
||||
this.signal.reason = reason;
|
||||
this.dispatchEvent(new Event('continue'));
|
||||
}
|
||||
}
|
||||
|
||||
export class SlashCommandAbortSignal {
|
||||
/**@type {boolean}*/ isQuiet = false;
|
||||
/**@type {boolean}*/ paused = false;
|
||||
/**@type {boolean}*/ aborted = false;
|
||||
/**@type {string}*/ reason = null;
|
||||
}
|
133
public/scripts/slash-commands/SlashCommandArgument.js
Normal file
133
public/scripts/slash-commands/SlashCommandArgument.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
|
||||
import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
|
||||
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
|
||||
import { SlashCommandScope } from './SlashCommandScope.js';
|
||||
|
||||
|
||||
|
||||
/**@readonly*/
|
||||
/**@enum {string}*/
|
||||
export const ARGUMENT_TYPE = {
|
||||
'STRING': 'string',
|
||||
'NUMBER': 'number',
|
||||
'RANGE': 'range',
|
||||
'BOOLEAN': 'bool',
|
||||
'VARIABLE_NAME': 'varname',
|
||||
'CLOSURE': 'closure',
|
||||
'SUBCOMMAND': 'subcommand',
|
||||
'LIST': 'list',
|
||||
'DICTIONARY': 'dictionary',
|
||||
};
|
||||
|
||||
export class SlashCommandArgument {
|
||||
/**
|
||||
* Creates an unnamed argument from a properties object.
|
||||
* @param {Object} props
|
||||
* @param {string} props.description description of the argument
|
||||
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} [props.typeList=[ARGUMENT_TYPE.STRING]] default: ARGUMENT_TYPE.STRING - list of accepted types (from ARGUMENT_TYPE)
|
||||
* @param {boolean} [props.isRequired=false] default: false - whether the argument is required (false = optional argument)
|
||||
* @param {boolean} [props.acceptsMultiple=false] default: false - whether argument accepts multiple values
|
||||
* @param {string|SlashCommandClosure} [props.defaultValue=null] default value if no value is provided
|
||||
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [props.enumList=[]] list of accepted values
|
||||
* @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} [props.enumProvider=null] function that returns auto complete options
|
||||
* @param {boolean} [props.forceEnum=false] default: false - whether the input must match one of the enum values
|
||||
*/
|
||||
static fromProps(props) {
|
||||
return new SlashCommandArgument(
|
||||
props.description,
|
||||
props.typeList ?? [ARGUMENT_TYPE.STRING],
|
||||
props.isRequired ?? false,
|
||||
props.acceptsMultiple ?? false,
|
||||
props.defaultValue ?? null,
|
||||
props.enumList ?? [],
|
||||
props.enumProvider ?? null,
|
||||
props.forceEnum ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/**@type {string}*/ description;
|
||||
/**@type {ARGUMENT_TYPE[]}*/ typeList = [];
|
||||
/**@type {boolean}*/ isRequired = false;
|
||||
/**@type {boolean}*/ acceptsMultiple = false;
|
||||
/**@type {string|SlashCommandClosure}*/ defaultValue;
|
||||
/**@type {SlashCommandEnumValue[]}*/ enumList = [];
|
||||
/**@type {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]}*/ enumProvider = null;
|
||||
/**@type {boolean}*/ forceEnum = false;
|
||||
|
||||
/**
|
||||
* @param {string} description
|
||||
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} types
|
||||
* @param {string|SlashCommandClosure} defaultValue
|
||||
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} enums
|
||||
* @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} enumProvider function that returns auto complete options
|
||||
*/
|
||||
constructor(description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], enumProvider = null, forceEnum = false) {
|
||||
this.description = description;
|
||||
this.typeList = types ? Array.isArray(types) ? types : [types] : [];
|
||||
this.isRequired = isRequired ?? false;
|
||||
this.acceptsMultiple = acceptsMultiple ?? false;
|
||||
this.defaultValue = defaultValue;
|
||||
this.enumList = (enums ? Array.isArray(enums) ? enums : [enums] : []).map(it=>{
|
||||
if (it instanceof SlashCommandEnumValue) return it;
|
||||
return new SlashCommandEnumValue(it);
|
||||
});
|
||||
this.enumProvider = enumProvider;
|
||||
this.forceEnum = forceEnum;
|
||||
|
||||
// If no enums were set explictly and the type is one where we know possible enum values, we set them here
|
||||
if (!this.enumList.length && this.typeList.length === 1 && this.typeList.includes(ARGUMENT_TYPE.BOOLEAN)) this.enumList = commonEnumProviders.boolean()();
|
||||
}
|
||||
}
|
||||
|
||||
export class SlashCommandNamedArgument extends SlashCommandArgument {
|
||||
/**
|
||||
* Creates an unnamed argument from a properties object.
|
||||
* @param {Object} props
|
||||
* @param {string} props.name the argument's name
|
||||
* @param {string} props.description description of the argument
|
||||
* @param {string[]} [props.aliasList=[]] list of aliases
|
||||
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} [props.typeList=[ARGUMENT_TYPE.STRING]] default: ARGUMENT_TYPE.STRING - list of accepted types (from ARGUMENT_TYPE)
|
||||
* @param {boolean} [props.isRequired=false] default: false - whether the argument is required (false = optional argument)
|
||||
* @param {boolean} [props.acceptsMultiple=false] default: false - whether argument accepts multiple values
|
||||
* @param {string|SlashCommandClosure} [props.defaultValue=null] default value if no value is provided
|
||||
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [props.enumList=[]] list of accepted values
|
||||
* @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} [props.enumProvider=null] function that returns auto complete options
|
||||
* @param {boolean} [props.forceEnum=false] default: false - whether the input must match one of the enum values
|
||||
*/
|
||||
static fromProps(props) {
|
||||
return new SlashCommandNamedArgument(
|
||||
props.name,
|
||||
props.description,
|
||||
props.typeList ?? [ARGUMENT_TYPE.STRING],
|
||||
props.isRequired ?? false,
|
||||
props.acceptsMultiple ?? false,
|
||||
props.defaultValue ?? null,
|
||||
props.enumList ?? [],
|
||||
props.aliasList ?? [],
|
||||
props.enumProvider ?? null,
|
||||
props.forceEnum ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/**@type {string}*/ name;
|
||||
/**@type {string[]}*/ aliasList = [];
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} description
|
||||
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} types
|
||||
* @param {boolean} [isRequired=false]
|
||||
* @param {boolean} [acceptsMultiple=false]
|
||||
* @param {string|SlashCommandClosure} [defaultValue=null]
|
||||
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [enums=[]]
|
||||
* @param {string[]} [aliases=[]]
|
||||
* @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} [enumProvider=null] function that returns auto complete options
|
||||
* @param {boolean} [forceEnum=false]
|
||||
*/
|
||||
constructor(name, description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], aliases = [], enumProvider = null, forceEnum = false) {
|
||||
super(description, types, isRequired, acceptsMultiple, defaultValue, enums, enumProvider, forceEnum);
|
||||
this.name = name;
|
||||
this.aliasList = aliases ? Array.isArray(aliases) ? aliases : [aliases] : [];
|
||||
}
|
||||
}
|
@@ -0,0 +1,191 @@
|
||||
import { AutoCompleteNameResult } from '../autocomplete/AutoCompleteNameResult.js';
|
||||
import { AutoCompleteSecondaryNameResult } from '../autocomplete/AutoCompleteSecondaryNameResult.js';
|
||||
import { SlashCommand } from './SlashCommand.js';
|
||||
import { SlashCommandCommandAutoCompleteOption } from './SlashCommandCommandAutoCompleteOption.js';
|
||||
import { SlashCommandEnumAutoCompleteOption } from './SlashCommandEnumAutoCompleteOption.js';
|
||||
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
|
||||
import { SlashCommandNamedArgumentAutoCompleteOption } from './SlashCommandNamedArgumentAutoCompleteOption.js';
|
||||
import { SlashCommandScope } from './SlashCommandScope.js';
|
||||
|
||||
export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
|
||||
/**@type {SlashCommandExecutor}*/ executor;
|
||||
/**@type {SlashCommandScope}*/ scope;
|
||||
|
||||
/**
|
||||
* @param {SlashCommandExecutor} executor
|
||||
* @param {SlashCommandScope} scope
|
||||
* @param {Object.<string,SlashCommand>} commands
|
||||
*/
|
||||
constructor(executor, scope, commands) {
|
||||
super(
|
||||
executor.name,
|
||||
executor.start,
|
||||
Object
|
||||
.keys(commands)
|
||||
.map(key=>new SlashCommandCommandAutoCompleteOption(commands[key], key))
|
||||
,
|
||||
false,
|
||||
()=>`No matching slash commands for "/${this.name}"`,
|
||||
()=>'No slash commands found!',
|
||||
);
|
||||
this.executor = executor;
|
||||
this.scope = scope;
|
||||
}
|
||||
|
||||
getSecondaryNameAt(text, index, isSelect) {
|
||||
const namedResult = this.getNamedArgumentAt(text, index, isSelect);
|
||||
if (!namedResult || namedResult.optionList.length == 0 || !namedResult.isRequired) {
|
||||
const unnamedResult = this.getUnnamedArgumentAt(text, index, isSelect);
|
||||
if (!namedResult) return unnamedResult;
|
||||
if (namedResult && unnamedResult) {
|
||||
const combinedResult = new AutoCompleteSecondaryNameResult(
|
||||
namedResult.name,
|
||||
namedResult.start,
|
||||
[...namedResult.optionList, ...unnamedResult.optionList],
|
||||
);
|
||||
combinedResult.isRequired = namedResult.isRequired || unnamedResult.isRequired;
|
||||
combinedResult.forceMatch = namedResult.forceMatch && unnamedResult.forceMatch;
|
||||
return combinedResult;
|
||||
}
|
||||
}
|
||||
return namedResult;
|
||||
}
|
||||
|
||||
getNamedArgumentAt(text, index, isSelect) {
|
||||
function getSplitRegex() {
|
||||
try {
|
||||
return new RegExp('(?<==)');
|
||||
} catch {
|
||||
// For browsers that don't support lookbehind
|
||||
return new RegExp('=(.*)');
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(this.executor.command?.namedArgumentList)) {
|
||||
return null;
|
||||
}
|
||||
const notProvidedNamedArguments = this.executor.command.namedArgumentList.filter(arg=>!this.executor.namedArgumentList.find(it=>it.name == arg.name));
|
||||
let name;
|
||||
let value;
|
||||
let start;
|
||||
let cmdArg;
|
||||
let argAssign;
|
||||
const unamedArgLength = this.executor.endUnnamedArgs - this.executor.startUnnamedArgs;
|
||||
const namedArgsFollowedBySpace = text[this.executor.endNamedArgs] == ' ';
|
||||
if (this.executor.startNamedArgs <= index && this.executor.endNamedArgs + (namedArgsFollowedBySpace ? 1 : 0) >= index) {
|
||||
// cursor is somewhere within the named arguments (including final space)
|
||||
argAssign = this.executor.namedArgumentList.find(it=>it.start <= index && it.end >= index);
|
||||
if (argAssign) {
|
||||
const [argName, ...v] = text.slice(argAssign.start, index).split(getSplitRegex());
|
||||
name = argName;
|
||||
value = v.join('');
|
||||
start = argAssign.start;
|
||||
cmdArg = this.executor.command.namedArgumentList.find(it=>[it.name, `${it.name}=`].includes(argAssign.name));
|
||||
if (cmdArg) notProvidedNamedArguments.push(cmdArg);
|
||||
} else {
|
||||
name = '';
|
||||
start = index;
|
||||
}
|
||||
} else if (unamedArgLength > 0 && index >= this.executor.startUnnamedArgs && index <= this.executor.endUnnamedArgs) {
|
||||
// cursor is somewhere within the unnamed arguments
|
||||
// if index is in first array item and that is a string, treat it as an unfinished named arg
|
||||
if (typeof this.executor.unnamedArgumentList[0]?.value == 'string') {
|
||||
if (index <= this.executor.startUnnamedArgs + this.executor.unnamedArgumentList[0].value.length) {
|
||||
name = this.executor.unnamedArgumentList[0].value.slice(0, index - this.executor.startUnnamedArgs);
|
||||
start = this.executor.startUnnamedArgs;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (name.includes('=') && cmdArg) {
|
||||
// if cursor is already behind "=" check for enums
|
||||
const enumList = cmdArg?.enumProvider?.(this.executor, this.scope) ?? cmdArg?.enumList;
|
||||
if (cmdArg && enumList?.length) {
|
||||
if (isSelect && enumList.find(it=>it.value == value) && argAssign && argAssign.end == index) {
|
||||
return null;
|
||||
}
|
||||
const result = new AutoCompleteSecondaryNameResult(
|
||||
value,
|
||||
start + name.length,
|
||||
enumList.map(it=>SlashCommandEnumAutoCompleteOption.from(this.executor.command, it)),
|
||||
true,
|
||||
);
|
||||
result.isRequired = true;
|
||||
result.forceMatch = cmdArg.forceEnum;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (notProvidedNamedArguments.length > 0) {
|
||||
const result = new AutoCompleteSecondaryNameResult(
|
||||
name,
|
||||
start,
|
||||
notProvidedNamedArguments.map(it=>new SlashCommandNamedArgumentAutoCompleteOption(it, this.executor.command)),
|
||||
false,
|
||||
);
|
||||
result.isRequired = notProvidedNamedArguments.find(it=>it.isRequired) != null;
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getUnnamedArgumentAt(text, index, isSelect) {
|
||||
if (!Array.isArray(this.executor.command?.unnamedArgumentList)) {
|
||||
return null;
|
||||
}
|
||||
const lastArgIsBlank = this.executor.unnamedArgumentList.slice(-1)[0]?.value == '';
|
||||
const notProvidedArguments = this.executor.command.unnamedArgumentList.slice(this.executor.unnamedArgumentList.length - (lastArgIsBlank ? 1 : 0));
|
||||
let value;
|
||||
let start;
|
||||
let cmdArg;
|
||||
let argAssign;
|
||||
if (this.executor.startUnnamedArgs <= index && this.executor.endUnnamedArgs + 1 >= index) {
|
||||
// cursor is somwehere in the unnamed args
|
||||
const idx = this.executor.unnamedArgumentList.findIndex(it=>it.start <= index && it.end >= index);
|
||||
if (idx > -1) {
|
||||
argAssign = this.executor.unnamedArgumentList[idx];
|
||||
cmdArg = this.executor.command.unnamedArgumentList[idx];
|
||||
if (cmdArg === undefined && this.executor.command.unnamedArgumentList.slice(-1)[0]?.acceptsMultiple) {
|
||||
cmdArg = this.executor.command.unnamedArgumentList.slice(-1)[0];
|
||||
}
|
||||
const enumList = cmdArg?.enumProvider?.(this.executor, this.scope) ?? cmdArg?.enumList;
|
||||
if (cmdArg && enumList.length > 0) {
|
||||
value = argAssign.value.toString().slice(0, index - argAssign.start);
|
||||
start = argAssign.start;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
value = '';
|
||||
start = index;
|
||||
cmdArg = notProvidedArguments[0];
|
||||
if (cmdArg === undefined && this.executor.command.unnamedArgumentList.slice(-1)[0]?.acceptsMultiple) {
|
||||
cmdArg = this.executor.command.unnamedArgumentList.slice(-1)[0];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enumList = cmdArg?.enumProvider?.(this.executor, this.scope) ?? cmdArg?.enumList;
|
||||
if (cmdArg == null || enumList.length == 0) return null;
|
||||
|
||||
const result = new AutoCompleteSecondaryNameResult(
|
||||
value,
|
||||
start,
|
||||
enumList.map(it=>SlashCommandEnumAutoCompleteOption.from(this.executor.command, it)),
|
||||
false,
|
||||
);
|
||||
const isCompleteValue = enumList.find(it=>it.value == value);
|
||||
const isSelectedValue = isSelect && isCompleteValue;
|
||||
result.isRequired = cmdArg.isRequired && !isSelectedValue;
|
||||
result.forceMatch = cmdArg.forceEnum;
|
||||
return result;
|
||||
}
|
||||
}
|
7
public/scripts/slash-commands/SlashCommandBreak.js
Normal file
7
public/scripts/slash-commands/SlashCommandBreak.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
|
||||
|
||||
export class SlashCommandBreak extends SlashCommandExecutor {
|
||||
get value() {
|
||||
return this.unnamedArgumentList[0]?.value;
|
||||
}
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
export class SlashCommandBreakController {
|
||||
/**@type {boolean} */ isBreak = false;
|
||||
|
||||
break() {
|
||||
this.isBreak = true;
|
||||
}
|
||||
}
|
3
public/scripts/slash-commands/SlashCommandBreakPoint.js
Normal file
3
public/scripts/slash-commands/SlashCommandBreakPoint.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
|
||||
|
||||
export class SlashCommandBreakPoint extends SlashCommandExecutor {}
|
147
public/scripts/slash-commands/SlashCommandBrowser.js
Normal file
147
public/scripts/slash-commands/SlashCommandBrowser.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import { escapeRegex } from '../utils.js';
|
||||
import { SlashCommandParser } from './SlashCommandParser.js';
|
||||
|
||||
export class SlashCommandBrowser {
|
||||
/**@type {SlashCommand[]}*/ cmdList;
|
||||
/**@type {HTMLElement}*/ dom;
|
||||
/**@type {HTMLElement}*/ search;
|
||||
/**@type {HTMLElement}*/ details;
|
||||
/**@type {Object.<string,HTMLElement>}*/ itemMap = {};
|
||||
/**@type {MutationObserver}*/ mo;
|
||||
|
||||
renderInto(parent) {
|
||||
if (!this.dom) {
|
||||
const queryRegex = /(?:(?:^|\s+)([^\s"][^\s]*?)(?:\s+|$))|(?:(?:^|\s+)"(.*?)(?:"|$)(?:\s+|$))/;
|
||||
const root = document.createElement('div'); {
|
||||
this.dom = root;
|
||||
const search = document.createElement('div'); {
|
||||
search.classList.add('search');
|
||||
const lbl = document.createElement('label'); {
|
||||
lbl.classList.add('searchLabel');
|
||||
lbl.textContent = 'Search: ';
|
||||
const inp = document.createElement('input'); {
|
||||
this.search = inp;
|
||||
inp.classList.add('searchInput');
|
||||
inp.classList.add('text_pole');
|
||||
inp.type = 'search';
|
||||
inp.placeholder = 'Search slash commands - use quotes to search "literal" instead of fuzzy';
|
||||
inp.addEventListener('input', ()=>{
|
||||
this.details?.remove();
|
||||
this.details = null;
|
||||
let query = inp.value.trim();
|
||||
if (query.slice(-1) === '"' && !/(?:^|\s+)"/.test(query)) {
|
||||
query = `"${query}`;
|
||||
}
|
||||
let fuzzyList = [];
|
||||
let quotedList = [];
|
||||
while (query.length > 0) {
|
||||
const match = queryRegex.exec(query);
|
||||
if (!match) break;
|
||||
if (match[1] !== undefined) {
|
||||
fuzzyList.push(new RegExp(`^(.*?)${match[1].split('').map(char=>`(${escapeRegex(char)})`).join('(.*?)')}(.*?)$`, 'i'));
|
||||
} else if (match[2] !== undefined) {
|
||||
quotedList.push(match[2]);
|
||||
}
|
||||
query = query.slice(match.index + match[0].length);
|
||||
}
|
||||
for (const cmd of this.cmdList) {
|
||||
const targets = [
|
||||
cmd.name,
|
||||
...cmd.namedArgumentList.map(it=>it.name),
|
||||
...cmd.namedArgumentList.map(it=>it.description),
|
||||
...cmd.namedArgumentList.map(it=>it.enumList.map(e=>e.value)).flat(),
|
||||
...cmd.namedArgumentList.map(it=>it.typeList).flat(),
|
||||
...cmd.unnamedArgumentList.map(it=>it.description),
|
||||
...cmd.unnamedArgumentList.map(it=>it.enumList.map(e=>e.value)).flat(),
|
||||
...cmd.unnamedArgumentList.map(it=>it.typeList).flat(),
|
||||
...cmd.aliases,
|
||||
cmd.helpString,
|
||||
];
|
||||
const find = ()=>targets.find(t=>(fuzzyList.find(f=>f.test(t)) ?? quotedList.find(q=>t.includes(q))) !== undefined) !== undefined;
|
||||
if (fuzzyList.length + quotedList.length === 0 || find()) {
|
||||
this.itemMap[cmd.name].classList.remove('isFiltered');
|
||||
} else {
|
||||
this.itemMap[cmd.name].classList.add('isFiltered');
|
||||
}
|
||||
}
|
||||
});
|
||||
lbl.append(inp);
|
||||
}
|
||||
search.append(lbl);
|
||||
}
|
||||
root.append(search);
|
||||
}
|
||||
const container = document.createElement('div'); {
|
||||
container.classList.add('commandContainer');
|
||||
const list = document.createElement('div'); {
|
||||
list.classList.add('autoComplete');
|
||||
this.cmdList = Object
|
||||
.keys(SlashCommandParser.commands)
|
||||
.filter(key => SlashCommandParser.commands[key].name === key) // exclude aliases
|
||||
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
|
||||
.map(key => SlashCommandParser.commands[key])
|
||||
;
|
||||
for (const cmd of this.cmdList) {
|
||||
const item = cmd.renderHelpItem();
|
||||
this.itemMap[cmd.name] = item;
|
||||
let details;
|
||||
item.addEventListener('click', ()=>{
|
||||
if (!details) {
|
||||
details = document.createElement('div'); {
|
||||
details.classList.add('autoComplete-detailsWrap');
|
||||
const inner = document.createElement('div'); {
|
||||
inner.classList.add('autoComplete-details');
|
||||
inner.append(cmd.renderHelpDetails());
|
||||
details.append(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.details !== details) {
|
||||
Array.from(list.querySelectorAll('.selected')).forEach(it=>it.classList.remove('selected'));
|
||||
item.classList.add('selected');
|
||||
this.details?.remove();
|
||||
container.append(details);
|
||||
this.details = details;
|
||||
const pRect = list.getBoundingClientRect();
|
||||
const rect = item.children[0].getBoundingClientRect();
|
||||
details.style.setProperty('--targetOffset', rect.top - pRect.top);
|
||||
} else {
|
||||
item.classList.remove('selected');
|
||||
details.remove();
|
||||
this.details = null;
|
||||
}
|
||||
});
|
||||
list.append(item);
|
||||
}
|
||||
container.append(list);
|
||||
}
|
||||
root.append(container);
|
||||
}
|
||||
root.classList.add('slashCommandBrowser');
|
||||
}
|
||||
}
|
||||
parent.append(this.dom);
|
||||
|
||||
this.mo = new MutationObserver(muts=>{
|
||||
if (muts.find(mut=>Array.from(mut.removedNodes).find(it=>it === this.dom || it.contains(this.dom)))) {
|
||||
this.mo.disconnect();
|
||||
window.removeEventListener('keydown', boundHandler);
|
||||
}
|
||||
});
|
||||
this.mo.observe(document.querySelector('#chat'), { childList:true, subtree:true });
|
||||
const boundHandler = this.handleKeyDown.bind(this);
|
||||
window.addEventListener('keydown', boundHandler);
|
||||
return this.dom;
|
||||
}
|
||||
|
||||
handleKeyDown(evt) {
|
||||
if (!evt.shiftKey && !evt.altKey && evt.ctrlKey && evt.key.toLowerCase() === 'f') {
|
||||
if (!this.dom.closest('body')) return;
|
||||
if (this.dom.closest('.mes') && !this.dom.closest('.last_mes')) return;
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
evt.stopImmediatePropagation();
|
||||
this.search.focus();
|
||||
}
|
||||
}
|
||||
}
|
532
public/scripts/slash-commands/SlashCommandClosure.js
Normal file
532
public/scripts/slash-commands/SlashCommandClosure.js
Normal file
@@ -0,0 +1,532 @@
|
||||
import { substituteParams } from '../../script.js';
|
||||
import { delay, escapeRegex, uuidv4 } from '../utils.js';
|
||||
import { SlashCommand } from './SlashCommand.js';
|
||||
import { SlashCommandAbortController } from './SlashCommandAbortController.js';
|
||||
import { SlashCommandBreak } from './SlashCommandBreak.js';
|
||||
import { SlashCommandBreakController } from './SlashCommandBreakController.js';
|
||||
import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
|
||||
import { SlashCommandClosureResult } from './SlashCommandClosureResult.js';
|
||||
import { SlashCommandDebugController } from './SlashCommandDebugController.js';
|
||||
import { SlashCommandExecutionError } from './SlashCommandExecutionError.js';
|
||||
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
|
||||
import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
|
||||
import { SlashCommandScope } from './SlashCommandScope.js';
|
||||
|
||||
export class SlashCommandClosure {
|
||||
/** @type {SlashCommandScope} */ scope;
|
||||
/** @type {boolean} */ executeNow = false;
|
||||
/** @type {SlashCommandNamedArgumentAssignment[]} */ argumentList = [];
|
||||
/** @type {SlashCommandNamedArgumentAssignment[]} */ providedArgumentList = [];
|
||||
/** @type {SlashCommandExecutor[]} */ executorList = [];
|
||||
/** @type {SlashCommandAbortController} */ abortController;
|
||||
/** @type {SlashCommandBreakController} */ breakController;
|
||||
/** @type {SlashCommandDebugController} */ debugController;
|
||||
/** @type {(done:number, total:number)=>void} */ onProgress;
|
||||
/** @type {string} */ rawText;
|
||||
/** @type {string} */ fullText;
|
||||
/** @type {string} */ parserContext;
|
||||
/** @type {string} */ #source = uuidv4();
|
||||
get source() { return this.#source; }
|
||||
set source(value) {
|
||||
this.#source = value;
|
||||
for (const executor of this.executorList) {
|
||||
executor.source = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**@type {number}*/
|
||||
get commandCount() {
|
||||
return this.executorList.map(executor=>executor.commandCount).reduce((sum,cur)=>sum + cur, 0);
|
||||
}
|
||||
|
||||
constructor(parent) {
|
||||
this.scope = new SlashCommandScope(parent);
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `[Closure]${this.executeNow ? '()' : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {SlashCommandScope} scope
|
||||
* @returns {string|SlashCommandClosure|(string|SlashCommandClosure)[]}
|
||||
*/
|
||||
substituteParams(text, scope = null) {
|
||||
let isList = false;
|
||||
let listValues = [];
|
||||
scope = scope ?? this.scope;
|
||||
const escapeMacro = (it, isAnchored = false)=>{
|
||||
const regexText = escapeRegex(it.key.replace(/\*/g, '~~~WILDCARD~~~'))
|
||||
.replaceAll('~~~WILDCARD~~~', '(?:(?:(?!(?:::|}})).)*)')
|
||||
;
|
||||
if (isAnchored) {
|
||||
return `^${regexText}$`;
|
||||
}
|
||||
return regexText;
|
||||
};
|
||||
const macroList = scope.macroList.toSorted((a,b)=>{
|
||||
if (a.key.includes('*') && !b.key.includes('*')) return 1;
|
||||
if (!a.key.includes('*') && b.key.includes('*')) return -1;
|
||||
if (a.key.includes('*') && b.key.includes('*')) return b.key.indexOf('*') - a.key.indexOf('*');
|
||||
return 0;
|
||||
});
|
||||
const macros = macroList.map(it=>escapeMacro(it)).join('|');
|
||||
const re = new RegExp(`(?<pipe>{{pipe}})|(?:{{var::(?<var>[^\\s]+?)(?:::(?<varIndex>(?!}}).+))?}})|(?:{{(?<macro>${macros})}})`);
|
||||
let done = '';
|
||||
let remaining = text;
|
||||
while (re.test(remaining)) {
|
||||
const match = re.exec(remaining);
|
||||
const before = substituteParams(remaining.slice(0, match.index));
|
||||
const after = remaining.slice(match.index + match[0].length);
|
||||
const replacer = match.groups.pipe ? scope.pipe : match.groups.var ? scope.getVariable(match.groups.var, match.groups.index) : macroList.find(it=>it.key == match.groups.macro || new RegExp(escapeMacro(it, true)).test(match.groups.macro))?.value;
|
||||
if (replacer instanceof SlashCommandClosure) {
|
||||
replacer.abortController = this.abortController;
|
||||
replacer.breakController = this.breakController;
|
||||
replacer.scope.parent = this.scope;
|
||||
if (this.debugController && !replacer.debugController) {
|
||||
replacer.debugController = this.debugController;
|
||||
}
|
||||
isList = true;
|
||||
if (match.index > 0) {
|
||||
listValues.push(before);
|
||||
}
|
||||
listValues.push(replacer);
|
||||
if (match.index + match[0].length + 1 < remaining.length) {
|
||||
const rest = this.substituteParams(after, scope);
|
||||
listValues.push(...(Array.isArray(rest) ? rest : [rest]));
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
done = `${done}${before}${replacer}`;
|
||||
remaining = after;
|
||||
}
|
||||
}
|
||||
if (!isList) {
|
||||
text = `${done}${substituteParams(remaining)}`;
|
||||
}
|
||||
|
||||
if (isList) {
|
||||
if (listValues.length > 1) return listValues;
|
||||
return listValues[0];
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
getCopy() {
|
||||
const closure = new SlashCommandClosure();
|
||||
closure.scope = this.scope.getCopy();
|
||||
closure.executeNow = this.executeNow;
|
||||
closure.argumentList = this.argumentList;
|
||||
closure.providedArgumentList = this.providedArgumentList;
|
||||
closure.executorList = this.executorList;
|
||||
closure.abortController = this.abortController;
|
||||
closure.breakController = this.breakController;
|
||||
closure.debugController = this.debugController;
|
||||
closure.rawText = this.rawText;
|
||||
closure.fullText = this.fullText;
|
||||
closure.parserContext = this.parserContext;
|
||||
closure.source = this.source;
|
||||
closure.onProgress = this.onProgress;
|
||||
return closure;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<SlashCommandClosureResult>}
|
||||
*/
|
||||
async execute() {
|
||||
// execute a copy of the closure to no taint it and its scope with the effects of its execution
|
||||
// as this would affect the closure being called a second time (e.g., loop, multiple /run calls)
|
||||
const closure = this.getCopy();
|
||||
const gen = closure.executeDirect();
|
||||
let step;
|
||||
while (!step?.done) {
|
||||
step = await gen.next(this.debugController?.testStepping(this) ?? false);
|
||||
if (!(step.value instanceof SlashCommandClosureResult) && this.debugController) {
|
||||
this.debugController.isStepping = await this.debugController.awaitBreakPoint(step.value.closure, step.value.executor);
|
||||
}
|
||||
}
|
||||
return step.value;
|
||||
}
|
||||
|
||||
async * executeDirect() {
|
||||
this.debugController?.down(this);
|
||||
// closure arguments
|
||||
for (const arg of this.argumentList) {
|
||||
let v = arg.value;
|
||||
if (v instanceof SlashCommandClosure) {
|
||||
/**@type {SlashCommandClosure}*/
|
||||
const closure = v;
|
||||
closure.scope.parent = this.scope;
|
||||
closure.breakController = this.breakController;
|
||||
if (closure.executeNow) {
|
||||
v = (await closure.execute())?.pipe;
|
||||
} else {
|
||||
v = closure;
|
||||
}
|
||||
} else {
|
||||
v = this.substituteParams(v);
|
||||
}
|
||||
// unescape value
|
||||
if (typeof v == 'string') {
|
||||
v = v
|
||||
?.replace(/\\\{/g, '{')
|
||||
?.replace(/\\\}/g, '}')
|
||||
;
|
||||
}
|
||||
this.scope.letVariable(arg.name, v);
|
||||
}
|
||||
for (const arg of this.providedArgumentList) {
|
||||
let v = arg.value;
|
||||
if (v instanceof SlashCommandClosure) {
|
||||
/**@type {SlashCommandClosure}*/
|
||||
const closure = v;
|
||||
closure.scope.parent = this.scope;
|
||||
closure.breakController = this.breakController;
|
||||
if (closure.executeNow) {
|
||||
v = (await closure.execute())?.pipe;
|
||||
} else {
|
||||
v = closure;
|
||||
}
|
||||
} else {
|
||||
v = this.substituteParams(v, this.scope.parent);
|
||||
}
|
||||
// unescape value
|
||||
if (typeof v == 'string') {
|
||||
v = v
|
||||
?.replace(/\\\{/g, '{')
|
||||
?.replace(/\\\}/g, '}')
|
||||
;
|
||||
}
|
||||
this.scope.setVariable(arg.name, v);
|
||||
}
|
||||
|
||||
if (this.executorList.length == 0) {
|
||||
this.scope.pipe = '';
|
||||
}
|
||||
const stepper = this.executeStep();
|
||||
let step;
|
||||
while (!step?.done && !this.breakController?.isBreak) {
|
||||
// get executor before execution
|
||||
step = await stepper.next();
|
||||
if (step.value instanceof SlashCommandBreakPoint) {
|
||||
console.log('encountered SlashCommandBreakPoint');
|
||||
if (this.debugController) {
|
||||
// resolve args
|
||||
step = await stepper.next();
|
||||
// "execute" breakpoint
|
||||
step = await stepper.next();
|
||||
// get next executor
|
||||
step = await stepper.next();
|
||||
// breakpoint has to yield before arguments are resolved if one of the
|
||||
// arguments is an immediate closure, otherwise you cannot step into the
|
||||
// immediate closure
|
||||
const hasImmediateClosureInNamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.namedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow);
|
||||
const hasImmediateClosureInUnnamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.unnamedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow);
|
||||
if (hasImmediateClosureInNamedArgs || hasImmediateClosureInUnnamedArgs) {
|
||||
this.debugController.isStepping = yield { closure:this, executor:step.value };
|
||||
} else {
|
||||
this.debugController.isStepping = true;
|
||||
this.debugController.stepStack[this.debugController.stepStack.length - 1] = true;
|
||||
}
|
||||
}
|
||||
} else if (!step.done && this.debugController?.testStepping(this)) {
|
||||
this.debugController.isSteppingInto = false;
|
||||
// if stepping, have to yield before arguments are resolved if one of the arguments
|
||||
// is an immediate closure, otherwise you cannot step into the immediate closure
|
||||
const hasImmediateClosureInNamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.namedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow);
|
||||
const hasImmediateClosureInUnnamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.unnamedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow);
|
||||
if (hasImmediateClosureInNamedArgs || hasImmediateClosureInUnnamedArgs) {
|
||||
this.debugController.isStepping = yield { closure:this, executor:step.value };
|
||||
}
|
||||
}
|
||||
// resolve args
|
||||
step = await stepper.next();
|
||||
if (step.value instanceof SlashCommandBreak) {
|
||||
console.log('encountered SlashCommandBreak');
|
||||
if (this.breakController) {
|
||||
this.breakController?.break();
|
||||
break;
|
||||
}
|
||||
} else if (!step.done && this.debugController?.testStepping(this)) {
|
||||
this.debugController.isSteppingInto = false;
|
||||
this.debugController.isStepping = yield { closure:this, executor:step.value };
|
||||
}
|
||||
// execute executor
|
||||
step = await stepper.next();
|
||||
}
|
||||
|
||||
// if execution has returned a closure result, return that (should only happen on abort)
|
||||
if (step.value instanceof SlashCommandClosureResult) {
|
||||
this.debugController?.up();
|
||||
return step.value;
|
||||
}
|
||||
/**@type {SlashCommandClosureResult} */
|
||||
const result = Object.assign(new SlashCommandClosureResult(), { pipe: this.scope.pipe, isBreak: this.breakController?.isBreak ?? false });
|
||||
this.debugController?.up();
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Generator that steps through the executor list.
|
||||
* Every executor is split into three steps:
|
||||
* - before arguments are resolved
|
||||
* - after arguments are resolved
|
||||
* - after execution
|
||||
*/
|
||||
async * executeStep() {
|
||||
let done = 0;
|
||||
let isFirst = true;
|
||||
for (const executor of this.executorList) {
|
||||
this.onProgress?.(done, this.commandCount);
|
||||
if (this.debugController) {
|
||||
this.debugController.setExecutor(executor);
|
||||
this.debugController.namedArguments = undefined;
|
||||
this.debugController.unnamedArguments = undefined;
|
||||
}
|
||||
// yield before doing anything with this executor, the debugger might want to do
|
||||
// something with it (e.g., breakpoint, immediate closures that need resolving
|
||||
// or stepping into)
|
||||
yield executor;
|
||||
/**@type {import('./SlashCommand.js').NamedArguments} */
|
||||
// @ts-ignore
|
||||
let args = {
|
||||
_scope: this.scope,
|
||||
_parserFlags: executor.parserFlags,
|
||||
_abortController: this.abortController,
|
||||
_debugController: this.debugController,
|
||||
_hasUnnamedArgument: executor.unnamedArgumentList.length > 0,
|
||||
};
|
||||
if (executor instanceof SlashCommandBreakPoint) {
|
||||
// nothing to do for breakpoints, just raise counter and yield for "before exec"
|
||||
done++;
|
||||
yield executor;
|
||||
isFirst = false;
|
||||
} else if (executor instanceof SlashCommandBreak) {
|
||||
// /break need to resolve the unnamed arg and put it into pipe, then yield
|
||||
// for "before exec"
|
||||
const value = await this.substituteUnnamedArgument(executor, isFirst, args);
|
||||
done += this.executorList.length - this.executorList.indexOf(executor);
|
||||
this.scope.pipe = value ?? this.scope.pipe;
|
||||
yield executor;
|
||||
isFirst = false;
|
||||
} else {
|
||||
// regular commands do all the argument resolving logic...
|
||||
await this.substituteNamedArguments(executor, args);
|
||||
let value = await this.substituteUnnamedArgument(executor, isFirst, args);
|
||||
|
||||
let abortResult = await this.testAbortController();
|
||||
if (abortResult) {
|
||||
return abortResult;
|
||||
}
|
||||
if (this.debugController) {
|
||||
this.debugController.namedArguments = args;
|
||||
this.debugController.unnamedArguments = value ?? '';
|
||||
}
|
||||
// then yield for "before exec"
|
||||
yield executor;
|
||||
// followed by command execution
|
||||
executor.onProgress = (subDone, subTotal)=>this.onProgress?.(done + subDone, this.commandCount);
|
||||
const isStepping = this.debugController?.testStepping(this);
|
||||
if (this.debugController) {
|
||||
this.debugController.isStepping = false || this.debugController.isSteppingInto;
|
||||
}
|
||||
try {
|
||||
this.scope.pipe = await executor.command.callback(args, value ?? '');
|
||||
} catch (ex) {
|
||||
throw new SlashCommandExecutionError(ex, ex.message, executor.name, executor.start, executor.end, this.fullText.slice(executor.start, executor.end), this.fullText);
|
||||
}
|
||||
if (this.debugController) {
|
||||
this.debugController.namedArguments = undefined;
|
||||
this.debugController.unnamedArguments = undefined;
|
||||
this.debugController.isStepping = isStepping;
|
||||
}
|
||||
this.#lintPipe(executor.command);
|
||||
done += executor.commandCount;
|
||||
this.onProgress?.(done, this.commandCount);
|
||||
abortResult = await this.testAbortController();
|
||||
if (abortResult) {
|
||||
return abortResult;
|
||||
}
|
||||
}
|
||||
// finally, yield for "after exec"
|
||||
yield executor;
|
||||
isFirst = false;
|
||||
}
|
||||
}
|
||||
|
||||
async testPaused() {
|
||||
while (!this.abortController?.signal?.aborted && this.abortController?.signal?.paused) {
|
||||
await delay(200);
|
||||
}
|
||||
}
|
||||
async testAbortController() {
|
||||
await this.testPaused();
|
||||
if (this.abortController?.signal?.aborted) {
|
||||
const result = new SlashCommandClosureResult();
|
||||
result.isAborted = true;
|
||||
result.isQuietlyAborted = this.abortController.signal.isQuiet;
|
||||
result.abortReason = this.abortController.signal.reason.toString();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SlashCommandExecutor} executor
|
||||
* @param {import('./SlashCommand.js').NamedArguments} args
|
||||
*/
|
||||
async substituteNamedArguments(executor, args) {
|
||||
/**
|
||||
* Handles the assignment of named arguments, considering if they accept multiple values
|
||||
* @param {string} name The name of the argument, as defined for the command execution
|
||||
* @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]} value The value to be assigned
|
||||
*/
|
||||
const assign = (name, value) => {
|
||||
// If an array is supposed to be assigned, assign it one by one
|
||||
if (Array.isArray(value)) {
|
||||
for (const val of value) {
|
||||
assign(name, val);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = executor.command.namedArgumentList.find(x => x.name == name);
|
||||
|
||||
// Prefer definition name if a valid named args defintion is found
|
||||
name = definition?.name ?? name;
|
||||
|
||||
// Unescape named argument
|
||||
if (value && typeof value == 'string') {
|
||||
value = value
|
||||
.replace(/\\\{/g, '{')
|
||||
.replace(/\\\}/g, '}');
|
||||
}
|
||||
|
||||
// If the named argument accepts multiple values, we have to make sure to build an array correctly
|
||||
if (definition?.acceptsMultiple) {
|
||||
if (args[name] !== undefined) {
|
||||
// If there already is something for that named arg, make the value is an array and add to it
|
||||
let currentValue = args[name];
|
||||
if (!Array.isArray(currentValue)) {
|
||||
currentValue = [currentValue];
|
||||
}
|
||||
currentValue.push(value);
|
||||
args[name] = currentValue;
|
||||
} else {
|
||||
// If there is nothing in there, we create an array with that singular value
|
||||
args[name] = [value];
|
||||
}
|
||||
} else {
|
||||
args[name] !== undefined && console.debug(`Named argument assigned multiple times: ${name}`);
|
||||
args[name] = value;
|
||||
}
|
||||
};
|
||||
|
||||
// substitute named arguments
|
||||
for (const arg of executor.namedArgumentList) {
|
||||
if (arg.value instanceof SlashCommandClosure) {
|
||||
/**@type {SlashCommandClosure}*/
|
||||
const closure = arg.value;
|
||||
closure.scope.parent = this.scope;
|
||||
closure.breakController = this.breakController;
|
||||
if (this.debugController && !closure.debugController) {
|
||||
closure.debugController = this.debugController;
|
||||
}
|
||||
if (closure.executeNow) {
|
||||
assign(arg.name, (await closure.execute())?.pipe);
|
||||
} else {
|
||||
assign(arg.name, closure);
|
||||
}
|
||||
} else {
|
||||
assign(arg.name, this.substituteParams(arg.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SlashCommandExecutor} executor
|
||||
* @param {boolean} isFirst
|
||||
* @param {import('./SlashCommand.js').NamedArguments} args
|
||||
* @returns {Promise<string|SlashCommandClosure|(string|SlashCommandClosure)[]>}
|
||||
*/
|
||||
async substituteUnnamedArgument(executor, isFirst, args) {
|
||||
let value;
|
||||
// substitute unnamed argument
|
||||
if (executor.unnamedArgumentList.length == 0) {
|
||||
if (!isFirst && executor.injectPipe) {
|
||||
value = this.scope.pipe;
|
||||
args._hasUnnamedArgument = this.scope.pipe !== null && this.scope.pipe !== undefined;
|
||||
}
|
||||
} else {
|
||||
value = [];
|
||||
for (let i = 0; i < executor.unnamedArgumentList.length; i++) {
|
||||
/** @type {string|SlashCommandClosure|(string|SlashCommandClosure)[]} */
|
||||
let v = executor.unnamedArgumentList[i].value;
|
||||
if (v instanceof SlashCommandClosure) {
|
||||
/**@type {SlashCommandClosure}*/
|
||||
const closure = v;
|
||||
closure.scope.parent = this.scope;
|
||||
closure.breakController = this.breakController;
|
||||
if (this.debugController && !closure.debugController) {
|
||||
closure.debugController = this.debugController;
|
||||
}
|
||||
if (closure.executeNow) {
|
||||
v = (await closure.execute())?.pipe;
|
||||
} else {
|
||||
v = closure;
|
||||
}
|
||||
} else {
|
||||
v = this.substituteParams(v);
|
||||
}
|
||||
value[i] = v;
|
||||
}
|
||||
if (!executor.command.splitUnnamedArgument) {
|
||||
if (value.length == 1) {
|
||||
value = value[0];
|
||||
} else if (!value.find(it=>it instanceof SlashCommandClosure)) {
|
||||
value = value.join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
// unescape unnamed argument
|
||||
if (typeof value == 'string') {
|
||||
value = value
|
||||
?.replace(/\\\{/g, '{')
|
||||
?.replace(/\\\}/g, '}')
|
||||
;
|
||||
} else if (Array.isArray(value)) {
|
||||
value = value.map(v=>{
|
||||
if (typeof v == 'string') {
|
||||
return v
|
||||
?.replace(/\\\{/g, '{')
|
||||
?.replace(/\\\}/g, '}');
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
value ??= '';
|
||||
|
||||
// Make sure that if unnamed args are split, it should always return an array
|
||||
if (executor.command.splitUnnamedArgument && !Array.isArray(value)) {
|
||||
value = [value];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-fixes the pipe if it is not a valid result for STscript.
|
||||
* @param {SlashCommand} command Command being executed
|
||||
*/
|
||||
#lintPipe(command) {
|
||||
if (this.scope.pipe === undefined || this.scope.pipe === null) {
|
||||
console.warn(`/${command.name} returned undefined or null. Auto-fixing to empty string.`);
|
||||
this.scope.pipe = '';
|
||||
} else if (!(typeof this.scope.pipe == 'string' || this.scope.pipe instanceof SlashCommandClosure)) {
|
||||
console.warn(`/${command.name} returned illegal type (${typeof this.scope.pipe} - ${this.scope.pipe.constructor?.name ?? ''}). Auto-fixing to stringified JSON.`);
|
||||
this.scope.pipe = JSON.stringify(this.scope.pipe) ?? '';
|
||||
}
|
||||
}
|
||||
}
|
10
public/scripts/slash-commands/SlashCommandClosureResult.js
Normal file
10
public/scripts/slash-commands/SlashCommandClosureResult.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export class SlashCommandClosureResult {
|
||||
/**@type {boolean}*/ interrupt = false;
|
||||
/**@type {string}*/ pipe;
|
||||
/**@type {boolean}*/ isBreak = false;
|
||||
/**@type {boolean}*/ isAborted = false;
|
||||
/**@type {boolean}*/ isQuietlyAborted = false;
|
||||
/**@type {string}*/ abortReason;
|
||||
/**@type {boolean}*/ isError = false;
|
||||
/**@type {string}*/ errorMessage;
|
||||
}
|
@@ -0,0 +1,37 @@
|
||||
import { SlashCommand } from './SlashCommand.js';
|
||||
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
|
||||
|
||||
export class SlashCommandCommandAutoCompleteOption extends AutoCompleteOption {
|
||||
/**@type {SlashCommand}*/ command;
|
||||
|
||||
|
||||
get value() {
|
||||
return this.command;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {SlashCommand} command
|
||||
* @param {string} name
|
||||
*/
|
||||
constructor(command, name) {
|
||||
super(name);
|
||||
this.command = command;
|
||||
}
|
||||
|
||||
|
||||
renderItem() {
|
||||
let li;
|
||||
li = this.command.renderHelpItem(this.name);
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', 'command');
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
renderDetails() {
|
||||
return this.command.renderHelpDetails(this.name);
|
||||
}
|
||||
}
|
314
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js
Normal file
314
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js
Normal file
@@ -0,0 +1,314 @@
|
||||
import { chat_metadata, characters, substituteParams, chat, extension_prompt_roles, extension_prompt_types, name2, neutralCharacterName } from '../../script.js';
|
||||
import { extension_settings } from '../extensions.js';
|
||||
import { getGroupMembers, groups } from '../group-chats.js';
|
||||
import { power_user } from '../power-user.js';
|
||||
import { searchCharByName, getTagsList, tags, tag_map } from '../tags.js';
|
||||
import { onlyUniqueJson, sortIgnoreCaseAndAccents } from '../utils.js';
|
||||
import { world_names } from '../world-info.js';
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
import { SlashCommandEnumValue, enumTypes } from './SlashCommandEnumValue.js';
|
||||
|
||||
/** @typedef {import('./SlashCommandExecutor.js').SlashCommandExecutor} SlashCommandExecutor */
|
||||
/** @typedef {import('./SlashCommandScope.js').SlashCommandScope} SlashCommandScope */
|
||||
|
||||
/**
|
||||
* A collection of regularly used enum icons
|
||||
*/
|
||||
export const enumIcons = {
|
||||
default: '◊',
|
||||
|
||||
// Variables
|
||||
variable: '𝑥',
|
||||
localVariable: 'L',
|
||||
globalVariable: 'G',
|
||||
scopeVariable: 'S',
|
||||
|
||||
// Common types
|
||||
character: '👤',
|
||||
group: '🧑🤝🧑',
|
||||
persona: '🧙♂️',
|
||||
qr: 'QR',
|
||||
closure: '𝑓',
|
||||
macro: '{{',
|
||||
tag: '🏷️',
|
||||
world: '🌐',
|
||||
preset: '⚙️',
|
||||
file: '📄',
|
||||
message: '💬',
|
||||
reasoning: '💡',
|
||||
voice: '🎤',
|
||||
server: '🖥️',
|
||||
popup: '🗔',
|
||||
image: '🖼️',
|
||||
|
||||
true: '✔️',
|
||||
false: '❌',
|
||||
null: '🚫',
|
||||
undefined: '❓',
|
||||
|
||||
// Value types
|
||||
boolean: '🔲',
|
||||
string: '📝',
|
||||
number: '1️⃣',
|
||||
array: '[]',
|
||||
enum: '📚',
|
||||
dictionary: '{}',
|
||||
|
||||
// Roles
|
||||
system: '⚙️',
|
||||
user: '👤',
|
||||
assistant: '🤖',
|
||||
|
||||
// WI Icons
|
||||
constant: '🔵',
|
||||
normal: '🟢',
|
||||
disabled: '❌',
|
||||
vectorized: '🔗',
|
||||
|
||||
/**
|
||||
* Returns the appropriate state icon based on a boolean
|
||||
*
|
||||
* @param {boolean} state - The state to determine the icon for
|
||||
* @returns {string} The corresponding state icon
|
||||
*/
|
||||
getStateIcon: (state) => {
|
||||
return state ? enumIcons.true : enumIcons.false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the appropriate WI icon based on the entry
|
||||
*
|
||||
* @param {Object} entry - WI entry
|
||||
* @returns {string} The corresponding WI icon
|
||||
*/
|
||||
getWiStatusIcon: (entry) => {
|
||||
if (entry.constant) return enumIcons.constant;
|
||||
if (entry.disable) return enumIcons.disabled;
|
||||
if (entry.vectorized) return enumIcons.vectorized;
|
||||
return enumIcons.normal;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the appropriate icon based on the role
|
||||
*
|
||||
* @param {extension_prompt_roles} role - The role to get the icon for
|
||||
* @returns {string} The corresponding icon
|
||||
*/
|
||||
getRoleIcon: (role) => {
|
||||
switch (role) {
|
||||
case extension_prompt_roles.SYSTEM: return enumIcons.system;
|
||||
case extension_prompt_roles.USER: return enumIcons.user;
|
||||
case extension_prompt_roles.ASSISTANT: return enumIcons.assistant;
|
||||
default: return enumIcons.default;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* A function to get the data type icon
|
||||
*
|
||||
* @param {string} type - The type of the data
|
||||
* @returns {string} The corresponding data type icon
|
||||
*/
|
||||
getDataTypeIcon: (type) => {
|
||||
// Remove possible nullable types definition to match type icon
|
||||
type = type.replace(/\?$/, '');
|
||||
return enumIcons[type] ?? enumIcons.default;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A collection of common enum providers
|
||||
*
|
||||
* Can be used on `SlashCommandNamedArgument` and `SlashCommandArgument` and their `enumProvider` property.
|
||||
*/
|
||||
export const commonEnumProviders = {
|
||||
/**
|
||||
* Enum values for booleans. Either using true/false or on/off
|
||||
* Optionally supports "toggle".
|
||||
*
|
||||
* @param {('onOff'|'onOffToggle'|'trueFalse')?} [mode='trueFalse'] - The mode to use. Default is 'trueFalse'.
|
||||
* @returns {() => SlashCommandEnumValue[]}
|
||||
*/
|
||||
boolean: (mode = 'trueFalse') => () => {
|
||||
switch (mode) {
|
||||
case 'onOff': return [new SlashCommandEnumValue('on', null, 'macro', enumIcons.true), new SlashCommandEnumValue('off', null, 'macro', enumIcons.false)];
|
||||
case 'onOffToggle': return [new SlashCommandEnumValue('on', null, 'macro', enumIcons.true), new SlashCommandEnumValue('off', null, 'macro', enumIcons.false), new SlashCommandEnumValue('toggle', null, 'macro', enumIcons.boolean)];
|
||||
case 'trueFalse': return [new SlashCommandEnumValue('true', null, 'macro', enumIcons.true), new SlashCommandEnumValue('false', null, 'macro', enumIcons.false)];
|
||||
default: throw new Error(`Invalid boolean enum provider mode: ${mode}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* All possible variable names
|
||||
*
|
||||
* Can be filtered by `type` to only show global or local variables
|
||||
*
|
||||
* @param {...('global'|'local'|'scope'|'all')} type - The type of variables to include in the array. Can be 'all', 'global', or 'local'.
|
||||
* @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]}
|
||||
*/
|
||||
variables: (...type) => (_, scope) => {
|
||||
const types = type.flat();
|
||||
const isAll = types.includes('all');
|
||||
return [
|
||||
...isAll || types.includes('scope') ? scope.allVariableNames.map(name => new SlashCommandEnumValue(name, null, enumTypes.variable, enumIcons.scopeVariable)) : [],
|
||||
...isAll || types.includes('local') ? Object.keys(chat_metadata.variables ?? []).map(name => new SlashCommandEnumValue(name, null, enumTypes.name, enumIcons.localVariable)) : [],
|
||||
...isAll || types.includes('global') ? Object.keys(extension_settings.variables.global ?? []).map(name => new SlashCommandEnumValue(name, null, enumTypes.macro, enumIcons.globalVariable)) : [],
|
||||
].filter((item, idx, list)=>idx == list.findIndex(it=>it.value == item.value));
|
||||
},
|
||||
|
||||
/**
|
||||
* Enum values for numbers and variable names
|
||||
*
|
||||
* Includes all variable names and the ability to specify any number
|
||||
*
|
||||
* @param {SlashCommandExecutor} executor - The executor of the slash command
|
||||
* @param {SlashCommandScope} scope - The scope of the slash command
|
||||
* @returns {SlashCommandEnumValue[]} The enum values
|
||||
*/
|
||||
numbersAndVariables: (executor, scope) => [
|
||||
...commonEnumProviders.variables('all')(executor, scope),
|
||||
new SlashCommandEnumValue(
|
||||
'any variable name',
|
||||
null,
|
||||
enumTypes.variable,
|
||||
enumIcons.variable,
|
||||
(input) => /^\w*$/.test(input),
|
||||
(input) => input,
|
||||
),
|
||||
new SlashCommandEnumValue(
|
||||
'any number',
|
||||
null,
|
||||
enumTypes.number,
|
||||
enumIcons.number,
|
||||
(input) => input == '' || !Number.isNaN(Number(input)),
|
||||
(input) => input,
|
||||
),
|
||||
],
|
||||
|
||||
/**
|
||||
* All possible char entities, like characters and groups. Can be filtered down to just one type.
|
||||
*
|
||||
* @param {('all' | 'character' | 'group')?} [mode='all'] - Which type to return
|
||||
* @returns {() => SlashCommandEnumValue[]}
|
||||
*/
|
||||
characters: (mode = 'all') => () => {
|
||||
return [
|
||||
...['all', 'character'].includes(mode) ? characters.map(char => new SlashCommandEnumValue(char.name, null, enumTypes.name, enumIcons.character)) : [],
|
||||
...['all', 'group'].includes(mode) ? groups.map(group => new SlashCommandEnumValue(group.name, null, enumTypes.qr, enumIcons.group)) : [],
|
||||
...(name2 === neutralCharacterName) ? [new SlashCommandEnumValue(neutralCharacterName, null, enumTypes.name, '🥸')] : [],
|
||||
];
|
||||
},
|
||||
|
||||
/**
|
||||
* All group members of the given group, or default the current active one
|
||||
*
|
||||
* @param {string?} groupId - The id of the group - pass in `undefined` to use the current active group
|
||||
* @returns {() =>SlashCommandEnumValue[]}
|
||||
*/
|
||||
groupMembers: (groupId = undefined) => () => getGroupMembers(groupId).map((character, index) => new SlashCommandEnumValue(String(index), character.name, enumTypes.enum, enumIcons.character)),
|
||||
|
||||
/**
|
||||
* All possible personas
|
||||
*
|
||||
* @returns {SlashCommandEnumValue[]}
|
||||
*/
|
||||
personas: () => Object.values(power_user.personas).map(persona => new SlashCommandEnumValue(persona, null, enumTypes.name, enumIcons.persona)),
|
||||
|
||||
/**
|
||||
* All possible tags, or only those that have been assigned
|
||||
*
|
||||
* @param {('all' | 'assigned')} [mode='all'] - Which types of tags to show
|
||||
* @returns {() => SlashCommandEnumValue[]}
|
||||
*/
|
||||
tags: (mode = 'all') => () => {
|
||||
let assignedTags = mode === 'assigned' ? new Set(Object.values(tag_map).flat()) : new Set();
|
||||
return tags.filter(tag => mode === 'all' || (mode === 'assigned' && assignedTags.has(tag.id)))
|
||||
.map(tag => new SlashCommandEnumValue(tag.name, null, enumTypes.command, enumIcons.tag));
|
||||
},
|
||||
|
||||
/**
|
||||
* All possible tags for a given char/group entity
|
||||
*
|
||||
* @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show
|
||||
* @returns {() => SlashCommandEnumValue[]}
|
||||
*/
|
||||
tagsForChar: (mode = 'all') => (/** @type {SlashCommandExecutor} */ executor) => {
|
||||
// Try to see if we can find the char during execution to filter down the tags list some more. Otherwise take all tags.
|
||||
const charName = executor.namedArgumentList.find(it => it.name == 'name')?.value;
|
||||
if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures');
|
||||
const key = searchCharByName(substituteParams(charName), { suppressLogging: true });
|
||||
const assigned = key ? getTagsList(key) : [];
|
||||
return tags.filter(it => mode === 'all' || mode === 'existing' && assigned.includes(it) || mode === 'not-existing' && !assigned.includes(it))
|
||||
.map(tag => new SlashCommandEnumValue(tag.name, null, enumTypes.command, enumIcons.tag));
|
||||
},
|
||||
|
||||
/**
|
||||
* All messages in the current chat, returning the message id
|
||||
*
|
||||
* Optionally supports variable names, and/or a placeholder for the last/new message id
|
||||
*
|
||||
* @param {object} [options={}] - Optional arguments
|
||||
* @param {boolean} [options.allowIdAfter=false] - Whether to add an enum option for the new message id after the last message
|
||||
* @param {boolean} [options.allowVars=false] - Whether to add enum option for variable names
|
||||
* @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]}
|
||||
*/
|
||||
messages: ({ allowIdAfter = false, allowVars = false } = {}) => (executor, scope) => {
|
||||
const nameFilter = executor.namedArgumentList.find(it => it.name == 'name')?.value || '';
|
||||
return [
|
||||
...chat.map((message, index) => new SlashCommandEnumValue(String(index), `${message.name}: ${message.mes}`, enumTypes.number, message.is_user ? enumIcons.user : message.is_system ? enumIcons.system : enumIcons.assistant)).filter(value => !nameFilter || value.description.startsWith(`${nameFilter}:`)),
|
||||
...allowIdAfter ? [new SlashCommandEnumValue(String(chat.length), '>> After Last Message >>', enumTypes.enum, '➕')] : [],
|
||||
...allowVars ? commonEnumProviders.variables('all')(executor, scope) : [],
|
||||
];
|
||||
},
|
||||
|
||||
/**
|
||||
* All names used in the current chat.
|
||||
*
|
||||
* @returns {SlashCommandEnumValue[]}
|
||||
*/
|
||||
messageNames: () => chat
|
||||
.map(message => ({
|
||||
name: message.name,
|
||||
icon: message.is_user ? enumIcons.user : enumIcons.assistant,
|
||||
}))
|
||||
.filter(onlyUniqueJson)
|
||||
.sort((a, b) => sortIgnoreCaseAndAccents(a.name, b.name))
|
||||
.map(name => new SlashCommandEnumValue(name.name, null, null, name.icon)),
|
||||
|
||||
/**
|
||||
* All existing worlds / lorebooks
|
||||
*
|
||||
* @returns {SlashCommandEnumValue[]}
|
||||
*/
|
||||
worlds: () => world_names.map(worldName => new SlashCommandEnumValue(worldName, null, enumTypes.name, enumIcons.world)),
|
||||
|
||||
/**
|
||||
* All existing injects for the current chat
|
||||
*
|
||||
* @returns {SlashCommandEnumValue[]}
|
||||
*/
|
||||
injects: () => {
|
||||
if (!chat_metadata.script_injects || !Object.keys(chat_metadata.script_injects).length) return [];
|
||||
return Object.entries(chat_metadata.script_injects)
|
||||
.map(([id, inject]) => {
|
||||
const positionName = (Object.entries(extension_prompt_types)).find(([_, value]) => value === inject.position)?.[0] ?? 'unknown';
|
||||
return new SlashCommandEnumValue(id, `${enumIcons.getRoleIcon(inject.role ?? extension_prompt_roles.SYSTEM)}[Inject](${positionName}, depth: ${inject.depth}, scan: ${inject.scan ?? false}) ${inject.value}`,
|
||||
enumTypes.enum, '💉');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets somewhat recognizable STscript types.
|
||||
*
|
||||
* @returns {SlashCommandEnumValue[]}
|
||||
*/
|
||||
types: () => [
|
||||
new SlashCommandEnumValue('string', null, enumTypes.type, enumIcons.string),
|
||||
new SlashCommandEnumValue('number', null, enumTypes.type, enumIcons.number),
|
||||
new SlashCommandEnumValue('boolean', null, enumTypes.type, enumIcons.boolean),
|
||||
new SlashCommandEnumValue('array', null, enumTypes.type, enumIcons.array),
|
||||
new SlashCommandEnumValue('object', null, enumTypes.type, enumIcons.dictionary),
|
||||
new SlashCommandEnumValue('null', null, enumTypes.type, enumIcons.null),
|
||||
new SlashCommandEnumValue('undefined', null, enumTypes.type, enumIcons.undefined),
|
||||
],
|
||||
};
|
83
public/scripts/slash-commands/SlashCommandDebugController.js
Normal file
83
public/scripts/slash-commands/SlashCommandDebugController.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
|
||||
|
||||
export class SlashCommandDebugController {
|
||||
/** @type {SlashCommandClosure[]} */ stack = [];
|
||||
/** @type {SlashCommandExecutor[]} */ cmdStack = [];
|
||||
/** @type {boolean[]} */ stepStack = [];
|
||||
/** @type {boolean} */ isStepping = false;
|
||||
/** @type {boolean} */ isSteppingInto = false;
|
||||
/** @type {boolean} */ isSteppingOut = false;
|
||||
|
||||
/** @type {object} */ namedArguments;
|
||||
/** @type {string|SlashCommandClosure|(string|SlashCommandClosure)[]} */ unnamedArguments;
|
||||
|
||||
/** @type {Promise<boolean>} */ continuePromise;
|
||||
/** @type {(boolean)=>void} */ continueResolver;
|
||||
|
||||
/** @type {(closure:SlashCommandClosure, executor:SlashCommandExecutor)=>Promise<boolean>} */ onBreakPoint;
|
||||
|
||||
|
||||
|
||||
|
||||
testStepping(closure) {
|
||||
return this.stepStack[this.stack.indexOf(closure)];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
down(closure) {
|
||||
this.stack.push(closure);
|
||||
if (this.stepStack.length < this.stack.length) {
|
||||
this.stepStack.push(this.isSteppingInto);
|
||||
}
|
||||
}
|
||||
up() {
|
||||
this.stack.pop();
|
||||
while (this.cmdStack.length > this.stack.length) this.cmdStack.pop();
|
||||
this.stepStack.pop();
|
||||
}
|
||||
|
||||
setExecutor(executor) {
|
||||
this.cmdStack[this.stack.length - 1] = executor;
|
||||
}
|
||||
|
||||
|
||||
|
||||
resume() {
|
||||
this.continueResolver?.(false);
|
||||
this.continuePromise = null;
|
||||
this.stepStack.forEach((_,idx)=>this.stepStack[idx] = false);
|
||||
}
|
||||
step() {
|
||||
this.stepStack.forEach((_,idx)=>this.stepStack[idx] = true);
|
||||
this.continueResolver?.(true);
|
||||
this.continuePromise = null;
|
||||
}
|
||||
stepInto() {
|
||||
this.isSteppingInto = true;
|
||||
this.stepStack.forEach((_,idx)=>this.stepStack[idx] = true);
|
||||
this.continueResolver?.(true);
|
||||
this.continuePromise = null;
|
||||
}
|
||||
stepOut() {
|
||||
this.isSteppingOut = true;
|
||||
this.stepStack[this.stepStack.length - 1] = false;
|
||||
this.continueResolver?.(false);
|
||||
this.continuePromise = null;
|
||||
}
|
||||
|
||||
async awaitContinue() {
|
||||
this.continuePromise ??= new Promise(resolve=>{
|
||||
this.continueResolver = resolve;
|
||||
});
|
||||
this.isStepping = await this.continuePromise;
|
||||
return this.isStepping;
|
||||
}
|
||||
|
||||
async awaitBreakPoint(closure, executor) {
|
||||
this.isStepping = await this.onBreakPoint(closure, executor);
|
||||
return this.isStepping;
|
||||
}
|
||||
}
|
@@ -0,0 +1,45 @@
|
||||
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
|
||||
import { SlashCommand } from './SlashCommand.js';
|
||||
import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
|
||||
|
||||
export class SlashCommandEnumAutoCompleteOption extends AutoCompleteOption {
|
||||
/**
|
||||
* @param {SlashCommand} cmd
|
||||
* @param {SlashCommandEnumValue} enumValue
|
||||
* @returns {SlashCommandEnumAutoCompleteOption}
|
||||
*/
|
||||
static from(cmd, enumValue) {
|
||||
const mapped = this.valueToOptionMap.find(it=>enumValue instanceof it.value)?.option ?? this;
|
||||
return new mapped(cmd, enumValue);
|
||||
}
|
||||
/**@type {{value:(typeof SlashCommandEnumValue), option:(typeof SlashCommandEnumAutoCompleteOption)}[]} */
|
||||
static valueToOptionMap = [];
|
||||
/**@type {SlashCommand}*/ cmd;
|
||||
/**@type {SlashCommandEnumValue}*/ enumValue;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {SlashCommand} cmd
|
||||
* @param {SlashCommandEnumValue} enumValue
|
||||
*/
|
||||
constructor(cmd, enumValue) {
|
||||
super(enumValue.value, enumValue.typeIcon, enumValue.type, enumValue.matchProvider, enumValue.valueProvider, enumValue.makeSelectable);
|
||||
this.cmd = cmd;
|
||||
this.enumValue = enumValue;
|
||||
}
|
||||
|
||||
|
||||
renderItem() {
|
||||
let li;
|
||||
li = this.makeItem(this.name, this.typeIcon, true, [], [], null, this.enumValue.description);
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', this.type);
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
renderDetails() {
|
||||
return this.cmd.renderHelpDetails();
|
||||
}
|
||||
}
|
75
public/scripts/slash-commands/SlashCommandEnumValue.js
Normal file
75
public/scripts/slash-commands/SlashCommandEnumValue.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @typedef {'enum' | 'command' | 'namedArgument' | 'variable' | 'qr' | 'macro' | 'number' | 'name'} EnumType
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collection of the enum types that can be used with `SlashCommandEnumValue`
|
||||
*
|
||||
* Contains documentation on which color this will result to
|
||||
*/
|
||||
export const enumTypes = {
|
||||
/** 'enum' - [string] - light orange @type {EnumType} */
|
||||
enum: 'enum',
|
||||
/** 'command' - [cmd] - light yellow @type {EnumType} */
|
||||
command: 'command',
|
||||
/** 'namedArgument' - [argName] - sky blue @type {EnumType} */
|
||||
namedArgument: 'namedArgument',
|
||||
/** 'variable' - [punctuationL1] - pink @type {EnumType} */
|
||||
variable: 'variable',
|
||||
/** 'qr' - [variable] - light blue @type {EnumType} */
|
||||
qr: 'qr',
|
||||
/** 'macro' - [variableLanguage] - blue @type {EnumType} */
|
||||
macro: 'macro',
|
||||
/** 'number' - [number] - light green @type {EnumType} */
|
||||
number: 'number',
|
||||
/** 'name' - [type] - forest green @type {EnumType} */
|
||||
name: 'name',
|
||||
|
||||
/**
|
||||
* Gets the value of the enum type based on the provided index
|
||||
*
|
||||
* Can be used to get differing colors or even random colors, by providing the index of a unique set
|
||||
*
|
||||
* @param {number?} index - The index used to retrieve the enum type
|
||||
* @return {EnumType} The enum type corresponding to the index
|
||||
*/
|
||||
getBasedOnIndex(index) {
|
||||
const keys = Object.keys(this);
|
||||
return this[keys[(index ?? 0) % keys.length]];
|
||||
},
|
||||
};
|
||||
|
||||
export class SlashCommandEnumValue {
|
||||
/**@type {string}*/ value;
|
||||
/**@type {string}*/ description;
|
||||
/**@type {EnumType}*/ type = 'enum';
|
||||
/**@type {string}*/ typeIcon = '◊';
|
||||
/**@type {(input:string)=>boolean}*/ matchProvider;
|
||||
/**@type {(input:string)=>string}*/ valueProvider;
|
||||
/**@type {boolean}*/ makeSelectable = false;
|
||||
|
||||
/**
|
||||
* A constructor for creating a SlashCommandEnumValue instance.
|
||||
*
|
||||
* @param {string} value - The value
|
||||
* @param {string?} description - Optional description, displayed in a second line
|
||||
* @param {EnumType?} type - type of the enum (defining its color)
|
||||
* @param {string?} typeIcon - The icon to display (Can be pulled from `enumIcons` for common ones)
|
||||
* @param {(input:string)=>boolean?} matchProvider - A custom function to match autocomplete input instead of startsWith/includes/fuzzy. Should only be used for generic options like "any number" or "any string". "input" is the part of the text that is getting auto completed.
|
||||
* @param {(input:string)=>string?} valueProvider - A function returning a value to be used in autocomplete instead of the enum value. "input" is the part of the text that is getting auto completed. By default, values with a valueProvider will not be selectable in the autocomplete (with tab/enter).
|
||||
* @param {boolean?} makeSelectable - Set to true to make the value selectable (through tab/enter) even though a valueProvider exists.
|
||||
*/
|
||||
constructor(value, description = null, type = 'enum', typeIcon = '◊', matchProvider = null, valueProvider = null, makeSelectable = false) {
|
||||
this.value = value;
|
||||
this.description = description;
|
||||
this.type = type ?? 'enum';
|
||||
this.typeIcon = typeIcon;
|
||||
this.matchProvider = matchProvider;
|
||||
this.valueProvider = valueProvider;
|
||||
this.makeSelectable = makeSelectable;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
60
public/scripts/slash-commands/SlashCommandExecutionError.js
Normal file
60
public/scripts/slash-commands/SlashCommandExecutionError.js
Normal file
@@ -0,0 +1,60 @@
|
||||
export class SlashCommandExecutionError extends Error {
|
||||
/**@type {string} */ commandName;
|
||||
/**@type {number} */ start;
|
||||
/**@type {number} */ end;
|
||||
/**@type {string} */ commandText;
|
||||
|
||||
/**@type {string} */ text;
|
||||
get index() { return this.start; }
|
||||
|
||||
get line() {
|
||||
return this.text.slice(0, this.index).replace(/[^\n]/g, '').length;
|
||||
}
|
||||
get column() {
|
||||
return this.text.slice(0, this.index).split('\n').pop().length;
|
||||
}
|
||||
get hint() {
|
||||
let lineOffset = this.line.toString().length;
|
||||
let lineStart = this.index;
|
||||
let start = this.index;
|
||||
let end = this.index;
|
||||
let offset = 0;
|
||||
let lineCount = 0;
|
||||
while (offset < 10000 && lineCount < 3 && start >= 0) {
|
||||
if (this.text[start] == '\n') lineCount++;
|
||||
if (lineCount == 0) lineStart--;
|
||||
offset++;
|
||||
start--;
|
||||
}
|
||||
if (this.text[start + 1] == '\n') start++;
|
||||
offset = 0;
|
||||
while (offset < 10000 && this.text[end] != '\n') {
|
||||
offset++;
|
||||
end++;
|
||||
}
|
||||
let hint = [];
|
||||
let lines = this.text.slice(start + 1, end - 1).split('\n');
|
||||
let lineNum = this.line - lines.length + 1;
|
||||
let tabOffset = 0;
|
||||
for (const line of lines) {
|
||||
const num = `${' '.repeat(lineOffset - lineNum.toString().length)}${lineNum}`;
|
||||
lineNum++;
|
||||
const untabbedLine = line.replace(/\t/g, ' '.repeat(4));
|
||||
tabOffset = untabbedLine.length - line.length;
|
||||
hint.push(`${num}: ${untabbedLine}`);
|
||||
}
|
||||
hint.push(`${' '.repeat(this.index - lineStart + lineOffset + 1 + tabOffset)}^^^^^`);
|
||||
return hint.join('\n');
|
||||
}
|
||||
|
||||
|
||||
|
||||
constructor(cause, message, commandName, start, end, commandText, fullText) {
|
||||
super(message, { cause });
|
||||
this.commandName = commandName;
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
this.commandText = commandText;
|
||||
this.text = fullText;
|
||||
}
|
||||
}
|
52
public/scripts/slash-commands/SlashCommandExecutor.js
Normal file
52
public/scripts/slash-commands/SlashCommandExecutor.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { uuidv4 } from '../utils.js';
|
||||
import { SlashCommand } from './SlashCommand.js';
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
|
||||
import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
|
||||
|
||||
export class SlashCommandExecutor {
|
||||
/**@type {Boolean}*/ injectPipe = true;
|
||||
/**@type {Number}*/ start;
|
||||
/**@type {Number}*/ end;
|
||||
/**@type {Number}*/ startNamedArgs;
|
||||
/**@type {Number}*/ endNamedArgs;
|
||||
/**@type {Number}*/ startUnnamedArgs;
|
||||
/**@type {Number}*/ endUnnamedArgs;
|
||||
/**@type {String}*/ name = '';
|
||||
/**@type {String}*/ #source = uuidv4();
|
||||
get source() { return this.#source; }
|
||||
set source(value) {
|
||||
this.#source = value;
|
||||
for (const arg of this.namedArgumentList.filter(it=>it.value instanceof SlashCommandClosure)) {
|
||||
arg.value.source = value;
|
||||
}
|
||||
for (const arg of this.unnamedArgumentList.filter(it=>it.value instanceof SlashCommandClosure)) {
|
||||
arg.value.source = value;
|
||||
}
|
||||
}
|
||||
/** @type {SlashCommand} */ command;
|
||||
/** @type {SlashCommandNamedArgumentAssignment[]} */ namedArgumentList = [];
|
||||
/** @type {SlashCommandUnnamedArgumentAssignment[]} */ unnamedArgumentList = [];
|
||||
/** @type {import('./SlashCommandParser.js').ParserFlags} */ parserFlags;
|
||||
|
||||
get commandCount() {
|
||||
return 1
|
||||
+ this.namedArgumentList.filter(it=>it.value instanceof SlashCommandClosure).map(it=>/**@type {SlashCommandClosure}*/(it.value).commandCount).reduce((cur, sum)=>cur + sum, 0)
|
||||
+ this.unnamedArgumentList.filter(it=>it.value instanceof SlashCommandClosure).map(it=>/**@type {SlashCommandClosure}*/(it.value).commandCount).reduce((cur, sum)=>cur + sum, 0)
|
||||
;
|
||||
}
|
||||
|
||||
set onProgress(value) {
|
||||
const closures = /**@type {SlashCommandClosure[]}*/([
|
||||
...this.namedArgumentList.filter(it=>it.value instanceof SlashCommandClosure).map(it=>it.value),
|
||||
...this.unnamedArgumentList.filter(it=>it.value instanceof SlashCommandClosure).map(it=>it.value),
|
||||
]);
|
||||
for (const closure of closures) {
|
||||
closure.onProgress = value;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(start) {
|
||||
this.start = start;
|
||||
}
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
|
||||
export class SlashCommandNamedArgumentAssignment {
|
||||
/** @type {number} */ start;
|
||||
/** @type {number} */ end;
|
||||
/** @type {string} */ name;
|
||||
/** @type {string|SlashCommandClosure} */ value;
|
||||
|
||||
|
||||
constructor() {
|
||||
}
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
|
||||
import { SlashCommand } from './SlashCommand.js';
|
||||
import { SlashCommandNamedArgument } from './SlashCommandArgument.js';
|
||||
|
||||
export class SlashCommandNamedArgumentAutoCompleteOption extends AutoCompleteOption {
|
||||
/** @type {SlashCommandNamedArgument} */ arg;
|
||||
/** @type {SlashCommand} */ cmd;
|
||||
|
||||
/**
|
||||
* @param {SlashCommandNamedArgument} arg
|
||||
*/
|
||||
constructor(arg, cmd) {
|
||||
super(`${arg.name}=`);
|
||||
this.arg = arg;
|
||||
this.cmd = cmd;
|
||||
}
|
||||
|
||||
|
||||
renderItem() {
|
||||
let li;
|
||||
li = this.makeItem(this.name, '⌗', true, [], [], null, `${this.arg.isRequired ? '' : '(optional) '}${this.arg.description ?? ''}`);
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', 'namedArgument');
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
renderDetails() {
|
||||
return this.cmd.renderHelpDetails();
|
||||
}
|
||||
}
|
1263
public/scripts/slash-commands/SlashCommandParser.js
Normal file
1263
public/scripts/slash-commands/SlashCommandParser.js
Normal file
File diff suppressed because it is too large
Load Diff
50
public/scripts/slash-commands/SlashCommandParserError.js
Normal file
50
public/scripts/slash-commands/SlashCommandParserError.js
Normal file
@@ -0,0 +1,50 @@
|
||||
export class SlashCommandParserError extends Error {
|
||||
/**@type {String}*/ text;
|
||||
/**@type {Number}*/ index;
|
||||
|
||||
get line() {
|
||||
return this.text.slice(0, this.index).replace(/[^\n]/g, '').length;
|
||||
}
|
||||
get column() {
|
||||
return this.text.slice(0, this.index).split('\n').pop().length;
|
||||
}
|
||||
get hint() {
|
||||
let lineOffset = this.line.toString().length;
|
||||
let lineStart = this.index;
|
||||
let start = this.index;
|
||||
let end = this.index;
|
||||
let offset = 0;
|
||||
let lineCount = 0;
|
||||
while (offset < 10000 && lineCount < 3 && start >= 0) {
|
||||
if (this.text[start] == '\n') lineCount++;
|
||||
if (lineCount == 0) lineStart--;
|
||||
offset++;
|
||||
start--;
|
||||
}
|
||||
if (this.text[start + 1] == '\n') start++;
|
||||
offset = 0;
|
||||
while (offset < 10000 && this.text[end] != '\n') {
|
||||
offset++;
|
||||
end++;
|
||||
}
|
||||
let hint = [];
|
||||
let lines = this.text.slice(start + 1, end - 1).split('\n');
|
||||
let lineNum = this.line - lines.length + 1;
|
||||
let tabOffset = 0;
|
||||
for (const line of lines) {
|
||||
const num = `${' '.repeat(lineOffset - lineNum.toString().length)}${lineNum}`;
|
||||
lineNum++;
|
||||
const untabbedLine = line.replace(/\t/g, ' '.repeat(4));
|
||||
tabOffset = untabbedLine.length - line.length;
|
||||
hint.push(`${num}: ${untabbedLine}`);
|
||||
}
|
||||
hint.push(`${' '.repeat(this.index - lineStart + lineOffset + 1 + tabOffset)}^^^^^`);
|
||||
return hint.join('\n');
|
||||
}
|
||||
|
||||
constructor(message, text, index) {
|
||||
super(message);
|
||||
this.text = text;
|
||||
this.index = index;
|
||||
}
|
||||
}
|
@@ -0,0 +1,40 @@
|
||||
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
|
||||
|
||||
export class SlashCommandQuickReplyAutoCompleteOption extends AutoCompleteOption {
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
constructor(name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
|
||||
renderItem() {
|
||||
let li;
|
||||
li = this.makeItem(this.name, 'QR', true);
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', 'qr');
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
renderDetails() {
|
||||
const frag = document.createDocumentFragment();
|
||||
const specs = document.createElement('div'); {
|
||||
specs.classList.add('specs');
|
||||
const name = document.createElement('div'); {
|
||||
name.classList.add('name');
|
||||
name.classList.add('monospace');
|
||||
name.textContent = this.name;
|
||||
specs.append(name);
|
||||
}
|
||||
frag.append(specs);
|
||||
}
|
||||
const help = document.createElement('span'); {
|
||||
help.classList.add('help');
|
||||
help.textContent = 'Quick Reply';
|
||||
frag.append(help);
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
}
|
81
public/scripts/slash-commands/SlashCommandReturnHelper.js
Normal file
81
public/scripts/slash-commands/SlashCommandReturnHelper.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { DOMPurify, showdown } from '../../lib.js';
|
||||
import { sendSystemMessage, system_message_types } from '../../script.js';
|
||||
import { callGenericPopup, POPUP_TYPE } from '../popup.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import { enumIcons } from './SlashCommandCommonEnumsProvider.js';
|
||||
import { enumTypes, SlashCommandEnumValue } from './SlashCommandEnumValue.js';
|
||||
|
||||
/** @typedef {'pipe'|'object'|'chat-html'|'chat-text'|'popup-html'|'popup-text'|'toast-html'|'toast-text'|'console'|'none'} SlashCommandReturnType */
|
||||
|
||||
export const slashCommandReturnHelper = {
|
||||
// Without this, VSCode formatter fucks up JS docs. Don't ask me why.
|
||||
_: false,
|
||||
|
||||
/**
|
||||
* Gets/creates the enum list of types of return relevant for a slash command
|
||||
*
|
||||
* @param {object} [options={}] Options
|
||||
* @param {boolean} [options.allowPipe=true] Allow option to pipe the return value
|
||||
* @param {boolean} [options.allowObject=false] Allow option to return the value as an object
|
||||
* @param {boolean} [options.allowChat=false] Allow option to return the value as a chat message
|
||||
* @param {boolean} [options.allowPopup=false] Allow option to return the value as a popup
|
||||
* @param {boolean}[options.allowTextVersion=true] Used in combination with chat/popup/toast, some of them do not make sense for text versions, e.g.if you are building a HTML string anyway
|
||||
* @returns {SlashCommandEnumValue[]} The enum list
|
||||
*/
|
||||
enumList: ({ allowPipe = true, allowObject = false, allowChat = false, allowPopup = false, allowTextVersion = true } = {}) => [
|
||||
allowPipe && new SlashCommandEnumValue('pipe', 'Return to the pipe for the next command', enumTypes.name, '|'),
|
||||
allowObject && new SlashCommandEnumValue('object', 'Return as an object (or array) to the pipe for the next command', enumTypes.variable, enumIcons.dictionary),
|
||||
allowChat && new SlashCommandEnumValue('chat-html', 'Sending a chat message with the return value - Can display HTML', enumTypes.command, enumIcons.message),
|
||||
allowChat && allowTextVersion && new SlashCommandEnumValue('chat-text', 'Sending a chat message with the return value - Will only display as text', enumTypes.qr, enumIcons.message),
|
||||
allowPopup && new SlashCommandEnumValue('popup-html', 'Showing as a popup with the return value - Can display HTML', enumTypes.command, enumIcons.popup),
|
||||
allowPopup && allowTextVersion && new SlashCommandEnumValue('popup-text', 'Showing as a popup with the return value - Will only display as text', enumTypes.qr, enumIcons.popup),
|
||||
new SlashCommandEnumValue('toast-html', 'Show the return value as a toast notification - Can display HTML', enumTypes.command, 'ℹ️'),
|
||||
allowTextVersion && new SlashCommandEnumValue('toast-text', 'Show the return value as a toast notification - Will only display as text', enumTypes.qr, 'ℹ️'),
|
||||
new SlashCommandEnumValue('console', 'Log the return value (object, if it can be one) to the console', enumTypes.enum, '>'),
|
||||
new SlashCommandEnumValue('none', 'No return value'),
|
||||
].filter(x => !!x),
|
||||
|
||||
/**
|
||||
* Handles the return value based on the specified type
|
||||
*
|
||||
* @param {SlashCommandReturnType} type The type of return
|
||||
* @param {object|number|string} value The value to return
|
||||
* @param {object} [options={}] Options
|
||||
* @param {(o: object) => string} [options.objectToStringFunc=null] Function to convert the object to a string, if object was provided and 'object' was not the chosen return type
|
||||
* @param {(o: object) => string} [options.objectToHtmlFunc=null] Analog to 'objectToStringFunc', which will be used here if not provided - but can do a different string layout if HTML is requested
|
||||
* @returns {Promise<*>} The processed return value
|
||||
*/
|
||||
async doReturn(type, value, { objectToStringFunc = o => o?.toString(), objectToHtmlFunc = null } = {}) {
|
||||
const shouldHtml = type.endsWith('html');
|
||||
const actualConverterFunc = shouldHtml && objectToHtmlFunc ? objectToHtmlFunc : objectToStringFunc;
|
||||
const stringValue = typeof value !== 'string' ? actualConverterFunc(value) : value;
|
||||
|
||||
switch (type) {
|
||||
case 'popup-html':
|
||||
case 'popup-text':
|
||||
case 'chat-text':
|
||||
case 'chat-html':
|
||||
case 'toast-text':
|
||||
case 'toast-html': {
|
||||
const htmlOrNotHtml = shouldHtml ? DOMPurify.sanitize((new showdown.Converter()).makeHtml(stringValue)) : escapeHtml(stringValue);
|
||||
|
||||
if (type.startsWith('popup')) await callGenericPopup(htmlOrNotHtml, POPUP_TYPE.TEXT, '', { allowVerticalScrolling: true, wide: true });
|
||||
if (type.startsWith('chat')) sendSystemMessage(system_message_types.GENERIC, htmlOrNotHtml);
|
||||
if (type.startsWith('toast')) toastr.info(htmlOrNotHtml, null, { escapeHtml: !shouldHtml });
|
||||
|
||||
return '';
|
||||
}
|
||||
case 'pipe':
|
||||
return stringValue ?? '';
|
||||
case 'object':
|
||||
return JSON.stringify(value);
|
||||
case 'console':
|
||||
console.info(value);
|
||||
return '';
|
||||
case 'none':
|
||||
return '';
|
||||
default:
|
||||
throw new Error(`Unknown return type: ${type}`);
|
||||
}
|
||||
},
|
||||
};
|
117
public/scripts/slash-commands/SlashCommandScope.js
Normal file
117
public/scripts/slash-commands/SlashCommandScope.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
import { convertValueType } from '../utils.js';
|
||||
|
||||
export class SlashCommandScope {
|
||||
/** @type {string[]} */ variableNames = [];
|
||||
get allVariableNames() {
|
||||
const names = [...this.variableNames, ...(this.parent?.allVariableNames ?? [])];
|
||||
return names.filter((it,idx)=>idx == names.indexOf(it));
|
||||
}
|
||||
// @ts-ignore
|
||||
/** @type {object.<string, string|SlashCommandClosure>} */ variables = {};
|
||||
// @ts-ignore
|
||||
/** @type {object.<string, string|SlashCommandClosure>} */ macros = {};
|
||||
/** @type {{key:string, value:string|SlashCommandClosure}[]} */
|
||||
get macroList() {
|
||||
return [...Object.keys(this.macros).map(key=>({ key, value:this.macros[key] })), ...(this.parent?.macroList ?? [])];
|
||||
}
|
||||
/** @type {SlashCommandScope} */ parent;
|
||||
/** @type {string} */ #pipe;
|
||||
get pipe() {
|
||||
return this.#pipe ?? this.parent?.pipe;
|
||||
}
|
||||
set pipe(value) {
|
||||
this.#pipe = value;
|
||||
}
|
||||
|
||||
|
||||
constructor(parent) {
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
getCopy() {
|
||||
const scope = new SlashCommandScope(this.parent);
|
||||
scope.variableNames = [...this.variableNames];
|
||||
scope.variables = Object.assign({}, this.variables);
|
||||
scope.macros = Object.assign({}, this.macros);
|
||||
scope.#pipe = this.#pipe;
|
||||
return scope;
|
||||
}
|
||||
|
||||
|
||||
setMacro(key, value, overwrite = true) {
|
||||
if (overwrite || !this.macroList.find(it=>it.key == key)) {
|
||||
this.macros[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
existsVariableInScope(key) {
|
||||
return Object.keys(this.variables).includes(key);
|
||||
}
|
||||
existsVariable(key) {
|
||||
return Object.keys(this.variables).includes(key) || this.parent?.existsVariable(key);
|
||||
}
|
||||
letVariable(key, value = undefined) {
|
||||
if (this.existsVariableInScope(key)) throw new SlashCommandScopeVariableExistsError(`Variable named "${key}" already exists.`);
|
||||
this.variables[key] = value;
|
||||
}
|
||||
setVariable(key, value, index = null, type = null) {
|
||||
if (this.existsVariableInScope(key)) {
|
||||
if (index !== null && index !== undefined) {
|
||||
let v = this.variables[key];
|
||||
try {
|
||||
v = JSON.parse(v);
|
||||
const numIndex = Number(index);
|
||||
if (Number.isNaN(numIndex)) {
|
||||
v[index] = convertValueType(value, type);
|
||||
} else {
|
||||
v[numIndex] = convertValueType(value, type);
|
||||
}
|
||||
v = JSON.stringify(v);
|
||||
} catch {
|
||||
v[index] = convertValueType(value, type);
|
||||
}
|
||||
this.variables[key] = v;
|
||||
} else {
|
||||
this.variables[key] = value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (this.parent) {
|
||||
return this.parent.setVariable(key, value, index, type);
|
||||
}
|
||||
throw new SlashCommandScopeVariableNotFoundError(`No such variable: "${key}"`);
|
||||
}
|
||||
getVariable(key, index = null) {
|
||||
if (this.existsVariableInScope(key)) {
|
||||
if (index !== null && index !== undefined) {
|
||||
let v = this.variables[key];
|
||||
try { v = JSON.parse(v); } catch { /* empty */ }
|
||||
const numIndex = Number(index);
|
||||
if (Number.isNaN(numIndex)) {
|
||||
v = v[index];
|
||||
} else {
|
||||
v = v[numIndex];
|
||||
}
|
||||
if (typeof v == 'object') return JSON.stringify(v);
|
||||
return v ?? '';
|
||||
} else {
|
||||
const value = this.variables[key];
|
||||
return (value?.trim?.() === '' || isNaN(Number(value))) ? (value || '') : Number(value);
|
||||
}
|
||||
}
|
||||
if (this.parent) {
|
||||
return this.parent.getVariable(key, index);
|
||||
}
|
||||
throw new SlashCommandScopeVariableNotFoundError(`No such variable: "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export class SlashCommandScopeVariableExistsError extends Error {}
|
||||
|
||||
|
||||
export class SlashCommandScopeVariableNotFoundError extends Error {}
|
@@ -0,0 +1,11 @@
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
|
||||
export class SlashCommandUnnamedArgumentAssignment {
|
||||
/** @type {number} */ start;
|
||||
/** @type {number} */ end;
|
||||
/** @type {string|SlashCommandClosure} */ value;
|
||||
|
||||
|
||||
constructor() {
|
||||
}
|
||||
}
|
@@ -0,0 +1,40 @@
|
||||
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
|
||||
|
||||
export class SlashCommandVariableAutoCompleteOption extends AutoCompleteOption {
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
constructor(name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
|
||||
renderItem() {
|
||||
let li;
|
||||
li = this.makeItem(this.name, '[𝑥]', true);
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', 'variable');
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
renderDetails() {
|
||||
const frag = document.createDocumentFragment();
|
||||
const specs = document.createElement('div'); {
|
||||
specs.classList.add('specs');
|
||||
const name = document.createElement('div'); {
|
||||
name.classList.add('name');
|
||||
name.classList.add('monospace');
|
||||
name.textContent = this.name;
|
||||
specs.append(name);
|
||||
}
|
||||
frag.append(specs);
|
||||
}
|
||||
const help = document.createElement('span'); {
|
||||
help.classList.add('help');
|
||||
help.textContent = 'scoped variable';
|
||||
frag.append(help);
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user