@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
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
|
+
}
|