fix: reworking some code smell issues

This commit is contained in:
2026-06-26 16:34:07 +02:00
parent 68de265137
commit d1acfd4d71
42 changed files with 661 additions and 129 deletions

View File

@@ -1,5 +1,11 @@
defmodule BDS.AI.Chat do
@moduledoc false
@moduledoc """
Context for AI chat conversations: start, list, fetch, and delete
conversations, persist surface state, and resolve the effective chat model.
Automatic AI activity is gated by the app's airplane (offline) mode; callers
must respect that gating before invoking model-backed operations.
"""
import Ecto.Query
require Logger
@@ -935,5 +941,5 @@ defmodule BDS.AI.Chat do
defp encode_nullable(nil), do: nil
defp encode_nullable(value), do: Jason.encode!(value)
defp blank?(value), do: value in [nil, ""]
defp blank?(value), do: BDS.Values.blank?(value)
end

View File

@@ -107,5 +107,5 @@ defmodule BDS.AI.Runtime do
end
end
defp blank?(value), do: value in [nil, ""]
defp blank?(value), do: BDS.Values.blank?(value)
end

View File

@@ -18,6 +18,7 @@ defmodule BDS.Desktop.ShellLive do
MediaEditor,
MenuEditor,
MiscEditor,
NativeMenuEvents,
OverlayManager,
ScriptEditor,
SettingsEditor,
@@ -81,6 +82,8 @@ defmodule BDS.Desktop.ShellLive do
@git_action_events ["git_fetch", "git_pull", "git_push", "git_prune_lfs"]
@native_menu_events NativeMenuEvents.events()
@layout_menu_actions MapSet.new([
:toggle_sidebar,
:toggle_panel,
@@ -524,31 +527,8 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, reload_shell(socket, SessionUtil.restore_workbench_session(session_payload))}
end
def handle_event("native_menu_action", %{"action" => action}, socket) do
{:noreply, handle_native_menu_action(socket, action)}
end
def handle_event("titlebar_menu_keydown", %{"key" => key}, socket) do
{:noreply, TitlebarMenu.handle_keydown(socket, key, &handle_native_menu_action/2)}
end
def handle_event("toggle_titlebar_menu", %{"group" => group}, socket) do
{:noreply, TitlebarMenu.toggle(socket, group)}
end
def handle_event("hover_titlebar_menu", %{"group" => group}, socket) do
{:noreply, TitlebarMenu.hover(socket, group)}
end
def handle_event("close_titlebar_menu", _params, socket) do
{:noreply, TitlebarMenu.close(socket)}
end
def handle_event("titlebar_menu_action", %{"action" => action}, socket) do
{:noreply,
socket
|> TitlebarMenu.close()
|> handle_native_menu_action(action)}
def handle_event(event, params, socket) when event in @native_menu_events do
NativeMenuEvents.handle(event, params, socket, &handle_native_menu_action/2)
end
@impl true

View File

@@ -90,12 +90,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end
def handle_event("send_chat_editor_message", _params, socket) do
Logger.info("CHAT send_chat_editor_message called, input=#{inspect(socket.assigns.input)}")
{:noreply, do_send_message(socket)}
end
def handle_event("abort_chat_editor_message", _params, socket) do
Logger.info("CHAT abort_chat_editor_message called")
{:noreply, do_abort_message(socket)}
end
@@ -581,8 +579,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
def chart_width(_max_value, _value), do: 0
@spec truthy?(term()) :: boolean()
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
def truthy?(_value), do: false
def truthy?(value), do: BDS.Values.truthy?(value)
# ── HEEx components ───────────────────────────────────────────────────────

View File

@@ -303,6 +303,5 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
defp map_value(_map, _key, default), do: default
defp truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
defp truthy?(_value), do: false
defp truthy?(value), do: BDS.Values.truthy?(value)
end

View File

@@ -1452,6 +1452,6 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
Map.get(item, :resolution) in ["overwrite", "merge"]
end
defp present?(value), do: value not in [nil, ""]
defp blank?(value), do: value in [nil, ""]
defp present?(value), do: BDS.Values.present?(value)
defp blank?(value), do: BDS.Values.blank?(value)
end

View File

@@ -310,5 +310,5 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
@spec translate_phase(term()) :: term()
def translate_phase(other), do: other
defp present?(value), do: value not in [nil, ""]
defp present?(value), do: BDS.Values.present?(value)
end

View File

@@ -284,5 +284,5 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
def maybe_put_option(opts, _key, nil), do: opts
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)
defp present?(value), do: value not in [nil, ""]
defp present?(value), do: BDS.Values.present?(value)
end

