Skip to content

Add i18n support for web UI (German + English)#5

Merged
JuliusScheuerer merged 2 commits into
mainfrom
feature/i18n
Mar 5, 2026
Merged

Add i18n support for web UI (German + English)#5
JuliusScheuerer merged 2 commits into
mainfrom
feature/i18n

Conversation

@JuliusScheuerer
Copy link
Copy Markdown
Owner

@JuliusScheuerer JuliusScheuerer commented Mar 5, 2026

Summary

  • Add JSON-based i18n module (src/document_anonymizer/i18n/) with LRU-cached translation loading, TypeGuard narrowing, and Jinja2 integration
  • Support language switching via ?lang= query param or persisted cookie (httponly, secure, samesite=lax)
  • Replace all hardcoded UI strings with server-side _("key") (Jinja2) and client-side window.__t("key") calls
  • Add German (53 keys) and English (53 keys) translation files with test-enforced key parity

Security

  • html.escape() on all translated strings interpolated into HTML attributes
  • </<\/ escaping in embedded translations JSON to prevent </script> breakout XSS
  • Language input validated via is_supported_lang() TypeGuard — no type: ignore needed
  • Cookie attributes: httponly=True, secure=True, samesite="lax"

Test plan

  • 19 unit tests for i18n module (translate, get_translations, jinja_translate, file consistency)
  • 17 integration tests (language switching, cookie persistence, query param override, English errors, XSS escaping, HTML lang attribute, tooltip i18n)
  • Existing tests updated with explicit cookies={"lang": "de"} where asserting German strings
  • match= patterns removed from pytest.raises for language-dependent error messages
  • All pre-commit hooks pass (ruff, ruff format, mypy strict, bandit)
  • 94%+ coverage on i18n module and modified routes

Summary by CodeRabbit

  • New Features

    • Added full multilingual support (German/English) across the UI, templates, and client scripts
    • Language switcher in the header; preference persists via cookie and query-param switching
    • Client-side translation API and runtime translation bootstrap for dynamic text
  • Bug Fixes

    • Improved fallback and error handling for missing/unsupported languages and missing translation keys
  • Tests

    • Added unit and integration tests covering translations, language switching, and UI text rendering

Introduce a JSON-based translation system with cookie-persisted language
switching. All UI strings are now translatable via server-side Jinja2 _()
and client-side window.__t() helpers.

New files:
- i18n module with LRU-cached translation loading and TypeGuard narrowing
- Translation files (de.json, en.json) with 53 keys each
- Client-side i18n.js helper with global placeholder replacement
- Unit tests (19) and integration tests (17) for i18n

Key changes:
- Language detection: query param > cookie > default (German)
- Secure cookie (httponly, secure, samesite=lax) set on explicit switch
- XSS prevention: html.escape on all interpolated values, </script>
  breakout escaping in embedded JSON
- TypeGuard is_supported_lang() eliminates type: ignore comments
- Existing tests pinned to explicit language via cookies
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 5, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9e37650d-4b6b-4d84-94f8-a7254d205156

📥 Commits

Reviewing files that changed from the base of the PR and between 879d3e5 and 821adb4.

📒 Files selected for processing (1)
  • src/document_anonymizer/web/routes.py

📝 Walkthrough

Walkthrough

Adds full internationalization: a new i18n Python module with German/English translation files, server-side language detection and Jinja integration, client-side translation bootstrap and helpers, template/JS text replaced with translation keys, and tests validating translations and language switching.

Changes

