From 8cb2e3f07a4a24971ddbf4dabc77419d8c414afe Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Thu, 26 Mar 2026 23:05:16 +0000 Subject: [PATCH 1/2] Better legacy handling and improved error reporting --- src/httk/web/engine/site_engine.py | 18 +++++- src/httk/web/model/page.py | 1 + src/httk/web/publishing/static.py | 1 + src/httk/web/templating/jinja2_engine.py | 31 ++++++++++- tests/test_publish_warnings.py | 70 ++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 tests/test_publish_warnings.py diff --git a/src/httk/web/engine/site_engine.py b/src/httk/web/engine/site_engine.py index 4d61e10..99e3ed2 100644 --- a/src/httk/web/engine/site_engine.py +++ b/src/httk/web/engine/site_engine.py @@ -33,12 +33,19 @@ def render(self, route: str, query: dict[str, str] | None = None) -> PageResult: query_params = dict(query or {}) rendered_html, metadata = self._render_content_without_templates(resolved) route_key = normalize_route(route) + warnings: list[str] = [] template_name = self._metadata_string(metadata, "template", default="default") base_template_name = self._metadata_string(metadata, "base_template", default="base_default") context = self._build_template_context(route_key=route_key, metadata=metadata, query=query_params) - self._apply_function_injections(metadata=metadata, context=context, query=query_params) + self._apply_function_injections( + metadata=metadata, + context=context, + query=query_params, + route_key=route_key, + warnings=warnings, + ) content_html = self.template_engine.render( TemplateRenderInput( @@ -54,6 +61,7 @@ def render(self, route: str, query: dict[str, str] | None = None) -> PageResult: content_type="text/html; charset=utf-8", body=content_html.encode("utf-8"), metadata=metadata, + warnings=warnings, ) def _render_content_without_templates(self, resolved: ResolvedRoute) -> tuple[str, dict[str, object]]: @@ -149,6 +157,8 @@ def _apply_function_injections( metadata: dict[str, object], context: dict[str, object], query: dict[str, str], + route_key: str, + warnings: list[str], ) -> None: function_keys = [key for key in metadata if key.endswith("-function")] @@ -166,6 +176,9 @@ def _apply_function_injections( if not self._function_args_satisfied(required, query): context[output_name] = "" metadata[output_name] = "" + warnings.append( + f"Function '{function_name}' on route '{route_key}' skipped: query constraints not satisfied." + ) del metadata[function_key] continue @@ -176,6 +189,9 @@ def _apply_function_injections( fragment = self.template_engine.render_fragment(template_name=function_template, context=joint_context) if fragment is None: output = str(result) + warnings.append( + f"Function '{function_name}' on route '{route_key}' rendered without template '{function_template}'." + ) else: output = Markup(fragment) diff --git a/src/httk/web/model/page.py b/src/httk/web/model/page.py index c56a5b5..156da52 100644 --- a/src/httk/web/model/page.py +++ b/src/httk/web/model/page.py @@ -16,6 +16,7 @@ class PageResult: content_type: str body: bytes metadata: dict[str, object] = field(default_factory=dict) + warnings: list[str] = field(default_factory=list) @dataclass(frozen=True) diff --git a/src/httk/web/publishing/static.py b/src/httk/web/publishing/static.py index ffc5e03..aa19763 100644 --- a/src/httk/web/publishing/static.py +++ b/src/httk/web/publishing/static.py @@ -34,6 +34,7 @@ def publish_site(*, engine: SiteEngine, outdir: str | Path) -> PublishReport: rel = content_file.relative_to(content_dir) route = str(rel.with_suffix("")) result = engine.render(route) + warnings.extend(result.warnings) target = output_root / rel.with_suffix(".html") target.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/httk/web/templating/jinja2_engine.py b/src/httk/web/templating/jinja2_engine.py index 5a666c8..5fd0977 100644 --- a/src/httk/web/templating/jinja2_engine.py +++ b/src/httk/web/templating/jinja2_engine.py @@ -44,7 +44,7 @@ def render(self, render_input: TemplateRenderInput) -> str: return content def render_fragment(self, *, template_name: str, context: dict[str, object]) -> str | None: - template_key = self._resolve_template(template_name) + template_key = self._resolve_fragment_template(template_name) if template_key is None: return None template = self._environment.get_template(template_key) @@ -88,3 +88,32 @@ def _is_safe_relative_path(self, path_candidate: Path) -> bool: except ValueError: return False return True + + def _resolve_fragment_template(self, name: str) -> str | None: + """ + Resolve function-fragment templates with extra legacy compatibility. + + In old httkweb metadata, function templates often used bare names + (e.g. `hello_world_result`) and were resolved against the active + template engine extension. Here we keep that behavior by probing a few + additional suffix variants. + """ + template_key = self._resolve_template(name) + if template_key is not None: + return template_key + + candidate = name.strip() + if not candidate: + return None + + path_candidate = Path(candidate) + if not self._is_safe_relative_path(path_candidate): + return None + + for suffix in (".html", ".httkweb.html", ".html.j2", ".jinja", ".j2"): + with_suffix = f"{candidate}{suffix}" + template_key = self._resolve_template(with_suffix) + if template_key is not None: + return template_key + + return None diff --git a/tests/test_publish_warnings.py b/tests/test_publish_warnings.py new file mode 100644 index 0000000..510ac40 --- /dev/null +++ b/tests/test_publish_warnings.py @@ -0,0 +1,70 @@ +from pathlib import Path + +from httk.web.api import publish + + +def _make_src(tmp_path: Path) -> tuple[Path, Path]: + src = tmp_path / "src" + out = tmp_path / "public" + (src / "content").mkdir(parents=True) + (src / "static").mkdir(parents=True) + (src / "templates").mkdir(parents=True) + (src / "functions").mkdir(parents=True) + return src, out + + +def test_publish_reports_warning_when_function_query_constraints_not_met(tmp_path: Path) -> None: + src, out = _make_src(tmp_path) + + (src / "functions" / "hello.py").write_text( + """def execute(name='x', global_data=None, **kwargs): + return name.upper() +""", + encoding="utf-8", + ) + + (src / "templates" / "default.html.j2").write_text("{{ greeting }}|{{ content }}", encoding="utf-8") + (src / "templates" / "base_default.html.j2").write_text("{{ content }}", encoding="utf-8") + (src / "templates" / "greeting_fragment.html.j2").write_text("Hello {{ result }}!", encoding="utf-8") + + (src / "content" / "index.md").write_text( + """--- +template: default +greeting-function: hello:name:greeting_fragment +--- + +Body text +""", + encoding="utf-8", + ) + + report = publish(src, out, "http://localhost/") + assert any("query constraints not satisfied" in warning for warning in report.warnings) + + +def test_publish_reports_warning_when_function_template_missing(tmp_path: Path) -> None: + src, out = _make_src(tmp_path) + + (src / "functions" / "hello.py").write_text( + """def execute(name='x', global_data=None, **kwargs): + return name.upper() +""", + encoding="utf-8", + ) + + (src / "templates" / "default.html.j2").write_text("{{ greeting }}|{{ content }}", encoding="utf-8") + (src / "templates" / "base_default.html.j2").write_text("{{ content }}", encoding="utf-8") + + (src / "content" / "index.md").write_text( + """--- +template: default +greeting-function: hello::missing_fragment +--- + +Body text +""", + encoding="utf-8", + ) + + report = publish(src, out, "http://localhost/") + assert any("rendered without template" in warning for warning in report.warnings) From 3c55259a9a0d194df248a3de181aaf978e522fbc Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Thu, 26 Mar 2026 23:17:49 +0000 Subject: [PATCH 2/2] Fixed .html legacy edge case resolution; For bare names, _resolve_fragment_template() now reuses TEMPLATE_SUFFIXES instead of a separate hardcoded list --- src/httk/web/templating/jinja2_engine.py | 34 ++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/httk/web/templating/jinja2_engine.py b/src/httk/web/templating/jinja2_engine.py index 5fd0977..6679187 100644 --- a/src/httk/web/templating/jinja2_engine.py +++ b/src/httk/web/templating/jinja2_engine.py @@ -110,10 +110,34 @@ def _resolve_fragment_template(self, name: str) -> str | None: if not self._is_safe_relative_path(path_candidate): return None - for suffix in (".html", ".httkweb.html", ".html.j2", ".jinja", ".j2"): - with_suffix = f"{candidate}{suffix}" - template_key = self._resolve_template(with_suffix) - if template_key is not None: - return template_key + # If metadata already carries an HTML-like suffix (e.g. "fragment.html"), + # also probe common Jinja variants that may be present in v2 projects. + # This mirrors old usage where function metadata often referenced ".html" + # while active template files could be ".html.j2" / ".jinja" / ".j2". + if candidate.endswith(".httkweb.html"): + stem = candidate[: -len(".httkweb.html")] + probes = [f"{candidate}.j2", f"{candidate}.jinja", f"{stem}.html.j2", f"{stem}.jinja", f"{stem}.j2"] + for probe in probes: + template_key = self._resolve_template(probe) + if template_key is not None: + return template_key + return None + + if candidate.endswith(".html"): + stem = candidate[: -len(".html")] + probes = [f"{candidate}.j2", f"{candidate}.jinja", f"{stem}.html.j2", f"{stem}.jinja", f"{stem}.j2"] + for probe in probes: + template_key = self._resolve_template(probe) + if template_key is not None: + return template_key + return 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: + template_key = self._resolve_template(f"{candidate}{suffix}") + if template_key is not None: + return template_key return None