Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: typebox #368

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
22,550 changes: 27 additions & 22,523 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@remix-run/node": "^1.19.3",
"@rollup/plugin-babel": "^5.3.1",
"@rollup/plugin-node-resolve": "^13.3.0",
"@sinclair/typebox": "^0.32.3",
"husky": "^8.0.3",
"lint-staged": "^13.1.2",
"npm-run-all": "^4.1.5",
Expand Down
8 changes: 8 additions & 0 deletions packages/conform-typebox/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# ignore all .ts, .tsx files except .d.ts
*.ts
*.tsx
!*.d.ts

# config / build result
tsconfig.json
*.tsbuildinfo
88 changes: 88 additions & 0 deletions packages/conform-typebox/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# @conform-to/typebox

> [Conform](https://github.com/edmundhung/conform) helpers for integrating with [typebox](https://github.com/sinclairzx81/typebox)

<!-- aside -->

## API Reference

- [getFieldsetConstraint](#getfieldsetconstraint)
- [parse](#parse)

<!-- /aside -->

### getFieldsetConstraint

This tries to infer constraint of each field based on the typebox schema. This is useful for:

1. Making it easy to style input using CSS, e.g. `:required`
2. Having some basic validation working before/without JS.

```tsx
import { useForm } from '@conform-to/react';
import { getFieldsetConstraint } from '@conform-to/typebox';
import { Type } from '@sinclairzx81/typebox';

const schema = Type.Object({
email: Type.String(),
password: Type.String(),
});

function Example() {
const [form, { email, password }] = useForm({
constraint: getFieldsetConstraint(schema),
});

// ...
}
```

### parse

It parses the formData and returns a submission result with the validation error. If no error is found, the parsed data will also be populated as `submission.value`.

```tsx
import { useForm } from '@conform-to/react';
import { parse } from '@conform-to/typebox';
import { Type } from '@sinclairzx81/typebox';

const schema = Type.Object({
email: Type.String(),
password: Type.String(),
});

function ExampleForm() {
const [form] = useForm({
onValidate({ formData }) {
return parse(formData, { schema });
},
});

// ...
}
```

Or when parsing the formData on server side (e.g. Remix):

```tsx
import { useForm } from '@conform-to/react';
import { parse } from '@conform-to/typebox';
import { Type } from '@sinclairzx81/typebox';

const schema = Type.Object({
// Define the schema with typebox
});

export async function action({ request }) {
const formData = await request.formData();
const submission = parse(formData, {
schema,
});

if (submission.intent !== 'submit' || !submission.value) {
return submission;
}

// ...
}
```
141 changes: 141 additions & 0 deletions packages/conform-typebox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {
type FieldConstraint,
type FieldsetConstraint,
type Submission,
parse as baseParse,
} from '@conform-to/dom';
import type { StaticDecode, TObject, TSchema } from '@sinclair/typebox';
import { OptionalKind, TypeGuard } from '@sinclair/typebox';
import { Value, ValueErrorIterator } from '@sinclair/typebox/value';

function transformPath(path: string): string {
const parts = path.split('/').filter(Boolean); // Split the string and remove empty parts
return parts
.map((part, index) => {
// If the part is a number, format it as an array index, otherwise use a dot or nothing for the first part
return isNaN(+part) ? (index === 0 ? part : `.${part}`) : `[${part}]`;
})
.join('');
}

export function getFieldsetConstraint<
T extends TObject,
R extends Record<string, any> = StaticDecode<T>,
>(schema: T): FieldsetConstraint<R> {
function discardKey(value: Record<PropertyKey, any>, key: PropertyKey) {
const { [key]: _, ...rest } = value;
return rest;
}
function inferConstraint<T extends TSchema>(schema: T): FieldConstraint<T> {
let constraint: FieldConstraint = {};
if (TypeGuard.IsOptional(schema)) {
const unwrapped = discardKey(schema, OptionalKind) as TSchema;
constraint = {
...inferConstraint(unwrapped),
required: false,
};
} else if (TypeGuard.IsArray(schema)) {
constraint = {
...inferConstraint(schema.items),
multiple: true,
};
} else if (TypeGuard.IsString(schema)) {
if (schema.minLength) {
constraint.minLength = schema.minLength;
}
if (schema.maxLength) {
constraint.maxLength = schema.maxLength;
}
if (schema.pattern) {
constraint.pattern = schema.pattern;
}
} else if (TypeGuard.IsNumber(schema) || TypeGuard.IsInteger(schema)) {
if (schema.minimum) {
constraint.min = schema.minimum;
}
if (schema.maximum) {
constraint.max = schema.maximum;
}
if (schema.multipleOf) {
constraint.step = schema.multipleOf;
}
} else if (TypeGuard.IsUnionLiteral(schema)) {
constraint.pattern = schema.anyOf
.map((literal) => {
const option = literal.const.toString();
// To escape unsafe characters on regex
return option
.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
.replace(/-/g, '\\x2d');
})
.join('|');
}

if (typeof constraint.required === 'undefined') {
constraint.required = true;
}

return constraint;
}
function resolveFieldsetConstraint<
T extends TObject,
R extends Record<string, any> = StaticDecode<T>,
>(schema: T): FieldsetConstraint<R> {
return Object.getOwnPropertyNames(schema.properties).reduce((acc, key) => {
return {
...acc,
[key]: inferConstraint(
schema.properties[key as keyof FieldsetConstraint<R>],
),
};
}, {} as FieldsetConstraint<R>);
}

return resolveFieldsetConstraint(schema);
}

export function parse<Schema extends TObject, R = StaticDecode<Schema>>(
payload: FormData | URLSearchParams,
config: {
schema: Schema | ((intent: string) => Schema);
},
): Submission<R>;

export function parse<Schema extends TObject, R = StaticDecode<Schema>>(
payload: FormData | URLSearchParams,
config: {
schema: Schema | ((intent: string) => Schema);
},
): Submission<R> {
return baseParse<R>(payload, {
resolve(input, intent) {
const schema =
typeof config.schema === 'function'
? config.schema(intent)
: config.schema;
const resolveData = (value: R) => ({ value });
const resolveError = (error: unknown) => {
if (error instanceof ValueErrorIterator) {
return {
error: Array.from(error).reduce((error, valueError) => {
const path = transformPath(valueError.path);
const innerError = (error[path] ??= []);
innerError.push(valueError.message);
return error;
}, {} as Record<string, string[]>),
};
}

throw error;
};

// coerce the input to the schema
const payload = Value.Convert(schema, input);
try {
return resolveData(Value.Decode(schema, payload));
} catch (error) {
return resolveError(Value.Errors(schema, payload));
}
},
});
}
44 changes: 44 additions & 0 deletions packages/conform-typebox/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@conform-to/typebox",
"description": "Conform helpers for integrating with typebox",
"homepage": "https://conform.guide",
"license": "MIT",
"version": "0.9.1",
"main": "index.js",
"module": "index.mjs",
"types": "index.d.ts",
"exports": {
".": {
"types": "./index.d.ts",
"module": "./index.mjs",
"import": "./index.mjs",
"require": "./index.js",
"default": "./index.mjs"
}
},
"repository": {
"type": "git",
"url": "https://github.com/edmundhung/conform",
"directory": "packages/conform-typebox"
},
"bugs": {
"url": "https://github.com/edmundhung/conform/issues"
},
"peerDependencies": {
"@conform-to/dom": "0.9.1",
"@sinclair/typebox": ">=0.32.0"
},
"devDependencies": {
"@sinclair/typebox": "^0.32.3"
},
"keywords": [
"constraint-validation",
"form",
"form-validation",
"html",
"progressive-enhancement",
"validation",
"typebox"
],
"sideEffects": false
}
14 changes: 14 additions & 0 deletions packages/conform-typebox/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"target": "ES2020",
"moduleResolution": "node16",
"allowSyntheticDefaultImports": false,
"noUncheckedIndexedAccess": true,
"strict": true,
"declaration": true,
"emitDeclarationOnly": true,
"composite": true,
"skipLibCheck": true
}
}
1 change: 1 addition & 0 deletions playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@conform-to/react": "*",
"@conform-to/validitystate": "*",
"@conform-to/zod": "*",
"@conform-to/typebox": "*",
"@headlessui/tailwindcss": "^0.1.3",
"@heroicons/react": "^2.0.18",
"@radix-ui/react-checkbox": "^1.0.4",
Expand Down
1 change: 1 addition & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export default function rollup() {
// Schema resolver
'conform-zod',
'conform-yup',
'conform-typebox',

// View adapter
'conform-react',
Expand Down