Skip to content

Commit 83c8172

Browse files
committed
feat: add Markdown formatter
Signed-off-by: Yordis Prieto <[email protected]>
1 parent e7abbae commit 83c8172

File tree

17 files changed

+1314
-54
lines changed

17 files changed

+1314
-54
lines changed

lib/ex_doc.ex

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,16 @@ defmodule ExDoc do
3535
end
3636

3737
defp find_formatter(name) do
38-
[ExDoc.Formatter, String.upcase(name)]
38+
[ExDoc.Formatter, format_module_name(name)]
3939
|> Module.concat()
4040
|> check_formatter_module(name)
4141
end
4242

43+
defp format_module_name("html"), do: "HTML"
44+
defp format_module_name("epub"), do: "EPUB"
45+
defp format_module_name("markdown"), do: "Markdown"
46+
defp format_module_name(name), do: String.upcase(name)
47+
4348
defp check_formatter_module(modname, argname) do
4449
if Code.ensure_loaded?(modname) do
4550
modname

lib/ex_doc/doc_ast.ex

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,67 @@ defmodule ExDoc.DocAST do
6565
Enum.map(attrs, fn {key, val} -> " #{key}=\"#{ExDoc.Utils.h(val)}\"" end)
6666
end
6767

68+
@doc """
69+
Transform AST into markdown string.
70+
"""
71+
def to_markdown(ast)
72+
73+
def to_markdown(binary) when is_binary(binary) do
74+
ExDoc.Utils.h(binary)
75+
end
76+
77+
def to_markdown(list) when is_list(list) do
78+
Enum.map_join(list, "", &to_markdown/1)
79+
end
80+
81+
def to_markdown({:comment, _attrs, inner, _meta}) do
82+
"<!--#{inner}-->"
83+
end
84+
85+
def to_markdown({:code, attrs, inner, _meta}) do
86+
lang = attrs[:class] || ""
87+
88+
"""
89+
```#{lang}
90+
#{inner}
91+
```
92+
"""
93+
end
94+
95+
def to_markdown({:a, attrs, inner, _meta}) do
96+
"[#{to_markdown(inner)}](#{attrs[:href]})"
97+
end
98+
99+
def to_markdown({:hr, _attrs, _inner, _meta}) do
100+
"\n\n---\n\n"
101+
end
102+
103+
def to_markdown({:p, _attrs, inner, _meta}) do
104+
to_markdown(inner) <> "\n\n"
105+
end
106+
107+
def to_markdown({:br, _attrs, _inner, _meta}) do
108+
"\n\n"
109+
end
110+
111+
def to_markdown({:img, attrs, _inner, _meta}) do
112+
alt = attrs[:alt] || ""
113+
title = attrs[:title] || ""
114+
"![#{alt}](#{attrs[:src]} \"#{title}\")"
115+
end
116+
117+
def to_markdown({tag, _attrs, _inner, _meta}) when tag in @void_elements do
118+
""
119+
end
120+
121+
def to_markdown({_tag, _attrs, inner, %{verbatim: true}}) do
122+
Enum.join(inner, "")
123+
end
124+
125+
def to_markdown({_tag, _attrs, inner, _meta}) do
126+
to_markdown(inner)
127+
end
128+
68129
## parse markdown
69130

70131
defp parse_markdown(markdown, opts) do

lib/ex_doc/formatter.ex

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,14 @@ defmodule ExDoc.Formatter do
4848

4949
specs = Enum.map(child_node.specs, &language.autolink_spec(&1, autolink_opts))
5050
child_node = %{child_node | specs: specs}
51-
render_doc(child_node, language, autolink_opts, opts)
51+
render_doc(child_node, ext, language, autolink_opts, opts)
5252
end
5353

54-
%{render_doc(group, language, autolink_opts, opts) | docs: docs}
54+
%{render_doc(group, ext, language, autolink_opts, opts) | docs: docs}
5555
end
5656

5757
%{
58-
render_doc(node, language, [{:id, node.id} | autolink_opts], opts)
58+
render_doc(node, ext, language, [{:id, node.id} | autolink_opts], opts)
5959
| docs_groups: docs_groups
6060
}
6161
end,
@@ -117,11 +117,11 @@ defmodule ExDoc.Formatter do
117117

118118
# Helper functions
119119

