@zod-to-form/cli 0.2.3 → 0.2.5

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 ADDED
@@ -0,0 +1,234 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/pradeepmouli/zod-to-form/master/attached_assets/banner.svg" alt="zod-to-form banner" />
3
+ </p>
4
+
5
+ # @zod-to-form/cli
6
+
7
+ Build-time code generator for Zod v4 form components.
8
+
9
+ `@zod-to-form/cli` loads a Zod schema module, walks it via `@zod-to-form/core`, and generates a TSX form component. It can also watch files and generate a paired Next.js server action.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pnpm add -D @zod-to-form/cli zod
15
+ ```
16
+
17
+ ## Requirements
18
+
19
+ - Node.js >= 20
20
+ - Zod v4
21
+
22
+ ## CLI Usage
23
+
24
+ ```bash
25
+ zod-to-form generate --config ./z2f.config.ts --schema ./src/schema.ts --export userSchema
26
+ ```
27
+
28
+ ```bash
29
+ zod-to-form init
30
+ ```
31
+
32
+ Alias: `z2f`.
33
+
34
+ ### Command
35
+
36
+ `zod-to-form generate`
37
+
38
+ Required options:
39
+
40
+ - `--schema <path>`: path to schema module
41
+ - `--export <name>`: named export containing the schema
42
+
43
+ Optional options:
44
+
45
+ - `--mode <mode>`: `submit | auto-save` (default `submit`)
46
+ - `--config <path>`: path to generate config (`.json` or `.ts`) **required**
47
+ - `--out <path>`: output directory or `.tsx` file path
48
+ - `--name <componentName>`: generated component name override
49
+ - `--ui <preset>`: `shadcn | unstyled` (default `shadcn`)
50
+ - `--dry-run`: print generated code to stdout without writing files
51
+ - `--server-action`: generate Next.js server action next to form output
52
+ - `--watch`: watch schema file and regenerate on changes
53
+
54
+ Generation selection/overwrite is now config-driven:
55
+
56
+ - `overwrite`: overwrite existing output files
57
+ - `types`: explicit list of schema exports to generate (used when `--export` is omitted)
58
+ - `include`: wildcard include patterns for schema export names
59
+ - `exclude`: wildcard exclude patterns for schema export names
60
+
61
+ When generating with `--config`, component mapping and generation controls come from the same file.
62
+ Default config discovery order (used by runtime helpers / existing workflows) is still:
63
+
64
+ 1. `z2f.config.ts`
65
+ 2. `component-config.ts`
66
+ 3. `z2f.config.js`
67
+ 4. `component-config.js`
68
+ 5. `z2f.config.json`
69
+ 6. `component-config.json`
70
+
71
+ ### Command
72
+
73
+ `zod-to-form init`
74
+
75
+ Creates `z2f.config.ts` using sensible defaults and introspection of shadcn `components.json` when available.
76
+
77
+ Optional options:
78
+
79
+ - `--out <path>`: output file or directory (default `z2f.config.ts`)
80
+ - `--components <modulePath>`: module path assigned to `components` in generated config (overrides inference)
81
+ - `--force`: overwrite existing config file
82
+ - `--dry-run`: print generated config and skip file writes
83
+ - `--verbose`: print detailed diagnostics for each step
84
+
85
+ Output behavior:
86
+
87
+ - default: concise progress + final summary
88
+ - `--verbose`: adds detailed diagnostics (detected config source/aliases)
89
+
90
+ ## Examples
91
+
92
+ Generate to default output (`<DerivedName>Form.tsx`):
93
+
94
+ ```bash
95
+ zod-to-form generate --schema ./src/user.schema.ts --export userSchema
96
+ ```
97
+
98
+ Generate to specific directory with custom component name:
99
+
100
+ ```bash
101
+ zod-to-form generate \
102
+ --config ./z2f.config.ts \
103
+ --schema ./src/user.schema.ts \
104
+ --export userSchema \
105
+ --out ./src/forms \
106
+ --name UserProfile
107
+ ```
108
+
109
+ Generate in auto-save mode with server action:
110
+
111
+ ```bash
112
+ zod-to-form generate \
113
+ --config ./z2f.config.ts \
114
+ --schema ./src/user.schema.ts \
115
+ --export userSchema \
116
+ --mode auto-save \
117
+ --server-action
118
+ ```
119
+
120
+ Dry run to inspect generated output:
121
+
122
+ ```bash
123
+ zod-to-form generate --config ./z2f.config.ts --schema ./src/user.schema.ts --export userSchema --dry-run
124
+ ```
125
+
126
+ Initialize config with verbose diagnostics:
127
+
128
+ ```bash
129
+ zod-to-form init --verbose
130
+ ```
131
+
132
+ Initialize config with explicit components module path:
133
+
134
+ ```bash
135
+ zod-to-form init --components ../../src/components/zod-form-components
136
+ ```
137
+
138
+ ## Type-Safe Component Config
139
+
140
+ The package exports helpers to define and validate component config.
141
+
142
+ ### `defineComponentConfig(...)`
143
+
144
+ `defineComponentConfig` gives type-safe field path support (including array path normalization).
145
+
146
+ ```ts
147
+ import { defineComponentConfig } from '@zod-to-form/cli';
148
+
149
+ type Values = {
150
+ profile: { bio: string };
151
+ tags: Array<{ label: string }>;
152
+ };
153
+
154
+ type Components = {
155
+ TextInput: unknown;
156
+ TextareaInput: unknown;
157
+ };
158
+
159
+ export default defineComponentConfig<Components, Values>({
160
+ components: '@/components/form-components',
161
+ overwrite: true,
162
+ types: ['userSchema'],
163
+ include: ['*Schema'],
164
+ exclude: ['Internal*'],
165
+ formPrimitives: {
166
+ field: 'Field',
167
+ label: 'FieldLabel',
168
+ control: 'FieldControl'
169
+ },
170
+ fieldTypes: {
171
+ Input: { component: 'TextInput' },
172
+ textarea: { component: 'TextareaInput' }
173
+ },
174
+ fields: {
175
+ 'profile.bio': { fieldType: 'textarea', props: { rows: 5 } },
176
+ 'tags[].label': { fieldType: 'Input' }
177
+ }
178
+ });
179
+ ```
180
+
181
+ `formPrimitives` is optional. When provided, generated fields use those wrappers instead of raw `div`/`label` markup.
182
+
183
+ Common examples:
184
+
185
+ ```ts
186
+ formPrimitives: {
187
+ field: 'Field',
188
+ label: 'FieldLabel',
189
+ control: 'FieldControl'
190
+ }
191
+ ```
192
+
193
+ ```ts
194
+ formPrimitives: {
195
+ field: 'FormField',
196
+ label: 'FormLabel',
197
+ control: 'FormControl'
198
+ }
199
+ ```
200
+
201
+ ### `validateComponentConfig(...)`
202
+
203
+ Use at runtime when loading external config objects.
204
+
205
+ ```ts
206
+ import { validateComponentConfig } from '@zod-to-form/cli';
207
+
208
+ const parsed = validateComponentConfig(configObject, 'component-config');
209
+ ```
210
+
211
+ ## Programmatic API
212
+
213
+ ### `runGenerate(options)`
214
+
215
+ Runs generation and returns:
216
+
217
+ - `outputPath`
218
+ - `code`
219
+ - `wroteFile`
220
+ - `actionPath` and `actionCode` (when `serverAction` enabled)
221
+
222
+ ### `createProgram()`
223
+
224
+ Returns Commander program instance for embedding or custom CLIs.
225
+
226
+ ## Development
227
+
228
+ From repository root:
229
+
230
+ ```bash
231
+ pnpm --filter @zod-to-form/cli run build
232
+ pnpm --filter @zod-to-form/cli run test
233
+ pnpm --filter @zod-to-form/cli run type-check
234
+ ```
package/dist/codegen.d.ts CHANGED
@@ -1,11 +1,19 @@
1
1
  import type { FormField } from '@zod-to-form/core';
2
+ import type { ComponentEntry, FieldOverride, ZodToFormComponentConfig } from './index.js';
2
3
  export type CodegenConfig = {
3
4
  schemaPath: string;
4
5
  exportName: string;
5
6
  outputPath: string;
6
7
  componentName: string;
8
+ mode: 'submit' | 'auto-save';
9
+ componentConfig?: ZodToFormComponentConfig<Record<string, unknown>>;
7
10
  ui: 'shadcn' | 'unstyled';
8
11
  serverAction: boolean;
9
12
  };
13
+ export declare function resolveFieldMapping<TComponents extends Record<string, unknown>>(fieldKey: string, fieldType: string | undefined, componentConfig: ZodToFormComponentConfig<TComponents> | undefined): {
14
+ entry?: ComponentEntry<TComponents>;
15
+ override?: FieldOverride;
16
+ source: 'fields' | 'fieldTypes' | 'none';
17
+ };
10
18
  export declare function generateFormComponent(fields: FormField[], config: CodegenConfig): Promise<string>;
11
19
  //# sourceMappingURL=codegen.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"codegen.d.ts","sourceRoot":"","sources":["../src/codegen.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAGnD,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,EAAE,EAAE,QAAQ,GAAG,UAAU,CAAC;IAC1B,YAAY,EAAE,OAAO,CAAC;CACvB,CAAC;AAuGF,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,SAAS,EAAE,EACnB,MAAM,EAAE,aAAa,GACpB,OAAO,CAAC,MAAM,CAAC,CAuCjB"}
1
+ {"version":3,"file":"codegen.d.ts","sourceRoot":"","sources":["../src/codegen.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,KAAK,EACV,cAAc,EACd,aAAa,EAEb,wBAAwB,EACzB,MAAM,YAAY,CAAC;AAGpB,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,QAAQ,GAAG,WAAW,CAAC;IAC7B,eAAe,CAAC,EAAE,wBAAwB,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACpE,EAAE,EAAE,QAAQ,GAAG,UAAU,CAAC;IAC1B,YAAY,EAAE,OAAO,CAAC;CACvB,CAAC;AAyJF,wBAAgB,mBAAmB,CAAC,WAAW,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7E,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,eAAe,EAAE,wBAAwB,CAAC,WAAW,CAAC,GAAG,SAAS,GACjE;IACD,KAAK,CAAC,EAAE,cAAc,CAAC,WAAW,CAAC,CAAC;IACpC,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,MAAM,EAAE,QAAQ,GAAG,YAAY,GAAG,MAAM,CAAC;CAC1C,CAuBA;AAkOD,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,SAAS,EAAE,EACnB,MAAM,EAAE,aAAa,GACpB,OAAO,CAAC,MAAM,CAAC,CA6FjB"}
package/dist/codegen.js CHANGED
@@ -1,13 +1,146 @@
1
1
  import path from 'node:path';
2
2
  import { getFileHeader, renderField } from './templates.js';
3
+ function renderLiteralProp(value) {
4
+ if (typeof value === 'string') {
5
+ return `"${value.replace(/"/g, '\\"')}"`;
6
+ }
7
+ if (typeof value === 'number' || typeof value === 'boolean') {
8
+ return `{${String(value)}}`;
9
+ }
10
+ return undefined;
11
+ }
12
+ function renderOverrideProps(props) {
13
+ if (!props) {
14
+ return '';
15
+ }
16
+ const attrs = Object.entries(props)
17
+ .map(([key, value]) => {
18
+ const rendered = renderLiteralProp(value);
19
+ return rendered ? ` ${key}=${rendered}` : '';
20
+ })
21
+ .join('');
22
+ return attrs;
23
+ }
24
+ function getMappedFieldComponent(field, componentConfig) {
25
+ const mapping = resolveFieldMapping(field.key, field.component, componentConfig);
26
+ if (!mapping.entry) {
27
+ return { source: mapping.source };
28
+ }
29
+ return {
30
+ componentName: mapping.entry.component,
31
+ override: mapping.override,
32
+ source: mapping.source
33
+ };
34
+ }
35
+ function collectMappedComponentNames(fields, componentConfig, out = new Set()) {
36
+ for (const field of fields) {
37
+ const mapping = getMappedFieldComponent(field, componentConfig);
38
+ if (mapping.componentName) {
39
+ out.add(mapping.componentName);
40
+ }
41
+ if (field.children?.length) {
42
+ collectMappedComponentNames(field.children, componentConfig, out);
43
+ }
44
+ if (field.arrayItem) {
45
+ collectMappedComponentNames([field.arrayItem], componentConfig, out);
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+ function collectFormPrimitiveNames(primitives) {
51
+ const names = new Set();
52
+ if (!primitives) {
53
+ return names;
54
+ }
55
+ if (primitives.field) {
56
+ names.add(primitives.field);
57
+ }
58
+ if (primitives.label) {
59
+ names.add(primitives.label);
60
+ }
61
+ if (primitives.control) {
62
+ names.add(primitives.control);
63
+ }
64
+ return names;
65
+ }
66
+ function renderFieldContainer(field, content, indent, primitives) {
67
+ const styleAttr = field.gridColumn ? ` style={{ gridColumn: '${field.gridColumn}' }}` : '';
68
+ const fieldTag = primitives?.field;
69
+ const labelTag = primitives?.label;
70
+ const controlTag = primitives?.control;
71
+ if (!fieldTag && !labelTag && !controlTag) {
72
+ return [
73
+ `${indent}<div${styleAttr}>`,
74
+ `${indent} <label htmlFor="${field.key}">${field.label}</label>`,
75
+ `${indent} ${content}`,
76
+ `${indent}</div>`
77
+ ].join('\n');
78
+ }
79
+ const openField = fieldTag ? `<${fieldTag}${styleAttr}>` : `<div${styleAttr}>`;
80
+ const closeField = fieldTag ? `</${fieldTag}>` : `</div>`;
81
+ const openLabel = labelTag
82
+ ? `<${labelTag} htmlFor="${field.key}">`
83
+ : `<label htmlFor="${field.key}">`;
84
+ const closeLabel = labelTag ? `</${labelTag}>` : `</label>`;
85
+ if (!controlTag) {
86
+ return [
87
+ `${indent}${openField}`,
88
+ `${indent} ${openLabel}${field.label}${closeLabel}`,
89
+ `${indent} ${content}`,
90
+ `${indent}${closeField}`
91
+ ].join('\n');
92
+ }
93
+ return [
94
+ `${indent}${openField}`,
95
+ `${indent} ${openLabel}${field.label}${closeLabel}`,
96
+ `${indent} <${controlTag}>`,
97
+ `${indent} ${content}`,
98
+ `${indent} </${controlTag}>`,
99
+ `${indent}${closeField}`
100
+ ].join('\n');
101
+ }
102
+ /**
103
+ * Normalise a concrete field key (e.g. `attributes.0.typeCall.type` or
104
+ * `attributes.${index}.typeCall.type`) to the bracket notation used in
105
+ * the user-facing `fields` config (e.g. `attributes[].typeCall.type`).
106
+ */
107
+ function normalizeFieldKey(key) {
108
+ // Replace `.0.` or `.${index}.` segments with `[].`
109
+ let result = key.replace(/\.(?:0|\$\{index\})\./g, '[].');
110
+ // Replace trailing `.0` or `.${index}`
111
+ result = result.replace(/\.(?:0|\$\{index\})$/, '[]');
112
+ return result;
113
+ }
114
+ export function resolveFieldMapping(fieldKey, fieldType, componentConfig) {
115
+ if (!componentConfig) {
116
+ return { source: 'none' };
117
+ }
118
+ // Try exact match first, then normalised bracket-notation match
119
+ const override = componentConfig.fields?.[fieldKey] ?? componentConfig.fields?.[normalizeFieldKey(fieldKey)];
120
+ if (override) {
121
+ return {
122
+ entry: override.fieldType ? componentConfig.fieldTypes[override.fieldType] : undefined,
123
+ override,
124
+ source: 'fields'
125
+ };
126
+ }
127
+ if (fieldType && componentConfig.fieldTypes[fieldType]) {
128
+ return {
129
+ entry: componentConfig.fieldTypes[fieldType],
130
+ source: 'fieldTypes'
131
+ };
132
+ }
133
+ return { source: 'none' };
134
+ }
3
135
  function getSchemaImportPath(config) {
4
136
  const relative = path
5
137
  .relative(path.dirname(config.outputPath), config.schemaPath)
6
138
  .replace(/\\/g, '/');
7
- if (relative.startsWith('.')) {
8
- return relative;
9
- }
10
- return `./${relative}`;
139
+ const withDot = relative.startsWith('.') ? relative : `./${relative}`;
140
+ return withDot
141
+ .replace(/\.mts$/i, '.mjs')
142
+ .replace(/\.cts$/i, '.cjs')
143
+ .replace(/\.tsx?$/i, '.js');
11
144
  }
12
145
  /** Convert a field key to a safe camelCase variable prefix (e.g. 'address.street' → 'addressStreet') */
13
146
  function toVarName(key) {
@@ -17,7 +150,7 @@ function toVarName(key) {
17
150
  function collectArrayFields(fields) {
18
151
  const result = [];
19
152
  for (const field of fields) {
20
- if (field.component === 'ArrayField') {
153
+ if (field.component === 'ArrayField' && !field.key.includes('.0.')) {
21
154
  result.push(field);
22
155
  }
23
156
  if (field.component === 'Fieldset' && field.children) {
@@ -26,9 +159,74 @@ function collectArrayFields(fields) {
26
159
  }
27
160
  return result;
28
161
  }
29
- function renderNestedBlock(field, indent) {
162
+ function replaceArrayIndexToken(key, arrayKey) {
163
+ const prefix = `${arrayKey}.0`;
164
+ if (key === prefix) {
165
+ return `${arrayKey}.${'${index}'}`;
166
+ }
167
+ if (key.startsWith(`${prefix}.`)) {
168
+ return `${arrayKey}.${'${index}'}.${key.slice(prefix.length + 1)}`;
169
+ }
170
+ return key;
171
+ }
172
+ function cloneFieldWithArrayIndex(field, arrayKey) {
173
+ return {
174
+ ...field,
175
+ key: replaceArrayIndexToken(field.key, arrayKey),
176
+ children: field.children?.map((child) => cloneFieldWithArrayIndex(child, arrayKey)),
177
+ arrayItem: field.arrayItem ? cloneFieldWithArrayIndex(field.arrayItem, arrayKey) : undefined
178
+ };
179
+ }
180
+ function getObjectPropertyName(path) {
181
+ const lastSegment = path.split('.').at(-1) ?? path;
182
+ return JSON.stringify(lastSegment);
183
+ }
184
+ function getDefaultArrayItemExpression(field) {
185
+ if (!field) {
186
+ return `''`;
187
+ }
188
+ if (field.defaultValue !== undefined) {
189
+ return JSON.stringify(field.defaultValue);
190
+ }
191
+ if (field.options && field.options.length > 0) {
192
+ return JSON.stringify(field.options[0].value);
193
+ }
194
+ if (field.component === 'Checkbox' || field.component === 'Switch') {
195
+ return 'false';
196
+ }
197
+ if (field.component === 'Input') {
198
+ const inputType = typeof field.props['type'] === 'string' ? field.props['type'] : 'text';
199
+ if (inputType === 'number') {
200
+ return '0';
201
+ }
202
+ if (inputType === 'checkbox') {
203
+ return 'false';
204
+ }
205
+ }
206
+ if (field.component === 'Fieldset') {
207
+ const children = field.children ?? [];
208
+ if (children.length === 0) {
209
+ return '{}';
210
+ }
211
+ const entries = children
212
+ .map((child) => `${getObjectPropertyName(child.key)}: ${getDefaultArrayItemExpression(child)}`)
213
+ .join(', ');
214
+ return `{ ${entries} }`;
215
+ }
216
+ if (field.component === 'ArrayField') {
217
+ return '[]';
218
+ }
219
+ if (field.zodType === 'number' || field.zodType === 'bigint') {
220
+ return '0';
221
+ }
222
+ if (field.zodType === 'boolean') {
223
+ return 'false';
224
+ }
225
+ return `''`;
226
+ }
227
+ function renderNestedBlock(field, componentConfig, primitives, indent) {
30
228
  const children = (field.children ?? [])
31
- .map((child) => renderFieldBlock(child, `${indent} `))
229
+ .map((child) => renderFieldBlockWithConfig(child, componentConfig, primitives, `${indent} `))
32
230
  .join('\n');
33
231
  return [
34
232
  `${indent}<div>`,
@@ -40,29 +238,44 @@ function renderNestedBlock(field, indent) {
40
238
  `${indent}</div>`
41
239
  ].join('\n');
42
240
  }
43
- function renderArrayBlock(field, indent) {
241
+ function renderArrayBlock(field, componentConfig, primitives, indent) {
242
+ if (field.key.includes('${')) {
243
+ return [
244
+ `${indent}<div>`,
245
+ `${indent} <label>${field.label}</label>`,
246
+ `${indent} <p>Nested array editing is not auto-generated for dynamic paths. Use a custom renderer for ${field.key}.</p>`,
247
+ `${indent}</div>`
248
+ ].join('\n');
249
+ }
44
250
  const varName = toVarName(field.key);
45
251
  const itemField = field.arrayItem;
46
- const itemJsx = itemField
47
- ? renderField({ ...itemField, key: `\${${varName}Fields[index].id}` })
48
- : `<input {...register(\`${field.key}.\${index}\`)} />`;
252
+ const indexedItemField = itemField ? cloneFieldWithArrayIndex(itemField, field.key) : undefined;
253
+ const mappedItem = indexedItemField
254
+ ? getMappedFieldComponent(indexedItemField, componentConfig)
255
+ : { source: 'none' };
256
+ const itemJsx = indexedItemField
257
+ ? mappedItem.componentName
258
+ ? `<${mappedItem.componentName} {...register(\`${indexedItemField.key}\`)}${renderOverrideProps(mappedItem.override?.props)} />`
259
+ : renderFieldBlockWithConfig(indexedItemField, componentConfig, primitives, `${indent} `)
260
+ : `${indent} <input {...register(\`${field.key}.\${index}\`)} />`;
49
261
  return [
50
262
  `${indent}<div>`,
51
263
  `${indent} <label>${field.label}</label>`,
52
264
  `${indent} {${varName}Fields.map((item, index) => (`,
53
265
  `${indent} <div key={item.id}>`,
54
- `${indent} ${itemJsx.replace(new RegExp(`register\\('${field.key}\\.0'\\)`), `register(\`${field.key}.\${index}\`)`)}`,
266
+ itemJsx,
55
267
  `${indent} <button type="button" onClick={() => remove${capitalize(varName)}(index)}>Remove</button>`,
56
268
  `${indent} </div>`,
57
269
  `${indent} ))}`,
58
- `${indent} <button type="button" onClick={() => append${capitalize(varName)}('')}>Add</button>`,
270
+ `${indent} <button type="button" onClick={() => append${capitalize(varName)}(${getDefaultArrayItemExpression(itemField)})}>Add</button>`,
59
271
  `${indent}</div>`
60
272
  ].join('\n');
61
273
  }
62
274
  function capitalize(s) {
63
275
  return s.charAt(0).toUpperCase() + s.slice(1);
64
276
  }
65
- function renderFieldBlock(field, indent = ' ') {
277
+ function renderFieldBlockWithConfig(field, componentConfig, primitives, indent = ' ') {
278
+ const mapping = getMappedFieldComponent(field, componentConfig);
66
279
  if (field.hasCustomRender) {
67
280
  const styleAttr = field.gridColumn ? ` style={{ gridColumn: '${field.gridColumn}' }}` : '';
68
281
  return [
@@ -72,51 +285,92 @@ function renderFieldBlock(field, indent = ' ') {
72
285
  `${indent}</div>`
73
286
  ].join('\n');
74
287
  }
288
+ // If a fields override maps this nested/array field to a custom component,
289
+ // render that component instead of expanding children.
290
+ if (mapping.source === 'fields' && mapping.componentName) {
291
+ const overrideProps = renderOverrideProps(mapping.override?.props);
292
+ const regExpr = field.key.includes('${') ? `register(\`${field.key}\`)` : `register('${field.key}')`;
293
+ const content = `<${mapping.componentName} id="${field.key}" {...${regExpr}}${overrideProps} />`;
294
+ return renderFieldContainer(field, content, indent, primitives);
295
+ }
75
296
  if (field.component === 'Fieldset') {
76
- return renderNestedBlock(field, indent);
297
+ return renderNestedBlock(field, componentConfig, primitives, indent);
77
298
  }
78
299
  if (field.component === 'ArrayField') {
79
- return renderArrayBlock(field, indent);
300
+ return renderArrayBlock(field, componentConfig, primitives, indent);
80
301
  }
81
- const styleAttr = field.gridColumn ? ` style={{ gridColumn: '${field.gridColumn}' }}` : '';
82
- return [
83
- `${indent}<div${styleAttr}>`,
84
- `${indent} <label htmlFor="${field.key}">${field.label}</label>`,
85
- `${indent} ${renderField(field)}`,
86
- `${indent}</div>`
87
- ].join('\n');
302
+ if (mapping.componentName) {
303
+ const overrideProps = renderOverrideProps(mapping.override?.props);
304
+ const content = `<${mapping.componentName} id="${field.key}" {...${field.key.includes('${') ? `register(\`${field.key}\`)` : `register('${field.key}')`}}${overrideProps} />`;
305
+ return renderFieldContainer(field, content, indent, primitives);
306
+ }
307
+ return renderFieldContainer(field, renderField(field), indent, primitives);
88
308
  }
89
309
  export async function generateFormComponent(fields, config) {
90
310
  const schemaImportPath = getSchemaImportPath(config);
91
311
  const arrayFields = collectArrayFields(fields);
92
312
  const hasArrays = arrayFields.length > 0;
93
- const header = getFileHeader(schemaImportPath, config.exportName, hasArrays);
94
- const body = fields.map((field) => renderFieldBlock(field)).join('\n');
313
+ const formPrimitives = config.componentConfig?.formPrimitives;
314
+ const mappedComponents = collectMappedComponentNames(fields, config.componentConfig);
315
+ const primitiveComponents = collectFormPrimitiveNames(formPrimitives);
316
+ const importNames = new Set([...mappedComponents, ...primitiveComponents]);
317
+ const componentImportLine = config.componentConfig && importNames.size > 0
318
+ ? `import { ${Array.from(importNames).sort().join(', ')} } from '${config.componentConfig.components}';`
319
+ : undefined;
320
+ const header = getFileHeader(schemaImportPath, config.exportName, hasArrays, config.mode, componentImportLine);
321
+ const body = fields
322
+ .map((field) => renderFieldBlockWithConfig(field, config.componentConfig, formPrimitives, ' '))
323
+ .join('\n');
95
324
  // useFieldArray hook declarations
96
325
  const arrayHooks = arrayFields
97
326
  .map((f) => {
98
327
  const varName = toVarName(f.key);
99
- return ` const { fields: ${varName}Fields, append: append${capitalize(varName)}, remove: remove${capitalize(varName)} } = useFieldArray({ control, name: '${f.key}' });`;
328
+ return ` const { fields: ${varName}Fields, append: append${capitalize(varName)}, remove: remove${capitalize(varName)} } = useFieldArray<FormData, '${f.key}'>({ control, name: '${f.key}' });`;
100
329
  })
101
330
  .join('\n');
102
- const useFormDestructure = hasArrays
103
- ? `{ register, handleSubmit, control }`
104
- : `{ register, handleSubmit }`;
331
+ const useFormDestructure = config.mode === 'auto-save'
332
+ ? hasArrays
333
+ ? `{ register, watch, control }`
334
+ : `{ register, watch }`
335
+ : hasArrays
336
+ ? `{ register, handleSubmit, control }`
337
+ : `{ register, handleSubmit }`;
338
+ const propsLines = config.mode === 'auto-save'
339
+ ? [` onValueChange?: (data: FormData) => void;`, ` onSubmit?: (data: FormData) => void;`]
340
+ : [` onSubmit: (data: FormData) => void;`];
341
+ const autoSaveEffect = config.mode === 'auto-save'
342
+ ? [
343
+ ` useEffect(() => {`,
344
+ ` const subscription = watch((values) => {`,
345
+ ` props.onValueChange?.(values as FormData);`,
346
+ ` });`,
347
+ ``,
348
+ ` return () => subscription.unsubscribe();`,
349
+ ` }, [watch, props.onValueChange]);`,
350
+ ``
351
+ ]
352
+ : [];
353
+ const formOpen = config.mode === 'auto-save'
354
+ ? ` <form>`
355
+ : ` <form onSubmit={handleSubmit(props.onSubmit)}>`;
356
+ const formTail = config.mode === 'auto-save' ? [] : [` <button type="submit">Submit</button>`];
105
357
  return [
106
358
  header,
107
359
  '',
108
360
  `export function ${config.componentName}(props: {`,
109
- ` onSubmit: (data: FormData) => void;`,
361
+ ...propsLines,
110
362
  `}) {`,
111
363
  ` const ${useFormDestructure} = useForm<FormData>({`,
112
- ` resolver: zodResolver(${config.exportName})`,
364
+ ` resolver: zodResolver(${config.exportName}),`,
365
+ ...(config.mode === 'auto-save' ? [` mode: 'onChange'`] : []),
113
366
  ` });`,
114
367
  ...(hasArrays ? [arrayHooks] : []),
368
+ ...autoSaveEffect,
115
369
  '',
116
370
  ` return (`,
117
- ` <form onSubmit={handleSubmit(props.onSubmit)}>`,
371
+ formOpen,
118
372
  body,
119
- ` <button type="submit">Submit</button>`,
373
+ ...formTail,
120
374
  ` </form>`,
121
375
  ` );`,
122
376
  `}`