defmodule Serum.Plugins.TableOfContents do @moduledoc """ A Serum plugin that inserts a table of contents. ## Using the Plugin First, add this plugin to your `serum.exs`: %{ plugins: [ #{__MODULE__ |> to_string() |> String.replace_prefix("Elixir.", "")} ] } This plugin works with both pages(`.md`, `.html`, and `.html.eex`) and blog posts(`.md`). Insert the `` tag at the position you want to display a table of contents at. The `start` and `end` attributes define a range of heading level this plugin recognizes. In the case of the above example, `

`, `

`, and `
` tags are ignored when generating a table of contents. After this plugin has run, each `` tag is replaced with an unordered list: This plugin produces a "flat" unordered list. However, each list item tag has an `indent-x` class, where `x` is an indentation level (from 0 to 5) of the current item in the list. You can utilize this when working on stylesheets. The `id` attribute of each target heading tag is used when hyperlinks are generated. If the element does not have an `id`, the plugin will set one appropriately. ## Notes You may use `` tag more than once in a single page. However, all occurrences of this tag will be replaced with a table of contents generated using the attributes of the first one. That is, for example, all three tags in the code below expand to the same table of contents, showing a 2-level deep list. ... ... It's recommended that you wrap a `` tag with a `
` tag when using in a markdown file, to ensure a well-formed structure of HTML output.
And finally, make sure you close every `` tag properly with ``. """ @behaviour Serum.Plugin serum_ver = Version.parse!(Mix.Project.config()[:version]) serum_req = "~> #{serum_ver.major}.#{serum_ver.minor}" def name, do: "Table of Contents" def version, do: "1.1.0" def elixir, do: "~> 1.8" def serum, do: unquote(serum_req) def description, do: "Inserts a table of contents into pages or posts." def implements, do: [rendering_fragment: 3] def rendering_fragment(html, metadata, _args) def rendering_fragment(html, %{type: :page}, _), do: {:ok, insert_toc(html)} def rendering_fragment(html, %{type: :post}, _), do: {:ok, insert_toc(html)} def rendering_fragment(html, _, _), do: {:ok, html} @spec insert_toc(Floki.html_tree()) :: Floki.html_tree() defp insert_toc(html) do case Floki.find(html, "serum-toc") do [] -> html [{"serum-toc", attr_list, _} | _] -> {start, end_} = get_range(attr_list) state = {start, end_, start, [0], []} {new_tree, new_state} = Floki.traverse_and_update(html, state, &tree_fun/2) items = new_state |> elem(4) |> Enum.reverse() toc = {"ul", [{"id", "toc"}, {"class", "serum-toc"}], items} Floki.traverse_and_update(new_tree, fn {"serum-toc", _, _} -> toc x -> x end) end end @spec get_range([{binary(), binary()}]) :: {integer(), integer()} defp get_range(attr_list) do attr_map = Map.new(attr_list) start = attr_map["start"] end_ = attr_map["end"] start = (start && parse_h_level(start, 1)) || 1 end_ = (end_ && parse_h_level(end_, 6)) || 6 end_ = max(start, end_) {start, end_} end @spec parse_h_level(binary(), integer()) :: integer() defp parse_h_level(str, default) do case Integer.parse(str) do {level, ""} -> max(1, min(level, 6)) _ -> default end end @spec tree_fun(Floki.html_tag(), term()) :: {Floki.html_tag(), term()} defp tree_fun(tree, state) defp tree_fun({<>, _, _} = tree, state) when ch in ?1..?6 do {start, end_, prev_level, counts, items} = state level = ch - ?0 if level >= start and level <= end_ do new_counts = update_counts(counts, level, prev_level) num_dot = new_counts |> Enum.reverse() |> Enum.join(".") {tree2, id} = try_set_id(tree, "s_#{num_dot}") link = toc_link(tree2, num_dot, id) item = {"li", [{"class", "indent-#{level - start}"}], [link]} new_state = {start, end_, level, new_counts, [item | items]} {tree2, new_state} else {tree, state} end end defp tree_fun(x, state), do: {x, state} @spec strip_a_tags(Floki.html_tag()) :: Floki.html_tag() defp strip_a_tags(tree) defp strip_a_tags({"a", _, children}), do: children defp strip_a_tags(x), do: x @spec update_counts([integer()], integer(), integer()) :: [integer()] defp update_counts(counts, level, prev_level) do case level - prev_level do 0 -> [x | xs] = counts [x + 1 | xs] diff when diff < 0 -> [x | xs] = Enum.drop(counts, -diff) [x + 1 | xs] diff when diff > 0 -> List.duplicate(1, diff) ++ counts end end @spec toc_link(Floki.html_tag(), binary(), binary()) :: Floki.html_tag() defp toc_link({_, _, children} = _header_tag, num_dot, target_id) do num_span = {"span", [{"class", "number"}], [num_dot]} contents = Floki.traverse_and_update(children, &strip_a_tags/1) {"a", [{"href", <>}], [num_span | contents]} end @spec try_set_id(Floki.html_tag(), binary()) :: {Floki.html_tag(), binary()} defp try_set_id({tag_name, attrs, children} = tree, new_id) do case Enum.find(attrs, fn {k, _} -> k === "id" end) do {"id", id} -> {tree, id} nil -> {{tag_name, [{"id", new_id} | attrs], children}, new_id} end end end