feat: preview working
This commit is contained in:
@@ -140,9 +140,20 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|
||||
def set_mode(socket, post_id, mode, reload) do
|
||||
workbench = socket.assigns.workbench
|
||||
normalized_mode = normalize_mode(mode)
|
||||
|
||||
if normalized_mode == :preview do
|
||||
case Repo.get(Post, post_id) do
|
||||
%Post{} = post ->
|
||||
_ = Preview.ensure_preview(post.project_id)
|
||||
|
||||
_other ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:post_editor_modes, Map.put(socket.assigns.post_editor_modes, post_id, normalize_mode(mode)))
|
||||
|> assign(:post_editor_modes, Map.put(socket.assigns.post_editor_modes, post_id, normalized_mode))
|
||||
|> reload.(workbench)
|
||||
end
|
||||
|
||||
@@ -870,18 +881,13 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
defp preview_url(_post, _active_language, _canonical_language, mode) when mode != :preview, do: nil
|
||||
|
||||
defp preview_url(%Post{} = post, active_language, canonical_language, :preview) do
|
||||
with {:ok, server} <- Preview.start_preview(post.project_id) do
|
||||
base_url = "http://#{server.host}:#{server.port}"
|
||||
query =
|
||||
%{}
|
||||
|> maybe_put_query("draft", "true")
|
||||
|> maybe_put_query("post_id", post.id)
|
||||
|> maybe_put_query("lang", active_language != canonical_language && active_language)
|
||||
query =
|
||||
%{}
|
||||
|> maybe_put_query("draft", "true")
|
||||
|> maybe_put_query("post_id", post.id)
|
||||
|> maybe_put_query("lang", active_language != canonical_language && active_language)
|
||||
|
||||
base_url <> canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query)
|
||||
else
|
||||
_other -> nil
|
||||
end
|
||||
Preview.base_url() <> canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query)
|
||||
end
|
||||
|
||||
defp canonical_preview_path(created_at_ms, slug) do
|
||||
|
||||
@@ -5,6 +5,7 @@ defmodule BDS.Preview do
|
||||
|
||||
alias BDS.Posts
|
||||
alias BDS.Posts.Translation
|
||||
alias BDS.PreviewAssets
|
||||
alias BDS.Projects
|
||||
alias BDS.Repo
|
||||
alias BDS.Rendering
|
||||
@@ -25,6 +26,17 @@ defmodule BDS.Preview do
|
||||
)
|
||||
end
|
||||
|
||||
def ensure_preview(project_id) when is_binary(project_id) do
|
||||
project = Projects.get_project!(project_id)
|
||||
|
||||
GenServer.call(
|
||||
__MODULE__,
|
||||
{:ensure_preview, project_id, Projects.project_data_dir(project), self()}
|
||||
)
|
||||
end
|
||||
|
||||
def base_url, do: "http://#{@host}:#{@port}"
|
||||
|
||||
def stop_preview(project_id) when is_binary(project_id) do
|
||||
GenServer.call(__MODULE__, {:stop_preview, project_id})
|
||||
end
|
||||
@@ -48,31 +60,17 @@ defmodule BDS.Preview do
|
||||
|
||||
@impl true
|
||||
def handle_call({:start_preview, project_id, data_dir, owner_pid}, _from, state) do
|
||||
state = stop_current_server(state)
|
||||
maybe_allow_repo(owner_pid)
|
||||
{reply, next_state} = start_server(state, project_id, data_dir, owner_pid)
|
||||
{:reply, reply, next_state}
|
||||
end
|
||||
|
||||
{:ok, listener} =
|
||||
:gen_tcp.listen(@port, [
|
||||
:binary,
|
||||
packet: :raw,
|
||||
active: false,
|
||||
reuseaddr: true,
|
||||
ip: {127, 0, 0, 1}
|
||||
])
|
||||
def handle_call({:ensure_preview, project_id, _data_dir, _owner_pid}, _from, %{current: %{project_id: project_id, is_running: true}} = state) do
|
||||
{:reply, {:ok, public_server(state.current)}, state}
|
||||
end
|
||||
|
||||
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}}
|
||||
def handle_call({:ensure_preview, project_id, data_dir, owner_pid}, _from, state) do
|
||||
{reply, next_state} = start_server(state, project_id, data_dir, owner_pid)
|
||||
{:reply, reply, next_state}
|
||||
end
|
||||
|
||||
def handle_call({:stop_preview, project_id}, _from, state) do
|
||||
@@ -141,24 +139,30 @@ defmodule BDS.Preview do
|
||||
defp ensure_running(_server, _project_id), do: {:error, :not_running}
|
||||
|
||||
defp resolve_request(server, request_path, query_params) do
|
||||
with {:ok, relative_path, kind} <- route_request(request_path) do
|
||||
full_path =
|
||||
case kind do
|
||||
:media -> safe_join(server.data_dir, Path.join(["media", relative_path]))
|
||||
:generated -> safe_join(Path.join(server.data_dir, "html"), relative_path)
|
||||
end
|
||||
case PreviewAssets.response(request_path) do
|
||||
{:ok, response} ->
|
||||
{:ok, response}
|
||||
|
||||
case full_path do
|
||||
{:error, :not_found} ->
|
||||
{:error, :not_found}
|
||||
:error ->
|
||||
with {:ok, relative_path, kind} <- route_request(request_path) do
|
||||
full_path =
|
||||
case kind do
|
||||
:media -> safe_join(server.data_dir, Path.join(["media", relative_path]))
|
||||
:generated -> safe_join(Path.join(server.data_dir, "html"), relative_path)
|
||||
end
|
||||
|
||||
resolved_path ->
|
||||
case read_response(resolved_path) do
|
||||
{:error, :not_found} -> render_not_found_response(server.project_id, query_params)
|
||||
{:ok, response} -> {:ok, apply_response_overrides(response, query_params)}
|
||||
other -> other
|
||||
case full_path do
|
||||
{:error, :not_found} ->
|
||||
{:error, :not_found}
|
||||
|
||||
resolved_path ->
|
||||
case read_response(resolved_path) do
|
||||
{:error, :not_found} -> render_not_found_response(server.project_id, query_params)
|
||||
{:ok, response} -> {:ok, apply_response_overrides(response, query_params)}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -197,8 +201,8 @@ defmodule BDS.Preview do
|
||||
%{
|
||||
id: translation.id,
|
||||
title: translation.title,
|
||||
content: translation.content || "",
|
||||
body: translation.content || "",
|
||||
content: Posts.editor_body(translation),
|
||||
body: Posts.editor_body(translation),
|
||||
slug: post.slug,
|
||||
language: translation.language,
|
||||
excerpt: translation.excerpt,
|
||||
@@ -209,8 +213,8 @@ defmodule BDS.Preview do
|
||||
%{
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
content: post.content || "",
|
||||
body: post.content || "",
|
||||
content: Posts.editor_body(post),
|
||||
body: Posts.editor_body(post),
|
||||
slug: post.slug,
|
||||
language: post.language,
|
||||
excerpt: post.excerpt,
|
||||
@@ -385,6 +389,34 @@ defmodule BDS.Preview do
|
||||
|
||||
defp stop_current_server(state), do: state
|
||||
|
||||
defp start_server(state, project_id, data_dir, owner_pid) 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
|
||||
}
|
||||
|
||||
{{:ok, public_server(server)}, %{state | current: server}}
|
||||
end
|
||||
|
||||
defp public_server(server) do
|
||||
Map.take(server, [:project_id, :host, :port, :is_running])
|
||||
end
|
||||
@@ -435,10 +467,23 @@ defmodule BDS.Preview do
|
||||
|
||||
defp apply_preview_overrides(body, query_params) when is_binary(body) and is_map(query_params) do
|
||||
body
|
||||
|> override_pico_stylesheet_href(normalize_override(query_params["theme"]))
|
||||
|> override_html_attribute("data-theme", normalize_override(query_params["theme"]))
|
||||
|> override_html_attribute("data-mode", normalize_override(query_params["mode"]))
|
||||
end
|
||||
|
||||
defp override_pico_stylesheet_href(body, nil), do: body
|
||||
|
||||
defp override_pico_stylesheet_href(body, theme) do
|
||||
replacement =
|
||||
case theme do
|
||||
"default" -> "/assets/pico.min.css"
|
||||
value -> "/assets/pico.#{value}.min.css"
|
||||
end
|
||||
|
||||
Regex.replace(~r{/assets/pico(?:\.[a-z]+)?\.min\.css}, body, replacement, global: false)
|
||||
end
|
||||
|
||||
defp normalize_override(nil), do: nil
|
||||
defp normalize_override(""), do: nil
|
||||
defp normalize_override(value), do: String.trim(value)
|
||||
|
||||
335
lib/bds/preview_assets.ex
Normal file
335
lib/bds/preview_assets.ex
Normal file
@@ -0,0 +1,335 @@
|
||||
defmodule BDS.PreviewAssets do
|
||||
@moduledoc false
|
||||
|
||||
@theme_tokens %{
|
||||
"amber" => %{light_primary: "#876400", dark_primary: "#c79400"},
|
||||
"blue" => %{light_primary: "#2060df", dark_primary: "#8999f9"},
|
||||
"cyan" => %{light_primary: "#047878", dark_primary: "#0ab1b1"},
|
||||
"fuchsia" => %{light_primary: "#c1208b", dark_primary: "#f869bf"},
|
||||
"green" => %{light_primary: "#33790f", dark_primary: "#4eb31b"},
|
||||
"grey" => %{light_primary: "#6a6a6a", dark_primary: "#9e9e9e"},
|
||||
"indigo" => %{light_primary: "#655cd6", dark_primary: "#a294e5"},
|
||||
"jade" => %{light_primary: "#007a50", dark_primary: "#00b478"},
|
||||
"lime" => %{light_primary: "#577400", dark_primary: "#82ab00"},
|
||||
"orange" => %{light_primary: "#bd3c13", dark_primary: "#f56b3d"},
|
||||
"pink" => %{light_primary: "#c72259", dark_primary: "#f7708e"},
|
||||
"pumpkin" => %{light_primary: "#9c5900", dark_primary: "#e48500"},
|
||||
"purple" => %{light_primary: "#aa40bf", dark_primary: "#d47de4"},
|
||||
"red" => %{light_primary: "#c52f21", dark_primary: "#f17961"},
|
||||
"sand" => %{light_primary: "#6e6a60", dark_primary: "#a39e8f"},
|
||||
"slate" => %{light_primary: "#5d6b89", dark_primary: "#909ebe"},
|
||||
"violet" => %{light_primary: "#8352c5", dark_primary: "#b290d9"},
|
||||
"yellow" => %{light_primary: "#756b00", dark_primary: "#ad9f00"},
|
||||
"zinc" => %{light_primary: "#646b79", dark_primary: "#969eaf"}
|
||||
}
|
||||
|
||||
@highlight_stylesheet """
|
||||
.hljs { color: #e6edf3; background: transparent; display: block; overflow-x: auto; }
|
||||
.hljs-keyword, .hljs-selector-tag, .hljs-literal { color: #ff7b72; }
|
||||
.hljs-string, .hljs-attr { color: #a5d6ff; }
|
||||
.hljs-number, .hljs-title, .hljs-section { color: #f2cc60; }
|
||||
.hljs-comment, .hljs-quote { color: #8b949e; }
|
||||
.hljs-built_in, .hljs-type, .hljs-symbol { color: #7ee787; }
|
||||
"""
|
||||
|
||||
@highlight_script "window.hljs = window.hljs || { highlightElement: function () {} };"
|
||||
|
||||
@lightbox_script "window.lightbox = window.lightbox || { option: function () {}, init: function () {} };"
|
||||
|
||||
@calendar_stylesheet """
|
||||
[data-blog-calendar-root] { min-height: 12rem; }
|
||||
[data-blog-calendar-root] button { font: inherit; }
|
||||
"""
|
||||
|
||||
@calendar_script """
|
||||
(function () {
|
||||
function Calendar() {}
|
||||
Calendar.prototype.init = function () {};
|
||||
window.VanillaCalendar = window.VanillaCalendar || Calendar;
|
||||
window.VanillaCalendarPro = window.VanillaCalendarPro || Calendar;
|
||||
})();
|
||||
"""
|
||||
|
||||
@calendar_runtime """
|
||||
(function () {
|
||||
function toggle(panel, hidden) {
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
panel.setAttribute('hidden', 'hidden');
|
||||
} else {
|
||||
panel.removeAttribute('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
var toggleButton = document.querySelector('[data-blog-calendar-toggle]');
|
||||
var closeButton = document.querySelector('[data-blog-calendar-close]');
|
||||
var panel = document.querySelector('[data-blog-calendar-panel]');
|
||||
var status = document.querySelector('[data-blog-calendar-status]');
|
||||
|
||||
if (!toggleButton || !panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleButton.addEventListener('click', function () {
|
||||
var isHidden = panel.hasAttribute('hidden');
|
||||
toggle(panel, !isHidden);
|
||||
if (status && !status.dataset.previewReady) {
|
||||
status.textContent = 'Preview calendar is unavailable in draft mode.';
|
||||
status.dataset.previewReady = 'true';
|
||||
}
|
||||
});
|
||||
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', function () {
|
||||
toggle(panel, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
"""
|
||||
|
||||
@search_runtime """
|
||||
(function () {
|
||||
function setHidden(panel, hidden) {
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
panel.setAttribute('hidden', 'hidden');
|
||||
} else {
|
||||
panel.removeAttribute('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
var toggle = document.querySelector('[data-blog-search-toggle]');
|
||||
var panel = document.querySelector('[data-blog-search-panel]');
|
||||
if (!toggle || !panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', function () {
|
||||
setHidden(panel, !panel.hasAttribute('hidden'));
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
if (panel.hasAttribute('hidden')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (panel.contains(event.target) || toggle.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHidden(panel, true);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
"""
|
||||
|
||||
@tag_cloud_runtime """
|
||||
(function () {
|
||||
function init() {}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
"""
|
||||
|
||||
@d3_cloud_script """
|
||||
(function () {
|
||||
var layout = {
|
||||
size: function () { return layout; },
|
||||
words: function () { return layout; },
|
||||
padding: function () { return layout; },
|
||||
rotate: function () { return layout; },
|
||||
font: function () { return layout; },
|
||||
fontSize: function () { return layout; },
|
||||
on: function () { return layout; },
|
||||
start: function () { return layout; }
|
||||
};
|
||||
|
||||
window.d3 = window.d3 || {};
|
||||
window.d3.layout = window.d3.layout || {};
|
||||
window.d3.layout.cloud = function () { return layout; };
|
||||
})();
|
||||
"""
|
||||
|
||||
def response(pathname) when is_binary(pathname) do
|
||||
case Regex.run(~r{^/assets/([^/]+)$}, pathname) do
|
||||
[_, asset_name] -> build_asset_response(asset_name)
|
||||
_other -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp build_asset_response(asset_name) do
|
||||
case asset_payload(asset_name) do
|
||||
{:ok, content_type, body} -> {:ok, %{content_type: content_type, body: body}}
|
||||
:error -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp asset_payload(asset_name) do
|
||||
case Regex.run(~r/^pico(?:\.([a-z]+))?\.min\.css$/, asset_name) do
|
||||
[_, theme] -> {:ok, "text/css", pico_stylesheet(theme)}
|
||||
[single] when single == asset_name -> {:ok, "text/css", pico_stylesheet(nil)}
|
||||
_other -> named_asset_payload(asset_name)
|
||||
end
|
||||
end
|
||||
|
||||
defp named_asset_payload("bds.css"), do: file_asset("bds.css", "text/css")
|
||||
defp named_asset_payload("code-enhancements.js"), do: file_asset("code-enhancements.js", "application/javascript")
|
||||
defp named_asset_payload("highlight.min.css"), do: {:ok, "text/css", @highlight_stylesheet}
|
||||
defp named_asset_payload("highlight.min.js"), do: {:ok, "application/javascript", @highlight_script}
|
||||
defp named_asset_payload("lightbox.min.css"), do: {:ok, "text/css", ""}
|
||||
defp named_asset_payload("lightbox.min.js"), do: {:ok, "application/javascript", @lightbox_script}
|
||||
defp named_asset_payload("vanilla-calendar.min.css"), do: {:ok, "text/css", @calendar_stylesheet}
|
||||
defp named_asset_payload("vanilla-calendar.min.js"), do: {:ok, "application/javascript", @calendar_script}
|
||||
defp named_asset_payload("calendar-runtime.js"), do: {:ok, "application/javascript", @calendar_runtime}
|
||||
defp named_asset_payload("search-runtime.js"), do: {:ok, "application/javascript", @search_runtime}
|
||||
defp named_asset_payload("tag-cloud.js"), do: {:ok, "application/javascript", @tag_cloud_runtime}
|
||||
defp named_asset_payload("d3.layout.cloud.js"), do: {:ok, "application/javascript", @d3_cloud_script}
|
||||
defp named_asset_payload(_asset_name), do: :error
|
||||
|
||||
defp file_asset(filename, content_type) do
|
||||
case File.read(asset_path(filename)) do
|
||||
{:ok, body} -> {:ok, content_type, body}
|
||||
{:error, _reason} -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp asset_path(filename) do
|
||||
Application.app_dir(:bds, "priv/preview_assets/#{filename}")
|
||||
end
|
||||
|
||||
defp pico_stylesheet(theme) do
|
||||
tokens = Map.get(@theme_tokens, theme || "", %{light_primary: "#2060df", dark_primary: "#8999f9"})
|
||||
|
||||
"""
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--pico-font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;
|
||||
--pico-line-height: 1.6;
|
||||
--pico-border-radius: 0.35rem;
|
||||
--pico-background-color: #ffffff;
|
||||
--pico-color: #1f2937;
|
||||
--pico-muted-color: #5d6b89;
|
||||
--pico-muted-border-color: #d6dde8;
|
||||
--pico-card-background-color: #f7f9fc;
|
||||
--pico-primary: #{tokens.light_primary};
|
||||
--pico-primary-hover: #{tokens.light_primary};
|
||||
--pico-primary-focus: rgba(32, 96, 223, 0.16);
|
||||
--pico-primary-inverse: #ffffff;
|
||||
--pico-code-background-color: #1f2937;
|
||||
--pico-code-color: #e6edf3;
|
||||
--pico-ins-color: #2f7a38;
|
||||
--pico-del-color: #b74848;
|
||||
--pico-form-element-background-color: #ffffff;
|
||||
--pico-form-element-border-color: #c7d0dd;
|
||||
--pico-form-element-color: #1f2937;
|
||||
}
|
||||
|
||||
:root[data-mode='light'] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-mode='dark'] {
|
||||
color-scheme: dark;
|
||||
--pico-background-color: #13171f;
|
||||
--pico-color: #e6edf3;
|
||||
--pico-muted-color: #909ebe;
|
||||
--pico-muted-border-color: #2d3645;
|
||||
--pico-card-background-color: #1b2230;
|
||||
--pico-primary: #{tokens.dark_primary};
|
||||
--pico-primary-hover: #{tokens.dark_primary};
|
||||
--pico-primary-focus: rgba(137, 153, 249, 0.18);
|
||||
--pico-code-background-color: #0f1520;
|
||||
--pico-form-element-background-color: #13171f;
|
||||
--pico-form-element-border-color: #364153;
|
||||
--pico-form-element-color: #e6edf3;
|
||||
}
|
||||
|
||||
@media only screen and (prefers-color-scheme: dark) {
|
||||
:root:not([data-mode='light']) {
|
||||
color-scheme: dark;
|
||||
--pico-background-color: #13171f;
|
||||
--pico-color: #e6edf3;
|
||||
--pico-muted-color: #909ebe;
|
||||
--pico-muted-border-color: #2d3645;
|
||||
--pico-card-background-color: #1b2230;
|
||||
--pico-primary: #{tokens.dark_primary};
|
||||
--pico-primary-hover: #{tokens.dark_primary};
|
||||
--pico-primary-focus: rgba(137, 153, 249, 0.18);
|
||||
--pico-code-background-color: #0f1520;
|
||||
--pico-form-element-background-color: #13171f;
|
||||
--pico-form-element-border-color: #364153;
|
||||
--pico-form-element-color: #e6edf3;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { font-family: var(--pico-font-family); background: var(--pico-background-color); color: var(--pico-color); }
|
||||
body { margin: 0; font-family: inherit; background: var(--pico-background-color); color: var(--pico-color); line-height: var(--pico-line-height); }
|
||||
h1, h2, h3, h4, h5, h6 { color: inherit; line-height: 1.2; margin: 0 0 0.75rem; }
|
||||
p, ul, ol, pre, table, blockquote { margin: 0 0 1rem; }
|
||||
a { color: var(--pico-primary); }
|
||||
a:hover, a:focus-visible { color: var(--pico-primary-hover); }
|
||||
button, [role='button'], input, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
button, [role='button'] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid var(--pico-form-element-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
background: var(--pico-card-background-color);
|
||||
color: var(--pico-color);
|
||||
padding: 0.45rem 0.8rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--pico-form-element-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
padding: 0.55rem 0.7rem;
|
||||
background: var(--pico-form-element-background-color);
|
||||
color: var(--pico-form-element-color);
|
||||
}
|
||||
code, pre, kbd {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
img { max-width: 100%; height: auto; }
|
||||
hr { border: 0; border-top: 1px solid var(--pico-muted-border-color); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 0.45rem 0.6rem; border-bottom: 1px solid var(--pico-muted-border-color); text-align: left; }
|
||||
blockquote {
|
||||
margin-left: 0;
|
||||
padding-left: 1rem;
|
||||
border-left: 4px solid var(--pico-muted-border-color);
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -172,8 +172,18 @@ defmodule BDS.Rendering do
|
||||
:page_title,
|
||||
Map.get(assigns, "page_title", Map.get(assigns, :title, Map.get(assigns, "title")))
|
||||
),
|
||||
pico_stylesheet_href: default_pico_stylesheet_href(),
|
||||
html_theme_attribute: html_theme_attribute(metadata.pico_theme),
|
||||
pico_stylesheet_href:
|
||||
Map.get(
|
||||
assigns,
|
||||
:pico_stylesheet_href,
|
||||
Map.get(assigns, "pico_stylesheet_href", default_pico_stylesheet_href(metadata.pico_theme))
|
||||
),
|
||||
html_theme_attribute:
|
||||
Map.get(
|
||||
assigns,
|
||||
:html_theme_attribute,
|
||||
Map.get(assigns, "html_theme_attribute", html_theme_attribute(metadata.pico_theme))
|
||||
),
|
||||
blog_languages: blog_languages(metadata, language),
|
||||
alternate_links: alternate_links(canonical_post, project_id, main_language),
|
||||
menu_items: menu_items(project_id),
|
||||
@@ -220,8 +230,18 @@ defmodule BDS.Rendering do
|
||||
),
|
||||
page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title")),
|
||||
posts: posts,
|
||||
pico_stylesheet_href: default_pico_stylesheet_href(),
|
||||
html_theme_attribute: html_theme_attribute(metadata.pico_theme),
|
||||
pico_stylesheet_href:
|
||||
Map.get(
|
||||
assigns,
|
||||
:pico_stylesheet_href,
|
||||
Map.get(assigns, "pico_stylesheet_href", default_pico_stylesheet_href(metadata.pico_theme))
|
||||
),
|
||||
html_theme_attribute:
|
||||
Map.get(
|
||||
assigns,
|
||||
:html_theme_attribute,
|
||||
Map.get(assigns, "html_theme_attribute", html_theme_attribute(metadata.pico_theme))
|
||||
),
|
||||
blog_languages: blog_languages(metadata, language),
|
||||
alternate_links: [],
|
||||
menu_items: menu_items(project_id),
|
||||
@@ -267,8 +287,18 @@ defmodule BDS.Rendering do
|
||||
:language_prefix,
|
||||
Map.get(assigns, "language_prefix", language_prefix(language, main_language))
|
||||
),
|
||||
pico_stylesheet_href: default_pico_stylesheet_href(),
|
||||
html_theme_attribute: html_theme_attribute(metadata.pico_theme),
|
||||
pico_stylesheet_href:
|
||||
Map.get(
|
||||
assigns,
|
||||
:pico_stylesheet_href,
|
||||
Map.get(assigns, "pico_stylesheet_href", default_pico_stylesheet_href(metadata.pico_theme))
|
||||
),
|
||||
html_theme_attribute:
|
||||
Map.get(
|
||||
assigns,
|
||||
:html_theme_attribute,
|
||||
Map.get(assigns, "html_theme_attribute", html_theme_attribute(metadata.pico_theme))
|
||||
),
|
||||
blog_languages: blog_languages(metadata, language),
|
||||
menu_items: menu_items(project_id),
|
||||
alternate_links: [],
|
||||
@@ -695,9 +725,13 @@ defmodule BDS.Rendering do
|
||||
|
||||
defp html_theme_attribute(nil), do: nil
|
||||
defp html_theme_attribute(""), do: nil
|
||||
defp html_theme_attribute("default"), do: nil
|
||||
defp html_theme_attribute(theme), do: ~s(data-theme="#{theme}")
|
||||
|
||||
defp default_pico_stylesheet_href, do: "/assets/pico.min.css"
|
||||
defp default_pico_stylesheet_href(nil), do: "/assets/pico.min.css"
|
||||
defp default_pico_stylesheet_href(""), do: "/assets/pico.min.css"
|
||||
defp default_pico_stylesheet_href("default"), do: "/assets/pico.min.css"
|
||||
defp default_pico_stylesheet_href(theme), do: "/assets/pico.#{theme}.min.css"
|
||||
|
||||
defp href_for_language(""), do: "/"
|
||||
defp href_for_language(prefix), do: prefix <> "/"
|
||||
|
||||
169
priv/preview_assets/bds.css
Normal file
169
priv/preview_assets/bds.css
Normal file
@@ -0,0 +1,169 @@
|
||||
:root { color-scheme: light dark; }
|
||||
@media only screen and (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) { --pico-background-color: #13171f; }
|
||||
}
|
||||
[data-theme='dark'] { --pico-background-color: #13171f; }
|
||||
body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem 4rem; background: var(--pico-background-color, var(--background-color)); color: var(--pico-color, var(--color)); }
|
||||
main { display: grid; gap: 1rem; }
|
||||
.blog-menu { position: relative; display: flex; align-items: baseline; justify-content: space-between; gap: .75rem; border-top: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); border-bottom: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); padding: .4rem 0; margin: -.15rem 0 .2rem; }
|
||||
.blog-menu > .blog-menu-list { width: 100%; }
|
||||
.blog-menu-list { list-style: none; display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .75rem; margin: 0; padding: 0; }
|
||||
.blog-menu-item { position: relative; }
|
||||
.blog-menu-link { display: inline-flex; align-items: center; color: var(--pico-muted-color, var(--muted-color)); text-decoration: none; font-size: .94rem; line-height: 1.4; padding: .2rem .1rem; }
|
||||
.blog-menu-item-with-children > .blog-menu-link::after { content: '▾'; font-size: .7em; margin-left: .38rem; opacity: .72; }
|
||||
.blog-menu-link:hover,
|
||||
.blog-menu-link:focus-visible { color: var(--pico-color, var(--color)); text-decoration: underline; }
|
||||
.blog-menu-submenu { position: absolute; top: calc(100% + .12rem); left: 0; min-width: 12rem; display: none; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); background: var(--pico-card-background-color, var(--card-background-color)); padding: .3rem 0; z-index: 10; }
|
||||
.blog-menu-submenu .blog-menu-list { flex-direction: column; flex-wrap: nowrap; gap: 0; margin: 0; }
|
||||
.blog-menu-submenu .blog-menu-item { display: block; padding: 0; margin: 0; }
|
||||
.blog-menu-submenu .blog-menu-link { display: block; padding: .22rem .75rem; font-size: .88rem; line-height: 1.3; }
|
||||
.blog-menu-submenu .blog-menu-item a.blog-menu-link { margin: 0; }
|
||||
.blog-menu-item-with-children:hover > .blog-menu-submenu,
|
||||
.blog-menu-item-with-children:focus-within > .blog-menu-submenu { display: block; }
|
||||
.blog-menu-calendar { position: relative; display: inline-flex; align-items: baseline; justify-content: center; margin-left: auto; align-self: baseline; flex-shrink: 0; }
|
||||
.blog-menu-calendar-button { display: inline-flex; align-items: center; justify-content: center; width: auto; height: auto; margin: 0; padding: .2rem .1rem; border: 0; background: transparent; color: var(--pico-muted-color, var(--muted-color)); border-radius: 0; cursor: pointer; font: inherit; font-size: .94rem; line-height: 1.4; appearance: none; -webkit-appearance: none; vertical-align: baseline; }
|
||||
.blog-menu-calendar-button svg { display: block; width: .9rem; height: .9rem; fill: none; stroke: currentColor; transform: translateY(2px); }
|
||||
.blog-menu-calendar-button:hover,
|
||||
.blog-menu-calendar-button:focus-visible { color: var(--pico-color, var(--color)); }
|
||||
.blog-calendar-panel { position: absolute; top: calc(100% + .15rem); right: 0; width: min(17.5rem, 92vw); border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); background: var(--pico-card-background-color, var(--card-background-color)); padding: .32rem; z-index: 30; }
|
||||
.blog-calendar-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: .1rem; }
|
||||
.blog-calendar-header strong { font-size: .9rem; line-height: 1.2; }
|
||||
.blog-calendar-close { border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); background: transparent; color: var(--pico-muted-color, var(--muted-color)); width: 1.35rem; height: 1.35rem; border-radius: .2rem; padding: 0; cursor: pointer; line-height: 1; }
|
||||
.blog-calendar-close:hover,
|
||||
.blog-calendar-close:focus-visible { color: var(--pico-color, var(--color)); border-color: var(--pico-color, var(--color)); }
|
||||
.blog-calendar-content { display: grid; gap: .08rem; }
|
||||
.blog-calendar-status { margin: .1rem 0 0; color: var(--pico-muted-color, var(--muted-color)); font-size: .74rem; }
|
||||
[data-blog-calendar-root] { font-size: .86rem; }
|
||||
[data-blog-calendar-root] [data-vc=header] { margin-bottom: .08rem; }
|
||||
[data-blog-calendar-root] [data-vc=month],
|
||||
[data-blog-calendar-root] [data-vc=year] { padding: .08rem .18rem; font-size: .9rem; line-height: 1.15; }
|
||||
[data-blog-calendar-root] [data-vc=months],
|
||||
[data-blog-calendar-root] [data-vc=years] { row-gap: .32rem; }
|
||||
[data-blog-calendar-root] [data-vc=years] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
[data-blog-calendar-root] [data-vc-months-month],
|
||||
[data-blog-calendar-root] [data-vc-years-year] { height: 1.72rem; }
|
||||
[data-blog-calendar-root] [data-vc-months-month],
|
||||
[data-blog-calendar-root] [data-vc-years-year] { word-break: normal; white-space: nowrap; }
|
||||
[data-blog-calendar-root] [data-vc-years-year] { min-width: 2.5rem; font-size: .7rem; line-height: 1; }
|
||||
[data-blog-calendar-root] [data-vc-week=days] { margin-bottom: .08rem; }
|
||||
[data-blog-calendar-root] [data-vc-week-day] { font-size: .68rem; line-height: .9rem; min-width: 1.45rem; }
|
||||
[data-blog-calendar-root] [data-vc-date] { padding-top: 0; padding-bottom: 0; }
|
||||
[data-blog-calendar-root] [data-vc-date-btn] { min-height: 1.45rem; min-width: 1.45rem; font-size: .68rem; line-height: .9rem; }
|
||||
[data-blog-calendar-has-posts='true'] [data-vc-date-btn] {
|
||||
border-color: hsl(var(--blog-calendar-heat-hue, 210) 85% 42% / .95);
|
||||
background-color: hsl(var(--blog-calendar-heat-hue, 210) 88% 52% / var(--blog-calendar-heat-alpha, 0));
|
||||
}
|
||||
[data-blog-calendar-root] [data-vc-months-month][data-blog-calendar-has-posts='true'],
|
||||
[data-blog-calendar-root] [data-vc-years-year][data-blog-calendar-has-posts='true'] {
|
||||
background-color: hsl(var(--blog-calendar-heat-hue, 210) 88% 52% / var(--blog-calendar-heat-alpha, 0));
|
||||
border-color: hsl(var(--blog-calendar-heat-hue, 210) 85% 42% / .95);
|
||||
}
|
||||
.post { border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); padding: 1rem; background: var(--pico-card-background-color, var(--card-background-color)); min-width: 0; }
|
||||
.post pre { position: relative; overflow-x: auto; max-width: 100%; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); border-radius: .3rem; margin: .9rem 0; padding: .85rem .9rem; background: var(--pico-code-background-color, rgba(33, 38, 45, .82)); box-sizing: border-box; }
|
||||
.post pre code { display: block; font-size: .88rem; line-height: 1.5; white-space: pre; }
|
||||
.code-copy-button {
|
||||
position: absolute;
|
||||
top: .4rem;
|
||||
right: .4rem;
|
||||
border: 1px solid var(--pico-muted-border-color, var(--muted-border-color));
|
||||
background: var(--pico-card-background-color, var(--card-background-color));
|
||||
color: var(--pico-muted-color, var(--muted-color));
|
||||
border-radius: .25rem;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: .88;
|
||||
}
|
||||
.code-copy-button:hover,
|
||||
.code-copy-button:focus-visible { opacity: 1; color: var(--pico-color, var(--color)); }
|
||||
.code-copy-icon { font-size: .95rem; line-height: 1; }
|
||||
.code-copy-success .code-copy-button { color: var(--pico-ins-color, rgb(53, 117, 56)); border-color: var(--pico-ins-color, rgb(53, 117, 56)); }
|
||||
.code-copy-failed .code-copy-button { color: var(--pico-del-color, rgb(183, 72, 72)); border-color: var(--pico-del-color, rgb(183, 72, 72)); }
|
||||
.post iframe { width: 100%; min-height: 20rem; }
|
||||
.macro-youtube, .macro-vimeo { margin-bottom: 1rem; }
|
||||
.macro-gallery, .macro-photo-archive, .macro-tag-cloud { border: 1px dashed var(--pico-muted-border-color, var(--muted-border-color)); padding: .75rem; margin: 1rem 0; }
|
||||
.gallery-container { display: grid; gap: .5rem; }
|
||||
.macro-gallery.gallery-cols-1 .gallery-container { grid-template-columns: 1fr; }
|
||||
.macro-gallery.gallery-cols-2 .gallery-container { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.macro-gallery.gallery-cols-3 .gallery-container { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.macro-gallery.gallery-cols-4 .gallery-container { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.macro-gallery.gallery-cols-5 .gallery-container { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.macro-gallery.gallery-cols-6 .gallery-container { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
.gallery-item, .photo-archive-item { display: block; overflow: hidden; border-radius: .25rem; }
|
||||
.gallery-item img, .photo-archive-item img { display: block; width: 100%; height: auto; aspect-ratio: 1 / 1; object-fit: cover; }
|
||||
.lb-nav a, .lb-nav a:hover, .lb-nav a:focus-visible { border: 0; box-shadow: none; outline: none; text-decoration: none; }
|
||||
.gallery-caption { margin-top: .5rem; text-align: center; color: var(--pico-muted-color, var(--muted-color)); font-size: .92rem; }
|
||||
.gallery-empty, .photo-archive-empty { color: var(--pico-muted-color, var(--muted-color)); font-style: italic; }
|
||||
.photo-archive-container { display: grid; gap: 1rem; }
|
||||
.photo-archive-month { display: grid; grid-template-columns: 3.25rem 1fr; gap: .75rem; align-items: start; }
|
||||
.photo-archive-month-label { display: flex; justify-content: center; align-items: center; }
|
||||
.photo-archive-month-label span { writing-mode: vertical-rl; transform: rotate(180deg); letter-spacing: .08em; text-transform: uppercase; color: var(--pico-muted-color, var(--muted-color)); }
|
||||
.photo-archive-gallery { display: grid; gap: .5rem; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.photo-archive-single-month .photo-archive-gallery { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.macro-tag-cloud { min-height: 14rem; }
|
||||
.tag-cloud-canvas { display: block; width: 100%; height: auto; min-height: 12rem; }
|
||||
.tag-cloud-empty { color: var(--pico-muted-color, var(--muted-color)); font-style: italic; }
|
||||
.archive-day-group { display: grid; grid-template-columns: 5.25rem 1fr; gap: 1.25rem; align-items: stretch; }
|
||||
.archive-day-marker { display: flex; justify-content: center; align-items: center; color: var(--pico-muted-color, var(--muted-color)); }
|
||||
.archive-day-marker span { writing-mode: vertical-rl; transform: rotate(180deg); letter-spacing: .16em; font-size: 1.05rem; font-weight: 600; text-transform: uppercase; }
|
||||
.archive-day-posts { display: grid; gap: 1rem; }
|
||||
.archive-day-separator { position: relative; height: 2px; width: 100%; color: var(--pico-color, var(--color)); border-top: 1px solid currentColor; opacity: .18; margin: .45rem 0 .65rem; }
|
||||
.archive-day-separator::before { content: ''; position: absolute; inset: 0; background: linear-gradient(to right, transparent 0%, transparent 18%, currentColor 58%, transparent 92%, transparent 100%); opacity: .85; }
|
||||
.single-post { margin: 0; padding: 0; background: transparent; border: 0; box-shadow: none; }
|
||||
.single-post-taxonomy { display: flex; flex-wrap: wrap; gap: .4rem .45rem; margin: -.1rem 0 .2rem; }
|
||||
.single-post-taxonomy-bubble {
|
||||
--bubble-accent: var(--pico-ins-color, rgb(53, 117, 56));
|
||||
--bubble-bg: var(--bubble-accent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--bubble-accent);
|
||||
border-radius: 999px;
|
||||
padding: .1rem .5rem;
|
||||
font-size: .74rem;
|
||||
line-height: 1.35;
|
||||
color: #000;
|
||||
background: var(--bubble-bg, var(--bubble-accent));
|
||||
text-decoration: none;
|
||||
}
|
||||
.single-post-taxonomy-bubble:hover,
|
||||
.single-post-taxonomy-bubble:focus-visible { text-decoration: underline; }
|
||||
.single-post-taxonomy-bubble-category { --bubble-accent: var(--pico-ins-color, rgb(53, 117, 56)); --bubble-bg: var(--pico-ins-color, rgb(53, 117, 56)); }
|
||||
.single-post-taxonomy-bubble-tag { --bubble-accent: var(--pico-del-color, rgb(183, 72, 72)); --bubble-bg: var(--pico-del-color, rgb(183, 72, 72)); }
|
||||
.single-post-backlinks { display: flex; flex-wrap: wrap; gap: .4rem .45rem; align-items: center; margin-top: 1.5rem; }
|
||||
.single-post-backlinks-label { font-size: .74rem; line-height: 1.35; color: var(--pico-muted-color, var(--muted-color)); margin-right: .15rem; }
|
||||
.single-post-backlink-bubble { --bubble-accent: var(--pico-primary, rgb(16, 107, 193)); --bubble-bg: var(--pico-primary, rgb(16, 107, 193)); color: var(--pico-primary-inverse, #fff); }
|
||||
.preview-pagination { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-top: .25rem; }
|
||||
.preview-pagination-link { color: var(--pico-muted-color, var(--muted-color)); text-decoration: none; font-size: .92rem; opacity: .72; transition: opacity .15s ease-in-out; }
|
||||
.preview-pagination-link:hover,
|
||||
.preview-pagination-link:focus-visible { opacity: 1; text-decoration: underline; }
|
||||
.preview-pagination .spacer { flex: 1; }
|
||||
.not-found { display: grid; place-items: center; min-height: 48vh; }
|
||||
.not-found article { max-width: 32rem; text-align: center; }
|
||||
.language-switcher { position: fixed; right: .75rem; top: 1.5rem; display: flex; flex-direction: column; gap: .1rem; z-index: 100; }
|
||||
.language-switcher-badge { display: block; padding: .05rem .1rem; font-size: .85rem; line-height: 1.1; text-decoration: none; border: 1px solid transparent; border-radius: .15rem; cursor: pointer; opacity: .7; transition: opacity .15s ease-in-out; }
|
||||
.language-switcher-badge:hover,
|
||||
.language-switcher-badge:focus-visible { opacity: 1; border-color: var(--pico-color, var(--color)); }
|
||||
.language-switcher-badge-current { opacity: 1; border-color: var(--pico-primary, var(--primary)); }
|
||||
.blog-search-widget, .blog-search-standalone { position: relative; margin-top: .15rem; }
|
||||
.blog-search-standalone { position: fixed; right: .75rem; top: 1.5rem; z-index: 100; }
|
||||
.blog-search-toggle { display: inline-flex; align-items: center; justify-content: center; border: 0; background: transparent; color: var(--pico-muted-color, var(--muted-color)); cursor: pointer; padding: .15rem; opacity: .7; transition: opacity .15s ease-in-out; }
|
||||
.blog-search-toggle:hover, .blog-search-toggle:focus-visible { opacity: 1; color: var(--pico-color, var(--color)); }
|
||||
.blog-search-toggle svg { display: block; }
|
||||
.blog-search-panel { position: absolute; top: calc(100% + .25rem); right: 0; width: min(24rem, 90vw); z-index: 40; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); background: var(--pico-card-background-color, var(--card-background-color)); padding: .5rem; border-radius: .35rem; box-shadow: 0 4px 24px rgba(0,0,0,.25); }
|
||||
.blog-search-panel .pagefind-ui { --pagefind-ui-scale: .8; --pagefind-ui-primary: var(--pico-primary, var(--primary)); --pagefind-ui-text: var(--pico-color, var(--color)); --pagefind-ui-background: var(--pico-card-background-color, var(--card-background-color)); --pagefind-ui-border: var(--pico-muted-border-color, var(--muted-border-color)); --pagefind-ui-tag: var(--pico-muted-border-color, var(--muted-border-color)); --pagefind-ui-border-width: 1px; --pagefind-ui-border-radius: .2rem; --pagefind-ui-image-border-radius: .2rem; --pagefind-ui-image-box-ratio: 0; --pagefind-ui-font: inherit; font-size: .85rem; }
|
||||
.blog-search-panel .pagefind-ui__search-input { font-size: .85rem; padding: .3rem .5rem; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); background: var(--pico-background-color, var(--background-color)); color: var(--pico-color, var(--color)); border-radius: .2rem; width: 100%; }
|
||||
.blog-search-panel .pagefind-ui__search-clear { color: var(--pico-muted-color, var(--muted-color)); background: none; font-size: .8rem; }
|
||||
.blog-search-panel .pagefind-ui__search-clear:focus { outline-color: var(--pico-primary, var(--primary)); }
|
||||
.blog-search-panel .pagefind-ui__drawer { max-height: min(60vh, 28rem); overflow-y: auto; }
|
||||
.blog-search-panel .pagefind-ui__message { color: var(--pico-muted-color, var(--muted-color)); font-size: .78rem; padding: .25rem 0; }
|
||||
.blog-search-panel .pagefind-ui__result { border-top: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); padding: .4rem 0; }
|
||||
.blog-search-panel .pagefind-ui__result-link { color: var(--pico-primary, var(--primary)); font-size: .85rem; }
|
||||
.blog-search-panel .pagefind-ui__result-title { font-size: .85rem; }
|
||||
.blog-search-panel .pagefind-ui__result-excerpt { font-size: .78rem; color: var(--pico-muted-color, var(--muted-color)); }
|
||||
.blog-search-panel .pagefind-ui__result-excerpt mark { background-color: var(--pico-primary-focus, rgba(255,223,0,.35)); color: inherit; }
|
||||
.blog-search-panel .pagefind-ui__button { color: var(--pico-primary, var(--primary)); background: none; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); border-radius: .2rem; font-size: .78rem; cursor: pointer; }
|
||||
.blog-search-panel .pagefind-ui__button:hover { border-color: var(--pico-primary, var(--primary)); }
|
||||
136
priv/preview_assets/code-enhancements.js
Normal file
136
priv/preview_assets/code-enhancements.js
Normal file
@@ -0,0 +1,136 @@
|
||||
(function () {
|
||||
function resolveCodeLanguage(codeElement) {
|
||||
if (!codeElement) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var direct = codeElement.getAttribute('data-code-language');
|
||||
if (typeof direct === 'string' && direct.trim()) {
|
||||
return direct.trim().toLowerCase();
|
||||
}
|
||||
|
||||
var className = codeElement.className || '';
|
||||
var classMatch = className.match(/(?:^|\s)language-([\w.+-]+)/i);
|
||||
if (classMatch && classMatch[1]) {
|
||||
return classMatch[1].toLowerCase();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function fallbackCopy(value) {
|
||||
var textarea = document.createElement('textarea');
|
||||
textarea.value = value;
|
||||
textarea.setAttribute('readonly', 'readonly');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
return document.execCommand('copy');
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCodeToClipboard(value) {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return fallbackCopy(value);
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackCopy(value);
|
||||
}
|
||||
|
||||
function ensureCopyButton(preElement, codeElement) {
|
||||
if (!preElement || preElement.querySelector(':scope > .code-copy-button')) {
|
||||
return;
|
||||
}
|
||||
|
||||
preElement.classList.add('code-block-enhanced');
|
||||
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'code-copy-button';
|
||||
button.setAttribute('aria-hidden', 'true');
|
||||
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'code-copy-icon';
|
||||
icon.textContent = '⧉';
|
||||
button.appendChild(icon);
|
||||
|
||||
button.addEventListener('click', async function () {
|
||||
var codeText = codeElement.textContent || '';
|
||||
var copied = await copyCodeToClipboard(codeText);
|
||||
preElement.classList.remove('code-copy-failed');
|
||||
preElement.classList.remove('code-copy-success');
|
||||
preElement.classList.add(copied ? 'code-copy-success' : 'code-copy-failed');
|
||||
|
||||
if (copied) {
|
||||
icon.textContent = '✓';
|
||||
window.setTimeout(function () {
|
||||
preElement.classList.remove('code-copy-success');
|
||||
icon.textContent = '⧉';
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(function () {
|
||||
preElement.classList.remove('code-copy-failed');
|
||||
}, 1200);
|
||||
});
|
||||
|
||||
preElement.appendChild(button);
|
||||
}
|
||||
|
||||
function highlightCodeBlock(codeElement) {
|
||||
var highlighter = window.hljs;
|
||||
if (!highlighter || typeof highlighter.highlightElement !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (codeElement.getAttribute('data-code-highlighted') === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
highlighter.highlightElement(codeElement);
|
||||
codeElement.setAttribute('data-code-highlighted', 'true');
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
|
||||
function initCodeBlocks() {
|
||||
var codeNodes = document.querySelectorAll('pre > code');
|
||||
codeNodes.forEach(function (codeElement) {
|
||||
var preElement = codeElement.parentElement;
|
||||
if (!preElement || preElement.tagName !== 'PRE') {
|
||||
return;
|
||||
}
|
||||
|
||||
var language = resolveCodeLanguage(codeElement);
|
||||
if (language) {
|
||||
codeElement.setAttribute('data-code-language', language);
|
||||
preElement.setAttribute('data-code-language', language);
|
||||
}
|
||||
|
||||
ensureCopyButton(preElement, codeElement);
|
||||
highlightCodeBlock(codeElement);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initCodeBlocks, { once: true });
|
||||
} else {
|
||||
initCodeBlocks();
|
||||
}
|
||||
})();
|
||||
@@ -78,6 +78,16 @@ defmodule BDS.PreviewTest do
|
||||
assert {:ok, %{body: "console.log('pagefind')", content_type: "application/javascript"}} =
|
||||
BDS.Preview.request(project.id, "/pagefind/pagefind-ui.js")
|
||||
|
||||
assert {:ok, %{body: pico_css, content_type: "text/css"}} =
|
||||
BDS.Preview.request(project.id, "/assets/pico.min.css")
|
||||
|
||||
assert pico_css =~ ":root"
|
||||
|
||||
assert {:ok, %{body: bds_css, content_type: "text/css"}} =
|
||||
BDS.Preview.request(project.id, "/assets/bds.css")
|
||||
|
||||
assert bds_css =~ ".blog-menu"
|
||||
|
||||
assert {:ok, %{body: "media body", content_type: "text/plain"}} =
|
||||
BDS.Preview.request(project.id, "/media/2026/04/image.txt")
|
||||
|
||||
@@ -85,6 +95,7 @@ defmodule BDS.PreviewTest do
|
||||
BDS.Preview.preview_draft(project.id, "/draft/draft-post", post.id)
|
||||
|
||||
assert draft_html =~ "Draft preview body"
|
||||
assert draft_html =~ ~s(href="/assets/pico.min.css")
|
||||
assert {:error, :not_found} = BDS.Preview.request(project.id, "/media/../../secret.txt")
|
||||
|
||||
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||
@@ -202,6 +213,40 @@ defmodule BDS.PreviewTest do
|
||||
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||
end
|
||||
|
||||
test "http draft preview serves published post body from the file-backed canonical route", %{
|
||||
project: project
|
||||
} do
|
||||
:inets.start()
|
||||
|
||||
assert {:ok, post} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Published HTTP Preview",
|
||||
content: "Published body from file",
|
||||
language: "en"
|
||||
})
|
||||
|
||||
assert {:ok, _published} = Posts.publish_post(post.id)
|
||||
assert {:ok, server} = BDS.Preview.start_preview(project.id)
|
||||
|
||||
datetime = DateTime.from_unix!(post.created_at, :millisecond)
|
||||
|
||||
request_url =
|
||||
"http://#{server.host}:#{server.port}/#{datetime.year}/#{String.pad_leading(Integer.to_string(datetime.month), 2, "0")}/#{String.pad_leading(Integer.to_string(datetime.day), 2, "0")}/#{post.slug}?draft=true&post_id=#{post.id}"
|
||||
|
||||
assert {:ok, {{_version, 200, _reason}, _headers, body}} =
|
||||
:httpc.request(
|
||||
:get,
|
||||
{to_charlist(request_url), []},
|
||||
[],
|
||||
body_format: :binary
|
||||
)
|
||||
|
||||
assert body =~ "Published body from file"
|
||||
|
||||
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||
end
|
||||
|
||||
test "preview renders not-found template for missing routes and rewrites markdown macros and canonical URLs",
|
||||
%{project: project, temp_dir: temp_dir} do
|
||||
:inets.start()
|
||||
@@ -362,12 +407,14 @@ defmodule BDS.PreviewTest do
|
||||
|
||||
assert generated_html =~ ~s(data-theme="amber")
|
||||
assert generated_html =~ ~s(data-mode="dark")
|
||||
assert generated_html =~ ~s(/assets/pico.amber.min.css)
|
||||
|
||||
assert {:ok, %{body: draft_html, content_type: "text/html"}} =
|
||||
BDS.Preview.preview_draft(project.id, "/draft/theme-draft?theme=amber&mode=dark", post.id)
|
||||
|
||||
assert draft_html =~ ~s(data-theme="amber")
|
||||
assert draft_html =~ ~s(data-mode="dark")
|
||||
assert draft_html =~ ~s(/assets/pico.amber.min.css)
|
||||
|
||||
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user