Skip to content

Instantly share code, notes, and snippets.

@mbrc12
Created August 7, 2026 06:01
Show Gist options
  • Select an option

  • Save mbrc12/5ce66de6c9c957ac2dd8d7c8df406ef5 to your computer and use it in GitHub Desktop.

Select an option

Save mbrc12/5ce66de6c9c957ac2dd8d7c8df406ef5 to your computer and use it in GitHub Desktop.
server for md and code files
#!/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