• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Yoast / wpseo-woocommerce / e461cd0639ed27e4f03947e76d5a18250ab665c2

16 Apr 2025 01:18PM UTC coverage: 51.845% (-3.3%) from 55.168%
e461cd0639ed27e4f03947e76d5a18250ab665c2

Pull #1053

github

web-flow
Merge 2570a097a into d6b2c99fb
Pull Request #1053: Drop compatibility with PHP 7.2 and 7.3

843 of 1626 relevant lines covered (51.85%)

4.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.71
/classes/woocommerce-schema.php
1
<?php
2
/**
3
 * WooCommerce Yoast SEO plugin file.
4
 *
5
 * @package WPSEO/WooCommerce
6
 */
7

8
use Yoast\WP\SEO\Config\Schema_IDs;
9

10
/**
11
 * Class WPSEO_WooCommerce_Schema.
12
 */
13
class WPSEO_WooCommerce_Schema {
14

15
        /**
16
         * The schema data we're going to output.
17
         *
18
         * @var array<string, string|int|array<string, string|int>>
19
         */
20
        protected $data;
21

22
        /**
23
         * WooCommerce version number.
24
         *
25
         * @var string
26
         */
27
        protected $wc_version;
28

29
        /**
30
         * The list of product variation images.
31
         *
32
         * @var array <string,string>
33
         */
34
        private $variation_images;
35

36
        /**
37
         * The list of product attributes that are allowed in the schema.
38
         *
39
         * @var array <string>
40
         */
41
        private $allowed_product_attributes = [
42
                'color',
43
                'pattern',
44
                'material',
45
        ];
46

47
        /**
48
         * WPSEO_WooCommerce_Schema constructor.
49
         *
50
         * @param string $wc_version The WooCommerce version.
51
         */
52
        public function __construct( $wc_version = WC_VERSION ) {
4✔
53
                $this->wc_version       = $wc_version;
4✔
54
                $this->variation_images = [];
4✔
55

56
                /**
57
                 * Filter: 'wpseo_allowed_product_attributes' - Allow changing the allowed product attributes.
58
                 *
59
                 * @param array<string> $allowed_product_attributes The default product attributes allowed.
60
                 */
61
                $this->allowed_product_attributes = apply_filters( 'wpseo_allowed_product_attributes', $this->allowed_product_attributes );
4✔
62

63
                // Filters & actions below in order of execution.
64
                add_filter( 'wpseo_frontend_presenters', [ $this, 'remove_unneeded_presenters' ] );
4✔
65
                add_filter( 'wpseo_schema_webpage', [ $this, 'filter_webpage' ], 10, 1 );
4✔
66
                add_filter( 'wpseo_schema_organization', [ $this, 'filter_organization' ], 10, 1 );
4✔
67
                add_filter( 'woocommerce_structured_data_product', [ $this, 'change_product' ], 10, 2 );
4✔
68
                add_filter( 'woocommerce_structured_data_type_for_page', [ $this, 'remove_woo_breadcrumbs' ] );
4✔
69

70
                // Only needed for WooCommerce versions before 3.8.1.
71
                if ( version_compare( $this->get_wc_version(), '3.8.1' ) < 0 ) {
4✔
72
                        add_filter( 'woocommerce_structured_data_review', [ $this, 'change_reviewed_entity' ] );
2✔
73
                }
74

75
                add_action( 'wp_footer', [ $this, 'output_schema_footer' ] );
4✔
76
        }
77

78
        /**
79
         * Get the WooCommerce version.
80
         *
81
         * @return string The WooCommerce version.
82
         */
83
        public function get_wc_version() {
×
84
                return $this->wc_version;
×
85
        }
86

87
        /**
88
         * If this is a product page, remove some of the presenters so we don't output them.
89
         *
90
         * @param array<string> $presenters Array of presenters.
91
         *
92
         * @return array<string> Array of presenters.
93
         */
94
        public function remove_unneeded_presenters( $presenters ) {
4✔
95
                if ( is_product() ) {
4✔
96
                        foreach ( $presenters as $key => $object ) {
2✔
97
                                if (
98
                                        is_a( $object, 'Yoast\WP\SEO\Presenters\Open_Graph\Article_Publisher_Presenter' )
2✔
99
                                        || is_a( $object, 'Yoast\WP\SEO\Presenters\Open_Graph\Article_Author_Presenter' )
2✔
100
                                ) {
101
                                        unset( $presenters[ $key ] );
2✔
102
                                }
103
                        }
104
                }
105

106
                return $presenters;
4✔
107
        }
108

109
        /**
110
         * Should the yoast schema output be used.
111
         *
112
         * @return bool Whether the Yoast SEO schema should be output.
113
         */
114
        public static function should_output_yoast_schema() {
2✔
115
                // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Using WPSEO hook.
116
                return apply_filters( 'wpseo_json_ld_output', true );
2✔
117
        }
118

119
        /**
120
         * Outputs the Woo Schema blob in the footer.
121
         *
122
         * @return bool False when there's nothing to output, true when we did output something.
123
         */
124
        public function output_schema_footer() {
2✔
125
                if ( ! is_array( $this->data ) || $this->data === [] ) {
2✔
126
                        return false;
2✔
127
                }
128

129
                // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- We need to output HTML. If we escape this we break it.
130
                echo new WPSEO_WooCommerce_Schema_Presenter(
2✔
131
                        [ $this->data ],
2✔
132
                        [
2✔
133
                                'yoast-schema-graph',
2✔
134
                                'yoast-schema-graph--woo',
2✔
135
                                'yoast-schema-graph--footer',
2✔
136
                        ]
2✔
137
                );
2✔
138

139
                // phpcs:enable
140

141
                return true;
2✔
142
        }
143

144
        /**
145
         * Changes the WebPage output to point to Product as the main entity.
146
         *
147
         * @param array<string, string|array<string>> $webpage_data Product Schema data.
148
         *
149
         * @return array<string, string|array<string>> Product Schema data.
150
         */
151
        public function filter_webpage( $webpage_data ) {
2✔
152
                if ( is_product() ) {
2✔
153
                        // We force the page type to be WebPage and ItemPage.
154
                        $webpage_data['@type'] = [ 'WebPage', 'ItemPage' ];
2✔
155
                        // We normally add a `ReadAction` on pages, we're replacing with a `BuyAction` on product pages.
156
                        $webpage_data['potentialAction'] = [
2✔
157
                                '@type'  => 'BuyAction',
2✔
158
                                'target' => YoastSEO()->meta->for_current_page()->canonical,
2✔
159
                        ];
2✔
160
                        unset( $webpage_data['datePublished'], $webpage_data['dateModified'] );
2✔
161
                }
162
                if ( is_checkout() || is_checkout_pay_page() ) {
2✔
163
                        $webpage_data['@type'] = 'CheckoutPage';
2✔
164
                        // We normally add a `ReadAction` on pages, adding that on a checkout makes no sense.
165
                        unset( $webpage_data['potentialAction'] );
2✔
166
                }
167

168
                return $webpage_data;
2✔
169
        }
170

171
        /**
172
         * Changes the Organization output to add a return policy if its available.
173
         *
174
         * @param array<string, string|array<string>> $organization_data Organization schema data.
175
         *
176
         * @return array<string, string|array<string>> Organization Schema data.
177
         */
178
        public function filter_organization( $organization_data ) {
×
179
                $schema_return_policy_id = WPSEO_Options::get( 'woo_schema_return_policy' );
×
180
                if ( ! empty( $schema_return_policy_id ) ) {
×
181
                        $url = get_permalink( $schema_return_policy_id );
×
182
                        if ( $url ) {
×
183
                                $organization_data['hasMerchantReturnPolicy'] = [
×
184
                                        '@type'              => 'MerchantReturnPolicy',
×
185
                                        'merchantReturnLink' => esc_url( $url ),
×
186
                                ];
×
187
                        }
188
                }
189

190
                return $organization_data;
×
191
        }
192

193
        /**
194
         * Changes the Review output to point to Product as the reviewed Item.
195
         *
196
         * @param array<string|array<string>> $data Review Schema data.
197
         *
198
         * @return array<string|array<string>> Review Schema data.
199
         */
200
        public function change_reviewed_entity( $data ) {
2✔
201
                unset( $data['@type'] );
2✔
202
                unset( $data['itemReviewed'] );
2✔
203

204
                $this->data['review'][] = $data;
2✔
205

206
                /**
207
                 * Filter: 'wpseo_schema_review' - Allow changing the Review type.
208
                 *
209
                 * @param array $data The Schema Review data.
210
                 */
211
                $this->data = apply_filters( 'wpseo_schema_review', $this->data );
2✔
212

213
                return [];
2✔
214
        }
215

216
        /**
217
         * Filter Schema Product data to work.
218
         *
219
         * @param array<string, string|int|array<string, string|int>> $data    Schema Product data.
220
         * @param WC_Product                                          $product Product object.
221
         *
222
         * @return array<string, string|int|array<string, string|int>> Schema Product data.
223
         */
224
        public function change_product( $data, $product ) {
22✔
225
                $data = $this->change_seller_in_offers( $data );
22✔
226
                $data = $this->filter_reviews( $data, $product );
22✔
227
                $data = $this->filter_sku( $data, $product );
22✔
228

229
                if ( $product instanceof WC_Product_Variable ) {
22✔
230
                        $data = $this->filter_variations( $data, $product );
×
231
                }
232
                else {
233
                        $data = $this->filter_offers( $data, $product );
22✔
234
                }
235

236
                // This product is the main entity of this page, so we set it as such.
237
                $data['mainEntityOfPage'] = [
22✔
238
                        '@id' => YoastSEO()->meta->for_current_page()->main_schema_id,
22✔
239
                ];
22✔
240

241
                // Now let's add this data to our overall output.
242
                $this->data = $data;
22✔
243

244
                $this->add_image();
22✔
245
                $this->add_variation_images();
22✔
246
                $this->add_brand( $product );
22✔
247
                $this->add_manufacturer( $product );
22✔
248
                $this->maybe_add_product_attributes( $product );
22✔
249
                $this->add_global_identifier( $product );
22✔
250

251
                /**
252
                 * Filter: 'wpseo_schema_product' - Allow changing the Product type.
253
                 *
254
                 * @param array $data The Schema Product data.
255
                 */
256
                $this->data = apply_filters( 'wpseo_schema_product', $this->data );
22✔
257

258
                return [];
22✔
259
        }
260

261
        /**
262
         * Filters the offers array to enrich it.
263
         *
264
         * @param array<string, string|int|array<string, string|int>> $data    Schema Product data.
265
         * @param WC_Product                                          $product The product.
266
         *
267
         * @return array<string, string|int|array<string, string|int>> Schema Product data.
268
         */
269
        protected function filter_offers( $data, $product ) {
16✔
270
                if ( ! isset( $data['offers'] ) || $data['offers'] === [] ) {
16✔
271
                        return $data;
×
272
                }
273

274
                $data['offers'] = $this->filter_sales( $data['offers'], $product );
16✔
275

276
                foreach ( $data['offers'] as $key => $offer ) {
16✔
277

278
                        // Add an @id to the offer.
279
                        $data['offers'][ $key ]['@id'] = YoastSEO()->meta->for_current_page()->site_url . '#/schema/offer/' . $product->get_id() . '-' . $key;
16✔
280

281
                        // WooCommerce 9.5.0 introduced the usage of UnitPriceSpecification for offers.
282
                        if ( version_compare( $this->get_wc_version(), '9.5.0' ) < 0 ) {
16✔
283
                                $price = WPSEO_WooCommerce_Utils::get_product_display_price( $product );
12✔
284
                                $this->add_price_specifications( $data, $key, $price );
12✔
285
                        }
286
                        else {
287
                                $this->add_unit_price_specifications( $data, $key, $offer['priceSpecification'], $product );
4✔
288
                        }
289

290
                        $data['offers'][ $key ]['seller'] = [ '@id' => YoastSEO()->meta->for_current_page()->site_url . '#organization' ];
16✔
291

292
                        // Remove price property from Schema output by WooCommerce.
293
                        if ( isset( $data['offers'][ $key ]['price'] ) ) {
16✔
294
                                unset( $data['offers'][ $key ]['price'] );
4✔
295
                        }
296
                        // Remove priceCurrency property from Schema output by WooCommerce.
297
                        if ( isset( $data['offers'][ $key ]['priceCurrency'] ) ) {
16✔
298
                                unset( $data['offers'][ $key ]['priceCurrency'] );
×
299
                        }
300

301
                        // Alter availability when product is "on backorder".
302
                        if ( $product->is_on_backorder() ) {
16✔
303
                                $data['offers'][ $key ]['availability'] = 'https://schema.org/PreOrder';
2✔
304
                        }
305
                }
306

307
                // We don't want an array with keys, we just need the offers.
308
                $data['offers'] = array_values( $data['offers'] );
16✔
309

310
                return $data;
16✔
311
        }
312

313
        /**
314
         * Filters the offers array to wrap and enrich it.
315
         *
316
         * @param array<string, string|int|array<string, string|int>> $data    Schema Product data.
317
         * @param WC_Product                                          $product The product.
318
         *
319
         * @return array<string, string|int|array<string, string|int>> Schema Product data.
320
         */
321
        protected function filter_variations( $data, $product ) {
2✔
322
                if ( ! isset( $data['offers'] ) || $data['offers'] === [] ) {
2✔
323
                        return $data;
×
324
                }
325

326
                $data['@type'] = 'ProductGroup';
2✔
327
                if ( isset( $data['sku'] ) ) {
2✔
328
                        $data['productGroupID'] = $data['sku'];
2✔
329
                }
330
                $data['hasVariant'] = [];
2✔
331
                unset( $data['offers'] );
2✔
332

333
                $product_variations = $product->get_available_variations( 'object' );
2✔
334
                foreach ( $product_variations as $key => $variation ) {
2✔
335
                        $variant_schema = $this->add_individual_product_variation( $product, $variation, $key );
2✔
336
                        if ( isset( $variant_schema['image'] ) ) {
2✔
337
                                $this->variation_images[] = [ '@id' => $variant_schema['image']['@id'] ];
2✔
338
                        }
339
                        $data['hasVariant'][] = $variant_schema;
2✔
340
                }
341

342
                // We don't want an array with keys, we just need the offers.
343
                $data['hasVariant'] = array_values( $data['hasVariant'] );
2✔
344

345
                return $data;
2✔
346
        }
347

348
        /**
349
         * Filters the offers array on sales, possibly unset them.
350
         *
351
         * @param array<string, string|int|array<string, string|int>> $offers  Schema Offer data.
352
         * @param WC_Product                                          $product The product.
353
         *
354
         * @return array<string, string|int|array<string, string|int>> Schema Offer data.
355
         */
356
        protected function filter_sales( $offers, $product ) {
8✔
357
                foreach ( $offers as $key => $offer ) {
8✔
358
                        /*
359
                         * WooCommerce assumes all prices will be valid until the end of next year,
360
                         * unless on sale and there is an end date. We keep the `priceValidUntil`
361
                         * property only for products with a sale price and a sale end date.
362
                         */
363

364
                        if ( ! $product->is_on_sale() || ! $product->get_date_on_sale_to() ) {
8✔
365
                                unset( $offers[ $key ]['priceValidUntil'] );
4✔
366
                        }
367
                }
368

369
                return $offers;
8✔
370
        }
371

372
        /**
373
         * Removes the SKU when it's empty to prevent the WooCommerce fallback to the product's ID.
374
         *
375
         * @param array<string, string|int|array<string, string|int>> $data    Schema Product data.
376
         * @param WC_Product                                          $product The product.
377
         *
378
         * @return array<string, string|int|array<string, string|int>> Schema Product data.
379
         */
380
        protected function filter_sku( $data, $product ) {
10✔
381
                /*
382
                 * When the SKU of a product is left empty, WooCommerce makes it the value of the product's id.
383
                 * In this method we check for that and unset it if done so.
384
                 */
385
                if ( empty( $product->get_sku() ) ) {
10✔
386
                        unset( $data['sku'] );
2✔
387
                }
388

389
                return $data;
10✔
390
        }
391

392
        /**
393
         * Removes the Woo Breadcrumbs from their Schema output.
394
         *
395
         * @param array<string> $types Types of Schema Woo will render.
396
         *
397
         * @return array<string> Types of Schema Woo will render.
398
         */
399
        public function remove_woo_breadcrumbs( $types ) {
2✔
400
                foreach ( $types as $key => $type ) {
2✔
401
                        if ( $type === 'breadcrumblist' ) {
2✔
402
                                unset( $types[ $key ] );
2✔
403
                        }
404
                }
405

406
                return $types;
2✔
407
        }
408

409
        /**
410
         * Retrieve the global identifier type and value if we have one.
411
         *
412
         * @param WC_Product $product Product object.
413
         *
414
         * @return bool True on success, false on failure.
415
         */
416
        protected function add_global_identifier( $product ) {
14✔
417
                $product_id               = $product->get_id();
14✔
418
                $global_identifier_values = get_post_meta( $product_id, 'wpseo_global_identifier_values', true );
14✔
419

420
                if ( ! is_array( $global_identifier_values ) || $global_identifier_values === [] ) {
14✔
421
                        return false;
10✔
422
                }
423

424
                foreach ( $global_identifier_values as $type => $value ) {
4✔
425
                        if ( empty( $value ) ) {
4✔
426
                                continue;
2✔
427
                        }
428
                        $this->data[ $type ] = $value;
4✔
429
                        if ( $type === 'isbn' ) {
4✔
430
                                if ( ! isset( $this->data['@type'] ) ) {
2✔
431
                                        $this->data['@type'] = 'Product';
2✔
432
                                }
433
                                if ( ! is_array( $this->data['@type'] ) ) {
2✔
434
                                        $this->data['@type'] = [ $this->data['@type'] ];
2✔
435
                                }
436
                                $this->data['@type'] = array_merge( [ 'Book' ], $this->data['@type'] );
2✔
437
                        }
438
                }
439

440
                return true;
4✔
441
        }
442

443
        /**
444
         * Update the seller attribute to reference the Organization, when it is set.
445
         *
446
         * @param array<string, string|int|array<string, string|int>> $data Schema Product data.
447
         *
448
         * @return array<string, string|int|array<string, string|int>> Schema Product data.
449
         */
450
        protected function change_seller_in_offers( $data ) {
12✔
451
                $company_or_person = WPSEO_Options::get( 'company_or_person', false );
12✔
452
                $company_name      = WPSEO_Options::get( 'company_name' );
12✔
453

454
                if ( $company_or_person !== 'company' || empty( $company_name ) ) {
12✔
455
                        return $data;
2✔
456
                }
457

458
                if ( ! empty( $data['offers'] ) ) {
10✔
459
                        foreach ( $data['offers'] as $key => $offer ) {
10✔
460
                                $data['offers'][ $key ]['seller'] = [
10✔
461
                                        '@id' => trailingslashit( YoastSEO()->meta->for_current_page()->site_url ) . Schema_IDs::ORGANIZATION_HASH,
10✔
462
                                ];
10✔
463
                        }
464
                }
465

466
                return $data;
10✔
467
        }
468

469
        /**
470
         * Add brand to our output.
471
         *
472
         * @param WC_Product $product Product object.
473
         *
474
         * @return void
475
         */
476
        private function add_brand( $product ) {
22✔
477
                $schema_brand = WPSEO_Options::get( 'woo_schema_brand' );
22✔
478
                if ( ! empty( $schema_brand ) ) {
22✔
479
                        $this->add_attribute_as( 'brand', $product, $schema_brand, 'Brand' );
22✔
480
                }
481
        }
482

483
        /**
484
         * Add manufacturer to our output.
485
         *
486
         * @param WC_Product $product Product object.
487
         *
488
         * @return void
489
         */
490
        private function add_manufacturer( $product ) {
22✔
491
                $schema_manufacturer = WPSEO_Options::get( 'woo_schema_manufacturer' );
22✔
492
                if ( ! empty( $schema_manufacturer ) ) {
22✔
493
                        $this->add_attribute_as( 'manufacturer', $product, $schema_manufacturer );
22✔
494
                }
495
        }
496

497
        /**
498
         * Adds an attribute to our Product data array with the value from a taxonomy, as an Organization,
499
         *
500
         * @param string     $attribute The attribute we're adding to Product.
501
         * @param WC_Product $product   The WooCommerce product we're working with.
502
         * @param string     $taxonomy  The taxonomy to get the attribute's value from.
503
         * @param string     $type      The Schema type to use.
504
         *
505
         * @return void
506
         */
507
        private function add_attribute_as( $attribute, $product, $taxonomy, $type = 'Organization' ) {
8✔
508
                $term = $this->get_primary_term_or_first_term( $taxonomy, $product->get_id() );
8✔
509

510
                if ( $term !== null ) {
8✔
511
                        $this->data[ $attribute ] = [
8✔
512
                                '@type' => $type,
8✔
513
                                'name'  => wp_strip_all_tags( $term->name ),
8✔
514
                        ];
8✔
515
                }
516
        }
517

518
        /**
519
         * Adds image schema.
520
         *
521
         * @return void
522
         */
523
        private function add_image() {
22✔
524
                /**
525
                 * WooCommerce will set the image to false if none is available. This is incorrect schema and we should fix it
526
                 * for our users for now.
527
                 *
528
                 * See https://github.com/woocommerce/woocommerce/issues/24188.
529
                 */
530
                if ( isset( $this->data['image'] ) && $this->data['image'] === false ) {
22✔
531
                        unset( $this->data['image'] );
22✔
532
                }
533

534
                if ( has_post_thumbnail() ) {
22✔
535
                        $this->data['image'] = [
20✔
536
                                '@id' => YoastSEO()->meta->for_current_page()->canonical . Schema_IDs::PRIMARY_IMAGE_HASH,
20✔
537
                        ];
20✔
538

539
                        return;
20✔
540
                }
541

542
                // Fallback to WooCommerce placeholder image.
543
                if ( function_exists( 'wc_placeholder_img_src' ) ) {
2✔
544
                        $image_schema_id     = YoastSEO()->meta->for_current_page()->canonical . '#woocommerceimageplaceholder';
2✔
545
                        $placeholder_img_src = wc_placeholder_img_src();
2✔
546
                        $this->data['image'] = YoastSEO()->helpers->schema->image->generate_from_url( $image_schema_id, $placeholder_img_src, '', false, false );
2✔
547
                }
548
        }
549

550
        /**
551
         * Adds image schema for product variations to main node.
552
         *
553
         * @return void
554
         */
555
        private function add_variation_images() {
8✔
556
                $image_list = [];
8✔
557
                if ( is_array( $this->variation_images ) && count( $this->variation_images ) !== 0 ) {
8✔
558
                        $image_list[] = $this->data['image'];
×
559
                        foreach ( $this->variation_images as $image ) {
×
560
                                $image_list[] = $image;
×
561
                        }
562
                        $this->data['image'] = $image_list;
×
563
                }
564
        }
565

566
        /**
567
         * Adds the product attributes to the Schema output.
568
         *
569
         * @param WC_Product $product The product object.
570
         *
571
         * @return void
572
         */
573
        private function maybe_add_product_attributes( $product ) {
22✔
574
                if ( $product->get_type() === 'variable' ) {
22✔
575
                        return;
×
576
                }
577

578
                $attributes     = $product->get_attributes();
22✔
579
                $product_schema = $this->data;
22✔
580

581
                foreach ( $attributes as $attribute ) {
22✔
582
                        $attribute_name = strtolower( wc_attribute_label( $attribute->get_name() ) );
4✔
583

584
                        if ( ! in_array( $attribute_name, $this->allowed_product_attributes, true ) ) {
4✔
585
                                continue;
×
586
                        }
587

588
                        $attribute_options = $attribute->get_options();
4✔
589

590
                        if ( count( $attribute_options ) > 1 || is_null( $attribute_options ) ) {
4✔
591
                                continue;
2✔
592
                        }
593

594
                        $attribute_value_label = $this->get_attribute_label( reset( $attribute_options ) );
2✔
595

596
                        if ( ! empty( $attribute_value_label ) ) {
2✔
597
                                $product_schema[ $attribute_name ] = $attribute_value_label;
2✔
598
                        }
599
                }
600

601
                $this->data = $product_schema;
22✔
602
        }
603

604
        /**
605
         * Get the label of an attribute value.
606
         *
607
         * @param int $attribute_value_id The attribute values id.
608
         *
609
         * @return string|null The attribute value label.
610
         */
611
        private function get_attribute_label( $attribute_value_id ) {
×
612
                $term = get_term( $attribute_value_id );
×
613
                if ( ! is_wp_error( $term ) && $term ) {
×
614
                        return $term->name;
×
615
                }
616

617
                return null;
×
618
        }
619

620
        /**
621
         * Tries to get the primary term, then the first term, null if none found.
622
         *
623
         * @param string $taxonomy_name Taxonomy name for the term.
624
         * @param int    $post_id       Post ID for the term.
625
         *
626
         * @return WP_Term|null The primary term, the first term or null.
627
         */
628
        protected function get_primary_term_or_first_term( $taxonomy_name, $post_id ) {
6✔
629
                $primary_term    = new WPSEO_Primary_Term( $taxonomy_name, $post_id );
6✔
630
                $primary_term_id = $primary_term->get_primary_term();
6✔
631

632
                if ( $primary_term_id !== false ) {
6✔
633
                        $primary_term = get_term( $primary_term_id );
2✔
634
                        if ( $primary_term instanceof WP_Term ) {
2✔
635
                                return $primary_term;
2✔
636
                        }
637
                }
638

639
                $terms = get_the_terms( $post_id, $taxonomy_name );
4✔
640

641
                if ( is_array( $terms ) && count( $terms ) > 0 ) {
4✔
642
                        return $terms[0];
2✔
643
                }
644

645
                return null;
2✔
646
        }
647

648
        /**
649
         * Adds the individual product variants as variants of the offer.
650
         *
651
         * @param WC_Product           $product   The WooCommerce Product we're working with.
652
         * @param WC_Product_Variation $variation The WooCommerce variation we're working with.
653
         * @param int                  $key       The nth product variation.
654
         *
655
         * @return array<string|int|array<string|int>> Schema Offers data.
656
         */
657
        protected function add_individual_offer( $product, $variation, $key ) {
2✔
658

659
                $currency           = get_woocommerce_currency();
2✔
660
                $tax_enabled        = wc_tax_enabled();
2✔
661
                $prices_include_tax = WPSEO_WooCommerce_Utils::prices_have_tax_included();
2✔
662
                $decimals           = wc_get_price_decimals();
2✔
663
                $product_id         = $product->get_id();
2✔
664
                $product_name       = $product->get_name();
2✔
665
                $variation_name     = implode( ' / ', $variation->get_attributes() );
2✔
666

667
                $offer = [
2✔
668
                        '@type'              => 'Offer',
2✔
669
                        '@id'                => YoastSEO()->meta->for_current_page()->site_url . '#/schema/offer/' . $product_id . '-' . $key,
2✔
670
                        'name'               => $product_name . ' - ' . $variation_name,
2✔
671
                        'url'                => get_permalink( $variation->get_id() ),
2✔
672
                        'priceSpecification' => [
2✔
673
                                [
2✔
674
                                        '@type'         => 'UnitPriceSpecification',
2✔
675
                                        'price'         => wc_format_decimal( $variation->get_regular_price(), $decimals ),
2✔
676
                                        'priceCurrency' => $currency,
2✔
677
                                ],
2✔
678
                        ],
2✔
679
                ];
2✔
680

681
                if ( $tax_enabled ) {
2✔
682
                        $offer['priceSpecification'][0]['valueAddedTaxIncluded'] = $prices_include_tax;
×
683
                }
684

685
                if ( $variation->is_on_sale() ) {
2✔
686
                        // If there is a sale the original price should be marked with ListPrice.
687
                        $offer['priceSpecification'][0]['priceType'] = 'https://schema.org/ListPrice';
2✔
688
                        $sale_offer                                  = [
2✔
689
                                '@type'         => 'UnitPriceSpecification',
2✔
690
                                'price'         => wc_format_decimal( $variation->get_sale_price(), $decimals ),
2✔
691
                                'priceCurrency' => $currency,
2✔
692
                        ];
2✔
693
                        if ( $this->is_sale_date_specified( $variation ) ) {
2✔
694
                                $sale_offer['validThrough'] = $variation->get_date_on_sale_to()->date_i18n();
2✔
695
                        }
696
                        if ( $tax_enabled ) {
2✔
697
                                $sale_offer['valueAddedTaxIncluded'] = $prices_include_tax;
×
698
                        }
699
                        $offer['priceSpecification'][] = $sale_offer;
2✔
700

701
                }
702
                $offer['priceSpecification'] = array_values( $offer['priceSpecification'] );
2✔
703
                if ( $product->is_on_backorder() ) {
2✔
704
                        $offer['availability'] = 'https://schema.org/PreOrder';
×
705
                }
706

707
                /**
708
                 * Filter: 'wpseo_schema_offer' - Allow changing the offer schema.
709
                 *
710
                 * @param array<string|int|array<string|int>> $offer     The schema offer data.
711
                 * @param WC_Product_Variation                $variation The WooCommerce product variation we're working with.
712
                 * @param WC_Product                          $product   The WooCommerce product we're working with.
713
                 */
714
                $data = apply_filters( 'wpseo_schema_offer', $offer, $variation, $product );
2✔
715

716
                if ( is_array( $data ) ) {
2✔
717
                        return $data;
2✔
718
                }
719

720
                return $offer;
×
721
        }
722

723
        /**
724
         * Adds the individual product variants.
725
         *
726
         * @param WC_Product           $product   The WooCommerce product we're working with.
727
         * @param WC_Product_Variation $variation The variation data.
728
         * @param int                  $key       The nth product variation data.
729
         *
730
         * @return array<string, string|int|array<string, string|int>> Schema Product data.
731
         */
732
        protected function add_individual_product_variation( $product, $variation, $key ) {
2✔
733
                $product_id         = $product->get_id();
2✔
734
                $product_name       = $product->get_name();
2✔
735
                $product_global_ids = get_post_meta( $product_id, 'wpseo_global_identifier_values', true );
2✔
736

737
                $variation_attributes = $variation->get_attributes();
2✔
738
                $variation_name       = implode( ' / ', $variation_attributes );
2✔
739

740
                $product_schema = [
2✔
741
                        '@type' => 'Product',
2✔
742
                        '@id'   => YoastSEO()->meta->for_current_page()->site_url . '#/product/' . $product_id . '-' . $key,
2✔
743
                        'name'  => $product_name . ' - ' . $variation_name,
2✔
744
                        'url'   => get_permalink( $variation->get_id() ),
2✔
745
                        'image' => $this->add_variation_image( $variation ),
2✔
746

747
                ];
2✔
748

749
                // Add the color, pattern and material attributes to the schema (if present).
750
                foreach ( $variation_attributes as $attribute => $value ) {
2✔
751
                        $attribute_name = strtolower( wc_attribute_label( $attribute ) );
2✔
752

753
                        if ( in_array( $attribute_name, $this->allowed_product_attributes, true ) ) {
2✔
754
                                $product_schema[ $attribute_name ] = $value;
×
755
                        }
756
                }
757

758
                if ( $variation->get_sku() ) {
2✔
759
                        $product_schema['sku'] = $variation->get_sku();
2✔
760
                }
761

762
                if ( $variation->get_description() !== '' ) {
2✔
763
                        $product_schema['description'] = YoastSEO()->helpers->string->strip_all_tags( stripslashes( $variation->get_description() ) );
2✔
764
                }
765
                // Adds variation's global identifiers to the $offer array.
766
                $variation_global_ids    = get_post_meta( $variation->get_id(), 'wpseo_variation_global_identifiers_values', true );
2✔
767
                $global_identifier_types = [
2✔
768
                        'gtin8',
2✔
769
                        'gtin12',
2✔
770
                        'gtin13',
2✔
771
                        'gtin14',
2✔
772
                        'mpn',
2✔
773
                ];
2✔
774

775
                foreach ( $global_identifier_types as $global_identifier_type ) {
2✔
776
                        if ( isset( $variation_global_ids[ $global_identifier_type ] ) && ! empty( $variation_global_ids[ $global_identifier_type ] ) ) {
2✔
777
                                $product_schema[ $global_identifier_type ] = $variation_global_ids[ $global_identifier_type ];
2✔
778
                        }
779
                        elseif ( isset( $product_global_ids[ $global_identifier_type ] ) && ! empty( $product_global_ids[ $global_identifier_type ] ) ) {
2✔
780
                                $product_schema[ $global_identifier_type ] = $product_global_ids[ $global_identifier_type ];
2✔
781
                        }
782
                }
783

784
                $product_schema['offers'] = $this->add_individual_offer( $product, $variation, $key );
2✔
785

786
                return $product_schema;
2✔
787
        }
788

789
        /**
790
         * Adds image schema for a product variation.
791
         *
792
         * @param WC_Product_Variation $variation The variation data.
793
         *
794
         * @return array<string, string> The imageObject schema.
795
         */
796
        private function add_variation_image( $variation ) {
×
797
                $image_id             = $variation->get_image_id();
×
798
                $base_image_schema_id = YoastSEO()->meta->for_current_page()->canonical;
×
799

800
                // Fallback to WooCommerce placeholder image.
801
                if ( empty( $image_id ) && function_exists( 'wc_placeholder_img_src' ) ) {
×
802
                        $image_schema_id     = "$base_image_schema_id#woocommerceimageplaceholder";
×
803
                        $placeholder_img_src = wc_placeholder_img_src();
×
804

805
                        return YoastSEO()->helpers->schema->image->generate_from_url( $image_schema_id, $placeholder_img_src, '', false, false );
×
806
                }
807

808
                $metadata      = YoastSEO()->helpers->image->get_metadata( $image_id );
×
809
                $image_title   = wp_basename( $metadata['file'] );
×
810
                $image_caption = $metadata['image_meta']['caption'];
×
811

812
                $image_schema_id = "$base_image_schema_id#$image_title";
×
813

814
                return YoastSEO()->helpers->schema->image->generate_from_attachment_id( $image_schema_id, $image_id, $image_caption );
×
815
        }
816

817
        /**
818
         * Adds the VAT to the price specification.
819
         *
820
         * @param array<string, string|int|array<string, string|int>> $price_specification The price specification object.
821
         *
822
         * @return void
823
         */
824
        private function maybe_add_vat( &$price_specification ) {
8✔
825
                if ( ! is_array( $price_specification ) ) {
8✔
826
                        return;
×
827
                }
828

829
                if ( wc_tax_enabled() ) {
8✔
830
                        $price_specification['valueAddedTaxIncluded'] = WPSEO_WooCommerce_Utils::prices_have_tax_included();
×
831
                }
832
                elseif ( isset( $price_specification['valueAddedTaxIncluded'] ) ) {
8✔
833
                        unset( $price_specification['valueAddedTaxIncluded'] );
8✔
834
                }
835
        }
836

837
        /**
838
         * Adds the price specification to the Schema Product data in case it is expressed with UnitPriceSpecification
839
         * objects.
840
         *
841
         * @param array<string,string|int|array<string,string|int>> $data                 Schema Product data.
842
         * @param int                                               $key                  The current offer key.
843
         * @param array<array<string,string|int>>                   $price_specifications The price specification object.
844
         * @param WC_Product                                        $product              The WooCommerce product we're
845
         *                                                                                working with.
846
         *
847
         * @return void
848
         */
849
        private function add_unit_price_specifications( &$data, $key, $price_specifications, $product ) {
4✔
850
                foreach ( $price_specifications as &$price_specification ) {
4✔
851
                        $this->maybe_add_vat( $price_specification );
4✔
852
                        // We don't support WooCommerce validThrough date for ListPrice as it will be set by default to the end of the next year.
853
                        if ( $this->is_sale_date_specified( $product ) && ! $this->is_list_price( $price_specification ) ) {
4✔
854
                                continue;
2✔
855
                        }
856

857
                        if ( isset( $price_specification['validThrough'] ) ) {
4✔
858
                                unset( $price_specification['validThrough'] );
4✔
859
                        }
860
                }
861
                $data['offers'][ $key ]['priceSpecification'] = $price_specifications;
4✔
862
        }
863

864
        /**
865
         * Adds the price specification to the Schema Product data.
866
         *
867
         * @param array<string,string|int|array<string,string|int>> $data  Schema Product data.
868
         * @param int                                               $key   The current offer key.
869
         * @param float                                             $price The price associated to the offer.
870
         *
871
         * @return void
872
         */
873
        private function add_price_specifications( &$data, $key, $price ) {
4✔
874

875
                $data['offers'][ $key ]['priceSpecification']['@type'] = 'PriceSpecification';
4✔
876
                $data['offers'][ $key ]['priceSpecification']['price'] = $price;
4✔
877

878
                $this->maybe_add_vat( $data['offers'][ $key ]['priceSpecification'] );
4✔
879
        }
880

881
        /**
882
         * Enhances the review data output by WooCommerce.
883
         *
884
         * @param array<string, string|int|array<string, string|int>> $data    Review Schema data.
885
         * @param WC_Product                                          $product The WooCommerce product we're working with.
886
         *
887
         * @return array<string, string|int|array<string, string|int>> Review Schema data.
888
         */
889
        protected function filter_reviews( $data, $product ) {
12✔
890
                if ( ! isset( $data['review'] ) || $data['review'] === [] ) {
12✔
891
                        return $data;
2✔
892
                }
893

894
                $product_id   = $product->get_id();
10✔
895
                $product_name = $product->get_name();
10✔
896

897
                foreach ( $data['review'] as $key => $review ) {
10✔
898
                        $data['review'][ $key ]['@id']  = YoastSEO()->meta->for_current_page()->site_url . '#/schema/review/' . $product_id . '-' . $key;
10✔
899
                        $data['review'][ $key ]['name'] = $product_name;
10✔
900
                }
901

902
                return $data;
10✔
903
        }
904

905
        /**
906
         * Check if the product is on sale and the sale end date is specified.
907
         *
908
         * @param WC_Product $product The WooCommerce product we're working with.
909
         *
910
         * @return bool True if the product is on sale and the sale end date is specified, false otherwise.
911
         */
912
        protected function is_sale_date_specified( $product ) {
4✔
913
                return $product->is_on_sale() && $product->get_date_on_sale_to();
4✔
914
        }
915

916
        /**
917
         * Check if the price specification is a ListPrice.
918
         *
919
         * @param array<string,string|int> $price_specification The price specification object.
920
         *
921
         * @return bool True if the price specification is a ListPrice, false otherwise.
922
         */
923
        protected function is_list_price( $price_specification ) {
2✔
924
                return isset( $price_specification['priceType'] ) && $price_specification['priceType'] === 'https://schema.org/ListPrice';
2✔
925
        }
926
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc