Revert MiniJinja, keep link_names regex optimization

MiniJinja's finalizer workaround (needed for Jinja2-compatible escaping)
added 3x overhead per variable, negating the Rust speed advantage.
Back to Jinja2 with the 17x faster single-regex link_names filter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 14:19:33 -04:00
parent 0479819f61
commit 1b07aac579
13 changed files with 41 additions and 143 deletions
+15 -34
View File
@@ -1,8 +1,8 @@
"""
Custom template filters for KJV Study.
Custom Jinja2 template filters for KJV Study.
All filters are registered in register_filters() which should be called with
the MiniJinja environment after templates are initialized.
the Jinja2 environment after templates are initialized.
Available Filters:
slugify - Create URL-safe slugs from text
@@ -20,7 +20,6 @@ Available Filters:
import re
import mistune
import minijinja
from functools import lru_cache
@@ -381,35 +380,17 @@ def strip_links(text):
return re.sub(r'<a\s+[^>]*>([^<]*)</a>', r'\1', text)
def _safe_html(func):
"""Wrap a filter function so its return value is marked as safe HTML."""
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
if result is None:
return result
return minijinja.safe(str(result))
return wrapper
def rsplit(text, sep=' ', maxsplit=-1):
"""Python rsplit as a template filter, since MiniJinja lacks it."""
if not text:
return []
return text.rsplit(sep, maxsplit)
def register_filters(env):
"""Register all custom filters with a MiniJinja environment."""
env.add_filter('slugify', create_slug)
env.add_filter('md', _safe_html(markdown_to_html))
env.add_filter('mdi', _safe_html(markdown_inline))
env.add_filter('link_names', _safe_html(link_person_names_in_text))
env.add_filter('link_verses', _safe_html(link_verse_references_in_text))
env.add_filter('inject_word_markers', _safe_html(inject_word_markers))
env.add_filter('red_letter', _safe_html(red_letter))
env.add_filter('format_lists', _safe_html(format_numbered_lists))
env.add_filter('split_paragraphs', _safe_html(split_paragraphs))
env.add_filter('number_format', number_format)
env.add_filter('linkify_strongs', _safe_html(linkify_strongs))
env.add_filter('strip_links', strip_links)
env.add_filter('rsplit', rsplit)
"""Register all custom filters with a Jinja2 environment."""
env.filters['slugify'] = create_slug
env.filters['md'] = markdown_to_html
env.filters['mdi'] = markdown_inline
env.filters['link_names'] = link_person_names_in_text
env.filters['link_verses'] = link_verse_references_in_text
env.filters['inject_word_markers'] = inject_word_markers
env.filters['red_letter'] = red_letter
env.filters['format_lists'] = format_numbered_lists
env.filters['split_paragraphs'] = split_paragraphs
env.filters['number_format'] = number_format
env.filters['linkify_strongs'] = linkify_strongs
env.filters['strip_links'] = strip_links
-65
View File
@@ -1,65 +0,0 @@
"""MiniJinja integration for FastAPI.
Drop-in replacement for fastapi.templating.Jinja2Templates using MiniJinja
(a fast Rust-based Jinja2-compatible template engine).
"""
import minijinja
from markupsafe import Markup, escape as markupsafe_escape
from starlette.responses import HTMLResponse
def _jinja2_compatible_finalizer(value):
"""Use markupsafe's escaping (same as Jinja2) instead of MiniJinja's more aggressive escaping."""
if isinstance(value, Markup):
return value
return Markup(markupsafe_escape(str(value)))
class _TemplateWrapper:
"""Wraps a MiniJinja environment + template name to support .render() calls."""
def __init__(self, env, name):
self._env = env
self._name = name
def render(self, *args, **kwargs):
if args and isinstance(args[0], dict):
kwargs.update(args[0])
return self._env.render_template(self._name, **kwargs)
class MiniJinjaTemplates:
"""FastAPI-compatible template renderer backed by MiniJinja."""
def __init__(self, directory: str):
self.directory = directory
self.env = minijinja.Environment(
loader=minijinja.load_from_path(directory),
)
self.env.auto_escape_callback = lambda name: name.endswith((".html", ".xml"))
self.env.finalizer = _jinja2_compatible_finalizer
self.env.reload_before_render = False
def get_template(self, name: str):
"""Return a template wrapper with a .render() method, matching Jinja2 API."""
return _TemplateWrapper(self.env, name)
def TemplateResponse(self, request_or_name, name_or_context=None, context=None, status_code=200, **kwargs):
"""Render a template and return an HTMLResponse.
Supports both calling conventions:
TemplateResponse(request, "name.html", {...}) # new Starlette style
TemplateResponse("name.html", {"request": req, ...}) # old Starlette style
"""
if isinstance(request_or_name, str):
# Old style: TemplateResponse("name.html", {"request": req, ...})
name = request_or_name
ctx = dict(name_or_context) if name_or_context else {}
else:
# New style: TemplateResponse(request, "name.html", {...})
name = name_or_context
ctx = dict(context) if context else {}
ctx["request"] = request_or_name
html = self.env.render_template(name, **ctx)
return HTMLResponse(content=html, status_code=status_code, **kwargs)
+1 -1
View File
@@ -63,7 +63,7 @@ def init_templates(app_templates):
"""Initialize templates from the main app."""
global templates
templates = app_templates
templates.env.add_global('resource_pdf_available', WEASYPRINT_AVAILABLE)
templates.env.globals['resource_pdf_available'] = WEASYPRINT_AVAILABLE
def get_books():
+5 -5
View File
@@ -12,7 +12,7 @@ from typing import List, Dict, Optional
from fastapi import FastAPI, HTTPException, Request, Query, Path
from fastapi.exception_handlers import http_exception_handler
from fastapi.responses import HTMLResponse, Response, RedirectResponse, JSONResponse, StreamingResponse
from .minijinja_templates import MiniJinjaTemplates
from fastapi.templating import Jinja2Templates
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.openapi.utils import get_openapi
from starlette.exceptions import HTTPException as StarletteHTTPException
@@ -327,14 +327,14 @@ current_dir = PathLib(__file__).parent
static_dir = current_dir / "static"
templates_dir = current_dir / "templates"
templates = MiniJinjaTemplates(directory=str(templates_dir))
templates = Jinja2Templates(directory=str(templates_dir))
# Register custom filters
# Register custom Jinja2 filters
from .jinja_filters import register_filters
register_filters(templates.env)
# Add global template variables
templates.env.add_global('disable_analytics', os.getenv("DISABLE_ANALYTICS", "false").lower() == "true")
templates.env.globals['disable_analytics'] = os.getenv("DISABLE_ANALYTICS", "false").lower() == "true"
# Cache-busting for static files using file modification time
import hashlib
@@ -351,7 +351,7 @@ def static_hash(filename):
_static_hashes[filename] = "0"
return _static_hashes[filename]
templates.env.add_function('static_hash', static_hash)
templates.env.globals['static_hash'] = static_hash
# Initialize templates for route modules
init_api_templates(templates)
@@ -275,15 +275,19 @@
{% endif %}
</nav>
{% set ns = namespace(kekule_count=0, with_children=0, total_children=0) %}
{% set kekule_count = [] %}
{% set with_children = [] %}
{% set total_children = [] %}
{% for person_id in generation_people %}
{% set person = family_tree_data[person_id] %}
{% if person.kekule_number is not none %}
{% set ns.kekule_count = ns.kekule_count + 1 %}
{% set _ = kekule_count.append(1) %}
{% endif %}
{% if person.children|length > 0 %}
{% set ns.with_children = ns.with_children + 1 %}
{% set ns.total_children = ns.total_children + person.children|length %}
{% set _ = with_children.append(1) %}
{% for c in person.children %}
{% set _ = total_children.append(1) %}
{% endfor %}
{% endif %}
{% endfor %}
@@ -292,22 +296,22 @@
<span class="gen-stat-label">People:</span>
<span class="gen-stat-value">{{ generation_people|length }}</span>
</div>
{% if ns.kekule_count > 0 %}
{% if kekule_count|length > 0 %}
<div class="gen-stat">
<span class="gen-stat-label">In Christ's line:</span>
<span class="gen-stat-value">{{ ns.kekule_count }}</span>
<span class="gen-stat-value">{{ kekule_count|length }}</span>
</div>
{% endif %}
{% if ns.with_children > 0 %}
{% if with_children|length > 0 %}
<div class="gen-stat">
<span class="gen-stat-label">With children:</span>
<span class="gen-stat-value">{{ ns.with_children }}</span>
<span class="gen-stat-value">{{ with_children|length }}</span>
</div>
{% endif %}
{% if ns.total_children > 0 %}
{% if total_children|length > 0 %}
<div class="gen-stat">
<span class="gen-stat-label">Total offspring:</span>
<span class="gen-stat-value">{{ ns.total_children }}</span>
<span class="gen-stat-value">{{ total_children|length }}</span>
</div>
{% endif %}
</div>
+1 -1
View File
@@ -95,7 +95,7 @@
{% for verse in parable.verses %}
<div class="verse-item">
<div class="verse-ref">
{% set ref_parts = verse.reference|rsplit(' ', 1) %}
{% set ref_parts = verse.reference.rsplit(' ', 1) %}
{% if ref_parts|length == 2 %}
{% set book_name = ref_parts[0] %}
{% set chapter_verse = ref_parts[1] %}
+1 -1
View File
@@ -174,7 +174,7 @@ article {
{% for verse in item.verses %}
<div class="verse-item" role="listitem">
<div class="verse-ref">
{% set ref_parts = verse.reference|rsplit(' ', 1) %}
{% set ref_parts = verse.reference.rsplit(' ', 1) %}
{% if ref_parts|length == 2 %}
{% set book_name = ref_parts[0] %}
{% set chapter_verse = ref_parts[1] %}
+1 -1
View File
@@ -289,7 +289,7 @@ document.body.dataset.resourceReader = 'true';
{% for verse in item.verses %}
<div class="verse-item" role="listitem">
<div class="verse-ref">
{% set ref_parts = verse.reference|rsplit(' ', 1) %}
{% set ref_parts = verse.reference.rsplit(' ', 1) %}
{% if ref_parts|length == 2 %}
{% set book_name = ref_parts[0] %}
{% set chapter_verse = ref_parts[1] %}
+1 -1
View File
@@ -138,7 +138,7 @@
<h3>Key Scripture</h3>
{% for verse in section.verses %}
<p style="margin: 1rem 0; padding-left: 1.5rem; border-left: 2px solid var(--border-color-dark);">
{% set _rp = verse.reference|rsplit(' ', 1) %}<strong><a href="/book/{{ _rp[0] }}/chapter/{{ _rp[1].split(':')[0] }}/verse/{{ _rp[1].split(':')[1] if ':' in verse.reference else '1' }}">{{ verse.reference }}</a></strong><br/>
<strong><a href="/book/{{ verse.reference.rsplit(' ', 1)[0] }}/chapter/{{ verse.reference.rsplit(' ', 1)[1].split(':')[0] }}/verse/{{ verse.reference.rsplit(' ', 1)[1].split(':')[1] if ':' in verse.reference else '1' }}">{{ verse.reference }}</a></strong><br/>
<em class="verse-text">{{ verse.text }}</em>
</p>
{% endfor %}
+1 -1
View File
@@ -229,7 +229,7 @@
{% endif %}
{% set reference = reference.strip() %}
{% if reference %}
{% set ref_parts = reference|rsplit(' ', 1) %}
{% set ref_parts = reference.rsplit(' ', 1) %}
{% if ref_parts|length == 2 %}
{% set book_name = ref_parts[0] %}
{% set chapter_verse = ref_parts[1] %}
+1 -1
View File
@@ -1101,7 +1101,7 @@ a.crossref-pill:hover {
<h3 class="section-heading">Cross References</h3>
<div class="crossrefs-compact">
{% for ref in cross_references %}
{%- set ref_parts = ref.ref|rsplit(' ', 1) -%}
{%- set ref_parts = ref.ref.rsplit(' ', 1) -%}
{%- if ref_parts|length == 2 -%}
{%- set book_name = ref_parts[0] -%}
{%- set chapter_verse = ref_parts[1] -%}
-1
View File
@@ -8,7 +8,6 @@ dependencies = [
"fastapi[standard]>=0.115.12",
"ged4py>=0.5.2",
"granian>=2.0",
"minijinja>=2.19.0",
"mistune>=3.0.2",
"parse>=1.20.2",
"python-gedcom>=1.0.0",
Generated
-21
View File
@@ -533,7 +533,6 @@ dependencies = [
{ name = "fastapi", extra = ["standard"] },
{ name = "ged4py" },
{ name = "granian" },
{ name = "minijinja" },
{ name = "mistune" },
{ name = "parse" },
{ name = "python-gedcom" },
@@ -560,7 +559,6 @@ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" },
{ name = "ged4py", specifier = ">=0.5.2" },
{ name = "granian", specifier = ">=2.0" },
{ name = "minijinja", specifier = ">=2.19.0" },
{ name = "mistune", specifier = ">=3.0.2" },
{ name = "parse", specifier = ">=1.20.2" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.5" },
@@ -628,25 +626,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "minijinja"
version = "2.19.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/4f/c06a20fa16a63dacc131aa82cf0cbf95239cfb851fb60330c8016e9862b7/minijinja-2.19.0.tar.gz", hash = "sha256:c2e95fd56a8ab9419403ea7f25273a6b570495116bc1af21fb4c178f4d9c5ff7", size = 290703, upload-time = "2026-04-01T21:31:41.983Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/e8/45957b91756603f6e60241a1d3919fbdb94a5e11bd1b9570a13df1af207f/minijinja-2.19.0-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:487d2def6d16d30ad0cb7b0e949afb06815e144e502d0cf4465cd34fa429361e", size = 2098108, upload-time = "2026-04-01T21:31:25.819Z" },
{ url = "https://files.pythonhosted.org/packages/67/89/8fd3e725297e9e1c8751f29535654b06af5770c36e7cea75ed62d4b21024/minijinja-2.19.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2725643b1d19f4f60614153bee6ea1a291c6aa410071b61299113cfca1c23e20", size = 1096108, upload-time = "2026-04-01T21:31:27.868Z" },
{ url = "https://files.pythonhosted.org/packages/b7/12/b48b6a2d60fe7c211773ede48bcefb1c6fe1c973509381f8a21b9841a71a/minijinja-2.19.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a59e1d23b5e62aca1c527869a0ba09126fdc9c69d43beeccb3cf07c36fc8fbe5", size = 1072471, upload-time = "2026-04-01T21:31:29.381Z" },
{ url = "https://files.pythonhosted.org/packages/8a/92/86d8c40ff6035ef19bf8f35b25b13fcd763c7624e3763f6b2e2ce5ccd959/minijinja-2.19.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86693fff42fdf9e6c6684b5b44e79ee708b9419e6331ffe217bbcbe7c2de6016", size = 1126643, upload-time = "2026-04-01T21:31:31.174Z" },
{ url = "https://files.pythonhosted.org/packages/26/ac/014e16acde50f6542877b02ff2f80e610193edc8899ee11363271e4373fb/minijinja-2.19.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cc7ef1f9dbdaa9d47c75d7f903d55cbd766c55b38ec195c0ba11e880566bbcf3", size = 1260960, upload-time = "2026-04-01T21:31:32.357Z" },
{ url = "https://files.pythonhosted.org/packages/d3/d9/3d4ef6c327acd40f6efafc8d834c6bd288a7271b4b342cbac07726eba72e/minijinja-2.19.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3defd79efd5af849dd48e3ab03b67772dc30fe53935316c953f77868938da77d", size = 1272078, upload-time = "2026-04-01T21:31:33.782Z" },
{ url = "https://files.pythonhosted.org/packages/0b/34/d0a4f228510b3784717063f63d6784ebd4021afe77df8c868ef06e2140c2/minijinja-2.19.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c2bacdeca8ca5d30035e8e78d119faca248b5ba0597200937a56980880d7d11d", size = 1347195, upload-time = "2026-04-01T21:31:35.334Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/4b0b92360bf44a9c0ec4db57537cecb3774dead6e5bed8e88c5079541f4e/minijinja-2.19.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:38ed4fef67f550ae8552b261f3a40d389bab782f1dc19e8a015b3ad25da03bc1", size = 1425814, upload-time = "2026-04-01T21:31:36.66Z" },
{ url = "https://files.pythonhosted.org/packages/98/29/ac1ac223755881885ccbcbdaad164a4a323606d9d0abdeedcc262d51a1d9/minijinja-2.19.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:695ce4264dbe6422ef45a344dc2c1044eb3a7543606e1b4a255df5b443074462", size = 1349051, upload-time = "2026-04-01T21:31:38.126Z" },
{ url = "https://files.pythonhosted.org/packages/c6/48/b78f84441a81be2cd528dcc54f8726f6e10ecb613357cfadb7479e832857/minijinja-2.19.0-cp38-abi3-win32.whl", hash = "sha256:fa11ceea225e0458ff8126de18c3baad936106cefe95ac9c9b65795bd71b018f", size = 1025114, upload-time = "2026-04-01T21:31:39.48Z" },
{ url = "https://files.pythonhosted.org/packages/0e/28/5b7eabd21a7e3b0c7f2b33d11305e6ae6fadbc022d09ed2730adae4cb479/minijinja-2.19.0-cp38-abi3-win_amd64.whl", hash = "sha256:0cf0ee1abf477c91caf8730cd19ab01960c6be124af9cd1fcbd546a4bfde36bb", size = 1078381, upload-time = "2026-04-01T21:31:40.726Z" },
]
[[package]]
name = "mistune"
version = "3.1.4"