#!/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'', 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("%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
# Rohe HTML-Zeilen (z.B.
,
")
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("
")
in_list, list_tag = True, "ul"
out.append("
%s
" % inline(_escape(m.group(2))))
i += 1
continue
m = re.match(r"^\d+\.\s+(.*)$", stripped)
if m:
if not in_list or list_tag != "ol":
close_list()
out.append("")
in_list, list_tag = True, "ol"
out.append("
%s
" % inline(_escape(m.group(1))))
i += 1
continue
if stripped.startswith(">"):
close_list()
quote = [stripped.lstrip("> ")]
while i + 1 < len(lines) and lines[i + 1].strip().startswith(">"):
i += 1
quote.append(lines[i].strip().lstrip("> "))
out.append("
%s
" % 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
" % inline(_escape(" ".join(para))))
i += 1
close_list()
while sec_stack:
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%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.append(level)
elif level > levels[-1]:
# tiefer -> neue verschachtelte Liste im letzten
parts.append("
")
levels.append(level)
else:
# gleiche oder hoehere Ebene -> Listen schliessen bis Ebene passt
while len(levels) > 1 and levels[-1] > level:
parts.append("
")
while len(levels) > 1:
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:
-
Bild aus images/ einfügen:
"""
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()