feat: PLAN step 1 done, supposedly

This commit is contained in:
2026-04-25 21:53:44 +02:00
parent f1957cbab2
commit 2991edf4cf
18 changed files with 704 additions and 133 deletions

View File

@@ -56,7 +56,11 @@ defmodule BDS.Frontmatter do
Integer.to_string(value)
end
["#{key}: #{rendered}"]
if timestamp_key?(key) do
["#{key}: '#{rendered}'"]
else
["#{key}: #{rendered}"]
end
end
defp serialize_field({key, value}) do
@@ -196,7 +200,7 @@ defmodule BDS.Frontmatter do
defp serialize_scalar(key, value) when is_integer(value) do
if is_binary(key) and timestamp_key?(key) do
Persistence.timestamp_to_iso8601(value)
"'#{Persistence.timestamp_to_iso8601(value)}'"
else
Integer.to_string(value)
end

View File

@@ -238,7 +238,13 @@ defmodule BDS.Generation do
single_outputs =
if :single in plan.sections do
build_single_outputs(plan.project_id, published_posts, published_translations, post_by_id)
build_single_outputs(
plan.project_id,
plan.language,
published_posts,
published_translations,
post_by_id
)
else
[]
end
@@ -439,7 +445,14 @@ defmodule BDS.Generation do
Enum.map(languages, fn language ->
{
archive_path(route_language(plan.language, language), [year], 1),
render_date_archive_page(plan, year, posts, language, pagination)
render_date_archive_page(
plan,
year,
%{kind: "year", year: String.to_integer(year)},
posts,
language,
pagination
)
}
end)
end)
@@ -451,7 +464,14 @@ defmodule BDS.Generation do
Enum.map(languages, fn language ->
{
archive_path(route_language(plan.language, language), [year, month], 1),
render_date_archive_page(plan, "#{year}-#{month}", posts, language, pagination)
render_date_archive_page(
plan,
"#{year}-#{month}",
%{kind: "month", year: String.to_integer(year), month: String.to_integer(month)},
posts,
language,
pagination
)
}
end)
end)
@@ -505,62 +525,100 @@ defmodule BDS.Generation do
end)
end
defp build_single_outputs(project_id, published_posts, published_translations, post_by_id) do
defp build_single_outputs(
project_id,
main_language,
published_posts,
published_translations,
post_by_id
) do
translations_by_post_language =
Map.new(published_translations, fn translation ->
{{translation.translation_for, translation.language}, translation}
end)
post_outputs =
Enum.map(published_posts, fn post ->
body = load_body(project_id, post.file_path, post.content)
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
body = load_body(project_id, canonical_variant.file_path, canonical_variant.content)
{post_output_path(post),
render_post_output(
project_id,
post.template_slug,
%{
id: post.id,
title: post.title,
id: canonical_variant.id,
title: canonical_variant.title,
content: body,
slug: post.slug,
language: post.language,
excerpt: post.excerpt
language: canonical_variant.language,
excerpt: canonical_variant.excerpt
},
fn ->
render_post_page(post.title, body, post.slug, post.language)
render_post_page(canonical_variant.title, body, post.slug, canonical_variant.language)
end
)}
end)
translation_outputs =
Enum.flat_map(published_translations, fn translation ->
case post_by_id[translation.translation_for] do
nil ->
[]
post ->
body = load_body(project_id, translation.file_path, translation.content)
[
{post_output_path(post, translation.language),
render_post_output(
project_id,
post.template_slug,
%{
id: translation.id,
title: translation.title,
content: body,
slug: post.slug,
language: translation.language,
excerpt: translation.excerpt
},
fn ->
render_post_page(translation.title, body, post.slug, translation.language)
end
)}
]
end
end)
post_outputs_for_noncanonical_variants(
project_id,
main_language,
published_posts,
published_translations,
post_by_id
)
post_outputs ++ translation_outputs
end
defp post_outputs_for_noncanonical_variants(
project_id,
main_language,
published_posts,
published_translations,
post_by_id
) do
Enum.flat_map(published_posts, fn post ->
post_variant =
if post.language == main_language do
[]
else
[{post.language, post}]
end
translation_variants =
published_translations
|> Enum.filter(&(&1.translation_for == post.id and &1.language != main_language))
|> Enum.map(&{&1.language, &1})
(post_variant ++ translation_variants)
|> Enum.flat_map(fn {language, variant} ->
canonical_post = Map.get(post_by_id, post.id, post)
body = load_body(project_id, variant.file_path, variant.content)
[
{post_output_path(canonical_post, language),
render_post_output(
project_id,
canonical_post.template_slug,
%{
id: variant.id,
title: variant.title,
content: body,
slug: canonical_post.slug,
language: variant.language,
excerpt: variant.excerpt
},
fn ->
render_post_page(variant.title, body, canonical_post.slug, variant.language)
end
)}
]
end)
end)
end
defp list_published_posts(project_id) do
Repo.all(
from post in Post,
@@ -778,7 +836,7 @@ defmodule BDS.Generation do
)
end
defp render_date_archive_page(plan, label, posts, language, pagination) do
defp render_date_archive_page(plan, label, archive_context, posts, language, pagination) do
fallback = fn ->
items =
posts
@@ -801,18 +859,8 @@ defmodule BDS.Generation do
plan,
language,
label,
Enum.map(posts, fn post ->
%{
id: post.id,
slug: post.slug,
title: post.title,
href: "#",
excerpt: post.excerpt,
content: nil,
language: post.language
}
end),
%{kind: "date", name: label},
build_list_posts(plan.base_url, posts, route_language(plan.language, language)),
archive_context,
pagination,
fallback
)

