feat: more implementations of partial code and cleanup

This commit is contained in:
2026-04-24 10:39:14 +02:00
parent a3f2c4a5f7
commit f857e739f6
13 changed files with 373 additions and 69 deletions

View File

@@ -258,7 +258,6 @@ defmodule BDS.Embeddings do
{:ok, suggestions} {:ok, suggestions}
else else
{:error, :not_found} -> {:ok, []} {:error, :not_found} -> {:ok, []}
{:disabled, _project_id} -> {:ok, []}
end end
end end
@@ -411,7 +410,6 @@ defmodule BDS.Embeddings do
defp enabled_for_project?(project_id) do defp enabled_for_project?(project_id) do
case Metadata.get_project_metadata(project_id) do case Metadata.get_project_metadata(project_id) do
{:ok, metadata} -> metadata.semantic_similarity_enabled == true {:ok, metadata} -> metadata.semantic_similarity_enabled == true
_other -> false
end end
end end
@@ -453,7 +451,10 @@ defmodule BDS.Embeddings do
end end
end end
defp compose_embedding_source(title, content), do: "#{title || ""}\n\n#{content || ""}" defp compose_embedding_source(title, content), do: string_or_empty(title) <> "\n\n" <> string_or_empty(content)
defp string_or_empty(nil), do: ""
defp string_or_empty(value) when is_binary(value), do: value
defp post_content_hash(%Post{} = post) do defp post_content_hash(%Post{} = post) do
body = resolve_post_body(post) body = resolve_post_body(post)

View File

