PluginProbe
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages / 3.4.1
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages v3.4.1
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 2.2.9 2.3.0 2.3.1 2.3.2 2.3.3 All 194 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.1, at includes/blocks/helpers/class-convertkit-shortcode-post-helper.php

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