feat: added liquid templates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
339
lib/bds/rendering.ex
Normal 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
|
||||
39
lib/bds/rendering/file_system.ex
Normal file
39
lib/bds/rendering/file_system.ex
Normal 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
|
||||
23
lib/bds/rendering/filters.ex
Normal file
23
lib/bds/rendering/filters.ex
Normal 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
224
lib/bds/rendering/i18n.ex
Normal 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 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
|
||||
60
lib/bds/starter_templates.ex
Normal file
60
lib/bds/starter_templates.ex
Normal 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
|
||||
2
mix.exs
2
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"}
|
||||
|
||||
8
mix.lock
8
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"},
|
||||
|
||||
30
priv/data/projects/default/templates/macros/gallery.liquid
Normal file
30
priv/data/projects/default/templates/macros/gallery.liquid
Normal file
@@ -0,0 +1,30 @@
|
||||
<div
|
||||
class="macro-gallery gallery-cols-{{ columns }}"
|
||||
data-post-id="{{ post_id | escape }}"
|
||||
data-columns="{{ columns }}"
|
||||
data-lightbox="true"
|
||||
>
|
||||
<div class="gallery-container gallery-lightbox">
|
||||
{%- if items.size > 0 -%}
|
||||
{%- for item in items -%}
|
||||
<a
|
||||
class="gallery-item"
|
||||
href="{{ item.media_path | escape }}"
|
||||
data-lightbox="{{ item.group_name | escape }}"
|
||||
data-title="{{ item.title | escape }}"
|
||||
>
|
||||
<img
|
||||
src="{{ item.media_path | escape }}"
|
||||
alt="{{ item.alt | escape }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
<div class="gallery-empty">{{ empty_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
{%- if caption -%}
|
||||
<figcaption class="gallery-caption">{{ caption | escape }}</figcaption>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
<div class="{{ root_classes }}"{% for attr in data_attrs %} {{ attr.name }}="{{ attr.value | escape }}"{% endfor %}>
|
||||
<div class="photo-archive-container">
|
||||
{%- if months.size > 0 -%}
|
||||
{%- for month in months -%}
|
||||
<div class="photo-archive-month-wrapper">
|
||||
<div class="photo-archive-month">
|
||||
<div class="photo-archive-month-label">
|
||||
<span>{{ month.label | escape }}</span>
|
||||
</div>
|
||||
<div class="photo-archive-gallery">
|
||||
{%- for item in month.items -%}
|
||||
<a
|
||||
class="photo-archive-item"
|
||||
href="{{ item.media_path | escape }}"
|
||||
data-lightbox="{{ item.group_name | escape }}"
|
||||
data-title="{{ item.title | escape }}"
|
||||
>
|
||||
<img
|
||||
src="{{ item.media_path | escape }}"
|
||||
alt="{{ item.alt | escape }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
<div class="photo-archive-empty">{{ empty_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
19
priv/data/projects/default/templates/macros/tag-cloud.liquid
Normal file
19
priv/data/projects/default/templates/macros/tag-cloud.liquid
Normal file
@@ -0,0 +1,19 @@
|
||||
<div
|
||||
class="macro-tag-cloud"
|
||||
data-tag-cloud="true"
|
||||
data-orientation="{{ orientation }}"
|
||||
data-color-distribution="quantile"
|
||||
data-color-easing="0.7"
|
||||
data-color-theme="pico"{%- if words_json -%} data-tag-cloud-words="{{ words_json }}" data-width="{{ width }}" data-height="{{ height }}"{%- endif -%}
|
||||
>
|
||||
{%- if words_json -%}
|
||||
<svg
|
||||
class="tag-cloud-canvas"
|
||||
viewBox="0 0 {{ width }} {{ height }}"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-label="{{ aria_label | escape }}"
|
||||
></svg>
|
||||
{%- else -%}
|
||||
<div class="tag-cloud-empty">{{ empty_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
9
priv/data/projects/default/templates/macros/vimeo.liquid
Normal file
9
priv/data/projects/default/templates/macros/vimeo.liquid
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="macro-vimeo">
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/{{ id | escape }}"
|
||||
title="{{ title | escape }}"
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="macro-youtube">
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/{{ id | escape }}?rel=0"
|
||||
title="{{ title | escape }}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
25
priv/data/projects/default/templates/not-found.liquid
Normal file
25
priv/data/projects/default/templates/not-found.liquid
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
id: 1ba67928-a8e8-44d7-b5f8-70e654d6cfad
|
||||
slug: not-found
|
||||
title: Not Found
|
||||
kind: not_found
|
||||
enabled: true
|
||||
version: 1
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
|
||||
<body>
|
||||
<main>
|
||||
<section class="not-found" data-template="not-found">
|
||||
<article>
|
||||
<h1>404</h1>
|
||||
{% assign default_not_found_message = 'render.notFound.message' | i18n: language %}
|
||||
{% assign default_not_found_back = 'render.notFound.back' | i18n: language %}
|
||||
<p>{{ not_found_message | default: default_not_found_message }}</p>
|
||||
<p><a href="/" role="button">{{ not_found_back_label | default: default_not_found_back }}</a></p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
27
priv/data/projects/default/templates/partials/head.liquid
Normal file
27
priv/data/projects/default/templates/partials/head.liquid
Normal file
@@ -0,0 +1,27 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ page_title }}</title>
|
||||
{% assign resolved_pico_stylesheet_href = pico_stylesheet_href | default: '/assets/pico.min.css' %}
|
||||
<link rel="stylesheet" href="{{ resolved_pico_stylesheet_href }}" />
|
||||
<link rel="stylesheet" href="/assets/lightbox.min.css" />
|
||||
<link rel="stylesheet" href="/assets/highlight.min.css" />
|
||||
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
|
||||
<link rel="stylesheet" href="/assets/bds.css" />
|
||||
{% assign feed_prefix = language_prefix | default: '' %}
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS" href="{{ feed_prefix }}/rss.xml" />
|
||||
<link rel="alternate" type="application/atom+xml" title="Atom" href="{{ feed_prefix }}/atom.xml" />
|
||||
{% for alternate_link in alternate_links %}
|
||||
<link rel="alternate" hreflang="{{ alternate_link.hreflang | escape }}" href="{{ alternate_link.href | escape }}" />
|
||||
{% endfor %}
|
||||
<script defer src="/assets/highlight.min.js"></script>
|
||||
<script defer src="/assets/code-enhancements.js"></script>
|
||||
<script defer src="/assets/d3.layout.cloud.js"></script>
|
||||
<script defer src="/assets/tag-cloud.js"></script>
|
||||
<script defer src="/assets/lightbox.min.js"></script>
|
||||
<script defer src="/assets/vanilla-calendar.min.js"></script>
|
||||
<script defer src="/assets/calendar-runtime.js"></script>
|
||||
<script defer src="/assets/search-runtime.js"></script>
|
||||
<link rel="stylesheet" href="{{ language_prefix }}/pagefind/pagefind-ui.css" />
|
||||
<script defer src="{{ language_prefix }}/pagefind/pagefind-ui.js"></script>
|
||||
</head>
|
||||
@@ -0,0 +1,41 @@
|
||||
{% if blog_languages.size > 1 %}
|
||||
<nav class="language-switcher" aria-label="{{ 'render.languageSwitcher.ariaLabel' | i18n: language }}">
|
||||
{% for lang in blog_languages %}
|
||||
{% if lang.is_current %}
|
||||
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="{{ lang.code }}">{{ lang.flag }}</span>
|
||||
{% else %}
|
||||
<a class="language-switcher-badge" href="{{ lang.href_prefix | default: '/' }}" data-lang-prefix="{{ lang.href_prefix }}" title="{{ lang.code }}">{{ lang.flag }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="blog-search-widget" aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
|
||||
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="blog-search-panel" data-blog-search-panel hidden>
|
||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
(function(){
|
||||
var links=document.querySelectorAll('.language-switcher-badge[data-lang-prefix]');
|
||||
var path=location.pathname.replace(/^\/[a-z]{2}(?=\/|$)/,'') || '/';
|
||||
links.forEach(function(a){a.href=(a.dataset.langPrefix||'')+path;});
|
||||
}());
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="blog-search-standalone" aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
|
||||
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="blog-search-panel" data-blog-search-panel hidden>
|
||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,63 @@
|
||||
<ul class="blog-menu-list">
|
||||
{% for item in items %}
|
||||
<li class="blog-menu-item{% if item.has_children %} blog-menu-item-with-children{% endif %}">
|
||||
{% if item.href == '#' %}
|
||||
<span class="blog-menu-link">{{ item.title }}</span>
|
||||
{% else %}
|
||||
<a class="blog-menu-link" href="{{ item.href }}">{{ item.title }}</a>
|
||||
{% endif %}
|
||||
{% if item.has_children %}
|
||||
<div class="blog-menu-submenu">
|
||||
{% render 'partials/menu-items', items: item.children %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{% if include_calendar %}
|
||||
<li class="blog-menu-item blog-menu-calendar">
|
||||
<button
|
||||
type="button"
|
||||
class="blog-menu-calendar-button"
|
||||
data-blog-calendar-toggle
|
||||
{% if calendar_initial_year %}data-blog-calendar-year="{{ calendar_initial_year }}"{% endif %}
|
||||
{% if calendar_initial_month %}data-blog-calendar-month="{{ calendar_initial_month }}"{% endif %}
|
||||
aria-label="{{ 'render.calendar.open' | i18n: language }}"
|
||||
title="{{ 'render.calendar.open' | i18n: language }}"
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" ry="2"></rect>
|
||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||
<line x1="8" y1="3" x2="8" y2="7"></line>
|
||||
<line x1="16" y1="3" x2="16" y2="7"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<section
|
||||
id="blog-calendar"
|
||||
class="blog-calendar-panel"
|
||||
data-blog-calendar-panel
|
||||
data-i18n-loading="{{ 'render.calendar.loading' | i18n: language }}"
|
||||
data-i18n-error="{{ 'render.calendar.error' | i18n: language }}"
|
||||
hidden
|
||||
>
|
||||
<header class="blog-calendar-header">
|
||||
<strong>{{ 'render.calendar.title' | i18n: language }}</strong>
|
||||
<button
|
||||
type="button"
|
||||
class="blog-calendar-close"
|
||||
data-blog-calendar-close
|
||||
aria-label="{{ 'render.calendar.close' | i18n: language }}"
|
||||
title="{{ 'render.calendar.close' | i18n: language }}"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="blog-calendar-content">
|
||||
<div data-blog-calendar-root></div>
|
||||
<p class="blog-calendar-status" data-blog-calendar-status>{{ 'render.calendar.loading' | i18n: language }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
@@ -0,0 +1,7 @@
|
||||
<nav class="blog-menu">
|
||||
{% if menu_items and menu_items.size > 0 %}
|
||||
{% render 'partials/menu-items', items: menu_items, include_calendar: true, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %}
|
||||
{% else %}
|
||||
{% render 'partials/menu-items', items: menu_items, include_calendar: true, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
99
priv/data/projects/default/templates/post-list.liquid
Normal file
99
priv/data/projects/default/templates/post-list.liquid
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
id: 7d72d1a2-c8d6-4842-8327-35635b18c1fb
|
||||
slug: post-list
|
||||
title: Post List
|
||||
kind: list
|
||||
enabled: true
|
||||
version: 1
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %}
|
||||
<body>
|
||||
<main>
|
||||
{% 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' %}
|
||||
<h1 class="archive-heading">{{ archive_context.name }} - {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
|
||||
{% else %}
|
||||
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
|
||||
<h1 class="archive-heading">{{ archive_context.name }}</h1>
|
||||
{% elsif archive_context.kind == 'month' and archive_context.month and archive_context.year %}
|
||||
{% assign month_key = 'render.month.' | append: archive_context.month %}
|
||||
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ month_key | i18n: language }} {{ archive_context.year }}</h1>
|
||||
{% elsif archive_context.kind == 'year' and archive_context.year %}
|
||||
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ archive_context.year }}</h1>
|
||||
{% 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 %}
|
||||
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ archive_context.day }}. {{ day_month_key | i18n: language }} {{ archive_context.year }}</h1>
|
||||
{% else %}
|
||||
<h1 class="archive-heading">{{ page_title }}</h1>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %}
|
||||
|
||||
<section class="post-list" data-template="post-list" data-list-page="{{ is_list_page }}" data-first-page="{{ is_first_page }}" data-last-page="{{ is_last_page }}">
|
||||
{% for day_block in day_blocks %}
|
||||
{% if day_block.show_date_marker %}
|
||||
<section class="archive-day-group">
|
||||
<aside class="archive-day-marker"><span>{{ day_block.date_label }}</span></aside>
|
||||
<div class="archive-day-posts">
|
||||
{% for post in day_block.posts %}
|
||||
<div class="post">
|
||||
{% 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 %}
|
||||
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
|
||||
{% 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 }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
{% for post in day_block.posts %}
|
||||
<div class="post">
|
||||
{% 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 %}
|
||||
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
|
||||
{% 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 }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if day_block.show_separator %}
|
||||
<div class="archive-day-separator" aria-hidden="true"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{% if has_prev_page or has_next_page %}
|
||||
<nav class="preview-pagination" aria-label="{{ 'render.pagination.label' | i18n: language }}">
|
||||
{% if has_prev_page %}
|
||||
<a href="{{ prev_page_href }}" class="preview-pagination-link" aria-label="{{ 'render.pagination.newer' | i18n: language }}">{{ 'render.pagination.newer' | i18n: language }}</a>
|
||||
{% else %}
|
||||
<span class="spacer"></span>
|
||||
{% endif %}
|
||||
|
||||
{% if has_next_page %}
|
||||
<a href="{{ next_page_href }}" class="preview-pagination-link" aria-label="{{ 'render.pagination.older' | i18n: language }}">{{ 'render.pagination.older' | i18n: language }}</a>
|
||||
{% else %}
|
||||
<span class="spacer"></span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
41
priv/data/projects/default/templates/single-post.liquid
Normal file
41
priv/data/projects/default/templates/single-post.liquid
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
id: 38f613a7-7b26-42b8-a086-4074bdf7032a
|
||||
slug: single-post
|
||||
title: Single Post
|
||||
kind: post
|
||||
enabled: true
|
||||
version: 1
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, alternate_links: alternate_links, language_prefix: language_prefix %}
|
||||
<body>
|
||||
<main>
|
||||
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}
|
||||
<h1>{{ post.title }}</h1>
|
||||
{% 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 %}
|
||||
<div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}">
|
||||
{% for category in post_categories %}
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | url_encode }}/">{{ category | escape }}</a>
|
||||
{% endfor %}
|
||||
{% for tag in post_tags %}
|
||||
{% assign tag_color = tag_color_by_name[tag] %}
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<article class="single-post blog-post" data-template="single-post" data-pagefind-body>
|
||||
<div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}</div>
|
||||
</article>
|
||||
{% if backlinks.size > 0 %}
|
||||
<div class="single-post-backlinks" aria-label="{{ 'render.backlinks.ariaLabel' | i18n: language }}">
|
||||
<span class="single-post-backlinks-label">{{ 'render.backlinks.label' | i18n: language }}</span>
|
||||
{% for backlink in backlinks %}
|
||||
<a class="single-post-taxonomy-bubble single-post-backlink-bubble" href="{{ backlink.path }}">{{ backlink.display_slug }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
30
priv/starter_templates/templates/macros/gallery.liquid
Normal file
30
priv/starter_templates/templates/macros/gallery.liquid
Normal file
@@ -0,0 +1,30 @@
|
||||
<div
|
||||
class="macro-gallery gallery-cols-{{ columns }}"
|
||||
data-post-id="{{ post_id | escape }}"
|
||||
data-columns="{{ columns }}"
|
||||
data-lightbox="true"
|
||||
>
|
||||
<div class="gallery-container gallery-lightbox">
|
||||
{%- if items.size > 0 -%}
|
||||
{%- for item in items -%}
|
||||
<a
|
||||
class="gallery-item"
|
||||
href="{{ item.media_path | escape }}"
|
||||
data-lightbox="{{ item.group_name | escape }}"
|
||||
data-title="{{ item.title | escape }}"
|
||||
>
|
||||
<img
|
||||
src="{{ item.media_path | escape }}"
|
||||
alt="{{ item.alt | escape }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
<div class="gallery-empty">{{ empty_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
{%- if caption -%}
|
||||
<figcaption class="gallery-caption">{{ caption | escape }}</figcaption>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
33
priv/starter_templates/templates/macros/photo-archive.liquid
Normal file
33
priv/starter_templates/templates/macros/photo-archive.liquid
Normal file
@@ -0,0 +1,33 @@
|
||||
<div class="{{ root_classes }}"{% for attr in data_attrs %} {{ attr.name }}="{{ attr.value | escape }}"{% endfor %}>
|
||||
<div class="photo-archive-container">
|
||||
{%- if months.size > 0 -%}
|
||||
{%- for month in months -%}
|
||||
<div class="photo-archive-month-wrapper">
|
||||
<div class="photo-archive-month">
|
||||
<div class="photo-archive-month-label">
|
||||
<span>{{ month.label | escape }}</span>
|
||||
</div>
|
||||
<div class="photo-archive-gallery">
|
||||
{%- for item in month.items -%}
|
||||
<a
|
||||
class="photo-archive-item"
|
||||
href="{{ item.media_path | escape }}"
|
||||
data-lightbox="{{ item.group_name | escape }}"
|
||||
data-title="{{ item.title | escape }}"
|
||||
>
|
||||
<img
|
||||
src="{{ item.media_path | escape }}"
|
||||
alt="{{ item.alt | escape }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
<div class="photo-archive-empty">{{ empty_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
19
priv/starter_templates/templates/macros/tag-cloud.liquid
Normal file
19
priv/starter_templates/templates/macros/tag-cloud.liquid
Normal file
@@ -0,0 +1,19 @@
|
||||
<div
|
||||
class="macro-tag-cloud"
|
||||
data-tag-cloud="true"
|
||||
data-orientation="{{ orientation }}"
|
||||
data-color-distribution="quantile"
|
||||
data-color-easing="0.7"
|
||||
data-color-theme="pico"{%- if words_json -%} data-tag-cloud-words="{{ words_json }}" data-width="{{ width }}" data-height="{{ height }}"{%- endif -%}
|
||||
>
|
||||
{%- if words_json -%}
|
||||
<svg
|
||||
class="tag-cloud-canvas"
|
||||
viewBox="0 0 {{ width }} {{ height }}"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-label="{{ aria_label | escape }}"
|
||||
></svg>
|
||||
{%- else -%}
|
||||
<div class="tag-cloud-empty">{{ empty_label | escape }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
9
priv/starter_templates/templates/macros/vimeo.liquid
Normal file
9
priv/starter_templates/templates/macros/vimeo.liquid
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="macro-vimeo">
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/{{ id | escape }}"
|
||||
title="{{ title | escape }}"
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
9
priv/starter_templates/templates/macros/youtube.liquid
Normal file
9
priv/starter_templates/templates/macros/youtube.liquid
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="macro-youtube">
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/{{ id | escape }}?rel=0"
|
||||
title="{{ title | escape }}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
17
priv/starter_templates/templates/not-found.liquid
Normal file
17
priv/starter_templates/templates/not-found.liquid
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
|
||||
<body>
|
||||
<main>
|
||||
<section class="not-found" data-template="not-found">
|
||||
<article>
|
||||
<h1>404</h1>
|
||||
{% assign default_not_found_message = 'render.notFound.message' | i18n: language %}
|
||||
{% assign default_not_found_back = 'render.notFound.back' | i18n: language %}
|
||||
<p>{{ not_found_message | default: default_not_found_message }}</p>
|
||||
<p><a href="/" role="button">{{ not_found_back_label | default: default_not_found_back }}</a></p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
27
priv/starter_templates/templates/partials/head.liquid
Normal file
27
priv/starter_templates/templates/partials/head.liquid
Normal file
@@ -0,0 +1,27 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ page_title }}</title>
|
||||
{% assign resolved_pico_stylesheet_href = pico_stylesheet_href | default: '/assets/pico.min.css' %}
|
||||
<link rel="stylesheet" href="{{ resolved_pico_stylesheet_href }}" />
|
||||
<link rel="stylesheet" href="/assets/lightbox.min.css" />
|
||||
<link rel="stylesheet" href="/assets/highlight.min.css" />
|
||||
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
|
||||
<link rel="stylesheet" href="/assets/bds.css" />
|
||||
{% assign feed_prefix = language_prefix | default: '' %}
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS" href="{{ feed_prefix }}/rss.xml" />
|
||||
<link rel="alternate" type="application/atom+xml" title="Atom" href="{{ feed_prefix }}/atom.xml" />
|
||||
{% for alternate_link in alternate_links %}
|
||||
<link rel="alternate" hreflang="{{ alternate_link.hreflang | escape }}" href="{{ alternate_link.href | escape }}" />
|
||||
{% endfor %}
|
||||
<script defer src="/assets/highlight.min.js"></script>
|
||||
<script defer src="/assets/code-enhancements.js"></script>
|
||||
<script defer src="/assets/d3.layout.cloud.js"></script>
|
||||
<script defer src="/assets/tag-cloud.js"></script>
|
||||
<script defer src="/assets/lightbox.min.js"></script>
|
||||
<script defer src="/assets/vanilla-calendar.min.js"></script>
|
||||
<script defer src="/assets/calendar-runtime.js"></script>
|
||||
<script defer src="/assets/search-runtime.js"></script>
|
||||
<link rel="stylesheet" href="{{ language_prefix }}/pagefind/pagefind-ui.css" />
|
||||
<script defer src="{{ language_prefix }}/pagefind/pagefind-ui.js"></script>
|
||||
</head>
|
||||
@@ -0,0 +1,41 @@
|
||||
{% if blog_languages.size > 1 %}
|
||||
<nav class="language-switcher" aria-label="{{ 'render.languageSwitcher.ariaLabel' | i18n: language }}">
|
||||
{% for lang in blog_languages %}
|
||||
{% if lang.is_current %}
|
||||
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="{{ lang.code }}">{{ lang.flag }}</span>
|
||||
{% else %}
|
||||
<a class="language-switcher-badge" href="{{ lang.href_prefix | default: '/' }}" data-lang-prefix="{{ lang.href_prefix }}" title="{{ lang.code }}">{{ lang.flag }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="blog-search-widget" aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
|
||||
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="blog-search-panel" data-blog-search-panel hidden>
|
||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
(function(){
|
||||
var links=document.querySelectorAll('.language-switcher-badge[data-lang-prefix]');
|
||||
var path=location.pathname.replace(/^\/[a-z]{2}(?=\/|$)/,'') || '/';
|
||||
links.forEach(function(a){a.href=(a.dataset.langPrefix||'')+path;});
|
||||
}());
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="blog-search-standalone" aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
|
||||
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="blog-search-panel" data-blog-search-panel hidden>
|
||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
63
priv/starter_templates/templates/partials/menu-items.liquid
Normal file
63
priv/starter_templates/templates/partials/menu-items.liquid
Normal file
@@ -0,0 +1,63 @@
|
||||
<ul class="blog-menu-list">
|
||||
{% for item in items %}
|
||||
<li class="blog-menu-item{% if item.has_children %} blog-menu-item-with-children{% endif %}">
|
||||
{% if item.href == '#' %}
|
||||
<span class="blog-menu-link">{{ item.title }}</span>
|
||||
{% else %}
|
||||
<a class="blog-menu-link" href="{{ item.href }}">{{ item.title }}</a>
|
||||
{% endif %}
|
||||
{% if item.has_children %}
|
||||
<div class="blog-menu-submenu">
|
||||
{% render 'partials/menu-items', items: item.children %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{% if include_calendar %}
|
||||
<li class="blog-menu-item blog-menu-calendar">
|
||||
<button
|
||||
type="button"
|
||||
class="blog-menu-calendar-button"
|
||||
data-blog-calendar-toggle
|
||||
{% if calendar_initial_year %}data-blog-calendar-year="{{ calendar_initial_year }}"{% endif %}
|
||||
{% if calendar_initial_month %}data-blog-calendar-month="{{ calendar_initial_month }}"{% endif %}
|
||||
aria-label="{{ 'render.calendar.open' | i18n: language }}"
|
||||
title="{{ 'render.calendar.open' | i18n: language }}"
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" ry="2"></rect>
|
||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||
<line x1="8" y1="3" x2="8" y2="7"></line>
|
||||
<line x1="16" y1="3" x2="16" y2="7"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<section
|
||||
id="blog-calendar"
|
||||
class="blog-calendar-panel"
|
||||
data-blog-calendar-panel
|
||||
data-i18n-loading="{{ 'render.calendar.loading' | i18n: language }}"
|
||||
data-i18n-error="{{ 'render.calendar.error' | i18n: language }}"
|
||||
hidden
|
||||
>
|
||||
<header class="blog-calendar-header">
|
||||
<strong>{{ 'render.calendar.title' | i18n: language }}</strong>
|
||||
<button
|
||||
type="button"
|
||||
class="blog-calendar-close"
|
||||
data-blog-calendar-close
|
||||
aria-label="{{ 'render.calendar.close' | i18n: language }}"
|
||||
title="{{ 'render.calendar.close' | i18n: language }}"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="blog-calendar-content">
|
||||
<div data-blog-calendar-root></div>
|
||||
<p class="blog-calendar-status" data-blog-calendar-status>{{ 'render.calendar.loading' | i18n: language }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
7
priv/starter_templates/templates/partials/menu.liquid
Normal file
7
priv/starter_templates/templates/partials/menu.liquid
Normal file
@@ -0,0 +1,7 @@
|
||||
<nav class="blog-menu">
|
||||
{% if menu_items and menu_items.size > 0 %}
|
||||
{% render 'partials/menu-items', items: menu_items, include_calendar: true, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %}
|
||||
{% else %}
|
||||
{% render 'partials/menu-items', items: menu_items, include_calendar: true, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
91
priv/starter_templates/templates/post-list.liquid
Normal file
91
priv/starter_templates/templates/post-list.liquid
Normal file
@@ -0,0 +1,91 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %}
|
||||
<body>
|
||||
<main>
|
||||
{% 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' %}
|
||||
<h1 class="archive-heading">{{ archive_context.name }} - {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
|
||||
{% else %}
|
||||
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
|
||||
<h1 class="archive-heading">{{ archive_context.name }}</h1>
|
||||
{% elsif archive_context.kind == 'month' and archive_context.month and archive_context.year %}
|
||||
{% assign month_key = 'render.month.' | append: archive_context.month %}
|
||||
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ month_key | i18n: language }} {{ archive_context.year }}</h1>
|
||||
{% elsif archive_context.kind == 'year' and archive_context.year %}
|
||||
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ archive_context.year }}</h1>
|
||||
{% 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 %}
|
||||
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ archive_context.day }}. {{ day_month_key | i18n: language }} {{ archive_context.year }}</h1>
|
||||
{% else %}
|
||||
<h1 class="archive-heading">{{ page_title }}</h1>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %}
|
||||
|
||||
<section class="post-list" data-template="post-list" data-list-page="{{ is_list_page }}" data-first-page="{{ is_first_page }}" data-last-page="{{ is_last_page }}">
|
||||
{% for day_block in day_blocks %}
|
||||
{% if day_block.show_date_marker %}
|
||||
<section class="archive-day-group">
|
||||
<aside class="archive-day-marker"><span>{{ day_block.date_label }}</span></aside>
|
||||
<div class="archive-day-posts">
|
||||
{% for post in day_block.posts %}
|
||||
<div class="post">
|
||||
{% 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 %}
|
||||
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
|
||||
{% 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 }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
{% for post in day_block.posts %}
|
||||
<div class="post">
|
||||
{% 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 %}
|
||||
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
|
||||
{% 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 }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if day_block.show_separator %}
|
||||
<div class="archive-day-separator" aria-hidden="true"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{% if has_prev_page or has_next_page %}
|
||||
<nav class="preview-pagination" aria-label="{{ 'render.pagination.label' | i18n: language }}">
|
||||
{% if has_prev_page %}
|
||||
<a href="{{ prev_page_href }}" class="preview-pagination-link" aria-label="{{ 'render.pagination.newer' | i18n: language }}">{{ 'render.pagination.newer' | i18n: language }}</a>
|
||||
{% else %}
|
||||
<span class="spacer"></span>
|
||||
{% endif %}
|
||||
|
||||
{% if has_next_page %}
|
||||
<a href="{{ next_page_href }}" class="preview-pagination-link" aria-label="{{ 'render.pagination.older' | i18n: language }}">{{ 'render.pagination.older' | i18n: language }}</a>
|
||||
{% else %}
|
||||
<span class="spacer"></span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
33
priv/starter_templates/templates/single-post.liquid
Normal file
33
priv/starter_templates/templates/single-post.liquid
Normal file
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, alternate_links: alternate_links, language_prefix: language_prefix %}
|
||||
<body>
|
||||
<main>
|
||||
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}
|
||||
<h1>{{ post.title }}</h1>
|
||||
{% 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 %}
|
||||
<div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}">
|
||||
{% for category in post_categories %}
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | url_encode }}/">{{ category | escape }}</a>
|
||||
{% endfor %}
|
||||
{% for tag in post_tags %}
|
||||
{% assign tag_color = tag_color_by_name[tag] %}
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<article class="single-post blog-post" data-template="single-post" data-pagefind-body>
|
||||
<div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}</div>
|
||||
</article>
|
||||
{% if backlinks.size > 0 %}
|
||||
<div class="single-post-backlinks" aria-label="{{ 'render.backlinks.ariaLabel' | i18n: language }}">
|
||||
<span class="single-post-backlinks-label">{{ 'render.backlinks.label' | i18n: language }}</span>
|
||||
{% for backlink in backlinks %}
|
||||
<a class="single-post-taxonomy-bubble single-post-backlink-bubble" href="{{ backlink.path }}">{{ backlink.display_slug }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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: "<main class=\"list-template\"><h1>{{ page_title }}</h1>{% for post in posts %}<a href=\"{{ post.href }}\">{{ post.title }}</a>{% endfor %}</main>"
|
||||
})
|
||||
|
||||
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: "<article class=\"post-template\"><h1>{{ post.title }}</h1><div class=\"body\">{{ post.content }}</div></article>"
|
||||
})
|
||||
|
||||
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(<nav class="blog-menu">)
|
||||
assert index_html =~ ~s(/assets/pico.min.css)
|
||||
assert index_html =~ "Starter Rendered Post"
|
||||
|
||||
post_html = File.read!(Path.join([temp_dir, "html", post_path]))
|
||||
assert post_html =~ ~s(data-template="single-post")
|
||||
assert post_html =~ ~s(<strong>Rendered</strong> body)
|
||||
assert post_html =~ "Taxonomy"
|
||||
assert post_html =~ "Language"
|
||||
end
|
||||
|
||||
test "single generation writes canonical post pages and language-prefixed translation pages", %{project: project, temp_dir: temp_dir} do
|
||||
assert {:ok, _metadata} =
|
||||
Metadata.update_project_metadata(project.id, %{
|
||||
@@ -126,4 +231,53 @@ defmodule BDS.GenerationTest do
|
||||
assert File.read!(Path.join([temp_dir, "html", translation_path])) =~ "Hallo generierte Welt"
|
||||
assert File.read!(Path.join([temp_dir, "html", post_path])) =~ ~s(data-pagefind-body)
|
||||
end
|
||||
|
||||
test "archive generation writes paginated category, tag, and date 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", "de"],
|
||||
max_posts_per_page: 2
|
||||
})
|
||||
|
||||
for index <- 1..3 do
|
||||
assert {:ok, post} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Archive #{index}",
|
||||
content: "Archive body #{index}",
|
||||
language: "en",
|
||||
categories: ["notes"],
|
||||
tags: ["Elixir"]
|
||||
})
|
||||
|
||||
created_at = DateTime.to_unix(~U[2026-04-15 12:00:00Z]) + index
|
||||
Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^post.id), set: [created_at: created_at, updated_at: created_at])
|
||||
assert {:ok, _published} = Posts.publish_post(post.id)
|
||||
end
|
||||
|
||||
assert {:ok, _tags} = BDS.Tags.sync_tags_from_posts(project.id)
|
||||
|
||||
assert {:ok, result} = BDS.Generation.generate_site(project.id, [:category, :tag, :date])
|
||||
|
||||
expected_paths = [
|
||||
"category/notes/index.html",
|
||||
"category/notes/page/2/index.html",
|
||||
"tag/elixir/index.html",
|
||||
"2026/index.html",
|
||||
"2026/04/index.html",
|
||||
"de/category/notes/index.html",
|
||||
"de/tag/elixir/index.html",
|
||||
"de/2026/index.html",
|
||||
"de/2026/04/index.html"
|
||||
]
|
||||
|
||||
assert expected_paths -- Enum.map(result.generated_files, & &1.relative_path) == []
|
||||
|
||||
assert File.read!(Path.join([temp_dir, "html", "category", "notes", "index.html"])) =~ "Archive 1"
|
||||
assert File.read!(Path.join([temp_dir, "html", "category", "notes", "page", "2", "index.html"])) =~ "Archive 3"
|
||||
assert File.read!(Path.join([temp_dir, "html", "tag", "elixir", "index.html"])) =~ "Elixir"
|
||||
assert File.read!(Path.join([temp_dir, "html", "2026", "04", "index.html"])) =~ "2026-04"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -109,7 +109,7 @@ defmodule BDS.MaintenanceTest do
|
||||
assert length(scripts) == 1
|
||||
|
||||
assert {:ok, templates} = BDS.Maintenance.rebuild_from_filesystem(project.id, "template")
|
||||
assert length(templates) == 1
|
||||
assert length(templates) == 4
|
||||
|
||||
assert Repo.get(BDS.Posts.Post, "dispatch-post") != nil
|
||||
assert Repo.get(BDS.Media.Media, "dispatch-media") != nil
|
||||
|
||||
@@ -3,8 +3,14 @@ defmodule BDS.PostsTest do
|
||||
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
{:ok, project} = BDS.Projects.create_project(%{name: "Publishing"})
|
||||
%{project: project}
|
||||
|
||||
temp_dir = Path.join(System.tmp_dir!(), "bds-posts-#{System.unique_integer([:positive])}")
|
||||
File.mkdir_p!(temp_dir)
|
||||
|
||||
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||
|
||||
{:ok, project} = BDS.Projects.create_project(%{name: "Publishing", data_path: temp_dir})
|
||||
%{project: project, temp_dir: temp_dir}
|
||||
end
|
||||
|
||||
test "create_post slugifies titles, stores list fields, and defaults draft fields", %{project: project} do
|
||||
@@ -42,7 +48,7 @@ defmodule BDS.PostsTest do
|
||||
assert duplicate_slug_post.categories == []
|
||||
end
|
||||
|
||||
test "create_post falls back to untitled and keeps slug uniqueness scoped to a project", %{project: project} do
|
||||
test "create_post falls back to untitled and keeps slug uniqueness scoped to a project", %{project: project, temp_dir: temp_dir} do
|
||||
assert {:ok, first} = BDS.Posts.create_post(%{project_id: project.id, title: nil})
|
||||
assert first.title == ""
|
||||
assert first.slug == "untitled"
|
||||
@@ -50,7 +56,10 @@ defmodule BDS.PostsTest do
|
||||
assert {:ok, second} = BDS.Posts.create_post(%{project_id: project.id, title: nil})
|
||||
assert second.slug == "untitled-2"
|
||||
|
||||
assert {:ok, other_project} = BDS.Projects.create_project(%{name: "Elsewhere"})
|
||||
other_temp_dir = Path.join(temp_dir, "elsewhere")
|
||||
File.mkdir_p!(other_temp_dir)
|
||||
|
||||
assert {:ok, other_project} = BDS.Projects.create_project(%{name: "Elsewhere", data_path: other_temp_dir})
|
||||
assert {:ok, other_post} = BDS.Posts.create_post(%{project_id: other_project.id, title: nil})
|
||||
assert other_post.slug == "untitled"
|
||||
end
|
||||
|
||||
@@ -59,4 +59,105 @@ defmodule BDS.PreviewTest do
|
||||
|
||||
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||
end
|
||||
|
||||
test "draft preview renders through the published post template", %{project: project} do
|
||||
assert {:ok, template} =
|
||||
BDS.Templates.create_template(%{
|
||||
project_id: project.id,
|
||||
title: "Preview Post",
|
||||
kind: :post,
|
||||
content: "<article class=\"preview-template\"><h1>{{ post.title }}</h1><div>{{ post.content }}</div></article>"
|
||||
})
|
||||
|
||||
assert {:ok, published_template} = BDS.Templates.publish_template(template.id)
|
||||
|
||||
assert {:ok, post} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Draft Post",
|
||||
content: "Draft preview body",
|
||||
language: "en",
|
||||
template_slug: published_template.slug
|
||||
})
|
||||
|
||||
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
|
||||
|
||||
assert {:ok, %{body: draft_html, content_type: "text/html"}} =
|
||||
BDS.Preview.preview_draft(project.id, "/draft/draft-post", post.id)
|
||||
|
||||
assert draft_html =~ "preview-template"
|
||||
assert draft_html =~ "Draft Post"
|
||||
assert draft_html =~ "Draft preview body"
|
||||
|
||||
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||
end
|
||||
|
||||
test "draft preview renders through copied starter templates with markdown and i18n", %{project: project} 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: "Draft Post",
|
||||
content: "**Draft** preview body",
|
||||
language: "en"
|
||||
})
|
||||
|
||||
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
|
||||
|
||||
assert {:ok, %{body: draft_html, content_type: "text/html"}} =
|
||||
BDS.Preview.preview_draft(project.id, "/draft/draft-post", post.id)
|
||||
|
||||
assert draft_html =~ ~s(data-template="single-post")
|
||||
assert draft_html =~ ~s(<strong>Draft</strong> preview body)
|
||||
assert draft_html =~ "Language"
|
||||
|
||||
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||
end
|
||||
|
||||
test "start_preview serves generated and draft routes over real HTTP on localhost", %{project: project} do
|
||||
:inets.start()
|
||||
|
||||
assert {:ok, _metadata} =
|
||||
Metadata.update_project_metadata(project.id, %{
|
||||
public_url: "https://example.com/blog",
|
||||
main_language: "en",
|
||||
blog_languages: ["en"]
|
||||
})
|
||||
|
||||
assert {:ok, _} = Generation.write_generated_file(project.id, "index.html", "<html>http home</html>")
|
||||
|
||||
assert {:ok, post} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "HTTP Draft",
|
||||
content: "Draft over HTTP",
|
||||
language: "en"
|
||||
})
|
||||
|
||||
assert {:ok, server} = BDS.Preview.start_preview(project.id)
|
||||
|
||||
assert {:ok, {{_version, 200, _reason}, headers, body}} =
|
||||
:httpc.request(:get, {to_charlist("http://#{server.host}:#{server.port}/"), []}, [], body_format: :binary)
|
||||
|
||||
assert body == "<html>http home</html>"
|
||||
assert Enum.any?(headers, fn {name, value} -> String.downcase(to_string(name)) == "content-type" and to_string(value) =~ "text/html" end)
|
||||
|
||||
assert {:ok, {{_version, 200, _reason}, _headers, draft_body}} =
|
||||
:httpc.request(:get, {to_charlist("http://#{server.host}:#{server.port}/draft/http-draft?post_id=#{post.id}"), []}, [], body_format: :binary)
|
||||
|
||||
assert draft_body =~ "Draft over HTTP"
|
||||
|
||||
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,31 +1,72 @@
|
||||
defmodule BDS.ProjectsTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.Projects.Project
|
||||
alias BDS.Repo
|
||||
alias BDS.Templates.Template
|
||||
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
|
||||
temp_root = Path.join(System.tmp_dir!(), "bds-projects-#{System.unique_integer([:positive])}")
|
||||
File.mkdir_p!(temp_root)
|
||||
|
||||
on_exit(fn -> File.rm_rf(temp_root) end)
|
||||
|
||||
%{temp_root: temp_root}
|
||||
end
|
||||
|
||||
test "create_project slugifies names, keeps new projects inactive, and deduplicates slugs" do
|
||||
assert {:ok, first} = BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: "/tmp/blog"})
|
||||
test "create_project slugifies names, keeps new projects inactive, and deduplicates slugs", %{temp_root: temp_root} do
|
||||
first_dir = Path.join(temp_root, "first")
|
||||
second_dir = Path.join(temp_root, "second")
|
||||
File.mkdir_p!(first_dir)
|
||||
File.mkdir_p!(second_dir)
|
||||
|
||||
assert {:ok, first} = BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: first_dir})
|
||||
|
||||
assert first.name == "Föö Bär Blog"
|
||||
assert first.slug == "foo-bar-blog"
|
||||
assert first.data_path == "/tmp/blog"
|
||||
assert first.data_path == first_dir
|
||||
assert first.is_active == false
|
||||
assert is_integer(first.created_at)
|
||||
assert is_integer(first.updated_at)
|
||||
|
||||
assert {:ok, second} = BDS.Projects.create_project(%{name: "Föö Bär Blog"})
|
||||
assert {:ok, second} = BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: second_dir})
|
||||
assert second.slug == "foo-bar-blog-2"
|
||||
assert second.is_active == false
|
||||
end
|
||||
|
||||
test "set_active_project clears the previous active project and activates the target" do
|
||||
assert {:ok, first} = BDS.Projects.create_project(%{name: "First"})
|
||||
assert {:ok, second} = BDS.Projects.create_project(%{name: "Second"})
|
||||
test "create_project installs starter templates into the project data directory", %{temp_root: temp_root} do
|
||||
temp_dir = Path.join(temp_root, "starter")
|
||||
File.mkdir_p!(temp_dir)
|
||||
|
||||
assert {:ok, project} = BDS.Projects.create_project(%{name: "Starter Blog", data_path: temp_dir})
|
||||
|
||||
assert File.exists?(Path.join([temp_dir, "templates", "single-post.liquid"]))
|
||||
assert File.exists?(Path.join([temp_dir, "templates", "post-list.liquid"]))
|
||||
assert File.exists?(Path.join([temp_dir, "templates", "not-found.liquid"]))
|
||||
assert File.exists?(Path.join([temp_dir, "templates", "partials", "head.liquid"]))
|
||||
assert File.exists?(Path.join([temp_dir, "templates", "partials", "menu-items.liquid"]))
|
||||
assert File.exists?(Path.join([temp_dir, "templates", "macros", "gallery.liquid"]))
|
||||
|
||||
starter_slugs =
|
||||
Repo.all(from template in Template, where: template.project_id == ^project.id, select: {template.slug, template.kind})
|
||||
|
||||
assert {"single-post", :post} in starter_slugs
|
||||
assert {"post-list", :list} in starter_slugs
|
||||
assert {"not-found", :not_found} in starter_slugs
|
||||
end
|
||||
|
||||
test "set_active_project clears the previous active project and activates the target", %{temp_root: temp_root} do
|
||||
first_dir = Path.join(temp_root, "active-first")
|
||||
second_dir = Path.join(temp_root, "active-second")
|
||||
File.mkdir_p!(first_dir)
|
||||
File.mkdir_p!(second_dir)
|
||||
|
||||
assert {:ok, first} = BDS.Projects.create_project(%{name: "First", data_path: first_dir})
|
||||
assert {:ok, second} = BDS.Projects.create_project(%{name: "Second", data_path: second_dir})
|
||||
|
||||
assert {:ok, active_first} = BDS.Projects.set_active_project(first.id)
|
||||
assert active_first.is_active == true
|
||||
|
||||
@@ -46,6 +46,96 @@ defmodule BDS.PublishingTest do
|
||||
assert_receive {:uploaded, :media, "/srv/blog/media", ["asset.jpg"], :rsync}
|
||||
end
|
||||
|
||||
test "upload_site runs rsync commands with SSH agent env and media exclude filters", %{project: project, temp_dir: temp_dir} do
|
||||
test_pid = self()
|
||||
|
||||
File.mkdir_p!(Path.join([temp_dir, "html"]))
|
||||
File.write!(Path.join([temp_dir, "html", "index.html"]), "<html />")
|
||||
File.mkdir_p!(Path.join([temp_dir, "thumbnails"]))
|
||||
File.write!(Path.join([temp_dir, "thumbnails", "thumb.jpg"]), "thumb")
|
||||
File.mkdir_p!(Path.join([temp_dir, "media"]))
|
||||
File.write!(Path.join([temp_dir, "media", "asset.jpg"]), "asset")
|
||||
File.write!(Path.join([temp_dir, "media", "asset.jpg.meta"]), "meta")
|
||||
|
||||
runner = fn command, args, opts ->
|
||||
send(test_pid, {:command_run, command, args, opts})
|
||||
{"", 0}
|
||||
end
|
||||
|
||||
credentials = %{
|
||||
ssh_host: "example.com",
|
||||
ssh_user: "deploy",
|
||||
ssh_remote_path: "/srv/blog",
|
||||
ssh_mode: :rsync
|
||||
}
|
||||
|
||||
assert {:ok, job} =
|
||||
BDS.Publishing.upload_site(project.id, credentials,
|
||||
command_runner: runner,
|
||||
ssh_auth_sock: "/tmp/test-agent.sock"
|
||||
)
|
||||
|
||||
assert wait_for_publish_job(job.id, &(&1.status == :completed)).status == :completed
|
||||
|
||||
assert_receive {:command_run, "rsync", html_args, html_opts}
|
||||
assert html_args == ["--update", "--compress", "--verbose", "-e", "ssh", Path.join([temp_dir, "html"]) <> "/", "deploy@example.com:/srv/blog/"]
|
||||
assert html_opts[:env] == [{"SSH_AUTH_SOCK", "/tmp/test-agent.sock"}]
|
||||
|
||||
assert_receive {:command_run, "rsync", thumb_args, _thumb_opts}
|
||||
assert thumb_args == ["--update", "--compress", "--verbose", "-e", "ssh", Path.join([temp_dir, "thumbnails"]) <> "/", "deploy@example.com:/srv/blog/thumbnails/"]
|
||||
|
||||
assert_receive {:command_run, "rsync", media_args, _media_opts}
|
||||
assert media_args == ["--update", "--compress", "--verbose", "--exclude=*.meta", "-e", "ssh", Path.join([temp_dir, "media"]) <> "/", "deploy@example.com:/srv/blog/media/"]
|
||||
end
|
||||
|
||||
test "upload_site runs scp commands for each eligible file and fails when a command exits non-zero", %{project: project, temp_dir: temp_dir} do
|
||||
test_pid = self()
|
||||
html_index = Path.join([temp_dir, "html", "index.html"])
|
||||
html_entry = Path.join([temp_dir, "html", "posts", "entry.html"])
|
||||
thumb_path = Path.join([temp_dir, "thumbnails", "thumb.jpg"])
|
||||
|
||||
File.mkdir_p!(Path.join([temp_dir, "html", "posts"]))
|
||||
File.write!(html_index, "<html />")
|
||||
File.write!(html_entry, "<html />")
|
||||
File.mkdir_p!(Path.join([temp_dir, "thumbnails"]))
|
||||
File.write!(thumb_path, "thumb")
|
||||
File.mkdir_p!(Path.join([temp_dir, "media"]))
|
||||
File.write!(Path.join([temp_dir, "media", "asset.jpg"]), "asset")
|
||||
File.write!(Path.join([temp_dir, "media", "asset.jpg.meta"]), "meta")
|
||||
|
||||
runner = fn command, args, opts ->
|
||||
send(test_pid, {:command_run, command, args, opts})
|
||||
|
||||
if List.last(args) == "deploy@example.com:/srv/blog/thumbnails/thumb.jpg" do
|
||||
{"thumbnail failure", 1}
|
||||
else
|
||||
{"", 0}
|
||||
end
|
||||
end
|
||||
|
||||
credentials = %{
|
||||
ssh_host: "example.com",
|
||||
ssh_user: "deploy",
|
||||
ssh_remote_path: "/srv/blog",
|
||||
ssh_mode: :scp
|
||||
}
|
||||
|
||||
assert {:ok, job} =
|
||||
BDS.Publishing.upload_site(project.id, credentials,
|
||||
command_runner: runner,
|
||||
ssh_auth_sock: "/tmp/test-agent.sock"
|
||||
)
|
||||
|
||||
failed_job = wait_for_publish_job(job.id, &(&1.status == :failed))
|
||||
assert failed_job.error =~ "thumbnail failure"
|
||||
|
||||
assert_receive {:command_run, "scp", ["-q", ^html_index, "deploy@example.com:/srv/blog/index.html"], opts_a}
|
||||
assert opts_a[:env] == [{"SSH_AUTH_SOCK", "/tmp/test-agent.sock"}]
|
||||
assert_receive {:command_run, "scp", ["-q", ^html_entry, "deploy@example.com:/srv/blog/posts/entry.html"], _opts_b}
|
||||
assert_receive {:command_run, "scp", ["-q", ^thumb_path, "deploy@example.com:/srv/blog/thumbnails/thumb.jpg"], _opts_c}
|
||||
refute_receive {:command_run, "scp", ["-q", _, "deploy@example.com:/srv/blog/media/asset.jpg"], _opts_d}
|
||||
end
|
||||
|
||||
test "upload_site marks the publish job failed when a target upload fails", %{project: project, temp_dir: temp_dir} do
|
||||
File.mkdir_p!(Path.join([temp_dir, "html"]))
|
||||
File.write!(Path.join([temp_dir, "html", "index.html"]), "<html />")
|
||||
|
||||
@@ -228,9 +228,9 @@ defmodule BDS.TemplatesTest do
|
||||
)
|
||||
|
||||
assert {:ok, templates} = BDS.Templates.rebuild_templates_from_files(project.id)
|
||||
assert length(templates) == 1
|
||||
assert length(templates) == 4
|
||||
|
||||
[template] = Repo.all(BDS.Templates.Template)
|
||||
template = Repo.get!(BDS.Templates.Template, "template-from-file")
|
||||
assert template.id == "template-from-file"
|
||||
assert template.slug == "recovered-view"
|
||||
assert template.title == "Recovered View"
|
||||
|
||||
Reference in New Issue
Block a user