View File

@@ -0,0 +1,52 @@
defmodule BDS.Desktop.ShellLive.NativeMenuEvents do
@moduledoc """
Native- and titlebar-menu LiveView events extracted from `BDS.Desktop.ShellLive`.
`ShellLive` routes the events listed in `events/0` here via a single delegating
`handle_event/3`, passing its `handle_native_menu_action/2` callback so the
menu-action dispatch logic stays owned by `ShellLive`.
"""
alias BDS.Desktop.ShellLive.TitlebarMenu
@events ~w(
native_menu_action
titlebar_menu_keydown
toggle_titlebar_menu
hover_titlebar_menu
close_titlebar_menu
titlebar_menu_action
)
@doc "Event names handled by this module."
@spec events() :: [String.t()]
def events, do: @events
@typep socket :: Phoenix.LiveView.Socket.t()
@typep native_action :: (socket(), String.t() -> socket())
@spec handle(String.t(), map(), socket(), native_action()) :: {:noreply, socket()}
def handle("native_menu_action", %{"action" => action}, socket, native_action) do
{:noreply, native_action.(socket, action)}
end
def handle("titlebar_menu_keydown", %{"key" => key}, socket, native_action) do
{:noreply, TitlebarMenu.handle_keydown(socket, key, native_action)}
end
def handle("toggle_titlebar_menu", %{"group" => group}, socket, _native_action) do
{:noreply, TitlebarMenu.toggle(socket, group)}
end
def handle("hover_titlebar_menu", %{"group" => group}, socket, _native_action) do
{:noreply, TitlebarMenu.hover(socket, group)}
end
def handle("close_titlebar_menu", _params, socket, _native_action) do
{:noreply, TitlebarMenu.close(socket)}
end
def handle("titlebar_menu_action", %{"action" => action}, socket, native_action) do
{:noreply, socket |> TitlebarMenu.close() |> native_action.(action)}
end
end

View File

