#!/usr/bin/env python3
"""Mini-CMS: MD-Dateien in md/ per Browseroberflaeche bearbeiten
und als statische Seite (HTML + CSS) nach output/ exportieren.
Nur Python-Standardbibliothek, kein Internet noetig.
Aufruf:
python3 cms.py -> Server + UI starten (http://localhost:8500)
python3 cms.py export -> nur exportieren, kein Server
python3 cms.py port 9000 -> Server auf anderem Port
"""
import json
import os
import re
import shutil
import sys
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote, urlparse
ROOT = os.path.dirname(os.path.abspath(__file__))
MD_DIR = os.path.join(ROOT, "md")
TPL_DIR = os.path.join(ROOT, "templates")
CSS_DIR = os.path.join(ROOT, "css")
OUT_DIR = os.path.join(ROOT, "output")
PORT = 8500
for d in (MD_DIR, TPL_DIR, CSS_DIR, OUT_DIR):
os.makedirs(d, exist_ok=True)
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$", re.IGNORECASE)
START_PAGE = "startseite"
FOOTER_PAGE = "footer" # Baustein: wird in jede Seite eingefuegt, nicht eigenstaendig exportiert
# ---------------------------------------------------------------- markdown
def md_to_html(text):
"""Minimaler Markdown->HTML-Konverter:
Ueberschriften, **fett**, *kursiv*, `code`, [Links](url),
Listen, Code-Bloecke, > Zitate, --- Trennlinien, Absaetze.
"""
lines = text.replace("\r\n", "\n").split("\n")
out = []
i = 0
in_code = False
in_list = False
list_tag = None
def inline(s):
s = re.sub(r"`([^`]+)`", r"\1", s)
s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
s = re.sub(r"\*([^*]+)\*", r"\1", s)
s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', s)
return s
def close_list():
nonlocal in_list, list_tag
if in_list:
out.append("%s>" % list_tag)
in_list = False
list_tag = None
while i < len(lines):
line = lines[i]
if line.strip().startswith("```"):
close_list()
if not in_code:
out.append("
")
in_code = True
else:
out.append("")
in_code = False
i += 1
continue
if in_code:
out.append(_escape(line))
i += 1
continue
stripped = line.strip()
if not stripped:
close_list()
i += 1
continue
m = re.match(r"^(#{1,6})\s+(.*)$", stripped)
if m:
close_list()
level = len(m.group(1))
out.append("" % inline(_escape(" ".join(quote)))) i += 1 continue close_list() para = [stripped] while (i + 1 < len(lines) and lines[i + 1].strip() and not re.match(r"^(#{1,6}\s|[-*+]\s|\d+\.\s|>|```|-{3,}$)", lines[i + 1].strip())): i += 1 para.append(lines[i].strip()) out.append("%s
%s
" % inline(_escape(" ".join(para)))) i += 1 close_list() return "\n".join(out) def _escape(s): return (s.replace("&", "&").replace("<", "<").replace(">", ">")) # ------------------------------------------------------------------- seiten def parse_md(raw): """Trennt Front-Matter (titel: ...) vom Inhalt.""" meta = {} body = raw if raw.startswith("---"): parts = raw.split("---", 2) if len(parts) >= 3: for line in parts[1].strip().splitlines(): if ":" in line: k, v = line.split(":", 1) meta[k.strip().lower()] = v.strip() body = parts[2].lstrip("\n") return meta, body def md_path(slug): return os.path.join(MD_DIR, slug + ".md") def list_pages(): pages = [] for f in sorted(os.listdir(MD_DIR)): if f.endswith(".md") and f[:-3] != FOOTER_PAGE: pages.append(f[:-3]) return pages def load_page(slug): with open(md_path(slug), encoding="utf-8") as fh: return fh.read() def save_page(slug, raw): with open(md_path(slug), "w", encoding="utf-8") as fh: fh.write(raw) def delete_page(slug): p = md_path(slug) if os.path.exists(p): os.remove(p) # ------------------------------------------------------------------ export def render_page(raw, template, footer_html=""): meta, body = parse_md(raw) html = md_to_html(body) page = (template .replace("{{titel}}", _escape(meta.get("titel", ""))) .replace("{{inhalt}}", html)) if "{{footer}}" in template: page = page.replace("{{footer}}", footer_html) elif footer_html and "Neue Seite:
" in page: # Template ohne {{footer}}-Platzhalter: Baustein automatisch anhaengen idx = page.rindex("") page = page[:idx] + "\n" + page[idx:] return page, meta.get("titel", "") def load_template(): files = [f for f in os.listdir(TPL_DIR) if f.endswith(".html")] if not files: raise SystemExit("Kein Template in templates/ gefunden (z.B. default.html).") with open(os.path.join(TPL_DIR, files[0]), encoding="utf-8") as fh: return fh.read() def export_site(): """Baut output/: pro MD-Datei eine HTML-Seite, CSS wird kopiert.""" template = load_template() # Output leeren und neu aufbauen if os.path.isdir(OUT_DIR): shutil.rmtree(OUT_DIR) os.makedirs(os.path.join(OUT_DIR, "css"), exist_ok=True) for f in os.listdir(CSS_DIR): if f.endswith(".css"): shutil.copy2(os.path.join(CSS_DIR, f), os.path.join(OUT_DIR, "css", f)) footer_html = "" if os.path.exists(md_path(FOOTER_PAGE)): _, fbody = parse_md(load_page(FOOTER_PAGE)) footer_html = md_to_html(fbody) exported = [] for slug in list_pages(): page, _ = render_page(load_page(slug), template, footer_html) # startseite.md wird zur index.html out_name = "index.html" if slug == START_PAGE else slug + ".html" with open(os.path.join(OUT_DIR, out_name), "w", encoding="utf-8") as fh: fh.write(page) exported.append(out_name) return exported # ------------------------------------------------------------------- server class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass def _send(self, code, body, ctype="application/json; charset=utf-8"): data = body.encode("utf-8") if isinstance(body, str) else body self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def _json(self, obj, code=200): self._send(code, json.dumps(obj)) def _body(self): n = int(self.headers.get("Content-Length", 0)) return json.loads(self.rfile.read(n).decode("utf-8")) if n else {} def do_GET(self): path = urlparse(self.path).path if path in ("/", "/admin"): self._send(200, ADMIN_HTML, "text/html; charset=utf-8") elif path == "/api/pages": self._json({"pages": list_pages(), "start": START_PAGE, "footer": os.path.exists(md_path(FOOTER_PAGE))}) elif path.startswith("/api/page/"): slug = unquote(path[len("/api/page/"):]) if slug == FOOTER_PAGE or slug in list_pages(): self._json({"slug": slug, "raw": load_page(slug)}) else: self._json({"error": "Seite nicht gefunden"}, 404) elif path == "/api/export": self._json({"exported": export_site()}) else: self._json({"error": "unbekannter Pfad"}, 404) def do_POST(self): path = urlparse(self.path).path try: data = self._body() except (ValueError, json.JSONDecodeError): return self._json({"error": "ungueltige Anfrage"}, 400) if path == "/api/page": slug = (data.get("slug") or "").strip() raw = data.get("raw") or "" if not SLUG_RE.match(slug): return self._json({"error": "Ungueltiger Name (a-z, 0-9, -)."}, 400) save_page(slug, raw) self._json({"ok": True, "slug": slug}) elif path == "/api/delete": slug = data.get("slug", "") if slug != FOOTER_PAGE and slug not in list_pages(): return self._json({"error": "Seite nicht gefunden"}, 404) if slug in (START_PAGE, FOOTER_PAGE): return self._json({"error": "Startseite und Footer-Baustein koennen nicht geloescht werden."}, 400) delete_page(slug) self._json({"ok": True}) elif path == "/api/preview": meta, body = parse_md(data.get("raw") or "") self._json({"html": md_to_html(body)}) else: self._json({"error": "unbekannter Pfad"}, 404) ADMIN_HTML = """