Packages

SEO optimization tools for Phoenix and Phoenix LiveView applications

Current section

Files

Jump to
Raw

lib/seo.ex

defmodule PhoenixSEOTools.SEO do
@moduledoc """
Core functionality for generating SEO-related metadata for your Phoenix application.
This module provides functions to generate various SEO elements:
- Basic meta tags (title, description, image)
- Open Graph tags for social media sharing
- JSON-LD structured data (Organization, Website, Article, BreadcrumbList)
- Canonical URLs
## Configuration
To use this module, add the following to your application config:
```elixir
config :phoenix_seo_tools,
name: "Your Site Name",
url: "https://yourdomain.com",
logo_url: "https://yourdomain.com/images/logo.png",
description: "Your site description",
social_media_links: [
"https://twitter.com/yourhandle",
"https://facebook.com/yourpage"
],
author: "Your Name"
```
"""
alias Phoenix.LiveView.Socket
alias PhoenixSEOTools.PageLink
alias PhoenixSEOTools.PageMeta
@doc """
Builds metadata for a page and assigns it to the connection or socket.
This is the main function you'll use in your controllers or LiveViews to add SEO
elements to your pages.
## Parameters
* `conn_or_socket` - A Plug.Conn or Phoenix.LiveView.Socket
* `options` - Keyword list of options:
* `:title` - The page title (required)
* `:description` - A description of the page (optional)
* `:image` - URL to an image representing the page (optional)
* `:breadcrumbs` - List of breadcrumb items (optional), each item should be a map with `:label` and `:to` keys
* `:article` - Article details for blog posts or articles (optional), should be a map with `:title`, `:description`, `:image`, `:inserted_at`, and `:slug` keys
## Returns
* A conn or socket with the `:meta` assign containing all generated metadata
## Examples
```elixir
# In a controller:
conn = PhoenixSEOTools.SEO.build_meta(conn, title: "Welcome", description: "Our homepage")
# In a LiveView:
socket = PhoenixSEOTools.SEO.build_meta(socket,
title: "Blog Post",
description: "An interesting article",
image: "https://example.com/images/post.jpg",
breadcrumbs: [
%{label: "Home", to: "/"},
%{label: "Blog", to: "/blog"}
],
article: %{
title: "Blog Post",
description: "An interesting article",
image: "https://example.com/images/post.jpg",
inserted_at: ~N[2023-01-01 12:00:00],
slug: "blog-post"
}
)
```
"""
def build_meta(conn_or_socket, options \\ []) do
defaults = [
title: Application.get_env(:phoenix_seo_tools, :name),
description: Application.get_env(:phoenix_seo_tools, :description),
image: Application.get_env(:phoenix_seo_tools, :logo_url),
breadcrumbs: [],
article: nil,
site_name: Application.get_env(:phoenix_seo_tools, :name),
site_url: Application.get_env(:phoenix_seo_tools, :url),
site_logo_url: Application.get_env(:phoenix_seo_tools, :logo_url),
site_description: Application.get_env(:phoenix_seo_tools, :description),
site_social_media_links: Application.get_env(:phoenix_seo_tools, :social_media_links),
site_author: Application.get_env(:phoenix_seo_tools, :author)
]
options = options |> Keyword.validate!(defaults) |> Map.new()
metas =
List.flatten([
build_page_seo(conn_or_socket, options),
build_open_graph(conn_or_socket, options)
])
schemas =
[
build_website_schema(options),
build_org_schema(options)
]
links = List.flatten([build_page_links(conn_or_socket, options)])
{schemas, breadcrumbs} =
if Enum.empty?(options.breadcrumbs) do
{schemas, []}
else
schemas = schemas ++ build_breadcrumb_schema(options.breadcrumbs, options)
{schemas, options.breadcrumbs}
end
schemas =
if is_nil(options.article) do
schemas
else
schemas ++ build_article_schema(options.article, options)
end
schemas = List.flatten(schemas)
assign(conn_or_socket, :meta, %{
page_title: options.title,
breadcrumbs: breadcrumbs,
links: links,
metas: metas,
schemas: schemas
})
end
defp build_page_seo(_conn_or_socket, options) do
Enum.reject(
[
new_page_meta("title", build_page_title(options.title, options)),
new_page_meta("description", options.description |> strip_html_tags() |> truncate()),
new_page_meta("image", options.image)
],
&is_nil(&1.content)
)
end
defp build_page_links(conn_or_socket, options) do
Enum.reject(
[new_page_link("canonical", get_current_url(conn_or_socket, options))],
&is_nil(&1.href)
)
end
defp build_open_graph(conn_or_socket, options) do
Enum.reject(
[
new_page_meta("og:title", build_page_title(options.title, options)),
new_page_meta("og:type", "website"),
new_page_meta("og:locale", "sv_SE"),
new_page_meta("og:description", options.description |> strip_html_tags() |> truncate()),
new_page_meta("og:url", get_current_url(conn_or_socket, options)),
new_page_meta("og:image", options.image)
],
&is_nil(&1.content)
)
end
defp build_website_schema(options) do
[
%{
"@context" => "https://schema.org/",
"@type" => "WebSite",
"name" => options.site_name,
"url" => options.site_url
}
]
end
defp build_org_schema(options) do
[
%{
"@context" => "https://schema.org/",
"@type" => "Organization",
"name" => options.site_name,
"url" => options.site_url,
"logo" => options.site_logo_url,
"description" => options.site_description,
"sameAs" => Keyword.values(options.site_social_media_links)
}
]
end
defp build_breadcrumb_schema(breadcrumbs, options) when is_list(breadcrumbs) do
base_url = options.site_url
[
%{
"@context" => "https://schema.org",
"@type" => "BreadcrumbList",
"itemListElement" =>
breadcrumbs
|> Enum.with_index(1)
|> Enum.map(fn {breadcrumb, index} ->
%{
"@type" => "ListItem",
"position" => index,
"name" => breadcrumb[:label],
"item" => base_url |> URI.merge(breadcrumb[:to]) |> URI.to_string()
}
end)
}
]
end
def build_article_schema(article, options) do
[
%{
"@context" => "https://schema.org",
"@type" => "Article",
"headline" => article.title,
"description" => article.description,
"image" => article.image,
"datePublished" => article.inserted_at,
"mainEntityOfPage" => %{
"@type" => "WebPage",
# Replace with the actual URL of the article if needed
"@id" => "#{options.site_url}/#{article.slug}"
},
"author" => %{
"@type" => "Person",
"name" => options.site_author
},
"publisher" => %{
"@type" => "Organization",
"name" => options.site_name,
"logo" => %{
"@type" => "ImageObject",
"url" => options.site_logo_url
}
}
}
]
end
defp build_page_title(title, options) when title in [nil, ""] do
options.site_name
end
defp build_page_title(title, options) do
"#{title} - #{options.site_name}"
end
defp assign(%Socket{} = conn_or_socket, key, value) do
Phoenix.Component.assign(conn_or_socket, key, value)
end
defp assign(conn_or_socket, key, value) do
Plug.Conn.assign(conn_or_socket, key, value)
end
defp get_current_url(%Socket{} = conn_or_socket, options) do
base_url = options.site_url
if conn_or_socket.assigns[:current_uri] do
base_url
|> URI.merge(conn_or_socket.assigns.current_uri.path)
|> URI.to_string()
# URI.to_string(%{conn_or_socket.assigns.current_uri | query: nil})
else
""
end
end
defp get_current_url(conn_or_socket, options) do
base_url = options.site_url
current = URI.parse(Phoenix.Controller.current_url(conn_or_socket, %{}))
base_url
|> URI.merge(current.path)
|> URI.to_string()
end
defp new_page_meta(name, content) do
%PageMeta{name: name, content: content}
end
defp new_page_link(rel, href) do
%PageLink{rel: rel, href: href}
end
@spec truncate(String.t()) :: String.t()
def truncate(string) do
truncate(string, 40)
end
def truncate(nil, _) do
nil
end
@spec truncate(String.t(), integer()) :: String.t()
def truncate(string, max_length, trail \\ "..") do
if String.length(string) > max_length do
"#{String.slice(string, 0, max_length)}#{trail}"
else
string
end
end
defp strip_html_tags(nil) do
nil
end
defp strip_html_tags(string) when is_binary(string) do
Regex.replace(~r/<[^>]*>/, string, "")
end
end