lib/mix/tasks/add_navigation.ex

defmodule Mix.Tasks.AddNavigation do
  @doc """
  ## Usage

  Run the `mix add_navigation` task to automatically add navigation.

  Sections for navigation should be included in the notebook using the following comments:

  ```md
  <!-- navigation-start -->
  <!-- navigation-end -->
  ```

  `livebook_utils` can update your navigation whenever your index file changes. You also have control over where navigation goes.

  ## Template Example
  Typically, we recommend putting a navigation section at the top and the bottom of the file.

  Here's an example template livebook file with navigation at the top and bottom of the file.

  ```md
  # Title
  ## Navigation
  <!-- navigation-start -->
  <!-- navigation-end -->

  ## SubTitle

  ## Navigation
  <!-- navigation-start -->
  <!-- navigation-end -->

  ```

  You must have two or more notebooks in your index file with valid paths and titles to add navigation.

  ## Styling

  Currently, we don't provide any control over styling the navigation section. Here's how navigation looks out of the box:

  <div style="display: flex; align-items: center; width: 100%; justify-content: space-between; font-size: 1rem; color: #61758a; background-color: #f0f5f9; height: 4rem; padding: 0 1rem; border-radius: 1rem;">
  <div style="display: flex; margin-right: auto;">
  <i class="ri-arrow-left-fill"></i>
  <a style="display: flex; color: #61758a; margin-left: 1rem;" href="#">Book 3</a>
  </div>
  <div style="display: flex; margin-left: auto;">
  <a style="display: flex; color: #61758a; margin-right: 1rem;" href="#">Book 1</a>
  <i class="ri-arrow-right-fill"></i>
  </div>
  </div>

  """
  use Mix.Task
  require Logger

  def run(_args) do
    Logger.info("Adding Navigation")
    index_path = Application.get_env(:livebook_utils, :index_path)
    notebooks_path = Application.get_env(:livebook_utils, :notebooks_path)
    index = File.read!(index_path)
    notebooks = get_notebooks(index, notebooks_path)

    if Enum.count(notebooks) <= 1 do
      raise "Cannot add navigation with one or fewer books"
    else
      write_navigation_sections!(notebooks, notebooks_path)
    end
  end

  defp get_notebooks(index, notebooks_path) do
    Regex.scan(~r/\[(.*)\]\((.*)\)/, index)
    |> Enum.map(fn [_, title, path] -> %{path: path, title: title} end)
    |> Enum.filter(fn %{path: path, title: title} ->
      String.contains?(path, ".livemd") and File.exists?(Path.join(notebooks_path, path)) and
        title != ""
    end)
  end

  defp write_navigation_sections!(notebooks, notebooks_path) do
    List.flatten([nil, notebooks])
    |> Enum.chunk_every(3, 1, [nil])
    |> Enum.map(fn
      [prev, current, next] ->
        file = File.read!(Path.join(notebooks_path, current.path))

        file_with_nav =
          Regex.replace(
            ~r/<!-- navigation-start -->(?:.|\n)*?<!-- navigation-end -->/,
            file,
            nav_snippet(prev, current, next)
          )

        File.write!("#{notebooks_path}/#{current.path}", file_with_nav)
    end)
  end

  defp nav_snippet(prev, _, next) do
    """
    <!-- navigation-start -->
    <div style="display: flex; align-items: center; width: 100%; justify-content: space-between; font-size: 1rem; color: #61758a; background-color: #f0f5f9; height: 4rem; padding: 0 1rem; border-radius: 1rem;">
    #{if prev, do: prev_snippet(prev)}
    #{if next, do: next_snippet(next)}
    </div>
    <!-- navigation-end -->
    """
  end

  defp next_snippet(next) do
    """
    <div style="display: flex; margin-left: auto;">
    <a style="display: flex; color: #61758a; margin-right: 1rem;" href="#{next[:path]}">#{next[:title]}</a>
    <i class="ri-arrow-right-fill"></i>
    </div>
    """
  end

  defp prev_snippet(prev) do
    """
    <div style="display: flex; margin-right: auto;">
    <i class="ri-arrow-left-fill"></i>
    <a style="display: flex; color: #61758a; margin-left: 1rem;" href="#{prev[:path]}">#{prev[:title]}</a>
    </div>
    """
  end
end