PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.0.7
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.0.7
4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 trunk 1.0.0 2.0.0 2.0.1 2.0.2 2.0.3 3.0 3.0.1 3.0.2 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.4 3.2.5 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.4.5 3.4.6 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.6 3.5.7 3.5.8 3.5.9 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1
superb-blocks / src / gutenberg / class-gutenberg-dynamic-content-controller.php
superb-blocks / src / gutenberg Last commit date
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