Skip to content

Commit f2cfdc4

Browse files
committed
add markdown rendering of AST
1 parent 6bd15cc commit f2cfdc4

File tree

8 files changed

+336
-249
lines changed

8 files changed

+336
-249
lines changed

lib/ex_doc/doc_ast.ex

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ defmodule ExDoc.DocAST do
2727
meta param source track wbr)a
2828

2929
@doc """
30-
Transform AST into string.
30+
Transform AST into an HTML string.
3131
"""
3232
def to_string(ast, fun \\ fn _ast, string -> string end)
3333

@@ -64,6 +64,66 @@ defmodule ExDoc.DocAST do
6464
Enum.map(attrs, fn {key, val} -> " #{key}=\"#{val}\"" end)
6565
end
6666

67+
@doc """
68+
Transform AST into a markdown string.
69+
"""
70+
def to_markdown_string(ast, fun \\ fn _ast, string -> string end)
71+
72+
def to_markdown_string(binary, _fun) when is_binary(binary) do
73+
ExDoc.Utils.h(binary)
74+
end
75+
76+
def to_markdown_string(list, fun) when is_list(list) do
77+
result = Enum.map_join(list, "", &to_markdown_string(&1, fun))
78+
fun.(list, result)
79+
end
80+
81+
def to_markdown_string({:comment, _attrs, inner, _meta} = ast, fun) do
82+
fun.(ast, "<!--#{inner}-->")
83+
end
84+
85+
def to_markdown_string({:code, _attrs, inner, _meta} = ast, fun) do
86+
result = """
87+
```
88+
#{inner}
89+
```
90+
"""
91+
92+
fun.(ast, result)
93+
end
94+
95+
def to_markdown_string({:a, attrs, inner, _meta} = ast, fun) do
96+
result = "[#{inner}](#{attrs[:href]})"
97+
fun.(ast, result)
98+
end
99+
100+
def to_markdown_string({:hr, _attrs, _inner, _meta} = ast, fun) do
101+
result = "\n\n---\n\n"
102+
fun.(ast, result)
103+
end
104+
105+
def to_markdown_string({tag, _attrs, _inner, _meta} = ast, fun) when tag in [:p, :br] do
106+
result = "\n\n"
107+
fun.(ast, result)
108+
end
109+
110+
# @void_elements ~W(area base br col command embed hr img input keygen link
111+
# meta param source track wbr)a
112+
def to_markdown_string({tag, _attrs, _inner, _meta} = ast, fun) when tag in @void_elements do
113+
result = ""
114+
fun.(ast, result)
115+
end
116+
117+
def to_markdown_string({tag, _attrs, inner, %{verbatim: true}} = ast, fun) do
118+
result = Enum.join(inner, "")
119+
fun.(ast, result)
120+
end
121+
122+
def to_markdown_string({tag, _attrs, inner, _meta} = ast, fun) do
123+
result = to_string(inner, fun)
124+
fun.(ast, result)
125+
end
126+
67127
## parse markdown
68128

69129
defp parse_markdown(markdown, opts) do

