feat: more complete metadata diff, scp publishing and rendering context

This commit is contained in:
2026-04-24 06:30:41 +02:00
parent e101a3db79
commit 624b698bb3
48 changed files with 2193 additions and 505 deletions

View File

@@ -9,8 +9,7 @@ config :bds, BDS.Repo,
stacktrace: true, stacktrace: true,
show_sensitive_data_on_connection_error: true show_sensitive_data_on_connection_error: true
config :bds, BDS.Application, config :bds, BDS.Application, desktop_adapter: :pending_selection
desktop_adapter: :pending_selection
config :bds, :scripting, config :bds, :scripting,
runtime: BDS.Scripting.Lua, runtime: BDS.Scripting.Lua,

View File

@@ -1,4 +1,3 @@
import Config import Config
config :bds, BDS.Repo, config :bds, BDS.Repo, pool_size: 5
pool_size: 5

View File

@@ -14,7 +14,8 @@ defmodule BDS.Generation do
@core_sections [:core, :single, :category, :tag, :date] @core_sections [:core, :single, :category, :tag, :date]
def plan_generation(project_id, sections \\ [:core]) when is_binary(project_id) and is_list(sections) do def plan_generation(project_id, sections \\ [:core])
when is_binary(project_id) and is_list(sections) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
{:ok, metadata} = Metadata.get_project_metadata(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id)
{:ok, generated_files} = list_generated_files(project_id) {:ok, generated_files} = list_generated_files(project_id)
@@ -34,7 +35,8 @@ defmodule BDS.Generation do
}} }}
end end
def generate_site(project_id, sections \\ [:core]) when is_binary(project_id) and is_list(sections) do def generate_site(project_id, sections \\ [:core])
when is_binary(project_id) and is_list(sections) do
with {:ok, plan} <- plan_generation(project_id, sections) do with {:ok, plan} <- plan_generation(project_id, sections) do
outputs = build_outputs(plan) outputs = build_outputs(plan)
@@ -106,7 +108,8 @@ defmodule BDS.Generation do
)} )}
end end
def delete_generated_file(project_id, relative_path) when is_binary(project_id) and is_binary(relative_path) do def delete_generated_file(project_id, relative_path)
when is_binary(project_id) and is_binary(relative_path) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
case File.rm(output_path(project, relative_path)) do case File.rm(output_path(project, relative_path)) do
@@ -117,7 +120,9 @@ defmodule BDS.Generation do
Repo.delete_all( Repo.delete_all(
from generated_file in GeneratedFileHash, from generated_file in GeneratedFileHash,
where: generated_file.project_id == ^project_id and generated_file.relative_path == ^relative_path where:
generated_file.project_id == ^project_id and
generated_file.relative_path == ^relative_path
) )
:ok :ok
@@ -146,8 +151,10 @@ defmodule BDS.Generation do
build_archive_outputs(plan, published_posts) build_archive_outputs(plan, published_posts)
urls = urls =
core_outputs ++ single_outputs ++ archive_outputs (core_outputs ++ single_outputs ++ archive_outputs)
|> Enum.map(fn {relative_path, _content} -> url_for_output(plan.base_url, relative_path) end) |> Enum.map(fn {relative_path, _content} ->
url_for_output(plan.base_url, relative_path)
end)
sitemap = sitemap =
if :core in plan.sections do if :core in plan.sections do
@@ -199,9 +206,42 @@ defmodule BDS.Generation do
Enum.with_index(paginated_posts, 1) Enum.with_index(paginated_posts, 1)
|> Enum.flat_map(fn {page_posts, page_number} -> |> Enum.flat_map(fn {page_posts, page_number} ->
Enum.map(languages, fn language -> Enum.map(languages, fn language ->
pagination = %{
current_page: page_number,
total_pages: length(paginated_posts),
total_items: length(posts),
items_per_page: max(plan.max_posts_per_page, 1),
has_prev_page: page_number > 1,
prev_page_href:
if(page_number > 1,
do:
archive_href(
route_language(plan.language, language),
["category", category_slug],
page_number - 1
),
else: ""
),
has_next_page: page_number < length(paginated_posts),
next_page_href:
if(page_number < length(paginated_posts),
do:
archive_href(
route_language(plan.language, language),
["category", category_slug],
page_number + 1
),
else: ""
)
}
{ {
archive_path(route_language(plan.language, language), ["category", category_slug], page_number), archive_path(
render_archive_page(plan, category, page_posts, language, "category") route_language(plan.language, language),
["category", category_slug],
page_number
),
render_archive_page(plan, category, page_posts, language, "category", pagination)
} }
end) end)
end) end)
@@ -216,11 +256,12 @@ defmodule BDS.Generation do
Enum.flat_map(tag_posts, fn {tag, posts} -> Enum.flat_map(tag_posts, fn {tag, posts} ->
tag_slug = Slug.slugify(tag) tag_slug = Slug.slugify(tag)
pagination = pagination_for_posts(posts)
Enum.map(languages, fn language -> Enum.map(languages, fn language ->
{ {
archive_path(route_language(plan.language, language), ["tag", tag_slug], 1), archive_path(route_language(plan.language, language), ["tag", tag_slug], 1),
render_archive_page(plan, tag, posts, language, "tag") render_archive_page(plan, tag, posts, language, "tag", pagination)
} }
end) end)
end) end)
@@ -232,20 +273,24 @@ defmodule BDS.Generation do
year_outputs = year_outputs =
Enum.flat_map(years, fn {year, posts} -> Enum.flat_map(years, fn {year, posts} ->
pagination = pagination_for_posts(posts)
Enum.map(languages, fn language -> Enum.map(languages, fn language ->
{ {
archive_path(route_language(plan.language, language), [year], 1), archive_path(route_language(plan.language, language), [year], 1),
render_date_archive_page(plan, year, posts, language) render_date_archive_page(plan, year, posts, language, pagination)
} }
end) end)
end) end)
month_outputs = month_outputs =
Enum.flat_map(months, fn {{year, month}, posts} -> Enum.flat_map(months, fn {{year, month}, posts} ->
pagination = pagination_for_posts(posts)
Enum.map(languages, fn language -> Enum.map(languages, fn language ->
{ {
archive_path(route_language(plan.language, language), [year, month], 1), archive_path(route_language(plan.language, language), [year, month], 1),
render_date_archive_page(plan, "#{year}-#{month}", posts, language) render_date_archive_page(plan, "#{year}-#{month}", posts, language, pagination)
} }
end) end)
end) end)
@@ -259,7 +304,16 @@ defmodule BDS.Generation do
main_posts = build_list_posts(plan.base_url, published_posts, nil) main_posts = build_list_posts(plan.base_url, published_posts, nil)
[ [
{"index.html", render_list_output(plan, language, plan.project_name, main_posts, %{kind: "core"}, fn -> render_home(plan, language) end)}, {"index.html",
render_list_output(
plan,
language,
plan.project_name,
main_posts,
%{kind: "core"},
pagination_for_posts(main_posts),
fn -> render_home(plan, language) end
)},
{"404.html", render_not_found_output(plan, language)}, {"404.html", render_not_found_output(plan, language)},
{"feed.xml", render_feed(plan, language, published_posts)}, {"feed.xml", render_feed(plan, language, published_posts)},
{"atom.xml", render_atom(plan, language, published_posts)}, {"atom.xml", render_atom(plan, language, published_posts)},
@@ -270,10 +324,22 @@ defmodule BDS.Generation do
localized_posts = build_list_posts(plan.base_url, published_posts, localized_prefix) localized_posts = build_list_posts(plan.base_url, published_posts, localized_prefix)
[ [
{Path.join(localized_language, "index.html"), render_list_output(plan, localized_language, plan.project_name, localized_posts, %{kind: "core"}, fn -> render_home(plan, localized_language) end)}, {Path.join(localized_language, "index.html"),
{Path.join(localized_language, "404.html"), render_not_found_output(plan, localized_language)}, render_list_output(
{Path.join(localized_language, "feed.xml"), render_feed(plan, localized_language, published_posts)}, plan,
{Path.join(localized_language, "atom.xml"), render_atom(plan, localized_language, published_posts)} localized_language,
plan.project_name,
localized_posts,
%{kind: "core"},
pagination_for_posts(localized_posts),
fn -> render_home(plan, localized_language) end
)},
{Path.join(localized_language, "404.html"),
render_not_found_output(plan, localized_language)},
{Path.join(localized_language, "feed.xml"),
render_feed(plan, localized_language, published_posts)},
{Path.join(localized_language, "atom.xml"),
render_atom(plan, localized_language, published_posts)}
] ]
end) end)
end end
@@ -284,9 +350,21 @@ defmodule BDS.Generation do
body = load_body(project_id, post.file_path, post.content) body = load_body(project_id, post.file_path, post.content)
{post_output_path(post), {post_output_path(post),
render_post_output(project_id, post.template_slug, %{id: post.id, title: post.title, content: body, slug: post.slug, language: post.language, excerpt: post.excerpt}, fn -> render_post_output(
project_id,
post.template_slug,
%{
id: post.id,
title: post.title,
content: body,
slug: post.slug,
language: post.language,
excerpt: post.excerpt
},
fn ->
render_post_page(post.title, body, post.slug, post.language) render_post_page(post.title, body, post.slug, post.language)
end)} end
)}
end) end)
translation_outputs = translation_outputs =
@@ -300,9 +378,21 @@ defmodule BDS.Generation do
[ [
{post_output_path(post, translation.language), {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_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) render_post_page(translation.title, body, post.slug, translation.language)
end)} end
)}
] ]
end end
end) end)
@@ -434,7 +524,7 @@ defmodule BDS.Generation do
|> IO.iodata_to_binary() |> IO.iodata_to_binary()
end end
defp render_archive_page(plan, title, posts, language, kind) do defp render_archive_page(plan, title, posts, language, kind, pagination) do
fallback = fn -> fallback = fn ->
items = items =
posts posts
@@ -460,14 +550,23 @@ defmodule BDS.Generation do
language, language,
title, title,
Enum.map(posts, fn post -> Enum.map(posts, fn post ->
%{title: post.title, href: "#", excerpt: post.excerpt, content: nil} %{
id: post.id,
slug: post.slug,
title: post.title,
href: "#",
excerpt: post.excerpt,
content: nil,
language: post.language
}
end), end),
%{kind: kind, name: title}, %{kind: kind, name: title},
pagination,
fallback fallback
) )
end end
defp render_date_archive_page(plan, label, posts, language) do defp render_date_archive_page(plan, label, posts, language, pagination) do
fallback = fn -> fallback = fn ->
items = items =
posts posts
@@ -491,21 +590,37 @@ defmodule BDS.Generation do
language, language,
label, label,
Enum.map(posts, fn post -> Enum.map(posts, fn post ->
%{title: post.title, href: "#", excerpt: post.excerpt, content: nil} %{
id: post.id,
slug: post.slug,
title: post.title,
href: "#",
excerpt: post.excerpt,
content: nil,
language: post.language
}
end), end),
%{kind: "date", name: label}, %{kind: "date", name: label},
pagination,
fallback fallback
) )
end end
defp load_body(_project_id, _file_path, inline_content) when is_binary(inline_content), do: inline_content defp load_body(_project_id, _file_path, inline_content) when is_binary(inline_content),
do: inline_content
defp load_body(project_id, file_path, _inline_content) do defp load_body(project_id, file_path, _inline_content) do
case file_path do case file_path do
nil -> "" nil ->
"" -> "" ""
"" ->
""
value -> value ->
project_path = Path.expand(value, Projects.project_data_dir(Projects.get_project!(project_id))) project_path =
Path.expand(value, Projects.project_data_dir(Projects.get_project!(project_id)))
case File.read(project_path) do case File.read(project_path) do
{:ok, contents} -> parse_frontmatter_body(contents) {:ok, contents} -> parse_frontmatter_body(contents)
{:error, _reason} -> "" {:error, _reason} -> ""
@@ -529,7 +644,9 @@ defmodule BDS.Generation do
defp month_key(created_at) do defp month_key(created_at) do
datetime = DateTime.from_unix!(created_at) datetime = DateTime.from_unix!(created_at)
{Integer.to_string(datetime.year), Integer.to_string(datetime.month) |> String.pad_leading(2, "0")}
{Integer.to_string(datetime.year),
Integer.to_string(datetime.month) |> String.pad_leading(2, "0")}
end end
defp build_list_posts(base_url, posts, language_prefix) do defp build_list_posts(base_url, posts, language_prefix) do
@@ -552,25 +669,46 @@ defmodule BDS.Generation do
end end
end end
defp render_list_output(%{project_id: project_id, language: main_language}, language, page_title, posts, archive_context, fallback) defp render_list_output(
%{project_id: project_id, language: main_language},
language,
page_title,
posts,
archive_context,
pagination,
fallback
)
when is_binary(project_id) do when is_binary(project_id) do
case Rendering.render_list_page(project_id, %{ case Rendering.render_list_page(project_id, %{
language: language, language: language,
language_prefix: language_prefix(language, main_language), language_prefix: language_prefix(language, main_language),
page_title: page_title, page_title: page_title,
posts: posts, posts: posts,
archive_context: archive_context archive_context: archive_context,
pagination: pagination
}) do }) do
{:ok, rendered} -> rendered {:ok, rendered} -> rendered
{:error, _reason} -> fallback.() {:error, _reason} -> fallback.()
end end
end end
defp render_list_output(_plan, _language, _page_title, _posts, _archive_context, fallback), do: fallback.() defp render_list_output(
_plan,
_language,
_page_title,
_posts,
_archive_context,
_pagination,
fallback
),
do: fallback.()
defp render_not_found_output(%{project_id: project_id, language: main_language}, language) defp render_not_found_output(%{project_id: project_id, language: main_language}, language)
when is_binary(project_id) do when is_binary(project_id) do
case Rendering.render_not_found_page(project_id, %{language: language, language_prefix: language_prefix(language, main_language)}) do case Rendering.render_not_found_page(project_id, %{
language: language,
language_prefix: language_prefix(language, main_language)
}) do
{:ok, rendered} -> rendered {:ok, rendered} -> rendered
{:error, _reason} -> render_not_found_page(language) {:error, _reason} -> render_not_found_page(language)
end end
@@ -582,6 +720,25 @@ defmodule BDS.Generation do
defp language_prefix(nil, _main_language), do: "" defp language_prefix(nil, _main_language), do: ""
defp language_prefix(language, _main_language), do: "/#{language}" defp language_prefix(language, _main_language), do: "/#{language}"
defp pagination_for_posts(posts) do
%{
current_page: 1,
total_pages: 1,
total_items: length(posts),
items_per_page: length(posts),
has_prev_page: false,
prev_page_href: "",
has_next_page: false,
next_page_href: ""
}
end
defp archive_href(language, segments, page_number) do
archive_path(language, segments, page_number)
|> String.trim_trailing("index.html")
|> then(&("/" <> String.trim_leading(&1, "/")))
end
defp url_for_output(nil, relative_path), do: "/" <> String.trim_leading(relative_path, "/") defp url_for_output(nil, relative_path), do: "/" <> String.trim_leading(relative_path, "/")
defp url_for_output(base_url, relative_path) do defp url_for_output(base_url, relative_path) do

View File

@@ -53,7 +53,8 @@ defmodule BDS.Maintenance do
defp post_diff_reports(project_id, project) do defp post_diff_reports(project_id, project) do
Repo.all( Repo.all(
from post in Post, from post in Post,
where: post.project_id == ^project_id and not is_nil(post.file_path) and post.file_path != "" where:
post.project_id == ^project_id and not is_nil(post.file_path) and post.file_path != ""
) )
|> Enum.flat_map(fn post -> |> Enum.flat_map(fn post ->
case read_frontmatter_document(project, post.file_path) do case read_frontmatter_document(project, post.file_path) do
@@ -66,6 +67,9 @@ defmodule BDS.Maintenance do
diff_field("language", post.language, Map.get(fields, "language")), diff_field("language", post.language, Map.get(fields, "language")),
diff_field("status", post.status, Map.get(fields, "status")), diff_field("status", post.status, Map.get(fields, "status")),
diff_field("template_slug", post.template_slug, Map.get(fields, "template_slug")), diff_field("template_slug", post.template_slug, Map.get(fields, "template_slug")),
diff_field("created_at", post.created_at, Map.get(fields, "created_at")),
diff_field("updated_at", post.updated_at, Map.get(fields, "updated_at")),
diff_field("published_at", post.published_at, Map.get(fields, "published_at")),
diff_field("tags", post.tags, Map.get(fields, "tags", [])), diff_field("tags", post.tags, Map.get(fields, "tags", [])),
diff_field("categories", post.categories, Map.get(fields, "categories", [])) diff_field("categories", post.categories, Map.get(fields, "categories", []))
] ]
@@ -86,7 +90,9 @@ defmodule BDS.Maintenance do
defp media_diff_reports(project_id, project) do defp media_diff_reports(project_id, project) do
Repo.all( Repo.all(
from media in Media, from media in Media,
where: media.project_id == ^project_id and not is_nil(media.sidecar_path) and media.sidecar_path != "" where:
media.project_id == ^project_id and not is_nil(media.sidecar_path) and
media.sidecar_path != ""
) )
|> Enum.flat_map(fn media -> |> Enum.flat_map(fn media ->
case read_sidecar_document(project, media.sidecar_path) do case read_sidecar_document(project, media.sidecar_path) do
@@ -98,6 +104,8 @@ defmodule BDS.Maintenance do
diff_field("caption", media.caption, Map.get(fields, "caption")), diff_field("caption", media.caption, Map.get(fields, "caption")),
diff_field("author", media.author, Map.get(fields, "author")), diff_field("author", media.author, Map.get(fields, "author")),
diff_field("language", media.language, Map.get(fields, "language")), diff_field("language", media.language, Map.get(fields, "language")),
diff_field("created_at", media.created_at, Map.get(fields, "created_at")),
diff_field("updated_at", media.updated_at, Map.get(fields, "updated_at")),
diff_field("tags", media.tags, Map.get(fields, "tags", [])) diff_field("tags", media.tags, Map.get(fields, "tags", []))
] ]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
@@ -117,7 +125,9 @@ defmodule BDS.Maintenance do
defp post_translation_diff_reports(project_id, project) do defp post_translation_diff_reports(project_id, project) do
Repo.all( Repo.all(
from translation in PostTranslation, from translation in PostTranslation,
where: translation.project_id == ^project_id and not is_nil(translation.file_path) and translation.file_path != "" where:
translation.project_id == ^project_id and not is_nil(translation.file_path) and
translation.file_path != ""
) )
|> Enum.flat_map(fn translation -> |> Enum.flat_map(fn translation ->
case read_frontmatter_document(project, translation.file_path) do case read_frontmatter_document(project, translation.file_path) do
@@ -128,14 +138,31 @@ defmodule BDS.Maintenance do
diff_field("excerpt", translation.excerpt, Map.get(fields, "excerpt")), diff_field("excerpt", translation.excerpt, Map.get(fields, "excerpt")),
diff_field("language", translation.language, Map.get(fields, "language")), diff_field("language", translation.language, Map.get(fields, "language")),
diff_field("status", translation.status, Map.get(fields, "status")), diff_field("status", translation.status, Map.get(fields, "status")),
diff_field("translation_for", translation.translation_for, Map.get(fields, "translation_for")) diff_field(
"translation_for",
translation.translation_for,
Map.get(fields, "translation_for")
),
diff_field("created_at", translation.created_at, Map.get(fields, "created_at")),
diff_field("updated_at", translation.updated_at, Map.get(fields, "updated_at")),
diff_field(
"published_at",
translation.published_at,
Map.get(fields, "published_at")
)
] ]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
if differences == [] do if differences == [] do
[] []
else else
[%{entity_type: "post_translation", entity_id: translation.id, differences: differences}] [
%{
entity_type: "post_translation",
entity_id: translation.id,
differences: differences
}
]
end end
{:error, _reason} -> {:error, _reason} ->
@@ -157,14 +184,24 @@ defmodule BDS.Maintenance do
diff_field("alt", translation.alt, Map.get(fields, "alt")), diff_field("alt", translation.alt, Map.get(fields, "alt")),
diff_field("caption", translation.caption, Map.get(fields, "caption")), diff_field("caption", translation.caption, Map.get(fields, "caption")),
diff_field("language", translation.language, Map.get(fields, "language")), diff_field("language", translation.language, Map.get(fields, "language")),
diff_field("translation_for", translation.translation_for, Map.get(fields, "translation_for")) diff_field(
"translation_for",
translation.translation_for,
Map.get(fields, "translation_for")
)
] ]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
if differences == [] do if differences == [] do
[] []
else else
[%{entity_type: "media_translation", entity_id: translation.id, differences: differences}] [
%{
entity_type: "media_translation",
entity_id: translation.id,
differences: differences
}
]
end end
_ -> _ ->
@@ -176,7 +213,9 @@ defmodule BDS.Maintenance do
defp script_diff_reports(project_id, project) do defp script_diff_reports(project_id, project) do
Repo.all( Repo.all(
from script in Script, from script in Script,
where: script.project_id == ^project_id and not is_nil(script.file_path) and script.file_path != "" where:
script.project_id == ^project_id and not is_nil(script.file_path) and
script.file_path != ""
) )
|> Enum.flat_map(fn script -> |> Enum.flat_map(fn script ->
case read_frontmatter_document(project, script.file_path) do case read_frontmatter_document(project, script.file_path) do
@@ -185,7 +224,9 @@ defmodule BDS.Maintenance do
[ [
diff_field("title", script.title, Map.get(fields, "title")), diff_field("title", script.title, Map.get(fields, "title")),
diff_field("entrypoint", script.entrypoint, Map.get(fields, "entrypoint")), diff_field("entrypoint", script.entrypoint, Map.get(fields, "entrypoint")),
diff_field("enabled", script.enabled, Map.get(fields, "enabled")) diff_field("enabled", script.enabled, Map.get(fields, "enabled")),
diff_field("created_at", script.created_at, Map.get(fields, "created_at")),
diff_field("updated_at", script.updated_at, Map.get(fields, "updated_at"))
] ]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
@@ -204,7 +245,9 @@ defmodule BDS.Maintenance do
defp template_diff_reports(project_id, project) do defp template_diff_reports(project_id, project) do
Repo.all( Repo.all(
from template in Template, from template in Template,
where: template.project_id == ^project_id and not is_nil(template.file_path) and template.file_path != "" where:
template.project_id == ^project_id and not is_nil(template.file_path) and
template.file_path != ""
) )
|> Enum.flat_map(fn template -> |> Enum.flat_map(fn template ->
case read_frontmatter_document(project, template.file_path) do case read_frontmatter_document(project, template.file_path) do
@@ -212,7 +255,9 @@ defmodule BDS.Maintenance do
differences = differences =
[ [
diff_field("title", template.title, Map.get(fields, "title")), diff_field("title", template.title, Map.get(fields, "title")),
diff_field("enabled", template.enabled, Map.get(fields, "enabled")) diff_field("enabled", template.enabled, Map.get(fields, "enabled")),
diff_field("created_at", template.created_at, Map.get(fields, "created_at")),
diff_field("updated_at", template.updated_at, Map.get(fields, "updated_at"))
] ]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
@@ -229,12 +274,44 @@ defmodule BDS.Maintenance do
end end
defp orphan_reports(project_id, project) do defp orphan_reports(project_id, project) do
post_paths = MapSet.new(Repo.all(from post in Post, where: post.project_id == ^project_id, select: post.file_path)) post_paths =
media_paths = MapSet.new(Repo.all(from media in Media, where: media.project_id == ^project_id, select: media.sidecar_path)) MapSet.new(
post_translation_paths = MapSet.new(Repo.all(from translation in PostTranslation, where: translation.project_id == ^project_id, select: translation.file_path)) Repo.all(from post in Post, where: post.project_id == ^project_id, select: post.file_path)
)
media_paths =
MapSet.new(
Repo.all(
from media in Media, where: media.project_id == ^project_id, select: media.sidecar_path
)
)
post_translation_paths =
MapSet.new(
Repo.all(
from translation in PostTranslation,
where: translation.project_id == ^project_id,
select: translation.file_path
)
)
media_translation_paths = MapSet.new(media_translation_sidecar_paths(project_id)) media_translation_paths = MapSet.new(media_translation_sidecar_paths(project_id))
script_paths = MapSet.new(Repo.all(from script in Script, where: script.project_id == ^project_id, select: script.file_path))
template_paths = MapSet.new(Repo.all(from template in Template, where: template.project_id == ^project_id, select: template.file_path)) script_paths =
MapSet.new(
Repo.all(
from script in Script, where: script.project_id == ^project_id, select: script.file_path
)
)
template_paths =
MapSet.new(
Repo.all(
from template in Template,
where: template.project_id == ^project_id,
select: template.file_path
)
)
post_orphans = post_orphans =
project project
@@ -276,7 +353,9 @@ defmodule BDS.Maintenance do
|> Enum.map(&Path.relative_to(&1, Projects.project_data_dir(project))) |> Enum.map(&Path.relative_to(&1, Projects.project_data_dir(project)))
|> Enum.reject(&MapSet.member?(template_paths, &1)) |> Enum.reject(&MapSet.member?(template_paths, &1))
(post_orphans ++ post_translation_orphans ++ media_orphans ++ media_translation_orphans ++ script_orphans ++ template_orphans) (post_orphans ++
post_translation_orphans ++
media_orphans ++ media_translation_orphans ++ script_orphans ++ template_orphans)
|> Enum.sort() |> Enum.sort()
|> Enum.map(&%{file_path: &1}) |> Enum.map(&%{file_path: &1})
end end
@@ -297,7 +376,10 @@ defmodule BDS.Maintenance do
defp stringify_value(value) when is_boolean(value), do: to_string(value) defp stringify_value(value) when is_boolean(value), do: to_string(value)
defp stringify_value(value) when is_integer(value), do: Integer.to_string(value) defp stringify_value(value) when is_integer(value), do: Integer.to_string(value)
defp stringify_value(value) when is_binary(value), do: value defp stringify_value(value) when is_binary(value), do: value
defp stringify_value(value) when is_list(value), do: Enum.map_join(value, ",", &stringify_value/1)
defp stringify_value(value) when is_list(value),
do: Enum.map_join(value, ",", &stringify_value/1)
defp stringify_value(value), do: to_string(value) defp stringify_value(value), do: to_string(value)
defp read_frontmatter_document(project, relative_path) do defp read_frontmatter_document(project, relative_path) do
@@ -345,7 +427,11 @@ defmodule BDS.Maintenance do
end end
defp media_translation_sidecar_path(project_id, translation) do defp media_translation_sidecar_path(project_id, translation) do
case Repo.one(from media in Media, where: media.project_id == ^project_id and media.id == ^translation.translation_for, select: media.file_path) do case Repo.one(
from media in Media,
where: media.project_id == ^project_id and media.id == ^translation.translation_for,
select: media.file_path
) do
nil -> nil nil -> nil
file_path -> "#{file_path}.#{translation.language}.meta" file_path -> "#{file_path}.#{translation.language}.meta"
end end

View File

@@ -68,7 +68,8 @@ defmodule BDS.Media do
{:error, :not_found} {:error, :not_found}
media -> media ->
updates = %{} updates =
%{}
|> maybe_put(:title, attr(attrs, :title)) |> maybe_put(:title, attr(attrs, :title))
|> maybe_put(:alt, attr(attrs, :alt)) |> maybe_put(:alt, attr(attrs, :alt))
|> maybe_put(:caption, attr(attrs, :caption)) |> maybe_put(:caption, attr(attrs, :caption))
@@ -104,14 +105,21 @@ defmodule BDS.Media do
{:error, :not_found} {:error, :not_found}
media -> media ->
translations = Repo.all(from translation in Translation, where: translation.translation_for == ^media.id) translations =
Repo.all(
from translation in Translation, where: translation.translation_for == ^media.id
)
delete_file_if_present(media.project_id, media.file_path) delete_file_if_present(media.project_id, media.file_path)
delete_file_if_present(media.project_id, media.sidecar_path) delete_file_if_present(media.project_id, media.sidecar_path)
delete_thumbnail_files(media.project_id, media) delete_thumbnail_files(media.project_id, media)
Enum.each(translations, fn translation -> Enum.each(translations, fn translation ->
delete_file_if_present(media.project_id, translation_sidecar_path(media, translation.language)) delete_file_if_present(
media.project_id,
translation_sidecar_path(media, translation.language)
)
Repo.delete!(translation) Repo.delete!(translation)
end) end)
@@ -243,7 +251,9 @@ defmodule BDS.Media do
updated_at: Map.get(fields, "updated_at", now) updated_at: Map.get(fields, "updated_at", now)
} }
media = Repo.get(Media, attrs.id) || Repo.get_by(Media, project_id: project.id, file_path: relative_file_path) || %Media{} media =
Repo.get(Media, attrs.id) ||
Repo.get_by(Media, project_id: project.id, file_path: relative_file_path) || %Media{}
media media
|> Media.changeset(attrs) |> Media.changeset(attrs)
@@ -278,7 +288,12 @@ defmodule BDS.Media do
end end
defp write_translation_sidecar(project, media, translation) do defp write_translation_sidecar(project, media, translation) do
path = Path.join(Projects.project_data_dir(project), translation_sidecar_path(media, translation.language)) path =
Path.join(
Projects.project_data_dir(project),
translation_sidecar_path(media, translation.language)
)
:ok = File.mkdir_p(Path.dirname(path)) :ok = File.mkdir_p(Path.dirname(path))
atomic_write( atomic_write(

View File

@@ -58,7 +58,18 @@ defmodule BDS.Media.Media do
], ],
empty_values: [nil] empty_values: [nil]
) )
|> validate_required([:id, :project_id, :filename, :original_name, :mime_type, :size, :file_path, :sidecar_path, :created_at, :updated_at]) |> validate_required([
:id,
:project_id,
:filename,
:original_name,
:mime_type,
:size,
:file_path,
:sidecar_path,
:created_at,
:updated_at
])
|> assoc_constraint(:project) |> assoc_constraint(:project)
end end
end end

