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())
26 <link href="https://fonts.googleapis.com/css2?family=Roboto&family=Roboto+Mono&display=swap" rel="stylesheet">
33 font-family: 'Roboto', sans-serif;
36 font-family: 'Roboto Mono', monospace;
48 margin-block-start: 0em;
55 <div style="float: right"><p><i>%s</i></p></div>
60 .TH "%s" "%s" "%s" "%s" "User Commands"
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"\ ")
75 fi = re.match(r'^(?P<fn>(?P<srcdir>.+/)?(?P<name>(?P<prog>[^/]+)\.(?P<sect>\d+))\.md)$', args.mdfile)
77 die('Failed to parse NAME.NUM.md out of input file:', args.mdfile)
78 fi = argparse.Namespace(**fi.groupdict())
83 fi.title = fi.prog + '(' + fi.sect + ') man page'
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) }
92 env_subs['VERSION'] = '1.0.0'
93 env_subs['libdir'] = '/usr'
95 for fn in 'NEWS.md Makefile'.split():
97 st = os.lstat(fi.srcdir + fn)
99 die('Failed to find', fi.srcdir + fn)
101 fi.mtime = st.st_mtime
103 with open(fi.srcdir + 'Makefile', 'r', encoding='utf-8') as fh:
105 m = re.match(r'^(\w+)=(.+)', line)
108 var, val = (m.group(1), m.group(2))
109 if var == 'prefix' and env_subs[var] is not None:
111 while re.search(r'\$\{', val):
112 val = re.sub(r'\$\{(\w+)\}', lambda m: env_subs[m.group(1)], val)
117 with open(fi.fn, 'r', encoding='utf-8') as fh:
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)
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'])
132 print("The test was successful.")
135 for fn, txt in ((fi.name + '.html', fi.html_out), (fi.name, fi.man_out)):
137 with open(fn, 'w', encoding='utf-8') as fh:
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(
156 at_first_tag_in_li = False,
157 at_first_tag_in_dd = False,
161 html_out = [ HTML_START % fi.title ],
162 man_out = [ MAN_START % fi.man_headings ],
166 self.feed(fi.html_in)
169 st.html_out.append(HTML_END % fi.date)
170 st.man_out.append(MAN_END)
172 fi.html_out = ''.join(st.html_out)
175 fi.man_out = ''.join(st.man_out)
179 def handle_starttag(self, tag, attrs_list):
182 self.output_debug('START', (tag, attrs_list))
183 if st.at_first_tag_in_li:
184 if st.list_state[-1] == 'dl':
189 st.html_out.append('<dt>')
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
194 if not st.at_first_tag_in_dd:
195 st.man_out.append(st.p_macro)
197 st.at_first_tag_in_li = True
198 lstate = st.list_state[-1]
202 st.man_out.append(".IP o\n")
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")
210 st.man_out.append(st.p_macro + ".nf\n")
211 elif tag == 'code' and not st.in_pre:
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]
221 for var, val in attrs_list:
223 start = int(val) # We only support integers.
226 st.man_out.append(".RS\n")
230 st.list_state.append('dl')
232 st.list_state.append(start)
233 st.man_out.append(st.p_macro)
236 st.man_out.append(st.p_macro)
238 st.man_out.append(".RS\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):
248 self.output_debug('END', (tag,))
249 if tag in CONSUMES_TXT or st.dt_from == tag:
256 st.man_out.append(st.p_macro + '.SH "' + manify(txt) + '"\n')
258 st.man_out.append(st.p_macro + '.SS "' + manify(txt) + '"\n')
260 if st.dt_from == 'p':
262 st.man_out.append('.IP "' + manify(txt) + '"\n')
265 st.man_out.append(manify(txt) + "\n")
267 if st.list_state[-1] == 'dl':
268 if st.at_first_tag_in_li:
269 die("Invalid 0. -> td translation")
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")
278 st.man_out.append(manify(txt) + "\n.fi\n")
279 elif (tag == 'code' and not st.in_pre):
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':
291 st.man_out.append(".RE\n")
294 st.at_first_tag_in_dd = False
295 st.html_out.append('</' + tag + '>')
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
307 st.html_out.append('<dd>')
308 st.at_first_tag_in_dd = True
311 def handle_data(self, txt):
314 self.output_debug('DATA', (txt,))
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)
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], ' ').replace(NBR_DASH[0], '-⁠'))
328 def output_debug(self, event, extra):
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:]
338 pprint.PrettyPrinter(indent=2).pprint(vars(st))
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)
351 return txt.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
355 print(*msg, file=sys.stderr)
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()
373 md_parser = html_via_cmarkgfm
377 md_parser = html_via_commonmark
379 die("Failed to find cmarkgfm or commonmark for python3.")