diff --git a/example/vite-app/config/vite.py b/example/vite-app/config/vite.py index 71431700..80102997 100644 --- a/example/vite-app/config/vite.py +++ b/example/vite-app/config/vite.py @@ -10,3 +10,5 @@ class ViteConfig: asset_url: str = "" static_url: str = "/build" mount_static: bool = True + template: bool = True + templates_directory: str = "resources/templates" diff --git a/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py b/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py index 71431700..80102997 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py @@ -10,3 +10,5 @@ class ViteConfig: asset_url: str = "" static_url: str = "/build" mount_static: bool = True + template: bool = True + templates_directory: str = "resources/templates" diff --git a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py index 4e0e7c33..cf56b890 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py @@ -28,6 +28,25 @@ def register(self) -> None: self.app.bind("vite", vite) + self.register_templates(config) + + def register_templates(self, config: ViteConfig) -> None: + # Bind a Jinja2 template engine out of the box so fresh apps can render + # views and receive the vite() globals during boot. An existing + # "templates" binding always wins, keeping this backward-compatible. + if not config.template or self.app.has("templates"): + return + + try: + from starlette.templating import Jinja2Templates + except ImportError as exc: + raise ImportError( + "Rendering Vite templates requires Jinja2. Install it with: pip install fastapi-startkit[vite]" + ) from exc + + templates_dir = self.app.base_path / config.templates_directory + self.app.bind("templates", Jinja2Templates(directory=str(templates_dir))) + def boot(self) -> None: vite: Vite = self.app.make("vite") config = self.app.make("config").get(self.provider_key) @@ -48,7 +67,7 @@ def boot(self) -> None: os.path.join(stubs_path, "tsconfig.json"): "tsconfig.json", os.path.join(stubs_path, "resources/js/app.ts"): "resources/js/app.ts", os.path.join(stubs_path, "resources/css/app.css"): "resources/css/app.css", - os.path.join(stubs_path, "templates/index.html"): "templates/index.html", + os.path.join(stubs_path, "resources/templates/index.html"): "resources/templates/index.html", } ) diff --git a/fastapi_startkit/src/fastapi_startkit/vite/stubs/templates/index.html b/fastapi_startkit/src/fastapi_startkit/vite/stubs/resources/templates/index.html similarity index 100% rename from fastapi_startkit/src/fastapi_startkit/vite/stubs/templates/index.html rename to fastapi_startkit/src/fastapi_startkit/vite/stubs/resources/templates/index.html diff --git a/fastapi_startkit/tests/vite/test_template_binding.py b/fastapi_startkit/tests/vite/test_template_binding.py new file mode 100644 index 00000000..20060339 --- /dev/null +++ b/fastapi_startkit/tests/vite/test_template_binding.py @@ -0,0 +1,64 @@ +import sys +from unittest import mock + +import pytest + +from fastapi_startkit.application import Application +from fastapi_startkit.providers import Provider +from fastapi_startkit.vite import ViteProvider +from fastapi_startkit.vite.config.vite import ViteConfig + + +def make_app(tmp_path, providers=None) -> Application: + if providers is None: + providers = [ViteProvider] + return Application(base_path=tmp_path, env="testing", providers=providers) + + +class _SentinelTemplatesProvider(Provider): + provider_key = "sentinel_templates" + + def register(self) -> None: + self.app.bind("templates", "SENTINEL") + + +class TestTemplateBinding: + def test_binds_templates_when_enabled_and_none_prebound(self, tmp_path): + from starlette.templating import Jinja2Templates + + app = make_app(tmp_path) + + assert app.has("templates") + assert isinstance(app.make("templates"), Jinja2Templates) + + def test_respects_existing_templates_binding(self, tmp_path): + app = make_app(tmp_path, providers=[_SentinelTemplatesProvider, ViteProvider]) + + assert app.make("templates") == "SENTINEL" + + def test_skips_binding_when_template_disabled(self, tmp_path): + app = make_app(tmp_path, providers=[(ViteProvider, {"template": False})]) + + assert not app.has("templates") + + def test_vite_globals_injected_after_boot(self, tmp_path): + app = make_app(tmp_path) + + env_globals = app.make("templates").env.globals + assert "vite" in env_globals + assert "vite_asset" in env_globals + assert "vite_react_refresh" in env_globals + + +class TestMissingJinja2: + def test_missing_jinja2_raises_clear_install_error(self, tmp_path): + app = make_app(tmp_path, providers=[]) + provider = ViteProvider(app) + + # Jinja2Templates is only importable from starlette.templating when + # jinja2 is installed; setting the module to None makes the import fail. + with mock.patch.dict(sys.modules, {"starlette.templating": None}): + with pytest.raises(ImportError) as exc_info: + provider.register_templates(ViteConfig()) + + assert "fastapi-startkit[vite]" in str(exc_info.value)