@@ -7,12 +7,17 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
import Ecto.Query
require Logger
alias BDS.{I18n, Media, Metadata, Posts, Repo}
alias BDS.{I18n, Media, Metadata, Posts, Repo, Slug, Strings}
alias BDS.Media.Media, as: MediaRecord
alias BDS.Media.Translation, as: MediaTranslation
alias BDS.Posts.{Post, PostMedia, Translation}
alias BDS.Tags.Tag
# Expected data/DB exceptions for the overlay fallbacks below. Narrowed (not a
# broad `rescue error ->`) so programmer bugs (MatchError, FunctionClauseError)
# surface unmodified instead of being swallowed into a fallback value.
@overlay_rescue [Ecto.NoResultsError, Ecto.QueryError, DBConnection.ConnectionError]
embed_templates("overlay_html/*")
def context(assigns, tab_title, tab_subtitle) do
@@ -71,8 +76,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
metadata
rescue
error ->
log_overlay_warning("project metadata", error)
error in @overlay_rescue ->
log_overlay_error("project metadata", error)
%{main_language: "en", blog_languages: []}
end
@@ -140,8 +145,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
select: pm.media_id
)
rescue
error ->
log_overlay_warning("post media ids for #{post_id}", error)
error in @overlay_rescue ->
log_overlay_error("post media ids for #{post_id}", error)
[]
end
@@ -155,8 +160,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
)
|> Map.new(fn {language, status} -> {language, Atom.to_string(status || :draft)} end)
rescue
error ->
log_overlay_warning("post translations for #{post_id}", error)
error in @overlay_rescue ->
log_overlay_error("post translations for #{post_id}", error)
%{}
end
@@ -168,8 +173,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
)
|> Map.new(fn {language, status} -> {language, status} end)
rescue
error ->
log_overlay_warning("media translations for #{media_id}", error)
error in @overlay_rescue ->
log_overlay_error("media translations for #{media_id}", error)
%{}
end
@@ -188,8 +193,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
_other -> metadata.main_language || "en"
end
rescue
error ->
log_overlay_warning("post source language for #{post_id}", error)
error in @overlay_rescue ->
log_overlay_error("post source language for #{post_id}", error)
metadata.main_language || "en"
end
@@ -199,21 +204,16 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
_other -> metadata.main_language || "en"
end
rescue
error ->
log_overlay_warning("media source language for #{media_id}", error)
error in @overlay_rescue ->
log_overlay_error("media source language for #{media_id}", error)
metadata.main_language || "en"
end
defp source_language(_tab, metadata), do: metadata.main_language || "en"
defp language_names do
%{
"en" => "English",
"de" => "Deutsch",
"fr" => "Francais",
"it" => "Italiano",
"es" => "Espanol"
}
I18n.supported_languages()
|> Enum.into(%{}, fn language -> {language.code, I18n.language_name(language.code)} end)
end
defp language_flags do
@@ -244,7 +244,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
%{
key: "slug",
label: BDS.Gettext.lgettext(page_language, "ui", "Slug"),
current_value: post.slug || slugify(post.title || title),
current_value: post.slug || Slug.slugify(post.title || title),
suggested_value: "",
locked: post.status == :published,
loading: true
@@ -255,8 +255,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
[]
end
rescue
error ->
log_overlay_warning("post AI fields for #{post_id}", error)
error in @overlay_rescue ->
log_overlay_error("post AI fields for #{post_id}", error)
[]
end
@@ -294,8 +294,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
[]
end
rescue
error ->
log_overlay_warning("media AI fields for #{media_id}", error)
error in @overlay_rescue ->
log_overlay_error("media AI fields for #{media_id}", error)
[]
end
@@ -326,8 +326,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
reference_list: reference_list
}
rescue
error ->
log_overlay_warning("delete media details for #{media_id}", error)
error in @overlay_rescue ->
log_overlay_error("delete media details for #{media_id}", error)
%{
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Media"),
@@ -349,8 +349,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
reference_list: []
}
rescue
error ->
log_overlay_warning("delete tag details", error)
error in @overlay_rescue ->
log_overlay_error("delete tag details", error)
%{
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"),
@@ -388,8 +388,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
message: BDS.Gettext.lgettext(page_language, "ui", "Cannot be undone.")
}
rescue
error ->
log_overlay_warning("merge tag details for project #{project_id}", error)
error in @overlay_rescue ->
log_overlay_error("merge tag details for project #{project_id}", error)
%{
target: "tag",
@@ -402,20 +402,10 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
defp canonical_post_url(post) do
timestamp = post.published_at || post.updated_at || System.system_time(:millisecond)
date = DateTime.from_unix!(timestamp, :millisecond)
"/#{date.year}/#{pad2(date.month)}/#{pad2(date.day)}/#{post.slug || post.id}"
"/#{date.year}/#{Strings.pad2(date.month)}/#{Strings.pad2(date.day)}/#{post.slug || post.id}"
end
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
defp slugify(value) do
value
|> to_string()
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, "-")
|> String.trim("-")
end
defp log_overlay_warning(context, error) do
Logger.warning("overlay component fallback for #{context}: #{Exception.message(error)}")
defp log_overlay_error(context, error) do
Logger.error("overlay component fallback for #{context}: #{Exception.message(error)}")
end
end

View File

@@ -300,5 +300,5 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
"#{rounded}%"
end
defp present?(value), do: value not in [nil, ""]
defp present?(value), do: BDS.Values.present?(value)
end

View File

@@ -227,8 +227,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
active_language == canonical_language or not Map.has_key?(translations, active_language)
end
defp truthy?(value) when value in [true, "true", "on", 1, "1"], do: true
defp truthy?(_value), do: false
defp truthy?(value), do: BDS.Values.truthy?(value)
defp blank_to_nil(value) do
value

View File

@@ -201,18 +201,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
defp canonical_preview_path(created_at_ms, slug) do
datetime = DateTime.from_unix!(created_at_ms, :millisecond)
"/#{datetime.year}/#{pad2(datetime.month)}/#{pad2(datetime.day)}/#{slug || ""}"
"/#{datetime.year}/#{BDS.Strings.pad2(datetime.month)}/#{BDS.Strings.pad2(datetime.day)}/#{slug || ""}"
end
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
defp maybe_put_query(query, _key, false), do: query
defp maybe_put_query(query, _key, nil), do: query
defp maybe_put_query(query, key, value), do: Map.put(query, key, value)
def truthy?(value) when value in [true, "true", "on", 1, "1"], do: true
@spec truthy?(term()) :: term()
def truthy?(_value), do: false
@spec truthy?(term()) :: boolean()
def truthy?(value), do: BDS.Values.truthy?(value)
@spec blank?(term()) :: term()
def blank?(value), do: blank_to_nil(value) == nil

