]*\bdata-narration-start(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+))?[^>]*>\s*#i'; private const START_OPEN_PATTERN = '#]*\bdata-narration-start(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+))?[^>]*>#i'; private const STOP_PATTERN = '#]*\bdata-narration-stop(?:=(?:"[^"]*"|\'[^\']*\'|[^\s>]+))?[^>]*>\s*#i'; private const SHORTCODE_PATTERN = '#(?|string $attributes */ public function article_shortcode( $attributes = array(), ?string $content = null ): string { return '
' . ( null === $content ? '' : wp_kses_post( $content ) ); } /** @param array|string $attributes */ public function stop_shortcode( $attributes = array(), ?string $content = null ): string { return '
' . ( null === $content ? '' : wp_kses_post( $content ) ); } /** @param array|string $attributes */ public function removed_shortcode( $attributes = array(), ?string $content = null ): string { return null === $content ? '' : wp_kses_post( $content ); } /** * Convert only AI Narration's approved standalone shortcodes to markers. */ public function normalise_shortcodes( string $content ): string { $normalised = preg_replace_callback( self::SHORTCODE_PATTERN, function ( array $matches ): string { $tag = (string) $matches[1]; if ( 'ai_narration' === $tag ) { return $this->article_shortcode(); } if ( 'ai_narration_stop' === $tag ) { return $this->stop_shortcode(); } return $this->removed_shortcode(); }, $content ); return is_string( $normalised ) ? $normalised : $content; } public function contains_article_start( string $content ): bool { return 1 === preg_match( self::START_OPEN_PATTERN, $this->normalise_shortcodes( $content ) ); } /** * Replace the first start and every stop with plain-text boundary tokens. * Parsing the complete HTML before slicing keeps nested builder markup valid. */ public function tokenise_article_boundaries( string $content, string $start_token, string $stop_token ): string { $content = $this->normalise_shortcodes( $content ); $placed = false; $content = (string) preg_replace_callback( self::START_PATTERN, static function () use ( $start_token, &$placed ): string { if ( ! $placed ) { $placed = true; return $start_token; } return ''; }, $content ); return (string) preg_replace( self::STOP_PATTERN, $stop_token, $content ); } /** * Apply the first article placement and remove every remaining boundary. */ public function render_article( string $content, string $player, bool $enabled ): string { $content = $this->normalise_shortcodes( $content ); $placed = false; $content = (string) preg_replace_callback( self::START_PATTERN, static function () use ( $player, $enabled, &$placed ): string { if ( $enabled && ! $placed ) { $placed = true; return $player; } return ''; }, $content ); return (string) preg_replace( self::STOP_PATTERN, '', $content ); } /** * Return only the content between the first article start and following stop. */ public function scope_article( string $content ): string { $content = $this->normalise_shortcodes( $content ); if ( 1 !== preg_match( self::START_PATTERN, $content, $start_match, PREG_OFFSET_CAPTURE ) ) { throw new RuntimeException( 'The empty AI Narration placement could not be used as the narration start boundary.' ); } $start_markup = (string) $start_match[0][0]; $start_offset = (int) $start_match[0][1] + strlen( $start_markup ); $bounded = (string) substr( $content, $start_offset ); if ( 1 === preg_match( self::STOP_PATTERN, $bounded, $stop_match, PREG_OFFSET_CAPTURE ) ) { $bounded = (string) substr( $bounded, 0, (int) $stop_match[0][1] ); } return $bounded; } }