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