diff --git a/__tests__/__snapshots__/nullables.test.ts.snap b/__tests__/__snapshots__/nullables.test.ts.snap index 36c411b..5bf8b2e 100644 --- a/__tests__/__snapshots__/nullables.test.ts.snap +++ b/__tests__/__snapshots__/nullables.test.ts.snap @@ -88,22 +88,23 @@ export const nullablesSchema = v.looseObject( exports[`RFC 3339 temporal formats > types.ts 1`] = ` "export type MyDate = \`\${number}-\${number}-\${number}\`; export type MyTime = \`\${number}:\${number}:\${number}\${string}\`; -export type MyDateTime = \`\${number}-\${number}-\${number}T\${number}:\${number}:\${number}\${string}\`; +export type MyDateTime = \`\${number}-\${number}-\${number}\${"T" | "t" | " "}\${number}:\${number}:\${number}\${string}\`; export type MyDuration = \`P\${string}\`; " `; exports[`RFC 3339 temporal formats > valibot.ts 1`] = ` "import * as v from "valibot"; - -export const inputMyDateSchema = v.pipe(v.string(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$/u, "date"), v.custom<\`\${number}-\${number}-\${number}\`>(() => true)); -export const myDateSchema = v.pipe(v.string(), v.trim(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$/u, "date"), v.custom<\`\${number}-\${number}-\${number}\`>(() => true)); -export const inputMyTimeSchema = v.pipe(v.string(), v.regex(/^([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "time"), v.custom<\`\${number}:\${number}:\${number}\${string}\`>(() => true)); -export const myTimeSchema = v.pipe(v.string(), v.trim(), v.regex(/^([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "time"), v.custom<\`\${number}:\${number}:\${number}\${string}\`>(() => true)); -export const inputMyDateTimeSchema = v.pipe(v.string(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "date-time"), v.custom<\`\${number}-\${number}-\${number}T\${number}:\${number}:\${number}\${string}\`>(() => true)); -export const myDateTimeSchema = v.pipe(v.string(), v.trim(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "date-time"), v.custom<\`\${number}-\${number}-\${number}T\${number}:\${number}:\${number}\${string}\`>(() => true)); -export const inputMyDurationSchema = v.pipe(v.string(), v.regex(/^P(?!$)((\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?)(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$/u, "duration"), v.custom<\`P\${string}\`>(() => true)); -export const myDurationSchema = v.pipe(v.string(), v.trim(), v.regex(/^P(?!$)((\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?)(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$/u, "duration"), v.custom<\`P\${string}\`>(() => true)); +import type { MyDate, MyDateTime, MyDuration, MyTime } from "./types.js"; + +export const inputMyDateSchema = v.pipe(v.string(), v.custom((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$/u.test(input), "date")); +export const myDateSchema = v.pipe(v.string(), v.trim(), v.custom((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$/u.test(input), "date")); +export const inputMyTimeSchema = v.pipe(v.string(), v.custom((input) => typeof input === "string" && /^([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "time")); +export const myTimeSchema = v.pipe(v.string(), v.trim(), v.custom((input) => typeof input === "string" && /^([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "time")); +export const inputMyDateTimeSchema = v.pipe(v.string(), v.custom((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "date-time")); +export const myDateTimeSchema = v.pipe(v.string(), v.trim(), v.custom((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "date-time")); +export const inputMyDurationSchema = v.pipe(v.string(), v.custom((input) => typeof input === "string" && /^P(?!$)((\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?)(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$/u.test(input), "duration")); +export const myDurationSchema = v.pipe(v.string(), v.trim(), v.custom((input) => typeof input === "string" && /^P(?!$)((\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?)(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$/u.test(input), "duration")); " `; diff --git a/__tests__/__snapshots__/temporal-formats.test.ts.snap b/__tests__/__snapshots__/temporal-formats.test.ts.snap new file mode 100644 index 0000000..ef57637 --- /dev/null +++ b/__tests__/__snapshots__/temporal-formats.test.ts.snap @@ -0,0 +1,24 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`a temporal schema names its alias, inlines a property's literal, and defers to a type hint > valibot.ts 1`] = ` +"import * as v from "valibot"; +import type { Timestamp } from "./types.js"; + +export const inputTimestampSchema = v.pipe(v.string(), v.custom((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "date-time")); +export const timestampSchema = v.pipe(v.string(), v.trim(), v.custom((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "date-time")); +export const inputWindowSchema = v.strictObject( + { + "opensAt": v.pipe(v.string(), v.custom<\`\${number}-\${number}-\${number}\${"T" | "t" | " "}\${number}:\${number}:\${number}\${string}\`>((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "date-time")) + , + "closesAt": v.optional(v.pipe(v.string(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "date-time"), v.custom(() => true))) + , + }); +export const windowSchema = v.strictObject( + { + "opensAt": v.pipe(v.string(), v.trim(), v.custom<\`\${number}-\${number}-\${number}\${"T" | "t" | " "}\${number}:\${number}:\${number}\${string}\`>((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "date-time")) + , + "closesAt": v.exactOptional(v.pipe(v.string(), v.trim(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "date-time"), v.custom(() => true))) + , + }); +" +`; diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index 82c3e30..e06450d 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,9 +1,9 @@ { - "#generator": "8e34237f9aac0959082f1a4be6dfe7dd", + "#generator": "11919a7d1783b79b2423f29e557cf1ae", "commands.ts": "593afb99bb4daed5e9491b2a55d75d75", - "types.ts": "f1e7d6c61bb9e15c5034a3cc3cc729b5", + "types.ts": "b26ed33a9b2d2f36998ab4e6b81352a0", "main.ts": "38065305823906f3aa1c7f968e278002", - "valibot.ts": "25d47efaf15282d72c20aed794bd32da", + "valibot.ts": "0715641305f23b10523ca4f6ad3e5e89", "hono.ts": "4dee43760e6f999bf0da89d4c063bcd4", "commands-validated.ts": "633c805329787be3de004224eeb86e5c", "enums.ts": "6d2420ab590db68349e2e8463a1b026d" diff --git a/__tests__/fixtures/docker/types.ts b/__tests__/fixtures/docker/types.ts index 93c4736..5932a05 100644 --- a/__tests__/fixtures/docker/types.ts +++ b/__tests__/fixtures/docker/types.ts @@ -308,7 +308,7 @@ export type Health = { * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. * @example 2020-01-04T10:44:24.496525531Z */ - Start?: `${number}-${number}-${number}T${number}:${number}:${number}${string}`; + Start?: `${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`; /** * Date and time at which this check ended in * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. @@ -338,7 +338,7 @@ export type HealthcheckResult = { * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. * @example 2020-01-04T10:44:24.496525531Z */ - Start?: `${number}-${number}-${number}T${number}:${number}:${number}${string}`; + Start?: `${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`; /** * Date and time at which this check ended in * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. @@ -5434,7 +5434,7 @@ export type ContainerState = { * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. * @example 2020-01-04T10:44:24.496525531Z */ - Start?: `${number}-${number}-${number}T${number}:${number}:${number}${string}`; + Start?: `${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`; /** * Date and time at which this check ended in * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. @@ -7826,7 +7826,7 @@ export type ContainerInspectCommandOutput = { * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. * @example 2020-01-04T10:44:24.496525531Z */ - Start?: `${number}-${number}-${number}T${number}:${number}:${number}${string}`; + Start?: `${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`; /** * Date and time at which this check ended in * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. diff --git a/__tests__/fixtures/docker/valibot.ts b/__tests__/fixtures/docker/valibot.ts index bb7fd22..d9a541f 100644 --- a/__tests__/fixtures/docker/valibot.ts +++ b/__tests__/fixtures/docker/valibot.ts @@ -1534,13 +1534,14 @@ export const inputHealthSchema = v.nullable( Start: v.optional( v.pipe( v.string(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( - () => true, - ), ), ), /** @@ -1599,13 +1600,14 @@ export const healthSchema = v.nullable( v.pipe( v.string(), v.trim(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( - () => true, - ), ), ), /** @@ -1645,13 +1647,14 @@ export const inputHealthcheckResultSchema = v.nullable( Start: v.optional( v.pipe( v.string(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( - () => true, - ), ), ), /** @@ -1684,13 +1687,14 @@ export const healthcheckResultSchema = v.nullable( v.pipe( v.string(), v.trim(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( - () => true, - ), ), ), /** @@ -20823,13 +20827,14 @@ export const inputContainerStateSchema = v.nullable( Start: v.optional( v.pipe( v.string(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( - () => true, - ), ), ), /** @@ -20959,13 +20964,14 @@ export const containerStateSchema = v.nullable( v.pipe( v.string(), v.trim(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( - () => true, - ), ), ), /** @@ -28290,13 +28296,14 @@ export const inputContainerInspectCommandResponseSchema = v.looseObject({ Start: v.optional( v.pipe( v.string(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( - () => true, - ), ), ), /** @@ -29715,13 +29722,14 @@ export const containerInspectCommandResponseSchema = v.looseObject({ v.pipe( v.string(), v.trim(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( - () => true, - ), ), ), /** diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index 7cfb4e6..347ad8a 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "8e34237f9aac0959082f1a4be6dfe7dd", + "#generator": "a1d1be2090fb6bef18a3285d1780a1bc", "commands.ts": "99ac148005b23e09a53adb6b62620dc0", "types.ts": "bc453b1a7fcd330909a23ae01be97f52", "main.ts": "8147604a37254400a015b78650466094", diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index a3412d7..9e9cec5 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "8e34237f9aac0959082f1a4be6dfe7dd", + "#generator": "a1d1be2090fb6bef18a3285d1780a1bc", "commands.ts": "69c6a9f2924568fc08fd489508c9df34", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", diff --git a/__tests__/fixtures/test1.json b/__tests__/fixtures/test1.json index fa71c40..03e1aa4 100644 --- a/__tests__/fixtures/test1.json +++ b/__tests__/fixtures/test1.json @@ -40,6 +40,18 @@ "type": "string", "format": "date-time" }, + "CalendarDate": { + "type": "string", + "format": "date" + }, + "WallTime": { + "type": "string", + "format": "time" + }, + "TrialLength": { + "type": "string", + "format": "duration" + }, "Email": { "description": "Valid email address with fully qualified public top-level domain", "type": "string", @@ -91,6 +103,21 @@ "pattern": "^https://embed\\.example\\.com/.+$", "x-typescript-hint": "`https://embed.example.com/${string}`" }, + "EmbedLink": { + "type": "object", + "additionalProperties": false, + "required": ["url"], + "properties": { + "url": { + "type": "string", + "x-typescript-hint": "EmbedUrl" + }, + "label": { + "type": "string", + "x-typescript-hint": "Capitalize" + } + } + }, "EventSource": { "oneOf": [ { diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index 8e06187..879fa69 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,9 +1,9 @@ { - "#generator": "8e34237f9aac0959082f1a4be6dfe7dd", + "#generator": "a1d1be2090fb6bef18a3285d1780a1bc", "commands.ts": "eab0f690af8f1e2d973f6504de470ba9", - "types.ts": "7bb3f162d3321e4db51673d3f3ba35ac", + "types.ts": "03487f5ea8bf5b977329f4e71a1bfb39", "main.ts": "5c768be2e48d0b06d7ed8da665653404", - "valibot.ts": "c8e831e3378ad09f85be0d368e1f0cf2", + "valibot.ts": "edf27c46cd7807a4419b9693b128c448", "hono.ts": "12cc7ef64bfc4acf632ba9d9011880d8", "commands-validated.ts": "afd10b6462e83dbe33e5d42dd227cd97", "enums.ts": "26ade5ccad3bf30118ce8691b99b7c91" diff --git a/__tests__/fixtures/test1/types.ts b/__tests__/fixtures/test1/types.ts index 377c0e7..66358da 100644 --- a/__tests__/fixtures/test1/types.ts +++ b/__tests__/fixtures/test1/types.ts @@ -8,6 +8,9 @@ import type { JsonValue, UndefinedOnPartialDeep } from "type-fest"; +export type CalendarDate = `${number}-${number}-${number}`; +export type WallTime = `${number}:${number}:${number}${string}`; +export type TrialLength = `P${string}`; export type PromoCode = string; /** The API version */ export type ApiVersion = "2024-01-01"; @@ -19,13 +22,17 @@ export type NullableDiscount = number | null; export type NullableStatus = "active" | "paused" | "cancelled" | null; export type AccountTier = "free" | "pro" | "enterprise"; export type EmbedUrl = `https://embed.example.com/${string}`; +export type EmbedLink = { + url: EmbedUrl; + label?: Capitalize; +}; export type EventSource = | "native" | `https://embed.example.com/${string}` | `https://syndicated.example.com/${string}`; export type StripeId = string; export type DateTime = - `${number}-${number}-${number}T${number}:${number}:${number}${string}`; + `${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`; export type BillingSubscriptionStatus = "active" | "inactive"; export type BillingSubscriptionInterval = "monthly" | "yearly"; export type PlanSku = diff --git a/__tests__/fixtures/test1/valibot.ts b/__tests__/fixtures/test1/valibot.ts index 4bc4ae8..83e9e92 100644 --- a/__tests__/fixtures/test1/valibot.ts +++ b/__tests__/fixtures/test1/valibot.ts @@ -8,6 +8,13 @@ import type { JsonValue } from "type-fest"; import * as v from "valibot"; +import type { + CalendarDate, + DateTime, + EmbedUrl, + TrialLength, + WallTime, +} from "./types.js"; const jsonValueSchema: v.GenericSchema = v.lazy(() => v.union([ @@ -19,6 +26,71 @@ const jsonValueSchema: v.GenericSchema = v.lazy(() => v.array(jsonValueSchema), ]), ); +export const inputCalendarDateSchema = v.pipe( + v.string(), + v.custom( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u.test(input), + "date", + ), +); +export const calendarDateSchema = v.pipe( + v.string(), + v.trim(), + v.custom( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u.test(input), + "date", + ), +); +export const inputWallTimeSchema = v.pipe( + v.string(), + v.custom( + (input) => + typeof input === "string" && + /^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), + "time", + ), +); +export const wallTimeSchema = v.pipe( + v.string(), + v.trim(), + v.custom( + (input) => + typeof input === "string" && + /^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), + "time", + ), +); +export const inputTrialLengthSchema = v.pipe( + v.string(), + v.custom( + (input) => + typeof input === "string" && + /^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u.test( + input, + ), + "duration", + ), +); +export const trialLengthSchema = v.pipe( + v.string(), + v.trim(), + v.custom( + (input) => + typeof input === "string" && + /^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u.test( + input, + ), + "duration", + ), +); export const inputPromoCodeSchema = v.pipe( v.string(), v.minLength(1), @@ -50,6 +122,32 @@ export const inputEmbedUrlSchema = v.pipe( v.custom<`https://embed.example.com/${string}`>(() => true), ); export const embedUrlSchema = inputEmbedUrlSchema; +export const inputEmbedLinkSchema = v.strictObject({ + url: v.pipe( + v.string(), + v.custom(() => true), + ), + label: v.optional( + v.pipe( + v.string(), + v.custom>(() => true), + ), + ), +}); +export const embedLinkSchema = v.strictObject({ + url: v.pipe( + v.string(), + v.trim(), + v.custom(() => true), + ), + label: v.exactOptional( + v.pipe( + v.string(), + v.trim(), + v.custom>(() => true), + ), + ), +}); export const inputEventSourceSchema = v.union([ v.picklist(["native"]), v.pipe( @@ -73,24 +171,26 @@ export const inputStripeIdSchema = v.pipe( export const stripeIdSchema = inputStripeIdSchema; export const inputDateTimeSchema = v.pipe( v.string(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( - () => true, - ), ); export const dateTimeSchema = v.pipe( v.string(), v.trim(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( - () => true, - ), ); export const inputBillingSubscriptionStatusSchema = v.picklist([ "active", diff --git a/__tests__/temporal-formats.test.ts b/__tests__/temporal-formats.test.ts new file mode 100644 index 0000000..c9782dc --- /dev/null +++ b/__tests__/temporal-formats.test.ts @@ -0,0 +1,155 @@ +import type { oas32 } from "openapi3-ts"; +import * as v from "valibot"; +import { expect, expectTypeOf, test } from "vitest"; +import { processOpenApiDocument } from "../lib/process-document.ts"; +import type { + CalendarDate, + DateTime, + TrialLength, + WallTime, +} from "./fixtures/test1/types.ts"; +import { + calendarDateSchema, + dateTimeSchema, + inputCalendarDateSchema, + inputDateTimeSchema, + inputTrialLengthSchema, + inputWallTimeSchema, + trialLengthSchema, + wallTimeSchema, +} from "./fixtures/test1/valibot.ts"; + +// regex-accepted values, so `satisfies` fails where a format's type is narrower than its regex +const dates = [ + "2024-02-29", + "0000-01-01", + "9999-12-31", +] as const satisfies readonly CalendarDate[]; + +const times = [ + "00:00:00Z", + "12:30:45z", + "23:59:60.123456+05:30", + "08:15:00-08:00", +] as const satisfies readonly WallTime[]; + +const dateTimes = [ + "2024-01-01T00:00:00Z", + "2024-01-01t00:00:00z", + "2024-01-01 23:59:60.5+05:30", + "1999-12-31T23:59:59-00:00", +] as const satisfies readonly DateTime[]; + +const durations = [ + "P1Y2M3W4DT5H6M7.5S", + "PT0S", + "P1W", + "PT1.5S", +] as const satisfies readonly TrialLength[]; + +test.each(dates)("a date %j parses and has the date type", (value) => { + expect(v.safeParse(calendarDateSchema, value)).toMatchObject({ + success: true, + output: value, + }); +}); + +test.each(times)("a time %j parses and has the time type", (value) => { + expect(v.safeParse(wallTimeSchema, value)).toMatchObject({ + success: true, + output: value, + }); +}); + +test.each(dateTimes)( + "a date-time %j parses and has the date-time type", + (value) => { + expect(v.safeParse(dateTimeSchema, value)).toMatchObject({ + success: true, + output: value, + }); + }, +); + +test.each(durations)( + "a duration %j parses and has the duration type", + (value) => { + expect(v.safeParse(trialLengthSchema, value)).toMatchObject({ + success: true, + output: value, + }); + }, +); + +test("a temporal format's schemas output its generated type", () => { + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); +}); + +test.each([ + ["date-time", dateTimeSchema, "2024-13-01T00:00:00Z"], + ["date-time", dateTimeSchema, "2024-01-01T00:00:00"], + ["date-time", dateTimeSchema, "2024-01-01"], + ["date-time", inputDateTimeSchema, "2024-01-01T24:00:00Z"], + ["date", calendarDateSchema, "2024-1-01"], + ["date", inputCalendarDateSchema, "2024-01-32"], + ["time", wallTimeSchema, "12:00:00"], + ["time", inputWallTimeSchema, "12:60:00Z"], + ["duration", trialLengthSchema, "P"], + ["duration", inputTrialLengthSchema, "PT"], + ["duration", trialLengthSchema, "1D"], +])("a malformed %s %j is rejected", (_format, schema, value) => { + expect(v.safeParse(schema, value).success).toBe(false); +}); + +test("a temporal schema names its alias, inlines a property's literal, and defers to a type hint", async () => { + const schema: oas32.OpenAPIObject = { + openapi: "3.1.0", + info: { title: "Test", version: "1.0.0" }, + paths: {}, + components: { + schemas: { + Timestamp: { type: "string", format: "date-time" }, + Window: { + type: "object", + additionalProperties: false, + required: ["opensAt"], + properties: { + opensAt: { type: "string", format: "date-time" }, + closesAt: { + type: "string", + format: "date-time", + "x-typescript-hint": "Timestamp", + }, + }, + }, + }, + }, + }; + + const result = await processOpenApiDocument("/tmp/whatever", schema); + + expect(result.valibotFile.getText()).toMatchSnapshot("valibot.ts"); +}); diff --git a/__tests__/typescript-hint.test.ts b/__tests__/typescript-hint.test.ts index d18be04..79fc471 100644 --- a/__tests__/typescript-hint.test.ts +++ b/__tests__/typescript-hint.test.ts @@ -75,3 +75,40 @@ test("x-typescript-hint honored inside anyOf branches", async () => { expect(result.typesFile.getText()).toMatchSnapshot("types.ts"); }); + +test("x-typescript-hint naming a generated type imports it into the validators", async () => { + const schema: oas32.OpenAPIObject = { + ...baseDoc, + components: { + schemas: { + EmbedUrl: { + type: "string", + "x-typescript-hint": "`https://embed.example.com/${string}`", + }, + EmbedLink: { + type: "object", + properties: { + url: { type: "string", "x-typescript-hint": "EmbedUrl" }, + label: { + type: "string", + "x-typescript-hint": "Capitalize", + }, + }, + }, + }, + }, + }; + + const result = await processOpenApiDocument( + "/tmp/like-you-know-whatever", + schema, + ); + + const imported = result.valibotFile + .getImportDeclarations() + .flatMap((declaration) => declaration.getNamedImports()) + .map((specifier) => specifier.getName()); + + expect(imported).toContain("EmbedUrl"); + expect(imported).not.toContain("Capitalize"); +}); diff --git a/lib/process-document.ts b/lib/process-document.ts index cf8a524..e39df73 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -47,7 +47,7 @@ import { createValibotFile, createValidatorForOperationInput, declareValidator, - importLazyTypes, + importNamedTypes, type ValidatorEntry, registerValidatorFromSchema, addJsonValueSchemaWhenUsed, @@ -2341,9 +2341,9 @@ export async function processOpenApiDocument( files.typesFile.fixUnusedIdentifiers(); files.commandsFile.fixUnusedIdentifiers(); files.commandsValidatedFile.fixUnusedIdentifiers(); - importLazyTypes( + importNamedTypes( files.valibotFile, - documentCtx.validators, + files.typesFile, buildTypesModuleSpecifier(files.typesFile), ); addJsonValueSchemaWhenUsed(files.valibotFile); diff --git a/lib/process-schema.ts b/lib/process-schema.ts index 63be389..0d8ef31 100644 --- a/lib/process-schema.ts +++ b/lib/process-schema.ts @@ -13,6 +13,7 @@ import { } from "ts-morph"; import { openChunk } from "./chunks.ts"; import { extractSchemaName, schemaRef } from "./refs.ts"; +import { temporalFormat } from "./temporal.ts"; import { type SchemaNode, type SchemaObject, @@ -27,26 +28,6 @@ function maybeWithNullUnion(type: string | WriterFunction, withNull = false) { return withNull && type !== "null" ? Writers.unionType(type, "null") : type; } -// Template-literal type per RFC 3339 temporal format, mirrored in valibot -function temporalStringType(format: string | undefined) { - switch (format) { - case "date": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`${number}-${number}-${number}`"; - case "time": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`${number}:${number}:${number}${string}`"; - case "date-time": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`${number}-${number}-${number}T${number}:${number}:${number}${string}`"; - case "duration": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`P${string}`"; - default: - return; - } -} - // int64 maps to bigint, and every other integer or number maps to number function numericType(isInt64: boolean, stringish: boolean | undefined) { // int64 reaches past Number.MAX_SAFE_INTEGER, and query, header, path and @@ -527,9 +508,7 @@ function stringType(schemaObject: SchemaObject) { return schemaObject["x-typescript-hint"]; } - const temporal = temporalStringType(schemaObject.format); - - return temporal ?? "string"; + return temporalFormat(schemaObject.format)?.type ?? "string"; } function schemaObjectType( @@ -799,11 +778,8 @@ function stringAliasType(schemaObject: SchemaObject) { ); } - // RFC 3339 temporal formats (date, date-time, time, duration) - const temporal = temporalStringType(schemaObject.format); - return maybeWithNullUnion( - temporal || "string", + temporalFormat(schemaObject.format)?.type ?? "string", schemaTypeIsNull(schemaObject), ); } diff --git a/lib/temporal.ts b/lib/temporal.ts new file mode 100644 index 0000000..a3b6f58 --- /dev/null +++ b/lib/temporal.ts @@ -0,0 +1,34 @@ +/** + * Maps an RFC 3339 temporal format to the regex literal that checks a value, + * and the template-literal type every match satisfies + */ +export function temporalFormat(format: string | undefined) { + // RFC 3339 departs from ISO 8601 in ways these patterns encode. `date-time` + // and `time` require an offset. The seconds field admits a leap second of + // `60`. `T` and `Z` may be lower case, and a space may separate the date + // from the time. `duration` follows the ISO 8601 grammar in Appendix A + switch (format) { + case "date": + return { + regex: String.raw`/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u`, + type: "`${number}-${number}-${number}`", + }; + case "time": + return { + regex: String.raw`/^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u`, + type: "`${number}:${number}:${number}${string}`", + }; + case "date-time": + return { + regex: String.raw`/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u`, + type: '`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`', + }; + case "duration": + return { + regex: String.raw`/^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u`, + type: "`P${string}`", + }; + default: + return; + } +} diff --git a/lib/valibot.ts b/lib/valibot.ts index 9bcbeb5..d13f4aa 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { oas30, oas32 } from "openapi3-ts"; import { type CodeBlockWriter, + Node, type Project, type SourceFile, SyntaxKind, @@ -13,6 +14,7 @@ import type { Primitive } from "type-fest"; import type * as v from "valibot"; import { openChunk } from "./chunks.ts"; import { schemaRef } from "./refs.ts"; +import { temporalFormat } from "./temporal.ts"; import { type SchemaNode, type SchemaObject, @@ -36,8 +38,6 @@ export type ValidatorEntry = { emitted: boolean; }; -const lazilyTyped = new WeakMap, Set>(); - // oxlint groups integer digits in threes once a literal reaches five digits function numericLiteral(value: number) { const text = String(value); @@ -144,66 +144,25 @@ function minMaxProperties(schema: SchemaObject) { const noTrimFormats = new Set(["uuid", "byte", "binary", "password"]); -// RFC 3339 temporal formats, validated by regex at runtime -function temporalRegexConstraint(format: string | undefined) { - // RFC 3339 departs from ISO 8601 in ways these patterns encode. `date-time` - // and `time` require an offset. The seconds field admits a leap second of - // `60`. `T` and `Z` may be lower case, and a space may separate the date - // from the time. `duration` follows the ISO 8601 grammar in Appendix A - switch (format) { - case "date": - return vcall( - "regex", - String.raw`/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u`, - JSON.stringify(format), - ); - case "time": - return vcall( - "regex", - String.raw`/^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u`, - JSON.stringify(format), - ); - case "date-time": - return vcall( - "regex", - String.raw`/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u`, - JSON.stringify(format), - ); - case "duration": - return vcall( - "regex", - String.raw`/^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u`, - JSON.stringify(format), - ); - default: - return; +// Runs the format's regex in `v.custom` to type the output, or alone when a type hint names it +function temporalConstraint( + format: string | undefined, + typeName: string | undefined, + hinted: boolean, +) { + const temporal = temporalFormat(format); + + if (!temporal) { + return; } -} -// Template-literal type per temporal format, mirrored in process-schema -function temporalTypeHint(format: string | undefined) { - switch (format) { - case "date": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`${number}-${number}-${number}`"; - case "time": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`${number}:${number}:${number}${string}`"; - case "date-time": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`${number}-${number}-${number}T${number}:${number}:${number}${string}`"; - case "duration": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`P${string}`"; - default: - return; + const message = JSON.stringify(format); + + if (hinted) { + return vcall("regex", temporal.regex, message); } -} -// Narrows the inferred output to the template-literal type, past the regex -function temporalHintSchema(format: string | undefined) { - const type = temporalTypeHint(format); - return type ? `v.custom<${type}>(() => true)` : undefined; + return `v.custom<${typeName ?? temporal.type}>((input) => typeof input === "string" && ${temporal.regex}.test(input), ${message})`; } function shouldCoerceString(schema: SchemaObject) { @@ -273,10 +232,6 @@ function resolveRef( // A recursive schema refers to one that is not declared yet. The // annotation stops TypeScript inferring its type from itself - const types = lazilyTyped.get(validators) ?? new Set(); - types.add(entry.type); - lazilyTyped.set(validators, types); - const type = mode === "input" || entry.wireIsInput ? `UndefinedOnPartialDeep<${entry.type}>` @@ -287,27 +242,38 @@ function resolveRef( } /** - * Imports the types that annotate lazy refs. Call it once every validator - * is emitted + * Imports each type the types module exports and a validator names, whether a + * lazy ref annotates it or an `x-typescript-hint` names it. Call it once every + * validator is emitted */ -export function importLazyTypes( +export function importNamedTypes( file: SourceFile, - validators: Map, + typesFile: SourceFile, typesModuleSpecifier: string, ) { - const types = lazilyTyped.get(validators); + const exported = typesFile.getExportedDeclarations(); + + const named = new Set( + file + .getDescendantsOfKind(SyntaxKind.TypeReference) + .map((reference) => reference.getTypeName()) + .filter((name) => Node.isIdentifier(name)) + .map((name) => name.getText()), + ); - if (!types) { - return; - } + const fromTypes = [...named].filter((name) => exported.has(name)); - file.addImportDeclaration({ - moduleSpecifier: typesModuleSpecifier, - namedImports: [...types], - isTypeOnly: true, - }); + if (fromTypes.length > 0) { + file.addImportDeclaration({ + moduleSpecifier: typesModuleSpecifier, + namedImports: fromTypes.toSorted(), + isTypeOnly: true, + }); + } - importFromTypeFest(file, "UndefinedOnPartialDeep"); + if (named.has("UndefinedOnPartialDeep")) { + importFromTypeFest(file, "UndefinedOnPartialDeep"); + } } function importFromTypeFest(file: SourceFile, name: string) { @@ -484,6 +450,7 @@ function stringValidator( mode: SchemaMode, isNullable: boolean, typescriptHintSchema: string | undefined, + temporalTypeName?: string, ) { const shouldTrim = mode === "wire" && @@ -496,7 +463,11 @@ function stringValidator( shouldTrim ? vcall("trim") : undefined, schema.format === "email" ? vcall("email") : undefined, schema.format === "uuid" ? vcall("uuid") : undefined, - temporalRegexConstraint(schema.format), + temporalConstraint( + schema.format, + temporalTypeName, + typescriptHintSchema !== undefined, + ), schema.minLength === undefined ? undefined @@ -507,8 +478,6 @@ function stringValidator( schema.pattern ? vcall("regex", `new RegExp(${regexSource(schema.pattern)})`) : undefined, - // A hint set by the `x-typescript-hint` extension wins over the format - typescriptHintSchema ? undefined : temporalHintSchema(schema.format), typescriptHintSchema, ), isNullable, @@ -933,6 +902,23 @@ export function createValibotFile(project: Project, outputDir: string) { return file; } +// Whether the schema's generated type alias is exactly its format's template literal +function isTemporalAlias(schema: SchemaNode): schema is SchemaObject { + return ( + isSchemaObject(schema) && + schema.type === "string" && + !("$ref" in schema) && + !("const" in schema) && + !schema.enum && + !schema.oneOf && + !schema.anyOf && + !schema.allOf && + !schemaIsNullable(schema) && + !extensionHintSchema(schema) && + temporalFormat(schema.format) !== undefined + ); +} + export function registerValidatorFromSchema( validators: Map, valibotFile: SourceFile, @@ -982,6 +968,13 @@ export function registerValidatorFromSchema( ] : []; + // A temporal alias's schema outputs the alias by name, so the template + // literal is written out once, in the types module + const initializer = (mode: SchemaMode) => + isTemporalAlias(schemaObject) + ? stringValidator(schemaObject, mode, false, undefined, entry.type) + : schemaToValidator(validators, schemaObject, mode); + // Input schema — always emitted (TS-side, allows undefined, no wire coercion) openChunk(valibotFile).addVariableStatement({ isExported: true, @@ -990,7 +983,7 @@ export function registerValidatorFromSchema( declarations: [ { name: inputName, - initializer: schemaToValidator(validators, schemaObject, "input"), + initializer: initializer("input"), }, ], }); @@ -1007,7 +1000,7 @@ export function registerValidatorFromSchema( declarations: [ { name: wireName, - initializer: schemaToValidator(validators, schemaObject, "wire"), + initializer: initializer("wire"), }, ], });