View File

@@ -8,7 +8,11 @@ defmodule BDS.Media.Translation do
@foreign_key_type :string @foreign_key_type :string
schema "media_translations" do schema "media_translations" do
belongs_to :media, BDS.Media.Media, foreign_key: :translation_for, references: :id, type: :string belongs_to :media, BDS.Media.Media,
foreign_key: :translation_for,
references: :id,
type: :string
field :project_id, :string field :project_id, :string
field :language, :string field :language, :string
field :title, :string field :title, :string
@@ -20,10 +24,29 @@ defmodule BDS.Media.Translation do
def changeset(translation, attrs) do def changeset(translation, attrs) do
translation translation
|> cast(attrs, [:id, :project_id, :translation_for, :language, :title, :alt, :caption, :created_at, :updated_at], |> cast(
attrs,
[
:id,
:project_id,
:translation_for,
:language,
:title,
:alt,
:caption,
:created_at,
:updated_at
],
empty_values: [nil] empty_values: [nil]
) )
|> validate_required([:id, :project_id, :translation_for, :language, :created_at, :updated_at]) |> validate_required([
:id,
:project_id,
:translation_for,
:language,
:created_at,
:updated_at
])
|> foreign_key_constraint(:translation_for) |> foreign_key_constraint(:translation_for)
|> unique_constraint(:language, name: :media_translations_translation_language_idx) |> unique_constraint(:language, name: :media_translations_translation_language_idx)
end end

View File

@@ -6,7 +6,11 @@ defmodule BDS.Menu do
alias BDS.Projects alias BDS.Projects
Record.defrecord(:xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl")) Record.defrecord(:xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl"))
Record.defrecord(:xmlAttribute, Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl"))
Record.defrecord(
:xmlAttribute,
Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl")
)
@valid_kinds [:page, :submenu, :category_archive, :home] @valid_kinds [:page, :submenu, :category_archive, :home]
@@ -187,7 +191,7 @@ defmodule BDS.Menu do
|> String.replace("&", "&amp;") |> String.replace("&", "&amp;")
|> String.replace("<", "&lt;") |> String.replace("<", "&lt;")
|> String.replace(">", "&gt;") |> String.replace(">", "&gt;")
|> String.replace(~s(") , "&quot;") |> String.replace(~s("), "&quot;")
end end
defp attr(attrs, key) do defp attr(attrs, key) do

View File

@@ -21,13 +21,28 @@ defmodule BDS.Metadata do
project_metadata = project_metadata =
state state
|> Map.take([:name, :description, :public_url, :main_language, :default_author, :max_posts_per_page, :blogmark_category, :pico_theme, :semantic_similarity_enabled, :blog_languages]) |> Map.take([
:name,
:description,
:public_url,
:main_language,
:default_author,
:max_posts_per_page,
:blogmark_category,
:pico_theme,
:semantic_similarity_enabled,
:blog_languages
])
|> Map.merge(normalize_project_metadata_attrs(attrs, project)) |> Map.merge(normalize_project_metadata_attrs(attrs, project))
Repo.transaction(fn -> Repo.transaction(fn ->
updated_project = updated_project =
project project
|> Project.changeset(%{name: project_metadata.name, description: project_metadata.description, updated_at: now}) |> Project.changeset(%{
name: project_metadata.name,
description: project_metadata.description,
updated_at: now
})
|> Repo.update!() |> Repo.update!()
persist_setting(project_id, "project", stringify_project_metadata(project_metadata), now) persist_setting(project_id, "project", stringify_project_metadata(project_metadata), now)
@@ -89,8 +104,13 @@ defmodule BDS.Metadata do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
now = System.system_time(:second) now = System.system_time(:second)
project_metadata_from_files = read_json(project, "project.json") || stringify_project_metadata(default_project_metadata(project)) project_metadata_from_files =
categories_from_files = read_json(project, "categories.json") || %{"categories" => @default_categories} read_json(project, "project.json") ||
stringify_project_metadata(default_project_metadata(project))
categories_from_files =
read_json(project, "categories.json") || %{"categories" => @default_categories}
category_meta_from_files = read_json(project, "category-meta.json") || %{"categories" => %{}} category_meta_from_files = read_json(project, "category-meta.json") || %{"categories" => %{}}
publishing_from_files = read_json(project, "publishing.json") || %{"ssh_mode" => "scp"} publishing_from_files = read_json(project, "publishing.json") || %{"ssh_mode" => "scp"}
@@ -125,8 +145,14 @@ defmodule BDS.Metadata do
end end
defp load_state(project) do defp load_state(project) do
project_metadata = load_setting(project.id, "project") || stringify_project_metadata(default_project_metadata(project)) project_metadata =
categories = (load_setting(project.id, "categories") || %{"categories" => @default_categories})["categories"] load_setting(project.id, "project") ||
stringify_project_metadata(default_project_metadata(project))
categories =
(load_setting(project.id, "categories") || %{"categories" => @default_categories})[
"categories"
]
category_settings = category_settings =
(load_setting(project.id, "category_meta") || %{"categories" => %{}})["categories"] (load_setting(project.id, "category_meta") || %{"categories" => %{}})["categories"]
@@ -139,10 +165,12 @@ defmodule BDS.Metadata do
public_url: Map.get(project_metadata, "public_url"), public_url: Map.get(project_metadata, "public_url"),
main_language: Map.get(project_metadata, "main_language"), main_language: Map.get(project_metadata, "main_language"),
default_author: Map.get(project_metadata, "default_author"), default_author: Map.get(project_metadata, "default_author"),
max_posts_per_page: Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page), max_posts_per_page:
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
blogmark_category: Map.get(project_metadata, "blogmark_category"), blogmark_category: Map.get(project_metadata, "blogmark_category"),
pico_theme: Map.get(project_metadata, "pico_theme"), pico_theme: Map.get(project_metadata, "pico_theme"),
semantic_similarity_enabled: Map.get(project_metadata, "semantic_similarity_enabled", false), semantic_similarity_enabled:
Map.get(project_metadata, "semantic_similarity_enabled", false),
blog_languages: Map.get(project_metadata, "blog_languages", []), blog_languages: Map.get(project_metadata, "blog_languages", []),
categories: categories, categories: categories,
category_settings: category_settings, category_settings: category_settings,
@@ -182,10 +210,13 @@ defmodule BDS.Metadata do
defp normalize_category_settings(settings) do defp normalize_category_settings(settings) do
%{ %{
"render_in_lists" => Map.get(settings, :render_in_lists, Map.get(settings, "render_in_lists", true)), "render_in_lists" =>
Map.get(settings, :render_in_lists, Map.get(settings, "render_in_lists", true)),
"show_title" => Map.get(settings, :show_title, Map.get(settings, "show_title", true)), "show_title" => Map.get(settings, :show_title, Map.get(settings, "show_title", true)),
"post_template_slug" => Map.get(settings, :post_template_slug, Map.get(settings, "post_template_slug")), "post_template_slug" =>
"list_template_slug" => Map.get(settings, :list_template_slug, Map.get(settings, "list_template_slug")) Map.get(settings, :post_template_slug, Map.get(settings, "post_template_slug")),
"list_template_slug" =>
Map.get(settings, :list_template_slug, Map.get(settings, "list_template_slug"))
} }
end end
@@ -220,7 +251,8 @@ defmodule BDS.Metadata do
write_publishing_json(project, state.publishing_preferences) write_publishing_json(project, state.publishing_preferences)
end end
defp write_project_json(project, project_json), do: write_json(project, "project.json", project_json) defp write_project_json(project, project_json),
do: write_json(project, "project.json", project_json)
defp write_categories_json(project, categories) do defp write_categories_json(project, categories) do
write_json(project, "categories.json", %{"categories" => Enum.sort(categories)}) write_json(project, "categories.json", %{"categories" => Enum.sort(categories)})

View File

@@ -37,7 +37,7 @@ defmodule BDS.Posts do
categories: attr(attrs, :categories) || [], categories: attr(attrs, :categories) || [],
template_slug: attr(attrs, :template_slug), template_slug: attr(attrs, :template_slug),
language: attr(attrs, :language), language: attr(attrs, :language),
do_not_translate: false, do_not_translate: attr(attrs, :do_not_translate) || false,
published_title: nil, published_title: nil,
published_content: nil, published_content: nil,
published_tags: nil, published_tags: nil,
@@ -63,6 +63,7 @@ defmodule BDS.Posts do
post -> post ->
with :ok <- validate_slug_change(post, attrs) do with :ok <- validate_slug_change(post, attrs) do
now = System.system_time(:second) now = System.system_time(:second)
updates = updates =
attrs attrs
|> normalize_updates(post) |> normalize_updates(post)
@@ -100,7 +101,12 @@ defmodule BDS.Posts do
body = publishable_post_body(post, full_path, project) body = publishable_post_body(post, full_path, project)
:ok = File.mkdir_p(Path.dirname(full_path)) :ok = File.mkdir_p(Path.dirname(full_path))
:ok = File.write(full_path, serialize_post_file(%{post | updated_at: updated_at, content: body}, published_at))
:ok =
File.write(
full_path,
serialize_post_file(%{post | updated_at: updated_at, content: body}, published_at)
)
post post
|> Post.changeset(%{ |> Post.changeset(%{
@@ -197,16 +203,21 @@ defmodule BDS.Posts do
{:error, {:error,
post post
|> Post.changeset(%{}) |> Post.changeset(%{})
|> Ecto.Changeset.add_error(:do_not_translate, "cannot add translations when do_not_translate is true")} |> Ecto.Changeset.add_error(
:do_not_translate,
"cannot add translations when do_not_translate is true"
)}
%Post{} = post -> %Post{} = post ->
now = System.system_time(:second) now = System.system_time(:second)
normalized_language = normalize_language(language) normalized_language = normalize_language(language)
translation = translation =
Repo.get_by(Translation, translation_for: post.id, language: normalized_language) || %Translation{} Repo.get_by(Translation, translation_for: post.id, language: normalized_language) ||
%Translation{}
updates = normalize_translation_updates(post, translation, normalized_language, attrs, now) updates =
normalize_translation_updates(post, translation, normalized_language, attrs, now)
translation translation
|> Translation.changeset(updates) |> Translation.changeset(updates)
@@ -253,7 +264,9 @@ defmodule BDS.Posts do
where: post.project_id == ^project_id, where: post.project_id == ^project_id,
select: {translation.translation_for, translation.language} select: {translation.translation_for, translation.language}
) )
|> Enum.group_by(fn {post_id, _language} -> post_id end, fn {_post_id, language} -> language end) |> Enum.group_by(fn {post_id, _language} -> post_id end, fn {_post_id, language} ->
language
end)
required_languages = required_languages =
metadata.blog_languages metadata.blog_languages
@@ -268,7 +281,9 @@ defmodule BDS.Posts do
available = Map.get(translation_languages, post.id, []) available = Map.get(translation_languages, post.id, [])
cond do cond do
post.do_not_translate -> [] post.do_not_translate ->
[]
true -> true ->
required_languages required_languages
|> Enum.reject(&(&1 in available)) |> Enum.reject(&(&1 in available))
@@ -299,7 +314,15 @@ defmodule BDS.Posts do
full_path = Path.join(Projects.project_data_dir(project), post.file_path) full_path = Path.join(Projects.project_data_dir(project), post.file_path)
body = published_post_body(post, full_path) body = published_post_body(post, full_path)
:ok = File.mkdir_p(Path.dirname(full_path)) :ok = File.mkdir_p(Path.dirname(full_path))
:ok = File.write(full_path, serialize_post_file(%{post | content: body}, post.published_at || System.system_time(:second)))
:ok =
File.write(
full_path,
serialize_post_file(
%{post | content: body},
post.published_at || System.system_time(:second)
)
)
end end
:ok :ok
@@ -328,7 +351,8 @@ defmodule BDS.Posts do
|> maybe_put(:published_excerpt, attr(attrs, :published_excerpt)) |> maybe_put(:published_excerpt, attr(attrs, :published_excerpt))
end end
defp validate_slug_change(%Post{published_at: published_at} = post, attrs) when not is_nil(published_at) do defp validate_slug_change(%Post{published_at: published_at} = post, attrs)
when not is_nil(published_at) do
case attr(attrs, :slug) do case attr(attrs, :slug) do
nil -> nil ->
:ok :ok
@@ -357,12 +381,25 @@ defmodule BDS.Posts do
defp maybe_reopen_published_post(updates, _post), do: updates defp maybe_reopen_published_post(updates, _post), do: updates
defp published_content_change?(updates, post) do defp published_content_change?(updates, post) do
Enum.any?([:title, :excerpt, :content, :author, :language, :template_slug, :tags, :categories, :do_not_translate], fn field -> Enum.any?(
[
:title,
:excerpt,
:content,
:author,
:language,
:template_slug,
:tags,
:categories,
:do_not_translate
],
fn field ->
case Map.fetch(updates, field) do case Map.fetch(updates, field) do
{:ok, value} -> value != Map.get(post, field) {:ok, value} -> value != Map.get(post, field)
:error -> false :error -> false
end end
end) end
)
end end
defp unique_slug(project_id, base_slug) do defp unique_slug(project_id, base_slug) do
@@ -386,7 +423,9 @@ defmodule BDS.Posts do
end end
defp slug_available?(project_id, slug) do defp slug_available?(project_id, slug) do
not Repo.exists?(from post in Post, where: post.project_id == ^project_id and post.slug == ^slug) not Repo.exists?(
from post in Post, where: post.project_id == ^project_id and post.slug == ^slug
)
end end
defp maybe_put(map, _key, nil), do: map defp maybe_put(map, _key, nil), do: map
@@ -409,7 +448,8 @@ defmodule BDS.Posts do
Path.join(["posts", year, month, "#{slug}.md"]) Path.join(["posts", year, month, "#{slug}.md"])
end end
defp publishable_post_body(%Post{content: content}, _full_path, _project) when is_binary(content), do: content defp publishable_post_body(%Post{content: content}, _full_path, _project)
when is_binary(content), do: content
defp publishable_post_body(%Post{file_path: file_path} = post, full_path, project) do defp publishable_post_body(%Post{file_path: file_path} = post, full_path, project) do
source_path = source_path =
@@ -444,7 +484,8 @@ defmodule BDS.Posts do
) )
end end
defp published_post_body(%Post{content: content}, _full_path) when is_binary(content), do: content defp published_post_body(%Post{content: content}, _full_path) when is_binary(content),
do: content
defp published_post_body(_post, full_path) do defp published_post_body(_post, full_path) do
case File.read(full_path) do case File.read(full_path) do
@@ -512,7 +553,8 @@ defmodule BDS.Posts do
end end
end end
defp delete_post_file(%Post{project_id: _project_id, file_path: file_path}) when file_path in [nil, ""], do: :ok defp delete_post_file(%Post{project_id: _project_id, file_path: file_path})
when file_path in [nil, ""], do: :ok
defp delete_post_file(%Post{} = post) do defp delete_post_file(%Post{} = post) do
project = Projects.get_project!(post.project_id) project = Projects.get_project!(post.project_id)
@@ -532,7 +574,8 @@ defmodule BDS.Posts do
|> maybe_put(:excerpt, attr(attrs, :excerpt)) |> maybe_put(:excerpt, attr(attrs, :excerpt))
|> maybe_put(:content, attr(attrs, :content)) |> maybe_put(:content, attr(attrs, :content))
reopened? = translation.status == :published and translation_content_change?(translation, updates) reopened? =
translation.status == :published and translation_content_change?(translation, updates)
%{ %{
id: translation.id || Ecto.UUID.generate(), id: translation.id || Ecto.UUID.generate(),
@@ -580,7 +623,15 @@ defmodule BDS.Posts do
body = publishable_translation_body(translation, full_path) body = publishable_translation_body(translation, full_path)
:ok = File.mkdir_p(Path.dirname(full_path)) :ok = File.mkdir_p(Path.dirname(full_path))
:ok = File.write(full_path, serialize_translation_file(%{translation | updated_at: updated_at, content: body}, published_at))
:ok =
File.write(
full_path,
serialize_translation_file(
%{translation | updated_at: updated_at, content: body},
published_at
)
)
translation translation
|> Translation.changeset(%{ |> Translation.changeset(%{
@@ -619,7 +670,8 @@ defmodule BDS.Posts do
) )
end end
defp publishable_translation_body(%Translation{content: content}, _full_path) when is_binary(content), do: content defp publishable_translation_body(%Translation{content: content}, _full_path)
when is_binary(content), do: content
defp publishable_translation_body(_translation, full_path) do defp publishable_translation_body(_translation, full_path) do
case File.read(full_path) do case File.read(full_path) do
@@ -634,7 +686,8 @@ defmodule BDS.Posts do
end end
end end
defp delete_translation_file(%Translation{project_id: _project_id, file_path: file_path}) when file_path in [nil, ""], do: :ok defp delete_translation_file(%Translation{project_id: _project_id, file_path: file_path})
when file_path in [nil, ""], do: :ok
defp delete_translation_file(%Translation{} = translation) do defp delete_translation_file(%Translation{} = translation) do
project = Projects.get_project!(translation.project_id) project = Projects.get_project!(translation.project_id)
@@ -649,7 +702,15 @@ defmodule BDS.Posts do
defp orphan_translation_files(project_id) do defp orphan_translation_files(project_id) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
translation_paths = MapSet.new(Repo.all(from translation in Translation, where: translation.project_id == ^project_id, select: translation.file_path))
translation_paths =
MapSet.new(
Repo.all(
from translation in Translation,
where: translation.project_id == ^project_id,
select: translation.file_path
)
)
project project
|> Projects.project_data_dir() |> Projects.project_data_dir()

View File

@@ -67,7 +67,15 @@ defmodule BDS.Posts.Post do
], ],
empty_values: [nil] empty_values: [nil]
) )
|> validate_required([:id, :project_id, :slug, :status, :created_at, :updated_at, :do_not_translate]) |> validate_required([
:id,
:project_id,
:slug,
:status,
:created_at,
:updated_at,
:do_not_translate
])
|> assoc_constraint(:project) |> assoc_constraint(:project)
|> unique_constraint(:slug, name: :posts_project_slug_idx) |> unique_constraint(:slug, name: :posts_project_slug_idx)
end end

View File

@@ -9,7 +9,10 @@ defmodule BDS.Posts.Translation do
@statuses [:draft, :published] @statuses [:draft, :published]
schema "post_translations" do schema "post_translations" do
belongs_to :post, BDS.Posts.Post, foreign_key: :translation_for, references: :id, type: :string belongs_to :post, BDS.Posts.Post,
foreign_key: :translation_for,
references: :id,
type: :string
field :project_id, :string field :project_id, :string
field :language, :string field :language, :string
@@ -26,7 +29,9 @@ defmodule BDS.Posts.Translation do
def changeset(translation, attrs) do def changeset(translation, attrs) do
translation translation
|> cast(attrs, [ |> cast(
attrs,
[
:id, :id,
:project_id, :project_id,
:translation_for, :translation_for,
@@ -40,8 +45,19 @@ defmodule BDS.Posts.Translation do
:published_at, :published_at,
:file_path, :file_path,
:checksum :checksum
], empty_values: [nil]) ],
|> validate_required([:id, :project_id, :translation_for, :language, :title, :status, :created_at, :updated_at]) empty_values: [nil]
)
|> validate_required([
:id,
:project_id,
:translation_for,
:language,
:title,
:status,
:created_at,
:updated_at
])
|> foreign_key_constraint(:translation_for) |> foreign_key_constraint(:translation_for)
|> unique_constraint(:language, name: :post_translations_translation_language_idx) |> unique_constraint(:language, name: :post_translations_translation_language_idx)
end end

View File

@@ -16,7 +16,11 @@ defmodule BDS.Preview do
def start_preview(project_id) when is_binary(project_id) do def start_preview(project_id) when is_binary(project_id) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
GenServer.call(__MODULE__, {:start_preview, project_id, Projects.project_data_dir(project), self()})
GenServer.call(
__MODULE__,
{:start_preview, project_id, Projects.project_data_dir(project), self()}
)
end end
def stop_preview(project_id) when is_binary(project_id) do def stop_preview(project_id) when is_binary(project_id) do
@@ -58,7 +62,15 @@ defmodule BDS.Preview do
state = stop_current_server(state) state = stop_current_server(state)
maybe_allow_repo(owner_pid) maybe_allow_repo(owner_pid)
{:ok, listener} = :gen_tcp.listen(@port, [:binary, packet: :raw, active: false, reuseaddr: true, ip: {127, 0, 0, 1}]) {:ok, listener} =
:gen_tcp.listen(@port, [
:binary,
packet: :raw,
active: false,
reuseaddr: true,
ip: {127, 0, 0, 1}
])
acceptor_pid = spawn_link(fn -> accept_loop(listener, project_id) end) acceptor_pid = spawn_link(fn -> accept_loop(listener, project_id) end)
server = %{ server = %{
@@ -145,7 +157,9 @@ defmodule BDS.Preview do
end end
case full_path do case full_path do
{:error, :not_found} -> {:error, :not_found} {:error, :not_found} ->
{:error, :not_found}
resolved_path -> resolved_path ->
case read_response(resolved_path) do case read_response(resolved_path) do
{:error, :not_found} -> render_not_found_response(server.project_id) {:error, :not_found} -> render_not_found_response(server.project_id)
@@ -258,7 +272,11 @@ defmodule BDS.Preview do
path = uri.path || "/" path = uri.path || "/"
query_params = URI.decode_query(uri.query || "") query_params = URI.decode_query(uri.query || "")
case GenServer.call(__MODULE__, {:http_request, project_id, method, path, query_params}, 5_000) do case GenServer.call(
__MODULE__,
{:http_request, project_id, method, path, query_params},
5_000
) do
{:ok, response} -> http_ok_response(response) {:ok, response} -> http_ok_response(response)
{:error, :not_found} -> http_error_response(404) {:error, :not_found} -> http_error_response(404)
{:error, :not_running} -> http_error_response(503) {:error, :not_running} -> http_error_response(503)

View File

@@ -101,7 +101,10 @@ defmodule BDS.Projects do
|> Multi.update_all(:clear_previous, from(p in Project, where: p.is_active == true), |> Multi.update_all(:clear_previous, from(p in Project, where: p.is_active == true),
set: [is_active: false, updated_at: now] set: [is_active: false, updated_at: now]
) )
|> Multi.update(:activate, Project.changeset(project, %{is_active: true, updated_at: now})) |> Multi.update(
:activate,
Project.changeset(project, %{is_active: true, updated_at: now})
)
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{activate: active_project}} -> {:ok, active_project} {:ok, %{activate: active_project}} -> {:ok, active_project}

View File

@@ -21,7 +21,9 @@ defmodule BDS.Projects.Project do
def changeset(project, attrs) do def changeset(project, attrs) do
project project
|> cast(attrs, [:id, :name, :slug, :description, :data_path, :created_at, :updated_at, :is_active], |> cast(
attrs,
[:id, :name, :slug, :description, :data_path, :created_at, :updated_at, :is_active],
empty_values: [nil] empty_values: [nil]
) )
|> validate_required([:id, :name, :slug, :created_at, :updated_at, :is_active]) |> validate_required([:id, :name, :slug, :created_at, :updated_at, :is_active])

View File

@@ -10,7 +10,8 @@ defmodule BDS.Publishing do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__) GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end end
def upload_site(project_id, credentials, opts \\ []) when is_binary(project_id) and is_map(credentials) and is_list(opts) do def upload_site(project_id, credentials, opts \\ [])
when is_binary(project_id) and is_map(credentials) and is_list(opts) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
normalized_credentials = normalize_credentials(credentials) normalized_credentials = normalize_credentials(credentials)
targets = build_upload_targets(Projects.project_data_dir(project), normalized_credentials) targets = build_upload_targets(Projects.project_data_dir(project), normalized_credentials)
@@ -23,7 +24,7 @@ defmodule BDS.Publishing do
@impl true @impl true
def init(_state) do def init(_state) do
{:ok, %{jobs: %{}}} {:ok, %{jobs: %{}, scp_uploads: %{}}}
end end
@impl true @impl true
@@ -41,15 +42,32 @@ defmodule BDS.Publishing do
{:reply, :ok, next_state} {:reply, :ok, next_state}
end end
def handle_call({:should_upload_scp_file, upload_key, local_mtime}, _from, state) do
should_upload? =
case state.scp_uploads[upload_key] do
nil -> true
recorded_mtime -> local_mtime > recorded_mtime
end
{:reply, should_upload?, state}
end
def handle_call({:mark_uploaded_scp_file, upload_key, local_mtime}, _from, state) do
{:reply, :ok, put_in(state, [:scp_uploads, upload_key], local_mtime)}
end
def handle_call({:upload_site, project_id, credentials, targets, opts}, _from, state) do def handle_call({:upload_site, project_id, credentials, targets, opts}, _from, state) do
job_id = "publish-" <> Integer.to_string(System.unique_integer([:positive, :monotonic])) job_id = "publish-" <> Integer.to_string(System.unique_integer([:positive, :monotonic]))
uploader = build_uploader(opts) uploader = build_uploader(Keyword.put_new(opts, :project_id, project_id))
job = %{ job = %{
id: job_id, id: job_id,
project_id: project_id, project_id: project_id,
status: :pending, status: :pending,
task_id: nil, task_id: nil,
ssh_host: credentials.ssh_host,
ssh_user: credentials.ssh_user,
ssh_remote_path: credentials.ssh_remote_path,
ssh_mode: credentials.ssh_mode, ssh_mode: credentials.ssh_mode,
targets: Enum.map(targets, & &1.kind), targets: Enum.map(targets, & &1.kind),
error: nil, error: nil,
@@ -58,12 +76,16 @@ defmodule BDS.Publishing do
} }
{:ok, task} = {:ok, task} =
Tasks.submit_task("publish #{project_id}", fn report -> Tasks.submit_task(
"publish #{project_id}",
fn report ->
run_upload(job_id, credentials, targets, uploader, report) run_upload(job_id, credentials, targets, uploader, report)
end, %{ end,
%{
group_id: project_id, group_id: project_id,
group_name: "Publishing" group_name: "Publishing"
}) }
)
next_job = %{job | task_id: task.id} next_job = %{job | task_id: task.id}
{:reply, {:ok, next_job}, put_in(state, [:jobs, job_id], next_job)} {:reply, {:ok, next_job}, put_in(state, [:jobs, job_id], next_job)}
@@ -104,9 +126,10 @@ defmodule BDS.Publishing do
nil -> nil ->
runner = Keyword.get(opts, :command_runner, &System.cmd/3) runner = Keyword.get(opts, :command_runner, &System.cmd/3)
ssh_auth_sock = Keyword.get(opts, :ssh_auth_sock, System.get_env("SSH_AUTH_SOCK")) ssh_auth_sock = Keyword.get(opts, :ssh_auth_sock, System.get_env("SSH_AUTH_SOCK"))
project_id = Keyword.fetch!(opts, :project_id)
fn target, files, credentials -> fn target, files, credentials ->
run_command_upload(target, files, credentials, runner, ssh_auth_sock) run_command_upload(project_id, target, files, credentials, runner, ssh_auth_sock)
end end
uploader -> uploader ->
@@ -114,22 +137,60 @@ defmodule BDS.Publishing do
end end
end end
defp run_command_upload(target, _files, %{ssh_mode: :rsync} = credentials, runner, ssh_auth_sock) do defp run_command_upload(
_project_id,
target,
_files,
%{ssh_mode: :rsync} = credentials,
runner,
ssh_auth_sock
) do
args = args =
["--update", "--compress", "--verbose"] ++ ["--update", "--compress", "--verbose"] ++
rsync_excludes(target) ++ rsync_excludes(target) ++
["-e", "ssh", ensure_trailing_slash(target.local_dir), remote_dir_spec(credentials, target.remote_dir)] [
"-e",
"ssh",
ensure_trailing_slash(target.local_dir),
remote_dir_spec(credentials, target.remote_dir)
]
run_command(runner, "rsync", args, ssh_auth_sock) run_command(runner, "rsync", args, ssh_auth_sock)
end end
defp run_command_upload(target, files, credentials, runner, ssh_auth_sock) do defp run_command_upload(project_id, target, files, credentials, runner, ssh_auth_sock) do
Enum.reduce_while(files, :ok, fn relative_path, :ok -> Enum.reduce_while(files, :ok, fn relative_path, :ok ->
local_path = Path.join(target.local_dir, relative_path) local_path = Path.join(target.local_dir, relative_path)
with {:ok, local_mtime} <- file_mtime(local_path),
true <-
should_upload_scp_file?(
project_id,
credentials,
target.kind,
relative_path,
local_mtime
) do
remote_path = remote_file_spec(credentials, target.remote_dir, relative_path) remote_path = remote_file_spec(credentials, target.remote_dir, relative_path)
case run_command(runner, "scp", ["-q", local_path, remote_path], ssh_auth_sock) do case run_command(runner, "scp", ["-q", local_path, remote_path], ssh_auth_sock) do
:ok -> {:cont, :ok} :ok ->
:ok =
mark_uploaded_scp_file(
project_id,
credentials,
target.kind,
relative_path,
local_mtime
)
{:cont, :ok}
{:error, reason} ->
{:halt, {:error, reason}}
end
else
false -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}} {:error, reason} -> {:halt, {:error, reason}}
end end
end) end)
@@ -147,10 +208,49 @@ defmodule BDS.Publishing do
end end
defp command_opts(nil), do: [stderr_to_stdout: true] defp command_opts(nil), do: [stderr_to_stdout: true]
defp command_opts(ssh_auth_sock), do: [stderr_to_stdout: true, env: [{"SSH_AUTH_SOCK", ssh_auth_sock}]]
defp normalize_command_error(_command, output, _status) when is_binary(output) and output != "", do: output defp command_opts(ssh_auth_sock),
defp normalize_command_error(command, _output, status), do: "#{command} exited with status #{status}" do: [stderr_to_stdout: true, env: [{"SSH_AUTH_SOCK", ssh_auth_sock}]]
defp normalize_command_error(_command, output, _status) when is_binary(output) and output != "",
do: output
defp normalize_command_error(command, _output, status),
do: "#{command} exited with status #{status}"
defp file_mtime(path) do
case File.stat(path, time: :posix) do
{:ok, stat} -> {:ok, stat.mtime}
{:error, reason} -> {:error, reason}
end
end
defp should_upload_scp_file?(project_id, credentials, target_kind, relative_path, local_mtime) do
GenServer.call(
__MODULE__,
{:should_upload_scp_file,
scp_upload_key(project_id, credentials, target_kind, relative_path), local_mtime}
)
end
defp mark_uploaded_scp_file(project_id, credentials, target_kind, relative_path, local_mtime) do
GenServer.call(
__MODULE__,
{:mark_uploaded_scp_file,
scp_upload_key(project_id, credentials, target_kind, relative_path), local_mtime}
)
end
defp scp_upload_key(project_id, credentials, target_kind, relative_path) do
{
project_id,
credentials.ssh_host,
credentials.ssh_user,
credentials.ssh_remote_path,
target_kind,
relative_path
}
end
defp rsync_excludes(%{kind: :media}), do: ["--exclude=*.meta"] defp rsync_excludes(%{kind: :media}), do: ["--exclude=*.meta"]
defp rsync_excludes(_target), do: [] defp rsync_excludes(_target), do: []
@@ -172,8 +272,16 @@ defmodule BDS.Publishing do
[ [
%{kind: :html, local_dir: Path.join(base_dir, "html"), remote_dir: remote_root}, %{kind: :html, local_dir: Path.join(base_dir, "html"), remote_dir: remote_root},
%{kind: :thumbnails, local_dir: Path.join(base_dir, "thumbnails"), remote_dir: Path.join(remote_root, "thumbnails")}, %{
%{kind: :media, local_dir: Path.join(base_dir, "media"), remote_dir: Path.join(remote_root, "media")} kind: :thumbnails,
local_dir: Path.join(base_dir, "thumbnails"),
remote_dir: Path.join(remote_root, "thumbnails")
},
%{
kind: :media,
local_dir: Path.join(base_dir, "media"),
remote_dir: Path.join(remote_root, "media")
}
] ]
end end
@@ -184,7 +292,9 @@ defmodule BDS.Publishing do
|> Path.wildcard(match_dot: true) |> Path.wildcard(match_dot: true)
|> Enum.filter(&File.regular?/1) |> Enum.filter(&File.regular?/1)
|> Enum.map(&Path.relative_to(&1, target.local_dir)) |> Enum.map(&Path.relative_to(&1, target.local_dir))
|> Enum.reject(fn relative_path -> target.kind == :media and String.ends_with?(relative_path, ".meta") end) |> Enum.reject(fn relative_path ->
target.kind == :media and String.ends_with?(relative_path, ".meta")
end)
|> Enum.sort() |> Enum.sort()
else else
[] []

