'https://schema.org/InStock', 'outofstock' => 'https://schema.org/OutOfStock', 'onbackorder' => 'https://schema.org/BackOrder', ); /** * @return array */ public static function build( \WC_Product $product ): array { $url = (string) get_permalink( $product->get_id() ); $data = array( '@context' => 'https://schema.org/', '@type' => 'Product', '@id' => $url . '#cortexguard-product', 'url' => $url, 'name' => self::decode( $product->get_name() ), 'description' => self::plain( $product->get_description() ), ); $image = self::image_url( $product ); if ( '' !== $image ) { $data['image'] = $image; } $brand = self::brand( $product ); if ( '' !== $brand ) { $data['brand'] = array( '@type' => 'Brand', 'name' => $brand, ); } if ( '' !== $product->get_sku() ) { $data['sku'] = $product->get_sku(); } // @phpstan-ignore-next-line function.alreadyNarrowedType -- the WooCommerce stubs PHPStan scans always declare this (9.2+), but the runtime store may still be older; guard stays for real-world compatibility. $gtin = method_exists( $product, 'get_global_unique_id' ) ? (string) $product->get_global_unique_id() : ''; if ( '' !== $gtin ) { $data['gtin'] = $gtin; } $offers = self::offers( $product ); if ( array() !== $offers ) { $data['offers'] = $offers; } return $data; } /** * Tags, shortcodes and entities out; whitespace collapsed (Shopify: strip_html | strip_newlines). */ public static function plain( string $html ): string { $text = wp_strip_all_tags( strip_shortcodes( $html ) ); $text = self::decode( $text ); return trim( (string) preg_replace( '/\s+/u', ' ', $text ) ); } /** * WordPress/WooCommerce store term names and (for users without unfiltered_html) post titles with * entities normalised in (e.g. "Marks & Spencer" -> "Marks & Spencer"). JSON-LD is not HTML, so * every plain-text field must come back out decoded to match the Shopify extension's raw values. */ private static function decode( string $text ): string { return html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } public static function image_url( \WC_Product $product ): string { $image_id = (int) $product->get_image_id(); if ( 0 === $image_id ) { return ''; } $url = wp_get_attachment_image_url( $image_id, 'full' ); return is_string( $url ) ? $url : ''; } private static function brand( \WC_Product $product ): string { if ( ! taxonomy_exists( 'product_brand' ) ) { return ''; } $terms = get_the_terms( $product->get_id(), 'product_brand' ); return is_array( $terms ) && isset( $terms[0] ) ? self::decode( $terms[0]->name ) : ''; } /** * @return array> */ private static function offers( \WC_Product $product ): array { $sources = $product instanceof \WC_Product_Variable ? array_map( 'wc_get_product', $product->get_children() ) : array( $product ); $offers = array(); foreach ( $sources as $source ) { if ( ! $source instanceof \WC_Product || '' === (string) $source->get_price() ) { continue; } if ( $source instanceof \WC_Product_Variation && 'publish' !== $source->get_status() ) { continue; } $offers[] = self::offer( $source ); } return $offers; } /** * @return array */ private static function offer( \WC_Product $product ): array { $offer = array( '@type' => 'Offer', // wc_format_decimal() with a precision always uses '.', whatever the store's number format. 'price' => wc_format_decimal( wc_get_price_to_display( $product ), wc_get_price_decimals() ), 'priceCurrency' => get_woocommerce_currency(), 'availability' => self::AVAILABILITY[ $product->get_stock_status() ] ?? self::AVAILABILITY['instock'], 'url' => (string) $product->get_permalink(), ); if ( '' !== $product->get_sku() ) { $offer['sku'] = $product->get_sku(); } return $offer; } }