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

Yoast / wpseo-woocommerce / 0a2db8dee1921406e9b9e6b41d0cfaa0eeb3ce9c

10 Feb 2025 02:14PM UTC coverage: 52.69% (-0.07%) from 52.764%
0a2db8dee1921406e9b9e6b41d0cfaa0eeb3ce9c

Pull #1042

github

web-flow
Merge 2b0b63406 into 7bca08177
Pull Request #1042: Fix schema image block

91 of 182 branches covered (50.0%)

Branch coverage included in aggregate %.

0 of 8 new or added lines in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

1045 of 1974 relevant lines covered (52.94%)

5.0 hits per line

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

86.87
/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>> $data
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
        }
2✔
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
                        [
1✔
133
                                'yoast-schema-graph',
2✔
134
                                'yoast-schema-graph--woo',
1✔
135
                                'yoast-schema-graph--footer',
1✔
136
                        ]
1✔
137
                );
1✔
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
                        ];
1✔
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 ) {
16✔
225
                $data = $this->change_seller_in_offers( $data );
16✔
226
                $data = $this->filter_reviews( $data, $product );
16✔
227
                $data = $this->filter_sku( $data, $product );
16✔
228

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

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

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

244
                $this->add_image();
16✔
245
                $this->add_variation_images();
16✔
246
                $this->add_brand( $product );
16✔
247
                $this->add_manufacturer( $product );
16✔
248
                $this->maybe_add_product_attributes( $product );
16✔
249
                $this->add_global_identifier( $product );
16✔
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 );
16✔
257

258
                return [];
16✔
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 ) {
12✔
270
                if ( ! isset( $data['offers'] ) || $data['offers'] === [] ) {
12✔
271
                        return $data;
×
272
                }
273

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

276
                foreach ( $data['offers'] as $key => $offer ) {
12✔
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;
12✔
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 ) {
12✔
283
                                $price = WPSEO_WooCommerce_Utils::get_product_display_price( $product );
10✔
284
                                $this->add_price_specifications( $data, $key, $price );
10✔
285
                        }
286
                        else {
287
                                $this->add_unit_price_specifications( $data, $key, $offer['priceSpecification'], $product );
2✔
288

289
                        }
290

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

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

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

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

311
                return $data;
12✔
312
        }
313

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

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

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

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

346
                return $data;
2✔
347
        }
348

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

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

370
                return $offers;
4✔
371
        }
372

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

390
                return $data;
6✔
391
        }
392

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

407
                return $types;
2✔
408
        }
409

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

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

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

441
                return true;
4✔
442
        }
443

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

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

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

467
                return $data;
6✔
468
        }
469

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

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

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

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

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

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

540
                        return;
14✔
541
                }
542

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

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

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

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

582
                foreach ( $attributes as $attribute ) {
16✔
583
                        $attribute_name = strtolower( wc_attribute_label( $attribute->get_name() ) );
2✔
584

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

589
                        $attribute_options     = $attribute->get_options();
2✔
590
                        $attribute_value_label = $this->get_attribute_label( reset( $attribute_options ) );
2✔
591

592
                        if ( ! empty( $attribute_value_label ) ) {
2✔
593
                                $product_schema[ $attribute_name ] = $attribute_value_label;
2✔
594
                        }
595
                }
596

597
                $this->data = $product_schema;
16✔
598
        }
6✔
599

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

613
                return null;
×
614
        }
615

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

628
                if ( $primary_term_id !== false ) {
6✔
629
                        $primary_term = get_term( $primary_term_id );
2✔
630
                        if ( $primary_term instanceof WP_Term ) {
2✔
631
                                return $primary_term;
2✔
632
                        }
633
                }
634

635
                $terms = get_the_terms( $post_id, $taxonomy_name );
4✔
636

637
                if ( is_array( $terms ) && count( $terms ) > 0 ) {
4✔
638
                        return $terms[0];
2✔
639
                }
640

641
                return null;
2✔
642
        }
643

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

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

663
                $offer = [
1✔
664
                        '@type'              => 'Offer',
2✔
665
                        '@id'                => YoastSEO()->meta->for_current_page()->site_url . '#/schema/offer/' . $product_id . '-' . $key,
2✔
666
                        'name'               => $product_name . ' - ' . $variation_name,
2✔
667
                        'url'                => get_permalink( $variation->get_id() ),
2✔
668
                        'priceSpecification' => [
1✔
669
                                '@type'         => 'PriceSpecification',
2✔
670
                                'price'         => wc_format_decimal( $variation->get_price(), $decimals ),
2✔
671
                                'priceCurrency' => $currency,
2✔
672
                        ],
1✔
673
                ];
1✔
674

675
                if ( $tax_enabled ) {
2✔
676
                        $offer['priceSpecification']['valueAddedTaxIncluded'] = $prices_include_tax;
×
677
                }
678

679
                if ( ! $product->is_on_sale() || ! $product->get_date_on_sale_to() ) {
2✔
680
                        unset( $offer['priceValidUntil'] );
2✔
681
                }
682

683
                if ( $product->is_on_backorder() ) {
2✔
684
                        $offer['availability'] = 'https://schema.org/PreOrder';
×
685
                }
686

687
                /**
688
                 * Filter: 'wpseo_schema_offer' - Allow changing the offer schema.
689
                 *
690
                 * @param array<string|int|array<string|int>> $offer     The schema offer data.
691
                 * @param WC_Product_Variation                $variation The WooCommerce product variation we're working with.
692
                 * @param WC_Product                          $product   The WooCommerce product we're working with.
693
                 */
694
                $data = apply_filters( 'wpseo_schema_offer', $offer, $variation, $product );
2✔
695

696
                if ( is_array( $data ) ) {
2✔
697
                        return $data;
2✔
698
                }
699

700
                return $offer;
×
701
        }
702

703
        /**
704
         * Adds the individual product variants.
705
         *
706
         * @param WC_Product           $product   The WooCommerce product we're working with.
707
         * @param WC_Product_Variation $variation The variation data.
708
         * @param int                  $key       The nth product variation data.
709
         *
710
         * @return array<string,string|int|array<string,string|int>> Schema Product data.
711
         */
712
        protected function add_individual_product_variation( $product, $variation, $key ) {
2✔
713
                $product_id         = $product->get_id();
2✔
714
                $product_name       = $product->get_name();
2✔
715
                $product_global_ids = get_post_meta( $product_id, 'wpseo_global_identifier_values', true );
2✔
716

717
                $variation_attributes = $variation->get_attributes();
2✔
718
                $variation_name       = implode( ' / ', $variation_attributes );
2✔
719

720
                $product_schema = [
1✔
721
                        '@type' => 'Product',
2✔
722
                        '@id'   => YoastSEO()->meta->for_current_page()->site_url . '#/product/' . $product_id . '-' . $key,
2✔
723
                        'name'  => $product_name . ' - ' . $variation_name,
2✔
724
                        'url'   => get_permalink( $variation->get_id() ),
2✔
725
                        'image' => $this->add_variation_image( $variation ),
2✔
726

727
                ];
1✔
728

729
                // Add the color, pattern and material attributes to the schema (if present).
730
                foreach ( $variation_attributes as $attribute => $value ) {
2✔
731
                        $attribute_name = strtolower( wc_attribute_label( $attribute ) );
2✔
732

733
                        if ( in_array( $attribute_name, $this->allowed_product_attributes, true ) ) {
2✔
734
                                $product_schema[ $attribute_name ] = $value;
×
735
                        }
736
                }
737

738
                if ( $variation->get_sku() ) {
2✔
739
                        $product_schema['sku'] = $variation->get_sku();
2✔
740
                }
741

742
                if ( $variation->get_description() !== '' ) {
2✔
743
                        $product_schema['description'] = YoastSEO()->helpers->string->strip_all_tags( stripslashes( $variation->get_description() ) );
2✔
744
                }
745
                // Adds variation's global identifiers to the $offer array.
746
                $variation_global_ids    = get_post_meta( $variation->get_id(), 'wpseo_variation_global_identifiers_values', true );
2✔
747
                $global_identifier_types = [
1✔
748
                        'gtin8',
2✔
749
                        'gtin12',
1✔
750
                        'gtin13',
1✔
751
                        'gtin14',
1✔
752
                        'mpn',
1✔
753
                ];
1✔
754

755
                foreach ( $global_identifier_types as $global_identifier_type ) {
2✔
756
                        if ( isset( $variation_global_ids[ $global_identifier_type ] ) && ! empty( $variation_global_ids[ $global_identifier_type ] ) ) {
2✔
757
                                $product_schema[ $global_identifier_type ] = $variation_global_ids[ $global_identifier_type ];
2✔
758
                        }
759
                        elseif ( isset( $product_global_ids[ $global_identifier_type ] ) && ! empty( $product_global_ids[ $global_identifier_type ] ) ) {
2✔
760
                                $product_schema[ $global_identifier_type ] = $product_global_ids[ $global_identifier_type ];
2✔
761
                        }
762
                }
763

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

766
                return $product_schema;
2✔
767
        }
768

769
        /**
770
         * Adds image schema for a product variation.
771
         *
772
         * @param WC_Product_Variation $variation The variation data.
773
         *
774
         * @return array<string,string> The imageObject schema.
775
         */
776
        private function add_variation_image( $variation ) {
×
NEW
777
                $image_id             = $variation->get_image_id();
×
NEW
778
                $base_image_schema_id = YoastSEO()->meta->for_current_page()->canonical;
×
779

780
                // Fallback to WooCommerce placeholder image.
NEW
781
                if ( empty( $image_id ) && function_exists( 'wc_placeholder_img_src' ) ) {
×
NEW
782
                        $image_schema_id     = "$base_image_schema_id#woocommerceimageplaceholder";
×
NEW
783
                        $placeholder_img_src = wc_placeholder_img_src();
×
784

NEW
785
                        return YoastSEO()->helpers->schema->image->generate_from_url( $image_schema_id, $placeholder_img_src, '', false, false );
×
786
                }
787

NEW
788
                $metadata      = YoastSEO()->helpers->image->get_metadata( $image_id );
×
789
                $image_title   = wp_basename( $metadata['file'] );
×
790
                $image_caption = $metadata['image_meta']['caption'];
×
791

NEW
792
                $image_schema_id = "$base_image_schema_id#$image_title";
×
UNCOV
793
                return YoastSEO()->helpers->schema->image->generate_from_attachment_id( $image_schema_id, $image_id, $image_caption );
×
794
        }
