diff --git a/lib/bds/generation.ex b/lib/bds/generation.ex index 4b181fa..aaf762f 100644 --- a/lib/bds/generation.ex +++ b/lib/bds/generation.ex @@ -8,9 +8,11 @@ defmodule BDS.Generation do alias BDS.Posts.Post alias BDS.Posts.Translation alias BDS.Projects + alias BDS.Rendering alias BDS.Repo + alias BDS.Slug - @core_sections [:core, :single] + @core_sections [:core, :single, :category, :tag, :date] def plan_generation(project_id, sections \\ [:core]) when is_binary(project_id) and is_list(sections) do project = Projects.get_project!(project_id) @@ -25,6 +27,7 @@ defmodule BDS.Generation do language: metadata.main_language, blog_languages: normalize_blog_languages(metadata.main_language, metadata.blog_languages), max_posts_per_page: metadata.max_posts_per_page, + categories: metadata.categories, pico_theme: metadata.pico_theme, sections: normalize_sections(sections), generated_files: generated_files @@ -139,8 +142,11 @@ defmodule BDS.Generation do [] end + archive_outputs = + build_archive_outputs(plan, published_posts) + urls = - core_outputs ++ single_outputs + core_outputs ++ single_outputs ++ archive_outputs |> Enum.map(fn {relative_path, _content} -> url_for_output(plan.base_url, relative_path) end) sitemap = @@ -150,22 +156,120 @@ defmodule BDS.Generation do [] end - core_outputs ++ single_outputs ++ sitemap + core_outputs ++ single_outputs ++ archive_outputs ++ sitemap + end + + defp build_archive_outputs(plan, published_posts) do + languages = plan.blog_languages + + category_outputs = + if :category in plan.sections do + build_category_outputs(plan, published_posts, languages) + else + [] + end + + tag_outputs = + if :tag in plan.sections do + build_tag_outputs(plan, published_posts, languages) + else + [] + end + + date_outputs = + if :date in plan.sections do + build_date_outputs(plan, published_posts, languages) + else + [] + end + + category_outputs ++ tag_outputs ++ date_outputs + end + + defp build_category_outputs(plan, published_posts, languages) do + category_posts = + published_posts + |> Enum.flat_map(fn post -> Enum.map(post.categories || [], &{&1, post}) end) + |> Enum.group_by(fn {category, _post} -> category end, fn {_category, post} -> post end) + + Enum.flat_map(category_posts, fn {category, posts} -> + paginated_posts = Enum.chunk_every(posts, max(plan.max_posts_per_page, 1)) + category_slug = Slug.slugify(category) + + Enum.with_index(paginated_posts, 1) + |> Enum.flat_map(fn {page_posts, page_number} -> + Enum.map(languages, fn language -> + { + archive_path(route_language(plan.language, language), ["category", category_slug], page_number), + render_archive_page(plan, category, page_posts, language, "category") + } + end) + end) + end) + end + + defp build_tag_outputs(plan, published_posts, languages) do + tag_posts = + published_posts + |> Enum.flat_map(fn post -> Enum.map(post.tags || [], &{&1, post}) end) + |> Enum.group_by(fn {tag, _post} -> tag end, fn {_tag, post} -> post end) + + Enum.flat_map(tag_posts, fn {tag, posts} -> + tag_slug = Slug.slugify(tag) + + Enum.map(languages, fn language -> + { + archive_path(route_language(plan.language, language), ["tag", tag_slug], 1), + render_archive_page(plan, tag, posts, language, "tag") + } + end) + end) + end + + defp build_date_outputs(plan, published_posts, languages) do + years = Enum.group_by(published_posts, &year_key(&1.created_at)) + months = Enum.group_by(published_posts, &month_key(&1.created_at)) + + year_outputs = + Enum.flat_map(years, fn {year, posts} -> + Enum.map(languages, fn language -> + { + archive_path(route_language(plan.language, language), [year], 1), + render_date_archive_page(plan, year, posts, language) + } + end) + end) + + month_outputs = + Enum.flat_map(months, fn {{year, month}, posts} -> + Enum.map(languages, fn language -> + { + archive_path(route_language(plan.language, language), [year, month], 1), + render_date_archive_page(plan, "#{year}-#{month}", posts, language) + } + end) + end) + + year_outputs ++ month_outputs end defp build_core_outputs(plan, published_posts) do language = plan.language additional_languages = Enum.reject(plan.blog_languages, &(&1 == language)) + main_posts = build_list_posts(plan.base_url, published_posts, nil) [ - {"index.html", render_home(plan, language)}, + {"index.html", render_list_output(plan, language, plan.project_name, main_posts, %{kind: "core"}, fn -> render_home(plan, language) end)}, {"feed.xml", render_feed(plan, language, published_posts)}, {"atom.xml", render_atom(plan, language, published_posts)}, {"calendar.json", render_calendar(published_posts)} ] ++ Enum.flat_map(additional_languages, fn localized_language -> + localized_prefix = route_language(plan.language, localized_language) + localized_posts = build_list_posts(plan.base_url, published_posts, localized_prefix) + [ - {Path.join(localized_language, "index.html"), render_home(plan, localized_language)}, + {Path.join(localized_language, "index.html"), render_list_output(plan, localized_language, plan.project_name, localized_posts, %{kind: "core"}, fn -> render_home(plan, localized_language) end)}, {Path.join(localized_language, "feed.xml"), render_feed(plan, localized_language, published_posts)}, {Path.join(localized_language, "atom.xml"), render_atom(plan, localized_language, published_posts)} ] @@ -175,7 +279,12 @@ defmodule BDS.Generation do defp build_single_outputs(project_id, published_posts, published_translations, post_by_id) do post_outputs = Enum.map(published_posts, fn post -> - {post_output_path(post), render_post_page(post.title, load_body(project_id, post.file_path, post.content), post.slug, post.language)} + body = load_body(project_id, post.file_path, post.content) + + {post_output_path(post), + render_post_output(project_id, post.template_slug, %{id: post.id, title: post.title, content: body, slug: post.slug, language: post.language, excerpt: post.excerpt}, fn -> + render_post_page(post.title, body, post.slug, post.language) + end)} end) translation_outputs = @@ -185,9 +294,13 @@ defmodule BDS.Generation do [] post -> + body = load_body(project_id, translation.file_path, translation.content) + [ {post_output_path(post, translation.language), - render_post_page(translation.title, load_body(project_id, translation.file_path, translation.content), post.slug, translation.language)} + render_post_output(project_id, post.template_slug, %{id: translation.id, title: translation.title, content: body, slug: post.slug, language: translation.language, excerpt: translation.excerpt}, fn -> + render_post_page(translation.title, body, post.slug, translation.language) + end)} ] end end) @@ -221,6 +334,20 @@ defmodule BDS.Generation do end end + defp archive_path(language, segments, 1), do: archive_path(language, segments) + + defp archive_path(language, segments, page_number) do + archive_path(language, segments ++ ["page", Integer.to_string(page_number)]) + end + + defp archive_path(nil, segments), do: Path.join(segments ++ ["index.html"]) + defp archive_path("", segments), do: Path.join(segments ++ ["index.html"]) + + defp archive_path(language, segments) do + prefix = if language in [nil, ""], do: [], else: [language] + Path.join(prefix ++ segments ++ ["index.html"]) + end + defp normalize_base_url(nil), do: nil defp normalize_base_url(url), do: String.trim_trailing(url, "/") @@ -230,6 +357,9 @@ defmodule BDS.Generation do |> Enum.uniq() end + defp route_language(main_language, language) when main_language == language, do: nil + defp route_language(_main_language, language), do: language + defp render_home(plan, language) do [ "", @@ -302,6 +432,70 @@ defmodule BDS.Generation do |> IO.iodata_to_binary() end + defp render_archive_page(plan, title, posts, language, kind) do + fallback = fn -> + items = + posts + |> Enum.map(fn post -> ["
  • ", post.title, "
  • "] end) + |> IO.iodata_to_binary() + + [ + "

    ", + title, + "

    " + ] + |> IO.iodata_to_binary() + end + + render_list_output( + plan, + language, + title, + Enum.map(posts, fn post -> + %{title: post.title, href: "#", excerpt: post.excerpt, content: nil} + end), + %{kind: kind, name: title}, + fallback + ) + end + + defp render_date_archive_page(plan, label, posts, language) do + fallback = fn -> + items = + posts + |> Enum.map(fn post -> ["
  • ", post.title, "
  • "] end) + |> IO.iodata_to_binary() + + [ + "

    ", + label, + "

    " + ] + |> IO.iodata_to_binary() + end + + render_list_output( + plan, + language, + label, + Enum.map(posts, fn post -> + %{title: post.title, href: "#", excerpt: post.excerpt, content: nil} + end), + %{kind: "date", name: label}, + fallback + ) + end + defp load_body(_project_id, _file_path, inline_content) when is_binary(inline_content), do: inline_content defp load_body(project_id, file_path, _inline_content) do @@ -324,6 +518,58 @@ defmodule BDS.Generation do end end + defp year_key(created_at) do + created_at + |> DateTime.from_unix!() + |> Map.fetch!(:year) + |> Integer.to_string() + end + + defp month_key(created_at) do + datetime = DateTime.from_unix!(created_at) + {Integer.to_string(datetime.year), Integer.to_string(datetime.month) |> String.pad_leading(2, "0")} + end + + defp build_list_posts(base_url, posts, language_prefix) do + Enum.map(posts, fn post -> + %{ + id: post.id, + slug: post.slug, + title: post.title, + href: url_for_output(base_url, post_output_path(post, language_prefix)), + excerpt: post.excerpt, + content: load_body(post.project_id, post.file_path, post.content) + } + end) + end + + defp render_post_output(project_id, template_slug, assigns, fallback) do + case Rendering.render_post_page(project_id, template_slug, assigns) do + {:ok, rendered} -> rendered + {:error, _reason} -> fallback.() + end + end + + defp render_list_output(%{project_id: project_id, language: main_language}, language, page_title, posts, archive_context, fallback) + when is_binary(project_id) do + case Rendering.render_list_page(project_id, %{ + language: language, + language_prefix: language_prefix(language, main_language), + page_title: page_title, + posts: posts, + archive_context: archive_context + }) do + {:ok, rendered} -> rendered + {:error, _reason} -> fallback.() + end + end + + defp render_list_output(_plan, _language, _page_title, _posts, _archive_context, fallback), do: fallback.() + + 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 url_for_output(nil, relative_path), do: "/" <> String.trim_leading(relative_path, "/") defp url_for_output(base_url, relative_path) do diff --git a/lib/bds/preview.ex b/lib/bds/preview.ex index 1b4e85f..bcd13af 100644 --- a/lib/bds/preview.ex +++ b/lib/bds/preview.ex @@ -5,6 +5,7 @@ defmodule BDS.Preview do alias BDS.Posts alias BDS.Projects + alias BDS.Rendering @host "127.0.0.1" @port 4123 @@ -15,7 +16,7 @@ defmodule BDS.Preview do def start_preview(project_id) when is_binary(project_id) do project = Projects.get_project!(project_id) - GenServer.call(__MODULE__, {:start_preview, project_id, Projects.project_data_dir(project)}) + GenServer.call(__MODULE__, {:start_preview, project_id, Projects.project_data_dir(project), self()}) end def stop_preview(project_id) when is_binary(project_id) do @@ -29,7 +30,22 @@ defmodule BDS.Preview do def preview_draft(project_id, request_path, post_id) when is_binary(project_id) and is_binary(request_path) and is_binary(post_id) do post = Posts.get_post!(post_id) - GenServer.call(__MODULE__, {:preview_draft, project_id, request_path, %{title: post.title, body: post.content || ""}}) + + GenServer.call(__MODULE__, { + :preview_draft, + project_id, + request_path, + %{ + id: post.id, + title: post.title, + content: post.content || "", + body: post.content || "", + slug: post.slug, + language: post.language, + excerpt: post.excerpt, + template_slug: post.template_slug + } + }) end @impl true @@ -38,15 +54,30 @@ defmodule BDS.Preview do end @impl true - def handle_call({:start_preview, project_id, data_dir}, _from, state) do - server = %{project_id: project_id, data_dir: data_dir, host: @host, port: @port, is_running: true} - {:reply, {:ok, server}, %{state | current: server}} + def handle_call({:start_preview, project_id, data_dir, owner_pid}, _from, state) do + state = stop_current_server(state) + maybe_allow_repo(owner_pid) + + {:ok, listener} = :gen_tcp.listen(@port, [:binary, packet: :raw, active: false, reuseaddr: true, ip: {127, 0, 0, 1}]) + acceptor_pid = spawn_link(fn -> accept_loop(listener, project_id) end) + + server = %{ + project_id: project_id, + data_dir: data_dir, + host: @host, + port: @port, + is_running: true, + listener: listener, + acceptor_pid: acceptor_pid + } + + {:reply, {:ok, public_server(server)}, %{state | current: server}} end def handle_call({:stop_preview, project_id}, _from, state) do next_state = if match?(%{project_id: ^project_id}, state.current) do - %{state | current: nil} + stop_current_server(state) else state end @@ -65,9 +96,15 @@ defmodule BDS.Preview do def handle_call({:preview_draft, project_id, _request_path, post}, _from, state) do with :ok <- ensure_running(state.current, project_id) do + body = + case Rendering.render_post_page(project_id, Map.get(post, :template_slug), post) do + {:ok, rendered} -> rendered + {:error, _reason} -> render_draft(post) + end + response = %{ content_type: "text/html", - body: render_draft(post) + body: body } {:reply, {:ok, response}, state} @@ -76,6 +113,26 @@ defmodule BDS.Preview do end end + def handle_call({:http_request, project_id, method, request_path, query_params}, _from, state) do + response = + with :ok <- ensure_running(state.current, project_id), + :ok <- ensure_get(method) do + case query_params["post_id"] do + post_id when is_binary(post_id) -> + if String.starts_with?(request_path, "/draft/") do + resolve_draft_request(project_id, post_id) + else + resolve_request(state.current, request_path) + end + + _other -> + resolve_request(state.current, request_path) + end + end + + {:reply, response, state} + end + defp ensure_running(%{project_id: project_id, is_running: true}, project_id), do: :ok defp ensure_running(_server, _project_id), do: {:error, :not_running} @@ -94,6 +151,37 @@ defmodule BDS.Preview do end end + defp resolve_draft_request(project_id, post_id) do + try do + post = Posts.get_post!(post_id) + + if post.project_id == project_id do + payload = %{ + id: post.id, + title: post.title, + content: post.content || "", + body: post.content || "", + slug: post.slug, + language: post.language, + excerpt: post.excerpt, + template_slug: post.template_slug + } + + body = + case Rendering.render_post_page(project_id, post.template_slug, payload) do + {:ok, rendered} -> rendered + {:error, _reason} -> render_draft(payload) + end + + {:ok, %{content_type: "text/html", body: body}} + else + {:error, :not_found} + end + rescue + Ecto.NoResultsError -> {:error, :not_found} + end + end + defp route_request(request_path) do normalized = request_path |> URI.parse() |> Map.get(:path, "/") segments = String.split(normalized, "/", trim: true) @@ -116,6 +204,125 @@ defmodule BDS.Preview do end end + defp accept_loop(listener, project_id) do + case :gen_tcp.accept(listener) do + {:ok, socket} -> + spawn(fn -> serve_client(socket, project_id) end) + accept_loop(listener, project_id) + + {:error, :closed} -> + :ok + + {:error, _reason} -> + :ok + end + end + + defp serve_client(socket, project_id) do + response = + case :gen_tcp.recv(socket, 0, 5_000) do + {:ok, request} -> + request + |> parse_http_request() + |> handle_http_request(project_id) + + {:error, _reason} -> + http_error_response(400) + end + + :gen_tcp.send(socket, response) + :gen_tcp.close(socket) + end + + defp parse_http_request(request) do + case String.split(request, "\r\n", parts: 2) do + [request_line | _rest] -> + case String.split(request_line, " ", parts: 3) do + [method, target, _version] -> {method, target} + _other -> {:error, :bad_request} + end + + _other -> + {:error, :bad_request} + end + end + + defp handle_http_request({:error, :bad_request}, _project_id), do: http_error_response(400) + + defp handle_http_request({method, target}, project_id) do + uri = URI.parse(target) + path = uri.path || "/" + query_params = URI.decode_query(uri.query || "") + + case GenServer.call(__MODULE__, {:http_request, project_id, method, path, query_params}, 5_000) do + {:ok, response} -> http_ok_response(response) + {:error, :not_found} -> http_error_response(404) + {:error, :not_running} -> http_error_response(503) + {:error, _reason} -> http_error_response(500) + end + end + + defp http_ok_response(response) do + [ + "HTTP/1.1 200 OK\r\n", + "content-type: ", + response.content_type, + "; charset=utf-8\r\n", + "content-length: ", + Integer.to_string(byte_size(response.body)), + "\r\nconnection: close\r\n\r\n", + response.body + ] + |> IO.iodata_to_binary() + end + + defp http_error_response(status_code) do + reason = + case status_code do + 400 -> "Bad Request" + 404 -> "Not Found" + 503 -> "Service Unavailable" + _other -> "Internal Server Error" + end + + body = reason + + [ + "HTTP/1.1 ", + Integer.to_string(status_code), + " ", + reason, + "\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: ", + Integer.to_string(byte_size(body)), + "\r\nconnection: close\r\n\r\n", + body + ] + |> IO.iodata_to_binary() + end + + defp ensure_get("GET"), do: :ok + defp ensure_get(_method), do: {:error, :not_found} + + defp maybe_allow_repo(owner_pid) do + try do + Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, owner_pid, self()) + rescue + _error -> :ok + end + end + + defp stop_current_server(%{current: %{listener: listener, acceptor_pid: acceptor_pid}} = state) do + _ = :gen_tcp.close(listener) + if is_pid(acceptor_pid), do: Process.exit(acceptor_pid, :normal) + %{state | current: nil} + end + + defp stop_current_server(state), do: state + + defp public_server(server) do + Map.take(server, [:project_id, :host, :port, :is_running]) + end + defp safe_join(root, relative_path) do expanded_root = Path.expand(root) expanded_path = Path.expand(relative_path, root) diff --git a/lib/bds/projects.ex b/lib/bds/projects.ex index eab765a..6e56dc8 100644 --- a/lib/bds/projects.ex +++ b/lib/bds/projects.ex @@ -6,7 +6,9 @@ defmodule BDS.Projects do alias Ecto.Multi alias BDS.Projects.Project alias BDS.Repo + alias BDS.StarterTemplates alias BDS.Slug + alias BDS.Templates @default_project_id "default" @default_project_name "My Blog" @@ -27,18 +29,29 @@ defmodule BDS.Projects do now = System.system_time(:second) is_active = not Repo.exists?(from project in Project, where: project.is_active == true) - %Project{} - |> Project.changeset(%{ - id: @default_project_id, - name: @default_project_name, - slug: unique_slug(Slug.slugify(@default_project_name)), - description: nil, - data_path: nil, - created_at: now, - updated_at: now, - is_active: is_active - }) - |> Repo.insert() + Repo.transaction(fn -> + project = + %Project{} + |> Project.changeset(%{ + id: @default_project_id, + name: @default_project_name, + slug: unique_slug(Slug.slugify(@default_project_name)), + description: nil, + data_path: nil, + created_at: now, + updated_at: now, + is_active: is_active + }) + |> Repo.insert!() + + :ok = StarterTemplates.install(project) + {:ok, _templates} = Templates.rebuild_templates_from_files(project.id) + project + end) + |> case do + {:ok, project} -> {:ok, project} + {:error, reason} -> {:error, reason} + end end end @@ -51,18 +64,29 @@ defmodule BDS.Projects do name = attr(attrs, :name) || "" slug = unique_slug(attr(attrs, :slug) || Slug.slugify(name)) - %Project{} - |> Project.changeset(%{ - id: Ecto.UUID.generate(), - name: name, - slug: slug, - description: attr(attrs, :description), - data_path: attr(attrs, :data_path), - created_at: now, - updated_at: now, - is_active: false - }) - |> Repo.insert() + Repo.transaction(fn -> + project = + %Project{} + |> Project.changeset(%{ + id: Ecto.UUID.generate(), + name: name, + slug: slug, + description: attr(attrs, :description), + data_path: attr(attrs, :data_path), + created_at: now, + updated_at: now, + is_active: false + }) + |> Repo.insert!() + + :ok = StarterTemplates.install(project) + {:ok, _templates} = Templates.rebuild_templates_from_files(project.id) + project + end) + |> case do + {:ok, project} -> {:ok, project} + {:error, reason} -> {:error, reason} + end end def set_active_project(project_id) do diff --git a/lib/bds/publishing.ex b/lib/bds/publishing.ex index 841ff06..d1b2350 100644 --- a/lib/bds/publishing.ex +++ b/lib/bds/publishing.ex @@ -43,7 +43,7 @@ defmodule BDS.Publishing do def handle_call({:upload_site, project_id, credentials, targets, opts}, _from, state) do job_id = "publish-" <> Integer.to_string(System.unique_integer([:positive, :monotonic])) - uploader = Keyword.get(opts, :uploader, fn _target, _files, _credentials -> :ok end) + uploader = build_uploader(opts) job = %{ id: job_id, @@ -99,6 +99,74 @@ defmodule BDS.Publishing do GenServer.call(__MODULE__, {:update_job, job_id, attrs}) end + defp build_uploader(opts) do + case Keyword.get(opts, :uploader) do + nil -> + runner = Keyword.get(opts, :command_runner, &System.cmd/3) + ssh_auth_sock = Keyword.get(opts, :ssh_auth_sock, System.get_env("SSH_AUTH_SOCK")) + + fn target, files, credentials -> + run_command_upload(target, files, credentials, runner, ssh_auth_sock) + end + + uploader -> + uploader + end + end + + defp run_command_upload(target, _files, %{ssh_mode: :rsync} = credentials, runner, ssh_auth_sock) do + args = + ["--update", "--compress", "--verbose"] ++ + rsync_excludes(target) ++ + ["-e", "ssh", ensure_trailing_slash(target.local_dir), remote_dir_spec(credentials, target.remote_dir)] + + run_command(runner, "rsync", args, ssh_auth_sock) + end + + defp run_command_upload(target, files, credentials, runner, ssh_auth_sock) do + Enum.reduce_while(files, :ok, fn relative_path, :ok -> + local_path = Path.join(target.local_dir, relative_path) + remote_path = remote_file_spec(credentials, target.remote_dir, relative_path) + + case run_command(runner, "scp", ["-q", local_path, remote_path], ssh_auth_sock) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp run_command(runner, command, args, ssh_auth_sock) do + opts = command_opts(ssh_auth_sock) + {output, exit_status} = runner.(command, args, opts) + + if exit_status == 0 do + :ok + else + {:error, normalize_command_error(command, output, exit_status)} + end + end + + defp command_opts(nil), do: [stderr_to_stdout: true] + defp command_opts(ssh_auth_sock), do: [stderr_to_stdout: true, env: [{"SSH_AUTH_SOCK", ssh_auth_sock}]] + + defp normalize_command_error(_command, output, _status) when is_binary(output) and output != "", do: output + defp normalize_command_error(command, _output, status), do: "#{command} exited with status #{status}" + + defp rsync_excludes(%{kind: :media}), do: ["--exclude=*.meta"] + defp rsync_excludes(_target), do: [] + + defp ensure_trailing_slash(path), do: String.trim_trailing(path, "/") <> "/" + + defp remote_dir_spec(credentials, remote_dir) do + remote_base(credentials) <> ":" <> ensure_trailing_slash(remote_dir) + end + + defp remote_file_spec(credentials, remote_dir, relative_path) do + remote_base(credentials) <> ":" <> Path.join(remote_dir, relative_path) + end + + defp remote_base(credentials), do: "#{credentials.ssh_user}@#{credentials.ssh_host}" + defp build_upload_targets(base_dir, credentials) do remote_root = String.trim_trailing(credentials.ssh_remote_path, "/") diff --git a/lib/bds/rendering.ex b/lib/bds/rendering.ex new file mode 100644 index 0000000..11c92c8 --- /dev/null +++ b/lib/bds/rendering.ex @@ -0,0 +1,339 @@ +defmodule BDS.Rendering do + @moduledoc false + + import Ecto.Query + + alias BDS.Frontmatter + alias BDS.Rendering.FileSystem + alias BDS.Menu + alias BDS.Metadata + alias BDS.Projects + alias BDS.Rendering.Filters + alias BDS.Rendering.I18n + alias BDS.Repo + alias BDS.Tags.Tag + alias BDS.Posts.Post + alias BDS.Posts.Translation + alias BDS.Templates.Template + + 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), + {:ok, rendered} <- render_template(project_id, template_source, 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), + {:ok, rendered} <- render_template(project_id, template_source, list_assigns(project_id, assigns)) do + {:ok, rendered} + end + end + + defp load_template_source(project_id, kind, slug) do + case select_template(project_id, kind, slug) do + nil -> {:error, :template_not_found} + %Template{} = template -> published_template_body(template) + 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 + ) || select_template(project_id, kind, nil) + 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: [asc: template.created_at, asc: 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) + template_root = Path.join(Projects.project_data_dir(project), "templates") + + context = + Liquex.Context.new(assigns, + static_environment: assigns, + filter_module: Filters, + file_system: FileSystem.new(template_root) + ) + + {result, _context} = Liquex.render!(template_ast, context) + {:ok, IO.iodata_to_binary(result)} + end + rescue + error -> {:error, error} + end + + defp post_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 + post_record = load_post_record(assigns) + post_categories = Map.get(post_record || %{}, :categories, []) || [] + post_tags = Map.get(post_record || %{}, :tags, []) || [] + + %{ + 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: nil, + html_theme_attribute: html_theme_attribute(metadata.pico_theme), + blog_languages: blog_languages(metadata, language), + alternate_links: [], + 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: [], + canonical_post_path_by_slug: canonical_post_path_by_slug(project_id, main_language), + canonical_media_path_by_source_path: %{}, + post_data_json_by_id: post_data_json(assigns), + post: %{ + 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")), + excerpt: Map.get(assigns, :excerpt, Map.get(assigns, "excerpt")), + language: Map.get(assigns, :language, Map.get(assigns, "language")), + show_title: true + } + } + end + + defp list_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 + posts = normalize_list_posts(Map.get(assigns, :posts, Map.get(assigns, "posts", []))) + archive_context = Map.get(assigns, :archive_context, Map.get(assigns, "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: nil, + html_theme_attribute: html_theme_attribute(metadata.pico_theme), + 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: normalize_archive_context(archive_context), + show_archive_range_heading: false, + min_date: nil, + max_date: nil, + is_list_page: true, + is_first_page: true, + is_last_page: true, + has_prev_page: false, + has_next_page: false, + prev_page_href: "", + next_page_href: "", + canonical_post_path_by_slug: canonical_post_path_by_slug(project_id, main_language), + canonical_media_path_by_source_path: %{}, + post_data_json_by_id: Enum.into(posts, %{}, fn post -> {post.id, Jason.encode!(Map.take(post, [:id, :slug, :title, :content]))} end), + day_blocks: [ + %{ + date_label: "", + show_date_marker: false, + show_separator: false, + posts: posts + } + ] + } + end + + defp project_metadata(project_id) do + case Metadata.get_project_metadata(project_id) do + {:ok, metadata} -> metadata + _other -> %{main_language: "en", blog_languages: [], pico_theme: nil} + end + end + + defp menu_items(project_id) do + case Menu.get_menu(project_id) do + {:ok, %{items: items}} -> Enum.map(items, &to_template_menu_item/1) + _other -> [] + end + 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) + + %{ + code: normalized, + flag: I18n.flag(normalized), + href_prefix: language_prefix(normalized, metadata.main_language || current_language), + 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 post_data_json(assigns) do + id = Map.get(assigns, :id, Map.get(assigns, "id")) + + if is_binary(id) do + %{ + id => + Jason.encode!(%{ + id: 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")) + }) + } + else + %{} + end + 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 post_path(post, language_prefix) when is_binary(language_prefix) and language_prefix != "" do + Path.join([String.trim_leading(language_prefix, "/"), post_path(post, nil)]) + end + + defp post_path(post, nil) do + datetime = DateTime.from_unix!(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) do + Enum.map(posts, fn post -> + %{ + 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", "")))), + show_title: true + } + end) + end + + defp normalize_archive_context(nil), do: nil + defp normalize_archive_context(%{} = archive_context), do: archive_context + + defp html_theme_attribute(nil), do: nil + defp html_theme_attribute(""), do: nil + defp html_theme_attribute(theme), do: ~s(data-theme="#{theme}") + + defp calendar_initial_year(%{created_at: created_at}) when is_integer(created_at), do: DateTime.from_unix!(created_at).year + defp calendar_initial_year(_post), do: nil + + defp calendar_initial_month(%{created_at: created_at}) when is_integer(created_at), do: DateTime.from_unix!(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}" +end diff --git a/lib/bds/rendering/file_system.ex b/lib/bds/rendering/file_system.ex new file mode 100644 index 0000000..a9ca641 --- /dev/null +++ b/lib/bds/rendering/file_system.ex @@ -0,0 +1,39 @@ +defmodule BDS.Rendering.FileSystem do + @moduledoc false + + defstruct [:root_path] + + def new(root_path) do + %__MODULE__{root_path: root_path} + end + + def full_path(%__MODULE__{root_path: root_path}, template_path) do + normalized_path = to_string(template_path) + + cond do + normalized_path == "" -> + raise Liquex.Error, message: "Illegal template path '#{template_path}'" + + Path.type(normalized_path) == :absolute -> + raise Liquex.Error, message: "Illegal template path '#{template_path}'" + + String.contains?(normalized_path, "..") -> + raise Liquex.Error, message: "Illegal template path '#{template_path}'" + + true -> + Path.expand(Path.join(root_path, normalized_path <> ".liquid")) + end + end +end + +defimpl Liquex.FileSystem, for: BDS.Rendering.FileSystem do + def read_template_file(file_system, template_path) do + file_system + |> BDS.Rendering.FileSystem.full_path(template_path) + |> File.read() + |> case do + {:ok, contents} -> contents + _error -> raise Liquex.Error, message: "No such template '#{template_path}'" + end + end +end diff --git a/lib/bds/rendering/filters.ex b/lib/bds/rendering/filters.ex new file mode 100644 index 0000000..8579d07 --- /dev/null +++ b/lib/bds/rendering/filters.ex @@ -0,0 +1,23 @@ +defmodule BDS.Rendering.Filters do + @moduledoc false + + use Liquex.Filter + + alias BDS.Rendering.I18n + + def i18n(value, language, _context) do + key = value |> to_string() |> String.trim() + + if key == "" do + "" + else + I18n.translate(language, key) + end + end + + def markdown(value, _post_id, _post_data_json_by_id, _canonical_post_paths, _canonical_media_paths, _language, _language_prefix, _context) do + value + |> to_string() + |> Earmark.as_html!() + end +end diff --git a/lib/bds/rendering/i18n.ex b/lib/bds/rendering/i18n.ex new file mode 100644 index 0000000..b99c7e8 --- /dev/null +++ b/lib/bds/rendering/i18n.ex @@ -0,0 +1,224 @@ +defmodule BDS.Rendering.I18n do + @moduledoc false + + @supported_languages ~w(en de fr it es) + + @flags %{ + "en" => "🇬🇧", + "de" => "🇩🇪", + "fr" => "🇫🇷", + "it" => "🇮🇹", + "es" => "🇪🇸" + } + + @catalog %{ + "en" => %{ + "render.archive" => "Archive", + "render.pagination.label" => "Pagination", + "render.pagination.newer" => "newer", + "render.pagination.older" => "older", + "render.notFound.message" => "The requested preview page could not be found.", + "render.notFound.back" => "Back to preview home", + "render.photoArchive.empty" => "No photos found for this archive.", + "render.gallery.empty" => "No linked images found.", + "render.tagCloud.empty" => "No tags found.", + "render.tagCloud.ariaLabel" => "Tag cloud", + "render.calendar.open" => "Open calendar", + "render.calendar.close" => "Close calendar", + "render.calendar.title" => "Archive calendar", + "render.calendar.loading" => "Loading calendar…", + "render.calendar.error" => "Calendar data could not be loaded.", + "render.taxonomy.ariaLabel" => "Taxonomy", + "render.backlinks.label" => "Linked from", + "render.backlinks.ariaLabel" => "Backlinks", + "render.languageSwitcher.ariaLabel" => "Language", + "render.video.youtubeTitle" => "YouTube video", + "render.video.vimeoTitle" => "Vimeo video", + "render.search.placeholder" => "Search...", + "render.search.ariaLabel" => "Site search", + "render.month.1" => "January", + "render.month.2" => "February", + "render.month.3" => "March", + "render.month.4" => "April", + "render.month.5" => "May", + "render.month.6" => "June", + "render.month.7" => "July", + "render.month.8" => "August", + "render.month.9" => "September", + "render.month.10" => "October", + "render.month.11" => "November", + "render.month.12" => "December" + }, + "de" => %{ + "render.archive" => "Archiv", + "render.pagination.label" => "Seitennummerierung", + "render.pagination.newer" => "neuer", + "render.pagination.older" => "älter", + "render.notFound.message" => "Die angeforderte Vorschauseite konnte nicht gefunden werden.", + "render.notFound.back" => "Zurück zur Vorschau-Startseite", + "render.photoArchive.empty" => "Keine Fotos für dieses Archiv gefunden.", + "render.gallery.empty" => "Keine verknüpften Bilder gefunden.", + "render.tagCloud.empty" => "Keine Tags gefunden.", + "render.tagCloud.ariaLabel" => "Tag-Wolke", + "render.calendar.open" => "Kalender öffnen", + "render.calendar.close" => "Kalender schließen", + "render.calendar.title" => "Archivkalender", + "render.calendar.loading" => "Kalender wird geladen …", + "render.calendar.error" => "Kalenderdaten konnten nicht geladen werden.", + "render.taxonomy.ariaLabel" => "Taxonomie", + "render.backlinks.label" => "Verlinkt von", + "render.backlinks.ariaLabel" => "Rückverweise", + "render.languageSwitcher.ariaLabel" => "Sprache", + "render.video.youtubeTitle" => "YouTube-Video", + "render.video.vimeoTitle" => "Vimeo-Video", + "render.search.placeholder" => "Suchen...", + "render.search.ariaLabel" => "Seitensuche", + "render.month.1" => "Januar", + "render.month.2" => "Februar", + "render.month.3" => "März", + "render.month.4" => "Apr.", + "render.month.5" => "Mai", + "render.month.6" => "Juni", + "render.month.7" => "Juli", + "render.month.8" => "Aug.", + "render.month.9" => "Sept.", + "render.month.10" => "Oktober", + "render.month.11" => "Nov.", + "render.month.12" => "Dezember" + }, + "fr" => %{ + "render.archive" => "Archives", + "render.pagination.label" => "Navigation paginée", + "render.pagination.newer" => "plus récent", + "render.pagination.older" => "plus ancien", + "render.notFound.message" => "La page d’aperçu demandée est introuvable.", + "render.notFound.back" => "Retour à l’accueil de l’aperçu", + "render.photoArchive.empty" => "Aucune photo trouvée pour cette archive.", + "render.gallery.empty" => "Aucune image liée trouvée.", + "render.tagCloud.empty" => "Aucun tag trouvé.", + "render.tagCloud.ariaLabel" => "Nuage de tags", + "render.calendar.open" => "Ouvrir le calendrier", + "render.calendar.close" => "Fermer le calendrier", + "render.calendar.title" => "Calendrier des archives", + "render.calendar.loading" => "Chargement du calendrier…", + "render.calendar.error" => "Impossible de charger les données du calendrier.", + "render.taxonomy.ariaLabel" => "Taxonomie", + "render.backlinks.label" => "Lié depuis", + "render.backlinks.ariaLabel" => "Rétroliens", + "render.languageSwitcher.ariaLabel" => "Langue", + "render.video.youtubeTitle" => "Vidéo YouTube", + "render.video.vimeoTitle" => "Vidéo Vimeo", + "render.search.placeholder" => "Rechercher...", + "render.search.ariaLabel" => "Recherche du site", + "render.month.1" => "janvier", + "render.month.2" => "février", + "render.month.3" => "mars", + "render.month.4" => "avril", + "render.month.5" => "mai", + "render.month.6" => "juin", + "render.month.7" => "juillet", + "render.month.8" => "août", + "render.month.9" => "septembre", + "render.month.10" => "octobre", + "render.month.11" => "novembre", + "render.month.12" => "décembre" + }, + "it" => %{ + "render.archive" => "Archivio", + "render.pagination.label" => "Paginazione", + "render.pagination.newer" => "più recente", + "render.pagination.older" => "più vecchio", + "render.notFound.message" => "La pagina di anteprima richiesta non è stata trovata.", + "render.notFound.back" => "Torna alla home di anteprima", + "render.photoArchive.empty" => "Nessuna foto trovata per questo archivio.", + "render.gallery.empty" => "Nessuna immagine collegata trovata.", + "render.tagCloud.empty" => "Nessun tag trovato.", + "render.tagCloud.ariaLabel" => "Nuvola di tag", + "render.calendar.open" => "Apri calendario", + "render.calendar.close" => "Chiudi calendario", + "render.calendar.title" => "Calendario archivio", + "render.calendar.loading" => "Caricamento calendario…", + "render.calendar.error" => "Impossibile caricare i dati del calendario.", + "render.taxonomy.ariaLabel" => "Tassonomia", + "render.backlinks.label" => "Collegato da", + "render.backlinks.ariaLabel" => "Retrocollegamenti", + "render.languageSwitcher.ariaLabel" => "Lingua", + "render.video.youtubeTitle" => "Video YouTube", + "render.video.vimeoTitle" => "Video Vimeo", + "render.search.placeholder" => "Cerca...", + "render.search.ariaLabel" => "Ricerca nel sito", + "render.month.1" => "gennaio", + "render.month.2" => "febbraio", + "render.month.3" => "marzo", + "render.month.4" => "aprile", + "render.month.5" => "maggio", + "render.month.6" => "giugno", + "render.month.7" => "luglio", + "render.month.8" => "agosto", + "render.month.9" => "settembre", + "render.month.10" => "ottobre", + "render.month.11" => "novembre", + "render.month.12" => "dicembre" + }, + "es" => %{ + "render.archive" => "Archivo", + "render.pagination.label" => "Paginación", + "render.pagination.newer" => "más reciente", + "render.pagination.older" => "más antiguo", + "render.notFound.message" => "No se pudo encontrar la página de vista previa solicitada.", + "render.notFound.back" => "Volver al inicio de vista previa", + "render.photoArchive.empty" => "No se encontraron fotos para este archivo.", + "render.gallery.empty" => "No se encontraron imágenes vinculadas.", + "render.tagCloud.empty" => "No se encontraron etiquetas.", + "render.tagCloud.ariaLabel" => "Nube de etiquetas", + "render.calendar.open" => "Abrir calendario", + "render.calendar.close" => "Cerrar calendario", + "render.calendar.title" => "Calendario de archivo", + "render.calendar.loading" => "Cargando calendario…", + "render.calendar.error" => "No se pudieron cargar los datos del calendario.", + "render.taxonomy.ariaLabel" => "Taxonomía", + "render.backlinks.label" => "Enlazado desde", + "render.backlinks.ariaLabel" => "Retroenlaces", + "render.languageSwitcher.ariaLabel" => "Idioma", + "render.video.youtubeTitle" => "Vídeo de YouTube", + "render.video.vimeoTitle" => "Vídeo de Vimeo", + "render.search.placeholder" => "Buscar...", + "render.search.ariaLabel" => "Buscar en el sitio", + "render.month.1" => "enero", + "render.month.2" => "febrero", + "render.month.3" => "marzo", + "render.month.4" => "abril", + "render.month.5" => "mayo", + "render.month.6" => "junio", + "render.month.7" => "julio", + "render.month.8" => "agosto", + "render.month.9" => "septiembre", + "render.month.10" => "octubre", + "render.month.11" => "noviembre", + "render.month.12" => "diciembre" + } + } + + def normalize_language(language) do + language + |> to_string() + |> String.trim() + |> String.downcase() + |> String.split("-", parts: 2) + |> List.first() + |> case do + value when value in @supported_languages -> value + _other -> "en" + end + end + + def translate(language, key) do + normalized_language = normalize_language(language) + @catalog[normalized_language][key] || @catalog["en"][key] || key + end + + def flag(language) do + normalized_language = normalize_language(language) + Map.get(@flags, normalized_language, String.upcase(normalized_language)) + end +end diff --git a/lib/bds/starter_templates.ex b/lib/bds/starter_templates.ex new file mode 100644 index 0000000..54814e5 --- /dev/null +++ b/lib/bds/starter_templates.ex @@ -0,0 +1,60 @@ +defmodule BDS.StarterTemplates do + @moduledoc false + + alias BDS.Frontmatter + alias BDS.Projects + + @top_level_templates [ + %{file_name: "single-post.liquid", slug: "single-post", title: "Single Post", kind: :post}, + %{file_name: "post-list.liquid", slug: "post-list", title: "Post List", kind: :list}, + %{file_name: "not-found.liquid", slug: "not-found", title: "Not Found", kind: :not_found} + ] + + def install(project) do + source_root = Path.join(Application.app_dir(:bds, "priv/starter_templates"), "templates") + target_root = Path.join(Projects.project_data_dir(project), "templates") + + :ok = File.mkdir_p(target_root) + + source_root + |> list_files() + |> Enum.each(fn source_path -> + relative_path = Path.relative_to(source_path, source_root) + target_path = Path.join(target_root, relative_path) + :ok = File.mkdir_p(Path.dirname(target_path)) + + case Enum.find(@top_level_templates, &(&1.file_name == relative_path)) do + nil -> + File.cp!(source_path, target_path) + + template -> + body = File.read!(source_path) + + File.write!( + target_path, + Frontmatter.serialize_document( + [ + {:id, Ecto.UUID.generate()}, + {:slug, template.slug}, + {:title, template.title}, + {:kind, template.kind}, + {:enabled, true}, + {:version, 1} + ], + body + ) + ) + end + end) + + :ok + end + + defp list_files(root) do + root + |> Path.join("**/*") + |> Path.wildcard(match_dot: true) + |> Enum.reject(&File.dir?/1) + |> Enum.sort() + end +end diff --git a/mix.exs b/mix.exs index 85cc95c..c14ef75 100644 --- a/mix.exs +++ b/mix.exs @@ -25,6 +25,8 @@ defmodule BDS.MixProject do {:ecto_sqlite3, "~> 0.21"}, {:luerl, "~> 1.5"}, {:jason, "~> 1.4"}, + {:earmark, "~> 1.4"}, + {:liquex, "~> 0.13.1"}, {:plug, "~> 1.18"}, {:image, "~> 0.65"}, {:stemex, "~> 0.2.1"} diff --git a/mix.lock b/mix.lock index 24d8f00..c630be1 100644 --- a/mix.lock +++ b/mix.lock @@ -1,18 +1,26 @@ %{ "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "color": {:hex, :color, "0.12.0", "f59f9bb6452a460760d44116ec0c1cf86f9d7707c8756c01f83c6d8fe042ae67", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "1e17768919dad0bd44f48d0daf294d24bdd5a615bbfe0b4e01a51312203bd294"}, + "date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"}, "exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"}, + "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, + "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.4", "271455b4d300d5d53a5d92b5bd1c00ad14c5abf1c9ff87be069af5736496515c", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "12e1754204e7db5df1750df0a5dba1bbdf89260800019ab081f2b046596be56b"}, "image": {:hex, :image, "0.65.0", "44908233a1a0dcdbb6ae873ec09fd9ae533d1840d300d8b0b1b186d586b935e6", [:mix], [{:color, "~> 0.4", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "0.11.0", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.11.0", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "d2060e08d0f42564f49de1ea97a82a5d237f9ac91edb141dece51f1238dd8b4a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"}, + "liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"}, "luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, diff --git a/priv/data/projects/default/templates/macros/gallery.liquid b/priv/data/projects/default/templates/macros/gallery.liquid new file mode 100644 index 0000000..12aea07 --- /dev/null +++ b/priv/data/projects/default/templates/macros/gallery.liquid @@ -0,0 +1,30 @@ + \ No newline at end of file diff --git a/priv/data/projects/default/templates/macros/photo-archive.liquid b/priv/data/projects/default/templates/macros/photo-archive.liquid new file mode 100644 index 0000000..259bb53 --- /dev/null +++ b/priv/data/projects/default/templates/macros/photo-archive.liquid @@ -0,0 +1,33 @@ +
    +
    + {%- if months.size > 0 -%} + {%- for month in months -%} +
    +
    +
    + {{ month.label | escape }} +
    + +
    +
    + {%- endfor -%} + {%- else -%} +
    {{ empty_label | escape }}
    + {%- endif -%} +
    +
    \ No newline at end of file diff --git a/priv/data/projects/default/templates/macros/tag-cloud.liquid b/priv/data/projects/default/templates/macros/tag-cloud.liquid new file mode 100644 index 0000000..75479a1 --- /dev/null +++ b/priv/data/projects/default/templates/macros/tag-cloud.liquid @@ -0,0 +1,19 @@ +
    + {%- if words_json -%} + + {%- else -%} +
    {{ empty_label | escape }}
    + {%- endif -%} +
    \ No newline at end of file diff --git a/priv/data/projects/default/templates/macros/vimeo.liquid b/priv/data/projects/default/templates/macros/vimeo.liquid new file mode 100644 index 0000000..ff14766 --- /dev/null +++ b/priv/data/projects/default/templates/macros/vimeo.liquid @@ -0,0 +1,9 @@ +
    + +
    \ No newline at end of file diff --git a/priv/data/projects/default/templates/macros/youtube.liquid b/priv/data/projects/default/templates/macros/youtube.liquid new file mode 100644 index 0000000..e20b81d --- /dev/null +++ b/priv/data/projects/default/templates/macros/youtube.liquid @@ -0,0 +1,9 @@ +
    + +
    \ No newline at end of file diff --git a/priv/data/projects/default/templates/not-found.liquid b/priv/data/projects/default/templates/not-found.liquid new file mode 100644 index 0000000..da217cd --- /dev/null +++ b/priv/data/projects/default/templates/not-found.liquid @@ -0,0 +1,25 @@ +--- +id: 1ba67928-a8e8-44d7-b5f8-70e654d6cfad +slug: not-found +title: Not Found +kind: not_found +enabled: true +version: 1 +--- + + + {% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %} + +
    +
    +
    +

    404

    + {% assign default_not_found_message = 'render.notFound.message' | i18n: language %} + {% assign default_not_found_back = 'render.notFound.back' | i18n: language %} +

    {{ not_found_message | default: default_not_found_message }}

    +

    {{ not_found_back_label | default: default_not_found_back }}

    +
    +
    +
    + + diff --git a/priv/data/projects/default/templates/partials/head.liquid b/priv/data/projects/default/templates/partials/head.liquid new file mode 100644 index 0000000..4fe5aac --- /dev/null +++ b/priv/data/projects/default/templates/partials/head.liquid @@ -0,0 +1,27 @@ + + + + {{ page_title }} + {% assign resolved_pico_stylesheet_href = pico_stylesheet_href | default: '/assets/pico.min.css' %} + + + + + + {% assign feed_prefix = language_prefix | default: '' %} + + + {% for alternate_link in alternate_links %} + + {% endfor %} + + + + + + + + + + + \ No newline at end of file diff --git a/priv/data/projects/default/templates/partials/language-switcher.liquid b/priv/data/projects/default/templates/partials/language-switcher.liquid new file mode 100644 index 0000000..0cfef43 --- /dev/null +++ b/priv/data/projects/default/templates/partials/language-switcher.liquid @@ -0,0 +1,41 @@ +{% if blog_languages.size > 1 %} + + +{% else %} +
    + + +
    +{% endif %} \ No newline at end of file diff --git a/priv/data/projects/default/templates/partials/menu-items.liquid b/priv/data/projects/default/templates/partials/menu-items.liquid new file mode 100644 index 0000000..8909529 --- /dev/null +++ b/priv/data/projects/default/templates/partials/menu-items.liquid @@ -0,0 +1,63 @@ + \ No newline at end of file diff --git a/priv/data/projects/default/templates/partials/menu.liquid b/priv/data/projects/default/templates/partials/menu.liquid new file mode 100644 index 0000000..5c24b09 --- /dev/null +++ b/priv/data/projects/default/templates/partials/menu.liquid @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/priv/data/projects/default/templates/post-list.liquid b/priv/data/projects/default/templates/post-list.liquid new file mode 100644 index 0000000..6f05408 --- /dev/null +++ b/priv/data/projects/default/templates/post-list.liquid @@ -0,0 +1,99 @@ +--- +id: 7d72d1a2-c8d6-4842-8327-35635b18c1fb +slug: post-list +title: Post List +kind: list +enabled: true +version: 1 +--- + + + {% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %} + +
    + {% render 'partials/language-switcher', blog_languages: blog_languages, language: language %} + {% if archive_context %} + {% if show_archive_range_heading and min_date and max_date %} + {% if archive_context.kind == 'tag' or archive_context.kind == 'category' %} +

    {{ archive_context.name }} - {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}

    + {% else %} +

    {{ 'render.archive' | i18n: language }} {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}

    + {% endif %} + {% else %} + {% if archive_context.kind == 'tag' or archive_context.kind == 'category' %} +

    {{ archive_context.name }}

    + {% elsif archive_context.kind == 'month' and archive_context.month and archive_context.year %} + {% assign month_key = 'render.month.' | append: archive_context.month %} +

    {{ 'render.archive' | i18n: language }} {{ month_key | i18n: language }} {{ archive_context.year }}

    + {% elsif archive_context.kind == 'year' and archive_context.year %} +

    {{ 'render.archive' | i18n: language }} {{ archive_context.year }}

    + {% elsif archive_context.kind == 'day' and archive_context.day and archive_context.month and archive_context.year %} + {% assign day_month_key = 'render.month.' | append: archive_context.month %} +

    {{ 'render.archive' | i18n: language }} {{ archive_context.day }}. {{ day_month_key | i18n: language }} {{ archive_context.year }}

    + {% else %} +

    {{ page_title }}

    + {% endif %} + {% endif %} + {% endif %} + + {% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %} + +
    + {% for day_block in day_blocks %} + {% if day_block.show_date_marker %} +
    + +
    + {% for post in day_block.posts %} +
    + {% if post.show_title %} + {% assign canonical_post_href = canonical_post_path_by_slug[post.slug] %} + {% if canonical_post_href == blank %} + {% assign canonical_post_href = '/posts/' | append: post.slug %} + {% endif %} +

    {{ post.title }}

    + {% endif %} + {{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }} +
    + {% endfor %} +
    +
    + {% else %} + {% for post in day_block.posts %} +
    + {% if post.show_title %} + {% assign canonical_post_href = canonical_post_path_by_slug[post.slug] %} + {% if canonical_post_href == blank %} + {% assign canonical_post_href = '/posts/' | append: post.slug %} + {% endif %} +

    {{ post.title }}

    + {% endif %} + {{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }} +
    + {% endfor %} + {% endif %} + + {% if day_block.show_separator %} + + {% endif %} + {% endfor %} +
    + + {% if has_prev_page or has_next_page %} + + {% endif %} +
    + + diff --git a/priv/data/projects/default/templates/single-post.liquid b/priv/data/projects/default/templates/single-post.liquid new file mode 100644 index 0000000..c51bf35 --- /dev/null +++ b/priv/data/projects/default/templates/single-post.liquid @@ -0,0 +1,41 @@ +--- +id: 38f613a7-7b26-42b8-a086-4074bdf7032a +slug: single-post +title: Single Post +kind: post +enabled: true +version: 1 +--- + + + {% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, alternate_links: alternate_links, language_prefix: language_prefix %} + +
    + {% render 'partials/language-switcher', blog_languages: blog_languages, language: language %} +

    {{ post.title }}

    + {% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %} + {% if post_categories.size > 0 or post_tags.size > 0 %} +
    + {% for category in post_categories %} + {{ category | escape }} + {% endfor %} + {% for tag in post_tags %} + {% assign tag_color = tag_color_by_name[tag] %} + {{ tag | escape }} + {% endfor %} +
    + {% 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 }}
    +
    + {% if backlinks.size > 0 %} +
    + {{ 'render.backlinks.label' | i18n: language }} + {% for backlink in backlinks %} + {{ backlink.display_slug }} + {% endfor %} +
    + {% endif %} +
    + + diff --git a/priv/starter_templates/templates/macros/gallery.liquid b/priv/starter_templates/templates/macros/gallery.liquid new file mode 100644 index 0000000..12aea07 --- /dev/null +++ b/priv/starter_templates/templates/macros/gallery.liquid @@ -0,0 +1,30 @@ + \ No newline at end of file diff --git a/priv/starter_templates/templates/macros/photo-archive.liquid b/priv/starter_templates/templates/macros/photo-archive.liquid new file mode 100644 index 0000000..259bb53 --- /dev/null +++ b/priv/starter_templates/templates/macros/photo-archive.liquid @@ -0,0 +1,33 @@ +
    +
    + {%- if months.size > 0 -%} + {%- for month in months -%} +
    +
    +
    + {{ month.label | escape }} +
    + +
    +
    + {%- endfor -%} + {%- else -%} +
    {{ empty_label | escape }}
    + {%- endif -%} +
    +
    \ No newline at end of file diff --git a/priv/starter_templates/templates/macros/tag-cloud.liquid b/priv/starter_templates/templates/macros/tag-cloud.liquid new file mode 100644 index 0000000..75479a1 --- /dev/null +++ b/priv/starter_templates/templates/macros/tag-cloud.liquid @@ -0,0 +1,19 @@ +
    + {%- if words_json -%} + + {%- else -%} +
    {{ empty_label | escape }}
    + {%- endif -%} +
    \ No newline at end of file diff --git a/priv/starter_templates/templates/macros/vimeo.liquid b/priv/starter_templates/templates/macros/vimeo.liquid new file mode 100644 index 0000000..ff14766 --- /dev/null +++ b/priv/starter_templates/templates/macros/vimeo.liquid @@ -0,0 +1,9 @@ +
    + +
    \ No newline at end of file diff --git a/priv/starter_templates/templates/macros/youtube.liquid b/priv/starter_templates/templates/macros/youtube.liquid new file mode 100644 index 0000000..e20b81d --- /dev/null +++ b/priv/starter_templates/templates/macros/youtube.liquid @@ -0,0 +1,9 @@ +
    + +
    \ No newline at end of file diff --git a/priv/starter_templates/templates/not-found.liquid b/priv/starter_templates/templates/not-found.liquid new file mode 100644 index 0000000..1fb3b0a --- /dev/null +++ b/priv/starter_templates/templates/not-found.liquid @@ -0,0 +1,17 @@ + + + {% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %} + +
    +
    +
    +

    404

    + {% assign default_not_found_message = 'render.notFound.message' | i18n: language %} + {% assign default_not_found_back = 'render.notFound.back' | i18n: language %} +

    {{ not_found_message | default: default_not_found_message }}

    +

    {{ not_found_back_label | default: default_not_found_back }}

    +
    +
    +
    + + \ No newline at end of file diff --git a/priv/starter_templates/templates/partials/head.liquid b/priv/starter_templates/templates/partials/head.liquid new file mode 100644 index 0000000..4fe5aac --- /dev/null +++ b/priv/starter_templates/templates/partials/head.liquid @@ -0,0 +1,27 @@ + + + + {{ page_title }} + {% assign resolved_pico_stylesheet_href = pico_stylesheet_href | default: '/assets/pico.min.css' %} + + + + + + {% assign feed_prefix = language_prefix | default: '' %} + + + {% for alternate_link in alternate_links %} + + {% endfor %} + + + + + + + + + + + \ No newline at end of file diff --git a/priv/starter_templates/templates/partials/language-switcher.liquid b/priv/starter_templates/templates/partials/language-switcher.liquid new file mode 100644 index 0000000..0cfef43 --- /dev/null +++ b/priv/starter_templates/templates/partials/language-switcher.liquid @@ -0,0 +1,41 @@ +{% if blog_languages.size > 1 %} + + +{% else %} +
    + + +
    +{% endif %} \ No newline at end of file diff --git a/priv/starter_templates/templates/partials/menu-items.liquid b/priv/starter_templates/templates/partials/menu-items.liquid new file mode 100644 index 0000000..8909529 --- /dev/null +++ b/priv/starter_templates/templates/partials/menu-items.liquid @@ -0,0 +1,63 @@ + \ No newline at end of file diff --git a/priv/starter_templates/templates/partials/menu.liquid b/priv/starter_templates/templates/partials/menu.liquid new file mode 100644 index 0000000..5c24b09 --- /dev/null +++ b/priv/starter_templates/templates/partials/menu.liquid @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/priv/starter_templates/templates/post-list.liquid b/priv/starter_templates/templates/post-list.liquid new file mode 100644 index 0000000..3238405 --- /dev/null +++ b/priv/starter_templates/templates/post-list.liquid @@ -0,0 +1,91 @@ + + + {% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %} + +
    + {% render 'partials/language-switcher', blog_languages: blog_languages, language: language %} + {% if archive_context %} + {% if show_archive_range_heading and min_date and max_date %} + {% if archive_context.kind == 'tag' or archive_context.kind == 'category' %} +

    {{ archive_context.name }} - {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}

    + {% else %} +

    {{ 'render.archive' | i18n: language }} {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}

    + {% endif %} + {% else %} + {% if archive_context.kind == 'tag' or archive_context.kind == 'category' %} +

    {{ archive_context.name }}

    + {% elsif archive_context.kind == 'month' and archive_context.month and archive_context.year %} + {% assign month_key = 'render.month.' | append: archive_context.month %} +

    {{ 'render.archive' | i18n: language }} {{ month_key | i18n: language }} {{ archive_context.year }}

    + {% elsif archive_context.kind == 'year' and archive_context.year %} +

    {{ 'render.archive' | i18n: language }} {{ archive_context.year }}

    + {% elsif archive_context.kind == 'day' and archive_context.day and archive_context.month and archive_context.year %} + {% assign day_month_key = 'render.month.' | append: archive_context.month %} +

    {{ 'render.archive' | i18n: language }} {{ archive_context.day }}. {{ day_month_key | i18n: language }} {{ archive_context.year }}

    + {% else %} +

    {{ page_title }}

    + {% endif %} + {% endif %} + {% endif %} + + {% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %} + +
    + {% for day_block in day_blocks %} + {% if day_block.show_date_marker %} +
    + +
    + {% for post in day_block.posts %} +
    + {% if post.show_title %} + {% assign canonical_post_href = canonical_post_path_by_slug[post.slug] %} + {% if canonical_post_href == blank %} + {% assign canonical_post_href = '/posts/' | append: post.slug %} + {% endif %} +

    {{ post.title }}

    + {% endif %} + {{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }} +
    + {% endfor %} +
    +
    + {% else %} + {% for post in day_block.posts %} +
    + {% if post.show_title %} + {% assign canonical_post_href = canonical_post_path_by_slug[post.slug] %} + {% if canonical_post_href == blank %} + {% assign canonical_post_href = '/posts/' | append: post.slug %} + {% endif %} +

    {{ post.title }}

    + {% endif %} + {{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }} +
    + {% endfor %} + {% endif %} + + {% if day_block.show_separator %} + + {% endif %} + {% endfor %} +
    + + {% if has_prev_page or has_next_page %} + + {% endif %} +
    + + \ No newline at end of file diff --git a/priv/starter_templates/templates/single-post.liquid b/priv/starter_templates/templates/single-post.liquid new file mode 100644 index 0000000..736c9d9 --- /dev/null +++ b/priv/starter_templates/templates/single-post.liquid @@ -0,0 +1,33 @@ + + + {% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, alternate_links: alternate_links, language_prefix: language_prefix %} + +
    + {% render 'partials/language-switcher', blog_languages: blog_languages, language: language %} +

    {{ post.title }}

    + {% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %} + {% if post_categories.size > 0 or post_tags.size > 0 %} +
    + {% for category in post_categories %} + {{ category | escape }} + {% endfor %} + {% for tag in post_tags %} + {% assign tag_color = tag_color_by_name[tag] %} + {{ tag | escape }} + {% endfor %} +
    + {% 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 }}
    +
    + {% if backlinks.size > 0 %} +
    + {{ 'render.backlinks.label' | i18n: language }} + {% for backlink in backlinks %} + {{ backlink.display_slug }} + {% endfor %} +
    + {% endif %} +
    + + \ No newline at end of file diff --git a/test/bds/generation_test.exs b/test/bds/generation_test.exs index 42305cc..7e80642 100644 --- a/test/bds/generation_test.exs +++ b/test/bds/generation_test.exs @@ -1,8 +1,11 @@ defmodule BDS.GenerationTest do use ExUnit.Case, async: false + import Ecto.Query + alias BDS.Metadata alias BDS.Posts + alias BDS.Repo setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) @@ -91,6 +94,108 @@ defmodule BDS.GenerationTest do assert File.read!(Path.join([temp_dir, "html", "sitemap.xml"])) =~ "https://example.com/blog/" end + test "generation renders published list and post templates for core and single pages", %{project: project, temp_dir: temp_dir} do + assert {:ok, _metadata} = + Metadata.update_project_metadata(project.id, %{ + public_url: "https://example.com/blog", + main_language: "en", + blog_languages: ["en"] + }) + + assert {:ok, list_template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "List View", + kind: :list, + content: "

    {{ page_title }}

    {% for post in posts %}{{ post.title }}{% endfor %}
    " + }) + + assert {:ok, _published_list} = BDS.Templates.publish_template(list_template.id) + + assert {:ok, post_template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Post View", + kind: :post, + content: "

    {{ post.title }}

    {{ post.content }}
    " + }) + + assert {:ok, published_post_template} = BDS.Templates.publish_template(post_template.id) + + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Rendered Post", + content: "Rendered body", + language: "en", + template_slug: published_post_template.slug + }) + + assert {:ok, published_post} = Posts.publish_post(post.id) + + assert {:ok, result} = BDS.Generation.generate_site(project.id, [:core, :single]) + + post_path = BDS.Generation.post_output_path(published_post) + relative_paths = Enum.map(result.generated_files, & &1.relative_path) + + assert "index.html" in relative_paths + assert post_path in relative_paths + + index_html = File.read!(Path.join([temp_dir, "html", "index.html"])) + assert index_html =~ "list-template" + assert index_html =~ "Rendered Post" + + post_html = File.read!(Path.join([temp_dir, "html", post_path])) + assert post_html =~ "post-template" + assert post_html =~ "Rendered body" + end + + test "generation renders copied starter templates with partials, i18n, and markdown", %{project: project, temp_dir: temp_dir} do + assert {:ok, _menu} = + BDS.Menu.update_menu(project.id, [ + %{kind: :page, label: "Notes", slug: "notes"} + ]) + + assert {:ok, _metadata} = + Metadata.update_project_metadata(project.id, %{ + public_url: "https://example.com/blog", + main_language: "en", + blog_languages: ["en", "de"] + }) + + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Starter Rendered Post", + content: "**Rendered** body", + language: "en", + categories: ["notes"], + tags: ["Elixir"] + }) + + assert {:ok, published_post} = Posts.publish_post(post.id) + assert {:ok, _tags} = BDS.Tags.sync_tags_from_posts(project.id) + + assert {:ok, result} = BDS.Generation.generate_site(project.id, [:core, :single]) + + post_path = BDS.Generation.post_output_path(published_post) + relative_paths = Enum.map(result.generated_files, & &1.relative_path) + + assert "index.html" in relative_paths + assert post_path in relative_paths + + index_html = File.read!(Path.join([temp_dir, "html", "index.html"])) + assert index_html =~ ~s(