formcn 1.0.0

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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +294 -0
  3. package/bin/index.js +71 -0
  4. package/generators/form-generator.js +152 -0
  5. package/generators/form-ui-templates.js +183 -0
  6. package/generators/multi-form-generator.js +257 -0
  7. package/generators/schema-generator.js +89 -0
  8. package/package.json +46 -0
  9. package/test/README.md +73 -0
  10. package/test/components.json +22 -0
  11. package/test/eslint.config.js +23 -0
  12. package/test/index.html +13 -0
  13. package/test/package-lock.json +4759 -0
  14. package/test/package.json +46 -0
  15. package/test/public/vite.svg +1 -0
  16. package/test/src/App.css +42 -0
  17. package/test/src/App.tsx +7 -0
  18. package/test/src/assets/react.svg +1 -0
  19. package/test/src/components/ui/button.tsx +62 -0
  20. package/test/src/components/ui/checkbox.tsx +32 -0
  21. package/test/src/components/ui/field.tsx +246 -0
  22. package/test/src/components/ui/input-group.tsx +170 -0
  23. package/test/src/components/ui/input.tsx +21 -0
  24. package/test/src/components/ui/label.tsx +22 -0
  25. package/test/src/components/ui/radio-group.tsx +43 -0
  26. package/test/src/components/ui/select.tsx +188 -0
  27. package/test/src/components/ui/separator.tsx +28 -0
  28. package/test/src/components/ui/textarea.tsx +18 -0
  29. package/test/src/index.css +123 -0
  30. package/test/src/lib/utils.ts +6 -0
  31. package/test/src/main.tsx +10 -0
  32. package/test/tsconfig.app.json +33 -0
  33. package/test/tsconfig.json +13 -0
  34. package/test/tsconfig.node.json +26 -0
  35. package/test/vite.config.ts +14 -0
  36. package/utils/ensurePackages.js +62 -0
  37. package/utils/lib.js +22 -0
  38. package/utils/prompts.js +200 -0
  39. package/utils/tailwind-presets.js +132 -0
  40. package/utils/templates.js +103 -0
  41. package/utils/test.js +136 -0