795

796
        /**
797
         * Adds the VAT to the price specification.
798
         *
799
         * @param array<string,string|int|array<string,string|int>> $price_specification The price specification object.
800
         *
801
         * @return void
802
         */
803
        private function maybe_add_vat( &$price_specification ) {
4✔
804
                if ( ! is_array( $price_specification ) ) {
4✔
805
                        return;
×
806
                }
807

808
                if ( wc_tax_enabled() ) {
4✔
809
                        $price_specification['valueAddedTaxIncluded'] = WPSEO_WooCommerce_Utils::prices_have_tax_included();
×
810
                }
811
                elseif ( isset( $price_specification['valueAddedTaxIncluded'] ) ) {
4✔
812
                                unset( $price_specification['valueAddedTaxIncluded'] );
4✔
813
                }
814
        }
815

816
        /**
817
         * Adds the price specification to the Schema Product data in case it is expressed with UnitPriceSpecification objects.
818
         *
819
         * @param array<string,string|int|array<string,string|int>> $data                 Schema Product data.
820
         * @param int                                               $key                  The current offer key.
821
         * @param array<array<string,string|int>>                   $price_specifications The price specification object.
822
         * @param WC_Product                                        $product              The WooCommerce product we're working with.
823
         * @return void
824
         */
825
        private function add_unit_price_specifications( &$data, $key, $price_specifications, $product ) {
2✔
826
                foreach ( $price_specifications as &$price_specification ) {
2✔
827
                        $this->maybe_add_vat( $price_specification );
2✔
828
                        // We don't support WooCommerce validThrough date for ListPrice as it will be set by default to the end of the next year.
829
                        if ( $this->is_sale_date_specified( $product ) && ! $this->is_list_price( $price_specification ) ) {
2✔
830
                                continue;
1✔
831
                        }
832

833
                        if ( isset( $price_specification['validThrough'] ) ) {
2✔
834
                                unset( $price_specification['validThrough'] );
2✔
835
                        }
836
                }
837
                $data['offers'][ $key ]['priceSpecification'] = $price_specifications;
2✔
838
        }
839

840
        /**
841
         *  Adds the price specification to the Schema Product data.
842
         *
843
         * @param array<string,string|int|array<string,string|int>> $data  Schema Product data.
844
         * @param int                                               $key   The current offer key.
845
         * @param float                                             $price The price associated to the offer.
846
         * @return void
847
         */
848
        private function add_price_specifications( &$data, $key, $price ) {
2✔
849

850
                $data['offers'][ $key ]['priceSpecification']['@type'] = 'PriceSpecification';
2✔
851
                $data['offers'][ $key ]['priceSpecification']['price'] = $price;
2✔
852

853
                $this->maybe_add_vat( $data['offers'][ $key ]['priceSpecification'] );
2✔
854
        }
855

856
        /**
857
         * Enhances the review data output by WooCommerce.
858
         *
859
         * @param array<string,string|int|array<string,string|int>> $data    Review Schema data.
860
         * @param WC_Product                                        $product The WooCommerce product we're working with.
861
         *
862
         * @return array<string,string|int|array<string,string|int>> Review Schema data.
863
         */
864
        protected function filter_reviews( $data, $product ) {
8✔
865
                if ( ! isset( $data['review'] ) || $data['review'] === [] ) {
8✔
866
                        return $data;
2✔
867
                }
868

869
                $product_id   = $product->get_id();
6✔
870
                $product_name = $product->get_name();
6✔
871

872
                foreach ( $data['review'] as $key => $review ) {
6✔
873
                        $data['review'][ $key ]['@id']  = YoastSEO()->meta->for_current_page()->site_url . '#/schema/review/' . $product_id . '-' . $key;
6✔
874
                        $data['review'][ $key ]['name'] = $product_name;
6✔
875
                }
876

877
                return $data;
6✔
878
        }
879

880
        /**
881
         * Check if the product is on sale and the sale end date is specified.
882
         *
883
         * @param WC_Product $product The WooCommerce product we're working with.
884
         * @return bool True if the product is on sale and the sale end date is specified, false otherwise.
885
         */
886
        protected function is_sale_date_specified( $product ) {
2✔
887
                return $product->is_on_sale() && $product->get_date_on_sale_to();
2✔
888
        }
889

890
        /**
891
         * Check if the price specification is a ListPrice.
892
         *
893
         * @param array<string,string|int> $price_specification The price specification object.
894
         * @return bool True if the price specification is a ListPrice, false otherwise.
895
         */
896
        protected function is_list_price( $price_specification ) {
1✔
897
                return isset( $price_specification['priceType'] ) && $price_specification['priceType'] === 'https://schema.org/ListPrice';
1✔
898
        }
899
}
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