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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/seo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ defmodule SEO do
- `:facebook` -> `SEO.Facebook`
- `:twitter` -> `SEO.Twitter`
- `:breadcrumb` -> `SEO.Breadcrumb`
- `:json_ld` -> `SEO.JsonLD`

For example:

Expand Down Expand Up @@ -127,6 +128,7 @@ defmodule SEO do
<SEO.Twitter.meta config={@twitter_config} item={SEO.Twitter.Build.build(@item, @conn)} />
<SEO.Facebook.meta config={@facebook_config} item={SEO.Facebook.Build.build(@item, @conn)} />
<SEO.Breadcrumb.meta config={@breadcrumb_config} item={SEO.Breadcrumb.Build.build(@item, @conn)} json_library={@json_library} :if={@json_library} />
<SEO.JsonLD.meta config={@json_ld_config} item={SEO.JsonLD.Build.build(@item, @conn)} json_library={@json_library} :if={@json_library} />
"""
end

Expand Down
3 changes: 2 additions & 1 deletion lib/seo/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ defmodule SEO.Config do
twitter: %{},
unfurl: %{},
open_graph: %{},
breadcrumb: %{}
breadcrumb: %{},
json_ld: %{}
]

@callback config() :: map()
Expand Down
80 changes: 80 additions & 0 deletions lib/seo/json_ld.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
defmodule SEO.JsonLD do
@moduledoc """
Renders JSON-LD structured data as `<script type="application/ld+json">` tags.

JSON-LD (JavaScript Object Notation for Linked Data) allows you to provide
structured data to search engines in a machine-readable format, enabling
rich results in search listings.

You can pass any map (or list of maps) with `@context` and `@type` keys:

<SEO.JsonLD.meta
item={%{"@context" => "https://schema.org", "@type" => "Organization", "name" => "Acme"}}
json_library={Jason}
/>

Or use one of the helper modules for common Schema.org types:

- `SEO.JsonLD.Article`
- `SEO.JsonLD.Organization`
- `SEO.JsonLD.FAQ`
- `SEO.JsonLD.Product`
- `SEO.JsonLD.LocalBusiness`
- `SEO.JsonLD.Event`

### Resources

- https://json-ld.org/
- https://schema.org/
- https://developers.google.com/search/docs/appearance/structured-data
- https://search.google.com/test/rich-results
"""

use Phoenix.Component

attr :item, :any
attr :json_library, :atom, required: true
attr :config, :any, default: nil

def meta(assigns) do
assigns = assign(assigns, :item, sanitize(assigns[:item]))

~H"""
<script :if={@item} type="application/ld+json">
<%= Phoenix.HTML.raw(@json_library.encode!(@item)) %>
</script>
"""
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
44 changes: 44 additions & 0 deletions lib/seo/json_ld/article.ex
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions lib/seo/json_ld/build.ex
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions lib/seo/json_ld/event.ex
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions lib/seo/json_ld/faq.ex
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions lib/seo/json_ld/local_business.ex
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions lib/seo/json_ld/organization.ex
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions lib/seo/json_ld/product.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading