-- Server-side rendering of a repository's about page, named by the -- about-filter setting in cgitrc and run inside cgit's embedded Lua -- interpreter so a readme costs no extra process per request. Markdown, man -- pages and plain text are the three formats, chosen from the readme's file -- extension, and anything that fails to parse falls back to escaped plain -- text. The wrappers it emits carry the classes that assets/cgit.css styles. -- It runs on Lua 5.1 through 5.5 and LuaJIT. -- -- about-filter=lua:/usr/local/lib/cgit/filters/about-render.lua -- Markdown and man pages need lpeg, built for the Lua cgit is linked -- against. Without it both fall back to escaped plain text. -- -- # Debian and Ubuntu -- sudo apt install lua-lpeg -- # Fedora -- sudo dnf install lua-lpeg -- # Alpine -- sudo apk add lua5.1-lpeg -- # or with LuaRocks, matched to your Lua version -- sudo luarocks --lua-version 5.1 install lpeg local has_lpeg, lpeg = pcall(require, "lpeg") -- Readmes larger than this are served as escaped plain text rather than -- parsed. local max_bytes = 512 * 1024 -- Ceiling on blockquote and emphasis nesting, so a hostile readme of stacked -- markers cannot drive the recursive parser into a stack overflow. local max_depth = 24 local filename, tree_base, tree_query, chunks = "", "", "", {} -- How often each heading anchor has been handed out. The ids already on the -- page around the readme start at one, so no heading takes them. local id_counts = {} -- Lua patterns backtrack, so "%s+$" costs the square of a run of spaces in -- the middle of a line. Finding each end directly stays linear. local function trim(text) local first = text:find("%S") if first == nil then return "" end local last = #text while text:find("^%s", last) do last = last - 1 end return text:sub(first, last) end -- A heading may close with a run of hashes, which only counts as markup when -- a space comes before it, so "# C#" keeps its sharp. local function heading_text(title) title = trim(title) local last = #title while last > 0 and title:sub(last, last) == "#" do last = last - 1 end if last == 0 or title:find("^%s", last) then return trim(title:sub(1, last)) end return title end -- Normalise CRLF and CR to newlines, then split. A trailing newline yields -- a final empty line, which every caller treats as blank. local function split_lines(text) text = text:gsub("\r\n?", "\n") local lines, start = {}, 1 while true do local newline = text:find("\n", start, true) if not newline then lines[#lines + 1] = text:sub(start) return lines end lines[#lines + 1] = text:sub(start, newline - 1) start = newline + 1 end end -- A pipe escaped with a backslash belongs to its cell, even inside a code -- span, so as on GitHub the backslash goes and the pipe stays. An even run of -- backslashes escapes only itself, so the pipe after it still divides. local function split_cells(row) row = trim(row) local cells, parts, start = {}, {}, 1 if row:sub(1, 1) == "|" then start = 2 end while true do local bar = row:find("|", start, true) if not bar then break end local slashes = 0 while row:sub(bar - slashes - 1, bar - slashes - 1) == "\\" do slashes = slashes + 1 end if slashes % 2 == 1 then parts[#parts + 1] = row:sub(start, bar - 2) .. "|" else parts[#parts + 1] = row:sub(start, bar - 1) cells[#cells + 1] = trim(table.concat(parts)) parts = {} end start = bar + 1 end if start <= #row or #parts > 0 then parts[#parts + 1] = row:sub(start) cells[#cells + 1] = trim(table.concat(parts)) end return cells end -- Control and whitespace bytes are stripped before the scheme check because -- a browser ignores them when resolving it, so "java\nscript:" must still be -- caught as javascript. The class is %c%s rather than a literal 0x00 range -- so it is safe on Lua 5.1, where an embedded zero byte ends a pattern. local function safe_url(url) url = url:gsub("[%c%s]", "") -- A leading "//" is scheme-relative, which a browser resolves to -- another origin, and it reads a backslash there as a slash, so no -- pair of the two is the local target the parser meant to allow. local head = url:sub(1, 2):gsub("\\", "/") if head == "//" then return nil end local scheme = url:match("^(%a[%w%+%.%-]*):") if scheme then scheme = scheme:lower() if scheme ~= "http" and scheme ~= "https" and scheme ~= "mailto" then return nil end end return url end -- A readme percent-encodes what a link path cannot carry, and the path is -- encoded again on its way out, so it is decoded first. An encoded NUL stays -- encoded, since cgit's sinks would stop at a real one. local function unescape_path(path) return (path:gsub("%%(%x%x)", function(hex) local byte = tonumber(hex, 16) if byte > 0 then return string.char(byte) end end)) end -- The path in the tree that a relative link names. Like GitHub, it resolves -- against the readme's own directory, or against the root when it starts -- with a slash, and dot segments fold without climbing above the root. local function tree_path(path) if path:sub(1, 1) ~= "/" then path = (filename:match("^(.*/)") or "") .. path end local segments = {} for segment in path:gmatch("[^/]+") do if segment == ".." then segments[#segments] = nil elseif segment ~= "." then segments[#segments + 1] = segment end end return table.concat(segments, "/") end -- Writes a link target that safe_url passed. A relative one leads into the -- tree at the ref the readme came from, keeping its fragment but not a -- query, which the tree page would read as its own. A readme with no tree -- behind it keeps its links as written. local function emit_href(url) if tree_base == "" or url:find("^#") or url:find("^%a[%w%+%.%-]*:") then html_attr(url) return end html_attr(tree_base) html_url_path(tree_path(unescape_path(url:match("^[^?#]*")))) html_attr(tree_query) html_attr(url:match("#.*$") or "") end -- The page renders untrusted repository content. Every run of text goes out -- through cgit's html_txt, every attribute value through html_attr or, for a -- path in the tree, html_url_path, and every link target through safe_url -- first. html() only ever gets literal tag scaffolding and the character -- references whose shape the markdown grammar checked. local emit_inline emit_inline = function(list) for _, node in ipairs(list) do local kind = node.kind if kind == "text" then html_txt(node.text) elseif kind == "reference" then html(node.text) elseif kind == "break" then html("
") elseif kind == "code" then html(""); html_txt(node.text); html("") elseif kind == "strong" then html(""); emit_inline(node.kids); html("") elseif kind == "em" then html(""); emit_inline(node.kids); html("") elseif kind == "link" then local target = safe_url(node.url) if target then html("") emit_inline(node.kids) html("") else emit_inline(node.kids) end end end end -- Whether a paragraph has anything to show once images are left out, so a -- row of badges leaves no empty paragraph behind. local function shows_something(lines) for _, line in ipairs(lines) do for _, node in ipairs(line) do local blank = node.kind == "text" and not node.text:find("%S") if node.kind ~= "image" and not blank then return true end end end return false end local function plain_text(nodes, parts) for _, node in ipairs(nodes) do if node.kind == "text" or node.kind == "code" then parts[#parts + 1] = node.text elseif node.kids then plain_text(node.kids, parts) end end return parts end -- Gives a heading the anchor GitHub would, so a readme's links to its own -- sections work. That is its text in lower case, with punctuation dropped -- and each space a hyphen, and a count added to a repeat. Bytes past ASCII -- stay, keeping the letters of other scripts. local function emit_id(nodes) local base = table.concat(plain_text(nodes, {})):lower() :gsub("[^%w_%- \128-\255]", ""):gsub(" ", "-") if base == "" then return end local count = id_counts[base] or 0 id_counts[base] = count + 1 html(" id='") html_attr(count > 0 and base .. "-" .. count or base) html("'") end local function emit_lines(lines) for i, line in ipairs(lines) do if i > 1 then html("\n") end emit_inline(line) end end local emit_blocks emit_blocks = function(blocks) for _, block in ipairs(blocks) do local kind = block.kind if kind == "heading" then html("") emit_inline(block.kids) html("") elseif kind == "hr" then html("
") elseif kind == "code" then html("
")
			html_txt(block.text)
			html("
") elseif kind == "blockquote" then html("
") emit_blocks(block.blocks) html("
") elseif kind == "table" then html("") for _, cell in ipairs(block.head) do html("") end html("") for _, row in ipairs(block.rows) do html("") for _, cell in ipairs(row) do html("") end html("") end html("
") emit_inline(cell) html("
") emit_inline(cell) html("
") elseif kind == "list" then local tag = block.ordered and "ol" or "ul" html("<" .. tag .. ">") for _, item in ipairs(block.items) do html("
  • ") emit_lines(item) html("
  • ") end html("") elseif kind == "para" and shows_something(block.lines) then html("

    ") emit_lines(block.lines) html("

    ") end end end local function render_plaintext(text) html("
    ")
    	html_txt(text)
    	html("
    ") end -- Markdown is a deliberate subset rather than CommonMark, leaving out -- reference links, raw HTML passthrough and nested lists, and inline markup -- does not span the end of a line. Man rendering covers the man(7) macros and -- drops the roff requests and definitions that generators put around them. -- Both parse into a node tree before anything is emitted, so a failed parse -- falls back to plain text without leaving half a page behind. local render_markdown, render_man if has_lpeg then local P, R, S, B, C, Ct = lpeg.P, lpeg.R, lpeg.S, lpeg.B, lpeg.C, lpeg.Ct local space = S(" \t") local whitespace = S(" \t\r\n\f\v") local eol = P(-1) local marker = S("`![*_<\\&") local punctuation = S("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~") local url_char = 1 - S(")") - whitespace -- Bound the link and image inner scans. Without a cap a readme of -- unclosed brackets ("[[[[...") makes every position scan to the -- end of the line for a "]" that never comes, which is quadratic. local max_scan = 512 local parse_inline local inline_depth = 0 local function node_text(text) return { kind = "text", text = text } end local function node_code(text) return { kind = "code", text = text } end local function node_strong(text) return { kind = "strong", kids = parse_inline(text) } end local function node_em(text) return { kind = "em", kids = parse_inline(text) } end local function node_link(text, url) return { kind = "link", kids = parse_inline(text), url = url } end local function node_image() return { kind = "image" } end local function node_reference(text) return { kind = "reference", text = text } end local function node_autolink(url) return { kind = "link", kids = { node_text(url) }, url = url } end local function node_mail(address) return { kind = "link", kids = { node_text(address) }, url = "mailto:" .. address } end -- CommonMark's autolinks, an absolute url or an address between angle -- brackets. Anything else in brackets stays escaped text. local alnum = R("az", "AZ", "09") local scheme_char = alnum + S("+.-") local uri = R("az", "AZ") * scheme_char * scheme_char ^ -30 * P(":") * (1 - S("<>") - R("\0 ") - P("\127")) ^ 0 local address = (alnum + S(".!#$%&'*+/=?^_`{|}~-")) ^ 1 * P("@") * alnum * (alnum + S(".-")) ^ 0 -- An underscore inside a word belongs to the word, as in snake_case, -- so it neither opens nor closes emphasis. local word = alnum + R("\128\255") local inner = B(word) * P("_") ^ 1 * #word -- A character reference passes through for the browser to decode, -- within CommonMark's lengths. Its shape leaves no room for markup, and -- a name the browser does not know shows as written. local digit, hex = R("09"), R("09", "af", "AF") local reference = P("&") * (R("az", "AZ") * alnum ^ -31 + P("#") * digit * digit ^ -6 + P("#") * S("xX") * hex * hex ^ -5) * P(";") local label = P("[") * C((1 - P("]")) ^ (-max_scan)) * P("]") local target = P("(") * space ^ 0 * C(url_char * url_char ^ (-max_scan)) * (1 - P(")")) ^ (-max_scan) * P(")") -- Images are parsed only to be left out, along with the link around a -- badge. The about page serves no files for a relative one to load, and -- a remote one would have every visitor's browser call a third party. local inline_grammar = Ct(( (P("`") * C((1 - P("`")) ^ 1) * P("`")) / node_code + (P("\\") * C(punctuation)) / node_text + C(reference) / node_reference + (P("<") * C(uri) * P(">")) / node_autolink + (P("<") * C(address) * P(">")) / node_mail + (P("[!") * label * target * P("]") * target) / node_image + (P("!") * label * target) / node_image + (label * target) / node_link + (P("**") * C((1 - P("**")) ^ 1) * P("**")) / node_strong + (-B(word) * P("__") * C((inner + (1 - P("__"))) ^ 1) * P("__") * -word) / node_strong + (P("*") * C((1 - whitespace) * (1 - P("*")) ^ 0) * P("*")) / node_em + (-B(word) * P("_") * C((1 - whitespace) * (inner + (1 - P("_"))) ^ 0) * P("_") * -word) / node_em + C((1 - marker + inner) ^ 1) / node_text + C(P(1)) / node_text ) ^ 0) parse_inline = function(s) if inline_depth >= max_depth then return { node_text(s) } end inline_depth = inline_depth + 1 local nodes = inline_grammar:match(s) or { node_text(s) } inline_depth = inline_depth - 1 return nodes end local lang_char = R("az", "AZ", "09") + S("_.+#-") local heading_line = C(P("#") * P("#") ^ -5) * space ^ 1 * C(P(1) ^ 0) -- A run of equals signs or dashes under a paragraph turns it into a -- heading, as on GitHub, rather than reading as more of its text. local setext_line = space ^ 0 * (C(P("=") ^ 1) + C(P("-") ^ 1)) * space ^ 0 * eol local code_indent = P(" ") + P("\t") local function thematic(mark) return space ^ 0 * P(mark) * (space ^ 0 * P(mark)) ^ 2 * space ^ 0 * eol end local break_line = thematic("-") + thematic("*") + thematic("_") local fence_line = space ^ 0 * (C(P("`") ^ 3) + C(P("~") ^ 3)) * space ^ 0 * C(lang_char ^ 0) local close_backtick = space ^ 0 * P("`") ^ 3 * space ^ 0 * eol local close_tilde = space ^ 0 * P("~") ^ 3 * space ^ 0 * eol local quote_line = space ^ 0 * P(">") local bullet = S("-*+") local number = R("09") ^ 1 * S(".)") local item_line = space ^ 0 * C(bullet + number) * space ^ 1 * C(P(1) ^ 0) local dash_cell = space ^ 0 * P(":") ^ -1 * P("-") ^ 1 * P(":") ^ -1 * space ^ 0 -- A delimiter row is dash-cells joined by pipes. Require at least one -- pipe, a leading one or one between cells, so a bare rule of dashes -- stays a thematic break and an ordinary paragraph line is never taken -- for a table. local pipe = P("|") local table_delimiter = space ^ 0 * ( pipe * dash_cell * (pipe * dash_cell) ^ 0 * pipe ^ -1 + dash_cell * (pipe * dash_cell) ^ 1 * pipe ^ -1 ) * space ^ 0 * eol local block_start = space ^ 0 * ((P("#") * P("#") ^ -5 * space) + P(">") + P("`") ^ 3 + P("~") ^ 3 + ((bullet + number) * space)) local function ordered(mark) return mark:match("%d") ~= nil end local function follows_list(blocks) local last = blocks[#blocks] return last ~= nil and last.kind == "list" end -- An indented block runs on through blank lines, and up to four spaces -- or one tab come off each of its lines. local function continues_code(line) return code_indent:match(line) or not line:find("%S") end local function unindent(line) return line:match("^\t(.*)$") or line:match("^ ? ? ? ?(.*)$") end local function setext_level(line) local run = setext_line:match(line) if run then return run:sub(1, 1) == "=" and 1 or 2 end end local function table_starts(lines, i) return i < #lines and lines[i]:find("|", 1, true) ~= nil and table_delimiter:match(lines[i + 1]) ~= nil end -- A blank line ends a paragraph, and so does the start of any other -- block. That includes a table, which GitHub's own parser lets cut a -- paragraph short. local function ends_paragraph(lines, i) return lines[i]:match("^%s*$") or block_start:match(lines[i]) or table_starts(lines, i) end -- A paragraph's lines run together the way GitHub shows them, unless one -- ends in two spaces or a backslash, which breaks the line there. The -- last line of a paragraph has nothing to break before. local function paragraph_lines(raw) local lines = {} for i, line in ipairs(raw) do local hard = false if i < #raw then if line:sub(-1) == "\\" then line, hard = line:sub(1, -2), true else hard = line:sub(-2) == " " end end lines[i] = parse_inline(trim(line)) if hard then lines[i][#lines[i] + 1] = { kind = "break" } end end return lines end local parse_blocks parse_blocks = function(lines, depth) local blocks, i, n = {}, 1, #lines while i <= n do local line = lines[i] local fence, lang = fence_line:match(line) local hashes, title = heading_line:match(line) local mark = item_line:match(line) if line:match("^%s*$") then i = i + 1 elseif fence then local close = (fence:sub(1, 1) == "`") and close_backtick or close_tilde local code = {} i = i + 1 while i <= n and not close:match(lines[i]) do code[#code + 1] = lines[i] i = i + 1 end i = i + 1 local body = table.concat(code, "\n") if #code > 0 then body = body .. "\n" end blocks[#blocks + 1] = { kind = "code", lang = lang, text = body, } elseif hashes then blocks[#blocks + 1] = { kind = "heading", level = #hashes, kids = parse_inline(heading_text(title)), } i = i + 1 elseif break_line:match(line) then blocks[#blocks + 1] = { kind = "hr" } i = i + 1 elseif quote_line:match(line) then local inner = {} while i <= n and quote_line:match(lines[i]) do inner[#inner + 1] = (lines[i]:gsub("^%s*>%s?", "")) i = i + 1 end if depth < max_depth then blocks[#blocks + 1] = { kind = "blockquote", blocks = parse_blocks(inner, depth + 1), } else blocks[#blocks + 1] = { kind = "para", lines = paragraph_lines(inner), } end elseif table_starts(lines, i) then local head = {} for _, cell in ipairs(split_cells(line)) do head[#head + 1] = parse_inline(cell) end local rows = {} i = i + 2 while i <= n and lines[i]:find("|", 1, true) do local row = {} for _, cell in ipairs(split_cells(lines[i])) do row[#row + 1] = parse_inline(cell) end rows[#rows + 1] = row i = i + 1 end blocks[#blocks + 1] = { kind = "table", head = head, rows = rows, } elseif mark then local is_ordered = ordered(mark) local items = {} while i <= n do local item_mark, content = item_line:match(lines[i]) if not item_mark or ordered(item_mark) ~= is_ordered then break end local item = { content } i = i + 1 -- A wrapped line carries the item on, whatever its -- indent, while a rule still ends the list. while i <= n and not ends_paragraph(lines, i) and not break_line:match(lines[i]) do item[#item + 1] = lines[i] i = i + 1 end items[#items + 1] = paragraph_lines(item) end blocks[#blocks + 1] = { kind = "list", ordered = is_ordered, items = items, } -- Four spaces or a tab start a code block, except straight -- after a list, where an indented paragraph more likely -- carries an item on. elseif code_indent:match(line) and not follows_list(blocks) then local code = {} while i <= n and continues_code(lines[i]) do code[#code + 1] = unindent(lines[i]) i = i + 1 end while code[#code]:match("^%s*$") do code[#code] = nil end blocks[#blocks + 1] = { kind = "code", text = table.concat(code, "\n") .. "\n", } else local paragraph, level = { line }, nil i = i + 1 while i <= n do level = setext_level(lines[i]) if level or ends_paragraph(lines, i) or break_line:match(lines[i]) then break end paragraph[#paragraph + 1] = lines[i] i = i + 1 end if level then local words = {} for k, text in ipairs(paragraph) do words[k] = trim(text) end blocks[#blocks + 1] = { kind = "heading", level = level, kids = parse_inline(table.concat(words, " ")), } i = i + 1 else blocks[#blocks + 1] = { kind = "para", lines = paragraph_lines(paragraph), } end end end return blocks end render_markdown = function(text) if #text > max_bytes then return render_plaintext(text) end local ok, blocks = pcall(parse_blocks, split_lines(text), 0) if not ok then return render_plaintext(text) end html("
    ") -- Emitting is wrapped so a bug there can at worst -- truncate the page, never raise and let the outer -- fallback duplicate what has already been sent. pcall(emit_blocks, blocks) html("
    ") end -- Roff fonts are a current state carried across a run rather than -- opened and closed, so the inline pass tokenises to a flat list -- and folds it into the nodes markdown emits, instead of nesting. local macro_line = S(".'") * space ^ 0 * C((1 - space) ^ 1) * space ^ 0 * C(P(1) ^ 0) -- Fonts by name and by position. Anything else, \fP for the previous -- font among them, falls back to roman. local fonts = { B = "B", I = "I", BI = "B", CB = "B", CI = "I", ["2"] = "I", ["3"] = "B", } -- The named characters and strings generated pages use most. A name -- with no entry renders as nothing rather than as its spelling. local man_chars = { aq = "'", dq = '"', cq = "’", oq = "‘", lq = "“", rq = "”", hy = "-", en = "–", em = "—", bu = "•", co = "©", rg = "®", tm = "™", ti = "~", ha = "^", rs = "\\", } local man_strings = { lq = "“", rq = "”", R = "®", Tm = "™", Aq = "'", ['L"'] = "“", ['R"'] = "”", } local backslash = P("\\") local function text_token(text) return { kind = "text", text = text } end local skip_token = { kind = "skip" } local function skip() return skip_token end local function font_token(name) return { kind = "font", font = fonts[name] or "R" } end local function char_token(name) return text_token(man_chars[name] or "") end local function string_token(name) return text_token(man_strings[name] or "") end -- \( takes a two character name and \[ a longer one, bounded so that a -- line of unclosed openers stays linear. local named = P("(") * C(P(1) * P(1)) + P("[") * C((1 - P("]")) ^ -64) * P("]") -- Comments, size changes, colours, the half line moves DocBook puts -- around a footnote mark and conditional braces draw nothing, and nor do -- the escapes that only steer hyphenation or breaks. local man_inline_grammar = Ct(( (backslash * P("f") * (named + C(P(1)))) / font_token + (backslash * S('"#') * P(1) ^ 0) / skip + (backslash * P("-")) / function() return text_token("-") end + (backslash * P("e")) / function() return text_token("\\") end + (backslash * S(" 0~")) / function() return text_token(" ") end + (backslash * S("&|^%:cud{}")) / skip + (backslash * named) / char_token + (backslash * P("*") * (named + C(P(1)))) / string_token + (backslash * P("s") * S("+-") ^ -1 * (named + R("09") * R("09") ^ -1)) / skip + (backslash * S("mM") * (named + P(1))) / skip + (backslash * C(P(1))) / text_token + backslash / skip + C((1 - backslash) ^ 1) / text_token ) ^ 0) local function man_inline(text) local tokens = man_inline_grammar:match(text) or {} local nodes, parts, font = {}, {}, "R" local function flush() if #parts == 0 then return end local run = table.concat(parts) parts = {} if run == "" then return end if font == "B" then nodes[#nodes + 1] = { kind = "strong", kids = { { kind = "text", text = run } }, } elseif font == "I" then nodes[#nodes + 1] = { kind = "em", kids = { { kind = "text", text = run } }, } else nodes[#nodes + 1] = { kind = "text", text = run } end end for _, token in ipairs(tokens) do if token.kind == "text" then parts[#parts + 1] = token.text elseif token.kind == "font" then flush() font = token.font end end flush() return nodes end -- A no fill block keeps its lines as they are, so only the text of -- each one is wanted there, without its fonts. local function man_plain(text) local parts = {} for _, token in ipairs(man_inline_grammar:match(text) or {}) do if token.kind == "text" then parts[#parts + 1] = token.text end end return table.concat(parts) end -- A macro's arguments divide at blanks, except inside double quotes. local function man_args(rest) local args, i = {}, 1 while true do i = rest:find("%S", i) if not i then return args end local stop if rest:sub(i, i) == '"' then stop = rest:find('"', i + 1, true) or #rest + 1 args[#args + 1] = rest:sub(i + 1, stop - 1) else stop = rest:find("%s", i) or #rest + 1 args[#args + 1] = rest:sub(i, stop - 1) end i = stop + 1 end end local function in_font(font, nodes) if font == "B" then return { { kind = "strong", kids = nodes } } elseif font == "I" then return { { kind = "em", kids = nodes } } end return nodes end -- Each alternating macro sets its arguments in its two fonts by turns -- with nothing between them, as in .BR ls (1). local single_font = { B = "B", I = "I" } local alternating = { BR = { "B", "R" }, RB = { "R", "B" }, IR = { "I", "R" }, RI = { "R", "I" }, BI = { "B", "I" }, IB = { "I", "B" }, } -- No fill blocks under their man(7) and groff names, and under the Vb -- and Ve that pod2man defines for its verbatim paragraphs. local nofill_start = { nf = true, EX = true, Vb = true } local nofill_end = { fi = true, EE = true, Ve = true } -- Macros that only begin a paragraph, Sp being pod2man's, ones that only -- set spacing or an index entry, and the requests that open a definition, -- which runs to a line of two dots. local breaks = { TH = true, PP = true, LP = true, P = true, HP = true, RS = true, RE = true, sp = true, Sp = true, } local silent = { PD = true, DT = true, IX = true } local definitions = { de = true, de1 = true, am = true, ig = true } local function parse_man(text) local lines = split_lines(text) local blocks, defined = {}, {} local paragraph, nofill_lines, nofill = nil, nil, false local skipping, tag_pending = false, false local function flush_paragraph() if paragraph and #paragraph > 0 then blocks[#blocks + 1] = { kind = "para", lines = paragraph } end paragraph = nil end local function flush_nofill() if nofill_lines then local body = table.concat(nofill_lines, "\n") if #nofill_lines > 0 then body = body .. "\n" end blocks[#blocks + 1] = { kind = "code", text = body } nofill_lines = nil end end -- A tagged paragraph puts its tag on a line of its own above the -- text it describes. local function add_line(nodes) if tag_pending then nodes[#nodes + 1] = { kind = "break" } tag_pending = false end paragraph = paragraph or {} paragraph[#paragraph + 1] = nodes end for _, line in ipairs(lines) do local macro, rest = macro_line:match(line) if skipping then skipping = not line:find("^%.%.%s*$") elseif macro and macro:sub(1, 1) == "\\" or line:find("^[.']%s*$") then -- A comment, the close of a conditional block or an empty -- request. elseif nofill then if nofill_end[macro] then nofill = false flush_nofill() elseif not macro then nofill_lines[#nofill_lines + 1] = man_plain(line) end elseif definitions[macro] then -- What a page defines for itself cannot be expanded here, -- so the definition is skipped and so are its uses. local name = man_args(rest)[1] if name and macro ~= "ig" then defined[name] = true end skipping = true elseif line:match("^%s*$") then flush_paragraph() elseif macro == "SH" or macro == "SS" then flush_paragraph() tag_pending = false blocks[#blocks + 1] = { kind = "heading", level = (macro == "SH") and 2 or 3, kids = man_inline(table.concat(man_args(rest), " ")), } elseif nofill_start[macro] then flush_paragraph() nofill = true nofill_lines = {} elseif nofill_end[macro] then flush_nofill() elseif single_font[macro] then local nodes = man_inline(table.concat(man_args(rest), " ")) add_line(in_font(single_font[macro], nodes)) elseif alternating[macro] then local pair, nodes = alternating[macro], {} for k, arg in ipairs(man_args(rest)) do local font = pair[(k - 1) % 2 + 1] for _, node in ipairs(in_font(font, man_inline(arg))) do nodes[#nodes + 1] = node end end add_line(nodes) elseif macro == "TP" then flush_paragraph() tag_pending = true elseif macro == "IP" then -- A bullet or a number leads the text on its line, and any -- other tag takes a line of its own. flush_paragraph() local tag = man_args(rest)[1] if tag and tag ~= "" then local nodes, plain = man_inline(tag), man_plain(tag) if plain:find("%w") and not plain:find("^%d+[.)]?$") then nodes[#nodes + 1] = { kind = "break" } else nodes[#nodes + 1] = { kind = "text", text = " " } end add_line(nodes) end elseif macro == "br" then if paragraph then local last = paragraph[#paragraph] last[#last + 1] = { kind = "break" } end elseif breaks[macro] then flush_paragraph() elseif macro and (silent[macro] or defined[macro] or macro:find("^%l")) then -- A macro the page defined, or a roff request, which in -- lower case only steers the layout. elseif macro then local args = man_args(rest) if #args > 0 then add_line(man_inline(table.concat(args, " "))) end else -- A line of nothing but escapes that draw nothing, such as -- a conditional's brace, adds no line. local nodes = man_inline(line) if #nodes > 0 then add_line(nodes) end end end flush_paragraph() flush_nofill() return blocks end render_man = function(text) if #text > max_bytes then return render_plaintext(text) end local ok, blocks = pcall(parse_man, text) if not ok then return render_plaintext(text) end html("
    ") pcall(emit_blocks, blocks) html("
    ") end else -- No lpeg, so markdown and man readmes read as escaped source, not -- nothing. render_markdown = render_plaintext render_man = render_plaintext end local handlers = {} for _, ext in ipairs({ "md", "markdown", "mkd", "mdown" }) do handlers[ext] = render_markdown end for _, ext in ipairs({ "1", "2", "3", "4", "5", "6", "7", "8", "9", "man" }) do handlers[ext] = render_man end -- A readme tracked in the repository comes with the url of the tree page at -- its ref and the query that follows a path there, both escaped for a url. function filter_open(name, base, query) filename = name or "" tree_base = base or "" tree_query = query or "" chunks = {} id_counts = { cgit = 1, header = 1, about = 1 } end function filter_write(str) chunks[#chunks + 1] = str end -- cgit takes filter output through a C string sink that stops at the first -- NUL byte, so each one becomes U+FFFD, as a browser would show it, and the -- text past it survives. A plain find works on Lua 5.1, where a NUL cannot -- sit in a pattern. local function replace_nul(text) if not text:find("\0", 1, true) then return text end local parts, start = {}, 1 while true do local at = text:find("\0", start, true) if not at then parts[#parts + 1] = text:sub(start) return table.concat(parts, "\239\191\189") end parts[#parts + 1] = text:sub(start, at - 1) start = at + 1 end end function filter_close() local text = replace_nul(table.concat(chunks)) chunks = {} local ext = (filename:match("%.([^.]+)$") or ""):lower() local render = handlers[ext] or render_plaintext local ok = pcall(render, text) if not ok then render_plaintext(text) end return 0 end