diff --git a/config/config.exs b/config/config.exs index 8631e5f..4298a9e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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" diff --git a/lib/mix/tasks/tableau.build.ex b/lib/mix/tasks/tableau.build.ex index 53eb54c..0bf0fc2 100644 --- a/lib/mix/tasks/tableau.build.ex +++ b/lib/mix/tasks/tableau.build.ex @@ -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() diff --git a/lib/tableau.ex b/lib/tableau.ex index 6cc2449..65b9750 100644 --- a/lib/tableau.ex +++ b/lib/tableau.ex @@ -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. diff --git a/lib/tableau/config.ex b/lib/tableau/config.ex index 37bf410..45f67b0 100644 --- a/lib/tableau/config.ex +++ b/lib/tableau/config.ex @@ -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() }, diff --git a/lib/tableau/extensions/rss_extension.ex b/lib/tableau/extensions/rss_extension.ex index 1f64687..d3c3ce5 100644 --- a/lib/tableau/extensions/rss_extension.ex +++ b/lib/tableau/extensions/rss_extension.ex @@ -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 `` tag. Defaults to "en-us" + - `:language` - string - Language to use in the `` 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 = - """ - - - - #{HtmlEntities.encode(rss.title)} - #{url} - #{HtmlEntities.encode(rss.description)} - #{rss.language} - Tableau v#{version()} - """ - - # 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 = """ - - #{HtmlEntities.encode(post.title)} - #{URI.merge(url, post.permalink)} - #{Calendar.strftime(post.date, "%a, %d %b %Y %X %Z")} - #{URI.merge(url, post.permalink)} - - + + + + #{HtmlEntities.encode(feed.title)} + #{url} + #{HtmlEntities.encode(feed.description)} + #{feed.language} + Tableau v#{version()} """ - end - # html - postlude = - """ - - - """ + items = + for post <- posts, + feed[:include] == nil || filter(post, feed[:include]), + feed[:exclude] == nil || not filter(post, feed[:exclude]), + into: "" do + """ + + #{HtmlEntities.encode(post.title)} + #{URI.merge(url, post.permalink)} + #{Calendar.strftime(post.date, "%a, %d %b %Y %X %Z")} + #{URI.merge(url, post.permalink)} + + + """ + end - File.mkdir_p!("_site") - File.write!("_site/feed.xml", prelude <> items <> postlude) + # html + postlude = + """ + + + """ + + 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 diff --git a/lib/tableau/extensions/sitemap_extension.ex b/lib/tableau/extensions/sitemap_extension.ex index f2457c0..0377a91 100644 --- a/lib/tableau/extensions/sitemap_extension.ex +++ b/lib/tableau/extensions/sitemap_extension.ex @@ -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 "" ] - 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 diff --git a/lib/tableau_dev_server/router.ex b/lib/tableau_dev_server/router.ex index 279372e..8e236ac 100644 --- a/lib/tableau_dev_server/router.ex +++ b/lib/tableau_dev_server/router.ex @@ -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''' Not Found @@ -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 diff --git a/mix.exs b/mix.exs index f6be486..2284170 100644 --- a/mix.exs +++ b/mix.exs @@ -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"}, diff --git a/mix.lock b/mix.lock index 8dd9d6d..d2d535f 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/test/tableau/extensions/rss_extension_test.exs b/test/tableau/extensions/rss_extension_test.exs new file mode 100644 index 00000000..186314c --- /dev/null +++ b/test/tableau/extensions/rss_extension_test.exs @@ -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