Skip to content

Templates

Generate new handlers, components, and commands from Handlebars templates you keep in your bot, with seedcord make prompting for each value.

You've started a new button by copying the last one. You renamed the class and the custom id, retyped the label, and fixed the import path when the copy was one folder deeper. Then you did the same again for the handler that answers it.

seedcord make generates those files from a template you keep in your project. You pick a template and type a name. It asks for each value the template reads, then writes every file in the template under your root folder.

pnpm exec seedcord make

It only runs in a terminal, since every answer comes from a prompt. It doesn't take the answers as flags. With no terminal, or with CI set, it throws MakeNeedsTerminal.

Writing a template

Templates live in a templates folder beside seedcord.config.ts. Each folder directly inside it is one template, and the first prompt lists them by folder name. seedcord skips a folder whose name starts with _, so partials can live there. make.templates in your config points at a different folder, as Your own helpers shows.

This Button template writes a button row and the handler that answers it:

templates/
└─ Button/
   ├─ components/
   │  └─ {{pascal name}}.ts.hbs
   └─ handlers/
      └─ {{pascal name}}.ts.hbs

The folders inside a template become folders under root, which is ./src in a scaffolded project. components/{{pascal name}}.ts.hbs goes to components/ with the .hbs taken off, and the handler goes to handlers/. Any extension works in front of .hbs, like .json.hbs. seedcord ignores a file that doesn't end in .hbs.

Each file is a Handlebars (opens in a new tab) template. The sample builds a custom id and a button row. Here's the component:

templates/Button/components/{{pascal name}}.ts.hbs
import { ButtonBuilder } from '@discordjs/builders';
import { CustomId, RowComponent } from '{{ transport }}';
import { ButtonStyle } from 'discord-api-types/v10';

export const {{ pascal name }}Id = new CustomId('{{ kebab name }}');

export class {{ pascal name }}Row extends RowComponent<'button'> {
    constructor() {
        super('button');

        this.instance.addComponents(
            new ButtonBuilder()
                .setCustomId({{ pascal name }}Id.encode({}))
                .setLabel({{ quote label }})
                .setStyle(ButtonStyle.Primary)
        );
    }
}

And the handler, which imports the component's custom id:

templates/Button/handlers/{{pascal name}}.ts.hbs
import { ButtonHandler, ButtonRoute } from '{{ transport }}';

import { {{ pascal name }}Id } from '{{ importOf "components" }}';

@ButtonRoute({{ pascal name }}Id)
export class {{ pascal name }}Button extends ButtonHandler<[typeof {{ pascal name }}Id]> {
    public async execute(): Promise<void> {
        await this.reply({{ quote reply }});
    }
}

Say you pick Button, type tickets/Appeal as the name, then answer Appeal ban for label and Sent to the mods for reply. seedcord writes two files:

src/components/tickets/Appeal.ts
src/handlers/tickets/Appeal.ts

In both, {{ pascal name }} becomes Appeal, and {{ transport }} becomes @seedcord/gateway on a gateway bot. In the handler, {{ importOf "components" }} becomes ../../components/tickets/Appeal, which is the path from the new handler to the new component.

Keep a component and the handler that answers it in one template. If you split them into two templates, you have to type that import path by hand, because importOf only reaches files the same run writes.

After make writes the files, it reminds you to run seedcord codegen, or to keep seedcord dev running. A new command's route only type checks once codegen records it.

Warning

Prettier treats a .hbs file as a Handlebars HTML template and fails on TypeScript inside it, like the typeof in the handler above. List templates/ in .prettierignore.

The name

name is the part after the last /, exactly as you typed it, so any casing works. roll dice, rollDice, and RollDice all become RollDice through {{ pascal name }}. {{ kebab name }} turns each of them into roll-dice.

The name starts with a letter and contains letters, digits, spaces, -, and _. To write into a subfolder, type the folder names first, separated by /. tickets/Appeal, for example, puts both files in a tickets folder. If a file the template would write already exists, the name prompt says the file "already exists" and asks again. make doesn't overwrite a file.

The template doesn't see the folders you typed. If it builds a custom id from name, as the component above does, then tickets/Appeal and moderation/Appeal both get the id appeal, and your bot throws CoreDuplicateName when it starts. Give those two different names, or build the id from a value the template asks for, like {{ kebab id }}.

seedcord renders only the file name in a template's path, so a folder called {{pascal name}} keeps that name. It works out every path as soon as you type the name, before it asks anything else. That's how the name prompt already knows a file exists, and why a file name can only use name and the helpers.

The values you're asked for

You don't declare a template's values anywhere. seedcord reads every .hbs file in the template, partials included, and picks each question from how the template uses that value. Add a value to a template and the next run asks for it.

