Files
berrypod/lib/berrypod/pages.ex
jamey 4aa7dece0c
All checks were successful
deploy / deploy (push) Successful in 4m59s
add SEO enhancements: OG images, meta robots, FAQ block, image sitemap
- 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>
2026-04-17 16:47:43 +01:00

388 lines
10 KiB
Elixir

defmodule Berrypod.Pages do
@moduledoc """
Context for database-driven page definitions.
Every page is a flat list of blocks stored as JSON. This module provides
the public API for reading, writing, and loading block data for pages.
Lookup order: ETS cache -> DB -> defaults (system pages only).
Custom pages return nil when not in DB.
"""
alias Berrypod.Repo
alias Berrypod.Pages.{Page, BlockTypes, Defaults, PageCache}
import Ecto.Query
# ── Read ──────────────────────────────────────────────────────────
@doc """
Gets a page definition by slug.
Checks the ETS cache first, falls back to DB, then to built-in defaults
(for system pages only). Returns a map with page fields, or nil for
unknown custom slugs.
"""
def get_page(slug) do
case PageCache.get(slug) do
{:ok, page_data} -> page_data
:miss -> get_page_uncached(slug)
end
rescue
# ETS table might not exist yet during startup
ArgumentError -> get_page_uncached(slug)
end
@doc """
Gets a page from the DB. System slugs fall back to defaults; custom slugs
return nil when not found. Used by PageCache.warm/0.
"""
def get_page_from_db(slug) do
case Repo.one(from p in Page, where: p.slug == ^slug) do
%Page{} = page ->
page_to_map(page)
nil ->
if Page.system_slug?(slug) do
Defaults.for_slug(slug)
else
nil
end
end
end
@doc "Gets the raw Page struct by slug. Returns nil if not in DB."
def get_page_struct(slug) do
Repo.one(from p in Page, where: p.slug == ^slug)
end
defp get_page_uncached(slug) do
page_data = get_page_from_db(slug)
# Cache for next time (best-effort, ETS might not be up yet)
try do
PageCache.put(slug, page_data)
rescue
ArgumentError -> :ok
end
page_data
end
@doc "Lists all 14 system pages with their current definitions."
def list_system_pages do
Page.system_slugs()
|> Enum.map(&get_page/1)
end
@doc false
def list_pages, do: list_system_pages()
@doc "Lists all custom pages, ordered by title."
def list_custom_pages do
from(p in Page, where: p.type == "custom", order_by: [asc: p.title])
|> Repo.all()
|> Enum.map(&page_to_map/1)
end
@doc "Lists all pages (system + custom)."
def list_all_pages do
list_system_pages() ++ list_custom_pages()
end
@doc "Lists pages that have a custom URL slug set. Used by Routes cache."
def list_pages_with_custom_urls do
from(p in Page, where: not is_nil(p.url_slug))
|> Repo.all()
end
@doc "Gets a published page by its effective URL (url_slug or slug)."
def get_published_page_by_effective_url(url) do
# First check for custom url_slug match
case Repo.one(from p in Page, where: p.url_slug == ^url and p.published == true) do
nil ->
# Fall back to slug match for custom pages
Repo.one(
from p in Page,
where: p.slug == ^url and p.type == "custom" and p.published == true
)
page ->
page
end
end
# ── Write ─────────────────────────────────────────────────────────
@doc """
Saves a page's block list. Creates or updates the DB row and invalidates
the cache. Uses the appropriate changeset based on page type.
Returns `{:ok, page}` or `{:error, changeset}`.
"""
def save_page(slug, attrs) do
existing = Repo.one(from p in Page, where: p.slug == ^slug)
changeset_fn =
if Page.system_slug?(slug),
do: &Page.system_changeset/2,
else: &Page.custom_changeset/2
result =
case existing do
nil ->
%Page{}
|> changeset_fn.(Map.merge(%{slug: slug}, attrs))
|> Repo.insert()
%Page{} = page ->
page
|> changeset_fn.(attrs)
|> Repo.update()
end
case result do
{:ok, page} ->
PageCache.invalidate(slug)
enqueue_link_check(slug)
{:ok, page}
error ->
error
end
end
@doc """
Creates a new custom page.
Returns `{:ok, page}` or `{:error, changeset}`.
"""
def create_custom_page(attrs) do
result =
%Page{}
|> Page.custom_changeset(attrs)
|> Repo.insert()
case result do
{:ok, page} ->
PageCache.put(page.slug, page_to_map(page))
{:ok, page}
error ->
error
end
end
@doc """
Updates a custom page. Creates an auto redirect if the slug changes.
Returns `{:ok, page}` or `{:error, changeset}`.
"""
def update_custom_page(%Page{type: "custom"} = page, attrs) do
old_slug = page.slug
result =
page
|> Page.custom_changeset(attrs)
|> Repo.update()
case result do
{:ok, updated} ->
PageCache.invalidate(old_slug)
PageCache.put(updated.slug, page_to_map(updated))
if old_slug != updated.slug do
Berrypod.Redirects.create_auto(%{
from_path: "/#{old_slug}",
to_path: "/#{updated.slug}",
source: "auto_slug_change"
})
end
enqueue_link_check(updated.slug)
{:ok, updated}
error ->
error
end
end
def update_custom_page(%Page{type: "system"}, _attrs), do: {:error, :system_page}
@doc """
Deletes a custom page.
Returns `{:ok, page}` or `{:error, :system_page}`.
"""
def delete_custom_page(%Page{type: "custom"} = page) do
case Repo.delete(page) do
{:ok, deleted} ->
PageCache.invalidate(deleted.slug)
{:ok, deleted}
error ->
error
end
end
def delete_custom_page(%Page{type: "system"}), do: {:error, :system_page}
@doc """
Duplicates a custom page, creating a draft copy with a "-copy" slug suffix.
"""
def duplicate_custom_page(%Page{type: "custom"} = page) do
new_slug = find_available_slug(page.slug <> "-copy")
create_custom_page(%{
"slug" => new_slug,
"title" => page.title <> " (copy)",
"blocks" => page.blocks,
"published" => false,
"meta_description" => page.meta_description,
"show_in_nav" => false
})
end
defp find_available_slug(slug) do
if Repo.one(from p in Page, where: p.slug == ^slug, select: p.id) do
find_available_slug(slug <> "-2")
else
slug
end
end
@doc """
Resets a page to its default block list. Deletes the DB row (if any)
and invalidates the cache.
"""
def reset_page(slug) do
case Repo.one(from p in Page, where: p.slug == ^slug) do
nil -> :ok
page -> Repo.delete(page)
end
PageCache.invalidate(slug)
:ok
end
# ── Block data loading ────────────────────────────────────────────
@doc """
Runs data loaders for all blocks on a page, returning a merged map
of extra assigns.
Only blocks with a `data_loader` in the BlockTypes registry trigger
a query. Blocks without data loaders rely on global or page-context assigns.
"""
def load_block_data(blocks, assigns) do
BlockTypes.load_data(blocks, assigns)
end
# ── Helpers ───────────────────────────────────────────────────────
defp enqueue_link_check(slug) do
Oban.insert(Berrypod.Workers.DeadLinkCheckerWorker.new(%{"page_slug" => slug}))
end
defp page_to_map(%Page{} = page) do
%{
slug: page.slug,
title: page.title,
blocks: page.blocks,
type: page.type || "system",
published: page.published,
meta_description: page.meta_description,
meta_robots: page.meta_robots,
focus_keyword: page.focus_keyword,
og_image_id: page.og_image_id,
show_in_nav: page.show_in_nav,
nav_label: page.nav_label,
nav_position: page.nav_position,
url_slug: page.url_slug
}
end
# ── URL customisation ────────────────────────────────────────────
@doc """
Updates a page's custom URL slug. Creates a redirect from the old
effective URL to the new one, and invalidates the R module cache.
Pass nil or empty string to clear the custom URL.
"""
def update_page_url_slug(%Page{} = page, url_slug) do
old_effective_url = Page.effective_url(page)
# Normalise empty string to nil
url_slug = if url_slug == "", do: nil, else: url_slug
changeset_fn =
if Page.system_slug?(page.slug),
do: &Page.system_changeset/2,
else: &Page.custom_changeset/2
result =
page
|> changeset_fn.(%{url_slug: url_slug})
|> Repo.update()
case result do
{:ok, updated} ->
new_effective_url = Page.effective_url(updated)
# Create redirect if URL changed
if old_effective_url != new_effective_url do
# Delete any stale redirect FROM the new URL (it's now a live page)
delete_stale_redirect("/#{new_effective_url}")
# Create redirect from old URL to new URL
Berrypod.Redirects.create_auto(%{
from_path: "/#{old_effective_url}",
to_path: "/#{new_effective_url}",
source: "auto_slug_change"
})
end
# Invalidate caches synchronously to avoid race conditions
PageCache.invalidate(page.slug)
BerrypodWeb.R.invalidate_all_sync()
{:ok, updated}
error ->
error
end
end
def update_page_url_slug(slug, url_slug) when is_binary(slug) do
case get_page_struct(slug) do
nil ->
# Page doesn't exist in DB - create it from defaults if it's a system page
if Page.system_slug?(slug) do
defaults = Defaults.for_slug(slug)
case save_page(slug, defaults) do
{:ok, page} -> update_page_url_slug(page, url_slug)
error -> error
end
else
{:error, :not_found}
end
page ->
update_page_url_slug(page, url_slug)
end
end
# When a URL becomes a live page, delete any redirect from that URL
defp delete_stale_redirect(path) do
case Repo.one(from r in Berrypod.Redirects.Redirect, where: r.from_path == ^path) do
%Berrypod.Redirects.Redirect{} = redirect ->
Berrypod.Redirects.delete_redirect(redirect)
nil ->
:ok
end
end
end