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