Skip to content
GitHub

Project structure

MeoCord 4.1 · Start · page 4 of 41

What each file of a new project is for, where new code goes as a bot grows, and what the build writes.

You'll learn

  • Find your way around the files the create command writes
  • Choose between grouping files by kind and by feature
  • Tell what the build writes to dist, and what the bot reads from it

Before this

The create command writes a project laid out by kind: controllers in one folder, services in another. Nothing in it is required by MeoCord except, at the root, meocord.config.ts, tsconfig.json and a package.json that lists meocord under dependencies, and the entry point, src/main.ts; the folders are a convention meocord generate follows, and you can lay a bot out by feature instead.

When to use it

Read this once a new project is running, before you add to it, and again when a bot outgrows the layout it started with.

Example

The layout the create command writes, besides a README, .gitignore, .prettierrc.mjs and the package manager's lockfile. Unless the folder is already inside a git repository, it makes it one, with the project, lockfile included, as its first commit. For pnpm it adds a pnpm-workspace.yaml, and for npm an allowScripts field in package.json. Both turn off the dependency install scripts the app doesn't need, those of @swc/core and unrs-resolver, and for npm fsevents, so pnpm 11 and later install without stopping and npm 11.16 and later without a warning:

text
.
├── .env.example            # copy to .env and fill in DISCORD_TOKEN
├── meocord.config.ts       # the token, the build hook, command registration
├── eslint.config.ts        # extends meocord/eslint
├── vitest.config.ts
├── vitest.setup.ts         # resets MeoCord's mocks after every test
├── tsconfig.json           # the app
├── tsconfig.test.json      # the app and its specs
├── tsconfig.eslint.json    # what ESLint type-checks
├── package.json
└── src
    ├── main.ts             # starts the app
    ├── app.ts              # the @MeoCord class: controllers, services, client options
    ├── controllers
    │   ├── slash/          # slash commands, with their builders under builders/
    │   ├── button/
    │   ├── select-menu/
    │   ├── modal-submit/
    │   ├── context-menu/   # context menu commands, with their builders under builders/
    │   ├── message/
    │   └── reaction/
    ├── guards/
    ├── presenters/         # how loading and error views look
    ├── services/
    └── types/              # declarations for asset imports, and for theme tokens of your own

Each sample controller, guard, presenter and service has a .spec.ts beside it; the command builders don't.

How it works

src/main.ts creates the app from src/app.ts with MeoCordFactory.create(App) and starts it. meocord build bundles it with Rsbuild into dist/, and meocord start runs dist/main.js, building it first under --dev or with --build. MeoCord finds nothing by folder: a controller runs because src/app.ts lists it, wherever its file is.

Adding to it

meocord generate writes a controller, its spec and, for commands, its builder, into the folder for its kind. It doesn't touch src/app.ts: add the new class to controllers there. A controller missing from the list is never bound, and its commands are never registered. The CLI lists every generator.

Growing by feature

Grouping by kind suits a bot with a handful of commands. Once a feature has several parts, such as a command, a form, buttons, a service and a guard, keeping them together is easier to read, and to delete as a unit:

text
src
├── main.ts
├── app.ts
├── i18n.ts
├── locales/
├── feedback/
│   ├── feedback.builder.ts
│   ├── feedback.controller.ts
│   ├── feedback.controller.spec.ts
│   ├── feedback.service.ts
│   ├── review.controller.ts
│   └── staff.guard.ts
└── shared/
    ├── guards/
    └── presenters/

Either layout works, and both can live in one project. A few things belong at the top of src, whichever you choose:

  • i18n.ts and locales/, since every feature's builders and replies use the same translator. See Localisation.
  • Presenters, and guards several features share.
  • Nothing that reads process.env at import time, other than meocord.config.ts. Read settings in a service instead, which a test can replace. See Configuration.

Imports

@src/* resolves to src/*. tsconfig.json declares it for the typechecker, the build resolves it, and vitest.config.ts gives Vitest the same alias. Prefer it to long relative paths: a file moved to another folder keeps its imports.

The build reads your paths as TypeScript does: from compilerOptions.baseUrl when your tsconfig.json sets one, else from the tsconfig.json itself. A baseUrl it only inherits through extends isn't applied to the paths it sets, so declare those relative to the project's own tsconfig.json. On TypeScript 6, which a generated app uses, baseUrl is deprecated, and the generated tsconfig.json declares its paths without it.

The three tsconfigs

FileChecksWhy it exists
tsconfig.jsonsrc, without specs, and the config fileWhat ships. noEmit: Rsbuild compiles, TypeScript only checks
tsconfig.test.jsonsrc with the specsAdds Vitest's global types, which the app itself must not see
tsconfig.eslint.jsonthe config files at the rootLets ESLint's type-aware rules read eslint.config.ts and its neighbours

npm run lint runs ESLint, then tsc against the first two, so a type error in a spec fails as surely as one in the app.

What the build writes

text
dist/
├── main.js                 # the bot, bundled
├── main.js.map             # its source map, so stack traces point at src
├── main.js.LICENSE.txt     # the licence comments a production bundle carries, when it carries any
├── meocord.config.mjs      # the config, compiled, which the bot loads at startup
├── assets/                 # the files your code imports: images, fonts, media and the rest
├── package.json            # marks dist as an ES module, with bundleDependencies only
├── meocord.platform.json   # the platform native addons were built for, when a self-contained build packs one
└── node_modules/           # with bundleDependencies only: native addons, externals and installed optional externals

The bot reads its config from dist, beside main.js, not from meocord.config.ts, so a change to the config takes a new build. Deployment covers what a server needs beside dist, and Self-contained builds covers bundleDependencies.

Gotchas

  • A generated file isn't listed for you. Add each controller meocord generate writes to src/app.ts.
  • Editing meocord.config.ts takes a new build before a bot started with --prod sees it. --dev rebuilds on its own.
  • .env stays out of git. The generated .gitignore lists it; keep it there.

Build it

The feedback bot is one feature, so its files live together in one folder, src/tutorial/, which is where the imports in the chapters after this one point. Create it:

Shell
mkdir src/tutorial

Leave the samples in place for now: they keep the project building and its specs passing while the feedback bot takes shape beside them, and you can delete each one, with its line in src/app.ts, once you no longer want it.

Next steps

  • Slash commands: the feedback bot's /feedback command, and every option a command can take.
  • Services: what a controller can ask for, and how long each instance lives.
  • Configuration: what meocord.config.ts sets.