Skip to content

Commit 449525d

Browse files
garazdawiclaude
andcommitted
Add man page formatter backend
Generate roff-formatted man pages from documentation AST, following OTP man page conventions. Modules get section 3, mix tasks get section 1, and extra pages get section 7 (configurable via man_section option). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0183a7f commit 449525d

10 files changed

Lines changed: 668 additions & 3 deletions

File tree

lib/ex_doc.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ defmodule ExDoc do
103103
104104
* `:formatters` - Formatter to use; default: ["html", "markdown", "epub"], options: "html", "markdown", "epub".
105105
106+
* `:man` - Controls which modules get man pages. `:all` (default) generates for all modules,
107+
`:tasks` generates only for mix tasks (section 1), `false` skips module man pages entirely.
108+
Extra pages only get man pages when their `:man_section` option is explicitly set.
109+
106110
* `:footer` - When false, does not render the footer on all pages, except for
107111
the required "Built with ExDoc" note.
108112

lib/ex_doc/extras.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ defmodule ExDoc.Extras do
9595
source_url = config.source_url_pattern.(source_path, 1)
9696
search_data = validate_search_data!(input_options[:search_data])
9797

98+
options = Map.take(input_options, [:man_section])
99+
98100
%ExDoc.ExtraNode{
99101
type: extra_type(extension),
100102
source_doc: source,
@@ -105,7 +107,8 @@ defmodule ExDoc.Extras do
105107
source_url: source_url,
106108
search_data: search_data,
107109
title: title,
108-
title_doc: title_doc || title
110+
title_doc: title_doc || title,
111+
options: options
109112
}
110113
end
111114

lib/ex_doc/formatter/config.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ defmodule ExDoc.Formatter.Config do
3131
extra_section: "Pages",
3232
footer: true,
3333
formatters: [],
34+
man: :all,
3435
search: [%{name: "Default", help: "In-browser search", url: "search.html?q="}],
3536
homepage_url: nil,
3637
source_url: nil,
@@ -141,6 +142,7 @@ defmodule ExDoc.Formatter.Config do
141142
:footer,
142143
:formatters,
143144
:homepage_url,
145+
:man,
144146
:source_url,
145147
:language,
146148
:authors,

