-- routes_public.lua — visitor-facing routes. -- -- The front page (/) is the article feed: published blog posts, newest -- first, paginated. /blog/:slug remains the permalink format. Wiki -- routes exist only when the feature_wiki flag is on. -- -- Registration order matters: */search before */:slug. The catch-all -- 404 is registered by app.lua after all modules. local fm = require "fm" local db = require "db" local render = require "render" local md = require "md" local auth = require "auth" local web = require "web" local ui = require "ui" local config = require "config" local media = require "media" local feed = require "feed" local route = web.route -- ── Shared detail-page body ─────────────────────────────────────────── local function detail_body(post, kind, back_href, back_label) local date = web.fmt_date(post.created_at) local updated = web.fmt_date(post.updated_at) local date_str = date if updated ~= date then date_str = date_str .. ' (updated ' .. updated .. ')' end if post.published == 0 then date_str = date_str .. ' Draft' end local tags_list = web.split_tags(post.tags or "") local tags_html = "" if #tags_list > 0 then -- Plain links on detail pages — no #post-list in the DOM to swap into. tags_html = '
' .. ui.tag_pills(tags_list, kind, false) .. '
' end -- Edit affordance for a logged-in admin. local edit_bar = "" if auth.has_session() then edit_bar = '
' .. 'Edit this page' .. '
' end return '
' .. '
' .. '← ' .. back_label .. '' .. '

' .. EscapeHtml(post.title) .. '

' .. '' .. tags_html .. '
' .. '
' .. md.render(post.body) .. '
' .. edit_bar .. '
' end -- ── Front page: the article feed ────────────────────────────────────── route(fm.GET "/", function(r) local tag = r.params.tag local tagline = config.get("site_tagline") local header = tagline ~= "" and ('

' .. EscapeHtml(tagline) .. '

