Skip to main content

lib/astral/discovery.ex

defmodule Astral.Discovery do
  @moduledoc """
  Discovers pages and layouts in an Astral project.
  """

  @page_extensions ~w(.html .md .astral)

  @doc "Discover pages and layouts for a site."
  @spec discover(Astral.Config.t()) :: {:ok, Astral.Site.t()} | {:error, term()}
  def discover(%Astral.Config{} = config) do
    with {:ok, entries} <- discover_collections(config),
         site = site_for_page_discovery(config, entries),
         {:ok, pages} <- discover_pages(site),
         {:ok, layouts} <- read_layouts(config) do
      site = %Astral.Site{
        config: config,
        pages: pages ++ entry_pages(entries, config, pages),
        layouts: layouts,
        collections: config.collections,
        entries: entries
      }

      site = Astral.PluginRunner.site_discovered(config.plugins, site)

      with :ok <- validate_unique_page_routes(site.pages) do
        routes = Astral.PluginRunner.routes(config.plugins, site)

        {:ok, %{site | routes: routes}}
      end
    end
  end

  defp site_for_page_discovery(config, entries) do
    %Astral.Site{config: config, collections: config.collections, entries: entries}
  end

  defp discover_pages(%Astral.Site{config: config} = site) do
    if File.dir?(config.pages) do
      config.pages
      |> page_paths()
      |> build_pages(site)
    else
      {:error, {:missing_pages_dir, config.pages}}
    end
  end

  defp page_paths(pages_dir) do
    pages_dir
    |> Path.join("**/*")
    |> Path.wildcard()
    |> Enum.filter(&(Path.extname(&1) in @page_extensions))
    |> Enum.sort()
  end

  defp build_pages(paths, site) do
    Enum.reduce_while(paths, {:ok, []}, fn path, {:ok, pages} ->
      case pages(path, site) do
        {:ok, path_pages} -> {:cont, {:ok, [path_pages | pages]}}
        {:error, _} = error -> {:halt, error}
      end
    end)
    |> case do
      {:ok, pages} -> {:ok, pages |> Enum.reverse() |> List.flatten()}
      {:error, _} = error -> error
    end
  end

  defp pages(path, %Astral.Site{config: config} = site) do
    with {:ok, content} <- read_content(path) do
      relative = Path.relative_to(path, config.pages)
      file_route = Astral.Route.File.parse(relative)

      if file_route.dynamic? do
        dynamic_pages(path, content, file_route, site)
      else
        {:ok, [static_page(path, content, file_route, config)]}
      end
    end
  end

  defp static_page(path, content, file_route, config) do
    route_path = content.permalink || Astral.Route.File.static_path(file_route)

    %Astral.Page{
      source_path: path,
      route_path: route_path,
      output_path: Path.join(config.outdir, Astral.Route.output_relative(route_path)),
      content: content
    }
  end

  defp dynamic_pages(path, content, file_route, %Astral.Site{config: config} = site) do
    case entry_dynamic_pages(path, content, file_route, site.entries, config) do
      [] -> route_path_pages(path, content, file_route, site)
      pages -> {:ok, pages}
    end
  end

  defp route_path_pages(path, content, file_route, site) do
    if Astral.Template.template?(path) do
      route_path_pages_from_setup(path, content, file_route, site)
    else
      {:error, {:unmatched_dynamic_route, path, file_route.pattern.source}}
    end
  end

  defp route_path_pages_from_setup(path, content, file_route, site) do
    case Astral.Template.setup_binding_file(path, page_discovery_assigns(site), site.config) do
      {:ok, binding} ->
        build_route_path_pages_from_binding(path, content, file_route, binding, site.config)

      {:error, reason} ->
        {:error, {:dynamic_route_paths_failed, path, reason}}
    end
  end

  defp build_route_path_pages_from_binding(path, content, file_route, binding, config) do
    case route_paths_from_binding(binding) do
      {:ok, nil} ->
        {:error, {:unmatched_dynamic_route, path, file_route.pattern.source}}

      {:ok, route_paths} ->
        build_route_path_pages(path, content, file_route, route_paths, config)

      {:error, reason} ->
        {:error, {:dynamic_route_paths_failed, path, reason}}
    end
  end

  defp entry_dynamic_pages(path, content, file_route, entries, config) do
    entries
    |> Map.values()
    |> List.flatten()
    |> Enum.flat_map(fn entry -> dynamic_page(path, content, file_route, entry, config) end)
  end

  defp route_paths_from_binding(binding) do
    case Keyword.fetch(binding, :paths) do
      {:ok, paths} -> validate_route_paths(paths)
      :error -> {:ok, nil}
    end
  end

  defp validate_route_paths(paths) when is_list(paths) do
    if Enum.all?(paths, &match?(%Astral.Route.Path{}, &1)) do
      {:ok, paths}
    else
      {:error, {:invalid_route_paths, paths}}
    end
  end

  defp validate_route_paths(paths), do: {:error, {:invalid_route_paths, paths}}

  defp build_route_path_pages(path, content, file_route, route_paths, config) do
    {:ok, Enum.map(route_paths, &route_path_page(path, content, file_route, &1, config))}
  rescue
    error in [ArgumentError] -> {:error, {:dynamic_route_paths_failed, path, error}}
  end

  defp route_path_page(path, content, file_route, route_path, config) do
    page_route = Astral.Route.File.generate(file_route, route_path)

    %Astral.Page{
      source_path: path,
      route_path: page_route,
      output_path: Path.join(config.outdir, Astral.Route.output_relative(page_route)),
      content: content,
      params: route_path.params,
      assigns: route_path.assigns
    }
  end

  defp page_discovery_assigns(site) do
    %{
      site: site,
      config: site.config,
      collections: site.entries,
      routes: []
    }
  end

  defp dynamic_page(path, content, file_route, entry, config) do
    case Astral.Route.File.match(file_route, entry.route_path) do
      {:ok, params} ->
        [
          %Astral.Page{
            source_path: path,
            route_path: entry.route_path,
            output_path: Path.join(config.outdir, Astral.Route.output_relative(entry.route_path)),
            content: %{content | layout: content.layout || entry.content.layout},
            entry: entry,
            params: params
          }
        ]

      :error ->
        []
    end
  end

  defp validate_unique_page_routes(pages) do
    pages
    |> Enum.group_by(& &1.route_path)
    |> Enum.find(fn {_route_path, pages} -> match?([_, _ | _], pages) end)
    |> case do
      nil ->
        :ok

      {route_path, pages} ->
        sources = pages |> Enum.map(& &1.source_path) |> Enum.sort()
        {:error, {:duplicate_page_route, route_path, sources}}
    end
  end

  defp read_content(path) do
    case Path.extname(path) do
      ".md" -> read_markdown(path)
      ".html" -> read_html(path)
      ".astral" -> read_astral(path)
    end
  end

  defp read_markdown(path) do
    with {:ok, source} <- File.read(path) do
      Astral.Markdown.render(source)
    end
  end

  defp read_html(path) do
    with {:ok, source} <- File.read(path) do
      {:ok, %Astral.Content{html: source}}
    end
  end

  defp read_astral(path) do
    with {:ok, source} <- File.read(path) do
      {:ok, %Astral.Content{html: source}}
    end
  end

  defp discover_collections(config) do
    Enum.reduce_while(config.collections, {:ok, %{}}, fn collection, {:ok, entries} ->
      case discover_collection(collection) do
        {:ok, collection_entries} ->
          {:cont, {:ok, Map.put(entries, collection.name, collection_entries)}}

        {:error, _reason} = error ->
          {:halt, error}
      end
    end)
  end

  defp discover_collection(collection) do
    if File.dir?(collection.dir) do
      collection.dir
      |> page_paths()
      |> Enum.filter(&(Path.extname(&1) == ".md"))
      |> build_entries(collection)
    else
      {:error, {:missing_collection_dir, collection.name, collection.dir}}
    end
  end

  defp build_entries(paths, collection) do
    Enum.reduce_while(paths, {:ok, []}, fn path, {:ok, entries} ->
      case entry(path, collection) do
        {:ok, nil} -> {:cont, {:ok, entries}}
        {:ok, entry} -> {:cont, {:ok, [entry | entries]}}
        {:error, _reason} = error -> {:halt, error}
      end
    end)
    |> case do
      {:ok, entries} -> {:ok, Enum.reverse(entries)}
      {:error, _} = error -> error
    end
  end

  defp entry(path, collection) do
    with {:ok, source} <- File.read(path),
         {:ok, content} <- Astral.Markdown.render(source),
         {:ok, data} <-
           Astral.Schema.normalize(collection.schema, content.metadata,
             base: path,
             source_dirs: []
           ),
         false <- draft?(data) and not collection.drafts do
      slug = entry_slug(path, collection)
      route_path = content.permalink || entry_route_path(collection, slug)

      {:ok,
       %Astral.Entry{
         collection: collection.name,
         slug: slug,
         source_path: path,
         route_path: route_path,
         content: %{content | layout: content.layout || collection.layout},
         metadata: content.metadata,
         data: data
       }}
    else
      true -> {:ok, nil}
      {:error, _reason} = error -> error
    end
  end

  defp draft?(data) when is_map(data), do: Map.get(data, :draft) == true

  defp entry_slug(path, collection) do
    path
    |> Path.relative_to(collection.dir)
    |> Path.rootname(Path.extname(path))
    |> Path.split()
    |> Enum.join("/")
  end

  defp entry_route_path(%{permalink: nil}, slug), do: "/" <> slug <> "/"
  defp entry_route_path(collection, slug), do: String.replace(collection.permalink, ":slug", slug)

  defp entry_pages(entries, config, pages) do
    dynamic_routes = MapSet.new(pages, & &1.route_path)

    entries
    |> Map.values()
    |> List.flatten()
    |> Enum.reject(&MapSet.member?(dynamic_routes, &1.route_path))
    |> Enum.map(&entry_page(&1, config))
  end

  defp entry_page(entry, config) do
    %Astral.Page{
      source_path: entry.source_path,
      route_path: entry.route_path,
      output_path: Path.join(config.outdir, Astral.Route.output_relative(entry.route_path)),
      content: entry.content,
      entry: entry,
      params: %{}
    }
  end

  defp read_layouts(config) do
    if File.dir?(config.layouts) do
      config.layouts
      |> layout_paths()
      |> Enum.sort()
      |> Enum.reduce_while({:ok, %{}}, &read_layout_file(&1, &2, config))
    else
      {:ok, %{}}
    end
  end

  defp layout_paths(layouts_dir) do
    layouts_dir
    |> Path.join("**/*")
    |> Path.wildcard()
    |> Enum.filter(&(Path.extname(&1) in [".html", ".astral"]))
  end

  defp read_layout_file(path, {:ok, layouts}, config) do
    case File.read(path) do
      {:ok, source} ->
        layout =
          if Astral.Template.template?(path) do
            %Astral.Template.Source{path: path, source: source}
          else
            source
          end

        {:cont, {:ok, Map.put(layouts, Path.relative_to(path, config.layouts), layout)}}

      {:error, reason} ->
        {:halt, {:error, {:layout_read_failed, path, reason}}}
    end
  end
end