@teardown/navigation-metro 2.0.43
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/package.json +46 -0
- package/src/generator/index.ts +13 -0
- package/src/generator/route-generator.test.ts +287 -0
- package/src/generator/route-generator.ts +231 -0
- package/src/index.ts +158 -0
- package/src/scanner/file-scanner.test.ts +271 -0
- package/src/scanner/file-scanner.ts +329 -0
- package/src/scanner/index.ts +15 -0
- package/src/validator/index.ts +5 -0
- package/src/validator/route-validator.test.ts +192 -0
- package/src/validator/route-validator.ts +178 -0
- package/src/watcher/file-watcher.ts +132 -0
- package/src/watcher/index.ts +5 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File scanner for @teardown/navigation-metro
|
|
3
|
+
* Scans routes directory and builds a route tree for type generation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface RouteNode {
|
|
10
|
+
/** Screen name derived from file path */
|
|
11
|
+
name: string;
|
|
12
|
+
/** URL path for this route */
|
|
13
|
+
path: string;
|
|
14
|
+
/** Absolute file path */
|
|
15
|
+
filePath: string;
|
|
16
|
+
/** Relative file path from routes dir */
|
|
17
|
+
relativePath: string;
|
|
18
|
+
/** Extracted dynamic params */
|
|
19
|
+
params: ParamDefinition[];
|
|
20
|
+
/** Child routes */
|
|
21
|
+
children: RouteNode[];
|
|
22
|
+
/** Navigator type from _layout.tsx */
|
|
23
|
+
layoutType: "stack" | "tabs" | "drawer" | "none";
|
|
24
|
+
/** Is this an index route */
|
|
25
|
+
isIndex: boolean;
|
|
26
|
+
/** Is this a layout file */
|
|
27
|
+
isLayout: boolean;
|
|
28
|
+
/** Is this a catch-all route */
|
|
29
|
+
isCatchAll: boolean;
|
|
30
|
+
/** Route group name (from parentheses) */
|
|
31
|
+
groupName: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ParamDefinition {
|
|
35
|
+
name: string;
|
|
36
|
+
isOptional: boolean;
|
|
37
|
+
isCatchAll: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ScanResult {
|
|
41
|
+
routes: RouteNode[];
|
|
42
|
+
errors: ScanError[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ScanError {
|
|
46
|
+
file: string;
|
|
47
|
+
message: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Scans a routes directory and builds a route tree
|
|
52
|
+
*/
|
|
53
|
+
export function scanRoutesDirectory(routesDir: string): ScanResult {
|
|
54
|
+
const errors: ScanError[] = [];
|
|
55
|
+
|
|
56
|
+
if (!existsSync(routesDir)) {
|
|
57
|
+
return {
|
|
58
|
+
routes: [],
|
|
59
|
+
errors: [{ file: routesDir, message: "Routes directory does not exist" }],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const files = findRouteFiles(routesDir);
|
|
64
|
+
const routeNodes = new Map<string, RouteNode>();
|
|
65
|
+
|
|
66
|
+
// First pass: create all route nodes
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
const absolutePath = join(routesDir, file);
|
|
69
|
+
const node = parseRouteFile(file, absolutePath);
|
|
70
|
+
|
|
71
|
+
if (node) {
|
|
72
|
+
routeNodes.set(file, node);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Second pass: build tree structure
|
|
77
|
+
const rootNodes: RouteNode[] = [];
|
|
78
|
+
|
|
79
|
+
for (const [filePath, node] of routeNodes) {
|
|
80
|
+
const parentPath = findParentLayoutPath(filePath, routeNodes);
|
|
81
|
+
|
|
82
|
+
if (parentPath) {
|
|
83
|
+
const parent = routeNodes.get(parentPath);
|
|
84
|
+
if (parent) {
|
|
85
|
+
parent.children.push(node);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
rootNodes.push(node);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { routes: rootNodes, errors };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Recursively finds all route files in a directory
|
|
97
|
+
*/
|
|
98
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: route file discovery branches
|
|
99
|
+
function findRouteFiles(dir: string, prefix = ""): string[] {
|
|
100
|
+
const results: string[] = [];
|
|
101
|
+
|
|
102
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
103
|
+
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
106
|
+
|
|
107
|
+
if (entry.isDirectory()) {
|
|
108
|
+
// Skip node_modules and hidden directories (except those in parentheses for groups)
|
|
109
|
+
if (entry.name === "node_modules" || (entry.name.startsWith(".") && !entry.name.startsWith("("))) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
results.push(...findRouteFiles(join(dir, entry.name), relativePath));
|
|
113
|
+
} else if (entry.isFile()) {
|
|
114
|
+
// Only include .ts and .tsx files
|
|
115
|
+
const ext = extname(entry.name);
|
|
116
|
+
if (ext !== ".ts" && ext !== ".tsx") {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Skip test files
|
|
121
|
+
if (entry.name.includes(".test.") || entry.name.includes(".spec.")) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Skip hidden files (starting with _) except _layout
|
|
126
|
+
const baseName = basename(entry.name, ext);
|
|
127
|
+
if (baseName.startsWith("_") && baseName !== "_layout") {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
results.push(relativePath);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Parses a route file and creates a RouteNode
|
|
140
|
+
*/
|
|
141
|
+
function parseRouteFile(relativePath: string, absolutePath: string): RouteNode | null {
|
|
142
|
+
const ext = extname(relativePath);
|
|
143
|
+
const fileName = basename(relativePath, ext);
|
|
144
|
+
|
|
145
|
+
const isLayout = fileName === "_layout";
|
|
146
|
+
const isIndex = fileName === "index";
|
|
147
|
+
const isCatchAll = fileName.startsWith("[...");
|
|
148
|
+
|
|
149
|
+
// Extract route group from path
|
|
150
|
+
const groupMatch = relativePath.match(/\(([^)]+)\)/);
|
|
151
|
+
const groupName = groupMatch ? groupMatch[1] : null;
|
|
152
|
+
|
|
153
|
+
// Parse params from the entire relative path (not just filename)
|
|
154
|
+
// This extracts params from both directory names and the filename
|
|
155
|
+
const params = extractParams(relativePath);
|
|
156
|
+
|
|
157
|
+
// Build URL path
|
|
158
|
+
const urlPath = buildUrlPath(relativePath, isIndex, isLayout);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
name: filePathToScreenName(relativePath),
|
|
162
|
+
path: urlPath,
|
|
163
|
+
filePath: absolutePath,
|
|
164
|
+
relativePath,
|
|
165
|
+
params,
|
|
166
|
+
children: [],
|
|
167
|
+
layoutType: isLayout ? detectLayoutType(absolutePath) : "none",
|
|
168
|
+
isIndex,
|
|
169
|
+
isLayout,
|
|
170
|
+
isCatchAll,
|
|
171
|
+
groupName,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Extracts dynamic parameters from a filename
|
|
177
|
+
*/
|
|
178
|
+
export function extractParams(fileName: string): ParamDefinition[] {
|
|
179
|
+
const params: ParamDefinition[] = [];
|
|
180
|
+
const regex = /\[(?:\.\.\.)?([^\]]+)\]\??/g;
|
|
181
|
+
let match: RegExpExecArray | null;
|
|
182
|
+
|
|
183
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex iteration pattern
|
|
184
|
+
while ((match = regex.exec(fileName)) !== null) {
|
|
185
|
+
const fullMatch = match[0];
|
|
186
|
+
const paramName = match[1].replace("?", "");
|
|
187
|
+
|
|
188
|
+
params.push({
|
|
189
|
+
name: paramName,
|
|
190
|
+
isOptional: fullMatch.endsWith("?"),
|
|
191
|
+
isCatchAll: fullMatch.startsWith("[..."),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return params;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Builds a URL path from a relative file path
|
|
200
|
+
*/
|
|
201
|
+
export function buildUrlPath(relativePath: string, _isIndex: boolean, isLayout: boolean): string {
|
|
202
|
+
if (isLayout) return "";
|
|
203
|
+
|
|
204
|
+
const ext = extname(relativePath);
|
|
205
|
+
let urlPath = relativePath
|
|
206
|
+
.replace(ext, "") // Remove extension
|
|
207
|
+
.replace(/\\/g, "/") // Normalize path separators
|
|
208
|
+
.replace(/\(([^)]+)\)\//g, "") // Remove route groups from URL
|
|
209
|
+
.replace(/\[\.\.\.([^\]]+)\]/g, "*") // Catch-all
|
|
210
|
+
.replace(/\[([^\]]+)\]\?/g, ":$1?") // Optional params
|
|
211
|
+
.replace(/\[([^\]]+)\]/g, ":$1"); // Required params
|
|
212
|
+
|
|
213
|
+
// Handle index files - remove "index" or trailing "/index"
|
|
214
|
+
if (urlPath === "index") {
|
|
215
|
+
return "/";
|
|
216
|
+
}
|
|
217
|
+
urlPath = urlPath.replace(/\/index$/, "");
|
|
218
|
+
|
|
219
|
+
return `/${urlPath}` || "/";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Converts a file path to a screen name
|
|
224
|
+
*/
|
|
225
|
+
export function filePathToScreenName(relativePath: string): string {
|
|
226
|
+
const ext = extname(relativePath);
|
|
227
|
+
return relativePath.replace(ext, "").replace(/\\/g, "/");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Detects the layout type from a _layout.tsx file
|
|
232
|
+
*/
|
|
233
|
+
function detectLayoutType(absolutePath: string): "stack" | "tabs" | "drawer" {
|
|
234
|
+
try {
|
|
235
|
+
const content = readFileSync(absolutePath, "utf-8");
|
|
236
|
+
|
|
237
|
+
if (content.includes("type: 'tabs'") || content.includes('type: "tabs"')) {
|
|
238
|
+
return "tabs";
|
|
239
|
+
}
|
|
240
|
+
if (content.includes("type: 'drawer'") || content.includes('type: "drawer"')) {
|
|
241
|
+
return "drawer";
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
// Default to stack on read error
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return "stack";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Finds the parent layout file path for a given route file
|
|
252
|
+
*/
|
|
253
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: layout path traversal
|
|
254
|
+
function findParentLayoutPath(filePath: string, routeNodes: Map<string, RouteNode>): string | null {
|
|
255
|
+
const dir = dirname(filePath);
|
|
256
|
+
const fileName = basename(filePath);
|
|
257
|
+
const isLayout = fileName === "_layout.tsx" || fileName === "_layout.ts";
|
|
258
|
+
|
|
259
|
+
// For root level files
|
|
260
|
+
if (dir === ".") {
|
|
261
|
+
// Check if there's a root _layout.tsx and this file is not the layout itself
|
|
262
|
+
const rootLayout = "_layout.tsx";
|
|
263
|
+
if (filePath !== rootLayout && routeNodes.has(rootLayout)) {
|
|
264
|
+
return rootLayout;
|
|
265
|
+
}
|
|
266
|
+
const rootLayoutTs = "_layout.ts";
|
|
267
|
+
if (filePath !== rootLayoutTs && routeNodes.has(rootLayoutTs)) {
|
|
268
|
+
return rootLayoutTs;
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// For non-layout files, look for _layout in the same directory first
|
|
274
|
+
if (!isLayout) {
|
|
275
|
+
const sameDirLayout = `${dir}/_layout.tsx`;
|
|
276
|
+
const sameDirLayoutTs = `${dir}/_layout.ts`;
|
|
277
|
+
|
|
278
|
+
if (routeNodes.has(sameDirLayout)) {
|
|
279
|
+
return sameDirLayout;
|
|
280
|
+
}
|
|
281
|
+
if (routeNodes.has(sameDirLayoutTs)) {
|
|
282
|
+
return sameDirLayoutTs;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// For layouts, or if no layout in same directory, look in parent directories
|
|
287
|
+
let parentDir = dirname(dir);
|
|
288
|
+
|
|
289
|
+
while (parentDir !== ".") {
|
|
290
|
+
const layoutPath = `${parentDir}/_layout.tsx`;
|
|
291
|
+
const layoutPathTs = `${parentDir}/_layout.ts`;
|
|
292
|
+
|
|
293
|
+
if (routeNodes.has(layoutPath)) {
|
|
294
|
+
return layoutPath;
|
|
295
|
+
}
|
|
296
|
+
if (routeNodes.has(layoutPathTs)) {
|
|
297
|
+
return layoutPathTs;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
parentDir = dirname(parentDir);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Check root level layout
|
|
304
|
+
if (routeNodes.has("_layout.tsx") && filePath !== "_layout.tsx") {
|
|
305
|
+
return "_layout.tsx";
|
|
306
|
+
}
|
|
307
|
+
if (routeNodes.has("_layout.ts") && filePath !== "_layout.ts") {
|
|
308
|
+
return "_layout.ts";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Flattens a route tree into a flat array
|
|
316
|
+
*/
|
|
317
|
+
export function flattenRoutes(routes: RouteNode[]): RouteNode[] {
|
|
318
|
+
const result: RouteNode[] = [];
|
|
319
|
+
|
|
320
|
+
function traverse(nodes: RouteNode[]): void {
|
|
321
|
+
for (const node of nodes) {
|
|
322
|
+
result.push(node);
|
|
323
|
+
traverse(node.children);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
traverse(routes);
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scanner module for @teardown/navigation-metro
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
buildUrlPath,
|
|
7
|
+
extractParams,
|
|
8
|
+
filePathToScreenName,
|
|
9
|
+
flattenRoutes,
|
|
10
|
+
type ParamDefinition,
|
|
11
|
+
type RouteNode,
|
|
12
|
+
type ScanError,
|
|
13
|
+
type ScanResult,
|
|
14
|
+
scanRoutesDirectory,
|
|
15
|
+
} from "./file-scanner";
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { validateRoutes } from "./route-validator";
|
|
5
|
+
|
|
6
|
+
const TEST_DIR = join(import.meta.dir, "__test_routes__");
|
|
7
|
+
|
|
8
|
+
function createTestFile(relativePath: string, content: string) {
|
|
9
|
+
const fullPath = join(TEST_DIR, relativePath);
|
|
10
|
+
const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
|
|
11
|
+
mkdirSync(dir, { recursive: true });
|
|
12
|
+
writeFileSync(fullPath, content);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("Route Validator", () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("validateRoutes", () => {
|
|
25
|
+
it("should return error for non-existent directory", () => {
|
|
26
|
+
const errors = validateRoutes("/non/existent/path");
|
|
27
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
28
|
+
expect(errors[0].severity).toBe("error");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should return no errors for valid simple route", () => {
|
|
32
|
+
createTestFile(
|
|
33
|
+
"about.tsx",
|
|
34
|
+
`
|
|
35
|
+
export default function About() {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
`
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const errors = validateRoutes(TEST_DIR);
|
|
42
|
+
const actualErrors = errors.filter((e) => e.severity === "error");
|
|
43
|
+
expect(actualErrors).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should detect missing default export", () => {
|
|
47
|
+
createTestFile(
|
|
48
|
+
"about.tsx",
|
|
49
|
+
`
|
|
50
|
+
export function About() {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
`
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const errors = validateRoutes(TEST_DIR);
|
|
57
|
+
const missingExport = errors.find((e) => e.message.includes("default export"));
|
|
58
|
+
expect(missingExport).toBeDefined();
|
|
59
|
+
expect(missingExport?.severity).toBe("error");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should detect duplicate route paths", () => {
|
|
63
|
+
// Create two routes that resolve to the same path
|
|
64
|
+
createTestFile("users/index.tsx", "export default function UsersIndex() {}");
|
|
65
|
+
// We can't easily create duplicate paths with file-based routing
|
|
66
|
+
// but we can simulate by checking the validator handles it
|
|
67
|
+
// For now, just check that the validator runs without error
|
|
68
|
+
const errors = validateRoutes(TEST_DIR);
|
|
69
|
+
expect(errors).toBeDefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should warn when screen doesn't use defineScreen", () => {
|
|
73
|
+
createTestFile(
|
|
74
|
+
"about.tsx",
|
|
75
|
+
`
|
|
76
|
+
export default function About() {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
`
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const errors = validateRoutes(TEST_DIR);
|
|
83
|
+
const warning = errors.find((e) => e.message.includes("defineScreen") && e.severity === "warning");
|
|
84
|
+
expect(warning).toBeDefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should not warn when screen uses defineScreen", () => {
|
|
88
|
+
createTestFile(
|
|
89
|
+
"about.tsx",
|
|
90
|
+
`
|
|
91
|
+
import { defineScreen } from '@teardown/navigation';
|
|
92
|
+
|
|
93
|
+
function AboutScreen() {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default defineScreen({
|
|
98
|
+
component: AboutScreen,
|
|
99
|
+
});
|
|
100
|
+
`
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const errors = validateRoutes(TEST_DIR);
|
|
104
|
+
const defineScreenWarning = errors.find((e) => e.message.includes("defineScreen") && e.file.includes("about"));
|
|
105
|
+
expect(defineScreenWarning).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should warn when layout doesn't use defineLayout", () => {
|
|
109
|
+
createTestFile(
|
|
110
|
+
"_layout.tsx",
|
|
111
|
+
`
|
|
112
|
+
export default {
|
|
113
|
+
type: 'stack'
|
|
114
|
+
};
|
|
115
|
+
`
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const errors = validateRoutes(TEST_DIR);
|
|
119
|
+
const warning = errors.find((e) => e.message.includes("defineLayout"));
|
|
120
|
+
expect(warning).toBeDefined();
|
|
121
|
+
expect(warning?.severity).toBe("warning");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should not warn when layout uses defineLayout", () => {
|
|
125
|
+
createTestFile(
|
|
126
|
+
"_layout.tsx",
|
|
127
|
+
`
|
|
128
|
+
import { defineLayout } from '@teardown/navigation';
|
|
129
|
+
|
|
130
|
+
export default defineLayout({
|
|
131
|
+
type: 'stack'
|
|
132
|
+
});
|
|
133
|
+
`
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const errors = validateRoutes(TEST_DIR);
|
|
137
|
+
const defineLayoutWarning = errors.find((e) => e.message.includes("defineLayout") && e.file.includes("_layout"));
|
|
138
|
+
expect(defineLayoutWarning).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("should warn when dynamic route lacks param schema", () => {
|
|
142
|
+
createTestFile(
|
|
143
|
+
"users/[userId].tsx",
|
|
144
|
+
`
|
|
145
|
+
export default function UserProfile() {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
`
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const errors = validateRoutes(TEST_DIR);
|
|
152
|
+
const warning = errors.find((e) => e.message.includes("param") && e.message.includes("schema"));
|
|
153
|
+
expect(warning).toBeDefined();
|
|
154
|
+
expect(warning?.severity).toBe("warning");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should not warn when dynamic route has param schema", () => {
|
|
158
|
+
createTestFile(
|
|
159
|
+
"users/[userId].tsx",
|
|
160
|
+
`
|
|
161
|
+
import { createParamSchema, paramValidators } from '@teardown/navigation';
|
|
162
|
+
|
|
163
|
+
export const paramsSchema = createParamSchema({
|
|
164
|
+
userId: paramValidators.uuid(),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
export default function UserProfile() {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
`
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const errors = validateRoutes(TEST_DIR);
|
|
174
|
+
const schemaWarning = errors.find(
|
|
175
|
+
(e) => e.message.includes("param") && e.message.includes("schema") && e.file.includes("[userId]")
|
|
176
|
+
);
|
|
177
|
+
expect(schemaWarning).toBeUndefined();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should validate nested routes", () => {
|
|
181
|
+
createTestFile("_layout.tsx", "export default { type: 'stack' }");
|
|
182
|
+
createTestFile("users/_layout.tsx", "export default { type: 'stack' }");
|
|
183
|
+
createTestFile("users/index.tsx", "export default function Users() {}");
|
|
184
|
+
createTestFile("users/[userId].tsx", "export default function User() {}");
|
|
185
|
+
|
|
186
|
+
const errors = validateRoutes(TEST_DIR);
|
|
187
|
+
// Should have some warnings but no errors
|
|
188
|
+
const actualErrors = errors.filter((e) => e.severity === "error");
|
|
189
|
+
expect(actualErrors).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|