litestar-vite 0.1.1__py3-none-any.whl → 0.15.0__py3-none-any.whl

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.
Files changed (169) hide show
  1. litestar_vite/__init__.py +54 -4
  2. litestar_vite/__metadata__.py +12 -7
  3. litestar_vite/cli.py +1048 -10
  4. litestar_vite/codegen/__init__.py +48 -0
  5. litestar_vite/codegen/_export.py +229 -0
  6. litestar_vite/codegen/_inertia.py +619 -0
  7. litestar_vite/codegen/_openapi.py +280 -0
  8. litestar_vite/codegen/_routes.py +720 -0
  9. litestar_vite/codegen/_ts.py +235 -0
  10. litestar_vite/codegen/_utils.py +141 -0
  11. litestar_vite/commands.py +73 -0
  12. litestar_vite/config/__init__.py +997 -0
  13. litestar_vite/config/_constants.py +97 -0
  14. litestar_vite/config/_deploy.py +70 -0
  15. litestar_vite/config/_inertia.py +241 -0
  16. litestar_vite/config/_paths.py +63 -0
  17. litestar_vite/config/_runtime.py +235 -0
  18. litestar_vite/config/_spa.py +93 -0
  19. litestar_vite/config/_types.py +94 -0
  20. litestar_vite/deploy.py +366 -0
  21. litestar_vite/doctor.py +1181 -0
  22. litestar_vite/exceptions.py +78 -0
  23. litestar_vite/executor.py +360 -0
  24. litestar_vite/handler/__init__.py +9 -0
  25. litestar_vite/handler/_app.py +612 -0
  26. litestar_vite/handler/_routing.py +130 -0
  27. litestar_vite/html_transform.py +569 -0
  28. litestar_vite/inertia/__init__.py +77 -0
  29. litestar_vite/inertia/_utils.py +119 -0
  30. litestar_vite/inertia/exception_handler.py +178 -0
  31. litestar_vite/inertia/helpers.py +1571 -0
  32. litestar_vite/inertia/middleware.py +54 -0
  33. litestar_vite/inertia/plugin.py +199 -0
  34. litestar_vite/inertia/precognition.py +274 -0
  35. litestar_vite/inertia/request.py +334 -0
  36. litestar_vite/inertia/response.py +802 -0
  37. litestar_vite/inertia/types.py +335 -0
  38. litestar_vite/loader.py +464 -123
  39. litestar_vite/plugin/__init__.py +687 -0
  40. litestar_vite/plugin/_process.py +185 -0
  41. litestar_vite/plugin/_proxy.py +689 -0
  42. litestar_vite/plugin/_proxy_headers.py +244 -0
  43. litestar_vite/plugin/_static.py +37 -0
  44. litestar_vite/plugin/_utils.py +489 -0
  45. litestar_vite/py.typed +0 -0
  46. litestar_vite/scaffolding/__init__.py +20 -0
  47. litestar_vite/scaffolding/generator.py +270 -0
  48. litestar_vite/scaffolding/templates.py +437 -0
  49. litestar_vite/templates/__init__.py +0 -0
  50. litestar_vite/templates/addons/tailwindcss/tailwind.css.j2 +1 -0
  51. litestar_vite/templates/angular/index.html.j2 +12 -0
  52. litestar_vite/templates/angular/openapi-ts.config.ts.j2 +18 -0
  53. litestar_vite/templates/angular/package.json.j2 +36 -0
  54. litestar_vite/templates/angular/src/app/app.component.css.j2 +3 -0
  55. litestar_vite/templates/angular/src/app/app.component.html.j2 +1 -0
  56. litestar_vite/templates/angular/src/app/app.component.ts.j2 +9 -0
  57. litestar_vite/templates/angular/src/app/app.config.ts.j2 +5 -0
  58. litestar_vite/templates/angular/src/main.ts.j2 +9 -0
  59. litestar_vite/templates/angular/src/styles.css.j2 +9 -0
  60. litestar_vite/templates/angular/tsconfig.app.json.j2 +34 -0
  61. litestar_vite/templates/angular/tsconfig.json.j2 +20 -0
  62. litestar_vite/templates/angular/vite.config.ts.j2 +21 -0
  63. litestar_vite/templates/angular-cli/.postcssrc.json.j2 +5 -0
  64. litestar_vite/templates/angular-cli/angular.json.j2 +36 -0
  65. litestar_vite/templates/angular-cli/openapi-ts.config.ts.j2 +18 -0
  66. litestar_vite/templates/angular-cli/package.json.j2 +28 -0
  67. litestar_vite/templates/angular-cli/proxy.conf.json.j2 +18 -0
  68. litestar_vite/templates/angular-cli/src/app/app.component.css.j2 +3 -0
  69. litestar_vite/templates/angular-cli/src/app/app.component.html.j2 +1 -0
  70. litestar_vite/templates/angular-cli/src/app/app.component.ts.j2 +9 -0
  71. litestar_vite/templates/angular-cli/src/app/app.config.ts.j2 +5 -0
  72. litestar_vite/templates/angular-cli/src/index.html.j2 +13 -0
  73. litestar_vite/templates/angular-cli/src/main.ts.j2 +6 -0
  74. litestar_vite/templates/angular-cli/src/styles.css.j2 +10 -0
  75. litestar_vite/templates/angular-cli/tailwind.config.js.j2 +4 -0
  76. litestar_vite/templates/angular-cli/tsconfig.app.json.j2 +16 -0
  77. litestar_vite/templates/angular-cli/tsconfig.json.j2 +26 -0
  78. litestar_vite/templates/angular-cli/tsconfig.spec.json.j2 +9 -0
  79. litestar_vite/templates/astro/astro.config.mjs.j2 +28 -0
  80. litestar_vite/templates/astro/openapi-ts.config.ts.j2 +15 -0
  81. litestar_vite/templates/astro/src/layouts/Layout.astro.j2 +63 -0
  82. litestar_vite/templates/astro/src/pages/index.astro.j2 +36 -0
  83. litestar_vite/templates/astro/src/styles/global.css.j2 +1 -0
  84. litestar_vite/templates/base/.gitignore.j2 +42 -0
  85. litestar_vite/templates/base/openapi-ts.config.ts.j2 +15 -0
  86. litestar_vite/templates/base/package.json.j2 +39 -0
  87. litestar_vite/templates/base/resources/vite-env.d.ts.j2 +1 -0
  88. litestar_vite/templates/base/tsconfig.json.j2 +37 -0
  89. litestar_vite/templates/htmx/src/main.js.j2 +8 -0
  90. litestar_vite/templates/htmx/templates/base.html.j2.j2 +56 -0
  91. litestar_vite/templates/htmx/templates/index.html.j2.j2 +13 -0
  92. litestar_vite/templates/htmx/vite.config.ts.j2 +40 -0
  93. litestar_vite/templates/nuxt/app.vue.j2 +29 -0
  94. litestar_vite/templates/nuxt/composables/useApi.ts.j2 +33 -0
  95. litestar_vite/templates/nuxt/nuxt.config.ts.j2 +31 -0
  96. litestar_vite/templates/nuxt/openapi-ts.config.ts.j2 +15 -0
  97. litestar_vite/templates/nuxt/pages/index.vue.j2 +54 -0
  98. litestar_vite/templates/react/index.html.j2 +13 -0
  99. litestar_vite/templates/react/src/App.css.j2 +56 -0
  100. litestar_vite/templates/react/src/App.tsx.j2 +19 -0
  101. litestar_vite/templates/react/src/main.tsx.j2 +10 -0
  102. litestar_vite/templates/react/vite.config.ts.j2 +39 -0
  103. litestar_vite/templates/react-inertia/index.html.j2 +14 -0
  104. litestar_vite/templates/react-inertia/package.json.j2 +47 -0
  105. litestar_vite/templates/react-inertia/resources/App.css.j2 +68 -0
  106. litestar_vite/templates/react-inertia/resources/main.tsx.j2 +17 -0
  107. litestar_vite/templates/react-inertia/resources/pages/Home.tsx.j2 +18 -0
  108. litestar_vite/templates/react-inertia/resources/ssr.tsx.j2 +19 -0
  109. litestar_vite/templates/react-inertia/vite.config.ts.j2 +59 -0
  110. litestar_vite/templates/react-router/index.html.j2 +12 -0
  111. litestar_vite/templates/react-router/src/App.css.j2 +17 -0
  112. litestar_vite/templates/react-router/src/App.tsx.j2 +7 -0
  113. litestar_vite/templates/react-router/src/main.tsx.j2 +10 -0
  114. litestar_vite/templates/react-router/vite.config.ts.j2 +39 -0
  115. litestar_vite/templates/react-tanstack/index.html.j2 +12 -0
  116. litestar_vite/templates/react-tanstack/openapi-ts.config.ts.j2 +18 -0
  117. litestar_vite/templates/react-tanstack/src/App.css.j2 +17 -0
  118. litestar_vite/templates/react-tanstack/src/main.tsx.j2 +21 -0
  119. litestar_vite/templates/react-tanstack/src/routeTree.gen.ts.j2 +7 -0
  120. litestar_vite/templates/react-tanstack/src/routes/__root.tsx.j2 +9 -0
  121. litestar_vite/templates/react-tanstack/src/routes/books.tsx.j2 +9 -0
  122. litestar_vite/templates/react-tanstack/src/routes/index.tsx.j2 +9 -0
  123. litestar_vite/templates/react-tanstack/vite.config.ts.j2 +39 -0
  124. litestar_vite/templates/svelte/index.html.j2 +13 -0
  125. litestar_vite/templates/svelte/src/App.svelte.j2 +30 -0
  126. litestar_vite/templates/svelte/src/app.css.j2 +45 -0
  127. litestar_vite/templates/svelte/src/main.ts.j2 +8 -0
  128. litestar_vite/templates/svelte/src/vite-env.d.ts.j2 +2 -0
  129. litestar_vite/templates/svelte/svelte.config.js.j2 +5 -0
  130. litestar_vite/templates/svelte/vite.config.ts.j2 +39 -0
  131. litestar_vite/templates/svelte-inertia/index.html.j2 +14 -0
  132. litestar_vite/templates/svelte-inertia/resources/app.css.j2 +21 -0
  133. litestar_vite/templates/svelte-inertia/resources/main.ts.j2 +11 -0
  134. litestar_vite/templates/svelte-inertia/resources/pages/Home.svelte.j2 +43 -0
  135. litestar_vite/templates/svelte-inertia/resources/vite-env.d.ts.j2 +2 -0
  136. litestar_vite/templates/svelte-inertia/svelte.config.js.j2 +5 -0
  137. litestar_vite/templates/svelte-inertia/vite.config.ts.j2 +37 -0
  138. litestar_vite/templates/sveltekit/openapi-ts.config.ts.j2 +15 -0
  139. litestar_vite/templates/sveltekit/src/app.css.j2 +40 -0
  140. litestar_vite/templates/sveltekit/src/app.html.j2 +12 -0
  141. litestar_vite/templates/sveltekit/src/hooks.server.ts.j2 +55 -0
  142. litestar_vite/templates/sveltekit/src/routes/+layout.svelte.j2 +12 -0
  143. litestar_vite/templates/sveltekit/src/routes/+page.svelte.j2 +34 -0
  144. litestar_vite/templates/sveltekit/svelte.config.js.j2 +12 -0
  145. litestar_vite/templates/sveltekit/tsconfig.json.j2 +14 -0
  146. litestar_vite/templates/sveltekit/vite.config.ts.j2 +31 -0
  147. litestar_vite/templates/vue/env.d.ts.j2 +7 -0
  148. litestar_vite/templates/vue/index.html.j2 +13 -0
  149. litestar_vite/templates/vue/src/App.vue.j2 +28 -0
  150. litestar_vite/templates/vue/src/main.ts.j2 +5 -0
  151. litestar_vite/templates/vue/src/style.css.j2 +45 -0
  152. litestar_vite/templates/vue/vite.config.ts.j2 +39 -0
  153. litestar_vite/templates/vue-inertia/env.d.ts.j2 +7 -0
  154. litestar_vite/templates/vue-inertia/index.html.j2 +14 -0
  155. litestar_vite/templates/vue-inertia/package.json.j2 +50 -0
  156. litestar_vite/templates/vue-inertia/resources/main.ts.j2 +18 -0
  157. litestar_vite/templates/vue-inertia/resources/pages/Home.vue.j2 +22 -0
  158. litestar_vite/templates/vue-inertia/resources/ssr.ts.j2 +21 -0
  159. litestar_vite/templates/vue-inertia/resources/style.css.j2 +21 -0
  160. litestar_vite/templates/vue-inertia/vite.config.ts.j2 +59 -0
  161. litestar_vite-0.15.0.dist-info/METADATA +230 -0
  162. litestar_vite-0.15.0.dist-info/RECORD +164 -0
  163. {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0.dist-info}/WHEEL +1 -1
  164. litestar_vite/config.py +0 -100
  165. litestar_vite/plugin.py +0 -45
  166. litestar_vite/template_engine.py +0 -103
  167. litestar_vite-0.1.1.dist-info/METADATA +0 -68
  168. litestar_vite-0.1.1.dist-info/RECORD +0 -11
  169. {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,720 @@
1
+ """Route metadata extraction and Ziggy-compatible generation."""
2
+
3
+ import contextlib
4
+ import re
5
+ from collections.abc import Generator
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, cast
8
+
9
+ from litestar import Litestar
10
+ from litestar._openapi.datastructures import OpenAPIContext # pyright: ignore[reportPrivateUsage]
11
+ from litestar._openapi.parameters import ( # pyright: ignore[reportPrivateUsage,reportPrivateImportUsage]
12
+ create_parameters_for_handler,
13
+ )
14
+ from litestar.handlers import HTTPRouteHandler
15
+ from litestar.routes import HTTPRoute
16
+
17
+ from litestar_vite.codegen._ts import normalize_path, ts_type_from_openapi
18
+
19
+ _PATH_PARAM_EXTRACT_PATTERN = re.compile(r"\{([^:}]+)(?::([^}]+))?\}")
20
+
21
+ # HTTP methods in priority order for Inertia router integration
22
+ _HTTP_METHOD_PRIORITY = ["GET", "POST", "PUT", "PATCH", "DELETE"]
23
+
24
+
25
+ def pick_primary_method(methods: list[str]) -> str:
26
+ """Pick the primary HTTP method for Inertia router integration.
27
+
28
+ When a route supports multiple HTTP methods, this picks the most
29
+ appropriate one for use with Inertia's router.visit() and form.submit().
30
+
31
+ Args:
32
+ methods: List of HTTP methods (e.g., ["GET", "HEAD", "OPTIONS"]).
33
+
34
+ Returns:
35
+ The primary method in lowercase (e.g., "get", "post").
36
+ """
37
+ for preferred in _HTTP_METHOD_PRIORITY:
38
+ if preferred in methods:
39
+ return preferred.lower()
40
+ # Fallback to first non-HEAD/OPTIONS method, or "get" if none
41
+ for method in methods:
42
+ if method not in {"HEAD", "OPTIONS"}:
43
+ return method.lower()
44
+ return "get"
45
+
46
+
47
+ _TS_SEMANTIC_ALIASES: dict[str, tuple[str, str]] = {
48
+ "UUID": ("UUID v4 string", "string"),
49
+ "DateTime": ("RFC 3339 date-time string", "string"),
50
+ "DateOnly": ("ISO 8601 date string (YYYY-MM-DD)", "string"),
51
+ "TimeOnly": ("ISO 8601 time string", "string"),
52
+ "Duration": ("ISO 8601 duration string", "string"),
53
+ "Email": ("Email address string", "string"),
54
+ "URI": ("URI/URL string", "string"),
55
+ "IPv4": ("IPv4 address string", "string"),
56
+ "IPv6": ("IPv6 address string", "string"),
57
+ }
58
+
59
+
60
+ def str_dict_factory() -> dict[str, str]:
61
+ """Return an empty ``dict[str, str]`` (typed for pyright).
62
+
63
+ Returns:
64
+ An empty dictionary.
65
+ """
66
+ return {}
67
+
68
+
69
+ @dataclass
70
+ class RouteMetadata:
71
+ """Metadata for a single route."""
72
+
73
+ name: str
74
+ path: str
75
+ methods: list[str]
76
+ method: str # Primary method for Inertia router (lowercase)
77
+ params: dict[str, str] = field(default_factory=str_dict_factory)
78
+ query_params: dict[str, str] = field(default_factory=str_dict_factory)
79
+ component: "str | None" = None
80
+
81
+
82
+ def extract_path_params(path: str) -> dict[str, str]:
83
+ """Extract path parameters and their types from a route.
84
+
85
+ Args:
86
+ path: The route path template.
87
+
88
+ Returns:
89
+ Mapping of parameter name to TypeScript type.
90
+ """
91
+ return {match.group(1): "string" for match in _PATH_PARAM_EXTRACT_PATTERN.finditer(path)}
92
+
93
+
94
+ def iter_route_handlers(app: Litestar) -> Generator[tuple["HTTPRoute", HTTPRouteHandler], None, None]:
95
+ """Iterate over HTTP route handlers in an app.
96
+
97
+ Returns handlers in deterministic order, sorted by (route_path, handler_name)
98
+ to ensure consistent output across multiple runs.
99
+
100
+ Args:
101
+ app: The Litestar application.
102
+
103
+ Yields:
104
+ Tuples of (HTTPRoute, HTTPRouteHandler), sorted for determinism.
105
+ """
106
+ handlers: list[tuple[HTTPRoute, HTTPRouteHandler]] = []
107
+ for route in app.routes:
108
+ if isinstance(route, HTTPRoute):
109
+ handlers.extend((route, route_handler) for route_handler in route.route_handlers)
110
+ # Sort by route path, then handler name for deterministic ordering
111
+ handlers.sort(key=lambda x: (str(x[0].path), x[1].handler_name or x[1].name or ""))
112
+ yield from handlers
113
+
114
+
115
+ def extract_params_from_litestar(
116
+ handler: HTTPRouteHandler, http_route: "HTTPRoute", openapi_context: OpenAPIContext | None
117
+ ) -> tuple[dict[str, str], dict[str, str]]:
118
+ """Extract path and query parameters using Litestar's native OpenAPI generation.
119
+
120
+ Args:
121
+ handler: The route handler.
122
+ http_route: The HTTP route.
123
+ openapi_context: The OpenAPI context, if available.
124
+
125
+ Returns:
126
+ A tuple of (path_params, query_params) maps.
127
+ """
128
+ path_params: dict[str, str] = {}
129
+ query_params: dict[str, str] = {}
130
+
131
+ if openapi_context is None:
132
+ return path_params, query_params
133
+
134
+ try:
135
+ route_path_params = http_route.path_parameters
136
+ params = create_parameters_for_handler(openapi_context, handler, route_path_params)
137
+
138
+ for param in params:
139
+ schema_dict = param.schema.to_schema() if param.schema else None
140
+ ts_type = ts_type_from_openapi(schema_dict or {}) if schema_dict else "any"
141
+ # For URL generation, `null` is not a meaningful value (it would stringify to "null").
142
+ # Treat `null` as "missing" rather than emitting `| null` into route parameter types.
143
+ ts_type = ts_type.replace(" | null", "").replace("null | ", "")
144
+
145
+ if not param.required and ts_type != "any" and "undefined" not in ts_type:
146
+ ts_type = f"{ts_type} | undefined"
147
+
148
+ match param.param_in:
149
+ case "path":
150
+ path_params[param.name] = ts_type.replace(" | undefined", "")
151
+ case "query":
152
+ query_params[param.name] = ts_type
153
+ case _:
154
+ pass
155
+
156
+ except (AttributeError, TypeError, ValueError, KeyError):
157
+ pass
158
+
159
+ return path_params, query_params
160
+
161
+
162
+ def make_unique_name(base_name: str, used_names: set[str], path: str, methods: list[str]) -> str:
163
+ """Generate a unique route name, avoiding collisions.
164
+
165
+ Returns:
166
+ A unique route name.
167
+ """
168
+ if base_name not in used_names:
169
+ return base_name
170
+
171
+ path_suffix = path.strip("/").replace("/", "_").replace("{", "").replace("}", "").replace("-", "_")
172
+ method_suffix = methods[0].lower() if methods else ""
173
+
174
+ candidate = f"{base_name}_{path_suffix}" if path_suffix else base_name
175
+ if candidate not in used_names:
176
+ return candidate
177
+
178
+ candidate = f"{base_name}_{path_suffix}_{method_suffix}" if path_suffix else f"{base_name}_{method_suffix}"
179
+ if candidate not in used_names:
180
+ return candidate
181
+
182
+ counter = 2
183
+ while f"{candidate}_{counter}" in used_names:
184
+ counter += 1
185
+ return f"{candidate}_{counter}"
186
+
187
+
188
+ def extract_route_metadata(
189
+ app: Litestar,
190
+ *,
191
+ only: "list[str] | None" = None,
192
+ exclude: "list[str] | None" = None,
193
+ openapi_schema: dict[str, Any] | None = None,
194
+ ) -> list[RouteMetadata]:
195
+ """Extract route metadata from a Litestar application.
196
+
197
+ Note:
198
+ ``openapi_schema`` is accepted for API compatibility and future enrichment,
199
+ but parameter typing is currently derived from Litestar's OpenAPI parameter
200
+ generation, not the exported schema document.
201
+
202
+ Returns:
203
+ A list of RouteMetadata objects.
204
+ """
205
+ routes_metadata: list[RouteMetadata] = []
206
+ used_names: set[str] = set()
207
+
208
+ openapi_context: OpenAPIContext | None = None
209
+ if app.openapi_config is not None:
210
+ with contextlib.suppress(AttributeError, TypeError, ValueError):
211
+ openapi_context = OpenAPIContext(openapi_config=app.openapi_config, plugins=app.plugins.openapi)
212
+
213
+ for http_route, route_handler in iter_route_handlers(app):
214
+ base_name = route_handler.name or route_handler.handler_name or str(route_handler)
215
+ methods = [method.upper() for method in route_handler.http_methods]
216
+
217
+ if methods in (["OPTIONS"], ["HEAD"]):
218
+ continue
219
+
220
+ full_path = str(http_route.path)
221
+
222
+ if full_path.startswith("/schema"):
223
+ if "openapi.json" in full_path:
224
+ if "openapi.json" in used_names:
225
+ continue
226
+ base_name = "openapi.json"
227
+ elif "openapi.yaml" in full_path or "openapi.yml" in full_path:
228
+ if "openapi.yaml" in used_names:
229
+ continue
230
+ base_name = "openapi.yaml"
231
+ else:
232
+ continue
233
+
234
+ route_name = make_unique_name(base_name, used_names, full_path, methods)
235
+ used_names.add(route_name)
236
+
237
+ if only and not any(pattern in route_name or pattern in full_path for pattern in only):
238
+ continue
239
+ if exclude and any(pattern in route_name or pattern in full_path for pattern in exclude):
240
+ continue
241
+
242
+ params, query_params = extract_params_from_litestar(route_handler, http_route, openapi_context)
243
+
244
+ if not params:
245
+ params = extract_path_params(full_path)
246
+
247
+ normalized_path = normalize_path(full_path)
248
+
249
+ opt: dict[str, Any] = route_handler.opt or {}
250
+ component = opt.get("component")
251
+
252
+ routes_metadata.append(
253
+ RouteMetadata(
254
+ name=route_name,
255
+ path=normalized_path,
256
+ methods=methods,
257
+ method=pick_primary_method(methods),
258
+ params=params,
259
+ query_params=query_params,
260
+ component=cast("str | None", component),
261
+ )
262
+ )
263
+
264
+ return routes_metadata
265
+
266
+
267
+ def generate_routes_json(
268
+ app: Litestar,
269
+ *,
270
+ only: "list[str] | None" = None,
271
+ exclude: "list[str] | None" = None,
272
+ include_components: bool = False,
273
+ openapi_schema: dict[str, Any] | None = None,
274
+ ) -> dict[str, Any]:
275
+ """Generate Ziggy-compatible routes JSON.
276
+
277
+ The output is deterministic: routes are sorted by name to produce
278
+ byte-identical output for the same input data.
279
+
280
+ Returns:
281
+ A Ziggy-compatible routes payload as a dictionary with sorted keys.
282
+ """
283
+ routes_metadata = extract_route_metadata(app, only=only, exclude=exclude, openapi_schema=openapi_schema)
284
+
285
+ # Sort routes by name for deterministic output
286
+ sorted_routes = sorted(routes_metadata, key=lambda r: r.name)
287
+
288
+ routes_dict: dict[str, Any] = {}
289
+
290
+ for route in sorted_routes:
291
+ route_data: dict[str, Any] = {"uri": route.path, "methods": route.methods, "method": route.method}
292
+
293
+ if route.params:
294
+ # Sort params dict for deterministic output
295
+ sorted_params = dict(sorted(route.params.items()))
296
+ route_data["parameters"] = list(sorted_params.keys())
297
+ route_data["parameterTypes"] = sorted_params
298
+
299
+ if route.query_params:
300
+ # Sort query params for deterministic output
301
+ route_data["queryParameters"] = dict(sorted(route.query_params.items()))
302
+
303
+ if include_components and route.component:
304
+ route_data["component"] = route.component
305
+
306
+ routes_dict[route.name] = route_data
307
+
308
+ return {"routes": routes_dict}
309
+
310
+
311
+ _TS_TYPE_MAP: dict[str, str] = {
312
+ "string": "string",
313
+ "integer": "number",
314
+ "number": "number",
315
+ "boolean": "boolean",
316
+ "array": "unknown[]",
317
+ "object": "Record<string, unknown>",
318
+ "uuid": "string",
319
+ "date": "string",
320
+ "date-time": "string",
321
+ "email": "string",
322
+ "uri": "string",
323
+ "url": "string",
324
+ "int": "number",
325
+ "float": "number",
326
+ "str": "string",
327
+ "bool": "boolean",
328
+ "path": "string",
329
+ "unknown": "unknown",
330
+ }
331
+
332
+
333
+ def ts_type_for_param(param_type: str) -> str:
334
+ """Map a parameter type string to TypeScript type.
335
+
336
+ Returns:
337
+ The TypeScript type for the parameter.
338
+ """
339
+ is_optional = "undefined" in param_type or param_type.endswith("?")
340
+ clean_type = param_type.replace(" | undefined", "").replace("?", "").strip()
341
+
342
+ ts_type = _TS_TYPE_MAP.get(clean_type) or clean_type or "string"
343
+
344
+ if is_optional and "undefined" not in ts_type:
345
+ return f"{ts_type} | undefined"
346
+ return ts_type
347
+
348
+
349
+ def is_type_required(param_type: str) -> bool:
350
+ """Check if a parameter type indicates a required field.
351
+
352
+ Returns:
353
+ True if the parameter is required, otherwise False.
354
+ """
355
+ return "undefined" not in param_type and not param_type.endswith("?")
356
+
357
+
358
+ def escape_ts_string(s: str) -> str:
359
+ """Escape a string for use in TypeScript string literals.
360
+
361
+ Returns:
362
+ The escaped string.
363
+ """
364
+ return s.replace("\\", "\\\\").replace("'", "\\'").replace('"', '\\"')
365
+
366
+
367
+ def generate_routes_ts(
368
+ app: Litestar,
369
+ *,
370
+ only: "list[str] | None" = None,
371
+ exclude: "list[str] | None" = None,
372
+ openapi_schema: dict[str, Any] | None = None,
373
+ global_route: bool = False,
374
+ ) -> str:
375
+ """Generate typed routes TypeScript file (Ziggy-style).
376
+
377
+ The output is deterministic: routes are sorted by name to produce
378
+ byte-identical output for the same input data.
379
+
380
+ Returns:
381
+ The generated TypeScript source.
382
+ """
383
+ routes_metadata = extract_route_metadata(app, only=only, exclude=exclude, openapi_schema=openapi_schema)
384
+
385
+ # Sort routes by name for deterministic output
386
+ sorted_routes = sorted(routes_metadata, key=lambda r: r.name)
387
+
388
+ route_names: list[str] = []
389
+ path_params_entries: list[str] = []
390
+ query_params_entries: list[str] = []
391
+ routes_entries: list[str] = []
392
+ used_aliases: set[str] = set()
393
+
394
+ for route in sorted_routes:
395
+ route_name = route.name
396
+ route_names.append(route_name)
397
+
398
+ # Sort params for deterministic output
399
+ sorted_params = dict(sorted(route.params.items())) if route.params else {}
400
+ sorted_query_params = dict(sorted(route.query_params.items())) if route.query_params else {}
401
+
402
+ if sorted_params:
403
+ param_fields: list[str] = []
404
+ for param_name, param_type in sorted_params.items():
405
+ ts_type = ts_type_for_param(param_type)
406
+ ts_type_clean = ts_type.replace(" | undefined", "")
407
+ used_aliases.update(collect_semantic_aliases(ts_type_clean))
408
+ param_fields.append(f" {param_name}: {ts_type_clean};")
409
+ path_params_entries.append(f" '{route_name}': {{\n" + "\n".join(param_fields) + "\n };")
410
+ else:
411
+ path_params_entries.append(f" '{route_name}': Record<string, never>;")
412
+
413
+ if sorted_query_params:
414
+ query_param_fields: list[str] = []
415
+ for param_name, param_type in sorted_query_params.items():
416
+ ts_type = ts_type_for_param(param_type)
417
+ is_required = is_type_required(param_type)
418
+ ts_type_clean = ts_type.replace(" | undefined", "")
419
+ used_aliases.update(collect_semantic_aliases(ts_type_clean))
420
+ if is_required:
421
+ query_param_fields.append(f" {param_name}: {ts_type_clean};")
422
+ else:
423
+ query_param_fields.append(f" {param_name}?: {ts_type_clean};")
424
+ query_params_entries.append(f" '{route_name}': {{\n" + "\n".join(query_param_fields) + "\n };")
425
+ else:
426
+ query_params_entries.append(f" '{route_name}': Record<string, never>;")
427
+
428
+ methods_str = ", ".join(f"'{m}'" for m in sorted(route.methods))
429
+ route_entry_lines = [
430
+ f" '{route_name}': {{",
431
+ f" path: '{escape_ts_string(route.path)}',",
432
+ f" methods: [{methods_str}] as const,",
433
+ f" method: '{route.method}',",
434
+ ]
435
+ param_names_str = ", ".join(f"'{p}'" for p in sorted_params) if sorted_params else ""
436
+ route_entry_lines.append(f" pathParams: [{param_names_str}] as const,")
437
+
438
+ query_names_str = ", ".join(f"'{p}'" for p in sorted_query_params) if sorted_query_params else ""
439
+ route_entry_lines.append(f" queryParams: [{query_names_str}] as const,")
440
+ if route.component:
441
+ route_entry_lines.append(f" component: '{escape_ts_string(route.component)}',")
442
+ route_entry_lines.append(" },")
443
+ routes_entries.append("\n".join(route_entry_lines))
444
+
445
+ route_names_union = "\n | ".join(f"'{name}'" for name in route_names) if route_names else "never"
446
+
447
+ alias_block = render_semantic_aliases(used_aliases)
448
+ alias_preamble = f"{alias_block}\n\n" if alias_block else ""
449
+
450
+ global_route_snippet = ""
451
+ if global_route:
452
+ global_route_snippet = (
453
+ "\n\n// Optionally register route() on window for global access\n"
454
+ "if (typeof window !== 'undefined') {\n"
455
+ " (window as any).route = route;\n"
456
+ "}\n"
457
+ )
458
+
459
+ return f"""// AUTO-GENERATED by litestar-vite. Do not edit.
460
+ /* eslint-disable */
461
+
462
+ // API base URL - only needed for separate dev servers
463
+ // Set VITE_API_URL=http://localhost:8000 when running Vite separately
464
+ const API_URL = (typeof import.meta !== 'undefined' && (import.meta as any).env?.VITE_API_URL) ?? '';
465
+
466
+ {alias_preamble}
467
+ /** All available route names */
468
+ export type RouteName =
469
+ | {route_names_union};
470
+
471
+ /** Path parameter definitions per route */
472
+ export interface RoutePathParams {{
473
+ {chr(10).join(path_params_entries)}
474
+ }}
475
+
476
+ /** Query parameter definitions per route */
477
+ export interface RouteQueryParams {{
478
+ {chr(10).join(query_params_entries)}
479
+ }}
480
+
481
+ type EmptyParams = Record<string, never>
482
+ type MergeParams<A, B> =
483
+ A extends EmptyParams ? (B extends EmptyParams ? EmptyParams : B) : B extends EmptyParams ? A : A & B
484
+
485
+ /** Combined parameters (path + query) */
486
+ export type RouteParams<T extends RouteName> = MergeParams<RoutePathParams[T], RouteQueryParams[T]>
487
+
488
+ /** Route metadata */
489
+ export const routeDefinitions = {{
490
+ {chr(10).join(routes_entries)}
491
+ }} as const
492
+
493
+ /** Check if path params are required for a route */
494
+ type HasRequiredPathParams<T extends RouteName> =
495
+ RoutePathParams[T] extends Record<string, never> ? false : true;
496
+
497
+ /** Check if query params have any required fields */
498
+ type HasRequiredQueryParams<T extends RouteName> =
499
+ RouteQueryParams[T] extends Record<string, never>
500
+ ? false
501
+ : Partial<RouteQueryParams[T]> extends RouteQueryParams[T]
502
+ ? false
503
+ : true;
504
+
505
+ /** Routes that require parameters (path or query) */
506
+ type RoutesWithRequiredParams = {{
507
+ [K in RouteName]: HasRequiredPathParams<K> extends true
508
+ ? K
509
+ : HasRequiredQueryParams<K> extends true
510
+ ? K
511
+ : never;
512
+ }}[RouteName];
513
+
514
+ /** Routes without any required parameters */
515
+ type RoutesWithoutRequiredParams = Exclude<RouteName, RoutesWithRequiredParams>;
516
+
517
+ /**
518
+ * Generate a URL for a named route.
519
+ *
520
+ * @example
521
+ * route('books') // '/api/books'
522
+ * route('book_detail', {{ book_id: 123 }}) // '/api/books/123'
523
+ * route('search', {{ q: 'test', limit: 5 }}) // '/api/search?q=test&limit=5'
524
+ *
525
+ * // Access HTTP method from route definition when needed:
526
+ * routeDefinitions.login.method // 'post'
527
+ */
528
+ export function route<T extends RoutesWithoutRequiredParams>(name: T): string;
529
+ export function route<T extends RoutesWithoutRequiredParams>(
530
+ name: T,
531
+ params?: RouteParams<T>,
532
+ ): string;
533
+ export function route<T extends RoutesWithRequiredParams>(
534
+ name: T,
535
+ params: RouteParams<T>,
536
+ ): string;
537
+ export function route<T extends RouteName>(
538
+ name: T,
539
+ params?: RouteParams<T>,
540
+ ): string {{
541
+ const def = routeDefinitions[name];
542
+ let url: string = def.path;
543
+
544
+ // Replace path parameters (use replaceAll to handle multiple occurrences)
545
+ if (params) {{
546
+ for (const param of def.pathParams) {{
547
+ const value = (params as Record<string, unknown>)[param];
548
+ if (value !== undefined) {{
549
+ url = url.replaceAll("{{" + param + "}}", String(value));
550
+ }}
551
+ }}
552
+ }}
553
+
554
+ // Add query parameters
555
+ if (params) {{
556
+ const queryParts: string[] = [];
557
+ for (const param of def.queryParams) {{
558
+ const value = (params as Record<string, unknown>)[param];
559
+ if (value !== undefined) {{
560
+ queryParts.push(encodeURIComponent(param) + "=" + encodeURIComponent(String(value)));
561
+ }}
562
+ }}
563
+ if (queryParts.length > 0) {{
564
+ url += "?" + queryParts.join("&");
565
+ }}
566
+ }}
567
+
568
+ // Apply API URL if set (for separate dev servers)
569
+ return API_URL ? API_URL.replace(/\\/$/, '') + url : url;
570
+ }}
571
+
572
+ /** Check if a route exists */
573
+ export function hasRoute(name: string): name is RouteName {{
574
+ return name in routeDefinitions;
575
+ }}
576
+
577
+ /** Get all route names */
578
+ export function getRouteNames(): RouteName[] {{
579
+ return Object.keys(routeDefinitions) as RouteName[];
580
+ }}
581
+
582
+ /** Get route metadata */
583
+ export function getRoute<T extends RouteName>(name: T): (typeof routeDefinitions)[T] {{
584
+ return routeDefinitions[name];
585
+ }}
586
+
587
+ // ============================================================================
588
+ // Route Matching Helpers
589
+ // ============================================================================
590
+
591
+ /** Cache for compiled route patterns */
592
+ const patternCache = new Map<string, RegExp>();
593
+
594
+ /**
595
+ * Compile a route path pattern to a regex for URL matching.
596
+ * Results are cached for performance.
597
+ */
598
+ function compilePattern(path: string): RegExp {{
599
+ const cached = patternCache.get(path);
600
+ if (cached) return cached;
601
+
602
+ // Escape special regex characters except {{ }}
603
+ let pattern = path.replace(/[.*+?^$|()\\[\\]]/g, '\\\\$&');
604
+ // Replace {{param}} or {{param:type}} with matchers
605
+ pattern = pattern.replace(/\\{{([^}}]+)\\}}/g, (_match, paramSpec: string) => {{
606
+ const paramType = paramSpec.includes(':') ? paramSpec.split(':')[1] : 'str';
607
+ switch (paramType) {{
608
+ case 'uuid':
609
+ return '[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}';
610
+ case 'path':
611
+ return '.*';
612
+ case 'int':
613
+ return '\\\\d+';
614
+ default:
615
+ return '[^/]+';
616
+ }}
617
+ }});
618
+ const regex = new RegExp(`^${{pattern}}$`, 'i');
619
+ patternCache.set(path, regex);
620
+ return regex;
621
+ }}
622
+
623
+ /**
624
+ * Convert a URL to its corresponding route name.
625
+ *
626
+ * @param url - URL or path to match (query strings and hashes are stripped)
627
+ * @returns The matching route name, or null if no match found
628
+ *
629
+ * @example
630
+ * toRoute('/api/books') // 'books'
631
+ * toRoute('/api/books/123') // 'book_detail'
632
+ * toRoute('/unknown') // null
633
+ */
634
+ export function toRoute(url: string): RouteName | null {{
635
+ // Strip query string and hash
636
+ const path = url.split('?')[0].split('#')[0];
637
+ // Normalize: remove trailing slash except for root
638
+ const normalized = path === '/' ? path : path.replace(/\\/$/, '');
639
+
640
+ for (const [name, def] of Object.entries(routeDefinitions)) {{
641
+ if (compilePattern(def.path).test(normalized)) {{
642
+ return name as RouteName;
643
+ }}
644
+ }}
645
+ return null;
646
+ }}
647
+
648
+ /**
649
+ * Get the current route name based on the browser URL.
650
+ * Returns null in SSR/non-browser environments.
651
+ *
652
+ * @returns Current route name, or null if no match or not in browser
653
+ *
654
+ * @example
655
+ * // On page /api/books/123
656
+ * currentRoute() // 'book_detail'
657
+ */
658
+ export function currentRoute(): RouteName | null {{
659
+ if (typeof window === 'undefined') return null;
660
+ return toRoute(window.location.pathname);
661
+ }}
662
+
663
+ /**
664
+ * Check if a URL matches a route name or pattern.
665
+ * Supports wildcard patterns with `*` to match multiple routes.
666
+ *
667
+ * @param url - URL or path to check
668
+ * @param pattern - Route name or pattern (e.g., 'books', 'book_*', '*_detail')
669
+ * @returns True if the URL matches the route pattern
670
+ *
671
+ * @example
672
+ * isRoute('/api/books', 'books') // true
673
+ * isRoute('/api/books/123', 'book_*') // true (wildcard)
674
+ */
675
+ export function isRoute(url: string, pattern: string): boolean {{
676
+ const routeName = toRoute(url);
677
+ if (!routeName) return false;
678
+ // Escape special regex chars (except *), then convert * to .*
679
+ const escaped = pattern.replace(/[.+?^$|()\\[\\]{{}}]/g, '\\\\$&');
680
+ const regex = new RegExp(`^${{escaped.replace(/\\*/g, '.*')}}$`);
681
+ return regex.test(routeName);
682
+ }}
683
+
684
+ /**
685
+ * Check if the current browser URL matches a route name or pattern.
686
+ * Supports wildcard patterns with `*` to match multiple routes.
687
+ * Returns false in SSR/non-browser environments.
688
+ *
689
+ * @param pattern - Route name or pattern (e.g., 'books', 'book_*', '*_page')
690
+ * @returns True if current URL matches the route pattern
691
+ *
692
+ * @example
693
+ * // On page /books
694
+ * isCurrentRoute('books_page') // true
695
+ * isCurrentRoute('*_page') // true (wildcard)
696
+ */
697
+ export function isCurrentRoute(pattern: string): boolean {{
698
+ const current = currentRoute();
699
+ if (!current) return false;
700
+ // Escape special regex chars (except *), then convert * to .*
701
+ const escaped = pattern.replace(/[.+?^$|()\\[\\]{{}}]/g, '\\\\$&');
702
+ const regex = new RegExp(`^${{escaped.replace(/\\*/g, '.*')}}$`);
703
+ return regex.test(current);
704
+ }}
705
+ {global_route_snippet}"""
706
+
707
+
708
+ def collect_semantic_aliases(type_expr: str) -> set[str]:
709
+ return {alias for alias in _TS_SEMANTIC_ALIASES if alias in type_expr}
710
+
711
+
712
+ def render_semantic_aliases(aliases: set[str]) -> str:
713
+ if not aliases:
714
+ return ""
715
+
716
+ lines: list[str] = ["/** Semantic string aliases derived from OpenAPI `format`. */"]
717
+ for alias in sorted(aliases):
718
+ doc, base = _TS_SEMANTIC_ALIASES[alias]
719
+ lines.extend((f"/** {doc} */", f"export type {alias} = {base};", ""))
720
+ return "\n".join(lines).rstrip()