Diff options
| -rw-r--r-- | custom/extensions/about-render.lua | 240 | |
| -rw-r--r-- | tests/extensions/test-about-render.lua | 71 | |
| -rwxr-xr-x | tests/t0501-about-render.sh | 43 |
3 files changed, 298 insertions, 56 deletions
diff --git a/custom/extensions/about-render.lua b/custom/extensions/about-render.lua index 040768c..808dd08 100644 --- a/custom/extensions/about-render.lua +++ b/custom/extensions/about-render.lua @@ -343,9 +343,10 @@ 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 common macros and --- drops the rest. 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. +-- 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 @@ -712,41 +713,58 @@ if has_lpeg then -- 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) - local one_font = { B = "B", I = "I" } - local two_font = { CB = "B", BI = "B", CI = "I" } + -- 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 = "'", - cq = "'", - oq = "'", - dq = '"', - lq = '"', - rq = '"', - hy = "-", - en = "-", - em = "-", + 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 font_token(name) return { kind = "font", font = name } end 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") * P("(") * C(P(1) * P(1))) - / function(name) return font_token(two_font[name] or "R") end - + (backslash * P("f") * P("[") * C((1 - P("]")) ^ 0) * P("]")) - / function(name) return font_token(one_font[name] or "R") end - + (backslash * P("f") * C(P(1))) - / function(name) return font_token(one_font[name] or "R") end + (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 * P(" ")) / function() return text_token(" ") end - + (backslash * S("&|^")) / function() return skip_token end - + (backslash * P("(") * C(P(1) * P(1))) - / function(name) return text_token(man_chars[name] or "") end - + (backslash * P("*") * (P("(") * P(1) * P(1) + P(1))) - / function() return skip_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 / function() return skip_token end + + backslash / skip + C((1 - backslash) ^ 1) / text_token ) ^ 0) @@ -788,10 +806,75 @@ if has_lpeg then 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 = {} + 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 } @@ -808,54 +891,107 @@ if has_lpeg then 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 rest then - rest = (rest:gsub('^"(.*)"$', "%1")) - end - if nofill then - if macro == "fi" then + 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() - else - nofill_lines[#nofill_lines + 1] = line + 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 and macro:sub(1, 1) == "\\" then - -- A roff comment, dropped. 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(rest), + kids = man_inline(table.concat(man_args(rest), " ")), } - elseif macro == "nf" then + elseif nofill_start[macro] then flush_paragraph() nofill = true nofill_lines = {} - elseif macro == "fi" then + elseif nofill_end[macro] then flush_nofill() - elseif macro == "B" or macro == "BR" or macro == "RB" or macro == "BI" or macro == "IB" then - add_line({ { kind = "strong", kids = man_inline(rest) } }) - elseif macro == "I" or macro == "IR" or macro == "RI" then - add_line({ { kind = "em", kids = man_inline(rest) } }) - elseif macro == "TH" or macro == "PP" or macro == "LP" - or macro == "P" or macro == "TP" or macro == "IP" - or macro == "HP" or macro == "RS" or macro == "RE" - or macro == "sp" or macro == "br" then + 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 - if rest ~= "" then - add_line(man_inline(rest)) + local args = man_args(rest) + if #args > 0 then + add_line(man_inline(table.concat(args, " "))) end else - add_line(man_inline(line)) + -- 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() diff --git a/tests/extensions/test-about-render.lua b/tests/extensions/test-about-render.lua index 0571796..88c136c 100644 --- a/tests/extensions/test-about-render.lua +++ b/tests/extensions/test-about-render.lua @@ -257,6 +257,15 @@ render("README.md", string.rep("# a\n", 50000)) render("README.md", "| a | b |\n|---|---|\n| x" .. string.rep(" ", 40000) .. "y | z |") h.check("a long run of spaces is trimmed quickly", os.clock() - started < 10) +-- The long forms of the man escapes scan for a closing bracket or quote, so +-- they are bounded the same way, and a line of openers stays linear. +started = os.clock() +render("page.1", string.rep("\\[", 100000)) +render("page.1", string.rep("\\f[", 100000)) +render("page.1", string.rep("\\h'", 100000)) +render("page.1", string.rep("\\s[", 100000)) +h.check("a line of unclosed man escapes renders quickly", os.clock() - started < 10) + out = render("README.md", "Title\n=====\n\nBody") h.contains("equals signs under a line make a heading", out, "<h1 id='title'>Title</h1><p>Body</p>") @@ -334,13 +343,71 @@ h.contains("the bold macro renders strong", out, "<p><strong>bold words</strong> out = render("page.1", "a \\fBbold\\fR c") h.contains("font escapes carry across a run", out, "<p>a <strong>bold</strong> c</p>") -out = render("page.1", "x \\(em y \\- z") -h.contains("character escapes are translated", out, "<p>x - y - z</p>") +out = render("page.1", "x \\(em y \\- z \\(bu \\[co]") +h.contains("character escapes are translated", out, "<p>x — y - z • ©</p>") + +out = render("page.1", "\\*(lqquoted\\*(rq\\*R") +h.contains("predefined strings are expanded", out, "<p>“quoted”®</p>") + +out = render("page.1", "a\\%b c\\:d \\s-1SMALL\\s0 text \\\" a comment") +h.contains("escapes that only steer the layout draw nothing", out, "<p>ab cd SMALL text </p>") out = render("page.1", ".nf\ncode <x>\n.fi\nafter") h.contains("a no fill block renders as code", out, "<pre><code>code <x>\n</code></pre>") h.contains("filling resumes after it", out, "<p>after</p>") +out = render("page.1", ".nf\n\\fBgit\\fR \\-\\-version\n.fi") +h.contains("a no fill block shows the text of its escapes", out, "<pre><code>git --version\n</code></pre>") + +out = render("page.1", ".EX\nline <one>\n.EE") +h.contains("an example block renders as code", out, "<pre><code>line <one>\n</code></pre>") + +out = render("page.1", ".TP\n.B \\-v\nBe verbose.") +h.contains("a tag takes a line of its own", out, "<p><strong>-v</strong><br>\nBe verbose.</p>") + +out = render("page.1", ".IP \\(bu 2\nAn item.\n.IP \\-v 4\nA flag.") +h.contains("a bullet leads its item", out, "<p>• \nAn item.</p>") +h.contains("a longer tag takes a line of its own", out, "<p>-v<br>\nA flag.</p>") + +out = render("page.1", ".BR ls (1),\n.IR file .txt") +h.contains("alternating macros switch fonts between arguments", out, + "<strong>ls</strong>(1),\n<em>file</em>.txt") + +out = render("page.1", '.B "two words" three') +h.contains("a quoted argument keeps its blank", out, "<strong>two words three</strong>") + +out = render("page.1", "one\n.br\ntwo\n.\nthree") +h.contains("a break request breaks the line", out, "<p>one<br>\ntwo\nthree</p>") + +-- pod2man and asciidoctor open their pages with macro definitions and +-- conditional requests, none of which may reach the page as text. +out = render("page.1", table.concat({ + ".de Vb", ".nf", "..", ".de IX", "..", ".if n .ds Aq '", ".ie \\n(.g \\{\\", + ". nr x 1", ".\\}", ".SH NAME", ".IX Header \"NAME\"", ".Vb 2", "\\& code", ".Ve", "after", +}, "\n")) +h.excludes("a page's own macro is not drawn", out, "Header") +h.excludes("a roff request is not drawn", out, "Aq") +h.contains("the heading after the preamble renders", out, "<h2 id='name'>NAME</h2>") +h.contains("pod2man's verbatim macros render code", out, "<pre><code> code\n</code></pre>") +h.contains("text after the definitions renders", out, "<p>after</p>") + +out = render("page.1", ".if n \\\n\\{\\\n.ds x y\n.\\}\nafter") +h.contains("a conditional block's requests and braces draw nothing", out, + "<div class='markdown manpage'><p>after</p></div>") + +out = render("page.1", "The \\m[blue]\\fBManual\\fR\\m[]\\s-2\\u[1]\\d\\s+2 here") +h.contains("DocBook's link colours and footnote marks draw nothing", out, + "<p>The <strong>Manual</strong>[1] here</p>") + +out = render("page.1", ".de x\n.B hidden\n.. \nshown") +h.contains("a definition ends at two dots with blanks after them", out, + "<div class='markdown manpage'><p>shown</p></div>") + +out = render("page.1", ".TP\n.PD 0\n.B \\-a\nall\n.PD\nDon\\*(Aqt") +h.excludes("a spacing macro draws nothing", out, ">0") +h.contains("DocBook's apostrophe string expands", out, "Don't") + + out = render("page.1", '.\\" a comment\nvisible') h.excludes("a roff comment is dropped", out, "comment") h.contains("the line after it renders", out, "visible") diff --git a/tests/t0501-about-render.sh b/tests/t0501-about-render.sh index 263c79b..339c5e2 100755 --- a/tests/t0501-about-render.sh +++ b/tests/t0501-about-render.sh @@ -39,7 +39,20 @@ test_expect_success 'create a repository with a markdown readme' ' See the [roadmap](ROADMAP.md#plan). EOF - git add README.md && + cat >tool.1 <<-\EOF && + .TH TOOL 1 + .SH NAME + tool \- does one thing \(em well + .SH OPTIONS + .TP + .B \-v + Be \fIverbose\fR. + .nf + tool \-v <file> + .fi + EOF + echo "Plain <b>notes</b> & more" >NOTES && + git add README.md tool.1 NOTES && git commit -m "add readme" && git branch next ) @@ -53,11 +66,20 @@ test_expect_success 'point cgit at it through the about filter' ' repo.path=$PWD/repos/md/.git repo.readme=:README.md repo.about-filter=lua:$EXTENSIONS_DIRECTORY/about-render.lua + repo.url=man + repo.path=$PWD/repos/md/.git + repo.readme=:tool.1 + repo.about-filter=lua:$EXTENSIONS_DIRECTORY/about-render.lua + repo.url=txt + repo.path=$PWD/repos/md/.git + repo.readme=:NOTES + repo.about-filter=lua:$EXTENSIONS_DIRECTORY/about-render.lua EOF ' if test "$CGIT_HAS_LUA" -eq 1 then + test_set_prereq CGIT_LUA if cgit_lua_probe "$PWD/repos/md/.git" lpeg then test_set_prereq CGIT_LUA_LPEG @@ -91,10 +113,27 @@ test_expect_success CGIT_LUA_LPEG 'the link stays on the branch shown' ' grep "<a href=./md/tree/ROADMAP.md?h=next#plan.>roadmap</a>" tmp ' +test_expect_success CGIT_LUA_LPEG 'the about page renders a man page' ' + cgit_url "man/about/" >tmp && + grep "<div class=.markdown manpage.><h2 id=.name.>NAME</h2>" tmp && + grep "tool - does one thing — well" tmp && + grep "<strong>-v</strong><br>" tmp && + grep "<pre><code>tool -v <file>" tmp && + ! grep "\\\\f" tmp +' + +test_expect_success CGIT_LUA 'the about page shows plain text as it is' ' + cgit_url "txt/about/" >tmp && + grep "<pre class=.plaintext.>Plain <b>notes</b> & more" tmp +' + test_expect_success CGIT_LUA_NOLPEG 'without lpeg the readme is served escaped' ' cgit_url "md/about/" >tmp && grep "<pre class=.plaintext.>" tmp && - grep "# The Title" tmp + grep "# The Title" tmp && + cgit_url "man/about/" >tmp && + grep "<pre class=.plaintext.>" tmp && + grep ".SH NAME" tmp ' test_done |
