fix: decode WordPress title entities before Twig to prevent double-encoding (v1.0.10)

WordPress's get_the_title() pre-encodes & as &. Twig autoescape
re-encoded the & in & to &, rendering as literal &
in the browser. Wrapped all 6 get_the_title() calls in ContextBuilder
with wp_specialchars_decode() so Twig can properly re-encode once.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 20:20:19 +01:00
parent 1a0a1fa63a
commit cb3c91b4b2
4 changed files with 27 additions and 7 deletions

View File

@@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [1.0.10] - 2026-02-25
### Fixed
- **Title double-encoding in Twig templates** (`inc/Template/ContextBuilder.php`): WordPress's `get_the_title()` pre-encodes `&` as `&#038;`. When passed to Twig with autoescape enabled, the `&` in `&#038;` was escaped again to `&amp;#038;`, rendering as literal `&#038;` in the browser (e.g. "Bewerbungen &#038; Nachrichten" instead of "Bewerbungen & Nachrichten"). Fixed by wrapping all 6 `get_the_title()` calls with `wp_specialchars_decode()` to decode WordPress entities before Twig. Twig autoescape then properly re-encodes `&``&amp;`. This is XSS-safe because Twig still escapes all output.
## [1.0.9] - 2026-02-19 ## [1.0.9] - 2026-02-19
### Performance ### Performance

View File

@@ -234,6 +234,20 @@ Build steps (in order):
## Session History ## Session History
### Session 16 — v1.0.10 Title Double-Encoding Fix (2026-02-25)
**Completed:** Fixed double-encoding of HTML entities in page titles rendered through Twig.
**Root cause:** WordPress's `get_the_title()` returns titles with HTML entities pre-encoded (e.g. `&``&#038;`). `ContextBuilder` passed these pre-encoded strings to Twig as template variables. Twig's autoescape then re-encoded the `&` in `&#038;` to `&amp;#038;`, which browsers rendered as the literal text `&#038;` instead of `&`. Affected all pages with `&` in their title (e.g. help pages "Bewerbungen & Nachrichten", "Konto & Sicherheit", "Abonnements & Abrechnung").
**Fix:** Wrapped all 6 `get_the_title()` calls in `ContextBuilder.php` with `wp_specialchars_decode()`. This decodes WordPress entities back to raw characters before Twig, allowing Twig autoescape to properly encode them once. XSS-safe because Twig still escapes all output.
**Files modified:**
- `inc/Template/ContextBuilder.php``wp_specialchars_decode()` on all 6 `get_the_title()` calls
- `style.css` — version bump to 1.0.10
- `CHANGELOG.md` — v1.0.10 entry
### Session 15 — v1.0.9 Performance Optimization (2026-02-19) ### Session 15 — v1.0.9 Performance Optimization (2026-02-19)
**Completed:** Two targeted performance fixes for production environments. **Completed:** Two targeted performance fixes for production environments.

View File

@@ -153,7 +153,7 @@ class ContextBuilder
return [ return [
'id' => $post->ID, 'id' => $post->ID,
'title' => get_the_title(), 'title' => wp_specialchars_decode( get_the_title() ),
'url' => get_permalink(), 'url' => get_permalink(),
'content' => apply_filters('the_content', get_the_content()), 'content' => apply_filters('the_content', get_the_content()),
'excerpt' => get_the_excerpt(), 'excerpt' => get_the_excerpt(),
@@ -184,7 +184,7 @@ class ContextBuilder
$wp_query->the_post(); $wp_query->the_post();
$posts[] = [ $posts[] = [
'id' => get_the_ID(), 'id' => get_the_ID(),
'title' => get_the_title(), 'title' => wp_specialchars_decode( get_the_title() ),
'url' => get_permalink(), 'url' => get_permalink(),
'excerpt' => get_the_excerpt(), 'excerpt' => get_the_excerpt(),
'date' => get_the_date(), 'date' => get_the_date(),
@@ -349,14 +349,14 @@ class ContextBuilder
if ($prev) { if ($prev) {
$navigation['previous'] = [ $navigation['previous'] = [
'title' => get_the_title($prev), 'title' => wp_specialchars_decode( get_the_title($prev) ),
'url' => get_permalink($prev), 'url' => get_permalink($prev),
]; ];
} }
if ($next) { if ($next) {
$navigation['next'] = [ $navigation['next'] = [
'title' => get_the_title($next), 'title' => wp_specialchars_decode( get_the_title($next) ),
'url' => get_permalink($next), 'url' => get_permalink($next),
]; ];
} }
@@ -384,7 +384,7 @@ class ContextBuilder
$query->the_post(); $query->the_post();
$posts[] = [ $posts[] = [
'id' => get_the_ID(), 'id' => get_the_ID(),
'title' => get_the_title(), 'title' => wp_specialchars_decode( get_the_title() ),
'url' => get_permalink(), 'url' => get_permalink(),
'date' => get_the_date(), 'date' => get_the_date(),
'date_iso' => get_the_date('c'), 'date_iso' => get_the_date('c'),
@@ -438,7 +438,7 @@ class ContextBuilder
while ($query->have_posts()) { while ($query->have_posts()) {
$query->the_post(); $query->the_post();
$posts[] = [ $posts[] = [
'title' => get_the_title(), 'title' => wp_specialchars_decode( get_the_title() ),
'url' => get_permalink(), 'url' => get_permalink(),
'date' => get_the_date(), 'date' => get_the_date(),
]; ];

View File

@@ -7,7 +7,7 @@ Description: A modern WordPress Block Theme built from scratch with Bootstrap 5.
Requires at least: 6.7 Requires at least: 6.7
Tested up to: 6.7 Tested up to: 6.7
Requires PHP: 8.3 Requires PHP: 8.3
Version: 1.0.9 Version: 1.0.10
License: GNU General Public License v2 or later License: GNU General Public License v2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html License URI: http://www.gnu.org/licenses/gpl-2.0.html
Text Domain: wp-bootstrap Text Domain: wp-bootstrap