create-chapter 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/choose.js ADDED
@@ -0,0 +1,27 @@
1
+ import * as p from '@clack/prompts';
2
+ /**
3
+ * A select menu that never asks a question with only one answer: when a single
4
+ * choice can be picked, it is taken right away and the user is told why.
5
+ * Returns `undefined` when no choice can be picked, and the cancel symbol when
6
+ * the user presses Ctrl+C or Esc.
7
+ */
8
+ export async function choose({ message, choices, initialValue, onlyChoice, }) {
9
+ const enabled = choices.filter(choice => !choice.disabled);
10
+ const [first] = enabled;
11
+ if (!first)
12
+ return undefined;
13
+ if (enabled.length === 1) {
14
+ p.log.info(onlyChoice(first.value));
15
+ return first.value;
16
+ }
17
+ // clack's option type can't be resolved for a generic `Value`, so the menu is
18
+ // typed with `string`: it only ever returns one of `choices`.
19
+ const picked = await p.select({
20
+ message,
21
+ initialValue: enabled.some(choice => choice.value === initialValue)
22
+ ? initialValue
23
+ : first.value,
24
+ options: choices,
25
+ });
26
+ return picked;
27
+ }
package/dist/index.js CHANGED
@@ -1,163 +1,96 @@
1
1
  #!/usr/bin/env node
2
2
  import * as p from '@clack/prompts';
