PluginProbe
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages / 3.4.6
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages v3.4.6
3.4.6 3.4.5 3.4.4 3.4.3 3.4.2 3.4.1 3.4.0 3.3.9 3.3.8 3.3.7 3.3.6 3.3.5 3.3.4 3.3.3 3.3.2 3.3.1 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.2.6 2.2.7 2.2.8 All 199 releases
convertkit / includes / blocks / helpers / class-convertkit-shortcode-post-helper.php

class-convertkit-shortcode-post-helper.php in Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages 3.4.6, at includes/blocks/helpers/class-convertkit-shortcode-post-helper.php

554 lines 16.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ConvertKit Shortcode Post Helper class.
4 *
5 * @package ConvertKit
6 * @author ConvertKit
7 */
8
9 /**
10 * Helper methods to find, insert, update and delete shortcodes within a WordPress Post's content.
11 *
12 * @package ConvertKit
13 * @author ConvertKit
14 */
15 class ConvertKit_Shortcode_Post_Helper {
16
17 /**
18 * The element-level HTML tags treated as top-level element boundaries when
19 * resolving an insertion position.
20 *
21 * This is the same set WordPress' wpautop() recognises as element-level,
22 * so the segmentation matches how WordPress itself conceptualises Classic
23 * editor content.
24 *
25 * @since 3.4.0
26 *
27 * @var string
28 */
29 const ELEMENT_LEVEL_TAGS = 'address|article|aside|blockquote|details|dd|div|dl|dt|' .
30 'figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hgroup|hr|' .
31 'main|menu|nav|ol|p|pre|section|table|ul';
32
33 /**
34 * Finds all occurrences of the given shortcode in a Post's content.
35 *
36 * @since 3.4.0
37 *
38 * @param int $post_id Post ID.
39 * @param string $shortcode_tag Programmatic Shortcode Tag.
40 * @return WP_Error|array
41 */
42 public static function find( $post_id, $shortcode_tag ) {
43
44 // Get Post.
45 $post = get_post( $post_id );
46 if ( ! $post ) {
47 return new WP_Error(
48 'convertkit_shortcode_post_helper_post_not_found',
49 /* translators: %d: post ID */
50 sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id )
51 );
52 }
53
54 // Match all occurrences of the shortcode in the Post's content.
55 $matches = self::match_shortcodes( $post->post_content, $shortcode_tag );
56 $found = array();
57
58 foreach ( $matches as $occurrence_index => $match ) {
59 $found[] = array(
60 // Zero-based index of this occurrence among occurrences of
61 // this shortcode in the post.
62 'occurrence_index' => (int) $occurrence_index,
63 'attrs' => self::parse_attrs( $match ),
64 );
65 }
66
67 return $found;
68
69 }
70
71 /**
72 * Inserts a new shortcode into the Post's content at the specified
73 * position.
74 *
75 * @since 3.4.0
76 *
77 * @param int $post_id Post ID.
78 * @param string $shortcode_tag Programmatic Shortcode Tag.
79 * @param array $attrs Shortcode Attributes.
80 * @param string $position One of 'prepend', 'append', 'index'.
81 * @param int $index Zero-based top-level element index; only used when $position is 'index'.
82 * @return WP_Error|array
83 */
84 public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'append', $index = 0 ) {
85
86 // If the index is negative, bail.
87 if ( $position === 'index' && (int) $index < 0 ) {
88 return new WP_Error(
89 'convertkit_shortcode_post_helper_invalid_index',
90 sprintf(
91 /* translators: %d: index */
92 __( 'The supplied index (%d) must be zero or a positive integer.', 'convertkit' ),
93 (int) $index
94 )
95 );
96 }
97
98 // Get Post.
99 $post = get_post( $post_id );
100 if ( ! $post ) {
101 return new WP_Error(
102 'convertkit_shortcode_post_helper_insert_post_not_found',
103 /* translators: %d: post ID */
104 sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id )
105 );
106 }
107
108 // Build the shortcode string to insert.
109 $shortcode = self::build_shortcode( $shortcode_tag, $attrs );
110 $content = $post->post_content;
111
112 // Determine the byte offset of the start of each top-level element.
113 $starts = self::get_element_starts( $content );
114
115 // Resolve $position into a concrete byte offset within the content.
116 switch ( $position ) {
117 case 'prepend':
118 $insert_at = 0;
119 break;
120
121 case 'index':
122 // Insert before the Nth top-level element. If no elements
123 // exist, or the index is equal to / beyond count(), append
124 // after all existing content — mirroring how array_splice()
125 // treats an index equal to the array length.
126 if ( empty( $starts ) || (int) $index >= count( $starts ) ) {
127 $insert_at = strlen( $content );
128 } else {
129 $insert_at = $starts[ (int) $index ];
130 }
131 break;
132
133 case 'append':
134 default:
135 $insert_at = strlen( $content );
136 break;
137 }
138
139 // Determine the occurrence index the new shortcode will have, by
140 // counting how many existing occurrences of the same shortcode start
141 // before the insertion offset.
142 $occurrence_index = 0;
143 foreach ( self::match_shortcodes( $content, $shortcode_tag ) as $match ) {
144 if ( $match['offset'] < $insert_at ) {
145 ++$occurrence_index;
146 }
147 }
148
149 // Splice the shortcode into the content at the resolved offset,
150 // wrapped in blank lines so it sits as its own top-level element.
151 // All other content is left byte-for-byte unchanged.
152 $snippet = self::pad_snippet( $shortcode, $content, $insert_at );
153 $content = substr_replace( $content, $snippet, $insert_at, 0 );
154
155 // Update Post, slashing the content as wp_update_post() unslashes it.
156 $result = wp_update_post(
157 array(
158 'ID' => $post_id,
159 'post_content' => wp_slash( $content ),
160 ),
161 true
162 );
163
164 // Bail if the update failed.
165 if ( is_wp_error( $result ) ) {
166 return $result;
167 }
168
169 // Return the occurrence index of the newly inserted shortcode.
170 return array(
171 'post_id' => $post_id,
172 'occurrence_index' => $occurrence_index,
173 );
174
175 }
176
177 /**
178 * Updates the attributes of an existing shortcode in the Post's content.
179 *
180 * @since 3.4.0
181 *
182 * @param int $post_id Post ID.
183 * @param string $shortcode_tag Programmatic Shortcode Tag.
184 * @param int $occurrence_index Zero-based occurrence index to update.
185 * @param array $attrs Shortcode Attributes.
186 * @return WP_Error|array
187 */
188 public static function update( $post_id, $shortcode_tag, $occurrence_index, $attrs ) {
189
190 // Get Post.
191 $post = get_post( $post_id );
192 if ( ! $post ) {
193 return new WP_Error(
194 'convertkit_shortcode_post_helper_update_post_not_found',
195 /* translators: %d: post ID */
196 sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id )
197 );
198 }
199
200 // Match all occurrences of the shortcode.
201 $matches = self::match_shortcodes( $post->post_content, $shortcode_tag );
202
203 // Bail if the requested occurrence does not exist.
204 if ( ! isset( $matches[ (int) $occurrence_index ] ) ) {
205 return new WP_Error(
206 'convertkit_shortcode_post_helper_occurrence_not_found',
207 sprintf(
208 /* translators: 1: shortcode tag, 2: occurrence index, 3: post ID */
209 __( 'No occurrence #%2$d of shortcode %1$s found in post %3$d.', 'convertkit' ),
210 $shortcode_tag,
211 (int) $occurrence_index,
212 $post_id
213 )
214 );
215 }
216
217 // Build the replacement shortcode, merging new attributes over existing.
218 $match = $matches[ (int) $occurrence_index ];
219 $merged_attrs = array_merge( self::parse_attrs( $match ), (array) $attrs );
220 $replacement = self::build_shortcode( $shortcode_tag, $merged_attrs );
221
222 // Replace the matched shortcode text with the rebuilt shortcode.
223 $content = self::replace_match( $post->post_content, $match, $replacement );
224
225 // Update Post, slashing the content as wp_update_post() unslashes it.
226 $result = wp_update_post(
227 array(
228 'ID' => $post_id,
229 'post_content' => wp_slash( $content ),
230 ),
231 true
232 );
233
234 // Bail if the update failed.
235 if ( is_wp_error( $result ) ) {
236 return $result;
237 }
238
239 // Return the occurrence index that was updated.
240 return array(
241 'post_id' => $post_id,
242 'occurrence_index' => (int) $occurrence_index,
243 );
244
245 }
246
247 /**
248 * Deletes a specific shortcode from the Post's content.
249 *
250 * @since 3.4.0
251 *
252 * @param int $post_id Post ID.
253 * @param string $shortcode_tag Programmatic Shortcode Tag.
254 * @param int $occurrence_index Zero-based occurrence index to delete.
255 * @return WP_Error|array
256 */
257 public static function delete( $post_id, $shortcode_tag, $occurrence_index ) {
258
259 // Get Post.
260 $post = get_post( $post_id );
261 if ( ! $post ) {
262 return new WP_Error(
263 'convertkit_shortcode_post_helper_delete_post_not_found',
264 /* translators: %d: post ID */
265 sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id )
266 );
267 }
268
269 // Match all occurrences of the shortcode.
270 $matches = self::match_shortcodes( $post->post_content, $shortcode_tag );
271
272 // Bail if the requested occurrence does not exist.
273 if ( ! isset( $matches[ (int) $occurrence_index ] ) ) {
274 return new WP_Error(
275 'convertkit_shortcode_post_helper_occurrence_not_found',
276 sprintf(
277 /* translators: 1: shortcode tag, 2: occurrence index, 3: post ID */
278 __( 'No occurrence #%2$d of shortcode %1$s found in post %3$d.', 'convertkit' ),
279 $shortcode_tag,
280 (int) $occurrence_index,
281 $post_id
282 )
283 );
284 }
285
286 // Remove the matched shortcode text from the content.
287 $content = self::replace_match( $post->post_content, $matches[ (int) $occurrence_index ], '' );
288
289 // Update Post, slashing the content as wp_update_post() unslashes it.
290 $result = wp_update_post(
291 array(
292 'ID' => $post_id,
293 'post_content' => wp_slash( $content ),
294 ),
295 true
296 );
297
298 // Bail if the update failed.
299 if ( is_wp_error( $result ) ) {
300 return $result;
301 }
302
303 // Return the occurrence index that was deleted.
304 return array(
305 'post_id' => $post_id,
306 'occurrence_index' => (int) $occurrence_index,
307 );
308
309 }
310
311 /**
312 * Returns all matches of the given shortcode tag within the content, in
313 * document order.
314 *
315 * Each match is an array of:
316 * - 'text' The full matched shortcode string (e.g. `[convertkit_form form="1"]`).
317 * - 'offset' Its byte offset within the content.
318 * - 'atts' The raw attribute string only (e.g. `form="1"`), suitable for
319 * passing directly to shortcode_parse_atts().
320 *
321 * @since 3.4.0
322 *
323 * @param string $content Post content.
324 * @param string $shortcode_tag Programmatic Shortcode Tag.
325 * @return array
326 */
327 private static function match_shortcodes( $content, $shortcode_tag ) {
328
329 // Build a shortcode regex scoped to this single tag.
330 $pattern = get_shortcode_regex( array( $shortcode_tag ) );
331
332 // Bail if there are no matches.
333 if ( ! preg_match_all( '/' . $pattern . '/', $content, $matches, PREG_OFFSET_CAPTURE ) ) {
334 return array();
335 }
336
337 // Build array of shortcode matches.
338 $found = array();
339 foreach ( $matches[0] as $i => $match ) {
340 $found[] = array(
341 'text' => $match[0],
342 'offset' => (int) $match[1],
343 'atts' => isset( $matches[3][ $i ][0] ) ? trim( (string) $matches[3][ $i ][0] ) : '',
344 );
345 }
346
347 return $found;
348
349 }
350
351 /**
352 * Parses the attributes of a single matched shortcode into a key/value
353 * array.
354 *
355 * @since 3.4.0
356 *
357 * @param array $shortcode A match from match_shortcodes().
358 * @return array
359 */
360 private static function parse_attrs( $shortcode ) {
361
362 // Parse the raw attribute string (e.g. `form="1"`). shortcode_parse_atts()
363 // expects only the attributes, without the surrounding brackets or tag name.
364 $attrs = shortcode_parse_atts( $shortcode['atts'] );
365
366 // Discard any positional (non-string keyed) attributes, keeping only
367 // named attributes.
368 foreach ( array_keys( $attrs ) as $key ) {
369 if ( ! is_string( $key ) ) {
370 unset( $attrs[ $key ] );
371 }
372 }
373
374 return $attrs;
375
376 }
377
378 /**
379 * Builds a self-closing shortcode string from a tag and attributes.
380 *
381 * @since 3.4.0
382 *
383 * @param string $shortcode_tag Programmatic Shortcode Tag.
384 * @param array $attrs Shortcode Attributes.
385 * @return string
386 */
387 private static function build_shortcode( $shortcode_tag, $attrs ) {
388
389 $shortcode = '[' . $shortcode_tag;
390
391 foreach ( (array) $attrs as $key => $value ) {
392 // Skip empty attribute names.
393 if ( ! is_string( $key ) || '' === $key ) {
394 continue;
395 }
396
397 $shortcode .= sprintf( ' %s="%s"', $key, esc_attr( (string) $value ) );
398 }
399
400 $shortcode .= ']';
401
402 return $shortcode;
403
404 }
405
406 /**
407 * Replaces a single matched shortcode occurrence with the replacement
408 * string.
409 *
410 * @since 3.4.0
411 *
412 * @param string $content Post content.
413 * @param array $atts A match from match_shortcodes().
414 * @param string $replacement Replacement string (empty string to delete).
415 * @return string
416 */
417 private static function replace_match( $content, $atts, $replacement ) {
418
419 return substr_replace(
420 $content,
421 $replacement,
422 $atts['offset'],
423 strlen( $atts['text'] )
424 );
425
426 }
427
428 /**
429 * Wraps a shortcode snippet in blank-line padding so that, once inserted
430 * at the given offset, it sits as its own top-level element.
431 *
432 * @since 3.4.0
433 *
434 * @param string $shortcode The shortcode string to insert.
435 * @param string $content The content the shortcode is being inserted into.
436 * @param int $offset Byte offset within $content the shortcode will be inserted at.
437 * @return string
438 */
439 private static function pad_snippet( $shortcode, $content, $offset ) {
440
441 // Determine the text immediately before and after the insertion point.
442 $before = substr( $content, 0, $offset );
443 $after = substr( $content, $offset );
444
445 // Add a leading blank line unless the shortcode is at the start of the
446 // content, or already preceded by a blank line.
447 $lead = ( $before === '' || preg_match( '/\R\R\s*$/', $before ) ) ? '' : "\n\n";
448
449 // Add a trailing blank line unless the shortcode is at the end of the
450 // content, or already followed by a blank line.
451 $trail = ( $after === '' || preg_match( '/^\s*\R\R/', $after ) ) ? '' : "\n\n";
452
453 return $lead . $shortcode . $trail;
454
455 }
456
457 /**
458 * Returns the byte offset of the start of each top-level element in the
459 * content, in document order.
460 *
461 * Uses WP_HTML_Tag_Processor (WP 6.2+) for nesting-aware structure, paired
462 * with a regex for the byte offsets the tag processor does not expose.
463 * Falls back to regex alone on older WordPress versions.
464 *
465 * @since 3.4.0
466 *
467 * @param string $content Post content.
468 * @return array
469 */
470 private static function get_element_starts( $content ) {
471
472 if ( trim( (string) $content ) === '' ) {
473 return array();
474 }
475
476 // Fallback for WP < 6.2: regex offsets verbatim, no nesting awareness.
477 if ( ! class_exists( 'WP_HTML_Tag_Processor' ) ) {
478 $pattern = '/<(' . self::ELEMENT_LEVEL_TAGS . ')\b[^>]*>.*?<\/\1>/is';
479 if ( ! preg_match_all( $pattern, $content, $matches, PREG_OFFSET_CAPTURE ) ) {
480 return array();
481 }
482
483 $starts = array();
484 foreach ( $matches[0] as $match ) {
485 $starts[] = (int) $match[1];
486 }
487 return $starts;
488 }
489
490 // Per-tag queue of opening tag offsets in document order, including nested and void tags such as <hr>.
491 preg_match_all( '/<(' . self::ELEMENT_LEVEL_TAGS . ')\b[^>]*>/i', $content, $matches, PREG_OFFSET_CAPTURE );
492 $queues = array();
493 foreach ( $matches[1] as $i => $tag_match ) {
494 $queues[ strtoupper( $tag_match[0] ) ][] = (int) $matches[0][ $i ][1];
495 }
496
497 // Walk with depth tracking; record offsets only for depth-zero openers.
498 $processor = new WP_HTML_Tag_Processor( $content );
499 $starts = array();
500 $depth = 0;
501 $element_level_tags = array_flip( explode( '|', strtoupper( self::ELEMENT_LEVEL_TAGS ) ) );
502
503 while ( $processor->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
504 $tag = $processor->get_tag();
505
506 if ( ! isset( $element_level_tags[ $tag ] ) ) {
507 continue;
508 }
509
510 if ( $processor->is_tag_closer() ) {
511 if ( $depth > 0 ) {
512 --$depth;
513 }
514 continue;
515 }
516
517 // Get this tag's offset, if one was matched.
518 $offset = ! empty( $queues[ $tag ] ) ? array_shift( $queues[ $tag ] ) : false;
519
520 if ( $depth === 0 && false !== $offset ) {
521 $starts[] = $offset;
522 }
523
524 if ( $tag !== 'HR' ) {
525 ++$depth;
526 }
527 }
528
529 // Treat blank line separated text as paragraphs, matching the logic in wpautop().
530 $opener_prefix = '/^<(?:' . self::ELEMENT_LEVEL_TAGS . ')\b/i';
531 $offset = 0;
532 foreach ( preg_split( '/(\R\R+)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE ) as $i => $chunk ) {
533 // Odd indices are the delimiters captured by PREG_SPLIT_DELIM_CAPTURE.
534 if ( $i % 2 === 1 ) {
535 $offset += strlen( $chunk );
536 continue;
537 }
538
539 $trimmed = trim( $chunk );
540 if ( $trimmed !== '' && ! preg_match( $opener_prefix, $trimmed ) ) {
541 $starts[] = $offset + ( strlen( $chunk ) - strlen( ltrim( $chunk ) ) );
542 }
543
544 $offset += strlen( $chunk );
545 }
546
547 sort( $starts, SORT_NUMERIC );
548
549 return $starts;
550
551 }
552
553 }
554