From 5b4f02438e4f2c709969aca410c8e09f69fa8548 Mon Sep 17 00:00:00 2001 From: Max Don Date: Wed, 12 Nov 2025 19:56:53 +0200 Subject: [PATCH 1/9] Add YouTube video component and harden blogging metadata extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add `PhoenixKitWeb.Components.Blogging.Video`, wire it into the PHK renderer/inline markdown flow, and document usage in guides - improve slug-mode storage to handle legacy posts without title/status and derive titles from content safely - teach metadata title extraction to skip inline components so hero/video blocks don’t hide headings - add missing Hammer config to the parent app so rate limiting boots in dev/test --- guides/phk_blogging_format.md | 19 ++ lib/phoenix_kit/blogging/renderer.ex | 28 +- .../components/blogging/video.ex | 258 ++++++++++++++++++ .../live/modules/blogging/context/metadata.ex | 55 +++- .../blogging/context/page_builder/renderer.ex | 1 + .../live/modules/blogging/context/storage.ex | 24 +- 6 files changed, 375 insertions(+), 10 deletions(-) create mode 100644 lib/phoenix_kit_web/components/blogging/video.ex diff --git a/guides/phk_blogging_format.md b/guides/phk_blogging_format.md index d3884d0c9..81a47d1ed 100644 --- a/guides/phk_blogging_format.md +++ b/guides/phk_blogging_format.md @@ -81,6 +81,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 +190,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..7c1b4e4fd 100644 --- a/lib/phoenix_kit/blogging/renderer.ex +++ b/lib/phoenix_kit/blogging/renderer.ex @@ -10,7 +10,7 @@ defmodule PhoenixKit.Blogging.Renderer do @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. @@ -193,6 +193,32 @@ defmodule PhoenixKit.Blogging.Renderer do "
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: [] + } + + case PhoenixKitWeb.Components.Blogging.Video.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 + 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_web/components/blogging/video.ex b/lib/phoenix_kit_web/components/blogging/video.ex new file mode 100644 index 000000000..2c5102e0f --- /dev/null +++ b/lib/phoenix_kit_web/components/blogging/video.ex @@ -0,0 +1,258 @@ +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 parse_uri(_), do: :error + + 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 classify_host(_), do: :error + + 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 + cond do + is_binary(path) and String.starts_with?(path, "/embed/") -> + path + |> String.split("/", trim: true) + |> List.last() + + true -> + 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/live/modules/blogging/context/metadata.ex b/lib/phoenix_kit_web/live/modules/blogging/context/metadata.ex index 07e3d90d7..ea8d6c25b 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/context/metadata.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/context/metadata.ex @@ -122,10 +122,8 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Metadata do 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 +147,53 @@ 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} + + 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 + # Extract metadata from YAML-style frontmatter defp extract_frontmatter(content) do case Regex.run(~r/^---\n(.*?)\n---\n(.*)$/s, content) do @@ -236,4 +281,4 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Metadata do defp metadata_value(metadata, key) do Map.get(metadata, key) || Map.get(metadata, Atom.to_string(key)) end -end +end \ No newline at end of file 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..4cb870f90 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 @@ -45,6 +45,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 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..90cccce9b 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,21 @@ 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 || "") + 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(:created_at, Map.get(post.metadata, :created_at)) |> Map.put(:slug, post.slug) |> apply_update_audit_metadata(audit_meta) @@ -1121,4 +1131,10 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do defp floor_to_minute(%DateTime{} = datetime) do %DateTime{datetime | second: 0, microsecond: {0, 0}} end -end + + defp metadata_value(metadata, key, fallback \\ nil) do + Map.get(metadata, key) || + Map.get(metadata, Atom.to_string(key)) || + fallback + end +end \ No newline at end of file From 3ea16c9431da22946aeffe99e96f8b1012e92491 Mon Sep 17 00:00:00 2001 From: Max Don Date: Wed, 12 Nov 2025 20:02:49 +0200 Subject: [PATCH 2/9] Fix metadata title extraction when posts start with components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - teach `Metadata.extract_title_from_content/1` to handle multi-line self-closing PHK components so opening tags like `