Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/ex_doc/formatter/epub/templates.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ defmodule ExDoc.Formatter.EPUB.Templates do
defp render_doc(ast), do: ast && ExDoc.DocAST.to_string(ast)

@doc """
Generate content from the module template for a given `node`
Generate content from the module template for a given `node`.
"""
def module_page(config, module_node) do
module_template(config, module_node)
end

@doc """
Generated ID for static file
Generated ID for static file.
"""
def static_file_to_id(static_file) do
static_file |> Path.basename() |> text_to_id()
Expand Down
2 changes: 1 addition & 1 deletion lib/ex_doc/formatter/html/templates.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ defmodule ExDoc.Formatter.HTML.Templates do
]

@doc """
Generate content from the module template for a given `node`
Generate content from the module template for a given `node`.
"""
def module_page(module_node, config) do
module_template(config, module_node)
Expand Down
222 changes: 222 additions & 0 deletions lib/ex_doc/formatter/markdown.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
defmodule ExDoc.Formatter.MARKDOWN do
@moduledoc false

alias __MODULE__.{Templates}
alias ExDoc.Formatter
alias ExDoc.Utils

@doc """
Generates Markdown documentation for the given modules.
"""
@spec run([ExDoc.ModuleNode.t()], [ExDoc.ModuleNode.t()], ExDoc.Config.t()) :: String.t()
def run(project_nodes, filtered_modules, config) when is_map(config) do
Utils.unset_warned()

config = normalize_config(config)

build = Path.join(config.output, ".build")
output_setup(build, config)

extras = Formatter.build_extras(config, ".md")

project_nodes =
project_nodes
|> Formatter.render_all(filtered_modules, ".md", config, highlight_tag: "samp")

nodes_map = %{
modules: Formatter.filter_list(:module, project_nodes),
tasks: Formatter.filter_list(:task, project_nodes)
}

config = %{config | extras: extras}

all_files =
[generate_nav(config, nodes_map)] ++
generate_extras(config) ++
generate_list(config, nodes_map.modules) ++
generate_list(config, nodes_map.tasks) ++
[generate_llm_index(config, nodes_map)]

generate_build(List.flatten(all_files), build)
config.output |> Path.join("index.md") |> Path.relative_to_cwd()
end

defp normalize_config(config) do
output = Path.expand(config.output)
%{config | output: output}
end

defp output_setup(build, config) do
if File.exists?(build) do
build
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.map(&Path.join(config.output, &1))
|> Enum.each(&File.rm/1)

File.rm(build)
else
# Only remove markdown files, not HTML/EPUB files
File.mkdir_p!(config.output)

if File.exists?(config.output) do
config.output
|> Path.join("*.md")
|> Path.wildcard()
|> Enum.each(&File.rm/1)

llms_file = Path.join(config.output, "llms.txt")
if File.exists?(llms_file), do: File.rm(llms_file)
end
end
end

defp generate_build(files, build) do
entries =
files
|> Enum.uniq()
|> Enum.sort()
|> Enum.map(&[&1, "\n"])

File.mkdir_p!(Path.dirname(build))
File.write!(build, entries)
end

defp normalize_output(output) do
output
|> String.replace(~r/\r\n?/, "\n")
|> String.replace(~r/\n{3,}/, "\n\n")
end

defp generate_nav(config, nodes) do
nodes =
Map.update!(nodes, :modules, fn modules ->
modules |> Enum.chunk_by(& &1.group) |> Enum.map(&{hd(&1).group, &1})
end)

content =
Templates.nav_template(config, nodes)
|> normalize_output()

filename = "index.md"
File.write("#{config.output}/#{filename}", content)
filename
end

defp generate_extras(config) do
for {_title, extras} <- config.extras,
%{id: id, source: content} <- extras,
not is_map_key(%{id: id, source: content}, :url) do
filename = "#{id}.md"
output = "#{config.output}/#{filename}"

if File.regular?(output) do
Utils.warn("file #{Path.relative_to_cwd(output)} already exists", [])
end

File.write!(output, normalize_output(content))
filename
end
end

defp generate_list(config, nodes) do
nodes
|> Task.async_stream(&generate_module_page(&1, config), timeout: :infinity)
|> Enum.map(&elem(&1, 1))
end

## Helpers

defp generate_module_page(module_node, config) do
content =
Templates.module_page(config, module_node)
|> normalize_output()

filename = "#{module_node.id}.md"
File.write("#{config.output}/#{filename}", content)
filename
end

defp generate_llm_index(config, nodes_map) do
content = generate_llm_index_content(config, nodes_map)
filename = "llms.txt"
File.write("#{config.output}/#{filename}", content)
filename
end

defp generate_llm_index_content(config, nodes_map) do
project_info = """
# #{config.project} #{config.version}

#{config.project} documentation index for Large Language Models.

## Modules

"""

modules_info =
nodes_map.modules
|> Enum.map(fn module_node ->
"- [#{module_node.title}](#{module_node.id}.md): #{module_node.doc |> ExDoc.DocAST.synopsis() |> extract_plain_text()}"
end)
|> Enum.join("\n")

tasks_info =
if length(nodes_map.tasks) > 0 do
tasks_list =
nodes_map.tasks
|> Enum.map(fn task_node ->
"- [#{task_node.title}](#{task_node.id}.md): #{task_node.doc |> ExDoc.DocAST.synopsis() |> extract_plain_text()}"
end)
|> Enum.join("\n")

