block-api
2 days ago
form
2 days ago
import
2 days ago
popup
2 days ago
templates
2 days ago
class-gutenberg-block-styles.php
2 days ago
class-gutenberg-cache-util.php
2 days ago
class-gutenberg-conditions-controller.php
2 days ago
class-gutenberg-controller.php
2 days ago
class-gutenberg-dynamic-content-controller.php
2 days ago
class-gutenberg-enhancements-controller.php
2 days ago
class-gutenberg-social-icons-controller.php
2 days ago
class-gutenberg-sticky-controller.php
2 days ago
class-gutenberg-z-index-controller.php
2 days ago
class-gutenberg-dynamic-content-controller.php
374 lines
| 1 | <?php |
| 2 | |
| 3 | namespace SuperbAddons\Gutenberg\Controllers; |
| 4 | |
| 5 | defined('ABSPATH') || exit(); |
| 6 | |
| 7 | use SuperbAddons\Config\Capabilities; |
| 8 | use SuperbAddons\Data\Controllers\RestController; |
| 9 | |
| 10 | use WP_Error; |
| 11 | use WP_REST_Server; |
| 12 | use WP_HTML_Tag_Processor; |
| 13 | |
| 14 | class GutenbergDynamicContentController |
| 15 | { |
| 16 | public static function Initialize() |
| 17 | { |
| 18 | self::InitializeEndpoints(); |
| 19 | add_filter('render_block', array(__CLASS__, 'FilterDynamicContent'), 10, 2); |
| 20 | } |
| 21 | |
| 22 | private static function InitializeEndpoints() |
| 23 | { |
| 24 | RestController::AddRoute('/dynamic-content/resolve', array( |
| 25 | 'methods' => WP_REST_Server::READABLE, |
| 26 | 'permission_callback' => array( |
| 27 | 'SuperbAddons\Gutenberg\Controllers\GutenbergEnhancementsController', |
| 28 | 'OptionsCallbackPermissionCheck' |
| 29 | ), |
| 30 | 'callback' => array(__CLASS__, 'ResolveCallback'), |
| 31 | )); |
| 32 | } |
| 33 | |
| 34 | // === REST API (editor preview) === |
| 35 | |
| 36 | public static function ResolveCallback($request) |
| 37 | { |
| 38 | $type = isset($request['type']) ? sanitize_text_field($request['type']) : ''; |
| 39 | $post_id = isset($request['post_id']) ? max(0, intval($request['post_id'])) : 0; |
| 40 | $format = isset($request['format']) ? sanitize_text_field($request['format']) : ''; |
| 41 | $kind = isset($request['kind']) ? sanitize_text_field($request['kind']) : 'value'; |
| 42 | |
| 43 | if (empty($type)) { |
| 44 | return new WP_Error('missing_type', 'Missing type parameter', array('status' => 400)); |
| 45 | } |
| 46 | |
| 47 | // Gate per-post reads so contributors can't enumerate drafts, private posts, |
| 48 | // or password-protected content by calling the endpoint with arbitrary IDs. |
| 49 | if ($post_id > 0 && !current_user_can('read_post', $post_id)) { |
| 50 | return new WP_Error('rest_forbidden', 'You do not have permission to read this post.', array('status' => 403)); |
| 51 | } |
| 52 | |
| 53 | $value = $kind === 'link' |
| 54 | ? self::ResolveLinkValue($type, $post_id, $format) |
| 55 | : self::ResolveValue($type, $post_id, $format); |
| 56 | |
| 57 | return rest_ensure_response(array('value' => $value)); |
| 58 | } |
| 59 | |
| 60 | // === render_block filter (frontend rendering) === |
| 61 | |
| 62 | public static function FilterDynamicContent($block_content, $block) |
| 63 | { |
| 64 | // Process dynamic values first (unwraps span, resolves text, preserves inner spans) |
| 65 | if (strpos($block_content, 'spbadd-dynamic-value') !== false) { |
| 66 | $block_content = self::ProcessDynamicValues($block_content); |
| 67 | } |
| 68 | |
| 69 | // Then process dynamic links (converts span to <a>, preserves inner content) |
| 70 | if (strpos($block_content, 'spbadd-dynamic-link') !== false) { |
| 71 | $block_content = self::ProcessDynamicLinks($block_content); |
| 72 | } |
| 73 | |
| 74 | return $block_content; |
| 75 | } |
| 76 | |
| 77 | private static function ProcessDynamicValues($content) |
| 78 | { |
| 79 | while ($span = self::FindBalancedSpan($content, 'spbadd-dynamic-value')) { |
| 80 | $type = self::ExtractAttribute($span['open_tag'], 'data-spbadd-dv-type'); |
| 81 | if (empty($type)) { |
| 82 | // Unwrap the broken span and keep scanning the rest of the content. |
| 83 | $content = substr_replace($content, $span['inner'], $span['start'], $span['end'] - $span['start']); |
| 84 | continue; |
| 85 | } |
| 86 | |
| 87 | $format = self::ExtractAttribute($span['open_tag'], 'data-spbadd-dv-format'); |
| 88 | |
| 89 | // Use the span's inner text content as fallback (the user's selected text) |
| 90 | $fallback_text = wp_strip_all_tags($span['inner']); |
| 91 | |
| 92 | $post_id = max(0, intval(get_the_ID())); |
| 93 | $resolved = self::ResolveValue($type, $post_id, $format); |
| 94 | if (empty($resolved) && !empty($fallback_text)) { |
| 95 | $resolved = $fallback_text; |
| 96 | } |
| 97 | |
| 98 | // Replace text content inside, preserving inner span wrappers (e.g. typing animation) |
| 99 | $inner = self::ReplaceTextContent($span['inner'], esc_html($resolved)); |
| 100 | |
| 101 | // Unwrap: replace the full dynamic-value span with just its (modified) inner HTML |
| 102 | $content = substr_replace($content, $inner, $span['start'], $span['end'] - $span['start']); |
| 103 | } |
| 104 | |
| 105 | return $content; |
| 106 | } |
| 107 | |
| 108 | private static function ProcessDynamicLinks($content) |
| 109 | { |
| 110 | while ($span = self::FindBalancedSpan($content, 'spbadd-dynamic-link')) { |
| 111 | $type = self::ExtractAttribute($span['open_tag'], 'data-spbadd-dl-type'); |
| 112 | if (empty($type)) { |
| 113 | // Unwrap the broken span and keep scanning the rest of the content. |
| 114 | $content = substr_replace($content, $span['inner'], $span['start'], $span['end'] - $span['start']); |
| 115 | continue; |
| 116 | } |
| 117 | |
| 118 | $target = self::ExtractAttribute($span['open_tag'], 'data-spbadd-dl-target'); |
| 119 | $fallback = self::ExtractAttribute($span['open_tag'], 'data-spbadd-dl-fallback'); |
| 120 | $format = self::ExtractAttribute($span['open_tag'], 'data-spbadd-dl-format'); |
| 121 | |
| 122 | $post_id = max(0, intval(get_the_ID())); |
| 123 | $url = self::ResolveLinkValue($type, $post_id, $format); |
| 124 | |
| 125 | if (empty($url) && !empty($fallback)) { |
| 126 | $url = $fallback; |
| 127 | } |
| 128 | |
| 129 | if (empty($url)) { |
| 130 | // Remove the span wrapper but keep inner content |
| 131 | $content = substr_replace($content, $span['inner'], $span['start'], $span['end'] - $span['start']); |
| 132 | continue; |
| 133 | } |
| 134 | |
| 135 | $target_attr = ''; |
| 136 | if ($target === '_blank') { |
| 137 | $target_attr = ' target="_blank" rel="noopener noreferrer"'; |
| 138 | } |
| 139 | |
| 140 | // Replace span with <a> tag, preserving all inner content (including nested spans) |
| 141 | $replacement = '<a href="' . esc_url($url) . '"' . $target_attr . '>' . $span['inner'] . '</a>'; |
| 142 | $content = substr_replace($content, $replacement, $span['start'], $span['end'] - $span['start']); |
| 143 | } |
| 144 | |
| 145 | return $content; |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * Find a span with the given class, properly handling nested spans. |
| 150 | * Returns array with start, end, open_tag, inner HTML, or false if not found. |
| 151 | */ |
| 152 | private static function FindBalancedSpan($content, $class_name) |
| 153 | { |
| 154 | $offset = 0; |
| 155 | $start = -1; |
| 156 | $open_tag = ''; |
| 157 | while (preg_match('/<span\s[^>]*>/s', $content, $match, PREG_OFFSET_CAPTURE, $offset)) { |
| 158 | $candidate = $match[0][0]; |
| 159 | $candidate_start = $match[0][1]; |
| 160 | $p = new WP_HTML_Tag_Processor($candidate); |
| 161 | if ($p->next_tag() && $p->has_class($class_name)) { |
| 162 | $start = $candidate_start; |
| 163 | $open_tag = $candidate; |
| 164 | break; |
| 165 | } |
| 166 | $offset = $candidate_start + 1; |
| 167 | } |
| 168 | if ($start === -1) { |
| 169 | return false; |
| 170 | } |
| 171 | |
| 172 | $pos = $start + strlen($open_tag); |
| 173 | $depth = 1; |
| 174 | $len = strlen($content); |
| 175 | |
| 176 | while ($depth > 0 && $pos < $len) { |
| 177 | $next_open = strpos($content, '<span', $pos); |
| 178 | $next_close = strpos($content, '</span>', $pos); |
| 179 | |
| 180 | if ($next_close === false) { |
| 181 | break; |
| 182 | } |
| 183 | |
| 184 | if ($next_open !== false && $next_open < $next_close) { |
| 185 | $char_after = isset($content[$next_open + 5]) ? $content[$next_open + 5] : ''; |
| 186 | if ($char_after === ' ' || $char_after === '>' || $char_after === '/') { |
| 187 | $depth++; |
| 188 | } |
| 189 | $pos = $next_open + 5; |
| 190 | } else { |
| 191 | $depth--; |
| 192 | if ($depth === 0) { |
| 193 | $end = $next_close + 7; // strlen('</span>') |
| 194 | $inner_start = $start + strlen($open_tag); |
| 195 | return array( |
| 196 | 'start' => $start, |
| 197 | 'end' => $end, |
| 198 | 'open_tag' => $open_tag, |
| 199 | 'inner' => substr($content, $inner_start, $next_close - $inner_start), |
| 200 | ); |
| 201 | } |
| 202 | $pos = $next_close + 7; |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | return false; |
| 207 | } |
| 208 | |
| 209 | /** |
| 210 | * Replace the text content within HTML, preserving inner span tags. |
| 211 | * If no inner tags exist, replaces the entire content. |
| 212 | */ |
| 213 | private static function ReplaceTextContent($html, $new_text) |
| 214 | { |
| 215 | if (strpos($html, '<') === false) { |
| 216 | return $new_text; |
| 217 | } |
| 218 | $gt = strpos($html, '>'); |
| 219 | if ($gt === false) { |
| 220 | return $new_text; |
| 221 | } |
| 222 | $lt = strpos($html, '<', $gt + 1); |
| 223 | if ($lt === false) { |
| 224 | return $new_text; |
| 225 | } |
| 226 | return substr($html, 0, $gt + 1) . $new_text . substr($html, $lt); |
| 227 | } |
| 228 | |
| 229 | private static function ExtractAttribute($html, $attr_name) |
| 230 | { |
| 231 | $p = new WP_HTML_Tag_Processor($html); |
| 232 | if (!$p->next_tag()) { |
| 233 | return ''; |
| 234 | } |
| 235 | $value = $p->get_attribute($attr_name); |
| 236 | return is_string($value) ? $value : ''; |
| 237 | } |
| 238 | |
| 239 | // === Value Resolution === |
| 240 | |
| 241 | private static function ResolveValue($type, $post_id, $format) |
| 242 | { |
| 243 | $type = sanitize_text_field($type); |
| 244 | $format = sanitize_text_field($format); |
| 245 | |
| 246 | switch ($type) { |
| 247 | case 'postID': |
| 248 | return strval($post_id); |
| 249 | |
| 250 | case 'postTitle': |
| 251 | return get_the_title($post_id); |
| 252 | |
| 253 | case 'postContent': |
| 254 | $post = get_post($post_id); |
| 255 | if (!$post) { |
| 256 | return ''; |
| 257 | } |
| 258 | $post_content = wp_strip_all_tags($post->post_content); |
| 259 | $max_length = !empty($format) ? intval($format) : 200; |
| 260 | if ($max_length > 0 && mb_strlen($post_content, 'UTF-8') > $max_length) { |
| 261 | $post_content = mb_substr($post_content, 0, $max_length, 'UTF-8') . '...'; |
| 262 | } |
| 263 | return $post_content; |
| 264 | |
| 265 | case 'postExcerpt': |
| 266 | $post = get_post($post_id); |
| 267 | return isset($post->post_excerpt) ? wp_strip_all_tags($post->post_excerpt) : ''; |
| 268 | |
| 269 | case 'postDate': |
| 270 | $date_format = !empty($format) ? $format : get_option('date_format'); |
| 271 | return get_the_date($date_format, $post_id); |
| 272 | |
| 273 | case 'postTime': |
| 274 | $time_format = !empty($format) ? $format : get_option('time_format'); |
| 275 | return get_the_time($time_format, $post_id); |
| 276 | |
| 277 | case 'postType': |
| 278 | $post_type_obj = get_post_type_object(get_post_type($post_id)); |
| 279 | return $post_type_obj ? $post_type_obj->labels->singular_name : ''; |
| 280 | |
| 281 | case 'postStatus': |
| 282 | $status = get_post_status($post_id); |
| 283 | $status_obj = get_post_status_object($status); |
| 284 | return $status_obj ? $status_obj->label : $status; |
| 285 | |
| 286 | case 'siteTitle': |
| 287 | return get_bloginfo('name'); |
| 288 | |
| 289 | case 'siteTagline': |
| 290 | return get_bloginfo('description'); |
| 291 | |
| 292 | case 'authorName': |
| 293 | $author_id = get_post_field('post_author', $post_id); |
| 294 | return get_the_author_meta('display_name', $author_id); |
| 295 | |
| 296 | case 'authorDescription': |
| 297 | $author_id = get_post_field('post_author', $post_id); |
| 298 | return wp_strip_all_tags(get_the_author_meta('description', $author_id)); |
| 299 | |
| 300 | case 'loggedInUserName': |
| 301 | GutenbergCacheUtil::MarkAsUncacheable(); |
| 302 | $user = wp_get_current_user(); |
| 303 | return $user->exists() ? $user->display_name : ''; |
| 304 | |
| 305 | case 'loggedInUserDescription': |
| 306 | GutenbergCacheUtil::MarkAsUncacheable(); |
| 307 | $user = wp_get_current_user(); |
| 308 | return $user->exists() ? $user->description : ''; |
| 309 | |
| 310 | case 'loggedInUserEmail': |
| 311 | GutenbergCacheUtil::MarkAsUncacheable(); |
| 312 | $user = wp_get_current_user(); |
| 313 | return $user->exists() ? $user->user_email : ''; |
| 314 | |
| 315 | case 'archiveTitle': |
| 316 | return is_archive() ? wp_strip_all_tags(get_the_archive_title()) : ''; |
| 317 | |
| 318 | case 'archiveDescription': |
| 319 | return is_archive() ? wp_strip_all_tags(get_the_archive_description()) : ''; |
| 320 | |
| 321 | case 'currentDate': |
| 322 | GutenbergCacheUtil::MarkAsUncacheable(); |
| 323 | $date_format = !empty($format) ? $format : get_option('date_format'); |
| 324 | return current_time($date_format); |
| 325 | |
| 326 | case 'currentTime': |
| 327 | GutenbergCacheUtil::MarkAsUncacheable(); |
| 328 | $time_format = !empty($format) ? $format : get_option('time_format'); |
| 329 | return current_time($time_format); |
| 330 | |
| 331 | default: |
| 332 | /* Filter callbacks that resolve to user-specific data must call |
| 333 | GutenbergCacheUtil::MarkAsUncacheable() to prevent state leaks via |
| 334 | page caches. */ |
| 335 | $result = apply_filters('superbaddons_resolve_dynamic_value', '', $type, $post_id, $format); |
| 336 | return is_string($result) ? $result : ''; |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | private static function ResolveLinkValue($type, $post_id, $format) |
| 341 | { |
| 342 | $type = sanitize_text_field($type); |
| 343 | $format = sanitize_text_field($format); |
| 344 | |
| 345 | switch ($type) { |
| 346 | case 'postURL': |
| 347 | return get_permalink($post_id); |
| 348 | |
| 349 | case 'featuredImageURL': |
| 350 | $thumbnail_id = get_post_thumbnail_id($post_id); |
| 351 | if (!$thumbnail_id) { |
| 352 | return ''; |
| 353 | } |
| 354 | $image_url = wp_get_attachment_url($thumbnail_id); |
| 355 | return $image_url ? $image_url : ''; |
| 356 | |
| 357 | case 'authorURL': |
| 358 | $author_id = get_post_field('post_author', $post_id); |
| 359 | return get_author_posts_url($author_id); |
| 360 | |
| 361 | case 'authorWebsite': |
| 362 | $author_id = get_post_field('post_author', $post_id); |
| 363 | return get_the_author_meta('url', $author_id); |
| 364 | |
| 365 | default: |
| 366 | /* Filter callbacks that resolve to user-specific URLs must call |
| 367 | GutenbergCacheUtil::MarkAsUncacheable() to prevent state leaks via |
| 368 | page caches. */ |
| 369 | $result = apply_filters('superbaddons_resolve_dynamic_link', '', $type, $post_id, $format); |
| 370 | return is_string($result) ? $result : ''; |
| 371 | } |
| 372 | } |
| 373 | } |
| 374 |