@@ -50,6 +50,102 @@ defmodule BDS.Generation do
end end
end end
def validate_site(project_id, sections \\ @core_sections)
def validate_site(project_id, sections) when is_binary(project_id) and is_list(sections) do
with {:ok, plan} <- plan_generation(project_id, sections) do
expected_outputs = build_outputs(plan)
expected_paths = MapSet.new(Enum.map(expected_outputs, &elem(&1, 0)))
expected_hashes = Map.new(expected_outputs, fn {relative_path, content} -> {relative_path, sha256(content)} end)
actual_files = disk_generated_files(project_id)
actual_paths = MapSet.new(Map.keys(actual_files))
missing_pages =
expected_paths
|> MapSet.difference(actual_paths)
|> MapSet.to_list()
|> Enum.sort()
extra_pages =
actual_paths
|> MapSet.difference(expected_paths)
|> MapSet.to_list()
|> Enum.sort()
stale_pages =
expected_hashes
|> Enum.filter(fn {relative_path, expected_hash} ->
case actual_files do
%{^relative_path => actual_hash} -> actual_hash != expected_hash
_other -> false
end
end)
|> Enum.map(&elem(&1, 0))
|> Enum.sort()
{:ok,
%{
missing_pages: missing_pages,
extra_pages: extra_pages,
stale_pages: stale_pages,
sections: affected_sections(missing_pages ++ extra_pages ++ stale_pages)
}}
end
end
def apply_validation(project_id, sections) when is_binary(project_id) and is_list(sections) do
with {:ok, plan} <- plan_generation(project_id, sections) do
expected_outputs = build_outputs(plan)
expected_paths = MapSet.new(Enum.map(expected_outputs, &elem(&1, 0)))
actual_files = disk_generated_files(project_id)
project = Projects.get_project!(project_id)
now = Persistence.now_ms()
Enum.each(expected_outputs, fn {relative_path, content} ->
expected_hash = sha256(content)
case actual_files do
%{^relative_path => ^expected_hash} ->
:ok
_other ->
:ok = Persistence.atomic_write(output_path(project, relative_path), content)
%GeneratedFileHash{}
|> GeneratedFileHash.changeset(%{
project_id: project_id,
relative_path: relative_path,
content_hash: expected_hash,
updated_at: now
})
|> Repo.insert!(
on_conflict: [set: [content_hash: expected_hash, updated_at: now]],
conflict_target: [:project_id, :relative_path]
)
end
end)
disk_generated_files(project_id)
|> Map.keys()
|> Enum.filter(fn relative_path ->
path_section(relative_path) in plan.sections and not MapSet.member?(expected_paths, relative_path)
end)
|> Enum.each(fn relative_path ->
_ = File.rm(output_path(project, relative_path))
Repo.delete_all(
from generated_file in GeneratedFileHash,
where:
generated_file.project_id == ^project_id and
generated_file.relative_path == ^relative_path
)
end)
{:ok, generated_files} = list_generated_files(project_id)
{:ok, %{sections: plan.sections, generated_files: generated_files}}
end
end
def post_output_path(%Post{} = post), do: post_output_path(post, nil) def post_output_path(%Post{} = post), do: post_output_path(post, nil)
def post_output_path(%Post{} = post, language) do def post_output_path(%Post{} = post, language) do
@@ -166,6 +262,64 @@ defmodule BDS.Generation do
core_outputs ++ single_outputs ++ archive_outputs ++ sitemap core_outputs ++ single_outputs ++ archive_outputs ++ sitemap
end end
defp disk_generated_files(project_id) do
project = Projects.get_project!(project_id)
html_root = output_path(project, "")
case File.ls(html_root) do
{:ok, _entries} ->
html_root
|> Path.join("**/*")
|> Path.wildcard(match_dot: false)
|> Enum.filter(&File.regular?/1)
|> Enum.map(fn path ->
relative_path = Path.relative_to(path, html_root)
{relative_path,
path
|> File.read!()
|> sha256()}
end)
|> Map.new()
{:error, :enoent} ->
%{}
end
end
defp affected_sections(paths) do
paths
|> Enum.map(&path_section/1)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> Enum.sort()
end
defp path_section(relative_path) do
segments = String.split(relative_path, "/", trim: true)
case strip_language_prefix(segments) do
["404.html"] -> :core
["index.html"] -> :core
["sitemap.xml"] -> :core
["feed.xml"] -> :core
["atom.xml"] -> :core
["calendar.json"] -> :core
["pagefind" | _rest] -> :core
[year, month, day, _slug, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 and byte_size(day) == 2 -> :single
["category" | _rest] -> :category
["tag" | _rest] -> :tag
[year, "index.html"] when byte_size(year) == 4 -> :date
[year, month, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 -> :date
_other -> :core
end
end
defp strip_language_prefix([language | rest]) when language in ["en", "de", "fr", "it", "es"],
do: rest
defp strip_language_prefix(segments), do: segments
defp build_archive_outputs(plan, published_posts) do defp build_archive_outputs(plan, published_posts) do
languages = plan.blog_languages languages = plan.blog_languages
@@ -692,17 +846,6 @@ defmodule BDS.Generation do
end end
end end
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, %{ case Rendering.render_not_found_page(project_id, %{
@@ -714,8 +857,6 @@ defmodule BDS.Generation do
end end
end end
defp render_not_found_output(_plan, language), do: render_not_found_page(language)
defp language_prefix(language, main_language) when language == main_language, do: "" defp language_prefix(language, main_language) when language == main_language, 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}"

View File

@@ -76,10 +76,6 @@ defmodule BDS.Persistence do
{:error, _reason} = error -> {:error, _reason} = error ->
_ = File.rm(temp_path) _ = File.rm(temp_path)
error error
error ->
_ = File.rm(temp_path)
error
end end
end end
end end

View File

@@ -409,7 +409,7 @@ defmodule BDS.Posts do
end end
defp unique_slug(project_id, base_slug) do defp unique_slug(project_id, base_slug) do
normalized = if base_slug in [nil, ""], do: "untitled", else: base_slug normalized = if base_slug == "", do: "untitled", else: base_slug
if slug_available?(project_id, normalized) do if slug_available?(project_id, normalized) do
normalized normalized
@@ -486,7 +486,7 @@ defmodule BDS.Posts do
{:tags, post.tags || []}, {:tags, post.tags || []},
{:categories, post.categories || []} {:categories, post.categories || []}
], ],
post.content || "" post.content
) )
end end
@@ -671,7 +671,7 @@ defmodule BDS.Posts do
{:updated_at, translation.updated_at}, {:updated_at, translation.updated_at},
{:published_at, published_at} {:published_at, published_at}
], ],
translation.content || "" translation.content
) )
end end

View File

