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 %>
-
+
<% 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 %}
{% 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 %}
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