fix: A1-6 implement on-demand rendering in preview server per spec
This commit is contained in:
@@ -154,27 +154,42 @@ defmodule BDS.Preview do
|
||||
|
||||
:error ->
|
||||
with {:ok, relative_path, kind} <- route_request(request_path) do
|
||||
full_path =
|
||||
case kind do
|
||||
:media -> safe_join(server.data_dir, Path.join(["media", relative_path]))
|
||||
:generated -> safe_join(Path.join(server.data_dir, "html"), relative_path)
|
||||
end
|
||||
case kind do
|
||||
:media ->
|
||||
serve_file(safe_join(server.data_dir, Path.join(["media", relative_path])),
|
||||
server: server, query_params: query_params)
|
||||
|
||||
case full_path do
|
||||
{:error, :not_found} ->
|
||||
{:error, :not_found}
|
||||
:generated ->
|
||||
case BDS.Preview.Router.render_route(server.project_id, request_path) do
|
||||
{:ok, response} ->
|
||||
{:ok, apply_response_overrides(response, query_params)}
|
||||
|
||||
resolved_path ->
|
||||
case read_response(resolved_path) do
|
||||
{:error, :not_found} -> render_not_found_response(server.project_id, query_params)
|
||||
{:ok, response} -> {:ok, apply_response_overrides(response, query_params)}
|
||||
other -> other
|
||||
:not_matched ->
|
||||
serve_file(safe_join(Path.join(server.data_dir, "html"), relative_path),
|
||||
server: server, query_params: query_params)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp serve_file({:error, :not_found}, opts) do
|
||||
render_not_found_response(opts[:server].project_id, opts[:query_params])
|
||||
end
|
||||
|
||||
defp serve_file(resolved_path, opts) do
|
||||
case read_response(resolved_path) do
|
||||
{:error, :not_found} ->
|
||||
render_not_found_response(opts[:server].project_id, opts[:query_params])
|
||||
|
||||
{:ok, response} ->
|
||||
{:ok, apply_response_overrides(response, opts[:query_params])}
|
||||
|
||||
other ->
|
||||
other
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_draft_request(project_id, post_id, query_params) do
|
||||
with {:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
|
||||
body =
|
||||
|
||||
560
lib/bds/preview/router.ex
Normal file
560
lib/bds/preview/router.ex
Normal file
@@ -0,0 +1,560 @@
|
||||
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
|
||||
Reference in New Issue
Block a user