@lunibee/builders 0.3.1 → 0.3.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunibee/builders",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "module": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -8,6 +8,6 @@
8
8
  ".": "./src/index.ts"
9
9
  },
10
10
  "dependencies": {
11
- "@lunibee/types": "^0.3.1"
11
+ "@lunibee/types": "^0.3.2"
12
12
  }
13
13
  }
package/src/attachment.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile, realpath } from "node:fs/promises";
2
2
  import { basename, isAbsolute, relative, resolve } from "node:path";
3
3
 
4
+ /** Options for `CreateAttachment`: file name, alt-text description, the contents or a file path, and an optional root folder the path must stay inside. */
4
5
  export interface AttachmentData {
5
6
  name: string;
6
7
  description?: string;
@@ -84,12 +85,20 @@ export class CreateAttachment {
84
85
 
85
86
  /** Resolves `file` against `root`, refusing anything outside it (after following symlinks). */
86
87
  async function insideRoot(root: string, file: string): Promise<string> {
87
- const base = await realpath(root);
88
+ const base = await realpath(root).catch(() => {
89
+ throw new Error("Attachment root folder was not found.");
90
+ });
88
91
  const target = await realpath(resolve(base, file)).catch(() => {
89
92
  throw new Error("Attachment file was not found inside its root.");
90
93
  });
91
94
  const rel = relative(base, target);
92
- if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
95
+ if (
96
+ rel === "" ||
97
+ rel === ".." ||
98
+ rel.startsWith("../") ||
99
+ rel.startsWith("..\\") ||
100
+ isAbsolute(rel)
101
+ )
93
102
  throw new Error("Attachment file is outside its root folder.");
94
103
  return target;
95
104
  }
package/src/clone.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A deep copy of builder data, for `toJSON()`.
3
+ *
4
+ * Builder payloads are plain JSON: objects, arrays and primitives. Copying
5
+ * those directly is several times faster than `structuredClone` on the small
6
+ * payloads builders hold. Anything else (a Date, a Map, a class instance) and
7
+ * anything nested deeper than 64 levels (a cycle) goes to `structuredClone`,
8
+ * so the result is what `structuredClone` would give.
9
+ */
10
+ export function cloneData<T>(value: T, depth = 0): T {
11
+ if (value === null || typeof value !== "object") return value;
12
+ if (depth > 64) return structuredClone(value);
13
+ if (Array.isArray(value)) {
14
+ const out = new Array(value.length);
15
+ for (let i = 0; i < value.length; i++)
16
+ out[i] = cloneData(value[i], depth + 1);
17
+ return out as T;
18
+ }
19
+ const proto = Object.getPrototypeOf(value);
20
+ if (proto !== Object.prototype && proto !== null)
21
+ return structuredClone(value);
22
+ const from = value as Record<string, unknown>;
23
+ const out: Record<string, unknown> = {};
24
+ for (const key of Object.keys(from))
25
+ out[key] = cloneData(from[key], depth + 1);
26
+ return out as T;
27
+ }
package/src/commands.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { cloneData } from "./clone.js";
1
2
  /** Discord application command option types. */
2
3
  export const ApplicationCommandOptionEnum = {
3
4
  Subcommand: 1,
@@ -137,7 +138,7 @@ export class CreateSlashCommand {
137
138
  }
138
139
  /** Serializes the command payload. */
139
140
  public toJSON(): Record<string, unknown> {
140
- return structuredClone(this.#data);
141
+ return cloneData(this.#data);
141
142
  }
142
143
  /** Adds a validated top-level command option. */
143
144
  protected addOption(option: CreateCommandOption): this {
@@ -174,14 +175,20 @@ export class CreateCommandOption {
174
175
  this.data.required = required;
175
176
  return this;
176
177
  }
177
- /** Sets autocomplete. */
178
+ /** Sets autocomplete; options with choices cannot use it. */
178
179
  public setAutocomplete(enabled = true): this {
180
+ if (
181
+ enabled &&
182
+ Array.isArray(this.data.choices) &&
183
+ this.data.choices.length
184
+ )
185
+ throw new RangeError("Autocomplete options cannot define choices.");
179
186
  this.data.autocomplete = enabled;
180
187
  return this;
181
188
  }
182
189
  /** Serializes the option payload. */
183
190
  public toJSON(): Record<string, unknown> {
184
- return structuredClone(this.data);
191
+ return cloneData(this.data);
185
192
  }
186
193
  }
187
194
 
@@ -235,6 +242,9 @@ export class CreateNumericOption extends CreateCommandOption {
235
242
  "min_value",
236
243
  this.#integer,
237
244
  );
245
+ const max = this.data.max_value as number | undefined;
246
+ if (max !== undefined && value > max)
247
+ throw new RangeError("min_value cannot exceed max_value.");
238
248
  this.data.min_value = value;
239
249
  return this;
240
250
  }
@@ -490,7 +500,18 @@ function appendChoices(
490
500
  throw new RangeError("Autocomplete options cannot define choices.");
491
501
  if (current.length + choices.length > 25)
492
502
  throw new RangeError("An option cannot contain more than 25 choices.");
493
- for (const choice of choices) validateText(choice.name, "Choice name", 100);
503
+ for (const choice of choices) {
504
+ validateText(choice.name, "Choice name", 100);
505
+ const value: unknown = choice.value;
506
+ if (
507
+ typeof value === "string"
508
+ ? value.length === 0 || value.length > 100
509
+ : typeof value !== "number" || !Number.isFinite(value)
510
+ )
511
+ throw new TypeError(
512
+ "Choice value must be a string of 1-100 characters or a finite number.",
513
+ );
514
+ }
494
515
  data.choices = [...current, ...choices.map((choice) => ({ ...choice }))];
495
516
  }
496
517
 
@@ -596,7 +617,7 @@ export class CreateContextMenuCommand {
596
617
  }
597
618
  /** Serializes the command payload for the Discord API. */
598
619
  public toJSON(): Record<string, unknown> {
599
- return structuredClone(this.data);
620
+ return cloneData(this.data);
600
621
  }
601
622
  }
602
623
 
package/src/components.ts CHANGED
@@ -1,14 +1,17 @@
1
+ import { cloneData } from "./clone.js";
1
2
  import { ButtonType, ComponentEnum } from "@lunibee/types";
2
3
  export { ButtonType, ComponentEnum };
3
4
 
4
5
  export const TextInputType = { Short: 1, Paragraph: 2 } as const;
5
6
  /** One {@link TextInputType} value, usable as a type. */
6
7
  export type TextInputType = (typeof TextInputType)[keyof typeof TextInputType];
8
+ /** An emoji on a button or select option: a custom emoji `id`, or a Unicode `name`. */
7
9
  export interface APIComponentEmoji {
8
10
  id?: string | null;
9
11
  name?: string | null;
10
12
  animated?: boolean;
11
13
  }
14
+ /** A button as a builder sends it. Exported from `lunibee` as `APIButtonPayload`. */
12
15
  export interface APIButtonComponent {
13
16
  type: typeof ComponentEnum.Button;
14
17
  style: 1 | 2 | 3 | 4 | 5 | 6;
@@ -20,6 +23,7 @@ export interface APIButtonComponent {
20
23
  sku_id?: string;
21
24
  disabled?: boolean;
22
25
  }
26
+ /** One option of a string select menu. */
23
27
  export interface APISelectOption {
24
28
  label: string;
25
29
  value: string;
@@ -27,6 +31,7 @@ export interface APISelectOption {
27
31
  emoji?: APIComponentEmoji;
28
32
  default?: boolean;
29
33
  }
34
+ /** A string select menu (component type 3) as Discord receives it. */
30
35
  export interface APIStringSelectComponent {
31
36
  type: typeof ComponentEnum.StringSelect;
32
37
  custom_id?: string;
@@ -37,6 +42,7 @@ export interface APIStringSelectComponent {
37
42
  disabled?: boolean;
38
43
  options?: APISelectOption[];
39
44
  }
45
+ /** A user, role, mentionable or channel select menu as Discord receives it. */
40
46
  export interface APIEntitySelectComponent {
41
47
  type:
42
48
  | typeof ComponentEnum.UserSelect
@@ -51,6 +57,7 @@ export interface APIEntitySelectComponent {
51
57
  disabled?: boolean;
52
58
  default_values?: APISelectDefaultValue[];
53
59
  }
60
+ /** A modal text input as a builder sends it. Exported from `lunibee` as `APITextInputPayload`. */
54
61
  export interface APITextInputComponent {
55
62
  type: typeof ComponentEnum.TextInput;
56
63
  style: typeof TextInputType.Short | typeof TextInputType.Paragraph;
@@ -62,68 +69,81 @@ export interface APITextInputComponent {
62
69
  required?: boolean;
63
70
  value?: string;
64
71
  }
72
+ /** A modal (callback type 9 data): title, custom ID and its components. */
65
73
  export interface APIModalComponent {
66
74
  type: 9;
67
75
  custom_id: string;
68
76
  title: string;
69
77
  components: APIActionRowComponent[];
70
78
  }
79
+ /** Any component an action row can hold: a button, a select menu or a text input. */
71
80
  export type APIActionRowChild =
72
81
  | APIButtonComponent
73
82
  | APIStringSelectComponent
74
83
  | APIEntitySelectComponent
75
84
  | APITextInputComponent;
85
+ /** An action row as a builder sends it: up to five buttons, or one select menu or text input. Exported from `lunibee` as `APIActionRowPayload`. */
76
86
  export interface APIActionRowComponent {
77
87
  type: typeof ComponentEnum.ActionRow;
78
88
  components: APIActionRowChild[];
79
89
  }
90
+ /** A Components V2 section: up to three text displays with an accessory. */
80
91
  export interface APISectionComponent {
81
92
  type: typeof ComponentEnum.Section;
82
93
  components?: APIComponent[]; // text displays
83
94
  accessory?: APIComponent; // buttons, thumbnails, etc.
84
95
  }
96
+ /** A Components V2 text display (Markdown content). */
85
97
  export interface APITextDisplayComponent {
86
98
  type: typeof ComponentEnum.TextDisplay;
87
99
  content: string;
88
100
  }
101
+ /** A Components V2 media gallery; each item is `{ media: { url }, description?, spoiler? }`. */
89
102
  export interface APIMediaGalleryComponent {
90
103
  type: typeof ComponentEnum.MediaGallery;
91
- items: { media: { url: string; description?: string } }[];
104
+ items: {
105
+ media: { url: string };
106
+ description?: string;
107
+ spoiler?: boolean;
108
+ }[];
92
109
  }
93
110
  /** Default values accepted by an auto-populated entity select. */
94
111
  export interface APISelectDefaultValue {
95
112
  id: string;
96
113
  type: "user" | "role" | "channel";
97
114
  }
115
+ /** A Components V2 file display for an uploaded attachment. */
98
116
  export interface APIFileComponent {
99
117
  type: typeof ComponentEnum.File;
100
118
  file: { url: string };
101
119
  spoiler?: boolean;
102
120
  }
121
+ /** A Components V2 separator, with optional divider line and spacing. */
103
122
  export interface APISeparatorComponent {
104
123
  type: typeof ComponentEnum.Separator;
105
124
  spacing?: 1 | 2;
106
125
  divider?: boolean;
107
126
  }
127
+ /** A Components V2 thumbnail: `{ type: 11, media: { url }, description?, spoiler? }`. */
108
128
  export interface APIThumbnailComponent {
109
129
  type: typeof ComponentEnum.Thumbnail;
110
- url: string;
111
- proxy_url?: string;
112
- width?: number;
113
- height?: number;
130
+ media: { url: string };
114
131
  description?: string;
115
132
  spoiler?: boolean;
116
133
  }
134
+ /** A Components V2 content inventory entry. */
117
135
  export interface APIContentInventoryEntryComponent {
118
136
  type: typeof ComponentEnum.ContentInventoryEntry;
119
137
  id: string;
120
138
  }
139
+ /** A Components V2 container: a box around other components, with an optional accent colour. */
121
140
  export interface APIContainerComponent {
122
141
  type: typeof ComponentEnum.Container;
123
142
  components: APIComponent[];
124
143
  accent_color?: number;
125
144
  spoiler?: boolean;
126
145
  }
146
+ /** Any top-level message component: an action row or a Components V2 layout component. */
127
147
  export type APIComponent =
128
148
  | APIActionRowComponent
129
149
  | APIButtonComponent
@@ -253,7 +273,7 @@ export class CreateStringSelect extends CreateSelectMenu {
253
273
  throw new RangeError(
254
274
  "max_values cannot exceed the number of options.",
255
275
  );
256
- return structuredClone(this.#data);
276
+ return cloneData(this.#data);
257
277
  }
258
278
  }
259
279
  export class CreateButton {
@@ -301,6 +321,8 @@ export class CreateButton {
301
321
  cause: error,
302
322
  });
303
323
  }
324
+ if (!["http:", "https:", "discord:"].includes(url.protocol))
325
+ throw new TypeError("Button URL must use http, https or discord.");
304
326
  this.setStyle(ButtonType.Link);
305
327
  this.#data.url = url.toString();
306
328
  return this;
@@ -346,7 +368,7 @@ export class CreateButton {
346
368
  }
347
369
  if (data.style !== ButtonType.Premium && !data.label && !data.emoji)
348
370
  throw new TypeError("Buttons require a label or an emoji.");
349
- return structuredClone(data);
371
+ return cloneData(data);
350
372
  }
351
373
  }
352
374
  export class CreateEntitySelect extends CreateSelectMenu {
@@ -375,7 +397,7 @@ export class CreateEntitySelect extends CreateSelectMenu {
375
397
  }
376
398
  public toJSON(): APIEntitySelectComponent {
377
399
  assertSelect(this.#data);
378
- return structuredClone(this.#data);
400
+ return cloneData(this.#data);
379
401
  }
380
402
  }
381
403
  export class CreateModal {
@@ -478,7 +500,7 @@ export class CreateTextInput {
478
500
  data.min_length > data.max_length
479
501
  )
480
502
  throw new RangeError("min_length cannot exceed max_length.");
481
- return structuredClone(data);
503
+ return cloneData(data);
482
504
  }
483
505
  }
484
506
 
@@ -558,8 +580,10 @@ export class CreateTextDisplay {
558
580
  }
559
581
 
560
582
  export class CreateMediaGallery {
561
- readonly #items: { media: { url: string; description?: string } }[] = [];
562
- public addItems(...items: { url: string; description?: string }[]): this {
583
+ readonly #items: APIMediaGalleryComponent["items"] = [];
584
+ public addItems(
585
+ ...items: { url: string; description?: string; spoiler?: boolean }[]
586
+ ): this {
563
587
  if (!items.length) {
564
588
  throw new TypeError("At least one media gallery item is required.");
565
589
  }
@@ -568,13 +592,19 @@ export class CreateMediaGallery {
568
592
  "MediaGallery can only contain up to 10 items.",
569
593
  );
570
594
  }
571
- this.#items.push(...items.map((item) => ({ media: item })));
595
+ this.#items.push(
596
+ ...items.map(({ url, description, spoiler }) => ({
597
+ media: { url },
598
+ ...(description !== undefined ? { description } : {}),
599
+ ...(spoiler !== undefined ? { spoiler } : {}),
600
+ })),
601
+ );
572
602
  return this;
573
603
  }
574
604
  public toJSON(): APIMediaGalleryComponent {
575
605
  return {
576
606
  type: ComponentEnum.MediaGallery,
577
- items: structuredClone(this.#items),
607
+ items: cloneData(this.#items),
578
608
  };
579
609
  }
580
610
  }
@@ -646,7 +676,7 @@ export class CreateThumbnail {
646
676
  public toJSON(): APIThumbnailComponent {
647
677
  const data: APIThumbnailComponent = {
648
678
  type: ComponentEnum.Thumbnail,
649
- url: this.#url,
679
+ media: { url: this.#url },
650
680
  };
651
681
  if (this.#description !== undefined)
652
682
  data.description = this.#description;
package/src/embed.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { cloneData } from "./clone.js";
1
2
  /** Discord API embed field payload. */
2
3
  export interface EmbedField {
3
4
  /** Field name. */
@@ -171,7 +172,11 @@ export class CreateEmbed {
171
172
  throw new RangeError(
172
173
  "Field index and delete count must be non-negative integers.",
173
174
  );
174
- current.splice(index, deleteCount, ...fields);
175
+ for (const field of fields) {
176
+ validate(field.name, 256, "Embed field name");
177
+ validate(field.value, 1024, "Embed field value");
178
+ }
179
+ current.splice(index, deleteCount, ...fields.map((f) => ({ ...f })));
175
180
  if (current.length > 25)
176
181
  throw new RangeError(
177
182
  "An embed cannot contain more than 25 fields.",
@@ -214,7 +219,7 @@ export class CreateEmbed {
214
219
  throw new RangeError(
215
220
  `An embed cannot contain more than 6000 characters in total (this one has ${total}).`,
216
221
  );
217
- return structuredClone(data);
222
+ return cloneData(data);
218
223
  }
219
224
  }
220
225
  /** Validates a bounded Discord string field. @param value Field value. @param max Maximum length. @param field Human-readable field name. @returns Validated value. @throws {RangeError} If length is invalid. */
@@ -225,9 +230,14 @@ function validate(value: string, max: number, field: string): string {
225
230
  }
226
231
  /** Validates and normalizes an absolute URL. @param value URL value. @param field Human-readable field name. @returns Normalized URL. @throws {TypeError} If URL is invalid. */
227
232
  function url(value: string, field: string): string {
233
+ let parsed: URL;
228
234
  try {
229
- return new URL(value).toString();
235
+ parsed = new URL(value);
230
236
  } catch (error) {
231
237
  throw new TypeError(`${field} must be a valid URL.`, { cause: error });
232
238
  }
239
+ // `attachment://` points an image at an uploaded file.
240
+ if (!["http:", "https:", "attachment:"].includes(parsed.protocol))
241
+ throw new TypeError(`${field} must use http, https or attachment.`);
242
+ return parsed.toString();
233
243
  }