From d2ed0fbea639ce902b8b914ce99b9feadf993a87 Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Thu, 29 Oct 2026 13:31:00 +0700 Subject: [PATCH 1/4] Render product structured data from the theme Product pages override the seo block with partials/product_structured_data.html: ProductGroup with per-variant Product and Offer, brand/gtin/mpn from product metadata, UPC as GTIN only when it has a valid length, the displayed price, and every JSON-LD string escaped with escapejs so a quote in a title no longer breaks the markup. --- CHANGELOG.md | 1 + docs/pdp-customization.md | 18 ++++++ partials/product_structured_data.html | 81 +++++++++++++++++++++++++++ templates/catalogue/product.html | 2 +- 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 partials/product_structured_data.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa4b69..de18ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. The meta and Open Graph tags are unchanged apart from the brand. 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). diff --git a/docs/pdp-customization.md b/docs/pdp-customization.md index 5ffc1e9..a653b09 100644 --- a/docs/pdp-customization.md +++ b/docs/pdp-customization.md @@ -157,3 +157,21 @@ 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` | +| `brand.name` | Product metadata `brand`, else the store name | +| `gtin` | Variant or product metadata `gtin`, else the UPC when it is 8, 12, 13 or 14 digits | +| `mpn` | Product or variant metadata `mpn`; omitted otherwise | +| `offers.price` | `session.price.price`, the price the page shows | +| `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`. 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. diff --git a/partials/product_structured_data.html b/partials/product_structured_data.html new file mode 100644 index 0000000..57c2d6c --- /dev/null +++ b/partials/product_structured_data.html @@ -0,0 +1,81 @@ +{# 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 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 %} +{% purchase_info_for_product request product as session %} + + + +{% if not product.is_public %} + +{% endif %} + + + + + + + +{% if session.price.exists %} + + +{% endif %} + + + diff --git a/templates/catalogue/product.html b/templates/catalogue/product.html index c39c35a..336b70f 100644 --- a/templates/catalogue/product.html +++ b/templates/catalogue/product.html @@ -3,7 +3,7 @@ {% block title %}{{ product.get_meta_title }} | {{ block.super }}{% endblock %} {% block seo %} - {% seo %} + {% include 'partials/product_structured_data.html' %} {% endblock %} {% block critical_preloads %} From 50dd3aedc191f35cd3596775cfa5cd59ba35a925 Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Thu, 29 Oct 2026 21:04:58 +0700 Subject: [PATCH 2/4] Give each structured-data variant an image and description Google's merchant listings test rejects a ProductGroup variant without an image. --- docs/pdp-customization.md | 2 +- partials/product_structured_data.html | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/pdp-customization.md b/docs/pdp-customization.md index a653b09..6a78807 100644 --- a/docs/pdp-customization.md +++ b/docs/pdp-customization.md @@ -165,7 +165,7 @@ Do not split the PDP just to satisfy one custom design. Split when the same beha | 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` | +| `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`. | | `brand.name` | Product metadata `brand`, else the store name | | `gtin` | Variant or product metadata `gtin`, else the UPC when it is 8, 12, 13 or 14 digits | | `mpn` | Product or variant metadata `mpn`; omitted otherwise | diff --git a/partials/product_structured_data.html b/partials/product_structured_data.html index 57c2d6c..eecc045 100644 --- a/partials/product_structured_data.html +++ b/partials/product_structured_data.html @@ -53,6 +53,8 @@ "@type": "Product", "productID": "{{ child.id }}", "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 }}",{% else %}"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 }}",{% endif %} {% if child.metadata.mpn %}"mpn": "{{ child.metadata.mpn|escapejs }}",{% endif %} From 85109bd00ebd136e7013bdd90dd06ecdb51f8483 Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Fri, 30 Oct 2026 08:57:13 +0700 Subject: [PATCH 3/4] Re-run Kilo Code Review From 5ac5ea665dd2fd733887e4420e848e1b94715bfe Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Mon, 2 Nov 2026 16:34:58 +0700 Subject: [PATCH 4/4] Drop priceless offers and empty images from structured data Omit offers when a product or variant has no price record and omit image when neither the product nor the store has one. Cap the image list at three, escape the UPC like every other string, and correct og:type and product:availability to their defined values. --- CHANGELOG.md | 2 +- docs/pdp-customization.md | 7 +++--- partials/product_structured_data.html | 35 +++++++++++++++------------ 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de18ca5..cf85d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +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. The meta and Open Graph tags are unchanged apart from the brand. See `docs/pdp-customization.md`, "Structured data". +- 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). diff --git a/docs/pdp-customization.md b/docs/pdp-customization.md index 6a78807..010c126 100644 --- a/docs/pdp-customization.md +++ b/docs/pdp-customization.md @@ -166,12 +166,13 @@ Do not split the PDP just to satisfy one custom design. Split when the same beha |---|---| | `@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 or product metadata `gtin`, else the UPC when it is 8, 12, 13 or 14 digits | +| `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.price` | `session.price.price`, the price the page shows | +| `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`. 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. +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. diff --git a/partials/product_structured_data.html b/partials/product_structured_data.html index eecc045..933dc2a 100644 --- a/partials/product_structured_data.html +++ b/partials/product_structured_data.html @@ -12,12 +12,12 @@ {% endif %} - + - + {% if session.price.exists %} @@ -28,11 +28,10 @@ { "@context": "https://schema.org", "@type": "{% if product.is_parent %}ProductGroup{% else %}Product{% endif %}", - {% if product.is_parent %}"productGroupID": "{{ product.id }}",{% else %}"productID": "{{ product.id }}",{% endif %} "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 }}", - "image": [{% with images=product.get_all_images %}{% if images %}{% for image in images %}{% image_thumbnail image.original "1200x1200" upscale=False as image_thumb %}"{{ image_thumb.url|escapejs }}"{% if not forloop.last %}, {% endif %}{% endfor %}{% else %}"{{ thumb.url|escapejs }}"{% endif %}{% endwith %}], + {% 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 %}" @@ -51,33 +50,39 @@ {% purchase_info_for_product request child as child_session %} { "@type": "Product", - "productID": "{{ child.id }}", "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 }}",{% else %}"image": "{{ thumb.url|escapejs }}",{% endif %}{% endwith %} + {% 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 }}",{% 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", - {% if child_session.price.exists %}"price": "{{ child_session.price.price|stringformat:'s' }}", - "priceCurrency": "{{ child_session.price.currency }}",{% endif %} + "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 }}",{% 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", - {% if session.price.exists %}"price": "{{ session.price.price|stringformat:'s' }}", - "priceCurrency": "{{ session.price.currency }}",{% endif %} + "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 %} }