#!/usr/bin/env python3 """Local dev server for the Klein ML blog, with an in-browser editor for posts. python3 dev/server.py # http://127.0.0.1:8000 python3 dev/server.py --port 8001 # another port Serves the site like `python3 -m http.server`. HTML pages get dev/editor.js added on the way out, so the files on disk stay clean. Open a post, click "Edit page", change the title, byline or article text, then "Save" (or Ctrl+S). Saving rewrites the title/byline and article sections of that .html file. Localhost only. Nothing here is used by the published Hugging Face Space. """ import argparse import json import re from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path HERE = Path(__file__).resolve().parent EDITOR = HERE / "editor.js" INJECT = b'\n' # Regions the editor may rewrite. Everything else in the file is left as it is. BAND_RE = re.compile(r'(
)(.*?)(
\s*)(.*?)()', re.S) class Handler(SimpleHTTPRequestHandler): root = HERE.parent # replaced in main() def __init__(self, *args, **kwargs): super().__init__(*args, directory=str(Handler.root), **kwargs) def _send(self, code, body, ctype): self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) def _json(self, code, obj): self._send(code, json.dumps(obj).encode("utf-8"), "application/json; charset=utf-8") def do_GET(self): path = self.path.split("?", 1)[0] if path == "/__dev/editor.js": return self._send(200, EDITOR.read_bytes(), "text/javascript; charset=utf-8") target = Path(self.translate_path(path)) if target.is_dir(): target = target / "index.html" if target.suffix == ".html" and target.is_file(): html = target.read_bytes() html = html.replace(b"", INJECT, 1) if b"" in html else html + INJECT return self._send(200, html, "text/html; charset=utf-8") return super().do_GET() def do_POST(self): if self.path != "/__dev/save": return self._json(404, {"error": "not found"}) # A custom header and a JSON body make the browser check cross-site requests first, # so other sites can't write to your files through this server. if self.headers.get("X-Dev-Editor") != "1" or "application/json" not in (self.headers.get("Content-Type") or ""): return self._json(400, {"error": "bad request"}) try: size = int(self.headers.get("Content-Length", "0")) data = json.loads(self.rfile.read(size) or b"{}") root = Handler.root.resolve() page = (root / str(data["page"]).lstrip("/")).resolve() if not page.is_relative_to(root) or page.suffix != ".html" or not page.is_file(): raise ValueError("not a page in this site") text = page.read_text(encoding="utf-8") if "band" in data: text, n = BAND_RE.subn(lambda m: m.group(1) + "\n " + str(data["band"]).strip() + "\n" + m.group(3), text, count=1) if n == 0: raise ValueError("this page has no title block to save into") if "article" in data: text, n = ARTICLE_RE.subn(lambda m: m.group(1) + "\n" + str(data["article"]).strip() + "\n " + m.group(3), text, count=1) if n == 0: raise ValueError("this page has no article to save into") page.write_text(text, encoding="utf-8") return self._json(200, {"ok": True, "page": str(page.relative_to(root))}) except (KeyError, ValueError, OSError, json.JSONDecodeError) as e: return self._json(400, {"error": str(e)}) def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--port", type=int, default=8000) ap.add_argument("--root", default=str(HERE.parent), help="site folder (default: the repo)") args = ap.parse_args() Handler.root = Path(args.root).resolve() srv = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) print(f"Serving {Handler.root} at http://127.0.0.1:{args.port}") print("Open a post and click 'Edit page'. Ctrl+S or 'Save' writes it back to the .html file.") srv.serve_forever() if __name__ == "__main__": main()