diff --git a/lib/ex_doc/formatter/epub/templates/module_template.eex b/lib/ex_doc/formatter/epub/templates/module_template.eex index 2639baddd..2242051de 100644 --- a/lib/ex_doc/formatter/epub/templates/module_template.eex +++ b/lib/ex_doc/formatter/epub/templates/module_template.eex @@ -18,15 +18,20 @@ <%= if summary != [] do %>

Summary

- <%= for {name, nodes} <- summary, do: H.summary_template(name, nodes) %> + <%= for group <- summary, do: H.summary_template(group.title, group.docs) %>
<% end %> - <%= for {name, nodes} <- summary, key = text_to_id(name) do %> + <%= for group <- summary, key = text_to_id(group.title) do %>
-

<%=h to_string(name) %>

+

<%=h to_string(group.title) %>

+ <%= if doc = group.rendered_doc do %> +
+ <%= H.link_group_headings(doc, key) %> +
+ <% end %>
- <%= for node <- nodes, do: H.detail_template(node, module) %> + <%= for node <- group.docs, do: H.detail_template(node, module) %>
<% end %> diff --git a/lib/ex_doc/formatter/html.ex b/lib/ex_doc/formatter/html.ex index bdc3420e8..e7584367c 100644 --- a/lib/ex_doc/formatter/html.ex +++ b/lib/ex_doc/formatter/html.ex @@ -93,27 +93,32 @@ defmodule ExDoc.Formatter.HTML do 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: {child_node.type, 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) + docs_groups = + for group <- node.docs_groups do + docs = + for child_node <- group.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: {child_node.type, 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 + + %{render_doc(group, language, autolink_opts, opts) | docs: docs} end %{ render_doc(node, language, [{:id, node.id} | autolink_opts], opts) - | docs: docs + | docs_groups: docs_groups } end, timeout: :infinity diff --git a/lib/ex_doc/formatter/html/templates.ex b/lib/ex_doc/formatter/html/templates.ex index 80d180548..de6897b9c 100644 --- a/lib/ex_doc/formatter/html/templates.ex +++ b/lib/ex_doc/formatter/html/templates.ex @@ -115,9 +115,9 @@ defmodule ExDoc.Formatter.HTML.Templates do {id, modules} end - defp sidebar_entries({group, nodes}) do + defp sidebar_entries(group) do nodes = - for node <- nodes do + for node <- group.docs do id = if "struct" in node.annotations do node.signature @@ -134,7 +134,7 @@ defmodule ExDoc.Formatter.HTML.Templates do %{id: id, title: node.signature, anchor: URI.encode(node.id), deprecated: deprecated?} end - %{key: text_to_id(group), name: group, nodes: nodes} + %{key: text_to_id(group.title), name: group.title, nodes: nodes} end defp module_sections(%ExDoc.ModuleNode{rendered_doc: nil}), do: [sections: []] @@ -167,10 +167,7 @@ defmodule ExDoc.Formatter.HTML.Templates do |> Enum.map(&%{id: &1, anchor: URI.encode(text_to_id(&1))}) end - def module_summary(module_node) do - # TODO: Maybe it should be moved to retriever and it already returned grouped metadata - ExDoc.GroupMatcher.group_by(module_node.docs_groups, module_node.docs, & &1.group) - end + def module_summary(module_node), do: module_node.docs_groups defp favicon_path(%{favicon: nil}), do: nil defp favicon_path(%{favicon: favicon}), do: "assets/favicon#{Path.extname(favicon)}" @@ -281,6 +278,10 @@ defmodule ExDoc.Formatter.HTML.Templates do link_headings(content, prefix <> "-") end + def link_group_headings(content, key) do + link_headings(content, "group-#{key}-") + end + templates = [ detail_template: [:node, :module], footer_template: [:config, :node], diff --git a/lib/ex_doc/formatter/html/templates/module_template.eex b/lib/ex_doc/formatter/html/templates/module_template.eex index e14af45bf..944b746d3 100644 --- a/lib/ex_doc/formatter/html/templates/module_template.eex +++ b/lib/ex_doc/formatter/html/templates/module_template.eex @@ -39,20 +39,25 @@ Summary - <%= for {name, nodes} <- summary, do: summary_template(name, nodes) %> + <%= for group <- summary, do: summary_template(group.title, group.docs) %> <% end %> -<%= for {name, nodes} <- summary, key = text_to_id(name) do %> +<%= for group <- summary, key = text_to_id(group.title) do %>

- <%= name %> + <%= group.title %>

+ <%= if doc = group.rendered_doc do %> +
+ <%= link_group_headings(doc, key) %> +
+ <% end %>
- <%= for node <- nodes, do: detail_template(node, module) %> + <%= for node <- group.docs, do: detail_template(node, module) %>
<% end %> diff --git a/lib/ex_doc/group_matcher.ex b/lib/ex_doc/group_matcher.ex index 6cda47c61..1ba306f11 100644 --- a/lib/ex_doc/group_matcher.ex +++ b/lib/ex_doc/group_matcher.ex @@ -14,23 +14,6 @@ defmodule ExDoc.GroupMatcher do Enum.find_index(groups, fn {k, _v} -> k == group end) || -1 end - @doc """ - Group the following entries and while preserving the order in `groups`. - """ - def group_by(groups, entries, by) do - entries = Enum.group_by(entries, by) - - {groups, leftovers} = - Enum.flat_map_reduce(groups, entries, fn group, grouped_nodes -> - case Map.pop(grouped_nodes, group, []) do - {[], grouped_nodes} -> {[], grouped_nodes} - {entries, grouped_nodes} -> {[{group, entries}], grouped_nodes} - end - end) - - groups ++ Enum.sort(leftovers) - end - @doc """ Finds a matching group for the given module name, id, and metadata. """ diff --git a/lib/ex_doc/nodes.ex b/lib/ex_doc/nodes.ex index 068e9f848..38d9d5750 100644 --- a/lib/ex_doc/nodes.ex +++ b/lib/ex_doc/nodes.ex @@ -43,7 +43,7 @@ defmodule ExDoc.ModuleNode do moduledoc_file: String.t(), source_path: String.t() | nil, source_url: String.t() | nil, - docs_groups: [atom()], + docs_groups: [ExDoc.DocGroupNode.t()], docs: [ExDoc.DocNode.t()], typespecs: [ExDoc.DocNode.t()], type: atom(), @@ -87,11 +87,23 @@ defmodule ExDoc.DocNode do rendered_doc: String.t() | nil, type: atom(), signature: String.t(), - specs: [ExDoc.Language.spec_ast()], + specs: [ExDoc.Language.spec_ast() | String.t()], annotations: [annotation()], - group: atom() | nil, + group: String.t() | nil, doc_file: String.t(), doc_line: non_neg_integer(), source_url: String.t() | nil } end + +defmodule ExDoc.DocGroupNode do + defstruct title: nil, description: nil, doc: nil, rendered_doc: nil, docs: [] + + @type t :: %__MODULE__{ + title: String.t() | atom(), + description: String.t() | nil, + doc: ExDoc.DocAST.t() | nil, + rendered_doc: String.t() | nil, + docs: [ExDoc.DocNode.t()] + } +end diff --git a/lib/ex_doc/retriever.ex b/lib/ex_doc/retriever.ex index 128b4a18d..5b308b38c 100644 --- a/lib/ex_doc/retriever.ex +++ b/lib/ex_doc/retriever.ex @@ -140,7 +140,18 @@ defmodule ExDoc.Retriever do group_for_doc = config.group_for_doc annotations_for_docs = config.annotations_for_docs - docs = get_docs(module_data, source, group_for_doc, annotations_for_docs) + {docs, nodes_groups} = get_docs(module_data, source, group_for_doc, annotations_for_docs) + docs = ExDoc.Utils.natural_sort_by(docs, &"#{&1.name}/#{&1.arity}") + + moduledoc_groups = Map.get(metadata, :groups, []) + + docs_groups = + get_docs_groups( + moduledoc_groups ++ config.docs_groups ++ module_data.default_groups, + nodes_groups, + docs + ) + metadata = Map.put(metadata, :kind, module_data.type) group = GroupMatcher.match_module(config.groups_for_modules, module, module_data.id, metadata) {nested_title, nested_context} = module_data.nesting_info || {nil, nil} @@ -154,8 +165,8 @@ defmodule ExDoc.Retriever do module: module, type: module_data.type, deprecated: metadata[:deprecated], - docs_groups: config.docs_groups ++ module_data.default_groups, - docs: ExDoc.Utils.natural_sort_by(docs, &"#{&1.name}/#{&1.arity}"), + docs_groups: docs_groups, + docs: docs, doc_format: format, doc: doc, source_doc: source_doc, @@ -189,13 +200,15 @@ defmodule ExDoc.Retriever do defp get_docs(module_data, source, group_for_doc, annotations_for_docs) do {:docs_v1, _, _, _, _, _, docs} = module_data.docs - nodes = + {nodes, groups} = for doc <- docs, doc_data = module_data.language.doc_data(doc, module_data) do - get_doc(doc, doc_data, module_data, source, group_for_doc, annotations_for_docs) + {_node, _group} = + get_doc(doc, doc_data, module_data, source, group_for_doc, annotations_for_docs) end + |> Enum.unzip() - filter_defaults(nodes) + {filter_defaults(nodes), groups} end defp get_doc(doc, doc_data, module_data, source, group_for_doc, annotations_for_docs) do @@ -222,9 +235,9 @@ defmodule ExDoc.Retriever do (source_doc && doc_ast(content_type, source_doc, file: doc_file, line: doc_line + 1)) || doc_data.doc_fallback.() - group = group_for_doc.(metadata) || doc_data.default_group + group = normalize_group(group_for_doc.(metadata) || doc_data.default_group) - %ExDoc.DocNode{ + doc_node = %ExDoc.DocNode{ id: doc_data.id_key <> nil_or_name(name, arity), name: name, arity: arity, @@ -238,9 +251,11 @@ defmodule ExDoc.Retriever do specs: doc_data.specs, source_url: source_url, type: doc_data.type, - group: group, + group: group.title, annotations: annotations } + + {doc_node, group} end defp get_defaults(_name, _arity, 0), do: [] @@ -261,6 +276,57 @@ defmodule ExDoc.Retriever do end) end + defp get_docs_groups(module_groups, nodes_groups, doc_nodes) do + module_groups = Enum.map(module_groups, &normalize_group/1) + + # Doc nodes already have normalized groups + nodes_groups_descriptions = Map.new(nodes_groups, &{&1.title, &1.description}) + + normal_groups = module_groups ++ nodes_groups + nodes_by_group_title = Enum.group_by(doc_nodes, & &1.group) + + {docs_groups, _} = + Enum.flat_map_reduce(normal_groups, %{}, fn + group, seen when is_map_key(seen, group.title) -> + {[], seen} + + group, seen -> + seen = Map.put(seen, group.title, true) + + case Map.get(nodes_by_group_title, group.title, []) do + [] -> + {[], seen} + + child_nodes -> + group = finalize_group(group, child_nodes, nodes_groups_descriptions) + {[group], seen} + end + end) + + docs_groups + end + + defp finalize_group(group, doc_nodes, description_fallbacks) do + description = + case group.description do + nil -> Map.get(description_fallbacks, group.title) + text -> text + end + + doc_ast = + case description do + nil -> nil + text -> doc_ast("text/markdown", %{"en" => text}, []) + end + + %ExDoc.DocGroupNode{ + title: group.title, + description: description, + doc: doc_ast, + docs: doc_nodes + } + end + ## General helpers defp nil_or_name(name, arity) do @@ -314,4 +380,19 @@ defmodule ExDoc.Retriever do defp source_link(%{url_pattern: url_pattern, relative_path: path}, line) do url_pattern.(path, line) end + + defp normalize_group(group) do + case group do + %{title: title, description: description} + when is_binary(title) and (is_binary(description) or is_nil(description)) -> + %{group | title: title, description: description} + + kw when is_list(kw) -> + true = Keyword.keyword?(kw) + %{title: to_string(Keyword.fetch!(kw, :title)), description: kw[:description]} + + title when is_binary(title) when is_atom(title) -> + %{title: to_string(title), description: nil} + end + end end diff --git a/test/ex_doc/formatter/epub/templates_test.exs b/test/ex_doc/formatter/epub/templates_test.exs index 724ea3400..ff93d13e5 100644 --- a/test/ex_doc/formatter/epub/templates_test.exs +++ b/test/ex_doc/formatter/epub/templates_test.exs @@ -145,6 +145,42 @@ defmodule ExDoc.Formatter.EPUB.TemplatesTest do assert content =~ ~r{id="functions".*id="example_1/0"}ms end + test "outputs groups descriptions" do + content = + get_module_page([CompiledWithDocs], + group_for_doc: fn metadata -> + if metadata[:purpose] == :example do + [ + title: "Example functions", + description: """ + ### A section heading example + + A content example. + + See `example/1` or `example/2`. + A link to `flatten/1`. + """ + ] + else + "Functions" + end + end + ) + + doc = LazyHTML.from_document(content) + + assert Enum.count(doc["div.group-description"]) == 1 + assert Enum.count(doc["#group-description-example-functions"]) == 1 + assert Enum.count(doc["#group-description-example-functions h3"]) == 1 + assert Enum.count(doc["#group-example-functions-a-section-heading-example"]) == 1 + assert Enum.count(doc["#example-functions .group-description a[href='#example/1']"]) == 1 + assert Enum.count(doc["#example-functions .group-description a[href='#example/2']"]) == 1 + assert Enum.count(doc["#example-functions .group-description a[href='#flatten/1']"]) == 1 + + assert content =~ ~s[A section heading example] + assert content =~ "

