@dudousxd/nestjs-codegen 0.26.0 → 0.27.0

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/dist/cli/main.js CHANGED
@@ -83,11 +83,32 @@ function validateUserConfig(userConfig) {
83
83
  "validation adapter is required \u2014 install @dudousxd/nestjs-codegen-zod and pass zodAdapter, or use @dudousxd/nestjs-codegen-valibot / -arktype"
84
84
  );
85
85
  }
86
- if (userConfig.pages && typeof userConfig.pages.glob !== "string") {
86
+ if (userConfig.pages && !isGlobInput(userConfig.pages.glob)) {
87
87
  throw new ConfigError(
88
- "Config validation failed: `pages.glob` must be a string when `pages` is set"
88
+ "Config validation failed: `pages.glob` must be a string or a non-empty array of strings when `pages` is set"
89
89
  );
90
90
  }
91
+ for (const [scope, scopeConfig] of Object.entries(userConfig.pages?.scopes ?? {})) {
92
+ if (scope === "default") {
93
+ throw new ConfigError(
94
+ 'Config validation failed: `pages.scopes` cannot declare "default" \u2014 the default app is `pages.glob` (InertiaModule.forRoot).'
95
+ );
96
+ }
97
+ if (!isGlobInput(scopeConfig?.glob)) {
98
+ throw new ConfigError(
99
+ `Config validation failed: \`pages.scopes.${scope}.glob\` must be a string or a non-empty array of strings`
100
+ );
101
+ }
102
+ }
103
+ if (userConfig.scopes && Object.keys(userConfig.scopes).length > 0) {
104
+ console.warn(
105
+ "[nestjs-codegen] The top-level `scopes` option has no effect and is deprecated. For several Inertia apps use `pages.scopes` (one entry per InertiaModule.forFeature scope); for several controller sets use one `contracts.glob` (e.g. a brace glob)."
106
+ );
107
+ }
108
+ }
109
+ function isGlobInput(value) {
110
+ if (typeof value === "string") return true;
111
+ return Array.isArray(value) && value.length > 0 && value.every((glob) => typeof glob === "string");
91
112
  }
92
113
  function applyDefaults(userConfig, cwd) {
93
114
  validateUserConfig(userConfig);
@@ -115,7 +136,17 @@ function applyDefaults(userConfig, cwd) {
115
136
  pages: userConfig.pages ? {
116
137
  glob: userConfig.pages.glob,
117
138
  propsExport: userConfig.pages.propsExport ?? "ComponentProps",
118
- componentNameStrategy: userConfig.pages.componentNameStrategy ?? "relative-no-ext"
139
+ componentNameStrategy: userConfig.pages.componentNameStrategy ?? "relative-no-ext",
140
+ scopes: Object.fromEntries(
141
+ Object.entries(userConfig.pages.scopes ?? {}).map(([scope, scopeConfig]) => [
142
+ scope,
143
+ {
144
+ glob: scopeConfig.glob,
145
+ prefix: scopeConfig.prefix ?? `${scope}/`,
146
+ componentNameStrategy: scopeConfig.componentNameStrategy ?? userConfig.pages?.componentNameStrategy ?? "relative-no-ext"
147
+ }
148
+ ])
149
+ )
119
150
  } : null,
120
151
  contracts: {
121
152
  glob: userConfig.contracts?.glob ?? "src/**/*.controller.ts",
@@ -189,26 +220,84 @@ import { dirname as dirname4, join as join14 } from "path";
189
220
 
190
221
  // src/discovery/pages.ts
191
222
  import { readFile } from "fs/promises";
192
- import { join as join2, relative as relative2 } from "path";
223
+ import { join as join2, relative as relative2, sep as sep2 } from "path";
193
224
  import fg from "fast-glob";
194
225
  var NON_PAGE_FILE_RE = /\.(?:test|spec|stories|story)\.(?:tsx?|jsx?|vue|svelte)$/i;
226
+ var MAGIC_SEGMENT_RE = /[*?{}[\]()!]/;
227
+ function globStaticBase(glob) {
228
+ const segments = glob.split("/");
229
+ const staticSegments = [];
230
+ for (const segment of segments.slice(0, -1)) {
231
+ if (MAGIC_SEGMENT_RE.test(segment)) break;
232
+ staticSegments.push(segment);
233
+ }
234
+ return staticSegments.join("/");
235
+ }
195
236
  async function discoverPages(opts) {
196
- const allFiles = await fg(opts.glob, { cwd: opts.cwd, absolute: true });
197
- const files = allFiles.filter((f) => !NON_PAGE_FILE_RE.test(f));
198
- files.sort();
199
- const globStatic = opts.glob.split("*")[0]?.replace(/\/$/, "") ?? "";
200
- const pagesBase = join2(opts.cwd, globStatic);
237
+ const globs = typeof opts.glob === "string" ? [opts.glob] : opts.glob;
238
+ const prefix = opts.prefix ?? "";
239
+ const scope = opts.scope ?? "default";
240
+ const seenFiles = /* @__PURE__ */ new Set();
201
241
  const out = [];
202
- for (const file of files) {
203
- const rel = relative2(opts.cwd, file);
204
- const nameRel = relative2(pagesBase, file);
205
- const name = computeName(nameRel, opts.componentNameStrategy);
206
- const source = await readFile(file, "utf8");
207
- const propsSource = extractPropsSource(source, opts.propsExport);
208
- out.push({ name, absolutePath: file, relativePath: rel, propsSource });
242
+ for (const glob of globs) {
243
+ const files = (await fg(glob, { cwd: opts.cwd, absolute: true })).filter((f) => !NON_PAGE_FILE_RE.test(f) && !seenFiles.has(f)).sort();
244
+ const pagesBase = join2(opts.cwd, globStaticBase(glob));
245
+ for (const file of files) {
246
+ seenFiles.add(file);
247
+ const rel = relative2(opts.cwd, file);
248
+ const nameRel = relative2(pagesBase, file).split(sep2).join("/");
249
+ const name = `${prefix}${computeName(nameRel, opts.componentNameStrategy)}`;
250
+ const source = await readFile(file, "utf8");
251
+ const propsSource = extractPropsSource(source, opts.propsExport);
252
+ out.push({ name, absolutePath: file, relativePath: rel, propsSource, scope });
253
+ }
254
+ }
255
+ assertUniqueNames(out);
256
+ return out;
257
+ }
258
+ async function discoverPageScopes(config) {
259
+ const pages = config.pages;
260
+ if (!pages) return [];
261
+ const common = { cwd: config.codegen.cwd, propsExport: pages.propsExport };
262
+ const out = await discoverPages({
263
+ ...common,
264
+ glob: pages.glob,
265
+ componentNameStrategy: pages.componentNameStrategy
266
+ });
267
+ for (const [scope, scopeConfig] of Object.entries(pages.scopes ?? {})) {
268
+ out.push(
269
+ ...await discoverPages({
270
+ ...common,
271
+ glob: scopeConfig.glob,
272
+ componentNameStrategy: scopeConfig.componentNameStrategy,
273
+ prefix: scopeConfig.prefix,
274
+ scope
275
+ })
276
+ );
209
277
  }
278
+ assertUniqueNames(out);
210
279
  return out;
211
280
  }
281
+ function allPageGlobs(config) {
282
+ if (!config.pages) return [];
283
+ const asList = (glob) => typeof glob === "string" ? [glob] : glob;
284
+ return [
285
+ ...asList(config.pages.glob),
286
+ ...Object.values(config.pages.scopes ?? {}).flatMap((scope) => asList(scope.glob))
287
+ ];
288
+ }
289
+ function assertUniqueNames(pages) {
290
+ const byName = /* @__PURE__ */ new Map();
291
+ for (const page of pages) {
292
+ const previous = byName.get(page.name);
293
+ if (previous) {
294
+ throw new Error(
295
+ `Two Inertia pages resolve to the component name "${page.name}": ${previous.relativePath} (scope "${previous.scope}") and ${page.relativePath} (scope "${page.scope}"). Give one of them another name, directory or scope prefix.`
296
+ );
297
+ }
298
+ byName.set(page.name, page);
299
+ }
300
+ }
212
301
  function computeName(rel, strat) {
213
302
  if (typeof strat === "function") return strat(rel);
214
303
  const noExt = rel.replace(/\.(tsx?|vue|svelte)$/, "");
@@ -4097,28 +4186,28 @@ function renderFetcherRequest(req, binaryResponse) {
4097
4186
  }
4098
4187
  function emitReqHelper() {
4099
4188
  return [
4100
- "/** Awaitable request handle. `await api.x.y({...})` runs the fetch; extensions add query/mutation helpers. */",
4101
- "type __Req<R> = {",
4102
- " then<T1 = R, T2 = never>(",
4189
+ "/** An awaitable request handle: a lazy, memoized `Promise` that also carries `fetch()` and the members extensions add. */",
4190
+ "export type RequestHandle<R, M = {}> = Promise<R> & { fetch(): Promise<R> } & M;",
4191
+ "class __Request<R> extends Promise<R> {",
4192
+ " static override get [Symbol.species](): PromiseConstructor {",
4193
+ " return Promise;",
4194
+ " }",
4195
+ " private __run: () => Promise<R>;",
4196
+ " private __p: Promise<R> | undefined;",
4197
+ " constructor(run: () => Promise<R>) {",
4198
+ " super(() => {});",
4199
+ " this.__run = run;",
4200
+ " }",
4201
+ " override then<T1 = R, T2 = never>(",
4103
4202
  " onfulfilled?: ((value: R) => T1 | PromiseLike<T1>) | null,",
4104
4203
  " onrejected?: ((reason: unknown) => T2 | PromiseLike<T2>) | null,",
4105
- " ): Promise<T1 | T2>;",
4106
- " catch<T = never>(onrejected?: ((reason: unknown) => T | PromiseLike<T>) | null): Promise<R | T>;",
4107
- " finally(onfinally?: (() => void) | null): Promise<R>;",
4108
- " fetch(): Promise<R>;",
4109
- "};",
4110
- "function __req<R>(run: () => Promise<R>): __Req<R> {",
4111
- " let __p: Promise<R> | undefined;",
4112
- " const __promise = () => {",
4113
- " __p ??= run();",
4114
- " return __p;",
4115
- " };",
4116
- " return {",
4117
- " then: (onfulfilled, onrejected) => __promise().then(onfulfilled, onrejected),",
4118
- " catch: (onrejected) => __promise().catch(onrejected),",
4119
- " finally: (onfinally) => __promise().finally(onfinally),",
4120
- " fetch: run,",
4121
- " };",
4204
+ " ): Promise<T1 | T2> {",
4205
+ " this.__p ??= this.__run();",
4206
+ " return this.__p.then(onfulfilled, onrejected);",
4207
+ " }",
4208
+ "}",
4209
+ "function __req<R, M extends object = {}>(run: () => Promise<R>, members?: M): RequestHandle<R, M> {",
4210
+ " return Object.assign(new __Request<R>(run), { fetch: run }, members) as RequestHandle<R, M>;",
4122
4211
  "}",
4123
4212
  ""
4124
4213
  ];
@@ -4136,18 +4225,26 @@ function emitFilterFieldGuard() {
4136
4225
  ];
4137
4226
  }
4138
4227
  function renderLeaf(pad, objKey, req, requestExpr, members, streamExpr) {
4139
- const lines = [`${pad}${objKey}: (input?: ${req.inputType}) => ({`];
4140
- lines.push(`${pad} ...__req<${req.responseType}>(() => ${requestExpr}),`);
4228
+ const memberLines = [];
4141
4229
  if (req.filterFieldsExpr) {
4142
- lines.push(`${pad} filterFields: ${req.filterFieldsExpr},`);
4230
+ memberLines.push(`${pad} filterFields: ${req.filterFieldsExpr},`);
4143
4231
  }
4144
4232
  if (streamExpr) {
4145
- lines.push(`${pad} stream: () => ${streamExpr},`);
4233
+ memberLines.push(`${pad} stream: () => ${streamExpr},`);
4146
4234
  }
4147
4235
  for (const [name, value] of Object.entries(members)) {
4148
- lines.push(`${pad} ${name}: ${value},`);
4149
- }
4150
- lines.push(`${pad}}),`);
4236
+ memberLines.push(`${pad} ${name}: ${value},`);
4237
+ }
4238
+ const body = [requestExpr, ...memberLines].join("\n");
4239
+ const param = /\binput\b/.test(body) ? `input?: ${req.inputType}` : "";
4240
+ const lines = [`${pad}${objKey}: (${param}) =>`];
4241
+ if (memberLines.length === 0) {
4242
+ lines.push(`${pad} __req<${req.responseType}>(() => ${requestExpr}),`);
4243
+ return lines;
4244
+ }
4245
+ lines.push(`${pad} __req(() => ${requestExpr}, {`);
4246
+ lines.push(...memberLines);
4247
+ lines.push(`${pad} }),`);
4151
4248
  return lines;
4152
4249
  }
4153
4250
  function renderStreamExpr(req) {
@@ -4281,6 +4378,23 @@ var EMPTY_PATH_NAMESPACE = [
4281
4378
  function isClientCallable(route) {
4282
4379
  return route.method.toUpperCase() !== "ALL";
4283
4380
  }
4381
+ var ROUTES_IMPORT_PLACEHOLDER = "/* @@routes-import@@ */";
4382
+ var ROUTES_EXPORTS = [
4383
+ // Called, never mentioned in prose: match the call so a comment saying "route" doesn't count.
4384
+ { name: "route", typeOnly: false },
4385
+ { name: "ROUTES", typeOnly: false },
4386
+ { name: "RouteName", typeOnly: true },
4387
+ { name: "ExtractParams", typeOnly: true },
4388
+ { name: "RouteParams", typeOnly: true }
4389
+ ];
4390
+ function finishRoutesImport(lines) {
4391
+ const body = lines.filter((l) => l !== ROUTES_IMPORT_PLACEHOLDER).join("\n");
4392
+ const used = ROUTES_EXPORTS.filter(
4393
+ ({ name }) => new RegExp(name === "route" ? "\\broute\\(" : `\\b${name}\\b`).test(body)
4394
+ ).map(({ name, typeOnly }) => typeOnly ? `type ${name}` : name);
4395
+ const importLine = used.length ? `import { ${used.join(", ")} } from './routes.js';` : null;
4396
+ return lines.flatMap((l) => l === ROUTES_IMPORT_PLACEHOLDER ? importLine ? [importLine] : [] : [l]).join("\n");
4397
+ }
4284
4398
  function buildApiFile(routes, outDir, opts = {}) {
4285
4399
  const fetcherImportPath = opts.fetcherImportPath;
4286
4400
  const serialization = opts.serialization ?? "json";
@@ -4340,9 +4454,7 @@ function buildApiFile(routes, outDir, opts = {}) {
4340
4454
  for (const imp of ext.apiHeader?.(ctx)?.imports ?? []) pushImport(imp);
4341
4455
  }
4342
4456
  lines.push(...extImports);
4343
- lines.push(
4344
- "import { route, ROUTES, type RouteName, type ExtractParams, type RouteParams } from './routes.js';"
4345
- );
4457
+ lines.push(ROUTES_IMPORT_PLACEHOLDER);
4346
4458
  const runtimeImport = fetcherImportPath ?? "@dudousxd/nestjs-client";
4347
4459
  lines.push(`import type { Fetcher } from '${runtimeImport}';`);
4348
4460
  if (serialization === "json" && contracted.length > 0) {
@@ -4393,7 +4505,7 @@ function buildApiFile(routes, outDir, opts = {}) {
4393
4505
  lines.push(...statements, "");
4394
4506
  }
4395
4507
  }
4396
- return lines.join("\n");
4508
+ return finishRoutesImport(lines);
4397
4509
  }
4398
4510
  const tree = /* @__PURE__ */ new Map();
4399
4511
  for (const r of contracted) {
@@ -4446,7 +4558,7 @@ function buildApiFile(routes, outDir, opts = {}) {
4446
4558
  lines.push(...statements, "");
4447
4559
  }
4448
4560
  }
4449
- return lines.join("\n");
4561
+ return finishRoutesImport(lines);
4450
4562
  }
4451
4563
 
4452
4564
  // src/emit/emit-cache.ts
@@ -4459,6 +4571,7 @@ async function emitCache(pages, outDir) {
4459
4571
  const s = await stat(p.absolutePath);
4460
4572
  return {
4461
4573
  name: p.name,
4574
+ scope: p.scope ?? "default",
4462
4575
  relativePath: p.relativePath,
4463
4576
  mtime: s.mtime.toISOString()
4464
4577
  };
@@ -5250,10 +5363,11 @@ async function emitPages(pages, outDir, _options = {}) {
5250
5363
  }).join("\n");
5251
5364
  const propsHelper = "\nexport type InertiaProps<K extends InertiaPageName> = import('@dudousxd/nestjs-inertia').InertiaPages[K];\n";
5252
5365
  const sharedPropsBlock = buildSharedPropsBlock(_options.sharedProps ?? null);
5366
+ const scopesBlock = buildScopesBlock(pages);
5253
5367
  const content = `// Generated by @dudousxd/nestjs-codegen. Do not edit.
5254
5368
 
5255
5369
  export type InertiaPageName = ${pageNameUnion};
5256
- ${propsHelper}
5370
+ ${propsHelper}${scopesBlock}
5257
5371
  declare module '@dudousxd/nestjs-inertia' {
5258
5372
  interface InertiaPages {
5259
5373
  ${augBody}
@@ -5262,6 +5376,31 @@ ${sharedPropsBlock}}
5262
5376
  `;
5263
5377
  await writeFile7(join11(outDir, "pages.d.ts"), content, "utf8");
5264
5378
  }
5379
+ function buildScopesBlock(pages) {
5380
+ const byScope = /* @__PURE__ */ new Map();
5381
+ for (const page of pages) {
5382
+ const scope = page.scope ?? "default";
5383
+ const names = byScope.get(scope) ?? [];
5384
+ names.push(JSON.stringify(page.name));
5385
+ byScope.set(scope, names);
5386
+ }
5387
+ if (![...byScope.keys()].some((scope) => scope !== "default")) return "";
5388
+ if (!byScope.has("default")) byScope.set("default", []);
5389
+ const scopes = ["default", ...[...byScope.keys()].filter((s) => s !== "default")];
5390
+ const body = scopes.map((scope) => {
5391
+ const key = needsQuotes(scope) ? JSON.stringify(scope) : scope;
5392
+ const names = byScope.get(scope) ?? [];
5393
+ return ` ${key}: ${names.length ? names.join(" | ") : "never"};`;
5394
+ }).join("\n");
5395
+ return `
5396
+ /** The page names of each Inertia app: \`default\` (forRoot) and every \`pages.scopes\` entry. */
5397
+ export interface InertiaScopePages {
5398
+ ${body}
5399
+ }
5400
+
5401
+ export type InertiaScopePageName<S extends keyof InertiaScopePages> = InertiaScopePages[S];
5402
+ `;
5403
+ }
5265
5404
  function buildSharedPropsBlock(sharedProps) {
5266
5405
  if (!sharedProps) return "";
5267
5406
  if (sharedProps.isImportRef) {
@@ -5469,8 +5608,7 @@ function diffConfigKeyHashes(previous, current) {
5469
5608
  return [...keys].filter((key) => previous[key] !== current[key]).sort();
5470
5609
  }
5471
5610
  async function discoverInputFiles(config) {
5472
- const globs = [config.contracts.glob, config.forms.watch];
5473
- if (config.pages) globs.push(config.pages.glob);
5611
+ const globs = [config.contracts.glob, config.forms.watch, ...allPageGlobs(config)];
5474
5612
  const cwd = config.codegen.cwd;
5475
5613
  const matched = await fg3(globs, { cwd, absolute: true, onlyFiles: true });
5476
5614
  return [...new Set(matched)].sort();
@@ -5601,12 +5739,7 @@ async function generate(config, inputRoutes = [], entryPoint = "cli") {
5601
5739
  }
5602
5740
  if (config.pages) {
5603
5741
  const pagesConfig = config.pages;
5604
- const pages = await discoverPages({
5605
- glob: pagesConfig.glob,
5606
- cwd: config.codegen.cwd,
5607
- propsExport: pagesConfig.propsExport,
5608
- componentNameStrategy: pagesConfig.componentNameStrategy
5609
- });
5742
+ const pages = await discoverPageScopes(config);
5610
5743
  const sharedProps = discoverSharedPropsFromConfig(config);
5611
5744
  await emitPages(pages, config.codegen.outDir, {
5612
5745
  propsExport: pagesConfig.propsExport,
@@ -5781,12 +5914,17 @@ async function watch(config, onChange, options = {}) {
5781
5914
  await runInitialPass();
5782
5915
  }
5783
5916
  let pagesDebounceTimer;
5784
- const pagesGlob = config.pages?.glob ?? ".nestjs-codegen-no-pages";
5785
- const pagesWatcher = chokidar.watch(join16(config.codegen.cwd, pagesGlob), {
5786
- ignoreInitial: true,
5787
- persistent: true,
5788
- awaitWriteFinish: { stabilityThreshold: 80, pollInterval: 20 }
5789
- });
5917
+ const pageGlobs = allPageGlobs(config);
5918
+ const pagesWatcher = chokidar.watch(
5919
+ (pageGlobs.length ? pageGlobs : [".nestjs-codegen-no-pages"]).map(
5920
+ (glob) => join16(config.codegen.cwd, glob)
5921
+ ),
5922
+ {
5923
+ ignoreInitial: true,
5924
+ persistent: true,
5925
+ awaitWriteFinish: { stabilityThreshold: 80, pollInterval: 20 }
5926
+ }
5927
+ );
5790
5928
  function schedulePagesRegenerate() {
5791
5929
  if (pagesDebounceTimer !== void 0) {
5792
5930
  clearTimeout(pagesDebounceTimer);
@@ -5866,7 +6004,7 @@ async function watch(config, onChange, options = {}) {
5866
6004
  }
5867
6005
 
5868
6006
  // src/index.ts
5869
- var VERSION = "0.26.0";
6007
+ var VERSION = "0.27.0";
5870
6008
 
5871
6009
  // src/cli/codegen.ts
5872
6010
  async function runCodegen(opts = {}) {