feat: claims for having the js parts in (not working, though)

This commit is contained in:
2026-04-26 19:26:11 +02:00
parent 57d255f79e
commit 09a1dcede3
42 changed files with 2662 additions and 353 deletions

View File

@@ -6,6 +6,7 @@ defmodule BDS.Generation do
alias BDS.Generation.GeneratedFileHash alias BDS.Generation.GeneratedFileHash
alias BDS.Metadata alias BDS.Metadata
alias BDS.Persistence alias BDS.Persistence
alias BDS.PreviewAssets
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Posts.Translation alias BDS.Posts.Translation
alias BDS.Projects alias BDS.Projects
@@ -272,7 +273,14 @@ defmodule BDS.Generation do
[] []
end end
core_outputs ++ single_outputs ++ archive_outputs ++ sitemap ++ pagefind_outputs asset_outputs =
if :core in plan.sections do
PreviewAssets.generated_outputs()
else
[]
end
core_outputs ++ single_outputs ++ archive_outputs ++ sitemap ++ pagefind_outputs ++ asset_outputs
end end
defp disk_generated_files(project_id) do defp disk_generated_files(project_id) do

View File

@@ -466,28 +466,39 @@ defmodule BDS.Preview do
end end
defp apply_preview_overrides(body, query_params) when is_binary(body) and is_map(query_params) do defp apply_preview_overrides(body, query_params) when is_binary(body) and is_map(query_params) do
theme_override = normalize_pico_theme_override(query_params["theme"])
mode_override = normalize_mode_override(query_params["mode"])
body body
|> override_pico_stylesheet_href(normalize_override(query_params["theme"])) |> override_pico_stylesheet_href(theme_override)
|> override_html_attribute("data-theme", normalize_override(query_params["theme"])) |> override_html_attribute("data-theme", mode_override)
|> override_html_attribute("data-mode", normalize_override(query_params["mode"])) |> override_html_attribute("data-mode", mode_override)
end end
defp override_pico_stylesheet_href(body, nil), do: body defp override_pico_stylesheet_href(body, nil), do: body
defp override_pico_stylesheet_href(body, theme) do defp override_pico_stylesheet_href(body, theme) do
replacement = Regex.replace(
case theme do ~r{/assets/pico(?:\.[a-z]+)?\.min\.css},
"default" -> "/assets/pico.min.css" body,
value -> "/assets/pico.#{value}.min.css" PreviewAssets.stylesheet_href(theme),
end global: false
)
Regex.replace(~r{/assets/pico(?:\.[a-z]+)?\.min\.css}, body, replacement, global: false)
end end
defp normalize_override(nil), do: nil defp normalize_override(nil), do: nil
defp normalize_override(""), do: nil defp normalize_override(""), do: nil
defp normalize_override(value), do: String.trim(value) defp normalize_override(value), do: String.trim(value)
defp normalize_pico_theme_override(value), do: normalize_override(value)
defp normalize_mode_override(value) do
case normalize_override(value) do
mode when mode in ["dark", "light"] -> mode
_other -> nil
end
end
defp override_html_attribute(body, _attribute, nil), do: body defp override_html_attribute(body, _attribute, nil), do: body
defp override_html_attribute(body, attribute, value) do defp override_html_attribute(body, attribute, value) do
@@ -509,8 +520,7 @@ defmodule BDS.Preview do
defp not_found_assigns(query_params) do defp not_found_assigns(query_params) do
%{} %{}
|> maybe_put_assign("html_theme_attribute", query_params["theme"], fn value -> ~s(data-theme="#{value}") end) |> maybe_put_assign("pico_stylesheet_href", normalize_pico_theme_override(query_params["theme"]), &PreviewAssets.stylesheet_href/1)
|> maybe_put_assign("html_mode_attribute", query_params["mode"], fn value -> ~s(data-mode="#{value}") end)
end end
defp maybe_put_assign(assigns, _key, nil, _mapper), do: assigns defp maybe_put_assign(assigns, _key, nil, _mapper), do: assigns

View File

