Improve gensend rule & list of PHONY targets.
[rsync.git] / md2man
blobc623efb96cc97e19eb0f0dd159c4be5af62e2d12
1 #!/usr/bin/python3
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.
14 # Copyright (C) 2020 Wayne Davison
16 # This program is freely redistributable.
18 import sys, os, re, argparse, subprocess, time
19 from html.parser import HTMLParser
21 CONSUMES_TXT = set('h1 h2 p li pre'.split())
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;
32 body, b, strong, u {
33   font-family: 'Roboto', sans-serif;
35 code {
36   font-family: 'Roboto Mono', monospace;
37   font-weight: bold;
39 pre code {
40   display: block;
41   font-weight: normal;
43 blockquote pre code {
44   background: #f1f1f1;
46 dd p:first-of-type {
47   margin-block-start: 0em;
49 </style>
50 </head><body>
51 """
53 HTML_END = """\
54 <div style="float: right"><p><i>%s</i></p></div>
55 </body></html>
56 """
58 MAN_START = r"""
59 .TH "%s" "%s" "%s" "%s" "User Commands"
60 """.lstrip()
62 MAN_END = """\
63 """
65 NORM_FONT = ('\1', r"\fP")
66 BOLD_FONT = ('\2', r"\fB")
67 ULIN_FONT = ('\3', r"\fI")
69 md_parser = None
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())
77     if not fi.srcdir:
78         fi.srcdir = './'
80     fi.title = fi.prog + '(' + fi.sect + ') man page'
81     fi.mtime = None
83     if os.path.lexists(fi.srcdir + '.git'):
84         fi.mtime = int(subprocess.check_output('git log -1 --format=%at'.split()))
86     chk_files = 'NEWS.md Makefile'.split()
87     for fn in chk_files:
88         try:
89             st = os.lstat(fi.srcdir + fn)
90         except:
91             die('Failed to find', fi.srcdir + fn)
92         if not fi.mtime:
93             fi.mtime = st.st_mtime
95     fi.date = time.strftime('%d %b %Y', time.localtime(fi.mtime))
97     env_subs = { }
99     with open(fi.srcdir + 'Makefile', 'r', encoding='utf-8') as fh:
100         for line in fh:
101             m = re.match(r'^(\w+)=(.+)', line)
102             if not m:
103                 continue
104             var, val = (m[1], m[2])
105             while re.search(r'\$\{', val):
106                 val = re.sub(r'\$\{(\w+)\}', lambda m: env_subs[m[1]], val)
107             env_subs[var] = val
108             if var == 'VERSION':
109                 break
111     with open(fi.fn, 'r', encoding='utf-8') as fh:
112         txt = fh.read()
114     txt = re.sub(r'@VERSION@', env_subs['VERSION'], txt)
115     txt = re.sub(r'@LIBDIR@', env_subs['libdir'], txt)
116     fi.html_in = md_parser(txt)
117     txt = None
119     fi.man_headings = (fi.prog, fi.sect, fi.date, fi.prog + ' ' + env_subs['VERSION'])
121     HtmlToManPage(fi)
123     if args.test:
124         print("The test was successful.")
125         return
127     for fn, txt in ((fi.name + '.html', fi.html_out), (fi.name, fi.man_out)):
128         print("Wrote:", fn)
129         with open(fn, 'w', encoding='utf-8') as fh:
130             fh.write(txt)
133 def html_via_cmarkgfm(txt):
134     return cmarkgfm.markdown_to_html(txt)
137 def html_via_commonmark(txt):
138     return commonmark.HtmlRenderer().render(commonmark.Parser().parse(txt))
141 class HtmlToManPage(HTMLParser):
142     def __init__(self, fi):
143         HTMLParser.__init__(self, convert_charrefs=True)
145         st = self.state = argparse.Namespace(
146                 list_state = [ ],
147                 p_macro = ".P\n",
148                 at_first_tag_in_li = False,
149                 at_first_tag_in_dd = False,
150                 dt_from = None,
151                 in_pre = False,
152                 html_out = [ HTML_START % fi.title ],
153                 man_out = [ MAN_START % fi.man_headings ],
154                 txt = '',
155                 )
157         self.feed(fi.html_in)
158         fi.html_in = None
160         st.html_out.append(HTML_END % fi.date)
161         st.man_out.append(MAN_END)
163         fi.html_out = ''.join(st.html_out)
164         st.html_out = None
166         fi.man_out = ''.join(st.man_out)
167         st.man_out = None
170     def handle_starttag(self, tag, attrs_list):
171         st = self.state
172         if args.debug:
173             self.output_debug('START', (tag, attrs_list))
174         if st.at_first_tag_in_li:
175             if st.list_state[-1] == 'dl':
176                 st.dt_from = tag
177                 if tag == 'p':
178                     tag = 'dt'
179                 else:
180                     st.html_out.append('<dt>')
181             st.at_first_tag_in_li = False
182         if tag == 'p':
183             if not st.at_first_tag_in_dd:
184                 st.man_out.append(st.p_macro)
185         elif tag == 'li':
186             st.at_first_tag_in_li = True
187             lstate = st.list_state[-1]
188             if lstate == 'dl':
189                 return
190             if lstate == 'o':
191                 st.man_out.append(".IP o\n")
192             else:
193                 st.man_out.append(".IP " + str(lstate) + ".\n")
194                 st.list_state[-1] += 1
195         elif tag == 'blockquote':
196             st.man_out.append(".RS 4\n")
197         elif tag == 'pre':
198             st.in_pre = True
199             st.man_out.append(st.p_macro + ".nf\n")
200         elif tag == 'code' and not st.in_pre:
201             st.txt += BOLD_FONT[0]
202         elif tag == 'strong' or tag == 'b':
203             st.txt += BOLD_FONT[0]
204         elif tag == 'em' or  tag == 'i':
205             tag = 'u' # Change it into underline to be more like the man page
206             st.txt += ULIN_FONT[0]
207         elif tag == 'ol':
208             start = 1
209             for var, val in attrs_list:
210                 if var == 'start':
211                     start = int(val) # We only support integers.
212                     break
213             if st.list_state:
214                 st.man_out.append(".RS\n")
215             if start == 0:
216                 tag = 'dl'
217                 attrs_list = [ ]
218                 st.list_state.append('dl')
219             else:
220                 st.list_state.append(start)
221             st.man_out.append(st.p_macro)
222             st.p_macro = ".IP\n"
223         elif tag == 'ul':
224             st.man_out.append(st.p_macro)
225             if st.list_state:
226                 st.man_out.append(".RS\n")
227                 st.p_macro = ".IP\n"
228             st.list_state.append('o')
229         st.html_out.append('<' + tag + ''.join(' ' + var + '="' + htmlify(val) + '"' for var, val in attrs_list) + '>')
230         st.at_first_tag_in_dd = False
233     def handle_endtag(self, tag):
234         st = self.state
235         if args.debug:
236             self.output_debug('END', (tag,))
237         if tag in CONSUMES_TXT or st.dt_from == tag:
238             txt = st.txt.strip()
239             st.txt = ''
240         else:
241             txt = None
242         add_to_txt = None
243         if tag == 'h1':
244             st.man_out.append(st.p_macro + '.SH "' + manify(txt) + '"\n')
245         elif tag == 'h2':
246             st.man_out.append(st.p_macro + '.SS "' + manify(txt) + '"\n')
247         elif tag == 'p':
248             if st.dt_from == 'p':
249                 tag = 'dt'
250                 st.man_out.append('.IP "' + manify(txt) + '"\n')
251                 st.dt_from = None
252             elif txt != '':
253                 st.man_out.append(manify(txt) + "\n")
254         elif tag == 'li':
255             if st.list_state[-1] == 'dl':
256                 if st.at_first_tag_in_li:
257                     die("Invalid 0. -> td translation")
258                 tag = 'dd'
259             if txt != '':
260                 st.man_out.append(manify(txt) + "\n")
261             st.at_first_tag_in_li = False
262         elif tag == 'blockquote':
263             st.man_out.append(".RE\n")
264         elif tag == 'pre':
265             st.in_pre = False
266             st.man_out.append(manify(txt) + "\n.fi\n")
267         elif (tag == 'code' and not st.in_pre) or tag == 'strong' or tag == 'b':
268             add_to_txt = NORM_FONT[0]
269         elif tag == 'em' or  tag == 'i':
270             tag = 'u' # Change it into underline to be more like the man page
271             add_to_txt = NORM_FONT[0]
272         elif tag == 'ol' or tag == 'ul':
273             if st.list_state.pop() == 'dl':
274                 tag = 'dl'
275             if st.list_state:
276                 st.man_out.append(".RE\n")
277             else:
278                 st.p_macro = ".P\n"
279             st.at_first_tag_in_dd = False
280         st.html_out.append('</' + tag + '>')
281         if add_to_txt:
282             if txt is None:
283                 st.txt += add_to_txt
284             else:
285                 txt += add_to_txt
286         if st.dt_from == tag:
287             st.man_out.append('.IP "' + manify(txt) + '"\n')
288             st.html_out.append('</dt><dd>')
289             st.at_first_tag_in_dd = True
290             st.dt_from = None
291         elif tag == 'dt':
292             st.html_out.append('<dd>')
293             st.at_first_tag_in_dd = True
296     def handle_data(self, data):
297         st = self.state
298         if args.debug:
299             self.output_debug('DATA', (data,))
300         st.html_out.append(htmlify(data))
301         st.txt += data
304     def output_debug(self, event, extra):
305         import pprint
306         st = self.state
307         if args.debug < 2:
308             st = argparse.Namespace(**vars(st))
309             if len(st.html_out) > 2:
310                 st.html_out = ['...'] + st.html_out[-2:]
311             if len(st.man_out) > 2:
312                 st.man_out = ['...'] + st.man_out[-2:]
313         print(event, extra)
314         pprint.PrettyPrinter(indent=2).pprint(vars(st))
317 def manify(txt):
318     return re.sub(r"^(['.])", r'\&\1', txt.replace('\\', '\\\\')
319             .replace(NORM_FONT[0], NORM_FONT[1])
320             .replace(BOLD_FONT[0], BOLD_FONT[1])
321             .replace(ULIN_FONT[0], ULIN_FONT[1]), flags=re.M)
324 def htmlify(txt):
325     return txt.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
328 def warn(*msg):
329     print(*msg, file=sys.stderr)
332 def die(*msg):
333     warn(*msg)
334     sys.exit(1)
337 if __name__ == '__main__':
338     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)
339     parser.add_argument('--test', action='store_true', help='Test if we can parse the input w/o updating any files.')
340     parser.add_argument('--debug', '-D', action='count', default=0, help='Output copious info on the html parsing. Repeat for even more.')
341     parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
342     parser.add_argument('mdfile', help="The NAME.NUM.md file to parse.")
343     args = parser.parse_args()
345     try:
346         import cmarkgfm
347         md_parser = html_via_cmarkgfm
348     except:
349         try:
350             import commonmark
351             md_parser = html_via_commonmark
352         except:
353             die("Failed to find cmarkgfm or commonmark for python3.")
355     main()