@@ -0,0 +1,257 @@
1
+ import fs from "fs-extra";
2
+ import path from "path";
3
+ import {
4
+ toCamelCaseForm,
5
+ toKebabCaseForm,
6
+ toPascalCaseForm,
7
+ } from "../utils/lib.js";
8
+ import {
9
+ generateImports,
10
+ getComponentUsage,
11
+ getDefaultValue,
12
+ renderError,
13
+ renderInputByType,
14
+ } from "./form-ui-templates.js";
15
+ import { generateZodSchema } from "./schema-generator.js";
16
+ import { multiFormPresets } from "../utils/tailwind-presets.js";
17
+ import { confirm, isCancel } from "@clack/prompts";
18
+
19
+ export async function generateMultiForm({ formName, steps, presetKey }) {
20
+ if (!Array.isArray(steps)) {
21
+ throw new Error("steps must be an array");
22
+ }
23
+
24
+ const baseDir = fs.existsSync(path.resolve("src"))
25
+ ? path.resolve("src/components/forms")
26
+ : path.resolve("components/forms");
27
+
28
+ const formDir = path.join(baseDir, formName);
29
+
30
+ const exists = await fs.pathExists(formDir);
31
+
32
+ if (exists) {
33
+ const overwrite = await confirm({
34
+ message: `Form "${formName}" already exists. Overwrite it?`,
35
+ initialValue: false,
36
+ });
37
+
38
+ if (isCancel(overwrite) || !overwrite) {
39
+ console.log("❌ Operation cancelled.");
40
+ process.exit(0);
41
+ }
42
+
43
+ await fs.remove(formDir);
44
+ }
45
+
46
+ try {
47
+ await fs.ensureDir(formDir);
48
+
49
+ const stepMetas = [];
50
+
51
+ for (const step of steps) {
52
+ const stepFileName = toCamelCaseForm(step.stepName);
53
+ const stepComponentName = `${toPascalCaseForm(step.stepName)}Step`;
54
+ const schemaName = `${stepFileName}Schema`;
55
+
56
+ await fs.writeFile(
57
+ path.join(formDir, `${toKebabCaseForm(step.stepName)}-schema.ts`),
58
+ generateZodSchema(step.fields, schemaName)
59
+ );
60
+
61
+ const uses = getComponentUsage(step.fields);
62
+
63
+ const stepComponentContent = `
64
+ "use client";
65
+
66
+ ${generateImports(uses, "step")}
67
+ import type {${toPascalCaseForm(
68
+ schemaName
69
+ )}Values } from "./${toKebabCaseForm(step.stepName)}-schema";
70
+
71
+ export default function ${stepComponentName}() {
72
+ const form = useFormContext<${toPascalCaseForm(schemaName)}Values>();
73
+
74
+ return (
75
+ <FieldGroup>
76
+ ${step.fields
77
+ .map((f) => {
78
+ const isCheckbox = f.type === "checkbox";
79
+
80
+ return `
81
+ <Controller
82
+ name="${f.name}"
83
+ control={form.control}
84
+ render={({ field, fieldState }) => (
85
+ ${
86
+ isCheckbox
87
+ ? `
88
+ <>
89
+ ${renderInputByType(f)}
90
+ ${renderError()}
91
+ </>
92
+ `
93
+ : `
94
+ <Field data-invalid={fieldState.invalid}>
95
+ <FieldLabel htmlFor="${f.name}">${f.label}${
96
+ f.required
97
+ ? ' <span className="text-red-500">*</span>'
98
+ : ""
99
+ }
100
+ </FieldLabel>
101
+ ${renderInputByType(f)}
102
+ ${renderError()}
103
+ </Field>
104
+ `
105
+ }
106
+ )}
107
+ />
108
+ `;
109
+ })
110
+ .join("\n")}
111
+ </FieldGroup>
112
+ );
113
+ }
114
+ `.trim();
115
+
116
+ await fs.writeFile(
117
+ path.join(formDir, `${stepFileName}Step.tsx`),
118
+ stepComponentContent
119
+ );
120
+
121
+ stepMetas.push({
122
+ id: stepFileName,
123
+ title: step.stepName,
124
+ component: stepComponentName,
125
+ schema: schemaName,
126
+ fields: step.fields,
127
+ });
128
+ }
129
+
130
+ let formComponentName = toPascalCaseForm(formName);
131
+ if (!formComponentName.endsWith("Form")) {
132
+ formComponentName += "Form";
133
+ }
134
+
135
+ const schemaImports = stepMetas
136
+ .map(
137
+ (s) => `import { ${s.schema} } from "./${toKebabCaseForm(s.schema)}";`
138
+ )
139
+ .join("\n");
140
+
141
+ const stepImports = stepMetas
142
+ .map(
143
+ (s) => `import ${s.component} from "./${toCamelCaseForm(s.title)}Step";`
144
+ )
145
+ .join("\n");
146
+
147
+ const combinedSchema = stepMetas
148
+ .map((s) => s.schema)
149
+ .join(".and(")
150
+ .concat(")".repeat(stepMetas.length - 1));
151
+
152
+ const allFields = stepMetas.flatMap((s) => s.fields);
153
+
154
+ const formUses = getComponentUsage(allFields);
155
+
156
+ const preset = multiFormPresets[presetKey];
157
+ if (!preset)
158
+ throw new Error(`Single form preset "${formPreset}" not found`);
159
+
160
+ const stepperTemplate = preset.stepper;
161
+
162
+ const formContent = `
163
+ "use client";
164
+
165
+ import { useState } from "react";
166
+ import { z } from "zod";
167
+
168
+ ${schemaImports}
169
+ ${stepImports}
170
+ ${generateImports(formUses, "multi")}
171
+
172
+ const fullFormSchema = ${combinedSchema};
173
+ export type FullFormData = z.infer<typeof fullFormSchema>;
174
+
175
+ export default function ${formComponentName}() {
176
+ const [currentStep, setCurrentStep] = useState(0);
177
+
178
+ const form = useForm({
179
+ resolver: zodResolver(fullFormSchema),
180
+ mode: "onChange",
181
+ defaultValues: {
182
+ ${allFields.map(getDefaultValue).join(",\n ")}
183
+ },
184
+ });
185
+
186
+ const steps = ${JSON.stringify(
187
+ stepMetas.map((s) => ({
188
+ id: s.id,
189
+ title: s.title,
190
+ description: s.description,
191
+ })),
192
+ null,
193
+ 2
194
+ )};
195
+
196
+ const stepSchemas = [${stepMetas.map((s) => s.schema).join(", ")}];
197
+ const stepComponents = [${stepMetas.map((s) => s.component).join(", ")}];
198
+
199
+ const CurrentStep = stepComponents[currentStep];
200
+ ${
201
+ presetKey === "minimal" || presetKey === "softType"
202
+ ? `const currentStepData = steps[currentStep];`
203
+ : ""
204
+ }
205
+
206
+ async function next() {
207
+ const schema = stepSchemas[currentStep];
208
+ const fields = Object.keys(schema.shape) as (keyof FullFormData)[];
209
+ const valid = await form.trigger(fields);
210
+ if (valid) setCurrentStep((s) => s + 1);
211
+ }
212
+
213
+ function prev() {
214
+ setCurrentStep((s) => s - 1);
215
+ }
216
+
217
+ function onSubmit(data: FullFormData) {
218
+ console.log("Submitted:", data);
219
+ }
220
+
221
+ return (
222
+ <FormProvider {...form}>
223
+ <form onSubmit={form.handleSubmit(onSubmit)} className="${preset.form}">
224
+ ${stepperTemplate}
225
+ <div className="${preset.step ?? ""}">
226
+ <CurrentStep />
227
+
228
+ <div className="${preset.buttonsWrapper}">
229
+ <Button type="button" onClick={prev} disabled={currentStep === 0}>
230
+ Previous
231
+ </Button>
232
+ {currentStep < steps.length - 1 && (
233
+ <Button type="button" onClick={next}>
234
+ Next
235
+ </Button>
236
+ )}
237
+ {currentStep === steps.length - 1 && <Button type="submit">Submit</Button>}
238
+ </div>
239
+ </div>
240
+ </form>
241
+ </FormProvider>
242
+ );
243
+ }
244
+ `.trim();
245
+
246
+ await fs.writeFile(
247
+ path.join(formDir, `${formComponentName}.tsx`),
248
+ formContent
249
+ );
250
+
251
+ console.log(`✅ Multi-step form generated at ${formDir}`);
252
+ } catch (error) {
253
+ await fs.remove(formDir);
254
+ console.error("❌ Failed to generate multi-step form. Changes reverted.");
255
+ throw error;
256
+ }
257
+ }
@@ -0,0 +1,89 @@
1
+ import { toPascalCaseForm } from "../utils/lib.js";
2
+
3
+ export function generateZodSchema(fields, schemaName = "schema") {
4
+ const getLabel = (field) => field.label || field.name;
5
+
6
+ const TYPE_SCHEMAS = {
7
+ email: (f) =>
8
+ f.required
9
+ ? `z.email("Invalid email")`
10
+ : `z.email("Invalid email").optional().or(z.literal(""))`,
11
+
12
+ password: (f) =>
13
+ f.required
14
+ ? `z.string().min(8, "Password must be at least 8 characters")`
15
+ : `z.string().min(8).optional()`,
16
+
17
+ number: (f) =>
18
+ f.required
19
+ ? `z.number({ error: "${getLabel(f)} is required" })`
20
+ : `z.number().optional()`,
21
+
22
+ phone: (f) =>
23
+ f.required
24
+ ? `z.coerce.number({ error: "${getLabel(f)} is required" })`
25
+ : `z.coerce.number().optional()`,
26
+
27
+ checkbox: (f) =>
28
+ f.required
29
+ ? `z.boolean().refine(val => val === true, "${getLabel(
30
+ f
31
+ )} must be checked")`
32
+ : `z.boolean().optional()`,
33
+
34
+ date: (f) =>
35
+ f.required
36
+ ? `z.string().min(1, "${getLabel(f)} is required")`
37
+ : `z.string().optional()`,
38
+
39
+ select: (f) => {
40
+ const options = f.options
41
+ .map((o) => `z.literal("${o.value}")`)
42
+ .join(", ");
43
+ return f.required
44
+ ? `z.union([${options}], { error: "Please select ${getLabel(f)}" })`
45
+ : `z.union([${options}]).optional()`;
46
+ },
47
+
48
+ url: (f) =>
49
+ f.required
50
+ ? `z.url("Invalid URL")`
51
+ : `z.url("Invalid URL").optional().or(z.literal(""))`,
52
+
53
+ default: (f) =>
54
+ f.required
55
+ ? `z.string().min(1, "${getLabel(f)} is required")`
56
+ : `z.string().optional()`,
57
+ };
58
+
59
+ const getSchema = (field) => {
60
+ if (field.isConfirmation) return TYPE_SCHEMAS.default(field);
61
+ return (TYPE_SCHEMAS[field.type] || TYPE_SCHEMAS.default)(field);
62
+ };
63
+
64
+ const shape = fields.map((f) => `${f.name}: ${getSchema(f)}`).join(",\n");
65
+
66
+ const passwordRefinements = fields
67
+ .filter((f) => f.isConfirmation)
68
+ .map(
69
+ (f) => `
70
+ .refine((data) => data.${f.confirmationFor} === data.${f.name}, {
71
+ message: "Passwords do not match",
72
+ path: ["${f.name}"],
73
+ })`
74
+ )
75
+ .join("");
76
+
77
+ const typeName = `${toPascalCaseForm(schemaName)}Values`;
78
+
79
+ return `
80
+ import { z } from "zod";
81
+
82
+ export const ${schemaName} = z
83
+ .object({
84
+ ${shape},
85
+ })${passwordRefinements};
86
+
87
+ export type ${typeName} = z.infer<typeof ${schemaName}>;
88
+ `.trim();
89
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "formcn",
3
+ "version": "1.0.0",
4
+ "description": "Schema-driven React form generator using React Hook Form, Zod, and shadcn/ui",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "echo \"Error: no test specified\" && exit 1"
9
+ },
10
+ "bin": {
11
+ "formcn": "./bin/index.js"
12
+ },
13
+ "keywords": [
14
+ "react",
15
+ "forms",
16
+ "typescript",
17
+ "react-hook-form",
18
+ "zod",
19
+ "shadcn",
20
+ "cli",
21
+ "generator"
22
+ ],
23
+ "author": "Fares Galal",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/F-47/formcn.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/F-47/formcn/issues"
31
+ },
32
+ "homepage": "https://github.com/F-47/formcn#readme",
33
+ "dependencies": {
34
+ "@clack/prompts": "^0.11.0",
35
+ "@hookform/resolvers": "^5.2.2",
36
+ "chalk": "^5.6.2",
37
+ "chalk-animation": "^2.0.3",
38
+ "commander": "^14.0.2",
39
+ "figlet": "^1.9.4",
40
+ "fs-extra": "^11.3.3",
41
+ "inquirer": "^13.1.0",
42
+ "nanospinner": "^1.2.2",
43
+ "react-hook-form": "^7.69.0",
44
+ "zod": "^4.2.1"
45
+ }
46
+ }
package/test/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17
+
18
+ ```js
19
+ export default defineConfig([
20
+ globalIgnores(['dist']),
21
+ {
22
+ files: ['**/*.{ts,tsx}'],
23
+ extends: [
24
+ // Other configs...
25
+
26
+ // Remove tseslint.configs.recommended and replace with this
27
+ tseslint.configs.recommendedTypeChecked,
28
+ // Alternatively, use this for stricter rules
29
+ tseslint.configs.strictTypeChecked,
30
+ // Optionally, add this for stylistic rules
31
+ tseslint.configs.stylisticTypeChecked,
32
+
33
+ // Other configs...
34
+ ],
35
+ languageOptions: {
36
+ parserOptions: {
37
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
38
+ tsconfigRootDir: import.meta.dirname,
39
+ },
40
+ // other options...
41
+ },
42
+ },
43
+ ])
44
+ ```
45
+
46
+ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47
+
48
+ ```js
49
+ // eslint.config.js
50
+ import reactX from 'eslint-plugin-react-x'
51
+ import reactDom from 'eslint-plugin-react-dom'
52
+
53
+ export default defineConfig([
54
+ globalIgnores(['dist']),
55
+ {
56
+ files: ['**/*.{ts,tsx}'],
57
+ extends: [
58
+ // Other configs...
59
+ // Enable lint rules for React
60
+ reactX.configs['recommended-typescript'],
61
+ // Enable lint rules for React DOM
62
+ reactDom.configs.recommended,
63
+ ],
64
+ languageOptions: {
65
+ parserOptions: {
66
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
67
+ tsconfigRootDir: import.meta.dirname,
68
+ },
69
+ // other options...
70
+ },
71
+ },
72
+ ])
73
+ ```
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/index.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "lucide",
14
+ "aliases": {
15
+ "components": "@/components",
16
+ "utils": "@/lib/utils",
17
+ "ui": "@/components/ui",
18
+ "lib": "@/lib",
19
+ "hooks": "@/hooks"
20
+ },
21
+ "registries": {}
22
+ }
@@ -0,0 +1,23 @@
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ])
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>test</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>