diff --git a/AGENTS.md b/AGENTS.md index e7549dcbe..7bfabad76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ - one file can have many components - all texts should be provided translations via the i18next library's `useTranslations` hook's `t` function - instead of `&&` operator for conditional rendering, use the ternary operator -- fixed-field mutations (an `_action` plus hidden inputs) use `` which type checks the action and fields against the route's zod action schema; real multi-input forms instead pass `schema` alongside `_action` to `SubmitButton`; enforced by the `no-raw-action-forms` Biome plugin +- fixed-field mutations (an `_action` plus hidden inputs) use `` which type checks the action and fields against the route's action schema; real multi-input forms instead pass `schema` alongside `_action` to `SubmitButton`; enforced by the `no-raw-action-forms` Biome plugin - for localized user-readable time strings use ``, `` or `useFormatDistanceToNow`. If needed use `useDateTimeFormat` directly. NEVER use e.g. `toLocaleString` directly as it does not include users' language selection. ## Remix/React Router diff --git a/README.md b/README.md index ba87f4ba9..3bb622c9b 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ For developers reading the [architecture.md](./docs/dev/architecture.md) file is - **UI Library**: React Aria Components - **Database**: SQLite3 (via Kysely) - **Styling**: CSS Modules -- **Validation**: Zod +- **Validation**: Valibot - **Internationalization**: i18next - **Testing**: - End-to-End (E2E): Playwright diff --git a/app/components/ActionButton.tsx b/app/components/ActionButton.tsx index 0e0ca6848..dbe76c341 100644 --- a/app/components/ActionButton.tsx +++ b/app/components/ActionButton.tsx @@ -1,17 +1,17 @@ import type * as React from "react"; import { type FetcherWithComponents, useFetcher } from "react-router"; -import type { z } from "zod"; import { type ActionsOf, type FieldsOf, serializeFieldValue, } from "~/utils/action-schemas"; +import type { AnySchema } from "~/utils/schema"; import { SendouButton, type SendouButtonProps } from "./elements/Button"; import { FormWithConfirm } from "./FormWithConfirm"; import { SubmitButton } from "./SubmitButton"; interface ActionButtonBaseProps< - TSchema extends z.ZodTypeAny, + TSchema extends AnySchema, TAction extends ActionsOf, > extends Omit { /** Action schema of the route the button submits to. Only used for typing `action` and `fields`. */ @@ -33,7 +33,7 @@ interface ActionButtonBaseProps< } type ActionButtonProps< - TSchema extends z.ZodTypeAny, + TSchema extends AnySchema, TAction extends ActionsOf, > = ActionButtonBaseProps & // biome-ignore lint/complexity/noBannedTypes: {} models "branch with no extra fields" @@ -43,7 +43,7 @@ type ActionButtonProps< /** * Button that submits a mutation to a route action as `_action` + hidden fields, - * type checked against the route's zod action schema. + * type checked against the route's action schema. * * @example * */ export function ActionButton< - TSchema extends z.ZodTypeAny, + TSchema extends AnySchema, const TAction extends ActionsOf, >({ schema, diff --git a/app/components/CustomThemeSelector.tsx b/app/components/CustomThemeSelector.tsx index 8b27dbea5..f8c91295b 100644 --- a/app/components/CustomThemeSelector.tsx +++ b/app/components/CustomThemeSelector.tsx @@ -1,6 +1,7 @@ import { Check, Clipboard, PencilLine } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; +import * as v from "valibot"; import type { CustomTheme } from "~/db/tables-json"; import { CUSTOM_THEME_VARS, @@ -13,7 +14,7 @@ import { clampThemeToGamut, type ThemeInput, } from "~/utils/oklch-gamut"; -import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/zod"; +import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/schema"; import styles from "./CustomThemeSelector.module.css"; import { Divider } from "./Divider"; import { LinkButton, SendouButton } from "./elements/Button"; @@ -165,8 +166,8 @@ function themeInputFromString(str: string): ThemeInput | null { raw[key] = num; } - const parsed = themeInputSchema.safeParse(raw); - return parsed.success ? parsed.data : null; + const parsed = v.safeParse(themeInputSchema, raw); + return parsed.success ? parsed.output : null; } const DEFAULT_THEME_INPUT: ThemeInput = { diff --git a/app/components/SubmitButton.tsx b/app/components/SubmitButton.tsx index 87506037c..39d884eac 100644 --- a/app/components/SubmitButton.tsx +++ b/app/components/SubmitButton.tsx @@ -1,9 +1,9 @@ import { type FetcherWithComponents, useNavigation } from "react-router"; -import type { z } from "zod"; import type { ActionsOf } from "~/utils/action-schemas"; +import type { AnySchema } from "~/utils/schema"; import { SendouButton, type SendouButtonProps } from "./elements/Button"; -type SubmitButtonProps = SendouButtonProps & { +type SubmitButtonProps = SendouButtonProps & { /** If the page has multiple forms you can pass in fetcher.state to differentiate when this SubmitButton should be in submitting state */ state?: FetcherWithComponents["state"]; testId?: string; @@ -17,7 +17,7 @@ type SubmitButtonProps = SendouButtonProps & { | { schema?: never; _action?: never } ); -export function SubmitButton({ +export function SubmitButton({ children, state, schema: _schema, diff --git a/app/components/layout/global-search-persisted.ts b/app/components/layout/global-search-persisted.ts index f5c644dd4..be382df36 100644 --- a/app/components/layout/global-search-persisted.ts +++ b/app/components/layout/global-search-persisted.ts @@ -1,9 +1,9 @@ -import { z } from "zod"; +import * as v from "valibot"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import { usePersistedState } from "~/modules/persisted-state/hooks"; import * as PersistedState from "~/modules/persisted-state/persisted-state"; -import { numericEnum } from "~/utils/zod"; +import { numericEnum } from "~/utils/schema"; import { GLOBAL_SEARCH_TYPES } from "./global-search-search-params"; const MAX_RECENT_WEAPONS = 5; @@ -11,14 +11,14 @@ const MAX_RECENT_WEAPONS = 5; export const searchTypePersisted = PersistedState.define({ key: "global-search-search-type", storage: "local", - schema: z.enum(GLOBAL_SEARCH_TYPES), + schema: v.picklist(GLOBAL_SEARCH_TYPES), default: "weapons", }); export const recentWeaponsPersisted = PersistedState.define({ key: "command-palette-recent-weapons", storage: "local", - schema: z.array(numericEnum(mainWeaponIds)), + schema: v.array(numericEnum(mainWeaponIds)), default: [], }); diff --git a/app/components/layout/global-search-search-params.ts b/app/components/layout/global-search-search-params.ts index 16b04f6a8..d8819dc5e 100644 --- a/app/components/layout/global-search-search-params.ts +++ b/app/components/layout/global-search-search-params.ts @@ -1,8 +1,8 @@ -import { z } from "zod"; +import * as v from "valibot"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import { numericEnum } from "~/utils/zod"; +import { numericEnum } from "~/utils/schema"; export const GLOBAL_SEARCH_TYPES = [ "weapons", @@ -14,7 +14,9 @@ export const GLOBAL_SEARCH_TYPES = [ export type GlobalSearchType = (typeof GLOBAL_SEARCH_TYPES)[number]; export const globalSearchSearchParams = SearchParams.define({ - search: SP.param(z.enum(["open"]).nullable(), { loader: false }), - type: SP.param(z.enum(GLOBAL_SEARCH_TYPES).nullable(), { loader: false }), - weapon: SP.param(numericEnum(mainWeaponIds).nullable(), { loader: false }), + search: SP.param(v.nullable(v.picklist(["open"])), { loader: false }), + type: SP.param(v.nullable(v.picklist(GLOBAL_SEARCH_TYPES)), { + loader: false, + }), + weapon: SP.param(v.nullable(numericEnum(mainWeaponIds)), { loader: false }), }); diff --git a/app/components/layout/layout-search-params.ts b/app/components/layout/layout-search-params.ts index 09f0ed827..3028ef6f4 100644 --- a/app/components/layout/layout-search-params.ts +++ b/app/components/layout/layout-search-params.ts @@ -1,7 +1,7 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; export const authErrorSearchParams = SearchParams.define({ - authError: SP.param(z.string().nullable(), { loader: false }), + authError: SP.param(v.nullable(v.string()), { loader: false }), }); diff --git a/app/components/match-page/match-page-schemas.ts b/app/components/match-page/match-page-schemas.ts index 38389c41d..eae8e8707 100644 --- a/app/components/match-page/match-page-schemas.ts +++ b/app/components/match-page/match-page-schemas.ts @@ -1,21 +1,21 @@ -import { z } from "zod"; -import { _action, weaponSplId } from "~/utils/zod"; +import * as v from "valibot"; +import { _action, coerceNumber, weaponSplId } from "~/utils/schema"; -const reportedMapIndex = z.coerce.number().int().nonnegative(); +const reportedMapIndex = v.pipe(coerceNumber(), v.integer(), v.minValue(0)); -export const reportWeaponSchema = z.object({ +export const reportWeaponSchema = v.object({ _action: _action("REPORT_WEAPON"), weaponSplId, mapIndex: reportedMapIndex, }); -export const undoWeaponReportSchema = z.object({ +export const undoWeaponReportSchema = v.object({ _action: _action("UNDO_WEAPON_REPORT"), mapIndex: reportedMapIndex, }); /** Weapon reporting actions shared by every match page route action schema. */ -export const weaponReportActionSchema = z.union([ +export const weaponReportActionSchema = v.union([ reportWeaponSchema, undoWeaponReportSchema, ]); diff --git a/app/components/match-page/match-page-search-params.ts b/app/components/match-page/match-page-search-params.ts index 4c26a644e..5ce766932 100644 --- a/app/components/match-page/match-page-search-params.ts +++ b/app/components/match-page/match-page-search-params.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; @@ -11,5 +11,5 @@ const MATCH_PAGE_TABS = [ ] as const; export const matchPageSearchParams = SearchParams.define({ - tab: SP.param(z.enum(MATCH_PAGE_TABS).nullable(), { loader: false }), + tab: SP.param(v.nullable(v.picklist(MATCH_PAGE_TABS)), { loader: false }), }); diff --git a/app/config-helpers.server.ts b/app/config-helpers.server.ts deleted file mode 100644 index 1bbce4fe5..000000000 --- a/app/config-helpers.server.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { z } from "zod"; - -/** - * Builds an `Error` with a readable, multi-line message describing every invalid - * environment variable. Schemas are keyed by the literal env var name so the - * issue path points straight at the variable a contributor needs to fix. - */ -export function formatEnvErrors( - scope: "client" | "server", - error: z.ZodError, -): Error { - const lines = error.issues.map((issue) => { - const name = issue.path.join(".") || "(unknown)"; - return ` - ${name}: ${issue.message}`; - }); - - return new Error( - `Invalid ${scope} environment configuration:\n${lines.join( - "\n", - )}\n\nSee .env.example for the full list of variables and how to set them.`, - ); -} - -/** - * String schema that must be set to a non-empty value in production, but falls - * back to `devFallback` outside of production so contributors can run the app - * without configuring every integration. - */ -export function requiredInProd(isProd: boolean, devFallback: string) { - return isProd - ? z.string({ message: "required in production" }).min(1, { - message: "required in production (cannot be empty)", - }) - : z.string().default(devFallback); -} diff --git a/app/config-helpers.ts b/app/config-helpers.ts new file mode 100644 index 000000000..46682f611 --- /dev/null +++ b/app/config-helpers.ts @@ -0,0 +1,60 @@ +import * as v from "valibot"; + +const TRUTHY_ENV_VALUES = ["true", "1", "yes", "on", "y", "enabled"]; +const FALSY_ENV_VALUES = ["false", "0", "no", "off", "n", "disabled"]; + +/** + * Builds an `Error` with a readable, multi-line message describing every invalid + * environment variable. Schemas are keyed by the literal env var name so the + * issue path points straight at the variable a contributor needs to fix. + */ +export function formatEnvErrors( + scope: "client" | "server", + issues: readonly v.BaseIssue[], +): Error { + const lines = issues.map((issue) => { + const name = issue.path?.map((item) => item.key).join(".") || "(unknown)"; + return ` - ${name}: ${issue.message}`; + }); + + return new Error( + `Invalid ${scope} environment configuration:\n${lines.join( + "\n", + )}\n\nSee .env.example for the full list of variables and how to set them.`, + ); +} + +/** + * String schema that must be set to a non-empty value in production, but falls + * back to `devFallback` outside of production so contributors can run the app + * without configuring every integration. + */ +export function requiredInProd(isProd: boolean, devFallback: string) { + // The production branch defaults to `""` rather than being required outright + // so that a missing variable reaches `minLength` and reports the same + // actionable message an empty one does, instead of valibot's "Invalid key". + return isProd + ? v.pipe( + v.optional(v.string(), ""), + v.minLength(1, "required in production"), + ) + : v.optional(v.string(), devFallback); +} + +/** Boolean parsed from a boolean-like string environment variable (e.g. `"true"`, `"0"`, `"on"`). */ +export const envBoolean = v.pipe( + v.string(), + v.check( + (value) => isTruthyEnvValue(value) || isFalsyEnvValue(value), + 'must be a boolean-like string (e.g. "true" or "false")', + ), + v.transform(isTruthyEnvValue), +); + +function isTruthyEnvValue(value: string) { + return TRUTHY_ENV_VALUES.includes(value.toLowerCase()); +} + +function isFalsyEnvValue(value: string) { + return FALSY_ENV_VALUES.includes(value.toLowerCase()); +} diff --git a/app/config.server.ts b/app/config.server.ts index cf0772842..ed5fc7bad 100644 --- a/app/config.server.ts +++ b/app/config.server.ts @@ -1,6 +1,7 @@ -import { z } from "zod"; -import { formatEnvErrors, requiredInProd } from "./config-helpers.server"; +import * as v from "valibot"; +import { envBoolean, formatEnvErrors, requiredInProd } from "./config-helpers"; import { IS_E2E_TEST_RUN } from "./utils/e2e"; +import { superRefine, type ValidationCtx } from "./utils/schema"; /** * Server (`process.env`) configuration. Import with @@ -14,16 +15,17 @@ import { IS_E2E_TEST_RUN } from "./utils/e2e"; const isProd = process.env.NODE_ENV === "production" && !IS_E2E_TEST_RUN; -const schema = z - .object({ - NODE_ENV: z - .enum(["development", "production", "test"]) - .default("development"), +const schema = v.pipe( + v.object({ + NODE_ENV: v.optional( + v.picklist(["development", "production", "test"]), + "development", + ), DB_PATH: requiredInProd(isProd, "db.sqlite3"), SESSION_SECRET: requiredInProd(isProd, "secret"), LOHI_TOKEN: requiredInProd(isProd, "salmon"), - SQL_LOG: z.enum(["none", "trunc", "full"]).default("none"), - DISABLE_CACHE: z.stringbool().default(false), + SQL_LOG: v.optional(v.picklist(["none", "trunc", "full"]), "none"), + DISABLE_CACHE: v.optional(envBoolean, "false"), DISCORD_CLIENT_ID: requiredInProd(isProd, ""), DISCORD_CLIENT_SECRET: requiredInProd(isProd, ""), @@ -34,30 +36,31 @@ const schema = z STORAGE_REGION: requiredInProd(isProd, "us-east-1"), STORAGE_BUCKET: requiredInProd(isProd, "sendou"), - SKALOP_SYSTEM_MESSAGE_URL: z.string().optional(), - SKALOP_TOKEN: z.string().optional(), + SKALOP_SYSTEM_MESSAGE_URL: v.optional(v.string()), + SKALOP_TOKEN: v.optional(v.string()), - TWITCH_CLIENT_ID: z.string().optional(), - TWITCH_CLIENT_SECRET: z.string().optional(), + TWITCH_CLIENT_ID: v.optional(v.string()), + TWITCH_CLIENT_SECRET: v.optional(v.string()), - PATREON_ACCESS_TOKEN: z.string().optional(), + PATREON_ACCESS_TOKEN: v.optional(v.string()), // The VAPID public key (VITE_VAPID_PUBLIC_KEY) lives in `~/config` since // it is client-readable; the full three-var coupling is completed by the // runtime check in webPush.server.ts. - VAPID_PRIVATE_KEY: z.string().optional(), - VAPID_EMAIL: z.string().optional(), - }) - .superRefine((val, ctx) => { + VAPID_PRIVATE_KEY: v.optional(v.string()), + VAPID_EMAIL: v.optional(v.string()), + }), + superRefine((val, ctx) => { requireTogether(ctx, val, "TWITCH_CLIENT_ID", "TWITCH_CLIENT_SECRET"); requireTogether(ctx, val, "VAPID_EMAIL", "VAPID_PRIVATE_KEY"); - }); + }), +); -const parsed = schema.safeParse(process.env); +const parsed = v.safeParse(schema, process.env); if (!parsed.success) { - throw formatEnvErrors("server", parsed.error); + throw formatEnvErrors("server", parsed.issues); } -const values = parsed.data; +const values = parsed.output; export const ServerConfig = { /** @@ -122,7 +125,7 @@ export const ServerConfig = { /** Adds a validation issue unless `a` and `b` are both set or both unset. */ function requireTogether( - ctx: z.RefinementCtx, + ctx: ValidationCtx, values: Record, a: string, b: string, @@ -134,7 +137,6 @@ function requireTogether( const present = aSet ? a : b; const missing = aSet ? b : a; ctx.addIssue({ - code: "custom", path: [missing], message: `must be set together with ${present}`, }); diff --git a/app/config.ts b/app/config.ts index c0a07b226..d154a905d 100644 --- a/app/config.ts +++ b/app/config.ts @@ -1,3 +1,5 @@ +import * as v from "valibot"; +import { envBoolean, formatEnvErrors, requiredInProd } from "./config-helpers"; import { IS_E2E_TEST_RUN } from "./utils/e2e"; /** @@ -7,9 +9,6 @@ import { IS_E2E_TEST_RUN } from "./utils/e2e"; * Values are validated once when this module is first imported, surfacing a * single clear error for any misconfigured variable. Variables required in * production fall back to development defaults outside of production. - * - * Note: this module ships in the critical client bundle so it must stay free of - * heavy dependencies (e.g. zod, which the server config uses). */ // `import.meta.env` is undefined when Playwright bundles test code, so guard the @@ -24,34 +23,37 @@ const isProd = import.meta.env.PROD === true && !IS_E2E_TEST_RUN; -const TRUTHY_STRINGS = ["true", "1", "yes", "on", "y", "enabled"]; -const FALSY_STRINGS = ["false", "0", "no", "off", "n", "disabled"]; - -const issues: Array<{ name: string; message: string }> = []; - -const values = { - VITE_SITE_DOMAIN: requiredInProd("VITE_SITE_DOMAIN", "http://localhost:5173"), +const schema = v.object({ + VITE_SITE_DOMAIN: requiredInProd(isProd, "http://localhost:5173"), VITE_TOURNAMENT_DEFAULT_LOGO: requiredInProd( - "VITE_TOURNAMENT_DEFAULT_LOGO", + isProd, "tournament-logo-default.avif", ), - VITE_STATIC_ASSETS_URL: withDefault( - "VITE_STATIC_ASSETS_URL", + VITE_STATIC_ASSETS_URL: v.optional( + v.string(), "https://sendou-assets.nyc3.cdn.digitaloceanspaces.com", ), - VITE_PROD_MODE: stringBool("VITE_PROD_MODE"), - VITE_SHOW_LUTI_NAV_ITEM: stringBool("VITE_SHOW_LUTI_NAV_ITEM"), - VITE_FUSE_ENABLED: stringBool("VITE_FUSE_ENABLED"), - VITE_SCANNER_ENABLED: stringBool("VITE_SCANNER_ENABLED"), - VITE_LEAGUE_GOOGLE_FORM_URL: env.VITE_LEAGUE_GOOGLE_FORM_URL, - VITE_SHOW_BANNER_FOR_SEASON: env.VITE_SHOW_BANNER_FOR_SEASON, - VITE_SKALOP_WS_URL: env.VITE_SKALOP_WS_URL, - VITE_VAPID_PUBLIC_KEY: env.VITE_VAPID_PUBLIC_KEY, -}; -if (issues.length > 0) { - throw envError(issues); + VITE_PROD_MODE: v.optional(envBoolean, "false"), + VITE_SHOW_LUTI_NAV_ITEM: v.optional(envBoolean, "false"), + VITE_FUSE_ENABLED: v.optional(envBoolean, "false"), + VITE_SCANNER_ENABLED: v.optional(envBoolean, "false"), + + VITE_LEAGUE_GOOGLE_FORM_URL: v.optional(v.string()), + VITE_SHOW_BANNER_FOR_SEASON: v.optional(v.string()), + VITE_SKALOP_WS_URL: v.optional(v.string()), + + // The VAPID private key and email live in `~/config.server` since they are + // server-only; the full three-var coupling is completed by the runtime check + // in webPush.server.ts. + VITE_VAPID_PUBLIC_KEY: v.optional(v.string()), +}); + +const parsed = v.safeParse(schema, env); +if (!parsed.success) { + throw formatEnvErrors("client", parsed.issues); } +const values = parsed.output; export const Config = { /** Base URL of the site, e.g. `https://sendou.ink`. */ @@ -80,51 +82,3 @@ export const Config = { publicKey: values.VITE_VAPID_PUBLIC_KEY, }, }; - -function requiredInProd(name: string, devFallback: string): string { - const value = env[name]; - - if (!isProd) { - return value ?? devFallback; - } - - if (value === undefined) { - issues.push({ name, message: "required in production" }); - return ""; - } - if (value.length === 0) { - issues.push({ name, message: "required in production (cannot be empty)" }); - return ""; - } - - return value; -} - -function withDefault(name: string, defaultValue: string): string { - return env[name] ?? defaultValue; -} - -function stringBool(name: string): boolean { - const value = env[name]; - if (value === undefined) return false; - - const normalized = value.toLowerCase(); - if (TRUTHY_STRINGS.includes(normalized)) return true; - if (FALSY_STRINGS.includes(normalized)) return false; - - issues.push({ - name, - message: `must be a boolean-like string (e.g. "true" or "false"), got "${value}"`, - }); - return false; -} - -function envError(issues: Array<{ name: string; message: string }>): Error { - const lines = issues.map((issue) => ` - ${issue.name}: ${issue.message}`); - - return new Error( - `Invalid client environment configuration:\n${lines.join( - "\n", - )}\n\nSee .env.example for the full list of variables and how to set them.`, - ); -} diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index 4d8100879..b27425362 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -10,9 +10,9 @@ import { notFoundIfNullish, successToast, } from "~/utils/remix.server"; +import { normalizeFriendCode } from "~/utils/schema"; import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; import { assertUnreachable } from "~/utils/types"; -import { normalizeFriendCode } from "~/utils/zod"; import { adminActionSchema } from "../admin-schemas"; import { sendUserBannedWebhook, diff --git a/app/features/admin/admin-schemas.ts b/app/features/admin/admin-schemas.ts index 402d1fdb5..066a2e0e2 100644 --- a/app/features/admin/admin-schemas.ts +++ b/app/features/admin/admin-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { friendCodeField } from "~/features/sendouq/q-schemas"; import { datetime, @@ -11,16 +11,16 @@ import { textFieldOptional, userSearch, } from "~/form/fields"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import { BAN_REASON_MAX_LENGTH } from "./admin-constants"; const userField = userSearch({ label: "labels.user" }); -export const friendCodeSearchSchema = z.object({ +export const friendCodeSearchSchema = v.object({ friendCode: friendCodeField, }); -export const migrateUserSchema = z.object({ +export const migrateUserSchema = v.object({ _action: stringConstant("MIGRATE"), oldUser: userSearch({ label: "labels.adminOldUser" }), newUser: userSearch({ @@ -29,39 +29,39 @@ export const migrateUserSchema = z.object({ }), }); -export const linkPlayerSchema = z.object({ +export const linkPlayerSchema = v.object({ _action: stringConstant("LINK_PLAYER"), user: userField, playerId: numberField({ label: "labels.adminPlayerId", min: 1 }), }); -export const giveArtistSchema = z.object({ +export const giveArtistSchema = v.object({ _action: stringConstant("ARTIST"), user: userField, }); -export const giveVideoAdderSchema = z.object({ +export const giveVideoAdderSchema = v.object({ _action: stringConstant("VIDEO_ADDER"), user: userField, }); -export const giveTournamentOrganizerSchema = z.object({ +export const giveTournamentOrganizerSchema = v.object({ _action: stringConstant("TOURNAMENT_ORGANIZER"), user: userField, }); -export const giveApiAccessSchema = z.object({ +export const giveApiAccessSchema = v.object({ _action: stringConstant("API_ACCESS"), user: userField, }); -export const updateFriendCodeSchema = z.object({ +export const updateFriendCodeSchema = v.object({ _action: stringConstant("UPDATE_FRIEND_CODE"), user: userField, friendCode: friendCodeField, }); -export const forcePatronSchema = z.object({ +export const forcePatronSchema = v.object({ _action: stringConstant("FORCE_PATRON"), user: userField, patronTier: select({ @@ -75,7 +75,7 @@ export const forcePatronSchema = z.object({ patronExpiresAt: datetime({ label: "labels.patronExpiresAt" }), }); -export const banUserSchema = z.object({ +export const banUserSchema = v.object({ _action: stringConstant("BAN_USER"), user: userField, expiresAt: datetimeOptional({ @@ -90,16 +90,16 @@ export const banUserSchema = z.object({ }), }); -export const unbanUserSchema = z.object({ +export const unbanUserSchema = v.object({ _action: stringConstant("UNBAN_USER"), user: userField, }); -export const refreshPlusTiersSchema = z.object({ +export const refreshPlusTiersSchema = v.object({ _action: stringConstant("REFRESH"), }); -export const adminActionSchema = z.union([ +export const adminActionSchema = v.union([ migrateUserSchema, linkPlayerSchema, giveArtistSchema, @@ -113,7 +113,7 @@ export const adminActionSchema = z.union([ refreshPlusTiersSchema, ]); -export const createExternalStreamSchema = z.object({ +export const createExternalStreamSchema = v.object({ _action: stringConstant("CREATE"), name: textField({ label: "labels.name", maxLength: 64 }), url: textField({ @@ -125,12 +125,12 @@ export const createExternalStreamSchema = z.object({ startTime: datetime({ label: "labels.startTime" }), }); -const deleteExternalStreamSchema = z.object({ +const deleteExternalStreamSchema = v.object({ _action: stringConstant("DELETE"), id, }); -export const externalStreamActionSchema = z.union([ +export const externalStreamActionSchema = v.union([ createExternalStreamSchema, deleteExternalStreamSchema, ]); diff --git a/app/features/admin/admin-search-params.ts b/app/features/admin/admin-search-params.ts index 75c8a4932..d72187267 100644 --- a/app/features/admin/admin-search-params.ts +++ b/app/features/admin/admin-search-params.ts @@ -1,10 +1,13 @@ -import { z } from "zod"; +import * as v from "valibot"; import { FRIEND_CODE_REGEXP } from "~/features/sendouq/q-constants"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; export const adminSearchParams = SearchParams.define({ - friendCode: SP.param(z.string().regex(FRIEND_CODE_REGEXP).nullable(), { - loader: true, - }), + friendCode: SP.param( + v.nullable(v.pipe(v.string(), v.regex(FRIEND_CODE_REGEXP))), + { + loader: true, + }, + ), }); diff --git a/app/features/admin/loaders/admin.server.ts b/app/features/admin/loaders/admin.server.ts index 098d119ce..dd76d51bd 100644 --- a/app/features/admin/loaders/admin.server.ts +++ b/app/features/admin/loaders/admin.server.ts @@ -6,7 +6,7 @@ import { } from "~/features/auth/core/user.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { isAdmin, isDev, isStaff } from "~/modules/permissions/utils"; -import { normalizeFriendCode } from "~/utils/zod"; +import { normalizeFriendCode } from "~/utils/schema"; import { adminSearchParams } from "../admin-search-params"; import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../core/dev-controls"; diff --git a/app/features/api-public/routes/calendar.$year.$week.ts b/app/features/api-public/routes/calendar.$year.$week.ts index 41a555b9b..34c91b306 100644 --- a/app/features/api-public/routes/calendar.$year.$week.ts +++ b/app/features/api-public/routes/calendar.$year.$week.ts @@ -1,5 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { db } from "~/db/sql"; import { databaseTimestampToDate, @@ -7,11 +7,12 @@ import { weekNumberToDateRange, } from "~/utils/dates"; import { parseParams } from "~/utils/remix.server"; +import { coerceNumber } from "~/utils/schema"; import type { GetCalendarWeekResponse } from "../schema"; -const paramsSchema = z.object({ - year: z.coerce.number().int().min(2020).max(2100), - week: z.coerce.number().int().min(1).max(53), +const paramsSchema = v.object({ + year: v.pipe(coerceNumber(), v.integer(), v.minValue(2020), v.maxValue(2100)), + week: v.pipe(coerceNumber(), v.integer(), v.minValue(1), v.maxValue(53)), }); export const loader = async ({ params }: LoaderFunctionArgs) => { diff --git a/app/features/api-public/routes/org.$id.ts b/app/features/api-public/routes/org.$id.ts index 5e985ce0e..7979981b7 100644 --- a/app/features/api-public/routes/org.$id.ts +++ b/app/features/api-public/routes/org.$id.ts @@ -1,15 +1,15 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { db } from "~/db/sql"; import { concatUserSubmittedImagePrefix, jsonArrayFrom, } from "~/utils/kysely.server"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import type { GetTournamentOrganizationResponse } from "../schema"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, }); diff --git a/app/features/api-public/routes/sendouq.active-match.$userId.ts b/app/features/api-public/routes/sendouq.active-match.$userId.ts index 5aba736f1..11a165b1d 100644 --- a/app/features/api-public/routes/sendouq.active-match.$userId.ts +++ b/app/features/api-public/routes/sendouq.active-match.$userId.ts @@ -1,11 +1,11 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; import { parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import type { GetUsersActiveSendouqMatchResponse } from "../schema"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ userId: id, }); diff --git a/app/features/api-public/routes/sendouq.match.$matchId.ts b/app/features/api-public/routes/sendouq.match.$matchId.ts index df2d65cfa..03786c2a4 100644 --- a/app/features/api-public/routes/sendouq.match.$matchId.ts +++ b/app/features/api-public/routes/sendouq.match.$matchId.ts @@ -1,12 +1,12 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; import { getFixedTForLanguage } from "~/modules/i18n/i18next.server"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import type { GetSendouqMatchResponse, MapListMap } from "../schema"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ matchId: id, }); diff --git a/app/features/api-public/routes/team.$id.ts b/app/features/api-public/routes/team.$id.ts index 4ec67dd61..e0435ccc5 100644 --- a/app/features/api-public/routes/team.$id.ts +++ b/app/features/api-public/routes/team.$id.ts @@ -1,12 +1,12 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { db } from "~/db/sql"; import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import type { GetTeamResponse } from "../schema"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, }); diff --git a/app/features/api-public/routes/tournament-match.$id.ts b/app/features/api-public/routes/tournament-match.$id.ts index fcb5192ae..089a2ed2d 100644 --- a/app/features/api-public/routes/tournament-match.$id.ts +++ b/app/features/api-public/routes/tournament-match.$id.ts @@ -1,5 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { db } from "~/db/sql"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; @@ -10,10 +10,10 @@ import { parseMaplistSource } from "~/modules/tournament-map-list-generator/sour import { jsonArrayFrom } from "~/utils/kysely.server"; import { logger } from "~/utils/logger"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import type { GetTournamentMatchResponse } from "../schema"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, }); diff --git a/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts b/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts index 9be523d4b..6320cf5f9 100644 --- a/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts +++ b/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts @@ -1,13 +1,13 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { coerceNumber, id } from "~/utils/schema"; import type { GetTournamentBracketStandingsResponse } from "../schema"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, - bidx: z.coerce.number().int(), + bidx: v.pipe(coerceNumber(), v.integer()), }); export const loader = async ({ params }: LoaderFunctionArgs) => { diff --git a/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts b/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts index 4c4dba3bf..240e92848 100644 --- a/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts +++ b/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts @@ -1,14 +1,14 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import type { Bracket } from "~/features/tournament-bracket/core/Bracket"; import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { coerceNumber, id } from "~/utils/schema"; import type { GetTournamentBracketResponse } from "../schema"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, - bidx: z.coerce.number().int(), + bidx: v.pipe(coerceNumber(), v.integer()), }); export const loader = async ({ params }: LoaderFunctionArgs) => { diff --git a/app/features/api-public/routes/tournament.$id.casted.ts b/app/features/api-public/routes/tournament.$id.casted.ts index 9b28f72b4..b80e20392 100644 --- a/app/features/api-public/routes/tournament.$id.casted.ts +++ b/app/features/api-public/routes/tournament.$id.casted.ts @@ -1,11 +1,11 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { db } from "~/db/sql"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import type { GetCastedTournamentMatchesResponse } from "../schema"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, }); diff --git a/app/features/api-public/routes/tournament.$id.players.ts b/app/features/api-public/routes/tournament.$id.players.ts index 7c2e8760a..4c4e3b542 100644 --- a/app/features/api-public/routes/tournament.$id.players.ts +++ b/app/features/api-public/routes/tournament.$id.players.ts @@ -1,11 +1,11 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server"; import { parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import type { GetTournamentPlayersResponse } from "../schema"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, }); diff --git a/app/features/api-public/routes/tournament.$id.seeds.ts b/app/features/api-public/routes/tournament.$id.seeds.ts index 97d1bbac1..9cbff973b 100644 --- a/app/features/api-public/routes/tournament.$id.seeds.ts +++ b/app/features/api-public/routes/tournament.$id.seeds.ts @@ -1,16 +1,16 @@ import type { ActionFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.seeds.server"; import { parseBody, parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import { wrapActionForApi } from "../api-action-wrapper.server"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, }); -const bodySchema = z.object({ - tournamentTeamIds: z.array(id), +const bodySchema = v.object({ + tournamentTeamIds: v.array(id), }); export const action = async (args: ActionFunctionArgs) => { diff --git a/app/features/api-public/routes/tournament.$id.starting-brackets.ts b/app/features/api-public/routes/tournament.$id.starting-brackets.ts index 3a9271d8c..21ebb45c3 100644 --- a/app/features/api-public/routes/tournament.$id.starting-brackets.ts +++ b/app/features/api-public/routes/tournament.$id.starting-brackets.ts @@ -1,19 +1,19 @@ import type { ActionFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.seeds.server"; import { parseBody, parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import { wrapActionForApi } from "../api-action-wrapper.server"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, }); -const bodySchema = z.object({ - startingBrackets: z.array( - z.object({ +const bodySchema = v.object({ + startingBrackets: v.array( + v.object({ tournamentTeamId: id, - startingBracketIdx: z.number().int().min(0), + startingBracketIdx: v.pipe(v.number(), v.integer(), v.minValue(0)), }), ), }); diff --git a/app/features/api-public/routes/tournament.$id.streams.ts b/app/features/api-public/routes/tournament.$id.streams.ts index 07f8f419a..3ca84346a 100644 --- a/app/features/api-public/routes/tournament.$id.streams.ts +++ b/app/features/api-public/routes/tournament.$id.streams.ts @@ -1,13 +1,13 @@ import { sql } from "kysely"; import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { db } from "~/db/sql"; import { jsonArrayFrom } from "~/utils/kysely.server"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import type { GetTournamentStreamsResponse } from "../schema"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, }); diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts index 08a9815ea..8e2f95692 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts @@ -1,5 +1,5 @@ import type { ActionFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import { userIsBanned } from "~/features/ban/core/banned.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; @@ -19,15 +19,15 @@ import { parseBody, parseParams, } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import { wrapActionForApi } from "../api-action-wrapper.server"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, teamId: id, }); -const bodySchema = z.object({ +const bodySchema = v.object({ userId: id, }); diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts index 0a225c632..5dde1f33e 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts @@ -1,5 +1,5 @@ import type { ActionFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; @@ -14,15 +14,15 @@ import { parseBody, parseParams, } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import { wrapActionForApi } from "../api-action-wrapper.server"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, teamId: id, }); -const bodySchema = z.object({ +const bodySchema = v.object({ userId: id, }); diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts index 2ddaac595..3e41b4d5a 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts @@ -1,5 +1,5 @@ import type { ActionFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { @@ -13,17 +13,17 @@ import { parseBody, parseParams, } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import { wrapActionForApi } from "../api-action-wrapper.server"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, teamId: id, }); -const bodySchema = z.object({ +const bodySchema = v.object({ userId: id, - inGameName: z.string().refine(inGameNameIsValid), + inGameName: v.pipe(v.string(), v.check(inGameNameIsValid)), }); export const action = async (args: ActionFunctionArgs) => { diff --git a/app/features/api-public/routes/tournament.$id.teams.ts b/app/features/api-public/routes/tournament.$id.teams.ts index 894b2445c..803535eea 100644 --- a/app/features/api-public/routes/tournament.$id.teams.ts +++ b/app/features/api-public/routes/tournament.$id.teams.ts @@ -1,6 +1,6 @@ import { sql } from "kysely"; import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { db } from "~/db/sql"; import { ordinalToSp } from "~/features/mmr/mmr-utils"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; @@ -14,10 +14,10 @@ import { tournamentUsername, } from "~/utils/kysely.server"; import { parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import type { GetTournamentTeamsResponse } from "../schema"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, }); diff --git a/app/features/api-public/routes/tournament.$id.teams.upsert.ts b/app/features/api-public/routes/tournament.$id.teams.upsert.ts index 643a28e7d..8cc953611 100644 --- a/app/features/api-public/routes/tournament.$id.teams.upsert.ts +++ b/app/features/api-public/routes/tournament.$id.teams.upsert.ts @@ -1,32 +1,35 @@ import type { ActionFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { upsertRegistrationAction } from "~/features/tournament-admin/actions/to.$id.admin.registration.server"; import { ADMIN_REGISTRATION_MAX_MEMBERS } from "~/features/tournament-admin/tournament-admin-registration-schemas"; import { existingImage } from "~/form/image-field"; import { parseBody, parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import { wrapActionForApi } from "../api-action-wrapper.server"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, }); -const bodySchema = z.object({ - tournamentTeamId: id.optional(), - name: z.string().max(TOURNAMENT.TEAM_NAME_MAX_LENGTH).optional(), - teamId: id.optional(), +const bodySchema = v.object({ + tournamentTeamId: v.optional(id), + name: v.optional( + v.pipe(v.string(), v.maxLength(TOURNAMENT.TEAM_NAME_MAX_LENGTH)), + ), + teamId: v.optional(id), ownerUserId: id, - members: z - .array( - z.object({ + members: v.pipe( + v.array( + v.object({ userId: id, - inGameName: z.string().optional(), + inGameName: v.optional(v.string()), }), - ) - .min(1) - .max(ADMIN_REGISTRATION_MAX_MEMBERS), + ), + v.minLength(1), + v.maxLength(ADMIN_REGISTRATION_MAX_MEMBERS), + ), }); export const action = async (args: ActionFunctionArgs) => { diff --git a/app/features/api-public/routes/tournament.$id.ts b/app/features/api-public/routes/tournament.$id.ts index 465cd229e..13ca9ec85 100644 --- a/app/features/api-public/routes/tournament.$id.ts +++ b/app/features/api-public/routes/tournament.$id.ts @@ -1,13 +1,13 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { db } from "~/db/sql"; import { databaseTimestampToDate } from "~/utils/dates"; import { jsonArrayFrom } from "~/utils/kysely.server"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import type { GetTournamentResponse } from "../schema"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ id, }); diff --git a/app/features/api-public/routes/user.$identifier.ids.ts b/app/features/api-public/routes/user.$identifier.ids.ts index dcc7c9249..0dcfe0044 100644 --- a/app/features/api-public/routes/user.$identifier.ids.ts +++ b/app/features/api-public/routes/user.$identifier.ids.ts @@ -1,11 +1,11 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod/v4"; +import * as v from "valibot"; import { userByIdentifierQuery } from "~/utils/kysely.server"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; import type { GetUserIdsResponse } from "../schema"; -const paramsSchema = z.object({ - identifier: z.string(), +const paramsSchema = v.object({ + identifier: v.string(), }); export const loader = async ({ params }: LoaderFunctionArgs) => { diff --git a/app/features/api-public/routes/user.$identifier.ts b/app/features/api-public/routes/user.$identifier.ts index de2af6479..837049042 100644 --- a/app/features/api-public/routes/user.$identifier.ts +++ b/app/features/api-public/routes/user.$identifier.ts @@ -1,5 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { db } from "~/db/sql"; import * as Seasons from "~/features/mmr/core/Seasons"; import { userSkills as _userSkills } from "~/features/mmr/tiered.server"; @@ -11,8 +11,8 @@ import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; import { badgeUrl } from "~/utils/urls"; import type { GetUserResponse } from "../schema"; -const paramsSchema = z.object({ - identifier: z.string(), +const paramsSchema = v.object({ + identifier: v.string(), }); export const loader = async ({ params }: LoaderFunctionArgs) => { diff --git a/app/features/api-public/routes/user.$userId.active-match.ts b/app/features/api-public/routes/user.$userId.active-match.ts index ed033fa13..ac8635d4e 100644 --- a/app/features/api-public/routes/user.$userId.active-match.ts +++ b/app/features/api-public/routes/user.$userId.active-match.ts @@ -1,12 +1,12 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server"; import { parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import type { GetUsersActiveMatchResponse } from "../schema"; -const paramsSchema = z.object({ +const paramsSchema = v.object({ userId: id, }); diff --git a/app/features/api/api-schemas.ts b/app/features/api/api-schemas.ts index e36403dde..0a08dc85e 100644 --- a/app/features/api/api-schemas.ts +++ b/app/features/api/api-schemas.ts @@ -1,11 +1,11 @@ -import { z } from "zod"; -import { _action } from "~/utils/zod"; +import * as v from "valibot"; +import { _action } from "~/utils/schema"; -export const apiActionSchema = z.union([ - z.object({ +export const apiActionSchema = v.union([ + v.object({ _action: _action("GENERATE_READ"), }), - z.object({ + v.object({ _action: _action("GENERATE_WRITE"), }), ]); diff --git a/app/features/art/art-image.ts b/app/features/art/art-image.ts index eeab10b57..29e977c88 100644 --- a/app/features/art/art-image.ts +++ b/app/features/art/art-image.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; /** * Allowed prefixes for an art data URL. Unlike the generic `image()` form field, art keeps the @@ -30,10 +30,11 @@ export const ART_FORM_MAX_BODY_BYTES = export const ART_IMAGE_TOO_LARGE_ERROR = "forms:errors.imageTooLarge"; const artImageDataUrl = (maxBytes: number) => - z - .string() - .max(maxDataUrlLength(maxBytes), ART_IMAGE_TOO_LARGE_ERROR) - .regex(ART_IMAGE_DATA_URL_PREFIX_REGEX); + v.pipe( + v.string(), + v.maxLength(maxDataUrlLength(maxBytes), ART_IMAGE_TOO_LARGE_ERROR), + v.regex(ART_IMAGE_DATA_URL_PREFIX_REGEX), + ); /** * JSON-serializable value of the art image form field. Art can't use the generic `image()` field: @@ -42,21 +43,21 @@ const artImageDataUrl = (maxBytes: number) => * uploaded (only the preview url rides along, never bytes) — art images can't be swapped after * upload. */ -export const artImageValue = z - .union([ - z.object({ - type: z.literal("EXISTING"), - url: z.string(), +export const artImageValue = v.nullable( + v.union([ + v.object({ + type: v.literal("EXISTING"), + url: v.string(), }), - z.object({ - type: z.literal("NEW"), + v.object({ + type: v.literal("NEW"), dataUrl: artImageDataUrl(ART_IMAGE_MAX_BYTES), thumbnailDataUrl: artImageDataUrl(ART_THUMBNAIL_MAX_BYTES), }), - ]) - .nullable(); + ]), +); -export type ArtImageValue = z.infer; +export type ArtImageValue = v.InferOutput; /** * Does a freshly compressed art image exceed what the schema accepts? Lets the form field reject diff --git a/app/features/art/art-schemas.server.ts b/app/features/art/art-schemas.server.ts index 015ba59b3..c3d56f807 100644 --- a/app/features/art/art-schemas.server.ts +++ b/app/features/art/art-schemas.server.ts @@ -1,17 +1,17 @@ -import { z } from "zod"; -import { _action, id } from "~/utils/zod"; +import * as v from "valibot"; +import { _action, id } from "~/utils/schema"; -const deleteArtSchema = z.object({ +const deleteArtSchema = v.object({ _action: _action("DELETE_ART"), id, }); -const unlinkArtSchema = z.object({ +const unlinkArtSchema = v.object({ _action: _action("UNLINK_ART"), id, }); -export const userArtPageActionSchema = z.union([ +export const userArtPageActionSchema = v.union([ deleteArtSchema, unlinkArtSchema, ]); diff --git a/app/features/art/art-schemas.ts b/app/features/art/art-schemas.ts index eb0ea3a0d..8a70358c4 100644 --- a/app/features/art/art-schemas.ts +++ b/app/features/art/art-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { array, customField, @@ -7,46 +7,51 @@ import { toggle, userSearchOptional, } from "~/form/fields"; -import { id } from "~/utils/zod"; +import { id, superRefine } from "~/utils/schema"; import { ART } from "./art-constants"; import { artImageValue } from "./art-image"; -const artTags = z - .array( - z.object({ - name: z.string().min(1).max(ART.TAG_MAX_LENGTH).optional(), - id: id.optional(), +const artTags = v.pipe( + v.array( + v.object({ + name: v.optional( + v.pipe(v.string(), v.minLength(1), v.maxLength(ART.TAG_MAX_LENGTH)), + ), + id: v.optional(id), }), - ) - .max(ART.TAGS_MAX_LENGTH); + ), + v.maxLength(ART.TAGS_MAX_LENGTH), +); -export const artFormSchema = z - .object({ - artId: idConstantOptional(), - img: customField({ initialValue: null }, artImageValue), - description: textAreaOptional({ - label: "labels.description", - maxLength: ART.DESCRIPTION_MAX_LENGTH, - }), - tags: customField({ initialValue: [] }, artTags), - linkedUsers: array({ - label: "labels.linkedUsers", - bottomText: "bottomTexts.linkedUsers", - max: ART.LINKED_USERS_MAX_LENGTH, - field: userSearchOptional({ label: "labels.user" }), - }), - isShowcase: toggle({ - label: "labels.showcase", - bottomText: "bottomTexts.showcase", - }), - }) - .superRefine((data, ctx) => { +const artFormFields = { + artId: idConstantOptional(), + img: customField({ initialValue: null }, artImageValue), + description: textAreaOptional({ + label: "labels.description", + maxLength: ART.DESCRIPTION_MAX_LENGTH, + }), + tags: customField({ initialValue: [] }, artTags), + linkedUsers: array({ + label: "labels.linkedUsers", + bottomText: "bottomTexts.linkedUsers", + max: ART.LINKED_USERS_MAX_LENGTH, + field: userSearchOptional({ label: "labels.user" }), + }), + isShowcase: toggle({ + label: "labels.showcase", + bottomText: "bottomTexts.showcase", + }), +}; + +export const artFormSchema = v.pipe( + v.object(artFormFields), + superRefine((data, ctx) => { // existing art keeps its image, new art must bring one if (!data.artId && data.img?.type !== "NEW") { ctx.addIssue({ path: ["img"], - code: "custom", message: "forms:errors.required", }); } - }); + }), +); diff --git a/app/features/art/art-search-params.ts b/app/features/art/art-search-params.ts index 5a9793f78..6189f93c1 100644 --- a/app/features/art/art-search-params.ts +++ b/app/features/art/art-search-params.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; @@ -8,18 +8,22 @@ export const ART_TABS = { } as const; export const artSearchParams = SearchParams.define({ - tag: SP.param(z.string().nullable(), { loader: true }), - tab: SP.param(z.enum([ART_TABS.RECENTLY_UPLOADED, ART_TABS.SHOWCASE]), { + tag: SP.param(v.nullable(v.string()), { loader: true }), + tab: SP.param(v.picklist([ART_TABS.RECENTLY_UPLOADED, ART_TABS.SHOWCASE]), { default: ART_TABS.RECENTLY_UPLOADED, loader: false, }), - open: SP.param(z.boolean(), { default: false, loader: false }), + open: SP.param(v.boolean(), { default: false, loader: false }), }); export const artGridSearchParams = SearchParams.define({ - big: SP.param(z.number().int().positive().nullable(), { loader: false }), + big: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), { + loader: false, + }), }); export const artNewSearchParams = SearchParams.define({ - art: SP.param(z.number().int().positive().nullable(), { loader: true }), + art: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), { + loader: true, + }), }); diff --git a/app/features/articles/articles-schemas.server.ts b/app/features/articles/articles-schemas.server.ts index 7ae2847d0..d09fc8ae0 100644 --- a/app/features/articles/articles-schemas.server.ts +++ b/app/features/articles/articles-schemas.server.ts @@ -1,14 +1,14 @@ -import { z } from "zod"; +import * as v from "valibot"; -const authorName = z.string().min(1); +const authorName = v.pipe(v.string(), v.minLength(1)); -const author = z.union([ +const author = v.union([ authorName, - z.object({ name: authorName, link: z.string().url() }), + v.object({ name: authorName, link: v.pipe(v.string(), v.url()) }), ]); -export const articleDataSchema = z.object({ - title: z.string().min(1), - author: z.union([author, z.array(author)]), - date: z.date(), +export const articleDataSchema = v.object({ + title: v.pipe(v.string(), v.minLength(1)), + author: v.union([author, v.array(author)]), + date: v.date(), }); diff --git a/app/features/articles/core/bySlug.server.ts b/app/features/articles/core/bySlug.server.ts index ee9f8e6f8..159ff191a 100644 --- a/app/features/articles/core/bySlug.server.ts +++ b/app/features/articles/core/bySlug.server.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import matter from "gray-matter"; -import { ZodError, type z } from "zod"; +import * as v from "valibot"; import { ARTICLES_FOLDER_PATH } from "../articles-constants"; import { articleDataSchema } from "../articles-schemas.server"; @@ -18,7 +18,7 @@ export function articleBySlug(slug: string) { ); const { content, data } = matter(rawMarkdown); - const { date, ...restParsed } = articleDataSchema.parse(data); + const { date, ...restParsed } = v.parse(articleDataSchema, data); return { content, @@ -29,7 +29,7 @@ export function articleBySlug(slug: string) { } catch (e) { if (!(e instanceof Error)) throw e; - if (e.message.includes("ENOENT") || e instanceof ZodError) { + if (e.message.includes("ENOENT") || e instanceof v.ValiError) { return null; } @@ -38,7 +38,7 @@ export function articleBySlug(slug: string) { } export function normalizeAuthors( - authors: z.infer["author"], + authors: v.InferOutput["author"], ): Array<{ name: string; link: string | null }> { if (Array.isArray(authors)) { return authors.map((author) => { diff --git a/app/features/articles/core/list.server.ts b/app/features/articles/core/list.server.ts index faaf4f404..5f679062f 100644 --- a/app/features/articles/core/list.server.ts +++ b/app/features/articles/core/list.server.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import matter from "gray-matter"; +import * as v from "valibot"; import { ARTICLES_FOLDER_PATH } from "../articles-constants"; import { articleDataSchema } from "../articles-schemas.server"; import { type articleBySlug, normalizeAuthors } from "./bySlug.server"; @@ -20,7 +21,7 @@ export async function mostRecentArticles(count: number) { ); const { data } = matter(rawMarkdown); - const { date, ...restParsed } = articleDataSchema.parse(data); + const { date, ...restParsed } = v.parse(articleDataSchema, data); articles.push({ date, slug: file.replace(".md", ""), diff --git a/app/features/associations/associations-schemas.ts b/app/features/associations/associations-schemas.ts index 104c53b81..cba8a65c5 100644 --- a/app/features/associations/associations-schemas.ts +++ b/app/features/associations/associations-schemas.ts @@ -1,42 +1,42 @@ -import { z } from "zod"; +import * as v from "valibot"; import { textField } from "~/form/fields"; -import { _action, id, inviteCode } from "~/utils/zod"; +import { _action, id, inviteCode } from "~/utils/schema"; import { ASSOCIATION } from "./associations-constants"; -export const createNewAssociationSchema = z.object({ +export const createNewAssociationSchema = v.object({ name: textField({ label: "labels.name", maxLength: 100, }), }); -const removeMemberSchema = z.object({ +const removeMemberSchema = v.object({ _action: _action("REMOVE_MEMBER"), associationId: id, userId: id, }); -const deleteAssociationSchema = z.object({ +const deleteAssociationSchema = v.object({ _action: _action("DELETE_ASSOCIATION"), associationId: id, }); -const refreshInviteCodeSchema = z.object({ +const refreshInviteCodeSchema = v.object({ _action: _action("REFRESH_INVITE_CODE"), associationId: id, }); -const joinAssociationSchema = z.object({ +const joinAssociationSchema = v.object({ _action: _action("JOIN_ASSOCIATION"), inviteCode, }); -const leaveAssociationSchema = z.object({ +const leaveAssociationSchema = v.object({ _action: _action("LEAVE_ASSOCIATION"), associationId: id, }); -export const associationsPageActionSchema = z.union([ +export const associationsPageActionSchema = v.union([ removeMemberSchema, deleteAssociationSchema, refreshInviteCodeSchema, @@ -44,12 +44,12 @@ export const associationsPageActionSchema = z.union([ leaveAssociationSchema, ]); -const virtualAssociationIdentifierSchema = z.enum( +const virtualAssociationIdentifierSchema = v.picklist( ASSOCIATION.VIRTUAL_IDENTIFIERS, ); -export const associationIdentifierSchema = z.union([ +export const associationIdentifierSchema = v.union([ virtualAssociationIdentifierSchema, id, - z.literal("PUBLIC"), // null in DB + v.literal("PUBLIC"), ]); diff --git a/app/features/associations/associations-search-params.ts b/app/features/associations/associations-search-params.ts index 92c8220d1..c1524b904 100644 --- a/app/features/associations/associations-search-params.ts +++ b/app/features/associations/associations-search-params.ts @@ -1,10 +1,13 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; import { SHORT_NANOID_LENGTH } from "~/utils/id"; export const associationsSearchParams = SearchParams.define({ - inviteCode: SP.param(z.string().length(SHORT_NANOID_LENGTH).nullable(), { - loader: true, - }), + inviteCode: SP.param( + v.nullable(v.pipe(v.string(), v.length(SHORT_NANOID_LENGTH))), + { + loader: true, + }, + ), }); diff --git a/app/features/auth/core/DiscordStrategy.server.ts b/app/features/auth/core/DiscordStrategy.server.ts index aa42407bc..88346be3a 100644 --- a/app/features/auth/core/DiscordStrategy.server.ts +++ b/app/features/auth/core/DiscordStrategy.server.ts @@ -1,6 +1,6 @@ import { add } from "date-fns"; import { OAuth2Strategy } from "remix-auth-oauth2"; -import { z } from "zod"; +import * as v from "valibot"; import { Config } from "~/config"; import { ServerConfig } from "~/config.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -8,36 +8,39 @@ import { logger } from "~/utils/logger"; let discordApiCooldownUntil: number | null = null; -const partialDiscordUserSchema = z.object({ - avatar: z.string().nullish(), - discriminator: z.string(), - id: z.string(), - username: z.string(), - global_name: z.string().nullish(), - verified: z.boolean().nullish(), +const partialDiscordUserSchema = v.object({ + avatar: v.optional(v.nullable(v.string())), + discriminator: v.string(), + id: v.string(), + username: v.string(), + global_name: v.optional(v.nullable(v.string())), + verified: v.optional(v.nullable(v.boolean())), }); -const partialDiscordConnectionsSchema = z.array( - z.object({ - visibility: z.number(), - verified: z.boolean(), - name: z.string(), - id: z.string(), - type: z.string(), +const partialDiscordConnectionsSchema = v.array( + v.object({ + visibility: v.number(), + verified: v.boolean(), + name: v.string(), + id: v.string(), + type: v.string(), }), ); -const discordUserDetailsSchema = z.tuple([ +const discordUserDetailsSchema = v.tuple([ partialDiscordUserSchema, partialDiscordConnectionsSchema, ]); -const discordRateLimitSchema = z.object({ - retry_after: z.number(), +const discordRateLimitSchema = v.object({ + retry_after: v.number(), }); export const DiscordStrategy = () => { const jsonIfOk = async (res: Response) => { if (res.status === 429) { - const body = discordRateLimitSchema.safeParse(await res.clone().json()); - const retryAfterSeconds = body.success ? body.data.retry_after : 60; + const body = v.safeParse( + discordRateLimitSchema, + await res.clone().json(), + ); + const retryAfterSeconds = body.success ? body.output.retry_after : 60; discordApiCooldownUntil = add(new Date(), { seconds: retryAfterSeconds, }).getTime(); @@ -89,8 +92,10 @@ export const DiscordStrategy = () => { tokens.accessToken(), ); - const [user, connections] = - discordUserDetailsSchema.parse(discordResponses); + const [user, connections] = v.parse( + discordUserDetailsSchema, + discordResponses, + ); const isAlreadyRegistered = Boolean( await UserRepository.findIdByIdentifier(user.id), @@ -119,7 +124,7 @@ export const DiscordStrategy = () => { }; function parseConnections( - connections: z.infer, + connections: v.InferOutput, ) { if (!connections) throw new Error("No connections"); diff --git a/app/features/auth/core/routes.server.ts b/app/features/auth/core/routes.server.ts index b52973b9f..9ce0a175a 100644 --- a/app/features/auth/core/routes.server.ts +++ b/app/features/auth/core/routes.server.ts @@ -1,7 +1,7 @@ import { isbot } from "isbot"; import type { ActionFunction, LoaderFunction } from "react-router"; import { redirect } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; import { requireUser } from "~/features/auth/core/user.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -11,6 +11,7 @@ import { canAccessLohiEndpoint, errorToastRedirect, } from "~/utils/remix.server"; +import type { AnySyncSchema } from "~/utils/schema"; import { ADMIN_PAGE, authErrorUrl } from "~/utils/urls"; import * as LogInLinkRepository from "../LogInLinkRepository.server"; import { @@ -151,17 +152,17 @@ async function safeReturnTo(request: Request): Promise { // only light validation here as we generally trust Lohi // auth flow params are infrastructure conventions and intentionally do not go // through app/modules/search-params/ -function parseSearchParams({ +function parseSearchParams({ request, schema, }: { request: Request; schema: T; -}): z.infer { +}): v.InferOutput { const searchParams = Object.fromEntries(new URL(request.url).searchParams); try { - return schema.parse(searchParams); + return v.parse(schema, searchParams); } catch (e) { logger.error("Error parsing search params", e); @@ -169,12 +170,12 @@ function parseSearchParams({ } } -const createLogInLinkActionSchema = z.object({ - discordId: z.string(), - discordAvatar: z.string().nullish(), - discordName: z.string(), - discordUniqueName: z.string(), - updateOnly: z.enum(["true", "false"]), +const createLogInLinkActionSchema = v.object({ + discordId: v.string(), + discordAvatar: v.optional(v.nullable(v.string())), + discordName: v.string(), + discordUniqueName: v.string(), + updateOnly: v.picklist(["true", "false"]), }); export const createLogInLinkAction: ActionFunction = async ({ request }) => { @@ -203,8 +204,8 @@ export const createLogInLinkAction: ActionFunction = async ({ request }) => { }; }; -const logInViaLinkActionSchema = z.object({ - code: z.string(), +const logInViaLinkActionSchema = v.object({ + code: v.string(), }); export const logInViaLinkLoader: LoaderFunction = async ({ request }) => { diff --git a/app/features/badges/actions/badges.$id.edit.server.ts b/app/features/badges/actions/badges.$id.edit.server.ts index fcfee8e4d..be59b5b7e 100644 --- a/app/features/badges/actions/badges.$id.edit.server.ts +++ b/app/features/badges/actions/badges.$id.edit.server.ts @@ -1,6 +1,6 @@ import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { notify } from "~/features/notifications/core/notify.server"; import { requirePermission, @@ -8,9 +8,9 @@ import { } from "~/modules/permissions/guards.server"; import { diff } from "~/utils/arrays"; import { notFoundIfNullish, parseRequestPayload } from "~/utils/remix.server"; +import { actualNumber, preprocess } from "~/utils/schema"; import { assertUnreachable } from "~/utils/types"; import { badgePage } from "~/utils/urls"; -import { actualNumber } from "~/utils/zod"; import * as BadgeRepository from "../BadgeRepository.server"; import { editBadgeActionSchema } from "../badges-schemas"; @@ -19,7 +19,7 @@ export const action: ActionFunction = async ({ request, params }) => { request, schema: editBadgeActionSchema, }); - const badgeId = z.preprocess(actualNumber, z.number()).parse(params.id); + const badgeId = v.parse(preprocess(actualNumber, v.number()), params.id); const badge = notFoundIfNullish(await BadgeRepository.findById(badgeId)); switch (data._action) { diff --git a/app/features/badges/badges-schemas.ts b/app/features/badges/badges-schemas.ts index 932e7dfc9..ee0ba6676 100644 --- a/app/features/badges/badges-schemas.ts +++ b/app/features/badges/badges-schemas.ts @@ -1,17 +1,29 @@ -import { z } from "zod"; -import { _action, id, noDuplicates, safeJSONParse } from "~/utils/zod"; +import * as v from "valibot"; +import { + _action, + id, + noDuplicates, + preprocess, + safeJSONParse, +} from "~/utils/schema"; import { BADGE } from "./badges-constants"; -export const editBadgeActionSchema = z.union([ - z.object({ +export const editBadgeActionSchema = v.union([ + v.object({ _action: _action("MANAGERS"), - managerIds: z.preprocess(safeJSONParse, z.array(id).refine(noDuplicates)), - }), - z.object({ - _action: _action("OWNERS"), - ownerIds: z.preprocess( + managerIds: preprocess( safeJSONParse, - z.array(id).max(BADGE.OWNERS_MAX_LENGTH), + v.pipe( + v.array(id), + v.check((managerIds) => noDuplicates(managerIds)), + ), + ), + }), + v.object({ + _action: _action("OWNERS"), + ownerIds: preprocess( + safeJSONParse, + v.pipe(v.array(id), v.maxLength(BADGE.OWNERS_MAX_LENGTH)), ), }), ]); diff --git a/app/features/badges/loaders/badges.$id.server.ts b/app/features/badges/loaders/badges.$id.server.ts index be07960da..4231832d6 100644 --- a/app/features/badges/loaders/badges.$id.server.ts +++ b/app/features/badges/loaders/badges.$id.server.ts @@ -1,7 +1,7 @@ import type { LoaderFunctionArgs } from "react-router"; import type { SerializeFrom } from "~/utils/remix"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; +import { idObject } from "~/utils/schema"; import * as BadgeRepository from "../BadgeRepository.server"; export type BadgeDetailsLoaderData = SerializeFrom; diff --git a/app/features/build-analyzer/analyzer-search-params.ts b/app/features/build-analyzer/analyzer-search-params.ts index ded79e5bd..19a2526a2 100644 --- a/app/features/build-analyzer/analyzer-search-params.ts +++ b/app/features/build-analyzer/analyzer-search-params.ts @@ -1,27 +1,21 @@ -import { z } from "zod"; +import * as v from "valibot"; import { EMPTY_BUILD } from "~/features/builds/builds-constants"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import * as SearchParams from "~/modules/search-params/search-params"; -import { SP } from "~/modules/search-params/search-params"; -import { numericEnum } from "~/utils/zod"; +import { codec, SP } from "~/modules/search-params/search-params"; +import { numericEnum } from "~/utils/schema"; import { MAX_LDE_INTENSITY } from "./analyzer-constants"; import type { SpecialEffectType } from "./analyzer-types"; import { deserializeBuild, serializeBuild } from "./core/serializer"; import { SPECIAL_EFFECTS } from "./core/specialEffects"; -export const serializedBuildCodec = z.codec( - z.string(), - z.custom>>(), +export const serializedBuildCodec = codec( + v.custom>>(() => true), { - decode: (value, payload) => { + decode: (value) => { const build = deserializeBuild(value); if (!build) { - payload.issues.push({ - code: "custom", - message: "Invalid serialized build", - input: value, - }); - return z.NEVER; + return undefined; } return build; }, @@ -44,13 +38,21 @@ export const analyzerSearchParams = SearchParams.define({ default: EMPTY_BUILD, loader: false, }), - lde: SP.param(z.number().int().min(0).max(MAX_LDE_INTENSITY), { - default: 0, - loader: false, - }), - effect: SP.param(z.array(z.enum(specialEffectTypes)), { + lde: SP.param( + v.pipe( + v.number(), + v.integer(), + v.minValue(0), + v.maxValue(MAX_LDE_INTENSITY), + ), + { + default: 0, + loader: false, + }, + ), + effect: SP.param(v.array(v.picklist(specialEffectTypes)), { default: [], loader: false, }), - focused: SP.param(z.literal([1, 2, 3]), { default: 1, loader: false }), + focused: SP.param(v.picklist([1, 2, 3]), { default: 1, loader: false }), }); diff --git a/app/features/builds/builds-schemas.ts b/app/features/builds/builds-schemas.ts index c1326aebc..cf1196360 100644 --- a/app/features/builds/builds-schemas.ts +++ b/app/features/builds/builds-schemas.ts @@ -1,24 +1,27 @@ -import { z } from "zod"; +import * as v from "valibot"; import { MAX_AP } from "~/features/build-analyzer/analyzer-constants"; import { isValidDate } from "~/utils/dates"; -import { ability } from "~/utils/zod"; +import { ability } from "~/utils/schema"; import { MAX_BUILD_FILTERS } from "./builds-constants"; -const abilityConditionSchema = z.object({ - ability: z.string().toUpperCase().pipe(ability), - value: z.union([z.int().min(0).max(MAX_AP), z.boolean()]), - comparison: z - .string() - .toUpperCase() - .pipe(z.enum(["AT_LEAST", "AT_MOST"])) - .optional(), +const abilityConditionSchema = v.object({ + ability: v.pipe(v.string(), v.toUpperCase(), ability), + value: v.union([ + v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(MAX_AP)), + v.boolean(), + ]), + comparison: v.optional( + v.pipe(v.string(), v.toUpperCase(), v.picklist(["AT_LEAST", "AT_MOST"])), + ), }); -export const abilityConditionsSchema = z - .array(abilityConditionSchema) - .max(MAX_BUILD_FILTERS); +export const abilityConditionsSchema = v.pipe( + v.array(abilityConditionSchema), + v.maxLength(MAX_BUILD_FILTERS), +); -export const buildsDateFilterSchema = z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/) - .refine((value) => isValidDate(new Date(value))); +export const buildsDateFilterSchema = v.pipe( + v.string(), + v.regex(/^\d{4}-\d{2}-\d{2}$/), + v.check((value) => isValidDate(new Date(value))), +); diff --git a/app/features/builds/builds-search-params.ts b/app/features/builds/builds-search-params.ts index c9828bbe7..87a0ba86c 100644 --- a/app/features/builds/builds-search-params.ts +++ b/app/features/builds/builds-search-params.ts @@ -1,7 +1,7 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import { modeShort } from "~/utils/zod"; +import { modeShort } from "~/utils/schema"; import { BUILDS_PAGE_BATCH_SIZE, BUILDS_PAGE_MAX_BUILDS, @@ -12,20 +12,28 @@ import { } from "./builds-schemas"; export const buildsSearchParams = SearchParams.define({ - limit: SP.param(z.number().int().min(1).max(BUILDS_PAGE_MAX_BUILDS), { - default: BUILDS_PAGE_BATCH_SIZE, - loader: true, - }), + limit: SP.param( + v.pipe( + v.number(), + v.integer(), + v.minValue(1), + v.maxValue(BUILDS_PAGE_MAX_BUILDS), + ), + { + default: BUILDS_PAGE_BATCH_SIZE, + loader: true, + }, + ), abilities: SP.json(abilityConditionsSchema, { default: [], resets: ["limit"], loader: true, }), - mode: SP.param(modeShort.nullable(), { + mode: SP.param(v.nullable(modeShort), { resets: ["limit"], loader: true, }), - date: SP.param(buildsDateFilterSchema.nullable(), { + date: SP.param(v.nullable(buildsDateFilterSchema), { resets: ["limit"], loader: true, }), diff --git a/app/features/calendar/actions/calendar.$id.report-winners.server.ts b/app/features/calendar/actions/calendar.$id.report-winners.server.ts index ca5f183a2..2875e3e7f 100644 --- a/app/features/calendar/actions/calendar.$id.report-winners.server.ts +++ b/app/features/calendar/actions/calendar.$id.report-winners.server.ts @@ -4,8 +4,8 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv import { parseFormData } from "~/form/parse.server"; import { requirePermission } from "~/modules/permissions/guards.server"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; +import { idObject } from "~/utils/schema"; import { calendarEventPage } from "~/utils/urls"; -import { idObject } from "~/utils/zod"; import { reportWinnersFormSchema } from "../calendar-report-winners-schemas"; export const action: ActionFunction = async (args) => { diff --git a/app/features/calendar/actions/calendar.$id.server.ts b/app/features/calendar/actions/calendar.$id.server.ts index 106c4c57b..a90a245f7 100644 --- a/app/features/calendar/actions/calendar.$id.server.ts +++ b/app/features/calendar/actions/calendar.$id.server.ts @@ -1,19 +1,20 @@ import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server"; import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; import { requirePermission } from "~/modules/permissions/guards.server"; import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server"; +import { actualNumber, id, preprocess } from "~/utils/schema"; import { CALENDAR_PAGE } from "~/utils/urls"; -import { actualNumber, id } from "~/utils/zod"; export const action: ActionFunction = async ({ params }) => { - const parsedParams = z - .object({ id: z.preprocess(actualNumber, id) }) - .parse(params); + const parsedParams = v.parse( + v.object({ id: preprocess(actualNumber, id) }), + params, + ); const event = notFoundIfNullish( await CalendarRepository.findById(parsedParams.id), ); diff --git a/app/features/calendar/calendar-new-schemas.server.ts b/app/features/calendar/calendar-new-schemas.server.ts index dbfd10b4b..02eae4300 100644 --- a/app/features/calendar/calendar-new-schemas.server.ts +++ b/app/features/calendar/calendar-new-schemas.server.ts @@ -1,8 +1,11 @@ +import * as v from "valibot"; +import { superRefine } from "~/utils/schema"; import { calendarNewBaseSchema, calendarNewSyncRefine, } from "./calendar-new-schemas"; -export const calendarNewSchemaServer = calendarNewBaseSchema.superRefine( - calendarNewSyncRefine, +export const calendarNewSchemaServer = v.pipe( + calendarNewBaseSchema, + superRefine(calendarNewSyncRefine), ); diff --git a/app/features/calendar/calendar-new-schemas.ts b/app/features/calendar/calendar-new-schemas.ts index d07287993..85733ee50 100644 --- a/app/features/calendar/calendar-new-schemas.ts +++ b/app/features/calendar/calendar-new-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { array, @@ -19,7 +19,7 @@ import { toggle, } from "~/form/fields"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; -import { id } from "~/utils/zod"; +import { id, type ValidationCtx } from "~/utils/schema"; import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants"; import { bracketsFormField, @@ -35,9 +35,23 @@ const calendarEventDateField = datetime({ max: calendarEventMaxDate, }); -export const calendarNewBaseSchema = z.object({ +// extracted so its literal item values don't widen to `string` in the +// object's inferred value type +const toToolsModeField = select({ + label: "labels.mapPickingStyle", + items: [ + { value: "ALL", label: "options.toToolsMode.ALL" }, + { value: "SZ", label: "options.toToolsMode.SZ" }, + { value: "TC", label: "options.toToolsMode.TC" }, + { value: "RM", label: "options.toToolsMode.RM" }, + { value: "CB", label: "options.toToolsMode.CB" }, + { value: "TO", label: "options.toToolsMode.TO" }, + ], +}); + +export const calendarNewBaseSchema = v.object({ // discriminates between a calendar event and a tournament; seeded from the loader, no visible control - toToolsEnabled: hidden(z.boolean(), false), + toToolsEnabled: hidden(v.boolean(), false), eventToEditId: idConstantOptional(), tournamentToCopyId: idConstantOptional(), name: textField({ @@ -87,7 +101,7 @@ export const calendarNewBaseSchema = z.object({ })), }), badges: badges({ label: "labels.badges", maxCount: 50 }), - trophyId: customField({ initialValue: null }, id.nullish()), + trophyId: customField({ initialValue: null }, v.nullish(id)), avatarImgId: image({ label: "labels.logo", bottomText: "bottomTexts.avatarValidation", @@ -112,18 +126,8 @@ export const calendarNewBaseSchema = z.object({ label: "labels.maxTeamSize", bottomText: "bottomTexts.maxTeamSize", }), - toToolsMode: select({ - label: "labels.mapPickingStyle", - items: [ - { value: "ALL", label: "options.toToolsMode.ALL" }, - { value: "SZ", label: "options.toToolsMode.SZ" }, - { value: "TC", label: "options.toToolsMode.TC" }, - { value: "RM", label: "options.toToolsMode.RM" }, - { value: "CB", label: "options.toToolsMode.CB" }, - { value: "TO", label: "options.toToolsMode.TO" }, - ], - }), - pool: customField({ initialValue: "" }, z.string().optional()), + toToolsMode: toToolsModeField, + pool: customField({ initialValue: "" }, v.optional(v.string())), // the two bracket progression fields are only rendered (and validated) for // tournaments; for calendar events both stay at their empty initial value brackets: bracketsFormField, @@ -165,14 +169,13 @@ export const calendarNewBaseSchema = z.object({ /** Shared sync cross-field rules, reused by the server schema (see `*.server.ts`). */ export function calendarNewSyncRefine( - data: z.infer, - ctx: z.RefinementCtx, + data: v.InferOutput, + ctx: ValidationCtx, ) { // a calendar event needs at least one date; a tournament needs its single start time if (!data.toToolsEnabled && data.date.length < 1) { ctx.addIssue({ path: ["date"], - code: z.ZodIssueCode.custom, message: "forms:errors.required", }); } @@ -180,7 +183,6 @@ export function calendarNewSyncRefine( if (data.toToolsEnabled && !data.startTime) { ctx.addIssue({ path: ["startTime"], - code: z.ZodIssueCode.custom, message: "forms:errors.required", }); } @@ -189,7 +191,6 @@ export function calendarNewSyncRefine( if (!data.toToolsEnabled && !data.bracketUrl) { ctx.addIssue({ path: ["bracketUrl"], - code: z.ZodIssueCode.custom, message: "forms:errors.bracketUrlRequired", }); } @@ -198,7 +199,6 @@ export function calendarNewSyncRefine( if (data.brackets.length === 0) { ctx.addIssue({ path: ["brackets"], - code: z.ZodIssueCode.custom, message: "forms:errors.bracketProgressionRequired", }); } else { @@ -220,7 +220,6 @@ export function calendarNewSyncRefine( if (!isValid) { ctx.addIssue({ path: ["pool"], - code: z.ZodIssueCode.custom, message: "forms:errors.allModePool", }); } @@ -229,7 +228,6 @@ export function calendarNewSyncRefine( if (data.trophyId && data.badges.length > 0) { ctx.addIssue({ path: ["badges"], - code: z.ZodIssueCode.custom, message: "forms:errors.trophyWithBadges", }); } @@ -242,7 +240,6 @@ export function calendarNewSyncRefine( ) { ctx.addIssue({ path: ["maxMembersPerTeam"], - code: z.ZodIssueCode.custom, message: "forms:errors.maxMembersRange", }); } diff --git a/app/features/calendar/calendar-progression-form.test.ts b/app/features/calendar/calendar-progression-form.test.ts index 42b4611a4..f72130882 100644 --- a/app/features/calendar/calendar-progression-form.test.ts +++ b/app/features/calendar/calendar-progression-form.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import type { z } from "zod"; import * as Progression from "~/features/tournament-bracket/core/Progression"; +import type { ValidationCtx } from "~/utils/schema"; import { defaultBracketsFormValues, formValuesToInputBrackets, @@ -67,11 +67,10 @@ function validationIssues(formValues: { brackets: Parameters[0]; progression: Parameters[1]; }) { - const issues: z.ZodIssue[] = []; - const ctx = { - addIssue: (issue: z.ZodIssue) => issues.push(issue), - path: [], - } as unknown as z.RefinementCtx; + const issues: Parameters[0][] = []; + const ctx: ValidationCtx = { + addIssue: (issue) => issues.push(issue), + }; validateBracketProgressionFormValues( formValues.brackets, diff --git a/app/features/calendar/calendar-progression-form.ts b/app/features/calendar/calendar-progression-form.ts index d11930abe..8ace15483 100644 --- a/app/features/calendar/calendar-progression-form.ts +++ b/app/features/calendar/calendar-progression-form.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import type { Tables } from "~/db/tables"; import type { TournamentStageSettings } from "~/db/tables-json"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; @@ -14,6 +14,7 @@ import { textFieldOptional, toggle, } from "~/form/fields"; +import { superRefine, type ValidationCtx } from "~/utils/schema"; import { assertUnreachable } from "~/utils/types"; const SWISS_DEFAULT_ADVANCE_THRESHOLD = 3; @@ -71,7 +72,7 @@ const progressionSourceField = radioGroup({ }); const bracketFieldset = fieldset({ - fields: z.object({ + fields: v.object({ name: textField({ label: "labels.bracketName", maxLength: TOURNAMENT.BRACKET_NAME_MAX_LENGTH, @@ -126,7 +127,7 @@ const bracketFieldset = fieldset({ }); const progressionSourceFieldset = fieldset({ - fields: z.object({ + fields: v.object({ bracketIdx: selectDynamic({ label: "labels.sourceBracket", initialValue: "0", @@ -140,7 +141,7 @@ const progressionSourceFieldset = fieldset({ }); const progressionEntryFieldset = fieldset({ - fields: z.object({ + fields: v.object({ source: progressionSourceField, sources: array({ min: 1, @@ -164,14 +165,15 @@ export const progressionFormField = array({ }); /** Standalone schema for forms that edit only the bracket progression (tournament admin page). */ -export const bracketProgressionFormSchema = z - .object({ +export const bracketProgressionFormSchema = v.pipe( + v.object({ brackets: bracketsFormField, progression: progressionFormField, - }) - .superRefine((data, ctx) => { + }), + superRefine((data, ctx) => { validateBracketProgressionFormValues(data.brackets, data.progression, ctx); - }); + }), +); /** Form field values of a new tournament's single starting bracket. Used to seed form default values. */ export function defaultBracketsFormValues(): { @@ -306,7 +308,7 @@ export function sourceBracketHasEarlyAdvance( export function validateBracketProgressionFormValues( brackets: BracketFormValue[], progression: ProgressionFormValue[], - ctx: z.RefinementCtx, + ctx: ValidationCtx, ) { for (const [entryIdx, entry] of progression.entries()) { if (entryIdx === 0 || entry.source !== "BRACKET") continue; @@ -321,7 +323,6 @@ export function validateBracketProgressionFormValues( sourceIdx === entryIdx ) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.invalidSourceBracket", path: [ "progression", @@ -343,7 +344,6 @@ export function validateBracketProgressionFormValues( for (const path of progressionErrorPaths(validated)) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: validated.type === "PLACEMENT_TOO_HIGH" ? "forms:errors.placementTooHigh" diff --git a/app/features/calendar/calendar-report-winners-schemas.ts b/app/features/calendar/calendar-report-winners-schemas.ts index 0cfc157ea..01a8d05be 100644 --- a/app/features/calendar/calendar-report-winners-schemas.ts +++ b/app/features/calendar/calendar-report-winners-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { array, customField, @@ -6,30 +6,32 @@ import { numberField, textField, } from "~/form/fields"; -import { id } from "~/utils/zod"; +import { id, superRefine } from "~/utils/schema"; import { CALENDAR_EVENT_RESULT } from "./calendar-constants"; -const reportedPlayerSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("USER"), id: id.nullable() }), - z.object({ - type: z.literal("NAME"), - name: z - .string() - .max(CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH) - .nullable(), +const reportedPlayerSchema = v.variant("type", [ + v.object({ type: v.literal("USER"), id: v.nullable(id) }), + v.object({ + type: v.literal("NAME"), + name: v.nullable( + v.pipe( + v.string(), + v.maxLength(CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH), + ), + ), }), ]); -export type ReportedPlayer = z.infer; +export type ReportedPlayer = v.InferOutput; export const EMPTY_REPORTED_PLAYER: ReportedPlayer = { type: "USER", id: null }; type StoredReportedPlayer = { userId: number | null; name: string | null }; -const reportedPlayersSchema = z - .array(reportedPlayerSchema) - .max(CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH) - .transform((players) => +const reportedPlayersSchema = v.pipe( + v.array(reportedPlayerSchema), + v.maxLength(CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH), + v.transform((players) => players.flatMap((player): Array => { if (player.type === "USER") { return player.id === null ? [] : [{ userId: player.id, name: null }]; @@ -37,21 +39,17 @@ const reportedPlayersSchema = z return player.name ? [{ userId: null, name: player.name }] : []; }), - ) - .refine((players) => players.length > 0, { - message: "forms:errors.emptyTeam", - }) - .refine( - (players) => { - const userIds = players.flatMap((player) => player.userId ?? []); + ), + v.check((players) => players.length > 0, "forms:errors.emptyTeam"), + v.check((players) => { + const userIds = players.flatMap((player) => player.userId ?? []); - return userIds.length === new Set(userIds).size; - }, - { message: "forms:errors.duplicatePlayer" }, - ); + return userIds.length === new Set(userIds).size; + }, "forms:errors.duplicatePlayer"), +); const reportedTeamFieldset = fieldset({ - fields: z.object({ + fields: v.object({ teamName: textField({ label: "labels.teamName", maxLength: CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH, @@ -71,8 +69,8 @@ const reportedTeamFieldset = fieldset({ }), }); -export const reportWinnersFormSchema = z - .object({ +export const reportWinnersFormSchema = v.pipe( + v.object({ participantCount: numberField({ label: "labels.participantCount", maxLength: String(CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT).length, @@ -83,14 +81,13 @@ export const reportWinnersFormSchema = z max: CALENDAR_EVENT_RESULT.MAX_TEAMS_COUNT, field: reportedTeamFieldset, }), - }) - .superRefine((data, ctx) => { + }), + superRefine((data, ctx) => { if ( data.participantCount < 1 || data.participantCount > CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT ) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.numberOutOfRange", path: ["participantCount"], }); @@ -102,7 +99,6 @@ export const reportWinnersFormSchema = z team.placement > CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT ) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.numberOutOfRange", path: ["teams", index, "placement"], }); @@ -112,9 +108,9 @@ export const reportWinnersFormSchema = z const teamNames = data.teams.map((team) => team.teamName); if (teamNames.length !== new Set(teamNames).size) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.uniqueTeamName", path: ["teams"], }); } - }); + }), +); diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts index 6e93fdaf6..d1ab4c9b9 100644 --- a/app/features/calendar/calendar-schemas.ts +++ b/app/features/calendar/calendar-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import type { CalendarEventTag } from "~/features/calendar/calendar-types"; import { BEST_TIER_NUMBER, @@ -6,51 +6,71 @@ import { } from "~/features/tournament/core/tiering"; import { gamesShort, versusShort } from "~/modules/in-game-lists/games"; import { modesShortWithSpecial } from "~/modules/in-game-lists/modes"; -import { gamesShortSchema, id, modeShortWithSpecial } from "~/utils/zod"; +import { + coerceNumber, + gamesShortSchema, + id, + modeShortWithSpecial, +} from "~/utils/schema"; import { CALENDAR_EVENT } from "./calendar-constants"; -const calendarEventTagSchema = z - .string() - .refine((val) => CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag)); +const calendarEventTagSchema = v.pipe( + v.string(), + v.check((val) => CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag)), +); -export const calendarFilterTagsArr = z - .array(calendarEventTagSchema) - .max(CALENDAR_EVENT.TAGS.length); +export const calendarFilterTagsArr = v.pipe( + v.array(calendarEventTagSchema), + v.maxLength(CALENDAR_EVENT.TAGS.length), +); -const calendarFiltersPlainStringArr = z.array(z.string().max(100)).max(10); -const calendarFiltersIdsArr = z.array(id).max(10); -const calendarFilterGamesArr = z.array(gamesShortSchema).min(1).max(3); -const preferredStartTime = z.enum(["ANY", "EU", "NA", "AU"]); -const preferredVersus = z - .array(z.enum(versusShort)) - .min(1) - .max(versusShort.length); -const modeArr = z - .array(modeShortWithSpecial) - .min(1) - .max(modesShortWithSpecial.length); -const tierNumber = z.coerce - .number() - .int() - .min(BEST_TIER_NUMBER) - .max(WORST_TIER_NUMBER); +const calendarFiltersPlainStringArr = v.pipe( + v.array(v.pipe(v.string(), v.maxLength(100))), + v.maxLength(10), +); +const calendarFiltersIdsArr = v.pipe(v.array(id), v.maxLength(10)); +const calendarFilterGamesArr = v.pipe( + v.array(gamesShortSchema), + v.minLength(1), + v.maxLength(3), +); +const preferredStartTime = v.picklist(["ANY", "EU", "NA", "AU"]); +const preferredVersus = v.pipe( + v.array(v.picklist(versusShort)), + v.minLength(1), + v.maxLength(versusShort.length), +); +const modeArr = v.pipe( + v.array(modeShortWithSpecial), + v.minLength(1), + v.maxLength(modesShortWithSpecial.length), +); +const tierNumber = v.pipe( + coerceNumber(), + v.integer(), + v.minValue(BEST_TIER_NUMBER), + v.maxValue(WORST_TIER_NUMBER), +); -export const calendarFiltersSearchParamsSchema = z.object({ - preferredStartTime: preferredStartTime.catch("ANY"), - tagsIncluded: calendarFilterTagsArr.catch([]), - tagsExcluded: calendarFilterTagsArr.catch([]), - isSendou: z.boolean().catch(false), - isRanked: z.boolean().catch(false), - orgsIncluded: calendarFiltersPlainStringArr.catch([]), - orgsExcluded: calendarFiltersPlainStringArr.catch([]), - authorIdsExcluded: calendarFiltersIdsArr.catch([]), - games: calendarFilterGamesArr.catch([...gamesShort]), - preferredVersus: preferredVersus.catch([...versusShort]), - modes: modeArr.catch([...modesShortWithSpecial]), - modesExact: z.boolean().catch(false), - minTeamCount: z.coerce.number().int().nonnegative().catch(0), - minTier: tierNumber.catch(BEST_TIER_NUMBER), - maxTier: tierNumber.catch(WORST_TIER_NUMBER), +export const calendarFiltersSearchParamsSchema = v.object({ + preferredStartTime: v.fallback(preferredStartTime, "ANY"), + tagsIncluded: v.fallback(calendarFilterTagsArr, []), + tagsExcluded: v.fallback(calendarFilterTagsArr, []), + isSendou: v.fallback(v.boolean(), false), + isRanked: v.fallback(v.boolean(), false), + orgsIncluded: v.fallback(calendarFiltersPlainStringArr, []), + orgsExcluded: v.fallback(calendarFiltersPlainStringArr, []), + authorIdsExcluded: v.fallback(calendarFiltersIdsArr, []), + games: v.fallback(calendarFilterGamesArr, [...gamesShort]), + preferredVersus: v.fallback(preferredVersus, [...versusShort]), + modes: v.fallback(modeArr, [...modesShortWithSpecial]), + modesExact: v.fallback(v.boolean(), false), + minTeamCount: v.fallback( + v.pipe(coerceNumber(), v.integer(), v.minValue(0)), + 0, + ), + minTier: v.fallback(tierNumber, BEST_TIER_NUMBER), + maxTier: v.fallback(tierNumber, WORST_TIER_NUMBER), }); const TAGS_TO_OMIT: CalendarEventTag[] = [ diff --git a/app/features/calendar/calendar-search-params.ts b/app/features/calendar/calendar-search-params.ts index 5dfb894b1..a46491c69 100644 --- a/app/features/calendar/calendar-search-params.ts +++ b/app/features/calendar/calendar-search-params.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { BEST_TIER_NUMBER, WORST_TIER_NUMBER, @@ -7,11 +7,7 @@ import { gamesShort, versusShort } from "~/modules/in-game-lists/games"; import { modesShortWithSpecial } from "~/modules/in-game-lists/modes"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import { - dayMonthYear, - gamesShortSchema, - modeShortWithSpecial, -} from "~/utils/zod"; +import { gamesShortSchema, modeShortWithSpecial } from "~/utils/schema"; import { calendarFilterTagsArr } from "./calendar-schemas"; export const VIEW_FILTERS = [ @@ -23,27 +19,64 @@ export const VIEW_FILTERS = [ ] as const; export type ViewFilter = (typeof VIEW_FILTERS)[number]; -const tierNumber = z - .number() - .int() - .min(BEST_TIER_NUMBER) - .max(WORST_TIER_NUMBER); +const tierNumber = v.pipe( + v.number(), + v.integer(), + v.minValue(BEST_TIER_NUMBER), + v.maxValue(WORST_TIER_NUMBER), +); + +// plain number schemas (not `dayMonthYear`'s coercing ones) because SP.param +// derives the URL encoding from the schema's base type and coerces itself +const dayNumber = v.pipe( + v.number(), + v.integer(), + v.minValue(1), + v.maxValue(31), +); +const monthNumber = v.pipe( + v.number(), + v.integer(), + v.minValue(0), + v.maxValue(11), +); +const yearNumber = v.pipe( + v.number(), + v.integer(), + v.minValue(2015), + v.maxValue(2100), +); export const calendarSearchParams = SearchParams.define({ modes: SP.param( - z.array(modeShortWithSpecial).min(1).max(modesShortWithSpecial.length), + v.pipe( + v.array(modeShortWithSpecial), + v.minLength(1), + v.maxLength(modesShortWithSpecial.length), + ), { default: [...modesShortWithSpecial], loader: true }, ), - modesExact: SP.param(z.boolean(), { default: false, loader: true }), - games: SP.param(z.array(gamesShortSchema).min(1).max(gamesShort.length), { - default: [...gamesShort], - loader: true, - }), + modesExact: SP.param(v.boolean(), { default: false, loader: true }), + games: SP.param( + v.pipe( + v.array(gamesShortSchema), + v.minLength(1), + v.maxLength(gamesShort.length), + ), + { + default: [...gamesShort], + loader: true, + }, + ), preferredVersus: SP.param( - z.array(z.enum(versusShort)).min(1).max(versusShort.length), + v.pipe( + v.array(v.picklist(versusShort)), + v.minLength(1), + v.maxLength(versusShort.length), + ), { default: [...versusShort], loader: true }, ), - preferredStartTime: SP.param(z.enum(["ANY", "EU", "NA", "AU"]), { + preferredStartTime: SP.param(v.picklist(["ANY", "EU", "NA", "AU"]), { default: "ANY", loader: true, }), @@ -55,43 +88,60 @@ export const calendarSearchParams = SearchParams.define({ default: [], loader: true, }), - isSendou: SP.param(z.boolean(), { default: false, loader: true }), - isRanked: SP.param(z.boolean(), { default: false, loader: true }), - minTeamCount: SP.param(z.number().int().nonnegative(), { + isSendou: SP.param(v.boolean(), { default: false, loader: true }), + isRanked: SP.param(v.boolean(), { default: false, loader: true }), + minTeamCount: SP.param(v.pipe(v.number(), v.integer(), v.minValue(0)), { default: 0, loader: true, }), minTier: SP.param(tierNumber, { default: BEST_TIER_NUMBER, loader: true }), maxTier: SP.param(tierNumber, { default: WORST_TIER_NUMBER, loader: true }), - orgsIncluded: SP.param(z.array(z.string().max(100)).max(10), { - default: [], - loader: true, - }), - orgsExcluded: SP.param(z.array(z.string().max(100)).max(10), { - default: [], - loader: true, - }), - authorIdsExcluded: SP.param(z.array(z.number().int().positive()).max(10), { - default: [], - loader: true, - }), + orgsIncluded: SP.param( + v.pipe(v.array(v.pipe(v.string(), v.maxLength(100))), v.maxLength(10)), + { + default: [], + loader: true, + }, + ), + orgsExcluded: SP.param( + v.pipe(v.array(v.pipe(v.string(), v.maxLength(100))), v.maxLength(10)), + { + default: [], + loader: true, + }, + ), + authorIdsExcluded: SP.param( + v.pipe( + v.array(v.pipe(v.number(), v.integer(), v.gtValue(0))), + v.maxLength(10), + ), + { + default: [], + loader: true, + }, + ), /** False once the user has edited the filters, making the URL win over their saved defaults. */ - useDefaults: SP.param(z.boolean(), { default: true, loader: true }), - day: SP.param(dayMonthYear.shape.day.nullable(), { loader: true }), - month: SP.param(dayMonthYear.shape.month.nullable(), { loader: true }), - year: SP.param(dayMonthYear.shape.year.nullable(), { loader: true }), + useDefaults: SP.param(v.boolean(), { default: true, loader: true }), + day: SP.param(v.nullable(dayNumber), { loader: true }), + month: SP.param(v.nullable(monthNumber), { loader: true }), + year: SP.param(v.nullable(yearNumber), { loader: true }), }); export const calendarEventsSearchParams = SearchParams.define({ - view: SP.param(z.enum(VIEW_FILTERS).nullable(), { loader: false }), + view: SP.param(v.nullable(v.picklist(VIEW_FILTERS)), { loader: false }), }); export const calendarNewSearchParams = SearchParams.define({ - eventId: SP.param(z.number().int().positive().nullable(), { loader: true }), - copyEventId: SP.param(z.number().int().positive().nullable(), { + eventId: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), { loader: true, }), - tournament: SP.param(z.boolean(), { + copyEventId: SP.param( + v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), + { + loader: true, + }, + ), + tournament: SP.param(v.boolean(), { default: false, loader: true, }), diff --git a/app/features/calendar/calendar-types.ts b/app/features/calendar/calendar-types.ts index 6fb7f683b..e30b2dc18 100644 --- a/app/features/calendar/calendar-types.ts +++ b/app/features/calendar/calendar-types.ts @@ -1,4 +1,4 @@ -import type { z } from "zod"; +import type * as v from "valibot"; import type { Tables } from "~/db/tables"; import type { tags } from "~/features/calendar/calendar-constants"; import type { calendarFiltersSearchParamsSchema } from "~/features/calendar/calendar-schemas"; @@ -73,4 +73,6 @@ export interface GroupedCalendarEvents { }; } -export type CalendarFilters = z.infer; +export type CalendarFilters = v.InferOutput< + typeof calendarFiltersSearchParamsSchema +>; diff --git a/app/features/calendar/calendar-urls.ts b/app/features/calendar/calendar-urls.ts index 658b31a4a..04077f047 100644 --- a/app/features/calendar/calendar-urls.ts +++ b/app/features/calendar/calendar-urls.ts @@ -1,5 +1,5 @@ +import type { DayMonthYear } from "~/utils/schema"; import { CALENDAR_PAGE, SENDOU_INK_BASE_URL } from "~/utils/urls"; -import type { DayMonthYear } from "~/utils/zod"; import { calendarNewSearchParams, calendarSearchParams, diff --git a/app/features/calendar/calendar-utils.ts b/app/features/calendar/calendar-utils.ts index 78be60709..a4e9b1a2d 100644 --- a/app/features/calendar/calendar-utils.ts +++ b/app/features/calendar/calendar-utils.ts @@ -1,7 +1,7 @@ import { addDays, addWeeks, startOfWeek, subWeeks } from "date-fns"; import { logger } from "~/utils/logger"; +import type { DayMonthYear } from "~/utils/schema"; import { assertUnreachable } from "~/utils/types"; -import type { DayMonthYear } from "~/utils/zod"; import { DAYS_SHOWN_AT_A_TIME, type RegClosesAtOption, diff --git a/app/features/calendar/loaders/calendar.$id.report-winners.server.ts b/app/features/calendar/loaders/calendar.$id.report-winners.server.ts index e03611844..44fabae17 100644 --- a/app/features/calendar/loaders/calendar.$id.report-winners.server.ts +++ b/app/features/calendar/loaders/calendar.$id.report-winners.server.ts @@ -2,7 +2,7 @@ import type { LoaderFunctionArgs } from "react-router"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; import { requirePermission } from "~/modules/permissions/guards.server"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; +import { idObject } from "~/utils/schema"; export const loader = async (args: LoaderFunctionArgs) => { const params = parseParams({ diff --git a/app/features/calendar/loaders/calendar.$id.server.ts b/app/features/calendar/loaders/calendar.$id.server.ts index dccd9d423..bfc23c529 100644 --- a/app/features/calendar/loaders/calendar.$id.server.ts +++ b/app/features/calendar/loaders/calendar.$id.server.ts @@ -2,8 +2,8 @@ import type { LoaderFunctionArgs } from "react-router"; import { redirect } from "react-router"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; +import { idObject } from "~/utils/schema"; import { tournamentPage } from "~/utils/urls"; -import { idObject } from "~/utils/zod"; export const loader = async (args: LoaderFunctionArgs) => { const params = parseParams({ diff --git a/app/features/calendar/loaders/calendar.server.ts b/app/features/calendar/loaders/calendar.server.ts index bb178ca82..ca412d210 100644 --- a/app/features/calendar/loaders/calendar.server.ts +++ b/app/features/calendar/loaders/calendar.server.ts @@ -1,6 +1,7 @@ import { add, startOfWeek, sub } from "date-fns"; import type { LoaderFunctionArgs } from "react-router"; import * as R from "remeda"; +import * as v from "valibot"; import type { UserPreferences } from "~/db/tables-json"; import { getUser } from "~/features/auth/core/user.server"; import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants"; @@ -47,7 +48,8 @@ export const loader = async (args: LoaderFunctionArgs) => { !R.isDeepEqual( filters, user.preferences?.defaultCalendarFilters - ? calendarFiltersSearchParamsSchema.parse( + ? v.parse( + calendarFiltersSearchParamsSchema, user.preferences.defaultCalendarFilters, ) : CalendarEvent.defaultFilters(), @@ -96,7 +98,8 @@ function resolveFilters( if (preferences?.defaultCalendarFilters) { // make sure the saved values still match current reality - const parsedDefault = calendarFiltersSearchParamsSchema.parse( + const parsedDefault = v.parse( + calendarFiltersSearchParamsSchema, preferences.defaultCalendarFilters, ); diff --git a/app/features/calendar/loaders/calendar[.]ics.server.ts b/app/features/calendar/loaders/calendar[.]ics.server.ts index a4613de90..74b70bed3 100644 --- a/app/features/calendar/loaders/calendar[.]ics.server.ts +++ b/app/features/calendar/loaders/calendar[.]ics.server.ts @@ -1,6 +1,7 @@ import type { LoaderFunctionArgs } from "react-router"; import * as R from "remeda"; -import { safeJSONParse } from "~/utils/zod"; +import * as v from "valibot"; +import { safeJSONParse } from "~/utils/schema"; import * as CalendarRepository from "../CalendarRepository.server"; import { calendarFiltersSearchParamsSchema } from "../calendar-schemas"; import { calendarSearchParams } from "../calendar-search-params"; @@ -48,10 +49,11 @@ function resolveFilters(request: Request) { // biome-ignore lint/plugin: legacy param no current route produces const legacyFilters = new URL(request.url).searchParams.get("filters"); if (legacyFilters !== null) { - const parsed = calendarFiltersSearchParamsSchema.safeParse( + const parsed = v.safeParse( + calendarFiltersSearchParamsSchema, safeJSONParse(legacyFilters), ); - if (parsed.success) return parsed.data; + if (parsed.success) return parsed.output; } return R.pick(calendarSearchParams.parse(request), [ diff --git a/app/features/calendar/routes/calendar.$id.test.ts b/app/features/calendar/routes/calendar.$id.test.ts index b158acb8e..4fd68e6ad 100644 --- a/app/features/calendar/routes/calendar.$id.test.ts +++ b/app/features/calendar/routes/calendar.$id.test.ts @@ -1,6 +1,6 @@ import { addDays } from "date-fns"; +import type * as v from "valibot"; import { describe, expect, test } from "vitest"; -import type { z } from "zod"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; @@ -8,7 +8,7 @@ import { dateToDatabaseTimestamp } from "~/utils/dates"; import { wrappedAction } from "~/utils/Test"; import { action } from "./calendar.$id"; -const deleteAction = wrappedAction>>({ +const deleteAction = wrappedAction>>({ action, }); diff --git a/app/features/calendar/routes/calendar.tsx b/app/features/calendar/routes/calendar.tsx index da6f4eb73..5b2bd4f11 100644 --- a/app/features/calendar/routes/calendar.tsx +++ b/app/features/calendar/routes/calendar.tsx @@ -30,8 +30,8 @@ import { useSearchParamsTyped } from "~/modules/search-params/hooks"; import { dayMonthYearToDateValue } from "~/utils/dates"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; +import type { DayMonthYear } from "~/utils/schema"; import { CALENDAR_PAGE, navIconUrl } from "~/utils/urls"; -import type { DayMonthYear } from "~/utils/zod"; import { action } from "../actions/calendar"; import { daysForCalendar } from "../calendar-utils"; import { FiltersBar } from "../components/FiltersBar"; diff --git a/app/features/chat/chat-last-read.ts b/app/features/chat/chat-last-read.ts index fb4f37779..baa0e224d 100644 --- a/app/features/chat/chat-last-read.ts +++ b/app/features/chat/chat-last-read.ts @@ -1,11 +1,11 @@ -import { z } from "zod"; +import * as v from "valibot"; import { usePersistedMapState } from "~/modules/persisted-state/hooks"; import * as PersistedState from "~/modules/persisted-state/persisted-state"; export const lastReadCountsPersisted = PersistedState.defineMap({ keyPrefix: "chat_read__", storage: "local", - schema: z.number(), + schema: v.number(), default: 0, }); diff --git a/app/features/chat/chat-search-params.ts b/app/features/chat/chat-search-params.ts index a8b2e564d..1887707b5 100644 --- a/app/features/chat/chat-search-params.ts +++ b/app/features/chat/chat-search-params.ts @@ -1,9 +1,9 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; export const chatUsersSearchParams = SearchParams.define({ - ids: SP.param(z.array(z.number().int().positive()), { + ids: SP.param(v.array(v.pipe(v.number(), v.integer(), v.gtValue(0))), { default: [], loader: true, }), diff --git a/app/features/comp-analyzer/comp-analyzer-search-params.ts b/app/features/comp-analyzer/comp-analyzer-search-params.ts index bd1c9fd9a..4815c91db 100644 --- a/app/features/comp-analyzer/comp-analyzer-search-params.ts +++ b/app/features/comp-analyzer/comp-analyzer-search-params.ts @@ -1,9 +1,9 @@ -import { z } from "zod"; +import * as v from "valibot"; import { MAX_AP } from "~/features/build-analyzer/analyzer-constants"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import { numericEnum } from "~/utils/zod"; +import { numericEnum } from "~/utils/schema"; import { MAX_WEAPONS } from "./comp-analyzer-constants"; import type { CategorizationType } from "./comp-analyzer-types"; @@ -14,21 +14,30 @@ const CATEGORIZATION_TYPES = [ ] as const satisfies readonly CategorizationType[]; export const compAnalyzerSearchParams = SearchParams.define({ - categorization: SP.param(z.enum(CATEGORIZATION_TYPES), { + categorization: SP.param(v.picklist(CATEGORIZATION_TYPES), { default: "category", loader: false, }), - weapons: SP.param(z.array(numericEnum(mainWeaponIds)).max(MAX_WEAPONS), { - default: [], - loader: false, - }), - singleCombos: SP.param(z.boolean(), { default: false, loader: false }), - subDef: SP.param(z.number().int().min(0).max(MAX_AP), { - default: 0, - loader: false, - }), - res: SP.param(z.number().int().min(0).max(MAX_AP), { - default: 0, - loader: false, - }), + weapons: SP.param( + v.pipe(v.array(numericEnum(mainWeaponIds)), v.maxLength(MAX_WEAPONS)), + { + default: [], + loader: false, + }, + ), + singleCombos: SP.param(v.boolean(), { default: false, loader: false }), + subDef: SP.param( + v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(MAX_AP)), + { + default: 0, + loader: false, + }, + ), + res: SP.param( + v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(MAX_AP)), + { + default: 0, + loader: false, + }, + ), }); diff --git a/app/features/components-showcase/form-examples-schema.ts b/app/features/components-showcase/form-examples-schema.ts index 540b3104a..f056d0aa3 100644 --- a/app/features/components-showcase/form-examples-schema.ts +++ b/app/features/components-showcase/form-examples-schema.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { checkboxGroup, customField, @@ -24,7 +24,7 @@ import { weaponSelectOptional, } from "~/form/fields"; -export const formFieldsShowcaseSchema = z.object({ +export const formFieldsShowcaseSchema = v.object({ // Text fields requiredText: textField({ label: "labels.name", @@ -162,6 +162,6 @@ export const formFieldsShowcaseSchema = z.object({ // Custom field customValue: customField( { initialValue: "custom initial value" }, - z.string().optional(), + v.optional(v.string()), ), }); diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index e10168f46..4fdf549d0 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -2746,7 +2746,7 @@ function FormFieldsSection({ id }: { id: string }) { Form Fields

Schema-based form fields using SendouForm. Each field type is defined - with Zod schemas that generate both UI and validation. + with valibot schemas that generate both UI and validation.

{ +const sendFriendRequestSchemaServer = v.pipeAsync( + sendFriendRequestBaseSchema, + superRefineAsync(async (data, ctx) => { const user = requireUser(); if (data.userId === user.id) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.cannotFriendSelf", path: ["userId"], }); @@ -28,7 +29,6 @@ const sendFriendRequestSchemaServer = sendFriendRequestBaseSchema.superRefine( }); if (existingFriendship) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.alreadyFriends", path: ["userId"], }); @@ -41,15 +41,14 @@ const sendFriendRequestSchemaServer = sendFriendRequestBaseSchema.superRefine( }); if (existingRequest) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.friendRequestExists", path: ["userId"], }); } - }, + }), ); -export const friendsActionSchema = z.union([ +export const friendsActionSchema = v.unionAsync([ sendFriendRequestSchemaServer, cancelFriendRequestSchema, deleteFriendSchema, diff --git a/app/features/friends/friends-schemas.ts b/app/features/friends/friends-schemas.ts index 33a746233..7dda77d34 100644 --- a/app/features/friends/friends-schemas.ts +++ b/app/features/friends/friends-schemas.ts @@ -1,28 +1,28 @@ -import { z } from "zod"; +import * as v from "valibot"; import { stringConstant, userSearch } from "~/form/fields"; -import { _action, id } from "~/utils/zod"; +import { _action, id } from "~/utils/schema"; -export const sendFriendRequestBaseSchema = z.object({ +export const sendFriendRequestBaseSchema = v.object({ _action: stringConstant("SEND_REQUEST"), userId: userSearch({ label: "labels.friendUser" }), }); -export const cancelFriendRequestSchema = z.object({ +export const cancelFriendRequestSchema = v.object({ _action: _action("CANCEL_REQUEST"), friendRequestId: id, }); -export const deleteFriendSchema = z.object({ +export const deleteFriendSchema = v.object({ _action: _action("DELETE_FRIEND"), friendshipId: id, }); -export const acceptFriendRequestSchema = z.object({ +export const acceptFriendRequestSchema = v.object({ _action: _action("ACCEPT_REQUEST"), friendRequestId: id, }); -export const declineFriendRequestSchema = z.object({ +export const declineFriendRequestSchema = v.object({ _action: _action("DECLINE_REQUEST"), friendRequestId: id, }); diff --git a/app/features/friends/friends-search-params.ts b/app/features/friends/friends-search-params.ts index 6bafc90a8..e4dea086e 100644 --- a/app/features/friends/friends-search-params.ts +++ b/app/features/friends/friends-search-params.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; @@ -6,5 +6,5 @@ export const VIEW_FILTERS = ["friends", "team", "all"] as const; export type ViewFilter = (typeof VIEW_FILTERS)[number]; export const friendsSearchParams = SearchParams.define({ - view: SP.param(z.enum(VIEW_FILTERS).nullable(), { loader: false }), + view: SP.param(v.nullable(v.picklist(VIEW_FILTERS)), { loader: false }), }); diff --git a/app/features/front-page/components/PWAInstallBanner.tsx b/app/features/front-page/components/PWAInstallBanner.tsx index cdb922bcc..a4308179f 100644 --- a/app/features/front-page/components/PWAInstallBanner.tsx +++ b/app/features/front-page/components/PWAInstallBanner.tsx @@ -1,7 +1,7 @@ import { Download } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { z } from "zod"; +import * as v from "valibot"; import { SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; import { usePersistedState } from "~/modules/persisted-state/hooks"; @@ -20,7 +20,7 @@ const SAFARI_MIN_INSTALL_VERSION = 17; const bannerDismissedPersisted = PersistedState.define({ key: "pwa-install-banner-dismissed", storage: "local", - schema: z.boolean(), + schema: v.boolean(), default: false, }); diff --git a/app/features/front-page/core/Changelog.server.ts b/app/features/front-page/core/Changelog.server.ts index 0b11456c5..6596530ff 100644 --- a/app/features/front-page/core/Changelog.server.ts +++ b/app/features/front-page/core/Changelog.server.ts @@ -1,5 +1,5 @@ import { formatDistance } from "date-fns"; -import { z } from "zod"; +import * as v from "valibot"; import { logger } from "~/utils/logger"; const BSKY_URL = @@ -7,48 +7,60 @@ const BSKY_URL = const CHANGE_LOG_ITEMS_MAX = 6; -const postsSchema = z.object({ - feed: z.array( - z.object({ - post: z.object({ - uri: z.string(), - record: z.object({ - $type: z.string(), - createdAt: z.string(), - facets: z - .array( - z.object({ - features: z.array( - z.object({ $type: z.string(), tag: z.string().nullish() }), - ), - index: z.object({ byteEnd: z.number(), byteStart: z.number() }), - }), - ) - .nullish(), - text: z.string(), - }), - embed: z - .object({ - $type: z.string(), - images: z - .array( - z.object({ - thumb: z.string(), - fullsize: z.string(), - alt: z.string(), - aspectRatio: z.object({ - height: z.number(), - width: z.number(), +const postsSchema = v.object({ + feed: v.array( + v.object({ + post: v.object({ + uri: v.string(), + record: v.object({ + $type: v.string(), + createdAt: v.string(), + facets: v.optional( + v.nullable( + v.array( + v.object({ + features: v.array( + v.object({ + $type: v.string(), + tag: v.nullish(v.string()), + }), + ), + index: v.object({ + byteEnd: v.number(), + byteStart: v.number(), }), }), - ) - .nullish(), - }) - .nullish(), - replyCount: z.number(), - repostCount: z.number(), - likeCount: z.number(), - quoteCount: z.number(), + ), + ), + ), + text: v.string(), + }), + embed: v.optional( + v.nullable( + v.object({ + $type: v.string(), + images: v.optional( + v.nullable( + v.array( + v.object({ + thumb: v.string(), + fullsize: v.string(), + alt: v.string(), + aspectRatio: v.object({ + height: v.number(), + width: v.number(), + }), + }), + ), + ), + ), + }), + ), + ), + replyCount: v.number(), + repostCount: v.number(), + likeCount: v.number(), + quoteCount: v.number(), }), }), ), @@ -73,7 +85,7 @@ export async function get() { return result; } -type RawPost = z.infer["feed"][number]["post"]; +type RawPost = v.InferOutput["feed"][number]["post"]; export interface ChangelogItem { id: string; @@ -107,12 +119,12 @@ async function fetchPosts() { } function parsePosts(data: unknown) { - const result = postsSchema.safeParse(data); + const result = v.safeParse(postsSchema, data); if (!result.success) { - throw new Error(`Failed to parse posts: ${result.error.message}`); + throw new Error(`Failed to parse posts: ${v.summarize(result.issues)}`); } - return result.data.feed.map((feed) => feed.post); + return result.output.feed.map((feed) => feed.post); } function postHasSendouInkTag(post: RawPost) { diff --git a/app/features/img-upload/upload-schemas.ts b/app/features/img-upload/upload-schemas.ts index 83590e660..5eabe587d 100644 --- a/app/features/img-upload/upload-schemas.ts +++ b/app/features/img-upload/upload-schemas.ts @@ -1,14 +1,17 @@ -import { z } from "zod"; -import { _action, id, safeJSONParse } from "~/utils/zod"; +import * as v from "valibot"; +import { _action, id, preprocess, safeJSONParse } from "~/utils/schema"; -const validateManySchema = z.object({ +const validateManySchema = v.object({ _action: _action("VALIDATE"), - imageIds: z.preprocess(safeJSONParse, z.array(id).min(1).max(5)), + imageIds: preprocess( + safeJSONParse, + v.pipe(v.array(id), v.minLength(1), v.maxLength(5)), + ), }); -const rejectSchema = z.object({ +const rejectSchema = v.object({ _action: _action("REJECT"), imageId: id, }); -export const validateImageSchema = z.union([validateManySchema, rejectSchema]); +export const validateImageSchema = v.union([validateManySchema, rejectSchema]); diff --git a/app/features/leaderboards/leaderboards-schemas.ts b/app/features/leaderboards/leaderboards-schemas.ts index 33df74d9b..58a8bd5b6 100644 --- a/app/features/leaderboards/leaderboards-schemas.ts +++ b/app/features/leaderboards/leaderboards-schemas.ts @@ -1,21 +1,22 @@ -import { z } from "zod"; +import * as v from "valibot"; import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils"; -import { _action } from "~/utils/zod"; +import { _action, coerceNumber } from "~/utils/schema"; const teamLeaderboardEntry = { - season: z.coerce.number().int().nonnegative(), - identifier: z - .string() - .regex(/^\d+-\d+-\d+-\d+$/) - .pipe(z.custom()), + season: v.pipe(coerceNumber(), v.integer(), v.minValue(0)), + identifier: v.pipe( + v.string(), + v.regex(/^\d+-\d+-\d+-\d+$/), + v.custom(() => true), + ), }; -export const leaderboardsActionSchema = z.union([ - z.object({ +export const leaderboardsActionSchema = v.union([ + v.object({ _action: _action("SKIP_TEAM"), ...teamLeaderboardEntry, }), - z.object({ + v.object({ _action: _action("UNSKIP_TEAM"), ...teamLeaderboardEntry, }), diff --git a/app/features/leaderboards/leaderboards-search-params.ts b/app/features/leaderboards/leaderboards-search-params.ts index 9a5325956..a0a962b1d 100644 --- a/app/features/leaderboards/leaderboards-search-params.ts +++ b/app/features/leaderboards/leaderboards-search-params.ts @@ -1,12 +1,14 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; import { LEADERBOARD_TYPES } from "./leaderboards-constants"; export const leaderboardsSearchParams = SearchParams.define({ - type: SP.param(z.enum(LEADERBOARD_TYPES), { + type: SP.param(v.picklist(LEADERBOARD_TYPES), { default: LEADERBOARD_TYPES[0], loader: true, }), - season: SP.param(z.number().int().nullable(), { loader: true }), + season: SP.param(v.nullable(v.pipe(v.number(), v.integer())), { + loader: true, + }), }); diff --git a/app/features/lfg/lfg-schemas.ts b/app/features/lfg/lfg-schemas.ts index b04983bbc..5b8b84358 100644 --- a/app/features/lfg/lfg-schemas.ts +++ b/app/features/lfg/lfg-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { LANGUAGE_OPTIONS } from "~/features/settings/match-profile-schemas"; import { checkboxGroup, @@ -7,11 +7,11 @@ import { selectDynamicOptional, textArea, } from "~/form/fields"; -import { _action, id } from "~/utils/zod"; +import { _action, id, superRefine } from "~/utils/schema"; import { LFG, TIMEZONES } from "./lfg-constants"; -export const lfgNewSchema = z - .object({ +export const lfgNewSchema = v.pipe( + v.object({ postId: idConstantOptional(), type: selectDynamic({ label: "labels.type" }), timezone: selectDynamic({ label: "labels.timezone" }), @@ -26,25 +26,30 @@ export const lfgNewSchema = z label: "labels.languages", items: LANGUAGE_OPTIONS, }), - }) - .refine( - (data) => LFG.types.includes(data.type as (typeof LFG.types)[number]), - { - message: "Invalid LFG type", - path: ["type"], - }, - ) - .refine((data) => TIMEZONES.includes(data.timezone), { - message: "Invalid timezone", - path: ["timezone"], - }); + }), + superRefine((data, ctx) => { + if (!LFG.types.includes(data.type as (typeof LFG.types)[number])) { + ctx.addIssue({ + message: "Invalid LFG type", + path: ["type"], + }); + } -export const lfgActionSchema = z.union([ - z.object({ + if (!TIMEZONES.includes(data.timezone)) { + ctx.addIssue({ + message: "Invalid timezone", + path: ["timezone"], + }); + } + }), +); + +export const lfgActionSchema = v.union([ + v.object({ _action: _action("DELETE_POST"), id, }), - z.object({ + v.object({ _action: _action("BUMP_POST"), id, }), diff --git a/app/features/lfg/lfg-search-params.ts b/app/features/lfg/lfg-search-params.ts index b9992fd16..e4b586a62 100644 --- a/app/features/lfg/lfg-search-params.ts +++ b/app/features/lfg/lfg-search-params.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { TIERS, type TierName } from "~/features/mmr/mmr-constants"; import { languagesUnified, @@ -7,7 +7,7 @@ import { import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import { numericEnum } from "~/utils/zod"; +import { numericEnum } from "~/utils/schema"; import { LFG, LFG_TYPES } from "./lfg-constants"; const LANGUAGE_CODES = languagesUnified.map((language) => language.code) as [ @@ -21,22 +21,32 @@ const FILTER_OPTIONS = { loader: true, resets: ["page", "post"] }; export const lfgSearchParams = SearchParams.define({ page: SP.page({ resets: ["post"] }), /** Post to jump to: the loader serves the page containing it, overriding `page`. */ - post: SP.param(z.number().int().positive().nullable(), { loader: true }), + post: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), { + loader: true, + }), weapons: SP.param( - z.array(numericEnum(mainWeaponIds)).max(LFG.MAX_WEAPON_FILTERS), + v.pipe( + v.array(numericEnum(mainWeaponIds)), + v.maxLength(LFG.MAX_WEAPON_FILTERS), + ), { default: [], ...FILTER_OPTIONS }, ), - type: SP.param(z.enum(LFG_TYPES).nullable(), FILTER_OPTIONS), + type: SP.param(v.nullable(v.picklist(LFG_TYPES)), FILTER_OPTIONS), timezone: SP.param( - z.number().int().min(0).max(12).nullable(), + v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(12))), FILTER_OPTIONS, ), - language: SP.param(z.enum(LANGUAGE_CODES).nullable(), FILTER_OPTIONS), - plusTier: SP.param(z.number().int().min(1).max(3).nullable(), FILTER_OPTIONS), - minTier: SP.param(z.enum(TIER_NAMES).nullable(), FILTER_OPTIONS), - maxTier: SP.param(z.enum(TIER_NAMES).nullable(), FILTER_OPTIONS), + language: SP.param(v.nullable(v.picklist(LANGUAGE_CODES)), FILTER_OPTIONS), + plusTier: SP.param( + v.nullable(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(3))), + FILTER_OPTIONS, + ), + minTier: SP.param(v.nullable(v.picklist(TIER_NAMES)), FILTER_OPTIONS), + maxTier: SP.param(v.nullable(v.picklist(TIER_NAMES)), FILTER_OPTIONS), }); export const lfgNewSearchParams = SearchParams.define({ - postId: SP.param(z.number().int().positive().nullable(), { loader: true }), + postId: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), { + loader: true, + }), }); diff --git a/app/features/map-list-generator/map-list-generator-search-params.ts b/app/features/map-list-generator/map-list-generator-search-params.ts index b6413b7c4..a01cd67a9 100644 --- a/app/features/map-list-generator/map-list-generator-search-params.ts +++ b/app/features/map-list-generator/map-list-generator-search-params.ts @@ -1,17 +1,17 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; import { MapPool } from "./core/map-pool"; export const mapListGeneratorSearchParams = SearchParams.define({ - pool: SP.param(z.string(), { + pool: SP.param(v.string(), { default: MapPool.ANARCHY.serialized, resets: ["eventId"], loader: false, }), - eventId: SP.param(z.number().int().positive().nullable(), { + eventId: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), { resets: ["pool"], loader: false, }), - readonly: SP.param(z.boolean(), { default: false, loader: false }), + readonly: SP.param(v.boolean(), { default: false, loader: false }), }); diff --git a/app/features/notifications/notifications-schemas.ts b/app/features/notifications/notifications-schemas.ts index b7ee1b258..1089f23c1 100644 --- a/app/features/notifications/notifications-schemas.ts +++ b/app/features/notifications/notifications-schemas.ts @@ -1,15 +1,24 @@ -import { z } from "zod"; -import { id } from "~/utils/zod"; +import * as v from "valibot"; +import { id } from "~/utils/schema"; import { NOTIFICATIONS } from "./notifications-contants"; -export const markAsSeenActionSchema = z.object({ - notificationIds: z.array(id).min(1).max(NOTIFICATIONS.MAX_SHOWN), +export const markAsSeenActionSchema = v.object({ + notificationIds: v.pipe( + v.array(id), + v.minLength(1), + v.maxLength(NOTIFICATIONS.MAX_SHOWN), + ), }); -export const subscribeSchema = z.object({ - endpoint: z.string().url().startsWith("https://").max(2048), - keys: z.object({ - auth: z.string().max(1024), - p256dh: z.string().max(1024), +export const subscribeSchema = v.object({ + endpoint: v.pipe( + v.string(), + v.url(), + v.startsWith("https://"), + v.maxLength(2048), + ), + keys: v.object({ + auth: v.pipe(v.string(), v.maxLength(1024)), + p256dh: v.pipe(v.string(), v.maxLength(1024)), }), }); diff --git a/app/features/object-damage-calculator/calculator-search-params.ts b/app/features/object-damage-calculator/calculator-search-params.ts index ff6933f78..45121f2f7 100644 --- a/app/features/object-damage-calculator/calculator-search-params.ts +++ b/app/features/object-damage-calculator/calculator-search-params.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { DAMAGE_TYPE, possibleApValues, @@ -18,40 +18,39 @@ import { weaponCategories, } from "~/modules/in-game-lists/weapon-ids"; import * as SearchParams from "~/modules/search-params/search-params"; -import { SP } from "~/modules/search-params/search-params"; +import { codec, SP } from "~/modules/search-params/search-params"; const DEFAULT_ANY_WEAPON: AnyWeapon = { type: "MAIN", id: weaponCategories[0].weaponIds[0], }; -const anyWeapon = z.codec(z.string(), z.custom(), { - decode: (value, payload) => { - const decoded = decodeAnyWeapon(value); - if (!decoded) { - payload.issues.push({ - code: "custom", - message: "Invalid weapon", - input: value, - }); - return z.NEVER; - } - return decoded; +const anyWeapon = codec( + v.custom(() => true), + { + decode: (value) => { + const decoded = decodeAnyWeapon(value); + if (!decoded) { + return undefined; + } + return decoded; + }, + encode: (weapon) => `${weapon.type}_${weapon.id}`, }, - encode: (weapon) => `${weapon.type}_${weapon.id}`, -}); +); export const calculatorSearchParams = SearchParams.define({ weapon: SP.custom(anyWeapon, { default: DEFAULT_ANY_WEAPON, loader: false }), ap: SP.param( - z - .number() - .int() - .refine((value) => possibleApValues().includes(value)), + v.pipe( + v.number(), + v.integer(), + v.check((value) => possibleApValues().includes(value)), + ), { default: 0, loader: false }, ), - dmg: SP.param(z.enum(DAMAGE_TYPE).nullable(), { loader: false }), - multi: SP.param(z.boolean(), { default: true, loader: false }), + dmg: SP.param(v.nullable(v.picklist(DAMAGE_TYPE)), { loader: false }), + multi: SP.param(v.boolean(), { default: true, loader: false }), }); function decodeAnyWeapon(value: string): AnyWeapon | null { diff --git a/app/features/params/params-search-params.ts b/app/features/params/params-search-params.ts index 202cb784b..f2bc9c933 100644 --- a/app/features/params/params-search-params.ts +++ b/app/features/params/params-search-params.ts @@ -1,18 +1,18 @@ -import { z } from "zod"; +import * as v from "valibot"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import { numericEnum } from "~/utils/zod"; +import { numericEnum } from "~/utils/schema"; export const weaponParamsSearchParams = SearchParams.define({ - tab: SP.param(z.enum(["params", "patches"]), { + tab: SP.param(v.picklist(["params", "patches"]), { default: "params", loader: false, }), - hidden: SP.param(z.array(z.number().int()), { + hidden: SP.param(v.array(v.pipe(v.number(), v.integer())), { default: [], loader: false, }), - kit: SP.param(numericEnum(mainWeaponIds).nullable(), { loader: false }), - kitExtras: SP.param(z.boolean(), { default: true, loader: false }), + kit: SP.param(v.nullable(numericEnum(mainWeaponIds)), { loader: false }), + kitExtras: SP.param(v.boolean(), { default: true, loader: false }), }); diff --git a/app/features/plus-suggestions/plus-suggestions-schemas.server.ts b/app/features/plus-suggestions/plus-suggestions-schemas.server.ts index 2e25dd5d5..d0748516c 100644 --- a/app/features/plus-suggestions/plus-suggestions-schemas.server.ts +++ b/app/features/plus-suggestions/plus-suggestions-schemas.server.ts @@ -1,14 +1,16 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server"; import { nextNonCompletedVoting, rangeToMonthYear, } from "~/features/plus-voting/core"; import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { superRefineAsync } from "~/utils/schema"; import { newSuggestionFormSchema } from "./plus-suggestions-schemas"; -export const newSuggestionFormSchemaServer = - newSuggestionFormSchema.superRefine(async (data, ctx) => { +export const newSuggestionFormSchemaServer = v.pipeAsync( + newSuggestionFormSchema, + superRefineAsync(async (data, ctx) => { const suggested = await UserRepository.findLeanById(data.userId); if (!suggested) return; @@ -16,7 +18,6 @@ export const newSuggestionFormSchemaServer = if (suggested.plusTier && suggested.plusTier <= targetPlusTier) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.plusAlreadyMember", path: ["userId"], }); @@ -35,9 +36,9 @@ export const newSuggestionFormSchemaServer = ); if (alreadySuggested) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.plusAlreadySuggested", path: ["userId"], }); } - }); + }), +); diff --git a/app/features/plus-suggestions/plus-suggestions-schemas.ts b/app/features/plus-suggestions/plus-suggestions-schemas.ts index fab49f152..a24edbe43 100644 --- a/app/features/plus-suggestions/plus-suggestions-schemas.ts +++ b/app/features/plus-suggestions/plus-suggestions-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { idConstant, selectDynamic, @@ -6,10 +6,10 @@ import { textArea, userSearch, } from "~/form/fields"; -import { _action, actualNumber } from "~/utils/zod"; +import { _action, actualNumber, preprocess } from "~/utils/schema"; import { PLUS_TIERS } from "./plus-suggestions-constants"; -export const followUpCommentFormSchema = z.object({ +export const followUpCommentFormSchema = v.object({ tier: idConstant(), suggestedId: idConstant(), comment: textArea({ @@ -23,32 +23,33 @@ const suggestionTextFormFieldSchema = textArea({ maxLength: 500, }); -export const newSuggestionFormSchema = z.object({ +export const newSuggestionFormSchema = v.object({ tier: selectDynamic({ label: "labels.plusTier" }), userId: userSearch({ label: "labels.user" }), comment: suggestionTextFormFieldSchema, }); -export const editSuggestionFormSchema = z.object({ +export const editSuggestionFormSchema = v.object({ _action: stringConstant("EDIT_SUGGESTION"), suggestionId: idConstant(), comment: suggestionTextFormFieldSchema, }); -export const suggestionActionSchema = z.union([ +export const suggestionActionSchema = v.union([ editSuggestionFormSchema, - z.object({ + v.object({ _action: _action("DELETE_COMMENT"), - suggestionId: z.preprocess(actualNumber, z.number()), + suggestionId: preprocess(actualNumber, v.number()), }), - z.object({ + v.object({ _action: _action("DELETE_SUGGESTION_OF_THEMSELVES"), - tier: z.preprocess( + tier: preprocess( actualNumber, - z - .number() - .min(Math.min(...PLUS_TIERS)) - .max(Math.max(...PLUS_TIERS)), + v.pipe( + v.number(), + v.minValue(Math.min(...PLUS_TIERS)), + v.maxValue(Math.max(...PLUS_TIERS)), + ), ), }), ]); diff --git a/app/features/plus-suggestions/plus-suggestions-search-params.ts b/app/features/plus-suggestions/plus-suggestions-search-params.ts index 4409f2461..2aa4d48cc 100644 --- a/app/features/plus-suggestions/plus-suggestions-search-params.ts +++ b/app/features/plus-suggestions/plus-suggestions-search-params.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; @@ -7,9 +7,12 @@ export const PLUS_TIER_PARAMS = ["1", "2", "3"] as const; export type PlusTierParam = (typeof PLUS_TIER_PARAMS)[number]; export const plusSuggestionsSearchParams = SearchParams.define({ - tier: SP.param(z.enum(PLUS_TIER_PARAMS), { default: "1", loader: true }), - alert: SP.param(z.boolean(), { default: false, loader: false }), - editingSuggestionId: SP.param(z.number().int().positive().nullable(), { - loader: false, - }), + tier: SP.param(v.picklist(PLUS_TIER_PARAMS), { default: "1", loader: true }), + alert: SP.param(v.boolean(), { default: false, loader: false }), + editingSuggestionId: SP.param( + v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), + { + loader: false, + }, + ), }); diff --git a/app/features/plus-voting/plus-voting-schemas.ts b/app/features/plus-voting/plus-voting-schemas.ts index a6386561b..76398a73d 100644 --- a/app/features/plus-voting/plus-voting-schemas.ts +++ b/app/features/plus-voting/plus-voting-schemas.ts @@ -1,16 +1,19 @@ -import { z } from "zod"; +import * as v from "valibot"; import type { PlusVoteFromFE } from "~/features/plus-voting/core"; +import { preprocess, safeJSONParse } from "~/utils/schema"; import { assertType } from "~/utils/types"; -import { safeJSONParse } from "~/utils/zod"; import { PLUS_DOWNVOTE, PLUS_UPVOTE } from "./plus-voting-constants"; -const voteSchema = z.object({ - votedId: z.number(), - score: z.number().refine((val) => [PLUS_DOWNVOTE, PLUS_UPVOTE].includes(val)), +const voteSchema = v.object({ + votedId: v.number(), + score: v.pipe( + v.number(), + v.check((val) => [PLUS_DOWNVOTE, PLUS_UPVOTE].includes(val)), + ), }); -assertType, PlusVoteFromFE>(); +assertType, PlusVoteFromFE>(); -export const votingActionSchema = z.object({ - votes: z.preprocess(safeJSONParse, z.array(voteSchema)), +export const votingActionSchema = v.object({ + votes: preprocess(safeJSONParse, v.array(voteSchema)), }); diff --git a/app/features/scanner-ingest/core/VodMatches.test.ts b/app/features/scanner-ingest/core/VodMatches.test.ts index 160ff295f..50adb8b08 100644 --- a/app/features/scanner-ingest/core/VodMatches.test.ts +++ b/app/features/scanner-ingest/core/VodMatches.test.ts @@ -1,3 +1,4 @@ +import * as v from "valibot"; import { describe, expect, test } from "vitest"; import { vodsNewSearchParams } from "~/features/vods/vods-search-params"; import { @@ -57,7 +58,7 @@ describe("prefillVodMatches", () => { }); test("rejects rows that are not sendou ids", () => { - const parsed = ingestVodPrefillSchema.safeParse({ + const parsed = v.safeParse(ingestVodPrefillSchema, { matches: [{ ...testMatch(), stage: "Scorch Gorge" }], }); expect(parsed.success).toBe(false); diff --git a/app/features/scanner-ingest/scanner-ingest-schemas.ts b/app/features/scanner-ingest/scanner-ingest-schemas.ts index fafca3589..6c4605ff6 100644 --- a/app/features/scanner-ingest/scanner-ingest-schemas.ts +++ b/app/features/scanner-ingest/scanner-ingest-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { scannerMatchSchema } from "~/features/scanner/scanner-schemas"; const MAX_MATCHES_PER_REQUEST = 50; @@ -9,8 +9,12 @@ const MAX_MATCHES_PER_REQUEST = 50; * scanner domain); this module only adds the ingest-specific envelope. The * POV user is always the session user, never client-supplied. */ -export const ingestBodySchema = z.object({ - matches: z.array(scannerMatchSchema).min(1).max(MAX_MATCHES_PER_REQUEST), +export const ingestBodySchema = v.object({ + matches: v.pipe( + v.array(scannerMatchSchema), + v.minLength(1), + v.maxLength(MAX_MATCHES_PER_REQUEST), + ), }); /** The sendou.ink match an ingested match's scoreboard was linked to. */ diff --git a/app/features/scanner-ingest/scanner-ingest-vod-schemas.ts b/app/features/scanner-ingest/scanner-ingest-vod-schemas.ts index 9e6d653a2..e9c9a1b49 100644 --- a/app/features/scanner-ingest/scanner-ingest-vod-schemas.ts +++ b/app/features/scanner-ingest/scanner-ingest-vod-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { mainWeaponIdSchema, modeShortSchema, @@ -7,27 +7,27 @@ import { import { videoMatchTypes } from "~/features/vods/vods-constants"; /** One detected match of a scanner VoD scan, projected from a ScannerMatch (~/features/scanner/components/sendou-upload.ts). */ -const ingestVodMatchSchema = z.object({ +const ingestVodMatchSchema = v.object({ /** whole seconds into the video the match starts at */ - startsAt: z.number().int().min(0), + startsAt: v.pipe(v.number(), v.integer(), v.minValue(0)), /** null when no source read it */ - mode: modeShortSchema.nullable(), + mode: v.nullable(modeShortSchema), /** * true when `mode` is the scanner's fabricated PoC default (SZ) rather * than a real read. Currently informational only — assumed modes are * still stored, since casted footage never exposes the mode. */ - modeAssumed: z.boolean().optional(), + modeAssumed: v.optional(v.boolean()), /** null when no source read it */ - stage: stageIdSchema.nullable(), + stage: v.nullable(stageIdSchema), /** sendou main-weapon ids; null for a slot that never read */ - weapons: z.array(mainWeaponIdSchema.nullable()).max(16), + weapons: v.pipe(v.array(v.nullable(mainWeaponIdSchema)), v.maxLength(16)), /** * the POV player's weapon, prefilling a non-CAST VoD's single weapon * select. Absent when no scoreboard identified the POV seat (or it read * no weapon) — including on casted footage, which has no POV. */ - povWeapon: mainWeaponIdSchema.optional(), + povWeapon: v.optional(mainWeaponIdSchema), }); /** @@ -38,10 +38,14 @@ const ingestVodMatchSchema = z.object({ * sent only when the scan auto-detected it (spectator map screens → CAST); * absent means the form's default. */ -export const ingestVodPrefillSchema = z.object({ - type: z.enum(videoMatchTypes).optional(), - matches: z.array(ingestVodMatchSchema).min(1).max(100), +export const ingestVodPrefillSchema = v.object({ + type: v.optional(v.picklist(videoMatchTypes)), + matches: v.pipe( + v.array(ingestVodMatchSchema), + v.minLength(1), + v.maxLength(100), + ), }); -export type IngestVodMatchInput = z.infer; -export type IngestVodPrefill = z.infer; +export type IngestVodMatchInput = v.InferOutput; +export type IngestVodPrefill = v.InferOutput; diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index a51adfa82..43f79ceeb 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -64,7 +64,7 @@ sequenceDiagram - `core/` is pure (mats in, events/matches out) and runs in the worker, the Screenshot tab, and Node tests. No DOM/browser APIs; Node-only helpers live in `node/`. Pure data/type imports from `~/modules` and - `~/features/build-analyzer/data` are fine — zod and the app config graph + `~/features/build-analyzer/data` are fine — valibot and the app config graph are not (schemas live in `scanner-schemas.ts`; core only `import type`s the shapes). - `core/match-builder.ts` turns a timeline into `ScannerMatch`es: a MapStart diff --git a/app/features/scanner/scanner-schemas.ts b/app/features/scanner/scanner-schemas.ts index 5b39b9083..503ae3575 100644 --- a/app/features/scanner/scanner-schemas.ts +++ b/app/features/scanner/scanner-schemas.ts @@ -1,15 +1,15 @@ /** - * Zod schemas for the scanner domain — the single source of truth shared by + * Valibot schemas for the scanner domain — the single source of truth shared by * the producer (the scanner match builder/UI in this feature) and the * validator (features/scanner-ingest). Every domain field is a sendou.ink id * type; the compile-time asserts at the bottom pin each schema to the * corresponding core interface so producer and validator cannot drift. * * The core/worker modules consume only the *types* (type-only imports point - * the other way), so zod never enters the worker bundle; runtime validation + * the other way), so valibot never enters the worker bundle; runtime validation * happens at the boundaries (ingest action, prefill loader). */ -import { z } from "zod"; +import * as v from "valibot"; import { abilities } from "~/modules/in-game-lists/abilities"; import { modesShort } from "~/modules/in-game-lists/modes"; import { stageIds } from "~/modules/in-game-lists/stage-ids"; @@ -24,95 +24,105 @@ import type { } from "./core/scanner-match"; import { SCANNER_LOBBIES } from "./scanner-types"; -const detectionText = z.string().max(500); +const detectionText = v.pipe(v.string(), v.maxLength(500)); -const scannerLobbySchema = z.enum(SCANNER_LOBBIES); -export const modeShortSchema = z.enum(modesShort); -export const stageIdSchema = z.literal(stageIds); -export const mainWeaponIdSchema = z.literal(mainWeaponIds); +const scannerLobbySchema = v.picklist(SCANNER_LOBBIES); +export const modeShortSchema = v.picklist(modesShort); +export const stageIdSchema = v.picklist(stageIds); +export const mainWeaponIdSchema = v.picklist(mainWeaponIds); const abilityNames = abilities.map((ability) => ability.name) as Ability[]; /** a sendou ability id, or the detectors' explicit unrecognized marker */ -const scannerAbilitySchema = z.union([ - z.literal(abilityNames), - z.literal("UNKNOWN"), +const scannerAbilitySchema = v.union([ + v.picklist(abilityNames), + v.literal("UNKNOWN"), ]); -const scannerMatchPlayerSchema = z.object({ - name: detectionText.nullable(), - weaponId: mainWeaponIdSchema.nullable(), - paint: z.number().nullable(), - ka: z.number().nullable(), - d: z.number().nullable(), - s: z.number().nullable(), +const scannerMatchPlayerSchema = v.object({ + name: v.nullable(detectionText), + weaponId: v.nullable(mainWeaponIdSchema), + paint: v.nullable(v.number()), + ka: v.nullable(v.number()), + d: v.nullable(v.number()), + s: v.nullable(v.number()), /** [head, clothes, shoes] ability rows harvested from death screens */ - abilities: z.array(z.array(scannerAbilitySchema).max(4)).max(3).optional(), + abilities: v.optional( + v.pipe( + v.array(v.pipe(v.array(scannerAbilitySchema), v.maxLength(4))), + v.maxLength(3), + ), + ), }); -const scannerMatchTeamSchema = z.object({ - players: z.array(scannerMatchPlayerSchema).max(4), +const scannerMatchTeamSchema = v.object({ + players: v.pipe(v.array(scannerMatchPlayerSchema), v.maxLength(4)), }); -const teamIndexSchema = z.union([z.literal(0), z.literal(1)]); +const teamIndexSchema = v.union([v.literal(0), v.literal(1)]); /** counters change at most 1/s, so a match yields a few hundred samples */ const MAX_OBJECTIVE_SAMPLES = 1000; -const scannerMatchObjectiveSampleSchema = z.object({ - t: z.number().int().min(0), - time: z.number().int().min(0).nullable(), - score: z.tuple([z.number().nullable(), z.number().nullable()]), - penalty: z.tuple([z.number().nullable(), z.number().nullable()]), - control: z.tuple([z.boolean(), z.boolean()]), +const scannerMatchObjectiveSampleSchema = v.object({ + t: v.pipe(v.number(), v.integer(), v.minValue(0)), + time: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), + score: v.tuple([v.nullable(v.number()), v.nullable(v.number())]), + penalty: v.tuple([v.nullable(v.number()), v.nullable(v.number())]), + control: v.tuple([v.boolean(), v.boolean()]), }); -const scannerMatchObjectiveSchema = z.object({ - mode: z.literal("SZ"), - samples: z - .array(scannerMatchObjectiveSampleSchema) - .max(MAX_OBJECTIVE_SAMPLES), +const scannerMatchObjectiveSchema = v.object({ + mode: v.literal("SZ"), + samples: v.pipe( + v.array(scannerMatchObjectiveSampleSchema), + v.maxLength(MAX_OBJECTIVE_SAMPLES), + ), }); -const playerFlagsSchema = z.tuple([ - z.boolean(), - z.boolean(), - z.boolean(), - z.boolean(), +const playerFlagsSchema = v.tuple([ + v.boolean(), + v.boolean(), + v.boolean(), + v.boolean(), ]); -const scannerMatchPlayerStatusSampleSchema = z.object({ - t: z.number().int().min(0), - time: z.number().int().min(0).nullable(), - special: z.tuple([playerFlagsSchema, playerFlagsSchema]), - dead: z.tuple([playerFlagsSchema, playerFlagsSchema]), +const scannerMatchPlayerStatusSampleSchema = v.object({ + t: v.pipe(v.number(), v.integer(), v.minValue(0)), + time: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), + special: v.tuple([playerFlagsSchema, playerFlagsSchema]), + dead: v.tuple([playerFlagsSchema, playerFlagsSchema]), }); -const scannerMatchPlayerStatusSchema = z.object({ - samples: z - .array(scannerMatchPlayerStatusSampleSchema) - .max(MAX_OBJECTIVE_SAMPLES), +const scannerMatchPlayerStatusSchema = v.object({ + samples: v.pipe( + v.array(scannerMatchPlayerStatusSampleSchema), + v.maxLength(MAX_OBJECTIVE_SAMPLES), + ), }); -export const scannerMatchSchema = z.object({ - startsAt: z.number().int().min(0).nullable(), - endsAt: z.number().int().min(0).nullable(), +export const scannerMatchSchema = v.object({ + startsAt: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), + endsAt: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), /** wall-clock ms the game was played */ - playedAt: z.number().int().positive().nullable(), - lobby: scannerLobbySchema.nullable(), - mode: modeShortSchema.nullable(), - stage: stageIdSchema.nullable(), - matchScores: z - .tuple([z.number().nullable(), z.number().nullable()]) - .nullable(), - replayCode: detectionText.nullable(), - cast: z.boolean(), - objective: scannerMatchObjectiveSchema.nullable(), - playerStatus: scannerMatchPlayerStatusSchema.nullable(), - teams: z.tuple([scannerMatchTeamSchema, scannerMatchTeamSchema]), - winner: teamIndexSchema.nullable(), - pov: z - .object({ team: teamIndexSchema, index: z.number().int().min(0).max(3) }) - .nullable(), + playedAt: v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), + lobby: v.nullable(scannerLobbySchema), + mode: v.nullable(modeShortSchema), + stage: v.nullable(stageIdSchema), + matchScores: v.nullable( + v.tuple([v.nullable(v.number()), v.nullable(v.number())]), + ), + replayCode: v.nullable(detectionText), + cast: v.boolean(), + objective: v.nullable(scannerMatchObjectiveSchema), + playerStatus: v.nullable(scannerMatchPlayerStatusSchema), + teams: v.tuple([scannerMatchTeamSchema, scannerMatchTeamSchema]), + winner: v.nullable(teamIndexSchema), + pov: v.nullable( + v.object({ + team: teamIndexSchema, + index: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(3)), + }), + ), }); // ---- compile-time drift protection: schema output <-> core interface ---- @@ -126,22 +136,22 @@ type MutuallyAssignable = [A] extends [B] // `true satisfies …` fails to compile the moment a schema and its core // interface disagree in either direction. true satisfies MutuallyAssignable< - z.infer, + v.InferOutput, ScannerMatchPlayer >; true satisfies MutuallyAssignable< - z.infer, + v.InferOutput, ScannerMatchTeam >; true satisfies MutuallyAssignable< - z.infer, + v.InferOutput, ScannerMatchObjective >; true satisfies MutuallyAssignable< - z.infer, + v.InferOutput, ScannerMatchPlayerStatus >; true satisfies MutuallyAssignable< - z.infer, + v.InferOutput, ScannerMatch >; diff --git a/app/features/scanner/scanner-search-params.ts b/app/features/scanner/scanner-search-params.ts index 1dcf0adc0..d1014fdf6 100644 --- a/app/features/scanner/scanner-search-params.ts +++ b/app/features/scanner/scanner-search-params.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; @@ -7,12 +7,14 @@ export const SCANNER_TABS = ["live", "screenshot", "vod"] as const; export type ScannerTab = (typeof SCANNER_TABS)[number]; export const scannerSearchParams = SearchParams.define({ - tab: SP.param(z.enum(SCANNER_TABS), { default: "live", loader: false }), + tab: SP.param(v.picklist(SCANNER_TABS), { default: "live", loader: false }), /** Inspect handoff key: the screenshot tab claims this frame on load */ - inspect: SP.param(z.string().max(100).nullable(), { loader: false }), + inspect: SP.param(v.nullable(v.pipe(v.string(), v.maxLength(100))), { + loader: false, + }), /** * Opt-in scan telemetry: counters are accumulated and the panel is shown * only when this is set by hand in the URL (no link points at it) */ - telemetry: SP.param(z.boolean(), { default: false, loader: false }), + telemetry: SP.param(v.boolean(), { default: false, loader: false }), }); diff --git a/app/features/scrims/actions/scrims.$id.server.ts b/app/features/scrims/actions/scrims.$id.server.ts index a1430b781..42ecbfe05 100644 --- a/app/features/scrims/actions/scrims.$id.server.ts +++ b/app/features/scrims/actions/scrims.$id.server.ts @@ -10,8 +10,8 @@ import { notFoundIfNullish, parseParams, } from "~/utils/remix.server"; +import { idObject } from "~/utils/schema"; import { assertUnreachable } from "~/utils/types"; -import { idObject } from "~/utils/zod"; import { databaseTimestampToDate } from "../../../utils/dates"; import { requireUser } from "../../auth/core/user.server"; import * as Scrim from "../core/Scrim"; diff --git a/app/features/scrims/actions/scrims.new.server.ts b/app/features/scrims/actions/scrims.new.server.ts index 85664b895..44243c0ce 100644 --- a/app/features/scrims/actions/scrims.new.server.ts +++ b/app/features/scrims/actions/scrims.new.server.ts @@ -1,6 +1,6 @@ import { add } from "date-fns"; import { type ActionFunctionArgs, redirect } from "react-router"; -import type { z } from "zod"; +import type * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import { userIsBanned } from "~/features/ban/core/banned.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -106,7 +106,7 @@ export const usersListForPost = async ({ from, authorId, }: { - from: z.infer; + from: v.InferOutput; authorId: number; }) => { if (from.mode === "PICKUP") { diff --git a/app/features/scrims/components/WithFormField.tsx b/app/features/scrims/components/WithFormField.tsx index c035c8b47..44933459c 100644 --- a/app/features/scrims/components/WithFormField.tsx +++ b/app/features/scrims/components/WithFormField.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import type { Key } from "react-aria-components"; import { useTranslation } from "react-i18next"; import * as R from "remeda"; -import type { z } from "zod"; +import type * as v from "valibot"; import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; import { UserSearch } from "~/components/elements/UserSearch"; import { FormMessage } from "~/components/FormMessage"; @@ -18,7 +18,7 @@ import type { CommonUser } from "~/utils/kysely.server"; import type { fromSchema } from "../scrims-schemas"; import styles from "./WithFormField.module.css"; -type FromValue = z.infer; +type FromValue = v.InferOutput; const NEW_PICKUP_KEY = "PICKUP"; /** Keeps one long username from crowding out the rest of the pick-up */ diff --git a/app/features/scrims/routes/scrims.new.tsx b/app/features/scrims/routes/scrims.new.tsx index 322d49ad3..f5bc0eb5b 100644 --- a/app/features/scrims/routes/scrims.new.tsx +++ b/app/features/scrims/routes/scrims.new.tsx @@ -2,7 +2,7 @@ import type { CalendarDateTime } from "@internationalized/date"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useLoaderData } from "react-router"; -import type { z } from "zod"; +import type * as v from "valibot"; import { SendouDatePicker } from "~/components/elements/DatePicker"; import { Label } from "~/components/Label"; import type { CustomFieldRenderProps } from "~/form"; @@ -28,7 +28,7 @@ export const handle: SendouRouteHandle = { i18n: "scrims", }; -type FormFields = z.infer; +type FormFields = v.InferOutput; const DEFAULT_NOT_FOUND_VISIBILITY = { at: null, diff --git a/app/features/scrims/routes/scrims.tsx b/app/features/scrims/routes/scrims.tsx index 2b7dd23b6..79044b051 100644 --- a/app/features/scrims/routes/scrims.tsx +++ b/app/features/scrims/routes/scrims.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { useLoaderData } from "react-router"; import * as R from "remeda"; -import type { z } from "zod"; +import * as v from "valibot"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { FilterBar } from "~/components/filter-bar/FilterBar"; import { LocaleTime } from "~/components/LocaleTime"; @@ -22,8 +22,8 @@ import { import { databaseTimestampToDate } from "~/utils/dates"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; +import { timeString } from "~/utils/schema"; import { navIconUrl, scrimsPage } from "~/utils/urls"; -import { timeString } from "~/utils/zod"; import { SendouTab, SendouTabList, @@ -46,7 +46,7 @@ import { Check, Download, Funnel, Megaphone, Star } from "lucide-react"; import styles from "./scrims.module.css"; -export type NewRequestFormFields = z.infer; +export type NewRequestFormFields = v.InferOutput; export const handle: SendouRouteHandle = { i18n: ["calendar", "scrims", "user", "q"], @@ -307,8 +307,8 @@ function TimeRangePopover({ } if ( - timeString.safeParse(timeRange.start).success && - timeString.safeParse(timeRange.end).success + v.safeParse(timeString, timeRange.start).success && + v.safeParse(timeString, timeRange.end).success ) { onChange(timeRange); } diff --git a/app/features/scrims/scrims-schemas.test.ts b/app/features/scrims/scrims-schemas.test.ts index a6c49ddfd..e051f827b 100644 --- a/app/features/scrims/scrims-schemas.test.ts +++ b/app/features/scrims/scrims-schemas.test.ts @@ -1,21 +1,22 @@ +import * as v from "valibot"; import { describe, expect, test } from "vitest"; import { divsSchema } from "./scrims-schemas"; describe("divsSchema", () => { test("swaps min and max when max is lower skill than min", () => { - const result = divsSchema.parse({ min: "1", max: "10" }); + const result = v.parse(divsSchema, { min: "1", max: "10" }); expect(result).toEqual({ min: "10", max: "1" }); }); test("keeps min and max when they are in correct order", () => { - const result = divsSchema.parse({ min: "10", max: "1" }); + const result = v.parse(divsSchema, { min: "10", max: "1" }); expect(result).toEqual({ min: "10", max: "1" }); }); test("keeps min and max when they are equal", () => { - const result = divsSchema.parse({ min: "5", max: "5" }); + const result = v.parse(divsSchema, { min: "5", max: "5" }); expect(result).toEqual({ min: "5", max: "5" }); }); diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts index 5ccd08000..ea9efdc93 100644 --- a/app/features/scrims/scrims-schemas.ts +++ b/app/features/scrims/scrims-schemas.ts @@ -1,5 +1,5 @@ import { add, sub } from "date-fns"; -import { z } from "zod"; +import * as v from "valibot"; import { customField, datetime, @@ -18,6 +18,7 @@ import { tournamentSearchOptional, } from "~/form/fields"; import { modesShort } from "~/modules/in-game-lists/modes"; +import { codec } from "~/modules/search-params/search-params"; import { _action, date, @@ -25,57 +26,61 @@ import { filterOutNullishMembers, id, noDuplicates, + preprocess, + superRefine, timeString, -} from "~/utils/zod"; +} from "~/utils/schema"; import { associationIdentifierSchema } from "../associations/associations-schemas"; import { LUTI_DIVS, SCRIM } from "./scrims-constants"; import { parseMapPoolInput } from "./scrims-utils"; -const deletePostSchema = z.object({ +const deletePostSchema = v.object({ _action: _action("DELETE_POST"), scrimPostId: id, }); -const fromUsers = z.preprocess( +const fromUsers = preprocess( filterOutNullishMembers, - z - .array(id) - .min(3, { - message: "forms:errors.minUsersExcludingYourself", - }) - .max(SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER) - .refine(noDuplicates, { - message: "forms:errors.usersMustBeUnique", - }), + v.pipe( + v.array(id), + v.minLength(3, "forms:errors.minUsersExcludingYourself"), + v.maxLength(SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER), + v.check((users) => noDuplicates(users), "forms:errors.usersMustBeUnique"), + ), ); -export const fromSchema = z.union([ - z.object({ mode: z.literal("PICKUP"), users: fromUsers }), - z.object({ mode: z.literal("TEAM"), teamId: id }), +export const fromSchema = v.union([ + v.object({ mode: v.literal("PICKUP"), users: fromUsers }), + v.object({ mode: v.literal("TEAM"), teamId: id }), ]); -export const newRequestSchema = z.object({ +export const newRequestSchema = v.object({ _action: _action("NEW_REQUEST"), scrimPostId: id, from: fromSchema, - message: z.preprocess( - falsyToNull, - z.string().max(SCRIM.REQUEST_MESSAGE_MAX_LENGTH).nullable(), + message: v.optional( + preprocess( + falsyToNull, + v.nullable( + v.pipe(v.string(), v.maxLength(SCRIM.REQUEST_MESSAGE_MAX_LENGTH)), + ), + ), + null, ), - at: z.preprocess(date, z.date()).nullish(), + at: v.nullish(preprocess(date, v.date())), }); -const acceptRequestSchema = z.object({ +const acceptRequestSchema = v.object({ _action: _action("ACCEPT_REQUEST"), scrimPostRequestId: id, }); -const cancelRequestSchema = z.object({ +const cancelRequestSchema = v.object({ _action: _action("CANCEL_REQUEST"), scrimPostRequestId: id, }); -export const cancelScrimFormSchema = z.object({ +export const cancelScrimFormSchema = v.object({ _action: stringConstant("CANCEL_SCRIM"), reason: textArea({ label: "labels.scrimCancelReason", @@ -84,31 +89,27 @@ export const cancelScrimFormSchema = z.object({ }), }); -const timeRangeSchema = z.object({ +const timeRangeSchema = v.object({ start: timeString, end: timeString, }); -const divsBaseSchema = z - .object({ - min: z.enum(LUTI_DIVS).nullable(), - max: z.enum(LUTI_DIVS).nullable(), - }) - .refine( - (div) => { - if (!div) return true; +const divsBaseSchema = v.pipe( + v.object({ + min: v.nullable(v.picklist(LUTI_DIVS)), + max: v.nullable(v.picklist(LUTI_DIVS)), + }), + v.check((div) => { + if (!div) return true; - if (div.max && !div.min) return false; - if (div.min && !div.max) return false; + if (div.max && !div.min) return false; + if (div.min && !div.max) return false; - return true; - }, - { - message: "forms:errors.divBothOrNeither", - }, - ); + return true; + }, "forms:errors.divBothOrNeither"), +); -export const divsSchema = divsBaseSchema.transform(normalizeDivs); +export const divsSchema = v.pipe(divsBaseSchema, v.transform(normalizeDivs)); function normalizeDivs( divs: T, @@ -126,13 +127,13 @@ function normalizeDivs( return divs; } -const scrimsFiltersSchema = z.object({ - weekdayTimes: timeRangeSchema.nullable().catch(null), - weekendTimes: timeRangeSchema.nullable().catch(null), - divs: divsSchema.nullable().catch(null), +const scrimsFiltersSchema = v.object({ + weekdayTimes: v.fallback(v.nullable(timeRangeSchema), null), + weekendTimes: v.fallback(v.nullable(timeRangeSchema), null), + divs: v.fallback(v.nullable(divsSchema), null), }); -export const timeRangeCodec = z.codec(z.string(), timeRangeSchema.nullable(), { +export const timeRangeCodec = codec(v.nullable(timeRangeSchema), { decode: (encoded) => { if (encoded[5] !== "-") return null; @@ -142,13 +143,14 @@ export const timeRangeCodec = z.codec(z.string(), timeRangeSchema.nullable(), { timeRange === null ? "" : `${timeRange.start}-${timeRange.end}`, }); -export const divsCodec = z.codec(z.string(), divsBaseSchema.nullable(), { +export const divsCodec = codec(v.nullable(divsBaseSchema), { decode: (encoded) => { const [max, min] = encoded.split("-"); - return normalizeDivs({ max: max ?? null, min: min ?? null }) as z.output< - typeof divsBaseSchema - >; + return normalizeDivs({ + max: max ?? null, + min: min ?? null, + }); }, encode: (divs) => (divs === null ? "" : `${divs.max}-${divs.min}`), }); @@ -173,12 +175,12 @@ const divsFormField = dualSelectOptional({ }, }); -const persistScrimFiltersSchema = z.object({ +const persistScrimFiltersSchema = v.object({ _action: _action("PERSIST_SCRIM_FILTERS"), filters: scrimsFiltersSchema, }); -export const scrimsActionSchema = z.union([ +export const scrimsActionSchema = v.union([ deletePostSchema, newRequestSchema, acceptRequestSchema, @@ -186,8 +188,8 @@ export const scrimsActionSchema = z.union([ persistScrimFiltersSchema, ]); -export const submitMapListFormSchema = z - .object({ +export const submitMapListFormSchema = v.pipe( + v.object({ _action: stringConstant("SUBMIT_MAP_LIST"), source: radioGroupDynamic({ label: "labels.scrimMapSource", @@ -204,50 +206,48 @@ export const submitMapListFormSchema = z tournamentId: tournamentSearchOptional({ label: "labels.scrimMapsTournament", }), - }) - .superRefine((data, ctx) => { + }), + superRefine((data, ctx) => { if (!["POOL", "TOURNAMENT", "FROM_POST"].includes(data.source)) { ctx.addIssue({ path: ["source"], message: "forms:errors.required", - code: z.ZodIssueCode.custom, }); } if (data.source === "POOL" && !data.serializedPool) { ctx.addIssue({ path: ["serializedPool"], message: "forms:errors.invalidMapPool", - code: z.ZodIssueCode.custom, }); } if (data.source === "TOURNAMENT" && !data.tournamentId) { ctx.addIssue({ path: ["tournamentId"], message: "forms:errors.scrimTournamentRequired", - code: z.ZodIssueCode.custom, }); } - }); + }), +); -const removeMapListSchema = z.object({ +const removeMapListSchema = v.object({ _action: _action("REMOVE_MAP_LIST"), }); -const reportMapSchema = z.object({ +const reportMapSchema = v.object({ _action: _action("REPORT_MAP"), mapId: id, - winnerSide: z.enum(["ALPHA", "BRAVO"]), + winnerSide: v.picklist(["ALPHA", "BRAVO"]), }); -const undoMapSchema = z.object({ +const undoMapSchema = v.object({ _action: _action("UNDO_MAP"), }); -const replayMapSchema = z.object({ +const replayMapSchema = v.object({ _action: _action("REPLAY_MAP"), }); -export const pickMapFormSchema = z.object({ +export const pickMapFormSchema = v.object({ _action: stringConstant("PICK_MAP"), mode: select({ label: "labels.vodMode", @@ -259,7 +259,7 @@ export const pickMapFormSchema = z.object({ stageId: stageSelect({ label: "labels.vodStage" }), }); -export const scrimIdActionSchema = z.union([ +export const scrimIdActionSchema = v.union([ cancelScrimFormSchema, submitMapListFormSchema, removeMapListSchema, @@ -280,7 +280,7 @@ export const RANGE_END_OPTIONS = [ "+3hours", ] as const; -export const scrimRequestFormSchema = z.object({ +export const scrimRequestFormSchema = v.object({ _action: stringConstant("NEW_REQUEST"), scrimPostId: idConstant(), from: customField({ initialValue: null }, fromSchema), @@ -312,8 +312,8 @@ const mapsItems = [ { label: "options.scrimMaps.tournament" as const, value: "TOURNAMENT" }, ] as const; -export const scrimsNewFormSchema = z - .object({ +export const scrimsNewFormSchema = v.pipe( + v.object({ at: datetime({ label: "labels.start", bottomText: "bottomTexts.scrimStart", @@ -333,18 +333,15 @@ export const scrimsNewFormSchema = z ), notFoundVisibility: customField( { initialValue: { at: null, forAssociation: "PUBLIC" } }, - z.object({ - at: z - .preprocess(date, z.date()) - .nullish() - .refine( - (date) => { - if (!date) return true; - if (date < sub(new Date(), { days: 1 })) return false; - return true; - }, - { message: "errors.dateInPast" }, - ), + v.object({ + at: v.pipe( + v.nullish(preprocess(date, v.date())), + v.check((date) => { + if (!date) return true; + if (date < sub(new Date(), { days: 1 })) return false; + return true; + }, "errors.dateInPast"), + ), forAssociation: associationIdentifierSchema, }), ), @@ -365,20 +362,19 @@ export const scrimsNewFormSchema = z mapsTournamentId: tournamentSearchOptional({ label: "labels.scrimMapsTournament", }), - }) + }), // a tournament pick is only meaningful when maps come from a tournament, so // drop any stale selection instead of erroring on a field that is not rendered - .overwrite((post) => + v.transform((post) => post.maps !== "TOURNAMENT" && post.mapsTournamentId ? { ...post, mapsTournamentId: null } : post, - ) - .superRefine((post, ctx) => { + ), + superRefine((post, ctx) => { if (post.maps === "TOURNAMENT" && !post.mapsTournamentId) { ctx.addIssue({ path: ["mapsTournamentId"], message: "forms:errors.tournamentMustBeSelected", - code: z.ZodIssueCode.custom, }); } @@ -389,7 +385,6 @@ export const scrimsNewFormSchema = z ctx.addIssue({ path: ["notFoundVisibility"], message: "forms:errors.visibilityMustBeDifferent", - code: z.ZodIssueCode.custom, }); } @@ -397,7 +392,6 @@ export const scrimsNewFormSchema = z ctx.addIssue({ path: ["notFoundVisibility"], message: "forms:errors.visibilityNotAllowedWhenPublic", - code: z.ZodIssueCode.custom, }); } @@ -405,7 +399,6 @@ export const scrimsNewFormSchema = z ctx.addIssue({ path: ["notFoundVisibility"], message: "forms:errors.dateAfterScrimDate", - code: z.ZodIssueCode.custom, }); } @@ -413,7 +406,7 @@ export const scrimsNewFormSchema = z ctx.addIssue({ path: ["notFoundVisibility"], message: "forms:errors.canNotSetIfLookingNow", - code: z.ZodIssueCode.custom, }); } - }); + }), +); diff --git a/app/features/scrims/scrims-search-params.ts b/app/features/scrims/scrims-search-params.ts index 6a68dc811..45122c437 100644 --- a/app/features/scrims/scrims-search-params.ts +++ b/app/features/scrims/scrims-search-params.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; import { divsCodec, timeRangeCodec } from "./scrims-schemas"; @@ -8,8 +8,11 @@ export const scrimsSearchParams = SearchParams.define({ weekendTimes: SP.custom(timeRangeCodec, { loader: true }), divs: SP.custom(divsCodec, { loader: true }), /** False once the user has edited the filters, making the URL win over their saved defaults. */ - useDefaults: SP.param(z.boolean(), { default: true, loader: true }), - pendingRequestPostId: SP.param(z.number().int().positive().nullable(), { - loader: false, - }), + useDefaults: SP.param(v.boolean(), { default: true, loader: true }), + pendingRequestPostId: SP.param( + v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), + { + loader: false, + }, + ), }); diff --git a/app/features/search/search-search-params.ts b/app/features/search/search-search-params.ts index 0e43f3901..d523debc8 100644 --- a/app/features/search/search-search-params.ts +++ b/app/features/search/search-search-params.ts @@ -1,13 +1,19 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; import { SEARCH_TYPES } from "./search-types"; export const searchSearchParams = SearchParams.define({ - q: SP.param(z.string().max(100), { default: "", loader: true }), - type: SP.param(z.enum(SEARCH_TYPES), { default: "users", loader: true }), - limit: SP.param(z.number().int().min(1).max(25), { - default: 10, + q: SP.param(v.pipe(v.string(), v.maxLength(100)), { + default: "", loader: true, }), + type: SP.param(v.picklist(SEARCH_TYPES), { default: "users", loader: true }), + limit: SP.param( + v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(25)), + { + default: 10, + loader: true, + }, + ), }); diff --git a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx index 864a9e250..faccb6ac6 100644 --- a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx +++ b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx @@ -2,7 +2,6 @@ import type { TFunction } from "i18next"; import { Ban, Check, Undo2, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useFetcher } from "react-router"; -import type { z } from "zod"; import { SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; import { SendouTabPanel } from "~/components/elements/Tabs"; @@ -15,6 +14,7 @@ import { WeaponReporter } from "~/components/match-page/WeaponReporter"; import { useUser } from "~/features/auth/core/user"; import { FormField } from "~/form/FormField"; import { SendouForm } from "~/form/SendouForm"; +import type { FormObjectSchema } from "~/form/types"; import { useActionSubmit } from "~/hooks/useActionSubmit"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { @@ -190,7 +190,7 @@ function CancelMatchForm({ label: () => member.username, })); - const schema: z.ZodObject = + const schema: FormObjectSchema = action === "REQUEST_CANCEL" ? requestCancelSchema : acceptCancelSchema; return ( diff --git a/app/features/sendouq-match/q-match-schemas.ts b/app/features/sendouq-match/q-match-schemas.ts index 4b6922d9c..02da1221a 100644 --- a/app/features/sendouq-match/q-match-schemas.ts +++ b/app/features/sendouq-match/q-match-schemas.ts @@ -1,10 +1,10 @@ -import { z } from "zod"; +import * as v from "valibot"; import { reportWeaponSchema, undoWeaponReportSchema, } from "~/components/match-page/match-page-schemas"; import { checkboxGroupDynamic, stringConstant, textArea } from "~/form/fields"; -import { _action, id } from "~/utils/zod"; +import { _action, coerceNumber, id, preprocess } from "~/utils/schema"; import { SENDOUQ } from "../sendouq/q-constants"; const cancelNominatedUserIdsField = checkboxGroupDynamic({ @@ -18,59 +18,59 @@ const cancelReasonField = textArea({ maxLength: SENDOUQ.CANCEL_REASON_MAX_LENGTH, }); -export const requestCancelSchema = z.object({ +export const requestCancelSchema = v.object({ _action: stringConstant("REQUEST_CANCEL"), nominatedUserIds: cancelNominatedUserIdsField, reason: cancelReasonField, }); -export const acceptCancelSchema = z.object({ +export const acceptCancelSchema = v.object({ _action: stringConstant("ACCEPT_CANCEL"), nominatedUserIds: cancelNominatedUserIdsField, reason: cancelReasonField, }); -export const matchSchema = z.union([ - z.object({ +export const matchSchema = v.union([ + v.object({ _action: _action("REPORT_SCORE"), winnerId: id, - reportedCount: z.coerce.number().int().nonnegative(), + reportedCount: v.pipe(coerceNumber(), v.integer(), v.minValue(0)), }), - z.object({ + v.object({ _action: _action("LOOK_AGAIN"), previousGroupId: id, }), - z.object({ + v.object({ _action: _action("CAST_CONTINUE_VOTE"), - isContinuing: z.preprocess( + isContinuing: preprocess( (value) => value === "1" || value === "true" ? true : value === "0" || value === "false" ? false : value, - z.boolean(), + v.boolean(), ), }), reportWeaponSchema, - z.object({ + v.object({ _action: _action("UNDO_MATCH_REPORT"), }), - z.object({ + v.object({ _action: _action("UNDO_MAP_REPORT"), - mapIndex: z.coerce.number().int().nonnegative(), + mapIndex: v.pipe(coerceNumber(), v.integer(), v.minValue(0)), }), undoWeaponReportSchema, requestCancelSchema, acceptCancelSchema, - z.object({ + v.object({ _action: _action("REFUSE_CANCEL"), }), - z.object({ + v.object({ _action: _action("ADMIN_CANCEL"), }), ]); -export const qMatchPageParamsSchema = z.object({ +export const qMatchPageParamsSchema = v.object({ id, }); diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts index 9c8e5667b..0dd8f607b 100644 --- a/app/features/sendouq/actions/q.server.ts +++ b/app/features/sendouq/actions/q.server.ts @@ -9,13 +9,13 @@ import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server" import * as UserRepository from "~/features/user-page/UserRepository.server"; import { parseFormData } from "~/form/parse.server"; import { errorToastIfFalsy } from "~/utils/remix.server"; +import { normalizeFriendCode } from "~/utils/schema"; import { assertUnreachable } from "~/utils/types"; import { SENDOUQ_LOOKING_PAGE, SENDOUQ_PREPARING_PAGE, SUSPENDED_PAGE, } from "~/utils/urls"; -import { normalizeFriendCode } from "~/utils/zod"; import { refreshSendouQInstance, SendouQ, diff --git a/app/features/sendouq/q-action-schemas.ts b/app/features/sendouq/q-action-schemas.ts index c39030731..a16cd1fe7 100644 --- a/app/features/sendouq/q-action-schemas.ts +++ b/app/features/sendouq/q-action-schemas.ts @@ -1,66 +1,66 @@ -import { z } from "zod"; -import { _action, deduplicate, id } from "~/utils/zod"; +import * as v from "valibot"; +import { _action, deduplicate, id, preprocess } from "~/utils/schema"; import { addFriendCodeSchema, updateGroupNoteSchema } from "./q-schemas"; -export const frontPageSchema = z.union([ - z.object({ +export const frontPageSchema = v.union([ + v.object({ _action: _action("JOIN_QUEUE"), - direct: z.preprocess(deduplicate, z.literal("true").nullish()), + direct: v.optional(preprocess(deduplicate, v.nullish(v.literal("true")))), }), - z.object({ + v.object({ _action: _action("JOIN_TEAM"), }), addFriendCodeSchema, ]); -export const preparingSchema = z.union([ - z.object({ +export const preparingSchema = v.union([ + v.object({ _action: _action("JOIN_QUEUE"), }), - z.object({ + v.object({ _action: _action("ADD_FRIEND"), id, }), ]); -export const lookingSchema = z.union([ - z.object({ +export const lookingSchema = v.union([ + v.object({ _action: _action("LIKE"), targetGroupId: id, }), - z.object({ + v.object({ _action: _action("RECHALLENGE"), targetGroupId: id, }), - z.object({ + v.object({ _action: _action("UNLIKE"), targetGroupId: id, }), - z.object({ + v.object({ _action: _action("SUGGEST"), targetGroupId: id, }), - z.object({ + v.object({ _action: _action("GROUP_UP"), targetGroupId: id, }), - z.object({ + v.object({ _action: _action("MATCH_UP"), targetGroupId: id, }), - z.object({ + v.object({ _action: _action("LEAVE_GROUP"), }), - z.object({ + v.object({ _action: _action("KICK_FROM_GROUP"), userId: id, }), - z.object({ + v.object({ _action: _action("REFRESH_GROUP"), }), updateGroupNoteSchema, ]); -export const readySchema = z.object({ +export const readySchema = v.object({ _action: _action("CONFIRM_READY"), }); diff --git a/app/features/sendouq/q-schemas.ts b/app/features/sendouq/q-schemas.ts index a4dc987f7..e581f8913 100644 --- a/app/features/sendouq/q-schemas.ts +++ b/app/features/sendouq/q-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { stringConstant, textAreaOptional, textField } from "~/form/fields"; import { FRIEND_CODE_MAX_LENGTH, @@ -6,7 +6,7 @@ import { SENDOUQ, } from "./q-constants"; -export const updateGroupNoteSchema = z.object({ +export const updateGroupNoteSchema = v.object({ _action: stringConstant("UPDATE_NOTE"), value: textAreaOptional({ label: "labels.note", @@ -29,7 +29,7 @@ export const friendCodeField = textField({ }, }); -export const addFriendCodeSchema = z.object({ +export const addFriendCodeSchema = v.object({ _action: stringConstant("ADD_FRIEND_CODE"), friendCode: friendCodeField, }); diff --git a/app/features/sendouq/q-search-params.ts b/app/features/sendouq/q-search-params.ts index 2223e96a8..c6486f433 100644 --- a/app/features/sendouq/q-search-params.ts +++ b/app/features/sendouq/q-search-params.ts @@ -1,21 +1,25 @@ -import { z } from "zod"; +import * as v from "valibot"; import { stageIds } from "~/modules/in-game-lists/stage-ids"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import { modeShort, numericEnum } from "~/utils/zod"; +import { modeShort, numericEnum } from "~/utils/schema"; export const qSearchParams = SearchParams.define({ - join: SP.param(z.string().nullable(), { loader: true }), + join: SP.param(v.nullable(v.string()), { loader: true }), }); export const qLookingSearchParams = SearchParams.define({ - preview: SP.param(z.boolean(), { default: false, loader: true }), - joining: SP.param(z.boolean(), { default: false, loader: false }), + preview: SP.param(v.boolean(), { default: false, loader: true }), + joining: SP.param(v.boolean(), { default: false, loader: false }), }); export const weaponUsageSearchParams = SearchParams.define({ - userId: SP.param(z.number().int().positive().nullable(), { loader: true }), - season: SP.param(z.number().int().nonnegative().nullable(), { loader: true }), - stageId: SP.param(numericEnum(stageIds).nullable(), { loader: true }), - modeShort: SP.param(modeShort.nullable(), { loader: true }), + userId: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), { + loader: true, + }), + season: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), { + loader: true, + }), + stageId: SP.param(v.nullable(numericEnum(stageIds)), { loader: true }), + modeShort: SP.param(v.nullable(modeShort), { loader: true }), }); diff --git a/app/features/settings/match-profile-schemas.test.ts b/app/features/settings/match-profile-schemas.test.ts index 225b95be7..3131512f4 100644 --- a/app/features/settings/match-profile-schemas.test.ts +++ b/app/features/settings/match-profile-schemas.test.ts @@ -1,9 +1,10 @@ +import * as v from "valibot"; import { describe, expect, test } from "vitest"; import { mapModePreferencesValueSchema } from "./match-profile-schemas"; describe("mapModePreferencesValueSchema", () => { test("strips pools for avoided modes", () => { - const result = mapModePreferencesValueSchema.parse({ + const result = v.parse(mapModePreferencesValueSchema, { modes: [ { mode: "SZ", preference: "PREFER" }, { mode: "TC", preference: "AVOID" }, @@ -18,7 +19,7 @@ describe("mapModePreferencesValueSchema", () => { }); test("keeps pools for preferred and neutral modes", () => { - const result = mapModePreferencesValueSchema.parse({ + const result = v.parse(mapModePreferencesValueSchema, { modes: [{ mode: "SZ", preference: "PREFER" }], pool: [ { mode: "SZ", stages: [1] }, @@ -33,7 +34,7 @@ describe("mapModePreferencesValueSchema", () => { }); test("does not mutate the modes selection", () => { - const result = mapModePreferencesValueSchema.parse({ + const result = v.parse(mapModePreferencesValueSchema, { modes: [{ mode: "TC", preference: "AVOID" }], pool: [{ mode: "TC", stages: [1] }], }); diff --git a/app/features/settings/match-profile-schemas.ts b/app/features/settings/match-profile-schemas.ts index 134ac2a25..46e314012 100644 --- a/app/features/settings/match-profile-schemas.ts +++ b/app/features/settings/match-profile-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import type { UserMapModePreferences } from "~/db/tables-json"; import { checkboxGroup, @@ -9,7 +9,7 @@ import { weaponPool, } from "~/form/fields"; import { languagesUnified } from "~/modules/i18n/config"; -import { modeShort, stageId } from "~/utils/zod"; +import { modeShort, stageId } from "~/utils/schema"; import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE, MATCH_PROFILE_WEAPON_POOL_MAX_SIZE, @@ -20,30 +20,34 @@ export const LANGUAGE_OPTIONS = languagesUnified.map((lang) => ({ value: lang.code, })); -const preferenceSchema = z.enum(["AVOID", "PREFER"]).optional(); +const preferenceSchema = v.optional(v.picklist(["AVOID", "PREFER"])); -export const mapModePreferencesValueSchema = z - .object({ - modes: z.array(z.object({ mode: modeShort, preference: preferenceSchema })), - pool: z.array( - z.object({ - stages: z.array(stageId).max(AMOUNT_OF_MAPS_IN_POOL_PER_MODE), +export const mapModePreferencesValueSchema = v.pipe( + v.object({ + modes: v.array(v.object({ mode: modeShort, preference: preferenceSchema })), + pool: v.array( + v.object({ + stages: v.pipe( + v.array(stageId), + v.maxLength(AMOUNT_OF_MAPS_IN_POOL_PER_MODE), + ), mode: modeShort, }), ), - }) + }), // Pools for avoided modes are kept in the client form state so they can be // restored if the user later un-avoids the mode, but they must not be // persisted as active pools. Strip them out before the value reaches the action. - .transform((val) => ({ + v.transform((val) => ({ ...val, pool: val.pool.filter((pool) => { const mp = val.modes.find((m) => m.mode === pool.mode); return mp?.preference !== "AVOID"; }), - })); + })), +); -export const updateMatchProfileSchema = z.object({ +export const updateMatchProfileSchema = v.object({ _action: stringConstant("UPDATE_MATCH_PROFILE"), mapModePreferences: customField( { initialValue: { modes: [], pool: [] } satisfies UserMapModePreferences }, diff --git a/app/features/settings/settings-schemas.server.ts b/app/features/settings/settings-schemas.server.ts index ddd072ddd..a92b90066 100644 --- a/app/features/settings/settings-schemas.server.ts +++ b/app/features/settings/settings-schemas.server.ts @@ -1,8 +1,8 @@ -import { z } from "zod"; +import * as v from "valibot"; import { updateMatchProfileSchema } from "./match-profile-schemas"; import { settingsEditSchema } from "./settings-schemas"; -export const settingsActionSchema = z.union([ +export const settingsActionSchema = v.union([ settingsEditSchema, updateMatchProfileSchema, ]); diff --git a/app/features/settings/settings-schemas.ts b/app/features/settings/settings-schemas.ts index 6dd8b9cba..d17cf9ff1 100644 --- a/app/features/settings/settings-schemas.ts +++ b/app/features/settings/settings-schemas.ts @@ -1,14 +1,14 @@ -import { z } from "zod"; +import * as v from "valibot"; import { hidden, select, stringConstant, toggle } from "~/form/fields"; -import { themeInputSchema } from "~/utils/zod"; +import { themeInputSchema } from "~/utils/schema"; -export const customThemeSchema = z.object({ +export const customThemeSchema = v.object({ _action: stringConstant("UPDATE_CUSTOM_THEME"), - newValue: hidden(themeInputSchema.nullable(), null), - revalidateRoot: z.literal(true).nullish(), + newValue: hidden(v.nullable(themeInputSchema), null), + revalidateRoot: v.optional(v.nullable(v.literal(true))), }); -export const clockFormatSchema = z.object({ +export const clockFormatSchema = v.object({ _action: stringConstant("UPDATE_CLOCK_FORMAT"), newValue: select({ label: "labels.clockFormat", @@ -20,7 +20,7 @@ export const clockFormatSchema = z.object({ }), }); -export const disableBuildAbilitySortingSchema = z.object({ +export const disableBuildAbilitySortingSchema = v.object({ _action: stringConstant("UPDATE_DISABLE_BUILD_ABILITY_SORTING"), newValue: toggle({ label: "labels.disableBuildAbilitySorting", @@ -28,7 +28,7 @@ export const disableBuildAbilitySortingSchema = z.object({ }), }); -export const disallowScrimPickupsFromUntrustedSchema = z.object({ +export const disallowScrimPickupsFromUntrustedSchema = v.object({ _action: stringConstant("DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"), newValue: toggle({ label: "labels.disallowScrimPickupsFromUntrusted", @@ -36,7 +36,7 @@ export const disallowScrimPickupsFromUntrustedSchema = z.object({ }), }); -export const spoilerFreeModeSchema = z.object({ +export const spoilerFreeModeSchema = v.object({ _action: stringConstant("UPDATE_SPOILER_FREE_MODE"), newValue: toggle({ label: "labels.spoilerFreeMode", @@ -44,12 +44,12 @@ export const spoilerFreeModeSchema = z.object({ }), }); -export const weaponReportDefaultOpenSchema = z.object({ +export const weaponReportDefaultOpenSchema = v.object({ _action: stringConstant("UPDATE_WEAPON_REPORT_DEFAULT_OPEN"), - newValue: z.boolean(), + newValue: v.boolean(), }); -export const settingsEditSchema = z.union([ +export const settingsEditSchema = v.union([ customThemeSchema, disableBuildAbilitySortingSchema, disallowScrimPickupsFromUntrustedSchema, diff --git a/app/features/settings/settings-search-params.ts b/app/features/settings/settings-search-params.ts index b85de0c85..e6e8adc53 100644 --- a/app/features/settings/settings-search-params.ts +++ b/app/features/settings/settings-search-params.ts @@ -1,9 +1,9 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; import { SETTINGS_TAB_SLUGS } from "./settings-constants"; export const settingsSearchParams = SearchParams.define({ - tab: SP.param(z.enum(SETTINGS_TAB_SLUGS).nullable(), { loader: false }), - lng: SP.param(z.string().nullable(), { loader: true }), + tab: SP.param(v.nullable(v.picklist(SETTINGS_TAB_SLUGS)), { loader: false }), + lng: SP.param(v.nullable(v.string()), { loader: true }), }); diff --git a/app/features/splatoon-rotations/splatoon-rotations.server.ts b/app/features/splatoon-rotations/splatoon-rotations.server.ts index 62ad7870e..9dac7f553 100644 --- a/app/features/splatoon-rotations/splatoon-rotations.server.ts +++ b/app/features/splatoon-rotations/splatoon-rotations.server.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import gameMisc from "~/../locales/en/game-misc.json"; import type { TablesInsertable } from "~/db/tables"; import { stageIds } from "~/modules/in-game-lists/stage-ids"; @@ -16,48 +16,48 @@ const RULE_TO_MODE: Record = { CLAM: "CB", }; -const vsStageSchema = z.object({ - name: z.string(), - image: z.object({ url: z.string() }), +const vsStageSchema = v.object({ + name: v.string(), + image: v.object({ url: v.string() }), }); -const vsRuleSchema = z.object({ - name: z.string(), - rule: z.string(), +const vsRuleSchema = v.object({ + name: v.string(), + rule: v.string(), }); -const bankaraMatchSettingSchema = z.object({ - vsStages: z.array(vsStageSchema), +const bankaraMatchSettingSchema = v.object({ + vsStages: v.array(vsStageSchema), vsRule: vsRuleSchema, - bankaraMode: z.enum(["CHALLENGE", "OPEN"]), + bankaraMode: v.picklist(["CHALLENGE", "OPEN"]), }); -const bankaraNodeSchema = z.object({ - startTime: z.string(), - endTime: z.string(), - bankaraMatchSettings: z.array(bankaraMatchSettingSchema).nullable(), +const bankaraNodeSchema = v.object({ + startTime: v.string(), + endTime: v.string(), + bankaraMatchSettings: v.nullable(v.array(bankaraMatchSettingSchema)), }); -const xMatchSettingSchema = z - .object({ - vsStages: z.array(vsStageSchema), +const xMatchSettingSchema = v.nullable( + v.object({ + vsStages: v.array(vsStageSchema), vsRule: vsRuleSchema, - }) - .nullable(); + }), +); -const xNodeSchema = z.object({ - startTime: z.string(), - endTime: z.string(), +const xNodeSchema = v.object({ + startTime: v.string(), + endTime: v.string(), xMatchSetting: xMatchSettingSchema, }); -const schedulesSchema = z.object({ - data: z.object({ - bankaraSchedules: z.object({ - nodes: z.array(bankaraNodeSchema), +const schedulesSchema = v.object({ + data: v.object({ + bankaraSchedules: v.object({ + nodes: v.array(bankaraNodeSchema), }), - xSchedules: z.object({ - nodes: z.array(xNodeSchema), + xSchedules: v.object({ + nodes: v.array(xNodeSchema), }), }), }); @@ -84,7 +84,7 @@ export async function fetchRotations(): Promise< } const json = await response.json(); - const parsed = schedulesSchema.parse(json); + const parsed = v.parse(schedulesSchema, json); const rotations: Omit[] = []; diff --git a/app/features/team/actions/t.$customUrl.edit.server.ts b/app/features/team/actions/t.$customUrl.edit.server.ts index 14dac412f..240e48830 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.ts @@ -1,5 +1,6 @@ import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import { parseFormDataWithImages } from "~/form/parse.server"; import { requirePermission } from "~/modules/permissions/guards.server"; @@ -14,7 +15,7 @@ import { canAddCustomizedColors } from "../team-utils"; export const action: ActionFunction = async ({ request, params }) => { requireUser(); - const { customUrl } = teamParamsSchema.parse(params); + const { customUrl } = v.parse(teamParamsSchema, params); const team = notFoundIfNullish( await TeamRepository.findByCustomUrl(customUrl), diff --git a/app/features/team/actions/t.$customUrl.index.server.ts b/app/features/team/actions/t.$customUrl.index.server.ts index 1a566e833..6b1aebbb4 100644 --- a/app/features/team/actions/t.$customUrl.index.server.ts +++ b/app/features/team/actions/t.$customUrl.index.server.ts @@ -1,5 +1,6 @@ import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import { requirePermission } from "~/modules/permissions/guards.server"; import { @@ -20,7 +21,7 @@ export const action: ActionFunction = async ({ request, params }) => { schema: teamProfilePageActionSchema, }); - const { customUrl } = teamParamsSchema.parse(params); + const { customUrl } = v.parse(teamParamsSchema, params); const team = notFoundIfNullish( await TeamRepository.findByCustomUrl(customUrl), ); diff --git a/app/features/team/actions/t.$customUrl.join.server.ts b/app/features/team/actions/t.$customUrl.join.server.ts index eff03d3f8..b50f30021 100644 --- a/app/features/team/actions/t.$customUrl.join.server.ts +++ b/app/features/team/actions/t.$customUrl.join.server.ts @@ -1,4 +1,5 @@ import { type ActionFunction, redirect } from "react-router"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server"; import { teamPage } from "~/utils/urls"; @@ -10,7 +11,7 @@ import { teamJoinSearchParams } from "../team-search-params"; export const action: ActionFunction = async ({ params, url }) => { const user = requireUser(); - const { customUrl } = teamParamsSchema.parse(params); + const { customUrl } = v.parse(teamParamsSchema, params); const team = notFoundIfNullish( await TeamRepository.findByCustomUrl(customUrl, { diff --git a/app/features/team/actions/t.$customUrl.roster.server.ts b/app/features/team/actions/t.$customUrl.roster.server.ts index 658cf7e61..68f7d19ff 100644 --- a/app/features/team/actions/t.$customUrl.roster.server.ts +++ b/app/features/team/actions/t.$customUrl.roster.server.ts @@ -1,5 +1,6 @@ import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import type { MemberRole, @@ -17,7 +18,7 @@ import { manageRosterSchema, teamParamsSchema } from "../team-schemas.server"; export const action: ActionFunction = async ({ request, params }) => { const user = requireUser(); - const { customUrl } = teamParamsSchema.parse(params); + const { customUrl } = v.parse(teamParamsSchema, params); const team = notFoundIfNullish( await TeamRepository.findByCustomUrl(customUrl), ); diff --git a/app/features/team/loaders/t.$customUrl.edit.server.ts b/app/features/team/loaders/t.$customUrl.edit.server.ts index d95d00448..a62b966e0 100644 --- a/app/features/team/loaders/t.$customUrl.edit.server.ts +++ b/app/features/team/loaders/t.$customUrl.edit.server.ts @@ -1,5 +1,6 @@ import type { LoaderFunctionArgs } from "react-router"; import { redirect } from "react-router"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import { hasPermission } from "~/modules/permissions/utils"; import { notFoundIfNullish } from "~/utils/remix.server"; @@ -10,7 +11,7 @@ import { canAddCustomizedColors } from "../team-utils"; export const loader = async ({ params }: LoaderFunctionArgs) => { const user = requireUser(); - const { customUrl } = teamParamsSchema.parse(params); + const { customUrl } = v.parse(teamParamsSchema, params); const team = notFoundIfNullish( await TeamRepository.findByCustomUrl(customUrl, { diff --git a/app/features/team/loaders/t.$customUrl.join.server.ts b/app/features/team/loaders/t.$customUrl.join.server.ts index b2ff68211..7f1e41bba 100644 --- a/app/features/team/loaders/t.$customUrl.join.server.ts +++ b/app/features/team/loaders/t.$customUrl.join.server.ts @@ -1,5 +1,6 @@ import type { LoaderFunctionArgs } from "react-router"; import { redirect } from "react-router"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import { SHORT_NANOID_LENGTH } from "~/utils/id"; import { notFoundIfNullish } from "~/utils/remix.server"; @@ -12,7 +13,7 @@ import { isTeamFull, isTeamMember } from "../team-utils"; export const loader = async ({ params, url }: LoaderFunctionArgs) => { const user = requireUser(); - const { customUrl } = teamParamsSchema.parse(params); + const { customUrl } = v.parse(teamParamsSchema, params); const team = notFoundIfNullish( await TeamRepository.findByCustomUrl(customUrl, { diff --git a/app/features/team/loaders/t.$customUrl.results.server.ts b/app/features/team/loaders/t.$customUrl.results.server.ts index 9ec8bbf90..73f59be6a 100644 --- a/app/features/team/loaders/t.$customUrl.results.server.ts +++ b/app/features/team/loaders/t.$customUrl.results.server.ts @@ -1,4 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as v from "valibot"; import type { SerializeFrom } from "~/utils/remix"; import { notFoundIfNullish } from "~/utils/remix.server"; import * as TeamRepository from "../TeamRepository.server"; @@ -7,7 +8,7 @@ import { teamParamsSchema } from "../team-schemas.server"; export type TeamResultsLoaderData = SerializeFrom; export const loader = async ({ params }: LoaderFunctionArgs) => { - const { customUrl } = teamParamsSchema.parse(params); + const { customUrl } = v.parse(teamParamsSchema, params); const team = notFoundIfNullish( await TeamRepository.findByCustomUrl(customUrl), diff --git a/app/features/team/loaders/t.$customUrl.roster.server.ts b/app/features/team/loaders/t.$customUrl.roster.server.ts index 0fdc33d0c..abae4d84b 100644 --- a/app/features/team/loaders/t.$customUrl.roster.server.ts +++ b/app/features/team/loaders/t.$customUrl.roster.server.ts @@ -1,11 +1,12 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as v from "valibot"; import { requirePermission } from "~/modules/permissions/guards.server"; import { notFoundIfNullish } from "~/utils/remix.server"; import * as TeamRepository from "../TeamRepository.server"; import { teamParamsSchema } from "../team-schemas.server"; export const loader = async ({ params }: LoaderFunctionArgs) => { - const { customUrl } = teamParamsSchema.parse(params); + const { customUrl } = v.parse(teamParamsSchema, params); const team = notFoundIfNullish( await TeamRepository.findByCustomUrl(customUrl, { diff --git a/app/features/team/loaders/t.$customUrl.server.ts b/app/features/team/loaders/t.$customUrl.server.ts index 202cb39fe..a3ec4da23 100644 --- a/app/features/team/loaders/t.$customUrl.server.ts +++ b/app/features/team/loaders/t.$customUrl.server.ts @@ -1,4 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as v from "valibot"; import type { SerializeFrom } from "~/utils/remix"; import { notFoundIfNullish } from "~/utils/remix.server"; import * as TeamRepository from "../TeamRepository.server"; @@ -8,7 +9,7 @@ import { canAddCustomizedColors } from "../team-utils"; export type TeamLoaderData = SerializeFrom; export const loader = async ({ params }: LoaderFunctionArgs) => { - const { customUrl } = teamParamsSchema.parse(params); + const { customUrl } = v.parse(teamParamsSchema, params); const team = notFoundIfNullish( await TeamRepository.findByCustomUrl(customUrl), diff --git a/app/features/team/team-schemas.server.ts b/app/features/team/team-schemas.server.ts index dbb5efb1a..259bb7267 100644 --- a/app/features/team/team-schemas.server.ts +++ b/app/features/team/team-schemas.server.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { mySlugify } from "~/utils/urls"; import * as TeamRepository from "./TeamRepository.server"; import { @@ -7,22 +7,22 @@ import { updateRosterSchema, } from "./team-schemas"; -export const createTeamSchemaServer = z.object({ - ...createTeamSchema.shape, - name: createTeamSchema.shape.name.refine( - async (name) => { +export const createTeamSchemaServer = v.objectAsync({ + ...createTeamSchema.entries, + name: v.pipeAsync( + createTeamSchema.entries.name, + v.checkAsync(async (name) => { const teams = await TeamRepository.findAllUndisbanded(); const customUrl = mySlugify(name); return !teams.some((team) => team.customUrl === customUrl); - }, - { message: "forms:errors.duplicateName" }, + }, "forms:errors.duplicateName"), ), }); -export const teamParamsSchema = z.object({ customUrl: z.string() }); +export const teamParamsSchema = v.object({ customUrl: v.string() }); -export const manageRosterSchema = z.union([ +export const manageRosterSchema = v.union([ updateRosterSchema, resetInviteLinkSchema, ]); diff --git a/app/features/team/team-schemas.ts b/app/features/team/team-schemas.ts index ed31ac207..7418ec410 100644 --- a/app/features/team/team-schemas.ts +++ b/app/features/team/team-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import type { UserMapModePreferences } from "~/db/tables-json"; import { mapModePreferencesValueSchema } from "~/features/settings/match-profile-schemas"; import { @@ -15,26 +15,31 @@ import { textFieldOptional, toggle, } from "~/form/fields"; +import { + _action, + preprocess, + superRefine, + themeInputSchema, +} from "~/utils/schema"; import { mySlugify } from "~/utils/urls"; -import { _action, themeInputSchema } from "~/utils/zod"; import { CUSTOM_ROLE_MAX_LENGTH, TEAM, TEAM_MEMBER_ROLES, } from "./team-constants"; -export const resetInviteLinkSchema = z.object({ +export const resetInviteLinkSchema = v.object({ _action: _action("RESET_INVITE_LINK"), }); -export const teamProfilePageActionSchema = z.union([ - z.object({ +export const teamProfilePageActionSchema = v.union([ + v.object({ _action: _action("LEAVE_TEAM"), }), - z.object({ + v.object({ _action: _action("MAKE_MAIN_TEAM"), }), - z.object({ + v.object({ _action: _action("DELETE_TEAM"), }), ]); @@ -45,7 +50,7 @@ const teamNameValidate = { message: "forms:errors.noOnlySpecialCharacters", } as const; -export const createTeamSchema = z.object({ +export const createTeamSchema = v.object({ name: textField({ label: "labels.name", minLength: TEAM.NAME_MIN_LENGTH, @@ -54,7 +59,7 @@ export const createTeamSchema = z.object({ }), }); -export const editTeamFormSchema = z.object({ +export const editTeamFormSchema = v.object({ _action: stringConstant("EDIT"), name: textField({ label: "labels.name", @@ -81,15 +86,15 @@ export const editTeamFormSchema = z.object({ banner: image({ label: "labels.banner", dimensions: "thick-banner" }), }); -export const updateTeamCustomThemeSchema = z.object({ +export const updateTeamCustomThemeSchema = v.object({ _action: _action("UPDATE_CUSTOM_THEME"), - newValue: z.preprocess( + newValue: preprocess( (val) => (!val || val === "null" ? null : val), - themeInputSchema.nullable(), + v.nullable(themeInputSchema), ), }); -export const updateTeamMapModePreferencesSchema = z.object({ +export const updateTeamMapModePreferencesSchema = v.object({ _action: stringConstant("UPDATE_MAP_MODE_PREFERENCES"), mapModePreferences: customField( { initialValue: { modes: [], pool: [] } satisfies UserMapModePreferences }, @@ -97,12 +102,12 @@ export const updateTeamMapModePreferencesSchema = z.object({ ), }); -const removeTeamMapModePreferencesSchema = z.object({ +const removeTeamMapModePreferencesSchema = v.object({ _action: _action("REMOVE_MAP_MODE_PREFERENCES"), }); /** Every payload the team edit route action accepts, discriminated by `_action`. */ -export const editTeamActionSchema = z.union([ +export const editTeamActionSchema = v.union([ editTeamFormSchema, updateTeamCustomThemeSchema, updateTeamMapModePreferencesSchema, @@ -110,17 +115,17 @@ export const editTeamActionSchema = z.union([ ]); /** Sentinel `role` value selected to switch a member to a free-text custom role. Never stored. */ -export const CUSTOM_ROLE_VALUE = "CUSTOM"; +export const CUSTOM_ROLE_VALUE = "CUSTOM" as const; -export const updateRosterSchema = z - .object({ +export const updateRosterSchema = v.pipe( + v.object({ _action: stringConstant("UPDATE_ROSTER"), members: array({ max: TEAM.MAX_MEMBER_COUNT, addable: false, sortable: true, field: fieldset({ - fields: z.object({ + fields: v.object({ userId: idConstant(), role: selectOptional({ label: "labels.teamMemberRole", @@ -150,17 +155,17 @@ export const updateRosterSchema = z }), }), }), - }) - .superRefine((data, ctx) => { + }), + superRefine((data, ctx) => { for (const [index, member] of data.members.entries()) { const isCustom = member.role === CUSTOM_ROLE_VALUE; if (isCustom && !member.customRole) { ctx.addIssue({ - code: z.ZodIssueCode.custom, path: ["members", index, "customRole"], message: "forms:errors.customRoleRequired", }); } } - }); + }), +); diff --git a/app/features/team/team-search-params.ts b/app/features/team/team-search-params.ts index 84102744e..021fae37e 100644 --- a/app/features/team/team-search-params.ts +++ b/app/features/team/team-search-params.ts @@ -1,7 +1,7 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; export const teamJoinSearchParams = SearchParams.define({ - code: SP.param(z.string().nullable(), { loader: true }), + code: SP.param(v.nullable(v.string()), { loader: true }), }); diff --git a/app/features/tier-list-maker/tier-list-maker-schemas.ts b/app/features/tier-list-maker/tier-list-maker-schemas.ts index c1424a67c..99cf071f6 100644 --- a/app/features/tier-list-maker/tier-list-maker-schemas.ts +++ b/app/features/tier-list-maker/tier-list-maker-schemas.ts @@ -1,5 +1,4 @@ -import { z } from "zod"; -import { assertType } from "~/utils/types"; +import * as v from "valibot"; import { ability, hexCodeWithoutAlpha, @@ -8,9 +7,10 @@ import { stageId, subWeaponId, weaponSplId, -} from "~/utils/zod"; +} from "~/utils/schema"; +import { assertType } from "~/utils/types"; -export const tierListItemTypeSchema = z.enum([ +export const tierListItemTypeSchema = v.picklist([ "main-weapon", "sub-weapon", "special-weapon", @@ -19,61 +19,64 @@ export const tierListItemTypeSchema = z.enum([ "stage-mode", "ability", ]); -assertType, TierListItem["type"]>(); +assertType< + v.InferOutput, + TierListItem["type"] +>(); -const tierListItemSchema = z.union([ - z.object({ +const tierListItemSchema = v.union([ + v.object({ id: weaponSplId, - nth: z.number().optional(), - type: z.literal("main-weapon"), + nth: v.optional(v.number()), + type: v.literal("main-weapon"), }), - z.object({ + v.object({ id: subWeaponId, - nth: z.number().optional(), - type: z.literal("sub-weapon"), + nth: v.optional(v.number()), + type: v.literal("sub-weapon"), }), - z.object({ + v.object({ id: specialWeaponId, - nth: z.number().optional(), - type: z.literal("special-weapon"), + nth: v.optional(v.number()), + type: v.literal("special-weapon"), }), - z.object({ + v.object({ id: stageId, - nth: z.number().optional(), - type: z.literal("stage"), + nth: v.optional(v.number()), + type: v.literal("stage"), }), - z.object({ + v.object({ id: modeShort, - nth: z.number().optional(), - type: z.literal("mode"), + nth: v.optional(v.number()), + type: v.literal("mode"), }), - z.object({ - id: z.string(), - nth: z.number().optional(), - type: z.literal("stage-mode"), + v.object({ + id: v.string(), + nth: v.optional(v.number()), + type: v.literal("stage-mode"), }), - z.object({ + v.object({ id: ability, - nth: z.number().optional(), - type: z.literal("ability"), + nth: v.optional(v.number()), + type: v.literal("ability"), }), ]); -export type TierListItem = z.infer; +export type TierListItem = v.InferOutput; -const tierSchema = z.object({ - id: z.string(), - name: z.string(), +const tierSchema = v.object({ + id: v.string(), + name: v.string(), color: hexCodeWithoutAlpha, }); -export type TierListMakerTier = z.infer; +export type TierListMakerTier = v.InferOutput; -type TierListItemSchemaType = z.infer; +type TierListItemSchemaType = v.InferOutput; -export const tierListStateSerializedSchema = z.object({ - tiers: z.array(tierSchema), - tierItems: z.array(z.tuple([z.string(), z.array(tierListItemSchema)])), +export const tierListStateSerializedSchema = v.object({ + tiers: v.array(tierSchema), + tierItems: v.array(v.tuple([v.string(), v.array(tierListItemSchema)])), }); export type TierListState = { diff --git a/app/features/tier-list-maker/tier-list-maker-search-params.ts b/app/features/tier-list-maker/tier-list-maker-search-params.ts index 6525e3565..3743a0a55 100644 --- a/app/features/tier-list-maker/tier-list-maker-search-params.ts +++ b/app/features/tier-list-maker/tier-list-maker-search-params.ts @@ -1,8 +1,8 @@ -import { z } from "zod"; +import * as v from "valibot"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import * as SearchParams from "~/modules/search-params/search-params"; -import { SP } from "~/modules/search-params/search-params"; -import { modeShort } from "~/utils/zod"; +import { codec, SP } from "~/modules/search-params/search-params"; +import { modeShort } from "~/utils/schema"; import { DEFAULT_TIERS } from "./tier-list-maker-constants"; import { type TierListState, @@ -15,28 +15,25 @@ const EMPTY_TIER_LIST_STATE: TierListState = { tierItems: new Map(), }; -const tierListState = z.codec(z.string(), z.custom(), { - decode: (value, payload) => { - const serialized = parseSerializedJson(value); - if (!serialized) { - payload.issues.push({ - code: "custom", - message: "Invalid tier list state", - input: value, - }); - return z.NEVER; - } - return { - tiers: serialized.tiers, - tierItems: new Map(serialized.tierItems), - }; +const tierListState = codec( + v.custom(() => true), + { + decode: (value) => { + const serialized = parseSerializedJson(value); + if (!serialized) return undefined; + + return { + tiers: serialized.tiers, + tierItems: new Map(serialized.tierItems), + }; + }, + encode: (state) => + JSON.stringify({ + tiers: state.tiers, + tierItems: Array.from(state.tierItems.entries()), + }), }, - encode: (state) => - JSON.stringify({ - tiers: state.tiers, - tierItems: Array.from(state.tierItems.entries()), - }), -}); +); export const tierListMakerSearchParams = SearchParams.define({ state: SP.custom(tierListState, { @@ -48,12 +45,12 @@ export const tierListMakerSearchParams = SearchParams.define({ default: "main-weapon", loader: false, }), - title: SP.param(z.string(), { default: "", loader: false }), - showTierHeaders: SP.param(z.boolean(), { default: true, loader: false }), - hideAltKits: SP.param(z.boolean(), { default: false, loader: false }), - hideAltSkins: SP.param(z.boolean(), { default: false, loader: false }), - canAddDuplicates: SP.param(z.boolean(), { default: false, loader: false }), - modes: SP.param(z.array(modeShort), { + title: SP.param(v.string(), { default: "", loader: false }), + showTierHeaders: SP.param(v.boolean(), { default: true, loader: false }), + hideAltKits: SP.param(v.boolean(), { default: false, loader: false }), + hideAltSkins: SP.param(v.boolean(), { default: false, loader: false }), + canAddDuplicates: SP.param(v.boolean(), { default: false, loader: false }), + modes: SP.param(v.array(modeShort), { default: [...rankedModesShort], loader: false, }), @@ -67,6 +64,6 @@ function parseSerializedJson(value: string) { return null; } - const parsed = tierListStateSerializedSchema.safeParse(json); - return parsed.success ? parsed.data : null; + const parsed = v.safeParse(tierListStateSerializedSchema, json); + return parsed.success ? parsed.output : null; } diff --git a/app/features/timezone/timezone-cookie.ts b/app/features/timezone/timezone-cookie.ts index 7a6fc240d..6ed4d789b 100644 --- a/app/features/timezone/timezone-cookie.ts +++ b/app/features/timezone/timezone-cookie.ts @@ -1,20 +1,21 @@ -import { z } from "zod"; +import * as v from "valibot"; import { logger } from "~/utils/logger"; const COOKIE_NAME = "timezone"; const TEN_YEARS_IN_MS = 315_360_000_000; -const ianaTimezone = z - .string() - .max(100) - .refine((value) => { +const ianaTimezone = v.pipe( + v.string(), + v.maxLength(100), + v.check((value) => { try { new Intl.DateTimeFormat("en-US", { timeZone: value }); return true; } catch { return false; } - }); + }), +); /** * Stores the browser's IANA timezone in a cookie so that the server can read it @@ -63,7 +64,7 @@ export function viewerTimezoneFromCookieHeader( .find((cookie) => cookie.startsWith(`${COOKIE_NAME}=`)) ?.slice(COOKIE_NAME.length + 1); - const parsed = ianaTimezone.safeParse(value); + const parsed = v.safeParse(ianaTimezone, value); - return parsed.success ? parsed.data : null; + return parsed.success ? parsed.output : null; } diff --git a/app/features/top-search/actions/xsearch.player.$id.server.ts b/app/features/top-search/actions/xsearch.player.$id.server.ts index 38ccd91ca..542a6cf3c 100644 --- a/app/features/top-search/actions/xsearch.player.$id.server.ts +++ b/app/features/top-search/actions/xsearch.player.$id.server.ts @@ -9,7 +9,7 @@ import { parseParams, successToast, } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; +import { idObject } from "~/utils/schema"; import * as XRankPlacementRepository from "../XRankPlacementRepository.server"; export const action = async ({ params }: ActionFunctionArgs) => { diff --git a/app/features/top-search/loaders/xsearch.player.$id.server.ts b/app/features/top-search/loaders/xsearch.player.$id.server.ts index cfd3a794b..333fc8645 100644 --- a/app/features/top-search/loaders/xsearch.player.$id.server.ts +++ b/app/features/top-search/loaders/xsearch.player.$id.server.ts @@ -1,7 +1,7 @@ import type { LoaderFunctionArgs } from "react-router"; import * as R from "remeda"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; +import { idObject } from "~/utils/schema"; import * as XRankPlacementRepository from "../XRankPlacementRepository.server"; export const loader = async (args: LoaderFunctionArgs) => { diff --git a/app/features/top-search/top-search-search-params.ts b/app/features/top-search/top-search-search-params.ts index 09c997e8f..b2d638fe4 100644 --- a/app/features/top-search/top-search-search-params.ts +++ b/app/features/top-search/top-search-search-params.ts @@ -1,11 +1,20 @@ -import { z } from "zod"; +import * as v from "valibot"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; export const topSearchSearchParams = SearchParams.define({ - mode: SP.param(z.enum(rankedModesShort), { default: "SZ", loader: true }), - region: SP.param(z.enum(["WEST", "JPN"]), { default: "WEST", loader: true }), - month: SP.param(z.number().int().min(1).max(12).nullable(), { loader: true }), - year: SP.param(z.number().int().min(2023).nullable(), { loader: true }), + mode: SP.param(v.picklist(rankedModesShort), { default: "SZ", loader: true }), + region: SP.param(v.picklist(["WEST", "JPN"]), { + default: "WEST", + loader: true, + }), + month: SP.param( + v.nullable(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(12))), + { loader: true }, + ), + year: SP.param( + v.nullable(v.pipe(v.number(), v.integer(), v.minValue(2023))), + { loader: true }, + ), }); diff --git a/app/features/tournament-admin/loaders/to.$id.admin.registration.$tid.server.ts b/app/features/tournament-admin/loaders/to.$id.admin.registration.$tid.server.ts index 9b9b40497..a1d6f13cc 100644 --- a/app/features/tournament-admin/loaders/to.$id.admin.registration.$tid.server.ts +++ b/app/features/tournament-admin/loaders/to.$id.admin.registration.$tid.server.ts @@ -1,12 +1,12 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { tournamentFromParams, tournamentTeamsFullCached, } from "~/features/tournament-bracket/core/Tournament.server"; import type { SerializeFrom } from "~/utils/remix"; import { parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; export type TournamentAdminRegistrationLoaderData = SerializeFrom< typeof loader @@ -15,7 +15,7 @@ export type TournamentAdminRegistrationLoaderData = SerializeFrom< export const loader = async ({ params }: LoaderFunctionArgs) => { const { tid: tournamentTeamId } = parseParams({ params, - schema: z.object({ tid: id.optional() }), + schema: v.object({ tid: v.optional(id) }), }); const { tournamentId, user } = await tournamentFromParams(params, { diff --git a/app/features/tournament-admin/tournament-admin-registration-schemas.server.ts b/app/features/tournament-admin/tournament-admin-registration-schemas.server.ts index bbb0cfd32..47f20d804 100644 --- a/app/features/tournament-admin/tournament-admin-registration-schemas.server.ts +++ b/app/features/tournament-admin/tournament-admin-registration-schemas.server.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { userIsBanned } from "~/features/ban/core/banned.server"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import * as TeamRepository from "~/features/team/TeamRepository.server"; @@ -10,6 +10,7 @@ import { import { tournamentTeamNameTaken } from "~/features/tournament/tournament-utils.server"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { superRefineAsync } from "~/utils/schema"; import { ADMIN_REGISTRATION_MAX_MEMBERS, adminRegistrationFormSchema, @@ -26,170 +27,165 @@ export function adminRegistrationFormSchemaServer({ }: { tournament: Tournament; }) { - return adminRegistrationFormSchema.superRefine(async (data, ctx) => { - const name = data.linkedTeam - ? typeof data.teamId === "number" - ? (await TeamRepository.findById(data.teamId))?.name - : undefined - : data.pickUpName; - if ( - name != null && - tournamentTeamNameTaken({ - tournament, - name, - exceptTournamentTeamId: data.tournamentTeamId ?? undefined, - }) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "forms:errors.regTeamNameTaken", - path: [data.linkedTeam ? "teamId" : "pickUpName"], - }); - } + return v.pipeAsync( + adminRegistrationFormSchema, + superRefineAsync(async (data, ctx) => { + const name = data.linkedTeam + ? typeof data.teamId === "number" + ? (await TeamRepository.findById(data.teamId))?.name + : undefined + : data.pickUpName; + if ( + name != null && + tournamentTeamNameTaken({ + tournament, + name, + exceptTournamentTeamId: data.tournamentTeamId ?? undefined, + }) + ) { + ctx.addIssue({ + message: "forms:errors.regTeamNameTaken", + path: [data.linkedTeam ? "teamId" : "pickUpName"], + }); + } - if (data.members.length > ADMIN_REGISTRATION_MAX_MEMBERS) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "forms:errors.regTooManyMembers", - path: ["members"], - }); - } + if (data.members.length > ADMIN_REGISTRATION_MAX_MEMBERS) { + ctx.addIssue({ + message: "forms:errors.regTooManyMembers", + path: ["members"], + }); + } - // the map pool is only written while it can still be changed, matching the - // form field's own visibility, so any other state says nothing about it - if (tournament.teamsPrePickMaps && !tournament.hasStarted) { - const currentMapPool = - typeof data.tournamentTeamId === "number" - ? (( - await TournamentTeamRepository.findMapPoolsByTeamIds([ - data.tournamentTeamId, - ]) - ).get(data.tournamentTeamId) ?? []) - : []; - // a pool valid when picked can stop being valid later (a map gets banned, the - // tie-breaker pool changes), so only a changed pool is held to being valid and - // an untouched one can't block unrelated edits to the team - const mapPoolChanged = - MapPool.serialize(data.mapPool) !== MapPool.serialize(currentMapPool); + // the map pool is only written while it can still be changed, matching the + // form field's own visibility, so any other state says nothing about it + if (tournament.teamsPrePickMaps && !tournament.hasStarted) { + const currentMapPool = + typeof data.tournamentTeamId === "number" + ? (( + await TournamentTeamRepository.findMapPoolsByTeamIds([ + data.tournamentTeamId, + ]) + ).get(data.tournamentTeamId) ?? []) + : []; + // a pool valid when picked can stop being valid later (a map gets banned, the + // tie-breaker pool changes), so only a changed pool is held to being valid and + // an untouched one can't block unrelated edits to the team + const mapPoolChanged = + MapPool.serialize(data.mapPool) !== MapPool.serialize(currentMapPool); - if (mapPoolChanged) { - const invalidMode = data.mapPool.some( - (map) => !tournament.modesIncluded.includes(map.mode), - ); - const status = validateCounterPickMapPool( - new MapPool(data.mapPool), - isOneModeTournamentOf( - tournament.ctx.mapPickingStyle, - tournament.ctx.toSetMapPool, - ), - tournament.ctx.tieBreakerMapPool, - ); + if (mapPoolChanged) { + const invalidMode = data.mapPool.some( + (map) => !tournament.modesIncluded.includes(map.mode), + ); + const status = validateCounterPickMapPool( + new MapPool(data.mapPool), + isOneModeTournamentOf( + tournament.ctx.mapPickingStyle, + tournament.ctx.toSetMapPool, + ), + tournament.ctx.tieBreakerMapPool, + ); - if (invalidMode || status !== "VALID") { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "forms:errors.invalidMapPool", - path: ["mapPool"], - }); + if (invalidMode || status !== "VALID") { + ctx.addIssue({ + message: "forms:errors.invalidMapPool", + path: ["mapPool"], + }); + } } } - } - const team = - typeof data.tournamentTeamId === "number" - ? tournament.teamById(data.tournamentTeamId) - : undefined; - const currentMemberIds = team?.memberUserIds ?? []; + const team = + typeof data.tournamentTeamId === "number" + ? tournament.teamById(data.tournamentTeamId) + : undefined; + const currentMemberIds = team?.memberUserIds ?? []; - if (team) { - const submittedMemberIds = data.members.map((member) => member.userId); - const membersToRemove = currentMemberIds.filter( - (memberId) => !submittedMemberIds.includes(memberId), - ); - - if (tournament.hasStarted) { - const participatedPlayerIds = - tournament.participatedPlayerUserIdsByTeamId(team.id); - const removingParticipatedPlayer = membersToRemove.some((memberId) => - participatedPlayerIds.includes(memberId), + if (team) { + const submittedMemberIds = data.members.map((member) => member.userId); + const membersToRemove = currentMemberIds.filter( + (memberId) => !submittedMemberIds.includes(memberId), ); - if (removingParticipatedPlayer) { + + if (tournament.hasStarted) { + const participatedPlayerIds = + tournament.participatedPlayerUserIdsByTeamId(team.id); + const removingParticipatedPlayer = membersToRemove.some((memberId) => + participatedPlayerIds.includes(memberId), + ); + if (removingParticipatedPlayer) { + ctx.addIssue({ + message: "forms:errors.regCannotRemoveParticipatedPlayer", + path: ["members"], + }); + } + } + + if ( + team.checkIns.length > 0 && + data.members.length < tournament.minMembersPerTeam + ) { ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "forms:errors.regCannotRemoveParticipatedPlayer", + message: "forms:errors.regCheckedInBelowMinRoster", path: ["members"], }); } } - if ( - team.checkIns.length > 0 && - data.members.length < tournament.minMembersPerTeam - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "forms:errors.regCheckedInBelowMinRoster", - path: ["members"], + for (const [index, member] of data.members.entries()) { + const path = ["members", index, "userId"]; + + const memberUser = await UserRepository.findLeanById(member.userId); + if (!memberUser) { + ctx.addIssue({ + message: "forms:errors.regMemberInvalid", + path, + }); + continue; + } + + if (!memberUser.friendCode) { + ctx.addIssue({ + message: "forms:errors.regMemberNoFriendCode", + path, + }); + } + + if ( + tournament.ctx.settings.requireInGameNames && + !member.inGameName && + !memberUser.inGameName + ) { + ctx.addIssue({ + message: "forms:errors.regMemberNoInGameName", + path, + }); + } + + // only members not already on the team are subject to ban / other-team checks + if (currentMemberIds.includes(member.userId)) continue; + + if (userIsBanned(member.userId)) { + ctx.addIssue({ + message: "forms:errors.regMemberBanned", + path, + }); + } + + const previousTeam = tournament.teamMemberOfByUser({ + id: member.userId, }); + if ( + previousTeam && + previousTeam.id !== team?.id && + !tournament.hasStarted + ) { + ctx.addIssue({ + message: "forms:errors.regMemberOnAnotherTeam", + path, + }); + } } - } - - for (const [index, member] of data.members.entries()) { - const path = ["members", index, "userId"]; - - const memberUser = await UserRepository.findLeanById(member.userId); - if (!memberUser) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "forms:errors.regMemberInvalid", - path, - }); - continue; - } - - if (!memberUser.friendCode) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "forms:errors.regMemberNoFriendCode", - path, - }); - } - - if ( - tournament.ctx.settings.requireInGameNames && - !member.inGameName && - !memberUser.inGameName - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "forms:errors.regMemberNoInGameName", - path, - }); - } - - // only members not already on the team are subject to ban / other-team checks - if (currentMemberIds.includes(member.userId)) continue; - - if (userIsBanned(member.userId)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "forms:errors.regMemberBanned", - path, - }); - } - - const previousTeam = tournament.teamMemberOfByUser({ id: member.userId }); - if ( - previousTeam && - previousTeam.id !== team?.id && - !tournament.hasStarted - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "forms:errors.regMemberOnAnotherTeam", - path, - }); - } - } - }); + }), + ); } diff --git a/app/features/tournament-admin/tournament-admin-registration-schemas.ts b/app/features/tournament-admin/tournament-admin-registration-schemas.ts index 3ed2a8c80..059cb4355 100644 --- a/app/features/tournament-admin/tournament-admin-registration-schemas.ts +++ b/app/features/tournament-admin/tournament-admin-registration-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { array, @@ -14,7 +14,7 @@ import { tournamentSearchOptional, userSearch, } from "~/form/fields"; -import { modeShort, stageId } from "~/utils/zod"; +import { modeShort, stageId, superRefine } from "~/utils/schema"; import { IN_GAME_NAME_MAX_LENGTH } from "../user-page/in-game-name"; import { USER } from "../user-page/user-page-constants"; /** @@ -25,7 +25,7 @@ import { USER } from "../user-page/user-page-constants"; export const ADMIN_REGISTRATION_MAX_MEMBERS = 12; const memberFieldset = fieldset({ - fields: z.object({ + fields: v.object({ userId: userSearch({ label: "labels.player" }), inGameName: textFieldOptional({ label: "labels.inGameName", @@ -44,8 +44,8 @@ const memberFieldset = fieldset({ }), }); -export const adminRegistrationFormSchema = z - .object({ +export const adminRegistrationFormSchema = v.pipe( + v.object({ _action: stringConstant("UPSERT_REGISTRATION"), /** Present when editing an existing registration, absent when adding a new team. */ tournamentTeamId: idConstantOptional(), @@ -68,21 +68,19 @@ export const adminRegistrationFormSchema = z }), mapPool: customField( { initialValue: [] }, - z.array(z.object({ mode: modeShort, stageId })), + v.array(v.object({ mode: modeShort, stageId })), ), - }) - .superRefine((data, ctx) => { + }), + superRefine((data, ctx) => { if (data.linkedTeam) { if (typeof data.teamId !== "number") { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.regLinkedTeamRequired", path: ["teamId"], }); } } else if (!data.pickUpName) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.regTeamNameRequired", path: ["pickUpName"], }); @@ -91,7 +89,6 @@ export const adminRegistrationFormSchema = z const memberIds = data.members.map((member) => member.userId); if (memberIds.length !== new Set(memberIds).size) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.usersMustBeUnique", path: ["members"], }); @@ -99,14 +96,14 @@ export const adminRegistrationFormSchema = z if (!memberIds.some((memberId) => String(memberId) === data.ownerId)) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.regOwnerMustBeMember", path: ["ownerId"], }); } - }); + }), +); -export type AdminRegistrationFormValues = z.input< +export type AdminRegistrationFormValues = v.InferInput< typeof adminRegistrationFormSchema >; @@ -116,23 +113,23 @@ export type AdminRegistrationFormValues = z.input< * client-side only — submitting prefills the registration form rather than * hitting the server. */ -export const importTeamFormSchema = z - .object({ +export const importTeamFormSchema = v.pipe( + v.object({ sourceTournamentId: tournamentSearchOptional({ label: "labels.regImportSourceTournament", }), sourceTournamentTeamId: selectDynamic({ label: "labels.regTeam", }), - }) - .superRefine((data, ctx) => { + }), + superRefine((data, ctx) => { if (typeof data.sourceTournamentId !== "number") { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.regImportTournamentRequired", path: ["sourceTournamentId"], }); } - }); + }), +); -export type ImportTeamFormValues = z.input; +export type ImportTeamFormValues = v.InferInput; diff --git a/app/features/tournament-admin/tournament-admin-schemas.ts b/app/features/tournament-admin/tournament-admin-schemas.ts index 72c2a9e32..16c0ae407 100644 --- a/app/features/tournament-admin/tournament-admin-schemas.ts +++ b/app/features/tournament-admin/tournament-admin-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { TOURNAMENT, TOURNAMENT_STAGE_TYPES, @@ -6,7 +6,13 @@ import { import * as Swiss from "~/features/tournament-bracket/core/engine/swiss/team-status"; import * as Progression from "~/features/tournament-bracket/core/Progression"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; -import { _action, id, safeJSONParse } from "~/utils/zod"; +import { + _action, + id, + preprocess, + safeJSONParse, + superRefine, +} from "~/utils/schema"; import { bracketIdx } from "../tournament-bracket/tournament-bracket-schemas"; import { adminStaffFormSchema } from "./tournament-admin-staff-schemas"; @@ -20,135 +26,142 @@ export function adminStaffFormSchemaServer({ }: { tournament: Tournament; }) { - return adminStaffFormSchema.superRefine((data, ctx) => { - for (const [index, staffer] of data.staff.entries()) { - if (staffer.userId === tournament.ctx.author.id) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "forms:errors.staffCannotBeAuthor", - path: ["staff", index, "userId"], - }); + return v.pipe( + adminStaffFormSchema, + superRefine((data, ctx) => { + for (const [index, staffer] of data.staff.entries()) { + if (staffer.userId === tournament.ctx.author.id) { + ctx.addIssue({ + message: "forms:errors.staffCannotBeAuthor", + path: ["staff", index, "userId"], + }); + } } - } - }); + }), + ); } -export const adminTeamsActionSchema = z.union([ - z.object({ +export const adminTeamsActionSchema = v.union([ + v.object({ _action: _action("CHECK_IN"), teamId: id, bracketIdx, }), - z.object({ + v.object({ _action: _action("CHECK_OUT"), teamId: id, bracketIdx, }), - z.object({ + v.object({ _action: _action("DELETE_TEAM"), teamId: id, }), - z.object({ + v.object({ _action: _action("DROP_TEAM_OUT"), teamId: id, }), - z.object({ + v.object({ _action: _action("UNDO_DROP_TEAM_OUT"), teamId: id, }), ]); -const bracketProgressionSchema = z.preprocess( +const bracketProgressionSchema = preprocess( safeJSONParse, - z - .array( - z.object({ - type: z.enum(TOURNAMENT_STAGE_TYPES), - name: z.string().min(1).max(TOURNAMENT.BRACKET_NAME_MAX_LENGTH), - settings: z - .object({ - thirdPlaceMatch: z.boolean().optional(), - teamsPerGroup: z.number().int().optional(), - hasAbDivisions: z.boolean().optional(), - groupCount: z.number().int().optional(), - roundCount: z.number().int().optional(), - advanceThreshold: z.number().int().optional(), - }) - .refine( - (settings) => { - if (settings.advanceThreshold) { - return Swiss.isValidAdvanceThreshold({ - roundCount: - settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT, - advanceThreshold: settings.advanceThreshold, - }); - } - return true; - }, - { + v.pipe( + v.array( + v.object({ + type: v.picklist(TOURNAMENT_STAGE_TYPES), + name: v.pipe( + v.string(), + v.minLength(1), + v.maxLength(TOURNAMENT.BRACKET_NAME_MAX_LENGTH), + ), + settings: v.pipe( + v.object({ + thirdPlaceMatch: v.optional(v.boolean()), + teamsPerGroup: v.optional(v.pipe(v.number(), v.integer())), + hasAbDivisions: v.optional(v.boolean()), + groupCount: v.optional(v.pipe(v.number(), v.integer())), + roundCount: v.optional(v.pipe(v.number(), v.integer())), + advanceThreshold: v.optional(v.pipe(v.number(), v.integer())), + }), + superRefine((settings, ctx) => { + if (!settings.advanceThreshold) return; + + const isValid = Swiss.isValidAdvanceThreshold({ + roundCount: + settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT, + advanceThreshold: settings.advanceThreshold, + }); + if (isValid) return; + + ctx.addIssue({ message: "Invalid advance threshold for the given round count", path: ["advanceThreshold"], - }, - ), - requiresCheckIn: z.boolean(), - startTime: z.number().optional(), - sources: z - .array( - z.object({ - bracketIdx: z.number(), - placements: z.array(z.number()), - rest: z.boolean().optional(), + }); + }), + ), + requiresCheckIn: v.boolean(), + startTime: v.optional(v.number()), + sources: v.optional( + v.array( + v.object({ + bracketIdx: v.number(), + placements: v.array(v.number()), + rest: v.optional(v.boolean()), }), - ) - .optional(), + ), + ), }), - ) - .refine( + ), + v.check( (progression) => Progression.bracketsToValidationError(progression) === null, "Invalid bracket progression", ), + ), ); -export const adminBracketsActionSchema = z.union([ - z.object({ +export const adminBracketsActionSchema = v.union([ + v.object({ _action: _action("RESET_BRACKET"), stageId: id, }), - z.object({ + v.object({ _action: _action("UPDATE_TOURNAMENT_PROGRESSION"), bracketProgression: bracketProgressionSchema, }), - z.object({ + v.object({ _action: _action("REOPEN_TOURNAMENT"), }), ]); -export const adminSeedsActionSchema = z.union([ - z.object({ +export const adminSeedsActionSchema = v.union([ + v.object({ _action: _action("UPDATE_SEEDS"), - seeds: z.preprocess(safeJSONParse, z.array(id)), + seeds: preprocess(safeJSONParse, v.array(id)), }), - z.object({ + v.object({ _action: _action("UPDATE_STARTING_BRACKETS"), - startingBrackets: z.preprocess( + startingBrackets: preprocess( safeJSONParse, - z.array( - z.object({ + v.array( + v.object({ tournamentTeamId: id, startingBracketIdx: bracketIdx, }), ), ), }), - z.object({ + v.object({ _action: _action("UPDATE_AB_DIVISIONS"), - abDivisions: z.preprocess( + abDivisions: preprocess( safeJSONParse, - z.array( - z.object({ + v.array( + v.object({ tournamentTeamId: id, - abDivision: z.union([z.literal(0), z.literal(1), z.null()]), + abDivision: v.union([v.literal(0), v.literal(1), v.null()]), }), ), ), diff --git a/app/features/tournament-admin/tournament-admin-search-params.ts b/app/features/tournament-admin/tournament-admin-search-params.ts index 84ba7ce9b..e7ba89b4c 100644 --- a/app/features/tournament-admin/tournament-admin-search-params.ts +++ b/app/features/tournament-admin/tournament-admin-search-params.ts @@ -1,22 +1,28 @@ -import { z } from "zod"; +import * as v from "valibot"; import { TOURNAMENT_AUDIT_LOG_TYPES } from "~/features/tournament/tournament-constants"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; export const tournamentAuditSearchParams = SearchParams.define({ page: SP.page(), - auditType: SP.param(z.enum(TOURNAMENT_AUDIT_LOG_TYPES).nullable(), { - loader: true, - resets: ["page"], - }), - auditTeam: SP.param(z.number().int().positive().nullable(), { + auditType: SP.param(v.nullable(v.picklist(TOURNAMENT_AUDIT_LOG_TYPES)), { loader: true, resets: ["page"], }), + auditTeam: SP.param( + v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), + { + loader: true, + resets: ["page"], + }, + ), }); export const tournamentImportTeamsSearchParams = SearchParams.define({ - fromTournamentId: SP.param(z.number().int().positive().nullable(), { - loader: true, - }), + fromTournamentId: SP.param( + v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), + { + loader: true, + }, + ), }); diff --git a/app/features/tournament-admin/tournament-admin-staff-schemas.ts b/app/features/tournament-admin/tournament-admin-staff-schemas.ts index aa9992bbc..45112d3c6 100644 --- a/app/features/tournament-admin/tournament-admin-staff-schemas.ts +++ b/app/features/tournament-admin/tournament-admin-staff-schemas.ts @@ -1,8 +1,9 @@ -import { z } from "zod"; +import * as v from "valibot"; import { TOURNAMENT_STAFF_ROLES } from "~/features/tournament/tournament-constants"; import { array, fieldset, select, textField, userSearch } from "~/form/fields"; +import { superRefine } from "~/utils/schema"; -export const adminStreamFormSchema = z.object({ +export const adminStreamFormSchema = v.object({ castTwitchAccounts: array({ label: "labels.castTwitchAccounts", bottomText: "bottomTexts.castTwitchAccounts", @@ -14,13 +15,13 @@ export const adminStreamFormSchema = z.object({ }), }); -export const adminStaffFormSchema = z - .object({ +export const adminStaffFormSchema = v.pipe( + v.object({ staff: array({ bottomText: "bottomTexts.staffRolesInfo", max: 50, field: fieldset({ - fields: z.object({ + fields: v.object({ userId: userSearch({ label: "labels.user" }), role: select({ label: "labels.staffRole", @@ -32,14 +33,14 @@ export const adminStaffFormSchema = z }), }), }), - }) - .superRefine((data, ctx) => { + }), + superRefine((data, ctx) => { const userIds = data.staff.map((staffer) => staffer.userId); if (userIds.length !== new Set(userIds).size) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.usersMustBeUnique", path: ["staff"], }); } - }); + }), +); diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts index 3568d8e5e..ed7c95d1b 100644 --- a/app/features/tournament-bracket/core/Tournament.server.ts +++ b/app/features/tournament-bracket/core/Tournament.server.ts @@ -22,9 +22,9 @@ import { notFoundIfNullish, parseParams, } from "~/utils/remix.server"; +import { idObject } from "~/utils/schema"; import type { Unwrapped } from "~/utils/types"; import { tournamentPage } from "~/utils/urls"; -import { idObject } from "~/utils/zod"; import type { Bracket } from "./Bracket"; import { RunningTournaments } from "./RunningTournaments.server"; import { diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.ts b/app/features/tournament-bracket/tournament-bracket-schemas.ts index f5ebbe0c9..a82f247b4 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { reportWeaponSchema, undoWeaponReportSchema, @@ -10,184 +10,207 @@ import { import { _action, checkboxValueToBoolean, + coerceNumber, id, modeShort, nullLiteraltoNull, numericEnum, + preprocess, safeJSONParse, stageId, -} from "~/utils/zod"; +} from "~/utils/schema"; import { TOURNAMENT } from "../tournament/tournament-constants"; import * as PickBan from "./core/PickBan"; import * as PreparedMaps from "./core/PreparedMaps"; -const activeRosterPlayerIds = z.preprocess(safeJSONParse, z.array(id)); +const activeRosterPlayerIds = preprocess(safeJSONParse, v.array(id)); -const bothTeamPlayerIds = z.preprocess( +const bothTeamPlayerIds = preprocess( safeJSONParse, - z.tuple([z.array(id), z.array(id)]), + v.tuple([v.array(id), v.array(id)]), ); -const reportedMatchPosition = z.preprocess( +const reportedMatchPosition = preprocess( Number, - z - .number() - .int() - .min(0) - .max(Math.max(...TOURNAMENT.AVAILABLE_BEST_OF) - 1), + v.pipe( + v.number(), + v.integer(), + v.minValue(0), + v.maxValue(Math.max(...TOURNAMENT.AVAILABLE_BEST_OF) - 1), + ), ); -const ko = z.preprocess(safeJSONParse, z.boolean().nullish()); -export const matchSchema = z.union([ - z.object({ +const ko = v.optional(preprocess(safeJSONParse, v.nullish(v.boolean()))); + +export const matchSchema = v.union([ + v.object({ _action: _action("REPORT_SCORE"), winnerTeamId: id, position: reportedMatchPosition, ko, }), - z.object({ + v.object({ _action: _action("SET_ACTIVE_ROSTER"), roster: activeRosterPlayerIds, teamId: id, }), - z.object({ + v.object({ _action: _action("BAN_PICK"), - stageId: stageId.optional(), - mode: modeShort.optional(), + stageId: v.optional(stageId), + mode: v.optional(modeShort), }), - z.object({ + v.object({ _action: _action("UNDO_REPORT_SCORE"), position: reportedMatchPosition, }), - z.object({ + v.object({ _action: _action("UPDATE_REPORTED_SCORE"), rosters: bothTeamPlayerIds, resultId: id, ko, }), - z.object({ + v.object({ _action: _action("REOPEN_MATCH"), }), - z.object({ + v.object({ _action: _action("SET_AS_CASTED"), - twitchAccount: z.preprocess( + twitchAccount: preprocess( nullLiteraltoNull, - z.string().min(1).max(100).nullable(), + v.nullable(v.pipe(v.string(), v.minLength(1), v.maxLength(100))), ), }), - z.object({ + v.object({ _action: _action("LOCK"), - twitchAccount: z.string().min(1).max(100), + twitchAccount: v.pipe(v.string(), v.minLength(1), v.maxLength(100)), }), - z.object({ + v.object({ _action: _action("UNLOCK"), }), - z.object({ + v.object({ _action: _action("END_SET"), - winnerTeamId: z.preprocess(nullLiteraltoNull, id.nullable()), + winnerTeamId: preprocess(nullLiteraltoNull, v.nullable(id)), }), reportWeaponSchema, undoWeaponReportSchema, ]); -export const bracketIdx = z.coerce.number().int().min(0).max(100); +export const bracketIdx = v.pipe( + coerceNumber(), + v.integer(), + v.minValue(0), + v.maxValue(100), +); -const customPickBanStep = z.object({ - action: z.enum(ACTION_TYPES), - side: z.enum(WHO_SIDES).optional(), +const customPickBanStep = v.object({ + action: v.picklist(ACTION_TYPES), + side: v.optional(v.picklist(WHO_SIDES)), }); -const customPickBanFlow = z - .object({ - preSet: z.array(customPickBanStep), - postGame: z.array(customPickBanStep), - }) - .nullish(); +const customPickBanFlow = v.optional( + v.nullable( + v.object({ + preSet: v.array(customPickBanStep), + postGame: v.array(customPickBanStep), + }), + ), +); -const tournamentRoundMaps = z.object({ - roundId: z.number().int().min(0), - groupId: z.number().int().min(0), - list: z - .array( - z.object({ - mode: modeShort, - stageId, - }), - ) - .nullish(), +const tournamentRoundMaps = v.object({ + roundId: v.pipe(v.number(), v.integer(), v.minValue(0)), + groupId: v.pipe(v.number(), v.integer(), v.minValue(0)), + list: v.optional( + v.nullable( + v.array( + v.object({ + mode: modeShort, + stageId, + }), + ), + ), + ), count: numericEnum(TOURNAMENT.AVAILABLE_BEST_OF), - type: z.enum(["BEST_OF", "PLAY_ALL"]), - pickBan: z.enum(PickBan.types).nullish(), + type: v.picklist(["BEST_OF", "PLAY_ALL"]), + pickBan: v.nullish(v.picklist(PickBan.types)), customFlow: customPickBanFlow, }); -export const bracketSchema = z.union([ - z.object({ +export const bracketSchema = v.union([ + v.object({ _action: _action("START_BRACKET"), bracketIdx, - thirdPlaceMatchLinked: z.preprocess(checkboxValueToBoolean, z.boolean()), - maps: z.preprocess(safeJSONParse, z.array(tournamentRoundMaps)), + thirdPlaceMatchLinked: v.optional( + preprocess(checkboxValueToBoolean, v.boolean()), + false, + ), + maps: preprocess(safeJSONParse, v.array(tournamentRoundMaps)), }), - z.object({ + v.object({ _action: _action("PREPARE_MAPS"), bracketIdx, - maps: z.preprocess(safeJSONParse, z.array(tournamentRoundMaps)), - thirdPlaceMatchLinked: z.preprocess(checkboxValueToBoolean, z.boolean()), - eliminationTeamCount: z.coerce - .number() - .optional() - .refine( + maps: preprocess(safeJSONParse, v.array(tournamentRoundMaps)), + thirdPlaceMatchLinked: v.optional( + preprocess(checkboxValueToBoolean, v.boolean()), + false, + ), + eliminationTeamCount: v.pipe( + v.optional(coerceNumber()), + v.check( (val) => !val || PreparedMaps.isValidMaxEliminationTeamCount(val), ), + ), }), - z.object({ + v.object({ _action: _action("ADVANCE_BRACKET"), groupId: id, bracketIdx, }), - z.object({ + v.object({ _action: _action("UNADVANCE_BRACKET"), groupId: id, roundId: id, bracketIdx, }), - z.object({ + v.object({ _action: _action("BRACKET_CHECK_IN"), bracketIdx, }), - z.object({ + v.object({ _action: _action("OVERRIDE_BRACKET_PROGRESSION"), tournamentTeamId: id, sourceBracketIdx: bracketIdx, - destinationBracketIdx: z.union([bracketIdx, z.literal(-1)]), + destinationBracketIdx: v.union([bracketIdx, v.literal(-1)]), }), ]); -export const matchPageParamsSchema = z.object({ id, mid: id }); +export const matchPageParamsSchema = v.object({ id, mid: id }); -export const tournamentTeamPageParamsSchema = z.object({ +export const tournamentTeamPageParamsSchema = v.object({ id, tid: id, }); -export type TournamentBadgeReceivers = z.infer; +export type TournamentBadgeReceivers = v.InferOutput; -const badgeReceivers = z.array( - z.object({ +const badgeReceivers = v.array( + v.object({ badgeId: id, tournamentTeamId: id, - userIds: z.array(id).min(1).max(50), + userIds: v.pipe(v.array(id), v.minLength(1), v.maxLength(50)), }), ); -export type TournamentTrophyReceiver = z.infer; +export type TournamentTrophyReceiver = v.InferOutput; -const trophyReceiver = z.object({ +const trophyReceiver = v.object({ trophyId: id, - userIds: z.array(id).min(1).max(50), + userIds: v.pipe(v.array(id), v.minLength(1), v.maxLength(50)), }); -export const finalizeTournamentActionSchema = z.object({ +export const finalizeTournamentActionSchema = v.object({ _action: _action("FINALIZE_TOURNAMENT"), - badgeReceivers: z.preprocess(safeJSONParse, badgeReceivers.nullish()), - trophyReceiver: z.preprocess(safeJSONParse, trophyReceiver.nullish()), + badgeReceivers: v.optional( + preprocess(safeJSONParse, v.nullish(badgeReceivers)), + ), + trophyReceiver: v.optional( + preprocess(safeJSONParse, v.nullish(trophyReceiver)), + ), }); diff --git a/app/features/tournament-bracket/tournament-bracket-search-params.ts b/app/features/tournament-bracket/tournament-bracket-search-params.ts index f64a5b58b..bdfd4299e 100644 --- a/app/features/tournament-bracket/tournament-bracket-search-params.ts +++ b/app/features/tournament-bracket/tournament-bracket-search-params.ts @@ -1,12 +1,14 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; export const tournamentBracketsSearchParams = SearchParams.define({ - idx: SP.param(z.number().int().min(0).nullable(), { + idx: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), { loader: true, resets: ["group"], }), /** Group of a swiss bracket, the only type whose groups are viewed one at a time. */ - group: SP.param(z.number().int().nullable(), { loader: true }), + group: SP.param(v.nullable(v.pipe(v.number(), v.integer())), { + loader: true, + }), }); diff --git a/app/features/tournament-lfg/tournament-lfg-schemas.ts b/app/features/tournament-lfg/tournament-lfg-schemas.ts index 556405790..7e21ef7c8 100644 --- a/app/features/tournament-lfg/tournament-lfg-schemas.ts +++ b/app/features/tournament-lfg/tournament-lfg-schemas.ts @@ -1,24 +1,24 @@ -import { z } from "zod"; +import * as v from "valibot"; import { stringConstant, textAreaOptional, toggle, userSearch, } from "~/form/fields"; -import { _action, id } from "~/utils/zod"; +import { _action, id } from "~/utils/schema"; const noteFieldSchema = textAreaOptional({ label: "labels.note", maxLength: 160, }); -export const addSubFormSchema = z.object({ +export const addSubFormSchema = v.object({ _action: stringConstant("ADD_SUB"), message: noteFieldSchema, }); -export const addSubForUserFormSchema = z.object({ - ...addSubFormSchema.shape, +export const addSubForUserFormSchema = v.object({ + ...addSubFormSchema.entries, _action: stringConstant("ADD_SUB_FOR_USER"), userId: userSearch({ label: "labels.user" }), }); @@ -28,55 +28,55 @@ const stayAsSubFieldSchema = toggle({ bottomText: "bottomTexts.stayAsSub", }); -export const joinQueueFormSchema = z.object({ +export const joinQueueFormSchema = v.object({ _action: stringConstant("JOIN_QUEUE"), note: noteFieldSchema, stayAsSub: stayAsSubFieldSchema, }); -export const updateGroupFormSchema = z.object({ +export const updateGroupFormSchema = v.object({ _action: stringConstant("UPDATE_GROUP"), note: noteFieldSchema, stayAsSub: stayAsSubFieldSchema, }); -export const lookingSchema = z.union([ - z.object({ +export const lookingSchema = v.union([ + v.object({ _action: _action("JOIN_QUEUE"), - note: noteFieldSchema.optional(), - stayAsSub: stayAsSubFieldSchema.optional(), + note: v.optional(noteFieldSchema), + stayAsSub: v.optional(stayAsSubFieldSchema), }), - z.object({ + v.object({ _action: _action("LIKE"), targetTeamId: id, }), - z.object({ + v.object({ _action: _action("UNLIKE"), targetTeamId: id, }), - z.object({ + v.object({ _action: _action("ACCEPT"), targetTeamId: id, }), - z.object({ + v.object({ _action: _action("GIVE_MANAGER"), userId: id, }), - z.object({ + v.object({ _action: _action("REMOVE_MANAGER"), userId: id, }), updateGroupFormSchema, - z.object({ + v.object({ _action: _action("LEAVE_GROUP"), }), - z.object({ + v.object({ _action: _action("DELETE_GROUP"), userId: id, }), addSubFormSchema, addSubForUserFormSchema, - z.object({ + v.object({ _action: _action("DELETE_SUB"), userId: id, }), diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts index e49a5f2e4..3e067af15 100644 --- a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts @@ -7,7 +7,7 @@ vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ setMetadata: vi.fn(), })); -import type { z } from "zod"; +import type * as v from "valibot"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; @@ -27,7 +27,7 @@ const tournamentMatchAction = wrappedAction({ isJsonSubmission: true, }); const removeMemberApiActionWrapped = wrappedAction< - z.ZodType<{ userId: number }> + v.GenericSchema<{ userId: number }> >({ action: removeMemberApiAction, isJsonSubmission: true, diff --git a/app/features/tournament-organization/tournament-organization-schemas.server.ts b/app/features/tournament-organization/tournament-organization-schemas.server.ts index d0f5b11a7..f4eb17f0f 100644 --- a/app/features/tournament-organization/tournament-organization-schemas.server.ts +++ b/app/features/tournament-organization/tournament-organization-schemas.server.ts @@ -1,18 +1,18 @@ -import { z } from "zod"; +import * as v from "valibot"; import { mySlugify } from "~/utils/urls"; import * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server"; import { newOrganizationSchema } from "./tournament-organization-schemas"; -export const newOrganizationSchemaServer = z.object({ - ...newOrganizationSchema.shape, - name: newOrganizationSchema.shape.name.refine( - async (name) => { +export const newOrganizationSchemaServer = v.objectAsync({ + ...newOrganizationSchema.entries, + name: v.pipeAsync( + newOrganizationSchema.entries.name, + v.checkAsync(async (name) => { const existing = await TournamentOrganizationRepository.findBySlug( mySlugify(name), ); return !existing; - }, - { message: "forms:errors.duplicateOrgName" }, + }, "forms:errors.duplicateOrgName"), ), }); diff --git a/app/features/tournament-organization/tournament-organization-schemas.ts b/app/features/tournament-organization/tournament-organization-schemas.ts index 094d51e31..ca32e496d 100644 --- a/app/features/tournament-organization/tournament-organization-schemas.ts +++ b/app/features/tournament-organization/tournament-organization-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { TOURNAMENT_ORGANIZATION, TOURNAMENT_ORGANIZATION_ROLES, @@ -17,8 +17,8 @@ import { toggle, userSearch, } from "~/form/fields"; +import { _action, id, superRefine } from "~/utils/schema"; import { mySlugify } from "~/utils/urls"; -import { _action, id } from "~/utils/zod"; const orgNameField = textField({ label: "labels.name", @@ -30,12 +30,12 @@ const orgNameField = textField({ }, }); -export const newOrganizationSchema = z.object({ +export const newOrganizationSchema = v.object({ name: orgNameField, }); -export const organizationEditFormSchema = z - .object({ +export const organizationEditFormSchema = v.pipe( + v.object({ name: orgNameField, logo: image({ label: "labels.logo", autoValidate: true }), description: textAreaOptional({ @@ -47,7 +47,7 @@ export const organizationEditFormSchema = z bottomText: "bottomTexts.orgMembersInfo", max: 32, field: fieldset({ - fields: z.object({ + fields: v.object({ userId: userSearch({ label: "labels.user" }), role: select({ label: "labels.orgMemberRole", @@ -72,7 +72,7 @@ export const organizationEditFormSchema = z label: "labels.orgSeries", max: 10, field: fieldset({ - fields: z.object({ + fields: v.object({ name: textField({ label: "labels.orgSeriesName", minLength: 1, @@ -87,14 +87,13 @@ export const organizationEditFormSchema = z }), }), badges: badges({ label: "labels.orgBadges", maxCount: 50 }), - }) - .superRefine((data, ctx) => { + }), + superRefine((data, ctx) => { const seenUserIds = new Set(); for (const [index, member] of data.members.entries()) { if (seenUserIds.has(member.userId)) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.duplicateOrgMember", path: ["members", index, "userId"], }); @@ -103,9 +102,10 @@ export const organizationEditFormSchema = z seenUserIds.add(member.userId); } - }); + }), +); -export const banUserActionSchema = z.object({ +export const banUserActionSchema = v.object({ _action: stringConstant("BAN_USER"), userId: userSearch({ label: "labels.player" }), privateNote: textAreaOptional({ @@ -121,27 +121,27 @@ export const banUserActionSchema = z.object({ }), }); -const unbanUserActionSchema = z.object({ +const unbanUserActionSchema = v.object({ _action: _action("UNBAN_USER"), userId: id, }); -export const updateIsEstablishedSchema = z.object({ +export const updateIsEstablishedSchema = v.object({ _action: stringConstant("UPDATE_IS_ESTABLISHED"), isEstablished: toggle({ label: "labels.isEstablished", }), }); -const deleteOrganizationActionSchema = z.object({ +const deleteOrganizationActionSchema = v.object({ _action: _action("DELETE_ORGANIZATION"), }); -const leaveOrganizationActionSchema = z.object({ +const leaveOrganizationActionSchema = v.object({ _action: _action("LEAVE_ORGANIZATION"), }); -export const orgPageActionSchema = z.union([ +export const orgPageActionSchema = v.union([ banUserActionSchema, unbanUserActionSchema, updateIsEstablishedSchema, diff --git a/app/features/tournament-organization/tournament-organization-search-params.ts b/app/features/tournament-organization/tournament-organization-search-params.ts index ad1ec8962..b01d80ce8 100644 --- a/app/features/tournament-organization/tournament-organization-search-params.ts +++ b/app/features/tournament-organization/tournament-organization-search-params.ts @@ -1,13 +1,23 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; export const tournamentOrganizationSearchParams = SearchParams.define({ - month: SP.param(z.number().int().min(0).max(11).nullable(), { loader: true }), - year: SP.param(z.number().int().min(2020).max(2100).nullable(), { + month: SP.param( + v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(11))), + { loader: true }, + ), + year: SP.param( + v.nullable( + v.pipe(v.number(), v.integer(), v.minValue(2020), v.maxValue(2100)), + ), + { + loader: true, + }, + ), + series: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), { loader: true, }), - series: SP.param(z.number().int().positive().nullable(), { loader: true }), page: SP.page({ max: 100 }), - source: SP.param(z.string().nullable(), { loader: true }), + source: SP.param(v.nullable(v.string()), { loader: true }), }); diff --git a/app/features/tournament-organization/tournament-organization-utils.server.ts b/app/features/tournament-organization/tournament-organization-utils.server.ts index e2f0a4a38..da3fa2947 100644 --- a/app/features/tournament-organization/tournament-organization-utils.server.ts +++ b/app/features/tournament-organization/tournament-organization-utils.server.ts @@ -1,10 +1,10 @@ import type { LoaderFunctionArgs } from "react-router"; -import { z } from "zod"; +import * as v from "valibot"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; import * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server"; -const organizationParamsSchema = z.object({ - slug: z.string(), +const organizationParamsSchema = v.object({ + slug: v.string(), }); export async function organizationFromParams( diff --git a/app/features/tournament-subs/loaders/to.$id.subs.server.ts b/app/features/tournament-subs/loaders/to.$id.subs.server.ts index bff2886b5..8ca1de419 100644 --- a/app/features/tournament-subs/loaders/to.$id.subs.server.ts +++ b/app/features/tournament-subs/loaders/to.$id.subs.server.ts @@ -1,7 +1,7 @@ import { type LoaderFunctionArgs, redirect } from "react-router"; import { parseParams } from "~/utils/remix.server"; +import { idObject } from "~/utils/schema"; import { tournamentSubsPage } from "~/utils/urls"; -import { idObject } from "~/utils/zod"; export const loader = async ({ params }: LoaderFunctionArgs) => { const { id: tournamentId } = parseParams({ diff --git a/app/features/tournament/actions/to.$id.info.server.ts b/app/features/tournament/actions/to.$id.info.server.ts index 55c939803..d3c32e264 100644 --- a/app/features/tournament/actions/to.$id.info.server.ts +++ b/app/features/tournament/actions/to.$id.info.server.ts @@ -6,8 +6,8 @@ import { parseParams, parseRequestPayload, } from "~/utils/remix.server"; +import { idObject } from "~/utils/schema"; import { assertUnreachable } from "~/utils/types"; -import { idObject } from "~/utils/zod"; import { TOURNAMENT } from "../tournament-constants"; import { saveTournamentSchema } from "../tournament-schemas"; diff --git a/app/features/tournament/loaders/to.$id.server.ts b/app/features/tournament/loaders/to.$id.server.ts index 8d9a3b1b4..e4f8fa8fc 100644 --- a/app/features/tournament/loaders/to.$id.server.ts +++ b/app/features/tournament/loaders/to.$id.server.ts @@ -16,7 +16,7 @@ import * as TournamentMatchVodRepository from "~/features/tournament-bracket/Tou import { hasPermission } from "~/modules/permissions/utils"; import { databaseTimestampToDate } from "~/utils/dates"; import { parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; +import { idObject } from "~/utils/schema"; import { serializeTournamentLoaderData } from "../core/layout-payload"; export type TournamentLoaderData = { diff --git a/app/features/tournament/tournament-register-schemas.server.ts b/app/features/tournament/tournament-register-schemas.server.ts index 17e793afd..006a7e23a 100644 --- a/app/features/tournament/tournament-register-schemas.server.ts +++ b/app/features/tournament/tournament-register-schemas.server.ts @@ -1,6 +1,7 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as TeamRepository from "~/features/team/TeamRepository.server"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import { superRefineAsync } from "~/utils/schema"; import { registerTeamFormSchema } from "./tournament-register-schemas"; import { tournamentTeamNameTaken } from "./tournament-utils.server"; @@ -18,25 +19,27 @@ export function registerTeamFormSchemaServer({ /** The team the registering user already owns, excluded from the uniqueness check. */ ownTeamId?: number; }) { - return registerTeamFormSchema.superRefine(async (data, ctx) => { - const linkedTeamId = data.teamId ? Number(data.teamId) : null; - const name = linkedTeamId - ? (await TeamRepository.findById(linkedTeamId))?.name - : data.pickUpName; - if (!name) return; + return v.pipeAsync( + registerTeamFormSchema, + superRefineAsync(async (data, ctx) => { + const linkedTeamId = data.teamId ? Number(data.teamId) : null; + const name = linkedTeamId + ? (await TeamRepository.findById(linkedTeamId))?.name + : data.pickUpName; + if (!name) return; - if ( - tournamentTeamNameTaken({ - tournament, - name, - exceptTournamentTeamId: ownTeamId, - }) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "forms:errors.regTeamNameTaken", - path: [linkedTeamId ? "teamId" : "pickUpName"], - }); - } - }); + if ( + tournamentTeamNameTaken({ + tournament, + name, + exceptTournamentTeamId: ownTeamId, + }) + ) { + ctx.addIssue({ + message: "forms:errors.regTeamNameTaken", + path: [linkedTeamId ? "teamId" : "pickUpName"], + }); + } + }), + ); } diff --git a/app/features/tournament/tournament-register-schemas.ts b/app/features/tournament/tournament-register-schemas.ts index 6c92d5ae5..9a4767dd9 100644 --- a/app/features/tournament/tournament-register-schemas.ts +++ b/app/features/tournament/tournament-register-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { image, selectDynamicOptional, @@ -6,10 +6,11 @@ import { textFieldOptional, toggle, } from "~/form/fields"; +import { superRefine } from "~/utils/schema"; import { TOURNAMENT } from "./tournament-constants"; -export const registerTeamFormSchema = z - .object({ +export const registerTeamFormSchema = v.pipe( + v.object({ _action: stringConstant("UPSERT_TEAM"), /** `String(teamId)` of one of the user's sendou.ink teams, or null for a pickup team. */ teamId: selectDynamicOptional({ label: "labels.regSignUpAs" }), @@ -20,15 +21,17 @@ export const registerTeamFormSchema = z /** Pickup team logo. Linked teams source their logo from the sendou.ink team instead. */ logo: image({ label: "labels.logo", autoValidate: true }), prefersNotToHost: toggle({ label: "labels.regPrefersNotToHost" }), - }) - .superRefine((data, ctx) => { + }), + superRefine((data, ctx) => { if (!data.teamId && !data.pickUpName) { ctx.addIssue({ - code: z.ZodIssueCode.custom, message: "forms:errors.regTeamNameRequired", path: ["pickUpName"], }); } - }); + }), +); -export type RegisterTeamFormValues = z.input; +export type RegisterTeamFormValues = v.InferInput< + typeof registerTeamFormSchema +>; diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts index dcc782bb1..37fe4060c 100644 --- a/app/features/tournament/tournament-schemas.server.ts +++ b/app/features/tournament/tournament-schemas.server.ts @@ -1,6 +1,6 @@ -import { z } from "zod"; +import * as v from "valibot"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; -import { _action } from "~/utils/zod"; +import { _action } from "~/utils/schema"; import { registerTeamFormSchemaServer } from "./tournament-register-schemas.server"; import { addPlayerSchema, @@ -16,16 +16,16 @@ export function registerSchema({ tournament: Tournament; ownTeamId?: number; }) { - return z.union([ + return v.unionAsync([ registerTeamFormSchemaServer({ tournament, ownTeamId }), updateMapPoolSchema, deleteTeamMemberSchema, - z.object({ + v.object({ _action: _action("LEAVE_TEAM"), }), checkInSchema, addPlayerSchema, - z.object({ + v.object({ _action: _action("UNREGISTER"), }), ]); diff --git a/app/features/tournament/tournament-schemas.ts b/app/features/tournament/tournament-schemas.ts index 02dc57624..11ea658bc 100644 --- a/app/features/tournament/tournament-schemas.ts +++ b/app/features/tournament/tournament-schemas.ts @@ -1,33 +1,40 @@ -import { z } from "zod"; -import { _action, id, modeShort, safeJSONParse, stageId } from "~/utils/zod"; +import * as v from "valibot"; +import { + _action, + id, + modeShort, + preprocess, + safeJSONParse, + stageId, +} from "~/utils/schema"; -export const checkInSchema = z.object({ +export const checkInSchema = v.object({ _action: _action("CHECK_IN"), }); -export const updateMapPoolSchema = z.object({ +export const updateMapPoolSchema = v.object({ _action: _action("UPDATE_MAP_POOL"), - mapPool: z.preprocess( + mapPool: preprocess( safeJSONParse, - z.array(z.object({ stageId, mode: modeShort })), + v.array(v.object({ stageId, mode: modeShort })), ), }); -export const addPlayerSchema = z.object({ +export const addPlayerSchema = v.object({ _action: _action("ADD_PLAYER"), userId: id, }); -export const deleteTeamMemberSchema = z.object({ +export const deleteTeamMemberSchema = v.object({ _action: _action("DELETE_TEAM_MEMBER"), userId: id, }); -export const saveTournamentSchema = z.union([ - z.object({ +export const saveTournamentSchema = v.union([ + v.object({ _action: _action("SAVE_TOURNAMENT"), }), - z.object({ + v.object({ _action: _action("UNSAVE_TOURNAMENT"), }), ]); diff --git a/app/features/tournament/tournament-search-params.ts b/app/features/tournament/tournament-search-params.ts index 78eba804e..ee6657c98 100644 --- a/app/features/tournament/tournament-search-params.ts +++ b/app/features/tournament/tournament-search-params.ts @@ -1,35 +1,34 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; -import { SP } from "~/modules/search-params/search-params"; +import { + codec, + nullableCodec, + SP, +} from "~/modules/search-params/search-params"; -const isoDateCodec = z.codec(z.string(), z.date(), { - decode: (value, payload) => { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - payload.issues.push({ - code: "custom", - message: "Invalid date", - input: value, - }); - return z.NEVER; - } - return date; - }, +const isoDateCodec = codec(v.date(), { + decode: (value) => new Date(value), encode: (date) => date.toISOString(), }); export const tournamentSearchSearchParams = SearchParams.define({ - q: SP.param(z.string().max(100), { default: "", loader: true }), - limit: SP.param(z.number().int().min(1).max(25), { - default: 25, + q: SP.param(v.pipe(v.string(), v.maxLength(100)), { + default: "", loader: true, }), - minStartTime: SP.custom(isoDateCodec.nullable(), { loader: true }), - maxStartTime: SP.custom(isoDateCodec.nullable(), { loader: true }), + limit: SP.param( + v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(25)), + { + default: 25, + loader: true, + }, + ), + minStartTime: SP.custom(nullableCodec(isoDateCodec), { loader: true }), + maxStartTime: SP.custom(nullableCodec(isoDateCodec), { loader: true }), }); export const tournamentJoinSearchParams = SearchParams.define({ - code: SP.param(z.string().nullable(), { loader: true }), + code: SP.param(v.nullable(v.string()), { loader: true }), }); export const tournamentTeamsSearchParams = SearchParams.define({ diff --git a/app/features/trophies/loaders/trophies.$id.server.ts b/app/features/trophies/loaders/trophies.$id.server.ts index 59dd2e2c5..d100635b6 100644 --- a/app/features/trophies/loaders/trophies.$id.server.ts +++ b/app/features/trophies/loaders/trophies.$id.server.ts @@ -1,7 +1,7 @@ import type { LoaderFunctionArgs } from "react-router"; import { getUser } from "~/features/auth/core/user.server"; import { notFoundIfNullish, parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; +import { idObject } from "~/utils/schema"; import * as TrophyRepository from "../TrophyRepository.server"; import { canAccessTrophies } from "../trophies-utils"; diff --git a/app/features/trophies/routes/trophies.$id.tournaments.ts b/app/features/trophies/routes/trophies.$id.tournaments.ts index f668fd515..0360c64d0 100644 --- a/app/features/trophies/routes/trophies.$id.tournaments.ts +++ b/app/features/trophies/routes/trophies.$id.tournaments.ts @@ -2,7 +2,7 @@ import type { LoaderFunctionArgs } from "react-router"; import { getUser } from "~/features/auth/core/user.server"; import type { SerializeFrom } from "~/utils/remix"; import { parseParams } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; +import { idObject } from "~/utils/schema"; import * as TrophyRepository from "../TrophyRepository.server"; import { canAccessTrophies } from "../trophies-utils"; diff --git a/app/features/trophies/routes/trophies.$id.wins.$userId.ts b/app/features/trophies/routes/trophies.$id.wins.$userId.ts index c7e7ed498..f843b645a 100644 --- a/app/features/trophies/routes/trophies.$id.wins.$userId.ts +++ b/app/features/trophies/routes/trophies.$id.wins.$userId.ts @@ -1,17 +1,17 @@ import type { LoaderFunctionArgs } from "react-router"; import * as R from "remeda"; -import { z } from "zod"; +import * as v from "valibot"; import { getUser } from "~/features/auth/core/user.server"; import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import type { SerializeFrom } from "~/utils/remix"; import { parseParams } from "~/utils/remix.server"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import * as TrophyRepository from "../TrophyRepository.server"; import { canAccessTrophies } from "../trophies-utils"; export type TrophyWinsLoaderData = SerializeFrom; -const paramsSchema = z.object({ id, userId: id }); +const paramsSchema = v.object({ id, userId: id }); export const loader = async ({ params }: LoaderFunctionArgs) => { if (!canAccessTrophies(getUser())) { diff --git a/app/features/trophies/trophies-schemas.ts b/app/features/trophies/trophies-schemas.ts index c1f10d765..a0a296ebd 100644 --- a/app/features/trophies/trophies-schemas.ts +++ b/app/features/trophies/trophies-schemas.ts @@ -1,11 +1,11 @@ -import { z } from "zod"; +import * as v from "valibot"; import { customField, stringConstant, textAreaOptional, textField, } from "~/form/fields"; -import { _action, id } from "~/utils/zod"; +import { _action, id, superRefine } from "~/utils/schema"; import { analyzeTrophyModel } from "./core/model-analysis"; import { TROPHY_DECLINE_REASON_MAX_LENGTH, @@ -19,36 +19,35 @@ import { const trophyModelField = () => customField( { initialValue: "" }, - z - .string() - .trim() - .min(1) - .max(TROPHY_MODEL_MAX_LENGTH) - .superRefine((model, ctx) => { + v.pipe( + v.string(), + v.trim(), + v.minLength(1), + v.maxLength(TROPHY_MODEL_MAX_LENGTH), + superRefine((model, ctx) => { const analysis = analyzeTrophyModel(model); if (!analysis) { - ctx.addIssue({ code: "custom", message: "Invalid model state" }); + ctx.addIssue({ message: "Invalid model state" }); return; } if (!analysis.cameraTargetCentered) { ctx.addIssue({ - code: "custom", message: "Camera target X and Z must be 0", }); } if (!analysis.backgroundIsAlpha) { ctx.addIssue({ - code: "custom", message: "Background color must be the alpha color", }); } }), + ), ); -export const createTrophyFormSchema = z.object({ +export const createTrophyFormSchema = v.object({ _action: stringConstant("CREATE"), name: textField({ label: "labels.trophyName", @@ -63,7 +62,7 @@ export const createTrophyFormSchema = z.object({ }), }); -export const updateTrophyFormSchema = z.object({ +export const updateTrophyFormSchema = v.object({ _action: stringConstant("UPDATE"), targetTrophyId: customField({ initialValue: null }, id), name: textField({ @@ -80,26 +79,27 @@ export const updateTrophyFormSchema = z.object({ }), }); -export const trophyFormSchema = z.discriminatedUnion("_action", [ +export const trophyFormSchema = v.variant("_action", [ createTrophyFormSchema, updateTrophyFormSchema, ]); -export const pendingTrophyActionSchema = z.union([ - z.object({ +export const pendingTrophyActionSchema = v.union([ + v.object({ _action: _action("DELETE"), pendingTrophyId: id, }), - z.object({ + v.object({ _action: _action("DECLINE"), pendingTrophyId: id, - reason: z - .string() - .trim() - .min(TROPHY_DECLINE_REASON_MIN_LENGTH) - .max(TROPHY_DECLINE_REASON_MAX_LENGTH), + reason: v.pipe( + v.string(), + v.trim(), + v.minLength(TROPHY_DECLINE_REASON_MIN_LENGTH), + v.maxLength(TROPHY_DECLINE_REASON_MAX_LENGTH), + ), }), - z.object({ + v.object({ _action: _action("APPROVE"), pendingTrophyId: id, }), diff --git a/app/features/user-card/user-card-schemas.ts b/app/features/user-card/user-card-schemas.ts index cedf82c1f..bc7c77044 100644 --- a/app/features/user-card/user-card-schemas.ts +++ b/app/features/user-card/user-card-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { SENDOUQ } from "~/features/sendouq/q-constants"; import { customField, @@ -12,12 +12,12 @@ import { textAreaOptional, toggle, } from "~/form/fields"; +import { _action, id } from "~/utils/schema"; import { preferenceEmojiUrl } from "~/utils/urls"; -import { _action, id } from "~/utils/zod"; import { PRESET_COLORS } from "../tier-list-maker/tier-list-maker-constants"; import { USER_CARD } from "./user-card-constants"; -export const updateUserCardSchema = z.object({ +export const updateUserCardSchema = v.object({ shortBio: textAreaOptional({ label: "labels.shortBio", maxLength: USER_CARD.SHORT_BIO_MAX_LENGTH, @@ -32,7 +32,7 @@ export const updateUserCardSchema = z.object({ }), bannerColor: customField( { initialValue: PRESET_COLORS[0] }, - z.string().regex(/^#[0-9a-f]{6}$/i), + v.pipe(v.string(), v.regex(/^#[0-9a-f]{6}$/i)), ), bannerStageId: stageSelect({ label: "labels.bannerStage" }), bannerImage: image({ @@ -56,7 +56,7 @@ export const updateUserCardSchema = z.object({ hideDiv: toggle({ label: "labels.hideDiv" }), }); -export const userCardNoteSaveSchema = z.object({ +export const userCardNoteSaveSchema = v.object({ _action: stringConstant("SAVE"), comment: textAreaOptional({ label: "labels.comment", @@ -85,13 +85,13 @@ export const userCardNoteSaveSchema = z.object({ }), }); -export const userCardNoteSchema = z.union([ +export const userCardNoteSchema = v.union([ userCardNoteSaveSchema, - z.object({ + v.object({ _action: _action("DELETE"), }), ]); -export const userCardNoteParamsSchema = z.object({ +export const userCardNoteParamsSchema = v.object({ id, }); diff --git a/app/features/user-card/user-card-search-params.ts b/app/features/user-card/user-card-search-params.ts index 740a01a3d..45884bf71 100644 --- a/app/features/user-card/user-card-search-params.ts +++ b/app/features/user-card/user-card-search-params.ts @@ -1,17 +1,19 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; export const userCardEditSearchParams = SearchParams.define({ returnTo: SP.param( - z - .string() - .refine((value) => value.startsWith("/") && !value.startsWith("//")) - .nullable(), + v.nullable( + v.pipe( + v.string(), + v.check((value) => value.startsWith("/") && !value.startsWith("//")), + ), + ), { loader: true }, ), }); export const userCardFriendshipSearchParams = SearchParams.define({ - mutuals: SP.param(z.boolean(), { default: false, loader: true }), + mutuals: SP.param(v.boolean(), { default: false, loader: true }), }); diff --git a/app/features/user-page/core/widgets/portfolio.ts b/app/features/user-page/core/widgets/portfolio.ts index c42c825b2..aa8b2915d 100644 --- a/app/features/user-page/core/widgets/portfolio.ts +++ b/app/features/user-page/core/widgets/portfolio.ts @@ -1,5 +1,6 @@ -import type { z } from "zod"; +import type * as v from "valibot"; import { TIMEZONES } from "~/features/lfg/lfg-constants"; +import type { FormObjectSchema } from "~/form/types"; import type { StoredWidget } from "./types"; import { artSchema, @@ -160,12 +161,12 @@ export function findWidgetById(widgetId: string) { function defineWidget< const Id extends string, const Slot extends "main" | "side", - S extends z.ZodObject, + S extends FormObjectSchema, >(def: { id: Id; slot: Slot; schema: S; - defaultSettings: z.infer; + defaultSettings: v.InferOutput; }): typeof def; function defineWidget< diff --git a/app/features/user-page/core/widgets/types.ts b/app/features/user-page/core/widgets/types.ts index d940576f4..a74bd02ba 100644 --- a/app/features/user-page/core/widgets/types.ts +++ b/app/features/user-page/core/widgets/types.ts @@ -1,4 +1,5 @@ -import type { z } from "zod"; +import type * as v from "valibot"; +import type { AnySchema } from "~/utils/schema"; import type { allWidgetsFlat } from "./portfolio"; import type { WIDGET_LOADERS } from "./portfolio-loaders.server"; @@ -7,7 +8,7 @@ type WidgetUnion = ReturnType[number]; export type WidgetId = WidgetUnion["id"]; type ExtractSchema = W extends { schema: infer S } - ? S extends z.ZodTypeAny + ? S extends AnySchema ? S : never : never; @@ -17,7 +18,7 @@ export type StoredWidget = { id: K["id"]; } & (ExtractSchema extends never ? { settings?: never } - : { settings: z.infer> }); + : { settings: v.InferOutput> }); }[WidgetUnion["id"]]; type InferLoaderReturn = T extends (...args: any[]) => Promise diff --git a/app/features/user-page/core/widgets/widget-form-schemas.ts b/app/features/user-page/core/widgets/widget-form-schemas.ts index cfab696fe..45e2435b3 100644 --- a/app/features/user-page/core/widgets/widget-form-schemas.ts +++ b/app/features/user-page/core/widgets/widget-form-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { ART_SOURCES } from "~/features/art/art-types"; import { TIMEZONES } from "~/features/lfg/lfg-constants"; import { @@ -12,18 +12,18 @@ import { textField, weaponSelect, } from "~/form/fields"; -import type { SelectOption } from "~/form/types"; +import type { FormObjectSchema, SelectOption } from "~/form/types"; import { GAME_BADGE_IDS } from "~/modules/in-game-lists/game-badge-ids"; import { USER } from "../../user-page-constants"; -export const bioSchema = z.object({ +export const bioSchema = v.object({ bio: textArea({ label: "labels.bio", maxLength: USER.BIO_MAX_LENGTH, }), }); -export const bioMdSchema = z.object({ +export const bioMdSchema = v.object({ bio: textArea({ label: "labels.bio", bottomText: "bottomTexts.bioMarkdown", @@ -31,7 +31,7 @@ export const bioMdSchema = z.object({ }), }); -export const xRankPeaksSchema = z.object({ +export const xRankPeaksSchema = v.object({ division: select({ label: "labels.division", items: [ @@ -42,7 +42,7 @@ export const xRankPeaksSchema = z.object({ }), }); -export const timezoneSchema = z.object({ +export const timezoneSchema = v.object({ timezone: selectDynamic({ label: "labels.timezone", }), @@ -53,13 +53,13 @@ export const TIMEZONE_OPTIONS: SelectOption[] = TIMEZONES.map((tz) => ({ label: tz, })); -export const favoriteStageSchema = z.object({ +export const favoriteStageSchema = v.object({ stageId: stageSelect({ label: "labels.favoriteStage", }), }); -export const peakXpUnverifiedSchema = z.object({ +export const peakXpUnverifiedSchema = v.object({ peakXp: numberField({ label: "labels.peakXp", minLength: 4, @@ -74,7 +74,7 @@ export const peakXpUnverifiedSchema = z.object({ }), }); -export const peakXpWeaponSchema = z.object({ +export const peakXpWeaponSchema = v.object({ weaponSplId: weaponSelect({ label: "labels.weapon", }), @@ -82,7 +82,7 @@ export const peakXpWeaponSchema = z.object({ const CONTROLLERS = ["s1-pro-con", "s2-pro-con", "grip", "handheld"] as const; -export const sensSchema = z.object({ +export const sensSchema = v.object({ controller: select({ label: "labels.controller", items: CONTROLLERS.map((controller) => ({ @@ -91,11 +91,11 @@ export const sensSchema = z.object({ })), initialValue: "s2-pro-con", }), - motionSens: customField({ initialValue: null }, z.number().nullable()), - stickSens: customField({ initialValue: null }, z.number().nullable()), + motionSens: customField({ initialValue: null }, v.nullable(v.number())), + stickSens: customField({ initialValue: null }, v.nullable(v.number())), }); -export const artSchema = z.object({ +export const artSchema = v.object({ source: select({ label: "labels.artSource", items: ART_SOURCES.map((source) => ({ @@ -105,7 +105,7 @@ export const artSchema = z.object({ }), }); -export const linksSchema = z.object({ +export const linksSchema = v.object({ links: array({ label: "labels.urls", min: 1, @@ -117,7 +117,7 @@ export const linksSchema = z.object({ }), }); -export const tierListSchema = z.object({ +export const tierListSchema = v.object({ searchParams: textField({ label: "labels.tierListUrl", leftAddon: "/tier-list-maker?", @@ -126,25 +126,26 @@ export const tierListSchema = z.object({ }), }); -const gameBadgeId = z - .string() - .refine((val) => (GAME_BADGE_IDS as readonly string[]).includes(val)); +const gameBadgeId = v.pipe( + v.string(), + v.check((val) => (GAME_BADGE_IDS as readonly string[]).includes(val)), +); -export const gameBadgesSchema = z.object({ +export const gameBadgesSchema = v.object({ badgeIds: customField( { initialValue: [] }, - z.array(gameBadgeId).max(USER.GAME_BADGES_MAX), + v.pipe(v.array(gameBadgeId), v.maxLength(USER.GAME_BADGES_MAX)), ), }); -export const gameBadgesSmallSchema = z.object({ +export const gameBadgesSmallSchema = v.object({ badgeIds: customField( { initialValue: [] }, - z.array(gameBadgeId).max(USER.GAME_BADGES_SMALL_MAX), + v.pipe(v.array(gameBadgeId), v.maxLength(USER.GAME_BADGES_SMALL_MAX)), ), }); -const WIDGET_FORM_SCHEMAS: Record> = { +const WIDGET_FORM_SCHEMAS: Record = { bio: bioSchema, "bio-md": bioMdSchema, "x-rank-peaks": xRankPeaksSchema, diff --git a/app/features/user-page/loaders/u.$identifier.art.server.ts b/app/features/user-page/loaders/u.$identifier.art.server.ts index 2f0fe4f3b..7f635f26e 100644 --- a/app/features/user-page/loaders/u.$identifier.art.server.ts +++ b/app/features/user-page/loaders/u.$identifier.art.server.ts @@ -1,4 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as v from "valibot"; import * as ArtRepository from "~/features/art/ArtRepository.server"; import { getUser } from "~/features/auth/core/user.server"; import * as ImageRepository from "~/features/img-upload/ImageRepository.server"; @@ -9,7 +10,7 @@ import { userParamsSchema } from "../user-page-schemas"; export const loader = async ({ params }: LoaderFunctionArgs) => { const loggedInUser = getUser(); - const { identifier } = userParamsSchema.parse(params); + const { identifier } = v.parse(userParamsSchema, params); const user = notFoundIfNullish( await UserRepository.findIdByIdentifier(identifier), ); diff --git a/app/features/user-page/loaders/u.$identifier.builds.new.server.ts b/app/features/user-page/loaders/u.$identifier.builds.new.server.ts index 4958fe40a..ea4aa9096 100644 --- a/app/features/user-page/loaders/u.$identifier.builds.new.server.ts +++ b/app/features/user-page/loaders/u.$identifier.builds.new.server.ts @@ -1,5 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; -import type { z } from "zod"; +import type * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField"; @@ -37,7 +37,7 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { } }; -type NewBuildDefaultValues = Partial>; +type NewBuildDefaultValues = Partial>; function resolveDefaultValues( params: SearchParamsValues, diff --git a/app/features/user-page/loaders/u.$identifier.builds.server.ts b/app/features/user-page/loaders/u.$identifier.builds.server.ts index 894015727..cb4cc22c0 100644 --- a/app/features/user-page/loaders/u.$identifier.builds.server.ts +++ b/app/features/user-page/loaders/u.$identifier.builds.server.ts @@ -1,5 +1,6 @@ import type { LoaderFunctionArgs } from "react-router"; import * as R from "remeda"; +import * as v from "valibot"; import { getUser } from "~/features/auth/core/user.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -12,7 +13,7 @@ export type UserBuildsPageData = SerializeFrom; export const loader = async ({ params }: LoaderFunctionArgs) => { const loggedInUser = getUser(); - const { identifier } = userParamsSchema.parse(params); + const { identifier } = v.parse(userParamsSchema, params); const user = notFoundIfNullish( await UserRepository.findBuildFieldsByIdentifier(identifier), ); diff --git a/app/features/user-page/loaders/u.$identifier.edit.server.ts b/app/features/user-page/loaders/u.$identifier.edit.server.ts index bc61ff13a..234a41e38 100644 --- a/app/features/user-page/loaders/u.$identifier.edit.server.ts +++ b/app/features/user-page/loaders/u.$identifier.edit.server.ts @@ -1,4 +1,5 @@ import { type LoaderFunctionArgs, redirect } from "react-router"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import * as TrophyRepository from "~/features/trophies/TrophyRepository.server"; import { canAccessTrophies } from "~/features/trophies/trophies-utils"; @@ -9,7 +10,7 @@ import { userParamsSchema } from "../user-page-schemas"; export const loader = async ({ params }: LoaderFunctionArgs) => { const user = requireUser(); - const { identifier } = userParamsSchema.parse(params); + const { identifier } = v.parse(userParamsSchema, params); const userToBeEdited = notFoundIfNullish( await UserRepository.findLayoutDataByIdentifier(identifier), ); diff --git a/app/features/user-page/loaders/u.$identifier.seasons.index.server.ts b/app/features/user-page/loaders/u.$identifier.seasons.index.server.ts index b12dd37f2..ebea2b43b 100644 --- a/app/features/user-page/loaders/u.$identifier.seasons.index.server.ts +++ b/app/features/user-page/loaders/u.$identifier.seasons.index.server.ts @@ -1,4 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; @@ -14,7 +15,7 @@ export type UserSeasonsSetsLoaderData = NonNullable< export const loader = async ({ params, url }: LoaderFunctionArgs) => { requireUser(); - const { identifier } = userParamsSchema.parse(params); + const { identifier } = v.parse(userParamsSchema, params); const { page, season: seasonParam } = userSeasonsSearchParams.parse(url); const user = notFoundIfNullish( diff --git a/app/features/user-page/loaders/u.$identifier.seasons.server.ts b/app/features/user-page/loaders/u.$identifier.seasons.server.ts index cbabb3003..2ebe0b886 100644 --- a/app/features/user-page/loaders/u.$identifier.seasons.server.ts +++ b/app/features/user-page/loaders/u.$identifier.seasons.server.ts @@ -1,4 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; import * as SkillRepository from "~/features/mmr/SkillRepository.server"; @@ -17,7 +18,7 @@ export type UserSeasonsPageLoaderData = NonNullable< export const loader = async ({ params, url }: LoaderFunctionArgs) => { const loggedInUser = requireUser(); - const { identifier } = userParamsSchema.parse(params); + const { identifier } = v.parse(userParamsSchema, params); const { season: seasonParam } = userSeasonsSearchParams.parse(url); const user = notFoundIfNullish( diff --git a/app/features/user-page/loaders/u.$identifier.seasons.stats.server.ts b/app/features/user-page/loaders/u.$identifier.seasons.stats.server.ts index 651cc08f6..29d6aa7d0 100644 --- a/app/features/user-page/loaders/u.$identifier.seasons.stats.server.ts +++ b/app/features/user-page/loaders/u.$identifier.seasons.stats.server.ts @@ -1,4 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; import * as PlayerStatRepository from "~/features/sendouq-match/PlayerStatRepository.server"; @@ -15,7 +16,7 @@ export type UserSeasonsStatsLoaderData = NonNullable< export const loader = async ({ params, url }: LoaderFunctionArgs) => { requireUser(); - const { identifier } = userParamsSchema.parse(params); + const { identifier } = v.parse(userParamsSchema, params); const { info, season: seasonParam } = userSeasonsSearchParams.parse(url); const user = notFoundIfNullish( diff --git a/app/features/user-page/loaders/u.$identifier.seasons.summary-graphic.server.ts b/app/features/user-page/loaders/u.$identifier.seasons.summary-graphic.server.ts index 8827b6bdd..2b781bbc3 100644 --- a/app/features/user-page/loaders/u.$identifier.seasons.summary-graphic.server.ts +++ b/app/features/user-page/loaders/u.$identifier.seasons.summary-graphic.server.ts @@ -1,4 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import * as SeasonSummary from "~/features/img-export/core/SeasonSummary"; import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; @@ -22,7 +23,7 @@ export type UserSeasonSummaryGraphicLoaderData = SerializeFrom; export const loader = async ({ params, url }: LoaderFunctionArgs) => { const loggedInUser = requireUser(); - const { identifier } = userParamsSchema.parse(params); + const { identifier } = v.parse(userParamsSchema, params); const { season } = userSeasonSummaryGraphicSearchParams.parse(url); if (typeof season !== "number") { throw new Response(null, { status: 400 }); diff --git a/app/features/user-page/routes/u.$identifier.edit-widgets.tsx b/app/features/user-page/routes/u.$identifier.edit-widgets.tsx index 0960a5503..5c39729b1 100644 --- a/app/features/user-page/routes/u.$identifier.edit-widgets.tsx +++ b/app/features/user-page/routes/u.$identifier.edit-widgets.tsx @@ -19,6 +19,7 @@ import { useState } from "react"; import { flushSync } from "react-dom"; import { useTranslation } from "react-i18next"; import { useFetcher, useLoaderData } from "react-router"; +import * as v from "valibot"; import { SendouButton } from "~/components/elements/Button"; import { Input } from "~/components/Input"; import { MainSlotIcon } from "~/components/icons/MainSlot"; @@ -490,7 +491,7 @@ function computeInvalidWidgetIds( for (const widget of widgets) { const schema = getWidgetFormSchema(widget.id); if (!schema) continue; - if (!schema.safeParse(widget.settings ?? {}).success) { + if (!v.safeParse(schema, widget.settings ?? {}).success) { invalid.add(widget.id); } } diff --git a/app/features/user-page/user-page-schemas.server.ts b/app/features/user-page/user-page-schemas.server.ts index 1757d5a6a..4fbc5ec87 100644 --- a/app/features/user-page/user-page-schemas.server.ts +++ b/app/features/user-page/user-page-schemas.server.ts @@ -1,17 +1,24 @@ +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; +import { superRefine, superRefineAsync } from "~/utils/schema"; import { gearAllOrNoneRefine, newBuildBaseSchema } from "./user-page-schemas"; -export const newBuildSchemaServer = newBuildBaseSchema - .refine(gearAllOrNoneRefine.fn, gearAllOrNoneRefine.opts) - .refine( - async (data) => { - if (!data.buildToEditId) return true; +export const newBuildSchemaServer = v.pipeAsync( + newBuildBaseSchema, + superRefine((data, ctx) => { + if (gearAllOrNoneRefine.fn(data)) return; - const user = requireUser(); - const ownerId = await BuildRepository.findOwnerIdById(data.buildToEditId); + ctx.addIssue(gearAllOrNoneRefine.opts); + }), + superRefineAsync(async (data, ctx) => { + if (!data.buildToEditId) return; - return ownerId === user.id; - }, - { message: "Not a build you own", path: ["buildToEditId"] }, - ); + const user = requireUser(); + const ownerId = await BuildRepository.findOwnerIdById(data.buildToEditId); + + if (ownerId === user.id) return; + + ctx.addIssue({ message: "Not a build you own", path: ["buildToEditId"] }); + }), +); diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts index 75ec3539f..e95e33156 100644 --- a/app/features/user-page/user-page-schemas.ts +++ b/app/features/user-page/user-page-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { BADGE } from "~/features/badges/badges-constants"; import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "~/features/trophies/trophies-constants"; import { @@ -28,8 +28,6 @@ import { headGearIds, shoesGearIds, } from "~/modules/in-game-lists/gear-ids"; -import { rawSensToString } from "~/utils/strings"; -import { isCustomUrl } from "~/utils/urls"; import { _action, actualNumber, @@ -37,12 +35,16 @@ import { emptyArrayToNull, headMainSlotAbility, id, + preprocess, processMany, removeDuplicates, safeJSONParse, shoesMainSlotAbility, stackableAbility, -} from "~/utils/zod"; + superRefine, +} from "~/utils/schema"; +import { rawSensToString } from "~/utils/strings"; +import { isCustomUrl } from "~/utils/urls"; import { allWidgetsFlat, findWidgetById } from "./core/widgets/portfolio"; import { BUILD_SORT_IDENTIFIERS, @@ -51,7 +53,7 @@ import { USER, } from "./user-page-constants"; -export const userParamsSchema = z.object({ identifier: z.string() }); +export const userParamsSchema = v.object({ identifier: v.string() }); const SENS_ITEMS = [ -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35, @@ -61,7 +63,7 @@ const SENS_ITEMS = [ value: String(val), })); -export const userEditProfileBaseSchema = z.object({ +export const userEditProfileBaseSchema = v.object({ customAvatar: image({ label: "labels.profileCustomAvatar", bottomText: "bottomTexts.profileCustomAvatar", @@ -173,16 +175,16 @@ export const userEditProfileBaseSchema = z.object({ }), }); -export const editHighlightsActionSchema = z.object({ - [HIGHLIGHT_CHECKBOX_NAME]: z.optional( - z.union([z.array(z.string()), z.string()]), +export const editHighlightsActionSchema = v.object({ + [HIGHLIGHT_CHECKBOX_NAME]: v.optional( + v.union([v.array(v.string()), v.string()]), ), - [HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME]: z.optional( - z.union([z.array(z.string()), z.string()]), + [HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME]: v.optional( + v.union([v.array(v.string()), v.string()]), ), }); -export const addModNoteSchema = z.object({ +export const addModNoteSchema = v.object({ _action: stringConstant("ADD_MOD_NOTE"), value: textArea({ label: "labels.text", @@ -191,37 +193,37 @@ export const addModNoteSchema = z.object({ }), }); -const deleteModNoteSchema = z.object({ +const deleteModNoteSchema = v.object({ _action: _action("DELETE_MOD_NOTE"), noteId: id, }); -export const adminTabActionSchema = z.union([ +export const adminTabActionSchema = v.union([ addModNoteSchema, deleteModNoteSchema, ]); const widgetSettingsSchemas = allWidgetsFlat().map((widget) => { - if ("schema" in widget) { - return z.object({ - id: z.literal(widget.id), + if ("schema" in widget && widget.schema) { + return v.object({ + id: v.literal(widget.id), settings: widget.schema, }); } - return z.object({ - id: z.literal(widget.id), + return v.object({ + id: v.literal(widget.id), }); }); -const widgetSettingsSchema = z.union(widgetSettingsSchemas); +const widgetSettingsSchema = v.union(widgetSettingsSchemas); -export const widgetsEditSchema = z.object({ - widgets: z.preprocess( +export const widgetsEditSchema = v.object({ + widgets: preprocess( safeJSONParse, - z - .array(widgetSettingsSchema) - .max(USER.MAX_MAIN_WIDGETS + USER.MAX_SIDE_WIDGETS) - .refine((widgets) => { + v.pipe( + v.array(widgetSettingsSchema), + v.maxLength(USER.MAX_MAIN_WIDGETS + USER.MAX_SIDE_WIDGETS), + v.check((widgets) => { let mainCount = 0; let sideCount = 0; for (const w of widgets) { @@ -235,49 +237,50 @@ export const widgetsEditSchema = z.object({ sideCount <= USER.MAX_SIDE_WIDGETS ); }), + ), ), }); -const headGearIdSchema = z - .number() - .nullable() - .refine( +const headGearIdSchema = v.pipe( + v.nullable(v.number()), + v.check( (val) => val === null || headGearIds.includes(val as (typeof headGearIds)[number]), - ); + ), +); -const clothesGearIdSchema = z - .number() - .nullable() - .refine( +const clothesGearIdSchema = v.pipe( + v.nullable(v.number()), + v.check( (val) => val === null || clothesGearIds.includes(val as (typeof clothesGearIds)[number]), - ); + ), +); -const shoesGearIdSchema = z - .number() - .nullable() - .refine( +const shoesGearIdSchema = v.pipe( + v.nullable(v.number()), + v.check( (val) => val === null || shoesGearIds.includes(val as (typeof shoesGearIds)[number]), - ); + ), +); -const abilitiesSchema = z.tuple([ - z.tuple([ +const abilitiesSchema = v.tuple([ + v.tuple([ headMainSlotAbility, stackableAbility, stackableAbility, stackableAbility, ]), - z.tuple([ + v.tuple([ clothesMainSlotAbility, stackableAbility, stackableAbility, stackableAbility, ]), - z.tuple([ + v.tuple([ shoesMainSlotAbility, stackableAbility, stackableAbility, @@ -293,7 +296,7 @@ const modeItems = [ { label: "modes.CB" as const, value: "CB" as const }, ]; -export const newBuildBaseSchema = z.object({ +export const newBuildBaseSchema = v.object({ buildToEditId: idConstantOptional(), weapons: weaponPool({ label: "labels.buildWeapons", @@ -349,22 +352,26 @@ export const gearAllOrNoneRefine = { opts: { message: "forms:errors.gearAllOrNone", path: ["head"] }, }; -export const newBuildSchema = newBuildBaseSchema.refine( - gearAllOrNoneRefine.fn, - gearAllOrNoneRefine.opts, +export const newBuildSchema = v.pipe( + newBuildBaseSchema, + superRefine((data, ctx) => { + if (gearAllOrNoneRefine.fn(data)) return; + + ctx.addIssue(gearAllOrNoneRefine.opts); + }), ); -export const buildsActionSchema = z.union([ - z.object({ +export const buildsActionSchema = v.union([ + v.object({ _action: _action("DELETE_BUILD"), - buildToDeleteId: z.preprocess(actualNumber, id), + buildToDeleteId: preprocess(actualNumber, id), }), - z.object({ + v.object({ _action: _action("UPDATE_SORTING"), - buildSorting: z.preprocess( + buildSorting: preprocess( processMany(safeJSONParse, removeDuplicates, emptyArrayToNull), - z.array(z.enum(BUILD_SORT_IDENTIFIERS)).nullable(), + v.nullable(v.array(v.picklist(BUILD_SORT_IDENTIFIERS))), ), }), ]); diff --git a/app/features/user-page/user-page-search-params.ts b/app/features/user-page/user-page-search-params.ts index 431390763..409e1026d 100644 --- a/app/features/user-page/user-page-search-params.ts +++ b/app/features/user-page/user-page-search-params.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { ART_SOURCES } from "~/features/art/art-types"; import { serializedBuildCodec } from "~/features/build-analyzer/analyzer-search-params"; import { EMPTY_BUILD } from "~/features/builds/builds-constants"; @@ -11,8 +11,8 @@ import { import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import * as SearchParams from "~/modules/search-params/search-params"; -import { SP } from "~/modules/search-params/search-params"; -import { numericEnum } from "~/utils/zod"; +import { codec, SP } from "~/modules/search-params/search-params"; +import { numericEnum } from "~/utils/schema"; import { RESULT_PLACEMENT_FILTERS, RESULT_SOURCES, @@ -21,17 +21,20 @@ import { const BUILD_FILTER_TABS = ["ALL", "PUBLIC", "PRIVATE"] as const; -const resultYear = z - .number() - .int() - .min(RESULTS_FIRST_YEAR) - .refine((year) => year <= new Date().getFullYear()); +const resultYear = v.pipe( + v.number(), + v.integer(), + v.minValue(RESULTS_FIRST_YEAR), + v.check((year) => year <= new Date().getFullYear()), +); -const resultsFilterName = z.string().trim().min(1).max(100).nullable(); +const resultsFilterName = v.nullable( + v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(100)), +); export const userResultsSearchParams = SearchParams.define({ /** Only applies to users who have highlighted results. */ - highlightsOnly: SP.param(z.boolean(), { + highlightsOnly: SP.param(v.boolean(), { default: true, loader: true, resets: ["page"], @@ -45,7 +48,7 @@ export const userResultsSearchParams = SearchParams.define({ loader: true, resets: ["page"], }), - mate: SP.param(z.number().int().positive().nullable(), { + mate: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), { loader: true, resets: ["page"], }), @@ -59,44 +62,48 @@ export const userResultsSearchParams = SearchParams.define({ loader: true, resets: ["page"], }), - maxPlacement: SP.param(numericEnum(RESULT_PLACEMENT_FILTERS).nullable(), { + maxPlacement: SP.param(v.nullable(numericEnum(RESULT_PLACEMENT_FILTERS)), { loader: true, resets: ["page"], }), - fromYear: SP.param(resultYear.nullable(), { + fromYear: SP.param(v.nullable(resultYear), { loader: true, resets: ["page"], timeDependent: true, }), - toYear: SP.param(resultYear.nullable(), { + toYear: SP.param(v.nullable(resultYear), { loader: true, resets: ["page"], timeDependent: true, }), - source: SP.param(z.enum(RESULT_SOURCES), { + source: SP.param(v.picklist(RESULT_SOURCES), { default: "ALL", loader: true, resets: ["page"], }), - minParticipantCount: SP.param(z.number().int().nonnegative().max(9999), { - default: 0, - loader: true, - resets: ["page"], - }), + minParticipantCount: SP.param( + v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(9999)), + { + default: 0, + loader: true, + resets: ["page"], + }, + ), }); -const startedSeason = z - .number() - .int() - .refine((nth) => Seasons.allStarted(new Date()).includes(nth)); +const startedSeason = v.pipe( + v.number(), + v.integer(), + v.check((nth) => Seasons.allStarted(new Date()).includes(nth)), +); export const userSeasonsSearchParams = SearchParams.define({ page: SP.page(), - info: SP.param(z.enum(["weapons", "stages", "mates", "enemies"]), { + info: SP.param(v.picklist(["weapons", "stages", "mates", "enemies"]), { default: "weapons", loader: true, }), - season: SP.param(startedSeason.nullable(), { + season: SP.param(v.nullable(startedSeason), { loader: true, resets: ["page"], timeDependent: true, @@ -104,17 +111,16 @@ export const userSeasonsSearchParams = SearchParams.define({ }); export const userSeasonSummaryGraphicSearchParams = SearchParams.define({ - season: SP.param(startedSeason.nullable(), { + season: SP.param(v.nullable(startedSeason), { loader: true, timeDependent: true, }), }); -const buildsWeaponFilterCodec = z.codec( - z.string(), - z.union([z.enum(BUILD_FILTER_TABS), numericEnum(mainWeaponIds)]), +const buildsWeaponFilterCodec = codec( + v.union([v.picklist(BUILD_FILTER_TABS), numericEnum(mainWeaponIds)]), { - decode: (value, payload) => { + decode: (value) => { if ((BUILD_FILTER_TABS as readonly string[]).includes(value)) { return value as (typeof BUILD_FILTER_TABS)[number]; } @@ -122,12 +128,7 @@ const buildsWeaponFilterCodec = z.codec( if ((mainWeaponIds as readonly number[]).includes(weaponId)) { return weaponId as MainWeaponId; } - payload.issues.push({ - code: "custom", - message: "Invalid builds weapon filter", - input: value, - }); - return z.NEVER; + return undefined; }, encode: (value) => String(value), }, @@ -135,17 +136,19 @@ const buildsWeaponFilterCodec = z.codec( export const userBuildsSearchParams = SearchParams.define({ weapon: SP.custom(buildsWeaponFilterCodec, { default: "ALL", loader: false }), - sorting: SP.param(z.boolean(), { default: false, loader: false }), + sorting: SP.param(v.boolean(), { default: false, loader: false }), }); export const userArtSearchParams = SearchParams.define({ - source: SP.param(z.enum(ART_SOURCES), { default: "ALL", loader: false }), - tag: SP.param(z.string().nullable(), { loader: false }), + source: SP.param(v.picklist(ART_SOURCES), { default: "ALL", loader: false }), + tag: SP.param(v.nullable(v.string()), { loader: false }), }); export const userBuildsNewSearchParams = SearchParams.define({ - buildId: SP.param(z.number().int().positive().nullable(), { loader: true }), - weapon: SP.param(numericEnum(mainWeaponIds).nullable(), { loader: true }), + buildId: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), { + loader: true, + }), + weapon: SP.param(v.nullable(numericEnum(mainWeaponIds)), { loader: true }), build: SP.custom(serializedBuildCodec, { default: EMPTY_BUILD, loader: true, diff --git a/app/features/user-report/user-report-schemas.server.ts b/app/features/user-report/user-report-schemas.server.ts index 7912f7ef3..8273e05ba 100644 --- a/app/features/user-report/user-report-schemas.server.ts +++ b/app/features/user-report/user-report-schemas.server.ts @@ -1,22 +1,21 @@ -import { z } from "zod"; +import * as v from "valibot"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; import { reportUserSchema } from "./user-report-schemas"; -export const reportUserSchemaServer = z.object({ - ...reportUserSchema.shape, - // cast to the concrete value type: the field's `.nullable()` makes its inferred - // type a union that Zod's `.refine` overload can't resolve - matchId: (reportUserSchema.shape.matchId as z.ZodType) - .refine( - async (matchId) => { - if (!matchId) return true; +export const reportUserSchemaServer = v.objectAsync({ + ...reportUserSchema.entries, + // cast to the concrete value type: the field's nullability makes its inferred + // type a union the async pipe can't resolve + matchId: v.pipeAsync( + reportUserSchema.entries.matchId as v.GenericSchema, + v.checkAsync(async (matchId) => { + if (!matchId) return true; - const id = Number(matchId); - if (!Number.isInteger(id) || id <= 0) return false; + const id = Number(matchId); + if (!Number.isInteger(id) || id <= 0) return false; - return SQMatchRepository.exists(id); - }, - { message: "forms:errors.matchNotFound" }, - ) - .transform((matchId) => (matchId ? Number(matchId) : null)), + return SQMatchRepository.exists(id); + }, "forms:errors.matchNotFound"), + v.transform((matchId) => (matchId ? Number(matchId) : null)), + ), }); diff --git a/app/features/user-report/user-report-schemas.ts b/app/features/user-report/user-report-schemas.ts index 7fa88895a..72d6e1aa9 100644 --- a/app/features/user-report/user-report-schemas.ts +++ b/app/features/user-report/user-report-schemas.ts @@ -1,9 +1,9 @@ -import { z } from "zod"; +import * as v from "valibot"; import { select, textArea, textFieldOptional } from "~/form/fields"; -import { id } from "~/utils/zod"; +import { id } from "~/utils/schema"; import { USER_REPORT } from "./user-report-constants"; -export const reportUserSchema = z.object({ +export const reportUserSchema = v.object({ category: select({ label: "labels.reportCategory", items: [ @@ -28,6 +28,6 @@ export const reportUserSchema = z.object({ }), }); -export const reportUserParamsSchema = z.object({ +export const reportUserParamsSchema = v.object({ id, }); diff --git a/app/features/vods/actions/vods.new.server.ts b/app/features/vods/actions/vods.new.server.ts index ba8c8b053..5e8236ddd 100644 --- a/app/features/vods/actions/vods.new.server.ts +++ b/app/features/vods/actions/vods.new.server.ts @@ -1,5 +1,5 @@ import { type ActionFunction, redirect } from "react-router"; -import type { z } from "zod"; +import type * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField"; import { parseFormData } from "~/form/parse.server"; @@ -41,7 +41,7 @@ export const action: ActionFunction = async ({ request }) => { throw redirect(vodVideoPage(savedVideo.id)); }; -type VodFormData = z.output; +type VodFormData = v.InferOutput; function transformFormDataToVideo(data: VodFormData): VideoBeingAdded { const teamSize = data.teamSize ? Number(data.teamSize) : 4; diff --git a/app/features/vods/routes/vods.new.browser.test.tsx b/app/features/vods/routes/vods.new.browser.test.tsx index d20b16d32..7831a6b58 100644 --- a/app/features/vods/routes/vods.new.browser.test.tsx +++ b/app/features/vods/routes/vods.new.browser.test.tsx @@ -80,7 +80,7 @@ function renderForm(options?: { schema={vodFormBaseSchema} defaultValues={createDefaultValues(options?.defaultValues)} > - {Object.keys(vodFormBaseSchema.shape) + {Object.keys(vodFormBaseSchema.entries) .filter((name) => name !== "pov") .map((name) => ( diff --git a/app/features/vods/routes/vods.new.tsx b/app/features/vods/routes/vods.new.tsx index ff44c4651..2abf69173 100644 --- a/app/features/vods/routes/vods.new.tsx +++ b/app/features/vods/routes/vods.new.tsx @@ -235,7 +235,7 @@ function useFloatingEmbedWidth(): number | null { } type VodFormFieldComponent = FormRenderProps< - typeof vodFormBaseSchema.shape + typeof vodFormBaseSchema.entries >["FormField"]; function VodFormFields({ diff --git a/app/features/vods/vods-schemas.server.ts b/app/features/vods/vods-schemas.server.ts index 72b2e2163..9216f824e 100644 --- a/app/features/vods/vods-schemas.server.ts +++ b/app/features/vods/vods-schemas.server.ts @@ -1,17 +1,22 @@ +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import { hasPermission } from "~/modules/permissions/utils"; +import { superRefineAsync } from "~/utils/schema"; import * as VodRepository from "./VodRepository.server"; import { vodFormBaseSchema } from "./vods-schemas"; -export const vodFormSchemaServer = vodFormBaseSchema.refine( - async (data) => { - if (!data.vodToEditId) return true; +export const vodFormSchemaServer = v.pipeAsync( + vodFormBaseSchema, + superRefineAsync(async (data, ctx) => { + if (!data.vodToEditId) return; const user = requireUser(); const vod = await VodRepository.findVodById(data.vodToEditId); - if (!vod) return false; + if (vod && hasPermission(vod, "EDIT", user)) return; - return hasPermission(vod, "EDIT", user); - }, - { message: "No permissions to edit this VOD", path: ["vodToEditId"] }, + ctx.addIssue({ + message: "No permissions to edit this VOD", + path: ["vodToEditId"], + }); + }), ); diff --git a/app/features/vods/vods-schemas.ts b/app/features/vods/vods-schemas.ts index c9a1d6da6..c1be22d23 100644 --- a/app/features/vods/vods-schemas.ts +++ b/app/features/vods/vods-schemas.ts @@ -1,5 +1,5 @@ import { add } from "date-fns"; -import { z } from "zod"; +import * as v from "valibot"; import { array, customField, @@ -20,67 +20,70 @@ import { id, modeShort, nonEmptyString, + preprocess, stageId, weaponSplId, -} from "~/utils/zod"; +} from "~/utils/schema"; import { dayMonthYearToDate } from "../../utils/dates"; import { videoMatchTypes } from "./vods-constants"; import { extractYoutubeIdFromVideoUrl } from "./vods-utils"; export const HOURS_MINUTES_SECONDS_REGEX = /^(\d{1,2}:)?\d{1,2}:\d{2}$/; -const videoMatchSchema = z.object({ - startsAt: z.string().regex(HOURS_MINUTES_SECONDS_REGEX, { - message: "Invalid time format. Use HH:MM:SS or MM:SS", - }), +const videoMatchSchema = v.object({ + startsAt: v.pipe( + v.string(), + v.regex( + HOURS_MINUTES_SECONDS_REGEX, + "Invalid time format. Use HH:MM:SS or MM:SS", + ), + ), stageId: stageId, mode: modeShort, - weapons: z.array(weaponSplId), + weapons: v.array(weaponSplId), }); -export const videoSchema = z.preprocess( +export const videoSchema = preprocess( (val: any) => (val.type === "CAST" ? { ...val, pov: undefined } : val), - z - .object({ - type: z.enum(videoMatchTypes), - eventId: z.number().optional(), - youtubeUrl: z.string().refine( - (val) => { + v.pipe( + v.object({ + type: v.picklist(videoMatchTypes), + eventId: v.optional(v.number()), + youtubeUrl: v.pipe( + v.string(), + v.check((val) => { const id = extractYoutubeIdFromVideoUrl(val); return id !== null; - }, - { - message: "Invalid YouTube URL", - }, + }, "Invalid YouTube URL"), ), - title: nonEmptyString.max(100), - date: dayMonthYear.refine( - (data) => { + title: v.pipe(nonEmptyString, v.maxLength(100)), + date: v.pipe( + dayMonthYear, + v.check((data) => { const date = dayMonthYearToDate(data); return date < add(new Date(), { days: 1 }); - }, - { - message: "Date must not be in the future", - }, + }, "Date must not be in the future"), ), - pov: z - .union([ - z.object({ - type: z.literal("USER"), + pov: v.optional( + v.union([ + v.object({ + type: v.literal("USER"), userId: id, }), - z.object({ - type: z.literal("NAME"), - name: nonEmptyString.max(100), + v.object({ + type: v.literal("NAME"), + name: v.pipe(nonEmptyString, v.maxLength(100)), }), - ]) - .optional(), - teamSize: z.number().int().min(1).max(4).optional(), - matches: z.array(videoMatchSchema), - }) - .refine((data) => { + ]), + ), + teamSize: v.optional( + v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(4)), + ), + matches: v.array(videoMatchSchema), + }), + v.check((data) => { if (data.type === "CAST") { const teamSize = data.teamSize ?? 4; return data.matches.every( @@ -90,20 +93,21 @@ export const videoSchema = z.preprocess( return data.matches.every((match) => match.weapons.length === 1); }), + ), ); -const povSchema = z.union([ - z.object({ - type: z.literal("USER"), - userId: id.optional(), +const povSchema = v.union([ + v.object({ + type: v.literal("USER"), + userId: v.optional(id), }), - z.object({ - type: z.literal("NAME"), - name: nonEmptyString.max(100), + v.object({ + type: v.literal("NAME"), + name: v.pipe(nonEmptyString, v.maxLength(100)), }), ]); -const matchFieldsetSchema = z.object({ +const matchFieldsetSchema = v.object({ startsAt: textField({ label: "labels.vodStartTimestamp", placeholder: "placeholders.vodStartTimestamp", @@ -138,7 +142,7 @@ const matchFieldsetSchema = z.object({ }), }); -export const vodFormBaseSchema = z.object({ +export const vodFormBaseSchema = v.object({ vodToEditId: idConstantOptional(), youtubeUrl: textField({ label: "labels.vodYoutubeUrl", @@ -176,7 +180,7 @@ export const vodFormBaseSchema = z.object({ }), pov: customField( { initialValue: { type: "USER" as const } }, - povSchema.optional(), + v.optional(povSchema), ), matches: array({ label: "labels.vodMatches", diff --git a/app/features/vods/vods-search-params.ts b/app/features/vods/vods-search-params.ts index 790a3fcf2..ce4cada67 100644 --- a/app/features/vods/vods-search-params.ts +++ b/app/features/vods/vods-search-params.ts @@ -1,42 +1,47 @@ -import { z } from "zod"; +import * as v from "valibot"; import { ingestVodPrefillSchema } from "~/features/scanner-ingest/scanner-ingest-vod-schemas"; import { stageIds } from "~/modules/in-game-lists/stage-ids"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; -import { modeShort, numericEnum } from "~/utils/zod"; +import { modeShort, numericEnum } from "~/utils/schema"; import { videoMatchTypes } from "./vods-constants"; export const vodsSearchParams = SearchParams.define({ page: SP.page(), - weapon: SP.param(numericEnum(mainWeaponIds).nullable(), { + weapon: SP.param(v.nullable(numericEnum(mainWeaponIds)), { loader: true, resets: ["page"], }), - mode: SP.param(modeShort.nullable(), { + mode: SP.param(v.nullable(modeShort), { loader: true, resets: ["page"], }), - stageId: SP.param(numericEnum(stageIds).nullable(), { + stageId: SP.param(v.nullable(numericEnum(stageIds)), { loader: true, resets: ["page"], }), - type: SP.param(z.enum(videoMatchTypes).nullable(), { + type: SP.param(v.nullable(v.picklist(videoMatchTypes)), { loader: true, resets: ["page"], }), }); export const vodsNewSearchParams = SearchParams.define({ - vod: SP.param(z.number().int().positive().nullable(), { loader: true }), - ingest: SP.json(ingestVodPrefillSchema.nullable(), { + vod: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), { + loader: true, + }), + ingest: SP.json(v.nullable(ingestVodPrefillSchema), { loader: true, compress: true, }), }); export const vodsVodSearchParams = SearchParams.define({ - start: SP.param(z.number().int().min(0), { default: 0, loader: false }), + start: SP.param(v.pipe(v.number(), v.integer(), v.minValue(0)), { + default: 0, + loader: false, + }), }); export const userVodsSearchParams = SearchParams.define({ diff --git a/app/features/vods/vods-types.ts b/app/features/vods/vods-types.ts index e22f4fc80..714e9c9df 100644 --- a/app/features/vods/vods-types.ts +++ b/app/features/vods/vods-types.ts @@ -1,9 +1,9 @@ -import type { z } from "zod"; +import type * as v from "valibot"; import type { Tables } from "~/db/tables"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import type { videoSchema } from "./vods-schemas"; -export type VideoBeingAdded = z.infer; +export type VideoBeingAdded = v.InferOutput; export interface Vod { id: Tables["Video"]["id"]; diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx index da39ba11a..f72b7c1eb 100644 --- a/app/form/FormField.tsx +++ b/app/form/FormField.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import type { z } from "zod"; import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; +import type { AnySyncSchema } from "~/utils/schema"; import { formRegistry } from "./fields"; import { ArrayFormField } from "./fields/ArrayFormField"; import { BadgesFormField } from "./fields/BadgesFormField"; @@ -69,7 +69,7 @@ interface FormFieldProps { /** Focuses the field on mount. Only `text-field` and `text-area` support it. */ autoFocus?: boolean; maxCount?: number; - field?: z.ZodType; + field?: AnySyncSchema; children?: | ((props: CustomFieldRenderProps) => React.ReactNode) | ((props: ArrayItemRenderContext) => React.ReactNode); @@ -111,10 +111,10 @@ export function FormField({ ); } - const zodObject = context.schema; + const objectSchema = context.schema; const result = name.includes(".") - ? getNestedSchema(zodObject, name) - : zodObject.shape[name]; + ? getNestedSchema(objectSchema, name) + : objectSchema.entries[name]; if (!result) { throw new Error( @@ -441,7 +441,6 @@ export function FormField({ } if (formField.type === "array") { - // @ts-expect-error Type instantiation is excessively deep with complex schemas const innerFieldMeta = formRegistry.get(formField.field) as | FormFieldType | undefined; diff --git a/app/form/SendouForm.browser.test.tsx b/app/form/SendouForm.browser.test.tsx index 6c3737d33..1812dbe13 100644 --- a/app/form/SendouForm.browser.test.tsx +++ b/app/form/SendouForm.browser.test.tsx @@ -1,9 +1,9 @@ import { type ComponentProps, Profiler } from "react"; import { createMemoryRouter, RouterProvider } from "react-router"; +import * as v from "valibot"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { userEvent } from "vitest/browser"; import { render } from "vitest-browser-react"; -import { z } from "zod"; import labelStyles from "~/components/Label.module.css"; import { FormField } from "./FormField"; import { @@ -23,26 +23,26 @@ import { userSearch, } from "./fields"; import { SendouForm, useFormFieldContext } from "./SendouForm"; -import type { ArrayItemRenderContext } from "./types"; +import type { ArrayItemRenderContext, FormObjectSchema } from "./types"; let mockFetcherData: { fieldErrors?: Record } | undefined; /** `fieldset` registers its metadata onto the object it is handed, so anything nested needs a fresh one. */ const singleTextField = () => - z.object({ + v.object({ name: textField({ label: "labels.name", maxLength: 100 }), }); const SINGLE_TEXT_FIELD = singleTextField(); -const NESTED_FIELDSET = z.object({ +const NESTED_FIELDSET = v.object({ member: fieldset({ label: "labels.member", fields: singleTextField(), }), }); -const FIELDSET_ARRAY = z.object({ +const FIELDSET_ARRAY = v.object({ members: array({ label: "labels.members", min: 0, @@ -51,7 +51,7 @@ const FIELDSET_ARRAY = z.object({ }), }); -const TEXT_FIELD_ARRAY = z.object({ +const TEXT_FIELD_ARRAY = v.object({ urls: array({ label: "labels.urls", min: 0, @@ -60,11 +60,11 @@ const TEXT_FIELD_ARRAY = z.object({ }), }); -const TOGGLE = z.object({ +const TOGGLE = v.object({ noScreen: toggleField({ label: "labels.noScreen" }), }); -const CHECKBOX_GROUP = z.object({ +const CHECKBOX_GROUP = v.object({ modes: checkboxGroup({ label: "labels.buildModes", items: [ @@ -75,7 +75,7 @@ const CHECKBOX_GROUP = z.object({ }), }); -const TIME_RANGE = z.object({ +const TIME_RANGE = v.object({ times: timeRangeOptional({}), }); @@ -95,7 +95,7 @@ vi.mock("react-router", async () => { }); function renderForm( - schema: z.ZodObject, + schema: FormObjectSchema, options?: { defaultValues?: Record; title?: string; @@ -103,7 +103,7 @@ function renderForm( mode?: "autoSubmit"; }, ) { - const props: ComponentProps> = { + const props: ComponentProps> = { schema, defaultValues: options?.defaultValues, title: options?.title, @@ -111,7 +111,7 @@ function renderForm( mode: options?.mode, children: ( <> - {Object.keys(schema.shape).map((name) => ( + {Object.keys(schema.entries).map((name) => ( ))} @@ -229,7 +229,7 @@ describe("SendouForm", () => { }); test("optional text field does not show error when empty", async () => { - const schema = z.object({ + const schema = v.object({ bio: textFieldOptional({ label: "labels.bio", maxLength: 500 }), }); @@ -254,7 +254,7 @@ describe("SendouForm", () => { describe("text area", () => { test("renders textarea element", async () => { - const schema = z.object({ + const schema = v.object({ bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }), }); @@ -265,7 +265,7 @@ describe("SendouForm", () => { }); test("displays value counter showing current/max characters", async () => { - const schema = z.object({ + const schema = v.object({ bio: textAreaOptional({ label: "labels.bio", maxLength: 100 }), }); @@ -275,7 +275,7 @@ describe("SendouForm", () => { }); test("value counter updates as user types", async () => { - const schema = z.object({ + const schema = v.object({ bio: textAreaOptional({ label: "labels.bio", maxLength: 100 }), }); @@ -290,7 +290,7 @@ describe("SendouForm", () => { }); test("value counter shows warning style near max length", async () => { - const schema = z.object({ + const schema = v.object({ bio: textAreaOptional({ label: "labels.bio", maxLength: 10 }), }); @@ -303,7 +303,7 @@ describe("SendouForm", () => { }); test("value counter shows error style when over max length", async () => { - const schema = z.object({ + const schema = v.object({ bio: textAreaOptional({ label: "labels.bio", maxLength: 5 }), }); @@ -316,7 +316,7 @@ describe("SendouForm", () => { }); test("typing updates value", async () => { - const schema = z.object({ + const schema = v.object({ bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }), }); @@ -329,7 +329,7 @@ describe("SendouForm", () => { }); test("required text area shows error when empty", async () => { - const schema = z.object({ + const schema = v.object({ bio: textArea({ label: "labels.bio", maxLength: 500 }), }); @@ -345,7 +345,7 @@ describe("SendouForm", () => { describe("select field", () => { test("renders with options from schema", async () => { - const schema = z.object({ + const schema = v.object({ format: select({ label: "labels.clockFormat", items: [ @@ -366,7 +366,7 @@ describe("SendouForm", () => { }); test("selecting option updates value", async () => { - const schema = z.object({ + const schema = v.object({ format: select({ label: "labels.clockFormat", items: [ @@ -386,7 +386,7 @@ describe("SendouForm", () => { }); test("initializes with first option as default", async () => { - const schema = z.object({ + const schema = v.object({ format: select({ label: "labels.clockFormat", items: [ @@ -405,7 +405,7 @@ describe("SendouForm", () => { describe("optional select field", () => { test("allows empty selection", async () => { - const schema = z.object({ + const schema = v.object({ format: selectOptional({ label: "labels.clockFormat", items: [ @@ -458,7 +458,7 @@ describe("SendouForm", () => { describe("radio group field", () => { test("renders radio options", async () => { - const schema = z.object({ + const schema = v.object({ vc: radioGroup({ label: "labels.voiceChat", items: [ @@ -476,7 +476,7 @@ describe("SendouForm", () => { }); test("clicking option updates value", async () => { - const schema = z.object({ + const schema = v.object({ vc: radioGroup({ label: "labels.voiceChat", items: [ @@ -495,7 +495,7 @@ describe("SendouForm", () => { }); test("initializes with first option selected", async () => { - const schema = z.object({ + const schema = v.object({ vc: radioGroup({ label: "labels.voiceChat", items: [ @@ -514,7 +514,7 @@ describe("SendouForm", () => { describe("checkbox group field", () => { test("renders checkbox options", async () => { - const schema = z.object({ + const schema = v.object({ modes: checkboxGroup({ label: "labels.buildModes", items: [ @@ -536,7 +536,7 @@ describe("SendouForm", () => { }); test("checking options updates array value", async () => { - const schema = z.object({ + const schema = v.object({ modes: checkboxGroup({ label: "labels.buildModes", items: [ @@ -559,7 +559,7 @@ describe("SendouForm", () => { }); test("unchecking option removes from array", async () => { - const schema = z.object({ + const schema = v.object({ modes: checkboxGroup({ label: "labels.buildModes", items: [ @@ -615,7 +615,7 @@ describe("SendouForm", () => { describe("validation", () => { test("validates multiple fields on submit", async () => { - const schema = z.object({ + const schema = v.object({ name: textField({ label: "labels.name", maxLength: 100 }), bio: textArea({ label: "labels.bio", maxLength: 500 }), }); @@ -634,7 +634,7 @@ describe("SendouForm", () => { describe("default values", () => { test("initializes multiple fields with default values", async () => { - const schema = z.object({ + const schema = v.object({ name: textField({ label: "labels.name", maxLength: 100 }), bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }), }); @@ -655,7 +655,7 @@ describe("SendouForm", () => { }); test("falls back to schema initial value when no default provided", async () => { - const schema = z.object({ + const schema = v.object({ format: select({ label: "labels.clockFormat", items: [ @@ -673,7 +673,7 @@ describe("SendouForm", () => { }); test("toggle falls back to schema initial value when no default provided", async () => { - const schema = z.object({ + const schema = v.object({ noScreen: toggleField({ label: "labels.noScreen", initialValue: true }), }); @@ -683,7 +683,7 @@ describe("SendouForm", () => { }); test("dynamic select falls back to schema initial value when no default provided", async () => { - const schema = z.object({ + const schema = v.object({ threshold: selectDynamic({ label: "labels.advanceThreshold", initialValue: "4", @@ -843,10 +843,10 @@ describe("SendouForm", () => { }); test("renders nested fields inside fieldset", async () => { - const schema = z.object({ + const schema = v.object({ member: fieldset({ label: "labels.member", - fields: z.object({ + fields: v.object({ name: textField({ label: "labels.name", maxLength: 100 }), bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }), }), @@ -945,7 +945,7 @@ describe("SendouForm", () => { }); test("disables add button when max items reached", async () => { - const schema = z.object({ + const schema = v.object({ urls: array({ label: "labels.urls", min: 0, @@ -1014,7 +1014,7 @@ describe("SendouForm", () => { }); test("sortable array renders move buttons and reorders items", async () => { - const schema = z.object({ + const schema = v.object({ members: array({ label: "labels.members", min: 0, @@ -1064,13 +1064,13 @@ describe("SendouForm", () => { test("removing an added fieldset row returns to a single non-removable row", async () => { // Mirrors the staff form: a select field gives the row a non-empty default // (role), so a freshly added row isn't "blank" yet is still pristine. - const schema = z.object({ + const schema = v.object({ staff: array({ label: "labels.members", min: 0, max: 10, field: fieldset({ - fields: z.object({ + fields: v.object({ name: textField({ label: "labels.name", maxLength: 100 }), role: select({ label: "labels.staffRole", @@ -1129,13 +1129,13 @@ describe("SendouForm", () => { // rather than leaving them only displayed as a fallback and failing // validation on submit. const onApply = vi.fn(); - const schema = z.object({ + const schema = v.object({ staff: array({ label: "labels.members", min: 0, max: 10, field: fieldset({ - fields: z.object({ + fields: v.object({ name: textField({ label: "labels.name", maxLength: 100 }), role: select({ label: "labels.staffRole", @@ -1174,13 +1174,13 @@ describe("SendouForm", () => { }); test("shows error on specific nested field within array item", async () => { - const schema = z.object({ + const schema = v.object({ series: array({ label: "labels.orgSeries", min: 1, max: 10, field: fieldset({ - fields: z.object({ + fields: v.object({ name: textField({ label: "labels.name", maxLength: 100 }), description: textAreaOptional({ label: "labels.description", @@ -1202,7 +1202,7 @@ describe("SendouForm", () => { }); test("shows 'This field is required' for empty required field in array fieldset", async () => { - const schema = z.object({ + const schema = v.object({ series: array({ label: "labels.orgSeries", min: 1, @@ -1225,13 +1225,13 @@ describe("SendouForm", () => { }); test("setItemField batches multiple field updates correctly", async () => { - const schema = z.object({ + const schema = v.object({ members: array({ label: "labels.members", min: 1, max: 10, field: fieldset({ - fields: z.object({ + fields: v.object({ name: textFieldOptional({ label: "labels.name", maxLength: 100 }), bio: textFieldOptional({ label: "labels.bio", maxLength: 100 }), }), @@ -1256,13 +1256,13 @@ describe("SendouForm", () => { describe("array field with custom-rendered items", () => { const memberSchema = () => - z.object({ + v.object({ members: array({ label: "labels.members", min: 0, max: 10, field: fieldset({ - fields: z.object({ + fields: v.object({ name: textField({ label: "labels.name", maxLength: 100 }), role: select({ label: "labels.staffRole", @@ -1527,12 +1527,12 @@ describe("SendouForm", () => { return null; } - const schema = z.object({ + const schema = v.object({ members: array({ label: "labels.members", max: 10, field: fieldset({ - fields: z.object({ + fields: v.object({ userId: userSearch({ label: "labels.user" }), role: select({ label: "labels.orgMemberRole", @@ -1548,11 +1548,11 @@ describe("SendouForm", () => { const defaultValues = { members: [ - { userId: 10, role: "ADMIN" }, - { userId: 20, role: "MEMBER" }, - { userId: 30, role: "MEMBER" }, - { userId: 40, role: "MEMBER" }, - { userId: 50, role: "MEMBER" }, + { userId: 10, role: "ADMIN" as const }, + { userId: 20, role: "MEMBER" as const }, + { userId: 30, role: "MEMBER" as const }, + { userId: 40, role: "MEMBER" as const }, + { userId: 50, role: "MEMBER" as const }, ], }; @@ -1600,7 +1600,7 @@ describe("SendouForm", () => { describe("render isolation", () => { test("typing in one field does not re-render sibling fields", async () => { - const schema = z.object({ + const schema = v.object({ name: textField({ label: "labels.name", maxLength: 100 }), bio: textFieldOptional({ label: "labels.bio", maxLength: 100 }), }); diff --git a/app/form/SendouForm.tsx b/app/form/SendouForm.tsx index eb27b8d25..292a5d18b 100644 --- a/app/form/SendouForm.tsx +++ b/app/form/SendouForm.tsx @@ -5,35 +5,38 @@ import { useTranslation } from "react-i18next"; import type { FetcherWithComponents } from "react-router"; import { useFetcher, useLocation } from "react-router"; import { isPlainObject } from "remeda"; -import type { z } from "zod"; +import * as v from "valibot"; import type { SendouButtonProps } from "~/components/elements/Button"; import { FormMessage } from "~/components/FormMessage"; import { SubmitButton } from "~/components/SubmitButton"; import { FormField as FormFieldComponent } from "./FormField"; import { getFormFieldMetadata } from "./fields"; import styles from "./SendouForm.module.css"; -import type { TypedFormFieldComponent } from "./types"; +import type { FormObjectSchema, TypedFormFieldComponent } from "./types"; import { useUnsavedChangesChecker } from "./UnsavedChangesGuard"; import { buildFieldPath, errorMessageId, getNestedValue, + issuePathKeys, seedArrayItemDefaults, setNestedValue, validateField, } from "./utils"; -type RequiredDefaultKeys = { +type RequiredDefaultKeys = { [K in keyof T & string]: T[K] extends { _requiresDefault: true } ? K : never; }[keyof T & string]; -type HasRequiredDefaults = +type HasRequiredDefaults = RequiredDefaultKeys extends never ? false : true; -export interface FormContextValue { - schema: z.ZodObject; - defaultValues?: Partial>> | null; - serverErrors: Partial>, string>>; +export interface FormContextValue { + schema: FormObjectSchema; + defaultValues?: Partial>> | null; + serverErrors: Partial< + Record>, string> + >; clientErrors: Partial>; hasSubmitted: boolean; setClientError: (name: string, error: string | undefined) => void; @@ -76,15 +79,15 @@ const FormContext = React.createContext(null); export const EMPTY_FORM_STORE = createFormStore({}, {}); -export interface FormRenderProps { +export interface FormRenderProps { FormField: TypedFormFieldComponent; } export type FormMode = "submit" | "autoSubmit" | "client"; -type BaseFormProps = { +type BaseFormProps = { children: React.ReactNode | ((props: FormRenderProps) => React.ReactNode); - schema: z.ZodObject; + schema: FormObjectSchema; title?: React.ReactNode; submitButtonText?: React.ReactNode; action?: string; @@ -127,20 +130,23 @@ type BaseFormProps = { * - `"client"`: no submit button and no `
` element; every change is * passed to `onApply` and field errors are computed already on mount. */ -type FormModeProps = +type FormModeProps = | { mode?: "submit"; /** When set, a valid submit is handed to this callback instead of being sent to the server. */ - onApply?: (values: z.infer>) => void; + onApply?: (values: v.InferOutput>) => void; } | { mode: "autoSubmit"; onApply?: never } - | { mode: "client"; onApply: (values: z.infer>) => void }; + | { + mode: "client"; + onApply: (values: v.InferOutput>) => void; + }; -export type FormDefaultValues = Partial< - z.input> +export type FormDefaultValues = Partial< + v.InferInput> >; -type SendouFormProps = BaseFormProps & +type SendouFormProps = BaseFormProps & FormModeProps & (HasRequiredDefaults extends true ? { @@ -150,7 +156,7 @@ type SendouFormProps = BaseFormProps & : { defaultValues?: FormDefaultValues | null }); interface LatestFormProps { - schema: z.ZodObject; + schema: FormObjectSchema; onApply: ((values: Record) => void) | undefined; action: string | undefined; revalidateRoot: boolean | undefined; @@ -159,7 +165,9 @@ interface LatestFormProps { t: (key: string) => string; } -export function SendouForm(props: SendouFormProps) { +export function SendouForm( + props: SendouFormProps, +) { // Remounting on URL change resets all form state (handles edit → new transitions) const location = useLocation(); @@ -171,7 +179,7 @@ export function SendouForm(props: SendouFormProps) { ); } -function SendouFormInner({ +function SendouFormInner({ children, schema, defaultValues, @@ -211,7 +219,7 @@ function SendouFormInner({ const store = storeRef.current; const latestProps: LatestFormProps = { - schema: schema as z.ZodObject, + schema: schema as FormObjectSchema, onApply: onApply as unknown as LatestFormProps["onApply"], action, revalidateRoot, @@ -282,7 +290,7 @@ function SendouFormInner({ const contextValue = React.useMemo( () => ({ - schema: schema as z.ZodObject, + schema: schema as FormObjectSchema, defaultValues: defaultValues as FormFieldContextValue["defaultValues"], serverErrors: visibleServerErrors, hasSubmitted, @@ -599,17 +607,18 @@ function createFormActions({ * (array/fieldset children render their own error slots). */ function computeFieldErrors( - schema: z.ZodObject, + schema: FormObjectSchema, values: Record, ): Record { const newErrors: Record = {}; - const fullValidation = schema.safeParse(values); + const fullValidation = v.safeParse(schema, values); if (fullValidation.success) return newErrors; - for (const issue of fullValidation.error.issues) { + for (const issue of fullValidation.issues) { + const issuePath = issuePathKeys(issue); const topLevelKey = - typeof issue.path[0] === "string" ? issue.path[0] : undefined; + typeof issuePath[0] === "string" ? issuePath[0] : undefined; if (topLevelKey && newErrors[topLevelKey] === undefined) { const topLevelError = validateField( schema, @@ -619,7 +628,7 @@ function computeFieldErrors( if (topLevelError) newErrors[topLevelKey] = topLevelError; } - const fieldName = buildFieldPath(issue.path); + const fieldName = buildFieldPath(issuePath); if (fieldName && newErrors[fieldName] === undefined) { const value = getNestedValue(values, fieldName); newErrors[fieldName] = @@ -631,25 +640,25 @@ function computeFieldErrors( } function computeTopLevelFieldErrors( - schema: z.ZodObject, + schema: FormObjectSchema, values: Record, ): Record { const errors: Record = {}; - for (const key of Object.keys(schema.shape)) { + for (const key of Object.keys(schema.entries)) { const error = validateField(schema, key, values[key]); if (error) errors[key] = error; } return errors; } -function buildInitialValues( - schema: z.ZodObject, - defaultValues?: Partial>> | null, +function buildInitialValues( + schema: FormObjectSchema, + defaultValues?: Partial>> | null, ): Record { const result: Record = {}; - for (const [key, fieldSchema] of Object.entries(schema.shape)) { - const formField = getFormFieldMetadata(fieldSchema as z.ZodType); + for (const [key, fieldSchema] of Object.entries(schema.entries)) { + const formField = getFormFieldMetadata(fieldSchema); const defaultValue = defaultValues?.[key as keyof typeof defaultValues]; if (defaultValue !== undefined) { diff --git a/app/form/fields.ts b/app/form/fields.ts index 327556665..0da973ed8 100644 --- a/app/form/fields.ts +++ b/app/form/fields.ts @@ -1,20 +1,24 @@ import * as R from "remeda"; -import { z } from "zod"; +import * as v from "valibot"; import { IN_GAME_NAME_MAX_LENGTH, inGameNameIsValid, } from "~/features/user-page/in-game-name"; +import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids"; +import type { AnySyncSchema, DayMonthYear } from "~/utils/schema"; import { + coerceNumber, date, falsyToNull, id, + preprocess, safeNullableStringSchema, safeStringSchema, stageId, timeString, weaponSplId, -} from "~/utils/zod"; +} from "~/utils/schema"; import { imageValue } from "./image-field"; import type { BadgeOption, @@ -35,24 +39,32 @@ import type { TrophyOption, } from "./types"; -export const formRegistry = z.registry(); +export const formRegistry = new WeakMap(); /** - * Looks up a schemas form field metadata. Needed to bypass the - * registrys deep generic `get` signature which causes - * "Type instantiation is excessively deep" errors. + * Attaches form field metadata to a schema. Clones the schema first so shared + * schema instances (e.g. `id`, `stageId`) each get their own registry entry. */ -export function getFormFieldMetadata(schema: z.ZodType): FormField | undefined { - const registry = formRegistry as { - get(schema: z.ZodType): FormField | undefined; - }; - return registry.get(schema); +function register(schema: T, metadata: FormField): T { + const clone = { ...schema }; + formRegistry.set(clone, metadata); + return clone; } -export type RequiresDefault = T & { +/** Looks up a schema's form field metadata. */ +export function getFormFieldMetadata( + schema: AnySyncSchema, +): FormField | undefined { + return formRegistry.get(schema); +} + +export type RequiresDefault = T & { _requiresDefault: true; }; +// A builder declares on its signature what `defaultValues` must supply for the +// field and returns `as never`; parsing itself always starts from `unknown`. + type WithTypedTranslationKeys = Omit< T, "label" | "bottomText" | "placeholder" @@ -114,9 +126,7 @@ export function image(args: { dimensions?: "logo" | "thick-banner" | { width: number; height: number }; autoValidate?: boolean; }) { - // clone so each field gets its own registry entry (the shared `imageValue` - // instance would otherwise have its metadata overwritten by later fields) - return imageValue.clone().register(formRegistry, { + return register(imageValue, { label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), dimensions: args.dimensions ?? "logo", @@ -126,12 +136,11 @@ export function image(args: { }); } -export function customField( +export function customField( args: Omit, "type">, schema: T, ) { - // @ts-expect-error Complex generic type with registry - return schema.register(formRegistry, { + return register(schema, { ...args, type: "custom", }); @@ -144,31 +153,45 @@ type TextFieldArgs = WithTypedTranslationKeys< > >; -export function textFieldOptional(args: TextFieldArgs) { - const schema = - args.validate === "url" - ? z.url() - : safeNullableStringSchema({ min: args.minLength, max: args.maxLength }); +export function textFieldOptional( + args: TextFieldArgs, +): v.GenericSchema { + // a url field is validated as a plain string, so unlike the other optional + // text fields it has no null to fall back to and its key stays required + if (args.validate === "url") { + return registerTextField( + v.pipe(v.string(), v.url()), + args, + false, + false, + ) as never; + } - return registerTextField(schema, args, false); + return registerTextField( + safeNullableStringSchema({ min: args.minLength, max: args.maxLength }), + args, + false, + true, + ) as never; } -export function textField(args: TextFieldArgs) { +export function textField(args: TextFieldArgs): v.GenericSchema { const schema = args.validate === "url" - ? z.string().url() + ? v.pipe(v.string(), v.url()) : safeStringSchema({ min: args.minLength, max: args.maxLength }); - return registerTextField(schema, args, true); + return registerTextField(schema, args, true, false) as never; } -function registerTextField>( +function registerTextField>( schema: T, args: TextFieldArgs, required: boolean, + nullable: boolean, ): T { - const refined = textFieldRefined(schema, args) as z.ZodType; - return refined.register(formRegistry, { + const refined = textFieldRefined(schema, args); + return register(nullable ? optionalKey(refined) : refined, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), @@ -176,47 +199,48 @@ function registerTextField>( required, type: "text-field", initialValue: "", - }) as T; + }) as unknown as T; } -function textFieldRefined>( +function textFieldRefined>( schema: T, args: Omit< Extract, "type" | "initialValue" | "required" >, -): T { - let result = schema as z.ZodType; +): v.GenericSchema { + let result: v.GenericSchema = schema; if (args.regExp) { - result = result.refine( - (val) => { + result = v.pipe( + result, + v.check((val) => { if (val === null) return true; return args.regExp!.pattern.test(val); - }, - { message: args.regExp!.message }, + }, args.regExp!.message), ); } if (args.validate && typeof args.validate !== "string") { - result = result.refine( - (val) => { + result = v.pipe( + result, + v.check((val) => { if (val === null) return true; return (args.validate as { func: (value: string) => boolean }).func( val, ); - }, - { message: args.validate!.message }, + }, args.validate!.message), ); } if (args.toLowerCase) { - result = result.transform( - (val) => val?.toLowerCase() ?? null, - ) as unknown as typeof result; + result = v.pipe( + result, + v.transform((val) => val?.toLowerCase() ?? null), + ); } - return result as T; + return result; } export function inGameName( @@ -225,13 +249,17 @@ export function inGameName( bottomText?: FormsTranslationKey; }>, ) { - const schema = safeNullableStringSchema({ - max: IN_GAME_NAME_MAX_LENGTH, - }).refine((val) => val === null || inGameNameIsValid(val), { - message: "forms:errors.profileInGameName", - }); + const schema = v.pipe( + safeNullableStringSchema({ + max: IN_GAME_NAME_MAX_LENGTH, + }), + v.check( + (val) => val === null || inGameNameIsValid(val), + "forms:errors.profileInGameName", + ), + ); - return schema.register(formRegistry, { + return register(optionalKey(schema), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), @@ -239,7 +267,10 @@ export function inGameName( required: false, type: "in-game-name", initialValue: "", - }); + }) as unknown as v.OptionalSchema< + v.GenericSchema, + null + >; } type NumberFieldArgs = WithTypedTranslationKeys< @@ -256,32 +287,39 @@ type NumberFieldArgs = WithTypedTranslationKeys< export function numberField( args: NumberFieldArgs & { min?: number; max?: number }, -) { - let schema = numberSchema(); +): v.GenericSchema { + let schema: v.GenericSchema = numberSchema(); // an empty field coerces to 0, so `min` is also what makes a required number // field reject being left blank if (typeof args.min === "number") { - schema = schema.min(args.min, { message: "forms:errors.numberOutOfRange" }); + schema = v.pipe( + schema, + v.minValue(args.min, "forms:errors.numberOutOfRange"), + ); } if (typeof args.max === "number") { - schema = schema.max(args.max, { message: "forms:errors.numberOutOfRange" }); + schema = v.pipe( + schema, + v.maxValue(args.max, "forms:errors.numberOutOfRange"), + ); } - return schema.register(formRegistry, numberFieldMetadata(args, true)); + return register(schema, numberFieldMetadata(args, true)); } -export function numberFieldOptional(args: NumberFieldArgs) { - return numberSchema() - .optional() - .register(formRegistry, numberFieldMetadata(args, false)); +export function numberFieldOptional( + args: NumberFieldArgs, +): v.OptionalSchema, undefined> { + return register(v.optional(numberSchema()), numberFieldMetadata(args, false)); } -function numberSchema() { - return z.coerce - .number() - .int({ message: "forms:errors.mustBeWholeNumber" }) - .nonnegative(); +function numberSchema(): v.GenericSchema { + return v.pipe( + coerceNumber(), + v.integer("forms:errors.mustBeWholeNumber"), + v.minValue(0), + ) as never; } function numberFieldMetadata(args: NumberFieldArgs, required: boolean) { @@ -304,35 +342,37 @@ type TextAreaArgs = WithTypedTranslationKeys< > >; -export function textAreaOptional(args: TextAreaArgs) { +export function textAreaOptional( + args: TextAreaArgs, +): v.GenericSchema { return registerTextArea( safeNullableStringSchema({ max: args.maxLength }), args, false, - ); + ) as never; } -export function textArea(args: TextAreaArgs) { +export function textArea(args: TextAreaArgs): v.GenericSchema { return registerTextArea( safeStringSchema({ max: args.maxLength }), args, true, - ); + ) as never; } -function registerTextArea>( +function registerTextArea>( schema: T, args: TextAreaArgs, required: boolean, ): T { - return (schema as z.ZodType).register(formRegistry, { + return register((required ? schema : optionalKey(schema)) as T, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), required, type: "text-area", initialValue: "", - }) as T; + }); } export function toggle( @@ -343,27 +383,43 @@ export function toggle( initialValue?: boolean; }, ) { - return z - .boolean() - .optional() - .default(false) - .register(formRegistry, { - ...args, - label: prefixKey(args.label), - bottomText: prefixKey(args.bottomText), - type: "switch", - initialValue: args.initialValue ?? false, - }); + return register(v.optional(v.boolean(), false), { + ...args, + label: prefixKey(args.label), + bottomText: prefixKey(args.bottomText), + type: "switch", + initialValue: args.initialValue ?? false, + }); } +/** + * Makes a nullable field tolerate a missing key: `v.object` requires every key + * whose entry schema is not itself optional, and a `preprocess` pipe hides the + * nullable wrapper behind its own type. The `null` default is fed through the + * schema, so an absent field parses exactly like an explicitly `null` one. + */ +function optionalKey>( + schema: TSchema, +) { + return v.optional(schema, null); +} + +/** + * Item value type of a field builder, shielded from inference. Without it, + * calling a builder inline inside `v.object({...})` lets valibot's entry + * constraint drive `V` from the return position and widen the item literals to + * `string`. + */ +type ItemValue = NoInfer; + function itemsSchema(items: FormFieldItems) { - return z.enum(items.map((item) => item.value) as [V, ...V[]]); + return v.picklist(items.map((item) => item.value) as [V, ...V[]]); } function clearableItemsSchema(items: FormFieldItems) { - return z.preprocess( + return preprocess( falsyToNull, - z.enum(items.map((item) => item.value) as [V, ...V[]]).nullable(), + v.nullable(v.picklist(items.map((item) => item.value) as [V, ...V[]])), ); } @@ -374,8 +430,8 @@ export function selectOptional( V > >, -) { - return clearableItemsSchema(args.items).register(formRegistry, { +): v.GenericSchema | null, ItemValue | null> { + return register(optionalKey(clearableItemsSchema(args.items)), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), @@ -383,7 +439,7 @@ export function selectOptional( type: "select", initialValue: null, clearable: true, - }); + }) as never; } export function select( @@ -396,8 +452,8 @@ export function select( /** Value selected when the form has no default value for the field. Defaults to the first item. */ initialValue?: V; }, -) { - return itemsSchema(args.items).register(formRegistry, { +): v.GenericSchema, ItemValue> { + return register(itemsSchema(args.items), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), @@ -419,14 +475,14 @@ export function selectDynamic( initialValue?: string; }, ) { - return z.string().register(formRegistry, { + return register(v.string(), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "select-dynamic", initialValue: args.initialValue ?? null, clearable: false, - }) as unknown as z.ZodType & FieldWithOptions; + }) as unknown as v.GenericSchema & FieldWithOptions; } export function selectDynamicOptional( @@ -437,19 +493,26 @@ export function selectDynamicOptional( > >, ) { - return z - .preprocess(falsyToNull, z.string().nullable()) - .register(formRegistry, { + return register( + optionalKey(preprocess(falsyToNull, v.nullable(v.string()))), + { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "select-dynamic", initialValue: null, clearable: true, - }) as unknown as z.ZodType & + }, + ) as unknown as v.GenericSchema & FieldWithOptions; } +/** Value schema of a dual select, before the `optional` wrapper is applied. */ +type DualSelectSchema = v.GenericSchema< + [unknown, unknown], + [V | null, V | null] +>; + export function dualSelectOptional( args: WithTypedTranslationKeys< WithTypedDualSelectFields< @@ -460,27 +523,25 @@ export function dualSelectOptional( V > >, -) { - let schema = z - .tuple([ - clearableItemsSchema(args.fields[0].items), - clearableItemsSchema(args.fields[1].items), - ]) - .optional(); +): v.OptionalSchema>, undefined> { + // the `optional` wrapper stays outermost so `v.object` still reads the key as + // optional (a pipe reports its first item's type, hiding the wrapper) + const tuple = v.tuple([ + clearableItemsSchema(args.fields[0].items), + clearableItemsSchema(args.fields[1].items), + ]); - if (args.validate) { - schema = schema.refine( - (val) => { - if (!val) return true; - const [first, second] = val; - return args.validate!.func([first, second]); - }, - { message: `forms:${args.validate!.message}` }, - ); - } + const schema: DualSelectSchema = args.validate + ? v.pipe( + tuple, + v.check( + ([first, second]) => args.validate!.func([first, second]), + `forms:${args.validate!.message}`, + ), + ) + : tuple; - // @ts-expect-error Complex generic type - return schema.register(formRegistry, { + return register(v.optional(schema), { ...args, bottomText: prefixKey(args.bottomText), fields: args.fields.map((field) => ({ @@ -491,7 +552,7 @@ export function dualSelectOptional( type: "dual-select", initialValue: [null, null], clearable: true, - }); + } as unknown as FormField); } export function radioGroup( @@ -501,8 +562,8 @@ export function radioGroup( V > >, -) { - return itemsSchema(args.items).register(formRegistry, { +): v.GenericSchema, ItemValue> { + return register(itemsSchema(args.items), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), @@ -520,13 +581,13 @@ export function radioGroupDynamic( > >, ) { - return z.string().register(formRegistry, { + return register(v.string(), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "radio-group-dynamic", initialValue: null, - }) as unknown as z.ZodType & + }) as unknown as v.GenericSchema & FieldWithOptions>; } @@ -538,17 +599,20 @@ export function checkboxGroupDynamic( > >, ) { - return z - .array(z.string()) - .min(args.minLength ?? 0, "forms:errors.required") - .refine((val) => val.length === R.unique(val).length) - .register(formRegistry, { + return register( + v.pipe( + v.array(v.string()), + v.minLength(args.minLength ?? 0, "forms:errors.required"), + v.check((val) => val.length === R.unique(val).length), + ), + { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "checkbox-group-dynamic", initialValue: [], - }) as unknown as z.ZodType & + }, + ) as unknown as v.GenericSchema & FieldWithOptions>; } @@ -559,17 +623,21 @@ type DateTimeArgs = WithTypedTranslationKeys< maxMessage?: FormsTranslationKey; }; -function boundedDate(args: DateTimeArgs, schema: z.ZodDate) { +function boundedDate(args: DateTimeArgs, schema: v.GenericSchema) { const resolveMin = args.min ?? (() => new Date(Date.UTC(2015, 4, 28))); const resolveMax = args.max ?? (() => new Date(Date.UTC(2030, 4, 28))); - return schema - .refine((d) => d >= resolveMin(), { - message: `forms:${args.minMessage ?? "errors.dateTooEarly"}`, - }) - .refine((d) => d <= resolveMax(), { - message: `forms:${args.maxMessage ?? "errors.dateTooLate"}`, - }); + return v.pipe( + schema, + v.check( + (d) => d >= resolveMin(), + `forms:${args.minMessage ?? "errors.dateTooEarly"}`, + ), + v.check( + (d) => d <= resolveMax(), + `forms:${args.maxMessage ?? "errors.dateTooLate"}`, + ), + ); } function datetimeMetadata( @@ -585,42 +653,38 @@ function datetimeMetadata( }; } -export function datetime(args: DateTimeArgs) { - return z - .preprocess( - date, - boundedDate(args, z.date({ message: "forms:errors.required" })), - ) - .register( - formRegistry, - datetimeMetadata(args, { type: "datetime", required: true }), - ); +export function datetime(args: DateTimeArgs): v.GenericSchema { + return register( + preprocess(date, boundedDate(args, v.date("forms:errors.required"))), + datetimeMetadata(args, { type: "datetime", required: true }), + ) as never; } -export function datetimeOptional(args: DateTimeArgs) { - return z - .preprocess(date, boundedDate(args, z.date()).nullish()) - .register( - formRegistry, - datetimeMetadata(args, { type: "datetime", required: false }), - ); +export function datetimeOptional( + args: DateTimeArgs, +): v.NullishSchema, undefined> { + // the `nullish` wrapper stays outermost so `v.object` still reads the key as + // optional (a pipe reports its first item's type, hiding the wrapper) + return register( + v.nullish(preprocess(date, boundedDate(args, v.date()))), + datetimeMetadata(args, { type: "datetime", required: false }), + ) as never; } -export function dayMonthYear(args: DateTimeArgs) { - return z - .preprocess( - date, - boundedDate(args, z.date({ message: "forms:errors.required" })), - ) - .transform((d) => ({ - day: d.getDate(), - month: d.getMonth(), - year: d.getFullYear(), - })) - .register( - formRegistry, - datetimeMetadata(args, { type: "date", required: true }), - ); +export function dayMonthYear( + args: DateTimeArgs, +): v.GenericSchema { + return register( + v.pipe( + preprocess(date, boundedDate(args, v.date("forms:errors.required"))), + v.transform((d) => ({ + day: d.getDate(), + month: d.getMonth(), + year: d.getFullYear(), + })), + ), + datetimeMetadata(args, { type: "date", required: true }), + ) as never; } export function checkboxGroup( @@ -630,19 +694,22 @@ export function checkboxGroup( V > >, -) { - return z - .array(itemsSchema(args.items)) - .min(args.minLength ?? 0) - .refine((val) => val.length === R.unique(val).length) - .register(formRegistry, { +): v.GenericSchema[], ItemValue[]> { + return register( + v.pipe( + v.array(itemsSchema(args.items)), + v.minLength(args.minLength ?? 0), + v.check((val) => val.length === R.unique(val).length), + ), + { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), items: prefixItems(args.items), type: "checkbox-group", initialValue: [], - }); + }, + ); } export function weaponPool( @@ -650,31 +717,46 @@ export function weaponPool( Omit, "type" | "initialValue"> >, ) { - let schema = z - .array( - z.object({ + type WeaponPoolInput = Array<{ + id: v.InferInput; + isFavorite: boolean; + }>; + type WeaponPoolValue = Array<{ + id: v.InferOutput; + isFavorite: boolean; + }>; + let schema: v.GenericSchema = v.pipe( + v.array( + v.object({ id: weaponSplId, - isFavorite: z.boolean(), + isFavorite: v.boolean(), }), - ) - .min(args.minCount ?? 0) - .max(args.maxCount); + ), + v.minLength(args.minCount ?? 0), + v.maxLength(args.maxCount), + ); if (!args.allowDuplicates) { - schema = schema.refine( - (val) => val.length === R.uniqueBy(val, (item) => item.id).length, + schema = v.pipe( + schema, + v.check( + (val) => val.length === R.uniqueBy(val, (item) => item.id).length, + ), ); } if (args.disableAltSkinDuplicates) { - schema = schema.refine( - (val) => - val.length === - R.uniqueBy(val, (item) => canonicalWeaponSplId(item.id)).length, + schema = v.pipe( + schema, + v.check( + (val) => + val.length === + R.uniqueBy(val, (item) => canonicalWeaponSplId(item.id)).length, + ), ); } - return schema.register(formRegistry, { + return register(schema, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), @@ -690,57 +772,58 @@ export function weaponPool( * Pass `initialValue` to hardcode the starting value. Omitting it makes the * field require a matching entry in the form's `defaultValues`. */ -export function hidden( +export function hidden( schema: T, - initialValue: z.input, + initialValue: v.InferInput, ): T; -export function hidden(schema: T): RequiresDefault; -export function hidden( +export function hidden(schema: T): RequiresDefault; +export function hidden( schema: T, - initialValue?: z.input, + initialValue?: v.InferInput, ) { - // @ts-expect-error Complex generic type with registry - return schema.register(formRegistry, { + return register(schema, { type: "hidden", initialValue, }) as never; } export function stringConstant(value: T) { - return hidden(z.literal(value), value); + return hidden(v.literal(value), value); } -export function idConstant(value: T): z.ZodLiteral; -export function idConstant(): RequiresDefault; +export function idConstant( + value: T, +): v.LiteralSchema; +export function idConstant(): RequiresDefault>; export function idConstant(value?: T) { return ( - value !== undefined ? hidden(z.literal(value), value) : hidden(id.clone()) + value !== undefined ? hidden(v.literal(value), value) : hidden(id) ) as never; } export function idConstantOptional(value?: T) { return value - ? hidden(z.literal(value).optional(), value) - : hidden(id.optional(), undefined); + ? hidden(v.optional(v.literal(value)), value) + : hidden(v.optional(id), undefined); } -export function array( +export function array( args: WithTypedTranslationKeys< Omit, "type" | "initialValue"> >, ) { - const schema = z - .array(args.field) - .min(args.min ?? 0) - .max(args.max); - // @ts-expect-error Complex generic type with registry - return schema.register(formRegistry, { + const schema = v.pipe( + v.array(args.field), + v.minLength(args.min ?? 0), + v.maxLength(args.max), + ); + return register(schema, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "array", initialValue: [], - }); + } as FormField); } type TimeRangeArgs = WithTypedTranslationKeys< @@ -754,13 +837,14 @@ type TimeRangeArgs = WithTypedTranslationKeys< }; export function timeRangeOptional(args: TimeRangeArgs) { - return z - .object({ - start: timeString, - end: timeString, - }) - .nullable() - .register(formRegistry, { + return register( + v.nullable( + v.object({ + start: timeString, + end: timeString, + }), + ), + { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), @@ -768,22 +852,22 @@ export function timeRangeOptional(args: TimeRangeArgs) { endLabel: prefixKey(args.endLabel), type: "time-range", initialValue: null, - }); + }, + ); } -export function fieldset( +export function fieldset( args: WithTypedTranslationKeys< Omit, "type" | "initialValue" | "fields"> - > & { fields: z.ZodObject }, -): z.ZodObject { - // @ts-expect-error Complex generic type with registry - return args.fields.register(formRegistry, { + > & { fields: v.ObjectSchema }, +): v.ObjectSchema { + return register(args.fields, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "fieldset", initialValue: {}, - }) as z.ZodObject; + } as FormField); } type UserSearchArgs = WithTypedTranslationKeys< @@ -793,12 +877,14 @@ type UserSearchArgs = WithTypedTranslationKeys< > >; -export function userSearch(args: UserSearchArgs) { - return id.clone().register(formRegistry, userSearchMetadata(args, true)); +export function userSearch(args: UserSearchArgs): v.GenericSchema { + return register(id, userSearchMetadata(args, true)) as never; } -export function userSearchOptional(args: UserSearchArgs) { - return id.optional().register(formRegistry, userSearchMetadata(args, false)); +export function userSearchOptional( + args: UserSearchArgs, +): v.OptionalSchema, undefined> { + return register(v.optional(id), userSearchMetadata(args, false)) as never; } function userSearchMetadata(args: UserSearchArgs, required: boolean) { @@ -820,14 +906,14 @@ export function tournamentSearchOptional( > >, ) { - return z.preprocess(falsyToNull, id.nullable()).register(formRegistry, { + return register(optionalKey(preprocess(falsyToNull, v.nullable(id))), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "tournament-search", initialValue: null, required: false, - }) as unknown as z.ZodType & + }) as unknown as v.GenericSchema & FieldWithOptions; } @@ -839,14 +925,14 @@ export function teamSearchOptional( > >, ) { - return z.preprocess(falsyToNull, id.nullable()).register(formRegistry, { + return register(optionalKey(preprocess(falsyToNull, v.nullable(id))), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "team-search", initialValue: null, required: false, - }) as unknown as z.ZodType & + }) as unknown as v.GenericSchema & FieldWithOptions; } @@ -855,16 +941,14 @@ export function badges( Omit, "type" | "initialValue"> >, ) { - return z - .array(id) - .max(args.maxCount ?? 50) - .register(formRegistry, { - ...args, - label: prefixKey(args.label), - bottomText: prefixKey(args.bottomText), - type: "badges", - initialValue: [], - }) as z.ZodArray & FieldWithOptions; + return register(v.pipe(v.array(id), v.maxLength(args.maxCount ?? 50)), { + ...args, + label: prefixKey(args.label), + bottomText: prefixKey(args.bottomText), + type: "badges", + initialValue: [], + }) as unknown as v.GenericSchema & + FieldWithOptions; } export function trophies( @@ -872,16 +956,14 @@ export function trophies( Omit, "type" | "initialValue"> >, ) { - return z - .array(id) - .max(args.maxCount ?? 100) - .register(formRegistry, { - ...args, - label: prefixKey(args.label), - bottomText: prefixKey(args.bottomText), - type: "trophies", - initialValue: [], - }) as z.ZodArray & FieldWithOptions; + return register(v.pipe(v.array(id), v.maxLength(args.maxCount ?? 100)), { + ...args, + label: prefixKey(args.label), + bottomText: prefixKey(args.bottomText), + type: "trophies", + initialValue: [], + }) as unknown as v.GenericSchema & + FieldWithOptions; } export function stageSelect( @@ -891,15 +973,15 @@ export function stageSelect( "type" | "initialValue" | "required" > >, -) { - return stageId.register(formRegistry, { +): v.GenericSchema { + return register(stageId, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "stage-select", initialValue: 1, required: true, - }); + }) as never; } type WeaponSelectArgs = WithTypedTranslationKeys< @@ -909,14 +991,19 @@ type WeaponSelectArgs = WithTypedTranslationKeys< > >; -export function weaponSelect(args: WeaponSelectArgs) { - return weaponSplId.register(formRegistry, weaponSelectMetadata(args, true)); +export function weaponSelect( + args: WeaponSelectArgs, +): v.GenericSchema { + return register(weaponSplId, weaponSelectMetadata(args, true)) as never; } -export function weaponSelectOptional(args: WeaponSelectArgs) { - return weaponSplId - .optional() - .register(formRegistry, weaponSelectMetadata(args, false)); +export function weaponSelectOptional( + args: WeaponSelectArgs, +): v.OptionalSchema, undefined> { + return register( + v.optional(weaponSplId), + weaponSelectMetadata(args, false), + ) as never; } function weaponSelectMetadata(args: WeaponSelectArgs, required: boolean) { diff --git a/app/form/fields/FieldsetFormField.tsx b/app/form/fields/FieldsetFormField.tsx index 9adc80fed..ca94183a2 100644 --- a/app/form/fields/FieldsetFormField.tsx +++ b/app/form/fields/FieldsetFormField.tsx @@ -1,19 +1,19 @@ -import type { z } from "zod"; +import type * as v from "valibot"; import { FormMessage } from "~/components/FormMessage"; import { FormField } from "../FormField"; import type { FormFieldProps } from "../types"; import { useTranslatedTexts } from "./FormFieldWrapper"; -type FieldsetFormFieldProps = Omit< +type FieldsetFormFieldProps = Omit< FormFieldProps<"fieldset">, "fields" > & { name: string; - fields: z.ZodObject; + fields: v.ObjectSchema; disabled?: boolean; }; -export function FieldsetFormField({ +export function FieldsetFormField({ label, name, bottomText, @@ -21,7 +21,7 @@ export function FieldsetFormField({ fields, disabled, }: FieldsetFormFieldProps) { - const fieldNames = Object.keys(fields.shape); + const fieldNames = Object.keys(fields.entries); const { translatedLabel, translatedBottomText, translatedError } = useTranslatedTexts({ label, bottomText, error }); @@ -34,7 +34,7 @@ export function FieldsetFormField({ ))} diff --git a/app/form/image-field.ts b/app/form/image-field.ts index 6230cc6f7..4afeac104 100644 --- a/app/form/image-field.ts +++ b/app/form/image-field.ts @@ -1,5 +1,5 @@ -import { z } from "zod"; -import { id } from "~/utils/zod"; +import * as v from "valibot"; +import { id } from "~/utils/schema"; /** * Allowed prefixes for a {@link imageValue} `NEW` data URL. The client compresses to webp, @@ -20,24 +20,25 @@ const IMAGE_FIELD_MAX_DATA_URL_LENGTH = 3_000_000; * `null` (none / removed), an unchanged `EXISTING` image (only the id reference + a preview url * ride in JSON, never bytes), or a newly picked `NEW` image as a base64 webp/png data URL. */ -export const imageValue = z - .union([ - z.object({ - type: z.literal("EXISTING"), +export const imageValue = v.nullable( + v.union([ + v.object({ + type: v.literal("EXISTING"), imgId: id, - url: z.string(), + url: v.string(), }), - z.object({ - type: z.literal("NEW"), - dataUrl: z - .string() - .max(IMAGE_FIELD_MAX_DATA_URL_LENGTH) - .regex(IMAGE_FIELD_DATA_URL_PREFIX_REGEX), + v.object({ + type: v.literal("NEW"), + dataUrl: v.pipe( + v.string(), + v.maxLength(IMAGE_FIELD_MAX_DATA_URL_LENGTH), + v.regex(IMAGE_FIELD_DATA_URL_PREFIX_REGEX), + ), }), - ]) - .nullable(); + ]), +); -export type ImageFieldValue = z.infer; +export type ImageFieldValue = v.InferOutput; /** * Builds an `EXISTING` {@link ImageFieldValue} for an edit form's default values, or `null` diff --git a/app/form/parse.server.ts b/app/form/parse.server.ts index 334249e05..18a992f1e 100644 --- a/app/form/parse.server.ts +++ b/app/form/parse.server.ts @@ -1,10 +1,11 @@ -import { z } from "zod"; +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import { imageFieldValueToImgId } from "~/features/img-upload/image-field.server"; import { formDataToObject } from "~/utils/remix.server"; +import type { AnySchema } from "~/utils/schema"; import { formRegistry } from "./fields"; import type { ImageFieldValue } from "./image-field"; -import { buildFieldPath } from "./utils"; +import { buildFieldPath, issuePathKeys } from "./utils"; export type ParseResult = | { success: true; data: T } @@ -18,13 +19,15 @@ export type ParseResult = const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024; /** - * Maps a {@link z.ZodError} to field-level errors keyed by form field name + * Maps validation issues to field-level errors keyed by form field name * (e.g. `members[0].userId`), keeping the first error per field. */ -function fieldErrorsFromZodError(error: z.ZodError): Record { +function fieldErrorsFromIssues( + issues: v.BaseIssue[], +): Record { const fieldErrors: Record = {}; - for (const issue of error.issues) { - const path = buildFieldPath(issue.path); + for (const issue of issues) { + const path = buildFieldPath(issuePathKeys(issue)); if (path && !fieldErrors[path]) { fieldErrors[path] = issue.message; } @@ -34,11 +37,11 @@ function fieldErrorsFromZodError(error: z.ZodError): Record { } /** - * Parses request body against a Zod schema. + * Parses request body against a schema. * Handles both JSON (SendouForm) and form data (FormWithConfirm) based on Content-Type. * Returns parsed data on success, or field-level errors on validation failure. */ -export async function parseFormData({ +export async function parseFormData({ request, schema, maxBodyBytes = DEFAULT_MAX_BODY_BYTES, @@ -47,16 +50,19 @@ export async function parseFormData({ schema: T; /** Overrides {@link DEFAULT_MAX_BODY_BYTES} for forms that legitimately submit a bigger body. */ maxBodyBytes?: number; -}): Promise>> { +}): Promise>> { const data = await requestBodyToObject(request, maxBodyBytes); - const result = await schema.safeParseAsync(data); + const result = await v.safeParseAsync(schema, data); if (result.success) { - return { success: true, data: result.data }; + return { success: true, data: result.output }; } - return { success: false, fieldErrors: fieldErrorsFromZodError(result.error) }; + return { + success: false, + fieldErrors: fieldErrorsFromIssues([...result.issues]), + }; } /** Image field values collapse to their stored id; everything else passes through. */ @@ -71,13 +77,13 @@ type ResolvedImages = T extends unknown * may be a single object or a union of objects (e.g. an `_action` discriminated form). The * consuming action receives a plain id per image field and only writes it to its own entity. */ -export async function parseFormDataWithImages({ +export async function parseFormDataWithImages({ request, schema, }: { request: Request; schema: T; -}): Promise>>> { +}): Promise>>> { const result = await parseFormData({ request, schema }); if (!result.success) return result; @@ -94,26 +100,28 @@ export async function parseFormDataWithImages({ } } - return { success: true, data: data as ResolvedImages> }; + return { success: true, data: data as ResolvedImages> }; } /** - * Collects every `image()` field across a schema object or union of objects, along with each + * Collects every `image()` field across a schema object or union/variant of objects, along with each * field's `autoValidate` flag (whether its uploads bypass the moderator queue). */ function imageFields( - schema: z.ZodTypeAny, + schema: AnySchema, ): Array<{ key: string; autoValidate: boolean }> { const objects = - schema instanceof z.ZodUnion - ? (schema.options as z.ZodObject[]) - : schema instanceof z.ZodObject - ? [schema] + schema.type === "union" || schema.type === "variant" + ? ((schema as unknown as { options: AnySchema[] }).options.filter( + (option) => option.type === "object", + ) as unknown as Array<{ entries: Record }>) + : schema.type === "object" + ? [schema as unknown as { entries: Record }] : []; const fields = new Map(); for (const object of objects) { - for (const [key, fieldSchema] of Object.entries(object.shape)) { + for (const [key, fieldSchema] of Object.entries(object.entries)) { const meta = formRegistry.get(fieldSchema); if (meta?.type === "image") { fields.set(key, meta.autoValidate ?? false); diff --git a/app/form/types.ts b/app/form/types.ts index 40dd61e8d..de732c9fa 100644 --- a/app/form/types.ts +++ b/app/form/types.ts @@ -1,7 +1,8 @@ -import type { z } from "zod"; +import type * as v from "valibot"; import type { TeamSearchResult } from "~/components/elements/TeamSearch"; import type { TournamentSearchItem } from "~/components/elements/TournamentSearch"; import type { UserSearchResult } from "~/components/elements/UserSearch"; +import type { AnySyncSchema } from "~/utils/schema"; import type forms from "../../locales/en/forms.json"; import type { ImageFieldDimensions } from "./image-field"; @@ -127,7 +128,7 @@ interface FormFieldImage extends FormFieldBase { autoValidate?: boolean; } -export interface FormFieldArray +export interface FormFieldArray extends FormFieldBase { min?: number; max: number; @@ -143,9 +144,9 @@ interface FormFieldTimeRange extends FormFieldBase { endLabel?: string; } -export interface FormFieldFieldset +export interface FormFieldFieldset extends FormFieldBase { - fields: z.ZodObject; + fields: v.ObjectSchema; } interface FormFieldUserSearch extends FormFieldBase { @@ -204,9 +205,9 @@ export type FormField = | FormFieldWeaponPool<"weapon-pool"> | FormFieldImage<"image"> | FormFieldHidden<"hidden"> - | FormFieldArray<"array", z.ZodType> + | FormFieldArray<"array", AnySyncSchema> | FormFieldTimeRange<"time-range"> - | FormFieldFieldset<"fieldset", z.ZodRawShape> + | FormFieldFieldset<"fieldset", v.ObjectEntries> | FormFieldUserSearch<"user-search"> | FormFieldTournamentSearch<"tournament-search"> | FormFieldTeamSearch<"team-search"> @@ -252,7 +253,7 @@ export type SelectOption = { label: string; }; -/** Brand type to encode required options directly in Zod schema types */ +/** Brand type to encode required options directly in schema types */ export type FieldWithOptions = { _requiredOptions: TOptions }; /** @@ -279,7 +280,7 @@ type FormFieldChildrenProps = { /** Props for a typed FormField based on field name and schema */ export type TypedFormFieldProps< - TSchema extends z.ZodRawShape, + TSchema extends v.ObjectEntries, TName extends keyof TSchema & string, > = { name: TName; @@ -317,7 +318,7 @@ export type FlexibleFormFieldProps = { }; /** Typed FormField component type for a specific schema */ -export type TypedFormFieldComponent = { +export type TypedFormFieldComponent = { ( props: TypedFormFieldProps, ): React.ReactNode; @@ -360,3 +361,12 @@ export type TournamentSearchFieldOptions = { /** Exposes the resolved tournament on selection — the stored form value is only the tournament id. */ onTournamentSelected?: (tournament: TournamentSearchItem | null) => void; }; + +/** + * Object schema of a whole form or a fieldset, whether plain or wrapped in a + * pipe (e.g. a cross-field `superRefine`). Value types are inferred from + * `entries`, which such a pipe leaves untouched. + */ +export type FormObjectSchema< + TEntries extends v.ObjectEntries = v.ObjectEntries, +> = AnySyncSchema & { readonly entries: TEntries }; diff --git a/app/form/utils.test.ts b/app/form/utils.test.ts index f1b37df81..e490acd6b 100644 --- a/app/form/utils.test.ts +++ b/app/form/utils.test.ts @@ -1,5 +1,6 @@ +import * as v from "valibot"; import { describe, expect, test } from "vitest"; -import { z } from "zod"; +import { preprocess } from "~/utils/schema"; import { getNestedSchema, getNestedValue, setNestedValue } from "./utils"; describe("getNestedValue", () => { @@ -60,67 +61,98 @@ describe("setNestedValue", () => { describe("getNestedSchema", () => { test("returns schema for simple path", () => { - const schema = z.object({ name: z.string() }); + const schema = v.object({ name: v.string() }); const result = getNestedSchema(schema, "name"); - expect(result).toBeInstanceOf(z.ZodString); + expect(result?.type).toBe("string"); }); test("returns schema for nested path", () => { - const schema = z.object({ config: z.object({ name: z.string() }) }); + const schema = v.object({ config: v.object({ name: v.string() }) }); const result = getNestedSchema(schema, "config.name"); - expect(result).toBeInstanceOf(z.ZodString); + expect(result?.type).toBe("string"); }); test("unwraps nullable wrapper", () => { - const schema = z.object({ - config: z.object({ name: z.string() }).nullable(), + const schema = v.object({ + config: v.nullable(v.object({ name: v.string() })), }); const result = getNestedSchema(schema, "config.name"); - const def = result?._def ?? (result as unknown as { def?: unknown })?.def; - const typeName = - (def as { typeName?: string })?.typeName ?? - (def as { type?: string })?.type; - expect(typeName).toBe("string"); + expect(result?.type).toBe("string"); }); test("unwraps optional wrapper", () => { - const schema = z.object({ - config: z.object({ name: z.string() }).optional(), + const schema = v.object({ + config: v.optional(v.object({ name: v.string() })), }); const result = getNestedSchema(schema, "config.name"); - const def = result?._def ?? (result as unknown as { def?: unknown })?.def; - const typeName = - (def as { typeName?: string })?.typeName ?? - (def as { type?: string })?.type; - expect(typeName).toBe("string"); + expect(result?.type).toBe("string"); }); test("returns undefined for invalid path", () => { - const schema = z.object({ name: z.string() }); + const schema = v.object({ name: v.string() }); expect(getNestedSchema(schema, "missing.path")).toBe(undefined); }); test("returns undefined when path goes through non-object", () => { - const schema = z.object({ name: z.string() }); + const schema = v.object({ name: v.string() }); expect(getNestedSchema(schema, "name.invalid")).toBe(undefined); }); test("returns schema for array element path", () => { - const schema = z.object({ - items: z.array(z.object({ name: z.string() })), + const schema = v.object({ + items: v.array(v.object({ name: v.string() })), }); const result = getNestedSchema(schema, "items[0].name"); - expect(result).toBeInstanceOf(z.ZodString); + expect(result?.type).toBe("string"); }); test("returns schema for array element path with min/max", () => { - const schema = z.object({ - items: z - .array(z.object({ name: z.string() })) - .min(1) - .max(10), + const schema = v.object({ + items: v.pipe( + v.array(v.object({ name: v.string() })), + v.minLength(1), + v.maxLength(10), + ), }); const result = getNestedSchema(schema, "items[0].name"); - expect(result).toBeInstanceOf(z.ZodString); + expect(result?.type).toBe("string"); + }); + + test("drills through a preprocess pipe into a nested object", () => { + const schema = v.object({ + config: preprocess((val) => val, v.object({ name: v.string() })), + }); + const result = getNestedSchema(schema, "config.name"); + expect(result?.type).toBe("string"); + }); + + test("drills through a preprocess pipe into a nested array", () => { + const schema = v.object({ + items: preprocess((val) => val, v.array(v.object({ name: v.string() }))), + }); + const result = getNestedSchema(schema, "items[0].name"); + expect(result?.type).toBe("string"); + }); + + test("drills through a preprocess pipe inside an array item", () => { + const schema = v.object({ + items: v.array(preprocess((val) => val, v.object({ name: v.string() }))), + }); + const result = getNestedSchema(schema, "items[0].name"); + expect(result?.type).toBe("string"); + }); + + test("drills through a preprocess pipe wrapping a validated object", () => { + const schema = v.object({ + config: preprocess( + (val) => val, + v.pipe( + v.object({ name: v.string() }), + v.check((val) => val.name.length > 0), + ), + ), + }); + const result = getNestedSchema(schema, "config.name"); + expect(result?.type).toBe("string"); }); }); diff --git a/app/form/utils.ts b/app/form/utils.ts index 8692fa3da..0f366dcb7 100644 --- a/app/form/utils.ts +++ b/app/form/utils.ts @@ -1,14 +1,16 @@ -import type { z } from "zod"; +import * as v from "valibot"; +import type { AnySyncSchema } from "~/utils/schema"; import { getFormFieldMetadata } from "./fields"; -import type { FormField } from "./types"; +import type { FormField, FormObjectSchema } from "./types"; export function infoMessageId(fieldId: string) { return `${fieldId}-info`; } /** - * Builds a form field name (e.g. `members[0].userId`) from a Zod issue path so - * that server- and client-side validation errors key fields identically. + * Builds a form field name (e.g. `members[0].userId`) from a validation issue + * path so that server- and client-side validation errors key fields + * identically. */ export function buildFieldPath(path: PropertyKey[]): string | null { if (path.length === 0) return null; @@ -23,6 +25,11 @@ export function buildFieldPath(path: PropertyKey[]): string | null { .join(""); } +/** Path of a valibot issue as plain keys, for {@link buildFieldPath}. */ +export function issuePathKeys(issue: v.BaseIssue): PropertyKey[] { + return (issue.path ?? []).map((item) => (item as { key: PropertyKey }).key); +} + export function getNestedValue( obj: Record, path: string, @@ -104,9 +111,9 @@ export function fieldsetDefaults( ): Record { if (fieldsetMeta.type !== "fieldset") return {}; - const shape = fieldsetMeta.fields.shape as Record; + const entries = fieldsetMeta.fields.entries as Record; const result: Record = {}; - for (const [key, fieldSchema] of Object.entries(shape)) { + for (const [key, fieldSchema] of Object.entries(entries)) { const fieldMeta = getFormFieldMetadata(fieldSchema); if (fieldMeta) result[key] = fieldMeta.initialValue; } @@ -123,7 +130,7 @@ export function fieldsetDefaults( * affect submitting a pristine form. */ export function seedArrayItemDefaults( - schema: z.ZodObject, + schema: FormObjectSchema, values: Record, name: string, ): Record { @@ -147,27 +154,25 @@ export function seedArrayItemDefaults( } export function getNestedSchema( - schema: z.ZodObject, + schema: FormObjectSchema, path: string, -): z.ZodType | undefined { +): AnySyncSchema | undefined { const parts = parsePath(path); - let current: z.ZodType = schema; + let current: AnySyncSchema = schema; for (const part of parts) { const unwrapped = unwrapSchema(current); if (typeof part === "number") { - const def = unwrapped._def as { - type?: string; - element?: z.ZodType; - }; - if (def.type === "array" && def.element) { - current = def.element; + if (unwrapped.type === "array" && "item" in unwrapped) { + current = (unwrapped as unknown as { item: AnySyncSchema }).item; } else { return undefined; } - } else if ("shape" in unwrapped && unwrapped.shape) { - const nextSchema = (unwrapped.shape as Record)[part]; + } else if ("entries" in unwrapped) { + const nextSchema = ( + unwrapped as unknown as { entries: Record } + ).entries[part]; if (!nextSchema) return undefined; current = nextSchema; } else { @@ -178,25 +183,34 @@ export function getNestedSchema( return current; } -function unwrapSchema(schema: z.ZodType): z.ZodType { - const def = schema._def ?? (schema as unknown as { def: unknown }).def; - const typeName = - (def as { typeName?: string }).typeName ?? (def as { type?: string }).type; - +/** + * Unwraps optional/nullable/nullish wrappers and pipes (e.g. `preprocess`) + * down to the schema that carries the structural properties (`entries`/`item`). + * A pipe over a plain schema needs no unwrapping since it spreads that + * schema's own properties. + */ +function unwrapSchema(schema: AnySyncSchema): AnySyncSchema { if ( - typeName === "ZodNullable" || - typeName === "ZodOptional" || - typeName === "ZodDefault" || - typeName === "nullable" || - typeName === "optional" || - typeName === "default" + (schema.type === "optional" || + schema.type === "nullable" || + schema.type === "nullish") && + "wrapped" in schema ) { - const inner = (def as unknown as { innerType: z.ZodType }).innerType; - return unwrapSchema(inner); + return unwrapSchema( + (schema as unknown as { wrapped: AnySyncSchema }).wrapped, + ); } - if (typeName === "ZodEffects" || typeName === "effects") { - return unwrapSchema((def as unknown as { schema: z.ZodType }).schema); + + if ("pipe" in schema) { + const pipeItems = (schema as unknown as { pipe: Array<{ kind: string }> }) + .pipe; + const schemas = pipeItems.filter((item) => item.kind === "schema"); + const last = schemas[schemas.length - 1]; + if (last && last !== pipeItems[0]) { + return unwrapSchema(last as unknown as AnySyncSchema); + } } + return schema; } @@ -205,16 +219,16 @@ export function errorMessageId(fieldId: string) { } export function validateField( - schema: z.ZodObject, + schema: FormObjectSchema, name: string, value: unknown, ): string | undefined { const fieldSchema = name.includes(".") ? getNestedSchema(schema, name) - : (schema.shape[name] as z.ZodType | undefined); + : (schema.entries[name] as AnySyncSchema | undefined); if (!fieldSchema) return undefined; - const result = fieldSchema.safeParse(value); + const result = v.safeParse(fieldSchema, value); if (result.success) return undefined; // `array`/`fieldset` fields render each child as its own FormField with its @@ -226,19 +240,24 @@ export function validateField( const childrenRenderOwnErrors = fieldMeta?.type === "array" || fieldMeta?.type === "fieldset"; const issue = childrenRenderOwnErrors - ? result.error.issues.find((i) => i.path.length === 0) - : result.error.issues[0]; + ? result.issues.find((i) => !i.path || i.path.length === 0) + : result.issues[0]; if (!issue) return undefined; const valueIsEmpty = value === null || value === undefined || value === ""; if ( valueIsEmpty && - (issue.code === "invalid_type" || issue.code === "too_small") + (issue.kind === "schema" || + issue.type === "min_length" || + issue.type === "min_value") ) { return "forms:errors.required"; } - if (issue.code === "too_small" && issue.minimum === 1) { + if ( + (issue.type === "min_length" || issue.type === "min_value") && + issue.requirement === 1 + ) { return "forms:errors.required"; } diff --git a/app/hooks/useActionSubmit.ts b/app/hooks/useActionSubmit.ts index 24060c9c9..f5a6bd9dd 100644 --- a/app/hooks/useActionSubmit.ts +++ b/app/hooks/useActionSubmit.ts @@ -1,10 +1,10 @@ import { type FetcherWithComponents, useFetcher } from "react-router"; -import type { z } from "zod"; import { type ActionsOf, type FieldsOf, serializeFieldValue, } from "~/utils/action-schemas"; +import type { AnySchema } from "~/utils/schema"; interface UseActionSubmitOptions { /** Route to submit to. Defaults to the current route. */ @@ -17,13 +17,13 @@ interface UseActionSubmitOptions { /** * Programmatic counterpart of ``: submits an `_action` mutation - * from an event handler, type checked against the route's zod action schema. + * from an event handler, type checked against the route's action schema. * * @example * const { submit } = useActionSubmit(deleteFriendSchema); * submit("DELETE_FRIEND", { friendshipId }); */ -export function useActionSubmit( +export function useActionSubmit( _schema: TSchema, opts?: UseActionSubmitOptions, ) { diff --git a/app/hooks/useRecentlyReportedWeapons.ts b/app/hooks/useRecentlyReportedWeapons.ts index 2651972b9..bfa1c9feb 100644 --- a/app/hooks/useRecentlyReportedWeapons.ts +++ b/app/hooks/useRecentlyReportedWeapons.ts @@ -1,16 +1,16 @@ -import { z } from "zod"; +import * as v from "valibot"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import { usePersistedState } from "~/modules/persisted-state/hooks"; import * as PersistedState from "~/modules/persisted-state/persisted-state"; -import { numericEnum } from "~/utils/zod"; +import { numericEnum } from "~/utils/schema"; const MAX_REPORTED_WEAPONS = 7; export const recentlyReportedWeaponsPersisted = PersistedState.define({ key: "sq__recently-reported-weapons", storage: "local", - schema: z.array(numericEnum(mainWeaponIds)), + schema: v.array(numericEnum(mainWeaponIds)), default: [], }); diff --git a/app/hooks/useReloadOnNewDeploy.ts b/app/hooks/useReloadOnNewDeploy.ts index 3e331afa5..e8d874f10 100644 --- a/app/hooks/useReloadOnNewDeploy.ts +++ b/app/hooks/useReloadOnNewDeploy.ts @@ -1,12 +1,12 @@ import * as React from "react"; -import { z } from "zod"; +import * as v from "valibot"; import * as PersistedState from "~/modules/persisted-state/persisted-state"; import { GIT_COMMIT } from "~/utils/git-commit"; const reloadedForCommitPersisted = PersistedState.define({ key: "reloadedForCommit", storage: "session", - schema: z.string(), + schema: v.string(), default: "", }); diff --git a/app/hooks/useSpoilerFree.ts b/app/hooks/useSpoilerFree.ts index 02b996562..1547c7a40 100644 --- a/app/hooks/useSpoilerFree.ts +++ b/app/hooks/useSpoilerFree.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { useUser } from "~/features/auth/core/user"; import { usePersistedState } from "~/modules/persisted-state/hooks"; import * as PersistedState from "~/modules/persisted-state/persisted-state"; @@ -6,7 +6,7 @@ import * as PersistedState from "~/modules/persisted-state/persisted-state"; export const revealedTournamentsPersisted = PersistedState.define({ key: "spoilerFreeRevealed", storage: "session", - schema: z.array(z.number()), + schema: v.array(v.number()), default: [], }); diff --git a/app/hooks/useUnseenFriendRequests.ts b/app/hooks/useUnseenFriendRequests.ts index ff325e09e..2927ff9ab 100644 --- a/app/hooks/useUnseenFriendRequests.ts +++ b/app/hooks/useUnseenFriendRequests.ts @@ -1,5 +1,5 @@ import * as R from "remeda"; -import { z } from "zod"; +import * as v from "valibot"; import { usePersistedState } from "~/modules/persisted-state/hooks"; import * as PersistedState from "~/modules/persisted-state/persisted-state"; @@ -8,7 +8,7 @@ const MAX_STORED_IDS = 200; export const seenFriendRequestsPersisted = PersistedState.define({ key: "seen-friend-requests", storage: "local", - schema: z.array(z.number()), + schema: v.array(v.number()), default: [], }); diff --git a/app/modules/patreon/schema.ts b/app/modules/patreon/schema.ts index 3eaeee544..f166d6355 100644 --- a/app/modules/patreon/schema.ts +++ b/app/modules/patreon/schema.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as v from "valibot"; import { TIER_1_ID, TIER_2_ID, @@ -7,68 +7,74 @@ import { UNKNOWN_TIER_ID, } from "./constants"; -export const patreonRateLimitSchema = z.object({ - errors: z.array( - z.object({ - retry_after_seconds: z.number().optional(), +export const patreonRateLimitSchema = v.object({ + errors: v.array( + v.object({ + retry_after_seconds: v.optional(v.number()), }), ), }); -export const patronResponseSchema = z.object({ - data: z.array( - z.object({ - attributes: z.object({ - pledge_relationship_start: z.string().nullish(), +export const patronResponseSchema = v.object({ + data: v.array( + v.object({ + attributes: v.object({ + pledge_relationship_start: v.nullish(v.string()), }), - id: z.string(), - relationships: z.object({ - currently_entitled_tiers: z.object({ - data: z.array( - z.object({ - id: z.enum([ + id: v.string(), + relationships: v.object({ + currently_entitled_tiers: v.object({ + data: v.array( + v.object({ + id: v.picklist([ TIER_1_ID, TIER_2_ID, TIER_3_ID, TIER_4_ID, UNKNOWN_TIER_ID, ]), - type: z.string(), + type: v.string(), }), ), }), - user: z.object({ - data: z.object({ id: z.string(), type: z.string() }), - links: z.object({ related: z.string() }), + user: v.object({ + data: v.object({ id: v.string(), type: v.string() }), + links: v.object({ related: v.string() }), }), }), - type: z.string(), + type: v.string(), }), ), - included: z - .array( - z.object({ - attributes: z.object({ - social_connections: z - .object({ - discord: z - .object({ - user_id: z.string(), - }) - .nullish(), - }) - .nullish(), + included: v.optional( + v.nullable( + v.array( + v.object({ + attributes: v.object({ + social_connections: v.optional( + v.nullable( + v.object({ + discord: v.optional( + v.nullable( + v.object({ + user_id: v.string(), + }), + ), + ), + }), + ), + ), + }), + id: v.string(), + type: v.string(), }), - id: z.string(), - type: z.string(), - }), - ) - .nullish(), - links: z.object({ next: z.string() }).nullish(), - meta: z.object({ - pagination: z.object({ - cursors: z.object({ next: z.string().nullish() }), - total: z.number(), + ), + ), + ), + links: v.nullish(v.object({ next: v.string() })), + meta: v.object({ + pagination: v.object({ + cursors: v.object({ next: v.nullish(v.string()) }), + total: v.number(), }), }), }); diff --git a/app/modules/patreon/updater.ts b/app/modules/patreon/updater.ts index 623003b6f..58fbecdce 100644 --- a/app/modules/patreon/updater.ts +++ b/app/modules/patreon/updater.ts @@ -1,4 +1,4 @@ -import type { z } from "zod"; +import * as v from "valibot"; import { ServerConfig } from "~/config.server"; import { STAFF_DISCORD_IDS } from "~/features/admin/admin-constants"; import * as TrophyRepository from "~/features/trophies/TrophyRepository.server"; @@ -77,10 +77,10 @@ async function fetchPatronData(urlToFetch: string) { ); } - const parsed = patreonRateLimitSchema.safeParse(await response.json()); + const parsed = v.safeParse(patreonRateLimitSchema, await response.json()); const retryAfterSeconds = Math.min( parsed.success - ? (parsed.data.errors[0]?.retry_after_seconds ?? + ? (parsed.output.errors[0]?.retry_after_seconds ?? DEFAULT_RETRY_AFTER_SECONDS) : DEFAULT_RETRY_AFTER_SECONDS, MAX_RETRY_AFTER_SECONDS, @@ -99,7 +99,7 @@ async function fetchPatronData(urlToFetch: string) { ); } - return patronResponseSchema.parse(await response.json()); + return v.parse(patronResponseSchema, await response.json()); } throw new Error("Unexpected end of fetch retry loop"); @@ -112,7 +112,7 @@ function sleep(ms: number) { function parsePatronData({ data, included, -}: z.infer) { +}: v.InferOutput) { const patronsWithIds: Array< { patreonId: string; diff --git a/app/modules/persisted-state/hooks.browser.test.tsx b/app/modules/persisted-state/hooks.browser.test.tsx index fb721586d..fb7788fb1 100644 --- a/app/modules/persisted-state/hooks.browser.test.tsx +++ b/app/modules/persisted-state/hooks.browser.test.tsx @@ -1,20 +1,20 @@ +import * as v from "valibot"; import { afterEach, describe, expect, test } from "vitest"; import { render } from "vitest-browser-react"; -import { z } from "zod"; import { usePersistedMapState, usePersistedState } from "./hooks"; import * as PersistedState from "./persisted-state"; const recentIds = PersistedState.define({ key: "test-recent-ids", storage: "local", - schema: z.array(z.number()), + schema: v.array(v.number()), default: [], }); const counts = PersistedState.defineMap({ keyPrefix: "test-counts__", storage: "local", - schema: z.number(), + schema: v.number(), default: 0, }); diff --git a/app/modules/persisted-state/persisted-state.test.ts b/app/modules/persisted-state/persisted-state.test.ts index b2fbb765c..e4ece4bd3 100644 --- a/app/modules/persisted-state/persisted-state.test.ts +++ b/app/modules/persisted-state/persisted-state.test.ts @@ -1,5 +1,5 @@ +import * as v from "valibot"; import { describe, expect, test } from "vitest"; -import { z } from "zod"; import * as PersistedState from "./persisted-state"; import { assertDecodesToDefault, @@ -9,28 +9,28 @@ import { const numberList = PersistedState.define({ key: "test-number-list", storage: "local", - schema: z.array(z.number()), + schema: v.array(v.number()), default: [], }); const searchType = PersistedState.define({ key: "test-search-type", storage: "local", - schema: z.enum(["weapons", "users"]), + schema: v.picklist(["weapons", "users"]), default: "weapons", }); const dismissed = PersistedState.define({ key: "test-dismissed", storage: "local", - schema: z.boolean(), + schema: v.boolean(), default: false, }); const counts = PersistedState.defineMap({ keyPrefix: "test-counts__", storage: "local", - schema: z.number(), + schema: v.number(), default: 0, }); diff --git a/app/modules/persisted-state/persisted-state.ts b/app/modules/persisted-state/persisted-state.ts index 8fc54fb6e..6e753475c 100644 --- a/app/modules/persisted-state/persisted-state.ts +++ b/app/modules/persisted-state/persisted-state.ts @@ -1,4 +1,5 @@ -import type { z } from "zod"; +import * as v from "valibot"; +import type { AnySyncSchema } from "~/utils/schema"; const readCaches = new WeakMap< object, @@ -34,12 +35,12 @@ export interface PersistedMapDefinition extends DefinitionBase { * Decoding is total: the default resolves for missing or malformed values, * legacy plain-string values are accepted where the schema allows them. */ -export function define(options: { +export function define(options: { key: string; storage: StorageKind; schema: S; - default: z.output; -}): PersistedDefinition> { + default: v.InferOutput; +}): PersistedDefinition> { return { key: options.key, storage: options.storage, @@ -52,12 +53,12 @@ export function define(options: { * Declares a keyed family of persisted values sharing a storage key prefix, * for maps whose entries are written independently (e.g. per chat room). */ -export function defineMap(options: { +export function defineMap(options: { keyPrefix: string; storage: StorageKind; schema: S; - default: z.output; -}): PersistedMapDefinition> { + default: v.InferOutput; +}): PersistedMapDefinition> { return { keyPrefix: options.keyPrefix, storage: options.storage, @@ -181,14 +182,17 @@ export function prependToRecentList( ); } -function codec(schema: S, defaultValue: z.output) { +function codec( + schema: S, + defaultValue: v.InferOutput, +) { return { - decode: (raw: string | null): z.output => { + decode: (raw: string | null): v.InferOutput => { if (raw === null) return defaultValue; - const parsed = schema.safeParse(rawToJson(raw)); - return parsed.success ? parsed.data : defaultValue; + const parsed = v.safeParse(schema, rawToJson(raw)); + return parsed.success ? parsed.output : defaultValue; }, - encode: (value: z.output) => JSON.stringify(value), + encode: (value: v.InferOutput) => JSON.stringify(value), }; } diff --git a/app/modules/search-params/hooks.browser.test.tsx b/app/modules/search-params/hooks.browser.test.tsx index b29411605..12d3d19ce 100644 --- a/app/modules/search-params/hooks.browser.test.tsx +++ b/app/modules/search-params/hooks.browser.test.tsx @@ -1,21 +1,27 @@ import * as React from "react"; import { createBrowserRouter, RouterProvider } from "react-router"; +import * as v from "valibot"; import { afterEach, describe, expect, test } from "vitest"; import { render } from "vitest-browser-react"; -import { z } from "zod"; import { useSearchParam, useSearchParamsTyped } from "./hooks"; import * as SearchParams from "./search-params"; import { SP } from "./search-params"; const definition = SearchParams.define({ - page: SP.param(z.number().int().min(1), { default: 1, loader: true }), - filters: SP.json(z.object({ q: z.string() }), { + page: SP.param(v.pipe(v.number(), v.integer(), v.minValue(1)), { + default: 1, + loader: true, + }), + filters: SP.json(v.object({ q: v.string() }), { default: { q: "" }, loader: true, resets: ["page"], }), - view: SP.param(z.enum(["list", "grid"]), { default: "list", loader: false }), - other: SP.param(z.number(), { default: 0, loader: false }), + view: SP.param(v.picklist(["list", "grid"]), { + default: "list", + loader: false, + }), + other: SP.param(v.number(), { default: 0, loader: false }), }); let loaderCalls = 0; diff --git a/app/modules/search-params/search-params.test.ts b/app/modules/search-params/search-params.test.ts index d1219c925..a54549cdd 100644 --- a/app/modules/search-params/search-params.test.ts +++ b/app/modules/search-params/search-params.test.ts @@ -1,6 +1,6 @@ import type { ShouldRevalidateFunction } from "react-router"; +import * as v from "valibot"; import { describe, expect, test } from "vitest"; -import { z } from "zod"; import * as SearchParams from "./search-params"; import { SP } from "./search-params"; import { @@ -9,26 +9,34 @@ import { } from "./search-params-test-utils"; const testDefinition = SearchParams.define({ - limit: SP.param(z.number().int().min(1).max(100), { - default: 24, + limit: SP.param( + v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)), + { + default: 24, + loader: true, + }, + ), + name: SP.param(v.pipe(v.string(), v.maxLength(20)), { + default: "", loader: true, }), - name: SP.param(z.string().max(20), { default: "", loader: true }), - enabled: SP.param(z.boolean(), { default: false, loader: false }), - mode: SP.param(z.enum(["TW", "SZ", "TC"]), { + enabled: SP.param(v.boolean(), { default: false, loader: false }), + mode: SP.param(v.picklist(["TW", "SZ", "TC"]), { default: "TW", loader: true, }), - season: SP.param(z.number().int().nullable(), { loader: true }), - ids: SP.param(z.array(z.number().int().positive()), { + season: SP.param(v.nullable(v.pipe(v.number(), v.integer())), { + loader: true, + }), + ids: SP.param(v.array(v.pipe(v.number(), v.integer(), v.gtValue(0))), { default: [], loader: false, }), filters: SP.json( - z.object({ minValue: z.number(), tags: z.array(z.string()) }), + v.object({ minValue: v.number(), tags: v.array(v.string()) }), { default: { minValue: 0, tags: [] }, loader: true, resets: ["limit"] }, ), - blob: SP.json(z.object({ text: z.string() }), { + blob: SP.json(v.object({ text: v.string() }), { default: { text: "" }, loader: false, compress: true, @@ -112,7 +120,7 @@ describe("SearchParams.define", () => { test("decodes legacy JSON-encoded arrays", () => { const definitionWithModes = SearchParams.define({ - modes: SP.param(z.array(z.enum(["SZ", "TC", "RM", "CB"])), { + modes: SP.param(v.array(v.picklist(["SZ", "TC", "RM", "CB"])), { default: ["SZ", "TC", "RM", "CB"], loader: false, }), @@ -143,19 +151,25 @@ describe("SearchParams.define", () => { test("rejects schemas outside the derivation table at define time", () => { expect(() => - SP.param(z.object({ a: z.string() }) as any, { + SP.param(v.object({ a: v.string() }) as any, { default: { a: "" }, loader: true, }), ).toThrow(/derive/); expect(() => - SP.param(z.string().transform((s) => s.length) as any, { - default: 0, - loader: true, - }), + SP.param( + v.pipe( + v.string(), + v.transform((s) => s.length), + ) as any, + { + default: 0, + loader: true, + }, + ), ).toThrow(/derive/); expect(() => - SP.param(z.array(z.array(z.number())) as any, { + SP.param(v.array(v.array(v.number())) as any, { default: [], loader: true, }), @@ -163,8 +177,10 @@ describe("SearchParams.define", () => { }); test("defaults .nullable() params to null without declaring it", () => { - const omitted = SP.param(z.number().int().nullable(), { loader: true }); - const declared = SP.param(z.number().int().nullable(), { + const omitted = SP.param(v.nullable(v.pipe(v.number(), v.integer())), { + loader: true, + }); + const declared = SP.param(v.nullable(v.pipe(v.number(), v.integer())), { default: null, loader: true, }); @@ -178,31 +194,26 @@ describe("SearchParams.define", () => { test("rejects .optional() and non-null defaults for .nullable()", () => { expect(() => - SP.param(z.number().optional() as any, { default: 1, loader: true }), + SP.param(v.optional(v.number()) as any, { default: 1, loader: true }), ).toThrow(/nullable/); expect(() => - SP.param(z.number().nullable(), { default: 1 as any, loader: true }), + SP.param(v.nullable(v.number()), { default: 1 as any, loader: true }), ).toThrow(/null as its default/); }); - test("supports SP.custom codecs with total decode via issues", () => { - const isoDate = z.codec(z.string(), z.date(), { - decode: (value, payload) => { + test("supports SP.custom codecs with total decode", () => { + const isoDate = SearchParams.codec(v.date(), { + decode: (value) => { const date = new Date(value); - if (Number.isNaN(date.getTime())) { - payload.issues.push({ - code: "custom", - message: "invalid date", - input: value, - }); - return z.NEVER; - } - return date; + return Number.isNaN(date.getTime()) ? undefined : date; }, encode: (date) => date.toISOString(), }); const customDefinition = SearchParams.define({ - from: SP.custom(isoDate.nullable(), { default: null, loader: true }), + from: SP.custom(SearchParams.nullableCodec(isoDate), { + default: null, + loader: true, + }), }); const value = new Date("2024-05-01T12:00:00.000Z"); @@ -220,7 +231,7 @@ describe("SearchParams.define", () => { test("rejects resets pointing at unknown params", () => { expect(() => SearchParams.define({ - a: SP.param(z.number(), { default: 0, loader: true, resets: ["b"] }), + a: SP.param(v.number(), { default: 0, loader: true, resets: ["b"] }), }), ).toThrow(/unknown param/); }); @@ -310,7 +321,7 @@ describe("SearchParams.href", () => { test("encodes arrays as repeated keys and empty arrays as one empty value", () => { const definitionWithDefault = SearchParams.define({ - modes: SP.param(z.array(z.enum(["SZ", "TC"])), { + modes: SP.param(v.array(v.picklist(["SZ", "TC"])), { default: ["SZ", "TC"], loader: false, }), diff --git a/app/modules/search-params/search-params.ts b/app/modules/search-params/search-params.ts index 9ea539439..41b98e27f 100644 --- a/app/modules/search-params/search-params.ts +++ b/app/modules/search-params/search-params.ts @@ -1,8 +1,10 @@ import type { ShouldRevalidateFunction } from "react-router"; import { isDeepEqual } from "remeda"; -import { z } from "zod"; +import * as v from "valibot"; import { compressToBase64, decompressFromBase64 } from "~/utils/compression"; +type AnySchema = v.GenericSchema; + const COMPRESSED_PREFIX = "lz~"; const ESCAPED_PREFIX = "lz~~"; const DECODE_CACHE_MAX_SIZE = 300; @@ -253,6 +255,56 @@ export function pickRelevantSearch(keys: string[], search: string): string { return picked.toString(); } +/** Bidirectional URL encoding for an `SP.custom` param. */ +export interface ParamCodec { + /** Decodes a plain URL value; `undefined` means malformed, resolving the param to its default. */ + decode: (plain: string) => Value | undefined; + /** Encodes a value to its canonical plain URL form. Must succeed for every value of the type. */ + encode: (value: Value) => string; +} + +/** + * Creates a {@link ParamCodec} whose decode result is validated against + * `schema`. The `decode` implementation returns `undefined` (or any value the + * schema rejects) for malformed input. + */ +export function codec( + schema: TSchema, + impl: { + decode: (encoded: string) => unknown; + encode: (value: v.InferOutput) => string; + }, +): ParamCodec> { + return { + decode: (encoded) => { + const parsed = v.safeParse(schema, impl.decode(encoded)); + return parsed.success ? parsed.output : undefined; + }, + encode: impl.encode, + }; +} + +/** + * Widens a codec to also accept `null` as its value. `null` must be the + * param's default, so it never reaches `encode` (encoding the default omits + * the param from the URL). + */ +export function nullableCodec( + inner: ParamCodec, +): ParamCodec { + return { + decode: inner.decode, + encode: (value) => { + if (value === null) { + throw new Error( + "Cannot encode null; a nullable search param's default is null, which is omitted from the URL", + ); + } + return inner.encode(value); + }, + }; +} + /** * Param declaration helpers. `SP.param` is the canonical declaration deriving * the URL encoding from the value schema; `SP.json` and `SP.custom` are the @@ -260,42 +312,43 @@ export function pickRelevantSearch(keys: string[], search: string): string { */ export const SP = { /** - * Declares a param whose URL encoding is derived from the zod value + * Declares a param whose URL encoding is derived from the valibot value * schema's type tree. Supported shapes: strings, numbers, booleans, string * and number enums/literals, same-base-type unions, arrays of those * (encoded as repeated keys) and a top-level `.nullable()` wrapper (`null` * encodes as param absent, so `default` is omitted for those). Anything else * is a `define()`-time error — use `SP.json` or `SP.custom` instead. */ - param( + param( schema: S, - opts: ParamOptions>, - ): ParamDef> { + opts: ParamOptions>, + ): ParamDef> { const resolved = resolveOptions(opts); - let core: z.ZodType = schema; + let core: AnySchema = schema; - if (core instanceof z.ZodOptional) { + if (core.type === "optional" || core.type === "nullish") { throw new Error( - "Search params use .nullable() instead of .optional() (null encodes as param absent)", + "Search params use v.nullable() instead of v.optional() (null encodes as param absent)", ); } - if (core instanceof z.ZodNullable) { + if (core.type === "nullable") { if (resolved.default !== null) { throw new Error( - "A .nullable() search param must have null as its default, otherwise null and the default could not be told apart in the URL", + "A v.nullable() search param must have null as its default, otherwise null and the default could not be told apart in the URL", ); } - core = core.unwrap() as z.ZodType; + core = (core as unknown as { wrapped: AnySchema }).wrapped; } - if (core instanceof z.ZodArray) { - const itemBase = deriveScalarBase(core.element as z.ZodType); + if (core.type === "array") { + const itemSchema = (core as unknown as { item: AnySchema }).item; + const itemBase = deriveScalarBase(itemSchema); if (!itemBase) { throw new Error( - `Cannot derive an URL encoding for the array item schema of a search param (got ${describeSchema(core.element as z.ZodType)}). Use SP.json or SP.custom.`, + `Cannot derive an URL encoding for the array item schema of a search param (got ${describeSchema(itemSchema)}). Use SP.json or SP.custom.`, ); } - return arrayParam(schema, core, itemBase, resolved); + return arrayParam(schema, itemSchema, itemBase, resolved); } const base = deriveScalarBase(core); @@ -310,20 +363,21 @@ export const SP = { /** Declares the 1-based `page` param of a paginated route, as `useSearchParamPagination` expects it. */ page(opts?: { max?: number; resets?: string[] }): ParamDef { return SP.param( - z - .number() - .int() - .min(1) - .max(opts?.max ?? DEFAULT_MAX_PAGE), + v.pipe( + v.number(), + v.integer(), + v.minValue(1), + v.maxValue(opts?.max ?? DEFAULT_MAX_PAGE), + ), { default: 1, loader: true, resets: opts?.resets }, ); }, /** Declares a param encoded as `JSON.stringify` in a single value. For objects and whole-array-as-one-param values. */ - json( + json( schema: S, - opts: ParamOptions>, - ): ParamDef> { + opts: ParamOptions>, + ): ParamDef> { const resolved = resolveOptions(opts); return { @@ -338,20 +392,20 @@ export const SP = { } catch { return resolved.default; } - const parsed = schema.safeParse(json); - return parsed.success ? parsed.data : resolved.default; + const parsed = v.safeParse(schema, json); + return parsed.success ? parsed.output : resolved.default; }, encodePlain: (value) => [JSON.stringify(value)], }; }, /** - * Escape hatch: declares a param from a `z.codec(z.string(), valueSchema, ...)` + * Escape hatch: declares a param from a {@link ParamCodec} (see `codec`) * passed directly. The codec's `decode` may accept legacy formats while * `encode` always emits the canonical one. */ custom( - codec: z.ZodType, + paramCodec: ParamCodec, opts: ParamOptions, ): ParamDef { const resolved = resolveOptions(opts); @@ -362,18 +416,10 @@ export const SP = { if (values.length === 0) return resolved.default; const plain = unwrapValue(values[0]); if (plain === DECODE_FAILED) return resolved.default; - const parsed = z.safeDecode(codec, plain); - return parsed.success ? parsed.data : resolved.default; - }, - encodePlain: (value) => { - const encoded = z.safeEncode(codec, value); - if (!encoded.success || typeof encoded.data !== "string") { - throw new Error( - "Encoding a search param value failed; SP.custom codecs must encode every value of their type", - ); - } - return [encoded.data]; + const decoded = paramCodec.decode(plain); + return decoded === undefined ? resolved.default : decoded; }, + encodePlain: (value) => [paramCodec.encode(value)], }; }, }; @@ -403,7 +449,7 @@ function baseDef( } function scalarParam( - schema: z.ZodType, + schema: AnySchema, base: ScalarBase, opts: ResolvedParamOptions, ): ParamDef { @@ -415,21 +461,19 @@ function scalarParam( if (plain === DECODE_FAILED) return opts.default; const candidate = plainToScalar(plain, base); if (candidate === DECODE_FAILED) return opts.default; - const parsed = schema.safeParse(candidate); - return parsed.success ? (parsed.data as T) : opts.default; + const parsed = v.safeParse(schema, candidate); + return parsed.success ? (parsed.output as T) : opts.default; }, encodePlain: (value) => [String(value)], }; } function arrayParam( - schema: z.ZodType, - arraySchema: z.ZodArray, + schema: AnySchema, + itemSchema: AnySchema, itemBase: ScalarBase, opts: ResolvedParamOptions, ): ParamDef { - const itemSchema = arraySchema.element as z.ZodType; - return { ...baseDef(opts), decodeValues: (values) => { @@ -463,12 +507,12 @@ function arrayParam( for (const item of items) { const candidate = plainToScalar(item, itemBase); if (candidate === DECODE_FAILED) continue; - const parsed = itemSchema.safeParse(candidate); - if (parsed.success) members.push(parsed.data); + const parsed = v.safeParse(itemSchema, candidate); + if (parsed.success) members.push(parsed.output); } - const parsed = schema.safeParse(members); - return parsed.success ? (parsed.data as T) : opts.default; + const parsed = v.safeParse(schema, members); + return parsed.success ? (parsed.output as T) : opts.default; }, encodePlain: (value) => { const items = value as unknown[]; @@ -495,19 +539,22 @@ function plainToScalar( return DECODE_FAILED; } -function deriveScalarBase(schema: z.ZodType): ScalarBase | null { - if (schema instanceof z.ZodString) return "string"; - if (schema instanceof z.ZodNumber) return "number"; - if (schema instanceof z.ZodBoolean) return "boolean"; +function deriveScalarBase(schema: AnySchema): ScalarBase | null { + if (hasNonValidationPipeItems(schema)) return null; - if (schema instanceof z.ZodEnum) { - return uniformTypeOf(schema.options); + if (schema.type === "string") return "string"; + if (schema.type === "number") return "number"; + if (schema.type === "boolean") return "boolean"; + + if (schema.type === "picklist" || schema.type === "enum") { + return uniformTypeOf((schema as unknown as { options: unknown[] }).options); } - if (schema instanceof z.ZodLiteral) { - return uniformTypeOf(Array.from(schema.values)); + if (schema.type === "literal") { + return uniformTypeOf([(schema as unknown as { literal: unknown }).literal]); } - if (schema instanceof z.ZodUnion) { - const bases = (schema.options as z.ZodType[]).map(deriveScalarBase); + if (schema.type === "union") { + const options = (schema as unknown as { options: AnySchema[] }).options; + const bases = options.map(deriveScalarBase); if (bases[0] && bases.every((base) => base === bases[0])) { return bases[0]; } @@ -517,6 +564,28 @@ function deriveScalarBase(schema: z.ZodType): ScalarBase | null { return null; } +/** + * A pipe may only add validations, metadata and type-preserving + * transformations (e.g. `v.trim()`) on top of its base schema. A custom + * transform or a nested schema changes the value's type, so the URL encoding + * cannot be derived from the base type. + */ +function hasNonValidationPipeItems(schema: AnySchema): boolean { + if (!("pipe" in schema)) return false; + + const pipeItems = ( + schema as unknown as { pipe: Array<{ kind: string; type: string }> } + ).pipe; + return pipeItems + .slice(1) + .some( + (item) => + item.kind === "schema" || + item.type === "transform" || + item.type === "raw_transform", + ); +} + function uniformTypeOf(values: unknown[]): ScalarBase | null { const types = new Set(values.map((value) => typeof value)); if (types.size !== 1) return null; @@ -528,8 +597,8 @@ function uniformTypeOf(values: unknown[]): ScalarBase | null { return null; } -function describeSchema(schema: z.ZodType) { - return schema.constructor.name; +function describeSchema(schema: AnySchema) { + return schema.type; } function toSearchParams( diff --git a/app/modules/twitch/schemas.ts b/app/modules/twitch/schemas.ts index 62b3c47d8..692bc991a 100644 --- a/app/modules/twitch/schemas.ts +++ b/app/modules/twitch/schemas.ts @@ -1,62 +1,62 @@ -import { z } from "zod"; +import * as v from "valibot"; import type { Unpacked } from "~/utils/types"; -export const streamsSchema = z.object({ - data: z.array( - z.object({ - id: z.string(), - user_id: z.string(), - user_login: z.string(), - user_name: z.string(), - game_id: z.string(), - game_name: z.string(), - type: z.string(), - title: z.string(), - viewer_count: z.number(), - started_at: z.string(), - language: z.string(), - thumbnail_url: z.string(), - tag_ids: z.array(z.unknown()), - tags: z.array(z.string()).nullish(), - is_mature: z.boolean(), +export const streamsSchema = v.object({ + data: v.array( + v.object({ + id: v.string(), + user_id: v.string(), + user_login: v.string(), + user_name: v.string(), + game_id: v.string(), + game_name: v.string(), + type: v.string(), + title: v.string(), + viewer_count: v.number(), + started_at: v.string(), + language: v.string(), + thumbnail_url: v.string(), + tag_ids: v.array(v.unknown()), + tags: v.nullish(v.array(v.string())), + is_mature: v.boolean(), }), ), - pagination: z.object({ cursor: z.string().nullish() }), + pagination: v.object({ cursor: v.nullish(v.string()) }), }); -export const tokenResponseSchema = z.object({ - access_token: z.string(), - expires_in: z.number(), - token_type: z.string(), +export const tokenResponseSchema = v.object({ + access_token: v.string(), + expires_in: v.number(), + token_type: v.string(), }); -export const usersSchema = z.object({ - data: z.array( - z.object({ - id: z.string(), - login: z.string(), - display_name: z.string(), +export const usersSchema = v.object({ + data: v.array( + v.object({ + id: v.string(), + login: v.string(), + display_name: v.string(), }), ), }); -export const videosSchema = z.object({ - data: z.array( - z.object({ - id: z.string(), - user_id: z.string(), - user_login: z.string(), - title: z.string(), - created_at: z.string(), - duration: z.string(), - view_count: z.number(), - type: z.string(), +export const videosSchema = v.object({ + data: v.array( + v.object({ + id: v.string(), + user_id: v.string(), + user_login: v.string(), + title: v.string(), + created_at: v.string(), + duration: v.string(), + view_count: v.number(), + type: v.string(), }), ), - pagination: z.object({ cursor: z.string().nullish() }), + pagination: v.object({ cursor: v.nullish(v.string()) }), }); -export type StreamsResponse = z.infer; -export type RawStream = Unpacked["data"]>; -export type UsersResponse = z.infer; -export type RawVideo = Unpacked["data"]>; +export type StreamsResponse = v.InferOutput; +export type RawStream = Unpacked["data"]>; +export type UsersResponse = v.InferOutput; +export type RawVideo = Unpacked["data"]>; diff --git a/app/modules/twitch/streams.ts b/app/modules/twitch/streams.ts index a27787a2d..3ab266ef9 100644 --- a/app/modules/twitch/streams.ts +++ b/app/modules/twitch/streams.ts @@ -1,4 +1,5 @@ import { cachified } from "@epic-web/cachified"; +import * as v from "valibot"; import { cache } from "~/utils/cache.server"; import { IS_E2E_TEST_RUN } from "~/utils/e2e"; import { logger } from "~/utils/logger"; @@ -138,10 +139,10 @@ async function getStreamsChunk(cursor?: string): Promise { }`, ); - const parsed = streamsSchema.safeParse(await res.json()); + const parsed = v.safeParse(streamsSchema, await res.json()); if (!parsed.success) { - throw new Error(parsed.error.message); + throw new Error(v.summarize(parsed.issues)); } - return parsed.data; + return parsed.output; } diff --git a/app/modules/twitch/token.ts b/app/modules/twitch/token.ts index 380b3cd13..d4a97be0b 100644 --- a/app/modules/twitch/token.ts +++ b/app/modules/twitch/token.ts @@ -1,4 +1,5 @@ import { cachified } from "@epic-web/cachified"; +import * as v from "valibot"; import { cache } from "~/utils/cache.server"; import { tokenResponseSchema } from "./schemas"; import { getTwitchEnvVars } from "./utils.server"; @@ -16,12 +17,12 @@ async function getFreshToken() { ); } - const parsed = tokenResponseSchema.safeParse(await res.json()); + const parsed = v.safeParse(tokenResponseSchema, await res.json()); if (!parsed.success) { throw new Error("Token response schema validation failed"); } - return parsed.data.access_token; + return parsed.output.access_token; } export function getToken() { diff --git a/app/modules/twitch/vods.ts b/app/modules/twitch/vods.ts index 3a676b7f2..7d06d9482 100644 --- a/app/modules/twitch/vods.ts +++ b/app/modules/twitch/vods.ts @@ -1,4 +1,5 @@ import * as R from "remeda"; +import * as v from "valibot"; import { twitchFetch } from "./fetch"; import { type RawVideo, @@ -21,14 +22,14 @@ export async function getUsersByLogin( `https://api.twitch.tv/helix/users?${params}`, ); - const parsed = usersSchema.safeParse(await res.json()); + const parsed = v.safeParse(usersSchema, await res.json()); if (!parsed.success) { throw new Error( - `Twitch users schema validation failed: ${parsed.error.message}`, + `Twitch users schema validation failed: ${v.summarize(parsed.issues)}`, ); } - results.push(...parsed.data.data); + results.push(...parsed.output.data); } return results; @@ -49,17 +50,17 @@ export async function getArchiveVideos(userId: string): Promise { const res = await twitchFetch(url.toString()); - const parsed = videosSchema.safeParse(await res.json()); + const parsed = v.safeParse(videosSchema, await res.json()); if (!parsed.success) { throw new Error( - `Twitch videos schema validation failed: ${parsed.error.message}`, + `Twitch videos schema validation failed: ${v.summarize(parsed.issues)}`, ); } - results.push(...parsed.data.data); + results.push(...parsed.output.data); - if (!parsed.data.pagination.cursor) break; - cursor = parsed.data.pagination.cursor; + if (!parsed.output.pagination.cursor) break; + cursor = parsed.output.pagination.cursor; } return results; diff --git a/app/utils/Test.ts b/app/utils/Test.ts index 4f7de278a..2a095ce97 100644 --- a/app/utils/Test.ts +++ b/app/utils/Test.ts @@ -3,8 +3,8 @@ import type { LoaderFunctionArgs, Params, } from "react-router"; +import type * as v from "valibot"; import { expect } from "vitest"; -import type { z } from "zod"; import { REGULAR_USER_TEST_ID } from "~/db/seed/constants"; import { actAs } from "~/db/seed/core/actAs"; import { ADMIN_ID } from "~/features/admin/admin-constants"; @@ -14,6 +14,7 @@ import { getUserFromRequest, userAsyncLocalStorage, } from "~/features/auth/core/user-context.server"; +import type { AnySchema } from "~/utils/schema"; import { logger } from "./logger"; /** @@ -60,7 +61,7 @@ export function withNoUser(fn: () => T): T { * * const someAction = wrappedAction({ action }); */ -export function wrappedAction({ +export function wrappedAction({ action, /** Is this action submitted as json (via SendouForm) */ isJsonSubmission = false, @@ -69,7 +70,7 @@ export function wrappedAction({ isJsonSubmission?: boolean; }) { return async ( - args: z.infer, + args: v.InferOutput, { user, params = {} }: { user?: TestUser; params?: Params } = {}, ) => { const body = isJsonSubmission diff --git a/app/utils/action-schemas.ts b/app/utils/action-schemas.ts index f5ae3a17e..6fdd5b3cc 100644 --- a/app/utils/action-schemas.ts +++ b/app/utils/action-schemas.ts @@ -1,18 +1,19 @@ -import type { z } from "zod"; +import type * as v from "valibot"; +import type { AnySchema } from "./schema"; /** `_action` literals of an action schema (union or single branch). */ -export type ActionsOf = - z.infer extends { +export type ActionsOf = + v.InferOutput extends { _action: infer TAction extends string; } ? TAction : never; /** Non-`_action` fields of the schema branch matching the given action, in parsed types. */ -export type FieldsOf< - TSchema extends z.ZodTypeAny, - TAction extends string, -> = Omit, { _action: TAction }>, "_action">; +export type FieldsOf = Omit< + Extract, { _action: TAction }>, + "_action" +>; /** Serializes a parsed field value into its form data representation. */ export function serializeFieldValue(value: unknown) { diff --git a/app/utils/dates.ts b/app/utils/dates.ts index 7b3895393..2c11b9868 100644 --- a/app/utils/dates.ts +++ b/app/utils/dates.ts @@ -9,7 +9,7 @@ import { enUS } from "date-fns/locale/en-US"; import type { MonthYear } from "~/features/plus-voting/core"; import type { LanguageCode } from "~/modules/i18n/config"; import { logger } from "./logger"; -import type { DayMonthYear } from "./zod"; +import type { DayMonthYear } from "./schema"; // en-US ships with date-fns core as the default locale, so it costs no extra bytes const LOCALE_LOADERS: Record Promise> = { diff --git a/app/utils/oklch-gamut.ts b/app/utils/oklch-gamut.ts index e45e6dbcb..308083b03 100644 --- a/app/utils/oklch-gamut.ts +++ b/app/utils/oklch-gamut.ts @@ -1,8 +1,8 @@ -import type { z } from "zod"; +import type * as v from "valibot"; import type { CustomTheme } from "~/db/tables-json"; -import type { themeInputSchema } from "~/utils/zod"; +import type { themeInputSchema } from "~/utils/schema"; -export type ThemeInput = z.infer; +export type ThemeInput = v.InferOutput; interface Lab { L: number; diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts index 8392c7186..c7d8abbc6 100644 --- a/app/utils/remix.server.ts +++ b/app/utils/remix.server.ts @@ -1,10 +1,11 @@ import type { Namespace, TFunction } from "i18next"; import type { Params, UIMatch } from "react-router"; import { data, redirect } from "react-router"; -import type { z } from "zod"; +import * as v from "valibot"; import type { navItems } from "~/components/layout/nav-items"; import { ServerConfig } from "~/config.server"; import type { Ok, Result } from "~/utils/result"; +import type { AnySchema, AnySyncSchema } from "~/utils/schema"; import { logger } from "./logger"; import { currentRequestPathname } from "./request-context.server"; @@ -70,19 +71,19 @@ export function paginate({ * * When using SendouForm, use parseFormData from /app/form/parse.server.ts instead. * */ -export async function parseRequestPayload({ +export async function parseRequestPayload({ request, schema, }: { request: Request; schema: T; -}): Promise> { +}): Promise> { const formDataObj = request.headers.get("Content-Type") === "application/json" ? await request.json() : formDataToObject(await request.formData()); try { - return await schema.parseAsync(formDataObj); + return await v.parseAsync(schema, formDataObj); } catch (e) { logger.error("Error parsing request payload", e); @@ -91,35 +92,35 @@ export async function parseRequestPayload({ } /** Parse params with the given schema. Throws HTTP 404 response if fails. */ -export function parseParams({ +export function parseParams({ params, schema, }: { params: Params; schema: T; -}): z.infer { - const parsed = schema.safeParse(params); +}): v.InferOutput { + const parsed = v.safeParse(schema, params); if (!parsed.success) { throw new Response(null, { status: 404 }); } - return parsed.data; + return parsed.output; } /** Parse JSON body with the given schema. Throws HTTP 400 response if fails. */ -export async function parseBody({ +export async function parseBody({ request, schema, }: { request: Request; schema: T; -}): Promise> { - const parsed = schema.safeParse(await request.json()); +}): Promise> { + const parsed = v.safeParse(schema, await request.json()); if (!parsed.success) { throw new Response(null, { status: 400 }); } - return parsed.data; + return parsed.output; } export function formDataToObject(formData: FormData) { diff --git a/app/utils/zod.test.ts b/app/utils/schema.test.ts similarity index 69% rename from app/utils/zod.test.ts rename to app/utils/schema.test.ts index 81d665d64..488ec72ab 100644 --- a/app/utils/zod.test.ts +++ b/app/utils/schema.test.ts @@ -1,3 +1,4 @@ +import * as v from "valibot"; import { describe, expect, test } from "vitest"; import { actuallyNonEmptyStringOrNull, @@ -5,7 +6,7 @@ import { hexCodeWithoutAlpha, normalizeFriendCode, timeString, -} from "./zod"; +} from "./schema"; describe("normalizeFriendCode", () => { test("returns well formatted friend code as is", () => { @@ -116,64 +117,64 @@ describe("actuallyNonEmptyStringOrNull", () => { describe("hexCodeWithoutAlpha", () => { test("accepts valid 3 and 6 digit hex colors", () => { - expect(hexCodeWithoutAlpha.safeParse("#fff").success).toBe(true); - expect(hexCodeWithoutAlpha.safeParse("#FFF").success).toBe(true); - expect(hexCodeWithoutAlpha.safeParse("#abc").success).toBe(true); - expect(hexCodeWithoutAlpha.safeParse("#ffffff").success).toBe(true); - expect(hexCodeWithoutAlpha.safeParse("#a1b2c3").success).toBe(true); + expect(v.safeParse(hexCodeWithoutAlpha, "#fff").success).toBe(true); + expect(v.safeParse(hexCodeWithoutAlpha, "#FFF").success).toBe(true); + expect(v.safeParse(hexCodeWithoutAlpha, "#abc").success).toBe(true); + expect(v.safeParse(hexCodeWithoutAlpha, "#ffffff").success).toBe(true); + expect(v.safeParse(hexCodeWithoutAlpha, "#a1b2c3").success).toBe(true); }); test("rejects strings that are not valid hex colors", () => { - expect(hexCodeWithoutAlpha.safeParse("#fff99").success).toBe(false); - expect(hexCodeWithoutAlpha.safeParse("#abc12").success).toBe(false); - expect(hexCodeWithoutAlpha.safeParse("#12345").success).toBe(false); - expect(hexCodeWithoutAlpha.safeParse("#ffffff99").success).toBe(false); + expect(v.safeParse(hexCodeWithoutAlpha, "#fff99").success).toBe(false); + expect(v.safeParse(hexCodeWithoutAlpha, "#abc12").success).toBe(false); + expect(v.safeParse(hexCodeWithoutAlpha, "#12345").success).toBe(false); + expect(v.safeParse(hexCodeWithoutAlpha, "#ffffff99").success).toBe(false); }); test("rejects alpha (4 and 8 digit) hex colors", () => { - expect(hexCodeWithoutAlpha.safeParse("#ffff").success).toBe(false); - expect(hexCodeWithoutAlpha.safeParse("#ffffffff").success).toBe(false); + expect(v.safeParse(hexCodeWithoutAlpha, "#ffff").success).toBe(false); + expect(v.safeParse(hexCodeWithoutAlpha, "#ffffffff").success).toBe(false); }); }); describe("timeString", () => { test("accepts valid time in HH:MM format", () => { - expect(timeString.safeParse("00:00").success).toBe(true); - expect(timeString.safeParse("12:30").success).toBe(true); - expect(timeString.safeParse("23:59").success).toBe(true); + expect(v.safeParse(timeString, "00:00").success).toBe(true); + expect(v.safeParse(timeString, "12:30").success).toBe(true); + expect(v.safeParse(timeString, "23:59").success).toBe(true); }); test("accepts times with leading zeros", () => { - expect(timeString.safeParse("01:05").success).toBe(true); - expect(timeString.safeParse("09:00").success).toBe(true); + expect(v.safeParse(timeString, "01:05").success).toBe(true); + expect(v.safeParse(timeString, "09:00").success).toBe(true); }); test("rejects invalid hour values", () => { - expect(timeString.safeParse("24:00").success).toBe(false); - expect(timeString.safeParse("25:30").success).toBe(false); - expect(timeString.safeParse("99:00").success).toBe(false); + expect(v.safeParse(timeString, "24:00").success).toBe(false); + expect(v.safeParse(timeString, "25:30").success).toBe(false); + expect(v.safeParse(timeString, "99:00").success).toBe(false); }); test("rejects invalid minute values", () => { - expect(timeString.safeParse("12:60").success).toBe(false); - expect(timeString.safeParse("12:99").success).toBe(false); + expect(v.safeParse(timeString, "12:60").success).toBe(false); + expect(v.safeParse(timeString, "12:99").success).toBe(false); }); test("rejects malformed time strings", () => { - expect(timeString.safeParse("1:30").success).toBe(false); - expect(timeString.safeParse("12:3").success).toBe(false); - expect(timeString.safeParse("12-30").success).toBe(false); - expect(timeString.safeParse("1230").success).toBe(false); - expect(timeString.safeParse("12:30:00").success).toBe(false); + expect(v.safeParse(timeString, "1:30").success).toBe(false); + expect(v.safeParse(timeString, "12:3").success).toBe(false); + expect(v.safeParse(timeString, "12-30").success).toBe(false); + expect(v.safeParse(timeString, "1230").success).toBe(false); + expect(v.safeParse(timeString, "12:30:00").success).toBe(false); }); test("rejects non-string values", () => { - expect(timeString.safeParse(1230).success).toBe(false); - expect(timeString.safeParse(null).success).toBe(false); - expect(timeString.safeParse(undefined).success).toBe(false); + expect(v.safeParse(timeString, 1230).success).toBe(false); + expect(v.safeParse(timeString, null).success).toBe(false); + expect(v.safeParse(timeString, undefined).success).toBe(false); }); test("rejects empty string", () => { - expect(timeString.safeParse("").success).toBe(false); + expect(v.safeParse(timeString, "").success).toBe(false); }); }); diff --git a/app/utils/schema.ts b/app/utils/schema.ts new file mode 100644 index 000000000..7a447ea62 --- /dev/null +++ b/app/utils/schema.ts @@ -0,0 +1,548 @@ +import * as v from "valibot"; +import { + abilities, + type abilitiesShort, +} from "~/modules/in-game-lists/abilities"; +import { stageIds } from "~/modules/in-game-lists/stage-ids"; +import { + mainWeaponIds, + specialWeaponIds, + subWeaponIds, +} from "~/modules/in-game-lists/weapon-ids"; +import { SHORT_NANOID_LENGTH } from "./id"; +import type { Unpacked } from "./types"; +import { assertType } from "./types"; + +/** Any synchronous valibot schema. */ +export type AnySyncSchema = v.GenericSchema; + +/** Any valibot schema, sync or async. */ +export type AnySchema = AnySyncSchema | v.GenericSchemaAsync; + +/** Runs `fn` on the raw input before validating it with `schema`. */ +export function preprocess( + fn: (value: unknown) => unknown, + schema: TSchema, +) { + return v.pipe( + v.unknown(), + v.transform(fn as (value: unknown) => v.InferInput), + schema, + ); +} + +/** Issue collector the cross-field validators report to (see {@link superRefine}). */ +export interface ValidationCtx { + addIssue: (issue: { message: string; path?: PropertyKey[] }) => void; +} + +/** + * Validation action running `fn` on the parsed value with an `addIssue` + * taking plain key paths. + */ +export function superRefine( + fn: (value: TValue, ctx: ValidationCtx) => void, +) { + return v.rawCheck(({ dataset, addIssue }) => { + if (!dataset.typed) return; + fn(dataset.value, { + addIssue: (issue) => { + addIssue({ + message: issue.message, + path: issue.path?.length + ? toIssuePath(dataset.value, issue.path) + : undefined, + }); + }, + }); + }); +} + +/** Async counterpart of {@link superRefine}. */ +export function superRefineAsync( + fn: (value: TValue, ctx: ValidationCtx) => Promise, +) { + return v.rawCheckAsync(async ({ dataset, addIssue }) => { + if (!dataset.typed) return; + await fn(dataset.value, { + addIssue: (issue) => { + addIssue({ + message: issue.message, + path: issue.path?.length + ? toIssuePath(dataset.value, issue.path) + : undefined, + }); + }, + }); + }); +} + +function toIssuePath( + root: unknown, + keys: PropertyKey[], +): [v.IssuePathItem, ...v.IssuePathItem[]] { + let current: unknown = root; + const items = keys.map((key) => { + const value = (current as Record | undefined)?.[key]; + const item = { + type: "unknown" as const, + origin: "value" as const, + input: current, + key, + value, + }; + current = value; + return item; + }); + return items as unknown as [v.IssuePathItem, ...v.IssuePathItem[]]; +} + +/** Coerces the input with `Number()` before validating. */ +export function coerceNumber(message?: string) { + return v.pipe(v.unknown(), v.transform(Number), v.number(message)); +} + +export const id = v.pipe(coerceNumber("Required"), v.integer(), v.minValue(1)); +export const idObject = v.object({ + id, +}); + +export const inviteCode = v.pipe(v.string(), v.length(SHORT_NANOID_LENGTH)); + +export const nonEmptyString = v.pipe( + v.string(), + v.trim(), + v.minLength(1, "Required"), +); + +// matches #RGB and #RRGGBB only (no alpha) https://stackoverflow.com/a/1636354 +const hexCodeWithoutAlphaRegex = /^#(?:[0-9a-fA-F]{3}){1,2}$/; +export const hexCodeWithoutAlpha = v.pipe( + v.string(), + v.regex(hexCodeWithoutAlphaRegex), +); + +export const THEME_INPUT_LIMITS = { + BASE_HUE_MIN: 0, + BASE_HUE_MAX: 360, + BASE_CHROMA_MIN: 0, + BASE_CHROMA_MAX: 0.1, + ACCENT_HUE_MIN: 0, + ACCENT_HUE_MAX: 360, + ACCENT_CHROMA_MIN: 0, + ACCENT_CHROMA_MAX: 0.3, + RADIUS_MIN: 0, + RADIUS_MAX: 5, + RADIUS_STEP: 1, + BORDER_WIDTH_MIN: 0.5, + BORDER_WIDTH_MAX: 2, + BORDER_WIDTH_STEP: 0.5, + SIZE_MIN: 0.9, + SIZE_MAX: 1.1, + SIZE_STEP: 0.05, +} as const; + +function isValidStep(value: number, min: number, step: number) { + const diff = value - min; + const steps = Math.round(diff / step); + return Math.abs(diff - steps * step) < 0.0001; +} + +export const themeInputSchema = v.object({ + baseHue: v.pipe( + v.number(), + v.minValue(THEME_INPUT_LIMITS.BASE_HUE_MIN), + v.maxValue(THEME_INPUT_LIMITS.BASE_HUE_MAX), + ), + baseChroma: v.pipe( + v.number(), + v.minValue(THEME_INPUT_LIMITS.BASE_CHROMA_MIN), + v.maxValue(THEME_INPUT_LIMITS.BASE_CHROMA_MAX), + ), + accentHue: v.pipe( + v.number(), + v.minValue(THEME_INPUT_LIMITS.ACCENT_HUE_MIN), + v.maxValue(THEME_INPUT_LIMITS.ACCENT_HUE_MAX), + ), + accentChroma: v.pipe( + v.number(), + v.minValue(THEME_INPUT_LIMITS.ACCENT_CHROMA_MIN), + v.maxValue(THEME_INPUT_LIMITS.ACCENT_CHROMA_MAX), + ), + chatHue: v.nullable( + v.pipe( + v.number(), + v.minValue(THEME_INPUT_LIMITS.BASE_HUE_MIN), + v.maxValue(THEME_INPUT_LIMITS.BASE_HUE_MAX), + ), + ), + radiusBox: v.pipe( + v.number(), + v.integer(), + v.minValue(THEME_INPUT_LIMITS.RADIUS_MIN), + v.maxValue(THEME_INPUT_LIMITS.RADIUS_MAX), + ), + radiusField: v.pipe( + v.number(), + v.integer(), + v.minValue(THEME_INPUT_LIMITS.RADIUS_MIN), + v.maxValue(THEME_INPUT_LIMITS.RADIUS_MAX), + ), + radiusSelector: v.pipe( + v.number(), + v.integer(), + v.minValue(THEME_INPUT_LIMITS.RADIUS_MIN), + v.maxValue(THEME_INPUT_LIMITS.RADIUS_MAX), + ), + borderWidth: v.pipe( + v.number(), + v.minValue(THEME_INPUT_LIMITS.BORDER_WIDTH_MIN), + v.maxValue(THEME_INPUT_LIMITS.BORDER_WIDTH_MAX), + v.check( + (val) => + isValidStep( + val, + THEME_INPUT_LIMITS.BORDER_WIDTH_MIN, + THEME_INPUT_LIMITS.BORDER_WIDTH_STEP, + ), + "Must be a valid step increment", + ), + ), + sizeField: v.pipe( + v.number(), + v.minValue(THEME_INPUT_LIMITS.SIZE_MIN), + v.maxValue(THEME_INPUT_LIMITS.SIZE_MAX), + v.check( + (val) => + isValidStep( + val, + THEME_INPUT_LIMITS.SIZE_MIN, + THEME_INPUT_LIMITS.SIZE_STEP, + ), + "Must be a valid step increment", + ), + ), + sizeSelector: v.pipe( + v.number(), + v.minValue(THEME_INPUT_LIMITS.SIZE_MIN), + v.maxValue(THEME_INPUT_LIMITS.SIZE_MAX), + v.check( + (val) => + isValidStep( + val, + THEME_INPUT_LIMITS.SIZE_MIN, + THEME_INPUT_LIMITS.SIZE_STEP, + ), + "Must be a valid step increment", + ), + ), + sizeSpacing: v.pipe( + v.number(), + v.minValue(THEME_INPUT_LIMITS.SIZE_MIN), + v.maxValue(THEME_INPUT_LIMITS.SIZE_MAX), + v.check( + (val) => + isValidStep( + val, + THEME_INPUT_LIMITS.SIZE_MIN, + THEME_INPUT_LIMITS.SIZE_STEP, + ), + "Must be a valid step increment", + ), + ), +}); + +const timeStringRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; +export const timeString = v.pipe(v.string(), v.regex(timeStringRegex)); + +const abilityNameToType = (val: string) => + abilities.find((ability) => ability.name === val)?.type; +export const headMainSlotAbility = v.pipe( + v.string(), + v.check( + (val) => + ["STACKABLE", "HEAD_MAIN_ONLY"].includes(abilityNameToType(val) as any), + "forms:errors.required", + ), +); +export const clothesMainSlotAbility = v.pipe( + v.string(), + v.check( + (val) => + ["STACKABLE", "CLOTHES_MAIN_ONLY"].includes( + abilityNameToType(val) as any, + ), + "forms:errors.required", + ), +); +export const shoesMainSlotAbility = v.pipe( + v.string(), + v.check( + (val) => + ["STACKABLE", "SHOES_MAIN_ONLY"].includes(abilityNameToType(val) as any), + "forms:errors.required", + ), +); +export const stackableAbility = v.pipe( + v.string(), + v.check( + (val) => abilityNameToType(val) === "STACKABLE", + "forms:errors.required", + ), +); + +export const normalizeFriendCode = (value: string) => { + const onlyNumbers = value.replace(/\D/g, ""); + + const withDashes = onlyNumbers + .split(/(\d{4})/) + .filter(Boolean) + .join("-"); + + return withDashes; +}; + +export const ability = v.picklist([ + "ISM", + "ISS", + "IRU", + "RSU", + "SSU", + "SCU", + "SS", + "SPU", + "QR", + "QSJ", + "BRU", + "RES", + "SRU", + "IA", + "OG", + "LDE", + "T", + "CB", + "NS", + "H", + "TI", + "RP", + "AD", + "SJ", + "OS", + "DR", +]); +// keep in-game-lists and the valibot enum in sync +assertType, Unpacked>(); + +export const weaponSplId = preprocess(actualNumber, numericEnum(mainWeaponIds)); + +export const subWeaponId = numericEnum(subWeaponIds); + +export const specialWeaponId = numericEnum(specialWeaponIds); + +export const modeShort = v.picklist(["TW", "SZ", "TC", "RM", "CB"]); +export const modeShortWithSpecial = v.picklist([ + "TW", + "SZ", + "TC", + "RM", + "CB", + "SR", + "TB", +]); + +export const gamesShortSchema = v.picklist(["S1", "S2", "S3"]); + +export const stageId = preprocess(actualNumber, numericEnum(stageIds)); + +export function processMany( + ...processFuncs: Array<(value: unknown) => unknown> +) { + return (value: unknown) => { + let result = value; + + for (const processFunc of processFuncs) { + result = processFunc(result); + } + + return result; + }; +} + +export function safeJSONParse(value: unknown): unknown { + try { + if (typeof value !== "string") return value; + return JSON.parse(value); + } catch { + return undefined; + } +} + +const EMPTY_CHARACTERS = [ + "\u00AD", + "\u200B", + "\u200C", + "\u200D", + "\u200E", + "\u200F", + "󠀠", + "\u2800", + "\u3164", + "\u115F", + "\u1160", + "\uFEFF", + "\u2060", + "[\\uFE00-\\uFE0F]", +]; +const EMPTY_CHARACTERS_REGEX = new RegExp(EMPTY_CHARACTERS.join("|"), "g"); + +const zalgoRe = /%CC%/; +export const hasZalgo = (txt: string) => zalgoRe.test(encodeURIComponent(txt)); + +/** Non-empty string that has the given length (max and optionally min). Prevents z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ text as well as filters out characters that have no width. */ +export const safeStringSchema = ({ min, max }: { min?: number; max: number }) => + preprocess( + actuallyNonEmptyStringOrNull, // if this returns null, none of the checks below will run because it's not a string + v.pipe( + v.string(), + v.minLength(min ?? 0), + v.maxLength(max), + v.check((text) => !hasZalgo(text), "Includes not allowed characters."), + ), + ); + +/** Nullable string that has the given length (max and optionally min). Prevents z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ text as well as filters out characters that have no width. */ +export const safeNullableStringSchema = ({ + min, + max, +}: { + min?: number; + max: number; +}) => + preprocess( + processMany(undefinedToNull, actuallyNonEmptyStringOrNull), + v.pipe( + v.nullable(v.pipe(v.string(), v.minLength(min ?? 0), v.maxLength(max))), + v.check((text) => { + if (typeof text !== "string") return true; + + return !hasZalgo(text); + }, "Includes not allowed characters."), + ), + ); + +/** + * Processes the input value and returns a non-empty string with invisible characters cleaned out or null. + */ +export function actuallyNonEmptyStringOrNull(value: unknown) { + if (typeof value !== "string") return value; + + const trimmed = value.replace(EMPTY_CHARACTERS_REGEX, "").trim(); + + return trimmed === "" ? null : trimmed; +} + +export function falsyToNull(value: unknown): unknown { + if (value) return value; + + return null; +} + +export function nullLiteraltoNull(value: unknown): unknown { + if (value === "null") return null; + + return value; +} + +function undefinedToNull(value: unknown): unknown { + if (value === undefined) return null; + + return value; +} + +export function actualNumber(value: unknown) { + if (value === "") return undefined; + + const parsed = Number(value); + + return Number.isNaN(parsed) ? undefined : parsed; +} + +export function date(value: unknown) { + if (typeof value === "string" || typeof value === "number") { + const valueAsNumber = Number(value); + + return new Date(Number.isNaN(valueAsNumber) ? value : valueAsNumber); + } + + return value; +} + +export function noDuplicates(arr: (number | string)[]) { + return new Set(arr).size === arr.length; +} + +export function filterOutNullishMembers(value: unknown) { + if (!Array.isArray(value)) return value; + + return value.filter((member) => member !== null && member !== undefined); +} + +export function removeDuplicates(value: unknown) { + if (!Array.isArray(value)) return value; + + return Array.from(new Set(value)); +} + +export function emptyArrayToNull(value: unknown) { + if (Array.isArray(value) && value.length === 0) return null; + + return value; +} + +export function checkboxValueToBoolean(value: unknown) { + if (!value) return false; + + if (typeof value !== "string") { + throw new Error("Expected string checkbox value"); + } + + return value === "on"; +} + +export const _action = (value: T) => + preprocess(deduplicate, v.literal(value)); + +// Fix bug at least in Safari 15 where SubmitButton value might get sent twice +export function deduplicate(value: unknown) { + if (Array.isArray(value)) { + const [one, two, ...rest] = value; + if (rest.length > 0) return value; + if (one !== two) return value; + + return one; + } + + return value; +} + +/** Number schema accepting only the given values, the numeric counterpart of `v.picklist`. */ +export function numericEnum( + values: TValues, +) { + return v.pipe( + v.number(), + v.check( + (val) => values.includes(val), + (issue) => + `Expected one of: ${values.join(", ")}, received ${issue.input}`, + ), + ) as unknown as v.GenericSchema; +} + +export const dayMonthYear = v.object({ + day: v.pipe(coerceNumber(), v.integer(), v.minValue(1), v.maxValue(31)), + month: v.pipe(coerceNumber(), v.integer(), v.minValue(0), v.maxValue(11)), + year: v.pipe(coerceNumber(), v.integer(), v.minValue(2015), v.maxValue(2100)), +}); + +export type DayMonthYear = v.InferOutput; diff --git a/app/utils/zod.ts b/app/utils/zod.ts deleted file mode 100644 index 8f639645f..000000000 --- a/app/utils/zod.ts +++ /dev/null @@ -1,450 +0,0 @@ -import type { ZodType } from "zod"; -import { z } from "zod"; -import { - abilities, - type abilitiesShort, -} from "~/modules/in-game-lists/abilities"; -import { stageIds } from "~/modules/in-game-lists/stage-ids"; -import { - mainWeaponIds, - specialWeaponIds, - subWeaponIds, -} from "~/modules/in-game-lists/weapon-ids"; -import { SHORT_NANOID_LENGTH } from "./id"; -import type { Unpacked } from "./types"; -import { assertType } from "./types"; - -export const id = z.coerce.number({ message: "Required" }).int().positive(); -export const idObject = z.object({ - id, -}); - -export const inviteCode = z.string().length(SHORT_NANOID_LENGTH); - -export const nonEmptyString = z.string().trim().min(1, { - message: "Required", -}); - -// matches #RGB and #RRGGBB only (no alpha) https://stackoverflow.com/a/1636354 -const hexCodeWithoutAlphaRegex = /^#(?:[0-9a-fA-F]{3}){1,2}$/; -export const hexCodeWithoutAlpha = z.string().regex(hexCodeWithoutAlphaRegex); - -export const THEME_INPUT_LIMITS = { - BASE_HUE_MIN: 0, - BASE_HUE_MAX: 360, - BASE_CHROMA_MIN: 0, - BASE_CHROMA_MAX: 0.1, - ACCENT_HUE_MIN: 0, - ACCENT_HUE_MAX: 360, - ACCENT_CHROMA_MIN: 0, - ACCENT_CHROMA_MAX: 0.3, - RADIUS_MIN: 0, - RADIUS_MAX: 5, - RADIUS_STEP: 1, - BORDER_WIDTH_MIN: 0.5, - BORDER_WIDTH_MAX: 2, - BORDER_WIDTH_STEP: 0.5, - SIZE_MIN: 0.9, - SIZE_MAX: 1.1, - SIZE_STEP: 0.05, -} as const; - -function isValidStep(value: number, min: number, step: number) { - const diff = value - min; - const steps = Math.round(diff / step); - return Math.abs(diff - steps * step) < 0.0001; -} - -export const themeInputSchema = z.object({ - baseHue: z - .number() - .min(THEME_INPUT_LIMITS.BASE_HUE_MIN) - .max(THEME_INPUT_LIMITS.BASE_HUE_MAX), - baseChroma: z - .number() - .min(THEME_INPUT_LIMITS.BASE_CHROMA_MIN) - .max(THEME_INPUT_LIMITS.BASE_CHROMA_MAX), - accentHue: z - .number() - .min(THEME_INPUT_LIMITS.ACCENT_HUE_MIN) - .max(THEME_INPUT_LIMITS.ACCENT_HUE_MAX), - accentChroma: z - .number() - .min(THEME_INPUT_LIMITS.ACCENT_CHROMA_MIN) - .max(THEME_INPUT_LIMITS.ACCENT_CHROMA_MAX), - chatHue: z - .number() - .min(THEME_INPUT_LIMITS.BASE_HUE_MIN) - .max(THEME_INPUT_LIMITS.BASE_HUE_MAX) - .nullable(), - radiusBox: z - .number() - .int() - .min(THEME_INPUT_LIMITS.RADIUS_MIN) - .max(THEME_INPUT_LIMITS.RADIUS_MAX), - radiusField: z - .number() - .int() - .min(THEME_INPUT_LIMITS.RADIUS_MIN) - .max(THEME_INPUT_LIMITS.RADIUS_MAX), - radiusSelector: z - .number() - .int() - .min(THEME_INPUT_LIMITS.RADIUS_MIN) - .max(THEME_INPUT_LIMITS.RADIUS_MAX), - borderWidth: z - .number() - .min(THEME_INPUT_LIMITS.BORDER_WIDTH_MIN) - .max(THEME_INPUT_LIMITS.BORDER_WIDTH_MAX) - .refine( - (val) => - isValidStep( - val, - THEME_INPUT_LIMITS.BORDER_WIDTH_MIN, - THEME_INPUT_LIMITS.BORDER_WIDTH_STEP, - ), - { message: "Must be a valid step increment" }, - ), - sizeField: z - .number() - .min(THEME_INPUT_LIMITS.SIZE_MIN) - .max(THEME_INPUT_LIMITS.SIZE_MAX) - .refine( - (val) => - isValidStep( - val, - THEME_INPUT_LIMITS.SIZE_MIN, - THEME_INPUT_LIMITS.SIZE_STEP, - ), - { message: "Must be a valid step increment" }, - ), - sizeSelector: z - .number() - .min(THEME_INPUT_LIMITS.SIZE_MIN) - .max(THEME_INPUT_LIMITS.SIZE_MAX) - .refine( - (val) => - isValidStep( - val, - THEME_INPUT_LIMITS.SIZE_MIN, - THEME_INPUT_LIMITS.SIZE_STEP, - ), - { message: "Must be a valid step increment" }, - ), - sizeSpacing: z - .number() - .min(THEME_INPUT_LIMITS.SIZE_MIN) - .max(THEME_INPUT_LIMITS.SIZE_MAX) - .refine( - (val) => - isValidStep( - val, - THEME_INPUT_LIMITS.SIZE_MIN, - THEME_INPUT_LIMITS.SIZE_STEP, - ), - { message: "Must be a valid step increment" }, - ), -}); - -const timeStringRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; -export const timeString = z.string().regex(timeStringRegex); - -const abilityNameToType = (val: string) => - abilities.find((ability) => ability.name === val)?.type; -export const headMainSlotAbility = z - .string() - .refine( - (val) => - ["STACKABLE", "HEAD_MAIN_ONLY"].includes(abilityNameToType(val) as any), - { message: "forms:errors.required" }, - ); -export const clothesMainSlotAbility = z - .string() - .refine( - (val) => - ["STACKABLE", "CLOTHES_MAIN_ONLY"].includes( - abilityNameToType(val) as any, - ), - { message: "forms:errors.required" }, - ); -export const shoesMainSlotAbility = z - .string() - .refine( - (val) => - ["STACKABLE", "SHOES_MAIN_ONLY"].includes(abilityNameToType(val) as any), - { message: "forms:errors.required" }, - ); -export const stackableAbility = z - .string() - .refine((val) => abilityNameToType(val) === "STACKABLE", { - message: "forms:errors.required", - }); - -export const normalizeFriendCode = (value: string) => { - const onlyNumbers = value.replace(/\D/g, ""); - - const withDashes = onlyNumbers - .split(/(\d{4})/) - .filter(Boolean) - .join("-"); - - return withDashes; -}; - -export const ability = z.enum([ - "ISM", - "ISS", - "IRU", - "RSU", - "SSU", - "SCU", - "SS", - "SPU", - "QR", - "QSJ", - "BRU", - "RES", - "SRU", - "IA", - "OG", - "LDE", - "T", - "CB", - "NS", - "H", - "TI", - "RP", - "AD", - "SJ", - "OS", - "DR", -]); -// keep in-game-lists and the zod enum in sync -assertType, Unpacked>(); - -export const weaponSplId = z.preprocess( - actualNumber, - numericEnum(mainWeaponIds), -); - -export const subWeaponId = numericEnum(subWeaponIds); - -export const specialWeaponId = numericEnum(specialWeaponIds); - -export const modeShort = z.enum(["TW", "SZ", "TC", "RM", "CB"]); -export const modeShortWithSpecial = z.enum([ - "TW", - "SZ", - "TC", - "RM", - "CB", - "SR", - "TB", -]); - -export const gamesShortSchema = z.enum(["S1", "S2", "S3"]); - -export const stageId = z.preprocess(actualNumber, numericEnum(stageIds)); - -export function processMany( - ...processFuncs: Array<(value: unknown) => unknown> -) { - return (value: unknown) => { - let result = value; - - for (const processFunc of processFuncs) { - result = processFunc(result); - } - - return result; - }; -} - -export function safeJSONParse(value: unknown): unknown { - try { - if (typeof value !== "string") return value; - return JSON.parse(value); - } catch { - return undefined; - } -} - -const EMPTY_CHARACTERS = [ - "\u00AD", - "\u200B", - "\u200C", - "\u200D", - "\u200E", - "\u200F", - "󠀠", - "\u2800", - "\u3164", - "\u115F", - "\u1160", - "\uFEFF", - "\u2060", - "[\\uFE00-\\uFE0F]", -]; -const EMPTY_CHARACTERS_REGEX = new RegExp(EMPTY_CHARACTERS.join("|"), "g"); - -const zalgoRe = /%CC%/; -export const hasZalgo = (txt: string) => zalgoRe.test(encodeURIComponent(txt)); - -/** Non-empty string that has the given length (max and optionally min). Prevents z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ text as well as filters out characters that have no width. */ -export const safeStringSchema = ({ min, max }: { min?: number; max: number }) => - z.preprocess( - actuallyNonEmptyStringOrNull, // if this returns null, none of the checks below will run because it's not a string - z - .string() - .min(min ?? 0) - .max(max) - .refine((text) => !hasZalgo(text), { - message: "Includes not allowed characters.", - }), - ); - -/** Nullable string that has the given length (max and optionally min). Prevents z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ text as well as filters out characters that have no width. */ -export const safeNullableStringSchema = ({ - min, - max, -}: { - min?: number; - max: number; -}) => - z.preprocess( - processMany(undefinedToNull, actuallyNonEmptyStringOrNull), - z - .string() - .min(min ?? 0) - .max(max) - .nullable() - .refine( - (text) => { - if (typeof text !== "string") return true; - - return !hasZalgo(text); - }, - { - message: "Includes not allowed characters.", - }, - ), - ); - -/** - * Processes the input value and returns a non-empty string with invisible characters cleaned out or null. - */ -export function actuallyNonEmptyStringOrNull(value: unknown) { - if (typeof value !== "string") return value; - - const trimmed = value.replace(EMPTY_CHARACTERS_REGEX, "").trim(); - - return trimmed === "" ? null : trimmed; -} - -export function falsyToNull(value: unknown): unknown { - if (value) return value; - - return null; -} - -export function nullLiteraltoNull(value: unknown): unknown { - if (value === "null") return null; - - return value; -} - -function undefinedToNull(value: unknown): unknown { - if (value === undefined) return null; - - return value; -} - -export function actualNumber(value: unknown) { - if (value === "") return undefined; - - const parsed = Number(value); - - return Number.isNaN(parsed) ? undefined : parsed; -} - -export function date(value: unknown) { - if (typeof value === "string" || typeof value === "number") { - const valueAsNumber = Number(value); - - return new Date(Number.isNaN(valueAsNumber) ? value : valueAsNumber); - } - - return value; -} - -export function noDuplicates(arr: (number | string)[]) { - return new Set(arr).size === arr.length; -} - -export function filterOutNullishMembers(value: unknown) { - if (!Array.isArray(value)) return value; - - return value.filter((member) => member !== null && member !== undefined); -} - -export function removeDuplicates(value: unknown) { - if (!Array.isArray(value)) return value; - - return Array.from(new Set(value)); -} - -export function emptyArrayToNull(value: unknown) { - if (Array.isArray(value) && value.length === 0) return null; - - return value; -} - -export function checkboxValueToBoolean(value: unknown) { - if (!value) return false; - - if (typeof value !== "string") { - throw new Error("Expected string checkbox value"); - } - - return value === "on"; -} - -export const _action = (value: T) => - z.preprocess(deduplicate, z.literal(value)); - -// Fix bug at least in Safari 15 where SubmitButton value might get sent twice -export function deduplicate(value: unknown) { - if (Array.isArray(value)) { - const [one, two, ...rest] = value; - if (rest.length > 0) return value; - if (one !== two) return value; - - return one; - } - - return value; -} - -// https://github.com/colinhacks/zod/issues/1118#issuecomment-1235065111 -export function numericEnum( - values: TValues, -) { - return z.number().superRefine((val, ctx) => { - if (!values.includes(val)) { - ctx.addIssue({ - code: z.ZodIssueCode.invalid_value, - input: val, - values: [...values], - message: `Expected one of: ${values.join(", ")}, received ${val}`, - }); - } - }) as ZodType; -} - -export const dayMonthYear = z.object({ - day: z.coerce.number().int().min(1).max(31), - month: z.coerce.number().int().min(0).max(11), - year: z.coerce.number().int().min(2015).max(2100), -}); - -export type DayMonthYear = z.infer; diff --git a/biome-plugins/no-raw-action-forms.grit b/biome-plugins/no-raw-action-forms.grit index f30b03120..bbda45d6d 100644 --- a/biome-plugins/no-raw-action-forms.grit +++ b/biome-plugins/no-raw-action-forms.grit @@ -3,7 +3,7 @@ language js // Fixed-field mutations (an `_action` plus hidden inputs) go through // `` (app/components/ActionButton.tsx). Real multi-input forms // keep their own form but pass `schema` alongside `_action` to `SubmitButton` -// so the action literal is type checked against the route's zod action schema. +// so the action literal is type checked against the route's action schema. or { JsxAttribute(name=`_action`) as $attr where { $attr <: not within or { diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 1d2f2ac48..3de2de4bd 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -88,7 +88,7 @@ You should aim to colocate code that "changes together" as much as possible. Fea - **FeatureRepository.server.ts**: Database queries & mappers (see `repositories.md`) - **feature-constants.ts**: Constant values - **feature-hooks.ts**: React hooks -- **feature-schemas.ts**: Zod schemas for validating form values, params, payloads +- **feature-schemas.ts**: valibot schemas for validating form values, params, payloads - **feature-types.ts**: Typescript types - **feature-utils.ts**: Utilities too small to make up for their own modules - **Component.module.css**: CSS module matching the React file of the same root name @@ -170,7 +170,7 @@ TODO (after React server actions in use) ### Forms -Forms are defined as Zod schemas built from the field builders in `~/form/fields` and rendered by `SendouForm`. The same schema validates the submission on the server. See `forms.md` for the full documentation. +Forms are defined as valibot schemas built from the field builders in `~/form/fields` and rendered by `SendouForm`. The same schema validates the submission on the server. See `forms.md` for the full documentation. ### Performance diff --git a/docs/dev/database-schemas.md b/docs/dev/database-schemas.md index 358c83c2d..f1e695c11 100644 --- a/docs/dev/database-schemas.md +++ b/docs/dev/database-schemas.md @@ -16,7 +16,7 @@ SQLite has no boolean type, so booleans are `0`/`1` integers typed as `DBBoolean Converting to one: - `toDBBoolean(someBoolean)` from `~/utils/sql` — use this instead of `Number(x)` or `x ? 1 : 0` when writing to the DB. -- `dbBoolean` / `checkboxValueToDbBoolean` from `~/utils/zod` for form and payload schemas. +- `dbBoolean` / `checkboxValueToDbBoolean` from `~/utils/schema` for form and payload schemas. Reading is just truthiness (`if (build.isPrivate)`); convert to a real boolean with `Boolean()` when the value crosses into a domain type. diff --git a/docs/dev/forms.md b/docs/dev/forms.md index 05a30e0bb..c342c8495 100644 --- a/docs/dev/forms.md +++ b/docs/dev/forms.md @@ -1,10 +1,10 @@ # SendouForm - Schema-Based Form System -This document describes the schema-based form system using `SendouForm`. Forms are defined as Zod schemas that generate both the UI and server-side validation. +This document describes the schema-based form system using `SendouForm`. Forms are defined as valibot schemas that generate both the UI and server-side validation. ## Core Concepts -- Forms are defined as Zod schemas using field builders from `~/form/fields` +- Forms are defined as valibot schemas using field builders from `~/form/fields` - The same schema validates both client-side and server-side - All translations go in `locales/en/forms.json` - `FormField` renders the correct UI based on schema metadata @@ -14,7 +14,7 @@ This document describes the schema-based form system using `SendouForm`. Forms a ### Basic Schema Example ```ts -export const myFormSchema = z.object({ +export const myFormSchema = v.object({ name: textField({ label: "labels.name", maxLength: 100, @@ -92,7 +92,7 @@ items: [ Define action discriminators with `stringConstant`: ```ts -export const myFormSchema = z.object({ +export const myFormSchema = v.object({ _action: stringConstant("CREATE_ITEM"), name: textField({ label: "labels.name", maxLength: 100 }), }); @@ -103,7 +103,7 @@ export const myFormSchema = z.object({ Use `idConstant` for IDs that need default values: ```ts -export const editFormSchema = z.object({ +export const editFormSchema = v.object({ itemId: idConstant(), // Requires defaultValues name: textField({ label: "labels.name", maxLength: 100 }), }); @@ -175,12 +175,12 @@ dualSelectOptional({ ### Arrays and Fieldsets ```ts -const itemSchema = z.object({ +const itemSchema = v.object({ name: textField({ label: "labels.itemName", maxLength: 50 }), quantity: numberFieldOptional({ label: "labels.quantity" }), }); -export const formSchema = z.object({ +export const formSchema = v.object({ items: array({ label: "labels.items", min: 1, @@ -192,7 +192,7 @@ export const formSchema = z.object({ ### Union for Shared Field Definitions -Place field inside `z.union([])` to reuse across multiple schemas: +Place field inside `v.union([])` to reuse across multiple schemas: ```ts const sharedNameField = textField({ @@ -200,18 +200,18 @@ const sharedNameField = textField({ maxLength: 100, }); -const createSchema = z.object({ +const createSchema = v.object({ _action: stringConstant("CREATE"), name: sharedNameField, }); -const editSchema = z.object({ +const editSchema = v.object({ _action: stringConstant("EDIT"), id: idConstant(), name: sharedNameField, }); -export const actionSchema = z.union([createSchema, editSchema]); +export const actionSchema = v.union([createSchema, editSchema]); ``` ## Component Usage @@ -341,7 +341,7 @@ flow, while keeping `SendouForm`'s single-submit `application/json` model unchan ```ts import { image } from "~/form/fields"; -export const editTeamSchema = z.object({ +export const editTeamSchema = v.object({ teamId: idConstant(), logo: image({ label: "labels.logo" }), // logo (default) banner: image({ label: "labels.banner", dimensions: "thick-banner" }), @@ -411,15 +411,15 @@ Use `customField` for complex UI that doesn't fit standard field types: ### Schema ```ts -const povSchema = z.union([ - z.object({ type: z.literal("USER"), userId: id.optional() }), - z.object({ type: z.literal("NAME"), name: z.string().max(100) }), +const povSchema = v.union([ + v.object({ type: v.literal("USER"), userId: v.optional(id) }), + v.object({ type: v.literal("NAME"), name: v.pipe(v.string(), v.maxLength(100)) }), ]); -export const formSchema = z.object({ +export const formSchema = v.object({ pov: customField( { initialValue: { type: "USER" as const } }, - povSchema.optional() + v.optional(povSchema) ), }); ``` @@ -506,7 +506,7 @@ When you need async validation (database checks, authorization), create a separa **Base schema (`feature-schemas.ts`)** - used by both client and server: ```ts -import { z } from "zod"; +import * as v from "valibot"; import { textField, idConstantOptional } from "~/form/fields"; // Shared sync validation that can be extracted for reuse @@ -524,45 +524,65 @@ function validateGearAllOrNone(data: { // Export refine config for reuse in server schema export const gearAllOrNoneRefine = { fn: validateGearAllOrNone, - opts: { message: "forms:errors.gearAllOrNone", path: ["head"] }, + message: "forms:errors.gearAllOrNone", + path: ["head"], }; // Base schema with form field builders (for UI generation) -export const newBuildBaseSchema = z.object({ +export const newBuildBaseSchema = v.object({ buildToEditId: idConstantOptional(), title: textField({ label: "labels.buildTitle", maxLength: 50 }), // ... other fields }); // Client schema with sync refinements only -export const newBuildSchema = newBuildBaseSchema.refine( - gearAllOrNoneRefine.fn, - gearAllOrNoneRefine.opts, +export const newBuildSchema = v.pipe( + newBuildBaseSchema, + superRefine((data, ctx) => { + if (!gearAllOrNoneRefine.fn(data)) { + ctx.addIssue({ + message: gearAllOrNoneRefine.message, + path: gearAllOrNoneRefine.path, + }); + } + }), ); ``` **Server schema (`feature-schemas.server.ts`)** - adds async validation: ```ts +import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; +import { superRefine, superRefineAsync } from "~/utils/schema"; import { gearAllOrNoneRefine, newBuildBaseSchema } from "./feature-schemas"; -export const newBuildSchemaServer = newBuildBaseSchema +export const newBuildSchemaServer = v.pipeAsync( + newBuildBaseSchema, // Reuse sync refinements from base - .refine(gearAllOrNoneRefine.fn, gearAllOrNoneRefine.opts) + superRefine((data, ctx) => { + if (gearAllOrNoneRefine.fn(data)) return; + + ctx.addIssue({ + message: gearAllOrNoneRefine.message, + path: gearAllOrNoneRefine.path, + }); + }), // Add async server-only validation - .refine( - async (data) => { - if (!data.buildToEditId) return true; + superRefineAsync(async (data, ctx) => { + if (!data.buildToEditId) return; - const user = requireUser(); - const ownerId = await BuildRepository.ownerIdById(data.buildToEditId); + const user = requireUser(); + const ownerId = await BuildRepository.ownerIdById(data.buildToEditId); + if (ownerId === user.id) return; - return ownerId === user.id; - }, - { message: "Not a build you own", path: ["buildToEditId"] }, - ); + ctx.addIssue({ + message: "Not a build you own", + path: ["buildToEditId"], + }); + }), +); ``` **Action using server schema:** @@ -594,15 +614,15 @@ Check for duplicates in the database: import { createTeamSchema } from "./feature-schemas"; import * as TeamRepository from "./TeamRepository.server"; -export const createTeamSchemaServer = z.object({ - ...createTeamSchema.shape, - name: createTeamSchema.shape.name.refine( - async (name) => { +export const createTeamSchemaServer = v.objectAsync({ + ...createTeamSchema.entries, + name: v.pipeAsync( + createTeamSchema.entries.name, + v.checkAsync(async (name) => { const teams = await TeamRepository.findAllUndisbanded(); const customUrl = mySlugify(name); return !teams.some((team) => team.customUrl === customUrl); - }, - { message: "forms:errors.duplicateName" }, + }, "forms:errors.duplicateName"), ), }); ``` @@ -612,18 +632,17 @@ export const createTeamSchemaServer = z.object({ For complex validation involving multiple fields: ```ts -export const scrimsNewFormSchema = z - .object({ +export const scrimsNewFormSchema = v.pipe( + v.object({ at: datetime({ label: "labels.start" }), maps: select({ label: "labels.maps", items: mapsItems }), - mapsTournamentId: customField({ initialValue: null }, id.nullable()), - }) - .superRefine((data, ctx) => { + mapsTournamentId: customField({ initialValue: null }, v.nullable(id)), + }), + superRefine((data, ctx) => { if (data.maps === "TOURNAMENT" && !data.mapsTournamentId) { ctx.addIssue({ path: ["mapsTournamentId"], message: "errors.tournamentMustBeSelected", - code: z.ZodIssueCode.custom, }); } @@ -631,10 +650,10 @@ export const scrimsNewFormSchema = z ctx.addIssue({ path: ["mapsTournamentId"], message: "errors.tournamentOnlyWhenMapsIsTournament", - code: z.ZodIssueCode.custom, }); } - }); + }), +); ``` ## Translations @@ -733,7 +752,7 @@ import { ### Schema (`feature-schemas.ts`) ```ts -import { z } from "zod"; +import * as v from "valibot"; import { textField, textAreaOptional, @@ -742,7 +761,7 @@ import { stringConstant, } from "~/form/fields"; -export const createItemSchema = z.object({ +export const createItemSchema = v.object({ _action: stringConstant("CREATE"), name: textField({ label: "labels.itemName", diff --git a/docs/dev/search-params.md b/docs/dev/search-params.md index f1d950c6f..564068387 100644 --- a/docs/dev/search-params.md +++ b/docs/dev/search-params.md @@ -16,15 +16,15 @@ One definition per route (or feature, when several routes share params), in a sh ```ts // app/features/builds/builds-search-params.ts -import { z } from "zod"; +import * as v from "valibot"; import * as SearchParams from "~/modules/search-params/search-params"; import { SP } from "~/modules/search-params/search-params"; export const buildsSearchParams = SearchParams.define({ - limit: SP.param(z.number().int().min(1).max(100), { default: 24, loader: true }), + limit: SP.param(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)), { default: 24, loader: true }), f: SP.json(buildFiltersSchema, { default: [], resets: ["limit"], loader: true }), - focused: SP.param(z.enum(["1", "2", "3"]), { default: "1", loader: false }), - tournament: SP.param(z.string().max(100).nullable(), { loader: true }), + focused: SP.param(v.picklist(["1", "2", "3"]), { default: "1", loader: false }), + tournament: SP.param(v.nullable(v.pipe(v.string(), v.maxLength(100))), { loader: true }), }); ``` @@ -38,26 +38,26 @@ Options accepted by every declaration: ### `SP.param` and the derivation table -`SP.param(valueSchema, opts)` is the canonical declaration. The value schema is plain zod — all validation lives there, and shared schemas from `app/utils/zod.ts` plug in directly. The URL encoding is derived from the schema's type: +`SP.param(valueSchema, opts)` is the canonical declaration. The value schema is plain valibot — all validation lives there, and shared schemas from `app/utils/schema.ts` plug in directly. The URL encoding is derived from the schema's type: | Schema base type | URL encoding | | --- | --- | -| `z.string()`, string enums/literals | as-is | -| `z.number()`, number enums/literals (incl. `numericEnum`) | `String(n)` | -| `z.boolean()` | `"true"` / `"false"` only | -| `z.array(item)` | repeated keys (`?id=1&id=2`); invalid members are dropped, not the whole array | -| `.nullable()` wrapper | unwrapped; `null` encodes as param absent. `default` is omitted (it is always `null`; passing anything else throws). `.optional()` is rejected — `.nullable()` is the project-wide convention | -| refinements (`.min`, `.max`, `.refine`, …) | validation only; a failing value resolves to the default | +| `v.string()`, string picklists/literals | as-is | +| `v.number()`, number enums (incl. `numericEnum`) | `String(n)` | +| `v.boolean()` | `"true"` / `"false"` only | +| `v.array(item)` | repeated keys (`?id=1&id=2`); invalid members are dropped, not the whole array | +| `v.nullable()` wrapper | unwrapped; `null` encodes as param absent. `default` is omitted (it is always `null`; passing anything else throws). `v.optional()` is rejected — `v.nullable()` is the project-wide convention | +| validations in a pipe (`v.minValue`, `v.maxLength`, `v.check`, …) | validation only; a failing value resolves to the default | -Derivation is closed, not best-effort: shapes outside this table (objects, mixed-type unions, transforms, `z.preprocess`) are a `define()`-time error. Those use the explicit helpers: +Derivation is closed, not best-effort: shapes outside this table (objects, mixed-type unions, transforms, `preprocess`) are a `define()`-time error. Those use the explicit helpers: | Helper | Encoding | | --- | --- | | `SP.json(schema, opts)` | `JSON.stringify` in a single value — for objects and whole-array-as-one-param values | -| `SP.custom(codec, opts)` | anything — pass a `z.codec(z.string(), valueSchema, { decode, encode })` directly | +| `SP.custom(codec, opts)` | anything — pass a `codec(valueSchema, { decode, encode })` (from the search-params module; `decode` returns `undefined` for malformed input, `nullableCodec` widens with `null`) | | `SP.page(opts?)` | the paginated route's `page` param (1-based, `loader: true`, default `1`, `max` overridable) | -Note: schemas built with `z.preprocess` (like `weaponSplId`, `stageId` in `app/utils/zod.ts`) are pipes and rejected — use the inner schema (`numericEnum(mainWeaponIds)`, `numericEnum(stageIds)`) since string→number conversion is the codec's job. +Note: schemas carrying a transform are rejected — those built with `preprocess` (like `weaponSplId`, `stageId` in `app/utils/schema.ts`) and those built with `coerceNumber` (like `id`). Use the inner schema instead (`numericEnum(mainWeaponIds)`, `numericEnum(stageIds)`, `v.pipe(v.number(), v.integer(), v.minValue(1))`) since string→number conversion is the codec's job. ### Compression diff --git a/docs/styles.md b/docs/styles.md index 8029348f9..9d98a5217 100644 --- a/docs/styles.md +++ b/docs/styles.md @@ -8,7 +8,7 @@ The custom theme system lets Patreon supporters customize the sites colors, bord | ------ | --------- | | `app/styles/vars.css` | Default CSS custom property values and semantic tokens | | `app/utils/oklch-gamut.ts` | Gamut clamping math, lightness values, chroma multipliers | -| `app/utils/zod.ts` | `themeInputSchema` and `THEME_INPUT_LIMITS` for validation | +| `app/utils/schema.ts` | `themeInputSchema` and `THEME_INPUT_LIMITS` for validation | | `app/db/tables.ts` | `CustomTheme` type and `CUSTOM_THEME_VARS` list | | `app/components/CustomThemeSelector.tsx` | UI component, `DEFAULT_THEME_INPUT` | | `app/root.tsx` | `useCustomThemeVars()` applies theme to `` element | diff --git a/e2e/helpers/playwright-form.ts b/e2e/helpers/playwright-form.ts index ef4e00f27..e1be5912d 100644 --- a/e2e/helpers/playwright-form.ts +++ b/e2e/helpers/playwright-form.ts @@ -2,9 +2,10 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { expect, type Page } from "@playwright/test"; -import type { z } from "zod"; +import type * as v from "valibot"; import { getFormFieldMetadata } from "~/form/fields"; -import type { FormField } from "~/form/types"; +import type { FormField, FormObjectSchema } from "~/form/types"; +import type { AnySyncSchema } from "~/utils/schema"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -37,23 +38,25 @@ function resolveTranslation(key: string): string { return typeof value === "string" ? value : key; } -type Inferred = z.infer>; +type Inferred = v.InferOutput< + v.ObjectSchema +>; -type FillableKeys = { +type FillableKeys = { [K in keyof Inferred]-?: string extends Inferred[K] ? K : never; }[keyof Inferred]; -type CheckableKeys = { +type CheckableKeys = { [K in keyof Inferred]-?: Inferred[K] extends boolean ? K : never; }[keyof Inferred]; -type SelectableKeys = { +type SelectableKeys = { [K in keyof Inferred]-?: Inferred[K] extends string | null | undefined ? K : never; }[keyof Inferred]; -type FormFieldHelpers = { +type FormFieldHelpers = { fill: (name: FillableKeys, value: string) => Promise; check: (name: CheckableKeys) => Promise; uncheck: (name: CheckableKeys) => Promise; @@ -72,16 +75,16 @@ type FormFieldHelpers = { getItemLabel: (name: keyof Inferred, itemValue: string) => string; }; -export function createFormHelpers( +export function createFormHelpers( page: Page, - schema: z.ZodObject, + schema: FormObjectSchema, options?: { submitTestId?: string }, ): FormFieldHelpers { const submitTestId = options?.submitTestId ?? "submit-button"; const getFieldMetadata = (name: string): FormField | undefined => { - const fieldSchema = schema.shape[name]; + const fieldSchema = schema.entries[name]; if (!fieldSchema) return undefined; - return getFormFieldMetadata(fieldSchema as z.ZodType); + return getFormFieldMetadata(fieldSchema as AnySyncSchema); }; const getLabel = (name: string): string => { diff --git a/package.json b/package.json index 79796bf17..8c450924c 100644 --- a/package.json +++ b/package.json @@ -105,9 +105,9 @@ "remix-i18next": "8.0.0", "slugify": "1.6.9", "swr": "2.5.0", + "valibot": "^1.4.2", "web-haptics": "0.0.6", - "web-push": "3.6.7", - "zod": "4.4.3" + "web-push": "3.6.7" }, "devDependencies": { "@babel/preset-typescript": "7.29.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b8ebe393..0dc969230 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,15 +175,15 @@ importers: swr: specifier: 2.5.0 version: 2.5.0(react@19.2.8) + valibot: + specifier: ^1.4.2 + version: 1.4.2(typescript@7.0.2) web-haptics: specifier: 0.0.6 version: 0.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) web-push: specifier: 3.6.7 version: 3.6.7 - zod: - specifier: 4.4.3 - version: 4.4.3 devDependencies: '@babel/preset-typescript': specifier: 7.29.7 diff --git a/scripts/create-analyzer-json.ts b/scripts/create-analyzer-json.ts index a9dfcec1e..70457613b 100644 --- a/scripts/create-analyzer-json.ts +++ b/scripts/create-analyzer-json.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { z } from "zod"; +import * as v from "valibot"; import type { BaseWeaponStats, MainWeaponParams, @@ -1043,17 +1043,17 @@ function resolveSpecialWeaponId(weapon: MainWeapon) { return specialWeaponObj.Id as SpecialWeaponId; } -const overwriteSchema = z.object({ - High: z.number().optional(), - Mid: z.number().optional(), - Low: z.number().optional(), +const overwriteSchema = v.object({ + High: v.optional(v.number()), + Mid: v.optional(v.number()), + Low: v.optional(v.number()), }); function resolveOverwrites(params: any) { const result: MainWeaponParams["overwrites"] = {}; for (const [key, value] of Object.entries(params)) { - const parsed = overwriteSchema.safeParse(value); + const parsed = v.safeParse(overwriteSchema, value); resolveOverwritesWithArbitraryKeys(result, value); @@ -1066,14 +1066,14 @@ function resolveOverwrites(params: any) { const abilityKey = key.split("_").at(-1); invariant(abilityKey, `Could not find ability key for '${key}'`); - if (!parsed.data.High && !parsed.data.Mid && !parsed.data.Low) { + if (!parsed.output.High && !parsed.output.Mid && !parsed.output.Low) { continue; } result[abilityKey] = { - High: parsed.data.High, - Mid: parsed.data.Mid, - Low: parsed.data.Low, + High: parsed.output.High, + Mid: parsed.output.Mid, + Low: parsed.output.Low, }; } } diff --git a/scripts/create-league-divisions.ts b/scripts/create-league-divisions.ts index f6f5d2966..930715e34 100644 --- a/scripts/create-league-divisions.ts +++ b/scripts/create-league-divisions.ts @@ -1,6 +1,6 @@ // for testing use the command `pnpm exec vite-node ./scripts/create-league-divisions.ts 6 'https://gist.githubusercontent.com/sendou-ink/38aa4d5d8426035ce178c09598ae627f/raw/17be9bb53a9f017c2097d0624f365d1c5a029f01/league.csv'` -import { z } from "zod"; +import * as v from "valibot"; import { db } from "~/db/sql"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; @@ -20,7 +20,10 @@ invariant( const csvUrl = process.argv[3]?.trim(); -invariant(z.string().url().parse(csvUrl), "csv url is required (argument 2)"); +invariant( + v.parse(v.pipe(v.string(), v.url()), csvUrl), + "csv url is required (argument 2)", +); async function main() { const tournament = await tournamentFromDB({ @@ -122,10 +125,10 @@ async function loadCsv() { return response.text(); } -const csvSchema = z.array( - z.object({ - "Team id": z.coerce.number(), - Div: z.string(), +const csvSchema = v.array( + v.object({ + "Team id": v.pipe(v.unknown(), v.transform(Number), v.number()), + Div: v.string(), }), ); @@ -145,7 +148,7 @@ function parseCsv(csv: string) { ); }); - const validated = csvSchema.parse(rows); + const validated = v.parse(csvSchema, rows); return validated.map((row) => ({ id: row["Team id"], diff --git a/scripts/placements/index.ts b/scripts/placements/index.ts index f83a840e2..679cbe875 100644 --- a/scripts/placements/index.ts +++ b/scripts/placements/index.ts @@ -1,3 +1,4 @@ +import * as v from "valibot"; import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server"; @@ -72,7 +73,7 @@ async function processJson(args: { logger.info(`reading in ${url}...`); const json = await fetch(url).then((res) => res.json()); - const validated = xRankSchema.parse(json); + const validated = v.parse(xRankSchema, json); const array = validated.data.node.xRankingAr ?? diff --git a/scripts/placements/schemas.ts b/scripts/placements/schemas.ts index 0ebf7ff05..b5b4f120e 100644 --- a/scripts/placements/schemas.ts +++ b/scripts/placements/schemas.ts @@ -1,76 +1,76 @@ -import { z } from "zod"; +import * as v from "valibot"; -const placements = z.object({ - edges: z.array( - z.object({ - node: z.object({ - id: z.string(), - name: z.string(), - rank: z.number(), - rankDiff: z.union([z.string(), z.null()]), - xPower: z.number(), - weapon: z.object({ - name: z.string(), - image: z.object({ url: z.string() }), - id: z.string(), - image3d: z.object({ url: z.string() }), - image2d: z.object({ url: z.string() }), - image3dThumbnail: z.object({ url: z.string() }), - image2dThumbnail: z.object({ url: z.string() }), - subWeapon: z.object({ - name: z.string(), - image: z.object({ url: z.string() }), - id: z.string(), +const placements = v.object({ + edges: v.array( + v.object({ + node: v.object({ + id: v.string(), + name: v.string(), + rank: v.number(), + rankDiff: v.union([v.string(), v.null()]), + xPower: v.number(), + weapon: v.object({ + name: v.string(), + image: v.object({ url: v.string() }), + id: v.string(), + image3d: v.object({ url: v.string() }), + image2d: v.object({ url: v.string() }), + image3dThumbnail: v.object({ url: v.string() }), + image2dThumbnail: v.object({ url: v.string() }), + subWeapon: v.object({ + name: v.string(), + image: v.object({ url: v.string() }), + id: v.string(), }), - specialWeapon: z.object({ - name: z.string(), - image: z.object({ url: z.string() }), - id: z.string(), + specialWeapon: v.object({ + name: v.string(), + image: v.object({ url: v.string() }), + id: v.string(), }), }), - weaponTop: z.boolean(), - __isPlayer: z.string(), - byname: z.string(), - nameId: z.string(), - nameplate: z.object({ - badges: z.array( - z.union([ - z.object({ - image: z.object({ url: z.string() }), - id: z.string(), + weaponTop: v.boolean(), + __isPlayer: v.string(), + byname: v.string(), + nameId: v.string(), + nameplate: v.object({ + badges: v.array( + v.union([ + v.object({ + image: v.object({ url: v.string() }), + id: v.string(), }), - z.null(), + v.null(), ]), ), - background: z.object({ - textColor: z.object({ - a: z.number(), - b: z.number(), - g: z.number(), - r: z.number(), + background: v.object({ + textColor: v.object({ + a: v.number(), + b: v.number(), + g: v.number(), + r: v.number(), }), - image: z.object({ url: z.string() }), - id: z.string(), + image: v.object({ url: v.string() }), + id: v.string(), }), }), - __typename: z.string(), + __typename: v.string(), }), - cursor: z.string(), + cursor: v.string(), }), ), - pageInfo: z.object({ endCursor: z.string(), hasNextPage: z.boolean() }), + pageInfo: v.object({ endCursor: v.string(), hasNextPage: v.boolean() }), }); // e.g. https://splatoon3.ink/data/xrank/xrank.detail.a-2.clamblitz.json -export const xRankSchema = z.object({ - data: z.object({ - node: z.object({ - __typename: z.string(), - xRankingAr: placements.optional(), - xRankingCl: placements.optional(), - xRankingLf: placements.optional(), - xRankingGl: placements.optional(), - id: z.string(), +export const xRankSchema = v.object({ + data: v.object({ + node: v.object({ + __typename: v.string(), + xRankingAr: v.optional(placements), + xRankingCl: v.optional(placements), + xRankingLf: v.optional(placements), + xRankingGl: v.optional(placements), + id: v.string(), }), }), }); diff --git a/vite.config.ts b/vite.config.ts index b377816ad..8795507a0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -129,8 +129,8 @@ export default defineConfig((config) => { "remix-i18next", "sql-formatter", "swr/immutable", + "valibot", "web-haptics/react", - "zod", ], }, };