Created
August 7, 2026 06:01
-
-
Save mbrc12/5ce66de6c9c957ac2dd8d7c8df406ef5 to your computer and use it in GitHub Desktop.
server for md and code files
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| r"""serv — a tiny static file server with rendered Markdown and source files. | |
| Serves the directory it is started in. Requests for *.md files are | |
| converted to standalone HTML via pandoc (KaTeX enabled; \(...\) and | |
| \[...\] math delimiters supported; mermaid diagrams rendered). Known | |
| source-code extensions are served as HTML with Prism syntax highlighting | |
| (from jsDelivr) and line numbers. Everything else is served normally. | |
| The local stylesheet is served from memory at /__serv__/style.css, and | |
| the mermaid header snippet is written to a temp file at startup (pandoc's | |
| --include-in-header needs a real path). To change styling, edit STYLE_CSS | |
| below and restart. | |
| Usage: | |
| serv [--port PORT] # default port: 1002 | |
| """ | |
| import argparse | |
| import html | |
| import http.server | |
| import io | |
| import os | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import time | |
| import urllib.parse | |
| CSS_URL = "/__serv__/style.css" | |
| PRISM_BASE_URL = "https://cdn.jsdelivr.net/npm/prismjs@1.29.0" | |
| # Extension-to-Prism-language map. Add a suffix here to render another | |
| # text-based source format as a highlighted, line-numbered HTML page. | |
| CODE_EXTENSIONS = { | |
| ".asm": "asm", | |
| ".bash": "bash", | |
| ".c": "c", | |
| ".cc": "cpp", | |
| ".cjs": "javascript", | |
| ".cpp": "cpp", | |
| ".cs": "csharp", | |
| ".css": "css", | |
| ".dockerfile": "docker", | |
| ".ex": "elixir", | |
| ".exs": "elixir", | |
| ".fish": "bash", | |
| ".go": "go", | |
| ".h": "c", | |
| ".hpp": "cpp", | |
| ".hs": "haskell", | |
| ".java": "java", | |
| ".js": "javascript", | |
| ".json": "json", | |
| ".jsonl": "json", | |
| ".json5": "json5", | |
| ".jsx": "jsx", | |
| ".kt": "kotlin", | |
| ".kts": "kotlin", | |
| ".lua": "lua", | |
| ".mjs": "javascript", | |
| ".php": "php", | |
| ".pl": "perl", | |
| ".pm": "perl", | |
| ".py": "python", | |
| ".pyi": "python", | |
| ".r": "r", | |
| ".rb": "ruby", | |
| ".rs": "rust", | |
| ".scala": "scala", | |
| ".sh": "bash", | |
| ".sql": "sql", | |
| ".swift": "swift", | |
| ".toml": "toml", | |
| ".txt": "plain", | |
| ".ts": "typescript", | |
| ".tsx": "tsx", | |
| ".vue": "markup", | |
| ".xml": "markup", | |
| ".yaml": "yaml", | |
| ".yml": "yaml", | |
| ".zsh": "bash", | |
| } | |
| # Components that Prism's small core bundle does not include. The values | |
| # are loaded in dependency order after the core bundle. | |
| PRISM_COMPONENTS = { | |
| "asm": ("nasm",), | |
| "bash": ("bash",), | |
| "c": ("c",), | |
| "cpp": ("c", "cpp"), | |
| "csharp": ("csharp",), | |
| "docker": ("docker",), | |
| "elixir": ("elixir",), | |
| "go": ("go",), | |
| "haskell": ("haskell",), | |
| "java": ("java",), | |
| "json": ("json",), | |
| "json5": ("json", "json5"), | |
| "jsx": ("jsx",), | |
| "kotlin": ("kotlin",), | |
| "lua": ("lua",), | |
| "perl": ("perl",), | |
| "php": ("markup-templating", "php"), | |
| "python": ("python",), | |
| "r": ("r",), | |
| "ruby": ("ruby",), | |
| "rust": ("rust",), | |
| "scala": ("scala",), | |
| "sql": ("sql",), | |
| "swift": ("swift",), | |
| "toml": ("toml",), | |
| "tsx": ("jsx", "typescript", "tsx"), | |
| "typescript": ("typescript",), | |
| "yaml": ("yaml",), | |
| } | |
| STYLE_CSS = """\ | |
| @import url('https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400;1,700&display=swap'); | |
| @import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&display=swap'); | |
| html { | |
| background-color: #f5f5f4; /* tailwind stone-100 */ | |
| } | |
| body { | |
| max-width: 60rem; | |
| color: #292524; /* tailwind stone-800 */ | |
| font-family: 'Atkinson Hyperlegible', system-ui, -apple-system, 'Segoe UI', sans-serif; | |
| font-size: 1.125rem; | |
| line-height: 1.65; | |
| margin-left: auto; | |
| margin-right: auto; | |
| } | |
| h1, h2, h3, h4, h5, h6 { | |
| font-weight: 600; | |
| line-height: 1.25; | |
| } | |
| h1 { font-size: 2rem; } | |
| code, pre, kbd, samp { | |
| font-family: 'Fira Code', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; | |
| font-size: 0.95rem; | |
| } | |
| pre { | |
| padding: 0.75em 1em; | |
| overflow-x: auto; | |
| } | |
| /* Bare source view: no code block, panel, or scroll container. */ | |
| body.code-page { | |
| max-width: none; | |
| min-height: 100vh; | |
| margin: 0; | |
| font: 12px/1.55 'Fira Code', ui-monospace, monospace; | |
| } | |
| .code-page pre[class*="language-"] { | |
| margin: 0; | |
| padding: 1rem 1rem 1rem 5.6em; | |
| background: transparent; | |
| color: inherit; | |
| font: inherit; | |
| white-space: pre-wrap; | |
| overflow: visible; | |
| overflow-wrap: anywhere; | |
| tab-size: 4; | |
| } | |
| /* Override the general inline-code size for the actual source text. */ | |
| .code-page pre[class*="language-"] > code { | |
| font: inherit; | |
| } | |
| .code-page .line-numbers .line-numbers-rows { | |
| left: -5.6em; | |
| width: 4.5em; | |
| border-right-color: #d0d7de; | |
| } | |
| /* A small GitHub-like palette; Prism supplies only the token classes. */ | |
| .code-page .token.comment { color: #6e7781; } | |
| .code-page .token.keyword, | |
| .code-page .token.selector { color: #cf222e; } | |
| .code-page .token.string, | |
| .code-page .token.attr-value { color: #0a3069; } | |
| .code-page .token.function, | |
| .code-page .token.class-name { color: #8250df; } | |
| .code-page .token.number, | |
| .code-page .token.boolean, | |
| .code-page .token.property { color: #0550ae; } | |
| blockquote { | |
| font-style: italic; | |
| } | |
| a, a:visited { | |
| color: #92400e; /* tailwind amber-800 */ | |
| } | |
| a:hover { | |
| color: #b45309; /* tailwind amber-700 */ | |
| } | |
| img { max-width: 100%; } | |
| .katex-display { | |
| overflow-x: auto; | |
| overflow-y: hidden; | |
| padding: 0.25em 0; | |
| } | |
| """ | |
| MERMAID_HEADER = """\ | |
| <!-- mermaid support: renders <pre class="mermaid"> blocks produced by pandoc --> | |
| <script type="module"> | |
| import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs'; | |
| // pandoc wraps fenced code in <code>…</code>; mermaid wants the bare text | |
| document.querySelectorAll('pre.mermaid > code:only-child').forEach(code => { | |
| code.parentElement.textContent = code.textContent; | |
| }); | |
| mermaid.initialize({ startOnLoad: false, theme: 'neutral' }); | |
| mermaid.run({ querySelector: 'pre.mermaid' }); | |
| </script> | |
| """ | |
| # Set in main(); pandoc's --include-in-header requires a real file path. | |
| HEADER_FILE = None | |
| def format_file_size(size): | |
| """Return a compact, readable file size.""" | |
| units = ("B", "KiB", "MiB", "GiB", "TiB") | |
| value = float(size) | |
| for unit in units: | |
| if value < 1024 or unit == units[-1]: | |
| return f"{int(value)} {unit}" if unit == "B" else f"{value:.1f} {unit}" | |
| value /= 1024 | |
| class Handler(http.server.SimpleHTTPRequestHandler): | |
| def do_GET(self): | |
| clean_path = self.path.split("?", 1)[0] | |
| if clean_path == CSS_URL: | |
| self.serve_stylesheet() | |
| return | |
| path = self.translate_path(clean_path) | |
| if path.lower().endswith(".md") and os.path.isfile(path): | |
| self.serve_markdown(path) | |
| return | |
| language = CODE_EXTENSIONS.get(os.path.splitext(path)[1].lower()) | |
| if language and os.path.isfile(path): | |
| self.serve_code(path, language) | |
| return | |
| super().do_GET() | |
| def list_directory(self, path): | |
| """Serve the normal link list, with aligned modification and size fields.""" | |
| try: | |
| names = sorted(os.listdir(path), key=str.lower) | |
| except OSError: | |
| self.send_error(404, "No permission to list directory") | |
| return None | |
| try: | |
| display_path = urllib.parse.unquote(self.path, errors="surrogatepass") | |
| except UnicodeDecodeError: | |
| display_path = urllib.parse.unquote(self.path) | |
| display_path = html.escape(display_path, quote=False) | |
| title = f"Directory listing for {display_path}" | |
| items = [] | |
| for name in names: | |
| full_name = os.path.join(path, name) | |
| display_name = link_name = name | |
| is_directory = os.path.isdir(full_name) | |
| if is_directory: | |
| display_name = link_name = name + "/" | |
| if os.path.islink(full_name): | |
| display_name += "@" | |
| try: | |
| stat = os.stat(full_name) | |
| modified = time.strftime("%Y-%m-%d %H:%M", time.localtime(stat.st_mtime)) | |
| size = "—" if is_directory else format_file_size(stat.st_size) | |
| except OSError: | |
| modified = size = "—" | |
| href = urllib.parse.quote(link_name, errors="surrogatepass") | |
| items.append( | |
| '<li><a class="name" href="%s">%s</a><time>%s</time><span class="size">%s</span></li>' | |
| % (href, html.escape(display_name, quote=False), modified, size) | |
| ) | |
| up_entry = """<li><a class=\"name\" href=\"../\" aria-label=\"Parent directory\" title=\"Parent directory\"><svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M8 13V3M4 7l4-4 4 4\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg></a><span></span><span></span></li>""" | |
| page = f"""<!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>{title}</title> | |
| <link rel="stylesheet" href="{CSS_URL}"> | |
| <style> | |
| .directory-list {{ list-style: none; padding: 0; }} | |
| .directory-list li {{ | |
| display: grid; | |
| grid-template-columns: 1fr 17ch 9ch; | |
| gap: 1rem; | |
| padding: 0.2rem 0.4rem; | |
| }} | |
| .directory-list li:hover {{ background: #e7e5e4; }} | |
| .directory-list .size {{ text-align: right; }} | |
| </style> | |
| </head> | |
| <body> | |
| <h1>{title}</h1> | |
| <ul class="directory-list"> | |
| {up_entry}{''.join(items)} | |
| </ul> | |
| </body> | |
| </html> | |
| """.encode("utf-8") | |
| self.send_response(200) | |
| self.send_header("Content-Type", "text/html; charset=utf-8") | |
| self.send_header("Content-Length", str(len(page))) | |
| self.end_headers() | |
| return io.BytesIO(page) | |
| def serve_stylesheet(self): | |
| body = STYLE_CSS.encode("utf-8") | |
| self.send_response(200) | |
| self.send_header("Content-Type", "text/css; charset=utf-8") | |
| self.send_header("Content-Length", str(len(body))) | |
| self.send_header("Cache-Control", "no-cache") # always re-fetch | |
| self.end_headers() | |
| self.wfile.write(body) | |
| def serve_code(self, path, language): | |
| try: | |
| with open(path, "r", encoding="utf-8", errors="replace") as f: | |
| source = f.read() | |
| except OSError as exc: | |
| self.send_error(500, f"could not read source file: {exc}") | |
| return | |
| title = html.escape(os.path.relpath(path, os.getcwd())) | |
| code = html.escape(source) | |
| components = "".join( | |
| f'<script src="{PRISM_BASE_URL}/components/prism-{component}.min.js"></script>' | |
| for component in PRISM_COMPONENTS.get(language, ()) | |
| ) | |
| page = f"""<!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>{title}</title> | |
| <link rel="stylesheet" href="{PRISM_BASE_URL}/plugins/line-numbers/prism-line-numbers.min.css"> | |
| <link rel="stylesheet" href="{CSS_URL}"> | |
| </head> | |
| <body class="code-page"> | |
| <pre class="line-numbers language-{language}"><code class="language-{language}">{code}</code></pre> | |
| <script src="{PRISM_BASE_URL}/prism.min.js"></script> | |
| {components} | |
| <script src="{PRISM_BASE_URL}/plugins/line-numbers/prism-line-numbers.min.js"></script> | |
| </body> | |
| </html> | |
| """.encode("utf-8") | |
| self.send_response(200) | |
| self.send_header("Content-Type", "text/html; charset=utf-8") | |
| self.send_header("Content-Length", str(len(page))) | |
| self.end_headers() | |
| self.wfile.write(page) | |
| def serve_markdown(self, path): | |
| try: | |
| result = subprocess.run( | |
| [ | |
| "pandoc", | |
| "-f", "markdown+tex_math_single_backslash", | |
| "-t", "html5", | |
| "--standalone", | |
| "--katex", | |
| "--css", CSS_URL, | |
| "--include-in-header", HEADER_FILE, | |
| path, | |
| ], | |
| capture_output=True, | |
| timeout=30, | |
| ) | |
| except FileNotFoundError: | |
| self.send_error(500, "pandoc is not installed or not on PATH") | |
| return | |
| except subprocess.TimeoutExpired: | |
| self.send_error(500, "pandoc timed out converting this file") | |
| return | |
| if result.returncode != 0: | |
| err = result.stderr.decode("utf-8", "replace").strip() | |
| self.send_error(500, f"pandoc failed: {err}") | |
| return | |
| body = result.stdout | |
| self.send_response(200) | |
| self.send_header("Content-Type", "text/html; charset=utf-8") | |
| self.send_header("Content-Length", str(len(body))) | |
| self.end_headers() | |
| self.wfile.write(body) | |
| def main(): | |
| global HEADER_FILE | |
| parser = argparse.ArgumentParser( | |
| prog="serv", | |
| description="Static file server; renders .md files via pandoc (--standalone --katex).", | |
| ) | |
| parser.add_argument( | |
| "--port", type=int, default=1002, help="port to listen on (default: 1002)" | |
| ) | |
| args = parser.parse_args() | |
| # pandoc's --include-in-header needs a real file; keep it for the | |
| # lifetime of the process. | |
| with tempfile.NamedTemporaryFile( | |
| "w", suffix=".html", prefix="serv-header-", delete=False | |
| ) as f: | |
| f.write(MERMAID_HEADER) | |
| HEADER_FILE = f.name | |
| root = os.getcwd() | |
| server = http.server.ThreadingHTTPServer(("", args.port), Handler) | |
| print(f"serving {root} at http://localhost:{args.port}/") | |
| try: | |
| server.serve_forever() | |
| except KeyboardInterrupt: | |
| print("\nstopped") | |
| server.server_close() | |
| sys.exit(0) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment