Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The GitHub release body is a summary, not a copy of the changelog section. Write

## Unreleased

- Product pages now render their own head metadata and schema.org JSON-LD from `partials/product_structured_data.html` instead of the platform `{% seo %}` output. A product with variants is marked up as a `ProductGroup` with one `Product` and `Offer` per variant, each with its own price, currency, availability, SKU and GTIN. Brand, GTIN and MPN come from product metadata keys `brand`, `gtin` and `mpn` when the store defines them; without `brand` the store name is used, and the UPC is used as the GTIN only when it is 8, 12, 13 or 14 digits. The UPC is no longer emitted as `mpn`, the description is no longer cut at 160 characters in the JSON-LD, the offer price is the price shown on the page, and the rating is included only when Product Reviews is on. Every string is escaped for JSON, so a product title containing a double quote (`12" Boot`) no longer produces markup that search engines and shopping agents reject. A product or variant without a price gets no offer, and the image list is capped at three. The meta and Open Graph tags match the platform's apart from the brand and two corrected values: `og:type` is now `product`, and `product:availability` is `instock` or `outofstock`. See `docs/pdp-customization.md`, "Structured data".
- Added Product Pages > Shop All > Show Filters on Shop All (`shop_all_filters`, off by default). When on, the Shop All page shows the same filter rail, mobile drawer, and sticky filter bar as category pages, using the filters created under Storefront > Filters. The platform already applied filter parameters on Shop All; only the controls were missing.
- Fixed category pages without any Storefront filters squeezing the product grid into the 15rem filter-rail column from 1024px up (240px wide at a 1280px viewport). The two-column layout now applies only when the filter rail renders. Introduced in 1.2.0.
- Fixed Pages, Support Articles, and Blog posts showing editor-written content without paragraph, heading, list, separator, and table spacing. Tailwind's reset had removed it, and the `content-body` class the Article and Blog templates already carried had no styles. Editor headings now follow the Heading Scale setting and sit below the page title, which now uses the same heading scale on Pages, Articles, and Blog posts. Wide editor tables scroll sideways inside their own box instead of widening the page; without JavaScript they stay plain tables and may overflow on narrow screens (#85).
Expand Down
19 changes: 19 additions & 0 deletions docs/pdp-customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,22 @@ Spark's current PDP is one template plus helper scripts. When repeated custom PD
| `partials/product_related.html` | Recommended and related products. |

Do not split the PDP just to satisfy one custom design. Split when the same behavior-preserving modules will be reused across multiple stores or when an app integration needs a stable override surface.

## Structured data

`templates/catalogue/product.html` overrides the `seo` block with `partials/product_structured_data.html`, which renders the product page's meta tags, Open Graph tags and schema.org JSON-LD. The platform `{% seo %}` tag is not called on product pages, so a fork that restores it gets the platform markup back and loses the variant and identifier fields below.

| JSON-LD field | Source |
|---|---|
| `@type` | `ProductGroup` for a parent product, `Product` otherwise |
| `hasVariant[]` | One `Product` per child, each with its own `Offer` from `purchase_info_for_product`, the child's first image (or the parent's when the child has none) and the product description. Google's merchant listings report rejects a variant without `image`. |
| `image` | The first three product images at 1200x1200, else the meta image; omitted when there is no image at all |
| `brand.name` | Product metadata `brand`, else the store name |
| `gtin` | Variant metadata `gtin` on each variant, or product metadata `gtin` on a product without variants; else the UPC when it is all digits and 8, 12, 13 or 14 long. Other UPCs are left out without a warning. A parent product carries no `gtin`. |
| `mpn` | Product or variant metadata `mpn`; omitted otherwise |
| `offers` | `session.price.price`, the price the page shows. A product or variant with no price record gets no `offers`, since Google rejects an offer without a price. |
| `aggregateRating` | Only when `settings.product_reviews` is on and the product has approved reviews |

To use the metadata keys, create product metadata definitions with the keys `brand`, `gtin` and `mpn` in the Dashboard and fill them per product or variant.

