Static blog with front page summary

Goal: have a personal blog, and try out another point in the 'modular
app design with elixir' space.

Designing OTP systems with elixir had some interesting ideas.
This commit is contained in:
Your Name
2026-03-17 11:17:21 +00:00
commit bc14696f57
94 changed files with 6846 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
defmodule Blogex do
@moduledoc """
Blogex — a multi-blog engine for Phoenix apps, powered by NimblePublisher.
Blogex lets you host multiple blogs (e.g. engineering blog, release notes)
from markdown files in your repo. Posts are compiled into the BEAM at build
time for instant reads with zero runtime I/O.
## Quick Start
1. Add `blogex` to your dependencies:
```elixir
def deps do
[
{:blogex, "~> 0.1.0"}
]
end
```
2. Create your markdown posts:
```
priv/blog/engineering/2026/03-10-our-new-architecture.md
priv/blog/release-notes/2026/03-01-v2-launch.md
```
Each file has frontmatter + content:
```markdown
%{
title: "Our New Architecture",
author: "Jane Doe",
tags: ~w(elixir architecture),
description: "How we rebuilt our platform"
}
---
Your markdown content here...
```
3. Define blog modules in your app:
```elixir
defmodule Firehose.EngineeringBlog do
use Blogex.Blog,
blog_id: :engineering,
app: :firehose,
from: "priv/blog/engineering/**/*.md",
title: "Engineering Blog",
description: "Deep dives into our tech stack",
base_path: "/blog/engineering"
end
defmodule Firehose.ReleaseNotes do
use Blogex.Blog,
blog_id: :release_notes,
app: :firehose,
from: "priv/blog/release-notes/**/*.md",
title: "Release Notes",
description: "What's new in our product",
base_path: "/blog/releases"
end
```
4. Register blogs in your config:
```elixir
config :blogex,
blogs: [Firehose.EngineeringBlog, Firehose.ReleaseNotes]
```
5. Mount routes in your Phoenix router:
```elixir
scope "/blog" do
pipe_through :browser
forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog
forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes
end
```
6. Enable live reloading in `config/dev.exs`:
```elixir
live_reload: [
patterns: [
...,
~r"priv/blog/*/.*(md)$"
]
]
```
## Architecture (Poncho Pattern)
Blogex is designed as a "poncho" — it wraps NimblePublisher and provides
a clean public API while the inner library does the heavy lifting of
markdown parsing and compilation. The host app's supervision tree is used
directly; Blogex adds no processes of its own since all data is compiled
into module attributes at build time.
## Modules
* `Blogex.Blog` — macro to define a blog context
* `Blogex.Post` — post struct
* `Blogex.Registry` — cross-blog queries
* `Blogex.Feed` — RSS/Atom feed generation
* `Blogex.SEO` — meta tags and sitemap generation
* `Blogex.Components` — Phoenix function components
* `Blogex.Router` — mountable Plug router
"""
defdelegate blogs, to: Blogex.Registry
defdelegate get_blog!(blog_id), to: Blogex.Registry
defdelegate get_blog(blog_id), to: Blogex.Registry
defdelegate all_posts, to: Blogex.Registry
defdelegate all_tags, to: Blogex.Registry
end
+122
View File
@@ -0,0 +1,122 @@
defmodule Blogex.Blog do
@moduledoc """
Macro to define a blog context backed by NimblePublisher.
## Usage
In your host application, define one module per blog:
defmodule Firehose.EngineeringBlog do
use Blogex.Blog,
blog_id: :engineering,
app: :firehose,
from: "priv/blog/engineering/**/*.md",
title: "Engineering Blog",
description: "Deep dives into our tech stack",
base_path: "/blog/engineering"
end
defmodule Firehose.ReleaseNotes do
use Blogex.Blog,
blog_id: :release_notes,
app: :firehose,
from: "priv/blog/release-notes/**/*.md",
title: "Release Notes",
description: "What's new in Firehose",
base_path: "/blog/releases"
end
Each module compiles all markdown posts at build time and exposes
query functions like `all_posts/0`, `get_post!/1`, `posts_by_tag/1`, etc.
"""
defmacro __using__(opts) do
blog_id = Keyword.fetch!(opts, :blog_id)
app = Keyword.fetch!(opts, :app)
from = Keyword.fetch!(opts, :from)
title = Keyword.fetch!(opts, :title)
description = Keyword.get(opts, :description, "")
base_path = Keyword.fetch!(opts, :base_path)
highlighters = Keyword.get(opts, :highlighters, [:makeup_elixir, :makeup_erlang])
quote do
alias Blogex.Post
use NimblePublisher,
build: Post,
from: Application.app_dir(unquote(app), unquote(from)),
as: :posts,
highlighters: unquote(highlighters)
# Inject the blog_id into each post and sort by descending date
@posts @posts
|> Enum.map(&Map.put(&1, :blog, unquote(blog_id)))
|> Enum.sort_by(& &1.date, {:desc, Date})
# Collect all unique tags
@tags @posts |> Enum.flat_map(& &1.tags) |> Enum.uniq() |> Enum.sort()
@blog_id unquote(blog_id)
@blog_title unquote(title)
@blog_description unquote(description)
@blog_base_path unquote(base_path)
@doc "Returns the blog identifier atom."
def blog_id, do: @blog_id
@doc "Returns the blog title."
def title, do: @blog_title
@doc "Returns the blog description."
def description, do: @blog_description
@doc "Returns the base URL path for this blog."
def base_path, do: @blog_base_path
@doc "Returns all published posts, newest first."
def all_posts, do: Enum.filter(@posts, & &1.published)
@doc "Returns the N most recent published posts."
def recent_posts(n \\ 5), do: Enum.take(all_posts(), n)
@doc "Returns all unique tags across all published posts."
def all_tags, do: @tags
@doc "Returns all published posts matching the given tag."
def posts_by_tag(tag) do
Enum.filter(all_posts(), fn post -> tag in post.tags end)
end
@doc "Returns a single post by slug/id, or raises."
def get_post!(id) do
Enum.find(all_posts(), &(&1.id == id)) ||
raise Blogex.NotFoundError, "post #{inspect(id)} not found in #{@blog_id}"
end
@doc "Returns a single post by slug/id, or nil."
def get_post(id) do
Enum.find(all_posts(), &(&1.id == id))
end
@doc "Returns paginated posts. Page is 1-indexed."
def paginate(page \\ 1, per_page \\ 10) do
posts = all_posts()
total = length(posts)
total_pages = max(ceil(total / per_page), 1)
entries =
posts
|> Enum.drop((page - 1) * per_page)
|> Enum.take(per_page)
%{
entries: entries,
page: page,
per_page: per_page,
total_entries: total,
total_pages: total_pages
}
end
end
end
end
+153
View File
@@ -0,0 +1,153 @@
defmodule Blogex.Components do
@moduledoc """
Phoenix function components for rendering blog content.
These are unstyled building blocks — the host app wraps them
in its own layout and applies its own CSS.
## Usage in a LiveView or template:
import Blogex.Components
<.post_index blog={@blog_module} posts={@posts} />
<.post_show post={@post} />
<.tag_list tags={@tags} base_path={@base_path} />
"""
use Phoenix.Component
@doc """
Renders a list of post previews.
## Attributes
* `:posts` - list of `%Blogex.Post{}` structs (required)
* `:base_path` - base URL path for post links (required)
"""
attr :posts, :list, required: true
attr :base_path, :string, required: true
def post_index(assigns) do
~H"""
<div class="blogex-post-index">
<article :for={post <- @posts} class="blogex-post-preview">
<header>
<h2>
<a href={"#{@base_path}/#{post.id}"}>{post.title}</a>
</h2>
<.post_meta post={post} />
</header>
<p class="blogex-post-description">{post.description}</p>
</article>
</div>
"""
end
@doc """
Renders a full blog post.
## Attributes
* `:post` - a `%Blogex.Post{}` struct (required)
"""
attr :post, :map, required: true
def post_show(assigns) do
~H"""
<article class="blogex-post">
<header class="blogex-post-header">
<h1>{@post.title}</h1>
<.post_meta post={@post} />
</header>
<div class="blogex-post-body">
{Phoenix.HTML.raw(@post.body)}
</div>
</article>
"""
end
@doc """
Renders post metadata (date, author, tags).
"""
attr :post, :map, required: true
def post_meta(assigns) do
~H"""
<div class="blogex-post-meta">
<time datetime={Date.to_iso8601(@post.date)}>
{Calendar.strftime(@post.date, "%B %d, %Y")}
</time>
<span :if={@post.author} class="blogex-post-author">
by {@post.author}
</span>
<span :for={tag <- @post.tags} class="blogex-tag">
{tag}
</span>
</div>
"""
end
@doc """
Renders a tag cloud / tag list with links.
## Attributes
* `:tags` - list of tag strings (required)
* `:base_path` - base URL path (required)
* `:current_tag` - currently selected tag for highlighting (optional)
"""
attr :tags, :list, required: true
attr :base_path, :string, required: true
attr :current_tag, :string, default: nil
def tag_list(assigns) do
~H"""
<nav class="blogex-tag-list">
<a
:for={tag <- @tags}
href={"#{@base_path}/tag/#{tag}"}
class={["blogex-tag-link", tag == @current_tag && "blogex-tag-active"]}
>
{tag}
</a>
</nav>
"""
end
@doc """
Renders pagination controls.
## Attributes
* `:page` - current page number (required)
* `:total_pages` - total number of pages (required)
* `:base_path` - base URL path (required)
"""
attr :page, :integer, required: true
attr :total_pages, :integer, required: true
attr :base_path, :string, required: true
def pagination(assigns) do
~H"""
<nav :if={@total_pages > 1} class="blogex-pagination">
<a
:if={@page > 1}
href={"#{@base_path}?page=#{@page - 1}"}
class="blogex-pagination-prev"
>
&larr; Newer
</a>
<span class="blogex-pagination-info">
Page {@page} of {@total_pages}
</span>
<a
:if={@page < @total_pages}
href={"#{@base_path}?page=#{@page + 1}"}
class="blogex-pagination-next"
>
Older &rarr;
</a>
</nav>
"""
end
end
+139
View File
@@ -0,0 +1,139 @@
defmodule Blogex.Feed do
@moduledoc """
Generates RSS 2.0 and Atom feeds for a blog.
## Usage
# In a controller or plug:
xml = Blogex.Feed.rss(MyApp.EngineeringBlog, "https://myapp.com")
conn |> put_resp_content_type("application/rss+xml") |> send_resp(200, xml)
"""
@doc """
Generates an RSS 2.0 XML feed for the given blog module.
## Options
* `:limit` - max number of posts to include (default: 20)
* `:language` - feed language (default: "en-us")
"""
def rss(blog_module, base_url, opts \\ []) do
limit = Keyword.get(opts, :limit, 20)
language = Keyword.get(opts, :language, "en-us")
posts = Enum.take(blog_module.all_posts(), limit)
blog_url = "#{base_url}#{blog_module.base_path()}"
feed_url = "#{blog_url}/feed.xml"
pub_date =
case posts do
[latest | _] -> format_rfc822(latest.date)
[] -> format_rfc822(Date.utc_today())
end
"""
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>#{escape(blog_module.title())}</title>
<link>#{blog_url}</link>
<description>#{escape(blog_module.description())}</description>
<language>#{language}</language>
<pubDate>#{pub_date}</pubDate>
<atom:link href="#{feed_url}" rel="self" type="application/rss+xml"/>
#{Enum.map_join(posts, "\n", &item_xml(&1, base_url, blog_module))}
</channel>
</rss>
"""
|> String.trim()
end
@doc """
Generates an Atom feed for the given blog module.
## Options
* `:limit` - max number of posts to include (default: 20)
"""
def atom(blog_module, base_url, opts \\ []) do
limit = Keyword.get(opts, :limit, 20)
posts = Enum.take(blog_module.all_posts(), limit)
blog_url = "#{base_url}#{blog_module.base_path()}"
feed_url = "#{blog_url}/feed.xml"
updated =
case posts do
[latest | _] -> format_iso8601(latest.date)
[] -> format_iso8601(Date.utc_today())
end
"""
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>#{escape(blog_module.title())}</title>
<link href="#{blog_url}" rel="alternate"/>
<link href="#{feed_url}" rel="self"/>
<id>#{blog_url}</id>
<updated>#{updated}</updated>
#{Enum.map_join(posts, "\n", &entry_xml(&1, base_url, blog_module))}
</feed>
"""
|> String.trim()
end
# Private helpers
defp item_xml(post, base_url, blog_module) do
url = "#{base_url}#{blog_module.base_path()}/#{post.id}"
"""
<item>
<title>#{escape(post.title)}</title>
<link>#{url}</link>
<guid isPermaLink="true">#{url}</guid>
<pubDate>#{format_rfc822(post.date)}</pubDate>
<description>#{escape(post.description)}</description>
<content:encoded><![CDATA[#{post.body}]]></content:encoded>
#{Enum.map_join(post.tags, "\n", &" <category>#{escape(&1)}</category>")}
</item>
"""
end
defp entry_xml(post, base_url, blog_module) do
url = "#{base_url}#{blog_module.base_path()}/#{post.id}"
"""
<entry>
<title>#{escape(post.title)}</title>
<link href="#{url}" rel="alternate"/>
<id>#{url}</id>
<published>#{format_iso8601(post.date)}</published>
<updated>#{format_iso8601(post.date)}</updated>
<author><name>#{escape(post.author)}</name></author>
<summary>#{escape(post.description)}</summary>
<content type="html"><![CDATA[#{post.body}]]></content>
#{Enum.map_join(post.tags, "\n", &" <category term=\"#{escape(&1)}\"/>")}
</entry>
"""
end
defp format_rfc822(date) do
date
|> DateTime.new!(~T[00:00:00], "Etc/UTC")
|> Calendar.strftime("%a, %d %b %Y %H:%M:%S +0000")
end
defp format_iso8601(date) do
"#{Date.to_iso8601(date)}T00:00:00Z"
end
defp escape(text) when is_binary(text) do
text
|> String.replace("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
|> String.replace("'", "&apos;")
end
end
+91
View File
@@ -0,0 +1,91 @@
defmodule Blogex.Layout do
@moduledoc """
Minimal HTML layout for Blogex pages.
Provides a default HTML shell when serving blog content to browsers.
Host apps can override by providing their own layout module via the
`:layout` option on the router.
"""
use Phoenix.Component
import Blogex.Components
@doc """
Wraps blog content in a minimal HTML page.
"""
attr :title, :string, required: true
attr :inner_content, :any, required: true
def page(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@title}</title>
</head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
{Phoenix.HTML.raw(@inner_content)}
</body>
</html>
"""
end
@doc "Renders the post index page."
def index_page(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@blog_title}</title>
</head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
<h1>{@blog_title}</h1>
<p>{@blog_description}</p>
<.post_index posts={@posts} base_path={@base_path} />
<.pagination page={@page} total_pages={@total_pages} base_path={@base_path} />
</body>
</html>
"""
end
@doc "Renders a single post page."
def show_page(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@post.title}</title>
</head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
<nav><a href={@base_path}>&larr; Back</a></nav>
<.post_show post={@post} />
</body>
</html>
"""
end
@doc "Renders a tag listing page."
def tag_page(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@blog_title} — #{@tag}</title>
</head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
<nav><a href={@base_path}>&larr; Back</a></nav>
<h1>Posts tagged "{@tag}"</h1>
<.post_index posts={@posts} base_path={@base_path} />
</body>
</html>
"""
end
end
+7
View File
@@ -0,0 +1,7 @@
defmodule Blogex.NotFoundError do
@moduledoc """
Raised when a blog or post is not found.
Implements Plug.Exception to return a 404 status.
"""
defexception [:message, plug_status: 404]
end
+68
View File
@@ -0,0 +1,68 @@
defmodule Blogex.Post do
@moduledoc """
Struct representing a single blog post.
Posts are parsed from markdown files with frontmatter metadata.
The filename determines the date and slug:
priv/blog/engineering/2026/03-10-our-new-architecture.md
Frontmatter example:
%{
title: "Our New Architecture",
author: "Jane Doe",
tags: ~w(elixir architecture),
description: "How we rebuilt our platform"
}
---
Your markdown content here...
"""
@enforce_keys [:id, :title, :author, :body, :description, :date]
defstruct [
:id,
:title,
:author,
:body,
:description,
:date,
:blog,
tags: [],
published: true
]
@type t :: %__MODULE__{
id: String.t(),
title: String.t(),
author: String.t(),
body: String.t(),
description: String.t(),
date: Date.t(),
tags: [String.t()],
blog: atom(),
published: boolean()
}
@doc """
Build callback for NimblePublisher.
Extracts the date from the filename path and merges with frontmatter attrs.
The `blog` atom is injected by the parent Blog module.
"""
def build(filename, attrs, body) do
[year, month_day_id] =
filename
|> Path.rootname()
|> Path.split()
|> Enum.take(-2)
[month, day, id] = String.split(month_day_id, "-", parts: 3)
date = Date.from_iso8601!("#{year}-#{month}-#{day}")
struct!(
__MODULE__,
[id: id, date: date, body: body] ++ Map.to_list(attrs)
)
end
end
+52
View File
@@ -0,0 +1,52 @@
defmodule Blogex.Registry do
@moduledoc """
Registry that tracks all blog modules in the host application.
Configure in your app's config:
config :blogex,
blogs: [MyApp.EngineeringBlog, MyApp.ReleaseNotes]
Then you can query across all blogs:
Blogex.Registry.all_posts() # posts from all blogs, sorted by date
Blogex.Registry.blogs() # list of blog modules
Blogex.Registry.get_blog!(:engineering) # get a specific blog module
"""
@doc "Returns the list of configured blog modules."
def blogs do
Application.get_env(:blogex, :blogs, [])
end
@doc "Returns a blog module by its blog_id, or raises."
def get_blog!(blog_id) do
Enum.find(blogs(), fn mod -> mod.blog_id() == blog_id end) ||
raise Blogex.NotFoundError, "blog #{inspect(blog_id)} not found"
end
@doc "Returns a blog module by its blog_id, or nil."
def get_blog(blog_id) do
Enum.find(blogs(), fn mod -> mod.blog_id() == blog_id end)
end
@doc "Returns all posts from all blogs, sorted newest first."
def all_posts do
blogs()
|> Enum.flat_map(& &1.all_posts())
|> Enum.sort_by(& &1.date, {:desc, Date})
end
@doc "Returns all unique tags across all blogs."
def all_tags do
blogs()
|> Enum.flat_map(& &1.all_tags())
|> Enum.uniq()
|> Enum.sort()
end
@doc "Returns a map of %{blog_id => blog_module} for all registered blogs."
def blogs_map do
Map.new(blogs(), fn mod -> {mod.blog_id(), mod} end)
end
end
+194
View File
@@ -0,0 +1,194 @@
defmodule Blogex.Router do
@moduledoc """
Plug router that serves blog pages and feeds.
Serves HTML to browsers (Accept: text/html) and JSON to API clients.
Mount this in your host app's router:
# In your Phoenix router
scope "/blog" do
pipe_through :browser
forward "/engineering", Blogex.Router, blog: MyApp.EngineeringBlog
forward "/releases", Blogex.Router, blog: MyApp.ReleaseNotes
end
Or use the convenience macro:
import Blogex.Router, only: [blogex_routes: 2]
scope "/blog" do
pipe_through :browser
blogex_routes "/engineering", MyApp.EngineeringBlog
blogex_routes "/releases", MyApp.ReleaseNotes
end
## Routes served
* `GET /` — post index (paginated)
* `GET /:slug` — individual post
* `GET /tag/:tag` — posts by tag
* `GET /feed.xml` — RSS feed
* `GET /atom.xml` — Atom feed
"""
use Plug.Router
plug :match
plug :dispatch
get "/feed.xml" do
blog = conn.private[:blogex_blog]
base_url = Blogex.Router.Helpers.base_url(conn)
xml = Blogex.Feed.rss(blog, base_url)
conn
|> put_resp_content_type("application/rss+xml")
|> send_resp(200, xml)
end
get "/atom.xml" do
blog = conn.private[:blogex_blog]
base_url = Blogex.Router.Helpers.base_url(conn)
xml = Blogex.Feed.atom(blog, base_url)
conn
|> put_resp_content_type("application/atom+xml")
|> send_resp(200, xml)
end
get "/tag/:tag" do
blog = conn.private[:blogex_blog]
posts = blog.posts_by_tag(tag)
if wants_html?(conn) do
assigns = %{
blog_title: blog.title(),
tag: tag,
posts: posts,
base_path: blog.base_path()
}
send_html(conn, Blogex.Layout.tag_page(assigns))
else
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(%{
blog: blog.blog_id(),
tag: tag,
posts: Enum.map(posts, &post_json/1)
}))
end
end
get "/:slug" do
blog = conn.private[:blogex_blog]
case blog.get_post(slug) do
nil ->
conn |> send_resp(404, "Post not found")
post ->
if wants_html?(conn) do
assigns = %{post: post, base_path: blog.base_path()}
send_html(conn, Blogex.Layout.show_page(assigns))
else
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(post_json(post)))
end
end
end
get "/" do
blog = conn.private[:blogex_blog]
page = (conn.params["page"] || "1") |> String.to_integer()
result = blog.paginate(page)
if wants_html?(conn) do
assigns = %{
blog_title: blog.title(),
blog_description: blog.description(),
posts: result.entries,
base_path: blog.base_path(),
page: result.page,
total_pages: result.total_pages
}
send_html(conn, Blogex.Layout.index_page(assigns))
else
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(%{
blog: blog.blog_id(),
title: blog.title(),
posts: Enum.map(result.entries, &post_json/1),
page: result.page,
total_pages: result.total_pages,
total_entries: result.total_entries
}))
end
end
match _ do
send_resp(conn, 404, "Not found")
end
@doc false
def init(opts), do: opts
@doc false
def call(conn, opts) do
blog = Keyword.fetch!(opts, :blog)
conn
|> Plug.Conn.put_private(:blogex_blog, blog)
|> super(opts)
end
defp wants_html?(conn) do
case Plug.Conn.get_req_header(conn, "accept") do
[accept | _] -> String.contains?(accept, "text/html")
_ -> false
end
end
defp send_html(conn, rendered) do
html =
rendered
|> Phoenix.HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
conn
|> put_resp_content_type("text/html")
|> send_resp(200, html)
end
defp post_json(post) do
%{
id: post.id,
title: post.title,
author: post.author,
date: Date.to_iso8601(post.date),
description: post.description,
tags: post.tags,
body: post.body
}
end
defmodule Helpers do
@moduledoc false
def base_url(conn) do
scheme = if conn.scheme == :https, do: "https", else: "http"
port_suffix = port_suffix(conn.scheme, conn.port)
"#{scheme}://#{conn.host}#{port_suffix}"
end
defp port_suffix(:http, 80), do: ""
defp port_suffix(:https, 443), do: ""
defp port_suffix(_, port), do: ":#{port}"
end
end
+62
View File
@@ -0,0 +1,62 @@
defmodule Blogex.SEO do
@moduledoc """
SEO helpers for generating meta tags and sitemaps.
"""
@doc """
Returns a map of meta tag attributes for a post.
Useful for setting OpenGraph and Twitter card tags in your layout.
<meta property="og:title" content={@meta.og_title} />
"""
def meta_tags(post, base_url, blog_module) do
url = "#{base_url}#{blog_module.base_path()}/#{post.id}"
%{
title: post.title,
description: post.description,
og_title: post.title,
og_description: post.description,
og_type: "article",
og_url: url,
article_published_time: Date.to_iso8601(post.date),
article_author: post.author,
article_tags: post.tags,
twitter_card: "summary_large_image"
}
end
@doc """
Generates a sitemap.xml string for the given blog modules.
xml = Blogex.SEO.sitemap([MyApp.EngineeringBlog, MyApp.ReleaseNotes], "https://myapp.com")
"""
def sitemap(blog_modules, base_url) when is_list(blog_modules) do
urls =
blog_modules
|> Enum.flat_map(fn mod ->
mod.all_posts()
|> Enum.map(fn post ->
url = "#{base_url}#{mod.base_path()}/#{post.id}"
lastmod = Date.to_iso8601(post.date)
"""
<url>
<loc>#{url}</loc>
<lastmod>#{lastmod}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
"""
end)
end)
|> Enum.join()
"""
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
#{urls}</urlset>
"""
|> String.trim()
end
end