@obsidiane/meridiane 0.0.13 → 1.0.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/README.md CHANGED
@@ -31,7 +31,11 @@ Options :
31
31
  - `--debug` : active des logs supplémentaires (CLI).
32
32
 
33
33
  Bonnes pratiques :
34
- - utiliser `--preset=native` si vous voulez des modèles “entity-like” (sans variantes `.jsonld/.jsonMergePatch`)
34
+ - utiliser `--preset=native` pour un mode “contract-driven” (LD+JSON) :
35
+ - modèles générés uniquement s’ils sont utilisés par les endpoints
36
+ - groups conservés
37
+ - pas de modèles `*.jsonMergePatch` (PATCH = `Partial<...>`)
38
+ - noms normalisés (pas de suffixe `.jsonld`)
35
39
  - utiliser `--no-models` si vous voulez uniquement le runtime (pas besoin de `--spec`)
36
40
  - laisser le registry à la CI (`.npmrc`, variables d’environnement, `npm publish --registry …`)
37
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obsidiane/meridiane",
3
- "version": "0.0.13",
3
+ "version": "1.0.1",
4
4
  "description": "Générateur de librairie Angular et modèles TypeScript (OpenAPI) pour un bridge Symfony/API Platform + Mercure.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,6 +50,146 @@ function buildNameMap(schemaNames, schemas, preset) {
50
50
  return nameMap;
51
51
  }
52
52
 
