templates.ex (9803B)
1 defmodule ExDoc.Formatter.HTML.Templates do 2 @moduledoc false 3 require EEx 4 5 import ExDoc.Utils, only: [h: 1] 6 7 # TODO: It should not depend on the parent module. Move required HTML functions to Utils. 8 # TODO: Add tests that assert on the returned structured, not on JSON 9 alias ExDoc.Formatter.HTML 10 11 @doc """ 12 Generate content from the module template for a given `node` 13 """ 14 def module_page(module_node, nodes_map, config) do 15 summary = module_summary(module_node) 16 module_template(config, module_node, summary, nodes_map) 17 end 18 19 @doc """ 20 Get the full specs from a function, already in HTML form. 21 """ 22 def get_specs(%ExDoc.TypeNode{spec: spec}) do 23 [spec] 24 end 25 26 def get_specs(%ExDoc.FunctionNode{specs: specs}) when is_list(specs) do 27 presence(specs) 28 end 29 30 def get_specs(_node) do 31 nil 32 end 33 34 @doc """ 35 Format the attribute type used to define the spec of the given `node`. 36 """ 37 def format_spec_attribute(module, node) do 38 module.language.format_spec_attribute(node) 39 end 40 41 @doc """ 42 Get defaults clauses. 43 """ 44 def get_defaults(%{defaults: defaults}) do 45 defaults 46 end 47 48 def get_defaults(_) do 49 [] 50 end 51 52 @doc """ 53 Get the pretty name of a function node 54 """ 55 def pretty_type(%{type: t}) do 56 Atom.to_string(t) 57 end 58 59 @doc """ 60 Returns the HTML formatted title for the module page. 61 """ 62 def module_type(%{type: :task}), do: "" 63 def module_type(%{type: :module}), do: "" 64 def module_type(%{type: type}), do: "<small>#{type}</small>" 65 66 @doc """ 67 Gets the first paragraph of the documentation of a node. It strips 68 surrounding white-spaces and trailing `:`. 69 70 If `doc` is `nil`, it returns `nil`. 71 """ 72 @spec synopsis(String.t()) :: String.t() 73 @spec synopsis(nil) :: nil 74 def synopsis(nil), do: nil 75 76 def synopsis(doc) when is_binary(doc) do 77 case :binary.split(doc, "</p>") do 78 [left, _] -> String.trim_trailing(left, ":") <> "</p>" 79 [all] -> all 80 end 81 end 82 83 defp presence([]), do: nil 84 defp presence(other), do: other 85 86 defp enc(binary), do: URI.encode(binary) 87 88 @doc """ 89 Create a JS object which holds all the items displayed in the sidebar area 90 """ 91 def create_sidebar_items(nodes_map, extras) do 92 nodes = 93 nodes_map 94 |> Enum.map(&sidebar_module/1) 95 |> Map.new() 96 |> Map.put(:extras, sidebar_extras(extras)) 97 98 ["sidebarNodes=" | ExDoc.Utils.to_json(nodes)] 99 end 100 101 defp sidebar_extras(extras) do 102 for extra <- extras do 103 %{id: id, title: title, group: group, content: content} = extra 104 105 %{ 106 id: to_string(id), 107 title: to_string(title), 108 group: to_string(group), 109 headers: extract_headers(content) 110 } 111 end 112 end 113 114 defp sidebar_module({id, modules}) do 115 modules = 116 for module <- modules do 117 extra = 118 module 119 |> module_summary() 120 |> Enum.reject(fn {_type, nodes_map} -> nodes_map == [] end) 121 |> case do 122 [] -> [] 123 entries -> [nodeGroups: Enum.map(entries, &sidebar_entries/1)] 124 end 125 126 sections = module_sections(module) 127 128 pairs = 129 for key <- [:id, :title, :nested_title, :nested_context], 130 value = Map.get(module, key), 131 do: {key, value} 132 133 Map.new([group: to_string(module.group)] ++ extra ++ pairs ++ sections) 134 end 135 136 {id, modules} 137 end 138 139 defp sidebar_entries({group, nodes}) do 140 nodes = 141 for node <- nodes do 142 id = 143 if "struct" in node.annotations do 144 node.signature 145 else 146 "#{node.name}/#{node.arity}" 147 end 148 149 %{id: id, anchor: URI.encode(node.id)} 150 end 151 152 %{key: HTML.text_to_id(group), name: group, nodes: nodes} 153 end 154 155 defp module_sections(%ExDoc.ModuleNode{rendered_doc: nil}), do: [sections: []] 156 157 defp module_sections(module) do 158 {sections, _} = 159 module.rendered_doc 160 |> extract_headers() 161 |> Enum.map_reduce(%{}, fn header, acc -> 162 # TODO Duplicates some of the logic of link_headings/3 163 case Map.fetch(acc, header.id) do 164 {:ok, id} -> 165 {%{header | anchor: "module-#{header.anchor}-#{id}"}, Map.put(acc, header.id, id + 1)} 166 167 :error -> 168 {%{header | anchor: "module-#{header.anchor}"}, Map.put(acc, header.id, 1)} 169 end 170 end) 171 172 [sections: sections] 173 end 174 175 @h2_regex ~r/<h2.*?>(.*?)<\/h2>/m 176 defp extract_headers(content) do 177 @h2_regex 178 |> Regex.scan(content, capture: :all_but_first) 179 |> List.flatten() 180 |> Enum.filter(&(&1 != "")) 181 |> Enum.map(&HTML.strip_tags/1) 182 |> Enum.map(&%{id: &1, anchor: URI.encode(HTML.text_to_id(&1))}) 183 end 184 185 def module_summary(module_node) do 186 [Types: module_node.typespecs] ++ 187 function_groups(module_node.function_groups, module_node.docs) 188 end 189 190 defp function_groups(groups, docs) do 191 for group <- groups, do: {group, Enum.filter(docs, &(&1.group == group))} 192 end 193 194 defp logo_path(%{logo: nil}), do: nil 195 defp logo_path(%{logo: logo}), do: "assets/logo#{Path.extname(logo)}" 196 197 defp sidebar_type(:exception), do: "modules" 198 defp sidebar_type(:module), do: "modules" 199 defp sidebar_type(:behaviour), do: "modules" 200 defp sidebar_type(:protocol), do: "modules" 201 defp sidebar_type(:task), do: "tasks" 202 203 defp sidebar_type(:search), do: "search" 204 defp sidebar_type(:cheatmd), do: "extras" 205 defp sidebar_type(:livemd), do: "extras" 206 defp sidebar_type(:extra), do: "extras" 207 208 def asset_rev(output, pattern) do 209 output = Path.expand(output) 210 211 output 212 |> Path.join(pattern) 213 |> Path.wildcard() 214 |> relative_asset(output, pattern) 215 end 216 217 defp relative_asset([], output, pattern), 218 do: raise("could not find matching #{output}/#{pattern}") 219 220 defp relative_asset([h | _], output, _pattern), do: Path.relative_to(h, output) 221 222 @doc """ 223 Link headings found with `regex` with in the given `content`. IDs are 224 prefixed with `prefix`. 225 """ 226 @heading_regex ~r/<(h[23]).*?>(.*?)<\/\1>/m 227 @spec link_headings(String.t() | nil, Regex.t(), String.t()) :: String.t() | nil 228 def link_headings(content, regex \\ @heading_regex, prefix \\ "") 229 def link_headings(nil, _, _), do: nil 230 231 def link_headings(content, regex, prefix) do 232 regex 233 |> Regex.scan(content) 234 |> Enum.reduce({content, %{}}, fn [match, tag, title], {content, occurrences} -> 235 possible_id = HTML.text_to_id(title) 236 id_occurred = Map.get(occurrences, possible_id, 0) 237 238 anchor_id = if id_occurred >= 1, do: "#{possible_id}-#{id_occurred}", else: possible_id 239 replacement = link_heading(match, tag, title, anchor_id, prefix) 240 linked_content = String.replace(content, match, replacement, global: false) 241 incremented_occs = Map.put(occurrences, possible_id, id_occurred + 1) 242 {linked_content, incremented_occs} 243 end) 244 |> elem(0) 245 end 246 247 @class_regex ~r/<h[23].*?(\sclass="(?<class>[^"]+)")?.*?>/ 248 @class_separator " " 249 defp link_heading(match, _tag, _title, "", _prefix), do: match 250 251 defp link_heading(match, tag, title, id, prefix) do 252 section_header_class_name = "section-heading" 253 254 # NOTE: This addition is mainly to preserve the previous `class` attributes 255 # from the headers, in case there is one. Now with the _admonition_ text 256 # block, we inject CSS classes. So far, the supported classes are: 257 # `warning`, `info`, `error`, and `neutral`. 258 # 259 # The Markdown syntax that we support for the admonition text 260 # blocks is something like this: 261 # 262 # > ### Never open this door! {: .warning} 263 # > 264 # > ... 265 # 266 # That should produce the following HTML: 267 # 268 # <blockquote> 269 # <h3 class="warning">Never open this door!</h3> 270 # <p>...</p> 271 # </blockquote> 272 # 273 # The original implementation discarded the previous CSS classes. Instead, 274 # it was setting `#{section_header_class_name}` as the only CSS class 275 # associated with the given header. 276 class_attribute = 277 case Regex.named_captures(@class_regex, match) do 278 %{"class" => ""} -> 279 section_header_class_name 280 281 %{"class" => previous_classes} -> 282 # Let's make sure that the `section_header_class_name` is not already 283 # included in the previous classes for the header 284 previous_classes 285 |> String.split(@class_separator) 286 |> Enum.reject(&(&1 == section_header_class_name)) 287 |> Enum.join(@class_separator) 288 |> Kernel.<>(" #{section_header_class_name}") 289 end 290 291 """ 292 <#{tag} id="#{prefix}#{id}" class="#{class_attribute}"> 293 <a href="##{prefix}#{id}" class="hover-link"><i class="ri-link-m" aria-hidden="true"></i> 294 <p class="sr-only">#{id}</p> 295 </a> 296 #{title} 297 </#{tag}> 298 """ 299 end 300 301 defp link_moduledoc_headings(content) do 302 link_headings(content, @heading_regex, "module-") 303 end 304 305 defp link_detail_headings(content, prefix) do 306 link_headings(content, @heading_regex, prefix <> "-") 307 end 308 309 templates = [ 310 detail_template: [:node, :module], 311 footer_template: [:config, :node], 312 head_template: [:config, :page], 313 module_template: [:config, :module, :summary, :nodes_map], 314 not_found_template: [:config, :nodes_map], 315 api_reference_entry_template: [:module_node], 316 api_reference_template: [:nodes_map], 317 extra_template: [:config, :node, :type, :nodes_map, :refs], 318 search_template: [:config, :nodes_map], 319 sidebar_template: [:config, :nodes_map], 320 summary_template: [:name, :nodes], 321 redirect_template: [:config, :redirect_to], 322 settings_button_template: [] 323 ] 324 325 Enum.each(templates, fn {name, args} -> 326 filename = Path.expand("templates/#{name}.eex", __DIR__) 327 @doc false 328 EEx.function_from_file(:def, name, filename, args, trim: true) 329 end) 330 end