Skip to content

Commit 4039623

Browse files
committed
feat: typebox
1 parent e0ebcc5 commit 4039623

File tree

11 files changed

+496
-22523
lines changed

11 files changed

+496
-22523
lines changed

package-lock.json

Lines changed: 27 additions & 22523 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@remix-run/node": "^1.19.3",
3434
"@rollup/plugin-babel": "^5.3.1",
3535
"@rollup/plugin-node-resolve": "^13.3.0",
36+
"@sinclair/typebox": "^0.32.3",
3637
"husky": "^8.0.3",
3738
"lint-staged": "^13.1.2",
3839
"npm-run-all": "^4.1.5",
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# ignore all .ts, .tsx files except .d.ts
2+
*.ts
3+
*.tsx
4+
!*.d.ts
5+
6+
# config / build result
7+
tsconfig.json
8+
*.tsbuildinfo

packages/conform-typebox/README.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# @conform-to/typebox
2+
3+
> [Conform](https://github.com/edmundhung/conform) helpers for integrating with [typebox](https://github.com/sinclairzx81/typebox)
4+
5+
<!-- aside -->
6+
7+
## API Reference
8+
9+
- [getFieldsetConstraint](#getfieldsetconstraint)
10+
- [parse](#parse)
11+
12+
<!-- /aside -->
13+
14+
### getFieldsetConstraint
15+
16+
This tries to infer constraint of each field based on the typebox schema. This is useful for:
17+
18+
1. Making it easy to style input using CSS, e.g. `:required`
19+
2. Having some basic validation working before/without JS.
20+
21+
```tsx
22+
import { useForm } from '@conform-to/react';
23+
import { getFieldsetConstraint } from '@conform-to/typebox';
24+
import { Type } from '@sinclairzx81/typebox';
25+
26+
const schema = Type.Object({
27+
email: Type.String(),
28+
password: Type.String(),
29+
});
30+
31+
function Example() {
32+
const [form, { email, password }] = useForm({
33+
constraint: getFieldsetConstraint(schema),
34+
});
35+
36+
// ...
37+
}
38+
```
39+
40+
### parse
41+
42+
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`.
43+
44+
```tsx
45+
import { useForm } from '@conform-to/react';
46+
import { parse } from '@conform-to/typebox';
47+
import { Type } from '@sinclairzx81/typebox';
48+
49+
const schema = Type.Object({
50+
email: Type.String(),
51+
password: Type.String(),
52+
});
53+
54+
function ExampleForm() {
55+
const [form] = useForm({
56+
onValidate({ formData }) {
57+
return parse(formData, { schema });
58+
},
59+
});
60+
61+
// ...
62+
}
63+
```
64+
65+
Or when parsing the formData on server side (e.g. Remix):
66+
67+
```tsx
68+
import { useForm } from '@conform-to/react';
69+
import { parse } from '@conform-to/typebox';
70+
import { Type } from '@sinclairzx81/typebox';
71+
72+
const schema = Type.Object({
73+
// Define the schema with typebox
74+
});
75+
76+
export async function action({ request }) {
77+
const formData = await request.formData();
78+
const submission = parse(formData, {
79+
schema,
80+
});
81+
82+
if (submission.intent !== 'submit' || !submission.value) {
83+
return submission;
84+
}
85+
86+
// ...
87+
}
88+
```

packages/conform-typebox/index.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import {
2+
type FieldConstraint,
3+
type FieldsetConstraint,
4+
type Submission,
5+
parse as baseParse,
6+
} from '@conform-to/dom';
7+
import type { Static, StaticDecode, TObject, TSchema } from '@sinclair/typebox';
8+
import { OptionalKind, TypeGuard } from '@sinclair/typebox';
9+
import { Value, ValueErrorIterator } from '@sinclair/typebox/value';
10+
11+
function transformPath(path: string): string {
12+
const parts = path.split('/').filter(Boolean); // Split the string and remove empty parts
13+
return parts
14+
.map((part, index) => {
15+
// If the part is a number, format it as an array index, otherwise use a dot or nothing for the first part
16+
return isNaN(+part) ? (index === 0 ? part : `.${part}`) : `[${part}]`;
17+
})
18+
.join('');
19+
}
20+
21+
export function getFieldsetConstraint<T extends TObject>(
22+
schema: T,
23+
): FieldsetConstraint<Static<T>> {
24+
function discardKey(value: Record<PropertyKey, any>, key: PropertyKey) {
25+
const { [key]: _, ...rest } = value;
26+
return rest;
27+
}
28+
function inferConstraint<T extends TSchema>(schema: T): FieldConstraint<T> {
29+
let constraint: FieldConstraint = {};
30+
if (TypeGuard.IsOptional(schema)) {
31+
const unwrapped = discardKey(schema, OptionalKind) as TSchema;
32+
constraint = {
33+
...inferConstraint(unwrapped),
34+
required: false,
35+
};
36+
} else if (TypeGuard.IsArray(schema)) {
37+
constraint = {
38+
...inferConstraint(schema.items),
39+
multiple: true,
40+
};
41+
} else if (TypeGuard.IsString(schema)) {
42+
if (schema.minLength) {
43+
constraint.minLength = schema.minLength;
44+
}
45+
if (schema.maxLength) {
46+
constraint.maxLength = schema.maxLength;
47+
}
48+
if (schema.pattern) {
49+
constraint.pattern = schema.pattern;
50+
}
51+
} else if (TypeGuard.IsNumber(schema) || TypeGuard.IsInteger(schema)) {
52+
if (schema.minimum) {
53+
constraint.min = schema.minimum;
54+
}
55+
if (schema.maximum) {
56+
constraint.max = schema.maximum;
57+
}
58+
if (schema.multipleOf) {
59+
constraint.step = schema.multipleOf;
60+
}
61+
} else if (TypeGuard.IsUnionLiteral(schema)) {
62+
constraint.pattern = schema.anyOf
63+
.map((literal) => {
64+
const option = literal.const.toString();
65+
// To escape unsafe characters on regex
66+
return option
67+
.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
68+
.replace(/-/g, '\\x2d');
69+
})
70+
.join('|');
71+
}
72+
73+
if (typeof constraint.required === 'undefined') {
74+
constraint.required = true;
75+
}
76+
77+
return constraint;
78+
}
79+
function resolveFieldsetConstraint<T extends TObject>(
80+
schema: T,
81+
): FieldsetConstraint<Static<T>> {
82+
return Object.getOwnPropertyNames(schema.properties).reduce((acc, key) => {
83+
return {
84+
...acc,
85+
[key]: inferConstraint(
86+
schema.properties[key as keyof FieldsetConstraint<Static<T>>],
87+
),
88+
};
89+
}, {} as FieldsetConstraint<Static<T>>);
90+
}
91+
92+
return resolveFieldsetConstraint(schema);
93+
}
94+
95+
export function parse<Schema extends TObject>(
96+
payload: FormData | URLSearchParams,
97+
config: {
98+
schema: Schema | ((intent: string) => Schema);
99+
},
100+
): Submission<Static<Schema>>;
101+
export function parse<Schema extends TObject>(
102+
payload: FormData | URLSearchParams,
103+
config: {
104+
schema: Schema | ((intent: string) => Schema);
105+
},
106+
): Submission<Static<Schema>> {
107+
return baseParse<StaticDecode<Schema>>(payload, {
108+
resolve(input, intent) {
109+
const schema =
110+
typeof config.schema === 'function'
111+
? config.schema(intent)
112+
: config.schema;
113+
const resolveData = (value: Static<Schema>) => ({ value });
114+
const resolveError = (error: unknown) => {
115+
if (error instanceof ValueErrorIterator) {
116+
return {
117+
error: Array.from(error).reduce((error, valueError) => {
118+
const path = transformPath(valueError.path);
119+
const innerError = (error[path] ??= []);
120+
innerError.push(valueError.message);
121+
return error;
122+
}, {} as Record<string, string[]>),
123+
};
124+
}
125+
126+
throw error;
127+
};
128+
129+
// coerce the input to the schema
130+
const payload = Value.Convert(schema, input);
131+
try {
132+
return resolveData(Value.Decode(schema, payload));
133+
} catch (error) {
134+
return resolveError(Value.Errors(schema, payload));
135+
}
136+
},
137+
});
138+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "@conform-to/typebox",
3+
"description": "Conform helpers for integrating with typebox",
4+
"homepage": "https://conform.guide",
5+
"license": "MIT",
6+
"version": "0.9.1",
7+
"main": "index.js",
8+
"module": "index.mjs",
9+
"types": "index.d.ts",
10+
"exports": {
11+
".": {
12+
"types": "./index.d.ts",
13+
"module": "./index.mjs",
14+
"import": "./index.mjs",
15+
"require": "./index.js",
16+
"default": "./index.mjs"
17+
}
18+
},
19+
"repository": {
20+
"type": "git",
21+
"url": "https://github.com/edmundhung/conform",
22+
"directory": "packages/conform-typebox"
23+
},
24+
"bugs": {
25+
"url": "https://github.com/edmundhung/conform/issues"
26+
},
27+
"peerDependencies": {
28+
"@conform-to/dom": "0.9.1",
29+
"@sinclair/typebox": ">=0.32.0"
30+
},
31+
"devDependencies": {
32+
"@sinclair/typebox": "^0.32.3"
33+
},
34+
"keywords": [
35+
"constraint-validation",
36+
"form",
37+
"form-validation",
38+
"html",
39+
"progressive-enhancement",
40+
"validation",
41+
"typebox"
42+
],
43+
"sideEffects": false
44+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"compilerOptions": {
3+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
4+
"target": "ES2020",
5+
"moduleResolution": "node16",
6+
"allowSyntheticDefaultImports": false,
7+
"noUncheckedIndexedAccess": true,
8+
"strict": true,
9+
"declaration": true,
10+
"emitDeclarationOnly": true,
11+
"composite": true,
12+
"skipLibCheck": true
13+
}
14+
}

playground/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@conform-to/react": "*",
1616
"@conform-to/validitystate": "*",
1717
"@conform-to/zod": "*",
18+
"@conform-to/typebox": "*",
1819
"@headlessui/tailwindcss": "^0.1.3",
1920
"@heroicons/react": "^2.0.18",
2021
"@radix-ui/react-checkbox": "^1.0.4",

rollup.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export default function rollup() {
7171
// Schema resolver
7272
'conform-zod',
7373
'conform-yup',
74+
'conform-typebox',
7475

7576
// View adapter
7677
'conform-react',

0 commit comments

Comments
 (0)