@obsidiane/meridiane 0.0.7 → 0.0.11

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.
@@ -0,0 +1,17 @@
1
+ export function normalizePreset(presetOption) {
2
+ if (presetOption === undefined) return 'all';
3
+ if (presetOption === true) return 'native';
4
+ if (presetOption === 'native' || presetOption === 'all') return presetOption;
5
+ throw new Error(`Invalid --preset value: ${presetOption} (expected: native|all)`);
6
+ }
7
+
8
+ export function splitListArgs(values) {
9
+ const out = [];
10
+ for (const v of Array.isArray(values) ? values : []) {
11
+ for (const part of String(v).split(',')) {
12
+ const s = part.trim();
13
+ if (s) out.push(s);
14
+ }
15
+ }
16
+ return out;
17
+ }
@@ -0,0 +1,59 @@
1
+ import fs from 'node:fs/promises';
2
+
3
+ function isHttpUrl(v) {
4
+ return /^https?:\/\//i.test(String(v || ''));
5
+ }
6
+
7
+ async function fetchJson(url) {
8
+ let res;
9
+ try {
10
+ res = await fetch(url);
11
+ } catch (err) {
12
+ const msg = err?.message ?? String(err);
13
+ throw new Error(
14
+ `Spec fetch failed: ${url}\n` +
15
+ `Reason: ${msg}\n` +
16
+ `Tip: if your environment cannot access the network, use a local JSON file via --spec ./openapi.json`
17
+ );
18
+ }
19
+ if (!res.ok) {
20
+ const body = await res.text().catch(() => '');
21
+ throw new Error(`Spec fetch failed: ${res.status} ${res.statusText}${body ? `\n${body}` : ''}`);
22
+ }
23
+ return await res.json();
24
+ }
25
+
26
+ export async function readOpenApiSpec(specArg) {
27
+ if (!specArg) throw new Error('Missing --spec (url or JSON file path)');
28
+ const spec = String(specArg);
29
+
30
+ if (isHttpUrl(spec)) {
31
+ try {
32
+ return await fetchJson(spec);
33
+ } catch (err) {
34
+ const isDocsJson = /\/api\/docs\.json$/i.test(spec);
35
+ if (!isDocsJson) throw err;
36
+ const fallback = spec.replace(/\/api\/docs\.json$/i, '/api/docs.jsonopenapi');
37
+ try {
38
+ return await fetchJson(fallback);
39
+ } catch (err2) {
40
+ const m1 = err?.message ?? String(err);
41
+ const m2 = err2?.message ?? String(err2);
42
+ throw new Error(`Spec fetch failed (both endpoints tried)\n- ${spec}\n ${m1}\n- ${fallback}\n ${m2}`);
43
+ }
44
+ }
45
+ }
46
+
47
+ try {
48
+ const raw = await fs.readFile(spec, 'utf8');
49
+ return JSON.parse(raw);
50
+ } catch (err) {
51
+ if (err?.code === 'ENOENT') {
52
+ throw new Error(`Spec file not found: ${spec}`);
53
+ }
54
+ if (err?.name === 'SyntaxError') {
55
+ throw new Error(`Invalid JSON in spec file ${spec}: ${err.message}`);
56
+ }
57
+ throw err;
58
+ }
59
+ }
@@ -0,0 +1,117 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+
5
+ import { writeJsonIfChanged } from './json.js';
6
+
7
+ async function ensureDir(p) {
8
+ await fs.mkdir(p, { recursive: true });
9
+ }
10
+
11
+ async function exists(p) {
12
+ try {
13
+ await fs.stat(p);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ function toolchainPackageJson({ angularVersion }) {
21
+ const v = angularVersion;
22
+ return {
23
+ name: 'meridiane-standalone-workspace',
24
+ private: true,
25
+ description: 'Temporary workspace created by @obsidiane/meridiane (standalone build).',
26
+ devDependencies: {
27
+ '@angular/common': v,
28
+ '@angular/compiler': v,
29
+ '@angular/compiler-cli': v,
30
+ '@angular/core': v,
31
+ 'ng-packagr': '20.1.0',
32
+ rxjs: '7.8.2',
33
+ tslib: '2.8.1',
34
+ typescript: '5.8.3',
35
+ },
36
+ };
37
+ }
38
+
39
+ function rootTsconfigJson() {
40
+ return {
41
+ compilerOptions: {
42
+ target: 'ES2022',
43
+ module: 'ES2022',
44
+ moduleResolution: 'Node',
45
+ lib: ['ES2022', 'DOM'],
46
+ strict: true,
47
+ skipLibCheck: true,
48
+ experimentalDecorators: true,
49
+ importHelpers: true,
50
+ },
51
+ angularCompilerOptions: {
52
+ compilationMode: 'partial',
53
+ strictTemplates: true,
54
+ },
55
+ };
56
+ }
57
+
58
+ export async function ensureStandaloneWorkspace({ repoRoot, log }) {
59
+ const distRoot = path.join(repoRoot, 'dist');
60
+ const workspaceRoot = path.join(distRoot, '.meridiane-workspace');
61
+
62
+ await ensureDir(distRoot);
63
+ await ensureDir(workspaceRoot);
64
+
65
+ const angularVersion = '20.1.7';
66
+ await writeJsonIfChanged(path.join(workspaceRoot, 'package.json'), toolchainPackageJson({ angularVersion }));
67
+ await writeJsonIfChanged(path.join(workspaceRoot, 'tsconfig.json'), rootTsconfigJson());
68
+
69
+ const npmCacheDir = path.join(workspaceRoot, '.npm-cache');
70
+ await ensureDir(npmCacheDir);
71
+
72
+ const requireFromWs = createRequire(path.join(workspaceRoot, 'package.json'));
73
+
74
+ return { workspaceRoot, distRoot, npmCacheDir, requireFromWs };
75
+ }
76
+
77
+ export async function ensureToolchainInstalled({ workspaceRoot, requireFromWs, npmCacheDir, log }) {
78
+ // Prefer using existing (hoisted) deps during local dev; in CI this will trigger installation.
79
+ const canResolve = (id) => {
80
+ try {
81
+ requireFromWs.resolve(id);
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ };
87
+
88
+ if (canResolve('ng-packagr/package.json')) return false;
89
+
90
+ log?.step?.('installation du toolchain (Angular + ng-packagr)');
91
+
92
+ // Use npm with a local cache to avoid writing to ~/.npm.
93
+ const env = {
94
+ ...process.env,
95
+ npm_config_cache: npmCacheDir,
96
+ npm_config_update_notifier: 'false',
97
+ npm_config_fund: 'false',
98
+ npm_config_audit: 'false',
99
+ };
100
+
101
+ // We avoid generating a lockfile in the backend repo by keeping everything under dist/.
102
+ const npmCmd = 'npm';
103
+ const args = ['install', '--silent', '--no-progress'];
104
+ const { spawn } = await import('node:child_process');
105
+
106
+ const code = await new Promise((resolve) => {
107
+ const child = spawn(npmCmd, args, { cwd: workspaceRoot, stdio: 'inherit', env });
108
+ child.on('exit', (c) => resolve(c ?? 0));
109
+ });
110
+
111
+ if (code !== 0) throw new Error(`Toolchain install failed (npm exit ${code})`);
112
+ if (!(await exists(path.join(workspaceRoot, 'node_modules')))) {
113
+ throw new Error('Toolchain install failed: node_modules missing');
114
+ }
115
+ return true;
116
+ }
117
+
package/tools/dev.js ADDED
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ import process from 'node:process';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+
7
+ import { deriveLibName } from './core/lib-name.js';
8
+ import { normalizePreset, splitListArgs } from './core/paths.js';
9
+ import { readOpenApiSpec } from './core/spec.js';
10
+ import { createLogger } from './core/logger.js';
11
+ import { generateBridgeWorkspace } from './core/generate.js';
12
+ import { ensureStandaloneWorkspace, ensureToolchainInstalled } from './core/standalone.js';
13
+
14
+ function run(cmd, args, { cwd, env } = {}) {
15
+ return new Promise((resolve) => {
16
+ const child = spawn(cmd, args, {
17
+ stdio: 'inherit',
18
+ cwd: cwd ?? process.cwd(),
19
+ env: env ?? process.env,
20
+ });
21
+ child.on('exit', (code) => resolve(code ?? 0));
22
+ });
23
+ }
24
+
25
+ async function pathIsFile(p) {
26
+ try {
27
+ const st = await fs.stat(p);
28
+ return st.isFile();
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ async function readPackageJsonName(dir) {
35
+ try {
36
+ const raw = await fs.readFile(path.join(dir, 'package.json'), 'utf8');
37
+ const parsed = JSON.parse(raw);
38
+ return typeof parsed?.name === 'string' ? parsed.name : undefined;
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ async function resolveDevAppRoot(startCwd) {
45
+ if (await pathIsFile(path.join(startCwd, 'package.json'))) return startCwd;
46
+ throw new Error('No package.json found. Run `meridiane dev` from your Angular app root.');
47
+ }
48
+
49
+ async function isMeridianeSandboxApp(appRoot) {
50
+ const name = await readPackageJsonName(appRoot);
51
+ return name === '@obsidiane/meridiane-sandbox';
52
+ }
53
+
54
+ async function resolveSandboxAppRoot(startCwd) {
55
+ // Allow running from apps/sandbox directly
56
+ if (await isMeridianeSandboxApp(startCwd)) return startCwd;
57
+
58
+ // Allow running from this repo root (monorepo) and target apps/sandbox
59
+ const sandboxApp = path.join(startCwd, 'apps', 'sandbox');
60
+ if (await pathIsFile(path.join(sandboxApp, 'package.json')) && (await isMeridianeSandboxApp(sandboxApp))) {
61
+ return sandboxApp;
62
+ }
63
+
64
+ throw new Error('Missing <packageName>. Run from the sandbox app (`apps/sandbox`) or provide a package name.');
65
+ }
66
+
67
+ async function findLatestTgz(distDir) {
68
+ const entries = await fs.readdir(distDir, { withFileTypes: true }).catch(() => []);
69
+ const tgzFiles = [];
70
+ for (const e of entries) {
71
+ if (!e.isFile()) continue;
72
+ if (!e.name.endsWith('.tgz')) continue;
73
+ const full = path.join(distDir, e.name);
74
+ const st = await fs.stat(full);
75
+ tgzFiles.push({ full, mtimeMs: st.mtimeMs });
76
+ }
77
+ tgzFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
78
+ return tgzFiles[0]?.full;
79
+ }
80
+
81
+ function splitPackageName(packageName) {
82
+ const s = String(packageName || '').trim();
83
+ if (!s) throw new Error('Missing packageName');
84
+ if (s.startsWith('@')) {
85
+ const [scope, name] = s.split('/');
86
+ if (!scope || !name) throw new Error(`Invalid packageName: ${packageName}`);
87
+ return { scope, name };
88
+ }
89
+ return { scope: undefined, name: s };
90
+ }
91
+
92
+ async function resolveNodeModulesRoot(startDir) {
93
+ let cur = startDir;
94
+ for (;;) {
95
+ const candidate = path.join(cur, 'node_modules');
96
+ try {
97
+ const st = await fs.stat(candidate);
98
+ if (st.isDirectory()) return candidate;
99
+ } catch {
100
+ // ignore
101
+ }
102
+ const parent = path.dirname(cur);
103
+ if (parent === cur) break;
104
+ cur = parent;
105
+ }
106
+
107
+ const nm = path.join(startDir, 'node_modules');
108
+ await fs.mkdir(nm, { recursive: true });
109
+ return nm;
110
+ }
111
+
112
+ async function installPackageFromDist({ packageName, distDir, appRoot, log }) {
113
+ const nmRoot = await resolveNodeModulesRoot(appRoot);
114
+ const { scope, name } = splitPackageName(packageName);
115
+ const pkgDir = scope ? path.join(nmRoot, scope, name) : path.join(nmRoot, name);
116
+
117
+ log?.step?.(`installation locale dans node_modules (${scope ? `${scope}/${name}` : name})`);
118
+
119
+ await fs.rm(pkgDir, { recursive: true, force: true });
120
+ await fs.mkdir(path.dirname(pkgDir), { recursive: true });
121
+
122
+ await fs.cp(distDir, pkgDir, {
123
+ recursive: true,
124
+ filter: (src) => !src.endsWith('.tgz'),
125
+ });
126
+
127
+ return pkgDir;
128
+ }
129
+
130
+ export async function runDev(packageName, opts) {
131
+ const startCwd = process.cwd();
132
+ const debug = !!opts.debug;
133
+ const log = createLogger({ debug });
134
+
135
+ const useSandboxDefaults = packageName === undefined;
136
+ const appRoot = useSandboxDefaults ? await resolveSandboxAppRoot(startCwd) : await resolveDevAppRoot(startCwd);
137
+
138
+ const effectivePackageName = packageName ?? '@obsidiane/bridge-sandbox';
139
+ const libName = deriveLibName(effectivePackageName);
140
+
141
+ const noModels = opts.models === false;
142
+ const preset = normalizePreset(useSandboxDefaults ? (opts.preset ?? 'native') : opts.preset);
143
+ const include = splitListArgs(opts.include);
144
+ const exclude = splitListArgs(opts.exclude);
145
+
146
+ const version = '0.0.0-dev';
147
+ const requiredMode = 'all-optional';
148
+ const specSource = useSandboxDefaults ? (opts.spec ?? 'http://localhost:8000/api/docs.json') : opts.spec;
149
+
150
+ log.title('dev — build standalone + install local');
151
+ log.info(`app: ${appRoot}`);
152
+ log.info(`package: ${effectivePackageName}@${version}`);
153
+ log.info(`lib: ${libName}`);
154
+ if (useSandboxDefaults) log.info('defaults sandbox appliqués');
155
+ if (noModels) log.info('models: désactivés (--no-models)');
156
+ else log.info(`spec: ${specSource} | preset: ${preset}`);
157
+
158
+ const { workspaceRoot, distRoot, npmCacheDir, requireFromWs } = await ensureStandaloneWorkspace({ repoRoot: appRoot, log });
159
+ log.info(`workspace: ${workspaceRoot}`);
160
+
161
+ let spec;
162
+ if (!noModels) {
163
+ log.step('lecture de la spec OpenAPI');
164
+ spec = await readOpenApiSpec(specSource);
165
+ log.success('spec chargée');
166
+ }
167
+
168
+ await ensureToolchainInstalled({ workspaceRoot, requireFromWs, npmCacheDir, log });
169
+
170
+ await generateBridgeWorkspace({
171
+ cwd: workspaceRoot,
172
+ libName,
173
+ packageName: effectivePackageName,
174
+ version,
175
+ noModels,
176
+ spec,
177
+ requiredMode,
178
+ preset,
179
+ include,
180
+ exclude,
181
+ debug,
182
+ log,
183
+ distRoot,
184
+ });
185
+
186
+ const ngPackageJsonPath = path.join(workspaceRoot, 'projects', libName, 'ng-package.json');
187
+ const tsconfigDevPath = path.join(workspaceRoot, 'projects', libName, 'tsconfig.lib.json');
188
+
189
+ log.step('ng-packagr');
190
+ const ngPackagrPath = requireFromWs.resolve('ng-packagr/src/cli/main.js');
191
+ const packagrCode = await run(process.execPath, [ngPackagrPath, '-p', ngPackageJsonPath, '-c', tsconfigDevPath], {
192
+ cwd: workspaceRoot,
193
+ });
194
+ if (packagrCode !== 0) process.exit(packagrCode);
195
+
196
+ const distDir = path.join(distRoot, libName);
197
+ log.step(`npm pack (${path.relative(appRoot, distDir)})`);
198
+ const packCode = await run('npm', ['pack'], {
199
+ cwd: distDir,
200
+ env: {
201
+ ...process.env,
202
+ npm_config_cache: npmCacheDir,
203
+ npm_config_update_notifier: 'false',
204
+ npm_config_fund: 'false',
205
+ npm_config_audit: 'false',
206
+ },
207
+ });
208
+ if (packCode !== 0) process.exit(packCode);
209
+
210
+ await findLatestTgz(distDir); // keep producing the tgz in dist for inspection/use
211
+ await installPackageFromDist({ packageName: effectivePackageName, distDir, appRoot, log });
212
+
213
+ log.success('bridge installé (node_modules) ✅');
214
+ }
215
+
216
+ if (import.meta.url === `file://${process.argv[1]}`) {
217
+ throw new Error('This module is not meant to be executed directly.');
218
+ }
@@ -2,13 +2,19 @@ import fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import Handlebars from 'handlebars';
4
4
 
5
+ const templateCache = new Map();
6
+
5
7
  /**
6
8
  * Rend un template Handlebars avec un contexte et écrit le fichier.
7
9
  * @param {{ templatePath: string, outPath: string, ctx: any }} params
8
10
  */
9
11
  export async function renderTemplateToFile({templatePath, outPath, ctx}) {
10
- const tplSrc = await fs.readFile(templatePath, 'utf8');
11
- const tpl = Handlebars.compile(tplSrc, {noEscape: true});
12
+ let tpl = templateCache.get(templatePath);
13
+ if (!tpl) {
14
+ const tplSrc = await fs.readFile(templatePath, 'utf8');
15
+ tpl = Handlebars.compile(tplSrc, {noEscape: true});
16
+ templateCache.set(templatePath, tpl);
17
+ }
12
18
  const out = tpl(ctx);
13
19
  await fs.mkdir(path.dirname(outPath), {recursive: true});
14
20
  await fs.writeFile(outPath, out, 'utf8');
@@ -1,20 +1,26 @@
1
1
  /**
2
- * Convertit un nom de schéma OpenAPI en un nom lisible (sans artefacts jsonld/jsonapi)
3
- * et intégrant les groupes (ex: Identity.jsonld-user.read -> Identity user read)
2
+ * Construit un label "tokenisé" (sans `.` ni `-`) à partir d'un nom de schéma OpenAPI.
3
+ * Le label est ensuite passé à `sanitizeTypeName()` pour produire un identifiant TS stable.
4
+ *
4
5
  * @param {string} original
6
+ * @param {{ preset: 'all'|'native' }} cfg
5
7
  */
6
- export function friendlyName(original) {
7
- const m = original.match(/^(.*?)(?:\.(?:jsonld|jsonapi))?-(.+)$/i);
8
- if (m) {
9
- const base = m[1];
10
- const baseSimple = base.split('.').pop();
11
- const groupTokens = m[2].split('.');
12
- if (groupTokens.length && groupTokens[0].toLowerCase() === String(baseSimple).toLowerCase()) {
13
- groupTokens.shift();
14
- }
15
- const group = groupTokens.join(' ');
16
- return group ? `${base} ${group}` : base;
8
+ export function schemaLabel(original, cfg) {
9
+ const preset = cfg?.preset ?? 'all';
10
+ if (preset === 'native') {
11
+ // En native, on vise un nom "entity-like" : on ignore le groupe (après `-`) si présent.
12
+ // Les variantes .jsonld/.jsonapi/jsonMergePatch sont censées être filtrées par le preset.
13
+ return String(original).split('-')[0];
17
14
  }
18
- return original.replace(/\.(jsonld|jsonapi)$/ig, '');
19
- }
20
15
 
16
+ // En all, on encode tout : `.` et `-` deviennent des séparateurs de tokens.
17
+ const rawTokens = String(original).split(/[.-]/g).filter(Boolean);
18
+ const tokens = rawTokens.map((t) => {
19
+ const lower = t.toLowerCase();
20
+ if (lower === 'jsonld') return 'Jsonld';
21
+ if (lower === 'jsonapi') return 'Jsonapi';
22
+ if (lower === 'jsonmergepatch' || t === 'jsonMergePatch') return 'JsonMergePatch';
23
+ return t;
24
+ });
25
+ return tokens.join(' ');
26
+ }
@@ -1,13 +1,11 @@
1
1
  import { sanitizeTypeName, quoteKeyIfNeeded } from './utils.js';
2
- import { filterSchemaNames, applySchemaNameFilters, mergeAllOf, isObject } from './schema-utils.js';
3
- import { friendlyName } from './naming.js';
2
+ import { filterSchemaNames, applySchemaNameFilters, mergeAllOf, isObject, schemaNameFromRef } from './schema-utils.js';
3
+ import { schemaLabel } from './naming.js';
4
4
  import { tsTypeOf, withNullable } from './type-resolver.js';
5
5
 
6
6
  /**
7
7
  * @typedef {{
8
8
  * requiredMode?: 'all-optional'|'spec',
9
- * preferFlavor?: 'jsonld'|'jsonapi'|'none',
10
- * hydraBaseRegex?: RegExp|string,
11
9
  * preset?: 'all'|'native',
12
10
  * includeSchemaNames?: Array<RegExp|string|((name: string) => boolean)>,
13
11
  * excludeSchemaNames?: Array<RegExp|string|((name: string) => boolean)>,
@@ -18,40 +16,37 @@ import { tsTypeOf, withNullable } from './type-resolver.js';
18
16
  * Construit une map originalName -> sanitizedName, en assurant l’unicité
19
17
  * @param {string[]} schemaNames
20
18
  */
21
- function buildNameMap(schemaNames) {
22
- const parse = (original) => {
23
- const m = original.match(/^(.*?)(?:\.(?:jsonld|jsonapi))?-(.+)$/i);
24
- return m ? { base: m[1], group: m[2] } : { base: original, group: null };
25
- };
26
-
27
- const parsed = schemaNames.map((n) => ({ name: n, ...parse(n) }));
28
- const roots = parsed.filter((p) => !p.group);
29
- const grouped = parsed.filter((p) => !!p.group);
30
-
31
- // Root sanitized names (to avoid collisions when dropping group)
32
- const rootSanitized = new Set(roots.map((p) => sanitizeTypeName(p.base, new Set())));
33
-
34
- // Count base candidates among grouped to see if unique
35
- const baseCount = new Map();
36
- for (const p of grouped) {
37
- const cand = sanitizeTypeName(p.base, new Set());
38
- baseCount.set(cand, (baseCount.get(cand) || 0) + 1);
39
- }
19
+ function normalizeNativeBase(original) {
20
+ return String(original)
21
+ .replace(/\.jsonld\b/ig, '')
22
+ .replace(/\.jsonapi\b/ig, '')
23
+ .replace(/\.jsonmergepatch\b/ig, '')
24
+ .split('-')[0];
25
+ }
40
26
 
41
- // Build final map with uniqueness
27
+ function buildNameMap(schemaNames, schemas, preset) {
28
+ // Build final map with uniqueness (deterministic order)
42
29
  const used = new Set();
43
30
  const nameMap = new Map();
44
- for (const p of parsed) {
45
- let label;
46
- if (p.group) {
47
- const cand = sanitizeTypeName(p.base, new Set());
48
- const isUniqueBase = (baseCount.get(cand) === 1) && !rootSanitized.has(cand);
49
- label = isUniqueBase ? p.base : friendlyName(p.name);
50
- } else {
51
- label = friendlyName(p.name);
31
+ for (const original of schemaNames) {
32
+ const label = schemaLabel(original, {preset});
33
+ nameMap.set(original, sanitizeTypeName(label, used));
34
+ }
35
+
36
+ // In native mode, alias excluded variants to the selected base type
37
+ // so `$ref` doesn't leak "User-user.write" etc into generated types.
38
+ if (preset === 'native') {
39
+ const baseToSanitized = new Map();
40
+ for (const original of schemaNames) {
41
+ baseToSanitized.set(normalizeNativeBase(original), nameMap.get(original));
42
+ }
43
+ for (const original of Object.keys(schemas)) {
44
+ const base = normalizeNativeBase(original);
45
+ const sanitized = baseToSanitized.get(base);
46
+ if (sanitized) nameMap.set(original, sanitized);
52
47
  }
53
- nameMap.set(p.name, sanitizeTypeName(label, used));
54
48
  }
49
+
55
50
  return nameMap;
56
51
  }
57
52
 
@@ -65,7 +60,8 @@ export function buildModelsFromOpenAPI(spec, options) {
65
60
  const opts = { requiredMode: 'all-optional', ...(options || {}) };
66
61
  const schemas = spec?.components?.schemas || {};
67
62
  const schemaNames = applySchemaNameFilters(filterSchemaNames(schemas, opts), opts);
68
- const nameMap = buildNameMap(schemaNames);
63
+ const preset = opts.preset ?? 'all';
64
+ const nameMap = buildNameMap(schemaNames, schemas, preset);
69
65
  const allSanitizedNames = new Set([...nameMap.values()]);
70
66
 
71
67
  /** @type {ModelDefinition[]} */
@@ -73,19 +69,49 @@ export function buildModelsFromOpenAPI(spec, options) {
73
69
 
74
70
  for (const originalName of schemaNames) {
75
71
  const raw = schemas[originalName];
72
+ const sanitized = nameMap.get(originalName);
73
+ if (!sanitized) continue;
74
+
75
+ /** @type {string[]} */
76
+ const extendsTypes = [];
77
+
76
78
  let effective = raw;
77
79
  if (raw?.allOf) {
78
- const merged = mergeAllOf(raw.allOf, opts);
79
- if (merged) effective = merged;
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;
95
+ }
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
+
102
+ extendsTypes.push(...[...ext].sort((a, b) => a.localeCompare(b)));
103
+ effective = { type: 'object', properties: props, required: [...required] };
104
+ }
80
105
  }
81
106
 
82
- const isObjectLike = effective?.type === 'object' || isObject(effective?.properties);
107
+ const isObjectLike = effective?.type === 'object' || isObject(effective?.properties) || extendsTypes.length > 0;
83
108
  if (!isObjectLike) continue;
84
109
 
85
- const sanitized = nameMap.get(originalName);
86
110
  const required = new Set(effective.required || []);
87
111
  const deps = new Set();
88
112
 
113
+ for (const e of extendsTypes) deps.add(e);
114
+
89
115
  const props = Object.entries(effective.properties || {}).map(([propName, prop]) => {
90
116
  const baseType = tsTypeOf(prop, deps, nameMap);
91
117
  const type = withNullable(baseType, prop);
@@ -102,7 +128,7 @@ export function buildModelsFromOpenAPI(spec, options) {
102
128
  .filter((n) => allSanitizedNames.has(n))
103
129
  .sort((a, b) => a.localeCompare(b));
104
130
 
105
- models.push({name: sanitized, props, imports});
131
+ models.push({name: sanitized, props, imports, extendsTypes});
106
132
  }
107
133
 
108
134
  return {models};