Packages
ex_doc
0.37.3
0.40.3
0.40.2
0.40.1
0.40.0
0.39.3
0.39.2
0.39.1
0.39.0
0.38.4
0.38.3
0.38.2
0.38.1
0.38.0
0.37.3
0.37.2
0.37.1
0.37.0
0.37.0-rc.2
0.37.0-rc.1
0.37.0-rc.0
0.36.1
0.36.0
0.35.1
0.35.0
0.34.2
0.34.1
0.34.0
0.33.0
0.32.2
0.32.1
0.32.0
0.31.2
0.31.1
0.31.0
0.30.9
0.30.8
0.30.7
0.30.6
0.30.5
0.30.4
0.30.3
0.30.2
0.30.1
0.30.0
0.29.4
0.29.3
0.29.2
0.29.1
0.29.0
0.28.6
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.3
0.27.2
0.27.1
0.27.0
0.26.0
0.25.5
0.25.4
0.25.3
0.25.2
0.25.1
0.25.0
0.24.2
0.24.1
0.24.0
0.23.0
0.22.6
0.22.5
0.22.4
0.22.2
0.22.1
0.22.0
0.21.3
0.21.2
0.21.1
0.21.0
0.20.2
0.20.1
0.20.0
0.19.3
0.19.2
0.19.1
0.19.0
0.19.0-rc
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
retired
0.17.1
0.17.0
retired
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.1
0.15.0
0.14.5
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
0.13.2
0.13.1
0.13.0
0.12.0
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.0
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.3
0.7.2
0.7.1
0.7.0
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
ExDoc is a documentation generation tool for Elixir
Current section
Files
Jump to
Current section
Files
lib/ex_doc/formatter/html.ex
defmodule ExDoc.Formatter.HTML do
@moduledoc false
alias __MODULE__.{Assets, Templates, SearchData}
alias ExDoc.{Markdown, GroupMatcher, Utils}
@main "api-reference"
@assets_dir "assets"
@search_data_keys [:anchor, :body, :title, :type]
@doc """
Generates HTML 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
config = normalize_config(config)
config = %{config | output: Path.expand(config.output)}
build = Path.join(config.output, ".build")
output_setup(build, config)
project_nodes = render_all(project_nodes, filtered_modules, ".html", config, [])
extras = build_extras(config, ".html")
# Generate search early on without api reference in extras
static_files = generate_assets(".", default_assets(config), config)
search_data = generate_search_data(project_nodes, extras, config)
# TODO: Move this categorization to the language
nodes_map = %{
modules: filter_list(:module, project_nodes),
tasks: filter_list(:task, project_nodes)
}
extras =
if config.api_reference do
[build_api_reference(nodes_map, config) | extras]
else
extras
end
all_files =
search_data ++
static_files ++
generate_sidebar_items(nodes_map, extras, config) ++
generate_extras(extras, config) ++
generate_favicon(@assets_dir, config) ++
generate_logo(@assets_dir, config) ++
generate_search(config) ++
generate_not_found(config) ++
generate_list(nodes_map.modules, config) ++
generate_list(nodes_map.tasks, config) ++
generate_redirects(config, ".html")
generate_build(Enum.sort(all_files), build)
config.output |> Path.join("index.html") |> Path.relative_to_cwd()
end
defp normalize_config(%{main: "index"}) do
raise ArgumentError,
message: ~S("main" cannot be set to "index", otherwise it will recursively link to itself)
end
defp normalize_config(%{main: main} = config) do
%{config | main: main || @main}
end
@doc """
Autolinks and renders all docs.
"""
def render_all(project_nodes, filtered_modules, ext, config, opts) do
base = [
apps: config.apps,
deps: config.deps,
ext: ext,
extras: extra_paths(config),
skip_undefined_reference_warnings_on: config.skip_undefined_reference_warnings_on,
skip_code_autolink_to: config.skip_code_autolink_to,
filtered_modules: filtered_modules
]
project_nodes
|> Task.async_stream(
fn node ->
language = node.language
autolink_opts =
[
current_module: node.module,
file: node.moduledoc_file,
line: node.moduledoc_line,
module_id: node.id,
language: language
] ++ base
docs =
for child_node <- node.docs do
id = id(node, child_node)
autolink_opts =
autolink_opts ++
[
id: id,
line: child_node.doc_line,
file: child_node.doc_file,
current_kfa: {child_node.type, child_node.name, child_node.arity}
]
specs = Enum.map(child_node.specs, &language.autolink_spec(&1, autolink_opts))
child_node = %{child_node | specs: specs}
render_doc(child_node, language, autolink_opts, opts)
end
%{
render_doc(node, language, [{:id, node.id} | autolink_opts], opts)
| docs: docs
}
end,
timeout: :infinity
)
|> Enum.map(&elem(&1, 1))
end
defp render_doc(%{doc: nil} = node, _language, _autolink_opts, _opts),
do: node
defp render_doc(%{doc: doc} = node, language, autolink_opts, opts) do
rendered = autolink_and_render(doc, language, autolink_opts, opts)
%{node | rendered_doc: rendered}
end
defp id(%{id: mod_id}, %{id: "c:" <> id}) do
"c:" <> mod_id <> "." <> id
end
defp id(%{id: mod_id}, %{id: "t:" <> id}) do
"t:" <> mod_id <> "." <> id
end
defp id(%{id: mod_id}, %{id: id}) do
mod_id <> "." <> id
end
defp autolink_and_render(doc, language, autolink_opts, opts) do
doc
|> language.autolink_doc(autolink_opts)
|> ExDoc.DocAST.to_string()
|> ExDoc.DocAST.highlight(language, opts)
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
File.rm_rf!(config.output)
File.mkdir_p!(config.output)
end
end
defp generate_build(files, build) do
entries = Enum.map(files, &[&1, "\n"])
File.write!(build, entries)
end
defp generate_not_found(config) do
filename = "404.html"
config = set_canonical_url(config, filename)
content = Templates.not_found_template(config)
File.write!("#{config.output}/#{filename}", content)
[filename]
end
defp generate_search(config) do
filename = "search.html"
config = set_canonical_url(config, filename)
content = Templates.search_template(config)
File.write!("#{config.output}/#{filename}", content)
[filename]
end
defp generate_sidebar_items(nodes_map, extras, config) do
content = Templates.create_sidebar_items(nodes_map, extras)
path = "dist/sidebar_items-#{digest(content)}.js"
File.write!(Path.join(config.output, path), content)
[path]
end
defp generate_search_data(linked, extras, config) do
content = SearchData.create(linked, extras, config.proglang)
path = "dist/search_data-#{digest(content)}.js"
File.write!(Path.join(config.output, path), content)
[path]
end
defp digest(content) do
content
|> :erlang.md5()
|> Base.encode16(case: :upper)
|> binary_part(0, 8)
end
defp generate_extras(extras, config) do
generated_extras =
extras
|> with_prev_next()
|> Enum.map(fn {node, prev, next} ->
filename = "#{node.id}.html"
output = "#{config.output}/#{filename}"
config = set_canonical_url(config, filename)
refs = %{
prev: prev && %{path: "#{prev.id}.html", title: prev.title},
next: next && %{path: "#{next.id}.html", title: next.title}
}
extension = node.source_path && Path.extname(node.source_path)
html = Templates.extra_template(config, node, extra_type(extension), refs)
if File.regular?(output) do
Utils.warn("file #{Path.relative_to_cwd(output)} already exists", [])
end
File.write!(output, html)
filename
end)
generated_extras ++ copy_extras(config, extras)
end
defp extra_type(".cheatmd"), do: :cheatmd
defp extra_type(".livemd"), do: :livemd
defp extra_type(_), do: :extra
defp copy_extras(config, extras) do
for %{source_path: source_path, id: id} when source_path != nil <- extras,
ext = extension_name(source_path),
ext == ".livemd" do
output = "#{config.output}/#{id}#{ext}"
File.copy!(source_path, output)
output
end
end
defp with_prev_next([]), do: []
defp with_prev_next([head | tail]) do
Enum.zip([[head | tail], [nil, head | tail], tail ++ [nil]])
end
@doc """
Generate assets from configs with the given default assets.
"""
def generate_assets(namespace, defaults, %{output: output, assets: assets}) do
namespaced_assets =
if is_map(assets) do
Enum.map(assets, fn {source, target} -> {source, Path.join(namespace, target)} end)
else
IO.warn("""
giving a binary to :assets is deprecated, please give a map from source to target instead:
#{inspect(assets: %{assets => "assets"})}
""")
[{assets, Path.join(namespace, "assets")}]
end
Enum.flat_map(defaults ++ namespaced_assets, fn {dir_or_files, relative_target_dir} ->
target_dir = Path.join(output, relative_target_dir)
File.mkdir_p!(target_dir)
cond do
is_list(dir_or_files) ->
Enum.map(dir_or_files, fn {name, content} ->
target = Path.join(target_dir, name)
File.write(target, content)
Path.relative_to(target, output)
end)
is_binary(dir_or_files) and File.dir?(dir_or_files) ->
dir_or_files
|> File.cp_r!(target_dir, dereference_symlinks: true)
|> Enum.map(&Path.relative_to(&1, output))
is_binary(dir_or_files) ->
[]
true ->
raise ":assets must be a map of source directories to target directories"
end
end)
end
defp default_assets(config) do
[
{Assets.dist(config.proglang), "dist"},
{Assets.fonts(), "dist"}
]
end
defp build_api_reference(nodes_map, config) do
api_reference = Templates.api_reference_template(nodes_map)
title_content =
~s{API Reference <small class="app-vsn">#{config.project} v#{config.version}</small>}
%{
content: api_reference,
group: nil,
id: "api-reference",
source_path: nil,
source_url: config.source_url,
title: "API Reference",
title_content: title_content
}
end
@doc """
Builds extra nodes by normalizing the config entries.
"""
def build_extras(config, ext) do
groups = config.groups_for_extras
language =
case config.proglang do
:erlang -> ExDoc.Language.Erlang
_ -> ExDoc.Language.Elixir
end
source_url_pattern = config.source_url_pattern
autolink_opts = [
apps: config.apps,
deps: config.deps,
ext: ext,
extras: extra_paths(config),
language: language,
skip_undefined_reference_warnings_on: config.skip_undefined_reference_warnings_on,
skip_code_autolink_to: config.skip_code_autolink_to
]
extras =
config.extras
|> Task.async_stream(
&build_extra(&1, groups, language, autolink_opts, source_url_pattern),
timeout: :infinity
)
|> Enum.map(&elem(&1, 1))
ids_count = Enum.reduce(extras, %{}, &Map.update(&2, &1.id, 1, fn c -> c + 1 end))
extras
|> Enum.map_reduce(1, fn extra, idx ->
if ids_count[extra.id] > 1, do: {disambiguate_id(extra, idx), idx + 1}, else: {extra, idx}
end)
|> elem(0)
|> Enum.sort_by(fn extra -> GroupMatcher.index(groups, extra.group) end)
end
def generate_redirects(config, ext) do
config.redirects
|> Map.new()
|> Map.put_new("index", config.main)
|> Enum.map(fn {from, to} ->
unless is_binary(from),
do: raise("expected a string for the source of a redirect, got: #{inspect(from)}")
unless is_binary(to),
do: raise("expected a string for the destination of a redirect, got: #{inspect(to)}")
source = from <> ext
destination = to <> ext
generate_redirect(source, config, destination)
source
end)
end
defp disambiguate_id(extra, discriminator) do
Map.put(extra, :id, "#{extra.id}-#{discriminator}")
end
defp build_extra({input, input_options}, groups, language, autolink_opts, source_url_pattern) do
input = to_string(input)
id = input_options[:filename] || input |> filename_to_title() |> Utils.text_to_id()
source_file = input_options[:source] || input
opts = [file: source_file, line: 1]
{source, ast} =
case extension_name(input) do
extension when extension in ["", ".txt"] ->
source = File.read!(input)
ast = [{:pre, [], "\n" <> source, %{}}]
{source, ast}
extension when extension in [".md", ".livemd", ".cheatmd"] ->
source = File.read!(input)
ast =
source
|> Markdown.to_ast(opts)
|> sectionize(extension)
{source, ast}
_ ->
raise ArgumentError,
"file extension not recognized, allowed extension is either .cheatmd, .livemd, .md, .txt or no extension"
end
{title_ast, ast} =
case ExDoc.DocAST.extract_title(ast) do
{:ok, title_ast, ast} -> {title_ast, ast}
:error -> {nil, ast}
end
title_text = title_ast && ExDoc.DocAST.text_from_ast(title_ast)
title_html = title_ast && ExDoc.DocAST.to_string(title_ast)
content_html = autolink_and_render(ast, language, [file: input] ++ autolink_opts, opts)
group = GroupMatcher.match_extra(groups, input)
title = input_options[:title] || title_text || filename_to_title(input)
source_path = source_file |> Path.relative_to(File.cwd!()) |> String.replace_leading("./", "")
source_url = source_url_pattern.(source_path, 1)
search_data = normalize_search_data!(input_options[:search_data])
%{
source: source,
content: content_html,
group: group,
id: id,
source_path: source_path,
source_url: source_url,
search_data: search_data,
title: title,
title_content: title_html || title
}
end
defp build_extra(input, groups, language, autolink_opts, source_url_pattern) do
build_extra({input, []}, groups, language, autolink_opts, source_url_pattern)
end
defp normalize_search_data!(nil), do: nil
defp normalize_search_data!(search_data) when is_list(search_data) do
Enum.each(search_data, fn search_data ->
has_keys = Map.keys(search_data)
if Enum.sort(has_keys) != @search_data_keys do
raise ArgumentError,
"Expected search data to be a list of maps with the keys: #{inspect(@search_data_keys)}, found keys: #{inspect(has_keys)}"
end
end)
search_data
end
defp normalize_search_data!(search_data) do
raise ArgumentError,
"Expected search data to be a list of maps with the keys: #{inspect(@search_data_keys)}, found: #{inspect(search_data)}"
end
defp extension_name(input) do
input
|> Path.extname()
|> String.downcase()
end
defp sectionize(ast, ".cheatmd") do
ExDoc.DocAST.sectionize(ast, fn
{:h2, _, _, _} -> true
{:h3, _, _, _} -> true
_ -> false
end)
end
defp sectionize(ast, _), do: ast
defp filename_to_title(input) do
input |> Path.basename() |> Path.rootname()
end
@doc """
Generates the favicon from config into the given directory.
"""
def generate_favicon(_dir, %{favicon: nil}) do
[]
end
def generate_favicon(dir, %{output: output, favicon: favicon}) do
generate_image(output, dir, favicon, "favicon")
end
@doc """
Generates the logo from config into the given directory.
"""
def generate_logo(_dir, %{logo: nil}) do
[]
end
def generate_logo(dir, %{output: output, logo: logo}) do
generate_image(output, dir, logo, "logo")
end
@doc """
Generates the cover from config into the given directory.
"""
def generate_cover(_dir, %{cover: nil}) do
[]
end
def generate_cover(dir, %{output: output, cover: cover}) do
generate_image(output, dir, cover, "cover")
end
defp generate_image(output, dir, image, name) do
extname =
image
|> Path.extname()
|> String.downcase()
if extname in ~w(.png .jpg .jpeg .svg) do
filename = Path.join(dir, "#{name}#{extname}")
target = Path.join(output, filename)
File.mkdir_p!(Path.dirname(target))
File.copy!(image, target)
[filename]
else
raise ArgumentError, "image format not recognized, allowed formats are: .png, .jpg, .svg"
end
end
defp generate_redirect(filename, config, redirect_to) do
unless case_sensitive_file_regular?("#{config.output}/#{redirect_to}") do
Utils.warn("#{filename} redirects to #{redirect_to}, which does not exist", [])
end
content = Templates.redirect_template(config, redirect_to)
File.write!("#{config.output}/#{filename}", content)
end
defp case_sensitive_file_regular?(path) do
if File.regular?(path) do
files = path |> Path.dirname() |> File.ls!()
Path.basename(path) in files
else
false
end
end
def filter_list(:module, nodes) do
Enum.filter(nodes, &(&1.type != :task))
end
def filter_list(type, nodes) do
Enum.filter(nodes, &(&1.type == type))
end
defp generate_list(nodes, config) do
nodes
|> Task.async_stream(&generate_module_page(&1, config), timeout: :infinity)
|> Enum.map(&elem(&1, 1))
end
defp generate_module_page(module_node, config) do
filename = "#{module_node.id}.html"
config = set_canonical_url(config, filename)
content = Templates.module_page(module_node, config)
File.write!("#{config.output}/#{filename}", content)
filename
end
defp set_canonical_url(config, filename) do
if config.canonical do
canonical_url =
config.canonical
|> String.trim_trailing("/")
|> Kernel.<>("/" <> filename)
Map.put(config, :canonical, canonical_url)
else
config
end
end
defp extra_paths(config) do
Map.new(config.extras, fn
path when is_binary(path) ->
base = Path.basename(path)
{base, Utils.text_to_id(Path.rootname(base))}
{path, opts} ->
base = path |> to_string() |> Path.basename()
{base, opts[:filename] || Utils.text_to_id(Path.rootname(base))}
end)
end
end