diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index e6ab451..728359d 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -1,6 +1,6 @@ -import './App.css'; -import { z } from 'zod'; -import { schema, useSnapshot } from '../../../src/index'; +import './App.css' +import { z } from 'zod' +import { schema, useSnapshot } from '../../../src/index' const userSchema = z.object({ username: z.string(), @@ -10,10 +10,10 @@ const userSchema = z.object({ lastName: z.string(), address: z.object({ city: z.string(), - country: z.string(), - }), - }), -}); + country: z.string() + }) + }) +}) const userState = schema(userSchema).proxy( { @@ -24,77 +24,75 @@ const userState = schema(userSchema).proxy( lastName: 'Smith', address: { city: 'Wonderland', - country: 'Fantasy', - }, - }, + country: 'Fantasy' + } + } }, - { safeParse: true, errorHandler: (e) => console.log(e.message) }, -); + { safeParse: true, errorHandler: (e) => console.log(e.message) } +) function App() { - const user = useSnapshot(userState); + const user = useSnapshot(userState) return (

Vite + React

- + (userState.username = e.target.value)} />

Username: {user.username}

- + (userState.age = Number(e.target.value))} + onChange={(e) => (userState.age = e.target.value)} />

Age: {user.age}

- + (userState.profile.firstName = e.target.value)} />

First Name: {user.profile.firstName}

- + (userState.profile.lastName = e.target.value)} />

Last Name: {user.profile.lastName}

- + (userState.profile.address.city = e.target.value)} />

City: {user.profile.address.city}

- + - (userState.profile.address.country = Number(e.target.value)) - } + onChange={(e) => (userState.profile.address.country = e.target.value)} />

Last Name: {user.profile.address.country}

