From 46d3a35c3a6ded89af017a28324966176281e87c Mon Sep 17 00:00:00 2001 From: Terence Ong Date: Mon, 13 Apr 2026 14:33:51 +0700 Subject: [PATCH] fix: decode HTML entities in posts widget titles and excerpts WordPress's get_the_title() returns HTML-encoded entities (e.g., ’ for smart quotes). Since the React component renders via createElement (textContent, not innerHTML), these entities displayed as raw text. Fix applied at two levels: - PHP REST endpoint: html_entity_decode() before sending JSON response - JS PostCard component: decodeEntities() safety net for any residual encoded entities from other sources Co-Authored-By: Claude Opus 4.6 --- assets/js/posts-widget.js | 37 ++++++++++++++++++++++-- includes/frontend/class-posts-widget.php | 12 ++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/assets/js/posts-widget.js b/assets/js/posts-widget.js index 1386c3c..e0714b9 100644 --- a/assets/js/posts-widget.js +++ b/assets/js/posts-widget.js @@ -96,6 +96,38 @@ return Math.max(1, Math.round((wordCount || 0) / 250)); } + /** + * Decode HTML entities in a string (e.g., ’ → '). + * + * Uses a DOMParser to safely decode entities without executing scripts. + * Falls back to common entity replacements if DOMParser is unavailable. + * + * @param {string} text Text that may contain HTML entities. + * @return {string} Decoded plain text. + */ + function decodeEntities(text) { + if (!text) { + return ''; + } + if (typeof DOMParser !== 'undefined') { + var doc = new DOMParser().parseFromString(text, 'text/html'); + return doc.body.textContent || ''; + } + // Fallback: decode common entities manually. + return text + .replace(/’/g, '\u2019') + .replace(/‘/g, '\u2018') + .replace(/“/g, '\u201C') + .replace(/”/g, '\u201D') + .replace(/–/g, '\u2013') + .replace(/—/g, '\u2014') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); + } + /** * Strip HTML tags from a string. * @@ -124,6 +156,7 @@ var post = props.post; var catClass = getCategoryClass(post.category_slug); var minutes = readingTime(post.word_count); + var title = decodeEntities(post.title); var excerpt = stripHtml(post.excerpt); // Choose placeholder icon based on category @@ -132,7 +165,7 @@ return el('a', { href: post.url || '#', className: 'prab-post-card', - 'aria-label': post.title + 'aria-label': title }, // Image area post.featured_image @@ -161,7 +194,7 @@ ), // Title - el('h3', { className: 'prab-post-title' }, post.title), + el('h3', { className: 'prab-post-title' }, title), // Excerpt el('p', { className: 'prab-post-excerpt' }, excerpt), diff --git a/includes/frontend/class-posts-widget.php b/includes/frontend/class-posts-widget.php index 9c1e6f5..d89d591 100644 --- a/includes/frontend/class-posts-widget.php +++ b/includes/frontend/class-posts-widget.php @@ -241,10 +241,18 @@ class PRAutoBlogger_Posts_Widget { $featured_image = get_the_post_thumbnail_url( $post->ID, 'medium_large' ); $word_count = str_word_count( wp_strip_all_tags( $post->post_content ) ); + // Decode HTML entities in title and excerpt so React's textContent + // rendering shows proper characters (e.g., ’ → '). + // WordPress functions like get_the_title() return HTML-encoded strings, + // but since our React component sets text via createElement (not innerHTML), + // raw entities would display literally. + $title = html_entity_decode( get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + $excerpt = html_entity_decode( get_the_excerpt( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + return [ 'id' => $post->ID, - 'title' => get_the_title( $post ), - 'excerpt' => get_the_excerpt( $post ), + 'title' => $title, + 'excerpt' => $excerpt, 'url' => get_permalink( $post ), 'date' => get_the_date( 'c', $post ), 'category_name' => $category_name,