3
- import { spawn } from 'node:child_process';
4
- import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
5
- import path from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
- const TEMPLATE_DIR = fileURLToPath(new URL('../template', import.meta.url));
8
- const OWN_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
9
- const DEFAULT_NAME = 'my-chapter-app';
10
- const RENAME_FILES = {
11
- _gitignore: '.gitignore',
12
- };
13
- const PACKAGE_MANAGERS = ['pnpm', 'npm', 'yarn', 'bun'];
14
- function exitIfCancelled(value) {
3
+ import { readFileSync } from 'node:fs';
4
+ import { relative, resolve } from 'node:path';
5
+ import { detectPackageManager, install, installedVersions, packageManagers, runScript, } from './package-manager.js';
6
+ import { choose } from './choose.js';
7
+ import { createProject, validateTargetDir } from './project.js';
8
+ import { listTemplates } from './templates.js';
9
+ import { nodeTooOld } from './node-version.js';
10
+ /** Stops cleanly when the user presses Ctrl+C or Esc on a prompt. */
11
+ function orExit(value) {
15
12
  if (p.isCancel(value)) {
16
- p.cancel('Operation cancelled.');
13
+ p.cancel('Cancelled, nothing was created.');
17
14
  process.exit(0);
18
15
  }
19
16
  return value;
20
17
  }
21
- function toValidPackageName(name) {
22
- return name
23
- .trim()
24
- .toLowerCase()
25
- .replace(/\s+/g, '-')
26
- .replace(/^[._]/, '')
27
- .replace(/[^a-z0-9-~]+/g, '-');
28
- }
29
- function isEmptyDir(dir) {
30
- if (!existsSync(dir))
31
- return true;
32
- // We tolerate .git to be able to do `git init` before `pnpm create chapter .`
33
- return readdirSync(dir).every(f => f === '.git');
18
+ // create-chapter and chapterjs are always released with the same version.
19
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
20
+ p.intro(`Create a ChapterJS bot (v${version})`);
21
+ const tooOld = nodeTooOld();
22
+ if (tooOld) {
23
+ p.cancel(tooOld);
24
+ process.exit(1);
34
25
  }
35
- function validateDir(value) {
36
- const dir = (value ?? '').trim() || DEFAULT_NAME;
37
- if (!isEmptyDir(path.resolve(dir))) {
38
- return dir === '.'
39
- ? 'The current directory is not empty'
40
- : `The directory "${dir}" already exists and is not empty`;
41
- }
42
- return undefined;
26
+ // Started now so it runs while the user answers the first question.
27
+ const versionsCheck = installedVersions();
28
+ // `pnpm create chapter my-bot` skips the first question when the folder is valid.
29
+ const argDir = process.argv[2];
30
+ const argError = argDir === undefined ? undefined : validateTargetDir(argDir);
31
+ if (argError)
32
+ p.log.warn(argError);
33
+ const dir = argDir !== undefined && argError === undefined
34
+ ? argDir.trim()
35
+ : orExit(await p.text({
36
+ message: 'Where should the project be created? (. for the current folder)',
37
+ placeholder: 'my-bot',
38
+ defaultValue: 'my-bot',
39
+ validate: value => validateTargetDir(value || 'my-bot'),
40
+ })).trim();
41
+ const versions = await versionsCheck;
42
+ const pm = orExit(await choose({
43
+ message: 'Which package manager do you want to use?',
44
+ initialValue: detectPackageManager(),
45
+ choices: packageManagers.map(value => {
46
+ const version = versions[value];
47
+ return version === undefined
48
+ ? { value, label: `${value} (not installed)`, disabled: true }
49
+ : { value, hint: `v${version}` };
50
+ }),
51
+ onlyChoice: value => `Using ${value}, the only package manager installed`,
52
+ }));
53
+ if (pm === undefined) {
54
+ p.cancel(`No package manager found. Install one of ${packageManagers.join(', ')}, then run this command again.`);
55
+ process.exit(1);
43
56
  }
44
- function detectPackageManager() {
45
- const agent = process.env.npm_config_user_agent ?? '';
46
- return PACKAGE_MANAGERS.find(pm => agent.startsWith(`${pm}/`));
57
+ const templates = await listTemplates();
58
+ const template = orExit(await choose({
59
+ message: 'Which template do you want to start from?',
60
+ choices: templates.map(({ name, description }) => ({
61
+ value: name,
62
+ hint: description,
63
+ })),
64
+ onlyChoice: value => `Using the ${value} template`,
65
+ }));
66
+ if (template === undefined) {
67
+ p.cancel('No template found. Reinstall create-chapter and try again.');
68
+ process.exit(1);
47
69
  }
48
- function install(pm, cwd) {
49
- const log = p.taskLog({
50
- title: `Installing dependencies with ${pm}`,
51
- limit: 8,
52
- });
53
- return new Promise((resolve, reject) => {
54
- const child = spawn(pm, ['install'], {
55
- cwd,
56
- stdio: ['ignore', 'pipe', 'pipe'],
57
- // Windows: pnpm/npm are .cmd
58
- shell: process.platform === 'win32',
59
- });
60
- // Ctrl+C during install: stop the child before exiting
61
- const onSigint = () => {
62
- child.kill('SIGINT');
63
- log.error('Installation cancelled.');
64
- process.exit(130);
65
- };
66
- process.once('SIGINT', onSigint);
67
- // raw: keep the chunks as-is (line breaks included)
68
- const onData = (chunk) => log.message(chunk.toString(), { raw: true });
69
- child.stdout.on('data', onData);
70
- child.stderr.on('data', onData);
71
- child.on('error', err => {
72
- process.off('SIGINT', onSigint);
73
- log.error(`Could not run ${pm}: ${err.message}`);
74
- reject(err);
75
- });
76
- child.on('close', code => {
77
- process.off('SIGINT', onSigint);
78
- if (code === 0) {
79
- log.success('Dependencies installed');
80
- resolve();
81
- }
82
- else {
83
- log.error(`${pm} install failed (code ${code})`, { showLog: true });
84
- reject(new Error(`${pm} install failed (code ${code})`));
85
- }
86
- });
87
- });
70
+ await createProject({
71
+ dir,
72
+ template,
73
+ chapterjsVersion: `^${version}`,
74
+ packageManager: pm,
75
+ });
76
+ p.log.success(`Project created in ${resolve(dir)}`);
77
+ const spinner = p.spinner();
78
+ spinner.start(`Installing dependencies with ${pm}`);
79
+ const result = await install(pm, dir);
80
+ if (result.ok) {
81
+ spinner.stop(`Dependencies installed with ${pm}`);
88
82
  }
89
- async function main() {
90
- const args = process.argv.slice(2);
91
- const noInstall = args.includes('--no-install');
92
- const argDir = args.find(arg => !arg.startsWith('--'));
93
- const interactive = p.isTTY(process.stdout) && !p.isCI();
94
- let projectDir;
95
- if (argDir && !validateDir(argDir)) {
96
- projectDir = argDir;
97
- }
98
- else {
99
- if (argDir)
100
- p.log.warn(validateDir(argDir));
101
- const answer = exitIfCancelled(await p.text({
102
- message: 'What is the name of your project? (. for the current directory)',
103
- placeholder: DEFAULT_NAME,
104
- defaultValue: DEFAULT_NAME,
105
- validate: validateDir,
106
- }));
107
- projectDir = answer.trim() || DEFAULT_NAME;
108
- }
109
- const targetDir = path.resolve(projectDir);
110
- const isCurrentDir = targetDir === process.cwd();
111
- // "." -> current directory
112
- const packageName = toValidPackageName(path.basename(targetDir));
113
- const s = p.spinner();
114
- s.start('Copying template');
115
- cpSync(TEMPLATE_DIR, targetDir, { recursive: true });
116
- for (const [from, to] of Object.entries(RENAME_FILES)) {
117
- const src = path.join(targetDir, from);
118
- if (existsSync(src))
119
- renameSync(src, path.join(targetDir, to));
120
- }
121
- const pkgPath = path.join(targetDir, 'package.json');
122
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
123
- pkg.name = packageName;
124
- // The template depends on `workspace:*` inside the monorepo; chapterjs and
125
- // create-chapter are released together, so pin the same version.
126
- pkg.dependencies.chapterjs = `^${OWN_VERSION}`;
127
- writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
128
- s.stop(`Project "${packageName}" created in ${targetDir}`);
129
- let pm = detectPackageManager();
130
- if (!pm && interactive) {
131
- pm = exitIfCancelled(await p.select({
132
- message: 'Which package manager do you want to use?',
133
- options: PACKAGE_MANAGERS.map(value => ({ value, label: value })),
134
- initialValue: 'pnpm',
135
- }));
136
- }
137
- pm ??= 'pnpm';
138
- let shouldInstall = !noInstall;
139
- if (shouldInstall && interactive) {
140
- shouldInstall = exitIfCancelled(await p.confirm({ message: `Install dependencies with ${pm}?` }));
141
- }
142
- let installed = false;
143
- if (shouldInstall) {
144
- try {
145
- await install(pm, targetDir);
146
- installed = true;
147
- }
148
- catch {
149
- // Error already shown by the task log
150
- }
151
- }
152
- const steps = [
153
- ...(isCurrentDir ? [] : [`cd ${projectDir}`]),
154
- ...(installed ? [] : [`${pm} install`]),
155
- `${pm === 'npm' ? 'npm run' : pm} dev`,
156
- ];
157
- p.note(steps.join('\n'), 'Next steps');
158
- p.outro('Happy dev!');
83
+ else {
84
+ spinner.error(`The dependencies could not be installed with ${pm}`);
85
+ if (result.output)
86
+ p.log.message(result.output);
159
87
  }
160
- main().catch(err => {
161
- p.log.error(err instanceof Error ? err.message : String(err));
162
- process.exit(1);
163
- });
88
+ const cd = relative(process.cwd(), resolve(dir));
89
+ const steps = [
90
+ ...(cd ? [`cd ${cd.includes(' ') ? `"${cd}"` : cd}`] : []),
91
+ ...(result.ok ? [] : [`${pm} install`]),
92
+ 'Copy .env.example to .env and fill in BOT_TOKEN and DEV_GUILD_ID',
93
+ runScript(pm, 'dev'),
94
+ ];
95
+ p.note(steps.join('\n'), 'Next steps');
96
+ p.outro('Happy building!');
@@ -0,0 +1,13 @@
1
+ /** The oldest Node.js a ChapterJS bot runs on, as `engines` says. */
2
+ export const MINIMUM_NODE = 24;
3
+ /**
4
+ * What to tell the person when this Node.js is older than ChapterJS needs,
5
+ * or `null` when it is recent enough: refused before creating anything,
6
+ * since the bot could not run.
7
+ */
8
+ export function nodeTooOld(version = process.versions.node) {
9
+ const major = Number(version.split('.')[0]);
10
+ if (major >= MINIMUM_NODE)
11
+ return null;
12
+ return `ChapterJS needs Node.js ${MINIMUM_NODE} or newer, and this is Node.js ${version}. Install the LTS version from https://nodejs.org, then run the command again.`;
13
+ }
@@ -0,0 +1,59 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { tmpdir } from 'node:os';
3
+ /** In menu order: pnpm, the one ChapterJS itself uses, comes first. */
4
+ export const packageManagers = ['pnpm', 'npm', 'yarn', 'bun'];
5
+ /**
6
+ * The package manager that launched `create chapter`, read from the user agent
7
+ * it sets (e.g. `pnpm/11.0.0 npm/? node/v24.0.0 darwin arm64`).
8
+ */
9
+ export function detectPackageManager() {
10
+ const name = process.env.npm_config_user_agent?.split('/')[0];
11
+ return packageManagers.find(pm => pm === name) ?? 'pnpm';
12
+ }
13
+ /**
14
+ * The installed version of each package manager, or `undefined` when it isn't
15
+ * installed. All are checked at once, so this takes as long as the slowest one.
16
+ */
17
+ export async function installedVersions() {
18
+ const versions = await Promise.all(packageManagers.map(pm => new Promise(resolve => {
19
+ let output = '';
20
+ const child = spawn(pm, ['--version'], {
21
+ // Outside the current folder: a `packageManager` field in a parent
22
+ // package.json makes corepack refuse to run any other manager.
23
+ cwd: tmpdir(),
24
+ // Corepack must never stop to ask before downloading a manager.
25
+ env: { ...process.env, COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' },
26
+ shell: process.platform === 'win32',
27
+ stdio: ['ignore', 'pipe', 'ignore'],
28
+ });
29
+ child.stdout.on('data', (chunk) => (output += chunk));
30
+ child.on('error', () => resolve(undefined));
31
+ child.on('close', code => resolve(code === 0 ? output.trim() || undefined : undefined));
32
+ })));
33
+ return Object.fromEntries(packageManagers.map((pm, i) => [pm, versions[i]]));
34
+ }
35
+ /** The command that runs a package.json script, e.g. `pnpm dev` or `npm run dev`. */
36
+ export function runScript(pm, script) {
37
+ return pm === 'npm' ? `npm run ${script}` : `${pm} ${script}`;
38
+ }
39
+ /** Runs `<pm> install` in `cwd`, capturing the output instead of printing it. */
40
+ export function install(pm, cwd) {
41
+ return new Promise(resolve => {
42
+ let output = '';
43
+ const child = spawn(pm, ['install'], {
44
+ cwd,
45
+ // Package managers are `.cmd` shims on Windows, which only a shell can run.
46
+ shell: process.platform === 'win32',
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ });
49
+ child.stdout.on('data', (chunk) => (output += chunk));
50
+ child.stderr.on('data', (chunk) => (output += chunk));
51
+ child.on('error', (error) => {
52
+ const reason = error.code === 'ENOENT'
53
+ ? `${pm} is not installed on this computer.`
54
+ : error.message;
55
+ resolve({ ok: false, output: reason });
56
+ });
57
+ child.on('close', code => resolve({ ok: code === 0, output: output.trim() }));
58
+ });
59
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { cp, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { basename, join, resolve } from 'node:path';
4
+ import { templatesDir } from './templates.js';
5
+ /** Files that don't make a folder "used": a project can still be created there. */
6
+ const ignoredFiles = new Set([
7
+ '.git',
8
+ '.DS_Store',
9
+ 'Thumbs.db',
10
+ '.idea',
11
+ '.vscode',
12
+ ]);
13
+ /** Returns why `input` can't hold a new project, or `undefined` when it can. */
14
+ export function validateTargetDir(input) {
15
+ const dir = input?.trim();
16
+ if (!dir)
17
+ return 'Type a folder name, or . to use the current folder.';
18
+ const path = resolve(dir);
19
+ if (!existsSync(path))
20
+ return undefined;
21
+ if (!statSync(path).isDirectory())
22
+ return `${dir} is a file, not a folder. Pick another name.`;
23
+ const used = readdirSync(path).some(file => !ignoredFiles.has(file));
24
+ if (used) {
25
+ return path === process.cwd()
26
+ ? 'The current folder is not empty. Type a name to create a new folder instead.'
27
+ : `The folder ${dir} is not empty. Pick another name.`;
28
+ }
29
+ return undefined;
30
+ }
31
+ /** A valid npm package name from a folder name, e.g. `Mon Bot Été` → `mon-bot-ete`. */
32
+ export function toPackageName(dir) {
33
+ const name = basename(resolve(dir))
34
+ // `é` → `e`: split letters from their accents, then drop the accents.
35
+ .normalize('NFD')
36
+ .replace(/\p{Diacritic}/gu, '')
37
+ .toLowerCase()
38
+ .replace(/[^a-z0-9-~._]+/g, '-')
39
+ .replace(/-{2,}/g, '-')
40
+ // npm names can't start with `.` or `_`, and 214 characters is the maximum.
41
+ .replace(/^[-._]+/, '')
42
+ .slice(0, 214)
43
+ .replace(/[-.]+$/, '');
44
+ return name || 'chapter-bot';
45
+ }
46
+ /**
47
+ * What pnpm needs to install a project whose framework comes with esbuild
48
+ * (which `chapterjs build` compiles the bot with). esbuild has an install
49
+ * script it does not need; pnpm refuses to go on until the project says whether that
50
+ * script may run. It may not: no script of a dependency runs.
51
+ * `allowBuilds` is read by pnpm 11, `ignoredBuiltDependencies` by pnpm 10.
52
+ */
53
+ export const PNPM_WORKSPACE = `# esbuild works without its install script: pnpm is told not to run it.
54
+ allowBuilds:
55
+ esbuild: false
56
+ ignoredBuiltDependencies:
57
+ - esbuild
58
+ `;
59
+ /** Copies the template into `dir` and names the project after its folder. */
60
+ export async function createProject({ dir, template, chapterjsVersion, packageManager, templatesRoot = templatesDir, }) {
61
+ const root = resolve(dir);
62
+ await cp(join(templatesRoot, template), root, { recursive: true });
63
+ // Where the files the bot sends go (`asset()`): git and npm keep no empty
64
+ // folder, so no template can ship it; every project gets it here.
65
+ await mkdir(join(root, 'public'), { recursive: true });
66
+ // npm strips `.gitignore` from published packages, so templates ship it as `_gitignore`.
67
+ const gitignore = join(root, '_gitignore');
68
+ if (existsSync(gitignore))
69
+ await rename(gitignore, join(root, '.gitignore'));
70
+ const pkgPath = join(root, 'package.json');
71
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
72
+ pkg.name = toPackageName(dir);
73
+ // The template's description is for the menu, not for the user's bot.
74
+ delete pkg.description;
75
+ pkg.dependencies = { ...pkg.dependencies, chapterjs: chapterjsVersion };
76
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
77
+ // Only for the package manager that reads it: nothing to explain to the
78
+ // others.
79
+ if (packageManager === 'pnpm') {
80
+ await writeFile(join(root, 'pnpm-workspace.yaml'), PNPM_WORKSPACE);
81
+ }
82
+ }
@@ -0,0 +1,31 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { join } from 'node:path';
5
+ /** `templates/` ships next to `dist/` in the published package. */
6
+ export const templatesDir = fileURLToPath(new URL('../templates/', import.meta.url));
7
+ /**
8
+ * Every folder of `templates/` with a package.json is a template: adding one
9
+ * needs no code change.
10
+ */
11
+ export async function listTemplates(dir = templatesDir) {
12
+ const entries = await readdir(dir, { withFileTypes: true });
13
+ const templates = await Promise.all(entries
14
+ .filter(entry => entry.isDirectory())
15
+ .map(async (entry) => {
16
+ const pkgPath = join(dir, entry.name, 'package.json');
17
+ if (!existsSync(pkgPath))
18
+ return undefined;
19
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
20
+ const description = typeof pkg.description === 'string' ? pkg.description : undefined;
21
+ return { name: entry.name, description };
22
+ }));
23
+ // `default` first, so it is the preselected choice.
24
+ return templates
25
+ .filter(template => template !== undefined)
26
+ .sort((a, b) => a.name === 'default'
27
+ ? -1
28
+ : b.name === 'default'
29
+ ? 1
30
+ : a.name.localeCompare(b.name));
31
+ }
package/package.json CHANGED
@@ -1,24 +1,41 @@
1
1
  {
2
2
  "name": "create-chapter",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/chapterjs/chapterjs.git",
8
+ "directory": "packages/create-chapter"
9
+ },
10
+ "homepage": "https://chapterjs.dev",
11
+ "bugs": "https://github.com/chapterjs/chapterjs/issues",
5
12
  "type": "module",
6
13
  "bin": {
7
14
  "create-chapter": "./dist/index.js"
8
15
  },
9
16
  "files": [
10
17
  "dist",
11
- "template"
18
+ "templates"
12
19
  ],
13
20
  "devDependencies": {
21
+ "@chapterjs/test-utils": "0.0.0",
14
22
  "@types/node": "^24.0.0",
15
- "typescript": "^7.0.2"
23
+ "@vitest/coverage-v8": "^5.0.3",
24
+ "typescript": "^7.0.2",
25
+ "vitest": "^5.0.3"
16
26
  },
17
27
  "dependencies": {
18
28
  "@clack/prompts": "^1.8.1"
19
29
  },
30
+ "engines": {
31
+ "node": ">=24"
32
+ },
20
33
  "scripts": {
21
34
  "build": "tsc",
22
- "dev": "tsc --watch"
35
+ "dev": "tsc --watch",
36
+ "check-types": "tsc --noEmit && tsc -p test",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "coverage": "vitest run --coverage"
23
40
  }
24
41
  }
@@ -0,0 +1,4 @@
1
+ # Your bot token: https://discord.com/developers/applications → your app → Bot → Reset Token
2
+ BOT_TOKEN=
3
+ # The ID of the server used by `chapterjs dev` (right-click the server → Copy Server ID)
4
+ DEV_GUILD_ID=
@@ -1,3 +1,4 @@
1
1
  node_modules
2
+ dist
2
3
  .env
3
- .DS_Store
4
+ .chapterjs
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "chapter-bot",
3
+ "description": "A minimal bot, ready to grow",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "chapterjs dev",
8
+ "start": "chapterjs start",
9
+ "build": "chapterjs build",
10
+ "typecheck": "tsc -b",
11
+ "postinstall": "chapterjs sync"
12
+ },
13
+ "dependencies": {
14
+ "chapterjs": "latest"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^24.0.0",
18
+ "typescript": "^7.0.2"
19
+ },
20
+ "engines": {
21
+ "node": ">=24"
22
+ }
23
+ }
@@ -0,0 +1,22 @@
1
+ import { asset, command } from 'chapterjs';
2
+
3
+ // /hello joins your voice channel and plays public/hello.ogg. An Opus file
4
+ // (.ogg, .webm) plays as it is; an .mp3 or a link needs ffmpeg installed.
5
+ export default command({
6
+ async run({ interaction, member, t }) {
7
+ const channel = member.voiceChannel;
8
+ if (!channel) {
9
+ await interaction.reply({ content: t('joinVoice'), ephemeral: true });
10
+ return;
11
+ }
12
+ // Answer first: a command has 3 seconds, joining takes a moment.
13
+ await interaction.reply(t('hello', { channel: channel.name }));
14
+ const voice = await channel.join();
15
+ // Whatever happens while it plays, the bot leaves after.
16
+ try {
17
+ await voice.play(asset('hello.ogg'));
18
+ } finally {
19
+ await voice.leave();
20
+ }
21
+ },
22
+ });
@@ -0,0 +1,37 @@
1
+ import { command } from 'chapterjs';
2
+ import again from '../components/buttons/again';
3
+
4
+ // The path of the file is the name of the command: this one is /ping.
5
+ // A file in a folder is a subcommand: src/commands/mod/ban.ts is /mod ban.
6
+ export default command({
7
+ // Only the person sees the answer, so `t` speaks their language.
8
+ ephemeral: true,
9
+ async run({ interaction, t }) {
10
+ // A button of src/components/buttons/, with the data it carries, and a
11
+ // text of src/messages/ in the language of who will read the answer.
12
+ await interaction.reply({
13
+ content: t('pong'),
14
+ components: [again({ count: 1 })],
15
+ });
16
+ },
17
+ });
18
+
19
+ // A command can ask for options, typed for you in `run`, suggest values
20
+ // while the person types, and make each person wait between two uses:
21
+ //
22
+ // export default command({
23
+ // description: 'Says hello to someone',
24
+ // cooldown: '10s',
25
+ // options: {
26
+ // who: { type: 'user', description: 'Who to greet', required: true },
27
+ // greeting: { type: 'string', description: 'How to greet' },
28
+ // },
29
+ // autocomplete: {
30
+ // // Called while the person types in "greeting", with what they typed.
31
+ // greeting: ({ value }) =>
32
+ // ['Hello', 'Hi', 'Hey'].filter(word => word.startsWith(value)),
33
+ // },
34
+ // async run({ interaction, options }) {
35
+ // await interaction.reply(`${options.greeting ?? 'Hello'} ${options.who}!`);
36
+ // },
37
+ // });
@@ -0,0 +1,40 @@
1
+ import { command } from 'chapterjs';
2
+
3
+ // /play plays a song in your voice channel: a file you send with the
4
+ // command, or a link to an audio file or a stream. play() takes both.
5
+ export default command({
6
+ options: {
7
+ file: { type: 'attachment' },
8
+ url: { type: 'string' },
9
+ },
10
+ async run({ interaction, member, options, t }) {
11
+ const { file, url } = options;
12
+ const song = file ?? url;
13
+ if (
14
+ !song ||
15
+ (file && !file.contentType?.startsWith('audio/')) ||
16
+ (url && !/^https?:\/\//.test(url))
17
+ ) {
18
+ await interaction.reply({ content: t('nothingToPlay'), ephemeral: true });
19
+ return;
20
+ }
21
+ const channel = member.voiceChannel;
22
+ if (!channel) {
23
+ await interaction.reply({ content: t('joinVoice'), ephemeral: true });
24
+ return;
25
+ }
26
+ const name = file?.filename ?? url ?? '';
27
+ await interaction.reply(
28
+ t('playing', { song: name, channel: channel.name })
29
+ );
30
+ // Joining the channel the bot is already in changes nothing, and a new
31
+ // play() replaces what was playing.
32
+ const voice = await channel.join();
33
+ // Whatever happens while it plays, the bot leaves after.
34
+ try {
35
+ await voice.play(song);
36
+ } finally {
37
+ await voice.leave();
38
+ }
39
+ },
40
+ });
@@ -0,0 +1,19 @@
1
+ import { button } from 'chapterjs';
2
+
3
+ // The folder is the kind of component (buttons, selects, modals, embeds)
4
+ // and the path of the file is what tells which one was used: you never
5
+ // write an id. `data` is what the button carries from one click to the next.
6
+ const again = button({
7
+ label: 'Again',
8
+ data: { count: 'number' },
9
+ async run({ interaction, data, t }) {
10
+ const count = data.count + 1;
11
+ // Changes the message the button is on, instead of sending a new one.
12
+ await interaction.update({
13
+ content: t('again', { count }),
14
+ components: [again({ count })],
15
+ });
16
+ },
17
+ });
18
+
19
+ export default again;
@@ -0,0 +1,11 @@
1
+ import { event } from 'chapterjs';
2
+
3
+ // Runs every time someone adds a reaction to a message of your server.
4
+ // Reactions of bots, yours included, are left out: when yours waves back
5
+ // below, this file does not run again. Other folders work the same way:
6
+ // voiceJoin, pollVoteAdd, banAdd... (see the list of events in the docs).
7
+ export default event(async ({ emoji, channel, messageId }) => {
8
+ if (emoji.name !== '👋') return;
9
+ const message = await channel.fetchMessage(messageId);
10
+ await message.react('👋');
11
+ });
@@ -0,0 +1,30 @@
1
+ import { event } from 'chapterjs';
2
+
3
+ // The folder is the event: every file of src/events/ready/ runs once, when
4
+ // your bot is connected. The name of the file is yours to choose.
5
+ export default event(({ user, guilds }) => {
6
+ console.log(`${user.username} is online in ${guilds.size} server(s)`);
7
+ });
8
+
9
+ // Something to do by itself, every so often or at given times, is a task:
10
+ // a file of src/tasks/, like src/tasks/report.ts:
11
+ //
12
+ // import { task } from 'chapterjs';
13
+ //
14
+ // export default task({
15
+ // cron: '0 9 * * 1', // every Monday at 9:00
16
+ // async run({ guilds }) {
17
+ // console.log(`A new week in ${guilds.size} server(s)`);
18
+ // },
19
+ // });
20
+ //
21
+ // To react to something else, create a folder named after the event. For
22
+ // example src/events/messageCreate/ping.ts, which answers "Pong!" to "!ping"
23
+ // (reading messages needs the Message Content intent: `chapterjs dev` tells
24
+ // you how to enable it):
25
+ //
26
+ // import { event } from 'chapterjs';
27
+ //
28
+ // export default event(async ({ message }) => {
29
+ // if (message.content === '!ping') await message.reply('Pong!');
30
+ // });
@@ -0,0 +1,33 @@
1
+ import { language } from 'chapterjs';
2
+
3
+ // One file per language in src/messages/, named after the language. Each
4
+ // function of your project receives `t`, which picks the language of who
5
+ // will read the message: t('pong'), t('again', { count }).
6
+ export default language({
7
+ // The language used when the one of a person or of a server is not here.
8
+ default: true,
9
+ texts: {
10
+ pong: 'Pong! in en-US',
11
+ again: 'Pong in en-US ! ×{count}',
12
+ joinVoice: 'Join a voice channel first, then ask again.',
13
+ hello: 'Coming to {channel}!',
14
+ nothingToPlay:
15
+ 'Send an audio file (.mp3, .ogg...) or a link starting with https://.',
16
+ playing: 'Playing {song} in {channel}.',
17
+ },
18
+ commands: {
19
+ ping: {
20
+ description: 'Replies with Pong!',
21
+ },
22
+ hello: {
23
+ description: 'Says hello in your voice channel',
24
+ },
25
+ play: {
26
+ description: 'Plays a song in your voice channel',
27
+ options: {
28
+ file: { description: 'A song to play' },
29
+ url: { description: 'Or the link of a song or a stream' },
30
+ },
31
+ },
32
+ },
33
+ });
@@ -0,0 +1,36 @@
1
+ import { language } from 'chapterjs';
2
+
3
+ // Every language has the texts of the default one, with the same
4
+ // {placeholders}: a missing one is reported when the file loads.
5
+ export default language({
6
+ texts: {
7
+ pong: 'Pong! en fr',
8
+ again: 'Pong en fr ! ×{count}',
9
+ joinVoice: 'Rejoignez d’abord un salon vocal, puis redemandez.',
10
+ hello: 'J’arrive dans {channel} !',
11
+ nothingToPlay:
12
+ 'Envoyez un fichier audio (.mp3, .ogg...) ou un lien qui commence par https://.',
13
+ playing: 'Lecture de {song} dans {channel}.',
14
+ },
15
+ // What the bot answers by itself can be translated too, phrase by phrase:
16
+ //
17
+ // framework: {
18
+ // command: 'commande',
19
+ // failed: "Quelque chose s'est mal passé avec cette {what}.",
20
+ // },
21
+ commands: {
22
+ ping: {
23
+ description: 'Répond avec Pong !',
24
+ },
25
+ hello: {
26
+ description: 'Dit bonjour dans votre salon vocal',
27
+ },
28
+ play: {
29
+ description: 'Joue une musique dans votre salon vocal',
30
+ options: {
31
+ file: { description: 'Une musique à jouer' },
32
+ url: { description: 'Ou le lien d’une musique ou d’un flux' },
33
+ },
34
+ },
35
+ },
36
+ });
@@ -0,0 +1,8 @@
1
+ import { presence } from 'chapterjs';
2
+
3
+ // What your bot shows under its name: its status, and what it is doing.
4
+ // Save this file and chapterjs dev changes it at once.
5
+ export default presence({
6
+ status: 'online',
7
+ activity: { type: 'playing', name: 'with ChapterJS' },
8
+ });
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "Preserve",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "erasableSyntaxOnly": true,
8
+ "verbatimModuleSyntax": true,
9
+ "noEmit": true,
10
+ "strict": true,
11
+ "skipLibCheck": true,
12
+ "types": ["node"]
13
+ },
14
+ "files": [],
15
+ "references": [
16
+ {
17
+ "path": "./.chapterjs"
18
+ }
19
+ ]
20
+ }
@@ -1,5 +0,0 @@
1
- # Required — Discord Developer Portal > your application > Bot > Token
2
- BOT_TOKEN=<YOUR_BOT_TOKEN>
3
-
4
- # Required — Discord > right-click your dev server > Copy Server ID
5
- DEV_GUILD_ID=<YOUR_DEV_GUILD_ID>
@@ -1,13 +0,0 @@
1
- {
2
- "name": "my-chapter-app",
3
- "version": "0.1.0",
4
- "private": true,
5
- "type": "module",
6
- "scripts": {
7
- "dev": "chapterjs dev",
8
- "build": "chapterjs build"
9
- },
10
- "dependencies": {
11
- "chapterjs": "workspace:*"
12
- }
13
- }
@@ -1,28 +0,0 @@
1
- import { defineButton } from 'chapterjs';
2
-
3
- // A button that handles its own clicks: they don't reach interactionCreate.
4
- // Its custom_id is generated from this file's path. Send it with
5
- // `components: [SayHi]`, or change it where it's used:
6
- // `new SayHi({ label: 'Hello', style: 'success' })`.
7
- export default defineButton({
8
- label: 'Say hi',
9
- emoji: '👋',
10
- async handler({ interaction, user }) {
11
- await interaction.reply({
12
- content: `Hi ${user}! Only you can see this message.`,
13
- ephemeral: true,
14
- });
15
- },
16
- });
17
-
18
- // Buttons can carry data, given back to the handler:
19
- //
20
- // export default defineButton<{ roleId: string }>({
21
- // label: 'Get the role',
22
- // async handler({ interaction, member, data }) {
23
- // await member?.addRole(data.roleId);
24
- // await interaction.reply({ content: 'Done!', ephemeral: true });
25
- // },
26
- // });
27
- //
28
- // message.reply({ components: [new GetRole({ data: { roleId: '…' } })] });
@@ -1,11 +0,0 @@
1
- import { defineEvent } from 'chapterjs';
2
-
3
- // Slash commands, and components whose custom_id you wrote yourself. Clicks
4
- // on components of src/components/ go to their own handler instead.
5
- // Answer within 3 seconds, or call `interaction.defer()` first and
6
- // `interaction.editReply()` later.
7
- export default defineEvent(async ({ interaction }) => {
8
- if (interaction.commandName === 'ping') {
9
- await interaction.reply({ content: 'Pong!', ephemeral: true });
10
- }
11
- });
@@ -1,25 +0,0 @@
1
- import { defineEvent } from 'chapterjs';
2
- import SayHi from '../components/buttons/sayHi.ts';
3
-
4
- // Mention the bot: it reacts and replies with a button (src/components/buttons/sayHi.ts).
5
- // Messages from bots are ignored, unless you add allowBots: true.
6
- export default defineEvent(async ({ message, client }) => {
7
- if (!message.mentions.some(user => user.id === client.user?.id)) return;
8
-
9
- await message.react('👋');
10
- await message.reply({
11
- content: `Hi ${message.author.displayName}!`,
12
- mention: false,
13
- components: [SayHi],
14
- });
15
- });
16
-
17
- // Without the privileged MessageContent intent, `message.content` is empty
18
- // except in DMs and messages mentioning the bot. To read every message:
19
- //
20
- // export default defineEvent({
21
- // intents: ['MessageContent'],
22
- // async handler({ message }) {
23
- // if (message.content === '!ping') await message.reply('Pong!');
24
- // },
25
- // });
@@ -1,6 +0,0 @@
1
- import { defineEvent } from 'chapterjs';
2
-
3
- // Discord only sends ids: the deleted message's content is gone.
4
- export default defineEvent(({ messageId, channel }) => {
5
- console.log(`Message ${messageId} deleted in ${channel}`);
6
- });
@@ -1,12 +0,0 @@
1
- import { defineEvent } from 'chapterjs';
2
-
3
- export default defineEvent(async ({ reaction, client }) => {
4
- if (reaction.userId === client.user?.id) return;
5
- const name = reaction.member?.displayName ?? reaction.userId;
6
- console.log(`${name} reacted ${reaction} in ${reaction.channel}`);
7
-
8
- // Reaction roles: give a role when reacting on a given message
9
- // if (reaction.messageId === ROLES_MESSAGE_ID && reaction.emoji.name === '🎮') {
10
- // await reaction.member?.addRole(GAMER_ROLE_ID);
11
- // }
12
- });
@@ -1,24 +0,0 @@
1
- import { defineEvent } from 'chapterjs';
2
-
3
- // Each file in src/events/ handles the Discord event it is named after
4
- // (messageCreate.ts, guildMemberAdd.ts…), reloaded on save. Several handlers
5
- // for one event go in a folder: src/events/messageCreate/*.ts. Files and
6
- // folders starting with _ are ignored, for shared code.
7
- // The gateway intents each event needs are requested automatically.
8
- //
9
- // Handlers receive what the event is about: { user, guilds } here,
10
- // { message, channel } in messageCreate, { interaction } in
11
- // interactionCreate… plus `client` and the raw `data` sent by Discord.
12
- export default defineEvent(({ user, guilds }) => {
13
- console.log(`${user.displayName} is ready in ${guilds.length} server(s)`);
14
- });
15
-
16
- // Some events need a privileged intent, enabled in the Developer Portal
17
- // (Bot > Privileged Gateway Intents), otherwise the bot can't connect:
18
- //
19
- // src/events/guildMemberAdd.ts — GuildMembers intent:
20
- //
21
- // export default defineEvent(async ({ member, client }) => {
22
- // await member.addRole(MEMBER_ROLE_ID, 'New member');
23
- // await client.channel(WELCOME_CHANNEL_ID).send(`Welcome ${member}! 👋`);
24
- // });
@@ -1,35 +0,0 @@
1
- import { definePresence } from 'chapterjs';
2
-
3
- // Status and activity shown on the bot's profile, reloaded on save.
4
- export default definePresence({
5
- status: 'online', // 'online' | 'idle' | 'dnd' | 'invisible'
6
- // 'playing' | 'listening' | 'watching' | 'competing' | 'streaming' (+ url),
7
- // or a plain string for a custom status: activity: 'Coding 🚀'
8
- activity: { type: 'playing', name: 'with ChapterJS' },
9
- });
10
-
11
- // Rotation, each step shown for its own duration (minimum 5s):
12
- //
13
- // export default definePresence([
14
- // { duration: 5_000, status: 'online', activity: 'Hello everyone 👋' },
15
- // { duration: 10_000, status: 'dnd', activity: { type: 'watching', name: 'the server' } },
16
- // ]);
17
-
18
- // Dynamic presence, e.g. a rotation stored in a database. It is reloaded
19
- // after each full rotation, or right away when `watch` calls `refresh`
20
- // (you can also call `refreshPresence()` from anywhere in your bot):
21
- //
22
- // export default definePresence({
23
- // async presence({ client }) {
24
- // const rows = await db.presence.findMany();
25
- // return rows.map((row) => ({
26
- // duration: row.duration,
27
- // status: row.status, // 'online' | 'idle' | 'dnd' | 'invisible'
28
- // activity: row.message,
29
- // }));
30
- // },
31
- // watch(refresh) {
32
- // const unsubscribe = db.onPresenceChange(refresh);
33
- // return unsubscribe;
34
- // },
35
- // });
@@ -1,15 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "strict": true,
7
- "outDir": "dist",
8
- "rootDir": "src",
9
- "skipLibCheck": true,
10
- "rewriteRelativeImportExtensions": true,
11
- "erasableSyntaxOnly": true,
12
- "types": ["node"]
13
- },
14
- "include": ["src"]
15
- }