@lunibee/builders 0.1.8 → 0.2.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/README.md CHANGED
@@ -55,4 +55,8 @@ console.log(embed.toJSON(), buttons.toJSON(), menu.toJSON(), ping.toJSON());
55
55
 
56
56
  Every builder validates Discord's limits as you set values and serialises with `toJSON()`.
57
57
 
58
+ ## What's new in 0.2.0
59
+
60
+ `toJSON()` validates whole components (required fields, value bounds, action-row rules, button field exclusivity); `ButtonBuilder.setSKUId()` for premium buttons.
61
+
58
62
  Docs: https://lunibee.js.org/core-concepts/builders/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunibee/builders",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "module": "./src/index.ts",
6
6
  "types": "./src/index.ts",
package/src/components.ts CHANGED
@@ -35,6 +35,8 @@ export interface APIButtonComponent {
35
35
  label?: string;
36
36
  emoji?: APIComponentEmoji;
37
37
  url?: string;
38
+ /** SKU for premium (style 6) buttons. */
39
+ sku_id?: string;
38
40
  disabled?: boolean;
39
41
  }
40
42
  export interface APISelectOption {
@@ -175,10 +177,20 @@ export class ActionRowBuilder<
175
177
  return this;
176
178
  }
177
179
  public toJSON(): APIActionRowComponent {
178
- return {
179
- type: ComponentType.ActionRow,
180
- components: this.#components.map((component) => component.toJSON()),
181
- };
180
+ const components = this.#components.map((component) =>
181
+ component.toJSON(),
182
+ );
183
+ if (components.length === 0)
184
+ throw new RangeError("An action row needs at least one component.");
185
+ const types = components.map((component) => component.type);
186
+ if (
187
+ types.some((type) => type !== ComponentType.Button) &&
188
+ components.length > 1
189
+ )
190
+ throw new RangeError(
191
+ "A select menu or text input must be alone in its action row.",
192
+ );
193
+ return { type: ComponentType.ActionRow, components };
182
194
  }
183
195
  }
