@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 +234 -0
- package/dist/codegen.d.ts +8 -0
- package/dist/codegen.d.ts.map +1 -1
- package/dist/codegen.js +287 -33
- package/dist/codegen.js.map +1 -1
- package/dist/filters.d.ts +4 -0
- package/dist/filters.d.ts.map +1 -0
- package/dist/filters.js +18 -0
- package/dist/filters.js.map +1 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +91 -15
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +17 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +458 -0
- package/dist/init.js.map +1 -0
- package/dist/loader.d.ts +11 -0
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +97 -1
- package/dist/loader.js.map +1 -1
- package/dist/templates.d.ts +1 -1
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +24 -8
- package/dist/templates.js.map +1 -1
- package/package.json +6 -5
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
|
package/dist/codegen.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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)}(
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
94
|
-
const
|
|
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 =
|
|
103
|
-
?
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
+
formOpen,
|
|
118
372
|
body,
|
|
119
|
-
|
|
373
|
+
...formTail,
|
|
120
374
|
` </form>`,
|
|
121
375
|
` );`,
|
|
122
376
|
`}`
|