A content example.

" + end + test "outputs summaries" do content = get_module_page([CompiledWithDocs]) diff --git a/test/ex_doc/formatter/html/templates_test.exs b/test/ex_doc/formatter/html/templates_test.exs index f13e21260..d0b7c404a 100644 --- a/test/ex_doc/formatter/html/templates_test.exs +++ b/test/ex_doc/formatter/html/templates_test.exs @@ -469,6 +469,42 @@ defmodule ExDoc.Formatter.HTML.TemplatesTest do assert Enum.count(doc["#functions [id='example/2']"]) == 0 end + test "outputs groups descriptions", context do + content = + get_module_page([CompiledWithDocs], context, + group_for_doc: fn metadata -> + if metadata[:purpose] == :example do + [ + title: "Example functions", + description: """ + ### A section heading example + + A content example. + + See `example/1` or `example/2`. + A link to `flatten/1`. + """ + ] + else + "Functions" + end + end + ) + + doc = LazyHTML.from_document(content) + + assert Enum.count(doc["div.group-description"]) == 1 + assert Enum.count(doc["#group-description-example-functions"]) == 1 + assert Enum.count(doc["#group-description-example-functions h3"]) == 1 + assert Enum.count(doc["#group-example-functions-a-section-heading-example"]) == 1 + assert Enum.count(doc["#example-functions .group-description a[href='#example/1']"]) == 1 + assert Enum.count(doc["#example-functions .group-description a[href='#example/2']"]) == 1 + assert Enum.count(doc["#example-functions .group-description a[href='#flatten/1']"]) == 1 + + assert content =~ ~s[A section heading example] + assert content =~ "

