Initial commit, blog system works
This commit is contained in:
266
.lua/render.lua
Normal file
266
.lua/render.lua
Normal file
@@ -0,0 +1,266 @@
|
||||
-- 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">▸</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>© ' .. os.date("!%Y") .. ' ' .. EscapeHtml(who)
|
||||
.. ' · <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
|
||||
Reference in New Issue
Block a user