123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- #!/usr/bin/env python3
- # Copyright 2015 The Chromium Authors. All rights reserved.
- # Use of this source code is governed by a BSD-style license that can be
- # found in the LICENSE file.
- """Simple Markdown browser for a Git checkout."""
- import http.server
- import socketserver
- import argparse
- import codecs
- import html
- import os
- import re
- import socket
- import sys
- import threading
- import time
- import urllib.request, urllib.parse, urllib.error
- import webbrowser
- from xml.etree import ElementTree
- THIS_DIR = os.path.realpath(os.path.dirname(__file__))
- SRC_DIR = os.path.dirname(os.path.dirname(THIS_DIR))
- sys.path.insert(0, os.path.join(SRC_DIR, 'third_party', 'Python-Markdown'))
- import markdown
- def main(argv):
- parser = argparse.ArgumentParser(prog='md_browser')
- parser.add_argument('-p', '--port', type=int, default=8080,
- help='port to run on (default = %(default)s)')
- parser.add_argument('-d', '--directory', type=str, default=SRC_DIR)
- parser.add_argument('-e', '--external', action='store_true',
- help='whether to bind to external port')
- parser.add_argument('file', nargs='?',
- help='open file in browser')
- args = parser.parse_args(argv)
- top_level = os.path.realpath(args.directory)
- hostname = '0.0.0.0' if args.external else 'localhost'
- server_address = (hostname, args.port)
- s = Server(server_address, top_level)
- origin = 'http://' + hostname
- if args.port != 80:
- origin += ':%s' % args.port
- print('Listening on %s/' % origin)
- thread = None
- if args.file:
- path = os.path.realpath(args.file)
- if not path.startswith(top_level):
- print('%s is not under %s' % (args.file, args.directory))
- return 1
- rpath = os.path.relpath(path, top_level)
- url = '%s/%s' % (origin, rpath)
- print('Opening %s' % url)
- thread = threading.Thread(target=_open_url, args=(url,))
- thread.start()
- elif os.path.isfile(os.path.join(top_level, 'docs', 'README.md')):
- print(' Try loading %s/docs/README.md' % origin)
- elif os.path.isfile(os.path.join(args.directory, 'README.md')):
- print(' Try loading %s/README.md' % origin)
- retcode = 1
- try:
- s.serve_forever()
- except KeyboardInterrupt:
- retcode = 130
- except Exception as e:
- print('Exception raised: %s' % str(e))
- s.shutdown()
- if thread:
- thread.join()
- return retcode
- def _open_url(url):
- time.sleep(1)
- webbrowser.open(url)
- def _gitiles_slugify(value, _separator):
- """Convert a string (representing a section title) to URL anchor name.
- This function is passed to "toc" extension as an extension option, so we
- can emulate the way how Gitiles converts header titles to URL anchors.
- Gitiles' official documentation about the conversion is at:
- https://gerrit.googlesource.com/gitiles/+/master/Documentation/markdown.md#Named-anchors
- Args:
- value: The name of a section that is to be converted.
- _separator: Unused. This is actually a configurable string that is used
- as a replacement character for spaces in the title, typically set to
- '-'. Since we emulate Gitiles' way of slugification here, it makes
- little sense to have the separator charactor configurable.
- """
- # TODO(yutak): Implement accent removal. This does not seem easy without
- # some library. For now we just make accented characters turn into
- # underscores, just like other non-ASCII characters.
- def decode_escaped_chars(regex_match):
- # Python-Markdown encodes escaped sequences (ex. "\_") as "\x02 (integer
- # ascii code) \x03". We decode the integer ascii code to align with Gitiles
- # behavior (ex. 95 -> '_').
- return chr(int(regex_match.group(1)))
- # Non-ASCII turns into '?'.
- value = value.encode('ascii', 'replace').decode('ascii')
- value = re.sub('\x02(\\d+)\x03', decode_escaped_chars, value)
- value = re.sub(r'[^- a-zA-Z0-9]', '_', value) # Non-alphanumerics to '_'.
- value = value.replace(' ', '-')
- value = re.sub(r'([-_])[-_]+', r'\1', value) # Fold hyphens and underscores.
- return value
- class Server(socketserver.TCPServer):
- def __init__(self, server_address, top_level):
- socketserver.TCPServer.__init__(self, server_address, Handler)
- self.top_level = top_level
- def server_bind(self):
- self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- self.socket.bind(self.server_address)
- class Handler(http.server.SimpleHTTPRequestHandler):
- def do_GET(self):
- self.path = urllib.parse.unquote(self.path)
- path = self.path
- # strip off the repo and branch info, if present, for compatibility
- # with gitiles.
- if path.startswith('/chromium/src/+/master'):
- path = path[len('/chromium/src/+/master'):]
- full_path = os.path.normpath(os.path.join(self.server.top_level, path[1:]))
- if not full_path.startswith(self.server.top_level):
- self._DoUnknown()
- elif path in ('/base.css', '/doc.css', '/prettify.css'):
- self._DoCSS(path[1:])
- elif not os.path.exists(full_path):
- self._DoNotFound()
- elif path.lower().endswith('.md'):
- self._DoMD(path)
- elif os.path.exists(full_path + '/README.md'):
- separator = '/'
- if path.endswith('/'):
- separator = ''
- self._DoMD(path + separator + 'README.md')
- elif path.lower().endswith('.png'):
- self._DoImage(full_path, 'image/png')
- elif path.lower().endswith('.jpg'):
- self._DoImage(full_path, 'image/jpeg')
- elif path.lower().endswith('.svg'):
- self._DoImage(full_path, 'image/svg+xml')
- elif os.path.isdir(full_path):
- self._DoDirListing(full_path)
- elif os.path.exists(full_path):
- self._DoRawSourceFile(full_path)
- else:
- self._DoUnknown()
- def _DoMD(self, path):
- extensions = [
- 'markdown.extensions.def_list',
- 'markdown.extensions.fenced_code',
- 'markdown.extensions.tables',
- 'markdown.extensions.toc',
- 'gitiles_autolink',
- 'gitiles_ext_blocks',
- 'gitiles_smart_quotes',
- ]
- extension_configs = {
- 'markdown.extensions.toc': {
- 'slugify': _gitiles_slugify
- },
- }
- contents = self._Read(path[1:])
- md = markdown.Markdown(extensions=extensions,
- extension_configs=extension_configs,
- tab_length=4,
- output_format='html4')
- has_a_single_h1 = (len([line for line in contents.splitlines()
- if (line.startswith('#') and
- not line.startswith('##'))]) == 1)
- md.treeprocessors.register(_AdjustTOC(has_a_single_h1), 'adjust_toc', 4)
- md_fragment = md.convert(contents)
- try:
- self._WriteHeader('text/html')
- self._WriteTemplate('header.html')
- self._Write('<div class="doc">')
- self._Write(md_fragment)
- self._Write('</div>')
- self._WriteTemplate('footer.html')
- except:
- raise
- def _DoRawSourceFile(self, full_path):
- self._WriteHeader('text/html')
- self._WriteTemplate('header.html')
- self._Write('<table class="FileContents">')
- with open(full_path) as fp:
- # Escape html over the entire file at once.
- data = fp.read().replace(
- '&', '&').replace(
- '<', '<').replace(
- '>', '>').replace(
- '"', '"')
- for i, line in enumerate(data.splitlines(), start=1):
- self._Write(
- ('<tr class="u-pre u-monospace FileContents-line">'
- '<td class="u-lineNum u-noSelect FileContents-lineNum">'
- '<a name="%(num)s" '
- 'onclick="window.location.hash=%(quot)s#%(num)s%(quot)s">'
- '%(num)s</a></td>'
- '<td class="FileContents-lineContents">%(line)s</td></tr>') % {
- 'num': i,
- 'quot': "'",
- 'line': line
- })
- self._Write('</table>')
- self._WriteTemplate('footer.html')
- def _DoCSS(self, template):
- self._WriteHeader('text/css')
- self._WriteTemplate(template)
- def _DoNotFound(self):
- self._WriteHeader('text/html', status_code=404)
- self._Write('<html><body>%s not found</body></html>' %
- html.escape(self.path))
- def _DoUnknown(self):
- self._WriteHeader('text/html', status_code=501)
- self._Write('<html><body>I do not know how to serve %s.</body>'
- '</html>' % html.escape(self.path))
- def _DoDirListing(self, full_path):
- self._WriteHeader('text/html')
- self._WriteTemplate('header.html')
- self._Write('<div class="doc">')
- self._Write('<div class="Breadcrumbs">\n')
- self._Write('<a class="Breadcrumbs-crumb">%s</a>\n' %
- html.escape(self.path))
- self._Write('</div>\n')
- escaped_dir = html.escape(self.path.rstrip('/'), quote=True)
- for _, dirs, files in os.walk(full_path):
- for f in sorted(files):
- if f.startswith('.'):
- continue
- f = html.escape(f, quote=True)
- if f.endswith('.md'):
- bold = ('<b>', '</b>')
- else:
- bold = ('', '')
- self._Write('<a href="%s/%s">%s%s%s</a><br/>\n' %
- (escaped_dir, f, bold[0], f, bold[1]))
- self._Write('<br/>\n')
- for d in sorted(dirs):
- if d.startswith('.'):
- continue
- d = html.escape(d, quote=True)
- self._Write('<a href="%s/%s">%s/</a><br/>\n' % (escaped_dir, d, d))
- break
- self._Write('</div>')
- self._WriteTemplate('footer.html')
- def _DoImage(self, full_path, mime_type):
- self._WriteHeader(mime_type)
- with open(full_path, 'rb') as f:
- self.wfile.write(f.read())
- def _Read(self, relpath, relative_to=None):
- if relative_to is None:
- relative_to = self.server.top_level
- assert not relpath.startswith(os.sep)
- path = os.path.join(relative_to, relpath)
- with codecs.open(path, encoding='utf-8') as fp:
- return fp.read()
- def _Write(self, contents):
- self.wfile.write(contents.encode('utf-8'))
- def _WriteHeader(self, content_type='text/plain', status_code=200):
- self.send_response(status_code)
- self.send_header('Content-Type', content_type)
- self.end_headers()
- def _WriteTemplate(self, template):
- contents = self._Read(os.path.join('tools', 'md_browser', template),
- relative_to=SRC_DIR)
- self._Write(contents)
- class _AdjustTOC(markdown.treeprocessors.Treeprocessor):
- def __init__(self, has_a_single_h1):
- super(_AdjustTOC, self).__init__()
- self.has_a_single_h1 = has_a_single_h1
- def run(self, tree):
- # Given
- #
- # # H1
- #
- # [TOC]
- #
- # ## first H2
- #
- # ## second H2
- #
- # the markdown.extensions.toc extension generates:
- #
- # <div class='toc'>
- # <ul><li><a>H1</a>
- # <ul><li>first H2
- # <li>second H2</li></ul></li><ul></div>
- #
- # for [TOC]. But, we want the TOC to have its own subheading, so
- # we rewrite <div class='toc'><ul>...</ul></div> to:
- #
- # <div class='toc'>
- # <h2>Contents</h2>
- # <div class='toc-aux'>
- # <ul>...</ul></div></div>
- #
- # In addition, if the document only has a single H1, it is usually the
- # title, and we don't want the title to be in the TOC. So, we remove it
- # and shift all of the title's children up a level, leaving:
- #
- # <div class='toc'>
- # <h2>Contents</h2>
- # <div class='toc-aux'>
- # <ul><li>first H2
- # <li>second H2</li></ul></div></div>
- for toc_node in tree.findall(".//*[@class='toc']"):
- toc_ul = toc_node[0]
- if self.has_a_single_h1:
- toc_ul_li = toc_ul[0]
- ul_with_the_desired_toc_entries = toc_ul_li[1]
- else:
- ul_with_the_desired_toc_entries = toc_ul
- toc_node.remove(toc_ul)
- contents = ElementTree.SubElement(toc_node, 'h2')
- contents.text = 'Contents'
- contents.tail = '\n'
- toc_aux = ElementTree.SubElement(toc_node, 'div', {'class': 'toc-aux'})
- toc_aux.text = '\n'
- toc_aux.append(ul_with_the_desired_toc_entries)
- toc_aux.tail = '\n'
- if __name__ == '__main__':
- sys.exit(main(sys.argv[1:]))
|