|
1 package md2man |
|
2 |
|
3 import ( |
|
4 "fmt" |
|
5 "io" |
|
6 "os" |
|
7 "strings" |
|
8 |
|
9 "github.com/russross/blackfriday/v2" |
|
10 ) |
|
11 |
|
12 // roffRenderer implements the blackfriday.Renderer interface for creating |
|
13 // roff format (manpages) from markdown text |
|
14 type roffRenderer struct { |
|
15 extensions blackfriday.Extensions |
|
16 listCounters []int |
|
17 firstHeader bool |
|
18 defineTerm bool |
|
19 listDepth int |
|
20 } |
|
21 |
|
22 const ( |
|
23 titleHeader = ".TH " |
|
24 topLevelHeader = "\n\n.SH " |
|
25 secondLevelHdr = "\n.SH " |
|
26 otherHeader = "\n.SS " |
|
27 crTag = "\n" |
|
28 emphTag = "\\fI" |
|
29 emphCloseTag = "\\fP" |
|
30 strongTag = "\\fB" |
|
31 strongCloseTag = "\\fP" |
|
32 breakTag = "\n.br\n" |
|
33 paraTag = "\n.PP\n" |
|
34 hruleTag = "\n.ti 0\n\\l'\\n(.lu'\n" |
|
35 linkTag = "\n\\[la]" |
|
36 linkCloseTag = "\\[ra]" |
|
37 codespanTag = "\\fB\\fC" |
|
38 codespanCloseTag = "\\fR" |
|
39 codeTag = "\n.PP\n.RS\n\n.nf\n" |
|
40 codeCloseTag = "\n.fi\n.RE\n" |
|
41 quoteTag = "\n.PP\n.RS\n" |
|
42 quoteCloseTag = "\n.RE\n" |
|
43 listTag = "\n.RS\n" |
|
44 listCloseTag = "\n.RE\n" |
|
45 arglistTag = "\n.TP\n" |
|
46 tableStart = "\n.TS\nallbox;\n" |
|
47 tableEnd = ".TE\n" |
|
48 tableCellStart = "T{\n" |
|
49 tableCellEnd = "\nT}\n" |
|
50 ) |
|
51 |
|
52 // NewRoffRenderer creates a new blackfriday Renderer for generating roff documents |
|
53 // from markdown |
|
54 func NewRoffRenderer() *roffRenderer { // nolint: golint |
|
55 var extensions blackfriday.Extensions |
|
56 |
|
57 extensions |= blackfriday.NoIntraEmphasis |
|
58 extensions |= blackfriday.Tables |
|
59 extensions |= blackfriday.FencedCode |
|
60 extensions |= blackfriday.SpaceHeadings |
|
61 extensions |= blackfriday.Footnotes |
|
62 extensions |= blackfriday.Titleblock |
|
63 extensions |= blackfriday.DefinitionLists |
|
64 return &roffRenderer{ |
|
65 extensions: extensions, |
|
66 } |
|
67 } |
|
68 |
|
69 // GetExtensions returns the list of extensions used by this renderer implementation |
|
70 func (r *roffRenderer) GetExtensions() blackfriday.Extensions { |
|
71 return r.extensions |
|
72 } |
|
73 |
|
74 // RenderHeader handles outputting the header at document start |
|
75 func (r *roffRenderer) RenderHeader(w io.Writer, ast *blackfriday.Node) { |
|
76 // disable hyphenation |
|
77 out(w, ".nh\n") |
|
78 } |
|
79 |
|
80 // RenderFooter handles outputting the footer at the document end; the roff |
|
81 // renderer has no footer information |
|
82 func (r *roffRenderer) RenderFooter(w io.Writer, ast *blackfriday.Node) { |
|
83 } |
|
84 |
|
85 // RenderNode is called for each node in a markdown document; based on the node |
|
86 // type the equivalent roff output is sent to the writer |
|
87 func (r *roffRenderer) RenderNode(w io.Writer, node *blackfriday.Node, entering bool) blackfriday.WalkStatus { |
|
88 |
|
89 var walkAction = blackfriday.GoToNext |
|
90 |
|
91 switch node.Type { |
|
92 case blackfriday.Text: |
|
93 r.handleText(w, node, entering) |
|
94 case blackfriday.Softbreak: |
|
95 out(w, crTag) |
|
96 case blackfriday.Hardbreak: |
|
97 out(w, breakTag) |
|
98 case blackfriday.Emph: |
|
99 if entering { |
|
100 out(w, emphTag) |
|
101 } else { |
|
102 out(w, emphCloseTag) |
|
103 } |
|
104 case blackfriday.Strong: |
|
105 if entering { |
|
106 out(w, strongTag) |
|
107 } else { |
|
108 out(w, strongCloseTag) |
|
109 } |
|
110 case blackfriday.Link: |
|
111 if !entering { |
|
112 out(w, linkTag+string(node.LinkData.Destination)+linkCloseTag) |
|
113 } |
|
114 case blackfriday.Image: |
|
115 // ignore images |
|
116 walkAction = blackfriday.SkipChildren |
|
117 case blackfriday.Code: |
|
118 out(w, codespanTag) |
|
119 escapeSpecialChars(w, node.Literal) |
|
120 out(w, codespanCloseTag) |
|
121 case blackfriday.Document: |
|
122 break |
|
123 case blackfriday.Paragraph: |
|
124 // roff .PP markers break lists |
|
125 if r.listDepth > 0 { |
|
126 return blackfriday.GoToNext |
|
127 } |
|
128 if entering { |
|
129 out(w, paraTag) |
|
130 } else { |
|
131 out(w, crTag) |
|
132 } |
|
133 case blackfriday.BlockQuote: |
|
134 if entering { |
|
135 out(w, quoteTag) |
|
136 } else { |
|
137 out(w, quoteCloseTag) |
|
138 } |
|
139 case blackfriday.Heading: |
|
140 r.handleHeading(w, node, entering) |
|
141 case blackfriday.HorizontalRule: |
|
142 out(w, hruleTag) |
|
143 case blackfriday.List: |
|
144 r.handleList(w, node, entering) |
|
145 case blackfriday.Item: |
|
146 r.handleItem(w, node, entering) |
|
147 case blackfriday.CodeBlock: |
|
148 out(w, codeTag) |
|
149 escapeSpecialChars(w, node.Literal) |
|
150 out(w, codeCloseTag) |
|
151 case blackfriday.Table: |
|
152 r.handleTable(w, node, entering) |
|
153 case blackfriday.TableCell: |
|
154 r.handleTableCell(w, node, entering) |
|
155 case blackfriday.TableHead: |
|
156 case blackfriday.TableBody: |
|
157 case blackfriday.TableRow: |
|
158 // no action as cell entries do all the nroff formatting |
|
159 return blackfriday.GoToNext |
|
160 default: |
|
161 fmt.Fprintln(os.Stderr, "WARNING: go-md2man does not handle node type "+node.Type.String()) |
|
162 } |
|
163 return walkAction |
|
164 } |
|
165 |
|
166 func (r *roffRenderer) handleText(w io.Writer, node *blackfriday.Node, entering bool) { |
|
167 var ( |
|
168 start, end string |
|
169 ) |
|
170 // handle special roff table cell text encapsulation |
|
171 if node.Parent.Type == blackfriday.TableCell { |
|
172 if len(node.Literal) > 30 { |
|
173 start = tableCellStart |
|
174 end = tableCellEnd |
|
175 } else { |
|
176 // end rows that aren't terminated by "tableCellEnd" with a cr if end of row |
|
177 if node.Parent.Next == nil && !node.Parent.IsHeader { |
|
178 end = crTag |
|
179 } |
|
180 } |
|
181 } |
|
182 out(w, start) |
|
183 escapeSpecialChars(w, node.Literal) |
|
184 out(w, end) |
|
185 } |
|
186 |
|
187 func (r *roffRenderer) handleHeading(w io.Writer, node *blackfriday.Node, entering bool) { |
|
188 if entering { |
|
189 switch node.Level { |
|
190 case 1: |
|
191 if !r.firstHeader { |
|
192 out(w, titleHeader) |
|
193 r.firstHeader = true |
|
194 break |
|
195 } |
|
196 out(w, topLevelHeader) |
|
197 case 2: |
|
198 out(w, secondLevelHdr) |
|
199 default: |
|
200 out(w, otherHeader) |
|
201 } |
|
202 } |
|
203 } |
|
204 |
|
205 func (r *roffRenderer) handleList(w io.Writer, node *blackfriday.Node, entering bool) { |
|
206 openTag := listTag |
|
207 closeTag := listCloseTag |
|
208 if node.ListFlags&blackfriday.ListTypeDefinition != 0 { |
|
209 // tags for definition lists handled within Item node |
|
210 openTag = "" |
|
211 closeTag = "" |
|
212 } |
|
213 if entering { |
|
214 r.listDepth++ |
|
215 if node.ListFlags&blackfriday.ListTypeOrdered != 0 { |
|
216 r.listCounters = append(r.listCounters, 1) |
|
217 } |
|
218 out(w, openTag) |
|
219 } else { |
|
220 if node.ListFlags&blackfriday.ListTypeOrdered != 0 { |
|
221 r.listCounters = r.listCounters[:len(r.listCounters)-1] |
|
222 } |
|
223 out(w, closeTag) |
|
224 r.listDepth-- |
|
225 } |
|
226 } |
|
227 |
|
228 func (r *roffRenderer) handleItem(w io.Writer, node *blackfriday.Node, entering bool) { |
|
229 if entering { |
|
230 if node.ListFlags&blackfriday.ListTypeOrdered != 0 { |
|
231 out(w, fmt.Sprintf(".IP \"%3d.\" 5\n", r.listCounters[len(r.listCounters)-1])) |
|
232 r.listCounters[len(r.listCounters)-1]++ |
|
233 } else if node.ListFlags&blackfriday.ListTypeDefinition != 0 { |
|
234 // state machine for handling terms and following definitions |
|
235 // since blackfriday does not distinguish them properly, nor |
|
236 // does it seperate them into separate lists as it should |
|
237 if !r.defineTerm { |
|
238 out(w, arglistTag) |
|
239 r.defineTerm = true |
|
240 } else { |
|
241 r.defineTerm = false |
|
242 } |
|
243 } else { |
|
244 out(w, ".IP \\(bu 2\n") |
|
245 } |
|
246 } else { |
|
247 out(w, "\n") |
|
248 } |
|
249 } |
|
250 |
|
251 func (r *roffRenderer) handleTable(w io.Writer, node *blackfriday.Node, entering bool) { |
|
252 if entering { |
|
253 out(w, tableStart) |
|
254 //call walker to count cells (and rows?) so format section can be produced |
|
255 columns := countColumns(node) |
|
256 out(w, strings.Repeat("l ", columns)+"\n") |
|
257 out(w, strings.Repeat("l ", columns)+".\n") |
|
258 } else { |
|
259 out(w, tableEnd) |
|
260 } |
|
261 } |
|
262 |
|
263 func (r *roffRenderer) handleTableCell(w io.Writer, node *blackfriday.Node, entering bool) { |
|
264 var ( |
|
265 start, end string |
|
266 ) |
|
267 if node.IsHeader { |
|
268 start = codespanTag |
|
269 end = codespanCloseTag |
|
270 } |
|
271 if entering { |
|
272 if node.Prev != nil && node.Prev.Type == blackfriday.TableCell { |
|
273 out(w, "\t"+start) |
|
274 } else { |
|
275 out(w, start) |
|
276 } |
|
277 } else { |
|
278 // need to carriage return if we are at the end of the header row |
|
279 if node.IsHeader && node.Next == nil { |
|
280 end = end + crTag |
|
281 } |
|
282 out(w, end) |
|
283 } |
|
284 } |
|
285 |
|
286 // because roff format requires knowing the column count before outputting any table |
|
287 // data we need to walk a table tree and count the columns |
|
288 func countColumns(node *blackfriday.Node) int { |
|
289 var columns int |
|
290 |
|
291 node.Walk(func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus { |
|
292 switch node.Type { |
|
293 case blackfriday.TableRow: |
|
294 if !entering { |
|
295 return blackfriday.Terminate |
|
296 } |
|
297 case blackfriday.TableCell: |
|
298 if entering { |
|
299 columns++ |
|
300 } |
|
301 default: |
|
302 } |
|
303 return blackfriday.GoToNext |
|
304 }) |
|
305 return columns |
|
306 } |
|
307 |
|
308 func out(w io.Writer, output string) { |
|
309 io.WriteString(w, output) // nolint: errcheck |
|
310 } |
|
311 |
|
312 func needsBackslash(c byte) bool { |
|
313 for _, r := range []byte("-_&\\~") { |
|
314 if c == r { |
|
315 return true |
|
316 } |
|
317 } |
|
318 return false |
|
319 } |
|
320 |
|
321 func escapeSpecialChars(w io.Writer, text []byte) { |
|
322 for i := 0; i < len(text); i++ { |
|
323 // escape initial apostrophe or period |
|
324 if len(text) >= 1 && (text[0] == '\'' || text[0] == '.') { |
|
325 out(w, "\\&") |
|
326 } |
|
327 |
|
328 // directly copy normal characters |
|
329 org := i |
|
330 |
|
331 for i < len(text) && !needsBackslash(text[i]) { |
|
332 i++ |
|
333 } |
|
334 if i > org { |
|
335 w.Write(text[org:i]) // nolint: errcheck |
|
336 } |
|
337 |
|
338 // escape a character |
|
339 if i >= len(text) { |
|
340 break |
|
341 } |
|
342 |
|
343 w.Write([]byte{'\\', text[i]}) // nolint: errcheck |
|
344 } |
|
345 } |