Coverage for app / backend / src / couchers / templating.py: 90%
126 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
1"""
2Provides string templating functionality using jinja2, with custom filters for localization.
3"""
5import logging
6import re
7from dataclasses import dataclass
8from datetime import date, datetime, time
9from functools import lru_cache
10from html import escape
11from pathlib import Path
12from typing import Any, ClassVar
14from google.protobuf.timestamp_pb2 import Timestamp
15from jinja2 import Environment, pass_context
16from jinja2.runtime import Context as JinjaContext
17from markdown_it import MarkdownIt
18from markupsafe import Markup
20from couchers.i18n import LocalizationContext
21from couchers.i18n.i18next import I18Next
22from couchers.i18n.localize import get_main_i18next
24logger = logging.getLogger(__name__)
26template_folder = Path(__file__).parent / ".." / ".." / "templates" / "v2"
28_markdown = MarkdownIt("zero", {"typographer": True}).enable(
29 ["smartquotes", "heading", "hr", "list", "link", "emphasis"]
30)
33@dataclass(frozen=True, slots=True, kw_only=True)
34class Jinja2Template:
35 """Context available to filter functions during templating."""
37 source: str
38 """The jinja2 template source code."""
40 html: bool
41 """If true, the template will be treated as HTML, so placeholders will be escaped by default."""
43 def render(self, args: dict[str, Any], loc_context: LocalizationContext, i18next: I18Next | None = None) -> str:
44 filter_context = _FilterContext(
45 output_html=self.html, i18next=i18next or get_main_i18next(), loc_context=loc_context
46 )
47 args = {**args, _FilterContext.KEY: filter_context}
48 return _get_jinja_env().from_string(self.source).render(args)
51@dataclass(frozen=True, slots=True, kw_only=True)
52class _FilterContext:
53 """Context available to filter functions during templating."""
55 KEY: ClassVar[str] = "_filter_context"
57 output_html: bool
58 i18next: I18Next
59 loc_context: LocalizationContext
61 @staticmethod
62 def from_jinja(jinja_context: JinjaContext) -> _FilterContext:
63 context: _FilterContext = jinja_context[_FilterContext.KEY]
64 return context
67@lru_cache(maxsize=1)
68def _get_jinja_env() -> Environment:
69 env = Environment(trim_blocks=True)
70 env.autoescape = False # We do escaping in _finalize
71 env.finalize = _finalize
72 env.filters["multiline"] = _filter_multiline
73 env.filters["quotelines"] = _filter_quotelines
74 env.filters["markdown"] = _filter_markdown
75 env.filters["html"] = _filter_html
76 env.filters["date"] = _filter_date
77 env.filters["time"] = _filter_time
78 env.filters["datetime"] = _filter_datetime
79 env.filters["translate"] = _filter_translate
80 return env
83@pass_context
84def _finalize(jinja_context: JinjaContext, value: Any) -> str:
85 """
86 Converts a placeholder value into a string for output in a jinja template.
87 For example, "{{ my_date }}" will honor the context's locale and timezone.
88 """
89 return _format_default(value, _FilterContext.from_jinja(jinja_context))
92def _format_default(value: Any, filter_context: _FilterContext) -> str:
93 """Formats a placeholder value into a string with useful defaults."""
94 match value:
95 case date():
96 return filter_context.loc_context.localize_date(value)
97 case datetime(): 97 ↛ 98line 97 didn't jump to line 98 because the pattern on line 97 never matched
98 return filter_context.loc_context.localize_datetime(value)
99 case time(): 99 ↛ 100line 99 didn't jump to line 100 because the pattern on line 99 never matched
100 return filter_context.loc_context.localize_time(value)
101 case Timestamp(): 101 ↛ 102line 101 didn't jump to line 102 because the pattern on line 101 never matched
102 return filter_context.loc_context.localize_datetime(value)
103 case Markup():
104 return str(value)
105 case _:
106 return escape(str(value)) if filter_context.output_html else str(value)
109@pass_context
110def _filter_multiline(jinja_context: JinjaContext, value: Any) -> str | Markup:
111 """Converts newlines to HTML line breaks, if rendering HTML."""
112 filter_context = _FilterContext.from_jinja(jinja_context)
113 if filter_context.output_html:
114 value = _format_default(value, filter_context) # Escape input HTML unless Markup()
115 return Markup(value.replace("\n", "<br>"))
116 elif isinstance(value, Markup): 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true
117 return value
118 else:
119 return _format_default(value, filter_context)
122@pass_context
123def _filter_quotelines(jinja_context: JinjaContext, value: str | Markup) -> str | Markup:
124 """If plaintext, prefixes each line to indicate a quote."""
125 if _FilterContext.from_jinja(jinja_context).output_html: 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true
126 return value
127 else:
128 return "\n".join(f"> {line}" for line in value.splitlines())
131@pass_context
132def _filter_markdown(jinja_context: JinjaContext, value: str) -> Markup:
133 """Renders markdown into html."""
134 filter_context = _FilterContext.from_jinja(jinja_context)
135 if filter_context.output_html: 135 ↛ 138line 135 didn't jump to line 138 because the condition on line 135 was always true
136 return Markup(_markdown.render(value))
137 else:
138 return Markup(value)
141def _filter_html(value: Any) -> Markup:
142 """Marks a string as safely containing HTML, so it won't get escaped."""
143 return Markup(value)
146@pass_context
147def _filter_date(jinja_context: JinjaContext, value: date | datetime | str) -> str:
148 """Formats a date using the context's locale."""
149 filter_context = _FilterContext.from_jinja(jinja_context)
150 if isinstance(value, str): 150 ↛ 152line 150 didn't jump to line 152 because the condition on line 150 was always true
151 value = date.fromisoformat(value)
152 return filter_context.loc_context.localize_date(value)
155@pass_context
156def _filter_time(jinja_context: JinjaContext, value: datetime | time) -> str:
157 """Formats a time using the context's locale."""
158 filter_context = _FilterContext.from_jinja(jinja_context)
159 return filter_context.loc_context.localize_time(value)
162@pass_context
163def _filter_datetime(jinja_context: JinjaContext, value: datetime | Timestamp) -> str:
164 """Formats a date+time using the context's locale and timezone."""
165 filter_context = _FilterContext.from_jinja(jinja_context)
166 return filter_context.loc_context.localize_datetime(value)
169@pass_context
170def _filter_translate(jinja_context: JinjaContext, key: str, **kwargs: Any) -> Markup:
171 """
172 Looks up a localized string, applying substitutions.
174 Usage in template:
175 {{ "greeting_key"|translate(name=user.name) }}
176 """
178 filter_context = _FilterContext.from_jinja(jinja_context)
180 # Stringify substitutions, applying default formatting and
181 # escaping unless they have been marked as safely containing HTML.
182 substitutions: dict[str, str | int] = {}
183 for k, v in kwargs.items():
184 match v:
185 case int():
186 substitutions[k] = v
187 case _:
188 substitutions[k] = _format_default(v, filter_context)
190 value = filter_context.i18next.localize(key, filter_context.loc_context.locale, substitutions)
192 # Translated strings are trusted to contain simple HTML tags,
193 # they can also have newlines for translator convenience.
194 if filter_context.output_html:
195 # Turn newlines into HTML line breaks.
196 value = value.replace("\n", "<br>")
197 else:
198 # Strip HTML tags that came from the strings since we're not outputting HTML.
199 # Doesn't support nesting, but should be sufficient for our needs.
200 value = re.sub(r"<(?P<name>\w+)(?P<attrs>[^>]*)>(?P<inner>.*?)</(?P=name)>", _replace_html_tag_match, value)
201 value = re.sub(r"<br\s*/?>", "\n", value)
203 return Markup(value)
206def _replace_html_tag_match(match: re.Match[str]) -> str:
207 inner_text = match.group("inner")
208 if match.group("name").lower() == "a":
209 # <a href="url">text</a> -> <url>
210 # If no url, fallback to text.
211 href_attr = re.search(r'\bhref="([^"]+)"', match.group("attrs"))
212 link_text: str
213 if href_attr: 213 ↛ 216line 213 didn't jump to line 216 because the condition on line 213 was always true
214 link_text = href_attr.group(1).removeprefix("mailto:")
215 else:
216 link_text = inner_text
217 return f"<{link_text}>"
218 else:
219 # <b>hello</b> -> hello
220 return inner_text