}> */ public static function nested_catalog(): array { return array( 'Person' => array( 'description' => __( 'A person (author, speaker, profile).', 'aiforya-schema-markup' ), 'fields' => array( 'name' => array( 'label' => __( 'Full name', 'aiforya-schema-markup' ), 'type' => 'text' ), 'url' => array( 'label' => __( 'Profile URL', 'aiforya-schema-markup' ), 'type' => 'url' ), 'image' => array( 'label' => __( 'Photo URL', 'aiforya-schema-markup' ), 'type' => 'url' ), 'jobTitle' => array( 'label' => __( 'Job Title', 'aiforya-schema-markup' ), 'type' => 'text' ), 'sameAs' => array( 'label' => __( 'sameAs (URLs)', 'aiforya-schema-markup' ), 'type' => 'string_array', 'description' => __( 'Links to LinkedIn, Twitter, Wikipedia profiles (one per line).', 'aiforya-schema-markup' ) ), ), ), 'Organization' => array( 'description' => __( 'An organization (company, NGO, school, publisher).', 'aiforya-schema-markup' ), 'fields' => array( 'name' => array( 'label' => __( 'Name', 'aiforya-schema-markup' ), 'type' => 'text' ), 'url' => array( 'label' => __( 'Website', 'aiforya-schema-markup' ), 'type' => 'url' ), 'logo' => array( 'label' => __( 'Logo URL', 'aiforya-schema-markup' ), 'type' => 'url' ), 'description' => array( 'label' => __( 'Description', 'aiforya-schema-markup' ), 'type' => 'textarea' ), 'sameAs' => array( 'label' => __( 'sameAs (URLs)', 'aiforya-schema-markup' ), 'type' => 'string_array' ), ), ), 'ImageObject' => array( 'description' => __( 'An image with explicit dimensions (strong Google signal).', 'aiforya-schema-markup' ), 'fields' => array( 'url' => array( 'label' => __( 'Image URL', 'aiforya-schema-markup' ), 'type' => 'url' ), 'width' => array( 'label' => __( 'Width (px)', 'aiforya-schema-markup' ), 'type' => 'number' ), 'height' => array( 'label' => __( 'Height (px)', 'aiforya-schema-markup' ), 'type' => 'number' ), 'caption' => array( 'label' => __( 'Caption', 'aiforya-schema-markup' ), 'type' => 'text' ), ), ), 'PostalAddress' => array( 'description' => __( 'A postal address (LocalBusiness, Event, Place).', 'aiforya-schema-markup' ), 'fields' => array( 'streetAddress' => array( 'label' => __( 'Street + number', 'aiforya-schema-markup' ), 'type' => 'text' ), 'addressLocality' => array( 'label' => __( 'City', 'aiforya-schema-markup' ), 'type' => 'text' ), 'postalCode' => array( 'label' => __( 'Postal code', 'aiforya-schema-markup' ), 'type' => 'text' ), 'addressRegion' => array( 'label' => __( 'Region / State', 'aiforya-schema-markup' ), 'type' => 'text' ), 'addressCountry' => array( 'label' => __( 'Country (ISO 3166)', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( '2-letter code: FR, DE, US, ...', 'aiforya-schema-markup' ) ), ), ), 'GeoCoordinates' => array( 'description' => __( 'GPS coordinates (latitude/longitude).', 'aiforya-schema-markup' ), 'fields' => array( 'latitude' => array( 'label' => __( 'Latitude', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( 'Decimal format (e.g. 48.8566).', 'aiforya-schema-markup' ) ), 'longitude' => array( 'label' => __( 'Longitude', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( 'Decimal format (e.g. 2.3522).', 'aiforya-schema-markup' ) ), ), ), 'Place' => array( 'description' => __( 'A physical location (Event, tourist attraction).', 'aiforya-schema-markup' ), 'fields' => array( 'name' => array( 'label' => __( 'Location name', 'aiforya-schema-markup' ), 'type' => 'text' ), 'address' => array( 'label' => __( 'Address', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'PostalAddress' ), 'geo' => array( 'label' => __( 'GPS coordinates', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'GeoCoordinates' ), ), ), 'Offer' => array( 'description' => __( 'A commercial offer (price, availability, currency).', 'aiforya-schema-markup' ), 'fields' => array( 'price' => array( 'label' => __( 'Price', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( 'Decimal format without symbol (e.g., 19.99).', 'aiforya-schema-markup' ) ), 'priceCurrency' => array( 'label' => __( 'Currency', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( 'ISO 4217 code (EUR, USD, GBP, ...)', 'aiforya-schema-markup' ) ), 'availability' => array( 'label' => __( 'Availability', 'aiforya-schema-markup' ), 'type' => 'select', 'options' => array( 'https://schema.org/InStock' => __( 'In stock', 'aiforya-schema-markup' ), 'https://schema.org/OutOfStock' => __( 'Out of stock', 'aiforya-schema-markup' ), 'https://schema.org/PreOrder' => __( 'Pre-order', 'aiforya-schema-markup' ), 'https://schema.org/Discontinued' => __( 'Discontinued', 'aiforya-schema-markup' ), ) ), 'url' => array( 'label' => __( 'Offer URL', 'aiforya-schema-markup' ), 'type' => 'url' ), 'priceValidUntil' => array( 'label' => __( 'Valid until', 'aiforya-schema-markup' ), 'type' => 'date' ), ), ), 'AggregateRating' => array( 'description' => __( 'Aggregated average rating (SERP stars).', 'aiforya-schema-markup' ), 'fields' => array( 'ratingValue' => array( 'label' => __( 'Average rating', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( 'Decimal between 1.0 and 5.0.', 'aiforya-schema-markup' ) ), 'reviewCount' => array( 'label' => __( 'Review count', 'aiforya-schema-markup' ), 'type' => 'number' ), 'bestRating' => array( 'label' => __( 'Best rating', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( '5 by default.', 'aiforya-schema-markup' ) ), 'worstRating' => array( 'label' => __( 'Worst rating', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( '1 by default.', 'aiforya-schema-markup' ) ), ), ), 'Review' => array( 'description' => __( 'An individual customer review.', 'aiforya-schema-markup' ), 'fields' => array( 'reviewBody' => array( 'label' => __( 'Review text', 'aiforya-schema-markup' ), 'type' => 'textarea' ), 'author' => array( 'label' => __( 'Author', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Person' ), 'reviewRating' => array( 'label' => __( 'Rating', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( 'Decimal 1.0–5.0.', 'aiforya-schema-markup' ) ), 'datePublished' => array( 'label' => __( 'Publication date', 'aiforya-schema-markup' ), 'type' => 'date' ), ), ), 'HowToStep' => array( 'description' => __( 'A step in a tutorial or recipe.', 'aiforya-schema-markup' ), 'fields' => array( 'name' => array( 'label' => __( 'Step title', 'aiforya-schema-markup' ), 'type' => 'text' ), 'text' => array( 'label' => __( 'Description', 'aiforya-schema-markup' ), 'type' => 'textarea' ), 'image' => array( 'label' => __( 'Image URL', 'aiforya-schema-markup' ), 'type' => 'url' ), 'url' => array( 'label' => __( 'Anchor URL', 'aiforya-schema-markup' ), 'type' => 'url' ), ), ), 'Question' => array( 'description' => __( 'A FAQ question/answer pair.', 'aiforya-schema-markup' ), 'fields' => array( 'name' => array( 'label' => __( 'Question', 'aiforya-schema-markup' ), 'type' => 'textarea' ), 'acceptedAnswer' => array( 'label' => __( 'Answer', 'aiforya-schema-markup' ), 'type' => 'textarea', 'description' => __( 'Will be automatically wrapped in an Answer.text.', 'aiforya-schema-markup' ) ), ), ), 'ListItem' => array( 'description' => __( 'A breadcrumb item.', 'aiforya-schema-markup' ), 'fields' => array( 'position' => array( 'label' => __( 'Position', 'aiforya-schema-markup' ), 'type' => 'number' ), 'name' => array( 'label' => __( 'Name', 'aiforya-schema-markup' ), 'type' => 'text' ), 'item' => array( 'label' => __( 'URL', 'aiforya-schema-markup' ), 'type' => 'url' ), ), ), 'MonetaryAmount' => array( 'description' => __( 'A monetary amount (salary, event price).', 'aiforya-schema-markup' ), 'fields' => array( 'currency' => array( 'label' => __( 'Currency', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( 'ISO 4217 code (EUR, USD).', 'aiforya-schema-markup' ) ), 'value' => array( 'label' => __( 'Amount', 'aiforya-schema-markup' ), 'type' => 'text' ), 'unitText' => array( 'label' => __( 'Period', 'aiforya-schema-markup' ), 'type' => 'select', 'options' => array( 'HOUR' => __( 'Hour', 'aiforya-schema-markup' ), 'DAY' => __( 'Day', 'aiforya-schema-markup' ), 'WEEK' => __( 'Week', 'aiforya-schema-markup' ), 'MONTH' => __( 'Month', 'aiforya-schema-markup' ), 'YEAR' => __( 'Year', 'aiforya-schema-markup' ), ) ), ), ), 'NutritionInformation' => array( 'description' => __( 'Nutritional information (per serving).', 'aiforya-schema-markup' ), 'fields' => array( 'calories' => array( 'label' => __( 'Calories', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( 'Format: "350 calories".', 'aiforya-schema-markup' ) ), 'proteinContent' => array( 'label' => __( 'Protein', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( 'For example: "28 g".', 'aiforya-schema-markup' ) ), 'fatContent' => array( 'label' => __( 'Fat', 'aiforya-schema-markup' ), 'type' => 'text' ), 'carbohydrateContent' => array( 'label' => __( 'Carbohydrates', 'aiforya-schema-markup' ), 'type' => 'text' ), 'fiberContent' => array( 'label' => __( 'Fiber', 'aiforya-schema-markup' ), 'type' => 'text' ), 'servingSize' => array( 'label' => __( 'Serving size', 'aiforya-schema-markup' ), 'type' => 'text' ), ), ), ); } /** * Catalogue enrichi des 17 types Schema.org racine, avec : * - description : phrase de cadrage UX * - required[] / recommended[] / optional[] * - fields[name] : { label, type, description?, nested_type?, options?, default? } * * Synchronisé avec Aiforya_Schema_Markup_Predictor::field_catalog() (poids requis/recommandés). * * @return array */ public static function schema_catalog(): array { // Helpers pour réduire la verbosité. $headline = array( 'label' => __( 'Main title', 'aiforya-schema-markup' ), 'type' => 'text' ); $name = array( 'label' => __( 'Name', 'aiforya-schema-markup' ), 'type' => 'text' ); $desc = array( 'label' => __( 'Description', 'aiforya-schema-markup' ), 'type' => 'textarea' ); $url = array( 'label' => __( 'URL', 'aiforya-schema-markup' ), 'type' => 'url' ); $image_url = array( 'label' => __( 'Image URL', 'aiforya-schema-markup' ), 'type' => 'url' ); $datePub = array( 'label' => __( 'Publication date', 'aiforya-schema-markup' ), 'type' => 'date' ); $dateMod = array( 'label' => __( 'Modification date', 'aiforya-schema-markup' ), 'type' => 'date' ); return array( 'Article' => array( 'description' => __( 'Generic editorial content (blog, press, feature).', 'aiforya-schema-markup' ), 'required' => array( 'headline', 'image', 'datePublished', 'author', 'publisher' ), 'recommended' => array( 'description', 'dateModified', 'wordCount', 'keywords', 'articleSection' ), 'optional' => array( 'inLanguage', 'mainEntityOfPage' ), 'fields' => array( 'headline' => $headline, 'image' => $image_url, 'datePublished' => $datePub, 'author' => array( 'label' => __( 'Author', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Person' ), 'publisher' => array( 'label' => __( 'Publisher', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Organization' ), 'description' => $desc, 'dateModified' => $dateMod, 'wordCount' => array( 'label' => __( 'Word count', 'aiforya-schema-markup' ), 'type' => 'number' ), 'keywords' => array( 'label' => __( 'Keywords (3-5)', 'aiforya-schema-markup' ), 'type' => 'string_array', 'description' => __( 'One keyword per line.', 'aiforya-schema-markup' ) ), 'articleSection' => array( 'label' => __( 'Main category', 'aiforya-schema-markup' ), 'type' => 'text' ), 'inLanguage' => array( 'label' => __( 'Language (BCP 47)', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( 'For example: fr-FR, en-US.', 'aiforya-schema-markup' ) ), 'mainEntityOfPage' => $url, ), ), 'BlogPosting' => array( 'description' => __( 'Blog post (Article variant targeting editorial blogs).', 'aiforya-schema-markup' ), 'required' => array( 'headline', 'image', 'datePublished', 'author', 'publisher' ), 'recommended' => array( 'description', 'dateModified', 'keywords', 'articleSection' ), 'optional' => array( 'inLanguage' ), 'fields' => array( 'headline' => $headline, 'image' => $image_url, 'datePublished' => $datePub, 'author' => array( 'label' => __( 'Author', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Person' ), 'publisher' => array( 'label' => __( 'Publisher', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Organization' ), 'description' => $desc, 'dateModified' => $dateMod, 'keywords' => array( 'label' => __( 'Keywords', 'aiforya-schema-markup' ), 'type' => 'string_array' ), 'articleSection' => array( 'label' => __( 'Category', 'aiforya-schema-markup' ), 'type' => 'text' ), 'inLanguage' => array( 'label' => __( 'Language (BCP 47)', 'aiforya-schema-markup' ), 'type' => 'text' ), ), ), 'NewsArticle' => array( 'description' => __( 'News article / press release.', 'aiforya-schema-markup' ), 'required' => array( 'headline', 'image', 'datePublished', 'author', 'publisher' ), 'recommended' => array( 'description', 'dateModified', 'dateline' ), 'optional' => array( 'printSection', 'inLanguage' ), 'fields' => array( 'headline' => $headline, 'image' => $image_url, 'datePublished' => $datePub, 'author' => array( 'label' => __( 'Author', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Person' ), 'publisher' => array( 'label' => __( 'Publisher', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Organization' ), 'description' => $desc, 'dateModified' => $dateMod, 'dateline' => array( 'label' => __( 'Dateline (ex: PARIS, FR)', 'aiforya-schema-markup' ), 'type' => 'text' ), 'printSection' => array( 'label' => __( 'Print section', 'aiforya-schema-markup' ), 'type' => 'text' ), 'inLanguage' => array( 'label' => __( 'Language (BCP 47)', 'aiforya-schema-markup' ), 'type' => 'text' ), ), ), 'Product' => array( 'description' => __( 'E-commerce product (SERP stars + Shopping).', 'aiforya-schema-markup' ), 'required' => array( 'name', 'image', 'description', 'offers' ), 'recommended' => array( 'aggregateRating', 'review', 'brand', 'sku', 'gtin' ), 'optional' => array( 'category', 'mpn', 'color' ), 'fields' => array( 'name' => $name, 'image' => $image_url, 'description' => $desc, 'offers' => array( 'label' => __( 'Offers', 'aiforya-schema-markup' ), 'type' => 'nested_array', 'nested_type' => 'Offer', 'item_label' => __( 'Offer', 'aiforya-schema-markup' ) ), 'aggregateRating' => array( 'label' => __( 'Average rating', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'AggregateRating' ), 'review' => array( 'label' => __( 'Customer Reviews', 'aiforya-schema-markup' ), 'type' => 'nested_array', 'nested_type' => 'Review', 'item_label' => __( 'Reviews', 'aiforya-schema-markup' ) ), 'brand' => array( 'label' => __( 'Brand', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Organization' ), 'sku' => array( 'label' => __( 'SKU', 'aiforya-schema-markup' ), 'type' => 'text' ), 'gtin' => array( 'label' => __( 'GTIN/EAN/UPC', 'aiforya-schema-markup' ), 'type' => 'text' ), 'category' => array( 'label' => __( 'Category', 'aiforya-schema-markup' ), 'type' => 'text' ), 'mpn' => array( 'label' => __( 'MPN (Manufacturer Part Number)', 'aiforya-schema-markup' ), 'type' => 'text' ), 'color' => array( 'label' => __( 'Color', 'aiforya-schema-markup' ), 'type' => 'text' ), ), ), 'Recipe' => array( 'description' => __( 'Cooking recipe (SERP carousel + nutrition panel).', 'aiforya-schema-markup' ), 'required' => array( 'name', 'image', 'recipeIngredient', 'recipeInstructions' ), 'recommended' => array( 'cookTime', 'prepTime', 'totalTime', 'recipeYield', 'nutrition', 'aggregateRating' ), 'optional' => array( 'description', 'recipeCategory', 'recipeCuisine', 'keywords' ), 'fields' => array( 'name' => $name, 'image' => $image_url, 'recipeIngredient' => array( 'label' => __( 'Ingredients', 'aiforya-schema-markup' ), 'type' => 'string_array', 'description' => __( 'One ingredient per line (e.g., "200g of flour").', 'aiforya-schema-markup' ) ), 'recipeInstructions' => array( 'label' => __( 'Preparation Steps', 'aiforya-schema-markup' ), 'type' => 'nested_array', 'nested_type' => 'HowToStep', 'item_label' => __( 'Step', 'aiforya-schema-markup' ) ), 'cookTime' => array( 'label' => __( 'Cook Time', 'aiforya-schema-markup' ), 'type' => 'duration', 'description' => __( 'ISO 8601 format (PT30M = 30 min).', 'aiforya-schema-markup' ) ), 'prepTime' => array( 'label' => __( 'Prep Time', 'aiforya-schema-markup' ), 'type' => 'duration' ), 'totalTime' => array( 'label' => __( 'Total Time', 'aiforya-schema-markup' ), 'type' => 'duration' ), 'recipeYield' => array( 'label' => __( 'Servings', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( 'E.g., "4 servings" or "1 cake".', 'aiforya-schema-markup' ) ), 'nutrition' => array( 'label' => __( 'Nutritional Information', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'NutritionInformation' ), 'aggregateRating' => array( 'label' => __( 'Average rating', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'AggregateRating' ), 'description' => $desc, 'recipeCategory' => array( 'label' => __( 'Category (appetizer, main course, dessert)', 'aiforya-schema-markup' ), 'type' => 'text' ), 'recipeCuisine' => array( 'label' => __( 'Cuisine (Italian, Japanese...)', 'aiforya-schema-markup' ), 'type' => 'text' ), 'keywords' => array( 'label' => __( 'Keywords', 'aiforya-schema-markup' ), 'type' => 'string_array' ), ), ), 'Event' => array( 'description' => __( 'Event (concert, conference, festival).', 'aiforya-schema-markup' ), 'required' => array( 'name', 'startDate', 'location' ), 'recommended' => array( 'description', 'image', 'endDate', 'organizer', 'offers', 'eventStatus', 'eventAttendanceMode' ), 'optional' => array( 'performer' ), 'fields' => array( 'name' => $name, 'startDate' => array( 'label' => __( 'Start Date', 'aiforya-schema-markup' ), 'type' => 'datetime' ), 'location' => array( 'label' => __( 'Venue', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Place' ), 'description' => $desc, 'image' => $image_url, 'endDate' => array( 'label' => __( 'End Date', 'aiforya-schema-markup' ), 'type' => 'datetime' ), 'organizer' => array( 'label' => __( 'Organizer', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Organization' ), 'offers' => array( 'label' => __( 'Ticketing', 'aiforya-schema-markup' ), 'type' => 'nested_array', 'nested_type' => 'Offer', 'item_label' => __( 'Price', 'aiforya-schema-markup' ) ), 'eventStatus' => array( 'label' => __( 'Status', 'aiforya-schema-markup' ), 'type' => 'select', 'options' => array( 'https://schema.org/EventScheduled' => __( 'Scheduled', 'aiforya-schema-markup' ), 'https://schema.org/EventCancelled' => __( 'Cancelled', 'aiforya-schema-markup' ), 'https://schema.org/EventPostponed' => __( 'Postponed', 'aiforya-schema-markup' ), 'https://schema.org/EventRescheduled' => __( 'Rescheduled', 'aiforya-schema-markup' ), 'https://schema.org/EventMovedOnline' => __( 'Moved Online', 'aiforya-schema-markup' ), ) ), 'eventAttendanceMode' => array( 'label' => __( 'Attendance', 'aiforya-schema-markup' ), 'type' => 'select', 'options' => array( 'https://schema.org/OfflineEventAttendanceMode' => __( 'In-person', 'aiforya-schema-markup' ), 'https://schema.org/OnlineEventAttendanceMode' => __( 'Online', 'aiforya-schema-markup' ), 'https://schema.org/MixedEventAttendanceMode' => __( 'Mixed', 'aiforya-schema-markup' ), ) ), 'performer' => array( 'label' => __( 'Performer / Speaker', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Person' ), ), ), 'FAQPage' => array( 'description' => __( 'FAQ page (rich snippet accordion SERP).', 'aiforya-schema-markup' ), 'required' => array( 'mainEntity' ), 'recommended' => array(), 'optional' => array( 'name', 'description' ), 'fields' => array( 'mainEntity' => array( 'label' => __( 'Questions / Answers', 'aiforya-schema-markup' ), 'type' => 'nested_array', 'nested_type' => 'Question', 'item_label' => __( 'Q&A', 'aiforya-schema-markup' ) ), 'name' => $name, 'description' => $desc, ), ), 'HowTo' => array( 'description' => __( 'Step-by-step tutorial.', 'aiforya-schema-markup' ), 'required' => array( 'name', 'step' ), 'recommended' => array( 'totalTime', 'tool', 'supply', 'image', 'estimatedCost' ), 'optional' => array( 'description' ), 'fields' => array( 'name' => $name, 'step' => array( 'label' => __( 'Steps', 'aiforya-schema-markup' ), 'type' => 'nested_array', 'nested_type' => 'HowToStep', 'item_label' => __( 'Step', 'aiforya-schema-markup' ) ), 'totalTime' => array( 'label' => __( 'Total Duration', 'aiforya-schema-markup' ), 'type' => 'duration' ), 'tool' => array( 'label' => __( 'Required Tools', 'aiforya-schema-markup' ), 'type' => 'string_array', 'description' => __( 'One tool per line.', 'aiforya-schema-markup' ) ), 'supply' => array( 'label' => __( 'Required Supplies', 'aiforya-schema-markup' ), 'type' => 'string_array' ), 'image' => $image_url, 'estimatedCost' => array( 'label' => __( 'Estimated cost', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'MonetaryAmount' ), 'description' => $desc, ), ), 'VideoObject' => array( 'description' => __( 'Video (video rich snippet SERP, Google Discover).', 'aiforya-schema-markup' ), 'required' => array( 'name', 'description', 'thumbnailUrl', 'uploadDate' ), 'recommended' => array( 'duration', 'contentUrl', 'embedUrl', 'transcript' ), 'optional' => array( 'expires' ), 'fields' => array( 'name' => $name, 'description' => $desc, 'thumbnailUrl' => array( 'label' => __( 'Thumbnail URL', 'aiforya-schema-markup' ), 'type' => 'url' ), 'uploadDate' => array( 'label' => __( 'Upload date', 'aiforya-schema-markup' ), 'type' => 'datetime' ), 'duration' => array( 'label' => __( 'Duration', 'aiforya-schema-markup' ), 'type' => 'duration', 'description' => __( 'ISO 8601 format (PT2M30S = 2min30).', 'aiforya-schema-markup' ) ), 'contentUrl' => array( 'label' => __( 'Video file URL (.mp4)', 'aiforya-schema-markup' ), 'type' => 'url' ), 'embedUrl' => array( 'label' => __( 'Embed URL (YouTube/Vimeo)', 'aiforya-schema-markup' ), 'type' => 'url' ), 'transcript' => array( 'label' => __( 'Transcript', 'aiforya-schema-markup' ), 'type' => 'textarea' ), 'expires' => array( 'label' => __( 'Expires on', 'aiforya-schema-markup' ), 'type' => 'date' ), ), ), 'LocalBusiness' => array( 'description' => __( 'Commerce / local business (Maps rich snippet).', 'aiforya-schema-markup' ), 'required' => array( 'name', 'address', 'telephone' ), 'recommended' => array( 'openingHours', 'image', 'priceRange', 'aggregateRating', 'geo', 'url' ), 'optional' => array( 'description' ), 'fields' => array( 'name' => $name, 'address' => array( 'label' => __( 'Postal address', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'PostalAddress' ), 'telephone' => array( 'label' => __( 'Phone', 'aiforya-schema-markup' ), 'type' => 'tel' ), 'openingHours' => array( 'label' => __( 'Opening hours', 'aiforya-schema-markup' ), 'type' => 'string_array', 'description' => __( 'Format "Mo-Fr 09:00-18:00" — one per line.', 'aiforya-schema-markup' ) ), 'image' => $image_url, 'priceRange' => array( 'label' => __( 'Price range', 'aiforya-schema-markup' ), 'type' => 'text', 'description' => __( 'Format "$$" or "10€-30€".', 'aiforya-schema-markup' ) ), 'aggregateRating' => array( 'label' => __( 'Average rating', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'AggregateRating' ), 'geo' => array( 'label' => __( 'GPS coordinates', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'GeoCoordinates' ), 'url' => $url, 'description' => $desc, ), ), 'Course' => array( 'description' => __( 'Course / online training (education SERP panel).', 'aiforya-schema-markup' ), 'required' => array( 'name', 'description', 'provider' ), 'recommended' => array( 'image', 'aggregateRating', 'offers' ), 'optional' => array( 'inLanguage' ), 'fields' => array( 'name' => $name, 'description' => $desc, 'provider' => array( 'label' => __( 'Organization', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Organization' ), 'image' => $image_url, 'aggregateRating' => array( 'label' => __( 'Average rating', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'AggregateRating' ), 'offers' => array( 'label' => __( 'Pricing', 'aiforya-schema-markup' ), 'type' => 'nested_array', 'nested_type' => 'Offer', 'item_label' => __( 'Price', 'aiforya-schema-markup' ) ), 'inLanguage' => array( 'label' => __( 'Language (BCP 47)', 'aiforya-schema-markup' ), 'type' => 'text' ), ), ), 'JobPosting' => array( 'description' => __( 'Job posting (Google for Jobs).', 'aiforya-schema-markup' ), 'required' => array( 'title', 'description', 'datePosted', 'hiringOrganization', 'jobLocation' ), 'recommended' => array( 'baseSalary', 'employmentType', 'validThrough' ), 'optional' => array( 'experienceRequirements', 'industry' ), 'fields' => array( 'title' => array( 'label' => __( 'Job title', 'aiforya-schema-markup' ), 'type' => 'text' ), 'description' => $desc, 'datePosted' => array( 'label' => __( 'Publication date', 'aiforya-schema-markup' ), 'type' => 'date' ), 'hiringOrganization' => array( 'label' => __( 'Company', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Organization' ), 'jobLocation' => array( 'label' => __( 'Work location', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Place' ), 'baseSalary' => array( 'label' => __( 'Salary', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'MonetaryAmount' ), 'employmentType' => array( 'label' => __( 'Contract Type', 'aiforya-schema-markup' ), 'type' => 'select', 'options' => array( 'FULL_TIME' => __( 'Full-time (Permanent)', 'aiforya-schema-markup' ), 'PART_TIME' => __( 'Part-time', 'aiforya-schema-markup' ), 'CONTRACTOR' => __( 'Freelance', 'aiforya-schema-markup' ), 'TEMPORARY' => __( 'Fixed-term Contract', 'aiforya-schema-markup' ), 'INTERN' => __( 'Internship', 'aiforya-schema-markup' ), 'VOLUNTEER' => __( 'Volunteer', 'aiforya-schema-markup' ), 'PER_DIEM' => __( 'Temporary', 'aiforya-schema-markup' ), 'OTHER' => __( 'Other', 'aiforya-schema-markup' ), ) ), 'validThrough' => array( 'label' => __( 'Valid until', 'aiforya-schema-markup' ), 'type' => 'datetime' ), 'experienceRequirements' => array( 'label' => __( 'Required Experience', 'aiforya-schema-markup' ), 'type' => 'text' ), 'industry' => array( 'label' => __( 'Industry', 'aiforya-schema-markup' ), 'type' => 'text' ), ), ), 'Review' => array( 'description' => __( 'Independent review of a product/service (star ratings in SERP).', 'aiforya-schema-markup' ), 'required' => array( 'itemReviewed', 'reviewRating', 'author' ), 'recommended' => array( 'datePublished', 'reviewBody', 'publisher' ), 'optional' => array(), 'fields' => array( 'itemReviewed' => array( 'label' => __( 'Reviewed item (name)', 'aiforya-schema-markup' ), 'type' => 'text' ), 'reviewRating' => array( 'label' => __( 'Rating (1.0–5.0)', 'aiforya-schema-markup' ), 'type' => 'text' ), 'author' => array( 'label' => __( 'Review author', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Person' ), 'datePublished' => array( 'label' => __( 'Publication date', 'aiforya-schema-markup' ), 'type' => 'date' ), 'reviewBody' => array( 'label' => __( 'Review text', 'aiforya-schema-markup' ), 'type' => 'textarea' ), 'publisher' => array( 'label' => __( 'Platform', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Organization' ), ), ), 'BreadcrumbList' => array( 'description' => __( 'Breadcrumb trail (displayed in SERP below the title).', 'aiforya-schema-markup' ), 'required' => array( 'itemListElement' ), 'recommended' => array(), 'optional' => array(), 'fields' => array( 'itemListElement' => array( 'label' => __( 'Items', 'aiforya-schema-markup' ), 'type' => 'nested_array', 'nested_type' => 'ListItem', 'item_label' => __( 'Level', 'aiforya-schema-markup' ) ), ), ), 'Organization' => array( 'description' => __( 'Publishing entity (Knowledge Panel SERP + sameAs).', 'aiforya-schema-markup' ), 'required' => array( 'name', 'url' ), 'recommended' => array( 'logo', 'sameAs', 'contactPoint', 'address', 'description' ), 'optional' => array(), 'fields' => array( 'name' => $name, 'url' => $url, 'logo' => array( 'label' => __( 'Logo URL', 'aiforya-schema-markup' ), 'type' => 'url' ), 'sameAs' => array( 'label' => __( 'sameAs (social network URLs)', 'aiforya-schema-markup' ), 'type' => 'string_array', 'description' => __( 'One per line (LinkedIn, Twitter, Wikipedia, ...).', 'aiforya-schema-markup' ) ), 'contactPoint' => array( 'label' => __( 'Contact phone', 'aiforya-schema-markup' ), 'type' => 'tel' ), 'address' => array( 'label' => __( 'Address', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'PostalAddress' ), 'description' => $desc, ), ), 'Person' => array( 'description' => __( 'Personal profile (author, speaker, executive).', 'aiforya-schema-markup' ), 'required' => array( 'name' ), 'recommended' => array( 'image', 'jobTitle', 'worksFor', 'sameAs', 'description', 'url' ), 'optional' => array(), 'fields' => array( 'name' => $name, 'image' => $image_url, 'jobTitle' => array( 'label' => __( 'Job Title', 'aiforya-schema-markup' ), 'type' => 'text' ), 'worksFor' => array( 'label' => __( 'Employer', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'Organization' ), 'sameAs' => array( 'label' => __( 'sameAs (LinkedIn, Twitter, ...)', 'aiforya-schema-markup' ), 'type' => 'string_array' ), 'description' => $desc, 'url' => $url, ), ), 'WebPage' => array( 'description' => __( 'Generic page (catch-all if no more specific type).', 'aiforya-schema-markup' ), 'required' => array( 'name' ), 'recommended' => array( 'description', 'datePublished', 'image', 'breadcrumb' ), 'optional' => array( 'inLanguage' ), 'fields' => array( 'name' => $name, 'description' => $desc, 'datePublished' => $datePub, 'image' => $image_url, 'breadcrumb' => array( 'label' => __( 'Breadcrumb', 'aiforya-schema-markup' ), 'type' => 'nested_object', 'nested_type' => 'ListItem' ), 'inLanguage' => array( 'label' => __( 'Language (BCP 47)', 'aiforya-schema-markup' ), 'type' => 'text' ), ), ), ); } /** * Liste les types Schema.org disponibles dans le Builder (clé => description). */ public static function available_types(): array { $catalog = self::schema_catalog(); $out = array(); foreach ( $catalog as $type => $cfg ) { $out[ $type ] = (string) ( $cfg['description'] ?? $type ); } return $out; } // ------------------------------------------------------------------------- // Templates utilisateur (option aiforya_sm_user_templates). // Format stocké : array // ------------------------------------------------------------------------- public function get_templates(): array { $stored = get_option( 'aiforya_sm_user_templates', array() ); if ( ! is_array( $stored ) ) { return array(); } // Tri par created_at desc. usort( $stored, function ( $a, $b ) { return strcmp( (string) ( $b['created_at'] ?? '' ), (string) ( $a['created_at'] ?? '' ) ); } ); return $stored; } /** * ⛔ AJOUTÉ LE 13/08/2026 — GRIEF wordpress.org « Data Must be Sanitized, Escaped, and * Validated », courriel « Review in Progress: AIFORYA Schema Markup » du 13/08 15:24. * * Ce qu'ils ont relevé, mot pour mot : « Raw JSON-LD is only decoded and type-checked, then * stored as schema_json without sanitization in apply_to_post », sur * `class-aiforya-schema-markup-ajax.php` lignes 79 et 101. * * ILS AVAIENT RAISON, ET LE DÉFAUT ÉTAIT SUBTIL : le JSON était bien `json_decode` puis * contrôlé (`is_array`, `@type` non vide) — mais c'est la CHAÎNE BRUTE qui était stockée * ensuite, pas la structure validée. La validation portait donc sur une valeur, et * l'enregistrement sur une autre. Un contrôle qui ne porte pas sur ce qu'on garde ne * protège rien. * * LE REMÈDE : on assainit la structure décodée, puis on RÉ-ENCODE — ce qui est stocké est * donc du JSON produit par nous, jamais la chaîne de l'utilisateur. « Sanitize early ». * * ⚠ CE QU'ON N'A PAS TOUCHÉ, ET POURQUOI ÇA COMPTE. Ils demandent de chercher les autres * occurrences du même défaut. Balayage fait sur les 43 usages de superglobales du plugin : * · `rules_save_handler` (l. 123) prend aussi un JSON brut — mais `Rules::save_rule()` * assainit CHAQUE champ (liste blanche de types, `sanitize_text_field`, * `sanitize_conditions`) et ne stocke jamais la chaîne d'origine. Correct : intact. * · `settings_save` (l. 821-822) `wp_unslash` deux tableaux, puis `sanitize_key` / * `sanitize_text_field` chaque entrée dans la boucle qui suit. Correct : intact. * · la SORTIE est déjà échappée aux trois endroits (`wp_json_encode` du décodé côté * générateur, `wp_kses( $json, array() )` côté frontend). Correct : intact. * Corriger ce qui n'est pas cassé aurait fabriqué du bruit dans la revue et fait perdre du * temps au bénévole — et un créneau de revue est sériel, donc c'est le bien le plus rare. * * @param string $jsonld La chaîne brute reçue du navigateur. * @return array{data: array, json: string}|WP_Error */ private function sanitize_jsonld_payload( string $jsonld ) { $data = json_decode( $jsonld, true ); if ( ! is_array( $data ) ) { return new WP_Error( 'invalid_json', __( 'The JSON-LD is invalid.', 'aiforya-schema-markup' ) ); } $clean = self::sanitize_jsonld_node( $data ); if ( ! is_array( $clean ) ) { return new WP_Error( 'invalid_json', __( 'The JSON-LD is invalid.', 'aiforya-schema-markup' ) ); } $type = (string) ( $clean['@type'] ?? '' ); if ( $type === '' ) { return new WP_Error( 'missing_type', __( 'The @type is required in the JSON-LD.', 'aiforya-schema-markup' ) ); } $encoded = wp_json_encode( $clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); if ( ! is_string( $encoded ) ) { return new WP_Error( 'invalid_json', __( 'The JSON-LD is invalid.', 'aiforya-schema-markup' ) ); } return array( 'data' => $clean, 'json' => $encoded, 'type' => $type ); } /** * Assainit récursivement un nœud JSON-LD décodé. * * Les clés sont bornées à ce qu'un document JSON-LD peut légitimement porter : les mots-clés * `@context` / `@type` / `@id`, et les propriétés schema.org. Une clé vidée par le filtre est * ABANDONNÉE plutôt que conservée sous un nom tronqué — un nom à moitié nettoyé est un nom * qu'on ne reconnaît plus. * * Les chaînes qui ressemblent à une URL passent par `esc_url_raw` (schema.org en porte * beaucoup : `@id`, `url`, `image`, `sameAs`) ; les autres par `sanitize_textarea_field`, * qui retire les balises tout en gardant les sauts de ligne d'une `description`. * * @param mixed $value * @return mixed */ private static function sanitize_jsonld_node( $value, string $key_parent = '' ) { if ( is_array( $value ) ) { $out = array(); foreach ( $value as $k => $v ) { if ( is_int( $k ) ) { // Élément de liste : il hérite du nom de la propriété qui le contient, sinon // `sameAs: [ "…" ]` perdrait le fait que ses éléments sont des URL. $out[] = self::sanitize_jsonld_node( $v, $key_parent ); continue; } $k = (string) $k; /* ⛔ UNE CLÉ NON CONFORME EST ABANDONNÉE, ELLE N'EST PAS RÉÉCRITE — et c'est le * banc qui me l'a appris, pas ma relecture. Mon premier jet FILTRAIT les * caractères de la clé : `headline` devenait `headbline`, c'est-à-dire une * AUTRE propriété, créée en silence. Le docbloc trois lignes plus haut disait * pourtant « un nom à moitié nettoyé est un nom qu'on ne reconnaît plus » : le * commentaire avait raison et le code faisait l'inverse. * Un JSON-LD dont une propriété a été renommée est pire qu'un JSON-LD amputé : * le premier ment à Google, le second lui manque. */ if ( ! preg_match( '/\A[A-Za-z0-9@:_.\-]+\z/', $k ) ) { continue; } $out[ $k ] = self::sanitize_jsonld_node( $v, $k ); } return $out; } if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || null === $value ) { return $value; } $s = trim( (string) $value ); /* Les propriétés dont schema.org dit qu'elles PORTENT une URL passent toujours par * `esc_url_raw`, quel que soit ce qu'elles contiennent. Se fier au préfixe `http://` ne * suffit pas : une valeur `javascript:…` posée dans `logo` ne commence pas par `http`, * donc elle échappait au filtre et ressortait telle quelle — trouvé par le banc. * « Use the most appropriate function for the context », dit la revue : le contexte, * ici, c'est le NOM de la propriété, pas la forme de la valeur. */ $cles_url = array( 'url', '@id', 'logo', 'image', 'sameas', 'contenturl', 'thumbnailurl', 'embedurl', 'contentUrl', 'targeturl', 'significantlink', 'discussionurl' ); if ( in_array( strtolower( $key_parent ), $cles_url, true ) || preg_match( '#\A(?:https?:)?//#i', $s ) ) { return esc_url_raw( $s ); } return sanitize_textarea_field( $s ); } /** * Sauvegarde un template utilisateur. * * @return array { id, name, schema_type, jsonld, created_at } ou WP_Error. */ public function save_template( string $name, string $jsonld ) { $name = trim( wp_strip_all_tags( $name ) ); if ( $name === '' ) { return new WP_Error( 'empty_name', __( 'Template name is required.', 'aiforya-schema-markup' ) ); } $payload = $this->sanitize_jsonld_payload( $jsonld ); if ( is_wp_error( $payload ) ) { return $payload; } $type = $payload['type']; $tpl = array( 'id' => (int) round( microtime( true ) * 1000 ), 'name' => $name, 'schema_type' => $type, // ⭐ le JSON RÉ-ENCODÉ après assainissement, jamais la chaîne reçue du navigateur. 'jsonld' => $payload['json'], 'created_at' => current_time( 'mysql' ), ); $list = $this->get_templates(); // Limit 50 templates max pour éviter bloat option. if ( count( $list ) >= 50 ) { array_pop( $list ); } array_unshift( $list, $tpl ); update_option( 'aiforya_sm_user_templates', $list, false ); return $tpl; } public function delete_template( int $id ): bool { if ( $id <= 0 ) return false; $list = $this->get_templates(); $filtered = array_values( array_filter( $list, static function ( $t ) use ( $id ) { return ( (int) ( $t['id'] ?? 0 ) ) !== $id; } ) ); if ( count( $filtered ) === count( $list ) ) { return false; } update_option( 'aiforya_sm_user_templates', $filtered, false ); return true; } // ------------------------------------------------------------------------- // Apply to post : override le schema actif via Db::save_or_update_schema(). // ------------------------------------------------------------------------- /** * @return array { post_id, schema_id, message } | WP_Error */ public function apply_to_post( int $post_id, string $jsonld ) { $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( 'invalid_post', __( 'Post not found.', 'aiforya-schema-markup' ) ); } $payload = $this->sanitize_jsonld_payload( $jsonld ); if ( is_wp_error( $payload ) ) { return $payload; } $type = $payload['type']; $db = Aiforya_Schema_Markup_Db::get_instance(); $result = $db->save_or_update_schema( array( 'post_id' => $post_id, 'post_type' => (string) $post->post_type, 'schema_type' => $type, // ⭐ C'EST LA LIGNE QUE wordpress.org A CITÉE : elle enregistrait `$jsonld` brut. 'schema_json' => $payload['json'], 'source' => 'manual_builder', 'status' => 'active', ) ); if ( is_wp_error( $result ) ) { return $result; } return array( 'post_id' => $post_id, 'schema_id' => (int) $result, 'message' => sprintf( /* translators: %1$s : valeur insérée dynamiquement, %2$s : valeur insérée dynamiquement. */ __( 'Schema %1$s applied to "%2$s".', 'aiforya-schema-markup' ), $type, get_the_title( $post_id ) ), ); } /* * ⛔ RETIRÉ LE 13/08/2026, JUSTE AVANT SOUMISSION — une méthode déclarée ici renvoyait * une erreur d'édition réservée, pour des suggestions de gabarits par IA. * * MESURE : elle n'était appelée NULLE PART — aucun bouton, aucun AJAX, aucune porte * d'entrée dans tout le paquet. Du code mort, donc, et pas un bridage : rien dans * l'interface ne proposait une fonction qui aurait ensuite refusé. * * ⚠ MAIS LA LIGNE DIRECTRICE 5/6 VISE PRÉCISÉMENT « license/feature gating », et le * formulaire de soumission la met en gras. Un relecteur qui cherche ce mot l'aurait * trouvée et aurait posé la question — un aller-retour se compte en jours, le retrait * en minutes. On ne garde pas un mot qui ressemble à une infraction pour du code que * personne n'appelle. * * Ce commentaire ne recopie pas le libellé retiré : une annulation qui cite ce qu'elle * annule laisse la chaîne extractible par une recherche (leçon V746-A). */ /** * Collects the site's salient characteristics to steer the AI. * * @return array */ private function collect_site_stats(): array { $pt_counts = array(); $public_pts = array_values( array_filter( get_post_types( array( 'public' => true ), 'names' ) ) ); foreach ( $public_pts as $pt ) { $count = wp_count_posts( $pt ); $pt_counts[ $pt ] = (int) ( ( is_object( $count ) && isset( $count->publish ) ) ? $count->publish : 0 ); } arsort( $pt_counts ); $pt_counts = array_slice( $pt_counts, 0, 10, true ); $top_categories = array(); $cats = get_terms( array( 'taxonomy' => 'category', 'hide_empty' => true, 'number' => 10, 'orderby' => 'count', 'order' => 'DESC' ) ); if ( ! is_wp_error( $cats ) ) { foreach ( (array) $cats as $c ) { if ( is_object( $c ) ) $top_categories[] = (string) $c->name; } } $top_tags = array(); $tags = get_terms( array( 'taxonomy' => 'post_tag', 'hide_empty' => true, 'number' => 10, 'orderby' => 'count', 'order' => 'DESC' ) ); if ( ! is_wp_error( $tags ) ) { foreach ( (array) $tags as $t ) { if ( is_object( $t ) ) $top_tags[] = (string) $t->name; } } $sample_titles = array(); $samples = get_posts( array( 'post_type' => $public_pts, 'posts_per_page' => 8, 'orderby' => 'date', 'order' => 'DESC', 'post_status' => 'publish', ) ); foreach ( (array) $samples as $p ) { if ( is_object( $p ) ) { $sample_titles[] = '[' . (string) $p->post_type . '] ' . wp_strip_all_tags( (string) get_the_title( $p ) ); } } $detected = array(); if ( class_exists( 'WooCommerce' ) ) $detected[] = 'WooCommerce'; if ( class_exists( 'Easy_Digital_Downloads' ) ) $detected[] = 'Easy Digital Downloads'; if ( class_exists( 'Tribe__Events__Main' ) ) $detected[] = 'The Events Calendar'; if ( defined( 'LEARNDASH_VERSION' ) ) $detected[] = 'LearnDash'; if ( defined( 'TUTOR_VERSION' ) ) $detected[] = 'Tutor LMS'; if ( class_exists( 'MeprAppCtrl' ) ) $detected[] = 'MemberPress'; if ( class_exists( 'WPForms\\Loader' ) ) $detected[] = 'WPForms'; if ( defined( 'GIVE_VERSION' ) ) $detected[] = 'GiveWP'; return array( 'site_name' => (string) get_bloginfo( 'name' ), 'site_tagline' => (string) get_bloginfo( 'description' ), 'site_lang' => (string) get_bloginfo( 'language' ), 'post_types' => $pt_counts, 'top_categories' => $top_categories, 'top_tags' => $top_tags, 'sample_titles' => $sample_titles, 'detected_plugins' => $detected, ); } private function build_suggest_prompt( array $stats ): string { $available_types = implode( ', ', array_keys( self::available_types() ) ); $stats_json = (string) wp_json_encode( $stats, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); /* ⚠ CE PROMPT EST ENVOYÉ AU MODÈLE — sa langue décide de la langue des RÉPONSES. * Rédigé en français à l'origine, il faisait répondre en français chez un utilisateur * anglophone. Un remplacement PARTIEL l'avait laissé en franglais le 12/08 (« matching * the site profile (ex: beaucoup de recettes → Recipe) ») : sur un prompt, une phrase * à moitié traduite est pire que l'original, parce que le modèle suit la langue qu'il * voit en dernier. Réécrit en entier, exemples compris. */ $prompt = "You are a semantic SEO expert specialising in Schema.org. Analyse the characteristics of the WordPress site below and propose 3 to 5 pre-filled JSON-LD templates suited to its niche, ready to load into a visual builder.\n\n"; $prompt .= "## Site characteristics\n```json\n" . $stats_json . "\n```\n\n"; $prompt .= "## Supported Schema.org types (use ONLY these)\n" . $available_types . "\n\n"; $prompt .= "## Instructions\n"; $prompt .= "1. Pick 3 to 5 Schema.org types from the list above, **matching the site profile** (e.g. many recipes → Recipe ; WooCommerce → Product ; titles starting with \"how to\" → HowTo ; The Events Calendar → Event ; LearnDash/Tutor → Course).\n"; $prompt .= "2. For each suggestion, provide a JSON-LD object **pre-filled with sensible values** based on the real content (use the actual site name as publisher.name, representative titles from sample_titles as name/headline, real categories as keywords).\n"; $prompt .= "3. Include ALL fields required by Google Rich Results for each type, plus the most impactful recommended ones.\n"; $prompt .= "4. For nested objects (Person, Organization, ...), include @type explicitly.\n"; $prompt .= "5. Reply ONLY with valid JSON in the format below, **with no preamble and no markdown fence**:\n\n"; $prompt .= "{\n"; $prompt .= " \"proposals\": [\n"; $prompt .= " {\n"; $prompt .= " \"name\": \"Recipe template — blog recipes\",\n"; $prompt .= " \"description\": \"Recipe pre-filled with aggregateRating and nutrition, based on your Cooking categories.\",\n"; $prompt .= " \"jsonld\": { \"@context\": \"https://schema.org\", \"@type\": \"Recipe\", \"name\": \"Homemade margherita pizza\", ... }\n"; $prompt .= " }\n"; $prompt .= " ]\n"; $prompt .= "}\n"; return $prompt; } // ------------------------------------------------------------------------- // Search posts (autocomplete). // ------------------------------------------------------------------------- /** * @return array */ public function search_posts( string $query, int $limit = 20 ): array { $query = trim( $query ); $limit = max( 1, min( 50, $limit ) ); $args = array( 'post_type' => array_values( array_filter( get_post_types( array( 'public' => true ), 'names' ) ) ), 'post_status' => array( 'publish', 'private', 'draft' ), 'posts_per_page' => $limit, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ); if ( $query !== '' ) { $args['s'] = $query; } $posts = get_posts( $args ); $out = array(); foreach ( (array) $posts as $p ) { $out[] = array( 'id' => (int) $p->ID, 'title' => (string) get_the_title( $p ) ?: ( '#' . $p->ID ), 'post_type' => (string) $p->post_type, 'edit_url' => (string) get_edit_post_link( $p->ID, 'raw' ), ); } return $out; } }