@@ -1,335 +1,70 @@
defmodule BDS.PreviewAssets do defmodule BDS.PreviewAssets do
@moduledoc false @moduledoc false
@theme_tokens %{ @preview_root Application.app_dir(:bds, "priv/preview_assets")
"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 def response(pathname) when is_binary(pathname) do
case Regex.run(~r{^/assets/([^/]+)$}, pathname) do pathname
[_, asset_name] -> build_asset_response(asset_name) |> request_path()
_other -> :error |> case do
{:ok, relative_path} ->
case File.read(Path.join(@preview_root, relative_path)) do
{:ok, body} -> {:ok, %{content_type: content_type(relative_path), body: body}}
{:error, _reason} -> :error
end
:error ->
:error
end end
end end
defp build_asset_response(asset_name) do def generated_outputs do
case asset_payload(asset_name) do ["assets", "images"]
{:ok, content_type, body} -> {:ok, %{content_type: content_type, body: body}} |> Enum.flat_map(fn directory ->
:error -> :error @preview_root
|> Path.join(directory)
|> Path.join("**/*")
|> Path.wildcard(match_dot: false)
end)
|> Enum.filter(&File.regular?/1)
|> Enum.sort()
|> Enum.map(fn path ->
{Path.relative_to(path, @preview_root), File.read!(path)}
end)
end
def stylesheet_href(nil), do: "/assets/pico.min.css"
def stylesheet_href(""), do: "/assets/pico.min.css"
def stylesheet_href("default"), do: "/assets/pico.min.css"
def stylesheet_href(theme), do: "/assets/pico.#{theme}.min.css"
defp request_path(pathname) do
normalized = pathname |> URI.parse() |> Map.get(:path, pathname)
case String.split(normalized, "/", trim: true) do
[directory, filename] when directory in ["assets", "images"] ->
relative_path = Path.join(directory, filename)
if File.regular?(Path.join(@preview_root, relative_path)) do
{:ok, relative_path}
else
:error
end
_other ->
:error
end end
end end
defp asset_payload(asset_name) do defp content_type(path) do
case Regex.run(~r/^pico(?:\.([a-z]+))?\.min\.css$/, asset_name) do case Path.extname(path) do
[_, theme] -> {:ok, "text/css", pico_stylesheet(theme)} ".css" -> "text/css"
[single] when single == asset_name -> {:ok, "text/css", pico_stylesheet(nil)} ".js" -> "application/javascript"
_other -> named_asset_payload(asset_name) ".png" -> "image/png"
".gif" -> "image/gif"
".jpg" -> "image/jpeg"
".jpeg" -> "image/jpeg"
_other -> "application/octet-stream"
end end
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 end

View File

@@ -8,6 +8,7 @@ defmodule BDS.Rendering do
alias BDS.Media.Media, as: MediaAsset alias BDS.Media.Media, as: MediaAsset
alias BDS.Menu alias BDS.Menu
alias BDS.Metadata alias BDS.Metadata
alias BDS.PreviewAssets
alias BDS.PostLinks alias BDS.PostLinks
alias BDS.Projects alias BDS.Projects
alias BDS.I18n alias BDS.I18n
@@ -182,7 +183,7 @@ defmodule BDS.Rendering do
Map.get( Map.get(
assigns, assigns,
:html_theme_attribute, :html_theme_attribute,
Map.get(assigns, "html_theme_attribute", html_theme_attribute(metadata.pico_theme)) Map.get(assigns, "html_theme_attribute")
), ),
blog_languages: blog_languages(metadata, language), blog_languages: blog_languages(metadata, language),
alternate_links: alternate_links(canonical_post, project_id, main_language), alternate_links: alternate_links(canonical_post, project_id, main_language),
@@ -240,7 +241,7 @@ defmodule BDS.Rendering do
Map.get( Map.get(
assigns, assigns,
:html_theme_attribute, :html_theme_attribute,
Map.get(assigns, "html_theme_attribute", html_theme_attribute(metadata.pico_theme)) Map.get(assigns, "html_theme_attribute")
), ),
blog_languages: blog_languages(metadata, language), blog_languages: blog_languages(metadata, language),
alternate_links: [], alternate_links: [],
@@ -297,7 +298,7 @@ defmodule BDS.Rendering do
Map.get( Map.get(
assigns, assigns,
:html_theme_attribute, :html_theme_attribute,
Map.get(assigns, "html_theme_attribute", html_theme_attribute(metadata.pico_theme)) Map.get(assigns, "html_theme_attribute")
), ),
blog_languages: blog_languages(metadata, language), blog_languages: blog_languages(metadata, language),
menu_items: menu_items(project_id), menu_items: menu_items(project_id),
@@ -723,15 +724,7 @@ defmodule BDS.Rendering do
defp show_archive_range_heading?(%{kind: "date"}, _day_blocks), do: true defp show_archive_range_heading?(%{kind: "date"}, _day_blocks), do: true
defp show_archive_range_heading?(_archive_context, _day_blocks), do: false defp show_archive_range_heading?(_archive_context, _day_blocks), do: false
defp html_theme_attribute(nil), do: nil defp default_pico_stylesheet_href(theme), do: PreviewAssets.stylesheet_href(theme)
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(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(""), do: "/"
defp href_for_language(prefix), do: prefix <> "/" defp href_for_language(prefix), do: prefix <> "/"

View File

@@ -0,0 +1,299 @@
(() => {
const button = document.querySelector('[data-blog-calendar-toggle]');
const panel = document.querySelector('[data-blog-calendar-panel]');
const closeButton = document.querySelector('[data-blog-calendar-close]');
const calendarRoot = document.querySelector('[data-blog-calendar-root]');
const status = document.querySelector('[data-blog-calendar-status]');
if (!button || !panel || !calendarRoot || !status) {
return;
}
const languagePrefix = document.documentElement.getAttribute('data-language-prefix') || '';
const labels = {
loading: panel.getAttribute('data-i18n-loading') || 'Loading calendar…',
error: panel.getAttribute('data-i18n-error') || 'Calendar data could not be loaded.',
};
let isInitialized = false;
let years = {};
let months = {};
let days = {};
let maxYearCount = 0;
let maxMonthCount = 0;
let maxDayCount = 0;
function pad2(value) {
return String(value).padStart(2, '0');
}
function normalizeCountMap(value) {
if (!value || typeof value !== 'object') {
return {};
}
const map = {};
for (const [key, rawCount] of Object.entries(value)) {
const count = Number(rawCount);
if (!Number.isFinite(count) || count <= 0) {
continue;
}
map[key] = Math.floor(count);
}
return map;
}
function computeMaxCount(value) {
const counts = Object.values(value || {});
if (counts.length === 0) {
return 0;
}
return Math.max(...counts.map((count) => Number(count) || 0));
}
function applyHeatStyle(target, count, maxCount) {
if (!(target instanceof HTMLElement) || !Number.isFinite(count) || count <= 0 || !Number.isFinite(maxCount) || maxCount <= 0) {
target?.style?.setProperty('--blog-calendar-heat-alpha', '0');
target?.style?.setProperty('--blog-calendar-heat-hue', '210');
return;
}
const normalized = Math.min(1, count / maxCount);
const hue = Math.round(210 - (210 * normalized));
const alpha = (0.30 + normalized * 0.65).toFixed(3);
target.style.setProperty('--blog-calendar-heat-hue', String(hue));
target.style.setProperty('--blog-calendar-heat-alpha', alpha);
}
function navigateTo(pathname) {
if (!pathname) {
return;
}
window.location.assign(languagePrefix + pathname);
}
function parseInitialYearMonth() {
const initialYearRaw = button.getAttribute('data-blog-calendar-year');
const initialMonthRaw = button.getAttribute('data-blog-calendar-month');
const initialYear = Number(initialYearRaw);
const initialMonth = Number(initialMonthRaw);
let selectedYear = Number.isInteger(initialYear) && initialYear > 0 ? initialYear : null;
let selectedMonth = Number.isInteger(initialMonth) && initialMonth >= 1 && initialMonth <= 12
? (initialMonth - 1)
: null;
if (!Number.isInteger(selectedYear) || !Number.isInteger(selectedMonth)) {
const rawPathname = window.location.pathname || '';
const pathname = languagePrefix && rawPathname.startsWith(languagePrefix + '/')
? rawPathname.slice(languagePrefix.length)
: rawPathname;
const parts = pathname.split('/').filter(Boolean);
const pathYear = Number(parts[0]);
const pathMonth = Number(parts[1]);
if (!Number.isInteger(selectedYear) && Number.isInteger(pathYear) && pathYear > 0 && String(parts[0]).length === 4) {
selectedYear = pathYear;
}
if (!Number.isInteger(selectedMonth) && Number.isInteger(pathMonth) && pathMonth >= 1 && pathMonth <= 12) {
selectedMonth = pathMonth - 1;
}
}
return { selectedYear, selectedMonth };
}
async function loadCalendarData() {
const response = await fetch('/calendar.json', { cache: 'no-store' });
if (!response.ok) {
throw new Error('calendar.json request failed');
}
const parsed = await response.json();
years = normalizeCountMap(parsed?.years);
months = normalizeCountMap(parsed?.months);
days = normalizeCountMap(parsed?.days);
maxYearCount = computeMaxCount(years);
maxMonthCount = computeMaxCount(months);
maxDayCount = computeMaxCount(days);
}
function getDateFromClickEvent(event) {
if (!(event?.target instanceof Element)) {
return '';
}
const dateEl = event.target.closest('[data-vc-date]');
if (!(dateEl instanceof HTMLElement)) {
return '';
}
return dateEl.dataset.vcDate || '';
}
async function initializeCalendar() {
if (isInitialized) {
return;
}
status.textContent = labels.loading;
try {
await loadCalendarData();
const Calendar = window.VanillaCalendarPro?.Calendar;
if (typeof Calendar !== 'function') {
throw new Error('Vanilla Calendar Pro is unavailable');
}
const initialYearMonth = parseInitialYearMonth();
const calendarOptions = {
...(Number.isInteger(initialYearMonth.selectedYear) ? { selectedYear: initialYearMonth.selectedYear } : {}),
...(Number.isInteger(initialYearMonth.selectedMonth) ? { selectedMonth: initialYearMonth.selectedMonth } : {}),
onCreateDateEls(_self, dateEl) {
const dateKey = dateEl.dataset.vcDate || '';
const count = Number(days[dateKey] || 0);
const buttonEl = dateEl.querySelector('[data-vc-date-btn]');
if (!(buttonEl instanceof HTMLElement)) {
return;
}
if (count <= 0) {
dateEl.removeAttribute('data-blog-calendar-has-posts');
applyHeatStyle(buttonEl, 0, maxDayCount);
return;
}
dateEl.setAttribute('data-blog-calendar-has-posts', 'true');
applyHeatStyle(buttonEl, count, maxDayCount);
},
onCreateMonthEls(self, monthEl) {
if (!(monthEl instanceof HTMLElement)) {
return;
}
const monthIndex = Number(monthEl.dataset.vcMonthsMonth);
const selectedYear = Number(self?.context?.selectedYear);
if (!Number.isInteger(monthIndex) || !Number.isInteger(selectedYear)) {
monthEl.removeAttribute('data-blog-calendar-has-posts');
applyHeatStyle(monthEl, 0, maxMonthCount);
return;
}
const monthKey = String(selectedYear) + '-' + pad2(monthIndex + 1);
const count = Number(months[monthKey] || 0);
if (count <= 0) {
monthEl.removeAttribute('data-blog-calendar-has-posts');
applyHeatStyle(monthEl, 0, maxMonthCount);
return;
}
monthEl.setAttribute('data-blog-calendar-has-posts', 'true');
applyHeatStyle(monthEl, count, maxMonthCount);
},
onCreateYearEls(_self, yearEl) {
if (!(yearEl instanceof HTMLElement)) {
return;
}
const yearValue = Number(yearEl.dataset.vcYearsYear);
if (!Number.isInteger(yearValue)) {
yearEl.removeAttribute('data-blog-calendar-has-posts');
applyHeatStyle(yearEl, 0, maxYearCount);
return;
}
const yearKey = String(yearValue);
const count = Number(years[yearKey] || 0);
if (count <= 0) {
yearEl.removeAttribute('data-blog-calendar-has-posts');
applyHeatStyle(yearEl, 0, maxYearCount);
return;
}
yearEl.setAttribute('data-blog-calendar-has-posts', 'true');
applyHeatStyle(yearEl, count, maxYearCount);
},
onClickDate(_self, event) {
const dateKey = getDateFromClickEvent(event);
if (!dateKey || !days[dateKey]) {
return;
}
const [year, month, day] = dateKey.split('-');
if (!year || !month || !day) {
return;
}
navigateTo('/' + year + '/' + month + '/' + day + '/');
},
onClickMonth(self) {
const selectedYear = Number(self?.context?.selectedYear);
const selectedMonth = Number(self?.context?.selectedMonth);
if (!Number.isInteger(selectedYear) || !Number.isInteger(selectedMonth)) {
return;
}
const monthKey = String(selectedYear) + '-' + pad2(selectedMonth + 1);
if (!months[monthKey]) {
return;
}
navigateTo('/' + String(selectedYear) + '/' + pad2(selectedMonth + 1) + '/');
},
onClickYear(self) {
const selectedYear = Number(self?.context?.selectedYear);
if (!Number.isInteger(selectedYear)) {
return;
}
const yearKey = String(selectedYear);
if (!years[yearKey]) {
return;
}
navigateTo('/' + String(selectedYear) + '/');
},
};
const calendar = new Calendar('[data-blog-calendar-root]', calendarOptions);
calendar.init();
status.textContent = '';
status.setAttribute('hidden', 'hidden');
isInitialized = true;
} catch {
status.textContent = labels.error;
status.removeAttribute('hidden');
}
}
function setPanelOpen(isOpen) {
if (isOpen) {
panel.removeAttribute('hidden');
void initializeCalendar();
return;
}
panel.setAttribute('hidden', 'hidden');
}
button.addEventListener('click', () => {
const isHidden = panel.hasAttribute('hidden');
setPanelOpen(isHidden);
});
if (closeButton) {
closeButton.addEventListener('click', () => {
setPanelOpen(false);
});
}
})();

View File

@@ -31,7 +31,7 @@
try { try {
return document.execCommand('copy'); return document.execCommand('copy');
} catch (_) { } catch {
return false; return false;
} finally { } finally {
document.body.removeChild(textarea); document.body.removeChild(textarea);
@@ -43,7 +43,7 @@
try { try {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
return true; return true;
} catch (_) { } catch {
return fallbackCopy(value); return fallbackCopy(value);
} }
} }
@@ -105,7 +105,7 @@
try { try {
highlighter.highlightElement(codeElement); highlighter.highlightElement(codeElement);
codeElement.setAttribute('data-code-highlighted', 'true'); codeElement.setAttribute('data-code-highlighted', 'true');
} catch (_) { } catch {
} }
} }

