File:  [ELWIX - Embedded LightWeight unIX -] / embedaddon / rsync / md2man
Revision 1.1.1.1 (vendor branch): download - view: text, annotated - select for diffs - revision graph
Wed Mar 17 00:32:36 2021 UTC (3 years, 3 months ago) by misho
Branches: rsync, MAIN
CVS tags: v3_2_3, HEAD
rsync 3.2.3

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

FreeBSD-CVSweb <freebsd-cvsweb@FreeBSD.org>