@lunibee/builders 0.1.8 → 0.2.1
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 +11 -0
- package/package.json +1 -1
- package/src/components.ts +144 -14
- package/src/embed.ts +25 -13
- package/src/index.ts +1 -0
package/README.md
CHANGED
|
@@ -55,4 +55,15 @@ 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.1
|
|
59
|
+
|
|
60
|
+
- `ModalBuilder.addTextInputs(...inputs)` wraps each text input in its own row.
|
|
61
|
+
- `componentsV2Message(components, options?)` builds a Components V2 message with the required flag.
|
|
62
|
+
- `EmbedBuilder` setters (`setTitle`, `setDescription`, `setURL`, `setThumbnail`, `setImage`) clear the field with `null`.
|
|
63
|
+
- `TextInputStyle` and `ComponentType` are usable as types; `ModalBuilder.toJSON()` is typed with a required `custom_id` and `title`.
|
|
64
|
+
|
|
65
|
+
## What's new in 0.2.0
|
|
66
|
+
|
|
67
|
+
`toJSON()` validates whole components (required fields, value bounds, action-row rules, button field exclusivity); `ButtonBuilder.setSKUId()` for premium buttons.
|
|
68
|
+
|
|
58
69
|
Docs: https://lunibee.js.org/core-concepts/builders/
|
package/package.json
CHANGED
package/src/components.ts
CHANGED
|
@@ -23,6 +23,11 @@ export const ComponentType = {
|
|
|
23
23
|
Container: 17,
|
|
24
24
|
} as const;
|
|
25
25
|
export const TextInputStyle = { Short: 1, Paragraph: 2 } as const;
|
|
26
|
+
/** One {@link TextInputStyle} value, usable as a type. */
|
|
27
|
+
export type TextInputStyle =
|
|
28
|
+
(typeof TextInputStyle)[keyof typeof TextInputStyle];
|
|
29
|
+
/** One {@link ComponentType} value, usable as a type. */
|
|
30
|
+
export type ComponentType = (typeof ComponentType)[keyof typeof ComponentType];
|
|
26
31
|
export interface APIComponentEmoji {
|
|
27
32
|
id?: string | null;
|
|
28
33
|
name?: string | null;
|
|
@@ -35,6 +40,8 @@ export interface APIButtonComponent {
|
|
|
35
40
|
label?: string;
|
|
36
41
|
emoji?: APIComponentEmoji;
|
|
37
42
|
url?: string;
|
|
43
|
+
/** SKU for premium (style 6) buttons. */
|
|
44
|
+
sku_id?: string;
|
|
38
45
|
disabled?: boolean;
|
|
39
46
|
}
|
|
40
47
|
export interface APISelectOption {
|
|
@@ -81,8 +88,8 @@ export interface APITextInputComponent {
|
|
|
81
88
|
}
|
|
82
89
|
export interface APIModalComponent {
|
|
83
90
|
type: 9;
|
|
84
|
-
custom_id
|
|
85
|
-
title
|
|
91
|
+
custom_id: string;
|
|
92
|
+
title: string;
|
|
86
93
|
components: APIActionRowComponent[];
|
|
87
94
|
}
|
|
88
95
|
export type APIActionRowChild =
|
|
@@ -175,10 +182,20 @@ export class ActionRowBuilder<
|
|
|
175
182
|
return this;
|
|
176
183
|
}
|
|
177
184
|
public toJSON(): APIActionRowComponent {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
185
|
+
const components = this.#components.map((component) =>
|
|
186
|
+
component.toJSON(),
|
|
187
|
+
);
|
|
188
|
+
if (components.length === 0)
|
|
189
|
+
throw new RangeError("An action row needs at least one component.");
|
|
190
|
+
const types = components.map((component) => component.type);
|
|
191
|
+
if (
|
|
192
|
+
types.some((type) => type !== ComponentType.Button) &&
|
|
193
|
+
components.length > 1
|
|
194
|
+
)
|
|
195
|
+
throw new RangeError(
|
|
196
|
+
"A select menu or text input must be alone in its action row.",
|
|
197
|
+
);
|
|
198
|
+
return { type: ComponentType.ActionRow, components };
|
|
182
199
|
}
|
|
183
200
|
}
|
|
184
201
|
export class StringSelectBuilder {
|
|
@@ -238,6 +255,14 @@ export class StringSelectBuilder {
|
|
|
238
255
|
return this;
|
|
239
256
|
}
|
|
240
257
|
public toJSON(): APIStringSelectComponent {
|
|
258
|
+
assertSelect(this.#data);
|
|
259
|
+
const count = this.#data.options?.length ?? 0;
|
|
260
|
+
if (count === 0)
|
|
261
|
+
throw new RangeError("A string select needs at least one option.");
|
|
262
|
+
if ((this.#data.max_values ?? 1) > count)
|
|
263
|
+
throw new RangeError(
|
|
264
|
+
"max_values cannot exceed the number of options.",
|
|
265
|
+
);
|
|
241
266
|
return structuredClone(this.#data);
|
|
242
267
|
}
|
|
243
268
|
}
|
|
@@ -246,9 +271,22 @@ export class ButtonBuilder {
|
|
|
246
271
|
type: ComponentType.Button,
|
|
247
272
|
style: ButtonStyle.Secondary,
|
|
248
273
|
};
|
|
274
|
+
/** Sets the style and drops fields that style cannot carry. */
|
|
249
275
|
public setStyle(style: APIButtonComponent["style"]): this {
|
|
250
|
-
|
|
251
|
-
|
|
276
|
+
const data = this.#data;
|
|
277
|
+
data.style = style;
|
|
278
|
+
if (style === ButtonStyle.Link) {
|
|
279
|
+
delete data.custom_id;
|
|
280
|
+
delete data.sku_id;
|
|
281
|
+
} else if (style === ButtonStyle.Premium) {
|
|
282
|
+
delete data.custom_id;
|
|
283
|
+
delete data.url;
|
|
284
|
+
delete data.label;
|
|
285
|
+
delete data.emoji;
|
|
286
|
+
} else {
|
|
287
|
+
delete data.url;
|
|
288
|
+
delete data.sku_id;
|
|
289
|
+
}
|
|
252
290
|
return this;
|
|
253
291
|
}
|
|
254
292
|
public setCustomId(value: string): this {
|
|
@@ -273,11 +311,17 @@ export class ButtonBuilder {
|
|
|
273
311
|
cause: error,
|
|
274
312
|
});
|
|
275
313
|
}
|
|
276
|
-
this
|
|
277
|
-
delete this.#data.custom_id;
|
|
314
|
+
this.setStyle(ButtonStyle.Link);
|
|
278
315
|
this.#data.url = url.toString();
|
|
279
316
|
return this;
|
|
280
317
|
}
|
|
318
|
+
/** Makes this a premium (purchase) button for a SKU. Premium buttons carry no label, emoji, custom ID or URL. */
|
|
319
|
+
public setSKUId(skuId: string): this {
|
|
320
|
+
validateText(skuId, 20, "Button SKU ID");
|
|
321
|
+
this.setStyle(ButtonStyle.Premium);
|
|
322
|
+
this.#data.sku_id = skuId;
|
|
323
|
+
return this;
|
|
324
|
+
}
|
|
281
325
|
public setEmoji(emoji: APIComponentEmoji | string): this {
|
|
282
326
|
this.#data.emoji =
|
|
283
327
|
typeof emoji === "string" ? { name: emoji } : { ...emoji };
|
|
@@ -288,7 +332,31 @@ export class ButtonBuilder {
|
|
|
288
332
|
return this;
|
|
289
333
|
}
|
|
290
334
|
public toJSON(): APIButtonComponent {
|
|
291
|
-
|
|
335
|
+
const data = this.#data;
|
|
336
|
+
if (data.style === ButtonStyle.Link) {
|
|
337
|
+
if (!data.url) throw new TypeError("Link buttons require a URL.");
|
|
338
|
+
if (data.custom_id || data.sku_id)
|
|
339
|
+
throw new TypeError(
|
|
340
|
+
"Link buttons cannot have a custom ID or SKU ID.",
|
|
341
|
+
);
|
|
342
|
+
} else if (data.style === ButtonStyle.Premium) {
|
|
343
|
+
if (!data.sku_id)
|
|
344
|
+
throw new TypeError("Premium buttons require a SKU ID.");
|
|
345
|
+
if (data.custom_id || data.url || data.label || data.emoji)
|
|
346
|
+
throw new TypeError(
|
|
347
|
+
"Premium buttons cannot have a custom ID, URL, label or emoji.",
|
|
348
|
+
);
|
|
349
|
+
} else {
|
|
350
|
+
if (!data.custom_id)
|
|
351
|
+
throw new TypeError("Non-link buttons require a custom ID.");
|
|
352
|
+
if (data.url || data.sku_id)
|
|
353
|
+
throw new TypeError(
|
|
354
|
+
"Only link buttons have a URL and only premium buttons have a SKU ID.",
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
if (data.style !== ButtonStyle.Premium && !data.label && !data.emoji)
|
|
358
|
+
throw new TypeError("Buttons require a label or an emoji.");
|
|
359
|
+
return structuredClone(data);
|
|
292
360
|
}
|
|
293
361
|
}
|
|
294
362
|
export class EntitySelectBuilder {
|
|
@@ -342,6 +410,7 @@ export class EntitySelectBuilder {
|
|
|
342
410
|
return this;
|
|
343
411
|
}
|
|
344
412
|
public toJSON(): APIEntitySelectComponent {
|
|
413
|
+
assertSelect(this.#data);
|
|
345
414
|
return structuredClone(this.#data);
|
|
346
415
|
}
|
|
347
416
|
}
|
|
@@ -371,11 +440,23 @@ export class ModalBuilder {
|
|
|
371
440
|
this.#components.push(...components);
|
|
372
441
|
return this;
|
|
373
442
|
}
|
|
443
|
+
/** Adds text inputs, each in its own action row (Discord's modal layout). @param inputs Text inputs, at most 5 per modal. */
|
|
444
|
+
public addTextInputs(...inputs: TextInputBuilder[]): this {
|
|
445
|
+
return this.addComponents(
|
|
446
|
+
...inputs.map((input) =>
|
|
447
|
+
new ActionRowBuilder<TextInputBuilder>().addComponents(input),
|
|
448
|
+
),
|
|
449
|
+
);
|
|
450
|
+
}
|
|
374
451
|
public toJSON(): APIModalComponent {
|
|
452
|
+
if (!this.#custom_id || !this.#title)
|
|
453
|
+
throw new TypeError("Modals require a custom ID and a title.");
|
|
454
|
+
if (this.#components.length === 0)
|
|
455
|
+
throw new RangeError("Modals require at least one component.");
|
|
375
456
|
return {
|
|
376
457
|
type: 9,
|
|
377
|
-
|
|
378
|
-
|
|
458
|
+
custom_id: this.#custom_id,
|
|
459
|
+
title: this.#title,
|
|
379
460
|
components: this.#components.map((component) => component.toJSON()),
|
|
380
461
|
};
|
|
381
462
|
}
|
|
@@ -424,7 +505,16 @@ export class TextInputBuilder {
|
|
|
424
505
|
return this;
|
|
425
506
|
}
|
|
426
507
|
public toJSON(): APITextInputComponent {
|
|
427
|
-
|
|
508
|
+
const data = this.#data;
|
|
509
|
+
if (!data.custom_id)
|
|
510
|
+
throw new TypeError("Text inputs require a custom ID.");
|
|
511
|
+
if (
|
|
512
|
+
data.min_length !== undefined &&
|
|
513
|
+
data.max_length !== undefined &&
|
|
514
|
+
data.min_length > data.max_length
|
|
515
|
+
)
|
|
516
|
+
throw new RangeError("min_length cannot exceed max_length.");
|
|
517
|
+
return structuredClone(data);
|
|
428
518
|
}
|
|
429
519
|
}
|
|
430
520
|
|
|
@@ -668,3 +758,43 @@ export class ChannelSelectMenuBuilder extends EntitySelectBuilder {
|
|
|
668
758
|
super(ComponentType.ChannelSelect);
|
|
669
759
|
}
|
|
670
760
|
}
|
|
761
|
+
|
|
762
|
+
/** Checks the fields every select menu needs. */
|
|
763
|
+
function assertSelect(data: {
|
|
764
|
+
custom_id?: string;
|
|
765
|
+
min_values?: number;
|
|
766
|
+
max_values?: number;
|
|
767
|
+
}): void {
|
|
768
|
+
if (!data.custom_id)
|
|
769
|
+
throw new TypeError("Select menus require a custom ID.");
|
|
770
|
+
if ((data.min_values ?? 1) > (data.max_values ?? 1))
|
|
771
|
+
throw new RangeError("min_values cannot exceed max_values.");
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Discord's flag for a message laid out with Components V2 (containers, text displays...). */
|
|
775
|
+
const IS_COMPONENTS_V2 = 1 << 15;
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* A message payload laid out with Components V2: the components, and the flag
|
|
779
|
+
* Discord requires for them. A V2 message cannot also carry `content` or `embeds`.
|
|
780
|
+
* @param components Top-level components (containers, sections, text displays, action rows...).
|
|
781
|
+
* @param options Extra message fields, such as `allowed_mentions` or `ephemeral`; `flags` are combined.
|
|
782
|
+
* @example channel.send(componentsV2Message(new ContainerBuilder().addComponents(new TextDisplayBuilder().setContent("Hi"))))
|
|
783
|
+
*/
|
|
784
|
+
export function componentsV2Message<
|
|
785
|
+
T extends Record<string, unknown> = Record<string, never>,
|
|
786
|
+
>(
|
|
787
|
+
components: { toJSON(): APIComponent } | Array<{ toJSON(): APIComponent }>,
|
|
788
|
+
options?: T & { flags?: number },
|
|
789
|
+
): { components: APIComponent[]; flags: number } & T {
|
|
790
|
+
const list = Array.isArray(components) ? components : [components];
|
|
791
|
+
if (!list.length)
|
|
792
|
+
throw new TypeError(
|
|
793
|
+
"A Components V2 message needs at least one component.",
|
|
794
|
+
);
|
|
795
|
+
return {
|
|
796
|
+
...(options ?? ({} as T)),
|
|
797
|
+
components: list.map((component) => component.toJSON()),
|
|
798
|
+
flags: (options?.flags ?? 0) | IS_COMPONENTS_V2,
|
|
799
|
+
};
|
|
800
|
+
}
|
package/src/embed.ts
CHANGED
|
@@ -32,22 +32,26 @@ export interface APIEmbed {
|
|
|
32
32
|
/** Builds compile-time-safe rich embed payloads for Discord API requests. */
|
|
33
33
|
export class EmbedBuilder {
|
|
34
34
|
readonly #data: APIEmbed = {};
|
|
35
|
-
/** Sets the embed title. @param value Title text. @returns This builder. @throws {RangeError} If title exceeds Discord's limit. */ public setTitle(
|
|
36
|
-
value: string,
|
|
35
|
+
/** Sets the embed title. @param value Title text, or null to clear. @returns This builder. @throws {RangeError} If title exceeds Discord's limit. */ public setTitle(
|
|
36
|
+
value: string | null,
|
|
37
37
|
): this {
|
|
38
|
-
|
|
38
|
+
if (value === null) delete this.#data.title;
|
|
39
|
+
else this.#data.title = validate(value, 256, "Embed title");
|
|
39
40
|
return this;
|
|
40
41
|
}
|
|
41
|
-
/** Sets the embed description. @param value Description text. @returns This builder. @throws {RangeError} If description exceeds Discord's limit. */ public setDescription(
|
|
42
|
-
value: string,
|
|
42
|
+
/** Sets the embed description. @param value Description text, or null to clear. @returns This builder. @throws {RangeError} If description exceeds Discord's limit. */ public setDescription(
|
|
43
|
+
value: string | null,
|
|
43
44
|
): this {
|
|
44
|
-
|
|
45
|
+
if (value === null) delete this.#data.description;
|
|
46
|
+
else
|
|
47
|
+
this.#data.description = validate(value, 4096, "Embed description");
|
|
45
48
|
return this;
|
|
46
49
|
}
|
|
47
|
-
/** Sets the embed URL. @param value Absolute URL. @returns This builder. @throws {TypeError} If URL is invalid. */ public setURL(
|
|
48
|
-
value: string,
|
|
50
|
+
/** Sets the embed URL. @param value Absolute URL, or null to clear. @returns This builder. @throws {TypeError} If URL is invalid. */ public setURL(
|
|
51
|
+
value: string | null,
|
|
49
52
|
): this {
|
|
50
|
-
|
|
53
|
+
if (value === null) delete this.#data.url;
|
|
54
|
+
else this.#data.url = url(value, "Embed URL");
|
|
51
55
|
return this;
|
|
52
56
|
}
|
|
53
57
|
/** Sets the embed color. @param value RGB integer. @returns This builder. @throws {RangeError} If color is outside 24-bit RGB range. */ public setColor(
|
|
@@ -92,16 +96,24 @@ export class EmbedBuilder {
|
|
|
92
96
|
};
|
|
93
97
|
return this;
|
|
94
98
|
}
|
|
95
|
-
/** Sets the thumbnail URL. @param value Thumbnail URL or payload. @returns This builder. @throws {TypeError} If URL is invalid. */ public setThumbnail(
|
|
96
|
-
value: { url: string } | string,
|
|
99
|
+
/** Sets the thumbnail URL. @param value Thumbnail URL or payload, or null to clear. @returns This builder. @throws {TypeError} If URL is invalid. */ public setThumbnail(
|
|
100
|
+
value: { url: string } | string | null,
|
|
97
101
|
): this {
|
|
102
|
+
if (value === null) {
|
|
103
|
+
delete this.#data.thumbnail;
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
98
106
|
const valueURL = typeof value === "string" ? value : value.url;
|
|
99
107
|
this.#data.thumbnail = { url: url(valueURL, "Thumbnail URL") };
|
|
100
108
|
return this;
|
|
101
109
|
}
|
|
102
|
-
/** Sets the image URL. @param value Image URL or payload. @returns This builder. @throws {TypeError} If URL is invalid. */ public setImage(
|
|
103
|
-
value: { url: string } | string,
|
|
110
|
+
/** Sets the image URL. @param value Image URL or payload, or null to clear. @returns This builder. @throws {TypeError} If URL is invalid. */ public setImage(
|
|
111
|
+
value: { url: string } | string | null,
|
|
104
112
|
): this {
|
|
113
|
+
if (value === null) {
|
|
114
|
+
delete this.#data.image;
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
105
117
|
const valueURL = typeof value === "string" ? value : value.url;
|
|
106
118
|
this.#data.image = { url: url(valueURL, "Image URL") };
|
|
107
119
|
return this;
|