#!/usr/bin/env python3 """Serve Markdown files under a directory as GitHub-styled HTML pages over a local web server.""" import argparse import html import http.server import json import sys import time import urllib.parse from pathlib import Path from python_helpers.console import COLOR_GREEN, COLOR_RESET, COLOR_YELLOW GITHUB_MARKDOWN_CSS_URL = "https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css" MARKED_JS_URL = "https://cdn.jsdelivr.net/npm/marked@15/marked.min.js" DOMPURIFY_JS_URL = "https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js" HIGHLIGHT_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" HIGHLIGHT_CSS_LIGHT_URL = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" HIGHLIGHT_CSS_DARK_URL = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" DEFAULT_LISTEN_ADDRESS = "127.0.0.1" DEFAULT_SERVE_PORT = 8000 DEFAULT_CANDIDATES = ["index.md", "README.md"] DEFAULT_WATCH_INTERVAL_SECONDS = 1.0 PYDOC_PREFIX = "pydoc:" class MarkdownServeError(Exception): """A user-facing error.""" def fail(message): """Raise a MarkdownServeError with message. Args: message: error text. """ raise MarkdownServeError(message) def find_index_file(directory): """Return the first of DEFAULT_CANDIDATES that exists as a file under directory, or None. Args: directory: directory to look in. """ return next( (path for path in (directory / candidate for candidate in DEFAULT_CANDIDATES) if path.is_file()), None, ) def resolve_route(route, root_dir, default_file): """Resolve a URL route to a Markdown file under root_dir, or None if it cannot be served. Args: route: URL path, e.g. "/", "/mypage.md", "/hello/", "/hello/notes.md". root_dir: directory routes are resolved relative to. default_file: file to use for the "/" route, or None to fall back to DEFAULT_CANDIDATES. """ relative = urllib.parse.unquote(route).lstrip("/") if relative == "": return default_file if default_file is not None else find_index_file(root_dir) if relative.endswith("/"): return find_index_file(root_dir / relative) candidate = root_dir / relative try: resolved = candidate.resolve() resolved.relative_to(root_dir.resolve()) except ValueError: return None return resolved if resolved.is_file() else None def render_pydoc_markdown(module_name): """Render a dotted Python module name to a Markdown string using pydoc-markdown. Args: module_name: dotted module name, e.g. "cloud_tools.az". """ try: from pydoc_markdown import PydocMarkdown from pydoc_markdown.contrib.loaders.python import PythonLoader except ImportError: fail("pydoc-markdown is not installed; install it to use pydoc: module paths") session = PydocMarkdown() session.loaders = [PythonLoader(modules=[module_name])] modules = session.load_modules() session.process(modules) return session.renderer.render_to_string(modules) def render_page(title, content_query, watch_interval_ms): """Build the HTML page shell that polls /content and renders it as Markdown. Args: title: page title. content_query: query string (including leading '?') appended to the /content request. watch_interval_ms: milliseconds between polls of /content. """ body = f"""
Loading...
""" return f""" {html.escape(title)} {body} """.encode("utf-8") class MarkdownRequestHandler(http.server.BaseHTTPRequestHandler): """Serves a page shell for any Markdown file under root_dir, and its live content at /content. root_dir, default_file, and watch_interval_ms are set as class attributes before the server starts, since HTTPServer instantiates request handlers itself with a fixed signature. """ root_dir = None default_file = None pydoc_module = None watch_interval_ms = 0 def send_json(self, payload): content_bytes = json.dumps(payload).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(content_bytes))) self.end_headers() self.wfile.write(content_bytes) def send_not_found(self, message): body = message.encode("utf-8") self.send_response(404) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): parsed = urllib.parse.urlsplit(self.path) if self.pydoc_module is not None: if parsed.path == "/content": self.send_json({"mtime": time.time(), "text": render_pydoc_markdown(self.pydoc_module), "error": None}) return content_query = "?" + urllib.parse.urlencode({"path": parsed.path}) page_bytes = render_page(self.pydoc_module, content_query, self.watch_interval_ms) self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(page_bytes))) self.end_headers() self.wfile.write(page_bytes) return if parsed.path == "/content": query = urllib.parse.parse_qs(parsed.query) route = query.get("path", [""])[0] markdown_path = resolve_route(route, self.root_dir, self.default_file) if markdown_path is None: self.send_json({"mtime": None, "text": None, "error": f"no such file for route '{route}'"}) return try: mtime = markdown_path.stat().st_mtime text = markdown_path.read_text(encoding="utf-8") self.send_json({"mtime": mtime, "text": text, "error": None}) except OSError as error: self.send_json({"mtime": None, "text": None, "error": str(error)}) return markdown_path = resolve_route(parsed.path, self.root_dir, self.default_file) if markdown_path is None: self.send_not_found(f"no Markdown file found for '{parsed.path}'") return content_query = "?" + urllib.parse.urlencode({"path": parsed.path}) page_bytes = render_page(markdown_path.name, content_query, self.watch_interval_ms) self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(page_bytes))) self.end_headers() self.wfile.write(page_bytes) def log_message(self, format_str, *log_args): pass def main(): """CLI entry point: parse args, resolve the document root, and serve Markdown files under it.""" parser = argparse.ArgumentParser( description="Render Markdown files under a directory as GitHub-styled pages and serve them locally." ) parser.add_argument( "path", nargs="?", help=( "Markdown file or directory to serve; defaults to the current directory. " "A directory is served at its own URL path, resolving to index.md or README.md within it. " f"Prefix with '{PYDOC_PREFIX}' and a dotted module name (e.g. '{PYDOC_PREFIX}cloud_tools.az') " "to render that module's docs with pydoc-markdown instead" ), ) parser.add_argument( "--listen-address", default=DEFAULT_LISTEN_ADDRESS, help=f"Address for the local web server to listen on (default: {DEFAULT_LISTEN_ADDRESS})", ) parser.add_argument( "--port", type=int, default=DEFAULT_SERVE_PORT, help=f"Port for the local web server (default: {DEFAULT_SERVE_PORT})", ) parser.add_argument( "--watch-interval", type=float, default=DEFAULT_WATCH_INTERVAL_SECONDS, help=( "Seconds between checks for changes to the file, polled by the browser page " f"(default: {DEFAULT_WATCH_INTERVAL_SECONDS})" ), ) args = parser.parse_args() try: default_file = None pydoc_module = None source_description = None if args.path and args.path.startswith(PYDOC_PREFIX): pydoc_module = args.path[len(PYDOC_PREFIX):] if not pydoc_module: fail("pydoc: requires a module name, e.g. 'pydoc:cloud_tools.az'") render_pydoc_markdown(pydoc_module) root_dir = None source_description = f"pydoc module '{pydoc_module}'" elif args.path: given_path = Path(args.path) if given_path.is_file(): root_dir = given_path.parent default_file = given_path elif given_path.is_dir(): root_dir = given_path else: fail(f"'{given_path}' does not exist or is not a file or directory") else: root_dir = Path.cwd() if pydoc_module is None: if default_file is None and find_index_file(root_dir) is None: fail(f"no file given and none of {', '.join(DEFAULT_CANDIDATES)} found in '{root_dir}'") source_description = f"'{root_dir}'" watch_interval_ms = round(args.watch_interval * 1000) MarkdownRequestHandler.root_dir = root_dir MarkdownRequestHandler.default_file = default_file MarkdownRequestHandler.pydoc_module = pydoc_module MarkdownRequestHandler.watch_interval_ms = watch_interval_ms try: server = http.server.HTTPServer((args.listen_address, args.port), MarkdownRequestHandler) except OSError as error: fail(f"could not start server on {args.listen_address}:{args.port} ({error}); try a different --listen-address/--port") url = f"http://{args.listen_address}:{args.port}/" print(f"{COLOR_GREEN}Serving at {url} (Ctrl+C to stop){COLOR_RESET}") print(f"{COLOR_GREEN}Document root: {source_description}, watching for changes every {args.watch_interval}s{COLOR_RESET}") try: server.serve_forever() except KeyboardInterrupt: print() print(f"{COLOR_YELLOW}Stopping server{COLOR_RESET}") finally: server.server_close() except MarkdownServeError as error: print(f"Error: {error}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()