Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/httk/web/engine/site_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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]]:
Expand Down Expand Up @@ -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")]

Expand All @@ -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

Expand All @@ -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)

Expand Down
1 change: 1 addition & 0 deletions src/httk/web/model/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/httk/web/publishing/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
55 changes: 54 additions & 1 deletion src/httk/web/templating/jinja2_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -88,3 +88,56 @@ 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

# 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
70 changes: 70 additions & 0 deletions tests/test_publish_warnings.py
Original file line number Diff line number Diff line change
@@ -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)
Loading