add JSON-LD structured data

Product pages: Product schema (name, description, image, price/currency,
availability) + BreadcrumbList (Home > Category > Product). Home page:
Organization schema (name, url). Uses Jason with html_safe escaping so
the JSON is safe to embed in <script> tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-23 22:37:34 +00:00
parent 0f1135256d
commit 4e36b654d3
4 changed files with 94 additions and 4 deletions

View File

@ -103,7 +103,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
| | **SEO** | | | |
| ~~58~~ | ~~Page titles with separators across all pages~~ | — | 1h | done |
| ~~59~~ | ~~Open Graph + Twitter Card meta tags (products, collections, home)~~ | 58 | 2h | done |
| 60 | Structured data / JSON-LD (Product, BreadcrumbList, Organization) | 59 | 2h | planned |
| ~~60~~ | ~~Structured data / JSON-LD (Product, BreadcrumbList, Organization)~~ | 59 | 2h | done |
| ~~61~~ | ~~Canonical URLs, robots.txt, sitemap.xml~~ | 59 | 1.5h | done |
| ~~62~~ | ~~Meta descriptions (per-page, auto-generated fallbacks)~~ | 58 | 1h | done |
| | **Profit-aware pricing & sales** ([plan](docs/plans/profit-aware-pricing.md)) | | | |

View File

@ -40,6 +40,11 @@
<% end %>
<meta name="twitter:title" content={og_title} />
<meta name="twitter:description" content={og_description} />
<%= if assigns[:json_ld] do %>
<script type="application/ld+json">
<%= Phoenix.HTML.raw(assigns[:json_ld]) %>
</script>
<% end %>
<!-- Preload critical fonts for the current typography preset -->
<%= for preload <- Berrypod.Theme.Fonts.preload_links(
@theme_settings.typography,

View File

@ -7,10 +7,25 @@ defmodule BerrypodWeb.Shop.Home do
def mount(_params, _session, socket) do
products = Products.list_visible_products(limit: 8)
base = BerrypodWeb.Endpoint.url()
site_name = socket.assigns.theme_settings.site_name
org_ld =
Jason.encode!(
%{
"@context" => "https://schema.org",
"@type" => "Organization",
"name" => site_name,
"url" => base <> "/"
},
escape: :html_safe
)
socket =
socket
|> assign(:page_title, "Home")
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/")
|> assign(:og_url, base <> "/")
|> assign(:json_ld, org_ld)
|> assign(:products, products)
{:ok, socket}

View File

@ -48,13 +48,18 @@ defmodule BerrypodWeb.Shop.ProductShow do
)
end
base = BerrypodWeb.Endpoint.url()
og_url = base <> "/products/#{slug}"
og_image = og_image_url(all_images)
socket =
socket
|> assign(:page_title, product.title)
|> assign(:page_description, meta_description(product.description))
|> assign(:og_type, "product")
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/products/#{slug}")
|> assign(:og_image, og_image_url(all_images))
|> assign(:og_url, og_url)
|> assign(:og_image, og_image)
|> assign(:json_ld, product_json_ld(product, og_url, og_image, base))
|> assign(:product, product)
|> assign(:all_images, all_images)
|> assign(:gallery_images, gallery_images)
@ -219,6 +224,71 @@ defmodule BerrypodWeb.Shop.ProductShow do
"""
end
defp product_json_ld(product, url, image, base) do
category_slug =
if product.category,
do: product.category |> String.downcase() |> String.replace(" ", "-"),
else: "all"
breadcrumbs =
[
%{"@type" => "ListItem", "position" => 1, "name" => "Home", "item" => base <> "/"},
product.category &&
%{
"@type" => "ListItem",
"position" => 2,
"name" => product.category,
"item" => base <> "/collections/#{category_slug}"
},
%{
"@type" => "ListItem",
"position" => if(product.category, do: 3, else: 2),
"name" => product.title,
"item" => url
}
]
|> Enum.reject(&is_nil/1)
data = %{
"@context" => "https://schema.org",
"@graph" => [
%{
"@type" => "Product",
"name" => product.title,
"description" => plain_text(product.description),
"image" => Enum.reject([image], &is_nil/1),
"url" => url,
"offers" => %{
"@type" => "Offer",
"price" => format_price(product.cheapest_price),
"priceCurrency" => "GBP",
"availability" =>
if(product.in_stock,
do: "https://schema.org/InStock",
else: "https://schema.org/OutOfStock"
),
"url" => url
}
},
%{
"@type" => "BreadcrumbList",
"itemListElement" => breadcrumbs
}
]
}
Jason.encode!(data, escape: :html_safe)
end
defp format_price(pence) when is_integer(pence) do
"#{div(pence, 100)}.#{String.pad_leading(to_string(rem(pence, 100)), 2, "0")}"
end
defp format_price(_), do: "0.00"
defp plain_text(nil), do: nil
defp plain_text(text), do: String.replace(text, ~r/<[^>]+>/, "")
defp og_image_url([%{url: "/" <> _ = path} | _]), do: BerrypodWeb.Endpoint.url() <> path
defp og_image_url(_), do: nil