feat: preview working

This commit is contained in:
2026-04-26 19:17:22 +02:00
parent f866aeca0a
commit 57d255f79e
7 changed files with 833 additions and 61 deletions

View File

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

View File

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

View File

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