@@ -28,17 +28,19 @@ defmodule BDS.Preview do
end end
def request(project_id, request_path) when is_binary(project_id) and is_binary(request_path) do def request(project_id, request_path) when is_binary(project_id) and is_binary(request_path) do
GenServer.call(__MODULE__, {:request, project_id, request_path}) {path, query_params} = split_request_path(request_path)
GenServer.call(__MODULE__, {:request, project_id, path, query_params})
end end
def preview_draft(project_id, request_path, post_id) def preview_draft(project_id, request_path, post_id)
when is_binary(project_id) and is_binary(request_path) and is_binary(post_id) do when is_binary(project_id) and is_binary(request_path) and is_binary(post_id) do
post = Posts.get_post!(post_id) post = Posts.get_post!(post_id)
{_path, query_params} = split_request_path(request_path)
GenServer.call(__MODULE__, { GenServer.call(__MODULE__, {
:preview_draft, :preview_draft,
project_id, project_id,
request_path, query_params,
%{ %{
id: post.id, id: post.id,
title: post.title, title: post.title,
@@ -97,22 +99,23 @@ defmodule BDS.Preview do
{:reply, :ok, next_state} {:reply, :ok, next_state}
end end
def handle_call({:request, project_id, request_path}, _from, state) do def handle_call({:request, project_id, request_path, query_params}, _from, state) do
with :ok <- ensure_running(state.current, project_id), with :ok <- ensure_running(state.current, project_id),
{:ok, response} <- resolve_request(state.current, request_path) do {:ok, response} <- resolve_request(state.current, request_path, query_params) do
{:reply, {:ok, response}, state} {:reply, {:ok, response}, state}
else else
{:error, reason} -> {:reply, {:error, reason}, state} {:error, reason} -> {:reply, {:error, reason}, state}
end end
end end
def handle_call({:preview_draft, project_id, _request_path, post}, _from, state) do def handle_call({:preview_draft, project_id, query_params, post}, _from, state) do
with :ok <- ensure_running(state.current, project_id) do with :ok <- ensure_running(state.current, project_id) do
body = body =
case Rendering.render_post_page(project_id, Map.get(post, :template_slug), post) do case Rendering.render_post_page(project_id, Map.get(post, :template_slug), post) do
{:ok, rendered} -> rendered {:ok, rendered} -> rendered
{:error, _reason} -> render_draft(post) {:error, _reason} -> render_draft(post)
end end
|> apply_preview_overrides(query_params)
response = %{ response = %{
content_type: "text/html", content_type: "text/html",
@@ -132,13 +135,13 @@ defmodule BDS.Preview do
case query_params["post_id"] do case query_params["post_id"] do
post_id when is_binary(post_id) -> post_id when is_binary(post_id) ->
if String.starts_with?(request_path, "/draft/") do if String.starts_with?(request_path, "/draft/") do
resolve_draft_request(project_id, post_id) resolve_draft_request(project_id, post_id, query_params)
else else
resolve_request(state.current, request_path) resolve_request(state.current, request_path, query_params)
end end
_other -> _other ->
resolve_request(state.current, request_path) resolve_request(state.current, request_path, query_params)
end end
end end
@@ -148,7 +151,7 @@ defmodule BDS.Preview do
defp ensure_running(%{project_id: project_id, is_running: true}, project_id), do: :ok defp ensure_running(%{project_id: project_id, is_running: true}, project_id), do: :ok
defp ensure_running(_server, _project_id), do: {:error, :not_running} defp ensure_running(_server, _project_id), do: {:error, :not_running}
defp resolve_request(server, request_path) do defp resolve_request(server, request_path, query_params) do
with {:ok, relative_path, kind} <- route_request(request_path) do with {:ok, relative_path, kind} <- route_request(request_path) do
full_path = full_path =
case kind do case kind do
@@ -162,14 +165,15 @@ defmodule BDS.Preview do
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, query_params)
{:ok, response} -> {:ok, apply_response_overrides(response, query_params)}
other -> other other -> other
end end
end end
end end
end end
defp resolve_draft_request(project_id, post_id) do defp resolve_draft_request(project_id, post_id, query_params) do
try do try do
post = Posts.get_post!(post_id) post = Posts.get_post!(post_id)
@@ -190,6 +194,7 @@ defmodule BDS.Preview do
{:ok, rendered} -> rendered {:ok, rendered} -> rendered
{:error, _reason} -> render_draft(payload) {:error, _reason} -> render_draft(payload)
end end
|> apply_preview_overrides(query_params)
{:ok, %{content_type: "text/html", body: body}} {:ok, %{content_type: "text/html", body: body}}
else else
@@ -378,16 +383,70 @@ defmodule BDS.Preview do
end end
end end
defp render_not_found_response(project_id) do defp render_not_found_response(project_id, query_params) do
body = body =
case Rendering.render_not_found_page(project_id, %{}) do case Rendering.render_not_found_page(project_id, not_found_assigns(query_params)) do
{:ok, rendered} -> rendered {:ok, rendered} -> rendered
{:error, _reason} -> "Not Found" {:error, _reason} -> "Not Found"
end end
|> apply_preview_overrides(query_params)
{:ok, %{status: 404, content_type: "text/html", body: body}} {:ok, %{status: 404, content_type: "text/html", body: body}}
end end
defp split_request_path(request_path) do
uri = URI.parse(request_path)
{uri.path || "/", URI.decode_query(uri.query || "")}
end
defp apply_response_overrides(%{content_type: content_type, body: body} = response, query_params)
when is_binary(content_type) and is_binary(body) do
if String.starts_with?(content_type, "text/html") do
%{response | body: apply_preview_overrides(body, query_params)}
else
response
end
end
defp apply_preview_overrides(body, query_params) when is_binary(body) and is_map(query_params) do
body
|> override_html_attribute("data-theme", normalize_override(query_params["theme"]))
|> override_html_attribute("data-mode", normalize_override(query_params["mode"]))
end
defp normalize_override(nil), do: nil
defp normalize_override(""), do: nil
defp normalize_override(value), do: String.trim(value)
defp override_html_attribute(body, _attribute, nil), do: body
defp override_html_attribute(body, attribute, value) do
case Regex.run(~r/<html\b[^>]*>/, body) do
[html_tag] ->
replacement =
if String.contains?(html_tag, attribute <> "=") do
Regex.replace(~r/\s#{attribute}="[^"]*"/, html_tag, ~s( #{attribute}="#{value}"), global: false)
else
String.replace_suffix(html_tag, ">", ~s( #{attribute}="#{value}">))
end
String.replace(body, html_tag, replacement, global: false)
_other ->
body
end
end
defp not_found_assigns(query_params) do
%{}
|> maybe_put_assign("html_theme_attribute", query_params["theme"], fn value -> ~s(data-theme="#{value}") end)
|> maybe_put_assign("html_mode_attribute", query_params["mode"], fn value -> ~s(data-mode="#{value}") end)
end
defp maybe_put_assign(assigns, _key, nil, _mapper), do: assigns
defp maybe_put_assign(assigns, _key, "", _mapper), do: assigns
defp maybe_put_assign(assigns, key, value, mapper), do: Map.put(assigns, key, mapper.(value))
defp content_type(path) do defp content_type(path) do
case Path.extname(path) do case Path.extname(path) do
".html" -> "text/html" ".html" -> "text/html"

View File

@@ -3,7 +3,6 @@ defmodule BDS.Projects do
import Ecto.Query import Ecto.Query
alias Ecto.Multi
alias BDS.Persistence alias BDS.Persistence
alias BDS.Projects.Project alias BDS.Projects.Project
alias BDS.Repo alias BDS.Repo
@@ -98,18 +97,19 @@ defmodule BDS.Projects do
project -> project ->
now = Persistence.now_ms() now = Persistence.now_ms()
Multi.new() Repo.transaction(fn ->
|> Multi.update_all(:clear_previous, from(p in Project, where: p.is_active == true), Repo.update_all(
set: [is_active: false, updated_at: now] from(p in Project, where: p.is_active == true),
) set: [is_active: false, updated_at: now]
|> Multi.update( )
:activate,
Project.changeset(project, %{is_active: true, updated_at: now}) project
) |> Project.changeset(%{is_active: true, updated_at: now})
|> Repo.transaction() |> Repo.update!()
end)
|> case do |> case do
{:ok, %{activate: active_project}} -> {:ok, active_project} {:ok, active_project} -> {:ok, active_project}
{:error, _step, reason, _changes} -> {:error, reason} {:error, reason} -> {:error, reason}
end end
end end
end end

View File

@@ -268,17 +268,13 @@ defmodule BDS.Rendering do
end end
defp project_metadata(project_id) do defp project_metadata(project_id) do
case Metadata.get_project_metadata(project_id) do {:ok, metadata} = Metadata.get_project_metadata(project_id)
{:ok, metadata} -> metadata metadata
_other -> %{main_language: "en", blog_languages: [], pico_theme: nil}
end
end end
defp menu_items(project_id) do defp menu_items(project_id) do
case Menu.get_menu(project_id) do {:ok, %{items: items}} = Menu.get_menu(project_id)
{:ok, %{items: items}} -> Enum.map(items, &to_template_menu_item/1) Enum.map(items, &to_template_menu_item/1)
_other -> []
end
end end
defp to_template_menu_item(item) do defp to_template_menu_item(item) do

View File

@@ -206,7 +206,7 @@ defmodule BDS.Rendering.Filters do
defp split_path_suffix(value) do defp split_path_suffix(value) do
case Regex.run(~r/^([^?#]*)([?#].*)?$/, String.trim(value)) do case Regex.run(~r/^([^?#]*)([?#].*)?$/, String.trim(value)) do
[_, path_part, suffix] -> {path_part, suffix || ""} [_, path_part, suffix] -> {path_part, suffix}
[_, path_part] -> {path_part, ""} [_, path_part] -> {path_part, ""}
_other -> {value, ""} _other -> {value, ""}
end end

View File

@@ -8,6 +8,9 @@ defmodule BDS.Scripting.Lua do
@behaviour BDS.Scripting.Runtime @behaviour BDS.Scripting.Runtime
@type lua_state :: tuple()
@type runtime_result :: {:ok, term(), lua_state} | {:error, term()}
@impl true @impl true
def validate(source) when is_binary(source) do def validate(source) when is_binary(source) do
case :luerl.load(source, :luerl_sandbox.init()) do case :luerl.load(source, :luerl_sandbox.init()) do
@@ -16,9 +19,6 @@ defmodule BDS.Scripting.Lua do
{:error, errors, warnings} -> {:error, errors, warnings} ->
{:error, {:compile_error, %{errors: errors, warnings: warnings}}} {:error, {:compile_error, %{errors: errors, warnings: warnings}}}
{:lua_error, error, _state} ->
{:error, {:lua_error, error}}
end end
end end
@@ -102,6 +102,7 @@ defmodule BDS.Scripting.Lua do
end end
end end
@spec run_entrypoint(binary(), binary(), lua_state, keyword()) :: runtime_result
defp run_entrypoint(source, entrypoint, state, opts) do defp run_entrypoint(source, entrypoint, state, opts) do
script = script =
IO.iodata_to_binary([ IO.iodata_to_binary([
@@ -110,27 +111,51 @@ defmodule BDS.Scripting.Lua do
entrypoint, entrypoint,
"(table.unpack(__bds_args__))\n" "(table.unpack(__bds_args__))\n"
]) ])
|> String.to_charlist()
case :luerl_sandbox.run(script, sandbox_flags(opts), state) do script
{:ok, result, next_state} -> {:ok, result, next_state} |> then(&sandbox_run(&1, sandbox_flags(opts), state))
{:lua_error, error, _state} -> {:error, {:lua_error, error}} |> normalize_sandbox_result()
end
@spec sandbox_run(term(), term(), lua_state) :: term()
defp sandbox_run(script, flags, state), do: apply(:luerl_sandbox, :run, [script, flags, state])
defp normalize_sandbox_result({:ok, result, next_state}), do: {:ok, result, next_state}
defp normalize_sandbox_result({:error, {:reductions, count}}), do: {:error, {:reductions_exceeded, count}}
defp normalize_sandbox_result({:error, :timeout}), do: {:error, :timeout}
defp normalize_sandbox_result({:error, reason}), do: {:error, reason}
defp normalize_sandbox_result({reply, next_state}) when is_tuple(next_state) do
case reply do
{:ok, result} -> {:ok, result, next_state}
{:ok, result, _reply_state} -> {:ok, result, next_state}
{:error, {:reductions, count}} -> {:error, {:reductions_exceeded, count}} {:error, {:reductions, count}} -> {:error, {:reductions_exceeded, count}}
{:error, :timeout} -> {:error, :timeout} {:error, :timeout} -> {:error, :timeout}
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
other -> {:error, {:unexpected_result, {other, next_state}}}
end end
end end
defp normalize_sandbox_result(other), do: {:error, {:unexpected_result, other}}
defp sandbox_flags(opts) do defp sandbox_flags(opts) do
config = Application.fetch_env!(:bds, :scripting) config = Application.fetch_env!(:bds, :scripting)
%{ [
max_time: Keyword.get(opts, :timeout, Keyword.fetch!(config, :timeout)), max_time: Keyword.get(opts, :timeout, Keyword.fetch!(config, :timeout)),
max_reductions: Keyword.get(opts, :max_reductions, Keyword.fetch!(config, :max_reductions)), max_reductions: Keyword.get(opts, :max_reductions, Keyword.fetch!(config, :max_reductions)),
spawn_opts: Keyword.get(opts, :spawn_opts, []) spawn_opts: Keyword.get(opts, :spawn_opts, [])
} ]
end
defp unwrap_result(values) when is_list(values) do
case values do
[] -> nil
[value] -> value
_other -> values
end
end end
defp unwrap_result([]), do: nil
defp unwrap_result([value]), do: value
defp unwrap_result(values), do: values defp unwrap_result(values), do: values
end end

View File

@@ -132,7 +132,7 @@ defmodule BDS.Scripts do
defp default_entrypoint(_kind), do: "main" defp default_entrypoint(_kind), do: "main"
defp unique_slug(project_id, base_slug, fallback, exclude_id \\ nil) do defp unique_slug(project_id, base_slug, fallback, exclude_id \\ nil) do
normalized = if base_slug in [nil, ""], do: fallback, else: base_slug normalized = if base_slug == "", do: fallback, else: base_slug
if slug_available?(project_id, normalized, exclude_id) do if slug_available?(project_id, normalized, exclude_id) do
normalized normalized

View File

@@ -173,7 +173,7 @@ defmodule BDS.Templates do
end end
defp unique_slug(project_id, base_slug, fallback, exclude_id \\ nil) do defp unique_slug(project_id, base_slug, fallback, exclude_id \\ nil) do
normalized = if base_slug in [nil, ""], do: fallback, else: base_slug normalized = if base_slug == "", do: fallback, else: base_slug
if slug_available?(project_id, normalized, exclude_id) do if slug_available?(project_id, normalized, exclude_id) do
normalized normalized

View File

@@ -398,4 +398,52 @@ defmodule BDS.GenerationTest do
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
test "validate_site reports missing, extra, and stale generated pages and apply_validation repairs them",
%{project: project, temp_dir: temp_dir} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog",
main_language: "en",
blog_languages: ["en"]
})
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Validation Post",
content: "Validation body",
language: "en"
})
assert {:ok, published_post} = Posts.publish_post(post.id)
assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:core, :single])
post_path = BDS.Generation.post_output_path(published_post)
index_path = Path.join([temp_dir, "html", "index.html"])
post_file_path = Path.join([temp_dir, "html", post_path])
extra_path = Path.join([temp_dir, "html", "obsolete.html"])
File.write!(index_path, "<html>tampered</html>")
File.rm!(post_file_path)
File.write!(extra_path, "<html>obsolete</html>")
assert {:ok, report} = BDS.Generation.validate_site(project.id)
assert "index.html" in report.stale_pages
assert post_path in report.missing_pages
assert "obsolete.html" in report.extra_pages
assert {:ok, repair} = BDS.Generation.apply_validation(project.id, [:core, :single])
assert Enum.sort(repair.sections) == [:core, :single]
assert File.read!(index_path) != "<html>tampered</html>"
assert File.exists?(post_file_path)
refute File.exists?(extra_path)
assert {:ok, clean_report} = BDS.Generation.validate_site(project.id, [:core, :single])
assert clean_report.missing_pages == []
assert clean_report.extra_pages == []
assert clean_report.stale_pages == []
end
end end

View File

@@ -289,4 +289,42 @@ defmodule BDS.PreviewTest do
assert :ok = BDS.Preview.stop_preview(project.id) assert :ok = BDS.Preview.stop_preview(project.id)
end end
test "preview query params can override the rendered theme for generated and draft pages", %{
project: project
} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog",
main_language: "en",
blog_languages: ["en"],
pico_theme: "blue"
})
assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:core])
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Theme Draft",
content: "Theme body",
language: "en"
})
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
assert {:ok, %{body: generated_html, content_type: "text/html"}} =
BDS.Preview.request(project.id, "/?theme=amber&mode=dark")
assert generated_html =~ ~s(data-theme="amber")
assert generated_html =~ ~s(data-mode="dark")
assert {:ok, %{body: draft_html, content_type: "text/html"}} =
BDS.Preview.preview_draft(project.id, "/draft/theme-draft?theme=amber&mode=dark", post.id)
assert draft_html =~ ~s(data-theme="amber")
assert draft_html =~ ~s(data-mode="dark")
assert :ok = BDS.Preview.stop_preview(project.id)
end
end end