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