From 52857f29594f8874b7dbc7eef781b9e89c8f9342 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 1 May 2026 12:11:15 +0200 Subject: [PATCH] chore: another god module down --- CODESMELL.md | 9 +- lib/bds/rendering.ex | 823 +---------------------- lib/bds/rendering/links_and_languages.ex | 131 ++++ lib/bds/rendering/list_archive.ex | 295 ++++++++ lib/bds/rendering/metadata.ex | 113 ++++ lib/bds/rendering/post_rendering.ex | 167 +++++ lib/bds/rendering/template_selection.ex | 153 +++++ 7 files changed, 875 insertions(+), 816 deletions(-) create mode 100644 lib/bds/rendering/links_and_languages.ex create mode 100644 lib/bds/rendering/list_archive.ex create mode 100644 lib/bds/rendering/metadata.ex create mode 100644 lib/bds/rendering/post_rendering.ex create mode 100644 lib/bds/rendering/template_selection.ex diff --git a/CODESMELL.md b/CODESMELL.md index bb6f9d3..0f2651e 100644 --- a/CODESMELL.md +++ b/CODESMELL.md @@ -2,7 +2,7 @@ Living document. Each section lists **status**, then **open work in priority order**, then a brief notes block. Completed work is listed compactly at the bottom (`## Changelog`). -Last refreshed: 2026-05-03. +Last refreshed: 2026-05-04. --- @@ -14,7 +14,6 @@ Last refreshed: 2026-05-03. | # | Module | Current lines | Target | Strategy | |---|---|---|---|---| -| 4 | `BDS.Rendering` | 838 | ≤ 200 | Extract `TemplateSelection` (~120), `PostRendering` (~180), `ListArchive` (~150), `Metadata` (~140), `LinksAndLanguages` (~100). Main keeps the 3 public renders. | | 5 | `BDS.Desktop.ShellLive.MenuEditor` | 871 | ≤ 350 | Extract `TreeOps` (~280), `TreePredicates` (~60), `DraftManagement` (~140), `PageCategory` (~120), `State` (~80). | | 6 | `BDS.Desktop.ShellLive.PostEditor` | 963 | ≤ 400 | Extract `DraftManagement` (~180), `ListValues` (~160), `Persistence` (~140), `PostMetadata` (~150). | | 7 | `BDS.Desktop.ShellLive.SettingsEditor` | 872 | ≤ 350 | Extract `ProjectSettings` (~140), `AISettings` (~150), `PublishingSettings` (~80), `ManagedCategories` (~140), `StyleEditor` (~80), `MCPConfig` (~60). | @@ -33,6 +32,7 @@ Last refreshed: 2026-05-03. - `BDS.Maintenance` 810 → 141 (83 %) - `BDS.Media` 993 → 324 (67 %) - `BDS.Desktop.ShellLive.ImportEditor` 1436 → 776 (46 %) +- `BDS.Rendering` 838 → 33 (96 %) --- @@ -166,6 +166,11 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search` ## Changelog +### 2026-05-04 + +- **God modules**: + - `BDS.Rendering` 838 → 33 (96 %). Submodules under `lib/bds/rendering/`: `LinksAndLanguages` (131, canonical_post_path_by_slug + canonical_media_path_by_source_path + post_path/2,3 + link_contexts/4 + link_context + language_prefix + normalize_language), `TemplateSelection` (153, load_template_source + select_template + published_template_body + render_template + load_bundled_template_source + maybe_load_bundled_template_source + bundled_template_slug + template_render_context), `Metadata` (113, project_metadata + menu_items + to_template_menu_item + menu_item_href + blog_languages + tag_color_by_name + alternate_links + backlinks + default_pico_stylesheet_href + href_for_language + calendar_initial_year + calendar_initial_month), `PostRendering` (167, post_assigns + load_post_record + canonical_post_record + canonical_post_id + post_data_json + post_data_json_value + build_post_context + render_post_content), `ListArchive` (295, list_assigns + not_found_assigns + normalize_list_posts + normalize_pagination + normalize_archive_context + build_day_blocks + min_date + max_date + show_archive_range_heading? + calendar_initial_year_from_posts + calendar_initial_month_from_posts). Coordinator now contains only the 3 public renders (`render_post_page/3`, `render_list_page/2`, `render_not_found_page/1,2`) which delegate to `TemplateSelection.load_template_source`, `TemplateSelection.render_template`, and the appropriate assigns builder (`PostRendering.post_assigns`, `ListArchive.list_assigns`, `ListArchive.not_found_assigns`). Cross-submodule deps are linear: ListArchive → PostRendering + Metadata + TemplateSelection + LinksAndLanguages; PostRendering → Metadata + TemplateSelection + LinksAndLanguages; Metadata → LinksAndLanguages; TemplateSelection + LinksAndLanguages have no internal deps. + ### 2026-05-03 - **God modules**: diff --git a/lib/bds/rendering.ex b/lib/bds/rendering.ex index 84f666f..b84a714 100644 --- a/lib/bds/rendering.ex +++ b/lib/bds/rendering.ex @@ -1,838 +1,33 @@ defmodule BDS.Rendering do @moduledoc false - import Ecto.Query - - alias BDS.Frontmatter - alias BDS.Persistence - alias BDS.Media.Media, as: MediaAsset - alias BDS.Menu - alias BDS.Metadata - alias BDS.PreviewAssets - alias BDS.PostLinks - alias BDS.Projects - alias BDS.I18n - alias BDS.Rendering.FileSystem - alias BDS.Rendering.Filters - alias BDS.Repo - alias BDS.StarterTemplates - alias BDS.Posts.Post - alias BDS.Posts.Translation - alias BDS.Tags.Tag - alias BDS.Templates.Template + alias BDS.Rendering.ListArchive + alias BDS.Rendering.PostRendering + alias BDS.Rendering.TemplateSelection def render_post_page(project_id, template_slug, assigns) when is_binary(project_id) and is_map(assigns) do - with {:ok, template_source} <- load_template_source(project_id, :post, template_slug), + with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :post, template_slug), {:ok, rendered} <- - render_template(project_id, template_source, post_assigns(project_id, assigns)) do + TemplateSelection.render_template(project_id, template_source, PostRendering.post_assigns(project_id, assigns)) do {:ok, rendered} end end def render_list_page(project_id, assigns) when is_binary(project_id) and is_map(assigns) do - with {:ok, template_source} <- load_template_source(project_id, :list, nil), + with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :list, nil), {:ok, rendered} <- - render_template(project_id, template_source, list_assigns(project_id, assigns)) do + TemplateSelection.render_template(project_id, template_source, ListArchive.list_assigns(project_id, assigns)) do {:ok, rendered} end end def render_not_found_page(project_id, assigns \\ %{}) when is_binary(project_id) and is_map(assigns) do - with {:ok, template_source} <- load_template_source(project_id, :not_found, nil), + with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :not_found, nil), {:ok, rendered} <- - render_template(project_id, template_source, not_found_assigns(project_id, assigns)) do + TemplateSelection.render_template(project_id, template_source, ListArchive.not_found_assigns(project_id, assigns)) do {:ok, rendered} end end - - defp load_template_source(project_id, kind, slug) do - project = Projects.get_project!(project_id) - - case select_template(project_id, kind, slug) do - %Template{} = 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) - end - end - - defp select_template(project_id, kind, slug) when is_binary(slug) and slug != "" do - Repo.one( - from template in Template, - where: - template.project_id == ^project_id and template.kind == ^kind and - template.status == :published and - template.enabled == true and template.slug == ^slug, - limit: 1 - ) - 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, - where: - template.project_id == ^project_id and template.kind == ^kind and - template.status == :published and - template.enabled == true, - order_by: [desc: template.created_at, desc: template.slug], - limit: 1 - ) - end - - defp published_template_body(%Template{content: content}) when is_binary(content), - do: {:ok, content} - - defp published_template_body(%Template{} = template) do - project = Projects.get_project!(template.project_id) - full_path = Path.join(Projects.project_data_dir(project), template.file_path) - - case File.read(full_path) do - {:ok, contents} -> - case Frontmatter.parse_document(contents) do - {:ok, %{body: body}} -> {:ok, body} - {:error, reason} -> {:error, reason} - end - - {:error, reason} -> - {:error, reason} - end - end - - defp render_template(project_id, source, assigns) do - with {:ok, template_ast} <- Liquex.parse(source) do - project = Projects.get_project!(project_id) - - context = - Liquex.Context.new(assigns, - static_environment: assigns, - filter_module: Filters, - file_system: FileSystem.new(StarterTemplates.template_roots(project)) - ) - - {result, _context} = Liquex.render!(template_ast, context) - {:ok, IO.iodata_to_binary(result)} - end - rescue - error -> {:error, error} - end - - defp load_bundled_template_source(project, kind, slug) do - desired_slug = bundled_template_slug(kind, slug) - - if is_binary(desired_slug) do - file_system = project |> StarterTemplates.template_roots() |> FileSystem.new() - source = Liquex.FileSystem.read_template_file(file_system, desired_slug) - - case Frontmatter.parse_document(source) do - {:ok, %{body: body}} -> {:ok, body} - {:error, :invalid_frontmatter} -> {:ok, source} - end - else - {:error, :template_not_found} - end - rescue - error in [Liquex.Error] -> - _ = error - {: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")) - - main_language = metadata.main_language || language - post_record = load_post_record(assigns) - canonical_post = canonical_post_record(post_record) - post_id = canonical_post_id(post_record, assigns) - post_categories = Map.get(post_record || %{}, :categories, []) || [] - 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: - Map.get( - assigns, - :language_prefix, - Map.get(assigns, "language_prefix", language_prefix(language, main_language)) - ), - page_title: - Map.get( - assigns, - :page_title, - Map.get(assigns, "page_title", Map.get(assigns, :title, Map.get(assigns, "title"))) - ), - pico_stylesheet_href: - Map.get( - assigns, - :pico_stylesheet_href, - Map.get(assigns, "pico_stylesheet_href", default_pico_stylesheet_href(metadata.pico_theme)) - ), - html_theme_attribute: - Map.get( - assigns, - :html_theme_attribute, - Map.get(assigns, "html_theme_attribute") - ), - blog_languages: blog_languages(metadata, language), - alternate_links: alternate_links(canonical_post, project_id, main_language), - menu_items: menu_items(project_id), - calendar_initial_year: calendar_initial_year(post_record), - calendar_initial_month: calendar_initial_month(post_record), - post_categories: post_categories, - post_tags: post_tags, - tag_color_by_name: tag_color_by_name(project_id), - 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(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 - 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) - - day_blocks = build_day_blocks(posts) - min_date = min_date(posts) - max_date = max_date(posts) - normalized_archive_context = normalize_archive_context(archive_context) - - %{ - language: language, - language_prefix: - Map.get( - assigns, - :language_prefix, - Map.get(assigns, "language_prefix", language_prefix(language, main_language)) - ), - page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title")), - posts: posts, - pico_stylesheet_href: - Map.get( - assigns, - :pico_stylesheet_href, - Map.get(assigns, "pico_stylesheet_href", default_pico_stylesheet_href(metadata.pico_theme)) - ), - html_theme_attribute: - Map.get( - assigns, - :html_theme_attribute, - Map.get(assigns, "html_theme_attribute") - ), - blog_languages: blog_languages(metadata, language), - alternate_links: [], - menu_items: menu_items(project_id), - calendar_initial_year: calendar_initial_year_from_posts(posts), - calendar_initial_month: calendar_initial_month_from_posts(posts), - archive_context: normalized_archive_context, - show_archive_range_heading: show_archive_range_heading?(normalized_archive_context, day_blocks), - min_date: min_date, - max_date: max_date, - is_list_page: true, - is_first_page: pagination.current_page <= 1, - is_last_page: pagination.current_page >= pagination.total_pages, - has_prev_page: pagination.has_prev_page, - has_next_page: pagination.has_next_page, - prev_page_href: pagination.prev_page_href, - next_page_href: pagination.next_page_href, - current_page: pagination.current_page, - total_pages: pagination.total_pages, - total_items: pagination.total_items, - items_per_page: pagination.items_per_page, - canonical_post_path_by_slug: canonical_post_paths, - canonical_media_path_by_source_path: canonical_media_paths, - post_data_json_by_id: - Enum.into(posts, %{}, fn post -> {post.id, post_data_json_value(post)} end), - day_blocks: day_blocks - } - end - - defp not_found_assigns(project_id, assigns) do - metadata = project_metadata(project_id) - - language = - Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) - - main_language = metadata.main_language || language - - %{ - page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title", "404")), - language: language, - language_prefix: - Map.get( - assigns, - :language_prefix, - Map.get(assigns, "language_prefix", language_prefix(language, main_language)) - ), - pico_stylesheet_href: - Map.get( - assigns, - :pico_stylesheet_href, - Map.get(assigns, "pico_stylesheet_href", default_pico_stylesheet_href(metadata.pico_theme)) - ), - html_theme_attribute: - Map.get( - assigns, - :html_theme_attribute, - Map.get(assigns, "html_theme_attribute") - ), - blog_languages: blog_languages(metadata, language), - menu_items: menu_items(project_id), - alternate_links: [], - not_found_message: - Map.get( - assigns, - :not_found_message, - Map.get( - assigns, - "not_found_message", - I18n.translate(language, "render.notFound.message") - ) - ), - not_found_back_label: - Map.get( - assigns, - :not_found_back_label, - Map.get( - assigns, - "not_found_back_label", - I18n.translate(language, "render.notFound.back") - ) - ) - } - end - - defp project_metadata(project_id) do - {:ok, metadata} = Metadata.get_project_metadata(project_id) - metadata - end - - defp menu_items(project_id) do - {:ok, %{items: items}} = Menu.get_menu(project_id) - Enum.map(items, &to_template_menu_item/1) - end - - defp to_template_menu_item(item) do - kind = Map.get(item, :kind) - children = Enum.map(Map.get(item, :children, []), &to_template_menu_item/1) - - %{ - title: Map.get(item, :label, ""), - href: menu_item_href(item), - has_children: children != [], - children: children, - kind: kind - } - end - - defp menu_item_href(%{kind: :home}), do: "/" - - defp menu_item_href(%{kind: :page, slug: slug}) when is_binary(slug) and slug != "", - do: "/#{slug}/" - - defp menu_item_href(%{kind: :category_archive, slug: slug}) when is_binary(slug) and slug != "", - do: "/category/#{URI.encode(slug)}/" - - defp menu_item_href(%{kind: :submenu}), do: "#" - defp menu_item_href(_item), do: "#" - - defp blog_languages(metadata, current_language) do - ([metadata.main_language] ++ (metadata.blog_languages || [])) - |> Enum.reject(&(&1 in [nil, ""])) - |> Enum.uniq() - |> Enum.map(fn language -> - normalized = I18n.normalize_language(language) - href_prefix = language_prefix(normalized, metadata.main_language || current_language) - - %{ - code: normalized, - flag: I18n.flag(normalized), - href: href_for_language(href_prefix), - href_prefix: href_prefix, - is_current: normalized == I18n.normalize_language(current_language) - } - end) - end - - defp tag_color_by_name(project_id) do - Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: {tag.name, tag.color}) - |> Enum.into(%{}, fn {name, color} -> {name, color} end) - end - - defp load_post_record(assigns) do - case Map.get(assigns, :id, Map.get(assigns, "id")) do - nil -> nil - post_id -> Repo.get(Post, post_id) || Repo.get(Translation, post_id) - end - end - - defp canonical_post_record(%Translation{translation_for: post_id}), do: Repo.get(Post, post_id) - defp canonical_post_record(%Post{} = post), do: post - defp canonical_post_record(_other), do: nil - - defp canonical_post_id(%Translation{translation_for: post_id}, _assigns), do: post_id - defp canonical_post_id(%Post{id: post_id}, _assigns), do: post_id - defp canonical_post_id(_post_record, assigns), do: Map.get(assigns, :id, Map.get(assigns, "id")) - - defp post_data_json(assigns, post_record) do - id = Map.get(assigns, :id, Map.get(assigns, "id")) - - if is_binary(id) do - incoming_links = link_contexts(Map.get(post_record || %{}, :project_id), canonical_post_id(post_record, assigns), :incoming, Map.get(post_record || %{}, :language)) - outgoing_links = link_contexts(Map.get(post_record || %{}, :project_id), canonical_post_id(post_record, assigns), :outgoing, Map.get(post_record || %{}, :language)) - - %{ - id => post_data_json_value(build_post_context(assigns, post_record, incoming_links, outgoing_links)) - } - else - %{} - end - end - - defp post_data_json_value(post_context) do - Jason.encode!(%{ - id: Map.get(post_context, :id), - title: Map.get(post_context, :title), - slug: Map.get(post_context, :slug), - excerpt: Map.get(post_context, :excerpt), - author: Map.get(post_context, :author), - language: Map.get(post_context, :language), - published_at: Map.get(post_context, :published_at), - created_at: Map.get(post_context, :created_at), - updated_at: Map.get(post_context, :updated_at), - tags: Map.get(post_context, :tags, []), - categories: Map.get(post_context, :categories, []) - }) - end - - defp canonical_post_path_by_slug(project_id, main_language) do - posts = - Repo.all( - from post in Post, where: post.project_id == ^project_id and post.status == :published - ) - - translations = - Repo.all( - from translation in Translation, - where: translation.project_id == ^project_id and translation.status == :published - ) - - post_by_id = Map.new(posts, fn post -> {post.id, post} end) - - post_paths = - Enum.into(posts, %{}, fn post -> - {post.slug, post_path(post, nil)} - end) - - Enum.reduce(translations, post_paths, fn translation, acc -> - case Map.get(post_by_id, translation.translation_for) do - nil -> acc - post -> Map.put(acc, post.slug, post_path(post, translation.language, main_language)) - end - end) - end - - defp alternate_links(nil, _project_id, _main_language), do: [] - - defp alternate_links(%Post{} = post, project_id, main_language) do - translations = - Repo.all( - from translation in Translation, - where: - translation.project_id == ^project_id and - translation.translation_for == ^post.id and - translation.status == :published, - order_by: [asc: translation.language] - ) - - [%{href: post_path(post, nil), hreflang: normalize_language(post.language, main_language)}] ++ - Enum.map(translations, fn translation -> - %{href: post_path(post, translation.language, main_language), hreflang: translation.language} - end) - end - - defp backlinks(incoming_links) do - Enum.map(incoming_links, fn link -> - %{path: link.href, display_slug: link.display_slug, title: link.title} - end) - end - - defp link_contexts(_project_id, nil, _direction, _main_language), do: [] - - defp link_contexts(project_id, post_id, :incoming, main_language) do - PostLinks.list_incoming_links(post_id) - |> Enum.map(&link_context(project_id, &1, :incoming, main_language)) - |> Enum.reject(&is_nil/1) - end - - defp link_contexts(project_id, post_id, :outgoing, main_language) do - PostLinks.list_outgoing_links(post_id) - |> Enum.map(&link_context(project_id, &1, :outgoing, main_language)) - |> Enum.reject(&is_nil/1) - end - - defp link_context(_project_id, link, direction, main_language) do - linked_post_id = - case direction do - :incoming -> link.source_post_id - :outgoing -> link.target_post_id - end - - case Repo.get(Post, linked_post_id) do - nil -> nil - linked_post -> %{href: post_path(linked_post, nil), title: linked_post.title, display_slug: linked_post.slug, language: normalize_language(linked_post.language, main_language)} - end - end - - defp canonical_media_path_by_source_path(project_id) do - Repo.all(from media in MediaAsset, where: media.project_id == ^project_id) - |> Enum.reduce(%{}, fn media, acc -> - datetime = Persistence.from_unix_ms!(media.created_at) - - source_key = - Path.join([ - "media", - Integer.to_string(datetime.year), - String.pad_leading(Integer.to_string(datetime.month), 2, "0"), - media.original_name - ]) - |> String.downcase() - - Map.put(acc, source_key, "/" <> media.file_path) - end) - end - - defp post_path(post, language_prefix) - when is_binary(language_prefix) and language_prefix != "" do - language_prefix <> post_path(post, nil) - end - - defp post_path(post, ""), do: post_path(post, nil) - - defp post_path(post, nil) do - datetime = Persistence.from_unix_ms!(post.created_at) - - Path.join([ - Integer.to_string(datetime.year), - String.pad_leading(Integer.to_string(datetime.month), 2, "0"), - String.pad_leading(Integer.to_string(datetime.day), 2, "0"), - post.slug, - "index.html" - ]) - |> then(&("/" <> String.trim_trailing(&1, "index.html"))) - end - - defp post_path(post, language, main_language) do - prefix = language_prefix(language, main_language) - post_path(post, prefix) - end - - 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: - 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))), - language: - Map.get( - post, - :language, - Map.get(post, "language", Map.get(post_record || %{}, :language)) - ), - published_at: - Map.get(post, :published_at, Map.get(post, "published_at", Map.get(post_record || %{}, :published_at))), - created_at: - Map.get(post, :created_at, Map.get(post, "created_at", Map.get(post_record || %{}, :created_at))), - updated_at: - Map.get(post, :updated_at, Map.get(post, "updated_at", Map.get(post_record || %{}, :updated_at))), - tags: Map.get(post, :tags, Map.get(post, "tags", Map.get(post_record || %{}, :tags, []))) || [], - categories: - Map.get(post, :categories, Map.get(post, "categories", Map.get(post_record || %{}, :categories, []))) || [], - template_slug: - Map.get(post, :template_slug, Map.get(post, "template_slug", Map.get(post_record || %{}, :template_slug))), - do_not_translate: - Map.get(post, :do_not_translate, Map.get(post, "do_not_translate", Map.get(post_record || %{}, :do_not_translate, false))), - href: Map.get(post, :href, Map.get(post, "href")), - show_title: true, - linked_media: [], - outgoing_links: [], - incoming_links: [] - } - end) - end - - defp build_post_context(assigns, post_record, incoming_links, outgoing_links) do - %{ - id: Map.get(assigns, :id, Map.get(assigns, "id")), - 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, - :excerpt, - Map.get(assigns, "excerpt", Map.get(post_record || %{}, :excerpt)) - ), - author: Map.get(post_record || %{}, :author), - language: - Map.get( - assigns, - :language, - Map.get(assigns, "language", Map.get(post_record || %{}, :language)) - ), - show_title: true, - published_at: Map.get(post_record || %{}, :published_at), - created_at: Map.get(post_record || %{}, :created_at), - updated_at: Map.get(post_record || %{}, :updated_at), - tags: Map.get(post_record || %{}, :tags, []) || [], - categories: Map.get(post_record || %{}, :categories, []) || [], - template_slug: - Map.get( - post_record || %{}, - :template_slug, - Map.get(assigns, :template_slug, Map.get(assigns, "template_slug")) - ), - do_not_translate: Map.get(post_record || %{}, :do_not_translate, false), - linked_media: [], - outgoing_links: outgoing_links, - incoming_links: incoming_links - } - 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) - - %{ - current_page: 1, - total_pages: 1, - total_items: total_items, - items_per_page: total_items, - has_prev_page: false, - prev_page_href: "", - has_next_page: false, - next_page_href: "" - } - end - - defp normalize_pagination(%{} = pagination, posts) do - total_items = - Map.get(pagination, :total_items, Map.get(pagination, "total_items", length(posts))) - - items_per_page = - Map.get(pagination, :items_per_page, Map.get(pagination, "items_per_page", total_items)) - - %{ - current_page: Map.get(pagination, :current_page, Map.get(pagination, "current_page", 1)), - total_pages: Map.get(pagination, :total_pages, Map.get(pagination, "total_pages", 1)), - total_items: total_items, - items_per_page: items_per_page, - has_prev_page: - Map.get(pagination, :has_prev_page, Map.get(pagination, "has_prev_page", false)), - prev_page_href: - Map.get(pagination, :prev_page_href, Map.get(pagination, "prev_page_href", "")), - has_next_page: - Map.get(pagination, :has_next_page, Map.get(pagination, "has_next_page", false)), - next_page_href: - Map.get(pagination, :next_page_href, Map.get(pagination, "next_page_href", "")) - } - end - - defp normalize_archive_context(nil), do: nil - - defp normalize_archive_context(%{} = archive_context) do - %{ - kind: Map.get(archive_context, :kind, Map.get(archive_context, "kind")), - name: Map.get(archive_context, :name, Map.get(archive_context, "name")), - month: Map.get(archive_context, :month, Map.get(archive_context, "month")), - year: Map.get(archive_context, :year, Map.get(archive_context, "year")), - day: Map.get(archive_context, :day, Map.get(archive_context, "day")) - } - end - - defp build_day_blocks(posts) do - grouped_blocks = - posts - |> Enum.filter(&is_integer(Map.get(&1, :created_at))) - |> Enum.group_by(&(Map.get(&1, :created_at) |> Persistence.from_unix_ms!() |> DateTime.to_date() |> Date.to_iso8601())) - |> Enum.sort_by(fn {label, _posts} -> label end) - - grouped_blocks - |> Enum.with_index() - |> Enum.map(fn {{date_label, grouped_posts}, index} -> - %{ - date_label: date_label, - show_date_marker: true, - show_separator: index < length(grouped_blocks) - 1, - posts: Enum.sort_by(grouped_posts, &Map.get(&1, :created_at)) - } - end) - |> case do - [] -> [%{date_label: "", show_date_marker: false, show_separator: false, posts: posts}] - blocks -> blocks - end - end - - defp min_date(posts) do - posts - |> Enum.map(&Map.get(&1, :created_at)) - |> Enum.filter(&is_integer/1) - |> Enum.min(fn -> nil end) - end - - defp max_date(posts) do - posts - |> Enum.map(&Map.get(&1, :created_at)) - |> Enum.filter(&is_integer/1) - |> Enum.max(fn -> nil end) - end - - defp show_archive_range_heading?(%{kind: "date"}, _day_blocks), do: true - defp show_archive_range_heading?(_archive_context, _day_blocks), do: false - - defp default_pico_stylesheet_href(theme), do: PreviewAssets.stylesheet_href(theme) - - defp href_for_language(""), do: "/" - defp href_for_language(prefix), do: prefix <> "/" - - defp calendar_initial_year(%{created_at: created_at}) when is_integer(created_at), - do: Persistence.from_unix_ms!(created_at).year - - defp calendar_initial_year(_post), do: nil - - defp calendar_initial_month(%{created_at: created_at}) when is_integer(created_at), - do: Persistence.from_unix_ms!(created_at).month - - defp calendar_initial_month(_post), do: nil - - defp calendar_initial_year_from_posts([post | _rest]), do: calendar_initial_year(post) - defp calendar_initial_year_from_posts([]), do: nil - - defp calendar_initial_month_from_posts([post | _rest]), do: calendar_initial_month(post) - defp calendar_initial_month_from_posts([]), do: nil - - 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}" - - defp normalize_language(nil, fallback), do: fallback - defp normalize_language("", fallback), do: fallback - - defp normalize_language(language, _fallback) do - language - |> to_string() - |> String.downcase() - |> String.split("-", parts: 2) - |> hd() - end end diff --git a/lib/bds/rendering/links_and_languages.ex b/lib/bds/rendering/links_and_languages.ex new file mode 100644 index 0000000..bc6211a --- /dev/null +++ b/lib/bds/rendering/links_and_languages.ex @@ -0,0 +1,131 @@ +defmodule BDS.Rendering.LinksAndLanguages do + @moduledoc false + + import Ecto.Query + + alias BDS.Media.Media, as: MediaAsset + alias BDS.Persistence + alias BDS.PostLinks + alias BDS.Posts.Post + alias BDS.Repo + + def canonical_post_path_by_slug(project_id, main_language) do + posts = + Repo.all( + from post in Post, where: post.project_id == ^project_id and post.status == :published + ) + + translations = + Repo.all( + from translation in BDS.Posts.Translation, + where: translation.project_id == ^project_id and translation.status == :published + ) + + post_by_id = Map.new(posts, fn post -> {post.id, post} end) + + post_paths = + Enum.into(posts, %{}, fn post -> + {post.slug, post_path(post, nil)} + end) + + Enum.reduce(translations, post_paths, fn translation, acc -> + case Map.get(post_by_id, translation.translation_for) do + nil -> acc + post -> Map.put(acc, post.slug, post_path(post, translation.language, main_language)) + end + end) + end + + def canonical_media_path_by_source_path(project_id) do + Repo.all(from media in MediaAsset, where: media.project_id == ^project_id) + |> Enum.reduce(%{}, fn media, acc -> + datetime = Persistence.from_unix_ms!(media.created_at) + + source_key = + Path.join([ + "media", + Integer.to_string(datetime.year), + String.pad_leading(Integer.to_string(datetime.month), 2, "0"), + media.original_name + ]) + |> String.downcase() + + Map.put(acc, source_key, "/" <> media.file_path) + end) + end + + def post_path(post, language_prefix) + when is_binary(language_prefix) and language_prefix != "" do + language_prefix <> post_path(post, nil) + end + + def post_path(post, ""), do: post_path(post, nil) + + def post_path(post, nil) do + datetime = Persistence.from_unix_ms!(post.created_at) + + Path.join([ + Integer.to_string(datetime.year), + String.pad_leading(Integer.to_string(datetime.month), 2, "0"), + String.pad_leading(Integer.to_string(datetime.day), 2, "0"), + post.slug, + "index.html" + ]) + |> then(&("/" <> String.trim_trailing(&1, "index.html"))) + end + + def post_path(post, language, main_language) do + prefix = language_prefix(language, main_language) + post_path(post, prefix) + end + + def link_contexts(_project_id, nil, _direction, _main_language), do: [] + + def link_contexts(project_id, post_id, :incoming, main_language) do + PostLinks.list_incoming_links(post_id) + |> Enum.map(&link_context(project_id, &1, :incoming, main_language)) + |> Enum.reject(&is_nil/1) + end + + def link_contexts(project_id, post_id, :outgoing, main_language) do + PostLinks.list_outgoing_links(post_id) + |> Enum.map(&link_context(project_id, &1, :outgoing, main_language)) + |> Enum.reject(&is_nil/1) + end + + defp link_context(_project_id, link, direction, main_language) do + linked_post_id = + case direction do + :incoming -> link.source_post_id + :outgoing -> link.target_post_id + end + + case Repo.get(Post, linked_post_id) do + nil -> + nil + + linked_post -> + %{ + href: post_path(linked_post, nil), + title: linked_post.title, + display_slug: linked_post.slug, + language: normalize_language(linked_post.language, main_language) + } + end + end + + def language_prefix(language, main_language) when language == main_language, do: "" + def language_prefix(nil, _main_language), do: "" + def language_prefix(language, _main_language), do: "/#{language}" + + def normalize_language(nil, fallback), do: fallback + def normalize_language("", fallback), do: fallback + + def normalize_language(language, _fallback) do + language + |> to_string() + |> String.downcase() + |> String.split("-", parts: 2) + |> hd() + end +end diff --git a/lib/bds/rendering/list_archive.ex b/lib/bds/rendering/list_archive.ex new file mode 100644 index 0000000..dada25a --- /dev/null +++ b/lib/bds/rendering/list_archive.ex @@ -0,0 +1,295 @@ +defmodule BDS.Rendering.ListArchive do + @moduledoc false + + alias BDS.I18n + alias BDS.Persistence + alias BDS.Rendering.LinksAndLanguages + alias BDS.Rendering.Metadata, as: RenderMetadata + alias BDS.Rendering.PostRendering + alias BDS.Rendering.TemplateSelection + + def list_assigns(project_id, assigns) do + metadata = RenderMetadata.project_metadata(project_id) + template_context = TemplateSelection.template_render_context(project_id) + + language = + Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) + + main_language = metadata.main_language || language + archive_context = Map.get(assigns, :archive_context, Map.get(assigns, "archive_context", %{})) + + canonical_post_paths = LinksAndLanguages.canonical_post_path_by_slug(project_id, main_language) + canonical_media_paths = LinksAndLanguages.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) + + day_blocks = build_day_blocks(posts) + min_date = min_date(posts) + max_date = max_date(posts) + normalized_archive_context = normalize_archive_context(archive_context) + + %{ + language: language, + language_prefix: + Map.get( + assigns, + :language_prefix, + Map.get(assigns, "language_prefix", LinksAndLanguages.language_prefix(language, main_language)) + ), + page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title")), + posts: posts, + pico_stylesheet_href: + Map.get( + assigns, + :pico_stylesheet_href, + Map.get(assigns, "pico_stylesheet_href", RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)) + ), + html_theme_attribute: + Map.get( + assigns, + :html_theme_attribute, + Map.get(assigns, "html_theme_attribute") + ), + blog_languages: RenderMetadata.blog_languages(metadata, language), + alternate_links: [], + menu_items: RenderMetadata.menu_items(project_id), + calendar_initial_year: calendar_initial_year_from_posts(posts), + calendar_initial_month: calendar_initial_month_from_posts(posts), + archive_context: normalized_archive_context, + show_archive_range_heading: show_archive_range_heading?(normalized_archive_context, day_blocks), + min_date: min_date, + max_date: max_date, + is_list_page: true, + is_first_page: pagination.current_page <= 1, + is_last_page: pagination.current_page >= pagination.total_pages, + has_prev_page: pagination.has_prev_page, + has_next_page: pagination.has_next_page, + prev_page_href: pagination.prev_page_href, + next_page_href: pagination.next_page_href, + current_page: pagination.current_page, + total_pages: pagination.total_pages, + total_items: pagination.total_items, + items_per_page: pagination.items_per_page, + canonical_post_path_by_slug: canonical_post_paths, + canonical_media_path_by_source_path: canonical_media_paths, + post_data_json_by_id: + Enum.into(posts, %{}, fn post -> {post.id, PostRendering.post_data_json_value(post)} end), + day_blocks: day_blocks + } + end + + def not_found_assigns(project_id, assigns) do + metadata = RenderMetadata.project_metadata(project_id) + + language = + Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) + + main_language = metadata.main_language || language + + %{ + page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title", "404")), + language: language, + language_prefix: + Map.get( + assigns, + :language_prefix, + Map.get(assigns, "language_prefix", LinksAndLanguages.language_prefix(language, main_language)) + ), + pico_stylesheet_href: + Map.get( + assigns, + :pico_stylesheet_href, + Map.get(assigns, "pico_stylesheet_href", RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)) + ), + html_theme_attribute: + Map.get( + assigns, + :html_theme_attribute, + Map.get(assigns, "html_theme_attribute") + ), + blog_languages: RenderMetadata.blog_languages(metadata, language), + menu_items: RenderMetadata.menu_items(project_id), + alternate_links: [], + not_found_message: + Map.get( + assigns, + :not_found_message, + Map.get( + assigns, + "not_found_message", + I18n.translate(language, "render.notFound.message") + ) + ), + not_found_back_label: + Map.get( + assigns, + :not_found_back_label, + Map.get( + assigns, + "not_found_back_label", + I18n.translate(language, "render.notFound.back") + ) + ) + } + end + + defp normalize_list_posts(posts, canonical_post_paths, canonical_media_paths, language, template_context) do + Enum.map(posts, fn post -> + post_record = PostRendering.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: + PostRendering.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))), + language: + Map.get( + post, + :language, + Map.get(post, "language", Map.get(post_record || %{}, :language)) + ), + published_at: + Map.get(post, :published_at, Map.get(post, "published_at", Map.get(post_record || %{}, :published_at))), + created_at: + Map.get(post, :created_at, Map.get(post, "created_at", Map.get(post_record || %{}, :created_at))), + updated_at: + Map.get(post, :updated_at, Map.get(post, "updated_at", Map.get(post_record || %{}, :updated_at))), + tags: Map.get(post, :tags, Map.get(post, "tags", Map.get(post_record || %{}, :tags, []))) || [], + categories: + Map.get(post, :categories, Map.get(post, "categories", Map.get(post_record || %{}, :categories, []))) || [], + template_slug: + Map.get(post, :template_slug, Map.get(post, "template_slug", Map.get(post_record || %{}, :template_slug))), + do_not_translate: + Map.get(post, :do_not_translate, Map.get(post, "do_not_translate", Map.get(post_record || %{}, :do_not_translate, false))), + href: Map.get(post, :href, Map.get(post, "href")), + show_title: true, + linked_media: [], + outgoing_links: [], + incoming_links: [] + } + end) + end + + defp normalize_pagination(nil, posts) do + total_items = length(posts) + + %{ + current_page: 1, + total_pages: 1, + total_items: total_items, + items_per_page: total_items, + has_prev_page: false, + prev_page_href: "", + has_next_page: false, + next_page_href: "" + } + end + + defp normalize_pagination(%{} = pagination, posts) do + total_items = + Map.get(pagination, :total_items, Map.get(pagination, "total_items", length(posts))) + + items_per_page = + Map.get(pagination, :items_per_page, Map.get(pagination, "items_per_page", total_items)) + + %{ + current_page: Map.get(pagination, :current_page, Map.get(pagination, "current_page", 1)), + total_pages: Map.get(pagination, :total_pages, Map.get(pagination, "total_pages", 1)), + total_items: total_items, + items_per_page: items_per_page, + has_prev_page: + Map.get(pagination, :has_prev_page, Map.get(pagination, "has_prev_page", false)), + prev_page_href: + Map.get(pagination, :prev_page_href, Map.get(pagination, "prev_page_href", "")), + has_next_page: + Map.get(pagination, :has_next_page, Map.get(pagination, "has_next_page", false)), + next_page_href: + Map.get(pagination, :next_page_href, Map.get(pagination, "next_page_href", "")) + } + end + + defp normalize_archive_context(nil), do: nil + + defp normalize_archive_context(%{} = archive_context) do + %{ + kind: Map.get(archive_context, :kind, Map.get(archive_context, "kind")), + name: Map.get(archive_context, :name, Map.get(archive_context, "name")), + month: Map.get(archive_context, :month, Map.get(archive_context, "month")), + year: Map.get(archive_context, :year, Map.get(archive_context, "year")), + day: Map.get(archive_context, :day, Map.get(archive_context, "day")) + } + end + + defp build_day_blocks(posts) do + grouped_blocks = + posts + |> Enum.filter(&is_integer(Map.get(&1, :created_at))) + |> Enum.group_by(&(Map.get(&1, :created_at) |> Persistence.from_unix_ms!() |> DateTime.to_date() |> Date.to_iso8601())) + |> Enum.sort_by(fn {label, _posts} -> label end) + + grouped_blocks + |> Enum.with_index() + |> Enum.map(fn {{date_label, grouped_posts}, index} -> + %{ + date_label: date_label, + show_date_marker: true, + show_separator: index < length(grouped_blocks) - 1, + posts: Enum.sort_by(grouped_posts, &Map.get(&1, :created_at)) + } + end) + |> case do + [] -> [%{date_label: "", show_date_marker: false, show_separator: false, posts: posts}] + blocks -> blocks + end + end + + defp min_date(posts) do + posts + |> Enum.map(&Map.get(&1, :created_at)) + |> Enum.filter(&is_integer/1) + |> Enum.min(fn -> nil end) + end + + defp max_date(posts) do + posts + |> Enum.map(&Map.get(&1, :created_at)) + |> Enum.filter(&is_integer/1) + |> Enum.max(fn -> nil end) + end + + defp show_archive_range_heading?(%{kind: "date"}, _day_blocks), do: true + defp show_archive_range_heading?(_archive_context, _day_blocks), do: false + + defp calendar_initial_year_from_posts([post | _rest]), do: RenderMetadata.calendar_initial_year(post) + defp calendar_initial_year_from_posts([]), do: nil + + defp calendar_initial_month_from_posts([post | _rest]), do: RenderMetadata.calendar_initial_month(post) + defp calendar_initial_month_from_posts([]), do: nil +end diff --git a/lib/bds/rendering/metadata.ex b/lib/bds/rendering/metadata.ex new file mode 100644 index 0000000..4b3156f --- /dev/null +++ b/lib/bds/rendering/metadata.ex @@ -0,0 +1,113 @@ +defmodule BDS.Rendering.Metadata do + @moduledoc false + + import Ecto.Query + + alias BDS.I18n + alias BDS.Menu + alias BDS.Metadata, as: ProjectMetadata + alias BDS.Persistence + alias BDS.PreviewAssets + alias BDS.Rendering.LinksAndLanguages + alias BDS.Repo + alias BDS.Posts.Post + alias BDS.Posts.Translation + alias BDS.Tags.Tag + + def project_metadata(project_id) do + {:ok, metadata} = ProjectMetadata.get_project_metadata(project_id) + metadata + end + + def menu_items(project_id) do + {:ok, %{items: items}} = Menu.get_menu(project_id) + Enum.map(items, &to_template_menu_item/1) + end + + defp to_template_menu_item(item) do + kind = Map.get(item, :kind) + children = Enum.map(Map.get(item, :children, []), &to_template_menu_item/1) + + %{ + title: Map.get(item, :label, ""), + href: menu_item_href(item), + has_children: children != [], + children: children, + kind: kind + } + end + + defp menu_item_href(%{kind: :home}), do: "/" + + defp menu_item_href(%{kind: :page, slug: slug}) when is_binary(slug) and slug != "", + do: "/#{slug}/" + + defp menu_item_href(%{kind: :category_archive, slug: slug}) when is_binary(slug) and slug != "", + do: "/category/#{URI.encode(slug)}/" + + defp menu_item_href(%{kind: :submenu}), do: "#" + defp menu_item_href(_item), do: "#" + + def blog_languages(metadata, current_language) do + ([metadata.main_language] ++ (metadata.blog_languages || [])) + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.uniq() + |> Enum.map(fn language -> + normalized = I18n.normalize_language(language) + href_prefix = LinksAndLanguages.language_prefix(normalized, metadata.main_language || current_language) + + %{ + code: normalized, + flag: I18n.flag(normalized), + href: href_for_language(href_prefix), + href_prefix: href_prefix, + is_current: normalized == I18n.normalize_language(current_language) + } + end) + end + + def tag_color_by_name(project_id) do + Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: {tag.name, tag.color}) + |> Enum.into(%{}, fn {name, color} -> {name, color} end) + end + + def alternate_links(nil, _project_id, _main_language), do: [] + + def alternate_links(%Post{} = post, project_id, main_language) do + translations = + Repo.all( + from translation in Translation, + where: + translation.project_id == ^project_id and + translation.translation_for == ^post.id and + translation.status == :published, + order_by: [asc: translation.language] + ) + + [%{href: LinksAndLanguages.post_path(post, nil), hreflang: LinksAndLanguages.normalize_language(post.language, main_language)}] ++ + Enum.map(translations, fn translation -> + %{href: LinksAndLanguages.post_path(post, translation.language, main_language), hreflang: translation.language} + end) + end + + def backlinks(incoming_links) do + Enum.map(incoming_links, fn link -> + %{path: link.href, display_slug: link.display_slug, title: link.title} + end) + end + + def default_pico_stylesheet_href(theme), do: PreviewAssets.stylesheet_href(theme) + + def href_for_language(""), do: "/" + def href_for_language(prefix), do: prefix <> "/" + + def calendar_initial_year(%{created_at: created_at}) when is_integer(created_at), + do: Persistence.from_unix_ms!(created_at).year + + def calendar_initial_year(_post), do: nil + + def calendar_initial_month(%{created_at: created_at}) when is_integer(created_at), + do: Persistence.from_unix_ms!(created_at).month + + def calendar_initial_month(_post), do: nil +end diff --git a/lib/bds/rendering/post_rendering.ex b/lib/bds/rendering/post_rendering.ex new file mode 100644 index 0000000..7398a73 --- /dev/null +++ b/lib/bds/rendering/post_rendering.ex @@ -0,0 +1,167 @@ +defmodule BDS.Rendering.PostRendering do + @moduledoc false + + alias BDS.Rendering.Filters + alias BDS.Rendering.LinksAndLanguages + alias BDS.Rendering.Metadata, as: RenderMetadata + alias BDS.Rendering.TemplateSelection + alias BDS.Posts.Post + alias BDS.Posts.Translation + alias BDS.Repo + + def post_assigns(project_id, assigns) do + metadata = RenderMetadata.project_metadata(project_id) + template_context = TemplateSelection.template_render_context(project_id) + + language = + Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) + + main_language = metadata.main_language || language + post_record = load_post_record(assigns) + canonical_post = canonical_post_record(post_record) + post_id = canonical_post_id(post_record, assigns) + post_categories = Map.get(post_record || %{}, :categories, []) || [] + post_tags = Map.get(post_record || %{}, :tags, []) || [] + canonical_post_paths = LinksAndLanguages.canonical_post_path_by_slug(project_id, main_language) + canonical_media_paths = LinksAndLanguages.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 = LinksAndLanguages.link_contexts(project_id, post_id, :incoming, main_language) + outgoing_links = LinksAndLanguages.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: + Map.get( + assigns, + :language_prefix, + Map.get(assigns, "language_prefix", LinksAndLanguages.language_prefix(language, main_language)) + ), + page_title: + Map.get( + assigns, + :page_title, + Map.get(assigns, "page_title", Map.get(assigns, :title, Map.get(assigns, "title"))) + ), + pico_stylesheet_href: + Map.get( + assigns, + :pico_stylesheet_href, + Map.get(assigns, "pico_stylesheet_href", RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)) + ), + html_theme_attribute: + Map.get( + assigns, + :html_theme_attribute, + Map.get(assigns, "html_theme_attribute") + ), + blog_languages: RenderMetadata.blog_languages(metadata, language), + alternate_links: RenderMetadata.alternate_links(canonical_post, project_id, main_language), + menu_items: RenderMetadata.menu_items(project_id), + calendar_initial_year: RenderMetadata.calendar_initial_year(post_record), + calendar_initial_month: RenderMetadata.calendar_initial_month(post_record), + post_categories: post_categories, + post_tags: post_tags, + tag_color_by_name: RenderMetadata.tag_color_by_name(project_id), + backlinks: RenderMetadata.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(post_assigns, post_record), + post: build_post_context(post_assigns, post_record, incoming_links, outgoing_links) + } + end + + def load_post_record(assigns) do + case Map.get(assigns, :id, Map.get(assigns, "id")) do + nil -> nil + post_id -> Repo.get(Post, post_id) || Repo.get(Translation, post_id) + end + end + + defp canonical_post_record(%Translation{translation_for: post_id}), do: Repo.get(Post, post_id) + defp canonical_post_record(%Post{} = post), do: post + defp canonical_post_record(_other), do: nil + + defp canonical_post_id(%Translation{translation_for: post_id}, _assigns), do: post_id + defp canonical_post_id(%Post{id: post_id}, _assigns), do: post_id + defp canonical_post_id(_post_record, assigns), do: Map.get(assigns, :id, Map.get(assigns, "id")) + + defp post_data_json(assigns, post_record) do + id = Map.get(assigns, :id, Map.get(assigns, "id")) + + if is_binary(id) do + incoming_links = LinksAndLanguages.link_contexts(Map.get(post_record || %{}, :project_id), canonical_post_id(post_record, assigns), :incoming, Map.get(post_record || %{}, :language)) + outgoing_links = LinksAndLanguages.link_contexts(Map.get(post_record || %{}, :project_id), canonical_post_id(post_record, assigns), :outgoing, Map.get(post_record || %{}, :language)) + + %{ + id => post_data_json_value(build_post_context(assigns, post_record, incoming_links, outgoing_links)) + } + else + %{} + end + end + + def post_data_json_value(post_context) do + Jason.encode!(%{ + id: Map.get(post_context, :id), + title: Map.get(post_context, :title), + slug: Map.get(post_context, :slug), + excerpt: Map.get(post_context, :excerpt), + author: Map.get(post_context, :author), + language: Map.get(post_context, :language), + published_at: Map.get(post_context, :published_at), + created_at: Map.get(post_context, :created_at), + updated_at: Map.get(post_context, :updated_at), + tags: Map.get(post_context, :tags, []), + categories: Map.get(post_context, :categories, []) + }) + end + + defp build_post_context(assigns, post_record, incoming_links, outgoing_links) do + %{ + id: Map.get(assigns, :id, Map.get(assigns, "id")), + 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, + :excerpt, + Map.get(assigns, "excerpt", Map.get(post_record || %{}, :excerpt)) + ), + author: Map.get(post_record || %{}, :author), + language: + Map.get( + assigns, + :language, + Map.get(assigns, "language", Map.get(post_record || %{}, :language)) + ), + show_title: true, + published_at: Map.get(post_record || %{}, :published_at), + created_at: Map.get(post_record || %{}, :created_at), + updated_at: Map.get(post_record || %{}, :updated_at), + tags: Map.get(post_record || %{}, :tags, []) || [], + categories: Map.get(post_record || %{}, :categories, []) || [], + template_slug: + Map.get( + post_record || %{}, + :template_slug, + Map.get(assigns, :template_slug, Map.get(assigns, "template_slug")) + ), + do_not_translate: Map.get(post_record || %{}, :do_not_translate, false), + linked_media: [], + outgoing_links: outgoing_links, + incoming_links: incoming_links + } + end + + def 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 +end diff --git a/lib/bds/rendering/template_selection.ex b/lib/bds/rendering/template_selection.ex new file mode 100644 index 0000000..dc002eb --- /dev/null +++ b/lib/bds/rendering/template_selection.ex @@ -0,0 +1,153 @@ +defmodule BDS.Rendering.TemplateSelection do + @moduledoc false + + import Ecto.Query + + alias BDS.Frontmatter + alias BDS.Projects + alias BDS.Rendering.FileSystem + alias BDS.Rendering.Filters + alias BDS.Repo + alias BDS.StarterTemplates + alias BDS.Templates.Template + + def load_template_source(project_id, kind, slug) do + project = Projects.get_project!(project_id) + + case select_template(project_id, kind, slug) do + %Template{} = 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) + end + end + + defp select_template(project_id, kind, slug) when is_binary(slug) and slug != "" do + Repo.one( + from template in Template, + where: + template.project_id == ^project_id and template.kind == ^kind and + template.status == :published and + template.enabled == true and template.slug == ^slug, + limit: 1 + ) + 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, + where: + template.project_id == ^project_id and template.kind == ^kind and + template.status == :published and + template.enabled == true, + order_by: [desc: template.created_at, desc: template.slug], + limit: 1 + ) + end + + defp published_template_body(%Template{content: content}) when is_binary(content), + do: {:ok, content} + + defp published_template_body(%Template{} = template) do + project = Projects.get_project!(template.project_id) + full_path = Path.join(Projects.project_data_dir(project), template.file_path) + + case File.read(full_path) do + {:ok, contents} -> + case Frontmatter.parse_document(contents) do + {:ok, %{body: body}} -> {:ok, body} + {:error, reason} -> {:error, reason} + end + + {:error, reason} -> + {:error, reason} + end + end + + def render_template(project_id, source, assigns) do + with {:ok, template_ast} <- Liquex.parse(source) do + project = Projects.get_project!(project_id) + + context = + Liquex.Context.new(assigns, + static_environment: assigns, + filter_module: Filters, + file_system: FileSystem.new(StarterTemplates.template_roots(project)) + ) + + {result, _context} = Liquex.render!(template_ast, context) + {:ok, IO.iodata_to_binary(result)} + end + rescue + error -> {:error, error} + end + + defp load_bundled_template_source(project, kind, slug) do + desired_slug = bundled_template_slug(kind, slug) + + if is_binary(desired_slug) do + file_system = project |> StarterTemplates.template_roots() |> FileSystem.new() + source = Liquex.FileSystem.read_template_file(file_system, desired_slug) + + case Frontmatter.parse_document(source) do + {:ok, %{body: body}} -> {:ok, body} + {:error, :invalid_frontmatter} -> {:ok, source} + end + else + {:error, :template_not_found} + end + rescue + error in [Liquex.Error] -> + _ = error + {: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) + + def 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 +end