diff --git a/src/utils/context.ts b/src/utils/context.ts index de94683dc..a5a63f3d7 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -1,45 +1,69 @@ import { createContext } from 'unctx' +import type { Draft07 } from '../types' type ContextKey = 'zod3' | 'zod4' | 'valibot' | 'unknown' -const nuxtContentContext = { - zod3: { - toJSONSchema: (_schema: unknown, _name: string) => { - throw new Error( - 'It seems you are using Zod version 3 for collection schema, but Zod is not installed, ' - + 'Nuxt Content does not ship with zod, install `zod` and `zod-to-json-schema` and it will work.', - ) - }, - }, - zod4: { - toJSONSchema: (_schema: unknown, _name: string) => { - throw new Error( - 'It seems you are using Zod version 4 for collection schema, but Zod is not installed, ' - + 'Nuxt Content does not ship with zod, install `zod` and it will work.', - ) - }, - }, - valibot: { - toJSONSchema: (_schema: unknown, _name: string) => { - throw new Error( - 'It seems you are using Valibot for collection schema, but Valibot is not installed, ' - + 'Nuxt Content does not ship with valibot, install `valibot` and `@valibot/to-json-schema` and it will work.', - ) - }, - }, - unknown: { - toJSONSchema: (_schema: unknown, _name: string) => { - throw new Error('Unknown schema vendor') - }, - }, - set: (key: ContextKey, value: unknown) => { - nuxtContentContext[key] = value as typeof nuxtContentContext[ContextKey] - }, - get: (key: ContextKey) => { - return nuxtContentContext[key] - }, +// Stash the validators context on `globalThis` under a `Symbol.for` key so +// that any duplicate evaluations of this module (e.g. when jiti re-loads +// `@nuxt/content` while processing `content.config.ts` under pnpm's +// `enableGlobalVirtualStore`, where realpath differences break Node's ESM +// cache) share the same backing object. Without this, the second instance +// resets the context to its stub state and `toJSONSchema` throws even +// after the first instance detected zod/valibot. +const SINGLETON_KEY = Symbol.for('@nuxt/content:validators-context') + +type SchemaHandler = { toJSONSchema: (schema: unknown, name: string) => Draft07 } + +type NuxtContentContext = { + zod3: SchemaHandler + zod4: SchemaHandler + valibot: SchemaHandler + unknown: SchemaHandler + set: (key: ContextKey, value: unknown) => void + get: (key: ContextKey) => SchemaHandler } +const nuxtContentContext: NuxtContentContext + = ((globalThis as Record)[SINGLETON_KEY] as NuxtContentContext | undefined) ?? ( + (globalThis as Record)[SINGLETON_KEY] = { + zod3: { + toJSONSchema: (_schema: unknown, _name: string) => { + throw new Error( + 'It seems you are using Zod version 3 for collection schema, but Zod is not installed, ' + + 'Nuxt Content does not ship with zod, install `zod` and `zod-to-json-schema` and it will work.', + ) + }, + }, + zod4: { + toJSONSchema: (_schema: unknown, _name: string) => { + throw new Error( + 'It seems you are using Zod version 4 for collection schema, but Zod is not installed, ' + + 'Nuxt Content does not ship with zod, install `zod` and it will work.', + ) + }, + }, + valibot: { + toJSONSchema: (_schema: unknown, _name: string) => { + throw new Error( + 'It seems you are using Valibot for collection schema, but Valibot is not installed, ' + + 'Nuxt Content does not ship with valibot, install `valibot` and `@valibot/to-json-schema` and it will work.', + ) + }, + }, + unknown: { + toJSONSchema: (_schema: unknown, _name: string) => { + throw new Error('Unknown schema vendor') + }, + }, + set(key: ContextKey, value: unknown) { + (this as unknown as Record)[key] = value + }, + get(key: ContextKey) { + return (this as unknown as Record)[key] + }, + } satisfies NuxtContentContext + ) as NuxtContentContext + const ctx = createContext() ctx.set(nuxtContentContext) diff --git a/src/utils/dependencies.ts b/src/utils/dependencies.ts index fd5eb8040..2892d3ab4 100644 --- a/src/utils/dependencies.ts +++ b/src/utils/dependencies.ts @@ -1,11 +1,16 @@ import { addDependency } from 'nypm' +import { resolvePackageJSON } from 'pkg-types' import { logger } from './dev' import nuxtContentContext from './context' import { tryUseNuxt } from '@nuxt/kit' export async function isPackageInstalled(packageName: string) { + // Resolve relative to @nuxt/content's own location so the check survives + // pnpm's `enableGlobalVirtualStore`, where dependencies declared by + // @nuxt/content (e.g. zod) aren't reachable from the user's project root + // and a plain dynamic import would fail. try { - await import(packageName) + await resolvePackageJSON(packageName, { from: import.meta.url }) return true } catch { diff --git a/test/unit/validatorRegistry.test.ts b/test/unit/validatorRegistry.test.ts new file mode 100644 index 000000000..31a4cbac2 --- /dev/null +++ b/test/unit/validatorRegistry.test.ts @@ -0,0 +1,33 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { z } from 'zod' + +const SINGLETON_KEY = Symbol.for('@nuxt/content:validators-context') + +function resetValidatorsContext() { + Reflect.deleteProperty(globalThis as Record, SINGLETON_KEY) +} + +describe('validator registry', () => { + beforeEach(() => { + vi.resetModules() + resetValidatorsContext() + }) + + afterEach(() => { + resetValidatorsContext() + }) + + test('preserves initialized validators across duplicate module evaluations', async () => { + const { initiateValidatorsContext } = await import('../../src/utils/dependencies.ts?deps=primary') + await initiateValidatorsContext() + + const { default: useContextA } = await import('../../src/utils/context.ts?ctx=a') + const { default: useContextB } = await import('../../src/utils/context.ts?ctx=b') + + const contextA = useContextA() + const contextB = useContextB() + + expect(contextA).toBe(contextB) + expect(() => contextB.get('zod3').toJSONSchema(z.object({ title: z.string() }), '__SCHEMA__')).not.toThrow() + }) +})