Skip to content

feat(rss): multiple feeds #125

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 27, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -2,4 +2,6 @@ import Config

config :elixir, :time_zone_database, Tz.TimeZoneDatabase

config :tableau, :config, url: "http://localhost:4999", out_dir: "_site"

import_config "#{Mix.env()}.exs"
2 changes: 1 addition & 1 deletion lib/mix/tasks/tableau.build.ex
Original file line number Diff line number Diff line change
@@ -17,7 +17,7 @@ defmodule Mix.Tasks.Tableau.Build do

{opts, _argv} = OptionParser.parse!(argv, strict: [out: :string])

out = Keyword.get(opts, :out, "_site")
out = opts[:out] || config.out_dir

mods =
:code.all_available()
1 change: 1 addition & 0 deletions lib/tableau.ex
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ defmodule Tableau do
## Global Site Configuration

* `:include_dir` - string - Directory that is just copied to the output directory. Defaults to `extra`.
* `:out_dir` - string - The directory to output your website to. Defaults to `_site`.
* `:timezone` - string - Timezone to use when parsing date times. Defaults to `Etc/UTC`.
* `:base_path` - string - Development server root. Defaults to '/'.
* `:url` - string (required) - The URL of your website.
10 changes: 4 additions & 6 deletions lib/tableau/config.ex
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@ defmodule Tableau.Config do
:url,
base_path: "",
include_dir: "extra",
out_dir: "_site",
timezone: "Etc/UTC",
reload_log: false,
converters: [md: Tableau.MDExConverter],
@@ -21,19 +22,16 @@ defmodule Tableau.Config do
Tableau.Config.new(Map.new(Application.get_env(:tableau, :config, %{})))
end

defp keyword(value) do
list(tuple([atom(), value]))
end

