diff --git a/config/config.exs b/config/config.exs index 92d4bc07a..bd6d36907 100644 --- a/config/config.exs +++ b/config/config.exs @@ -41,7 +41,8 @@ config :logger, :console, :path, :blog, :pattern, - :content_size + :content_size, + :error ] # For development/testing with real SMTP (when available) @@ -53,3 +54,9 @@ config :logger, :console, # password: System.get_env("SMTP_PASSWORD"), # tls: :if_available, # retries: 1 + +# Import environment-specific config +# This allows config/test.exs to override settings for test environment +if File.exists?("#{__DIR__}/#{Mix.env()}.exs") do + import_config "#{Mix.env()}.exs" +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 000000000..715c8adbc --- /dev/null +++ b/config/test.exs @@ -0,0 +1,44 @@ +import Config + +# Configure test environment for PhoenixKit +# This file is imported by config.exs when Mix.env() == :test + +# Configure test database (when PhoenixKit is used in parent applications) +# Parent apps should configure their own test repo here +# config :phoenix_kit, +# repo: MyApp.Repo + +# Configure test mailer - use Local adapter for test environment +config :phoenix_kit, PhoenixKit.Mailer, adapter: Swoosh.Adapters.Test + +# Disable Swoosh API client as it is only required for production adapters +config :swoosh, :api_client, false + +# Configure Hammer rate limiting for tests +# Use test-friendly limits that match test expectations +config :hammer, + backend: {Hammer.Backend.ETS, [expiry_ms: 60_000, cleanup_interval_ms: 60_000]} + +config :phoenix_kit, PhoenixKit.Users.RateLimiter, + login_limit: 5, + login_window_ms: 60_000, + magic_link_limit: 3, + magic_link_window_ms: 300_000, + password_reset_limit: 3, + password_reset_window_ms: 300_000, + registration_limit: 3, + registration_window_ms: 3_600_000, + registration_ip_limit: 10, + registration_ip_window_ms: 3_600_000 + +# Configure session fingerprinting for tests +config :phoenix_kit, + session_fingerprint_enabled: true, + session_fingerprint_strict: false + +# Future: Configure FakeSettings when blogging tests are implemented +# config :phoenix_kit, +# blogging_settings_module: PhoenixKit.Test.FakeSettings + +# Configure logger for tests +config :logger, level: :warning diff --git a/guides/phk_blogging_format.md b/guides/phk_blogging_format.md index d3884d0c9..7a7bd4bfd 100644 --- a/guides/phk_blogging_format.md +++ b/guides/phk_blogging_format.md @@ -49,6 +49,7 @@ Only a subset is required, but the blogging UI will populate everything shown ab - `title` – displayed in admin tables and public templates. - `status` – controls whether the post is discoverable publicly (`published` only). - `published_at` – timestamp used for ordering and for timestamp-mode folders. +- `featured_image_id` – optional PhoenixKit Storage file ID used for the public listing thumbnail. - `created_by_* / updated_by_*` – audit metadata; the editor manages these. --- @@ -81,6 +82,7 @@ You can mix **bold text** and inline components: | `` | Renders a hero-style heading. | | `` | Medium-sized supporting text. | | `Label` | Button styled by the admin theme. | +| `` | Responsive YouTube embeds. Provide either `video_id="dQw4w9WgXcQ"` or a `url="https://youtu.be/dQw4w9WgXcQ"`. Optional attributes: `autoplay`, `muted`, `controls`, `loop`, `start` (seconds), and `ratio` (`16:9`, `4:3`, `1:1`, `21:9`). Use the component body (or `caption="..."` when self-closing) to show a caption. | Additional components can be introduced by adding Phoenix components under `lib/phoenix_kit_web/components/blogging/` and registering them in the PageBuilder renderer. @@ -189,6 +191,24 @@ published_at: 2025-07-01T10:00:00Z --- +## Example – embedding a YouTube video + +```markdown +## Watch the launch recap + + +``` + +--- + ## Rendering pipeline (current behaviour) 1. **Frontmatter parsing** – YAML is parsed to capture metadata. diff --git a/lib/phoenix_kit/blogging/renderer.ex b/lib/phoenix_kit/blogging/renderer.ex index 908e37759..4283e054a 100644 --- a/lib/phoenix_kit/blogging/renderer.ex +++ b/lib/phoenix_kit/blogging/renderer.ex @@ -8,9 +8,14 @@ defmodule PhoenixKit.Blogging.Renderer do require Logger + alias Phoenix.HTML.Safe + alias PhoenixKitWeb.Components.Blogging.Image + alias PhoenixKitWeb.Components.Blogging.Video + alias PhoenixKitWeb.Live.Modules.Blogging.PageBuilder + @cache_name :blog_posts @cache_version "v1" - @component_regex ~r/<(Image|Hero|CTA|Headline|Subheadline)\s+([^>]*?)\/>/s + @component_regex ~r/<(Image|Hero|CTA|Headline|Subheadline|Video)\s+([^>]*?)\/>/s @doc """ Renders a post's markdown content to HTML. @@ -55,7 +60,7 @@ defmodule PhoenixKit.Blogging.Renderer do {time, result} = :timer.tc(fn -> cond do - is_pure_phk_content?(content) -> + pure_phk_content?(content) -> render_phk_content(content) has_embedded_components?(content) -> @@ -73,7 +78,7 @@ defmodule PhoenixKit.Blogging.Renderer do def render_markdown(_), do: "" # Detect if content is pure .phk XML format (starts with or ) - defp is_pure_phk_content?(content) do + defp pure_phk_content?(content) do trimmed = String.trim(content) String.starts_with?(trimmed, " # Convert Phoenix.LiveView.Rendered to string html - |> Phoenix.HTML.Safe.to_iodata() + |> Safe.to_iodata() |> IO.iodata_to_binary() {:error, reason} -> @@ -120,8 +125,6 @@ defmodule PhoenixKit.Blogging.Renderer do Regex.replace(~r/^[ \t]+(?=#)/m, content, "") end - defp normalize_markdown(content), do: content - # Render mixed content: markdown with embedded XML components defp render_mixed_content(content) when content == "" or is_nil(content), do: "" @@ -178,21 +181,35 @@ defmodule PhoenixKit.Blogging.Renderer do children: [] } - case PhoenixKitWeb.Components.Blogging.Image.render(assigns) do - rendered when is_struct(rendered) -> - rendered - |> Phoenix.HTML.Safe.to_iodata() - |> IO.iodata_to_binary() - - html when is_binary(html) -> - html - end + Image.render(assigns) + |> Safe.to_iodata() + |> IO.iodata_to_binary() rescue error -> Logger.warning("Error rendering Image component: #{inspect(error)}") "
Error rendering image
" end + defp render_inline_component("Video", attrs) do + attr_map = parse_xml_attributes(attrs) + + assigns = %{ + __changed__: nil, + attributes: attr_map, + variant: Map.get(attr_map, "variant", "default"), + content: Map.get(attr_map, "caption"), + children: [] + } + + Video.render(assigns) + |> Safe.to_iodata() + |> IO.iodata_to_binary() + rescue + error -> + Logger.warning("Error rendering Video component: #{inspect(error)}") + "
Error rendering video
" + end + defp render_inline_component(tag, _attrs) do # Fallback for other components Logger.warning("Inline component not supported yet: #{tag}") diff --git a/lib/phoenix_kit/config.ex b/lib/phoenix_kit/config.ex index e6661bc36..15bba49c3 100644 --- a/lib/phoenix_kit/config.ex +++ b/lib/phoenix_kit/config.ex @@ -345,7 +345,7 @@ defmodule PhoenixKit.Config do # This function is used as a fallback when explicit `:parent_app_name` configuration # is not provided. It enables PhoenixKit to automatically integrate with parent # applications without requiring additional configuration in most cases. - defp get_parent_app_fallback() do + defp get_parent_app_fallback do # Get the application of the configured repo to determine parent app case get(:repo) do {:ok, repo_module} when is_atom(repo_module) -> diff --git a/lib/phoenix_kit/install/basic_configuration.ex b/lib/phoenix_kit/install/basic_configuration.ex index d72c98fa7..1297edb95 100644 --- a/lib/phoenix_kit/install/basic_configuration.ex +++ b/lib/phoenix_kit/install/basic_configuration.ex @@ -20,13 +20,13 @@ defmodule PhoenixKit.Install.BasicConfiguration do |> Config.configure_new( "config.exs", :phoenix_kit, - :parent_app_name, + [:parent_app_name], parent_app_name ) |> Config.configure_new( "config.exs", :phoenix_kit, - :parent_module, + [:parent_module], parent_module ) end diff --git a/lib/phoenix_kit/install/repo_detection.ex b/lib/phoenix_kit/install/repo_detection.ex index 5a30f410e..78e19601f 100644 --- a/lib/phoenix_kit/install/repo_detection.ex +++ b/lib/phoenix_kit/install/repo_detection.ex @@ -151,27 +151,25 @@ defmodule PhoenixKit.Install.RepoDetection do # Add repo configuration to config files defp add_repo_config_to_files(igniter, repo_module) do - try do - igniter - # Add repo config to main config.exs - |> Config.configure_new( - "config.exs", - :phoenix_kit, - [:repo], - repo_module - ) - # Also add repo config to test.exs for testing - |> Config.configure_new( - "test.exs", - :phoenix_kit, - [:repo], - repo_module - ) - rescue - _ -> - # Fallback to simple file operations - add_repo_config_simple(igniter, repo_module) - end + igniter + # Add repo config to main config.exs + |> Config.configure_new( + "config.exs", + :phoenix_kit, + [:repo], + repo_module + ) + # Also add repo config to test.exs for testing + |> Config.configure_new( + "test.exs", + :phoenix_kit, + [:repo], + repo_module + ) + rescue + _ -> + # Fallback to simple file operations + add_repo_config_simple(igniter, repo_module) end # Simple file append for repo configuration when Igniter fails diff --git a/lib/phoenix_kit/storage.ex b/lib/phoenix_kit/storage.ex index f0663aea2..f4fb83110 100644 --- a/lib/phoenix_kit/storage.ex +++ b/lib/phoenix_kit/storage.ex @@ -804,11 +804,9 @@ defmodule PhoenixKit.Storage do end defp signed_file_url(file_id, variant_name) do - try do - URLSigner.signed_url(file_id, variant_name, locale: :none) - rescue - _ -> nil - end + URLSigner.signed_url(file_id, variant_name, locale: :none) + rescue + _ -> nil end @doc """ diff --git a/lib/phoenix_kit_web/components/blogging/hero.ex b/lib/phoenix_kit_web/components/blogging/hero.ex index f254a5a3f..d4723b1ec 100644 --- a/lib/phoenix_kit_web/components/blogging/hero.ex +++ b/lib/phoenix_kit_web/components/blogging/hero.ex @@ -20,6 +20,8 @@ defmodule PhoenixKitWeb.Components.Blogging.Hero do """ use Phoenix.Component + alias PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer + attr :variant, :string, default: "centered" attr :children, :list, default: [] attr :attributes, :map, default: %{} @@ -91,7 +93,7 @@ defmodule PhoenixKitWeb.Components.Blogging.Hero do end defp render_child(child, assigns) do - case PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer.render(child, assigns) do + case Renderer.render(child, assigns) do {:ok, html} -> html {:error, _} -> "" end diff --git a/lib/phoenix_kit_web/components/blogging/page.ex b/lib/phoenix_kit_web/components/blogging/page.ex index 2e6145ac2..a1e66c247 100644 --- a/lib/phoenix_kit_web/components/blogging/page.ex +++ b/lib/phoenix_kit_web/components/blogging/page.ex @@ -4,6 +4,8 @@ defmodule PhoenixKitWeb.Components.Blogging.Page do """ use Phoenix.Component + alias PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer + attr :children, :list, default: [] attr :attributes, :map, default: %{} attr :variant, :string, default: "default" @@ -19,7 +21,7 @@ defmodule PhoenixKitWeb.Components.Blogging.Page do end defp render_child(child, assigns) do - case PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer.render(child, assigns) do + case Renderer.render(child, assigns) do {:ok, html} -> html {:error, _} -> "" end diff --git a/lib/phoenix_kit_web/components/blogging/video.ex b/lib/phoenix_kit_web/components/blogging/video.ex new file mode 100644 index 000000000..5829e5f44 --- /dev/null +++ b/lib/phoenix_kit_web/components/blogging/video.ex @@ -0,0 +1,252 @@ +defmodule PhoenixKitWeb.Components.Blogging.Video do + @moduledoc """ + Responsive YouTube embed component. + + Supports both explicit `video_id` and full YouTube URLs. + Optional attributes: + * `autoplay` - "true" | "false" (default) + * `muted` - "true" | "false" (default) + * `controls` - "true" | "false" (default) + * `loop` - "true" | "false" (default) + * `start` - start time in seconds + * `ratio` - aspect ratio string (e.g., "16:9", "4:3", "1:1") + Component body text is rendered as a caption beneath the player. + """ + use Phoenix.Component + + @standard_youtube_hosts [ + "youtube.com", + "www.youtube.com", + "m.youtube.com", + "music.youtube.com" + ] + + @short_youtube_hosts [ + "youtu.be" + ] + + attr(:attributes, :map, default: %{}) + attr(:variant, :string, default: "default") + attr(:content, :string, default: nil) + + def render(assigns) do + attrs = assigns.attributes || %{} + + video_id = + attrs + |> Map.get("video_id") + |> presence() || extract_video_id(Map.get(attrs, "url")) + + assigns = + assigns + |> assign(:video_id, video_id) + |> assign(:embed_url, build_embed_url(video_id, attrs)) + |> assign(:aspect_ratio_class, ratio_class(Map.get(attrs, "ratio", "16:9"))) + |> assign(:has_caption?, has_caption?(assigns.content)) + |> assign(:caption, assigns.content) + + ~H""" + <%= if @video_id do %> +
+
+ +
+ <%= if @has_caption? do %> +

