alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
211 lines (190 loc) • 6.23 kB
text/typescript
import { type TSchema, TypeBoxError, z } from "alepha";
import type { BaseInputField } from "./FormModel.ts";
import { prettyName } from "./prettyName.ts";
/**
* Semantic icon hint derived from schema metadata. UI layers map this
* to their own icon set — this module is headless and ships no JSX.
*/
export type IconHint =
| "email"
| "password"
| "phone"
| "url"
| "number"
| "calendar"
| "clock"
| "list"
| "text"
| "user"
| "file"
| "switch";
export interface FieldConstraints {
minLength?: number;
maxLength?: number;
minimum?: number;
maximum?: number;
pattern?: string;
}
export interface FieldMeta {
id?: string;
label: string;
description?: string;
error?: string;
required: boolean;
type?: string;
format?: string;
isEnum: boolean;
isArray: boolean;
isObject: boolean;
isArrayOfObjects: boolean;
enum?: readonly unknown[];
iconHint?: IconHint;
constraints: FieldConstraints;
testId?: string;
schema: TSchema;
/**
* Raw `$control` value from the schema, untyped here. The UI layer
* (`alepha/react/ui`) provides the strict {@link SchemaControl} type and
* a `resolveSchemaControl` helper to evaluate the function form.
*/
control?: unknown;
}
export interface ParseFieldOptions {
label?: string;
description?: string;
error?: Error;
}
/**
* Derives a {@link FieldMeta} from an `InputField` (from `useForm`) plus
* optional overrides. Pure — no React, no JSX, no UI library coupling.
*
* UI components consume this metadata to render labels, descriptions,
* error messages, icons, and validation constraints.
*/
export const parseField = (
input: BaseInputField,
options: ParseFieldOptions = {},
): FieldMeta => {
// Peel optional/nullable/default wrappers: structure, `.meta()`, format and
// constraints all live on the inner schema, not the wrapper. `required` is
// tracked separately via `input.required`.
const schema = z.schema.unwrap(input.schema) as TSchema & {
minLength?: number;
maxLength?: number;
minValue?: number;
maxValue?: number;
};
// Title / description / `$control` ride on zod's `.meta()` registry now
// (typebox kept them as plain schema properties). Read them from there.
const meta = (
typeof (schema as any).meta === "function" ? (schema as any).meta() : null
) as Record<string, unknown> | null;
const label =
options.label ??
(typeof meta?.title === "string" ? meta.title : undefined) ??
prettyName(input.path);
const description =
options.description ??
(typeof meta?.description === "string" ? meta.description : undefined);
const error =
options.error instanceof TypeBoxError
? (options.error as TypeBoxError).value?.message
: undefined;
// Structural classification via zod guards (the schema is a real zod schema,
// not a JSON-Schema object — `schema.type`/`schema.enum` no longer apply).
const isEnum = z.schema.isEnum(schema);
const isArray = z.schema.isArray(schema);
const isObject = z.schema.isObject(schema);
const isInteger = z.schema.isInteger(schema);
const isNumber = !isInteger && z.schema.isNumber(schema);
const isBoolean = z.schema.isBoolean(schema);
const type = isObject
? "object"
: isArray
? "array"
: isEnum
? "string"
: isInteger
? "integer"
: isNumber
? "number"
: isBoolean
? "boolean"
: z.schema.isString(schema)
? "string"
: undefined;
const format =
typeof z.schema.format(schema) === "string"
? z.schema.format(schema)
: undefined;
const element = (schema as any).element ?? (schema as any).items;
const isArrayOfObjects = isArray && z.schema.isObject(element);
const name = input.props.name;
const iconHint = inferIconHint({ type, format, name, isEnum, isArray });
const constraints: FieldConstraints = {};
if (typeof schema.minLength === "number")
constraints.minLength = schema.minLength;
if (typeof schema.maxLength === "number")
constraints.maxLength = schema.maxLength;
if (typeof schema.minValue === "number")
constraints.minimum = schema.minValue;
if (typeof schema.maxValue === "number")
constraints.maximum = schema.maxValue;
const pattern = readPattern(schema);
if (pattern) constraints.pattern = pattern;
return {
id: input.props.id,
label,
description,
error,
required: input.required,
type,
format,
isEnum,
isArray,
isObject,
isArrayOfObjects,
enum: isEnum ? z.schema.enumValues(schema) : undefined,
iconHint,
constraints,
testId: (input.props as Record<string, unknown>)["data-testid"] as
| string
| undefined,
schema: input.schema,
control: meta?.$control,
};
};
/** Best-effort read of a `ZodString` regex pattern from its check list. */
const readPattern = (schema: unknown): string | undefined => {
const checks = (schema as any)?._zod?.def?.checks as
| Array<{ _zod?: { def?: { pattern?: RegExp; format?: string } } }>
| undefined;
if (!Array.isArray(checks)) return undefined;
for (const check of checks) {
const pattern = check?._zod?.def?.pattern;
if (pattern instanceof RegExp) return pattern.source;
}
return undefined;
};
const inferIconHint = (params: {
type?: string;
format?: string;
name?: string;
isEnum: boolean;
isArray: boolean;
}): IconHint | undefined => {
const { type, format, name, isEnum, isArray } = params;
if (format === "email") return "email";
if (format === "url" || format === "uri") return "url";
if (format === "tel" || format === "phone") return "phone";
if (format === "date" || format === "date-time") return "calendar";
if (format === "time") return "clock";
if (name?.toLowerCase().includes("password")) return "password";
if (name?.toLowerCase().includes("email")) return "email";
if (name?.toLowerCase().includes("phone")) return "phone";
if (type === "boolean") return "switch";
if (type === "number" || type === "integer") return "number";
if (isEnum || isArray) return "list";
if (type === "string") return "text";
return undefined;
};