Skip to content

Commit df631d0

Browse files
committed
feat: add Markdown formatter
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent a359f56 commit df631d0

File tree

12 files changed

+913
-18
lines changed

12 files changed

+913
-18
lines changed

lib/ex_doc/formatter/epub/templates.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ defmodule ExDoc.Formatter.EPUB.Templates do
1313
defp render_doc(ast), do: ast && ExDoc.DocAST.to_string(ast)
1414

1515
@doc """
16-
Generate content from the module template for a given `node`
16+
Generate content from the module template for a given `node`.
1717
"""
1818
def module_page(config, module_node) do
1919
module_template(config, module_node)
2020
end
2121

2222
@doc """
23-
Generated ID for static file
23+
Generated ID for static file.
2424
"""
2525
def static_file_to_id(static_file) do
2626
static_file |> Path.basename() |> text_to_id()

lib/ex_doc/formatter/html/templates.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ defmodule ExDoc.Formatter.HTML.Templates do
1313
]
1414

1515
@doc """
16-
Generate content from the module template for a given `node`
16+
Generate content from the module template for a given `node`.
1717
"""
1818
def module_page(module_node, config) do
1919
module_template(config, module_node)

lib/ex_doc/formatter/markdown.ex

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
defmodule ExDoc.Formatter.MARKDOWN do
2+
@moduledoc false
3+
4+
alias __MODULE__.{Templates}
5+
alias ExDoc.Formatter
6+
alias ExDoc.Utils
7+
8+
@doc """
9+
Generates Markdown documentation for the given modules.
10+
"""
11+
@spec run([ExDoc.ModuleNode.t()], [ExDoc.ModuleNode.t()], ExDoc.Config.t()) :: String.t()
12+
def run(project_nodes, filtered_modules, config) when is_map(config) do
13+
Utils.unset_warned()
14+
15+
config = normalize_config(config)
16+
17+
build = Path.join(config.output, ".build")
18+
output_setup(build, config)
19+
20+
extras = Formatter.build_extras(config, ".md")
21+
22+
project_nodes =
23+
project_nodes
24+
|> Formatter.render_all(filtered_modules, ".md", config, highlight_tag: "samp")
25+
26+
nodes_map = %{
27+
modules: Formatter.filter_list(:module, project_nodes),
28+
tasks: Formatter.filter_list(:task, project_nodes)
29+
}
30+
31+
config = %{config | extras: extras}
32+
33+
all_files =
34+
[generate_nav(config, nodes_map)] ++
35+
generate_extras(config) ++
36+
generate_list(config, nodes_map.modules) ++
37+
generate_list(config, nodes_map.tasks) ++
38+
[generate_llm_index(config, nodes_map)]
39+
40+
generate_build(List.flatten(all_files), build)
41+
config.output |> Path.join("index.md") |> Path.relative_to_cwd()
42+
end
43+
44+
defp normalize_config(config) do
45+
output = Path.expand(config.output)
46+
%{config | output: output}
47+
end
48+
49+
defp output_setup(build, config) do
50+
if File.exists?(build) do
51+
build
52+
|> File.read!()
53+
|> String.split("\n", trim: true)
54+
|> Enum.map(&Path.join(config.output, &1))
55+
|> Enum.each(&File.rm/1)
56+
57+
File.rm(build)
58+
else
59+
# Only remove markdown files, not HTML/EPUB files
60+
File.mkdir_p!(config.output)
61+
62+
if File.exists?(config.output) do
63+
config.output
64+
|> Path.join("*.md")
65+
|> Path.wildcard()
66+
|> Enum.each(&File.rm/1)
67+
68+
llms_file = Path.join(config.output, "llms.txt")
69+
if File.exists?(llms_file), do: File.rm(llms_file)
70+
end
71+
end
72+
end
73+
74+
defp generate_build(files, build) do
75+
entries =
76+
files
77+
|> Enum.uniq()
78+
|> Enum.sort()
79+
|> Enum.map(&[&1, "\n"])
80+
81+
File.mkdir_p!(Path.dirname(build))
82+
File.write!(build, entries)
83+
end
84+
85+
defp normalize_output(output) do
86+
output
87+
|> String.replace(~r/\r\n?/, "\n")
88+
|> String.replace(~r/\n{3,}/, "\n\n")
89+
end
90+
91+
defp generate_nav(config, nodes) do
92+
nodes =
93+
Map.update!(nodes, :modules, fn modules ->
94+
modules |> Enum.chunk_by(& &1.group) |> Enum.map(&{hd(&1).group, &1})
95+
end)
96+
97+
content =
98+
Templates.nav_template(config, nodes)
99+
|> normalize_output()
100+
101+
filename = "index.md"
102+
File.write("#{config.output}/#{filename}", content)
103+
filename
104+
end
105+
106+
defp generate_extras(config) do
107+
for {_title, extras} <- config.extras,
108+
%{id: id, source: content} <- extras,
109+
not is_map_key(%{id: id, source: content}, :url) do
110+
filename = "#{id}.md"
111+
output = "#{config.output}/#{filename}"
112+
113+
if File.regular?(output) do
114+
Utils.warn("file #{Path.relative_to_cwd(output)} already exists", [])
115+
end
116+
117+
File.write!(output, normalize_output(content))
118+
filename
119+
end
120+
end
121+
122+
defp generate_list(config, nodes) do
123+
nodes
124+
|> Task.async_stream(&generate_module_page(&1, config), timeout: :infinity)
125+
|> Enum.map(&elem(&1, 1))
126+
end
127+
128+
## Helpers
129+
130+
defp generate_module_page(module_node, config) do
131+
content =
132+
Templates.module_page(config, module_node)
133+
|> normalize_output()
134+
135+
filename = "#{module_node.id}.md"
136+
File.write("#{config.output}/#{filename}", content)
137+
filename
138+
end
139+
140+
defp generate_llm_index(config, nodes_map) do
141+
content = generate_llm_index_content(config, nodes_map)
142+
filename = "llms.txt"
143+
File.write("#{config.output}/#{filename}", content)
144+
filename
145+
end
146+
147+
defp generate_llm_index_content(config, nodes_map) do
148+
project_info = """
149+
# #{config.project} #{config.version}
150+
151+
#{config.project} documentation index for Large Language Models.
152+
153+
## Modules
154+
155+
"""
156+
157+
modules_info =
158+
nodes_map.modules
159+
|> Enum.map(fn module_node ->
160+
"- [#{module_node.title}](#{module_node.id}.md): #{module_node.doc |> ExDoc.DocAST.synopsis() |> extract_plain_text()}"
161+
end)
162+
|> Enum.join("\n")
163+
164+
tasks_info =
165+
if length(nodes_map.tasks) > 0 do
166+
tasks_list =
167+
nodes_map.tasks
168+
|> Enum.map(fn task_node ->
169+
"- [#{task_node.title}](#{task_node.id}.md): #{task_node.doc |> ExDoc.DocAST.synopsis() |> extract_plain_text()}"
170+
end)
171+
|> Enum.join("\n")
172+
173+
"\n\n## Mix Tasks\n\n" <> tasks_list
174+
else
175+
""
176+
end
177+
178+
extras_info =
179+
if is_list(config.extras) and length(config.extras) > 0 do
180+
extras_list =
181+
config.extras
182+
|> Enum.flat_map(fn
183+
{_group, extras} when is_list(extras) -> extras
184+
_ -> []
185+
end)
186+
|> Enum.map(fn extra ->
187+
"- [#{extra.title}](#{extra.id}.md)"
188+
end)
189+
|> Enum.join("\n")
190+
191+
if extras_list == "" do
192+
""
193+
else
194+
"\n\n## Guides\n\n" <> extras_list
195+
end
196+
else
197+
""
198+
end
199+
200+
project_info <> modules_info <> tasks_info <> extras_info
201+
end
202+
203+
defp extract_plain_text(html) when is_binary(html) do
204+
html
205+
|> String.replace(~r/<[^>]*>/, "")
206+
|> String.replace(~r/\s+/, " ")
207+
|> String.trim()
208+
|> case do
209+
"" ->
210+
"No documentation available"
211+
212+
text ->
213+
if String.length(text) > 150 do
214+
String.slice(text, 0, 150) <> "..."
215+
else
216+
text
217+
end
218+
end
219+
end
220+
221+
defp extract_plain_text(_), do: "No documentation available"
222+
end
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
defmodule ExDoc.Formatter.MARKDOWN.Templates do
2+
@moduledoc false
3+
4+
require EEx
5+
6+
import ExDoc.Utils,
7+
only: [before_closing_body_tag: 2, h: 1]
8+
9+
@doc """
10+
Generate content from the module template for a given `node`.
11+
"""
12+
def module_page(config, module_node) do
13+
docs =
14+
for group <- module_node.docs_groups do
15+
{group.title, group.docs}
16+
end
17+
18+
module_template(config, module_node, docs)
19+
end
20+
21+
@doc """
22+
Formats the attribute type used to define the spec of the given `node`.
23+
"""
24+
def format_spec_attribute(module, node) do
25+
module.language.format_spec_attribute(node)
26+
end
27+
28+
@doc """
29+
Returns the original markdown documentation from source_doc.
30+
"""
31+
def node_doc(%{source_doc: %{"en" => source}}) when is_binary(source), do: source
32+
def node_doc(_), do: nil
33+
34+
@doc """
35+
Creates a chapter which contains all the details about an individual module.
36+
"""
37+
EEx.function_from_file(
38+
:def,
39+
:module_template,
40+
Path.expand("templates/module_template.eex", __DIR__),
41+
[:config, :module, :docs],
42+
trim: true
43+
)
44+
45+
@doc """
46+
Creates the table of contents.
47+
"""
48+
EEx.function_from_file(
49+
:def,
50+
:nav_template,
51+
Path.expand("templates/nav_template.eex", __DIR__),
52+
[:config, :nodes],
53+
trim: true
54+
)
55+
56+
EEx.function_from_file(
57+
:defp,
58+
:nav_item_template,
59+
Path.expand("templates/nav_item_template.eex", __DIR__),
60+
[:name, :nodes],
61+
trim: true
62+
)
63+
64+
EEx.function_from_file(
65+
:defp,
66+
:nav_grouped_item_template,
67+
Path.expand("templates/nav_grouped_item_template.eex", __DIR__),
68+
[:nodes],
69+
trim: true
70+
)
71+
72+
@doc false
73+
EEx.function_from_file(
74+
:def,
75+
:detail_template,
76+
Path.expand("templates/detail_template.eex", __DIR__),
77+
[:node, :module],
78+
trim: true
79+
)
80+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# `<%= node.name %>`
2+
<%= if node.source_url do %>[🔗](<%= node.source_url %>)<% end %>
3+
4+
<%= for annotation <- node.annotations do %>*<%= annotation %>* <% end %>
5+
<%= if deprecated = node.deprecated do %>
6+
> This <%= node.type %> is deprecated. <%= h(deprecated) %>.
7+
<% end %>
8+
<%= if node.specs != [] do %>
9+
<%= for spec <- node.specs do %>
10+
```elixir
11+
<%= format_spec_attribute(module, node) %> <%= spec %>
12+
```
13+
<% end %>
14+
<% end %>
15+
<%= node_doc(node) %>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# `<%=h module.title %>`
2+
<%= if module.source_url do %>[🔗](<%= module.source_url %>)<% end %>
3+
4+
<%= for annotation <- module.annotations do %>*<%= annotation %>* <% end %>
5+
<%= if deprecated = module.deprecated do %>
6+
> This <%= module.type %> is deprecated. <%=h deprecated %>.
7+
<% end %>
8+
<%= node_doc(module) %>
9+
<%= for {_name, nodes} <- docs, node <- nodes do %>
10+
<%= detail_template(node, module) %>
11+
<% end %>
12+
<%= before_closing_body_tag(config, :markdown) %>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<%= for {title, nodes} <- nodes do %>
2+
<%= if title do %>
3+
- <%=h to_string(title) %>
4+
<% end %>
5+
<%= for node <- nodes do %>
6+
- [<%=h node.title %>](<%= URI.encode node.id %>.md)
7+
<% end %>
8+
<% end %>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<%= unless Enum.empty?(nodes) do %>
2+
- <%= name %>
3+
<%= for node <- nodes do %>
4+
- [<%=h node.title %>](<%= URI.encode node.id %>.md)
5+
<% end %>
6+
<% end %>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# <%= config.project %> v<%= config.version %> - Documentation - Table of Contents
2+
3+
<%= nav_grouped_item_template config.extras %>
4+
<%= unless Enum.empty?(nodes.modules) do %>
5+
## Modules
6+
<%= nav_grouped_item_template nodes.modules %>
7+
<% end %>
8+
<%= nav_item_template "Mix Tasks", nodes.tasks %>
9+
<%= before_closing_body_tag(config, :markdown) %>

0 commit comments

Comments
 (0)