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)
* `:schemas` - List of structured data schemas (optional) - see Structured Data section below
## Structured Data Schemas
You can pass any schema.org structured data in the `:schemas` option. The library provides
helper functions to build common schemas:
```elixir
# Job posting example
conn = PhoenixSEOTools.SEO.build_meta(conn,
title: "Senior Developer Position",
description: "We're hiring!",
schemas: [
PhoenixSEOTools.SEO.job_posting(
title: "Senior Developer",
description: "Job description",
company_name: "Acme Corp",
company_logo: "https://acme.com/logo.png",
location: "Stockholm, Sweden",
posted_date: ~D[2024-01-01],
expiry_date: ~D[2024-12-31],
company_url: "https://acme.com",
employment_type: :full_time,
application_url: "https://apply.com"
)
]
)
# Job postings list example
conn = PhoenixSEOTools.SEO.build_meta(conn,
title: "Job Listings",
schemas: [
PhoenixSEOTools.SEO.job_postings_list([
%{title: "Developer", url: "/jobs/developer", ...},
%{title: "Designer", url: "/jobs/designer", ...}
])
]
)
```
## 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,
schemas: [],
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 = schemas ++ (options.schemas || [])
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
@doc """
Creates a JobPosting schema for structured data.
## Parameters
* `opts` - Keyword list with the following keys:
* `:title` - Job title (required)
* `:description` - Job description (required)
* `:company_name` - Name of the hiring company (required)
* `:location` - Job location (required)
* `:url` - URL to the job listing (required)
* `:company_logo` - URL to company logo (optional)
* `:company_url` - Company website URL (optional)
* `:posted_date` - Date job was posted (optional)
* `:expiry_date` - Job expiration date (optional)
* `:employment_type` - One of `:full_time`, `:part_time`, `:contract`, `:temporary`, `:intern`, `:volunteer`, `:per_diem`, `:other` (optional, defaults to `:full_time`)
* `:remote_allowed` - Boolean indicating if remote work is allowed (optional)
* `:salary_min` - Minimum salary (optional)
* `:salary_max` - Maximum salary (optional)
* `:salary_currency` - Currency code like "USD" or "SEK" (optional)
* `:application_url` - URL to apply (optional)
* `:application_email` - Email to apply (optional)
## Examples
iex> PhoenixSEOTools.SEO.job_posting(
...> title: "Senior Elixir Developer",
...> description: "We're looking for an experienced Elixir developer...",
...> company_name: "Acme Corp",
...> location: "Stockholm, Sweden",
...> url: "https://example.com/jobs/senior-elixir-developer",
...> posted_date: ~D[2024-01-01],
...> employment_type: :full_time,
...> salary_min: 50000,
...> salary_max: 80000,
...> salary_currency: "SEK"
...> )
"""
def job_posting(opts) do
required_keys = [:title, :description, :company_name, :location, :url]
Enum.each(required_keys, fn key ->
if is_nil(opts[key]) do
raise ArgumentError, "#{key} is required for job_posting"
end
end)
employment_type_map = %{
full_time: "FULL_TIME",
part_time: "PART_TIME",
contract: "CONTRACT",
temporary: "TEMPORARY",
intern: "INTERN",
volunteer: "VOLUNTEER",
per_diem: "PER_DIEM",
other: "OTHER"
}
schema = %{
"@context" => "https://schema.org",
"@type" => "JobPosting",
"title" => opts[:title],
"description" => strip_html_tags(opts[:description]),
"url" => opts[:url],
"hiringOrganization" => %{
"@type" => "Organization",
"name" => opts[:company_name]
},
"jobLocation" => %{
"@type" => "Place",
"address" => %{
"@type" => "PostalAddress",
"addressLocality" => opts[:location],
"addressCountry" => opts[:country] || "SE"
}
}
}
# Add optional fields
schema = if opts[:company_logo], do: put_in(schema["hiringOrganization"]["logo"], opts[:company_logo]), else: schema
schema = if opts[:company_url], do: put_in(schema["hiringOrganization"]["sameAs"], opts[:company_url]), else: schema
schema = if opts[:posted_date], do: Map.put(schema, "datePosted", format_date(opts[:posted_date])), else: schema
schema = if opts[:expiry_date], do: Map.put(schema, "validThrough", format_date(opts[:expiry_date])), else: schema
employment_type = Map.get(employment_type_map, opts[:employment_type] || :full_time, "FULL_TIME")
schema = Map.put(schema, "employmentType", employment_type)
# Add remote work option
schema = if opts[:remote_allowed] do
Map.put(schema, "jobLocationType", "TELECOMMUTE")
else
schema
end
# Add salary information
schema = if opts[:salary_min] || opts[:salary_max] do
base_salary = %{
"@type" => "MonetaryAmount",
"currency" => opts[:salary_currency] || "USD"
}
base_salary = if opts[:salary_min] && opts[:salary_max] do
Map.merge(base_salary, %{
"value" => %{
"@type" => "QuantitativeValue",
"minValue" => opts[:salary_min],
"maxValue" => opts[:salary_max],
"unitText" => "MONTH"
}
})
else
value = opts[:salary_min] || opts[:salary_max]
Map.put(base_salary, "value", value)
end
Map.put(schema, "baseSalary", base_salary)
else
schema
end
# Add application information
cond do
opts[:application_url] ->
Map.put(schema, "applicationContact", %{
"@type" => "ContactPoint",
"url" => opts[:application_url]
})
opts[:application_email] ->
Map.put(schema, "applicationContact", %{
"@type" => "ContactPoint",
"email" => opts[:application_email]
})
true ->
schema
end
end
@doc """
Creates an ItemList schema containing multiple job postings.
## Parameters
* `job_postings` - List of job posting data, each item should have the same fields as `job_posting/1`
## Examples
iex> PhoenixSEOTools.SEO.job_postings_list([
...> %{title: "Developer", description: "...", company_name: "Acme", location: "Stockholm", url: "/jobs/1"},
...> %{title: "Designer", description: "...", company_name: "Acme", location: "Stockholm", url: "/jobs/2"}
...> ])
"""
def job_postings_list(job_postings) when is_list(job_postings) do
%{
"@context" => "https://schema.org",
"@type" => "ItemList",
"itemListElement" =>
job_postings
|> Enum.with_index(1)
|> Enum.map(fn {job_data, index} ->
# Ensure it has an @id if not already present
item_schema = job_posting(job_data)
item_schema = Map.put_new(item_schema, "@id", job_data[:url])
%{
"@type" => "ListItem",
"position" => index,
"item" => item_schema
}
end)
}
end
defp format_date(nil), do: nil
defp format_date(%Date{} = date), do: Date.to_iso8601(date)
defp format_date(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime)
defp format_date(%NaiveDateTime{} = datetime), do: NaiveDateTime.to_iso8601(datetime)
end