├── .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 | --------------------------------------------------------------------------------