Add RSS subscribe links with icon across blog pages, footer, and head

- Add <.rss_icon> inline SVG component in core_components.ex
- Inject RSS <link> tags into <head> on blog pages for auto-discovery
- Show RSS icon next to blog title on index and tag pages
- Show RSS icon on post pages next to back-link
- Add Subscribe section in footer with links to both feeds
- Wire feed URL through blog controller (index, show, tag actions)

174 tests pass, credo clean.
This commit is contained in:
Willem van den Ende 2026-07-08 16:44:41 +01:00
parent 9238ecb7f3
commit 855b6cb590
7 changed files with 111 additions and 7 deletions

View File

@ -420,6 +420,31 @@ defmodule FirehoseWeb.CoreComponents do
""" """
end end
@doc """
Renders an RSS feed icon (inline SVG).
## Examples
<.rss_icon class="size-5" />
"""
attr :class, :string, default: "size-4"
attr :rest, :global
def rss_icon(assigns) do
~H"""
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class={[@class]}
{@rest}
>
<path d="M3.75 3.75v3c0 7.454 6.046 13.5 13.5 13.5h3v-5.25a.75.75 0 0 0-.75-.75h-3a.75.75 0 0 0-.75.75v1.5a8.25 8.25 0 0 1-8.25-8.25h1.5a.75.75 0 0 0 .75-.75v-3a.75.75 0 0 0-.75-.75h-5.25a.75.75 0 0 0-.75.75Z" />
<circle cx="5.25" cy="18.75" r="2.25" />
</svg>
"""
end
## JS Commands ## JS Commands
def show(js \\ %JS{}, selector) do def show(js \\ %JS{}, selector) do

View File

@ -39,4 +39,30 @@
</div> </div>
</main> </main>
<footer class="border-t border-base-200 px-4 py-8 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-3">
<h3 class="text-sm font-semibold tracking-wide text-base-content/60 uppercase">Subscribe</h3>
<div class="flex flex-wrap gap-4">
<a
href="/api/blog/engineering/feed.xml"
class="inline-flex items-center gap-2 text-sm text-base-content/70 hover:text-primary transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<.rss_icon class="size-4 text-orange-500" />
Engineering Blog
</a>
<a
href="/api/blog/releases/feed.xml"
class="inline-flex items-center gap-2 text-sm text-base-content/70 hover:text-primary transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<.rss_icon class="size-4 text-orange-500" />
Release Notes
</a>
</div>
</div>
</footer>
<.flash_group flash={@flash} /> <.flash_group flash={@flash} />

View File

@ -41,6 +41,9 @@
<% end %> <% end %>
<meta name="twitter:card" content={meta.twitter_card} /> <meta name="twitter:card" content={meta.twitter_card} />
<% end %> <% end %>
<%= for feed <- get_content_for(assigns, :feed_links) do %>
<link rel="alternate" type="application/rss+xml" title={feed.title} href={feed.rss_url} />
<% end %>
</head> </head>
<body> <body>
{@inner_content} {@inner_content}

View File

