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