diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e6519a1..736fae6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,14 +38,14 @@ jobs: formatter: runs-on: ubuntu-latest - name: Formatter (1.14.x.x/25.x) + name: Formatter (1.15.x/26.x) steps: - uses: actions/checkout@v2 - uses: erlef/setup-beam@v1 with: - otp-version: 25.x - elixir-version: 1.14.x + otp-version: 26.x + elixir-version: 1.15.x - uses: actions/cache@v3 id: cache with: diff --git a/config/test.exs b/config/test.exs index 63787d4..ccbc04d 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,3 +1,9 @@ import Config config :logger, level: :warning + +config :tableau, :config, url: "http://localhost:4999" +config :tableau, Tableau.RSSExtension, enabled: false +config :tableau, Tableau.PostExtension, enabled: false +config :tableau, Mix.Tasks.Tableau.LogExtension, enabled: true +config :tableau, Mix.Tasks.Tableau.FailExtension, enabled: true diff --git a/lib/mix/tasks/tableau.build.ex b/lib/mix/tasks/tableau.build.ex index 3ae89a4..d3dd390 100644 --- a/lib/mix/tasks/tableau.build.ex +++ b/lib/mix/tasks/tableau.build.ex @@ -11,7 +11,7 @@ defmodule Mix.Tasks.Tableau.Build do @impl Mix.Task def run(argv) do {:ok, config} = Tableau.Config.new(@config) - site = %{config: config} + token = %{site: %{config: config}} Mix.Task.run("app.start", ["--preload-modules"]) {opts, _argv} = OptionParser.parse!(argv, strict: [out: :string]) @@ -20,11 +20,35 @@ defmodule Mix.Tasks.Tableau.Build do mods = :code.all_available() - for module <- pre_build_extensions(mods) do - with :error <- module.run(%{site: site}) do - Logger.error("#{inspect(module)} failed to run") + token = + for module <- pre_build_extensions(mods), reduce: token do + token -> + config_mod = Module.concat([module, Config]) + + raw_config = + Application.get_env(:tableau, module, %{}) |> Map.new() + + if raw_config[:enabled] do + {:ok, config} = + raw_config + |> config_mod.new() + + {:ok, key} = Tableau.Extension.key(module) + + token = put_in(token[key], config) + + case module.run(token) do + {:ok, token} -> + token + + :error -> + Logger.error("#{inspect(module)} failed to run") + token + end + else + token + end end - end mods = :code.all_available() graph = Tableau.Graph.new(mods) @@ -35,12 +59,12 @@ defmodule Mix.Tasks.Tableau.Build do {mod, Map.new(mod.__tableau_opts__() || [])} end - {mods, pages} = Enum.unzip(pages) + {page_mods, pages} = Enum.unzip(pages) - site = Map.put(site, :pages, pages) + token = put_in(token.site[:pages], pages) - for mod <- mods do - content = Tableau.Document.render(graph, mod, %{site: site}) + for mod <- page_mods do + content = Tableau.Document.render(graph, mod, token) permalink = mod.__tableau_permalink__() dir = Path.join(out, permalink) @@ -52,13 +76,50 @@ defmodule Mix.Tasks.Tableau.Build do if File.exists?(config.include_dir) do File.cp_r!(config.include_dir, out) end + + for module <- post_write_extensions(mods), reduce: token do + token -> + config_mod = Module.concat([module, Config]) + + raw_config = + Application.get_env(:tableau, module, %{}) |> Map.new() + + if raw_config[:enabled] do + {:ok, config} = + raw_config + |> config_mod.new() + + {:ok, key} = Tableau.Extension.key(module) + + token = put_in(token[key], config) + + case module.run(token) do + {:ok, token} -> + token + + :error -> + Logger.error("#{inspect(module)} failed to run") + token + end + else + token + end + end end defp pre_build_extensions(modules) do for {mod, _, _} <- modules, mod = Module.concat([to_string(mod)]), - match?({:ok, :pre_build}, Tableau.Extension.type(mod)), - Tableau.Extension.enabled?(mod) do + match?({:ok, :pre_build}, Tableau.Extension.type(mod)) do + mod + end + |> Enum.sort_by(& &1.__tableau_extension_priority__()) + end + + defp post_write_extensions(modules) do + for {mod, _, _} <- modules, + mod = Module.concat([to_string(mod)]), + match?({:ok, :post_write}, Tableau.Extension.type(mod)) do mod end |> Enum.sort_by(& &1.__tableau_extension_priority__()) diff --git a/lib/tableau/config.ex b/lib/tableau/config.ex index dbd5a65..b0f85cf 100644 --- a/lib/tableau/config.ex +++ b/lib/tableau/config.ex @@ -4,12 +4,12 @@ defmodule Tableau.Config do * `:include_dir` - Directory that is just copied to the output directory. Defaults to `extra`. * `:timezone` - Timezone to use when parsing date times. Defaults to `Etc/UTC`. + * `:url` - The URL of your website. """ import Schematic - defstruct include_dir: "extra", - timezone: "Etc/UTC" + defstruct [:url, include_dir: "extra", timezone: "Etc/UTC"] def new(config) do unify(schematic(), config) @@ -20,7 +20,8 @@ defmodule Tableau.Config do __MODULE__, %{ optional(:include_dir) => str(), - optional(:timezone) => str() + optional(:timezone) => str(), + url: str() }, convert: false ) diff --git a/lib/tableau/document.ex b/lib/tableau/document.ex index 0a10563..f6774d7 100644 --- a/lib/tableau/document.ex +++ b/lib/tableau/document.ex @@ -9,8 +9,8 @@ defmodule Tableau.Document do defmacro render(inner_content) do quote do case unquote(inner_content) do - [{module, page_assigns} | rest] -> - module.template(%{page: page_assigns, inner_content: rest}) + [{module, page_assigns, assigns} | rest] -> + module.template(Map.merge(assigns, %{page: page_assigns, inner_content: rest})) [] -> nil @@ -32,7 +32,7 @@ defmodule Tableau.Document do end page_assigns = Map.new(module.__tableau_opts__() || []) - mods = for mod <- mods, do: {mod, page_assigns} + mods = for mod <- mods, do: {mod, page_assigns, assigns} root.template(Map.merge(assigns, %{inner_content: mods, page: page_assigns})) end diff --git a/lib/tableau/extension.ex b/lib/tableau/extension.ex index e003f17..e44a85f 100644 --- a/lib/tableau/extension.ex +++ b/lib/tableau/extension.ex @@ -4,21 +4,27 @@ defmodule Tableau.Extension do An extension can be used to generate other kinds of content. + ## Options + + * `:key` - The key in which the extensions configuration and data is loaded. + * `:type` - The type of extension. See below for a description. + * `:priority` - An integer used for ordering extensions of the same type. + * `:enabled` - Whether or not to enable the extension. Defaults to true, and can be configured differently based on the extension. + ## Types There are currently the following extension types: - `:pre_build` - executed before tableau builds your site and writes anything to disk. + - `:post_write` - executed after tableau builds your site and writes everthing to disk. - ## Priority - - Extensions can be assigned a numeric priority for used with sorting. + ## Example ```elixir defmodule MySite.PostsExtension do - use Tableau.Extension, type: :pre_build, priority: 300 + use Tableau.Extension, key: :posts, type: :pre_build, priority: 300 - def run(_site) do + def run(token) do posts = Path.wildcard("_posts/**/*.md") for post <- post do @@ -27,27 +33,28 @@ defmodule Tableau.Extension do |> then(&File.write(Path.join(Path.rootname(post), "index.html"), &1)) end - :ok + {:ok, token} end end ``` ''' - @typep extension_type :: :pre_build + @typep extension_type :: :pre_build | :post_write @doc """ The extension entry point. - The function is passed the a set of default assigns. + The function is passed a token and can return a new token with new data loaded into it. """ - @callback run(map()) :: :ok | :error + @callback run(map()) :: {:ok, map()} | :error defmacro __using__(opts) do - opts = Keyword.validate!(opts, [:enabled, :type, :priority]) + opts = Keyword.validate!(opts, [:key, :enabled, :type, :priority]) prelude = quote do def __tableau_extension_type__, do: unquote(opts)[:type] + def __tableau_extension_key__, do: unquote(opts)[:key] def __tableau_extension_enabled__, do: unquote(opts)[:enabled] || true def __tableau_extension_priority__, do: unquote(opts)[:priority] || 0 end @@ -70,6 +77,16 @@ defmodule Tableau.Extension do end end + @doc false + @spec key(module()) :: extension_type() + def key(module) do + if function_exported?(module, :__tableau_extension_key__, 0) do + {:ok, module.__tableau_extension_key__()} + else + :error + end + end + @doc false @spec enabled?(module()) :: boolean() def enabled?(module) do diff --git a/lib/tableau/extensions/post_extension.ex b/lib/tableau/extensions/post_extension.ex index cd64f0c..754d532 100644 --- a/lib/tableau/extensions/post_extension.ex +++ b/lib/tableau/extensions/post_extension.ex @@ -83,9 +83,9 @@ defmodule Tableau.PostExtension do @config config - use Tableau.Extension, enabled: @config.enabled, type: :pre_build, priority: 100 + use Tableau.Extension, key: :posts, type: :pre_build, priority: 100 - def run(_site) do + def run(token) do Module.create( Tableau.PostExtension.Posts, quote do @@ -111,22 +111,25 @@ defmodule Tableau.PostExtension do Macro.Env.location(__ENV__) ) - for post <- apply(Tableau.PostExtension.Posts, :posts, []) do - {:module, _module, _binary, _term} = - Module.create( - Module.concat([post.id]), - quote do - @external_resource unquote(post.file) - use Tableau.Page, unquote(Macro.escape(Keyword.new(post))) - - def template(_assigns) do - unquote(post.body) - end - end, - Macro.Env.location(__ENV__) - ) - end - - :ok + posts = + for post <- apply(Tableau.PostExtension.Posts, :posts, []) do + {:module, _module, _binary, _term} = + Module.create( + Module.concat([post.id]), + quote do + @external_resource unquote(post.file) + use Tableau.Page, unquote(Macro.escape(Keyword.new(post))) + + def template(_assigns) do + unquote(post.body) + end + end, + Macro.Env.location(__ENV__) + ) + + post + end + + {:ok, Map.put(token, :posts, posts)} end end diff --git a/lib/tableau/extensions/rss_extension.ex b/lib/tableau/extensions/rss_extension.ex new file mode 100644 index 00000000..d75e269 --- /dev/null +++ b/lib/tableau/extensions/rss_extension.ex @@ -0,0 +1,71 @@ +defmodule Tableau.RSSExtension.Config do + import Schematic + + defstruct [:title, :description, language: "en-us", enabled: true] + + def new(input), do: unify(schematic(), input) + + def schematic do + schema( + __MODULE__, + %{ + optional(:enabled) => bool(), + optional(:language) => str(), + title: str(), + description: str() + }, + convert: false + ) + end +end + +defmodule Tableau.RSSExtension do + use Tableau.Extension, key: :rss, type: :post_write, priority: 200 + + def run(%{site: %{config: %{url: url}}, posts: posts, rss: rss} = token) do + prelude = + """ + + + + #{rss.title} + #{url} + #{rss.description} + #{rss.language} + Tableau v#{version()} + """ + + # html + items = + for post <- posts, into: "" do + """ + + #{post.title} + https://#{Path.join(url, post.permalink)} + #{Calendar.strftime(post.date, "%a, %d %b %Y %X %Z")} + http://#{Path.join(url, post.permalink)} + + + """ + end + + # html + postlude = + """ + + + """ + + File.mkdir_p!("_site") + File.write!("_site/feed.xml", prelude <> items <> postlude) + + {:ok, token} + end + + defp version() do + case :application.get_key(:tableau, :vsn) do + {:ok, version} -> to_string(version) + _ -> "dev" + end + end +end diff --git a/test/mix/tasks/tableau.build_test.exs b/test/mix/tasks/tableau.build_test.exs index 043adf7..2a9c3df 100644 --- a/test/mix/tasks/tableau.build_test.exs +++ b/test/mix/tasks/tableau.build_test.exs @@ -1,14 +1,22 @@ defmodule Mix.Tasks.Tableau.LogExtension do - use Tableau.Extension, type: :pre_build, priority: 200 + defmodule Config do + def new(i), do: {:ok, i} + end - def run(_site) do + use Tableau.Extension, key: :log, type: :pre_build, priority: 200 + + def run(token) do IO.inspect(System.monotonic_time(), label: "second") - :ok + {:ok, token} end end defmodule Mix.Tasks.Tableau.FailExtension do - use Tableau.Extension, type: :pre_build, priority: 100 + defmodule Config do + def new(i), do: {:ok, i} + end + + use Tableau.Extension, key: :fail, type: :pre_build, priority: 100 def run(_site) do IO.inspect(System.monotonic_time(), label: "first")