@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 +7 -0
- package/bin/azure-net.ts +8 -0
- package/package.json +9 -2
- package/src/generate/module/generatePresenter.js +40 -10
- package/src/generate/module/generatePresenter.ts +61 -10
- package/src/generate/routes/generateRoutes.js +217 -0
- package/src/generate/routes/generateRoutes.ts +282 -0
- package/src/scaffolds/createPresenterFactory.js +2 -2
- package/src/scaffolds/createPresenterFactory.ts +2 -2
- package/src/utils/loadConfig.ts +1 -0
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.
|
|
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": "
|
|
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 =
|
|
220
|
-
process.cwd(),
|
|
221
|
-
'src',
|
|
222
|
-
'app',
|
|
248
|
+
const domainPortsInterfacePath = await resolveDomainInterfaceFile({
|
|
223
249
|
contextName,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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 =
|
|
267
|
-
process.cwd(),
|
|
268
|
-
'src',
|
|
269
|
-
'app',
|
|
307
|
+
const domainPortsInterfacePath = await resolveDomainInterfaceFile({
|
|
270
308
|
contextName,
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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 {
|
|
10
|
+
) => `import { createAsyncHelpers, createPresenterFactory } from '@azure-net/kit/delivery';
|
|
11
11
|
|
|
12
|
-
export const AsyncHelpers = createAsyncHelpers(
|
|
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 {
|
|
13
|
+
): string => `import { createAsyncHelpers, createPresenterFactory } from '@azure-net/kit/delivery';
|
|
14
14
|
|
|
15
|
-
export const AsyncHelpers = createAsyncHelpers(
|
|
15
|
+
export const AsyncHelpers = createAsyncHelpers();
|
|
16
16
|
|
|
17
17
|
export const ${factoryName} = createPresenterFactory({ ...AsyncHelpers });
|
|
18
18
|
`;
|