lib/griffin_ssg.ex

defmodule GriffinSSG do
  @moduledoc """
  Griffin is a Static Site Generator.

  Griffin reads Markdown files from disk and outputs HTML pages. A combination
  of Application level config, frontmatter attributes and layout files can be
  used to customize the output of each file and to build the page structure of
  the website.

  Each input file can have a first segment called "front matter" that lists
  metadata about the file contents. This front matter segment is delineated by
  a sequence of characters `---` and the contents should be in YAML format.
  The contents that follow can contain plain content or reference front matter
  attributes.
  Here is an example of a short Markdown file with some front matter attributes:

  ```
  ---
  title: "Griffin Static Site Generator"
  draft: true
  ---

  # Griffin Static Site Generator
  Griffin is an Elixir framework for building static websites.
  ```

  """

  alias GriffinSSG.Filesystem

  @doc """
  Parses a file at the given path into two components: front matter and file content.
  """
  def parse(file_path, input_path, output_path) do
    {:ok, %{front_matter: front_matter, content: content}} = parse_string(File.read!(file_path))

    file_output_path =
      if Map.has_key?(front_matter, :permalink) do
        Path.join([output_path, front_matter.permalink, "index.html"])
      else
        Filesystem.output_filepath(file_path, input_path, output_path)
      end

    url =
      file_output_path
      |> String.trim_leading(output_path)
      |> String.trim_trailing("index.html")

    date =
      Map.get_lazy(front_matter, :date, fn ->
        timestamp = File.stat!(file_path, time: :posix).ctime

        timestamp
        |> DateTime.from_unix!()
        |> DateTime.to_iso8601()
      end)

    %{
      page: %{
        url: url,
        title: Map.get(front_matter, :title),
        description: Map.get(front_matter, :description),
        input_path: file_path,
        output_path: file_output_path,
        date: date
      },
      data: Map.put(front_matter, :url, url),
      content: content,
      input: file_path,
      output: file_output_path
    }
  end

  @doc """
  Parses the string contents of a file into two components: front matter and file content.

  Front matter is an optional YAML snippet containing variables to be used in the content.
  The content immediately follows the front matter and may reference front matter variables.

  Returns `{:ok, map()}` where map contains both the front matter and file content.
  """
  def parse_string(string_content) do
    {front_matter, content} =
      case String.split(string_content, ~r/\n---\n/, parts: 2) do
        [content] ->
          {%{}, content}

        [raw_frontmatter, content] ->
          {parse_frontmatter(raw_frontmatter), content}
      end

    {:ok, %{front_matter: front_matter, content: content}}
  rescue
    MatchError ->
      {:error, :parsing_front_matter_failed}
  end

  @doc """
  Renders a layout with a given content, front matter and assigns.

  The layout is assumed to be a compiled EEx file or string, such that calling
  `Code.eval_quoted/2` on the layout will generate a correct result.
  """
  def render(layout, options) do
    assigns = Map.get(options, :assigns, %{})
    rerender_partials = Map.get(options, :rerender_partials, true)

    content =
      options
      |> Map.fetch!(:content)
      |> EEx.eval_string(assigns: assigns)
      |> then(fn content_string ->
        case Map.get(options, :content_type, ".md") do
          md when md in [".md", ".markdown"] ->
            Earmark.as_html!(content_string)

          ".eex" ->
            content_string
        end
      end)

    layout_assigns =
      assigns
      |> Map.put(:content, content)
      # here we're re-rendering all existing partials when we might only need a very small subset.
      # refactor: render only required partials by looking at args in the quoted expression for `layout`
      |> then(fn current_assigns ->
        if rerender_partials do
          Map.update(current_assigns, :partials, %{}, fn partials ->
            # refactor: reduce nesting level by pulling parts into separate functions.
            # credo:disable-for-lines:3
            Map.new(partials, fn partial ->
              {compiled, _bindings} = Code.eval_quoted(partial, assigns: current_assigns)
              compiled
            end)
          end)
        else
          current_assigns
        end
      end)
      |> Enum.to_list()

    {result, _bindings} = Code.eval_quoted(layout, assigns: layout_assigns)
    result
  end

  @doc """
  Lists all pages in a directory, returning metadata about each page.
  The directory page is relative to the project root.
  """
  def list_pages(parsed_files, directory, opts \\ []) do
    parsed_files
    |> Enum.filter(fn file ->
      file_inside_directory?(file.page.input_path, directory)
    end)
    |> maybe_filter_files(Keyword.get(opts, :filter))
    |> maybe_take_files(Keyword.get(opts, :take))
    |> maybe_sort_files(opts)
  end

  defp parse_frontmatter(yaml) do
    {:ok, [parsed]} = YamlElixir.read_all_from_string(yaml, atoms: true)

    Map.new(parsed, fn {k, v} -> {String.to_atom(k), v} end)
  end

  defp file_inside_directory?(file, directory) do
    directory = Path.expand(directory)

    directory_path =
      if String.ends_with?(directory, "/") do
        directory
      else
        "#{directory}/"
      end

    String.starts_with?(Path.expand(file), directory_path)
  end

  defp get_page_datetime(page) do
    datetime = Map.get(page.data, :date, "")

    case DateTime.from_iso8601(datetime) do
      {:error, _} ->
        # make posts without date appear at the end when using the default descending sort
        DateTime.from_unix!(0)

      {:ok, datetime, _} ->
        datetime
    end
  end

  defp maybe_filter_files(files, nil), do: files

  defp maybe_filter_files(files, filter) when is_function(filter) do
    Enum.filter(files, fn file -> filter.(file.data) end)
  end

  defp maybe_take_files(files, nil), do: files
  defp maybe_take_files(files, amount) when is_integer(amount), do: Enum.take(files, amount)

  defp maybe_sort_files(files, opts) do
    case Keyword.get(opts, :sort_by) do
      nil ->
        files

      :date ->
        # date sort defaults to descending order
        sorter = {Keyword.get(opts, :sort_order, :desc), DateTime}
        Enum.sort_by(files, &get_page_datetime/1, sorter)

      sort ->
        Enum.sort_by(files, sort, Keyword.get(opts, :sort_order, :asc))
    end
  end
end