]*\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;
}
}