"\n\n## Mix Tasks\n\n" <> tasks_list
else
""
end

extras_info =
if is_list(config.extras) and length(config.extras) > 0 do
extras_list =
config.extras
|> Enum.flat_map(fn
{_group, extras} when is_list(extras) -> extras
_ -> []
end)
|> Enum.map(fn extra ->
"- [#{extra.title}](#{extra.id}.md)"
end)
|> Enum.join("\n")

if extras_list == "" do
""
else
"\n\n## Guides\n\n" <> extras_list
end
else
""
end

project_info <> modules_info <> tasks_info <> extras_info
end

defp extract_plain_text(html) when is_binary(html) do
html
|> String.replace(~r/<[^>]*>/, "")
|> String.replace(~r/\s+/, " ")
|> String.trim()
|> case do
"" ->
"No documentation available"

text ->
if String.length(text) > 150 do
String.slice(text, 0, 150) <> "..."
else
text
end
end
end

defp extract_plain_text(_), do: "No documentation available"
end
80 changes: 80 additions & 0 deletions lib/ex_doc/formatter/markdown/templates.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
defmodule ExDoc.Formatter.MARKDOWN.Templates do
@moduledoc false

require EEx

import ExDoc.Utils,
only: [before_closing_body_tag: 2, h: 1]

@doc """
Generate content from the module template for a given `node`.
"""
def module_page(config, module_node) do
docs =
for group <- module_node.docs_groups do
{group.title, group.docs}
end

module_template(config, module_node, docs)
end

@doc """
Formats the attribute type used to define the spec of the given `node`.
"""
def format_spec_attribute(module, node) do
module.language.format_spec_attribute(node)
end

@doc """
Returns the original markdown documentation from source_doc.
"""
def node_doc(%{source_doc: %{"en" => source}}) when is_binary(source), do: source
def node_doc(_), do: nil

@doc """
Creates a chapter which contains all the details about an individual module.
"""
EEx.function_from_file(
:def,
:module_template,
Path.expand("templates/module_template.eex", __DIR__),
[:config, :module, :docs],
trim: true
)

@doc """
Creates the table of contents.
"""
EEx.function_from_file(
:def,
:nav_template,
Path.expand("templates/nav_template.eex", __DIR__),
[:config, :nodes],
trim: true
)

EEx.function_from_file(
:defp,
:nav_item_template,
Path.expand("templates/nav_item_template.eex", __DIR__),
[:name, :nodes],
trim: true
)

EEx.function_from_file(
:defp,
:nav_grouped_item_template,
Path.expand("templates/nav_grouped_item_template.eex", __DIR__),
[:nodes],
trim: true
)

@doc false
EEx.function_from_file(
:def,
:detail_template,
Path.expand("templates/detail_template.eex", __DIR__),
[:node, :module],
trim: true
)
end
15 changes: 15 additions & 0 deletions lib/ex_doc/formatter/markdown/templates/detail_template.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# `<%= node.name %>`
<%= if node.source_url do %>[🔗](<%= node.source_url %>)<% end %>

<%= for annotation <- node.annotations do %>*<%= annotation %>* <% end %>
<%= if deprecated = node.deprecated do %>
> This <%= node.type %> is deprecated. <%= h(deprecated) %>.
<% end %>
<%= if node.specs != [] do %>
<%= for spec <- node.specs do %>
```elixir
<%= format_spec_attribute(module, node) %> <%= spec %>
```
<% end %>
<% end %>
<%= node_doc(node) %>
12 changes: 12 additions & 0 deletions lib/ex_doc/formatter/markdown/templates/module_template.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# `<%=h module.title %>`
<%= if module.source_url do %>[🔗](<%= module.source_url %>)<% end %>

<%= for annotation <- module.annotations do %>*<%= annotation %>* <% end %>
<%= if deprecated = module.deprecated do %>
> This <%= module.type %> is deprecated. <%=h deprecated %>.
<% end %>
<%= node_doc(module) %>
<%= for {_name, nodes} <- docs, node <- nodes do %>
<%= detail_template(node, module) %>
<% end %>
<%= before_closing_body_tag(config, :markdown) %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<%= for {title, nodes} <- nodes do %>
<%= if title do %>
- <%=h to_string(title) %>
<% end %>
<%= for node <- nodes do %>
- [<%=h node.title %>](<%= URI.encode node.id %>.md)
<% end %>
<% end %>
6 changes: 6 additions & 0 deletions lib/ex_doc/formatter/markdown/templates/nav_item_template.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<%= unless Enum.empty?(nodes) do %>
- <%= name %>
<%= for node <- nodes do %>
- [<%=h node.title %>](<%= URI.encode node.id %>.md)
<% end %>
<% end %>
9 changes: 9 additions & 0 deletions lib/ex_doc/formatter/markdown/templates/nav_template.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# <%= config.project %> v<%= config.version %> - Documentation - Table of Contents

<%= nav_grouped_item_template config.extras %>
<%= unless Enum.empty?(nodes.modules) do %>
## Modules
<%= nav_grouped_item_template nodes.modules %>
<% end %>
<%= nav_item_template "Mix Tasks", nodes.tasks %>
<%= before_closing_body_tag(config, :markdown) %>
Loading