From 28faff1eb2af8c9db8b39a74c4ee9d7f49f6f9b4 Mon Sep 17 00:00:00 2001
From: Mayel de Borniol
Date: Thu, 26 Dec 2024 15:05:36 +0000
Subject: [PATCH] WIP for https://github.com/elixir-lang/ex_doc/pull/1976
---
lib/ex_doc.ex | 1 +
lib/ex_doc/cli.ex | 2 +-
.../html/templates/footer_template.eex | 6 +
.../html/templates/module_template.eex | 8 +
lib/ex_doc/formatter/markdown.ex | 49 +++--
lib/ex_doc/formatter/markdown/assets.ex | 2 +-
lib/ex_doc/formatter/markdown/templates.ex | 77 +++-----
.../templates/nav_grouped_item_template.eex | 8 +
.../markdown/templates/nav_item_template.eex | 6 +
.../markdown/templates/nav_template.eex | 9 +
.../formatter/markdown/templates_test.exs | 157 ++++++++++++++++
test/ex_doc/formatter/markdown_test.exs | 173 ++++++++++++++++++
12 files changed, 425 insertions(+), 73 deletions(-)
create mode 100644 lib/ex_doc/formatter/markdown/templates/nav_grouped_item_template.eex
create mode 100644 lib/ex_doc/formatter/markdown/templates/nav_item_template.eex
create mode 100644 lib/ex_doc/formatter/markdown/templates/nav_template.eex
create mode 100644 test/ex_doc/formatter/markdown/templates_test.exs
create mode 100644 test/ex_doc/formatter/markdown_test.exs
diff --git a/lib/ex_doc.ex b/lib/ex_doc.ex
index c108c3560..4fdf725a3 100644
--- a/lib/ex_doc.ex
+++ b/lib/ex_doc.ex
@@ -44,6 +44,7 @@ defmodule ExDoc do
if Code.ensure_loaded?(modname) do
modname
else
+ IO.inspect(modname)
raise "formatter module #{inspect(argname)} not found"
end
end
diff --git a/lib/ex_doc/cli.ex b/lib/ex_doc/cli.ex
index 5573ede94..2d8e14ef1 100644
--- a/lib/ex_doc/cli.ex
+++ b/lib/ex_doc/cli.ex
@@ -103,7 +103,7 @@ defmodule ExDoc.CLI do
defp normalize_formatters(opts) do
formatters =
case Keyword.get_values(opts, :formatter) do
- [] -> opts[:formatters] || ["html", "epub", "markdown"]
+ [] -> opts[:formatters] || ["html", "epub"]
values -> values
end
diff --git a/lib/ex_doc/formatter/html/templates/footer_template.eex b/lib/ex_doc/formatter/html/templates/footer_template.eex
index 5488a0212..2f0042019 100644
--- a/lib/ex_doc/formatter/html/templates/footer_template.eex
+++ b/lib/ex_doc/formatter/html/templates/footer_template.eex
@@ -22,6 +22,12 @@
Download ePub version
<% end %>
+
+ <%= if "markdown" in config.formatters do %>
+
+ Download Markdown version
+
+ <% end %>
diff --git a/lib/ex_doc/formatter/html/templates/module_template.eex b/lib/ex_doc/formatter/html/templates/module_template.eex
index 4f2824c16..bdb31d715 100644
--- a/lib/ex_doc/formatter/html/templates/module_template.eex
+++ b/lib/ex_doc/formatter/html/templates/module_template.eex
@@ -9,6 +9,14 @@
View Source
<% end %>
+ <%= if "markdown" in config.formatters do %>
+
+ <%= IO.inspect(module).title %>
+
+ Download Markdown version
+
+ <% end %>
+
<%= module.title %> <%= module_type(module) %>
(<%= config.project %> v<%= config.version %>)
<%= for annotation <- module.annotations do %>
diff --git a/lib/ex_doc/formatter/markdown.ex b/lib/ex_doc/formatter/markdown.ex
index 381b42041..ca0e49178 100644
--- a/lib/ex_doc/formatter/markdown.ex
+++ b/lib/ex_doc/formatter/markdown.ex
@@ -1,7 +1,6 @@
-defmodule ExDoc.Formatter.Markdown do
+defmodule ExDoc.Formatter.MARKDOWN do
@moduledoc false
- @mimetype "text/markdown"
@assets_dir "MD/assets"
alias __MODULE__.{Assets, Templates}
alias ExDoc.Formatter.HTML
@@ -28,24 +27,24 @@ defmodule ExDoc.Formatter.Markdown do
extras =
config
- |> HTML.build_extras(".xhtml")
+ |> HTML.build_extras(".md")
|> Enum.chunk_by(& &1.group)
|> Enum.map(&{hd(&1).group, &1})
config = %{config | extras: extras}
- static_files = HTML.generate_assets("MD", default_assets(config), config)
- HTML.generate_logo(@assets_dir, config)
- HTML.generate_cover(@assets_dir, config)
-
- # generate_nav(config, nodes_map)
+ generate_nav(config, nodes_map)
generate_extras(config)
generate_list(config, nodes_map.modules)
generate_list(config, nodes_map.tasks)
- {:ok, epub} = generate_zip(config.output)
- File.rm_rf!(config.output)
- Path.relative_to_cwd(epub)
+ # if config[:generate_zip] do # TODO: add a command line flag?
+ # {:ok, zip} = generate_zip(config.output)
+ # File.rm_rf!(config.output)
+ # Path.relative_to_cwd(zip)
+ # else
+ config.output |> Path.join("index.md") |> Path.relative_to_cwd()
+ # end
end
defp normalize_config(config) do
@@ -57,6 +56,23 @@ defmodule ExDoc.Formatter.Markdown do
%{config | output: output}
end
+ defp normalize_output(output) do
+ output
+ |> String.replace(~r/\r\n|\r|\n/, "\n")
+ |> String.replace(~r/\n{2,}/, "\n")
+ end
+
+ defp generate_nav(config, nodes) do
+ nodes =
+ Map.update!(nodes, :modules, fn modules ->
+ modules |> Enum.chunk_by(& &1.group) |> Enum.map(&{hd(&1).group, &1})
+ end)
+
+ content = Templates.nav_template(config, nodes)
+ |> normalize_output()
+ File.write("#{config.output}/MD/index.md", content)
+ end
+
defp generate_extras(config) do
for {_title, extras} <- config.extras do
Enum.each(extras, fn %{id: id, title: title, title_content: _title_content, source: content} ->
@@ -66,6 +82,7 @@ defmodule ExDoc.Formatter.Markdown do
#{content}
"""
+ |> normalize_output()
if File.regular?(output) do
Utils.warn("file #{Path.relative_to_cwd(output)} already exists", [])
@@ -77,8 +94,6 @@ defmodule ExDoc.Formatter.Markdown do
end
-
-
defp generate_list(config, nodes) do
nodes
|> Task.async_stream(&generate_module_page(&1, config), timeout: :infinity)
@@ -99,13 +114,6 @@ defmodule ExDoc.Formatter.Markdown do
## Helpers
- defp default_assets(config) do
- [
- {Assets.dist(config.proglang), "MD/dist"},
- {Assets.metainfo(), "META-INF"}
- ]
- end
-
defp files_to_add(path) do
Enum.reduce(Path.wildcard(Path.join(path, "**/*")), [], fn file, acc ->
case File.read(file) do
@@ -120,6 +128,7 @@ defmodule ExDoc.Formatter.Markdown do
defp generate_module_page(module_node, config) do
content = Templates.module_page(config, module_node)
+ |> normalize_output()
File.write("#{config.output}/MD/#{module_node.id}.md", content)
end
diff --git a/lib/ex_doc/formatter/markdown/assets.ex b/lib/ex_doc/formatter/markdown/assets.ex
index 2a3041226..5001fae5b 100644
--- a/lib/ex_doc/formatter/markdown/assets.ex
+++ b/lib/ex_doc/formatter/markdown/assets.ex
@@ -1,4 +1,4 @@
-defmodule ExDoc.Formatter.Markdown.Assets do
+defmodule ExDoc.Formatter.MARKDOWN.Assets do
@moduledoc false
defmacrop embed_pattern(pattern) do
diff --git a/lib/ex_doc/formatter/markdown/templates.ex b/lib/ex_doc/formatter/markdown/templates.ex
index ab9c5b498..da57e5341 100644
--- a/lib/ex_doc/formatter/markdown/templates.ex
+++ b/lib/ex_doc/formatter/markdown/templates.ex
@@ -1,10 +1,10 @@
-defmodule ExDoc.Formatter.Markdown.Templates do
+defmodule ExDoc.Formatter.MARKDOWN.Templates do
@moduledoc false
require EEx
import ExDoc.Utils,
- only: [before_closing_body_tag: 2, before_closing_head_tag: 2, h: 1, text_to_id: 1]
+ only: [before_closing_body_tag: 2, h: 1, text_to_id: 1]
alias ExDoc.Formatter.HTML.Templates, as: H
@@ -119,48 +119,34 @@ defmodule ExDoc.Formatter.Markdown.Templates do
trim: true
)
- # @doc """
- # Creates the table of contents.
-
- # This template follows the EPUB Navigation Document Definition.
-
- # See http://www.idpf.org/epub/30/spec/epub30-contentdocs.html#sec-xhtml-nav.
- # """
- # EEx.function_from_file(
- # :def,
- # :nav_template,
- # Path.expand("templates/nav_template.eex", __DIR__),
- # [:config, :nodes],
- # trim: true
- # )
+ @doc """
+ Creates the table of contents.
- # @doc """
- # Creates a new chapter when the user provides additional files.
- # """
- # EEx.function_from_file(
- # :def,
- # :extra_template,
- # Path.expand("templates/extra_template.eex", __DIR__),
- # [:config, :title, :title_content, :content],
- # trim: true
- # )
+ """
+ EEx.function_from_file(
+ :def,
+ :nav_template,
+ Path.expand("templates/nav_template.eex", __DIR__),
+ [:config, :nodes],
+ trim: true
+ )
- # EEx.function_from_file(
- # :defp,
- # :nav_item_template,
- # Path.expand("templates/nav_item_template.eex", __DIR__),
- # [:name, :nodes],
- # trim: true
- # )
+ EEx.function_from_file(
+ :defp,
+ :nav_item_template,
+ Path.expand("templates/nav_item_template.eex", __DIR__),
+ [:name, :nodes],
+ trim: true
+ )
- # EEx.function_from_file(
- # :defp,
- # :nav_grouped_item_template,
- # Path.expand("templates/nav_grouped_item_template.eex", __DIR__),
- # [:nodes],
- # trim: true
- # )
+ EEx.function_from_file(
+ :defp,
+ :nav_grouped_item_template,
+ Path.expand("templates/nav_grouped_item_template.eex", __DIR__),
+ [:nodes],
+ trim: true
+ )
# EEx.function_from_file(
# :defp,
@@ -170,17 +156,6 @@ defmodule ExDoc.Formatter.Markdown.Templates do
# trim: true
# )
- # "templates/media-types.txt"
- # |> Path.expand(__DIR__)
- # |> File.read!()
- # |> String.split("\n", trim: true)
- # |> Enum.each(fn line ->
- # [extension, media] = String.split(line, ",")
-
- # def media_type("." <> unquote(extension)) do
- # unquote(media)
- # end
- # end)
# def media_type(_arg), do: nil
diff --git a/lib/ex_doc/formatter/markdown/templates/nav_grouped_item_template.eex b/lib/ex_doc/formatter/markdown/templates/nav_grouped_item_template.eex
new file mode 100644
index 000000000..874ebdbfd
--- /dev/null
+++ b/lib/ex_doc/formatter/markdown/templates/nav_grouped_item_template.eex
@@ -0,0 +1,8 @@
+<%= for {title, nodes} <- nodes do %>
+<%= if title do %>
+- <%=h to_string(title) %>
+<% end %>
+<%= for node <- nodes do %>
+ - [<%=h node.title %>](<%= URI.encode node.id %>.md)
+<% end %>
+<% end %>
diff --git a/lib/ex_doc/formatter/markdown/templates/nav_item_template.eex b/lib/ex_doc/formatter/markdown/templates/nav_item_template.eex
new file mode 100644
index 000000000..aaa568662
--- /dev/null
+++ b/lib/ex_doc/formatter/markdown/templates/nav_item_template.eex
@@ -0,0 +1,6 @@
+<%= unless Enum.empty?(nodes) do %>
+- <%= name %>
+<%= for node <- nodes do %>
+1. [<%=h node.title %>](<%= URI.encode node.id %>.md)
+<% end %>
+<% end %>
diff --git a/lib/ex_doc/formatter/markdown/templates/nav_template.eex b/lib/ex_doc/formatter/markdown/templates/nav_template.eex
new file mode 100644
index 000000000..aa35d02df
--- /dev/null
+++ b/lib/ex_doc/formatter/markdown/templates/nav_template.eex
@@ -0,0 +1,9 @@
+# Table of contents
+
+<%= nav_grouped_item_template config.extras %>
+<%= unless Enum.empty?(nodes.modules) do %>
+## Modules
+<%= nav_grouped_item_template nodes.modules %>
+<% end %>
+<%= nav_item_template "Mix Tasks", nodes.tasks %>
+<%= before_closing_body_tag(config, :markdown) %>
diff --git a/test/ex_doc/formatter/markdown/templates_test.exs b/test/ex_doc/formatter/markdown/templates_test.exs
new file mode 100644
index 000000000..69c3cbc97
--- /dev/null
+++ b/test/ex_doc/formatter/markdown/templates_test.exs
@@ -0,0 +1,157 @@
+defmodule ExDoc.Formatter.MARKDOWN.TemplatesTest do
+ use ExUnit.Case, async: true
+
+ alias ExDoc.Formatter.HTML
+ alias ExDoc.Formatter.MARKDOWN.Templates
+
+ defp source_url do
+ "https://github.com/elixir-lang/elixir"
+ end
+
+ defp homepage_url do
+ "https://elixir-lang.org"
+ end
+
+ defp doc_config(config \\ []) do
+ default = %ExDoc.Config{
+ project: "Elixir",
+ version: "1.0.1",
+ source_url_pattern: "#{source_url()}/blob/master/%{path}#L%{line}",
+ homepage_url: homepage_url(),
+ source_url: source_url(),
+ output: "test/tmp/markdown_templates"
+ }
+
+ struct(default, config)
+ end
+
+ defp get_module_page(names, config \\ []) do
+ config = doc_config(config)
+ {mods, []} = ExDoc.Retriever.docs_from_modules(names, config)
+ [mod | _] = HTML.render_all(mods, [], ".md", config, highlight_tag: "samp")
+ Templates.module_page(config, mod)
+ end
+
+ setup_all do
+ # File.mkdir_p!("test/tmp/markdown_templates")
+ # File.cp_r!("formatters/markdown", "test/tmp/markdown_templates")
+ :ok
+ end
+
+ describe "content_template/5" do
+ test "includes logo as a resource if specified in the config" do
+ nodes = %{modules: [], tasks: []}
+
+ content =
+ [logo: "my_logo.png"]
+ |> doc_config()
+ |> Templates.content_template(nodes, "uuid", "datetime", _static_files = [])
+
+ assert content =~ ~S| |
+ end
+
+
+ test "includes modules as a resource" do
+ module_node = %ExDoc.ModuleNode{
+ module: XPTOModule,
+ doc: nil,
+ id: "XPTOModule",
+ title: "XPTOModule"
+ }
+
+ nodes = %{modules: [module_node], tasks: []}
+
+ content =
+ Templates.content_template(doc_config(), nodes, "uuid", "datetime", _static_files = [])
+
+ assert content =~
+ ~S| |
+
+ assert content =~ ~S||
+ end
+ end
+
+ describe "module_page/2" do
+ test "generates only the module name when there's no more info" do
+ module_node = %ExDoc.ModuleNode{
+ module: XPTOModule,
+ doc: nil,
+ id: "XPTOModule",
+ title: "XPTOModule"
+ }
+
+ content = Templates.module_page(doc_config(), module_node)
+
+ assert content =~ ~r{#\s*XPTOModule\s*}
+ end
+
+ test "outputs the functions and docstrings" do
+ content = get_module_page([CompiledWithDocs])
+
+ assert content =~ ~r{#\s*CompiledWithDocs\s*}
+
+ assert content =~ ~s{# Summary}
+
+ assert content =~
+ ~r{## .*Example.*}ms
+
+ assert content =~
+ ~r{### .*Example H3 heading.*}ms
+
+ assert content =~
+ ~r{moduledoc.*Example.*CompiledWithDocs\.example.*}ms
+
+ assert content =~ ~r{example/2.*Some example}ms
+ assert content =~ ~r{example_without_docs/0.*}ms
+ assert content =~ ~r{example_1/0.*> \(macro\)}ms
+
+ assert content =~ ~s{example(foo, bar \\\\ Baz)}
+ end
+
+ test "outputs function groups" do
+ content =
+ get_module_page([CompiledWithDocs],
+ groups_for_docs: [
+ "Example functions": &(&1[:purpose] == :example),
+ Legacy: &is_binary(&1[:deprecated])
+ ]
+ )
+
+ assert content =~ ~r{.*Example functions}ms
+ assert content =~ ~r{.*Legacy}ms
+ end
+
+
+ ## BEHAVIOURS
+
+ test "outputs behavior and callbacks" do
+ content = get_module_page([CustomBehaviourOne])
+
+ assert content =~
+ ~r{# \s*CustomBehaviourOne\s*behaviour\s*}m
+
+ assert content =~ ~r{Callbacks}
+
+ content = get_module_page([CustomBehaviourTwo])
+
+ assert content =~
+ ~r{# \s*CustomBehaviourTwo\s*behaviour\s*}m
+
+ assert content =~ ~r{Callbacks}
+ end
+
+ ## PROTOCOLS
+
+ test "outputs the protocol type" do
+ content = get_module_page([CustomProtocol])
+ assert content =~ ~r{# \s*CustomProtocol\s*protocol\s*}m
+ end
+
+ ## TASKS
+
+ test "outputs the task type" do
+ content = get_module_page([Mix.Tasks.TaskWithDocs])
+ assert content =~ ~r{# \s*mix task_with_docs\s*}m
+ end
+ end
+end
diff --git a/test/ex_doc/formatter/markdown_test.exs b/test/ex_doc/formatter/markdown_test.exs
new file mode 100644
index 000000000..9af39ecf6
--- /dev/null
+++ b/test/ex_doc/formatter/markdown_test.exs
@@ -0,0 +1,173 @@
+defmodule ExDoc.Formatter.MARKDOWNTest do
+ use ExUnit.Case, async: false
+
+ import ExUnit.CaptureIO
+
+ alias ExDoc.Utils
+
+ @moduletag :tmp_dir
+
+ @before_closing_body_tag_content_md "UNIQUE:©BEFORE-CLOSING-BODY-TAG-HTML"
+
+ defp before_closing_body_tag(:markdown), do: @before_closing_body_tag_content_md
+
+ def before_closing_body_tag(:markdown, name), do: "#{name}"
+
+ defp doc_config(%{tmp_dir: tmp_dir} = _context) do
+ [
+ app: :elixir,
+ project: "Elixir",
+ version: "1.0.1",
+ formatter: "markdown",
+ output: tmp_dir <> "/markdown",
+ source_beam: "test/tmp/beam",
+ extras: ["test/fixtures/README.md"],
+ skip_undefined_reference_warnings_on: ["Warnings"]
+ ]
+ end
+
+ defp doc_config(context, config) when is_map(context) and is_list(config) do
+ Keyword.merge(doc_config(context), config)
+ end
+
+ defp generate_docs(config) do
+ ExDoc.generate_docs(config[:project], config[:version], config)
+ end
+
+ defp generate_docs(_context, config) do
+ generate_docs(config)
+ end
+
+
+ test "generates a markdown nav file in the default directory", %{tmp_dir: tmp_dir} = context do
+ generate_docs(doc_config(context))
+ assert File.regular?(tmp_dir <> "/markdown/Elixir/MD/nav.md")
+ end
+
+ test "generates a markdown file with erlang as proglang", %{tmp_dir: tmp_dir} = context do
+ config =
+ context
+ |> doc_config()
+ |> Keyword.put(:proglang, :erlang)
+ |> Keyword.update!(:skip_undefined_reference_warnings_on, &["test/fixtures/README.md" | &1])
+
+ generate_docs(config)
+ assert File.regular?(tmp_dir <> "/markdown/Elixir/MD/nav.md")
+ end
+
+ test "generates a markdown file in specified output directory", %{tmp_dir: tmp_dir} = context do
+ config = doc_config(context, output: tmp_dir <> "/markdown/another_dir", main: "RandomError")
+ generate_docs(config)
+
+ assert File.regular?(tmp_dir <> "/markdown/another_dir/nav.md")
+ end
+
+
+ test "generates the readme file", %{tmp_dir: tmp_dir} = context do
+ config = doc_config(context, main: "README")
+ generate_docs(context, config)
+
+ content = File.read!(tmp_dir <> "/markdown/Elixir/MD/readme.md")
+ assert content =~ ~r{README [^<]*}
+ assert content =~ ~r{RandomError
}
+
+ assert content =~
+ ~r{CustomBehaviourImpl.hello/1
}
+
+ assert content =~
+ ~r{TypesAndSpecs.Sub
}
+
+ content = File.read!(tmp_dir <> "/markdown/Elixir/MD/nav.md")
+ assert content =~ ~r{README}
+ end
+
+ test "uses samp as highlight tag for markdown", %{tmp_dir: tmp_dir} = context do
+ generate_docs(context, doc_config(context))
+
+ assert File.read!(tmp_dir <> "/markdown/Elixir/MD/CompiledWithDocs.md") =~
+ "CompiledWithDocs<\/samp>"
+ end
+
+ @example_basenames [
+ # "structural" pages
+ "nav.md",
+ "readme.md",
+ # "module pages"
+ "CompiledWithDocs.md",
+ "CompiledWithDocs.Nested.md"
+ ]
+
+ test "before_closing_*_tags required by the user are in the right place",
+ %{tmp_dir: tmp_dir} = context do
+ generate_docs(
+ context,
+ doc_config(context,
+ before_closing_body_tag: &before_closing_body_tag/1
+ )
+ )
+
+ dir = tmp_dir <> "/markdown/Elixir/MD"
+
+ for basename <- @example_basenames do
+ content = File.read!(Path.join(dir, basename))
+ assert content =~ ~r[#{@before_closing_body_tag_content_md}\s]
+ end
+ end
+
+ test "before_closing_*_tags required by the user are in the right place using map",
+ %{tmp_dir: tmp_dir} = context do
+ generate_docs(
+ context,
+ doc_config(context,
+ before_closing_body_tag: %{markdown: "StaticDemo
"}
+ )
+ )
+
+ dir = tmp_dir <> "/markdown/Elixir/MD"
+
+ for basename <- @example_basenames do
+ content = File.read!(Path.join(dir, basename))
+ assert content =~ ~r[StaticDemo
\s]
+ end
+ end
+
+ test "before_closing_*_tags required by the user are in the right place using a MFA",
+ %{tmp_dir: tmp_dir} = context do
+ generate_docs(
+ context,
+ doc_config(context,
+ before_closing_body_tag: {__MODULE__, :before_closing_body_tag, ["Demo"]}
+ )
+ )
+
+ dir = tmp_dir <> "/markdown/Elixir/MD"
+
+ for basename <- @example_basenames do
+ content = File.read!(Path.join(dir, basename))
+ assert content =~ ~r[Demo
\s]
+ end
+ end
+
+ test "assets required by the user end up in the right place", %{tmp_dir: tmp_dir} = context do
+ File.mkdir_p!("test/tmp/markdown_assets/hello")
+ File.touch!("test/tmp/markdown_assets/hello/world.png")
+ File.touch!("test/tmp/markdown_assets/hello/world.pdf")
+
+ generate_docs(
+ context,
+ doc_config(context,
+ assets: %{"test/tmp/markdown_assets" => "assets"},
+ logo: "test/fixtures/elixir.png",
+ cover: "test/fixtures/elixir.png"
+ )
+ )
+
+ assert File.regular?(tmp_dir <> "/markdown/assets/hello/world.png")
+ assert File.regular?(tmp_dir <> "/markdown/assets/hello/world.pdf")
+ assert File.regular?(tmp_dir <> "/markdown/assets/logo.png")
+ assert File.regular?(tmp_dir <> "/markdown/assets/cover.png")
+ after
+ File.rm_rf!("test/tmp/markdown_assets")
+ end
+
+end