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 VALID_PAGES = 'README INSTALL COPYING rsync.1 rrsync.1 rsync-ssl.1 rsyncd.conf.5'.split()
37 CONSUMES_TXT = set('h1 h2 h3 p li pre'.split())
41 <title>%TITLE%</title>
42 <meta charset="UTF-8"/>
43 <link href="https://fonts.googleapis.com/css2?family=Roboto&family=Roboto+Mono&display=swap" rel="stylesheet">
50 font-family: 'Roboto', sans-serif;
52 a.tgt { font-face: symbol; font-weight: 400; font-size: 70%; visibility: hidden; text-decoration: none; color: #ddd; padding: 0 4px; border: 0; }
53 a.tgt:after { content: '🔗'; }
54 a.tgt:hover { color: #444; background-color: #eaeaea; }
55 h1:hover > a.tgt, h2:hover > a.tgt, h3:hover > a.tgt, dt:hover > a.tgt { visibility: visible; }
57 font-family: 'Roboto Mono', monospace;
69 margin-block-start: 0em;
81 border-top: 1px solid grey;
84 background-color: #f6f8fa;
87 border: 1px solid #dfe2e5;
95 <div style="float: right"><p><i>%s</i></p></div>
103 .TH "%s" "%s" "%s" "%s" "User Commands"
110 NORM_FONT = ('\1', r"\fP")
111 BOLD_FONT = ('\2', r"\fB")
112 UNDR_FONT = ('\3', r"\fI")
113 NBR_DASH = ('\4', r"\-")
114 NBR_SPACE = ('\xa0', r"\ ")
116 FILENAME_RE = re.compile(r'^(?P<fn>(?P<srcdir>.+/)?(?P<name>(?P<prog>[^/]+?)(\.(?P<sect>\d+))?)\.md)$')
117 ASSIGNMENT_RE = re.compile(r'^(\w+)=(.+)')
118 QUOTED_RE = re.compile(r'"(.+?)"')
119 VAR_REF_RE = re.compile(r'\$\{(\w+)\}')
120 VERSION_RE = re.compile(r' (\d[.\d]+)[, ]')
121 BIN_CHARS_RE = re.compile(r'[\1-\7]+')
122 SPACE_DOUBLE_DASH_RE = re.compile(r'\s--(\s)')
123 NON_SPACE_SINGLE_DASH_RE = re.compile(r'(^|\W)-')
124 WHITESPACE_RE = re.compile(r'\s')
125 CODE_BLOCK_RE = re.compile(r'[%s]([^=%s]+)[=%s]' % (BOLD_FONT[0], NORM_FONT[0], NORM_FONT[0]))
126 NBR_DASH_RE = re.compile(r'[%s]' % NBR_DASH[0])
127 INVALID_TARGET_CHARS_RE = re.compile(r'[^-A-Za-z0-9._]')
128 INVALID_START_CHAR_RE = re.compile(r'^([^A-Za-z0-9])')
129 MANIFY_LINESTART_RE = re.compile(r"^(['.])", flags=re.M)
137 for mdfn in args.mdfiles:
141 print("The test was successful.")
144 def parse_md_file(mdfn):
145 fi = FILENAME_RE.match(mdfn)
147 die('Failed to parse a md input file name:', mdfn)
148 fi = argparse.Namespace(**fi.groupdict())
149 fi.want_manpage = not not fi.sect
151 fi.title = fi.prog + '(' + fi.sect + ') man page'
153 fi.title = fi.prog + ' for rsync'
157 find_man_substitutions()
158 prog_ver = 'rsync ' + env_subs['VERSION']
159 if fi.prog != 'rsync':
160 prog_ver = fi.prog + ' from ' + prog_ver
161 fi.man_headings = (fi.prog, fi.sect, env_subs['date'], prog_ver, env_subs['prefix'])
163 with open(mdfn, 'r', encoding='utf-8') as fh:
166 use_gfm_parser = '@USE_GFM_PARSER@' in txt
168 txt = txt.replace('@USE_GFM_PARSER@', '')
171 txt = (txt.replace('@VERSION@', env_subs['VERSION'])
172 .replace('@BINDIR@', env_subs['bindir'])
173 .replace('@LIBDIR@', env_subs['libdir']))
177 die('Input file requires cmarkgfm parser:', mdfn)
178 fi.html_in = gfm_parser(txt)
180 fi.html_in = md_parser(txt)
188 output_list = [ (fi.name + '.html', fi.html_out) ]
190 output_list += [ (fi.name, fi.man_out) ]
191 for fn, txt in output_list:
192 if args.dest and args.dest != '.':
193 fn = os.path.join(args.dest, fn)
194 if os.path.lexists(fn):
197 with open(fn, 'w', encoding='utf-8') as fh:
201 def find_man_substitutions():
202 srcdir = os.path.dirname(sys.argv[0]) + '/'
205 git_dir = srcdir + '.git'
206 if os.path.lexists(git_dir):
207 mtime = int(subprocess.check_output(['git', '--git-dir', git_dir, 'log', '-1', '--format=%at']))
209 # Allow "prefix" to be overridden via the environment:
210 env_subs['prefix'] = os.environ.get('RSYNC_OVERRIDE_PREFIX', None)
213 env_subs['VERSION'] = '1.0.0'
214 env_subs['bindir'] = '/usr/bin'
215 env_subs['libdir'] = '/usr/lib/rsync'
217 for fn in (srcdir + 'version.h', 'Makefile'):
221 die('Failed to find', srcdir + fn)
225 with open(srcdir + 'version.h', 'r', encoding='utf-8') as fh:
227 m = QUOTED_RE.search(txt)
228 env_subs['VERSION'] = m.group(1)
230 with open('Makefile', 'r', encoding='utf-8') as fh:
232 m = ASSIGNMENT_RE.match(line)
235 var, val = (m.group(1), m.group(2))
236 if var == 'prefix' and env_subs[var] is not None:
238 while VAR_REF_RE.search(val):
239 val = VAR_REF_RE.sub(lambda m: env_subs[m.group(1)], val)
244 env_subs['date'] = time.strftime('%d %b %Y', time.localtime(mtime))
247 def html_via_commonmark(txt):
248 return commonmark.HtmlRenderer().render(commonmark.Parser().parse(txt))
251 class TransformHtml(HTMLParser):
252 def __init__(self, fi):
253 HTMLParser.__init__(self, convert_charrefs=True)
257 st = self.state = argparse.Namespace(
260 at_first_tag_in_li = False,
261 at_first_tag_in_dd = False,
265 html_out = [ HTML_START.replace('%TITLE%', fi.title) ],
268 want_manpage = fi.want_manpage,
269 created_hashtags = set(),
270 derived_hashtags = set(),
271 referenced_hashtags = set(),
272 bad_hashtags = set(),
273 latest_targets = [ ],
280 st.man_out.append(MAN_START % fi.man_headings)
282 if '</table>' in fi.html_in:
283 st.html_out[0] = st.html_out[0].replace('</style>', TABLE_STYLE + '</style>')
285 self.feed(fi.html_in)
289 st.html_out.append(MAN_HTML_END % env_subs['date'])
290 st.html_out.append(HTML_END)
291 st.man_out.append(MAN_END)
293 fi.html_out = ''.join(st.html_out)
296 fi.man_out = ''.join(st.man_out)
299 for tgt, txt in st.derived_hashtags:
300 derived = txt2target(txt, tgt)
301 if derived not in st.created_hashtags:
302 txt = BIN_CHARS_RE.sub('', txt.replace(NBR_DASH[0], '-').replace(NBR_SPACE[0], ' '))
303 warn('Unknown derived hashtag link in', self.fn, 'based on:', (tgt, txt))
305 for bad in st.bad_hashtags:
306 if bad in st.created_hashtags:
307 warn('Missing "#" in hashtag link in', self.fn + ':', bad)
309 warn('Unknown non-hashtag link in', self.fn + ':', bad)
311 for bad in st.referenced_hashtags - st.created_hashtags:
312 warn('Unknown hashtag link in', self.fn + ':', '#' + bad)
314 def handle_starttag(self, tag, attrs_list):
317 self.output_debug('START', (tag, attrs_list))
318 if st.at_first_tag_in_li:
319 if st.list_state[-1] == 'dl':
324 st.html_out.append('<dt>')
326 st.at_first_tag_in_dd = True # Kluge to suppress a .P at the start of an li.
327 st.at_first_tag_in_li = False
329 if not st.at_first_tag_in_dd:
330 st.man_out.append(st.p_macro)
332 st.at_first_tag_in_li = True
333 lstate = st.list_state[-1]
337 st.man_out.append(".IP o\n")
339 st.man_out.append(".IP " + str(lstate) + ".\n")
340 st.list_state[-1] += 1
341 elif tag == 'blockquote':
342 st.man_out.append(".RS 4\n")
345 st.man_out.append(st.p_macro + ".nf\n")
346 elif tag == 'code' and not st.in_pre:
348 st.txt += BOLD_FONT[0]
349 elif tag == 'strong' or tag == 'b':
350 st.txt += BOLD_FONT[0]
351 elif tag == 'em' or tag == 'i':
353 tag = 'u' # Change it into underline to be more like the man page
354 st.txt += UNDR_FONT[0]
357 for var, val in attrs_list:
359 start = int(val) # We only support integers.
362 st.man_out.append(".RS\n")
366 st.list_state.append('dl')
368 st.list_state.append(start)
369 st.man_out.append(st.p_macro)
372 st.man_out.append(st.p_macro)
374 st.man_out.append(".RS\n")
376 st.list_state.append('o')
378 st.man_out.append(".l\n")
379 st.html_out.append("<hr />")
383 for var, val in attrs_list:
385 if val.startswith(('https://', 'http://', 'mailto:', 'ftp:')):
386 pass # nothing to check
388 pg, tgt = val.split('#', 2)
389 if pg and pg not in VALID_PAGES or '#' in tgt:
390 st.bad_hashtags.add(val)
391 elif tgt in ('', 'opt', 'dopt'):
394 st.referenced_hashtags.add(tgt)
395 if tgt in st.latest_targets:
396 warn('Found link to the current section in', self.fn + ':', val)
397 elif val not in VALID_PAGES:
398 st.bad_hashtags.add(val)
399 st.a_txt_start = len(st.txt)
400 st.html_out.append('<' + tag + ''.join(' ' + var + '="' + htmlify(val) + '"' for var, val in attrs_list) + '>')
401 st.at_first_tag_in_dd = False
404 def handle_endtag(self, tag):
407 self.output_debug('END', (tag,))
408 if tag in CONSUMES_TXT or st.dt_from == tag:
417 if tgt.startswith('NEWS for '):
418 m = VERSION_RE.search(tgt)
421 st.target_suf = '-' + tgt
422 self.add_targets(tag, tgt)
424 st.man_out.append(st.p_macro + '.SH "' + manify(txt) + '"\n')
425 self.add_targets(tag, txt, st.target_suf)
426 st.opt_prefix = 'dopt' if txt == 'DAEMON OPTIONS' else 'opt'
428 st.man_out.append(st.p_macro + '.SS "' + manify(txt) + '"\n')
429 self.add_targets(tag, txt, st.target_suf)
431 if st.dt_from == 'p':
433 st.man_out.append('.IP "' + manify(txt) + '"\n')
434 if txt.startswith(BOLD_FONT[0]):
435 self.add_targets(tag, txt)
438 st.man_out.append(manify(txt) + "\n")
440 if st.list_state[-1] == 'dl':
441 if st.at_first_tag_in_li:
442 die("Invalid 0. -> td translation")
445 st.man_out.append(manify(txt) + "\n")
446 st.at_first_tag_in_li = False
447 elif tag == 'blockquote':
448 st.man_out.append(".RE\n")
451 st.man_out.append(manify(txt) + "\n.fi\n")
452 elif (tag == 'code' and not st.in_pre):
454 add_to_txt = NORM_FONT[0]
455 elif tag == 'strong' or tag == 'b':
456 add_to_txt = NORM_FONT[0]
457 elif tag == 'em' or tag == 'i':
459 tag = 'u' # Change it into underline to be more like the man page
460 add_to_txt = NORM_FONT[0]
461 elif tag == 'ol' or tag == 'ul':
462 if st.list_state.pop() == 'dl':
465 st.man_out.append(".RE\n")
468 st.at_first_tag_in_dd = False
473 atxt = st.txt[st.a_txt_start:]
474 find = 'href="' + st.a_href + '"'
475 for j in range(len(st.html_out)-1, 0, -1):
476 if find in st.html_out[j]:
477 pg, tgt = st.a_href.split('#', 2)
478 derived = txt2target(atxt, tgt)
480 if derived in st.latest_targets:
481 warn('Found link to the current section in', self.fn + ':', st.a_href)
482 st.derived_hashtags.add((tgt, atxt))
483 st.html_out[j] = st.html_out[j].replace(find, 'href="' + pg + '#' + derived + '"')
486 die('INTERNAL ERROR: failed to find href in html data:', find)
487 st.html_out.append('</' + tag + '>')
493 if st.dt_from == tag:
494 st.man_out.append('.IP "' + manify(txt) + '"\n')
495 st.html_out.append('</dt><dd>')
496 st.at_first_tag_in_dd = True
499 st.html_out.append('<dd>')
500 st.at_first_tag_in_dd = True
503 def handle_data(self, txt):
506 warn('Malformed link in', self.fn + ':', txt)
508 self.output_debug('DATA', (txt,))
512 txt = SPACE_DOUBLE_DASH_RE.sub(NBR_SPACE[0] + r'--\1', txt).replace('--', NBR_DASH[0]*2)
513 txt = NON_SPACE_SINGLE_DASH_RE.sub(r'\1' + NBR_DASH[0], txt)
516 txt = WHITESPACE_RE.sub(NBR_SPACE[0], txt)
517 html = html.replace(NBR_DASH[0], '-').replace(NBR_SPACE[0], ' ') # <code> is non-breaking in CSS
518 st.html_out.append(html.replace(NBR_SPACE[0], ' ').replace(NBR_DASH[0], '-⁠'))
522 def add_targets(self, tag, txt, suf=None):
524 tag = '<' + tag + '>'
525 targets = CODE_BLOCK_RE.findall(txt)
530 txt = txt2target(txt, st.opt_prefix)
535 if txt in st.created_hashtags:
536 for j in range(2, 1000):
537 chk = txt + '-' + str(j)
538 if chk not in st.created_hashtags:
539 print('Made link target unique:', chk)
544 while st.html_out[tag_pos] != tag:
546 st.html_out[tag_pos] = tag[:-1] + ' id="' + txt + '">'
547 st.html_out.append('<a href="#' + txt + '" class="tgt"></a>')
548 tag_pos -= 1 # take into account the append
550 st.html_out[tag_pos] = '<span id="' + txt + '"></span>' + st.html_out[tag_pos]
551 st.created_hashtags.add(txt)
552 st.latest_targets = targets
555 def output_debug(self, event, extra):
559 st = argparse.Namespace(**vars(st))
560 if len(st.html_out) > 2:
561 st.html_out = ['...'] + st.html_out[-2:]
562 if len(st.man_out) > 2:
563 st.man_out = ['...'] + st.man_out[-2:]
565 pprint.PrettyPrinter(indent=2).pprint(vars(st))
568 def txt2target(txt, opt_prefix):
569 txt = txt.strip().rstrip(':')
570 m = CODE_BLOCK_RE.search(txt)
573 txt = NBR_DASH_RE.sub('-', txt)
574 txt = BIN_CHARS_RE.sub('', txt)
575 txt = INVALID_TARGET_CHARS_RE.sub('_', txt)
576 if opt_prefix and txt.startswith('-'):
577 txt = opt_prefix + txt
579 txt = INVALID_START_CHAR_RE.sub(r't\1', txt)
584 return MANIFY_LINESTART_RE.sub(r'\&\1', txt.replace('\\', '\\\\')
585 .replace(NBR_SPACE[0], NBR_SPACE[1])
586 .replace(NBR_DASH[0], NBR_DASH[1])
587 .replace(NORM_FONT[0], NORM_FONT[1])
588 .replace(BOLD_FONT[0], BOLD_FONT[1])
589 .replace(UNDR_FONT[0], UNDR_FONT[1]))
593 return txt.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
597 print(*msg, file=sys.stderr)
607 if __name__ == '__main__':
608 parser = argparse.ArgumentParser(description="Output html and (optionally) nroff for markdown pages.", add_help=False)
609 parser.add_argument('--test', action='store_true', help="Just test the parsing without outputting any files.")
610 parser.add_argument('--dest', metavar='DIR', help="Put files into DIR instead of the current directory.")
611 parser.add_argument('--debug', '-D', action='count', default=0, help='Output copious info on the html parsing. Repeat for even more.')
612 parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.")
613 parser.add_argument("mdfiles", nargs='+', help="The source .md files to convert.")
614 args = parser.parse_args()
618 md_parser = cmarkgfm.markdown_to_html
619 gfm_parser = cmarkgfm.github_flavored_markdown_to_html
623 md_parser = html_via_commonmark
625 die("Failed to find cmarkgfm or commonmark for python3.")