@l4yercak3/cli 1.2.21 → 1.3.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/cli.js +25 -0
- package/docs/CLI_PAGE_DETECTION_REQUIREMENTS.md +519 -0
- package/package.json +1 -1
- package/src/api/backend-client.js +149 -0
- package/src/commands/connect.js +243 -0
- package/src/commands/pages.js +317 -0
- package/src/commands/scaffold.js +409 -0
- package/src/commands/spread.js +59 -181
- package/src/commands/sync.js +169 -0
- package/src/detectors/index.js +13 -0
- package/src/detectors/mapping-suggestor.js +119 -0
- package/src/detectors/model-detector.js +318 -0
- package/src/detectors/page-detector.js +480 -0
- package/src/generators/manifest-generator.js +154 -0
- package/src/utils/init-helpers.js +243 -0
- package/tests/page-detector.test.js +371 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page/Screen Detector
|
|
3
|
+
* Detects pages in Next.js projects and screens in Expo/React Native projects
|
|
4
|
+
* Used for syncing application structure with L4YERCAK3 backend
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
class PageDetector {
|
|
11
|
+
/**
|
|
12
|
+
* Detect all pages/screens in a project
|
|
13
|
+
* @param {string} projectPath - Path to project root
|
|
14
|
+
* @param {string} frameworkType - 'nextjs', 'expo', or 'react-native'
|
|
15
|
+
* @param {object} frameworkMetadata - Additional framework info (routerType, etc.)
|
|
16
|
+
* @returns {Array<{path: string, name: string, pageType: string, filePath: string}>}
|
|
17
|
+
*/
|
|
18
|
+
detect(projectPath, frameworkType, frameworkMetadata = {}) {
|
|
19
|
+
switch (frameworkType) {
|
|
20
|
+
case 'nextjs':
|
|
21
|
+
return this.detectNextJsPages(projectPath, frameworkMetadata);
|
|
22
|
+
case 'expo':
|
|
23
|
+
case 'react-native':
|
|
24
|
+
return this.detectExpoScreens(projectPath, frameworkMetadata);
|
|
25
|
+
default:
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Detect Next.js pages (supports both App Router and Pages Router)
|
|
32
|
+
*/
|
|
33
|
+
detectNextJsPages(projectPath, metadata) {
|
|
34
|
+
const routerType = metadata.routerType || 'pages';
|
|
35
|
+
const pages = [];
|
|
36
|
+
|
|
37
|
+
if (routerType === 'app') {
|
|
38
|
+
// App Router: look for page.tsx/page.js files in app/ or src/app/
|
|
39
|
+
const appDirs = [
|
|
40
|
+
path.join(projectPath, 'app'),
|
|
41
|
+
path.join(projectPath, 'src', 'app'),
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
for (const appDir of appDirs) {
|
|
45
|
+
if (fs.existsSync(appDir)) {
|
|
46
|
+
this.scanAppRouterDirectory(appDir, '', pages, appDir);
|
|
47
|
+
break; // Only use the first found app directory
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
// Pages Router: look for files in pages/ or src/pages/
|
|
52
|
+
const pagesDirs = [
|
|
53
|
+
path.join(projectPath, 'pages'),
|
|
54
|
+
path.join(projectPath, 'src', 'pages'),
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
for (const pagesDir of pagesDirs) {
|
|
58
|
+
if (fs.existsSync(pagesDir)) {
|
|
59
|
+
this.scanPagesRouterDirectory(pagesDir, '', pages, pagesDir);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return pages;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Scan App Router directory structure
|
|
70
|
+
* In App Router, pages are defined by page.tsx/page.js files
|
|
71
|
+
* API routes are defined by route.tsx/route.js files
|
|
72
|
+
*/
|
|
73
|
+
scanAppRouterDirectory(dir, routePath, pages, baseDir) {
|
|
74
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
75
|
+
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const fullPath = path.join(dir, entry.name);
|
|
78
|
+
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
// Skip special directories
|
|
81
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.')) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Handle route groups (parentheses) - they don't add to URL
|
|
86
|
+
let newRoutePath = routePath;
|
|
87
|
+
if (!entry.name.startsWith('(') || !entry.name.endsWith(')')) {
|
|
88
|
+
// Handle dynamic segments [param] and catch-all [...param]
|
|
89
|
+
let segment = entry.name;
|
|
90
|
+
if (segment.startsWith('[') && segment.endsWith(']')) {
|
|
91
|
+
// Keep dynamic segment notation
|
|
92
|
+
newRoutePath = `${routePath}/${segment}`;
|
|
93
|
+
} else {
|
|
94
|
+
newRoutePath = `${routePath}/${segment}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.scanAppRouterDirectory(fullPath, newRoutePath, pages, baseDir);
|
|
99
|
+
} else if (entry.isFile()) {
|
|
100
|
+
const ext = path.extname(entry.name);
|
|
101
|
+
const baseName = path.basename(entry.name, ext);
|
|
102
|
+
|
|
103
|
+
// Check for page files
|
|
104
|
+
if (baseName === 'page' && ['.tsx', '.ts', '.jsx', '.js'].includes(ext)) {
|
|
105
|
+
const pagePath = routePath || '/';
|
|
106
|
+
pages.push({
|
|
107
|
+
path: pagePath,
|
|
108
|
+
name: this.generatePageName(pagePath),
|
|
109
|
+
pageType: this.detectPageType(pagePath),
|
|
110
|
+
filePath: path.relative(baseDir, fullPath),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check for API route files
|
|
115
|
+
if (baseName === 'route' && ['.tsx', '.ts', '.jsx', '.js'].includes(ext)) {
|
|
116
|
+
const apiPath = routePath || '/';
|
|
117
|
+
const methods = this.detectHttpMethods(fullPath, 'app');
|
|
118
|
+
pages.push({
|
|
119
|
+
path: apiPath,
|
|
120
|
+
name: this.generatePageName(apiPath, true),
|
|
121
|
+
pageType: 'api_route',
|
|
122
|
+
filePath: path.relative(baseDir, fullPath),
|
|
123
|
+
methods,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Detect HTTP methods exported from a route file
|
|
132
|
+
* @param {string} filePath - Absolute path to the route file
|
|
133
|
+
* @param {string} routerType - 'app' or 'pages'
|
|
134
|
+
* @returns {string[]} Array of HTTP methods (e.g., ['GET', 'POST'])
|
|
135
|
+
*/
|
|
136
|
+
detectHttpMethods(filePath, routerType) {
|
|
137
|
+
let content;
|
|
138
|
+
try {
|
|
139
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
140
|
+
} catch {
|
|
141
|
+
return ['GET', 'POST']; // Default fallback
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const methods = new Set();
|
|
145
|
+
|
|
146
|
+
if (routerType === 'app') {
|
|
147
|
+
// App Router: look for exported handler functions
|
|
148
|
+
// export async function GET(...) or export const GET = ...
|
|
149
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
|
150
|
+
for (const method of HTTP_METHODS) {
|
|
151
|
+
const funcPattern = new RegExp(`export\\s+(?:async\\s+)?function\\s+${method}\\b`);
|
|
152
|
+
const constPattern = new RegExp(`export\\s+const\\s+${method}\\s*=`);
|
|
153
|
+
if (funcPattern.test(content) || constPattern.test(content)) {
|
|
154
|
+
methods.add(method);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
// Pages Router: look for req.method checks
|
|
159
|
+
const methodCheckRegex = /req\.method\s*===?\s*['"](\w+)['"]/g;
|
|
160
|
+
let match;
|
|
161
|
+
while ((match = methodCheckRegex.exec(content)) !== null) {
|
|
162
|
+
methods.add(match[1].toUpperCase());
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Also check switch/case patterns: case 'GET':
|
|
166
|
+
const caseRegex = /case\s+['"](\w+)['"]\s*:/g;
|
|
167
|
+
while ((match = caseRegex.exec(content)) !== null) {
|
|
168
|
+
const val = match[1].toUpperCase();
|
|
169
|
+
if (['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'].includes(val)) {
|
|
170
|
+
methods.add(val);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Default to GET/POST if no methods detected
|
|
176
|
+
if (methods.size === 0) {
|
|
177
|
+
return ['GET', 'POST'];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return Array.from(methods).sort();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Scan Pages Router directory structure
|
|
185
|
+
* In Pages Router, each file is a page, and files in api/ are API routes
|
|
186
|
+
*/
|
|
187
|
+
scanPagesRouterDirectory(dir, routePath, pages, baseDir) {
|
|
188
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
189
|
+
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
const fullPath = path.join(dir, entry.name);
|
|
192
|
+
|
|
193
|
+
if (entry.isDirectory()) {
|
|
194
|
+
// Skip special directories
|
|
195
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.')) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const newRoutePath = `${routePath}/${entry.name}`;
|
|
200
|
+
this.scanPagesRouterDirectory(fullPath, newRoutePath, pages, baseDir);
|
|
201
|
+
} else if (entry.isFile()) {
|
|
202
|
+
const ext = path.extname(entry.name);
|
|
203
|
+
const baseName = path.basename(entry.name, ext);
|
|
204
|
+
|
|
205
|
+
// Only process TypeScript/JavaScript files
|
|
206
|
+
if (!['.tsx', '.ts', '.jsx', '.js'].includes(ext)) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Skip special files
|
|
211
|
+
if (baseName.startsWith('_')) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Determine the route path
|
|
216
|
+
let pagePath;
|
|
217
|
+
if (baseName === 'index') {
|
|
218
|
+
pagePath = routePath || '/';
|
|
219
|
+
} else {
|
|
220
|
+
pagePath = `${routePath}/${baseName}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check if this is an API route
|
|
224
|
+
const isApiRoute = routePath.startsWith('/api') || pagePath.startsWith('/api');
|
|
225
|
+
|
|
226
|
+
const entry_data = {
|
|
227
|
+
path: pagePath,
|
|
228
|
+
name: this.generatePageName(pagePath, isApiRoute),
|
|
229
|
+
pageType: isApiRoute ? 'api_route' : this.detectPageType(pagePath),
|
|
230
|
+
filePath: path.relative(baseDir, fullPath),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (isApiRoute) {
|
|
234
|
+
entry_data.methods = this.detectHttpMethods(fullPath, 'pages');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
pages.push(entry_data);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Detect Expo/React Native screens
|
|
244
|
+
* Supports expo-router (file-based) and react-navigation
|
|
245
|
+
*/
|
|
246
|
+
detectExpoScreens(projectPath, metadata) {
|
|
247
|
+
const screens = [];
|
|
248
|
+
const routerType = metadata.routerType || 'native';
|
|
249
|
+
|
|
250
|
+
if (routerType === 'expo-router') {
|
|
251
|
+
// Expo Router uses file-based routing similar to Next.js App Router
|
|
252
|
+
const appDirs = [
|
|
253
|
+
path.join(projectPath, 'app'),
|
|
254
|
+
path.join(projectPath, 'src', 'app'),
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
for (const appDir of appDirs) {
|
|
258
|
+
if (fs.existsSync(appDir)) {
|
|
259
|
+
this.scanExpoRouterDirectory(appDir, '', screens, appDir);
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
// React Navigation or native - scan for screen files
|
|
265
|
+
const screenDirs = [
|
|
266
|
+
path.join(projectPath, 'src', 'screens'),
|
|
267
|
+
path.join(projectPath, 'screens'),
|
|
268
|
+
path.join(projectPath, 'src', 'views'),
|
|
269
|
+
path.join(projectPath, 'views'),
|
|
270
|
+
path.join(projectPath, 'app', 'screens'),
|
|
271
|
+
];
|
|
272
|
+
|
|
273
|
+
for (const screenDir of screenDirs) {
|
|
274
|
+
if (fs.existsSync(screenDir)) {
|
|
275
|
+
this.scanScreensDirectory(screenDir, screens, screenDir);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return screens;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Scan Expo Router directory structure
|
|
285
|
+
* Similar to Next.js App Router but with some differences
|
|
286
|
+
*/
|
|
287
|
+
scanExpoRouterDirectory(dir, routePath, screens, baseDir) {
|
|
288
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
289
|
+
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
const fullPath = path.join(dir, entry.name);
|
|
292
|
+
|
|
293
|
+
if (entry.isDirectory()) {
|
|
294
|
+
// Skip special directories
|
|
295
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.')) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Handle route groups (parentheses)
|
|
300
|
+
let newRoutePath = routePath;
|
|
301
|
+
if (!entry.name.startsWith('(') || !entry.name.endsWith(')')) {
|
|
302
|
+
newRoutePath = `${routePath}/${entry.name}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
this.scanExpoRouterDirectory(fullPath, newRoutePath, screens, baseDir);
|
|
306
|
+
} else if (entry.isFile()) {
|
|
307
|
+
const ext = path.extname(entry.name);
|
|
308
|
+
const baseName = path.basename(entry.name, ext);
|
|
309
|
+
|
|
310
|
+
// Skip non-JS/TS files and special files
|
|
311
|
+
if (!['.tsx', '.ts', '.jsx', '.js'].includes(ext)) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (baseName.startsWith('_')) {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Determine the route path
|
|
319
|
+
let screenPath;
|
|
320
|
+
if (baseName === 'index') {
|
|
321
|
+
screenPath = routePath || '/';
|
|
322
|
+
} else if (baseName === '+not-found') {
|
|
323
|
+
screenPath = '/*'; // Catch-all not found
|
|
324
|
+
} else if (baseName.startsWith('+')) {
|
|
325
|
+
// Skip Expo Router special files like +html.tsx, +native-intent.tsx
|
|
326
|
+
continue;
|
|
327
|
+
} else {
|
|
328
|
+
screenPath = `${routePath}/${baseName}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
screens.push({
|
|
332
|
+
path: screenPath,
|
|
333
|
+
name: this.generatePageName(screenPath),
|
|
334
|
+
pageType: this.detectPageType(screenPath),
|
|
335
|
+
filePath: path.relative(baseDir, fullPath),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Scan traditional screens directory for React Navigation setup
|
|
343
|
+
*/
|
|
344
|
+
scanScreensDirectory(dir, screens, baseDir) {
|
|
345
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
346
|
+
|
|
347
|
+
for (const entry of entries) {
|
|
348
|
+
const fullPath = path.join(dir, entry.name);
|
|
349
|
+
|
|
350
|
+
if (entry.isDirectory()) {
|
|
351
|
+
// Recurse into subdirectories
|
|
352
|
+
this.scanScreensDirectory(fullPath, screens, baseDir);
|
|
353
|
+
} else if (entry.isFile()) {
|
|
354
|
+
const ext = path.extname(entry.name);
|
|
355
|
+
const baseName = path.basename(entry.name, ext);
|
|
356
|
+
|
|
357
|
+
// Only process screen files
|
|
358
|
+
if (!['.tsx', '.ts', '.jsx', '.js'].includes(ext)) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Skip index files and non-screen files
|
|
363
|
+
if (baseName === 'index' || baseName.startsWith('_')) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Try to extract a meaningful path from the file name
|
|
368
|
+
// e.g., HomeScreen.tsx -> /home, ProfileScreen.tsx -> /profile
|
|
369
|
+
let screenPath = this.fileNameToPath(baseName);
|
|
370
|
+
|
|
371
|
+
screens.push({
|
|
372
|
+
path: screenPath,
|
|
373
|
+
name: this.generatePageName(screenPath),
|
|
374
|
+
pageType: 'static',
|
|
375
|
+
filePath: path.relative(baseDir, fullPath),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Convert a file name to a route path
|
|
383
|
+
* e.g., "HomeScreen" -> "/home", "UserProfileScreen" -> "/user-profile"
|
|
384
|
+
*/
|
|
385
|
+
fileNameToPath(fileName) {
|
|
386
|
+
// Remove common suffixes
|
|
387
|
+
let name = fileName
|
|
388
|
+
.replace(/Screen$/, '')
|
|
389
|
+
.replace(/View$/, '')
|
|
390
|
+
.replace(/Page$/, '');
|
|
391
|
+
|
|
392
|
+
// Convert camelCase/PascalCase to kebab-case
|
|
393
|
+
name = name
|
|
394
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
395
|
+
.toLowerCase();
|
|
396
|
+
|
|
397
|
+
// Handle "Home" or "Index" specially
|
|
398
|
+
if (name === 'home' || name === 'index' || name === 'main') {
|
|
399
|
+
return '/';
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return `/${name}`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Generate a human-readable name from a path
|
|
407
|
+
*/
|
|
408
|
+
generatePageName(routePath, isApiRoute = false) {
|
|
409
|
+
if (routePath === '/') {
|
|
410
|
+
return isApiRoute ? 'API: Root' : 'Home';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Handle catch-all not found (Expo +not-found)
|
|
414
|
+
if (routePath === '/*') {
|
|
415
|
+
return 'Not Found';
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Remove leading slash and split
|
|
419
|
+
const segments = routePath.slice(1).split('/');
|
|
420
|
+
|
|
421
|
+
// Handle API routes
|
|
422
|
+
if (isApiRoute || segments[0] === 'api') {
|
|
423
|
+
const apiSegments = segments.filter(s => s !== 'api');
|
|
424
|
+
if (apiSegments.length === 0) {
|
|
425
|
+
return 'API: Root';
|
|
426
|
+
}
|
|
427
|
+
const name = this.formatSegment(apiSegments[apiSegments.length - 1]);
|
|
428
|
+
return `API: ${name}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Get the last segment for the name
|
|
432
|
+
const lastSegment = segments[segments.length - 1];
|
|
433
|
+
|
|
434
|
+
// Check if it's a dynamic segment
|
|
435
|
+
if (lastSegment.startsWith('[') && lastSegment.endsWith(']')) {
|
|
436
|
+
// Use the parent segment + "Detail"
|
|
437
|
+
if (segments.length > 1) {
|
|
438
|
+
const parentSegment = segments[segments.length - 2];
|
|
439
|
+
return `${this.formatSegment(parentSegment)} Detail`;
|
|
440
|
+
}
|
|
441
|
+
return 'Detail';
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Check for catch-all
|
|
445
|
+
if (lastSegment.startsWith('[...')) {
|
|
446
|
+
return 'Catch All';
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return this.formatSegment(lastSegment);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Format a URL segment into a readable name
|
|
454
|
+
*/
|
|
455
|
+
formatSegment(segment) {
|
|
456
|
+
// Handle dynamic segments
|
|
457
|
+
if (segment.startsWith('[') && segment.endsWith(']')) {
|
|
458
|
+
segment = segment.slice(1, -1);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Convert kebab-case to Title Case
|
|
462
|
+
return segment
|
|
463
|
+
.split('-')
|
|
464
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
465
|
+
.join(' ');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Detect page type based on path
|
|
470
|
+
*/
|
|
471
|
+
detectPageType(routePath) {
|
|
472
|
+
// Check for dynamic segments
|
|
473
|
+
if (routePath.includes('[') && routePath.includes(']')) {
|
|
474
|
+
return 'dynamic';
|
|
475
|
+
}
|
|
476
|
+
return 'static';
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
module.exports = new PageDetector();
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest Generator
|
|
3
|
+
* Generates and manages the .l4yercak3.json project manifest file
|
|
4
|
+
*
|
|
5
|
+
* The manifest captures the project's structure (framework, models, routes)
|
|
6
|
+
* and is used by the platform to understand and integrate with the app.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const MANIFEST_FILENAME = '.l4yercak3.json';
|
|
13
|
+
const MANIFEST_VERSION = '1.0.0';
|
|
14
|
+
|
|
15
|
+
class ManifestGenerator {
|
|
16
|
+
/**
|
|
17
|
+
* Generate or update the .l4yercak3.json manifest
|
|
18
|
+
* @param {object} options
|
|
19
|
+
* @param {string} options.projectPath - Project root directory
|
|
20
|
+
* @param {object} options.detection - Detection results from ProjectDetector
|
|
21
|
+
* @param {Array} options.models - Detected models from model-detector
|
|
22
|
+
* @param {Array} options.routes - Detected routes from page-detector (with methods)
|
|
23
|
+
* @param {Array} options.mappings - Suggested mappings from mapping-suggestor
|
|
24
|
+
* @returns {string} Path to the generated manifest file
|
|
25
|
+
*/
|
|
26
|
+
generate(options) {
|
|
27
|
+
const { projectPath, detection, models, routes, mappings } = options;
|
|
28
|
+
const manifestPath = path.join(projectPath, MANIFEST_FILENAME);
|
|
29
|
+
|
|
30
|
+
// Read existing manifest to preserve user overrides
|
|
31
|
+
const existing = this.loadManifest(projectPath);
|
|
32
|
+
|
|
33
|
+
const manifest = {
|
|
34
|
+
version: MANIFEST_VERSION,
|
|
35
|
+
framework: detection?.framework?.type || 'unknown',
|
|
36
|
+
routerType: detection?.framework?.metadata?.routerType || null,
|
|
37
|
+
typescript: detection?.framework?.metadata?.hasTypeScript || false,
|
|
38
|
+
database: detection?.database?.primary?.type || null,
|
|
39
|
+
detectedModels: (models || []).map(m => ({
|
|
40
|
+
name: m.name,
|
|
41
|
+
source: m.source,
|
|
42
|
+
fields: m.fields || [],
|
|
43
|
+
})),
|
|
44
|
+
detectedRoutes: (routes || [])
|
|
45
|
+
.filter(r => r.pageType === 'api_route')
|
|
46
|
+
.map(r => ({
|
|
47
|
+
path: r.path,
|
|
48
|
+
methods: r.methods || ['GET', 'POST'],
|
|
49
|
+
})),
|
|
50
|
+
suggestedMappings: (mappings || []).map(m => ({
|
|
51
|
+
localModel: m.localModel,
|
|
52
|
+
platformType: m.platformType,
|
|
53
|
+
confidence: m.confidence,
|
|
54
|
+
})),
|
|
55
|
+
lastSyncedAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Preserve user overrides from existing manifest
|
|
59
|
+
if (existing && existing.userOverrides) {
|
|
60
|
+
manifest.userOverrides = existing.userOverrides;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Preserve confirmed mappings from existing manifest
|
|
64
|
+
if (existing && existing.confirmedMappings) {
|
|
65
|
+
manifest.confirmedMappings = existing.confirmedMappings;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
69
|
+
return manifestPath;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load an existing manifest from a project
|
|
74
|
+
* @param {string} projectPath - Project root directory
|
|
75
|
+
* @returns {object|null} Parsed manifest or null if not found
|
|
76
|
+
*/
|
|
77
|
+
loadManifest(projectPath) {
|
|
78
|
+
const manifestPath = path.join(projectPath, MANIFEST_FILENAME);
|
|
79
|
+
try {
|
|
80
|
+
if (!fs.existsSync(manifestPath)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const content = fs.readFileSync(manifestPath, 'utf8');
|
|
84
|
+
return JSON.parse(content);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if a manifest exists in the project
|
|
92
|
+
* @param {string} projectPath
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
hasManifest(projectPath) {
|
|
96
|
+
return fs.existsSync(path.join(projectPath, MANIFEST_FILENAME));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Compute a diff between old and new manifest data
|
|
101
|
+
* @param {object} oldManifest - Previous manifest
|
|
102
|
+
* @param {object} newManifest - New manifest data
|
|
103
|
+
* @returns {object} Diff summary
|
|
104
|
+
*/
|
|
105
|
+
diff(oldManifest, newManifest) {
|
|
106
|
+
const result = {
|
|
107
|
+
modelsAdded: [],
|
|
108
|
+
modelsRemoved: [],
|
|
109
|
+
routesAdded: [],
|
|
110
|
+
routesRemoved: [],
|
|
111
|
+
mappingsChanged: [],
|
|
112
|
+
hasChanges: false,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (!oldManifest) {
|
|
116
|
+
result.modelsAdded = newManifest.detectedModels || [];
|
|
117
|
+
result.routesAdded = newManifest.detectedRoutes || [];
|
|
118
|
+
result.hasChanges = result.modelsAdded.length > 0 || result.routesAdded.length > 0;
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Compare models
|
|
123
|
+
const oldModelNames = new Set((oldManifest.detectedModels || []).map(m => m.name));
|
|
124
|
+
const newModelNames = new Set((newManifest.detectedModels || []).map(m => m.name));
|
|
125
|
+
|
|
126
|
+
for (const m of (newManifest.detectedModels || [])) {
|
|
127
|
+
if (!oldModelNames.has(m.name)) result.modelsAdded.push(m);
|
|
128
|
+
}
|
|
129
|
+
for (const m of (oldManifest.detectedModels || [])) {
|
|
130
|
+
if (!newModelNames.has(m.name)) result.modelsRemoved.push(m);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Compare routes
|
|
134
|
+
const oldRoutePaths = new Set((oldManifest.detectedRoutes || []).map(r => r.path));
|
|
135
|
+
const newRoutePaths = new Set((newManifest.detectedRoutes || []).map(r => r.path));
|
|
136
|
+
|
|
137
|
+
for (const r of (newManifest.detectedRoutes || [])) {
|
|
138
|
+
if (!oldRoutePaths.has(r.path)) result.routesAdded.push(r);
|
|
139
|
+
}
|
|
140
|
+
for (const r of (oldManifest.detectedRoutes || [])) {
|
|
141
|
+
if (!newRoutePaths.has(r.path)) result.routesRemoved.push(r);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
result.hasChanges =
|
|
145
|
+
result.modelsAdded.length > 0 ||
|
|
146
|
+
result.modelsRemoved.length > 0 ||
|
|
147
|
+
result.routesAdded.length > 0 ||
|
|
148
|
+
result.routesRemoved.length > 0;
|
|
149
|
+
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = new ManifestGenerator();
|