@ -9,9 +9,11 @@ defmodule FirehoseWeb.BlogController do
result = blog.paginate(page) result = blog.paginate(page)
meta = Blogex.SEO.meta_tags_for_blog(blog, FirehoseWeb.Endpoint.url()) meta = Blogex.SEO.meta_tags_for_blog(blog, FirehoseWeb.Endpoint.url())
rss_url = rss_feed_url(blog)
conn conn
|> FirehoseWeb.Layouts.put_content_for(:meta_tags, meta) |> FirehoseWeb.Layouts.put_content_for(:meta_tags, meta)
|> FirehoseWeb.Layouts.put_content_for(:feed_links, %{rss_url: rss_url, title: blog.title()})
|> render(:index, |> render(:index,
page_title: blog.title(), page_title: blog.title(),
blog_title: blog.title(), blog_title: blog.title(),
@ -19,7 +21,8 @@ defmodule FirehoseWeb.BlogController do
posts: result.entries, posts: result.entries,
base_path: blog.base_path(), base_path: blog.base_path(),
page: result.page, page: result.page,
total_pages: result.total_pages total_pages: result.total_pages,
rss_feed_url: rss_url
) )
end end
@ -27,31 +30,40 @@ defmodule FirehoseWeb.BlogController do
blog = conn.assigns.blog blog = conn.assigns.blog
post = blog.get_post!(slug) post = blog.get_post!(slug)
visibility = Blogex.Post.visibility(post) visibility = Blogex.Post.visibility(post)
rss_url = rss_feed_url(blog)
meta = Blogex.SEO.meta_tags(post, FirehoseWeb.Endpoint.url(), blog) meta = Blogex.SEO.meta_tags(post, FirehoseWeb.Endpoint.url(), blog)
conn conn
|> FirehoseWeb.Layouts.put_content_for(:meta_tags, meta) |> FirehoseWeb.Layouts.put_content_for(:meta_tags, meta)
|> FirehoseWeb.Layouts.put_content_for(:feed_links, %{rss_url: rss_url, title: blog.title()})
|> render(:show, |> render(:show,
page_title: post.title, page_title: post.title,
post: post, post: post,
meta: meta, meta: meta,
base_path: blog.base_path(), base_path: blog.base_path(),
visibility: visibility, visibility: visibility,
authenticated: !!(conn.assigns[:current_scope] && conn.assigns.current_scope.user) authenticated: !!(conn.assigns[:current_scope] && conn.assigns.current_scope.user),
rss_feed_url: rss_url
) )
end end
def tag(conn, %{"tag" => tag}) do def tag(conn, %{"tag" => tag}) do
blog = conn.assigns.blog blog = conn.assigns.blog
posts = blog.posts_by_tag(tag) posts = blog.posts_by_tag(tag)
rss_url = rss_feed_url(blog)
render(conn, :tag, rss_link = %{rss_url: rss_url, title: blog.title()}
conn
|> FirehoseWeb.Layouts.put_content_for(:feed_links, rss_link)
|> render(:tag,
page_title: "#{blog.title()}#{tag}", page_title: "#{blog.title()}#{tag}",
blog_title: blog.title(), blog_title: blog.title(),
tag: tag, tag: tag,
posts: posts, posts: posts,
base_path: blog.base_path() base_path: blog.base_path(),
rss_feed_url: rss_url
) )
end end
@ -77,4 +89,9 @@ defmodule FirehoseWeb.BlogController do
_ -> 1 _ -> 1
end end
end end
defp rss_feed_url(blog) do
base_id = blog.base_path() |> String.trim_leading("/blog/")
FirehoseWeb.Endpoint.url() <> "/api/blog/#{base_id}/feed.xml"
end
end end

View File

@ -1,6 +1,17 @@
<div class="space-y-8"> <div class="space-y-8">
<header> <header>
<h1 class="text-3xl font-bold font-display">{@blog_title}</h1> <div class="flex items-center gap-3">
<h1 class="text-3xl font-bold font-display">{@blog_title}</h1>
<a
href={@rss_feed_url}
class="link link-hover text-orange-500 hover:text-orange-600 transition-colors"
title="Subscribe to {@blog_title} RSS feed"
target="_blank"
rel="noopener noreferrer"
>
<.rss_icon class="size-5" />
</a>
</div>
<p :if={@blog_description} class="mt-2 text-base-content/70">{@blog_description}</p> <p :if={@blog_description} class="mt-2 text-base-content/70">{@blog_description}</p>
</header> </header>

View File

@ -1,5 +1,16 @@
<div class="space-y-8"> <div class="space-y-8">
<a href={@base_path} class="text-sm text-primary hover:underline">&larr; Back to posts</a> <div class="flex items-center justify-between">
<a href={@base_path} class="text-sm text-primary hover:underline">&larr; Back to posts</a>
<a
href={@rss_feed_url}
class="link link-hover text-orange-500 hover:text-orange-600 transition-colors"
title="Subscribe to RSS feed"
target="_blank"
rel="noopener noreferrer"
>
<.rss_icon class="size-5" />
</a>
</div>
<%= if @authenticated and @visibility == :draft do %> <%= if @authenticated and @visibility == :draft do %>
<div <div

View File

@ -1,6 +1,17 @@
<div class="space-y-8"> <div class="space-y-8">
<header> <header>
<h1 class="text-3xl font-bold font-display">{@blog_title}</h1> <div class="flex items-center gap-3">
<h1 class="text-3xl font-bold font-display">{@blog_title}</h1>
<a
href={@rss_feed_url}
class="link link-hover text-orange-500 hover:text-orange-600 transition-colors"
title="Subscribe to {@blog_title} RSS feed"
target="_blank"
rel="noopener noreferrer"
>
<.rss_icon class="size-5" />
</a>
</div>
<p class="mt-2 text-base-content/70">Posts tagged "{@tag}"</p> <p class="mt-2 text-base-content/70">Posts tagged "{@tag}"</p>
</header> </header>