lib/ex_doc/formatter/man.ex

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
defmodule ExDoc.Formatter.MAN do
2+
@moduledoc false
3+
4+
alias __MODULE__.Templates
5+
6+
def run(config, project_nodes, extras) when is_map(config) do
7+
File.mkdir_p!(config.output)
8+
9+
{modules, tasks} =
10+
project_nodes
11+
|> Enum.filter(&(&1.source_format == "text/markdown"))
12+
|> Enum.split_with(&(&1.type != :task))
13+
14+
all_files =
15+
case config.man do
16+
:all -> generate_list(config, modules) ++ generate_list(config, tasks)
17+
:tasks -> generate_list(config, tasks)
18+
false -> []
19+
end ++ generate_extras(config, extras)
20+
21+
all_files = List.flatten(all_files)
22+
entrypoint = man_entrypoint(config, all_files)
23+
%{entrypoint: entrypoint, build: all_files}
24+
end
25+
26+
defp man_entrypoint(_config, []), do: ""
27+
28+
defp man_entrypoint(config, [first | _]) do
29+
config.output |> Path.join(first) |> Path.relative_to_cwd()
30+
end
31+
32+
defp generate_list(config, nodes) do
33+
nodes
34+
|> Task.async_stream(&generate_module(config, &1), timeout: :infinity)
35+
|> Enum.map(&elem(&1, 1))
36+
end
37+
38+
defp generate_module(config, module_node) do
39+
section = if module_node.type == :task, do: "1", else: "3"
40+
filename = "#{module_node.id}.#{section}"
41+
42+
config
43+
|> Templates.module_template(module_node)
44+
|> normalize_output()
45+
|> then(&write!(config, filename, &1))
46+
47+
filename
48+
end
49+
50+
defp generate_extras(config, extras) do
51+
for %ExDoc.ExtraNode{} = extra <- extras,
52+
section = extra.options[:man_section],
53+
section != nil do
54+
section = to_string(section)
55+
filename = "#{extra.id}.#{section}"
56+
57+
config
58+
|> Templates.extra_template(extra, section)
59+
|> normalize_output()
60+
|> then(&write!(config, filename, &1))
61+
62+
filename
63+
end
64+
end
65+
66+
defp normalize_output(output) do
67+
output
68+
|> String.replace("\r\n", "\n")
69+
|> String.replace(~r/\n{3,}/, "\n\n")
70+
end
71+
72+
defp write!(config, filename, content) do
73+
config.output
74+
|> Path.join(filename)
75+
|> File.write!(content)
76+
end
77+
end
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
defmodule ExDoc.Formatter.MAN.Templates do
2+
@moduledoc false
3+
4+
require EEx
5+
6+
@doc """
7+
Converts a doc AST node to roff-formatted text.
8+
"""
9+
def doc_to_roff(nil), do: ""
10+
def doc_to_roff(ast) when is_list(ast), do: Enum.map_join(ast, &doc_to_roff/1)
11+
def doc_to_roff(binary) when is_binary(binary), do: binary |> strip_prefix() |> escape()
12+
13+
def doc_to_roff({:comment, _attrs, _inner, _meta}), do: ""
14+
15+
def doc_to_roff({:p, _attrs, inner, _meta}) do
16+
text = inner |> inline() |> String.replace(~r/\n{2,}/, "\n")
17+
".PP\n#{text}\n"
18+
end
19+
20+
def doc_to_roff({tag, _attrs, inner, _meta})
21+
when tag in [:h1, :h2, :h3, :h4, :h5, :h6] do
22+
".SS \"#{inline(inner)}\"\n"
23+
end
24+
25+
def doc_to_roff({:pre, _attrs, [{:code, _, [code], _}], _meta}) when is_binary(code) do
26+
".IP\n.nf\n#{escape(code)}\n.fi\n"
27+
end
28+
29+
def doc_to_roff({:pre, _attrs, inner, _meta}) do
30+
".IP\n.nf\n#{inline(inner)}\n.fi\n"
31+
end
32+
33+
def doc_to_roff({:blockquote, _attrs, inner, _meta}) do
34+
".RS 4\n#{doc_to_roff(inner)}.RE\n"
35+
end
36+
37+
def doc_to_roff({:ul, _attrs, items, _meta}) do
38+
Enum.map_join(items, &render_li/1)
39+
end
40+
41+
def doc_to_roff({:ol, _attrs, items, _meta}) do
42+
items
43+
|> Enum.with_index(1)
44+
|> Enum.map_join(fn {{:li, _attrs, inner, _meta}, idx} ->
45+
".IP #{idx}. 3\n#{render_li_body(inner)}"
46+
end)
47+
end
48+
49+
def doc_to_roff({:dl, _attrs, inner, _meta}) do
50+
Enum.map_join(inner, fn
51+
{:dt, _attrs, inner, _meta} -> ".TP\n\\fB#{inline(inner)}\\fR\n"
52+
{:dd, _attrs, inner, _meta} -> doc_to_roff(inner)
53+
end)
54+
end
55+
56+
def doc_to_roff({:table, _attrs, inner, _meta}), do: doc_to_roff(inner)
57+
def doc_to_roff({:thead, _attrs, inner, _meta}), do: doc_to_roff(inner)
58+
def doc_to_roff({:tbody, _attrs, inner, _meta}), do: doc_to_roff(inner)
59+
60+
def doc_to_roff({:tr, _attrs, cells, _meta}) do
61+
text = Enum.map_join(cells, " | ", fn {_tag, _attrs, inner, _meta} -> inline(inner) end)
62+
".PP\n#{text}\n"
63+
end
64+
65+
def doc_to_roff({:hr, _attrs, _inner, _meta}), do: ""
66+
def doc_to_roff({:section, _attrs, inner, _meta}), do: doc_to_roff(inner)
67+
def doc_to_roff({_tag, _attrs, inner, _meta}), do: doc_to_roff(inner)
68+
69+
## List items
70+
71+
defp render_li({:li, _attrs, inner, _meta}) do
72+
case detect_deflist(inner) do
73+
{:deflist, tag, rest} ->
74+
"\n#{escape(tag)}\n.RS 2\n#{render_li_body(rest)}.RE\n"
75+
76+
:regular ->
77+
".IP \\(bu 2\n#{render_li_body(inner)}"
78+
end
79+
end
80+
81+
# Detects definition list items: `tag` \- desc or **`tag`** - desc.
82+
# Returns {:deflist, tag, rest} or :regular.
83+
defp detect_deflist([{:p, p_attrs, [head | rest], p_meta} | tail]) do
84+
with {:ok, tag} <- extract_code_tag(head),
85+
stripped when stripped != rest <- strip_dash_prefix(rest) do
86+
{:deflist, tag, [{:p, p_attrs, stripped, p_meta} | tail]}
87+
else
88+
_ -> :regular
89+
end
90+
end
91+
92+
defp detect_deflist([head | rest]) do
93+
with {:ok, tag} <- extract_code_tag(head),
94+
stripped when stripped != rest <- strip_dash_prefix(rest) do
95+
{:deflist, tag, stripped}
96+
else
97+
_ -> :regular
98+
end
99+
end
100+
101+
defp detect_deflist(_), do: :regular
102+
103+
defp extract_code_tag({:strong, _, [{:code, _, [tag], _}], _}) when is_binary(tag),
104+
do: {:ok, tag}
105+
106+
defp extract_code_tag({:code, _, [tag], _}) when is_binary(tag), do: {:ok, tag}
107+
defp extract_code_tag(_), do: :error
108+
109+
defp strip_dash_prefix([text | rest]) when is_binary(text) do
110+
case String.replace(text, ~r/^\s*\\?-\s*/, "", global: false) do
111+
"" -> rest
112+
trimmed -> [trimmed | rest]
113+
end
114+
end
115+
116+
defp strip_dash_prefix(content), do: content
117+
118+
defp render_li_body(inner) when is_list(inner) do
119+
{body, _} =
120+
Enum.map_reduce(inner, true, fn
121+
binary, first? when is_binary(binary) -> {inline(binary), first?}
122+
{:p, _attrs, content, _meta}, true -> {inline(content), false}
123+
{:p, _attrs, content, _meta}, false -> {".PP\n#{inline(content)}", false}
124+
other, _first? -> {doc_to_roff(other), false}
125+
end)
126+
127+
body = body |> IO.iodata_to_binary() |> String.replace(~r/\n{2,}/, "\n")
128+
if String.ends_with?(body, "\n"), do: body, else: body <> "\n"
129+
end
130+
131+
## Inline rendering
132+
133+
@doc """
134+
Renders inline AST content to roff.
135+
"""
136+
def inline(list) when is_list(list), do: Enum.map_join(list, &inline/1)
137+
def inline(binary) when is_binary(binary), do: binary |> strip_prefix() |> escape()
138+
139+
def inline({:comment, _attrs, _inner, _meta}), do: ""
140+
141+
def inline({:code, _attrs, [code], _meta}) when is_binary(code),
142+
do: "\\fI#{code |> strip_prefix() |> escape()}\\fR"
143+
144+
def inline({:strong, _attrs, inner, _meta}), do: "\\fB#{inline(inner)}\\fR"
145+
def inline({:b, _attrs, inner, _meta}), do: "\\fB#{inline(inner)}\\fR"
146+
def inline({:em, _attrs, inner, _meta}), do: "\\fI#{inline(inner)}\\fR"
147+
def inline({:i, _attrs, inner, _meta}), do: "\\fI#{inline(inner)}\\fR"
148+
def inline({:br, _attrs, _inner, _meta}), do: "\n.br\n"
149+
def inline({:a, _attrs, inner, _meta}), do: inline(inner)
150+
def inline({_tag, _attrs, inner, _meta}), do: inline(inner)
151+
152+
## Text processing
153+
154+
# Strips m:, t:, c: doc reference prefixes.
155+
# At start of text (code nodes) or at word boundaries (plain text after autolinking).
156+
defp strip_prefix(text) do
157+
String.replace(text, ~r/\b[mtc]:(\w)/, "\\1")
158+
end
159+
160+
@doc """
161+
Formats a spec for man page output.
162+
Wraps each line individually in bold.
163+
"""
164+
def format_spec(spec_text) do
165+
spec_text
166+
|> escape()
167+
|> String.split("\n")
168+
|> Enum.map_join("\n", &"\\fB#{&1}\\fR")
169+
end
170+
171+
@doc """
172+
Escapes text for roff output.
173+
"""
174+
def escape(text) do
175+
String.replace(text, "\\", "\\\\")
176+
end
177+
178+
@doc """
179+
Extracts a short synopsis from the doc AST for the NAME section.
180+
"""
181+
def synopsis_text(nil), do: nil
182+
183+
def synopsis_text(ast) when is_list(ast) do
184+
Enum.find_value(ast, fn
185+
{:comment, _, _, _} -> nil
186+
node -> synopsis_text(node)
187+
end)
188+
end
189+
190+
def synopsis_text({:p, _attrs, inner, _meta}) do
191+
inner
192+
|> ExDoc.DocAST.text()
193+
|> String.replace("\n", " ")
194+
|> String.split(~r/\.(\s|$)/, parts: 2)
195+
|> hd()
196+
|> String.trim()
197+
end
198+
199+
def synopsis_text(_), do: nil
200+
201+
@doc """
202+
Returns the section title for a doc group, uppercased for .SH headers.
203+
"""
204+
def group_section_title("Types"), do: "DATA TYPES"
205+
def group_section_title(title) when is_atom(title), do: group_section_title(to_string(title))
206+
def group_section_title(title) when is_binary(title), do: String.upcase(title)
207+
208+
## EEx templates
209+
210+
EEx.function_from_file(
211+
:def,
212+
:module_template,
213+
Path.expand("templates/module_template.eex", __DIR__),
214+
[:config, :module],
215+
trim: true
216+
)
217+
218+
EEx.function_from_file(
219+
:def,
220+
:extra_template,
221+
Path.expand("templates/extra_template.eex", __DIR__),
222+
[:config, :extra, :section],
223+
trim: true
224+
)
225+
226+
EEx.function_from_file(
227+
:defp,
228+
:detail_template,
229+
Path.expand("templates/detail_template.eex", __DIR__),
230+
[:node, :module],
231+
trim: true
232+
)
233+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
.LP
2+
.B
3+
<%= if node.source_specs != [] do %><%= for spec <- node.source_specs do %><%= format_spec(module.language.format_spec(spec)) %>
4+
<% end %><% else %>\fB<%= escape(to_string(node.name)) %>\fR
5+
<% end %>
6+
.br
7+
.RS
8+
<%= for annotation <- node.annotations do %>\fI<%= escape(to_string(annotation)) %>\fR
9+
<% end %>
10+
<%= if node.deprecated do %>.PP
11+
\fBDeprecated:\fR <%= escape(to_string(node.deprecated)) %>
12+
<% end %>
13+
<%= doc_to_roff(node.doc) %>
14+
15+
.RE
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.TH <%= extra.id %> <%= section %> "<%= config.project %> v<%= config.version %>" "<%= config.project %>"
2+
.SH NAME
3+
<%= escape(extra.title) %><%= if synopsis = synopsis_text(extra.doc) do %> \- <%= escape(synopsis) %><% end %>
4+
<%= if extra.doc do %>
5+
.SH DESCRIPTION
6+
<%= doc_to_roff(extra.doc) %>
7+
<% end %>

0 commit comments

Comments
 (0)