View File

@@ -17,23 +17,28 @@ defmodule BDS.Rendering do
alias BDS.Posts.Translation alias BDS.Posts.Translation
alias BDS.Templates.Template alias BDS.Templates.Template
def render_post_page(project_id, template_slug, assigns) when is_binary(project_id) and is_map(assigns) do def render_post_page(project_id, template_slug, assigns)
when is_binary(project_id) and is_map(assigns) do
with {:ok, template_source} <- load_template_source(project_id, :post, template_slug), with {:ok, template_source} <- load_template_source(project_id, :post, template_slug),
{:ok, rendered} <- render_template(project_id, template_source, post_assigns(project_id, assigns)) do {:ok, rendered} <-
render_template(project_id, template_source, post_assigns(project_id, assigns)) do
{:ok, rendered} {:ok, rendered}
end end
end end
def render_list_page(project_id, assigns) when is_binary(project_id) and is_map(assigns) do def render_list_page(project_id, assigns) when is_binary(project_id) and is_map(assigns) do
with {:ok, template_source} <- load_template_source(project_id, :list, nil), with {:ok, template_source} <- load_template_source(project_id, :list, nil),
{:ok, rendered} <- render_template(project_id, template_source, list_assigns(project_id, assigns)) do {:ok, rendered} <-
render_template(project_id, template_source, list_assigns(project_id, assigns)) do
{:ok, rendered} {:ok, rendered}
end end
end end
def render_not_found_page(project_id, assigns \\ %{}) when is_binary(project_id) and is_map(assigns) do def render_not_found_page(project_id, assigns \\ %{})
when is_binary(project_id) and is_map(assigns) do
with {:ok, template_source} <- load_template_source(project_id, :not_found, nil), with {:ok, template_source} <- load_template_source(project_id, :not_found, nil),
{:ok, rendered} <- render_template(project_id, template_source, not_found_assigns(project_id, assigns)) do {:ok, rendered} <-
render_template(project_id, template_source, not_found_assigns(project_id, assigns)) do
{:ok, rendered} {:ok, rendered}
end end
end end
@@ -49,7 +54,8 @@ defmodule BDS.Rendering do
Repo.one( Repo.one(
from template in Template, from template in Template,
where: where:
template.project_id == ^project_id and template.kind == ^kind and template.status == :published and template.project_id == ^project_id and template.kind == ^kind and
template.status == :published and
template.enabled == true and template.slug == ^slug, template.enabled == true and template.slug == ^slug,
limit: 1 limit: 1
) || select_template(project_id, kind, nil) ) || select_template(project_id, kind, nil)
@@ -59,14 +65,16 @@ defmodule BDS.Rendering do
Repo.one( Repo.one(
from template in Template, from template in Template,
where: where:
template.project_id == ^project_id and template.kind == ^kind and template.status == :published and template.project_id == ^project_id and template.kind == ^kind and
template.status == :published and
template.enabled == true, template.enabled == true,
order_by: [asc: template.created_at, asc: template.slug], order_by: [asc: template.created_at, asc: template.slug],
limit: 1 limit: 1
) )
end end
defp published_template_body(%Template{content: content}) when is_binary(content), do: {:ok, content} defp published_template_body(%Template{content: content}) when is_binary(content),
do: {:ok, content}
defp published_template_body(%Template{} = template) do defp published_template_body(%Template{} = template) do
project = Projects.get_project!(template.project_id) project = Projects.get_project!(template.project_id)
@@ -105,17 +113,32 @@ defmodule BDS.Rendering do
defp post_assigns(project_id, assigns) do defp post_assigns(project_id, assigns) do
metadata = project_metadata(project_id) metadata = project_metadata(project_id)
language = Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en"))
language =
Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en"))
main_language = metadata.main_language || language main_language = metadata.main_language || language
post_record = load_post_record(assigns) post_record = load_post_record(assigns)
post_categories = Map.get(post_record || %{}, :categories, []) || [] post_categories = Map.get(post_record || %{}, :categories, []) || []
post_tags = Map.get(post_record || %{}, :tags, []) || [] post_tags = Map.get(post_record || %{}, :tags, []) || []
canonical_post_paths = canonical_post_path_by_slug(project_id, main_language)
canonical_media_paths = canonical_media_path_by_source_path(project_id)
%{ %{
language: language, language: language,
language_prefix: Map.get(assigns, :language_prefix, Map.get(assigns, "language_prefix", language_prefix(language, main_language))), language_prefix:
page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title", Map.get(assigns, :title, Map.get(assigns, "title")))), Map.get(
pico_stylesheet_href: nil, assigns,
:language_prefix,
Map.get(assigns, "language_prefix", language_prefix(language, main_language))
),
page_title:
Map.get(
assigns,
:page_title,
Map.get(assigns, "page_title", Map.get(assigns, :title, Map.get(assigns, "title")))
),
pico_stylesheet_href: default_pico_stylesheet_href(),
html_theme_attribute: html_theme_attribute(metadata.pico_theme), html_theme_attribute: html_theme_attribute(metadata.pico_theme),
blog_languages: blog_languages(metadata, language), blog_languages: blog_languages(metadata, language),
alternate_links: [], alternate_links: [],
@@ -126,34 +149,40 @@ defmodule BDS.Rendering do
post_tags: post_tags, post_tags: post_tags,
tag_color_by_name: tag_color_by_name(project_id), tag_color_by_name: tag_color_by_name(project_id),
backlinks: [], backlinks: [],
canonical_post_path_by_slug: canonical_post_path_by_slug(project_id, main_language), canonical_post_path_by_slug: canonical_post_paths,
canonical_media_path_by_source_path: canonical_media_path_by_source_path(project_id), canonical_media_path_by_source_path: canonical_media_paths,
post_data_json_by_id: post_data_json(assigns), post_data_json_by_id: post_data_json(assigns, post_record),
post: %{ post: build_post_context(assigns, post_record)
id: Map.get(assigns, :id, Map.get(assigns, "id")),
slug: Map.get(assigns, :slug, Map.get(assigns, "slug")),
title: Map.get(assigns, :title, Map.get(assigns, "title")),
content: Map.get(assigns, :content, Map.get(assigns, "content")),
excerpt: Map.get(assigns, :excerpt, Map.get(assigns, "excerpt")),
language: Map.get(assigns, :language, Map.get(assigns, "language")),
show_title: true
}
} }
end end
defp list_assigns(project_id, assigns) do defp list_assigns(project_id, assigns) do
metadata = project_metadata(project_id) metadata = project_metadata(project_id)
language = Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en"))
language =
Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en"))
main_language = metadata.main_language || language main_language = metadata.main_language || language
posts = normalize_list_posts(Map.get(assigns, :posts, Map.get(assigns, "posts", []))) posts = normalize_list_posts(Map.get(assigns, :posts, Map.get(assigns, "posts", [])))
archive_context = Map.get(assigns, :archive_context, Map.get(assigns, "archive_context", %{})) archive_context = Map.get(assigns, :archive_context, Map.get(assigns, "archive_context", %{}))
pagination =
normalize_pagination(Map.get(assigns, :pagination, Map.get(assigns, "pagination")), posts)
canonical_post_paths = canonical_post_path_by_slug(project_id, main_language)
canonical_media_paths = canonical_media_path_by_source_path(project_id)
%{ %{
language: language, language: language,
language_prefix: Map.get(assigns, :language_prefix, Map.get(assigns, "language_prefix", language_prefix(language, main_language))), language_prefix:
Map.get(
assigns,
:language_prefix,
Map.get(assigns, "language_prefix", language_prefix(language, main_language))
),
page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title")), page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title")),
posts: posts, posts: posts,
pico_stylesheet_href: nil, pico_stylesheet_href: default_pico_stylesheet_href(),
html_theme_attribute: html_theme_attribute(metadata.pico_theme), html_theme_attribute: html_theme_attribute(metadata.pico_theme),
blog_languages: blog_languages(metadata, language), blog_languages: blog_languages(metadata, language),
alternate_links: [], alternate_links: [],
@@ -165,15 +194,20 @@ defmodule BDS.Rendering do
min_date: nil, min_date: nil,
max_date: nil, max_date: nil,
is_list_page: true, is_list_page: true,
is_first_page: true, is_first_page: pagination.current_page <= 1,
is_last_page: true, is_last_page: pagination.current_page >= pagination.total_pages,
has_prev_page: false, has_prev_page: pagination.has_prev_page,
has_next_page: false, has_next_page: pagination.has_next_page,
prev_page_href: "", prev_page_href: pagination.prev_page_href,
next_page_href: "", next_page_href: pagination.next_page_href,
canonical_post_path_by_slug: canonical_post_path_by_slug(project_id, main_language), current_page: pagination.current_page,
canonical_media_path_by_source_path: canonical_media_path_by_source_path(project_id), total_pages: pagination.total_pages,
post_data_json_by_id: Enum.into(posts, %{}, fn post -> {post.id, Jason.encode!(Map.take(post, [:id, :slug, :title, :content]))} end), total_items: pagination.total_items,
items_per_page: pagination.items_per_page,
canonical_post_path_by_slug: canonical_post_paths,
canonical_media_path_by_source_path: canonical_media_paths,
post_data_json_by_id:
Enum.into(posts, %{}, fn post -> {post.id, post_data_json_value(post)} end),
day_blocks: [ day_blocks: [
%{ %{
date_label: "", date_label: "",
@@ -187,20 +221,46 @@ defmodule BDS.Rendering do
defp not_found_assigns(project_id, assigns) do defp not_found_assigns(project_id, assigns) do
metadata = project_metadata(project_id) metadata = project_metadata(project_id)
language = Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en"))
language =
Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en"))
main_language = metadata.main_language || language main_language = metadata.main_language || language
%{ %{
page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title", "404 Not Found")), page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title", "404")),
language: language, language: language,
language_prefix: Map.get(assigns, :language_prefix, Map.get(assigns, "language_prefix", language_prefix(language, main_language))), language_prefix:
pico_stylesheet_href: nil, Map.get(
assigns,
:language_prefix,
Map.get(assigns, "language_prefix", language_prefix(language, main_language))
),
pico_stylesheet_href: default_pico_stylesheet_href(),
html_theme_attribute: html_theme_attribute(metadata.pico_theme), html_theme_attribute: html_theme_attribute(metadata.pico_theme),
blog_languages: blog_languages(metadata, language), blog_languages: blog_languages(metadata, language),
menu_items: menu_items(project_id), menu_items: menu_items(project_id),
alternate_links: [], alternate_links: [],
not_found_message: Map.get(assigns, :not_found_message, Map.get(assigns, "not_found_message")), not_found_message:
not_found_back_label: Map.get(assigns, :not_found_back_label, Map.get(assigns, "not_found_back_label")) Map.get(
assigns,
:not_found_message,
Map.get(
assigns,
"not_found_message",
I18n.translate(language, "render.notFound.message")
)
),
not_found_back_label:
Map.get(
assigns,
:not_found_back_label,
Map.get(
assigns,
"not_found_back_label",
I18n.translate(language, "render.notFound.back")
)
)
} }
end end
@@ -232,8 +292,13 @@ defmodule BDS.Rendering do
end end
defp menu_item_href(%{kind: :home}), do: "/" defp menu_item_href(%{kind: :home}), do: "/"
defp menu_item_href(%{kind: :page, slug: slug}) when is_binary(slug) and slug != "", do: "/#{slug}/"
defp menu_item_href(%{kind: :category_archive, slug: slug}) when is_binary(slug) and slug != "", do: "/category/#{URI.encode(slug)}/" defp menu_item_href(%{kind: :page, slug: slug}) when is_binary(slug) and slug != "",
do: "/#{slug}/"
defp menu_item_href(%{kind: :category_archive, slug: slug}) when is_binary(slug) and slug != "",
do: "/category/#{URI.encode(slug)}/"
defp menu_item_href(%{kind: :submenu}), do: "#" defp menu_item_href(%{kind: :submenu}), do: "#"
defp menu_item_href(_item), do: "#" defp menu_item_href(_item), do: "#"
@@ -243,11 +308,13 @@ defmodule BDS.Rendering do
|> Enum.uniq() |> Enum.uniq()
|> Enum.map(fn language -> |> Enum.map(fn language ->
normalized = I18n.normalize_language(language) normalized = I18n.normalize_language(language)
href_prefix = language_prefix(normalized, metadata.main_language || current_language)
%{ %{
code: normalized, code: normalized,
flag: I18n.flag(normalized), flag: I18n.flag(normalized),
href_prefix: language_prefix(normalized, metadata.main_language || current_language), href: href_for_language(href_prefix),
href_prefix: href_prefix,
is_current: normalized == I18n.normalize_language(current_language) is_current: normalized == I18n.normalize_language(current_language)
} }
end) end)
@@ -265,26 +332,39 @@ defmodule BDS.Rendering do
end end
end end
defp post_data_json(assigns) do defp post_data_json(assigns, post_record) do
id = Map.get(assigns, :id, Map.get(assigns, "id")) id = Map.get(assigns, :id, Map.get(assigns, "id"))
if is_binary(id) do if is_binary(id) do
%{ %{
id => id => post_data_json_value(build_post_context(assigns, post_record))
Jason.encode!(%{
id: id,
slug: Map.get(assigns, :slug, Map.get(assigns, "slug")),
title: Map.get(assigns, :title, Map.get(assigns, "title")),
content: Map.get(assigns, :content, Map.get(assigns, "content"))
})
} }
else else
%{} %{}
end end
end end
defp post_data_json_value(post_context) do
Jason.encode!(%{
id: Map.get(post_context, :id),
title: Map.get(post_context, :title),
slug: Map.get(post_context, :slug),
excerpt: Map.get(post_context, :excerpt),
author: Map.get(post_context, :author),
language: Map.get(post_context, :language),
published_at: Map.get(post_context, :published_at),
created_at: Map.get(post_context, :created_at),
updated_at: Map.get(post_context, :updated_at),
tags: Map.get(post_context, :tags, []),
categories: Map.get(post_context, :categories, [])
})
end
defp canonical_post_path_by_slug(project_id, main_language) do defp canonical_post_path_by_slug(project_id, main_language) do
posts = Repo.all(from post in Post, where: post.project_id == ^project_id and post.status == :published) posts =
Repo.all(
from post in Post, where: post.project_id == ^project_id and post.status == :published
)
translations = translations =
Repo.all( Repo.all(
@@ -311,6 +391,7 @@ defmodule BDS.Rendering do
Repo.all(from media in MediaAsset, where: media.project_id == ^project_id) Repo.all(from media in MediaAsset, where: media.project_id == ^project_id)
|> Enum.reduce(%{}, fn media, acc -> |> Enum.reduce(%{}, fn media, acc ->
datetime = DateTime.from_unix!(media.created_at) datetime = DateTime.from_unix!(media.created_at)
source_key = source_key =
Path.join([ Path.join([
"media", "media",
@@ -324,7 +405,8 @@ defmodule BDS.Rendering do
end) end)
end end
defp post_path(post, language_prefix) when is_binary(language_prefix) and language_prefix != "" do defp post_path(post, language_prefix)
when is_binary(language_prefix) and language_prefix != "" do
Path.join([String.trim_leading(language_prefix, "/"), post_path(post, nil)]) Path.join([String.trim_leading(language_prefix, "/"), post_path(post, nil)])
end end
@@ -338,7 +420,7 @@ defmodule BDS.Rendering do
post.slug, post.slug,
"index.html" "index.html"
]) ])
|> then(&"/" <> String.trim_trailing(&1, "index.html")) |> then(&("/" <> String.trim_trailing(&1, "index.html")))
end end
defp post_path(post, language, main_language) do defp post_path(post, language, main_language) do
@@ -348,16 +430,119 @@ defmodule BDS.Rendering do
defp normalize_list_posts(posts) do defp normalize_list_posts(posts) do
Enum.map(posts, fn post -> Enum.map(posts, fn post ->
post_record = load_post_record(post)
%{ %{
id: Map.get(post, :id, Map.get(post, "id")), id: Map.get(post, :id, Map.get(post, "id")),
slug: Map.get(post, :slug, Map.get(post, "slug")), slug: Map.get(post, :slug, Map.get(post, "slug")),
title: Map.get(post, :title, Map.get(post, "title")), title: Map.get(post, :title, Map.get(post, "title")),
content: Map.get(post, :content, Map.get(post, "content", Map.get(post, :excerpt, Map.get(post, "excerpt", "")))), content:
show_title: true Map.get(
post,
:content,
Map.get(post, "content", Map.get(post, :excerpt, Map.get(post, "excerpt", "")))
),
excerpt:
Map.get(post, :excerpt, Map.get(post, "excerpt", Map.get(post_record || %{}, :excerpt))),
author: Map.get(post_record || %{}, :author),
language:
Map.get(
post,
:language,
Map.get(post, "language", Map.get(post_record || %{}, :language))
),
published_at: Map.get(post_record || %{}, :published_at),
created_at: Map.get(post_record || %{}, :created_at),
updated_at: Map.get(post_record || %{}, :updated_at),
tags: Map.get(post_record || %{}, :tags, []) || [],
categories: Map.get(post_record || %{}, :categories, []) || [],
template_slug: Map.get(post_record || %{}, :template_slug),
do_not_translate: Map.get(post_record || %{}, :do_not_translate, false),
href: Map.get(post, :href, Map.get(post, "href")),
show_title: true,
linked_media: [],
outgoing_links: [],
incoming_links: []
} }
end) end)
end end
defp build_post_context(assigns, post_record) do
%{
id: Map.get(assigns, :id, Map.get(assigns, "id")),
slug: Map.get(assigns, :slug, Map.get(assigns, "slug")),
title: Map.get(assigns, :title, Map.get(assigns, "title")),
content: Map.get(assigns, :content, Map.get(assigns, "content")),
excerpt:
Map.get(
assigns,
:excerpt,
Map.get(assigns, "excerpt", Map.get(post_record || %{}, :excerpt))
),
author: Map.get(post_record || %{}, :author),
language:
Map.get(
assigns,
:language,
Map.get(assigns, "language", Map.get(post_record || %{}, :language))
),
show_title: true,
published_at: Map.get(post_record || %{}, :published_at),
created_at: Map.get(post_record || %{}, :created_at),
updated_at: Map.get(post_record || %{}, :updated_at),
tags: Map.get(post_record || %{}, :tags, []) || [],
categories: Map.get(post_record || %{}, :categories, []) || [],
template_slug:
Map.get(
post_record || %{},
:template_slug,
Map.get(assigns, :template_slug, Map.get(assigns, "template_slug"))
),
do_not_translate: Map.get(post_record || %{}, :do_not_translate, false),
linked_media: [],
outgoing_links: [],
incoming_links: []
}
end
defp normalize_pagination(nil, posts) do
total_items = length(posts)
%{
current_page: 1,
total_pages: 1,
total_items: total_items,
items_per_page: total_items,
has_prev_page: false,
prev_page_href: "",
has_next_page: false,
next_page_href: ""
}
end
defp normalize_pagination(%{} = pagination, posts) do
total_items =
Map.get(pagination, :total_items, Map.get(pagination, "total_items", length(posts)))
items_per_page =
Map.get(pagination, :items_per_page, Map.get(pagination, "items_per_page", total_items))
%{
current_page: Map.get(pagination, :current_page, Map.get(pagination, "current_page", 1)),
total_pages: Map.get(pagination, :total_pages, Map.get(pagination, "total_pages", 1)),
total_items: total_items,
items_per_page: items_per_page,
has_prev_page:
Map.get(pagination, :has_prev_page, Map.get(pagination, "has_prev_page", false)),
prev_page_href:
Map.get(pagination, :prev_page_href, Map.get(pagination, "prev_page_href", "")),
has_next_page:
Map.get(pagination, :has_next_page, Map.get(pagination, "has_next_page", false)),
next_page_href:
Map.get(pagination, :next_page_href, Map.get(pagination, "next_page_href", ""))
}
end
defp normalize_archive_context(nil), do: nil defp normalize_archive_context(nil), do: nil
defp normalize_archive_context(%{} = archive_context), do: archive_context defp normalize_archive_context(%{} = archive_context), do: archive_context
@@ -365,10 +550,19 @@ defmodule BDS.Rendering do
defp html_theme_attribute(""), do: nil defp html_theme_attribute(""), do: nil
defp html_theme_attribute(theme), do: ~s(data-theme="#{theme}") defp html_theme_attribute(theme), do: ~s(data-theme="#{theme}")
defp calendar_initial_year(%{created_at: created_at}) when is_integer(created_at), do: DateTime.from_unix!(created_at).year defp default_pico_stylesheet_href, do: "/assets/pico.min.css"
defp href_for_language(""), do: "/"
defp href_for_language(prefix), do: prefix <> "/"
defp calendar_initial_year(%{created_at: created_at}) when is_integer(created_at),
do: DateTime.from_unix!(created_at).year
defp calendar_initial_year(_post), do: nil defp calendar_initial_year(_post), do: nil
defp calendar_initial_month(%{created_at: created_at}) when is_integer(created_at), do: DateTime.from_unix!(created_at).month defp calendar_initial_month(%{created_at: created_at}) when is_integer(created_at),
do: DateTime.from_unix!(created_at).month
defp calendar_initial_month(_post), do: nil defp calendar_initial_month(_post), do: nil
defp calendar_initial_year_from_posts([post | _rest]), do: calendar_initial_year(post) defp calendar_initial_year_from_posts([post | _rest]), do: calendar_initial_year(post)

View File

@@ -15,7 +15,16 @@ defmodule BDS.Rendering.Filters do
end end
end end
def markdown(value, _post_id, _post_data_json_by_id, canonical_post_paths, canonical_media_paths, language, _language_prefix, context) do def markdown(
value,
_post_id,
_post_data_json_by_id,
canonical_post_paths,
canonical_media_paths,
language,
_language_prefix,
context
) do
value value
|> to_string() |> to_string()
|> replace_built_in_macros(language, context) |> replace_built_in_macros(language, context)
@@ -24,21 +33,37 @@ defmodule BDS.Rendering.Filters do
end end
defp replace_built_in_macros(content, language, context) do defp replace_built_in_macros(content, language, context) do
Regex.replace(~r/\[\[(\w+)(?:\s+([^\]]+))?\]\]/, content, fn full_match, macro_name, raw_params -> Regex.replace(~r/\[\[(\w+)(?:\s+([^\]]+))?\]\]/, content, fn full_match,
macro_name,
raw_params ->
params = parse_macro_params(raw_params) params = parse_macro_params(raw_params)
case String.downcase(macro_name) do case String.downcase(macro_name) do
"youtube" -> "youtube" ->
render_macro_template("macros/youtube", %{ render_macro_template(
"macros/youtube",
%{
"id" => Map.get(params, "id", ""), "id" => Map.get(params, "id", ""),
"title" => default_macro_title(Map.get(params, "title"), language, "render.video.youtubeTitle") "title" =>
}, context) default_macro_title(
Map.get(params, "title"),
language,
"render.video.youtubeTitle"
)
},
context
)
"vimeo" -> "vimeo" ->
render_macro_template("macros/vimeo", %{ render_macro_template(
"macros/vimeo",
%{
"id" => Map.get(params, "id", ""), "id" => Map.get(params, "id", ""),
"title" => default_macro_title(Map.get(params, "title"), language, "render.video.vimeoTitle") "title" =>
}, context) default_macro_title(Map.get(params, "title"), language, "render.video.vimeoTitle")
},
context
)
_other -> _other ->
full_match full_match
@@ -46,8 +71,12 @@ defmodule BDS.Rendering.Filters do
end) end)
end end
defp default_macro_title(nil, language, translation_key), do: I18n.translate(language, translation_key) defp default_macro_title(nil, language, translation_key),
defp default_macro_title("", language, translation_key), do: I18n.translate(language, translation_key) do: I18n.translate(language, translation_key)
defp default_macro_title("", language, translation_key),
do: I18n.translate(language, translation_key)
defp default_macro_title(title, _language, _translation_key), do: title defp default_macro_title(title, _language, _translation_key), do: title
defp parse_macro_params(nil), do: %{} defp parse_macro_params(nil), do: %{}
@@ -63,8 +92,12 @@ defmodule BDS.Rendering.Filters do
defp render_macro_template(template_path, assigns, context) do defp render_macro_template(template_path, assigns, context) do
case Map.get(assigns, "id") do case Map.get(assigns, "id") do
"" -> "" "" ->
nil -> "" ""
nil ->
""
_id -> _id ->
template_source = Liquex.FileSystem.read_template_file(context.file_system, template_path) template_source = Liquex.FileSystem.read_template_file(context.file_system, template_path)
template_ast = Liquex.parse!(template_source) template_ast = Liquex.parse!(template_source)
@@ -78,7 +111,10 @@ defmodule BDS.Rendering.Filters do
defp rewrite_rendered_html_urls(html, canonical_post_paths, canonical_media_paths) do defp rewrite_rendered_html_urls(html, canonical_post_paths, canonical_media_paths) do
html html
|> rewrite_attribute("href", &normalize_post_href(&1, canonical_post_paths, canonical_media_paths)) |> rewrite_attribute(
"href",
&normalize_post_href(&1, canonical_post_paths, canonical_media_paths)
)
|> rewrite_attribute("src", &normalize_media_src(&1, canonical_media_paths)) |> rewrite_attribute("src", &normalize_media_src(&1, canonical_media_paths))
end end
@@ -91,8 +127,12 @@ defmodule BDS.Rendering.Filters do
defp normalize_post_href(raw_href, canonical_post_paths, canonical_media_paths) do defp normalize_post_href(raw_href, canonical_post_paths, canonical_media_paths) do
cond do cond do
raw_href == "" -> raw_href raw_href == "" ->
external_or_special_url?(raw_href) -> raw_href raw_href
external_or_special_url?(raw_href) ->
raw_href
true -> true ->
{path_part, suffix} = split_path_suffix(raw_href) {path_part, suffix} = split_path_suffix(raw_href)
@@ -103,7 +143,8 @@ defmodule BDS.Rendering.Filters do
canonical -> canonical <> suffix canonical -> canonical <> suffix
end end
_other -> raw_href _other ->
raw_href
end end
end end
end end
@@ -136,8 +177,12 @@ defmodule BDS.Rendering.Filters do
defp normalize_media_src(raw_src, canonical_media_paths) do defp normalize_media_src(raw_src, canonical_media_paths) do
cond do cond do
raw_src == "" -> raw_src raw_src == "" ->
external_or_special_url?(raw_src) -> raw_src raw_src
external_or_special_url?(raw_src) ->
raw_src
true -> true ->
{path_part, suffix} = split_path_suffix(raw_src) {path_part, suffix} = split_path_suffix(raw_src)

View File

@@ -64,7 +64,9 @@ defmodule BDS.Scripting do
opts: batch_job_defaults(opts)} opts: batch_job_defaults(opts)}
case DynamicSupervisor.start_child(BDS.Scripting.JobSupervisor, child_spec) do case DynamicSupervisor.start_child(BDS.Scripting.JobSupervisor, child_spec) do
{:ok, _pid} -> {:ok, BDS.Scripting.JobStore.fetch_job!(job_id)} {:ok, _pid} ->
{:ok, BDS.Scripting.JobStore.fetch_job!(job_id)}
{:error, reason} -> {:error, reason} ->
:ok = :ok =
BDS.Scripting.JobStore.update_job(job_id, %{ BDS.Scripting.JobStore.update_job(job_id, %{
@@ -91,7 +93,8 @@ defmodule BDS.Scripting do
_job -> {:error, :not_running} _job -> {:error, :not_running}
end end
pid -> BDS.Scripting.JobRunner.cancel(pid) pid ->
BDS.Scripting.JobRunner.cancel(pid)
end end
end end

View File

@@ -50,7 +50,8 @@ defmodule BDS.Scripting.JobStore do
end end
def handle_call({:update_job, job_id, attrs}, _from, state) do def handle_call({:update_job, job_id, attrs}, _from, state) do
next_state = update_in(state, [:jobs, job_id], fn next_state =
update_in(state, [:jobs, job_id], fn
nil -> nil nil -> nil
job -> Map.merge(job, attrs) job -> Map.merge(job, attrs)
end) end)

View File

@@ -66,7 +66,8 @@ defmodule BDS.Scripting.Lua do
end end
end end
defp install_progress_callback(_state, callback), do: {:error, {:invalid_progress_callback, callback}} defp install_progress_callback(_state, callback),
do: {:error, {:invalid_progress_callback, callback}}
defp install_capabilities(state, capabilities) when capabilities in [%{}, []], do: {:ok, state} defp install_capabilities(state, capabilities) when capabilities in [%{}, []], do: {:ok, state}
@@ -81,7 +82,8 @@ defmodule BDS.Scripting.Lua do
end) end)
end end
defp install_capabilities(_state, capabilities), do: {:error, {:invalid_capabilities, capabilities}} defp install_capabilities(_state, capabilities),
do: {:error, {:invalid_capabilities, capabilities}}
defp normalize_progress_payload(payload) when is_list(payload) do defp normalize_progress_payload(payload) when is_list(payload) do
if Enum.all?(payload, &match?({key, _value} when is_binary(key) or is_atom(key), &1)) do if Enum.all?(payload, &match?({key, _value} when is_binary(key) or is_atom(key), &1)) do

View File

@@ -50,11 +50,19 @@ defmodule BDS.Scripts do
:ok = :ok =
File.write( File.write(
full_path, full_path,
serialize_script_file(%{script | status: :published, file_path: file_path, updated_at: updated_at}, content) serialize_script_file(
%{script | status: :published, file_path: file_path, updated_at: updated_at},
content
)
) )
script script
|> Script.changeset(%{status: :published, file_path: file_path, content: nil, updated_at: updated_at}) |> Script.changeset(%{
status: :published,
file_path: file_path,
content: nil,
updated_at: updated_at
})
|> Repo.update() |> Repo.update()
end end
end end
@@ -75,7 +83,8 @@ defmodule BDS.Scripts do
content_changed? = has_attr?(attrs, :content) and attr(attrs, :content) != script.content content_changed? = has_attr?(attrs, :content) and attr(attrs, :content) != script.content
now = System.system_time(:second) now = System.system_time(:second)
updates = %{} updates =
%{}
|> maybe_put(:title, attr(attrs, :title)) |> maybe_put(:title, attr(attrs, :title))
|> maybe_put(:kind, attr(attrs, :kind)) |> maybe_put(:kind, attr(attrs, :kind))
|> maybe_put(:entrypoint, attr(attrs, :entrypoint)) |> maybe_put(:entrypoint, attr(attrs, :entrypoint))
@@ -84,7 +93,10 @@ defmodule BDS.Scripts do
|> Map.put(:slug, next_slug) |> Map.put(:slug, next_slug)
|> Map.put(:version, script.version + 1) |> Map.put(:version, script.version + 1)
|> Map.put(:updated_at, now) |> Map.put(:updated_at, now)
|> maybe_put(:status, if(script.status == :published and content_changed?, do: :draft, else: nil)) |> maybe_put(
:status,
if(script.status == :published and content_changed?, do: :draft, else: nil)
)
script script
|> Script.changeset(updates) |> Script.changeset(updates)
@@ -141,7 +153,8 @@ defmodule BDS.Scripts do
end end
defp slug_available?(project_id, slug, exclude_id) do defp slug_available?(project_id, slug, exclude_id) do
query = from script in Script, where: script.project_id == ^project_id and script.slug == ^slug query =
from script in Script, where: script.project_id == ^project_id and script.slug == ^slug
scoped_query = scoped_query =
case exclude_id do case exclude_id do

View File

@@ -25,10 +25,38 @@ defmodule BDS.Scripts.Script do
def changeset(script, attrs) do def changeset(script, attrs) do
script script
|> cast(attrs, [:id, :project_id, :slug, :title, :kind, :entrypoint, :enabled, :version, :file_path, :status, :content, :created_at, :updated_at], |> cast(
attrs,
[
:id,
:project_id,
:slug,
:title,
:kind,
:entrypoint,
:enabled,
:version,
:file_path,
:status,
:content,
:created_at,
:updated_at
],
empty_values: [nil] empty_values: [nil]
) )
|> validate_required([:id, :project_id, :slug, :title, :kind, :entrypoint, :enabled, :version, :status, :created_at, :updated_at]) |> validate_required([
:id,
:project_id,
:slug,
:title,
:kind,
:entrypoint,
:enabled,
:version,
:status,
:created_at,
:updated_at
])
|> assoc_constraint(:project) |> assoc_constraint(:project)
|> unique_constraint(:slug, name: :scripts_project_slug_idx) |> unique_constraint(:slug, name: :scripts_project_slug_idx)
end end

View File

@@ -96,8 +96,15 @@ defmodule BDS.Search do
end end
def reindex_project(project_id) do def reindex_project(project_id) do
Repo.query!("DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)", [project_id]) Repo.query!(
Repo.query!("DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)", [project_id]) "DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)",
[project_id]
)
Repo.query!(
"DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)",
[project_id]
)
Repo.all(from post in Post, where: post.project_id == ^project_id) Repo.all(from post in Post, where: post.project_id == ^project_id)
|> Enum.each(&sync_post/1) |> Enum.each(&sync_post/1)
@@ -241,7 +248,11 @@ defmodule BDS.Search do
matches_month?(post, filters.month) and matches_month?(post, filters.month) and
matches_from?(post, filters.from) and matches_from?(post, filters.from) and
matches_to?(post, filters.to) and matches_to?(post, filters.to) and
matches_missing_translation?(post, filters.missing_translation_language, translation_languages) matches_missing_translation?(
post,
filters.missing_translation_language,
translation_languages
)
end) end)
end end
@@ -270,7 +281,13 @@ defmodule BDS.Search do
defp matches_to?(post, to_unix), do: post.created_at <= to_unix defp matches_to?(post, to_unix), do: post.created_at <= to_unix
defp matches_missing_translation?(_post, nil, _translation_languages), do: true defp matches_missing_translation?(_post, nil, _translation_languages), do: true
defp matches_missing_translation?(%Post{do_not_translate: true}, _language, _translation_languages), do: false
defp matches_missing_translation?(
%Post{do_not_translate: true},
_language,
_translation_languages
),
do: false
defp matches_missing_translation?(post, language, translation_languages) do defp matches_missing_translation?(post, language, translation_languages) do
language not in Map.get(translation_languages, post.id, []) language not in Map.get(translation_languages, post.id, [])
@@ -286,7 +303,9 @@ defmodule BDS.Search do
"SELECT translation_for, language FROM post_translations WHERE translation_for IN (#{placeholders})", "SELECT translation_for, language FROM post_translations WHERE translation_for IN (#{placeholders})",
post_ids post_ids
).rows ).rows
|> Enum.group_by(fn [post_id, _language] -> post_id end, fn [_post_id, language] -> language end) |> Enum.group_by(fn [post_id, _language] -> post_id end, fn [_post_id, language] ->
language
end)
end end
defp paginate(items, filters) do defp paginate(items, filters) do
@@ -300,16 +319,27 @@ defmodule BDS.Search do
post_language = normalize_language(post.language) post_language = normalize_language(post.language)
title = title =
[stem(post.title, post_language) | Enum.map(translations, &stem(Map.get(&1, "title"), Map.get(&1, "language")))] [
stem(post.title, post_language)
| Enum.map(translations, &stem(Map.get(&1, "title"), Map.get(&1, "language")))
]
|> join_text() |> join_text()
excerpt = excerpt =
[stem(post.excerpt, post_language) | Enum.map(translations, &stem(Map.get(&1, "excerpt"), Map.get(&1, "language")))] [
stem(post.excerpt, post_language)
| Enum.map(translations, &stem(Map.get(&1, "excerpt"), Map.get(&1, "language")))
]
|> join_text() |> join_text()
content = content =
[stem(post_content(post), post_language) | [
Enum.map(translations, &stem(translation_content(post.project_id, &1), Map.get(&1, "language")))] stem(post_content(post), post_language)
| Enum.map(
translations,
&stem(translation_content(post.project_id, &1), Map.get(&1, "language"))
)
]
|> join_text() |> join_text()
tags = stem(Enum.join(post.tags || [], " "), post_language) tags = stem(Enum.join(post.tags || [], " "), post_language)
@@ -320,15 +350,25 @@ defmodule BDS.Search do
defp media_index_fields(media) do defp media_index_fields(media) do
translations = translations =
Repo.all(from translation in MediaTranslation, where: translation.translation_for == ^media.id) Repo.all(
from translation in MediaTranslation, where: translation.translation_for == ^media.id
)
media_language = normalize_language(media.language) media_language = normalize_language(media.language)
title = [stem(media.title, media_language) | Enum.map(translations, &stem(&1.title, &1.language))] |> join_text() title =
alt = [stem(media.alt, media_language) | Enum.map(translations, &stem(&1.alt, &1.language))] |> join_text() [stem(media.title, media_language) | Enum.map(translations, &stem(&1.title, &1.language))]
|> join_text()
alt =
[stem(media.alt, media_language) | Enum.map(translations, &stem(&1.alt, &1.language))]
|> join_text()
caption = caption =
[stem(media.caption, media_language) | Enum.map(translations, &stem(&1.caption, &1.language))] [
stem(media.caption, media_language)
| Enum.map(translations, &stem(&1.caption, &1.language))
]
|> join_text() |> join_text()
original_name = stem(media.original_name || "", media_language) original_name = stem(media.original_name || "", media_language)
@@ -356,7 +396,8 @@ defmodule BDS.Search do
defp post_content(%Post{content: content}) when is_binary(content), do: content defp post_content(%Post{content: content}) when is_binary(content), do: content
defp post_content(%Post{project_id: project_id, file_path: file_path}) when is_binary(file_path) and file_path != "" do defp post_content(%Post{project_id: project_id, file_path: file_path})
when is_binary(file_path) and file_path != "" do
project_id project_id
|> Projects.get_project!() |> Projects.get_project!()
|> Projects.project_data_dir() |> Projects.project_data_dir()
@@ -366,9 +407,11 @@ defmodule BDS.Search do
defp post_content(_post), do: "" defp post_content(_post), do: ""
defp translation_content(_project_id, %{"content" => content}) when is_binary(content), do: content defp translation_content(_project_id, %{"content" => content}) when is_binary(content),
do: content
defp translation_content(project_id, %{"status" => "published", "file_path" => file_path}) when is_binary(file_path) and file_path != "" do defp translation_content(project_id, %{"status" => "published", "file_path" => file_path})
when is_binary(file_path) and file_path != "" do
project_id project_id
|> Projects.get_project!() |> Projects.get_project!()
|> Projects.project_data_dir() |> Projects.project_data_dir()
@@ -403,7 +446,7 @@ defmodule BDS.Search do
|> Enum.map_join(" OR ", fn tokens -> |> Enum.map_join(" OR ", fn tokens ->
tokens tokens
|> Enum.map_join(" AND ", &quoted_term/1) |> Enum.map_join(" AND ", &quoted_term/1)
|> then(&"(" <> &1 <> ")") |> then(&("(" <> &1 <> ")"))
end) end)
end end
@@ -495,7 +538,10 @@ defmodule BDS.Search do
end end
defp normalize_non_negative_integer(nil, default), do: default defp normalize_non_negative_integer(nil, default), do: default
defp normalize_non_negative_integer(value, _default) when is_integer(value) and value >= 0, do: value
defp normalize_non_negative_integer(value, _default) when is_integer(value) and value >= 0,
do: value
defp normalize_non_negative_integer(value, default), do: normalize_integer(value) || default defp normalize_non_negative_integer(value, default), do: normalize_integer(value) || default
defp normalize_timestamp(nil, _position), do: nil defp normalize_timestamp(nil, _position), do: nil
@@ -508,7 +554,8 @@ defmodule BDS.Search do
{:ok, datetime} = DateTime.new(date, time, "Etc/UTC") {:ok, datetime} = DateTime.new(date, time, "Etc/UTC")
DateTime.to_unix(datetime) DateTime.to_unix(datetime)
{:error, _reason} -> nil {:error, _reason} ->
nil
end end
end end

