lib/phoenix_pages.ex

defmodule PhoenixPages do
  @moduledoc """
  Blogs, docs, and static pages in Phoenix. Check out the [README](readme.html) to get started.

  ## Options

    * `:otp_app` - The name of the OTP application to use as the base directory when looking for
    page files. This value is required.

    * `:render_options` - Allows the renderers to be configured. See `pages/4` for the options.

  """

  @type page :: PhoenixPages.Page.t()

  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      @behaviour PhoenixPages
      @before_compile PhoenixPages

      import PhoenixPages, only: [pages: 4]

      # find all the installed makeup lexers and starts them
      {lexers, lexers_hash} = PhoenixPages.Helpers.list_lexers()
      for lexer <- lexers, do: Application.ensure_all_started(lexer)

      @phoenix_pages_app_dir Keyword.fetch!(opts, :otp_app) |> Application.app_dir()
      @phoenix_pages_render_opts Keyword.get(opts, :render_options, [])
      @phoenix_pages_lexers_hash lexers_hash

      Module.register_attribute(__MODULE__, :phoenix_pages_from, accumulate: true)
    end
  end

  defmacro __before_compile__(_env) do
    quote do
      @impl true
      def get_pages(id), do: :error

      @impl true
      def get_pages!(id) do
        raise PhoenixPages.NotFoundError, id: id
      end

      # this can screw things up in development (-_-)
      # if having problems with not recompiling for tests, run `MIX_ENV=test mix compile --force`
      def __mix_recompile__? do
        Enum.any?([
          # recompile if a new makeup lexer dependency is added
          PhoenixPages.Helpers.list_lexers() |> elem(1) != @phoenix_pages_lexers_hash,

          # recompile if any pages were added or removed
          Enum.any?(@phoenix_pages_from, fn {from, hash} ->
            PhoenixPages.Helpers.list_files(@phoenix_pages_app_dir, from) |> elem(1) != hash
          end)
        ])
      end
    end
  end

  @doc """
  Finds all the pages matching a pattern and generates a GET route for each one.

  ## Options

    * `:id` - The ID for this collection of pages so they can later be accessed with
    [`get_pages/1`](PhoenixPages.html#c:get_pages/1) and [`get_pages!/1`](PhoenixPages.html#c:get_pages!/1).
    This is only required if using those functions.

    * `:from` - The [wildcard pattern](https://hexdocs.pm/elixir/1.13/Path.html#wildcard/2) used
    to look for pages on the filesystem. Make sure the base directory is included in your release
    (`priv` is included by default). Defaults to `priv/pages/**/*.md`.

    * `:sort` - The order of the pages returned when using `get_pages/1` and `get_pages!/1`.
    Defined as a tuple with the first element being an atom for the sort value (which can be any
    value from the attributes), and the second element being either `:asc` or `:desc`.

    * `:attrs` - A list of attributes used when parsing the markdown frontmatter for each page.
    Atoms will be required for each page, key values will be optional with a default value.
    Defaults to `[]`.

    * `:render_options` - Allows the renderers to be configured. This can also be set globally in
    the [module options](PhoenixPages.html#module-options).

      * `:markdown` - Allows the Earmark renderer to be configured.

        * `:hard_breaks` - Whether to convert hard line breaks to `<br>` tags. Defaults to `false`.
        * `:wiki_links` - Whether to enable wiki-style links such as `[[page]]`. Defaults to `true`.
        * `:pure_links` - Whether to convert raw URLs to `<a>` tags. Defaults to `true`.
        * `:sub_sup` - Whether to convert `~x~` and `^x^` to `<sub>` and `<sup>` tags. Defaults to `true`.
        * `:footnotes` - Whether to enable footnotes. Defaults to `true`.
        * `:smartypants` - Whether to enable [smartypants](https://daringfireball.net/projects/smartypants/). Defaults to `true`.
        * `:compact_output` - Whether to avoid indentation and minimize whitespace in output. Defaults to `false`.
        * `:escape_html` - Whether to allow raw HTML in markdown. Defaults to `false`.
        * `:syntax_highlighting` - Whether to enable Makeup syntax highlighting. Defaults to `true`.
        * `:code_class_prefix` - A class prefix to add to the language class of code blocks.

    * All other options are passed to [`Phoenix.Router.match/5`](https://hexdocs.pm/phoenix/Phoenix.Router.html#match/5-options)

  """
  defmacro pages(path, plug, plug_opts, opts \\ []) do
    quote bind_quoted: [path: path, plug: plug, plug_opts: plug_opts, opts: opts] do
      {id, opts} = Keyword.pop(opts, :id)
      {from, opts} = Keyword.pop(opts, :from, "priv/pages/**/*.md")
      {sort, opts} = Keyword.pop(opts, :sort)
      {attrs, opts} = Keyword.pop(opts, :attrs, [])
      {render_opts, opts} = Keyword.pop(opts, :render_options, @phoenix_pages_render_opts)
      {files, hash} = PhoenixPages.Helpers.list_files(@phoenix_pages_app_dir, from)

      assigns = Keyword.get(opts, :assigns, %{})

      pages =
        for file <- files do
          @external_resource file

          path = PhoenixPages.Helpers.into_path(path, file, from)
          filename = Path.relative_to(file, @phoenix_pages_app_dir)

          {data, content} = File.read!(file) |> PhoenixPages.Frontmatter.parse(filename)
          assigns = Map.merge(assigns, PhoenixPages.Frontmatter.cast(data, attrs))

          inner_content = PhoenixPages.render(content, filename, render_opts)
          assigns = Map.put(assigns, :inner_content, inner_content)

          struct!(PhoenixPages.Page,
            path: path,
            filename: filename,
            content: content,
            assigns: assigns
          )
        end

      for page <- pages do
        opts = Keyword.put(opts, :assigns, page.assigns)
        Phoenix.Router.get(page.path, plug, plug_opts, opts)
      end

      @phoenix_pages PhoenixPages.sort(pages, sort)
      @phoenix_pages_from {from, hash}

      if id do
        @impl true
        def get_pages(unquote_splicing([id])) do
          # the page id is guaranteed to exist, as it's being defined below
          {:ok, get_pages!(unquote(id))}
        end

        @impl true
        def get_pages!(unquote_splicing([id])) do
          @phoenix_pages
        end
      end
    end
  end

  @doc false
  def sort(pages, {sort_by, sort_dir}) do
    Enum.sort_by(pages, &Map.get(&1.assigns, sort_by), sort_dir)
  end

  def sort(pages, _), do: pages

  @doc false
  def render(content, filename, opts) do
    case Path.extname(filename) do
      ext when ext in [".md", ".markdown"] ->
        markdown_opts = Keyword.get(opts, :markdown, [])
        PhoenixPages.Markdown.render(content, filename, markdown_opts)

      _ ->
        content
    end
  end

  @doc """
  Gets a list of pages for a given ID. See the `:id` option in `pages/4`.

  This can be used to get a list of pages from within a controller for index pages or further
  customization such as finding related pages, filtering by a value, etc. Returns `{:ok, pages}`
  if successful, or `:error` if no pages were defined with the ID.
  """
  @callback get_pages(id :: atom | binary) :: {:ok, list(page)} | :error

  @doc """
  Same as `get_pages/1`, but will raise `PhoenixPages.NotFoundError` if no pages were defined with
  the ID.
  """
  @callback get_pages!(id :: atom | binary) :: list(page)
end