@newt-dev/compiler 0.1.0

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/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@newt-dev/compiler",
3
+ "version": "0.1.0",
4
+ "description": "Compiler for the Newt Discord bot DSL.",
5
+ "type": "module",
6
+ "main": "dist/src/index.js",
7
+ "types": "dist/src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/src/index.d.ts",
11
+ "import": "./dist/src/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "check": "tsc -p tsconfig.json --noEmit",
17
+ "test": "node --test dist/test/*.test.js"
18
+ },
19
+ "keywords": [
20
+ "discord",
21
+ "bot",
22
+ "dsl",
23
+ "compiler",
24
+ "newt"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/newt-dev-sudo/newt.git",
29
+ "directory": "packages/compiler"
30
+ },
31
+ "homepage": "https://github.com/newt-dev-sudo/newt#readme",
32
+ "devDependencies": {
33
+ "@types/node": "^20.12.0",
34
+ "typescript": "^5.5.0"
35
+ },
36
+ "license": "MIT"
37
+ }
package/src/ast.ts ADDED
@@ -0,0 +1,307 @@
1
+ export interface SourceLocation {
2
+ line: number;
3
+ column: number;
4
+ }
5
+
6
+ export interface BaseNode {
7
+ type: string;
8
+ loc: SourceLocation;
9
+ }
10
+
11
+ export interface Program extends BaseNode {
12
+ type: "Program";
13
+ body: TopLevelNode[];
14
+ }
15
+
16
+ export type TopLevelNode = BotDecl | Handler | TimerDecl;
17
+
18
+ export type BotDeclKind = "name" | "prefix" | "token";
19
+
20
+ export interface BotDecl extends BaseNode {
21
+ type: "BotDecl";
22
+ kind: BotDeclKind;
23
+ value: StringLiteral;
24
+ fromEnv: boolean;
25
+ }
26
+
27
+ export type Handler =
28
+ | ReadyHandler
29
+ | CommandHandler
30
+ | MessageContainsHandler
31
+ | JoinHandler
32
+ | LeaveHandler
33
+ | ReactionAddHandler;
34
+
35
+ export interface ReadyHandler extends BaseNode {
36
+ type: "ReadyHandler";
37
+ body: Statement[];
38
+ }
39
+
40
+ export interface CommandHandler extends BaseNode {
41
+ type: "CommandHandler";
42
+ command: string;
43
+ body: Statement[];
44
+ }
45
+
46
+ export interface MessageContainsHandler extends BaseNode {
47
+ type: "MessageContainsHandler";
48
+ needle: StringLiteral;
49
+ body: Statement[];
50
+ }
51
+
52
+ export interface JoinHandler extends BaseNode {
53
+ type: "JoinHandler";
54
+ body: Statement[];
55
+ }
56
+
57
+ export interface LeaveHandler extends BaseNode {
58
+ type: "LeaveHandler";
59
+ body: Statement[];
60
+ }
61
+
62
+ export interface ReactionAddHandler extends BaseNode {
63
+ type: "ReactionAddHandler";
64
+ emoji: StringLiteral;
65
+ body: Statement[];
66
+ }
67
+
68
+ export type TimerDecl = EveryTimerDecl | DailyTimerDecl;
69
+
70
+ export interface EveryTimerDecl extends BaseNode {
71
+ type: "EveryTimerDecl";
72
+ amount: NumberLiteral;
73
+ unit: TimeUnit;
74
+ body: Statement[];
75
+ }
76
+
77
+ export interface DailyTimerDecl extends BaseNode {
78
+ type: "DailyTimerDecl";
79
+ time: StringLiteral;
80
+ body: Statement[];
81
+ }
82
+
83
+ export type TimeUnit = "second" | "seconds" | "minute" | "minutes" | "hour" | "hours" | "day" | "days";
84
+
85
+ export type Statement =
86
+ | ReplyStatement
87
+ | SayStatement
88
+ | SayEmbedStatement
89
+ | LetDecl
90
+ | StoreStatement
91
+ | IfStatement
92
+ | ForEachStatement
93
+ | RequireRoleStatement
94
+ | GiveRoleStatement
95
+ | RemoveRoleStatement
96
+ | MuteStatement
97
+ | KickStatement
98
+ | BanStatement
99
+ | PinStatement
100
+ | DeleteKeyStatement
101
+ | WaitStatement
102
+ | TryCatchStatement
103
+ | ExpressionStatement;
104
+
105
+ export interface ReplyStatement extends BaseNode {
106
+ type: "ReplyStatement";
107
+ message: Expression;
108
+ }
109
+
110
+ export interface SayStatement extends BaseNode {
111
+ type: "SayStatement";
112
+ message: Expression;
113
+ channel?: StringLiteral;
114
+ }
115
+
116
+ export interface SayEmbedStatement extends BaseNode {
117
+ type: "SayEmbedStatement";
118
+ embed: EmbedBlock;
119
+ }
120
+
121
+ export interface EmbedBlock extends BaseNode {
122
+ type: "EmbedBlock";
123
+ title?: StringLiteral;
124
+ description?: StringLiteral;
125
+ color?: ColorLiteral;
126
+ fields: EmbedField[];
127
+ }
128
+
129
+ export interface EmbedField extends BaseNode {
130
+ type: "EmbedField";
131
+ name: StringLiteral;
132
+ value: StringLiteral;
133
+ }
134
+
135
+ export interface LetDecl extends BaseNode {
136
+ type: "LetDecl";
137
+ name: string;
138
+ value: Expression;
139
+ }
140
+
141
+ export interface StoreStatement extends BaseNode {
142
+ type: "StoreStatement";
143
+ namespace: Expression;
144
+ key: string;
145
+ value: Expression;
146
+ }
147
+
148
+ export interface IfStatement extends BaseNode {
149
+ type: "IfStatement";
150
+ condition: Expression;
151
+ consequent: Statement[];
152
+ alternate: Statement[];
153
+ }
154
+
155
+ export interface ForEachStatement extends BaseNode {
156
+ type: "ForEachStatement";
157
+ itemName: string;
158
+ iterable: Expression;
159
+ body: Statement[];
160
+ }
161
+
162
+ export interface RequireRoleStatement extends BaseNode {
163
+ type: "RequireRoleStatement";
164
+ role: StringLiteral;
165
+ }
166
+
167
+ export interface GiveRoleStatement extends BaseNode {
168
+ type: "GiveRoleStatement";
169
+ subject: Expression;
170
+ role: StringLiteral;
171
+ }
172
+
173
+ export interface RemoveRoleStatement extends BaseNode {
174
+ type: "RemoveRoleStatement";
175
+ subject: Expression;
176
+ role: StringLiteral;
177
+ }
178
+
179
+ export interface MuteStatement extends BaseNode {
180
+ type: "MuteStatement";
181
+ subject: Expression;
182
+ duration?: DurationLiteral;
183
+ }
184
+
185
+ export interface KickStatement extends BaseNode {
186
+ type: "KickStatement";
187
+ subject: Expression;
188
+ }
189
+
190
+ export interface BanStatement extends BaseNode {
191
+ type: "BanStatement";
192
+ subject: Expression;
193
+ }
194
+
195
+ export interface PinStatement extends BaseNode {
196
+ type: "PinStatement";
197
+ target: Expression;
198
+ }
199
+
200
+ export interface DeleteKeyStatement extends BaseNode {
201
+ type: "DeleteKeyStatement";
202
+ namespace: Expression;
203
+ key: string;
204
+ }
205
+
206
+ export interface WaitStatement extends BaseNode {
207
+ type: "WaitStatement";
208
+ duration: DurationLiteral;
209
+ }
210
+
211
+ export interface TryCatchStatement extends BaseNode {
212
+ type: "TryCatchStatement";
213
+ body: Statement[];
214
+ errorHandler: Statement[];
215
+ }
216
+
217
+ export interface ExpressionStatement extends BaseNode {
218
+ type: "ExpressionStatement";
219
+ expression: Expression;
220
+ }
221
+
222
+ export type Expression =
223
+ | StringLiteral
224
+ | NumberLiteral
225
+ | BooleanLiteral
226
+ | ColorLiteral
227
+ | IdentifierExpr
228
+ | MemberExpr
229
+ | ArgsIndexExpr
230
+ | LoadExpr
231
+ | FetchExpr
232
+ | BinaryExpr
233
+ | UnaryExpr
234
+ | CallExpr;
235
+
236
+ export interface StringLiteral extends BaseNode {
237
+ type: "StringLiteral";
238
+ value: string;
239
+ interpolated: boolean;
240
+ }
241
+
242
+ export interface NumberLiteral extends BaseNode {
243
+ type: "NumberLiteral";
244
+ value: number;
245
+ }
246
+
247
+ export interface BooleanLiteral extends BaseNode {
248
+ type: "BooleanLiteral";
249
+ value: boolean;
250
+ }
251
+
252
+ export interface ColorLiteral extends BaseNode {
253
+ type: "ColorLiteral";
254
+ value: string;
255
+ }
256
+
257
+ export interface IdentifierExpr extends BaseNode {
258
+ type: "IdentifierExpr";
259
+ name: string;
260
+ }
261
+
262
+ export interface MemberExpr extends BaseNode {
263
+ type: "MemberExpr";
264
+ path: string[];
265
+ }
266
+
267
+ export interface ArgsIndexExpr extends BaseNode {
268
+ type: "ArgsIndexExpr";
269
+ index: number;
270
+ }
271
+
272
+ export interface LoadExpr extends BaseNode {
273
+ type: "LoadExpr";
274
+ namespace: Expression;
275
+ key: string;
276
+ fallback?: Expression;
277
+ }
278
+
279
+ export interface FetchExpr extends BaseNode {
280
+ type: "FetchExpr";
281
+ url: Expression;
282
+ }
283
+
284
+ export interface BinaryExpr extends BaseNode {
285
+ type: "BinaryExpr";
286
+ operator: string;
287
+ left: Expression;
288
+ right: Expression;
289
+ }
290
+
291
+ export interface UnaryExpr extends BaseNode {
292
+ type: "UnaryExpr";
293
+ operator: string;
294
+ argument: Expression;
295
+ }
296
+
297
+ export interface CallExpr extends BaseNode {
298
+ type: "CallExpr";
299
+ callee: string;
300
+ args: Expression[];
301
+ }
302
+
303
+ export interface DurationLiteral extends BaseNode {
304
+ type: "DurationLiteral";
305
+ amount: NumberLiteral;
306
+ unit: TimeUnit;
307
+ }
package/src/codegen.ts ADDED
@@ -0,0 +1,286 @@
1
+ import type {
2
+ BotDecl,
3
+ Expression,
4
+ Handler,
5
+ Program,
6
+ Statement,
7
+ TimerDecl
8
+ } from "./ast.js";
9
+
10
+ export interface GeneratedProject {
11
+ botJs: string;
12
+ packageJson: string;
13
+ }
14
+
15
+ export function generate(program: Program): GeneratedProject {
16
+ const botName = getBotValue(program, "name") ?? "NewtBot";
17
+ const prefix = getBotValue(program, "prefix") ?? "!";
18
+ const tokenDecl = program.body.find((node): node is BotDecl => node.type === "BotDecl" && node.kind === "token");
19
+ const tokenExpr = tokenDecl?.fromEnv ? `process.env.${tokenDecl.value.value}` : JSON.stringify(tokenDecl?.value.value ?? "");
20
+ const handlers = program.body
21
+ .filter((node): node is Handler | TimerDecl => node.type.endsWith("Handler") || node.type.endsWith("TimerDecl"))
22
+ .map((node) => emitTopLevel(node, prefix))
23
+ .join("\n\n");
24
+
25
+ const botJs = `import { Client, EmbedBuilder, GatewayIntentBits, Partials } from "discord.js";
26
+ import Database from "better-sqlite3";
27
+ import axios from "axios";
28
+
29
+ const client = new Client({
30
+ intents: [
31
+ GatewayIntentBits.Guilds,
32
+ GatewayIntentBits.GuildMessages,
33
+ GatewayIntentBits.GuildMembers,
34
+ GatewayIntentBits.MessageContent,
35
+ GatewayIntentBits.GuildMessageReactions
36
+ ],
37
+ partials: [Partials.Message, Partials.Channel, Partials.Reaction]
38
+ });
39
+
40
+ const db = new Database("newt-store.sqlite");
41
+ db.exec("CREATE TABLE IF NOT EXISTS store (namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT, PRIMARY KEY(namespace, key))");
42
+ const botName = ${JSON.stringify(botName)};
43
+ const prefix = ${JSON.stringify(prefix)};
44
+
45
+ function saveValue(namespace, key, value) {
46
+ db.prepare("INSERT OR REPLACE INTO store(namespace, key, value) VALUES (?, ?, ?)").run(String(namespace), String(key), JSON.stringify(value));
47
+ }
48
+
49
+ function loadValue(namespace, key, fallback = undefined) {
50
+ const row = db.prepare("SELECT value FROM store WHERE namespace = ? AND key = ?").get(String(namespace), String(key));
51
+ return row ? JSON.parse(row.value) : fallback;
52
+ }
53
+
54
+ function findChannel(guild, name) {
55
+ return guild?.channels?.cache?.find((channel) => channel.name === name);
56
+ }
57
+
58
+ function findRole(guild, name) {
59
+ return guild?.roles?.cache?.find((role) => role.name === name);
60
+ }
61
+
62
+ ${handlers}
63
+
64
+ client.login(${tokenExpr});
65
+ `;
66
+
67
+ return {
68
+ botJs,
69
+ packageJson: JSON.stringify({
70
+ type: "module",
71
+ scripts: { start: "node bot.js" },
72
+ dependencies: {
73
+ "axios": "^1.7.0",
74
+ "better-sqlite3": "^11.0.0",
75
+ "discord.js": "^14.15.0"
76
+ }
77
+ }, null, 2)
78
+ };
79
+ }
80
+
81
+ function getBotValue(program: Program, kind: BotDecl["kind"]): string | undefined {
82
+ return program.body.find((node): node is BotDecl => node.type === "BotDecl" && node.kind === kind)?.value.value;
83
+ }
84
+
85
+ function emitTopLevel(node: Handler | TimerDecl, prefix: string): string {
86
+ switch (node.type) {
87
+ case "ReadyHandler":
88
+ return `client.once("ready", async () => {
89
+ for (const guild of client.guilds.cache.values()) {
90
+ const server = guild;
91
+ ${emitStatements(node.body, " ", "guild")}
92
+ }
93
+ });`;
94
+ case "CommandHandler":
95
+ return `client.on("messageCreate", async (message) => {
96
+ if (message.author.bot) return;
97
+ if (!message.content.startsWith(prefix + ${JSON.stringify(node.command)})) return;
98
+ const args = message.content.slice((prefix + ${JSON.stringify(node.command)}).length).trim().split(/\\s+/).filter(Boolean);
99
+ const user = message.author;
100
+ const channel = message.channel;
101
+ const server = message.guild;
102
+ const target = message.mentions.members.first();
103
+ ${emitStatements(node.body, " ", "message")}
104
+ });`;
105
+ case "MessageContainsHandler":
106
+ return `client.on("messageCreate", async (message) => {
107
+ if (message.author.bot) return;
108
+ if (!message.content.includes(${emitExpression(node.needle)})) return;
109
+ const user = message.author;
110
+ const channel = message.channel;
111
+ const server = message.guild;
112
+ ${emitStatements(node.body, " ", "message")}
113
+ });`;
114
+ case "JoinHandler":
115
+ return `client.on("guildMemberAdd", async (member) => {
116
+ const user = member.user;
117
+ const server = member.guild;
118
+ const channel = findChannel(server, "general");
119
+ ${emitStatements(node.body, " ", "member")}
120
+ });`;
121
+ case "LeaveHandler":
122
+ return `client.on("guildMemberRemove", async (member) => {
123
+ const user = member.user;
124
+ const server = member.guild;
125
+ ${emitStatements(node.body, " ", "member")}
126
+ });`;
127
+ case "ReactionAddHandler":
128
+ return `client.on("messageReactionAdd", async (reaction, user) => {
129
+ if (reaction.emoji.name !== ${emitExpression(node.emoji)}) return;
130
+ const message = reaction.message;
131
+ const channel = message.channel;
132
+ const server = message.guild;
133
+ ${emitStatements(node.body, " ", "message")}
134
+ });`;
135
+ case "EveryTimerDecl":
136
+ return `setInterval(async () => {\n${emitStatements(node.body, " ", "message")}\n}, ${durationMs(node.amount.value, node.unit)});`;
137
+ case "DailyTimerDecl":
138
+ return `setInterval(async () => {
139
+ const now = new Date();
140
+ const hhmm = now.toTimeString().slice(0, 5);
141
+ if (hhmm !== ${emitExpression(node.time)}) return;
142
+ ${emitStatements(node.body, " ", "message")}
143
+ }, 60000);`;
144
+ default:
145
+ return "";
146
+ }
147
+ }
148
+
149
+ function emitStatements(statements: Statement[], indent: string, triggerName: string): string {
150
+ return statements.map((statement) => emitStatement(statement, indent, triggerName)).join("\n");
151
+ }
152
+
153
+ function emitStatement(statement: Statement, indent: string, triggerName: string): string {
154
+ switch (statement.type) {
155
+ case "ReplyStatement":
156
+ return `${indent}await ${triggerName}.reply(${emitExpression(statement.message)});`;
157
+ case "SayStatement": {
158
+ if (statement.channel) {
159
+ return `${indent}await findChannel(${triggerName === "guild" ? "server" : `server ?? ${triggerName}.guild`}, ${emitExpression(statement.channel)})?.send(${emitExpression(statement.message)});`;
160
+ }
161
+ return `${indent}await (${triggerName}.channel ?? channel)?.send(${emitExpression(statement.message)});`;
162
+ }
163
+ case "SayEmbedStatement":
164
+ if (triggerName === "member") {
165
+ return `${indent}await findChannel(server, "general")?.send({ embeds: [${emitEmbed(statement.embed)}] });`;
166
+ }
167
+ return `${indent}await (${triggerName}.channel ?? channel)?.send({ embeds: [${emitEmbed(statement.embed)}] });`;
168
+ case "LetDecl":
169
+ return `${indent}const ${statement.name} = ${emitExpression(statement.value)};`;
170
+ case "StoreStatement":
171
+ return `${indent}saveValue(${emitExpression(statement.namespace)}, ${JSON.stringify(statement.key)}, ${emitExpression(statement.value)});`;
172
+ case "IfStatement":
173
+ return `${indent}if (${emitExpression(statement.condition)}) {\n${emitStatements(statement.consequent, `${indent} `, triggerName)}\n${indent}}${statement.alternate.length ? ` else {\n${emitStatements(statement.alternate, `${indent} `, triggerName)}\n${indent}}` : ""}`;
174
+ case "ForEachStatement":
175
+ return `${indent}for (const ${statement.itemName} of (server?.members?.cache?.values?.() ?? [])) {\n${emitStatements(statement.body, `${indent} `, triggerName)}\n${indent}}`;
176
+ case "RequireRoleStatement":
177
+ return `${indent}if (!${triggerName}.member?.roles?.cache?.some((role) => role.name === ${emitExpression(statement.role)})) return;`;
178
+ case "GiveRoleStatement":
179
+ if (triggerName === "member") {
180
+ return `${indent}await ${triggerName}.roles?.add(findRole(server, ${emitExpression(statement.role)}));`;
181
+ }
182
+ return `${indent}await (${emitExpression(statement.subject)}?.roles ?? ${triggerName}.member?.roles)?.add(findRole(server ?? ${triggerName}.guild, ${emitExpression(statement.role)}));`;
183
+ case "RemoveRoleStatement":
184
+ return `${indent}await (${emitExpression(statement.subject)}?.roles ?? ${triggerName}.member?.roles)?.remove(findRole(server ?? ${triggerName}.guild, ${emitExpression(statement.role)}));`;
185
+ case "MuteStatement":
186
+ return `${indent}await ${emitExpression(statement.subject)}?.timeout?.(${statement.duration ? durationMs(statement.duration.amount.value, statement.duration.unit) : 600000});`;
187
+ case "KickStatement":
188
+ return `${indent}await ${emitExpression(statement.subject)}?.kick?.();`;
189
+ case "BanStatement":
190
+ return `${indent}await ${emitExpression(statement.subject)}?.ban?.();`;
191
+ case "TryCatchStatement":
192
+ return `${indent}try {\n${emitStatements(statement.body, `${indent} `, triggerName)}\n${indent}} catch (error) {\n${emitStatements(statement.errorHandler, `${indent} `, triggerName)}\n${indent}}`;
193
+ case "WaitStatement":
194
+ return `${indent}await new Promise((resolve) => setTimeout(resolve, ${durationMs(statement.duration.amount.value, statement.duration.unit)}));`;
195
+ case "ExpressionStatement":
196
+ return `${indent}${emitExpression(statement.expression)};`;
197
+ default:
198
+ return `${indent}// TODO: ${statement.type}`;
199
+ }
200
+ }
201
+
202
+ function emitEmbed(embed: { title?: Expression; description?: Expression; color?: { value: string }; fields: { name: Expression; value: Expression }[] }): string {
203
+ const lines = ["new EmbedBuilder()"];
204
+ if (embed.title) lines.push(`.setTitle(${emitExpression(embed.title)})`);
205
+ if (embed.description) lines.push(`.setDescription(${emitExpression(embed.description)})`);
206
+ if (embed.color) lines.push(`.setColor(${JSON.stringify(embed.color.value)})`);
207
+ for (const field of embed.fields) {
208
+ lines.push(`.addFields({ name: ${emitExpression(field.name)}, value: ${emitExpression(field.value)} })`);
209
+ }
210
+ return lines.join("");
211
+ }
212
+
213
+ function emitExpression(expression: Expression): string {
214
+ switch (expression.type) {
215
+ case "StringLiteral":
216
+ if (expression.interpolated) {
217
+ // Replace {expr} placeholders with properly emitted expressions
218
+ let result = expression.value.replace(/`/g, "\\`");
219
+ // Find all {expr} patterns and replace them
220
+ result = result.replace(/\{([^}]+)\}/g, (match, exprContent) => {
221
+ // Map user.name to user.username for Discord.js v14 compatibility
222
+ if (exprContent === "user.name") {
223
+ return "${user.username}";
224
+ }
225
+ if (exprContent === "member.name") {
226
+ return "${member.user.username}";
227
+ }
228
+ return "${" + exprContent + "}";
229
+ });
230
+ return "`" + result + "`";
231
+ }
232
+ return JSON.stringify(expression.value);
233
+ case "NumberLiteral":
234
+ return String(expression.value);
235
+ case "BooleanLiteral":
236
+ return String(expression.value);
237
+ case "ColorLiteral":
238
+ return JSON.stringify(expression.value);
239
+ case "IdentifierExpr":
240
+ return expression.name;
241
+ case "MemberExpr":
242
+ // Map user.name to user.username for Discord.js v14 compatibility
243
+ if (expression.path.join(".") === "user.name") {
244
+ return "user.username";
245
+ }
246
+ // Map member.name to member.user.username for Discord.js v14 compatibility
247
+ if (expression.path.join(".") === "member.name") {
248
+ return "member.user.username";
249
+ }
250
+ return expression.path.join(".");
251
+ case "ArgsIndexExpr":
252
+ return `args[${expression.index}]`;
253
+ case "LoadExpr":
254
+ return `loadValue(${emitExpression(expression.namespace)}, ${JSON.stringify(expression.key)}${expression.fallback ? `, ${emitExpression(expression.fallback)}` : ""})`;
255
+ case "FetchExpr":
256
+ return `(await axios.get(${emitExpression(expression.url)})).data`;
257
+ case "BinaryExpr":
258
+ if (expression.operator === "or") {
259
+ return `(${emitExpression(expression.left)} ?? ${emitExpression(expression.right)})`;
260
+ }
261
+ if (expression.operator === "has") {
262
+ return `(message.member?.roles?.cache?.some((role) => role.name === ${emitExpression(expression.right)}) ?? false)`;
263
+ }
264
+ return `(${emitExpression(expression.left)} ${expression.operator} ${emitExpression(expression.right)})`;
265
+ case "UnaryExpr":
266
+ return `(${expression.operator}${emitExpression(expression.argument)})`;
267
+ case "CallExpr":
268
+ return `${expression.callee}(${expression.args.map(emitExpression).join(", ")})`;
269
+ default:
270
+ return "undefined";
271
+ }
272
+ }
273
+
274
+ function durationMs(amount: number, unit: string): number {
275
+ const multipliers: Record<string, number> = {
276
+ second: 1000,
277
+ seconds: 1000,
278
+ minute: 60000,
279
+ minutes: 60000,
280
+ hour: 3600000,
281
+ hours: 3600000,
282
+ day: 86400000,
283
+ days: 86400000
284
+ };
285
+ return amount * (multipliers[unit] ?? 1000);
286
+ }