├── .eslintignore
├── .eslintrc.json
├── .github
└── workflows
│ └── php.yml
├── .gitignore
├── CHANGELOG.md
├── README.md
├── composer.json
├── grumphp.yml
├── package.json
├── pdepend.xml
├── phpcs.xml
├── phpmd.xml
├── phpstan.neon
├── phpunit.xml
├── src
├── Model
│ └── Config
│ │ └── Source
│ │ └── ProductAttributes.php
├── ViewModel
│ └── Schema
│ │ ├── AbstractSchema.php
│ │ ├── Breadcrumbs.php
│ │ ├── Organization.php
│ │ ├── Product.php
│ │ ├── SchemaInterface.php
│ │ └── Website.php
├── etc
│ ├── adminhtml
│ │ ├── config.xml
│ │ └── system.xml
│ ├── config.xml
│ └── module.xml
├── registration.php
└── view
│ └── frontend
│ ├── layout
│ ├── catalog_category_view.xml
│ ├── catalog_product_view.xml
│ └── cms_index_index.xml
│ └── templates
│ └── schema.phtml
└── tests
├── Model
└── Config
│ └── Source
│ └── ProductAttributesTest.php
└── ViewModel
└── Schema
├── AbstractSchemaTest.php
├── BreadcrumbsTest.php
├── OrganizationTest.php
├── ProductTest.php
└── WebsiteTest.php
/.eslintignore:
--------------------------------------------------------------------------------
1 | *.min.js
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "amd": true,
4 | "browser": true,
5 | "jasmine": true
6 | },
7 | "rules": {
8 | "consistent-return": 2,
9 | "eqeqeq": [2, "smart"],
10 | "guard-for-in": 2,
11 | "lines-around-comment": [
12 | 2,
13 | {
14 | "beforeBlockComment": true
15 | }
16 | ],
17 | "max-len": [2, 120, 4],
18 | "max-nested-callbacks": [2, 3],
19 | "no-alert": 2,
20 | "no-array-constructor": 2,
21 | "no-caller": 2,
22 | "no-catch-shadow": 2,
23 | "no-else-return": 2,
24 | "no-eval": 2,
25 | "no-extend-native": 2,
26 | "no-extra-bind": 2,
27 | "no-floating-decimal": 2,
28 | "no-implied-eval": 2,
29 | "no-lone-blocks": 2,
30 | "no-lonely-if": 2,
31 | "no-loop-func": 2,
32 | "no-multi-str": 2,
33 | "no-new-object": 2,
34 | "no-proto": 2,
35 | "no-return-assign": 2,
36 | "no-self-compare": 2,
37 | "no-shadow": 2,
38 | "no-undef-init": 2,
39 | "no-unused-vars": [
40 | 2,
41 | {
42 | "args": "after-used",
43 | "vars": "all",
44 | "varsIgnorePattern": "^config$"
45 | }
46 | ],
47 | "no-with": 2,
48 | "operator-assignment": [2, "always"],
49 | "radix": 2,
50 | "semi": [2, "always"],
51 | "semi-spacing": 2,
52 | "space-before-blocks": "error",
53 | "space-before-function-paren":"error",
54 | "func-style": [2, "expression"],
55 | "eol-last":"error"
56 | }
57 | }
--------------------------------------------------------------------------------
/.github/workflows/php.yml:
--------------------------------------------------------------------------------
1 | name: PHP
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | env:
10 | COMPOSER_AUTH: |
11 | {
12 | "http-basic": {
13 | "repo.magento.com": {
14 | "username": "${{ secrets.MAGENTO_PUBLIC_KEY }}",
15 | "password": "${{ secrets.MAGENTO_PRIVATE_KEY }}"
16 | }
17 | }
18 | }
19 | jobs:
20 | php:
21 | name: GrumPHP
22 | runs-on: ubuntu-latest
23 | container: srcoder/development-php:php74-fpm
24 |
25 | steps:
26 | - name: Checkout repository
27 | uses: actions/checkout@v2
28 |
29 | - name: Install Composer package
30 | run: composer update
31 |
32 | - name: GrumPHP
33 | run: vendor/bin/grumphp run
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor
2 | /composer.lock
3 | /.idea
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 | ## [Unreleased]
8 |
9 | ## [1.2.0] - 2022-02-02
10 | ### Added
11 | - Make it possible to set a [Brand](https://schema.org/Brand) for products
12 |
13 | ### Remove
14 | - Remove `@cover` from test docblocks
15 |
16 | ## [1.1.0] - 2022-02-02
17 | ### Changed
18 | - Added requirements to allow adding the extension to Magento 2.3.7-p1
19 |
20 | ## [1.0.2] - 2021-09-28
21 | ### Fixed
22 | - Fix incorrect check if module is enabled
23 |
24 | ## [1.0.1] - 2021-09-27
25 | ### Added
26 | - Add missing [url](https://schema.org/url) to [Organization](https://schema.org/Organization)
27 |
28 | ## [1.0.0] - 2021-09-25
29 | ### Added
30 | - Implement structured data for [Organization](https://schema.org/Organization)
31 | - Implement structured data for [WebSite](https://schema.org/WebSite)
32 | - Implement structured data for [BreadcrumbList](https://schema.org/BreadcrumbList)
33 | - Implement structured data for [Product](https://schema.org/Product)
34 |
35 | [Unreleased]: https://github.com/elgentos/magento2-structured-data/tree/main
36 | [1.1.0]: https://github.com/elgentos/magento2-structured-data/compare/1.0.2...1.1.0
37 | [1.0.2]: https://github.com/elgentos/magento2-structured-data/compare/1.0.1...1.0.2
38 | [1.0.1]: https://github.com/elgentos/magento2-structured-data/compare/1.0.0...1.0.1
39 | [1.0.0]: https://github.com/elgentos/magento2-structured-data/releases/tag/1.0.0
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Magento 2 Structured Data
2 |
3 |
4 |
5 | ## Installation
6 |
7 | This package can be installed using [Composer](https://getcomposer.com).
8 |
9 | ```bash
10 | composer require elgentos/magento2-structured-data
11 | bin/magento module:enable Elgentos_StructuredData
12 | bin/magento setup:upgrade
13 | ```
14 |
15 | ## Usage
16 | To use the extension, you need to enable it in the configuration of Magento. This will display the structured data
17 | in your pages just before the end of the `
` tag.
18 |
19 | Also make sure you remove all `itemscope`, `itemtype` and `itemprop` attributes. These are normally added to the product
20 | page and will also add structured data to the product pages. This needs to be done in your theme.
21 |
22 | ## Contributing
23 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
24 |
25 | Please make sure to update tests as appropriate.
26 |
27 | ## License
28 | MIT
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "elgentos/magento2-structured-data",
3 | "description": "Implement structured data for Magento 2 webshops.",
4 | "type": "magento2-module",
5 | "license": "MIT",
6 | "authors": [
7 | {
8 | "name": "elgentos ecommerce",
9 | "email": "info@elgentos.nl"
10 | }
11 | ],
12 | "minimum-stability": "stable",
13 | "require": {
14 | "php": "^7.4|^8.0|^8.1",
15 | "magento/framework": "^102.0.7||^103.0",
16 | "magento/module-catalog": "^103.0.7||^104.0",
17 | "magento/module-eav": "^102.0.7||^102.1",
18 | "magento/module-review": "^100.3.7||^100.4",
19 | "magento/module-search": "^101.0.7||^101.1",
20 | "magento/module-store": "^101.0.7||^101.1",
21 | "magento/module-theme": "^101.0.7||^101.1"
22 | },
23 | "require-dev": {
24 | "mediact/coding-standard-magento2": "@stable",
25 | "mediact/testing-suite": "^2.9"
26 | },
27 | "repositories": {
28 | "magento": {
29 | "type": "composer",
30 | "url": "https://repo.magento.com"
31 | }
32 | },
33 | "archive": {
34 | "exclude": [
35 | "/.gitignore",
36 | "/grumphp.yml",
37 | "/pdepend.xml",
38 | "/phpstan.neon",
39 | "/phpunit.xml",
40 | "/phpcs.xml",
41 | "/phpmd.xml",
42 | "/package.json",
43 | "/.eslintrc.json",
44 | "/.eslintignore",
45 | "/tests"
46 | ]
47 | },
48 | "config": {
49 | "sort-packages": true,
50 | "allow-plugins": {
51 | "mediact/composer-unclog-plugin": true,
52 | "mediact/coding-standard-phpstorm": true,
53 | "magento/composer-dependency-version-audit-plugin": true,
54 | "phpro/grumphp": true,
55 | "mediact/testing-suite": true
56 | }
57 | },
58 | "autoload": {
59 | "files": [
60 | "src/registration.php"
61 | ],
62 | "psr-4": {
63 | "Elgentos\\StructuredData\\": "src/"
64 | }
65 | },
66 | "autoload-dev": {
67 | "psr-4": {
68 | "Elgentos\\StructuredData\\Tests\\": "tests/"
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/grumphp.yml:
--------------------------------------------------------------------------------
1 | imports:
2 | - resource: 'vendor/mediact/testing-suite/config/default/grumphp.yml'
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "devDependencies": {
3 | "eslint": "^4.19.1"
4 | }
5 | }
--------------------------------------------------------------------------------
/pdepend.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 | memory
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/phpcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 | PHPCS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/phpmd.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 | PHPMD
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: 6
3 | paths:
4 | - src
5 | - tests
6 | ignoreErrors:
7 | - '#(class|type) Magento\\TestFramework#i'
8 | - '#(class|type) Magento\\\S*Factory#i'
9 | - '#(class|type) Elgentos\\\S*Factory#i'
10 | - '#(method) Magento\\Framework\\Api\\ExtensionAttributesInterface#i'
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 | tests
8 |
9 |
10 |
11 |
12 | src
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/Model/Config/Source/ProductAttributes.php:
--------------------------------------------------------------------------------
1 | collectionFactory = $collectionFactory;
20 | }
21 |
22 | public function toOptionArray(): array
23 | {
24 | /** @var Collection $collection */
25 | $collection = $this->collectionFactory->create();
26 | $items = [
27 | [
28 | 'value' => '',
29 | 'label' => __('-- Select an Attribute --')
30 | ]
31 | ];
32 |
33 | /** @var Attribute $attribute */
34 | foreach ($collection as $attribute) {
35 | $items[] = [
36 | 'value' => $attribute->getId(),
37 | 'label' => $attribute->getDefaultFrontendLabel()
38 | ];
39 | }
40 |
41 | return $items;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/ViewModel/Schema/AbstractSchema.php:
--------------------------------------------------------------------------------
1 | scopeConfig = $scopeConfig;
39 | $this->serializer = $serializer;
40 | }
41 |
42 | public function getSerializedData(): string
43 | {
44 | return $this->serializer->serialize($this->getStructuredData());
45 | }
46 |
47 | public function isEnabled(): bool
48 | {
49 | return $this->scopeConfig->isSetFlag(
50 | self::XML_PATH_ENABLED,
51 | ScopeInterface::SCOPE_STORE
52 | );
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/ViewModel/Schema/Breadcrumbs.php:
--------------------------------------------------------------------------------
1 | catalogData = $catalogData;
34 | $this->urlBuilder = $urlBuilder;
35 | }
36 |
37 | public function getStructuredData(): array
38 | {
39 | return [
40 | '@context' => self::SCHEMA_CONTEXT,
41 | '@type' => self::SCHEMA_TYPE_BREADCRUMB_LIST,
42 | 'itemListElement' => $this->getBreadcrumbItems()
43 | ];
44 | }
45 |
46 | public function isEnabled(): bool
47 | {
48 | return parent::isEnabled() &&
49 | $this->scopeConfig->isSetFlag(
50 | self::XML_PATH_BREADCRUMBS_ENABLED,
51 | ScopeInterface::SCOPE_STORE
52 | );
53 | }
54 |
55 | private function getBreadcrumbItems(): array
56 | {
57 | $items = [
58 | $this->generateListItem(
59 | 0,
60 | [
61 | 'link' => $this->urlBuilder->getBaseUrl(),
62 | 'label' => __('Home')
63 | ]
64 | )
65 | ];
66 |
67 | $position = 0;
68 |
69 | foreach ($this->catalogData->getBreadcrumbPath() as $item) {
70 | $items[] = $this->generateListItem(++$position, $item);
71 | }
72 |
73 | return $items;
74 | }
75 |
76 | private function generateListItem(int $position, array $item): array
77 | {
78 | return [
79 | '@type' => self::SCHEMA_TYPE_LIST_ITEM,
80 | 'position' => $position,
81 | 'item' => [
82 | '@id' => $item['link'] ?? $this->urlBuilder->getCurrentUrl(),
83 | 'name' => $item['label']
84 | ]
85 | ];
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/ViewModel/Schema/Organization.php:
--------------------------------------------------------------------------------
1 | urlBuilder = $urlBuilder;
30 | $this->logo = $logo;
31 | }
32 |
33 | public function getStructuredData(): array
34 | {
35 | return [
36 | '@context' => self::SCHEMA_CONTEXT,
37 | '@type' => self::SCHEMA_TYPE_ORGANIZATION,
38 | 'name' => $this->getCompanyName(),
39 | 'email' => $this->getCompanyEmail(),
40 | 'telephone' => $this->getCompanyTelephone(),
41 | 'logo' => $this->getWebsiteLogo(),
42 | 'url' => $this->urlBuilder->getBaseUrl(),
43 | 'address' => $this->getOrganizationAddress()
44 | ];
45 | }
46 |
47 | public function isEnabled(): bool
48 | {
49 | return parent::isEnabled() &&
50 | $this->scopeConfig->isSetFlag(
51 | self::XML_PATH_ORGANIZATION_ENABLED,
52 | ScopeInterface::SCOPE_STORE
53 | );
54 | }
55 |
56 | private function getOrganizationAddress(): array
57 | {
58 | return [
59 | '@type' => self::SCHEMA_TYPE_POSTAL_ADDRESS,
60 | 'addressLocality' => $this->getCompanyAddressCity(),
61 | 'addressRegion' => $this->getCompanyAddressRegion(),
62 | 'addressCountry' => $this->getCompanyAddressCountry(),
63 | 'postalCode' => $this->getCompanyAddressPostalCode(),
64 | 'streetAddress' => $this->getCompanyAddressStreetAddress()
65 | ];
66 | }
67 |
68 | private function getCompanyName(): string
69 | {
70 | return (string) $this->scopeConfig->getValue(
71 | 'general/store_information/name',
72 | ScopeInterface::SCOPE_STORE
73 | );
74 | }
75 |
76 | private function getCompanyEmail(): string
77 | {
78 | return (string) $this->scopeConfig->getValue(
79 | 'trans_email/ident_general/email',
80 | ScopeInterface::SCOPE_STORE
81 | );
82 | }
83 |
84 | private function getCompanyTelephone(): string
85 | {
86 | return (string) $this->scopeConfig->getValue(
87 | 'general/store_information/phone',
88 | ScopeInterface::SCOPE_STORE
89 | );
90 | }
91 |
92 | private function getWebsiteLogo(): string
93 | {
94 | return $this->logo->getLogoSrc();
95 | }
96 |
97 | private function getCompanyAddressCity(): string
98 | {
99 | return (string) $this->scopeConfig->getValue(
100 | 'general/store_information/city',
101 | ScopeInterface::SCOPE_STORE
102 | );
103 | }
104 |
105 | private function getCompanyAddressRegion(): ?string
106 | {
107 | return $this->scopeConfig->getValue(
108 | 'general/store_information/region_id',
109 | ScopeInterface::SCOPE_STORE
110 | ) ?: null;
111 | }
112 |
113 | private function getCompanyAddressCountry(): string
114 | {
115 | return (string) $this->scopeConfig->getValue(
116 | 'general/store_information/country_id',
117 | ScopeInterface::SCOPE_STORE
118 | );
119 | }
120 |
121 | private function getCompanyAddressPostalCode(): string
122 | {
123 | return (string) $this->scopeConfig->getValue(
124 | 'general/store_information/postcode',
125 | ScopeInterface::SCOPE_STORE
126 | );
127 | }
128 |
129 | private function getCompanyAddressStreetAddress(): string
130 | {
131 | return trim(
132 | $this->scopeConfig->getValue(
133 | 'general/store_information/street_line1',
134 | ScopeInterface::SCOPE_STORE
135 | ) . ' ' .
136 | $this->scopeConfig->getValue(
137 | 'general/store_information/street_line2',
138 | ScopeInterface::SCOPE_STORE
139 | )
140 | );
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/src/ViewModel/Schema/Product.php:
--------------------------------------------------------------------------------
1 | registry = $registry;
57 | $this->imageHelper = $imageHelper;
58 | $this->reviewCollectionFactory = $reviewCollectionFactory;
59 | $this->reviewFactory = $reviewFactory;
60 | $this->storeManager = $storeManager;
61 | }
62 |
63 | /**
64 | * @throws NoSuchEntityException
65 | */
66 | public function getStructuredData(): array
67 | {
68 | $product = $this->getProduct();
69 |
70 | if (!$product instanceof ProductModel || !$product->getId()) {
71 | return [];
72 | }
73 |
74 | /** @var Store $store */
75 | $store = $this->storeManager->getStore();
76 | $data = [
77 | '@context' => self::SCHEMA_CONTEXT,
78 | '@type' => self::SCHEMA_TYPE_PRODUCT,
79 | 'url' => $product->getProductUrl(),
80 | 'name' => $product->getName(),
81 | 'sku' => $product->getSku(),
82 | 'description' => $product->getData('description'),
83 | 'image' => $this->getProductImage($product),
84 | 'offers' => [
85 | '@type' => self::SCHEMA_TYPE_OFFER,
86 | 'url' => $product->getProductUrl(),
87 | 'availability' => $product->isSalable()
88 | ? self::SCHEMA_AVAILABILITY_IN_STOCK
89 | : self::SCHEMA_AVAILABILITY_OUT_OF_STOCK,
90 | 'price' => $product->getFinalPrice(),
91 | 'sku' => $product->getSku(),
92 | 'priceCurrency' => $store->getCurrentCurrencyCode(),
93 | 'itemCondition' => self::SCHEMA_ITEM_CONDITION_NEW
94 | ]
95 | ];
96 |
97 | $gtinAttribute = $this->getProductAttribute(self::XML_PATH_ATTRIBUTE_GTIN);
98 | $brandAttribute = $this->getProductAttribute(self::XML_PATH_ATTRIBUTE_BRAND);
99 | $reviews = $this->getProductReviews($product);
100 |
101 | if ($gtinAttribute) {
102 | $data['gtin'] = $product->getData($gtinAttribute);
103 | }
104 |
105 | if ($brandAttribute) {
106 | $data['brand'] = [
107 | '@type' => self::SCHEMA_TYPE_BRAND,
108 | 'name' => $product->getData($brandAttribute)
109 | ];
110 | }
111 |
112 | if ($reviews->getSize()) {
113 | $data['review'] = [];
114 |
115 | foreach ($reviews as $review) {
116 | $data['review'][] = $this->addReviewEntity($review);
117 | }
118 |
119 | $data['aggregateRating'] = $this->getAggregateRatingValue($product);
120 | }
121 |
122 | return $data;
123 | }
124 |
125 | public function isEnabled(): bool
126 | {
127 | return parent::isEnabled() &&
128 | $this->scopeConfig->isSetFlag(
129 | self::XML_PATH_PRODUCT_ENABLED,
130 | ScopeInterface::SCOPE_STORE
131 | );
132 | }
133 |
134 | private function getProduct(): ?ProductModel
135 | {
136 | return $this->registry->registry('current_product');
137 | }
138 |
139 | private function getProductImage(ProductModel $product): string
140 | {
141 | return $this->imageHelper
142 | ->init($product, 'product_base_image')
143 | ->getUrl();
144 | }
145 |
146 | private function getProductAttribute(string $attribute): ?string
147 | {
148 | return $this->scopeConfig->getValue(
149 | $attribute,
150 | ScopeInterface::SCOPE_STORE
151 | );
152 | }
153 |
154 | private function getProductReviews(ProductModel $product): Collection
155 | {
156 | $reviewLimit = $this->getReviewLimit();
157 |
158 | /** @var Collection $collection */
159 | $collection = $this->reviewCollectionFactory->create();
160 | $collection->addStatusFilter(Review::STATUS_APPROVED)
161 | ->addEntityFilter('product', $product->getId())
162 | ->setDateOrder()
163 | ->addRateVotes();
164 |
165 | if ($reviewLimit > 0) {
166 | $collection->setPageSize($reviewLimit)
167 | ->setCurPage(1);
168 | }
169 |
170 | return $collection;
171 | }
172 |
173 | private function addReviewEntity(Review $review): array
174 | {
175 | $item = [
176 | '@type' => self::SCHEMA_TYPE_REVIEW,
177 | 'author' => $review->getData('nickname'),
178 | 'datePublished' => $review->getCreatedAt(),
179 | 'reviewBody' => $review->getData('detail'),
180 | 'name' => $review->getData('title')
181 | ];
182 |
183 | if ($review->getData('rating_votes')) {
184 | $item['reviewRating'] = [
185 | '@type' => self::SCHEMA_TYPE_RATING,
186 | 'bestRating' => self::RATINGS_BEST_RATING,
187 | 'ratingValue' => round($this->calculateRatingValue($review), 2),
188 | 'worstRating' => 1
189 | ];
190 | }
191 |
192 | return $item;
193 | }
194 |
195 | private function calculateRatingValue(Review $review): float
196 | {
197 | $value = 0;
198 |
199 | /** @var Vote $vote */
200 | foreach ($review->getData('rating_votes') as $vote) {
201 | $value += $vote->getData('percent');
202 | }
203 |
204 | return self::RATINGS_BEST_RATING / 100 * ($value / count($review->getData('rating_votes')));
205 | }
206 |
207 | /**
208 | * @throws NoSuchEntityException
209 | */
210 | private function getAggregateRatingValue(ProductModel $product): array
211 | {
212 | /** @var Review $review */
213 | $review = $this->reviewFactory->create();
214 |
215 | /** @var Store $store */
216 | $store = $this->storeManager->getStore();
217 |
218 | $review->getEntitySummary($product, $store->getId());
219 |
220 | /** @var DataObject $ratingSummary */
221 | $ratingSummary = $product->getData('rating_summary');
222 |
223 | return [
224 | '@type' => self::SCHEMA_TYPE_AGGREGATE_RATING,
225 | 'ratingValue' => self::RATINGS_BEST_RATING / 100 * $ratingSummary->getData('rating_summary'),
226 | 'reviewCount' => $ratingSummary->getData('reviews_count')
227 | ];
228 | }
229 |
230 | private function getReviewLimit(): int
231 | {
232 | return (int) $this->scopeConfig->getValue(
233 | self::XML_PATH_REVIEW_LIMIT,
234 | ScopeInterface::SCOPE_STORE
235 | );
236 | }
237 | }
238 |
--------------------------------------------------------------------------------
/src/ViewModel/Schema/SchemaInterface.php:
--------------------------------------------------------------------------------
1 | urlBuilder = $urlBuilder;
30 | $this->searchHelper = $searchHelper;
31 | }
32 |
33 | public function getStructuredData(): array
34 | {
35 | return [
36 | '@context' => self::SCHEMA_CONTEXT,
37 | '@type' => self::SCHEMA_TYPE_WEBSITE,
38 | 'url' => $this->urlBuilder->getBaseUrl(),
39 | 'potentialAction' => $this->getPotentialActionData()
40 | ];
41 | }
42 |
43 | private function getPotentialActionData(): array
44 | {
45 | return [
46 | '@type' => self::SCHEMA_TYPE_SEARCH_ACTION,
47 | 'target' => $this->searchHelper->getResultUrl() . '?q={search_term_string}',
48 | 'query-input' => 'required name=search_term_string'
49 | ];
50 | }
51 |
52 | public function isEnabled(): bool
53 | {
54 | return parent::isEnabled() &&
55 | $this->scopeConfig->isSetFlag(
56 | self::XML_PATH_WEBSITE_ENABLED,
57 | ScopeInterface::SCOPE_STORE
58 | );
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/etc/adminhtml/config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 0
7 |
8 |
9 | 0
10 | 0
11 |
12 |
13 | 0
14 |
15 |
16 | 0
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/etc/adminhtml/system.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
10 |
11 |
12 |
13 |
14 |
15 | elgentos
16 |
17 | Elgentos_StructuredData::config_structureddata
18 |
19 |
20 |
21 |
22 | Magento\Config\Model\Config\Source\Yesno
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | Magento\Config\Model\Config\Source\Yesno
31 |
32 |
33 |
34 | Magento\Config\Model\Config\Source\Yesno
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | Magento\Config\Model\Config\Source\Yesno
43 |
44 |
45 |
46 | Elgentos\StructuredData\Model\Config\Source\ProductAttributes
47 |
48 |
49 |
50 |
51 | Elgentos\StructuredData\Model\Config\Source\ProductAttributes
52 |
53 |
54 |
55 | validate-zero-or-greater
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | Magento\Config\Model\Config\Source\Yesno
64 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/src/etc/config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 0
7 |
8 |
9 | 0
10 | 0
11 |
12 |
13 | 0
14 |
15 |
16 |
17 |
18 | 0
19 |
20 | 10
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/etc/module.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/registration.php:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
8 |
9 | Elgentos\StructuredData\ViewModel\Schema\Breadcrumbs
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/view/frontend/layout/catalog_product_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
8 |
9 | Elgentos\StructuredData\ViewModel\Schema\Breadcrumbs
10 |
11 |
12 |
14 |
15 | Elgentos\StructuredData\ViewModel\Schema\Product
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/view/frontend/layout/cms_index_index.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
8 |
9 | Elgentos\StructuredData\ViewModel\Schema\Organization
10 |
11 |
12 |
14 |
15 | Elgentos\StructuredData\ViewModel\Schema\Website
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/view/frontend/templates/schema.phtml:
--------------------------------------------------------------------------------
1 | getData('structured_data_view_model');
9 |
10 | use Elgentos\StructuredData\Block\Schema\SchemaInterface;
11 | ?>
12 | isEnabled()): ?>
13 |
16 |
--------------------------------------------------------------------------------
/tests/Model/Config/Source/ProductAttributesTest.php:
--------------------------------------------------------------------------------
1 | createMock(Collection::class);
27 | $collection->expects(self::any())
28 | ->method('getIterator')
29 | ->willReturn(
30 | new ArrayIterator(
31 | [$this->createMock(Attribute::class)]
32 | )
33 | );
34 |
35 | $collectionFactory = $this->getMockBuilder(CollectionFactory::class)
36 | ->disableOriginalConstructor()
37 | ->allowMockingUnknownTypes()
38 | ->setMethods(['create'])
39 | ->getMock();
40 |
41 | $collectionFactory->expects(self::once())
42 | ->method('create')
43 | ->willReturn($collection);
44 |
45 | $subject = new ProductAttributes($collectionFactory);
46 |
47 | $subject->toOptionArray();
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/tests/ViewModel/Schema/AbstractSchemaTest.php:
--------------------------------------------------------------------------------
1 | createAbstractSchemaMock($structuredData);
30 |
31 | $this->assertEquals(
32 | json_encode($structuredData),
33 | $subject->getSerializedData()
34 | );
35 | }
36 |
37 | private function createAbstractSchemaMock(array $structuredData): AbstractSchema
38 | {
39 | $serializer = $this->createMock(Json::class);
40 | $serializer->expects(self::once())
41 | ->method('serialize')
42 | ->willReturn(json_encode($structuredData));
43 |
44 | return new class (
45 | $this->createMock(ScopeConfigInterface::class),
46 | $serializer
47 | ) extends AbstractSchema {
48 | public function __construct(ScopeConfigInterface $scopeConfig, Json $serializer)
49 | {
50 | parent::__construct($scopeConfig, $serializer);
51 | }
52 |
53 | public function getStructuredData(): array
54 | {
55 | return [];
56 | }
57 | };
58 | }
59 |
60 | public function setStructuredData(): array
61 | {
62 | return [
63 | [],
64 | [
65 | ['@type' => 'WebSite', '@context' => 'https://schema.org/WebSite']
66 | ]
67 | ];
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/tests/ViewModel/Schema/BreadcrumbsTest.php:
--------------------------------------------------------------------------------
1 | createMock(UrlInterface::class);
32 | $urlBuilder->expects(empty($breadcrumbs) ? self::never() : self::once())
33 | ->method('getCurrentUrl')
34 | ->willReturn('https://domain.com/');
35 |
36 | $urlBuilder->expects(self::once())
37 | ->method('getBaseUrl')
38 | ->willReturn('https://domain.com/');
39 |
40 | $catalogData = $this->createMock(Data::class);
41 | $catalogData->expects(self::once())
42 | ->method('getBreadcrumbPath')
43 | ->willReturn($breadcrumbs);
44 |
45 | $subject = new Breadcrumbs(
46 | $this->createMock(ScopeConfigInterface::class),
47 | $this->createMock(Json::class),
48 | $urlBuilder,
49 | $catalogData
50 | );
51 |
52 | $result = $subject->getStructuredData();
53 | $this->assertCount(count($breadcrumbs) + 1, $result['itemListElement']);
54 | }
55 |
56 | public function breadcrumbsDataProvider(): array
57 | {
58 | return [
59 | [],
60 | [
61 | [
62 | ['link' => 'https://domain.com', 'label' => 'Category 1'],
63 | ['link' => 'https://domain.com', 'label' => 'Category 2'],
64 | ['link' => 'https://domain.com', 'label' => 'Category 3'],
65 | ['label' => 'Category 4'],
66 | ]
67 | ]
68 | ];
69 | }
70 |
71 | public function testIsEnabled()
72 | {
73 | $scopeConfig = $this->createMock(ScopeConfigInterface::class);
74 | $scopeConfig->expects(self::any())
75 | ->method('isSetFlag')
76 | ->willReturn(true);
77 |
78 | $subject = new Breadcrumbs(
79 | $scopeConfig,
80 | $this->createMock(Json::class),
81 | $this->createMock(UrlInterface::class),
82 | $this->createMock(Data::class)
83 | );
84 |
85 | $this->assertTrue($subject->isEnabled());
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/tests/ViewModel/Schema/OrganizationTest.php:
--------------------------------------------------------------------------------
1 | createMock(ScopeConfigInterface::class);
30 | $scopeConfig->expects(self::any())
31 | ->method('getValue')
32 | ->willReturn('random string');
33 |
34 | $logo = $this->createMock(Logo::class);
35 | $logo->expects(self::once())
36 | ->method('getLogoSrc')
37 | ->willReturn('https://domain.com/logo.svg');
38 |
39 | $subject = new Organization(
40 | $scopeConfig,
41 | $this->createMock(Json::class),
42 | $this->createMock(UrlInterface::class),
43 | $logo
44 | );
45 |
46 | $subject->getStructuredData();
47 | }
48 |
49 | /**
50 | * @return void
51 | *
52 | * @covers ::isEnabled
53 | */
54 | public function testIsEnabled(): void
55 | {
56 | $scopeConfig = $this->createMock(ScopeConfigInterface::class);
57 | $scopeConfig->expects(self::any())
58 | ->method('isSetFlag')
59 | ->willReturn(true);
60 |
61 | $subject = new Organization(
62 | $scopeConfig,
63 | $this->createMock(Json::class),
64 | $this->createMock(UrlInterface::class),
65 | $this->createMock(Logo::class)
66 | );
67 |
68 | $this->assertTrue($subject->isEnabled());
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/tests/ViewModel/Schema/ProductTest.php:
--------------------------------------------------------------------------------
1 | createMock(ScopeConfigInterface::class);
40 | $scopeConfig->expects(self::any())
41 | ->method('isSetFlag')
42 | ->willReturn(true);
43 |
44 | $subject = new Product(
45 | $scopeConfig,
46 | $this->createMock(Json::class),
47 | $this->createMock(Registry::class),
48 | $this->createMock(Image::class),
49 | $this->createReviewCollectionFactoryMock(false),
50 | $this->createReviewFactoryMock(false),
51 | $this->createMock(StoreManagerInterface::class)
52 | );
53 |
54 | $this->assertTrue($subject->isEnabled());
55 | }
56 |
57 | /**
58 | * @dataProvider structuredDataDataProvider
59 | *
60 | * @throws NoSuchEntityException
61 | */
62 | public function testGetStructuredData(
63 | bool $hasValidProduct = true,
64 | bool $productIsSalable = true
65 | ): void {
66 | $registry = $this->createMock(Registry::class);
67 | $registry->expects(self::once())
68 | ->method('registry')
69 | ->willReturn(
70 | $this->createProductModelMock($hasValidProduct, $productIsSalable)
71 | );
72 |
73 | $imageHelper = $this->createMock(Image::class);
74 | $imageHelper->expects($hasValidProduct ? self::once() : self::never())
75 | ->method('init')
76 | ->willReturn($imageHelper);
77 |
78 | $imageHelper->expects($hasValidProduct ? self::once() : self::never())
79 | ->method('getUrl')
80 | ->willReturn('https://domain.com/image.jpg');
81 |
82 | $storeManager = $this->createMock(StoreManagerInterface::class);
83 | $storeManager->expects($hasValidProduct ? self::any() : self::never())
84 | ->method('getStore')
85 | ->willReturn($this->createMock(Store::class));
86 |
87 | $scopeConfig = $this->createMock(ScopeConfigInterface::class);
88 | $scopeConfig->expects(self::any())
89 | ->method('getValue')
90 | ->withConsecutive(
91 | [Product::XML_PATH_ATTRIBUTE_GTIN, ScopeInterface::SCOPE_STORE],
92 | [Product::XML_PATH_ATTRIBUTE_BRAND, ScopeInterface::SCOPE_STORE],
93 | [Product::XML_PATH_REVIEW_LIMIT, ScopeInterface::SCOPE_STORE]
94 | )
95 | ->willReturnOnConsecutiveCalls('gtin', 'brand', 3);
96 |
97 | $subject = new Product(
98 | $scopeConfig,
99 | $this->createMock(Json::class),
100 | $registry,
101 | $imageHelper,
102 | $this->createReviewCollectionFactoryMock($hasValidProduct),
103 | $this->createReviewFactoryMock($hasValidProduct),
104 | $storeManager
105 | );
106 |
107 | $subject->getStructuredData();
108 | }
109 |
110 | private function createReviewCollectionFactoryMock(bool $factoryIsCalled = true): CollectionFactory
111 | {
112 | $collection = $this->createReviewCollectionMock($factoryIsCalled);
113 | $review = $this->createMock(Review::class);
114 | $review->expects(self::any())
115 | ->method('getData')
116 | ->willReturn([$this->createMock(Vote::class)]);
117 |
118 | $collection->expects($factoryIsCalled ? self::once() : self::never())
119 | ->method('getIterator')
120 | ->willReturn(new ArrayIterator([$review]));
121 |
122 | $factory = $this->getMockBuilder(CollectionFactory::class)
123 | ->allowMockingUnknownTypes()
124 | ->disableOriginalConstructor()
125 | ->setMethods(['create'])
126 | ->getMock();
127 |
128 | $factory->expects($factoryIsCalled ? self::once() : self::never())
129 | ->method('create')
130 | ->willReturn($collection);
131 |
132 | return $factory;
133 | }
134 |
135 | private function createReviewFactoryMock(bool $factoryIsCalled = true): ReviewFactory
136 | {
137 | $reviewModel = $this->createMock(Review::class);
138 | $reviewModel->expects($factoryIsCalled ? self::any() : self::never())
139 | ->method('getEntitySummary');
140 |
141 | $factory = $this->getMockBuilder(ReviewFactory::class)
142 | ->allowMockingUnknownTypes()
143 | ->disableOriginalConstructor()
144 | ->setMethods(['create'])
145 | ->getMock();
146 |
147 | $factory->expects($factoryIsCalled ? self::any() : self::never())
148 | ->method('create')
149 | ->willReturn($reviewModel);
150 |
151 | return $factory;
152 | }
153 |
154 | private function createProductModelMock(
155 | bool $hasValidProduct,
156 | bool $productIsSalable
157 | ): ?ProductModel {
158 | if (!$hasValidProduct) {
159 | return null;
160 | }
161 |
162 | $productModel = $this->createMock(ProductModel::class);
163 | $productModel->expects(self::any())
164 | ->method('getId')
165 | ->willReturn(1);
166 |
167 | $productModel->expects(self::once())
168 | ->method('isSalable')
169 | ->willReturn($productIsSalable);
170 |
171 | $productModel->expects(self::any())
172 | ->method('getData')
173 | ->withConsecutive(
174 | ['description'],
175 | ['gtin'],
176 | ['brand'],
177 | ['rating_summary']
178 | )
179 | ->willReturnOnConsecutiveCalls(
180 | 'description',
181 | 'gtin',
182 | 'brand',
183 | new DataObject()
184 | );
185 |
186 | return $productModel;
187 | }
188 |
189 | public function structuredDataDataProvider(): array
190 | {
191 | return [
192 | [],
193 | [false]
194 | ];
195 | }
196 |
197 | private function createReviewCollectionMock(bool $factoryIsCalled): MockObject
198 | {
199 | $collection = $this->createMock(Collection::class);
200 | $collection->expects($factoryIsCalled ? self::once() : self::never())
201 | ->method('addStatusFilter')
202 | ->willReturn($collection);
203 |
204 | $collection->expects($factoryIsCalled ? self::once() : self::never())
205 | ->method('addEntityFilter')
206 | ->willReturn($collection);
207 |
208 | $collection->expects($factoryIsCalled ? self::once() : self::never())
209 | ->method('setDateOrder')
210 | ->willReturn($collection);
211 |
212 | $collection->expects($factoryIsCalled ? self::once() : self::never())
213 | ->method('addRateVotes')
214 | ->willReturn($collection);
215 |
216 | $collection->expects($factoryIsCalled ? self::once() : self::never())
217 | ->method('getSize')
218 | ->willReturn(2);
219 |
220 | $collection->expects($factoryIsCalled ? self::once() : self::never())
221 | ->method('setPageSize')
222 | ->willReturn($collection);
223 |
224 | $collection->expects($factoryIsCalled ? self::once() : self::never())
225 | ->method('setCurPage')
226 | ->willReturn($collection);
227 |
228 | return $collection;
229 | }
230 | }
231 |
--------------------------------------------------------------------------------
/tests/ViewModel/Schema/WebsiteTest.php:
--------------------------------------------------------------------------------
1 | createMock(UrlInterface::class);
27 | $urlBuilder->expects(self::once())
28 | ->method('getBaseUrl')
29 | ->willReturn('https://domain.com');
30 |
31 | $searchHelper = $this->createMock(SearchHelper::class);
32 | $searchHelper->expects(self::once())
33 | ->method('getResultUrl')
34 | ->willReturn('https://domain.com/catalogsearch/result');
35 |
36 | $subject = new Website(
37 | $this->createMock(ScopeConfigInterface::class),
38 | $this->createMock(Json::class),
39 | $urlBuilder,
40 | $searchHelper,
41 | );
42 |
43 | $subject->getStructuredData();
44 | }
45 |
46 | public function testIsEnabled()
47 | {
48 | $scopeConfig = $this->createMock(ScopeConfigInterface::class);
49 | $scopeConfig->expects(self::any())
50 | ->method('isSetFlag')
51 | ->willReturn(true);
52 |
53 | $subject = new Website(
54 | $scopeConfig,
55 | $this->createMock(Json::class),
56 | $this->createMock(UrlInterface::class),
57 | $this->createMock(SearchHelper::class),
58 | );
59 |
60 | $this->assertTrue($subject->isEnabled());
61 | }
62 | }
63 |
--------------------------------------------------------------------------------