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