├── .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 | [](https://packagist.org/packages/outeredge/magento-structured-data-module)
2 | [](https://packagist.org/packages/outeredge/magento-structured-data-module)
3 | [](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 | 
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 | 
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 | 
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 | 
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 |