') or "" local list_html, pagination_html if tag then -- Tag filter: unpaginated result list, like a search. list_html = ui.post_list(db.search(nil, "blog", tag), "blog") pagination_html = "" else local per = math.max(1, config.get_number("posts_per_page")) local page = math.max(1, math.floor(tonumber(r.params.page) or 1)) local total = db.count_published("blog") local pages = math.max(1, math.ceil(total / per)) if page > pages then page = pages end local posts = db.list_published_page("blog", per, (page - 1) * per) list_html = ui.post_list(posts, "blog") pagination_html = ui.pagination("/", page, pages) end local body = header .. ui.tag_cloud(db.list_tags("blog"), "blog", tag) .. list_html .. pagination_html return render.respond(config.get("site_title"), body) end) -- Old section index → the feed now lives at /. route(fm.GET "/blog", function(r) local tag = r.params.tag return web.redirect(tag and ("/?tag=" .. EscapePath(tag)) or "/") end) -- Tag-filter fragment for the feed's tag pills (HTMX swaps #post-list). -- Must be registered before /blog/:slug. route(fm.GET "/blog/search", function(r) local tag = r.params.tag if tag then return render.partial( ui.search_results(db.search(nil, "blog", tag), "blog", "tag: " .. tag)) end return render.partial(ui.post_list(db.list_published("blog"), "blog")) end) route(fm.GET "/blog/:slug", function(r) local post = db.get_content("blog", r.params.slug, auth.has_session()) if not post then return web.not_found() end return render.respond(post.title, detail_body(post, "blog", "/", "Home")) end) -- ── /links — single free-form page (markdown stored in settings) ───── route(fm.GET "/links", function(r) local body_md = config.get("links_page") if body_md == "" then return web.not_found() end local edit_bar = "" if auth.has_session() then edit_bar = '
' .. 'Edit this page' .. '
' end local body = '
' .. '

Links

' .. '
' .. md.render(body_md) .. '
' .. edit_bar .. '
' return render.respond("Links", body) end) -- ── /radio/channels — lazy channel list for the radio player ───────── -- Fetches the configured m3u playlist server-side (browser CSP allows -- remote audio only, not remote fetches) and returns channel buttons. local function parse_m3u(text) local channels = {} local pending_name for line in (text .. "\n"):gmatch("([^\n]*)\n") do line = line:gsub("\r$", ""):match("^%s*(.-)%s*$") if line:match("^#EXTINF") then pending_name = line:match("^#EXTINF:[^,]*,%s*(.+)$") elseif line ~= "" and not line:match("^#") then if line:match("^https?://") then local name = pending_name if not name or name == "" then name = line:match("([^/]+)/*$") or line end channels[#channels + 1] = { name = name, url = line } if #channels >= 32 then break end end pending_name = nil end end return channels end route(fm.GET "/radio/channels", function(r) local url = config.get("radio_url") if not config.enabled("radio_enabled") or url == "" then return web.not_found() end local ok, status, _, playlist = pcall(Fetch, url) if not ok or type(status) ~= "number" or status ~= 200 or not playlist then Log(kLogWarn, "radio playlist fetch failed: " .. tostring(ok and status or status)) return render.partial('Station unreachable.') end local channels = parse_m3u(playlist) if #channels == 0 then return render.partial('No channels in playlist.') end local out = {} for _, ch in ipairs(channels) do out[#out + 1] = '' end return render.partial(table.concat(out, "\n")) end) -- ── Atom feed ───────────────────────────────────────────────────────── route(fm.GET "/atom.xml", function(r) return feed.atom(), {ContentType = "application/atom+xml; charset=utf-8"} end) -- ── Wiki (optional feature; parked unless feature_wiki is on) ───────── local function wiki_enabled() return config.enabled("feature_wiki") end route(fm.GET "/wiki", function(r) if not wiki_enabled() then return web.not_found() end local tag = r.params.tag local pages = tag and db.search(nil, "wiki", tag) or db.list_published("wiki") local body = '
' .. '

Wiki

' .. '

Notes and reference pages.

' .. '
' .. ui.tag_cloud(db.list_tags("wiki"), "wiki", tag) .. ui.post_list(pages, "wiki") return render.respond("Wiki", body) end) -- Tag-filter fragment for the wiki's tag pills (HTMX swaps #post-list). -- Must be registered before /wiki/:slug. route(fm.GET "/wiki/search", function(r) if not wiki_enabled() then return web.not_found() end local tag = r.params.tag if tag then return render.partial( ui.search_results(db.search(nil, "wiki", tag), "wiki", "tag: " .. tag)) end return render.partial(ui.post_list(db.list_published("wiki"), "wiki")) end) route(fm.GET "/wiki/:slug", function(r) if not wiki_enabled() then return web.not_found() end local page = db.get_content("wiki", r.params.slug, auth.has_session()) if not page then return web.not_found() end return render.respond(page.title, detail_body(page, "wiki", "/wiki", "Wiki")) end) -- ── Global search ───────────────────────────────────────────────────── -- The nav search bar is the site's only search input: it hx-gets this -- route into #content as you type (and submits here as a plain form). route(fm.GET "/search", function(r) local q = r.params.q or "" -- With the wiki parked there is only one section to search. local only_kind = wiki_enabled() and nil or "blog" local results_html if q == "" then results_html = '
' .. '
Type in the search bar above to search all content.
' .. '
' else local results = db.search(q, only_kind, nil) if #results == 0 then results_html = '
' .. '
No results for “' .. EscapeHtml(q) .. '”.
' .. '
' else local by_kind = { blog = {}, wiki = {} } for _, item in ipairs(results) do local k = item.kind if by_kind[k] then by_kind[k][#by_kind[k] + 1] = item end end local groups = {} for _, kind in ipairs({ "blog", "wiki" }) do if #by_kind[kind] > 0 then local cards = {} for _, post in ipairs(by_kind[kind]) do cards[#cards + 1] = ui.post_card(post, kind) end groups[#groups + 1] = '
' .. '
' .. kind .. '
' .. table.concat(cards, "\n") .. '
' end end results_html = '
' .. table.concat(groups, "\n") .. '
' end end local body = '
' .. '

Search

' .. (q ~= "" and ('

Results for “' .. EscapeHtml(q) .. '”

') or '') .. '
' .. results_html return render.respond("Search", body) end) -- ── Docs: binary file server ────────────────────────────────────────── -- Docs entries are not listed in the nav or search — they are linked -- directly (this also serves images uploaded from the post editor). route(fm.GET "/docs/:slug", function(r) local doc = db.get_doc(r.params.slug, auth.has_session()) if not doc or not doc.file_data or doc.file_data == "" then return web.not_found() end local mime = (doc.mime_type ~= "" and doc.mime_type) or "application/octet-stream" local ext = media.MIME_EXT[mime] or "" -- Content-Disposition is set directly; Content-Type is passed as the second -- return value so Fullmoon uses it verbatim and skips detectType() sniffing. SetHeader("Content-Disposition", 'inline; filename="' .. doc.slug .. ext .. '"') return doc.file_data, {ContentType = mime} end) -- Static files (serves /static/* assets from the zip) route(fm.GET "/static/*", "/static/*")