View File

@@ -312,7 +312,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
defp normalize_endpoint_result({:ok, _endpoint}), do: :ok
defp normalize_endpoint_result({:error, reason}), do: {:error, reason}
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
defp truthy?(value), do: BDS.Values.truthy?(value)
defp blank_to_nil(nil), do: nil

View File

@@ -77,7 +77,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.EditorSettings do
}
end
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
defp truthy?(value), do: BDS.Values.truthy?(value)
defp boolean_string(true), do: "true"
defp boolean_string(false), do: "false"
end

View File

@@ -187,7 +187,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end)
end
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
defp truthy?(value), do: BDS.Values.truthy?(value)
defp blank_to_nil(nil), do: nil

View File

@@ -94,7 +94,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
}
end
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
defp truthy?(value), do: BDS.Values.truthy?(value)
defp parse_integer(nil, fallback), do: fallback
defp parse_integer(value, _fallback) when is_integer(value), do: value

View File

@@ -1,5 +1,11 @@
defmodule BDS.Embeddings do
@moduledoc false
@moduledoc """
Context for post embeddings: build and refresh the per-project vector index,
sync individual posts, report indexing progress, and compute similarity.
The backing index is persisted to disk and rebuilt on demand; a failed persist
is retried by the next reindex.
"""
import Ecto.Query
require Logger

View File

@@ -108,7 +108,10 @@ defmodule BDS.Embeddings.Backends.Neural do
{:reply, error, state}
end
rescue
exception ->
# Only genuine Nx/serving runtime errors become {:error, _}. Programmer bugs
# (MatchError, FunctionClauseError, …) are not caught here so they crash the
# supervised GenServer and surface unmodified instead of being repackaged.
exception in [ArgumentError, RuntimeError] ->
{:reply, {:error, Exception.message(exception)}, state}
end

View File

