@page-speed/forms 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -425
- package/dist/inputs.cjs +160 -36
- package/dist/inputs.cjs.map +1 -1
- package/dist/inputs.d.cts +143 -1
- package/dist/inputs.d.ts +143 -1
- package/dist/inputs.js +159 -36
- package/dist/inputs.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,469 +1,96 @@
|
|
|
1
|
+
<img width="1200" height="330" alt="page-speed-forms-npm-module" src="https://github.com/user-attachments/assets/4dd21311-9de6-4c42-be75-bbc8fe5a0192" />
|
|
2
|
+
|
|
1
3
|
# @page-speed/forms
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
Type-safe form state management and validation for React applications.
|
|
4
6
|
|
|
5
|
-
##
|
|
7
|
+
## Overview
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
- =� **Tree-Shakable**: Import only what you need - Core module starts at 13 KB gzipped
|
|
9
|
-
- � **Built on @legendapp/state**: Observable-based state management for optimal performance
|
|
10
|
-
- **Valibot Integration**: Lightweight validation (95% smaller than Zod)
|
|
11
|
-
- <� **TypeScript-First**: Full type safety with comprehensive type definitions
|
|
12
|
-
- **Accessible**: ARIA attributes and semantic HTML out of the box
|
|
13
|
-
- = **Progressive Enhancement**: Forms work without JavaScript
|
|
14
|
-
- <� **Unstyled**: Bring your own styles - no CSS to override
|
|
9
|
+
OpenSite Page Speed Forms is a high-performance library designed to streamline form state management, validation, and submission handling in React applications. This library is part of OpenSite AI's open-source ecosystem, built for performance and open collaboration. By emphasizing type safety and modularity, it aligns with OpenSite's goal to create scalable, open, and developer-friendly performance tooling.
|
|
15
10
|
|
|
16
|
-
|
|
11
|
+
Learn more at [OpenSite.ai Developers](https://opensite.ai/developers).
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
# Using pnpm (recommended)
|
|
20
|
-
pnpm add @page-speed/forms
|
|
13
|
+
## Key Features
|
|
21
14
|
|
|
22
|
-
|
|
23
|
-
|
|
15
|
+
- Type-safe form state management with TypeScript.
|
|
16
|
+
- Flexible validation schemas supporting both synchronous and asynchronous validation.
|
|
17
|
+
- Modular useForm and useField hooks for complete form and field control.
|
|
18
|
+
- Built-in support for form submission and error handling.
|
|
19
|
+
- Configurable validation modes: `onChange`, `onBlur`, and `onSubmit`.
|
|
24
20
|
|
|
25
|
-
|
|
26
|
-
pnpm add @legendapp/state
|
|
27
|
-
```
|
|
21
|
+
## Installation
|
|
28
22
|
|
|
29
|
-
|
|
23
|
+
To install OpenSite Page Speed Forms, ensure you have Node.js and npm installed, then run:
|
|
30
24
|
|
|
31
|
-
|
|
25
|
+
```
|
|
26
|
+
npm install @page-speed/forms
|
|
27
|
+
```
|
|
32
28
|
|
|
33
|
-
|
|
34
|
-
-
|
|
35
|
-
- **Valibot Adapter**: 392 B
|
|
36
|
-
- **Full Bundle**: 13.25 KB
|
|
29
|
+
Dependencies:
|
|
30
|
+
- React
|
|
37
31
|
|
|
38
32
|
## Quick Start
|
|
39
33
|
|
|
40
|
-
|
|
34
|
+
Here is a basic example to get started with OpenSite Page Speed Forms in your React application:
|
|
41
35
|
|
|
42
|
-
```
|
|
43
|
-
import
|
|
44
|
-
import {
|
|
36
|
+
```typescript
|
|
37
|
+
import React from 'react';
|
|
38
|
+
import { useForm, Form } from '@page-speed/forms';
|
|
45
39
|
|
|
46
|
-
function
|
|
40
|
+
function MyForm() {
|
|
47
41
|
const form = useForm({
|
|
48
|
-
initialValues: {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
validationSchema: {
|
|
53
|
-
email: (value) => !value ? 'Required' : undefined,
|
|
54
|
-
password: (value) => value.length < 8 ? 'Too short' : undefined,
|
|
55
|
-
},
|
|
56
|
-
onSubmit: async (values) => {
|
|
57
|
-
await login(values);
|
|
58
|
-
},
|
|
42
|
+
initialValues: { email: '' },
|
|
43
|
+
onSubmit: (values) => {
|
|
44
|
+
console.log('Form Submitted:', values);
|
|
45
|
+
}
|
|
59
46
|
});
|
|
60
47
|
|
|
61
48
|
return (
|
|
62
49
|
<Form form={form}>
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
</Field>
|
|
71
|
-
|
|
72
|
-
<Field name="password" label="Password">
|
|
73
|
-
{({ field, meta }) => (
|
|
74
|
-
<>
|
|
75
|
-
<TextInput {...field} type="password" error={!!meta.error} />
|
|
76
|
-
{meta.error && <span>{meta.error}</span>}
|
|
77
|
-
</>
|
|
78
|
-
)}
|
|
79
|
-
</Field>
|
|
80
|
-
|
|
81
|
-
<button type="submit" disabled={form.isSubmitting}>
|
|
82
|
-
Submit
|
|
83
|
-
</button>
|
|
50
|
+
<input
|
|
51
|
+
name="email"
|
|
52
|
+
value={form.values.email}
|
|
53
|
+
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
|
54
|
+
onBlur={() => form.setFieldTouched('email', true)}
|
|
55
|
+
/>
|
|
56
|
+
<button type="submit">Submit</button>
|
|
84
57
|
</Form>
|
|
85
58
|
);
|
|
86
59
|
}
|
|
87
60
|
```
|
|
88
61
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
```tsx
|
|
92
|
-
import { useForm } from '@page-speed/forms';
|
|
93
|
-
import { createValibotSchema } from '@page-speed/forms/validation/valibot';
|
|
94
|
-
import * as v from 'valibot';
|
|
95
|
-
|
|
96
|
-
const LoginSchema = v.object({
|
|
97
|
-
email: v.pipe(v.string(), v.email('Invalid email')),
|
|
98
|
-
password: v.pipe(v.string(), v.minLength(8, 'Too short')),
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
function LoginForm() {
|
|
102
|
-
const form = useForm({
|
|
103
|
-
initialValues: { email: '', password: '' },
|
|
104
|
-
validationSchema: createValibotSchema(LoginSchema),
|
|
105
|
-
onSubmit: async (values) => {
|
|
106
|
-
await login(values);
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
// ... rest of form
|
|
111
|
-
}
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
## API Reference
|
|
115
|
-
|
|
116
|
-
### `useForm(options)`
|
|
117
|
-
|
|
118
|
-
Main hook for creating form state.
|
|
119
|
-
|
|
120
|
-
**Options:**
|
|
62
|
+
## Configuration or Advanced Usage
|
|
121
63
|
|
|
122
|
-
|
|
123
|
-
- `validationSchema`: Schema mapping field names to validators
|
|
124
|
-
- `validateOn`: When to validate - `"onBlur"` (default) | `"onChange"` | `"onSubmit"`
|
|
125
|
-
- `revalidateOn`: When to revalidate after first validation - `"onChange"` (default) | `"onBlur"`
|
|
126
|
-
- `onSubmit` (required): Submit handler function
|
|
127
|
-
- `onError`: Error handler for validation failures
|
|
128
|
-
- `debug`: Enable debug logging
|
|
129
|
-
|
|
130
|
-
**Returns:**
|
|
64
|
+
OpenSite Page Speed Forms can be customized with various options:
|
|
131
65
|
|
|
132
66
|
```typescript
|
|
133
|
-
{
|
|
134
|
-
// State
|
|
135
|
-
values: T;
|
|
136
|
-
errors: FormErrors<T>;
|
|
137
|
-
touched: TouchedFields<T>;
|
|
138
|
-
isSubmitting: boolean;
|
|
139
|
-
isValid: boolean;
|
|
140
|
-
isDirty: boolean;
|
|
141
|
-
status: 'idle' | 'submitting' | 'success' | 'error';
|
|
142
|
-
|
|
143
|
-
// Actions
|
|
144
|
-
handleSubmit: (e?: React.FormEvent) => Promise<void>;
|
|
145
|
-
setFieldValue: (field: keyof T, value: T[field]) => void;
|
|
146
|
-
setFieldError: (field: keyof T, error: string) => void;
|
|
147
|
-
setFieldTouched: (field: keyof T, touched: boolean) => void;
|
|
148
|
-
validateForm: () => Promise<FormErrors<T>>;
|
|
149
|
-
validateField: (field: keyof T) => Promise<string | undefined>;
|
|
150
|
-
resetForm: () => void;
|
|
151
|
-
getFieldProps: (field: keyof T) => FieldInputProps;
|
|
152
|
-
getFieldMeta: (field: keyof T) => FieldMeta;
|
|
153
|
-
}
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
### `<Form>`
|
|
157
|
-
|
|
158
|
-
Progressive enhancement wrapper component.
|
|
159
|
-
|
|
160
|
-
```tsx
|
|
161
|
-
<Form
|
|
162
|
-
form={form}
|
|
163
|
-
action="/api/endpoint" // Fallback for no-JS
|
|
164
|
-
method="post" // Fallback for no-JS
|
|
165
|
-
className="my-form"
|
|
166
|
-
>
|
|
167
|
-
{/* fields */}
|
|
168
|
-
</Form>
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
### `<Field>`
|
|
172
|
-
|
|
173
|
-
Field wrapper with label, description, and error display.
|
|
174
|
-
|
|
175
|
-
```tsx
|
|
176
|
-
<Field
|
|
177
|
-
name="email"
|
|
178
|
-
label="Email Address"
|
|
179
|
-
description="We'll never share your email"
|
|
180
|
-
validate={(value) => !value ? 'Required' : undefined}
|
|
181
|
-
>
|
|
182
|
-
{({ field, meta, helpers }) => (
|
|
183
|
-
<TextInput {...field} error={!!meta.error} />
|
|
184
|
-
)}
|
|
185
|
-
</Field>
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
### `useField(options)`
|
|
189
|
-
|
|
190
|
-
Field-level hook for accessing field state.
|
|
191
|
-
|
|
192
|
-
```tsx
|
|
193
|
-
const { field, meta, helpers } = useField({
|
|
194
|
-
name: 'email',
|
|
195
|
-
validate: (value) => !value ? 'Required' : undefined,
|
|
196
|
-
transform: (value) => value.toLowerCase(),
|
|
197
|
-
});
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
### `<TextInput>`
|
|
201
|
-
|
|
202
|
-
Lightweight, accessible text input component.
|
|
203
|
-
|
|
204
|
-
```tsx
|
|
205
|
-
<TextInput
|
|
206
|
-
name="email"
|
|
207
|
-
value={value}
|
|
208
|
-
onChange={onChange}
|
|
209
|
-
onBlur={onBlur}
|
|
210
|
-
type="email"
|
|
211
|
-
placeholder="you@example.com"
|
|
212
|
-
error={hasError}
|
|
213
|
-
disabled={false}
|
|
214
|
-
required={true}
|
|
215
|
-
/>
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
## Validation
|
|
219
|
-
|
|
220
|
-
### Inline Validators
|
|
221
|
-
|
|
222
|
-
```tsx
|
|
223
67
|
const form = useForm({
|
|
224
68
|
initialValues: { email: '' },
|
|
225
69
|
validationSchema: {
|
|
226
|
-
email: (value) =>
|
|
227
|
-
if (!value) return 'Required';
|
|
228
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
229
|
-
return 'Invalid email';
|
|
230
|
-
}
|
|
231
|
-
return undefined;
|
|
232
|
-
},
|
|
233
|
-
},
|
|
234
|
-
onSubmit: async (values) => { /* ... */ },
|
|
235
|
-
});
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
### Async Validation
|
|
239
|
-
|
|
240
|
-
```tsx
|
|
241
|
-
const form = useForm({
|
|
242
|
-
initialValues: { username: '' },
|
|
243
|
-
validationSchema: {
|
|
244
|
-
username: async (value) => {
|
|
245
|
-
const exists = await checkUsernameExists(value);
|
|
246
|
-
return exists ? 'Username taken' : undefined;
|
|
247
|
-
},
|
|
248
|
-
},
|
|
249
|
-
onSubmit: async (values) => { /* ... */ },
|
|
250
|
-
});
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
### Multiple Validators per Field
|
|
254
|
-
|
|
255
|
-
```tsx
|
|
256
|
-
const form = useForm({
|
|
257
|
-
initialValues: { password: '' },
|
|
258
|
-
validationSchema: {
|
|
259
|
-
password: [
|
|
260
|
-
(value) => !value ? 'Required' : undefined,
|
|
261
|
-
(value) => value.length < 8 ? 'Too short' : undefined,
|
|
262
|
-
(value) => !/[A-Z]/.test(value) ? 'Needs uppercase' : undefined,
|
|
263
|
-
],
|
|
264
|
-
},
|
|
265
|
-
onSubmit: async (values) => { /* ... */ },
|
|
266
|
-
});
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
### Valibot Integration
|
|
270
|
-
|
|
271
|
-
```tsx
|
|
272
|
-
import { createValibotSchema } from '@page-speed/forms/validation/valibot';
|
|
273
|
-
import * as v from 'valibot';
|
|
274
|
-
|
|
275
|
-
const schema = v.object({
|
|
276
|
-
email: v.pipe(
|
|
277
|
-
v.string(),
|
|
278
|
-
v.email('Invalid email'),
|
|
279
|
-
v.endsWith('@company.com', 'Must be company email')
|
|
280
|
-
),
|
|
281
|
-
age: v.pipe(
|
|
282
|
-
v.number(),
|
|
283
|
-
v.minValue(18, 'Must be 18+')
|
|
284
|
-
),
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
const form = useForm({
|
|
288
|
-
initialValues: { email: '', age: 0 },
|
|
289
|
-
validationSchema: createValibotSchema(schema),
|
|
290
|
-
onSubmit: async (values) => { /* ... */ },
|
|
291
|
-
});
|
|
292
|
-
```
|
|
293
|
-
|
|
294
|
-
## Advanced Usage
|
|
295
|
-
|
|
296
|
-
### Conditional Validation
|
|
297
|
-
|
|
298
|
-
```tsx
|
|
299
|
-
const form = useForm({
|
|
300
|
-
initialValues: {
|
|
301
|
-
contactMethod: 'email',
|
|
302
|
-
email: '',
|
|
303
|
-
phone: '',
|
|
304
|
-
},
|
|
305
|
-
validationSchema: {
|
|
306
|
-
email: (value, allValues) => {
|
|
307
|
-
if (allValues.contactMethod === 'email' && !value) {
|
|
308
|
-
return 'Email required when email is selected';
|
|
309
|
-
}
|
|
310
|
-
return undefined;
|
|
311
|
-
},
|
|
312
|
-
phone: (value, allValues) => {
|
|
313
|
-
if (allValues.contactMethod === 'phone' && !value) {
|
|
314
|
-
return 'Phone required when phone is selected';
|
|
315
|
-
}
|
|
316
|
-
return undefined;
|
|
317
|
-
},
|
|
318
|
-
},
|
|
319
|
-
onSubmit: async (values) => { /* ... */ },
|
|
320
|
-
});
|
|
321
|
-
```
|
|
322
|
-
|
|
323
|
-
### Dynamic Forms
|
|
324
|
-
|
|
325
|
-
```tsx
|
|
326
|
-
function DynamicForm() {
|
|
327
|
-
const form = useForm({
|
|
328
|
-
initialValues: {
|
|
329
|
-
contacts: [{ name: '', email: '' }],
|
|
330
|
-
},
|
|
331
|
-
onSubmit: async (values) => { /* ... */ },
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
return (
|
|
335
|
-
<Form form={form}>
|
|
336
|
-
{form.values.contacts.map((contact, index) => (
|
|
337
|
-
<div key={index}>
|
|
338
|
-
<Field name={`contacts.${index}.name`}>
|
|
339
|
-
{({ field }) => <TextInput {...field} />}
|
|
340
|
-
</Field>
|
|
341
|
-
<Field name={`contacts.${index}.email`}>
|
|
342
|
-
{({ field }) => <TextInput {...field} type="email" />}
|
|
343
|
-
</Field>
|
|
344
|
-
</div>
|
|
345
|
-
))}
|
|
346
|
-
<button
|
|
347
|
-
type="button"
|
|
348
|
-
onClick={() => {
|
|
349
|
-
form.setFieldValue('contacts', [
|
|
350
|
-
...form.values.contacts,
|
|
351
|
-
{ name: '', email: '' },
|
|
352
|
-
]);
|
|
353
|
-
}}
|
|
354
|
-
>
|
|
355
|
-
Add Contact
|
|
356
|
-
</button>
|
|
357
|
-
</Form>
|
|
358
|
-
);
|
|
359
|
-
}
|
|
360
|
-
```
|
|
361
|
-
|
|
362
|
-
### Form Helpers in onSubmit
|
|
363
|
-
|
|
364
|
-
```tsx
|
|
365
|
-
const form = useForm({
|
|
366
|
-
initialValues: { email: '' },
|
|
367
|
-
onSubmit: async (values, helpers) => {
|
|
368
|
-
try {
|
|
369
|
-
await api.submit(values);
|
|
370
|
-
helpers.resetForm();
|
|
371
|
-
} catch (error) {
|
|
372
|
-
if (error.code === 'EMAIL_EXISTS') {
|
|
373
|
-
helpers.setFieldError('email', 'Email already exists');
|
|
374
|
-
} else {
|
|
375
|
-
helpers.setErrors({ email: 'Unexpected error' });
|
|
376
|
-
}
|
|
377
|
-
}
|
|
70
|
+
email: (value) => value.includes('@') ? undefined : 'Invalid email'
|
|
378
71
|
},
|
|
72
|
+
validateOn: 'onBlur',
|
|
73
|
+
revalidateOn: 'onChange',
|
|
74
|
+
onSubmit: (values) => console.log(values),
|
|
75
|
+
onError: (errors) => console.error(errors),
|
|
76
|
+
debug: true
|
|
379
77
|
});
|
|
380
78
|
```
|
|
381
79
|
|
|
382
|
-
##
|
|
383
|
-
|
|
384
|
-
The library is designed for optimal tree-shaking. Import only what you need:
|
|
385
|
-
|
|
386
|
-
```tsx
|
|
387
|
-
// Import core functionality (13.11 KB)
|
|
388
|
-
import { useForm, Form, Field } from '@page-speed/forms/core';
|
|
389
|
-
|
|
390
|
-
// Import input components separately (502 B)
|
|
391
|
-
import { TextInput } from '@page-speed/forms/inputs';
|
|
392
|
-
|
|
393
|
-
// Import validation adapters separately (392 B)
|
|
394
|
-
import { createValibotSchema } from '@page-speed/forms/validation/valibot';
|
|
395
|
-
|
|
396
|
-
// Or import from main entry point
|
|
397
|
-
import { useForm } from '@page-speed/forms';
|
|
398
|
-
```
|
|
399
|
-
|
|
400
|
-
## Performance
|
|
401
|
-
|
|
402
|
-
The library uses @legendapp/state for optimal performance:
|
|
403
|
-
|
|
404
|
-
- **~1 re-render per change** vs ~10 for traditional hooks
|
|
405
|
-
- **Observable-based state**: Fine-grained reactivity at the field level
|
|
406
|
-
- **No unnecessary re-renders**: Parent form doesn't re-render when child field changes
|
|
407
|
-
- **Efficient validation**: Debounced and memoized by default
|
|
408
|
-
|
|
409
|
-
## Progressive Enhancement
|
|
410
|
-
|
|
411
|
-
Forms work without JavaScript by using native HTML form submission:
|
|
412
|
-
|
|
413
|
-
```tsx
|
|
414
|
-
<Form
|
|
415
|
-
form={form}
|
|
416
|
-
action="/api/endpoint" // Used when JS disabled
|
|
417
|
-
method="post"
|
|
418
|
-
>
|
|
419
|
-
{/* fields */}
|
|
420
|
-
</Form>
|
|
421
|
-
```
|
|
422
|
-
|
|
423
|
-
When JavaScript is available, `onSubmit` handles the submission. When JavaScript is disabled, the native HTML form submission takes over.
|
|
424
|
-
|
|
425
|
-
## Browser Support
|
|
426
|
-
|
|
427
|
-
- Modern browsers (Chrome, Firefox, Safari, Edge)
|
|
428
|
-
- Requires ES2020 support
|
|
429
|
-
- No IE11 support
|
|
80
|
+
## Performance Notes
|
|
430
81
|
|
|
431
|
-
|
|
82
|
+
Performance is a core facet of everything we build at OpenSite AI. The library is optimized for minimal re-renders and efficient form state updates, ensuring your applications remain responsive and fast.
|
|
432
83
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
```tsx
|
|
436
|
-
import type {
|
|
437
|
-
FormValues,
|
|
438
|
-
FormErrors,
|
|
439
|
-
TouchedFields,
|
|
440
|
-
ValidationSchema,
|
|
441
|
-
FieldValidator,
|
|
442
|
-
UseFormOptions,
|
|
443
|
-
UseFormReturn,
|
|
444
|
-
} from '@page-speed/forms/core';
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
## Examples
|
|
84
|
+
## Contributing
|
|
448
85
|
|
|
449
|
-
|
|
450
|
-
- Basic forms with inline validation
|
|
451
|
-
- Valibot schema integration
|
|
452
|
-
- Dynamic forms with conditional fields
|
|
453
|
-
- Progressive enhancement
|
|
454
|
-
- Async validation
|
|
86
|
+
We welcome contributions from the community to enhance OpenSite Page Speed Forms. Please refer to our [GitHub repository](https://github.com/opensite-ai) for guidelines and more information on how to get involved.
|
|
455
87
|
|
|
456
88
|
## License
|
|
457
89
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
## Contributing
|
|
461
|
-
|
|
462
|
-
Contributions welcome! Please read our contributing guidelines first.
|
|
90
|
+
Licensed under the BSD 3-Clause License. See the [LICENSE](./LICENSE) file for details.
|
|
463
91
|
|
|
464
|
-
##
|
|
92
|
+
## Related Projects
|
|
465
93
|
|
|
466
|
-
|
|
467
|
-
- [
|
|
468
|
-
- [
|
|
469
|
-
- [tsup](https://tsup.egoist.dev/) - TypeScript bundler
|
|
94
|
+
- [Domain Extractor](https://github.com/opensite-ai/domain_extractor)
|
|
95
|
+
- [Page Speed Hooks](https://github.com/opensite-ai/page-speed-hooks)
|
|
96
|
+
- Visit [opensite.ai](https://opensite.ai) for more tools and information.
|