diff --git a/package.json b/package.json index a12930b..cc1d960 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,8 @@ "oxlint": "^1.48.0", "perfect-debounce": "^2.1.0", "typescript": "^5.9.3", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "zod": "^4.3.6" }, "peerDependencies": { "chokidar": "^5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 313ac1e..a883524 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: vitest: specifier: ^4.0.18 version: 4.0.18(@types/node@25.2.3)(jiti@2.6.1) + zod: + specifier: ^4.3.6 + version: 4.3.6 test/bench: dependencies: @@ -2217,6 +2220,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -4321,4 +4327,6 @@ snapshots: yocto-queue@0.1.0: {} + zod@4.3.6: {} + zwitch@2.0.4: {} diff --git a/src/loader.ts b/src/loader.ts index 1ed3011..42f8bc5 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -20,6 +20,7 @@ import type { InputConfig, ConfigSource, ConfigFunctionContext, + StandardSchemaV1, } from "./types.ts"; const _normalize = (p?: string) => p?.replace(/\\/g, "/"); @@ -49,10 +50,23 @@ export const SUPPORTED_EXTENSIONS = Object.freeze([ ".toml", ]) as unknown as string[]; +export async function loadConfig< + MT extends ConfigLayerMeta = ConfigLayerMeta, + S extends StandardSchemaV1 = StandardSchemaV1, +>( + options: LoadConfigOptions & { schema: S }, +): Promise & UserInputConfig, MT>>; + export async function loadConfig< T extends UserInputConfig = UserInputConfig, MT extends ConfigLayerMeta = ConfigLayerMeta, ->(options: LoadConfigOptions): Promise> { +>(options: LoadConfigOptions): Promise>; + +export async function loadConfig< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta, + S extends StandardSchemaV1 = StandardSchemaV1, +>(options: LoadConfigOptions): Promise> { // Normalize options options.cwd = resolve(process.cwd(), options.cwd || "."); options.name = options.name || "config"; @@ -208,6 +222,19 @@ export async function loadConfig< throw new Error(`Required config (${r.configFile}) cannot be resolved.`); } + // Validate config + if (options.schema) { + let result = options.schema["~standard"].validate(r); + if (result instanceof Promise) result = await result; + if (result.issues) { + const messages = result.issues.map((issue) => { + const path = issue.path?.map((p) => (typeof p === "object" ? p.key : p)).join("."); + return path ? ` - ${path}: ${issue.message}` : ` - ${issue.message}`; + }); + throw new Error(`Config validation failed:\n${messages.join("\n")}`); + } + } + // Return resolved config return r; } @@ -215,7 +242,8 @@ export async function loadConfig< async function extendConfig< T extends UserInputConfig = UserInputConfig, MT extends ConfigLayerMeta = ConfigLayerMeta, ->(config: InputConfig, options: LoadConfigOptions) { + S extends StandardSchemaV1 = StandardSchemaV1, +>(config: InputConfig, options: LoadConfigOptions) { (config as any)._layers = config._layers || []; if (!options.extend) { return; @@ -275,9 +303,10 @@ const NPM_PACKAGE_RE = /^(@[\da-z~-][\d._a-z~-]*\/)?[\da-z~-][\d._a-z~-]*($|\/.* async function resolveConfig< T extends UserInputConfig = UserInputConfig, MT extends ConfigLayerMeta = ConfigLayerMeta, + S extends StandardSchemaV1 = StandardSchemaV1, >( source: string, - options: LoadConfigOptions, + options: LoadConfigOptions, sourceOptions: SourceOptions = {}, ): Promise> { // Custom user resolver @@ -437,7 +466,7 @@ async function resolveConfig< // --- internal --- -function tryResolve(id: string, options: LoadConfigOptions) { +function tryResolve(id: string, options: LoadConfigOptions) { const res = resolveModulePath(id, { try: true, from: pathToFileURL(join(options.cwd || ".", options.configFile || "/")), diff --git a/src/types.ts b/src/types.ts index 4045a88..9efd8a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -99,6 +99,7 @@ export type ResolvableConfig = export interface LoadConfigOptions< T extends UserInputConfig = UserInputConfig, MT extends ConfigLayerMeta = ConfigLayerMeta, + S extends StandardSchemaV1 = StandardSchemaV1, > { name?: string; cwd?: string; @@ -126,7 +127,7 @@ export interface LoadConfigOptions< resolve?: ( id: string, - options: LoadConfigOptions, + options: LoadConfigOptions, ) => null | undefined | ResolvedConfig | Promise | undefined | null>; /** Custom import function used to load configuration files */ @@ -146,6 +147,8 @@ export interface LoadConfigOptions< }; configFileRequired?: boolean; + + schema?: S; } export type DefineConfig< @@ -159,3 +162,82 @@ export function createDefineConfig< >(): DefineConfig { return (input: InputConfig) => input; } + +// ----- Standard Schema types ----- + +// https://github.com/standard-schema/standard-schema/blob/main/packages/spec/src/index.ts +/** The Standard Schema interface. */ +export interface StandardSchemaV1 { + /** The Standard Schema properties. */ + readonly "~standard": StandardSchemaV1.Props; +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export declare namespace StandardSchemaV1 { + /** The Standard Schema properties interface. */ + export interface Props { + /** The version number of the standard. */ + readonly version: 1; + /** The vendor name of the schema library. */ + readonly vendor: string; + /** Validates unknown input values. */ + readonly validate: (value: unknown) => Result | Promise>; + /** Inferred types associated with the schema. */ + readonly types?: Types | undefined; + } + + /** The result interface of the validate function. */ + export type Result = SuccessResult | FailureResult; + + /** The result interface if validation succeeds. */ + export interface SuccessResult { + /** The typed output value. */ + readonly value: Output; + /** The non-existent issues. */ + readonly issues?: undefined; + } + + /** The result interface if validation fails. */ + export interface FailureResult { + /** The issues of failed validation. */ + readonly issues: ReadonlyArray; + } + + /** The issue interface of the failure output. */ + export interface Issue { + /** The error message of the issue. */ + readonly message: string; + /** The path of the issue, if any. */ + readonly path?: ReadonlyArray | undefined; + } + + /** The path segment interface of the issue. */ + export interface PathSegment { + /** The key representing a path segment. */ + readonly key: PropertyKey; + } + + /** The Standard Schema types interface. */ + export interface Types { + /** The input type of the schema. */ + readonly input: Input; + /** The output type of the schema. */ + readonly output: Output; + } + + /** Infers the input type of a Standard Schema. */ + export type InferInput = NonNullable< + Schema["~standard"]["types"] + >["input"]; + + /** Infers the output type of a Standard Schema. */ + export type InferOutput = NonNullable< + Schema["~standard"]["types"] + >["output"]; + + /** Extracts the `config` property type from a schema's output type. */ + export type InferConfigOutput = + InferOutput extends { config: infer C } ? C : never; + + // biome-ignore lint/complexity/noUselessEmptyExport: needed for granular visibility control of TS namespace +} diff --git a/test/loader.test.ts b/test/loader.test.ts index 3dc5288..e7ec964 100644 --- a/test/loader.test.ts +++ b/test/loader.test.ts @@ -4,10 +4,32 @@ import { normalize } from "pathe"; import type { ConfigLayer, ConfigLayerMeta, UserInputConfig } from "../src/index.ts"; import { loadConfig } from "../src/index.ts"; +import { z } from "zod"; + const r = (path: string) => normalize(fileURLToPath(new URL(path, import.meta.url))); const transformPaths = (object: object) => JSON.parse(JSON.stringify(object).replaceAll(r("."), "/")); +const ConfigSchema = z.object({ + defaultConfig: z.boolean().optional(), + virtual: z.boolean().optional(), + githubLayer: z.boolean().optional(), + npmConfig: z.boolean().optional(), + devConfig: z.boolean().optional(), + baseConfig: z.boolean().optional(), + array: z.array(z.string()).optional(), + baseEnvConfig: z.boolean().optional(), + packageJSON2: z.boolean().optional(), + packageJSON: z.boolean().optional(), + testConfig: z.boolean().optional(), + rcFile: z.boolean().optional(), + configFile: z.union([z.string(), z.boolean(), z.undefined()]).optional(), + overridden: z.boolean().optional(), + enableDefault: z.boolean().optional(), + envConfig: z.boolean().optional(), + theme: z.string().optional(), +}); + describe("loader", () => { it("load fixture config", async () => { type UserConfig = Partial<{ @@ -17,7 +39,10 @@ describe("loader", () => { defaultConfig: boolean; extends: string[]; }>; - const { config, layers } = await loadConfig({ + const { config, layers } = await loadConfig({ + schema: z.object({ + config: ConfigSchema, + }), cwd: r("./fixture"), name: "test", dotenv: { @@ -371,6 +396,36 @@ describe("loader", () => { }); }); + it("schema validation formats errors", async () => { + await expect( + loadConfig({ + schema: z.object({ + config: z.object({ + requiredField: z.string(), + }), + }), + cwd: r("./fixture"), + name: "test", + }), + ).rejects.toThrowError("Config validation failed:"); + }); + + it("schema infers config type", async () => { + const { config } = await loadConfig({ + schema: z.object({ + config: z.object({ + configFile: z.union([z.string(), z.boolean()]).optional(), + }), + }), + cwd: r("./fixture"), + name: "test", + }); + + // Type check: config.configFile should be typed as string | boolean | undefined + const _configFile: string | boolean | undefined = config.configFile; + expect(_configFile).toBeDefined(); + }); + it("try reproduce error with index.js on root importing jsx/tsx", async () => { await loadConfig({ name: "test",