Cohort / File(s) Summary
Core i18n Module
src/document_anonymizer/i18n/__init__.py
New module: Lang type, supported/default languages, LRU-cached translation loading, get_translations, translate, is_supported_lang, and jinja_translate for Jinja integration with error handling and logging.
Translation Files
src/document_anonymizer/i18n/translations/de.json, src/document_anonymizer/i18n/translations/en.json
Added German and English JSON translation resources covering UI labels, headings, errors, placeholders, and messages.
Web Routes Internationalization
src/document_anonymizer/web/routes.py
Language detection via query/cookie, new _get_lang() and _template_response(), propagate lang through helpers (_parse_selected_entities_json, _report_skipped, _reconstruct_*, _build_highlighted_text), wire Jinja global _ and return translations_json to client.
Client-side i18n
src/document_anonymizer/web/static/js/i18n.js, src/document_anonymizer/web/static/js/app.js, src/document_anonymizer/web/static/js/review.js
Add window.__t() bootstrap and replacement of hardcoded German strings with __t() lookups; safe fallback when translations missing; placeholder interpolation support.
HTML Templates Localization
src/document_anonymizer/web/templates/base.html, .../index.html, .../results.html, .../anonymized.html
All hardcoded German text replaced with _("key") lookups; base.html exposes translations_json, sets dynamic lang attribute, and adds language switcher and localized noscript/footer.
Tests: i18n & Web
tests/test_i18n/test_translations.py, tests/test_web/test_i18n_integration.py, tests/test_web/test_highlighted_text.py, tests/test_web/test_review_helpers.py, tests/test_web/test_review_routes.py, tests/test_web/test_routes.py
New and updated tests covering translation loading/formatting/fallbacks, Jinja helper behavior, language switching via query/cookie, tooltip language in highlights, and adjusted error-message assertions to be language-agnostic.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Server
    participant Templates
    participant FS as Translations FS

    Client->>Server: HTTP GET / (may include ?lang=)
    Server->>Server: _get_lang(request) (query -> cookie -> default)
    Server->>FS: _load_translations_cached(lang)
    FS-->>Server: translations JSON
    Server->>Templates: render template with _ (jinja_translate) and translations_json
    Templates-->>Server: rendered HTML (window.__translations embedded)
    Server-->>Client: HTML response (sets lang cookie if requested)
    Client->>Client: window.__t(key) uses window.__translations for runtime UI strings
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 Hoppity-hop, translations sprout,

From routes to scripts I dart about.
Deutsch and English, now both can sing,
Every label, tooltip — a multilingual spring.
🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add i18n support for web UI (German + English)' directly and accurately describes the main change in the pull request, which introduces internationalization (i18n) support with German and English translations across the web UI.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/i18n

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
src/document_anonymizer/i18n/__init__.py (1)

16-16: Consider using the project's get_logger helper for consistency.

The codebase has a get_logger utility in src/document_anonymizer/audit/logging.py that wraps structlog.get_logger. For consistency across modules, consider using it here instead of calling structlog.get_logger directly.

♻️ Suggested change
-import structlog
+from document_anonymizer.audit.logging import get_logger
 from jinja2 import pass_context
 
-logger = structlog.get_logger(__name__)
+logger = get_logger(__name__)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/document_anonymizer/i18n/__init__.py` at line 16, Replace the direct call
to structlog.get_logger in the module-level assignment (logger =
structlog.get_logger(__name__)) with the project's wrapper get_logger to keep
logging consistent; import get_logger from document_anonymizer.audit.logging at
the top of the file and change the assignment to use get_logger(__name__)
(retain the existing logger variable name) so the module uses the centralized
logging helper instead of structlog.get_logger directly.
tests/test_web/test_i18n_integration.py (1)

8-8: Minor: Module-level TestClient instead of fixture.

The module creates a TestClient directly rather than using the client fixture from conftest.py. This works but is inconsistent with the existing test pattern. Since these tests are read-only and don't modify state, it's functionally fine.

🔧 Suggested alignment with conftest pattern
-client = TestClient(app)
+import pytest
+
+@pytest.fixture
+def client() -> TestClient:
+    return TestClient(app)

Then inject client as a parameter in each test method.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_web/test_i18n_integration.py` at line 8, Replace the module-level
TestClient instantiation ("client = TestClient(app)") with the existing pytest
fixture by removing that line and updating each test function in this file to
accept the "client" fixture as a parameter; ensure tests use the injected
"client" (from conftest.py) instead of the removed TestClient and keep test
bodies unchanged other than the parameter.
src/document_anonymizer/web/templates/base.html (1)

