-- SPDX-FileCopyrightText: © 2022—2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. --[[ HTML rendering for lilgit: page shell, views, escaping. `http.html_escape` escapes only `<`/`>` and is not attribute-safe, so lilgit ships its own full escaper. Page templates are `${var}` strings filled with `std.txt.template`; users can override them via the `templates` config table. Tree, blob and refs views take a `view` table: { repo, ref, path_segs, listing, readme_html, readme_title, body_html, title, branches, tags } — each view reads the fields it needs. ]] local std = require("std") local url = require("http.url") local markdown = require("markdown") -- The `&` substitution must run first, or it would re-escape the others. local html_escape = function(s) return (s:gsub("&", "&"):gsub("<", "<"):gsub(">", ">"):gsub('"', """):gsub("'", "'")) end --[[ Build a browse URL: `/repo/kind/ref/path...`. The ref may contain `/` (slashed branches), so it is split and every segment escaped individually. `trailing` appends the canonical trailing slash for tree URLs. ]] local href = function(repo_name, kind, ref_str, path_segs, trailing) local parts = { "", url.escape(repo_name), kind } for seg in ref_str:gmatch("[^/]+") do parts[#parts + 1] = url.escape(seg) end for _, seg in ipairs(path_segs or {}) do parts[#parts + 1] = url.escape(seg) end local s = table.concat(parts, "/") if trailing then s = s .. "/" end return s end --[[ Render markdown/djot to an HTML fragment. The first `

` is extracted as the page title and removed from the body (the page shell displays the title itself); on parse failure the content is served as escaped `
`. Returns `html, title` with the filename as the title fallback.
]]
local render_markdown = function(content, filename)
	local ok, html = pcall(markdown.render_html, content)
	if not ok or not html then
		return "
" .. html_escape(content) .. "
", filename end local title local h1 = html:match("]*>(.-)

") if h1 then title = h1:gsub("<[^>]+>", ""):gsub("^%s+", ""):gsub("%s+$", "") html = html:gsub("]*>.-%s*", "", 1) end if not title or title == "" then title = filename end return html, title end local format_time = function(ts) return os.date("!%Y-%m-%d %H:%M", ts) end local human_size = function(n) if n < 1024 then return n .. " B" end local v = n for _, unit in ipairs({ "K", "M", "G" }) do v = v / 1024 if v < 1024 then return string.format("%.1f%s", v, unit) end end return string.format("%.1fT", v / 1024) end local default_templates = { page = [[ ${title} — ${site_title}
${body}
]], } local build_templates = function(cfg) local templates = std.tbl.copy(default_templates) return std.tbl.merge(templates, cfg.templates or {}) end local page = function(state, title, nav, body) return std.txt.template(state.templates.page, { site_title = html_escape(state.cfg.site_title), title = html_escape(title), nav = nav, body = body, }) end local index_nav = function(state) return '' .. html_escape(state.cfg.site_title) .. "" end -- site / repo / path segments; every intermediate segment is a tree -- prefix of the current node, so each crumb links to its tree page. local breadcrumbs = function(state, repo, ref_str, path_segs) local parts = { index_nav(state) } parts[#parts + 1] = '' .. html_escape(repo.name) .. "" for i = 1, #path_segs do if i < #path_segs then local prefix = {} for j = 1, i do prefix[j] = path_segs[j] end parts[#parts + 1] = '' .. html_escape(path_segs[i]) .. "" else parts[#parts + 1] = "" .. html_escape(path_segs[i]) .. "" end end return table.concat(parts, " / ") end --[[ Pure-HTML ref switcher (a
dropdown, no JS): links to the same kind/path under every branch and tag, plus a link to the /refs page. Switching to a ref where the path does not exist 404s — same trade-off every git web UI makes. ]] local ref_switcher = function(repo, kind, ref_str, path_segs, branches, tags) if not branches then return "" end local items = {} local add_group = function(label, list) if #list == 0 then return end items[#items + 1] = '
  • ' .. label .. "
  • " for _, ref in ipairs(list) do local class = ref.name == ref_str and ' class="current"' or "" items[#items + 1] = "' .. html_escape(ref.name) .. "" end end add_group("branches", branches) add_group("tags", tags) items[#items + 1] = '
  • all refs
  • ' return '
    ' .. html_escape(ref_str) .. "
      " .. table.concat(items) .. "
    " end local index_page = function(state, host) local scheme = state.cfg.ssl and "https" or "http" local rows = {} for _, repo in ipairs(state.registry.ordered) do local clone_url = scheme .. "://" .. (host or "localhost") .. "/" .. url.escape(repo.name) rows[#rows + 1] = '' .. html_escape(repo.name) .. "" .. html_escape(repo.description or "") .. "" .. html_escape(clone_url) .. "" end local body if #rows == 0 then body = "

    No repositories.

    " else body = '\n' .. table.concat(rows, "\n") .. "\n
    namedescriptionclone
    " end return page(state, state.cfg.site_title, index_nav(state), body) end local KIND_MARKERS = { tree = "d", blob = "-", symlink = "l", gitlink = "@" } local tree_page = function(state, view) local repo, ref_str, path_segs = view.repo, view.ref, view.path_segs local title = repo.name if #path_segs > 0 then title = repo.name .. "/" .. table.concat(path_segs, "/") end local with_sizes = false for _, entry in ipairs(view.listing) do if entry.size then with_sizes = true break end end local size_cell = function(entry) if not with_sizes then return "" end local text = entry.size and human_size(entry.size) or "" return '' .. text .. "" end local rows = {} if #path_segs > 0 then local parent = {} for i = 1, #path_segs - 1 do parent[i] = path_segs[i] end rows[#rows + 1] = 'd..' .. (with_sizes and '' or "") .. "" end for _, entry in ipairs(view.listing) do local child = {} for i, seg in ipairs(path_segs) do child[i] = seg end child[#child + 1] = entry.name local cell if entry.kind == "tree" then cell = '' .. html_escape(entry.name) .. "/" elseif entry.kind == "gitlink" then cell = '' .. html_escape(entry.name) .. " @ " .. entry.sha:sub(1, 7) .. "" else cell = '' .. html_escape(entry.name) .. "" end rows[#rows + 1] = '' .. (KIND_MARKERS[entry.kind] or "-") .. '' .. cell .. "" .. size_cell(entry) .. "" end local body = ref_switcher(repo, "tree", ref_str, path_segs, view.branches, view.tags) .. 'log' .. "

    " .. html_escape(title) .. '

    \n\n' .. table.concat(rows, "\n") .. "\n
    " if view.readme_html then body = body .. '\n
    \n' if view.readme_title then body = body .. "

    " .. html_escape(view.readme_title) .. "

    \n" end body = body .. view.readme_html .. "\n
    " end return page(state, title, breadcrumbs(state, repo, ref_str, path_segs), body) end local blob_page = function(state, view) local repo, ref_str, path_segs = view.repo, view.ref, view.path_segs local raw = 'raw' local body = ref_switcher(repo, "blob", ref_str, path_segs, view.branches, view.tags) .. raw .. "\n

    " .. html_escape(view.title) .. "

    \n" .. view.body_html return page(state, view.title, breadcrumbs(state, repo, ref_str, path_segs), body) end local refs_page = function(state, view) local repo = view.repo local section = function(label, list, target_of) if #list == 0 then return "" end local rows = {} for _, ref in ipairs(list) do rows[#rows + 1] = '' .. html_escape(ref.name) .. 'log' .. target_of(ref):sub(1, 12) .. "" end return "

    " .. label .. '

    \n\n' .. table.concat(rows, "\n") .. "\n
    \n" end local body = "

    " .. html_escape(repo.name) .. ": refs

    \n" .. section("Branches", view.branches, function(ref) return ref.sha end) .. section("Tags", view.tags, function(ref) return ref.target end) if #view.branches == 0 and #view.tags == 0 then body = body .. "

    No refs.

    " end local nav = index_nav(state) .. ' / ' .. html_escape(repo.name) .. " / refs" return page(state, repo.name .. ": refs", nav, body) end local commit_href = function(repo_name, sha) return "/" .. url.escape(repo_name) .. "/commit/" .. sha end local log_page = function(state, view) local repo, ref_str = view.repo, view.ref local rows = {} for _, commit in ipairs(view.entries) do rows[#rows + 1] = '' .. commit.sha:sub(1, 7) .. '' .. html_escape(commit.subject) .. '' .. html_escape(commit.author.name) .. '' .. format_time(commit.author.time) .. "" end local body = ref_switcher(repo, "log", ref_str, nil, view.branches, view.tags) .. "

    " .. html_escape(repo.name .. ": log") .. '

    \n\n' .. table.concat(rows, "\n") .. "\n
    " if view.next_sha then body = body .. '\n

    older →

    ' end local nav = index_nav(state) .. ' / ' .. html_escape(repo.name) .. " / log" return page(state, repo.name .. ": log", nav, body) end local commit_page = function(state, view) local repo, commit = view.repo, view.commit local meta = {} local meta_row = function(label, value_html) meta[#meta + 1] = "" .. label .. "" .. value_html .. "" end meta_row("commit", "" .. commit.sha .. "") meta_row( "author", html_escape(commit.author.name) .. ' <' .. html_escape(commit.author.email) .. "> " .. format_time(commit.author.time) ) if commit.committer.name ~= commit.author.name or commit.committer.time ~= commit.author.time then meta_row( "committer", html_escape(commit.committer.name) .. ' <' .. html_escape(commit.committer.email) .. "> " .. format_time(commit.committer.time) ) end for _, parent in ipairs(commit.parents) do meta_row("parent", '' .. parent .. "") end meta_row("tree", 'browse') local files = {} local STATUS_MARKERS = { added = "A", deleted = "D", modified = "M" } for _, change in ipairs(view.files) do local target_ref = commit.sha if change.status == "deleted" then target_ref = commit.parents[1] end local segs = {} for seg in change.path:gmatch("[^/]+") do segs[#segs + 1] = seg end files[#files + 1] = '' .. STATUS_MARKERS[change.status] .. '' .. html_escape(change.path) .. "" end local body = "

    " .. html_escape(commit.subject) .. "

    \n" body = body .. '\n' .. table.concat(meta, "\n") .. "\n
    \n" if commit.message ~= "" and commit.message:gsub("%s+$", "") ~= commit.subject then body = body .. '
    ' .. html_escape(commit.message:gsub("%s+$", "")) .. "
    \n" end if #files > 0 then body = body .. "

    Changed files

    \n" .. '\n' .. table.concat(files, "\n") .. "\n
    " end local nav = index_nav(state) .. ' / ' .. html_escape(repo.name) .. " / commit" return page(state, commit.subject, nav, body) end return { html_escape = html_escape, href = href, markdown = render_markdown, human_size = human_size, format_time = format_time, build_templates = build_templates, index_page = index_page, tree_page = tree_page, blob_page = blob_page, refs_page = refs_page, log_page = log_page, commit_page = commit_page, }