@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.
@@ -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,5 @@
1
+ /**
2
+ * Validator module for @teardown/navigation-metro
3
+ */
4
+
5
+ export { type ValidationError, validateRoutes } from "./route-validator";
@@ -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
+ });