Skip to content

Commit a6870e3

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

File tree

11 files changed

+689
-8
lines changed

11 files changed

+689
-8
lines changed

lib/ex_doc/doc_ast.ex

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,76 @@ 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+
The optional `fun` argument allows post-processing each AST node
72+
after it's been converted to markdown.
73+
"""
74+
def to_markdown_string(ast, fun \\ fn _ast, string -> string end)
75+
76+
def to_markdown_string(binary, _fun) when is_binary(binary) do
77+
ExDoc.Utils.h(binary)
78+
end
79+
80+
def to_markdown_string(list, fun) when is_list(list) do
81+
result = Enum.map_join(list, "", &to_markdown_string(&1, fun))
82+
fun.(list, result)
83+
end
84+
85+
def to_markdown_string({:comment, _attrs, inner, _meta} = ast, fun) do
86+
fun.(ast, "<!--#{inner}-->")
87+
end
88+
89+
def to_markdown_string({:code, attrs, inner, _meta} = ast, fun) do
90+
lang = attrs[:class] || ""
91+
result = """
92+
```#{lang}
93+
#{inner}
94+
```
95+
"""
96+
97+
fun.(ast, result)
98+
end
99+
100+
def to_markdown_string({:a, attrs, inner, _meta} = ast, fun) do
101+
result = "[#{to_markdown_string(inner, fun)}](#{attrs[:href]})"
102+
fun.(ast, result)
103+
end
104+
105+
def to_markdown_string({:hr, _attrs, _inner, _meta} = ast, fun) do
106+
result = "\n\n---\n\n"
107+
fun.(ast, result)
108+
end
109+
110+
def to_markdown_string({tag, _attrs, _inner, _meta} = ast, fun) when tag in [:p, :br] do
111+
result = "\n\n"
112+
fun.(ast, result)
113+
end
114+
115+
def to_markdown_string({:img, attrs, _inner, _meta} = ast, fun) do
116+
alt = attrs[:alt] || ""
117+
title = attrs[:title] || ""
118+
result = "![#{alt}](#{attrs[:src]} \"#{title}\")"
119+
fun.(ast, result)
120+
end
121+
122+
# ignoring these: area base col command embed input keygen link meta param source track wbr
123+
def to_markdown_string({tag, _attrs, _inner, _meta} = ast, fun) when tag in @void_elements do
124+
result = ""
125+
fun.(ast, result)
126+
end
127+
128+
def to_markdown_string({_tag, _attrs, inner, %{verbatim: true}} = ast, fun) do
129+
result = Enum.join(inner, "")
130+
fun.(ast, result)
131+
end
132+
133+
def to_markdown_string({_tag, _attrs, inner, _meta} = ast, fun) do
134+
result = to_markdown_string(inner, fun)
135+
fun.(ast, result)
136+
end
137+
68138
## parse markdown
69139

70140
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.to_markdown_string()
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)
@@ -183,6 +189,7 @@ defmodule ExDoc.Formatter do
183189
id = input_options[:filename] || input |> filename_to_title() |> Utils.text_to_id()
184190
source_file = input_options[:source] || input
185191
opts = [file: source_file, line: 1]
192+
ext = Keyword.fetch!(autolink_opts, :ext)
186193

187194
{extension, source, ast} =
188195
case extension_name(input) do
@@ -198,7 +205,7 @@ defmodule ExDoc.Formatter do
198205
source
199206
|> Markdown.to_ast(opts)
200207
|> ExDoc.DocAST.add_ids_to_headers([:h2, :h3])
201-
|> autolink_and_highlight(language, [file: input] ++ autolink_opts, opts)
208+
|> autolink_and_render(ext, language, [file: input] ++ autolink_opts, opts)
202209

203210
{extension, source, ast}
204211

lib/ex_doc/formatter/markdown.ex

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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+
File.rm_rf!(config.output)
17+
File.mkdir_p!(config.output)
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+
generate_nav(config, nodes_map)
33+
generate_extras(config)
34+
generate_list(config, nodes_map.modules)
35+
generate_list(config, nodes_map.tasks)
36+
generate_llm_index(config, nodes_map)
37+
38+
config.output |> Path.join("index.md") |> Path.relative_to_cwd()
39+
end
40+
41+
defp normalize_config(config) do
42+
output =
43+
config.output
44+
|> Path.expand()
45+
|> Path.join("markdown")
46+
47+
%{config | output: output}
48+
end
49+
50+
defp normalize_output(output) do
51+
output
52+
|> String.replace(~r/\r\n|\r|\n/, "\n")
53+
|> String.replace(~r/\n{3,}/, "\n\n")
54+
end
55+
56+
defp generate_nav(config, nodes) do
57+
nodes =
58+
Map.update!(nodes, :modules, fn modules ->
59+
modules |> Enum.chunk_by(& &1.group) |> Enum.map(&{hd(&1).group, &1})
60+
end)
61+
62+
content =
63+
Templates.nav_template(config, nodes)
64+
|> normalize_output()
65+
66+
File.write("#{config.output}/index.md", content)
67+
end
68+
69+
defp generate_extras(config) do
70+
for {_title, extras} <- config.extras do
71+
Enum.each(extras, fn %{id: id, source: content} ->
72+
output = "#{config.output}/#{id}.md"
73+
74+
if File.regular?(output) do
75+
Utils.warn("file #{Path.relative_to_cwd(output)} already exists", [])
76+
end
77+
78+
File.write!(output, normalize_output(content))
79+
end)
80+
end
81+
end
82+
83+
defp generate_list(config, nodes) do
84+
nodes
85+
|> Task.async_stream(&generate_module_page(&1, config), timeout: :infinity)
86+
|> Enum.map(&elem(&1, 1))
87+
end
88+
89+
## Helpers
90+
91+
defp generate_module_page(module_node, config) do
92+
content =
93+
Templates.module_page(config, module_node)
94+
|> normalize_output()
95+
96+
File.write("#{config.output}/#{module_node.id}.md", content)
97+
end
98+
99+
defp generate_llm_index(config, nodes_map) do
100+
content = generate_llm_index_content(config, nodes_map)
101+
File.write("#{config.output}/llms.txt", content)
102+
end
103+
104+
defp generate_llm_index_content(config, nodes_map) do
105+
project_info = """
106+
# #{config.project} #{config.version}
107+
108+
#{config.project} documentation index for Large Language Models.
109+
110+
## Modules
111+
112+
"""
113+
114+
modules_info =
115+
nodes_map.modules
116+
|> Enum.map(fn module_node ->
117+
"- **#{module_node.title}** (#{module_node.id}.md): #{module_node.doc |> extract_summary()}"
118+
end)
119+
|> Enum.join("\n")
120+
121+
tasks_info =
122+
if length(nodes_map.tasks) > 0 do
123+
tasks_list =
124+
nodes_map.tasks
125+
|> Enum.map(fn task_node ->
126+
"- **#{task_node.title}** (#{task_node.id}.md): #{task_node.doc |> extract_summary()}"
127+
end)
128+
|> Enum.join("\n")
129+
130+
"\n\n## Mix Tasks\n\n" <> tasks_list
131+
else
132+
""
133+
end
134+
135+
extras_info =
136+
if is_list(config.extras) and length(config.extras) > 0 do
137+
extras_list =
138+
config.extras
139+
|> Enum.flat_map(fn
140+
{_group, extras} when is_list(extras) -> extras
141+
_ -> []
142+
end)
143+
|> Enum.map(fn extra ->
144+
"- **#{extra.title}** (#{extra.id}.md): #{extra.title}"
145+
end)
146+
|> Enum.join("\n")
147+
148+
if extras_list == "" do
149+
""
150+
else
151+
"\n\n## Guides\n\n" <> extras_list
152+
end
153+
else
154+
""
155+
end
156+
157+
project_info <> modules_info <> tasks_info <> extras_info
158+
end
159+
160+
defp extract_summary(nil), do: "No documentation available"
161+
defp extract_summary(""), do: "No documentation available"
162+
163+
defp extract_summary(doc) when is_binary(doc) do
164+
doc
165+
|> String.split("\n")
166+
|> Enum.find("", fn line -> String.trim(line) != "" end)
167+
|> String.trim()
168+
|> case do
169+
"" ->
170+
"No documentation available"
171+
172+
summary ->
173+
summary
174+
|> String.slice(0, 150)
175+
|> then(fn s -> if String.length(s) == 150, do: s <> "...", else: s end)
176+
end
177+
end
178+
179+
defp extract_summary(doc_ast) when is_list(doc_ast) do
180+
# For DocAST (which is a list), extract the first text node
181+
extract_first_text_from_ast(doc_ast)
182+
end
183+
184+
defp extract_summary(_), do: "No documentation available"
185+
186+
defp extract_first_text_from_ast([]), do: "No documentation available"
187+
188+
defp extract_first_text_from_ast([{:p, _, content} | _rest]) do
189+
extract_text_from_content(content)
190+
|> String.slice(0, 150)
191+
|> then(fn s -> if String.length(s) == 150, do: s <> "...", else: s end)
192+
end
193+
194+
defp extract_first_text_from_ast([_node | rest]) do
195+
extract_first_text_from_ast(rest)
196+
end
197+
198+
defp extract_text_from_content([]), do: ""
199+
defp extract_text_from_content([text | _rest]) when is_binary(text), do: text
200+
201+
defp extract_text_from_content([{_tag, _attrs, content} | rest]) do
202+
case extract_text_from_content(content) do
203+
"" -> extract_text_from_content(rest)
204+
text -> text
205+
end
206+
end
207+
208+
defp extract_text_from_content([_node | rest]) do
209+
extract_text_from_content(rest)
210+
end
211+
end

0 commit comments

Comments
 (0)