defmodule Pipeline do @moduledoc """ Post-export markdown transformation pipeline. Applies a list of transform modules sequentially over every .md file in a content directory. Each transform module must implement: apply(content :: String.t(), opts :: map()) :: String.t() Transforms are applied in the order given. A file is rewritten only when at least one transform mutates its content (checked via equality). ## Usage opts = %{ zotero_url: "http://localhost:23119", bibtex_file: System.get_env("BIBTEX_FILE"), citation_mode: :warn # :silent | :warn | :strict } Pipeline.run(content_dir, [Pipeline.Transforms.Citations], opts) """ require Logger @type transform :: module() @type opts :: map() @doc """ Run all transforms over every .md file under `content_dir`. Returns `{:ok, stats}` where stats maps each transform to a count of files it changed. """ @spec run(String.t(), [transform()], opts()) :: {:ok, map()} def run(content_dir, transforms, opts \\ %{}) do md_files = content_dir |> Path.join("**/*.md") |> Path.wildcard() if md_files == [] do Logger.warning("Pipeline: no .md files found in #{content_dir}") {:ok, %{}} else Logger.info("Pipeline: processing #{length(md_files)} markdown files with #{length(transforms)} transform(s)") # Initialise transforms (allows them to perform setup such as loading a .bib file). # Each transform module must implement the Pipeline.Transform behaviour. initialized = Enum.map(transforms, fn mod -> state = mod.init(opts) {mod, state} end) stats = Enum.reduce(md_files, %{}, fn path, acc -> original = File.read!(path) {transformed, file_stats} = Enum.reduce(initialized, {original, %{}}, fn {mod, state}, {content, fstats} -> result = mod.apply(content, state, opts) changed = result != content {result, Map.update(fstats, mod, (if changed, do: 1, else: 0), &(&1 + (if changed, do: 1, else: 0)))} end) if transformed != original do File.write!(path, transformed) Logger.debug("Pipeline: updated #{Path.relative_to_cwd(path)}") end Map.merge(acc, file_stats, fn _k, a, b -> a + b end) end) Enum.each(initialized, fn {mod, state} -> # teardown/1 is optional in the behaviour if function_exported?(mod, :teardown, 1) do mod.teardown(state) end end) {:ok, stats} end end end