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 %}
+
+
+
+ {% for store in primary_stores %}
+ {{ affiliate_link(store) | safe }}
+ {% endfor %}
+ {% if more_stores %}
+ -
+
+ {{ _('More') }}
+
+ {% for store in more_stores %}
+ {{ affiliate_link(store) | safe }}
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {{ _('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 '' in result
+
+ def test_contains_small_commission_text(self, affiliate_links_template):
+ result = affiliate_links_template.render(primary_stores=[], more_stores=[])
+ assert "small commission" in result
+ assert "help/faq/about" in result
+
+ def test_renders_single_primary_store(self, affiliate_links_template):
+ stores = [
+ _AffiliateStore(
+ key="amazon",
+ analytics_key="Amazon",
+ name="Amazon",
+ link="https://amazon.com/dp/123",
+ price="$10.00",
+ price_note="",
+ ),
+ ]
+ result = affiliate_links_template.render(primary_stores=stores, more_stores=[])
+ assert "prices-amazon" in result
+ assert "Amazon" in result
+ assert "$10.00" in result
+
+ def test_renders_store_without_price(self, affiliate_links_template):
+ stores = [
+ _AffiliateStore(
+ key="bookshop-org",
+ analytics_key="BookshopOrg",
+ name="Bookshop.org",
+ link="https://bookshop.org/a/test/123",
+ ),
+ ]
+ result = affiliate_links_template.render(primary_stores=stores, more_stores=[])
+ assert "Bookshop.org" in result
+ assert "price" not in result or result.count('') == 0
+
+ def test_renders_more_stores_section(self, affiliate_links_template):
+ stores = [
+ _AffiliateStore(key="amazon", analytics_key="Amazon", name="Amazon", link="https://amazon.com/dp/123"),
+ ]
+ more = [
+ _AffiliateStore(
+ key="bookshop-org",
+ analytics_key="BookshopOrg",
+ name="Bookshop.org",
+ link="https://bookshop.org/a/test/123",
+ ),
+ ]
+ result = affiliate_links_template.render(primary_stores=stores, more_stores=more)
+ assert "" in result
+ assert "Bookshop.org" in result
+
+ def test_no_more_stores_section_when_empty(self, affiliate_links_template):
+ stores = [
+ _AffiliateStore(key="amazon", analytics_key="Amazon", name="Amazon", link="https://amazon.com/dp/123"),
+ ]
+ result = affiliate_links_template.render(primary_stores=stores, more_stores=[])
+ assert "" not in result
+
+ def test_special_characters_escaped(self, affiliate_links_template):
+ stores = [
+ _AffiliateStore(
+ key="amazon",
+ analytics_key="Amazon",
+ name="Amazon & Co.",
+ link="https://amazon.com/dp/123?tag=a&b=c",
+ price="$10 & up",
+ price_note=" + tax",
+ ),
+ ]
+ result = affiliate_links_template.render(primary_stores=stores, more_stores=[])
+ assert 'href="https://amazon.com/dp/123?tag=a&b=c"' in result
+ assert "Amazon & Co." in result
+ assert "$10 & up" in result
+
+ def test_all_fields_present_in_output(self, affiliate_links_template):
+ stores = [
+ _AffiliateStore(
+ key="test-key",
+ analytics_key="TestAnalytics",
+ name="Test Store",
+ link="https://example.com/book",
+ price="$19.99",
+ price_note=" - note",
+ ),
+ ]
+ result = affiliate_links_template.render(primary_stores=stores, more_stores=[])
+ assert "prices-test-key" in result
+ assert "TestAnalytics" in result
+ assert "Test Store" in result
+ assert "https://example.com/book" in result
+ assert "$19.99" in result
+ assert " - note" in result
+
+ def test_output_analytics_tracking_attr(self, affiliate_links_template):
+ stores = [
+ _AffiliateStore(
+ key="amazon",
+ analytics_key="Amazon",
+ name="Amazon",
+ link="https://amazon.com/dp/123",
+ ),
+ ]
+ result = affiliate_links_template.render(primary_stores=stores, more_stores=[])
+ assert 'data-ol-link-track="BuyLink|Amazon"' in result
+
+
+# --- Equivalence tests (need web.py for Templetor) ---
+
+
+@pytest.mark.skipif(not WEBPY_AVAILABLE or WEBPY_GETTEXT is None, reason="web.py or gettext not available")
+class TestTempletorEquivalence:
+ """Compares Jinja2 output against Templetor output for the same inputs.
+
+ Requires web.py (web-py) to be installed, which is only available
+ on Python 3.12 or earlier.
+ """
+
+ @pytest.fixture(scope="class")
+ def templetor_template(self):
+ web.template.Template.globals["_"] = WEBPY_GETTEXT
+ source = (MACROS_DIR / "AffiliateLinks.html").read_text()
+ return web.template.Template(source)
+
+ def _render_both(self, templetor_template, affiliate_links_template, stores, more):
+ templetor_html = str(templetor_template(stores, more))
+ jinja2_html = affiliate_links_template.render(primary_stores=stores, more_stores=more)
+ return templetor_html, jinja2_html
+
+ def test_empty_stores_match(self, templetor_template, affiliate_links_template):
+ t, j = self._render_both(templetor_template, affiliate_links_template, [], [])
+ assert _normalize_html(t) == _normalize_html(j)
+
+ def test_primary_stores_only_match(self, templetor_template, affiliate_links_template):
+ stores = [
+ _AffiliateStore(
+ key="betterworldbooks",
+ analytics_key="BetterWorldBooks",
+ name="Better World Books",
+ link="https://www.betterworldbooks.com/product/detail/123",
+ price="$9.99",
+ price_note=" - includes shipping",
+ ),
+ _AffiliateStore(
+ key="amazon",
+ analytics_key="Amazon",
+ name="Amazon",
+ link="https://www.amazon.com/dp/123/?tag=test",
+ price="$14.99",
+ price_note="",
+ ),
+ ]
+ t, j = self._render_both(templetor_template, affiliate_links_template, stores, [])
+ assert _normalize_html(t) == _normalize_html(j)
+
+ def test_primary_and_more_stores_match(self, templetor_template, affiliate_links_template):
+ stores = [
+ _AffiliateStore(key="amazon", analytics_key="Amazon", name="Amazon", link="https://amazon.com/dp/123"),
+ ]
+ more = [
+ _AffiliateStore(
+ key="bookshop-org",
+ analytics_key="BookshopOrg",
+ name="Bookshop.org",
+ link="https://bookshop.org/a/test/123",
+ ),
+ ]
+ t, j = self._render_both(templetor_template, affiliate_links_template, stores, more)
+ assert _normalize_html(t) == _normalize_html(j)
+
+ def test_multiple_stores_detailed_match(self, templetor_template, affiliate_links_template):
+ stores = [
+ _AffiliateStore(
+ key="betterworldbooks",
+ analytics_key="BetterWorldBooks",
+ name="Better World Books",
+ link="https://www.betterworldbooks.com/product/detail/9781234567890",
+ price="$4.99",
+ price_note=" - includes shipping",
+ ),
+ _AffiliateStore(
+ key="amazon",
+ analytics_key="Amazon",
+ name="Amazon.com",
+ link="https://www.amazon.com/dp/9781234567890/?tag=ol-20",
+ price="$12.50",
+ price_note="",
+ ),
+ ]
+ more = [
+ _AffiliateStore(
+ key="bookshop-org",
+ analytics_key="BookshopOrg",
+ name="Bookshop.org",
+ link="https://bookshop.org/a/1234/9781234567890",
+ ),
+ ]
+ t, j = self._render_both(templetor_template, affiliate_links_template, stores, more)
+ assert _normalize_html(t) == _normalize_html(j)
diff --git a/requirements.txt b/requirements.txt
index c3867b86b3a..c067ed4fa66 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,6 +16,7 @@ httpx==0.28.1
ijson==3.5.0
internetarchive==5.8.0
isbnlib==3.10.14
+Jinja2>=3.1.6
luqum==0.11.0
lxml==6.1.0
multipart==1.3.1