View File

@@ -0,0 +1,505 @@
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.d3||(g.d3 = {}));g=(g.layout||(g.layout = {}));g.cloud = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
// Word cloud layout by Jason Davies, https://www.jasondavies.com/wordcloud/
// Algorithm due to Jonathan Feinberg, https://s3.amazonaws.com/static.mrfeinberg.com/bv_ch03.pdf
const dispatch = require("d3-dispatch").dispatch;
const RADIANS = Math.PI / 180;
const SPIRALS = {
archimedean: archimedeanSpiral,
rectangular: rectangularSpiral
};
const cw = 1 << 11 >> 5;
const ch = 1 << 11;
module.exports = function() {
var size = [256, 256],
text = cloudText,
font = cloudFont,
fontSize = cloudFontSize,
fontStyle = cloudFontNormal,
fontWeight = cloudFontNormal,
rotate = cloudRotate,
padding = cloudPadding,
spiral = archimedeanSpiral,
words = [],
timeInterval = Infinity,
event = dispatch("word", "end"),
timer = null,
random = Math.random,
cloud = {},
canvas = cloudCanvas;
cloud.canvas = function(_) {
return arguments.length ? (canvas = functor(_), cloud) : canvas;
};
cloud.start = function() {
var contextAndRatio = getContext(canvas()),
board = zeroArray((size[0] >> 5) * size[1]),
bounds = null,
n = words.length,
i = -1,
tags = [],
data = words.map(function(d, i) {
d.text = text.call(this, d, i);
d.font = font.call(this, d, i);
d.style = fontStyle.call(this, d, i);
d.weight = fontWeight.call(this, d, i);
d.rotate = rotate.call(this, d, i);
d.size = ~~fontSize.call(this, d, i);
d.padding = padding.call(this, d, i);
return d;
}).sort(function(a, b) { return b.size - a.size; });
if (timer) clearInterval(timer);
timer = setInterval(step, 0);
step();
return cloud;
function step() {
var start = Date.now();
while (Date.now() - start < timeInterval && ++i < n && timer) {
var d = data[i];
d.x = (size[0] * (random() + .5)) >> 1;
d.y = (size[1] * (random() + .5)) >> 1;
cloudSprite(contextAndRatio, d, data, i);
if (d.hasText && place(board, d, bounds)) {
tags.push(d);
event.call("word", cloud, d);
if (bounds) cloudBounds(bounds, d);
else bounds = [{x: d.x + d.x0, y: d.y + d.y0}, {x: d.x + d.x1, y: d.y + d.y1}];
// Temporary hack
d.x -= size[0] >> 1;
d.y -= size[1] >> 1;
}
}
if (i >= n) {
cloud.stop();
event.call("end", cloud, tags, bounds);
}
}
}
cloud.stop = function() {
if (timer) {
clearInterval(timer);
timer = null;
}
for (const d of words) {
delete d.sprite;
}
return cloud;
};
function getContext(canvas) {
const context = canvas.getContext("2d", {willReadFrequently: true});
canvas.width = canvas.height = 1;
const ratio = Math.sqrt(context.getImageData(0, 0, 1, 1).data.length >> 2);
canvas.width = (cw << 5) / ratio;
canvas.height = ch / ratio;
context.fillStyle = context.strokeStyle = "red";
return {context, ratio};
}
function place(board, tag, bounds) {
var perimeter = [{x: 0, y: 0}, {x: size[0], y: size[1]}],
startX = tag.x,
startY = tag.y,
maxDelta = Math.sqrt(size[0] * size[0] + size[1] * size[1]),
s = spiral(size),
dt = random() < .5 ? 1 : -1,
t = -dt,
dxdy,
dx,
dy;
while (dxdy = s(t += dt)) {
dx = ~~dxdy[0];
dy = ~~dxdy[1];
if (Math.min(Math.abs(dx), Math.abs(dy)) >= maxDelta) break;
tag.x = startX + dx;
tag.y = startY + dy;
if (tag.x + tag.x0 < 0 || tag.y + tag.y0 < 0 ||
tag.x + tag.x1 > size[0] || tag.y + tag.y1 > size[1]) continue;
// TODO only check for collisions within current bounds.
if (!bounds || collideRects(tag, bounds)) {
if (!cloudCollide(tag, board, size[0])) {
var sprite = tag.sprite,
w = tag.width >> 5,
sw = size[0] >> 5,
lx = tag.x - (w << 4),
sx = lx & 0x7f,
msx = 32 - sx,
h = tag.y1 - tag.y0,
x = (tag.y + tag.y0) * sw + (lx >> 5),
last;
for (var j = 0; j < h; j++) {
last = 0;
for (var i = 0; i <= w; i++) {
board[x + i] |= (last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0);
}
x += sw;
}
return true;
}
}
}
return false;
}
cloud.timeInterval = function(_) {
return arguments.length ? (timeInterval = _ == null ? Infinity : _, cloud) : timeInterval;
};
cloud.words = function(_) {
return arguments.length ? (words = _, cloud) : words;
};
cloud.size = function(_) {
return arguments.length ? (size = [+_[0], +_[1]], cloud) : size;
};
cloud.font = function(_) {
return arguments.length ? (font = functor(_), cloud) : font;
};
cloud.fontStyle = function(_) {
return arguments.length ? (fontStyle = functor(_), cloud) : fontStyle;
};
cloud.fontWeight = function(_) {
return arguments.length ? (fontWeight = functor(_), cloud) : fontWeight;
};
cloud.rotate = function(_) {
return arguments.length ? (rotate = functor(_), cloud) : rotate;
};
cloud.text = function(_) {
return arguments.length ? (text = functor(_), cloud) : text;
};
cloud.spiral = function(_) {
return arguments.length ? (spiral = SPIRALS[_] || _, cloud) : spiral;
};
cloud.fontSize = function(_) {
return arguments.length ? (fontSize = functor(_), cloud) : fontSize;
};
cloud.padding = function(_) {
return arguments.length ? (padding = functor(_), cloud) : padding;
};
cloud.random = function(_) {
return arguments.length ? (random = _, cloud) : random;
};
cloud.on = function() {
var value = event.on.apply(event, arguments);
return value === event ? cloud : value;
};
return cloud;
};
function cloudText(d) {
return d.text;
}
function cloudFont() {
return "serif";
}
function cloudFontNormal() {
return "normal";
}
function cloudFontSize(d) {
return Math.sqrt(d.value);
}
function cloudRotate() {
return (~~(random() * 6) - 3) * 30;
}
function cloudPadding() {
return 1;
}
// Fetches a monochrome sprite bitmap for the specified text.
// Load in batches for speed.
function cloudSprite(contextAndRatio, d, data, di) {
if (d.sprite) return;
var c = contextAndRatio.context,
ratio = contextAndRatio.ratio;
c.clearRect(0, 0, (cw << 5) / ratio, ch / ratio);
var x = 0,
y = 0,
maxh = 0,
n = data.length;
--di;
while (++di < n) {
d = data[di];
c.save();
c.font = d.style + " " + d.weight + " " + ~~((d.size + 1) / ratio) + "px " + d.font;
const metrics = c.measureText(d.text);
const anchor = -Math.floor(metrics.width / 2);
let w = (metrics.width + 1) * ratio;
let h = d.size << 1;
if (d.rotate) {
var sr = Math.sin(d.rotate * RADIANS),
cr = Math.cos(d.rotate * RADIANS),
wcr = w * cr,
wsr = w * sr,
hcr = h * cr,
hsr = h * sr;
w = (Math.max(Math.abs(wcr + hsr), Math.abs(wcr - hsr)) + 0x1f) >> 5 << 5;
h = ~~Math.max(Math.abs(wsr + hcr), Math.abs(wsr - hcr));
} else {
w = (w + 0x1f) >> 5 << 5;
}
if (h > maxh) maxh = h;
if (x + w >= (cw << 5)) {
x = 0;
y += maxh;
maxh = 0;
}
if (y + h >= ch) break;
c.translate((x + (w >> 1)) / ratio, (y + (h >> 1)) / ratio);
if (d.rotate) c.rotate(d.rotate * RADIANS);
c.fillText(d.text, anchor, 0);
if (d.padding) c.lineWidth = 2 * d.padding, c.strokeText(d.text, anchor, 0);
c.restore();
d.width = w;
d.height = h;
d.xoff = x;
d.yoff = y;
d.x1 = w >> 1;
d.y1 = h >> 1;
d.x0 = -d.x1;
d.y0 = -d.y1;
d.hasText = true;
x += w;
}
var pixels = c.getImageData(0, 0, (cw << 5) / ratio, ch / ratio).data,
sprite = [];
while (--di >= 0) {
d = data[di];
if (!d.hasText) continue;
var w = d.width,
w32 = w >> 5,
h = d.y1 - d.y0;
// Zero the buffer
for (var i = 0; i < h * w32; i++) sprite[i] = 0;
x = d.xoff;
if (x == null) return;
y = d.yoff;
var seen = 0,
seenRow = -1;
for (var j = 0; j < h; j++) {
for (var i = 0; i < w; i++) {
var k = w32 * j + (i >> 5),
m = pixels[((y + j) * (cw << 5) + (x + i)) << 2] ? 1 << (31 - (i % 32)) : 0;
sprite[k] |= m;
seen |= m;
}
if (seen) seenRow = j;
else {
d.y0++;
h--;
j--;
y++;
}
}
d.y1 = d.y0 + seenRow;
d.sprite = sprite.slice(0, (d.y1 - d.y0) * w32);
}
}
// Use mask-based collision detection.
function cloudCollide(tag, board, sw) {
sw >>= 5;
var sprite = tag.sprite,
w = tag.width >> 5,
lx = tag.x - (w << 4),
sx = lx & 0x7f,
msx = 32 - sx,
h = tag.y1 - tag.y0,
x = (tag.y + tag.y0) * sw + (lx >> 5),
last;
for (var j = 0; j < h; j++) {
last = 0;
for (var i = 0; i <= w; i++) {
if (((last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0))
& board[x + i]) return true;
}
x += sw;
}
return false;
}
function cloudBounds(bounds, d) {
var b0 = bounds[0],
b1 = bounds[1];
if (d.x + d.x0 < b0.x) b0.x = d.x + d.x0;
if (d.y + d.y0 < b0.y) b0.y = d.y + d.y0;
if (d.x + d.x1 > b1.x) b1.x = d.x + d.x1;
if (d.y + d.y1 > b1.y) b1.y = d.y + d.y1;
}
function collideRects(a, b) {
return a.x + a.x1 > b[0].x && a.x + a.x0 < b[1].x && a.y + a.y1 > b[0].y && a.y + a.y0 < b[1].y;
}
function archimedeanSpiral(size) {
var e = size[0] / size[1];
return function(t) {
return [e * (t *= .1) * Math.cos(t), t * Math.sin(t)];
};
}
function rectangularSpiral(size) {
var dy = 4,
dx = dy * size[0] / size[1],
x = 0,
y = 0;
return function(t) {
var sign = t < 0 ? -1 : 1;
// See triangular numbers: T_n = n * (n + 1) / 2.
switch ((Math.sqrt(1 + 4 * sign * t) - sign) & 3) {
case 0: x += dx; break;
case 1: y += dy; break;
case 2: x -= dx; break;
default: y -= dy; break;
}
return [x, y];
};
}
// TODO reuse arrays?
function zeroArray(n) {
var a = [],
i = -1;
while (++i < n) a[i] = 0;
return a;
}
function cloudCanvas() {
return document.createElement("canvas");
}
function functor(d) {
return typeof d === "function" ? d : function() { return d; };
}
},{"d3-dispatch":2}],2:[function(require,module,exports){
// https://d3js.org/d3-dispatch/ v1.0.6 Copyright 2019 Mike Bostock
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = global || self, factory(global.d3 = global.d3 || {}));
}(this, function (exports) { 'use strict';
var noop = {value: function() {}};
function dispatch() {
for (var i = 0, n = arguments.length, _ = {}, t; i < n; ++i) {
if (!(t = arguments[i] + "") || (t in _) || /[\s.]/.test(t)) throw new Error("illegal type: " + t);
_[t] = [];
}
return new Dispatch(_);
}
function Dispatch(_) {
this._ = _;
}
function parseTypenames(typenames, types) {
return typenames.trim().split(/^|\s+/).map(function(t) {
var name = "", i = t.indexOf(".");
if (i >= 0) name = t.slice(i + 1), t = t.slice(0, i);
if (t && !types.hasOwnProperty(t)) throw new Error("unknown type: " + t);
return {type: t, name: name};
});
}
Dispatch.prototype = dispatch.prototype = {
constructor: Dispatch,
on: function(typename, callback) {
var _ = this._,
T = parseTypenames(typename + "", _),
t,
i = -1,
n = T.length;
// If no callback was specified, return the callback of the given type and name.
if (arguments.length < 2) {
while (++i < n) if ((t = (typename = T[i]).type) && (t = get(_[t], typename.name))) return t;
return;
}
// If a type was specified, set the callback for the given type and name.
// Otherwise, if a null callback was specified, remove callbacks of the given name.
if (callback != null && typeof callback !== "function") throw new Error("invalid callback: " + callback);
while (++i < n) {
if (t = (typename = T[i]).type) _[t] = set(_[t], typename.name, callback);
else if (callback == null) for (t in _) _[t] = set(_[t], typename.name, null);
}
return this;
},
copy: function() {
var copy = {}, _ = this._;
for (var t in _) copy[t] = _[t].slice();
return new Dispatch(copy);
},
call: function(type, that) {
if ((n = arguments.length - 2) > 0) for (var args = new Array(n), i = 0, n, t; i < n; ++i) args[i] = arguments[i + 2];
if (!this._.hasOwnProperty(type)) throw new Error("unknown type: " + type);
for (t = this._[type], i = 0, n = t.length; i < n; ++i) t[i].value.apply(that, args);
},
apply: function(type, that, args) {
if (!this._.hasOwnProperty(type)) throw new Error("unknown type: " + type);
for (var t = this._[type], i = 0, n = t.length; i < n; ++i) t[i].value.apply(that, args);
}
};
function get(type, name) {
for (var i = 0, n = type.length, c; i < n; ++i) {
if ((c = type[i]).name === name) {
return c.value;
}
}
}
function set(type, name, callback) {
for (var i = 0, n = type.length; i < n; ++i) {
if (type[i].name === name) {
type[i] = noop, type = type.slice(0, i).concat(type.slice(i + 1));
break;
}
}
if (callback != null) type.push({name: name, value: callback});
return type;
}
exports.dispatch = dispatch;
Object.defineProperty(exports, '__esModule', { value: true });
}));
},{}]},{},[1])(1)
});

