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