Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(subcommands): type improvements & fix build #59

Merged
merged 3 commits into from
Feb 12, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
[![npm](https://img.shields.io/npm/v/@sapphire/plugin-api?color=crimson&logo=npm&style=flat-square&label=@sapphire/plugin-api)](https://www.npmjs.com/package/@sapphire/plugin-api)
[![npm](https://img.shields.io/npm/v/@sapphire/plugin-logger?color=crimson&logo=npm&style=flat-square&label=@sapphire/plugin-logger)](https://www.npmjs.com/package/@sapphire/plugin-logger)
[![npm](https://img.shields.io/npm/v/@sapphire/plugin-i18next?color=crimson&logo=npm&style=flat-square&label=@sapphire/plugin-i18next)](https://www.npmjs.com/package/@sapphire/plugin-i18next)
[![npm](https://img.shields.io/npm/v/@sapphire/plugin-subcommands?color=crimson&logo=npm&style=flat-square&label=@sapphire/plugin-subcommands)](https://www.npmjs.com/package/@sapphire/plugin-subcommands)

</div>

Expand Down
142 changes: 142 additions & 0 deletions packages/subcommands/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<div align="center">

![Sapphire Logo](https://cdn.skyra.pw/gh-assets/sapphire.png)

# @sapphire/plugin-subcommands

**Plugin for <a href="https://github.com/sapphire-project/framework">@sapphire/framework</a> so your commands can have subcommands.**

[![GitHub](https://img.shields.io/github/license/sapphire-project/plugins)](https://github.com/sapphire-project/plugins/blob/main/LICENSE.md)
[![Total alerts](https://img.shields.io/lgtm/alerts/g/sapphire-project/plugins.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/sapphire-project/plugins/alerts/)
[![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/sapphire-project/plugins.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/sapphire-project/plugins/context:javascript)
[![Coverage Status](https://coveralls.io/repos/github/sapphire-project/plugins/badge.svg?branch=main)](https://coveralls.io/github/sapphire-project/plugins?branch=main)
[![npm bundle size](https://img.shields.io/bundlephobia/min/@sapphire/plugin-subcommands?logo=webpack&style=flat-square)](https://bundlephobia.com/result?p=@sapphire/plugin-subcommands)
[![npm](https://img.shields.io/npm/v/@sapphire/plugin-subcommands?color=crimson&logo=npm&style=flat-square)](https://www.npmjs.com/package/@sapphire/plugin-subcommands)
[![Depfu](https://badges.depfu.com/badges/11bbf7392987e6fd51fc6559e1d42dfc/count.svg)](https://depfu.com/github/sapphire-project/plugins?project_id=15201)

</div>

## Description

Subcommands are a way to split 1 command into multiple. This can in particular be very useful for configuration commands with subcommands such as `set`, `reset` and `remove`.

## Features

- Fully ready for TypeScript!
- Includes ESM ready entrypoint
- Type generics for easy extension in TypeScript
- Input/Output mapping

## Installation

```sh
yarn add -D @sapphire/plugin-subcommands
```

---

## Usage

_With TypeScript:_

```typescript
import { SubCommandPluginCommand } from '@sapphire/plugin-subcommands';
import { ApplyOptions } from '@sapphire/decorators';
import type { Args } from '@sapphire/framework';
import type { Message } from 'discord.js';

// Using ApplyOptions decorator makes it easy to configure
@ApplyOptions<SkyraCommand.Options>({
favna marked this conversation as resolved.
Show resolved Hide resolved
subCommands: ['add', 'remove', 'list', 'reset', { input: 'show', default: true }]
})
// Extend `SubCommandPluginCommand` instead of `Command`
export class UserCommand extends SubCommandPluginCommand {
// Do not include a `run` method, each method name should match with the subcommand names
public async add(message: Message, args: Args) {}

public async remove(message: Message, args: Args) {}

public async list(message: Message, args: Args) {}

public async reset(message: Message, args: Args) {}

public async show(message: Message, args: Args) {}
}
```

_With JavaScript:_

```javascript

const { SubCommandPluginCommand } = require('@sapphire/plugin-subcommands');

// Extend `SubCommandPluginCommand` instead of `Command`
export class UserCommand extends SubCommandPluginCommand {

constructor(context, options) {
super(context, {
...options,
subCommands: ['add', 'remove', 'list', 'reset', { input: 'show', default: true }]
}
)
}

// Do not include a `run` method, each method name should match with the subcommand names
public async add(message, args) {}

public async remove(message, args) {}

public async list(message, args) {}

public async reset(message, args) {}

public async show(message, args) {}
}
```

## SubCommands Documentation

For the full @sapphire/plugin-subcommands documentation please refer to the TypeDoc generated [documentation](https://sapphire-project.github.io/plugins/modules/_sapphire_plugin_subcommands.html).

## Buy us some doughnuts

Sapphire Project is and always will be open source, even if we don't get donations. That being said, we know there are amazing people who may still want to donate just to show their appreciation. Thank you very much in advance!

We accept donations through Open Collective, Ko-fi, Paypal, Patreon and GitHub Sponsorships. You can use the buttons below to donate through your method of choice.

| Donate With | Address |
| :-------------: | :----------------------------------------------------------------------------------------------: |
| Open Collective | [Click Here](https://opencollective.com/sapphire-project) |
| Ko-fi | [Click Here](https://ko-fi.com/sapphireproject) |
| Patreon | [Click Here](https://www.patreon.com/sapphire_project) |
| PayPal | [Click Here](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=SP738BQTQQYZY) |

## Contributors ✨

Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):

<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://favware.tech/"><img src="https://avatars3.githubusercontent.com/u/4019718?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jeroen Claassens</b></sub></a><br /><a href="https://github.com/sapphire-project/plugins/commits?author=Favna" title="Code">💻</a> <a href="#infra-Favna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#projectManagement-Favna" title="Project Management">📆</a></td>
<td align="center"><a href="https://quantumlytangled.com"><img src="https://avatars1.githubusercontent.com/u/7919610?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nejc Drobnic</b></sub></a><br /><a href="https://github.com/sapphire-project/plugins/commits?author=QuantumlyTangled" title="Code">💻</a> <a href="https://github.com/sapphire-project/plugins/commits?author=QuantumlyTangled" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/kyranet"><img src="https://avatars0.githubusercontent.com/u/24852502?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Antonio Román</b></sub></a><br /><a href="https://github.com/sapphire-project/plugins/commits?author=kyranet" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/vladfrangu"><img src="https://avatars3.githubusercontent.com/u/17960496?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Vlad Frangu</b></sub></a><br /><a href="https://github.com/sapphire-project/plugins/pulls?q=is%3Apr+reviewed-by%3Avladfrangu" title="Reviewed Pull Requests">👀</a></td>
<td align="center"><a href="https://github.com/apps/depfu"><img src="https://avatars3.githubusercontent.com/in/715?v=4?s=100" width="100px;" alt=""/><br /><sub><b>depfu[bot]</b></sub></a><br /><a href="#maintenance-depfu[bot]" title="Maintenance">🚧</a></td>
<td align="center"><a href="https://github.com/apps/dependabot"><img src="https://avatars0.githubusercontent.com/in/29110?v=4?s=100" width="100px;" alt=""/><br /><sub><b>dependabot[bot]</b></sub></a><br /><a href="#maintenance-dependabot[bot]" title="Maintenance">🚧</a></td>
<td align="center"><a href="https://github.com/apps/allcontributors"><img src="https://avatars0.githubusercontent.com/in/23186?v=4?s=100" width="100px;" alt=""/><br /><sub><b>allcontributors[bot]</b></sub></a><br /><a href="https://github.com/sapphire-project/plugins/commits?author=allcontributors[bot]" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Nytelife26"><img src="https://avatars1.githubusercontent.com/u/22531310?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tyler J Russell</b></sub></a><br /><a href="https://github.com/sapphire-project/plugins/commits?author=Nytelife26" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Stitch07"><img src="https://avatars.githubusercontent.com/u/29275227?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Stitch07</b></sub></a><br /><a href="https://github.com/sapphire-project/plugins/commits?author=Stitch07" title="Code">💻</a> <a href="https://github.com/sapphire-project/plugins/issues?q=author%3AStitch07" title="Bug reports">🐛</a></td>
</tr>
</table>

<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->

<!-- ALL-CONTRIBUTORS-LIST:END -->

This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
7 changes: 4 additions & 3 deletions packages/subcommands/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
"prepublishOnly": "yarn build"
},
"peerDependencies": {
"@sapphire/framework": "1.x"
"@sapphire/framework": "1.x",
"@sapphire/utilities": "1.x",
"discord.js": "12.x"
},
"repository": {
"type": "git",
Expand All @@ -30,8 +32,7 @@
},
"files": [
"dist",
"!dist/*.tsbuildinfo",
"register.*"
"!dist/*.tsbuildinfo"
],
"engines": {
"node": ">=14",
Expand Down
22 changes: 11 additions & 11 deletions packages/subcommands/src/lib/SubCommandEntry.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
import type { Args, Awaited, Command, CommandContext } from '@sapphire/framework';
import type { Message } from 'discord.js';
import { isFunction } from '@sapphire/utilities';
import type { Message } from 'discord.js';

/**
* @since 1.0.0
* SubCommandEntry represents a basic subcommand entry. Methods and command names are supported in core.
* @see {@link SubCommandEntryCommand}
* @see {@link SubCommandEntryMethod}
*/
export abstract class SubCommandEntry<T extends Args, C extends Command> {
public readonly input: string | ((context: SubCommandEntry.RunContext<T, C>) => Awaited<string>);
export abstract class SubCommandEntry<ArgType extends Args = Args, CommandType extends Command<ArgType> = Command<ArgType>> {
public readonly input: string | ((context: SubCommandEntry.RunContext<ArgType, CommandType>) => Awaited<string>);
public readonly output: string;

public constructor(options: SubCommandEntry.Options<T, C>) {
public constructor(options: SubCommandEntry.Options<ArgType, CommandType>) {
this.input = options.input;
if (!options.output && typeof options.input !== 'string') throw new ReferenceError('No output provided.');
this.output = options.output ?? (options.input as string);
}

public async match(value: string, context: SubCommandEntry.RunContext<T, C>): Promise<boolean> {
public async match(value: string, context: SubCommandEntry.RunContext<ArgType, CommandType>): Promise<boolean> {
return (isFunction(this.input) ? await this.input(context) : this.input) === value;
}

public abstract run(context: SubCommandEntry.RunContext<T, C>): unknown;
public abstract run(context: SubCommandEntry.RunContext<ArgType, CommandType>): unknown;
}

// eslint-disable-next-line @typescript-eslint/no-namespace
Expand All @@ -39,19 +39,19 @@ export namespace SubCommandEntry {
* }]
* ```
*/
export interface Options<T extends Args, C extends Command> {
input: string | ((context: RunContext<T, C>) => Awaited<string>);
export interface Options<ArgType extends Args = Args, CommandType extends Command<ArgType> = Command<ArgType>> {
input: string | ((context: RunContext<ArgType, CommandType>) => Awaited<string>);
output?: string;
}

/**
* RunContext is passed to SubCommandManager.run() and to input (if it is a function)
* @see {@link SubCommandEntry.Options}
*/
export interface RunContext<T extends Args, C extends Command> {
command: C;
export interface RunContext<ArgType extends Args = Args, CommandType extends Command<ArgType> = Command<ArgType>> {
command: CommandType;
message: Message;
args: T;
args: ArgType;
context: CommandContext;
}
}
7 changes: 5 additions & 2 deletions packages/subcommands/src/lib/SubCommandEntryCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ import { SubCommandEntry } from './SubCommandEntry';
* }]
* ```
*/
export class SubCommandEntryCommand<T extends Args, C extends Command> extends SubCommandEntry<T, C> {
public run(context: SubCommandEntry.RunContext<T, C>): unknown {
export class SubCommandEntryCommand<ArgType extends Args = Args, CommandType extends Command<ArgType> = Command<ArgType>> extends SubCommandEntry<
ArgType,
CommandType
> {
public run(context: SubCommandEntry.RunContext<ArgType, CommandType>): unknown {
const command = Store.injectedContext.stores.get('commands').get(this.output);
if (command) return command.run(context.message, context.args, context.context);
throw new ReferenceError(`The command '${this.input}' does not exist.`);
Expand Down
7 changes: 5 additions & 2 deletions packages/subcommands/src/lib/SubCommandEntryMethod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ import { SubCommandEntry } from './SubCommandEntry';
* }
* ```
*/
export class SubCommandEntryMethod<T extends Args, C extends Command> extends SubCommandEntry<T, C> {
public run(context: SubCommandEntry.RunContext<T, C>): unknown {
export class SubCommandEntryMethod<ArgType extends Args = Args, CommandType extends Command<ArgType> = Command<ArgType>> extends SubCommandEntry<
ArgType,
CommandType
> {
public run(context: SubCommandEntry.RunContext<ArgType, CommandType>): unknown {
const method = Reflect.get(context.command, this.output);
if (method) return Reflect.apply(method, context.command, [context.message, context.args, context.context]);
throw new ReferenceError(`The method '${this.input}' does not exist for the command '${context.command.name}'.`);
Expand Down
20 changes: 12 additions & 8 deletions packages/subcommands/src/lib/SubCommandManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import type { SubCommandEntry } from './SubCommandEntry';
import { SubCommandEntryCommand } from './SubCommandEntryCommand';
import { SubCommandEntryMethod } from './SubCommandEntryMethod';

export class SubCommandManager<T extends Args, C extends Command> {
private readonly entries: SubCommandEntry<T, C>[] = [];
private readonly default: SubCommandEntry<T, C> | null = null;
export class SubCommandManager<ArgType extends Args = Args, CommandType extends Command<ArgType> = Command<ArgType>> {
private readonly entries: SubCommandEntry<ArgType, CommandType>[] = [];
private readonly default: SubCommandEntry<ArgType, CommandType> | null = null;

public constructor(entries: SubCommandManager.RawEntries<T, C>) {
public constructor(entries: SubCommandManager.RawEntries<ArgType, CommandType>) {
for (const data of entries) {
const value = this.resolve(data);
const Ctor = SubCommandManager.handlers.get(value.type ?? 'method');
Expand All @@ -23,7 +23,7 @@ export class SubCommandManager<T extends Args, C extends Command> {
}
}

public async run(context: SubCommandEntry.RunContext<T, C>) {
public async run(context: SubCommandEntry.RunContext<ArgType, CommandType>) {
// Pick one argument, then try to match a subcommand:
context.args.save();
const value = context.args.nextMaybe();
Expand All @@ -42,7 +42,7 @@ export class SubCommandManager<T extends Args, C extends Command> {
return err(new UserError({ identifier: Identifiers.SubCommandNoMatch, context }));
}

protected resolve(value: string | SubCommandManager.Entry<T, C>): SubCommandManager.Entry<T, C> {
protected resolve(value: string | SubCommandManager.Entry<ArgType, CommandType>): SubCommandManager.Entry<ArgType, CommandType> {
if (typeof value !== 'string') return value;
return { input: value, output: value, type: 'method' };
}
Expand All @@ -56,10 +56,14 @@ export class SubCommandManager<T extends Args, C extends Command> {
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace SubCommandManager {
export type Type = 'command' | 'method';
export interface Entry<T extends Args, C extends Command> extends SubCommandEntry.Options<T, C> {
export interface Entry<ArgType extends Args = Args, CommandType extends Command<ArgType> = Command<ArgType>>
extends SubCommandEntry.Options<ArgType, CommandType> {
type?: Type;
default?: boolean;
}

export type RawEntries<T extends Args, C extends Command> = readonly (string | Entry<T, C>)[];
export type RawEntries<ArgType extends Args = Args, CommandType extends Command<ArgType> = Command<ArgType>> = readonly (
| string
| Entry<ArgType, CommandType>
)[];
}
15 changes: 8 additions & 7 deletions packages/subcommands/src/lib/SubCommandPluginCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@ import { Args, Awaited, Command, CommandContext, CommandOptions, PieceContext }
import type { Message } from 'discord.js';
import { SubCommandManager } from './SubCommandManager';

export class SubCommandPluginCommand<T extends Args = Args, C extends Command = Command> extends Command<T> {
public readonly subCommands: SubCommandManager<T, C> | null;
export class SubCommandPluginCommand<ArgType extends Args = Args, CommandType extends Command<ArgType> = Command<ArgType>> extends Command<ArgType> {
public readonly subCommands: SubCommandManager<ArgType, CommandType> | null;

public constructor(context: PieceContext, options: SubCommandPluginCommandOptions<T>) {
public constructor(context: PieceContext, options: SubCommandPluginCommandOptions<ArgType>) {
super(context, options);

this.subCommands = options.subCommands ? new SubCommandManager(options.subCommands) : null;
}

public run(message: Message, args: T, context: CommandContext): Awaited<unknown> {
public run(message: Message, args: ArgType, context: CommandContext): Awaited<unknown> {
if (!this.subCommands) throw new Error(`The command ${this.name} does not have a 'run' method and does not support sub-commands.`);
return this.subCommands.run({ message, args, context, command: this });
return this.subCommands.run({ message, args, context, command: (this as unknown) as CommandType });
}
}

export interface SubCommandPluginCommandOptions<T extends Args = Args, C extends Command = Command> extends CommandOptions {
subCommands: SubCommandManager.RawEntries<T, C>;
export interface SubCommandPluginCommandOptions<ArgType extends Args = Args, CommandType extends Command<ArgType> = Command<ArgType>>
extends CommandOptions {
subCommands: SubCommandManager.RawEntries<ArgType, CommandType>;
}