From 8fb2b3dcfd44e0fb3e45a155fc69ea9908c64312 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 02:02:00 -0700 Subject: [PATCH] feat(vite): auto-load Jinja2 templates from the Vite provider Fresh apps got no template engine because ViteProvider only injected the vite() globals when a "templates" binding already existed. The provider now binds a Jinja2Templates engine out of the box, config-gated and backward-compatible. - Add ViteConfig.template (default True) and ViteConfig.templates_directory (default "resources/templates") - ViteProvider.register() binds "templates" when enabled and none is pre-bound; an existing binding always wins. The jinja2 import is guarded with a clear "install fastapi-startkit[vite]" message - Publish the template stub under resources/templates/index.html so all scaffolding stays consistent under resources/ - Sync the example app's config/vite.py with the new fields - Cover binding, override, disabled, post-boot globals and the missing jinja2 error in tests/vite/ Rendering stays the standard Starlette way: app.make("templates").TemplateResponse(request, name, context). --- example/vite-app/config/vite.py | 2 + .../src/fastapi_startkit/vite/config/vite.py | 2 + .../vite/providers/provider.py | 21 +++++- .../{ => resources}/templates/index.html | 0 .../tests/vite/test_template_binding.py | 64 +++++++++++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) rename fastapi_startkit/src/fastapi_startkit/vite/stubs/{ => resources}/templates/index.html (100%) create mode 100644 fastapi_startkit/tests/vite/test_template_binding.py 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)