8-8: Consider translating meta description for SEO.

The <meta name="description"> remains hardcoded in German while other UI text is now internationalized. This affects SEO for non-German users.

🌐 Suggested fix
-    <meta name="description" content="Datenschutzkonforme Dokumentenanonymisierung mit deutscher PII-Erkennung. Zero-Persistence-Architektur.">
+    <meta name="description" content="{{ _('meta.description') }}">

Then add meta.description keys to both translation files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/document_anonymizer/web/templates/base.html` at line 8, The meta
description in the base.html template is hardcoded in German; replace the static
content attribute with the i18n lookup used elsewhere (e.g., use the same
translation helper/tag used for UI strings) and add a new translation key
meta.description to both language resource files (e.g., en and de) so SEO uses
localized descriptions; ensure the template uses the translation key
(meta.description) consistently and that both translation files contain
appropriate localized texts.
src/document_anonymizer/web/static/js/app.js (1)

24-39: Consider: German-only example text may confuse English users.

The EXAMPLE_TEXT is hardcoded German sample data. While this is demo content rather than UI text, English users may find it less intuitive. This is a minor UX consideration that could be addressed in a follow-up if needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/document_anonymizer/web/static/js/app.js` around lines 24 - 39,
EXAMPLE_TEXT in src/document_anonymizer/web/static/js/app.js currently contains
only German sample data; replace it with an English example or provide
locale-aware examples so English users aren’t confused. Update the code to
define two constants (e.g., EXAMPLE_TEXT_EN and EXAMPLE_TEXT_DE) and set
EXAMPLE_TEXT dynamically based on navigator.language (or a simple language
toggle) so the UI uses the appropriate sample; ensure the variable name
EXAMPLE_TEXT remains the one consumed elsewhere so no further changes are
needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/document_anonymizer/web/routes.py`:
- Around line 111-118: The cookie is being set with secure=True unconditionally
in the response.set_cookie call (where query_lang is written) which prevents the
cookie in local HTTP dev; change the secure flag to be conditional on the
environment (use APP_ENV) so secure=False for development and secure=True
otherwise, and ensure os.getenv("APP_ENV") (or the existing config variable) is
imported/used when constructing the set_cookie arguments in the same function
that handles the query_lang and response.set_cookie call.

---

Nitpick comments:
In `@src/document_anonymizer/i18n/__init__.py`:
- Line 16: Replace the direct call to structlog.get_logger in the module-level
assignment (logger = structlog.get_logger(__name__)) with the project's wrapper
get_logger to keep logging consistent; import get_logger from
document_anonymizer.audit.logging at the top of the file and change the
assignment to use get_logger(__name__) (retain the existing logger variable
name) so the module uses the centralized logging helper instead of
structlog.get_logger directly.

In `@src/document_anonymizer/web/static/js/app.js`:
- Around line 24-39: EXAMPLE_TEXT in
src/document_anonymizer/web/static/js/app.js currently contains only German
sample data; replace it with an English example or provide locale-aware examples
so English users aren’t confused. Update the code to define two constants (e.g.,
EXAMPLE_TEXT_EN and EXAMPLE_TEXT_DE) and set EXAMPLE_TEXT dynamically based on
navigator.language (or a simple language toggle) so the UI uses the appropriate
sample; ensure the variable name EXAMPLE_TEXT remains the one consumed elsewhere
so no further changes are needed.

In `@src/document_anonymizer/web/templates/base.html`:
- Line 8: The meta description in the base.html template is hardcoded in German;
replace the static content attribute with the i18n lookup used elsewhere (e.g.,
use the same translation helper/tag used for UI strings) and add a new
translation key meta.description to both language resource files (e.g., en and
de) so SEO uses localized descriptions; ensure the template uses the translation
key (meta.description) consistently and that both translation files contain
appropriate localized texts.

In `@tests/test_web/test_i18n_integration.py`:
- Line 8: Replace the module-level TestClient instantiation ("client =
TestClient(app)") with the existing pytest fixture by removing that line and
updating each test function in this file to accept the "client" fixture as a
parameter; ensure tests use the injected "client" (from conftest.py) instead of
the removed TestClient and keep test bodies unchanged other than the parameter.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f1a4c9be-01d3-4239-bf6e-998664bc9700

📥 Commits

Reviewing files that changed from the base of the PR and between 2fc8689 and 879d3e5.

📒 Files selected for processing (18)
  • src/document_anonymizer/i18n/__init__.py
  • src/document_anonymizer/i18n/translations/de.json
  • src/document_anonymizer/i18n/translations/en.json
  • src/document_anonymizer/web/routes.py
  • src/document_anonymizer/web/static/js/app.js
  • src/document_anonymizer/web/static/js/i18n.js
  • src/document_anonymizer/web/static/js/review.js
  • src/document_anonymizer/web/templates/anonymized.html
  • src/document_anonymizer/web/templates/base.html
  • src/document_anonymizer/web/templates/index.html
  • src/document_anonymizer/web/templates/results.html
  • tests/test_i18n/__init__.py
  • tests/test_i18n/test_translations.py
  • tests/test_web/test_highlighted_text.py
  • tests/test_web/test_i18n_integration.py
  • tests/test_web/test_review_helpers.py
  • tests/test_web/test_review_routes.py
  • tests/test_web/test_routes.py

Comment on lines +111 to +118
response.set_cookie(
"lang",
query_lang,
httponly=True,
secure=True,
samesite="lax",
max_age=365 * 24 * 3600,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's any environment-based configuration for secure cookies
rg -n "secure.*cookie|SECURE.*COOKIE|https.*local" --type=py

Repository: JuliusScheuerer/document-anonymizer

Length of output: 61


🏁 Script executed:

# Examine the routes.py file to understand the full context
head -n 125 src/document_anonymizer/web/routes.py | tail -n 30

Repository: JuliusScheuerer/document-anonymizer

Length of output: 1036


🏁 Script executed:

# Search for environment variable usage and configuration patterns
rg -n "environ|config|getenv|FLASK_ENV|DEBUG" --type=py src/document_anonymizer/web/ | head -20

Repository: JuliusScheuerer/document-anonymizer

Length of output: 61


🏁 Script executed:

# Look for any development or setup documentation
find . -type f \( -name "*.md" -o -name "*.txt" -o -name "*.rst" \) | xargs rg -l "development\|local\|https\|secure" 2>/dev/null | head -10

Repository: JuliusScheuerer/document-anonymizer

Length of output: 61


🏁 Script executed:

# Search for Flask app initialization and configuration
rg -n "Flask\|app\s*=\|create_app" --type=py src/document_anonymizer/web/ | head -20

Repository: JuliusScheuerer/document-anonymizer

Length of output: 61


🏁 Script executed:

# Check main entry point and app setup
find src -name "main.py" -o -name "__main__.py" -o -name "app.py" | xargs cat

Repository: JuliusScheuerer/document-anonymizer

Length of output: 3518


🏁 Script executed:

# Look for README or setup documentation
find . -maxdepth 2 -name "README*" -o -name "SETUP*" -o -name "DEVELOP*" | xargs cat 2>/dev/null | head -100

Repository: JuliusScheuerer/document-anonymizer

Length of output: 4474


🏁 Script executed:

# Check if there's any Docker or deployment configuration
find . -maxdepth 2 \( -name "Dockerfile" -o -name "docker-compose*" -o -name ".env*" \) | head -5

Repository: JuliusScheuerer/document-anonymizer

Length of output: 127


🏁 Script executed:

# Check .env.example and docker-compose.yml for HTTPS/secure configuration
cat .env.example docker-compose.yml

Repository: JuliusScheuerer/document-anonymizer

Length of output: 680


🏁 Script executed:

# Search for any environment variable controlling secure cookies or development mode
rg -n "ENVIRONMENT|DEVELOPMENT|PRODUCTION|DEBUG" --type=py src/ | head -20

Repository: JuliusScheuerer/document-anonymizer

Length of output: 150


🏁 Script executed:

# Check if secure flag is used anywhere else in the codebase
rg -n "secure\s*=" --type=py src/

Repository: JuliusScheuerer/document-anonymizer

Length of output: 145


Make the secure cookie flag conditional on environment — it's set unconditionally but local development uses HTTP.

The secure=True flag prevents the cookie from being sent over HTTP. Since the README shows local development runs on http://localhost:8000 (via uvicorn --reload), the language preference cookie will silently fail to be set in development. Use APP_ENV (already defined in .env.example) to conditionally set secure=False for development:

response.set_cookie(
    "lang",
    query_lang,
    httponly=True,
    secure=os.getenv("APP_ENV") != "development",
    samesite="lax",
    max_age=365 * 24 * 3600,
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/document_anonymizer/web/routes.py` around lines 111 - 118, The cookie is
being set with secure=True unconditionally in the response.set_cookie call
(where query_lang is written) which prevents the cookie in local HTTP dev;
change the secure flag to be conditional on the environment (use APP_ENV) so
secure=False for development and secure=True otherwise, and ensure
os.getenv("APP_ENV") (or the existing config variable) is imported/used when
constructing the set_cookie arguments in the same function that handles the
query_lang and response.set_cookie call.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 5, 2026

