@omnifyjp/omnify 3.12.5 → 3.13.1
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/package.json +6 -6
- package/ts-dist/cli.d.ts +13 -0
- package/ts-dist/cli.js +83 -33
- package/ts-dist/generator.js +101 -1
- package/ts-dist/input-resolver.d.ts +84 -0
- package/ts-dist/input-resolver.js +246 -0
- package/ts-dist/metadata-generator.d.ts +76 -0
- package/ts-dist/metadata-generator.js +329 -0
- package/ts-dist/payload-builder-generator.d.ts +59 -0
- package/ts-dist/payload-builder-generator.js +339 -0
- package/ts-dist/zod-generator.d.ts +1 -1
- package/ts-dist/zod-generator.js +68 -1
- package/types/config.d.ts +14 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @omnify/ts — Payload Builder Generator (issue omnify-jp/omnify-go#55)
|
|
3
|
+
*
|
|
4
|
+
* Emits per-model form-state interfaces and payload-builder helpers
|
|
5
|
+
* alongside the existing zod schemas. Translatable fields are typed as
|
|
6
|
+
* `Record<Locale, string>` in the form state (matching the design
|
|
7
|
+
* system's `<Input translatable />` component shape) and the builder
|
|
8
|
+
* collapses them into the nested locale-keyed payload that the create /
|
|
9
|
+
* update zod schemas accept (see issue #53).
|
|
10
|
+
*
|
|
11
|
+
* Output (added to base/<Model>.ts):
|
|
12
|
+
*
|
|
13
|
+
* export interface <Model>CreateFormState { ... }
|
|
14
|
+
* export interface <Model>UpdateFormState { ... }
|
|
15
|
+
* export function empty<Model>CreateForm() { ... }
|
|
16
|
+
* export function build<Model>CreatePayload(form) { ... }
|
|
17
|
+
* export function build<Model>UpdatePayload(form) { ... }
|
|
18
|
+
*
|
|
19
|
+
* The shared `buildI18nPayload` utility is emitted exactly once at the
|
|
20
|
+
* codegen output root in `payload-helpers.ts` so per-model files can
|
|
21
|
+
* import it.
|
|
22
|
+
*/
|
|
23
|
+
import type { GeneratorOptions, SchemaDefinition } from './types.js';
|
|
24
|
+
import { type MetadataFieldType } from './metadata-generator.js';
|
|
25
|
+
/** A single field as seen by the form-state / payload builder. */
|
|
26
|
+
export interface FormField {
|
|
27
|
+
/** Field key as it appears on the wire (snake_case, with `_id` suffix on FKs). */
|
|
28
|
+
fieldName: string;
|
|
29
|
+
/** TypeScript primitive used in the form-state interface. */
|
|
30
|
+
tsType: string;
|
|
31
|
+
/** Coarse type taxonomy from the metadata generator. */
|
|
32
|
+
metaType: MetadataFieldType;
|
|
33
|
+
/** Whether the YAML declared the field translatable. */
|
|
34
|
+
translatable: boolean;
|
|
35
|
+
/** Whether the field is required by the create schema. */
|
|
36
|
+
required: boolean;
|
|
37
|
+
/** Whether the YAML declared the field nullable. */
|
|
38
|
+
nullable: boolean;
|
|
39
|
+
/** Default value for `empty<Model>CreateForm()`, as a JS literal string. */
|
|
40
|
+
defaultLiteral: string;
|
|
41
|
+
/**
|
|
42
|
+
* When true, the field is intentionally omitted from the
|
|
43
|
+
* `empty<Model>CreateForm()` factory body. Used for optional enum
|
|
44
|
+
* reference fields where no sensible default exists — the interface
|
|
45
|
+
* marks the field `?:` so omitting the key is valid, and this is
|
|
46
|
+
* safer than emitting `undefined` under `exactOptionalPropertyTypes`.
|
|
47
|
+
* Issue omnify-jp/omnify-go#56.
|
|
48
|
+
*/
|
|
49
|
+
omitFromEmpty?: boolean;
|
|
50
|
+
}
|
|
51
|
+
/** Resolved per-schema form metadata. */
|
|
52
|
+
export interface SchemaFormShape {
|
|
53
|
+
modelName: string;
|
|
54
|
+
fields: FormField[];
|
|
55
|
+
}
|
|
56
|
+
/** Build the form shape IR from a schema. */
|
|
57
|
+
export declare function buildFormShape(schema: SchemaDefinition, allSchemas: Record<string, SchemaDefinition>, options: GeneratorOptions): SchemaFormShape;
|
|
58
|
+
/** Render the full payload-builder section for a schema. */
|
|
59
|
+
export declare function formatPayloadBuilderSection(shape: SchemaFormShape, defaultLocale: string): string;
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @omnify/ts — Payload Builder Generator (issue omnify-jp/omnify-go#55)
|
|
3
|
+
*
|
|
4
|
+
* Emits per-model form-state interfaces and payload-builder helpers
|
|
5
|
+
* alongside the existing zod schemas. Translatable fields are typed as
|
|
6
|
+
* `Record<Locale, string>` in the form state (matching the design
|
|
7
|
+
* system's `<Input translatable />` component shape) and the builder
|
|
8
|
+
* collapses them into the nested locale-keyed payload that the create /
|
|
9
|
+
* update zod schemas accept (see issue #53).
|
|
10
|
+
*
|
|
11
|
+
* Output (added to base/<Model>.ts):
|
|
12
|
+
*
|
|
13
|
+
* export interface <Model>CreateFormState { ... }
|
|
14
|
+
* export interface <Model>UpdateFormState { ... }
|
|
15
|
+
* export function empty<Model>CreateForm() { ... }
|
|
16
|
+
* export function build<Model>CreatePayload(form) { ... }
|
|
17
|
+
* export function build<Model>UpdatePayload(form) { ... }
|
|
18
|
+
*
|
|
19
|
+
* The shared `buildI18nPayload` utility is emitted exactly once at the
|
|
20
|
+
* codegen output root in `payload-helpers.ts` so per-model files can
|
|
21
|
+
* import it.
|
|
22
|
+
*/
|
|
23
|
+
import { toSnakeCase } from './interface-generator.js';
|
|
24
|
+
import { classifyFieldType } from './metadata-generator.js';
|
|
25
|
+
import { toEnumMemberName } from './enum-generator.js';
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Build the IR
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
const PRIMITIVE_TS_DEFAULTS = {
|
|
30
|
+
string: "''",
|
|
31
|
+
text: "''",
|
|
32
|
+
number: '0',
|
|
33
|
+
boolean: 'false',
|
|
34
|
+
date: "''",
|
|
35
|
+
datetime: "''",
|
|
36
|
+
time: "''",
|
|
37
|
+
json: 'null',
|
|
38
|
+
enum: "''",
|
|
39
|
+
uuid: "''",
|
|
40
|
+
file: 'null',
|
|
41
|
+
compound: "''",
|
|
42
|
+
};
|
|
43
|
+
function tsTypeForField(metaType, prop) {
|
|
44
|
+
switch (metaType) {
|
|
45
|
+
case 'string':
|
|
46
|
+
case 'text':
|
|
47
|
+
case 'date':
|
|
48
|
+
case 'datetime':
|
|
49
|
+
case 'time':
|
|
50
|
+
case 'uuid':
|
|
51
|
+
return 'string';
|
|
52
|
+
case 'number':
|
|
53
|
+
return 'number';
|
|
54
|
+
case 'boolean':
|
|
55
|
+
return 'boolean';
|
|
56
|
+
case 'json':
|
|
57
|
+
return 'unknown';
|
|
58
|
+
case 'file':
|
|
59
|
+
return 'unknown';
|
|
60
|
+
case 'enum':
|
|
61
|
+
// Use the enum reference name when available so downstream form
|
|
62
|
+
// components get type narrowing on the union.
|
|
63
|
+
if (typeof prop.enum === 'string')
|
|
64
|
+
return prop.enum;
|
|
65
|
+
if (Array.isArray(prop.enum)) {
|
|
66
|
+
return prop.enum
|
|
67
|
+
.map((v) => `'${typeof v === 'string' ? v : v.value}'`)
|
|
68
|
+
.join(' | ');
|
|
69
|
+
}
|
|
70
|
+
return 'string';
|
|
71
|
+
default:
|
|
72
|
+
return 'string';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Resolve an enum's value list either from a plugin enum bank
|
|
77
|
+
* (customTypes.enums) or from a schema-defined enum (`kind: enum` schemas).
|
|
78
|
+
* Returns undefined if the enum reference can't be resolved at codegen time.
|
|
79
|
+
*/
|
|
80
|
+
function resolveEnumValueList(prop, allSchemas, pluginEnums) {
|
|
81
|
+
if (typeof prop.enum === 'string') {
|
|
82
|
+
// Plugin enum first (customTypes.enums), then schema enum.
|
|
83
|
+
const fromPlugin = pluginEnums[prop.enum];
|
|
84
|
+
if (fromPlugin)
|
|
85
|
+
return fromPlugin;
|
|
86
|
+
const schemaEnum = allSchemas[prop.enum];
|
|
87
|
+
if (schemaEnum?.values && schemaEnum.values.length > 0) {
|
|
88
|
+
return schemaEnum.values.map((v) => v.value);
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(prop.enum)) {
|
|
93
|
+
return prop.enum.map((v) => typeof v === 'string' ? v : v.value);
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
function defaultLiteralForField(metaType, prop, required, allSchemas, pluginEnums) {
|
|
98
|
+
// -----------------------------------------------------------------
|
|
99
|
+
// Enum fields — reference the enum member, not a string literal.
|
|
100
|
+
//
|
|
101
|
+
// `status: 'free'` is not assignable to `TableStatus` under strict
|
|
102
|
+
// TypeScript because enum types are nominal. We must emit
|
|
103
|
+
// `TableStatus.Free` instead. For inline enums (array of strings) the
|
|
104
|
+
// TS type is a literal union, so string literals are fine — that case
|
|
105
|
+
// falls through to the JSON.stringify path at the bottom.
|
|
106
|
+
//
|
|
107
|
+
// Issue omnify-jp/omnify-go#56.
|
|
108
|
+
// -----------------------------------------------------------------
|
|
109
|
+
if (metaType === 'enum' && typeof prop.enum === 'string') {
|
|
110
|
+
const enumName = prop.enum;
|
|
111
|
+
// Optional / nullable enum refs: omit from the factory body entirely.
|
|
112
|
+
// The form-state interface already marks the field `?:`, so skipping
|
|
113
|
+
// the key is a valid object literal under strict TS regardless of
|
|
114
|
+
// `exactOptionalPropertyTypes`.
|
|
115
|
+
if (!required) {
|
|
116
|
+
return { literal: '', omit: true };
|
|
117
|
+
}
|
|
118
|
+
// Pick the default value: honor YAML `default` first, else first
|
|
119
|
+
// declared enum member.
|
|
120
|
+
let rawValue;
|
|
121
|
+
if (prop.default !== undefined && prop.default !== null) {
|
|
122
|
+
rawValue = String(prop.default);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const values = resolveEnumValueList(prop, allSchemas, pluginEnums);
|
|
126
|
+
if (values && values.length > 0) {
|
|
127
|
+
rawValue = values[0];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (rawValue === undefined) {
|
|
131
|
+
// Can't resolve the enum values at codegen time (unlikely, but
|
|
132
|
+
// keeps the generator total). Fall back to omitting — the dev
|
|
133
|
+
// will get a TS error at form init and can fix it manually.
|
|
134
|
+
return { literal: '', omit: true };
|
|
135
|
+
}
|
|
136
|
+
return { literal: `${enumName}.${toEnumMemberName(rawValue)}` };
|
|
137
|
+
}
|
|
138
|
+
// Honor an explicit YAML default when present (non-enum path).
|
|
139
|
+
if (prop.default !== undefined && prop.default !== null) {
|
|
140
|
+
return { literal: JSON.stringify(prop.default) };
|
|
141
|
+
}
|
|
142
|
+
// Inline enum (array form) — the form-state tsType is a literal union,
|
|
143
|
+
// so a string literal is directly assignable. Use the first value.
|
|
144
|
+
if (metaType === 'enum') {
|
|
145
|
+
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
|
146
|
+
const first = prop.enum[0];
|
|
147
|
+
const val = typeof first === 'string' ? first : first.value;
|
|
148
|
+
return { literal: JSON.stringify(val) };
|
|
149
|
+
}
|
|
150
|
+
return { literal: "''" };
|
|
151
|
+
}
|
|
152
|
+
if (metaType === 'boolean')
|
|
153
|
+
return { literal: 'false' };
|
|
154
|
+
return { literal: PRIMITIVE_TS_DEFAULTS[metaType] };
|
|
155
|
+
}
|
|
156
|
+
/** Build the form shape IR from a schema. */
|
|
157
|
+
export function buildFormShape(schema, allSchemas, options) {
|
|
158
|
+
const fields = [];
|
|
159
|
+
if (!schema.properties) {
|
|
160
|
+
return { modelName: schema.name, fields };
|
|
161
|
+
}
|
|
162
|
+
const propNames = schema.propertyOrder ?? Object.keys(schema.properties);
|
|
163
|
+
for (const propName of propNames) {
|
|
164
|
+
const prop = schema.properties[propName];
|
|
165
|
+
if (!prop)
|
|
166
|
+
continue;
|
|
167
|
+
// Skip inverse-side associations — they have no column.
|
|
168
|
+
if (prop.type === 'Association' &&
|
|
169
|
+
(prop.mappedBy || prop.relation === 'OneToMany' ||
|
|
170
|
+
prop.relation === 'ManyToMany' || prop.relation === 'MorphMany' ||
|
|
171
|
+
prop.relation === 'MorphTo' || prop.relation === 'MorphToMany' ||
|
|
172
|
+
prop.relation === 'MorphedByMany')) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
// Skip File for the form-state shape — file uploads have a different
|
|
176
|
+
// lifecycle (multipart form, temp tokens) handled outside the JSON
|
|
177
|
+
// payload builder.
|
|
178
|
+
if (prop.type === 'File')
|
|
179
|
+
continue;
|
|
180
|
+
const metaType = classifyFieldType(prop, allSchemas, options.customTypes.simple);
|
|
181
|
+
const isOwningAssoc = prop.type === 'Association' &&
|
|
182
|
+
(prop.relation === 'ManyToOne' || prop.relation === 'OneToOne') &&
|
|
183
|
+
!prop.mappedBy;
|
|
184
|
+
const fieldName = isOwningAssoc
|
|
185
|
+
? `${toSnakeCase(propName)}_id`
|
|
186
|
+
: toSnakeCase(propName);
|
|
187
|
+
// rules.required overrides nullable inference
|
|
188
|
+
const required = prop.rules?.required === false
|
|
189
|
+
? false
|
|
190
|
+
: prop.rules?.required === true
|
|
191
|
+
? true
|
|
192
|
+
: !(prop.nullable ?? false);
|
|
193
|
+
const { literal, omit } = defaultLiteralForField(metaType, prop, required, allSchemas, options.customTypes.enums);
|
|
194
|
+
fields.push({
|
|
195
|
+
fieldName,
|
|
196
|
+
tsType: tsTypeForField(metaType, prop),
|
|
197
|
+
metaType,
|
|
198
|
+
translatable: prop.translatable === true,
|
|
199
|
+
required,
|
|
200
|
+
nullable: prop.nullable ?? false,
|
|
201
|
+
defaultLiteral: literal,
|
|
202
|
+
omitFromEmpty: omit,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return { modelName: schema.name, fields };
|
|
206
|
+
}
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Format the form-state interfaces + builder helpers
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
/** Render the full payload-builder section for a schema. */
|
|
211
|
+
export function formatPayloadBuilderSection(shape, defaultLocale) {
|
|
212
|
+
const { modelName, fields } = shape;
|
|
213
|
+
if (fields.length === 0)
|
|
214
|
+
return '';
|
|
215
|
+
const parts = [];
|
|
216
|
+
parts.push(`// ============================================================================\n`);
|
|
217
|
+
parts.push(`// Form State + Payload Builders (issue #55)\n`);
|
|
218
|
+
parts.push(`// ============================================================================\n\n`);
|
|
219
|
+
// ---- Create form state interface ----
|
|
220
|
+
parts.push(`/** Form-state shape for creating a ${modelName}. Translatable fields are\n` +
|
|
221
|
+
` * flat per-locale maps; the builder collapses them into the wire shape. */\n`);
|
|
222
|
+
parts.push(`export interface ${modelName}CreateFormState {\n`);
|
|
223
|
+
for (const f of fields) {
|
|
224
|
+
if (f.translatable) {
|
|
225
|
+
parts.push(` ${f.fieldName}: Record<Locale, string>;\n`);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const optional = !f.required;
|
|
229
|
+
parts.push(` ${f.fieldName}${optional ? '?' : ''}: ${f.tsType}${f.nullable ? ' | null' : ''};\n`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
parts.push(`}\n\n`);
|
|
233
|
+
// ---- Update form state interface ----
|
|
234
|
+
parts.push(`/** Form-state shape for updating a ${modelName}. Every field optional. */\n`);
|
|
235
|
+
parts.push(`export interface ${modelName}UpdateFormState {\n`);
|
|
236
|
+
for (const f of fields) {
|
|
237
|
+
if (f.translatable) {
|
|
238
|
+
parts.push(` ${f.fieldName}?: Record<Locale, string>;\n`);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
parts.push(` ${f.fieldName}?: ${f.tsType}${f.nullable ? ' | null' : ''};\n`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
parts.push(`}\n\n`);
|
|
245
|
+
// ---- empty<Model>CreateForm factory ----
|
|
246
|
+
parts.push(`/** Default-state factory for the create form. Pre-fills every locale key. */\n`);
|
|
247
|
+
parts.push(`export function empty${modelName}CreateForm(): ${modelName}CreateFormState {\n`);
|
|
248
|
+
parts.push(` return {\n`);
|
|
249
|
+
for (const f of fields) {
|
|
250
|
+
// Skip fields marked for omission (e.g. optional enum references
|
|
251
|
+
// with no sensible default — the interface marks them `?:`).
|
|
252
|
+
// Issue omnify-jp/omnify-go#56.
|
|
253
|
+
if (f.omitFromEmpty)
|
|
254
|
+
continue;
|
|
255
|
+
if (f.translatable) {
|
|
256
|
+
parts.push(` ${f.fieldName}: emptyLocaleMap(),\n`);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
parts.push(` ${f.fieldName}: ${f.defaultLiteral},\n`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
parts.push(` };\n`);
|
|
263
|
+
parts.push(`}\n\n`);
|
|
264
|
+
// ---- buildCreatePayload ----
|
|
265
|
+
parts.push(`/** Convert create form state into the payload that ` +
|
|
266
|
+
`base${modelName}CreateSchema accepts. */\n`);
|
|
267
|
+
parts.push(`export function build${modelName}CreatePayload(form: ${modelName}CreateFormState): Base${modelName}Create {\n`);
|
|
268
|
+
// Field-by-field assignment.
|
|
269
|
+
parts.push(` const payload: Record<string, unknown> = {\n`);
|
|
270
|
+
for (const f of fields) {
|
|
271
|
+
if (f.translatable) {
|
|
272
|
+
// Top-level mirror = default-locale value, trimmed.
|
|
273
|
+
parts.push(` ${f.fieldName}: (form.${f.fieldName}[${JSON.stringify(defaultLocale)}] ?? '').trim(),\n`);
|
|
274
|
+
}
|
|
275
|
+
else if (f.metaType === 'string' || f.metaType === 'text' || f.metaType === 'uuid') {
|
|
276
|
+
if (f.required) {
|
|
277
|
+
parts.push(` ${f.fieldName}: form.${f.fieldName}.trim(),\n`);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
// Drop empty optional strings.
|
|
281
|
+
parts.push(` ${f.fieldName}: form.${f.fieldName}?.toString().trim() || ${f.nullable ? 'null' : 'undefined'},\n`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
else if (f.metaType === 'date' || f.metaType === 'datetime') {
|
|
285
|
+
// Already a string in form state; pass through.
|
|
286
|
+
parts.push(` ${f.fieldName}: form.${f.fieldName},\n`);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
parts.push(` ${f.fieldName}: form.${f.fieldName},\n`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
parts.push(` };\n\n`);
|
|
293
|
+
// Spread per-locale sub-objects (issue #53 wire shape).
|
|
294
|
+
const translatableFieldNames = fields.filter((f) => f.translatable).map((f) => f.fieldName);
|
|
295
|
+
if (translatableFieldNames.length > 0) {
|
|
296
|
+
parts.push(` // Per-locale sub-objects (matches issue #53 wire shape).\n`);
|
|
297
|
+
parts.push(` const i18n = buildI18nPayload({\n`);
|
|
298
|
+
for (const name of translatableFieldNames) {
|
|
299
|
+
parts.push(` ${name}: form.${name},\n`);
|
|
300
|
+
}
|
|
301
|
+
parts.push(` });\n`);
|
|
302
|
+
parts.push(` Object.assign(payload, i18n);\n\n`);
|
|
303
|
+
}
|
|
304
|
+
parts.push(` return payload as Base${modelName}Create;\n`);
|
|
305
|
+
parts.push(`}\n\n`);
|
|
306
|
+
// ---- buildUpdatePayload ----
|
|
307
|
+
parts.push(`/** Convert update form state into the payload that ` +
|
|
308
|
+
`base${modelName}UpdateSchema accepts. Only fields present on \`form\` are emitted. */\n`);
|
|
309
|
+
parts.push(`export function build${modelName}UpdatePayload(form: ${modelName}UpdateFormState): Base${modelName}Update {\n`);
|
|
310
|
+
parts.push(` const payload: Record<string, unknown> = {};\n`);
|
|
311
|
+
for (const f of fields) {
|
|
312
|
+
if (f.translatable) {
|
|
313
|
+
parts.push(` if (form.${f.fieldName} !== undefined) {\n`);
|
|
314
|
+
parts.push(` payload.${f.fieldName} = (form.${f.fieldName}[${JSON.stringify(defaultLocale)}] ?? '').trim();\n`);
|
|
315
|
+
parts.push(` }\n`);
|
|
316
|
+
}
|
|
317
|
+
else if (f.metaType === 'string' || f.metaType === 'text' || f.metaType === 'uuid') {
|
|
318
|
+
parts.push(` if (form.${f.fieldName} !== undefined) {\n`);
|
|
319
|
+
parts.push(` payload.${f.fieldName} = form.${f.fieldName}?.toString().trim() ?? null;\n`);
|
|
320
|
+
parts.push(` }\n`);
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
parts.push(` if (form.${f.fieldName} !== undefined) {\n`);
|
|
324
|
+
parts.push(` payload.${f.fieldName} = form.${f.fieldName};\n`);
|
|
325
|
+
parts.push(` }\n`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (translatableFieldNames.length > 0) {
|
|
329
|
+
parts.push(`\n // Per-locale sub-objects only for fields the user touched.\n`);
|
|
330
|
+
parts.push(` const localeInput: Record<string, Record<string, string> | undefined> = {};\n`);
|
|
331
|
+
for (const name of translatableFieldNames) {
|
|
332
|
+
parts.push(` if (form.${name} !== undefined) localeInput.${name} = form.${name};\n`);
|
|
333
|
+
}
|
|
334
|
+
parts.push(` Object.assign(payload, buildI18nPayload(localeInput));\n`);
|
|
335
|
+
}
|
|
336
|
+
parts.push(` return payload as Base${modelName}Update;\n`);
|
|
337
|
+
parts.push(`}\n`);
|
|
338
|
+
return parts.join('');
|
|
339
|
+
}
|
|
@@ -25,7 +25,7 @@ export declare function getExcludedFields(schema: SchemaDefinition): {
|
|
|
25
25
|
export declare function formatZodSchemasSection(schemaName: string, zodSchemas: ZodPropertySchema[], displayNames: SchemaDisplayNames, excludedFields: {
|
|
26
26
|
create: Set<string>;
|
|
27
27
|
update: Set<string>;
|
|
28
|
-
}): string;
|
|
28
|
+
}, schema: SchemaDefinition, options: GeneratorOptions): string;
|
|
29
29
|
/**
|
|
30
30
|
* Format user model file with Zod re-exports.
|
|
31
31
|
*/
|
package/ts-dist/zod-generator.js
CHANGED
|
@@ -409,10 +409,69 @@ export function getExcludedFields(schema) {
|
|
|
409
409
|
}
|
|
410
410
|
return { create: createExclude, update: updateExclude };
|
|
411
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* Build the per-locale sub-object lines for a schema's translatable fields.
|
|
414
|
+
*
|
|
415
|
+
* For every property with `translatable: true`, we synthesize a "translation
|
|
416
|
+
* variant" of its zod schema by forcing `nullable: true` and clearing
|
|
417
|
+
* `rules.required`. This means:
|
|
418
|
+
* - `String` translatable fields drop the implicit `.min(1)` (translation
|
|
419
|
+
* rows for non-default locales may legitimately be empty).
|
|
420
|
+
* - `max(N)` / `length(N)` validators are preserved so client and server
|
|
421
|
+
* agree on bounds.
|
|
422
|
+
* - The variant ends in `.optional().nullable()` so missing translations
|
|
423
|
+
* don't break validation.
|
|
424
|
+
*
|
|
425
|
+
* Each locale (from `options.locales`) gets its own sub-object containing
|
|
426
|
+
* only the translatable fields. The sub-object itself is `.optional()` so
|
|
427
|
+
* a payload may omit any locale entirely. Implements omnify-jp/omnify-go#53.
|
|
428
|
+
*
|
|
429
|
+
* Returns the lines (without trailing newlines) ready to splice into the
|
|
430
|
+
* `z.object({ ... })` body of the create schema, or an empty array when
|
|
431
|
+
* the schema has no translatable fields.
|
|
432
|
+
*/
|
|
433
|
+
function buildTranslatableLocaleSchemaLines(schema, options) {
|
|
434
|
+
if (!schema.properties || options.locales.length === 0)
|
|
435
|
+
return [];
|
|
436
|
+
// Collect translatable fields in declaration order so the generated
|
|
437
|
+
// sub-object body matches the rest of the file.
|
|
438
|
+
const translatableProps = [];
|
|
439
|
+
const propNames = schema.propertyOrder ?? Object.keys(schema.properties);
|
|
440
|
+
for (const propName of propNames) {
|
|
441
|
+
const propDef = schema.properties[propName];
|
|
442
|
+
if (!propDef || !propDef.translatable)
|
|
443
|
+
continue;
|
|
444
|
+
// Build the translation variant: nullable, no `required` rule. The
|
|
445
|
+
// existing zod generator handles the rest (max length, regex, etc.).
|
|
446
|
+
const variantDef = {
|
|
447
|
+
...propDef,
|
|
448
|
+
nullable: true,
|
|
449
|
+
rules: propDef.rules ? { ...propDef.rules, required: false } : undefined,
|
|
450
|
+
};
|
|
451
|
+
const variantSchema = getZodSchemaForType(variantDef, propName, options);
|
|
452
|
+
if (!variantSchema)
|
|
453
|
+
continue;
|
|
454
|
+
translatableProps.push({
|
|
455
|
+
fieldName: toSnakeCase(propName),
|
|
456
|
+
schema: variantSchema,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
if (translatableProps.length === 0)
|
|
460
|
+
return [];
|
|
461
|
+
const lines = [];
|
|
462
|
+
for (const locale of options.locales) {
|
|
463
|
+
lines.push(` ${locale}: z.object({`);
|
|
464
|
+
for (const tp of translatableProps) {
|
|
465
|
+
lines.push(` ${tp.fieldName}: ${tp.schema},`);
|
|
466
|
+
}
|
|
467
|
+
lines.push(` }).optional(),`);
|
|
468
|
+
}
|
|
469
|
+
return lines;
|
|
470
|
+
}
|
|
412
471
|
/**
|
|
413
472
|
* Format Zod schemas section for a base file.
|
|
414
473
|
*/
|
|
415
|
-
export function formatZodSchemasSection(schemaName, zodSchemas, displayNames, excludedFields) {
|
|
474
|
+
export function formatZodSchemasSection(schemaName, zodSchemas, displayNames, excludedFields, schema, options) {
|
|
416
475
|
const parts = [];
|
|
417
476
|
const lowerName = schemaName.charAt(0).toLowerCase() + schemaName.slice(1);
|
|
418
477
|
// I18n section
|
|
@@ -451,6 +510,14 @@ export function formatZodSchemasSection(schemaName, zodSchemas, displayNames, ex
|
|
|
451
510
|
for (const prop of createFields) {
|
|
452
511
|
parts.push(` ${prop.fieldName}: base${schemaName}Schemas.${prop.fieldName},\n`);
|
|
453
512
|
}
|
|
513
|
+
// Per-locale sub-objects for translatable fields. The Update schema
|
|
514
|
+
// (which is `.partial()` of Create) inherits these for free, so the
|
|
515
|
+
// partial form correctly types each locale key as optional too.
|
|
516
|
+
// Issue omnify-jp/omnify-go#53.
|
|
517
|
+
const localeLines = buildTranslatableLocaleSchemaLines(schema, options);
|
|
518
|
+
for (const line of localeLines) {
|
|
519
|
+
parts.push(line + '\n');
|
|
520
|
+
}
|
|
454
521
|
parts.push(`});\n\n`);
|
|
455
522
|
// Update Schema
|
|
456
523
|
parts.push(`/** Update schema for ${schemaName} */\n`);
|
package/types/config.d.ts
CHANGED
|
@@ -31,8 +31,10 @@ export interface ConnectionConfig {
|
|
|
31
31
|
export interface CodegenTypeScriptConfig {
|
|
32
32
|
/** Enable TypeScript codegen. Defaults to false. */
|
|
33
33
|
enable: boolean;
|
|
34
|
-
/** Output directory for generated TypeScript model files. */
|
|
35
|
-
modelsPath
|
|
34
|
+
/** Output directory for generated TypeScript model files (legacy alias for `output`). */
|
|
35
|
+
modelsPath?: string;
|
|
36
|
+
/** Output directory for generated TypeScript model files. Preferred over `modelsPath`; symmetrical with the top-level `input` field used by consumer-mode configs. */
|
|
37
|
+
output?: string;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/** Laravel codegen path configuration for a target (model, request, etc.). */
|
|
@@ -107,6 +109,16 @@ export interface PackageConfig {
|
|
|
107
109
|
|
|
108
110
|
/** Main Omnify configuration (omnify.yaml). */
|
|
109
111
|
export interface OmnifyConfig {
|
|
112
|
+
/**
|
|
113
|
+
* Consumer-mode shortcut: path/URL/npm-spec for an upstream `schemas.json`.
|
|
114
|
+
* When set, omnify-ts skips the connections+migrations indirection and
|
|
115
|
+
* uses this value directly. Accepts a local path (relative to this file),
|
|
116
|
+
* an http(s) URL (cached + pinned via `.omnify/input.lock.json`), or a
|
|
117
|
+
* scoped npm package specifier (e.g. `@famgia/dxs-product-schemas/schemas.json`).
|
|
118
|
+
* Frontend / consumer projects should use this; backend projects use
|
|
119
|
+
* `schemasDir` + `connections` instead.
|
|
120
|
+
*/
|
|
121
|
+
input?: string;
|
|
110
122
|
/** Directory containing schema YAML files. */
|
|
111
123
|
schemasDir?: string;
|
|
112
124
|
/** External schema packages to consume. */
|