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}
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)

View File

@@ -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}"

View File

@@ -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

View File

@@ -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

View File

@@ -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/<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
case Path.extname(path) do
".html" -> "text/html"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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