3 # This script transforms markdown files into html and (optionally) nroff. The
4 # output files are written into the current directory named for the input file
5 # without the .md suffix and either the .html suffix or no suffix.
7 # If the input .md file has a section number at the end of the name (e.g.,
8 # rsync.1.md) a nroff file is also output (PROJ.NUM.md -> PROJ.NUM).
10 # The markdown input format has one extra extension: if a numbered list starts
11 # at 0, it is turned into a description list. The dl's dt tag is taken from the
12 # contents of the first tag inside the li, which is usually a p, code, or
15 # The cmarkgfm or commonmark lib is used to transforms the input file into
16 # html. Then, the html.parser is used as a state machine that lets us tweak
17 # the html and (optionally) output nroff data based on the html tags.
19 # If the string @USE_GFM_PARSER@ exists in the file, the string is removed and
20 # a github-flavored-markup parser is used to parse the file.
22 # The man-page .md files also get the vars @VERSION@, @BINDIR@, and @LIBDIR@
23 # substituted. Some of these values depend on the Makefile $(prefix) (see the
24 # generated Makefile). If the maintainer wants to build files for /usr/local
25 # while creating release-ready man-page files for /usr, use the environment to
26 # set RSYNC_OVERRIDE_PREFIX=/usr.
28 # Copyright (C) 2020 - 2021 Wayne Davison
30 # This program is freely redistributable.
32 import os, sys, re, argparse, subprocess, time
33 from html.parser import HTMLParser
35 CONSUMES_TXT = set('h1 h2 p li pre'.split())
40 <link href="https://fonts.googleapis.com/css2?family=Roboto&family=Roboto+Mono&display=swap" rel="stylesheet">
47 font-family: 'Roboto', sans-serif;
50 font-family: 'Roboto Mono', monospace;
62 margin-block-start: 0em;
74 border-top: 1px solid grey;
77 background-color: #f6f8fa;
80 border: 1px solid #dfe2e5;
88 <div style="float: right"><p><i>%s</i></p></div>
96 .TH "%s" "%s" "%s" "%s" "User Commands"
103 NORM_FONT = ('\1', r"\fP")
104 BOLD_FONT = ('\2', r"\fB")
105 UNDR_FONT = ('\3', r"\fI")
106 NBR_DASH = ('\4', r"\-")
107 NBR_SPACE = ('\xa0', r"\ ")
113 for mdfn in args.mdfiles:
117 print("The test was successful.")
120 def parse_md_file(mdfn):
121 fi = re.match(r'^(?P<fn>(?P<srcdir>.+/)?(?P<name>(?P<prog>[^/]+?)(\.(?P<sect>\d+))?)\.md)$', mdfn)
123 die('Failed to parse a md input file name:', mdfn)
124 fi = argparse.Namespace(**fi.groupdict())
125 fi.want_manpage = not not fi.sect
127 fi.title = fi.prog + '(' + fi.sect + ') man page'
133 find_man_substitutions()
134 prog_ver = 'rsync ' + env_subs['VERSION']
135 if fi.prog != 'rsync':
136 prog_ver = fi.prog + ' from ' + prog_ver
137 fi.man_headings = (fi.prog, fi.sect, env_subs['date'], prog_ver, env_subs['prefix'])
139 with open(mdfn, 'r', encoding='utf-8') as fh:
142 use_gfm_parser = '@USE_GFM_PARSER@' in txt
144 txt = txt.replace('@USE_GFM_PARSER@', '')
147 txt = (txt.replace('@VERSION@', env_subs['VERSION'])
148 .replace('@BINDIR@', env_subs['bindir'])
149 .replace('@LIBDIR@', env_subs['libdir']))
153 die('Input file requires cmarkgfm parser:', mdfn)
154 fi.html_in = gfm_parser(txt)
156 fi.html_in = md_parser(txt)
164 output_list = [ (fi.name + '.html', fi.html_out) ]
166 output_list += [ (fi.name, fi.man_out) ]
167 for fn, txt in output_list:
168 if os.path.lexists(fn):
171 with open(fn, 'w', encoding='utf-8') as fh:
175 def find_man_substitutions():
176 srcdir = os.path.dirname(sys.argv[0]) + '/'
179 git_dir = srcdir + '.git'
180 if os.path.lexists(git_dir):
181 mtime = int(subprocess.check_output(['git', '--git-dir', git_dir, 'log', '-1', '--format=%at']))
183 # Allow "prefix" to be overridden via the environment:
184 env_subs['prefix'] = os.environ.get('RSYNC_OVERRIDE_PREFIX', None)
187 env_subs['VERSION'] = '1.0.0'
188 env_subs['bindir'] = '/usr/bin'
189 env_subs['libdir'] = '/usr/lib/rsync'
191 for fn in (srcdir + 'version.h', 'Makefile'):
195 die('Failed to find', srcdir + fn)
199 with open(srcdir + 'version.h', 'r', encoding='utf-8') as fh:
201 m = re.search(r'"(.+?)"', txt)
202 env_subs['VERSION'] = m.group(1)
204 with open('Makefile', 'r', encoding='utf-8') as fh:
206 m = re.match(r'^(\w+)=(.+)', line)
209 var, val = (m.group(1), m.group(2))
210 if var == 'prefix' and env_subs[var] is not None:
212 while re.search(r'\$\{', val):
213 val = re.sub(r'\$\{(\w+)\}', lambda m: env_subs[m.group(1)], val)
218 env_subs['date'] = time.strftime('%d %b %Y', time.localtime(mtime))
221 def html_via_commonmark(txt):
222 return commonmark.HtmlRenderer().render(commonmark.Parser().parse(txt))
225 class TransformHtml(HTMLParser):
226 def __init__(self, fi):
227 HTMLParser.__init__(self, convert_charrefs=True)
229 st = self.state = argparse.Namespace(
232 at_first_tag_in_li = False,
233 at_first_tag_in_dd = False,
237 html_out = [ HTML_START % fi.title ],
240 want_manpage = fi.want_manpage,
244 st.man_out.append(MAN_START % fi.man_headings)
246 if '</table>' in fi.html_in:
247 st.html_out[0] = st.html_out[0].replace('</style>', TABLE_STYLE + '</style>')
249 self.feed(fi.html_in)
253 st.html_out.append(MAN_HTML_END % env_subs['date'])
254 st.html_out.append(HTML_END)
255 st.man_out.append(MAN_END)
257 fi.html_out = ''.join(st.html_out)
260 fi.man_out = ''.join(st.man_out)
264 def handle_starttag(self, tag, attrs_list):
267 self.output_debug('START', (tag, attrs_list))
268 if st.at_first_tag_in_li:
269 if st.list_state[-1] == 'dl':
274 st.html_out.append('<dt>')
276 st.at_first_tag_in_dd = True # Kluge to suppress a .P at the start of an li.
277 st.at_first_tag_in_li = False
279 if not st.at_first_tag_in_dd:
280 st.man_out.append(st.p_macro)
282 st.at_first_tag_in_li = True
283 lstate = st.list_state[-1]
287 st.man_out.append(".IP o\n")
289 st.man_out.append(".IP " + str(lstate) + ".\n")
290 st.list_state[-1] += 1
291 elif tag == 'blockquote':
292 st.man_out.append(".RS 4\n")
295 st.man_out.append(st.p_macro + ".nf\n")
296 elif tag == 'code' and not st.in_pre:
298 st.txt += BOLD_FONT[0]
299 elif tag == 'strong' or tag == 'b':
300 st.txt += BOLD_FONT[0]
301 elif tag == 'em' or tag == 'i':
303 tag = 'u' # Change it into underline to be more like the man page
304 st.txt += UNDR_FONT[0]
307 for var, val in attrs_list:
309 start = int(val) # We only support integers.
312 st.man_out.append(".RS\n")
316 st.list_state.append('dl')
318 st.list_state.append(start)
319 st.man_out.append(st.p_macro)
322 st.man_out.append(st.p_macro)
324 st.man_out.append(".RS\n")
326 st.list_state.append('o')
328 st.man_out.append(".l\n")
329 st.html_out.append("<hr />")
331 st.html_out.append('<' + tag + ''.join(' ' + var + '="' + htmlify(val) + '"' for var, val in attrs_list) + '>')
332 st.at_first_tag_in_dd = False
335 def handle_endtag(self, tag):
338 self.output_debug('END', (tag,))
339 if tag in CONSUMES_TXT or st.dt_from == tag:
346 st.man_out.append(st.p_macro + '.SH "' + manify(txt) + '"\n')
348 st.man_out.append(st.p_macro + '.SS "' + manify(txt) + '"\n')
350 if st.dt_from == 'p':
352 st.man_out.append('.IP "' + manify(txt) + '"\n')
355 st.man_out.append(manify(txt) + "\n")
357 if st.list_state[-1] == 'dl':
358 if st.at_first_tag_in_li:
359 die("Invalid 0. -> td translation")
362 st.man_out.append(manify(txt) + "\n")
363 st.at_first_tag_in_li = False
364 elif tag == 'blockquote':
365 st.man_out.append(".RE\n")
368 st.man_out.append(manify(txt) + "\n.fi\n")
369 elif (tag == 'code' and not st.in_pre):
371 add_to_txt = NORM_FONT[0]
372 elif tag == 'strong' or tag == 'b':
373 add_to_txt = NORM_FONT[0]
374 elif tag == 'em' or tag == 'i':
376 tag = 'u' # Change it into underline to be more like the man page
377 add_to_txt = NORM_FONT[0]
378 elif tag == 'ol' or tag == 'ul':
379 if st.list_state.pop() == 'dl':
382 st.man_out.append(".RE\n")
385 st.at_first_tag_in_dd = False
388 st.html_out.append('</' + tag + '>')
394 if st.dt_from == tag:
395 st.man_out.append('.IP "' + manify(txt) + '"\n')
396 st.html_out.append('</dt><dd>')
397 st.at_first_tag_in_dd = True
400 st.html_out.append('<dd>')
401 st.at_first_tag_in_dd = True
404 def handle_data(self, txt):
407 self.output_debug('DATA', (txt,))
411 txt = re.sub(r'\s--(\s)', NBR_SPACE[0] + r'--\1', txt).replace('--', NBR_DASH[0]*2)
412 txt = re.sub(r'(^|\W)-', r'\1' + NBR_DASH[0], txt)
415 txt = re.sub(r'\s', NBR_SPACE[0], txt)
416 html = html.replace(NBR_DASH[0], '-').replace(NBR_SPACE[0], ' ') # <code> is non-breaking in CSS
417 st.html_out.append(html.replace(NBR_SPACE[0], ' ').replace(NBR_DASH[0], '-⁠'))
421 def output_debug(self, event, extra):
425 st = argparse.Namespace(**vars(st))
426 if len(st.html_out) > 2:
427 st.html_out = ['...'] + st.html_out[-2:]
428 if len(st.man_out) > 2:
429 st.man_out = ['...'] + st.man_out[-2:]
431 pprint.PrettyPrinter(indent=2).pprint(vars(st))
435 return re.sub(r"^(['.])", r'\&\1', txt.replace('\\', '\\\\')
436 .replace(NBR_SPACE[0], NBR_SPACE[1])
437 .replace(NBR_DASH[0], NBR_DASH[1])
438 .replace(NORM_FONT[0], NORM_FONT[1])
439 .replace(BOLD_FONT[0], BOLD_FONT[1])
440 .replace(UNDR_FONT[0], UNDR_FONT[1]), flags=re.M)
444 return txt.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
448 print(*msg, file=sys.stderr)
456 if __name__ == '__main__':
457 parser = argparse.ArgumentParser(description="Output html and (optionally) nroff for markdown pages.", add_help=False)
458 parser.add_argument('--test', action='store_true', help="Just test the parsing without outputting any files.")
459 parser.add_argument('--debug', '-D', action='count', default=0, help='Output copious info on the html parsing. Repeat for even more.')
460 parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
461 parser.add_argument("mdfiles", nargs='+', help="The source .md files to convert.")
462 args = parser.parse_args()
466 md_parser = cmarkgfm.markdown_to_html
467 gfm_parser = cmarkgfm.github_flavored_markdown_to_html
471 md_parser = html_via_commonmark
473 die("Failed to find cmarkgfm or commonmark for python3.")