53
+ function getContentSchema(content, wantedLower) {
54
+ if (!content || typeof content !== 'object') return undefined;
55
+ for (const [k, v] of Object.entries(content)) {
56
+ if (String(k).toLowerCase() === wantedLower) return v?.schema;
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ function is2xxStatus(code) {
62
+ const s = String(code || '');
63
+ return /^[0-9]{3}$/.test(s) && s.startsWith('2');
64
+ }
65
+
66
+ function collectRefsFromSchema(schema, out) {
67
+ const seen = new Set();
68
+
69
+ function walk(node) {
70
+ if (!node || typeof node !== 'object') return;
71
+ if (seen.has(node)) return;
72
+ seen.add(node);
73
+
74
+ if (node.$ref && typeof node.$ref === 'string') {
75
+ out.add(schemaNameFromRef(node.$ref));
76
+ return;
77
+ }
78
+
79
+ if (Array.isArray(node)) {
80
+ for (const it of node) walk(it);
81
+ return;
82
+ }
83
+
84
+ // Common JSON schema constructs (plus a generic fallback)
85
+ if (node.allOf) walk(node.allOf);
86
+ if (node.oneOf) walk(node.oneOf);
87
+ if (node.anyOf) walk(node.anyOf);
88
+ if (node.items) walk(node.items);
89
+ if (node.properties) walk(node.properties);
90
+ if (node.additionalProperties) walk(node.additionalProperties);
91
+
92
+ for (const v of Object.values(node)) walk(v);
93
+ }
94
+
95
+ walk(schema);
96
+ }
97
+
98
+ function listNativeRootsFromPaths(spec, { mediaType = 'application/ld+json' } = {}) {
99
+ const wantedLower = String(mediaType).toLowerCase();
100
+ const roots = [];
101
+
102
+ const paths = spec?.paths;
103
+ if (!paths || typeof paths !== 'object') return roots;
104
+
105
+ for (const pathItem of Object.values(paths)) {
106
+ if (!pathItem || typeof pathItem !== 'object') continue;
107
+ for (const op of Object.values(pathItem)) {
108
+ if (!op || typeof op !== 'object') continue;
109
+
110
+ // requestBody (LD+JSON only)
111
+ const rbSchema = getContentSchema(op.requestBody?.content, wantedLower);
112
+ if (rbSchema) roots.push(rbSchema);
113
+
114
+ // responses 2xx (LD+JSON only)
115
+ const responses = op.responses;
116
+ if (!responses || typeof responses !== 'object') continue;
117
+ for (const [code, resp] of Object.entries(responses)) {
118
+ if (!is2xxStatus(code)) continue;
119
+ const schema = getContentSchema(resp?.content, wantedLower);
120
+ if (schema) roots.push(schema);
121
+ }
122
+ }
123
+ }
124
+
125
+ return roots;
126
+ }
127
+
128
+ function parseOpenApiSchemaName(originalName) {
129
+ const raw = String(originalName || '');
130
+ const i = raw.indexOf('-');
131
+ let baseRaw = i >= 0 ? raw.slice(0, i) : raw;
132
+ let groupRaw = i >= 0 ? raw.slice(i + 1) : '';
133
+
134
+ let baseVariant = 'none';
135
+ if (/\.jsonld$/i.test(baseRaw)) {
136
+ baseVariant = 'jsonld';
137
+ baseRaw = baseRaw.replace(/\.jsonld$/i, '');
138
+ } else if (/\.multipart$/i.test(baseRaw)) {
139
+ baseVariant = 'multipart';
140
+ baseRaw = baseRaw.replace(/\.multipart$/i, '');
141
+ }
142
+
143
+ let isMergePatch = false;
144
+ if (/\.jsonmergepatch$/i.test(baseRaw)) {
145
+ isMergePatch = true;
146
+ baseRaw = baseRaw.replace(/\.jsonmergepatch$/i, '');
147
+ }
148
+ if (/\.jsonmergepatch$/i.test(groupRaw)) {
149
+ isMergePatch = true;
150
+ groupRaw = groupRaw.replace(/\.jsonmergepatch$/i, '');
151
+ }
152
+
153
+ return {
154
+ originalName: raw,
155
+ basePath: baseRaw,
156
+ groupPath: groupRaw,
157
+ baseVariant,
158
+ isMergePatch,
159
+ key: `${baseRaw.toLowerCase()}|${groupRaw.toLowerCase()}`,
160
+ };
161
+ }
162
+
163
+ function pascalizeTokens(tokens) {
164
+ return tokens
165
+ .filter(Boolean)
166
+ .map((t) => String(t).replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, ''))
167
+ .filter(Boolean)
168
+ .map((t) => t.charAt(0).toUpperCase() + t.slice(1))
169
+ .join('');
170
+ }
171
+
172
+ function baseTsNameFromBasePath(basePath) {
173
+ const parts = String(basePath || '').split('.').filter(Boolean);
174
+ if (parts.length === 0) return 'Model';
175
+ const a = pascalizeTokens([parts[0]]);
176
+ if (parts.length === 1) return a;
177
+ const b = pascalizeTokens([parts[1]]);
178
+ if (parts.length === 2 && b.toLowerCase().startsWith(a.toLowerCase())) return b;
179
+ return parts.map((p) => pascalizeTokens([p])).join('');
180
+ }
181
+
182
+ function groupTsNameFromGroupPath(groupPath) {
183
+ const tokens = String(groupPath || '').split(/[._:]+/g).filter(Boolean);
184
+ return pascalizeTokens(tokens);
185
+ }
186
+
187
+ function stableUnion(types) {
188
+ const uniq = [...new Set(types.filter(Boolean))];
189
+ uniq.sort((a, b) => a.localeCompare(b));
190
+ return uniq.join(' | ') || 'any';
191
+ }
192
+
53
193
  /**
54
194
  * Construit les définitions de modèles à partir d’une spec OpenAPI (components.schemas)
55
195
  * @param {any} spec
@@ -59,77 +199,249 @@ function buildNameMap(schemaNames, schemas, preset) {
59
199
  export function buildModelsFromOpenAPI(spec, options) {
60
200
  const opts = { requiredMode: 'all-optional', ...(options || {}) };
61
201
  const schemas = spec?.components?.schemas || {};
62
- const schemaNames = applySchemaNameFilters(filterSchemaNames(schemas, opts), opts);
63
202
  const preset = opts.preset ?? 'all';
64
- const nameMap = buildNameMap(schemaNames, schemas, preset);
65
- const allSanitizedNames = new Set([...nameMap.values()]);
66
203
 
67
204
  /** @type {ModelDefinition[]} */
68
205
  const models = [];
69
206
 
70
- for (const originalName of schemaNames) {
71
- const raw = schemas[originalName];
72
- const sanitized = nameMap.get(originalName);
73
- if (!sanitized) continue;
207
+ if (preset !== 'native') {
208
+ const schemaNames = applySchemaNameFilters(filterSchemaNames(schemas, opts), opts);
209
+ const nameMap = buildNameMap(schemaNames, schemas, preset);
210
+ const allSanitizedNames = new Set([...nameMap.values()]);
211
+
212
+ for (const originalName of schemaNames) {
213
+ const raw = schemas[originalName];
214
+ const sanitized = nameMap.get(originalName);
215
+ if (!sanitized) continue;
216
+
217
+ /** @type {string[]} */
218
+ const extendsTypes = [];
74
219
 
75
- /** @type {string[]} */
76
- const extendsTypes = [];
77
-
78
- let effective = raw;
79
- if (raw?.allOf) {
80
- const merged = mergeAllOf(raw.allOf);
81
- if (merged) {
82
- effective = merged;
83
- } else {
84
- // Non-flattenable allOf (refs, mixed forms): keep inline object props and model refs as extends.
85
- const props = {};
86
- const required = new Set();
87
- const ext = new Set();
88
-
89
- for (const part of raw.allOf) {
90
- if (part?.$ref) {
91
- const orig = schemaNameFromRef(part.$ref);
92
- const refName = orig ? nameMap.get(orig) : undefined;
93
- if (refName && refName !== sanitized && allSanitizedNames.has(refName)) ext.add(refName);
94
- continue;
220
+ let effective = raw;
221
+ if (raw?.allOf) {
222
+ const merged = mergeAllOf(raw.allOf);
223
+ if (merged) {
224
+ effective = merged;
225
+ } else {
226
+ // Non-flattenable allOf (refs, mixed forms): keep inline object props and model refs as extends.
227
+ const props = {};
228
+ const required = new Set();
229
+ const ext = new Set();
230
+
231
+ for (const part of raw.allOf) {
232
+ if (part?.$ref) {
233
+ const orig = schemaNameFromRef(part.$ref);
234
+ const refName = orig ? nameMap.get(orig) : undefined;
235
+ if (refName && refName !== sanitized && allSanitizedNames.has(refName)) ext.add(refName);
236
+ continue;
237
+ }
238
+ const isObj = part?.type === 'object' || isObject(part?.properties);
239
+ if (!isObj) continue;
240
+ Object.assign(props, part.properties || {});
241
+ for (const r of part.required || []) required.add(r);
95
242
  }
96
- const isObj = part?.type === 'object' || isObject(part?.properties);
97
- if (!isObj) continue;
98
- Object.assign(props, part.properties || {});
99
- for (const r of part.required || []) required.add(r);
100
- }
101
243
 
102
- extendsTypes.push(...[...ext].sort((a, b) => a.localeCompare(b)));
103
- effective = { type: 'object', properties: props, required: [...required] };
244
+ extendsTypes.push(...[...ext].sort((a, b) => a.localeCompare(b)));
245
+ effective = { type: 'object', properties: props, required: [...required] };
246
+ }
104
247
  }
248
+
249
+ const isObjectLike = effective?.type === 'object' || isObject(effective?.properties) || extendsTypes.length > 0;
250
+ if (!isObjectLike) continue;
251
+
252
+ const required = new Set(effective.required || []);
253
+ const deps = new Set();
254
+
255
+ for (const e of extendsTypes) deps.add(e);
256
+
257
+ const props = Object.entries(effective.properties || {}).map(([propName, prop]) => {
258
+ const baseType = tsTypeOf(prop, deps, nameMap, { requiredMode: opts.requiredMode ?? 'all-optional', forceNullable: false });
259
+ const type = withNullable(baseType, prop);
260
+ return {
261
+ name: propName,
262
+ tsKey: quoteKeyIfNeeded(propName),
263
+ type,
264
+ optional: opts.requiredMode === 'spec' ? !required.has(propName) : true,
265
+ };
266
+ });
267
+
268
+ const imports = [...deps]
269
+ .filter((n) => n !== sanitized)
270
+ .filter((n) => allSanitizedNames.has(n))
271
+ .sort((a, b) => a.localeCompare(b));
272
+
273
+ models.push({name: sanitized, props, imports, extendsTypes});
274
+ }
275
+
276
+ return { models };
277
+ }
278
+
279
+ // preset=native (v2): contract-driven (paths), LD+JSON, groups preserved, jsonld/non-jsonld merged.
280
+ const roots = listNativeRootsFromPaths(spec, { mediaType: 'application/ld+json' });
281
+ const initialRefs = new Set();
282
+ for (const r of roots) collectRefsFromSchema(r, initialRefs);
283
+
284
+ const usedSchemaNames = new Set();
285
+ const queue = [...initialRefs];
286
+ while (queue.length) {
287
+ const name = queue.pop();
288
+ if (!name || usedSchemaNames.has(name)) continue;
289
+ usedSchemaNames.add(name);
290
+ const schema = schemas[name];
291
+ if (!schema) continue;
292
+ const refs = new Set();
293
+ collectRefsFromSchema(schema, refs);
294
+ for (const ref of refs) if (ref && !usedSchemaNames.has(ref)) queue.push(ref);
295
+ }
296
+
297
+ // Group and merge by (basePath, groupPath), ignoring variants.
298
+ const buckets = new Map();
299
+ for (const originalName of [...usedSchemaNames].sort((a, b) => a.localeCompare(b))) {
300
+ if (!originalName) continue;
301
+ if (/^Hydra/i.test(originalName)) continue; // technical
302
+
303
+ const parsed = parseOpenApiSchemaName(originalName);
304
+ if (!parsed.basePath) continue;
305
+
306
+ // User include/exclude filters (apply to canonical name and to raw variants)
307
+ const canonical = parsed.groupPath ? `${parsed.basePath}-${parsed.groupPath}` : parsed.basePath;
308
+ const fulls = [originalName, canonical];
309
+ const include = Array.isArray(opts.includeSchemaNames) ? opts.includeSchemaNames : [];
310
+ const exclude = Array.isArray(opts.excludeSchemaNames) ? opts.excludeSchemaNames : [];
311
+
312
+ const matches = (val, rule) => {
313
+ if (rule instanceof RegExp) return rule.test(val);
314
+ if (typeof rule === 'function') return !!rule(val);
315
+ if (typeof rule === 'string' && rule.length) return val.includes(rule);
316
+ return false;
317
+ };
318
+
319
+ const hasInclude = include.length > 0;
320
+ const included = !hasInclude || include.some((r) => fulls.some((v) => matches(v, r)));
321
+ const excluded = exclude.some((r) => fulls.some((v) => matches(v, r)));
322
+ if (!included || excluded) continue;
323
+
324
+ const bucket = buckets.get(parsed.key) || { basePath: parsed.basePath, groupPath: parsed.groupPath, entries: [] };
325
+ bucket.entries.push(parsed);
326
+ buckets.set(parsed.key, bucket);
327
+ }
328
+
329
+ // Stable bucket ordering
330
+ const bucketList = [...buckets.values()].sort((a, b) => {
331
+ const ka = `${a.basePath}-${a.groupPath}`.toLowerCase();
332
+ const kb = `${b.basePath}-${b.groupPath}`.toLowerCase();
333
+ return ka.localeCompare(kb);
334
+ });
335
+
336
+ // Compute TS names (unique) and build originalName -> tsName map (all variants in a bucket).
337
+ const usedTsNames = new Set();
338
+ const nameMap = new Map();
339
+ for (const b of bucketList) {
340
+ const baseTs = baseTsNameFromBasePath(b.basePath);
341
+ const groupTs = groupTsNameFromGroupPath(b.groupPath);
342
+ const desired = `${baseTs}${groupTs}` || 'Model';
343
+ const tsName = sanitizeTypeName(desired, usedTsNames);
344
+ b.tsName = tsName;
345
+ for (const e of b.entries) {
346
+ nameMap.set(e.originalName, tsName);
105
347
  }
348
+ }
349
+ const allSanitizedNames = new Set(bucketList.map((b) => b.tsName));
106
350
 
107
- const isObjectLike = effective?.type === 'object' || isObject(effective?.properties) || extendsTypes.length > 0;
108
- if (!isObjectLike) continue;
351
+ // Generate merged models per bucket.
352
+ for (const b of bucketList) {
353
+ const sanitized = b.tsName;
354
+ if (!sanitized) continue;
109
355
 
110
- const required = new Set(effective.required || []);
111
356
  const deps = new Set();
357
+ const extendsSet = new Set();
358
+ const propTypes = new Map(); // propName -> Set<tsType>
112
359
 
113
- for (const e of extendsTypes) deps.add(e);
360
+ // Choose candidates: jsonld + base (no multipart, no merge-patch)
361
+ const candidates = b.entries
362
+ .filter((e) => !e.isMergePatch)
363
+ .filter((e) => e.baseVariant !== 'multipart')
364
+ .sort((a, b2) => {
365
+ const score = (e) => (e.baseVariant === 'jsonld' ? 2 : 1);
366
+ const sa = score(a), sb = score(b2);
367
+ if (sb !== sa) return sb - sa;
368
+ return a.originalName.localeCompare(b2.originalName);
369
+ });
370
+
371
+ for (const e of candidates) {
372
+ const raw = schemas[e.originalName];
373
+ if (!raw) continue;
374
+
375
+ let effective = raw;
376
+ /** @type {string[]} */
377
+ const extendsTypes = [];
378
+
379
+ if (raw?.allOf) {
380
+ const merged = mergeAllOf(raw.allOf);
381
+ if (merged) {
382
+ effective = merged;
383
+ } else {
384
+ const props = {};
385
+ const ext = new Set();
386
+ for (const part of raw.allOf) {
387
+ if (part?.$ref) {
388
+ const orig = schemaNameFromRef(part.$ref);
389
+ const refName = orig ? nameMap.get(orig) || orig : undefined;
390
+ if (refName && refName !== sanitized && allSanitizedNames.has(refName)) ext.add(refName);
391
+ continue;
392
+ }
393
+ const isObj = part?.type === 'object' || isObject(part?.properties);
394
+ if (!isObj) continue;
395
+ Object.assign(props, part.properties || {});
396
+ }
397
+ extendsTypes.push(...[...ext].sort((a, b3) => a.localeCompare(b3)));
398
+ effective = { type: 'object', properties: props };
399
+ }
400
+ }
401
+
402
+ const isObjectLike = effective?.type === 'object' || isObject(effective?.properties) || extendsTypes.length > 0;
403
+ if (!isObjectLike) continue;
404
+
405
+ for (const ex of extendsTypes) extendsSet.add(ex);
114
406
 
115
- const props = Object.entries(effective.properties || {}).map(([propName, prop]) => {
116
- const baseType = tsTypeOf(prop, deps, nameMap);
117
- const type = withNullable(baseType, prop);
118
- return {
119
- name: propName,
120
- tsKey: quoteKeyIfNeeded(propName),
121
- type,
122
- optional: opts.requiredMode === 'spec' ? !required.has(propName) : true,
123
- };
124
- });
407
+ for (const [propName, prop] of Object.entries(effective.properties || {})) {
408
+ const baseType = tsTypeOf(prop, deps, nameMap, { requiredMode: 'all-optional', forceNullable: true });
409
+ const existing = propTypes.get(propName) || new Set();
410
+ existing.add(baseType);
411
+ propTypes.set(propName, existing);
412
+ }
413
+ }
125
414
 
415
+ const extendsTypes = [...extendsSet]
416
+ .filter((n) => n !== sanitized)
417
+ .filter((n) => allSanitizedNames.has(n))
418
+ .sort((a, b2) => a.localeCompare(b2));
419
+
420
+ // Imports are any referenced model types (+ extends), excluding self.
421
+ for (const e of extendsTypes) deps.add(e);
126
422
  const imports = [...deps]
127
423
  .filter((n) => n !== sanitized)
128
424
  .filter((n) => allSanitizedNames.has(n))
129
- .sort((a, b) => a.localeCompare(b));
425
+ .sort((a, b2) => a.localeCompare(b2));
426
+
427
+ const props = [...propTypes.keys()]
428
+ .sort((a, b2) => a.localeCompare(b2))
429
+ .map((propName) => {
430
+ const union = stableUnion([...propTypes.get(propName)]);
431
+ const type = /\|\s*null\b/.test(union) ? union : `${union} | null`;
432
+ return {
433
+ name: propName,
434
+ tsKey: quoteKeyIfNeeded(propName),
435
+ type,
436
+ optional: true,
437
+ };
438
+ });
439
+
440
+ // Skip empty models (no props + no extends): avoid polluting exports.
441
+ if (props.length === 0 && extendsTypes.length === 0) continue;
130
442
 
131
- models.push({name: sanitized, props, imports, extendsTypes});
443
+ models.push({ name: sanitized, props, imports, extendsTypes });
132
444
  }
133
445
 
134
- return {models};
446
+ return { models };
135
447
  }
@@ -112,6 +112,9 @@ export function applySchemaNameFilters(schemaNames, cfg) {
112
112
  if (preset === 'native') {
113
113
  out = out
114
114
  .filter((n) => !/^Hydra/i.test(n))
115
+ // API Platform adds a technical "Multipart" schema for multipart/form-data request bodies (file uploads).
116
+ // It's not a resource model and tends to pollute the generated "native" model list.
117
+ .filter((n) => !/^Multipart(?:$|[.-])/i.test(n))
115
118
  .filter((n) => !/jsonMergePatch/i.test(n))
116
119
  .filter((n) => !/\.jsonld\b/i.test(n))
117
120
  .filter((n) => !/\.jsonapi\b/i.test(n));
@@ -16,10 +16,13 @@ export function withNullable(ts, schema) {
16
16
  * @param {any} schema
17
17
  * @param {Set<string>} deps
18
18
  * @param {Map<string,string>} nameMap
19
+ * @param {{ requiredMode?: 'all-optional'|'spec', forceNullable?: boolean }} [cfg]
19
20
  * @returns {string}
20
21
  */
21
- export function tsTypeOf(schema, deps, nameMap) {
22
+ export function tsTypeOf(schema, deps, nameMap, cfg) {
22
23
  if (!schema) return 'any';
24
+ const requiredMode = cfg?.requiredMode ?? 'all-optional';
25
+ const forceNullable = !!cfg?.forceNullable;
23
26
 
24
27
  if (schema.$ref) {
25
28
  const orig = schemaNameFromRef(schema.$ref);
@@ -49,9 +52,14 @@ export function tsTypeOf(schema, deps, nameMap) {
49
52
  else if (t === 'string') parts.add('string');
50
53
  else if (t === 'integer' || t === 'number') parts.add('number');
51
54
  else if (t === 'boolean') parts.add('boolean');
52
- else if (t === 'array') parts.add(tsTypeOf({ type: 'array', items: schema.items }, deps, nameMap));
55
+ else if (t === 'array') parts.add(tsTypeOf({ type: 'array', items: schema.items }, deps, nameMap, cfg));
53
56
  else if (t === 'object') {
54
- const objTs = tsTypeOf({ type: 'object', properties: schema.properties, required: schema.required, additionalProperties: schema.additionalProperties }, deps, nameMap);
57
+ const objTs = tsTypeOf(
58
+ { type: 'object', properties: schema.properties, required: schema.required, additionalProperties: schema.additionalProperties },
59
+ deps,
60
+ nameMap,
61
+ cfg
62
+ );
55
63
  parts.add(objTs);
56
64
  } else parts.add('any');
57
65
  }
@@ -60,13 +68,13 @@ export function tsTypeOf(schema, deps, nameMap) {
60
68
 
61
69
  if (schema.allOf) {
62
70
  // Aplatir au niveau buildModelsFromOpenAPI; ici on combine si nécessaire
63
- return schema.allOf.map((s) => tsTypeOf(s, deps, nameMap)).join(' & ');
71
+ return schema.allOf.map((s) => tsTypeOf(s, deps, nameMap, cfg)).join(' & ');
64
72
  }
65
- if (schema.oneOf) return schema.oneOf.map((s) => tsTypeOf(s, deps, nameMap)).join(' | ');
66
- if (schema.anyOf) return schema.anyOf.map((s) => tsTypeOf(s, deps, nameMap)).join(' | ');
73
+ if (schema.oneOf) return schema.oneOf.map((s) => tsTypeOf(s, deps, nameMap, cfg)).join(' | ');
74
+ if (schema.anyOf) return schema.anyOf.map((s) => tsTypeOf(s, deps, nameMap, cfg)).join(' | ');
67
75
 
68
76
  if (schema.type === 'array') {
69
- const it = tsTypeOf(schema.items || {}, deps, nameMap);
77
+ const it = tsTypeOf(schema.items || {}, deps, nameMap, cfg);
70
78
  return `${it}[]`;
71
79
  }
72
80
 
@@ -74,14 +82,15 @@ export function tsTypeOf(schema, deps, nameMap) {
74
82
  const props = schema.properties || {};
75
83
  const entries = Object.entries(props);
76
84
  const inline = entries.map(([k, v]) => {
77
- const t = tsTypeOf(v, deps, nameMap);
78
- const opt = (schema.required || []).includes(k) ? '' : '?';
79
- const nullable = v?.nullable && !/\|\s*null\b/.test(t) ? ' | null' : '';
85
+ const t0 = tsTypeOf(v, deps, nameMap, cfg);
86
+ const opt = requiredMode === 'spec' ? ((schema.required || []).includes(k) ? '' : '?') : '?';
87
+ const wantsNull = forceNullable || v?.nullable;
88
+ const nullable = wantsNull && !/\|\s*null\b/.test(t0) ? ' | null' : '';
80
89
  const key = quoteKeyIfNeeded(k);
81
- return `${key}${opt}: ${t}${nullable};`;
90
+ return `${key}${opt}: ${t0}${nullable};`;
82
91
  });
83
92
  if (schema.additionalProperties) {
84
- const at = tsTypeOf(schema.additionalProperties, deps, nameMap);
93
+ const at = tsTypeOf(schema.additionalProperties, deps, nameMap, cfg);
85
94
  inline.push(`[key: string]: ${at};`);
86
95
  }
87
96
  return `{ ${inline.join(' ')} }`;
@@ -93,4 +102,3 @@ export function tsTypeOf(schema, deps, nameMap) {
93
102
 
94
103
  return 'any';
95
104
  }
96
-