@@ -347,8 +347,10 @@ defmodule BDS.Embeddings.Index do
:ok
rescue
exception ->
Logger.debug(
"swallowed embeddings index persist error for #{project_id}: #{inspect(exception)}"
# Logged at :error (not :debug) so a real persist failure — or a programmer
# bug surfacing as an exception — is visible; next reindex retries the save.
Logger.error(
"embeddings index persist failed for #{project_id}: #{inspect(exception)}"
)
:ok
@@ -392,8 +394,8 @@ defmodule BDS.Embeddings.Index do
end
rescue
exception ->
Logger.debug(
"swallowed embeddings index load_from_disk error for #{project_id}: #{inspect(exception)}"
Logger.error(
"embeddings index load_from_disk failed for #{project_id}: #{inspect(exception)}"
)
:error

View File

@@ -1,5 +1,11 @@
defmodule BDS.Generation do
@moduledoc false
@moduledoc """
Context for static-site generation: plan, generate, and validate a project's
rendered output, apply validation results, and write generated files.
Generation is organised into sections (defaulting to `:core`) so callers can
regenerate a subset of the site.
"""
import Ecto.Query

View File

@@ -1,5 +1,12 @@
defmodule BDS.Git do
@moduledoc false
@moduledoc """
Git operations for a project's repository: initialize, inspect status and
diffs, browse history, and run network actions (fetch/pull/push) with
Git LFS support.
Local and network operations use separate timeouts; network calls are gated by
the app's offline mode at the call sites that invoke them.
"""
alias BDS.Projects
require Logger

View File

@@ -28,6 +28,15 @@ defmodule BDS.I18n do
"ES" => "🇪🇸"
}
# Native autonyms — a language's self-name, not translated into the UI locale.
@language_names %{
"en" => "English",
"de" => "Deutsch",
"fr" => "Français",
"it" => "Italiano",
"es" => "Español"
}
@default_language "en"
@default_format_locale "en-US"
@@ -35,6 +44,12 @@ defmodule BDS.I18n do
def default_language, do: @default_language
@doc "Native autonym (self-name) for a supported language code, e.g. \"de\" -> \"Deutsch\"."
def language_name(language) do
code = resolve_render_locale(language)
Map.get(@language_names, code, code)
end
def normalize_language(language) do
language
|> normalize_locale_prefix()

View File

@@ -423,13 +423,14 @@ defmodule BDS.ImportAnalysis do
end
defp parse_macro_params(raw_params) do
Regex.scan(@param_regex, raw_params)
|> Enum.map(fn captures ->
key = Enum.at(captures, 1)
value = Enum.at(captures, 2) || Enum.at(captures, 3) || Enum.at(captures, 4) || ""
@param_regex
|> Regex.scan(raw_params)
|> Map.new(fn [_full, key | value_groups] ->
# Exactly one of the quoted/unquoted value groups participates (the others
# are "" or dropped by Regex.scan); an empty quoted value falls back to "".
value = value_groups |> Enum.reject(&(&1 == "")) |> List.last() |> Kernel.||("")
{key, value}
end)
|> Map.new()
end
defp serialize_params(params) when params == %{}, do: ""

View File

@@ -1,5 +1,11 @@
defmodule BDS.Media do
@moduledoc false
@moduledoc """
Context for media assets and their translations: import, update, replace, and
delete media, and manage per-language metadata translations.
Each operation keeps the database record, the media file on disk, and the
derived metadata in sync.
"""
import BDS.Media.FileOps,
only: [

View File

@@ -1,5 +1,12 @@
defmodule BDS.Posts do
@moduledoc false
@moduledoc """
Context for blog posts and their translations: create, update, publish, and
discard changes, plus editor-body resolution.
Published posts keep their body on the filesystem rather than in the database,
so `editor_body/1` reads from the post's file when no in-memory content is
present. Changes are kept in sync with the filesystem and the embeddings index.
"""
import Ecto.Query
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]

View File

@@ -1,5 +1,11 @@
defmodule BDS.Preview do
@moduledoc false
@moduledoc """
GenServer that runs the local, self-contained preview server for a project.
Starts/stops per-project preview rendering and serves rendered pages and draft
previews via `request/2` and `preview_draft/3`. Generated HTML references only
local, package-bundled assets — never remote CDN JS/CSS.
"""
use GenServer
require Logger
@@ -22,10 +28,12 @@ defmodule BDS.Preview do
@listen_retry_attempts 10
@listen_retry_delay_ms 50
@spec start_link(term()) :: GenServer.on_start()
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
@spec start_preview(String.t()) :: {:ok, map()} | {:error, term()}
def start_preview(project_id) when is_binary(project_id) do
project = Projects.get_project!(project_id)
@@ -35,6 +43,7 @@ defmodule BDS.Preview do
)
end
@spec ensure_preview(String.t()) :: {:ok, map()} | {:error, term()}
def ensure_preview(project_id) when is_binary(project_id) do
project = Projects.get_project!(project_id)
@@ -44,6 +53,7 @@ defmodule BDS.Preview do
)
end
@spec base_url() :: String.t()
def base_url do
case :ets.whereis(@server_table) do
:undefined -> "http://#{@host}:#{@preferred_port}"
@@ -56,10 +66,12 @@ defmodule BDS.Preview do
end
end
@spec stop_preview(String.t()) :: :ok | {:error, term()}
def stop_preview(project_id) when is_binary(project_id) do
GenServer.call(__MODULE__, {:stop_preview, project_id})
end
@spec request(String.t(), String.t()) :: {:ok, map()} | {:error, term()}
def request(project_id, request_path) when is_binary(project_id) and is_binary(request_path) do
{path, query_params} = split_request_path(request_path)
@@ -68,6 +80,7 @@ defmodule BDS.Preview do
end)
end
@spec preview_draft(String.t(), String.t(), String.t()) :: {:ok, map()} | {:error, term()}
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
{_path, query_params} = split_request_path(request_path)

View File

@@ -238,26 +238,27 @@ defmodule BDS.Rendering.Filters do
path_part |> String.replace(~r/\.html?$/i, "")
match = Regex.run(~r|^/?post/([a-z0-9-]+(?:\.html?)?)$|i, path_part) ->
slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "")
Map.get(canonical_post_paths, slug)
slug_from_match(match, canonical_post_paths)
match = Regex.run(~r|^/?post/\d{4}/\d{1,2}/([a-z0-9-]+(?:\.html?)?)$|i, path_part) ->
slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "")
Map.get(canonical_post_paths, slug)
slug_from_match(match, canonical_post_paths)
match = Regex.run(~r|^/?posts/([a-z0-9-]+(?:\.html?)?)$|i, path_part) ->
slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "")
Map.get(canonical_post_paths, slug)
slug_from_match(match, canonical_post_paths)
match = Regex.run(~r|^/?posts/\d{4}/\d{1,2}/([a-z0-9-]+(?:\.html?)?)$|i, path_part) ->
slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "")
Map.get(canonical_post_paths, slug)
slug_from_match(match, canonical_post_paths)
true ->
nil
end
end
defp slug_from_match([_full, captured], canonical_post_paths) do
slug = String.replace(captured, ~r/\.html?$/i, "")
Map.get(canonical_post_paths, slug)
end
defp normalize_media_src(raw_src, canonical_media_paths) do
cond do
raw_src == "" ->

