|
| 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