Files
btmx/.lua/render.lua

267 lines
11 KiB
Lua

-- render.lua — HTML layout and partial helpers.
--
-- render.layout(title, body_html) → full HTML page
-- render.partial(fragment_html) → raw fragment (for HTMX sub-requests)
-- render.respond(title, body_html) → dispatches between the two
--
-- Brand, nav items, and footer come from config (settings table), so
-- they are built per request rather than baked in at load time.
local config = require "config"
local M = {}
-- Tiny inline script applied before CSS to avoid flash-of-wrong-theme.
local THEME_INIT = '<script>(function(){'
.. "var t=localStorage.getItem('theme');"
.. "if(t==='dark'||t==='light'||t==='mac8')document.documentElement.dataset.theme=t;"
.. '})();</script>'
-- Inline SVG icon helper (Lucide-style stroked icons).
local function icon(paths, size)
size = size or 16
return '<svg xmlns="http://www.w3.org/2000/svg"'
.. ' width="' .. size .. '" height="' .. size .. '" viewBox="0 0 24 24"'
.. ' fill="none" stroke="currentColor" stroke-width="2"'
.. ' stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
.. paths .. '</svg>'
end
local ICON_SUN = icon(
'<circle cx="12" cy="12" r="5"/>'
.. '<line x1="12" y1="1" x2="12" y2="3"/>'
.. '<line x1="12" y1="21" x2="12" y2="23"/>'
.. '<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>'
.. '<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>'
.. '<line x1="1" y1="12" x2="3" y2="12"/>'
.. '<line x1="21" y1="12" x2="23" y2="12"/>'
.. '<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>'
.. '<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>')
local ICON_MOON = icon('<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>')
-- Auto/system theme: monitor.
local ICON_MONITOR = icon(
'<rect x="2" y="3" width="20" height="14" rx="2"/>'
.. '<line x1="8" y1="21" x2="16" y2="21"/>'
.. '<line x1="12" y1="17" x2="12" y2="21"/>')
-- Mac-8 retro theme: classic compact Mac.
local ICON_RETRO = icon(
'<rect x="5" y="2" width="14" height="20" rx="2"/>'
.. '<rect x="8" y="5" width="8" height="7"/>'
.. '<line x1="8" y1="16" x2="12" y2="16"/>')
-- Theme picker: half-filled contrast circle on the trigger.
local ICON_CONTRAST = icon(
'<circle cx="12" cy="12" r="10"/>'
.. '<path d="M12 2v20a10 10 0 0 0 0-20z" fill="currentColor" stroke="none"/>')
local ICON_PLAY = icon('<polygon points="6 3 20 12 6 21 6 3" fill="currentColor" stroke="none"/>', 14)
local ICON_PAUSE = icon(
'<rect x="5" y="3" width="5" height="18" fill="currentColor" stroke="none"/>'
.. '<rect x="14" y="3" width="5" height="18" fill="currentColor" stroke="none"/>', 14)
-- One entry per selectable theme, in menu order.
local THEME_CHOICES = {
{ key = "auto", label = "System", svg = ICON_MONITOR },
{ key = "light", label = "Light", svg = ICON_SUN },
{ key = "dark", label = "Dark", svg = ICON_MOON },
{ key = "mac8", label = "Mac\194\1608", svg = ICON_RETRO },
}
local function theme_menu_html()
local buttons = {}
for _, t in ipairs(THEME_CHOICES) do
buttons[#buttons + 1] = '<button type="button" class="theme-option"'
.. ' data-theme-choice="' .. t.key .. '"'
.. ' title="' .. t.label .. '" aria-label="' .. t.label .. ' theme">'
.. t.svg .. '</button>'
end
return '<details class="theme-menu" id="theme-menu">'
.. '<summary class="theme-toggle" aria-label="Choose theme" title="Theme">'
.. ICON_CONTRAST .. '</summary>'
.. '<div class="theme-options">' .. table.concat(buttons) .. '</div>'
.. '</details>'
end
-- Atom feed icon (nav).
local ICON_FEED = '<svg xmlns="http://www.w3.org/2000/svg"'
.. ' width="14" height="14" viewBox="0 0 24 24" fill="none"'
.. ' stroke="currentColor" stroke-width="2"'
.. ' stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
.. '<path d="M4 11a9 9 0 0 1 9 9"/>'
.. '<path d="M4 4a16 16 0 0 1 16 16"/>'
.. '<circle cx="5" cy="19" r="1"/>'
.. '</svg>'
local function nav_html()
-- No Home item: the site brand already links to /.
local items = {}
if config.get("links_page") ~= "" then
items[#items + 1] = '<li><a href="/links" class="nav-link">Links</a></li>'
end
if config.enabled("feature_wiki") then
items[#items + 1] = '<li><a href="/wiki" class="nav-link">Wiki</a></li>'
end
for _, l in ipairs(config.nav_links()) do
items[#items + 1] = '<li><a href="' .. EscapeHtml(l.url) .. '" class="nav-link">'
.. EscapeHtml(l.label) .. '</a></li>'
end
-- show:window:top restores normal navigation feel: without it the
-- boosted swap leaves the viewport wherever it was (or scrolls the
-- target into view) instead of starting the new page at the top.
return '<header class="site-header">'
.. '<div class="header-inner">'
.. '<a href="/" class="site-brand" hx-boost="true"'
.. ' hx-target="#content" hx-swap="outerHTML show:window:top">'
.. EscapeHtml(config.get("site_title")) .. '</a>'
.. '<nav class="site-nav"'
.. ' hx-boost="true"'
.. ' hx-target="#content"'
.. ' hx-swap="outerHTML show:window:top">'
.. '<ul class="nav-list">'
.. table.concat(items)
.. '</ul>'
.. '</nav>'
.. '<div class="nav-end">'
-- The site's only search input. Carries the active query so that
-- landing on /search?q=… (or a full page load mid-search) shows the
-- term being searched.
.. '<form class="nav-search" action="/search" method="get">'
.. '<input id="nav-q" name="q" type="search" class="nav-search-input"'
.. ' value="' .. EscapeHtml(GetParam("q") or "") .. '"'
.. ' placeholder="Search\226\128\166" aria-label="Site search"'
.. ' hx-get="/search"'
.. ' hx-trigger="input changed delay:400ms"'
.. ' hx-target="#content"'
.. ' hx-push-url="true">'
.. '</form>'
.. '<a class="nav-feed" href="/atom.xml" aria-label="Atom feed" title="Atom feed">'
.. ICON_FEED
.. '</a>'
.. theme_menu_html()
.. '</div>'
.. '</div>'
.. '</header>'
end
-- Floating radio player (bottom-left, collapsed by default). The
-- channel list is fetched lazily from /radio/channels on first expand.
local function radio_html()
if not config.enabled("radio_enabled") or config.get("radio_url") == "" then
return ""
end
return '<div id="radio-player" class="radio-player">'
.. '<div class="radio-controls">'
.. '<button type="button" id="radio-play" class="radio-btn" disabled'
.. ' aria-label="Play or pause radio" title="Play/pause">'
.. '<span class="radio-icon-play">' .. ICON_PLAY .. '</span>'
.. '<span class="radio-icon-pause">' .. ICON_PAUSE .. '</span>'
.. '</button>'
.. '<input type="range" id="radio-vol" min="0" max="100" step="5" value="80"'
.. ' aria-label="Radio volume" title="Volume">'
.. '<button type="button" id="radio-expand" class="radio-btn"'
.. ' aria-label="Show channels" aria-expanded="false" title="Channels"'
.. ' hx-get="/radio/channels" hx-target="#radio-channels"'
.. ' hx-trigger="click once">&#9656;</button>'
.. '</div>'
.. '<div id="radio-channels" class="radio-channels" hidden>'
.. '<span class="radio-loading">Loading\226\128\166</span>'
.. '</div>'
.. '<audio id="radio-audio" preload="none"></audio>'
.. '</div>'
end
local function footer_html()
local author = config.get("site_author")
local who = author ~= "" and author or config.get("site_title")
return '<footer class="site-footer">'
.. '<p>&#169; ' .. os.date("!%Y") .. ' ' .. EscapeHtml(who)
.. ' &#183; <a href="/atom.xml">Atom feed</a></p>'
.. '</footer>'
end
-- Opening tag of the #content element. Shared by layout() and the
-- boosted-fragment path in respond(): boosted swaps replace #content via
-- outerHTML, so the replacement must carry the same hx- attributes or
-- boosting would stop working after the first navigation.
local MAIN_OPEN = '<main id="content" class="main-content"'
.. ' hx-boost="true" hx-target="#content"'
.. ' hx-swap="outerHTML show:window:top" hx-history-elt>'
local function page_title(title)
return EscapeHtml(title) .. " \226\128\148 " .. EscapeHtml(config.get("site_title"))
end
--- Return a complete HTML document wrapping body_html in the site shell.
--- Always returns a full page; callers check GetHeader("HX-Request") and
--- call render.partial() instead when only a fragment is needed.
function M.layout(title, body_html)
local site_title = config.get("site_title")
local safe_title = page_title(title)
return "<!DOCTYPE html>\n"
.. '<html lang="en" data-theme="light">\n'
.. "<head>\n"
.. '<meta charset="UTF-8">\n'
.. '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
.. "<title>" .. safe_title .. "</title>\n"
.. '<link rel="alternate" type="application/atom+xml" title="'
.. EscapeHtml(site_title) .. '" href="/atom.xml">\n'
.. THEME_INIT .. "\n"
.. '<link rel="stylesheet" href="/static/style.css">\n'
.. '<script src="/static/theme.js"></script>\n'
.. '<script src="/static/htmx.min.js"></script>\n'
.. '<script src="/static/highlight.min.js" defer></script>\n'
.. (radio_html() ~= "" and '<script src="/static/radio.js" defer></script>\n' or "")
.. "</head>\n"
.. "<body>\n"
.. nav_html() .. "\n"
-- hx-boost on <main> makes every internal link inside page content
-- (post cards, pagination, back-links, links in markdown) swap only
-- #content, so the radio player <audio> outside it keeps playing
-- across navigation. hx-history-elt scopes htmx's history snapshot/
-- restore to #content — without it the back button restores the whole
-- <body>, recreating the player. Elements inside that need different
-- swap behaviour (tag pills, editor preview) carry explicit hx-*
-- attributes, which override this inheritance.
.. MAIN_OPEN .. "\n"
.. body_html .. "\n"
.. "</main>\n"
.. footer_html() .. "\n"
.. radio_html() .. "\n"
.. "</body>\n"
.. "</html>"
end
--- Return the fragment as-is for HTMX partial responses.
function M.partial(fragment_html)
return fragment_html
end
--- Smart dispatch: return the right response for the request type.
---
--- Three cases:
--- 1. Direct browser navigation → full layout (no HX-Request header)
--- 2. Boosted navigation → a <title> plus the replacement
--- #content element only (HX-Request + HX-Boosted). htmx swaps it
--- via outerHTML and picks the <title> out of the response; nav,
--- footer, and the radio player are left untouched.
--- 3. True HTMX fragment request → bare partial (HX-Request, no
--- HX-Boosted; e.g. nav search input, editor preview, tag pills)
---
--- All page-returning handlers should call this instead of checking
--- GetHeader("HX-Request") themselves.
function M.respond(title, body_html)
if GetHeader("HX-Request") then
if GetHeader("HX-Boosted") then
return "<title>" .. page_title(title) .. "</title>\n"
.. MAIN_OPEN .. "\n" .. body_html .. "\n</main>"
end
return M.partial(body_html)
end
return M.layout(title, body_html)
end
return M