defp schematic do
schema(
__MODULE__,
%{
optional(:include_dir) => str(),
optional(:out_dir) => str(),
optional(:timezone) => str(),
optional(:reload_log) => bool(),
optional(:converters) => keyword(atom()),
optional(:markdown) => keyword(list()),
optional(:converters) => keyword(values: atom()),
optional(:markdown) => keyword(values: list()),
optional(:base_path) => str(),
url: str()
},
151 changes: 108 additions & 43 deletions lib/tableau/extensions/rss_extension.ex
Original file line number Diff line number Diff line change
@@ -1,76 +1,131 @@
defmodule Tableau.RSSExtension do
@moduledoc """
Generate RSS data and write to `_site/feed.xml`.
Generate one or more RSS feeds.

## Configuration

Configuration is a keyword list of configuration lists with the key being the name of the XML file to be generated.

- `:enabled` - boolean - Extension is active or not.
- `:title` - string (required) - Title of your feed.
- `:description` - string (required) - Description of your feed.
- `:language` - string - Language to use in the `<language>` tag. Defaults to "en-us"
- `:language` - string - Language to use in the `<language>` tag. Defaults to "en-us".
- `:include` - keyword list - List of front matter keys and values to include in the feed. If a post has any value in the list, it'll be included. Defaults to including everything.
- `:exclude` - keyword list - List of front matter keys and values to exclude from the feed. If a post has any value in the list, it'll be included. Defaults to not excluding anything.

### Example

```elixir
config :tableau, Tableau.RSSExtension,
enabled: true,
language: "pt-BR",
title: "My Elixir Devlog",
description: "My Journey on Becoming the Best Elixirist"
feeds: [
super: [
enabled: true,
language: "pt-BR",
title: "The Super Feed",
description: "Includes all posts on the site"
],
posts: [
enabled: true,
language: "pt-BR",
title: "My Elixir Devlog",
description: "My Journey on Becoming the Best Elixirist",
exclude: [category: "til"] # excludes posts with this category
],
til: [
enabled: true,
language: "en-US",
title: "Today I Learned",
description: "Short log of what I learn every day",
include: [category: "til"]
]
]
```
"""
use Tableau.Extension, key: :rss, type: :post_write, priority: 200

import Schematic

@impl Tableau.Extension
def config(config) do
unify(
map(%{
optional(:enabled, true) => bool(),
optional(:language, "en-us") => str(),
title: str(),
description: str()
}),
oneof([
map(%{enabled: false}),
feed_s(&map/1),
map(%{
enabled: true,
feeds: keyword(values: feed_s(&keyword/1))
})
]),
config
)
end

def run(%{site: %{config: %{url: url}}, posts: posts, extensions: %{rss: %{config: rss}}} = token) do
prelude =
"""
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<atom:link href="#{url}/feed.xml" rel="self" type="application/rss+xml" />
<title>#{HtmlEntities.encode(rss.title)}</title>
<link>#{url}</link>
<description>#{HtmlEntities.encode(rss.description)}</description>
<language>#{rss.language}</language>
<generator>Tableau v#{version()}</generator>
"""

# html
items =
for post <- posts, into: "" do
defp feed_s(type) do
type.(%{
optional(:enabled, true) => bool(),
optional(:language, "en-us") => str(),
optional(:include) => include_s(),
optional(:exclude) => include_s(),
title: str(),
description: str()
})
end

defp include_s do
keyword(values: list(str()))
end

@impl Tableau.Extension
def run(%{site: %{config: %{url: url, out_dir: out_dir}}, posts: posts, extensions: %{rss: %{config: feeds}}} = token) do
feeds =
if Map.has_key?(feeds, :feeds) do
feeds.feeds
else
[feed: feeds]
end

for {name, feed} <- feeds, feed[:enabled] do
feed = Map.new(feed)

prelude =
"""
<item>
<title>#{HtmlEntities.encode(post.title)}</title>
<link>#{URI.merge(url, post.permalink)}</link>
<pubDate>#{Calendar.strftime(post.date, "%a, %d %b %Y %X %Z")}</pubDate>
<guid>#{URI.merge(url, post.permalink)}</guid>
<description><![CDATA[ #{post.body} ]]></description>
</item>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<atom:link href="#{url}/#{name}.xml" rel="self" type="application/rss+xml" />
<title>#{HtmlEntities.encode(feed.title)}</title>
<link>#{url}</link>
<description>#{HtmlEntities.encode(feed.description)}</description>
<language>#{feed.language}</language>
<generator>Tableau v#{version()}</generator>
"""
end

# html
postlude =
"""
</channel>
</rss>
"""
items =
for post <- posts,
feed[:include] == nil || filter(post, feed[:include]),
feed[:exclude] == nil || not filter(post, feed[:exclude]),
into: "" do
"""
<item>
<title>#{HtmlEntities.encode(post.title)}</title>
<link>#{URI.merge(url, post.permalink)}</link>
<pubDate>#{Calendar.strftime(post.date, "%a, %d %b %Y %X %Z")}</pubDate>
<guid>#{URI.merge(url, post.permalink)}</guid>
<description><![CDATA[ #{post.body} ]]></description>
</item>
"""
end

File.mkdir_p!("_site")
File.write!("_site/feed.xml", prelude <> items <> postlude)
# html
postlude =
"""
</channel>
</rss>
"""

File.mkdir_p!(out_dir)
File.write!("#{out_dir}/#{name}.xml", prelude <> items <> postlude)
end

{:ok, token}
end
@@ -81,4 +136,14 @@ defmodule Tableau.RSSExtension do
_ -> "dev"
end
end

defp filter(post, include_filter) do
Enum.any?(include_filter, fn {front_matter_key, values_to_accept} ->
post[front_matter_key]
|> List.wrap()
|> Enum.any?(fn front_matter_key ->
front_matter_key in List.wrap(values_to_accept)
end)
end)
end
end
6 changes: 3 additions & 3 deletions lib/tableau/extensions/sitemap_extension.ex
Original file line number Diff line number Diff line change
@@ -48,7 +48,7 @@ defmodule Tableau.SitemapExtension do

require Logger

def run(%{site: %{config: %{url: root}, pages: pages}} = token) do
def run(%{site: %{config: %{url: root, out_dir: out_dir}, pages: pages}} = token) do
urls =
for page <- pages, uniq: true do
loc =
@@ -72,8 +72,8 @@ defmodule Tableau.SitemapExtension do
"</urlset>"
]

