-- 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 = ''
-- Inline SVG icon helper (Lucide-style stroked icons).
local function icon(paths, size)
size = size or 16
return ''
end
local ICON_SUN = icon(
''
.. ''
.. ''
.. ''
.. ''
.. ''
.. ''
.. ''
.. '')
local ICON_MOON = icon('')
-- Auto/system theme: monitor.
local ICON_MONITOR = icon(
''
.. ''
.. '')
-- Mac-8 retro theme: classic compact Mac.
local ICON_RETRO = icon(
''
.. ''
.. '')
-- Theme picker: half-filled contrast circle on the trigger.
local ICON_CONTRAST = icon(
''
.. '')
local ICON_PLAY = icon('', 14)
local ICON_PAUSE = icon(
''
.. '', 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] = ''
end
return ''
.. ''
.. ICON_CONTRAST .. ''
.. '
' .. table.concat(buttons) .. '
'
.. ''
end
-- Atom feed icon (nav).
local ICON_FEED = ''
local function nav_html()
-- No Home item: the site brand already links to /.
local items = {}
if config.get("links_page") ~= "" then
items[#items + 1] = '
'
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 ''
.. '
'
-- 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.
.. ''
.. ''
.. ICON_FEED
.. ''
.. theme_menu_html()
.. '
'
.. '
'
.. ''
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 '
'
.. '
'
.. ''
.. ''
.. ''
.. '
'
.. '
'
.. 'Loading\226\128\166'
.. '
'
.. ''
.. '
'
end
local function footer_html()
local author = config.get("site_author")
local who = author ~= "" and author or config.get("site_title")
return ''
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 = ''
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 "\n"
.. '\n'
.. "\n"
.. '\n'
.. '\n'
.. "" .. safe_title .. "\n"
.. '\n'
.. THEME_INIT .. "\n"
.. '\n'
.. '\n'
.. '\n'
.. '\n'
.. (radio_html() ~= "" and '\n' or "")
.. "\n"
.. "\n"
.. nav_html() .. "\n"
-- hx-boost on makes every internal link inside page content
-- (post cards, pagination, back-links, links in markdown) swap only
-- #content, so the radio player \n"
.. footer_html() .. "\n"
.. radio_html() .. "\n"
.. "\n"
.. ""
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 plus the replacement
--- #content element only (HX-Request + HX-Boosted). htmx swaps it
--- via outerHTML and picks the 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 "" .. page_title(title) .. "\n"
.. MAIN_OPEN .. "\n" .. body_html .. "\n"
end
return M.partial(body_html)
end
return M.layout(title, body_html)
end
return M