View File

@@ -185,7 +185,10 @@ defmodule BDS.Tags do
target_tag -> target_tag ->
source_tags = source_tags =
Repo.all(from tag in Tag, where: tag.id in ^source_tag_ids and tag.project_id == ^target_tag.project_id) Repo.all(
from tag in Tag,
where: tag.id in ^source_tag_ids and tag.project_id == ^target_tag.project_id
)
Repo.transaction(fn -> Repo.transaction(fn ->
source_names = Enum.map(source_tags, & &1.name) source_names = Enum.map(source_tags, & &1.name)
@@ -227,10 +230,21 @@ defmodule BDS.Tags do
end end
defp validate_unique_name(project_id, name) do defp validate_unique_name(project_id, name) do
if Repo.exists?(from tag in Tag, where: tag.project_id == ^project_id and fragment("lower(?)", tag.name) == ^String.downcase(name)) do if Repo.exists?(
from tag in Tag,
where:
tag.project_id == ^project_id and
fragment("lower(?)", tag.name) == ^String.downcase(name)
) do
{:error, {:error,
%Tag{} %Tag{}
|> Tag.changeset(%{project_id: project_id, name: name, id: Ecto.UUID.generate(), created_at: 0, updated_at: 0}) |> Tag.changeset(%{
project_id: project_id,
name: name,
id: Ecto.UUID.generate(),
created_at: 0,
updated_at: 0
})
|> Ecto.Changeset.add_error(:name, "has already been taken")} |> Ecto.Changeset.add_error(:name, "has already been taken")}
else else
:ok :ok
@@ -238,10 +252,21 @@ defmodule BDS.Tags do
end end
defp validate_rename_target(project_id, tag_id, name) do defp validate_rename_target(project_id, tag_id, name) do
if Repo.exists?(from tag in Tag, where: tag.project_id == ^project_id and tag.id != ^tag_id and fragment("lower(?)", tag.name) == ^String.downcase(name)) do if Repo.exists?(
from tag in Tag,
where:
tag.project_id == ^project_id and tag.id != ^tag_id and
fragment("lower(?)", tag.name) == ^String.downcase(name)
) do
{:error, {:error,
%Tag{} %Tag{}
|> Tag.changeset(%{project_id: project_id, name: name, id: tag_id, created_at: 0, updated_at: 0}) |> Tag.changeset(%{
project_id: project_id,
name: name,
id: tag_id,
created_at: 0,
updated_at: 0
})
|> Ecto.Changeset.add_error(:name, "has already been taken")} |> Ecto.Changeset.add_error(:name, "has already been taken")}
else else
:ok :ok

View File

@@ -19,7 +19,9 @@ defmodule BDS.Tags.Tag do
def changeset(tag, attrs) do def changeset(tag, attrs) do
tag tag
|> cast(attrs, [:id, :project_id, :name, :color, :post_template_slug, :created_at, :updated_at], |> cast(
attrs,
[:id, :project_id, :name, :color, :post_template_slug, :created_at, :updated_at],
empty_values: [nil] empty_values: [nil]
) )
|> validate_required([:id, :project_id, :name, :created_at, :updated_at]) |> validate_required([:id, :project_id, :name, :created_at, :updated_at])

View File

@@ -10,7 +10,8 @@ defmodule BDS.Tasks do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__) GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end end
def submit_task(name, work, attrs \\ %{}) when is_binary(name) and is_function(work, 1) and is_map(attrs) do def submit_task(name, work, attrs \\ %{})
when is_binary(name) and is_function(work, 1) and is_map(attrs) do
GenServer.call(__MODULE__, {:submit_task, name, work, attrs}) GenServer.call(__MODULE__, {:submit_task, name, work, attrs})
end end
@@ -57,7 +58,8 @@ defmodule BDS.Tasks do
if map_size(next_state.running) < max_concurrent() do if map_size(next_state.running) < max_concurrent() do
{:reply, {:ok, public_task(task)}, start_task(next_state, task.id, work)} {:reply, {:ok, public_task(task)}, start_task(next_state, task.id, work)}
else else
{:reply, {:ok, public_task(task)}, %{next_state | queue: next_state.queue ++ [{task.id, work}]}} {:reply, {:ok, public_task(task)},
%{next_state | queue: next_state.queue ++ [{task.id, work}]}}
end end
end end
@@ -83,7 +85,9 @@ defmodule BDS.Tasks do
next_state = next_state =
state state
|> update_task(task_id, %{status: :cancelled, finished_at: DateTime.utc_now()}) |> update_task(task_id, %{status: :cancelled, finished_at: DateTime.utc_now()})
|> Map.update!(:queue, fn queue -> Enum.reject(queue, fn {queued_id, _work} -> queued_id == task_id end) end) |> Map.update!(:queue, fn queue ->
Enum.reject(queue, fn {queued_id, _work} -> queued_id == task_id end)
end)
|> start_queued_tasks() |> start_queued_tasks()
{:reply, :ok, next_state} {:reply, :ok, next_state}
@@ -109,7 +113,11 @@ defmodule BDS.Tasks do
def handle_call({:complete_task, task_id}, _from, state) do def handle_call({:complete_task, task_id}, _from, state) do
next_state = next_state =
state state
|> update_task(task_id, %{status: :completed, progress: 1.0, finished_at: DateTime.utc_now()}) |> update_task(task_id, %{
status: :completed,
progress: 1.0,
finished_at: DateTime.utc_now()
})
|> start_queued_tasks() |> start_queued_tasks()
{:reply, :ok, next_state} {:reply, :ok, next_state}
@@ -118,7 +126,11 @@ defmodule BDS.Tasks do
def handle_call({:fail_task, task_id, error_message}, _from, state) do def handle_call({:fail_task, task_id, error_message}, _from, state) do
next_state = next_state =
state state
|> update_task(task_id, %{status: :failed, message: error_message, finished_at: DateTime.utc_now()}) |> update_task(task_id, %{
status: :failed,
message: error_message,
finished_at: DateTime.utc_now()
})
|> start_queued_tasks() |> start_queued_tasks()
{:reply, :ok, next_state} {:reply, :ok, next_state}
@@ -146,8 +158,16 @@ defmodule BDS.Tasks do
_status -> _status ->
attrs = attrs =
case normalize_result(result) do case normalize_result(result) do
{:ok, value} -> %{status: :completed, result: value, progress: 1.0, finished_at: DateTime.utc_now()} {:ok, value} ->
{:error, reason} -> %{status: :failed, error: reason, finished_at: DateTime.utc_now()} %{
status: :completed,
result: value,
progress: 1.0,
finished_at: DateTime.utc_now()
}
{:error, reason} ->
%{status: :failed, error: reason, finished_at: DateTime.utc_now()}
end end
update_task(state, task_id, attrs) update_task(state, task_id, attrs)
@@ -176,7 +196,11 @@ defmodule BDS.Tasks do
state state
true -> true ->
update_task(state, task_id, %{status: :failed, error: reason, finished_at: DateTime.utc_now()}) update_task(state, task_id, %{
status: :failed,
error: reason,
finished_at: DateTime.utc_now()
})
end end
|> remove_running(task_id, ref) |> remove_running(task_id, ref)
|> start_queued_tasks() |> start_queued_tasks()
@@ -186,7 +210,9 @@ defmodule BDS.Tasks do
end end
defp start_task(state, task_id, work) do defp start_task(state, task_id, work) do
reporter = fn value, message -> send(__MODULE__, {:task_progress, task_id, value, message}) end reporter = fn value, message ->
send(__MODULE__, {:task_progress, task_id, value, message})
end
task = task =
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
@@ -209,6 +235,7 @@ defmodule BDS.Tasks do
true -> true ->
[{task_id, work} | remaining] = state.queue [{task_id, work} | remaining] = state.queue
state state
|> Map.put(:queue, remaining) |> Map.put(:queue, remaining)
|> start_task(task_id, work) |> start_task(task_id, work)
@@ -231,8 +258,13 @@ defmodule BDS.Tasks do
now_ms = System.monotonic_time(:millisecond) now_ms = System.monotonic_time(:millisecond)
last_reported_at = Map.get(task, :last_reported_at) last_reported_at = Map.get(task, :last_reported_at)
if is_nil(last_reported_at) or now_ms - last_reported_at >= progress_throttle_ms() or value == 1.0 do if is_nil(last_reported_at) or now_ms - last_reported_at >= progress_throttle_ms() or
update_task(state, task_id, %{progress: value, message: message, last_reported_at: now_ms}) value == 1.0 do
update_task(state, task_id, %{
progress: value,
message: message,
last_reported_at: now_ms
})
else else
state state
end end

View File

@@ -50,11 +50,19 @@ defmodule BDS.Templates do
:ok = :ok =
File.write( File.write(
full_path, full_path,
serialize_template_file(%{template | status: :published, file_path: file_path, updated_at: updated_at}, content) serialize_template_file(
%{template | status: :published, file_path: file_path, updated_at: updated_at},
content
)
) )
template template
|> Template.changeset(%{status: :published, file_path: file_path, content: nil, updated_at: updated_at}) |> Template.changeset(%{
status: :published,
file_path: file_path,
content: nil,
updated_at: updated_at
})
|> Repo.update() |> Repo.update()
end end
end end
@@ -67,18 +75,32 @@ defmodule BDS.Templates do
template -> template ->
next_slug = next_slug =
if has_attr?(attrs, :slug) do if has_attr?(attrs, :slug) do
unique_slug(template.project_id, Slug.slugify(attr(attrs, :slug)), "template", template.id) unique_slug(
template.project_id,
Slug.slugify(attr(attrs, :slug)),
"template",
template.id
)
else else
template.slug template.slug
end end
content_changed? = has_attr?(attrs, :content) and attr(attrs, :content) != template.content content_changed? =
has_attr?(attrs, :content) and attr(attrs, :content) != template.content
slug_changed? = next_slug != template.slug slug_changed? = next_slug != template.slug
now = System.system_time(:second) now = System.system_time(:second)
next_status = if(template.status == :published and content_changed?, do: :draft, else: template.status)
next_status =
if(template.status == :published and content_changed?,
do: :draft,
else: template.status
)
next_file_path = next_template_file_path(template, next_slug) next_file_path = next_template_file_path(template, next_slug)
updates = %{} updates =
%{}
|> maybe_put(:title, attr(attrs, :title)) |> maybe_put(:title, attr(attrs, :title))
|> maybe_put(:kind, attr(attrs, :kind)) |> maybe_put(:kind, attr(attrs, :kind))
|> maybe_put(:enabled, attr(attrs, :enabled)) |> maybe_put(:enabled, attr(attrs, :enabled))
@@ -172,7 +194,9 @@ defmodule BDS.Templates do
end end
defp slug_available?(project_id, slug, exclude_id) do defp slug_available?(project_id, slug, exclude_id) do
query = from template in Template, where: template.project_id == ^project_id and template.slug == ^slug query =
from template in Template,
where: template.project_id == ^project_id and template.slug == ^slug
scoped_query = scoped_query =
case exclude_id do case exclude_id do
@@ -210,11 +234,23 @@ defmodule BDS.Templates do
end end
defp count_referencing_posts(template) do defp count_referencing_posts(template) do
Repo.aggregate(from(post in BDS.Posts.Post, where: post.project_id == ^template.project_id and post.template_slug == ^template.slug), :count, :id) Repo.aggregate(
from(post in BDS.Posts.Post,
where: post.project_id == ^template.project_id and post.template_slug == ^template.slug
),
:count,
:id
)
end end
defp count_referencing_tags(template) do defp count_referencing_tags(template) do
Repo.aggregate(from(tag in BDS.Tags.Tag, where: tag.project_id == ^template.project_id and tag.post_template_slug == ^template.slug), :count, :id) Repo.aggregate(
from(tag in BDS.Tags.Tag,
where: tag.project_id == ^template.project_id and tag.post_template_slug == ^template.slug
),
:count,
:id
)
end end
defp clear_template_references(template) do defp clear_template_references(template) do
@@ -227,10 +263,14 @@ defmodule BDS.Templates do
) )
) )
from(post in BDS.Posts.Post, where: post.project_id == ^template.project_id and post.template_slug == ^template.slug) from(post in BDS.Posts.Post,
where: post.project_id == ^template.project_id and post.template_slug == ^template.slug
)
|> Repo.update_all(set: [template_slug: nil, updated_at: now]) |> Repo.update_all(set: [template_slug: nil, updated_at: now])
from(tag in BDS.Tags.Tag, where: tag.project_id == ^template.project_id and tag.post_template_slug == ^template.slug) from(tag in BDS.Tags.Tag,
where: tag.project_id == ^template.project_id and tag.post_template_slug == ^template.slug
)
|> Repo.update_all(set: [post_template_slug: nil, updated_at: now]) |> Repo.update_all(set: [post_template_slug: nil, updated_at: now])
Enum.each(affected_posts, fn post -> Enum.each(affected_posts, fn post ->
@@ -246,17 +286,23 @@ defmodule BDS.Templates do
affected_posts = affected_posts =
Repo.all( Repo.all(
from(post in BDS.Posts.Post, from(post in BDS.Posts.Post,
where: post.project_id == ^original_template.project_id and post.template_slug == ^original_template.slug where:
post.project_id == ^original_template.project_id and
post.template_slug == ^original_template.slug
) )
) )
from(post in BDS.Posts.Post, from(post in BDS.Posts.Post,
where: post.project_id == ^original_template.project_id and post.template_slug == ^original_template.slug where:
post.project_id == ^original_template.project_id and
post.template_slug == ^original_template.slug
) )
|> Repo.update_all(set: [template_slug: updated_template.slug, updated_at: updated_at]) |> Repo.update_all(set: [template_slug: updated_template.slug, updated_at: updated_at])
from(tag in BDS.Tags.Tag, from(tag in BDS.Tags.Tag,
where: tag.project_id == ^original_template.project_id and tag.post_template_slug == ^original_template.slug where:
tag.project_id == ^original_template.project_id and
tag.post_template_slug == ^original_template.slug
) )
|> Repo.update_all(set: [post_template_slug: updated_template.slug, updated_at: updated_at]) |> Repo.update_all(set: [post_template_slug: updated_template.slug, updated_at: updated_at])

View File

@@ -24,10 +24,36 @@ defmodule BDS.Templates.Template do
def changeset(template, attrs) do def changeset(template, attrs) do
template template
|> cast(attrs, [:id, :project_id, :slug, :title, :kind, :enabled, :version, :file_path, :status, :content, :created_at, :updated_at], |> cast(
attrs,
[
:id,
:project_id,
:slug,
:title,
:kind,
:enabled,
:version,
:file_path,
:status,
:content,
:created_at,
:updated_at
],
empty_values: [nil] empty_values: [nil]
) )
|> validate_required([:id, :project_id, :slug, :title, :kind, :enabled, :version, :status, :created_at, :updated_at]) |> validate_required([
:id,
:project_id,
:slug,
:title,
:kind,
:enabled,
:version,
:status,
:created_at,
:updated_at
])
|> assoc_constraint(:project) |> assoc_constraint(:project)
|> unique_constraint(:slug, name: :templates_project_slug_idx) |> unique_constraint(:slug, name: :templates_project_slug_idx)
end end

View File

@@ -27,7 +27,8 @@ defmodule BDS.Types.StringList do
:error :error
end end
_ -> :error _ ->
:error
end end
end end

View File

@@ -10,7 +10,10 @@ defmodule BDS.GenerationTest do
setup do setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-generation-#{System.unique_integer([:positive])}")
temp_dir =
Path.join(System.tmp_dir!(), "bds-generation-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir) File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end) on_exit(fn -> File.rm_rf(temp_dir) end)
@@ -18,8 +21,13 @@ defmodule BDS.GenerationTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end end
test "write_generated_file writes under html output and skips unchanged content by hash", %{project: project, temp_dir: temp_dir} do test "write_generated_file writes under html output and skips unchanged content by hash", %{
assert {:ok, first_write} = BDS.Generation.write_generated_file(project.id, "index.html", "<html>hello</html>") project: project,
temp_dir: temp_dir
} do
assert {:ok, first_write} =
BDS.Generation.write_generated_file(project.id, "index.html", "<html>hello</html>")
assert first_write.written? == true assert first_write.written? == true
output_path = Path.join([temp_dir, "html", "index.html"]) output_path = Path.join([temp_dir, "html", "index.html"])
@@ -29,18 +37,30 @@ defmodule BDS.GenerationTest do
assert tracked_file.relative_path == "index.html" assert tracked_file.relative_path == "index.html"
assert tracked_file.content_hash == first_write.content_hash assert tracked_file.content_hash == first_write.content_hash
assert {:ok, second_write} = BDS.Generation.write_generated_file(project.id, "index.html", "<html>hello</html>") assert {:ok, second_write} =
BDS.Generation.write_generated_file(project.id, "index.html", "<html>hello</html>")
assert second_write.written? == false assert second_write.written? == false
assert second_write.content_hash == first_write.content_hash assert second_write.content_hash == first_write.content_hash
assert {:ok, third_write} = BDS.Generation.write_generated_file(project.id, "index.html", "<html>updated</html>") assert {:ok, third_write} =
BDS.Generation.write_generated_file(project.id, "index.html", "<html>updated</html>")
assert third_write.written? == true assert third_write.written? == true
assert third_write.content_hash != first_write.content_hash assert third_write.content_hash != first_write.content_hash
assert File.read!(output_path) == "<html>updated</html>" assert File.read!(output_path) == "<html>updated</html>"
end end
test "delete_generated_file removes tracked output and forgets its hash", %{project: project, temp_dir: temp_dir} do test "delete_generated_file removes tracked output and forgets its hash", %{
assert {:ok, _write} = BDS.Generation.write_generated_file(project.id, "tag/elixir/index.html", "<html>tag</html>") project: project,
temp_dir: temp_dir
} do
assert {:ok, _write} =
BDS.Generation.write_generated_file(
project.id,
"tag/elixir/index.html",
"<html>tag</html>"
)
output_path = Path.join([temp_dir, "html", "tag", "elixir", "index.html"]) output_path = Path.join([temp_dir, "html", "tag", "elixir", "index.html"])
assert File.exists?(output_path) assert File.exists?(output_path)
@@ -52,7 +72,8 @@ defmodule BDS.GenerationTest do
assert files == [] assert files == []
end end
test "plan_generation derives generation settings from project metadata and core generation writes tracked files", %{project: project, temp_dir: temp_dir} do test "plan_generation derives generation settings from project metadata and core generation writes tracked files",
%{project: project, temp_dir: temp_dir} do
assert {:ok, _metadata} = assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{ Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog", public_url: "https://example.com/blog",
@@ -88,7 +109,8 @@ defmodule BDS.GenerationTest do
"de/atom.xml" "de/atom.xml"
] ]
assert Enum.sort(Enum.map(result.generated_files, & &1.relative_path)) == Enum.sort(expected_paths) assert Enum.sort(Enum.map(result.generated_files, & &1.relative_path)) ==
Enum.sort(expected_paths)
for relative_path <- expected_paths do for relative_path <- expected_paths do
assert File.exists?(Path.join([temp_dir, "html", relative_path])) assert File.exists?(Path.join([temp_dir, "html", relative_path]))
@@ -97,7 +119,10 @@ defmodule BDS.GenerationTest do
assert File.read!(Path.join([temp_dir, "html", "sitemap.xml"])) =~ "https://example.com/blog/" assert File.read!(Path.join([temp_dir, "html", "sitemap.xml"])) =~ "https://example.com/blog/"
end end
test "generation renders published list and post templates for core and single pages", %{project: project, temp_dir: temp_dir} do test "generation renders published list and post templates for core and single pages", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, _metadata} = assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{ Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog", public_url: "https://example.com/blog",
@@ -110,7 +135,8 @@ defmodule BDS.GenerationTest do
project_id: project.id, project_id: project.id,
title: "List View", title: "List View",
kind: :list, kind: :list,
content: "<main class=\"list-template\"><h1>{{ page_title }}</h1>{% for post in posts %}<a href=\"{{ post.href }}\">{{ post.title }}</a>{% endfor %}</main>" content:
"<main class=\"list-template\"><h1>{{ page_title }}</h1>{% for post in posts %}<a href=\"{{ post.href }}\">{{ post.title }}</a>{% endfor %}</main>"
}) })
assert {:ok, _published_list} = BDS.Templates.publish_template(list_template.id) assert {:ok, _published_list} = BDS.Templates.publish_template(list_template.id)
@@ -120,7 +146,8 @@ defmodule BDS.GenerationTest do
project_id: project.id, project_id: project.id,
title: "Post View", title: "Post View",
kind: :post, kind: :post,
content: "<article class=\"post-template\"><h1>{{ post.title }}</h1><div class=\"body\">{{ post.content }}</div></article>" content:
"<article class=\"post-template\"><h1>{{ post.title }}</h1><div class=\"body\">{{ post.content }}</div></article>"
}) })
assert {:ok, published_post_template} = BDS.Templates.publish_template(post_template.id) assert {:ok, published_post_template} = BDS.Templates.publish_template(post_template.id)
@@ -153,7 +180,10 @@ defmodule BDS.GenerationTest do
assert post_html =~ "Rendered body" assert post_html =~ "Rendered body"
end end
test "generation renders copied starter templates with partials, i18n, and markdown", %{project: project, temp_dir: temp_dir} do test "generation renders copied starter templates with partials, i18n, and markdown", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, _menu} = assert {:ok, _menu} =
BDS.Menu.update_menu(project.id, [ BDS.Menu.update_menu(project.id, [
%{kind: :page, label: "Notes", slug: "notes"} %{kind: :page, label: "Notes", slug: "notes"}
@@ -199,7 +229,8 @@ defmodule BDS.GenerationTest do
assert post_html =~ "Language" assert post_html =~ "Language"
end end
test "generation expands starter-template markdown macros, rewrites canonical post links, media links, and emits not-found page", %{project: project, temp_dir: temp_dir} do test "generation expands starter-template markdown macros, rewrites canonical post links, media links, and emits not-found page",
%{project: project, temp_dir: temp_dir} do
assert {:ok, _metadata} = assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{ Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog", public_url: "https://example.com/blog",
@@ -228,7 +259,10 @@ defmodule BDS.GenerationTest do
assert {:ok, published_linked_post} = Posts.publish_post(linked_post.id) assert {:ok, published_linked_post} = Posts.publish_post(linked_post.id)
media_source_reference = "/" <> Path.join(Path.dirname(media.file_path), media.original_name) media_source_reference = "/" <> Path.join(Path.dirname(media.file_path), media.original_name)
canonical_post_href = "/" <> String.trim_trailing(BDS.Generation.post_output_path(published_linked_post), "index.html")
canonical_post_href =
"/" <>
String.trim_trailing(BDS.Generation.post_output_path(published_linked_post), "index.html")
assert {:ok, post} = assert {:ok, post} =
Posts.create_post(%{ Posts.create_post(%{
@@ -252,7 +286,9 @@ defmodule BDS.GenerationTest do
assert "404.html" in Enum.map(result.generated_files, & &1.relative_path) assert "404.html" in Enum.map(result.generated_files, & &1.relative_path)
post_html = File.read!(Path.join([temp_dir, "html", BDS.Generation.post_output_path(published_post)])) post_html =
File.read!(Path.join([temp_dir, "html", BDS.Generation.post_output_path(published_post)]))
assert post_html =~ ~s(src="https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0") assert post_html =~ ~s(src="https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0")
assert post_html =~ ~s(href="#{canonical_post_href}") assert post_html =~ ~s(href="#{canonical_post_href}")
assert post_html =~ ~s(src="/#{media.file_path}") assert post_html =~ ~s(src="/#{media.file_path}")
@@ -262,7 +298,10 @@ defmodule BDS.GenerationTest do
assert not_found_html =~ "Back to preview home" assert not_found_html =~ "Back to preview home"
end end
test "single generation writes canonical post pages and language-prefixed translation pages", %{project: project, temp_dir: temp_dir} do test "single generation writes canonical post pages and language-prefixed translation pages", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, _metadata} = assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{ Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog", public_url: "https://example.com/blog",
@@ -291,14 +330,18 @@ defmodule BDS.GenerationTest do
post_path = BDS.Generation.post_output_path(published_post) post_path = BDS.Generation.post_output_path(published_post)
translation_path = BDS.Generation.post_output_path(published_post, "de") translation_path = BDS.Generation.post_output_path(published_post, "de")
assert Enum.map(result.generated_files, & &1.relative_path) |> Enum.sort() == Enum.sort([post_path, translation_path]) assert Enum.map(result.generated_files, & &1.relative_path) |> Enum.sort() ==
Enum.sort([post_path, translation_path])
assert File.read!(Path.join([temp_dir, "html", post_path])) =~ "Hello generated world" assert File.read!(Path.join([temp_dir, "html", post_path])) =~ "Hello generated world"
assert File.read!(Path.join([temp_dir, "html", translation_path])) =~ "Hallo generierte Welt" assert File.read!(Path.join([temp_dir, "html", translation_path])) =~ "Hallo generierte Welt"
assert File.read!(Path.join([temp_dir, "html", post_path])) =~ ~s(data-pagefind-body) assert File.read!(Path.join([temp_dir, "html", post_path])) =~ ~s(data-pagefind-body)
end end
test "archive generation writes paginated category, tag, and date pages", %{project: project, temp_dir: temp_dir} do test "archive generation writes paginated category, tag, and date pages", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, _metadata} = assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{ Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog", public_url: "https://example.com/blog",
@@ -319,7 +362,11 @@ defmodule BDS.GenerationTest do
}) })
created_at = DateTime.to_unix(~U[2026-04-15 12:00:00Z]) + index created_at = DateTime.to_unix(~U[2026-04-15 12:00:00Z]) + index
Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^post.id), set: [created_at: created_at, updated_at: created_at])
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} = Posts.publish_post(post.id) assert {:ok, _published} = Posts.publish_post(post.id)
end end
@@ -341,8 +388,13 @@ defmodule BDS.GenerationTest do
assert expected_paths -- Enum.map(result.generated_files, & &1.relative_path) == [] assert expected_paths -- Enum.map(result.generated_files, & &1.relative_path) == []
assert File.read!(Path.join([temp_dir, "html", "category", "notes", "index.html"])) =~ "Archive 1" assert File.read!(Path.join([temp_dir, "html", "category", "notes", "index.html"])) =~
assert File.read!(Path.join([temp_dir, "html", "category", "notes", "page", "2", "index.html"])) =~ "Archive 3" "Archive 1"
assert File.read!(
Path.join([temp_dir, "html", "category", "notes", "page", "2", "index.html"])
) =~ "Archive 3"
assert File.read!(Path.join([temp_dir, "html", "tag", "elixir", "index.html"])) =~ "Elixir" assert File.read!(Path.join([temp_dir, "html", "tag", "elixir", "index.html"])) =~ "Elixir"
assert File.read!(Path.join([temp_dir, "html", "2026", "04", "index.html"])) =~ "2026-04" assert File.read!(Path.join([temp_dir, "html", "2026", "04", "index.html"])) =~ "2026-04"
end end

View File

@@ -7,7 +7,10 @@ defmodule BDS.MaintenanceTest do
setup do setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-maintenance-#{System.unique_integer([:positive])}")
temp_dir =
Path.join(System.tmp_dir!(), "bds-maintenance-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir) File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end) on_exit(fn -> File.rm_rf(temp_dir) end)
@@ -15,7 +18,10 @@ defmodule BDS.MaintenanceTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end end
test "rebuild_from_filesystem dispatches to posts, media, scripts, and templates rebuilders", %{project: project, temp_dir: temp_dir} do test "rebuild_from_filesystem dispatches to posts, media, scripts, and templates rebuilders", %{
project: project,
temp_dir: temp_dir
} do
posts_dir = Path.join([temp_dir, "posts", "2026", "04"]) posts_dir = Path.join([temp_dir, "posts", "2026", "04"])
File.mkdir_p!(posts_dir) File.mkdir_p!(posts_dir)
@@ -60,6 +66,7 @@ defmodule BDS.MaintenanceTest do
template_dir = Path.join(temp_dir, "templates") template_dir = Path.join(temp_dir, "templates")
File.mkdir_p!(template_dir) File.mkdir_p!(template_dir)
File.write!( File.write!(
Path.join(template_dir, "dispatch-view.liquid"), Path.join(template_dir, "dispatch-view.liquid"),
[ [
@@ -81,6 +88,7 @@ defmodule BDS.MaintenanceTest do
script_dir = Path.join(temp_dir, "scripts") script_dir = Path.join(temp_dir, "scripts")
File.mkdir_p!(script_dir) File.mkdir_p!(script_dir)
File.write!( File.write!(
Path.join(script_dir, "dispatch.lua"), Path.join(script_dir, "dispatch.lua"),
[ [
@@ -120,10 +128,14 @@ defmodule BDS.MaintenanceTest do
end end
test "rebuild_from_filesystem rejects unsupported entity types", %{project: project} do test "rebuild_from_filesystem rejects unsupported entity types", %{project: project} do
assert {:error, :unsupported_entity_type} = BDS.Maintenance.rebuild_from_filesystem(project.id, "unknown") assert {:error, :unsupported_entity_type} =
BDS.Maintenance.rebuild_from_filesystem(project.id, "unknown")
end end
test "metadata_diff reports field differences and orphan files across managed entities", %{project: project, temp_dir: temp_dir} do test "metadata_diff reports field differences and orphan files across managed entities", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "sample.txt") source_path = Path.join(temp_dir, "sample.txt")
File.write!(source_path, "hello media") File.write!(source_path, "hello media")
@@ -192,6 +204,7 @@ defmodule BDS.MaintenanceTest do
assert {:ok, published_template} = BDS.Templates.publish_template(template.id) assert {:ok, published_template} = BDS.Templates.publish_template(template.id)
post_path = Path.join(temp_dir, published_post.file_path) post_path = Path.join(temp_dir, published_post.file_path)
File.write!( File.write!(
post_path, post_path,
[ [
@@ -205,9 +218,9 @@ defmodule BDS.MaintenanceTest do
"language: de", "language: de",
"do_not_translate: false", "do_not_translate: false",
"template_slug: ", "template_slug: ",
"created_at: #{published_post.created_at}", "created_at: #{published_post.created_at + 10}",
"updated_at: #{published_post.updated_at}", "updated_at: #{published_post.updated_at + 20}",
"published_at: #{published_post.published_at}", "published_at: #{published_post.published_at + 30}",
"tags:", "tags:",
" - beta", " - beta",
"categories:", "categories:",
@@ -220,6 +233,7 @@ defmodule BDS.MaintenanceTest do
) )
post_translation_path = Path.join(temp_dir, published_post_translation.file_path) post_translation_path = Path.join(temp_dir, published_post_translation.file_path)
File.write!( File.write!(
post_translation_path, post_translation_path,
[ [
@@ -241,6 +255,7 @@ defmodule BDS.MaintenanceTest do
) )
media_sidecar_path = Path.join(temp_dir, media.sidecar_path) media_sidecar_path = Path.join(temp_dir, media.sidecar_path)
File.write!( File.write!(
media_sidecar_path, media_sidecar_path,
[ [
@@ -262,7 +277,9 @@ defmodule BDS.MaintenanceTest do
|> Enum.join("\n") |> Enum.join("\n")
) )
media_translation_sidecar_path = Path.join(temp_dir, "#{media.file_path}.#{media_translation.language}.meta") media_translation_sidecar_path =
Path.join(temp_dir, "#{media.file_path}.#{media_translation.language}.meta")
File.write!( File.write!(
media_translation_sidecar_path, media_translation_sidecar_path,
[ [
@@ -277,6 +294,7 @@ defmodule BDS.MaintenanceTest do
) )
script_path = Path.join(temp_dir, published_script.file_path) script_path = Path.join(temp_dir, published_script.file_path)
File.write!( File.write!(
script_path, script_path,
[ [
@@ -298,6 +316,7 @@ defmodule BDS.MaintenanceTest do
) )
template_path = Path.join(temp_dir, published_template.file_path) template_path = Path.join(temp_dir, published_template.file_path)
File.write!( File.write!(
template_path, template_path,
[ [
@@ -317,50 +336,128 @@ defmodule BDS.MaintenanceTest do
|> Enum.join("\n") |> Enum.join("\n")
) )
File.write!(Path.join([temp_dir, "posts", "2026", "04", "orphan-post.md"]), "---\nid: orphan\ntitle: Orphan\nslug: orphan\nstatus: published\ncreated_at: 1\nupdated_at: 1\npublished_at: 1\ntags:\ncategories:\n---\nBody\n") File.write!(
File.write!(Path.join([temp_dir, "posts", "2026", "04", "orphan-post.es.md"]), "---\nid: orphan-post-translation\ntranslation_for: orphan\nlanguage: es\ntitle: Huerfano\nstatus: published\ncreated_at: 1\nupdated_at: 1\npublished_at: 1\n---\nCuerpo\n") Path.join([temp_dir, "posts", "2026", "04", "orphan-post.md"]),
File.write!(Path.join([temp_dir, "media", "2026", "04", "orphan.txt"]), "orphan") "---\nid: orphan\ntitle: Orphan\nslug: orphan\nstatus: published\ncreated_at: 1\nupdated_at: 1\npublished_at: 1\ntags:\ncategories:\n---\nBody\n"
File.write!(Path.join([temp_dir, "media", "2026", "04", "orphan.txt.meta"]), "id: orphan-media\noriginal_name: orphan.txt\nmime_type: text/plain\nsize: 6\ncreated_at: 1\nupdated_at: 1\ntags:\n") )
File.write!(Path.join([temp_dir, "media", "2026", "04", "orphan.txt.es.meta"]), "translation_for: orphan-media\nlanguage: es\ntitle: Huerfano\nalt: Texto\ncaption: Leyenda\n")
File.write!(Path.join([temp_dir, "scripts", "orphan.lua"]), "---\nid: orphan-script\nslug: orphan-script\ntitle: Orphan Script\nkind: utility\nentrypoint: main\nenabled: true\nversion: 1\ncreated_at: 1\nupdated_at: 1\n---\nfunction main() return true end\n")
File.write!(Path.join([temp_dir, "templates", "orphan-view.liquid"]), "---\nid: orphan-template\nslug: orphan-view\ntitle: Orphan View\nkind: list\nenabled: true\nversion: 1\ncreated_at: 1\nupdated_at: 1\n---\n<section>Orphan</section>\n")
assert {:ok, %{diff_reports: diff_reports, orphan_reports: orphan_reports}} = BDS.Maintenance.metadata_diff(project.id) File.write!(
Path.join([temp_dir, "posts", "2026", "04", "orphan-post.es.md"]),
"---\nid: orphan-post-translation\ntranslation_for: orphan\nlanguage: es\ntitle: Huerfano\nstatus: published\ncreated_at: 1\nupdated_at: 1\npublished_at: 1\n---\nCuerpo\n"
)
File.write!(Path.join([temp_dir, "media", "2026", "04", "orphan.txt"]), "orphan")
File.write!(
Path.join([temp_dir, "media", "2026", "04", "orphan.txt.meta"]),
"id: orphan-media\noriginal_name: orphan.txt\nmime_type: text/plain\nsize: 6\ncreated_at: 1\nupdated_at: 1\ntags:\n"
)
File.write!(
Path.join([temp_dir, "media", "2026", "04", "orphan.txt.es.meta"]),
"translation_for: orphan-media\nlanguage: es\ntitle: Huerfano\nalt: Texto\ncaption: Leyenda\n"
)
File.write!(
Path.join([temp_dir, "scripts", "orphan.lua"]),
"---\nid: orphan-script\nslug: orphan-script\ntitle: Orphan Script\nkind: utility\nentrypoint: main\nenabled: true\nversion: 1\ncreated_at: 1\nupdated_at: 1\n---\nfunction main() return true end\n"
)
File.write!(
Path.join([temp_dir, "templates", "orphan-view.liquid"]),
"---\nid: orphan-template\nslug: orphan-view\ntitle: Orphan View\nkind: list\nenabled: true\nversion: 1\ncreated_at: 1\nupdated_at: 1\n---\n<section>Orphan</section>\n"
)
assert {:ok, %{diff_reports: diff_reports, orphan_reports: orphan_reports}} =
BDS.Maintenance.metadata_diff(project.id)
assert Enum.any?(diff_reports, fn report -> assert Enum.any?(diff_reports, fn report ->
report.entity_type == "post" and report.entity_id == published_post.id and report.entity_type == "post" and report.entity_id == published_post.id and
Enum.any?(report.differences, &(&1.name == "title" and &1.db_value == "Original Post" and &1.file_value == "Edited Post")) and Enum.any?(
Enum.any?(report.differences, &(&1.name == "excerpt" and &1.db_value == "Original summary" and &1.file_value == "Edited summary")) report.differences,
&(&1.name == "title" and &1.db_value == "Original Post" and
&1.file_value == "Edited Post")
) and
Enum.any?(
report.differences,
&(&1.name == "excerpt" and &1.db_value == "Original summary" and
&1.file_value == "Edited summary")
) and
Enum.any?(
report.differences,
&(&1.name == "created_at" and
&1.file_value == Integer.to_string(published_post.created_at + 10))
) and
Enum.any?(
report.differences,
&(&1.name == "updated_at" and
&1.file_value == Integer.to_string(published_post.updated_at + 20))
) and
Enum.any?(
report.differences,
&(&1.name == "published_at" and
&1.file_value == Integer.to_string(published_post.published_at + 30))
)
end) end)
assert Enum.any?(diff_reports, fn report -> assert Enum.any?(diff_reports, fn report ->
report.entity_type == "media" and report.entity_id == media.id and report.entity_type == "media" and report.entity_id == media.id and
Enum.any?(report.differences, &(&1.name == "title" and &1.file_value == "Edited media title")) and Enum.any?(
report.differences,
&(&1.name == "title" and &1.file_value == "Edited media title")
) and
Enum.any?(report.differences, &(&1.name == "language" and &1.file_value == "de")) Enum.any?(report.differences, &(&1.name == "language" and &1.file_value == "de"))
end) end)
assert Enum.any?(diff_reports, fn report -> assert Enum.any?(diff_reports, fn report ->
report.entity_type == "script" and report.entity_id == published_script.id and report.entity_type == "script" and report.entity_id == published_script.id and
Enum.any?(report.differences, &(&1.name == "title" and &1.file_value == "Edited Script")) and Enum.any?(
Enum.any?(report.differences, &(&1.name == "entrypoint" and &1.file_value == "run")) report.differences,
&(&1.name == "title" and &1.file_value == "Edited Script")
) and
Enum.any?(
report.differences,
&(&1.name == "entrypoint" and &1.file_value == "run")
)
end) end)
assert Enum.any?(diff_reports, fn report -> assert Enum.any?(diff_reports, fn report ->
report.entity_type == "template" and report.entity_id == published_template.id and report.entity_type == "template" and report.entity_id == published_template.id and
Enum.any?(report.differences, &(&1.name == "title" and &1.file_value == "Edited Template")) and Enum.any?(
report.differences,
&(&1.name == "title" and &1.file_value == "Edited Template")
) and
Enum.any?(report.differences, &(&1.name == "enabled" and &1.file_value == "false")) Enum.any?(report.differences, &(&1.name == "enabled" and &1.file_value == "false"))
end) end)
assert Enum.any?(diff_reports, fn report -> assert Enum.any?(diff_reports, fn report ->
report.entity_type == "post_translation" and report.entity_id == published_post_translation.id and report.entity_type == "post_translation" and
Enum.any?(report.differences, &(&1.name == "title" and &1.db_value == "Ursprunglicher Beitrag" and &1.file_value == "Bearbeiteter Beitrag")) and report.entity_id == published_post_translation.id and
Enum.any?(report.differences, &(&1.name == "excerpt" and &1.db_value == "Zusammenfassung" and &1.file_value == "Bearbeitete Zusammenfassung")) Enum.any?(
report.differences,
&(&1.name == "title" and &1.db_value == "Ursprunglicher Beitrag" and
&1.file_value == "Bearbeiteter Beitrag")
) and
Enum.any?(
report.differences,
&(&1.name == "excerpt" and &1.db_value == "Zusammenfassung" and
&1.file_value == "Bearbeitete Zusammenfassung")
)
end) end)
assert Enum.any?(diff_reports, fn report -> assert Enum.any?(diff_reports, fn report ->
report.entity_type == "media_translation" and report.entity_id == media_translation.id and report.entity_type == "media_translation" and
Enum.any?(report.differences, &(&1.name == "title" and &1.db_value == "Ubersetzter Medientitel" and &1.file_value == "Bearbeiteter Medientitel")) and report.entity_id == media_translation.id and
Enum.any?(report.differences, &(&1.name == "alt" and &1.db_value == "Ubersetzter Alt-Text" and &1.file_value == "Bearbeiteter Alt-Text")) Enum.any?(
report.differences,
&(&1.name == "title" and &1.db_value == "Ubersetzter Medientitel" and
&1.file_value == "Bearbeiteter Medientitel")
) and
Enum.any?(
report.differences,
&(&1.name == "alt" and &1.db_value == "Ubersetzter Alt-Text" and
&1.file_value == "Bearbeiteter Alt-Text")
)
end) end)
orphan_paths = Enum.map(orphan_reports, & &1.file_path) orphan_paths = Enum.map(orphan_reports, & &1.file_path)

View File

@@ -13,7 +13,10 @@ defmodule BDS.MediaTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end end
test "import_media copies the binary, creates a sidecar, and persists the row", %{project: project, temp_dir: temp_dir} do test "import_media copies the binary, creates a sidecar, and persists the row", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "sample.txt") source_path = Path.join(temp_dir, "sample.txt")
File.write!(source_path, "hello media") File.write!(source_path, "hello media")
@@ -54,7 +57,8 @@ defmodule BDS.MediaTest do
source_path = Path.join(temp_dir, "sample.txt") source_path = Path.join(temp_dir, "sample.txt")
File.write!(source_path, "hello media") File.write!(source_path, "hello media")
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
assert {:ok, updated} = assert {:ok, updated} =
BDS.Media.update_media(media.id, %{ BDS.Media.update_media(media.id, %{
@@ -76,11 +80,15 @@ defmodule BDS.MediaTest do
assert sidecar =~ "tags:\n - beta\n" assert sidecar =~ "tags:\n - beta\n"
end end
test "delete_media removes the binary, sidecar, and database row", %{project: project, temp_dir: temp_dir} do test "delete_media removes the binary, sidecar, and database row", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "sample.txt") source_path = Path.join(temp_dir, "sample.txt")
File.write!(source_path, "hello media") File.write!(source_path, "hello media")
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
assert {:ok, _translation} = assert {:ok, _translation} =
BDS.Media.upsert_media_translation(media.id, "de", %{ BDS.Media.upsert_media_translation(media.id, "de", %{
@@ -103,7 +111,10 @@ defmodule BDS.MediaTest do
end) end)
end end
test "rebuild_media_from_files recreates media rows from sidecars", %{project: project, temp_dir: temp_dir} do test "rebuild_media_from_files recreates media rows from sidecars", %{
project: project,
temp_dir: temp_dir
} do
media_dir = Path.join([temp_dir, "media", "2026", "04"]) media_dir = Path.join([temp_dir, "media", "2026", "04"])
File.mkdir_p!(media_dir) File.mkdir_p!(media_dir)
@@ -181,16 +192,27 @@ defmodule BDS.MediaTest do
end) end)
end end
test "import_media generates the four thumbnail files in bucketed thumbnail paths", %{project: project, temp_dir: temp_dir} do test "import_media generates the four thumbnail files in bucketed thumbnail paths", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "sample.jpg") source_path = Path.join(temp_dir, "sample.jpg")
File.write!(source_path, tiny_jpeg_binary()) File.write!(source_path, tiny_jpeg_binary())
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
thumbnail_paths = BDS.Media.thumbnail_paths(media) thumbnail_paths = BDS.Media.thumbnail_paths(media)
assert thumbnail_paths.small == "thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-small.webp"
assert thumbnail_paths.medium == "thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-medium.webp" assert thumbnail_paths.small ==
assert thumbnail_paths.large == "thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-large.webp" "thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-small.webp"
assert thumbnail_paths.medium ==
"thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-medium.webp"
assert thumbnail_paths.large ==
"thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-large.webp"
assert thumbnail_paths.ai == "thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-ai.jpg" assert thumbnail_paths.ai == "thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-ai.jpg"
Enum.each(Map.values(thumbnail_paths), fn path -> Enum.each(Map.values(thumbnail_paths), fn path ->
@@ -198,11 +220,15 @@ defmodule BDS.MediaTest do
end) end)
end end
test "import_media extracts image dimensions and writes real encoded thumbnails", %{project: project, temp_dir: temp_dir} do test "import_media extracts image dimensions and writes real encoded thumbnails", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "sample.jpg") source_path = Path.join(temp_dir, "sample.jpg")
File.write!(source_path, tiny_jpeg_binary()) File.write!(source_path, tiny_jpeg_binary())
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
assert media.mime_type == "image/jpeg" assert media.mime_type == "image/jpeg"
assert media.width == 3 assert media.width == 3
@@ -230,11 +256,13 @@ defmodule BDS.MediaTest do
assert Path.extname(thumbnail_paths.ai) == ".jpg" assert Path.extname(thumbnail_paths.ai) == ".jpg"
end end
test "import_media keeps raw header dimensions but autorotates thumbnails from EXIF orientation", %{project: project, temp_dir: temp_dir} do test "import_media keeps raw header dimensions but autorotates thumbnails from EXIF orientation",
%{project: project, temp_dir: temp_dir} do
source_path = Path.join(temp_dir, "rotated.jpg") source_path = Path.join(temp_dir, "rotated.jpg")
write_oriented_jpeg!(source_path, 6) write_oriented_jpeg!(source_path, 6)
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
assert media.width == 2 assert media.width == 2
assert media.height == 3 assert media.height == 3
@@ -248,11 +276,15 @@ defmodule BDS.MediaTest do
assert_images_match!(actual_thumbnail, expected_thumbnail) assert_images_match!(actual_thumbnail, expected_thumbnail)
end end
test "regenerate_thumbnails recreates thumbnail files for an existing image media item", %{project: project, temp_dir: temp_dir} do test "regenerate_thumbnails recreates thumbnail files for an existing image media item", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "sample.jpg") source_path = Path.join(temp_dir, "sample.jpg")
File.write!(source_path, tiny_jpeg_binary()) File.write!(source_path, tiny_jpeg_binary())
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
thumbnail_paths = BDS.Media.thumbnail_paths(media) thumbnail_paths = BDS.Media.thumbnail_paths(media)
File.rm!(Path.join(temp_dir, thumbnail_paths.small)) File.rm!(Path.join(temp_dir, thumbnail_paths.small))
@@ -266,12 +298,17 @@ defmodule BDS.MediaTest do
end) end)
end end
test "import_media generates thumbnails for png and webp sources", %{project: project, temp_dir: temp_dir} do test "import_media generates thumbnails for png and webp sources", %{
Enum.each([{ ".png", "image/png"}, {".webp", "image/webp"}], fn {extension, mime_type} -> project: project,
temp_dir: temp_dir
} do
Enum.each([{".png", "image/png"}, {".webp", "image/webp"}], fn {extension, mime_type} ->
source_path = Path.join(temp_dir, "sample#{extension}") source_path = Path.join(temp_dir, "sample#{extension}")
File.write!(source_path, sample_image_binary(extension)) File.write!(source_path, sample_image_binary(extension))
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
assert media.mime_type == mime_type assert media.mime_type == mime_type
assert media.width == 2 assert media.width == 2
assert media.height == 3 assert media.height == 3
@@ -282,29 +319,39 @@ defmodule BDS.MediaTest do
end) end)
end end
test "import_media detects supported TIFF, BMP, HEIC, and HEIF extensions", %{project: project, temp_dir: temp_dir} do test "import_media detects supported TIFF, BMP, HEIC, and HEIF extensions", %{
Enum.each([ project: project,
temp_dir: temp_dir
} do
Enum.each(
[
{"asset.tif", "image/tiff"}, {"asset.tif", "image/tiff"},
{"asset.tiff", "image/tiff"}, {"asset.tiff", "image/tiff"},
{"asset.bmp", "image/bmp"}, {"asset.bmp", "image/bmp"},
{"asset.heic", "image/heic"}, {"asset.heic", "image/heic"},
{"asset.heif", "image/heif"} {"asset.heif", "image/heif"}
], fn {file_name, mime_type} -> ],
fn {file_name, mime_type} ->
source_path = Path.join(temp_dir, file_name) source_path = Path.join(temp_dir, file_name)
File.write!(source_path, "placeholder") File.write!(source_path, "placeholder")
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
assert media.mime_type == mime_type assert media.mime_type == mime_type
assert media.width == nil assert media.width == nil
assert media.height == nil assert media.height == nil
end) end
)
end end
test "upsert_media_translation persists the row and writes a translated sidecar next to the binary", %{project: project, temp_dir: temp_dir} do test "upsert_media_translation persists the row and writes a translated sidecar next to the binary",
%{project: project, temp_dir: temp_dir} do
source_path = Path.join(temp_dir, "sample.txt") source_path = Path.join(temp_dir, "sample.txt")
File.write!(source_path, "hello media") File.write!(source_path, "hello media")
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
assert {:ok, translation} = assert {:ok, translation} =
BDS.Media.upsert_media_translation(media.id, "de", %{ BDS.Media.upsert_media_translation(media.id, "de", %{

View File

@@ -11,7 +11,8 @@ defmodule BDS.MenuTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end end
test "update_menu normalizes Home first, writes meta/menu.opml, and load returns nested items", %{project: project, temp_dir: temp_dir} do test "update_menu normalizes Home first, writes meta/menu.opml, and load returns nested items",
%{project: project, temp_dir: temp_dir} do
assert {:ok, menu} = assert {:ok, menu} =
BDS.Menu.update_menu(project.id, [ BDS.Menu.update_menu(project.id, [
%{kind: :page, label: "About", slug: "about"}, %{kind: :page, label: "About", slug: "about"},
@@ -52,7 +53,10 @@ defmodule BDS.MenuTest do
assert loaded == menu assert loaded == menu
end end
test "sync_menu_from_filesystem loads canonical OPML and preserves a prepended Home entry", %{project: project, temp_dir: temp_dir} do test "sync_menu_from_filesystem loads canonical OPML and preserves a prepended Home entry", %{
project: project,
temp_dir: temp_dir
} do
meta_dir = Path.join(temp_dir, "meta") meta_dir = Path.join(temp_dir, "meta")
File.mkdir_p!(meta_dir) File.mkdir_p!(meta_dir)

View File

@@ -11,7 +11,10 @@ defmodule BDS.MetadataTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end end
test "update_project_metadata writes meta/project.json and load returns the saved values", %{project: project, temp_dir: temp_dir} do test "update_project_metadata writes meta/project.json and load returns the saved values", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, metadata} = assert {:ok, metadata} =
BDS.Metadata.update_project_metadata(project.id, %{ BDS.Metadata.update_project_metadata(project.id, %{
name: "Renamed Blog", name: "Renamed Blog",
@@ -51,7 +54,8 @@ defmodule BDS.MetadataTest do
assert loaded.blog_languages == ["de", "fr"] assert loaded.blog_languages == ["de", "fr"]
end end
test "category and publishing updates write their meta files and sync_project_metadata_from_filesystem loads them", %{project: project, temp_dir: temp_dir} do test "category and publishing updates write their meta files and sync_project_metadata_from_filesystem loads them",
%{project: project, temp_dir: temp_dir} do
assert {:ok, _metadata} = BDS.Metadata.add_category(project.id, "news") assert {:ok, _metadata} = BDS.Metadata.add_category(project.id, "news")
assert {:ok, _metadata} = assert {:ok, _metadata} =

View File

@@ -6,7 +6,10 @@ defmodule BDS.PostTranslationsTest do
setup do setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-post-translations-#{System.unique_integer([:positive])}")
temp_dir =
Path.join(System.tmp_dir!(), "bds-post-translations-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir) File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end) on_exit(fn -> File.rm_rf(temp_dir) end)
@@ -14,7 +17,8 @@ defmodule BDS.PostTranslationsTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end end
test "upserted post translations publish with the canonical post, reopen on edit, and delete their file", %{project: project, temp_dir: temp_dir} do test "upserted post translations publish with the canonical post, reopen on edit, and delete their file",
%{project: project, temp_dir: temp_dir} do
assert {:ok, post} = assert {:ok, post} =
Posts.create_post(%{ Posts.create_post(%{
project_id: project.id, project_id: project.id,
@@ -75,7 +79,8 @@ defmodule BDS.PostTranslationsTest do
assert {:ok, []} = Posts.list_post_translations(post.id) assert {:ok, []} = Posts.list_post_translations(post.id)
end end
test "validate_translations reports missing languages, orphan translation files, and do-not-translate posts", %{project: project, temp_dir: temp_dir} do test "validate_translations reports missing languages, orphan translation files, and do-not-translate posts",
%{project: project, temp_dir: temp_dir} do
assert {:ok, _metadata} = assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{ Metadata.update_project_metadata(project.id, %{
main_language: "en", main_language: "en",

View File

@@ -13,7 +13,9 @@ defmodule BDS.PostsTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end end
test "create_post slugifies titles, stores list fields, and defaults draft fields", %{project: project} do test "create_post slugifies titles, stores list fields, and defaults draft fields", %{
project: project
} do
assert {:ok, post} = assert {:ok, post} =
BDS.Posts.create_post(%{ BDS.Posts.create_post(%{
project_id: project.id, project_id: project.id,
@@ -48,7 +50,10 @@ defmodule BDS.PostsTest do
assert duplicate_slug_post.categories == [] assert duplicate_slug_post.categories == []
end end
test "create_post falls back to untitled and keeps slug uniqueness scoped to a project", %{project: project, temp_dir: temp_dir} do test "create_post falls back to untitled and keeps slug uniqueness scoped to a project", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, first} = BDS.Posts.create_post(%{project_id: project.id, title: nil}) assert {:ok, first} = BDS.Posts.create_post(%{project_id: project.id, title: nil})
assert first.title == "" assert first.title == ""
assert first.slug == "untitled" assert first.slug == "untitled"
@@ -59,12 +64,15 @@ defmodule BDS.PostsTest do
other_temp_dir = Path.join(temp_dir, "elsewhere") other_temp_dir = Path.join(temp_dir, "elsewhere")
File.mkdir_p!(other_temp_dir) File.mkdir_p!(other_temp_dir)
assert {:ok, other_project} = BDS.Projects.create_project(%{name: "Elsewhere", data_path: other_temp_dir}) assert {:ok, other_project} =
BDS.Projects.create_project(%{name: "Elsewhere", data_path: other_temp_dir})
assert {:ok, other_post} = BDS.Posts.create_post(%{project_id: other_project.id, title: nil}) assert {:ok, other_post} = BDS.Posts.create_post(%{project_id: other_project.id, title: nil})
assert other_post.slug == "untitled" assert other_post.slug == "untitled"
end end
test "update_post rejects slug changes after first publish and reopens published posts when content changes", %{project: project} do test "update_post rejects slug changes after first publish and reopens published posts when content changes",
%{project: project} do
assert {:ok, post} = assert {:ok, post} =
BDS.Posts.create_post(%{ BDS.Posts.create_post(%{
project_id: project.id, project_id: project.id,
@@ -97,11 +105,14 @@ defmodule BDS.PostsTest do
end end
test "publish_post writes frontmatter to the project data directory and clears draft content" do test "publish_post writes frontmatter to the project data directory and clears draft content" do
temp_dir = Path.join(System.tmp_dir!(), "bds-post-publish-#{System.unique_integer([:positive])}") temp_dir =
Path.join(System.tmp_dir!(), "bds-post-publish-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir) File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end) on_exit(fn -> File.rm_rf(temp_dir) end)
assert {:ok, project} = BDS.Projects.create_project(%{name: "Filesystem", data_path: temp_dir}) assert {:ok, project} =
BDS.Projects.create_project(%{name: "Filesystem", data_path: temp_dir})
assert {:ok, post} = assert {:ok, post} =
BDS.Posts.create_post(%{ BDS.Posts.create_post(%{
@@ -144,7 +155,9 @@ defmodule BDS.PostsTest do
end end
test "delete_post removes the database row and published markdown file when present" do test "delete_post removes the database row and published markdown file when present" do
temp_dir = Path.join(System.tmp_dir!(), "bds-post-delete-#{System.unique_integer([:positive])}") temp_dir =
Path.join(System.tmp_dir!(), "bds-post-delete-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir) File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end) on_exit(fn -> File.rm_rf(temp_dir) end)
@@ -178,11 +191,14 @@ defmodule BDS.PostsTest do
assert {:ok, archived_draft} = BDS.Posts.archive_post(draft_post.id) assert {:ok, archived_draft} = BDS.Posts.archive_post(draft_post.id)
assert archived_draft.status == :archived assert archived_draft.status == :archived
temp_dir = Path.join(System.tmp_dir!(), "bds-post-archive-#{System.unique_integer([:positive])}") temp_dir =
Path.join(System.tmp_dir!(), "bds-post-archive-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir) File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end) on_exit(fn -> File.rm_rf(temp_dir) end)
assert {:ok, publish_project} = BDS.Projects.create_project(%{name: "Archive Published", data_path: temp_dir}) assert {:ok, publish_project} =
BDS.Projects.create_project(%{name: "Archive Published", data_path: temp_dir})
assert {:ok, published_post} = assert {:ok, published_post} =
BDS.Posts.create_post(%{ BDS.Posts.create_post(%{
@@ -200,7 +216,9 @@ defmodule BDS.PostsTest do
end end
test "publish_post republishes archived posts without losing the existing body or original published_at" do test "publish_post republishes archived posts without losing the existing body or original published_at" do
temp_dir = Path.join(System.tmp_dir!(), "bds-post-republish-#{System.unique_integer([:positive])}") temp_dir =
Path.join(System.tmp_dir!(), "bds-post-republish-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir) File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end) on_exit(fn -> File.rm_rf(temp_dir) end)
@@ -227,7 +245,9 @@ defmodule BDS.PostsTest do
end end
test "rebuild_posts_from_files recreates published posts from disk" do test "rebuild_posts_from_files recreates published posts from disk" do
temp_dir = Path.join(System.tmp_dir!(), "bds-post-rebuild-#{System.unique_integer([:positive])}") temp_dir =
Path.join(System.tmp_dir!(), "bds-post-rebuild-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir) File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end) on_exit(fn -> File.rm_rf(temp_dir) end)
@@ -279,9 +299,9 @@ defmodule BDS.PostsTest do
assert post.language == "en" assert post.language == "en"
assert post.do_not_translate == true assert post.do_not_translate == true
assert post.template_slug == "article" assert post.template_slug == "article"
assert post.created_at == 1711843200 assert post.created_at == 1_711_843_200
assert post.updated_at == 1711929600 assert post.updated_at == 1_711_929_600
assert post.published_at == 1712016000 assert post.published_at == 1_712_016_000
assert post.tags == ["alpha"] assert post.tags == ["alpha"]
assert post.categories == ["notes"] assert post.categories == ["notes"]
assert post.file_path == "posts/2026/04/recovered-post.md" assert post.file_path == "posts/2026/04/recovered-post.md"

View File

@@ -16,7 +16,8 @@ defmodule BDS.PreviewTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end end
test "start_preview binds localhost and request resolves generated routes, assets, media, and draft previews", %{project: project, temp_dir: temp_dir} do test "start_preview binds localhost and request resolves generated routes, assets, media, and draft previews",
%{project: project, temp_dir: temp_dir} do
assert {:ok, _metadata} = assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{ Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog", public_url: "https://example.com/blog",
@@ -24,10 +25,29 @@ defmodule BDS.PreviewTest do
blog_languages: ["en", "de"] blog_languages: ["en", "de"]
}) })
assert {:ok, _} = Generation.write_generated_file(project.id, "index.html", "<html>home</html>") assert {:ok, _} =
assert {:ok, _} = Generation.write_generated_file(project.id, "de/index.html", "<html>startseite</html>") Generation.write_generated_file(project.id, "index.html", "<html>home</html>")
assert {:ok, _} = Generation.write_generated_file(project.id, "tag/elixir/index.html", "<html>tag archive</html>")
assert {:ok, _} = Generation.write_generated_file(project.id, "pagefind/pagefind-ui.js", "console.log('pagefind')") assert {:ok, _} =
Generation.write_generated_file(
project.id,
"de/index.html",
"<html>startseite</html>"
)
assert {:ok, _} =
Generation.write_generated_file(
project.id,
"tag/elixir/index.html",
"<html>tag archive</html>"
)
assert {:ok, _} =
Generation.write_generated_file(
project.id,
"pagefind/pagefind-ui.js",
"console.log('pagefind')"
)
media_dir = Path.join([temp_dir, "media", "2026", "04"]) media_dir = Path.join([temp_dir, "media", "2026", "04"])
File.mkdir_p!(media_dir) File.mkdir_p!(media_dir)
@@ -46,11 +66,20 @@ defmodule BDS.PreviewTest do
assert server.port == 4123 assert server.port == 4123
assert server.is_running == true assert server.is_running == true
assert {:ok, %{body: "<html>home</html>", content_type: "text/html"}} = BDS.Preview.request(project.id, "/") assert {:ok, %{body: "<html>home</html>", content_type: "text/html"}} =
assert {:ok, %{body: "<html>startseite</html>", content_type: "text/html"}} = BDS.Preview.request(project.id, "/de/") BDS.Preview.request(project.id, "/")
assert {:ok, %{body: "<html>tag archive</html>", content_type: "text/html"}} = BDS.Preview.request(project.id, "/tag/elixir")
assert {:ok, %{body: "console.log('pagefind')", content_type: "application/javascript"}} = BDS.Preview.request(project.id, "/pagefind/pagefind-ui.js") assert {:ok, %{body: "<html>startseite</html>", content_type: "text/html"}} =
assert {:ok, %{body: "media body", content_type: "text/plain"}} = BDS.Preview.request(project.id, "/media/2026/04/image.txt") BDS.Preview.request(project.id, "/de/")
assert {:ok, %{body: "<html>tag archive</html>", content_type: "text/html"}} =
BDS.Preview.request(project.id, "/tag/elixir")
assert {:ok, %{body: "console.log('pagefind')", content_type: "application/javascript"}} =
BDS.Preview.request(project.id, "/pagefind/pagefind-ui.js")
assert {:ok, %{body: "media body", content_type: "text/plain"}} =
BDS.Preview.request(project.id, "/media/2026/04/image.txt")
assert {:ok, %{body: draft_html, content_type: "text/html"}} = assert {:ok, %{body: draft_html, content_type: "text/html"}} =
BDS.Preview.preview_draft(project.id, "/draft/draft-post", post.id) BDS.Preview.preview_draft(project.id, "/draft/draft-post", post.id)
@@ -67,7 +96,8 @@ defmodule BDS.PreviewTest do
project_id: project.id, project_id: project.id,
title: "Preview Post", title: "Preview Post",
kind: :post, kind: :post,
content: "<article class=\"preview-template\"><h1>{{ post.title }}</h1><div>{{ post.content }}</div></article>" content:
"<article class=\"preview-template\"><h1>{{ post.title }}</h1><div>{{ post.content }}</div></article>"
}) })
assert {:ok, published_template} = BDS.Templates.publish_template(template.id) assert {:ok, published_template} = BDS.Templates.publish_template(template.id)
@@ -93,7 +123,9 @@ defmodule BDS.PreviewTest do
assert :ok = BDS.Preview.stop_preview(project.id) assert :ok = BDS.Preview.stop_preview(project.id)
end end
test "draft preview renders through copied starter templates with markdown and i18n", %{project: project} do test "draft preview renders through copied starter templates with markdown and i18n", %{
project: project
} do
assert {:ok, _menu} = assert {:ok, _menu} =
BDS.Menu.update_menu(project.id, [ BDS.Menu.update_menu(project.id, [
%{kind: :page, label: "Notes", slug: "notes"} %{kind: :page, label: "Notes", slug: "notes"}
@@ -126,7 +158,8 @@ defmodule BDS.PreviewTest do
assert :ok = BDS.Preview.stop_preview(project.id) assert :ok = BDS.Preview.stop_preview(project.id)
end end
test "preview renders not-found template for missing routes and rewrites markdown macros and canonical URLs", %{project: project, temp_dir: temp_dir} do 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() :inets.start()
assert {:ok, _metadata} = assert {:ok, _metadata} =
@@ -157,7 +190,10 @@ defmodule BDS.PreviewTest do
assert {:ok, published_linked_post} = Posts.publish_post(linked_post.id) assert {:ok, published_linked_post} = Posts.publish_post(linked_post.id)
media_source_reference = "/" <> Path.join(Path.dirname(media.file_path), media.original_name) media_source_reference = "/" <> Path.join(Path.dirname(media.file_path), media.original_name)
canonical_post_href = "/" <> String.trim_trailing(BDS.Generation.post_output_path(published_linked_post), "index.html")
canonical_post_href =
"/" <>
String.trim_trailing(BDS.Generation.post_output_path(published_linked_post), "index.html")
assert {:ok, post} = assert {:ok, post} =
Posts.create_post(%{ Posts.create_post(%{
@@ -190,14 +226,21 @@ defmodule BDS.PreviewTest do
assert missing_body =~ ~s(data-template="not-found") assert missing_body =~ ~s(data-template="not-found")
assert {:ok, {{_version, 404, _reason}, _headers, body}} = assert {:ok, {{_version, 404, _reason}, _headers, body}} =
:httpc.request(:get, {to_charlist("http://#{server.host}:#{server.port}/missing-page"), []}, [], body_format: :binary) :httpc.request(
:get,
{to_charlist("http://#{server.host}:#{server.port}/missing-page"), []},
[],
body_format: :binary
)
assert body =~ ~s(data-template="not-found") assert body =~ ~s(data-template="not-found")
assert :ok = BDS.Preview.stop_preview(project.id) assert :ok = BDS.Preview.stop_preview(project.id)
end end
test "start_preview serves generated and draft routes over real HTTP on localhost", %{project: project} do test "start_preview serves generated and draft routes over real HTTP on localhost", %{
project: project
} do
:inets.start() :inets.start()
assert {:ok, _metadata} = assert {:ok, _metadata} =
@@ -207,7 +250,8 @@ defmodule BDS.PreviewTest do
blog_languages: ["en"] blog_languages: ["en"]
}) })
assert {:ok, _} = Generation.write_generated_file(project.id, "index.html", "<html>http home</html>") assert {:ok, _} =
Generation.write_generated_file(project.id, "index.html", "<html>http home</html>")
assert {:ok, post} = assert {:ok, post} =
Posts.create_post(%{ Posts.create_post(%{
@@ -220,13 +264,26 @@ defmodule BDS.PreviewTest do
assert {:ok, server} = BDS.Preview.start_preview(project.id) assert {:ok, server} = BDS.Preview.start_preview(project.id)
assert {:ok, {{_version, 200, _reason}, headers, body}} = assert {:ok, {{_version, 200, _reason}, headers, body}} =
:httpc.request(:get, {to_charlist("http://#{server.host}:#{server.port}/"), []}, [], body_format: :binary) :httpc.request(:get, {to_charlist("http://#{server.host}:#{server.port}/"), []}, [],
body_format: :binary
)
assert body == "<html>http home</html>" assert body == "<html>http home</html>"
assert Enum.any?(headers, fn {name, value} -> String.downcase(to_string(name)) == "content-type" and to_string(value) =~ "text/html" end)
assert Enum.any?(headers, fn {name, value} ->
String.downcase(to_string(name)) == "content-type" and
to_string(value) =~ "text/html"
end)
assert {:ok, {{_version, 200, _reason}, _headers, draft_body}} = assert {:ok, {{_version, 200, _reason}, _headers, draft_body}} =
:httpc.request(:get, {to_charlist("http://#{server.host}:#{server.port}/draft/http-draft?post_id=#{post.id}"), []}, [], body_format: :binary) :httpc.request(
:get,
{to_charlist(
"http://#{server.host}:#{server.port}/draft/http-draft?post_id=#{post.id}"
), []},
[],
body_format: :binary
)
assert draft_body =~ "Draft over HTTP" assert draft_body =~ "Draft over HTTP"

View File

@@ -18,13 +18,16 @@ defmodule BDS.ProjectsTest do
%{temp_root: temp_root} %{temp_root: temp_root}
end end
test "create_project slugifies names, keeps new projects inactive, and deduplicates slugs", %{temp_root: temp_root} do test "create_project slugifies names, keeps new projects inactive, and deduplicates slugs", %{
temp_root: temp_root
} do
first_dir = Path.join(temp_root, "first") first_dir = Path.join(temp_root, "first")
second_dir = Path.join(temp_root, "second") second_dir = Path.join(temp_root, "second")
File.mkdir_p!(first_dir) File.mkdir_p!(first_dir)
File.mkdir_p!(second_dir) File.mkdir_p!(second_dir)
assert {:ok, first} = BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: first_dir}) assert {:ok, first} =
BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: first_dir})
assert first.name == "Föö Bär Blog" assert first.name == "Föö Bär Blog"
assert first.slug == "foo-bar-blog" assert first.slug == "foo-bar-blog"
@@ -33,16 +36,21 @@ defmodule BDS.ProjectsTest do
assert is_integer(first.created_at) assert is_integer(first.created_at)
assert is_integer(first.updated_at) assert is_integer(first.updated_at)
assert {:ok, second} = BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: second_dir}) assert {:ok, second} =
BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: second_dir})
assert second.slug == "foo-bar-blog-2" assert second.slug == "foo-bar-blog-2"
assert second.is_active == false assert second.is_active == false
end end
test "create_project installs starter templates into the project data directory", %{temp_root: temp_root} do test "create_project installs starter templates into the project data directory", %{
temp_root: temp_root
} do
temp_dir = Path.join(temp_root, "starter") temp_dir = Path.join(temp_root, "starter")
File.mkdir_p!(temp_dir) File.mkdir_p!(temp_dir)
assert {:ok, project} = BDS.Projects.create_project(%{name: "Starter Blog", data_path: temp_dir}) assert {:ok, project} =
BDS.Projects.create_project(%{name: "Starter Blog", data_path: temp_dir})
assert File.exists?(Path.join([temp_dir, "templates", "single-post.liquid"])) assert File.exists?(Path.join([temp_dir, "templates", "single-post.liquid"]))
assert File.exists?(Path.join([temp_dir, "templates", "post-list.liquid"])) assert File.exists?(Path.join([temp_dir, "templates", "post-list.liquid"]))
@@ -52,18 +60,25 @@ defmodule BDS.ProjectsTest do
assert File.exists?(Path.join([temp_dir, "templates", "macros", "gallery.liquid"])) assert File.exists?(Path.join([temp_dir, "templates", "macros", "gallery.liquid"]))
starter_slugs = starter_slugs =
Repo.all(from template in Template, where: template.project_id == ^project.id, select: {template.slug, template.kind}) Repo.all(
from template in Template,
where: template.project_id == ^project.id,
select: {template.slug, template.kind}
)
assert {"single-post", :post} in starter_slugs assert {"single-post", :post} in starter_slugs
assert {"post-list", :list} in starter_slugs assert {"post-list", :list} in starter_slugs
assert {"not-found", :not_found} in starter_slugs assert {"not-found", :not_found} in starter_slugs
end end
test "starter template installation is idempotent for existing top-level templates", %{temp_root: temp_root} do test "starter template installation is idempotent for existing top-level templates", %{
temp_root: temp_root
} do
temp_dir = Path.join(temp_root, "idempotent-starter") temp_dir = Path.join(temp_root, "idempotent-starter")
File.mkdir_p!(temp_dir) File.mkdir_p!(temp_dir)
assert {:ok, project} = BDS.Projects.create_project(%{name: "Starter Blog", data_path: temp_dir}) assert {:ok, project} =
BDS.Projects.create_project(%{name: "Starter Blog", data_path: temp_dir})
template_path = Path.join([temp_dir, "templates", "single-post.liquid"]) template_path = Path.join([temp_dir, "templates", "single-post.liquid"])
original_contents = File.read!(template_path) original_contents = File.read!(template_path)
@@ -75,11 +90,15 @@ defmodule BDS.ProjectsTest do
reinstalled_contents = File.read!(template_path) reinstalled_contents = File.read!(template_path)
assert reinstalled_contents == original_contents assert reinstalled_contents == original_contents
assert {:ok, %{fields: reinstalled_fields}} = BDS.Frontmatter.parse_document(reinstalled_contents) assert {:ok, %{fields: reinstalled_fields}} =
BDS.Frontmatter.parse_document(reinstalled_contents)
assert reinstalled_fields["id"] == original_fields["id"] assert reinstalled_fields["id"] == original_fields["id"]
end end
test "set_active_project clears the previous active project and activates the target", %{temp_root: temp_root} do test "set_active_project clears the previous active project and activates the target", %{
temp_root: temp_root
} do
first_dir = Path.join(temp_root, "active-first") first_dir = Path.join(temp_root, "active-first")
second_dir = Path.join(temp_root, "active-second") second_dir = Path.join(temp_root, "active-second")
File.mkdir_p!(first_dir) File.mkdir_p!(first_dir)

View File

@@ -3,7 +3,10 @@ defmodule BDS.PublishingTest do
setup do setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-publishing-#{System.unique_integer([:positive])}")
temp_dir =
Path.join(System.tmp_dir!(), "bds-publishing-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir) File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end) on_exit(fn -> File.rm_rf(temp_dir) end)
@@ -11,7 +14,10 @@ defmodule BDS.PublishingTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end end
test "upload_site creates a publish job, uploads all targets, and excludes media sidecars", %{project: project, temp_dir: temp_dir} do test "upload_site creates a publish job, uploads all targets, and excludes media sidecars", %{
project: project,
temp_dir: temp_dir
} do
test_pid = self() test_pid = self()
File.mkdir_p!(Path.join([temp_dir, "html"])) File.mkdir_p!(Path.join([temp_dir, "html"]))
@@ -25,7 +31,11 @@ defmodule BDS.PublishingTest do
File.write!(Path.join([temp_dir, "media", "asset.jpg.meta"]), "meta") File.write!(Path.join([temp_dir, "media", "asset.jpg.meta"]), "meta")
uploader = fn target, files, credentials -> uploader = fn target, files, credentials ->
send(test_pid, {:uploaded, target.kind, target.remote_dir, Enum.sort(files), credentials.ssh_mode}) send(
test_pid,
{:uploaded, target.kind, target.remote_dir, Enum.sort(files), credentials.ssh_mode}
)
:ok :ok
end end
@@ -46,7 +56,10 @@ defmodule BDS.PublishingTest do
assert_receive {:uploaded, :media, "/srv/blog/media", ["asset.jpg"], :rsync} assert_receive {:uploaded, :media, "/srv/blog/media", ["asset.jpg"], :rsync}
end end
test "upload_site runs rsync commands with SSH agent env and media exclude filters", %{project: project, temp_dir: temp_dir} do test "upload_site runs rsync commands with SSH agent env and media exclude filters", %{
project: project,
temp_dir: temp_dir
} do
test_pid = self() test_pid = self()
File.mkdir_p!(Path.join([temp_dir, "html"])) File.mkdir_p!(Path.join([temp_dir, "html"]))
@@ -78,17 +91,47 @@ defmodule BDS.PublishingTest do
assert wait_for_publish_job(job.id, &(&1.status == :completed)).status == :completed assert wait_for_publish_job(job.id, &(&1.status == :completed)).status == :completed
assert_receive {:command_run, "rsync", html_args, html_opts} assert_receive {:command_run, "rsync", html_args, html_opts}
assert html_args == ["--update", "--compress", "--verbose", "-e", "ssh", Path.join([temp_dir, "html"]) <> "/", "deploy@example.com:/srv/blog/"]
assert html_args == [
"--update",
"--compress",
"--verbose",
"-e",
"ssh",
Path.join([temp_dir, "html"]) <> "/",
"deploy@example.com:/srv/blog/"
]
assert html_opts[:env] == [{"SSH_AUTH_SOCK", "/tmp/test-agent.sock"}] assert html_opts[:env] == [{"SSH_AUTH_SOCK", "/tmp/test-agent.sock"}]
assert_receive {:command_run, "rsync", thumb_args, _thumb_opts} assert_receive {:command_run, "rsync", thumb_args, _thumb_opts}
assert thumb_args == ["--update", "--compress", "--verbose", "-e", "ssh", Path.join([temp_dir, "thumbnails"]) <> "/", "deploy@example.com:/srv/blog/thumbnails/"]
assert thumb_args == [
"--update",
"--compress",
"--verbose",
"-e",
"ssh",
Path.join([temp_dir, "thumbnails"]) <> "/",
"deploy@example.com:/srv/blog/thumbnails/"
]
assert_receive {:command_run, "rsync", media_args, _media_opts} assert_receive {:command_run, "rsync", media_args, _media_opts}
assert media_args == ["--update", "--compress", "--verbose", "--exclude=*.meta", "-e", "ssh", Path.join([temp_dir, "media"]) <> "/", "deploy@example.com:/srv/blog/media/"]
assert media_args == [
"--update",
"--compress",
"--verbose",
"--exclude=*.meta",
"-e",
"ssh",
Path.join([temp_dir, "media"]) <> "/",
"deploy@example.com:/srv/blog/media/"
]
end end
test "upload_site runs scp commands for each eligible file and fails when a command exits non-zero", %{project: project, temp_dir: temp_dir} do test "upload_site runs scp commands for each eligible file and fails when a command exits non-zero",
%{project: project, temp_dir: temp_dir} do
test_pid = self() test_pid = self()
html_index = Path.join([temp_dir, "html", "index.html"]) html_index = Path.join([temp_dir, "html", "index.html"])
html_entry = Path.join([temp_dir, "html", "posts", "entry.html"]) html_entry = Path.join([temp_dir, "html", "posts", "entry.html"])
@@ -129,14 +172,26 @@ defmodule BDS.PublishingTest do
failed_job = wait_for_publish_job(job.id, &(&1.status == :failed)) failed_job = wait_for_publish_job(job.id, &(&1.status == :failed))
assert failed_job.error =~ "thumbnail failure" assert failed_job.error =~ "thumbnail failure"
assert_receive {:command_run, "scp", ["-q", ^html_index, "deploy@example.com:/srv/blog/index.html"], opts_a} assert_receive {:command_run, "scp",
["-q", ^html_index, "deploy@example.com:/srv/blog/index.html"], opts_a}
assert opts_a[:env] == [{"SSH_AUTH_SOCK", "/tmp/test-agent.sock"}] assert opts_a[:env] == [{"SSH_AUTH_SOCK", "/tmp/test-agent.sock"}]
assert_receive {:command_run, "scp", ["-q", ^html_entry, "deploy@example.com:/srv/blog/posts/entry.html"], _opts_b}
assert_receive {:command_run, "scp", ["-q", ^thumb_path, "deploy@example.com:/srv/blog/thumbnails/thumb.jpg"], _opts_c} assert_receive {:command_run, "scp",
refute_receive {:command_run, "scp", ["-q", _, "deploy@example.com:/srv/blog/media/asset.jpg"], _opts_d} ["-q", ^html_entry, "deploy@example.com:/srv/blog/posts/entry.html"], _opts_b}
assert_receive {:command_run, "scp",
["-q", ^thumb_path, "deploy@example.com:/srv/blog/thumbnails/thumb.jpg"],
_opts_c}
refute_receive {:command_run, "scp",
["-q", _, "deploy@example.com:/srv/blog/media/asset.jpg"], _opts_d}
end end
test "upload_site marks the publish job failed when a target upload fails", %{project: project, temp_dir: temp_dir} do test "upload_site marks the publish job failed when a target upload fails", %{
project: project,
temp_dir: temp_dir
} do
File.mkdir_p!(Path.join([temp_dir, "html"])) File.mkdir_p!(Path.join([temp_dir, "html"]))
File.write!(Path.join([temp_dir, "html", "index.html"]), "<html />") File.write!(Path.join([temp_dir, "html", "index.html"]), "<html />")
File.mkdir_p!(Path.join([temp_dir, "thumbnails"])) File.mkdir_p!(Path.join([temp_dir, "thumbnails"]))
@@ -161,6 +216,75 @@ defmodule BDS.PublishingTest do
assert failed_job.error == "thumbnail failure" assert failed_job.error == "thumbnail failure"
end end
test "upload_site skips unchanged files for scp and only re-uploads files with newer mtimes", %{
project: project,
temp_dir: temp_dir
} do
test_pid = self()
html_index = Path.join([temp_dir, "html", "index.html"])
media_asset = Path.join([temp_dir, "media", "asset.jpg"])
File.mkdir_p!(Path.dirname(html_index))
File.write!(html_index, "<html />")
File.mkdir_p!(Path.join([temp_dir, "thumbnails"]))
File.write!(Path.join([temp_dir, "thumbnails", "thumb.jpg"]), "thumb")
File.mkdir_p!(Path.dirname(media_asset))
File.write!(media_asset, "asset")
runner = fn command, args, opts ->
send(test_pid, {:command_run, command, args, opts})
{"", 0}
end
credentials = %{
ssh_host: "example.com",
ssh_user: "deploy",
ssh_remote_path: "/srv/blog",
ssh_mode: :scp
}
assert {:ok, first_job} =
BDS.Publishing.upload_site(project.id, credentials,
command_runner: runner,
ssh_auth_sock: "/tmp/test-agent.sock"
)
assert wait_for_publish_job(first_job.id, &(&1.status == :completed)).status == :completed
first_uploads = collect_command_runs()
assert length(first_uploads) == 3
assert {:ok, second_job} =
BDS.Publishing.upload_site(project.id, credentials,
command_runner: runner,
ssh_auth_sock: "/tmp/test-agent.sock"
)
assert wait_for_publish_job(second_job.id, &(&1.status == :completed)).status == :completed
assert collect_command_runs() == []
:ok = File.touch(html_index, {{2099, 1, 1}, {0, 0, 0}})
assert {:ok, third_job} =
BDS.Publishing.upload_site(project.id, credentials,
command_runner: runner,
ssh_auth_sock: "/tmp/test-agent.sock"
)
assert wait_for_publish_job(third_job.id, &(&1.status == :completed)).status == :completed
assert [html_upload] = collect_command_runs()
assert elem(html_upload, 0) == "scp"
assert elem(html_upload, 1) == ["-q", html_index, "deploy@example.com:/srv/blog/index.html"]
end
defp collect_command_runs(acc \\ []) do
receive do
{:command_run, command, args, _opts} -> collect_command_runs([{command, args} | acc])
after
50 -> Enum.reverse(acc)
end
end
defp wait_for_publish_job(job_id, predicate, attempts \\ 100) defp wait_for_publish_job(job_id, predicate, attempts \\ 100)
defp wait_for_publish_job(job_id, predicate, attempts) when attempts > 0 do defp wait_for_publish_job(job_id, predicate, attempts) when attempts > 0 do

152
test/bds/rendering_test.exs Normal file
View File

@@ -0,0 +1,152 @@
defmodule BDS.RenderingTest do
use ExUnit.Case, async: false
import Ecto.Query
alias BDS.Rendering
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-rendering-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "Rendering", data_path: temp_dir})
%{project: project, temp_dir: temp_dir}
end
test "render_post_page exposes the spec post context and blog language links", %{
project: project
} do
assert {:ok, _metadata} =
BDS.Metadata.update_project_metadata(project.id, %{
main_language: "en",
blog_languages: ["en", "de"]
})
assert {:ok, template} =
BDS.Templates.create_template(%{
project_id: project.id,
title: "Render Post Context",
kind: :post,
content:
"{{ pico_stylesheet_href }}|{% for lang in blog_languages %}[{{ lang.code }}={{ lang.href }}:{{ lang.href_prefix }}]{% endfor %}|{{ post.author }}|{{ post.published_at }}|{{ post.created_at }}|{{ post.updated_at }}|{{ post.tags.size }}|{{ post.categories.size }}|{{ post.template_slug }}|{{ post.do_not_translate }}"
})
assert {:ok, published_template} = BDS.Templates.publish_template(template.id)
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Render Me",
content: "Body",
author: "Writer",
tags: ["alpha", "beta"],
categories: ["notes"],
language: "en",
template_slug: published_template.slug,
do_not_translate: true
})
assert {:ok, published_post} = BDS.Posts.publish_post(post.id)
assert {:ok, rendered} =
Rendering.render_post_page(project.id, published_template.slug, %{
id: published_post.id,
title: published_post.title,
content: published_post.content || "",
slug: published_post.slug,
language: "de",
excerpt: published_post.excerpt,
template_slug: published_post.template_slug
})
assert rendered =~ "/assets/pico.min.css"
assert rendered =~ "[en=/:]"
assert rendered =~ "[de=/de/:/de]"
assert rendered =~ "|Writer|"
assert rendered =~
"|#{published_post.published_at}|#{published_post.created_at}|#{published_post.updated_at}|"
assert rendered =~ "|2|1|#{published_template.slug}|true"
end
test "render_list_page exposes pagination and render_not_found_page localizes default copy", %{
project: project
} do
assert {:ok, _metadata} =
BDS.Metadata.update_project_metadata(project.id, %{
main_language: "en",
blog_languages: ["en", "de"]
})
assert {:ok, list_template} =
BDS.Templates.create_template(%{
project_id: project.id,
title: "Render List Context",
kind: :list,
content:
"{{ current_page }}|{{ total_pages }}|{{ total_items }}|{{ items_per_page }}|{{ has_prev_page }}|{{ prev_page_href }}|{{ has_next_page }}|{{ next_page_href }}"
})
assert {:ok, not_found_template} =
BDS.Templates.create_template(%{
project_id: project.id,
title: "Render Not Found Context",
kind: :not_found,
content: "{{ not_found_message }}|{{ not_found_back_label }}"
})
assert {:ok, published_list_template} = BDS.Templates.publish_template(list_template.id)
assert {:ok, _published_not_found_template} =
BDS.Templates.publish_template(not_found_template.id)
BDS.Repo.update_all(
from(template in BDS.Templates.Template,
where:
template.project_id == ^project.id and template.kind == :list and
template.id != ^published_list_template.id
),
set: [enabled: false]
)
BDS.Repo.update_all(
from(template in BDS.Templates.Template,
where:
template.project_id == ^project.id and template.kind == :not_found and
template.slug != ^not_found_template.slug
),
set: [enabled: false]
)
assert {:ok, rendered_list} =
Rendering.render_list_page(project.id, %{
language: "en",
page_title: "Archive",
posts: [],
archive_context: %{kind: "tag", name: "elixir"},
pagination: %{
current_page: 2,
total_pages: 5,
total_items: 12,
items_per_page: 3,
has_prev_page: true,
prev_page_href: "/page/1/",
has_next_page: true,
next_page_href: "/page/3/"
}
})
assert rendered_list == "2|5|12|3|true|/page/1/|true|/page/3/"
assert {:ok, rendered_not_found} =
Rendering.render_not_found_page(project.id, %{language: "de"})
assert rendered_not_found ==
"Die angeforderte Vorschauseite konnte nicht gefunden werden.|Zurück zur Vorschau-Startseite"
assert published_list_template.kind == :list
end
end

View File

@@ -180,7 +180,13 @@ defmodule BDS.Repo.SchemaMigrationTest do
"ai_model_modalities" => ["provider", "model_id", "direction", "modality"], "ai_model_modalities" => ["provider", "model_id", "direction", "modality"],
"ai_catalog_meta" => ["key", "value"], "ai_catalog_meta" => ["key", "value"],
"embedding_keys" => ["label", "post_id", "project_id", "content_hash", "vector"], "embedding_keys" => ["label", "post_id", "project_id", "content_hash", "vector"],
"dismissed_duplicate_pairs" => ["id", "project_id", "post_id_a", "post_id_b", "dismissed_at"], "dismissed_duplicate_pairs" => [
"id",
"project_id",
"post_id_a",
"post_id_b",
"dismissed_at"
],
"import_definitions" => [ "import_definitions" => [
"id", "id",
"project_id", "project_id",
@@ -229,8 +235,16 @@ defmodule BDS.Repo.SchemaMigrationTest do
assert unique_index_columns("tags", "tags_project_name_idx") == ["project_id", "name"] assert unique_index_columns("tags", "tags_project_name_idx") == ["project_id", "name"]
assert unique_index_columns("scripts", "scripts_project_slug_idx") == ["project_id", "slug"] assert unique_index_columns("scripts", "scripts_project_slug_idx") == ["project_id", "slug"]
assert unique_index_columns("templates", "templates_project_slug_idx") == ["project_id", "slug"]
assert unique_index_columns("post_media", "post_media_post_media_idx") == ["post_id", "media_id"] assert unique_index_columns("templates", "templates_project_slug_idx") == [
"project_id",
"slug"
]
assert unique_index_columns("post_media", "post_media_post_media_idx") == [
"post_id",
"media_id"
]
assert unique_index_columns( assert unique_index_columns(
"generated_file_hashes", "generated_file_hashes",
@@ -345,7 +359,9 @@ defmodule BDS.Repo.SchemaMigrationTest do
defp unique_index_columns(table, index_name) do defp unique_index_columns(table, index_name) do
indexes = query_rows("PRAGMA index_list(#{table})") indexes = query_rows("PRAGMA index_list(#{table})")
assert Enum.any?(indexes, fn [_seq, name, unique | _rest] -> name == index_name and unique == 1 end), assert Enum.any?(indexes, fn [_seq, name, unique | _rest] ->
name == index_name and unique == 1
end),
"expected unique index #{index_name} on #{table}" "expected unique index #{index_name} on #{table}"
query_rows("PRAGMA index_info(#{index_name})") query_rows("PRAGMA index_info(#{index_name})")

View File

@@ -58,7 +58,13 @@ defmodule BDS.Scripting.JobTest do
assert {:ok, job} = BDS.Scripting.start_job("irrelevant", "main") assert {:ok, job} = BDS.Scripting.start_job("irrelevant", "main")
assert job.status in [:queued, :running] assert job.status in [:queued, :running]
running_job = wait_for_job(job.id, &(&1.status == :running and &1.progress == %{"phase" => "started", "current" => 1, "total" => 2})) running_job =
wait_for_job(
job.id,
&(&1.status == :running and
&1.progress == %{"phase" => "started", "current" => 1, "total" => 2})
)
assert running_job.started_at != nil assert running_job.started_at != nil
completed_job = wait_for_job(job.id, &(&1.status == :completed)) completed_job = wait_for_job(job.id, &(&1.status == :completed))

View File

@@ -42,7 +42,10 @@ defmodule BDS.ScriptsTest do
assert macro_script.slug == "render-card" assert macro_script.slug == "render-card"
end end
test "publish_script writes a lua file with frontmatter and clears draft content", %{project: project, temp_dir: temp_dir} do test "publish_script writes a lua file with frontmatter and clears draft content", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, script} = assert {:ok, script} =
BDS.Scripts.create_script(%{ BDS.Scripts.create_script(%{
project_id: project.id, project_id: project.id,
@@ -73,7 +76,9 @@ defmodule BDS.ScriptsTest do
assert contents =~ "\n---\nfunction main() return 'ok' end\n" assert contents =~ "\n---\nfunction main() return 'ok' end\n"
end end
test "update_script bumps version and reopens a published script when content changes", %{project: project} do test "update_script bumps version and reopens a published script when content changes", %{
project: project
} do
assert {:ok, script} = assert {:ok, script} =
BDS.Scripts.create_script(%{ BDS.Scripts.create_script(%{
project_id: project.id, project_id: project.id,
@@ -99,7 +104,10 @@ defmodule BDS.ScriptsTest do
assert updated.updated_at >= published.updated_at assert updated.updated_at >= published.updated_at
end end
test "delete_script removes the published file and database row", %{project: project, temp_dir: temp_dir} do test "delete_script removes the published file and database row", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, script} = assert {:ok, script} =
BDS.Scripts.create_script(%{ BDS.Scripts.create_script(%{
project_id: project.id, project_id: project.id,
@@ -116,7 +124,10 @@ defmodule BDS.ScriptsTest do
refute File.exists?(Path.join(temp_dir, published.file_path)) refute File.exists?(Path.join(temp_dir, published.file_path))
end end
test "rebuild_scripts_from_files recreates published scripts from disk", %{project: project, temp_dir: temp_dir} do test "rebuild_scripts_from_files recreates published scripts from disk", %{
project: project,
temp_dir: temp_dir
} do
script_dir = Path.join(temp_dir, "scripts") script_dir = Path.join(temp_dir, "scripts")
File.mkdir_p!(script_dir) File.mkdir_p!(script_dir)

View File

@@ -13,7 +13,8 @@ defmodule BDS.SearchTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end end
test "search_posts indexes writes, supports filters and pagination, and removes deleted posts", %{project: project} do test "search_posts indexes writes, supports filters and pagination, and removes deleted posts",
%{project: project} do
assert {:ok, draft_post} = assert {:ok, draft_post} =
BDS.Posts.create_post(%{ BDS.Posts.create_post(%{
project_id: project.id, project_id: project.id,
@@ -54,14 +55,25 @@ defmodule BDS.SearchTest do
assert results.limit == 50 assert results.limit == 50
assert Enum.map(results.posts, & &1.id) == [draft_post.id] assert Enum.map(results.posts, & &1.id) == [draft_post.id]
assert {:ok, tag_results} = BDS.Search.search_posts(project.id, "galaxy", %{tags: ["space"], categories: ["astronomy"]}) assert {:ok, tag_results} =
assert tag_results.total == 2 BDS.Search.search_posts(project.id, "galaxy", %{
assert Enum.sort(Enum.map(tag_results.posts, & &1.id)) == Enum.sort([draft_post.id, published_post.id]) tags: ["space"],
categories: ["astronomy"]
})
assert tag_results.total == 2
assert Enum.sort(Enum.map(tag_results.posts, & &1.id)) ==
Enum.sort([draft_post.id, published_post.id])
assert {:ok, language_results} =
BDS.Search.search_posts(project.id, "galaxy", %{language: "de"})
assert {:ok, language_results} = BDS.Search.search_posts(project.id, "galaxy", %{language: "de"})
assert Enum.map(language_results.posts, & &1.id) == [published_post.id] assert Enum.map(language_results.posts, & &1.id) == [published_post.id]
assert {:ok, paged_results} = BDS.Search.search_posts(project.id, "galaxy", %{limit: 1, offset: 1}) assert {:ok, paged_results} =
BDS.Search.search_posts(project.id, "galaxy", %{limit: 1, offset: 1})
assert paged_results.total == 3 assert paged_results.total == 3
assert paged_results.offset == 1 assert paged_results.offset == 1
assert paged_results.limit == 1 assert paged_results.limit == 1
@@ -120,12 +132,17 @@ defmodule BDS.SearchTest do
assert Enum.map(results.posts, & &1.id) == [post.id] assert Enum.map(results.posts, & &1.id) == [post.id]
assert {:ok, missing_translation_results} = assert {:ok, missing_translation_results} =
BDS.Search.search_posts(project.id, "Canonical", %{missing_translation_language: "de"}) BDS.Search.search_posts(project.id, "Canonical", %{
missing_translation_language: "de"
})
assert Enum.map(missing_translation_results.posts, & &1.id) == [post.id] assert Enum.map(missing_translation_results.posts, & &1.id) == [post.id]
end end
test "search_media indexes metadata, includes translation text, and removes deleted media", %{project: project, temp_dir: temp_dir} do test "search_media indexes metadata, includes translation text, and removes deleted media", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "hero.txt") source_path = Path.join(temp_dir, "hero.txt")
File.write!(source_path, "hero") File.write!(source_path, "hero")
@@ -164,7 +181,10 @@ defmodule BDS.SearchTest do
assert deleted_results.total == 0 assert deleted_results.total == 0
end end
test "rebuild operations repopulate the search index from filesystem truth", %{project: project, temp_dir: temp_dir} do test "rebuild operations repopulate the search index from filesystem truth", %{
project: project,
temp_dir: temp_dir
} do
posts_dir = Path.join([temp_dir, "posts", "2026", "04"]) posts_dir = Path.join([temp_dir, "posts", "2026", "04"])
File.mkdir_p!(posts_dir) File.mkdir_p!(posts_dir)
@@ -225,7 +245,9 @@ defmodule BDS.SearchTest do
assert Enum.map(media_results.media, & &1.id) == ["search-media-from-file"] assert Enum.map(media_results.media, & &1.id) == ["search-media-from-file"]
end end
test "search_posts applies language-aware stemming to indexed and query text", %{project: project} do test "search_posts applies language-aware stemming to indexed and query text", %{
project: project
} do
assert {:ok, german_post} = assert {:ok, german_post} =
BDS.Posts.create_post(%{ BDS.Posts.create_post(%{
project_id: project.id, project_id: project.id,

View File

@@ -14,8 +14,13 @@ defmodule BDS.TagsTest do
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
end end
test "create_tag persists the row and rewrites meta/tags.json sorted by name", %{project: project, temp_dir: temp_dir} do test "create_tag persists the row and rewrites meta/tags.json sorted by name", %{
assert {:ok, zebra} = BDS.Tags.create_tag(%{project_id: project.id, name: "Zebra", color: "#000000"}) project: project,
temp_dir: temp_dir
} do
assert {:ok, zebra} =
BDS.Tags.create_tag(%{project_id: project.id, name: "Zebra", color: "#000000"})
assert {:ok, alpha} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) assert {:ok, alpha} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"})
assert zebra.name == "Zebra" assert zebra.name == "Zebra"
@@ -35,7 +40,10 @@ defmodule BDS.TagsTest do
assert "has already been taken" in errors_on(changeset).name assert "has already been taken" in errors_on(changeset).name
end end
test "update_tag rewrites the tag row and meta/tags.json", %{project: project, temp_dir: temp_dir} do test "update_tag rewrites the tag row and meta/tags.json", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) assert {:ok, tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"})
assert {:ok, updated} = assert {:ok, updated} =
@@ -51,11 +59,16 @@ defmodule BDS.TagsTest do
tags_path = Path.join([temp_dir, "meta", "tags.json"]) tags_path = Path.join([temp_dir, "meta", "tags.json"])
assert %{"tags" => [%{"name" => "Alpha", "color" => "#112233", "post_template_slug" => "article"}]} = assert %{
"tags" => [
%{"name" => "Alpha", "color" => "#112233", "post_template_slug" => "article"}
]
} =
Jason.decode!(File.read!(tags_path)) Jason.decode!(File.read!(tags_path))
end end
test "rename_tag updates post tag arrays, rewrites published post files, and refreshes tags.json", %{project: project, temp_dir: temp_dir} do test "rename_tag updates post tag arrays, rewrites published post files, and refreshes tags.json",
%{project: project, temp_dir: temp_dir} do
assert {:ok, tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) assert {:ok, tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"})
assert {:ok, post} = assert {:ok, post} =
@@ -83,7 +96,8 @@ defmodule BDS.TagsTest do
assert %{"tags" => [%{"name" => "Beta"}]} = Jason.decode!(File.read!(tags_path)) assert %{"tags" => [%{"name" => "Beta"}]} = Jason.decode!(File.read!(tags_path))
end end
test "merge_tags moves source tags onto the target, deduplicates post tags, deletes sources, and refreshes tags.json", %{project: project, temp_dir: temp_dir} do test "merge_tags moves source tags onto the target, deduplicates post tags, deletes sources, and refreshes tags.json",
%{project: project, temp_dir: temp_dir} do
assert {:ok, source_a} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) assert {:ok, source_a} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"})
assert {:ok, source_b} = BDS.Tags.create_tag(%{project_id: project.id, name: "Beta"}) assert {:ok, source_b} = BDS.Tags.create_tag(%{project_id: project.id, name: "Beta"})
assert {:ok, target} = BDS.Tags.create_tag(%{project_id: project.id, name: "Gamma"}) assert {:ok, target} = BDS.Tags.create_tag(%{project_id: project.id, name: "Gamma"})
@@ -115,7 +129,8 @@ defmodule BDS.TagsTest do
assert %{"tags" => [%{"name" => "Gamma"}]} = Jason.decode!(File.read!(tags_path)) assert %{"tags" => [%{"name" => "Gamma"}]} = Jason.decode!(File.read!(tags_path))
end end
test "delete_tag removes the tag from posts, rewrites published files, deletes the row, and refreshes tags.json", %{project: project, temp_dir: temp_dir} do test "delete_tag removes the tag from posts, rewrites published files, deletes the row, and refreshes tags.json",
%{project: project, temp_dir: temp_dir} do
assert {:ok, doomed} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) assert {:ok, doomed} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"})
assert {:ok, _other} = BDS.Tags.create_tag(%{project_id: project.id, name: "Beta"}) assert {:ok, _other} = BDS.Tags.create_tag(%{project_id: project.id, name: "Beta"})
@@ -145,7 +160,8 @@ defmodule BDS.TagsTest do
assert %{"tags" => [%{"name" => "Beta"}]} = Jason.decode!(File.read!(tags_path)) assert %{"tags" => [%{"name" => "Beta"}]} = Jason.decode!(File.read!(tags_path))
end end
test "sync_tags_from_posts creates missing tags from post tag arrays and refreshes tags.json", %{project: project, temp_dir: temp_dir} do test "sync_tags_from_posts creates missing tags from post tag arrays and refreshes tags.json",
%{project: project, temp_dir: temp_dir} do
assert {:ok, existing} = assert {:ok, existing} =
BDS.Tags.create_tag(%{ BDS.Tags.create_tag(%{
project_id: project.id, project_id: project.id,
@@ -183,7 +199,11 @@ defmodule BDS.TagsTest do
assert %{ assert %{
"tags" => [ "tags" => [
%{"name" => "Another"}, %{"name" => "Another"},
%{"name" => "Existing", "color" => "#112233", "post_template_slug" => "feature-view"}, %{
"name" => "Existing",
"color" => "#112233",
"post_template_slug" => "feature-view"
},
%{"name" => "Missing"} %{"name" => "Missing"}
] ]
} = Jason.decode!(File.read!(tags_path)) } = Jason.decode!(File.read!(tags_path))

View File

@@ -94,7 +94,11 @@ defmodule BDS.TasksTest do
end end
test "external tasks are registered as running and can report progress and complete" do test "external tasks are registered as running and can report progress and complete" do
assert {:ok, task} = BDS.Tasks.register_external_task("preview build", %{group_id: "generation", group_name: "Generation"}) assert {:ok, task} =
BDS.Tasks.register_external_task("preview build", %{
group_id: "generation",
group_name: "Generation"
})
assert task.status == :running assert task.status == :running
assert task.group_id == "generation" assert task.group_id == "generation"
@@ -106,7 +110,9 @@ defmodule BDS.TasksTest do
assert progressed.status == :running assert progressed.status == :running
assert :ok = BDS.Tasks.complete_task(task.id) assert :ok = BDS.Tasks.complete_task(task.id)
assert wait_for_task(task.id, &(&1.status == :completed and &1.progress == 1.0)).status == :completed
assert wait_for_task(task.id, &(&1.status == :completed and &1.progress == 1.0)).status ==
:completed
end end
defp receive_started do defp receive_started do

View File

@@ -33,12 +33,20 @@ defmodule BDS.TemplatesTest do
assert template.content == "<article>{{ content }}</article>" assert template.content == "<article>{{ content }}</article>"
assert {:ok, duplicate} = assert {:ok, duplicate} =
BDS.Templates.create_template(%{project_id: project.id, title: "Article View", kind: :post, content: "x"}) BDS.Templates.create_template(%{
project_id: project.id,
title: "Article View",
kind: :post,
content: "x"
})
assert duplicate.slug == "article-view-2" assert duplicate.slug == "article-view-2"
end end
test "publish_template writes a liquid file with frontmatter and clears draft content", %{project: project, temp_dir: temp_dir} do test "publish_template writes a liquid file with frontmatter and clears draft content", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, template} = assert {:ok, template} =
BDS.Templates.create_template(%{ BDS.Templates.create_template(%{
project_id: project.id, project_id: project.id,
@@ -68,7 +76,9 @@ defmodule BDS.TemplatesTest do
assert contents =~ "\n---\n<section>{{ page_title }}</section>\n" assert contents =~ "\n---\n<section>{{ page_title }}</section>\n"
end end
test "update_template bumps version and reopens a published template when content changes", %{project: project} do test "update_template bumps version and reopens a published template when content changes", %{
project: project
} do
assert {:ok, template} = assert {:ok, template} =
BDS.Templates.create_template(%{ BDS.Templates.create_template(%{
project_id: project.id, project_id: project.id,
@@ -94,7 +104,8 @@ defmodule BDS.TemplatesTest do
assert updated.updated_at >= published.updated_at assert updated.updated_at >= published.updated_at
end end
test "delete_template refuses referenced templates unless forced, then clears references and deletes the file", %{project: project, temp_dir: temp_dir} do test "delete_template refuses referenced templates unless forced, then clears references and deletes the file",
%{project: project, temp_dir: temp_dir} do
assert {:ok, template} = assert {:ok, template} =
BDS.Templates.create_template(%{ BDS.Templates.create_template(%{
project_id: project.id, project_id: project.id,
@@ -122,7 +133,8 @@ defmodule BDS.TemplatesTest do
post_template_slug: published.slug post_template_slug: published.slug
}) })
assert {:error, {:has_references, %{posts: 1, tags: 1}}} = BDS.Templates.delete_template(published.id) assert {:error, {:has_references, %{posts: 1, tags: 1}}} =
BDS.Templates.delete_template(published.id)
assert {:ok, :deleted} = BDS.Templates.delete_template(published.id, force: true) assert {:ok, :deleted} = BDS.Templates.delete_template(published.id, force: true)
@@ -143,7 +155,8 @@ defmodule BDS.TemplatesTest do
assert %{"tags" => [%{"name" => "Feature"}]} = Jason.decode!(File.read!(tags_path)) assert %{"tags" => [%{"name" => "Feature"}]} = Jason.decode!(File.read!(tags_path))
end end
test "update_template cascades slug changes to posts and tags and renames the published file", %{project: project, temp_dir: temp_dir} do test "update_template cascades slug changes to posts and tags and renames the published file",
%{project: project, temp_dir: temp_dir} do
assert {:ok, template} = assert {:ok, template} =
BDS.Templates.create_template(%{ BDS.Templates.create_template(%{
project_id: project.id, project_id: project.id,
@@ -198,11 +211,15 @@ defmodule BDS.TemplatesTest do
assert post_contents =~ "\n---\nBody\n" assert post_contents =~ "\n---\nBody\n"
tags_path = Path.join([temp_dir, "meta", "tags.json"]) tags_path = Path.join([temp_dir, "meta", "tags.json"])
assert %{"tags" => [%{"name" => "Feature", "post_template_slug" => "feature-view"}]} = assert %{"tags" => [%{"name" => "Feature", "post_template_slug" => "feature-view"}]} =
Jason.decode!(File.read!(tags_path)) Jason.decode!(File.read!(tags_path))
end end
test "rebuild_templates_from_files recreates published templates from disk", %{project: project, temp_dir: temp_dir} do test "rebuild_templates_from_files recreates published templates from disk", %{
project: project,
temp_dir: temp_dir
} do
template_dir = Path.join(temp_dir, "templates") template_dir = Path.join(temp_dir, "templates")
File.mkdir_p!(template_dir) File.mkdir_p!(template_dir)