From f857e739f6b025b3ddba406420da1e275b0f9a15 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 24 Apr 2026 10:39:14 +0200 Subject: [PATCH] feat: more implementations of partial code and cleanup --- lib/bds/embeddings.ex | 7 +- lib/bds/generation.ex | 167 ++++++++++++++++++++++++++++++++--- lib/bds/persistence.ex | 4 - lib/bds/posts.ex | 6 +- lib/bds/preview.ex | 85 +++++++++++++++--- lib/bds/projects.ex | 24 ++--- lib/bds/rendering.ex | 12 +-- lib/bds/rendering/filters.ex | 2 +- lib/bds/scripting/lua.ex | 45 +++++++--- lib/bds/scripts.ex | 2 +- lib/bds/templates.ex | 2 +- test/bds/generation_test.exs | 48 ++++++++++ test/bds/preview_test.exs | 38 ++++++++ 13 files changed, 373 insertions(+), 69 deletions(-) diff --git a/lib/bds/embeddings.ex b/lib/bds/embeddings.ex index c604431..c1375f6 100644 --- a/lib/bds/embeddings.ex +++ b/lib/bds/embeddings.ex @@ -258,7 +258,6 @@ defmodule BDS.Embeddings do {:ok, suggestions} else {:error, :not_found} -> {:ok, []} - {:disabled, _project_id} -> {:ok, []} end end @@ -411,7 +410,6 @@ defmodule BDS.Embeddings do defp enabled_for_project?(project_id) do case Metadata.get_project_metadata(project_id) do {:ok, metadata} -> metadata.semantic_similarity_enabled == true - _other -> false end end @@ -453,7 +451,10 @@ defmodule BDS.Embeddings do 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 body = resolve_post_body(post) diff --git a/lib/bds/generation.ex b/lib/bds/generation.ex index a50a8e2..b752a2c 100644 --- a/lib/bds/generation.ex +++ b/lib/bds/generation.ex @@ -50,6 +50,102 @@ defmodule BDS.Generation do 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, language) do @@ -166,6 +262,64 @@ defmodule BDS.Generation do core_outputs ++ single_outputs ++ archive_outputs ++ sitemap 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 languages = plan.blog_languages @@ -692,17 +846,6 @@ defmodule BDS.Generation do 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) when is_binary(project_id) do case Rendering.render_not_found_page(project_id, %{ @@ -714,8 +857,6 @@ defmodule BDS.Generation do 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(nil, _main_language), do: "" defp language_prefix(language, _main_language), do: "/#{language}" diff --git a/lib/bds/persistence.ex b/lib/bds/persistence.ex index 7d96fd3..a42e7a2 100644 --- a/lib/bds/persistence.ex +++ b/lib/bds/persistence.ex @@ -76,10 +76,6 @@ defmodule BDS.Persistence do {:error, _reason} = error -> _ = File.rm(temp_path) error - - error -> - _ = File.rm(temp_path) - error end end end diff --git a/lib/bds/posts.ex b/lib/bds/posts.ex index bc4ac45..7306c0b 100644 --- a/lib/bds/posts.ex +++ b/lib/bds/posts.ex @@ -409,7 +409,7 @@ defmodule BDS.Posts do end 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 normalized @@ -486,7 +486,7 @@ defmodule BDS.Posts do {:tags, post.tags || []}, {:categories, post.categories || []} ], - post.content || "" + post.content ) end @@ -671,7 +671,7 @@ defmodule BDS.Posts do {:updated_at, translation.updated_at}, {:published_at, published_at} ], - translation.content || "" + translation.content ) end diff --git a/lib/bds/preview.ex b/lib/bds/preview.ex index 80a7adb..ccc2f2b 100644 --- a/lib/bds/preview.ex +++ b/lib/bds/preview.ex @@ -28,17 +28,19 @@ defmodule BDS.Preview do end 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 def preview_draft(project_id, request_path, post_id) when is_binary(project_id) and is_binary(request_path) and is_binary(post_id) do post = Posts.get_post!(post_id) + {_path, query_params} = split_request_path(request_path) GenServer.call(__MODULE__, { :preview_draft, project_id, - request_path, + query_params, %{ id: post.id, title: post.title, @@ -97,22 +99,23 @@ defmodule BDS.Preview do {:reply, :ok, next_state} 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), - {:ok, response} <- resolve_request(state.current, request_path) do + {:ok, response} <- resolve_request(state.current, request_path, query_params) do {:reply, {:ok, response}, state} else {:error, reason} -> {:reply, {:error, reason}, state} 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 body = case Rendering.render_post_page(project_id, Map.get(post, :template_slug), post) do {:ok, rendered} -> rendered {:error, _reason} -> render_draft(post) end + |> apply_preview_overrides(query_params) response = %{ content_type: "text/html", @@ -132,13 +135,13 @@ defmodule BDS.Preview do case query_params["post_id"] do post_id when is_binary(post_id) -> 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 - resolve_request(state.current, request_path) + resolve_request(state.current, request_path, query_params) end _other -> - resolve_request(state.current, request_path) + resolve_request(state.current, request_path, query_params) 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(_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 full_path = case kind do @@ -162,14 +165,15 @@ defmodule BDS.Preview do resolved_path -> 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 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 post = Posts.get_post!(post_id) @@ -190,6 +194,7 @@ defmodule BDS.Preview do {:ok, rendered} -> rendered {:error, _reason} -> render_draft(payload) end + |> apply_preview_overrides(query_params) {:ok, %{content_type: "text/html", body: body}} else @@ -378,16 +383,70 @@ defmodule BDS.Preview do end end - defp render_not_found_response(project_id) do + defp render_not_found_response(project_id, query_params) do 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 {:error, _reason} -> "Not Found" end + |> apply_preview_overrides(query_params) {:ok, %{status: 404, content_type: "text/html", body: body}} 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/]*>/, 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 case Path.extname(path) do ".html" -> "text/html" diff --git a/lib/bds/projects.ex b/lib/bds/projects.ex index 564c992..0d62812 100644 --- a/lib/bds/projects.ex +++ b/lib/bds/projects.ex @@ -3,7 +3,6 @@ defmodule BDS.Projects do import Ecto.Query - alias Ecto.Multi alias BDS.Persistence alias BDS.Projects.Project alias BDS.Repo @@ -98,18 +97,19 @@ defmodule BDS.Projects do project -> now = Persistence.now_ms() - Multi.new() - |> Multi.update_all(:clear_previous, 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}) - ) - |> Repo.transaction() + Repo.transaction(fn -> + Repo.update_all( + from(p in Project, where: p.is_active == true), + set: [is_active: false, updated_at: now] + ) + + project + |> Project.changeset(%{is_active: true, updated_at: now}) + |> Repo.update!() + end) |> case do - {:ok, %{activate: active_project}} -> {:ok, active_project} - {:error, _step, reason, _changes} -> {:error, reason} + {:ok, active_project} -> {:ok, active_project} + {:error, reason} -> {:error, reason} end end end diff --git a/lib/bds/rendering.ex b/lib/bds/rendering.ex index 184799d..dba9a78 100644 --- a/lib/bds/rendering.ex +++ b/lib/bds/rendering.ex @@ -268,17 +268,13 @@ defmodule BDS.Rendering do end defp project_metadata(project_id) do - case Metadata.get_project_metadata(project_id) do - {:ok, metadata} -> metadata - _other -> %{main_language: "en", blog_languages: [], pico_theme: nil} - end + {:ok, metadata} = Metadata.get_project_metadata(project_id) + metadata end defp menu_items(project_id) do - case Menu.get_menu(project_id) do - {:ok, %{items: items}} -> Enum.map(items, &to_template_menu_item/1) - _other -> [] - end + {:ok, %{items: items}} = Menu.get_menu(project_id) + Enum.map(items, &to_template_menu_item/1) end defp to_template_menu_item(item) do diff --git a/lib/bds/rendering/filters.ex b/lib/bds/rendering/filters.ex index cf272cb..53c0533 100644 --- a/lib/bds/rendering/filters.ex +++ b/lib/bds/rendering/filters.ex @@ -206,7 +206,7 @@ defmodule BDS.Rendering.Filters do defp split_path_suffix(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, ""} _other -> {value, ""} end diff --git a/lib/bds/scripting/lua.ex b/lib/bds/scripting/lua.ex index 5509276..a1796ef 100644 --- a/lib/bds/scripting/lua.ex +++ b/lib/bds/scripting/lua.ex @@ -8,6 +8,9 @@ defmodule BDS.Scripting.Lua do @behaviour BDS.Scripting.Runtime + @type lua_state :: tuple() + @type runtime_result :: {:ok, term(), lua_state} | {:error, term()} + @impl true def validate(source) when is_binary(source) do case :luerl.load(source, :luerl_sandbox.init()) do @@ -16,9 +19,6 @@ defmodule BDS.Scripting.Lua do {:error, errors, warnings} -> {:error, {:compile_error, %{errors: errors, warnings: warnings}}} - - {:lua_error, error, _state} -> - {:error, {:lua_error, error}} end end @@ -102,6 +102,7 @@ defmodule BDS.Scripting.Lua do end end + @spec run_entrypoint(binary(), binary(), lua_state, keyword()) :: runtime_result defp run_entrypoint(source, entrypoint, state, opts) do script = IO.iodata_to_binary([ @@ -110,27 +111,51 @@ defmodule BDS.Scripting.Lua do entrypoint, "(table.unpack(__bds_args__))\n" ]) + |> String.to_charlist() - case :luerl_sandbox.run(script, sandbox_flags(opts), state) do - {:ok, result, next_state} -> {:ok, result, next_state} - {:lua_error, error, _state} -> {:error, {:lua_error, error}} + script + |> then(&sandbox_run(&1, sandbox_flags(opts), state)) + |> 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, :timeout} -> {:error, :timeout} {:error, reason} -> {:error, reason} + other -> {:error, {:unexpected_result, {other, next_state}}} end end + defp normalize_sandbox_result(other), do: {:error, {:unexpected_result, other}} + defp sandbox_flags(opts) do config = Application.fetch_env!(:bds, :scripting) - %{ + [ max_time: Keyword.get(opts, :timeout, Keyword.fetch!(config, :timeout)), max_reductions: Keyword.get(opts, :max_reductions, Keyword.fetch!(config, :max_reductions)), 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 - defp unwrap_result([]), do: nil - defp unwrap_result([value]), do: value defp unwrap_result(values), do: values end diff --git a/lib/bds/scripts.ex b/lib/bds/scripts.ex index d0ed50c..c42a061 100644 --- a/lib/bds/scripts.ex +++ b/lib/bds/scripts.ex @@ -132,7 +132,7 @@ defmodule BDS.Scripts do defp default_entrypoint(_kind), do: "main" 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 normalized diff --git a/lib/bds/templates.ex b/lib/bds/templates.ex index 004b9ed..afa2cda 100644 --- a/lib/bds/templates.ex +++ b/lib/bds/templates.ex @@ -173,7 +173,7 @@ defmodule BDS.Templates do end 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 normalized diff --git a/test/bds/generation_test.exs b/test/bds/generation_test.exs index 511ba25..a46efcb 100644 --- a/test/bds/generation_test.exs +++ b/test/bds/generation_test.exs @@ -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", "2026", "04", "index.html"])) =~ "2026-04" 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, "tampered") + File.rm!(post_file_path) + File.write!(extra_path, "obsolete") + + 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) != "tampered" + 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 diff --git a/test/bds/preview_test.exs b/test/bds/preview_test.exs index 40a5edf..1995508 100644 --- a/test/bds/preview_test.exs +++ b/test/bds/preview_test.exs @@ -289,4 +289,42 @@ defmodule BDS.PreviewTest do assert :ok = BDS.Preview.stop_preview(project.id) 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