Every string written into the JSON-LD goes through `escapejs`, including the UPC. Keep that when editing the partial: a value written with `|safe` or without escaping breaks the JSON on the first title with a double quote, and a crawler then drops the whole block. Check a changed partial against a real product with a structured-data validator such as Google's Rich Results Test.
88 changes: 88 additions & 0 deletions partials/product_structured_data.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
{# Spark Theme — PDP head metadata. Rendered from the seo block in templates/catalogue/product.html. #}
{# Replaces the platform {% seo %} output on product pages: meta and Open Graph tags plus schema.org JSON-LD. #}
{# Every string inside the JSON-LD goes through escapejs, so quotes, backslashes and </script> in merchant text stay valid JSON. #}
{# Identifiers come from product metadata when the store defines them: brand, gtin, mpn. Without brand, the store name is used. #}
{# gtin falls back to the UPC only when the UPC is 8, 12, 13 or 14 digits. The UPC is never emitted as mpn. #}
{% image_thumbnail product.get_meta_image "1200x630" upscale=True crop="center" as thumb %}
Comment thread
next-devin marked this conversation as resolved.
Resolved
{% purchase_info_for_product request product as session %}

<meta name="title" content="{{ product.get_meta_title }}" />
<meta name="description" content="{{ product.get_meta_description|striptags|slice:160 }}" />
{% if not product.is_public %}
<meta name="robots" content="noindex, nofollow" />
Comment thread
next-devin marked this conversation as resolved.
Resolved
{% endif %}
<meta property="og:title" content="{{ product.get_meta_title }}" />
<meta property="og:type" content="product" />
<meta property="og:description" content="{{ product.get_meta_description|striptags|slice:160 }}" />
<meta property="og:url" content="{{ product.get_primary_url }}" />
Comment thread
next-devin marked this conversation as resolved.
Resolved
<meta property="og:image" content="{{ thumb.url }}" />
Comment thread
next-devin marked this conversation as resolved.
Resolved
<meta property="product:brand" content="{% if product.metadata.brand %}{{ product.metadata.brand }}{% else %}{{ shop_name }}{% endif %}" />
Comment thread
next-devin marked this conversation as resolved.
Resolved
<meta property="product:availability" content="{% if session.availability.is_available_to_buy %}instock{% else %}outofstock{% endif %}" />
{% if session.price.exists %}
<meta property="product:price:amount" content="{{ session.price.price|stringformat:'s' }}" />
<meta property="product:price:currency" content="{{ session.price.currency }}" />
{% endif %}
<meta property="product:retailer_item_id" content="{{ product.id }}" />

<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "{% if product.is_parent %}ProductGroup{% else %}Product{% endif %}",
Comment thread
next-devin marked this conversation as resolved.
Resolved
"name": "{{ product.get_title|escapejs }}",
"description": "{% if product.description %}{{ product.description|striptags|escapejs }}{% else %}{{ product.get_meta_description|striptags|escapejs }}{% endif %}",
"url": "{{ product.get_primary_url|escapejs }}",
{% with images=product.get_all_images %}{% if images %}"image": [{% for image in images|slice:':3' %}{% image_thumbnail image.original "1200x1200" upscale=False as image_thumb %}"{{ image_thumb.url|escapejs }}"{% if not forloop.last %}, {% endif %}{% endfor %}],{% elif thumb.url %}"image": "{{ thumb.url|escapejs }}",{% endif %}{% endwith %}
"brand": {
"@type": "Brand",
"name": "{% if product.metadata.brand %}{{ product.metadata.brand|escapejs }}{% else %}{{ shop_name|escapejs }}{% endif %}"
Comment thread
next-devin marked this conversation as resolved.
Resolved
},
{% if product.metadata.mpn %}"mpn": "{{ product.metadata.mpn|escapejs }}",{% endif %}
{% if settings.product_reviews and product.rating and product.num_approved_reviews %}
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "{{ product.rating|stringformat:'s' }}",
"reviewCount": "{{ product.num_approved_reviews }}"
},
{% endif %}
{% if product.is_parent %}
"hasVariant": [
{% for child in product.children.all %}
Comment thread
next-devin marked this conversation as resolved.
Resolved
{% purchase_info_for_product request child as child_session %}
{
"@type": "Product",
"name": "{{ child.get_title|escapejs }}",
"description": "{% if product.description %}{{ product.description|striptags|escapejs }}{% else %}{{ product.get_meta_description|striptags|escapejs }}{% endif %}",
{% with child_images=child.get_all_images %}{% if child_images %}{% image_thumbnail child_images.0.original "1200x1200" upscale=False as child_thumb %}"image": "{{ child_thumb.url|escapejs }}",{% elif thumb.url %}"image": "{{ thumb.url|escapejs }}",{% endif %}{% endwith %}
{% if child.sku %}"sku": "{{ child.sku|escapejs }}",{% endif %}
{% if child.metadata.gtin %}"gtin": "{{ child.metadata.gtin|escapejs }}",{% elif child.upc and child.upc.isdigit and child.upc|length|stringformat:'s' in '8,12,13,14'|split:',' %}"gtin": "{{ child.upc|escapejs }}",{% endif %}
{% if child.metadata.mpn %}"mpn": "{{ child.metadata.mpn|escapejs }}",{% endif %}
{% if child_session.price.exists %}
"offers": {
"@type": "Offer",
"price": "{{ child_session.price.price|stringformat:'s' }}",
"priceCurrency": "{{ child_session.price.currency }}",
"availability": "{% if child_session.availability.is_available_to_buy %}https://schema.org/InStock{% else %}https://schema.org/OutOfStock{% endif %}",
"url": "{{ product.get_primary_url|escapejs }}"
},
{% endif %}
"productID": "{{ child.id }}"
}{% if not forloop.last %},{% endif %}
{% endfor %}
],
"productGroupID": "{{ product.id }}"
{% else %}
{% if product.sku %}"sku": "{{ product.sku|escapejs }}",{% endif %}
{% if product.metadata.gtin %}"gtin": "{{ product.metadata.gtin|escapejs }}",{% elif product.upc and product.upc.isdigit and product.upc|length|stringformat:'s' in '8,12,13,14'|split:',' %}"gtin": "{{ product.upc|escapejs }}",{% endif %}
{% if session.price.exists %}
"offers": {
"@type": "Offer",
"price": "{{ session.price.price|stringformat:'s' }}",
"priceCurrency": "{{ session.price.currency }}",
"availability": "{% if session.availability.is_available_to_buy %}https://schema.org/InStock{% else %}https://schema.org/OutOfStock{% endif %}",
"url": "{{ product.get_primary_url|escapejs }}"
},
{% endif %}
"productID": "{{ product.id }}"
{% endif %}
}
</script>
2 changes: 1 addition & 1 deletion templates/catalogue/product.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{% block title %}{{ product.get_meta_title }} | {{ block.super }}{% endblock %}

{% block seo %}
{% seo %}
{% include 'partials/product_structured_data.html' %}
Comment thread
next-devin marked this conversation as resolved.
Resolved
{% endblock %}

{% block critical_preloads %}
Expand Down
Loading