<!DOCTYPE html>
<html lang="en" style="scrollbar-gutter:stable;">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="GFAkeGUiOAMBEw0ULiJ6UGU6GSwzWzlyTec-QMnwUZIADF44QiQcF3zG">
    <title data-suffix=" · Catalin Mititiuc">
WebDevCat.me
     · Catalin Mititiuc</title>
    <link rel="stylesheet" id="font-bitter-css" href="//fonts.googleapis.com/css?family=Bitter:400,700" type="text/css" media="screen">
    <link phx-track-static rel="stylesheet" href="/assets/app-131585bb1e255488c3d2558ee5c81330.css?vsn=d">

      <link phx-track-static rel="stylesheet" href="/assets/cgit-313ed4244ed6cc8d5b67d6fbb4ab18c8.css?vsn=d">
      <style>
        article > * { max-width: unset; }
        div#cgit table.list {
          table-layout: auto;
          width: 100%;
          display: table;
        }
        div#cgit div.content {
          overflow: scroll;
        }
        div#cgit table.tabs {
          table-layout: auto;
          width: 100%;
          display: table;
        }
        div#cgit table.blob {
          table-layout: auto;
          width: 100%;
          display: table;
        }
        div#cgit table.tabs {
          table-layout: auto;
          width: 100%;
          display: table;
        }
        td.linenumbers { width: 1px; }
        td.lines { max-width: 1px; overflow: hidden; }

        td.linenumbers pre, td.lines pre {
          line-height: 1.25em;
        }

        pre { overflow-x: scroll; overflow-y: hidden; }
        code { font-size: unset; }
      </style>

    <script defer phx-track-static type="text/javascript" src="/assets/app-7bb68f31e771b77e6d1026a2eca15d48.js?vsn=d">
    </script>
  </head>
  <body class="bg-white">
    <header>
      <div style="display: inline-block;">
        <h1><a href="/">Web Dev Solutions</a></h1>
        <h3 style="text-align: left">Catalin Mititiuc</h3>
      </div>
    </header>

    <main>
<article>
  From b8d8b3dbd88ab42fc8f050af0d18fc4dd66d7ffe Mon Sep 17 00:00:00 2001
From: Catalin Mititiuc <webdevcat@proton.me>
Date: Thu, 9 Jan 2025 11:25:52 -0800
Subject: Handle installing Pandoc

---
 LICENCE.md                      |  32 ++++
 README.md                       | 187 ++++++++-------------
 config/config.exs               |   7 +
 lib/mix/tasks/pandoc.ex         |  62 ++++---
 lib/mix/tasks/pandoc.install.ex |  70 ++++++++
 lib/pandoc.ex                   | 360 ++++++++++++++++++++++++++++++++++++++--
 lib/pandoc/application.ex       |  43 +++--
 lib/pandoc/watcher.ex           |  23 ++-
 mix.exs                         |  17 +-
 mix.lock                        |   1 +
 test/pandoc_test.exs            | 111 ++++++++++++-
 11 files changed, 725 insertions(+), 188 deletions(-)
 create mode 100644 LICENCE.md
 create mode 100644 config/config.exs
 create mode 100644 lib/mix/tasks/pandoc.install.ex

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.
+
diff --git a/README.md b/README.md
index 9a00282..3255f4b 100644
--- a/README.md
+++ b/README.md
@@ -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}
diff --git a/mix.exs b/mix.exs
index 7a0bb94..ab539d3 100644
--- a/mix.exs
+++ b/mix.exs
@@ -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
diff --git a/mix.lock b/mix.lock
index d2090ab..26e0289 100644
--- a/mix.lock
+++ b/mix.lock
@@ -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
-- 
cgit v1.2.3


</article>
    </main>

    <footer>
      <p>100% Human Made, No AI Used</p>
      <p>stasis 0.2.12</p>
    </footer>
  </body>
</html>