|
| 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 | + build = Path.join(config.output, ".build-markdown") |
| 17 | + output_setup(build, config) |
| 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 | + all_files = |
| 33 | + [generate_nav(config, nodes_map)] ++ |
| 34 | + generate_extras(config) ++ |
| 35 | + generate_list(config, nodes_map.modules) ++ |
| 36 | + generate_list(config, nodes_map.tasks) ++ |
| 37 | + [generate_llm_index(config, nodes_map)] |
| 38 | + |
| 39 | + generate_build(List.flatten(all_files), build) |
| 40 | + config.output |> Path.join("index.md") |> Path.relative_to_cwd() |
| 41 | + end |
| 42 | + |
| 43 | + defp normalize_config(config) do |
| 44 | + output = Path.expand(config.output) |
| 45 | + %{config | output: output} |
| 46 | + end |
| 47 | + |
| 48 | + defp output_setup(build, config) do |
| 49 | + if File.exists?(build) do |
| 50 | + build |
| 51 | + |> File.read!() |
| 52 | + |> String.split("\n", trim: true) |
| 53 | + |> Enum.map(&Path.join(config.output, &1)) |
| 54 | + |> Enum.each(&File.rm/1) |
| 55 | + |
| 56 | + File.rm(build) |
| 57 | + else |
| 58 | + # Only remove markdown files, not HTML/EPUB files |
| 59 | + File.mkdir_p!(config.output) |
| 60 | + |
| 61 | + if File.exists?(config.output) do |
| 62 | + config.output |
| 63 | + |> Path.join("*.md") |
| 64 | + |> Path.wildcard() |
| 65 | + |> Enum.each(&File.rm/1) |
| 66 | + |
| 67 | + llms_file = Path.join(config.output, "llms.txt") |
| 68 | + if File.exists?(llms_file), do: File.rm(llms_file) |
| 69 | + end |
| 70 | + end |
| 71 | + end |
| 72 | + |
| 73 | + defp generate_build(files, build) do |
| 74 | + entries = |
| 75 | + files |
| 76 | + |> Enum.uniq() |
| 77 | + |> Enum.sort() |
| 78 | + |> Enum.map(&[&1, "\n"]) |
| 79 | + |
| 80 | + File.write!(build, entries) |
| 81 | + end |
| 82 | + |
| 83 | + defp normalize_output(output) do |
| 84 | + output |
| 85 | + |> String.replace(~r/\r\n?/, "\n") |
| 86 | + |> String.replace(~r/\n{3,}/, "\n\n") |
| 87 | + end |
| 88 | + |
| 89 | + defp generate_nav(config, nodes) do |
| 90 | + nodes = |
| 91 | + Map.update!(nodes, :modules, fn modules -> |
| 92 | + modules |> Enum.chunk_by(& &1.group) |> Enum.map(&{hd(&1).group, &1}) |
| 93 | + end) |
| 94 | + |
| 95 | + content = |
| 96 | + Templates.nav_template(config, nodes) |
| 97 | + |> normalize_output() |
| 98 | + |
| 99 | + filename = "index.md" |
| 100 | + File.write("#{config.output}/#{filename}", content) |
| 101 | + filename |
| 102 | + end |
| 103 | + |
| 104 | + defp generate_extras(config) do |
| 105 | + for {_title, extras} <- config.extras, |
| 106 | + %{id: id, source: content} <- extras, |
| 107 | + not is_map_key(%{id: id, source: content}, :url) do |
| 108 | + filename = "#{id}.md" |
| 109 | + output = "#{config.output}/#{filename}" |
| 110 | + |
| 111 | + if File.regular?(output) do |
| 112 | + Utils.warn("file #{Path.relative_to_cwd(output)} already exists", []) |
| 113 | + end |
| 114 | + |
| 115 | + File.write!(output, normalize_output(content)) |
| 116 | + filename |
| 117 | + end |
| 118 | + end |
| 119 | + |
| 120 | + defp generate_list(config, nodes) do |
| 121 | + nodes |
| 122 | + |> Task.async_stream(&generate_module_page(&1, config), timeout: :infinity) |
| 123 | + |> Enum.map(&elem(&1, 1)) |
| 124 | + end |
| 125 | + |
| 126 | + ## Helpers |
| 127 | + |
| 128 | + defp generate_module_page(module_node, config) do |
| 129 | + content = |
| 130 | + Templates.module_page(config, module_node) |
| 131 | + |> normalize_output() |
| 132 | + |
| 133 | + filename = "#{module_node.id}.md" |
| 134 | + File.write("#{config.output}/#{filename}", content) |
| 135 | + filename |
| 136 | + end |
| 137 | + |
| 138 | + defp generate_llm_index(config, nodes_map) do |
| 139 | + content = generate_llm_index_content(config, nodes_map) |
| 140 | + filename = "llms.txt" |
| 141 | + File.write("#{config.output}/#{filename}", content) |
| 142 | + filename |
| 143 | + end |
| 144 | + |
| 145 | + defp generate_llm_index_content(config, nodes_map) do |
| 146 | + project_info = """ |
| 147 | + # #{config.project} #{config.version} |
| 148 | +
|
| 149 | + #{config.project} documentation index for Large Language Models. |
| 150 | +
|
| 151 | + ## Modules |
| 152 | +
|
| 153 | + """ |
| 154 | + |
| 155 | + modules_info = |
| 156 | + nodes_map.modules |
| 157 | + |> Enum.map(fn module_node -> |
| 158 | + "- **#{module_node.title}** (#{module_node.id}.md): #{module_node.doc |> ExDoc.DocAST.synopsis() |> extract_plain_text()}" |
| 159 | + end) |
| 160 | + |> Enum.join("\n") |
| 161 | + |
| 162 | + tasks_info = |
| 163 | + if length(nodes_map.tasks) > 0 do |
| 164 | + tasks_list = |
| 165 | + nodes_map.tasks |
| 166 | + |> Enum.map(fn task_node -> |
| 167 | + "- **#{task_node.title}** (#{task_node.id}.md): #{task_node.doc |> ExDoc.DocAST.synopsis() |> extract_plain_text()}" |
| 168 | + end) |
| 169 | + |> Enum.join("\n") |
| 170 | + |
| 171 | + "\n\n## Mix Tasks\n\n" <> tasks_list |
| 172 | + else |
| 173 | + "" |
| 174 | + end |
| 175 | + |
| 176 | + extras_info = |
| 177 | + if is_list(config.extras) and length(config.extras) > 0 do |
| 178 | + extras_list = |
| 179 | + config.extras |
| 180 | + |> Enum.flat_map(fn |
| 181 | + {_group, extras} when is_list(extras) -> extras |
| 182 | + _ -> [] |
| 183 | + end) |
| 184 | + |> Enum.map(fn extra -> |
| 185 | + "- **#{extra.title}** (#{extra.id}.md): #{extra.title}" |
| 186 | + end) |
| 187 | + |> Enum.join("\n") |
| 188 | + |
| 189 | + if extras_list == "" do |
| 190 | + "" |
| 191 | + else |
| 192 | + "\n\n## Guides\n\n" <> extras_list |
| 193 | + end |
| 194 | + else |
| 195 | + "" |
| 196 | + end |
| 197 | + |
| 198 | + project_info <> modules_info <> tasks_info <> extras_info |
| 199 | + end |
| 200 | + |
| 201 | + defp extract_plain_text(html) when is_binary(html) do |
| 202 | + html |
| 203 | + |> String.replace(~r/<[^>]*>/, "") |
| 204 | + |> String.replace(~r/\s+/, " ") |
| 205 | + |> String.trim() |
| 206 | + |> case do |
| 207 | + "" -> |
| 208 | + "No documentation available" |
| 209 | + |
| 210 | + text -> |
| 211 | + if String.length(text) > 150 do |
| 212 | + String.slice(text, 0, 150) <> "..." |
| 213 | + else |
| 214 | + text |
| 215 | + end |
| 216 | + end |
| 217 | + end |
| 218 | + |
| 219 | + defp extract_plain_text(_), do: "No documentation available" |
| 220 | +end |
0 commit comments