defmodule Mix.Tasks.Docs do use Mix.Task @shortdoc "Generate documentation for the project" @requirements ["compile"] @moduledoc ~S""" Uses ExDoc to generate a static web page from the project documentation. ## Command line options * `--canonical`, `-n` - Indicate the preferred URL with `rel="canonical"` link element, defaults to no canonical path * `--formatter`, `-f` - Which formatters to use, `html`, `epub`, or `markdown`. This option can be given more than once. By default, `html`, `epub`, and `markdown` are generated. * `--language` - Specifies the language to annotate the EPUB output in valid [BCP 47](https://tools.ietf.org/html/bcp47) * `--open` - open browser window pointed to the documentation * `--output`, `-o` - Output directory for the generated docs, default: `"doc"` * `--proglang` - Chooses the main programming language: `elixir` or `erlang` * `--warnings-as-errors` - Exits with non-zero exit code if any warnings are found The command line options have higher precedence than the options specified in your `mix.exs` file below. ## Configuration ExDoc will automatically pull in information from your project, like the application and version. However, you may want to set `:name`, `:source_url` and `:homepage_url` to have a nicer output from ExDoc, for example: def project do [ app: :my_app, version: "0.1.0-dev", deps: deps(), # Docs name: "My App", source_url: "https://github.com/USER/PROJECT", homepage_url: "http://YOUR_PROJECT_HOMEPAGE", docs: [ main: "MyApp", # The main page in the docs favicon: "path/to/favicon.png", logo: "path/to/logo.png", extras: ["README.md"] ] ] end ExDoc also allows configuration specific to the documentation to be set. The following options should be put under the `:docs` key in your project's main configuration. The `:docs` options should be a keyword list or a function returning a keyword list that will be lazily executed. See all supported options in `ExDoc.generate/4`. ## Umbrella project ExDoc can be used in an umbrella project and generates a single documentation for all child apps. You can use the `:ignore_apps` configuration to exclude certain projects in the umbrella from documentation. Generating documentation per each child app can be achieved by running: mix cmd mix docs See `mix help cmd` for more information. """ @switches [ canonical: :string, formatter: :keep, language: :string, open: :boolean, output: :string, proglang: :string, warnings_as_errors: :boolean ] @aliases [ f: :formatter, n: :canonical, o: :output ] @doc false def run(args, config \\ Mix.Project.config(), generator \\ &ExDoc.generate/4) do {:ok, _} = Application.ensure_all_started(:ex_doc) unless Code.ensure_loaded?(ExDoc.Formatter.Config) do Mix.raise( "Could not load ExDoc configuration. Please make sure you are running the " <> "docs task in the same Mix environment it is listed in your deps" ) end {cli_opts, args, _} = OptionParser.parse(args, aliases: @aliases, switches: @switches) if args != [] do Mix.raise("Extraneous arguments on the command line") end project = to_string( config[:name] || config[:app] || Mix.raise("expected :name or :app to be found in the project definition in mix.exs") ) version = config[:version] || "dev" cli_opts = Keyword.update(cli_opts, :proglang, :elixir, fn proglang -> if proglang not in ~w(erlang elixir) do Mix.raise("--proglang must be elixir or erlang") end String.to_atom(proglang) end) options = config |> get_docs_opts() |> Keyword.merge(cli_opts) # accepted at root level config |> normalize_source_url(config) # accepted at root level config |> normalize_homepage_url(config) # accepted at root level config |> normalize_description(config) |> normalize_apps(config) |> normalize_main() |> normalize_deps() |> normalize_formatters() |> put_package(config) source_beams = source_beams(options, config) Code.prepend_path(source_beams) for path <- Keyword.get_values(options, :paths), path <- Path.wildcard(path) do Code.prepend_path(path) end Mix.shell().info("Generating docs...") generated_docs = generator.(project, version, source_beams, options) Enum.each(generated_docs, fn %{entrypoint: entrypoint, formatter: formatter} -> extension = formatter_module_to_extension(formatter) Mix.shell().info([:green, "View #{extension} docs at #{inspect(entrypoint)}"]) end) open? = Keyword.get(cli_opts, :open, false) with [%{entrypoint: entrypoint} | _] when open? <- generated_docs do browser_open(entrypoint) end warned = Enum.filter(generated_docs, & &1.warned?) if options[:warnings_as_errors] == true and warned != [] do formatters = Enum.map(warned, &formatter_module_to_extension(&1.formatter)) format_message = case formatters do [formatter] -> "#{formatter} format" _ -> "#{Enum.join(formatters, ", ")} formats" end message = "Documents have been generated, but generation for #{format_message} failed " <> "due to warnings while using the --warnings-as-errors option" message_formatted = IO.ANSI.format([:red, message, :reset]) IO.puts(:stderr, message_formatted) exit({:shutdown, 1}) else Enum.map(generated_docs, & &1.entrypoint) end end defp formatter_module_to_extension(module) do module |> Module.split() |> List.last() |> String.downcase() end defp normalize_formatters(options) do formatters = case Keyword.get_values(options, :formatter) do [] -> options[:formatters] || ["html", "markdown", "epub"] values -> values end options |> Keyword.delete(:formatter) |> Keyword.put(:formatters, formatters) end defp get_docs_opts(config) do docs = config[:docs] cond do is_function(docs, 0) -> docs.() is_nil(docs) -> [] true -> docs end end defp normalize_source_url(options, config) do if source_url = config[:source_url] do Keyword.put(options, :source_url, source_url) else options end end defp normalize_homepage_url(options, config) do if homepage_url = config[:homepage_url] do Keyword.put(options, :homepage_url, homepage_url) else options end end defp normalize_description(options, config) do if description = config[:description] do Keyword.put(options, :description, description) else options end end defp source_beams(options, config) do if Mix.Project.umbrella?(config) do umbrella_compile_paths(Keyword.get(options, :ignore_apps, [])) else [Mix.Project.compile_path()] end end defp umbrella_compile_paths(ignored_apps) do build = Mix.Project.build_path() for {app, _} <- Mix.Project.apps_paths(), app not in ignored_apps do Path.join([build, "lib", Atom.to_string(app), "ebin"]) end end defp normalize_apps(options, config) do if Mix.Project.umbrella?(config) do ignore = Keyword.get(options, :ignore_apps, []) apps = for {app, _} <- Mix.Project.apps_paths(), app not in ignore do app end Keyword.put(options, :apps, Enum.sort(apps)) else Keyword.put(options, :apps, List.wrap(config[:app])) end end defp normalize_main(options) do main = options[:main] cond do is_nil(main) -> Keyword.delete(options, :main) is_atom(main) -> Keyword.put(options, :main, inspect(main)) is_binary(main) -> options end end defp normalize_deps(options) do user_deps = Keyword.get(options, :deps, []) deps = for {app, doc} <- Keyword.merge(get_deps(), user_deps), lib_dir = :code.lib_dir(app), is_list(lib_dir), do: {app, doc} Keyword.put(options, :deps, deps) end defp get_deps do for {key, _} <- Mix.Project.deps_paths(), _ = Application.load(key), vsn = Application.spec(key, :vsn) do {key, "https://hexdocs.pm/#{key}/#{vsn}/"} end end defp put_package(options, config) do if package = config[:package] do Keyword.put(options, :package, package[:name] || config[:app]) else options end end defp browser_open(path) do {cmd, args, options} = case :os.type() do {:win32, _} -> dirname = Path.dirname(path) basename = Path.basename(path) {"cmd", ["/c", "start", basename], [cd: dirname]} {:unix, :darwin} -> {"open", [path], []} {:unix, _} -> {"xdg-open", [path], []} end System.cmd(cmd, args, options) end end