- ); + ) } -export default App; +export default App diff --git a/examples/react/src/test.tsx b/examples/react/src/test.tsx new file mode 100644 index 0000000..dffee3a --- /dev/null +++ b/examples/react/src/test.tsx @@ -0,0 +1,6 @@ +import { ImageMetadataSchema, ImageMetadata } from './types' +import { schema } from '../../../src/index' + +const store = schema(ImageMetadataSchema).proxy({ + base64: null +}) diff --git a/examples/react/src/types.ts b/examples/react/src/types.ts new file mode 100644 index 0000000..fd76bc4 --- /dev/null +++ b/examples/react/src/types.ts @@ -0,0 +1,48 @@ +import { z } from 'zod' + +export type ImageMetadataType = { + base64: string + filename: string + tags: string[] + title: string + contentType: string + width: number + height: number +} + +export type SelectFieldOption = { label: string; value: string } + +// Base64 validation regex +const base64Regex = + /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ + +// Content type validation regex (matches any MIME type starting with 'image/') +const contentTypeRegex = /^image\/[a-zA-Z0-9+\.-]+$/ + +// Filename validation regex (simple validation to exclude invalid filename characters) +const filenameRegex = /^[^<>:"/\\|?*\x00-\x1F]+$/ + +// Define the ImageMetadata schema +export const ImageMetadataSchema = z.object({ + base64: z.string().regex(base64Regex, { message: 'Invalid image data' }), + filename: z + .string({ required_error: 'Filename cannot be empty' }) + .regex(filenameRegex, { message: 'Filename contains invalid characters' }), + tags: z.array(z.string({ required_error: 'Tags cannot be empty strings' })), + title: z.string({ required_error: 'Title cannot be empty' }), + contentType: z.string().regex(contentTypeRegex, { + message: + 'Content type must start with "image/" and contain valid characters' + }), + width: z + .number() + .int({ message: 'Width must be an integer' }) + .positive({ message: 'Width must be a positive number' }), + height: z + .number() + .int({ message: 'Height must be an integer' }) + .positive({ message: 'Height must be a positive number' }) +}) + +// Export the TypeScript type inferred from the schema +export type ImageMetadata = z.infer diff --git a/src/index.ts b/src/index.ts index 16c37f3..9a86fda 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,83 +1,86 @@ /* eslint-disable */ -import { z, ZodType } from 'zod'; -import { proxy as vproxy, useSnapshot as vsnap, getVersion } from 'valtio'; -import _ from 'lodash'; +import { z, ZodType } from 'zod' +import { proxy as vproxy, useSnapshot as vsnap, getVersion } from 'valtio' +import _ from 'lodash' +import type { O } from 'vitest/dist/reporters-yx5ZTtEV.js' type ValtioProxy = { - [P in keyof T]: T[P]; -}; + [P in keyof T]: T[P] +} type SchemaConfig = { - parseAsync?: boolean; - safeParse?: boolean; - errorHandler?: (error: unknown) => void; -}; + parseAsync?: boolean + safeParse?: boolean + errorHandler?: (error: unknown) => void +} const defaultConfig = { parseAsync: false, safeParse: false, - errorHandler: (error: unknown) => console.error(error), -}; + errorHandler: (error: unknown) => console.error(error) +} export const vzGlobalConfig = { safeParse: false, - errorHandler: (error: unknown) => console.error(error), -}; + errorHandler: (error: unknown) => console.error(error) +} const isObject = (x: unknown): x is object => - typeof x === 'object' && x !== null; + typeof x === 'object' && x !== null -type MergedConfig = Required; +type MergedConfig = Required type SchemaMeta = SchemaConfig & { - initialState: unknown; -}; + initialState: unknown +} -type PropType = string | number | symbol; -const schemaMeta = new WeakMap, SchemaMeta>(); -const pathList = new WeakMap<{}, PropType[]>(); -const isProxySymbol = Symbol('isProxy'); +type PropType = string | number | symbol +const schemaMeta = new WeakMap, SchemaMeta>() +const pathList = new WeakMap<{}, PropType[]>() +const isProxySymbol = Symbol('isProxy') type SchemaReturn> = { proxy: { - (initialState: any, config?: SchemaConfig): ValtioProxy>; - }; -}; + (initialState: any, config?: SchemaConfig): ValtioProxy> + } +} -const valtioStoreSymbol = Symbol('valtioStore'); +const valtioStoreSymbol = Symbol('valtioStore') export const useSnapshot = (store: any) => { - const valtioStore = store[valtioStoreSymbol]; - return vsnap(valtioStore[valtioStoreSymbol]); -}; + const valtioStore = store[valtioStoreSymbol] + return vsnap(valtioStore[valtioStoreSymbol]) +} export const schema = >( - zodSchema: T, -): SchemaReturn => { - const proxy = ( - initialState: z.infer, - config: SchemaConfig = {}, - ): ValtioProxy> => { + zodSchema: T +): { + proxy: (initialState: O, config?: SchemaConfig) => O +} => { + const proxy = ( + initialState: O, + config: SchemaConfig = {} + ): O => { if (!isObject(initialState)) { - throw new Error('object required'); + throw new Error('object required') } - const mergedConfig: MergedConfig = { ...defaultConfig, ...config }; + const mergedConfig: MergedConfig = { ...defaultConfig, ...config } - const parseAsync = mergedConfig.parseAsync; - const safeParse = mergedConfig.safeParse; - const errorHandler = mergedConfig.errorHandler; + const parseAsync = mergedConfig.parseAsync + const safeParse = mergedConfig.safeParse + const errorHandler = mergedConfig.errorHandler // before proxying, validate the initial state if (parseAsync) { zodSchema.parseAsync(initialState).catch((e) => { - throw e; - }); + throw e + }) } else { - zodSchema.parse(initialState); + zodSchema.parse(initialState) } - const valtioProxy = vproxy(initialState); + const valtioProxy = vproxy(initialState) const createProxy = (target: any, parentPath: PropType[] = []): any => { if (!schemaMeta.has(zodSchema)) { @@ -85,100 +88,100 @@ export const schema = >( safeParse, parseAsync, errorHandler, - initialState, - }); + initialState + }) } return new Proxy(target, { get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); + const value = Reflect.get(target, prop, receiver) if (isObject(value)) { if ((value as any)[isProxySymbol]) { - return value; + return value } else { - const newPath = parentPath.concat(prop); - pathList.set(value, newPath); - const proxyObj = createProxy(value, newPath); - proxyObj[isProxySymbol] = true; - return proxyObj; + const newPath = parentPath.concat(prop) + pathList.set(value, newPath) + const proxyObj = createProxy(value, newPath) + proxyObj[isProxySymbol] = true + return proxyObj } } else { - const pathToGet = [...(pathList.get(target) || []), prop]; - return _.get(valtioProxy, pathToGet, value); + const pathToGet = [...(pathList.get(target) || []), prop] + return _.get(valtioProxy, pathToGet, value) } }, set(target, prop, value, receiver) { const originalObject = schemaMeta.get(zodSchema)! - .initialState as z.infer; + .initialState as z.infer - const objectToValidate = _.cloneDeep(originalObject); - const pathToSet = [...(pathList.get(target) || []), prop]; + const objectToValidate = _.cloneDeep(originalObject) + const pathToSet = [...(pathList.get(target) || []), prop] - _.set(objectToValidate, pathToSet, value); + _.set(objectToValidate, pathToSet, value) const handleAsyncParse = async () => { try { - const parsedValue = await zodSchema.parseAsync(objectToValidate); - _.set(valtioProxy, pathToSet, value); - Reflect.set(target, prop, value, receiver); - return true; + const parsedValue = await zodSchema.parseAsync(objectToValidate) + _.set(valtioProxy, pathToSet, value) + Reflect.set(target, prop, value, receiver) + return true } catch (error) { - errorHandler(error); + errorHandler(error) if (!safeParse) { - throw error; + throw error } - return true; + return true } - }; + } const handleSyncParse = () => { try { if (safeParse) { - const result = zodSchema.safeParse(objectToValidate); + const result = zodSchema.safeParse(objectToValidate) if (result.success) { - _.set(valtioProxy, pathToSet, value); - Reflect.set(target, prop, value, receiver); - return true; + _.set(valtioProxy, pathToSet, value) + Reflect.set(target, prop, value, receiver) + return true } else { - errorHandler(result.error); + errorHandler(result.error) // need to return true here to prevent an error from being thrown // ifrom the proxy not updating the value - return true; + return true } } else { - const parsedValue = zodSchema.parse(objectToValidate); - _.set(valtioProxy, pathToSet, value); - Reflect.set(target, prop, value, receiver); - return true; + const parsedValue = zodSchema.parse(objectToValidate) + _.set(valtioProxy, pathToSet, value) + Reflect.set(target, prop, value, receiver) + return true } } catch (error) { - errorHandler(error); + errorHandler(error) if (!safeParse) { - throw error; + throw error } - return true; + return true } - }; + } if (parseAsync) { handleAsyncParse().catch((error) => { - errorHandler(error); + errorHandler(error) if (!safeParse) { - throw error; + throw error } - }); - return true; + }) + return true } else { - return handleSyncParse(); + return handleSyncParse() } - }, - }); - }; + } + }) + } - const store = createProxy(valtioProxy); - store[valtioStoreSymbol] = valtioProxy; + const store = createProxy(valtioProxy) + store[valtioStoreSymbol] = valtioProxy - return store; - }; - return { proxy }; -}; + return store + } + return { proxy } +}