add SEO enhancements: OG images, meta robots, FAQ block, image sitemap
All checks were successful
deploy / deploy (push) Successful in 4m59s
All checks were successful
deploy / deploy (push) Successful in 4m59s
- Per-page SEO controls: meta robots directives, focus keyword, OG image - Site-wide default OG image in admin settings - FAQ block type with FAQPage JSON-LD schema - Enhanced Organization JSON-LD with business info, contact, address - Image sitemap with product images - SEO preview panel with Google/social card mockups - SEO checklist with real-time scoring - Business info section in site editor - GSC integration scaffolding (OAuth, client, cache) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
107
lib/berrypod_web/helpers/seo_helpers.ex
Normal file
107
lib/berrypod_web/helpers/seo_helpers.ex
Normal file
@@ -0,0 +1,107 @@
|
||||
defmodule BerrypodWeb.Helpers.SeoHelpers do
|
||||
@moduledoc """
|
||||
SEO-related helpers for generating structured data.
|
||||
"""
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
alias Berrypod.Media
|
||||
|
||||
@doc """
|
||||
Generates FAQPage JSON-LD schema from page blocks.
|
||||
|
||||
Extracts FAQ items from any FAQ blocks on the page and builds a valid
|
||||
FAQPage schema. Returns nil if no FAQ blocks with valid content are found.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> faq_json_ld([%{"type" => "faq", "settings" => %{"items" => [...]}}])
|
||||
~s({"@context":"https://schema.org","@type":"FAQPage",...})
|
||||
|
||||
"""
|
||||
def faq_json_ld(blocks) when is_list(blocks) do
|
||||
questions =
|
||||
blocks
|
||||
|> Enum.filter(&(&1["type"] == "faq"))
|
||||
|> Enum.flat_map(fn block ->
|
||||
(block["settings"] || %{})["items"] || []
|
||||
end)
|
||||
|> Enum.filter(fn item ->
|
||||
question = String.trim(item["question"] || "")
|
||||
answer = String.trim(item["answer"] || "")
|
||||
question != "" and answer != ""
|
||||
end)
|
||||
|> Enum.map(fn item ->
|
||||
%{
|
||||
"@type" => "Question",
|
||||
"name" => item["question"],
|
||||
"acceptedAnswer" => %{
|
||||
"@type" => "Answer",
|
||||
"text" => item["answer"]
|
||||
}
|
||||
}
|
||||
end)
|
||||
|
||||
if questions != [] do
|
||||
%{
|
||||
"@context" => "https://schema.org",
|
||||
"@type" => "FAQPage",
|
||||
"mainEntity" => questions
|
||||
}
|
||||
|> Jason.encode!(escape: :html_safe)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def faq_json_ld(_), do: nil
|
||||
|
||||
@doc """
|
||||
Assigns an og_image URL to the socket based on page-specific or default image.
|
||||
|
||||
Priority: page-specific image > site-wide default > none
|
||||
"""
|
||||
def assign_og_image(socket, page, base) do
|
||||
og_image_id = get_og_image_id(page)
|
||||
|
||||
og_image =
|
||||
cond do
|
||||
og_image_id ->
|
||||
Media.get_image(og_image_id)
|
||||
|
||||
true ->
|
||||
Media.get_default_og_image()
|
||||
end
|
||||
|
||||
if og_image do
|
||||
url = og_image_url(og_image, base)
|
||||
assign(socket, :og_image, url)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp get_og_image_id(nil), do: nil
|
||||
defp get_og_image_id(%{og_image_id: id}), do: id
|
||||
defp get_og_image_id(page) when is_map(page), do: page[:og_image_id]
|
||||
defp get_og_image_id(_), do: nil
|
||||
|
||||
@doc """
|
||||
Generates a full URL for an OG image, preferring 1200px width for social sharing.
|
||||
|
||||
Falls back to the largest available width if the image is smaller than 1200px.
|
||||
"""
|
||||
def og_image_url(image, base) do
|
||||
path =
|
||||
if image.is_svg do
|
||||
"/image_cache/#{image.id}.webp"
|
||||
else
|
||||
widths = Berrypod.Images.Optimizer.applicable_widths(image.source_width)
|
||||
# Prefer 1200px or larger, otherwise use the largest available
|
||||
width = Enum.find(widths, List.last(widths), &(&1 >= 1200))
|
||||
|
||||
"/image_cache/#{image.id}-#{width}.webp"
|
||||
end
|
||||
|
||||
base <> path
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user