-- 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 = ''
.. ''
.. '' .. 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 ('')
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 = ''
.. ''
.. '' .. 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('' .. table.concat(groups, "\n") .. '
'
end
end
local body = ''
.. 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/*")