index : pandoc | |
Hex package with a file-watcher and Mix task for using Pandoc to convert Markdown files to HTML. |
aboutsummaryrefslogtreecommitdiff |
diff options
-rw-r--r-- | LICENCE.md | 32 | ||||
-rw-r--r-- | README.md | 187 | ||||
-rw-r--r-- | config/config.exs | 7 | ||||
-rw-r--r-- | lib/mix/tasks/pandoc.ex | 62 | ||||
-rw-r--r-- | lib/mix/tasks/pandoc.install.ex | 70 | ||||
-rw-r--r-- | lib/pandoc.ex | 360 | ||||
-rw-r--r-- | lib/pandoc/application.ex | 43 | ||||
-rw-r--r-- | lib/pandoc/watcher.ex | 23 | ||||
-rw-r--r-- | mix.exs | 17 | ||||
-rw-r--r-- | mix.lock | 1 | ||||
-rw-r--r-- | test/pandoc_test.exs | 111 |
11 files changed, 725 insertions, 188 deletions
diff --git a/LICENCE.md b/LICENCE.md new file mode 100644 index 0000000..3b17725 --- /dev/null +++ b/LICENCE.md @@ -0,0 +1,32 @@ +# MIT License + +Most of this code is copied directly from +[esbuild](https://github.com/phoenixframework/esbuild) and +[tailwind](https://github.com/phoenixframework/tailwind), so I guess, +technically, some or all of these people own the copyrights: + +Copyright (c) 2021 Wojtek Mach, José Valim. Copyright (c) 2022 Chris McCord. + +And for the bits that I wrote: + +Copyright (c) 2024 Catalin Mititiuc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + @@ -1,168 +1,117 @@ # Pandoc -A watcher and a Mix task that uses Pandoc to convert markdown files to html. +A watcher and Mix tasks for installing and invoking [pandoc](https://pandoc.org/). ## Requirements -- inotify-tools -- pandoc +Currently only supports `linux-amd64` architectures. ## Installation -```elixir -# mix.exs +If you are going to convert markup in production, then you add `pandoc` as +dependency on all environments but only start it in dev: +```elixir def deps do [ - {:pandoc, "~> 0.2.0", runtime: Mix.env() == :dev} + {:pandoc, "~> 0.3", runtime: Mix.env() == :dev} ] end ``` -## Use +However, if your markup is preconverted during development, then it only needs +to be a dev dependency: ```elixir -# mix.exs - -# ... -defp aliases do +def deps do [ - # ... - "documents.build": ["pandoc hello"], - "statics.build": ["assets.build", "documents.build"], - "statics.deploy": ["assets.deploy", "documents.build"] + {:pandoc, "~> 0.3", only: :dev} ] end ``` -```elixir -# config/config.exs - -config :pandoc, - hello: [ - args: ~w(--mathjax -o ../priv/static/posts), - cd: Path.expand("../documents", __DIR__) - ] -``` +Once installed, change your `config/config.exs` to pick your pandoc version of +choice: ```elixir -# config/dev.exs - -config :hello, HelloWeb.Endpoint, - # ... - watchers: [ - # ... - pandoc: {Pandoc, :run, [:hello, ~w(--watch)]} - ] - -config :pandoc, hello: [pattern: "**/*.md"] +config :pandoc, version: "3.6.1" ``` -```elixir -# lib/hello_web/router.ex - - scope "/", HelloWeb do - pipe_through :browser +Now you can install pandoc by running: - get "/drafts/:id", PostController, :draft - get "/posts/:id", PostController, :show - get "/posts", PostController, :index - - get "/", PageController, :home - end +```bash +$ mix pandoc.install ``` -```elixir -# lib/hello_web/controllers/posts_controller.ex - -defmodule HelloWeb.PostController do - use HelloWeb, :controller +And invoke pandoc with: - alias Hello.Document - @path "documents/**/*.md" - paths = Path.wildcard(@path) - @paths_hash :erlang.md5(paths) - for path <- paths, do: @external_resource(path) - @posts Document.list() +```bash +$ mix pandoc default documents/hello.md -o priv/static/posts/hello.html +``` - def __mix_recompile__?(), do: @path |> Path.wildcard() |> :erlang.md5() != @paths_hash +The executable is kept at `_build/pandoc-TARGET`. Where `TARGET` is your +system target architecture. - def index(conn, _params) do - render(conn, :index, posts: @posts) - end +## Profiles - def show(conn, %{"id" => id}) do - assigns = [ - post: :hello |> :code.priv_dir() |> Path.join("static/posts/#{id}.html") |> File.read!() - ] +The first argument to `pandoc` is the execution profile. You can define multiple +execution profiles with the current directory, the OS environment, and default +arguments to the `pandoc` task: - render(conn, :show, assigns) - end +```elixir +config :pandoc, + version: "3.6.1", + default: [ + args: ~w(--mathjax), + cd: Path.expand("../documents", __DIR__) + ] +``` - def drafts(conn, %{"id" => id}) do - config = Application.get_env(:pandoc, :hello) +When `mix pandoc default` is invoked, the task arguments will be appended to +the ones configured above. Note profiles must be configured in your +`config/config.exs`, as `pandoc` runs without starting your application (and +therefore it won't pick settings in `config/runtime.exs`). - opts = [ - cd: config[:cd] || File.cwd!() - ] +## Adding to Phoenix - filename = List.keyfind(@posts, id, 0) |> elem(1) |> Map.get(:filename) - path = Path.join("_drafts", filename) +To add `pandoc` to an application using Phoenix, you will need Phoenix v1.6+ and +the following steps. - render(conn, :show, post: "pandoc" |> System.cmd([path], opts) |> elem(0)) - end -``` +First add it as a dependency in your `mix.exs`: ```elixir -# lib/hello/document.ex - -defmodule Stasis.Document do - require Logger - - @ext ".md" - @pattern Application.compile_env(:pandoc, [:hello, :pattern]) - - def list() do - "documents" - |> Path.join(@pattern) - |> Path.wildcard() - |> Enum.map(fn path -> {Path.basename(path), path} end) - |> Enum.sort(fn {basename_a, _}, {basename_b, _} -> basename_a < basename_b end) - |> Enum.reduce([], fn {filename, path}, acc -> - id = Path.rootname(filename, @ext) - data = if "_drafts" in Path.split(path), do: %{:draft, true}, else: %{} - - [{id, data} | acc] - end) - end +def deps do + [ + {:phoenix, "~> 1.6"}, + {:pandoc, "~> 0.3", runtime: Mix.env() == :dev} + ] +end ``` -```elixir -# lib/hello_web/controllers/post_html.ex - -# ... - -defp href(filename, draft \\ false) do - root = (draft && "/drafts") || "/posts" - Path.join(root, filename |> Path.basename(".md")) -end +Now let's change `config/config.exs` to configure `pandoc` to write to +`priv/static/posts`: +```elixir +config :pandoc, + version: "3.6.1", + default: [ + args: fn extra_args -> + {_, [input_file], _} = OptionParser.parse(extra_args, switches: []) + ~w(--output=../priv/static/posts/#{Path.rootname(input_file)}.html) + end, + cd: Path.expand("../documents", __DIR__) + ] ``` -```heex -<!-- lib/hello_web/controllers/post_html/index.html.heex --> +For development, we want to enable the watcher. So find the `watchers` +configuration in your `config/dev.exs` and add: -<%= for {id, data} <- @posts do %> - <p> - <.link href={href(Path.rootname(filename), data[:draft])} method="get"> - <%= id %> - </.link> - </p> -<% end %> +```elixir +pandoc: {Pandoc, :watch, [:default]} ``` -```heex -<!-- lib/hello_web/controllers/post_html/show.html.heex --> +Note we are enabling the file system watcher. -<%= raw(@post) %> -``` +## Licence + +pandoc source code is licensed under the MIT License. diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..f2c4dac --- /dev/null +++ b/config/config.exs @@ -0,0 +1,7 @@ +import Config + +config :pandoc, + version: "3.6.1", + another: [ + args: ["--version"] + ] diff --git a/lib/mix/tasks/pandoc.ex b/lib/mix/tasks/pandoc.ex index 2c1573d..a04ab26 100644 --- a/lib/mix/tasks/pandoc.ex +++ b/lib/mix/tasks/pandoc.ex @@ -1,13 +1,44 @@ defmodule Mix.Tasks.Pandoc do - use Mix.Task + @moduledoc """ + Invokes pandoc with the given args. + + Usage: + + $ mix pandoc TASK_OPTIONS PROFILE PANDOC_ARGS + + Example: + + $ mix pandoc default documents/hello.md -o priv/static/posts/hello.html + + If pandoc is not installed, it is automatically downloaded. Note the + arguments given to this task will be appended to any configured arguments. + + ## Options + + * `--runtime-config` - load the runtime configuration + before executing command - @ext ".md" + Note flags to control this Mix task must be given before the profile: + + $ mix pandoc --runtime-config default documents/hello.md + + """ + + @shortdoc "Invokes pandoc with the profile and args" + @compile {:no_warn_undefined, Mix} + + use Mix.Task @impl true def run(args) do switches = [runtime_config: :boolean] {opts, remaining_args} = OptionParser.parse_head!(args, switches: switches) + if function_exported?(Mix, :ensure_application!, 1) do + Mix.ensure_application!(:inets) + Mix.ensure_application!(:ssl) + end + if opts[:runtime_config] do Mix.Task.run("app.config") else @@ -19,28 +50,11 @@ defmodule Mix.Tasks.Pandoc do install_and_run(remaining_args) end - defp install_and_run([profile | _args] = all) do - IO.puts("Converting markdown...") - - profile = String.to_atom(profile) - config = Application.get_env(:pandoc, profile) - args = config[:args] || [] - opts = [cd: config[:cd] || File.cwd!()] - - out_path = List.last(args) - full_out_path = [opts[:cd], out_path] |> Path.join() |> Path.expand() - File.rm_rf!(full_out_path) - File.mkdir_p!(full_out_path) - - opts[:cd] - |> Path.join("*#{@ext}") - |> Path.wildcard() - |> Enum.each(fn path -> - case Pandoc.run(profile, path) do - 0 -> :ok - status -> Mix.raise("`mix pandoc #{Enum.join(all, " ")}` exited with #{status}") - end - end) + defp install_and_run([profile | args] = all) do + case Pandoc.install_and_run(String.to_atom(profile), args) do + 0 -> :ok + status -> Mix.raise("`mix pandoc #{Enum.join(all, " ")}` exited with #{status}") + end end defp install_and_run([]) do diff --git a/lib/mix/tasks/pandoc.install.ex b/lib/mix/tasks/pandoc.install.ex new file mode 100644 index 0000000..1e36696 --- /dev/null +++ b/lib/mix/tasks/pandoc.install.ex @@ -0,0 +1,70 @@ +defmodule Mix.Tasks.Pandoc.Install do + @moduledoc """ + Installs pandoc under `_build`. + + ```bash + $ mix pandoc.install + $ mix pandoc.install --if-missing + ``` + + By default, it installs #{Pandoc.latest_version()} but you can configure it + in your config files, such as: + + config :pandoc, :version, "#{Pandoc.latest_version()}" + + ## Options + + * `--runtime-config` - load the runtime configuration before executing + command + + * `--if-missing` - install only if the given version does not exist + """ + + @shortdoc "Installs pandoc under _build" + @compile {:no_warn_undefined, Mix} + + use Mix.Task + + @impl true + def run(args) do + valid_options = [runtime_config: :boolean, if_missing: :boolean] + + {opts, base_url} = + case OptionParser.parse_head!(args, strict: valid_options) do + {opts, []} -> + {opts, Pandoc.default_base_url()} + + {opts, [base_url]} -> + {opts, base_url} + + {_, _} -> + Mix.raise(""" + Invalid arguments to pandoc.install, expected one of: + + mix pandoc.install + mix pandoc.install 'https://github.com/jgm/pandoc/releases/download/$version/pandoc-$version-$target.tar.gz' + mix pandoc.install --runtime-config + mix pandoc.install --if-missing + """) + end + + if opts[:runtime_config], do: Mix.Task.run("app.config") + + if opts[:if_missing] && latest_version?() do + :ok + else + if function_exported?(Mix, :ensure_application!, 1) do + Mix.ensure_application!(:inets) + Mix.ensure_application!(:ssl) + end + + Mix.Task.run("loadpaths") + Pandoc.install(base_url) + end + end + + defp latest_version?() do + version = Pandoc.configured_version() + match?({:ok, ^version}, Pandoc.bin_version()) + end +end diff --git a/lib/pandoc.ex b/lib/pandoc.ex index f1f07f7..ca30847 100644 --- a/lib/pandoc.ex +++ b/lib/pandoc.ex @@ -1,17 +1,130 @@ defmodule Pandoc do + # https://github.com/jgm/pandoc/releases + @latest_version "3.6.1" + @moduledoc """ - Documentation for `Pandoc`. + Pandoc is an installer, runner and watcher for [pandoc](https://pandoc.org). + + ## Profiles + + You can define multiple pandoc profiles. By default, there is a profile + called `:default` which you can configure its args, current directory and + environment. You can make the args dynamic by defining a function. + + config :pandoc, + version: "#{@latest_version}", + default: [ + args: fn extra_args -> + {_, [input_file], _} = OptionParser.parse(extra_args, switches: []) + ~w(--output=../priv/static/posts/\#{Path.rootname(input_file)}.html) + end, + cd: Path.expand("../documents", __DIR__) + ] + + ## Pandoc configuration + + There are four global configurations for the pandoc application: + + * `:version` - the expected pandoc version + + * `:version_check` - whether to perform the version check or not. + Useful when you manage the pandoc executable with an external + tool + + * `:cacerts_path` - the directory to find certificates for + https connections + + * `:path` - the path to find the pandoc executable at. By + default, it is automatically downloaded and placed inside + the `_build` directory of your current app + + Overriding the `:path` is not recommended, as we will automatically download + and manage `pandoc` for you. But in case you can't download it, you may want + to set the `:path` to a configurable system location. + """ + + require Logger + + @doc false + # Latest known version at the time of publishing. + def latest_version, do: @latest_version + + @doc """ + Returns the configured pandoc version. """ + def configured_version do + Application.get_env(:pandoc, :version, latest_version()) + end + + @doc """ + Returns the configuration for the given profile. + + Returns nil if the profile does not exist. + """ + def config_for!(profile) when is_atom(profile) do + Application.get_env(:pandoc, profile) || + raise ArgumentError, """ + unknown pandoc profile. Make sure the profile is defined in your config/config.exs file, such as: - def run(profile, ["--watch" | _]) do - config = Application.get_env(:pandoc, profile) - opts = [cd: config[:cd] || File.cwd!()] - dirs = [opts[:cd], Path.join(opts[:cd], "_drafts")] + config :pandoc, + version: "#{@latest_version}", + #{profile}: [ + cd: Path.expand("../documents", __DIR__) + ] + """ + end + + @doc """ + Returns the path to the executable. + + The executable may not be available if it was not yet installed. + """ + def bin_path do + name = "pandoc-#{target()}" + + Application.get_env(:pandoc, :path) || + if Code.ensure_loaded?(Mix.Project) do + relative_build_dir = Mix.Project.build_path() |> Path.dirname() |> Path.relative_to_cwd() + project_dir = Path.dirname(Mix.Project.project_file()) + Path.join([project_dir, relative_build_dir, name]) + else + Path.expand("_build/#{name}") + end + end + + @doc """ + Returns the version of the pandoc executable. + + Returns `{:ok, version_string}` on success or `:error` when the executable + is not available. + """ + def bin_version do + path = bin_path() + + with true <- File.exists?(path), + {out, 0} <- System.cmd(path, ["--version"]), + [vsn] <- Regex.run(~r/#{Path.basename(path)} ([^\s]+)/, out, capture: :all_but_first) do + {:ok, vsn} + else + _ -> :error + end + end + + @doc """ + Starts a file system watcher that runs the given command with `args` when a + file event is received for a file that matches the given pattern. + + The given args will be appended to the configured args. The task output will + be streamed directly to stdio. + """ + def watch(profile, extra_args \\ [], pattern \\ ~r/\.md$/) when is_atom(profile) do + config = config_for!(profile) + opts = [dirs: [config[:cd] || File.cwd!()]] ref = __MODULE__.Supervisor |> Supervisor.start_child( - Supervisor.child_spec({Pandoc.Watcher, [profile, dirs: dirs]}, + Supervisor.child_spec({Pandoc.Watcher, [profile, opts, pattern, extra_args]}, restart: :transient, id: __MODULE__.Watcher ) @@ -27,9 +140,24 @@ defmodule Pandoc do end end - def run(profile, path) do - config = Application.get_env(:pandoc, profile) - args = config[:args] || [] + @doc """ + Runs the given command with `args`. + + The given args will be appended to the configured args. The task output will + be streamed directly to stdio. It returns the status of the underlying call. + """ + def run(profile, extra_args) when is_atom(profile) and is_list(extra_args) do + config = config_for!(profile) + + args = + case config[:args] do + args_fn when is_function(args_fn) -> args_fn.(extra_args) + args -> args || [] + end + + if args == [] and extra_args == [] do + raise "no arguments passed to pandoc" + end opts = [ cd: config[:cd] || File.cwd!(), @@ -37,17 +165,215 @@ defmodule Pandoc do stderr_to_stdout: true ] - new_filename = - path |> Path.basename() |> String.replace_suffix(".md", ".html") |> String.slice(11..-1//1) + {parsed_args, _, _} = OptionParser.parse(args, switches: [output: :string]) + {_, input_files, _} = OptionParser.parse(extra_args, switches: []) + + if parsed_args[:output] && + not File.cd!(opts[:cd], fn -> + input_files |> Enum.map(&File.exists?(&1)) |> Enum.all?() + end) do + parsed_args[:output] |> Path.expand(opts[:cd]) |> File.rm!() + else + bin_path() |> System.cmd(args ++ extra_args, opts) |> elem(1) + end + end + + defp start_unique_install_worker() do + ref = + __MODULE__.Supervisor + |> Supervisor.start_child( + Supervisor.child_spec({Task, &install/0}, restart: :transient, id: __MODULE__.Installer) + ) + |> case do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid + end + |> Process.monitor() + + receive do + {:DOWN, ^ref, _, _, _} -> :ok + end + end + + @doc """ + Installs, if not available, and then runs `pandoc`. + + This task may be invoked concurrently and it will avoid concurrent installs. + + Returns the same as `run/2`. + """ + def install_and_run(profile, args) do + File.exists?(bin_path()) || start_unique_install_worker() + + run(profile, args) + end + + @doc """ + The default URL to install Pandoc from. + """ + def default_base_url do + "https://github.com/jgm/pandoc/releases/download/$version/pandoc-$version-$target.tar.gz" + end + + @doc """ + Installs pandoc with `configured_version/0`. + + If invoked concurrently, this task will perform concurrent installs. + """ + def install(base_url \\ default_base_url()) do + version = configured_version() + tmp_opts = if System.get_env("MIX_XDG"), do: %{os: :linux}, else: %{} + + tmp_dir = + freshdir_p(:filename.basedir(:user_cache, "phx-pandoc", tmp_opts)) || + freshdir_p(Path.join(System.tmp_dir!(), "phx-pandoc")) || + raise "could not install pandoc. Set MIX_XGD=1 and then set XDG_CACHE_HOME to the path you want to use as cache" - new_path = args |> List.last() |> Path.join(new_filename) - out_path = Path.join(opts[:cd], new_path) |> Path.expand() + url = get_url(base_url) + tar = fetch_body!(url) - if File.exists?(path) do - args = List.replace_at(args, -1, out_path) - "pandoc" |> System.cmd(args ++ [path], opts) |> elem(1) + case :erl_tar.extract({:binary, tar}, [:compressed, cwd: to_charlist(tmp_dir)]) do + :ok -> :ok + other -> raise "couldn't unpack archive: #{inspect(other)}" + end + + bin_path = bin_path() + File.mkdir_p!(Path.dirname(bin_path)) + [tmp_dir, "pandoc-" <> version, "bin", "pandoc"] |> Path.join() |> File.cp!(bin_path) + end + + defp freshdir_p(path) do + with {:ok, _} <- File.rm_rf(path), + :ok <- File.mkdir_p(path) do + path else - File.rm(out_path) + _ -> nil end end + + defp fetch_body!(url, retry \\ true) do + scheme = URI.parse(url).scheme + url = String.to_charlist(url) + Logger.debug("Downloading pandoc from #{url}") + + {:ok, _} = Application.ensure_all_started(:inets) + {:ok, _} = Application.ensure_all_started(:ssl) + + if proxy = proxy_for_scheme(scheme) do + %{host: host, port: port} = URI.parse(proxy) + Logger.debug("Using #{String.upcase(scheme)}_PROXY: #{proxy}") + set_option = if "https" == scheme, do: :https_proxy, else: :proxy + :httpc.set_options([{set_option, {{String.to_charlist(host), port}, []}}]) + end + + # https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/inets + cacertfile = cacertfile() |> String.to_charlist() + + http_options = + [ + ssl: [ + verify: :verify_peer, + cacertfile: cacertfile, + depth: 2, + customize_hostname_check: [ + match_fun: :public_key.pkix_verify_hostname_match_fun(:https) + ], + versions: protocol_versions() + ] + ] + |> maybe_add_proxy_auth(scheme) + + options = [body_format: :binary] + + case {retry, :httpc.request(:get, {url, []}, http_options, options)} do + {_, {:ok, {{_, 200, _}, _headers, body}}} -> + body + + {true, {:error, {:failed_connect, [{:to_address, _}, {inet, _, reason}]}}} + when inet in [:inet, :inet6] and + reason in [:ehostunreach, :enetunreach, :eprotonosupport, :nxdomain] -> + :httpc.set_options(ipfamily: fallback(inet)) + fetch_body!(url, false) + + other -> + raise """ + Couldn't fetch #{url}: #{inspect(other)} + + This typically means we cannot reach the source or you are behind a proxy. + You can try again later and, if that does not work, you might: + + 1. If behind a proxy, ensure your proxy is configured and that + your certificates are set via the cacerts_path configuration + + 2. Manually download the executable from the URL above and + place it inside "_build/pandoc-#{target()}" + """ + end + end + + defp fallback(:inet), do: :inet6 + defp fallback(:inet6), do: :inet + + defp proxy_for_scheme("http") do + System.get_env("HTTP_PROXY") || System.get_env("http_proxy") + end + + defp proxy_for_scheme("https") do + System.get_env("HTTPS_PROXY") || System.get_env("https_proxy") + end + + defp maybe_add_proxy_auth(http_options, scheme) do + case proxy_auth(scheme) do + nil -> http_options + auth -> [{:proxy_auth, auth} | http_options] + end + end + + defp proxy_auth(scheme) do + with proxy when is_binary(proxy) <- proxy_for_scheme(scheme), + %{userinfo: userinfo} when is_binary(userinfo) <- URI.parse(proxy), + [username, password] <- String.split(userinfo, ":") do + {String.to_charlist(username), String.to_charlist(password)} + else + _ -> nil + end + end + + defp cacertfile() do + Application.get_env(:pandoc, :cacerts_path) || CAStore.file_path() + end + + defp protocol_versions do + if otp_version() < 25, do: [:"tlsv1.2"], else: [:"tlsv1.2", :"tlsv1.3"] + end + + defp otp_version do + :erlang.system_info(:otp_release) |> List.to_integer() + end + + # Available targets: https://github.com/jgm/pandoc/releases + # We support only linux-amd64, for now. + defp target do + case :os.type() do + # Assuming it's an x86 CPU + {:win32, _} -> + raise "pandoc does not currently support OS family: Windows" + + {:unix, osname} -> + arch_str = :erlang.system_info(:system_architecture) + [arch | _] = arch_str |> List.to_string() |> String.split("-") + + case arch do + "amd64" -> "#{osname}-amd64" + "x86_64" -> "#{osname}-amd64" + _ -> raise "pandoc does not currently support architecture: #{arch_str}" + end + end + end + + defp get_url(base_url) do + base_url + |> String.replace("$version", configured_version()) + |> String.replace("$target", target()) + end end diff --git a/lib/pandoc/application.ex b/lib/pandoc/application.ex index e30e2fa..04344da 100644 --- a/lib/pandoc/application.ex +++ b/lib/pandoc/application.ex @@ -5,16 +5,37 @@ defmodule Pandoc.Application do use Application - @impl true - def start(_type, _args) do - children = [ - # Starts a worker by calling: Pandoc.Worker.start_link(arg) - # {Pandoc.Worker, arg} - ] - - # See https://hexdocs.pm/elixir/Supervisor.html - # for other strategies and supported options - opts = [strategy: :one_for_one, name: Pandoc.Supervisor] - Supervisor.start_link(children, opts) + require Logger + import Pandoc, only: [latest_version: 0, configured_version: 0, bin_version: 0] + + @doc false + def start(_, _) do + if Application.get_env(:pandoc, :version_check, true) do + unless Application.get_env(:pandoc, :version) do + Logger.warning(""" + pandoc version is not configured. Please set it in your config files: + + config :pandoc, :version, "#{latest_version()}" + """) + end + + configured_version = configured_version() + + case bin_version() do + {:ok, ^configured_version} -> + :ok + + {:ok, version} -> + Logger.warning(""" + Outdated pandoc version. Expected #{configured_version}, got #{version}. \ + Please run `mix pandoc.install` or update the version in your config files.\ + """) + + :error -> + :ok + end + end + + Supervisor.start_link([], strategy: :one_for_one, name: Pandoc.Supervisor) end end diff --git a/lib/pandoc/watcher.ex b/lib/pandoc/watcher.ex index a174e46..9ff2620 100644 --- a/lib/pandoc/watcher.ex +++ b/lib/pandoc/watcher.ex @@ -1,22 +1,29 @@ defmodule Pandoc.Watcher do - use GenServer + @moduledoc false - @ext ".md" + use GenServer def start_link(args) do GenServer.start_link(__MODULE__, args) end - def init([profile | args]) do - {:ok, watcher_pid} = FileSystem.start_link(args) + # Callbacks + + @impl true + def init([profile, options, pattern, extra_args]) do + {:ok, watcher_pid} = FileSystem.start_link(options) FileSystem.subscribe(watcher_pid) - {:ok, %{watcher_pid: watcher_pid, profile: profile}} + {:ok, %{watcher_pid: watcher_pid, profile: profile, pattern: pattern, extra_args: extra_args}} end + @impl true def handle_info({:file_event, watcher_pid, {path, events}}, %{watcher_pid: watcher_pid} = state) do - case {Path.extname(path), :closed in events or :deleted in events} do - {@ext, true} -> Pandoc.run(state[:profile], path) - _ -> nil + case {String.match?(path, state[:pattern]), :closed in events or :deleted in events} do + {true, true} -> + Pandoc.install_and_run(state[:profile], [Path.basename(path) | state[:extra_args]]) + + _ -> + nil end {:noreply, state} @@ -1,7 +1,7 @@ defmodule Pandoc.MixProject do use Mix.Project - @version "0.2.0" + @version "0.3.0" @source_url "https://webdevcat.me/git/pandoc/" def project do @@ -10,22 +10,28 @@ defmodule Pandoc.MixProject do version: @version, elixir: "~> 1.14", deps: deps(), - description: "File-watcher and Mix task to convert Markdown files to HTML", + description: "File system watcher and Mix tasks for installing and invoking pandoc", package: [ links: %{ "pandoc" => "https://pandoc.org/" }, licenses: ["MIT"] ], - source_url: @source_url + source_url: @source_url, + source_url_pattern: "#{@source_url}tree/%{path}#n%{line}", + docs: [ + main: "Pandoc" + ], + aliases: [test: ["pandoc.install --if-missing", "test"]] ] end # Run "mix help compile.app" to learn about applications. def application do [ - extra_applications: [:logger], - mod: {Pandoc.Application, []} + extra_applications: [:logger, inets: :optional, ssl: :optional], + mod: {Pandoc.Application, []}, + env: [default: []] ] end @@ -33,6 +39,7 @@ defmodule Pandoc.MixProject do defp deps do [ {:file_system, "~> 1.0"}, + {:castore, ">= 0.0.0"}, {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} ] end @@ -1,4 +1,5 @@ %{ + "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"}, "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, diff --git a/test/pandoc_test.exs b/test/pandoc_test.exs index 1a4c067..170a77c 100644 --- a/test/pandoc_test.exs +++ b/test/pandoc_test.exs @@ -1,8 +1,111 @@ defmodule PandocTest do - use ExUnit.Case - doctest Pandoc + use ExUnit.Case, async: true - test "greets the world" do - assert Pandoc.hello() == :world + alias Pandoc.Watcher + import ExUnit.CaptureIO + + @version Pandoc.latest_version() + + test "run on default" do + assert capture_io(fn -> + assert Pandoc.run(:default, ["--version"]) == 0 + end) =~ @version + end + + test "run on profile" do + assert capture_io(fn -> + assert Pandoc.run(:another, []) == 0 + end) =~ @version + end + + test "updates on install" do + Application.put_env(:pandoc, :version, "3.6") + + Mix.Task.rerun("pandoc.install", ["--if-missing"]) + + assert capture_io(fn -> + assert Pandoc.run(:default, ["--version"]) == 0 + end) =~ "3.6" + + Application.delete_env(:pandoc, :version) + + Mix.Task.rerun("pandoc.install", ["--if-missing"]) + + assert capture_io(fn -> + assert Pandoc.run(:default, ["--version"]) == 0 + end) =~ @version + after + Application.delete_env(:pandoc, :version) end + + test "install and run multiple concurrently" do + bin_path = Pandoc.bin_path() + + assert :ok = File.exists?(bin_path) && File.rm!(bin_path) + + results = + [:extra1, :extra2, :extra3] + |> Enum.map(fn profile -> + Application.put_env(:pandoc, profile, args: ["--version"]) + + Task.async(fn -> + capture_io(fn -> + ret_code = Pandoc.install_and_run(profile, []) + # Let the first finished task set the binary file to read and execute only, + # so that the others will fail if they try to overwrite it. + File.chmod!(bin_path, 0o500) + ret_code == 0 + end) + end) + end) + |> Task.await_many(:infinity) + + File.chmod!(bin_path, 0o700) + assert Enum.all?(results) + end + + test "installs with custom URL" do + assert :ok = + Mix.Task.rerun("pandoc.install", [ + "https://github.com/jgm/pandoc/releases/download/$version/pandoc-$version-$target.tar.gz" + ]) + end + + test "starts watching and writes to stdio" do + in_tmp("documents", fn -> + task = Task.async(fn -> Pandoc.watch(:default) end) + %{pid: pid, ref: ref} = task + :timer.sleep(200) + + {_, watcher_pid, _, _} = + Pandoc.Supervisor + |> Supervisor.which_children() + |> Enum.find(fn + {Watcher, _, _, _} -> true + _ -> false + end) + + assert capture_io(fn -> + # Redirect watcher output to current process so it can be captured + Process.group_leader(watcher_pid, Process.group_leader()) + File.write!("hello.md", "# hello") + Task.yield(task, 200) + end) =~ ~s{<h1 id="hello">hello</h1>\n} + + assert :ok = Supervisor.terminate_child(Pandoc.Supervisor, Watcher) + assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 1000 + end) + after + File.rm_rf!(tmp_path()) + end + + defp in_tmp(which, function) do + path = tmp_path(which) + File.rm_rf!(path) + File.mkdir_p!(path) + File.cd!(path, function) + end + + defp tmp_path, do: Path.expand("../tmp", __DIR__) + defp tmp_path(extension), do: Path.join(tmp_path(), extension) end |