Diff options
| author | Bryce Kwon <bryce@brycekwon.com> | |
|---|---|---|
| committer | Bryce Kwon <bryce@brycekwon.com> | |
| commit | ||
| parent | ||
| tree | ||
| download | ||
Render the man pages that generators write
DocBook, pod2man and hand written pages leaked roff onto the page: font
escapes inside no fill blocks, comments, layout escapes such as \% and
\c, the colours and half line moves around DocBook's links, and the
requests and macro definitions their preambles open with. Those now
draw nothing, the common named characters and strings render, .EX and
pod2man's .Vb read as code, a .TP tag takes a line of its own and the
alternating font macros switch fonts between their arguments. Anything
rarer still falls back to its text.
| -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 |
