From 6265dd4b00dc8fc438e5cb15f2c9b7541fe6ca5a Mon Sep 17 00:00:00 2001
From: Eksperimental
Date: Tue, 26 Nov 2024 02:10:08 -0500
Subject: [PATCH 01/12] Port Markdown formatter from HTML
---
lib/ex_doc/formatter/markdown.ex | 507 ++++++++++++++++++
lib/ex_doc/formatter/markdown/templates.ex | 356 ++++++++++++
.../api_reference_entry_template.eex | 11 +
.../templates/api_reference_template.eex | 21 +
.../markdown/templates/detail_template.eex | 36 ++
.../markdown/templates/extra_template.eex | 60 +++
.../markdown/templates/footer_template.eex | 44 ++
.../markdown/templates/head_template.eex | 43 ++
.../markdown/templates/module_template.eex | 57 ++
.../markdown/templates/not_found_template.eex | 15 +
.../markdown/templates/redirect_template.eex | 10 +
.../markdown/templates/search_template.eex | 12 +
.../markdown/templates/sidebar_template.eex | 92 ++++
.../markdown/templates/summary_template.eex | 18 +
lib/ex_doc/markdown/assets.ex | 14 +
15 files changed, 1296 insertions(+)
create mode 100644 lib/ex_doc/formatter/markdown.ex
create mode 100644 lib/ex_doc/formatter/markdown/templates.ex
create mode 100644 lib/ex_doc/formatter/markdown/templates/api_reference_entry_template.eex
create mode 100644 lib/ex_doc/formatter/markdown/templates/api_reference_template.eex
create mode 100644 lib/ex_doc/formatter/markdown/templates/detail_template.eex
create mode 100644 lib/ex_doc/formatter/markdown/templates/extra_template.eex
create mode 100644 lib/ex_doc/formatter/markdown/templates/footer_template.eex
create mode 100644 lib/ex_doc/formatter/markdown/templates/head_template.eex
create mode 100644 lib/ex_doc/formatter/markdown/templates/module_template.eex
create mode 100644 lib/ex_doc/formatter/markdown/templates/not_found_template.eex
create mode 100644 lib/ex_doc/formatter/markdown/templates/redirect_template.eex
create mode 100644 lib/ex_doc/formatter/markdown/templates/search_template.eex
create mode 100644 lib/ex_doc/formatter/markdown/templates/sidebar_template.eex
create mode 100644 lib/ex_doc/formatter/markdown/templates/summary_template.eex
create mode 100644 lib/ex_doc/markdown/assets.ex
diff --git a/lib/ex_doc/formatter/markdown.ex b/lib/ex_doc/formatter/markdown.ex
new file mode 100644
index 000000000..be566de92
--- /dev/null
+++ b/lib/ex_doc/formatter/markdown.ex
@@ -0,0 +1,507 @@
+defmodule ExDoc.Formatter.Markdown do
+ @moduledoc false
+
+ alias __MODULE__.Assets
+ alias __MODULE__.Templates
+ alias ExDoc.GroupMatcher
+ alias ExDoc.Markdown
+ alias ExDoc.Utils
+
+ # @main "api-reference"
+ @assets_dir "assets"
+
+ @doc """
+ Generates Markdown documentation for the given modules.
+ """
+ @spec run([ExDoc.ModuleNode.t()], [ExDoc.ModuleNode.t()], ExDoc.Config.t()) :: String.t()
+ def run(project_nodes, filtered_modules, config) when is_map(config) do
+ Utils.unset_warned()
+
+ config = %{config | output: Path.expand(config.output)}
+
+ build = Path.join(config.output, ".build")
+ output_setup(build, config)
+
+ project_nodes = render_all(project_nodes, filtered_modules, ".md", config, [])
+ extras = build_extras(config, ".md")
+
+ # Generate search early on without api reference in extras
+ static_files = generate_assets(".", default_assets(config), config)
+
+ # TODO: Move this categorization to the language
+ nodes_map = %{
+ modules: filter_list(:module, project_nodes),
+ tasks: filter_list(:task, project_nodes)
+ }
+
+ extras =
+ if config.api_reference do
+ [build_api_reference(nodes_map, config) | extras]
+ else
+ extras
+ end
+
+ all_files =
+ static_files ++
+ generate_extras(nodes_map, extras, config) ++
+ generate_logo(@assets_dir, config) ++
+ generate_list(nodes_map.modules, nodes_map, config) ++
+ generate_list(nodes_map.tasks, nodes_map, config)
+
+ generate_build(Enum.sort(all_files), build)
+ config.output |> Path.join("index.md") |> Path.relative_to_cwd()
+ end
+
+ @doc """
+ Autolinks and renders all docs.
+ """
+ def render_all(project_nodes, filtered_modules, ext, config, opts) do
+ base = [
+ apps: config.apps,
+ deps: config.deps,
+ ext: ext,
+ extras: extra_paths(config),
+ skip_undefined_reference_warnings_on: config.skip_undefined_reference_warnings_on,
+ skip_code_autolink_to: config.skip_code_autolink_to,
+ filtered_modules: filtered_modules
+ ]
+
+ project_nodes
+ |> Task.async_stream(
+ fn node ->
+ language = node.language
+
+ autolink_opts =
+ [
+ current_module: node.module,
+ file: node.moduledoc_file,
+ line: node.moduledoc_line,
+ module_id: node.id,
+ language: language
+ ] ++ base
+
+ docs =
+ for child_node <- node.docs do
+ id = id(node, child_node)
+
+ autolink_opts =
+ autolink_opts ++
+ [
+ id: id,
+ line: child_node.doc_line,
+ file: child_node.doc_file,
+ current_kfa: {:function, child_node.name, child_node.arity}
+ ]
+
+ specs = Enum.map(child_node.specs, &language.autolink_spec(&1, autolink_opts))
+ child_node = %{child_node | specs: specs}
+ render_doc(child_node, language, autolink_opts, opts)
+ end
+
+ typespecs =
+ for child_node <- node.typespecs do
+ id = id(node, child_node)
+
+ autolink_opts =
+ autolink_opts ++
+ [
+ id: id,
+ line: child_node.doc_line,
+ file: child_node.doc_file,
+ current_kfa: {child_node.type, child_node.name, child_node.arity}
+ ]
+
+ child_node = %{
+ child_node
+ | spec: language.autolink_spec(child_node.spec, autolink_opts)
+ }
+
+ render_doc(child_node, language, autolink_opts, opts)
+ end
+
+ %{
+ render_doc(node, language, [{:id, node.id} | autolink_opts], opts)
+ | docs: docs,
+ typespecs: typespecs
+ }
+ end,
+ timeout: :infinity
+ )
+ |> Enum.map(&elem(&1, 1))
+ end
+
+ defp render_doc(%{doc: nil} = node, _language, _autolink_opts, _opts),
+ do: node
+
+ defp render_doc(%{doc: doc} = node, language, autolink_opts, opts) do
+ rendered = autolink_and_render(doc, language, autolink_opts, opts)
+ %{node | rendered_doc: rendered}
+ end
+
+ defp id(%{id: mod_id}, %{id: "c:" <> id}) do
+ "c:" <> mod_id <> "." <> id
+ end
+
+ defp id(%{id: mod_id}, %{id: "t:" <> id}) do
+ "t:" <> mod_id <> "." <> id
+ end
+
+ defp id(%{id: mod_id}, %{id: id}) do
+ mod_id <> "." <> id
+ end
+
+ defp autolink_and_render(doc, language, autolink_opts, opts) do
+ doc
+ |> language.autolink_doc(autolink_opts)
+ |> ExDoc.DocAST.to_string()
+ |> ExDoc.DocAST.highlight(language, opts)
+ end
+
+ defp output_setup(build, config) do
+ if File.exists?(build) do
+ build
+ |> File.read!()
+ |> String.split("\n", trim: true)
+ |> Enum.map(&Path.join(config.output, &1))
+ |> Enum.each(&File.rm/1)
+
+ File.rm(build)
+ else
+ File.rm_rf!(config.output)
+ File.mkdir_p!(config.output)
+ end
+ end
+
+ defp generate_build(files, build) do
+ entries = Enum.map(files, &[&1, "\n"])
+ File.write!(build, entries)
+ end
+
+ defp generate_extras(nodes_map, extras, config) do
+ generated_extras =
+ extras
+ |> with_prev_next()
+ |> Enum.map(fn {node, prev, next} ->
+ filename = "#{node.id}.md"
+ output = "#{config.output}/#{filename}"
+ config = set_canonical_url(config, filename)
+
+ refs = %{
+ prev: prev && %{path: "#{prev.id}.md", title: prev.title},
+ next: next && %{path: "#{next.id}.md", title: next.title}
+ }
+
+ extension = node.source_path && Path.extname(node.source_path)
+ markdown = Templates.extra_template(config, node, extra_type(extension), nodes_map, refs)
+
+ if File.regular?(output) do
+ Utils.warn("file #{Path.relative_to_cwd(output)} already exists", [])
+ end
+
+ File.write!(output, markdown)
+ filename
+ end)
+
+ generated_extras ++ copy_extras(config, extras)
+ end
+
+ defp extra_type(".cheatmd"), do: :cheatmd
+ defp extra_type(".livemd"), do: :livemd
+ defp extra_type(_), do: :extra
+
+ defp copy_extras(config, extras) do
+ for %{source_path: source_path, id: id} when source_path != nil <- extras,
+ ext = extension_name(source_path),
+ ext == ".livemd" do
+ output = "#{config.output}/#{id}#{ext}"
+
+ File.copy!(source_path, output)
+
+ output
+ end
+ end
+
+ defp with_prev_next([]), do: []
+
+ defp with_prev_next([head | tail]) do
+ Enum.zip([[head | tail], [nil, head | tail], tail ++ [nil]])
+ end
+
+ @doc """
+ Generate assets from configs with the given default assets.
+ """
+ def generate_assets(namespace, defaults, %{output: output, assets: assets}) do
+ namespaced_assets =
+ if is_map(assets) do
+ Enum.map(assets, fn {source, target} -> {source, Path.join(namespace, target)} end)
+ else
+ IO.warn("""
+ giving a binary to :assets is deprecated, please give a map from source to target instead:
+
+ #{inspect(assets: %{assets => "assets"})}
+ """)
+
+ [{assets, Path.join(namespace, "assets")}]
+ end
+
+ Enum.flat_map(defaults ++ namespaced_assets, fn {dir_or_files, relative_target_dir} ->
+ target_dir = Path.join(output, relative_target_dir)
+ File.mkdir_p!(target_dir)
+
+ cond do
+ is_list(dir_or_files) ->
+ Enum.map(dir_or_files, fn {name, content} ->
+ target = Path.join(target_dir, name)
+ File.write(target, content)
+ Path.relative_to(target, output)
+ end)
+
+ is_binary(dir_or_files) and File.dir?(dir_or_files) ->
+ dir_or_files
+ |> File.cp_r!(target_dir, dereference_symlinks: true)
+ |> Enum.map(&Path.relative_to(&1, output))
+
+ is_binary(dir_or_files) ->
+ []
+
+ true ->
+ raise ":assets must be a map of source directories to target directories"
+ end
+ end)
+ end
+
+ defp default_assets(config) do
+ [
+ {Assets.dist(config.proglang), "dist"}
+ ]
+ end
+
+ defp build_api_reference(nodes_map, config) do
+ api_reference = Templates.api_reference_template(nodes_map)
+
+ title_content =
+ ~s{API Reference #{config.project} v#{config.version}}
+
+ %{
+ content: api_reference,
+ group: nil,
+ id: "api-reference",
+ source_path: nil,
+ source_url: config.source_url,
+ title: "API Reference",
+ title_content: title_content
+ }
+ end
+
+ @doc """
+ Builds extra nodes by normalizing the config entries.
+ """
+ def build_extras(config, ext) do
+ groups = config.groups_for_extras
+
+ language =
+ case config.proglang do
+ :erlang -> ExDoc.Language.Erlang
+ _ -> ExDoc.Language.Elixir
+ end
+
+ source_url_pattern = config.source_url_pattern
+
+ autolink_opts = [
+ apps: config.apps,
+ deps: config.deps,
+ ext: ext,
+ extras: extra_paths(config),
+ language: language,
+ skip_undefined_reference_warnings_on: config.skip_undefined_reference_warnings_on,
+ skip_code_autolink_to: config.skip_code_autolink_to
+ ]
+
+ extras =
+ config.extras
+ |> Task.async_stream(
+ &build_extra(&1, groups, language, autolink_opts, source_url_pattern),
+ timeout: :infinity
+ )
+ |> Enum.map(&elem(&1, 1))
+
+ ids_count = Enum.reduce(extras, %{}, &Map.update(&2, &1.id, 1, fn c -> c + 1 end))
+
+ extras
+ |> Enum.map_reduce(1, fn extra, idx ->
+ if ids_count[extra.id] > 1, do: {disambiguate_id(extra, idx), idx + 1}, else: {extra, idx}
+ end)
+ |> elem(0)
+ |> Enum.sort_by(fn extra -> GroupMatcher.group_index(groups, extra.group) end)
+ end
+
+ defp disambiguate_id(extra, discriminator) do
+ Map.put(extra, :id, "#{extra.id}-#{discriminator}")
+ end
+
+ defp build_extra({input, input_options}, groups, language, autolink_opts, source_url_pattern) do
+ input = to_string(input)
+ id = input_options[:filename] || input |> filename_to_title() |> Utils.text_to_id()
+ source_file = input_options[:source] || input
+ opts = [file: source_file, line: 1]
+
+ {source, ast} =
+ case extension_name(input) do
+ extension when extension in ["", ".txt"] ->
+ source = File.read!(input)
+ ast = [{:pre, [], "\n" <> source, %{}}]
+ {source, ast}
+
+ extension when extension in [".md", ".livemd", ".cheatmd"] ->
+ source = File.read!(input)
+
+ ast =
+ source
+ |> Markdown.to_ast(opts)
+ |> sectionize(extension)
+
+ {source, ast}
+
+ _ ->
+ raise ArgumentError,
+ "file extension not recognized, allowed extension is either .cheatmd, .livemd, .md, .txt or no extension"
+ end
+
+ {title_ast, ast} =
+ case ExDoc.DocAST.extract_title(ast) do
+ {:ok, title_ast, ast} -> {title_ast, ast}
+ :error -> {nil, ast}
+ end
+
+ title_text = title_ast && ExDoc.DocAST.text_from_ast(title_ast)
+ title_markdown = title_ast && ExDoc.DocAST.to_string(title_ast)
+ content_markdown = autolink_and_render(ast, language, [file: input] ++ autolink_opts, opts)
+
+ group = GroupMatcher.match_extra(groups, input)
+ title = input_options[:title] || title_text || filename_to_title(input)
+
+ source_path = source_file |> Path.relative_to(File.cwd!()) |> String.replace_leading("./", "")
+ source_url = Utils.source_url_pattern(source_url_pattern, source_path, 1)
+
+ %{
+ source: source,
+ content: content_markdown,
+ group: group,
+ id: id,
+ source_path: source_path,
+ source_url: source_url,
+ title: title,
+ title_content: title_markdown || title
+ }
+ end
+
+ defp build_extra(input, groups, language, autolink_opts, source_url_pattern) do
+ build_extra({input, []}, groups, language, autolink_opts, source_url_pattern)
+ end
+
+ defp extension_name(input) do
+ input
+ |> Path.extname()
+ |> String.downcase()
+ end
+
+ defp sectionize(ast, ".cheatmd") do
+ ExDoc.DocAST.sectionize(ast, fn
+ {:h2, _, _, _} -> true
+ {:h3, _, _, _} -> true
+ _ -> false
+ end)
+ end
+
+ defp sectionize(ast, _), do: ast
+
+ defp filename_to_title(input) do
+ input |> Path.basename() |> Path.rootname()
+ end
+
+ @doc """
+ Generates the logo from config into the given directory.
+ """
+ def generate_logo(_dir, %{logo: nil}) do
+ []
+ end
+
+ def generate_logo(dir, %{output: output, logo: logo}) do
+ generate_image(output, dir, logo, "logo")
+ end
+
+ @doc """
+ Generates the cover from config into the given directory.
+ """
+ def generate_cover(_dir, %{cover: nil}) do
+ []
+ end
+
+ def generate_cover(dir, %{output: output, cover: cover}) do
+ generate_image(output, dir, cover, "cover")
+ end
+
+ defp generate_image(output, dir, image, name) do
+ extname =
+ image
+ |> Path.extname()
+ |> String.downcase()
+
+ if extname in ~w(.png .jpg .jpeg .svg) do
+ filename = Path.join(dir, "#{name}#{extname}")
+ target = Path.join(output, filename)
+ File.mkdir_p!(Path.dirname(target))
+ File.copy!(image, target)
+ [filename]
+ else
+ raise ArgumentError, "image format not recognized, allowed formats are: .png, .jpg, .svg"
+ end
+ end
+
+ def filter_list(:module, nodes) do
+ Enum.filter(nodes, &(&1.type != :task))
+ end
+
+ def filter_list(type, nodes) do
+ Enum.filter(nodes, &(&1.type == type))
+ end
+
+ defp generate_list(nodes, nodes_map, config) do
+ nodes
+ |> Task.async_stream(&generate_module_page(&1, nodes_map, config), timeout: :infinity)
+ |> Enum.map(&elem(&1, 1))
+ end
+
+ defp generate_module_page(module_node, nodes_map, config) do
+ filename = "#{module_node.id}.md"
+ config = set_canonical_url(config, filename)
+ content = Templates.module_page(module_node, nodes_map, config)
+ File.write!("#{config.output}/#{filename}", content)
+ filename
+ end
+
+ defp set_canonical_url(config, filename) do
+ if config.canonical do
+ canonical_url =
+ config.canonical
+ |> String.trim_trailing("/")
+ |> Kernel.<>("/" <> filename)
+
+ Map.put(config, :canonical, canonical_url)
+ else
+ config
+ end
+ end
+
+ defp extra_paths(config) do
+ Map.new(config.extras, fn
+ path when is_binary(path) ->
+ base = Path.basename(path)
+ {base, Utils.text_to_id(Path.rootname(base))}
+
+ {path, opts} ->
+ base = path |> to_string() |> Path.basename()
+ {base, opts[:filename] || Utils.text_to_id(Path.rootname(base))}
+ end)
+ end
+end
diff --git a/lib/ex_doc/formatter/markdown/templates.ex b/lib/ex_doc/formatter/markdown/templates.ex
new file mode 100644
index 000000000..085c59ac7
--- /dev/null
+++ b/lib/ex_doc/formatter/markdown/templates.ex
@@ -0,0 +1,356 @@
+defmodule ExDoc.Formatter.Markdown.Templates do
+ @moduledoc false
+ require EEx
+
+ import ExDoc.Utils,
+ only: [
+ h: 1,
+ before_closing_body_tag: 2,
+ before_closing_footer_tag: 2,
+ before_closing_head_tag: 2,
+ text_to_id: 1
+ ]
+
+ @doc """
+ Generate content from the module template for a given `node`
+ """
+ def module_page(module_node, nodes_map, config) do
+ summary = module_summary(module_node)
+ module_template(config, module_node, summary, nodes_map)
+ end
+
+ @doc """
+ Get the full specs from a function, already in HTML form.
+ """
+ def get_specs(%ExDoc.TypeNode{spec: spec}) do
+ [spec]
+ end
+
+ def get_specs(%ExDoc.FunctionNode{specs: specs}) when is_list(specs) do
+ presence(specs)
+ end
+
+ def get_specs(_node) do
+ nil
+ end
+
+ @doc """
+ Format the attribute type used to define the spec of the given `node`.
+ """
+ def format_spec_attribute(module, node) do
+ module.language.format_spec_attribute(node)
+ end
+
+ @doc """
+ Get defaults clauses.
+ """
+ def get_defaults(%{defaults: defaults}) do
+ defaults
+ end
+
+ def get_defaults(_) do
+ []
+ end
+
+ @doc """
+ Get the pretty name of a function node
+ """
+ def pretty_type(%{type: t}) do
+ Atom.to_string(t)
+ end
+
+ @doc """
+ Returns the HTML formatted title for the module page.
+ """
+ def module_type(%{type: :task}), do: ""
+ def module_type(%{type: :module}), do: ""
+ def module_type(%{type: type}), do: "#{type}"
+
+ @doc """
+ Gets the first paragraph of the documentation of a node. It strips
+ surrounding white-spaces and trailing `:`.
+
+ If `doc` is `nil`, it returns `nil`.
+ """
+ @spec synopsis(String.t()) :: String.t()
+ @spec synopsis(nil) :: nil
+ def synopsis(nil), do: nil
+
+ def synopsis(doc) when is_binary(doc) do
+ doc =
+ case :binary.split(doc, "
") do
+ [left, _] -> String.trim_trailing(left, ":") <> ""
+ [all] -> all
+ end
+
+ # Remove any anchors found in synopsis.
+ # Old Erlang docs placed anchors at the top of the documentation
+ # for links. Ideally they would have been removed but meanwhile
+ # it is simpler to guarantee they won't be duplicated in docs.
+ Regex.replace(~r|(<[^>]*) id="[^"]*"([^>]*>)|, doc, ~S"\1\2", [])
+ end
+
+ defp presence([]), do: nil
+ defp presence(other), do: other
+
+ defp enc(binary), do: URI.encode(binary)
+
+ @doc """
+ Create a JS object which holds all the items displayed in the sidebar area
+ """
+ def create_sidebar_items(nodes_map, extras) do
+ nodes =
+ nodes_map
+ |> Enum.map(&sidebar_module/1)
+ |> Map.new()
+ |> Map.put(:extras, sidebar_extras(extras))
+
+ ["sidebarNodes=" | ExDoc.Utils.to_json(nodes)]
+ end
+
+ defp sidebar_extras(extras) do
+ for extra <- extras do
+ %{id: id, title: title, group: group, content: content} = extra
+
+ %{
+ id: to_string(id),
+ title: to_string(title),
+ group: to_string(group),
+ headers: extract_headers(content)
+ }
+ end
+ end
+
+ defp sidebar_module({id, modules}) do
+ modules =
+ for module <- modules do
+ extra =
+ module
+ |> module_summary()
+ |> case do
+ [] -> []
+ entries -> [nodeGroups: Enum.map(entries, &sidebar_entries/1)]
+ end
+
+ sections = module_sections(module)
+
+ deprecated? = not is_nil(module.deprecated)
+
+ pairs =
+ for key <- [:id, :title, :nested_title, :nested_context],
+ value = Map.get(module, key),
+ do: {key, value}
+
+ pairs = [{:deprecated, deprecated?} | pairs]
+
+ Map.new([group: to_string(module.group)] ++ extra ++ pairs ++ sections)
+ end
+
+ {id, modules}
+ end
+
+ defp sidebar_entries({group, nodes}) do
+ nodes =
+ for node <- nodes do
+ id =
+ if "struct" in node.annotations do
+ node.signature
+ else
+ if node.name == nil do
+ "nil/#{node.arity}"
+ else
+ "#{node.name}/#{node.arity}"
+ end
+ end
+
+ deprecated? = not is_nil(node.deprecated)
+
+ %{id: id, title: node.signature, anchor: URI.encode(node.id), deprecated: deprecated?}
+ end
+
+ %{key: text_to_id(group), name: group, nodes: nodes}
+ end
+
+ defp module_sections(%ExDoc.ModuleNode{rendered_doc: nil}), do: [sections: []]
+
+ defp module_sections(module) do
+ {sections, _} =
+ module.rendered_doc
+ |> extract_headers()
+ |> Enum.map_reduce(%{}, fn header, acc ->
+ # TODO Duplicates some of the logic of link_headings/3
+ case Map.fetch(acc, header.id) do
+ {:ok, id} ->
+ {%{header | anchor: "module-#{header.anchor}-#{id}"}, Map.put(acc, header.id, id + 1)}
+
+ :error ->
+ {%{header | anchor: "module-#{header.anchor}"}, Map.put(acc, header.id, 1)}
+ end
+ end)
+
+ [sections: sections]
+ end
+
+ # TODO: split into sections in Formatter.HTML instead.
+ @h2_regex ~r/(.*?)<\/h2>/m
+ defp extract_headers(content) do
+ @h2_regex
+ |> Regex.scan(content, capture: :all_but_first)
+ |> List.flatten()
+ |> Enum.filter(&(&1 != ""))
+ |> Enum.map(&ExDoc.Utils.strip_tags/1)
+ |> Enum.map(&%{id: &1, anchor: URI.encode(text_to_id(&1))})
+ end
+
+ def module_summary(module_node) do
+ entries = docs_groups(module_node.docs_groups, module_node.docs ++ module_node.typespecs)
+
+ Enum.reject(entries, fn {_type, nodes} -> nodes == [] end)
+ end
+
+ defp docs_groups(groups, docs) do
+ for group <- groups, do: {group, Enum.filter(docs, &(&1.group == group))}
+ end
+
+ defp logo_path(%{logo: nil}), do: nil
+ defp logo_path(%{logo: logo}), do: "assets/logo#{Path.extname(logo)}"
+
+ defp sidebar_type(:exception), do: "modules"
+ defp sidebar_type(:module), do: "modules"
+ defp sidebar_type(:behaviour), do: "modules"
+ defp sidebar_type(:protocol), do: "modules"
+ defp sidebar_type(:task), do: "tasks"
+
+ defp sidebar_type(:search), do: "search"
+ defp sidebar_type(:cheatmd), do: "extras"
+ defp sidebar_type(:livemd), do: "extras"
+ defp sidebar_type(:extra), do: "extras"
+
+ def asset_rev(output, pattern) do
+ output = Path.expand(output)
+
+ output
+ |> Path.join(pattern)
+ |> Path.wildcard()
+ |> relative_asset(output, pattern)
+ end
+
+ defp relative_asset([], output, pattern),
+ do: raise("could not find matching #{output}/#{pattern}")
+
+ defp relative_asset([h | _], output, _pattern), do: Path.relative_to(h, output)
+
+ # TODO: Move link_headings and friends to html.ex or even to autolinking code,
+ # so content is built with it upfront instead of added at the template level.
+
+ @doc """
+ Add link headings for the given `content`.
+
+ IDs are prefixed with `prefix`.
+
+ We only link `h2` and `h3` headers. This is kept consistent in ExDoc.SearchData.
+ """
+ @heading_regex ~r/<(h[23]).*?>(.*?)<\/\1>/m
+ @spec link_headings(String.t() | nil, String.t()) :: String.t() | nil
+ def link_headings(content, prefix \\ "")
+ def link_headings(nil, _), do: nil
+
+ def link_headings(content, prefix) do
+ @heading_regex
+ |> Regex.scan(content)
+ |> Enum.reduce({content, %{}}, fn [match, tag, title], {content, occurrences} ->
+ possible_id = text_to_id(title)
+ id_occurred = Map.get(occurrences, possible_id, 0)
+
+ anchor_id = if id_occurred >= 1, do: "#{possible_id}-#{id_occurred}", else: possible_id
+ replacement = link_heading(match, tag, title, anchor_id, prefix)
+ linked_content = String.replace(content, match, replacement, global: false)
+ incremented_occs = Map.put(occurrences, possible_id, id_occurred + 1)
+ {linked_content, incremented_occs}
+ end)
+ |> elem(0)
+ end
+
+ @class_regex ~r/[^"]+)")?.*?>/
+ @class_separator " "
+ defp link_heading(match, _tag, _title, "", _prefix), do: match
+
+ defp link_heading(match, tag, title, id, prefix) do
+ section_header_class_name = "section-heading"
+
+ # NOTE: This addition is mainly to preserve the previous `class` attributes
+ # from the headers, in case there is one. Now with the _admonition_ text
+ # block, we inject CSS classes. So far, the supported classes are:
+ # `warning`, `info`, `error`, and `neutral`.
+ #
+ # The Markdown syntax that we support for the admonition text
+ # blocks is something like this:
+ #
+ # > ### Never open this door! {: .warning}
+ # >
+ # > ...
+ #
+ # That should produce the following HTML:
+ #
+ #
+ # Never open this door!
+ # ...
+ #
+ #
+ # The original implementation discarded the previous CSS classes. Instead,
+ # it was setting `#{section_header_class_name}` as the only CSS class
+ # associated with the given header.
+ class_attribute =
+ case Regex.named_captures(@class_regex, match) do
+ %{"class" => ""} ->
+ section_header_class_name
+
+ %{"class" => previous_classes} ->
+ # Let's make sure that the `section_header_class_name` is not already
+ # included in the previous classes for the header
+ previous_classes
+ |> String.split(@class_separator)
+ |> Enum.reject(&(&1 == section_header_class_name))
+ |> Enum.join(@class_separator)
+ |> Kernel.<>(" #{section_header_class_name}")
+ end
+
+ """
+ <#{tag} id="#{prefix}#{id}" class="#{class_attribute}">
+
+
+
+ #{title}
+ #{tag}>
+ """
+ end
+
+ def link_moduledoc_headings(content) do
+ link_headings(content, "module-")
+ end
+
+ def link_detail_headings(content, prefix) do
+ link_headings(content, prefix <> "-")
+ end
+
+ templates = [
+ detail_template: [:node, :module],
+ footer_template: [:config, :node],
+ head_template: [:config, :page],
+ module_template: [:config, :module, :summary, :nodes_map],
+ not_found_template: [:config, :nodes_map],
+ api_reference_entry_template: [:module_node],
+ api_reference_template: [:nodes_map],
+ extra_template: [:config, :node, :type, :nodes_map, :refs],
+ search_template: [:config, :nodes_map],
+ sidebar_template: [:config, :nodes_map],
+ summary_template: [:name, :nodes],
+ redirect_template: [:config, :redirect_to]
+ ]
+
+ Enum.each(templates, fn {name, args} ->
+ filename = Path.expand("templates/#{name}.eex", __DIR__)
+ @doc false
+ EEx.function_from_file(:def, name, filename, args, trim: true)
+ end)
+end
diff --git a/lib/ex_doc/formatter/markdown/templates/api_reference_entry_template.eex b/lib/ex_doc/formatter/markdown/templates/api_reference_entry_template.eex
new file mode 100644
index 000000000..ec17835f7
--- /dev/null
+++ b/lib/ex_doc/formatter/markdown/templates/api_reference_entry_template.eex
@@ -0,0 +1,11 @@
+
+
+ <%= if doc = module_node.rendered_doc do %>
+
<%= synopsis(doc) %>
+ <% end %>
+
diff --git a/lib/ex_doc/formatter/markdown/templates/api_reference_template.eex b/lib/ex_doc/formatter/markdown/templates/api_reference_template.eex
new file mode 100644
index 000000000..523a28964
--- /dev/null
+++ b/lib/ex_doc/formatter/markdown/templates/api_reference_template.eex
@@ -0,0 +1,21 @@
+<%= if nodes_map.modules != [] do %>
+
+ Modules
+
+ <%= for module_node <- Enum.sort_by(nodes_map.modules, & &1.id) do
+ api_reference_entry_template(module_node)
+ end %>
+
+
+<% end %>
+
+<%= if nodes_map.tasks != [] do %>
+
+ Mix Tasks
+
+ <%= for task_node <- nodes_map.tasks do
+ api_reference_entry_template(task_node)
+ end %>
+
+
+<% end %>
diff --git a/lib/ex_doc/formatter/markdown/templates/detail_template.eex b/lib/ex_doc/formatter/markdown/templates/detail_template.eex
new file mode 100644
index 000000000..450b45390
--- /dev/null
+++ b/lib/ex_doc/formatter/markdown/templates/detail_template.eex
@@ -0,0 +1,36 @@
+
+ <%= for {default_name, default_arity} <- get_defaults(node) do %>
+ ">
+ <% end %>
+
+ <%= if deprecated = node.deprecated do %>
+
+ This <%= node.type %> is deprecated. <%= h(deprecated) %>.
+
+ <% end %>
+
+
+ <%= if specs = get_specs(node) do %>
+
+ <%= for spec <- specs do %>
+
<%= format_spec_attribute(module, node) %> <%= spec %>
+ <% end %>
+
+ <% end %>
+
+ <%= link_detail_headings(node.rendered_doc, enc(node.id)) %>
+
+
diff --git a/lib/ex_doc/formatter/markdown/templates/extra_template.eex b/lib/ex_doc/formatter/markdown/templates/extra_template.eex
new file mode 100644
index 000000000..e7d22f9df
--- /dev/null
+++ b/lib/ex_doc/formatter/markdown/templates/extra_template.eex
@@ -0,0 +1,60 @@
+<%= head_template(config, %{title: node.title, type: type, noindex: false}) %>
+<%= sidebar_template(config, nodes_map) %>
+
+
+
+ <%= if node.source_url do %>
+
+
+ View Source
+
+ <% end %>
+ <%= if type == :cheatmd do %>
+
+ <% end %>
+
+ <%= node.title_content %>
+
+
+ <%= if type == :livemd do %>
+
+ <% end %>
+
+ <%= link_headings(node.content) %>
+
+
+
+
+<%= footer_template(config, node) %>
diff --git a/lib/ex_doc/formatter/markdown/templates/footer_template.eex b/lib/ex_doc/formatter/markdown/templates/footer_template.eex
new file mode 100644
index 000000000..5488a0212
--- /dev/null
+++ b/lib/ex_doc/formatter/markdown/templates/footer_template.eex
@@ -0,0 +1,44 @@
+
+
+
+
+ <%= before_closing_body_tag(config, :html) %>
+