/** * `@nifrajs/web/forms` - a typed form ↔ backend-schema binding. The three artifacts that must agree - * a form's ``, the action's `formData.get`, and the typed-client payload - all key off the * ONE route body schema, derived purely from `typeof backend`. A typo'd field name, or a field the * schema doesn't have, becomes a COMPILE error instead of a silent runtime empty. * * import type { backend } from "../backend" * import { formFor } from "@nifrajs/web/forms" * * const f = formFor() // method defaults to "post" * // In the component (any framework - spread the props onto an input): * // "text" ∈ the body's keys, else a compile error * // In the action: * const text = f.read(await request.formData(), "text") // an orphan key won't compile * await api.todos.post({ text }) // payload already typed by nifra * * Framework-agnostic + dependency-free: no codegen, no schema is bundled into the client, and the * runtime is a trivial pass-through - all the work is in the types. It checks the field KEY, never the * MEANING (a `field("email")` collecting a phone is still valid here); that stays an app/AI concern. */ import type { RouteInfo, Server } from "@nifrajs/core/server" // Derived from `FormData` itself (not the `FormDataEntryValue` global) so this file stays DOM-lib-free: // `@nifrajs/web`'s server surface is typechecked by the root config, which has no DOM lib. type FormEntry = ReturnType type FormEntries = ReturnType /** The route registry baked into a server's type (`typeof app`). */ type RegistryOf = App extends Server ? R : never /** Every route path the app declares - constrains `Path`, so a wrong path is itself a type error. */ export type RoutePaths = keyof RegistryOf & string /** The body object type of `App`'s `Method Path` route (`never` when the route declares no body). */ export type RouteBody< App, Path extends string, Method extends string, > = Path extends keyof RegistryOf ? Uppercase extends keyof RegistryOf[Path] ? (RegistryOf[Path][Uppercase] & RouteInfo)["body"] : never : never /** The valid field names for that route's body - the schema's keys as a string union. */ export type FieldKey = [ RouteBody, ] extends [never] ? never : keyof RouteBody & string /** Extra attributes merged into the returned input props (id, type, defaultValue, placeholder, …). */ export type FieldProps = Record export interface FormHandle { /** Input props for a schema field. `name` is constrained to the body's keys - a typo, or a field the * schema doesn't have, is a COMPILE error. Spread onto an element: ``. */ field>( name: K, props?: FieldProps, ): FieldProps & { name: K } /** `formData.get`, key-constrained to the body's fields - reading an orphan key won't compile. */ read>(form: FormData, name: K): FormEntry /** `formData.getAll`, key-checked the same way. */ readAll>(form: FormData, name: K): FormEntries } /** * Bind a form to a backend route's body schema at the type level. `App` is `typeof backend`; `Path` is * constrained to the app's real routes (a wrong path is a type error); `Method` defaults to `"post"`. */ export function formFor< App, Path extends RoutePaths, Method extends string = "post", >(): FormHandle { const handle = { field: (name: string, props?: FieldProps): FieldProps & { name: string } => ({ ...(props ?? {}), name, }), read: (form: FormData, name: string): FormEntry => form.get(name), readAll: (form: FormData, name: string): FormEntries => form.getAll(name), } return handle as unknown as FormHandle }