Source code for litestar_vite.loader
from __future__ import annotations
import json
from functools import cached_property
from pathlib import Path
from textwrap import dedent
from typing import TYPE_CHECKING, Any, ClassVar, Mapping, cast
from urllib.parse import urljoin
import markupsafe
from litestar.exceptions import ImproperlyConfiguredException
if TYPE_CHECKING:
from litestar.connection import Request
from litestar_vite.config import ViteConfig
from litestar_vite.plugin import VitePlugin
def _get_request_from_context(context: Mapping[str, Any]) -> Request[Any, Any, Any]:
"""Get the request from the template context.
Args:
context: The template context.
Returns:
The request object.
"""
return cast("Request[Any, Any, Any]", context["request"])
[docs]
def render_hmr_client(context: Mapping[str, Any], /) -> markupsafe.Markup:
"""Render the HMR client.
Args:
context: The template context.
Returns:
The HMR client.
"""
return cast(
"VitePlugin", _get_request_from_context(context).app.plugins.get("VitePlugin")
).asset_loader.render_hmr_client()
[docs]
def render_asset_tag(
context: Mapping[str, Any], /, path: str | list[str], scripts_attrs: dict[str, str] | None = None
) -> markupsafe.Markup:
"""Render an asset tag.
Args:
context: The template context.
path: The path to the asset.
scripts_attrs: The attributes for the script tag.
Returns:
The asset tag.
"""
return cast(
"VitePlugin", _get_request_from_context(context).app.plugins.get("VitePlugin")
).asset_loader.render_asset_tag(path, scripts_attrs)
[docs]
class ViteAssetLoader:
"""Vite manifest loader.
Please see: https://vitejs.dev/guide/backend-integration.html
"""
_instance: ClassVar[ViteAssetLoader | None] = None
[docs]
def __init__(self, config: ViteConfig) -> None:
self._config = config
self._manifest: dict[str, Any] = {}
self._manifest_content: str = ""
self._vite_base_path: str | None = None
[docs]
@classmethod
def initialize_loader(cls, config: ViteConfig) -> ViteAssetLoader:
"""Singleton manifest loader."""
if cls._instance is None:
cls._instance = cls(config=config)
cls._instance.parse_manifest()
return cls._instance
@cached_property
def version_id(self) -> str:
if self._manifest_content != "":
return str(hash(self.manifest_content))
return "1.0"
[docs]
def render_hmr_client(self) -> markupsafe.Markup:
"""Generate the script tag for the Vite WS client for HMR."""
return markupsafe.Markup(
f"{self.generate_react_hmr_tags()}{self.generate_ws_client_tags()}",
)
[docs]
def render_asset_tag(self, path: str | list[str], scripts_attrs: dict[str, str] | None = None) -> markupsafe.Markup:
"""Generate all assets include tags for the file in argument."""
path = [str(p) for p in path] if isinstance(path, list) else [str(path)]
return markupsafe.Markup(
"".join([self.generate_asset_tags(p, scripts_attrs=scripts_attrs) for p in path]),
)
[docs]
def parse_manifest(self) -> None:
"""Parse the Vite manifest file.
The manifest file is a JSON file that maps source files to their corresponding output files.
Example manifest file structure:
.. code-block:: json
{
"main.js": {
"file": "assets/main.4889e940.js",
"src": "main.js",
"isEntry": true,
"dynamicImports": ["views/foo.js"],
"css": ["assets/main.b82dbe22.css"],
"assets": ["assets/asset.0ab0f9cd.png"]
},
"views/foo.js": {
"file": "assets/foo.869aea0d.js",
"src": "views/foo.js",
"isDynamicEntry": true,
"imports": ["_shared.83069a53.js"]
},
"_shared.83069a53.js": {
"file": "assets/shared.83069a53.js"
}
}
The manifest is parsed and stored in memory for asset resolution during template rendering.
"""
if self._config.hot_reload and self._config.dev_mode:
hot_file_path = Path(
f"{self._config.bundle_dir}/{self._config.hot_file}",
)
if hot_file_path.exists():
with hot_file_path.open() as hot_file:
self._vite_base_path = hot_file.read()
else:
manifest_path = Path(f"{self._config.bundle_dir}/{self._config.manifest_name}")
try:
if manifest_path.exists():
with manifest_path.open() as manifest_file:
self.manifest_content = manifest_file.read()
self._manifest = json.loads(self.manifest_content)
else:
self._manifest = {}
except Exception as exc:
msg = "There was an issue reading the Vite manifest file at %s. Did you forget to build your assets?"
raise RuntimeError(
msg,
manifest_path,
) from exc
[docs]
def generate_ws_client_tags(self) -> str:
"""Generate the script tag for the Vite WS client for HMR.
Only used when hot module reloading is enabled, in production this method returns an empty string.
Returns:
str: The script tag or an empty string.
"""
if self._config.hot_reload and self._config.dev_mode:
return self._script_tag(
self._vite_server_url("@vite/client"),
{"type": "module"},
)
return ""
[docs]
def generate_react_hmr_tags(self) -> str:
"""Generate the script tag for the Vite WS client for HMR.
Only used when hot module reloading is enabled, in production this method returns an empty string.
Returns:
str: The script tag or an empty string.
"""
if self._config.is_react and self._config.hot_reload and self._config.dev_mode:
return dedent(f"""
<script type="module">
import RefreshRuntime from '{self._vite_server_url()}@react-refresh'
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {{}}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__=true
</script>
""")
return ""
[docs]
def generate_asset_tags(self, path: str | list[str], scripts_attrs: dict[str, str] | None = None) -> str:
"""Generate all assets include tags for the file in argument.
Returns:
str: All tags to import this asset in your HTML page.
"""
if isinstance(path, str):
path = [path]
if self._config.hot_reload and self._config.dev_mode:
return "".join(
[
self._style_tag(self._vite_server_url(p))
if p.endswith(".css")
else self._script_tag(
self._vite_server_url(p),
{"type": "module", "async": "", "defer": ""},
)
for p in path
],
)
if any(p for p in path if p not in self._manifest):
msg = "Cannot find %s in Vite manifest at %s. Did you forget to build your assets after an update?"
raise ImproperlyConfiguredException(
msg,
path,
Path(f"{self._config.bundle_dir}/{self._config.manifest_name}"),
)
tags: list[str] = []
manifest_entry: dict[str, Any] = {}
manifest_entry.update({p: self._manifest[p] for p in path if p})
if not scripts_attrs:
scripts_attrs = {"type": "module", "async": "", "defer": ""}
for manifest in manifest_entry.values():
if "css" in manifest:
tags.extend(
self._style_tag(urljoin(self._config.asset_url, css_path)) for css_path in manifest.get("css", {})
)
# Add dependent "vendor"
if "imports" in manifest:
tags.extend(
self.generate_asset_tags(vendor_path, scripts_attrs=scripts_attrs)
for vendor_path in manifest.get("imports", {})
)
# Add the script by itself
if manifest.get("file").endswith(".css"):
tags.append(
self._style_tag(urljoin(self._config.asset_url, manifest["file"])),
)
else:
tags.append(
self._script_tag(
urljoin(self._config.asset_url, manifest["file"]),
attrs=scripts_attrs,
),
)
return "".join(tags)
def _vite_server_url(self, path: str | None = None) -> str:
"""Generate an URL to and asset served by the Vite development server.
Keyword Arguments:
path: Path to the asset. (default: {None})
Returns:
str: Full URL to the asset.
"""
base_path = self._vite_base_path or f"{self._config.protocol}://{self._config.host}:{self._config.port}"
return urljoin(
base_path,
urljoin(self._config.asset_url, path if path is not None else ""),
)
def _script_tag(self, src: str, attrs: dict[str, str] | None = None) -> str:
"""Generate an HTML script tag."""
if attrs is None:
attrs = {}
attrs_str = " ".join([f'{key}="{value}"' for key, value in attrs.items()])
return f'<script {attrs_str} src="{src}"></script>'
def _style_tag(self, href: str) -> str:
"""Generate and HTML <link> stylesheet tag for CSS.
Args:
href: CSS file URL.
Returns:
str: CSS link tag.
"""
return f'<link rel="stylesheet" href="{href}" />'