'https://schema.org/InStock', 'outofstock' => 'https://schema.org/OutOfStock', 'onbackorder' => 'https://schema.org/BackOrder', ); /** * Generate Product schema for a WooCommerce product. * * @param \WC_Product $product The WooCommerce product. * * @return array Schema.org Product data. */ public function generate( \WC_Product $product ): array { $schema = array( '@context' => 'https://schema.org', '@type' => 'Product', 'name' => $product->get_name(), 'description' => $this->get_description( $product ), 'url' => get_permalink( $product->get_id() ), 'sku' => $product->get_sku(), ); // Product images. $images = $this->get_images( $product ); if ( ! empty( $images ) ) { $schema['image'] = $images; } // GTIN / MPN / Brand from product meta. $this->add_identifiers( $schema, $product ); // Offers (single or aggregate for variable products). $offers = $this->build_offers( $product ); if ( ! empty( $offers ) ) { $schema['offers'] = $offers; } // AggregateRating. $rating = $this->build_aggregate_rating( $product ); if ( ! empty( $rating ) ) { $schema['aggregateRating'] = $rating; } // Remove empty values. $schema = array_filter( $schema, static function ( $value ): bool { return '' !== $value && null !== $value; } ); /** * Filters the Product schema before it is added to the page. * * @param array $schema The schema.org Product data. * @param \WC_Product $product The WooCommerce product. */ return apply_filters( 'aicommerce_product_schema', $schema, $product ); } /** * Get the product description, preferring full description over short. * * @param \WC_Product $product The product. * * @return string */ private function get_description( \WC_Product $product ): string { $description = $product->get_description(); if ( empty( $description ) ) { $description = $product->get_short_description(); } // Strip HTML tags for schema — plain text only. return wp_strip_all_tags( $description ); } /** * Get product image URLs. * * @param \WC_Product $product The product. * * @return array Image URLs. */ private function get_images( \WC_Product $product ): array { $images = array(); // Main product image. $image_id = $product->get_image_id(); if ( $image_id ) { $url = wp_get_attachment_url( $image_id ); if ( $url ) { $images[] = $url; } } // Gallery images. $gallery_ids = $product->get_gallery_image_ids(); foreach ( $gallery_ids as $gallery_id ) { $url = wp_get_attachment_url( $gallery_id ); if ( $url ) { $images[] = $url; } } return $images; } /** * Add GTIN, MPN, and Brand identifiers from product meta. * * @param array $schema Schema array (passed by reference). * @param \WC_Product $product The product. * * @return void */ private function add_identifiers( array &$schema, \WC_Product $product ): void { // GTIN: check AICommerce meta, then WC native, then raw meta. $gtin = $product->get_meta( '_aicommerce_gtin' ); if ( empty( $gtin ) && method_exists( $product, 'get_global_unique_id' ) ) { $gtin = $product->get_global_unique_id(); } if ( empty( $gtin ) ) { $gtin = $product->get_meta( '_global_unique_id' ); } if ( ! empty( $gtin ) ) { $schema['gtin'] = $gtin; } // MPN: check AICommerce meta, then common meta. $mpn = $product->get_meta( '_aicommerce_mpn' ); if ( empty( $mpn ) ) { $mpn = $product->get_meta( '_mpn' ); } if ( ! empty( $mpn ) ) { $schema['mpn'] = $mpn; } // Brand: check AICommerce meta, WC meta, brand taxonomy, then default setting. $brand = $product->get_meta( '_aicommerce_brand' ); if ( empty( $brand ) ) { $brand = $product->get_meta( '_wc_brand' ); } if ( empty( $brand ) && taxonomy_exists( 'product_brand' ) ) { $brand_terms = get_the_terms( $product->get_id(), 'product_brand' ); if ( is_array( $brand_terms ) && ! empty( $brand_terms ) ) { $brand = $brand_terms[0]->name; } } if ( empty( $brand ) ) { $brand = get_option( 'aicommerce_default_brand', '' ); } if ( ! empty( $brand ) ) { $schema['brand'] = array( '@type' => 'Brand', 'name' => $brand, ); } } /** * Build Offer or AggregateOffer schema. * * Variable products produce an AggregateOffer with lowPrice/highPrice. * Simple/grouped/external products produce a single Offer. * * @param \WC_Product $product The product. * * @return array Offer schema data. */ public function build_offers( \WC_Product $product ): array { if ( $product->is_type( 'variable' ) ) { return $this->build_aggregate_offer( $product ); } return $this->build_single_offer( $product ); } /** * Build a single Offer schema. * * @param \WC_Product $product The product. * * @return array */ private function build_single_offer( \WC_Product $product ): array { $price = $product->get_price(); if ( '' === $price ) { return array(); } $offer = array( '@type' => 'Offer', 'url' => get_permalink( $product->get_id() ), 'price' => $price, 'priceCurrency' => get_woocommerce_currency(), 'availability' => $this->get_availability( $product ), ); // Price valid until — sale end date or 1 year from now. $sale_end = $product->get_date_on_sale_to(); if ( $sale_end ) { $offer['priceValidUntil'] = $sale_end->date( 'Y-m-d' ); } else { $offer['priceValidUntil'] = gmdate( 'Y-m-d', strtotime( '+1 year' ) ); } // Seller (the store itself). $offer['seller'] = array( '@type' => 'Organization', 'name' => get_bloginfo( 'name' ), ); /** * Filters the individual Offer schema. * * @param array $offer The Offer data. * @param \WC_Product $product The WooCommerce product. */ return apply_filters( 'aicommerce_offer_schema', $offer, $product ); } /** * Build an AggregateOffer schema for variable products. * * @param \WC_Product $product The variable product. * * @return array */ private function build_aggregate_offer( \WC_Product $product ): array { /** @var \WC_Product_Variable $product */ $prices = $product->get_variation_prices( true ); if ( empty( $prices['price'] ) ) { return array(); } $min_price = min( $prices['price'] ); $max_price = max( $prices['price'] ); $offer = array( '@type' => 'AggregateOffer', 'lowPrice' => $min_price, 'highPrice' => $max_price, 'priceCurrency' => get_woocommerce_currency(), 'offerCount' => count( $prices['price'] ), 'availability' => $this->get_availability( $product ), ); /** * Filters the AggregateOffer schema for variable products. * * @param array $offer The AggregateOffer data. * @param \WC_Product $product The variable product. */ return apply_filters( 'aicommerce_aggregate_offer_schema', $offer, $product ); } /** * Build AggregateRating schema from WooCommerce reviews. * * @param \WC_Product $product The product. * * @return array AggregateRating schema or empty array. */ public function build_aggregate_rating( \WC_Product $product ): array { $rating_count = $product->get_rating_count(); $review_count = $product->get_review_count(); $avg_rating = (float) $product->get_average_rating(); // Only include if there are actual reviews. if ( $rating_count < 1 || $avg_rating <= 0 ) { return array(); } $rating = array( '@type' => 'AggregateRating', 'ratingValue' => number_format( $avg_rating, 1 ), 'reviewCount' => $review_count, 'ratingCount' => $rating_count, 'bestRating' => '5', 'worstRating' => '1', ); /** * Filters the AggregateRating schema. * * Third-party review plugins (Judge.me, Yotpo) can hook here to * provide their own review counts and averages. * * @param array $rating The AggregateRating data. * @param \WC_Product $product The WooCommerce product. */ return apply_filters( 'aicommerce_aggregate_rating_schema', $rating, $product ); } /** * Map WooCommerce stock status to schema.org availability. * * @param \WC_Product $product The product. * * @return string Schema.org availability URL. */ private function get_availability( \WC_Product $product ): string { $status = $product->get_stock_status(); return self::AVAILABILITY_MAP[ $status ] ?? 'https://schema.org/InStock'; } }