A few more man page format tweaks.
[rsync.git] / md-convert
1 #!/usr/bin/env python3
2
3 # This script transforms markdown files into html and (optionally) nroff. The
4 # output files are written into the current directory named for the input file
5 # without the .md suffix and either the .html suffix or no suffix.
6 #
7 # If the input .md file has a section number at the end of the name (e.g.,
8 # rsync.1.md) a nroff file is also output (PROJ.NUM.md -> PROJ.NUM).
9 #
10 # The markdown input format has one extra extension: if a numbered list starts
11 # at 0, it is turned into a description list. The dl's dt tag is taken from the
12 # contents of the first tag inside the li, which is usually a p, code, or
13 # strong tag.
14 #
15 # The cmarkgfm or commonmark lib is used to transforms the input file into
16 # html.  Then, the html.parser is used as a state machine that lets us tweak
17 # the html and (optionally) output nroff data based on the html tags.
18 #
19 # If the string @USE_GFM_PARSER@ exists in the file, the string is removed and
20 # a github-flavored-markup parser is used to parse the file.
21 #
22 # The man-page .md files also get the vars @VERSION@, @BINDIR@, and @LIBDIR@
23 # substituted.  Some of these values depend on the Makefile $(prefix) (see the
24 # generated Makefile).  If the maintainer wants to build files for /usr/local
25 # while creating release-ready man-page files for /usr, use the environment to
26 # set RSYNC_OVERRIDE_PREFIX=/usr.
27
28 # Copyright (C) 2020 - 2021 Wayne Davison
29 #
30 # This program is freely redistributable.
31
32 import os, sys, re, argparse, subprocess, time
33 from html.parser import HTMLParser
34
35 CONSUMES_TXT = set('h1 h2 h3 p li pre'.split())
36
37 HTML_START = """\
38 <html><head>
39 <title>%TITLE%</title>
40 <meta charset="UTF-8"/>
41 <link href="https://fonts.googleapis.com/css2?family=Roboto&family=Roboto+Mono&display=swap" rel="stylesheet">
42 <style>
43 body {
44   max-width: 50em;
45   margin: auto;
46 }
47 body, b, strong, u {
48   font-family: 'Roboto', sans-serif;
49 }
50 a.tgt { font-face: symbol; font-weight: 400; font-size: 70%; visibility: hidden; text-decoration: none; color: #ddd; padding: 0 4px; border: 0; vertical-align: top; }
51 a.tgt:after { content: '🔗'; }
52 a.tgt:hover { color: #444; background-color: #eaeaea; }
53 h1:hover > a.tgt, h2:hover > a.tgt, h3:hover > a.tgt, dt:hover > a.tgt { visibility: visible; }
54 code {
55   font-family: 'Roboto Mono', monospace;
56   font-weight: bold;
57   white-space: pre;
58 }
59 pre code {
60   display: block;
61   font-weight: normal;
62 }
63 blockquote pre code {
64   background: #f1f1f1;
65 }
66 dd p:first-of-type {
67   margin-block-start: 0em;
68 }
69 </style>
70 </head><body>
71 """
72
73 TABLE_STYLE = """\
74 table {
75   border-color: grey;
76   border-spacing: 0;
77 }
78 tr {
79   border-top: 1px solid grey;
80 }
81 tr:nth-child(2n) {
82   background-color: #f6f8fa;
83 }
84 th, td {
85   border: 1px solid #dfe2e5;
86   text-align: center;
87   padding-left: 1em;
88   padding-right: 1em;
89 }
90 """
91
92 MAN_HTML_END = """\
93 <div style="float: right"><p><i>%s</i></p></div>
94 """
95
96 HTML_END = """\
97 </body></html>
98 """
99
100 MAN_START = r"""
101 .TH "%s" "%s" "%s" "%s" "User Commands"
102 .\" prefix=%s
103 """.lstrip()
104
105 MAN_END = """\
106 """
107
108 NORM_FONT = ('\1', r"\fP")
109 BOLD_FONT = ('\2', r"\fB")
110 UNDR_FONT = ('\3', r"\fI")
111 NBR_DASH = ('\4', r"\-")
112 NBR_SPACE = ('\xa0', r"\ ")
113
114 md_parser = None
115 env_subs = { }
116
117 def main():
118     for mdfn in args.mdfiles:
119         parse_md_file(mdfn)
120
121     if args.test:
122         print("The test was successful.")
123
124
125 def parse_md_file(mdfn):
126     fi = re.match(r'^(?P<fn>(?P<srcdir>.+/)?(?P<name>(?P<prog>[^/]+?)(\.(?P<sect>\d+))?)\.md)$', mdfn)
127     if not fi:
128         die('Failed to parse a md input file name:', mdfn)
129     fi = argparse.Namespace(**fi.groupdict())
130     fi.want_manpage = not not fi.sect
131     if fi.want_manpage:
132         fi.title = fi.prog + '(' + fi.sect + ') man page'
133     else:
134         fi.title = fi.prog
135
136     if fi.want_manpage:
137         if not env_subs:
138             find_man_substitutions()
139         prog_ver = 'rsync ' + env_subs['VERSION']
140         if fi.prog != 'rsync':
141             prog_ver = fi.prog + ' from ' + prog_ver
142         fi.man_headings = (fi.prog, fi.sect, env_subs['date'], prog_ver, env_subs['prefix'])
143
144     with open(mdfn, 'r', encoding='utf-8') as fh:
145         txt = fh.read()
146
147     use_gfm_parser = '@USE_GFM_PARSER@' in txt
148     if use_gfm_parser:
149         txt = txt.replace('@USE_GFM_PARSER@', '')
150
151     if fi.want_manpage:
152         txt = (txt.replace('@VERSION@', env_subs['VERSION'])
153                   .replace('@BINDIR@', env_subs['bindir'])
154                   .replace('@LIBDIR@', env_subs['libdir']))
155
156     if use_gfm_parser:
157         if not gfm_parser:
158             die('Input file requires cmarkgfm parser:', mdfn)
159         fi.html_in = gfm_parser(txt)
160     else:
161         fi.html_in = md_parser(txt)
162     txt = None
163
164     TransformHtml(fi)
165
166     if args.test:
167         return
168
169     output_list = [ (fi.name + '.html', fi.html_out) ]
170     if fi.want_manpage:
171         output_list += [ (fi.name, fi.man_out) ]
172     for fn, txt in output_list:
173         if os.path.lexists(fn):
174             os.unlink(fn)
175         print("Wrote:", fn)
176         with open(fn, 'w', encoding='utf-8') as fh:
177             fh.write(txt)
178
179
180 def find_man_substitutions():
181     srcdir = os.path.dirname(sys.argv[0]) + '/'
182     mtime = 0
183
184     git_dir = srcdir + '.git'
185     if os.path.lexists(git_dir):
186         mtime = int(subprocess.check_output(['git', '--git-dir', git_dir, 'log', '-1', '--format=%at']))
187
188     # Allow "prefix" to be overridden via the environment:
189     env_subs['prefix'] = os.environ.get('RSYNC_OVERRIDE_PREFIX', None)
190
191     if args.test:
192         env_subs['VERSION'] = '1.0.0'
193         env_subs['bindir'] = '/usr/bin'
194         env_subs['libdir'] = '/usr/lib/rsync'
195     else:
196         for fn in (srcdir + 'version.h', 'Makefile'):
197             try:
198                 st = os.lstat(fn)
199             except OSError:
200                 die('Failed to find', srcdir + fn)
201             if not mtime:
202                 mtime = st.st_mtime
203
204         with open(srcdir + 'version.h', 'r', encoding='utf-8') as fh:
205             txt = fh.read()
206         m = re.search(r'"(.+?)"', txt)
207         env_subs['VERSION'] = m.group(1)
208
209         with open('Makefile', 'r', encoding='utf-8') as fh:
210             for line in fh:
211                 m = re.match(r'^(\w+)=(.+)', line)
212                 if not m:
213                     continue
214                 var, val = (m.group(1), m.group(2))
215                 if var == 'prefix' and env_subs[var] is not None:
216                     continue
217                 while re.search(r'\$\{', val):
218                     val = re.sub(r'\$\{(\w+)\}', lambda m: env_subs[m.group(1)], val)
219                 env_subs[var] = val
220                 if var == 'srcdir':
221                     break
222
223     env_subs['date'] = time.strftime('%d %b %Y', time.localtime(mtime))
224
225
226 def html_via_commonmark(txt):
227     return commonmark.HtmlRenderer().render(commonmark.Parser().parse(txt))
228
229
230 class TransformHtml(HTMLParser):
231     def __init__(self, fi):
232         HTMLParser.__init__(self, convert_charrefs=True)
233
234         st = self.state = argparse.Namespace(
235                 list_state = [ ],
236                 p_macro = ".P\n",
237                 at_first_tag_in_li = False,
238                 at_first_tag_in_dd = False,
239                 dt_from = None,
240                 in_pre = False,
241                 in_code = False,
242                 html_out = [ HTML_START.replace('%TITLE%', fi.title) ],
243                 man_out = [ ],
244                 txt = '',
245                 want_manpage = fi.want_manpage,
246                 )
247
248         if st.want_manpage:
249             st.man_out.append(MAN_START % fi.man_headings)
250
251         if '</table>' in fi.html_in:
252             st.html_out[0] = st.html_out[0].replace('</style>', TABLE_STYLE + '</style>')
253
254         self.feed(fi.html_in)
255         fi.html_in = None
256
257         if st.want_manpage:
258             st.html_out.append(MAN_HTML_END % env_subs['date'])
259         st.html_out.append(HTML_END)
260         st.man_out.append(MAN_END)
261
262         fi.html_out = ''.join(st.html_out)
263         st.html_out = None
264
265         fi.man_out = ''.join(st.man_out)
266         st.man_out = None
267
268
269     def handle_starttag(self, tag, attrs_list):
270         st = self.state
271         if args.debug:
272             self.output_debug('START', (tag, attrs_list))
273         if st.at_first_tag_in_li:
274             if st.list_state[-1] == 'dl':
275                 st.dt_from = tag
276                 if tag == 'p':
277                     tag = 'dt'
278                 else:
279                     st.html_out.append('<dt>')
280             elif tag == 'p':
281                 st.at_first_tag_in_dd = True # Kluge to suppress a .P at the start of an li.
282             st.at_first_tag_in_li = False
283         if tag == 'p':
284             if not st.at_first_tag_in_dd:
285                 st.man_out.append(st.p_macro)
286         elif tag == 'li':
287             st.at_first_tag_in_li = True
288             lstate = st.list_state[-1]
289             if lstate == 'dl':
290                 return
291             if lstate == 'o':
292                 st.man_out.append(".IP o\n")
293             else:
294                 st.man_out.append(".IP " + str(lstate) + ".\n")
295                 st.list_state[-1] += 1
296         elif tag == 'blockquote':
297             st.man_out.append(".RS 4\n")
298         elif tag == 'pre':
299             st.in_pre = True
300             st.man_out.append(st.p_macro + ".nf\n")
301         elif tag == 'code' and not st.in_pre:
302             st.in_code = True
303             st.txt += BOLD_FONT[0]
304         elif tag == 'strong' or tag == 'b':
305             st.txt += BOLD_FONT[0]
306         elif tag == 'em' or  tag == 'i':
307             if st.want_manpage:
308                 tag = 'u' # Change it into underline to be more like the man page
309                 st.txt += UNDR_FONT[0]
310         elif tag == 'ol':
311             start = 1
312             for var, val in attrs_list:
313                 if var == 'start':
314                     start = int(val) # We only support integers.
315                     break
316             if st.list_state:
317                 st.man_out.append(".RS\n")
318             if start == 0:
319                 tag = 'dl'
320                 attrs_list = [ ]
321                 st.list_state.append('dl')
322             else:
323                 st.list_state.append(start)
324             st.man_out.append(st.p_macro)
325             st.p_macro = ".IP\n"
326         elif tag == 'ul':
327             st.man_out.append(st.p_macro)
328             if st.list_state:
329                 st.man_out.append(".RS\n")
330                 st.p_macro = ".IP\n"
331             st.list_state.append('o')
332         elif tag == 'hr':
333             st.man_out.append(".l\n")
334             st.html_out.append("<hr />")
335             return
336         st.html_out.append('<' + tag + ''.join(' ' + var + '="' + htmlify(val) + '"' for var, val in attrs_list) + '>')
337         st.at_first_tag_in_dd = False
338
339
340     def add_target(self, txt):
341         st = self.state
342         txt = re.sub(r'[%s](.+?)[=%s].*' % (BOLD_FONT[0], NORM_FONT[0]), r'\1', txt.strip())
343         txt = re.sub(r'[%s]' % NBR_DASH[0], '-', txt)
344         txt = re.sub(r'[\1-\7]+', '', txt)
345         txt = re.sub(r'[^-A-Za-z0-9._]', '_', txt)
346         if txt.startswith('-'):
347             txt = 'opt' + txt
348         else:
349             txt = re.sub(r'^([^A-Za-z])', r't\1', txt)
350         if txt:
351             st.html_out.append('<a id="' + txt + '" href="#' + txt + '" class="tgt"></a>')
352
353
354     def handle_endtag(self, tag):
355         st = self.state
356         if args.debug:
357             self.output_debug('END', (tag,))
358         if tag in CONSUMES_TXT or st.dt_from == tag:
359             txt = st.txt.strip()
360             st.txt = ''
361         else:
362             txt = None
363         add_to_txt = None
364         if tag == 'h1' or tag == 'h2':
365             st.man_out.append(st.p_macro + '.SH "' + manify(txt) + '"\n')
366             self.add_target(txt)
367         elif tag == 'h3':
368             st.man_out.append(st.p_macro + '.SS "' + manify(txt) + '"\n')
369             self.add_target(txt)
370         elif tag == 'p':
371             if st.dt_from == 'p':
372                 tag = 'dt'
373                 st.man_out.append('.IP "' + manify(txt) + '"\n')
374                 if txt.startswith(BOLD_FONT[0]):
375                     self.add_target(txt)
376                 st.dt_from = None
377             elif txt != '':
378                 st.man_out.append(manify(txt) + "\n")
379         elif tag == 'li':
380             if st.list_state[-1] == 'dl':
381                 if st.at_first_tag_in_li:
382                     die("Invalid 0. -> td translation")
383                 tag = 'dd'
384             if txt != '':
385                 st.man_out.append(manify(txt) + "\n")
386             st.at_first_tag_in_li = False
387         elif tag == 'blockquote':
388             st.man_out.append(".RE\n")
389         elif tag == 'pre':
390             st.in_pre = False
391             st.man_out.append(manify(txt) + "\n.fi\n")
392         elif (tag == 'code' and not st.in_pre):
393             st.in_code = False
394             add_to_txt = NORM_FONT[0]
395         elif tag == 'strong' or tag == 'b':
396             add_to_txt = NORM_FONT[0]
397         elif tag == 'em' or  tag == 'i':
398             if st.want_manpage:
399                 tag = 'u' # Change it into underline to be more like the man page
400                 add_to_txt = NORM_FONT[0]
401         elif tag == 'ol' or tag == 'ul':
402             if st.list_state.pop() == 'dl':
403                 tag = 'dl'
404             if st.list_state:
405                 st.man_out.append(".RE\n")
406             else:
407                 st.p_macro = ".P\n"
408             st.at_first_tag_in_dd = False
409         elif tag == 'hr':
410             return
411         st.html_out.append('</' + tag + '>')
412         if add_to_txt:
413             if txt is None:
414                 st.txt += add_to_txt
415             else:
416                 txt += add_to_txt
417         if st.dt_from == tag:
418             st.man_out.append('.IP "' + manify(txt) + '"\n')
419             st.html_out.append('</dt><dd>')
420             st.at_first_tag_in_dd = True
421             st.dt_from = None
422         elif tag == 'dt':
423             st.html_out.append('<dd>')
424             st.at_first_tag_in_dd = True
425
426
427     def handle_data(self, txt):
428         st = self.state
429         if args.debug:
430             self.output_debug('DATA', (txt,))
431         if st.in_pre:
432             html = htmlify(txt)
433         else:
434             txt = re.sub(r'\s--(\s)', NBR_SPACE[0] + r'--\1', txt).replace('--', NBR_DASH[0]*2)
435             txt = re.sub(r'(^|\W)-', r'\1' + NBR_DASH[0], txt)
436             html = htmlify(txt)
437             if st.in_code:
438                 txt = re.sub(r'\s', NBR_SPACE[0], txt)
439                 html = html.replace(NBR_DASH[0], '-').replace(NBR_SPACE[0], ' ') # <code> is non-breaking in CSS
440         st.html_out.append(html.replace(NBR_SPACE[0], '&nbsp;').replace(NBR_DASH[0], '-&#8288;'))
441         st.txt += txt
442
443
444     def output_debug(self, event, extra):
445         import pprint
446         st = self.state
447         if args.debug < 2:
448             st = argparse.Namespace(**vars(st))
449             if len(st.html_out) > 2:
450                 st.html_out = ['...'] + st.html_out[-2:]
451             if len(st.man_out) > 2:
452                 st.man_out = ['...'] + st.man_out[-2:]
453         print(event, extra)
454         pprint.PrettyPrinter(indent=2).pprint(vars(st))
455
456
457 def manify(txt):
458     return re.sub(r"^(['.])", r'\&\1', txt.replace('\\', '\\\\')
459             .replace(NBR_SPACE[0], NBR_SPACE[1])
460             .replace(NBR_DASH[0], NBR_DASH[1])
461             .replace(NORM_FONT[0], NORM_FONT[1])
462             .replace(BOLD_FONT[0], BOLD_FONT[1])
463             .replace(UNDR_FONT[0], UNDR_FONT[1]), flags=re.M)
464
465
466 def htmlify(txt):
467     return txt.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
468
469
470 def warn(*msg):
471     print(*msg, file=sys.stderr)
472
473
474 def die(*msg):
475     warn(*msg)
476     sys.exit(1)
477
478
479 if __name__ == '__main__':
480     parser = argparse.ArgumentParser(description="Output html and (optionally) nroff for markdown pages.", add_help=False)
481     parser.add_argument('--test', action='store_true', help="Just test the parsing without outputting any files.")
482     parser.add_argument('--debug', '-D', action='count', default=0, help='Output copious info on the html parsing. Repeat for even more.')
483     parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
484     parser.add_argument("mdfiles", nargs='+', help="The source .md files to convert.")
485     args = parser.parse_args()
486
487     try:
488         import cmarkgfm
489         md_parser = cmarkgfm.markdown_to_html
490         gfm_parser = cmarkgfm.github_flavored_markdown_to_html
491     except:
492         try:
493             import commonmark
494             md_parser = html_via_commonmark
495         except:
496             die("Failed to find cmarkgfm or commonmark for python3.")
497         gfm_parser = None
498
499     main()