View File

@@ -120,7 +120,7 @@ defmodule BDS.Rendering.LinksAndLanguages do
defp post_date_path_parts(created_at) do
datetime = Persistence.from_unix_ms!(created_at)
year_month_path_parts(datetime) ++ [pad2(datetime.day)]
year_month_path_parts(datetime) ++ [BDS.Strings.pad2(datetime.day)]
end
defp year_month_path_parts(created_at) when is_integer(created_at) do
@@ -130,13 +130,11 @@ defmodule BDS.Rendering.LinksAndLanguages do
end
defp year_month_path_parts(%DateTime{} = datetime) do
[Integer.to_string(datetime.year), pad2(datetime.month)]
[Integer.to_string(datetime.year), BDS.Strings.pad2(datetime.month)]
end
defp normalize_language_prefix(prefix) when is_binary(prefix) and prefix != "",
do: String.trim_trailing(prefix, "/")
defp normalize_language_prefix(_prefix), do: ""
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
end

View File

@@ -160,7 +160,7 @@ defmodule BDS.Scripting.Capabilities.Util do
def truthy?(value), do: value in [true, "true", 1, 1.0, "1"]
@spec pad2(integer()) :: String.t()
def pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
defdelegate pad2(value), to: BDS.Strings
@spec blank_to_nil(term()) :: term()
def blank_to_nil(nil), do: nil

9
lib/bds/strings.ex Normal file
View File

@@ -0,0 +1,9 @@
defmodule BDS.Strings do
@moduledoc """
Small string-formatting helpers shared across the codebase.
"""
@doc "Zero-pads an integer to at least two digits, e.g. `3 -> \"03\"`."
@spec pad2(integer()) :: String.t()
def pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
end

View File

@@ -1,5 +1,8 @@
defmodule BDS.Tags do
@moduledoc false
@moduledoc """
Context for post tags: create, list, update, rename, delete, and merge tags,
and keep them in sync with posts and the on-disk `tags.json`.
"""
import Ecto.Query

View File

@@ -194,5 +194,5 @@ defmodule BDS.UI.Dashboard do
if blank?(post.title), do: post.slug || "", else: post.title
end
defp blank?(value), do: value in [nil, ""]
defp blank?(value), do: BDS.Values.blank?(value)
end

View File

@@ -1125,5 +1125,5 @@ defmodule BDS.UI.Sidebar do
defp media_size_label(_size), do: "0 B"
defp present?(value), do: value not in [nil, ""]
defp present?(value), do: BDS.Values.present?(value)
end

27
lib/bds/values.ex Normal file
View File

@@ -0,0 +1,27 @@
defmodule BDS.Values do
@moduledoc """
Canonical scalar predicates for blank/present/truthy checks.
These operate on individual values (not maps), which is why they live here
rather than in `BDS.MapUtils`. `present?/1` and `blank?/1` use the same
non-trimming contract as `BDS.MapUtils.blank_to_nil/1`: a whitespace-only
string such as `" "` is considered **present**. Call sites that need
trim-aware semantics must compose `String.trim/1` themselves.
"""
@doc "Maps `nil` and `\"\"` to `nil`, everything else to itself (non-trimming)."
@spec blank_to_nil(term()) :: term()
defdelegate blank_to_nil(value), to: BDS.MapUtils
@doc "True when `value` is neither `nil` nor `\"\"` (non-trimming)."
@spec present?(term()) :: boolean()
def present?(value), do: blank_to_nil(value) != nil
@doc "True when `value` is `nil` or `\"\"` (non-trimming)."
@spec blank?(term()) :: boolean()
def blank?(value), do: blank_to_nil(value) == nil
@doc "True for the canonical truthy set: `true`, `\"true\"`, `\"on\"`, `1`, `1.0`, `\"1\"`."
@spec truthy?(term()) :: boolean()
def truthy?(value), do: value in [true, "true", "on", 1, 1.0, "1"]
end