zf

zenflows testing
git clone https://s.sonu.ch/~srfsh/zf.git
Log | Files | Refs | Submodules | README | LICENSE

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