@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/src/index.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @teardown/navigation-metro
3
+ * Metro plugin for type-safe file-based navigation
4
+ */
5
+
6
+ import { resolve } from "node:path";
7
+ import { generateAllRouteFiles } from "./generator/route-generator";
8
+ import { startRouteWatcher } from "./watcher/file-watcher";
9
+
10
+ /**
11
+ * Options for the Teardown Navigation Metro plugin
12
+ */
13
+ export interface TeardownNavigationOptions {
14
+ /**
15
+ * Path to routes directory relative to project root
16
+ * @default './src/routes'
17
+ */
18
+ routesDir?: string;
19
+
20
+ /**
21
+ * Path for generated type files
22
+ * @default './.teardown'
23
+ */
24
+ generatedDir?: string;
25
+
26
+ /**
27
+ * Deep link URL prefixes
28
+ * @default []
29
+ */
30
+ prefixes?: string[];
31
+
32
+ /**
33
+ * Enable verbose logging
34
+ * @default false
35
+ */
36
+ verbose?: boolean;
37
+ }
38
+
39
+ /**
40
+ * Metro configuration type (simplified)
41
+ * Full type from metro-config can be used if available
42
+ */
43
+ export interface MetroConfig {
44
+ projectRoot?: string;
45
+ watchFolders?: string[];
46
+ resolver?: {
47
+ resolveRequest?: (
48
+ context: unknown,
49
+ moduleName: string,
50
+ platform: string | null
51
+ ) => { filePath: string; type: string } | null;
52
+ [key: string]: unknown;
53
+ };
54
+ transformer?: {
55
+ unstable_allowRequireContext?: boolean;
56
+ [key: string]: unknown;
57
+ };
58
+ [key: string]: unknown;
59
+ }
60
+
61
+ /**
62
+ * Wraps a Metro configuration with Teardown Navigation support
63
+ *
64
+ * This function:
65
+ * 1. Generates TypeScript type definitions on startup
66
+ * 2. Watches for route file changes in development
67
+ * 3. Configures Metro to include generated files
68
+ *
69
+ * @example
70
+ * ```js
71
+ * // metro.config.js
72
+ * const { getDefaultConfig } = require('expo/metro-config');
73
+ * const { withTeardownNavigation } = require('@teardown/navigation-metro');
74
+ *
75
+ * const config = getDefaultConfig(__dirname);
76
+ *
77
+ * module.exports = withTeardownNavigation(config, {
78
+ * routesDir: './src/routes',
79
+ * generatedDir: './.teardown',
80
+ * prefixes: ['myapp://', 'https://myapp.com'],
81
+ * verbose: true,
82
+ * });
83
+ * ```
84
+ */
85
+ export function withTeardownNavigation(config: MetroConfig, options: TeardownNavigationOptions = {}): MetroConfig {
86
+ const { routesDir = "./src/routes", generatedDir = "./.teardown", prefixes = [], verbose = false } = options;
87
+
88
+ const projectRoot = config.projectRoot ?? process.cwd();
89
+ const absoluteRoutesDir = resolve(projectRoot, routesDir);
90
+ const absoluteGeneratedDir = resolve(projectRoot, generatedDir);
91
+
92
+ // Generate types on startup
93
+ try {
94
+ generateAllRouteFiles({
95
+ routesDir: absoluteRoutesDir,
96
+ generatedDir: absoluteGeneratedDir,
97
+ prefixes,
98
+ verbose,
99
+ });
100
+
101
+ if (verbose) {
102
+ console.log("[teardown/navigation] Initial generation complete");
103
+ }
104
+ } catch (error) {
105
+ console.error("[teardown/navigation] Initial generation failed:", error);
106
+ }
107
+
108
+ // Start file watcher in development
109
+ if (process.env.NODE_ENV !== "production") {
110
+ startRouteWatcher({
111
+ routesDir: absoluteRoutesDir,
112
+ generatedDir: absoluteGeneratedDir,
113
+ prefixes,
114
+ verbose,
115
+ onRegenerate: () => {
116
+ if (verbose) {
117
+ console.log("[teardown/navigation] Routes regenerated");
118
+ }
119
+ },
120
+ onError: (_errors) => {
121
+ if (verbose) {
122
+ console.error("[teardown/navigation] Validation errors during watch");
123
+ }
124
+ },
125
+ });
126
+ }
127
+
128
+ // Add watch folders for Metro
129
+ const watchFolders = [...(config.watchFolders ?? []), absoluteRoutesDir, absoluteGeneratedDir];
130
+
131
+ return {
132
+ ...config,
133
+ watchFolders,
134
+ transformer: {
135
+ ...config.transformer,
136
+ unstable_allowRequireContext: true,
137
+ },
138
+ resolver: {
139
+ ...config.resolver,
140
+ },
141
+ };
142
+ }
143
+
144
+ export type { GenerateOptions, RouteParamEntry } from "./generator/route-generator";
145
+ // Re-export modules
146
+ export { generateAllRouteFiles } from "./generator/route-generator";
147
+ export type { ParamDefinition, RouteNode, ScanError, ScanResult } from "./scanner/file-scanner";
148
+ export {
149
+ buildUrlPath,
150
+ extractParams,
151
+ filePathToScreenName,
152
+ flattenRoutes,
153
+ scanRoutesDirectory,
154
+ } from "./scanner/file-scanner";
155
+ export type { ValidationError } from "./validator/route-validator";
156
+ export { validateRoutes } from "./validator/route-validator";
157
+ export type { WatcherOptions } from "./watcher/file-watcher";
158
+ export { isWatcherRunning, startRouteWatcher, stopRouteWatcher } from "./watcher/file-watcher";
@@ -0,0 +1,271 @@
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 { buildUrlPath, extractParams, filePathToScreenName, type RouteNode, scanRoutesDirectory } from "./file-scanner";
5
+
6
+ const TEST_DIR = join(import.meta.dir, "__test_routes__");
7
+
8
+ function createTestFile(relativePath: string, content = "export default {}") {
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("File Scanner", () => {
16
+ beforeEach(() => {
17
+ mkdirSync(TEST_DIR, { recursive: true });
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(TEST_DIR, { recursive: true, force: true });
22
+ });
23
+
24
+ describe("extractParams", () => {
25
+ it("should extract required param from [param]", () => {
26
+ const params = extractParams("[userId]");
27
+ expect(params).toEqual([{ name: "userId", isOptional: false, isCatchAll: false }]);
28
+ });
29
+
30
+ it("should extract optional param from [param]?", () => {
31
+ const params = extractParams("[section]?");
32
+ expect(params).toEqual([{ name: "section", isOptional: true, isCatchAll: false }]);
33
+ });
34
+
35
+ it("should extract catch-all param from [...slug]", () => {
36
+ const params = extractParams("[...slug]");
37
+ expect(params).toEqual([{ name: "slug", isOptional: false, isCatchAll: true }]);
38
+ });
39
+
40
+ it("should return empty array for static filename", () => {
41
+ const params = extractParams("about");
42
+ expect(params).toEqual([]);
43
+ });
44
+
45
+ it("should return empty array for index", () => {
46
+ const params = extractParams("index");
47
+ expect(params).toEqual([]);
48
+ });
49
+
50
+ it("should return empty array for _layout", () => {
51
+ const params = extractParams("_layout");
52
+ expect(params).toEqual([]);
53
+ });
54
+ });
55
+
56
+ describe("buildUrlPath", () => {
57
+ it("should build root path for index.tsx", () => {
58
+ const path = buildUrlPath("index.tsx", true, false);
59
+ expect(path).toBe("/");
60
+ });
61
+
62
+ it("should build static path", () => {
63
+ const path = buildUrlPath("about.tsx", false, false);
64
+ expect(path).toBe("/about");
65
+ });
66
+
67
+ it("should build nested path", () => {
68
+ const path = buildUrlPath("users/index.tsx", true, false);
69
+ expect(path).toBe("/users");
70
+ });
71
+
72
+ it("should build dynamic path with required param", () => {
73
+ const path = buildUrlPath("users/[userId].tsx", false, false);
74
+ expect(path).toBe("/users/:userId");
75
+ });
76
+
77
+ it("should build dynamic path with optional param", () => {
78
+ const path = buildUrlPath("settings/[section]?.tsx", false, false);
79
+ expect(path).toBe("/settings/:section?");
80
+ });
81
+
82
+ it("should build catch-all path", () => {
83
+ const path = buildUrlPath("docs/[...slug].tsx", false, false);
84
+ expect(path).toBe("/docs/*");
85
+ });
86
+
87
+ it("should remove route groups from path", () => {
88
+ const path = buildUrlPath("(tabs)/home.tsx", false, false);
89
+ expect(path).toBe("/home");
90
+ });
91
+
92
+ it("should return empty string for layout files", () => {
93
+ const path = buildUrlPath("_layout.tsx", false, true);
94
+ expect(path).toBe("");
95
+ });
96
+
97
+ it("should handle deeply nested dynamic paths", () => {
98
+ const path = buildUrlPath("users/[userId]/posts/[postId].tsx", false, false);
99
+ expect(path).toBe("/users/:userId/posts/:postId");
100
+ });
101
+ });
102
+
103
+ describe("filePathToScreenName", () => {
104
+ it("should convert file path to screen name", () => {
105
+ expect(filePathToScreenName("users/[userId].tsx")).toBe("users/[userId]");
106
+ });
107
+
108
+ it("should handle index files", () => {
109
+ expect(filePathToScreenName("users/index.tsx")).toBe("users/index");
110
+ });
111
+
112
+ it("should handle layout files", () => {
113
+ expect(filePathToScreenName("users/_layout.tsx")).toBe("users/_layout");
114
+ });
115
+ });
116
+
117
+ describe("scanRoutesDirectory", () => {
118
+ it("should return empty routes for non-existent directory", () => {
119
+ const result = scanRoutesDirectory("/non/existent/path");
120
+ expect(result.routes).toEqual([]);
121
+ expect(result.errors.length).toBeGreaterThan(0);
122
+ });
123
+
124
+ it("should scan single index file", () => {
125
+ createTestFile("index.tsx", "export default function Home() { return null; }");
126
+
127
+ const result = scanRoutesDirectory(TEST_DIR);
128
+ expect(result.errors).toEqual([]);
129
+ expect(result.routes.length).toBe(1);
130
+ expect(result.routes[0].path).toBe("/");
131
+ expect(result.routes[0].isIndex).toBe(true);
132
+ });
133
+
134
+ it("should scan static route file", () => {
135
+ createTestFile("about.tsx", "export default function About() { return null; }");
136
+
137
+ const result = scanRoutesDirectory(TEST_DIR);
138
+ expect(result.routes.length).toBe(1);
139
+ expect(result.routes[0].path).toBe("/about");
140
+ expect(result.routes[0].isIndex).toBe(false);
141
+ });
142
+
143
+ it("should scan dynamic route file", () => {
144
+ createTestFile("users/[userId].tsx", "export default function User() { return null; }");
145
+
146
+ const result = scanRoutesDirectory(TEST_DIR);
147
+ const userRoute = result.routes.find((r) => r.path === "/users/:userId");
148
+ expect(userRoute).toBeDefined();
149
+ expect(userRoute?.params).toEqual([{ name: "userId", isOptional: false, isCatchAll: false }]);
150
+ });
151
+
152
+ it("should scan layout files", () => {
153
+ createTestFile("_layout.tsx", "export default { type: 'stack' }");
154
+
155
+ const result = scanRoutesDirectory(TEST_DIR);
156
+ expect(result.routes.length).toBe(1);
157
+ expect(result.routes[0].isLayout).toBe(true);
158
+ expect(result.routes[0].layoutType).toBe("stack");
159
+ });
160
+
161
+ it("should scan tab layout files", () => {
162
+ createTestFile(
163
+ "(tabs)/_layout.tsx",
164
+ `
165
+ import { defineLayout } from '@teardown/navigation';
166
+ export default defineLayout({ type: 'tabs' });
167
+ `
168
+ );
169
+
170
+ const result = scanRoutesDirectory(TEST_DIR);
171
+ const tabLayout = result.routes.find((r) => r.isLayout && r.groupName === "tabs");
172
+ expect(tabLayout).toBeDefined();
173
+ expect(tabLayout?.layoutType).toBe("tabs");
174
+ });
175
+
176
+ it("should detect route groups", () => {
177
+ createTestFile("(auth)/login.tsx", "export default function Login() { return null; }");
178
+
179
+ const result = scanRoutesDirectory(TEST_DIR);
180
+ const loginRoute = result.routes.find((r) => r.path === "/login");
181
+ expect(loginRoute).toBeDefined();
182
+ expect(loginRoute?.groupName).toBe("auth");
183
+ });
184
+
185
+ it("should ignore hidden files starting with underscore (except _layout)", () => {
186
+ createTestFile("_hidden.tsx", "export default {}");
187
+ createTestFile("about.tsx", "export default {}");
188
+
189
+ const result = scanRoutesDirectory(TEST_DIR);
190
+ expect(result.routes.length).toBe(1);
191
+ expect(result.routes[0].path).toBe("/about");
192
+ });
193
+
194
+ it("should ignore test files", () => {
195
+ createTestFile("about.test.tsx", "export default {}");
196
+ createTestFile("about.tsx", "export default {}");
197
+
198
+ const result = scanRoutesDirectory(TEST_DIR);
199
+ expect(result.routes.length).toBe(1);
200
+ expect(result.routes[0].path).toBe("/about");
201
+ });
202
+
203
+ it("should scan catch-all routes", () => {
204
+ createTestFile("[...catchAll].tsx", "export default function CatchAll() { return null; }");
205
+
206
+ const result = scanRoutesDirectory(TEST_DIR);
207
+ expect(result.routes.length).toBe(1);
208
+ expect(result.routes[0].path).toBe("/*");
209
+ expect(result.routes[0].isCatchAll).toBe(true);
210
+ expect(result.routes[0].params).toEqual([{ name: "catchAll", isOptional: false, isCatchAll: true }]);
211
+ });
212
+
213
+ it("should build route tree with parent-child relationships", () => {
214
+ createTestFile("users/_layout.tsx", "export default { type: 'stack' }");
215
+ createTestFile("users/index.tsx", "export default {}");
216
+ createTestFile("users/[userId].tsx", "export default {}");
217
+
218
+ const result = scanRoutesDirectory(TEST_DIR);
219
+
220
+ // Find the layout
221
+ const layout = result.routes.find((r) => r.isLayout && r.relativePath.includes("users"));
222
+ expect(layout).toBeDefined();
223
+ expect(layout?.children.length).toBe(2);
224
+
225
+ // Children should be index and [userId]
226
+ const childPaths = layout?.children.map((c) => c.path) || [];
227
+ expect(childPaths).toContain("/users");
228
+ expect(childPaths).toContain("/users/:userId");
229
+ });
230
+
231
+ it("should handle complex nested structure", () => {
232
+ createTestFile("_layout.tsx", "export default { type: 'stack' }");
233
+ createTestFile("index.tsx", "export default {}");
234
+ createTestFile("about.tsx", "export default {}");
235
+ createTestFile("users/_layout.tsx", "export default { type: 'stack' }");
236
+ createTestFile("users/index.tsx", "export default {}");
237
+ createTestFile("users/[userId].tsx", "export default {}");
238
+ createTestFile("users/[userId]/posts/[postId].tsx", "export default {}");
239
+ createTestFile("(tabs)/_layout.tsx", "export default { type: 'tabs' }");
240
+ createTestFile("(tabs)/home.tsx", "export default {}");
241
+ createTestFile("(tabs)/settings.tsx", "export default {}");
242
+
243
+ const result = scanRoutesDirectory(TEST_DIR);
244
+
245
+ // Should have root layout with children
246
+ const rootLayout = result.routes.find((r) => r.isLayout && r.relativePath === "_layout.tsx");
247
+ expect(rootLayout).toBeDefined();
248
+
249
+ // Flatten all routes for easier checking
250
+ const allRoutes = flattenRoutes(result.routes);
251
+ const allPaths = allRoutes.filter((r) => !r.isLayout).map((r) => r.path);
252
+
253
+ expect(allPaths).toContain("/");
254
+ expect(allPaths).toContain("/about");
255
+ expect(allPaths).toContain("/users");
256
+ expect(allPaths).toContain("/users/:userId");
257
+ expect(allPaths).toContain("/users/:userId/posts/:postId");
258
+ expect(allPaths).toContain("/home");
259
+ expect(allPaths).toContain("/settings");
260
+ });
261
+ });
262
+ });
263
+
264
+ function flattenRoutes(routes: RouteNode[]): RouteNode[] {
265
+ const result: RouteNode[] = [];
266
+ for (const route of routes) {
267
+ result.push(route);
268
+ result.push(...flattenRoutes(route.children));
269
+ }
270
+ return result;
271
+ }