Man page improvments, including html cross-links.
[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 warning_count = 0
118
119 def main():
120     for mdfn in args.mdfiles:
121         parse_md_file(mdfn)
122
123     if args.test:
124         print("The test was successful.")
125
126
127 def parse_md_file(mdfn):
128     fi = re.match(r'^(?P<fn>(?P<srcdir>.+/)?(?P<name>(?P<prog>[^/]+?)(\.(?P<sect>\d+))?)\.md)$', mdfn)
129     if not fi:
130         die('Failed to parse a md input file name:', mdfn)
131     fi = argparse.Namespace(**fi.groupdict())
132     fi.want_manpage = not not fi.sect
133     if fi.want_manpage:
134         fi.title = fi.prog + '(' + fi.sect + ') man page'
135     else:
136         fi.title = fi.prog
137
138     if fi.want_manpage:
139         if not env_subs:
140             find_man_substitutions()
141         prog_ver = 'rsync ' + env_subs['VERSION']
142         if fi.prog != 'rsync':
143             prog_ver = fi.prog + ' from ' + prog_ver
144         fi.man_headings = (fi.prog, fi.sect, env_subs['date'], prog_ver, env_subs['prefix'])
145
146     with open(mdfn, 'r', encoding='utf-8') as fh:
147         txt = fh.read()
148
149     use_gfm_parser = '@USE_GFM_PARSER@' in txt
150     if use_gfm_parser:
151         txt = txt.replace('@USE_GFM_PARSER@', '')
152
153     if fi.want_manpage:
154         txt = (txt.replace('@VERSION@', env_subs['VERSION'])
155                   .replace('@BINDIR@', env_subs['bindir'])
156                   .replace('@LIBDIR@', env_subs['libdir']))
157
158     if use_gfm_parser:
159         if not gfm_parser:
160             die('Input file requires cmarkgfm parser:', mdfn)
161         fi.html_in = gfm_parser(txt)
162     else:
163         fi.html_in = md_parser(txt)
164     txt = None
165
166     TransformHtml(fi)
167
168     if args.test:
169         return
170
171     output_list = [ (fi.name + '.html', fi.html_out) ]
172     if fi.want_manpage:
173         output_list += [ (fi.name, fi.man_out) ]
174     for fn, txt in output_list:
175         if os.path.lexists(fn):
176             os.unlink(fn)
177         print("Wrote:", fn)
178         with open(fn, 'w', encoding='utf-8') as fh:
179             fh.write(txt)
180
181
182 def find_man_substitutions():
183     srcdir = os.path.dirname(sys.argv[0]) + '/'
184     mtime = 0
185
186     git_dir = srcdir + '.git'
187     if os.path.lexists(git_dir):
188         mtime = int(subprocess.check_output(['git', '--git-dir', git_dir, 'log', '-1', '--format=%at']))
189
190     # Allow "prefix" to be overridden via the environment:
191     env_subs['prefix'] = os.environ.get('RSYNC_OVERRIDE_PREFIX', None)
192
193     if args.test:
194         env_subs['VERSION'] = '1.0.0'
195         env_subs['bindir'] = '/usr/bin'
196         env_subs['libdir'] = '/usr/lib/rsync'
197     else:
198         for fn in (srcdir + 'version.h', 'Makefile'):
199             try:
200                 st = os.lstat(fn)
201             except OSError:
202                 die('Failed to find', srcdir + fn)
203             if not mtime:
204                 mtime = st.st_mtime
205
206         with open(srcdir + 'version.h', 'r', encoding='utf-8') as fh:
207             txt = fh.read()
208         m = re.search(r'"(.+?)"', txt)
209         env_subs['VERSION'] = m.group(1)
210
211         with open('Makefile', 'r', encoding='utf-8') as fh:
212             for line in fh:
213                 m = re.match(r'^(\w+)=(.+)', line)
214                 if not m:
215                     continue
216                 var, val = (m.group(1), m.group(2))
217                 if var == 'prefix' and env_subs[var] is not None:
218                     continue
219                 while re.search(r'\$\{', val):
220                     val = re.sub(r'\$\{(\w+)\}', lambda m: env_subs[m.group(1)], val)
221                 env_subs[var] = val
222                 if var == 'srcdir':
223                     break
224
225     env_subs['date'] = time.strftime('%d %b %Y', time.localtime(mtime))
226
227
228 def html_via_commonmark(txt):
229     return commonmark.HtmlRenderer().render(commonmark.Parser().parse(txt))
230
231
232 class TransformHtml(HTMLParser):
233     def __init__(self, fi):
234         HTMLParser.__init__(self, convert_charrefs=True)
235
236         self.fn = fi.fn
237
238         st = self.state = argparse.Namespace(
239                 list_state = [ ],
240                 p_macro = ".P\n",
241                 at_first_tag_in_li = False,
242                 at_first_tag_in_dd = False,
243                 dt_from = None,
244                 in_pre = False,
245                 in_code = False,
246                 html_out = [ HTML_START.replace('%TITLE%', fi.title) ],
247                 man_out = [ ],
248                 txt = '',
249                 want_manpage = fi.want_manpage,
250                 created_hashtags = set(),
251                 derived_hashtags = set(),
252                 referenced_hashtags = set(),
253                 bad_hashtags = set(),
254                 prior_target = None,
255                 opt_prefix = 'opt',
256                 a_txt_start = None,
257                 )
258
259         if st.want_manpage:
260             st.man_out.append(MAN_START % fi.man_headings)
261
262         if '</table>' in fi.html_in:
263             st.html_out[0] = st.html_out[0].replace('</style>', TABLE_STYLE + '</style>')
264
265         self.feed(fi.html_in)
266         fi.html_in = None
267
268         if st.want_manpage:
269             st.html_out.append(MAN_HTML_END % env_subs['date'])
270         st.html_out.append(HTML_END)
271         st.man_out.append(MAN_END)
272
273         fi.html_out = ''.join(st.html_out)
274         st.html_out = None
275
276         fi.man_out = ''.join(st.man_out)
277         st.man_out = None
278
279         for href, txt in st.derived_hashtags:
280             derived = txt2target(txt, href[1:])
281             if derived not in st.created_hashtags:
282                 txt = re.sub(r'[\1-\7]+', '', txt.replace(NBR_DASH[0], '-').replace(NBR_SPACE[0], ' '))
283                 warn('Unknown derived hashtag link in', self.fn, 'based on:', (href, txt))
284
285         for bad in st.bad_hashtags:
286             if bad in st.created_hashtags:
287                 warn('Missing "#" in hashtag link in', self.fn + ':', bad)
288             else:
289                 warn('Unknown non-hashtag link in', self.fn + ':', bad)
290
291         for bad in st.referenced_hashtags - st.created_hashtags:
292             warn('Unknown hashtag link in', self.fn + ':', '#' + bad)
293
294     def handle_starttag(self, tag, attrs_list):
295         st = self.state
296         if args.debug:
297             self.output_debug('START', (tag, attrs_list))
298         if st.at_first_tag_in_li:
299             if st.list_state[-1] == 'dl':
300                 st.dt_from = tag
301                 if tag == 'p':
302                     tag = 'dt'
303                 else:
304                     st.html_out.append('<dt>')
305             elif tag == 'p':
306                 st.at_first_tag_in_dd = True # Kluge to suppress a .P at the start of an li.
307             st.at_first_tag_in_li = False
308         if tag == 'p':
309             if not st.at_first_tag_in_dd:
310                 st.man_out.append(st.p_macro)
311         elif tag == 'li':
312             st.at_first_tag_in_li = True
313             lstate = st.list_state[-1]
314             if lstate == 'dl':
315                 return
316             if lstate == 'o':
317                 st.man_out.append(".IP o\n")
318             else:
319                 st.man_out.append(".IP " + str(lstate) + ".\n")
320                 st.list_state[-1] += 1
321         elif tag == 'blockquote':
322             st.man_out.append(".RS 4\n")
323         elif tag == 'pre':
324             st.in_pre = True
325             st.man_out.append(st.p_macro + ".nf\n")
326         elif tag == 'code' and not st.in_pre:
327             st.in_code = True
328             st.txt += BOLD_FONT[0]
329         elif tag == 'strong' or tag == 'b':
330             st.txt += BOLD_FONT[0]
331         elif tag == 'em' or  tag == 'i':
332             if st.want_manpage:
333                 tag = 'u' # Change it into underline to be more like the man page
334                 st.txt += UNDR_FONT[0]
335         elif tag == 'ol':
336             start = 1
337             for var, val in attrs_list:
338                 if var == 'start':
339                     start = int(val) # We only support integers.
340                     break
341             if st.list_state:
342                 st.man_out.append(".RS\n")
343             if start == 0:
344                 tag = 'dl'
345                 attrs_list = [ ]
346                 st.list_state.append('dl')
347             else:
348                 st.list_state.append(start)
349             st.man_out.append(st.p_macro)
350             st.p_macro = ".IP\n"
351         elif tag == 'ul':
352             st.man_out.append(st.p_macro)
353             if st.list_state:
354                 st.man_out.append(".RS\n")
355                 st.p_macro = ".IP\n"
356             st.list_state.append('o')
357         elif tag == 'hr':
358             st.man_out.append(".l\n")
359             st.html_out.append("<hr />")
360             return
361         elif tag == 'a':
362             st.a_href = None
363             for var, val in attrs_list:
364                 if var == 'href':
365                     if val in ('#', '#opt', '#daemon-opt'):
366                         st.a_href = val
367                     elif val.startswith('#'):
368                         st.referenced_hashtags.add(val[1:])
369                         if val[1:] == st.prior_target:
370                             warn('Found link to the current section in', self.fn + ':', val)
371                     elif not val.startswith(('https://', 'http://', 'mailto:', 'ftp:', './')):
372                         st.bad_hashtags.add(val)
373             st.a_txt_start = len(st.txt)
374         st.html_out.append('<' + tag + ''.join(' ' + var + '="' + htmlify(val) + '"' for var, val in attrs_list) + '>')
375         st.at_first_tag_in_dd = False
376
377
378     def handle_endtag(self, tag):
379         st = self.state
380         if args.debug:
381             self.output_debug('END', (tag,))
382         if tag in CONSUMES_TXT or st.dt_from == tag:
383             txt = st.txt.strip()
384             st.txt = ''
385         else:
386             txt = None
387         add_to_txt = None
388         if tag == 'h1' or tag == 'h2':
389             st.man_out.append(st.p_macro + '.SH "' + manify(txt) + '"\n')
390             self.add_target(txt)
391             st.opt_prefix = 'daemon-opt' if txt == 'DAEMON OPTIONS' else 'opt'
392         elif tag == 'h3':
393             st.man_out.append(st.p_macro + '.SS "' + manify(txt) + '"\n')
394             self.add_target(txt)
395         elif tag == 'p':
396             if st.dt_from == 'p':
397                 tag = 'dt'
398                 st.man_out.append('.IP "' + manify(txt) + '"\n')
399                 if txt.startswith(BOLD_FONT[0]):
400                     self.add_target(txt)
401                 st.dt_from = None
402             elif txt != '':
403                 st.man_out.append(manify(txt) + "\n")
404         elif tag == 'li':
405             if st.list_state[-1] == 'dl':
406                 if st.at_first_tag_in_li:
407                     die("Invalid 0. -> td translation")
408                 tag = 'dd'
409             if txt != '':
410                 st.man_out.append(manify(txt) + "\n")
411             st.at_first_tag_in_li = False
412         elif tag == 'blockquote':
413             st.man_out.append(".RE\n")
414         elif tag == 'pre':
415             st.in_pre = False
416             st.man_out.append(manify(txt) + "\n.fi\n")
417         elif (tag == 'code' and not st.in_pre):
418             st.in_code = False
419             add_to_txt = NORM_FONT[0]
420         elif tag == 'strong' or tag == 'b':
421             add_to_txt = NORM_FONT[0]
422         elif tag == 'em' or  tag == 'i':
423             if st.want_manpage:
424                 tag = 'u' # Change it into underline to be more like the man page
425                 add_to_txt = NORM_FONT[0]
426         elif tag == 'ol' or tag == 'ul':
427             if st.list_state.pop() == 'dl':
428                 tag = 'dl'
429             if st.list_state:
430                 st.man_out.append(".RE\n")
431             else:
432                 st.p_macro = ".P\n"
433             st.at_first_tag_in_dd = False
434         elif tag == 'hr':
435             return
436         elif tag == 'a':
437             if st.a_href:
438                 atxt = st.txt[st.a_txt_start:]
439                 find = 'href="' + st.a_href + '"'
440                 for j in range(len(st.html_out)-1, 0, -1):
441                     if find in st.html_out[j]:
442                         derived = txt2target(atxt, st.a_href[1:])
443                         if derived == st.prior_target:
444                             warn('Found link to the current section in', self.fn + ':', derived)
445                         st.derived_hashtags.add((st.a_href, atxt))
446                         st.html_out[j] = st.html_out[j].replace(find, 'href="#' + derived + '"')
447                         break
448                 else:
449                     die('INTERNAL ERROR: failed to find href in html data:', find)
450         st.html_out.append('</' + tag + '>')
451         if add_to_txt:
452             if txt is None:
453                 st.txt += add_to_txt
454             else:
455                 txt += add_to_txt
456         if st.dt_from == tag:
457             st.man_out.append('.IP "' + manify(txt) + '"\n')
458             st.html_out.append('</dt><dd>')
459             st.at_first_tag_in_dd = True
460             st.dt_from = None
461         elif tag == 'dt':
462             st.html_out.append('<dd>')
463             st.at_first_tag_in_dd = True
464
465
466     def handle_data(self, txt):
467         st = self.state
468         if '](' in txt:
469             warn('Malformed link in', self.fn + ':', txt)
470         if args.debug:
471             self.output_debug('DATA', (txt,))
472         if st.in_pre:
473             html = htmlify(txt)
474         else:
475             txt = re.sub(r'\s--(\s)', NBR_SPACE[0] + r'--\1', txt).replace('--', NBR_DASH[0]*2)
476             txt = re.sub(r'(^|\W)-', r'\1' + NBR_DASH[0], txt)
477             html = htmlify(txt)
478             if st.in_code:
479                 txt = re.sub(r'\s', NBR_SPACE[0], txt)
480                 html = html.replace(NBR_DASH[0], '-').replace(NBR_SPACE[0], ' ') # <code> is non-breaking in CSS
481         st.html_out.append(html.replace(NBR_SPACE[0], '&nbsp;').replace(NBR_DASH[0], '-&#8288;'))
482         st.txt += txt
483
484
485     def add_target(self, txt):
486         st = self.state
487         txt = txt2target(txt, st.opt_prefix)
488         if txt:
489             st.html_out.append('<a id="' + txt + '" href="#' + txt + '" class="tgt"></a>')
490             st.created_hashtags.add(txt)
491             st.prior_target = txt
492
493
494     def output_debug(self, event, extra):
495         import pprint
496         st = self.state
497         if args.debug < 2:
498             st = argparse.Namespace(**vars(st))
499             if len(st.html_out) > 2:
500                 st.html_out = ['...'] + st.html_out[-2:]
501             if len(st.man_out) > 2:
502                 st.man_out = ['...'] + st.man_out[-2:]
503         print(event, extra)
504         pprint.PrettyPrinter(indent=2).pprint(vars(st))
505
506
507 def txt2target(txt, opt_prefix):
508     txt = re.sub(r'[%s](.+?)[=%s].*' % (BOLD_FONT[0], NORM_FONT[0]), r'\1', txt.strip())
509     txt = re.sub(r'[%s]' % NBR_DASH[0], '-', txt)
510     txt = re.sub(r'[\1-\7]+', '', txt)
511     txt = re.sub(r'[^-A-Za-z0-9._]', '_', txt)
512     if opt_prefix and txt.startswith('-'):
513         txt = opt_prefix + txt
514     else:
515         txt = re.sub(r'^([^A-Za-z])', r't\1', txt)
516     return txt
517
518
519 def manify(txt):
520     return re.sub(r"^(['.])", r'\&\1', txt.replace('\\', '\\\\')
521             .replace(NBR_SPACE[0], NBR_SPACE[1])
522             .replace(NBR_DASH[0], NBR_DASH[1])
523             .replace(NORM_FONT[0], NORM_FONT[1])
524             .replace(BOLD_FONT[0], BOLD_FONT[1])
525             .replace(UNDR_FONT[0], UNDR_FONT[1]), flags=re.M)
526
527
528 def htmlify(txt):
529     return txt.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
530
531
532 def warn(*msg):
533     print(*msg, file=sys.stderr)
534     global warning_count
535     warning_count += 1
536
537
538 def die(*msg):
539     warn(*msg)
540     sys.exit(1)
541
542
543 if __name__ == '__main__':
544     parser = argparse.ArgumentParser(description="Output html and (optionally) nroff for markdown pages.", add_help=False)
545     parser.add_argument('--test', action='store_true', help="Just test the parsing without outputting any files.")
546     parser.add_argument('--debug', '-D', action='count', default=0, help='Output copious info on the html parsing. Repeat for even more.')
547     parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
548     parser.add_argument("mdfiles", nargs='+', help="The source .md files to convert.")
549     args = parser.parse_args()
550
551     try:
552         import cmarkgfm
553         md_parser = cmarkgfm.markdown_to_html
554         gfm_parser = cmarkgfm.github_flavored_markdown_to_html
555     except:
556         try:
557             import commonmark
558             md_parser = html_via_commonmark
559         except:
560             die("Failed to find cmarkgfm or commonmark for python3.")
561         gfm_parser = None
562
563     main()
564     if warning_count:
565         sys.exit(1)