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,619 @@
1
+ """Inertia page-props metadata extraction and export."""
2
+
3
+ import re
4
+ from contextlib import suppress
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any, cast
8
+
9
+ from litestar._openapi.datastructures import _get_normalized_schema_key # pyright: ignore[reportPrivateUsage]
10
+ from litestar.handlers import HTTPRouteHandler
11
+ from litestar.openapi.spec import Reference, Schema
12
+ from litestar.response.base import ASGIResponse
13
+ from litestar.routes import HTTPRoute
14
+ from litestar.types.builtin_types import NoneType
15
+ from litestar.typing import FieldDefinition
16
+
17
+ from litestar_vite.codegen._openapi import (
18
+ OpenAPISupport,
19
+ build_schema_name_map,
20
+ merge_generated_components_into_openapi,
21
+ openapi_components_schemas,
22
+ resolve_page_props_field_definition,
23
+ schema_name_from_ref,
24
+ )
25
+ from litestar_vite.codegen._ts import collect_ref_names, normalize_path, python_type_to_typescript, ts_type_from_openapi
26
+
27
+ if TYPE_CHECKING:
28
+ from litestar import Litestar
29
+
30
+ from litestar_vite.config import InertiaConfig, TypeGenConfig
31
+
32
+ # Compiled regex for splitting TypeScript type strings on union/intersection operators
33
+ _TYPE_OPERATOR_RE = re.compile(r"(\s*[|&]\s*)")
34
+
35
+
36
+ def str_list_factory() -> list[str]:
37
+ """Return an empty ``list[str]`` (typed for pyright).
38
+
39
+ Returns:
40
+ An empty list.
41
+ """
42
+ return []
43
+
44
+
45
+ def _pick_inertia_method(http_methods: "set[Any] | frozenset[Any] | None") -> str:
46
+ """Pick a deterministic HTTP method for Inertia page props inference.
47
+
48
+ Inertia pages are typically loaded via GET requests, so we prefer GET.
49
+ For determinism, we sort remaining methods alphabetically.
50
+
51
+ Args:
52
+ http_methods: Set of HTTP methods from the route handler.
53
+
54
+ Returns:
55
+ The selected HTTP method string.
56
+ """
57
+ if not http_methods:
58
+ return "GET"
59
+ # Prefer GET for Inertia page loads
60
+ if "GET" in http_methods:
61
+ return "GET"
62
+ # Fallback to alphabetically first method for determinism
63
+ return sorted(http_methods)[0]
64
+
65
+
66
+ def normalize_type_name(type_name: str, openapi_schemas: set[str]) -> str:
67
+ """Strip module prefix from mangled type names.
68
+
69
+ Always converts 'app_lib_schema_NoProps' -> 'NoProps' because:
70
+ 1. If 'NoProps' exists in OpenAPI, it will be imported correctly
71
+ 2. If 'NoProps' doesn't exist, the error message is clearer for users
72
+ (they can add it to OpenAPI or configure type_import_paths)
73
+
74
+ The mangled name 'app_lib_schema_NoProps' will NEVER work - it doesn't
75
+ exist anywhere. The short name is always preferable.
76
+
77
+ Args:
78
+ type_name: The potentially mangled type name.
79
+ openapi_schemas: Set of available OpenAPI schema names.
80
+
81
+ Returns:
82
+ The normalized (unmangled) type name.
83
+ """
84
+ if type_name in openapi_schemas:
85
+ return type_name
86
+
87
+ # Check if this looks like a mangled module path (contains underscores)
88
+ if "_" not in type_name:
89
+ return type_name
90
+
91
+ # Try progressively shorter suffixes to find the class name
92
+ parts = type_name.split("_")
93
+ for i in range(len(parts)):
94
+ short_name = "_".join(parts[i:])
95
+ # Prefer OpenAPI match, but if we get to the last part, use it anyway
96
+ if short_name in openapi_schemas:
97
+ return short_name
98
+
99
+ # Use the last part as the class name (e.g., 'NoProps' from 'app_lib_schema_NoProps')
100
+ # This is always better than the mangled name for error messages
101
+ return parts[-1] if parts else type_name
102
+
103
+
104
+ def normalize_type_string(type_string: str, openapi_schemas: set[str]) -> str:
105
+ """Normalize all type names within a TypeScript type string.
106
+
107
+ Handles union types like 'any | app_lib_schema_NoProps' by parsing the
108
+ string and normalizing each type name individually.
109
+
110
+ Args:
111
+ type_string: A TypeScript type string (may contain unions, intersections).
112
+ openapi_schemas: Set of available OpenAPI schema names.
113
+
114
+ Returns:
115
+ The type string with all type names normalized.
116
+ """
117
+ # Primitives and special types that should not be normalized
118
+ skip_types = {"any", "unknown", "null", "undefined", "void", "never", "string", "number", "boolean", "object"}
119
+
120
+ # Split on | and & while preserving whitespace
121
+ tokens = _TYPE_OPERATOR_RE.split(type_string)
122
+ result_parts: list[str] = []
123
+
124
+ for token in tokens:
125
+ stripped = token.strip()
126
+ # Keep operators and whitespace as-is
127
+ if stripped in {"|", "&", ""} or stripped in skip_types or stripped == "{}":
128
+ result_parts.append(token)
129
+ # Normalize type names
130
+ else:
131
+ normalized = normalize_type_name(stripped, openapi_schemas)
132
+ # Preserve original whitespace around the type
133
+ prefix = token[: len(token) - len(token.lstrip())]
134
+ suffix = token[len(token.rstrip()) :]
135
+ result_parts.append(prefix + normalized + suffix)
136
+
137
+ return "".join(result_parts)
138
+
139
+
140
+ @dataclass
141
+ class InertiaPageMetadata:
142
+ """Metadata for a single Inertia page component."""
143
+
144
+ component: str
145
+ route_path: str
146
+ props_type: str | None = None
147
+ schema_ref: str | None = None
148
+ handler_name: str | None = None
149
+ ts_type: str | None = None
150
+ custom_types: list[str] = field(default_factory=str_list_factory)
151
+
152
+
153
+ def get_return_type_name(handler: HTTPRouteHandler) -> "str | None":
154
+ field_definition = handler.parsed_fn_signature.return_type
155
+ excluded_types: tuple[type[Any], ...] = (NoneType, ASGIResponse)
156
+ if field_definition.is_subclass_of(excluded_types):
157
+ return None
158
+
159
+ fn = handler.fn
160
+ with suppress(AttributeError):
161
+ return_annotation = fn.__annotations__.get("return")
162
+ if isinstance(return_annotation, str) and return_annotation:
163
+ return return_annotation
164
+
165
+ raw = field_definition.raw
166
+ if isinstance(raw, str):
167
+ return raw
168
+ if isinstance(raw, type):
169
+ return raw.__name__
170
+ origin: Any = None
171
+ with suppress(AttributeError):
172
+ origin = field_definition.origin
173
+ if isinstance(origin, type):
174
+ return origin.__name__
175
+ return str(raw)
176
+
177
+
178
+ def get_openapi_schema_ref(
179
+ handler: HTTPRouteHandler, openapi_schema: dict[str, Any] | None, route_path: str, method: str = "GET"
180
+ ) -> "str | None":
181
+ if not openapi_schema:
182
+ return None
183
+
184
+ paths = openapi_schema.get("paths", {})
185
+ path_item = paths.get(route_path, {})
186
+ operation = path_item.get(method.lower(), {})
187
+
188
+ responses = operation.get("responses", {})
189
+ success_response = responses.get("200", responses.get("2XX", {}))
190
+ content = success_response.get("content", {})
191
+
192
+ json_content = content.get("application/json", {})
193
+ schema = json_content.get("schema", {})
194
+
195
+ ref = schema.get("$ref")
196
+ return cast("str | None", ref) if ref else None
197
+
198
+
199
+ def extract_inertia_component(handler: HTTPRouteHandler) -> str | None:
200
+ opt = handler.opt or {}
201
+ component = opt.get("component") or opt.get("page")
202
+ return component if isinstance(component, str) and component else None
203
+
204
+
205
+ def infer_inertia_props_type(
206
+ component: str,
207
+ handler: HTTPRouteHandler,
208
+ schema_creator: Any,
209
+ page_schema_keys: dict[str, tuple[str, ...]],
210
+ page_schema_dicts: dict[str, dict[str, Any]],
211
+ *,
212
+ fallback_type: str,
213
+ ) -> str | None:
214
+ if schema_creator is not None:
215
+ field_def, schema_result = resolve_page_props_field_definition(handler, schema_creator)
216
+ if field_def is not None and isinstance(schema_result, Reference):
217
+ page_schema_keys[component] = _get_normalized_schema_key(field_def)
218
+ return None
219
+ if isinstance(schema_result, Schema):
220
+ schema_dict = schema_result.to_schema()
221
+ page_schema_dicts[component] = schema_dict
222
+ return ts_type_from_openapi(schema_dict)
223
+ return None
224
+
225
+ raw_type = get_return_type_name(handler)
226
+ if not raw_type:
227
+ return None
228
+ props_type, _ = python_type_to_typescript(raw_type, fallback=fallback_type)
229
+ return props_type
230
+
231
+
232
+ def finalize_inertia_pages(
233
+ pages: list[InertiaPageMetadata],
234
+ *,
235
+ openapi_support: OpenAPISupport,
236
+ page_schema_keys: dict[str, tuple[str, ...]],
237
+ page_schema_dicts: dict[str, dict[str, Any]],
238
+ ) -> None:
239
+ context = openapi_support.context
240
+ if context is None:
241
+ return
242
+
243
+ generated_components = context.schema_registry.generate_components_schemas()
244
+ name_map = build_schema_name_map(context.schema_registry)
245
+ openapi_components = openapi_components_schemas(openapi_support.openapi_schema)
246
+
247
+ # Build set of available OpenAPI schema names for type normalization
248
+ openapi_schema_names: set[str] = set(openapi_components.keys())
249
+ openapi_schema_names.update(generated_components.keys())
250
+
251
+ if openapi_support.openapi_schema is not None:
252
+ merge_generated_components_into_openapi(openapi_support.openapi_schema, generated_components)
253
+
254
+ for page in pages:
255
+ schema_key = page_schema_keys.get(page.component)
256
+
257
+ schema_name: str | None = None
258
+ if page.schema_ref:
259
+ schema_name = schema_name_from_ref(page.schema_ref)
260
+ elif schema_key:
261
+ schema_name = name_map.get(schema_key)
262
+
263
+ if schema_name:
264
+ # Normalize mangled type names (e.g., 'app_lib_schema_NoProps' -> 'NoProps')
265
+ normalized_name = normalize_type_name(schema_name, openapi_schema_names)
266
+ page.ts_type = normalized_name
267
+ page.props_type = normalized_name
268
+ elif page.props_type:
269
+ # Normalize type names in union/intersection type strings
270
+ # (e.g., 'any | app_lib_schema_NoProps' -> 'any | NoProps')
271
+ page.props_type = normalize_type_string(page.props_type, openapi_schema_names)
272
+
273
+ custom_types: set[str] = set()
274
+ if page.ts_type:
275
+ custom_types.add(page.ts_type)
276
+
277
+ if page.schema_ref:
278
+ openapi_schema_dict = openapi_components.get(page.ts_type or "")
279
+ if isinstance(openapi_schema_dict, dict):
280
+ custom_types.update(collect_ref_names(openapi_schema_dict))
281
+ else:
282
+ page_schema_dict = page_schema_dicts.get(page.component)
283
+ if isinstance(page_schema_dict, dict):
284
+ custom_types.update(collect_ref_names(page_schema_dict))
285
+ elif schema_key:
286
+ registered = context.schema_registry._schema_key_map.get( # pyright: ignore[reportPrivateUsage]
287
+ schema_key
288
+ )
289
+ if registered:
290
+ custom_types.update(collect_ref_names(registered.schema.to_schema()))
291
+
292
+ # Normalize all custom type names
293
+ page.custom_types = sorted(normalize_type_name(t, openapi_schema_names) for t in custom_types)
294
+
295
+
296
+ def extract_inertia_pages(
297
+ app: "Litestar",
298
+ *,
299
+ openapi_schema: dict[str, Any] | None = None,
300
+ fallback_type: "str" = "unknown",
301
+ openapi_support: OpenAPISupport | None = None,
302
+ ) -> list[InertiaPageMetadata]:
303
+ """Extract Inertia page metadata from an application.
304
+
305
+ When multiple handlers map to the same component, GET handlers are preferred
306
+ since Inertia pages are typically loaded via GET requests.
307
+
308
+ Args:
309
+ app: Litestar application instance.
310
+ openapi_schema: Optional OpenAPI schema dict.
311
+ fallback_type: TypeScript fallback type for unknown types.
312
+ openapi_support: Optional shared OpenAPISupport instance. If not provided,
313
+ a new one will be created. Sharing improves determinism and performance.
314
+
315
+ Returns:
316
+ List of InertiaPageMetadata for each discovered page.
317
+ """
318
+ # Track seen components: component -> (metadata, is_get_handler)
319
+ # When multiple handlers map to the same component, prefer GET handlers
320
+ seen_components: dict[str, tuple[InertiaPageMetadata, bool]] = {}
321
+
322
+ if openapi_support is None:
323
+ openapi_support = OpenAPISupport.from_app(app, openapi_schema)
324
+
325
+ page_schema_keys: dict[str, tuple[str, ...]] = {}
326
+ page_schema_dicts: dict[str, dict[str, Any]] = {}
327
+
328
+ for http_route, route_handler in iter_route_handlers(app):
329
+ component = extract_inertia_component(route_handler)
330
+ if not component:
331
+ continue
332
+
333
+ normalized_path = normalize_path(str(http_route.path))
334
+ handler_name = route_handler.handler_name or route_handler.name
335
+ is_get_handler = "GET" in (route_handler.http_methods or set())
336
+
337
+ props_type = infer_inertia_props_type(
338
+ component,
339
+ route_handler,
340
+ openapi_support.schema_creator,
341
+ page_schema_keys,
342
+ page_schema_dicts,
343
+ fallback_type=fallback_type,
344
+ )
345
+
346
+ method = _pick_inertia_method(route_handler.http_methods)
347
+ schema_ref = get_openapi_schema_ref(route_handler, openapi_schema, normalized_path, method=str(method))
348
+
349
+ page_metadata = InertiaPageMetadata(
350
+ component=component,
351
+ route_path=normalized_path,
352
+ props_type=props_type,
353
+ schema_ref=schema_ref,
354
+ handler_name=handler_name,
355
+ )
356
+
357
+ # Prefer GET handlers when multiple handlers map to the same component
358
+ existing = seen_components.get(component)
359
+ if existing is None:
360
+ seen_components[component] = (page_metadata, is_get_handler)
361
+ elif is_get_handler and not existing[1]:
362
+ # New handler is GET, existing is not - prefer the GET handler
363
+ seen_components[component] = (page_metadata, is_get_handler)
364
+ # Otherwise keep existing (it's either GET or we prefer first-seen for determinism)
365
+
366
+ pages = [entry[0] for entry in seen_components.values()]
367
+
368
+ if openapi_support.enabled:
369
+ finalize_inertia_pages(
370
+ pages,
371
+ openapi_support=openapi_support,
372
+ page_schema_keys=page_schema_keys,
373
+ page_schema_dicts=page_schema_dicts,
374
+ )
375
+
376
+ return pages
377
+
378
+
379
+ def iter_route_handlers(app: "Litestar") -> "list[tuple[HTTPRoute, HTTPRouteHandler]]":
380
+ """Iterate over HTTP route handlers in an app.
381
+
382
+ Returns a deterministically sorted list to ensure consistent output
383
+ across multiple runs. Handlers are sorted by (route_path, handler_name).
384
+
385
+ Returns:
386
+ A list of (http_route, route_handler) tuples, sorted for determinism.
387
+ """
388
+ handlers: list[tuple[HTTPRoute, HTTPRouteHandler]] = []
389
+ for route in app.routes:
390
+ if isinstance(route, HTTPRoute):
391
+ handlers.extend((route, route_handler) for route_handler in route.route_handlers)
392
+ # Sort by route path, then handler name for deterministic ordering
393
+ return sorted(handlers, key=lambda x: (str(x[0].path), x[1].handler_name or x[1].name or ""))
394
+
395
+
396
+ def get_fallback_ts_type(types_config: "TypeGenConfig | None") -> str:
397
+ fallback_type = types_config.fallback_type if types_config is not None else "unknown"
398
+ return "any" if fallback_type == "any" else "unknown"
399
+
400
+
401
+ def ts_type_from_value(value: Any, *, fallback_ts_type: str) -> str:
402
+ ts_type = fallback_ts_type
403
+ if value is None:
404
+ ts_type = "null"
405
+ elif isinstance(value, bool):
406
+ ts_type = "boolean"
407
+ elif isinstance(value, str):
408
+ ts_type = "string"
409
+ elif isinstance(value, (int, float)):
410
+ ts_type = "number"
411
+ elif isinstance(value, (bytes, bytearray, Path)):
412
+ ts_type = "string"
413
+ elif isinstance(value, (list, tuple, set, frozenset)):
414
+ ts_type = f"{fallback_ts_type}[]"
415
+ elif isinstance(value, dict):
416
+ ts_type = f"Record<string, {fallback_ts_type}>"
417
+ return ts_type
418
+
419
+
420
+ def should_register_value_schema(value: Any) -> bool:
421
+ if value is None:
422
+ return False
423
+ return not isinstance(value, (bool, str, int, float, bytes, bytearray, Path, list, tuple, set, frozenset, dict))
424
+
425
+
426
+ def process_session_props(
427
+ session_props: "set[str] | dict[str, type]",
428
+ shared_props: dict[str, dict[str, Any]],
429
+ shared_schema_keys: dict[str, tuple[str, ...]],
430
+ openapi_support: OpenAPISupport,
431
+ fallback_ts_type: str,
432
+ ) -> None:
433
+ """Process session props and add them to shared_props.
434
+
435
+ Handles both set[str] (legacy) and dict[str, type] (new typed) formats.
436
+ """
437
+ if isinstance(session_props, dict):
438
+ # New behavior: dict maps prop names to Python types
439
+ for key, prop_type_class in session_props.items():
440
+ if not key:
441
+ continue
442
+ # Register the type with OpenAPI if possible
443
+ if openapi_support.enabled and openapi_support.schema_creator:
444
+ try:
445
+ field_def = FieldDefinition.from_annotation(prop_type_class)
446
+ schema_result = openapi_support.schema_creator.for_field_definition(field_def)
447
+ if isinstance(schema_result, Reference):
448
+ shared_schema_keys[key] = _get_normalized_schema_key(field_def)
449
+ type_name = prop_type_class.__name__ if hasattr(prop_type_class, "__name__") else fallback_ts_type
450
+ shared_props.setdefault(key, {"type": type_name, "optional": True})
451
+ except (AttributeError, TypeError, ValueError): # pragma: no cover - defensive
452
+ shared_props.setdefault(key, {"type": fallback_ts_type, "optional": True})
453
+ else:
454
+ type_name = prop_type_class.__name__ if hasattr(prop_type_class, "__name__") else fallback_ts_type
455
+ shared_props.setdefault(key, {"type": type_name, "optional": True})
456
+ else:
457
+ # Legacy behavior: set of prop names (types are unknown)
458
+ for key in session_props:
459
+ if not key:
460
+ continue
461
+ shared_props.setdefault(key, {"type": fallback_ts_type, "optional": True})
462
+
463
+
464
+ def build_inertia_shared_props(
465
+ app: "Litestar",
466
+ *,
467
+ openapi_schema: dict[str, Any] | None,
468
+ include_default_auth: bool,
469
+ include_default_flash: bool,
470
+ inertia_config: "InertiaConfig | None",
471
+ types_config: "TypeGenConfig | None",
472
+ openapi_support: OpenAPISupport | None = None,
473
+ ) -> dict[str, dict[str, Any]]:
474
+ """Build shared props metadata (built-ins + configured props).
475
+
476
+ Args:
477
+ app: Litestar application instance.
478
+ openapi_schema: Optional OpenAPI schema dict.
479
+ include_default_auth: Include default auth shared prop.
480
+ include_default_flash: Include default flash shared prop.
481
+ inertia_config: Optional Inertia configuration.
482
+ types_config: Optional type generation configuration.
483
+ openapi_support: Optional shared OpenAPISupport instance. If not provided,
484
+ a new one will be created. Sharing improves determinism and performance.
485
+
486
+ Returns:
487
+ Mapping of shared prop name to metadata payload.
488
+ """
489
+ fallback_ts_type = get_fallback_ts_type(types_config)
490
+
491
+ shared_props: dict[str, dict[str, Any]] = {
492
+ "errors": {"type": "Record<string, string[]>", "optional": True},
493
+ "csrf_token": {"type": "string", "optional": True},
494
+ }
495
+
496
+ if include_default_auth or include_default_flash:
497
+ shared_props["auth"] = {"type": "AuthData", "optional": True}
498
+ shared_props["flash"] = {"type": "FlashMessages", "optional": True}
499
+
500
+ if inertia_config is None:
501
+ return shared_props
502
+
503
+ if openapi_support is None:
504
+ openapi_support = OpenAPISupport.from_app(app, openapi_schema)
505
+ shared_schema_keys: dict[str, tuple[str, ...]] = {}
506
+
507
+ for key, value in inertia_config.extra_static_page_props.items():
508
+ if not key:
509
+ continue
510
+
511
+ shared_props[key] = {"type": ts_type_from_value(value, fallback_ts_type=fallback_ts_type), "optional": True}
512
+
513
+ if openapi_support.enabled and isinstance(openapi_schema, dict) and should_register_value_schema(value):
514
+ try:
515
+ field_def = FieldDefinition.from_annotation(value.__class__)
516
+ schema_result = openapi_support.schema_creator.for_field_definition(field_def) # type: ignore[union-attr]
517
+ if isinstance(schema_result, Reference):
518
+ shared_schema_keys[key] = _get_normalized_schema_key(field_def)
519
+ except (AttributeError, TypeError, ValueError): # pragma: no cover - defensive
520
+ pass
521
+
522
+ # Handle session props - can be set[str] or dict[str, type]
523
+ process_session_props(
524
+ inertia_config.extra_session_page_props, shared_props, shared_schema_keys, openapi_support, fallback_ts_type
525
+ )
526
+
527
+ if not (
528
+ openapi_support.context
529
+ and openapi_support.schema_creator
530
+ and isinstance(openapi_schema, dict)
531
+ and shared_schema_keys
532
+ ):
533
+ return shared_props
534
+
535
+ generated_components = openapi_support.context.schema_registry.generate_components_schemas()
536
+ name_map = build_schema_name_map(openapi_support.context.schema_registry)
537
+ merge_generated_components_into_openapi(openapi_schema, generated_components)
538
+
539
+ for prop_name, schema_key in shared_schema_keys.items():
540
+ type_name = name_map.get(schema_key)
541
+ if type_name:
542
+ shared_props[prop_name]["type"] = type_name
543
+
544
+ return shared_props
545
+
546
+
547
+ def generate_inertia_pages_json(
548
+ app: "Litestar",
549
+ *,
550
+ openapi_schema: dict[str, Any] | None = None,
551
+ include_default_auth: bool = True,
552
+ include_default_flash: bool = True,
553
+ inertia_config: "InertiaConfig | None" = None,
554
+ types_config: "TypeGenConfig | None" = None,
555
+ ) -> dict[str, Any]:
556
+ """Generate Inertia pages metadata JSON.
557
+
558
+ The output is deterministic: all dict keys are sorted alphabetically
559
+ to produce byte-identical output for the same input data.
560
+
561
+ A single OpenAPISupport instance is shared across both page extraction and
562
+ shared props building to ensure consistent schema registration and naming.
563
+ This eliminates non-determinism from split schema registries.
564
+
565
+ Returns:
566
+ An Inertia pages metadata payload as a dictionary with sorted keys.
567
+ """
568
+ # Create a single OpenAPISupport instance to share across the entire pipeline.
569
+ # This ensures consistent schema registration and prevents "split-brain" issues
570
+ # where separate registries could produce different schema names.
571
+ openapi_support = OpenAPISupport.from_app(app, openapi_schema)
572
+
573
+ pages_metadata = extract_inertia_pages(
574
+ app,
575
+ openapi_schema=openapi_schema,
576
+ fallback_type=types_config.fallback_type if types_config is not None else "unknown",
577
+ openapi_support=openapi_support,
578
+ )
579
+
580
+ pages_dict: dict[str, dict[str, Any]] = {}
581
+ for page in pages_metadata:
582
+ page_data: dict[str, Any] = {"route": page.route_path}
583
+ if page.props_type:
584
+ page_data["propsType"] = page.props_type
585
+ if page.ts_type:
586
+ page_data["tsType"] = page.ts_type
587
+ if page.custom_types:
588
+ page_data["customTypes"] = page.custom_types
589
+ if page.schema_ref:
590
+ page_data["schemaRef"] = page.schema_ref
591
+ if page.handler_name:
592
+ page_data["handler"] = page.handler_name
593
+ pages_dict[page.component] = page_data
594
+
595
+ shared_props = build_inertia_shared_props(
596
+ app,
597
+ openapi_schema=openapi_schema,
598
+ include_default_auth=include_default_auth,
599
+ include_default_flash=include_default_flash,
600
+ inertia_config=inertia_config,
601
+ types_config=types_config,
602
+ openapi_support=openapi_support,
603
+ )
604
+
605
+ # Sort all dict keys for deterministic output
606
+ # Pages sorted by component name, shared props sorted by prop name
607
+ sorted_pages = dict(sorted(pages_dict.items()))
608
+ sorted_shared_props = dict(sorted(shared_props.items()))
609
+
610
+ root: dict[str, Any] = {
611
+ "fallbackType": types_config.fallback_type if types_config is not None else None,
612
+ "pages": sorted_pages,
613
+ "sharedProps": sorted_shared_props,
614
+ "typeGenConfig": {"includeDefaultAuth": include_default_auth, "includeDefaultFlash": include_default_flash},
615
+ "typeImportPaths": types_config.type_import_paths if types_config is not None else None,
616
+ }
617
+
618
+ # Remove None values for cleaner output
619
+ return {k: v for k, v in root.items() if v is not None}