From fbfc92bab812f8d0d5e7b418ef9f2dec6e047d47 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 01:23:02 +0900 Subject: [PATCH 1/2] chore: migrate props to zod v4 Preserve public prop and platform function validation contracts under the Zod v4 APIs. --- README.md | 16 +- generated/COMPONENT_TYPES.md | 39 ++--- generated/PROPS_OVERVIEW.md | 11 +- lib/common/cadModel.ts | 2 +- lib/common/connectionsProp.ts | 2 +- lib/common/layout.ts | 4 +- lib/common/pcbSx.ts | 8 +- lib/common/schematicPinStyle.ts | 1 + lib/common/url.ts | 2 +- lib/components/analogsweepparameter.ts | 2 +- lib/components/analogtransientsimulation.ts | 6 +- lib/components/cadassembly.ts | 6 +- lib/components/chip.ts | 4 +- lib/components/diode.ts | 6 +- lib/components/footprint.ts | 6 +- lib/components/group.ts | 12 +- lib/components/platedhole.ts | 10 +- lib/components/resistor.ts | 7 +- lib/components/silkscreen-rect.ts | 2 +- lib/components/symbol.ts | 8 +- lib/enclosure/fdm/box.ts | 2 +- lib/platformConfig.ts | 140 ++++++++++++------ lib/projectConfig.ts | 2 +- package.json | 8 +- tests/autorouter.test.ts | 23 +++ tests/cad-assembly.test.ts | 6 + tests/footprint.test.ts | 4 + .../resolveProjectStaticFileImportUrl.test.ts | 35 +++++ tests/silkscreen-rect.test.ts | 8 + 29 files changed, 259 insertions(+), 123 deletions(-) create mode 100644 tests/cad-assembly.test.ts create mode 100644 tests/silkscreen-rect.test.ts diff --git a/README.md b/README.md index 00b0c010..5c3e1f2a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ This repo contains all the prop definitions and zod parsers for tscircuit builti This repo is the source-of-truth for defining the React props, API changes begin here. The focus of the API is on ergonomics for the user (unlike [circuit-json](https://github.com/tscircuit/circuit-json) which focuses on ergonomics for a renderer) +> **Zod v4 migration:** This package now requires `zod@^4` and +> `circuit-json@^0.0.454` as peer dependencies. Schemas composed with these +> props must use the same Zod major; consumers that inspect raw Zod errors +> should update to the v4 error APIs. + ```ts import type { ResistorProps, ResistorPropsInput } from "@tscircuit/props"; import { resistorProps } from "@tscircuit/props"; @@ -440,8 +445,8 @@ export interface CadAssemblyProps { * components will be mirrored. * * Generally, you shouldn't set this except where it can help prevent - * confusion because you have a complex multi-layer assembly. Default is - * "top" and this is most intuitive. + * confusion because you have a complex multi-layer assembly. When omitted, + * the assembly leaves its original layer unspecified. */ originalLayer?: LayerRef; @@ -846,8 +851,8 @@ export interface FootprintProps { * components will be mirrored. * * Generally, you shouldn't set this except where it can help prevent - * confusion because you have a complex multi-layer footprint. Default is - * "top" and this is most intuitive. + * confusion because you have a complex multi-layer footprint. When omitted, + * the footprint leaves its original layer unspecified. */ originalLayer?: LayerRef; /** @@ -2035,7 +2040,8 @@ export interface SymbolProps { * The facing direction that the symbol is designed for. If you set this to "right", * then it means the children were intended to represent the symbol facing right. * Generally, you shouldn't set this except where it can help prevent confusion - * because you have a complex symbol. Default is "right" and this is most intuitive. + * because you have a complex symbol. When omitted, the symbol leaves its + * original facing direction unspecified. */ originalFacingDirection?: "up" | "down" | "left" | "right"; width?: string | number; diff --git a/generated/COMPONENT_TYPES.md b/generated/COMPONENT_TYPES.md index 99bec4ed..ba99b183 100644 --- a/generated/COMPONENT_TYPES.md +++ b/generated/COMPONENT_TYPES.md @@ -94,7 +94,7 @@ export interface CadModelJscad extends CadModelBase { jscad: Record } export const cadModelJscad = cadModelBase.extend({ - jscad: z.record(z.any()), + jscad: z.record(z.string(), z.any()), }) export const cadModelProp = z.union([ z.null(), @@ -143,7 +143,7 @@ export const circleShapeProps = z.object({ export const createConnectionsProp = ( labels: T, ) => { - return z.record(z.enum(labels), connectionTarget) + return z.partialRecord(z.enum(labels), connectionTarget) } ``` @@ -561,7 +561,9 @@ export interface SupplierProps { supplierPartNumbers?: SupplierPartNumbers } export const supplierProps = z.object({ - supplierPartNumbers: z.record(supplier_name, z.array(z.string())).optional(), + supplierPartNumbers: z + .partialRecord(supplier_name, z.array(z.string())) + .optional(), }) export interface CommonComponentProps extends CommonLayoutProps { @@ -878,6 +880,7 @@ export type SchematicPinStyle = Record< } /** @deprecated use marginBottom */ export const schematicPinStyle = z.record( + z.string(), z.object({ marginLeft: distance.optional(), marginRight: distance.optional(), @@ -900,7 +903,7 @@ export const url = z.preprocess((value) => { } return value -}, z.string()) as z.ZodType +}, z.string()) as unknown as z.ZodType ``` ## Available Component Types @@ -1118,9 +1121,9 @@ export interface AnalogTransientSimulationProps export const analogTransientSimulationProps = z .object({ ...analogAnalysisSimulationBaseProps, - duration: positiveMilliseconds.default("10ms"), - startTime: ms.default("0ms"), - timePerStep: positiveMilliseconds.default("0.01ms"), + duration: positiveMilliseconds.prefault("10ms"), + startTime: ms.prefault("0ms"), + timePerStep: positiveMilliseconds.prefault("0.01ms"), }) ``` @@ -1290,11 +1293,11 @@ export interface CadAssemblyProps { * components will be mirrored. * * Generally, you shouldn't set this except where it can help prevent - * confusion because you have a complex multi-layer assembly. Default is - * "top" and this is most intuitive. + * confusion because you have a complex multi-layer assembly. When omitted, + * the assembly leaves its original layer unspecified. */ export const cadassemblyProps = z.object({ - originalLayer: layer_ref.default("top").optional(), + originalLayer: layer_ref.optional(), children: z.any().optional(), }) ``` @@ -1412,7 +1415,9 @@ export type ChipConnections) => any> = { } export const pinCompatibleVariant = z.object({ manufacturerPartNumber: z.string().optional(), - supplierPartNumber: z.record(supplier_name, z.array(z.string())).optional(), + supplierPartNumber: z + .partialRecord(supplier_name, z.array(z.string())) + .optional(), }) export const chipProps = commonComponentProps.extend({ manufacturerPartNumber: z.string().optional(), @@ -1996,7 +2001,7 @@ export interface FootprintProps { export const footprintProps = z.object({ children: z.any().optional(), name: z.string().optional(), - originalLayer: layer_ref.default("top").optional(), + originalLayer: layer_ref.optional(), circuitJson: z.array(z.any()).optional(), src: footprintProp.describe("Can be a footprint or kicad string").optional(), insertionDirection: footprintInsertionDirection @@ -4076,7 +4081,7 @@ export const silkscreenPathProps = pcbLayoutProps export const silkscreenRectProps = pcbLayoutProps .omit({ pcbRotation: true }) .extend({ - filled: z.boolean().default(true).optional(), + filled: z.boolean().optional(), stroke: z.enum(["dashed", "solid", "none"]).optional(), strokeWidth: distance.optional(), width: distance, @@ -4370,13 +4375,11 @@ export interface SymbolProps { * The facing direction that the symbol is designed for. If you set this to "right", * then it means the children were intended to represent the symbol facing right. * Generally, you shouldn't set this except where it can help prevent confusion - * because you have a complex symbol. Default is "right" and this is most intuitive. + * because you have a complex symbol. When omitted, the symbol leaves its + * original facing direction unspecified. */ export const symbolProps = z.object({ - originalFacingDirection: z - .enum(["up", "down", "left", "right"]) - .default("right") - .optional(), + originalFacingDirection: z.enum(["up", "down", "left", "right"]).optional(), width: distance.optional(), height: distance.optional(), name: z.string().optional(), diff --git a/generated/PROPS_OVERVIEW.md b/generated/PROPS_OVERVIEW.md index ab5d8baf..9e146cde 100644 --- a/generated/PROPS_OVERVIEW.md +++ b/generated/PROPS_OVERVIEW.md @@ -416,8 +416,8 @@ export interface CadAssemblyProps { * components will be mirrored. * * Generally, you shouldn't set this except where it can help prevent - * confusion because you have a complex multi-layer assembly. Default is - * "top" and this is most intuitive. + * confusion because you have a complex multi-layer assembly. When omitted, + * the assembly leaves its original layer unspecified. */ originalLayer?: LayerRef @@ -993,8 +993,8 @@ export interface FootprintProps { * components will be mirrored. * * Generally, you shouldn't set this except where it can help prevent - * confusion because you have a complex multi-layer footprint. Default is - * "top" and this is most intuitive. + * confusion because you have a complex multi-layer footprint. When omitted, + * the footprint leaves its original layer unspecified. */ originalLayer?: LayerRef /** @@ -2449,7 +2449,8 @@ export interface SymbolProps { * The facing direction that the symbol is designed for. If you set this to "right", * then it means the children were intended to represent the symbol facing right. * Generally, you shouldn't set this except where it can help prevent confusion - * because you have a complex symbol. Default is "right" and this is most intuitive. + * because you have a complex symbol. When omitted, the symbol leaves its + * original facing direction unspecified. */ originalFacingDirection?: "up" | "down" | "left" | "right" width?: string | number diff --git a/lib/common/cadModel.ts b/lib/common/cadModel.ts index 2a3e7207..53c3d36d 100644 --- a/lib/common/cadModel.ts +++ b/lib/common/cadModel.ts @@ -110,7 +110,7 @@ export interface CadModelJscad extends CadModelBase { jscad: Record } export const cadModelJscad = cadModelBase.extend({ - jscad: z.record(z.any()), + jscad: z.record(z.string(), z.any()), }) export type CadModelProp = diff --git a/lib/common/connectionsProp.ts b/lib/common/connectionsProp.ts index 3ff513b4..f345c2a1 100644 --- a/lib/common/connectionsProp.ts +++ b/lib/common/connectionsProp.ts @@ -8,5 +8,5 @@ export const connectionTarget = z export const createConnectionsProp = ( labels: T, ) => { - return z.record(z.enum(labels), connectionTarget) + return z.partialRecord(z.enum(labels), connectionTarget) } diff --git a/lib/common/layout.ts b/lib/common/layout.ts index c3606f3d..da58266a 100644 --- a/lib/common/layout.ts +++ b/lib/common/layout.ts @@ -225,7 +225,9 @@ export interface SupplierProps { supplierPartNumbers?: SupplierPartNumbers } export const supplierProps = z.object({ - supplierPartNumbers: z.record(supplier_name, z.array(z.string())).optional(), + supplierPartNumbers: z + .partialRecord(supplier_name, z.array(z.string())) + .optional(), }) expectTypesMatch>(true) diff --git a/lib/common/pcbSx.ts b/lib/common/pcbSx.ts index 71a58cfd..b4321250 100644 --- a/lib/common/pcbSx.ts +++ b/lib/common/pcbSx.ts @@ -29,9 +29,9 @@ export const pcbSxValue = z.object({ visibility: z.enum(["hidden", "visible", "inherit"]).optional(), }) -export const pcbSx = z.record( - z.string(), - pcbSxValue, -) as unknown as z.ZodType +export const pcbSx = z.record(z.string(), pcbSxValue) as unknown as z.ZodType< + PcbSx, + PcbSx +> expectTypesMatch>(true) diff --git a/lib/common/schematicPinStyle.ts b/lib/common/schematicPinStyle.ts index 52bbaaac..25a3bc59 100644 --- a/lib/common/schematicPinStyle.ts +++ b/lib/common/schematicPinStyle.ts @@ -22,6 +22,7 @@ export type SchematicPinStyle = Record< > export const schematicPinStyle = z.record( + z.string(), z.object({ marginLeft: distance.optional(), marginRight: distance.optional(), diff --git a/lib/common/url.ts b/lib/common/url.ts index aa3d7356..21f7afb2 100644 --- a/lib/common/url.ts +++ b/lib/common/url.ts @@ -6,4 +6,4 @@ export const url = z.preprocess((value) => { } return value -}, z.string()) as z.ZodType +}, z.string()) as unknown as z.ZodType diff --git a/lib/components/analogsweepparameter.ts b/lib/components/analogsweepparameter.ts index 88646ca6..3737ef7f 100644 --- a/lib/components/analogsweepparameter.ts +++ b/lib/components/analogsweepparameter.ts @@ -63,7 +63,7 @@ const voltageSweepQuantity = voltage.pipe(z.number()) const currentSweepQuantity = current.pipe(z.number()) const createAnalogSweepCoordinateProps = ( - sweepQuantity: z.ZodType, + sweepQuantity: z.ZodType, ) => ({ name: z.string().optional(), values: z.array(sweepQuantity).min(1).optional(), diff --git a/lib/components/analogtransientsimulation.ts b/lib/components/analogtransientsimulation.ts index fc495768..7353c250 100644 --- a/lib/components/analogtransientsimulation.ts +++ b/lib/components/analogtransientsimulation.ts @@ -24,9 +24,9 @@ const positiveMilliseconds = ms.refine( export const analogTransientSimulationProps = z .object({ ...analogAnalysisSimulationBaseProps, - duration: positiveMilliseconds.default("10ms"), - startTime: ms.default("0ms"), - timePerStep: positiveMilliseconds.default("0.01ms"), + duration: positiveMilliseconds.prefault("10ms"), + startTime: ms.prefault("0ms"), + timePerStep: positiveMilliseconds.prefault("0.01ms"), }) .superRefine((simulation, context) => { if ( diff --git a/lib/components/cadassembly.ts b/lib/components/cadassembly.ts index aed939a1..208655eb 100644 --- a/lib/components/cadassembly.ts +++ b/lib/components/cadassembly.ts @@ -10,8 +10,8 @@ export interface CadAssemblyProps { * components will be mirrored. * * Generally, you shouldn't set this except where it can help prevent - * confusion because you have a complex multi-layer assembly. Default is - * "top" and this is most intuitive. + * confusion because you have a complex multi-layer assembly. When omitted, + * the assembly leaves its original layer unspecified. */ originalLayer?: LayerRef @@ -19,7 +19,7 @@ export interface CadAssemblyProps { } export const cadassemblyProps = z.object({ - originalLayer: layer_ref.default("top").optional(), + originalLayer: layer_ref.optional(), children: z.any().optional(), }) diff --git a/lib/components/chip.ts b/lib/components/chip.ts index 97b997cb..d57b49ff 100644 --- a/lib/components/chip.ts +++ b/lib/components/chip.ts @@ -170,7 +170,9 @@ expectTypesMatch>(true) export const pinCompatibleVariant = z.object({ manufacturerPartNumber: z.string().optional(), - supplierPartNumber: z.record(supplier_name, z.array(z.string())).optional(), + supplierPartNumber: z + .partialRecord(supplier_name, z.array(z.string())) + .optional(), }) export const chipProps = commonComponentProps.extend({ diff --git a/lib/components/diode.ts b/lib/components/diode.ts index d3018966..72e5e3eb 100644 --- a/lib/components/diode.ts +++ b/lib/components/diode.ts @@ -31,9 +31,9 @@ const connectionTarget = z .or(z.array(z.string()).readonly()) .or(z.array(z.string())) -const connectionsProp = z.record(diodeConnectionKeys, connectionTarget) +const connectionsProp = z.partialRecord(diodeConnectionKeys, connectionTarget) -const diodePinLabelsProp = z.record( +const diodePinLabelsProp = z.partialRecord( z.enum(diodePins), schematicPinLabel .or(z.array(schematicPinLabel).readonly()) @@ -79,7 +79,7 @@ export const diodeProps = commonComponentProps message: "Exactly one diode variant must be enabled", path: [], }) - return z.INVALID + return } }) .transform((data) => { diff --git a/lib/components/footprint.ts b/lib/components/footprint.ts index d9cc2d0c..2606e28c 100644 --- a/lib/components/footprint.ts +++ b/lib/components/footprint.ts @@ -32,8 +32,8 @@ export interface FootprintProps { * components will be mirrored. * * Generally, you shouldn't set this except where it can help prevent - * confusion because you have a complex multi-layer footprint. Default is - * "top" and this is most intuitive. + * confusion because you have a complex multi-layer footprint. When omitted, + * the footprint leaves its original layer unspecified. */ originalLayer?: LayerRef /** @@ -54,7 +54,7 @@ export interface FootprintProps { export const footprintProps = z.object({ children: z.any().optional(), name: z.string().optional(), - originalLayer: layer_ref.default("top").optional(), + originalLayer: layer_ref.optional(), circuitJson: z.array(z.any()).optional(), src: footprintProp.describe("Can be a footprint or kicad string").optional(), insertionDirection: footprintInsertionDirection diff --git a/lib/components/group.ts b/lib/components/group.ts index e08d4b17..82771707 100644 --- a/lib/components/group.ts +++ b/lib/components/group.ts @@ -453,14 +453,12 @@ export const autorouterPreset = z.union([ ]) const autorouterString = z.string() as z.ZodType< + AutocompleteString, AutocompleteString > -export const autorouterProp: z.ZodType = z.union([ - autorouterConfig, - autorouterPreset, - autorouterString, -]) +export const autorouterProp: z.ZodType = + z.union([autorouterConfig, autorouterPreset, autorouterString]) export const autorouterEffortLevel = z.enum(["1x", "2x", "5x", "10x", "100x"]) @@ -637,7 +635,9 @@ export const baseGroupProps = commonLayoutProps.extend({ pcbAnchorAlignment: pcbAnchorAlignmentAutocomplete.optional(), }) -export const partsEngine = z.custom((v) => "findPart" in v) +export const partsEngine = z.custom( + (value) => typeof value === "object" && value !== null && "findPart" in value, +) export const subcircuitGroupProps = baseGroupProps.extend({ manualEdits: manual_edits_file.optional(), diff --git a/lib/components/platedhole.ts b/lib/components/platedhole.ts index 40a8ebed..c80c357b 100644 --- a/lib/components/platedhole.ts +++ b/lib/components/platedhole.ts @@ -167,12 +167,10 @@ const inferPlatedHoleShapeAndDefaults = (rawProps: unknown): unknown => { return props } -const distanceHiddenUndefined = z - .custom>() - .transform((a) => { - if (a === undefined) return undefined - return distance.parse(a) - }) +const distanceHiddenUndefined = distance.optional() as unknown as z.ZodType< + Distance, + z.input +> const platedHolePropsByShape = z .discriminatedUnion("shape", [ diff --git a/lib/components/resistor.ts b/lib/components/resistor.ts index a0de3b8b..8a5f74a9 100644 --- a/lib/components/resistor.ts +++ b/lib/components/resistor.ts @@ -58,7 +58,12 @@ const mapResistorFootprint = ( return `res${footprint}` } -const resistorFootprintProp: z.ZodType = footprintProp +type ResistorFootprintPropSchema = z.ZodPipe< + z.ZodOptional, + z.ZodTransform +> + +const resistorFootprintProp: ResistorFootprintPropSchema = footprintProp .optional() .transform(mapResistorFootprint) diff --git a/lib/components/silkscreen-rect.ts b/lib/components/silkscreen-rect.ts index 2148dae2..2d973b25 100644 --- a/lib/components/silkscreen-rect.ts +++ b/lib/components/silkscreen-rect.ts @@ -5,7 +5,7 @@ import { z } from "zod" export const silkscreenRectProps = pcbLayoutProps .omit({ pcbRotation: true }) .extend({ - filled: z.boolean().default(true).optional(), + filled: z.boolean().optional(), stroke: z.enum(["dashed", "solid", "none"]).optional(), strokeWidth: distance.optional(), width: distance, diff --git a/lib/components/symbol.ts b/lib/components/symbol.ts index 689b1b90..837931d5 100644 --- a/lib/components/symbol.ts +++ b/lib/components/symbol.ts @@ -7,7 +7,8 @@ export interface SymbolProps { * The facing direction that the symbol is designed for. If you set this to "right", * then it means the children were intended to represent the symbol facing right. * Generally, you shouldn't set this except where it can help prevent confusion - * because you have a complex symbol. Default is "right" and this is most intuitive. + * because you have a complex symbol. When omitted, the symbol leaves its + * original facing direction unspecified. */ originalFacingDirection?: "up" | "down" | "left" | "right" width?: string | number @@ -16,10 +17,7 @@ export interface SymbolProps { } export const symbolProps = z.object({ - originalFacingDirection: z - .enum(["up", "down", "left", "right"]) - .default("right") - .optional(), + originalFacingDirection: z.enum(["up", "down", "left", "right"]).optional(), width: distance.optional(), height: distance.optional(), name: z.string().optional(), diff --git a/lib/enclosure/fdm/box.ts b/lib/enclosure/fdm/box.ts index 743ea530..7afd4a40 100644 --- a/lib/enclosure/fdm/box.ts +++ b/lib/enclosure/fdm/box.ts @@ -16,7 +16,7 @@ export const enclosureFdmBoxProps = z.object({ width: distance.optional(), height: distance.optional(), depth: distance.optional(), - wallThickness: distance.default("2mm"), + wallThickness: distance.prefault("2mm"), }) export type EnclosureFdmBoxPropsInput = z.input diff --git a/lib/platformConfig.ts b/lib/platformConfig.ts index 12d1a1c7..925cd9ca 100644 --- a/lib/platformConfig.ts +++ b/lib/platformConfig.ts @@ -129,29 +129,78 @@ const footprintLibraryResult = z.object({ footprintCircuitJson: z.array(z.any()), cadModel: cadModelProp.optional(), }) -const pathToCircuitJsonFn = z - .function() - .args(z.string()) - .returns(z.promise(footprintLibraryResult)) - .or( - z - .function() - .args( - z.string(), - z.object({ resolvedPcbStyle: pcbStyle.optional() }).optional(), - ) - .returns(z.promise(footprintLibraryResult)), - ) - .describe("A function that takes a path and returns Circuit JSON") + +// Zod 4's z.function is a factory rather than a schema: +// https://zod.dev/v4/changelog#zfunction +const createValidatedAsyncFunctionSchema = < + T extends (...args: any[]) => Promise, +>( + argsSchema: z.ZodTuple, + outputSchema: z.ZodType, +) => + z + .custom((value): value is T => typeof value === "function") + .transform((fn) => { + const validatedFunction = async (...args: unknown[]) => { + const parsedArgs = argsSchema.parse(args) + const result = await (fn as (...args: unknown[]) => unknown)( + ...parsedArgs, + ) + + return outputSchema.parse(result) + } + + return validatedFunction as T + }) + +const createValidatedFunctionSchema = unknown>( + argsSchema: z.ZodTuple, + outputSchema: z.ZodType, +) => + z + .custom((value): value is T => typeof value === "function") + .transform((fn) => { + const validatedFunction = (...args: unknown[]) => { + const parsedArgs = argsSchema.parse(args) + const result = (fn as (...args: unknown[]) => unknown)(...parsedArgs) + + if ( + result !== null && + typeof result === "object" && + "then" in result && + typeof result.then === "function" + ) { + return Promise.resolve(result).then((value) => + outputSchema.parse(value), + ) + } + + return outputSchema.parse(result) + } + + return validatedFunction as T + }) + +type PathToCircuitJsonFn = ( + path: string, + options?: { resolvedPcbStyle?: PcbStyle }, +) => Promise + +const pathToCircuitJsonFn = + createValidatedAsyncFunctionSchema( + z.tuple([ + z.string(), + z.object({ resolvedPcbStyle: pcbStyle.optional() }).optional(), + ]), + footprintLibraryResult, + ).describe("A function that takes a path and returns Circuit JSON") const footprintFileParserEntry = z.object({ - loadFromUrl: z - .function() - .args(z.string()) - .returns(z.promise(footprintLibraryResult)) - .describe( - "A function that takes a footprint file URL and returns Circuit JSON", - ), + loadFromUrl: createValidatedAsyncFunctionSchema< + FootprintFileParserEntry["loadFromUrl"] + >(z.tuple([z.string()]), footprintLibraryResult).describe( + "A function that takes a footprint file URL and returns Circuit JSON", + ), }) const spiceEngineSimulationResult = z.object({ @@ -160,13 +209,12 @@ const spiceEngineSimulationResult = z.object({ }) const spiceEngineZod = z.object({ - simulate: z - .function() - .args(z.string()) - .returns(z.promise(spiceEngineSimulationResult)) - .describe( - "A function that takes a SPICE string and returns a simulation result", - ), + simulate: createValidatedAsyncFunctionSchema( + z.tuple([z.string()]), + spiceEngineSimulationResult, + ).describe( + "A function that takes a SPICE string and returns a simulation result", + ), }) const defaultSpiceEngine = z.custom>( @@ -174,24 +222,21 @@ const defaultSpiceEngine = z.custom>( ) const autorouterInstance = z.object({ - run: z - .function() - .args() - .returns(z.promise(z.unknown())) - .describe("Run the autorouter"), - getOutputSimpleRouteJson: z - .function() - .args() - .returns(z.promise(z.any())) - .describe("Get the resulting SimpleRouteJson"), + run: createValidatedAsyncFunctionSchema( + z.tuple([]), + z.unknown(), + ).describe("Run the autorouter"), + getOutputSimpleRouteJson: createValidatedAsyncFunctionSchema< + AutorouterInstance["getOutputSimpleRouteJson"] + >(z.tuple([]), z.unknown()).describe("Get the resulting SimpleRouteJson"), }) const autorouterDefinition = z.object({ - createAutorouter: z - .function() - .args(z.any(), z.any().optional()) - .returns(z.union([autorouterInstance, z.promise(autorouterInstance)])) - .describe("Create an autorouter instance"), + createAutorouter: createValidatedFunctionSchema< + AutorouterDefinition["createAutorouter"] + >(z.tuple([z.any(), z.any().optional()]), autorouterInstance).describe( + "Create an autorouter instance", + ), }) const platformFetch = z @@ -249,15 +294,14 @@ export const platformConfig = z.object({ footprintFileParserMap: z .record(z.string(), footprintFileParserEntry) .optional(), - resolveProjectStaticFileImportUrl: z - .function() - .args(z.string()) - .returns(z.promise(z.string())) + resolveProjectStaticFileImportUrl: createValidatedAsyncFunctionSchema< + NonNullable + >(z.tuple([z.string()]), z.string()) .describe( "A function that returns a string URL for static files for the project", ) .optional(), platformFetch: platformFetch.optional(), -}) as z.ZodType +}) as z.ZodType expectTypesMatch>(true) diff --git a/lib/projectConfig.ts b/lib/projectConfig.ts index b1e17251..68184806 100644 --- a/lib/projectConfig.ts +++ b/lib/projectConfig.ts @@ -30,6 +30,6 @@ export const projectConfig = platformConfigObject.pick({ defaultSpiceEngine: true, pcbDisabled: true, schematicDisabled: true, -}) as z.ZodType +}) as z.ZodType expectTypesMatch>(true) diff --git a/package.json b/package.json index 8b442038..93db26bb 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@types/node": "^20.12.11", "@types/react": "^18.3.2", "ava": "^6.1.3", - "circuit-json": "^0.0.450", + "circuit-json": "^0.0.454", "expect-type": "^1.3.0", "glob": "^11.0.0", "madge": "^8.0.0", @@ -42,11 +42,11 @@ "tsup": "^8.0.2", "tsx": "^4.10.2", "typescript": "^5.4.5", - "zod": "3" + "zod": "^4.0.0" }, "peerDependencies": { - "circuit-json": "*", + "circuit-json": "^0.0.454", "react": "*", - "zod": "*" + "zod": "^4.0.0" } } diff --git a/tests/autorouter.test.ts b/tests/autorouter.test.ts index a8792994..188956e8 100644 --- a/tests/autorouter.test.ts +++ b/tests/autorouter.test.ts @@ -5,6 +5,7 @@ import { subcircuitGroupPropsWithBool, type RoutingTolerances, } from "../lib/components/group" +import { platformConfig } from "../lib/platformConfig" test("supports freerouting preset", () => { const result = autorouterProp.parse("freerouting") @@ -90,3 +91,25 @@ test("supports autorouter version v6", () => { }) expect(result.autorouterVersion).toBe("v6") }) + +test("validates async platform autorouter factories", async () => { + const config = platformConfig.parse({ + autorouterMap: { + local: { + createAutorouter: async () => ({ + run: async () => undefined, + getOutputSimpleRouteJson: async () => ({ routes: [] }), + }), + }, + }, + }) + const createAutorouter = config.autorouterMap?.local?.createAutorouter + + if (!createAutorouter) { + throw new Error("expected local autorouter factory") + } + + const autorouter = await createAutorouter({}) + await autorouter.run() + expect(await autorouter.getOutputSimpleRouteJson()).toEqual({ routes: [] }) +}) diff --git a/tests/cad-assembly.test.ts b/tests/cad-assembly.test.ts new file mode 100644 index 00000000..7705a7cf --- /dev/null +++ b/tests/cad-assembly.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from "bun:test" +import { cadassemblyProps } from "lib/components/cadassembly" + +test("leaves originalLayer undefined when omitted", () => { + expect(cadassemblyProps.parse({}).originalLayer).toBeUndefined() +}) diff --git a/tests/footprint.test.ts b/tests/footprint.test.ts index 658b8048..41e45bf6 100644 --- a/tests/footprint.test.ts +++ b/tests/footprint.test.ts @@ -7,6 +7,10 @@ test("should parse footprint name", () => { expect(parsed.name).toBe("U1_FOOTPRINT") }) +test("leaves originalLayer undefined when omitted", () => { + expect(footprintProps.parse({}).originalLayer).toBeUndefined() +}) + test("should parse footprint insertionDirection options", () => { const insertionDirections = [ "from_above", diff --git a/tests/resolveProjectStaticFileImportUrl.test.ts b/tests/resolveProjectStaticFileImportUrl.test.ts index a202e401..c311d71b 100644 --- a/tests/resolveProjectStaticFileImportUrl.test.ts +++ b/tests/resolveProjectStaticFileImportUrl.test.ts @@ -12,3 +12,38 @@ test("resolveProjectStaticFileImportUrl returns the provided string", async () = const url = resolver ? await resolver("images/logo.png") : undefined expect(url).toBe("https://cdn.example.com/images/logo.png") }) + +test("resolveProjectStaticFileImportUrl validates the resolved URL", async () => { + const config = platformConfig.parse({ + resolveProjectStaticFileImportUrl: async () => 123, + }) + const resolver = config.resolveProjectStaticFileImportUrl + + if (!resolver) { + throw new Error("expected resolveProjectStaticFileImportUrl") + } + + await expect(resolver("images/logo.png")).rejects.toThrow() +}) + +test("resolveProjectStaticFileImportUrl validates invocation arguments", async () => { + const config = platformConfig.parse({ + resolveProjectStaticFileImportUrl: async (path: string) => + `https://cdn.example.com/${path}`, + }) + const resolver = config.resolveProjectStaticFileImportUrl + + if (!resolver) { + throw new Error("expected resolveProjectStaticFileImportUrl") + } + + await expect((resolver as any)(123)).rejects.toThrow() +}) + +test("resolveProjectStaticFileImportUrl rejects non-functions", () => { + expect(() => + platformConfig.parse({ + resolveProjectStaticFileImportUrl: "https://cdn.example.com", + }), + ).toThrow() +}) diff --git a/tests/silkscreen-rect.test.ts b/tests/silkscreen-rect.test.ts new file mode 100644 index 00000000..98db5879 --- /dev/null +++ b/tests/silkscreen-rect.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from "bun:test" +import { silkscreenRectProps } from "lib/components/silkscreen-rect" + +test("leaves filled undefined when omitted", () => { + const rect = silkscreenRectProps.parse({ width: 1, height: 1 }) + + expect(rect.filled).toBeUndefined() +}) From b13decc052cb193ae45df5fa34426db9d22c384e Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 01:32:52 +0900 Subject: [PATCH 2/2] chore: require circuit-json zod v4 release --- README.md | 2 +- package.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5c3e1f2a..8fcca3ff 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This repo is the source-of-truth for defining the React props, API changes begin the user (unlike [circuit-json](https://github.com/tscircuit/circuit-json) which focuses on ergonomics for a renderer) > **Zod v4 migration:** This package now requires `zod@^4` and -> `circuit-json@^0.0.454` as peer dependencies. Schemas composed with these +> `circuit-json@^0.0.455` as peer dependencies. Schemas composed with these > props must use the same Zod major; consumers that inspect raw Zod errors > should update to the v4 error APIs. diff --git a/package.json b/package.json index 93db26bb..2a71ded1 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@types/node": "^20.12.11", "@types/react": "^18.3.2", "ava": "^6.1.3", - "circuit-json": "^0.0.454", + "circuit-json": "^0.0.455", "expect-type": "^1.3.0", "glob": "^11.0.0", "madge": "^8.0.0", @@ -45,7 +45,7 @@ "zod": "^4.0.0" }, "peerDependencies": { - "circuit-json": "^0.0.454", + "circuit-json": "^0.0.455", "react": "*", "zod": "^4.0.0" }