Greptile Summary

This PR adds a solid JSON-based i18n module with LRU-cached translation loading, server-side Jinja2 integration (_("key")), and client-side window.__t() support, enabling German/English language switching via query param or cookie. The design is well-structured and the security measures (html.escape, <\/ escaping, TypeGuard narrowing, httponly/secure/samesite cookie) are appropriate.

However, the PR contains a critical regression introduced while implementing the optimization suggested in a previous review thread:

  • routes.py lines 100–107 are at zero indentation (module level) instead of inside _template_response. This causes a SyntaxError: 'return' outside function at compile time — the module cannot be imported and the application cannot start.
  • Additionally, even if the indentation were fixed, the guard condition template_name in ("index.html", "base.html") is incorrect: "base.html" is never passed directly as template_name to _template_response, so it is dead code. Only "index.html" is a full-page template that extends base.html and renders window.__translations.

Key changes:

  • New src/document_anonymizer/i18n/ module with translate(), get_translations(), and jinja_translate() (Jinja2 global)
  • Language preference resolved via query param → cookie → default ("de")
  • 53-key German and English translation files with test-enforced key parity
  • All hardcoded UI strings replaced server-side (_("key")) and client-side (window.__t("key"))
  • Language switcher in the header, lang cookie set with httponly=True, secure=True, samesite="lax"

