-
-
Save bakura10/75b03f0d92d73581bd8b0df7dc3c2db4 to your computer and use it in GitHub Desktop.
{%- comment -%} | |
This snippet structures the micro-data using JSON-LD specification. Please note that for Product especially, | |
the schema often changes. We try to output as much info as possible, but Google may add new requirements over time, | |
or change the format of some info | |
LAST UPDATE: May 10th 2023 (we added the "hasMerchantReturnPolicy" and "shippingDetails" to include the shipping and | |
return policy if they have been specified as store policies). | |
{%- endcomment -%} | |
{%- if request.page_type == 'product' -%} | |
{%- assign days_product_price_valid_until = 10 | times: 86400 -%} | |
{%- capture main_entity_microdata -%} | |
{%- assign is_valid_global_gtin_length = false -%} | |
{%- if product.selected_or_first_available_variant.barcode != blank -%} | |
{%- assign gtin_string_length = product.selected_or_first_available_variant.barcode | size -%} | |
{%- if gtin_string_length == 8 or gtin_string_length == 12 or gtin_string_length == 13 or gtin_string_length == 14 -%} | |
{%- assign is_valid_global_gtin_length = true -%} | |
{%- endif -%} | |
{%- endif -%} | |
"@type": "Product", | |
"productID": {{ product.id | json }}, | |
"offers": [ | |
{%- for variant in product.variants -%} | |
{%- assign is_valid_gtin_length = false -%} | |
{%- if variant.barcode != blank -%} | |
{%- assign gtin_string_length = variant.barcode | size -%} | |
{%- if gtin_string_length == 8 or gtin_string_length == 12 or gtin_string_length == 13 or gtin_string_length == 14 -%} | |
{%- assign is_valid_gtin_length = true -%} | |
{%- endif -%} | |
{%- endif -%} | |
{ | |
"@type": "Offer", | |
"name": {% if product.has_only_default_variant %}{{ product.title | json }}{% else %}{{ variant.title | json }}{% endif %}, | |
"availability": {%- if variant.available -%}"https://schema.org/InStock"{%- elsif variant.incoming -%}"https://schema.org/BackOrder"{% else %}"https://schema.org/OutOfStock"{%- endif -%}, | |
"price": {{ variant.price | divided_by: 100.0 | json }}, | |
"priceCurrency": {{ cart.currency.iso_code | json }}, | |
"priceValidUntil": "{{ 'now' | date: '%s' | plus: days_product_price_valid_until | date: '%Y-%m-%d' }}", | |
{%- if variant.sku != blank -%} | |
"sku": {{ variant.sku | json }}, | |
{%- endif -%} | |
{%- if variant.barcode != blank -%} | |
{%- if is_valid_gtin_length -%} | |
"gtin": {{ variant.barcode | json }}, | |
{%- else -%} | |
"mpn": {{ variant.barcode | json }}, | |
{%- endif -%} | |
{%- endif -%} | |
{%- if shop.refund_policy.body != blank -%} | |
"hasMerchantReturnPolicy": { | |
"merchantReturnLink": {{ shop.refund_policy.url | prepend: request.origin | json }} | |
}, | |
{%- endif -%} | |
{%- if shop.shipping_policy.body != blank -%} | |
"shippingDetails": { | |
"shippingSettingsLink": {{ shop.shipping_policy.url | prepend: request.origin | json }} | |
}, | |
{%- endif -%} | |
"url": "{{ shop.url }}{{ product.url }}?variant={{ variant.id }}" | |
}{% unless forloop.last %},{% endunless %} | |
{%- endfor -%} | |
], | |
{%- if product.metafields.reviews.rating.value != blank and product.metafields.reviews.rating_count.value > 0 -%} | |
"aggregateRating": { | |
"@type": "AggregateRating", | |
"ratingValue": "{{ product.metafields.reviews.rating.value }}", | |
"reviewCount": "{{ product.metafields.reviews.rating_count.value }}", | |
"worstRating": "{{ product.metafields.reviews.rating.value.scale_min }}", | |
"bestRating": "{{ product.metafields.reviews.rating.value.scale_max }}" | |
}, | |
{%- endif -%} | |
"brand": { | |
"@type": "Brand", | |
"name": {{ product.vendor | json }} | |
}, | |
"name": {{ product.title | json }}, | |
"description": {{ product.description | strip_html | json }}, | |
"category": {{ product.type | json }}, | |
"url": "{{ shop.url }}{{ product.url }}", | |
"sku": {{ product.selected_or_first_available_variant.sku | json }}, | |
{%- if product.selected_or_first_available_variant.barcode != blank -%} | |
{%- if is_valid_global_gtin_length -%} | |
"gtin": {{ product.selected_or_first_available_variant.barcode | json }}, | |
{%- else -%} | |
"mpn": {{ product.selected_or_first_available_variant.barcode | json }}, | |
{%- endif -%} | |
{%- endif -%} | |
"image": { | |
"@type": "ImageObject", | |
"url": "https:{{ page_image | image_url: width: 1024 }}", | |
"image": "https:{{ page_image | image_url: width: 1024 }}", | |
"name": {{ page_image.alt | json }}, | |
"width": "1024", | |
"height": "1024" | |
} | |
{%- endcapture -%} | |
{%- elsif request.page_type == 'article' -%} | |
{%- capture main_entity_microdata -%} | |
"@type": "BlogPosting", | |
"mainEntityOfPage": "{{ article.url }}", | |
"articleSection": {{ blog.title | json }}, | |
"keywords": "{{ article.tags | join: ', ' }}", | |
"headline": {{ article.title | json }}, | |
"description": {{ article.excerpt_or_content | strip_html | truncatewords: 25 | json }}, | |
"dateCreated": "{{ article.created_at | date: '%Y-%m-%dT%T' }}", | |
"datePublished": "{{ article.published_at | date: '%Y-%m-%dT%T' }}", | |
"dateModified": "{{ article.published_at | date: '%Y-%m-%dT%T' }}", | |
"image": { | |
"@type": "ImageObject", | |
"url": "https:{{ page_image | image_url: width: 1024 }}", | |
"image": "https:{{ page_image | image_url: width: 1024 }}", | |
"name": {{ page_image.alt | json }}, | |
"width": "1024", | |
"height": "1024" | |
}, | |
"author": { | |
"@type": "Person", | |
"name": "{{ article.user.first_name | escape }} {{ article.user.last_name | escape }}", | |
"givenName": {{ article.user.first_name | json }}, | |
"familyName": {{ article.user.last_name | json }} | |
}, | |
"publisher": { | |
"@type": "Organization", | |
"name": {{ shop.name | json }} | |
}, | |
"commentCount": {{ article.comments_count }}, | |
"comment": [ | |
{%- for comment in article.comments limit: 5 -%} | |
{ | |
"@type": "Comment", | |
"author": {{ comment.author | json }}, | |
"datePublished": "{{ comment.created_at | date: '%Y-%m-%dT%T' }}", | |
"text": {{ comment.content | json }} | |
}{%- unless forloop.last -%},{%- endunless -%} | |
{%- endfor -%} | |
] | |
{%- endcapture -%} | |
{%- endif -%} | |
{%- capture breadcrumb_entity_microdata -%} | |
"@type": "BreadcrumbList", | |
"itemListElement": [{ | |
"@type": "ListItem", | |
"position": 1, | |
"name": {{ 'general.home' | t | json }}, | |
"item": "{{ shop.url }}" | |
} | |
{%- if request.page_type == 'product' -%} | |
{%- if collection -%} | |
,{ | |
"@type": "ListItem", | |
"position": 2, | |
"name": {{ collection.title | json }}, | |
"item": "{{ shop.url }}{{ collection.url }}" | |
}, { | |
"@type": "ListItem", | |
"position": 3, | |
"name": {{ product.title | json }}, | |
"item": "{{ shop.url }}{{ product.url }}" | |
} | |
{%- else -%} | |
,{ | |
"@type": "ListItem", | |
"position": 2, | |
"name": {{ product.title | json }}, | |
"item": "{{ shop.url }}{{ product.url }}" | |
} | |
{%- endif -%} | |
{%- elsif request.page_type == 'collection' -%} | |
,{ | |
"@type": "ListItem", | |
"position": 2, | |
"name": {{ collection.title | json }}, | |
"item": "{{ shop.url }}{{ collection.url }}" | |
} | |
{%- elsif request.page_type == 'blog' -%} | |
,{ | |
"@type": "ListItem", | |
"position": 2, | |
"name": {{ blog.title | json }}, | |
"item": "{{ shop.url }}{{ blog.url }}" | |
} | |
{%- elsif request.page_type == 'article' -%} | |
,{ | |
"@type": "ListItem", | |
"position": 2, | |
"name": {{ blog.title | json }}, | |
"item": "{{ shop.url }}{{ blog.url }}" | |
}, { | |
"@type": "ListItem", | |
"position": 3, | |
"name": {{ blog.title | json }}, | |
"item": "{{ shop.url }}{{ article.url }}" | |
} | |
{%- elsif request.page_type == 'page' -%} | |
,{ | |
"@type": "ListItem", | |
"position": 2, | |
"name": {{ page.title | json }}, | |
"item": "{{ shop.url }}{{ page.url }}" | |
} | |
{%- endif -%} | |
] | |
{%- endcapture -%} | |
{% if main_entity_microdata != blank %} | |
<script type="application/ld+json"> | |
{ | |
"@context": "https://schema.org", | |
{{ main_entity_microdata }} | |
} | |
</script> | |
{% endif %} | |
{% if breadcrumb_entity_microdata != blank %} | |
<script type="application/ld+json"> | |
{ | |
"@context": "https://schema.org", | |
{{ breadcrumb_entity_microdata }} | |
} | |
</script> | |
{% endif %} | |
{%- if request.page_type == 'index' -%} | |
{%- assign potential_action_target = request.origin | append: routes.search_url | append: "?q={search_term_string}" -%} | |
<script type="application/ld+json"> | |
[ | |
{ | |
"@context": "https://schema.org", | |
"@type": "WebSite", | |
"name": {{ shop.name | json }}, | |
"url": {{ shop.url | append: page.url | json }}, | |
"potentialAction": { | |
"@type": "SearchAction", | |
"target": {{ potential_action_target | json }}, | |
"query-input": "required name=search_term_string" | |
} | |
}, | |
{ | |
"@context": "https://schema.org", | |
"@type": "Organization", | |
"name": {{ shop.name | json }}, | |
{%- if shop.brand.logo -%} | |
"logo": {{ shop.brand.logo | image_url: width: shop.brand.logo.width | prepend: "https:" | json }}, | |
{%- endif -%} | |
{%- if shop.brand.short_description -%} | |
"description": {{ shop.brand.short_description | json }}, | |
{%- endif -%} | |
{%- if shop.brand.slogan -%} | |
"slogan": {{ shop.brand.slogan | json }}, | |
{%- endif -%} | |
{%- if shop.brand.metafields.social_links.size > 0 -%} | |
"sameAs": [ | |
{%- for social_link in shop.brand.metafields.social_links -%} | |
{{- social_link.last.value | json -}}{%- unless forloop.last -%},{%- endunless -%} | |
{%- endfor -%} | |
], | |
{%- endif -%} | |
"url": {{ shop.url | append: page.url | json }} | |
} | |
] | |
</script> | |
{%- endif -%} |
Which review app you used ?
Hi @bakura10,
thank you for this!
How would you use it in a theme?Cheers
copy code in product-page-section.liqiud
Hi, thanks for updating this code, I have found it has resolved a lot of warnings in my rich snippets. But I find that for some products it picks them up twice. They have no variants but for some reason, it is picked up under both rules. Is there a way around this?
Thanks for this, it was super helpful.
To use this, I created a new file in my theme's "snippets" folder and called it microdata-json-ld.liquid
. I then included it on my page.liquid so it was on every page with:
{% include 'microdata-json-ld' %}
I also updated line 28 to this:
"name": {%- if variant.title != "Default Title" -%}{{ variant.title | json }}{%- else -%}{{ product.title | json }}{%- endif -%},
This solves the problem with products with one variant and one offer, shopify calls it "Default Title". You can see a discussion about this problem here. The line of code above will just look for that and instead use the product's title as the offer name which in our case was correct thing to do.
You can see it in my fork here
Thanks a lot @tmchow , this is a good point. I think you can simply improve it by using the {% if product.has_only_default_variant %} which will ensure it will work in context of multi-languages. I will update this!
@bakura10 Another change I'd suggest is always adding a "gtin" property if it is a valid GTIN length. Currently you get specific and if it's say 12 digits long, just use a prop named "gtin12". Google does not recognize this for their rich snippets testing tool.
https://gist.github.com/tmchow/4fa580ec6d8f2b2ec4b1773599c4d382#file-microdata-schema-liquid-L38
Good catch @tmchow . After checking, it seems this is recent, as the doc says that the "GTIN" generalizes the earlier "gtin8/12" parameters:
So I suppose internally Google still parse them for compatibility reason, but I will update our theme to make sure we use the generalized version, thanks!
@bakura10 actually digging into this more, the issue seems to be when "gtin" or even variants (eg. "gtin12") are in offers instead of at the root product level. I just tested our own products and rich results testing tool is still complaining about lack of a global unique identifier even though we have:
- Brand at the root level
- gtin AND gtin12 defined at the offer level
Look at this thread here where this person solved it. Look where the "gtin12" value is -- outside the offers!
https://community.shopify.com/c/technical-q-a/no-global-identifier-provided-e-g-gtin-mpn-isbn/td-p/1390806
I'm a little confused by this given I expected gtin to be an offer property but I guess it makes sense if you think about it like selling a book, and what if multiple offers exist from 2 different merchants. In both cases, each merchant is selling the same book so it's the same gtin/upc.
However if you then consider a T-Shirt with multiple sizes (say Small and a Medium), are those considered "Offers"? If so, each one would have a different UPC/GTIN making this property at the root level not make sense.
Digging in more in the Schema Product definition, you see "Size" as a prop at the root level of the product:
I think the way this microdata works for clothing sizes doesn't make sense then. Becuase it sets the SKU right now for the Product to be the first variant, when that is wrong for clothing. For example imagine this:
Red shirt made by Foo
- Small: SKU=ABC, GTIN=123, Price=$1
- Medium: SKU=DEF, GTIN=456, Price=$2
With current microdata as defined, it spits this out which is incorrect:
Product
Name: Red shirt
Brand: Foo
SKU: ABC
Offers: SKU=ABC, GTIN=123, Price=$1
Offers: SKU=DEF, GTIN=456, Price=$2
I believe what should happen in this case is different products on the same page right? So:
Product
Name: Red shirt
Brand: Foo
SKU: ABC
GTIN: 123
Size: Small
Offers: Price=$1
Product
Name: Red shirt
Brand: Foo
SKU: DEF
GTIN: 456
Size: Medium
Offers: Price=$2
I may be wrong, but it seems like this is correct?
I had a look and it supports it both at the product and offer level. I just realize there is another issue at the offer level because we are always using the same code for every variant.
However, according to the doc, the gtin superseeds the variation so setting gtin AND gtin12 seems to be an error.
I just updated the code with the following fixes:
- Output a different barcode per variant
- Adds a global gtin matching the first variant
This already complexicate quite a lot this, so I would prefer to not add much more on the barcode level at this stage, so hopefully this cover all use cases.
Yep, I came to same conclusion of not having both gtin and gtin12, so my mistake originally. Thanks for catching and fixing.
Even though GTIN is supported at product and offer level, it doesn't seem like google recognizes it when it's only at the offer level which is odd. I'm getting complaints of it missing even though it's there in the offer.
Which tool are you using to test it ? There are two: https://developers.google.com/search/docs/advanced/structured-data and I think the most up to date is this one: https://search.google.com/test/rich-results
It is possible that Google actually does not use the whole vocabulary define in schema.org. In all cases, the new updated code I did should cover the whole spec (both product and offer level). But yeah, it is really strange that Google asks duplication, because the offer should always overseed the global attribute.
They definitely do not use the entire vocabulary of schema.org. I agree with you teh most up to date one is the second one you linked to (https://search.google.com/test/rich-results).
@bakura10 one other suggestion.
https://gist.github.com/bakura10/75b03f0d92d73581bd8b0df7dc3c2db4#file-microdata-schema-liquid-L22
ProductID should be text, no? (surrounded by quotes).
In my microdata spit out with your latest snippet, i'm getting this:
@bakura10 It looks like there is a tweak to be made for when you use product.url
. According to shopify's docs, that is the relative URL, and in microdata we need to use absolute URLs. Example is on line 52 and 72.
For the product.ID it seems to be working with it being as an ID. I am a bit unsure about this, while I agree it should be better, using {{ product.id | json }} is just safer in case of Shopify change in the future the format and that this format requires escaping (highly improbable but we never know).
You are right about the URL, Google does not seem to report it as an error but it should be absolute ideally. I just updated the file.
hi @bakura10 thank you for all of this. I'm having trouble with line 151 general.home is not defined in shopify - I'm not technical but can you tell me what this is or to what this can be changed?
@kcfaul I don't recommend that you use this anymore. Shopify has shipped a structured_data
filter that will let Shopify maintain this stuff. More info here: https://shopify.dev/docs/api/liquid/filters/structured_data
@kcfaul I don't recommend that you use this anymore. Shopify has shipped a
structured_data
filter that will let Shopify maintain this stuff. More info here: shopify.dev/docs/api/liquid/filters/structured_data
Shopify's doesn't seem to include the description, hasMerchantReturnPolicy and shippingDetails
And I can't seem to find a way to add those without paying hundreds of dollars for some app or using custom tags like this.
Hi @bakura10,
thank you for this!
How would you use it in a theme?
Cheers