184
196
  export class StringSelectBuilder {
@@ -238,6 +250,14 @@ export class StringSelectBuilder {
238
250
  return this;
239
251
  }
240
252
  public toJSON(): APIStringSelectComponent {
253
+ assertSelect(this.#data);
254
+ const count = this.#data.options?.length ?? 0;
255
+ if (count === 0)
256
+ throw new RangeError("A string select needs at least one option.");
257
+ if ((this.#data.max_values ?? 1) > count)
258
+ throw new RangeError(
259
+ "max_values cannot exceed the number of options.",
260
+ );
241
261
  return structuredClone(this.#data);
242
262
  }
243
263
  }
@@ -246,9 +266,22 @@ export class ButtonBuilder {
246
266
  type: ComponentType.Button,
247
267
  style: ButtonStyle.Secondary,
248
268
  };
269
+ /** Sets the style and drops fields that style cannot carry. */
249
270
  public setStyle(style: APIButtonComponent["style"]): this {
250
- this.#data.style = style;
251
- if (style === ButtonStyle.Link) delete this.#data.custom_id;
271
+ const data = this.#data;
272
+ data.style = style;
273
+ if (style === ButtonStyle.Link) {
274
+ delete data.custom_id;
275
+ delete data.sku_id;
276
+ } else if (style === ButtonStyle.Premium) {
277
+ delete data.custom_id;
278
+ delete data.url;
279
+ delete data.label;
280
+ delete data.emoji;
281
+ } else {
282
+ delete data.url;
283
+ delete data.sku_id;
284
+ }
252
285
  return this;
253
286
  }
254
287
  public setCustomId(value: string): this {
@@ -273,11 +306,17 @@ export class ButtonBuilder {
273
306
  cause: error,
274
307
  });
275
308
  }
276
- this.#data.style = ButtonStyle.Link;
277
- delete this.#data.custom_id;
309
+ this.setStyle(ButtonStyle.Link);
278
310
  this.#data.url = url.toString();
279
311
  return this;
280
312
  }
313
+ /** Makes this a premium (purchase) button for a SKU. Premium buttons carry no label, emoji, custom ID or URL. */
314
+ public setSKUId(skuId: string): this {
315
+ validateText(skuId, 20, "Button SKU ID");
316
+ this.setStyle(ButtonStyle.Premium);
317
+ this.#data.sku_id = skuId;
318
+ return this;
319
+ }
281
320
  public setEmoji(emoji: APIComponentEmoji | string): this {
282
321
  this.#data.emoji =
283
322
  typeof emoji === "string" ? { name: emoji } : { ...emoji };
@@ -288,7 +327,31 @@ export class ButtonBuilder {
288
327
  return this;
289
328
  }
290
329
  public toJSON(): APIButtonComponent {
291
- return structuredClone(this.#data);
330
+ const data = this.#data;
331
+ if (data.style === ButtonStyle.Link) {
332
+ if (!data.url) throw new TypeError("Link buttons require a URL.");
333
+ if (data.custom_id || data.sku_id)
334
+ throw new TypeError(
335
+ "Link buttons cannot have a custom ID or SKU ID.",
336
+ );
337
+ } else if (data.style === ButtonStyle.Premium) {
338
+ if (!data.sku_id)
339
+ throw new TypeError("Premium buttons require a SKU ID.");
340
+ if (data.custom_id || data.url || data.label || data.emoji)
341
+ throw new TypeError(
342
+ "Premium buttons cannot have a custom ID, URL, label or emoji.",
343
+ );
344
+ } else {
345
+ if (!data.custom_id)
346
+ throw new TypeError("Non-link buttons require a custom ID.");
347
+ if (data.url || data.sku_id)
348
+ throw new TypeError(
349
+ "Only link buttons have a URL and only premium buttons have a SKU ID.",
350
+ );
351
+ }
352
+ if (data.style !== ButtonStyle.Premium && !data.label && !data.emoji)
353
+ throw new TypeError("Buttons require a label or an emoji.");
354
+ return structuredClone(data);
292
355
  }
293
356
  }
294
357
  export class EntitySelectBuilder {
@@ -342,6 +405,7 @@ export class EntitySelectBuilder {
342
405
  return this;
343
406
  }
344
407
  public toJSON(): APIEntitySelectComponent {
408
+ assertSelect(this.#data);
345
409
  return structuredClone(this.#data);
346
410
  }
347
411
  }
@@ -372,6 +436,10 @@ export class ModalBuilder {
372
436
  return this;
373
437
  }
374
438
  public toJSON(): APIModalComponent {
439
+ if (!this.#custom_id || !this.#title)
440
+ throw new TypeError("Modals require a custom ID and a title.");
441
+ if (this.#components.length === 0)
442
+ throw new RangeError("Modals require at least one component.");
375
443
  return {
376
444
  type: 9,
377
445
  ...(this.#custom_id ? { custom_id: this.#custom_id } : {}),
@@ -424,7 +492,16 @@ export class TextInputBuilder {
424
492
  return this;
425
493
  }
426
494
  public toJSON(): APITextInputComponent {
427
- return structuredClone(this.#data);
495
+ const data = this.#data;
496
+ if (!data.custom_id)
497
+ throw new TypeError("Text inputs require a custom ID.");
498
+ if (
499
+ data.min_length !== undefined &&
500
+ data.max_length !== undefined &&
501
+ data.min_length > data.max_length
502
+ )
503
+ throw new RangeError("min_length cannot exceed max_length.");
504
+ return structuredClone(data);
428
505
  }
429
506
  }
430
507
 
@@ -668,3 +745,15 @@ export class ChannelSelectMenuBuilder extends EntitySelectBuilder {
668
745
  super(ComponentType.ChannelSelect);
669
746
  }
670
747
  }
748
+
749
+ /** Checks the fields every select menu needs. */
750
+ function assertSelect(data: {
751
+ custom_id?: string;
752
+ min_values?: number;
753
+ max_values?: number;
754
+ }): void {
755
+ if (!data.custom_id)
756
+ throw new TypeError("Select menus require a custom ID.");
757
+ if ((data.min_values ?? 1) > (data.max_values ?? 1))
758
+ throw new RangeError("min_values cannot exceed max_values.");
759
+ }