View File

@@ -367,16 +367,25 @@ defmodule BDS.Maintenance do
end
defp diff_field(name, db_value, file_value) do
db_value = stringify_value(db_value)
file_value = stringify_value(file_value)
if db_value == file_value do
if equal_diff_values?(db_value, file_value) do
nil
else
%{name: name, db_value: db_value, file_value: file_value}
%{name: name, db_value: stringify_value(db_value), file_value: stringify_value(file_value)}
end
end
defp equal_diff_values?(left, right) when is_list(left) and is_list(right) do
normalize_list_diff_values(left) == normalize_list_diff_values(right)
end
defp equal_diff_values?(left, right), do: stringify_value(left) == stringify_value(right)
defp normalize_list_diff_values(values) do
values
|> Enum.map(&stringify_value/1)
|> Enum.sort()
end
defp stringify_value(nil), do: ""
defp stringify_value(value) when is_atom(value), do: Atom.to_string(value)
defp stringify_value(value) when is_boolean(value), do: to_string(value)

View File

@@ -4,7 +4,9 @@ defmodule BDS.Preview do
use GenServer
alias BDS.Posts
alias BDS.Posts.Translation
alias BDS.Projects
alias BDS.Repo
alias BDS.Rendering
@host "127.0.0.1"
@@ -34,24 +36,9 @@ defmodule BDS.Preview do
def preview_draft(project_id, request_path, post_id)
when is_binary(project_id) and is_binary(request_path) and is_binary(post_id) do
post = Posts.get_post!(post_id)
{_path, query_params} = split_request_path(request_path)
GenServer.call(__MODULE__, {
:preview_draft,
project_id,
query_params,
%{
id: post.id,
title: post.title,
content: post.content || "",
body: post.content || "",
slug: post.slug,
language: post.language,
excerpt: post.excerpt,
template_slug: post.template_slug
}
})
GenServer.call(__MODULE__, {:preview_draft, project_id, query_params, post_id})
end
@impl true
@@ -108,12 +95,13 @@ defmodule BDS.Preview do
end
end
def handle_call({:preview_draft, project_id, query_params, post}, _from, state) do
with :ok <- ensure_running(state.current, project_id) do
def handle_call({:preview_draft, project_id, query_params, post_id}, _from, state) do
with :ok <- ensure_running(state.current, project_id),
{:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
body =
case Rendering.render_post_page(project_id, Map.get(post, :template_slug), post) do
case Rendering.render_post_page(project_id, Map.get(payload, :template_slug), payload) do
{:ok, rendered} -> rendered
{:error, _reason} -> render_draft(post)
{:error, _reason} -> render_draft(payload)
end
|> apply_preview_overrides(query_params)
@@ -124,6 +112,7 @@ defmodule BDS.Preview do
{:reply, {:ok, response}, state}
else
{:error, :not_found} = error -> {:reply, error, state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
@@ -174,11 +163,50 @@ defmodule BDS.Preview do
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 =
case Rendering.render_post_page(project_id, Map.get(payload, :template_slug), payload) do
{:ok, rendered} -> rendered
{:error, _reason} -> render_draft(payload)
end
|> apply_preview_overrides(query_params)
{:ok, %{content_type: "text/html", body: body}}
end
end
defp load_draft_preview_payload(project_id, post_id, query_params) do
try do
post = Posts.get_post!(post_id)
if post.project_id == project_id do
payload = %{
{:ok, draft_preview_payload(post, query_params)}
else
{:error, :not_found}
end
rescue
Ecto.NoResultsError -> {:error, :not_found}
end
end
defp draft_preview_payload(post, query_params) do
requested_language = query_params |> Map.get("lang") |> normalize_requested_language()
case draft_preview_translation(post.id, requested_language, post.language) do
%Translation{} = translation ->
%{
id: translation.id,
title: translation.title,
content: translation.content || "",
body: translation.content || "",
slug: post.slug,
language: translation.language,
excerpt: translation.excerpt,
template_slug: post.template_slug
}
nil ->
%{
id: post.id,
title: post.title,
content: post.content || "",
@@ -188,23 +216,20 @@ defmodule BDS.Preview do
excerpt: post.excerpt,
template_slug: post.template_slug
}
body =
case Rendering.render_post_page(project_id, post.template_slug, payload) do
{:ok, rendered} -> rendered
{:error, _reason} -> render_draft(payload)
end
|> apply_preview_overrides(query_params)
{:ok, %{content_type: "text/html", body: body}}
else
{:error, :not_found}
end
rescue
Ecto.NoResultsError -> {:error, :not_found}
end
end
defp draft_preview_translation(_post_id, nil, _post_language), do: nil
defp draft_preview_translation(_post_id, requested_language, post_language) when requested_language == post_language, do: nil
defp draft_preview_translation(post_id, requested_language, _post_language) do
Repo.get_by(Translation, translation_for: post_id, language: requested_language)
end
defp normalize_requested_language(nil), do: nil
defp normalize_requested_language(""), do: nil
defp normalize_requested_language(language), do: language |> String.trim() |> String.downcase()
defp route_request(request_path) do
normalized = request_path |> URI.parse() |> Map.get(:path, "/")
segments = String.split(normalized, "/", trim: true)

View File

@@ -500,6 +500,8 @@ defmodule BDS.Rendering do
language_prefix <> post_path(post, nil)
end
defp post_path(post, ""), do: post_path(post, nil)
defp post_path(post, nil) do
datetime = Persistence.from_unix_ms!(post.created_at)

View File

@@ -4,6 +4,7 @@ defmodule BDS.Rendering.Filters do
use Liquex.Filter
alias BDS.I18n
alias BDS.Slug
def i18n(value, language, _context) do
key = value |> to_string() |> String.trim()
@@ -32,6 +33,12 @@ defmodule BDS.Rendering.Filters do
|> rewrite_rendered_html_urls(canonical_post_paths || %{}, canonical_media_paths || %{})
end
def slugify(value, _context) do
value
|> to_string()
|> Slug.slugify()
end
defp replace_built_in_macros(content, language, context) do
Regex.replace(~r/\[\[(\w+)(?:\s+([^\]]+))?\]\]/, content, fn full_match,
macro_name,

View File

@@ -4,12 +4,18 @@ defmodule BDS.Sidecar do
alias BDS.Persistence
@list_item_prefix " - "
@document_marker "---"
@always_quoted_keys MapSet.new(["originalName", "title", "alt", "caption", "author"])
def serialize_document(fields) when is_list(fields) do
fields
|> Enum.flat_map(&serialize_field/1)
serialized_fields =
fields
|> Enum.flat_map(&serialize_field/1)
|> Enum.join("\n")
[@document_marker, serialized_fields, @document_marker]
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")
|> Kernel.<>("\n")
end
def parse_document(contents) when is_binary(contents) do
@@ -23,7 +29,8 @@ defmodule BDS.Sidecar do
defp serialize_field({_key, ""}), do: []
defp serialize_field({key, values}) when is_list(values) do
["#{key}:" | Enum.map(values, &" - #{serialize_scalar(nil, &1)}")]
serialized_values = values |> Enum.map(&serialize_inline_list_scalar/1) |> Enum.join(", ")
["#{key}: [#{serialized_values}]"]
end
defp serialize_field({key, value}) when is_boolean(value) do
@@ -104,6 +111,10 @@ defmodule BDS.Sidecar do
defp parse_generic_scalar("false"), do: false
defp parse_generic_scalar("[]"), do: []
defp parse_generic_scalar("[" <> _rest = value) do
parse_inline_list(value)
end
defp parse_generic_scalar(value) do
if Regex.match?(~r/^-?\d+$/, value) do
String.to_integer(value)
@@ -142,26 +153,77 @@ defmodule BDS.Sidecar do
end
end
defp serialize_scalar(_key, value) do
defp serialize_scalar(key, value) do
string_value = to_string(value)
if is_binary(key) and MapSet.member?(@always_quoted_keys, key) do
quote_string(string_value)
else
maybe_quote_string(string_value)
end
end
defp serialize_inline_list_scalar(value) when is_binary(value), do: quote_string(value)
defp serialize_inline_list_scalar(value), do: serialize_scalar(nil, value)
defp parse_inline_list(value) do
inner =
value
|> String.trim()
|> String.trim_leading("[")
|> String.trim_trailing("]")
|> String.trim()
if inner == "" do
[]
else
parse_inline_list_items(inner)
end
end
defp parse_inline_list_items(inner) do
if String.contains?(inner, "\"") or String.contains?(inner, "'") do
Regex.scan(~r/"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'/, inner, capture: :all_but_first)
|> Enum.map(fn captures ->
captures
|> Enum.find(&(&1 != ""))
|> parse_inline_string_item()
end)
else
inner
|> String.split(",", trim: true)
|> Enum.map(&(String.trim(&1) |> parse_scalar(nil)))
end
end
defp parse_inline_string_item(nil), do: ""
defp parse_inline_string_item(value) do
value
|> to_string()
|> maybe_quote_string()
|> String.replace("\\n", "\n")
|> String.replace("\\\"", "\"")
|> String.replace("\\'", "'")
|> String.replace("\\\\", "\\")
end
defp maybe_quote_string(value) do
if Regex.match?(~r/^[\p{L}\p{N} ._\/-]+$/u, value) do
value
else
escaped =
value
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
|> String.replace("\n", "\\n")
"\"#{escaped}\""
quote_string(value)
end
end
defp quote_string(value) do
escaped =
value
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
|> String.replace("\n", "\\n")
"\"#{escaped}\""
end
defp timestamp_key?(key) do
rendered = to_string(key)
String.ends_with?(rendered, "_at") or String.ends_with?(rendered, "At")