#!/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()