html.ex (16055B)
1 defmodule ExDoc.Formatter.HTML do 2 @moduledoc false 3 4 alias __MODULE__.{Assets, Templates, SearchItems} 5 alias ExDoc.{Markdown, GroupMatcher, Utils} 6 7 @main "api-reference" 8 @assets_dir "assets" 9 10 @doc """ 11 Generate HTML documentation for the given modules. 12 """ 13 @spec run(list, ExDoc.Config.t()) :: String.t() 14 def run(project_nodes, config) when is_map(config) do 15 config = normalize_config(config) 16 config = %{config | output: Path.expand(config.output)} 17 18 build = Path.join(config.output, ".build") 19 output_setup(build, config) 20 21 project_nodes = render_all(project_nodes, ".html", config, []) 22 extras = build_extras(config, ".html") 23 24 # Generate search early on without api reference in extras 25 static_files = generate_assets(config, @assets_dir, default_assets(config)) 26 search_items = generate_search_items(project_nodes, extras, config) 27 28 nodes_map = %{ 29 modules: filter_list(:module, project_nodes), 30 tasks: filter_list(:task, project_nodes) 31 } 32 33 extras = 34 if config.api_reference do 35 [build_api_reference(nodes_map, config) | extras] 36 else 37 extras 38 end 39 40 all_files = 41 search_items ++ 42 static_files ++ 43 generate_sidebar_items(nodes_map, extras, config) ++ 44 generate_extras(nodes_map, extras, config) ++ 45 generate_logo(@assets_dir, config) ++ 46 generate_search(nodes_map, config) ++ 47 generate_not_found(nodes_map, config) ++ 48 generate_list(nodes_map.modules, nodes_map, config) ++ 49 generate_list(nodes_map.tasks, nodes_map, config) ++ generate_index(config) 50 51 generate_build(Enum.sort(all_files), build) 52 config.output |> Path.join("index.html") |> Path.relative_to_cwd() 53 end 54 55 defp normalize_config(%{main: "index"}) do 56 raise ArgumentError, 57 message: ~S("main" cannot be set to "index", otherwise it will recursively link to itself) 58 end 59 60 defp normalize_config(%{main: main} = config) do 61 %{config | main: main || @main} 62 end 63 64 @doc """ 65 Autolinks and renders all docs. 66 """ 67 def render_all(project_nodes, ext, config, opts) do 68 base = [ 69 apps: config.apps, 70 deps: config.deps, 71 ext: ext, 72 extras: extra_paths(config), 73 skip_undefined_reference_warnings_on: config.skip_undefined_reference_warnings_on 74 ] 75 76 project_nodes 77 |> Task.async_stream( 78 fn node -> 79 autolink_opts = 80 [ 81 current_module: node.module, 82 file: node.source_path, 83 line: node.doc_line, 84 module_id: node.id 85 ] ++ base 86 87 language = node.language 88 89 docs = 90 for child_node <- node.docs do 91 id = id(node, child_node) 92 autolink_opts = autolink_opts ++ [id: id, line: child_node.doc_line] 93 specs = Enum.map(child_node.specs, &language.autolink_spec(&1, autolink_opts)) 94 child_node = %{child_node | specs: specs} 95 render_doc(child_node, language, autolink_opts, opts) 96 end 97 98 typespecs = 99 for child_node <- node.typespecs do 100 id = id(node, child_node) 101 autolink_opts = autolink_opts ++ [id: id, line: child_node.doc_line] 102 103 child_node = %{ 104 child_node 105 | spec: language.autolink_spec(child_node.spec, autolink_opts) 106 } 107 108 render_doc(child_node, language, autolink_opts, opts) 109 end 110 111 %{ 112 render_doc(node, language, [{:id, node.id} | autolink_opts], opts) 113 | docs: docs, 114 typespecs: typespecs 115 } 116 end, 117 timeout: :infinity 118 ) 119 |> Enum.map(&elem(&1, 1)) 120 end 121 122 defp render_doc(%{doc: nil} = node, _language, _autolink_opts, _opts), 123 do: node 124 125 defp render_doc(%{doc: doc} = node, language, autolink_opts, opts) do 126 rendered = autolink_and_render(doc, language, autolink_opts, opts) 127 %{node | rendered_doc: rendered} 128 end 129 130 defp id(%{id: mod_id}, %{id: "c:" <> id}) do 131 "c:" <> mod_id <> "." <> id 132 end 133 134 defp id(%{id: mod_id}, %{id: "t:" <> id}) do 135 "t:" <> mod_id <> "." <> id 136 end 137 138 defp id(%{id: mod_id}, %{id: id}) do 139 mod_id <> "." <> id 140 end 141 142 defp autolink_and_render(doc, language, autolink_opts, opts) do 143 doc 144 |> language.autolink_doc(autolink_opts) 145 |> ExDoc.DocAST.to_string() 146 |> ExDoc.DocAST.highlight(language, opts) 147 end 148 149 defp output_setup(build, config) do 150 if File.exists?(build) do 151 build 152 |> File.read!() 153 |> String.split("\n", trim: true) 154 |> Enum.map(&Path.join(config.output, &1)) 155 |> Enum.each(&File.rm/1) 156 157 File.rm(build) 158 else 159 File.rm_rf!(config.output) 160 File.mkdir_p!(config.output) 161 end 162 end 163 164 defp generate_build(files, build) do 165 entries = Enum.map(files, &[&1, "\n"]) 166 File.write!(build, entries) 167 end 168 169 defp generate_index(config) do 170 index_file = "index.html" 171 main_file = "#{config.main}.html" 172 generate_redirect(index_file, config, main_file) 173 [index_file] 174 end 175 176 defp generate_not_found(nodes_map, config) do 177 filename = "404.html" 178 config = set_canonical_url(config, filename) 179 content = Templates.not_found_template(config, nodes_map) 180 File.write!("#{config.output}/#{filename}", content) 181 [filename] 182 end 183 184 defp generate_search(nodes_map, config) do 185 filename = "search.html" 186 config = set_canonical_url(config, filename) 187 content = Templates.search_template(config, nodes_map) 188 File.write!("#{config.output}/#{filename}", content) 189 [filename] 190 end 191 192 defp generate_sidebar_items(nodes_map, extras, config) do 193 content = Templates.create_sidebar_items(nodes_map, extras) 194 sidebar_items = "dist/sidebar_items-#{digest(content)}.js" 195 File.write!(Path.join(config.output, sidebar_items), content) 196 [sidebar_items] 197 end 198 199 defp generate_search_items(linked, extras, config) do 200 content = SearchItems.create(linked, extras) 201 search_items = "dist/search_items-#{digest(content)}.js" 202 File.write!(Path.join(config.output, search_items), content) 203 [search_items] 204 end 205 206 defp digest(content) do 207 content 208 |> :erlang.md5() 209 |> Base.encode16(case: :upper) 210 |> binary_part(0, 8) 211 end 212 213 defp generate_extras(nodes_map, extras, config) do 214 generated_extras = 215 extras 216 |> with_prev_next() 217 |> Enum.map(fn {node, prev, next} -> 218 filename = "#{node.id}.html" 219 output = "#{config.output}/#{filename}" 220 config = set_canonical_url(config, filename) 221 222 refs = %{ 223 prev: prev && %{path: "#{prev.id}.html", title: prev.title}, 224 next: next && %{path: "#{next.id}.html", title: next.title} 225 } 226 227 extension = node.source_path && Path.extname(node.source_path) 228 html = Templates.extra_template(config, node, extra_type(extension), nodes_map, refs) 229 230 if File.regular?(output) do 231 IO.puts(:stderr, "warning: file #{Path.relative_to_cwd(output)} already exists") 232 end 233 234 File.write!(output, html) 235 filename 236 end) 237 238 generated_extras ++ copy_extras(config, extras) 239 end 240 241 defp extra_type(".cheatmd"), do: :cheatmd 242 defp extra_type(".livemd"), do: :livemd 243 defp extra_type(_), do: :extra 244 245 defp copy_extras(config, extras) do 246 for %{source_path: source_path, id: id} when source_path != nil <- extras, 247 ext = extension_name(source_path), 248 ext == ".livemd" do 249 output = "#{config.output}/#{id}#{ext}" 250 251 File.copy!(source_path, output) 252 253 output 254 end 255 end 256 257 defp with_prev_next([]), do: [] 258 259 defp with_prev_next([head | tail]) do 260 Enum.zip([[head | tail], [nil, head | tail], tail ++ [nil]]) 261 end 262 263 @doc """ 264 Generate assets from configs with the given default assets. 265 """ 266 def generate_assets(config, assets_dir, defaults) do 267 write_default_assets(config, defaults) ++ copy_assets(config, assets_dir) 268 end 269 270 defp copy_assets(config, assets_dir) do 271 if path = config.assets do 272 path 273 |> Path.join("**/*") 274 |> Path.wildcard() 275 |> Enum.map(fn source -> 276 filename = Path.join(assets_dir, Path.relative_to(source, path)) 277 target = Path.join(config.output, filename) 278 File.mkdir(Path.dirname(target)) 279 File.copy(source, target) 280 filename 281 end) 282 else 283 [] 284 end 285 end 286 287 defp write_default_assets(config, sources) do 288 Enum.flat_map(sources, fn {files, dir} -> 289 target_dir = Path.join(config.output, dir) 290 File.mkdir_p!(target_dir) 291 292 Enum.map(files, fn {name, content} -> 293 target = Path.join(target_dir, name) 294 File.write(target, content) 295 Path.relative_to(target, config.output) 296 end) 297 end) 298 end 299 300 defp default_assets(config) do 301 [ 302 {Assets.dist(config.proglang), "dist"}, 303 {Assets.fonts(), "dist"} 304 ] 305 end 306 307 defp build_api_reference(nodes_map, config) do 308 api_reference = Templates.api_reference_template(nodes_map) 309 310 title_content = 311 ~s{API Reference <small class="app-vsn">#{config.project} v#{config.version}</small>} 312 313 %{ 314 content: api_reference, 315 group: nil, 316 id: "api-reference", 317 source_path: nil, 318 source_url: nil, 319 title: "API Reference", 320 title_content: title_content 321 } 322 end 323 324 @doc """ 325 Builds extra nodes by normalizing the config entries. 326 """ 327 def build_extras(config, ext) do 328 groups = config.groups_for_extras 329 source_url_pattern = config.source_url_pattern 330 331 autolink_opts = [ 332 apps: config.apps, 333 deps: config.deps, 334 ext: ext, 335 extras: extra_paths(config), 336 skip_undefined_reference_warnings_on: config.skip_undefined_reference_warnings_on 337 ] 338 339 config.extras 340 |> Task.async_stream( 341 &build_extra(&1, groups, autolink_opts, source_url_pattern), 342 timeout: :infinity 343 ) 344 |> Enum.map(&elem(&1, 1)) 345 |> Enum.sort_by(fn extra -> GroupMatcher.group_index(groups, extra.group) end) 346 end 347 348 defp build_extra({input, options}, groups, autolink_opts, source_url_pattern) do 349 input = to_string(input) 350 id = options[:filename] || input |> filename_to_title() |> text_to_id() 351 build_extra(input, id, options[:title], groups, autolink_opts, source_url_pattern) 352 end 353 354 defp build_extra(input, groups, autolink_opts, source_url_pattern) do 355 id = input |> filename_to_title() |> text_to_id() 356 build_extra(input, id, nil, groups, autolink_opts, source_url_pattern) 357 end 358 359 defp build_extra(input, id, title, groups, autolink_opts, source_url_pattern) do 360 opts = [file: input, line: 1] 361 362 ast = 363 case extension_name(input) do 364 extension when extension in ["", ".txt"] -> 365 [{:pre, [], "\n" <> File.read!(input), %{}}] 366 367 extension when extension in [".md", ".livemd", ".cheatmd"] -> 368 input 369 |> File.read!() 370 |> Markdown.to_ast(opts) 371 |> sectionize(extension) 372 373 _ -> 374 raise ArgumentError, 375 "file extension not recognized, allowed extension is either .cheatmd, .livemd, .md, .txt or no extension" 376 end 377 378 {title_ast, ast} = 379 case ExDoc.DocAST.extract_title(ast) do 380 {:ok, title_ast, ast} -> {title_ast, ast} 381 :error -> {nil, ast} 382 end 383 384 title_text = title_ast && ExDoc.DocAST.text_from_ast(title_ast) 385 title_html = title_ast && ExDoc.DocAST.to_string(title_ast) 386 387 # TODO: don't hardcode Elixir for extras? 388 language = ExDoc.Language.Elixir 389 content_html = autolink_and_render(ast, language, [file: input] ++ autolink_opts, opts) 390 391 group = GroupMatcher.match_extra(groups, input) 392 title = title || title_text || filename_to_title(input) 393 394 source_path = input |> Path.relative_to(File.cwd!()) |> String.replace_leading("./", "") 395 396 source_url = Utils.source_url_pattern(source_url_pattern, source_path, 1) 397 398 %{ 399 content: content_html, 400 group: group, 401 id: id, 402 source_path: source_path, 403 source_url: source_url, 404 title: title, 405 title_content: title_html || title 406 } 407 end 408 409 defp extension_name(input) do 410 input 411 |> Path.extname() 412 |> String.downcase() 413 end 414 415 defp sectionize(ast, ".cheatmd") do 416 Markdown.sectionize(ast, fn 417 {:h2, _, _, _} -> true 418 {:h3, _, _, _} -> true 419 _ -> false 420 end) 421 end 422 423 defp sectionize(ast, _), do: ast 424 425 @doc """ 426 Convert the input file name into a title 427 """ 428 def filename_to_title(input) do 429 input |> Path.basename() |> Path.rootname() 430 end 431 432 @clean_html_regex ~r/<(?:[^>=]|='[^']*'|="[^"]*"|=[^'"][^\s>]*)*>/ 433 434 @doc """ 435 Strips html tags from text leaving their text content 436 """ 437 def strip_tags(text, replace_with \\ "") when is_binary(text) do 438 String.replace(text, @clean_html_regex, replace_with) 439 end 440 441 @doc """ 442 Generates an ID from some text 443 444 Used primarily with titles, headings, and functions group names. 445 """ 446 def text_to_id(atom) when is_atom(atom), do: text_to_id(Atom.to_string(atom)) 447 448 def text_to_id(text) when is_binary(text) do 449 text 450 |> strip_tags() 451 |> String.replace(~r/&#\d+;/, "") 452 |> String.replace(~r/&[A-Za-z0-9]+;/, "") 453 |> String.replace(~r/\W+/u, "-") 454 |> String.trim("-") 455 |> String.downcase() 456 end 457 458 @doc """ 459 Generates the logo from config into the given directory. 460 """ 461 def generate_logo(_dir, %{logo: nil}) do 462 [] 463 end 464 465 def generate_logo(dir, %{output: output, logo: logo}) do 466 generate_image(output, dir, logo, "logo") 467 end 468 469 @doc """ 470 Generates the cover from config into the given directory. 471 """ 472 def generate_cover(_dir, %{cover: nil}) do 473 [] 474 end 475 476 def generate_cover(dir, %{output: output, cover: cover}) do 477 generate_image(output, dir, cover, "cover") 478 end 479 480 defp generate_image(output, dir, image, name) do 481 extname = 482 image 483 |> Path.extname() 484 |> String.downcase() 485 486 if extname in ~w(.png .jpg .svg) do 487 filename = Path.join(dir, "#{name}#{extname}") 488 target = Path.join(output, filename) 489 File.mkdir_p!(Path.dirname(target)) 490 File.copy!(image, target) 491 [filename] 492 else 493 raise ArgumentError, "image format not recognized, allowed formats are: .jpg, .png" 494 end 495 end 496 497 defp generate_redirect(filename, config, redirect_to) do 498 unless case_sensitive_file_regular?("#{config.output}/#{redirect_to}") do 499 IO.puts(:stderr, "warning: #{filename} redirects to #{redirect_to}, which does not exist") 500 end 501 502 content = Templates.redirect_template(config, redirect_to) 503 File.write!("#{config.output}/#{filename}", content) 504 end 505 506 defp case_sensitive_file_regular?(path) do 507 if File.regular?(path) do 508 files = path |> Path.dirname() |> File.ls!() 509 Path.basename(path) in files 510 else 511 false 512 end 513 end 514 515 # TODO: Move this categorization to the language 516 def filter_list(:module, nodes) do 517 Enum.filter(nodes, &(&1.type != :task)) 518 end 519 520 def filter_list(type, nodes) do 521 Enum.filter(nodes, &(&1.type == type)) 522 end 523 524 defp generate_list(nodes, nodes_map, config) do 525 nodes 526 |> Task.async_stream(&generate_module_page(&1, nodes_map, config), timeout: :infinity) 527 |> Enum.map(&elem(&1, 1)) 528 end 529 530 defp generate_module_page(module_node, nodes_map, config) do 531 filename = "#{module_node.id}.html" 532 config = set_canonical_url(config, filename) 533 content = Templates.module_page(module_node, nodes_map, config) 534 File.write!("#{config.output}/#{filename}", content) 535 filename 536 end 537 538 defp set_canonical_url(config, filename) do 539 if config.canonical do 540 canonical_url = 541 config.canonical 542 |> String.trim_trailing("/") 543 |> Kernel.<>("/" <> filename) 544 545 Map.put(config, :canonical, canonical_url) 546 else 547 config 548 end 549 end 550 551 defp extra_paths(config) do 552 Map.new(config.extras, fn 553 path when is_binary(path) -> 554 base = Path.basename(path) 555 {base, text_to_id(Path.rootname(base))} 556 557 {path, opts} -> 558 base = path |> Atom.to_string() |> Path.basename() 559 {base, opts[:filename] || text_to_id(Path.rootname(base))} 560 end) 561 end 562 end