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,130 @@
1
+ """SPA route handlers and routing helpers."""
2
+
3
+ from contextlib import suppress
4
+ from typing import TYPE_CHECKING, Any, cast
5
+
6
+ from litestar import Response
7
+ from litestar.exceptions import ImproperlyConfiguredException, NotFoundException
8
+
9
+ from litestar_vite.plugin import is_litestar_route
10
+
11
+ if TYPE_CHECKING:
12
+ from litestar.connection import Request
13
+
14
+
15
+ _HTML_MEDIA_TYPE = "text/html; charset=utf-8"
16
+
17
+
18
+ def is_static_asset_path(request_path: str, asset_prefix: str | None) -> bool:
19
+ """Check if a request path targets static assets rather than SPA routes.
20
+
21
+ Args:
22
+ request_path: Incoming request path.
23
+ asset_prefix: Normalized asset URL prefix (e.g., ``/static``) or None.
24
+
25
+ Returns:
26
+ True when ``request_path`` matches the asset prefix (or a descendant path), otherwise False.
27
+ """
28
+ if not asset_prefix:
29
+ return False
30
+ return request_path == asset_prefix or request_path.startswith(f"{asset_prefix}/")
31
+
32
+
33
+ def get_route_opt(request: "Request[Any, Any, Any]") -> "dict[str, Any] | None":
34
+ """Return the current route handler opt dict when available.
35
+
36
+ Returns:
37
+ The route handler ``opt`` mapping, or None if unavailable.
38
+ """
39
+ route_handler = request.scope.get("route_handler") # pyright: ignore[reportUnknownMemberType]
40
+ with suppress(AttributeError):
41
+ opt_any = cast("Any", route_handler).opt
42
+ return cast("dict[str, Any] | None", opt_any)
43
+ return None # pragma: no cover
44
+
45
+
46
+ def get_route_asset_prefix(request: "Request[Any, Any, Any]") -> str | None:
47
+ """Get the static asset prefix for the current SPA route handler.
48
+
49
+ Returns:
50
+ The asset URL prefix for this SPA route, or None if not configured.
51
+ """
52
+ opt = get_route_opt(request)
53
+ if opt is None:
54
+ return None
55
+ asset_prefix = opt.get("_vite_asset_prefix")
56
+ if isinstance(asset_prefix, str) and asset_prefix:
57
+ return asset_prefix
58
+ return None
59
+
60
+
61
+ def get_spa_handler_from_request(request: "Request[Any, Any, Any]") -> Any:
62
+ """Resolve the SPA handler instance for the current request.
63
+
64
+ This is stored on the SPA route handler's ``opt`` when the route is created.
65
+
66
+ Args:
67
+ request: Incoming request.
68
+
69
+ Returns:
70
+ The configured SPA handler instance.
71
+
72
+ Raises:
73
+ ImproperlyConfiguredException: If the SPA handler is not available on the route metadata.
74
+ """
75
+ opt = get_route_opt(request)
76
+ handler = opt.get("_vite_spa_handler") if opt is not None else None
77
+ if handler is not None:
78
+ try:
79
+ _ = handler.get_html
80
+ _ = handler.get_bytes
81
+ except AttributeError:
82
+ pass
83
+ else:
84
+ return handler
85
+ msg = "SPA handler is not available for this route. Ensure AppHandler.create_route_handler() was used."
86
+ raise ImproperlyConfiguredException(msg)
87
+
88
+
89
+ async def spa_handler_dev(request: "Request[Any, Any, Any]") -> Response[str]:
90
+ """Serve the SPA HTML (dev mode - proxied from Vite).
91
+
92
+ Checks if the request path matches a static asset or Litestar route before serving.
93
+
94
+ Raises:
95
+ NotFoundException: If the path matches a static asset or Litestar route.
96
+
97
+ Returns:
98
+ The HTML response from the Vite dev server.
99
+ """
100
+ path = request.url.path
101
+ asset_prefix = get_route_asset_prefix(request)
102
+ if is_static_asset_path(path, asset_prefix):
103
+ raise NotFoundException(detail=f"Static asset path: {path}")
104
+ if path != "/" and is_litestar_route(path, request.app):
105
+ raise NotFoundException(detail=f"Not an SPA route: {path}")
106
+
107
+ spa_handler = get_spa_handler_from_request(request)
108
+ html = await spa_handler.get_html(request)
109
+ return Response(content=html, status_code=200, media_type="text/html")
110
+
111
+
112
+ async def spa_handler_prod(request: "Request[Any, Any, Any]") -> Response[bytes]:
113
+ """Serve the SPA HTML (production - cached).
114
+
115
+ Raises:
116
+ NotFoundException: If the path matches a static asset or Litestar route.
117
+
118
+ Returns:
119
+ HTML bytes response from the cached SPA handler.
120
+ """
121
+ path = request.url.path
122
+ asset_prefix = get_route_asset_prefix(request)
123
+ if is_static_asset_path(path, asset_prefix):
124
+ raise NotFoundException(detail=f"Static asset path: {path}")
125
+ if path != "/" and is_litestar_route(path, request.app):
126
+ raise NotFoundException(detail=f"Not an SPA route: {path}")
127
+
128
+ spa_handler = get_spa_handler_from_request(request)
129
+ body = await spa_handler.get_bytes()
130
+ return Response(content=body, status_code=200, media_type=_HTML_MEDIA_TYPE)
@@ -0,0 +1,569 @@
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
95
+ .replace("&", "&amp;")
96
+ .replace('"', "&quot;")
97
+ .replace("'", "&#39;")
98
+ .replace("<", "&lt;")
99
+ .replace(">", "&gt;")
100
+ )
101
+
102
+
103
+ def _set_attribute_replacer(
104
+ match: re.Match[str], *, attr_pattern: re.Pattern[str], attr_name: str, escaped_val: str
105
+ ) -> str:
106
+ """Replace or add an attribute on an opening tag match.
107
+
108
+ Args:
109
+ match: Regex match capturing the opening portion and closing delimiter.
110
+ attr_pattern: Compiled pattern that matches the attribute assignment.
111
+ attr_name: Attribute name to set.
112
+ escaped_val: Escaped attribute value.
113
+
114
+ Returns:
115
+ Updated tag string with ``attr_name`` set to ``escaped_val``.
116
+ """
117
+ opening = match.group(1)
118
+ closing = match.group(2)
119
+ if attr_pattern.search(opening):
120
+ opening = attr_pattern.sub(f'{attr_name}="{escaped_val}"', opening)
121
+ else:
122
+ opening = opening.rstrip() + f' {attr_name}="{escaped_val}"'
123
+ return opening + closing
124
+
125
+
126
+ def _set_inner_html_replacer(match: re.Match[str], *, content: str) -> str:
127
+ """Replace inner HTML for an ID-targeted element match.
128
+
129
+ Args:
130
+ match: Regex match from ``_get_id_element_with_content_pattern``.
131
+ content: Raw HTML to inject as the element's inner HTML.
132
+
133
+ Returns:
134
+ Updated HTML fragment with replaced inner content.
135
+ """
136
+ return match.group(1) + content + match.group(5)
137
+
138
+
139
+ def inject_head_script(html: str, script: str, *, escape: bool = True, nonce: str | None = None) -> str:
140
+ """Inject a script tag before the closing </head> tag.
141
+
142
+ Args:
143
+ html: The HTML document.
144
+ script: The JavaScript code to inject (without <script> tags).
145
+ escape: Whether to escape the script content. Default True.
146
+ nonce: Optional CSP nonce to add to the injected ``<script>`` tag.
147
+
148
+ Returns:
149
+ The HTML with the injected script. If ``</head>`` is not found,
150
+ falls back to injecting before ``</html>``. If neither is found,
151
+ appends the script at the end. Returns the original HTML unchanged
152
+ if ``script`` is empty.
153
+
154
+ Example:
155
+ html = inject_head_script(html, "window.__DATA__ = {foo: 1};")
156
+ """
157
+ if not script:
158
+ return html
159
+
160
+ if escape:
161
+ script = _escape_script(script)
162
+
163
+ nonce_attr = f' nonce="{_escape_attr(nonce)}"' if nonce else ""
164
+ script_tag = f"<script{nonce_attr}>{script}</script>\n"
165
+
166
+ head_end_match = _HEAD_END_PATTERN.search(html)
167
+ if head_end_match:
168
+ pos = head_end_match.start()
169
+ return html[:pos] + script_tag + html[pos:]
170
+
171
+ html_end_match = _HTML_END_PATTERN.search(html)
172
+ if html_end_match:
173
+ pos = html_end_match.start()
174
+ return html[:pos] + script_tag + html[pos:]
175
+
176
+ return html + "\n" + script_tag
177
+
178
+
179
+ def inject_head_html(html: str, content: str) -> str:
180
+ """Inject raw HTML into the ``<head>`` section.
181
+
182
+ This is used for Inertia SSR, where the SSR server returns an array of HTML strings
183
+ (typically ``<title>``, ``<meta>``, etc.) that must be placed in the final HTML response.
184
+
185
+ Args:
186
+ html: The HTML document.
187
+ content: Raw HTML to inject. This is inserted as-is.
188
+
189
+ Returns:
190
+ The HTML with the content injected before ``</head>`` when present.
191
+ Falls back to injecting before ``</html>`` or appending at the end.
192
+ """
193
+ if not content:
194
+ return html
195
+
196
+ head_end_match = _HEAD_END_PATTERN.search(html)
197
+ if head_end_match:
198
+ pos = head_end_match.start()
199
+ return html[:pos] + content + "\n" + html[pos:]
200
+
201
+ html_end_match = _HTML_END_PATTERN.search(html)
202
+ if html_end_match:
203
+ pos = html_end_match.start()
204
+ return html[:pos] + content + "\n" + html[pos:]
205
+
206
+ return html + "\n" + content
207
+
208
+
209
+ def inject_body_content(html: str, content: str, *, position: str = "end") -> str:
210
+ """Inject content into the body element.
211
+
212
+ Args:
213
+ html: The HTML document.
214
+ content: The content to inject (can include HTML tags).
215
+ position: Where to inject - "start" (after <body>) or "end" (before </body>).
216
+
217
+ Returns:
218
+ The HTML with the injected content. Returns the original HTML unchanged
219
+ if ``content`` is empty or if no ``<body>`` tag is found.
220
+
221
+ Example:
222
+ html = inject_body_content(html, '<div id="portal"></div>', position="end")
223
+ """
224
+ if not content:
225
+ return html
226
+
227
+ if position == "end":
228
+ body_end_match = _BODY_END_PATTERN.search(html)
229
+ if body_end_match:
230
+ pos = body_end_match.start()
231
+ return html[:pos] + content + "\n" + html[pos:]
232
+
233
+ elif position == "start":
234
+ body_start_match = _BODY_START_PATTERN.search(html)
235
+ if body_start_match:
236
+ pos = body_start_match.end()
237
+ return html[:pos] + "\n" + content + html[pos:]
238
+
239
+ return html
240
+
241
+
242
+ def set_data_attribute(html: str, selector: str, attr: str, value: str) -> str:
243
+ """Set a data attribute on an element matching the selector.
244
+
245
+ This function supports simple ID selectors (#id) and element selectors (div).
246
+ For complex selectors, consider using a proper HTML parser.
247
+
248
+ Args:
249
+ html: The HTML document.
250
+ selector: CSS-like selector (currently supports #id and element names).
251
+ attr: The attribute name (e.g., "data-page").
252
+ value: The attribute value (will be HTML-escaped automatically).
253
+
254
+ Returns:
255
+ The HTML with the attribute set. If the attribute already exists, it is
256
+ replaced. Returns the original HTML unchanged if ``selector`` or ``attr``
257
+ is empty, or if no matching element is found.
258
+
259
+ Note:
260
+ Only the first matching element is modified. The value is automatically
261
+ escaped to prevent XSS vulnerabilities.
262
+
263
+ Example:
264
+ html = set_data_attribute(html, "#app", "data-page", '{"component":"Home"}')
265
+ """
266
+ if not selector or not attr:
267
+ return html
268
+
269
+ escaped_value = _escape_attr(value)
270
+ attr_pattern = _get_attr_pattern(attr)
271
+ replacer = partial(_set_attribute_replacer, attr_pattern=attr_pattern, attr_name=attr, escaped_val=escaped_value)
272
+
273
+ if selector.startswith("#"):
274
+ element_id = selector[1:]
275
+ pattern = _get_id_selector_pattern(element_id)
276
+ return pattern.sub(replacer, html, count=1)
277
+
278
+ element_name = selector.lower()
279
+ pattern = _get_element_selector_pattern(element_name)
280
+ return pattern.sub(replacer, html, count=1)
281
+
282
+
283
+ def set_element_inner_html(html: str, selector: str, content: str) -> str:
284
+ """Replace the inner HTML of an element matching the selector.
285
+
286
+ Supports only simple ID selectors (``#app``). This is intentionally limited to avoid
287
+ the overhead and edge cases of a full HTML parser.
288
+
289
+ Args:
290
+ html: The HTML document.
291
+ selector: The selector (only ``#id`` supported).
292
+ content: The raw HTML to set as the element's innerHTML.
293
+
294
+ Returns:
295
+ Updated HTML. If no matching element is found, returns the original HTML.
296
+ """
297
+ if not selector or not selector.startswith("#"):
298
+ return html
299
+
300
+ element_id = selector[1:]
301
+ pattern = _get_id_element_with_content_pattern(element_id)
302
+ replacer = partial(_set_inner_html_replacer, content=content)
303
+ return pattern.sub(replacer, html, count=1)
304
+
305
+
306
+ def inject_page_script(html: str, json_data: str, *, nonce: str | None = None, script_id: str = "app_page") -> str:
307
+ r"""Inject page data as a JSON script element before ``</body>``.
308
+
309
+ This is an Inertia.js v2.3+ optimization that embeds page data in a
310
+ ``<script type="application/json">`` element instead of a ``data-page`` attribute.
311
+ This provides ~37% payload reduction for large pages by avoiding HTML entity escaping.
312
+
313
+ The script element is inserted before ``</body>`` with:
314
+ - ``type="application/json"`` (non-executable, just data)
315
+ - ``id="app_page"`` (Inertia's expected ID for useScriptElementForInitialPage)
316
+ - Optional ``nonce`` for CSP compliance
317
+
318
+ Args:
319
+ html: The HTML document.
320
+ json_data: Pre-serialized JSON string (page props).
321
+ nonce: Optional CSP nonce to add to the script element.
322
+ script_id: The script element ID (default "app_page" per Inertia protocol).
323
+
324
+ Returns:
325
+ The HTML with the script element injected before ``</body>``.
326
+ Falls back to appending at the end if no ``</body>`` tag is found.
327
+
328
+ Note:
329
+ The JSON content is escaped to prevent XSS via ``</script>`` injection.
330
+ Sequences like ``</`` are replaced with ``<\\/`` (escaped forward slash)
331
+ which is valid JSON and prevents HTML parser issues.
332
+
333
+ Example:
334
+ html = inject_page_script(html, '{"component":"Home","props":{}}')
335
+ """
336
+ if not json_data:
337
+ return html
338
+
339
+ # Escape sequences that could break out of script element
340
+ # Replace </ with <\/ to prevent premature tag closure (XSS prevention)
341
+ escaped_json = json_data.replace("</", r"<\/")
342
+
343
+ nonce_attr = f' nonce="{_escape_attr(nonce)}"' if nonce else ""
344
+ script_tag = f'<script type="application/json" id="{script_id}"{nonce_attr}>{escaped_json}</script>\n'
345
+
346
+ body_end_match = _BODY_END_PATTERN.search(html)
347
+ if body_end_match:
348
+ pos = body_end_match.start()
349
+ return html[:pos] + script_tag + html[pos:]
350
+
351
+ return html + "\n" + script_tag
352
+
353
+
354
+ def inject_json_script(html: str, var_name: str, data: dict[str, Any], *, nonce: str | None = None) -> str:
355
+ """Inject a script that sets a global JavaScript variable to JSON data.
356
+
357
+ This is a convenience function for injecting structured data into the page.
358
+ The data is serialized with compact JSON (no extra whitespace) and non-ASCII
359
+ characters are preserved.
360
+
361
+ Args:
362
+ html: The HTML document.
363
+ var_name: The global variable name (e.g., "__LITESTAR_ROUTES__").
364
+ data: The data to serialize as JSON.
365
+ nonce: Optional CSP nonce to add to the injected ``<script>`` tag.
366
+
367
+ Returns:
368
+ The HTML with the injected script in the ``<head>`` section. Falls back
369
+ to injecting before ``</html>`` or at the end if no ``</head>`` is found.
370
+
371
+ Note:
372
+ The script content is NOT escaped to preserve valid JSON. Ensure that
373
+ ``data`` does not contain user-controlled content that could include
374
+ malicious ``</script>`` sequences.
375
+
376
+ Example:
377
+ html = inject_json_script(html, "__ROUTES__", {"home": "/", "about": "/about"})
378
+ """
379
+ json_data = encode_json(data).decode("utf-8")
380
+ script = f"window.{var_name} = {json_data};"
381
+ return inject_head_script(html, script, escape=False, nonce=nonce)
382
+
383
+
384
+ def inject_vite_dev_scripts(
385
+ html: str,
386
+ vite_url: str,
387
+ *,
388
+ asset_url: str = "/static/",
389
+ is_react: bool = False,
390
+ csp_nonce: str | None = None,
391
+ resource_dir: str | None = None,
392
+ ) -> str:
393
+ """Inject Vite dev server scripts for HMR support.
394
+
395
+ This function injects the necessary scripts for Vite's Hot Module Replacement
396
+ (HMR) to work when serving HTML from the backend (e.g., in hybrid/Inertia mode).
397
+ The scripts are injected into the ``<head>`` section.
398
+
399
+ For React apps, a preamble script is injected before the Vite client to
400
+ enable React Fast Refresh.
401
+
402
+ Scripts are injected as relative URLs using the ``asset_url`` prefix. This
403
+ routes them through Litestar's proxy middleware, which forwards to Vite
404
+ with the correct base path handling.
405
+
406
+ When ``resource_dir`` is provided, entry point script URLs are also transformed
407
+ to include the asset URL prefix (e.g., ``/resources/main.tsx`` becomes
408
+ ``/static/resources/main.tsx``).
409
+
410
+ Args:
411
+ html: The HTML document.
412
+ vite_url: The Vite dev server URL (kept for backward compatibility, unused).
413
+ asset_url: The asset URL prefix (e.g., "/static/"). Scripts are served
414
+ at ``{asset_url}@vite/client`` etc.
415
+ is_react: Whether to inject the React Fast Refresh preamble.
416
+ csp_nonce: Optional CSP nonce to add to injected ``<script>`` tags.
417
+ resource_dir: Optional resource directory name (e.g., "resources", "src").
418
+ When provided, script sources starting with ``/{resource_dir}/`` are
419
+ prefixed with ``asset_url``.
420
+
421
+ Returns:
422
+ The HTML with Vite dev scripts injected. Scripts are inserted before
423
+ ``</head>`` when present, otherwise before ``</html>`` or at the end.
424
+
425
+ Example:
426
+ html = inject_vite_dev_scripts(html, "", asset_url="/static/", is_react=True)
427
+ """
428
+ # Use relative URLs with asset_url prefix so requests go through Litestar's proxy
429
+ # This ensures proper base path handling (Vite expects /static/@vite/client, not /@vite/client)
430
+ base = asset_url.rstrip("/")
431
+ nonce_attr = f' nonce="{_escape_attr(csp_nonce)}"' if csp_nonce else ""
432
+
433
+ # Transform entry point script URLs to include the asset URL prefix
434
+ # This ensures /resources/main.tsx becomes /static/resources/main.tsx
435
+ if resource_dir:
436
+ resource_prefix = f"/{resource_dir.strip('/')}/"
437
+
438
+ def transform_entry_script(match: re.Match[str]) -> str:
439
+ prefix = match.group(1)
440
+ src = match.group(2)
441
+ suffix = match.group(3)
442
+ if src.startswith(resource_prefix) and not src.startswith(base):
443
+ return prefix + base + src + suffix
444
+ return match.group(0)
445
+
446
+ html = _SCRIPT_SRC_PATTERN.sub(transform_entry_script, html)
447
+
448
+ scripts: list[str] = []
449
+
450
+ if is_react:
451
+ react_preamble = f"""import RefreshRuntime from '{base}/@react-refresh'
452
+ RefreshRuntime.injectIntoGlobalHook(window)
453
+ window.$RefreshReg$ = () => {{}}
454
+ window.$RefreshSig$ = () => (type) => type
455
+ window.__vite_plugin_react_preamble_installed__ = true"""
456
+ scripts.append(f'<script type="module"{nonce_attr}>{react_preamble}</script>')
457
+
458
+ scripts.append(f'<script type="module" src="{base}/@vite/client"{nonce_attr}></script>')
459
+
460
+ script_content = "\n".join(scripts) + "\n"
461
+
462
+ head_end_match = _HEAD_END_PATTERN.search(html)
463
+ if head_end_match:
464
+ pos = head_end_match.start()
465
+ return html[:pos] + script_content + html[pos:]
466
+
467
+ html_end_match = _HTML_END_PATTERN.search(html)
468
+ if html_end_match:
469
+ pos = html_end_match.start()
470
+ return html[:pos] + script_content + html[pos:]
471
+
472
+ return html + "\n" + script_content
473
+
474
+
475
+ def transform_asset_urls(
476
+ html: str, manifest: dict[str, Any], asset_url: str = "/static/", base_url: str | None = None
477
+ ) -> str:
478
+ """Transform asset URLs in HTML based on Vite manifest.
479
+
480
+ This function replaces source asset paths (e.g., /resources/main.tsx)
481
+ with their hashed production equivalents from the Vite manifest
482
+ (e.g., /static/assets/main-C-_c4FS5.js).
483
+
484
+ This is essential for production mode when using Vite's library mode
485
+ (input: ["resources/main.tsx"]) where Vite doesn't transform index.html.
486
+
487
+ Args:
488
+ html: The HTML document to transform.
489
+ manifest: The Vite manifest dictionary mapping source paths to output.
490
+ Each entry should have a ``file`` key with the hashed output path.
491
+ asset_url: Base URL for assets (default "/static/").
492
+ base_url: Optional CDN base URL override for production assets. When
493
+ provided, takes precedence over ``asset_url``.
494
+
495
+ Returns:
496
+ The HTML with transformed asset URLs. Returns the original HTML unchanged
497
+ if ``manifest`` is empty. Asset paths not found in the manifest are left
498
+ unchanged (no error is raised).
499
+
500
+ Note:
501
+ This function transforms ``<script src="...">`` and ``<link href="...">``
502
+ attributes. Leading slashes in source paths are normalized for manifest
503
+ lookup (e.g., "/resources/main.tsx" matches "resources/main.tsx" in manifest).
504
+
505
+ Example:
506
+ manifest = {"resources/main.tsx": {"file": "assets/main-abc123.js"}}
507
+ html = '<script type="module" src="/resources/main.tsx"></script>'
508
+ result = transform_asset_urls(html, manifest)
509
+ # Result: '<script type="module" src="/static/assets/main-abc123.js"></script>'
510
+ """
511
+ if not manifest:
512
+ return html
513
+
514
+ url_base = base_url or asset_url
515
+
516
+ def _normalize_path(path: str) -> str:
517
+ """Normalize a path for manifest lookup by removing leading slash.
518
+
519
+ Returns:
520
+ The normalized path without leading slash.
521
+ """
522
+ return path.lstrip("/")
523
+
524
+ def _build_url(file_path: str) -> str:
525
+ """Build the full URL for an asset file.
526
+
527
+ Returns:
528
+ The full URL combining base and file path.
529
+ """
530
+ base = url_base if url_base.endswith("/") else url_base + "/"
531
+ return base + file_path
532
+
533
+ def replace_script_src(match: re.Match[str]) -> str:
534
+ """Replace script src with manifest lookup.
535
+
536
+ Returns:
537
+ The transformed script tag with updated src, or original if not found.
538
+ """
539
+ prefix = match.group(1)
540
+ src = match.group(2)
541
+ suffix = match.group(3)
542
+
543
+ normalized = _normalize_path(src)
544
+ if normalized in manifest:
545
+ entry = manifest[normalized]
546
+ new_src = _build_url(entry.get("file", src))
547
+ return prefix + new_src + suffix
548
+ return match.group(0)
549
+
550
+ def replace_link_href(match: re.Match[str]) -> str:
551
+ """Replace link href with manifest lookup.
552
+
553
+ Returns:
554
+ The transformed link tag with updated href, or original if not found.
555
+ """
556
+ prefix = match.group(1)
557
+ href = match.group(2)
558
+ suffix = match.group(3)
559
+
560
+ normalized = _normalize_path(href)
561
+ if normalized in manifest:
562
+ entry = manifest[normalized]
563
+ new_href = _build_url(entry.get("file", href))
564
+ return prefix + new_href + suffix
565
+ return match.group(0)
566
+
567
+ html = _SCRIPT_SRC_PATTERN.sub(replace_script_src, html)
568
+
569
+ return _LINK_HREF_PATTERN.sub(replace_link_href, html)