lib/ex_doc/formatter.ex

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
defmodule ExDoc.Formatter do
2+
@moduledoc false
3+
4+
alias __MODULE__.{Assets, Templates, SearchData}
5+
alias ExDoc.{Markdown, GroupMatcher, Utils}
6+
7+
@main "api-reference"
8+
@assets_dir "assets"
9+
10+
@doc """
11+
Autolinks and renders all docs.
12+
"""
13+
def render_all(project_nodes, filtered_modules, ext, config, opts) do
14+
base = [
15+
apps: config.apps,
16+
deps: config.deps,
17+
ext: ext,
18+
extras: extra_paths(config),
19+
skip_undefined_reference_warnings_on: config.skip_undefined_reference_warnings_on,
20+
skip_code_autolink_to: config.skip_code_autolink_to,
21+
filtered_modules: filtered_modules
22+
]
23+
24+
project_nodes
25+
|> Task.async_stream(
26+
fn node ->
27+
language = node.language
28+
29+
autolink_opts =
30+
[
31+
current_module: node.module,
32+
file: node.moduledoc_file,
33+
line: node.moduledoc_line,
34+
module_id: node.id,
35+
language: language
36+
] ++ base
37+
38+
docs =
39+
for child_node <- node.docs do
40+
id = id(node, child_node)
41+
42+
autolink_opts =
43+
autolink_opts ++
44+
[
45+
id: id,
46+
line: child_node.doc_line,
47+
file: child_node.doc_file,
48+
current_kfa: {child_node.type, child_node.name, child_node.arity}
49+
]
50+
51+
specs = Enum.map(child_node.specs, &language.autolink_spec(&1, autolink_opts))
52+
child_node = %{child_node | specs: specs}
53+
render_doc(child_node, ext, language, autolink_opts, opts)
54+
end
55+
56+
%{
57+
render_doc(node, ext, language, [{:id, node.id} | autolink_opts], opts)
58+
| docs: docs
59+
}
60+
end,
61+
timeout: :infinity
62+
)
63+
|> Enum.map(&elem(&1, 1))
64+
end
65+
66+
defp render_doc(%{doc: nil} = node, ext, _language, _autolink_opts, _opts),
67+
do: node
68+
69+
defp render_doc(%{doc: doc} = node, ext, language, autolink_opts, opts) do
70+
rendered = autolink_and_render(doc, ext, language, autolink_opts, opts)
71+
%{node | rendered_doc: rendered}
72+
end
73+
74+
defp id(%{id: mod_id}, %{id: "c:" <> id}) do
75+
"c:" <> mod_id <> "." <> id
76+
end
77+
78+
defp id(%{id: mod_id}, %{id: "t:" <> id}) do
79+
"t:" <> mod_id <> "." <> id
80+
end
81+
82+
defp id(%{id: mod_id}, %{id: id}) do
83+
mod_id <> "." <> id
84+
end
85+
86+
defp autolink_and_render(doc, ".md", language, autolink_opts, opts) do
87+
doc
88+
|> language.autolink_doc(autolink_opts)
89+
|> ExDoc.DocAST.to_markdown_string()
90+
end
91+
92+
defp autolink_and_render(doc, _html_ext, language, autolink_opts, opts) do
93+
doc
94+
|> language.autolink_doc(autolink_opts)
95+
|> ExDoc.DocAST.to_string()
96+
|> ExDoc.DocAST.highlight(language, opts)
97+
end
98+
99+
@doc """
100+
Builds extra nodes by normalizing the config entries.
101+
"""
102+
def build_extras(config, ext) do
103+
groups = config.groups_for_extras
104+
105+
language =
106+
case config.proglang do
107+
:erlang -> ExDoc.Language.Erlang
108+
_ -> ExDoc.Language.Elixir
109+
end
110+
111+
source_url_pattern = config.source_url_pattern
112+
113+
autolink_opts = [
114+
apps: config.apps,
115+
deps: config.deps,
116+
ext: ext,
117+
extras: extra_paths(config),
118+
language: language,
119+
skip_undefined_reference_warnings_on: config.skip_undefined_reference_warnings_on,
120+
skip_code_autolink_to: config.skip_code_autolink_to
121+
]
122+
123+
extras =
124+
config.extras
125+
|> Task.async_stream(
126+
&build_extra(&1, groups, ext, language, autolink_opts, source_url_pattern),
127+
timeout: :infinity
128+
)
129+
|> Enum.map(&elem(&1, 1))
130+
131+
ids_count = Enum.reduce(extras, %{}, &Map.update(&2, &1.id, 1, fn c -> c + 1 end))
132+
133+
extras
134+
|> Enum.map_reduce(1, fn extra, idx ->
135+
if ids_count[extra.id] > 1, do: {disambiguate_id(extra, idx), idx + 1}, else: {extra, idx}
136+
end)
137+
|> elem(0)
138+
|> Enum.sort_by(fn extra -> GroupMatcher.index(groups, extra.group) end)
139+
end
140+
141+
defp build_extra(
142+
{input, input_options},
143+
groups,
144+
ext,
145+
language,
146+
autolink_opts,
147+
source_url_pattern
148+
) do
149+
input = to_string(input)
150+
id = input_options[:filename] || input |> filename_to_title() |> Utils.text_to_id()
151+
source_file = input_options[:source] || input
152+
opts = [file: source_file, line: 1]
153+
154+
{source, ast} =
155+
case extension_name(input) do
156+
extension when extension in ["", ".txt"] ->
157+
source = File.read!(input)
158+
ast = [{:pre, [], "\n" <> source, %{}}]
159+
{source, ast}
160+
161+
extension when extension in [".md", ".livemd", ".cheatmd"] ->
162+
source = File.read!(input)
163+
164+
ast =
165+
source
166+
|> Markdown.to_ast(opts)
167+
|> sectionize(extension)
168+
169+
{source, ast}
170+
171+
_ ->
172+
raise ArgumentError,
173+
"file extension not recognized, allowed extension is either .cheatmd, .livemd, .md, .txt or no extension"
174+
end
175+
176+
{title_ast, ast} =
177+
case ExDoc.DocAST.extract_title(ast) do
178+
{:ok, title_ast, ast} -> {title_ast, ast}
179+
:error -> {nil, ast}
180+
end
181+
182+
title_text = title_ast && ExDoc.DocAST.text_from_ast(title_ast)
183+
title_html = title_ast && ExDoc.DocAST.to_string(title_ast)
184+
content_html = autolink_and_render(ast, ext, language, [file: input] ++ autolink_opts, opts)
185+
186+
group = GroupMatcher.match_extra(groups, input)
187+
title = input_options[:title] || title_text || filename_to_title(input)
188+
189+
source_path = source_file |> Path.relative_to(File.cwd!()) |> String.replace_leading("./", "")
190+
source_url = Utils.source_url_pattern(source_url_pattern, source_path, 1)
191+
192+
%{
193+
source: source,
194+
content: content_html,
195+
group: group,
196+
id: id,
197+
source_path: source_path,
198+
source_url: source_url,
199+
title: title,
200+
title_content: title_html || title
201+
}
202+
end
203+
204+
defp build_extra(input, groups, ext, language, autolink_opts, source_url_pattern) do
205+
build_extra({input, []}, groups, ext, language, autolink_opts, source_url_pattern)
206+
end
207+
208+
defp extra_paths(config) do
209+
Map.new(config.extras, fn
210+
path when is_binary(path) ->
211+
base = Path.basename(path)
212+
{base, Utils.text_to_id(Path.rootname(base))}
213+
214+
{path, opts} ->
215+
base = path |> to_string() |> Path.basename()
216+
{base, opts[:filename] || Utils.text_to_id(Path.rootname(base))}
217+
end)
218+
end
219+
220+
defp disambiguate_id(extra, discriminator) do
221+
Map.put(extra, :id, "#{extra.id}-#{discriminator}")
222+
end
223+
224+
defp sectionize(ast, ".cheatmd") do
225+
ExDoc.DocAST.sectionize(ast, fn
226+
{:h2, _, _, _} -> true
227+
{:h3, _, _, _} -> true
228+
_ -> false
229+
end)
230+
end
231+
232+
defp sectionize(ast, _), do: ast
233+
234+
defp filename_to_title(input) do
235+
input |> Path.basename() |> Path.rootname()
236+
end
237+
238+
def filter_list(:module, nodes) do
239+
Enum.filter(nodes, &(&1.type != :task))
240+
end
241+
242+
def filter_list(type, nodes) do
243+
Enum.filter(nodes, &(&1.type == type))
244+
end
245+
246+
def extension_name(input) do
247+
input
248+
|> Path.extname()
249+
|> String.downcase()
250+
end
251+
end

