- Create .gitignore to exclude build artifacts - Add README.md with project description and installation instructions - Implement pyproject.toml for package metadata and script entry points - Add console output helpers for command-line scripts - Implement environment file loading functionality - Add JSON file I/O helpers - Create token decoder CLI for decoding JWT claims - Implement serve-markdown CLI for serving Markdown files as HTML
336 lines
12 KiB
Python
Executable File
336 lines
12 KiB
Python
Executable File
#!/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"""<article id="content" class="markdown-body">Loading...</article>
|
|
<script>
|
|
let lastMtime = null;
|
|
function render(text) {{
|
|
const target = document.getElementById("content");
|
|
if (window.marked && window.DOMPurify) {{
|
|
target.innerHTML = DOMPurify.sanitize(marked.parse(text, {{gfm: true}}));
|
|
if (window.hljs) {{
|
|
target.querySelectorAll("pre code").forEach((block) => hljs.highlightElement(block));
|
|
}}
|
|
}} else {{
|
|
const pre = document.createElement("pre");
|
|
pre.textContent = text;
|
|
target.innerHTML = "";
|
|
target.appendChild(pre);
|
|
}}
|
|
}}
|
|
async function poll() {{
|
|
let data;
|
|
try {{
|
|
data = await (await fetch("/content{content_query}")).json();
|
|
}} catch (error) {{
|
|
return;
|
|
}}
|
|
if (data.mtime !== null && data.mtime !== lastMtime) {{
|
|
lastMtime = data.mtime;
|
|
render(data.text);
|
|
}} else if (data.mtime === null) {{
|
|
render("(file not found: " + data.error + ")");
|
|
}}
|
|
}}
|
|
poll();
|
|
setInterval(poll, {watch_interval_ms});
|
|
</script>"""
|
|
return f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>{html.escape(title)}</title>
|
|
<link rel="stylesheet" href="{GITHUB_MARKDOWN_CSS_URL}">
|
|
<link rel="stylesheet" href="{HIGHLIGHT_CSS_LIGHT_URL}" media="(prefers-color-scheme: light)">
|
|
<link rel="stylesheet" href="{HIGHLIGHT_CSS_DARK_URL}" media="(prefers-color-scheme: dark)">
|
|
<script src="{MARKED_JS_URL}"></script>
|
|
<script src="{DOMPURIFY_JS_URL}"></script>
|
|
<script src="{HIGHLIGHT_JS_URL}"></script>
|
|
<style>
|
|
body {{ margin: 0; background-color: #ffffff; }}
|
|
@media (prefers-color-scheme: dark) {{
|
|
body {{ background-color: #0d1117; }}
|
|
}}
|
|
.markdown-body {{ box-sizing: border-box; max-width: 980px; margin: 0 auto; padding: 45px; }}
|
|
.markdown-body pre {{ white-space: pre; overflow-x: auto; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{body}
|
|
</body>
|
|
</html>
|
|
""".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()
|