diff --git a/lib/bds/desktop/shell_live/post_editor.ex b/lib/bds/desktop/shell_live/post_editor.ex index a3c31ae..ddba32c 100644 --- a/lib/bds/desktop/shell_live/post_editor.ex +++ b/lib/bds/desktop/shell_live/post_editor.ex @@ -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 diff --git a/lib/bds/preview.ex b/lib/bds/preview.ex index 51a24fe..d050e6d 100644 --- a/lib/bds/preview.ex +++ b/lib/bds/preview.ex @@ -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) diff --git a/lib/bds/preview_assets.ex b/lib/bds/preview_assets.ex new file mode 100644 index 0000000..d1db31a --- /dev/null +++ b/lib/bds/preview_assets.ex @@ -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 diff --git a/lib/bds/rendering.ex b/lib/bds/rendering.ex index 7edd7bc..77d448f 100644 --- a/lib/bds/rendering.ex +++ b/lib/bds/rendering.ex @@ -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 <> "/" diff --git a/priv/preview_assets/bds.css b/priv/preview_assets/bds.css new file mode 100644 index 0000000..6e1ef60 --- /dev/null +++ b/priv/preview_assets/bds.css @@ -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)); } \ No newline at end of file diff --git a/priv/preview_assets/code-enhancements.js b/priv/preview_assets/code-enhancements.js new file mode 100644 index 0000000..b60cd10 --- /dev/null +++ b/priv/preview_assets/code-enhancements.js @@ -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(); + } +})(); \ No newline at end of file diff --git a/test/bds/preview_test.exs b/test/bds/preview_test.exs index 9ad72ae..cf4ff77 100644 --- a/test/bds/preview_test.exs +++ b/test/bds/preview_test.exs @@ -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