Skip to content

Commit

Permalink
feat: typebox
Browse files Browse the repository at this point in the history
  • Loading branch information
lifeiscontent committed Dec 30, 2023
1 parent e0ebcc5 commit 5625832
Show file tree
Hide file tree
Showing 11 changed files with 497 additions and 22,523 deletions.
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;
}

// ...
}
```
139 changes: 139 additions & 0 deletions packages/conform-typebox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {
type FieldConstraint,
type FieldsetConstraint,
type Submission,
parse as baseParse,
} from '@conform-to/dom';
import type { Static, 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>(
schema: T,
): FieldsetConstraint<Static<T>> {
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>(
schema: T,
): FieldsetConstraint<Static<T>> {
return Object.getOwnPropertyNames(schema.properties).reduce((acc, key) => {
return {
...acc,
[key]: inferConstraint(
schema.properties[key as keyof FieldsetConstraint<Static<T>>],
),
};
}, {} as FieldsetConstraint<Static<T>>);
}

return resolveFieldsetConstraint(schema);
}

export function parse<Schema extends TObject>(
payload: FormData | URLSearchParams,
config: {
schema: Schema | ((intent: string) => Schema);
},
): Submission<Static<Schema>>;
export function parse<Schema extends TObject>(
payload: FormData | URLSearchParams,
config: {
schema: Schema | ((intent: string) => Schema);
},
): Submission<Static<Schema>> {
return baseParse<StaticDecode<Schema>>(payload, {
resolve(input, intent) {
const schema =
typeof config.schema === 'function'
? config.schema(intent)
: config.schema;
const resolveData = (value: Static<Schema>) => ({ 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;
};

const payload = Value.Convert(schema, input);
try {
const data = Value.Decode(schema, payload);

return resolveData(data);
} 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

0 comments on commit 5625832

Please sign in to comment.