Initial commit, blog system works

This commit is contained in:
2026-07-16 21:08:50 -05:00
commit 3841122960
22 changed files with 5628 additions and 0 deletions

306
.lua/routes_public.lua Normal file
View File

@@ -0,0 +1,306 @@
-- 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 .. ' <span style="opacity:.6">(updated ' .. updated .. ')</span>'
end
if post.published == 0 then
date_str = date_str .. ' <span class="status-badge status-draft">Draft</span>'
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 = '<div class="post-meta-tags">'
.. ui.tag_pills(tags_list, kind, false) .. '</div>'
end
-- Edit affordance for a logged-in admin.
local edit_bar = ""
if auth.has_session() then
edit_bar = '<div class="admin-edit-bar">'
.. '<a href="/edit/' .. post.id .. '" class="admin-edit-link">Edit this page</a>'
.. '</div>'
end
return '<article class="post-detail">'
.. '<header class="post-header">'
.. '<a href="' .. back_href .. '" class="back-link">&#8592; ' .. back_label .. '</a>'
.. '<h1>' .. EscapeHtml(post.title) .. '</h1>'
.. '<div class="post-meta"><time datetime="' .. date .. '">' .. date_str .. '</time></div>'
.. tags_html
.. '</header>'
.. '<div class="post-body">' .. md.render(post.body) .. '</div>'
.. edit_bar
.. '</article>'
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 ('<div class="section-header"><p>' .. EscapeHtml(tagline) .. '</p></div>')
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 = '<div class="admin-edit-bar">'
.. '<a href="/edit/links" class="admin-edit-link">Edit this page</a>'
.. '</div>'
end
local body = '<article class="post-detail">'
.. '<header class="post-header"><h1>Links</h1></header>'
.. '<div class="post-body">' .. md.render(body_md) .. '</div>'
.. edit_bar
.. '</article>'
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('<span class="radio-loading">Station unreachable.</span>')
end
local channels = parse_m3u(playlist)
if #channels == 0 then
return render.partial('<span class="radio-loading">No channels in playlist.</span>')
end
local out = {}
for _, ch in ipairs(channels) do
out[#out + 1] = '<button type="button" class="radio-ch"'
.. ' data-url="' .. EscapeHtml(ch.url) .. '">'
.. EscapeHtml(ch.name) .. '</button>'
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 = '<div class="section-header">'
.. '<h1>Wiki</h1>'
.. '<p>Notes and reference pages.</p>'
.. '</div>'
.. 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 = '<div id="search-results">'
.. '<div class="no-results">Type in the search bar above to search all content.</div>'
.. '</div>'
else
local results = db.search(q, only_kind, nil)
if #results == 0 then
results_html = '<div id="search-results">'
.. '<div class="no-results">No results for &#8220;' .. EscapeHtml(q) .. '&#8221;.</div>'
.. '</div>'
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] = '<div class="search-group">'
.. '<div class="search-group-label">' .. kind .. '</div>'
.. table.concat(cards, "\n")
.. '</div>'
end
end
results_html = '<div id="search-results">' .. table.concat(groups, "\n") .. '</div>'
end
end
local body = '<div class="section-header">'
.. '<h1>Search</h1>'
.. (q ~= "" and ('<p>Results for &#8220;' .. EscapeHtml(q) .. '&#8221;</p>') or '')
.. '</div>'
.. 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/*")