Packages

OpenGraph template + hierarchical assignment module for PhoenixKit

Current section

Files

Jump to
phoenix_kit_og lib phoenix_kit_og.ex
Raw

lib/phoenix_kit_og.ex

defmodule PhoenixKitOG do
@moduledoc """
OpenGraph template + hierarchical assignment plugin for PhoenixKit.
See `AGENTS.md` for the high-level architecture. The integration seam
with `phoenix_kit_publishing` is `refine_og/4` — publishing's
`Web.Controller.build_og_data/4` calls it when this module is loaded.
## Module callbacks
Implements `PhoenixKit.Module` so the host app's discovery picks this
up automatically — no config line. Settings key: `phoenix_kit_og_enabled`.
"""
use PhoenixKit.Module
alias PhoenixKit.Dashboard.Tab
alias PhoenixKit.Settings
alias PhoenixKitOG.{Assignments, Render, Slots, Templates, Variables}
# ===========================================================================
# Required PhoenixKit.Module callbacks
# ===========================================================================
@impl PhoenixKit.Module
def module_key, do: "phoenix_kit_og"
@impl PhoenixKit.Module
def module_name, do: "OpenGraph"
@impl PhoenixKit.Module
def enabled? do
Settings.get_boolean_setting("phoenix_kit_og_enabled", false)
rescue
_ -> false
catch
:exit, _ -> false
end
@impl PhoenixKit.Module
def enable_system do
Settings.update_boolean_setting_with_module(
"phoenix_kit_og_enabled",
true,
module_key()
)
end
@impl PhoenixKit.Module
def disable_system do
Settings.update_boolean_setting_with_module(
"phoenix_kit_og_enabled",
false,
module_key()
)
end
# ===========================================================================
# Optional callbacks
# ===========================================================================
@impl PhoenixKit.Module
def version, do: "0.1.0"
@impl PhoenixKit.Module
def permission_metadata do
%{
key: module_key(),
label: "OpenGraph",
icon: "hero-share",
description: "OpenGraph image templates + assignment per module/group/post"
}
end
@impl PhoenixKit.Module
def admin_tabs do
[
%Tab{
id: :admin_phoenix_kit_og,
label: "OpenGraph",
icon: "hero-share",
path: "open-graph",
priority: 650,
level: :admin,
permission: module_key(),
match: :prefix,
group: :admin_modules,
subtab_display: :when_active,
highlight_with_subtabs: false,
live_view: {PhoenixKitOG.Web.TemplatesLive, :index}
},
%Tab{
id: :admin_phoenix_kit_og_templates,
label: "Templates",
icon: "hero-rectangle-stack",
path: "open-graph",
priority: 651,
level: :admin,
permission: module_key(),
# Highlight on any /admin/open-graph URL that isn't the
# Assignments subtab — so the list page, `/new`, and
# `/:uuid/edit` all light Templates up, but Assignments keeps
# its own subtab active.
match: fn path ->
String.starts_with?(path, "/admin/open-graph") and
not String.starts_with?(path, "/admin/open-graph/assignments")
end,
parent: :admin_phoenix_kit_og,
live_view: {PhoenixKitOG.Web.TemplatesLive, :index}
},
%Tab{
id: :admin_phoenix_kit_og_assignments,
label: "Assignments",
icon: "hero-arrows-pointing-in",
path: "open-graph/assignments",
priority: 652,
level: :admin,
permission: module_key(),
parent: :admin_phoenix_kit_og,
live_view: {PhoenixKitOG.Web.AssignmentsLive, :index}
}
]
end
@impl PhoenixKit.Module
def css_sources, do: [:phoenix_kit_og]
@impl PhoenixKit.Module
def route_module, do: PhoenixKitOG.Routes
# ===========================================================================
# Public API — the publishing seam
# ===========================================================================
@doc """
The seam `phoenix_kit_publishing` calls. Walks the hierarchy, picks the
winning template, and would (in Phase 3) substitute a rendered-image URL
into `og[:image]`.
Today it's a pass-through: if no template wins, return `og` unchanged.
When a template wins, it still returns `og` unchanged but logs the
resolution — once the renderer ships, this is where we swap `og[:image]`
for the rendered URL.
The seam contract: return a map with the same keys (`:title`,
`:description`, `:image`, `:url`, `:locale`, `:type`). Anything else is
ignored by publishing's HTML renderer.
"""
@spec refine_og(map(), Plug.Conn.t() | nil, map(), String.t() | nil) :: map()
def refine_og(og, conn, post, language) when is_map(og) do
# Kill switch — when the admin flips the module off, publishing
# keeps its own per-post OG image (featured image + override)
# untouched. No template lookup, no render attempt.
#
# When enabled, the per-post OG override fields don't bypass the
# plugin; they feed into it. `og_resolve` on the publishing side
# reads them first when resolving `post_title`,
# `post_featured_image`, etc., so authors set preferences here and
# the plugin renders with them.
if enabled?() do
render_and_swap(og, conn, post, language)
else
og
end
rescue
# Resolution failures must never crash a public post page render.
_ -> og
end
@doc """
Returns `{:ok, url}` with the OG-plugin-generated image for a post,
or `:none` when no template resolves. Used by publishing's editor to
render a "what the plugin will produce" preview alongside the manual
OG-image override.
"""
@spec preview_og_image_url(map(), Plug.Conn.t() | nil, String.t() | nil) ::
{:ok, String.t()} | :none
def preview_og_image_url(post, conn, language) do
with true <- enabled?(),
hierarchy = publishing_hierarchy_for(post),
{:ok, template, slot_mapping} <-
Assignments.resolve_template_with_mapping("publishing", hierarchy),
values = resolve_values(template, slot_mapping, conn, post, language),
{:ok, path} <- Render.render_url(template, %{values: values}) do
{:ok, absolutize(conn, path)}
else
_ -> :none
end
rescue
_ -> :none
end
defp render_and_swap(og, conn, post, language) do
hierarchy = publishing_hierarchy_for(post)
with {:ok, template, slot_mapping} <-
Assignments.resolve_template_with_mapping("publishing", hierarchy),
values = resolve_values(template, slot_mapping, conn, post, language),
{:ok, path} <- Render.render_url(template, %{values: values}) do
og
|> Map.put(:image, absolutize(conn, path))
|> Map.put(:image_width, Map.get(template.canvas, "width", 1200))
|> Map.put(:image_height, Map.get(template.canvas, "height", 630))
|> Map.put(:image_type, "image/png")
else
:none -> og
{:error, _reason} -> og
end
end
# Merges the slot mapping with the resolver context into the
# `%{"slot_name" => "value"}` map the SVG renderer substitutes. The
# returned map also includes all `[[global]]` values so the renderer
# can resolve both bracket styles from the same map.
defp resolve_values(template, slot_mapping, conn, post, language) do
slots = Slots.used(template.canvas)
context = %{
module_key: "publishing",
resource: post,
conn: conn,
language: language,
page_url: Map.get(post || %{}, :url) || Map.get(post || %{}, "url")
}
wired = Variables.resolve(slots, slot_mapping, context)
globals = Variables.global_values(context)
Map.merge(globals, wired)
end
defp absolutize(_conn, "http" <> _ = url), do: url
defp absolutize(%Plug.Conn{} = conn, "/" <> _ = path) do
port_suffix = if conn.port in [80, 443, nil], do: "", else: ":#{conn.port}"
"#{conn.scheme}://#{conn.host}#{port_suffix}#{path}"
end
defp absolutize(_, path), do: path
# Builds the publishing hierarchy from a post map. Order is most-specific
# first so `Assignments.resolve_template/2` walks it in priority order.
# The "default" tier always trails — it matches the module-wide assignment
# whose `scope_uuid` is NULL.
defp publishing_hierarchy_for(post) when is_map(post) do
[
{"post", Map.get(post, :uuid)},
{"group", get_in(post, [:metadata, :group_uuid])},
{"default", nil}
]
end
# ===========================================================================
# Re-exports — thin delegators so consumers import a single module
# ===========================================================================
defdelegate list_templates, to: Templates, as: :list
defdelegate get_template(uuid), to: Templates, as: :get
defdelegate create_template(attrs), to: Templates, as: :create
defdelegate update_template(template, attrs), to: Templates, as: :update
defdelegate delete_template(template), to: Templates, as: :delete
defdelegate list_assignments(module_key), to: Assignments, as: :list_for_module
defdelegate set_assignment(module_key, scope_type, scope_uuid, template_uuid),
to: Assignments,
as: :set
defdelegate clear_assignment(module_key, scope_type, scope_uuid),
to: Assignments,
as: :clear
defdelegate resolve_template(module_key, hierarchy), to: Assignments
end