diff --git a/.agents/skills/formisch/.claude-plugin/plugin.json b/.agents/skills/formisch/.claude-plugin/plugin.json new file mode 100644 index 0000000..05e30c7 --- /dev/null +++ b/.agents/skills/formisch/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "formisch", + "description": "Form validation with Formisch, the schema-based, headless form library", + "version": "1.0.0", + "author": { + "name": "Open Circle", + "url": "https://github.com/open-circle" + }, + "homepage": "https://formisch.dev", + "repository": "https://github.com/open-circle/agent-skills", + "license": "MIT", + "keywords": ["formisch", "forms", "validation", "typescript"] +} diff --git a/.agents/skills/formisch/SKILL.md b/.agents/skills/formisch/SKILL.md new file mode 100644 index 0000000..a2b27aa --- /dev/null +++ b/.agents/skills/formisch/SKILL.md @@ -0,0 +1,1118 @@ +--- +name: formisch +description: Form handling with Formisch, the schema-first and type-safe form library for Angular, Preact, Qwik, React, React Native, Solid, Svelte, and Vue. Use when creating forms, handling form state, validating inputs, working with field arrays, or using @formisch/* packages. +license: MIT +metadata: + author: open-circle + version: "1.3" +--- + +# Formisch + +This skill helps AI agents work effectively with [Formisch](https://formisch.dev/), the schema-based, headless form library for modern frameworks. + +## When to Use This Skill + +- When the user asks about form handling with Formisch +- When managing form state and validation +- When working with Angular, Preact, Qwik, React, React Native, Solid, Svelte, or Vue forms +- When integrating Valibot schemas with forms + +## Introduction + +Formisch is a schema-based, headless form library that works across multiple frameworks. Key highlights: + +- **Small bundle size** — Starting at ~2.5 kB +- **Schema-based validation** — Uses Valibot for type-safe validation +- **Headless design** — You control the UI completely +- **Type safety** — Full TypeScript support with autocompletion +- **Framework-native** — Native performance for each supported framework + +### Supported Frameworks + +| Framework | Package | Hook/Primitive | +| ------------ | ------------------------ | -------------- | +| Angular | `@formisch/angular` | `injectForm` | +| Preact | `@formisch/preact` | `useForm` | +| Qwik | `@formisch/qwik` | `useForm$` | +| React | `@formisch/react` | `useForm` | +| React Native | `@formisch/react-native` | `useForm` | +| SolidJS | `@formisch/solid` | `createForm` | +| Svelte | `@formisch/svelte` | `createForm` | +| Vue | `@formisch/vue` | `useForm` | + +## Installation + +### 1. Install Valibot (peer dependency) + +```bash +npm install valibot +``` + +### 2. Install Formisch for your framework + +```bash +npm install @formisch/react # React +npm install @formisch/angular # Angular +npm install @formisch/vue # Vue +npm install @formisch/solid # SolidJS +npm install @formisch/preact # Preact +npm install @formisch/svelte # Svelte +npm install @formisch/qwik # Qwik +npm install @formisch/react-native # React Native +``` + +## Core Concepts + +### Schema-First Design + +Every form starts with a Valibot schema. Types are automatically inferred from the schema. + +```ts +import * as v from "valibot"; + +const LoginSchema = v.object({ + email: v.pipe( + v.string("Please enter your email."), + v.nonEmpty("Please enter your email."), + v.email("The email address is badly formatted."), + ), + password: v.pipe( + v.string("Please enter your password."), + v.nonEmpty("Please enter your password."), + v.minLength(8, "Your password must have 8 characters or more."), + ), +}); +``` + +### Form Store + +The form store manages all form state. Access it via the framework-specific hook/primitive. + +**Form Store Properties:** + +- `isSubmitting` — Form is currently being submitted +- `isSubmitted` — Form submission has been attempted +- `isValidating` — Validation is in progress +- `isTouched` — At least one field has been touched +- `isEdited` — At least one field has been edited +- `isDirty` — At least one field differs from initial value +- `isValid` — All fields pass validation +- `errors` — Root-level validation errors + +### Field Store + +Each field has its own reactive store with: + +- `path` — Path array to the field +- `input` — Current field value +- `errors` — Field-specific errors +- `isTouched` — Field has been focused +- `isEdited` — Field value has been edited +- `isDirty` — Field value differs from initial value +- `isValid` — Field passes validation +- `props` — Props to spread onto native elements (Angular connects controls with `[formischControl]` instead) +- `onChange` (React and React Native) / `onInput` (Solid, Svelte, Preact, and Qwik) / `setInput` (Angular) — Sets the field input value programmatically. Use this when the field cannot be connected to a native element. In Vue, set `field.input` directly (for example with `v-model`). + +Store reactivity is framework-specific. React, React Native, Solid, Svelte, and Vue expose plain reactive properties. Angular properties are signals and are called like `field.errors()`, except `path`, which is a plain value. Preact and Qwik properties are signals; use `.value` in conditions and ordinary TypeScript logic. Do not copy one framework's access syntax into another. + +### Dirty Tracking + +Formisch tracks two inputs per field: + +- **Initial input** — Baseline for dirty tracking (server state) +- **Current input** — What the user is editing (client state) + +`isDirty` becomes `true` when current input differs from initial input. + +## Framework Examples + +### Angular Example + +Angular uses signals, dependency injection, and directives instead of a JSX component API. + +```ts +import { Component } from "@angular/core"; +import { + FormischControl, + FormischField, + FormischForm, + injectForm, + type SubmitHandler, +} from "@formisch/angular"; +import * as v from "valibot"; + +const LoginSchema = v.object({ + email: v.pipe(v.string(), v.email()), + password: v.pipe(v.string(), v.minLength(8)), +}); + +@Component({ + selector: "app-login", + imports: [FormischForm, FormischField, FormischControl], + template: ` +
+ + + @if (field.errors(); as errors) { +
{{ errors[0] }}
+ } +
+ +
+ `, +}) +export class LoginComponent { + readonly loginForm = injectForm({ schema: LoginSchema }); + + readonly handleSubmit: SubmitHandler = (output) => { + console.log(output); + }; +} +``` + +Let `[formischControl]` synchronize the native control. Do not add competing `[value]` or `[checked]` bindings except when `value` identifies an option in a radio or checkbox group. + +### React Native Example + +React Native has no DOM `
` element or Formisch `Form` component. Use `handleSubmit` and bind `field.props` to `TextInput`. + +```tsx +import { Field, handleSubmit, useForm } from "@formisch/react-native"; +import { Button, TextInput, View } from "react-native"; +import * as v from "valibot"; + +const LoginSchema = v.object({ + email: v.pipe(v.string(), v.email()), + password: v.pipe(v.string(), v.minLength(8)), +}); + +export default function LoginScreen() { + const loginForm = useForm({ schema: LoginSchema }); + const submitForm = handleSubmit(loginForm, (output) => console.log(output)); + + return ( + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + + ); +} +``` + +### Vue Example + +```vue + + + +``` + +### SolidJS Example + +```tsx +import { Field, Form, createForm } from "@formisch/solid"; +import type { SubmitHandler } from "@formisch/solid"; +import * as v from "valibot"; + +const LoginSchema = v.object({ + email: v.pipe(v.string(), v.email()), + password: v.pipe(v.string(), v.minLength(8)), +}); + +export default function LoginPage() { + const loginForm = createForm({ + schema: LoginSchema, + }); + + const handleSubmit: SubmitHandler = (output) => { + console.log(output); + }; + + return ( +
+ + {(field) => ( +
+ + {field.errors &&
{field.errors[0]}
} +
+ )} +
+ + {(field) => ( +
+ + {field.errors &&
{field.errors[0]}
} +
+ )} +
+ +
+ ); +} +``` + +### Svelte Example + +```svelte + + +
+ + {#snippet children(field)} +
+ + {#if field.errors} +
{field.errors[0]}
+ {/if} +
+ {/snippet} +
+ + {#snippet children(field)} +
+ + {#if field.errors} +
{field.errors[0]}
+ {/if} +
+ {/snippet} +
+ +
+``` + +### Qwik Example + +```tsx +import { Field, Form, useForm$ } from "@formisch/qwik"; +import { component$ } from "@qwik.dev/core"; +import * as v from "valibot"; + +const LoginSchema = v.object({ + email: v.pipe(v.string(), v.email()), + password: v.pipe(v.string(), v.minLength(8)), +}); + +export default component$(() => { + const loginForm = useForm$(() => ({ + schema: LoginSchema, + })); + + return ( +
console.log(output)}> + ( +
+ + {field.errors.value &&
{field.errors.value[0]}
} +
+ )} + /> + ( +
+ + {field.errors.value &&
{field.errors.value[0]}
} +
+ )} + /> + + + ); +}); +``` + +## Form Configuration + +```ts +const form = useForm({ + // Required: Valibot schema + schema: MySchema, + + // Optional: Initial values (partial allowed) + initialInput: { + email: "user@example.com", + }, + + // Optional: Empty values for required fields without initial input + // Required strings default to ''; number, boolean, and date to undefined + emptyInput: { + number: 0, + }, + + // Optional: When first validation occurs + // Options: 'initial' | 'touch' | 'input' | 'change' | 'blur' | 'submit' (default) + validate: "submit", + + // Optional: When a field is validated again once it already has an + // error or the form has been submitted + // Options: 'touch' | 'input' (default) | 'change' | 'blur' | 'submit' + revalidate: "input", +}); +``` + +In Qwik, `useForm$` must receive a function that returns the config, e.g. `useForm$(() => ({ schema: MySchema }))`. This allows Qwik to convert the config into a QRL. + +Optional and nullable fields remain `undefined`. `emptyInput` only supplies fallbacks for required fields whose input is `undefined`. + +## Field Paths + +Paths are type-safe arrays that reference fields in your schema. + +```tsx +// Top-level field + + +// Nested field (schema: { user: { email: string } }) + + +// Array item field (schema: { todos: [{ label: string }] }) + + +// Dynamic array index +{items.map((item, index) => ( + +))} +``` + +## Form Methods + +All methods follow a consistent API pattern: + +- **First parameter**: Form store +- **Second parameter**: Config object + +### Reading Values + +```ts +import { + getDeepError, + getDeepErrorEntries, + getDeepErrorEntry, + getDeepErrors, + getErrors, + getInput, +} from "@formisch/react"; + +// Get field value +const email = getInput(form, { path: ["email"] }); + +// Get entire form input +const allInputs = getInput(form); + +// Get field errors +const emailErrors = getErrors(form, { path: ["email"] }); + +// Get all errors across all fields (including form-level errors) +const allErrors = getDeepErrors(form); + +// Get all errors of a field and its descendants +const todoErrors = getDeepErrors(form, { path: ["todos"] }); + +// Get every error together with its field path +const errorEntries = getDeepErrorEntries(form); + +// Get only the first error of a field and its descendants +const firstTodoError = getDeepError(form, { path: ["todos"] }); + +// Get only the first error together with its field path +const firstErrorEntry = getDeepErrorEntry(form); +``` + +Form-level `form.errors` and `getErrors(form)` contain only root-level errors. Use the deep-error methods when descendant field errors are needed. The singular variants `getDeepError` and `getDeepErrorEntry` stop at the first field with errors, which is useful for showing a single message for a nested structure. + +### Reading Dirty State + +```ts +import { + getDirtyInput, + getDirtyPaths, + isDirty, + pickDirty, +} from "@formisch/react"; + +// Raw dirty form input, or undefined when nothing is dirty +const dirtyInput = getDirtyInput(form); + +// Paths of dirty fields (arrays are treated as atomic values) +const dirtyPaths = getDirtyPaths(form); + +// Cheap boolean check when the dirty values are not needed +const hasChanges = isDirty(form); + +// Boolean check scoped to a field and its descendants +const emailChanged = isDirty(form, { path: ["email"] }); + +// In a submit handler, keep transformed output only where fields are dirty +const dirtyOutput = pickDirty(form, { from: output }); +``` + +`getDirtyInput` returns raw form input. `pickDirty` applies the form's dirty mask to a supplied value, which is useful for validated and transformed submit output. + +The sibling methods `isTouched`, `isEdited`, and `isValid` follow the same pattern as `isDirty`. Each checks the entire form when called without a config, or a specific field and its descendants when called with a `path`. + +### Setting Values + +```ts +import { setInput, setErrors, reset } from "@formisch/react"; + +// Set field value (updates current input, not initial) +setInput(form, { path: ["email"], input: "new@example.com" }); + +// Set field errors manually +setErrors(form, { path: ["email"], errors: ["Email already taken"] }); + +// Clear errors +setErrors(form, { path: ["email"], errors: null }); + +// Reset entire form +reset(form); + +// Reset a single field +reset(form, { path: ["email"] }); + +// Reset with new initial values +reset(form, { + initialInput: { email: "", password: "" }, +}); + +// Reset but keep current input +reset(form, { + initialInput: newServerData, + keepInput: true, +}); +``` + +`reset` also accepts the flags `keepTouched`, `keepEdited`, and `keepErrors`. The form-level reset additionally accepts `keepSubmitted`. All flags default to `false`. + +### Form Control + +```ts +import { validate, focus, submit, handleSubmit } from "@formisch/react"; + +// Validate form manually (returns a Promise of a Valibot SafeParseResult) +const result = await validate(form); +if (result.success) { + console.log(result.output); +} else { + console.log(result.issues); +} + +// Validate and focus first error field +await validate(form, { shouldFocus: true }); + +// Focus a specific field +focus(form, { path: ["email"] }); + +// Programmatically submit form +submit(form); + +// Create submit handler for external buttons +const onExternalSubmit = handleSubmit(form, (output) => { + console.log(output); +}); +``` + +`submit` requires a registered DOM form and is not exported by `@formisch/react-native`. In React Native and in layouts without a `
` element, call the function returned by `handleSubmit` instead. + +## Field Arrays + +For dynamic lists of fields, use `FieldArray` with array manipulation methods. + +The field array store exposes `path`, `items` (stable item IDs for use as keys), `errors`, `isTouched`, `isEdited`, `isDirty`, and `isValid`. + +### Schema + +```ts +const TodoSchema = v.object({ + heading: v.pipe(v.string(), v.nonEmpty()), + todos: v.pipe( + v.array( + v.object({ + label: v.pipe(v.string(), v.nonEmpty()), + deadline: v.pipe(v.string(), v.nonEmpty()), + }), + ), + v.nonEmpty(), + v.maxLength(10), + ), +}); +``` + +### React Example + +```tsx +import { + Field, + FieldArray, + Form, + useForm, + insert, + remove, + move, + swap, +} from "@formisch/react"; + +export default function TodoPage() { + const todoForm = useForm({ + schema: TodoSchema, + initialInput: { + heading: "", + todos: [{ label: "", deadline: "" }], + }, + }); + + return ( + console.log(output)}> + + {(field) => } + + + + {(fieldArray) => ( +
+ {fieldArray.items.map((item, index) => ( +
+ + {(field) => ( + + )} + + + {(field) => ( + + )} + + +
+ ))} + {fieldArray.errors &&
{fieldArray.errors[0]}
} +
+ )} +
+ + + + +
+ ); +} +``` + +### Array Methods + +```ts +import { insert, remove, move, swap, replace } from "@formisch/react"; + +// Add item at end +insert(form, { path: ["todos"], initialInput: { label: "", deadline: "" } }); + +// Add item at specific index +insert(form, { + path: ["todos"], + at: 0, + initialInput: { label: "", deadline: "" }, +}); + +// Remove item at index +remove(form, { path: ["todos"], at: index }); + +// Move item from one index to another +move(form, { path: ["todos"], from: 0, to: 3 }); + +// Swap two items +swap(form, { path: ["todos"], at: 0, and: 1 }); + +// Replace item at index +replace(form, { + path: ["todos"], + at: 0, + initialInput: { label: "New task", deadline: "2024-12-31" }, +}); +``` + +## TypeScript Integration + +### Type Inference + +Types are automatically inferred from your Valibot schema: + +```ts +const LoginSchema = v.object({ + email: v.pipe(v.string(), v.email()), + password: v.pipe(v.string(), v.minLength(8)), +}); + +const form = useForm({ schema: LoginSchema }); +// form is FormStore + +// Submit handler receives typed output +const handleSubmit: SubmitHandler = (output) => { + output.email; // ✓ string + output.password; // ✓ string + output.username; // ✗ TypeScript error +}; +``` + +### Input vs Output Types + +Schemas with transformations have different input and output types: + +```ts +const ProfileSchema = v.object({ + age: v.pipe( + v.string(), // Input: string + v.transform((input) => Number(input)), // Output: number + v.number(), + ), + birthDate: v.pipe( + v.string(), // Input: string + v.transform((input) => new Date(input)), // Output: Date + v.date(), + ), +}); + +// In Field: field.input is string +// In onSubmit: output.age is number, output.birthDate is Date +``` + +### Type-Safe Props + +Pass forms to child components with proper typing: + +```tsx +import { Form, type FormStore, useForm } from "@formisch/react"; + +export default function LoginPage() { + const loginForm = useForm({ schema: LoginSchema }); + return ; +} + +type FormContentProps = { + of: FormStore; +}; + +function FormContent({ of }: FormContentProps) { + return ( +
console.log(output)}> + {/* ... */} +
+ ); +} +``` + +### Generic Field Components + +Create reusable field components with proper typing: + +```tsx +import { useField, type FormStore } from "@formisch/react"; +import * as v from "valibot"; + +type EmailInputProps = { + of: FormStore>; +}; + +function EmailInput({ of }: EmailInputProps) { + const field = useField(of, { path: ["email"] }); + + return ( +
+ + {field.errors &&
{field.errors[0]}
} +
+ ); +} +``` + +The `v.GenericSchema<{ email: string }>` constraint accepts any form whose schema contains an `email` field of type `string`. TypeScript catches mismatches at compile time. + +### Available Types + +```ts +import type { + FormStore, // Form store type + FieldStore, // Field store type + FieldArrayStore, // Field array store type + SubmitHandler, // Submit handler function type + ValidPath, // Valid field path type + ValidArrayPath, // Valid array field path type + Schema, // Base schema type from Valibot +} from "@formisch/react"; +``` + +## Validation Timing + +### validate Option + +Controls when the **first** validation occurs: + +| Value | Description | +| ----------- | ---------------------------------------------- | +| `'initial'` | Validate immediately on form creation | +| `'touch'` | Validate when a field is first focused | +| `'input'` | Validate on every input event | +| `'change'` | Validate on change events (value is committed) | +| `'blur'` | Validate when field loses focus | +| `'submit'` | Validate only on form submission (default) | + +### revalidate Option + +Controls when a field is validated **again**, once it already has an error or the form has been submitted: + +| Value | Description | +| ---------- | ------------------------------------------------ | +| `'touch'` | Revalidate when a field is first focused | +| `'input'` | Revalidate on every input event (default) | +| `'change'` | Revalidate on change events (value is committed) | +| `'blur'` | Revalidate when field loses focus | +| `'submit'` | Revalidate only on form submission | + +## Special Inputs + +### Select (Single) + +```tsx + + {(field) => ( + + )} + +``` + +### Select (Multiple) + +```tsx + + {(field) => ( + + )} + +``` + +### Checkbox + +```tsx + + {(field) => } + +``` + +### File Input + +File inputs cannot be controlled. Handle via UI around them: + +```tsx + + {(field) => ( +
+ + {field.input && {field.input.name}} +
+ )} +
+``` + +## useField Hook + +For complex field components, use the `useField` hook instead of the `Field` component: + +```tsx +import { useField } from "@formisch/react"; +import { useEffect } from "react"; + +function EmailInput({ form }) { + const field = useField(form, { path: ["email"] }); + + // Access field state in component logic + useEffect(() => { + if (field.errors) { + console.log("Email has errors:", field.errors); + } + }, [field.errors]); + + return ( +
+ + {field.errors &&
{field.errors[0]}
} +
+ ); +} +``` + +**When to use which:** + +- **`Field` component** — Multiple fields in the same component +- **`useField` hook** — Single field with component logic access + +The `useFieldArray` hook is the equivalent counterpart of the `FieldArray` component. In Angular, use the `injectField` and `injectFieldArray` functions or the `*formischField` and `*formischFieldArray` directives. + +## Using Component Libraries + +When using component libraries that don't expose their underlying native elements, you cannot spread `field.props` directly. Instead, update the value programmatically with `field.onChange` (React and React Native), `field.onInput` (Solid, Svelte, Preact, and Qwik), `field.setInput` (Angular), or by assigning to `field.input` (Vue): + +```tsx +import { DatePicker } from "some-component-library"; + + + {(field) => ( + field.onChange(newDate)} + /> + )} +; +``` + +These setters update the field value and trigger validation, just like a native input would. + +This is useful for: + +- **Component libraries** that wrap native elements without exposing them +- **Complex custom inputs** like date pickers, rich text editors, or color pickers + +## Async Submission + +```tsx +import { setErrors, useForm } from "@formisch/react"; +import type { SubmitHandler } from "@formisch/react"; + +const loginForm = useForm({ schema: LoginSchema }); + +const handleSubmit: SubmitHandler = async (output) => { + try { + const response = await fetch("/api/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(output), + }); + + if (!response.ok) { + // Set server-side errors + const data = await response.json(); + setErrors(loginForm, { path: ["email"], errors: [data.error] }); + } + } catch (error) { + console.error("Submission failed:", error); + } +}; +``` + +The form's `isSubmitting` state stays `true` until the async handler resolves. If the handler throws, Formisch catches the error and sets its message as a root-level form error on `form.errors`. + +## Common Patterns + +### Loading State + +```tsx + +``` + +### Submit on Enter + +Formisch handles this automatically via the native `
` element. + +### Reset After Success + +```tsx +const handleSubmit: SubmitHandler = async (output) => { + await saveData(output); + + // Full reset to initial state + reset(form); + + // Or reset but keep current input values + reset(form, { keepInput: true }); +}; +``` + +### Server Data Sync + +When server data changes, update the baseline without losing user edits: + +```tsx +// After refetching data from server +reset(form, { + initialInput: newServerData, + keepInput: true, // Keep user's current edits + keepTouched: true, // Keep touched state (optional) +}); +``` + +### Conditional Fields + +```tsx + + + {(field) => ( + + )} + + {getInput(form, { path: ["hasAccount"] }) && ( + + {(field) => } + + )} + +``` + +In React, calling `getInput` during render is reactive because `useForm` and `useField` enable signal tracking in the component that calls them. In the other frameworks, the read is tracked by their own reactive scopes. + +## Additional Resources + +- [Formisch Documentation](https://formisch.dev/) +- [Formisch Coding Agents Guide](https://formisch.dev/react/guides/coding-agents/) +- Formisch MCP server: `https://formisch.dev/mcp` (`search_docs`, `get_doc`, and `list_docs`) +- Append `.md` to any documentation URL for agent-friendly Markdown, or use `https://formisch.dev/llms-{framework}.txt` for a framework-specific index +- [Formisch GitHub](https://github.com/open-circle/formisch) +- [Valibot Documentation](https://valibot.dev/) diff --git a/.agents/skills/valibot/.claude-plugin/plugin.json b/.agents/skills/valibot/.claude-plugin/plugin.json new file mode 100644 index 0000000..7587c83 --- /dev/null +++ b/.agents/skills/valibot/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "valibot", + "description": "Schema validation with Valibot, the modular and type-safe schema library", + "version": "1.0.0", + "author": { + "name": "Open Circle", + "url": "https://github.com/open-circle" + }, + "homepage": "https://valibot.dev", + "repository": "https://github.com/open-circle/agent-skills", + "license": "MIT", + "keywords": ["valibot", "schema", "validation", "typescript"] +} diff --git a/.agents/skills/valibot/SKILL.md b/.agents/skills/valibot/SKILL.md new file mode 100644 index 0000000..c280d85 --- /dev/null +++ b/.agents/skills/valibot/SKILL.md @@ -0,0 +1,765 @@ +--- +name: valibot +description: Schema validation with Valibot, the modular and type-safe schema library. Use when the user needs to validate data, create schemas, parse inputs, or work with Valibot in their project. Also use when migrating from Zod to Valibot. +license: MIT +metadata: + author: open-circle + version: "1.0" +--- + +# Valibot + +This skill helps you work effectively with [Valibot](https://valibot.dev), the modular and type-safe schema library for validating structural data. + +## When to use this skill + +- When the user asks about schema validation with Valibot +- When creating or modifying Valibot schemas +- When parsing or validating user input +- When the user mentions Valibot, schema, or validation +- When migrating from Zod to Valibot + +## CRITICAL: Valibot vs Zod — Do Not Confuse! + +**Valibot and Zod have different APIs. Never mix them up!** + +### Key Differences + +| Feature | Zod ❌ | Valibot ✅ | +| ------------------- | ------------------------------------- | --------------------------------------------------------- | +| Import | `import { z } from 'zod'` | `import * as v from 'valibot'` | +| Validations | Chained methods: `.email().min(5)` | Pipeline: `v.pipe(v.string(), v.email(), v.minLength(5))` | +| Parsing | `schema.parse(data)` | `v.parse(schema, data)` | +| Safe parsing | `schema.safeParse(data)` | `v.safeParse(schema, data)` | +| Optional | `z.string().optional()` | `v.optional(v.string())` | +| Nullable | `z.string().nullable()` | `v.nullable(v.string())` | +| Default | `z.string().default('x')` | `v.optional(v.string(), 'x')` | +| Transform | `z.string().transform(fn)` | `v.pipe(v.string(), v.transform(fn))` | +| Refine/Check | `z.string().refine(fn)` | `v.pipe(v.string(), v.check(fn))` | +| Enum | `z.enum(['a', 'b'])` | `v.picklist(['a', 'b'])` | +| Native enum | `z.nativeEnum(MyEnum)` | `v.enum(MyEnum)` | +| Union | `z.union([a, b])` | `v.union([a, b])` | +| Discriminated union | `z.discriminatedUnion('type', [...])` | `v.variant('type', [...])` | +| Intersection | `z.intersection(a, b)` | `v.intersect([a, b])` | +| Min/max length | `.min(5).max(10)` | `v.minLength(5), v.maxLength(10)` | +| Min/max value | `.gte(5).lte(10)` | `v.minValue(5), v.maxValue(10)` | +| Infer type | `z.infer` | `v.InferOutput` | +| Infer input | `z.input` | `v.InferInput` | + +### Common Mistakes to Avoid + +```typescript +// ❌ WRONG - This is Zod syntax, NOT Valibot! +const Schema = v.string().email().min(5); +const result = Schema.parse(data); + +// ✅ CORRECT - Valibot uses functions and pipelines +const Schema = v.pipe(v.string(), v.email(), v.minLength(5)); +const result = v.parse(Schema, data); +``` + +```typescript +// ❌ WRONG - Zod-style optional +const Schema = v.object({ + name: v.string().optional(), +}); + +// ✅ CORRECT - Valibot wraps with optional() +const Schema = v.object({ + name: v.optional(v.string()), +}); +``` + +```typescript +// ❌ WRONG - Zod-style default +const Schema = v.string().default("hello"); + +// ✅ CORRECT - Valibot uses second argument +const Schema = v.optional(v.string(), "hello"); +``` + +## Installation + +```bash +npm install valibot # npm +yarn add valibot # yarn +pnpm add valibot # pnpm +bun add valibot # bun +``` + +Import with a wildcard (recommended): + +```typescript +import * as v from "valibot"; +``` + +Or with individual imports: + +```typescript +import { object, string, pipe, email, parse } from "valibot"; +``` + +## Mental Model + +Valibot's API is divided into three main concepts: + +### 1. Schemas + +Schemas define the expected data type. They are the starting point. + +```typescript +import * as v from "valibot"; + +// Primitive schemas +const StringSchema = v.string(); +const NumberSchema = v.number(); +const BooleanSchema = v.boolean(); +const DateSchema = v.date(); + +// Complex schemas +const ArraySchema = v.array(v.string()); +const ObjectSchema = v.object({ + name: v.string(), + age: v.number(), +}); +``` + +### 2. Methods + +Methods help you use or modify schemas. The schema is always the first argument. + +```typescript +// Parsing +const result = v.parse(StringSchema, "hello"); +const safeResult = v.safeParse(StringSchema, "hello"); + +// Type guard +if (v.is(StringSchema, data)) { + // data is typed as string +} +``` + +### 3. Actions + +Actions validate or transform data within a `pipe()`. They MUST be used inside pipelines. + +```typescript +// Actions are used in pipe() +const EmailSchema = v.pipe( + v.string(), + v.trim(), + v.email(), + v.endsWith("@example.com"), +); +``` + +## Pipelines + +Pipelines extend schemas with validation and transformation actions. A pipeline always starts with a schema, followed by actions. + +```typescript +import * as v from "valibot"; + +const UsernameSchema = v.pipe( + v.string(), + v.trim(), + v.minLength(3, "Username must be at least 3 characters"), + v.maxLength(20, "Username must be at most 20 characters"), + v.regex( + /^[a-z0-9_]+$/i, + "Username can only contain letters, numbers, and underscores", + ), +); + +const AgeSchema = v.pipe( + v.number(), + v.integer("Age must be a whole number"), + v.minValue(0, "Age cannot be negative"), + v.maxValue(150, "Age cannot exceed 150"), +); +``` + +### Common Validation Actions + +**String validations:** + +- `v.email()` — Valid email format +- `v.url()` — Valid URL format +- `v.uuid()` — Valid UUID format +- `v.regex(pattern)` — Match regex pattern +- `v.minLength(n)` — Minimum length +- `v.maxLength(n)` — Maximum length +- `v.length(n)` — Exact length +- `v.nonEmpty()` — Not empty string +- `v.startsWith(str)` — Starts with string +- `v.endsWith(str)` — Ends with string +- `v.includes(str)` — Contains string + +**Number validations:** + +- `v.minValue(n)` — Minimum value (>=) +- `v.maxValue(n)` — Maximum value (<=) +- `v.gtValue(n)` — Greater than (>) +- `v.ltValue(n)` — Less than (<) +- `v.integer()` — Must be integer +- `v.finite()` — Must be finite +- `v.safeInteger()` — Safe integer range +- `v.multipleOf(n)` — Must be multiple of n + +**Array validations:** + +- `v.minLength(n)` — Minimum items +- `v.maxLength(n)` — Maximum items +- `v.length(n)` — Exact item count +- `v.nonEmpty()` — At least one item +- `v.includes(item)` — Contains item +- `v.excludes(item)` — Does not contain item + +### Custom Validation with check() + +```typescript +const PasswordSchema = v.pipe( + v.string(), + v.minLength(8), + v.check( + (input) => /[A-Z]/.test(input), + "Password must contain an uppercase letter", + ), + v.check((input) => /[0-9]/.test(input), "Password must contain a number"), +); +``` + +### Value Transformations + +These actions modify the value without changing its type: + +**String transformations:** + +- `v.trim()` — Remove leading/trailing whitespace +- `v.trimStart()` — Remove leading whitespace +- `v.trimEnd()` — Remove trailing whitespace +- `v.toLowerCase()` — Convert to lowercase +- `v.toUpperCase()` — Convert to uppercase + +**Number transformations:** + +- `v.toMinValue(n)` — Clamp to minimum value (if less than n, set to n) +- `v.toMaxValue(n)` — Clamp to maximum value (if greater than n, set to n) + +```typescript +const NormalizedEmailSchema = v.pipe( + v.string(), + v.trim(), + v.toLowerCase(), + v.email(), +); + +// Clamp number to range 0-100 +const PercentageSchema = v.pipe(v.number(), v.toMinValue(0), v.toMaxValue(100)); +``` + +### Type Transformations + +For converting between data types, use these built-in transformation actions: + +- `v.toNumber()` — Convert to number +- `v.toString()` — Convert to string +- `v.toBoolean()` — Convert to boolean +- `v.toBigint()` — Convert to bigint +- `v.toDate()` — Convert to Date + +```typescript +// Convert string to number +const PortSchema = v.pipe(v.string(), v.toNumber(), v.integer(), v.minValue(1)); + +// Convert ISO string to Date +const TimestampSchema = v.pipe(v.string(), v.isoDateTime(), v.toDate()); + +// Convert to boolean +const FlagSchema = v.pipe(v.string(), v.toBoolean()); +``` + +### Custom Transformations + +For custom transformations, use `v.transform()`: + +```typescript +const DateStringSchema = v.pipe( + v.string(), + v.isoDate(), + v.transform((input) => new Date(input)), +); + +// Custom object transformation +const UserSchema = v.pipe( + v.object({ + firstName: v.string(), + lastName: v.string(), + }), + v.transform((input) => ({ + ...input, + fullName: `${input.firstName} ${input.lastName}`, + })), +); +``` + +## Object Schemas + +### Basic Object + +```typescript +const UserSchema = v.object({ + id: v.number(), + name: v.string(), + email: v.pipe(v.string(), v.email()), + age: v.optional(v.number()), +}); + +type User = v.InferOutput; +``` + +### Object Variants + +```typescript +// Regular object - strips unknown keys (default) +const ObjectSchema = v.object({ key: v.string() }); + +// Loose object - allows and preserves unknown keys +const LooseObjectSchema = v.looseObject({ key: v.string() }); + +// Strict object - throws on unknown keys +const StrictObjectSchema = v.strictObject({ key: v.string() }); + +// Object with rest - validates unknown keys against a schema +const ObjectWithRestSchema = v.objectWithRest( + { key: v.string() }, + v.number(), // unknown keys must be numbers +); +``` + +### Optional and Nullable Fields + +```typescript +const ProfileSchema = v.object({ + // Required + name: v.string(), + + // Optional (can be undefined or missing) + nickname: v.optional(v.string()), + + // Optional with default + role: v.optional(v.string(), "user"), + + // Nullable (can be null) + avatar: v.nullable(v.string()), + + // Nullish (can be null or undefined) + bio: v.nullish(v.string()), + + // Nullish with default + theme: v.nullish(v.string(), "light"), +}); +``` + +### Object Methods + +```typescript +const BaseSchema = v.object({ + id: v.number(), + name: v.string(), + email: v.string(), + password: v.string(), +}); + +// Pick specific keys +const PublicUserSchema = v.pick(BaseSchema, ["id", "name"]); + +// Omit specific keys +const UserWithoutPasswordSchema = v.omit(BaseSchema, ["password"]); + +// Make all optional +const PartialUserSchema = v.partial(BaseSchema); + +// Make all required +const RequiredUserSchema = v.required(PartialUserSchema); + +// Merge objects +const ExtendedUserSchema = v.object({ + ...BaseSchema.entries, + createdAt: v.date(), +}); +``` + +### Cross-Field Validation + +```typescript +const RegistrationSchema = v.pipe( + v.object({ + password: v.pipe(v.string(), v.minLength(8)), + confirmPassword: v.string(), + }), + v.forward( + v.partialCheck( + [["password"], ["confirmPassword"]], + (input) => input.password === input.confirmPassword, + "Passwords do not match", + ), + ["confirmPassword"], + ), +); +``` + +## Arrays and Tuples + +### Arrays + +```typescript +const TagsSchema = v.pipe( + v.array(v.string()), + v.minLength(1, "At least one tag required"), + v.maxLength(10, "Maximum 10 tags allowed"), +); + +// Array of objects +const UsersSchema = v.array( + v.object({ + id: v.number(), + name: v.string(), + }), +); +``` + +### Tuples + +```typescript +// Fixed-length array with specific types +const CoordinatesSchema = v.tuple([v.number(), v.number()]); +// Type: [number, number] + +// Tuple with rest +const ArgsSchema = v.tupleWithRest( + [v.string()], // first arg is string + v.number(), // rest are numbers +); +// Type: [string, ...number[]] +``` + +## Unions and Variants + +### Union + +```typescript +const StringOrNumberSchema = v.union([v.string(), v.number()]); + +const StatusSchema = v.union([ + v.literal("pending"), + v.literal("active"), + v.literal("inactive"), +]); +``` + +### Picklist (for string/number literals) + +```typescript +// Simpler than union of literals +const StatusSchema = v.picklist(["pending", "active", "inactive"]); + +const PrioritySchema = v.picklist([1, 2, 3]); +``` + +### Variant (discriminated union) + +Use `variant` for better performance with discriminated unions: + +```typescript +const EventSchema = v.variant("type", [ + v.object({ + type: v.literal("click"), + x: v.number(), + y: v.number(), + }), + v.object({ + type: v.literal("keypress"), + key: v.string(), + }), + v.object({ + type: v.literal("scroll"), + direction: v.picklist(["up", "down"]), + }), +]); +``` + +## Parsing Data + +### parse() — Throws on Error + +```typescript +import * as v from "valibot"; + +const EmailSchema = v.pipe(v.string(), v.email()); + +try { + const email = v.parse(EmailSchema, "jane@example.com"); + console.log(email); // 'jane@example.com' +} catch (error) { + console.error(error); // ValiError +} +``` + +### safeParse() — Returns Result Object + +```typescript +const result = v.safeParse(EmailSchema, input); + +if (result.success) { + console.log(result.output); // Valid data +} else { + console.log(result.issues); // Array of issues +} +``` + +### is() — Type Guard + +```typescript +if (v.is(EmailSchema, input)) { + // input is typed as string +} +``` + +### Configuration Options + +```typescript +// Abort early - stop at first error +v.parse(Schema, data, { abortEarly: true }); + +// Abort pipe early - stop pipeline at first error +v.parse(Schema, data, { abortPipeEarly: true }); +``` + +## Type Inference + +```typescript +import * as v from "valibot"; + +const UserSchema = v.object({ + name: v.string(), + age: v.pipe(v.string(), v.transform(Number)), + role: v.optional(v.string(), "user"), +}); + +// Output type (after transformations and defaults) +type User = v.InferOutput; +// { name: string; age: number; role: string } + +// Input type (before transformations) +type UserInput = v.InferInput; +// { name: string; age: string; role?: string | undefined } + +// Issue type +type UserIssue = v.InferIssue; +``` + +## Error Handling + +### Custom Error Messages + +```typescript +const LoginSchema = v.object({ + email: v.pipe( + v.string("Email must be a string"), + v.nonEmpty("Please enter your email"), + v.email("Invalid email format"), + ), + password: v.pipe( + v.string("Password must be a string"), + v.nonEmpty("Please enter your password"), + v.minLength(8, "Password must be at least 8 characters"), + ), +}); +``` + +### Flattening Errors + +```typescript +const result = v.safeParse(LoginSchema, data); + +if (!result.success) { + const flat = v.flatten(result.issues); + // { nested: { email: ['Invalid email format'], password: ['...'] } } +} +``` + +### Issue Structure + +Each issue contains: + +- `kind`: 'schema' | 'validation' | 'transformation' +- `type`: Function name (e.g., 'string', 'email', 'min_length') +- `input`: The problematic input +- `expected`: What was expected +- `received`: What was received +- `message`: Human-readable message +- `path`: Array of path items for nested issues + +## Fallback Values + +```typescript +// Static fallback +const NumberSchema = v.fallback(v.number(), 0); +v.parse(NumberSchema, "invalid"); // Returns 0 + +// Dynamic fallback +const DateSchema = v.fallback(v.date(), () => new Date()); +``` + +## Recursive Schemas + +```typescript +import * as v from "valibot"; + +type TreeNode = { + value: string; + children: TreeNode[]; +}; + +const TreeNodeSchema: v.GenericSchema = v.object({ + value: v.string(), + children: v.lazy(() => v.array(TreeNodeSchema)), +}); +``` + +## Async Validation + +For async operations (e.g., database checks), use async variants: + +```typescript +import * as v from "valibot"; + +const isUsernameAvailable = async (username: string) => { + // Check database + return true; +}; + +const UsernameSchema = v.pipeAsync( + v.string(), + v.minLength(3), + v.checkAsync(isUsernameAvailable, "Username is already taken"), +); + +// Must use parseAsync +const username = await v.parseAsync(UsernameSchema, "john"); +``` + +## JSON Schema Conversion + +```typescript +import { toJsonSchema } from "@valibot/to-json-schema"; +import * as v from "valibot"; + +const EmailSchema = v.pipe(v.string(), v.email()); +const jsonSchema = toJsonSchema(EmailSchema); +// { type: 'string', format: 'email' } +``` + +## Naming Conventions + +### Convention 1: Same Name (Recommended for simplicity) + +```typescript +export const User = v.object({ + name: v.string(), + email: v.pipe(v.string(), v.email()), +}); + +export type User = v.InferOutput; + +// Usage +const users: User[] = []; +users.push(v.parse(User, data)); +``` + +### Convention 2: With Suffixes (Recommended when input/output differ) + +```typescript +export const UserSchema = v.object({ + name: v.string(), + age: v.pipe(v.string(), v.transform(Number)), +}); + +export type UserInput = v.InferInput; +export type UserOutput = v.InferOutput; +``` + +## Common Patterns + +### Login Form + +```typescript +const LoginSchema = v.object({ + email: v.pipe( + v.string(), + v.nonEmpty("Please enter your email"), + v.email("Invalid email address"), + ), + password: v.pipe( + v.string(), + v.nonEmpty("Please enter your password"), + v.minLength(8, "Password must be at least 8 characters"), + ), +}); +``` + +### API Response + +```typescript +const ApiResponseSchema = v.variant("status", [ + v.object({ + status: v.literal("success"), + data: v.unknown(), + }), + v.object({ + status: v.literal("error"), + error: v.object({ + code: v.string(), + message: v.string(), + }), + }), +]); +``` + +### Environment Variables + +```typescript +const EnvSchema = v.object({ + NODE_ENV: v.picklist(["development", "production", "test"]), + PORT: v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1)), + DATABASE_URL: v.pipe(v.string(), v.url()), + API_KEY: v.pipe(v.string(), v.minLength(32)), +}); + +const env = v.parse(EnvSchema, process.env); +``` + +### Date Handling + +```typescript +// String to Date +const DateFromStringSchema = v.pipe( + v.string(), + v.isoDate(), + v.transform((input) => new Date(input)), +); + +// Date validation +const FutureDateSchema = v.pipe( + v.date(), + v.minValue(new Date(), "Date must be in the future"), +); +``` + +## Additional Resources + +- [Valibot Documentation](https://valibot.dev) +- [Valibot GitHub](https://github.com/open-circle/valibot) +- [API Reference](https://valibot.dev/api/) +- [Migration from Zod](https://valibot.dev/guides/migrate-from-zod/) diff --git a/packages/web/package.json b/packages/web/package.json index 684533e..32c3936 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@kobalte/core": "^0.13.13", + "@formisch/solid": "1.0.0", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^1.0.0", "@solidjs/start": "^2.0.0", @@ -54,6 +55,7 @@ "relay-runtime": "^21.0.1", "solid-js": "^1.9.5", "solid-relay": "1.0.0-beta.29", + "valibot": "1.4.2", "vite": "^8.2.0" }, "devDependencies": { diff --git a/packages/web/src/routes/sign-in.tsx b/packages/web/src/routes/sign-in.tsx index 8190302..774736d 100644 --- a/packages/web/src/routes/sign-in.tsx +++ b/packages/web/src/routes/sign-in.tsx @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { Field, Form, type SubmitHandler, createForm } from "@formisch/solid"; import { Alert } from "@kobalte/core/alert"; import { Button } from "@kobalte/core/button"; import { TextField } from "@kobalte/core/text-field"; @@ -21,6 +22,7 @@ import { Title } from "@solidjs/meta"; import { graphql } from "relay-runtime"; import { Match, Show, Switch, createSignal } from "solid-js"; import { createMutation } from "solid-relay"; +import * as v from "valibot"; import type { SignInMutation } from "./__generated__/SignInMutation.graphql.ts"; @@ -34,6 +36,15 @@ const signInMutation = graphql` } `; +const signInSchema = v.object({ + email: v.pipe( + v.string(), + v.trim(), + v.nonEmpty("Enter a valid email address."), + v.email("Enter a valid email address."), + ), +}); + interface SignInResult { message: string; status: "error" | "success"; @@ -43,21 +54,13 @@ export default function SignInPage() { const [result, setResult] = createSignal(); const [commitSignIn, isSigningIn] = createMutation(signInMutation); + const signInForm = createForm({ + schema: signInSchema, + initialInput: { email: "" }, + }); - const submit = (event: SubmitEvent & { currentTarget: HTMLFormElement }) => { - event.preventDefault(); - + const submit: SubmitHandler = ({ email }) => { const verifyUrl = `${globalThis.location.origin}/confirm/{token}?code={code}`; - const formData = new FormData(event.currentTarget); - const email = formData.get("email"); - if (typeof email !== "string" || email === "") { - setResult({ - message: "Enter a valid email address.", - status: "error", - }); - return; - } - setResult(undefined); commitSignIn({ variables: { email, verifyUrl }, @@ -96,22 +99,45 @@ export default function SignInPage() {

Enter your email address to receive a secure sign-in link.

-
- - - Email address - - Required - - - - + + + {(field) => ( + + + Email address + + Required + + + + + {(errors) => ( + + {errors()[0]} + + )} + + + )} + -
+ {(formResult) => ( diff --git a/packages/web/src/routes/workspace/create/instance.tsx b/packages/web/src/routes/workspace/create/instance.tsx index 7063487..4f1441f 100644 --- a/packages/web/src/routes/workspace/create/instance.tsx +++ b/packages/web/src/routes/workspace/create/instance.tsx @@ -15,6 +15,7 @@ // along with this program. If not, see . import { faker } from "@faker-js/faker"; +import { Field, Form, type SubmitHandler, createForm } from "@formisch/solid"; import { Button } from "@kobalte/core/button"; import { TextField } from "@kobalte/core/text-field"; import { Title } from "@solidjs/meta"; @@ -22,6 +23,7 @@ import { useNavigate } from "@solidjs/router"; import { graphql } from "relay-runtime"; import { Show, createSignal } from "solid-js"; import { createMutation } from "solid-relay"; +import * as v from "valibot"; import type { CreateInstanceMutation } from "./__generated__/CreateInstanceMutation.graphql.ts"; @@ -41,22 +43,37 @@ const createInstanceMutation = graphql` } `; +const createInstanceSchema = v.object({ + slug: v.pipe( + v.string(), + v.trim(), + v.minLength(4, "The slug must contain at least 4 characters."), + v.maxLength(63, "The slug must contain at most 63 characters."), + v.regex( + /^[a-z0-9-]+$/u, + "The slug can contain only lowercase letters, numbers, and hyphens.", + ), + ), +}); + +const generateSlugWord = () => faker.word.noun({ length: { min: 1, max: 20 } }); + export default function CreateInstancePage() { const navigate = useNavigate(); const [errorMessage, setErrorMessage] = createSignal(); const [commitCreateInstance, isCreatingInstance] = createMutation(createInstanceMutation); - const submit = (event: SubmitEvent & { currentTarget: HTMLFormElement }) => { - event.preventDefault(); - - const formData = new FormData(event.currentTarget); - const slug = formData.get("slug"); - if (typeof slug !== "string" || slug === "") { - setErrorMessage("Enter a valid slug."); - return; - } + const createInstanceForm = createForm({ + schema: createInstanceSchema, + initialInput: { + slug: [generateSlugWord(), generateSlugWord(), generateSlugWord()] + .join("-") + .toLowerCase(), + }, + }); + const submit: SubmitHandler = ({ slug }) => { setErrorMessage(undefined); commitCreateInstance({ variables: { slug }, @@ -110,26 +127,48 @@ export default function CreateInstancePage() {

Review the generated identifier for your new instance.

-
- - - Slug - Generated · Read only - - - - - - DrFed generates this identifier automatically. It cannot be - edited. - - - + + + {(field) => ( + + + Slug + Generated · Read only + + + + + + DrFed generates this identifier automatically. It cannot be + edited. + + + + {(errors) => ( + + {errors()[0]} + + )} + + + )} + -
- +