Confidence Score: 0/5

  • Not safe to merge — a SyntaxError in routes.py prevents the application from starting.
  • The indentation bug at lines 100–107 of routes.py moves an if/else block from inside the _template_response function to module level. Python detects return response (inside the resulting module-level else block) as SyntaxError: 'return' outside function at compile time. The module cannot be imported, crashing the application on startup. All other parts of the PR (i18n module, translations, templates, JS helpers, tests) are well-implemented, but none of that can run until this one-line indentation issue is resolved.
  • src/document_anonymizer/web/routes.py — lines 100–123 must be re-indented to sit inside _template_response, and the template guard condition must be corrected.

Important Files Changed

Filename Overview
src/document_anonymizer/web/routes.py Critical indentation bug: the if/else block at lines 100–107 landed at module level (0 indent) instead of inside _template_response, causing a SyntaxError: 'return' outside function that prevents the module from being imported and the application from starting.
src/document_anonymizer/i18n/init.py Well-structured i18n module: LRU-cached translation loading, TypeGuard narrowing for Lang, Jinja2 pass_context integration, and safe fallback behaviour on missing keys. Minor: cached function returns a mutable dict that callers could inadvertently corrupt.
src/document_anonymizer/web/static/js/i18n.js Clean client-side translation helper with {placeholder} substitution and graceful fallbacks; correctly wrapped in IIFE with strict mode.
src/document_anonymizer/web/templates/base.html Correctly embeds window.__translations via `translations_json
tests/test_web/test_i18n_integration.py Integration tests cover language switching, cookie persistence, query-param override, English error messages, XSS escaping of the translations JSON block, and HTML lang attribute. All would fail to import app until the SyntaxError in routes.py is fixed.

Sequence Diagram

sequenceDiagram
    participant Browser
    participant FastAPI as FastAPI (routes.py)
    participant i18n as i18n module
    participant Jinja2
    participant JS as Client JS (i18n.js / app.js / review.js)

    Browser->>FastAPI: GET / (optional ?lang=en or lang cookie)
    FastAPI->>i18n: _get_lang(request) → Lang
    FastAPI->>i18n: get_translations(lang) → dict
    FastAPI->>Jinja2: TemplateResponse("index.html", {lang, translations_json})
    Note over Jinja2: index.html extends base.html<br/>_("key") calls jinja_translate(context, key)<br/>window.__translations = translations_json
    Jinja2-->>Browser: Full HTML page with embedded translations JSON
    Browser->>JS: i18n.js loaded → sets window.__t()
    Browser->>JS: app.js loaded (defer) → uses window.__t()

    Browser->>FastAPI: POST /detect (HX-Request, lang cookie)
    FastAPI->>i18n: _get_lang(request) → Lang
    FastAPI->>Jinja2: TemplateResponse("results.html", {lang, ...})
    Note over Jinja2: Fragment — no base.html<br/>_("key") uses lang from context
    Jinja2-->>Browser: HTML fragment (HTMX swap into #results)
    Browser->>JS: review.js loaded → uses window.__t() (already set)

    Browser->>FastAPI: GET /?lang=en (language switch)
    FastAPI->>i18n: is_supported_lang("en") → True
    FastAPI->>FastAPI: set_cookie("lang", "en", httponly, secure, samesite=lax)
    FastAPI-->>Browser: Full page in English + Set-Cookie: lang=en
Loading

Last reviewed commit: 821adb4

Comment thread src/document_anonymizer/web/routes.py
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
@JuliusScheuerer JuliusScheuerer merged commit ddfe5b4 into main Mar 5, 2026
2 of 5 checks passed
@JuliusScheuerer JuliusScheuerer deleted the feature/i18n branch March 5, 2026 12:23
Comment on lines +100 to +123
# Only embed translations for full-page responses that include base.html
if template_name in ("index.html", "base.html"): # or check explicitly
all_translations = get_translations(lang)
ctx["translations_json"] = json.dumps(all_translations, ensure_ascii=False).replace(
"</", r"<\/"
)
else:
ctx["translations_json"] = "{}" # or omit entirely

response = templates.TemplateResponse(
request, template_name, ctx, status_code=status_code
)
# Only set cookie when user explicitly requests a language via query param
query_lang = request.query_params.get("lang", "")
if is_supported_lang(query_lang):
response.set_cookie(
"lang",
query_lang,
httponly=True,
secure=True,
samesite="lax",
max_age=365 * 24 * 3600,
)
return response
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SyntaxError: if/else block is at module level, not inside _template_response

Lines 100–107 have zero indentation, so Python parses them as module-level statements rather than as the body of _template_response. As a result:

  1. The function body silently ends at line 98 (after ctx.update(context)), returning None unconditionally instead of an HTMLResponse.
  2. The module-level else: block at line 106 contains return response at line 123 (still 4-space indented, so inside the else), which is a SyntaxError: 'return' outside function — Python raises this at compile time, preventing the module from being imported at all and crashing the application on startup.

The entire block from line 99 to 123 must be indented four spaces so it belongs inside _template_response:

Suggested change
# Only embed translations for full-page responses that include base.html
if template_name in ("index.html", "base.html"): # or check explicitly
all_translations = get_translations(lang)
ctx["translations_json"] = json.dumps(all_translations, ensure_ascii=False).replace(
"</", r"<\/"
)
else:
ctx["translations_json"] = "{}" # or omit entirely
response = templates.TemplateResponse(
request, template_name, ctx, status_code=status_code
)
# Only set cookie when user explicitly requests a language via query param
query_lang = request.query_params.get("lang", "")
if is_supported_lang(query_lang):
response.set_cookie(
"lang",
query_lang,
httponly=True,
secure=True,
samesite="lax",
max_age=365 * 24 * 3600,
)
return response
# Provide all translations as JSON for client-side window.__t()
# Only embed translations for full-page responses that include base.html
if template_name == "index.html":
all_translations = get_translations(lang)
ctx["translations_json"] = json.dumps(all_translations, ensure_ascii=False).replace(
"</", r"<\/"
)
else:
ctx["translations_json"] = "{}"
response = templates.TemplateResponse(
request, template_name, ctx, status_code=status_code
)
# Only set cookie when user explicitly requests a language via query param
query_lang = request.query_params.get("lang", "")
if is_supported_lang(query_lang):
response.set_cookie(
"lang",
query_lang,
httponly=True,
secure=True,
samesite="lax",
max_age=365 * 24 * 3600,
)
return response

Note: the condition is also corrected from template_name in ("index.html", "base.html") to template_name == "index.html" — see the separate comment below.

ctx.update(context)
# Provide all translations as JSON for client-side window.__t()
# Only embed translations for full-page responses that include base.html
if template_name in ("index.html", "base.html"): # or check explicitly
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect template name guard — "base.html" is never a direct template argument

base.html is only ever used as a parent template (extended via {% extends "base.html" %}); no route calls _template_response(request, "base.html", ...) directly. The only full-page template among the current route handlers is index.html.

Including "base.html" in the set gives a false sense of coverage, and any future full-page template added (e.g. an error page) would silently receive an empty translations_json, breaking client-side window.__t() on that page.

Prefer an explicit allowlist or, for safety, flip the logic and only skip serialization for known fragment templates:

Suggested change
if template_name in ("index.html", "base.html"): # or check explicitly
if template_name == "index.html":

Or, if you expect additional full-page templates to be added:

_FULL_PAGE_TEMPLATES = frozenset({"index.html"})
...
if template_name in _FULL_PAGE_TEMPLATES:

JuliusScheuerer added a commit that referenced this pull request Mar 25, 2026
…#5 followup)

The i18n PR (#5) introduced two bugs: (1) broken indentation in
_template_response() causing SyntaxError at import, and (2) an inline
<script> violating script-src 'self' CSP policy.

Fixes:
- Re-indent lines 100-107 of routes.py back inside _template_response()
- Add per-request CSP nonce via secrets.token_urlsafe() in middleware
- Add nonce attribute to inline translations <script> in base.html
- Add rate limiter reset fixture to prevent 429 cascade across tests
- Add tests verifying nonce presence, uniqueness, and header/HTML match
JuliusScheuerer added a commit that referenced this pull request Mar 25, 2026
…#5 followup) (#6)

The i18n PR (#5) introduced two bugs: (1) broken indentation in
_template_response() causing SyntaxError at import, and (2) an inline
<script> violating script-src 'self' CSP policy.

Fixes:
- Re-indent lines 100-107 of routes.py back inside _template_response()
- Add per-request CSP nonce via secrets.token_urlsafe() in middleware
- Add nonce attribute to inline translations <script> in base.html
- Add rate limiter reset fixture to prevent 429 cascade across tests
- Add tests verifying nonce presence, uniqueness, and header/HTML match
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant