@azure-net/cli 5.0.0 → 5.1.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/bin/azure-net.js CHANGED
@@ -179,6 +179,13 @@ generateCommand
179
179
  const handler = (await import('../src/generate/module/generateModulePreset.js')).default;
180
180
  await handler();
181
181
  });
182
+ generateCommand
183
+ .command('routes')
184
+ .description('Generate typed SvelteKit routes list')
185
+ .action(async () => {
186
+ const handler = (await import('../src/generate/routes/generateRoutes.js')).default;
187
+ await handler();
188
+ });
182
189
  checkCommand
183
190
  .command('presenter-names')
184
191
  .description('Check duplicate presenter names in src')
package/bin/azure-net.ts CHANGED
@@ -209,6 +209,14 @@ generateCommand
209
209
  await handler();
210
210
  });
211
211
 
212
+ generateCommand
213
+ .command('routes')
214
+ .description('Generate typed SvelteKit routes list')
215
+ .action(async () => {
216
+ const handler = (await import('../src/generate/routes/generateRoutes.js')).default;
217
+ await handler();
218
+ });
219
+
212
220
  checkCommand
213
221
  .command('presenter-names')
214
222
  .description('Check duplicate presenter names in src')
package/package.json CHANGED
@@ -1,12 +1,18 @@
1
1
  {
2
2
  "name": "@azure-net/cli",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "azure-net": "bin/azure-net.js"
7
7
  },
8
+ "license": "MIT",
9
+ "author": "Pixel1917",
10
+ "publishConfig": {
11
+ "access": "public",
12
+ "registry": "https://registry.npmjs.org/"
13
+ },
8
14
  "peerDependencies": {
9
- "@azure-net/kit": "^4.0.0"
15
+ "@azure-net/kit": ">=5.0.0-4"
10
16
  },
11
17
  "dependencies": {
12
18
  "commander": "^11",
@@ -16,6 +22,7 @@
16
22
  "@eslint/js": "^9.31.0",
17
23
  "@types/node": "^24.0.3",
18
24
  "@types/prompts": "^2.4.9",
25
+ "env-cmd": "^11.0.0",
19
26
  "eslint": "^9.31.0",
20
27
  "prettier": "^3.6.2",
21
28
  "typescript": "^5",
@@ -119,6 +119,7 @@ const normalizePresenterName = (rawName, fallbackUseCasesName) => {
119
119
  const isPrimitive = (typeName) => PRIMITIVE_TYPES.has(String(typeName ?? '').trim());
120
120
  const normalizeTypeImports = (types) => Array.from(new Set(types.filter((typeName) => !isPrimitive(typeName))));
121
121
  const isPathParamName = (name) => name === 'pathParam' || name === 'path';
122
+ const toTypeFileName = (typeName) => String(typeName ?? '').replace(/^I/, '') || 'Type';
122
123
  const buildMethodInvocation = (method, useCasesName, schemaByMethod) => {
123
124
  const paramsSignature = method.params.map((item) => `${item.name}: ${item.type}`).join(', ');
124
125
  const argNames = method.params.map((item) => item.name);
@@ -153,6 +154,34 @@ const getApplicationProviderFile = async (contextName) => {
153
154
  return candidate ? path.join(providersPath, candidate) : null;
154
155
  }
155
156
  };
157
+ const resolveDomainInterfaceFile = async ({ contextName, domainName, interfaceName }) => {
158
+ const portsPath = path.join(process.cwd(), 'src', 'app', contextName, 'layers', 'domain', domainName, 'ports');
159
+ const preferredPath = path.join(portsPath, `${toTypeFileName(interfaceName)}.ts`);
160
+ const legacyPath = path.join(portsPath, `${interfaceName}.ts`);
161
+ for (const candidate of [preferredPath, legacyPath]) {
162
+ try {
163
+ await fs.access(candidate);
164
+ return candidate;
165
+ } catch {
166
+ // Try the next candidate.
167
+ }
168
+ }
169
+ try {
170
+ const files = await fs.readdir(portsPath);
171
+ for (const fileName of files) {
172
+ if (!fileName.endsWith('.ts') || fileName === 'index.ts') continue;
173
+ const candidate = path.join(portsPath, fileName);
174
+ const content = await fs.readFile(candidate, 'utf-8');
175
+ const escapedName = interfaceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
176
+ if (new RegExp(`export\\s+interface\\s+${escapedName}\\b`).test(content)) {
177
+ return candidate;
178
+ }
179
+ }
180
+ } catch {
181
+ return null;
182
+ }
183
+ return null;
184
+ };
156
185
  export default async function generatePresenter(options = {}) {
157
186
  let contextName = options.contextName ? String(options.contextName) : null;
158
187
  if (!contextName) {
@@ -216,17 +245,18 @@ export default async function generatePresenter(options = {}) {
216
245
  return;
217
246
  }
218
247
  const repositoryHttpMethods = parseRepositoryHttpMethods(repositoryInfo.content);
219
- const domainPortsInterfacePath = path.join(
220
- process.cwd(),
221
- 'src',
222
- 'app',
248
+ const domainPortsInterfacePath = await resolveDomainInterfaceFile({
223
249
  contextName,
224
- 'layers',
225
- 'domain',
226
- useCasesInfo.domainName,
227
- 'ports',
228
- `${useCasesInfo.repositoryInterfaceName}.ts`
229
- );
250
+ domainName: useCasesInfo.domainName,
251
+ interfaceName: useCasesInfo.repositoryInterfaceName
252
+ });
253
+ if (!domainPortsInterfacePath) {
254
+ console.error(
255
+ `❌ Repository interface "${useCasesInfo.repositoryInterfaceName}" was not found in ${path.join(process.cwd(), 'src', 'app', contextName, 'layers', 'domain', useCasesInfo.domainName, 'ports')}`
256
+ );
257
+ process.exitCode = 1;
258
+ return;
259
+ }
230
260
  const repositoryInterfaceContent = await fs.readFile(domainPortsInterfacePath, 'utf-8');
231
261
  const repositoryMethods = parseRepositoryInterfaceMethods(repositoryInterfaceContent).filter((method) =>
232
262
  useCasesInfo.methods.includes(method.name)
@@ -142,6 +142,7 @@ const isPrimitive = (typeName: string): boolean => PRIMITIVE_TYPES.has(String(ty
142
142
 
143
143
  const normalizeTypeImports = (types: string[]): string[] => Array.from(new Set(types.filter((typeName) => !isPrimitive(typeName))));
144
144
  const isPathParamName = (name: string): boolean => name === 'pathParam' || name === 'path';
145
+ const toTypeFileName = (typeName: string): string => String(typeName ?? '').replace(/^I/, '') || 'Type';
145
146
 
146
147
  const buildMethodInvocation = (
147
148
  method: RepositoryMethod & { httpMethod: string },
@@ -186,6 +187,46 @@ const getApplicationProviderFile = async (contextName: string): Promise<string |
186
187
  }
187
188
  };
188
189
 
190
+ const resolveDomainInterfaceFile = async ({
191
+ contextName,
192
+ domainName,
193
+ interfaceName
194
+ }: {
195
+ contextName: string;
196
+ domainName: string;
197
+ interfaceName: string;
198
+ }): Promise<string | null> => {
199
+ const portsPath = path.join(process.cwd(), 'src', 'app', contextName, 'layers', 'domain', domainName, 'ports');
200
+ const preferredPath = path.join(portsPath, `${toTypeFileName(interfaceName)}.ts`);
201
+ const legacyPath = path.join(portsPath, `${interfaceName}.ts`);
202
+
203
+ for (const candidate of [preferredPath, legacyPath]) {
204
+ try {
205
+ await fs.access(candidate);
206
+ return candidate;
207
+ } catch {
208
+ // Try the next candidate.
209
+ }
210
+ }
211
+
212
+ try {
213
+ const files = await fs.readdir(portsPath);
214
+ for (const fileName of files) {
215
+ if (!fileName.endsWith('.ts') || fileName === 'index.ts') continue;
216
+ const candidate = path.join(portsPath, fileName);
217
+ const content = await fs.readFile(candidate, 'utf-8');
218
+ const escapedName = interfaceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
219
+ if (new RegExp(`export\\s+interface\\s+${escapedName}\\b`).test(content)) {
220
+ return candidate;
221
+ }
222
+ }
223
+ } catch {
224
+ return null;
225
+ }
226
+
227
+ return null;
228
+ };
229
+
189
230
  type GeneratePresenterOptions = {
190
231
  contextName?: string;
191
232
  useCasesName?: string;
@@ -263,17 +304,27 @@ export default async function generatePresenter(options: GeneratePresenterOption
263
304
  }
264
305
 
265
306
  const repositoryHttpMethods = parseRepositoryHttpMethods(repositoryInfo.content);
266
- const domainPortsInterfacePath = path.join(
267
- process.cwd(),
268
- 'src',
269
- 'app',
307
+ const domainPortsInterfacePath = await resolveDomainInterfaceFile({
270
308
  contextName,
271
- 'layers',
272
- 'domain',
273
- useCasesInfo.domainName,
274
- 'ports',
275
- `${useCasesInfo.repositoryInterfaceName}.ts`
276
- );
309
+ domainName: useCasesInfo.domainName,
310
+ interfaceName: useCasesInfo.repositoryInterfaceName
311
+ });
312
+ if (!domainPortsInterfacePath) {
313
+ console.error(
314
+ `❌ Repository interface "${useCasesInfo.repositoryInterfaceName}" was not found in ${path.join(
315
+ process.cwd(),
316
+ 'src',
317
+ 'app',
318
+ contextName,
319
+ 'layers',
320
+ 'domain',
321
+ useCasesInfo.domainName,
322
+ 'ports'
323
+ )}`
324
+ );
325
+ process.exitCode = 1;
326
+ return;
327
+ }
277
328
  const repositoryInterfaceContent = await fs.readFile(domainPortsInterfacePath, 'utf-8');
278
329
  const repositoryMethods = parseRepositoryInterfaceMethods(repositoryInterfaceContent).filter((method) =>
279
330
  useCasesInfo.methods.includes(method.name)
@@ -0,0 +1,217 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import prompts from 'prompts';
4
+ import { loadUserConfig } from '../../utils/loadConfig.js';
5
+ import { getSharedContextRootPath, getSharedState } from '../../utils/sharedFoundation.js';
6
+ import { toCamelCase } from '../../utils/contextUtils.js';
7
+ const ROUTES_DIR = path.join(process.cwd(), 'src', 'routes');
8
+ const ROUTE_FILE_PATTERN = /^\+(page|server)(?:\.server)?\.(?:svelte|ts|js)$/;
9
+ const PAGE_FILE_PATTERN = /^\+page(?:\.server)?\.(?:svelte|ts|js)$/;
10
+ const SERVER_FILE_PATTERN = /^\+server\.(?:ts|js)$/;
11
+ const isRouteGroup = (segment) => /^\(.+\)$/.test(segment);
12
+ const getRouteGroupName = (segment) => segment.slice(1, -1).trim();
13
+ const extractParamName = (rawParam) => {
14
+ const withoutRest = rawParam.startsWith('...') ? rawParam.slice(3) : rawParam;
15
+ const matcherIndex = withoutRest.indexOf('=');
16
+ return matcherIndex === -1 ? withoutRest : withoutRest.slice(0, matcherIndex);
17
+ };
18
+ const convertDynamicSegments = (segment) => {
19
+ const optionalMatch = segment.match(/^\[\[(.+)\]\]$/);
20
+ if (optionalMatch?.[1]) {
21
+ const paramName = extractParamName(optionalMatch[1]);
22
+ return { variants: ['', `{${paramName}}`], params: [paramName] };
23
+ }
24
+ const params = [];
25
+ const converted = segment.replace(/\[(.+?)\]/g, (_match, rawParam) => {
26
+ const paramName = extractParamName(rawParam);
27
+ if (paramName.length) params.push(paramName);
28
+ return `{${paramName}}`;
29
+ });
30
+ return { variants: [converted], params };
31
+ };
32
+ const normalizeUrl = (segments) => {
33
+ const filtered = segments.filter(Boolean);
34
+ return filtered.length ? `/${filtered.join('/')}` : '/';
35
+ };
36
+ const createRouteVariants = (segments) => {
37
+ let variants = [[]];
38
+ const dynamicParams = new Set();
39
+ for (const segment of segments) {
40
+ if (isRouteGroup(segment)) continue;
41
+ const converted = convertDynamicSegments(segment);
42
+ for (const param of converted.params) {
43
+ dynamicParams.add(param);
44
+ }
45
+ const nextVariants = [];
46
+ for (const variant of variants) {
47
+ for (const nextSegment of converted.variants) {
48
+ nextVariants.push(nextSegment ? [...variant, nextSegment] : [...variant]);
49
+ }
50
+ }
51
+ variants = nextVariants;
52
+ }
53
+ return {
54
+ urls: [...new Set(variants.map(normalizeUrl))],
55
+ dynamicParams: [...dynamicParams]
56
+ };
57
+ };
58
+ const hasRouteFile = (files) => files.some((file) => ROUTE_FILE_PATTERN.test(file));
59
+ const hasPageFile = (files) => files.some((file) => PAGE_FILE_PATTERN.test(file));
60
+ const hasServerFile = (files) => files.some((file) => SERVER_FILE_PATTERN.test(file));
61
+ const readDirSafe = async (dir) => {
62
+ try {
63
+ return await fs.readdir(dir, { withFileTypes: true });
64
+ } catch {
65
+ return [];
66
+ }
67
+ };
68
+ const collectRoutes = async (dir, segments = [], groupStack = [], rootGroup) => {
69
+ const entries = (await readDirSafe(dir)).sort((a, b) => a.name.localeCompare(b.name));
70
+ const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
71
+ const dirs = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'));
72
+ const routes = [];
73
+ const routeVariants = createRouteVariants(segments);
74
+ if (hasRouteFile(files)) {
75
+ for (const url of routeVariants.urls) {
76
+ routes.push({
77
+ url,
78
+ routeDir: dir,
79
+ relativeDir: path.relative(ROUTES_DIR, dir) || '.',
80
+ segments,
81
+ dynamicParams: routeVariants.dynamicParams,
82
+ rootGroup,
83
+ groupStack,
84
+ depth: segments.length,
85
+ hasChildren: dirs.length > 0,
86
+ hasPage: hasPageFile(files),
87
+ hasServer: hasServerFile(files)
88
+ });
89
+ }
90
+ }
91
+ for (const child of dirs) {
92
+ const childGroupName = isRouteGroup(child.name) ? getRouteGroupName(child.name) : undefined;
93
+ const nextGroupStack = childGroupName ? [...groupStack, childGroupName] : groupStack;
94
+ const nextRootGroup = rootGroup ?? (segments.length === 0 ? childGroupName : undefined);
95
+ const childRoutes = await collectRoutes(path.join(dir, child.name), [...segments, child.name], nextGroupStack, nextRootGroup);
96
+ routes.push(...childRoutes);
97
+ }
98
+ return routes;
99
+ };
100
+ const sanitizeGroupName = (value) => {
101
+ const trimmed = value.trim();
102
+ return trimmed.length ? trimmed : 'routes';
103
+ };
104
+ const toRouteVariableName = (groupName) => {
105
+ const base = toCamelCase(sanitizeGroupName(groupName).replace(/[^\p{L}\p{N}_-]+/gu, '-'));
106
+ return `${base || 'routes'}Routes`;
107
+ };
108
+ const ensureUniqueVariableName = (name, used) => {
109
+ if (!used.has(name)) {
110
+ used.add(name);
111
+ return name;
112
+ }
113
+ let index = 2;
114
+ let nextName = `${name}${index}`;
115
+ while (used.has(nextName)) {
116
+ index += 1;
117
+ nextName = `${name}${index}`;
118
+ }
119
+ used.add(nextName);
120
+ return nextName;
121
+ };
122
+ const createGroupMap = (routes, mode, resolver) => {
123
+ const groups = new Map();
124
+ for (const route of routes) {
125
+ const groupName =
126
+ mode === 'none'
127
+ ? 'routes'
128
+ : mode === 'root-groups'
129
+ ? (route.rootGroup ?? 'routes')
130
+ : (sanitizeGroupName(String(resolver?.(route) ?? 'routes')) ?? 'routes');
131
+ const list = groups.get(groupName) ?? [];
132
+ list.push(route);
133
+ groups.set(groupName, list);
134
+ }
135
+ return groups;
136
+ };
137
+ const createStringArray = (items, indent = '\t') => {
138
+ if (!items.length) return '[]';
139
+ return `[\n${items.map((item) => `${indent}'${item}'`).join(',\n')}\n]`;
140
+ };
141
+ const createGeneratedFile = (groups) => {
142
+ const usedVariables = new Set();
143
+ const groupEntries = [...groups.entries()].map(([groupName, routes]) => ({
144
+ groupName,
145
+ variableName: ensureUniqueVariableName(
146
+ groupName === 'routes' ? (groups.size === 1 ? 'routes' : 'ungroupedRoutes') : toRouteVariableName(groupName),
147
+ usedVariables
148
+ ),
149
+ routes: [...new Set(routes.map((route) => route.url))].sort((a, b) => a.localeCompare(b))
150
+ }));
151
+ const lines = ['// This file is generated by `azure-net generate routes`.', '// Do not edit it manually.', ''];
152
+ for (const group of groupEntries) {
153
+ lines.push(`export const ${group.variableName} = ${createStringArray(group.routes)} as const;`);
154
+ lines.push('');
155
+ }
156
+ if (groupEntries.length !== 1 || groupEntries[0]?.variableName !== 'routes') {
157
+ lines.push(`export const routes = [${groupEntries.map((group) => `...${group.variableName}`).join(', ')}] as const;`);
158
+ lines.push('');
159
+ }
160
+ lines.push('export const routeGroups = {');
161
+ lines.push(
162
+ ...groupEntries.map((group, index) => `\t'${group.groupName}': ${group.variableName}${index === groupEntries.length - 1 ? '' : ','}`)
163
+ );
164
+ lines.push('} as const;');
165
+ lines.push('');
166
+ lines.push('export type Route = (typeof routes)[number];');
167
+ lines.push('export type RouteGroupName = keyof typeof routeGroups;');
168
+ lines.push('');
169
+ return `${lines.join('\n')}\n`;
170
+ };
171
+ const selectGroupingMode = async () => {
172
+ const { mode } = await prompts({
173
+ type: 'select',
174
+ name: 'mode',
175
+ message: 'How should routes be grouped?',
176
+ choices: [
177
+ { title: 'By root route groups: (account), (landing), ...', value: 'root-groups' },
178
+ { title: 'By azure-net.config.ts groupRoutesFunc', value: 'config-function' },
179
+ { title: 'Do not group, generate one routes array', value: 'none' }
180
+ ]
181
+ });
182
+ return mode ? mode : null;
183
+ };
184
+ const getConfigRouteGroupResolver = async () => {
185
+ const config = await loadUserConfig();
186
+ if (typeof config.groupRoutesFunc !== 'function') {
187
+ console.error('❌ `groupRoutesFunc` is missing in azure-net.config.ts/js or is not a function.');
188
+ console.error('Example:');
189
+ console.error('export default {');
190
+ console.error("\tgroupRoutesFunc: (route) => route.rootGroup ?? 'app'");
191
+ console.error('};');
192
+ return null;
193
+ }
194
+ return config.groupRoutesFunc;
195
+ };
196
+ export default async function generateRoutes() {
197
+ const mode = await selectGroupingMode();
198
+ if (!mode) return;
199
+ const { sharedContext } = await getSharedState();
200
+ const resolver = mode === 'config-function' ? await getConfigRouteGroupResolver() : undefined;
201
+ if (mode === 'config-function' && !resolver) {
202
+ process.exitCode = 1;
203
+ return;
204
+ }
205
+ const routes = await collectRoutes(ROUTES_DIR);
206
+ if (!routes.length) {
207
+ console.warn('⚠️ No SvelteKit routes found in src/routes');
208
+ return;
209
+ }
210
+ const groups = createGroupMap(routes, mode, resolver ?? undefined);
211
+ const outputDir = path.join(getSharedContextRootPath(sharedContext.name), 'runtime', 'routes');
212
+ const outputFile = path.join(outputDir, 'index.ts');
213
+ await fs.mkdir(outputDir, { recursive: true });
214
+ await fs.writeFile(outputFile, createGeneratedFile(groups), 'utf-8');
215
+ console.log(`✅ Routes generated: ${outputFile}`);
216
+ }
217
+ //# sourceMappingURL=generateRoutes.js.map
@@ -0,0 +1,282 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import prompts from 'prompts';
4
+ import { loadUserConfig } from '../../utils/loadConfig.js';
5
+ import { getSharedContextRootPath, getSharedState } from '../../utils/sharedFoundation.js';
6
+ import { toCamelCase } from '../../utils/contextUtils.js';
7
+
8
+ type RouteGroupingMode = 'root-groups' | 'config-function' | 'none';
9
+
10
+ export type RouteGenerationMeta = {
11
+ url: string;
12
+ routeDir: string;
13
+ relativeDir: string;
14
+ segments: string[];
15
+ dynamicParams: string[];
16
+ rootGroup?: string;
17
+ groupStack: string[];
18
+ depth: number;
19
+ hasChildren: boolean;
20
+ hasPage: boolean;
21
+ hasServer: boolean;
22
+ };
23
+
24
+ type RouteRecord = RouteGenerationMeta;
25
+ type RouteGroupResolver = (route: RouteGenerationMeta) => string | null | undefined;
26
+
27
+ const ROUTES_DIR = path.join(process.cwd(), 'src', 'routes');
28
+ const ROUTE_FILE_PATTERN = /^\+(page|server)(?:\.server)?\.(?:svelte|ts|js)$/;
29
+ const PAGE_FILE_PATTERN = /^\+page(?:\.server)?\.(?:svelte|ts|js)$/;
30
+ const SERVER_FILE_PATTERN = /^\+server\.(?:ts|js)$/;
31
+
32
+ const isRouteGroup = (segment: string): boolean => /^\(.+\)$/.test(segment);
33
+ const getRouteGroupName = (segment: string): string => segment.slice(1, -1).trim();
34
+
35
+ const extractParamName = (rawParam: string): string => {
36
+ const withoutRest = rawParam.startsWith('...') ? rawParam.slice(3) : rawParam;
37
+ const matcherIndex = withoutRest.indexOf('=');
38
+ return matcherIndex === -1 ? withoutRest : withoutRest.slice(0, matcherIndex);
39
+ };
40
+
41
+ const convertDynamicSegments = (segment: string): { variants: string[]; params: string[] } => {
42
+ const optionalMatch = segment.match(/^\[\[(.+)\]\]$/);
43
+ if (optionalMatch?.[1]) {
44
+ const paramName = extractParamName(optionalMatch[1]);
45
+ return { variants: ['', `{${paramName}}`], params: [paramName] };
46
+ }
47
+
48
+ const params: string[] = [];
49
+ const converted = segment.replace(/\[(.+?)\]/g, (_match, rawParam: string) => {
50
+ const paramName = extractParamName(rawParam);
51
+ if (paramName.length) params.push(paramName);
52
+ return `{${paramName}}`;
53
+ });
54
+
55
+ return { variants: [converted], params };
56
+ };
57
+
58
+ const normalizeUrl = (segments: string[]): string => {
59
+ const filtered = segments.filter(Boolean);
60
+ return filtered.length ? `/${filtered.join('/')}` : '/';
61
+ };
62
+
63
+ const createRouteVariants = (segments: string[]): { urls: string[]; dynamicParams: string[] } => {
64
+ let variants: string[][] = [[]];
65
+ const dynamicParams = new Set<string>();
66
+
67
+ for (const segment of segments) {
68
+ if (isRouteGroup(segment)) continue;
69
+
70
+ const converted = convertDynamicSegments(segment);
71
+ for (const param of converted.params) {
72
+ dynamicParams.add(param);
73
+ }
74
+
75
+ const nextVariants: string[][] = [];
76
+ for (const variant of variants) {
77
+ for (const nextSegment of converted.variants) {
78
+ nextVariants.push(nextSegment ? [...variant, nextSegment] : [...variant]);
79
+ }
80
+ }
81
+ variants = nextVariants;
82
+ }
83
+
84
+ return {
85
+ urls: [...new Set(variants.map(normalizeUrl))],
86
+ dynamicParams: [...dynamicParams]
87
+ };
88
+ };
89
+
90
+ const hasRouteFile = (files: string[]): boolean => files.some((file) => ROUTE_FILE_PATTERN.test(file));
91
+ const hasPageFile = (files: string[]): boolean => files.some((file) => PAGE_FILE_PATTERN.test(file));
92
+ const hasServerFile = (files: string[]): boolean => files.some((file) => SERVER_FILE_PATTERN.test(file));
93
+
94
+ const readDirSafe = async (dir: string): Promise<import('node:fs').Dirent[]> => {
95
+ try {
96
+ return await fs.readdir(dir, { withFileTypes: true });
97
+ } catch {
98
+ return [];
99
+ }
100
+ };
101
+
102
+ const collectRoutes = async (
103
+ dir: string,
104
+ segments: string[] = [],
105
+ groupStack: string[] = [],
106
+ rootGroup?: string
107
+ ): Promise<RouteRecord[]> => {
108
+ const entries = (await readDirSafe(dir)).sort((a, b) => a.name.localeCompare(b.name));
109
+ const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
110
+ const dirs = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'));
111
+ const routes: RouteRecord[] = [];
112
+ const routeVariants = createRouteVariants(segments);
113
+
114
+ if (hasRouteFile(files)) {
115
+ for (const url of routeVariants.urls) {
116
+ routes.push({
117
+ url,
118
+ routeDir: dir,
119
+ relativeDir: path.relative(ROUTES_DIR, dir) || '.',
120
+ segments,
121
+ dynamicParams: routeVariants.dynamicParams,
122
+ rootGroup,
123
+ groupStack,
124
+ depth: segments.length,
125
+ hasChildren: dirs.length > 0,
126
+ hasPage: hasPageFile(files),
127
+ hasServer: hasServerFile(files)
128
+ });
129
+ }
130
+ }
131
+
132
+ for (const child of dirs) {
133
+ const childGroupName = isRouteGroup(child.name) ? getRouteGroupName(child.name) : undefined;
134
+ const nextGroupStack = childGroupName ? [...groupStack, childGroupName] : groupStack;
135
+ const nextRootGroup = rootGroup ?? (segments.length === 0 ? childGroupName : undefined);
136
+ const childRoutes = await collectRoutes(path.join(dir, child.name), [...segments, child.name], nextGroupStack, nextRootGroup);
137
+ routes.push(...childRoutes);
138
+ }
139
+
140
+ return routes;
141
+ };
142
+
143
+ const sanitizeGroupName = (value: string): string => {
144
+ const trimmed = value.trim();
145
+ return trimmed.length ? trimmed : 'routes';
146
+ };
147
+
148
+ const toRouteVariableName = (groupName: string): string => {
149
+ const base = toCamelCase(sanitizeGroupName(groupName).replace(/[^\p{L}\p{N}_-]+/gu, '-'));
150
+ return `${base || 'routes'}Routes`;
151
+ };
152
+
153
+ const ensureUniqueVariableName = (name: string, used: Set<string>): string => {
154
+ if (!used.has(name)) {
155
+ used.add(name);
156
+ return name;
157
+ }
158
+
159
+ let index = 2;
160
+ let nextName = `${name}${index}`;
161
+ while (used.has(nextName)) {
162
+ index += 1;
163
+ nextName = `${name}${index}`;
164
+ }
165
+ used.add(nextName);
166
+ return nextName;
167
+ };
168
+
169
+ const createGroupMap = (routes: RouteRecord[], mode: RouteGroupingMode, resolver?: RouteGroupResolver): Map<string, RouteRecord[]> => {
170
+ const groups = new Map<string, RouteRecord[]>();
171
+
172
+ for (const route of routes) {
173
+ const groupName =
174
+ mode === 'none'
175
+ ? 'routes'
176
+ : mode === 'root-groups'
177
+ ? (route.rootGroup ?? 'routes')
178
+ : (sanitizeGroupName(String(resolver?.(route) ?? 'routes')) ?? 'routes');
179
+ const list = groups.get(groupName) ?? [];
180
+ list.push(route);
181
+ groups.set(groupName, list);
182
+ }
183
+
184
+ return groups;
185
+ };
186
+
187
+ const createStringArray = (items: string[], indent = '\t'): string => {
188
+ if (!items.length) return '[]';
189
+ return `[\n${items.map((item) => `${indent}'${item}'`).join(',\n')}\n]`;
190
+ };
191
+
192
+ const createGeneratedFile = (groups: Map<string, RouteRecord[]>): string => {
193
+ const usedVariables = new Set<string>();
194
+ const groupEntries = [...groups.entries()].map(([groupName, routes]) => ({
195
+ groupName,
196
+ variableName: ensureUniqueVariableName(
197
+ groupName === 'routes' ? (groups.size === 1 ? 'routes' : 'ungroupedRoutes') : toRouteVariableName(groupName),
198
+ usedVariables
199
+ ),
200
+ routes: [...new Set(routes.map((route) => route.url))].sort((a, b) => a.localeCompare(b))
201
+ }));
202
+
203
+ const lines: string[] = ['// This file is generated by `azure-net generate routes`.', '// Do not edit it manually.', ''];
204
+
205
+ for (const group of groupEntries) {
206
+ lines.push(`export const ${group.variableName} = ${createStringArray(group.routes)} as const;`);
207
+ lines.push('');
208
+ }
209
+
210
+ if (groupEntries.length !== 1 || groupEntries[0]?.variableName !== 'routes') {
211
+ lines.push(`export const routes = [${groupEntries.map((group) => `...${group.variableName}`).join(', ')}] as const;`);
212
+ lines.push('');
213
+ }
214
+
215
+ lines.push('export const routeGroups = {');
216
+ lines.push(
217
+ ...groupEntries.map((group, index) => `\t'${group.groupName}': ${group.variableName}${index === groupEntries.length - 1 ? '' : ','}`)
218
+ );
219
+ lines.push('} as const;');
220
+ lines.push('');
221
+ lines.push('export type Route = (typeof routes)[number];');
222
+ lines.push('export type RouteGroupName = keyof typeof routeGroups;');
223
+ lines.push('');
224
+
225
+ return `${lines.join('\n')}\n`;
226
+ };
227
+
228
+ const selectGroupingMode = async (): Promise<RouteGroupingMode | null> => {
229
+ const { mode } = await prompts({
230
+ type: 'select',
231
+ name: 'mode',
232
+ message: 'How should routes be grouped?',
233
+ choices: [
234
+ { title: 'By root route groups: (account), (landing), ...', value: 'root-groups' },
235
+ { title: 'By azure-net.config.ts groupRoutesFunc', value: 'config-function' },
236
+ { title: 'Do not group, generate one routes array', value: 'none' }
237
+ ]
238
+ });
239
+
240
+ return mode ? (mode as RouteGroupingMode) : null;
241
+ };
242
+
243
+ const getConfigRouteGroupResolver = async (): Promise<RouteGroupResolver | null> => {
244
+ const config = await loadUserConfig();
245
+ if (typeof config.groupRoutesFunc !== 'function') {
246
+ console.error('❌ `groupRoutesFunc` is missing in azure-net.config.ts/js or is not a function.');
247
+ console.error('Example:');
248
+ console.error('export default {');
249
+ console.error("\tgroupRoutesFunc: (route) => route.rootGroup ?? 'app'");
250
+ console.error('};');
251
+ return null;
252
+ }
253
+
254
+ return config.groupRoutesFunc as RouteGroupResolver;
255
+ };
256
+
257
+ export default async function generateRoutes(): Promise<void> {
258
+ const mode = await selectGroupingMode();
259
+ if (!mode) return;
260
+
261
+ const { sharedContext } = await getSharedState();
262
+ const resolver = mode === 'config-function' ? await getConfigRouteGroupResolver() : undefined;
263
+ if (mode === 'config-function' && !resolver) {
264
+ process.exitCode = 1;
265
+ return;
266
+ }
267
+
268
+ const routes = await collectRoutes(ROUTES_DIR);
269
+ if (!routes.length) {
270
+ console.warn('⚠️ No SvelteKit routes found in src/routes');
271
+ return;
272
+ }
273
+
274
+ const groups = createGroupMap(routes, mode, resolver ?? undefined);
275
+ const outputDir = path.join(getSharedContextRootPath(sharedContext.name), 'runtime', 'routes');
276
+ const outputFile = path.join(outputDir, 'index.ts');
277
+
278
+ await fs.mkdir(outputDir, { recursive: true });
279
+ await fs.writeFile(outputFile, createGeneratedFile(groups), 'utf-8');
280
+
281
+ console.log(`✅ Routes generated: ${outputFile}`);
282
+ }
@@ -7,9 +7,9 @@ import { toPascalCase } from '../utils/contextUtils.js';
7
7
  import { getFoundationConstructPath, getSharedState } from '../utils/sharedFoundation.js';
8
8
  const createPresenterFactoryTemplate = (
9
9
  factoryName
10
- ) => `import { createErrorHandler, createAsyncHelpers, createPresenterFactory } from '@azure-net/kit/delivery';
10
+ ) => `import { createAsyncHelpers, createPresenterFactory } from '@azure-net/kit/delivery';
11
11
 
12
- export const AsyncHelpers = createAsyncHelpers({ handler: createErrorHandler() });
12
+ export const AsyncHelpers = createAsyncHelpers();
13
13
 
14
14
  export const ${factoryName} = createPresenterFactory({ ...AsyncHelpers });
15
15
  `;
@@ -10,9 +10,9 @@ type ConfigPath = 'ts' | 'js';
10
10
 
11
11
  const createPresenterFactoryTemplate = (
12
12
  factoryName: string
13
- ): string => `import { createErrorHandler, createAsyncHelpers, createPresenterFactory } from '@azure-net/kit/delivery';
13
+ ): string => `import { createAsyncHelpers, createPresenterFactory } from '@azure-net/kit/delivery';
14
14
 
15
- export const AsyncHelpers = createAsyncHelpers({ handler: createErrorHandler() });
15
+ export const AsyncHelpers = createAsyncHelpers();
16
16
 
17
17
  export const ${factoryName} = createPresenterFactory({ ...AsyncHelpers });
18
18
  `;
@@ -9,6 +9,7 @@ export type AzureNetConfig = {
9
9
  packageManager?: string;
10
10
  defaultSchemaFactory?: string;
11
11
  defaultPresenterFactory?: string;
12
+ groupRoutesFunc?: unknown;
12
13
  [key: string]: unknown;
13
14
  };
14
15