diff --git a/src/httk/web/api.py b/src/httk/web/api.py index 550a1b7..efa64f8 100644 --- a/src/httk/web/api.py +++ b/src/httk/web/api.py @@ -14,9 +14,10 @@ def create_asgi_app( srcdir: str | Path, *, baseurl: str | None = None, + compatibility_mode: bool = False, debug: bool = False, ) -> Starlette: - config = SiteConfig.from_srcdir(srcdir=srcdir, baseurl=baseurl) + config = SiteConfig.from_srcdir(srcdir=srcdir, baseurl=baseurl, compatibility_mode=compatibility_mode) engine = SiteEngine(config) return create_app(engine=engine, debug=debug) @@ -27,9 +28,10 @@ def serve( host: str = "127.0.0.1", port: int = 8080, baseurl: str | None = None, + compatibility_mode: bool = False, debug: bool = False, ) -> None: - app = create_asgi_app(srcdir=srcdir, baseurl=baseurl, debug=debug) + app = create_asgi_app(srcdir=srcdir, baseurl=baseurl, compatibility_mode=compatibility_mode, debug=debug) run_dev_server(app=app, host=host, port=port) @@ -37,7 +39,9 @@ def publish( srcdir: str | Path, outdir: str | Path, baseurl: str, + *, + compatibility_mode: bool = False, ) -> PublishReport: - config = SiteConfig.from_srcdir(srcdir=srcdir, baseurl=baseurl) + config = SiteConfig.from_srcdir(srcdir=srcdir, baseurl=baseurl, compatibility_mode=compatibility_mode) engine = SiteEngine(config) return publish_site(engine=engine, outdir=outdir) diff --git a/src/httk/web/engine/site_engine.py b/src/httk/web/engine/site_engine.py index bf4c1a6..2352de1 100644 --- a/src/httk/web/engine/site_engine.py +++ b/src/httk/web/engine/site_engine.py @@ -9,13 +9,22 @@ from httk.web.model.page import PageResult, ResolvedRoute from httk.web.model.request import HttpRequestContext from httk.web.renderers import RENDERERS_BY_SUFFIX -from httk.web.templating import JinjaTemplateEngine, TemplateRenderInput +from httk.web.templating import ( + HttkCompatTemplateEngine, + JinjaTemplateEngine, + TemplateEngine, + TemplateRenderInput, +) class SiteEngine: def __init__(self, config: SiteConfig) -> None: self.config = config - self.template_engine = JinjaTemplateEngine(template_dir=config.template_dir) + self.template_engine: TemplateEngine + if config.compatibility_mode: + self.template_engine = HttkCompatTemplateEngine(template_dir=config.template_dir) + else: + self.template_engine = JinjaTemplateEngine(template_dir=config.template_dir) self.function_handler = PythonFunctionHandler(functions_dir=config.functions_dir) def resolve(self, route: str) -> ResolvedRoute: diff --git a/src/httk/web/model/config.py b/src/httk/web/model/config.py index dcb06ab..ee1fcec 100644 --- a/src/httk/web/model/config.py +++ b/src/httk/web/model/config.py @@ -11,10 +11,17 @@ class SiteConfig: template_subdir: str = "templates" functions_subdir: str = "functions" baseurl: str | None = None + compatibility_mode: bool = False @classmethod - def from_srcdir(cls, srcdir: str | Path, *, baseurl: str | None = None) -> Self: - return cls(srcdir=Path(srcdir).resolve(), baseurl=baseurl) + def from_srcdir( + cls, + srcdir: str | Path, + *, + baseurl: str | None = None, + compatibility_mode: bool = False, + ) -> Self: + return cls(srcdir=Path(srcdir).resolve(), baseurl=baseurl, compatibility_mode=compatibility_mode) @property def content_dir(self) -> Path: diff --git a/src/httk/web/templating/__init__.py b/src/httk/web/templating/__init__.py index c56023f..6ac66c7 100644 --- a/src/httk/web/templating/__init__.py +++ b/src/httk/web/templating/__init__.py @@ -1,4 +1,5 @@ -from .base import TemplateRenderInput +from .base import TemplateEngine, TemplateRenderInput +from .httk_compat import HttkCompatTemplateEngine from .jinja2_engine import JinjaTemplateEngine -__all__ = ["TemplateRenderInput", "JinjaTemplateEngine"] +__all__ = ["TemplateRenderInput", "TemplateEngine", "JinjaTemplateEngine", "HttkCompatTemplateEngine"] diff --git a/src/httk/web/templating/base.py b/src/httk/web/templating/base.py index 786a870..c8b3237 100644 --- a/src/httk/web/templating/base.py +++ b/src/httk/web/templating/base.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Protocol @dataclass(frozen=True) @@ -7,3 +8,9 @@ class TemplateRenderInput: template_name: str | None base_template_name: str | None context: dict[str, object] + + +class TemplateEngine(Protocol): + def render(self, render_input: TemplateRenderInput) -> str: ... + + def render_fragment(self, *, template_name: str, context: dict[str, object]) -> str | None: ... diff --git a/src/httk/web/templating/httk_compat.py b/src/httk/web/templating/httk_compat.py new file mode 100644 index 0000000..f69ea1d --- /dev/null +++ b/src/httk/web/templating/httk_compat.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from .jinja2_engine import JinjaTemplateEngine + +LEGACY_TEMPLATE_SUFFIXES: tuple[str, ...] = ( + ".httkweb.html", + ".html", + ".html.j2", + ".jinja", + ".j2", +) + + +class HttkCompatTemplateEngine(JinjaTemplateEngine): + """ + Legacy-oriented template resolution for old httkweb projects. + + The compatibility engine keeps Jinja rendering but prioritizes legacy + template suffixes so old `.httkweb.html` files resolve first. + """ + + def __init__(self, template_dir: Path) -> None: + super().__init__(template_dir, template_suffixes=LEGACY_TEMPLATE_SUFFIXES) diff --git a/src/httk/web/templating/jinja2_engine.py b/src/httk/web/templating/jinja2_engine.py index 6679187..9bc0a55 100644 --- a/src/httk/web/templating/jinja2_engine.py +++ b/src/httk/web/templating/jinja2_engine.py @@ -15,8 +15,14 @@ class JinjaTemplateEngine: - def __init__(self, template_dir: Path) -> None: + def __init__( + self, + template_dir: Path, + *, + template_suffixes: tuple[str, ...] = TEMPLATE_SUFFIXES, + ) -> None: self.template_dir = template_dir + self.template_suffixes = template_suffixes self._environment = Environment( loader=FileSystemLoader(str(template_dir)), autoescape=True, @@ -69,7 +75,7 @@ def _resolve_template(self, name: str | None) -> str | None: if path_candidate.suffix: return None - for suffix in TEMPLATE_SUFFIXES: + for suffix in self.template_suffixes: with_suffix = self.template_dir / f"{candidate}{suffix}" if with_suffix.exists() and with_suffix.is_file(): return f"{candidate}{suffix}" @@ -135,7 +141,7 @@ def _resolve_fragment_template(self, name: str) -> str | None: # For bare names, probe the same suffix families as standard template # resolution plus legacy html-style names used by old function metadata. if not path_candidate.suffix: - for suffix in TEMPLATE_SUFFIXES: + for suffix in self.template_suffixes: template_key = self._resolve_template(f"{candidate}{suffix}") if template_key is not None: return template_key diff --git a/tests/test_api.py b/tests/test_api.py index e99572c..33540ba 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -34,3 +34,41 @@ def test_publish_writes_html_output(tmp_path: Path) -> None: index_out = out / "index.html" assert index_out.exists() assert any(path == index_out for path in report.written_files) + + +def test_create_asgi_app_compatibility_mode_prefers_httkweb_templates(tmp_path: Path) -> None: + src = tmp_path / "src" + (src / "content").mkdir(parents=True) + (src / "static").mkdir(parents=True) + (src / "templates").mkdir(parents=True) + + (src / "content" / "index.md").write_text("---\ntemplate: default\n---\n\nhello", encoding="utf-8") + (src / "templates" / "default.html.j2").write_text("modern={{ content }}", encoding="utf-8") + (src / "templates" / "default.httkweb.html").write_text("legacy={{ content }}", encoding="utf-8") + + app = create_asgi_app(src, compatibility_mode=True) + + with TestClient(app) as client: + response = client.get("/") + + assert response.status_code == 200 + assert "legacy=" in response.text + assert "modern=" not in response.text + + +def test_publish_compatibility_mode_prefers_httkweb_templates(tmp_path: Path) -> None: + src = tmp_path / "src" + out = tmp_path / "public" + (src / "content").mkdir(parents=True) + (src / "static").mkdir(parents=True) + (src / "templates").mkdir(parents=True) + + (src / "content" / "index.md").write_text("---\ntemplate: default\n---\n\nhello", encoding="utf-8") + (src / "templates" / "default.html.j2").write_text("modern={{ content }}", encoding="utf-8") + (src / "templates" / "default.httkweb.html").write_text("legacy={{ content }}", encoding="utf-8") + + publish(src, out, "http://localhost/", compatibility_mode=True) + + rendered = (out / "index.html").read_text(encoding="utf-8") + assert "legacy=" in rendered + assert "modern=" not in rendered