A content example.

" + end + test "outputs deprecation information", context do content = get_module_page([CompiledWithDocs], context) diff --git a/test/ex_doc/group_matcher_test.exs b/test/ex_doc/group_matcher_test.exs index 9f5832570..a73f4363d 100644 --- a/test/ex_doc/group_matcher_test.exs +++ b/test/ex_doc/group_matcher_test.exs @@ -2,16 +2,6 @@ defmodule ExDoc.GroupMatcherTest do use ExUnit.Case, async: true import ExDoc.GroupMatcher - describe "group_by" do - test "group by given data with leftovers" do - assert group_by([1, 3, 5], [%{key: 1}, %{key: 3}, %{key: 2}], & &1.key) == [ - {1, [%{key: 1}]}, - {3, [%{key: 3}]}, - {2, [%{key: 2}]} - ] - end - end - describe "module matching" do test "by atom names" do patterns = [ diff --git a/test/ex_doc/retriever/erlang_test.exs b/test/ex_doc/retriever/erlang_test.exs index 671fb329f..56f10126f 100644 --- a/test/ex_doc/retriever/erlang_test.exs +++ b/test/ex_doc/retriever/erlang_test.exs @@ -59,7 +59,7 @@ defmodule ExDoc.Retriever.ErlangTest do moduledoc_line: 2, moduledoc_file: moduledoc_file, docs: [equiv_function2, function1, function2], - docs_groups: ["Types", "Callbacks", "Functions"], + docs_groups: [%{title: "Functions"}], group: nil, id: "mod", language: ExDoc.Language.Erlang, @@ -156,7 +156,7 @@ defmodule ExDoc.Retriever.ErlangTest do moduledoc_line: 6, moduledoc_file: moduledoc_file, docs: [type, callback, function], - docs_groups: ["Types", "Callbacks", "Functions"], + docs_groups: [%{title: "Types"}, %{title: "Callbacks"}, %{title: "Functions"}], group: nil, id: "mod", language: ExDoc.Language.Erlang, @@ -397,7 +397,7 @@ defmodule ExDoc.Retriever.ErlangTest do deprecated: nil, moduledoc_line: _, docs: [function1, function2], - docs_groups: ["Types", "Callbacks", "Functions"], + docs_groups: [%{title: "Functions"}], group: nil, id: "mod", language: ExDoc.Language.Erlang, diff --git a/test/ex_doc/retriever_test.exs b/test/ex_doc/retriever_test.exs index 80588962a..243726d33 100644 --- a/test/ex_doc/retriever_test.exs +++ b/test/ex_doc/retriever_test.exs @@ -108,6 +108,87 @@ defmodule ExDoc.RetrieverTest do assert %{id: "baz/0", group: "c"} = baz end + test "default_group_for_doc can return group description from @moduledoc", c do + elixirc(c, ~S""" + defmodule A do + + @moduledoc groups: [ + "c", + %{title: "b", description: "predefined b"} + ] + + @doc test_group: "a" + @callback foo() :: :ok + + @doc test_group: "b" + def bar(), do: :ok + + @doc test_group: "c" + def baz(), do: :ok + end + """) + + config = %ExDoc.Config{ + group_for_doc: fn meta -> + case meta[:test_group] do + "a" -> [title: "a", description: "for a"] + "b" -> [title: "b", description: "ignored description"] + "c" -> [title: "c", description: "for c"] + end + end + } + + {[mod], []} = Retriever.docs_from_modules([A], config) + + assert [c, b, a] = mod.docs_groups + + # Description returned by the function should override nil + assert %{title: "c", description: "for c"} = c + + # Description returned by the function should not override a + # description from @moduledoc + assert %{title: "b", description: "predefined b"} = b + + # Description returned by th function should define a description + # for leftover groups + assert %{title: "a", description: "for a"} = a + + [bar, baz, foo] = mod.docs + + assert %{id: "c:foo/0", group: "a"} = foo + assert %{id: "bar/0", group: "b"} = bar + assert %{id: "baz/0", group: "c"} = baz + end + + test "function groups description use moduledoc :groups metadata", c do + elixirc(c, ~S""" + defmodule A do + @moduledoc groups: [ + "c", + %{title: "b", description: "text for b"} + ] + + @doc group: "a" + @callback foo() :: :ok + + @doc group: "b" + def bar(), do: :ok + + @doc group: "c" + def baz(), do: :ok + end + """) + + config = %ExDoc.Config{} + {[mod], []} = Retriever.docs_from_modules([A], config) + + assert [ + %{description: nil, title: "c"}, + %{description: "text for b", title: "b"}, + %{description: nil, title: "a"} + ] = mod.docs_groups + end + test "function annotations", c do elixirc(c, ~S""" defmodule A do