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,997 @@
1
+ """Litestar-Vite Configuration.
2
+
3
+ This module provides the configuration dataclasses for the Vite integration.
4
+ The configuration is split into logical groups:
5
+
6
+ - PathConfig: File system paths
7
+ - RuntimeConfig: Execution settings
8
+ - TypeGenConfig: Type generation settings
9
+ - ViteConfig: Root configuration combining all sub-configs
10
+
11
+ Example usage::
12
+
13
+ # Minimal - SPA mode with defaults
14
+ VitePlugin(config=ViteConfig())
15
+
16
+ # Development mode
17
+ VitePlugin(config=ViteConfig(dev_mode=True))
18
+
19
+ # With type generation
20
+ VitePlugin(config=ViteConfig(dev_mode=True, types=True))
21
+
22
+ # Template mode for HTMX
23
+ VitePlugin(config=ViteConfig(mode="template", dev_mode=True))
24
+ """
25
+
26
+ import logging
27
+ import os
28
+ from dataclasses import dataclass, field, replace
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, runtime_checkable
31
+
32
+ from litestar.exceptions import SerializationException
33
+ from litestar.serialization import decode_json
34
+
35
+ from litestar_vite.config._constants import ( # pyright: ignore[reportPrivateUsage]
36
+ FSSPEC_INSTALLED,
37
+ JINJA_INSTALLED,
38
+ TRUE_VALUES,
39
+ )
40
+ from litestar_vite.config._deploy import DeployConfig # pyright: ignore[reportPrivateUsage]
41
+ from litestar_vite.config._inertia import ( # pyright: ignore[reportPrivateUsage]
42
+ InertiaConfig,
43
+ InertiaSSRConfig,
44
+ InertiaTypeGenConfig,
45
+ )
46
+ from litestar_vite.config._paths import PathConfig # pyright: ignore[reportPrivateUsage]
47
+ from litestar_vite.config._runtime import ExternalDevServer, RuntimeConfig # pyright: ignore[reportPrivateUsage]
48
+ from litestar_vite.config._spa import LoggingConfig, SPAConfig # pyright: ignore[reportPrivateUsage]
49
+ from litestar_vite.config._types import TypeGenConfig # pyright: ignore[reportPrivateUsage]
50
+
51
+ logger = logging.getLogger("litestar_vite")
52
+
53
+ if TYPE_CHECKING:
54
+ from collections.abc import Sequence
55
+
56
+ from litestar.types import Guard # pyright: ignore[reportUnknownVariableType]
57
+
58
+ from litestar_vite.executor import JSExecutor
59
+
60
+ __all__ = (
61
+ "FSSPEC_INSTALLED",
62
+ "JINJA_INSTALLED",
63
+ "DeployConfig",
64
+ "ExternalDevServer",
65
+ "InertiaConfig",
66
+ "InertiaSSRConfig",
67
+ "InertiaTypeGenConfig",
68
+ "LoggingConfig",
69
+ "PaginationContainer",
70
+ "PathConfig",
71
+ "RuntimeConfig",
72
+ "SPAConfig",
73
+ "TypeGenConfig",
74
+ "ViteConfig",
75
+ )
76
+
77
+
78
+ @runtime_checkable
79
+ class PaginationContainer(Protocol):
80
+ """Protocol for pagination containers that can be unwrapped for Inertia scroll.
81
+
82
+ Any type that has `items` and pagination metadata can implement this protocol.
83
+ The response will extract items and calculate scroll_props automatically.
84
+
85
+ Built-in support:
86
+ - litestar.pagination.OffsetPagination
87
+ - litestar.pagination.ClassicPagination
88
+ - advanced_alchemy.service.OffsetPagination
89
+
90
+ Custom types can implement this protocol::
91
+
92
+ @dataclass
93
+ class MyPagination:
94
+ items: list[T]
95
+ total: int
96
+ limit: int
97
+ offset: int
98
+ """
99
+
100
+ items: "Sequence[Any]"
101
+
102
+
103
+ def _to_root_path(root_dir: Path, path: Path) -> Path:
104
+ """Resolve a path relative to the configured root directory.
105
+
106
+ Args:
107
+ root_dir: Application root directory.
108
+ path: Path to resolve.
109
+
110
+ Returns:
111
+ Absolute path rooted at ``root_dir`` when ``path`` is relative, otherwise ``path`` unchanged.
112
+ """
113
+ return path if path.is_absolute() else (root_dir / path)
114
+
115
+
116
+ @dataclass
117
+ class ViteConfig:
118
+ """Root Vite configuration.
119
+
120
+ This is the main configuration class that combines all sub-configurations.
121
+ Supports shortcuts for common configurations:
122
+
123
+ - dev_mode: Shortcut for runtime.dev_mode
124
+ - types=True or TypeGenConfig(): Enable type generation (presence = enabled)
125
+ - inertia=True or InertiaConfig(): Enable Inertia.js (presence = enabled)
126
+
127
+ Mode auto-detection:
128
+
129
+ - If mode is not explicitly set:
130
+
131
+ - If Inertia is enabled and index.html exists -> Hybrid mode
132
+ - If Inertia is enabled without index.html -> Template mode
133
+ - Checks for index.html in common locations -> SPA mode
134
+ - Checks if Jinja2 template engine is configured -> Template mode
135
+ - Otherwise defaults to SPA mode
136
+
137
+ Dev-mode auto-enable:
138
+
139
+ - If mode="spa" and no built assets are found in bundle_dir, dev_mode is
140
+ enabled automatically (unless VITE_AUTO_DEV_MODE=False).
141
+
142
+ - Explicit mode parameter overrides auto-detection
143
+
144
+ Attributes:
145
+ mode: Serving mode - "spa", "template", "htmx", "hybrid", "framework", "ssr", "ssg", or "external".
146
+ Auto-detected if not set. Use "external" for non-Vite frameworks (Angular CLI, etc.)
147
+ that have their own build system - auto-serves bundle_dir in production.
148
+ paths: File system paths configuration.
149
+ runtime: Runtime execution settings.
150
+ types: Type generation settings (True/TypeGenConfig enables, False/None disables).
151
+ inertia: Inertia.js settings (True/InertiaConfig enables, False/None disables).
152
+ spa: SPA transformation settings (True enables with defaults, False disables).
153
+ logging: Logging configuration (True enables with defaults, None uses defaults).
154
+ dev_mode: Convenience shortcut for runtime.dev_mode.
155
+ base_url: Base URL for the app entry point.
156
+ deploy: Deployment configuration for CDN publishing.
157
+ """
158
+
159
+ mode: "Literal['spa', 'template', 'htmx', 'hybrid', 'inertia', 'framework', 'ssr', 'ssg', 'external'] | None" = None
160
+ paths: PathConfig = field(default_factory=PathConfig)
161
+ runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
162
+ types: "TypeGenConfig | bool | None" = None
163
+ inertia: "InertiaConfig | bool | None" = None
164
+ spa: "SPAConfig | bool | None" = None
165
+ logging: "LoggingConfig | bool | None" = None
166
+ dev_mode: bool = False
167
+ base_url: "str | None" = field(default_factory=lambda: os.getenv("VITE_BASE_URL"))
168
+ deploy: "DeployConfig | bool" = False
169
+ guards: "Sequence[Guard] | None" = None # pyright: ignore[reportUnknownVariableType]
170
+ """Custom guards for the SPA catch-all route.
171
+
172
+ When set, these guards are applied to the SPA handler route that serves the
173
+ SPA index.html (mode="spa"/"framework" with spa_handler=True).
174
+ """
175
+ exclude_static_from_auth: bool = True
176
+ """Exclude static file routes from authentication.
177
+
178
+ When True (default), static file routes are served with
179
+ opt={"exclude_from_auth": True}, which tells auth middleware to skip
180
+ authentication for asset requests. Set to False if you need to protect
181
+ static assets with authentication.
182
+ """
183
+ spa_path: "str | None" = None
184
+ """Path where the SPA handler serves index.html.
185
+
186
+ Controls where AppHandler registers its catch-all routes.
187
+
188
+ - Default: "/" (root)
189
+ - Non-root (e.g. "/web/"): optionally set `include_root_spa_paths=True` to
190
+ also serve at "/" and "/{path:path}".
191
+ """
192
+ include_root_spa_paths: bool = False
193
+ """Also register SPA routes at root when spa_path is non-root.
194
+
195
+ When True and spa_path is set to a non-root path (e.g., "/web/"),
196
+ the SPA handler will also serve at "/" and "/{path:path}" in addition
197
+ to the spa_path routes.
198
+
199
+ This is useful for Angular apps with --base-href /web/ that also
200
+ want to serve the SPA from the root path for convenience.
201
+ """
202
+
203
+ _executor_instance: "JSExecutor | None" = field(default=None, repr=False)
204
+ _mode_auto_detected: bool = field(default=False, repr=False)
205
+
206
+ def __post_init__(self) -> None:
207
+ """Normalize configurations and apply shortcuts."""
208
+ self._normalize_mode()
209
+ self._normalize_types()
210
+ self._normalize_inertia()
211
+ self._normalize_spa_flag()
212
+ self._normalize_logging()
213
+ self._apply_dev_mode_shortcut()
214
+ self._auto_detect_mode()
215
+ self._auto_configure_inertia()
216
+ self._auto_detect_react()
217
+ self._apply_framework_mode_defaults()
218
+ self._normalize_deploy()
219
+ self._ensure_spa_default()
220
+ self._auto_enable_dev_mode()
221
+ self._warn_missing_assets()
222
+
223
+ def _auto_detect_react(self) -> None:
224
+ """Enable React Fast Refresh automatically for React templates.
225
+
226
+ When serving HTML outside Vite's native index.html pipeline (template/hybrid modes),
227
+ @vitejs/plugin-react requires the React preamble to be injected into the HTML.
228
+ The asset loader handles this when `runtime.is_react` is enabled.
229
+
230
+ We auto-enable it when `@vitejs/plugin-react` is present in the project's package.json.
231
+ """
232
+ if self.runtime.is_react:
233
+ return
234
+
235
+ package_json = self.root_dir / "package.json"
236
+ if not package_json.exists():
237
+ return
238
+
239
+ try:
240
+ payload = decode_json(package_json.read_text(encoding="utf-8"))
241
+ except (OSError, UnicodeDecodeError, SerializationException): # pragma: no cover - defensive
242
+ return
243
+
244
+ deps_any = payload.get("dependencies")
245
+ dev_deps_any = payload.get("devDependencies")
246
+
247
+ deps: dict[str, Any] = {}
248
+ dev_deps: dict[str, Any] = {}
249
+
250
+ if isinstance(deps_any, dict):
251
+ deps = cast("dict[str, Any]", deps_any)
252
+ if isinstance(dev_deps_any, dict):
253
+ dev_deps = cast("dict[str, Any]", dev_deps_any)
254
+
255
+ if "@vitejs/plugin-react" in deps or "@vitejs/plugin-react" in dev_deps:
256
+ self.runtime.is_react = True
257
+
258
+ def _normalize_mode(self) -> None:
259
+ """Normalize mode aliases.
260
+
261
+ Aliases:
262
+ - 'framework': Canonical name for meta-framework integration mode (Astro/Nuxt/SvelteKit, etc.)
263
+ that uses dev-time proxying to a frontend dev server and optional SSR/SSG output handling.
264
+
265
+ - 'ssr' / 'ssg' → 'framework': Aliases for framework proxy mode.
266
+ Static Site Generation (SSG) uses the same dev-time proxy behavior as SSR:
267
+ forward non-API routes to the framework dev server. SSG pre-renders at build time,
268
+ SSR renders per-request, but their dev-time proxy behavior is identical.
269
+
270
+ - 'inertia' → 'hybrid': Inertia.js apps without Jinja templates use hybrid mode.
271
+ This is clearer terminology since "hybrid" refers to the SPA-with-server-routing
272
+ pattern that Inertia implements.
273
+ """
274
+ if self.mode in {"ssr", "ssg"}:
275
+ self.mode = "framework"
276
+ elif self.mode == "inertia":
277
+ self.mode = "hybrid"
278
+
279
+ def _normalize_types(self) -> None:
280
+ """Normalize type generation configuration.
281
+
282
+ Supports:
283
+ - True: Enable with defaults -> TypeGenConfig()
284
+ - False/None: Disabled -> None
285
+ - TypeGenConfig: Use as-is (presence = enabled)
286
+ """
287
+ if self.types is True:
288
+ self.types = TypeGenConfig()
289
+ elif self.types is False or self.types is None:
290
+ self.types = None
291
+ return
292
+ self._resolve_type_paths(self.types)
293
+
294
+ def _normalize_inertia(self) -> None:
295
+ """Normalize inertia configuration.
296
+
297
+ Supports:
298
+ - True: Enable with defaults -> InertiaConfig()
299
+ - False/None: Disabled -> None
300
+ - InertiaConfig: Use as-is
301
+ """
302
+ if self.inertia is True:
303
+ self.inertia = InertiaConfig()
304
+ elif self.inertia is False:
305
+ self.inertia = None
306
+
307
+ def _normalize_spa_flag(self) -> None:
308
+ if self.spa is True:
309
+ self.spa = SPAConfig()
310
+
311
+ def _normalize_logging(self) -> None:
312
+ """Normalize logging configuration.
313
+
314
+ Supports:
315
+ - True: Enable with defaults -> LoggingConfig()
316
+ - False/None: Use defaults -> LoggingConfig()
317
+ - LoggingConfig: Use as-is
318
+ """
319
+ if self.logging is True or self.logging is None or self.logging is False:
320
+ self.logging = LoggingConfig()
321
+
322
+ def _apply_dev_mode_shortcut(self) -> None:
323
+ if self.dev_mode:
324
+ self.runtime.dev_mode = True
325
+
326
+ def _auto_detect_mode(self) -> None:
327
+ if self.mode is None:
328
+ self.mode = self._detect_mode()
329
+ self._mode_auto_detected = True
330
+
331
+ def _auto_configure_inertia(self) -> None:
332
+ """Auto-configure settings when Inertia is enabled.
333
+
334
+ When Inertia is configured, automatically enable CSRF token injection
335
+ in the SPA config, since Inertia forms need CSRF protection.
336
+ """
337
+ if isinstance(self.inertia, InertiaConfig) and isinstance(self.spa, SPAConfig) and not self.spa.inject_csrf:
338
+ self.spa = replace(self.spa, inject_csrf=True)
339
+
340
+ def _apply_framework_mode_defaults(self) -> None:
341
+ """Apply intelligent defaults for framework proxy mode.
342
+
343
+ When mode='framework' is set, automatically configure proxy_mode and spa_handler
344
+ based on dev_mode and whether built assets exist:
345
+
346
+ - Dev mode: proxy_mode='proxy', spa_handler=False
347
+ (Proxy all non-API routes to the SSR/SSG framework dev server)
348
+ - Prod mode with built assets: proxy_mode=None, spa_handler=True
349
+ (Serve static SSG output like Astro's dist/)
350
+ - Prod mode without built assets: proxy_mode=None, spa_handler=False
351
+ (True SSR - Node server handles HTML, Litestar only serves API)
352
+ """
353
+ if self.mode != "framework":
354
+ return
355
+
356
+ if self.runtime.dev_mode:
357
+ env_proxy = os.getenv("VITE_PROXY_MODE")
358
+ if env_proxy is None:
359
+ self.runtime.proxy_mode = "proxy"
360
+ self.runtime.spa_handler = False
361
+ else:
362
+ self.runtime.proxy_mode = None
363
+ if self.has_built_assets():
364
+ self.runtime.spa_handler = True
365
+ else:
366
+ self.runtime.spa_handler = False
367
+
368
+ def _normalize_deploy(self) -> None:
369
+ if self.deploy is True:
370
+ self.deploy = DeployConfig(enabled=True)
371
+ elif self.deploy is False:
372
+ self.deploy = DeployConfig(enabled=False)
373
+
374
+ def _resolve_type_paths(self, types: TypeGenConfig) -> None:
375
+ """Resolve type generation paths relative to the configured root.
376
+
377
+ Args:
378
+ types: Type generation configuration to mutate.
379
+
380
+ """
381
+ root_dir = self.root_dir
382
+
383
+ default_rel = Path("src/generated")
384
+ default_openapi = default_rel / "openapi.json"
385
+ default_routes = default_rel / "routes.json"
386
+ default_page_props = default_rel / "inertia-pages.json"
387
+
388
+ if types.openapi_path == default_openapi and types.output != default_rel:
389
+ types.openapi_path = types.output / "openapi.json"
390
+ if types.routes_path == default_routes and types.output != default_rel:
391
+ types.routes_path = types.output / "routes.json"
392
+ if types.page_props_path == default_page_props and types.output != default_rel:
393
+ types.page_props_path = types.output / "inertia-pages.json"
394
+
395
+ if types.routes_ts_path is None or (
396
+ types.routes_ts_path == default_rel / "routes.ts" and types.output != default_rel
397
+ ):
398
+ types.routes_ts_path = types.output / "routes.ts"
399
+
400
+ types.output = _to_root_path(root_dir, types.output)
401
+ types.openapi_path = (
402
+ _to_root_path(root_dir, types.openapi_path) if types.openapi_path else types.output / "openapi.json"
403
+ )
404
+ types.routes_path = (
405
+ _to_root_path(root_dir, types.routes_path) if types.routes_path else types.output / "routes.json"
406
+ )
407
+ types.routes_ts_path = (
408
+ _to_root_path(root_dir, types.routes_ts_path) if types.routes_ts_path else types.output / "routes.ts"
409
+ )
410
+ types.page_props_path = (
411
+ _to_root_path(root_dir, types.page_props_path)
412
+ if types.page_props_path
413
+ else types.output / "inertia-pages.json"
414
+ )
415
+
416
+ def _ensure_spa_default(self) -> None:
417
+ if self.mode in {"spa", "hybrid"} and self.spa is None:
418
+ self.spa = SPAConfig()
419
+ elif self.spa is None:
420
+ self.spa = False
421
+
422
+ def _auto_enable_dev_mode(self) -> None:
423
+ if not self._mode_auto_detected:
424
+ return
425
+
426
+ auto_dev_mode = os.getenv("VITE_AUTO_DEV_MODE", "True") in TRUE_VALUES
427
+ if (
428
+ auto_dev_mode
429
+ and not self.runtime.dev_mode
430
+ and self.mode in {"spa", "hybrid"}
431
+ and not self.has_built_assets()
432
+ ):
433
+ self.runtime.dev_mode = True
434
+
435
+ def _warn_missing_assets(self) -> None:
436
+ """Warn if running in production mode without built assets."""
437
+ import sys
438
+
439
+ if self.mode not in {"spa", "hybrid"}:
440
+ return
441
+ if self.runtime.dev_mode:
442
+ return
443
+ if self.has_built_assets():
444
+ return
445
+
446
+ cli_commands_skip_warning = {
447
+ "install",
448
+ "build",
449
+ "init",
450
+ "serve",
451
+ "deploy",
452
+ "doctor",
453
+ "generate-types",
454
+ "export-routes",
455
+ "status",
456
+ }
457
+ argv_str = " ".join(sys.argv)
458
+ if any(f"assets {cmd}" in argv_str for cmd in cli_commands_skip_warning):
459
+ return
460
+
461
+ if self.runtime.external_dev_server is not None:
462
+ return
463
+
464
+ candidates = self.candidate_manifest_paths()
465
+ manifest_locations = " or ".join(str(path) for path in candidates)
466
+ logger.warning(
467
+ "Vite manifest not found at %s. "
468
+ "Run 'litestar assets build' (or 'npm run build') to build assets, "
469
+ "or set dev_mode=True for development. "
470
+ "Assets will not load correctly without built files or a running Vite dev server.",
471
+ manifest_locations,
472
+ )
473
+
474
+ def _detect_mode(self) -> Literal["spa", "template", "htmx", "hybrid"]:
475
+ """Auto-detect the serving mode based on project structure.
476
+
477
+ Detection order:
478
+ 1. If Inertia is enabled:
479
+ a. Default to hybrid mode for SPA-style Inertia applications
480
+ b. Hybrid mode works with AppHandler + HTML transformation
481
+ c. index.html is served by Vite dev server in dev mode or built assets in production
482
+ Note: If using Jinja2 templates with Inertia, set mode="template" explicitly.
483
+ 2. Check for index.html in resource_dir, root_dir, or static_dir → SPA
484
+ 3. Check if Jinja2 is installed and likely to be used → Template
485
+ 4. Default to SPA
486
+
487
+ Returns:
488
+ The detected mode.
489
+ """
490
+ inertia_enabled = isinstance(self.inertia, InertiaConfig)
491
+
492
+ if inertia_enabled:
493
+ return "hybrid"
494
+
495
+ if any(path.exists() for path in self.candidate_index_html_paths()):
496
+ return "spa"
497
+
498
+ if JINJA_INSTALLED:
499
+ return "template"
500
+
501
+ return "spa"
502
+
503
+ def validate_mode(self) -> None:
504
+ """Validate the mode configuration against the project structure.
505
+
506
+ Raises:
507
+ ValueError: If the configuration is invalid for the selected mode.
508
+ """
509
+ if self.mode == "spa":
510
+ index_candidates = self.candidate_index_html_paths()
511
+ if not self.runtime.dev_mode and not any(path.exists() for path in index_candidates):
512
+ joined_paths = ", ".join(str(path) for path in index_candidates)
513
+ msg = (
514
+ "SPA mode requires index.html at one of: "
515
+ f"{joined_paths}. "
516
+ "Either create the file, run in dev mode, or switch to template mode."
517
+ )
518
+ raise ValueError(msg)
519
+
520
+ elif self.mode == "hybrid":
521
+ index_candidates = self.candidate_index_html_paths()
522
+ if not self.runtime.dev_mode and not any(path.exists() for path in index_candidates):
523
+ joined_paths = ", ".join(str(path) for path in index_candidates)
524
+ msg = (
525
+ "Hybrid mode requires index.html at one of: "
526
+ f"{joined_paths}. "
527
+ "Either create the file or run in dev mode."
528
+ )
529
+ raise ValueError(msg)
530
+
531
+ elif self.mode in {"template", "htmx"}:
532
+ if not JINJA_INSTALLED:
533
+ msg = (
534
+ f"{self.mode} mode requires Jinja2 to be installed. "
535
+ "Install it with: pip install litestar-vite[jinja]"
536
+ )
537
+ raise ValueError(msg)
538
+
539
+ @property
540
+ def executor(self) -> "JSExecutor":
541
+ """Get the JavaScript executor instance.
542
+
543
+ Returns:
544
+ The configured JavaScript executor.
545
+ """
546
+ if self._executor_instance is None:
547
+ self._executor_instance = self._create_executor()
548
+ return self._executor_instance
549
+
550
+ def reset_executor(self) -> None:
551
+ """Reset the cached executor instance.
552
+
553
+ Call this after modifying logging config to pick up new settings.
554
+ """
555
+ self._executor_instance = None
556
+
557
+ def _create_executor(self) -> "JSExecutor":
558
+ """Create the appropriate executor based on runtime config.
559
+
560
+ Returns:
561
+ An instance of the selected JSExecutor.
562
+ """
563
+ from litestar_vite.executor import (
564
+ BunExecutor,
565
+ DenoExecutor,
566
+ NodeenvExecutor,
567
+ NodeExecutor,
568
+ PnpmExecutor,
569
+ YarnExecutor,
570
+ )
571
+
572
+ executor_type = self.runtime.executor or "node"
573
+ silent = self.logging_config.suppress_npm_output
574
+
575
+ if executor_type == "bun":
576
+ return BunExecutor(silent=silent)
577
+ if executor_type == "deno":
578
+ return DenoExecutor(silent=silent)
579
+ if executor_type == "yarn":
580
+ return YarnExecutor(silent=silent)
581
+ if executor_type == "pnpm":
582
+ return PnpmExecutor(silent=silent)
583
+ if self.runtime.detect_nodeenv:
584
+ return NodeenvExecutor(self, silent=silent)
585
+ return NodeExecutor(silent=silent)
586
+
587
+ @property
588
+ def bundle_dir(self) -> Path:
589
+ """Get bundle directory path.
590
+
591
+ Returns:
592
+ The configured bundle directory path.
593
+ """
594
+ return self.paths.bundle_dir if isinstance(self.paths.bundle_dir, Path) else Path(self.paths.bundle_dir)
595
+
596
+ @property
597
+ def resource_dir(self) -> Path:
598
+ """Get resource directory path.
599
+
600
+ Returns:
601
+ The configured resource directory path.
602
+ """
603
+ return self.paths.resource_dir if isinstance(self.paths.resource_dir, Path) else Path(self.paths.resource_dir)
604
+
605
+ @property
606
+ def static_dir(self) -> Path:
607
+ """Get static directory path.
608
+
609
+ Returns:
610
+ The configured static directory path.
611
+ """
612
+ return self.paths.static_dir if isinstance(self.paths.static_dir, Path) else Path(self.paths.static_dir)
613
+
614
+ @property
615
+ def root_dir(self) -> Path:
616
+ """Get root directory path.
617
+
618
+ Returns:
619
+ The configured project root directory path.
620
+ """
621
+ return self.paths.root if isinstance(self.paths.root, Path) else Path(self.paths.root)
622
+
623
+ @property
624
+ def manifest_name(self) -> str:
625
+ """Get manifest file name.
626
+
627
+ Returns:
628
+ The configured Vite manifest filename.
629
+ """
630
+ return self.paths.manifest_name
631
+
632
+ @property
633
+ def hot_file(self) -> str:
634
+ """Get hot file name.
635
+
636
+ Returns:
637
+ The configured hotfile filename.
638
+ """
639
+ return self.paths.hot_file
640
+
641
+ @property
642
+ def asset_url(self) -> str:
643
+ """Get asset URL.
644
+
645
+ Returns:
646
+ The configured asset URL prefix.
647
+ """
648
+ return self.paths.asset_url
649
+
650
+ def _resolve_to_root(self, path: Path) -> Path:
651
+ """Resolve a path relative to the configured root directory.
652
+
653
+ Returns:
654
+ The resolved absolute Path.
655
+ """
656
+
657
+ if path.is_absolute():
658
+ return path
659
+ return self.root_dir / path
660
+
661
+ def candidate_index_html_paths(self) -> list[Path]:
662
+ """Return possible index.html locations for SPA mode detection.
663
+
664
+ Order mirrors the JS plugin auto-detection:
665
+ 1. bundle_dir/index.html (for production static builds like Astro/Nuxt/SvelteKit)
666
+ 2. resource_dir/index.html
667
+ 3. root_dir/index.html
668
+ 4. static_dir/index.html
669
+
670
+ Returns:
671
+ A de-duplicated list of candidate index.html paths, ordered by preference.
672
+ """
673
+
674
+ bundle_dir = self._resolve_to_root(self.bundle_dir)
675
+ resource_dir = self._resolve_to_root(self.resource_dir)
676
+ static_dir = self._resolve_to_root(self.static_dir)
677
+ root_dir = self.root_dir
678
+
679
+ candidates = [
680
+ bundle_dir / "index.html",
681
+ resource_dir / "index.html",
682
+ root_dir / "index.html",
683
+ static_dir / "index.html",
684
+ ]
685
+
686
+ unique: list[Path] = []
687
+ seen: set[Path] = set()
688
+ for path in candidates:
689
+ if path in seen:
690
+ continue
691
+ seen.add(path)
692
+ unique.append(path)
693
+ return unique
694
+
695
+ def candidate_manifest_paths(self) -> list[Path]:
696
+ """Return possible manifest.json locations in the bundle directory.
697
+
698
+ Some meta-frameworks emit the manifest under a ``.vite/`` subdirectory
699
+ (e.g. ``<bundle_dir>/.vite/manifest.json``), while plain Vite builds may
700
+ write it directly to ``<bundle_dir>/manifest.json``.
701
+
702
+ Returns:
703
+ A de-duplicated list of candidate manifest paths, ordered by preference.
704
+ """
705
+ bundle_path = self._resolve_to_root(self.bundle_dir)
706
+ manifest_rel = Path(self.manifest_name)
707
+
708
+ candidates: list[Path] = [bundle_path / manifest_rel]
709
+ if not manifest_rel.is_absolute() and (not manifest_rel.parts or manifest_rel.parts[0] != ".vite"):
710
+ candidates.append(bundle_path / ".vite" / manifest_rel)
711
+
712
+ unique: list[Path] = []
713
+ seen: set[Path] = set()
714
+ for path in candidates:
715
+ if path in seen:
716
+ continue
717
+ seen.add(path)
718
+ unique.append(path)
719
+ return unique
720
+
721
+ def resolve_manifest_path(self) -> Path:
722
+ """Resolve the most likely manifest path.
723
+
724
+ Returns:
725
+ The first existing manifest path, or the highest-priority candidate when none exist.
726
+ """
727
+ candidates = self.candidate_manifest_paths()
728
+ for candidate in candidates:
729
+ if candidate.exists():
730
+ return candidate
731
+ return candidates[0]
732
+
733
+ def has_built_assets(self) -> bool:
734
+ """Check if production assets exist in the bundle directory.
735
+
736
+ Returns:
737
+ True if a manifest or built index.html exists in bundle_dir.
738
+
739
+ Note:
740
+ This method checks the bundle_dir (output directory) for built artifacts,
741
+ NOT source directories. The presence of source index.html in resource_dir
742
+ does not indicate built assets exist.
743
+ """
744
+ bundle_path = self._resolve_to_root(self.bundle_dir)
745
+ index_path = bundle_path / "index.html"
746
+
747
+ return any(path.exists() for path in self.candidate_manifest_paths()) or index_path.exists()
748
+
749
+ @property
750
+ def host(self) -> str:
751
+ """Get dev server host.
752
+
753
+ Returns:
754
+ The configured Vite dev server host.
755
+ """
756
+ return self.runtime.host
757
+
758
+ @property
759
+ def port(self) -> int:
760
+ """Get dev server port.
761
+
762
+ Returns:
763
+ The configured Vite dev server port.
764
+ """
765
+ return self.runtime.port
766
+
767
+ @property
768
+ def protocol(self) -> str:
769
+ """Get dev server protocol.
770
+
771
+ Returns:
772
+ The configured Vite dev server protocol.
773
+ """
774
+ return self.runtime.protocol
775
+
776
+ @property
777
+ def hot_reload(self) -> bool:
778
+ """Check if hot reload is enabled (derived from dev_mode and proxy_mode).
779
+
780
+ HMR requires dev_mode=True AND a Vite-based proxy mode (vite, direct, or proxy).
781
+ All modes support HMR since even SSR frameworks use Vite internally.
782
+
783
+ Returns:
784
+ True if hot reload is enabled, otherwise False.
785
+ """
786
+ return self.runtime.dev_mode and self.runtime.proxy_mode in {"vite", "direct", "proxy"}
787
+
788
+ @property
789
+ def is_dev_mode(self) -> bool:
790
+ """Check if dev mode is enabled.
791
+
792
+ Returns:
793
+ True if dev mode is enabled, otherwise False.
794
+ """
795
+ return self.runtime.dev_mode
796
+
797
+ @property
798
+ def is_react(self) -> bool:
799
+ """Check if React mode is enabled.
800
+
801
+ Returns:
802
+ True if React mode is enabled, otherwise False.
803
+ """
804
+ return self.runtime.is_react
805
+
806
+ @property
807
+ def run_command(self) -> list[str]:
808
+ """Get the run command.
809
+
810
+ Returns:
811
+ The argv list used to start the Vite dev server.
812
+ """
813
+ return self.runtime.run_command or ["npm", "run", "dev"]
814
+
815
+ @property
816
+ def build_command(self) -> list[str]:
817
+ """Get the build command.
818
+
819
+ Returns:
820
+ The argv list used to build production assets.
821
+ """
822
+ return self.runtime.build_command or ["npm", "run", "build"]
823
+
824
+ @property
825
+ def build_watch_command(self) -> list[str]:
826
+ """Get the watch command for building frontend in watch mode.
827
+
828
+ Used by `litestar assets serve` when hot_reload is disabled.
829
+
830
+ Returns:
831
+ The command argv list used for watch builds.
832
+ """
833
+ return self.runtime.build_watch_command or ["npm", "run", "build", "--", "--watch"]
834
+
835
+ @property
836
+ def serve_command(self) -> "list[str] | None":
837
+ """Get the serve command for running production server.
838
+
839
+ Used by `litestar assets serve --production` for SSR frameworks.
840
+ Returns None if not configured.
841
+
842
+ Returns:
843
+ The command argv list used to serve production assets, or None if not configured.
844
+ """
845
+ return self.runtime.serve_command
846
+
847
+ @property
848
+ def install_command(self) -> list[str]:
849
+ """Get the install command.
850
+
851
+ Returns:
852
+ The argv list used to install frontend dependencies.
853
+ """
854
+ return self.runtime.install_command or ["npm", "install"]
855
+
856
+ @property
857
+ def health_check(self) -> bool:
858
+ """Check if health check is enabled.
859
+
860
+ Returns:
861
+ True if health checks are enabled, otherwise False.
862
+ """
863
+ return self.runtime.health_check
864
+
865
+ @property
866
+ def set_environment(self) -> bool:
867
+ """Check if environment should be set.
868
+
869
+ Returns:
870
+ True if Vite environment variables should be set, otherwise False.
871
+ """
872
+ return self.runtime.set_environment
873
+
874
+ @property
875
+ def set_static_folders(self) -> bool:
876
+ """Check if static folders should be configured.
877
+
878
+ Returns:
879
+ True if static folders should be configured, otherwise False.
880
+ """
881
+ return self.runtime.set_static_folders
882
+
883
+ @property
884
+ def detect_nodeenv(self) -> bool:
885
+ """Check if nodeenv detection is enabled.
886
+
887
+ Returns:
888
+ True if nodeenv detection is enabled, otherwise False.
889
+ """
890
+ return self.runtime.detect_nodeenv
891
+
892
+ @property
893
+ def proxy_mode(self) -> "Literal['vite', 'direct', 'proxy'] | None":
894
+ """Get proxy mode.
895
+
896
+ Returns:
897
+ The configured proxy mode, or None if proxying is disabled.
898
+ """
899
+ return self.runtime.proxy_mode
900
+
901
+ @property
902
+ def external_dev_server(self) -> "ExternalDevServer | None":
903
+ """Get external dev server config.
904
+
905
+ Returns:
906
+ External dev server configuration, or None if not configured.
907
+ """
908
+ if isinstance(self.runtime.external_dev_server, ExternalDevServer):
909
+ return self.runtime.external_dev_server
910
+ return None
911
+
912
+ @property
913
+ def spa_handler(self) -> bool:
914
+ """Check if SPA handler auto-registration is enabled.
915
+
916
+ Returns:
917
+ True if the SPA handler should be auto-registered, otherwise False.
918
+ """
919
+ return self.runtime.spa_handler
920
+
921
+ @property
922
+ def http2(self) -> bool:
923
+ """Check if HTTP/2 is enabled for proxy connections.
924
+
925
+ Returns:
926
+ True if HTTP/2 is enabled for proxy connections, otherwise False.
927
+ """
928
+ return self.runtime.http2
929
+
930
+ @property
931
+ def csp_nonce(self) -> "str | None":
932
+ """Return the CSP nonce used for injected inline scripts.
933
+
934
+ Returns:
935
+ CSP nonce string when configured, otherwise None.
936
+ """
937
+ return self.runtime.csp_nonce
938
+
939
+ @property
940
+ def trusted_proxies(self) -> "list[str] | str | None":
941
+ """Get trusted proxies configuration.
942
+
943
+ When set, enables ProxyHeadersMiddleware to handle X-Forwarded-* headers
944
+ from reverse proxies (Railway, Heroku, AWS ALB, nginx, etc.).
945
+
946
+ Returns:
947
+ The trusted proxies configuration, or None if disabled.
948
+ """
949
+ return self.runtime.trusted_proxies
950
+
951
+ @property
952
+ def ssr_output_dir(self) -> "Path | None":
953
+ """Get SSR output directory.
954
+
955
+ Returns:
956
+ The configured SSR output directory, or None if not configured.
957
+ """
958
+ if self.paths.ssr_output_dir is None:
959
+ return None
960
+ return (
961
+ self.paths.ssr_output_dir
962
+ if isinstance(self.paths.ssr_output_dir, Path)
963
+ else Path(self.paths.ssr_output_dir)
964
+ )
965
+
966
+ @property
967
+ def spa_config(self) -> "SPAConfig | None":
968
+ """Get SPA configuration if enabled, or None if disabled.
969
+
970
+ Returns:
971
+ SPAConfig instance if spa transformations are enabled, None otherwise.
972
+ """
973
+ if isinstance(self.spa, SPAConfig):
974
+ return self.spa
975
+ return None
976
+
977
+ @property
978
+ def deploy_config(self) -> "DeployConfig | None":
979
+ """Get deploy configuration if enabled.
980
+
981
+ Returns:
982
+ DeployConfig instance when deployment is configured, None otherwise.
983
+ """
984
+ if isinstance(self.deploy, DeployConfig) and self.deploy.enabled:
985
+ return self.deploy
986
+ return None
987
+
988
+ @property
989
+ def logging_config(self) -> LoggingConfig:
990
+ """Get logging configuration.
991
+
992
+ Returns:
993
+ LoggingConfig instance (always available after normalization).
994
+ """
995
+ if isinstance(self.logging, LoggingConfig):
996
+ return self.logging
997
+ return LoggingConfig()