react-router-routes 0.3.0__tar.gz

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,115 @@
1
+ Metadata-Version: 2.3
2
+ Name: react-router-routes
3
+ Version: 0.3.0
4
+ Summary: Generate strongly-typed Python helpers (TypedDict param classes + path/url builders) from a React Router v6+ route tree so Python code can build frontend URLs safely.
5
+ Keywords: react-router,router,routes,url-generation,typed-routes,typed,cli,backend,frontend,integration
6
+ Author: Michael Bianco
7
+ Author-email: Michael Bianco <mike@mikebian.co>
8
+ Requires-Dist: typer>=0.12.5
9
+ Requires-Dist: jinja2>=3.1.4
10
+ Requires-Dist: structlog-config>=0.8.0
11
+ Requires-Python: >=3.11
12
+ Project-URL: Repository, https://github.com/iloveitaly/react-router-routes
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Generate Typed Python URL helpers from React Router routes
16
+
17
+ Generate strongly-typed Python helpers (TypedDict param objects + overloads) from a React Router v6+ route tree. This is useful when a Python backend, worker, or test suite needs to construct URLs that stay in sync with a JavaScript/TypeScript frontend using React Router.
18
+
19
+ [Take a look at this project for a live usage example.](https://github.com/iloveitaly/python-starter-template/blob/0946513b9430c1cd4eb2b115ea596ae2ffc6994a/just/python.just#L61-L62)
20
+
21
+ ## How it works
22
+
23
+ Given a React Router project, the CLI either:
24
+
25
+ * Auto-detects your package manager (bun, pnpm, or npm) and runs `<package-manager> react-router routes --json` (if you pass `--directory`), or
26
+ * Reads a pre-generated JSON file (if you pass `--json-file`)
27
+
28
+ It walks the returned route objects and produces a Python module containing:
29
+
30
+ * `RoutePaths` Literal of every concrete route pattern (e.g. `/users/:userId?`, `/files/*`).
31
+ * Per-route `TypedDict` classes containing snake_case parameter keys.
32
+ * Overloaded `react_router_path()` to build a relative path with validation + percent-encoding.
33
+ * Overloaded `react_router_url()` to prepend a base URL (explicit argument or `BASE_URL` env var).
34
+ * Optional `url_params` argument on both functions to append query string parameters.
35
+
36
+ ## Installation
37
+
38
+ Requires Python 3.11+.
39
+
40
+ Using uv (recommended):
41
+
42
+ ```bash
43
+ uv add react-router-routes
44
+ ```
45
+
46
+ Or with pip:
47
+
48
+ ```bash
49
+ pip install react-router-routes
50
+ ```
51
+
52
+ ## Prerequisites
53
+
54
+ Your JS project must have `react-router` and a package manager with the `react-router routes --json` command available (React Router v6+ data APIs). The tool automatically detects your package manager (bun, pnpm, or npm) based on lockfiles and availability. The Python process must run inside (or have access to) that project directory so the CLI can execute the command.
55
+
56
+ ## CLI Usage
57
+
58
+ The script entry point is named `react-router-routes` (see `pyproject.toml`).
59
+
60
+ Two ways to supply routes:
61
+
62
+ 1. Have the tool invoke `<package-manager> react-router routes --json` by providing a directory (auto-detects bun, pnpm, or npm):
63
+
64
+ ```bash
65
+ react-router-routes ./routes_typing.py --directory ./frontend
66
+ ```
67
+
68
+ 1. Provide an existing JSON file (output of `pnpm react-router routes --json`):
69
+
70
+ ```bash
71
+ react-router-routes ./routes_typing.py --json-file tests/react-router.json
72
+ ```
73
+
74
+ Then import the generated module in Python code:
75
+
76
+ ```python
77
+ from routes_typing import react_router_path, react_router_url, RoutePaths
78
+
79
+ # Basic path generation
80
+ react_router_path('/users/:userId', {'user_id': 123}) # -> '/users/123'
81
+
82
+ # URL generation with base URL
83
+ react_router_url('/files/*', {'splat': 'docs/readme.md'}, base_url='https://example.com')
84
+ # -> 'https://example.com/files/docs/readme.md'
85
+
86
+ # Adding query parameters with url_params
87
+ react_router_path('/users/:userId', {'user_id': 123}, url_params={'tab': 'profile', 'edit': 'true'})
88
+ # -> '/users/123?tab=profile&edit=true'
89
+
90
+ react_router_url('/home', base_url='https://example.com', url_params={'page': '1', 'sort': 'name'})
91
+ # -> 'https://example.com/home?page=1&sort=name'
92
+ ```
93
+
94
+
95
+ ## Environment Variables
96
+
97
+ * `BASE_URL` (optional) – If set and you omit `base_url` when calling `react_router_url`, this value is prepended. If missing the function returns the path and logs a warning.
98
+ * `LOG_LEVEL` (optional) – Standard Python logging level (INFO, DEBUG, etc.).
99
+
100
+
101
+ ## Development
102
+
103
+ Clone and install dev deps:
104
+
105
+ ```bash
106
+ uv sync --group dev
107
+ ```
108
+
109
+ Run tests:
110
+
111
+ ```bash
112
+ uv run pytest
113
+ ```
114
+
115
+ ## [MIT License](LICENSE)
@@ -0,0 +1,101 @@
1
+ # Generate Typed Python URL helpers from React Router routes
2
+
3
+ Generate strongly-typed Python helpers (TypedDict param objects + overloads) from a React Router v6+ route tree. This is useful when a Python backend, worker, or test suite needs to construct URLs that stay in sync with a JavaScript/TypeScript frontend using React Router.
4
+
5
+ [Take a look at this project for a live usage example.](https://github.com/iloveitaly/python-starter-template/blob/0946513b9430c1cd4eb2b115ea596ae2ffc6994a/just/python.just#L61-L62)
6
+
7
+ ## How it works
8
+
9
+ Given a React Router project, the CLI either:
10
+
11
+ * Auto-detects your package manager (bun, pnpm, or npm) and runs `<package-manager> react-router routes --json` (if you pass `--directory`), or
12
+ * Reads a pre-generated JSON file (if you pass `--json-file`)
13
+
14
+ It walks the returned route objects and produces a Python module containing:
15
+
16
+ * `RoutePaths` Literal of every concrete route pattern (e.g. `/users/:userId?`, `/files/*`).
17
+ * Per-route `TypedDict` classes containing snake_case parameter keys.
18
+ * Overloaded `react_router_path()` to build a relative path with validation + percent-encoding.
19
+ * Overloaded `react_router_url()` to prepend a base URL (explicit argument or `BASE_URL` env var).
20
+ * Optional `url_params` argument on both functions to append query string parameters.
21
+
22
+ ## Installation
23
+
24
+ Requires Python 3.11+.
25
+
26
+ Using uv (recommended):
27
+
28
+ ```bash
29
+ uv add react-router-routes
30
+ ```
31
+
32
+ Or with pip:
33
+
34
+ ```bash
35
+ pip install react-router-routes
36
+ ```
37
+
38
+ ## Prerequisites
39
+
40
+ Your JS project must have `react-router` and a package manager with the `react-router routes --json` command available (React Router v6+ data APIs). The tool automatically detects your package manager (bun, pnpm, or npm) based on lockfiles and availability. The Python process must run inside (or have access to) that project directory so the CLI can execute the command.
41
+
42
+ ## CLI Usage
43
+
44
+ The script entry point is named `react-router-routes` (see `pyproject.toml`).
45
+
46
+ Two ways to supply routes:
47
+
48
+ 1. Have the tool invoke `<package-manager> react-router routes --json` by providing a directory (auto-detects bun, pnpm, or npm):
49
+
50
+ ```bash
51
+ react-router-routes ./routes_typing.py --directory ./frontend
52
+ ```
53
+
54
+ 1. Provide an existing JSON file (output of `pnpm react-router routes --json`):
55
+
56
+ ```bash
57
+ react-router-routes ./routes_typing.py --json-file tests/react-router.json
58
+ ```
59
+
60
+ Then import the generated module in Python code:
61
+
62
+ ```python
63
+ from routes_typing import react_router_path, react_router_url, RoutePaths
64
+
65
+ # Basic path generation
66
+ react_router_path('/users/:userId', {'user_id': 123}) # -> '/users/123'
67
+
68
+ # URL generation with base URL
69
+ react_router_url('/files/*', {'splat': 'docs/readme.md'}, base_url='https://example.com')
70
+ # -> 'https://example.com/files/docs/readme.md'
71
+
72
+ # Adding query parameters with url_params
73
+ react_router_path('/users/:userId', {'user_id': 123}, url_params={'tab': 'profile', 'edit': 'true'})
74
+ # -> '/users/123?tab=profile&edit=true'
75
+
76
+ react_router_url('/home', base_url='https://example.com', url_params={'page': '1', 'sort': 'name'})
77
+ # -> 'https://example.com/home?page=1&sort=name'
78
+ ```
79
+
80
+
81
+ ## Environment Variables
82
+
83
+ * `BASE_URL` (optional) – If set and you omit `base_url` when calling `react_router_url`, this value is prepended. If missing the function returns the path and logs a warning.
84
+ * `LOG_LEVEL` (optional) – Standard Python logging level (INFO, DEBUG, etc.).
85
+
86
+
87
+ ## Development
88
+
89
+ Clone and install dev deps:
90
+
91
+ ```bash
92
+ uv sync --group dev
93
+ ```
94
+
95
+ Run tests:
96
+
97
+ ```bash
98
+ uv run pytest
99
+ ```
100
+
101
+ ## [MIT License](LICENSE)
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "react-router-routes"
3
+ version = "0.3.0"
4
+ description = "Generate strongly-typed Python helpers (TypedDict param classes + path/url builders) from a React Router v6+ route tree so Python code can build frontend URLs safely."
5
+ keywords = [
6
+ "react-router",
7
+ "router",
8
+ "routes",
9
+ "url-generation",
10
+ "typed-routes",
11
+ "typed",
12
+ "cli",
13
+ "backend",
14
+ "frontend",
15
+ "integration",
16
+ ]
17
+ readme = "README.md"
18
+ requires-python = ">=3.11"
19
+ dependencies = ["typer>=0.12.5", "jinja2>=3.1.4", "structlog-config>=0.8.0"]
20
+ authors = [{ name = "Michael Bianco", email = "mike@mikebian.co" }]
21
+ urls = { "Repository" = "https://github.com/iloveitaly/react-router-routes" }
22
+
23
+ # additional packaging information: https://packaging.python.org/en/latest/specifications/core-metadata/#license
24
+ [project.scripts]
25
+ react-router-routes = "react_router_routes:main"
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.9.0,<0.10.0"]
29
+ build-backend = "uv_build"
30
+
31
+ [tool.uv.build-backend]
32
+ # avoids the src/ directory structure
33
+ module-root = ""
34
+
35
+ [dependency-groups]
36
+ dev = ["pytest>=8.3.3", "pyright[nodejs]>=1.1.380", "ruff>=0.8.0"]
37
+
38
+ [tool.pyright]
39
+ exclude = ["examples/", "playground/", "tmp/", ".venv/", "tests/"]
40
+
41
+ [tool.ruff]
42
+ extend-exclude = ["playground.py", "playground/"]
@@ -0,0 +1,23 @@
1
+ """Package entrypoint for the react-router-routes CLI.
2
+
3
+ Running the installed script (configured via [project.scripts]) will
4
+ invoke Typer's CLI defined in `generate.py`.
5
+ """
6
+
7
+ from structlog_config import configure_logger
8
+
9
+ from .generate import app
10
+
11
+ logger = configure_logger()
12
+
13
+
14
+ def main() -> None: # console_scripts entry point
15
+ """Dispatch to the Typer application.
16
+
17
+ Example:
18
+ react-router-routes generate-route-types ./js-app ./routes_typing.py
19
+ """
20
+ app()
21
+
22
+
23
+ __all__ = ["main", "app"]
@@ -0,0 +1,383 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from jinja2 import Environment
9
+ from structlog_config import configure_logger
10
+
11
+ app = typer.Typer()
12
+ log = configure_logger()
13
+
14
+
15
+ def detect_package_manager(directory: Path) -> str:
16
+ """Detect which package manager to use based on lockfiles and availability."""
17
+ lockfile_to_manager = {
18
+ "bun.lockb": "bun",
19
+ "pnpm-lock.yaml": "pnpm",
20
+ "package-lock.json": "npm",
21
+ "yarn.lock": "yarn",
22
+ }
23
+
24
+ for lockfile, manager in lockfile_to_manager.items():
25
+ if not (directory / lockfile).exists():
26
+ continue
27
+
28
+ if _is_package_manager_available(manager):
29
+ return manager
30
+
31
+ for manager in ["bun", "pnpm", "npm"]:
32
+ if _is_package_manager_available(manager):
33
+ return manager
34
+
35
+ return "npm"
36
+
37
+
38
+ def _is_package_manager_available(manager: str) -> bool:
39
+ """Check if package manager is available on system."""
40
+ try:
41
+ subprocess.run([manager, "--version"], capture_output=True, check=True)
42
+ return True
43
+ except (subprocess.CalledProcessError, FileNotFoundError):
44
+ return False
45
+
46
+
47
+ def lint_generated_file(output_file: Path) -> None:
48
+ """Automatically lint the generated file with ruff if available."""
49
+ try:
50
+ # Check if ruff is available
51
+ subprocess.run(
52
+ ["ruff", "--version"],
53
+ capture_output=True,
54
+ check=True,
55
+ )
56
+
57
+ # Run ruff check with --fix to automatically fix issues
58
+ subprocess.run(
59
+ ["ruff", "check", "--fix", str(output_file)],
60
+ capture_output=True,
61
+ check=False, # Don't fail if there are lint errors
62
+ )
63
+
64
+ # Run ruff format to format the code
65
+ subprocess.run(
66
+ ["ruff", "format", str(output_file)],
67
+ capture_output=True,
68
+ check=False, # Don't fail if formatting changes are made
69
+ )
70
+
71
+ except (subprocess.CalledProcessError, FileNotFoundError):
72
+ # ruff is not available or failed, continue silently
73
+ pass
74
+
75
+
76
+ def collect_route_patterns(routes: list[dict], parent_path: str = "") -> list[str]:
77
+ patterns: list[str] = []
78
+ for route in routes:
79
+ # Clean up path by collapsing repeated slashes and removing trailing slashes
80
+ raw = f"{parent_path}/{route.get('path', '')}"
81
+ current_path = re.sub(r"/{2,}", "/", raw).rstrip("/") or "/"
82
+ if "file" in route and ("path" in route or "index" in route):
83
+ patterns.append(current_path)
84
+ if "children" in route:
85
+ patterns.extend(collect_route_patterns(route["children"], current_path))
86
+
87
+ # De-duplicate while preserving order
88
+ deduped: list[str] = []
89
+ seen: set[str] = set()
90
+ for p in patterns:
91
+ if p not in seen:
92
+ seen.add(p)
93
+ deduped.append(p)
94
+ return deduped
95
+
96
+
97
+ def camel_to_snake(name: str) -> str:
98
+ out: list[str] = []
99
+ for ch in name:
100
+ if ch.isupper():
101
+ if out:
102
+ out.append("_")
103
+ out.append(ch.lower())
104
+ else:
105
+ out.append(ch)
106
+ return "".join(out)
107
+
108
+
109
+ def segment_to_pascal(segment: str) -> str:
110
+ if not segment:
111
+ return ""
112
+ parts = re.split(r"[^A-Za-z0-9]+", segment)
113
+ return "".join(s[:1].upper() + s[1:] for s in parts if s)
114
+
115
+
116
+ def pattern_to_class_name(pattern: str) -> str:
117
+ if pattern == "/":
118
+ return "RootParams"
119
+ name_parts: list[str] = []
120
+ for seg in pattern.strip("/").split("/"):
121
+ if not seg:
122
+ continue
123
+ if seg == "*":
124
+ name_parts.append("Splat")
125
+ continue
126
+ if seg.startswith(":"):
127
+ token = seg.lstrip(":").rstrip("?")
128
+ name_parts.append(segment_to_pascal(token))
129
+ continue
130
+ name_parts.append(segment_to_pascal(seg))
131
+ base = "".join(name_parts) or "Root"
132
+ return f"{base}Params"
133
+
134
+
135
+ def parse_params(pattern: str) -> tuple[list[tuple[str, bool]], bool]:
136
+ """Return ([(param_name, is_optional)], has_splat). param_name is the token as written (camelCase)."""
137
+ params: list[tuple[str, bool]] = []
138
+ for m in re.finditer(r":([A-Za-z0-9_]+)(\?)?", pattern):
139
+ name = m.group(1)
140
+ is_optional = m.group(2) is not None
141
+ params.append((name, is_optional))
142
+ has_splat = "*" in pattern
143
+ return params, has_splat
144
+
145
+
146
+ JINJA_TEMPLATE = r'''
147
+ """AUTOGENERATED FILE: Do not edit manually.
148
+ Generated by react-router-routes from the React Router config.
149
+
150
+ - RoutePaths is a Literal of route patterns
151
+ - Per-route TypedDicts define snake_case keys for params
152
+ - react_router_path builds a path from a pattern and params
153
+ - react_router_url prepends BASE_URL (env) or an explicit base_url
154
+ """
155
+ from typing import Literal, overload, TypedDict, Mapping, NotRequired
156
+ import re
157
+ from urllib.parse import quote, urlencode
158
+ import os
159
+ import logging
160
+
161
+ logger = logging.getLogger("react_router_routes.generated")
162
+
163
+ RoutePaths = Literal[{% for p in patterns %}{% if not loop.first %}, {% endif %}"{{ p }}"{% endfor %}]
164
+
165
+ {% for r in routes if r.params or r.has_splat %}
166
+ class {{ r.class_name }}(TypedDict):
167
+ {% if r.has_splat %}
168
+ splat: str
169
+ {% endif %}
170
+ {% for p in r.params %}
171
+ {{ p.snake }}: {% if p.optional %}NotRequired[str]{% else %}str{% endif %}
172
+ {% endfor %}
173
+
174
+ {% endfor %}
175
+
176
+ ALIAS_MAP: dict[str, dict[str, str]] = {
177
+ {% for r in routes if r.alias_map %}
178
+ "{{ r.pattern }}": {
179
+ {% for k,v in r.alias_map.items() %}"{{ k }}": "{{ v }}"{% if not loop.last %},
180
+ {% endif %}{% endfor %}
181
+ },
182
+ {% endfor %}
183
+ }
184
+
185
+ # overloads for path
186
+ {% for r in routes %}
187
+ {% if r.params or r.has_splat %}
188
+ @overload
189
+ def react_router_path(path: Literal["{{ r.pattern }}"], params: {{ r.class_name }}, *, url_params: dict[str, str] | None = None) -> str: ...
190
+ @overload
191
+ def react_router_path(path: Literal["{{ r.pattern }}"], params: Mapping[str, object], *, url_params: dict[str, str] | None = None) -> str: ...
192
+ {% else %}
193
+ @overload
194
+ def react_router_path(path: Literal["{{ r.pattern }}"], params: None | Mapping[str, object] = None, *, url_params: dict[str, str] | None = None) -> str: ...
195
+ {% endif %}
196
+ {% endfor %}
197
+ @overload
198
+ def react_router_path(path: RoutePaths, params: None | Mapping[str, object] = None, *, url_params: dict[str, str] | None = None) -> str: ...
199
+
200
+ def react_router_path(path: RoutePaths, params: Mapping[str, object] | None = None, *, url_params: dict[str, str] | None = None) -> str:
201
+ """Render a URL path from a typed pattern and params.
202
+
203
+ - Accepts snake_case or original token keys (via ALIAS_MAP)
204
+ - Replaces required ":name" and optional ":name?" tokens
205
+ - Replaces "*" with the "splat" param
206
+ - Percent-encodes values (splat keeps "/")
207
+ - Appends query parameters from url_params if provided
208
+ """
209
+ values: dict[str, object] = {} if params is None else dict(params)
210
+ alias = ALIAS_MAP.get(path, {})
211
+ # accept both snake_case and original token keys
212
+ for k, v in list(values.items()):
213
+ if k in alias:
214
+ values[alias[k]] = v
215
+
216
+ rendered = path
217
+
218
+ def _replace_optional(match: re.Match[str]) -> str:
219
+ name = match.group(1)
220
+ if name in values:
221
+ return quote(str(values[name]), safe="")
222
+ return ""
223
+
224
+ rendered = re.sub(r":([A-Za-z0-9_]+)\?", _replace_optional, rendered)
225
+
226
+ def _replace_required(match: re.Match[str]) -> str:
227
+ name = match.group(1)
228
+ assert name in values, f"missing required param: {name}"
229
+ return quote(str(values[name]), safe="")
230
+
231
+ rendered = re.sub(r":([A-Za-z0-9_]+)(?!\?)", _replace_required, rendered)
232
+
233
+ if "*" in rendered:
234
+ assert "splat" in values, "missing required param: splat"
235
+ rendered = rendered.replace("*", quote(str(values["splat"]), safe="/"))
236
+
237
+ rendered = re.sub(r"/{2,}", "/", rendered)
238
+ if rendered != "/" and rendered.endswith("/"):
239
+ rendered = rendered[:-1]
240
+
241
+ # Append query parameters if provided
242
+ if url_params:
243
+ query_string = urlencode(url_params)
244
+ rendered += f"?{query_string}"
245
+
246
+ return rendered
247
+
248
+ # overloads for url
249
+ {% for r in routes %}
250
+ {% if r.params or r.has_splat %}
251
+ @overload
252
+ def react_router_url(path: Literal["{{ r.pattern }}"], params: {{ r.class_name }}, *, base_url: str | None = None, url_params: dict[str, str] | None = None) -> str: ...
253
+ @overload
254
+ def react_router_url(path: Literal["{{ r.pattern }}"], params: Mapping[str, object], *, base_url: str | None = None, url_params: dict[str, str] | None = None) -> str: ...
255
+ {% else %}
256
+ @overload
257
+ def react_router_url(path: Literal["{{ r.pattern }}"], params: None | Mapping[str, object] = None, *, base_url: str | None = None, url_params: dict[str, str] | None = None) -> str: ...
258
+ {% endif %}
259
+ {% endfor %}
260
+ @overload
261
+ def react_router_url(path: RoutePaths, params: None | Mapping[str, object] = None, *, base_url: str | None = None, url_params: dict[str, str] | None = None) -> str: ...
262
+
263
+ def react_router_url(path: RoutePaths, params: Mapping[str, object] | None = None, *, base_url: str | None = None, url_params: dict[str, str] | None = None) -> str:
264
+ """Build a full URL by prepending base_url or ENV BASE_URL to the path."""
265
+ built = react_router_path(path, params, url_params=url_params)
266
+ base = base_url if base_url is not None else os.environ.get("BASE_URL")
267
+ if not base:
268
+ logger.warning("BASE_URL missing; returning path only: %s", path)
269
+ return built
270
+
271
+ return base.rstrip("/") + built
272
+ '''
273
+
274
+
275
+ def render_routes_module(patterns: list[str]) -> str:
276
+ # Build context for Jinja
277
+ routes = []
278
+ for pattern in patterns:
279
+ params, has_splat = parse_params(pattern)
280
+ route = {
281
+ "pattern": pattern,
282
+ "class_name": pattern_to_class_name(pattern),
283
+ "has_splat": has_splat,
284
+ "params": [
285
+ {
286
+ "name": token,
287
+ "snake": camel_to_snake(token),
288
+ "optional": is_optional,
289
+ }
290
+ for token, is_optional in params
291
+ ],
292
+ "alias_map": {camel_to_snake(token): token for token, _ in params},
293
+ }
294
+ routes.append(route)
295
+
296
+ env = Environment()
297
+ template = env.from_string(JINJA_TEMPLATE)
298
+ return template.render(patterns=patterns, routes=routes) + "\n"
299
+
300
+
301
+ @app.command()
302
+ def generate_route_types(
303
+ output_file: Path = typer.Argument(
304
+ ..., help="Path to output routes_typing.py file"
305
+ ),
306
+ directory: Path | None = typer.Option(
307
+ None,
308
+ "--directory",
309
+ "-d",
310
+ help="Path to React Router project directory (auto-detects package manager: bun, pnpm, or npm)",
311
+ ),
312
+ json_file: Path | None = typer.Option(
313
+ None,
314
+ "--json-file",
315
+ "-j",
316
+ help="Path to an existing react-router routes JSON file (skips package manager detection)",
317
+ ),
318
+ verbose: bool = typer.Option(
319
+ False,
320
+ "--verbose",
321
+ "-v",
322
+ help="Enable debug logging",
323
+ ),
324
+ ):
325
+ """Generate Python route typings and helpers from React Router routes.
326
+
327
+ You must supply either --json-file or --directory. If both are supplied, --json-file wins.
328
+ """
329
+
330
+ if verbose:
331
+ os.environ["LOG_LEVEL"] = "DEBUG"
332
+ global log
333
+ log = configure_logger()
334
+
335
+ if json_file is not None:
336
+ routes_json = json.loads(json_file.read_text())
337
+ else:
338
+ if directory is None:
339
+ directory = Path.cwd()
340
+ log.info("using default directory", directory=directory)
341
+
342
+ # Detect and use appropriate package manager
343
+ package_manager = detect_package_manager(directory)
344
+ typer.echo(f"Using package manager: {package_manager}")
345
+
346
+ result = subprocess.run(
347
+ [package_manager, "react-router", "routes", "--json"],
348
+ cwd=directory,
349
+ capture_output=True,
350
+ text=True,
351
+ )
352
+
353
+ if result.returncode != 0:
354
+ command = " ".join(str(arg) for arg in result.args)
355
+ log.debug(
356
+ "react-router command failed",
357
+ package_manager=package_manager,
358
+ command=command,
359
+ exit_code=result.returncode,
360
+ stdout=result.stdout or "",
361
+ stderr=result.stderr or "",
362
+ )
363
+ typer.echo(f"Error running react-router with {package_manager}")
364
+ raise typer.Exit(1)
365
+ routes_json = json.loads(result.stdout)
366
+
367
+ patterns = collect_route_patterns(routes_json)
368
+ content = render_routes_module(patterns)
369
+ output_file.write_text(content)
370
+
371
+ # Automatically lint the generated file with ruff if available
372
+ lint_generated_file(output_file)
373
+
374
+ try:
375
+ relative_output = output_file.relative_to(Path.cwd())
376
+ except ValueError:
377
+ relative_output = output_file
378
+
379
+ typer.secho(f"Generated route types: {relative_output}", fg=typer.colors.GREEN)
380
+
381
+
382
+ if __name__ == "__main__":
383
+ app()