Skip to content

Commit

Permalink
Add Zod schema for ambassadors (#88)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
MattIPv4 authored Dec 12, 2024
1 parent a0a660d commit ee2c147
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 57 deletions.
31 changes: 28 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 12 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alveusgg/data",
"version": "0.47.0",
"version": "0.48.0",
"private": true,
"license": "SEE LICENSE IN LICENSE.md",
"repository": {
Expand All @@ -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
}
}
}
93 changes: 59 additions & 34 deletions src/ambassadors/core.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>;
commands: Readonly<string[]>;
class: Class;
species: string;
scientific: string;
sex: Nullable<"Male" | "Female">;
birth: Nullable<PartialDateString>;
arrival: Nullable<PartialDateString>;
retired: Nullable<PartialDateString>;
iucn: {
id: Nullable<number>;
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<typeof ambassadorSchema>;

const ambassadors = {
// Active ambassadors
Expand Down Expand Up @@ -1271,6 +1292,10 @@ const ambassadors = {
},
} as const satisfies Record<string, Ambassador>;

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 =>
Expand Down
31 changes: 25 additions & 6 deletions src/ambassadors/images.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { z } from "zod";

import { isAmbassadorKey, type Ambassadors, type AmbassadorKey } from "./core";
import {
isAmbassadorWithPlushKey,
Expand Down Expand Up @@ -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<typeof ambassadorImageSchema>;
export type AmbassadorImages = [AmbassadorImage, ...AmbassadorImage[]];

const ambassadorImages: {
Expand Down
19 changes: 13 additions & 6 deletions src/ambassadors/lifespans.ts
Original file line number Diff line number Diff line change
@@ -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<typeof lifespanSchema>;

const lifespans = {
emu: {
Expand Down
22 changes: 16 additions & 6 deletions src/iucn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]}`;
};
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = T | null;

0 comments on commit ee2c147

Please sign in to comment.