litestar-vite 0.1.1__py3-none-any.whl → 0.15.0rc2__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 (154) hide show
  1. litestar_vite/__init__.py +54 -4
  2. litestar_vite/__metadata__.py +12 -7
  3. litestar_vite/_codegen/__init__.py +26 -0
  4. litestar_vite/_codegen/inertia.py +407 -0
  5. litestar_vite/_codegen/openapi.py +233 -0
  6. litestar_vite/_codegen/routes.py +653 -0
  7. litestar_vite/_codegen/ts.py +235 -0
  8. litestar_vite/_handler/__init__.py +8 -0
  9. litestar_vite/_handler/app.py +524 -0
  10. litestar_vite/_handler/routing.py +130 -0
  11. litestar_vite/cli.py +1147 -10
  12. litestar_vite/codegen.py +39 -0
  13. litestar_vite/commands.py +79 -0
  14. litestar_vite/config.py +1594 -70
  15. litestar_vite/deploy.py +355 -0
  16. litestar_vite/doctor.py +1179 -0
  17. litestar_vite/exceptions.py +78 -0
  18. litestar_vite/executor.py +316 -0
  19. litestar_vite/handler.py +9 -0
  20. litestar_vite/html_transform.py +426 -0
  21. litestar_vite/inertia/__init__.py +53 -0
  22. litestar_vite/inertia/_utils.py +114 -0
  23. litestar_vite/inertia/exception_handler.py +172 -0
  24. litestar_vite/inertia/helpers.py +1043 -0
  25. litestar_vite/inertia/middleware.py +54 -0
  26. litestar_vite/inertia/plugin.py +133 -0
  27. litestar_vite/inertia/request.py +286 -0
  28. litestar_vite/inertia/response.py +706 -0
  29. litestar_vite/inertia/types.py +316 -0
  30. litestar_vite/loader.py +462 -121
  31. litestar_vite/plugin.py +2160 -21
  32. litestar_vite/py.typed +0 -0
  33. litestar_vite/scaffolding/__init__.py +20 -0
  34. litestar_vite/scaffolding/generator.py +270 -0
  35. litestar_vite/scaffolding/templates.py +437 -0
  36. litestar_vite/templates/__init__.py +0 -0
  37. litestar_vite/templates/addons/tailwindcss/tailwind.css.j2 +1 -0
  38. litestar_vite/templates/angular/index.html.j2 +12 -0
  39. litestar_vite/templates/angular/openapi-ts.config.ts.j2 +18 -0
  40. litestar_vite/templates/angular/package.json.j2 +35 -0
  41. litestar_vite/templates/angular/src/app/app.component.css.j2 +3 -0
  42. litestar_vite/templates/angular/src/app/app.component.html.j2 +1 -0
  43. litestar_vite/templates/angular/src/app/app.component.ts.j2 +9 -0
  44. litestar_vite/templates/angular/src/app/app.config.ts.j2 +5 -0
  45. litestar_vite/templates/angular/src/main.ts.j2 +9 -0
  46. litestar_vite/templates/angular/src/styles.css.j2 +9 -0
  47. litestar_vite/templates/angular/tsconfig.app.json.j2 +34 -0
  48. litestar_vite/templates/angular/tsconfig.json.j2 +20 -0
  49. litestar_vite/templates/angular/vite.config.ts.j2 +21 -0
  50. litestar_vite/templates/angular-cli/.postcssrc.json.j2 +5 -0
  51. litestar_vite/templates/angular-cli/angular.json.j2 +36 -0
  52. litestar_vite/templates/angular-cli/openapi-ts.config.ts.j2 +18 -0
  53. litestar_vite/templates/angular-cli/package.json.j2 +27 -0
  54. litestar_vite/templates/angular-cli/proxy.conf.json.j2 +18 -0
  55. litestar_vite/templates/angular-cli/src/app/app.component.css.j2 +3 -0
  56. litestar_vite/templates/angular-cli/src/app/app.component.html.j2 +1 -0
  57. litestar_vite/templates/angular-cli/src/app/app.component.ts.j2 +9 -0
  58. litestar_vite/templates/angular-cli/src/app/app.config.ts.j2 +5 -0
  59. litestar_vite/templates/angular-cli/src/index.html.j2 +13 -0
  60. litestar_vite/templates/angular-cli/src/main.ts.j2 +6 -0
  61. litestar_vite/templates/angular-cli/src/styles.css.j2 +10 -0
  62. litestar_vite/templates/angular-cli/tailwind.config.js.j2 +4 -0
  63. litestar_vite/templates/angular-cli/tsconfig.app.json.j2 +16 -0
  64. litestar_vite/templates/angular-cli/tsconfig.json.j2 +26 -0
  65. litestar_vite/templates/angular-cli/tsconfig.spec.json.j2 +9 -0
  66. litestar_vite/templates/astro/astro.config.mjs.j2 +28 -0
  67. litestar_vite/templates/astro/openapi-ts.config.ts.j2 +15 -0
  68. litestar_vite/templates/astro/src/layouts/Layout.astro.j2 +63 -0
  69. litestar_vite/templates/astro/src/pages/index.astro.j2 +36 -0
  70. litestar_vite/templates/astro/src/styles/global.css.j2 +1 -0
  71. litestar_vite/templates/base/.gitignore.j2 +42 -0
  72. litestar_vite/templates/base/openapi-ts.config.ts.j2 +15 -0
  73. litestar_vite/templates/base/package.json.j2 +38 -0
  74. litestar_vite/templates/base/resources/vite-env.d.ts.j2 +1 -0
  75. litestar_vite/templates/base/tsconfig.json.j2 +37 -0
  76. litestar_vite/templates/htmx/src/main.js.j2 +8 -0
  77. litestar_vite/templates/htmx/templates/base.html.j2.j2 +56 -0
  78. litestar_vite/templates/htmx/templates/index.html.j2.j2 +13 -0
  79. litestar_vite/templates/htmx/vite.config.ts.j2 +40 -0
  80. litestar_vite/templates/nuxt/app.vue.j2 +29 -0
  81. litestar_vite/templates/nuxt/composables/useApi.ts.j2 +33 -0
  82. litestar_vite/templates/nuxt/nuxt.config.ts.j2 +31 -0
  83. litestar_vite/templates/nuxt/openapi-ts.config.ts.j2 +15 -0
  84. litestar_vite/templates/nuxt/pages/index.vue.j2 +54 -0
  85. litestar_vite/templates/react/index.html.j2 +13 -0
  86. litestar_vite/templates/react/src/App.css.j2 +56 -0
  87. litestar_vite/templates/react/src/App.tsx.j2 +19 -0
  88. litestar_vite/templates/react/src/main.tsx.j2 +10 -0
  89. litestar_vite/templates/react/vite.config.ts.j2 +39 -0
  90. litestar_vite/templates/react-inertia/index.html.j2 +14 -0
  91. litestar_vite/templates/react-inertia/package.json.j2 +46 -0
  92. litestar_vite/templates/react-inertia/resources/App.css.j2 +68 -0
  93. litestar_vite/templates/react-inertia/resources/main.tsx.j2 +17 -0
  94. litestar_vite/templates/react-inertia/resources/pages/Home.tsx.j2 +18 -0
  95. litestar_vite/templates/react-inertia/resources/ssr.tsx.j2 +19 -0
  96. litestar_vite/templates/react-inertia/vite.config.ts.j2 +59 -0
  97. litestar_vite/templates/react-router/index.html.j2 +12 -0
  98. litestar_vite/templates/react-router/src/App.css.j2 +17 -0
  99. litestar_vite/templates/react-router/src/App.tsx.j2 +7 -0
  100. litestar_vite/templates/react-router/src/main.tsx.j2 +10 -0
  101. litestar_vite/templates/react-router/vite.config.ts.j2 +39 -0
  102. litestar_vite/templates/react-tanstack/index.html.j2 +12 -0
  103. litestar_vite/templates/react-tanstack/openapi-ts.config.ts.j2 +18 -0
  104. litestar_vite/templates/react-tanstack/src/App.css.j2 +17 -0
  105. litestar_vite/templates/react-tanstack/src/main.tsx.j2 +21 -0
  106. litestar_vite/templates/react-tanstack/src/routeTree.gen.ts.j2 +7 -0
  107. litestar_vite/templates/react-tanstack/src/routes/__root.tsx.j2 +9 -0
  108. litestar_vite/templates/react-tanstack/src/routes/books.tsx.j2 +9 -0
  109. litestar_vite/templates/react-tanstack/src/routes/index.tsx.j2 +9 -0
  110. litestar_vite/templates/react-tanstack/vite.config.ts.j2 +39 -0
  111. litestar_vite/templates/svelte/index.html.j2 +13 -0
  112. litestar_vite/templates/svelte/src/App.svelte.j2 +30 -0
  113. litestar_vite/templates/svelte/src/app.css.j2 +45 -0
  114. litestar_vite/templates/svelte/src/main.ts.j2 +8 -0
  115. litestar_vite/templates/svelte/src/vite-env.d.ts.j2 +2 -0
  116. litestar_vite/templates/svelte/svelte.config.js.j2 +5 -0
  117. litestar_vite/templates/svelte/vite.config.ts.j2 +39 -0
  118. litestar_vite/templates/svelte-inertia/index.html.j2 +14 -0
  119. litestar_vite/templates/svelte-inertia/resources/app.css.j2 +21 -0
  120. litestar_vite/templates/svelte-inertia/resources/main.ts.j2 +11 -0
  121. litestar_vite/templates/svelte-inertia/resources/pages/Home.svelte.j2 +43 -0
  122. litestar_vite/templates/svelte-inertia/resources/vite-env.d.ts.j2 +2 -0
  123. litestar_vite/templates/svelte-inertia/svelte.config.js.j2 +5 -0
  124. litestar_vite/templates/svelte-inertia/vite.config.ts.j2 +37 -0
  125. litestar_vite/templates/sveltekit/openapi-ts.config.ts.j2 +15 -0
  126. litestar_vite/templates/sveltekit/src/app.css.j2 +40 -0
  127. litestar_vite/templates/sveltekit/src/app.html.j2 +12 -0
  128. litestar_vite/templates/sveltekit/src/hooks.server.ts.j2 +55 -0
  129. litestar_vite/templates/sveltekit/src/routes/+layout.svelte.j2 +12 -0
  130. litestar_vite/templates/sveltekit/src/routes/+page.svelte.j2 +34 -0
  131. litestar_vite/templates/sveltekit/svelte.config.js.j2 +12 -0
  132. litestar_vite/templates/sveltekit/tsconfig.json.j2 +14 -0
  133. litestar_vite/templates/sveltekit/vite.config.ts.j2 +31 -0
  134. litestar_vite/templates/vue/env.d.ts.j2 +7 -0
  135. litestar_vite/templates/vue/index.html.j2 +13 -0
  136. litestar_vite/templates/vue/src/App.vue.j2 +28 -0
  137. litestar_vite/templates/vue/src/main.ts.j2 +5 -0
  138. litestar_vite/templates/vue/src/style.css.j2 +45 -0
  139. litestar_vite/templates/vue/vite.config.ts.j2 +39 -0
  140. litestar_vite/templates/vue-inertia/env.d.ts.j2 +7 -0
  141. litestar_vite/templates/vue-inertia/index.html.j2 +14 -0
  142. litestar_vite/templates/vue-inertia/package.json.j2 +49 -0
  143. litestar_vite/templates/vue-inertia/resources/main.ts.j2 +18 -0
  144. litestar_vite/templates/vue-inertia/resources/pages/Home.vue.j2 +22 -0
  145. litestar_vite/templates/vue-inertia/resources/ssr.ts.j2 +21 -0
  146. litestar_vite/templates/vue-inertia/resources/style.css.j2 +21 -0
  147. litestar_vite/templates/vue-inertia/vite.config.ts.j2 +59 -0
  148. litestar_vite-0.15.0rc2.dist-info/METADATA +230 -0
  149. litestar_vite-0.15.0rc2.dist-info/RECORD +151 -0
  150. {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0rc2.dist-info}/WHEEL +1 -1
  151. litestar_vite/template_engine.py +0 -103
  152. litestar_vite-0.1.1.dist-info/METADATA +0 -68
  153. litestar_vite-0.1.1.dist-info/RECORD +0 -11
  154. {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0rc2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,426 @@
1
+ """HTML transformation and injection utilities for SPA output.
2
+
3
+ Regex patterns are compiled once at import time for performance.
4
+ """
5
+
6
+ import re
7
+ from functools import lru_cache, partial
8
+ from typing import Any
9
+
10
+ from litestar.serialization import encode_json
11
+
12
+ _HEAD_END_PATTERN = re.compile(r"</head\s*>", re.IGNORECASE)
13
+ _BODY_END_PATTERN = re.compile(r"</body\s*>", re.IGNORECASE)
14
+ _BODY_START_PATTERN = re.compile(r"<body[^>]*>", re.IGNORECASE)
15
+ _HTML_END_PATTERN = re.compile(r"</html\s*>", re.IGNORECASE)
16
+ _SCRIPT_SRC_PATTERN = re.compile(r'(<script[^>]*\s+src\s*=\s*["\'])([^"\']+)(["\'][^>]*>)', re.IGNORECASE)
17
+ _LINK_HREF_PATTERN = re.compile(r'(<link[^>]*\s+href\s*=\s*["\'])([^"\']+)(["\'][^>]*>)', re.IGNORECASE)
18
+
19
+
20
+ @lru_cache(maxsize=128)
21
+ def _get_id_selector_pattern(element_id: str) -> re.Pattern[str]:
22
+ """Return a compiled regex pattern for an ID selector.
23
+
24
+ Returns:
25
+ Pattern matching an element with the given ID.
26
+ """
27
+ return re.compile(
28
+ rf'(<[a-zA-Z][a-zA-Z0-9]*\s+[^>]*id\s*=\s*["\']?{re.escape(element_id)}["\']?[^>]*)(>)', re.IGNORECASE
29
+ )
30
+
31
+
32
+ @lru_cache(maxsize=128)
33
+ def _get_element_selector_pattern(element_name: str) -> re.Pattern[str]:
34
+ """Return a compiled regex pattern for an element selector.
35
+
36
+ Returns:
37
+ Pattern matching elements with the given tag name.
38
+ """
39
+ return re.compile(rf"(<{re.escape(element_name)}[^>]*)(>)", re.IGNORECASE)
40
+
41
+
42
+ @lru_cache(maxsize=128)
43
+ def _get_attr_pattern(attr: str) -> re.Pattern[str]:
44
+ """Return a compiled regex pattern for an attribute.
45
+
46
+ Returns:
47
+ Pattern matching the attribute with its value.
48
+ """
49
+ return re.compile(rf'{re.escape(attr)}\s*=\s*["\'][^"\']*["\']', re.IGNORECASE)
50
+
51
+
52
+ @lru_cache(maxsize=128)
53
+ def _get_id_element_with_content_pattern(element_id: str) -> re.Pattern[str]:
54
+ """Return a compiled regex pattern to match an element by ID and capture its inner HTML.
55
+
56
+ The pattern matches: <tag ... id="element_id" ...> ... </tag>
57
+ and captures the opening tag, the inner content, and the closing tag.
58
+
59
+ Returns:
60
+ Pattern matching an element with the given ID, capturing its inner HTML.
61
+ """
62
+ return re.compile(
63
+ rf"(<(?P<tag>[a-zA-Z0-9]+)(?P<attrs>[^>]*\bid=[\"']{re.escape(element_id)}[\"'][^>]*)>)(?P<inner>.*?)(</(?P=tag)\s*>)",
64
+ flags=re.IGNORECASE | re.DOTALL,
65
+ )
66
+
67
+
68
+ def _escape_script(script: str) -> str:
69
+ r"""Escape script content to prevent breaking out of script tags.
70
+
71
+ Replaces ``</script>`` with ``<\/script>`` to prevent premature tag closure.
72
+
73
+ Args:
74
+ script: The script content to escape.
75
+
76
+ Returns:
77
+ The escaped script content safe for embedding in ``<script>`` tags.
78
+ """
79
+ return script.replace("</script>", r"<\/script>")
80
+
81
+
82
+ def _escape_attr(value: str) -> str:
83
+ """Escape attribute value for safe HTML embedding.
84
+
85
+ Escapes special HTML characters: ``&``, ``"``, ``'``, ``<``, ``>``.
86
+
87
+ Args:
88
+ value: The attribute value to escape.
89
+
90
+ Returns:
91
+ The escaped value safe for use in HTML attribute values.
92
+ """
93
+ return (
94
+ value.replace("&", "&amp;")
95
+ .replace('"', "&quot;")
96
+ .replace("'", "&#39;")
97
+ .replace("<", "&lt;")
98
+ .replace(">", "&gt;")
99
+ )
100
+
101
+
102
+ def _set_attribute_replacer(
103
+ match: re.Match[str], *, attr_pattern: re.Pattern[str], attr_name: str, escaped_val: str
104
+ ) -> str:
105
+ """Replace or add an attribute on an opening tag match.
106
+
107
+ Args:
108
+ match: Regex match capturing the opening portion and closing delimiter.
109
+ attr_pattern: Compiled pattern that matches the attribute assignment.
110
+ attr_name: Attribute name to set.
111
+ escaped_val: Escaped attribute value.
112
+
113
+ Returns:
114
+ Updated tag string with ``attr_name`` set to ``escaped_val``.
115
+ """
116
+ opening = match.group(1)
117
+ closing = match.group(2)
118
+ if attr_pattern.search(opening):
119
+ opening = attr_pattern.sub(f'{attr_name}="{escaped_val}"', opening)
120
+ else:
121
+ opening = opening.rstrip() + f' {attr_name}="{escaped_val}"'
122
+ return opening + closing
123
+
124
+
125
+ def _set_inner_html_replacer(match: re.Match[str], *, content: str) -> str:
126
+ """Replace inner HTML for an ID-targeted element match.
127
+
128
+ Args:
129
+ match: Regex match from ``_get_id_element_with_content_pattern``.
130
+ content: Raw HTML to inject as the element's inner HTML.
131
+
132
+ Returns:
133
+ Updated HTML fragment with replaced inner content.
134
+ """
135
+ return match.group(1) + content + match.group(5)
136
+
137
+
138
+ def inject_head_script(html: str, script: str, *, escape: bool = True) -> str:
139
+ """Inject a script tag before the closing </head> tag.
140
+
141
+ Args:
142
+ html: The HTML document.
143
+ script: The JavaScript code to inject (without <script> tags).
144
+ escape: Whether to escape the script content. Default True.
145
+
146
+ Returns:
147
+ The HTML with the injected script. If ``</head>`` is not found,
148
+ falls back to injecting before ``</html>``. If neither is found,
149
+ appends the script at the end. Returns the original HTML unchanged
150
+ if ``script`` is empty.
151
+
152
+ Example:
153
+ html = inject_head_script(html, "window.__DATA__ = {foo: 1};")
154
+ """
155
+ if not script:
156
+ return html
157
+
158
+ if escape:
159
+ script = _escape_script(script)
160
+
161
+ script_tag = f"<script>{script}</script>\n"
162
+
163
+ head_end_match = _HEAD_END_PATTERN.search(html)
164
+ if head_end_match:
165
+ pos = head_end_match.start()
166
+ return html[:pos] + script_tag + html[pos:]
167
+
168
+ html_end_match = _HTML_END_PATTERN.search(html)
169
+ if html_end_match:
170
+ pos = html_end_match.start()
171
+ return html[:pos] + script_tag + html[pos:]
172
+
173
+ return html + "\n" + script_tag
174
+
175
+
176
+ def inject_head_html(html: str, content: str) -> str:
177
+ """Inject raw HTML into the ``<head>`` section.
178
+
179
+ This is used for Inertia SSR, where the SSR server returns an array of HTML strings
180
+ (typically ``<title>``, ``<meta>``, etc.) that must be placed in the final HTML response.
181
+
182
+ Args:
183
+ html: The HTML document.
184
+ content: Raw HTML to inject. This is inserted as-is.
185
+
186
+ Returns:
187
+ The HTML with the content injected before ``</head>`` when present.
188
+ Falls back to injecting before ``</html>`` or appending at the end.
189
+ """
190
+ if not content:
191
+ return html
192
+
193
+ head_end_match = _HEAD_END_PATTERN.search(html)
194
+ if head_end_match:
195
+ pos = head_end_match.start()
196
+ return html[:pos] + content + "\n" + html[pos:]
197
+
198
+ html_end_match = _HTML_END_PATTERN.search(html)
199
+ if html_end_match:
200
+ pos = html_end_match.start()
201
+ return html[:pos] + content + "\n" + html[pos:]
202
+
203
+ return html + "\n" + content
204
+
205
+
206
+ def inject_body_content(html: str, content: str, *, position: str = "end") -> str:
207
+ """Inject content into the body element.
208
+
209
+ Args:
210
+ html: The HTML document.
211
+ content: The content to inject (can include HTML tags).
212
+ position: Where to inject - "start" (after <body>) or "end" (before </body>).
213
+
214
+ Returns:
215
+ The HTML with the injected content. Returns the original HTML unchanged
216
+ if ``content`` is empty or if no ``<body>`` tag is found.
217
+
218
+ Example:
219
+ html = inject_body_content(html, '<div id="portal"></div>', position="end")
220
+ """
221
+ if not content:
222
+ return html
223
+
224
+ if position == "end":
225
+ body_end_match = _BODY_END_PATTERN.search(html)
226
+ if body_end_match:
227
+ pos = body_end_match.start()
228
+ return html[:pos] + content + "\n" + html[pos:]
229
+
230
+ elif position == "start":
231
+ body_start_match = _BODY_START_PATTERN.search(html)
232
+ if body_start_match:
233
+ pos = body_start_match.end()
234
+ return html[:pos] + "\n" + content + html[pos:]
235
+
236
+ return html
237
+
238
+
239
+ def set_data_attribute(html: str, selector: str, attr: str, value: str) -> str:
240
+ """Set a data attribute on an element matching the selector.
241
+
242
+ This function supports simple ID selectors (#id) and element selectors (div).
243
+ For complex selectors, consider using a proper HTML parser.
244
+
245
+ Args:
246
+ html: The HTML document.
247
+ selector: CSS-like selector (currently supports #id and element names).
248
+ attr: The attribute name (e.g., "data-page").
249
+ value: The attribute value (will be HTML-escaped automatically).
250
+
251
+ Returns:
252
+ The HTML with the attribute set. If the attribute already exists, it is
253
+ replaced. Returns the original HTML unchanged if ``selector`` or ``attr``
254
+ is empty, or if no matching element is found.
255
+
256
+ Note:
257
+ Only the first matching element is modified. The value is automatically
258
+ escaped to prevent XSS vulnerabilities.
259
+
260
+ Example:
261
+ html = set_data_attribute(html, "#app", "data-page", '{"component":"Home"}')
262
+ """
263
+ if not selector or not attr:
264
+ return html
265
+
266
+ escaped_value = _escape_attr(value)
267
+ attr_pattern = _get_attr_pattern(attr)
268
+ replacer = partial(_set_attribute_replacer, attr_pattern=attr_pattern, attr_name=attr, escaped_val=escaped_value)
269
+
270
+ if selector.startswith("#"):
271
+ element_id = selector[1:]
272
+ pattern = _get_id_selector_pattern(element_id)
273
+ return pattern.sub(replacer, html, count=1)
274
+
275
+ element_name = selector.lower()
276
+ pattern = _get_element_selector_pattern(element_name)
277
+ return pattern.sub(replacer, html, count=1)
278
+
279
+
280
+ def set_element_inner_html(html: str, selector: str, content: str) -> str:
281
+ """Replace the inner HTML of an element matching the selector.
282
+
283
+ Supports only simple ID selectors (``#app``). This is intentionally limited to avoid
284
+ the overhead and edge cases of a full HTML parser.
285
+
286
+ Args:
287
+ html: The HTML document.
288
+ selector: The selector (only ``#id`` supported).
289
+ content: The raw HTML to set as the element's innerHTML.
290
+
291
+ Returns:
292
+ Updated HTML. If no matching element is found, returns the original HTML.
293
+ """
294
+ if not selector or not selector.startswith("#"):
295
+ return html
296
+
297
+ element_id = selector[1:]
298
+ pattern = _get_id_element_with_content_pattern(element_id)
299
+ replacer = partial(_set_inner_html_replacer, content=content)
300
+ return pattern.sub(replacer, html, count=1)
301
+
302
+
303
+ def inject_json_script(html: str, var_name: str, data: dict[str, Any]) -> str:
304
+ """Inject a script that sets a global JavaScript variable to JSON data.
305
+
306
+ This is a convenience function for injecting structured data into the page.
307
+ The data is serialized with compact JSON (no extra whitespace) and non-ASCII
308
+ characters are preserved.
309
+
310
+ Args:
311
+ html: The HTML document.
312
+ var_name: The global variable name (e.g., "__LITESTAR_ROUTES__").
313
+ data: The data to serialize as JSON.
314
+
315
+ Returns:
316
+ The HTML with the injected script in the ``<head>`` section. Falls back
317
+ to injecting before ``</html>`` or at the end if no ``</head>`` is found.
318
+
319
+ Note:
320
+ The script content is NOT escaped to preserve valid JSON. Ensure that
321
+ ``data`` does not contain user-controlled content that could include
322
+ malicious ``</script>`` sequences.
323
+
324
+ Example:
325
+ html = inject_json_script(html, "__ROUTES__", {"home": "/", "about": "/about"})
326
+ """
327
+ json_data = encode_json(data).decode("utf-8")
328
+ script = f"window.{var_name} = {json_data};"
329
+ return inject_head_script(html, script, escape=False)
330
+
331
+
332
+ def transform_asset_urls(
333
+ html: str, manifest: dict[str, Any], asset_url: str = "/static/", base_url: str | None = None
334
+ ) -> str:
335
+ """Transform asset URLs in HTML based on Vite manifest.
336
+
337
+ This function replaces source asset paths (e.g., /resources/main.tsx)
338
+ with their hashed production equivalents from the Vite manifest
339
+ (e.g., /static/assets/main-C-_c4FS5.js).
340
+
341
+ This is essential for production mode when using Vite's library mode
342
+ (input: ["resources/main.tsx"]) where Vite doesn't transform index.html.
343
+
344
+ Args:
345
+ html: The HTML document to transform.
346
+ manifest: The Vite manifest dictionary mapping source paths to output.
347
+ Each entry should have a ``file`` key with the hashed output path.
348
+ asset_url: Base URL for assets (default "/static/").
349
+ base_url: Optional CDN base URL override for production assets. When
350
+ provided, takes precedence over ``asset_url``.
351
+
352
+ Returns:
353
+ The HTML with transformed asset URLs. Returns the original HTML unchanged
354
+ if ``manifest`` is empty. Asset paths not found in the manifest are left
355
+ unchanged (no error is raised).
356
+
357
+ Note:
358
+ This function transforms ``<script src="...">`` and ``<link href="...">``
359
+ attributes. Leading slashes in source paths are normalized for manifest
360
+ lookup (e.g., "/resources/main.tsx" matches "resources/main.tsx" in manifest).
361
+
362
+ Example:
363
+ manifest = {"resources/main.tsx": {"file": "assets/main-abc123.js"}}
364
+ html = '<script type="module" src="/resources/main.tsx"></script>'
365
+ result = transform_asset_urls(html, manifest)
366
+ # Result: '<script type="module" src="/static/assets/main-abc123.js"></script>'
367
+ """
368
+ if not manifest:
369
+ return html
370
+
371
+ url_base = base_url or asset_url
372
+
373
+ def _normalize_path(path: str) -> str:
374
+ """Normalize a path for manifest lookup by removing leading slash.
375
+
376
+ Returns:
377
+ The normalized path without leading slash.
378
+ """
379
+ return path.lstrip("/")
380
+
381
+ def _build_url(file_path: str) -> str:
382
+ """Build the full URL for an asset file.
383
+
384
+ Returns:
385
+ The full URL combining base and file path.
386
+ """
387
+ base = url_base if url_base.endswith("/") else url_base + "/"
388
+ return base + file_path
389
+
390
+ def replace_script_src(match: re.Match[str]) -> str:
391
+ """Replace script src with manifest lookup.
392
+
393
+ Returns:
394
+ The transformed script tag with updated src, or original if not found.
395
+ """
396
+ prefix = match.group(1)
397
+ src = match.group(2)
398
+ suffix = match.group(3)
399
+
400
+ normalized = _normalize_path(src)
401
+ if normalized in manifest:
402
+ entry = manifest[normalized]
403
+ new_src = _build_url(entry.get("file", src))
404
+ return prefix + new_src + suffix
405
+ return match.group(0)
406
+
407
+ def replace_link_href(match: re.Match[str]) -> str:
408
+ """Replace link href with manifest lookup.
409
+
410
+ Returns:
411
+ The transformed link tag with updated href, or original if not found.
412
+ """
413
+ prefix = match.group(1)
414
+ href = match.group(2)
415
+ suffix = match.group(3)
416
+
417
+ normalized = _normalize_path(href)
418
+ if normalized in manifest:
419
+ entry = manifest[normalized]
420
+ new_href = _build_url(entry.get("file", href))
421
+ return prefix + new_href + suffix
422
+ return match.group(0)
423
+
424
+ html = _SCRIPT_SRC_PATTERN.sub(replace_script_src, html)
425
+
426
+ return _LINK_HREF_PATTERN.sub(replace_link_href, html)
@@ -0,0 +1,53 @@
1
+ from litestar_vite.config import InertiaConfig
2
+ from litestar_vite.inertia import helpers
3
+ from litestar_vite.inertia.exception_handler import create_inertia_exception_response, exception_to_http_response
4
+ from litestar_vite.inertia.helpers import (
5
+ PropFilter,
6
+ clear_history,
7
+ defer,
8
+ error,
9
+ except_,
10
+ extract_deferred_props,
11
+ extract_merge_props,
12
+ flash,
13
+ get_shared_props,
14
+ lazy,
15
+ merge,
16
+ only,
17
+ scroll_props,
18
+ share,
19
+ )
20
+ from litestar_vite.inertia.middleware import InertiaMiddleware
21
+ from litestar_vite.inertia.plugin import InertiaPlugin
22
+ from litestar_vite.inertia.request import InertiaDetails, InertiaHeaders, InertiaRequest
23
+ from litestar_vite.inertia.response import InertiaBack, InertiaExternalRedirect, InertiaRedirect, InertiaResponse
24
+
25
+ __all__ = (
26
+ "InertiaBack",
27
+ "InertiaConfig",
28
+ "InertiaDetails",
29
+ "InertiaExternalRedirect",
30
+ "InertiaHeaders",
31
+ "InertiaMiddleware",
32
+ "InertiaPlugin",
33
+ "InertiaRedirect",
34
+ "InertiaRequest",
35
+ "InertiaResponse",
36
+ "PropFilter",
37
+ "clear_history",
38
+ "create_inertia_exception_response",
39
+ "defer",
40
+ "error",
41
+ "except_",
42
+ "exception_to_http_response",
43
+ "extract_deferred_props",
44
+ "extract_merge_props",
45
+ "flash",
46
+ "get_shared_props",
47
+ "helpers",
48
+ "lazy",
49
+ "merge",
50
+ "only",
51
+ "scroll_props",
52
+ "share",
53
+ )
@@ -0,0 +1,114 @@
1
+ from enum import Enum
2
+ from typing import TYPE_CHECKING, Any
3
+
4
+ if TYPE_CHECKING:
5
+ from collections.abc import Callable
6
+
7
+ from litestar_vite.inertia.types import InertiaHeaderType
8
+
9
+
10
+ class InertiaHeaders(str, Enum):
11
+ """Enum for Inertia Headers.
12
+
13
+ See: https://inertiajs.com/the-protocol
14
+
15
+ This includes both core protocol headers and v2 extensions (partial excludes, reset, error bags,
16
+ and infinite scroll merge intent).
17
+ """
18
+
19
+ ENABLED = "X-Inertia"
20
+ VERSION = "X-Inertia-Version"
21
+ LOCATION = "X-Inertia-Location"
22
+ REFERER = "Referer"
23
+
24
+ PARTIAL_DATA = "X-Inertia-Partial-Data"
25
+ PARTIAL_COMPONENT = "X-Inertia-Partial-Component"
26
+ PARTIAL_EXCEPT = "X-Inertia-Partial-Except"
27
+
28
+ RESET = "X-Inertia-Reset"
29
+ ERROR_BAG = "X-Inertia-Error-Bag"
30
+
31
+ INFINITE_SCROLL_MERGE_INTENT = "X-Inertia-Infinite-Scroll-Merge-Intent"
32
+
33
+
34
+ def get_enabled_header(enabled: bool = True) -> "dict[str, Any]":
35
+ """True if inertia is enabled.
36
+
37
+ Args:
38
+ enabled: Whether inertia is enabled.
39
+
40
+ Returns:
41
+ The headers for inertia.
42
+ """
43
+
44
+ return {InertiaHeaders.ENABLED.value: "true" if enabled else "false"}
45
+
46
+
47
+ def get_version_header(version: str) -> "dict[str, Any]":
48
+ """Return headers for change swap method response.
49
+
50
+ Args:
51
+ version: The version of the inertia.
52
+
53
+ Returns:
54
+ The headers for inertia.
55
+ """
56
+ return {InertiaHeaders.VERSION.value: version}
57
+
58
+
59
+ def get_partial_data_header(partial: str) -> "dict[str, Any]":
60
+ """Return headers for a partial data response.
61
+
62
+ Args:
63
+ partial: The partial data.
64
+
65
+ Returns:
66
+ The headers for inertia.
67
+ """
68
+ return {InertiaHeaders.PARTIAL_DATA.value: partial}
69
+
70
+
71
+ def get_partial_component_header(partial: str) -> "dict[str, Any]":
72
+ """Return headers for a partial data response.
73
+
74
+ Args:
75
+ partial: The partial data.
76
+
77
+ Returns:
78
+ The headers for inertia.
79
+ """
80
+ return {InertiaHeaders.PARTIAL_COMPONENT.value: partial}
81
+
82
+
83
+ def get_headers(inertia_headers: "InertiaHeaderType") -> "dict[str, Any]":
84
+ """Return headers for Inertia responses.
85
+
86
+ Args:
87
+ inertia_headers: The inertia headers.
88
+
89
+ Raises:
90
+ ValueError: If the inertia headers are None.
91
+
92
+ Returns:
93
+ The headers for inertia.
94
+ """
95
+ if not inertia_headers:
96
+ msg = "Value for inertia_headers cannot be None."
97
+ raise ValueError(msg)
98
+ inertia_headers_dict: "dict[str, Callable[..., dict[str, Any]]]" = {
99
+ "enabled": get_enabled_header,
100
+ "partial_data": get_partial_data_header,
101
+ "partial_component": get_partial_component_header,
102
+ "version": get_version_header,
103
+ }
104
+
105
+ header: "dict[str, Any]" = {}
106
+ response: "dict[str, Any]"
107
+ key: "str"
108
+ value: "Any"
109
+
110
+ for key, value in inertia_headers.items():
111
+ if value is not None:
112
+ response = inertia_headers_dict[key](value)
113
+ header.update(response)
114
+ return header