@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 ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@teardown/navigation-metro",
3
+ "version": "2.0.43",
4
+ "description": "Metro plugin for @teardown/navigation type generation",
5
+ "private": false,
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "type": "module",
10
+ "main": "./src/index.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./src/index.ts",
14
+ "import": "./src/index.ts",
15
+ "default": "./src/index.ts"
16
+ }
17
+ },
18
+ "files": [
19
+ "src/**/*"
20
+ ],
21
+ "scripts": {
22
+ "typecheck": "bun x tsgo --noEmit",
23
+ "lint": "bun x biome lint --write ./src",
24
+ "fmt": "bun x biome format --write ./src",
25
+ "check": "bun x biome check ./src",
26
+ "test": "bun test"
27
+ },
28
+ "dependencies": {
29
+ "chokidar": "^4.0.0",
30
+ "glob": "^11.0.0"
31
+ },
32
+ "peerDependencies": {
33
+ "metro-config": ">=0.76.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "metro-config": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "devDependencies": {
41
+ "@biomejs/biome": "2.3.11",
42
+ "@teardown/tsconfig": "2.0.43",
43
+ "@types/node": "24.10.1",
44
+ "typescript": "5.9.3"
45
+ }
46
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Generator module for @teardown/navigation-metro
3
+ */
4
+
5
+ export {
6
+ buildRouteParamsInterface,
7
+ type GenerateOptions,
8
+ generateAllRouteFiles,
9
+ generateLinkingFileContent,
10
+ generateRegisterFileContent,
11
+ generateRoutesFileContent,
12
+ type RouteParamEntry,
13
+ } from "./route-generator";
@@ -0,0 +1,287 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { scanRoutesDirectory } from "../scanner/file-scanner";
5
+ import {
6
+ buildRouteParamsInterface,
7
+ generateAllRouteFiles,
8
+ generateLinkingFileContent,
9
+ generateRegisterFileContent,
10
+ generateRoutesFileContent,
11
+ } from "./route-generator";
12
+
13
+ const TEST_ROUTES_DIR = join(import.meta.dir, "__test_routes__");
14
+ const TEST_GENERATED_DIR = join(import.meta.dir, "__test_generated__");
15
+
16
+ function createTestFile(relativePath: string, content = "export default {}") {
17
+ const fullPath = join(TEST_ROUTES_DIR, relativePath);
18
+ const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
19
+ mkdirSync(dir, { recursive: true });
20
+ writeFileSync(fullPath, content);
21
+ }
22
+
23
+ describe("Route Generator", () => {
24
+ beforeEach(() => {
25
+ mkdirSync(TEST_ROUTES_DIR, { recursive: true });
26
+ mkdirSync(TEST_GENERATED_DIR, { recursive: true });
27
+ });
28
+
29
+ afterEach(() => {
30
+ rmSync(TEST_ROUTES_DIR, { recursive: true, force: true });
31
+ rmSync(TEST_GENERATED_DIR, { recursive: true, force: true });
32
+ });
33
+
34
+ describe("buildRouteParamsInterface", () => {
35
+ it("should return empty array for empty routes", () => {
36
+ const result = buildRouteParamsInterface([]);
37
+ expect(result).toEqual([]);
38
+ });
39
+
40
+ it("should generate static route with undefined params", () => {
41
+ createTestFile("about.tsx");
42
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
43
+
44
+ const result = buildRouteParamsInterface(routes);
45
+ expect(result).toEqual([{ path: "/about", params: [] }]);
46
+ });
47
+
48
+ it("should generate dynamic route with params", () => {
49
+ createTestFile("users/[userId].tsx");
50
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
51
+
52
+ const result = buildRouteParamsInterface(routes);
53
+ const userRoute = result.find((r) => r.path === "/users/:userId");
54
+ expect(userRoute).toBeDefined();
55
+ expect(userRoute?.params).toEqual([{ name: "userId", isOptional: false, isCatchAll: false }]);
56
+ });
57
+
58
+ it("should handle optional params", () => {
59
+ createTestFile("settings/[section]?.tsx");
60
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
61
+
62
+ const result = buildRouteParamsInterface(routes);
63
+ const settingsRoute = result.find((r) => r.path === "/settings/:section?");
64
+ expect(settingsRoute).toBeDefined();
65
+ expect(settingsRoute?.params).toEqual([{ name: "section", isOptional: true, isCatchAll: false }]);
66
+ });
67
+
68
+ it("should handle catch-all params", () => {
69
+ createTestFile("[...slug].tsx");
70
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
71
+
72
+ const result = buildRouteParamsInterface(routes);
73
+ const catchAllRoute = result.find((r) => r.path === "/*");
74
+ expect(catchAllRoute).toBeDefined();
75
+ expect(catchAllRoute?.params).toEqual([{ name: "slug", isOptional: false, isCatchAll: true }]);
76
+ });
77
+
78
+ it("should accumulate params from parent layouts", () => {
79
+ createTestFile("users/_layout.tsx");
80
+ createTestFile("users/[userId]/_layout.tsx");
81
+ createTestFile("users/[userId]/posts/[postId].tsx");
82
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
83
+
84
+ const result = buildRouteParamsInterface(routes);
85
+ const postRoute = result.find((r) => r.path === "/users/:userId/posts/:postId");
86
+ expect(postRoute).toBeDefined();
87
+ expect(postRoute?.params.length).toBe(2);
88
+ expect(postRoute?.params.map((p) => p.name)).toContain("userId");
89
+ expect(postRoute?.params.map((p) => p.name)).toContain("postId");
90
+ });
91
+
92
+ it("should skip layout files from route entries", () => {
93
+ createTestFile("_layout.tsx");
94
+ createTestFile("index.tsx");
95
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
96
+
97
+ const result = buildRouteParamsInterface(routes);
98
+ // Should only have index, not layout
99
+ expect(result.length).toBe(1);
100
+ expect(result[0].path).toBe("/");
101
+ });
102
+ });
103
+
104
+ describe("generateRoutesFileContent", () => {
105
+ it("should generate valid TypeScript for static routes", () => {
106
+ createTestFile("index.tsx");
107
+ createTestFile("about.tsx");
108
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
109
+
110
+ const content = generateRoutesFileContent(routes);
111
+
112
+ expect(content).toContain("export interface RouteParams");
113
+ expect(content).toContain('"/": undefined');
114
+ expect(content).toContain('"/about": undefined');
115
+ expect(content).toContain("export type RoutePath = keyof RouteParams");
116
+ expect(content).toContain("export type ParamsFor<T extends RoutePath> = RouteParams[T]");
117
+ });
118
+
119
+ it("should generate valid TypeScript for dynamic routes", () => {
120
+ createTestFile("users/[userId].tsx");
121
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
122
+
123
+ const content = generateRoutesFileContent(routes);
124
+
125
+ expect(content).toContain('"/users/:userId": { userId: string }');
126
+ });
127
+
128
+ it("should generate valid TypeScript for optional params", () => {
129
+ createTestFile("settings/[section]?.tsx");
130
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
131
+
132
+ const content = generateRoutesFileContent(routes);
133
+
134
+ expect(content).toContain('"/settings/:section?": { section?: string }');
135
+ });
136
+
137
+ it("should generate valid TypeScript for catch-all routes", () => {
138
+ createTestFile("[...slug].tsx");
139
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
140
+
141
+ const content = generateRoutesFileContent(routes);
142
+
143
+ expect(content).toContain('"/*": { slug: string[] }');
144
+ });
145
+
146
+ it("should include RouteWithParams utility type", () => {
147
+ createTestFile("index.tsx");
148
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
149
+
150
+ const content = generateRoutesFileContent(routes);
151
+
152
+ expect(content).toContain("export type RouteWithParams");
153
+ });
154
+
155
+ it("should include NavigatorType", () => {
156
+ createTestFile("index.tsx");
157
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
158
+
159
+ const content = generateRoutesFileContent(routes);
160
+
161
+ expect(content).toContain('export type NavigatorType = "stack" | "tabs" | "drawer"');
162
+ });
163
+ });
164
+
165
+ describe("generateLinkingFileContent", () => {
166
+ it("should generate linking config for static routes", () => {
167
+ createTestFile("index.tsx");
168
+ createTestFile("about.tsx");
169
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
170
+
171
+ const content = generateLinkingFileContent(routes, ["myapp://"]);
172
+
173
+ expect(content).toContain("export const generatedLinkingConfig");
174
+ expect(content).toContain('"myapp://"');
175
+ });
176
+
177
+ it("should convert dynamic params to react-navigation format", () => {
178
+ createTestFile("users/[userId].tsx");
179
+ const { routes } = scanRoutesDirectory(TEST_ROUTES_DIR);
180
+
181
+ const content = generateLinkingFileContent(routes, []);
182
+
183
+ // Should have :userId format for react-navigation
184
+ expect(content).toContain('"/users/:userId"');
185
+ });
186
+ });
187
+
188
+ describe("generateRegisterFileContent", () => {
189
+ it("should generate module augmentation", () => {
190
+ const content = generateRegisterFileContent();
191
+
192
+ expect(content).toContain("declare module '@teardown/navigation'");
193
+ expect(content).toContain("interface Register");
194
+ expect(content).toContain("routeParams: RouteParams");
195
+ expect(content).toContain("routePath: RoutePath");
196
+ });
197
+ });
198
+
199
+ describe("generateAllRouteFiles", () => {
200
+ it("should create generated directory if not exists", () => {
201
+ rmSync(TEST_GENERATED_DIR, { recursive: true, force: true });
202
+ createTestFile("index.tsx");
203
+
204
+ generateAllRouteFiles({
205
+ routesDir: TEST_ROUTES_DIR,
206
+ generatedDir: TEST_GENERATED_DIR,
207
+ prefixes: [],
208
+ verbose: false,
209
+ });
210
+
211
+ expect(existsSync(TEST_GENERATED_DIR)).toBe(true);
212
+ });
213
+
214
+ it("should generate routes.generated.ts", () => {
215
+ createTestFile("index.tsx");
216
+ createTestFile("about.tsx");
217
+
218
+ generateAllRouteFiles({
219
+ routesDir: TEST_ROUTES_DIR,
220
+ generatedDir: TEST_GENERATED_DIR,
221
+ prefixes: [],
222
+ verbose: false,
223
+ });
224
+
225
+ const generatedPath = join(TEST_GENERATED_DIR, "routes.generated.ts");
226
+ expect(existsSync(generatedPath)).toBe(true);
227
+
228
+ const content = readFileSync(generatedPath, "utf-8");
229
+ expect(content).toContain("RouteParams");
230
+ expect(content).toContain('"/about"');
231
+ });
232
+
233
+ it("should generate linking.generated.ts", () => {
234
+ createTestFile("index.tsx");
235
+
236
+ generateAllRouteFiles({
237
+ routesDir: TEST_ROUTES_DIR,
238
+ generatedDir: TEST_GENERATED_DIR,
239
+ prefixes: ["myapp://"],
240
+ verbose: false,
241
+ });
242
+
243
+ const generatedPath = join(TEST_GENERATED_DIR, "linking.generated.ts");
244
+ expect(existsSync(generatedPath)).toBe(true);
245
+
246
+ const content = readFileSync(generatedPath, "utf-8");
247
+ expect(content).toContain("generatedLinkingConfig");
248
+ });
249
+
250
+ it("should generate register.d.ts", () => {
251
+ createTestFile("index.tsx");
252
+
253
+ generateAllRouteFiles({
254
+ routesDir: TEST_ROUTES_DIR,
255
+ generatedDir: TEST_GENERATED_DIR,
256
+ prefixes: [],
257
+ verbose: false,
258
+ });
259
+
260
+ const generatedPath = join(TEST_GENERATED_DIR, "register.d.ts");
261
+ expect(existsSync(generatedPath)).toBe(true);
262
+
263
+ const content = readFileSync(generatedPath, "utf-8");
264
+ expect(content).toContain("declare module '@teardown/navigation'");
265
+ });
266
+
267
+ it("should generate manifest.json", () => {
268
+ createTestFile("index.tsx");
269
+ createTestFile("users/[userId].tsx");
270
+
271
+ generateAllRouteFiles({
272
+ routesDir: TEST_ROUTES_DIR,
273
+ generatedDir: TEST_GENERATED_DIR,
274
+ prefixes: [],
275
+ verbose: false,
276
+ });
277
+
278
+ const generatedPath = join(TEST_GENERATED_DIR, "manifest.json");
279
+ expect(existsSync(generatedPath)).toBe(true);
280
+
281
+ const content = JSON.parse(readFileSync(generatedPath, "utf-8"));
282
+ expect(content.routeCount).toBe(2);
283
+ expect(content.routes).toBeInstanceOf(Array);
284
+ expect(content.generatedAt).toBeDefined();
285
+ });
286
+ });
287
+ });
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Route generator for @teardown/navigation-metro
3
+ * Generates TypeScript type definitions from scanned route tree
4
+ */
5
+
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { flattenRoutes, type ParamDefinition, type RouteNode, scanRoutesDirectory } from "../scanner/file-scanner";
9
+
10
+ export interface GenerateOptions {
11
+ routesDir: string;
12
+ generatedDir: string;
13
+ prefixes: string[];
14
+ verbose: boolean;
15
+ }
16
+
17
+ export interface RouteParamEntry {
18
+ path: string;
19
+ params: ParamDefinition[];
20
+ }
21
+
22
+ /**
23
+ * Generates all route files (routes.generated.ts, linking.generated.ts, register.d.ts, manifest.json)
24
+ */
25
+ export function generateAllRouteFiles(options: GenerateOptions): void {
26
+ const { routesDir, generatedDir, prefixes, verbose } = options;
27
+
28
+ // Ensure generated directory exists
29
+ mkdirSync(generatedDir, { recursive: true });
30
+
31
+ // Scan routes
32
+ const { routes, errors } = scanRoutesDirectory(routesDir);
33
+
34
+ if (errors.length > 0 && verbose) {
35
+ console.warn("[teardown/navigation] Scan warnings:", errors);
36
+ }
37
+
38
+ // Generate files
39
+ const routesContent = generateRoutesFileContent(routes);
40
+ writeFileSync(join(generatedDir, "routes.generated.ts"), routesContent);
41
+
42
+ const linkingContent = generateLinkingFileContent(routes, prefixes);
43
+ writeFileSync(join(generatedDir, "linking.generated.ts"), linkingContent);
44
+
45
+ const registerContent = generateRegisterFileContent();
46
+ writeFileSync(join(generatedDir, "register.d.ts"), registerContent);
47
+
48
+ const manifestContent = generateManifestContent(routes);
49
+ writeFileSync(join(generatedDir, "manifest.json"), JSON.stringify(manifestContent, null, 2));
50
+
51
+ if (verbose) {
52
+ const count = countRoutes(routes);
53
+ console.log(`[teardown/navigation] Generated ${count} routes`);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Builds the RouteParams interface entries from the route tree
59
+ * Each route has all its params extracted from its full path, so no accumulation needed
60
+ */
61
+ export function buildRouteParamsInterface(routes: RouteNode[]): RouteParamEntry[] {
62
+ const result: RouteParamEntry[] = [];
63
+
64
+ function traverse(nodes: RouteNode[]): void {
65
+ for (const node of nodes) {
66
+ if (node.isLayout) {
67
+ // Layouts don't get their own route entry
68
+ traverse(node.children);
69
+ continue;
70
+ }
71
+
72
+ result.push({
73
+ path: node.path,
74
+ params: node.params,
75
+ });
76
+
77
+ if (node.children.length > 0) {
78
+ traverse(node.children);
79
+ }
80
+ }
81
+ }
82
+
83
+ traverse(routes);
84
+ return result;
85
+ }
86
+
87
+ /**
88
+ * Generates the routes.generated.ts file content
89
+ */
90
+ export function generateRoutesFileContent(routes: RouteNode[]): string {
91
+ const routeParams = buildRouteParamsInterface(routes);
92
+
93
+ const lines: string[] = [
94
+ "// Auto-generated by @teardown/navigation",
95
+ "// Do not edit this file directly",
96
+ `// Generated at: ${new Date().toISOString()}`,
97
+ "",
98
+ "export interface RouteParams {",
99
+ ];
100
+
101
+ // Add route param entries
102
+ for (const { path, params } of routeParams) {
103
+ const paramsType =
104
+ params.length === 0
105
+ ? "undefined"
106
+ : `{ ${params
107
+ .map((p) => {
108
+ const type = p.isCatchAll ? "string[]" : "string";
109
+ return `${p.name}${p.isOptional ? "?" : ""}: ${type}`;
110
+ })
111
+ .join("; ")} }`;
112
+
113
+ lines.push(`\t"${path}": ${paramsType};`);
114
+ }
115
+
116
+ lines.push("}");
117
+ lines.push("");
118
+
119
+ // Add type aliases
120
+ lines.push("export type RoutePath = keyof RouteParams;");
121
+ lines.push("");
122
+ lines.push("export type ParamsFor<T extends RoutePath> = RouteParams[T];");
123
+ lines.push("");
124
+
125
+ // Add RouteWithParams utility type
126
+ lines.push("export type RouteWithParams = {");
127
+ lines.push("\t[K in RoutePath]: RouteParams[K] extends undefined");
128
+ lines.push("\t\t? { path: K }");
129
+ lines.push("\t\t: { path: K; params: RouteParams[K] };");
130
+ lines.push("}[RoutePath];");
131
+ lines.push("");
132
+
133
+ // Add NavigatorType
134
+ lines.push('export type NavigatorType = "stack" | "tabs" | "drawer";');
135
+ lines.push("");
136
+
137
+ return lines.join("\n");
138
+ }
139
+
140
+ /**
141
+ * Generates the linking.generated.ts file content
142
+ */
143
+ export function generateLinkingFileContent(routes: RouteNode[], prefixes: string[]): string {
144
+ const routeParams = buildRouteParamsInterface(routes);
145
+
146
+ const lines: string[] = [
147
+ "// Auto-generated by @teardown/navigation",
148
+ 'import type { LinkingOptions } from "@react-navigation/native";',
149
+ 'import type { RouteParams } from "./routes.generated";',
150
+ "",
151
+ ];
152
+
153
+ // Build screens config
154
+ const screens: Record<string, string> = {};
155
+ for (const { path } of routeParams) {
156
+ // Convert /users/:userId to users/:userId (remove leading slash)
157
+ const linkingPath = path === "/" ? "" : path.slice(1);
158
+ screens[path] = linkingPath;
159
+ }
160
+
161
+ lines.push('export const generatedLinkingConfig: LinkingOptions<RouteParams>["config"] = {');
162
+ lines.push("\tscreens: {");
163
+
164
+ for (const [routePath, linkingPath] of Object.entries(screens)) {
165
+ lines.push(`\t\t"${routePath}": "${linkingPath}",`);
166
+ }
167
+
168
+ lines.push("\t},");
169
+ lines.push("};");
170
+ lines.push("");
171
+
172
+ // Add prefixes
173
+ lines.push(`export const defaultPrefixes: string[] = ${JSON.stringify(prefixes)};`);
174
+ lines.push("");
175
+
176
+ return lines.join("\n");
177
+ }
178
+
179
+ /**
180
+ * Generates the register.d.ts file content
181
+ */
182
+ export function generateRegisterFileContent(): string {
183
+ const lines: string[] = [
184
+ "// Auto-generated by @teardown/navigation",
185
+ 'import type { RouteParams, RoutePath } from "./routes.generated";',
186
+ "",
187
+ "declare module '@teardown/navigation' {",
188
+ "\tinterface Register {",
189
+ "\t\trouteParams: RouteParams;",
190
+ "\t\troutePath: RoutePath;",
191
+ "\t}",
192
+ "}",
193
+ "",
194
+ ];
195
+
196
+ return lines.join("\n");
197
+ }
198
+
199
+ /**
200
+ * Generates the manifest.json content
201
+ */
202
+ function generateManifestContent(routes: RouteNode[]): {
203
+ generatedAt: string;
204
+ routeCount: number;
205
+ routes: Array<{
206
+ path: string;
207
+ file: string;
208
+ params: ParamDefinition[];
209
+ layoutType: string;
210
+ }>;
211
+ } {
212
+ const allRoutes = flattenRoutes(routes);
213
+
214
+ return {
215
+ generatedAt: new Date().toISOString(),
216
+ routeCount: allRoutes.filter((r) => !r.isLayout).length,
217
+ routes: allRoutes.map((r) => ({
218
+ path: r.path,
219
+ file: r.relativePath,
220
+ params: r.params,
221
+ layoutType: r.layoutType,
222
+ })),
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Counts the number of non-layout routes
228
+ */
229
+ function countRoutes(routes: RouteNode[]): number {
230
+ return flattenRoutes(routes).filter((r) => !r.isLayout).length;
231
+ }