Change man page src format from yodl to markdown.
[rsync.git] / md2man
1 #!/usr/bin/python3
2
3 # This script takes a manpage written in github-flavored markdown and turns it
4 # into a html web page and a nroff man page.  The input file must have the name
5 # of the program and the section in the format: NAME.NUM.md. The output files
6 # are written into the current directory named NAME.NUM.html and NAME.NUM.  The
7 # input format has one extra extension: if a numbered list starts at 0, it is
8 # turned into a description list. The dl's dt tag is taken from the contents of
9 # the first tag inside the li, which is usually a p tag or a code tag.  The
10 # cmarkgfm lib is used to transforms the input file into html. The html.parser
11 # is used as a state machine that both tweaks the html and outputs the nroff
12 # 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, 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&display=swap" rel="stylesheet">
27 <style>
28 body {
29   max-width: 40em;
30   margin: auto;
31   font-size: 1.2em;
32   font-family: 'Roboto', sans-serif;
33 }
34 blockquote pre code {
35   background: #eee;
36 }
37 dd p:first-of-type {
38   margin-block-start: 0em;
39 }
40 </style>
41 </head><body>
42 """
43
44 HTML_END = """\
45 <div style="float: right"><p><i>%s</i></p></div>
46 </body></html>
47 """
48
49 MAN_START = r"""
50 .TH "%s" "%s" "%s" "" ""
51 """.lstrip()
52
53 MAN_END = """\
54 """
55
56 NORM_FONT = ('\1', r"\fP")
57 BOLD_FONT = ('\2', r"\fB")
58 ULIN_FONT = ('\3', r"\fI")
59
60 env_subs = { }
61
62 def main():
63     mtime = None
64
65     fi = re.match(r'^(?P<fn>(?P<srcdir>.+/)?(?P<name>(?P<prog>[^/]+)\.(?P<sect>\d+))\.md)$', args.mdfile)
66     if not fi:
67         die('Failed to parse NAME.NUM.md out of input file:', args.mdfile)
68     fi = argparse.Namespace(**fi.groupdict())
69     if not fi.srcdir:
70         fi.srcdir = './'
71
72     chk_files = 'latest-year.h Makefile'.split()
73     for fn in chk_files:
74         try:
75             st = os.lstat(fi.srcdir + fn)
76         except:
77             die('Failed to find', fi.srcdir + fn)
78         if not mtime:
79             mtime = st.st_mtime
80
81     with open(fi.srcdir + 'Makefile', 'r', encoding='utf-8') as fh:
82         for line in fh:
83             m = re.match(r'^(\w+)=(.+)', line)
84             if not m:
85                 continue
86             var, val = (m[1], m[2])
87             while re.search(r'\$\{', val):
88                 val = re.sub(r'\$\{(\w+)\}', lambda m: env_subs[m[1]], val)
89             env_subs[var] = val
90             if var == 'VERSION':
91                 break
92
93     MarkdownToManPage(fi, mtime)
94
95
96 class MarkdownToManPage(HTMLParser):
97     def __init__(self, fi, mtime):
98         HTMLParser.__init__(self, convert_charrefs=True)
99
100         self.man_fh = self.html_fh = None
101         self.state = argparse.Namespace(
102                 list_state = [ ],
103                 p_macro = ".P\n",
104                 first_li_tag = False,
105                 first_dd_tag = False,
106                 dt_from = None,
107                 in_pre = False,
108                 txt = '',
109                 )
110
111         self.date = time.strftime('%d %b %Y', time.localtime(mtime))
112
113         with open(fi.fn, 'r', encoding='utf-8') as fh:
114             txt = re.sub(r'@VERSION@', env_subs['VERSION'], fh.read())
115             txt = re.sub(r'@LIBDIR@', env_subs['libdir'], txt)
116             html = cmarkgfm.github_flavored_markdown_to_html(txt)
117             txt = None
118
119         if args.test:
120             self.html_fh = open(os.devnull, 'w', encoding='utf-8')
121             self.man_fh = self.html_fh
122         else:
123             self.html_fn = fi.name + '.html'
124             self.html_fh = open(self.html_fn, 'w', encoding='utf-8')
125             self.html_fh.write(HTML_START % fi.prog + '(' + fi.sect + ') man page')
126
127             self.man_fn = fi.name
128             self.man_fh = open(self.man_fn, 'w', encoding='utf-8')
129             self.man_fh.write(MAN_START % (fi.prog, fi.sect, self.date))
130
131         self.feed(html)
132
133     def __del__(self):
134         if args.test:
135             print("The test was successful.")
136             return
137
138         if self.html_fh:
139             self.html_fh.write(HTML_END % self.date)
140             self.html_fh.close()
141             print("Output HTML page: ", self.html_fn)
142
143         if self.man_fh:
144             self.man_fh.write(MAN_END)
145             self.man_fh.close()
146             print("Output man page:  ", self.man_fn)
147
148     def handle_starttag(self, tag, attrs_list):
149         st = self.state
150         if args.debug:
151             print('START', tag, attrs_list, st)
152         if st.first_li_tag:
153             if st.list_state[-1] == 'dl':
154                 st.dt_from = tag
155                 if tag == 'p':
156                     tag = 'dt'
157                 else:
158                     self.html_fh.write('<dt>')
159             st.first_li_tag = False
160         if tag == 'p':
161             if not st.first_dd_tag:
162                 self.man_fh.write(st.p_macro)
163         elif tag == 'li':
164             st.first_li_tag = True
165             lstate = st.list_state[-1]
166             if lstate == 'dl':
167                 return
168             if lstate == 'o':
169                 self.man_fh.write(".IP o\n")
170             else:
171                 self.man_fh.write(".IP " + str(lstate) + ".\n")
172                 st.list_state[-1] += 1
173         elif tag == 'blockquote':
174             self.man_fh.write(".RS 4\n")
175         elif tag == 'pre':
176             st.in_pre = True
177             self.man_fh.write(st.p_macro + ".nf\n")
178         elif tag == 'code' and not st.in_pre:
179             st.txt += BOLD_FONT[0]
180         elif tag == 'strong' or tag == 'bold':
181             st.txt += BOLD_FONT[0]
182         elif tag == 'i' or tag == 'em':
183             st.txt += ULIN_FONT[0]
184         elif tag == 'ol':
185             start = 1
186             for var, val in attrs_list:
187                 if var == 'start':
188                     start = int(val) # We only support integers.
189                     break
190             if st.list_state:
191                 self.man_fh.write(".RS\n")
192             if start == 0:
193                 tag = 'dl'
194                 attrs_list = [ ]
195                 st.list_state.append('dl')
196             else:
197                 st.list_state.append(start)
198             self.man_fh.write(st.p_macro)
199             st.p_macro = ".IP\n"
200         elif tag == 'ul':
201             self.man_fh.write(st.p_macro)
202             if st.list_state:
203                 self.man_fh.write(".RS\n")
204                 st.p_macro = ".IP\n"
205             st.list_state.append('o')
206         outer_tag = '<' + tag
207         for var, val in attrs_list:
208             outer_tag += ' ' + var + '=' + safeText(val) + '"'
209         self.html_fh.write(outer_tag + '>')
210         st.first_dd_tag = False
211
212     def handle_endtag(self, tag):
213         st = self.state
214         if args.debug:
215             print('  END', tag, st)
216         if tag in CONSUMES_TXT or st.dt_from == tag:
217             txt = st.txt.strip()
218             st.txt = ''
219         else:
220             txt = None
221         add_to_txt = None
222         if tag == 'h1':
223             self.man_fh.write(st.p_macro + '.SH "' + manify(txt) + '"\n')
224         elif tag == 'p':
225             if st.dt_from == 'p':
226                 tag = 'dt'
227                 self.man_fh.write('.IP "' + manify(txt) + '"\n')
228                 st.dt_from = None
229             else:
230                 self.man_fh.write(manify(txt) + "\n")
231         elif tag == 'li':
232             if st.list_state[-1] == 'dl':
233                 if st.first_li_tag:
234                     die("Invalid 0. -> td translation")
235                 tag = 'dd'
236             if txt != '':
237                 self.man_fh.write(manify(txt) + "\n")
238             st.first_li_tag = False
239         elif tag == 'blockquote':
240             self.man_fh.write(".RE\n")
241         elif tag == 'pre':
242             st.in_pre = False
243             self.man_fh.write(manify(txt) + "\n.fi\n")
244         elif tag == 'code' and not st.in_pre:
245              add_to_txt = NORM_FONT[0]
246         elif tag == 'strong' or tag == 'bold':
247              add_to_txt = NORM_FONT[0]
248         elif tag == 'i' or tag == 'em':
249              add_to_txt = NORM_FONT[0]
250         elif tag == 'ol' or tag == 'ul':
251             if st.list_state.pop() == 'dl':
252                 tag = 'dl'
253             if st.list_state:
254                 self.man_fh.write(".RE\n")
255             else:
256                 st.p_macro = ".P\n"
257             st.first_dd_tag = False
258         self.html_fh.write('</' + tag + '>')
259         if add_to_txt:
260             if txt is None:
261                 st.txt += add_to_txt
262             else:
263                 txt += add_to_txt
264         if st.dt_from == tag:
265             self.man_fh.write('.IP "' + manify(txt) + '"\n')
266             self.html_fh.write('</dt><dd>')
267             st.first_dd_tag = True
268             st.dt_from = None
269         elif tag == 'dt':
270             self.html_fh.write('<dd>')
271             st.first_dd_tag = True
272
273     def handle_data(self, data):
274         st = self.state
275         if args.debug:
276             print(' DATA', [data], st)
277         self.html_fh.write(safeText(data))
278         st.txt += data
279
280
281 def manify(txt):
282     return re.sub(r"^(['.])", r'\&\1', txt.replace('\\', '\\\\')
283             .replace(NORM_FONT[0], NORM_FONT[1])
284             .replace(BOLD_FONT[0], BOLD_FONT[1])
285             .replace(ULIN_FONT[0], ULIN_FONT[1]), flags=re.M)
286
287
288 def safeText(txt):
289     return txt.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
290
291
292 def warn(*msg):
293     print(*msg, file=sys.stderr)
294
295
296 def die(*msg):
297     warn(*msg)
298     sys.exit(1)
299
300
301 if __name__ == '__main__':
302     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)
303     parser.add_argument('--test', action='store_true', help='Test if we can parse the input w/o updating any files.')
304     parser.add_argument('--debug', '-D', action='count', default=0, help='Output copious info on the html parsing.')
305     parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
306     parser.add_argument('mdfile', help="The NAME.NUM.md file to parse.")
307     args = parser.parse_args()
308
309     try:
310         import cmarkgfm
311     except:
312         die("The cmarkgfm library is not available for python3.")
313
314     main()