lib/ex_doc/formatter/epub.ex

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ defmodule ExDoc.Formatter.EPUB do
44
@mimetype "application/epub+zip"
55
@assets_dir "OEBPS/assets"
66
alias __MODULE__.{Assets, Templates}
7+
alias ExDoc.Formatter
78
alias ExDoc.Formatter.HTML
89
alias ExDoc.Utils
910

@@ -17,16 +18,18 @@ defmodule ExDoc.Formatter.EPUB do
1718
File.mkdir_p!(Path.join(config.output, "OEBPS"))
1819

1920
project_nodes =
20-
HTML.render_all(project_nodes, filtered_modules, ".xhtml", config, highlight_tag: "samp")
21+
Formatter.render_all(project_nodes, filtered_modules, ".xhtml", config,
22+
highlight_tag: "samp"
23+
)
2124

2225
nodes_map = %{
23-
modules: HTML.filter_list(:module, project_nodes),
24-
tasks: HTML.filter_list(:task, project_nodes)
26+
modules: Formatter.filter_list(:module, project_nodes),
27+
tasks: Formatter.filter_list(:task, project_nodes)
2528
}
2629

2730
extras =
2831
config
29-
|> HTML.build_extras(".xhtml")
32+
|> Formatter.build_extras(".xhtml")
3033
|> Enum.chunk_by(& &1.group)
3134
|> Enum.map(&{hd(&1).group, &1})
3235

0 commit comments

Comments
 (0)