View File

@@ -0,0 +1,10 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub Dark
Description: Dark theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-dark
Current colors taken from GitHub's CSS
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.lb-loader,.lightbox{text-align:center;line-height:0}body.lb-disable-scrolling{overflow:hidden}.lightboxOverlay{position:absolute;top:0;left:0;z-index:9999;background-color:#000;opacity:.8;display:none}.lightbox{position:absolute;left:0;width:100%;z-index:10000;font-weight:400;outline:0}.lightbox .lb-image{display:block;height:auto;max-width:inherit;max-height:none;border-radius:3px;border:4px solid #fff}.lightbox a img{border:none}.lb-outerContainer{position:relative;width:250px;height:250px;margin:0 auto;border-radius:4px;background-color:#fff}.lb-loader,.lb-nav{position:absolute;left:0}.lb-outerContainer:after{content:"";display:table;clear:both}.lb-loader{top:43%;height:25%;width:100%}.lb-cancel{display:block;width:32px;height:32px;margin:0 auto;background:url(../images/loading.gif) no-repeat}.lb-nav{top:0;height:100%;width:100%;z-index:10}.lb-container>.nav{left:0}.lb-nav a{outline:0;background-image:url(data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==)}.lb-next,.lb-prev{height:100%;cursor:pointer;display:block}.lb-nav a.lb-prev{width:34%;left:0;float:left;background:url(../images/prev.png) left 48% no-repeat;opacity:0;-webkit-transition:opacity .6s;-moz-transition:opacity .6s;-o-transition:opacity .6s;transition:opacity .6s}.lb-nav a.lb-prev:hover{opacity:1}.lb-nav a.lb-next{width:64%;right:0;float:right;background:url(../images/next.png) right 48% no-repeat;opacity:0;-webkit-transition:opacity .6s;-moz-transition:opacity .6s;-o-transition:opacity .6s;transition:opacity .6s}.lb-nav a.lb-next:hover{opacity:1}.lb-dataContainer{margin:0 auto;padding-top:5px;width:100%;border-bottom-left-radius:4px;border-bottom-right-radius:4px}.lb-dataContainer:after{content:"";display:table;clear:both}.lb-data{padding:0 4px;color:#ccc}.lb-data .lb-details{width:85%;float:left;text-align:left;line-height:1.1em}.lb-data .lb-caption{font-size:13px;font-weight:700;line-height:1em}.lb-data .lb-caption a{color:#4ae}.lb-data .lb-number{display:block;clear:left;padding-bottom:1em;font-size:12px;color:#999}.lb-data .lb-close{display:block;float:right;width:30px;height:30px;background:url(../images/close.png) top right no-repeat;text-align:right;outline:0;opacity:.7;-webkit-transition:opacity .2s;-moz-transition:opacity .2s;-o-transition:opacity .2s;transition:opacity .2s}.lb-data .lb-close:hover{cursor:pointer;opacity:1}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,52 @@
(() => {
const toggle = document.querySelector('[data-blog-search-toggle]');
const panel = document.querySelector('[data-blog-search-panel]');
const root = document.querySelector('[data-blog-search-root]');
if (!toggle || !panel || !root) {
return;
}
let initialized = false;
function initSearch() {
if (initialized || typeof PagefindUI === 'undefined') {
return;
}
initialized = true;
var placeholder = root.getAttribute('data-search-placeholder') || 'Search...';
new PagefindUI({
element: root,
showSubResults: true,
showImages: false,
translations: { placeholder: placeholder }
});
var input = root.querySelector('input');
if (input) {
input.focus();
}
}
toggle.addEventListener('click', function() {
var isHidden = panel.hasAttribute('hidden');
if (isHidden) {
panel.removeAttribute('hidden');
initSearch();
} else {
panel.setAttribute('hidden', '');
}
});
document.addEventListener('click', function(e) {
if (!panel.hasAttribute('hidden') && !panel.contains(e.target) && !toggle.contains(e.target)) {
panel.setAttribute('hidden', '');
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !panel.hasAttribute('hidden')) {
panel.setAttribute('hidden', '');
toggle.focus();
}
});
})();

View File

@@ -0,0 +1,272 @@
(function () {
function parseWords(rawWords) {
if (!rawWords || typeof rawWords !== 'string') {
return [];
}
try {
const parsed = JSON.parse(rawWords);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function clamp01(value) {
if (!Number.isFinite(value)) {
return 0;
}
if (value < 0) {
return 0;
}
if (value > 1) {
return 1;
}
return value;
}
function parseCssColor(colorValue) {
if (typeof colorValue !== 'string') {
return null;
}
const value = colorValue.trim();
if (!value) {
return null;
}
const hexMatch = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hexMatch) {
const hex = hexMatch[1];
if (hex.length === 3) {
return [
Number.parseInt(hex[0] + hex[0], 16),
Number.parseInt(hex[1] + hex[1], 16),
Number.parseInt(hex[2] + hex[2], 16),
];
}
return [
Number.parseInt(hex.slice(0, 2), 16),
Number.parseInt(hex.slice(2, 4), 16),
Number.parseInt(hex.slice(4, 6), 16),
];
}
const rgbMatch = value.match(/^rgba?\\(([^)]+)\\)$/i);
if (rgbMatch) {
const channels = rgbMatch[1]
.split(',')
.map((channel) => channel.trim())
.slice(0, 3)
.map((channel) => {
if (channel.endsWith('%')) {
return Math.round((Number.parseFloat(channel) / 100) * 255);
}
return Math.round(Number.parseFloat(channel));
});
if (channels.length === 3 && channels.every((channel) => Number.isFinite(channel))) {
return channels.map((channel) => Math.max(0, Math.min(255, channel)));
}
}
return null;
}
function interpolateColor(fromColor, toColor, t) {
return [
Math.round(fromColor[0] + ((toColor[0] - fromColor[0]) * t)),
Math.round(fromColor[1] + ((toColor[1] - fromColor[1]) * t)),
Math.round(fromColor[2] + ((toColor[2] - fromColor[2]) * t)),
];
}
function mixColor(fromColor, toColor, weight) {
return interpolateColor(fromColor, toColor, clamp01(weight));
}
function colorToCss(color) {
return 'rgb(' + color[0] + ',' + color[1] + ',' + color[2] + ')';
}
function getPicoThemeStops() {
const style = window.getComputedStyle(document.documentElement);
const blue = parseCssColor(style.getPropertyValue('--pico-secondary')) || [74, 99, 146];
const green = parseCssColor(style.getPropertyValue('--pico-ins-color')) || [53, 117, 56];
const red = parseCssColor(style.getPropertyValue('--pico-del-color')) || [183, 72, 72];
const yellow = mixColor(green, red, 0.45);
const orange = mixColor(green, red, 0.72);
return [blue, green, yellow, orange, red];
}
function interpolateStops(stops, value) {
if (!Array.isArray(stops) || stops.length === 0) {
return 'currentColor';
}
if (stops.length === 1) {
return colorToCss(stops[0]);
}
const clamped = clamp01(value);
const scaled = clamped * (stops.length - 1);
const lowerIndex = Math.floor(scaled);
const upperIndex = Math.min(stops.length - 1, lowerIndex + 1);
const localT = scaled - lowerIndex;
return colorToCss(interpolateColor(stops[lowerIndex], stops[upperIndex], localT));
}
function resolveQuantileColorMap(words) {
const counts = Array.from(
new Set(words.map((word) => Number(word.count)).filter((count) => Number.isFinite(count)))
).sort((a, b) => a - b);
const quantiles = new Map();
if (counts.length === 0) {
return quantiles;
}
if (counts.length === 1) {
quantiles.set(counts[0], 1);
return quantiles;
}
counts.forEach((count, index) => {
quantiles.set(count, index / (counts.length - 1));
});
return quantiles;
}
function applyThemeAwareColors(words, container) {
const gammaRaw = Number.parseFloat(container.getAttribute('data-color-easing') || '0.7');
const gamma = Number.isFinite(gammaRaw) && gammaRaw > 0 ? gammaRaw : 0.7;
const quantiles = resolveQuantileColorMap(words);
const stops = getPicoThemeStops();
return words.map((word) => {
const count = Number(word.count);
const quantile = quantiles.get(count) ?? 0;
const eased = Math.pow(clamp01(quantile), gamma);
return {
...word,
color: interpolateStops(stops, eased),
};
});
}
function drawTagCloud(container) {
const cloudFactory = window.d3 && window.d3.layout && typeof window.d3.layout.cloud === 'function'
? window.d3.layout.cloud
: null;
if (!cloudFactory) {
return;
}
const langPrefix = document.documentElement.getAttribute('data-language-prefix') || '';
const rawWords = container.getAttribute('data-tag-cloud-words');
const words = parseWords(rawWords);
if (words.length === 0) {
return;
}
const colorDistribution = container.getAttribute('data-color-distribution') || 'quantile';
const colorTheme = container.getAttribute('data-color-theme') || 'pico';
const coloredWords = colorDistribution === 'quantile' && colorTheme === 'pico'
? applyThemeAwareColors(words, container)
: words;
const width = Number.parseInt(container.getAttribute('data-width') || '900', 10) || 900;
const height = Number.parseInt(container.getAttribute('data-height') || '420', 10) || 420;
const orientation = container.getAttribute('data-orientation') || 'horizontal';
const resolveRotation = () => {
if (orientation === 'mixed-hv') {
return Math.random() < 0.5 ? 0 : 90;
}
if (orientation === 'mixed-diagonal') {
const diagonalAngles = [-60, -30, 0, 30, 60, 90];
const index = Math.floor(Math.random() * diagonalAngles.length);
return diagonalAngles[index];
}
return 0;
};
const svgNode = container.querySelector('svg.tag-cloud-canvas');
if (!svgNode) {
return;
}
while (svgNode.firstChild) {
svgNode.removeChild(svgNode.firstChild);
}
cloudFactory()
.size([width, height])
.words(coloredWords.map((word) => ({ ...word })))
.padding(4)
.rotate(() => resolveRotation())
.font('sans-serif')
.fontSize((word) => word.size)
.on('end', (layoutWords) => {
svgNode.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
svgNode.setAttribute('preserveAspectRatio', 'xMidYMid meet');
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('transform', 'translate(' + (width / 2) + ',' + (height / 2) + ')');
for (const word of layoutWords) {
const textNode = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textNode.textContent = word.text;
textNode.setAttribute('text-anchor', 'middle');
textNode.setAttribute('transform', 'translate(' + word.x + ',' + word.y + ')rotate(' + (word.rotate || 0) + ')');
textNode.style.fontFamily = 'sans-serif';
textNode.style.fontSize = word.size + 'px';
textNode.style.fill = typeof word.color === 'string' && word.color.length > 0
? word.color
: 'currentColor';
textNode.style.cursor = 'pointer';
textNode.style.opacity = '0.9';
const titleNode = document.createElementNS('http://www.w3.org/2000/svg', 'title');
titleNode.textContent = word.text + ' (' + word.count + ')';
textNode.appendChild(titleNode);
textNode.addEventListener('click', () => {
if (word && typeof word.url === 'string' && word.url.length > 0) {
window.location.assign(langPrefix + word.url);
}
});
group.appendChild(textNode);
}
svgNode.appendChild(group);
})
.start();
}
function initTagClouds() {
const containers = document.querySelectorAll('[data-tag-cloud="true"]');
containers.forEach((container) => drawTagCloud(container));
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTagClouds, { once: true });
} else {
initTagClouds();
}
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -6,6 +6,7 @@ defmodule BDS.GenerationTest do
alias BDS.Media alias BDS.Media
alias BDS.Metadata alias BDS.Metadata
alias BDS.Posts alias BDS.Posts
alias BDS.PreviewAssets
alias BDS.Repo alias BDS.Repo
setup do setup do
@@ -113,7 +114,7 @@ defmodule BDS.GenerationTest do
"de/pagefind/index.json", "de/pagefind/index.json",
"de/pagefind/pagefind-ui.css", "de/pagefind/pagefind-ui.css",
"de/pagefind/pagefind-ui.js" "de/pagefind/pagefind-ui.js"
] ] ++ Enum.map(PreviewAssets.generated_outputs(), &elem(&1, 0))
assert Enum.sort(Enum.map(result.generated_files, & &1.relative_path)) == assert Enum.sort(Enum.map(result.generated_files, & &1.relative_path)) ==
Enum.sort(expected_paths) Enum.sort(expected_paths)
@@ -125,6 +126,60 @@ defmodule BDS.GenerationTest do
assert File.read!(Path.join([temp_dir, "html", "sitemap.xml"])) =~ "https://example.com/blog/" assert File.read!(Path.join([temp_dir, "html", "sitemap.xml"])) =~ "https://example.com/blog/"
end end
test "core generation writes local macro and preview assets for static html", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog",
main_language: "en",
blog_languages: ["en"],
pico_theme: "green"
})
assert {:ok, result} = BDS.Generation.generate_site(project.id, [:core])
expected_assets = [
"assets/pico.min.css",
"assets/pico.green.min.css",
"assets/lightbox.min.css",
"assets/lightbox.min.js",
"assets/highlight.min.css",
"assets/highlight.min.js",
"assets/code-enhancements.js",
"assets/d3.layout.cloud.js",
"assets/tag-cloud.js",
"assets/vanilla-calendar.min.css",
"assets/vanilla-calendar.min.js",
"assets/calendar-runtime.js",
"assets/search-runtime.js",
"assets/bds.css",
"images/prev.png",
"images/next.png",
"images/close.png",
"images/loading.gif"
]
generated_paths = Enum.map(result.generated_files, & &1.relative_path)
for relative_path <- expected_assets do
assert relative_path in generated_paths
assert File.exists?(Path.join([temp_dir, "html", relative_path]))
end
assert File.read!(Path.join([temp_dir, "html", "assets", "calendar-runtime.js"])) =~
"loadCalendarData"
assert File.read!(Path.join([temp_dir, "html", "assets", "tag-cloud.js"])) =~
"data-tag-cloud-words"
assert File.read!(Path.join([temp_dir, "html", "assets", "bds.css"])) =~ ".blog-menu"
assert File.read!(Path.join([temp_dir, "html", "assets", "pico.green.min.css"])) =~
"color-scheme"
end
test "generation writes feed and atom entries with canonical URLs for published posts", %{ test "generation writes feed and atom entries with canonical URLs for published posts", %{
project: project, project: project,
temp_dir: temp_dir temp_dir: temp_dir

View File

@@ -88,6 +88,23 @@ defmodule BDS.PreviewTest do
assert bds_css =~ ".blog-menu" assert bds_css =~ ".blog-menu"
assert {:ok, %{body: calendar_runtime, content_type: "application/javascript"}} =
BDS.Preview.request(project.id, "/assets/calendar-runtime.js")
assert calendar_runtime =~ "loadCalendarData"
assert calendar_runtime =~ "window.location.assign"
assert {:ok, %{body: tag_cloud_runtime, content_type: "application/javascript"}} =
BDS.Preview.request(project.id, "/assets/tag-cloud.js")
assert tag_cloud_runtime =~ "data-tag-cloud-words"
assert {:ok, %{body: _prev_png, content_type: "image/png"}} =
BDS.Preview.request(project.id, "/images/prev.png")
assert {:ok, %{body: _loading_gif, content_type: "image/gif"}} =
BDS.Preview.request(project.id, "/images/loading.gif")
assert {:ok, %{body: "media body", content_type: "text/plain"}} = assert {:ok, %{body: "media body", content_type: "text/plain"}} =
BDS.Preview.request(project.id, "/media/2026/04/image.txt") BDS.Preview.request(project.id, "/media/2026/04/image.txt")
@@ -405,14 +422,14 @@ defmodule BDS.PreviewTest do
assert {:ok, %{body: generated_html, content_type: "text/html"}} = assert {:ok, %{body: generated_html, content_type: "text/html"}} =
BDS.Preview.request(project.id, "/?theme=amber&mode=dark") BDS.Preview.request(project.id, "/?theme=amber&mode=dark")
assert generated_html =~ ~s(data-theme="amber") assert generated_html =~ ~s(data-theme="dark")
assert generated_html =~ ~s(data-mode="dark") assert generated_html =~ ~s(data-mode="dark")
assert generated_html =~ ~s(/assets/pico.amber.min.css) assert generated_html =~ ~s(/assets/pico.amber.min.css)
assert {:ok, %{body: draft_html, content_type: "text/html"}} = assert {:ok, %{body: draft_html, content_type: "text/html"}} =
BDS.Preview.preview_draft(project.id, "/draft/theme-draft?theme=amber&mode=dark", post.id) 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-theme="dark")
assert draft_html =~ ~s(data-mode="dark") assert draft_html =~ ~s(data-mode="dark")
assert draft_html =~ ~s(/assets/pico.amber.min.css) assert draft_html =~ ~s(/assets/pico.amber.min.css)