From 92e5c2ccfdd407a73a6e1146bfc9cfa1a6be111a Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 26 Apr 2026 19:53:29 +0200 Subject: [PATCH] feat: preview working and template delete is in, too --- lib/bds/desktop/shell_live.ex | 29 ++++++ .../desktop/shell_live/sidebar_components.ex | 80 +++++++++++---- lib/bds/rendering.ex | 98 ++++++++++++++++--- lib/bds/rendering/filters.ex | 4 + lib/bds/templates.ex | 25 +++++ .../default/templates/post-list.liquid | 4 +- .../default/templates/single-post.liquid | 2 +- .../templates/post-list.liquid | 4 +- .../templates/single-post.liquid | 2 +- priv/ui/app.css | 45 ++++++++- test/bds/desktop/shell_live_test.exs | 38 +++++++ test/bds/generation_test.exs | 5 +- test/bds/preview_test.exs | 26 ++++- test/bds/rendering_test.exs | 53 ++++++++++ test/bds/templates_test.exs | 28 ++++++ 15 files changed, 401 insertions(+), 42 deletions(-) diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 012c34c..c4cd59a 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -17,6 +17,7 @@ defmodule BDS.Desktop.ShellLive do alias BDS.Posts.Post alias BDS.Projects alias BDS.Repo + alias BDS.Templates alias BDS.UI.{Commands, MenuBar, Registry, Session, Workbench} @refresh_interval 1_500 @@ -286,6 +287,34 @@ defmodule BDS.Desktop.ShellLive do |> reload_shell(workbench)} end + def handle_event("delete_sidebar_template", %{"id" => template_id}, socket) do + case Repo.get(Templates.Template, template_id) do + %Templates.Template{project_id: project_id} when project_id == socket.assigns.projects.active_project_id -> + case Templates.delete_template(template_id) do + {:ok, :deleted} -> + workbench = Workbench.close_tab(socket.assigns.workbench, :templates, template_id) + tab_meta = Map.delete(socket.assigns.tab_meta, {:templates, template_id}) + + {:noreply, + socket + |> assign(:tab_meta, tab_meta) + |> reload_shell(workbench)} + + {:error, reason} -> + {:noreply, + socket + |> append_output_entry(translated("Delete") <> " " <> translated("Template"), inspect(reason), nil, "error") + |> reload_shell(socket.assigns.workbench)} + end + + _other -> + {:noreply, + socket + |> append_output_entry(translated("Delete") <> " " <> translated("Template"), inspect(:not_found), nil, "error") + |> reload_shell(socket.assigns.workbench)} + end + end + def handle_event("toggle_offline_mode", _params, socket) do socket = assign(socket, :offline_mode, not socket.assigns.offline_mode) {:noreply, reload_shell(socket, socket.assigns.workbench)} diff --git a/lib/bds/desktop/shell_live/sidebar_components.ex b/lib/bds/desktop/shell_live/sidebar_components.ex index 44cc4de..bd49f27 100644 --- a/lib/bds/desktop/shell_live/sidebar_components.ex +++ b/lib/bds/desktop/shell_live/sidebar_components.ex @@ -350,27 +350,65 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do defp render_entity_sidebar(assigns) do ~H""" <%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %> -
+
<%= for item <- Map.get(@sidebar_data, :items, []) do %> - + <%= if item.route == "templates" do %> +
+ + +
+ <% else %> + + <% end %> <% end %>
<% else %> @@ -426,6 +464,8 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) + defp template_sidebar?(sidebar_data), do: Map.get(sidebar_data, :title) == "Templates" + defp group_year_month_counts(entries) do entries |> Enum.group_by(& &1.year) diff --git a/lib/bds/rendering.ex b/lib/bds/rendering.ex index f5a9cb8..84f666f 100644 --- a/lib/bds/rendering.ex +++ b/lib/bds/rendering.ex @@ -52,7 +52,13 @@ defmodule BDS.Rendering do case select_template(project_id, kind, slug) do %Template{} = template -> - published_template_body(template) + case published_template_body(template) do + {:ok, _source} = ok -> + ok + + {:error, reason} = error -> + maybe_load_bundled_template_source(project, kind, slug, template, reason, error) + end nil -> load_bundled_template_source(project, kind, slug) @@ -70,6 +76,23 @@ defmodule BDS.Rendering do ) end + defp select_template(project_id, :post, nil) do + case StarterTemplates.default_slug(:post) do + nil -> + nil + + default_slug -> + Repo.one( + from template in Template, + where: + template.project_id == ^project_id and template.kind == :post and + template.status == :published and + template.enabled == true and template.slug == ^default_slug, + limit: 1 + ) + end + end + defp select_template(project_id, kind, nil) do Repo.one( from template in Template, @@ -139,11 +162,24 @@ defmodule BDS.Rendering do {:error, :template_not_found} end + defp maybe_load_bundled_template_source(project, kind, slug, template, reason, error) + when reason in [:enoent, :template_not_found] do + if template.content in [nil, ""] and StarterTemplates.default_template?(kind, template.slug) do + load_bundled_template_source(project, kind, slug) + else + error + end + end + + defp maybe_load_bundled_template_source(_project, _kind, _slug, _template, _reason, error), + do: error + defp bundled_template_slug(_kind, slug) when is_binary(slug) and slug != "", do: slug defp bundled_template_slug(kind, _slug), do: StarterTemplates.default_slug(kind) defp post_assigns(project_id, assigns) do metadata = project_metadata(project_id) + template_context = template_render_context(project_id) language = Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) @@ -156,9 +192,16 @@ defmodule BDS.Rendering do 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) + raw_content = Map.get(assigns, :content, Map.get(assigns, "content")) + rendered_content = render_post_content(raw_content, canonical_post_paths, canonical_media_paths, language, template_context) incoming_links = link_contexts(project_id, post_id, :incoming, main_language) outgoing_links = link_contexts(project_id, post_id, :outgoing, main_language) + post_assigns = + assigns + |> Map.put(:content, rendered_content) + |> Map.put(:raw_content, raw_content) + %{ language: language, language_prefix: @@ -196,26 +239,35 @@ defmodule BDS.Rendering do backlinks: backlinks(incoming_links), canonical_post_path_by_slug: canonical_post_paths, canonical_media_path_by_source_path: canonical_media_paths, - post_data_json_by_id: post_data_json(assigns, post_record), - post: build_post_context(assigns, post_record, incoming_links, outgoing_links) + post_data_json_by_id: post_data_json(post_assigns, post_record), + post: build_post_context(post_assigns, post_record, incoming_links, outgoing_links) } end defp list_assigns(project_id, assigns) do metadata = project_metadata(project_id) + template_context = template_render_context(project_id) language = Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) main_language = metadata.main_language || language - posts = normalize_list_posts(Map.get(assigns, :posts, Map.get(assigns, "posts", []))) archive_context = Map.get(assigns, :archive_context, Map.get(assigns, "archive_context", %{})) + canonical_post_paths = canonical_post_path_by_slug(project_id, main_language) + canonical_media_paths = canonical_media_path_by_source_path(project_id) + posts = + normalize_list_posts( + Map.get(assigns, :posts, Map.get(assigns, "posts", [])), + canonical_post_paths, + canonical_media_paths, + language, + template_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) day_blocks = build_day_blocks(posts) min_date = min_date(posts) max_date = max_date(posts) @@ -551,20 +603,29 @@ defmodule BDS.Rendering do post_path(post, prefix) end - defp normalize_list_posts(posts) do + defp normalize_list_posts(posts, canonical_post_paths, canonical_media_paths, language, template_context) do Enum.map(posts, fn post -> post_record = load_post_record(post) + raw_content = + Map.get( + post, + :content, + Map.get(post, "content", Map.get(post, :excerpt, Map.get(post, "excerpt", ""))) + ) %{ id: Map.get(post, :id, Map.get(post, "id")), slug: Map.get(post, :slug, Map.get(post, "slug")), 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", ""))) + render_post_content( + raw_content, + canonical_post_paths, + canonical_media_paths, + language, + template_context ), + raw_content: raw_content, excerpt: Map.get(post, :excerpt, Map.get(post, "excerpt", Map.get(post_record || %{}, :excerpt))), author: Map.get(post, :author, Map.get(post, "author", Map.get(post_record || %{}, :author))), @@ -602,6 +663,7 @@ defmodule BDS.Rendering do 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")), + raw_content: Map.get(assigns, :raw_content, Map.get(assigns, "raw_content")), excerpt: Map.get( assigns, @@ -634,6 +696,20 @@ defmodule BDS.Rendering do } end + defp render_post_content(content, canonical_post_paths, canonical_media_paths, language, template_context) do + Filters.render_markdown(content, canonical_post_paths, canonical_media_paths, language, template_context) + end + + defp template_render_context(project_id) do + project = Projects.get_project!(project_id) + + Liquex.Context.new(%{}, + static_environment: %{}, + filter_module: Filters, + file_system: FileSystem.new(StarterTemplates.template_roots(project)) + ) + end + defp normalize_pagination(nil, posts) do total_items = length(posts) diff --git a/lib/bds/rendering/filters.ex b/lib/bds/rendering/filters.ex index df82bb9..ba28544 100644 --- a/lib/bds/rendering/filters.ex +++ b/lib/bds/rendering/filters.ex @@ -26,6 +26,10 @@ defmodule BDS.Rendering.Filters do _language_prefix, context ) do + render_markdown(value, canonical_post_paths, canonical_media_paths, language, context) + end + + def render_markdown(value, canonical_post_paths, canonical_media_paths, language, context) do value |> to_string() |> replace_built_in_macros(language, context) diff --git a/lib/bds/templates.ex b/lib/bds/templates.ex index 055a19b..af4846a 100644 --- a/lib/bds/templates.ex +++ b/lib/bds/templates.ex @@ -155,6 +155,8 @@ defmodule BDS.Templates do template end) + remove_stale_published_templates(project_id, project, template_paths) + {:ok, templates} end @@ -380,6 +382,29 @@ defmodule BDS.Templates do |> Repo.insert_or_update!() end + defp remove_stale_published_templates(project_id, project, template_paths) do + tracked_paths = + template_paths + |> Enum.map(&Path.relative_to(&1, Projects.project_data_dir(project))) + |> MapSet.new() + + Repo.all( + from template in Template, + where: + template.project_id == ^project_id and + template.status == :published and + template.file_path != "" and + not is_nil(template.file_path) + ) + |> Enum.reject(&(MapSet.member?(tracked_paths, &1.file_path) or File.exists?(full_file_path(project_id, &1.file_path)))) + |> Enum.each(fn template -> + clear_template_references(template) + Repo.delete!(template) + end) + + :ok + end + defp parse_template_kind(kind) when is_atom(kind), do: kind defp parse_template_kind("post"), do: :post defp parse_template_kind("list"), do: :list diff --git a/priv/data/projects/default/templates/post-list.liquid b/priv/data/projects/default/templates/post-list.liquid index 6a809b7..02dcc73 100644 --- a/priv/data/projects/default/templates/post-list.liquid +++ b/priv/data/projects/default/templates/post-list.liquid @@ -53,7 +53,7 @@ version: 1 {% endif %}

{{ post.title }}

{% endif %} - {{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }} + {{ post.content }}
{% endfor %} @@ -68,7 +68,7 @@ version: 1 {% endif %}

{{ post.title }}

{% endif %} - {{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }} + {{ post.content }} {% endfor %} {% endif %} diff --git a/priv/data/projects/default/templates/single-post.liquid b/priv/data/projects/default/templates/single-post.liquid index a1030e5..fc0ef79 100644 --- a/priv/data/projects/default/templates/single-post.liquid +++ b/priv/data/projects/default/templates/single-post.liquid @@ -26,7 +26,7 @@ version: 1 {% endif %}
-
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}
+
{{ post.content }}
{% if backlinks.size > 0 %}
diff --git a/priv/starter_templates/templates/post-list.liquid b/priv/starter_templates/templates/post-list.liquid index 3238405..037644c 100644 --- a/priv/starter_templates/templates/post-list.liquid +++ b/priv/starter_templates/templates/post-list.liquid @@ -45,7 +45,7 @@ {% endif %}

{{ post.title }}

{% endif %} - {{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }} + {{ post.content }}
{% endfor %} @@ -60,7 +60,7 @@ {% endif %}

{{ post.title }}

{% endif %} - {{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }} + {{ post.content }} {% endfor %} {% endif %} diff --git a/priv/starter_templates/templates/single-post.liquid b/priv/starter_templates/templates/single-post.liquid index d0fe643..c49f36d 100644 --- a/priv/starter_templates/templates/single-post.liquid +++ b/priv/starter_templates/templates/single-post.liquid @@ -18,7 +18,7 @@ {% endif %}
-
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}
+
{{ post.content }}
{% if backlinks.size > 0 %}
diff --git a/priv/ui/app.css b/priv/ui/app.css index a00df03..5a8f104 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -4246,9 +4246,11 @@ button svg * { .chat-list-item { display: flex; + align-items: center; width: 100%; - padding: 10px 12px; + padding: 8px 12px; border: none; + border-bottom: 1px solid var(--vscode-sideBar-border); background: transparent; color: inherit; text-align: left; @@ -4271,6 +4273,26 @@ button svg * { gap: 2px; } +.chat-item-open { + display: flex; + flex: 1; + min-width: 0; + padding: 0; + border: none; + background: transparent; + color: inherit; + text-align: left; +} + +.chat-item-open:hover { + background: transparent; +} + +.chat-item-open:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + .chat-item-title { overflow: hidden; text-overflow: ellipsis; @@ -4282,6 +4304,27 @@ button svg * { font-size: 12px; } +.chat-item-delete { + background: transparent; + border: none; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 0 4px; + opacity: 0; + transition: opacity 0.15s, color 0.15s; +} + +.chat-list-item:hover .chat-item-delete, +.chat-list-item.active .chat-item-delete { + opacity: 1; +} + +.chat-item-delete:hover { + color: var(--vscode-errorForeground); +} + .settings-nav-list { display: flex; flex-direction: column; diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 7078182..4654a4e 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -727,6 +727,44 @@ defmodule BDS.Desktop.ShellLiveTest do refute html =~ ~s(phx-value-mode="visual") end + test "template sidebar exposes old-app style delete control and removes template rows", %{project: project} do + assert {:ok, template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Sidebar Template", + kind: :post, + content: "
{{ post.content }}
" + }) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html = + view + |> element("[data-testid='activity-button'][data-view='templates']") + |> render_click() + + assert html =~ "Sidebar Template" + assert html =~ ~s(data-testid="sidebar-delete-template") + + html = + view + |> element("[data-testid='sidebar-open-item'][data-item-id='#{template.id}']") + |> render_click() + + assert html =~ ~s(data-tab-type="templates") + assert html =~ ~s(data-tab-id="#{template.id}") + + html = + view + |> element("[data-testid='sidebar-delete-template'][data-item-id='#{template.id}']") + |> render_click() + + assert BDS.Repo.get(BDS.Templates.Template, template.id) == nil + refute html =~ "Sidebar Template" + refute html =~ ~s(data-tab-type="templates") + refute html =~ ~s(data-tab-id="#{template.id}") + end + defp seed_sidebar_posts(project_id) do now = Persistence.now_ms() diff --git a/test/bds/generation_test.exs b/test/bds/generation_test.exs index 30ae0e8..6a710ba 100644 --- a/test/bds/generation_test.exs +++ b/test/bds/generation_test.exs @@ -259,7 +259,7 @@ defmodule BDS.GenerationTest do Posts.create_post(%{ project_id: project.id, title: "Rendered Post", - content: "Rendered body", + content: "**Rendered** body", language: "en", template_slug: published_post_template.slug }) @@ -280,7 +280,8 @@ defmodule BDS.GenerationTest do post_html = File.read!(Path.join([temp_dir, "html", post_path])) assert post_html =~ "post-template" - assert post_html =~ "Rendered body" + assert post_html =~ ~s(Rendered body) + refute post_html =~ "**Rendered** body" assert "pagefind/index.json" in relative_paths assert "pagefind/pagefind-ui.js" in relative_paths diff --git a/test/bds/preview_test.exs b/test/bds/preview_test.exs index 321b63f..58d9363 100644 --- a/test/bds/preview_test.exs +++ b/test/bds/preview_test.exs @@ -134,7 +134,7 @@ defmodule BDS.PreviewTest do Posts.create_post(%{ project_id: project.id, title: "Draft Post", - content: "Draft preview body", + content: "**Draft** preview body", language: "en", template_slug: published_template.slug }) @@ -146,7 +146,29 @@ defmodule BDS.PreviewTest do assert draft_html =~ "preview-template" assert draft_html =~ "Draft Post" - assert draft_html =~ "Draft preview body" + assert draft_html =~ ~s(Draft preview body) + refute draft_html =~ "**Draft** preview body" + + assert {:ok, published_post} = Posts.publish_post(post.id) + + published_datetime = DateTime.from_unix!(published_post.created_at, :millisecond) + published_path = + "/#{published_datetime.year}/#{String.pad_leading(Integer.to_string(published_datetime.month), 2, "0")}/#{String.pad_leading(Integer.to_string(published_datetime.day), 2, "0")}/#{published_post.slug}" + + :inets.start() + + assert {:ok, server} = BDS.Preview.start_preview(project.id) + + assert {:ok, {{_version, 200, _reason}, _headers, published_html}} = + :httpc.request( + :get, + {to_charlist("http://#{server.host}:#{server.port}#{published_path}?draft=true&post_id=#{published_post.id}"), []}, + [], + body_format: :binary + ) + + assert published_html =~ ~s(Draft preview body) + refute published_html =~ "**Draft** preview body" assert :ok = BDS.Preview.stop_preview(project.id) end diff --git a/test/bds/rendering_test.exs b/test/bds/rendering_test.exs index 67104ac..43d61b3 100644 --- a/test/bds/rendering_test.exs +++ b/test/bds/rendering_test.exs @@ -311,6 +311,59 @@ defmodule BDS.RenderingTest do assert rendered =~ "range=1711843200-1711929600" end + test "render_post_page falls back to bundled starter template when the published default template file is missing", %{ + project: project + } do + assert {:ok, _metadata} = + BDS.Metadata.update_project_metadata(project.id, %{ + public_url: "https://example.com/blog", + main_language: "en", + blog_languages: ["en"] + }) + + assert {:ok, template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Broken Default Post Template", + kind: :post, + content: "this custom template should not be used" + }) + + assert {:ok, published_template} = BDS.Templates.publish_template(template.id) + + BDS.Repo.update_all( + from(template in BDS.Templates.Template, + where: template.id == ^published_template.id + ), + set: [slug: "single-post", file_path: "templates/single-post.liquid", content: nil] + ) + + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Fallback Body", + content: "**Rendered** body", + language: "en" + }) + + assert {:ok, published_post} = BDS.Posts.publish_post(post.id) + + assert {:ok, rendered} = + Rendering.render_post_page(project.id, nil, %{ + id: published_post.id, + title: published_post.title, + content: BDS.Posts.editor_body(published_post), + slug: published_post.slug, + language: published_post.language, + excerpt: published_post.excerpt, + template_slug: published_post.template_slug + }) + + assert rendered =~ ~s(data-template="single-post") + assert rendered =~ ~s(Rendered body) + refute rendered =~ "this custom template should not be used" + end + defp canonical_post_href(post) do datetime = DateTime.from_unix!(post.created_at, :millisecond) diff --git a/test/bds/templates_test.exs b/test/bds/templates_test.exs index 32170cc..5ab66d9 100644 --- a/test/bds/templates_test.exs +++ b/test/bds/templates_test.exs @@ -263,4 +263,32 @@ defmodule BDS.TemplatesTest do assert template.created_at == 101 assert template.updated_at == 202 end + + test "rebuild_templates_from_files removes stale published default templates when no local template files exist", %{ + project: project + } do + now = BDS.Persistence.now_ms() + + stale_template = + %BDS.Templates.Template{} + |> BDS.Templates.Template.changeset(%{ + id: Ecto.UUID.generate(), + project_id: project.id, + slug: "single-post", + title: "Single Post", + kind: :post, + enabled: true, + version: 1, + file_path: "templates/single-post.liquid", + status: :published, + content: nil, + created_at: now, + updated_at: now + }) + |> Repo.insert!() + + assert {:ok, templates} = BDS.Templates.rebuild_templates_from_files(project.id) + assert templates == [] + assert Repo.get(BDS.Templates.Template, stale_template.id) == nil + end end