$category = $this->get_primary_category_label( $post->ID ); $description = $this->build_recipe_description( $post, $schema_data ); return array( 'title' => get_the_title( $post->ID ), 'description' => $description, 'ingredients' => $ingredients, 'instructions' => $instructions, 'prep_time' => $prep_time, 'cook_time' => $cook_time, 'total_time' => $this->build_total_time_label( $prep_time, $cook_time ), 'servings' => $servings, 'nutrition' => $nutrition, 'category' => $category, 'notes' => $notes, 'image' => get_the_post_thumbnail_url( $post->ID, 'full' ) ?: '', 'url' => get_permalink( $post->ID ), $category = $this->get_primary_category_label( $post->ID ); $description = $this->build_recipe_description( $post, $schema_data ); return array( 'title' => get_the_title( $post->ID ), 'description' => $description, 'ingredients' => $ingredients, 'instructions' => $instructions, 'prep_time' => $prep_time, 'cook_time' => $cook_time, 'total_time' => $this->build_total_time_label( $prep_time, $cook_time ), 'servings' => $servings, 'nutrition' => $nutrition, 'category' => $category, 'notes' => $notes, 'image' => get_the_post_thumbnail_url( $post->ID, 'full' ) ?: '', 'url' => get_permalink( $post->ID ), ); } $ingredients = ! empty( $schema_data['ingredients'] ) ? (array) $schema_data['ingredients'] : $this->extract_html_list_section( $content, array( 'Ingredients', 'Ingredient', 'What You Need', 'المكونات' ) ); if ( empty( $ingredients ) ) { $ingredients = $this->extract_list_block( $plain, array( 'Ingredients', 'Ingredient', 'المكونات' ), array( 'Instructions', 'Directions', 'Method', 'طريقة التحضير', 'الطريقة', 'Prep Time', 'Cook Time', 'Nutrition', 'FAQ', 'الأسئلة الشائعة' ) ); } $instructions = ! empty( $schema_data['instructions'] ) ? (array) $schema_data['instructions'] : $this->extract_html_list_section( $content, array( 'Instructions', 'Directions', 'Method', 'How to Make', 'طريقة التحضير', 'الطريقة' ) ); if ( empty( $instructions ) ) { $instructions = $this->extract_list_block( $plain, array( 'Instructions', 'Directions', 'Method', 'طريقة التحضير', 'الطريقة' ), array( 'Nutrition', 'Prep Time', 'Cook Time', 'Ingredients', 'FAQ', 'الأسئلة الشائعة' ) ); } $prep_time = ! empty( $schema_data['prep_time'] ) ? (string) $schema_data['prep_time'] : $this->extract_inline_value( $plain, array( 'Prep Time', 'وقت التحضير' ) ); $cook_time = ! empty( $schema_data['cook_time'] ) ? (string) $schema_data['cook_time'] : $this->extract_inline_value( $plain, array( 'Cook Time', 'وقت الطهي', 'وقت الطبخ' ) ); $nutrition = ! empty( $schema_data['nutrition'] ) ? (string) $schema_data['nutrition'] : $this->extract_inline_value( $plain, array( 'Nutrition', 'القيم الغذائية' ) ); $servings = ! empty( $schema_data['servings'] ) ? (string) $schema_data['servings'] : $this->extract_inline_value( $plain, array( 'Servings', 'Yield', 'الكمية', 'عدد الحصص' ) ); $notes = $this->extract_html_notes_section( $content, array( 'Notes', 'Tips', 'Chef Tips', 'Storage', 'ملاحظات', 'نصائح', 'التخزين' ) ); $category = $this->get_primary_category_label( $post->ID ); private function build_recipe_card_html( WP_Post $post, array $data ): string { $schema = array( '@context' => 'https://schema.org', '@type' => 'Recipe', 'name' => (string) $data['title'], 'description' => (string) $data['description'], 'keywords' => implode( ', ', $this->build_recipe_keywords( $post, $data ) ), 'mainEntityOfPage' => (string) $data['url'], 'recipeIngredient' => array_values( (array) $data['ingredients'] ), 'recipeInstructions' => array(), ); if ( ! empty( $data['image'] ) ) { $schema['image'] = array( (string) $data['image'] ); } if ( ! empty( $data['prep_time'] ) ) { $schema['prepTime'] = $this->label_to_iso_duration( (string) $data['prep_time'] ); } if ( ! empty( $data['cook_time'] ) ) { $schema['cookTime'] = $this->label_to_iso_duration( (string) $data['cook_time'] ); } if ( ! empty( $data['total_time'] ) ) { $schema['totalTime'] = $this->label_to_iso_duration( (string) $data['total_time'] ); } if ( ! empty( $data['servings'] ) ) { $schema['recipeYield'] = (string) $data['servings']; } if ( ! empty( $data['nutrition'] ) ) { $schema['nutrition'] = array( '@type' => 'NutritionInformation', 'calories' => (string) $data['nutrition'], ); } $aggregate_rating = $this->build_recipe_aggregate_rating( $post ); if ( ! empty( $aggregate_rating ) ) { $schema['aggregateRating'] = $aggregate_rating; } $video = $this->build_recipe_video_object( $post, $data ); if ( ! empty( $video ) ) { $schema['video'] = $video; } foreach ( (array) $data['instructions'] as $step ) { $schema['recipeInstructions'][] = array( '@type' => 'HowToStep', 'text' => (string) $step, ); } $html = self::CARD_START . "\n"; $html .= '
'; $html .= '
'; if ( ! empty( $data['image'] ) ) { $html .= '
' . esc_attr( (string) $data['title'] ) . '
'; } $html .= '
'; $html .= '
' . esc_html__( 'Recipe Card', 'recipe-seo-ai-pro' ) . '
'; $html .= '

' . esc_html( (string) $data['title'] ) . '

'; if ( ! empty( $data['description'] ) ) { $html .= '

' . esc_html( (string) $data['description'] ) . '

'; } $rating_value = ''; $review_count = 0; if ( ! empty( $schema['aggregateRating'] ) && is_array( $schema['aggregateRating'] ) ) { $rating_value = isset( $schema['aggregateRating']['ratingValue'] ) ? (string) $schema['aggregateRating']['ratingValue'] : ''; $review_count = isset( $schema['aggregateRating']['reviewCount'] ) ? (int) $schema['aggregateRating']['reviewCount'] : 0; } $html .= '
'; $html .= '
'; $html .= '
'; if ( $rating_value !== '' ) { $round = round( (float) $rating_value, 1 ); $html .= ''; $html .= '
' . esc_html( (string) $round ) . '
'; if ( $review_count > 0 ) { $html .= '
' . esc_html( (string) $review_count ) . ' ' . esc_html__( 'Reviews', 'recipe-seo-ai-pro' ) . '
'; } } $html .= '
'; $author = get_the_author_meta( 'display_name', $post->post_author ); $updated = get_post_modified_time( 'F j, Y', false, $post ); $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= '
'; $html .= '
'; $html .= '
'; $html .= $this->metric_card_html( __( 'Prep', 'recipe-seo-ai-pro' ), (string) $data['prep_time'] ); $html .= $this->metric_card_html( __( 'Cook', 'recipe-seo-ai-pro' ), (string) $data['cook_time'] ); $html .= $this->metric_card_html( __( 'Total', 'recipe-seo-ai-pro' ), (string) $data['total_time'] ); $html .= $this->metric_card_html( __( 'Servings', 'recipe-seo-ai-pro' ), (string) $data['servings'] ); $html .= $this->metric_card_html( __( 'Category', 'recipe-seo-ai-pro' ), (string) $data['category'] ); $html .= $this->metric_card_html( __( 'Nutrition', 'recipe-seo-ai-pro' ), (string) $data['nutrition'] ); $html .= '
'; $html .= '
'; $html .= ''; $html .= '
'; if ( ! empty( $data['ingredients'] ) ) { $html .= '
'; $html .= '

' . esc_html__( 'Ingredients', 'recipe-seo-ai-pro' ) . '

'; } if ( ! empty( $data['instructions'] ) ) { $html .= '
'; $html .= '

' . esc_html__( 'Instructions', 'recipe-seo-ai-pro' ) . '

    '; foreach ( (array) $data['instructions'] as $step ) { $html .= '
  1. ' . esc_html( (string) $step ) . '
  2. '; } $html .= '
'; } $html .= '
'; if ( ! empty( $data['notes'] ) ) { $html .= '
' . esc_html__( 'Notes', 'recipe-seo-ai-pro' ) . ': ' . esc_html( (string) $data['notes'] ) . '
'; } if ( ! $this->post_has_recipe_schema( (string) $post->post_content ) ) { $html .= ''; } $html .= '' . "\n"; $html .= self::CARD_END; return $html; } $html .= ''; } $html .= ''; if ( ! empty( $data['notes'] ) ) { $html .= '
' . esc_html__( 'Notes', 'recipe-seo-ai-pro' ) . ': ' . esc_html( (string) $data['notes'] ) . '
'; } if ( ! $this->post_has_recipe_schema( (string) $post->post_content ) ) { $html .= ''; } $html .= ''; $html .= '' . "\n"; $html .= self::CARD_END; return $html; } private function build_recipe_keywords( WP_Post $post, array $data ): array { $keywords = array(); foreach ( rsaip_get_focus_keywords( $post->ID ) as $keyword ) { $keyword = sanitize_text_field( (string) $keyword ); $keyword = trim( $keyword ); if ( $keyword !== '' ) { $keywords[] = $keyword; } } if ( empty( $keywords ) ) { $keywords[] = (string) $data['title']; $category = trim( (string) $data['category'] ); if ( $category !== '' ) { $keywords[] = $category; } } $keywords = array_values( array_unique( array_filter( $keywords, static function ( $value ): bool { return is_string( $value ) && trim( $value ) !== ''; } ) ) ); return array_slice( $keywords, 0, 10 ); } private function build_recipe_aggregate_rating( WP_Post $post ): array { $existing = $this->extract_existing_recipe_rating( (string) $post->post_content ); if ( ! empty( $existing ) ) { return $existing; } $rating_candidates = array( 'rsaip_recipe_rating_value', 'aggregate_rating', 'rating', 'recipe_rating', ); $review_candidates = array( 'rsaip_recipe_review_count', 'review_count', 'aggregate_rating_count', 'recipe_review_count', ); $rating_value = ''; foreach ( $rating_candidates as $meta_key ) { $meta_value = get_post_meta( $post->ID, $meta_key, true ); if ( is_string( $meta_value ) && trim( $meta_value ) !== '' ) { $rating_value = trim( $meta_value ); break; } if ( is_numeric( $meta_value ) ) { $rating_value = (string) $meta_value; break; } } $review_count = 0; foreach ( $review_candidates as $meta_key ) { $meta_value = get_post_meta( $post->ID, $meta_key, true ); if ( is_numeric( $meta_value ) ) { $review_count = (int) $meta_value; break; } } if ( $rating_value === '' ) { return array(); } $rating_value = (float) $rating_value; if ( $rating_value <= 0 ) { return array(); } $rating = array( '@type' => 'AggregateRating', 'ratingValue' => (string) $rating_value, 'bestRating' => '5', 'worstRating' => '1', ); if ( $review_count > 0 ) { $rating['reviewCount'] = $review_count; } return $rating; } private function build_recipe_video_object( WP_Post $post, array $data ): array { $video_url = $this->extract_video_url_from_content( (string) $post->post_content ); if ( $video_url === '' ) { return array(); } $video = array( '@type' => 'VideoObject', 'name' => (string) $data['title'], 'description' => (string) $data['description'], 'contentUrl' => $video_url, 'embedUrl' => $video_url, ); $thumbnail = get_the_post_thumbnail_url( $post->ID, 'large' ); if ( is_string( $thumbnail ) && $thumbnail !== '' ) { $video['thumbnailUrl'] = $thumbnail; } return $video; } private function extract_existing_recipe_rating( string $content ): array { if ( ! preg_match_all( '/]+type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/isu', $content, $matches ) ) { return array(); } foreach ( (array) $matches[1] as $raw ) { $decoded = json_decode( trim( (string) $raw ), true ); if ( ! is_array( $decoded ) ) { continue; } $nodes = $this->flatten_schema_nodes( $decoded ); foreach ( $nodes as $node ) { if ( ! isset( $node['@type'] ) ) { continue; } $type = $node['@type']; if ( $type !== 'Recipe' && ( ! is_array( $type ) || ! in_array( 'Recipe', $type, true ) ) ) { continue; } if ( ! empty( $node['aggregateRating'] ) && is_array( $node['aggregateRating'] ) ) { return $node['aggregateRating']; } } } return array(); } private function extract_video_url_from_content( string $content ): string { $patterns = array( '/https?:\/\/[^\s"\'<>]+(?:youtube\.com|youtu\.be|vimeo\.com|videopress\.com|\.mp4|\.webm)[^\s"\'<>]*/iu', '/\b(?:youtube\.com|youtu\.be|vimeo\.com|videopress\.com)\b[^\s"\'<>]*/iu', ); foreach ( $patterns as $pattern ) { if ( preg_match( $pattern, $content, $match ) ) { return trim( (string) $match[0] ); } } return ''; } private function post_has_recipe_schema( string $content ): bool { if ( ! preg_match_all( '/]+type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/isu', $content, $matches ) ) { return false; } foreach ( (array) $matches[1] as $raw ) { $decoded = json_decode( trim( (string) $raw ), true ); if ( ! is_array( $decoded ) ) { continue; } $nodes = $this->flatten_schema_nodes( $decoded ); foreach ( $nodes as $node ) { $type = $node['@type'] ?? ''; if ( $type === 'Recipe' || ( is_array( $type ) && in_array( 'Recipe', $type, true ) ) ) { return true; } } } return false; } private function render_stars( float $rating ): string { $out = ''; $full = (int) floor( $rating ); $half = ( $rating - $full ) >= 0.5 ? 1 : 0; for ( $i = 0; $i < 5; $i++ ) { if ( $i < $full ) { $out .= ''; } elseif ( $i === $full && $half ) { $out .= ''; } else { $out .= ''; } } return $out; } private function metric_card_html( string $label, string $value ): string { if ( trim( $value ) === '' ) { return ''; } return '
' . esc_html( $label ) . '' . esc_html( $value ) . '
'; } private function plain_text_from_content( string $content ): string { $text = preg_replace( '/<\s*br\s*\/?>/i', "\n", $content ); $text = preg_replace( '/<\/(p|div|li|ul|ol|h1|h2|h3|h4|h5|h6|table|tr)>/i', "$0\n", (string) $text ); $text = wp_strip_all_tags( (string) $text ); $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ); $text = preg_replace( "/\r\n?/", "\n", (string) $text ); $text = preg_replace( "/\n{3,}/", "\n\n", (string) $text ); return trim( (string) $text ); } private function extract_list_block( string $text, array $headings, array $stop_headings ): array { $heading_pattern = implode( '|', array_map( 'preg_quote', $headings ) ); $stop_pattern = implode( '|', array_map( 'preg_quote', $stop_headings ) ); if ( ! preg_match( '/(?:^|\n)\s*(?:' . $heading_pattern . ')\s*:?\s*\n(.+?)(?=\n\s*(?:' . $stop_pattern . ')\b|\z)/isu', $text, $match ) ) { return array(); } $block = trim( (string) ( $match[1] ?? '' ) ); if ( $block === '' ) { return array(); } $lines = preg_split( '/\n+/u', $block ); $out = array(); foreach ( (array) $lines as $line ) { $line = trim( (string) $line ); $line = preg_replace( '/^[\-\*\•\d\.\)\(]+\s*/u', '', (string) $line ); $line = preg_replace( '/\s+/u', ' ', (string) $line ); $line = trim( (string) $line ); if ( $line === '' ) { continue; } $out[] = $line; if ( count( $out ) >= 20 ) { break; } } return $out; } private function extract_html_list_section( string $content, array $headings ): array { $heading_pattern = implode( '|', array_map( 'preg_quote', $headings ) ); if ( ! preg_match( '/]*>\s*(?:' . $heading_pattern . ')\s*<\/h[2-4]>(.*?)(?=]*>|$)/isu', $content, $match ) ) { return array(); } $block = (string) ( $match[1] ?? '' ); $items = array(); if ( preg_match_all( '/]*>(.*?)<\/li>/isu', $block, $li_matches ) ) { foreach ( (array) $li_matches[1] as $li ) { $text = trim( wp_strip_all_tags( html_entity_decode( (string) $li, ENT_QUOTES, 'UTF-8' ) ) ); $text = preg_replace( '/\s+/u', ' ', (string) $text ); if ( $text === '' ) { continue; } $items[] = $text; } } if ( empty( $items ) ) { $block = preg_replace( '//i', "\n", $block ); $block = wp_strip_all_tags( (string) $block ); $lines = preg_split( '/\n+/u', (string) $block ); foreach ( (array) $lines as $line ) { $line = trim( preg_replace( '/^[\-\*\•\d\.\)\(]+\s*/u', '', (string) $line ) ); $line = preg_replace( '/\s+/u', ' ', (string) $line ); if ( $line === '' ) { continue; } $items[] = $line; } } return array_values( array_slice( array_unique( $items ), 0, 25 ) ); } private function extract_html_notes_section( string $content, array $headings ): string { $heading_pattern = implode( '|', array_map( 'preg_quote', $headings ) ); if ( preg_match( '/]*>\s*(?:' . $heading_pattern . ')\s*<\/h[2-4]>(.*?)(?=]*>|$)/isu', $content, $match ) ) { $text = trim( wp_strip_all_tags( html_entity_decode( (string) ( $match[1] ?? '' ), ENT_QUOTES, 'UTF-8' ) ) ); $text = preg_replace( '/\s+/u', ' ', (string) $text ); return $this->safe_truncate( (string) $text, 260 ); } return ''; } private function extract_recipe_schema_data( string $content ): array { $data = array( 'ingredients' => array(), 'instructions' => array(), 'prep_time' => '', 'cook_time' => '', 'servings' => '', 'nutrition' => '', 'description' => '', ); if ( ! preg_match_all( '/]+type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/isu', $content, $matches ) ) { return $data; } foreach ( (array) $matches[1] as $raw ) { $decoded = json_decode( trim( (string) $raw ), true ); if ( ! is_array( $decoded ) ) { continue; } $nodes = $this->flatten_schema_nodes( $decoded ); foreach ( $nodes as $node ) { $type = $node['@type'] ?? ''; if ( $type !== 'Recipe' && ( ! is_array( $type ) || ! in_array( 'Recipe', $type, true ) ) ) { continue; } $data['ingredients'] = $this->normalize_schema_ingredients( $node['recipeIngredient'] ?? array() ); $data['instructions'] = $this->normalize_schema_instructions( $node['recipeInstructions'] ?? array() ); $data['prep_time'] = $this->iso_to_label( (string) ( $node['prepTime'] ?? '' ) ); $data['cook_time'] = $this->iso_to_label( (string) ( $node['cookTime'] ?? '' ) ); $data['servings'] = sanitize_text_field( (string) ( $node['recipeYield'] ?? '' ) ); if ( ! empty( $node['nutrition']['calories'] ) ) { $data['nutrition'] = sanitize_text_field( (string) $node['nutrition']['calories'] ); } $data['description'] = sanitize_text_field( (string) ( $node['description'] ?? '' ) ); return $data; } } return $data; } private function flatten_schema_nodes( $schema ): array { $out = array(); if ( ! is_array( $schema ) ) { return $out; } $out[] = $schema; foreach ( $schema as $value ) { if ( is_array( $value ) ) { foreach ( $this->flatten_schema_nodes( $value ) as $child ) { $out[] = $child; } } } return $out; } private function normalize_schema_ingredients( $ingredients ): array { if ( ! is_array( $ingredients ) ) { return array(); } $out = array(); foreach ( $ingredients as $ingredient ) { $text = sanitize_text_field( is_string( $ingredient ) ? $ingredient : wp_json_encode( $ingredient ) ); $text = trim( preg_replace( '/\s+/u', ' ', (string) $text ) ); if ( $text !== '' ) { $out[] = $text; } } return array_values( array_slice( array_unique( $out ), 0, 25 ) ); } private function normalize_schema_instructions( $instructions ): array { if ( is_string( $instructions ) && trim( $instructions ) !== '' ) { return array( sanitize_text_field( trim( $instructions ) ) ); } if ( ! is_array( $instructions ) ) { return array(); } $out = array(); foreach ( $instructions as $instruction ) { if ( is_string( $instruction ) ) { $text = sanitize_text_field( trim( $instruction ) ); } elseif ( is_array( $instruction ) ) { $text = sanitize_text_field( (string) ( $instruction['text'] ?? $instruction['name'] ?? '' ) ); } else { $text = ''; } $text = trim( preg_replace( '/\s+/u', ' ', (string) $text ) ); if ( $text !== '' ) { $out[] = $text; } } return array_values( array_slice( array_unique( $out ), 0, 25 ) ); } private function build_recipe_description( WP_Post $post, array $schema_data ): string { if ( ! empty( $schema_data['description'] ) ) { return (string) $schema_data['description']; } $excerpt = has_excerpt( $post ) ? (string) $post->post_excerpt : (string) wp_trim_words( wp_strip_all_tags( $post->post_content ), 28, '' ); $excerpt = trim( preg_replace( '/\s+/u', ' ', (string) $excerpt ) ); return $this->safe_truncate( $excerpt, 180 ); } private function get_primary_category_label( int $post_id ): string { $categories = get_the_terms( $post_id, 'category' ); if ( is_array( $categories ) && ! empty( $categories[0]->name ) ) { return sanitize_text_field( (string) $categories[0]->name ); } return ''; } private function extract_inline_value( string $text, array $labels ): string { $label_pattern = implode( '|', array_map( 'preg_quote', $labels ) ); if ( preg_match( '/(?:' . $label_pattern . ')\s*[:\-]?\s*([^\n]{1,80})/iu', $text, $match ) ) { return trim( (string) $match[1] ); } return ''; } private function build_total_time_label( string $prep_time, string $cook_time ): string { $prep_minutes = $this->label_to_minutes( $prep_time ); $cook_minutes = $this->label_to_minutes( $cook_time ); $total = $prep_minutes + $cook_minutes; if ( $total <= 0 ) { return ''; } $hours = (int) floor( $total / 60 ); $minutes = $total % 60; if ( $hours > 0 && $minutes > 0 ) { return $hours . ' hour ' . $minutes . ' min'; } if ( $hours > 0 ) { return $hours . ' hour'; } return $minutes . ' min'; } private function label_to_minutes( string $label ): int { $label = strtolower( trim( $label ) ); if ( $label === '' ) { return 0; } $total = 0; if ( preg_match( '/(\d+)\s*(hour|hours|hr|hrs|ساعة|ساعات)/u', $label, $hours ) ) { $total += absint( $hours[1] ) * 60; } if ( preg_match( '/(\d+)\s*(minute|minutes|min|mins|دقيقة|دقائق)/u', $label, $mins ) ) { $total += absint( $mins[1] ); } if ( 0 === $total && preg_match( '/^\d+$/', $label ) ) { $total = absint( $label ); } return $total; } private function label_to_iso_duration( string $label ): string { $minutes = $this->label_to_minutes( $label ); if ( $minutes <= 0 ) { return ''; } $hours = (int) floor( $minutes / 60 ); $mins = $minutes % 60; $iso = 'PT'; if ( $hours > 0 ) { $iso .= $hours . 'H'; } if ( $mins > 0 ) { $iso .= $mins . 'M'; } return 'PT' === $iso ? '' : $iso; } private function iso_to_label( string $iso ): string { $iso = trim( strtoupper( $iso ) ); if ( $iso === '' || strpos( $iso, 'PT' ) !== 0 ) { return ''; } $hours = 0; $mins = 0; if ( preg_match( '/(\d+)H/', $iso, $hour_match ) ) { $hours = absint( $hour_match[1] ); } if ( preg_match( '/(\d+)M/', $iso, $min_match ) ) { $mins = absint( $min_match[1] ); } if ( $hours > 0 && $mins > 0 ) { return $hours . ' hour ' . $mins . ' min'; } if ( $hours > 0 ) { return $hours . ' hour'; } if ( $mins > 0 ) { return $mins . ' min'; } return ''; } private function safe_truncate( string $text, int $length ): string { if ( function_exists( 'mb_substr' ) ) { return (string) mb_substr( $text, 0, $length ); } return substr( $text, 0, $length ); } }