# convertkit/3.4.3/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php

Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages, version 3.4.3. 558 lines.

- Page: https://pluginprobe.com/plugins/convertkit/3.4.3/code/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php
- Raw: https://pluginprobe.com/plugins/convertkit/3.4.3/raw/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php
- Modified: 2026-08-28T10:49:42+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/convertkit/3.4.3/code/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php#L10-L20`.

```php
<?php
/**
 * ConvertKit Shortcode Post Helper class.
 *
 * @package ConvertKit
 * @author ConvertKit
 */

/**
 * Helper methods to find, insert, update and delete shortcodes within a WordPress Post's content.
 *
 * @package ConvertKit
 * @author  ConvertKit
 */
class ConvertKit_Shortcode_Post_Helper {

	/**
	 * The element-level HTML tags treated as top-level element boundaries when
	 * resolving an insertion position.
	 *
	 * This is the same set WordPress' wpautop() recognises as element-level,
	 * so the segmentation matches how WordPress itself conceptualises Classic
	 * editor content.
	 *
	 * @since   3.4.0
	 *
	 * @var     string
	 */
	const ELEMENT_LEVEL_TAGS = 'address|article|aside|blockquote|details|dd|div|dl|dt|' .
		'figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hgroup|hr|' .
		'main|menu|nav|ol|p|pre|section|table|ul';

	/**
	 * Finds all occurrences of the given shortcode in a Post's content.
	 *
	 * @since   3.4.0
	 *
	 * @param   int    $post_id          Post ID.
	 * @param   string $shortcode_tag    Programmatic Shortcode Tag.
	 * @return  WP_Error|bool|array
	 */
	public static function find( $post_id, $shortcode_tag ) {

		// Get Post.
		$post = get_post( $post_id );
		if ( ! $post ) {
			return new WP_Error(
				'convertkit_shortcode_post_helper_post_not_found',
				/* translators: %d: post ID */
				sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id )
			);
		}

		// Match all occurrences of the shortcode in the Post's content.
		$matches = self::match_shortcodes( $post->post_content, $shortcode_tag );
		$found   = array();

		foreach ( $matches as $occurrence_index => $match ) {
			$found[] = array(
				// Zero-based index of this occurrence among occurrences of
				// this shortcode in the post.
				'occurrence_index' => (int) $occurrence_index,
				'attrs'            => self::parse_attrs( $match ),
			);
		}

		// If no shortcodes found, return false.
		if ( empty( $found ) ) {
			return false;
		}

		return $found;

	}

	/**
	 * Inserts a new shortcode into the Post's content at the specified
	 * position.
	 *
	 * @since   3.4.0
	 *
	 * @param   int    $post_id          Post ID.
	 * @param   string $shortcode_tag    Programmatic Shortcode Tag.
	 * @param   array  $attrs            Shortcode Attributes.
	 * @param   string $position         One of 'prepend', 'append', 'index'.
	 * @param   int    $index            Zero-based top-level element index; only used when $position is 'index'.
	 * @return  WP_Error|array
	 */
	public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'append', $index = 0 ) {

		// If the index is negative, bail.
		if ( $position === 'index' && (int) $index < 0 ) {
			return new WP_Error(
				'convertkit_shortcode_post_helper_invalid_index',
				sprintf(
					/* translators: %d: index */
					__( 'The supplied index (%d) must be zero or a positive integer.', 'convertkit' ),
					(int) $index
				)
			);
		}

		// Get Post.
		$post = get_post( $post_id );
		if ( ! $post ) {
			return new WP_Error(
				'convertkit_shortcode_post_helper_insert_post_not_found',
				/* translators: %d: post ID */
				sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id )
			);
		}

		// Build the shortcode string to insert.
		$shortcode = self::build_shortcode( $shortcode_tag, $attrs );
		$content   = $post->post_content;

		// Determine the byte offset of the start of each top-level element.
		$starts = self::get_element_starts( $content );

		// Resolve $position into a concrete byte offset within the content.
		switch ( $position ) {
			case 'prepend':
				$insert_at = 0;
				break;

			case 'index':
				// Insert before the Nth top-level element. If no elements
				// exist, or the index is equal to / beyond count(), append
				// after all existing content — mirroring how array_splice()
				// treats an index equal to the array length.
				if ( empty( $starts ) || (int) $index >= count( $starts ) ) {
					$insert_at = strlen( $content );
				} else {
					$insert_at = $starts[ (int) $index ];
				}
				break;

			case 'append':
			default:
				$insert_at = strlen( $content );
				break;
		}

		// Determine the occurrence index the new shortcode will have, by
		// counting how many existing occurrences of the same shortcode start
		// before the insertion offset.
		$occurrence_index = 0;
		foreach ( self::match_shortcodes( $content, $shortcode_tag ) as $match ) {
			if ( $match['offset'] < $insert_at ) {
				++$occurrence_index;
			}
		}

		// Splice the shortcode into the content at the resolved offset,
		// wrapped in blank lines so it sits as its own top-level element.
		// All other content is left byte-for-byte unchanged.
		$snippet = self::pad_snippet( $shortcode, $content, $insert_at );
		$content = substr_replace( $content, $snippet, $insert_at, 0 );

		// Update Post.
		$result = wp_update_post(
			array(
				'ID'           => $post_id,
				'post_content' => $content,
			),
			true
		);

		// Bail if the update failed.
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		// Return the occurrence index of the newly inserted shortcode.
		return array(
			'post_id'          => $post_id,
			'occurrence_index' => $occurrence_index,
		);

	}

	/**
	 * Updates the attributes of an existing shortcode in the Post's content.
	 *
	 * @since   3.4.0
	 *
	 * @param   int    $post_id            Post ID.
	 * @param   string $shortcode_tag      Programmatic Shortcode Tag.
	 * @param   int    $occurrence_index   Zero-based occurrence index to update.
	 * @param   array  $attrs              Shortcode Attributes.
	 * @return  WP_Error|array
	 */
	public static function update( $post_id, $shortcode_tag, $occurrence_index, $attrs ) {

		// Get Post.
		$post = get_post( $post_id );
		if ( ! $post ) {
			return new WP_Error(
				'convertkit_shortcode_post_helper_update_post_not_found',
				/* translators: %d: post ID */
				sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id )
			);
		}

		// Match all occurrences of the shortcode.
		$matches = self::match_shortcodes( $post->post_content, $shortcode_tag );

		// Bail if the requested occurrence does not exist.
		if ( ! isset( $matches[ (int) $occurrence_index ] ) ) {
			return new WP_Error(
				'convertkit_shortcode_post_helper_occurrence_not_found',
				sprintf(
					/* translators: 1: shortcode tag, 2: occurrence index, 3: post ID */
					__( 'No occurrence #%2$d of shortcode %1$s found in post %3$d.', 'convertkit' ),
					$shortcode_tag,
					(int) $occurrence_index,
					$post_id
				)
			);
		}

		// Build the replacement shortcode, merging new attributes over existing.
		$match        = $matches[ (int) $occurrence_index ];
		$merged_attrs = array_merge( self::parse_attrs( $match ), (array) $attrs );
		$replacement  = self::build_shortcode( $shortcode_tag, $merged_attrs );

		// Replace the matched shortcode text with the rebuilt shortcode.
		$content = self::replace_match( $post->post_content, $match, $replacement );

		// Update Post.
		$result = wp_update_post(
			array(
				'ID'           => $post_id,
				'post_content' => $content,
			),
			true
		);

		// Bail if the update failed.
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		// Return the occurrence index that was updated.
		return array(
			'post_id'          => $post_id,
			'occurrence_index' => (int) $occurrence_index,
		);

	}

	/**
	 * Deletes a specific shortcode from the Post's content.
	 *
	 * @since   3.4.0
	 *
	 * @param   int    $post_id            Post ID.
	 * @param   string $shortcode_tag      Programmatic Shortcode Tag.
	 * @param   int    $occurrence_index   Zero-based occurrence index to delete.
	 * @return  WP_Error|array
	 */
	public static function delete( $post_id, $shortcode_tag, $occurrence_index ) {

		// Get Post.
		$post = get_post( $post_id );
		if ( ! $post ) {
			return new WP_Error(
				'convertkit_shortcode_post_helper_delete_post_not_found',
				/* translators: %d: post ID */
				sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id )
			);
		}

		// Match all occurrences of the shortcode.
		$matches = self::match_shortcodes( $post->post_content, $shortcode_tag );

		// Bail if the requested occurrence does not exist.
		if ( ! isset( $matches[ (int) $occurrence_index ] ) ) {
			return new WP_Error(
				'convertkit_shortcode_post_helper_occurrence_not_found',
				sprintf(
					/* translators: 1: shortcode tag, 2: occurrence index, 3: post ID */
					__( 'No occurrence #%2$d of shortcode %1$s found in post %3$d.', 'convertkit' ),
					$shortcode_tag,
					(int) $occurrence_index,
					$post_id
				)
			);
		}

		// Remove the matched shortcode text from the content.
		$content = self::replace_match( $post->post_content, $matches[ (int) $occurrence_index ], '' );

		// Update Post.
		$result = wp_update_post(
			array(
				'ID'           => $post_id,
				'post_content' => $content,
			),
			true
		);

		// Bail if the update failed.
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		// Return the occurrence index that was deleted.
		return array(
			'post_id'          => $post_id,
			'occurrence_index' => (int) $occurrence_index,
		);

	}

	/**
	 * Returns all matches of the given shortcode tag within the content, in
	 * document order.
	 *
	 * Each match is an array of:
	 * - 'text'   The full matched shortcode string (e.g. `[convertkit_form form="1"]`).
	 * - 'offset' Its byte offset within the content.
	 * - 'atts'   The raw attribute string only (e.g. `form="1"`), suitable for
	 *            passing directly to shortcode_parse_atts().
	 *
	 * @since   3.4.0
	 *
	 * @param   string $content         Post content.
	 * @param   string $shortcode_tag   Programmatic Shortcode Tag.
	 * @return  array
	 */
	private static function match_shortcodes( $content, $shortcode_tag ) {

		// Build a shortcode regex scoped to this single tag.
		$pattern = get_shortcode_regex( array( $shortcode_tag ) );

		// Bail if there are no matches.
		if ( ! preg_match_all( '/' . $pattern . '/', $content, $matches, PREG_OFFSET_CAPTURE ) ) {
			return array();
		}

		// Build array of shortcode matches.
		$found = array();
		foreach ( $matches[0] as $i => $match ) {
			$found[] = array(
				'text'   => $match[0],
				'offset' => (int) $match[1],
				'atts'   => isset( $matches[3][ $i ][0] ) ? trim( (string) $matches[3][ $i ][0] ) : '',
			);
		}

		return $found;

	}

	/**
	 * Parses the attributes of a single matched shortcode into a key/value
	 * array.
	 *
	 * @since   3.4.0
	 *
	 * @param   array $shortcode   A match from match_shortcodes().
	 * @return  array
	 */
	private static function parse_attrs( $shortcode ) {

		// Parse the raw attribute string (e.g. `form="1"`). shortcode_parse_atts()
		// expects only the attributes, without the surrounding brackets or tag name.
		$attrs = shortcode_parse_atts( $shortcode['atts'] );

		// Discard any positional (non-string keyed) attributes, keeping only
		// named attributes.
		foreach ( array_keys( $attrs ) as $key ) {
			if ( ! is_string( $key ) ) {
				unset( $attrs[ $key ] );
			}
		}

		return $attrs;

	}

	/**
	 * Builds a self-closing shortcode string from a tag and attributes.
	 *
	 * @since   3.4.0
	 *
	 * @param   string $shortcode_tag   Programmatic Shortcode Tag.
	 * @param   array  $attrs           Shortcode Attributes.
	 * @return  string
	 */
	private static function build_shortcode( $shortcode_tag, $attrs ) {

		$shortcode = '[' . $shortcode_tag;

		foreach ( (array) $attrs as $key => $value ) {
			// Skip empty attribute names.
			if ( ! is_string( $key ) || '' === $key ) {
				continue;
			}

			$shortcode .= sprintf( ' %s="%s"', $key, esc_attr( (string) $value ) );
		}

		$shortcode .= ']';

		return $shortcode;

	}

	/**
	 * Replaces a single matched shortcode occurrence with the replacement
	 * string.
	 *
	 * @since   3.4.0
	 *
	 * @param   string $content       Post content.
	 * @param   array  $atts          A match from match_shortcodes().
	 * @param   string $replacement   Replacement string (empty string to delete).
	 * @return  string
	 */
	private static function replace_match( $content, $atts, $replacement ) {

		return substr_replace(
			$content,
			$replacement,
			$atts['offset'],
			strlen( $atts['text'] )
		);

	}

	/**
	 * Wraps a shortcode snippet in blank-line padding so that, once inserted
	 * at the given offset, it sits as its own top-level element.
	 *
	 * @since   3.4.0
	 *
	 * @param   string $shortcode   The shortcode string to insert.
	 * @param   string $content     The content the shortcode is being inserted into.
	 * @param   int    $offset      Byte offset within $content the shortcode will be inserted at.
	 * @return  string
	 */
	private static function pad_snippet( $shortcode, $content, $offset ) {

		// Determine the text immediately before and after the insertion point.
		$before = substr( $content, 0, $offset );
		$after  = substr( $content, $offset );

		// Add a leading blank line unless the shortcode is at the start of the
		// content, or already preceded by a blank line.
		$lead = ( $before === '' || preg_match( '/\R\R\s*$/', $before ) ) ? '' : "\n\n";

		// Add a trailing blank line unless the shortcode is at the end of the
		// content, or already followed by a blank line.
		$trail = ( $after === '' || preg_match( '/^\s*\R\R/', $after ) ) ? '' : "\n\n";

		return $lead . $shortcode . $trail;

	}

	/**
	 * Returns the byte offset of the start of each top-level element in the
	 * content, in document order.
	 *
	 * Uses WP_HTML_Tag_Processor (WP 6.2+) for nesting-aware structure, paired
	 * with a regex for the byte offsets the tag processor does not expose.
	 * Falls back to regex alone on older WordPress versions.
	 *
	 * @since   3.4.0
	 *
	 * @param   string $content   Post content.
	 * @return  array
	 */
	private static function get_element_starts( $content ) {

		if ( trim( (string) $content ) === '' ) {
			return array();
		}

		// Candidate offsets, one per regex-matched element-level opener.
		$pattern = '/<(' . self::ELEMENT_LEVEL_TAGS . ')\b[^>]*>.*?<\/\1>/is';
		if ( ! preg_match_all( $pattern, $content, $matches, PREG_OFFSET_CAPTURE ) ) {
			return array();
		}

		// Fallback for WP < 6.2: regex offsets verbatim, no nesting awareness.
		if ( ! class_exists( 'WP_HTML_Tag_Processor' ) ) {
			$starts = array();
			foreach ( $matches[0] as $match ) {
				$starts[] = (int) $match[1];
			}
			return $starts;
		}

		// Per-tag queue of regex offsets in document order.
		$queues = array();
		foreach ( $matches[1] as $i => $tag_match ) {
			$queues[ strtoupper( $tag_match[0] ) ][] = (int) $matches[0][ $i ][1];
		}

		// Walk with depth tracking; record offsets only for depth-zero openers.
		$processor          = new WP_HTML_Tag_Processor( $content );
		$starts             = array();
		$depth              = 0;
		$element_level_tags = array_flip( explode( '|', strtoupper( self::ELEMENT_LEVEL_TAGS ) ) );

		while ( $processor->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
			$tag = $processor->get_tag();

			if ( ! isset( $element_level_tags[ $tag ] ) ) {
				continue;
			}

			if ( $processor->is_tag_closer() ) {
				if ( $depth > 0 ) {
					--$depth;
				}
				continue;
			}

			$offset = array_shift( $queues[ $tag ] );

			if ( $depth === 0 ) {
				$starts[] = $offset;
			}

			if ( $tag !== 'HR' ) {
				++$depth;
			}
		}

		// Treat blank line separated text as paragraphs, matching the logic in wpautop().
		$opener_prefix = '/^<(?:' . self::ELEMENT_LEVEL_TAGS . ')\b/i';
		$offset        = 0;
		foreach ( preg_split( '/(\R\R+)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE ) as $i => $chunk ) {
			// Odd indices are the delimiters captured by PREG_SPLIT_DELIM_CAPTURE.
			if ( $i % 2 === 1 ) {
				$offset += strlen( $chunk );
				continue;
			}

			$trimmed = trim( $chunk );
			if ( $trimmed !== '' && ! preg_match( $opener_prefix, $trimmed ) ) {
				$starts[] = $offset + ( strlen( $chunk ) - strlen( ltrim( $chunk ) ) );
			}

			$offset += strlen( $chunk );
		}

		sort( $starts, SORT_NUMERIC );

		return $starts;

	}

}

```
