#!/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" HEADER_PAGE = "header" # Baustein: wird ueber den Inhalt jeder Seite gesetzt FOOTER_PAGE = "footer" # Baustein: wird unter den Inhalt jeder Seite gesetzt # ---------------------------------------------------------------- markdown def inline(s): """Inline-Markdown: bilder, code, fett, kursiv, Links.""" 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) s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', s) return s def md_to_html(text, headings=None): """Minimaler Markdown->HTML-Konverter: Ueberschriften, **fett**, *kursiv*, `code`, [Links](url), Listen, Code-Bloecke, > Zitate, --- Trennlinien, Absaetze. Ueberschriften bekommen id-Anker; in headings (Liste) werden optional (anker, text) aller Ueberschriften gesammelt. """ # vergessenes Trigger-Wort "image" direkt vor einem Bild nicht mit ausgeben text = re.sub(r"\bimage\s*(?=!\[)", "", text, flags=re.IGNORECASE) lines = text.replace("\r\n", "\n").split("\n") out = [] i = 0 in_code = False in_list = False list_tag = None used_ids = {} sec_stack = [] # offene Sektionen (pro # mehr eine Ebene, eingerueckt) def close_list(): nonlocal in_list, list_tag if in_list: out.append("" % 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 # Rohe HTML-Zeilen (z.B.
, ) unveraendert durchreichen if stripped.startswith("<"): close_list() out.append(stripped) i += 1 continue m = re.match(r"^(#{1,6})\s+(.*)$", stripped) if m: close_list() level = len(m.group(1)) text_h = m.group(2) hid = _slugify(text_h) if hid in used_ids: used_ids[hid] += 1 hid = "%s-%d" % (hid, used_ids[hid]) else: used_ids[hid] = 0 if headings is not None: headings.append((level, hid, text_h)) # tiefere Sektionen schliessen, bis die Ebene wieder passt while sec_stack and sec_stack[-1] >= level: out.append("
") sec_stack.pop() # ein # mehr als die umgebende Ueberschrift -> Stufe einruecken if sec_stack: out.append('
') else: out.append('
') out.append('%s' % (level, hid, inline(_escape(text_h)), level)) sec_stack.append(level) i += 1 continue if re.match(r"^-{3,}$", stripped): close_list() out.append("
") i += 1 continue # Tabellen: | zelle | zelle | mit --- Trennzeile if stripped.startswith("|"): close_list() rows = [] j = i while j < len(lines) and lines[j].strip().startswith("|"): rows.append(lines[j].strip()) j += 1 tbl = _parse_table(rows) if tbl: out.append(tbl) i = j else: # keine gueltige Tabelle -> als normaler Absatz out.append("

%s