in the templatewhat you're asked
{{ label }}a line of text
{{#if ephemeral}} or {{#unless ephemeral}}yes or no
{{#each choices}}{{ this }}{{/each}}one line of text, split at each comma
{{#each options}}{{ label }}{{/each}}"Add an item to options?", then each field of that item, until you answer no
{{ author.label }}, or {{ label }} inside {{#with author}}each field of author, asked as author.label

The questions come in the order the values first appear. make reads the files in alphabetical order of their paths. If a template prints a value and also uses it in an #if, as in {{#if note}}{{ note }}{{/if}}, make asks for text. An empty answer skips the block.

make also asks for a value you reach through ../, @root, or a block param, as in as |option|. It doesn't ask for @index, which contains the position in the loop.

seedcord disables Handlebars' HTML escaping, so {{ label }} writes your answer exactly as typed, quotes and < included. An answer like Don't panic ends a '...' string early. {{ quote label }}, as in the samples above, writes the answer as a single-quoted string with every single quote and backslash in it escaped.

make throws on these, after you type the name and before it asks for any value:

  • If the template uses one value as two of text, a list, and an item with fields, make throws MakeValueUsedTwoWays. Looping over tags with #each and also printing {{ tags.first }} is one example. So is printing {{ author }} and also {{ author.name }}.
  • If a partial calls itself, make throws MakePartialCallsItself, because it can't tell how many levels to ask for.
  • If Handlebars' lookup helper gets an unquoted key, make throws MakeLookupDynamicKey, because seedcord can't tell which field to ask for. Quote the key, as in {{ lookup author "label" }}.

If a template file doesn't parse, calls a helper that doesn't exist, or has a file name that reads a value other than name, make throws MakeTemplateFailed with the file's path and the error Handlebars gave.

The values seedcord fills in

make fills these in from your project and the name you typed.

valuewhat it contains
namethe name you typed, after the last /
transportthe package your bot's Seedcord comes from, @seedcord/gateway or @seedcord/http
isGatewaytrue on a gateway bot
isEdgetrue on a bot that runs on Cloudflare Workers
toRootthe path from the file being written back to root, like ../..

Use toRoot to import a file at a fixed place under root, like '{{ toRoot }}/db'. The path is correct at any depth.

To fill in transport, make loads your instance file the way seedcord codegen does. If constructing your bot throws, for example because an env var is missing, make throws too.

The helpers seedcord ships

With the name typed as roll dice:

helpercallgives
pascal{{ pascal name }}RollDice
camel{{ camel name }}rollDice
snake{{ snake name }}roll_dice
kebab{{ kebab name }}roll-dice
quote{{ quote name }}'roll dice'
importOf{{ importOf "components" }}the import path to the one file in that template folder

importOf takes the path of a folder inside the template, like "components". It returns the import path from the file being written to the file in that folder. The path leaves off a .ts or .js extension. If that folder contains no .hbs file or more than one, make throws MakeImportOfUnmatched.

Your own helpers

If several templates need the same change to a value, write it once as a helper under make.helpers in seedcord.config.ts:

seedcord.config.ts
import { defineConfig } from 'seedcord';

export default defineConfig({
    root: './src',
    instance: './bot.ts',
    make: {
        templates: './templates', // the default
        helpers: {
            upper: (text: string) => text.toUpperCase()
        }
    }
});

A template calls upper like a built-in helper. {{ upper (snake name) }} turns roll dice into ROLL_DICE, a name for a constant. Handlebars writes what a helper returns into the file as text, so the type of make.helpers rejects a helper that doesn't return a string. Handlebars passes your arguments first, then an options object you can ignore. If you keep your helpers in their own file, type them as SeedcordMakeHelpers from seedcord.

If you give a helper a name templates already use, like pascal, Handlebars' each, or a value seedcord fills in like name, TypeScript reports an error in your config, and make throws CliConfigInvalidField. make never asks for a value with a helper's name, even when a template calls the helper with no arguments, as in {{ upper }}.

Partials

Every handler template starts with the same import line. If it lives in each template, a change to it means editing every copy. A partial holds that shared piece once. Put it in templates/_partials/ and call it by its file name without .hbs:

templates/_partials/imports.hbs
import { {{ symbols }} } from '{{ transport }}';
templates/Button/handlers/{{pascal name}}.ts.hbs
{{> imports symbols="ButtonHandler, ButtonRoute"}}

symbols="..." passes a value into the partial. If the partial reads a value you don't pass in, make asks for it like any other, because seedcord reads a partial as part of the template that calls it.

A value after the partial's name becomes the data inside it. In {{> card author}}, a {{ label }} inside card reads author.label, so that's the question you get.

A partial in a subfolder keeps the folder in its name. _partials/discord/imports.hbs, for example, is {{> discord/imports}}. Your templates use the _partials in your templates folder. A plugin's templates use the plugin's own _partials.

Templates from a plugin

A plugin's users write the same files around it in every bot, like a handler that reads the plugin from this.core. A plugin can ship those as templates. make reads the package.json of each installed package listed in your bot's own package.json. If one points at a templates folder, make lists its templates.

package.json
{
    "name": "@you/seedcord-plugin",
    "files": ["dist", "templates"],
    "seedcord": {
        "templates": "./templates"
    }
}

The path resolves against the plugin's own folder. List the folder in files too, or npm leaves it out of the published package. The first prompt shows where each template came from beside its name. Your own templates show your templates folder, like templates/. A plugin's templates show its package name.

A plugin's templates work like yours. One template can serve gateway and http bots alike, since {{ transport }} and {{#if isGateway}} read the bot make runs in.

Starting with no templates

A project from create-seedcord starts with templates in templates/ for a slash command, a button, a select menu, a modal, and on gateway an event. They come from the starters (opens in a new tab) in the seedcord repo, so you can copy them into a project that doesn't have them.

If neither your bot nor its plugins have a template, make throws MakeNoTemplates with the folder it read.