561 lines
17 KiB
Elixir
561 lines
17 KiB
Elixir
defmodule BDS.Preview.Router do
|
|
@moduledoc false
|
|
|
|
import Ecto.Query
|
|
|
|
alias BDS.Generation.Paths
|
|
alias BDS.MapUtils
|
|
alias BDS.Metadata, as: ProjectMetadata
|
|
alias BDS.Posts
|
|
alias BDS.Posts.Post
|
|
alias BDS.Posts.Translation
|
|
alias BDS.Rendering
|
|
alias BDS.Repo
|
|
|
|
@type route ::
|
|
{:home, pos_integer()}
|
|
| {:post, String.t(), integer(), integer(), integer()}
|
|
| {:page, String.t()}
|
|
| {:category, String.t(), pos_integer()}
|
|
| {:tag, String.t(), pos_integer()}
|
|
| {:year, integer(), pos_integer()}
|
|
| {:month, integer(), integer(), pos_integer()}
|
|
| {:day, integer(), integer(), integer(), pos_integer()}
|
|
| :not_matched
|
|
|
|
@spec render_route(String.t(), String.t()) :: {:ok, map()} | :not_matched
|
|
def render_route(project_id, request_path) do
|
|
{:ok, metadata} = ProjectMetadata.get_project_metadata(project_id)
|
|
main_language = metadata.main_language || "en"
|
|
blog_languages = metadata.blog_languages || []
|
|
additional_languages = Enum.reject(blog_languages, &(&1 == main_language))
|
|
|
|
segments = String.split(request_path, "/", trim: true)
|
|
{language, route_segments} = extract_language_prefix(segments, additional_languages)
|
|
effective_language = language || main_language
|
|
|
|
case match_route(route_segments) do
|
|
:not_matched ->
|
|
:not_matched
|
|
|
|
route ->
|
|
case render(project_id, route, effective_language, main_language, metadata) do
|
|
{:ok, body} ->
|
|
{:ok, %{content_type: "text/html", body: body}}
|
|
|
|
{:error, :not_found} ->
|
|
:not_matched
|
|
end
|
|
end
|
|
end
|
|
|
|
@spec match_route([String.t()]) :: route()
|
|
def match_route([]), do: {:home, 1}
|
|
def match_route(["page", n]), do: {:home, parse_page(n)}
|
|
|
|
def match_route(["category", name]), do: {:category, URI.decode(name), 1}
|
|
|
|
def match_route(["category", name, "page", n]),
|
|
do: {:category, URI.decode(name), parse_page(n)}
|
|
|
|
def match_route(["tag", name]), do: {:tag, URI.decode(name), 1}
|
|
def match_route(["tag", name, "page", n]), do: {:tag, URI.decode(name), parse_page(n)}
|
|
|
|
def match_route([y, m, d, slug]) do
|
|
with {year, ""} <- Integer.parse(y),
|
|
{month, ""} <- Integer.parse(m),
|
|
{day, ""} <- Integer.parse(d) do
|
|
{:post, slug, year, month, day}
|
|
else
|
|
_ -> :not_matched
|
|
end
|
|
end
|
|
|
|
def match_route([y, m, d, "page", n]) do
|
|
with {year, ""} <- Integer.parse(y),
|
|
{month, ""} <- Integer.parse(m),
|
|
{day, ""} <- Integer.parse(d) do
|
|
{:day, year, month, day, parse_page(n)}
|
|
else
|
|
_ -> :not_matched
|
|
end
|
|
end
|
|
|
|
def match_route([y, m, d]) do
|
|
with {year, ""} <- Integer.parse(y),
|
|
{month, ""} <- Integer.parse(m),
|
|
{day, ""} <- Integer.parse(d) do
|
|
{:day, year, month, day, 1}
|
|
else
|
|
_ -> :not_matched
|
|
end
|
|
end
|
|
|
|
def match_route([y, m, "page", n]) do
|
|
with {year, ""} <- Integer.parse(y),
|
|
{month, ""} <- Integer.parse(m) do
|
|
{:month, year, month, parse_page(n)}
|
|
else
|
|
_ -> :not_matched
|
|
end
|
|
end
|
|
|
|
def match_route([y, m]) do
|
|
with {year, ""} <- Integer.parse(y),
|
|
{month, ""} <- Integer.parse(m) do
|
|
{:month, year, month, 1}
|
|
else
|
|
_ -> :not_matched
|
|
end
|
|
end
|
|
|
|
def match_route([y, "page", n]) do
|
|
with {year, ""} <- Integer.parse(y) do
|
|
{:year, year, parse_page(n)}
|
|
else
|
|
_ -> :not_matched
|
|
end
|
|
end
|
|
|
|
def match_route([y]) do
|
|
case Integer.parse(y) do
|
|
{year, ""} -> {:year, year, 1}
|
|
_ -> {:page, y}
|
|
end
|
|
end
|
|
|
|
def match_route(_segments), do: :not_matched
|
|
|
|
## Rendering
|
|
|
|
defp render(project_id, {:home, page_number}, language, main_language, metadata) do
|
|
posts = load_published_list_posts(project_id, metadata)
|
|
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
|
render_list(project_id, posts, page_number, metadata, language, main_language, %{kind: "core"})
|
|
end
|
|
|
|
defp render(project_id, {:post, slug, year, month, day}, language, main_language, _metadata) do
|
|
case find_post_by_slug_and_date(project_id, slug, year, month, day) do
|
|
nil ->
|
|
{:error, :not_found}
|
|
|
|
post ->
|
|
render_post(project_id, post, language, main_language)
|
|
end
|
|
end
|
|
|
|
defp render(project_id, {:page, slug}, language, main_language, _metadata) do
|
|
case find_page_by_slug(project_id, slug) do
|
|
nil -> {:error, :not_found}
|
|
post -> render_post(project_id, post, language, main_language)
|
|
end
|
|
end
|
|
|
|
defp render(project_id, {:category, name, page_number}, language, main_language, metadata) do
|
|
posts = load_published_posts_by_category(project_id, name)
|
|
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
|
|
|
render_list(project_id, posts, page_number, metadata, language, main_language, %{
|
|
kind: "category",
|
|
name: name
|
|
})
|
|
end
|
|
|
|
defp render(project_id, {:tag, name, page_number}, language, main_language, metadata) do
|
|
posts = load_published_posts_by_tag(project_id, name)
|
|
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
|
|
|
render_list(project_id, posts, page_number, metadata, language, main_language, %{
|
|
kind: "tag",
|
|
name: name
|
|
})
|
|
end
|
|
|
|
defp render(project_id, {:year, year, page_number}, language, main_language, metadata) do
|
|
posts = load_published_posts_by_year(project_id, year)
|
|
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
|
|
|
render_list(project_id, posts, page_number, metadata, language, main_language, %{
|
|
kind: "date",
|
|
year: year
|
|
})
|
|
end
|
|
|
|
defp render(project_id, {:month, year, month, page_number}, language, main_language, metadata) do
|
|
posts = load_published_posts_by_month(project_id, year, month)
|
|
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
|
|
|
render_list(project_id, posts, page_number, metadata, language, main_language, %{
|
|
kind: "date",
|
|
year: year,
|
|
month: month
|
|
})
|
|
end
|
|
|
|
defp render(project_id, {:day, year, month, day, page_number}, language, main_language, metadata) do
|
|
posts = load_published_posts_by_day(project_id, year, month, day)
|
|
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
|
|
|
render_list(project_id, posts, page_number, metadata, language, main_language, %{
|
|
kind: "date",
|
|
year: year,
|
|
month: month,
|
|
day: day
|
|
})
|
|
end
|
|
|
|
## Post rendering
|
|
|
|
defp render_post(project_id, post, language, main_language) do
|
|
{effective_record, body} = resolve_post_for_language(project_id, post, language, main_language)
|
|
|
|
assigns = %{
|
|
id: effective_record.id,
|
|
title: effective_record.title,
|
|
content: body,
|
|
slug: post.slug,
|
|
language: Map.get(effective_record, :language, post.language),
|
|
excerpt: Map.get(effective_record, :excerpt, post.excerpt),
|
|
_post_record: effective_record
|
|
}
|
|
|
|
case Rendering.render_post_page(project_id, post.template_slug, assigns) do
|
|
{:ok, rendered} -> {:ok, rendered}
|
|
{:error, _reason} -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
defp resolve_post_for_language(project_id, post, language, main_language) do
|
|
post_lang = String.downcase(to_string(post.language || main_language))
|
|
target_lang = String.downcase(to_string(language))
|
|
|
|
if post_lang == target_lang do
|
|
{post, Posts.editor_body(post)}
|
|
else
|
|
case Repo.get_by(Translation,
|
|
translation_for: post.id,
|
|
language: language,
|
|
project_id: project_id
|
|
) do
|
|
%Translation{status: status} = translation when status in [:published, :draft] ->
|
|
{translation, Posts.editor_body(translation)}
|
|
|
|
_ ->
|
|
{post, Posts.editor_body(post)}
|
|
end
|
|
end
|
|
end
|
|
|
|
## List rendering
|
|
|
|
defp render_list(project_id, posts, page_number, metadata, language, main_language, archive_ctx) do
|
|
max_per_page = max(metadata.max_posts_per_page || 50, 1)
|
|
total_items = length(posts)
|
|
total_pages = Paths.page_count(total_items, max_per_page)
|
|
|
|
if page_number > total_pages and page_number > 1 do
|
|
{:error, :not_found}
|
|
else
|
|
page_posts =
|
|
posts
|
|
|> Enum.chunk_every(max_per_page)
|
|
|> Enum.at(page_number - 1, [])
|
|
|> Enum.map(&post_to_list_entry(project_id, &1, language, main_language))
|
|
|
|
language_prefix = Paths.language_prefix(language, main_language)
|
|
route_language = Paths.route_language(main_language, language)
|
|
|
|
segments = archive_context_to_segments(archive_ctx)
|
|
|
|
pagination = %{
|
|
current_page: page_number,
|
|
total_pages: total_pages,
|
|
total_items: total_items,
|
|
items_per_page: max_per_page,
|
|
has_prev_page: page_number > 1,
|
|
prev_page_href: Paths.archive_or_root_href(route_language, segments, page_number - 1),
|
|
has_next_page: page_number < total_pages,
|
|
next_page_href: Paths.archive_or_root_href(route_language, segments, page_number + 1)
|
|
}
|
|
|
|
assigns = %{
|
|
language: language,
|
|
language_prefix: language_prefix,
|
|
page_title: archive_page_title(archive_ctx),
|
|
posts: page_posts,
|
|
archive_context: archive_ctx,
|
|
pagination: pagination
|
|
}
|
|
|
|
try do
|
|
case Rendering.render_list_page(project_id, assigns) do
|
|
{:ok, rendered} -> {:ok, rendered}
|
|
{:error, _reason} -> {:ok, fallback_list_html(page_posts, archive_ctx)}
|
|
end
|
|
rescue
|
|
_ -> {:ok, fallback_list_html(page_posts, archive_ctx)}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp post_to_list_entry(_project_id, post, language, main_language) do
|
|
route_language = Paths.route_language(main_language, language)
|
|
|
|
%{
|
|
id: post.id,
|
|
slug: post.slug,
|
|
title: post.title,
|
|
href: Paths.url_for_output(nil, Paths.post_output_path(post, route_language)),
|
|
excerpt: post.excerpt,
|
|
content: Posts.editor_body(post),
|
|
language: post.language,
|
|
author: post.author,
|
|
created_at: post.created_at,
|
|
updated_at: post.updated_at,
|
|
published_at: post.published_at,
|
|
tags: post.tags || [],
|
|
categories: post.categories || [],
|
|
template_slug: post.template_slug,
|
|
do_not_translate: Map.get(post, :do_not_translate, false)
|
|
}
|
|
end
|
|
|
|
defp archive_context_to_segments(%{kind: "core"}), do: []
|
|
defp archive_context_to_segments(%{kind: "category", name: name}), do: ["category", name]
|
|
defp archive_context_to_segments(%{kind: "tag", name: name}), do: ["tag", name]
|
|
|
|
defp archive_context_to_segments(%{kind: "date", year: y, month: m, day: d})
|
|
when is_integer(y) and is_integer(m) and is_integer(d) do
|
|
[
|
|
Integer.to_string(y),
|
|
String.pad_leading(Integer.to_string(m), 2, "0"),
|
|
String.pad_leading(Integer.to_string(d), 2, "0")
|
|
]
|
|
end
|
|
|
|
defp archive_context_to_segments(%{kind: "date", year: y, month: m})
|
|
when is_integer(y) and is_integer(m) do
|
|
[Integer.to_string(y), String.pad_leading(Integer.to_string(m), 2, "0")]
|
|
end
|
|
|
|
defp archive_context_to_segments(%{kind: "date", year: y}) when is_integer(y),
|
|
do: [Integer.to_string(y)]
|
|
|
|
defp archive_context_to_segments(_), do: []
|
|
|
|
defp fallback_list_html(posts, archive_ctx) do
|
|
title = archive_page_title(archive_ctx) || "Archive"
|
|
|
|
items =
|
|
posts
|
|
|> Enum.map(fn post ->
|
|
["<li>", to_string(Map.get(post, :title, "")), "</li>"]
|
|
end)
|
|
|> IO.iodata_to_binary()
|
|
|
|
IO.iodata_to_binary([
|
|
"<html><body><h1>",
|
|
title,
|
|
"</h1><ul>",
|
|
items,
|
|
"</ul></body></html>"
|
|
])
|
|
end
|
|
|
|
defp archive_page_title(%{kind: "category", name: name}), do: name
|
|
defp archive_page_title(%{kind: "tag", name: name}), do: name
|
|
|
|
defp archive_page_title(%{kind: "date", year: y, month: m, day: d})
|
|
when is_integer(y) and is_integer(m) and is_integer(d),
|
|
do: "#{y}-#{String.pad_leading(Integer.to_string(m), 2, "0")}-#{String.pad_leading(Integer.to_string(d), 2, "0")}"
|
|
|
|
defp archive_page_title(%{kind: "date", year: y, month: m})
|
|
when is_integer(y) and is_integer(m),
|
|
do: "#{y}-#{String.pad_leading(Integer.to_string(m), 2, "0")}"
|
|
|
|
defp archive_page_title(%{kind: "date", year: y}) when is_integer(y), do: Integer.to_string(y)
|
|
defp archive_page_title(_), do: nil
|
|
|
|
## Data loading
|
|
|
|
@default_category_settings %{
|
|
"article" => %{render_in_lists: true},
|
|
"picture" => %{render_in_lists: true},
|
|
"aside" => %{render_in_lists: true},
|
|
"page" => %{render_in_lists: false}
|
|
}
|
|
|
|
defp load_published_list_posts(project_id, metadata) do
|
|
raw_settings = Map.get(metadata, :category_settings, %{}) || %{}
|
|
|
|
resolved =
|
|
Enum.reduce(raw_settings, @default_category_settings, fn {category, settings}, acc ->
|
|
flag =
|
|
case MapUtils.attr(settings, :render_in_lists, true) do
|
|
false -> false
|
|
_ -> true
|
|
end
|
|
|
|
Map.put(acc, category, %{render_in_lists: flag})
|
|
end)
|
|
|
|
excluded =
|
|
resolved
|
|
|> Enum.filter(fn {_cat, settings} -> settings.render_in_lists == false end)
|
|
|> Enum.map(&elem(&1, 0))
|
|
|> MapSet.new()
|
|
|
|
project_id
|
|
|> load_all_published_posts()
|
|
|> Enum.reject(fn post ->
|
|
Enum.any?(post.categories || [], &MapSet.member?(excluded, &1))
|
|
end)
|
|
end
|
|
|
|
defp load_all_published_posts(project_id) do
|
|
Repo.all(
|
|
from p in Post,
|
|
where: p.project_id == ^project_id and p.status == :published,
|
|
order_by: [desc: p.created_at, desc: p.published_at, asc: p.slug]
|
|
)
|
|
end
|
|
|
|
defp load_published_posts_by_category(project_id, category) do
|
|
project_id
|
|
|> load_all_published_posts()
|
|
|> Enum.filter(fn post -> category in (post.categories || []) end)
|
|
end
|
|
|
|
defp load_published_posts_by_tag(project_id, tag) do
|
|
project_id
|
|
|> load_all_published_posts()
|
|
|> Enum.filter(fn post -> tag in (post.tags || []) end)
|
|
end
|
|
|
|
defp load_published_posts_by_year(project_id, year) do
|
|
project_id
|
|
|> load_all_published_posts()
|
|
|> Enum.filter(fn post ->
|
|
{post_year, _, _} = Paths.local_date_parts!(post.created_at)
|
|
post_year == year
|
|
end)
|
|
end
|
|
|
|
defp load_published_posts_by_month(project_id, year, month) do
|
|
project_id
|
|
|> load_all_published_posts()
|
|
|> Enum.filter(fn post ->
|
|
{post_year, post_month, _} = Paths.local_date_parts!(post.created_at)
|
|
post_year == year and post_month == month
|
|
end)
|
|
end
|
|
|
|
defp load_published_posts_by_day(project_id, year, month, day) do
|
|
project_id
|
|
|> load_all_published_posts()
|
|
|> Enum.filter(fn post ->
|
|
{post_year, post_month, post_day} = Paths.local_date_parts!(post.created_at)
|
|
post_year == year and post_month == month and post_day == day
|
|
end)
|
|
end
|
|
|
|
defp find_post_by_slug_and_date(project_id, slug, year, month, day) do
|
|
case Repo.one(
|
|
from p in Post,
|
|
where: p.project_id == ^project_id and p.slug == ^slug and p.status == :published
|
|
) do
|
|
nil ->
|
|
nil
|
|
|
|
post ->
|
|
{post_year, post_month, post_day} = Paths.local_date_parts!(post.created_at)
|
|
|
|
if post_year == year and post_month == month and post_day == day do
|
|
post
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
|
|
defp find_page_by_slug(project_id, slug) do
|
|
case Repo.one(
|
|
from p in Post,
|
|
where: p.project_id == ^project_id and p.slug == ^slug and p.status == :published
|
|
) do
|
|
%Post{categories: categories} = post ->
|
|
if "page" in (categories || []), do: post, else: nil
|
|
|
|
nil ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
## Language resolution
|
|
|
|
defp maybe_resolve_language(posts, language, main_language, project_id) do
|
|
if String.downcase(to_string(language)) == String.downcase(to_string(main_language)) do
|
|
posts
|
|
else
|
|
translations = load_translations_for_language(project_id, Enum.map(posts, & &1.id), language)
|
|
|
|
Enum.map(posts, fn post ->
|
|
case Map.get(translations, post.id) do
|
|
nil -> post
|
|
translation -> overlay_translation(post, translation)
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
defp load_translations_for_language(project_id, post_ids, language) do
|
|
if Enum.empty?(post_ids) do
|
|
%{}
|
|
else
|
|
Repo.all(
|
|
from t in Translation,
|
|
where:
|
|
t.project_id == ^project_id and
|
|
t.translation_for in ^post_ids and
|
|
t.language == ^language and
|
|
t.status in [:published, :draft]
|
|
)
|
|
|> Map.new(&{&1.translation_for, &1})
|
|
end
|
|
end
|
|
|
|
defp overlay_translation(post, translation) do
|
|
%{
|
|
post
|
|
| id: translation.id,
|
|
title: translation.title,
|
|
excerpt: translation.excerpt,
|
|
content: translation.content,
|
|
language: translation.language,
|
|
updated_at: translation.updated_at,
|
|
published_at: translation.published_at || post.published_at
|
|
}
|
|
end
|
|
|
|
## Helpers
|
|
|
|
defp extract_language_prefix([], _additional_languages), do: {nil, []}
|
|
|
|
defp extract_language_prefix([first | rest] = segments, additional_languages) do
|
|
normalized = String.downcase(first)
|
|
|
|
if normalized in Enum.map(additional_languages, &String.downcase/1) do
|
|
{normalized, rest}
|
|
else
|
|
{nil, segments}
|
|
end
|
|
end
|
|
|
|
defp parse_page(n) do
|
|
case Integer.parse(n) do
|
|
{page, ""} when page >= 1 -> page
|
|
_ -> 1
|
|
end
|
|
end
|
|
end
|