diff --git a/packages/builders/package.json b/packages/builders/package.json index 73f4c1e530a5..f22eac7b92bc 100644 --- a/packages/builders/package.json +++ b/packages/builders/package.json @@ -67,11 +67,12 @@ "dependencies": { "@discordjs/formatters": "workspace:^", "@discordjs/util": "workspace:^", - "@sapphire/shapeshift": "^3.9.7", "discord-api-types": "0.37.87", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", - "tslib": "^2.6.2" + "tslib": "^2.6.2", + "zod": "^3.22.4", + "zod-validation-error": "^3.0.0" }, "devDependencies": { "@discordjs/api-extractor": "workspace:^", diff --git a/packages/builders/src/components/Assertions.ts b/packages/builders/src/components/Assertions.ts index 960efd706c7c..7da0c65dc3bc 100644 --- a/packages/builders/src/components/Assertions.ts +++ b/packages/builders/src/components/Assertions.ts @@ -1,80 +1,83 @@ -import { s } from '@sapphire/shapeshift'; import { ButtonStyle, ChannelType, type APIMessageComponentEmoji } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../util/validation.js'; +import { z } from 'zod'; +import { parse } from '../util/validation.js'; import { StringSelectMenuOptionBuilder } from './selectMenu/StringSelectMenuOption.js'; -export const customIdValidator = s.string - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(100) - .setValidationEnabled(isValidationEnabled); +export const customIdValidator = z.string().min(1).max(100); -export const emojiValidator = s +export const emojiValidator = z .object({ - id: s.string, - name: s.string, - animated: s.boolean, + id: z.string(), + name: z.string(), + animated: z.boolean(), }) - .partial.strict.setValidationEnabled(isValidationEnabled); + .partial() + .strict(); -export const disabledValidator = s.boolean; +export const disabledValidator = z.boolean(); -export const buttonLabelValidator = s.string - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(80) - .setValidationEnabled(isValidationEnabled); +export const buttonLabelValidator = z.string().min(1).max(80); -export const buttonStyleValidator = s.nativeEnum(ButtonStyle); +export const buttonStyleValidator = z.union([ + z.nativeEnum(ButtonStyle), + z + .enum( + Object.values(ButtonStyle).filter((value) => typeof value === 'string') as [ + keyof typeof ButtonStyle, + ...(keyof typeof ButtonStyle)[], + ], + ) + .transform((key) => ButtonStyle[key]), +]); -export const placeholderValidator = s.string.lengthLessThanOrEqual(150).setValidationEnabled(isValidationEnabled); -export const minMaxValidator = s.number.int - .greaterThanOrEqual(0) - .lessThanOrEqual(25) - .setValidationEnabled(isValidationEnabled); +export const placeholderValidator = z.string().max(150); +export const minMaxValidator = z.number().int().gte(0).lte(25); -export const labelValueDescriptionValidator = s.string - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(100) - .setValidationEnabled(isValidationEnabled); +export const labelValueDescriptionValidator = z.string().min(1).max(100); -export const jsonOptionValidator = s - .object({ - label: labelValueDescriptionValidator, - value: labelValueDescriptionValidator, - description: labelValueDescriptionValidator.optional, - emoji: emojiValidator.optional, - default: s.boolean.optional, - }) - .setValidationEnabled(isValidationEnabled); +export const jsonOptionValidator = z.object({ + label: labelValueDescriptionValidator, + value: labelValueDescriptionValidator, + description: labelValueDescriptionValidator.optional(), + emoji: emojiValidator.optional(), + default: z.boolean().optional(), +}); -export const optionValidator = s.instance(StringSelectMenuOptionBuilder).setValidationEnabled(isValidationEnabled); +export const optionValidator = z.instanceof(StringSelectMenuOptionBuilder); -export const optionsValidator = optionValidator.array - .lengthGreaterThanOrEqual(0) - .setValidationEnabled(isValidationEnabled); -export const optionsLengthValidator = s.number.int - .greaterThanOrEqual(0) - .lessThanOrEqual(25) - .setValidationEnabled(isValidationEnabled); +export const optionsValidator = optionValidator.array().min(0); +export const optionsLengthValidator = z.number().int().gte(0).lte(25); export function validateRequiredSelectMenuParameters(options: StringSelectMenuOptionBuilder[], customId?: string) { - customIdValidator.parse(customId); - optionsValidator.parse(options); + parse(customIdValidator, customId); + parse(optionsValidator, options); } -export const defaultValidator = s.boolean; +export const defaultValidator = z.boolean(); export function validateRequiredSelectMenuOptionParameters(label?: string, value?: string) { - labelValueDescriptionValidator.parse(label); - labelValueDescriptionValidator.parse(value); + parse(labelValueDescriptionValidator, label); + parse(labelValueDescriptionValidator, value); } -export const channelTypesValidator = s.nativeEnum(ChannelType).array.setValidationEnabled(isValidationEnabled); - -export const urlValidator = s.string - .url({ - allowedProtocols: ['http:', 'https:', 'discord:'], - }) - .setValidationEnabled(isValidationEnabled); +export const channelTypesValidator = z + .union([ + z.nativeEnum(ChannelType), + z + .enum( + Object.values(ChannelType).filter((value) => typeof value === 'string') as [ + keyof typeof ChannelType, + ...(keyof typeof ChannelType)[], + ], + ) + .transform((key) => ChannelType[key]), + ]) + .array(); + +export const urlValidator = z + .string() + .url() + .regex(/^(?https?|discord):\/\//); export function validateRequiredButtonParameters( style?: ButtonStyle, diff --git a/packages/builders/src/components/button/Button.ts b/packages/builders/src/components/button/Button.ts index 30aad629b8a8..ff1ef79594c0 100644 --- a/packages/builders/src/components/button/Button.ts +++ b/packages/builders/src/components/button/Button.ts @@ -6,6 +6,7 @@ import { type APIButtonComponentWithCustomId, type ButtonStyle, } from 'discord-api-types/v10'; +import { parse } from '../../util/validation.js'; import { buttonLabelValidator, buttonStyleValidator, @@ -59,7 +60,7 @@ export class ButtonBuilder extends ComponentBuilder { * @param style - The style to use */ public setStyle(style: ButtonStyle) { - this.data.style = buttonStyleValidator.parse(style); + this.data.style = parse(buttonStyleValidator, style); return this; } @@ -72,7 +73,7 @@ export class ButtonBuilder extends ComponentBuilder { * @param url - The URL to use */ public setURL(url: string) { - (this.data as APIButtonComponentWithURL).url = urlValidator.parse(url); + (this.data as APIButtonComponentWithURL).url = parse(urlValidator, url); return this; } @@ -84,7 +85,7 @@ export class ButtonBuilder extends ComponentBuilder { * @param customId - The custom id to use */ public setCustomId(customId: string) { - (this.data as APIButtonComponentWithCustomId).custom_id = customIdValidator.parse(customId); + (this.data as APIButtonComponentWithCustomId).custom_id = parse(customIdValidator, customId); return this; } @@ -94,7 +95,7 @@ export class ButtonBuilder extends ComponentBuilder { * @param emoji - The emoji to use */ public setEmoji(emoji: APIMessageComponentEmoji) { - this.data.emoji = emojiValidator.parse(emoji); + this.data.emoji = parse(emojiValidator, emoji); return this; } @@ -104,7 +105,7 @@ export class ButtonBuilder extends ComponentBuilder { * @param disabled - Whether to disable this button */ public setDisabled(disabled = true) { - this.data.disabled = disabledValidator.parse(disabled); + this.data.disabled = parse(disabledValidator, disabled); return this; } @@ -114,7 +115,7 @@ export class ButtonBuilder extends ComponentBuilder { * @param label - The label to use */ public setLabel(label: string) { - this.data.label = buttonLabelValidator.parse(label); + this.data.label = parse(buttonLabelValidator, label); return this; } diff --git a/packages/builders/src/components/selectMenu/BaseSelectMenu.ts b/packages/builders/src/components/selectMenu/BaseSelectMenu.ts index 298d7dc5e1fd..a53a26be295c 100644 --- a/packages/builders/src/components/selectMenu/BaseSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/BaseSelectMenu.ts @@ -1,4 +1,5 @@ import type { APISelectMenuComponent } from 'discord-api-types/v10'; +import { parse } from '../../util/validation.js'; import { customIdValidator, disabledValidator, minMaxValidator, placeholderValidator } from '../Assertions.js'; import { ComponentBuilder } from '../Component.js'; @@ -16,7 +17,7 @@ export abstract class BaseSelectMenuBuilder< * @param placeholder - The placeholder to use */ public setPlaceholder(placeholder: string) { - this.data.placeholder = placeholderValidator.parse(placeholder); + this.data.placeholder = parse(placeholderValidator, placeholder); return this; } @@ -26,7 +27,7 @@ export abstract class BaseSelectMenuBuilder< * @param minValues - The minimum values that must be selected */ public setMinValues(minValues: number) { - this.data.min_values = minMaxValidator.parse(minValues); + this.data.min_values = parse(minMaxValidator, minValues); return this; } @@ -36,7 +37,7 @@ export abstract class BaseSelectMenuBuilder< * @param maxValues - The maximum values that must be selected */ public setMaxValues(maxValues: number) { - this.data.max_values = minMaxValidator.parse(maxValues); + this.data.max_values = parse(minMaxValidator, maxValues); return this; } @@ -46,7 +47,7 @@ export abstract class BaseSelectMenuBuilder< * @param customId - The custom id to use */ public setCustomId(customId: string) { - this.data.custom_id = customIdValidator.parse(customId); + this.data.custom_id = parse(customIdValidator, customId); return this; } @@ -56,7 +57,7 @@ export abstract class BaseSelectMenuBuilder< * @param disabled - Whether this select menu is disabled */ public setDisabled(disabled = true) { - this.data.disabled = disabledValidator.parse(disabled); + this.data.disabled = parse(disabledValidator, disabled); return this; } @@ -64,7 +65,7 @@ export abstract class BaseSelectMenuBuilder< * {@inheritDoc ComponentBuilder.toJSON} */ public toJSON(): SelectMenuType { - customIdValidator.parse(this.data.custom_id); + parse(customIdValidator, this.data.custom_id); return { ...this.data, } as SelectMenuType; diff --git a/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts b/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts index 204dcf84a178..5e2b355a5cf5 100644 --- a/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts @@ -6,6 +6,7 @@ import { SelectMenuDefaultValueType, } from 'discord-api-types/v10'; import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js'; +import { parse } from '../../util/validation.js'; import { channelTypesValidator, customIdValidator, optionsLengthValidator } from '../Assertions.js'; import { BaseSelectMenuBuilder } from './BaseSelectMenu.js'; @@ -48,7 +49,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedTypes = normalizeArray(types); this.data.channel_types ??= []; - this.data.channel_types.push(...channelTypesValidator.parse(normalizedTypes)); + this.data.channel_types.push(...parse(channelTypesValidator, normalizedTypes)); return this; } @@ -60,7 +61,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedTypes = normalizeArray(types); this.data.channel_types ??= []; - this.data.channel_types.splice(0, this.data.channel_types.length, ...channelTypesValidator.parse(normalizedTypes)); + this.data.channel_types.splice(0, this.data.channel_types.length, ...parse(channelTypesValidator, normalizedTypes)); return this; } @@ -71,7 +72,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(channels); - optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); + parse(optionsLengthValidator, (this.data.default_values?.length ?? 0) + normalizedValues.length); this.data.default_values ??= []; this.data.default_values.push( @@ -91,7 +92,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(channels); - optionsLengthValidator.parse(normalizedValues.length); + parse(optionsLengthValidator, normalizedValues.length); this.data.default_values = normalizedValues.map((id) => ({ id, @@ -105,7 +106,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(roles); - optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); + parse(optionsLengthValidator, (this.data.default_values?.length ?? 0) + normalizedValues.length); this.data.default_values ??= []; this.data.default_values.push( @@ -66,7 +67,7 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(users); - optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); + parse(optionsLengthValidator, (this.data.default_values?.length ?? 0) + normalizedValues.length); this.data.default_values ??= []; this.data.default_values.push( @@ -91,7 +92,7 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder ) { const normalizedValues = normalizeArray(values); - optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); + parse(optionsLengthValidator, (this.data.default_values?.length ?? 0) + normalizedValues.length); this.data.default_values ??= []; this.data.default_values.push(...normalizedValues); return this; @@ -109,7 +110,7 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder ) { const normalizedValues = normalizeArray(values); - optionsLengthValidator.parse(normalizedValues.length); + parse(optionsLengthValidator, normalizedValues.length); this.data.default_values = normalizedValues; return this; } diff --git a/packages/builders/src/components/selectMenu/RoleSelectMenu.ts b/packages/builders/src/components/selectMenu/RoleSelectMenu.ts index 640be8f81539..5f9c8d5efea1 100644 --- a/packages/builders/src/components/selectMenu/RoleSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/RoleSelectMenu.ts @@ -5,6 +5,7 @@ import { SelectMenuDefaultValueType, } from 'discord-api-types/v10'; import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js'; +import { parse } from '../../util/validation.js'; import { optionsLengthValidator } from '../Assertions.js'; import { BaseSelectMenuBuilder } from './BaseSelectMenu.js'; @@ -45,7 +46,7 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(roles); - optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); + parse(optionsLengthValidator, (this.data.default_values?.length ?? 0) + normalizedValues.length); this.data.default_values ??= []; this.data.default_values.push( @@ -65,7 +66,7 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(roles); - optionsLengthValidator.parse(normalizedValues.length); + parse(optionsLengthValidator, normalizedValues.length); this.data.default_values = normalizedValues.map((id) => ({ id, diff --git a/packages/builders/src/components/selectMenu/StringSelectMenu.ts b/packages/builders/src/components/selectMenu/StringSelectMenu.ts index 9c6542387db0..7425b1a6dfa2 100644 --- a/packages/builders/src/components/selectMenu/StringSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/StringSelectMenu.ts @@ -1,6 +1,7 @@ import { ComponentType } from 'discord-api-types/v10'; import type { APIStringSelectComponent, APISelectMenuOption } from 'discord-api-types/v10'; import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; +import { parse } from '../../util/validation.js'; import { jsonOptionValidator, optionsLengthValidator, validateRequiredSelectMenuParameters } from '../Assertions.js'; import { BaseSelectMenuBuilder } from './BaseSelectMenu.js'; import { StringSelectMenuOptionBuilder } from './StringSelectMenuOption.js'; @@ -58,12 +59,12 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedOptions = normalizeArray(options); - optionsLengthValidator.parse(this.options.length + normalizedOptions.length); + parse(optionsLengthValidator, this.options.length + normalizedOptions.length); this.options.push( ...normalizedOptions.map((normalizedOption) => normalizedOption instanceof StringSelectMenuOptionBuilder ? normalizedOption - : new StringSelectMenuOptionBuilder(jsonOptionValidator.parse(normalizedOption)), + : new StringSelectMenuOptionBuilder(parse(jsonOptionValidator, normalizedOption)), ), ); return this; @@ -120,11 +121,11 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder normalizedOption instanceof StringSelectMenuOptionBuilder ? normalizedOption - : new StringSelectMenuOptionBuilder(jsonOptionValidator.parse(normalizedOption)), + : new StringSelectMenuOptionBuilder(parse(jsonOptionValidator, normalizedOption)), ), ); - optionsLengthValidator.parse(clone.length); + parse(optionsLengthValidator, clone.length); this.options.splice(0, this.options.length, ...clone); return this; } diff --git a/packages/builders/src/components/selectMenu/StringSelectMenuOption.ts b/packages/builders/src/components/selectMenu/StringSelectMenuOption.ts index 3e45970878e2..f66c978f7fdf 100644 --- a/packages/builders/src/components/selectMenu/StringSelectMenuOption.ts +++ b/packages/builders/src/components/selectMenu/StringSelectMenuOption.ts @@ -1,5 +1,6 @@ import type { JSONEncodable } from '@discordjs/util'; import type { APIMessageComponentEmoji, APISelectMenuOption } from 'discord-api-types/v10'; +import { parse } from '../../util/validation.js'; import { defaultValidator, emojiValidator, @@ -41,7 +42,7 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable) { const normalizedValues = normalizeArray(users); - optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); + parse(optionsLengthValidator, (this.data.default_values?.length ?? 0) + normalizedValues.length); this.data.default_values ??= []; this.data.default_values.push( @@ -65,7 +66,7 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(users); - optionsLengthValidator.parse(normalizedValues.length); + parse(optionsLengthValidator, normalizedValues.length); this.data.default_values = normalizedValues.map((id) => ({ id, diff --git a/packages/builders/src/components/textInput/Assertions.ts b/packages/builders/src/components/textInput/Assertions.ts index f0cfc4a3f802..1957bdc353e8 100644 --- a/packages/builders/src/components/textInput/Assertions.ts +++ b/packages/builders/src/components/textInput/Assertions.ts @@ -1,27 +1,28 @@ -import { s } from '@sapphire/shapeshift'; import { TextInputStyle } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../../util/validation.js'; +import { z } from 'zod'; +import { parse } from '../../util/validation.js'; import { customIdValidator } from '../Assertions.js'; -export const textInputStyleValidator = s.nativeEnum(TextInputStyle); -export const minLengthValidator = s.number.int - .greaterThanOrEqual(0) - .lessThanOrEqual(4_000) - .setValidationEnabled(isValidationEnabled); -export const maxLengthValidator = s.number.int - .greaterThanOrEqual(1) - .lessThanOrEqual(4_000) - .setValidationEnabled(isValidationEnabled); -export const requiredValidator = s.boolean; -export const valueValidator = s.string.lengthLessThanOrEqual(4_000).setValidationEnabled(isValidationEnabled); -export const placeholderValidator = s.string.lengthLessThanOrEqual(100).setValidationEnabled(isValidationEnabled); -export const labelValidator = s.string - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(45) - .setValidationEnabled(isValidationEnabled); +export const textInputStyleValidator = z.union([ + z.nativeEnum(TextInputStyle), + z + .enum( + Object.values(TextInputStyle).filter((value) => typeof value === 'string') as [ + keyof typeof TextInputStyle, + ...(keyof typeof TextInputStyle)[], + ], + ) + .transform((key) => TextInputStyle[key]), +]); +export const minLengthValidator = z.number().int().gte(0).lte(4_000); +export const maxLengthValidator = z.number().int().gte(1).lte(4_000); +export const requiredValidator = z.boolean(); +export const valueValidator = z.string().max(4_000); +export const placeholderValidator = z.string().max(100); +export const labelValidator = z.string().min(1).max(45); export function validateRequiredParameters(customId?: string, style?: TextInputStyle, label?: string) { - customIdValidator.parse(customId); - textInputStyleValidator.parse(style); - labelValidator.parse(label); + parse(customIdValidator, customId); + parse(textInputStyleValidator, style); + parse(labelValidator, label); } diff --git a/packages/builders/src/components/textInput/TextInput.ts b/packages/builders/src/components/textInput/TextInput.ts index c8bb1f838746..ed5654c7db4a 100644 --- a/packages/builders/src/components/textInput/TextInput.ts +++ b/packages/builders/src/components/textInput/TextInput.ts @@ -1,6 +1,7 @@ import { isJSONEncodable, type Equatable, type JSONEncodable } from '@discordjs/util'; import { ComponentType, type TextInputStyle, type APITextInputComponent } from 'discord-api-types/v10'; import isEqual from 'fast-deep-equal'; +import { parse } from '../../util/validation.js'; import { customIdValidator } from '../Assertions.js'; import { ComponentBuilder } from '../Component.js'; import { @@ -54,7 +55,7 @@ export class TextInputBuilder * @param customId - The custom id to use */ public setCustomId(customId: string) { - this.data.custom_id = customIdValidator.parse(customId); + this.data.custom_id = parse(customIdValidator, customId); return this; } @@ -64,7 +65,7 @@ export class TextInputBuilder * @param label - The label to use */ public setLabel(label: string) { - this.data.label = labelValidator.parse(label); + this.data.label = parse(labelValidator, label); return this; } @@ -74,7 +75,7 @@ export class TextInputBuilder * @param style - The style to use */ public setStyle(style: TextInputStyle) { - this.data.style = textInputStyleValidator.parse(style); + this.data.style = parse(textInputStyleValidator, style); return this; } @@ -84,7 +85,7 @@ export class TextInputBuilder * @param minLength - The minimum length of text for this text input */ public setMinLength(minLength: number) { - this.data.min_length = minLengthValidator.parse(minLength); + this.data.min_length = parse(minLengthValidator, minLength); return this; } @@ -94,7 +95,7 @@ export class TextInputBuilder * @param maxLength - The maximum length of text for this text input */ public setMaxLength(maxLength: number) { - this.data.max_length = maxLengthValidator.parse(maxLength); + this.data.max_length = parse(maxLengthValidator, maxLength); return this; } @@ -104,7 +105,7 @@ export class TextInputBuilder * @param placeholder - The placeholder to use */ public setPlaceholder(placeholder: string) { - this.data.placeholder = placeholderValidator.parse(placeholder); + this.data.placeholder = parse(placeholderValidator, placeholder); return this; } @@ -114,7 +115,7 @@ export class TextInputBuilder * @param value - The value to use */ public setValue(value: string) { - this.data.value = valueValidator.parse(value); + this.data.value = parse(valueValidator, value); return this; } @@ -124,7 +125,7 @@ export class TextInputBuilder * @param required - Whether this text input is required */ public setRequired(required = true) { - this.data.required = requiredValidator.parse(required); + this.data.required = parse(requiredValidator, required); return this; } diff --git a/packages/builders/src/interactions/contextMenuCommands/Assertions.ts b/packages/builders/src/interactions/contextMenuCommands/Assertions.ts index feb97af4f521..0bc4e2e95d26 100644 --- a/packages/builders/src/interactions/contextMenuCommands/Assertions.ts +++ b/packages/builders/src/interactions/contextMenuCommands/Assertions.ts @@ -1,29 +1,27 @@ -import { s } from '@sapphire/shapeshift'; import { ApplicationCommandType } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../../util/validation.js'; +import { z } from 'zod'; +import { parse } from '../../util/validation.js'; import type { ContextMenuCommandType } from './ContextMenuCommandBuilder.js'; -const namePredicate = s.string - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(32) +const namePredicate = z + .string() + .min(1) + .max(32) // eslint-disable-next-line prefer-named-capture-group - .regex(/^( *[\p{P}\p{L}\p{N}\p{sc=Devanagari}\p{sc=Thai}]+ *)+$/u) - .setValidationEnabled(isValidationEnabled); -const typePredicate = s - .union(s.literal(ApplicationCommandType.User), s.literal(ApplicationCommandType.Message)) - .setValidationEnabled(isValidationEnabled); -const booleanPredicate = s.boolean; + .regex(/^( *[\p{P}\p{L}\p{N}\p{sc=Devanagari}\p{sc=Thai}]+ *)+$/u); +const typePredicate = z.union([z.literal(ApplicationCommandType.User), z.literal(ApplicationCommandType.Message)]); +const booleanPredicate = z.boolean(); export function validateDefaultPermission(value: unknown): asserts value is boolean { - booleanPredicate.parse(value); + parse(booleanPredicate, value); } export function validateName(name: unknown): asserts name is string { - namePredicate.parse(name); + parse(namePredicate, name); } export function validateType(type: unknown): asserts type is ContextMenuCommandType { - typePredicate.parse(type); + parse(typePredicate, type); } export function validateRequiredParameters(name: string, type: number) { @@ -34,18 +32,24 @@ export function validateRequiredParameters(name: string, type: number) { validateType(type); } -const dmPermissionPredicate = s.boolean.nullish; +const dmPermissionPredicate = z.boolean().nullish(); export function validateDMPermission(value: unknown): asserts value is boolean | null | undefined { - dmPermissionPredicate.parse(value); + parse(dmPermissionPredicate, value); } -const memberPermissionPredicate = s.union( - s.bigint.transform((value) => value.toString()), - s.number.safeInt.transform((value) => value.toString()), - s.string.regex(/^\d+$/), -).nullish; +const memberPermissionPredicate = z + .union([ + z.bigint().transform((value) => value.toString()), + z + .number() + .int() + .safe() + .transform((value) => value.toString()), + z.string().regex(/^\d+$/), + ]) + .nullish(); export function validateDefaultMemberPermissions(permissions: unknown) { - return memberPermissionPredicate.parse(permissions); + return parse(memberPermissionPredicate, permissions); } diff --git a/packages/builders/src/interactions/modals/Assertions.ts b/packages/builders/src/interactions/modals/Assertions.ts index a59bb24cfe62..c61b974048a4 100644 --- a/packages/builders/src/interactions/modals/Assertions.ts +++ b/packages/builders/src/interactions/modals/Assertions.ts @@ -1,23 +1,17 @@ -import { s } from '@sapphire/shapeshift'; +import { z } from 'zod'; import { ActionRowBuilder, type ModalActionRowComponentBuilder } from '../../components/ActionRow.js'; import { customIdValidator } from '../../components/Assertions.js'; -import { isValidationEnabled } from '../../util/validation.js'; +import { parse } from '../../util/validation.js'; -export const titleValidator = s.string - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(45) - .setValidationEnabled(isValidationEnabled); -export const componentsValidator = s - .instance(ActionRowBuilder) - .array.lengthGreaterThanOrEqual(1) - .setValidationEnabled(isValidationEnabled); +export const titleValidator = z.string().min(1).max(45); +export const componentsValidator = z.instanceof(ActionRowBuilder).array().min(1); export function validateRequiredParameters( customId?: string, title?: string, components?: ActionRowBuilder[], ) { - customIdValidator.parse(customId); - titleValidator.parse(title); - componentsValidator.parse(components); + parse(customIdValidator, customId); + parse(titleValidator, title); + parse(componentsValidator, components); } diff --git a/packages/builders/src/interactions/modals/Modal.ts b/packages/builders/src/interactions/modals/Modal.ts index 948d774df203..0343f252600a 100644 --- a/packages/builders/src/interactions/modals/Modal.ts +++ b/packages/builders/src/interactions/modals/Modal.ts @@ -10,6 +10,7 @@ import { ActionRowBuilder, type ModalActionRowComponentBuilder } from '../../com import { customIdValidator } from '../../components/Assertions.js'; import { createComponentBuilder } from '../../components/Components.js'; import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; +import { parse } from '../../util/validation.js'; import { titleValidator, validateRequiredParameters } from './Assertions.js'; /** @@ -43,7 +44,7 @@ export class ModalBuilder implements JSONEncodable(input: unknown, ExpectedInstanceOf: new () => ReturnType): asserts input is ReturnType { - s.instance(ExpectedInstanceOf).parse(input); + parse(z.instanceof(ExpectedInstanceOf), input); } -export const localizationMapPredicate = s - .object(Object.fromEntries(Object.values(Locale).map((locale) => [locale, s.string.nullish]))) - .strict.nullish.setValidationEnabled(isValidationEnabled); +export const localizationMapPredicate = z.record(z.nativeEnum(Locale), z.string().nullish()).nullish(); export function validateLocalizationMap(value: unknown): asserts value is LocalizationMap { - localizationMapPredicate.parse(value); + parse(localizationMapPredicate, value); } -const dmPermissionPredicate = s.boolean.nullish; +const dmPermissionPredicate = z.boolean().nullish(); export function validateDMPermission(value: unknown): asserts value is boolean | null | undefined { - dmPermissionPredicate.parse(value); + parse(dmPermissionPredicate, value); } -const memberPermissionPredicate = s.union( - s.bigint.transform((value) => value.toString()), - s.number.safeInt.transform((value) => value.toString()), - s.string.regex(/^\d+$/), -).nullish; +const memberPermissionPredicate = z + .union([ + z.bigint().transform((value) => value.toString()), + z + .number() + .int() + .safe() + .transform((value) => value.toString()), + z.string().regex(/^\d+$/), + ]) + .nullish(); export function validateDefaultMemberPermissions(permissions: unknown) { - return memberPermissionPredicate.parse(permissions); + return parse(memberPermissionPredicate, permissions); } export function validateNSFW(value: unknown): asserts value is boolean { - booleanPredicate.parse(value); + parse(booleanPredicate, value); } diff --git a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionChannelTypesMixin.ts b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionChannelTypesMixin.ts index 83cb16b2b12c..4f7889a34734 100644 --- a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionChannelTypesMixin.ts +++ b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionChannelTypesMixin.ts @@ -1,6 +1,7 @@ -import { s } from '@sapphire/shapeshift'; import { ChannelType } from 'discord-api-types/v10'; +import { z } from 'zod'; import { normalizeArray, type RestOrArray } from '../../../util/normalizeArray'; +import { parse } from '../../../util/validation.js'; /** * The allowed channel types used for a channel option in a slash command builder. @@ -26,7 +27,13 @@ const allowedChannelTypes = [ */ export type ApplicationCommandOptionAllowedChannelTypes = (typeof allowedChannelTypes)[number]; -const channelTypesPredicate = s.array(s.union(...allowedChannelTypes.map((type) => s.literal(type)))); +const channelTypesPredicate = z.array( + z.union([ + z.literal(allowedChannelTypes[0]), + z.literal(allowedChannelTypes[1]), + ...allowedChannelTypes.slice(2).map((type) => z.literal(type)), + ]), +); /** * This mixin holds channel type symbols used for options. @@ -47,7 +54,7 @@ export class ApplicationCommandOptionChannelTypesMixin { Reflect.set(this, 'channel_types', []); } - this.channel_types!.push(...channelTypesPredicate.parse(normalizeArray(channelTypes))); + this.channel_types!.push(...parse(channelTypesPredicate, normalizeArray(channelTypes))); return this; } diff --git a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithAutocompleteMixin.ts b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithAutocompleteMixin.ts index 550768f3b8d7..8f0a18d7db1e 100644 --- a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithAutocompleteMixin.ts +++ b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithAutocompleteMixin.ts @@ -1,7 +1,8 @@ -import { s } from '@sapphire/shapeshift'; import type { ApplicationCommandOptionType } from 'discord-api-types/v10'; +import { z } from 'zod'; +import { parse } from '../../../util/validation.js'; -const booleanPredicate = s.boolean; +const booleanPredicate = z.boolean(); /** * This mixin holds choices and autocomplete symbols used for options. @@ -26,7 +27,7 @@ export class ApplicationCommandOptionWithAutocompleteMixin { */ public setAutocomplete(autocomplete: boolean): this { // Assert that you actually passed a boolean - booleanPredicate.parse(autocomplete); + parse(booleanPredicate, autocomplete); if (autocomplete && 'choices' in this && Array.isArray(this.choices) && this.choices.length > 0) { throw new RangeError('Autocomplete and choices are mutually exclusive to each other.'); diff --git a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesMixin.ts b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesMixin.ts index 6bc75d24a44e..bd0c3ff733b6 100644 --- a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesMixin.ts +++ b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesMixin.ts @@ -1,15 +1,18 @@ -import { s } from '@sapphire/shapeshift'; import { ApplicationCommandOptionType, type APIApplicationCommandOptionChoice } from 'discord-api-types/v10'; +import { z } from 'zod'; import { normalizeArray, type RestOrArray } from '../../../util/normalizeArray.js'; +import { parse } from '../../../util/validation.js'; import { localizationMapPredicate, validateChoicesLength } from '../Assertions.js'; -const stringPredicate = s.string.lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100); -const numberPredicate = s.number.greaterThan(Number.NEGATIVE_INFINITY).lessThan(Number.POSITIVE_INFINITY); -const choicesPredicate = s.object({ - name: stringPredicate, - name_localizations: localizationMapPredicate, - value: s.union(stringPredicate, numberPredicate), -}).array; +const stringPredicate = z.string().min(1).max(100); +const numberPredicate = z.number().gt(Number.NEGATIVE_INFINITY).lt(Number.POSITIVE_INFINITY); +const choicesPredicate = z + .object({ + name: stringPredicate, + name_localizations: localizationMapPredicate, + value: z.union([stringPredicate, numberPredicate]), + }) + .array(); /** * This mixin holds choices and autocomplete symbols used for options. @@ -38,7 +41,7 @@ export class ApplicationCommandOptionWithChoicesMixinhttps?|attachment):\/\//) + .nullish(); + +export const urlPredicate = z + .string() + .url() + .regex(/^https?:\/\//) + .nullish(); + +export const embedAuthorPredicate = z.object({ + name: authorNamePredicate, + iconURL: imageURLPredicate, + url: urlPredicate, +}); + +export const RGBPredicate = z.number().int().min(0).max(255); +export const colorPredicate = z + .number() + .int() + .min(0) + .max(0xffffff) + .or(z.tuple([RGBPredicate, RGBPredicate, RGBPredicate])) + .nullable(); + +export const descriptionPredicate = z.string().min(1).max(4_096).nullable(); + +export const footerTextPredicate = z.string().min(1).max(2_048).nullable(); + +export const embedFooterPredicate = z.object({ + text: footerTextPredicate, + iconURL: imageURLPredicate, +}); + +export const timestampPredicate = z.union([z.number(), z.date()]).nullable(); + +export const titlePredicate = fieldNamePredicate.nullable(); diff --git a/packages/builders/src/messages/embed/Embed.ts b/packages/builders/src/messages/embed/Embed.ts index 683e0598c188..d7ed9ec64fc0 100644 --- a/packages/builders/src/messages/embed/Embed.ts +++ b/packages/builders/src/messages/embed/Embed.ts @@ -1,5 +1,6 @@ import type { APIEmbed, APIEmbedAuthor, APIEmbedField, APIEmbedFooter, APIEmbedImage } from 'discord-api-types/v10'; import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; +import { parse } from '../../util/validation.js'; import { colorPredicate, descriptionPredicate, @@ -113,7 +114,7 @@ export class EmbedBuilder { validateFieldLength(normalizedFields.length, this.data.fields); // Data assertions - embedFieldsArrayPredicate.parse(normalizedFields); + parse(embedFieldsArrayPredicate, normalizedFields); if (this.data.fields) this.data.fields.push(...normalizedFields); else this.data.fields = normalizedFields; @@ -154,7 +155,8 @@ export class EmbedBuilder { validateFieldLength(fields.length - deleteCount, this.data.fields); // Data assertions - embedFieldsArrayPredicate.parse(fields); + parse(embedFieldsArrayPredicate, fields); + if (this.data.fields) this.data.fields.splice(index, deleteCount, ...fields); else this.data.fields = fields; return this; @@ -188,7 +190,7 @@ export class EmbedBuilder { } // Data assertions - embedAuthorPredicate.parse(options); + parse(embedAuthorPredicate, options); this.data.author = { name: options.name, url: options.url, icon_url: options.iconURL }; return this; @@ -201,7 +203,7 @@ export class EmbedBuilder { */ public setColor(color: RGBTuple | number | null): this { // Data assertions - colorPredicate.parse(color); + parse(colorPredicate, color); if (Array.isArray(color)) { const [red, green, blue] = color; @@ -220,7 +222,7 @@ export class EmbedBuilder { */ public setDescription(description: string | null): this { // Data assertions - descriptionPredicate.parse(description); + parse(descriptionPredicate, description); this.data.description = description ?? undefined; return this; @@ -238,7 +240,7 @@ export class EmbedBuilder { } // Data assertions - embedFooterPredicate.parse(options); + parse(embedFooterPredicate, options); this.data.footer = { text: options.text, icon_url: options.iconURL }; return this; @@ -251,7 +253,7 @@ export class EmbedBuilder { */ public setImage(url: string | null): this { // Data assertions - imageURLPredicate.parse(url); + parse(imageURLPredicate, url); this.data.image = url ? { url } : undefined; return this; @@ -264,7 +266,7 @@ export class EmbedBuilder { */ public setThumbnail(url: string | null): this { // Data assertions - imageURLPredicate.parse(url); + parse(imageURLPredicate, url); this.data.thumbnail = url ? { url } : undefined; return this; @@ -277,7 +279,7 @@ export class EmbedBuilder { */ public setTimestamp(timestamp: Date | number | null = Date.now()): this { // Data assertions - timestampPredicate.parse(timestamp); + parse(timestampPredicate, timestamp); this.data.timestamp = timestamp ? new Date(timestamp).toISOString() : undefined; return this; @@ -290,7 +292,7 @@ export class EmbedBuilder { */ public setTitle(title: string | null): this { // Data assertions - titlePredicate.parse(title); + parse(titlePredicate, title); this.data.title = title ?? undefined; return this; @@ -303,7 +305,7 @@ export class EmbedBuilder { */ public setURL(url: string | null): this { // Data assertions - urlPredicate.parse(url); + parse(urlPredicate, url); this.data.url = url ?? undefined; return this; diff --git a/packages/builders/src/util/validation.ts b/packages/builders/src/util/validation.ts index 37e5c224bc6e..8d5924521bde 100644 --- a/packages/builders/src/util/validation.ts +++ b/packages/builders/src/util/validation.ts @@ -1,3 +1,6 @@ +import type { ZodTypeAny, output } from 'zod'; +import { fromZodError } from 'zod-validation-error'; + let validate = true; /** @@ -24,3 +27,20 @@ export function disableValidators() { export function isValidationEnabled() { return validate; } + +/** + * Parses a value with a given validator + * + * @param validator - Tthe zod validator to use + * @param value - The value to parse + * @returns The result from parsing + * @internal + */ +export function parse(validator: Validator, value: unknown): output { + const result = validator.safeParse(value); + if (isValidationEnabled() && !result.success) { + throw fromZodError(result.error); + } + + return result.success ? result.data : value; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f4f9cc89595..ef5829485dd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -676,9 +676,6 @@ importers: '@discordjs/util': specifier: workspace:^ version: link:../util - '@sapphire/shapeshift': - specifier: ^3.9.7 - version: 3.9.7 discord-api-types: specifier: 0.37.87 version: 0.37.87 @@ -691,6 +688,12 @@ importers: tslib: specifier: ^2.6.2 version: 2.6.2 + zod: + specifier: ^3.22.4 + version: 3.22.4 + zod-validation-error: + specifier: ^3.0.0 + version: 3.3.0(zod@3.22.4) devDependencies: '@discordjs/api-extractor': specifier: workspace:^ @@ -5208,10 +5211,6 @@ packages: resolution: {integrity: sha512-QCjj7X/QlY0QUCeAaZQmnrsMH/b2BMQYee3F1Y5iF17JagUQqO3KZlG7vfXWQU3SRAJX5OgZZynBjixUH+nNGg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - '@sapphire/shapeshift@3.9.7': - resolution: {integrity: sha512-4It2mxPSr4OGn4HSQWGmhFMsNFGfFVhWeRPCRwbH972Ek2pzfGRZtb0pJ4Ze6oIzcyh2jw7nUDa6qGlWofgd9g==} - engines: {node: '>=v16'} - '@sapphire/snowflake@3.5.3': resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} @@ -14023,6 +14022,12 @@ packages: peerDependencies: zod: ^3.18.0 + zod-validation-error@3.3.0: + resolution: {integrity: sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.18.0 + zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} @@ -18565,11 +18570,6 @@ snapshots: '@sapphire/result@2.6.6': {} - '@sapphire/shapeshift@3.9.7': - dependencies: - fast-deep-equal: 3.1.3 - lodash: 4.17.21 - '@sapphire/snowflake@3.5.3': {} '@sapphire/utilities@3.13.0': {} @@ -30997,6 +30997,10 @@ snapshots: dependencies: zod: 3.22.4 + zod-validation-error@3.3.0(zod@3.22.4): + dependencies: + zod: 3.22.4 + zod@3.22.4: {} zwitch@2.0.4: {}