diff --git a/.DS_Store b/.DS_Store
index dc8b856..f912345 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/ascii/ascii-logo.md b/ascii/ascii-logo.md
new file mode 100644
index 0000000..7598ce1
--- /dev/null
+++ b/ascii/ascii-logo.md
@@ -0,0 +1,28 @@
+
+
+ &&&& &&&&&
+ +xX&&XX+++x
+ &&$x+xX++x+:;
+ &;;:$&;:;X&&
+ ::::::::::;
+ &&$$$$$$$$$$$;::::x::+:;
+ &&XXX;::::::::::::::::::xxx
+ &&$x+x;::::::::::::::::::X&
+ &&X+++x;::::::::::::::::::X&
+ &&Xxxxx;::::::::::::::::::X&
+ &&&&&&+;::++;::;++;::;++:x&&
+ &$::&&X:;&&&&;:X&&::X&
+ &&xX&&$:;&&&&Xx&&&:;X&
+ &&&&&&&&& &&&& &&&&
+
+
+██╗ ██╗███████╗ ██████╗ ██████╗ ███████╗ ██████╗ ██╗ ██╗██╗██╗ ██████╗ ██╗ ██╗███╗ ███╗
+██║ ██║██╔════╝██╔════╝ ██╔═══██╗██╔════╝ ██╔══██╗██║ ██║██║██║ ██╔══██╗ ██║ ██║████╗ ████║
+███████║█████╗ ██║ ███╗ ██║ ██║███████╗ ██████╔╝██║ ██║██║██║ ██║ ██║ ██║ ██║██╔████╔██║
+██╔══██║██╔══╝ ██║ ██║ ██║ ██║╚════██║ ██╔══██╗██║ ██║██║██║ ██║ ██║ ╚██╗ ██╔╝██║╚██╔╝██║
+██║ ██║██║ ╚██████╔╝███████╗╚██████╔╝███████║ ██████╔╝╚██████╔╝██║███████╗██████╔╝ ╚████╔╝ ██║ ╚═╝ ██║
+╚═╝ ╚═╝╚═╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝╚══════╝╚═════╝ ╚═══╝ ╚═╝ ╚═╝
+
+DEBIAN 13 VM auf w4d-gpu unterm Schreibtisch
+
+
diff --git a/cms.py b/cms.py
new file mode 100755
index 0000000..61612d3
--- /dev/null
+++ b/cms.py
@@ -0,0 +1,482 @@
+#!/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 = """ + +
+ +
+ + +
+