├── .gitignore ├── .gitpod.yml ├── Api └── OffersRepositoryInterface.php ├── Block ├── Adminhtml │ └── Form │ │ └── Field │ │ └── RelatedPage.php ├── Category.php ├── Cms.php ├── Jsonld.php ├── Organization.php └── Product.php ├── Helper └── Config.php ├── LICENSE ├── Model ├── Api │ └── OffersRepository.php ├── Cache │ └── Type │ │ └── StructuredDataCache.php ├── Config │ ├── Backend │ │ └── RelatedPage.php │ └── Source │ │ └── ContactType.php ├── Resolver │ └── Product │ │ └── StructuredData.php └── Type │ └── Product.php ├── Observer └── FlushStructuredDataCache.php ├── Plugin ├── Cache │ ├── BulkUpFlushStructuredCache.php │ └── FlushStructuredDataCache.php ├── EscapeStripTags.php └── StripReviewSummary.php ├── README.md ├── assets ├── config-cms.png ├── config-organization.png ├── config-product.png ├── outeredge-structured-data-module-user-guide.pdf └── screenshot-schema.png ├── composer.json ├── etc ├── acl.xml ├── adminhtml │ ├── di.xml │ └── system.xml ├── cache.xml ├── config.xml ├── di.xml ├── events.xml ├── module.xml ├── schema.graphqls └── webapi.xml ├── phpcs.xml ├── rector.php ├── registration.php └── view └── frontend ├── layout ├── catalog_category_view.xml ├── catalog_product_view.xml ├── catalog_product_view_type_bundle.xml ├── catalog_product_view_type_configurable.xml ├── catalog_product_view_type_downloadable.xml ├── catalog_product_view_type_grouped.xml ├── cms_index_index.xml ├── cms_page_view.xml ├── contact_index_index.xml ├── default.xml └── hyva_catalog_product_view.xml └── templates ├── html └── breadcrumbs.phtml ├── jsonld.phtml └── jsonld ├── cms.phtml ├── organization.phtml ├── product-offers.phtml ├── product-reviewsio.phtml └── schema.phtml /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | vendor/ -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: outeredge/edge-docker-php-dev:8.1 2 | 3 | vscode: 4 | extensions: 5 | - editorconfig.editorconfig 6 | - felixfbecker.php-debug 7 | - bmewburn.vscode-intelephense-client 8 | 9 | tasks: 10 | - init: composer install -------------------------------------------------------------------------------- /Api/OffersRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | addColumn('url', ['label' => __('Full URL'), 'class' => 'required-entry']); 12 | $this->_addAfter = false; 13 | $this->_addButtonLabel = __('Add Related Web Page'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Block/Category.php: -------------------------------------------------------------------------------- 1 | listProductBlock = $listProductBlock; 27 | 28 | parent::__construct($context, $data); 29 | } 30 | 31 | public function getSchemaJson() 32 | { 33 | $collection = $this->listProductBlock->getLoadedProductCollection(); 34 | return json_encode($this->getSchemaData($collection), JSON_UNESCAPED_SLASHES); 35 | } 36 | 37 | public function getSchemaData(AbstractCollection $productCollection) 38 | { 39 | if (!$this->_productCollection) { 40 | $this->_productCollection = $productCollection; 41 | } 42 | 43 | $listData = []; 44 | 45 | $i = 1; 46 | foreach ($this->_productCollection as $product) { 47 | $listData[] = [ 48 | "@context" => "https://schema.org/", 49 | "@type" => "ListItem", 50 | "position" => $i++, 51 | "url" => $product->getProductUrl() 52 | ]; 53 | } 54 | 55 | $data = [ 56 | "@type" => "ItemList", 57 | "itemListElement" => $listData 58 | ]; 59 | 60 | return $data; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Block/Cms.php: -------------------------------------------------------------------------------- 1 | _filterProvider = $filterProvider; 53 | 54 | parent::__construct( 55 | $context, 56 | $storeManager, 57 | $request, 58 | $page, 59 | $logo, 60 | $logoPathResolver, 61 | $data 62 | ); 63 | } 64 | 65 | public function getTitle() 66 | { 67 | $title = $this->getPageType() == Jsonld::PAGE_TYPE_WEBSITE ? $this->getConfig('general/store_information/name') . ' | ' : null; 68 | 69 | if ($this->getPage()->getContentHeading()) { 70 | return $title . nl2br($this->getPage()->getContentHeading()); 71 | } 72 | return $title . nl2br((string) $this->getPage()->getTitle()); 73 | } 74 | 75 | public function getContent() 76 | { 77 | if (!$this->_content) { 78 | $content = $this->_filterProvider->getPageFilter()->filter($this->getPage()->getContent()); 79 | $content = nl2br((string) $content); 80 | if ($content) { 81 | $content = preg_replace('/([\r\n\t])/', ' ', $content); 82 | } 83 | $this->_content = $content; 84 | } 85 | return $this->_content; 86 | } 87 | 88 | public function getMetaDescription() 89 | { 90 | if (!$this->_metaDescription) { 91 | $this->_metaDescription = nl2br((string) $this->getPage()->getMetaDescription()); 92 | } 93 | return $this->_metaDescription; 94 | } 95 | 96 | public function getImage() 97 | { 98 | if ($this->_image === null) { 99 | $this->_image = false; 100 | 101 | $imageTypeArray = [ 102 | 'image', 103 | 'small_image', 104 | 'thumbnail', 105 | 'primary_image', 106 | 'secondary_image', 107 | 'tertiary_image' 108 | ]; 109 | 110 | foreach ($imageTypeArray as $image) { 111 | if ($this->getPage()->getData($image)) { 112 | $this->_image = $this->getPage()->getData($image); 113 | break; 114 | } 115 | } 116 | 117 | if (!$this->_image) { 118 | $this->setImageFromContent(); 119 | } 120 | 121 | if ($this->_image) { 122 | if (!preg_match('/^http/', $this->_image)) { 123 | if (preg_match('/^\/?media/', $this->_image)) { 124 | $this->_image = $this->getStore()->getBaseUrl() . $this->_image; 125 | } else { 126 | $this->_image = $this->getStore()->getBaseUrl(UrlInterface::URL_TYPE_MEDIA) . $this->_image; 127 | } 128 | } 129 | } 130 | } 131 | return $this->_image; 132 | } 133 | 134 | /** 135 | * Finds first image within CMS page content 136 | */ 137 | protected function setImageFromContent() 138 | { 139 | $content = $this->getContent(); 140 | 141 | if($content) { 142 | $doc = new DOMDocument(); 143 | 144 | libxml_use_internal_errors(true); 145 | $doc->loadHtml($content); 146 | 147 | $tags = $doc->getElementsByTagName('img'); 148 | if ($tags->length > 0) { 149 | foreach ($tags as $tag) { 150 | $this->_image = $tag->getAttribute('src'); 151 | break; 152 | } 153 | } 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Block/Jsonld.php: -------------------------------------------------------------------------------- 1 | setData('logoPathResolver', $logoPathResolver); 72 | 73 | $this->_logo = $logo; 74 | $this->_page = $page; 75 | $this->_storeManager = $storeManager; 76 | $this->_request = $request; 77 | $this->_pageType = null; 78 | 79 | parent::__construct($context, $data); 80 | } 81 | 82 | public function getConfig($config) 83 | { 84 | return $this->_scopeConfig->getValue($config, ScopeInterface::SCOPE_STORE); 85 | } 86 | 87 | public function getStore() 88 | { 89 | return $this->_storeManager->getStore(); 90 | } 91 | 92 | public function getPage() 93 | { 94 | return $this->_page; 95 | } 96 | 97 | public function getPageType() 98 | { 99 | if ($this->_pageType == null) { 100 | $module = $this->_request->getModuleName(); 101 | $controller = $this->_request->getControllerName(); 102 | 103 | switch ($module) { 104 | case 'catalog': 105 | if ($controller == 'product') { 106 | $this->_pageType = $this::PAGE_TYPE_ITEMPAGE; 107 | break; 108 | } 109 | $this->_pageType = $this::PAGE_TYPE_COLLECTIONPAGE; 110 | break; 111 | case 'catalogsearch': 112 | $this->_pageType = $this::PAGE_TYPE_SEARCHPAGE; 113 | break; 114 | case 'contact': 115 | $this->_pageType = $this::PAGE_TYPE_CONTACTPAGE; 116 | break; 117 | case 'checkout': 118 | $this->_pageType = $this::PAGE_TYPE_CHECKOUTPAGE; 119 | break; 120 | case 'cms': 121 | if ($controller == 'index') { 122 | $this->_pageType = $this::PAGE_TYPE_WEBSITE; 123 | break; 124 | } 125 | $this->_pageType = $this::PAGE_TYPE_WEBPAGE; 126 | break; 127 | default: 128 | $this->_pageType = $this::PAGE_TYPE_WEBPAGE; 129 | } 130 | 131 | $this->_pageType = $this->isAboutPage() ? $this::PAGE_TYPE_ABOUTPAGE : $this->_pageType; 132 | 133 | } 134 | return $this->_pageType; 135 | } 136 | 137 | public function getStoreLogoUrl() 138 | { 139 | return $this->_logo->getLogoSrc(); 140 | } 141 | 142 | public function isAboutPage() 143 | { 144 | $aboutPage = $this->getConfig('structureddata/cms/about_page'); 145 | if (empty($aboutPage)) { 146 | return false; 147 | } 148 | 149 | $currentPage = $this->getPage()->getIdentifier(); 150 | 151 | if ($this->getConfig('structureddata/cms/enable_about') && $currentPage == $aboutPage) { 152 | return true; 153 | } 154 | 155 | return false; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Block/Organization.php: -------------------------------------------------------------------------------- 1 | _scopeConfig->getValue($config, ScopeInterface::SCOPE_STORE); 29 | } 30 | 31 | public function getStore() 32 | { 33 | return $this->_storeManager->getStore(); 34 | } 35 | 36 | public function getStoreLogoUrl() 37 | { 38 | return $this->logo->getLogoSrc(); 39 | } 40 | 41 | public function isLocalBusiness() 42 | { 43 | return $this->getConfig('structureddata/contact/type') == 'LocalBusiness'; 44 | } 45 | 46 | public function getStreetAddress() 47 | { 48 | return implode(', ', array_map('trim', [ 49 | (string) $this->getConfig('general/store_information/street_line1'), 50 | (string) $this->getConfig('general/store_information/street_line2') 51 | ])); 52 | } 53 | 54 | public function getRelatedPages() 55 | { 56 | $relatedPages = $this->getConfig('structureddata/contact/related_pages'); 57 | $pages = $relatedPages ? $this->serializer->unserialize($relatedPages) : null; 58 | $result = null; 59 | 60 | if ($pages) { 61 | $result = []; 62 | foreach ($pages as $page) { 63 | $result[] = $this->_escaper->escapeUrl($page['url']); 64 | } 65 | $result = json_encode($result); 66 | } 67 | 68 | return $result; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Block/Product.php: -------------------------------------------------------------------------------- 1 | _productData = $productData; 57 | parent::__construct( 58 | $context, 59 | $urlEncoder, 60 | $jsonEncoder, 61 | $string, 62 | $productHelper, 63 | $productTypeConfig, 64 | $localeFormat, 65 | $customerSession, 66 | $productRepository, 67 | $priceCurrency, 68 | $data 69 | ); 70 | } 71 | 72 | public function getSchemaJson() 73 | { 74 | return json_encode($this->_productData->getSchemaData($this->getProduct()), JSON_UNESCAPED_SLASHES); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Helper/Config.php: -------------------------------------------------------------------------------- 1 | scopeConfig->getValue($config, ScopeInterface::SCOPE_STORE); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 outer/edge 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Model/Api/OffersRepository.php: -------------------------------------------------------------------------------- 1 | structuredDataProduct->getChildOffers($this->productRepository->getById($productId))]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Model/Cache/Type/StructuredDataCache.php: -------------------------------------------------------------------------------- 1 | get(self::TYPE_IDENTIFIER), 19 | self::CACHE_TAG 20 | ); 21 | } 22 | } -------------------------------------------------------------------------------- /Model/Config/Backend/RelatedPage.php: -------------------------------------------------------------------------------- 1 | serializer = $serializer; 29 | parent::__construct($context, $registry, $config, $cacheTypeList, $resource, $resourceCollection, $data); 30 | } 31 | 32 | public function beforeSave() 33 | { 34 | $value = $this->getValue(); 35 | unset($value['__empty']); 36 | 37 | $this->setValue($this->serializer->serialize($value)); 38 | } 39 | 40 | protected function _afterLoad() 41 | { 42 | $value = $this->getValue(); 43 | 44 | if ($value) { 45 | $this->setValue($this->serializer->unserialize($value)); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Model/Config/Source/ContactType.php: -------------------------------------------------------------------------------- 1 | 'Organization', 19 | 'label' => __('Organization') 20 | ], 21 | [ 22 | 'value' => 'LocalBusiness', 23 | 'label' => __('LocalBusiness') 24 | ] 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Model/Resolver/Product/StructuredData.php: -------------------------------------------------------------------------------- 1 | productData = $productData; 29 | } 30 | 31 | /** 32 | * @inheritdoc 33 | * 34 | * Get Schema.org Structured Data for products 35 | * 36 | * @param \Magento\Framework\GraphQl\Config\Element\Field $field 37 | * @param ContextInterface $context 38 | * @param ResolveInfo $info 39 | * @param array|null $value 40 | * @param array|null $args 41 | * @throws \Exception 42 | * @return array 43 | */ 44 | public function resolve( 45 | Field $field, 46 | $context, 47 | ResolveInfo $info, 48 | array $value = null, 49 | array $args = null 50 | ) { 51 | if (!$value['model'] instanceof Product) { 52 | throw new LocalizedException(__('"model" value should be specified')); 53 | } 54 | 55 | /** @var ProductInterface $product */ 56 | $product = $value['model']; 57 | 58 | return json_encode($this->productData->getSchemaData($product), JSON_UNESCAPED_SLASHES); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Model/Type/Product.php: -------------------------------------------------------------------------------- 1 | _product) { 111 | $this->_product = $product; 112 | } 113 | 114 | $data = [ 115 | "@context" => "https://schema.org/", 116 | "@type" => "Product", 117 | "@id" => $this->escapeUrl(strtok($this->_product->getUrlInStore(), '?'))."#Product", 118 | "name" => $this->escapeQuote((string)strip_tags($this->_product->getName())), 119 | "sku" => $this->escapeQuote((string)strip_tags($this->_product->getSku())), 120 | "description" => $this->escapeHtml((string)$this->template->stripTags($this->getDescription())), 121 | "image" => $this->escapeUrl(strip_tags($this->getImageUrl($this->_product, 'product_page_image_medium'))) 122 | ]; 123 | 124 | if ($this->getBrand()) { 125 | $data['brand'] = [ 126 | "@type" => "Brand", 127 | "name" => $this->escapeQuote((string)strip_tags($this->getBrand())) 128 | ]; 129 | } 130 | 131 | if ($this->_moduleManager->isEnabled('Magento_Review') && 132 | $this->getConfig('structureddata/product/include_reviews') && 133 | !$this->getConfig('structureddata/product/include_reviewsio') && 134 | $this->getReviewsCount() 135 | ){ 136 | $data['aggregateRating'] = [ 137 | "@type" => "AggregateRating", 138 | "bestRating" => "100", 139 | "worstRating" => "1", 140 | "ratingValue" => $this->escapeQuote((string)$this->getReviewsRating()), 141 | "reviewCount" => $this->escapeQuote((string)$this->getReviewsCount()) 142 | ]; 143 | 144 | $data['review'] = []; 145 | foreach ($this->getReviewCollection() as $review) { 146 | $votes = $this->getVoteCollection($review->getId()); 147 | 148 | $averageRating = 0; 149 | $ratingCount = count($votes); 150 | foreach ($votes as $vote) { 151 | $averageRating = $averageRating + $vote->getValue(); 152 | } 153 | 154 | $reviewData = [ 155 | "@type" => "Review", 156 | "author" => [ 157 | "@type" => "Person", 158 | "name" => $this->escapeQuote((string)$review->getData('nickname')) 159 | ], 160 | "datePublished" => $this->escapeQuote($review->getCreatedAt()), 161 | "name" => $this->escapeQuote((string)$review->getTitle()), 162 | "reviewBody" => $this->escapeQuote((string)$review->getDetail()) 163 | ]; 164 | 165 | if ($ratingCount > 0) { 166 | $finalRating = $averageRating / $ratingCount; 167 | 168 | $reviewData["reviewRating"] = [ 169 | "@type" => "Rating", 170 | "ratingValue" => $finalRating, 171 | "bestRating" => "5", 172 | "worstRating" => "1" 173 | ]; 174 | } 175 | 176 | $data['review'][] = $reviewData; 177 | } 178 | } 179 | 180 | if ($this->getGtin()) { 181 | $data['gtin'] = $this->escapeQuote((string)strip_tags($this->getGtin())); 182 | } 183 | 184 | if ($this->getMpn()) { 185 | $data['mpn'] = $this->escapeQuote((string)strip_tags($this->getMpn())); 186 | } 187 | 188 | if ($this->getIsbn()) { 189 | $data['isbn'] = $this->escapeQuote((string)strip_tags($this->getIsbn())); 190 | } 191 | 192 | if ($this->getSize()) { 193 | $data['size'] = $this->escapeQuote((string)strip_tags($this->getSize())); 194 | } 195 | 196 | if ($this->getMaterial()) { 197 | $data['material'] = $this->escapeQuote((string)strip_tags($this->getMaterial())); 198 | } 199 | 200 | if ($this->getColor()) { 201 | $data['color'] = $this->escapeQuote((string)strip_tags($this->getColor())); 202 | } 203 | 204 | if ($this->getKeywords()) { 205 | $data['keywords'] = $this->escapeQuote((string)strip_tags($this->getKeywords())); 206 | } 207 | 208 | if ($this->_product->getTypeId() == \Magento\Catalog\Model\Product\Type::TYPE_SIMPLE 209 | || !$this->getConfig('structureddata/product/include_children') 210 | ) { 211 | $this->weight = $this->_product->getWeight(); 212 | $data = $this->includeWeight($data); 213 | $data['offers'] = $this->getOffer($this->_product); 214 | } 215 | 216 | return $data; 217 | } 218 | 219 | protected function includeWeight($data) 220 | { 221 | if ($this->getConfig('structureddata/product/include_weight')) { 222 | $data['weight'] = [ 223 | '@type' => "QuantitativeValue", 224 | 'unitText' => $this->escapeQuote($this->getConfig('general/locale/weight_unit')) 225 | ]; 226 | 227 | if ($this->weight !== null) { 228 | $data['weight']['value'] = $this->weight; 229 | } 230 | if ($this->minWeight !== null) { 231 | $data['weight']['minValue'] = $this->minWeight; 232 | } 233 | if ($this->maxWeight !== null) { 234 | $data['weight']['maxValue'] = $this->maxWeight; 235 | } 236 | } 237 | 238 | return $data; 239 | } 240 | 241 | public function getChildOffers($product) 242 | { 243 | $this->_product = $product; 244 | 245 | $children = $this->getChildren(); 246 | if ($children) { 247 | $offers = $data = []; 248 | $lastKey = key(array_slice($children, -1, 1, true)); 249 | 250 | foreach ($children as $key => $_childProduct) { 251 | $productFinalPrice = $_childProduct->getFinalPrice(); 252 | $productWeight = $_childProduct->getWeight(); 253 | 254 | $this->minPrice = $productFinalPrice < $this->minPrice || $this->minPrice === null ? $productFinalPrice : $this->minPrice; 255 | $this->maxPrice = $productFinalPrice > $this->maxPrice || $this->maxPrice === null ? $productFinalPrice : $this->maxPrice; 256 | 257 | $this->minWeight = $productWeight < $this->minWeight || $this->minWeight === null ? $productWeight : $this->minWeight; 258 | $this->maxWeight = $productWeight > $this->maxWeight || $this->maxWeight === null ? $productWeight : $this->maxWeight; 259 | 260 | $offers[] = $this->getOffer($_childProduct); 261 | $offers[$key]['sku'] = $_childProduct->getSku(); 262 | 263 | if ($_childProduct->getVisibility() == Visibility::VISIBILITY_NOT_VISIBLE) { 264 | $offers[$key]['url'] = $this->_product->getProductUrl(); 265 | } 266 | $key == $lastKey ? '' : ','; 267 | } 268 | 269 | $data['offers'] = [ 270 | '@type' => "AggregateOffer", 271 | 'priceCurrency' => $this->escapeQuote($this->getStore()->getCurrentCurrency()->getCode()), 272 | 'offerCount' => is_countable($children) ? count($children) : 0, 273 | 'offers' => $offers 274 | ]; 275 | 276 | if ($this->minWeight == $this->maxWeight) { 277 | $this->weight = $this->minWeight; 278 | $this->minWeight = null; 279 | $this->maxWeight = null; 280 | } 281 | 282 | if ($product->getTypeId() == 'bundle') { 283 | $rangeBundle = $this->getBundlePriceRange($this->_product->getId()); 284 | $this->minPrice = $rangeBundle['minPrice']->getValue(); 285 | $this->maxPrice = $rangeBundle['maxPrice']->getValue(); 286 | } 287 | 288 | $minPricewithTax = $this->taxHelper->getTaxPrice($this->_product, $this->minPrice, $this->checkTaxIncluded()); 289 | $maxPricewithTax = $this->taxHelper->getTaxPrice($this->_product, $this->maxPrice, $this->checkTaxIncluded()); 290 | 291 | $data['offers']['lowPrice'] = $this->escapeQuote((string)$this->pricingHelper->currency($minPricewithTax, false, false)); 292 | $data['offers']['highPrice'] = $this->escapeQuote((string)$this->pricingHelper->currency($maxPricewithTax, false, false)); 293 | $data = $this->includeWeight($data); 294 | } 295 | 296 | return $data; 297 | } 298 | 299 | public function getOffer(ProductModel $product) 300 | { 301 | if ($result = $this->getCache($product->getId())) { 302 | return $result; 303 | } 304 | 305 | $availability = 'OutOfStock'; 306 | $product = $this->productRepository->getById($product->getId()); 307 | $quantityAvailable = $this->_stockState->getStockQty($product->getId()); 308 | $backorderStatus = null; 309 | 310 | if ($stockItem = $product->getExtensionAttributes()->getStockItem()) { 311 | $backorderStatus = $stockItem->getBackorders(); 312 | } 313 | 314 | if ($product->isAvailable()) { 315 | $availability = 'InStock'; 316 | 317 | if ($quantityAvailable <= 0 && $backorderStatus == Stock::BACKORDERS_YES_NOTIFY) { 318 | $availability = 'BackOrder'; 319 | } 320 | } 321 | 322 | $pricewithTax = $this->taxHelper->getTaxPrice($product, $product->getFinalPrice(), $this->checkTaxIncluded()); 323 | 324 | $data = [ 325 | "@type" => "Offer", 326 | "url" => $this->escapeUrl(strtok($product->getUrlInStore(), '?')), 327 | "price" => $this->escapeQuote((string)$this->pricingHelper->currency($pricewithTax, false, false)), 328 | "priceCurrency" => $this->escapeQuote($this->getStore()->getCurrentCurrency()->getCode()), 329 | "availability" => "http://schema.org/$availability", 330 | "itemCondition" => "http://schema.org/NewCondition", 331 | "priceSpecification" => [ 332 | "@type" => "UnitPriceSpecification", 333 | "price" => $this->escapeQuote((string)$this->pricingHelper->currency($pricewithTax, false, false)), 334 | "priceCurrency" => $this->escapeQuote($this->getStore()->getCurrentCurrency()->getCode()), 335 | "valueAddedTaxIncluded" => $this->escapeQuote($this->checkTaxIncluded() ? 'true' : 'false') 336 | ] 337 | ]; 338 | 339 | if ($product->getFinalPrice() < $product->getPrice() && $product->getSpecialToDate()) { 340 | $priceToDate = date_create($product->getSpecialToDate()); 341 | $data['priceValidUntil'] = $this->escapeQuote($priceToDate->format('Y-m-d')); 342 | } 343 | 344 | $this->saveCache($product->getId(), $data); 345 | return $data; 346 | } 347 | 348 | public function getDescription() 349 | { 350 | if ($this->getConfig('structureddata/product/use_short_description') 351 | && $this->_product->getShortDescription()) { 352 | $description = nl2br($this->_product->getShortDescription()); 353 | } else { 354 | $description = nl2br((string) $this->_product->getDescription()); 355 | } 356 | 357 | if ($description) { 358 | $description = preg_replace('/([\r\n\t])/', ' ', $description); 359 | } 360 | 361 | return substr($description, 0, 5000); 362 | } 363 | 364 | public function getBrand() 365 | { 366 | if ($this->brand === null) { 367 | if ($value = $this->getBrandFieldFromConfig()) { 368 | if ($this->_product->getData($value)) { 369 | $this->brand = $this->getAttributeText($value); 370 | } 371 | } 372 | 373 | if ($this->brand === null) { 374 | if ($this->_product->getBrand()) { 375 | $this->brand = $this->getAttributeText('brand'); 376 | } elseif ($this->_product->getManufacturer()) { 377 | $this->brand = $this->getAttributeText('manufacturer'); 378 | } else { 379 | $this->brand = false; 380 | } 381 | } 382 | } 383 | 384 | return $this->brand; 385 | } 386 | 387 | public function getGtin() 388 | { 389 | if ($field = $this->getConfig('structureddata/product/product_gtin_field')) { 390 | if (!empty($this->_product->getData($field))) { 391 | return $this->getAttributeText($field); 392 | } 393 | } 394 | return false; 395 | } 396 | 397 | public function getMpn() 398 | { 399 | if ($field = $this->getConfig('structureddata/product/field_mpn')) { 400 | if (!empty($this->_product->getData($field))) { 401 | return $this->getAttributeText($field); 402 | } 403 | } 404 | return false; 405 | } 406 | 407 | public function getIsbn() 408 | { 409 | if ($field = $this->getConfig('structureddata/product/field_isbn')) { 410 | if (!empty($this->_product->getData($field))) { 411 | return $this->getAttributeText($field); 412 | } 413 | } 414 | return false; 415 | } 416 | 417 | public function getColor() 418 | { 419 | if ($field = $this->getConfig('structureddata/product/field_color')) { 420 | if (!empty($this->_product->getData($field))) { 421 | return $this->getAttributeText($field); 422 | } 423 | } elseif ($this->_product->getColor()) { // removing these lines will require a major version bump 424 | $data['color'] = $this->escapeQuote((string)$this->getAttributeText('color')); 425 | } elseif ($this->_product->getColour()) { 426 | $data['color'] = $this->escapeQuote((string)$this->getAttributeText('colour')); 427 | } 428 | 429 | return false; 430 | } 431 | 432 | public function getSize() 433 | { 434 | if ($field = $this->getConfig('structureddata/product/field_size')) { 435 | if (!empty($this->_product->getData($field))) { 436 | return $this->getAttributeText($field); 437 | } 438 | } 439 | return false; 440 | } 441 | 442 | public function getMaterial() 443 | { 444 | if ($field = $this->getConfig('structureddata/product/field_material')) { 445 | if (!empty($this->_product->getData($field))) { 446 | return $this->getAttributeText($field); 447 | } 448 | } 449 | return false; 450 | } 451 | 452 | public function getKeywords() 453 | { 454 | if ($field = $this->getConfig('structureddata/product/field_keywords')) { 455 | if (!empty($this->_product->getData($field))) { 456 | return $this->getAttributeText($field); 457 | } 458 | } 459 | return false; 460 | } 461 | 462 | /** 463 | * Retrieve product image 464 | * 465 | * @param \Magento\Catalog\Model\Product $product 466 | * @param string $imageId 467 | * @param array $attributes 468 | * @return string 469 | */ 470 | public function getImageUrl($product, $imageId) 471 | { 472 | return $this->imageHelper->init($product, $imageId)->getUrl(); 473 | } 474 | 475 | public function getAttributeText($attribute) 476 | { 477 | $attributeText = $this->_product->getAttributeText($attribute); 478 | if (is_array($attributeText)) { 479 | $attributeText = implode(', ', $attributeText); 480 | } 481 | return $attributeText; 482 | } 483 | 484 | public function getReviewsRating() 485 | { 486 | if ($data = $this->getYotpoProductSnippet()) { 487 | $ratingSummary = $data['average_score']; 488 | } else { 489 | $ratingSummary = !empty($this->getReviewData()) ? $this->getReviewData()->getRatingSummary() : 1; 490 | } 491 | 492 | return $ratingSummary; 493 | } 494 | 495 | public function getReviewsCount() 496 | { 497 | if ($this->reviewsCount === null) { 498 | if ($data = $this->getYotpoProductSnippet()) { 499 | $reviewCount = $data['reviews_count'] ?? null; 500 | } else { 501 | $reviewCount = !empty($this->getReviewData()) ? $this->getReviewData()->getReviewsCount() : null; 502 | } 503 | 504 | $this->reviewsCount = $reviewCount; 505 | } 506 | 507 | return $this->reviewsCount; 508 | } 509 | 510 | public function getReviewData() 511 | { 512 | if ($this->_reviewData === null) { 513 | $this->_reviewData = $this->_reviewSummaryFactory->create()->load($this->_product->getId()); 514 | } 515 | return $this->_reviewData; 516 | } 517 | 518 | public function getReviewCollection() 519 | { 520 | $collection = $this->_reviewCollectionFactory->create() 521 | ->addStatusFilter( 522 | \Magento\Review\Model\Review::STATUS_APPROVED 523 | )->addEntityFilter( 524 | 'product', 525 | $this->_product->getId() 526 | )->setDateOrder(); 527 | 528 | return $collection; 529 | } 530 | 531 | public function getVoteCollection($reviewId) 532 | { 533 | $collection = $this->_ratingOptionVoteFactory->create() 534 | ->addFilter('review_id', $reviewId); 535 | 536 | return $collection; 537 | } 538 | 539 | public function getYotpoProductSnippet() 540 | { 541 | if ($this->_moduleManager->isOutputEnabled('Yotpo_Yotpo') && 542 | $this->_moduleManager->isEnabled('Yotpo_Yotpo') && 543 | $this->getConfig('yotpo/settings/active') == true 544 | ) { 545 | return ObjectManager::getInstance()->create('Yotpo\Yotpo\Model\Api\Products')->getRichSnippet(); 546 | } 547 | 548 | return false; 549 | } 550 | 551 | protected function getChildren() 552 | { 553 | if (!$this->getConfig('structureddata/product/include_children')) { 554 | return []; 555 | } 556 | 557 | if ($this->_product->getTypeId() == \Magento\Bundle\Model\Product\Type::TYPE_CODE) { 558 | $children = []; 559 | $productsIds = $this->_product->getTypeInstance()->getChildrenIds($this->_product->getId(), true); 560 | foreach ($productsIds as $product) { 561 | if ($child = $this->loadProduct(reset($product))) { 562 | $children[] = $child; 563 | } 564 | } 565 | return $children; 566 | } 567 | 568 | if ($this->_product->getTypeId() == \Magento\GroupedProduct\Model\Product\Type\Grouped::TYPE_CODE) { 569 | $children = []; 570 | $products = $this->_product->getTypeInstance()->getAssociatedProducts($this->_product); 571 | foreach ($products as $product) { 572 | $children[] = $product; 573 | } 574 | return $children; 575 | } 576 | 577 | if ($this->_product->getTypeId() 578 | != \Magento\ConfigurableProduct\Model\Product\Type\Configurable::TYPE_CODE) { 579 | return []; 580 | } 581 | 582 | return $this->_product->getTypeInstance()->getUsedProducts($this->_product); 583 | } 584 | 585 | public function getBrandFieldFromConfig() 586 | { 587 | if ($value = $this->getConfig('structureddata/product/product_brand_field')) { 588 | return $value; 589 | } 590 | return false; 591 | } 592 | 593 | public function getBundlePriceRange($productId) 594 | { 595 | $bundleObj = $this->loadProduct($productId) 596 | ->getPriceInfo() 597 | ->getPrice('final_price'); 598 | 599 | return [ 600 | 'minPrice' => $bundleObj->getMinimalPrice(), 601 | 'maxPrice' => $bundleObj->getMaximalPrice() 602 | ]; 603 | } 604 | 605 | public function checkTaxIncluded() 606 | { 607 | $taxDisplayType = $this->getConfig('tax/display/type'); 608 | if ($taxDisplayType == 2 || $taxDisplayType == 3) { 609 | return true; 610 | } else { 611 | return false; 612 | } 613 | } 614 | 615 | public function getStore() 616 | { 617 | return $this->_storeManager->getStore(); 618 | } 619 | 620 | public function getConfig($config) 621 | { 622 | return $this->_scopeConfig->getValue($config, ScopeInterface::SCOPE_STORE); 623 | } 624 | 625 | public function loadProduct($id) 626 | { 627 | return $this->_productFactory->create()->load($id); 628 | } 629 | 630 | /** 631 | * Escape URL 632 | * 633 | * @param string $string 634 | * @return string 635 | */ 636 | public function escapeUrl($string) 637 | { 638 | return $this->_escaper->escapeUrl((string)$string); 639 | } 640 | 641 | /** 642 | * Escape HTML entities 643 | * 644 | * @param string|array $data 645 | * @param array|null $allowedTags 646 | * @return string 647 | */ 648 | public function escapeHtml($data, $allowedTags = null) 649 | { 650 | return $this->_escaper->escapeHtml($data, $allowedTags); 651 | } 652 | 653 | public function escapeQuote($data) 654 | { 655 | return htmlspecialchars($data, ENT_QUOTES | ENT_SUBSTITUTE, null, false); 656 | } 657 | 658 | protected function getCacheId($productId) 659 | { 660 | return StructuredDataCache::TYPE_IDENTIFIER . '_' . $this->getStore()->getId() . '_' . $productId; 661 | } 662 | 663 | protected function saveCache($productId, $data) 664 | { 665 | $this->cache->save( 666 | $this->serializer->serialize($data), 667 | $this->getCacheId($productId), 668 | [StructuredDataCache::CACHE_TAG] 669 | ); 670 | } 671 | 672 | protected function getCache($productId) 673 | { 674 | if ($result = $this->cache->load($this->getCacheId($productId))) { 675 | return $this->serializer->unserialize($result); 676 | } 677 | return false; 678 | } 679 | } 680 | -------------------------------------------------------------------------------- /Observer/FlushStructuredDataCache.php: -------------------------------------------------------------------------------- 1 | getEvent()->getObject(); 23 | 24 | if ($object instanceof Product && $object->hasDataChanges()) { 25 | $cacheId = StructuredDataCache::TYPE_IDENTIFIER . '_' . $this->storeManager->getStore()->getId() . '_' . $object->getEntityId(); 26 | $this->cache->remove($cacheId); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Plugin/Cache/BulkUpFlushStructuredCache.php: -------------------------------------------------------------------------------- 1 | attributeHelper->getProductIds(); 24 | 25 | foreach ($selectedProductIds as $productId) { 26 | $cacheId = StructuredDataCache::TYPE_IDENTIFIER . '_' . $this->storeManager->getStore()->getId() . '_' . $productId; 27 | $this->cache->remove($cacheId); 28 | } 29 | 30 | return $originalReturn; 31 | 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Plugin/Cache/FlushStructuredDataCache.php: -------------------------------------------------------------------------------- 1 | tagsList)) { 24 | $prodId = str_replace($tagName, '', $tag); 25 | $cacheId = StructuredDataCache::TYPE_IDENTIFIER . '_' . $this->storeManager->getStore()->getId() . '_' . $prodId; 26 | $subject->remove($cacheId); 27 | } 28 | } 29 | } 30 | return null; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Plugin/EscapeStripTags.php: -------------------------------------------------------------------------------- 1 | ~Usi', '', $data); 13 | } 14 | return [$data, $allowableTags, $allowHtmlEntities]; 15 | } 16 | } -------------------------------------------------------------------------------- /Plugin/StripReviewSummary.php: -------------------------------------------------------------------------------- 1 | loadHTML($result); 18 | $xpath = new \DOMXPath($dom); 19 | $nodes = $xpath->query("//@itemprop|//@itemscope|//@itemtype"); 20 | foreach ($nodes as $node) { 21 | $node->parentNode->removeAttribute($node->nodeName); 22 | } 23 | 24 | return $dom->saveHTML(); 25 | } catch(\Exception $e) { 26 | return $result; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Packagist](https://img.shields.io/packagist/v/outeredge/magento-structured-data-module?style=for-the-badge)](https://packagist.org/packages/outeredge/magento-structured-data-module) 2 | [![Packagist](https://img.shields.io/packagist/dt/outeredge/magento-structured-data-module?style=for-the-badge)](https://packagist.org/packages/outeredge/magento-structured-data-module) 3 | [![Packagist](https://img.shields.io/packagist/dm/outeredge/magento-structured-data-module?style=for-the-badge)](https://packagist.org/packages/outeredge/magento-structured-data-module) 4 | 5 | # outer/edge Structured Data Module for Magento 2 6 | 7 | [Hyvä](https://hyva.io) and [Breeze](https://breezefront.com/) compatible. 8 | 9 | Our open source module allows you to quickly add structured data markup (also known as Rich Snippets) to any Magento 2 store by simply installing our module and setting a few configuration options. 10 | 11 | Once this module is installed you will have valid structured data in the source of your product, contact and CMS pages. For example: 12 | https://developers.google.com/search/docs/advanced/structured-data/product 13 | 14 | This will look similar to the below: 15 | 16 | ``` 17 | 53 | ``` 54 | 55 | The module provides the following structured data: 56 | 57 | ### Product Page (GraphQL available) 58 | 59 | * @type 60 | * @id 61 | * name 62 | * sku 63 | * description 64 | * image 65 | * weight 66 | * brand 67 | * aggregateRating 68 | * bestRating 69 | * worstRating 70 | * ratingValue 71 | * reviewCount 72 | * mpn 73 | * material 74 | * color 75 | * price 76 | * priceCurency 77 | * valueAddedTaxIncluded 78 | * availability 79 | * itemCondition 80 | * AggregateOffer 81 | * offers 82 | * highPrice 83 | * lowPrice 84 | 85 | ### Contact Page 86 | 87 | * @type 88 | * @id 89 | * name 90 | * image 91 | * address 92 | * telephone 93 | * email 94 | * url 95 | * geo 96 | 97 | ### CMS Page 98 | 99 | * name 100 | * mainContentOfPage 101 | * description 102 | * primaryImageOfPage 103 | 104 | ## Installation 105 | 106 | #### Install via Composer 107 | 108 | ``` 109 | composer require outeredge/magento-structured-data-module 110 | ``` 111 | 112 | #### Review configuration for Structure Data Module 113 | 114 | Configuration is available in `Stores > Configuration > outer/edge > Structured Data`. The following options are available: 115 | 116 | #### Products 117 | 118 | ![structured_data-product](/assets/config-product.png) 119 | 120 | * **Enable:** Enable or disable structured data on product pages. 121 | * **Use Short Description:** Use `short_description` attribute for the `description` markup. By default `description` will be used. 122 | * **Include ChildProducts:** Choose whether to include individual offer for each child (simple) product for structured data on configurable product pages. 123 | * **Include Product Weights:** Ad `weight` schema to product page structured data. 124 | * **Product Brand/Manufacturer field:** Choose which Magento attribute is used to populate the structured data values. 125 | - **Brand** (Default: `manufacturer` or `brand`) 126 | - **MPN** (Default: empty) 127 | - **ISBN** (Default: empty) 128 | - **Size** (Default: empty) 129 | - **GTIN** (Default: empty) 130 | - **Color** (Default: `Color` or `Colour`) 131 | - **Material** (Default: empty) 132 | - **Keywords** (Default: empty) 133 | 134 | #### CMS Pages 135 | 136 | ![structured_data-cms](/assets/config-cms.png) 137 | 138 | * **Enable:** Enable or disable structured data on CMS pages. 139 | * **Enable About Page:** Enable or disable `"@type": "AboutPage"`. 140 | * **About Page:** Select the CMS page for `"@type": "AboutPage"`. 141 | 142 | #### Organization 143 | 144 | ![structured_data-contact](/assets/config-organization.png) 145 | 146 | * **Type:** Select whether business in a Local Business or Organization. 147 | * **Latitude:** Specify latitude for local business. 148 | * **Longitude:** Specify longitude for local business. 149 | * **Enable on Home Page:** Enable or disable Organization structured data on Home page. 150 | * **Enable on Contact Page:** Enable or disable Organization structured data on Contact page. 151 | * **Related Pages** Populates "SameAs" property. Add links to related pages, for example Facebook, Linked In and other social media sites. 152 | 153 | Once the module is installed and configured you will find the schema markup in your source code: 154 | 155 | ![schema_screenshot](/assets/screenshot-schema.png) 156 | 157 | ## GraphQL 158 | 159 | Our structured data module provides for product schema to the built in Magento GraphGL endpoint. Simply request the `structured_data` field with your product data as per the example below and the data will be returned as a JSON array: 160 | 161 | ``` 162 | { 163 | products( 164 | filter: { 165 | ... 166 | } 167 | ) { 168 | items { 169 | sku 170 | name 171 | structured_data 172 | } 173 | } 174 | } 175 | ``` 176 | 177 | 178 | ## Uninstalling the module 179 | 180 | #### Remove via Composer 181 | 182 | ``` 183 | composer remove outeredge/magento-structured-data-module 184 | ``` 185 | 186 | ### References 187 | 188 | #### Google docs for structured data format (using JSON-LD format) 189 | https://developers.google.com/search/docs/guides/intro-structured-data 190 | https://developers.google.com/search/docs/advanced/structured-data/product 191 | 192 | #### Structured data syntax is based on 193 | http://schema.org/ 194 | -------------------------------------------------------------------------------- /assets/config-cms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/outeredge/magento-structured-data-module/d84dae66576d1227dcd4bc02c7a2b61290ed65c5/assets/config-cms.png -------------------------------------------------------------------------------- /assets/config-organization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/outeredge/magento-structured-data-module/d84dae66576d1227dcd4bc02c7a2b61290ed65c5/assets/config-organization.png -------------------------------------------------------------------------------- /assets/config-product.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/outeredge/magento-structured-data-module/d84dae66576d1227dcd4bc02c7a2b61290ed65c5/assets/config-product.png -------------------------------------------------------------------------------- /assets/outeredge-structured-data-module-user-guide.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/outeredge/magento-structured-data-module/d84dae66576d1227dcd4bc02c7a2b61290ed65c5/assets/outeredge-structured-data-module-user-guide.pdf -------------------------------------------------------------------------------- /assets/screenshot-schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/outeredge/magento-structured-data-module/d84dae66576d1227dcd4bc02c7a2b61290ed65c5/assets/screenshot-schema.png -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "outeredge/magento-structured-data-module", 3 | "description": "Magento Structured Data Module by outer/edge", 4 | "type": "magento2-module", 5 | "version": "5.1.3", 6 | "license": [ 7 | "MIT" 8 | ], 9 | "authors": [ 10 | { 11 | "name": "outer/edge", 12 | "email": "support@outeredge.agency", 13 | "homepage": "https://outeredge.agency/", 14 | "role": "Developer" 15 | } 16 | ], 17 | "require": { 18 | "php": "^8.1" 19 | }, 20 | "require-dev": { 21 | "outeredge/dev-dependencies": "^3.0" 22 | }, 23 | "autoload": { 24 | "files": [ 25 | "registration.php" 26 | ], 27 | "psr-4": { 28 | "OuterEdge\\StructuredData\\": "" 29 | } 30 | }, 31 | "config": { 32 | "allow-plugins": { 33 | "phpstan/extension-installer": true 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /etc/acl.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /etc/adminhtml/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /etc/adminhtml/system.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | outeredge 10 | OuterEdge_StructuredData::config 11 | 12 | 13 | 14 | 15 | OuterEdge\StructuredData\Model\Config\Source\ContactType 16 | 17 | 18 | 19 | 20 | LocalBusiness 21 | 22 | 23 | 24 | 25 | 26 | LocalBusiness 27 | 28 | 29 | 30 | 31 | Magento\Config\Model\Config\Source\Yesno 32 | 33 | 34 | 35 | Magento\Config\Model\Config\Source\Yesno 36 | 37 | 38 | 39 | OuterEdge\StructuredData\Block\Adminhtml\Form\Field\RelatedPage 40 | OuterEdge\StructuredData\Model\Config\Backend\RelatedPage 41 | Populates "SameAs" property. Add links to related pages, for example Facebook, Linked In and other social media sites. 42 | 43 | 44 | 45 | 46 | 47 | 48 | Magento\Config\Model\Config\Source\Yesno 49 | 50 | 51 | 52 | Magento\Config\Model\Config\Source\Yesno 53 | 54 | 55 | 56 | Magento\Config\Model\Config\Source\Yesno 57 | Instead of description 58 | 59 | 1 60 | 61 | 62 | 63 | 64 | Magento\Config\Model\Config\Source\Yesno 65 | Include offers each child product on configurable products 66 | 67 | 1 68 | 69 | 70 | 71 | 72 | Magento\Config\Model\Config\Source\Yesno 73 | 74 | 1 75 | 76 | 77 | 78 | 79 | Magento\Config\Model\Config\Source\Yesno 80 | Check theme and https://validator.schema.org - May conflict with core markup. 81 | 82 | 1 83 | 84 | 85 | 86 | 87 | Magento\Config\Model\Config\Source\Yesno 88 | Requirements: Disable Magento reviews (above). Official Review.io Magento module (don't enable Rich Snippets) 89 | 90 | 1 91 | 0 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | Magento\Config\Model\Config\Source\Yesno 124 | 125 | 126 | 127 | Magento\Config\Model\Config\Source\Yesno 128 | 129 | 1 130 | 131 | 132 | 133 | 134 | Magento\Cms\Model\Config\Source\Page 135 | 136 | 1 137 | 138 | 139 | 140 |
141 |
142 |
143 | -------------------------------------------------------------------------------- /etc/cache.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Cache for Structured Data 6 | 7 | 8 | -------------------------------------------------------------------------------- /etc/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 0 7 | 0 8 | gtin 9 | mpn 10 | material 11 | 12 | 13 | Organization 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /etc/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /etc/events.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /etc/module.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /etc/schema.graphqls: -------------------------------------------------------------------------------- 1 | interface ProductInterface { 2 | structured_data: String 3 | @doc(description: "Schema.org Structured Data") 4 | @resolver(class: "OuterEdge\\StructuredData\\Model\\Resolver\\Product\\StructuredData") 5 | } 6 | -------------------------------------------------------------------------------- /etc/webapi.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | . 5 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | paths([__DIR__]); 14 | 15 | $rectorConfig->skip([__DIR__ . '/vendor']); 16 | 17 | $parameters = $rectorConfig->parameters(); 18 | 19 | $parameters->set(Option::FILE_EXTENSIONS, [ 20 | 'php', 21 | 'phtml' 22 | ]); 23 | 24 | $rectorConfig->rule(NullToStrictStringFuncCallArgRector::class); 25 | 26 | $rectorConfig->sets([ 27 | LevelSetList::UP_TO_PHP_74 28 | ]); 29 | 30 | }; 31 | -------------------------------------------------------------------------------- /registration.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /view/frontend/layout/catalog_product_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | false 16 | 17 | 18 | 19 | 20 | false 21 | 22 | 23 | 24 | 25 | false 26 | 27 | 28 | 29 | 30 | false 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /view/frontend/layout/catalog_product_view_type_bundle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /view/frontend/layout/catalog_product_view_type_configurable.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /view/frontend/layout/catalog_product_view_type_downloadable.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | false 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /view/frontend/layout/catalog_product_view_type_grouped.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /view/frontend/layout/cms_index_index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /view/frontend/layout/cms_page_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /view/frontend/layout/contact_index_index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /view/frontend/layout/default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /view/frontend/layout/hyva_catalog_product_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /view/frontend/templates/html/breadcrumbs.phtml: -------------------------------------------------------------------------------- 1 | 2 | 20 | 21 | 22 | 23 | 24 | 44 | 45 | -------------------------------------------------------------------------------- /view/frontend/templates/jsonld.phtml: -------------------------------------------------------------------------------- 1 | 2 | getChildHtml('main.entity'); ?> 3 | getChildHtml('additional.schema'); ?> 4 | 26 | getChildHtml('product.offers.schema'); ?> 27 | -------------------------------------------------------------------------------- /view/frontend/templates/jsonld/cms.phtml: -------------------------------------------------------------------------------- 1 | ,"name": "escapeQuote($block->stripTags($block->getTitle())); ?>" 2 | escapeQuote($block->stripTags($block->getContent())); ?> 3 | 4 | ,"mainContentOfPage": { 5 | "text": "" 6 | } 7 | 8 | getMetaDescription()): ?> 9 | ,"description": "escapeQuote(preg_replace('/\s+/', ' ', trim($block->stripTags($block->getMetaDescription())))); ?>" 10 | 11 | getImage()): ?> 12 | ,"primaryImageOfPage": { 13 | "@type": "ImageObject", 14 | "url": "escapeQuote($block->getImage()); ?>" 15 | } 16 | 17 | -------------------------------------------------------------------------------- /view/frontend/templates/jsonld/organization.phtml: -------------------------------------------------------------------------------- 1 | { 2 | "@type": "escapeQuote($block->getConfig('structureddata/contact/type')); ?>", 3 | "@id": "escapeUrl($block->getStore()->getBaseUrl()); ?>", 4 | "name": "escapeQuote($block->getConfig('general/store_information/name') ?? ''); ?>", 5 | "image": "escapeUrl($block->getStoreLogoUrl() ?? ''); ?>", 6 | escapeQuote($block->getStreetAddress()), ' ,\t\n\r\0\x0B') !== ''): ?> 7 | "address": { 8 | "@type": "PostalAddress", 9 | "streetAddress": "escapeQuote($block->getStreetAddress()); ?>", 10 | getConfig('general/store_information/city')):?> 11 | "addressLocality": "escapeQuote($block->getConfig('general/store_information/city')); ?>", 12 | 13 | getConfig('general/store_information/region_id')):?> 14 | "addressRegion": "escapeQuote($block->getConfig('general/store_information/region_id')); ?>", 15 | 16 | getConfig('general/store_information/postcode')):?> 17 | "postalCode": "escapeQuote((string)$block->getConfig('general/store_information/postcode')); ?>", 18 | 19 | getConfig('general/store_information/country_id')):?> 20 | "addressCountry": "escapeQuote($block->getConfig('general/store_information/country_id')); ?>" 21 | 22 | }, 23 | 24 | getConfig('general/store_information/phone')):?> 25 | "telephone": "escapeQuote((string)$block->getConfig('general/store_information/phone')); ?>", 26 | 27 | getConfig('trans_email/ident_general/email')):?> 28 | "email": "escapeQuote($block->getConfig('trans_email/ident_general/email')); ?>", 29 | 30 | "url": "escapeUrl($block->getStore()->getBaseUrl()); ?>" 31 | isLocalBusiness() && trim($block->getConfig('structureddata/contact/latitude')) !== ''): ?> 32 | ,"geo": { 33 | "@type": "GeoCoordinates", 34 | "latitude": "escapeQuote((string)$block->getConfig('structureddata/contact/latitude')) ?>", 35 | "longitude": "escapeQuote((string)$block->getConfig('structureddata/contact/longitude')) ?>" 36 | } 37 | 38 | getRelatedPages()): ?> 39 | ,"sameAs": getRelatedPages() ?> 40 | 41 | } 42 | -------------------------------------------------------------------------------- /view/frontend/templates/jsonld/product-offers.phtml: -------------------------------------------------------------------------------- 1 | getProduct(); 3 | if ($product && $product->getEntityId()): 4 | ?> 5 | 31 | 32 | -------------------------------------------------------------------------------- /view/frontend/templates/jsonld/product-reviewsio.phtml: -------------------------------------------------------------------------------- 1 | getProduct(); 3 | if ($product->getTypeId() == 'configurable') { 4 | $associatedProducts = $product->getTypeInstance()->getUsedProducts($product); 5 | $skus = []; 6 | $skus[] = $product->getSku(); 7 | foreach ($associatedProducts as $associatedProduct) { 8 | $skus[] = $associatedProduct->getSku(); 9 | } 10 | $skuString = implode(';', $skus); 11 | } else { 12 | $skuString = $product->getSku(); 13 | } 14 | ?> 15 | 33 | -------------------------------------------------------------------------------- /view/frontend/templates/jsonld/schema.phtml: -------------------------------------------------------------------------------- 1 | getSchemaJson(); ?> 2 | --------------------------------------------------------------------------------