" % inline(_escape(stripped))) i += 1 continue m = re.match(r"^([-*+])\s+(.*)$", stripped) if m: if not in_list or list_tag != "ul": close_list() out.append("
") sec_stack.pop() return "\n".join(out) def _escape(s): return (s.replace("&", "&").replace("<", "<").replace(">", ">")) def _parse_table(rows): """Baut aus |-getrennten Zeilen eine HTML-Tabelle. Zeile 1: Kopfzeile, Zeile 2: Trenner (---, :---, ---:, :---:), danach: Datenzeilen. Liefert None, wenn es keine gueltige Tabelle ist. """ def split_row(r): if r.startswith("|"): r = r[1:] if r.endswith("|"): r = r[:-1] return [c.strip() for c in r.split("|")] def is_sep(c): return re.match(r"^:?-{2,}:?$", c) is not None if len(rows) < 2 or not all(is_sep(c) and c for c in split_row(rows[1])): return None aligns = [] for c in split_row(rows[1]): left, right = c.startswith(":"), c.endswith(":") if left and right: aligns.append("center") elif right: aligns.append("right") else: aligns.append("left") def cell(tag, j, text): style = ' style="text-align: %s"' % aligns[j] if j < len(aligns) else "" return "<%s%s>%s" % (tag, style, inline(text), tag) out = ["", ""] for j, c in enumerate(split_row(rows[0])): out.append(cell("th", j, _escape(c))) out.append("") if len(rows) > 2: out.append("") for r in rows[2:]: out.append("") for j, c in enumerate(split_row(r)): out.append(cell("td", j, _escape(c))) out.append("") out.append("") out.append("
") return "".join(out) def _slugify(s): """Ueberschriftentext -> HTML-id (ankertauglich, klein, ohne Sonderzeichen).""" s = s.lower() for k, v in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")): s = s.replace(k, v) s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") return s or "abschnitt" def heading_nav(headings, prefix=""): """Baut die Anker-Navigation als verschachtelte ul/li-Liste: Eine Ueberschrift mit einem # mehr als die vorherige wird als Unterpunkt eingerueckt. prefix erlaubt Links auf eine andere Seite (z.B. "index.html" -> href="index.html#anker"). headings: Liste von (level, anker, text). """ if not headings: return "" parts = [] levels = [] for level, hid, text in headings: if not levels: # erste Ueberschrift: aeusserste Liste oeffnen parts.append("") levels.pop() parts.append("") return "".join(parts) # ------------------------------------------------------------------- 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] not in (HEADER_PAGE, 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 block_html(slug): """Rendert einen Baustein (header/footer) als HTML, leer wenn nicht vorhanden.""" if os.path.exists(md_path(slug)): _, body = parse_md(load_page(slug)) return md_to_html(body) return "" def render_page(raw, template, header_html="", footer_html="", start_anker_html=""): meta, body = parse_md(raw) headings = [] html = md_to_html(body, headings) anker_html = heading_nav(headings) page = (template .replace("{{titel}}", _escape(meta.get("titel", ""))) .replace("{{beschreibung}}", _escape(meta.get("beschreibung", ""))) .replace("{{inhalt}}", html)) # {{anker-start}}: Navigation der Startseite, verlinkt auf index.html#anker if "{{anker-start}}" in template: page = page.replace("{{anker-start}}", start_anker_html) if "{{anker}}" in template: page = page.replace("{{anker}}", anker_html) elif (anker_html and "{{anker-start}}" not in template and "{{header}}" in template): # Template ganz ohne Anker-Platzhalter: eigene Anker ans Header-Ende page = page.replace("{{header}}", header_html + '\n") if "{{header}}" in template: page = page.replace("{{header}}", header_html) elif header_html and "" in page: # Template ohne {{header}}-Platzhalter: Baustein automatisch setzen idx = page.index("") + len("") page = page[:idx] + "\n
\n" + header_html + "\n
\n" + page[idx:] if "{{footer}}" in template: page = page.replace("{{footer}}", footer_html) elif footer_html and "" 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(slug=None): """Template fuer eine Seite: templates/.html wenn vorhanden, sonst default.html, sonst das erste gefundene Template.""" candidates = [] if slug: candidates.append(os.path.join(TPL_DIR, slug + ".html")) candidates.append(os.path.join(TPL_DIR, "default.html")) for c in candidates: if os.path.exists(c): with open(c, encoding="utf-8") as fh: return fh.read() 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.""" # 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)) # Bilder (z.B. Logo/Favicon) nach output/images/ kopieren img_src = os.path.join(ROOT, "images") if os.path.isdir(img_src): shutil.copytree(img_src, os.path.join(OUT_DIR, "images"), dirs_exist_ok=True) footer_html = block_html(FOOTER_PAGE) header_html = block_html(HEADER_PAGE) # Anker-Navigation der Startseite (fuer Templates mit {{anker-start}}, # z.B. impressum: Links auf index.html#anker) start_headings = [] if os.path.exists(md_path(START_PAGE)): _, s_body = parse_md(load_page(START_PAGE)) md_to_html(s_body, start_headings) start_anker = heading_nav(start_headings, prefix="index.html") exported = [] for slug in list_pages(): template = load_template(slug) page, _ = render_page(load_page(slug), template, header_html, footer_html, start_anker) # 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, "header": os.path.exists(md_path(HEADER_PAGE)), "footer": os.path.exists(md_path(FOOTER_PAGE))}) elif path.startswith("/api/page/"): slug = unquote(path[len("/api/page/"):]) if slug in (HEADER_PAGE, 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/images": img_dir = os.path.join(ROOT, "images") files = [] if os.path.isdir(img_dir): for f in sorted(os.listdir(img_dir)): if os.path.isfile(os.path.join(img_dir, f)) and not f.startswith("."): files.append(f) self._json({"images": files}) 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 not in (HEADER_PAGE, FOOTER_PAGE) and slug not in list_pages(): return self._json({"error": "Seite nicht gefunden"}, 404) if slug in (START_PAGE, HEADER_PAGE, FOOTER_PAGE): return self._json({"error": "Startseite und Bausteine 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 = """ Mini-CMS

Seiten


Neue Seite:

-
""" def main(): global PORT if len(sys.argv) > 1 and sys.argv[1] == "export": files = export_site() print("Exportiert nach output/:") for f in files: print(" -", f) return if len(sys.argv) > 2 and sys.argv[1] == "port": PORT = int(sys.argv[2]) server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler) url = "http://localhost:%d" % PORT print("Mini-CMS laeuft unter", url) print("Beenden mit Strg+C.") threading.Timer(1.0, lambda: webbrowser.open(url)).start() try: server.serve_forever() except KeyboardInterrupt: print("\nServer gestoppt.") if __name__ == "__main__": main()