120-
defp render_doc(%{doc: nil} = node, _language, _autolink_opts, _opts),
120+
defp render_doc(%{doc: nil} = node, _ext, _language, _autolink_opts, _opts),
121121
do: node
122122

123-
defp render_doc(%{doc: doc} = node, language, autolink_opts, opts) do
124-
doc = autolink_and_highlight(doc, language, autolink_opts, opts)
123+
defp render_doc(%{doc: doc} = node, ext, language, autolink_opts, opts) do
124+
doc = autolink_and_render(doc, ext, language, autolink_opts, opts)
125125
%{node | doc: doc}
126126
end
127127

@@ -137,7 +137,13 @@ defmodule ExDoc.Formatter do
137137
mod_id <> "." <> id
138138
end
139139

140-
defp autolink_and_highlight(doc, language, autolink_opts, opts) do
140+
defp autolink_and_render(doc, ".md", language, autolink_opts, opts) do
141+
doc
142+
|> language.autolink_doc(autolink_opts)
143+
|> ExDoc.DocAST.highlight(language, opts)
144+
end
145+
146+
defp autolink_and_render(doc, _html_ext, language, autolink_opts, opts) do
141147
doc
142148
|> language.autolink_doc(autolink_opts)
143149
|> ExDoc.DocAST.highlight(language, opts)
@@ -187,6 +193,7 @@ defmodule ExDoc.Formatter do
187193

188194
source_file = validate_extra_string!(input_options, :source) || input
189195
opts = [file: source_file, line: 1]
196+
ext = Keyword.fetch!(autolink_opts, :ext)
190197

191198
{extension, source, ast} =
192199
case extension_name(input) do
@@ -202,7 +209,7 @@ defmodule ExDoc.Formatter do
202209
source
203210
|> Markdown.to_ast(opts)
204211
|> ExDoc.DocAST.add_ids_to_headers([:h2, :h3])
205-
|> autolink_and_highlight(language, [file: input] ++ autolink_opts, opts)
212+
|> autolink_and_render(ext, language, [file: input] ++ autolink_opts, opts)
206213

