feat: PLAN step 1 done, supposedly
This commit is contained in:
5
PLAN.md
5
PLAN.md
@@ -10,6 +10,7 @@ The rewrite already implements most of the backend and compatibility-critical su
|
||||
|
||||
- Foundation and persistence: OTP app startup, Ecto repo, migrations, and persisted tables for projects, posts, translations, media, tags, templates, scripts, settings, chat, AI catalog data, embeddings, imports, publishing jobs, MCP proposals, and CLI notifications.
|
||||
- Compatibility-critical file contracts: post frontmatter, media sidecars, thumbnail layout, template and script frontmatter, menu OPML, metadata diff, and rebuild-from-filesystem flows.
|
||||
- Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting.
|
||||
- Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata.
|
||||
- Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync.
|
||||
- Desktop shell foundation: native menu definitions, shell HTML/CSS/JS bundle, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, project switcher, and shell command routing.
|
||||
@@ -45,8 +46,8 @@ Ordered from base contracts upward:
|
||||
|
||||
The remaining work needs to proceed from base contracts upward. Later phases should not outrun the lower layers they depend on.
|
||||
|
||||
1. Lock compatibility contracts.
|
||||
Validate schema, frontmatter, sidecars, template context, generation output, metadata diff, and rebuild behavior against both the Allium specs and the old bDS application using fixture-based parity tests.
|
||||
1. Lock compatibility contracts. Completed 2026-04-25.
|
||||
Schema, frontmatter, sidecars, template context, generation output, preview behavior, metadata diff, and rebuild behavior are now pinned against the Allium specs and the old bDS application with executable parity tests.
|
||||
|
||||
2. Close engine-level behavior gaps.
|
||||
Finish any remaining save/publish/delete side-effects, translation cascades, link graph maintenance, thumbnail regeneration rules, and rebuild notifications so backend behavior is fully spec-complete independent of UI.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -17,11 +17,11 @@ version: 1
|
||||
{% if post_categories.size > 0 or post_tags.size > 0 %}
|
||||
<div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}">
|
||||
{% for category in post_categories %}
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | url_encode }}/">{{ category | escape }}</a>
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | slugify | url_encode }}/">{{ category | escape }}</a>
|
||||
{% endfor %}
|
||||
{% for tag in post_tags %}
|
||||
{% assign tag_color = tag_color_by_name[tag] %}
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | slugify | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
{% if post_categories.size > 0 or post_tags.size > 0 %}
|
||||
<div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}">
|
||||
{% for category in post_categories %}
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | url_encode }}/">{{ category | escape }}</a>
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | slugify | url_encode }}/">{{ category | escape }}</a>
|
||||
{% endfor %}
|
||||
{% for tag in post_tags %}
|
||||
{% assign tag_color = tag_color_by_name[tag] %}
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | slugify | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
138
test/bds/compatibility_serializer_parity_test.exs
Normal file
138
test/bds/compatibility_serializer_parity_test.exs
Normal file
@@ -0,0 +1,138 @@
|
||||
defmodule BDS.CompatibilitySerializerParityTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
test "post frontmatter serialization matches old bDS output" do
|
||||
rendered =
|
||||
BDS.Frontmatter.serialize_document(
|
||||
[
|
||||
{"id", "post-1"},
|
||||
{"title", "Published Post"},
|
||||
{"slug", "published-post"},
|
||||
{"excerpt", "Summary"},
|
||||
{"status", :published},
|
||||
{"author", "Writer"},
|
||||
{"language", "en"},
|
||||
{"doNotTranslate", true},
|
||||
{"templateSlug", "article"},
|
||||
{"createdAt", 1_711_833_600_000},
|
||||
{"updatedAt", 1_711_920_000_000},
|
||||
{"publishedAt", 1_712_006_400_000},
|
||||
{"tags", ["alpha"]},
|
||||
{"categories", ["notes"]}
|
||||
],
|
||||
"Hello from markdown"
|
||||
)
|
||||
|
||||
assert rendered ==
|
||||
[
|
||||
"---",
|
||||
"id: post-1",
|
||||
"title: Published Post",
|
||||
"slug: published-post",
|
||||
"excerpt: Summary",
|
||||
"status: published",
|
||||
"author: Writer",
|
||||
"language: en",
|
||||
"doNotTranslate: true",
|
||||
"templateSlug: article",
|
||||
"createdAt: '2024-03-30T21:20:00.000Z'",
|
||||
"updatedAt: '2024-03-31T21:20:00.000Z'",
|
||||
"publishedAt: '2024-04-01T21:20:00.000Z'",
|
||||
"tags:",
|
||||
" - alpha",
|
||||
"categories:",
|
||||
" - notes",
|
||||
"---",
|
||||
"Hello from markdown",
|
||||
""
|
||||
]
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
test "media sidecar serialization matches old bDS output" do
|
||||
rendered =
|
||||
BDS.Sidecar.serialize_document([
|
||||
{"id", "media-from-file"},
|
||||
{"originalName", "original.jpg"},
|
||||
{"mimeType", "image/jpeg"},
|
||||
{"size", 123},
|
||||
{"width", 3},
|
||||
{"height", 2},
|
||||
{"title", "Recovered"},
|
||||
{"alt", "Recovered alt"},
|
||||
{"caption", "Recovered caption"},
|
||||
{"author", "Writer"},
|
||||
{"language", "en"},
|
||||
{"createdAt", 1_711_833_600_000},
|
||||
{"updatedAt", 1_711_920_000_000},
|
||||
{"tags", ["alpha"]},
|
||||
{"linkedPostIds", ["post-a"]}
|
||||
])
|
||||
|
||||
assert rendered ==
|
||||
[
|
||||
"---",
|
||||
"id: media-from-file",
|
||||
"originalName: \"original.jpg\"",
|
||||
"mimeType: image/jpeg",
|
||||
"size: 123",
|
||||
"width: 3",
|
||||
"height: 2",
|
||||
"title: \"Recovered\"",
|
||||
"alt: \"Recovered alt\"",
|
||||
"caption: \"Recovered caption\"",
|
||||
"author: \"Writer\"",
|
||||
"language: en",
|
||||
"createdAt: 2024-03-30T21:20:00.000Z",
|
||||
"updatedAt: 2024-03-31T21:20:00.000Z",
|
||||
"tags: [\"alpha\"]",
|
||||
"linkedPostIds: [\"post-a\"]",
|
||||
"---"
|
||||
]
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
test "media sidecar parsing accepts old bDS inline arrays and document markers" do
|
||||
contents =
|
||||
[
|
||||
"---",
|
||||
"id: media-from-file",
|
||||
"originalName: \"original.jpg\"",
|
||||
"mimeType: image/jpeg",
|
||||
"size: 123",
|
||||
"width: 3",
|
||||
"height: 2",
|
||||
"title: \"Recovered\"",
|
||||
"alt: \"Recovered alt\"",
|
||||
"caption: \"Recovered caption\"",
|
||||
"author: \"Writer\"",
|
||||
"language: en",
|
||||
"createdAt: 2024-03-30T21:20:00.000Z",
|
||||
"updatedAt: 2024-03-31T21:20:00.000Z",
|
||||
"tags: [\"alpha\", \"beta\"]",
|
||||
"linkedPostIds: [\"post-a\", \"post-b\"]",
|
||||
"---"
|
||||
]
|
||||
|> Enum.join("\n")
|
||||
|
||||
assert {:ok, fields} = BDS.Sidecar.parse_document(contents)
|
||||
|
||||
assert fields == %{
|
||||
"id" => "media-from-file",
|
||||
"originalName" => "original.jpg",
|
||||
"mimeType" => "image/jpeg",
|
||||
"size" => 123,
|
||||
"width" => 3,
|
||||
"height" => 2,
|
||||
"title" => "Recovered",
|
||||
"alt" => "Recovered alt",
|
||||
"caption" => "Recovered caption",
|
||||
"author" => "Writer",
|
||||
"language" => "en",
|
||||
"createdAt" => 1_711_833_600_000,
|
||||
"updatedAt" => 1_711_920_000_000,
|
||||
"tags" => ["alpha", "beta"],
|
||||
"linkedPostIds" => ["post-a", "post-b"]
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -125,6 +125,48 @@ defmodule BDS.GenerationTest do
|
||||
assert File.read!(Path.join([temp_dir, "html", "sitemap.xml"])) =~ "https://example.com/blog/"
|
||||
end
|
||||
|
||||
test "generation writes feed and atom entries with canonical URLs for published posts", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
assert {:ok, _metadata} =
|
||||
Metadata.update_project_metadata(project.id, %{
|
||||
public_url: "https://example.com/blog",
|
||||
main_language: "en",
|
||||
blog_languages: ["en"]
|
||||
})
|
||||
|
||||
assert {:ok, post} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Feed Entry",
|
||||
content: "Feed body",
|
||||
language: "en"
|
||||
})
|
||||
|
||||
created_at = DateTime.to_unix(~U[2026-04-15 12:00:00Z])
|
||||
|
||||
Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^post.id),
|
||||
set: [created_at: created_at, updated_at: created_at]
|
||||
)
|
||||
|
||||
assert {:ok, published_post} = Posts.publish_post(post.id)
|
||||
assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:core, :single])
|
||||
|
||||
canonical_url =
|
||||
"https://example.com/blog" <>
|
||||
"/" <> String.trim_trailing(BDS.Generation.post_output_path(published_post), "index.html")
|
||||
|
||||
feed_xml = File.read!(Path.join([temp_dir, "html", "feed.xml"]))
|
||||
atom_xml = File.read!(Path.join([temp_dir, "html", "atom.xml"]))
|
||||
|
||||
assert feed_xml =~ "<rss>"
|
||||
assert feed_xml =~ "<item><title>Feed Entry</title><link>#{canonical_url}</link></item>"
|
||||
|
||||
assert atom_xml =~ "<feed>"
|
||||
assert atom_xml =~ "<entry><title>Feed Entry</title><id>#{canonical_url}</id></entry>"
|
||||
end
|
||||
|
||||
test "generation renders published list and post templates for core and single pages", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
@@ -377,6 +419,97 @@ defmodule BDS.GenerationTest do
|
||||
assert not_found_html =~ "Back to preview home"
|
||||
end
|
||||
|
||||
test "generation starter templates render localized archive headings in each output language", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
assert {:ok, _metadata} =
|
||||
Metadata.update_project_metadata(project.id, %{
|
||||
public_url: "https://example.com/blog",
|
||||
main_language: "fr",
|
||||
blog_languages: ["fr", "en"],
|
||||
max_posts_per_page: 10
|
||||
})
|
||||
|
||||
assert {:ok, post} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Archive Heading",
|
||||
content: "Archive body",
|
||||
language: "fr"
|
||||
})
|
||||
|
||||
created_at = DateTime.to_unix(~U[2026-02-15 12:00:00Z])
|
||||
|
||||
Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^post.id),
|
||||
set: [created_at: created_at, updated_at: created_at]
|
||||
)
|
||||
|
||||
assert {:ok, _published_post} = Posts.publish_post(post.id)
|
||||
assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:date])
|
||||
|
||||
french_html = File.read!(Path.join([temp_dir, "html", "2026", "02", "index.html"]))
|
||||
english_html = File.read!(Path.join([temp_dir, "html", "en", "2026", "02", "index.html"]))
|
||||
|
||||
assert french_html =~ ~s(<html lang="fr")
|
||||
assert french_html =~ "Archives février 2026"
|
||||
|
||||
assert english_html =~ ~s(<html lang="en")
|
||||
assert english_html =~ "Archive February 2026"
|
||||
end
|
||||
|
||||
test "generation rewrites media aliases with suffixes and renders slugged taxonomy links with tag colors",
|
||||
%{project: project, temp_dir: temp_dir} do
|
||||
assert {:ok, _metadata} =
|
||||
Metadata.update_project_metadata(project.id, %{
|
||||
public_url: "https://example.com/blog",
|
||||
main_language: "en",
|
||||
blog_languages: ["en"]
|
||||
})
|
||||
|
||||
source_path = Path.join(temp_dir, "sample.txt")
|
||||
File.write!(source_path, "media body")
|
||||
|
||||
assert {:ok, media} =
|
||||
Media.import_media(%{
|
||||
project_id: project.id,
|
||||
source_path: source_path,
|
||||
title: "Sample"
|
||||
})
|
||||
|
||||
media_source_reference =
|
||||
"/" <> Path.join(Path.dirname(media.file_path), media.original_name) <> "?download=1#preview"
|
||||
|
||||
assert {:ok, post} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Taxonomy Colors",
|
||||
content: "",
|
||||
language: "en",
|
||||
categories: ["Release Notes"],
|
||||
tags: ["Elixir"]
|
||||
})
|
||||
|
||||
assert {:ok, published_post} = Posts.publish_post(post.id)
|
||||
assert {:ok, [tag]} = BDS.Tags.sync_tags_from_posts(project.id)
|
||||
assert {:ok, _updated_tag} = BDS.Tags.update_tag(tag.id, %{color: "#112233"})
|
||||
assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:single])
|
||||
|
||||
post_html =
|
||||
File.read!(Path.join([temp_dir, "html", BDS.Generation.post_output_path(published_post)]))
|
||||
|
||||
category_link = ~s(href="/category/release-notes/")
|
||||
tag_link = ~s(href="/tag/elixir/" style="--bubble-accent: #112233;")
|
||||
|
||||
assert post_html =~ category_link
|
||||
assert post_html =~ tag_link
|
||||
|
||||
assert elem(:binary.match(post_html, category_link), 0) <
|
||||
elem(:binary.match(post_html, tag_link), 0)
|
||||
|
||||
assert post_html =~ ~s(src="/#{media.file_path}?download=1#preview")
|
||||
end
|
||||
|
||||
test "single generation writes canonical post pages and language-prefixed translation pages", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
@@ -417,6 +550,52 @@ defmodule BDS.GenerationTest do
|
||||
assert File.read!(Path.join([temp_dir, "html", post_path])) =~ ~s(data-pagefind-body)
|
||||
end
|
||||
|
||||
test "single generation renders the canonical route in the project main language when a translation exists",
|
||||
%{project: project, temp_dir: temp_dir} do
|
||||
assert {:ok, _metadata} =
|
||||
Metadata.update_project_metadata(project.id, %{
|
||||
public_url: "https://example.com/blog",
|
||||
main_language: "fr",
|
||||
blog_languages: ["fr", "en"]
|
||||
})
|
||||
|
||||
assert {:ok, post} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Hello World",
|
||||
content: "Canonical body",
|
||||
language: "en"
|
||||
})
|
||||
|
||||
assert {:ok, _translation} =
|
||||
Posts.upsert_post_translation(post.id, "fr", %{
|
||||
title: "Bonjour le monde",
|
||||
content: "Corps FR"
|
||||
})
|
||||
|
||||
assert {:ok, published_post} = Posts.publish_post(post.id)
|
||||
|
||||
assert {:ok, result} = BDS.Generation.generate_site(project.id, [:single])
|
||||
|
||||
post_path = BDS.Generation.post_output_path(published_post)
|
||||
translation_path = BDS.Generation.post_output_path(published_post, "en")
|
||||
|
||||
assert Enum.map(result.generated_files, & &1.relative_path) |> Enum.sort() ==
|
||||
Enum.sort([post_path, translation_path])
|
||||
|
||||
canonical_html = File.read!(Path.join([temp_dir, "html", post_path]))
|
||||
english_html = File.read!(Path.join([temp_dir, "html", translation_path]))
|
||||
|
||||
assert canonical_html =~ ~s(<html lang="fr")
|
||||
assert canonical_html =~ "Bonjour le monde"
|
||||
assert canonical_html =~ "Corps FR"
|
||||
refute canonical_html =~ "Canonical body"
|
||||
|
||||
assert english_html =~ ~s(<html lang="en")
|
||||
assert english_html =~ "Hello World"
|
||||
assert english_html =~ "Canonical body"
|
||||
end
|
||||
|
||||
test "archive generation writes paginated category, tag, and date pages", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
|
||||
@@ -700,6 +700,55 @@ defmodule BDS.MaintenanceTest do
|
||||
assert "templates/orphan-view.liquid" in orphan_paths
|
||||
end
|
||||
|
||||
test "metadata_diff ignores tag and category order like old bDS", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
assert {:ok, post} =
|
||||
BDS.Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Ordered Post",
|
||||
content: "Body",
|
||||
tags: ["alpha", "beta"],
|
||||
categories: ["article", "notes"]
|
||||
})
|
||||
|
||||
assert {:ok, published_post} = BDS.Posts.publish_post(post.id)
|
||||
|
||||
post_path = Path.join(temp_dir, published_post.file_path)
|
||||
|
||||
File.write!(
|
||||
post_path,
|
||||
[
|
||||
"---",
|
||||
"id: #{published_post.id}",
|
||||
"title: #{published_post.title}",
|
||||
"slug: #{published_post.slug}",
|
||||
"status: published",
|
||||
"createdAt: '#{BDS.Persistence.timestamp_to_iso8601(published_post.created_at)}'",
|
||||
"updatedAt: '#{BDS.Persistence.timestamp_to_iso8601(published_post.updated_at)}'",
|
||||
"publishedAt: '#{BDS.Persistence.timestamp_to_iso8601(published_post.published_at)}'",
|
||||
"tags:",
|
||||
" - beta",
|
||||
" - alpha",
|
||||
"categories:",
|
||||
" - notes",
|
||||
" - article",
|
||||
"---",
|
||||
"Body",
|
||||
""
|
||||
]
|
||||
|> Enum.join("\n")
|
||||
)
|
||||
|
||||
assert {:ok, %{diff_reports: diff_reports}} = BDS.Maintenance.metadata_diff(project.id)
|
||||
|
||||
refute Enum.any?(diff_reports, fn report ->
|
||||
report.entity_type == "post" and report.entity_id == published_post.id and
|
||||
Enum.any?(report.differences, &(&1.name in ["tags", "categories"]))
|
||||
end)
|
||||
end
|
||||
|
||||
defp collect_progress_events(acc \\ []) do
|
||||
receive do
|
||||
{:rebuild_progress, value, message} -> collect_progress_events([{value, message} | acc])
|
||||
|
||||
@@ -42,18 +42,20 @@ defmodule BDS.MediaTest do
|
||||
assert File.read!(Path.join(temp_dir, media.file_path)) == "hello media"
|
||||
|
||||
sidecar = File.read!(Path.join(temp_dir, media.sidecar_path))
|
||||
assert sidecar =~ "---\n"
|
||||
assert sidecar =~ "id: #{media.id}\n"
|
||||
assert sidecar =~ "originalName: sample.txt\n"
|
||||
assert sidecar =~ "originalName: \"sample.txt\"\n"
|
||||
assert sidecar =~ "mimeType: text/plain\n"
|
||||
assert sidecar =~ "title: Sample\n"
|
||||
assert sidecar =~ "alt: Alt text\n"
|
||||
assert sidecar =~ "caption: Caption\n"
|
||||
assert sidecar =~ "author: Writer\n"
|
||||
assert sidecar =~ "title: \"Sample\"\n"
|
||||
assert sidecar =~ "alt: \"Alt text\"\n"
|
||||
assert sidecar =~ "caption: \"Caption\"\n"
|
||||
assert sidecar =~ "author: \"Writer\"\n"
|
||||
assert sidecar =~ "language: en\n"
|
||||
assert sidecar =~ "tags:\n - alpha\n"
|
||||
assert sidecar =~ "linkedPostIds:\n"
|
||||
assert sidecar =~ "tags: [\"alpha\"]\n"
|
||||
assert sidecar =~ "linkedPostIds: []\n"
|
||||
assert sidecar =~ ~r/createdAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
|
||||
assert sidecar =~ ~r/updatedAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
|
||||
assert String.ends_with?(sidecar, "\n---")
|
||||
refute File.exists?(Path.join(temp_dir, media.sidecar_path <> ".tmp"))
|
||||
end
|
||||
|
||||
@@ -78,10 +80,10 @@ defmodule BDS.MediaTest do
|
||||
assert updated.language == "de"
|
||||
|
||||
sidecar = File.read!(Path.join(temp_dir, updated.sidecar_path))
|
||||
assert sidecar =~ "title: Updated\n"
|
||||
assert sidecar =~ "alt: Updated alt\n"
|
||||
assert sidecar =~ "title: \"Updated\"\n"
|
||||
assert sidecar =~ "alt: \"Updated alt\"\n"
|
||||
assert sidecar =~ "language: de\n"
|
||||
assert sidecar =~ "tags:\n - beta\n"
|
||||
assert sidecar =~ "tags: [\"beta\"]\n"
|
||||
end
|
||||
|
||||
test "delete_media removes the binary, sidecar, and database row", %{
|
||||
@@ -452,11 +454,12 @@ defmodule BDS.MediaTest do
|
||||
|
||||
translated_sidecar_path = Path.join(temp_dir, media.file_path <> ".de.meta")
|
||||
contents = File.read!(translated_sidecar_path)
|
||||
assert contents =~ "---\n"
|
||||
assert contents =~ "translationFor: #{media.id}\n"
|
||||
assert contents =~ "language: de\n"
|
||||
assert contents =~ "title: Titel\n"
|
||||
assert contents =~ "alt: Alt text\n"
|
||||
assert contents =~ "caption: Bildunterschrift\n"
|
||||
assert contents =~ "title: \"Titel\"\n"
|
||||
assert contents =~ "alt: \"Alt text\"\n"
|
||||
assert contents =~ "caption: \"Bildunterschrift\"\n---"
|
||||
end
|
||||
|
||||
defp tiny_jpeg_binary do
|
||||
|
||||
@@ -151,9 +151,9 @@ defmodule BDS.PostsTest do
|
||||
assert file_contents =~ "templateSlug: article\n"
|
||||
assert file_contents =~ "tags:\n - alpha\n"
|
||||
assert file_contents =~ "categories:\n - notes\n"
|
||||
assert file_contents =~ ~r/createdAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
|
||||
assert file_contents =~ ~r/updatedAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
|
||||
assert file_contents =~ ~r/publishedAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
|
||||
assert file_contents =~ ~r/createdAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
|
||||
assert file_contents =~ ~r/updatedAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
|
||||
assert file_contents =~ ~r/publishedAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
|
||||
assert file_contents =~ "\n---\nHello from markdown\n"
|
||||
|
||||
refute File.exists?(full_path <> ".tmp")
|
||||
|
||||
@@ -158,6 +158,50 @@ defmodule BDS.PreviewTest do
|
||||
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||
end
|
||||
|
||||
test "draft preview honors the lang query parameter and falls back to the canonical draft", %{
|
||||
project: project
|
||||
} do
|
||||
assert {:ok, _metadata} =
|
||||
Metadata.update_project_metadata(project.id, %{
|
||||
public_url: "https://example.com/blog",
|
||||
main_language: "en",
|
||||
blog_languages: ["en", "de"]
|
||||
})
|
||||
|
||||
assert {:ok, post} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Canonical Draft",
|
||||
content: "Canonical body",
|
||||
language: "en"
|
||||
})
|
||||
|
||||
assert {:ok, _translation} =
|
||||
Posts.upsert_post_translation(post.id, "de", %{
|
||||
title: "Deutscher Entwurf",
|
||||
content: "Deutscher Inhalt"
|
||||
})
|
||||
|
||||
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
|
||||
|
||||
assert {:ok, %{body: german_html, content_type: "text/html"}} =
|
||||
BDS.Preview.preview_draft(project.id, "/draft/canonical-draft?lang=de", post.id)
|
||||
|
||||
assert german_html =~ ~s(<html lang="de")
|
||||
assert german_html =~ "Deutscher Entwurf"
|
||||
assert german_html =~ "Deutscher Inhalt"
|
||||
refute german_html =~ "Canonical body"
|
||||
|
||||
assert {:ok, %{body: fallback_html, content_type: "text/html"}} =
|
||||
BDS.Preview.preview_draft(project.id, "/draft/canonical-draft?lang=fr", post.id)
|
||||
|
||||
assert fallback_html =~ ~s(<html lang="en")
|
||||
assert fallback_html =~ "Canonical Draft"
|
||||
assert fallback_html =~ "Canonical body"
|
||||
|
||||
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||
end
|
||||
|
||||
test "preview renders not-found template for missing routes and rewrites markdown macros and canonical URLs",
|
||||
%{project: project, temp_dir: temp_dir} do
|
||||
:inets.start()
|
||||
|
||||
@@ -72,8 +72,8 @@ defmodule BDS.ScriptsTest do
|
||||
assert contents =~ "entrypoint: main\n"
|
||||
assert contents =~ "enabled: true\n"
|
||||
assert contents =~ "version: 1\n"
|
||||
assert contents =~ ~r/createdAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
|
||||
assert contents =~ ~r/updatedAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
|
||||
assert contents =~ ~r/createdAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
|
||||
assert contents =~ ~r/updatedAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
|
||||
assert contents =~ "\n---\nfunction main() return 'ok' end\n"
|
||||
refute File.exists?(full_path <> ".tmp")
|
||||
end
|
||||
|
||||
@@ -72,8 +72,8 @@ defmodule BDS.TemplatesTest do
|
||||
assert contents =~ "kind: list\n"
|
||||
assert contents =~ "enabled: true\n"
|
||||
assert contents =~ "version: 1\n"
|
||||
assert contents =~ ~r/createdAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
|
||||
assert contents =~ ~r/updatedAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
|
||||
assert contents =~ ~r/createdAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
|
||||
assert contents =~ ~r/updatedAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
|
||||
assert contents =~ "\n---\n<section>{{ page_title }}</section>\n"
|
||||
refute File.exists?(full_path <> ".tmp")
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user