Skip to content

Commit 7ce837b

Browse files
authored
refactor: extract minimal shared logic between HTML and EPUB formatters (#2147)
Signed-off-by: Yordis Prieto <[email protected]>
1 parent c4c9518 commit 7ce837b

File tree

5 files changed

+385
-370
lines changed

5 files changed

+385
-370
lines changed

lib/ex_doc/formatter.ex

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
defmodule ExDoc.Formatter do
2+
@moduledoc false
3+
4+
alias ExDoc.{Markdown, GroupMatcher, Utils}
5+
6+
@doc """
7+
Autolinks and renders all docs.
8+
"""
9+
def render_all(project_nodes, filtered_modules, ext, config, opts) do
10+
base = [
11+
apps: config.apps,
12+
deps: config.deps,
13+
ext: ext,
14+
extras: extra_paths(config),
15+
skip_undefined_reference_warnings_on: config.skip_undefined_reference_warnings_on,
16+
skip_code_autolink_to: config.skip_code_autolink_to,
17+
filtered_modules: filtered_modules
18+
]
19+
20+
project_nodes
21+
|> Task.async_stream(
22+
fn node ->
23+
language = node.language
24+
25+
autolink_opts =
26+
[
27+
current_module: node.module,
28+
file: node.moduledoc_file,
29+
line: node.moduledoc_line,
30+
module_id: node.id,
31+
language: language
32+
] ++ base
33+
34+
docs_groups =
35+
for group <- node.docs_groups do
36+
docs =
37+
for child_node <- group.docs do
38+
id = id(node, child_node)
39+
40+
autolink_opts =
41+
autolink_opts ++
42+
[
43+
id: id,
44+
line: child_node.doc_line,
45+
file: child_node.doc_file,
46+
current_kfa: {child_node.type, child_node.name, child_node.arity}
47+
]
48+
49+
specs = Enum.map(child_node.specs, &language.autolink_spec(&1, autolink_opts))
50+
child_node = %{child_node | specs: specs}
51+
render_doc(child_node, language, autolink_opts, opts)
52+
end
53+
54+
%{render_doc(group, language, autolink_opts, opts) | docs: docs}
55+
end
56+
57+
%{
58+
render_doc(node, language, [{:id, node.id} | autolink_opts], opts)
59+
| docs_groups: docs_groups
60+
}
61+
end,
62+
timeout: :infinity
63+
)
64+
|> Enum.map(&elem(&1, 1))
65+
end
66+
67+
@doc """
68+
Builds extra nodes by normalizing the config entries.
69+
"""
70+
def build_extras(config, ext) do
71+
groups = config.groups_for_extras
72+
73+
language =
74+
case config.proglang do
75+
:erlang -> ExDoc.Language.Erlang
76+
_ -> ExDoc.Language.Elixir
77+
end
78+
79+
source_url_pattern = config.source_url_pattern
80+
81+
autolink_opts = [
82+
apps: config.apps,
83+
deps: config.deps,
84+
ext: ext,
85+
extras: extra_paths(config),
86+
language: language,
87+
skip_undefined_reference_warnings_on: config.skip_undefined_reference_warnings_on,
88+
skip_code_autolink_to: config.skip_code_autolink_to
89+
]
90+
91+
extras =
92+
config.extras
93+
|> Enum.map(&normalize_extras/1)
94+
|> Task.async_stream(
95+
&build_extra(&1, groups, language, autolink_opts, source_url_pattern),
96+
timeout: :infinity
97+
)
98+
|> Enum.map(&elem(&1, 1))
99+
100+
ids_count = Enum.reduce(extras, %{}, &Map.update(&2, &1.id, 1, fn c -> c + 1 end))
101+
102+
extras
103+
|> Enum.map_reduce(1, fn extra, idx ->
104+
if ids_count[extra.id] > 1, do: {disambiguate_id(extra, idx), idx + 1}, else: {extra, idx}
105+
end)
106+
|> elem(0)
107+
|> Enum.sort_by(fn extra -> GroupMatcher.index(groups, extra.group) end)
108+
end
109+
110+
def filter_list(:module, nodes) do
111+
Enum.filter(nodes, &(&1.type != :task))
112+
end
113+
114+
def filter_list(type, nodes) do
115+
Enum.filter(nodes, &(&1.type == type))
116+
end
117+
118+
# Helper functions
119+
120+
defp render_doc(%{doc: nil} = node, _language, _autolink_opts, _opts),
121+
do: node
122+
123+
defp render_doc(%{doc: doc} = node, language, autolink_opts, opts) do
124+
doc = autolink_and_highlight(doc, language, autolink_opts, opts)
125+
%{node | doc: doc}
126+
end
127+
128+
defp id(%{id: mod_id}, %{id: "c:" <> id}) do
129+
"c:" <> mod_id <> "." <> id
130+
end
131+
132+
defp id(%{id: mod_id}, %{id: "t:" <> id}) do
133+
"t:" <> mod_id <> "." <> id
134+
end
135+
136+
defp id(%{id: mod_id}, %{id: id}) do
137+
mod_id <> "." <> id
138+
end
139+
140+
defp autolink_and_highlight(doc, language, autolink_opts, opts) do
141+
doc
142+
|> language.autolink_doc(autolink_opts)
143+
|> ExDoc.DocAST.highlight(language, opts)
144+
end
145+
146+
defp extra_paths(config) do
147+
Enum.reduce(config.extras, %{}, fn
148+
path, acc when is_binary(path) ->
149+
base = Path.basename(path)
150+
Map.put(acc, base, Utils.text_to_id(Path.rootname(base)))
151+
152+
{path, opts}, acc ->
153+
if Keyword.has_key?(opts, :url) do
154+
acc
155+
else
156+
base = path |> to_string() |> Path.basename()
157+
158+
name =
159+
Keyword.get_lazy(opts, :filename, fn -> Utils.text_to_id(Path.rootname(base)) end)
160+
161+
Map.put(acc, base, name)
162+
end
163+
end)
164+
end
165+
166+
defp normalize_extras(base) when is_binary(base), do: {base, %{}}
167+
defp normalize_extras({base, opts}), do: {base, Map.new(opts)}
168+
169+
defp disambiguate_id(extra, discriminator) do
170+
Map.put(extra, :id, "#{extra.id}-#{discriminator}")
171+
end
172+
173+
defp build_extra({input, %{url: _} = input_options}, groups, _lang, _auto, _url_pattern) do
174+
input = to_string(input)
175+
title = input_options[:title] || input
176+
group = GroupMatcher.match_extra(groups, input_options[:url])
177+
178+
%{group: group, id: Utils.text_to_id(title), title: title, url: input_options[:url]}
179+
end
180+
181+
defp build_extra({input, input_options}, groups, language, autolink_opts, source_url_pattern) do
182+
input = to_string(input)
183+
id = input_options[:filename] || input |> filename_to_title() |> Utils.text_to_id()
184+
source_file = input_options[:source] || input
185+
opts = [file: source_file, line: 1]
186+
187+
{extension, source, ast} =
188+
case extension_name(input) do
189+
extension when extension in ["", ".txt"] ->
190+
source = File.read!(input)
191+
ast = [{:pre, [], ["\n" <> source], %{}}]
192+
{extension, source, ast}
193+
194+
extension when extension in [".md", ".livemd", ".cheatmd"] ->
195+
source = File.read!(input)
196+
197+
ast =
198+
source
199+
|> Markdown.to_ast(opts)
200+
|> ExDoc.DocAST.add_ids_to_headers([:h2, :h3])
201+
|> autolink_and_highlight(language, [file: input] ++ autolink_opts, opts)
202+
203+
{extension, source, ast}
204+
205+
_ ->
206+
raise ArgumentError,
207+
"file extension not recognized, allowed extension is either .cheatmd, .livemd, .md, .txt or no extension"
208+
end
209+
210+
{title_doc, title_text, ast} =
211+
case ExDoc.DocAST.extract_title(ast) do
212+
{:ok, title_doc, ast} -> {title_doc, ExDoc.DocAST.text(title_doc), ast}
213+
:error -> {nil, nil, ast}
214+
end
215+
216+
title = input_options[:title] || title_text || filename_to_title(input)
217+
group = GroupMatcher.match_extra(groups, input)
218+
source_path = source_file |> Path.relative_to(File.cwd!()) |> String.replace_leading("./", "")
219+
source_url = source_url_pattern.(source_path, 1)
220+
search_data = normalize_search_data!(input_options[:search_data])
221+
222+
%{
223+
type: extra_type(extension),
224+
source: source,
225+
group: group,
226+
id: id,
227+
doc: ast,
228+
source_path: source_path,
229+
source_url: source_url,
230+
search_data: search_data,
231+
title: title,
232+
title_doc: title_doc || title
233+
}
234+
end
235+
236+
defp normalize_search_data!(nil), do: nil
237+
238+
defp normalize_search_data!(search_data) when is_list(search_data) do
239+
search_data_keys = [:anchor, :body, :title, :type]
240+
241+
Enum.each(search_data, fn search_data ->
242+
has_keys = Map.keys(search_data)
243+
244+
if Enum.sort(has_keys) != search_data_keys do
245+
raise ArgumentError,
246+
"Expected search data to be a list of maps with the keys: #{inspect(search_data_keys)}, found keys: #{inspect(has_keys)}"
247+
end
248+
end)
249+
250+
search_data
251+
end
252+
253+
defp normalize_search_data!(search_data) do
254+
search_data_keys = [:anchor, :body, :title, :type]
255+
256+
raise ArgumentError,
257+
"Expected search data to be a list of maps with the keys: #{inspect(search_data_keys)}, found: #{inspect(search_data)}"
258+
end
259+
260+
defp extension_name(input) do
261+
input
262+
|> Path.extname()
263+
|> String.downcase()
264+
end
265+
266+
defp filename_to_title(input) do
267+
input |> Path.basename() |> Path.rootname()
268+
end
269+
270+
defp extra_type(".cheatmd"), do: :cheatmd
271+
defp extra_type(".livemd"), do: :livemd
272+
defp extra_type(_), do: :extra
273+
274+
@doc """
275+
Generate assets from configs with the given default assets.
276+
"""
277+
def generate_assets(namespace, defaults, %{output: output, assets: assets}) do
278+
namespaced_assets =
279+
if is_map(assets) do
280+
Enum.map(assets, fn {source, target} -> {source, Path.join(namespace, target)} end)
281+
else
282+
IO.warn("""
283+
giving a binary to :assets is deprecated, please give a map from source to target instead:
284+
285+
#{inspect(assets: %{assets => "assets"})}
286+
""")
287+
288+
[{assets, Path.join(namespace, "assets")}]
289+
end
290+
291+
Enum.flat_map(defaults ++ namespaced_assets, fn {dir_or_files, relative_target_dir} ->
292+
target_dir = Path.join(output, relative_target_dir)
293+
File.mkdir_p!(target_dir)
294+
295+
cond do
296+
is_list(dir_or_files) ->
297+
Enum.map(dir_or_files, fn {name, content} ->
298+
target = Path.join(target_dir, name)
299+
File.write(target, content)
300+
Path.relative_to(target, output)
301+
end)
302+
303+
is_binary(dir_or_files) and File.dir?(dir_or_files) ->
304+
dir_or_files
305+
|> File.cp_r!(target_dir, dereference_symlinks: true)
306+
|> Enum.reduce([], fn path, acc ->
307+
# Omit directories in .build file
308+
if File.dir?(path) do
309+
acc
310+
else
311+
[Path.relative_to(path, output) | acc]
312+
end
313+
end)
314+
|> Enum.reverse()
315+
316+
is_binary(dir_or_files) ->
317+
[]
318+
319+
true ->
320+
raise ":assets must be a map of source directories to target directories"
321+
end
322+
end)
323+
end
324+
325+
@doc """
326+
Generates the logo from config into the given directory.
327+
"""
328+
def generate_logo(_dir, %{logo: nil}) do
329+
[]
330+
end
331+
332+
def generate_logo(dir, %{output: output, logo: logo}) do
333+
generate_image(output, dir, logo, "logo")
334+
end
335+
336+
@doc """
337+
Generates the cover from config into the given directory.
338+
"""
339+
def generate_cover(_dir, %{cover: nil}) do
340+
[]
341+
end
342+
343+
def generate_cover(dir, %{output: output, cover: cover}) do
344+
generate_image(output, dir, cover, "cover")
345+
end
346+
347+
def generate_image(output, dir, image, name) do
348+
extname =
349+
image
350+
|> Path.extname()
351+
|> String.downcase()
352+
353+
if extname in ~w(.png .jpg .jpeg .svg) do
354+
filename = Path.join(dir, "#{name}#{extname}")
355+
target = Path.join(output, filename)
356+
File.mkdir_p!(Path.dirname(target))
357+
File.copy!(image, target)
358+
[filename]
359+
else
360+
raise ArgumentError, "image format not recognized, allowed formats are: .png, .jpg, .svg"
361+
end
362+
end
363+
end

0 commit comments

Comments
 (0)