diff --git a/openlibrary/fastapi/partials.py b/openlibrary/fastapi/partials.py index 71e8f371e43..2718e4ca1f4 100644 --- a/openlibrary/fastapi/partials.py +++ b/openlibrary/fastapi/partials.py @@ -54,12 +54,15 @@ async def search_facets_partial( @router.get("/partials/AffiliateLinks.json", include_in_schema=SHOW_PARTIALS_IN_SCHEMA) async def affiliate_links_partial( data: Annotated[str, Query(description="JSON-encoded data with book information")], + jinja: Annotated[bool, Query(description="Use Jinja2 template instead of Templetor")] = False, ) -> dict: """ Get affiliate links HTML for a book. The data parameter should contain: - args: list with [title, opts] where opts is a dict with optional isbn + + Optionally pass jinja=true to render with the Jinja2 template (for testing). """ try: parsed_data = json.loads(data) @@ -76,6 +79,7 @@ async def affiliate_links_partial( isbn=opts.get("isbn", None), asin=opts.get("asin", None), prices=opts.get("prices", False), + jinja=jinja, ) diff --git a/openlibrary/macros/AffiliateLinks.html.jinja b/openlibrary/macros/AffiliateLinks.html.jinja new file mode 100644 index 00000000000..bf780a9e45b --- /dev/null +++ b/openlibrary/macros/AffiliateLinks.html.jinja @@ -0,0 +1,33 @@ +{% macro affiliate_link(store) %} +
  • + {{ store.name }} + {% if store.price %} +
    + {{ store.price }}{{ store.price_note }} + {% endif %} +
  • +{% endmacro %} + + + + {{ _('When you buy books using these links the Internet Archive may earn a small commission.') | safe }} + diff --git a/openlibrary/plugins/openlibrary/partials.py b/openlibrary/plugins/openlibrary/partials.py index 9a5fd963a7e..eed56701f30 100644 --- a/openlibrary/plugins/openlibrary/partials.py +++ b/openlibrary/plugins/openlibrary/partials.py @@ -1,5 +1,7 @@ from dataclasses import dataclass +from functools import cache as functools_cache from hashlib import md5 +from pathlib import Path from typing import Literal, NotRequired, TypedDict from urllib.parse import parse_qs, quote, quote_plus @@ -19,7 +21,6 @@ get_betterworldbooks_metadata, ) from openlibrary.i18n import gettext as _ -from openlibrary.plugins.openlibrary.code import is_bot from openlibrary.plugins.openlibrary.lists import get_lists_async, get_user_lists from openlibrary.plugins.upstream.utils import render_macro from openlibrary.plugins.upstream.yearly_reading_goals import get_reading_goals @@ -292,6 +293,38 @@ def build_more_stores(ctx: AffiliateStoreBuildContext) -> list[AffiliateStore]: ] +@functools_cache +def _get_jinja_env(): + """Lazily initialize and return the Jinja2 environment (cached after first call). + + Per Jinja docs, a single Environment instance should be reused to take + advantage of template compilation caching. + """ + from jinja2 import Environment, FileSystemLoader, StrictUndefined + + env = Environment( + loader=FileSystemLoader(Path(__file__).resolve().parent.parent.parent / "macros"), + autoescape=True, + undefined=StrictUndefined, + trim_blocks=True, + lstrip_blocks=True, + ) + env.globals["_"] = _ + return env + + +def _render_affiliate_links_jinja( + primary_stores: list[AffiliateStore], + more_stores: list[AffiliateStore], +) -> str: + """Render affiliate links using Jinja2.""" + template = _get_jinja_env().get_template("AffiliateLinks.html.jinja") + return template.render( + primary_stores=primary_stores, + more_stores=more_stores, + ) + + class AffiliateLinksPartial: """Handler for affiliate links""" @@ -301,7 +334,10 @@ async def generate_async( isbn: str | None, asin: str | None, prices: bool, + jinja: bool = False, ) -> dict: + from openlibrary.plugins.openlibrary.code import is_bot + bwb_metadata = None amz_metadata = None should_fetch_prices = not is_bot() and prices @@ -317,8 +353,14 @@ async def generate_async( primary_stores = build_primary_stores(ctx) more_stores = build_more_stores(ctx) - macro = web.template.Template.globals["macros"].AffiliateLinks(primary_stores, more_stores) - return {"partials": str(macro)} + + if jinja: + html = _render_affiliate_links_jinja(primary_stores, more_stores) + else: + macro = web.template.Template.globals["macros"].AffiliateLinks(primary_stores, more_stores) + html = str(macro) + + return {"partials": html} class SearchFacetsPartial: diff --git a/openlibrary/plugins/openlibrary/tests/test_jinja_experiment.py b/openlibrary/plugins/openlibrary/tests/test_jinja_experiment.py new file mode 100644 index 00000000000..913e88ab53b --- /dev/null +++ b/openlibrary/plugins/openlibrary/tests/test_jinja_experiment.py @@ -0,0 +1,325 @@ +"""Tests for the Phase 3 Jinja2 template experiment. + +Validates that the Jinja2 AffiliateLinks template renders correctly +and produces semantically equivalent HTML to the Templetor template. + +Uses Jinja2 directly (not via partials.py) to avoid web.py dependency +which is unavailable on Python 3.14+. +""" + +from dataclasses import dataclass +from pathlib import Path + +import pytest + +JINJA2_AVAILABLE = False +try: + import jinja2 # noqa: F401 +except ImportError: + pass +else: + JINJA2_AVAILABLE = True + +pytestmark = pytest.mark.skipif(not JINJA2_AVAILABLE, reason="Jinja2 is not installed") + +WEBPY_AVAILABLE = False +WEBPY_GETTEXT = None +try: + import web + + WEBPY_AVAILABLE = True +except ImportError: + pass + +if WEBPY_AVAILABLE: + + def _gettext_stub(message, *args, **kwargs): + if kwargs: + return message % kwargs + return message + + WEBPY_GETTEXT = _gettext_stub + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +MACROS_DIR = PROJECT_ROOT / "macros" + + +def _make_jinja_env(): + """Create a Jinja2 environment configured like the production experiment.""" + from jinja2 import Environment, FileSystemLoader, StrictUndefined + + env = Environment( + loader=FileSystemLoader(str(MACROS_DIR)), + autoescape=True, + undefined=StrictUndefined, + trim_blocks=True, + lstrip_blocks=True, + ) + + def fake_gettext(message: str, **kwargs) -> str: + if kwargs: + return message % kwargs + return message + + env.globals["_"] = fake_gettext + return env + + +def _normalize_html(html: str) -> str: + """Collapse all non-semantic whitespace in HTML for comparison between engines.""" + import re + + html = re.sub(r">\s+<", "><", html) + html = re.sub(r"\s{2,}", " ", html) + return html.strip() + + +@pytest.fixture(scope="module") +def jinja_env(): + return _make_jinja_env() + + +@pytest.fixture(scope="module") +def affiliate_links_template(jinja_env): + return jinja_env.get_template("AffiliateLinks.html.jinja") + + +# --- Test data objects --- + + +@dataclass +class _AffiliateStore: + key: str + analytics_key: str + name: str + link: str + price: str | None = None + price_note: str = "" + + +# --- _normalize_html tests --- + + +class TestNormalizeHtml: + def test_collapses_whitespace_between_tags(self): + assert _normalize_html("
    a
    ") == "
    a
    " + + def test_preserves_inner_text_whitespace(self): + assert _normalize_html("hello world") == "hello world" + + def test_empty_string(self): + assert _normalize_html("") == "" + + def test_strips_outer_whitespace(self): + assert _normalize_html("
    text
    ") == "
    text
    " + + +# --- Jinja2 rendering tests --- + + +class TestJinja2TemplateRendering: + """Tests that the Jinja2 template renders without errors and produces expected output.""" + + def test_renders_empty_stores(self, affiliate_links_template): + result = affiliate_links_template.render(primary_stores=[], more_stores=[]) + assert '' in result + assert '