From ee2c147d27aa650940957fe1af2fbca260eb8509 Mon Sep 17 00:00:00 2001 From: Matt Cowley Date: Thu, 12 Dec 2024 16:48:09 +0000 Subject: [PATCH] Add Zod schema for ambassadors (#88) * Add type-guard for PartialDateString * Add type-guard for IUCNStatus * Add Zod as direct dependency * Add Zod schema for Lifespan * Add Zod schema for Ambassador * Add Zod schema for AmbassadorImage * Mark Zod (+ Tailwind) as peer dependency * Bump version --- package-lock.json | 31 ++++++++++-- package.json | 14 +++++- src/ambassadors/core.ts | 93 +++++++++++++++++++++++------------- src/ambassadors/images.ts | 31 +++++++++--- src/ambassadors/lifespans.ts | 19 +++++--- src/iucn.ts | 22 ++++++--- src/types.ts | 11 +++++ 7 files changed, 164 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index 541dc9c..85a04b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@alveusgg/data", - "version": "0.47.0", + "version": "0.48.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@alveusgg/data", - "version": "0.47.0", + "version": "0.48.0", "license": "SEE LICENSE IN LICENSE.md", "devDependencies": { "@typescript-eslint/eslint-plugin": "^6.2.0", @@ -16,7 +16,17 @@ "lint-staged": "^15.2.10", "prettier": "^3.0.0", "tailwindcss": "^3.4.14", - "typescript": "^5.1.6" + "typescript": "^5.1.6", + "zod": "^3.24.1" + }, + "peerDependencies": { + "tailwindcss": "^3.0.0", + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "tailwindcss": { + "optional": true + } } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -3165,6 +3175,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -5308,6 +5327,12 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "dev": true } } } diff --git a/package.json b/package.json index 451b4f0..492786e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@alveusgg/data", - "version": "0.47.0", + "version": "0.48.0", "private": true, "license": "SEE LICENSE IN LICENSE.md", "repository": { @@ -27,6 +27,16 @@ "lint-staged": "^15.2.10", "prettier": "^3.0.0", "tailwindcss": "^3.4.14", - "typescript": "^5.1.6" + "typescript": "^5.1.6", + "zod": "^3.24.1" + }, + "peerDependencies": { + "tailwindcss": "^3.0.0", + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "tailwindcss": { + "optional": true + } } } diff --git a/src/ambassadors/core.ts b/src/ambassadors/core.ts index a4133d4..766c173 100644 --- a/src/ambassadors/core.ts +++ b/src/ambassadors/core.ts @@ -1,40 +1,61 @@ -import type { IUCNStatus } from "../iucn"; -import type { EnclosureKey } from "../enclosures"; -import type { PartialDateString, Nullable } from "../types"; -import type { Class } from "./classification"; -import lifespans, { type Lifespan } from "./lifespans"; +import { z } from "zod"; -export type Ambassadors = typeof ambassadors; +import { isIUCNStatus } from "../iucn"; +import { isEnclosureKey } from "../enclosures"; +import { isPartialDateString } from "../types"; +import { isClass } from "./classification"; +import lifespans, { lifespanSchema } from "./lifespans"; -export type AmbassadorKey = keyof Ambassadors; +export const ambassadorSchema = z.object({ + name: z.string(), + alternate: z.array(z.string()).readonly(), + commands: z.array(z.string()).readonly(), + class: z.string().refine(isClass), + species: z.string(), + scientific: z.string(), + sex: z.enum(["Male", "Female"]).nullable(), + birth: z.string().refine(isPartialDateString).nullable(), + arrival: z.string().refine(isPartialDateString).nullable(), + retired: z.string().refine(isPartialDateString).nullable(), + iucn: z.object({ + id: z.number().nullable(), + status: z.string().refine(isIUCNStatus), + }), + enclosure: z.string().refine(isEnclosureKey), + story: z.string(), + mission: z.string(), + native: z.object({ + text: z.string(), + source: z.string(), + }), + lifespan: lifespanSchema, + clips: z + .array( + z.object({ + id: z.string(), + caption: z.string(), + }), + ) + .readonly(), + homepage: z + .object({ + title: z.string(), + description: z.string(), + }) + .nullable(), + plush: z + .union([ + z.object({ + link: z.string(), + }), + z.object({ + soon: z.string(), + }), + ]) + .nullable(), +}); -export type Ambassador = { - name: string; - alternate: Readonly; - commands: Readonly; - class: Class; - species: string; - scientific: string; - sex: Nullable<"Male" | "Female">; - birth: Nullable; - arrival: Nullable; - retired: Nullable; - iucn: { - id: Nullable; - status: IUCNStatus; - }; - enclosure: EnclosureKey; - story: string; - mission: string; - native: { - text: string; - source: string; - }; - lifespan: Lifespan; - clips: Readonly<{ id: string; caption: string }[]>; - homepage: Nullable<{ title: string; description: string }>; - plush: Nullable<{ link: string } | { soon: string }>; -}; +export type Ambassador = z.infer; const ambassadors = { // Active ambassadors @@ -1271,6 +1292,10 @@ const ambassadors = { }, } as const satisfies Record; +export type Ambassadors = typeof ambassadors; + +export type AmbassadorKey = keyof Ambassadors; + const ambassadorKeys = Object.keys(ambassadors) as AmbassadorKey[]; export const isAmbassadorKey = (str: string): str is AmbassadorKey => diff --git a/src/ambassadors/images.ts b/src/ambassadors/images.ts index 530eb74..5b5b230 100644 --- a/src/ambassadors/images.ts +++ b/src/ambassadors/images.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + import { isAmbassadorKey, type Ambassadors, type AmbassadorKey } from "./core"; import { isAmbassadorWithPlushKey, @@ -220,13 +222,30 @@ import winnieTheMooImageIcon from "../../assets/ambassadors/winnieTheMoo/icon.pn type OneToNine = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; type ZeroToNine = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; -type Percentage = `${OneToNine}${ZeroToNine}%` | `${ZeroToNine}%` | "100%"; - -export type AmbassadorImage = { - src: typeof stompyImage1; - alt: string; - position?: `${Percentage} ${Percentage}`; +type Percentage = `${ZeroToNine}%` | `${OneToNine}${ZeroToNine}%` | "100%"; +type Position = `${Percentage} ${Percentage}`; + +const isPercentage = (str: string): str is Percentage => + /^(100|[1-9]?[0-9])%$/.test(str); +const isPosition = (str: string): str is Position => { + const [x, y, ...rest] = str.split(" "); + return rest.length === 0 && !!x && !!y && isPercentage(x) && isPercentage(y); }; + +export const ambassadorImageSchema = z.object({ + // Use an always true refine to narrow down the type + // Ensure the image import type includes jpg + png + src: z + .unknown() + .refine( + (src: unknown): src is typeof abbottImage1 | typeof abbottImageIcon => + true, + ), + alt: z.string(), + position: z.string().refine(isPosition).optional(), +}); + +export type AmbassadorImage = z.infer; export type AmbassadorImages = [AmbassadorImage, ...AmbassadorImage[]]; const ambassadorImages: { diff --git a/src/ambassadors/lifespans.ts b/src/ambassadors/lifespans.ts index 7d7c213..ebe8b72 100644 --- a/src/ambassadors/lifespans.ts +++ b/src/ambassadors/lifespans.ts @@ -1,9 +1,16 @@ -export type Lifespan = { - // In years - wild?: number | { min: number; max: number }; - captivity?: number | { min: number; max: number }; - source: string; -}; +import { z } from "zod"; + +export const lifespanSchema = z.object({ + wild: z + .union([z.number(), z.object({ min: z.number(), max: z.number() })]) + .optional(), + captivity: z + .union([z.number(), z.object({ min: z.number(), max: z.number() })]) + .optional(), + source: z.string(), +}); + +export type Lifespan = z.infer; const lifespans = { emu: { diff --git a/src/iucn.ts b/src/iucn.ts index 7fa22fa..f1e8d05 100644 --- a/src/iucn.ts +++ b/src/iucn.ts @@ -19,19 +19,29 @@ type IUCNStatuses = keyof typeof iucnStatuses; type ICUNFlags = keyof typeof iucnFlags; export type IUCNStatus = IUCNStatuses | `${IUCNStatuses}/${ICUNFlags}`; -const isIUCNStatus = (str: string): str is IUCNStatuses => - Object.keys(iucnStatuses).includes(str as IUCNStatuses); +const isIUCNStatuses = (str: string): str is IUCNStatuses => + Object.keys(iucnStatuses).includes(str); -const isIUCNFlag = (str: string): str is ICUNFlags => - Object.keys(iucnFlags).includes(str as ICUNFlags); +const isIUCNFlags = (str: string): str is ICUNFlags => + Object.keys(iucnFlags).includes(str); + +export const isIUCNStatus = (str: string): str is IUCNStatus => { + const [status, flag, ...rest] = str.split("/"); + if (!status || rest.length > 0) return false; + + if (!isIUCNStatuses(status)) return false; + if (flag !== undefined && !isIUCNFlags(flag)) return false; + + return true; +}; export const getIUCNStatus = (fullStatus: IUCNStatus): string => { const [status, flag] = fullStatus.split("/"); - if (!status || !isIUCNStatus(status)) + if (!status || !isIUCNStatuses(status)) throw new Error(`Invalid IUCN status: ${status}`); if (!flag) return iucnStatuses[status]; - if (!isIUCNFlag(flag)) throw new Error(`Invalid IUCN flag: ${flag}`); + if (!isIUCNFlags(flag)) throw new Error(`Invalid IUCN flag: ${flag}`); return `${iucnStatuses[status]} ${iucnFlags[flag]}`; }; diff --git a/src/types.ts b/src/types.ts index 539a50d..053169a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,4 +12,15 @@ export type PartialDateString = | DateStringYearMonth | DateString; +export const isPartialDateString = ( + value: string, +): value is PartialDateString => { + const year = "(19|20)\\d{2}"; + const month = "(0[1-9]|1[0-2])"; + const day = "(0[1-9]|[12][0-9]|3[01])"; + return new RegExp( + `^(${year}|${year}-${month}|${year}-${month}-${day})$`, + ).test(value); +}; + export type Nullable = T | null;