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; } }