File.mkdir_p!("_site")
File.write!("_site/sitemap.xml", xml)
File.mkdir_p!(out_dir)
File.write!("#{out_dir}/sitemap.xml", xml)

{:ok, token}
rescue
5 changes: 3 additions & 2 deletions lib/tableau_dev_server/router.ex
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ defmodule TableauDevServer.Router do
require Logger

@base_path Path.join("/", Application.compile_env(:tableau, [:config, :base_path], ""))
@out_dir Application.compile_env(:tableau, [:config, :out_dir], "_site")

@not_found ~s'''
<!DOCTYPE html><html lang="en"><head></head><body>Not Found</body></html>
@@ -15,7 +16,7 @@ defmodule TableauDevServer.Router do
plug :rerender

plug TableauDevServer.IndexHtml
plug Plug.Static, at: @base_path, from: "_site", cache_control_for_etags: "no-cache"
plug Plug.Static, at: @base_path, from: @out_dir, cache_control_for_etags: "no-cache"

plug :match
plug :dispatch
@@ -57,7 +58,7 @@ defmodule TableauDevServer.Router do

defp rerender(conn, _) do
if conn.request_path != "/ws" do
Mix.Task.rerun("tableau.build", ["--out", "_site"])
Mix.Task.rerun("tableau.build", ["--out", @out_dir])
end

conn
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
@@ -38,7 +38,7 @@ defmodule Tableau.MixProject do
{:html_entities, "~> 0.5.2"},
{:libgraph, "~> 0.16.0"},
{:mdex, "~> 0.2.0"},
{:schematic, "~> 0.4"},
{:schematic, "~> 0.5"},
{:tz, "~> 0.28.1"},
{:web_dev_utils, "~> 0.3"},
{:websock_adapter, "~> 0.5"},
2 changes: 1 addition & 1 deletion mix.lock
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.8.2", "5f25cbe220a8fac3e7ad62e6f950fcdca5a5a5f8501835d2823e8c74bf4268d5", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "63d1bd5f8e23096d1ff851839923162096364bac8656a4a3c00d1fff8e83ee0a"},
"schematic": {:hex, :schematic, "0.4.0", "2b3c4865c919bb9392251aba4198982f7be8785bc37c732ccfe672a3e19e4591", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "692747901601f4b511171fbd3a4032bf13d84dc81d52e827f041373ce2035588"},
"schematic": {:hex, :schematic, "0.5.0", "e9df4e9ad2074ad803cc82612cdfddaa57fbded9698b27f94e9d3aeb0b48e88e", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6cc50951999548506b9f99bfd5c707af70624ec2c4cb85a772da1be12be1b06"},
"styler": {:hex, :styler, "1.1.1", "ccb55763316915b5de532bf14c587c211ddc86bc749ac676e74dfacd3894cc0d", [:mix], [], "hexpm", "80ce12fb862e13d998589eea7c1932f4e6ce9d6ded2182cb322f8f9b2b8d3632"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"},
210 changes: 210 additions & 0 deletions test/tableau/extensions/rss_extension_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
defmodule Tableau.RSSExtensionTest do
use ExUnit.Case, async: true

alias Tableau.RSSExtension

describe "run/1" do
@describetag :tmp_dir
# NOTE: this is deprecated, but we want backwards compatibility for a while
test "creates a single feed", %{tmp_dir: tmp_dir} do
posts = [
post(1, tags: ["post"]),
post(2, tags: ["til"]),
post(3, tags: ["recipe"])
]

rss_config = %{
enabled: true,
language: "pt-BR",
title: "My Elixir Devlog",
description: "My Journey on Becoming the Best Elixirist",
include: [tags: ["post"]]
}

token = %{
site: %{
config: %{
out_dir: tmp_dir,
url: "https://example.com"
}
},
posts: posts,
extensions: %{
rss: %{config: rss_config}
}
}

assert {:ok, _} = RSSExtension.run(token)

feed_path = Path.join(tmp_dir, "feed.xml")
assert File.exists?(feed_path)
feed_content = File.read!(feed_path)

assert feed_content =~ "Post 1"
refute feed_content =~ "Post 2"
refute feed_content =~ "Post 3"
end
end

describe "multiple feeds" do
@describetag :tmp_dir
setup %{tmp_dir: tmp_dir} do
posts = [
post(1, tags: ["post"]),
post(2, tags: ["til"], category: "important"),
post(3, tags: ["recipe"], category: "casual")
]

rss_config = %{
enabled: true,
feeds: [
super: %{
enabled: true,
language: "en-US",
title: "The feed to rule them all",
description: "this is a super feed which comprises all the other feeds"
},
til: %{
enabled: true,
language: "en-US",
title: "Today I Learned",
description: "Short log of what I learn everyday",
include: [tags: ["til"]]
}
]
}

token = %{
site: %{
config: %{
out_dir: tmp_dir,
url: "https://example.com"
}
},
posts: posts,
extensions: %{
rss: %{config: rss_config}
}
}

[token: token]
end

test "include works with various frontmatter", %{token: token, tmp_dir: tmp_dir} do
token =
put_in(token.extensions.rss.config.feeds,
feed: %{
enabled: true,
language: "en-US",
title: "My Elixir Devlog",
description: "My Journey on Becoming the Best Elixirist",
include: [tags: ["post"]]
},
casual: %{
enabled: true,
language: "en-US",
title: "Casual posts",
description: "",
include: [category: "casual"]
}
)

assert {:ok, _} = RSSExtension.run(token)

# only contains posts tagged as "post"
feed_path = Path.join(tmp_dir, "feed.xml")
assert File.exists?(feed_path)
feed_content = File.read!(feed_path)

assert feed_content =~ "Post 1"
refute feed_content =~ "Post 2"
refute feed_content =~ "Post 3"
# only contains posts with category as "casual"
casual_path = Path.join(tmp_dir, "casual.xml")
assert File.exists?(casual_path)
casual_content = File.read!(casual_path)

refute casual_content =~ "Post 2"
refute casual_content =~ "Post 1"
assert casual_content =~ "Post 3"
end

test "includes everything if there is no includes key", %{token: token, tmp_dir: tmp_dir} do
token =
put_in(token.extensions.rss.config.feeds,
feed: %{
enabled: true,
language: "en-US",
title: "My Elixir Devlog",
description: "My Journey on Becoming the Best Elixirist"
}
)

assert {:ok, _} = RSSExtension.run(token)

# contains all posts
feed_path = Path.join(tmp_dir, "feed.xml")
assert File.exists?(feed_path)
feed_content = File.read!(feed_path)

assert feed_content =~ "Post 1"
assert feed_content =~ "Post 2"
assert feed_content =~ "Post 3"
end

test "excludes various frontmatter", %{token: token, tmp_dir: tmp_dir} do
token =
put_in(token.extensions.rss.config.feeds,
not_posts: %{
enabled: true,
language: "en-US",
title: "",
description: "",
exclude: [tags: ["post"]]
},
not_casual: %{
enabled: true,
language: "en-US",
title: "",
description: "",
exclude: [category: "casual"]
}
)

assert {:ok, _} = RSSExtension.run(token)

# only contains posts not tagged as "post"
not_posts_path = Path.join(tmp_dir, "not_posts.xml")
assert File.exists?(not_posts_path)
not_posts_content = File.read!(not_posts_path)

refute not_posts_content =~ "Post 1"
assert not_posts_content =~ "Post 2"
assert not_posts_content =~ "Post 3"

# only contains posts without category as "bar"
not_casual_path = Path.join(tmp_dir, "not_casual.xml")
assert File.exists?(not_casual_path)
not_casual_content = File.read!(not_casual_path)

assert not_casual_content =~ "Post 2"
assert not_casual_content =~ "Post 1"
refute not_casual_content =~ "Post 3"
end
end

defp post(idx, overrides) do
base = %{
title: "Post #{idx}",
permalink: "/posts/post-#{1}",
date: DateTime.utc_now(),
body: """
## Welcome to Post #{idx}
Here, we post like crazy.
"""
}

Map.merge(base, Map.new(overrides))
end
end