diff --git a/lib/modules/emails/templates.ex b/lib/modules/emails/templates.ex
index fb5029493..68089ed00 100644
--- a/lib/modules/emails/templates.ex
+++ b/lib/modules/emails/templates.ex
@@ -570,218 +570,220 @@ defmodule PhoenixKit.Modules.Emails.Templates do
"""
def seed_system_templates do
- system_templates = [
- %{
- name: "magic_link",
- slug: "magic-link",
- display_name: "Magic Link Authentication",
- description: "Secure login link email for passwordless authentication",
- subject: "Your secure login link",
- html_body: magic_link_html_template(),
- text_body: magic_link_text_template(),
- category: "system",
- status: "active",
- is_system: true,
- variables: %{
- "user_email" => "User's email address",
- "magic_link_url" => "URL for magic link authentication"
- },
- metadata: %{"source_module" => "users"}
- },
- %{
- name: "register",
- slug: "register",
- display_name: "Account Confirmation",
- description: "Email sent to confirm user registration",
- subject: "Confirm your account",
- html_body: register_html_template(),
- text_body: register_text_template(),
- category: "system",
- status: "active",
- is_system: true,
- variables: %{
- "user_email" => "User's email address",
- "confirmation_url" => "URL for account confirmation"
+ # Wrap string fields in language maps for multilingual schema compatibility
+ system_templates =
+ wrap_i18n_fields([
+ %{
+ name: "magic_link",
+ slug: "magic-link",
+ display_name: "Magic Link Authentication",
+ description: "Secure login link email for passwordless authentication",
+ subject: "Your secure login link",
+ html_body: magic_link_html_template(),
+ text_body: magic_link_text_template(),
+ category: "system",
+ status: "active",
+ is_system: true,
+ variables: %{
+ "user_email" => "User's email address",
+ "magic_link_url" => "URL for magic link authentication"
+ },
+ metadata: %{"source_module" => "users"}
},
- metadata: %{"source_module" => "users"}
- },
- %{
- name: "reset_password",
- slug: "reset-password",
- display_name: "Password Reset",
- description: "Email sent for password reset requests",
- subject: "Reset your password",
- html_body: reset_password_html_template(),
- text_body: reset_password_text_template(),
- category: "system",
- status: "active",
- is_system: true,
- variables: %{
- "user_email" => "User's email address",
- "reset_url" => "URL for password reset"
+ %{
+ name: "register",
+ slug: "register",
+ display_name: "Account Confirmation",
+ description: "Email sent to confirm user registration",
+ subject: "Confirm your account",
+ html_body: register_html_template(),
+ text_body: register_text_template(),
+ category: "system",
+ status: "active",
+ is_system: true,
+ variables: %{
+ "user_email" => "User's email address",
+ "confirmation_url" => "URL for account confirmation"
+ },
+ metadata: %{"source_module" => "users"}
},
- metadata: %{"source_module" => "users"}
- },
- %{
- name: "test_email",
- slug: "test-email",
- display_name: "Test Email",
- description: "Test email for verifying email tracking system",
- subject: "Test Tracking Email - {{timestamp}}",
- html_body: test_email_html_template(),
- text_body: test_email_text_template(),
- category: "system",
- status: "active",
- is_system: true,
- variables: %{
- "recipient_email" => "Recipient's email address",
- "timestamp" => "Current timestamp",
- "test_link_url" => "URL for testing link tracking"
+ %{
+ name: "reset_password",
+ slug: "reset-password",
+ display_name: "Password Reset",
+ description: "Email sent for password reset requests",
+ subject: "Reset your password",
+ html_body: reset_password_html_template(),
+ text_body: reset_password_text_template(),
+ category: "system",
+ status: "active",
+ is_system: true,
+ variables: %{
+ "user_email" => "User's email address",
+ "reset_url" => "URL for password reset"
+ },
+ metadata: %{"source_module" => "users"}
},
- metadata: %{"source_module" => "admin"}
- },
- %{
- name: "update_email",
- slug: "update-email",
- display_name: "Email Change Confirmation",
- description: "Email sent to confirm email address changes",
- subject: "Confirm your email change",
- html_body: update_email_html_template(),
- text_body: update_email_text_template(),
- category: "system",
- status: "active",
- is_system: true,
- variables: %{
- "user_email" => "User's email address",
- "update_url" => "URL for email update confirmation"
+ %{
+ name: "test_email",
+ slug: "test-email",
+ display_name: "Test Email",
+ description: "Test email for verifying email tracking system",
+ subject: "Test Tracking Email - {{timestamp}}",
+ html_body: test_email_html_template(),
+ text_body: test_email_text_template(),
+ category: "system",
+ status: "active",
+ is_system: true,
+ variables: %{
+ "recipient_email" => "Recipient's email address",
+ "timestamp" => "Current timestamp",
+ "test_link_url" => "URL for testing link tracking"
+ },
+ metadata: %{"source_module" => "admin"}
},
- metadata: %{"source_module" => "users"}
- },
- %{
- name: "billing_invoice",
- slug: "billing-invoice",
- display_name: "Billing Invoice",
- description: "Invoice email sent to customers for payment",
- subject: "Invoice {{invoice_number}} - {{company_name}}",
- html_body: billing_invoice_html_template(),
- text_body: billing_invoice_text_template(),
- category: "transactional",
- status: "active",
- is_system: true,
- variables: %{
- "user_email" => "Customer's email address",
- "user_name" => "Customer's name",
- "invoice_number" => "Invoice number",
- "invoice_date" => "Invoice date",
- "due_date" => "Payment due date",
- "subtotal" => "Subtotal amount",
- "tax_amount" => "Tax amount",
- "total" => "Total amount",
- "currency" => "Currency code",
- "line_items_html" => "HTML table of line items",
- "line_items_text" => "Text list of line items",
- "company_name" => "Company name",
- "company_address" => "Company address",
- "company_vat" => "Company VAT number",
- "bank_name" => "Bank name",
- "bank_iban" => "Bank IBAN",
- "bank_swift" => "Bank SWIFT/BIC",
- "payment_terms" => "Payment terms",
- "invoice_url" => "URL to view invoice online"
+ %{
+ name: "update_email",
+ slug: "update-email",
+ display_name: "Email Change Confirmation",
+ description: "Email sent to confirm email address changes",
+ subject: "Confirm your email change",
+ html_body: update_email_html_template(),
+ text_body: update_email_text_template(),
+ category: "system",
+ status: "active",
+ is_system: true,
+ variables: %{
+ "user_email" => "User's email address",
+ "update_url" => "URL for email update confirmation"
+ },
+ metadata: %{"source_module" => "users"}
},
- metadata: %{"source_module" => "billing"}
- },
- %{
- name: "billing_receipt",
- slug: "billing-receipt",
- display_name: "Billing Receipt",
- description: "Receipt email sent to customers after payment confirmation",
- subject: "Receipt {{receipt_number}} - {{company_name}}",
- html_body: billing_receipt_html_template(),
- text_body: billing_receipt_text_template(),
- category: "transactional",
- status: "active",
- is_system: true,
- variables: %{
- "user_email" => "Customer's email address",
- "user_name" => "Customer's name",
- "receipt_number" => "Receipt number",
- "invoice_number" => "Original invoice number",
- "payment_date" => "Date of payment",
- "subtotal" => "Subtotal amount",
- "tax_amount" => "Tax amount",
- "total" => "Total amount",
- "paid_amount" => "Amount paid",
- "currency" => "Currency code",
- "line_items_html" => "HTML table of line items",
- "line_items_text" => "Text list of line items",
- "company_name" => "Company name",
- "company_address" => "Company address",
- "company_vat" => "Company VAT number",
- "receipt_url" => "URL to view receipt online"
+ %{
+ name: "billing_invoice",
+ slug: "billing-invoice",
+ display_name: "Billing Invoice",
+ description: "Invoice email sent to customers for payment",
+ subject: "Invoice {{invoice_number}} - {{company_name}}",
+ html_body: billing_invoice_html_template(),
+ text_body: billing_invoice_text_template(),
+ category: "transactional",
+ status: "active",
+ is_system: true,
+ variables: %{
+ "user_email" => "Customer's email address",
+ "user_name" => "Customer's name",
+ "invoice_number" => "Invoice number",
+ "invoice_date" => "Invoice date",
+ "due_date" => "Payment due date",
+ "subtotal" => "Subtotal amount",
+ "tax_amount" => "Tax amount",
+ "total" => "Total amount",
+ "currency" => "Currency code",
+ "line_items_html" => "HTML table of line items",
+ "line_items_text" => "Text list of line items",
+ "company_name" => "Company name",
+ "company_address" => "Company address",
+ "company_vat" => "Company VAT number",
+ "bank_name" => "Bank name",
+ "bank_iban" => "Bank IBAN",
+ "bank_swift" => "Bank SWIFT/BIC",
+ "payment_terms" => "Payment terms",
+ "invoice_url" => "URL to view invoice online"
+ },
+ metadata: %{"source_module" => "billing"}
},
- metadata: %{"source_module" => "billing"}
- },
- %{
- name: "billing_credit_note",
- slug: "billing-credit-note",
- display_name: "Billing Credit Note",
- description: "Credit note email sent to customers when a refund is issued",
- subject: "Credit Note {{credit_note_number}} - Refund Issued - {{company_name}}",
- html_body: billing_credit_note_html_template(),
- text_body: billing_credit_note_text_template(),
- category: "transactional",
- status: "active",
- is_system: true,
- variables: %{
- "user_email" => "Customer's email address",
- "user_name" => "Customer's name",
- "credit_note_number" => "Credit note number",
- "invoice_number" => "Original invoice number",
- "refund_date" => "Date of refund",
- "refund_amount" => "Refund amount",
- "refund_reason" => "Reason for refund",
- "transaction_number" => "Transaction reference number",
- "currency" => "Currency code",
- "company_name" => "Company name",
- "company_address" => "Company address",
- "company_vat" => "Company VAT number",
- "credit_note_url" => "URL to view credit note online"
+ %{
+ name: "billing_receipt",
+ slug: "billing-receipt",
+ display_name: "Billing Receipt",
+ description: "Receipt email sent to customers after payment confirmation",
+ subject: "Receipt {{receipt_number}} - {{company_name}}",
+ html_body: billing_receipt_html_template(),
+ text_body: billing_receipt_text_template(),
+ category: "transactional",
+ status: "active",
+ is_system: true,
+ variables: %{
+ "user_email" => "Customer's email address",
+ "user_name" => "Customer's name",
+ "receipt_number" => "Receipt number",
+ "invoice_number" => "Original invoice number",
+ "payment_date" => "Date of payment",
+ "subtotal" => "Subtotal amount",
+ "tax_amount" => "Tax amount",
+ "total" => "Total amount",
+ "paid_amount" => "Amount paid",
+ "currency" => "Currency code",
+ "line_items_html" => "HTML table of line items",
+ "line_items_text" => "Text list of line items",
+ "company_name" => "Company name",
+ "company_address" => "Company address",
+ "company_vat" => "Company VAT number",
+ "receipt_url" => "URL to view receipt online"
+ },
+ metadata: %{"source_module" => "billing"}
},
- metadata: %{"source_module" => "billing"}
- },
- %{
- name: "billing_payment_confirmation",
- slug: "billing-payment-confirmation",
- display_name: "Billing Payment Confirmation",
- description: "Payment confirmation email sent to customers when a payment is received",
- subject: "Payment Received - {{confirmation_number}} - {{company_name}}",
- html_body: billing_payment_confirmation_html_template(),
- text_body: billing_payment_confirmation_text_template(),
- category: "transactional",
- status: "active",
- is_system: true,
- variables: %{
- "user_email" => "Customer's email address",
- "user_name" => "Customer's name",
- "confirmation_number" => "Payment confirmation number",
- "invoice_number" => "Invoice number",
- "payment_date" => "Date of payment",
- "payment_amount" => "Payment amount",
- "payment_method" => "Payment method",
- "transaction_number" => "Transaction reference number",
- "invoice_total" => "Invoice total",
- "total_paid" => "Total paid so far",
- "remaining_balance" => "Remaining balance",
- "is_final_payment" => "Whether this is the final payment",
- "currency" => "Currency code",
- "company_name" => "Company name",
- "company_address" => "Company address",
- "payment_url" => "URL to view payment confirmation online"
+ %{
+ name: "billing_credit_note",
+ slug: "billing-credit-note",
+ display_name: "Billing Credit Note",
+ description: "Credit note email sent to customers when a refund is issued",
+ subject: "Credit Note {{credit_note_number}} - Refund Issued - {{company_name}}",
+ html_body: billing_credit_note_html_template(),
+ text_body: billing_credit_note_text_template(),
+ category: "transactional",
+ status: "active",
+ is_system: true,
+ variables: %{
+ "user_email" => "Customer's email address",
+ "user_name" => "Customer's name",
+ "credit_note_number" => "Credit note number",
+ "invoice_number" => "Original invoice number",
+ "refund_date" => "Date of refund",
+ "refund_amount" => "Refund amount",
+ "refund_reason" => "Reason for refund",
+ "transaction_number" => "Transaction reference number",
+ "currency" => "Currency code",
+ "company_name" => "Company name",
+ "company_address" => "Company address",
+ "company_vat" => "Company VAT number",
+ "credit_note_url" => "URL to view credit note online"
+ },
+ metadata: %{"source_module" => "billing"}
},
- metadata: %{"source_module" => "billing"}
- }
- ]
+ %{
+ name: "billing_payment_confirmation",
+ slug: "billing-payment-confirmation",
+ display_name: "Billing Payment Confirmation",
+ description: "Payment confirmation email sent to customers when a payment is received",
+ subject: "Payment Received - {{confirmation_number}} - {{company_name}}",
+ html_body: billing_payment_confirmation_html_template(),
+ text_body: billing_payment_confirmation_text_template(),
+ category: "transactional",
+ status: "active",
+ is_system: true,
+ variables: %{
+ "user_email" => "Customer's email address",
+ "user_name" => "Customer's name",
+ "confirmation_number" => "Payment confirmation number",
+ "invoice_number" => "Invoice number",
+ "payment_date" => "Date of payment",
+ "payment_amount" => "Payment amount",
+ "payment_method" => "Payment method",
+ "transaction_number" => "Transaction reference number",
+ "invoice_total" => "Invoice total",
+ "total_paid" => "Total paid so far",
+ "remaining_balance" => "Remaining balance",
+ "is_final_payment" => "Whether this is the final payment",
+ "currency" => "Currency code",
+ "company_name" => "Company name",
+ "company_address" => "Company address",
+ "payment_url" => "URL to view payment confirmation online"
+ },
+ metadata: %{"source_module" => "billing"}
+ }
+ ])
results =
Enum.map(system_templates, fn template_attrs ->
@@ -807,6 +809,21 @@ defmodule PhoenixKit.Modules.Emails.Templates do
# Private helper functions
+ # Wraps string fields in language maps for multilingual schema compatibility
+ defp wrap_i18n_fields(templates) do
+ Enum.map(templates, fn t ->
+ t
+ |> Map.update(:display_name, nil, &wrap_i18n/1)
+ |> Map.update(:description, nil, &wrap_i18n/1)
+ |> Map.update(:subject, nil, &wrap_i18n/1)
+ |> Map.update(:html_body, nil, &wrap_i18n/1)
+ |> Map.update(:text_body, nil, &wrap_i18n/1)
+ end)
+ end
+
+ defp wrap_i18n(v) when is_binary(v), do: %{"en" => v}
+ defp wrap_i18n(v), do: v
+
defp apply_filters(query, opts) do
Enum.reduce(opts, query, fn
{:category, category}, q when is_binary(category) ->
diff --git a/lib/modules/publishing/posts.ex b/lib/modules/publishing/posts.ex
index b8732cc2d..09f8d059c 100644
--- a/lib/modules/publishing/posts.ex
+++ b/lib/modules/publishing/posts.ex
@@ -16,7 +16,6 @@ defmodule PhoenixKit.Modules.Publishing.Posts do
alias PhoenixKit.Modules.Publishing.DBStorage
alias PhoenixKit.Modules.Publishing.LanguageHelpers
alias PhoenixKit.Modules.Publishing.ListingCache
- alias PhoenixKit.Modules.Publishing.Metadata
alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
alias PhoenixKit.Modules.Publishing.Shared
alias PhoenixKit.Modules.Publishing.SlugHelpers
@@ -650,12 +649,15 @@ defmodule PhoenixKit.Modules.Publishing.Posts do
true ->
{:error, :slug_already_exists}
- {:error, %Ecto.Changeset{errors: errors}} ->
- if Keyword.has_key?(errors, :slug),
+ {:error, %Ecto.Changeset{} = changeset} ->
+ Logger.warning("[Publishing] slug update changeset error: #{inspect(changeset.errors)}")
+
+ if Keyword.has_key?(changeset.errors, :slug),
do: {:error, :slug_already_exists},
else: {:error, :db_update_failed}
{:error, reason} ->
+ Logger.warning("[Publishing] slug update failed: #{inspect(reason)}")
{:error, reason}
end
end
@@ -709,12 +711,10 @@ defmodule PhoenixKit.Modules.Publishing.Posts do
end
end
- defp resolve_post_title(params, post, content) do
- extracted_title = Metadata.extract_title_from_content(content)
+ defp resolve_post_title(params, post, _content) do
post_metadata = post[:metadata] || %{}
Map.get(params, "title") ||
- if(extracted_title != Constants.default_title(), do: extracted_title) ||
post_metadata[:title] ||
Constants.default_title()
end
diff --git a/lib/modules/publishing/pubsub.ex b/lib/modules/publishing/pubsub.ex
index 493e6d6d3..0a3b626d6 100644
--- a/lib/modules/publishing/pubsub.ex
+++ b/lib/modules/publishing/pubsub.ex
@@ -18,6 +18,21 @@ defmodule PhoenixKit.Modules.Publishing.PubSub do
@topic_editor_forms "publishing:editor_forms"
@topic_groups "publishing:groups"
+ # ============================================================================
+ # Post Identifier Resolution
+ # ============================================================================
+
+ @doc """
+ Returns the broadcast identifier for a post.
+
+ Uses slug when available, falls back to uuid. This identifier is used
+ for PubSub topic construction and must be consistent between broadcasters
+ (e.g. translation worker) and subscribers (e.g. editor).
+ """
+ def broadcast_id(post) do
+ post[:slug] || post[:uuid]
+ end
+
# ============================================================================
# Group-Level Updates (group creation/deletion)
# ============================================================================
diff --git a/lib/modules/publishing/web/components/language_switcher.ex b/lib/modules/publishing/web/components/language_switcher.ex
index a682f0fff..728303f53 100644
--- a/lib/modules/publishing/web/components/language_switcher.ex
+++ b/lib/modules/publishing/web/components/language_switcher.ex
@@ -176,8 +176,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher do
<% else %>
<%= if @lang[:url] do %>
-
@@ -194,7 +194,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher do
known={@known}
is_primary={@is_primary}
/>
-
+
<% else %>
- "bg-base-content/10 text-base-content/50 font-semibold cursor-pointer"
+ "bg-base-content/30 text-base-content/50 font-semibold cursor-pointer"
(!enabled or !known) and exists ->
"text-base-content/40 hover:bg-base-200/50 cursor-pointer"
is_current ->
- "bg-primary/10 text-primary font-semibold"
+ "bg-primary/30 text-primary font-semibold"
!exists && show_add ->
"text-success hover:bg-success/10 cursor-pointer"
diff --git a/lib/modules/publishing/web/controller.ex b/lib/modules/publishing/web/controller.ex
index d33f3e6cf..66c865e71 100644
--- a/lib/modules/publishing/web/controller.ex
+++ b/lib/modules/publishing/web/controller.ex
@@ -31,6 +31,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do
alias PhoenixKit.Modules.Publishing.Web.Controller.Listing
alias PhoenixKit.Modules.Publishing.Web.Controller.PostRendering
alias PhoenixKit.Modules.Publishing.Web.Controller.Routing
+ alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML
alias PhoenixKit.Settings
# ============================================================================
@@ -160,6 +161,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do
defp handle_group_listing(conn, group_slug, language) do
case Listing.render_group_listing(conn, group_slug, language, conn.params) do
{:ok, assigns} ->
+ listing_url = PublishingHTML.group_listing_path(assigns.current_language, group_slug)
+
+ base_url =
+ "#{conn.scheme}://#{conn.host}#{if conn.port in [80, 443], do: "", else: ":#{conn.port}"}"
+
conn
|> assign(:page_title, assigns.page_title)
|> assign(:group, assigns.group)
@@ -171,6 +177,12 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do
|> assign(:total_count, assigns.total_count)
|> assign(:total_pages, assigns.total_pages)
|> assign(:breadcrumbs, assigns.breadcrumbs)
+ |> assign(:og, %{
+ title: assigns.group["name"],
+ url: base_url <> listing_url,
+ locale: assigns.current_language,
+ type: "website"
+ })
|> render(:index)
{:redirect, url} ->
@@ -188,6 +200,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do
defp handle_post(conn, group_slug, identifier, language) do
case PostRendering.render_post(conn, group_slug, identifier, language) do
{:ok, assigns} ->
+ canonical_url =
+ PublishingHTML.build_post_url(group_slug, assigns.post, assigns.current_language)
+
conn
|> assign(:page_title, assigns.page_title)
|> assign(:group_slug, assigns.group_slug)
@@ -197,6 +212,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do
|> assign(:translations, assigns.translations)
|> assign(:breadcrumbs, assigns.breadcrumbs)
|> assign(:version_dropdown, assigns.version_dropdown)
+ |> assign(:og, build_og_data(conn, assigns.post, canonical_url, assigns.current_language))
|> render(:show)
{:redirect, url} ->
@@ -228,6 +244,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do
|> assign(:is_live_version, assigns.is_live_version)
|> assign(:version, assigns.version)
|> assign(:version_dropdown, assigns.version_dropdown)
+ |> assign(
+ :og,
+ build_og_data(conn, assigns.post, assigns.canonical_url, assigns.current_language)
+ )
|> render(:show)
{:error, reason} ->
@@ -238,6 +258,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do
defp handle_date_only_url(conn, group_slug, date, language) do
case PostRendering.handle_date_only_url(conn, group_slug, date, language) do
{:ok, assigns} ->
+ canonical_url =
+ PublishingHTML.build_post_url(group_slug, assigns.post, assigns.current_language)
+
conn
|> assign(:page_title, assigns.page_title)
|> assign(:group_slug, assigns.group_slug)
@@ -247,6 +270,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do
|> assign(:translations, assigns.translations)
|> assign(:breadcrumbs, assigns.breadcrumbs)
|> assign(:version_dropdown, assigns.version_dropdown)
+ |> assign(:og, build_og_data(conn, assigns.post, canonical_url, assigns.current_language))
|> render(:show)
{:redirect, url} ->
@@ -292,4 +316,31 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do
defp set_gettext_locale(language) do
Gettext.put_locale(PhoenixKitWeb.Gettext, language)
end
+
+ defp build_og_data(conn, post, canonical_url, language) do
+ seo = Map.get(post.metadata, :seo) || Map.get(post, :seo) || %{}
+ description = seo["og_description"] || Map.get(post.metadata, :description)
+ image = seo["og_image"] || PublishingHTML.featured_image_url(post, "large")
+
+ base_url =
+ "#{conn.scheme}://#{conn.host}#{if conn.port in [80, 443], do: "", else: ":#{conn.port}"}"
+
+ %{
+ title: seo["og_title"] || post.metadata.title,
+ description: description,
+ image: absolute_url(base_url, image),
+ url: absolute_url(base_url, canonical_url),
+ locale: language,
+ type: "article"
+ }
+ end
+
+ defp absolute_url(_base, nil), do: nil
+ defp absolute_url(_base, ""), do: nil
+
+ defp absolute_url(base, url) when is_binary(url) do
+ if String.starts_with?(url, "http"), do: url, else: base <> url
+ end
+
+ defp absolute_url(_base, _url), do: nil
end
diff --git a/lib/modules/publishing/web/controller/listing.ex b/lib/modules/publishing/web/controller/listing.ex
index 57c1f2fee..13064bfc0 100644
--- a/lib/modules/publishing/web/controller/listing.ex
+++ b/lib/modules/publishing/web/controller/listing.ex
@@ -182,8 +182,13 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do
lang_excerpts = post[:language_excerpts] || %{}
metadata = post[:metadata] || %{}
- title = Map.get(lang_titles, language, metadata[:title] || Constants.default_title())
- excerpt = Map.get(lang_excerpts, language)
+ # Language map keys are full dialect codes (e.g., "en-US") but the
+ # canonical_language may be a base code (e.g., "en") when only one
+ # dialect is enabled. Try exact match first, then match by base code.
+ resolved_key = resolve_language_key(language, Map.keys(lang_titles))
+
+ title = Map.get(lang_titles, resolved_key, metadata[:title] || Constants.default_title())
+ excerpt = Map.get(lang_excerpts, resolved_key)
post
|> Map.update(:metadata, %{title: title}, &Map.put(&1, :title, title))
@@ -192,6 +197,17 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do
end)
end
+ # Resolves a display language code to a key in the language map.
+ # Handles base code ("en") → dialect code ("en-US") matching.
+ defp resolve_language_key(language, available_keys) do
+ if language in available_keys do
+ language
+ else
+ base = DialectMapper.extract_base(language)
+ Enum.find(available_keys, language, fn key -> DialectMapper.extract_base(key) == base end)
+ end
+ end
+
# ============================================================================
# Filtering Functions
# ============================================================================
diff --git a/lib/modules/publishing/web/edit.html.heex b/lib/modules/publishing/web/edit.html.heex
index b874a4073..77a00a174 100644
--- a/lib/modules/publishing/web/edit.html.heex
+++ b/lib/modules/publishing/web/edit.html.heex
@@ -50,18 +50,12 @@
)}
- ✓ {gettext("Valid examples")}: blog,
- product-updates
- ,
+ ✓ {gettext("Valid examples")}: blog, product-updates,
news-2025
- ✗ {gettext("Invalid examples")}: Blog,
- product_updates
- , -news,
-
- my blog
-
+ ✗ {gettext("Invalid examples")}: Blog, product_updates, -news,
+ my blog
diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex
index 0edfdac8b..de7b29999 100644
--- a/lib/modules/publishing/web/editor.ex
+++ b/lib/modules/publishing/web/editor.ex
@@ -28,8 +28,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do
alias Phoenix.LiveView.JS
alias PhoenixKit.Modules.AI
alias PhoenixKit.Modules.Publishing
- alias PhoenixKit.Modules.Publishing.Constants
- alias PhoenixKit.Modules.Publishing.Metadata
alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
@@ -438,21 +436,12 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do
is_published = form["status"] == "published"
- # Seed auto-title from existing content for manual-set detection
- extracted_title = Metadata.extract_title_from_content(post.content || "")
- auto_title = if extracted_title == Constants.default_title(), do: "", else: extracted_title
- form_title = Map.get(form, "title", "")
- title_manually_set = form_title != "" and auto_title != "" and form_title != auto_title
-
sock =
socket
|> assign(:group_mode, group_mode)
|> assign(:post, %{post | group: group_slug})
|> assign(:group_name, Publishing.group_name(group_slug) || group_slug)
- |> Forms.assign_form_with_tracking(form,
- last_auto_title: auto_title,
- title_manually_set: title_manually_set
- )
+ |> Forms.assign_form_with_tracking(form)
|> assign(:content, post.content)
|> assign(:available_languages, post.available_languages)
|> assign(:all_enabled_languages, all_enabled_languages)
@@ -495,35 +484,49 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do
if socket.assigns.readonly? or socket.assigns.translation_locked? do
{:noreply, socket}
else
+ target = Map.get(params, "_target", [])
params = params |> Map.drop(["_target"])
params = Forms.preserve_auto_url_slug(params, socket)
- params = Forms.preserve_auto_title(params, socket)
+
+ # When typing in the title field, the browser sends stale slug/url_slug values.
+ # Preserve the server's current slug to avoid overwriting the auto-generated value.
+ params =
+ if target == ["title"] do
+ params
+ |> Map.put("slug", socket.assigns.form["slug"] || "")
+ |> Map.put("url_slug", socket.assigns.form["url_slug"] || "")
+ else
+ params
+ end
new_form =
socket.assigns.form
|> Map.merge(params)
|> Forms.normalize_form()
+ # Only detect manual slug edits when the user is directly editing the slug field,
+ # not when slug arrives stale from the browser during title editing
slug_manually_set =
- if Map.has_key?(params, "slug") do
- slug_value = Map.get(new_form, "slug", "")
- slug_value != "" && slug_value != socket.assigns.last_auto_slug
- else
- socket.assigns.slug_manually_set
- end
+ if target == ["slug"],
+ do: detect_slug_manual_set(params, new_form, socket),
+ else: socket.assigns.slug_manually_set
url_slug_manually_set =
- if Map.has_key?(params, "url_slug") do
- url_slug_value = Map.get(new_form, "url_slug", "")
- url_slug_value != "" && url_slug_value != socket.assigns.last_auto_url_slug
- else
- socket.assigns.url_slug_manually_set
- end
-
- {new_form, title_manually_set} =
- Forms.detect_title_manual_set(params, new_form, socket)
+ if target == ["url_slug"],
+ do: detect_url_slug_manual_set(params, new_form, socket),
+ else: socket.assigns.url_slug_manually_set
+
+ # Generate slug from title in real-time
+ {socket_with_slug, new_form, slug_events} =
+ maybe_generate_slug_from_title(
+ socket,
+ params,
+ new_form,
+ slug_manually_set,
+ url_slug_manually_set
+ )
- has_changes = Forms.dirty?(socket.assigns.post, new_form, socket.assigns.content)
+ has_changes = Forms.dirty?(socket_with_slug.assigns.post, new_form, socket.assigns.content)
language = Helpers.editor_language(socket.assigns)
new_status = new_form["status"]
@@ -553,16 +556,16 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do
public_url = Helpers.build_public_url(updated_post, language)
socket =
- socket
+ socket_with_slug
|> assign(:form, new_form)
|> assign(:post, updated_post)
|> assign(:slug_manually_set, slug_manually_set)
|> assign(:url_slug_manually_set, url_slug_manually_set)
- |> assign(:title_manually_set, title_manually_set)
|> assign(:has_pending_changes, has_changes)
|> assign(:public_url, public_url)
|> clear_flash()
|> push_event("changes-status", %{has_changes: has_changes})
+ |> Forms.push_slug_events(slug_events)
socket = if has_changes, do: schedule_autosave(socket), else: socket
@@ -580,25 +583,20 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do
if socket.assigns.readonly? or socket.assigns.translation_locked? do
{:noreply, socket}
else
- {socket, new_form, slug_events} = Forms.maybe_update_slug_from_content(socket, content)
-
- # Auto-update title from H1 if not manually set
- socket = assign(socket, :form, new_form)
- {socket, new_form, title_events} = Forms.maybe_update_title_from_content(socket, content)
-
- has_changes = Forms.dirty?(socket.assigns.post, new_form, content)
+ has_changes = Forms.dirty?(socket.assigns.post, socket.assigns.form, content)
socket =
socket
|> assign(:content, content)
- |> assign(:form, new_form)
|> assign(:has_pending_changes, has_changes)
|> push_event("changes-status", %{has_changes: has_changes})
- socket = Forms.push_slug_events(socket, slug_events ++ title_events)
socket = if has_changes, do: schedule_autosave(socket), else: socket
- Collaborative.broadcast_form_change(socket, :content, %{content: content, form: new_form})
+ Collaborative.broadcast_form_change(socket, :content, %{
+ content: content,
+ form: socket.assigns.form
+ })
socket = Collaborative.touch_activity(socket)
@@ -606,12 +604,12 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do
end
end
- def handle_event("generate_slug_from_content", _params, socket) do
+ def handle_event("regenerate_slug", _params, socket) do
if socket.assigns.group_mode == "slug" do
- content = socket.assigns.content || ""
+ title = socket.assigns.form["title"] || ""
{socket, new_form, slug_events} =
- Forms.maybe_update_slug_from_content(socket, content, force: true)
+ Forms.maybe_update_slug_from_title(socket, title, force: true)
has_changes = Forms.dirty?(socket.assigns.post, new_form, socket.assigns.content)
@@ -955,7 +953,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do
def handle_event("switch_language", %{"language" => new_language}, socket) do
if socket.assigns[:is_new_post] do
{:noreply,
- put_flash(socket, :error, gettext("Please save the post first before switching languages"))}
+ put_flash(socket, :warning, gettext("Save the post to enable language switching"))}
else
do_switch_language(socket, new_language)
end
@@ -1130,21 +1128,13 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do
end
def handle_info({:editor_content_changed, %{content: content}}, socket) do
- {socket, new_form, slug_events} = Forms.maybe_update_slug_from_content(socket, content)
-
- # Auto-update title from H1 if not manually set
- socket = assign(socket, :form, new_form)
- {socket, new_form, title_events} = Forms.maybe_update_title_from_content(socket, content)
-
- has_changes = Forms.dirty?(socket.assigns.post, new_form, content)
+ has_changes = Forms.dirty?(socket.assigns.post, socket.assigns.form, content)
socket =
socket
|> assign(:content, content)
- |> assign(:form, new_form)
|> assign(:has_pending_changes, has_changes)
|> push_event("changes-status", %{has_changes: has_changes})
- |> Forms.push_slug_events(slug_events ++ title_events)
socket = if has_changes, do: schedule_autosave(socket), else: socket
@@ -1330,7 +1320,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do
def handle_info({:translation_created, group_slug, post_identifier, language}, socket) do
if socket.assigns[:group_slug] == group_slug && post_matches?(socket, post_identifier) do
- case re_read_post(socket) do
+ case re_read_post(socket, socket.assigns[:current_language]) do
{:ok, updated_post} ->
socket =
socket
@@ -1489,20 +1479,13 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do
end
defp reload_post_on_lock_acquired(socket) do
- case re_read_post(socket) do
+ case re_read_post(socket, socket.assigns[:current_language]) do
{:ok, post} ->
form = Forms.post_form(post)
- extracted_title = Metadata.extract_title_from_content(post.content || "")
-
- auto_title =
- if extracted_title == Constants.default_title(), do: "", else: extracted_title
socket
|> assign(:post, %{post | group: socket.assigns.group_slug})
- |> Forms.assign_form_with_tracking(form,
- last_auto_title: auto_title,
- title_manually_set: false
- )
+ |> Forms.assign_form_with_tracking(form)
|> assign(:content, post.content)
|> assign(:has_pending_changes, false)
|> push_event("changes-status", %{has_changes: false})
@@ -1516,6 +1499,42 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do
end
end
+ defp detect_slug_manual_set(params, form, socket) do
+ if Map.has_key?(params, "slug") do
+ slug_value = Map.get(form, "slug", "")
+ slug_value != "" && slug_value != socket.assigns.last_auto_slug
+ else
+ socket.assigns.slug_manually_set
+ end
+ end
+
+ defp detect_url_slug_manual_set(params, form, socket) do
+ if Map.has_key?(params, "url_slug") do
+ url_slug_value = Map.get(form, "url_slug", "")
+ url_slug_value != "" && url_slug_value != socket.assigns.last_auto_url_slug
+ else
+ socket.assigns.url_slug_manually_set
+ end
+ end
+
+ defp maybe_generate_slug_from_title(
+ socket,
+ params,
+ form,
+ slug_manually_set,
+ url_slug_manually_set
+ ) do
+ if Map.has_key?(params, "title") do
+ socket
+ |> assign(:form, form)
+ |> assign(:slug_manually_set, slug_manually_set)
+ |> assign(:url_slug_manually_set, url_slug_manually_set)
+ |> Forms.maybe_update_slug_from_title(form["title"])
+ else
+ {socket, form, []}
+ end
+ end
+
defp maybe_reclaim_lock(socket) do
if socket.assigns[:lock_released_by_timeout] do
Collaborative.try_reclaim_lock(socket)
@@ -1534,11 +1553,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do
assign(socket, :autosave_timer, timer_ref)
end
- defp re_read_post(socket) do
+ defp re_read_post(socket, language) do
case socket.assigns[:post] do
nil -> {:error, :no_post}
%{uuid: nil} -> {:error, :no_uuid}
- post -> Publishing.read_post_by_uuid(post.uuid)
+ post -> Publishing.read_post_by_uuid(post.uuid, language)
end
end
diff --git a/lib/modules/publishing/web/editor.html.heex b/lib/modules/publishing/web/editor.html.heex
index 3fbe0eaa4..fa8c51bd5 100644
--- a/lib/modules/publishing/web/editor.html.heex
+++ b/lib/modules/publishing/web/editor.html.heex
@@ -77,14 +77,6 @@
}
});
- // Listen for title update event from LiveView
- window.addEventListener("phx:update-title", function(e) {
- const titleInput = document.getElementById("title-input");
- if (titleInput && e.detail.title) {
- titleInput.value = e.detail.title;
- }
- });
-
// Listen for url_slug update event from LiveView (for translations)
window.addEventListener("phx:update-url-slug", function(e) {
const urlSlugInput = document.getElementById("url-slug-input");
@@ -159,14 +151,16 @@
)}
-
-
+ <%= if @ai_translation_total && @ai_translation_total > 0 do %>
+
+
+ <% else %>
+
+ <% end %>
<% end %>
@@ -692,86 +694,73 @@
- <%!-- Title field --%>
-
-
-
- <%= if @is_primary_language do %>
- {gettext("Auto-populated from the first heading. Edit to override.")}
- <% else %>
- {gettext("Leave empty to use the primary language title.")}
- <% end %>
-
-
- <%!-- Content header with save status --%>
-
-
{gettext("Content")}
-
- <%!-- Other viewers indicator --%>
- <%= if @other_viewers != [] do %>
-
-
- <.icon name="hero-eye" class="w-3 h-3" />
- {ngettext(
- "1 other viewing",
- "%{count} others viewing",
- length(@other_viewers),
- count: length(@other_viewers)
- )}
-
-
- <% end %>
- <%= cond do %>
- <% @is_autosaving -> %>
-
-
- {gettext("Saving...")}
-
- <% @has_pending_changes -> %>
-
- {gettext("Unsaved changes")}
-
- <% @is_new_post -> %>
-
{gettext("New")}
- <% true -> %>
-
- <.icon name="hero-check" class="w-3 h-3" />
- {gettext("Saved")}
-
- <% end %>
- <% save_disabled = edit_disabled? || @is_autosaving %>
-
+
+ <%!-- Other viewers indicator --%>
+ <%= if @other_viewers != [] do %>
+
-
-
+
+ <.icon name="hero-eye" class="w-3 h-3" />
+ {ngettext(
+ "1 other viewing",
+ "%{count} others viewing",
+ length(@other_viewers),
+ count: length(@other_viewers)
+ )}
+
+
+ <% end %>
+ <%= cond do %>
+ <% @is_autosaving -> %>
+
+
{gettext("Saving...")}
-
- <.icon name="hero-arrow-down-tray" class="w-3 h-3" /> {gettext("Save now")}
+ <% @has_pending_changes -> %>
+
+ {gettext("Unsaved changes")}
-
-
+ <% @is_new_post -> %>
+ {gettext("New")}
+ <% true -> %>
+
+ <.icon name="hero-check" class="w-3 h-3" />
+ {gettext("Saved")}
+
+ <% end %>
+ <% save_disabled = edit_disabled? || @is_autosaving %>
+
+
+
+ {gettext("Saving...")}
+
+
+ <.icon name="hero-arrow-down-tray" class="w-3 h-3" /> {gettext("Save now")}
+
+
-
+ <%!-- Title field --%>
+
<%!-- Markdown Editor Component --%>
<.live_component
module={PhoenixKitWeb.Components.Core.MarkdownEditor}
@@ -810,7 +799,7 @@
value={@form["slug"]}
pattern="[a-z0-9]+(-[a-z0-9]+)*"
class={"input input-bordered w-full lowercase #{if edit_disabled? or @viewing_older_version, do: "input-disabled bg-base-200"}"}
- placeholder={gettext("auto-generated from first heading")}
+ placeholder={gettext("auto-generated from title")}
title={
gettext(
"Use lowercase letters, numbers, and hyphens only. No spaces or special characters."
diff --git a/lib/modules/publishing/web/editor/collaborative.ex b/lib/modules/publishing/web/editor/collaborative.ex
index 68a7e1e6c..1a44ff109 100644
--- a/lib/modules/publishing/web/editor/collaborative.ex
+++ b/lib/modules/publishing/web/editor/collaborative.ex
@@ -184,9 +184,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Collaborative do
end
defp subscribe_to_post_translations(socket) do
- case socket.assigns[:post] && socket.assigns.post[:slug] do
- post_slug when is_binary(post_slug) ->
- PublishingPubSub.subscribe_to_post_translations(socket.assigns.group_slug, post_slug)
+ case PublishingPubSub.broadcast_id(socket.assigns[:post]) do
+ id when is_binary(id) ->
+ PublishingPubSub.subscribe_to_post_translations(socket.assigns.group_slug, id)
_ ->
:ok
@@ -194,9 +194,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Collaborative do
end
defp subscribe_to_post_versions(socket) do
- case socket.assigns[:post] && socket.assigns.post[:slug] do
- post_slug when is_binary(post_slug) ->
- PublishingPubSub.subscribe_to_post_versions(socket.assigns.group_slug, post_slug)
+ case PublishingPubSub.broadcast_id(socket.assigns[:post]) do
+ id when is_binary(id) ->
+ PublishingPubSub.subscribe_to_post_versions(socket.assigns.group_slug, id)
_ ->
:ok
diff --git a/lib/modules/publishing/web/editor/forms.ex b/lib/modules/publishing/web/editor/forms.ex
index f6a61bc98..d8184ad24 100644
--- a/lib/modules/publishing/web/editor/forms.ex
+++ b/lib/modules/publishing/web/editor/forms.ex
@@ -8,7 +8,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.Constants
- alias PhoenixKit.Modules.Publishing.Metadata
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Slug
@@ -184,7 +183,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do
def assign_form_with_tracking(socket, form, opts \\ []) do
{slug_manually_set, last_auto_slug} = resolve_slug_tracking(socket, form, opts)
{url_slug_manually_set, last_auto_url_slug} = resolve_url_slug_tracking(socket, form, opts)
- {title_manually_set, last_auto_title} = resolve_title_tracking(socket, form, opts)
socket
|> Phoenix.Component.assign(:form, form)
@@ -192,8 +190,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do
|> Phoenix.Component.assign(:slug_manually_set, slug_manually_set)
|> Phoenix.Component.assign(:last_auto_url_slug, last_auto_url_slug)
|> Phoenix.Component.assign(:url_slug_manually_set, url_slug_manually_set)
- |> Phoenix.Component.assign(:last_auto_title, last_auto_title)
- |> Phoenix.Component.assign(:title_manually_set, title_manually_set)
end
defp resolve_slug_tracking(socket, form, opts) do
@@ -237,110 +233,67 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do
{manually_set, last_auto}
end
- defp resolve_title_tracking(socket, form, opts) do
- manually_set =
- case Keyword.fetch(opts, :title_manually_set) do
- {:ok, value} ->
- value
-
- :error ->
- existing = Map.get(socket.assigns, :title_manually_set, false)
- title = Map.get(form, "title", "")
- last_auto = Map.get(socket.assigns, :last_auto_title, "")
- existing || (title != "" and last_auto != "" and title != last_auto)
- end
-
- last_auto =
- case Keyword.fetch(opts, :last_auto_title) do
- {:ok, value} -> value
- :error -> Map.get(socket.assigns, :last_auto_title, "")
- end
-
- {manually_set, last_auto}
- end
-
# ============================================================================
- # Slug Auto-Generation
+ # Slug Generation from Title
# ============================================================================
@doc """
- Updates slug from content if applicable.
+ Updates slug from title if applicable.
Returns {socket, new_form, slug_events}.
"""
- def maybe_update_slug_from_content(socket, content, opts \\ []) do
- content = content || ""
+ def maybe_update_slug_from_title(socket, title, opts \\ []) do
+ title = title || ""
cond do
- not slug_update_applicable?(socket, content) ->
+ socket.assigns.group_mode != "slug" or String.trim(title) == "" ->
no_slug_update(socket)
Map.get(socket.assigns, :is_primary_language, true) ->
- maybe_update_primary_slug(socket, content, opts)
+ maybe_update_primary_slug_from_title(socket, title, opts)
true ->
- maybe_update_translation_url_slug(socket, content, opts)
+ maybe_update_translation_url_slug_from_title(socket, title, opts)
end
end
- defp slug_update_applicable?(socket, content) do
- socket.assigns.group_mode == "slug" and String.trim(content) != ""
- end
-
- defp maybe_update_primary_slug(socket, content, opts) do
+ defp maybe_update_primary_slug_from_title(socket, title, opts) do
force? = Keyword.get(opts, :force, false)
slug_manually_set? = Map.get(socket.assigns, :slug_manually_set, false)
if not force? and slug_manually_set? do
no_slug_update(socket)
else
- update_slug_from_content(socket, content)
+ current_slug = socket.assigns.post.slug || Map.get(socket.assigns.form, "slug", "")
+
+ case Publishing.generate_unique_slug(socket.assigns.group_slug, title, nil,
+ current_slug: current_slug
+ ) do
+ {:ok, ""} -> no_slug_update(socket)
+ {:ok, new_slug} -> apply_new_slug(socket, new_slug)
+ {:error, _reason} -> no_slug_update(socket)
+ end
end
end
- defp maybe_update_translation_url_slug(socket, content, opts) do
+ defp maybe_update_translation_url_slug_from_title(socket, title, opts) do
force? = Keyword.get(opts, :force, false)
url_slug_manually_set? = Map.get(socket.assigns, :url_slug_manually_set, false)
if not force? and url_slug_manually_set? do
no_slug_update(socket)
else
- update_url_slug_from_content(socket, content)
- end
- end
+ new_url_slug = Slug.slugify(title)
+ current_url_slug = Map.get(socket.assigns.form, "url_slug", "")
- defp no_slug_update(socket), do: {socket, socket.assigns.form, []}
-
- defp update_slug_from_content(socket, content) do
- title = Metadata.extract_title_from_content(content)
- current_slug = socket.assigns.post.slug || Map.get(socket.assigns.form, "slug", "")
-
- case Publishing.generate_unique_slug(socket.assigns.group_slug, title, nil,
- current_slug: current_slug
- ) do
- {:ok, ""} ->
- no_slug_update(socket)
-
- {:ok, new_slug} ->
- apply_new_slug(socket, new_slug)
-
- {:error, _reason} ->
+ if new_url_slug == "" do
no_slug_update(socket)
+ else
+ apply_new_url_slug(socket, new_url_slug, current_url_slug)
+ end
end
end
- defp update_url_slug_from_content(socket, content) do
- title = Metadata.extract_title_from_content(content)
- current_url_slug = Map.get(socket.assigns.form, "url_slug", "")
-
- # Generate a slug from the title
- new_url_slug = Slug.slugify(title)
-
- if new_url_slug == "" do
- no_slug_update(socket)
- else
- apply_new_url_slug(socket, new_url_slug, current_url_slug)
- end
- end
+ defp no_slug_update(socket), do: {socket, socket.assigns.form, []}
defp apply_new_slug(socket, new_slug) do
current_slug = Map.get(socket.assigns.form, "slug", "")
@@ -390,97 +343,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do
end
end
- # ============================================================================
- # Title Auto-Generation
- # ============================================================================
-
- @doc """
- Updates the title from content if not manually set.
- Returns {socket, form, events}.
- """
- def maybe_update_title_from_content(socket, content) do
- content = content || ""
- title_manually_set? = Map.get(socket.assigns, :title_manually_set, false)
-
- if title_manually_set? do
- {socket, socket.assigns.form, []}
- else
- extracted = Metadata.extract_title_from_content(content)
- new_title = if extracted == Constants.default_title(), do: "", else: extracted
- current_title = Map.get(socket.assigns.form, "title", "")
-
- if new_title != "" and new_title != current_title do
- form = Map.put(socket.assigns.form, "title", new_title)
-
- socket =
- socket
- |> Phoenix.Component.assign(:last_auto_title, new_title)
- |> Phoenix.Component.assign(:title_manually_set, false)
-
- {socket, form, [{"update-title", %{title: new_title}}]}
- else
- socket =
- if new_title != "" do
- Phoenix.Component.assign(socket, :last_auto_title, new_title)
- else
- socket
- end
-
- {socket, socket.assigns.form, []}
- end
- end
- end
-
- @doc """
- Preserve auto-generated title when browser sends empty value.
- """
- def preserve_auto_title(params, socket) do
- browser_title = Map.get(params, "title", "")
- last_auto = Map.get(socket.assigns, :last_auto_title, "")
- manually_set = Map.get(socket.assigns, :title_manually_set, false)
-
- if browser_title == "" and last_auto != "" and not manually_set do
- Map.put(params, "title", last_auto)
- else
- params
- end
- end
-
- @doc """
- Detects whether the user manually set the title field.
- Returns {form, title_manually_set}.
- """
- def detect_title_manual_set(params, form, socket) do
- if Map.has_key?(params, "title") do
- title_value = Map.get(form, "title", "")
-
- if title_value != "" do
- manually_set = title_value != Map.get(socket.assigns, :last_auto_title, "")
- {form, manually_set}
- else
- # User cleared title — revert to auto from H1
- revert_title_to_auto(form, socket)
- end
- else
- {form, Map.get(socket.assigns, :title_manually_set, false)}
- end
- end
-
- @doc """
- Reverts the title to the auto-extracted H1 heading.
- Returns {form, false}.
- """
- def revert_title_to_auto(form, socket) do
- extracted = Metadata.extract_title_from_content(socket.assigns.content || "")
- auto_title = if extracted == Constants.default_title(), do: "", else: extracted
-
- if auto_title != "" do
- {Map.put(form, "title", auto_title), false}
- else
- {form, false}
- end
- end
-
@doc """
Preserve auto-generated url_slug when browser sends empty value.
"""
diff --git a/lib/modules/publishing/web/editor/persistence.ex b/lib/modules/publishing/web/editor/persistence.ex
index 023446f6c..b66272451 100644
--- a/lib/modules/publishing/web/editor/persistence.ex
+++ b/lib/modules/publishing/web/editor/persistence.ex
@@ -26,6 +26,39 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do
Returns {:noreply, socket}.
"""
def perform_save(socket) do
+ is_autosaving = Map.get(socket.assigns, :is_autosaving, false)
+ title = (socket.assigns.form["title"] || "") |> String.trim()
+ slug = (socket.assigns.form["slug"] || "") |> String.trim()
+
+ cond do
+ title == "" ->
+ if is_autosaving do
+ {:noreply, socket}
+ else
+ {:noreply,
+ Phoenix.LiveView.put_flash(socket, :warning, gettext("Title is required to save."))}
+ end
+
+ socket.assigns.group_mode == "slug" and slug == "" ->
+ if is_autosaving do
+ {:noreply, socket}
+ else
+ {:noreply,
+ Phoenix.LiveView.put_flash(
+ socket,
+ :warning,
+ gettext(
+ "Slug is required. Enter a title to auto-generate one, or type a slug manually."
+ )
+ )}
+ end
+
+ true ->
+ do_perform_save_with_params(socket)
+ end
+ end
+
+ defp do_perform_save_with_params(socket) do
params =
socket.assigns.form
|> Map.take(["status", "published_at", "slug", "featured_image_uuid", "url_slug", "title"])
@@ -648,7 +681,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do
Phoenix.LiveView.put_flash(
socket,
:error,
- gettext("Title is required. Add a heading to your content or enter a title manually.")
+ gettext("Title is required.")
)}
end
@@ -658,6 +691,17 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do
{:noreply, Phoenix.LiveView.put_flash(socket, :error, gettext("Failed to save post"))}
end
+ defp slug_constraint_error?(changeset) do
+ Keyword.has_key?(changeset.errors, :slug) or
+ Enum.any?(changeset.errors, fn
+ {:group_uuid, {_, opts}} ->
+ Keyword.get(opts, :constraint_name) == "idx_publishing_posts_group_slug"
+
+ _ ->
+ false
+ end)
+ end
+
defp handle_post_creation_error(socket, :invalid_slug, _fallback_message) do
{:noreply,
Phoenix.LiveView.put_flash(
@@ -674,10 +718,26 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do
Phoenix.LiveView.put_flash(
socket,
:error,
- gettext("A post with that slug already exists")
+ gettext(
+ "A post with that slug already exists. Please choose a different title or edit the slug manually."
+ )
)}
end
+ defp handle_post_creation_error(socket, %Ecto.Changeset{} = changeset, fallback_message) do
+ if slug_constraint_error?(changeset) do
+ handle_post_creation_error(socket, :slug_already_exists, fallback_message)
+ else
+ group = socket.assigns[:group_slug]
+
+ Logger.warning(
+ "[Publishing.Editor] Post creation failed in #{group}: #{inspect(changeset.errors)}"
+ )
+
+ {:noreply, Phoenix.LiveView.put_flash(socket, :error, fallback_message)}
+ end
+ end
+
defp handle_post_creation_error(socket, reason, fallback_message) do
group = socket.assigns[:group_slug]
Logger.warning("[Publishing.Editor] Post creation failed in #{group}: #{inspect(reason)}")
@@ -710,8 +770,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do
"""
def reload_translated_content(socket, flash_msg, flash_level) do
group_slug = socket.assigns.group_slug
+ current_language = socket.assigns[:current_language]
- case re_read_post(socket) do
+ case re_read_post(socket, current_language) do
{:ok, updated_post} ->
current_version = socket.assigns[:current_version]
form = Forms.post_form_with_primary_status(group_slug, updated_post, current_version)
@@ -756,9 +817,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do
"""
def reload_post(socket) do
group_slug = socket.assigns.group_slug
+ current_language = socket.assigns[:current_language]
current_version = socket.assigns[:current_version]
- case re_read_post(socket) do
+ case re_read_post(socket, current_language) do
{:ok, updated_post} ->
form = Forms.post_form_with_primary_status(group_slug, updated_post, current_version)
diff --git a/lib/modules/publishing/web/html.ex b/lib/modules/publishing/web/html.ex
index 94e99b039..bc198221a 100644
--- a/lib/modules/publishing/web/html.ex
+++ b/lib/modules/publishing/web/html.ex
@@ -6,6 +6,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do
alias PhoenixKit.Config
alias PhoenixKit.Modules.Languages
+ alias PhoenixKit.Modules.Languages.DialectMapper
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.Constants
alias PhoenixKit.Modules.Publishing.Renderer
@@ -115,7 +116,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do
# language_slugs map from cache
map_size(Map.get(post, :language_slugs, %{})) > 0 ->
- Map.get(post.language_slugs, language, post.slug)
+ # Language map keys are full dialect codes (e.g., "en-US") but the
+ # language param may be a base code (e.g., "en") when only one
+ # dialect is enabled. Try exact match first, then match by base code.
+ resolved_key = resolve_language_key(language, Map.keys(post.language_slugs))
+ Map.get(post.language_slugs, resolved_key, post.slug)
# metadata.url_slug
is_map(Map.get(post, :metadata)) and Map.get(post.metadata, :url_slug) not in [nil, ""] ->
@@ -127,6 +132,17 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do
end
end
+ # Resolves a display language code to a key in the language map.
+ # Handles base code ("en") → dialect code ("en-US") matching.
+ defp resolve_language_key(language, available_keys) do
+ if language in available_keys do
+ language
+ else
+ base = DialectMapper.extract_base(language)
+ Enum.find(available_keys, language, fn key -> DialectMapper.extract_base(key) == base end)
+ end
+ end
+
@doc """
Builds a public path with explicit date and time (always includes time).
Used when redirecting from date-only URLs to full timestamp URLs.
diff --git a/lib/modules/publishing/web/new.html.heex b/lib/modules/publishing/web/new.html.heex
index bbc04ed11..8cde9fb20 100644
--- a/lib/modules/publishing/web/new.html.heex
+++ b/lib/modules/publishing/web/new.html.heex
@@ -57,18 +57,12 @@
)}
- ✓ {gettext("Valid examples")}: blog,
- product-updates
- ,
+ ✓ {gettext("Valid examples")}: blog, product-updates,
news-2025
- ✗ {gettext("Invalid examples")}: Blog,
- product_updates
- , -news,
-
- my blog
-
+ ✗ {gettext("Invalid examples")}: Blog, product_updates, -news,
+ my blog
<% slug_value = @form[:slug].value || "" %>
diff --git a/lib/modules/publishing/web/preview.ex b/lib/modules/publishing/web/preview.ex
index 1b7d0a181..599abb209 100644
--- a/lib/modules/publishing/web/preview.ex
+++ b/lib/modules/publishing/web/preview.ex
@@ -1,6 +1,9 @@
defmodule PhoenixKit.Modules.Publishing.Web.Preview do
@moduledoc """
Preview rendering for publishing posts.
+
+ Shows the full public-facing interface with a preview banner,
+ allowing editors to see exactly what visitors will see.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
@@ -8,12 +11,17 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do
require Logger
alias PhoenixKit.Modules.Publishing
- # alias PhoenixKit.Modules.Publishing.PageBuilder # COMMENTED OUT: Component system
+ alias PhoenixKit.Modules.Publishing.Constants
alias PhoenixKit.Modules.Publishing.Renderer
+ alias PhoenixKit.Modules.Publishing.Web.Controller.PostRendering
+ alias PhoenixKit.Modules.Publishing.Web.Controller.Translations
alias PhoenixKit.Modules.Publishing.Web.Editor.Helpers
+ alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
+ import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher
+
@impl true
def mount(params, _session, socket) do
group_slug = params["group"] || params["category"] || params["type"]
@@ -21,14 +29,18 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do
socket =
socket
|> assign(:project_title, Settings.get_project_title())
- |> assign(:page_title, "Preview")
+ |> assign(:page_title, gettext("Preview"))
|> assign(:group_slug, group_slug)
|> assign(:group_name, Publishing.group_name(group_slug) || group_slug)
|> assign(
:current_path,
Routes.path("/admin/publishing/#{group_slug}/preview")
)
- |> assign(:rendered_content, nil)
+ |> assign(:post, nil)
+ |> assign(:html_content, nil)
+ |> assign(:translations, [])
+ |> assign(:breadcrumbs, [])
+ |> assign(:version_dropdown, nil)
|> assign(:error, nil)
{:ok, socket}
@@ -44,12 +56,30 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do
{:ok, post} ->
case render_markdown_content(post.content) do
{:ok, rendered_html} ->
+ post = Map.put(post, :uuid, post_uuid)
+
+ # Build the same data as the public controller
+ canonical_language = post.language
+
+ translations =
+ Translations.build_translation_links(group_slug, post, canonical_language)
+
+ breadcrumbs = PostRendering.build_breadcrumbs(group_slug, post, canonical_language)
+
+ version_dropdown =
+ PostRendering.build_version_dropdown(group_slug, post, canonical_language)
+
{:noreply,
socket
- |> assign(:post, Map.put(post, :uuid, post_uuid))
+ |> assign(:post, post)
|> assign(:group_slug, post.group)
|> assign(:group_name, Publishing.group_name(post.group) || post.group)
- |> assign(:rendered_content, rendered_html)
+ |> assign(:html_content, rendered_html)
+ |> assign(:current_language, canonical_language)
+ |> assign(:translations, translations)
+ |> assign(:breadcrumbs, breadcrumbs)
+ |> assign(:version_dropdown, version_dropdown)
+ |> assign(:page_title, post.metadata.title || Constants.default_title())
|> assign(:error, nil)}
{:error, error_message} ->
@@ -58,7 +88,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do
|> assign(:post, Map.put(post, :uuid, post_uuid))
|> assign(:group_slug, post.group)
|> assign(:group_name, Publishing.group_name(post.group) || post.group)
- |> assign(:rendered_content, nil)
+ |> assign(:html_content, nil)
|> assign(:error, error_message)}
end
@@ -97,6 +127,31 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do
{:noreply, push_navigate(socket, to: destination)}
end
+ # Delegate to PublishingHTML helpers used in the template
+ defdelegate has_publication_date?(post), to: PublishingHTML
+ defdelegate format_post_date(post, group_slug), to: PublishingHTML
+
+ @doc false
+ def build_preview_translations(translations, post, group_slug) do
+ post_uuid = post[:uuid]
+ version = post[:version]
+
+ Enum.map(translations, fn translation ->
+ code = translation[:code] || translation.code
+ query = URI.encode_query(%{"lang" => code, "v" => version})
+
+ %{
+ code: code,
+ display_code: translation[:display_code] || code,
+ name: translation[:name] || translation.name,
+ flag: translation[:flag] || "",
+ url: Routes.path("/admin/publishing/#{group_slug}/#{post_uuid}/preview?#{query}"),
+ status: "published",
+ exists: true
+ }
+ end)
+ end
+
defp render_markdown_content(content) when is_binary(content) do
content
|> Renderer.render_markdown()
diff --git a/lib/modules/publishing/web/preview.html.heex b/lib/modules/publishing/web/preview.html.heex
index 396f13072..da31c53c2 100644
--- a/lib/modules/publishing/web/preview.html.heex
+++ b/lib/modules/publishing/web/preview.html.heex
@@ -7,14 +7,14 @@
current_locale={@current_locale}
>
- <%!-- Preview Header --%>
-
+ <%!-- Preview Banner --%>
+
<.icon name="hero-eye" class="w-5 h-5" />
- {gettext("Preview Mode")}
-
- — {gettext("Showing saved content")}
+ {gettext("Preview Mode")}
+
+ — {gettext("This is how the post will appear to visitors")}
@@ -26,7 +26,7 @@
- <%!-- Preview Content --%>
+ <%!-- Content --%>
<%= if @error do %>
<% else %>
- <%= if @rendered_content do %>
-
- <.markdown_content content={@rendered_content} />
-
+ <%= if @post && @html_content do %>
+ <%!-- Public post interface --%>
+
+ <%!-- Breadcrumb Navigation (non-interactive in preview) --%>
+
+
+ <%= for breadcrumb <- @breadcrumbs do %>
+ {breadcrumb.label}
+ <% end %>
+
+
+
+ <%!-- Post Header --%>
+
+
+ <%!-- Post Content --%>
+
+
+
+ {raw(@html_content)}
+
+
+ <%!-- Post Footer --%>
+
+
+ <.icon name="hero-arrow-left" class="w-4 h-4 mr-2" /> {gettext("Back to %{group}",
+ group: String.capitalize(@group_slug)
+ )}
+
+
+
<% else %>
diff --git a/lib/modules/publishing/web/templates/all_groups.html.heex b/lib/modules/publishing/web/templates/all_groups.html.heex
index a912e2b62..97a0ef44c 100644
--- a/lib/modules/publishing/web/templates/all_groups.html.heex
+++ b/lib/modules/publishing/web/templates/all_groups.html.heex
@@ -18,12 +18,12 @@
diff --git a/lib/modules/publishing/web/templates/index.html.heex b/lib/modules/publishing/web/templates/index.html.heex
index ff6582983..fa8a16962 100644
--- a/lib/modules/publishing/web/templates/index.html.heex
+++ b/lib/modules/publishing/web/templates/index.html.heex
@@ -10,7 +10,7 @@
<%= for breadcrumb <- @breadcrumbs do %>
<%= if breadcrumb.url do %>
- {breadcrumb.label}
+ <.link navigate={breadcrumb.url}>{breadcrumb.label}
<% else %>
{breadcrumb.label}
<% end %>
@@ -54,12 +54,12 @@
<% end %>
<% excerpt =
@@ -86,12 +86,12 @@
<% end %>
-
{gettext("Read More →")}
-
+
@@ -104,12 +104,12 @@
<%= if page_num == @page do %>
{page_num}
<% else %>
-
{page_num}
-
+
<% end %>
<% end %>
diff --git a/lib/modules/publishing/web/templates/show.html.heex b/lib/modules/publishing/web/templates/show.html.heex
index 604c7bfea..35bbbe113 100644
--- a/lib/modules/publishing/web/templates/show.html.heex
+++ b/lib/modules/publishing/web/templates/show.html.heex
@@ -4,7 +4,6 @@
current_path={@conn.request_path}
phoenix_kit_current_scope={assigns[:phoenix_kit_current_scope]}
>
- <%!-- TODO: Add SEO meta tags (description, canonical) when layout system supports meta_tags slot --%>
<%!-- Breadcrumb Navigation --%>
@@ -12,7 +11,7 @@
<%= for breadcrumb <- @breadcrumbs do %>
<%= if breadcrumb.url do %>
- {breadcrumb.label}
+ <.link navigate={breadcrumb.url}>{breadcrumb.label}
<% else %>
{breadcrumb.label}
<% end %>
@@ -77,15 +76,15 @@
>
<%= for v <- @version_dropdown.versions do %>
-
v{v.version}
<%= if v.is_live do %>
live
<% end %>
-
+
<% end %>
@@ -237,11 +236,14 @@
<%!-- Post Footer --%>
diff --git a/lib/modules/publishing/workers/translate_post_worker.ex b/lib/modules/publishing/workers/translate_post_worker.ex
index f56100c09..1f80a67cf 100644
--- a/lib/modules/publishing/workers/translate_post_worker.ex
+++ b/lib/modules/publishing/workers/translate_post_worker.ex
@@ -184,7 +184,7 @@ defmodule PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker do
) do
group_slug = source_post.group
total = length(target_languages)
- broadcast_id = source_post.slug || source_post[:uuid]
+ broadcast_id = PublishingPubSub.broadcast_id(source_post)
# Broadcast that translation has started
PublishingPubSub.broadcast_translation_started(group_slug, broadcast_id, target_languages)
diff --git a/lib/phoenix_kit/application.ex b/lib/phoenix_kit/application.ex
index 951a98aae..07f324695 100644
--- a/lib/phoenix_kit/application.ex
+++ b/lib/phoenix_kit/application.ex
@@ -7,10 +7,24 @@ defmodule PhoenixKit.Application do
"""
use Application
+ require Logger
+
@impl true
def start(_type, _args) do
+ check_installation()
+
# PhoenixKit.Supervisor is started by parent app in its supervision tree
# This is just a placeholder to satisfy OTP application callback
Supervisor.start_link([], strategy: :one_for_one, name: PhoenixKit.AppSupervisor)
end
+
+ defp check_installation do
+ unless PhoenixKit.configured?() do
+ Logger.warning("""
+ PhoenixKit is added as a dependency but not installed.
+ Run: mix phoenix_kit.install
+ See: https://hexdocs.pm/phoenix_kit
+ """)
+ end
+ end
end
diff --git a/lib/phoenix_kit_web/components/core/flash.ex b/lib/phoenix_kit_web/components/core/flash.ex
index d4098a02c..2866c4689 100644
--- a/lib/phoenix_kit_web/components/core/flash.ex
+++ b/lib/phoenix_kit_web/components/core/flash.ex
@@ -21,7 +21,7 @@ defmodule PhoenixKitWeb.Components.Core.Flash do
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
- attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
+ attr :kind, :atom, values: [:info, :warning, :error], doc: "used for styling and flash lookup"
attr :autoclose, :any, default: 5000, doc: "Auto-dismiss delay in ms, or false to disable"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
@@ -45,9 +45,11 @@ defmodule PhoenixKitWeb.Components.Core.Flash do
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
+ <.icon :if={@kind == :warning} name="hero-exclamation-triangle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
{@title}
@@ -64,6 +66,7 @@ defmodule PhoenixKitWeb.Components.Core.Flash do
class={[
"h-full",
@kind == :info && "bg-info-content/30",
+ @kind == :warning && "bg-warning-content/30",
@kind == :error && "bg-error-content/30"
]}
style="width: 100%"
@@ -88,8 +91,9 @@ defmodule PhoenixKitWeb.Components.Core.Flash do
def flash_group(assigns) do
~H"""
- <.flash kind={:info} title="Success!" flash={@flash} />
- <.flash kind={:error} title="Error!" flash={@flash} autoclose={8000} />
+ <.flash kind={:info} title={gettext("Success!")} flash={@flash} />
+ <.flash kind={:warning} title={gettext("Note")} flash={@flash} />
+ <.flash kind={:error} title={gettext("Error!")} flash={@flash} autoclose={8000} />
"""
end
diff --git a/lib/phoenix_kit_web/components/layouts/root.html.heex b/lib/phoenix_kit_web/components/layouts/root.html.heex
index 168dffe5f..10e3cc477 100644
--- a/lib/phoenix_kit_web/components/layouts/root.html.heex
+++ b/lib/phoenix_kit_web/components/layouts/root.html.heex
@@ -12,6 +12,33 @@
<% end %>
+ <%= if og = assigns[:og] do %>
+
+ <%= if og[:title] do %>
+
+
+ <% end %>
+ <%= if og[:description] do %>
+
+
+
+ <% end %>
+ <%= if og[:image] do %>
+
+
+ <% end %>
+ <%= if og[:url] do %>
+
+
+ <% end %>
+ <%= if og[:locale] do %>
+
+ <% end %>
+
+ <% end %>
<.live_title
default={PhoenixKit.Settings.get_project_title()}
suffix=" · Phoenix Framework"
diff --git a/test/modules/publishing/editor_forms_test.exs b/test/modules/publishing/editor_forms_test.exs
new file mode 100644
index 000000000..443c66dc8
--- /dev/null
+++ b/test/modules/publishing/editor_forms_test.exs
@@ -0,0 +1,250 @@
+defmodule PhoenixKit.Modules.Publishing.Web.Editor.FormsTest do
+ use ExUnit.Case, async: true
+
+ alias PhoenixKit.Modules.Publishing.Web.Editor.Forms
+
+ # ============================================================================
+ # Helper to build a minimal socket with assigns for testing
+ # ============================================================================
+
+ defp build_socket(overrides) do
+ defaults = %{
+ form: %{"title" => "", "slug" => "", "url_slug" => "", "status" => "draft"},
+ post: %{slug: "", uuid: nil, metadata: %{status: "draft"}},
+ group_slug: "blog",
+ group_mode: "slug",
+ is_primary_language: true,
+ slug_manually_set: false,
+ last_auto_slug: "",
+ url_slug_manually_set: false,
+ last_auto_url_slug: ""
+ }
+
+ merged = Map.merge(defaults, overrides)
+ # Override form fully if provided
+ merged =
+ if overrides[:form],
+ do: %{merged | form: Map.merge(defaults.form, overrides.form)},
+ else: merged
+
+ # Build a proper Phoenix.LiveView.Socket with assigns
+ %Phoenix.LiveView.Socket{}
+ |> Phoenix.Component.assign(merged)
+ end
+
+ # ============================================================================
+ # maybe_update_slug_from_title/3
+ # ============================================================================
+
+ describe "maybe_update_slug_from_title/3" do
+ test "generates slug from title for primary language" do
+ socket = build_socket(%{group_mode: "slug", is_primary_language: true})
+ {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "Hello World")
+
+ assert form["slug"] == "hello-world"
+ assert [{"update-slug", %{slug: "hello-world"}}] = events
+ end
+
+ test "returns no update for empty title" do
+ socket = build_socket(%{group_mode: "slug"})
+ {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "")
+
+ assert form["slug"] == ""
+ assert events == []
+ end
+
+ test "returns no update for nil title" do
+ socket = build_socket(%{group_mode: "slug"})
+ {_socket, _form, events} = Forms.maybe_update_slug_from_title(socket, nil)
+
+ assert events == []
+ end
+
+ test "returns no update for timestamp mode" do
+ socket = build_socket(%{group_mode: "timestamp"})
+ {_socket, _form, events} = Forms.maybe_update_slug_from_title(socket, "Hello World")
+
+ assert events == []
+ end
+
+ test "respects slug_manually_set flag" do
+ socket = build_socket(%{slug_manually_set: true, form: %{"slug" => "custom-slug"}})
+ {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "Different Title")
+
+ assert form["slug"] == "custom-slug"
+ assert events == []
+ end
+
+ test "overrides slug when force option is set" do
+ socket = build_socket(%{slug_manually_set: true, form: %{"slug" => "custom-slug"}})
+
+ {_socket, form, events} =
+ Forms.maybe_update_slug_from_title(socket, "New Title", force: true)
+
+ assert form["slug"] == "new-title"
+ assert [{"update-slug", _}] = events
+ end
+
+ test "generates url_slug for translation language" do
+ socket = build_socket(%{is_primary_language: false, form: %{"url_slug" => ""}})
+ {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "Translated Title")
+
+ assert form["url_slug"] == "translated-title"
+ assert [{"update-url-slug", %{url_slug: "translated-title"}}] = events
+ end
+
+ test "respects url_slug_manually_set for translations" do
+ socket =
+ build_socket(%{
+ is_primary_language: false,
+ url_slug_manually_set: true,
+ form: %{"url_slug" => "custom-url"}
+ })
+
+ {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "Other Title")
+
+ assert form["url_slug"] == "custom-url"
+ assert events == []
+ end
+
+ test "no update when slug already matches" do
+ socket = build_socket(%{form: %{"slug" => "hello-world"}})
+ {_socket, _form, events} = Forms.maybe_update_slug_from_title(socket, "Hello World")
+
+ assert events == []
+ end
+ end
+
+ # ============================================================================
+ # assign_form_with_tracking/3
+ # ============================================================================
+
+ describe "assign_form_with_tracking/3" do
+ test "assigns slug tracking state" do
+ socket = build_socket(%{})
+ form = %{"title" => "Test", "slug" => "test", "status" => "draft"}
+
+ result = Forms.assign_form_with_tracking(socket, form)
+
+ assert result.assigns.form == form
+ assert result.assigns.slug_manually_set == false
+ assert result.assigns.last_auto_slug == "test"
+ end
+
+ test "does not assign title_manually_set (removed)" do
+ socket = build_socket(%{})
+ form = %{"title" => "Test", "slug" => "test", "status" => "draft"}
+
+ result = Forms.assign_form_with_tracking(socket, form)
+
+ refute Map.has_key?(result.assigns, :title_manually_set)
+ refute Map.has_key?(result.assigns, :last_auto_title)
+ end
+ end
+
+ # ============================================================================
+ # Form Building
+ # ============================================================================
+
+ describe "post_form/1" do
+ test "builds form with title from metadata" do
+ post = %{
+ metadata: %{
+ title: "My Post",
+ status: "draft",
+ published_at: nil,
+ featured_image_uuid: nil,
+ url_slug: nil
+ },
+ slug: "my-post",
+ mode: :slug,
+ content: "# My Post\nContent here",
+ url_slug: nil
+ }
+
+ form = Forms.post_form(post)
+
+ assert form["title"] == "My Post"
+ assert form["slug"] == "my-post"
+ assert form["status"] == "draft"
+ end
+
+ test "returns empty title for Untitled posts" do
+ post = %{
+ metadata: %{
+ title: "Untitled",
+ status: "draft",
+ published_at: nil,
+ featured_image_uuid: nil,
+ url_slug: nil
+ },
+ slug: nil,
+ mode: "timestamp",
+ content: "",
+ url_slug: nil
+ }
+
+ form = Forms.post_form(post)
+ assert form["title"] == ""
+ end
+ end
+
+ # ============================================================================
+ # dirty?/3
+ # ============================================================================
+
+ describe "dirty?/3" do
+ test "detects title change as dirty" do
+ post = %{
+ metadata: %{
+ title: "Original",
+ status: "draft",
+ published_at: nil,
+ featured_image_uuid: nil,
+ url_slug: nil
+ },
+ slug: "original",
+ mode: "slug",
+ content: "content",
+ url_slug: nil
+ }
+
+ form = Forms.post_form(post)
+ modified_form = Map.put(form, "title", "Changed Title")
+
+ assert Forms.dirty?(post, modified_form, "content")
+ end
+
+ test "detects content change as dirty" do
+ post = %{
+ metadata: %{
+ title: "Title",
+ status: "draft",
+ published_at: nil,
+ featured_image_uuid: nil,
+ url_slug: nil
+ },
+ slug: "title",
+ mode: "slug",
+ content: "original content",
+ url_slug: nil
+ }
+
+ form = Forms.post_form(post)
+ assert Forms.dirty?(post, form, "new content")
+ end
+ end
+
+ # ============================================================================
+ # push_slug_events/2
+ # ============================================================================
+
+ describe "push_slug_events/2" do
+ test "pushes no events for empty list" do
+ socket = build_socket(%{})
+ result = Forms.push_slug_events(socket, [])
+ # No crash = success; events are pushed via Phoenix.LiveView.push_event
+ assert result
+ end
+ end
+end
diff --git a/test/modules/publishing/integration/slug_update_test.exs b/test/modules/publishing/integration/slug_update_test.exs
new file mode 100644
index 000000000..e701f4891
--- /dev/null
+++ b/test/modules/publishing/integration/slug_update_test.exs
@@ -0,0 +1,65 @@
+defmodule PhoenixKit.Integration.Publishing.SlugUpdateTest do
+ use PhoenixKit.DataCase, async: true
+
+ alias PhoenixKit.Modules.Publishing
+ alias PhoenixKit.Modules.Publishing.Groups
+ alias PhoenixKit.Modules.Publishing.Posts
+
+ describe "slug update on existing post" do
+ setup do
+ {:ok, group} = Groups.add_group("Slug Test", mode: "slug", slug: "slug-test")
+ %{group: group}
+ end
+
+ test "can create post with explicit slug and update it", %{group: group} do
+ # Create post with title and slug
+ {:ok, post} =
+ Posts.create_post(group["slug"], %{title: "Original Title", slug: "original-title"})
+
+ assert post.slug == "original-title"
+
+ # Update to a different slug
+ result =
+ Publishing.update_post(group["slug"], post, %{
+ "slug" => "new-slug",
+ "title" => "Original Title",
+ "content" => "Some content",
+ "status" => "draft"
+ })
+
+ assert {:ok, updated} = result
+ assert updated.slug == "new-slug"
+ end
+
+ test "can create post with auto-generated slug from title", %{group: group} do
+ {:ok, post} =
+ Posts.create_post(group["slug"], %{title: "My Great Post"})
+
+ assert post.slug == "my-great-post"
+ end
+
+ test "can create post with empty title (gets untitled slug)", %{group: group} do
+ {:ok, post} =
+ Posts.create_post(group["slug"], %{title: ""})
+
+ assert post.slug == "untitled"
+ end
+
+ test "can update slug from untitled to real slug", %{group: group} do
+ # This simulates the bug: post created with "untitled" slug, user types title, slug changes
+ {:ok, post} = Posts.create_post(group["slug"], %{title: ""})
+ assert post.slug == "untitled"
+
+ result =
+ Publishing.update_post(group["slug"], post, %{
+ "slug" => "hello",
+ "title" => "Hello",
+ "content" => "content",
+ "status" => "draft"
+ })
+
+ assert {:ok, updated} = result
+ assert updated.slug == "hello"
+ end
+ end
+end
diff --git a/test/modules/publishing/integration/translation_reload_test.exs b/test/modules/publishing/integration/translation_reload_test.exs
new file mode 100644
index 000000000..eaca72348
--- /dev/null
+++ b/test/modules/publishing/integration/translation_reload_test.exs
@@ -0,0 +1,70 @@
+defmodule PhoenixKit.Integration.Publishing.TranslationReloadTest do
+ use PhoenixKit.DataCase, async: true
+
+ alias PhoenixKit.Modules.Publishing
+ alias PhoenixKit.Modules.Publishing.Groups
+ alias PhoenixKit.Modules.Publishing.Posts
+
+ describe "read_post_by_uuid with language parameter" do
+ setup do
+ {:ok, group} = Groups.add_group("Translation Test", mode: "slug", slug: "translation-test")
+ {:ok, post} = Posts.create_post(group["slug"], %{title: "English Title", slug: "test-post"})
+
+ # Save the primary language content
+ {:ok, saved_post} =
+ Publishing.update_post(group["slug"], post, %{
+ "title" => "English Title",
+ "content" => "English content",
+ "status" => "draft"
+ })
+
+ # Add a translation language
+ {:ok, _} =
+ Publishing.add_language_to_post(group["slug"], saved_post[:uuid], "uk", 1)
+
+ # Save translated content
+ {:ok, translated_post} =
+ Publishing.read_post_by_uuid(saved_post[:uuid], "uk", 1)
+
+ {:ok, _} =
+ Publishing.update_post(group["slug"], translated_post, %{
+ "title" => "Ukrainian Title",
+ "content" => "Ukrainian content",
+ "status" => "draft"
+ })
+
+ %{group: group, post_uuid: saved_post[:uuid]}
+ end
+
+ test "reading without language returns primary language content", %{post_uuid: uuid} do
+ {:ok, post} = Publishing.read_post_by_uuid(uuid)
+ assert post.metadata.title == "English Title"
+ assert post.content == "English content"
+ end
+
+ test "reading with specific language returns that language's content", %{post_uuid: uuid} do
+ {:ok, post} = Publishing.read_post_by_uuid(uuid, "uk")
+ assert post.metadata.title == "Ukrainian Title"
+ assert post.content == "Ukrainian content"
+ end
+
+ test "reload_translated_content should read correct language", %{post_uuid: uuid} do
+ # This test verifies the pattern used in reload_translated_content:
+ # re_read_post(socket, current_language) should return the translated content,
+ # NOT the primary language content.
+ {:ok, primary} = Publishing.read_post_by_uuid(uuid, nil)
+ {:ok, translated} = Publishing.read_post_by_uuid(uuid, "uk")
+
+ # Primary should return English
+ assert primary.language == "en-US"
+ assert primary.metadata.title == "English Title"
+
+ # Translated should return Ukrainian
+ assert translated.language == "uk"
+ assert translated.metadata.title == "Ukrainian Title"
+
+ # They should be different
+ refute primary.content == translated.content
+ end
+ end
+end
diff --git a/test/modules/publishing/pubsub_broadcast_id_test.exs b/test/modules/publishing/pubsub_broadcast_id_test.exs
new file mode 100644
index 000000000..b526747d4
--- /dev/null
+++ b/test/modules/publishing/pubsub_broadcast_id_test.exs
@@ -0,0 +1,71 @@
+defmodule PhoenixKit.Modules.Publishing.PubSubBroadcastIdTest do
+ use ExUnit.Case, async: true
+
+ alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
+
+ # ============================================================================
+ # broadcast_id/1
+ # ============================================================================
+
+ describe "broadcast_id/1" do
+ test "returns slug when present" do
+ post = %{slug: "my-post", uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"}
+ assert PublishingPubSub.broadcast_id(post) == "my-post"
+ end
+
+ test "falls back to uuid when slug is nil" do
+ post = %{slug: nil, uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"}
+ assert PublishingPubSub.broadcast_id(post) == "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"
+ end
+
+ test "falls back to uuid when slug key is missing" do
+ post = %{uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"}
+ assert PublishingPubSub.broadcast_id(post) == "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"
+ end
+
+ test "returns nil when both slug and uuid are nil" do
+ post = %{slug: nil, uuid: nil}
+ assert PublishingPubSub.broadcast_id(post) == nil
+ end
+
+ test "returns nil for nil post" do
+ assert PublishingPubSub.broadcast_id(nil) == nil
+ end
+
+ test "prefers slug over uuid" do
+ post = %{slug: "hello-world", uuid: "019cfcf7-0000-0000-0000-000000000000"}
+ assert PublishingPubSub.broadcast_id(post) == "hello-world"
+ end
+ end
+
+ # ============================================================================
+ # Topic consistency
+ # ============================================================================
+
+ describe "topic consistency" do
+ test "subscription and broadcast use the same topic for slug-mode posts" do
+ post = %{slug: "my-post", uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"}
+ broadcast_id = PublishingPubSub.broadcast_id(post)
+
+ # The subscription topic should match what the worker would broadcast to
+ topic = PublishingPubSub.post_translations_topic("blog", broadcast_id)
+ assert topic == "publishing:blog:post:my-post:translations"
+ end
+
+ test "subscription and broadcast use the same topic for timestamp-mode posts (no slug)" do
+ post = %{slug: nil, uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"}
+ broadcast_id = PublishingPubSub.broadcast_id(post)
+
+ topic = PublishingPubSub.post_translations_topic("news", broadcast_id)
+ assert topic == "publishing:news:post:019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18:translations"
+ end
+
+ test "version topic uses same broadcast_id pattern" do
+ post = %{slug: nil, uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"}
+ broadcast_id = PublishingPubSub.broadcast_id(post)
+
+ topic = PublishingPubSub.post_versions_topic("news", broadcast_id)
+ assert topic == "publishing:news:post:019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18:versions"
+ end
+ end
+end