diff --git a/README.md b/README.md
index 9f4e016..f1ff4a8 100644
--- a/README.md
+++ b/README.md
@@ -163,6 +163,20 @@ defimpl SEO.Breadcrumb.Build, for: MyApp.Article do
])
end
end
+
+defimpl SEO.JsonLD.Build, for: MyApp.Article do
+ use MyAppWeb, :verified_routes
+
+ def build(article, conn) do
+ SEO.JsonLD.Article.build(
+ headline: article.title,
+ description: article.description,
+ datePublished: article.published_at,
+ author: %{"@type" => "Person", "name" => article.author},
+ mainEntityOfPage: url(conn, ~p"/articles/#{article}")
+ )
+ end
+end
```
3. Assign the item to your conns and/or sockets
diff --git a/lib/seo.ex b/lib/seo.ex
index 2943099..963b6c0 100644
--- a/lib/seo.ex
+++ b/lib/seo.ex
@@ -14,6 +14,7 @@ defmodule SEO do
- `:facebook` -> `SEO.Facebook`
- `:twitter` -> `SEO.Twitter`
- `:breadcrumb` -> `SEO.Breadcrumb`
+ - `:json_ld` -> `SEO.JsonLD`
For example:
@@ -127,6 +128,7 @@ defmodule SEO do
+
"""
end
diff --git a/lib/seo/config.ex b/lib/seo/config.ex
index e617868..56649b9 100644
--- a/lib/seo/config.ex
+++ b/lib/seo/config.ex
@@ -7,7 +7,8 @@ defmodule SEO.Config do
twitter: %{},
unfurl: %{},
open_graph: %{},
- breadcrumb: %{}
+ breadcrumb: %{},
+ json_ld: %{}
]
@callback config() :: map()
diff --git a/lib/seo/json_ld.ex b/lib/seo/json_ld.ex
new file mode 100644
index 0000000..bc006eb
--- /dev/null
+++ b/lib/seo/json_ld.ex
@@ -0,0 +1,80 @@
+defmodule SEO.JsonLD do
+ @moduledoc """
+ Renders JSON-LD structured data as `
+ """
+ end
+
+ defp sanitize(nil), do: nil
+
+ defp sanitize(items) when is_list(items) do
+ cleaned = Enum.map(items, &drop_nils/1) |> Enum.reject(&empty?/1)
+
+ case cleaned do
+ [] -> nil
+ [single] -> single
+ multiple -> multiple
+ end
+ end
+
+ defp sanitize(item) when is_struct(item) do
+ item |> Map.from_struct() |> sanitize()
+ end
+
+ defp sanitize(item) when is_map(item) do
+ cleaned = drop_nils(item)
+ if empty?(cleaned), do: nil, else: cleaned
+ end
+
+ defp sanitize(_), do: nil
+
+ defp drop_nils(map) when is_map(map) do
+ for {k, v} <- map, v != nil, into: %{}, do: {k, v}
+ end
+
+ defp drop_nils(other), do: other
+
+ defp empty?(map) when map_size(map) == 0, do: true
+ defp empty?(_), do: false
+end
diff --git a/lib/seo/json_ld/article.ex b/lib/seo/json_ld/article.ex
new file mode 100644
index 0000000..4349977
--- /dev/null
+++ b/lib/seo/json_ld/article.ex
@@ -0,0 +1,44 @@
+defmodule SEO.JsonLD.Article do
+ @moduledoc """
+ Helper for building a Schema.org [Article](https://schema.org/Article) JSON-LD structure.
+
+ ## Example
+
+ SEO.JsonLD.Article.build(
+ headline: "My Post",
+ description: "A post about things",
+ datePublished: ~D[2024-01-15],
+ author: %{"@type" => "Person", "name" => "Jane Doe"}
+ )
+ """
+
+ @doc """
+ Build an Article JSON-LD map.
+
+ ## Fields
+
+ - `:headline` - The headline of the article
+ - `:description` - A short description
+ - `:datePublished` - Date/DateTime/string when the article was published
+ - `:dateModified` - Date/DateTime/string when the article was last modified
+ - `:author` - A map or list of maps describing the author(s)
+ - `:publisher` - A map describing the publisher
+ - `:image` - URL string or list of URL strings for article images
+ - `:mainEntityOfPage` - URL of the page this article is the main entity of
+ """
+ @spec build(Keyword.t() | map()) :: map()
+ def build(attrs) do
+ attrs
+ |> Enum.into(%{})
+ |> maybe_format_date(:datePublished)
+ |> maybe_format_date(:dateModified)
+ |> Map.merge(%{"@context" => "https://schema.org", "@type" => "Article"})
+ end
+
+ defp maybe_format_date(map, key) do
+ case Map.get(map, key) do
+ nil -> map
+ date -> Map.put(map, key, SEO.Utils.to_iso8601(date))
+ end
+ end
+end
diff --git a/lib/seo/json_ld/build.ex b/lib/seo/json_ld/build.ex
new file mode 100644
index 0000000..ef8806a
--- /dev/null
+++ b/lib/seo/json_ld/build.ex
@@ -0,0 +1,31 @@
+defprotocol SEO.JsonLD.Build do
+ @fallback_to_any true
+ @moduledoc """
+ Derive or implement this protocol to build JSON-LD structured data for your schema.
+
+ Implement `build/2` which receives your item and conn and returns a map, list of maps,
+ or `nil`.
+
+ The map(s) should contain `"@context"` and `"@type"` keys, or you can use one of the
+ helper modules like `SEO.JsonLD.Article` to build them.
+
+ ## Example
+
+ defimpl SEO.JsonLD.Build, for: MyApp.Article do
+ def build(article, _conn) do
+ SEO.JsonLD.Article.build(
+ headline: article.title,
+ description: article.description,
+ datePublished: article.published_at
+ )
+ end
+ end
+ """
+
+ @spec build(term, Plug.Conn.t()) :: map() | list(map()) | nil
+ def build(thing, conn)
+end
+
+defimpl SEO.JsonLD.Build, for: Any do
+ def build(_item, _conn), do: nil
+end
diff --git a/lib/seo/json_ld/event.ex b/lib/seo/json_ld/event.ex
new file mode 100644
index 0000000..01b52de
--- /dev/null
+++ b/lib/seo/json_ld/event.ex
@@ -0,0 +1,46 @@
+defmodule SEO.JsonLD.Event do
+ @moduledoc """
+ Helper for building a Schema.org [Event](https://schema.org/Event) JSON-LD structure.
+
+ ## Example
+
+ SEO.JsonLD.Event.build(
+ name: "ElixirConf 2024",
+ startDate: ~D[2024-08-28],
+ location: %{"@type" => "Place", "name" => "Gaylord Rockies"}
+ )
+ """
+
+ @doc """
+ Build an Event JSON-LD map.
+
+ ## Fields
+
+ - `:name` - The name of the event
+ - `:startDate` - Date/DateTime/string when the event starts
+ - `:endDate` - Date/DateTime/string when the event ends
+ - `:location` - A map describing the location
+ - `:description` - A short description
+ - `:image` - URL or list of URLs for event images
+ - `:organizer` - A map describing the organizer
+ - `:performer` - A map or list of maps describing performers
+ - `:offers` - A map or list of maps describing ticket offers
+ - `:eventStatus` - Event status URL
+ - `:eventAttendanceMode` - Attendance mode URL
+ """
+ @spec build(Keyword.t() | map()) :: map()
+ def build(attrs) do
+ attrs
+ |> Enum.into(%{})
+ |> maybe_format_date(:startDate)
+ |> maybe_format_date(:endDate)
+ |> Map.merge(%{"@context" => "https://schema.org", "@type" => "Event"})
+ end
+
+ defp maybe_format_date(map, key) do
+ case Map.get(map, key) do
+ nil -> map
+ date -> Map.put(map, key, SEO.Utils.to_iso8601(date))
+ end
+ end
+end
diff --git a/lib/seo/json_ld/faq.ex b/lib/seo/json_ld/faq.ex
new file mode 100644
index 0000000..3a52ca8
--- /dev/null
+++ b/lib/seo/json_ld/faq.ex
@@ -0,0 +1,41 @@
+defmodule SEO.JsonLD.FAQ do
+ @moduledoc """
+ Helper for building a Schema.org [FAQPage](https://schema.org/FAQPage) JSON-LD structure.
+
+ Takes a list of question/answer pairs and wraps them in the correct Schema.org format.
+
+ ## Example
+
+ SEO.JsonLD.FAQ.build([
+ %{question: "What is Elixir?", answer: "A functional programming language."},
+ %{question: "What is Phoenix?", answer: "A web framework for Elixir."}
+ ])
+ """
+
+ @doc """
+ Build a FAQPage JSON-LD map from a list of question/answer pairs.
+
+ Each item in the list should be a map or keyword list with `:question` and `:answer` keys.
+ """
+ @spec build(list(map() | Keyword.t())) :: map()
+ def build(qa_pairs) when is_list(qa_pairs) do
+ %{
+ "@context" => "https://schema.org",
+ "@type" => "FAQPage",
+ "mainEntity" => Enum.map(qa_pairs, &build_question/1)
+ }
+ end
+
+ defp build_question(qa) do
+ qa = Enum.into(qa, %{})
+
+ %{
+ "@type" => "Question",
+ "name" => qa[:question],
+ "acceptedAnswer" => %{
+ "@type" => "Answer",
+ "text" => qa[:answer]
+ }
+ }
+ end
+end
diff --git a/lib/seo/json_ld/local_business.ex b/lib/seo/json_ld/local_business.ex
new file mode 100644
index 0000000..49bca11
--- /dev/null
+++ b/lib/seo/json_ld/local_business.ex
@@ -0,0 +1,35 @@
+defmodule SEO.JsonLD.LocalBusiness do
+ @moduledoc """
+ Helper for building a Schema.org [LocalBusiness](https://schema.org/LocalBusiness) JSON-LD structure.
+
+ ## Example
+
+ SEO.JsonLD.LocalBusiness.build(
+ name: "Joe's Pizza",
+ address: %{"@type" => "PostalAddress", "streetAddress" => "123 Main St"},
+ telephone: "+1-555-555-5555"
+ )
+ """
+
+ @doc """
+ Build a LocalBusiness JSON-LD map.
+
+ ## Fields
+
+ - `:name` - The name of the business
+ - `:address` - A map describing the postal address
+ - `:telephone` - Contact phone number
+ - `:url` - The URL of the business's website
+ - `:image` - URL or list of URLs for business images
+ - `:priceRange` - The price range, e.g. "$$"
+ - `:openingHoursSpecification` - A map or list of maps for opening hours
+ - `:geo` - A map with latitude and longitude
+ - `:sameAs` - List of URLs for social profiles
+ """
+ @spec build(Keyword.t() | map()) :: map()
+ def build(attrs) do
+ attrs
+ |> Enum.into(%{})
+ |> Map.merge(%{"@context" => "https://schema.org", "@type" => "LocalBusiness"})
+ end
+end
diff --git a/lib/seo/json_ld/organization.ex b/lib/seo/json_ld/organization.ex
new file mode 100644
index 0000000..683efbf
--- /dev/null
+++ b/lib/seo/json_ld/organization.ex
@@ -0,0 +1,34 @@
+defmodule SEO.JsonLD.Organization do
+ @moduledoc """
+ Helper for building a Schema.org [Organization](https://schema.org/Organization) JSON-LD structure.
+
+ ## Example
+
+ SEO.JsonLD.Organization.build(
+ name: "Acme Corp",
+ url: "https://acme.com",
+ logo: "https://acme.com/logo.png"
+ )
+ """
+
+ @doc """
+ Build an Organization JSON-LD map.
+
+ ## Fields
+
+ - `:name` - The name of the organization
+ - `:url` - The URL of the organization's website
+ - `:logo` - URL of the organization's logo
+ - `:sameAs` - List of URLs for the organization's social profiles
+ - `:description` - A short description
+ - `:email` - Contact email
+ - `:telephone` - Contact phone number
+ - `:address` - A map describing the postal address
+ """
+ @spec build(Keyword.t() | map()) :: map()
+ def build(attrs) do
+ attrs
+ |> Enum.into(%{})
+ |> Map.merge(%{"@context" => "https://schema.org", "@type" => "Organization"})
+ end
+end
diff --git a/lib/seo/json_ld/product.ex b/lib/seo/json_ld/product.ex
new file mode 100644
index 0000000..ab4fa3a
--- /dev/null
+++ b/lib/seo/json_ld/product.ex
@@ -0,0 +1,34 @@
+defmodule SEO.JsonLD.Product do
+ @moduledoc """
+ Helper for building a Schema.org [Product](https://schema.org/Product) JSON-LD structure.
+
+ ## Example
+
+ SEO.JsonLD.Product.build(
+ name: "Widget",
+ description: "A great widget",
+ offers: %{"@type" => "Offer", "price" => "19.99", "priceCurrency" => "USD"}
+ )
+ """
+
+ @doc """
+ Build a Product JSON-LD map.
+
+ ## Fields
+
+ - `:name` - The name of the product
+ - `:description` - A short description
+ - `:image` - URL or list of URLs for product images
+ - `:brand` - A map describing the brand
+ - `:offers` - A map or list of maps describing offers/pricing
+ - `:sku` - The Stock Keeping Unit
+ - `:review` - A map or list of maps for reviews
+ - `:aggregateRating` - A map describing the aggregate rating
+ """
+ @spec build(Keyword.t() | map()) :: map()
+ def build(attrs) do
+ attrs
+ |> Enum.into(%{})
+ |> Map.merge(%{"@context" => "https://schema.org", "@type" => "Product"})
+ end
+end
diff --git a/mix.exs b/mix.exs
index f85a692..f4d6311 100644
--- a/mix.exs
+++ b/mix.exs
@@ -75,6 +75,7 @@ defmodule SEO.MixProject do
[
Domains: [
SEO.Breadcrumb,
+ SEO.JsonLD,
SEO.OpenGraph,
SEO.Site,
SEO.Twitter,
@@ -93,6 +94,14 @@ defmodule SEO.MixProject do
SEO.Breadcrumb.List,
SEO.Breadcrumb.ListItem
],
+ "JSON-LD": [
+ SEO.JsonLD.Article,
+ SEO.JsonLD.Event,
+ SEO.JsonLD.FAQ,
+ SEO.JsonLD.LocalBusiness,
+ SEO.JsonLD.Organization,
+ SEO.JsonLD.Product
+ ],
LLMs: [
SEO.LLMs,
SEO.LLMs.Entry,
@@ -100,6 +109,7 @@ defmodule SEO.MixProject do
],
Protocol: [
SEO.Breadcrumb.Build,
+ SEO.JsonLD.Build,
SEO.OpenGraph.Build,
SEO.Site.Build,
SEO.Twitter.Build,
diff --git a/test/seo/json_ld_test.exs b/test/seo/json_ld_test.exs
new file mode 100644
index 0000000..c8fac5d
--- /dev/null
+++ b/test/seo/json_ld_test.exs
@@ -0,0 +1,275 @@
+defmodule SEO.JsonLDTest do
+ use ExUnit.Case, async: true
+ import Phoenix.LiveViewTest
+ alias SEO.JsonLD
+ alias SEO.JsonLD.{Article, Event, FAQ, LocalBusiness, Organization, Product}
+
+ describe "meta" do
+ test "renders a single JSON-LD item" do
+ item = %{
+ "@context" => "https://schema.org",
+ "@type" => "Organization",
+ "name" => "Acme Corp",
+ "url" => "https://acme.com"
+ }
+
+ result = render_component(&JsonLD.meta/1, build_assigns(item))
+ {:ok, html} = Floki.parse_fragment(result)
+ ld = linking_data(html)
+
+ assert ld["@context"] == "https://schema.org"
+ assert ld["@type"] == "Organization"
+ assert ld["name"] == "Acme Corp"
+ end
+
+ test "renders a list of JSON-LD items" do
+ items = [
+ %{"@context" => "https://schema.org", "@type" => "Organization", "name" => "Acme"},
+ %{"@context" => "https://schema.org", "@type" => "WebSite", "name" => "Acme Site"}
+ ]
+
+ result = render_component(&JsonLD.meta/1, build_assigns(items))
+ {:ok, html} = Floki.parse_fragment(result)
+ ld = linking_data(html)
+
+ assert is_list(ld)
+ assert length(ld) == 2
+ assert Enum.at(ld, 0)["@type"] == "Organization"
+ assert Enum.at(ld, 1)["@type"] == "WebSite"
+ end
+
+ test "doesn't render when item is nil" do
+ result = render_component(&JsonLD.meta/1, build_assigns(nil))
+ assert result == ""
+ end
+
+ test "doesn't render when item is empty list" do
+ result = render_component(&JsonLD.meta/1, build_assigns([]))
+ assert result == ""
+ end
+
+ test "doesn't render when item is empty map" do
+ result = render_component(&JsonLD.meta/1, build_assigns(%{}))
+ assert result == ""
+ end
+
+ test "renders item built from map with atom keys" do
+ item = %{
+ "@context": "https://schema.org",
+ "@type": "Organization",
+ name: "Acme Corp"
+ }
+
+ result = render_component(&JsonLD.meta/1, build_assigns(item))
+ {:ok, html} = Floki.parse_fragment(result)
+ ld = linking_data(html)
+
+ assert ld["@type"] == "Organization"
+ assert ld["name"] == "Acme Corp"
+ end
+
+ test "drops nil values from item" do
+ item = %{
+ "@context" => "https://schema.org",
+ "@type" => "Organization",
+ "name" => "Acme",
+ "description" => nil
+ }
+
+ result = render_component(&JsonLD.meta/1, build_assigns(item))
+ {:ok, html} = Floki.parse_fragment(result)
+ ld = linking_data(html)
+
+ refute Map.has_key?(ld, "description")
+ end
+ end
+
+ describe "Article helper" do
+ test "builds an Article with required fields" do
+ item =
+ Article.build(
+ headline: "My Great Post",
+ description: "A post about things",
+ datePublished: ~D[2024-01-15]
+ )
+
+ result = render_component(&JsonLD.meta/1, build_assigns(item))
+ {:ok, html} = Floki.parse_fragment(result)
+ ld = linking_data(html)
+
+ assert ld["@context"] == "https://schema.org"
+ assert ld["@type"] == "Article"
+ assert ld["headline"] == "My Great Post"
+ assert ld["description"] == "A post about things"
+ assert ld["datePublished"] == "2024-01-15"
+ end
+
+ test "builds an Article with all fields" do
+ item =
+ Article.build(
+ headline: "My Post",
+ description: "About things",
+ datePublished: ~D[2024-01-15],
+ dateModified: ~D[2024-02-01],
+ author: %{"@type" => "Person", "name" => "Jane"},
+ publisher: %{"@type" => "Organization", "name" => "Acme"},
+ image: "https://example.com/img.jpg"
+ )
+
+ result = render_component(&JsonLD.meta/1, build_assigns(item))
+ {:ok, html} = Floki.parse_fragment(result)
+ ld = linking_data(html)
+
+ assert ld["author"] == %{"@type" => "Person", "name" => "Jane"}
+ assert ld["publisher"] == %{"@type" => "Organization", "name" => "Acme"}
+ assert ld["image"] == "https://example.com/img.jpg"
+ assert ld["dateModified"] == "2024-02-01"
+ end
+ end
+
+ describe "Organization helper" do
+ test "builds an Organization" do
+ item =
+ Organization.build(
+ name: "Acme Corp",
+ url: "https://acme.com",
+ logo: "https://acme.com/logo.png",
+ sameAs: ["https://twitter.com/acme", "https://facebook.com/acme"]
+ )
+
+ result = render_component(&JsonLD.meta/1, build_assigns(item))
+ {:ok, html} = Floki.parse_fragment(result)
+ ld = linking_data(html)
+
+ assert ld["@context"] == "https://schema.org"
+ assert ld["@type"] == "Organization"
+ assert ld["name"] == "Acme Corp"
+ assert ld["url"] == "https://acme.com"
+ assert ld["logo"] == "https://acme.com/logo.png"
+ assert ld["sameAs"] == ["https://twitter.com/acme", "https://facebook.com/acme"]
+ end
+ end
+
+ describe "FAQ helper" do
+ test "builds a FAQPage from question/answer pairs" do
+ item =
+ FAQ.build([
+ %{question: "What is Elixir?", answer: "A functional programming language."},
+ %{question: "What is Phoenix?", answer: "A web framework for Elixir."}
+ ])
+
+ result = render_component(&JsonLD.meta/1, build_assigns(item))
+ {:ok, html} = Floki.parse_fragment(result)
+ ld = linking_data(html)
+
+ assert ld["@context"] == "https://schema.org"
+ assert ld["@type"] == "FAQPage"
+ assert length(ld["mainEntity"]) == 2
+
+ [q1, q2] = ld["mainEntity"]
+ assert q1["@type"] == "Question"
+ assert q1["name"] == "What is Elixir?"
+ assert q1["acceptedAnswer"]["@type"] == "Answer"
+ assert q1["acceptedAnswer"]["text"] == "A functional programming language."
+
+ assert q2["name"] == "What is Phoenix?"
+ end
+ end
+
+ describe "Product helper" do
+ test "builds a Product" do
+ item =
+ Product.build(
+ name: "Widget",
+ description: "A great widget",
+ image: "https://example.com/widget.jpg",
+ brand: %{"@type" => "Brand", "name" => "Acme"},
+ offers: %{
+ "@type" => "Offer",
+ "price" => "19.99",
+ "priceCurrency" => "USD",
+ "availability" => "https://schema.org/InStock"
+ }
+ )
+
+ result = render_component(&JsonLD.meta/1, build_assigns(item))
+ {:ok, html} = Floki.parse_fragment(result)
+ ld = linking_data(html)
+
+ assert ld["@type"] == "Product"
+ assert ld["name"] == "Widget"
+ assert ld["offers"]["price"] == "19.99"
+ assert ld["brand"]["name"] == "Acme"
+ end
+ end
+
+ describe "LocalBusiness helper" do
+ test "builds a LocalBusiness" do
+ item =
+ LocalBusiness.build(
+ name: "Joe's Pizza",
+ address: %{
+ "@type" => "PostalAddress",
+ "streetAddress" => "123 Main St",
+ "addressLocality" => "Springfield",
+ "addressRegion" => "IL",
+ "postalCode" => "62701"
+ },
+ telephone: "+1-555-555-5555",
+ openingHoursSpecification: %{
+ "@type" => "OpeningHoursSpecification",
+ "dayOfWeek" => ["Monday", "Tuesday"],
+ "opens" => "11:00",
+ "closes" => "22:00"
+ }
+ )
+
+ result = render_component(&JsonLD.meta/1, build_assigns(item))
+ {:ok, html} = Floki.parse_fragment(result)
+ ld = linking_data(html)
+
+ assert ld["@type"] == "LocalBusiness"
+ assert ld["name"] == "Joe's Pizza"
+ assert ld["address"]["streetAddress"] == "123 Main St"
+ assert ld["telephone"] == "+1-555-555-5555"
+ end
+ end
+
+ describe "Event helper" do
+ test "builds an Event" do
+ item =
+ Event.build(
+ name: "ElixirConf 2024",
+ startDate: ~D[2024-08-28],
+ endDate: ~D[2024-08-30],
+ location: %{
+ "@type" => "Place",
+ "name" => "Gaylord Rockies",
+ "address" => "6700 N Gaylord Rockies Blvd"
+ },
+ description: "The Elixir conference"
+ )
+
+ result = render_component(&JsonLD.meta/1, build_assigns(item))
+ {:ok, html} = Floki.parse_fragment(result)
+ ld = linking_data(html)
+
+ assert ld["@type"] == "Event"
+ assert ld["name"] == "ElixirConf 2024"
+ assert ld["startDate"] == "2024-08-28"
+ assert ld["endDate"] == "2024-08-30"
+ assert ld["location"]["name"] == "Gaylord Rockies"
+ end
+ end
+
+ defp build_assigns(item) do
+ [item: item, config: %{}, json_library: Jason]
+ end
+
+ defp linking_data(html) do
+ case Floki.find(html, "script[type='application/ld+json']") do
+ [{"script", _, json}] -> Jason.decode!(json)
+ _ -> false
+ end
+ end
+end
diff --git a/test/seo_test.exs b/test/seo_test.exs
index 622acee..a62d35e 100644
--- a/test/seo_test.exs
+++ b/test/seo_test.exs
@@ -43,11 +43,12 @@ defmodule SEOTest do
# facebook
assert meta_content(html, "name='fb:app_id'", "123")
- # breadcrumb
- ld = linking_data(html)
- assert ld["@type"] == "BreadcrumbList"
+ # breadcrumb + json_ld
+ [breadcrumb_ld, json_ld] = linking_data(html)
- assert ld["itemListElement"] == [
+ assert breadcrumb_ld["@type"] == "BreadcrumbList"
+
+ assert breadcrumb_ld["itemListElement"] == [
%{
"@type" => "ListItem",
"item" => "https://example.com/articles",
@@ -61,6 +62,9 @@ defmodule SEOTest do
"position" => 2
}
]
+
+ assert json_ld["@type"] == "Article"
+ assert json_ld["headline"] == "Title"
end
test "renders almost nothing when struct not implemented" do
diff --git a/test/support/helpers.ex b/test/support/helpers.ex
index c8ee01b..cf1c8bd 100644
--- a/test/support/helpers.ex
+++ b/test/support/helpers.ex
@@ -47,6 +47,9 @@ defmodule SEO.Test.Helpers do
[{"script", _, json}] ->
Jason.decode!(json)
+ scripts when is_list(scripts) and length(scripts) > 0 ->
+ Enum.map(scripts, fn {"script", _, json} -> Jason.decode!(json) end)
+
_ ->
false
end
diff --git a/test/support/my_app_web_impl.ex b/test/support/my_app_web_impl.ex
index b47e6b6..877a622 100644
--- a/test/support/my_app_web_impl.ex
+++ b/test/support/my_app_web_impl.ex
@@ -102,3 +102,13 @@ defimpl SEO.Breadcrumb.Build, for: MyApp.Article do
])
end
end
+
+defimpl SEO.JsonLD.Build, for: MyApp.Article do
+ def build(article, _conn) do
+ SEO.JsonLD.Article.build(
+ headline: article.title,
+ description: article.description,
+ datePublished: "2022-10-13"
+ )
+ end
+end