diff --git a/pyproject.toml b/pyproject.toml
index 8a7a0cab0..9fd97ff0f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,6 +47,8 @@ dependencies = [
"zarr >=3.1",
"typing-extensions; python_version<'3.13'",
"scverse-misc>=0.0.3",
+ "jinja2>=3.1",
+ "markupsafe>=3.0",
]
dynamic = [ "version" ]
diff --git a/src/anndata/_repr/environment.py b/src/anndata/_repr/environment.py
new file mode 100644
index 000000000..8382147b9
--- /dev/null
+++ b/src/anndata/_repr/environment.py
@@ -0,0 +1,35 @@
+"""
+Jinja2 Environment for the AnnData HTML repr (middle-ground POC).
+
+This module wires Jinja2 into the existing repr pipeline in a minimal way:
+
+- A single autoescape-enabled ``Environment`` loads templates from
+ ``anndata._repr.templates``.
+- The existing formatter machinery still produces HTML fragments as strings;
+ the top-level renderer wraps those fragments in ``markupsafe.Markup`` at the
+ boundary so they pass through autoescape verbatim.
+- Any additional values injected directly into the outer template (container
+ id, depth, inline style, etc.) are autoescaped by default, which closes the
+ "forgot to call ``html.escape()``" class of bug for those specific
+ insertions.
+
+This is deliberately narrow in scope. It illustrates the trust contract
+(``Markup`` = trusted, ``str`` = untrusted) without rewriting the per-type
+formatters.
+"""
+
+from __future__ import annotations
+
+from functools import cache
+
+from jinja2 import Environment, PackageLoader, select_autoescape
+
+
+@cache
+def get_env() -> Environment:
+ return Environment(
+ loader=PackageLoader("anndata._repr", "templates"),
+ autoescape=select_autoescape(default=True, default_for_string=True),
+ trim_blocks=True,
+ lstrip_blocks=True,
+ )
diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py
index 9cae219cb..114e54414 100644
--- a/src/anndata/_repr/html.py
+++ b/src/anndata/_repr/html.py
@@ -14,6 +14,10 @@
import uuid
from typing import TYPE_CHECKING
+from markupsafe import Markup
+
+from .environment import get_env
+
from .._repr_constants import (
CSS_BADGE_BACKED,
CSS_BADGE_EXTENSION,
@@ -267,13 +271,6 @@ def generate_repr_html( # noqa: PLR0913
# Generate unique container ID
container_id = _container_id or f"anndata-repr-{uuid.uuid4().hex[:8]}"
- # Build HTML parts
- parts = []
-
- # CSS and JS only at top level
- if depth == 0:
- parts.append(get_css())
-
# Calculate field name column width based on content
max_field_width = get_setting(
"repr_html_max_field_width", default=DEFAULT_MAX_FIELD_WIDTH
@@ -283,61 +280,66 @@ def generate_repr_html( # noqa: PLR0913
# Get type column width from settings
type_width = get_setting("repr_html_type_width", default=DEFAULT_TYPE_WIDTH)
- # Container with computed column widths as CSS variables.
- # Inline font-family:monospace provides readable fallback when CSS is stripped
- # (GitHub, untrusted notebooks). CSS overrides with its own font stack.
- # Inline min-width on cells + CSS custom properties give column alignment
- # even without a stylesheet.
- style = f"font-family: monospace; --anndata-name-col-width: {field_width}px; --anndata-type-col-width: {type_width}px;"
- parts.append(
- f'
'
+ # Computed column widths as CSS variables. Inline font-family:monospace
+ # provides a readable fallback when CSS is stripped (GitHub, untrusted
+ # notebooks).
+ style = (
+ f"font-family: monospace; "
+ f"--anndata-name-col-width: {field_width}px; "
+ f"--anndata-type-col-width: {type_width}px;"
)
- # Header (with search box integrated on the right)
+ # Gather already-rendered HTML fragments and mark them as trusted Markup.
+ # Each of these is produced by existing formatter/renderer code; wrapping
+ # at this boundary is the trust assertion the POC illustrates.
+ header_html: Markup | None = None
if show_header:
- parts.append(
+ header_html = Markup(
_render_header(
- adata, show_search=show_search and depth == 0, container_id=container_id
+ adata,
+ show_search=show_search and depth == 0,
+ container_id=container_id,
)
)
- # Index preview (only at top level)
- if depth == 0:
- parts.append(_render_index_preview(adata))
-
- # Sections container
- parts.append('
')
- parts.extend(_render_all_sections(adata, context))
- parts.append("
") # anndata-repr__sections
-
- # Footer with metadata (only at top level)
+ index_preview_html: Markup | None = None
+ footer_html: Markup | None = None
+ hints_html: Markup | None = None
+ css_html: Markup | None = None
+ javascript_html: Markup | None = None
if depth == 0:
- parts.append(_render_footer(adata))
- # Degradation hints: visible only when CSS or JS is missing.
- # No-CSS hint: visible by default, hidden by CSS.
- parts.append(
+ index_preview_html = Markup(_render_index_preview(adata))
+ footer_html = Markup(_render_footer(adata))
+ hints_html = Markup(
'
'
"Styled representation available in Jupyter and trusted notebooks "
"(colors, search, type highlighting)."
"
"
- )
- # No-JS hint: hidden by default (no-CSS case already has its own hint),
- # shown by CSS (for static HTML with styles but no JS),
- # hidden again by JS on init.
- parts.append(
'
'
"Interactive features (search, copy, category wrapping) "
"require JavaScript. Trust this notebook to enable them."
"
"
)
+ css_html = Markup(get_css())
+ javascript_html = Markup(get_javascript(container_id))
- parts.append("
") # anndata-repr
-
- # JavaScript (only at top level)
- if depth == 0:
- parts.append(get_javascript(container_id))
+ sections_markup = [Markup(s) for s in _render_all_sections(adata, context)]
- return "\n".join(parts)
+ # Render the outer template. `container_id`, `depth`, and `style` are
+ # plain strings and get autoescaped by the engine; the Markup-wrapped
+ # fragments pass through verbatim.
+ return get_env().get_template("anndata.j2").render(
+ container_id=container_id,
+ depth=depth,
+ style=style,
+ css=css_html,
+ header=header_html,
+ index_preview=index_preview_html,
+ sections=sections_markup,
+ footer=footer_html,
+ hints=hints_html,
+ javascript=javascript_html,
+ )
def _render_all_sections(
diff --git a/src/anndata/_repr/templates/anndata.j2 b/src/anndata/_repr/templates/anndata.j2
new file mode 100644
index 000000000..a3d202251
--- /dev/null
+++ b/src/anndata/_repr/templates/anndata.j2
@@ -0,0 +1,18 @@
+{# Outer AnnData repr template (middle-ground POC).
+ All fragments (header, sections, footer, css, js) are rendered by the
+ existing Python code and arrive here as Markup — they pass through
+ autoescape verbatim.
+ The remaining interpolations ({{ container_id }}, {{ depth }},
+ {{ style }}) are user-adjacent values; Jinja autoescapes them by default.
+#}
+{% if css %}{{ css }}{% endif %}
+
+ {% if header %}{{ header }}{% endif %}
+ {% if index_preview %}{{ index_preview }}{% endif %}
+
+ {% for section in sections %}{{ section }}{% endfor %}
+
+ {% if footer %}{{ footer }}{% endif %}
+ {% if hints %}{{ hints }}{% endif %}
+
+{% if javascript %}{{ javascript }}{% endif %}