207214
{extension, source, ast}
208215

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: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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+
build = Path.join(config.output, ".build-markdown")
17+
output_setup(build, config)
18+
19+
extras = Formatter.build_extras(config, ".md")
20+
21+
project_nodes =
22+
project_nodes
23+
|> Formatter.render_all(filtered_modules, ".md", config, highlight_tag: "samp")
24+
25+
nodes_map = %{
26+
modules: Formatter.filter_list(:module, project_nodes),
27+
tasks: Formatter.filter_list(:task, project_nodes)
28+
}
29+
30+
config = %{config | extras: extras}
31+
32+
all_files =
33+
[generate_nav(config, nodes_map)] ++
34+
generate_extras(config) ++
35+
generate_list(config, nodes_map.modules) ++
36+
generate_list(config, nodes_map.tasks) ++
37+
[generate_llm_index(config, nodes_map)]
38+
39+
generate_build(List.flatten(all_files), build)
40+
config.output |> Path.join("index.md") |> Path.relative_to_cwd()
41+
end
42+
43+
defp normalize_config(config) do
44+
output = Path.expand(config.output)
45+
%{config | output: output}
46+
end
47+
48+
defp output_setup(build, config) do
49+
if File.exists?(build) do
50+
build
51+
|> File.read!()
52+
|> String.split("\n", trim: true)
53+
|> Enum.map(&Path.join(config.output, &1))
54+
|> Enum.each(&File.rm/1)
55+
56+
File.rm(build)
57+
else
58+
# Only remove markdown files, not HTML/EPUB files
59+
File.mkdir_p!(config.output)
60+
61+
if File.exists?(config.output) do
62+
config.output
63+
|> Path.join("*.md")
64+
|> Path.wildcard()
65+
|> Enum.each(&File.rm/1)
66+
67+
llms_file = Path.join(config.output, "llms.txt")
68+
if File.exists?(llms_file), do: File.rm(llms_file)
69+
end
70+
end
71+
end
72+
73+
defp generate_build(files, build) do
74+
entries =
75+
files
76+
|> Enum.uniq()
77+
|> Enum.sort()
78+
|> Enum.map(&[&1, "\n"])
79+
80+
File.write!(build, entries)
81+
end
82+
83+
defp normalize_output(output) do
84+
output
85+
|> String.replace(~r/\r\n?/, "\n")
86+
|> String.replace(~r/\n{3,}/, "\n\n")
87+
end
88+
89+
defp generate_nav(config, nodes) do
90+
nodes =
91+
Map.update!(nodes, :modules, fn modules ->
92+
modules |> Enum.chunk_by(& &1.group) |> Enum.map(&{hd(&1).group, &1})
93+
end)
94+
95+
content =
96+
Templates.nav_template(config, nodes)
97+
|> normalize_output()
98+
99+
filename = "index.md"
100+
File.write("#{config.output}/#{filename}", content)
101+
filename
102+
end
103+
104+
defp generate_extras(config) do
105+
for {_title, extras} <- config.extras,
106+
%{id: id, source: content} <- extras,
107+
not is_map_key(%{id: id, source: content}, :url) do
108+
filename = "#{id}.md"
109+
output = "#{config.output}/#{filename}"
110+
111+
if File.regular?(output) do
112+
Utils.warn("file #{Path.relative_to_cwd(output)} already exists", [])
113+
end
114+
115+
File.write!(output, normalize_output(content))
116+
filename
117+
end
118+
end
119+
120+
defp generate_list(config, nodes) do
121+
nodes
122+
|> Task.async_stream(&generate_module_page(&1, config), timeout: :infinity)
123+
|> Enum.map(&elem(&1, 1))
124+
end
125+
126+
## Helpers
127+
128+
defp generate_module_page(module_node, config) do
129+
content =
130+
Templates.module_page(config, module_node)
131+
|> normalize_output()
132+
133+
filename = "#{module_node.id}.md"
134+
File.write("#{config.output}/#{filename}", content)
135+
filename
136+
end
137+
138+
defp generate_llm_index(config, nodes_map) do
139+
content = generate_llm_index_content(config, nodes_map)
140+
filename = "llms.txt"
141+
File.write("#{config.output}/#{filename}", content)
142+
filename
143+
end
144+
145+
defp generate_llm_index_content(config, nodes_map) do
146+
project_info = """
147+
# #{config.project} #{config.version}
148+
149+
#{config.project} documentation index for Large Language Models.
150+
151+
## Modules
152+
153+
"""
154+
155+
modules_info =
156+
nodes_map.modules
157+
|> Enum.map(fn module_node ->
158+
"- **#{module_node.title}** (#{module_node.id}.md): #{module_node.doc |> ExDoc.DocAST.synopsis() |> extract_plain_text()}"
159+
end)
160+
|> Enum.join("\n")
161+
162+
tasks_info =
163+
if length(nodes_map.tasks) > 0 do
164+
tasks_list =
165+
nodes_map.tasks
166+
|> Enum.map(fn task_node ->
167+
"- **#{task_node.title}** (#{task_node.id}.md): #{task_node.doc |> ExDoc.DocAST.synopsis() |> extract_plain_text()}"
168+
end)
169+
|> Enum.join("\n")
170+
171+
"\n\n## Mix Tasks\n\n" <> tasks_list
172+
else
173+
""
174+
end
175+
176+
extras_info =
177+
if is_list(config.extras) and length(config.extras) > 0 do
178+
extras_list =
179+
config.extras
180+
|> Enum.flat_map(fn
181+
{_group, extras} when is_list(extras) -> extras
182+
_ -> []
183+
end)
184+
|> Enum.map(fn extra ->
185+
"- **#{extra.title}** (#{extra.id}.md): #{extra.title}"
186+
end)
187+
|> Enum.join("\n")
188+
189+
if extras_list == "" do
190+
""
191+
else
192+
"\n\n## Guides\n\n" <> extras_list
193+
end
194+
else
195+
""
196+
end
197+
198+
project_info <> modules_info <> tasks_info <> extras_info
199+
end
200+
201+
defp extract_plain_text(html) when is_binary(html) do
202+
html
203+
|> String.replace(~r/<[^>]*>/, "")
204+
|> String.replace(~r/\s+/, " ")
205+
|> String.trim()
206+
|> case do
207+
"" ->
208+
"No documentation available"
209+
210+
text ->
211+
if String.length(text) > 150 do
212+
String.slice(text, 0, 150) <> "..."
213+
else
214+
text
215+
end
216+
end
217+
end
218+
219+
defp extract_plain_text(_), do: "No documentation available"
220+
end

0 commit comments

Comments
 (0)