+ {@caption} +

+ <% end %> +
+ <% else %> +
+ Unable to load video – please supply a valid YouTube `video_id` or `url`. +
+ <% end %> + """ + end + + defp build_embed_url(nil, _attrs), do: nil + + defp build_embed_url(video_id, attrs) do + base = "https://www.youtube.com/embed/#{video_id}" + + params = + [ + {"autoplay", truthy?(Map.get(attrs, "autoplay"))}, + {"mute", truthy?(Map.get(attrs, "muted"))}, + {"controls", controls_value(Map.get(attrs, "controls"))}, + {"loop", truthy?(Map.get(attrs, "loop"))}, + {"start", parse_integer(Map.get(attrs, "start"))} + ] + |> Enum.reduce([], fn + {"loop", true}, acc -> [{"loop", "1"}, {"playlist", video_id} | acc] + {"loop", _}, acc -> acc + {"autoplay", true}, acc -> [{"autoplay", "1"} | acc] + {"autoplay", _}, acc -> acc + {"mute", true}, acc -> [{"mute", "1"} | acc] + {"mute", _}, acc -> acc + {"controls", value}, acc when value in ["0", "1"] -> [{"controls", value} | acc] + {"start", nil}, acc -> acc + {"start", value}, acc -> [{"start", Integer.to_string(value)} | acc] + _, acc -> acc + end) + |> Enum.reverse() + + if params == [] do + base + else + base <> "?" <> URI.encode_query(params) + end + end + + defp ratio_class(ratio) do + case ratio do + "4:3" -> "aspect-[4/3]" + "1:1" -> "aspect-square" + "21:9" -> "aspect-[21/9]" + _ -> "aspect-video" + end + end + + defp extract_video_id(nil), do: nil + + defp extract_video_id(url) when is_binary(url) do + with trimmed when trimmed != "" <- String.trim(url), + {:ok, uri} <- parse_uri(trimmed), + {:ok, host_type} <- classify_host(uri.host) do + case host_type do + :short -> extract_short_id(uri.path) + :standard -> extract_standard_id(uri) + end + else + _ -> nil + end + end + + defp extract_video_id(_), do: nil + + defp presence(nil), do: nil + + defp presence(value) when is_binary(value) do + value = String.trim(value) + if value == "", do: nil, else: value + end + + defp presence(_), do: nil + + defp truthy?(true), do: true + defp truthy?(false), do: false + + defp truthy?(value) when is_binary(value) do + value = value |> String.trim() |> String.downcase() + value in ["true", "1", "yes", "on"] + end + + defp truthy?(value) when is_integer(value), do: value != 0 + defp truthy?(_), do: false + + defp controls_value(value) do + if explicitly_false?(value), do: "0", else: "1" + end + + defp explicitly_false?(value) when is_binary(value) do + value + |> String.trim() + |> String.downcase() + |> case do + "" -> false + v -> v in ["false", "0", "no", "off"] + end + end + + defp explicitly_false?(false), do: true + defp explicitly_false?(0), do: true + defp explicitly_false?(_), do: false + + defp parse_integer(nil), do: nil + + defp parse_integer(value) when is_binary(value) do + value + |> String.trim() + |> case do + "" -> + nil + + number -> + case Integer.parse(number) do + {int, _} when int >= 0 -> int + _ -> nil + end + end + end + + defp parse_integer(value) when is_integer(value) and value >= 0, do: value + defp parse_integer(_), do: nil + + defp has_caption?(value) when is_binary(value) do + String.trim(value) != "" + end + + defp has_caption?(_), do: false + + defp parse_uri(string) when is_binary(string) do + case URI.parse(string) do + %URI{scheme: scheme, host: host} = uri + when scheme in ["http", "https"] and is_binary(host) -> + {:ok, uri} + + _ -> + :error + end + end + + defp classify_host(host) when is_binary(host) do + host = String.downcase(host) + + cond do + host in @short_youtube_hosts -> {:ok, :short} + host in @standard_youtube_hosts -> {:ok, :standard} + true -> :error + end + end + + defp extract_short_id(nil), do: nil + + defp extract_short_id(path) do + path + |> String.trim_leading("/") + |> String.split("/", trim: true) + |> List.first() + end + + defp extract_standard_id(%URI{path: path} = uri) do + if is_binary(path) and String.starts_with?(path, "/embed/") do + path + |> String.split("/", trim: true) + |> List.last() + else + uri.query + |> decode_query() + |> Map.get("v") + end + end + + defp decode_query(nil), do: %{} + + defp decode_query(query) do + query + |> URI.query_decoder() + |> Enum.into(%{}) + end +end diff --git a/lib/phoenix_kit_web/controllers/blog_controller.ex b/lib/phoenix_kit_web/controllers/blog_controller.ex index 0d981aeb6..3255348d0 100644 --- a/lib/phoenix_kit_web/controllers/blog_controller.ex +++ b/lib/phoenix_kit_web/controllers/blog_controller.ex @@ -18,6 +18,8 @@ defmodule PhoenixKitWeb.BlogController do alias PhoenixKit.Settings alias PhoenixKitWeb.BlogHTML alias PhoenixKitWeb.Live.Modules.Blogging + alias PhoenixKitWeb.Live.Modules.Blogging.Metadata + alias PhoenixKitWeb.Live.Modules.Blogging.Storage @doc """ Displays a blog post, blog listing, or all blogs overview. @@ -313,14 +315,11 @@ defmodule PhoenixKitWeb.BlogController do # Filter available languages to only show enabled ones languages = - if Enum.empty?(post.available_languages) do - [current_language] - else - # Only show translations that are both available AND enabled - Enum.filter(post.available_languages, fn lang -> - lang in enabled_languages - end) - end + post.available_languages + |> normalize_languages(current_language) + |> Enum.filter(fn lang -> + language_enabled?(lang, enabled_languages) and translation_published?(post, lang) + end) Enum.map(languages, fn lang -> %{ @@ -332,6 +331,39 @@ defmodule PhoenixKitWeb.BlogController do end) end + defp normalize_languages([], current_language), do: [current_language] + defp normalize_languages(languages, _current_language) when is_list(languages), do: languages + + defp language_enabled?(language, enabled_languages), do: language in enabled_languages + + defp translation_published?(post, language) when language == post.language do + Map.get(post.metadata, :status) == "published" + end + + defp translation_published?(post, language) do + case fetch_translation_metadata(post, language) do + {:ok, metadata} -> Map.get(metadata, :status) == "published" + _ -> false + end + end + + defp fetch_translation_metadata(post, language) do + post.full_path + |> Path.dirname() + |> Path.join(Storage.language_filename(language)) + |> read_metadata() + end + + defp read_metadata(path) do + with true <- File.exists?(path), + {:ok, contents} <- File.read(path), + {:ok, metadata, _content} <- Metadata.parse_with_content(contents) do + {:ok, metadata} + else + _ -> :error + end + end + defp build_breadcrumbs(blog_slug, post, language) do {:ok, blog} = fetch_blog(blog_slug) diff --git a/lib/phoenix_kit_web/controllers/blog_html.ex b/lib/phoenix_kit_web/controllers/blog_html.ex index b278b946a..a2738f557 100644 --- a/lib/phoenix_kit_web/controllers/blog_html.ex +++ b/lib/phoenix_kit_web/controllers/blog_html.ex @@ -7,8 +7,9 @@ defmodule PhoenixKitWeb.BlogHTML do alias PhoenixKit.Blogging.Renderer alias PhoenixKit.Config alias PhoenixKit.Module.Languages + alias PhoenixKit.Storage - embed_templates "blog_html/*" + embed_templates("blog_html/*") @doc """ Builds the public URL for a blog listing page. @@ -183,4 +184,23 @@ defmodule PhoenixKitWeb.BlogHTML do enabled_count == 1 end + + @doc """ + Resolves a featured image URL for a post, falling back to the original variant. + """ + def featured_image_url(post, variant \\ "medium") do + post.metadata + |> Map.get(:featured_image_id) + |> resolve_featured_image_url(variant) + end + + defp resolve_featured_image_url(nil, _variant), do: nil + defp resolve_featured_image_url("", _variant), do: nil + + defp resolve_featured_image_url(file_id, variant) when is_binary(file_id) do + Storage.get_public_url_by_id(file_id, variant) || + Storage.get_public_url_by_id(file_id) + rescue + _ -> nil + end end diff --git a/lib/phoenix_kit_web/controllers/blog_html/index.html.heex b/lib/phoenix_kit_web/controllers/blog_html/index.html.heex index c0905a191..3c71338af 100644 --- a/lib/phoenix_kit_web/controllers/blog_html/index.html.heex +++ b/lib/phoenix_kit_web/controllers/blog_html/index.html.heex @@ -30,6 +30,16 @@
<%= for post <- @posts do %>
+ <%= if featured_image_url = featured_image_url(post, "medium") do %> +
+ {post.metadata.title +
+ <% end %>

[%{"name" => "Docs", "slug" => "docs", "mode" => "slug"}] }) @@ -447,8 +453,12 @@ FakeSettings.update_json_setting("blogging_blogs", %{ ### Running Tests +**Status:** Tests not yet implemented + +When implemented, tests will be run as follows: + ```bash -# Run all blogging tests (76 tests total) +# Run all blogging tests mix test test/phoenix_kit_web/live/modules/blogging/ # Run specific test suites @@ -616,30 +626,30 @@ To change modes, you must: ### Problem: FakeSettings module not found in tests -**Symptoms:** -``` -** (ArgumentError) The module PhoenixKitWeb.Live.Modules.Blogging.BloggingModeTest.FakeSettings -was given as a child to a supervisor but it does not exist -``` +**Status:** Not applicable - FakeSettings not yet implemented -**Root Cause:** +**Future Implementation:** -Test file is trying to reference `FakeSettings` from wrong module namespace. +When blogging tests are added, the FakeSettings module will be: -**Solution:** +1. **Defined in:** `test/support/fake_settings.ex` (standard Phoenix test support location) +2. **Configured in:** `config/test.exs` (NOT runtime in test files) +3. **Used as:** `PhoenixKit.Test.FakeSettings` (standard namespace) -Use the shared test helper: +**Correct pattern:** ```elixir -# ✅ Correct -alias PhoenixKitWeb.Live.Modules.Blogging.FakeSettings - -# ❌ Wrong -alias PhoenixKitWeb.Live.Modules.Blogging.BloggingModeTest.FakeSettings +# config/test.exs +config :phoenix_kit, + blogging_settings_module: PhoenixKit.Test.FakeSettings + +# test/support/fake_settings.ex +defmodule PhoenixKit.Test.FakeSettings do + use Agent + # Implementation details... +end ``` -The `FakeSettings` module is defined in `test/support/blogging_fake_settings.exs` and is available to all test files. - ## Getting Help 1. Check test suite for usage examples: `test/phoenix_kit_web/live/modules/blogging/` diff --git a/lib/phoenix_kit_web/live/modules/blogging/blogging.ex b/lib/phoenix_kit_web/live/modules/blogging/blogging.ex index 34d3f3c63..0ab4253ce 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/blogging.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/blogging.ex @@ -364,7 +364,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging do end defp settings_module do - Application.get_env(:phoenix_kit, :blogging_settings_module, PhoenixKit.Settings) + PhoenixKit.Config.get(:blogging_settings_module, PhoenixKit.Settings) end defp settings_call(fun, args) do diff --git a/lib/phoenix_kit_web/live/modules/blogging/context/metadata.ex b/lib/phoenix_kit_web/live/modules/blogging/context/metadata.ex index 07e3d90d7..27270978e 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/context/metadata.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/context/metadata.ex @@ -21,6 +21,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Metadata do description: String.t() | nil, slug: String.t(), published_at: String.t(), + featured_image_id: String.t() | nil, created_at: String.t() | nil, created_by_id: String.t() | nil, created_by_email: String.t() | nil, @@ -57,7 +58,14 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Metadata do @spec serialize(metadata()) :: String.t() def serialize(metadata) do optional_lines = - [:created_at, :created_by_id, :created_by_email, :updated_by_id, :updated_by_email] + [ + :featured_image_id, + :created_at, + :created_by_id, + :created_by_email, + :updated_by_id, + :updated_by_email + ] |> Enum.flat_map(fn key -> case metadata_value(metadata, key) do nil -> [] @@ -95,6 +103,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Metadata do description: nil, slug: "", published_at: DateTime.to_iso8601(now), + featured_image_id: nil, created_at: nil, created_by_id: nil, created_by_email: nil, @@ -112,20 +121,32 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Metadata do def extract_title_from_content(content) when is_binary(content) do content |> String.trim() - |> extract_title_from_lines() + |> do_extract_title() end def extract_title_from_content(_), do: "Untitled" + defp do_extract_title(""), do: "Untitled" + + defp do_extract_title(content) do + content + |> extract_title_from_lines() + |> case do + "Untitled" -> + extract_title_from_components(content) || "Untitled" + + title -> + title + end + end + defp extract_title_from_lines(""), do: "Untitled" defp extract_title_from_lines(content) do lines = content - |> String.split("\n") - |> Enum.take(10) - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) + |> extract_candidate_lines() + |> Enum.take(15) # Look for first H1 heading (# Title) h1_line = @@ -149,6 +170,102 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Metadata do end end + defp extract_candidate_lines(content) do + {lines, _depth} = + content + |> String.split("\n") + |> Enum.reduce({[], 0}, fn raw_line, {acc, depth} -> + line = String.trim(raw_line) + + cond do + line == "" and depth == 0 -> + {acc, depth} + + component_self_closing?(line) -> + {acc, depth} + + component_open?(line) -> + {acc, depth + 1} + + depth > 0 and multiline_self_close?(raw_line) -> + {acc, max(depth - 1, 0)} + + component_close?(line) and depth > 0 -> + {acc, max(depth - 1, 0)} + + depth > 0 -> + {acc, depth} + + true -> + {[line | acc], depth} + end + end) + + lines + |> Enum.reverse() + |> Enum.reject(&(&1 == "")) + end + + defp component_open?(line) do + String.starts_with?(line, "<") and + not String.starts_with?(line, "}, line) + end + + defp component_self_closing?(line) do + component_open?(line) and String.ends_with?(line, "/>") + end + + defp multiline_self_close?(line) do + line + |> String.trim() + |> case do + "/>" -> true + ">" -> false + other -> String.ends_with?(other, "/>") + end + end + + defp extract_title_from_components(content) do + component_title(content, "Headline") || + component_attribute(content, "Hero", "title") || + component_title(content, "Title") + end + + defp component_title(content, tag) do + regex = ~r/<#{tag}\b[^>]*>(.*?)<\/#{tag}>/is + + case Regex.run(regex, content, capture: :all_but_first) do + [inner | _] -> sanitize_component_text(inner) + _ -> nil + end + end + + defp component_attribute(content, tag, attr) do + regex = ~r/<#{tag}\b[^>]*#{attr}="([^"]+)"[^>]*>/i + + case Regex.run(regex, content, capture: :all_but_first) do + [value | _] -> sanitize_component_text(value) + _ -> nil + end + end + + defp sanitize_component_text(text) do + text + |> String.trim() + |> String.replace(~r/<[^>]+>/, "") + |> String.replace(~r/\s+/, " ") + |> String.trim() + |> case do + "" -> nil + cleaned -> String.slice(cleaned, 0, 100) + end + end + # Extract metadata from YAML-style frontmatter defp extract_frontmatter(content) do case Regex.run(~r/^---\n(.*?)\n---\n(.*)$/s, content) do @@ -188,6 +305,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Metadata do status: Map.get(metadata, "status", default.status), slug: Map.get(metadata, "slug", default.slug), published_at: Map.get(metadata, "published_at", default.published_at), + featured_image_id: Map.get(metadata, "featured_image_id", default.featured_image_id), description: Map.get(metadata, "description"), created_at: Map.get(metadata, "created_at", default.created_at), created_by_id: Map.get(metadata, "created_by_id", default.created_by_id), diff --git a/lib/phoenix_kit_web/live/modules/blogging/context/page_builder/renderer.ex b/lib/phoenix_kit_web/live/modules/blogging/context/page_builder/renderer.ex index 822ad24e8..d423a42fd 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/context/page_builder/renderer.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/context/page_builder/renderer.ex @@ -20,14 +20,12 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer do def render(ast, _assigns) when is_list(ast) do {:ok, Phoenix.HTML.raw( - ast - |> Enum.map(fn node -> + Enum.map_join(ast, fn node -> case render(node, %{}) do {:ok, html} -> Phoenix.HTML.safe_to_string(html) {:error, _} -> "" end end) - |> Enum.join() )} end @@ -45,6 +43,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer do defp resolve_component(:cta), do: {:ok, PhoenixKitWeb.Components.Blogging.CTA} defp resolve_component(:image), do: {:ok, PhoenixKitWeb.Components.Blogging.Image} + defp resolve_component(:video), do: {:ok, PhoenixKitWeb.Components.Blogging.Video} defp resolve_component(_), do: {:error, :not_found} # Render using the component module @@ -81,14 +80,12 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.PageBuilder.Renderer do ast.content ast[:children] -> - ast.children - |> Enum.map(fn child -> + Enum.map_join(ast.children, fn child -> case render(child, assigns) do {:ok, html} -> Phoenix.HTML.safe_to_string(html) _ -> "" end end) - |> Enum.join() true -> "" diff --git a/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex b/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex index f6c97bc7e..d854c22f4 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex @@ -631,11 +631,24 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do def update_post_slug_in_place(_blog_slug, post, params, audit_meta) do audit_meta = Map.new(audit_meta) + current_title = + metadata_value(post.metadata, :title) || + Metadata.extract_title_from_content(post.content || "") + + featured_image_id = resolve_featured_image_id(params, post.metadata) + metadata = post.metadata - |> Map.put(:title, Map.get(params, "title", post.metadata.title)) - |> Map.put(:status, Map.get(params, "status", post.metadata.status)) - |> Map.put(:published_at, Map.get(params, "published_at", post.metadata.published_at)) + |> Map.put(:title, Map.get(params, "title", current_title)) + |> Map.put( + :status, + Map.get(params, "status", metadata_value(post.metadata, :status, "draft")) + ) + |> Map.put( + :published_at, + Map.get(params, "published_at", metadata_value(post.metadata, :published_at)) + ) + |> Map.put(:featured_image_id, featured_image_id) |> Map.put(:created_at, Map.get(post.metadata, :created_at)) |> Map.put(:slug, post.slug) |> apply_update_audit_metadata(audit_meta) @@ -685,11 +698,14 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do {final_metadata, final_content} = if lang_code == post.language do + featured_image_id = resolve_featured_image_id(params, metadata) + updated_metadata = base_metadata |> Map.put(:title, Map.get(params, "title", metadata.title)) |> Map.put(:status, Map.get(params, "status", metadata.status)) |> Map.put(:published_at, Map.get(params, "published_at", metadata.published_at)) + |> Map.put(:featured_image_id, featured_image_id) {updated_metadata, Map.get(params, "content", content)} else @@ -920,11 +936,14 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do def update_post(_blog_slug, post, params, audit_meta) do audit_meta = Map.new(audit_meta) + featured_image_id = resolve_featured_image_id(params, post.metadata) + new_metadata = post.metadata |> Map.put(:title, Map.get(params, "title", post.metadata.title)) |> Map.put(:status, Map.get(params, "status", post.metadata.status)) |> Map.put(:published_at, Map.get(params, "published_at", post.metadata.published_at)) + |> Map.put(:featured_image_id, featured_image_id) |> apply_update_audit_metadata(audit_meta) new_content = Map.get(params, "content", post.content) @@ -1121,4 +1140,28 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do defp floor_to_minute(%DateTime{} = datetime) do %DateTime{datetime | second: 0, microsecond: {0, 0}} end + + defp resolve_featured_image_id(params, metadata) do + case Map.fetch(params, "featured_image_id") do + {:ok, value} -> normalize_featured_image_id(value) + :error -> metadata_value(metadata, :featured_image_id) + end + end + + defp normalize_featured_image_id(value) when is_binary(value) do + value + |> String.trim() + |> case do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_featured_image_id(_), do: nil + + defp metadata_value(metadata, key, fallback \\ nil) do + Map.get(metadata, key) || + Map.get(metadata, Atom.to_string(key)) || + fallback + end end diff --git a/lib/phoenix_kit_web/live/modules/blogging/editor.ex b/lib/phoenix_kit_web/live/modules/blogging/editor.ex index f280a07a4..fc0de8705 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/editor.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/editor.ex @@ -7,6 +7,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do alias PhoenixKit.Blogging.Renderer alias PhoenixKit.Settings + alias PhoenixKit.Storage alias PhoenixKit.Utils.Routes alias PhoenixKitWeb.BlogHTML alias PhoenixKitWeb.Live.Modules.Blogging @@ -145,7 +146,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do ) ) |> assign(:has_pending_changes, false) - |> assign(:public_url, build_public_url(post, socket.assigns.current_locale)) + |> assign(:public_url, build_public_url(post, post.language)) |> push_event("changes-status", %{has_changes: false}) end @@ -181,7 +182,8 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do | metadata: Map.merge(socket.assigns.post.metadata, %{status: new_form["status"]}) } - public_url = build_public_url(updated_post, socket.assigns.current_locale) + language = editor_language(socket.assigns) + public_url = build_public_url(updated_post, language) {:noreply, socket @@ -278,7 +280,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do def handle_event("save", _params, socket) do params = socket.assigns.form - |> Map.take(["status", "published_at", "slug"]) + |> Map.take(["status", "published_at", "slug", "featured_image_id"]) |> Map.put("content", socket.assigns.content) params = @@ -646,7 +648,8 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do post.metadata.published_at || DateTime.utc_now() |> floor_datetime_to_minute() - |> DateTime.to_iso8601() + |> DateTime.to_iso8601(), + "featured_image_id" => post.metadata.featured_image_id || "" } form = @@ -678,10 +681,17 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do end defp normalize_form(form) when is_map(form) do + featured_image_id = + form + |> Map.get("featured_image_id", "") + |> to_string() + |> String.trim() + base = %{ "status" => Map.get(form, "status", "draft") || "draft", - "published_at" => normalize_published_at(Map.get(form, "published_at")) + "published_at" => normalize_published_at(Map.get(form, "published_at")), + "featured_image_id" => featured_image_id } case Map.fetch(form, "slug") do @@ -694,7 +704,12 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do end defp normalize_form(_), - do: %{"status" => "draft", "published_at" => "", "slug" => ""} + do: %{ + "status" => "draft", + "published_at" => "", + "slug" => "", + "featured_image_id" => "" + } defp datetime_local_value(nil), do: "" @@ -738,6 +753,27 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do defp normalize_published_at(_), do: "" + defp featured_image_preview_url(value) do + case sanitize_featured_image_id(value) do + nil -> + nil + + file_id -> + BlogHTML.featured_image_url(%{metadata: %{featured_image_id: file_id}}, "medium") + end + end + + defp sanitize_featured_image_id(value) when is_binary(value) do + value + |> String.trim() + |> case do + "" -> nil + trimmed -> trimmed + end + end + + defp sanitize_featured_image_id(_), do: nil + defp build_preview_payload(socket) do form = socket.assigns.form || %{} post = socket.assigns.post @@ -999,7 +1035,8 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do title: "", status: "draft", published_at: DateTime.to_iso8601(now), - slug: "" + slug: "", + featured_image_id: nil }, content: "", language: primary_language, @@ -1031,7 +1068,8 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do metadata: %{ title: "", status: "draft", - published_at: DateTime.to_iso8601(now) + published_at: DateTime.to_iso8601(now), + featured_image_id: nil }, content: "", language: primary_language, @@ -1091,6 +1129,12 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do end end + defp editor_language(assigns) do + assigns[:current_language] || + assigns |> Map.get(:post, %{}) |> Map.get(:language) || + hd(Storage.enabled_language_codes()) + end + defp invalidate_post_cache(blog_slug, post) do # Determine identifier based on post mode identifier = diff --git a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex index c610699bf..3a8de4c3d 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex @@ -189,6 +189,64 @@

<% end %> +
+ + +

+ + {gettext("Use the Media Manager to upload or copy a file ID.")} + + + {gettext("Open Media Manager")} + +

+ <%= if preview_url = featured_image_preview_url(@form["featured_image_id"]) do %> +
+ {@post.metadata.title +

+ {gettext("Clear the field above to remove the featured image.")} +

+

+ {gettext("Valid image ID")} +

+
+ <% else %> + <% trimmed_id = @form["featured_image_id"] |> to_string() |> String.trim() %> + <%= if trimmed_id == "" do %> +

+ {gettext("No featured image selected.")} +

+ <% else %> +

+ {gettext("Invalid image ID or file not found.")} +

+ <% end %> + <% end %> +
+