diff --git a/examples/blog/astro.config.mjs b/examples/blog/astro.config.mjs index 0dbd924c3929..e4fac76ec350 100644 --- a/examples/blog/astro.config.mjs +++ b/examples/blog/astro.config.mjs @@ -8,4 +8,7 @@ import { defineConfig } from 'astro/config'; export default defineConfig({ site: 'https://example.com', integrations: [mdx(), sitemap()], + experimental: { + zod4: true + } }); diff --git a/examples/blog/package.json b/examples/blog/package.json index 3065d1624375..afcf52a37d67 100644 --- a/examples/blog/package.json +++ b/examples/blog/package.json @@ -14,6 +14,7 @@ "@astrojs/rss": "^4.0.12", "@astrojs/sitemap": "^3.6.0", "astro": "^5.14.7", - "sharp": "^0.34.3" + "sharp": "^0.34.3", + "zod": "^4.0.0" } } diff --git a/examples/blog/src/content.config.ts b/examples/blog/src/content.config.ts index ce37c7f6ff34..300f288d883a 100644 --- a/examples/blog/src/content.config.ts +++ b/examples/blog/src/content.config.ts @@ -1,5 +1,6 @@ -import { defineCollection, z } from 'astro:content'; +import { defineCollection } from 'astro:content'; import { glob } from 'astro/loaders'; +import { z } from 'zod/v3' const blog = defineCollection({ // Load Markdown and MDX files in the `src/content/blog/` directory. @@ -12,7 +13,7 @@ const blog = defineCollection({ // Transform string to Date object pubDate: z.coerce.date(), updatedDate: z.coerce.date().optional(), - heroImage: image().optional(), + heroImage: (image()).optional(), }), }); diff --git a/packages/astro/dev-only.d.ts b/packages/astro/dev-only.d.ts index 215484d5376b..0796ac1e3a30 100644 --- a/packages/astro/dev-only.d.ts +++ b/packages/astro/dev-only.d.ts @@ -18,3 +18,7 @@ declare module 'virtual:astro:actions/options' { declare module 'virtual:astro:actions/runtime' { export * from './src/actions/runtime/client.js'; } + +declare module 'virtual:astro:config/experimentalZod4' { + export const experimentalZod4: boolean; +} diff --git a/packages/astro/package.json b/packages/astro/package.json index 4daea4486fbe..61c4f82cb0d5 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -135,6 +135,7 @@ "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.0", + "json-schema-to-typescript": "^15.0.4", "kleur": "^4.1.5", "magic-string": "^0.30.18", "magicast": "^0.3.5", @@ -162,7 +163,7 @@ "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", - "zod": "^3.25.76", + "zod": "^3.25.76 || ^4.0.0", "zod-to-json-schema": "^3.24.6", "zod-to-ts": "^1.2.0" }, diff --git a/packages/astro/src/actions/runtime/server.ts b/packages/astro/src/actions/runtime/server.ts index 9fc12183fc76..a0f6d920df4a 100644 --- a/packages/astro/src/actions/runtime/server.ts +++ b/packages/astro/src/actions/runtime/server.ts @@ -1,8 +1,8 @@ -import { z } from 'zod'; +import * as z3 from 'zod/v3'; +import * as z4 from 'zod/v4/core'; import type { Pipeline } from '../../core/base-pipeline.js'; import { shouldAppendForwardSlash } from '../../core/build/util.js'; -import { AstroError } from '../../core/errors/errors.js'; -import { ActionCalledFromServerError, ActionNotFoundError } from '../../core/errors/errors-data.js'; +import { ActionNotFoundError } from '../../core/errors/errors-data.js'; import { removeTrailingForwardSlash } from '../../core/path.js'; import { apiContextRoutesSymbol } from '../../core/render-context.js'; import type { APIContext } from '../../types/public/index.js'; @@ -11,7 +11,6 @@ import { ACTION_QUERY_PARAMS, ActionError, ActionInputError, - callSafely, deserializeActionResult, type SafeResult, type SerializedActionResult, @@ -24,7 +23,6 @@ import { type ErrorInferenceObject, formContentTypes, hasContentType, - isActionAPIContext, type MaybePromise, } from './utils.js'; @@ -32,78 +30,55 @@ export * from './shared.js'; export type ActionAccept = 'form' | 'json'; -export type ActionHandler = TInputSchema extends z.ZodType - ? (input: z.infer, context: ActionAPIContext) => MaybePromise - : (input: any, context: ActionAPIContext) => MaybePromise; +export type ActionHandler = TInputSchema extends z3.ZodType + ? (input: z3.infer, context: ActionAPIContext) => MaybePromise + : TInputSchema extends z4.$ZodType + ? (input: z4.infer, context: ActionAPIContext) => MaybePromise + : (input: any, context: ActionAPIContext) => MaybePromise; export type ActionReturnType> = Awaited>; export type ActionClient< TOutput, TAccept extends ActionAccept | undefined, - TInputSchema extends z.ZodType | undefined, -> = TInputSchema extends z.ZodType + TInputSchema extends z3.ZodType | z4.$ZodType | undefined, +> = TInputSchema extends z3.ZodType ? (( - input: TAccept extends 'form' ? FormData : z.input, + input: TAccept extends 'form' ? FormData : z3.input, ) => Promise< SafeResult< - z.input extends ErrorInferenceObject - ? z.input + z3.input extends ErrorInferenceObject + ? z3.input : ErrorInferenceObject, Awaited > >) & { queryString: string; orThrow: ( - input: TAccept extends 'form' ? FormData : z.input, + input: TAccept extends 'form' ? FormData : z3.input, ) => Promise>; } - : ((input?: any) => Promise>>) & { - orThrow: (input?: any) => Promise>; - }; - -export function defineAction< - TOutput, - TAccept extends ActionAccept | undefined = undefined, - TInputSchema extends z.ZodType | undefined = TAccept extends 'form' - ? // If `input` is omitted, default to `FormData` for forms and `any` for JSON. - z.ZodType - : undefined, ->({ - accept, - input: inputSchema, - handler, -}: { - input?: TInputSchema; - accept?: TAccept; - handler: ActionHandler; -}): ActionClient & string { - const serverHandler = - accept === 'form' - ? getFormServerHandler(handler, inputSchema) - : getJsonServerHandler(handler, inputSchema); - - async function safeServerHandler(this: ActionAPIContext, unparsedInput: unknown) { - // The ActionAPIContext should always contain the `params` property - if (typeof this === 'function' || !isActionAPIContext(this)) { - throw new AstroError(ActionCalledFromServerError); - } - return callSafely(() => serverHandler(unparsedInput, this)); - } - - Object.assign(safeServerHandler, { - orThrow(this: ActionAPIContext, unparsedInput: unknown) { - if (typeof this === 'function') { - throw new AstroError(ActionCalledFromServerError); + : TInputSchema extends z4.$ZodType + ? (( + input: TAccept extends 'form' ? FormData : z4.input, + ) => Promise< + SafeResult< + z4.input extends ErrorInferenceObject + ? z4.input + : ErrorInferenceObject, + Awaited + > + >) & { + queryString: string; + orThrow: ( + input: TAccept extends 'form' ? FormData : z4.input, + ) => Promise>; } - return serverHandler(unparsedInput, this); - }, - }); - - return safeServerHandler as ActionClient & string; -} + : ((input?: any) => Promise>>) & { + orThrow: (input?: any) => Promise>; + }; -function getFormServerHandler( +export function getFormServerHandler( handler: ActionHandler, inputSchema?: TInputSchema, ) { @@ -117,12 +92,24 @@ function getFormServerHandler( if (!inputSchema) return await handler(unparsedInput, context); - const baseSchema = unwrapBaseObjectSchema(inputSchema, unparsedInput); - const parsed = await inputSchema.safeParseAsync( - baseSchema instanceof z.ZodObject - ? formDataToObject(unparsedInput, baseSchema) - : unparsedInput, - ); + let parsed; + if ('_zod' in inputSchema) { + const baseSchema = unwrapBaseZ4ObjectSchema(inputSchema, unparsedInput); + parsed = await z4.safeParseAsync( + inputSchema, + baseSchema instanceof z4.$ZodObject + ? formDataToZ4Object(unparsedInput, baseSchema) + : unparsedInput, + ); + } else { + const baseSchema = unwrapBaseZ3ObjectSchema(inputSchema, unparsedInput); + parsed = await inputSchema.safeParseAsync( + baseSchema instanceof z3.ZodObject + ? formDataToZ3Object(unparsedInput, baseSchema) + : unparsedInput, + ); + } + if (!parsed.success) { throw new ActionInputError(parsed.error.issues); } @@ -130,7 +117,7 @@ function getFormServerHandler( }; } -function getJsonServerHandler( +export function getJsonServerHandler( handler: ActionHandler, inputSchema?: TInputSchema, ) { @@ -143,7 +130,10 @@ function getJsonServerHandler( } if (!inputSchema) return await handler(unparsedInput, context); - const parsed = await inputSchema.safeParseAsync(unparsedInput); + const parsed = + '_zod' in inputSchema + ? await z4.safeParseAsync(inputSchema, unparsedInput) + : await inputSchema.safeParseAsync(unparsedInput); if (!parsed.success) { throw new ActionInputError(parsed.error.issues); } @@ -152,7 +142,7 @@ function getJsonServerHandler( } /** Transform form data to an object based on a Zod schema. */ -export function formDataToObject( +export function formDataToZ3Object( formData: FormData, schema: T, ): Record { @@ -162,12 +152,12 @@ export function formDataToObject( let validator = baseValidator; while ( - validator instanceof z.ZodOptional || - validator instanceof z.ZodNullable || - validator instanceof z.ZodDefault + validator instanceof z3.ZodOptional || + validator instanceof z3.ZodNullable || + validator instanceof z3.ZodDefault ) { // use default value when key is undefined - if (validator instanceof z.ZodDefault && !formData.has(key)) { + if (validator instanceof z3.ZodDefault && !formData.has(key)) { obj[key] = validator._def.defaultValue(); } validator = validator._def.innerType; @@ -176,34 +166,99 @@ export function formDataToObject( if (!formData.has(key) && key in obj) { // continue loop if form input is not found and default value is set continue; - } else if (validator instanceof z.ZodBoolean) { + } else if (validator instanceof z3.ZodBoolean) { + const val = formData.get(key); + obj[key] = val === 'true' ? true : val === 'false' ? false : formData.has(key); + } else if (validator instanceof z3.ZodArray) { + obj[key] = handleZ3FormDataGetAll(key, formData, validator); + } else { + obj[key] = handleZ3FormDataGet(key, formData, validator, baseValidator); + } + } + return obj; +} + +/** Transform form data to an object based on a Zod schema. */ +export function formDataToZ4Object( + formData: FormData, + schema: T, +): Record { + const obj: Record = schema._zod.def.catchall + ? Object.fromEntries(formData.entries()) + : {}; + for (const [key, baseValidator] of Object.entries(schema._zod.def.shape)) { + let validator = baseValidator; + + while ( + validator instanceof z4.$ZodOptional || + validator instanceof z4.$ZodNullable || + validator instanceof z4.$ZodDefault + ) { + // use default value when key is undefined + if (validator instanceof z4.$ZodDefault && !formData.has(key)) { + obj[key] = + validator._zod.def.defaultValue instanceof Function + ? validator._zod.def.defaultValue() + : validator._zod.def.defaultValue; + } + validator = validator._zod.def.innerType; + } + + if (!formData.has(key) && key in obj) { + // continue loop if form input is not found and default value is set + continue; + } else if (validator instanceof z4.$ZodBoolean) { const val = formData.get(key); obj[key] = val === 'true' ? true : val === 'false' ? false : formData.has(key); - } else if (validator instanceof z.ZodArray) { - obj[key] = handleFormDataGetAll(key, formData, validator); + } else if (validator instanceof z4.$ZodArray) { + obj[key] = handleZ4FormDataGetAll(key, formData, validator); } else { - obj[key] = handleFormDataGet(key, formData, validator, baseValidator); + obj[key] = handleZ4FormDataGet(key, formData, validator, baseValidator); } } return obj; } -function handleFormDataGetAll( +function handleZ3FormDataGetAll( key: string, formData: FormData, - validator: z.ZodArray, + validator: z3.ZodArray, ) { const entries = Array.from(formData.getAll(key)); const elementValidator = validator._def.type; - if (elementValidator instanceof z.ZodNumber) { + if (elementValidator instanceof z3.ZodNumber) { + return entries.map(Number); + } else if (elementValidator instanceof z3.ZodBoolean) { + return entries.map(Boolean); + } + return entries; +} + +function handleZ4FormDataGetAll(key: string, formData: FormData, validator: z4.$ZodArray) { + const entries = Array.from(formData.getAll(key)); + const elementValidator = validator._zod.def.element; + if (elementValidator instanceof z4.$ZodNumber) { return entries.map(Number); - } else if (elementValidator instanceof z.ZodBoolean) { + } else if (elementValidator instanceof z4.$ZodBoolean) { return entries.map(Boolean); } return entries; } -function handleFormDataGet( +function handleZ3FormDataGet( + key: string, + formData: FormData, + validator: unknown, + baseValidator: unknown, +) { + const value = formData.get(key); + if (!value) { + return baseValidator instanceof z3.ZodOptional ? undefined : null; + } + return validator instanceof z3.ZodNumber ? Number(value) : value; +} + +function handleZ4FormDataGet( key: string, formData: FormData, validator: unknown, @@ -211,21 +266,21 @@ function handleFormDataGet( ) { const value = formData.get(key); if (!value) { - return baseValidator instanceof z.ZodOptional ? undefined : null; + return baseValidator instanceof z4.$ZodOptional ? undefined : null; } - return validator instanceof z.ZodNumber ? Number(value) : value; + return validator instanceof z4.$ZodNumber ? Number(value) : value; } -function unwrapBaseObjectSchema(schema: z.ZodType, unparsedInput: FormData) { - while (schema instanceof z.ZodEffects || schema instanceof z.ZodPipeline) { - if (schema instanceof z.ZodEffects) { +function unwrapBaseZ3ObjectSchema(schema: z3.ZodType, unparsedInput: FormData) { + while (schema instanceof z3.ZodEffects || schema instanceof z3.ZodPipeline) { + if (schema instanceof z3.ZodEffects) { schema = schema._def.schema; } - if (schema instanceof z.ZodPipeline) { + if (schema instanceof z3.ZodPipeline) { schema = schema._def.in; } } - if (schema instanceof z.ZodDiscriminatedUnion) { + if (schema instanceof z3.ZodDiscriminatedUnion) { const typeKey = schema._def.discriminator; const typeValue = unparsedInput.get(typeKey); if (typeof typeValue !== 'string') return schema; @@ -238,6 +293,25 @@ function unwrapBaseObjectSchema(schema: z.ZodType, unparsedInput: FormData) { return schema; } +function unwrapBaseZ4ObjectSchema(schema: z4.$ZodType, unparsedInput: FormData) { + if (schema instanceof z4.$ZodPipe) { + return schema._zod.def.in; + } + if (schema instanceof z4.$ZodDiscriminatedUnion) { + const typeKey = schema._zod.def.discriminator; + const typeValue = unparsedInput.get(typeKey); + if (typeof typeValue !== 'string') return schema; + + const objSchema = schema._zod.def.options.find((option) => + (option as any).def.shape[typeKey].values.has(typeValue), + ); + if (!objSchema) return schema; + + return objSchema; + } + return schema; +} + export type AstroActionContext = { /** Information about an incoming action request. */ action?: { diff --git a/packages/astro/src/actions/runtime/shared.ts b/packages/astro/src/actions/runtime/shared.ts index 834c446bdd4a..5440d1bbab2d 100644 --- a/packages/astro/src/actions/runtime/shared.ts +++ b/packages/astro/src/actions/runtime/shared.ts @@ -1,5 +1,6 @@ import { parse as devalueParse, stringify as devalueStringify } from 'devalue'; -import type { z } from 'zod'; +import type * as z3 from 'zod/v3'; +import type * as z4 from 'zod/v4/core'; import { REDIRECT_STATUS_CODES } from '../../core/constants.js'; import { AstroError } from '../../core/errors/errors.js'; import { @@ -191,10 +192,10 @@ export class ActionInputError extends ActionErro // Not all properties will serialize from server to client, // and we don't want to import the full ZodError object into the client. - issues: z.ZodIssue[]; - fields: z.ZodError['formErrors']['fieldErrors']; + issues: z3.ZodIssue[] | z4.$ZodIssue[]; + fields: { [P in keyof T]?: string[] | undefined }; - constructor(issues: z.ZodIssue[]) { + constructor(issues: z3.ZodIssue[] | z4.$ZodIssue[]) { super({ message: `Failed to validate: ${JSON.stringify(issues, null, 2)}`, code: 'BAD_REQUEST', @@ -213,7 +214,7 @@ export class ActionInputError extends ActionErro export async function callSafely( handler: () => MaybePromise, -): Promise> { +): Promise> { try { const data = await handler(); return { data, error: undefined }; diff --git a/packages/astro/src/actions/runtime/utils.ts b/packages/astro/src/actions/runtime/utils.ts index 6c9bc9a331a4..30732d0169b4 100644 --- a/packages/astro/src/actions/runtime/utils.ts +++ b/packages/astro/src/actions/runtime/utils.ts @@ -1,4 +1,17 @@ +import type * as z3 from 'zod/v3'; +import type * as z4 from 'zod/v4/core'; +import { AstroError } from '../../core/errors/errors.js'; +import { ActionCalledFromServerError } from '../../core/errors/errors-data.js'; import type { APIContext } from '../../types/public/context.js'; +import { checkZodSchemaCompatibility } from '../../vite-plugin-experimental-zod4/utils.js'; +import { + type ActionAccept, + type ActionClient, + type ActionHandler, + callSafely, + getFormServerHandler, + getJsonServerHandler, +} from './server.js'; import type { SerializedActionResult } from './shared.js'; export type ActionPayload = { @@ -59,3 +72,57 @@ export function isActionAPIContext(ctx: ActionAPIContext): boolean { const symbol = Reflect.get(ctx, ACTION_API_CONTEXT_SYMBOL); return symbol === true; } + +export function createDefineAction(experimentalZod4: boolean) { + return function defineAction< + TOutput, + TAccept extends ActionAccept | undefined = undefined, + TInputSchema extends z3.ZodType | z4.$ZodType | undefined = TAccept extends 'form' + ? // If `input` is omitted, default to `FormData` for forms and `any` for JSON. + z3.ZodType + : undefined, + >({ + accept, + input: inputSchema, + handler, + }: { + input?: TInputSchema; + accept?: TAccept; + handler: ActionHandler; + }): ActionClient & string { + if (inputSchema) { + const error = checkZodSchemaCompatibility( + inputSchema, + experimentalZod4, + 'content collections', + ); + if (error) { + throw error; + } + } + + const serverHandler = + accept === 'form' + ? getFormServerHandler(handler, inputSchema) + : getJsonServerHandler(handler, inputSchema); + + async function safeServerHandler(this: ActionAPIContext, unparsedInput: unknown) { + // The ActionAPIContext should always contain the `params` property + if (typeof this === 'function' || !isActionAPIContext(this)) { + throw new AstroError(ActionCalledFromServerError); + } + return callSafely(() => serverHandler(unparsedInput, this)); + } + + Object.assign(safeServerHandler, { + orThrow(this: ActionAPIContext, unparsedInput: unknown) { + if (typeof this === 'function') { + throw new AstroError(ActionCalledFromServerError); + } + return serverHandler(unparsedInput, this); + }, + }); + + return safeServerHandler as ActionClient & string; + }; +} diff --git a/packages/astro/src/actions/runtime/virtual.ts b/packages/astro/src/actions/runtime/virtual/index.ts similarity index 96% rename from packages/astro/src/actions/runtime/virtual.ts rename to packages/astro/src/actions/runtime/virtual/index.ts index 05e157870982..bdea68b7dd79 100644 --- a/packages/astro/src/actions/runtime/virtual.ts +++ b/packages/astro/src/actions/runtime/virtual/index.ts @@ -1,6 +1,6 @@ import { shouldAppendTrailingSlash } from 'virtual:astro:actions/options'; -import type { APIContext } from '../../types/public/context.js'; -import type { ActionClient, SafeResult } from './server.js'; +import type { APIContext } from '../../../types/public/context.js'; +import type { ActionClient, SafeResult } from '../server.js'; import { ACTION_QUERY_PARAMS, ActionError, @@ -8,7 +8,7 @@ import { astroCalledServerError, deserializeActionResult, getActionQueryString, -} from './shared.js'; +} from '../shared.js'; export * from 'virtual:astro:actions/runtime'; diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts new file mode 100644 index 000000000000..a9ec3f0ac637 --- /dev/null +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -0,0 +1,4 @@ +import { experimentalZod4 } from 'virtual:astro:config/experimentalZod4'; +import { createDefineAction } from '../utils.js'; + +export const defineAction = createDefineAction(experimentalZod4); \ No newline at end of file diff --git a/packages/astro/src/actions/vite-plugin-actions.ts b/packages/astro/src/actions/vite-plugin-actions.ts index d73bd3daba48..3d41a05bad98 100644 --- a/packages/astro/src/actions/vite-plugin-actions.ts +++ b/packages/astro/src/actions/vite-plugin-actions.ts @@ -101,7 +101,7 @@ export function vitePluginActions({ }, async load(id, opts) { if (id === RESOLVED_VIRTUAL_MODULE_ID) { - return { code: `export * from 'astro/actions/runtime/virtual.js';` }; + return { code: `export * from 'astro/actions/runtime/virtual/index.js';` }; } if (id === RESOLVED_NOOP_ENTRYPOINT_VIRTUAL_MODULE_ID) { @@ -113,8 +113,14 @@ export function vitePluginActions({ } if (id === RESOLVED_RUNTIME_VIRTUAL_MODULE_ID) { + if (opts?.ssr) { + return ` + export * from 'astro/actions/runtime/server.js'; + export * from 'astro/actions/runtime/virtual/server.js'; + `; + } return { - code: `export * from 'astro/actions/runtime/${opts?.ssr ? 'server' : 'client'}.js';`, + code: `export * from 'astro/actions/runtime/client.js';`, }; } diff --git a/packages/astro/src/assets/fonts/config.ts b/packages/astro/src/assets/fonts/config.ts index 73b28105bf64..a09ba27e82ae 100644 --- a/packages/astro/src/assets/fonts/config.ts +++ b/packages/astro/src/assets/fonts/config.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/v3'; import { LOCAL_PROVIDER_NAME } from './constants.js'; const weightSchema = z.union([z.string(), z.number()]); diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index 3e8b0a2b0e45..3e803eba9fcd 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -1,6 +1,6 @@ import type { Font } from '@capsizecss/unpack'; import type * as unifont from 'unifont'; -import type { z } from 'zod'; +import type { z } from 'zod/v3'; import type { fontProviderSchema, localFontFamilySchema, diff --git a/packages/astro/src/content/config.ts b/packages/astro/src/content/config.ts index e6424b5b7b10..9026959bfc8c 100644 --- a/packages/astro/src/content/config.ts +++ b/packages/astro/src/content/config.ts @@ -1,5 +1,8 @@ -import type { ZodLiteral, ZodNumber, ZodObject, ZodString, ZodType, ZodUnion } from 'zod'; +import { experimentalZod4 } from 'virtual:astro:config/experimentalZod4'; +import type * as z3 from 'zod/v3'; +import type * as z4 from 'zod/v4/core'; import { AstroError, AstroErrorData, AstroUserError } from '../core/errors/index.js'; +import { checkZodSchemaCompatibility } from '../vite-plugin-experimental-zod4/utils.js'; import { CONTENT_LAYER_TYPE, LIVE_CONTENT_TYPE } from './consts.js'; import type { LiveLoader, Loader } from './loaders/types.js'; @@ -24,20 +27,38 @@ function getImporterFilename() { } // This needs to be in sync with ImageMetadata -export type ImageFunction = () => ZodObject<{ - src: ZodString; - width: ZodNumber; - height: ZodNumber; - format: ZodUnion< +type Z3ImageFunction = () => z3.ZodObject<{ + src: z3.ZodString; + width: z3.ZodNumber; + height: z3.ZodNumber; + format: z3.ZodUnion< [ - ZodLiteral<'png'>, - ZodLiteral<'jpg'>, - ZodLiteral<'jpeg'>, - ZodLiteral<'tiff'>, - ZodLiteral<'webp'>, - ZodLiteral<'gif'>, - ZodLiteral<'svg'>, - ZodLiteral<'avif'>, + z3.ZodLiteral<'png'>, + z3.ZodLiteral<'jpg'>, + z3.ZodLiteral<'jpeg'>, + z3.ZodLiteral<'tiff'>, + z3.ZodLiteral<'webp'>, + z3.ZodLiteral<'gif'>, + z3.ZodLiteral<'svg'>, + z3.ZodLiteral<'avif'>, + ] + >; +}>; +// This needs to be in sync with ImageMetadata +type Z4ImageFunction = () => z4.$ZodObject<{ + src: z4.$ZodString; + width: z4.$ZodNumber; + height: z4.$ZodNumber; + format: z4.$ZodUnion< + [ + z4.$ZodLiteral<'png'>, + z4.$ZodLiteral<'jpg'>, + z4.$ZodLiteral<'jpeg'>, + z4.$ZodLiteral<'tiff'>, + z4.$ZodLiteral<'webp'>, + z4.$ZodLiteral<'gif'>, + z4.$ZodLiteral<'svg'>, + z4.$ZodLiteral<'avif'>, ] >; }>; @@ -67,32 +88,7 @@ export interface MetaStore { has: (key: string) => boolean; } -export type BaseSchema = ZodType; - -export type SchemaContext = { image: ImageFunction }; - -type ContentLayerConfig = { - type?: 'content_layer'; - schema?: S | ((context: SchemaContext) => S); - loader: - | Loader - | (() => - | Array - | Promise> - | Record & { id?: string }> - | Promise & { id?: string }>>); -}; - -type DataCollectionConfig = { - type: 'data'; - schema?: S | ((context: SchemaContext) => S); -}; - -type ContentCollectionConfig = { - type?: 'content'; - schema?: S | ((context: SchemaContext) => S); - loader?: never; -}; +export type BaseSchema = z3.ZodType | z4.$ZodType; export type LiveCollectionConfig< L extends LiveLoader, @@ -103,10 +99,21 @@ export type LiveCollectionConfig< loader: L; }; -export type CollectionConfig = - | ContentCollectionConfig - | DataCollectionConfig - | ContentLayerConfig; +export type CollectionConfig = { + type?: 'content_layer'; + schema?: + | S + | ((context: { + image: NoInfer extends z4.$ZodType ? Z4ImageFunction : Z3ImageFunction; + }) => S); + loader: + | Loader + | (() => + | Array<{ id: string }> + | Promise> + | Record + | Promise>); +}; export function defineLiveCollection< L extends LiveLoader, @@ -167,9 +174,15 @@ export function defineLiveCollection< return config; } -export function defineCollection( +export function defineCollection( config: CollectionConfig, -): CollectionConfig { +): CollectionConfig; +export function defineCollection( + config: CollectionConfig, +): CollectionConfig; +export function defineCollection( + config: CollectionConfig, +): CollectionConfig { const importerFilename = getImporterFilename(); if (importerFilename?.includes('live.config')) { @@ -206,5 +219,18 @@ export function defineCollection( ); } config.type = CONTENT_LAYER_TYPE; + + if ( + config.schema && + typeof config.schema !== 'function' && + '_zod' in config.schema && + !experimentalZod4 + ) { + const error = checkZodSchemaCompatibility(config.schema, experimentalZod4, 'content collections'); + if (error) { + throw error; + } + } + return config; } diff --git a/packages/astro/src/content/content-layer.ts b/packages/astro/src/content/content-layer.ts index 227c5028ba9a..5345e4d83dd6 100644 --- a/packages/astro/src/content/content-layer.ts +++ b/packages/astro/src/content/content-layer.ts @@ -3,7 +3,7 @@ import { createMarkdownProcessor, type MarkdownProcessor } from '@astrojs/markdo import PQueue from 'p-queue'; import type { FSWatcher } from 'vite'; import xxhash from 'xxhash-wasm'; -import type { z } from 'zod'; +import * as z4 from 'zod/v4/core'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import type { Logger } from '../core/logger/core.js'; import type { AstroSettings } from '../types/astro.js'; @@ -23,7 +23,8 @@ import { getEntryConfigByExtMap, getEntryDataAndImages, globalContentConfigObserver, - loaderReturnSchema, + loaderReturnZ3Schema, + loaderReturnZ4Schema, safeStringify, } from './utils.js'; import { createWatcherWrapper, type WrappedWatcher } from './watcher.js'; @@ -282,6 +283,7 @@ class ContentLayer { }, collectionWithResolvedSchema, false, + this.#settings.config.experimental.zod4, ); return parsedData; @@ -295,7 +297,11 @@ class ContentLayer { }); if (typeof collection.loader === 'function') { - return simpleLoader(collection.loader as CollectionLoader<{ id: string }>, context); + return simpleLoader( + collection.loader as CollectionLoader<{ id: string }>, + context, + this.#settings.config.experimental.zod4, + ); } if (!collection.loader.load) { @@ -355,18 +361,26 @@ class ContentLayer { async function simpleLoader( handler: CollectionLoader, context: LoaderContext, + experimentalZod4: boolean, ) { const unsafeData = await handler(); - const parsedData = loaderReturnSchema.safeParse(unsafeData); + const parsedData = experimentalZod4 + ? z4.safeParse(loaderReturnZ4Schema, unsafeData) + : loaderReturnZ3Schema.safeParse(unsafeData); if (!parsedData.success) { - const issue = parsedData.error.issues[0] as z.ZodInvalidUnionIssue; - - // Due to this being a union, zod will always throw an "Expected array, received object" error along with the other errors. - // This error is in the second position if the data is an array, and in the first position if the data is an object. - const parseIssue = Array.isArray(unsafeData) ? issue.unionErrors[0] : issue.unionErrors[1]; + let errors; + if (parsedData.error instanceof z4.$ZodError) { + errors = (parsedData.error.issues[0] as any).errors[0] + } else { + const issue = parsedData.error.issues[0] as any; + + // Due to this being a union, zod will always throw an "Expected array, received object" error along with the other errors. + // This error is in the second position if the data is an array, and in the first position if the data is an object. + errors = issue.unionErrors[Array.isArray(unsafeData) ? 0 : 1].errors; + } - const error = parseIssue.errors[0]; + const error = errors[0]; const firstPathItem = error.path[0]; const entry = Array.isArray(unsafeData) diff --git a/packages/astro/src/content/loaders/errors.ts b/packages/astro/src/content/loaders/errors.ts index e9fcba94a220..f62bf04d3a0c 100644 --- a/packages/astro/src/content/loaders/errors.ts +++ b/packages/astro/src/content/loaders/errors.ts @@ -1,4 +1,9 @@ -import type { ZodError } from 'zod'; +import type * as z3 from 'zod/v3'; +import type * as z4 from 'zod/v4/core'; + +function formatZodError(error: z3.ZodError | z4.$ZodError): string[] { + return error.issues.map((issue) => ` **${issue.path.join('.')}**: ${issue.message}`); +} export class LiveCollectionError extends Error { constructor( @@ -31,12 +36,12 @@ export class LiveEntryNotFoundError extends LiveCollectionError { } export class LiveCollectionValidationError extends LiveCollectionError { - constructor(collection: string, entryId: string, error: ZodError) { + constructor(collection: string, entryId: string, error: z3.ZodError | z4.$ZodError) { super( collection, [ `**${collection} → ${entryId}** data does not match the collection schema.\n`, - ...error.errors.map((zodError) => ` **${zodError.path.join('.')}**: ${zodError.message}`), + ...formatZodError(error), '', ].join('\n'), ); @@ -48,12 +53,12 @@ export class LiveCollectionValidationError extends LiveCollectionError { } export class LiveCollectionCacheHintError extends LiveCollectionError { - constructor(collection: string, entryId: string | undefined, error: ZodError) { + constructor(collection: string, entryId: string | undefined, error: z3.ZodError | z4.$ZodError) { super( collection, [ `**${String(collection)}${entryId ? ` → ${String(entryId)}` : ''}** returned an invalid cache hint.\n`, - ...error.errors.map((zodError) => ` **${zodError.path.join('.')}**: ${zodError.message}`), + ...formatZodError(error), '', ].join('\n'), ); diff --git a/packages/astro/src/content/loaders/types.ts b/packages/astro/src/content/loaders/types.ts index 6939357a5620..2a1c4cbf4c90 100644 --- a/packages/astro/src/content/loaders/types.ts +++ b/packages/astro/src/content/loaders/types.ts @@ -1,5 +1,6 @@ import type { FSWatcher } from 'vite'; -import type { ZodSchema } from 'zod'; +import type * as z3 from 'zod/v3'; +import type * as z4 from 'zod/v4/core'; import type { AstroIntegrationLogger } from '../../core/logger/core.js'; import type { AstroConfig } from '../../types/public/config.js'; import type { @@ -49,6 +50,8 @@ export interface LoaderContext { entryTypes: Map; } +type ZodSchema = z3.ZodType | z4.$ZodType; + export interface Loader { /** Unique name of the loader, e.g. the npm package name */ name: string; diff --git a/packages/astro/src/content/runtime-assets.ts b/packages/astro/src/content/runtime-assets.ts index b23642faa03a..d42b991db68e 100644 --- a/packages/astro/src/content/runtime-assets.ts +++ b/packages/astro/src/content/runtime-assets.ts @@ -1,15 +1,16 @@ import type { PluginContext } from 'rollup'; -import { z } from 'zod'; +import * as z3 from 'zod/v3'; +import * as z4 from 'zod/v4'; import type { ImageMetadata, OmitBrand } from '../assets/types.js'; import { emitImageMetadata } from '../assets/utils/node/emitAsset.js'; -export function createImage( +export function createZ3Image( pluginContext: PluginContext, shouldEmitFile: boolean, entryFilePath: string, ) { return () => { - return z.string().transform(async (imagePath, ctx) => { + return z3.string().transform(async (imagePath, ctx) => { const resolvedFilePath = (await pluginContext.resolve(imagePath, entryFilePath))?.id; const metadata = (await emitImageMetadata( resolvedFilePath, @@ -23,7 +24,35 @@ export function createImage( fatal: true, }); - return z.never(); + return z3.never(); + } + + return { ...metadata, ASTRO_ASSET: metadata.fsPath }; + }); + }; +} + +export function createZ4Image( + pluginContext: PluginContext, + shouldEmitFile: boolean, + entryFilePath: string, +) { + return () => { + return z4.string().transform(async (imagePath, ctx) => { + const resolvedFilePath = (await pluginContext.resolve(imagePath, entryFilePath))?.id; + const metadata = (await emitImageMetadata( + resolvedFilePath, + shouldEmitFile ? pluginContext.emitFile : undefined, + )) as OmitBrand; + + if (!metadata) { + ctx.addIssue({ + code: 'custom', + message: `Image ${imagePath} does not exist. Is the path correct?`, + fatal: true, + }); + + return z4.never(); } return { ...metadata, ASTRO_ASSET: metadata.fsPath }; diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts index 735b43dcdf12..1e149afef450 100644 --- a/packages/astro/src/content/runtime.ts +++ b/packages/astro/src/content/runtime.ts @@ -1,7 +1,7 @@ import type { MarkdownHeading } from '@astrojs/markdown-remark'; import { escape } from 'html-escaper'; import { Traverse } from 'neotraverse/modern'; -import { ZodIssueCode, z } from 'zod'; +import { z } from 'zod/v3'; import type { GetImageResult, ImageMetadata } from '../assets/types.js'; import { imageSrcToImportId } from '../assets/utils/resolveImports.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; @@ -681,7 +681,7 @@ export function createReference() { // If these don't match then something is wrong with the reference if (lookup.collection !== collection) { ctx.addIssue({ - code: ZodIssueCode.custom, + code: z.ZodIssueCode.custom, message: `**${flattenedErrorPath}**: Reference to ${collection} invalid. Expected ${collection}. Received ${lookup.collection}.`, }); return; diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 0c26a0ee8484..ccae9a5737af 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -1,9 +1,12 @@ import type fsMod from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { compile } from 'json-schema-to-typescript'; import { bold, cyan } from 'kleur/colors'; import { normalizePath, type ViteDevServer } from 'vite'; -import { type ZodSchema, z } from 'zod'; +import * as z3 from 'zod/v3'; +import * as _z4 from 'zod/v4'; +import * as z4 from 'zod/v4/core'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { AstroError } from '../core/errors/errors.js'; import { AstroErrorData } from '../core/errors/index.js'; @@ -342,12 +345,12 @@ function normalizeConfigPath(from: string, to: string) { return `"${isRelativePath(configPath) ? '' : './'}${normalizedPath}"` as const; } -const schemaCache = new Map(); +const schemaCache = new Map(); async function getContentLayerSchema( collection: ContentConfig['collections'][T], collectionKey: T, -): Promise { +): Promise { const cached = schemaCache.get(collectionKey); if (cached) { return cached; @@ -383,9 +386,23 @@ async function typeForCollection( if (!schema) { return 'any'; } + if ('_zod' in schema) { + try { + const jsonSchema = z4.toJSONSchema(schema); + // schema versions do not match + const result = await compile(jsonSchema as any, 'X', { + format: false, + bannerComment: '', + }); + // Removes "export interface X" + return result.slice(19); + } catch { + return 'any'; + } + } try { const zodToTs = await import('zod-to-ts'); - const ast = zodToTs.zodToTs(schema); + const ast = zodToTs.zodToTs(schema as any); return zodToTs.printNode(ast.node); } catch (err: any) { // zod-to-ts is sad if we don't have TypeScript installed, but that's fine as we won't be needing types in that case @@ -468,7 +485,14 @@ async function writeContentFiles({ collectionConfig && (collectionConfig.schema || (await getContentLayerSchema(collectionConfig, collectionKey))) ) { - await generateJSONSchema(fs, collectionConfig, collectionKey, collectionSchemasDir, logger); + await generateJSONSchema( + fs, + collectionConfig, + collectionKey, + collectionSchemasDir, + logger, + settings.config.experimental.zod4, + ); contentCollectionsMap[collectionKey] = collection; } @@ -556,10 +580,11 @@ async function generateJSONSchema( collectionKey: string, collectionSchemasDir: URL, logger: Logger, + experimentalZod4: boolean, ) { let zodSchemaForJson = typeof collectionConfig.schema === 'function' - ? collectionConfig.schema({ image: () => z.string() }) + ? collectionConfig.schema({ image: () => (experimentalZod4 ? _z4.string() : z3.string()) }) : collectionConfig.schema; if (!zodSchemaForJson && collectionConfig.type === CONTENT_LAYER_TYPE) { @@ -576,12 +601,19 @@ async function generateJSONSchema( // `file()` supports arrays of items, but you can’t set `$schema` when using a top-level array, // so we’re only handling the object case. // We use `z.object()` instead of `z.record()` for compatibility with the next `if` statement. - zodSchemaForJson = z.object({}).catchall(zodSchemaForJson); + zodSchemaForJson = experimentalZod4 + ? _z4.object({}).catchall(zodSchemaForJson) + : z3.object({}).catchall(zodSchemaForJson); } - if (zodSchemaForJson instanceof z.ZodObject) { + if (zodSchemaForJson instanceof z4.$ZodObject) { + zodSchemaForJson = _z4.object({ + ...(zodSchemaForJson as any).shape, + $schema: _z4.string().optional(), + }); + } else if (zodSchemaForJson instanceof z3.ZodObject) { zodSchemaForJson = zodSchemaForJson.extend({ - $schema: z.string().optional(), + $schema: z3.string().optional(), }); } @@ -589,13 +621,18 @@ async function generateJSONSchema( await fsMod.promises.writeFile( new URL(`./${collectionKey.replace(/"/g, '')}.schema.json`, collectionSchemasDir), JSON.stringify( - zodToJsonSchema(zodSchemaForJson, { - name: collectionKey.replace(/"/g, ''), - markdownDescription: true, - errorMessages: true, - // Fix for https://github.com/StefanTerdell/zod-to-json-schema/issues/110 - dateStrategy: ['format:date-time', 'format:date', 'integer'], - }), + '_zod' in zodSchemaForJson + ? z4.toJSONSchema(zodSchemaForJson, { + unrepresentable: 'any', + io: 'input', + }) + : zodToJsonSchema(zodSchemaForJson, { + name: collectionKey.replace(/"/g, ''), + markdownDescription: true, + errorMessages: true, + // Fix for https://github.com/StefanTerdell/zod-to-json-schema/issues/110 + dateStrategy: ['format:date-time', 'format:date', 'integer'], + }), null, 2, ), diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index bedd4c93c5a4..e3075a7f62e0 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -7,7 +7,8 @@ import { green, red } from 'kleur/colors'; import type { PluginContext } from 'rollup'; import type { ViteDevServer } from 'vite'; import xxhash from 'xxhash-wasm'; -import { z } from 'zod'; +import * as z3 from 'zod/v3'; +import * as z4 from 'zod/v4'; import { AstroError, AstroErrorData, errorMap, MarkdownError } from '../core/errors/index.js'; import { isYAMLException } from '../core/errors/utils.js'; import type { Logger } from '../core/logger/core.js'; @@ -24,24 +25,24 @@ import { LIVE_CONTENT_TYPE, PROPAGATED_ASSET_FLAG, } from './consts.js'; -import { createImage } from './runtime-assets.js'; +import { createZ3Image, createZ4Image } from './runtime-assets.js'; -const entryTypeSchema = z - .object({ - id: z.string({ - invalid_type_error: 'Content entry `id` must be a string', - // Default to empty string so we can validate properly in the loader - }), - }) - .passthrough(); - -export const loaderReturnSchema = z.union([ - z.array(entryTypeSchema), - z.record( - z.string(), - z +export const loaderReturnZ3Schema = z3.union([ + z3.array( + z3 + .object({ + id: z3.string({ + invalid_type_error: 'Content entry `id` must be a string', + // Default to empty string so we can validate properly in the loader + }), + }) + .passthrough(), + ), + z3.record( + z3.string(), + z3 .object({ - id: z + id: z3 .string({ invalid_type_error: 'Content entry `id` must be a string', }) @@ -51,52 +52,75 @@ export const loaderReturnSchema = z.union([ ), ]); -const collectionConfigParser = z.union([ - z.object({ - type: z.literal(CONTENT_LAYER_TYPE), - schema: z.any().optional(), - loader: z.union([ - z.function(), - z.object({ - name: z.string(), - load: z.function( - z.tuple( +export const loaderReturnZ4Schema = z4.union([ + z4.array( + z4.looseObject({ + id: z4.string({ + error: (issue) => + issue.input === undefined ? undefined : 'Content entry `id` must be a string', + // Default to empty string so we can validate properly in the loader + }), + }), + ), + z4.record( + z4.string(), + z4.looseObject({ + id: z4 + .string({ + error: (issue) => + issue.input === undefined ? undefined : 'Content entry `id` must be a string', + }) + .optional(), + }), + ), +]); + +const collectionConfigParser = z3.union([ + z3.object({ + type: z3.literal(CONTENT_LAYER_TYPE), + schema: z3.any().optional(), + loader: z3.union([ + z3.function(), + z3.object({ + name: z3.string(), + load: z3.function( + z3.tuple( [ - z.object({ - collection: z.string(), - store: z.any(), - meta: z.any(), - logger: z.any(), - config: z.any(), - entryTypes: z.any(), - parseData: z.any(), - renderMarkdown: z.any(), - generateDigest: z.function(z.tuple([z.any()], z.string())), - watcher: z.any().optional(), - refreshContextData: z.record(z.unknown()).optional(), + z3.object({ + collection: z3.string(), + store: z3.any(), + meta: z3.any(), + logger: z3.any(), + config: z3.any(), + entryTypes: z3.any(), + parseData: z3.any(), + renderMarkdown: z3.any(), + generateDigest: z3.function(z3.tuple([z3.any()], z3.string())), + watcher: z3.any().optional(), + refreshContextData: z3.record(z3.unknown()).optional(), }), ], - z.unknown(), + z3.unknown(), ), ), - schema: z.any().optional(), - render: z.function(z.tuple([z.any()], z.unknown())).optional(), + schema: z3.any().optional(), + render: z3.function(z3.tuple([z3.any()], z3.unknown())).optional(), }), ]), }), - z.object({ - type: z.literal(LIVE_CONTENT_TYPE).optional().default(LIVE_CONTENT_TYPE), - schema: z.any().optional(), - loader: z.function(), + z3.object({ + type: z3.literal(LIVE_CONTENT_TYPE).optional().default(LIVE_CONTENT_TYPE), + schema: z3.any().optional(), + loader: z3.function(), }), ]); -const contentConfigParser = z.object({ - collections: z.record(collectionConfigParser), +const contentConfigParser = z3.object({ + collections: z3.record(collectionConfigParser), }); -export type CollectionConfig = z.infer; -export type ContentConfig = z.infer & { digest?: string }; +export type CollectionConfig = z3.infer; +export type ContentConfig = z3.infer & { digest?: string }; type EntryInternal = { rawData: string | undefined; filePath: string }; @@ -112,7 +136,7 @@ export function parseEntrySlug({ frontmatterSlug?: unknown; }) { try { - return z.string().default(generatedSlug).parse(frontmatterSlug); + return z3.string().default(generatedSlug).parse(frontmatterSlug); } catch { throw new AstroError({ ...AstroErrorData.InvalidContentEntrySlugError, @@ -133,6 +157,7 @@ export async function getEntryDataAndImages< }, collectionConfig: CollectionConfig, shouldEmitFile: boolean, + experimentalZod4: boolean, pluginContext?: PluginContext, ): Promise<{ data: TOutputData; imageImports: Array }> { let data = entry.unvalidatedData as TOutputData; @@ -142,42 +167,97 @@ export async function getEntryDataAndImages< const imageImports = new Set(); if (typeof schema === 'function') { - if (pluginContext) { - schema = schema({ - image: createImage(pluginContext, shouldEmitFile, entry._internal.filePath), - }); - } else if (collectionConfig.type === CONTENT_LAYER_TYPE) { - schema = schema({ - image: () => - z.string().transform((val) => { - imageImports.add(val); - return `${IMAGE_IMPORT_PREFIX}${val}`; - }), - }); + try { + if (pluginContext) { + schema = schema({ + image: (experimentalZod4 ? createZ4Image : createZ3Image)( + pluginContext, + shouldEmitFile, + entry._internal.filePath, + ), + }); + } else if (collectionConfig.type === CONTENT_LAYER_TYPE) { + const transform = (val: string) => { + imageImports.add(val); + return `${IMAGE_IMPORT_PREFIX}${val}`; + }; + schema = schema({ + image: experimentalZod4 + ? () => z4.string().transform(transform) + : () => z3.string().transform(transform), + }); + } + } catch (cause) { + throw new AstroError( + { + ...AstroErrorData.CannotExecuteContentCollectionSchema, + message: AstroErrorData.CannotExecuteContentCollectionSchema.message(entry.collection), + }, + { cause }, + ); } } if (schema) { - // Use `safeParseAsync` to allow async transforms - let formattedError; - const parsed = await (schema as z.ZodSchema).safeParseAsync(data, { - errorMap(error, ctx) { - if (error.code === 'custom' && error.params?.isHoistedAstroError) { - formattedError = error.params?.astroError; - } - return errorMap(error, ctx); - }, - }); - if (parsed.success) { - data = parsed.data as TOutputData; + if ('_zod' in schema) { + let parsed; + try { + parsed = await z4.safeParseAsync(schema, data, { + // TODO: error map + }); + } catch (cause) { + throw new AstroError( + { + ...AstroErrorData.CannotExecuteContentCollectionSchema, + message: AstroErrorData.CannotExecuteContentCollectionSchema.message(entry.collection), + }, + { cause }, + ); + } + if (parsed.success) { + data = parsed.data as TOutputData; + } else { + throw new AstroError({ + ...AstroErrorData.InvalidContentEntryDataError, + message: AstroErrorData.InvalidContentEntryDataError.message( + entry.collection, + entry.id, + parsed.error.issues, + ), + location: { + file: entry._internal?.filePath, + line: getYAMLErrorLine( + entry._internal?.rawData, + String(parsed.error.issues[0].path[0]), + ), + column: 0, + }, + }); + } } else { - if (!formattedError) { - formattedError = new AstroError({ + let parsed; + try { + parsed = await (schema as z3.ZodSchema).safeParseAsync(data, { + errorMap, + }); + } catch (cause) { + throw new AstroError( + { + ...AstroErrorData.CannotExecuteContentCollectionSchema, + message: AstroErrorData.CannotExecuteContentCollectionSchema.message(entry.collection), + }, + { cause }, + ); + } + if (parsed.success) { + data = parsed.data as TOutputData; + } else { + throw new AstroError({ ...AstroErrorData.InvalidContentEntryDataError, message: AstroErrorData.InvalidContentEntryDataError.message( entry.collection, entry.id, - parsed.error, + parsed.error.errors, ), location: { file: entry._internal?.filePath, @@ -189,7 +269,6 @@ export async function getEntryDataAndImages< }, }); } - throw formattedError; } } @@ -205,12 +284,14 @@ export async function getEntryData( }, collectionConfig: CollectionConfig, shouldEmitFile: boolean, + experimentalZod4: boolean, pluginContext?: PluginContext, ) { const { data } = await getEntryDataAndImages( entry, collectionConfig, shouldEmitFile, + experimentalZod4, pluginContext, ); return data; diff --git a/packages/astro/src/content/vite-plugin-content-imports.ts b/packages/astro/src/content/vite-plugin-content-imports.ts index 4950d2d9b2e7..a286de5853a5 100644 --- a/packages/astro/src/content/vite-plugin-content-imports.ts +++ b/packages/astro/src/content/vite-plugin-content-imports.ts @@ -217,7 +217,7 @@ type GetEntryModuleParams = async function getContentEntryModule( params: GetEntryModuleParams, ): Promise { - const { fileId, contentDir, pluginContext } = params; + const { fileId, contentDir, pluginContext, config } = params; const { collectionConfig, entryConfig, entry, rawContents, collection } = await getEntryModuleBaseInfo(params); @@ -245,6 +245,7 @@ async function getContentEntryModule( { id, collection, _internal, unvalidatedData }, collectionConfig, params.shouldEmitFile, + config.experimental.zod4, pluginContext, ) : unvalidatedData; @@ -264,7 +265,7 @@ async function getContentEntryModule( async function getDataEntryModule( params: GetEntryModuleParams, ): Promise { - const { fileId, contentDir, pluginContext } = params; + const { fileId, contentDir, pluginContext, config } = params; const { collectionConfig, entryConfig, entry, rawContents, collection } = await getEntryModuleBaseInfo(params); @@ -280,6 +281,7 @@ async function getDataEntryModule( { id, collection, _internal, unvalidatedData }, collectionConfig, params.shouldEmitFile, + config.experimental.zod4, pluginContext, ) : unvalidatedData; diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 68a7e2ce2ab1..781d7c7e632f 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -1,4 +1,4 @@ -import type { ZodType } from 'zod'; +import type { ZodType } from 'zod/v3'; import type { ActionAccept, ActionClient } from '../../actions/runtime/server.js'; import type { RoutingStrategies } from '../../i18n/utils.js'; import type { ComponentInstance, SerializedRouteData } from '../../types/astro.js'; diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index 679e6cd4b927..05fe4a5d40bb 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -1,4 +1,4 @@ -import type { ZodType } from 'zod'; +import type { ZodType } from 'zod/v3'; import { NOOP_ACTIONS_MOD } from '../actions/noop-actions.js'; import type { ActionAccept, ActionClient } from '../actions/runtime/server.js'; import { createI18nMiddleware } from '../i18n/middleware.js'; diff --git a/packages/astro/src/core/config/config.ts b/packages/astro/src/core/config/config.ts index 9a371e994db2..f32cfc48eade 100644 --- a/packages/astro/src/core/config/config.ts +++ b/packages/astro/src/core/config/config.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import * as colors from 'kleur/colors'; -import { ZodError } from 'zod'; +import { ZodError } from 'zod/v3'; import { eventConfigError, telemetry } from '../../events/index.js'; import type { AstroConfig, diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 9aabc6b552bb..7155ba877abc 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -7,7 +7,7 @@ import type { } from '@astrojs/markdown-remark'; import { markdownConfigDefaults, syntaxHighlightDefaults } from '@astrojs/markdown-remark'; import { type BuiltinTheme, bundledThemes } from 'shiki'; -import { z } from 'zod'; +import { z } from 'zod/v3'; import { localFontFamilySchema, remoteFontFamilySchema } from '../../../assets/fonts/config.js'; import { EnvSchema } from '../../../env/schema.js'; import type { AstroUserConfig, ViteUserConfig } from '../../../types/public/config.js'; @@ -101,6 +101,7 @@ export const ASTRO_CONFIG_DEFAULTS = { csp: false, chromeDevtoolsWorkspace: false, failOnPrerenderConflict: false, + zod4: false, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -508,6 +509,7 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.failOnPrerenderConflict), + zod4: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.zod4), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/experimental-flags/ for a list of all current experiments.`, diff --git a/packages/astro/src/core/config/schemas/refined.ts b/packages/astro/src/core/config/schemas/refined.ts index d5940f224fcf..b25809a512d5 100644 --- a/packages/astro/src/core/config/schemas/refined.ts +++ b/packages/astro/src/core/config/schemas/refined.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/v3'; import type { AstroConfig } from '../../../types/public/config.js'; export const AstroConfigRefinedSchema = z.custom().superRefine((config, ctx) => { diff --git a/packages/astro/src/core/config/schemas/relative.ts b/packages/astro/src/core/config/schemas/relative.ts index ade69e7f6b46..21dfe18fbf0a 100644 --- a/packages/astro/src/core/config/schemas/relative.ts +++ b/packages/astro/src/core/config/schemas/relative.ts @@ -1,7 +1,7 @@ import type { OutgoingHttpHeaders } from 'node:http'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { z } from 'zod'; +import { z } from 'zod/v3'; import { appendForwardSlash, prependForwardSlash, removeTrailingForwardSlash } from '../../path.js'; import { ASTRO_CONFIG_DEFAULTS, AstroConfigSchema } from './base.js'; diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index a8061c25fad6..e88695f2002b 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -24,6 +24,7 @@ import type { AstroSettings, RoutesList } from '../types/astro.js'; import astroVitePlugin from '../vite-plugin-astro/index.js'; import { vitePluginAstroServer } from '../vite-plugin-astro-server/index.js'; import configAliasVitePlugin from '../vite-plugin-config-alias/index.js'; +import { experimentalZod4VitePlugin } from '../vite-plugin-experimental-zod4/index.js'; import vitePluginFileURL from '../vite-plugin-fileurl/index.js'; import astroHeadPlugin from '../vite-plugin-head/index.js'; import astroHmrReloadPlugin from '../vite-plugin-hmr-reload/index.js'; @@ -175,6 +176,7 @@ export async function createVite( vitePluginServerIslands({ settings, logger }), astroContainer(), astroHmrReloadPlugin(), + experimentalZod4VitePlugin({ settings }), ], publicDir: fileURLToPath(settings.config.publicDir), root: fileURLToPath(settings.config.root), diff --git a/packages/astro/src/core/csp/config.ts b/packages/astro/src/core/csp/config.ts index b33055816e11..5274f562beb8 100644 --- a/packages/astro/src/core/csp/config.ts +++ b/packages/astro/src/core/csp/config.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/v3'; type UnionToIntersection = (U extends never ? never : (arg: U) => never) extends ( arg: infer I, diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 9233cba12567..d17106ff9c51 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -2,7 +2,7 @@ // Additionally, this code, much like `types/public/config.ts`, is used to generate documentation, so make sure to pass // your changes by our wonderful docs team before merging! -import type { ZodError } from 'zod'; +import type { ZodError } from 'zod/v3'; export interface ErrorData { name: string; @@ -1680,10 +1680,14 @@ export const InvalidContentEntryFrontmatterError = { export const InvalidContentEntryDataError = { name: 'InvalidContentEntryDataError', title: 'Content entry data does not match schema.', - message(collection: string, entryId: string, error: ZodError) { + message( + collection: string, + entryId: string, + errors: Array<{ path: Array; message: string }>, + ) { return [ `**${String(collection)} → ${String(entryId)}** data does not match collection schema.\n`, - ...error.errors.map((zodError) => ` **${zodError.path.join('.')}**: ${zodError.message}`), + ...errors.map(({ path, message }) => ` **${path.join('.')}**: ${message}`), '', ].join('\n'); }, @@ -2088,6 +2092,30 @@ export const SessionStorageSaveError = { hint: 'For more information, see https://docs.astro.build/en/guides/sessions/', } satisfies ErrorData; +/** + * @docs + * @message The provided Zod schema uses a version incompatible with `experimental.zod4`. + */ +export const InvalidZodSchemaVersion = { + name: 'InvalidZodSchemaVersion', + title: 'Invalid Zod Schema version.', + message: (feature: string, version: 3 | 4) => + `Zod schema provided to ${feature} uses zod v${version}. However, experimental.zod4 is ${version === 3 ? 'enabled' : 'disabled'}.`, + hint: 'Update your schema or the option in your Astro config.', +} satisfies ErrorData; + +/** + * @docs + * @message An unknown error occurred while executing content collection schema. + */ +export const CannotExecuteContentCollectionSchema = { + name: 'CannotExecuteContentCollectionSchema', + title: 'Cannot Execute Content Collection Schema', + message: (collection: string) => + `An unknown error occurred while executing the ${JSON.stringify(collection)} content collection schema.`, + hint: 'This is likely caused by a zod v3 and v4 mismatch. Review your schema or the experimental.zod4 option in your Astro config.', +} satisfies ErrorData; + /* * Adding an error? Follow these steps: * 1. Determine in which category it belongs (Astro, Vite, CSS, Content Collections etc.) diff --git a/packages/astro/src/core/errors/errors.ts b/packages/astro/src/core/errors/errors.ts index 71c4e0cf67b0..c102e8f81ff5 100644 --- a/packages/astro/src/core/errors/errors.ts +++ b/packages/astro/src/core/errors/errors.ts @@ -1,4 +1,4 @@ -import type { ZodError } from 'zod'; +import type { ZodError } from 'zod/v3'; import { codeFrame } from './printer.js'; interface ErrorProperties { diff --git a/packages/astro/src/core/errors/zod-error-map.ts b/packages/astro/src/core/errors/zod-error-map.ts index 4137191e45ca..e6e8c151bc34 100644 --- a/packages/astro/src/core/errors/zod-error-map.ts +++ b/packages/astro/src/core/errors/zod-error-map.ts @@ -1,4 +1,4 @@ -import type { ZodErrorMap } from 'zod'; +import type { ZodErrorMap } from 'zod/v3'; type TypeOrLiteralErrByPathEntry = { code: 'invalid_type' | 'invalid_literal'; diff --git a/packages/astro/src/core/messages.ts b/packages/astro/src/core/messages.ts index caa00ac139ea..76bdb176f33a 100644 --- a/packages/astro/src/core/messages.ts +++ b/packages/astro/src/core/messages.ts @@ -15,7 +15,7 @@ import { } from 'kleur/colors'; import { detect, resolveCommand } from 'package-manager-detector'; import type { ResolvedServerUrls } from 'vite'; -import type { ZodError } from 'zod'; +import type { ZodError } from 'zod/v3'; import { getDocsForError, renderErrorMarkdown } from './errors/dev/utils.js'; import { AstroError, diff --git a/packages/astro/src/env/schema.ts b/packages/astro/src/env/schema.ts index f000ec1b9925..135f0eb6a163 100644 --- a/packages/astro/src/env/schema.ts +++ b/packages/astro/src/env/schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/v3'; const StringSchema = z.object({ type: z.literal('string'), diff --git a/packages/astro/src/events/error.ts b/packages/astro/src/events/error.ts index 65e0cabb7e17..c49bbb17271f 100644 --- a/packages/astro/src/events/error.ts +++ b/packages/astro/src/events/error.ts @@ -1,4 +1,4 @@ -import type { ZodError } from 'zod'; +import type { ZodError } from 'zod/v3'; import type { ErrorData } from '../core/errors/errors-data.js'; import { AstroError, AstroErrorData, type ErrorWithMetadata } from '../core/errors/index.js'; diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 8543f00bfc60..674ccbd6aceb 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -2422,6 +2422,17 @@ export interface AstroUserConfig< * See the [experimental Chrome DevTools workspace feature documentation](https://docs.astro.build/en/reference/experimental-flags/chrome-devtools-workspace/) for more information. */ chromeDevtoolsWorkspace?: boolean; + + /** + * @name experimental.zod4 + * @type {boolean} + * @default `false` + * @version 6.0 + * @description + * + * TODO: + */ + zod4?: boolean; }; } diff --git a/packages/astro/src/types/public/context.ts b/packages/astro/src/types/public/context.ts index 0a85bd55da2f..c6f523c3ad90 100644 --- a/packages/astro/src/types/public/context.ts +++ b/packages/astro/src/types/public/context.ts @@ -1,4 +1,4 @@ -import type { z } from 'zod'; +import type { z } from 'zod/v3'; import type { ActionAccept, ActionClient, ActionReturnType } from '../../actions/runtime/server.js'; import type { AstroCookies } from '../../core/cookies/cookies.js'; import type { CspDirective, CspHash } from '../../core/csp/config.js'; diff --git a/packages/astro/src/virtual-modules/live-config.ts b/packages/astro/src/virtual-modules/live-config.ts index 687aab2d6c01..10447a532136 100644 --- a/packages/astro/src/virtual-modules/live-config.ts +++ b/packages/astro/src/virtual-modules/live-config.ts @@ -1,4 +1,4 @@ -export * as z from 'zod'; +export * as z from 'zod/v3'; export { defineLiveCollection } from '../content/config.js'; function createErrorFunction(message: string) { diff --git a/packages/astro/src/vite-plugin-experimental-zod4/constants.ts b/packages/astro/src/vite-plugin-experimental-zod4/constants.ts new file mode 100644 index 000000000000..2aaac0770405 --- /dev/null +++ b/packages/astro/src/vite-plugin-experimental-zod4/constants.ts @@ -0,0 +1,3 @@ +/** Exposes data */ +export const VIRTUAL_MODULE_ID = 'virtual:astro:config/experimentalZod4'; +export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID; diff --git a/packages/astro/src/vite-plugin-experimental-zod4/index.ts b/packages/astro/src/vite-plugin-experimental-zod4/index.ts new file mode 100644 index 000000000000..5c7dbbd933d8 --- /dev/null +++ b/packages/astro/src/vite-plugin-experimental-zod4/index.ts @@ -0,0 +1,19 @@ +import type { Plugin } from 'vite'; +import type { AstroSettings } from '../types/astro.js'; +import { RESOLVED_VIRTUAL_MODULE_ID, VIRTUAL_MODULE_ID } from './constants.js'; + +export function experimentalZod4VitePlugin({ settings }: { settings: AstroSettings }): Plugin { + return { + name: VIRTUAL_MODULE_ID, + resolveId(id) { + if (id === VIRTUAL_MODULE_ID) { + return RESOLVED_VIRTUAL_MODULE_ID; + } + }, + load(id) { + if (id === RESOLVED_VIRTUAL_MODULE_ID) { + return `export const experimentalZod4 = ${JSON.stringify(settings.config.experimental.zod4)};`; + } + }, + }; +} diff --git a/packages/astro/src/vite-plugin-experimental-zod4/utils.ts b/packages/astro/src/vite-plugin-experimental-zod4/utils.ts new file mode 100644 index 000000000000..ccccdf6ef8df --- /dev/null +++ b/packages/astro/src/vite-plugin-experimental-zod4/utils.ts @@ -0,0 +1,26 @@ +import type * as z3 from 'zod/v3'; +import type * as z4 from 'zod/v4/core'; +import { AstroError } from '../core/errors/errors.js'; +import { InvalidZodSchemaVersion } from '../core/errors/errors-data.js'; + +export function checkZodSchemaCompatibility( + schema: z3.ZodType | z4.$ZodType, + experimentalZod4: boolean, + feature: string, +): AstroError | null { + if ('_zod' in schema && !experimentalZod4) { + return new AstroError({ + ...InvalidZodSchemaVersion, + message: InvalidZodSchemaVersion.message(feature, 4), + }); + } + + if (!('_zod' in schema) && experimentalZod4) { + return new AstroError({ + ...InvalidZodSchemaVersion, + message: InvalidZodSchemaVersion.message(feature, 3), + }); + } + + return null; +} diff --git a/packages/astro/src/zod.ts b/packages/astro/src/zod.ts index f1a79152b412..c7c8f7a63aa9 100644 --- a/packages/astro/src/zod.ts +++ b/packages/astro/src/zod.ts @@ -1,5 +1,5 @@ -import * as mod from 'zod'; +import * as mod from 'zod/v3'; -export * from 'zod'; +export * from 'zod/v3'; export { mod as z }; export default mod; diff --git a/packages/astro/test/fixtures/content-collections-number-id/astro.config.mjs b/packages/astro/test/fixtures/content-collections-number-id/astro.config.mjs new file mode 100644 index 000000000000..77967aa91d53 --- /dev/null +++ b/packages/astro/test/fixtures/content-collections-number-id/astro.config.mjs @@ -0,0 +1,8 @@ +// @ts-check +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + experimental: { + zod4: true, + }, +}); diff --git a/packages/astro/test/fixtures/content-collections-number-id/package.json b/packages/astro/test/fixtures/content-collections-number-id/package.json index 5c4fc5e9243a..a3ba7471ed97 100644 --- a/packages/astro/test/fixtures/content-collections-number-id/package.json +++ b/packages/astro/test/fixtures/content-collections-number-id/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "dependencies": { - "astro": "workspace:*" + "astro": "workspace:*", + "zod": "^4.1.12" } } diff --git a/packages/astro/test/fixtures/content-collections-number-id/src/content.config.ts b/packages/astro/test/fixtures/content-collections-number-id/src/content.config.ts index c2315eff6e0e..58a962a7ee6a 100644 --- a/packages/astro/test/fixtures/content-collections-number-id/src/content.config.ts +++ b/packages/astro/test/fixtures/content-collections-number-id/src/content.config.ts @@ -1,4 +1,5 @@ -import { defineCollection, z } from 'astro:content'; +import { defineCollection } from 'astro:content'; +import { z } from 'zod/v4' const data = defineCollection({ loader: async () => ([ diff --git a/packages/astro/test/units/actions/form-data-to-object.test.js b/packages/astro/test/units/actions/form-data-to-object.test.js index b4f9b65a3d1a..4845d4b52a52 100644 --- a/packages/astro/test/units/actions/form-data-to-object.test.js +++ b/packages/astro/test/units/actions/form-data-to-object.test.js @@ -1,9 +1,9 @@ import * as assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { z } from 'zod'; -import { formDataToObject } from '../../../dist/actions/runtime/server.js'; +import { z } from 'zod/v3'; +import { formDataToZ3Object } from '../../../dist/actions/runtime/server.js'; -describe('formDataToObject', () => { +describe('formDataToZ3Object', () => { it('should handle strings', () => { const formData = new FormData(); formData.set('name', 'Ben'); @@ -14,7 +14,7 @@ describe('formDataToObject', () => { email: z.string(), }); - const res = formDataToObject(formData, input); + const res = formDataToZ3Object(formData, input); assert.equal(res.name, 'Ben'); assert.equal(res.email, 'test@test.test'); }); @@ -27,7 +27,7 @@ describe('formDataToObject', () => { age: z.number(), }); - const res = formDataToObject(formData, input); + const res = formDataToZ3Object(formData, input); assert.equal(res.age, 25); }); @@ -39,7 +39,7 @@ describe('formDataToObject', () => { age: z.number(), }); - const res = formDataToObject(formData, input); + const res = formDataToZ3Object(formData, input); assert.ok(isNaN(res.age)); }); @@ -58,7 +58,7 @@ describe('formDataToObject', () => { falseString: z.boolean(), }); - const res = formDataToObject(formData, input); + const res = formDataToZ3Object(formData, input); assert.equal(res.isCool, true); assert.equal(res.isNotCool, false); assert.equal(res.isTrue, true); @@ -76,7 +76,7 @@ describe('formDataToObject', () => { age: z.number().optional(), }); - const res = formDataToObject(formData, input); + const res = formDataToZ3Object(formData, input); assert.equal(res.name, 'Ben'); assert.equal(res.email, undefined); @@ -93,7 +93,7 @@ describe('formDataToObject', () => { age: z.number().nullable(), }); - const res = formDataToObject(formData, input); + const res = formDataToZ3Object(formData, input); assert.equal(res.name, 'Ben'); assert.equal(res.email, null); @@ -109,7 +109,7 @@ describe('formDataToObject', () => { favoriteNumbers: z.array(z.number()).default([1, 2]), }); - const res = formDataToObject(formData, input); + const res = formDataToZ3Object(formData, input); assert.equal(res.name, 'test'); assert.equal(res.email, 'test@test.test'); assert.deepEqual(res.favoriteNumbers, [1, 2]); @@ -125,7 +125,7 @@ describe('formDataToObject', () => { favoriteNumbers: z.array(z.number()).default([1, 2]).nullish().optional(), }); - const res = formDataToObject(formData, input); + const res = formDataToZ3Object(formData, input); assert.equal(res.name, 'test'); assert.equal(res.email, 'test@test.test'); assert.deepEqual(res.favoriteNumbers, [1, 2]); @@ -139,7 +139,7 @@ describe('formDataToObject', () => { file: z.instanceof(File), }); - const res = formDataToObject(formData, input); + const res = formDataToZ3Object(formData, input); assert.equal(res.file instanceof File, true); }); @@ -154,7 +154,7 @@ describe('formDataToObject', () => { contact: z.array(z.string()), }); - const res = formDataToObject(formData, input); + const res = formDataToZ3Object(formData, input); assert.ok(Array.isArray(res.contact), 'contact is not an array'); assert.deepEqual(res.contact.sort(), ['Ben', 'Jane', 'John']); @@ -170,7 +170,7 @@ describe('formDataToObject', () => { age: z.array(z.number()), }); - const res = formDataToObject(formData, input); + const res = formDataToZ3Object(formData, input); assert.ok(Array.isArray(res.age), 'age is not an array'); assert.deepEqual(res.age.sort(), [25, 30, 35]); @@ -187,7 +187,7 @@ describe('formDataToObject', () => { files: z.array(z.instanceof(File)), }); - const res = formDataToObject(formData, input); + const res = formDataToZ3Object(formData, input); assert.equal(res.files instanceof Array, true); assert.deepEqual(res.files, [file1, file2]); @@ -204,7 +204,7 @@ describe('formDataToObject', () => { }) .passthrough(); - const res = formDataToObject(formData, input); + const res = formDataToZ3Object(formData, input); assert.deepEqual(res, { expected: 42, unexpected: '42', diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js index 338b66f6bf5f..2ec46c789f70 100644 --- a/packages/astro/test/units/config/config-validate.test.js +++ b/packages/astro/test/units/config/config-validate.test.js @@ -2,7 +2,7 @@ import * as assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { stripVTControlCharacters } from 'node:util'; -import { z } from 'zod'; +import { z } from 'zod/v3'; import { fontProviders } from '../../../dist/assets/fonts/providers/index.js'; import { validateConfig as _validateConfig } from '../../../dist/core/config/validate.js'; import { formatConfigErrorMessage } from '../../../dist/core/messages.js'; diff --git a/packages/astro/types/content.d.ts b/packages/astro/types/content.d.ts index cb5b6d7360b8..17273ac72b63 100644 --- a/packages/astro/types/content.d.ts +++ b/packages/astro/types/content.d.ts @@ -8,17 +8,7 @@ declare module 'astro:content' { BaseSchema, SchemaContext, } from 'astro/content/config'; - - export function defineLiveCollection< - L extends import('astro/loaders').LiveLoader, - S extends import('astro/content/config').BaseSchema | undefined = undefined, - >( - config: import('astro/content/config').LiveCollectionConfig, - ): import('astro/content/config').LiveCollectionConfig; - - export function defineCollection( - config: import('astro/content/config').CollectionConfig, - ): import('astro/content/config').CollectionConfig; + export { defineCollection, defineLiveCollection } from 'astro/content/config'; /** Run `astro dev` or `astro sync` to generate high fidelity types */ export const getEntryBySlug: (...args: any[]) => any; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4fd1060a6c5..c5b03124c0f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: sharp: specifier: ^0.34.3 version: 0.34.3 + zod: + specifier: ^4.0.0 + version: 4.1.12 examples/component: devDependencies: @@ -556,6 +559,9 @@ importers: js-yaml: specifier: ^4.1.0 version: 4.1.0 + json-schema-to-typescript: + specifier: ^15.0.4 + version: 15.0.4 kleur: specifier: ^4.1.5 version: 4.1.5 @@ -638,14 +644,14 @@ importers: specifier: ^0.2.3 version: 0.2.3 zod: - specifier: ^3.25.76 - version: 3.25.76 + specifier: ^3.25.76 || ^4.0.0 + version: 4.1.12 zod-to-json-schema: specifier: ^3.24.6 - version: 3.24.6(zod@3.25.76) + version: 3.24.6(zod@4.1.12) zod-to-ts: specifier: ^1.2.0 - version: 1.2.0(typescript@5.9.3)(zod@3.25.76) + version: 1.2.0(typescript@5.9.3)(zod@4.1.12) devDependencies: '@astrojs/check': specifier: ^0.9.4 @@ -2687,6 +2693,9 @@ importers: astro: specifier: workspace:* version: link:../../.. + zod: + specifier: ^4.1.12 + version: 4.1.12 packages/astro/test/fixtures/content-collections-same-contents: dependencies: @@ -6489,6 +6498,10 @@ packages: '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + '@apidevtools/json-schema-ref-parser@11.9.3': + resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} + engines: {node: '>= 16'} + '@assemblyscript/loader@0.19.23': resolution: {integrity: sha512-ulkCYfFbYj01ie1MDOyxv2F6SpRN1TOj7fQxbP07D6HmeR+gr2JLSmINKjga2emB+b1L2KGrFKBTc+e00p54nw==} @@ -8204,6 +8217,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@jsdevtools/rehype-toc@3.0.2': resolution: {integrity: sha512-n5JEf16Wr4mdkRMZ8wMP/wN9/sHmTjRPbouXjJH371mZ2LEGDl72t8tEsMRNFerQN/QJtivOxqK1frdGa4QK5Q==} engines: {node: '>=10'} @@ -9040,6 +9056,9 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash@4.17.20': + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + '@types/markdown-it@12.2.3': resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==} @@ -11318,6 +11337,11 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-to-typescript@15.0.4: + resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==} + engines: {node: '>=16.0.0'} + hasBin: true + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -14263,6 +14287,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -14270,6 +14297,12 @@ snapshots: '@antfu/utils@0.7.10': {} + '@apidevtools/json-schema-ref-parser@11.9.3': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.1.0 + '@assemblyscript/loader@0.19.23': {} '@astro-community/astro-embed-baseline-status@0.1.2(astro@packages+astro)': @@ -15807,6 +15840,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jsdevtools/ono@7.1.3': {} + '@jsdevtools/rehype-toc@3.0.2': {} '@libsql/client@0.15.15': @@ -16786,6 +16821,8 @@ snapshots: '@types/linkify-it@5.0.0': {} + '@types/lodash@4.17.20': {} + '@types/markdown-it@12.2.3': dependencies: '@types/linkify-it': 5.0.0 @@ -19362,6 +19399,18 @@ snapshots: json-buffer@3.0.1: {} + json-schema-to-typescript@15.0.4: + dependencies: + '@apidevtools/json-schema-ref-parser': 11.9.3 + '@types/json-schema': 7.0.15 + '@types/lodash': 4.17.20 + is-glob: 4.0.3 + js-yaml: 4.1.0 + lodash: 4.17.21 + minimist: 1.2.8 + prettier: 3.6.2 + tinyglobby: 0.2.15 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -22807,14 +22856,14 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 - zod-to-json-schema@3.24.6(zod@3.25.76): + zod-to-json-schema@3.24.6(zod@4.1.12): dependencies: - zod: 3.25.76 + zod: 4.1.12 - zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): + zod-to-ts@1.2.0(typescript@5.9.3)(zod@4.1.12): dependencies: typescript: 5.9.3 - zod: 3.25.76 + zod: 4.1.12 zod-validation-error@3.5.3(zod@3.25.76): dependencies: @@ -22824,4 +22873,6 @@ snapshots: zod@3.25.76: {} + zod@4.1.12: {} + zwitch@2.0.4: {}