A few more trivial tweaks.
[rsync.git] / md2man
1 #!/usr/bin/python3
2
3 # This script takes a manpage written in markdown and turns it into an html web
4 # page and a nroff man page.  The input file must have the name of the program
5 # and the section in this format: NAME.NUM.md.  The output files are written
6 # into the current directory named NAME.NUM.html and NAME.NUM.  The input
7 # format has one extra extension: if a numbered list starts at 0, it is turned
8 # into a description list. The dl's dt tag is taken from the contents of the
9 # first tag inside the li, which is usually a p, code, or strong tag.  The
10 # cmarkgfm or commonmark lib is used to transforms the input file into html.
11 # The html.parser is used as a state machine that both tweaks the html and
12 # outputs the nroff data based on the html tags.
13 #
14 # Copyright (C) 2020 Wayne Davison
15 #
16 # This program is freely redistributable.
17
18 import sys, os, re, argparse, subprocess, time
19 from html.parser import HTMLParser
20
21 CONSUMES_TXT = set('h1 h2 p li pre'.split())
22
23 HTML_START = """\
24 <html><head>
25 <title>%s</title>
26 <link href="https://fonts.googleapis.com/css2?family=Roboto&family=Roboto+Mono&display=swap" rel="stylesheet">
27 <style>
28 body {
29   max-width: 50em;
30   margin: auto;
31 }
32 body, b, strong, u {
33   font-family: 'Roboto', sans-serif;
34 }
35 code {
36   font-family: 'Roboto Mono', monospace;
37   font-weight: bold;
38 }
39 pre code {
40   display: block;
41   font-weight: normal;
42 }
43 blockquote pre code {
44   background: #f1f1f1;
45 }
46 dd p:first-of-type {
47   margin-block-start: 0em;
48 }
49 </style>
50 </head><body>
51 """
52
53 HTML_END = """\
54 <div style="float: right"><p><i>%s</i></p></div>
55 </body></html>
56 """
57
58 MAN_START = r"""
59 .TH "%s" "%s" "%s" "%s" "User Commands"
60 """.lstrip()
61
62 MAN_END = """\
63 """
64
65 NORM_FONT = ('\1', r"\fP")
66 BOLD_FONT = ('\2', r"\fB")
67 ULIN_FONT = ('\3', r"\fI")
68
69 md_parser = None
70
71 def main():
72     fi = re.match(r'^(?P<fn>(?P<srcdir>.+/)?(?P<name>(?P<prog>[^/]+)\.(?P<sect>\d+))\.md)$', args.mdfile)
73     if not fi:
74         die('Failed to parse NAME.NUM.md out of input file:', args.mdfile)
75     fi = argparse.Namespace(**fi.groupdict())
76
77     if not fi.srcdir:
78         fi.srcdir = './'
79
80     fi.title = fi.prog + '(' + fi.sect + ') man page'
81     fi.mtime = 0
82
83     if os.path.lexists(fi.srcdir + '.git'):
84         fi.mtime = int(subprocess.check_output('git log -1 --format=%at'.split()))
85
86     env_subs = { 'prefix': os.environ.get('RSYNC_OVERRIDE_PREFIX', None) }
87
88     if args.test:
89         env_subs['VERSION'] = '1.0.0'
90         env_subs['libdir'] = '/usr'
91     else:
92         for fn in 'NEWS.md Makefile'.split():
93             try:
94                 st = os.lstat(fi.srcdir + fn)
95             except:
96                 die('Failed to find', fi.srcdir + fn)
97             if not fi.mtime:
98                 fi.mtime = st.st_mtime
99
100         with open(fi.srcdir + 'Makefile', 'r', encoding='utf-8') as fh:
101             for line in fh:
102                 m = re.match(r'^(\w+)=(.+)', line)
103                 if not m:
104                     continue
105                 var, val = (m[1], m[2])
106                 if var == 'prefix' and env_subs[var] is not None:
107                     continue
108                 while re.search(r'\$\{', val):
109                     val = re.sub(r'\$\{(\w+)\}', lambda m: env_subs[m[1]], val)
110                 env_subs[var] = val
111                 if var == 'VERSION':
112                     break
113
114     with open(fi.fn, 'r', encoding='utf-8') as fh:
115         txt = fh.read()
116
117     txt = re.sub(r'@VERSION@', env_subs['VERSION'], txt)
118     txt = re.sub(r'@LIBDIR@', env_subs['libdir'], txt)
119
120     fi.html_in = md_parser(txt)
121     txt = None
122
123     fi.date = time.strftime('%d %b %Y', time.localtime(fi.mtime))
124     fi.man_headings = (fi.prog, fi.sect, fi.date, fi.prog + ' ' + env_subs['VERSION'])
125
126     HtmlToManPage(fi)
127
128     if args.test:
129         print("The test was successful.")
130         return
131
132     for fn, txt in ((fi.name + '.html', fi.html_out), (fi.name, fi.man_out)):
133         print("Wrote:", fn)
134         with open(fn, 'w', encoding='utf-8') as fh:
135             fh.write(txt)
136
137
138 def html_via_cmarkgfm(txt):
139     return cmarkgfm.markdown_to_html(txt)
140
141
142 def html_via_commonmark(txt):
143     return commonmark.HtmlRenderer().render(commonmark.Parser().parse(txt))
144
145
146 class HtmlToManPage(HTMLParser):
147     def __init__(self, fi):
148         HTMLParser.__init__(self, convert_charrefs=True)
149
150         st = self.state = argparse.Namespace(
151                 list_state = [ ],
152                 p_macro = ".P\n",
153                 at_first_tag_in_li = False,
154                 at_first_tag_in_dd = False,
155                 dt_from = None,
156                 in_pre = False,
157                 in_code = False,
158                 html_out = [ HTML_START % fi.title ],
159                 man_out = [ MAN_START % fi.man_headings ],
160                 txt = '',
161                 )
162
163         self.feed(fi.html_in)
164         fi.html_in = None
165
166         st.html_out.append(HTML_END % fi.date)
167         st.man_out.append(MAN_END)
168
169         fi.html_out = ''.join(st.html_out)
170         st.html_out = None
171
172         fi.man_out = ''.join(st.man_out)
173         st.man_out = None
174
175
176     def handle_starttag(self, tag, attrs_list):
177         st = self.state
178         if args.debug:
179             self.output_debug('START', (tag, attrs_list))
180         if st.at_first_tag_in_li:
181             if st.list_state[-1] == 'dl':
182                 st.dt_from = tag
183                 if tag == 'p':
184                     tag = 'dt'
185                 else:
186                     st.html_out.append('<dt>')
187             elif tag == 'p':
188                 st.at_first_tag_in_dd = True # Kluge to suppress a .P at the start of an li.
189             st.at_first_tag_in_li = False
190         if tag == 'p':
191             if not st.at_first_tag_in_dd:
192                 st.man_out.append(st.p_macro)
193         elif tag == 'li':
194             st.at_first_tag_in_li = True
195             lstate = st.list_state[-1]
196             if lstate == 'dl':
197                 return
198             if lstate == 'o':
199                 st.man_out.append(".IP o\n")
200             else:
201                 st.man_out.append(".IP " + str(lstate) + ".\n")
202                 st.list_state[-1] += 1
203         elif tag == 'blockquote':
204             st.man_out.append(".RS 4\n")
205         elif tag == 'pre':
206             st.in_pre = True
207             st.man_out.append(st.p_macro + ".nf\n")
208         elif tag == 'code' and not st.in_pre:
209             st.in_code = True
210             st.txt += BOLD_FONT[0]
211         elif tag == 'strong' or tag == 'b':
212             st.txt += BOLD_FONT[0]
213         elif tag == 'em' or  tag == 'i':
214             tag = 'u' # Change it into underline to be more like the man page
215             st.txt += ULIN_FONT[0]
216         elif tag == 'ol':
217             start = 1
218             for var, val in attrs_list:
219                 if var == 'start':
220                     start = int(val) # We only support integers.
221                     break
222             if st.list_state:
223                 st.man_out.append(".RS\n")
224             if start == 0:
225                 tag = 'dl'
226                 attrs_list = [ ]
227                 st.list_state.append('dl')
228             else:
229                 st.list_state.append(start)
230             st.man_out.append(st.p_macro)
231             st.p_macro = ".IP\n"
232         elif tag == 'ul':
233             st.man_out.append(st.p_macro)
234             if st.list_state:
235                 st.man_out.append(".RS\n")
236                 st.p_macro = ".IP\n"
237             st.list_state.append('o')
238         st.html_out.append('<' + tag + ''.join(' ' + var + '="' + htmlify(val) + '"' for var, val in attrs_list) + '>')
239         st.at_first_tag_in_dd = False
240
241
242     def handle_endtag(self, tag):
243         st = self.state
244         if args.debug:
245             self.output_debug('END', (tag,))
246         if tag in CONSUMES_TXT or st.dt_from == tag:
247             txt = st.txt.strip()
248             st.txt = ''
249         else:
250             txt = None
251         add_to_txt = None
252         if tag == 'h1':
253             st.man_out.append(st.p_macro + '.SH "' + manify(txt) + '"\n')
254         elif tag == 'h2':
255             st.man_out.append(st.p_macro + '.SS "' + manify(txt) + '"\n')
256         elif tag == 'p':
257             if st.dt_from == 'p':
258                 tag = 'dt'
259                 st.man_out.append('.IP "' + manify(txt) + '"\n')
260                 st.dt_from = None
261             elif txt != '':
262                 st.man_out.append(manify(txt) + "\n")
263         elif tag == 'li':
264             if st.list_state[-1] == 'dl':
265                 if st.at_first_tag_in_li:
266                     die("Invalid 0. -> td translation")
267                 tag = 'dd'
268             if txt != '':
269                 st.man_out.append(manify(txt) + "\n")
270             st.at_first_tag_in_li = False
271         elif tag == 'blockquote':
272             st.man_out.append(".RE\n")
273         elif tag == 'pre':
274             st.in_pre = False
275             st.man_out.append(manify(txt) + "\n.fi\n")
276         elif (tag == 'code' and not st.in_pre):
277             st.in_code = False
278             add_to_txt = NORM_FONT[0]
279         elif tag == 'strong' or tag == 'b':
280             add_to_txt = NORM_FONT[0]
281         elif tag == 'em' or  tag == 'i':
282             tag = 'u' # Change it into underline to be more like the man page
283             add_to_txt = NORM_FONT[0]
284         elif tag == 'ol' or tag == 'ul':
285             if st.list_state.pop() == 'dl':
286                 tag = 'dl'
287             if st.list_state:
288                 st.man_out.append(".RE\n")
289             else:
290                 st.p_macro = ".P\n"
291             st.at_first_tag_in_dd = False
292         st.html_out.append('</' + tag + '>')
293         if add_to_txt:
294             if txt is None:
295                 st.txt += add_to_txt
296             else:
297                 txt += add_to_txt
298         if st.dt_from == tag:
299             st.man_out.append('.IP "' + manify(txt) + '"\n')
300             st.html_out.append('</dt><dd>')
301             st.at_first_tag_in_dd = True
302             st.dt_from = None
303         elif tag == 'dt':
304             st.html_out.append('<dd>')
305             st.at_first_tag_in_dd = True
306
307
308     def handle_data(self, data):
309         st = self.state
310         if args.debug:
311             self.output_debug('DATA', (data,))
312         if st.in_code:
313             data = re.sub(r'\s', '\xa0', data) # nbsp in non-pre code
314         data = re.sub(r'\s--\s', '\xa0-- ', data)
315         st.html_out.append(htmlify(data))
316         st.txt += data
317
318
319     def output_debug(self, event, extra):
320         import pprint
321         st = self.state
322         if args.debug < 2:
323             st = argparse.Namespace(**vars(st))
324             if len(st.html_out) > 2:
325                 st.html_out = ['...'] + st.html_out[-2:]
326             if len(st.man_out) > 2:
327                 st.man_out = ['...'] + st.man_out[-2:]
328         print(event, extra)
329         pprint.PrettyPrinter(indent=2).pprint(vars(st))
330
331
332 def manify(txt):
333     return re.sub(r"^(['.])", r'\&\1', txt.replace('\\', '\\\\')
334             .replace("\xa0", r'\ ') # non-breaking space
335             .replace('--', r'\-\-') # non-breaking double dash
336             .replace(NORM_FONT[0], NORM_FONT[1])
337             .replace(BOLD_FONT[0], BOLD_FONT[1])
338             .replace(ULIN_FONT[0], ULIN_FONT[1]), flags=re.M)
339
340
341 def htmlify(txt):
342     return re.sub(r'(\W)-', r'\1&#8209;',
343             txt.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
344             .replace('--', '&#8209;&#8209;').replace("\xa0-", '&nbsp;&#8209;').replace("\xa0", '&nbsp;'))
345
346
347 def warn(*msg):
348     print(*msg, file=sys.stderr)
349
350
351 def die(*msg):
352     warn(*msg)
353     sys.exit(1)
354
355
356 if __name__ == '__main__':
357     parser = argparse.ArgumentParser(description='Transform a NAME.NUM.md markdown file into a NAME.NUM.html web page & a NAME.NUM man page.', add_help=False)
358     parser.add_argument('--test', action='store_true', help='Test if we can parse the input w/o updating any files.')
359     parser.add_argument('--debug', '-D', action='count', default=0, help='Output copious info on the html parsing. Repeat for even more.')
360     parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
361     parser.add_argument('mdfile', help="The NAME.NUM.md file to parse.")
362     args = parser.parse_args()
363
364     try:
365         import cmarkgfm
366         md_parser = html_via_cmarkgfm
367     except:
368         try:
369             import commonmark
370             md_parser = html_via_commonmark
371         except:
372             die("Failed to find cmarkgfm or commonmark for python3.")
373
374     main()