feat: added liquid templates

This commit is contained in:
2026-04-23 21:37:45 +02:00
parent b48bed8823
commit 4e46e1b393
42 changed files with 2470 additions and 53 deletions

View File

@@ -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
[
"<html>",
@@ -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 -> ["<li>", post.title, "</li>"] end)
|> IO.iodata_to_binary()
[
"<html><body data-kind=\"",
kind,
"\" data-language=\"",
to_string(language),
"\"><h1>",
title,
"</h1><ul>",
items,
"</ul></body></html>"
]
|> 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 -> ["<li>", post.title, "</li>"] end)
|> IO.iodata_to_binary()
[
"<html><body data-kind=\"date\" data-language=\"",
to_string(language),
"\"><h1>",
label,
"</h1><ul>",
items,
"</ul></body></html>"
]
|> 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

View File

@@ -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)

View File

@@ -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

View File

@@ -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, "/")

339
lib/bds/rendering.ex Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

224
lib/bds/rendering/i18n.ex Normal file
View File

@@ -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 daperçu demandée est introuvable.",
"render.notFound.back" => "Retour à laccueil de laperç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

View File

@@ -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