Files
2026-10-03 11:51:03 +02:00

544 lines
18 KiB
Python
Executable File

#!/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 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.
"""
lines = text.replace("\r\n", "\n").split("\n")
out = []
i = 0
in_code = False
in_list = False
list_tag = None
used_ids = {}
def inline(s):
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", s)
s = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", s)
s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', 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("<pre><code>")
in_code = True
else:
out.append("</code></pre>")
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. <div>, <svg>) 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((hid, text_h))
out.append('<h%d id="%s">%s</h%d>'
% (level, hid, inline(_escape(text_h)), level))
i += 1
continue
if re.match(r"^-{3,}$", stripped):
close_list()
out.append("<hr>")
i += 1
continue
m = re.match(r"^([-*+])\s+(.*)$", stripped)
if m:
if not in_list or list_tag != "ul":
close_list()
out.append("<ul>")
in_list, list_tag = True, "ul"
out.append("<li>%s</li>" % 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("<ol>")
in_list, list_tag = True, "ol"
out.append("<li>%s</li>" % 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("<blockquote><p>%s</p></blockquote>" % 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("<p>%s</p>" % inline(_escape(" ".join(para))))
i += 1
close_list()
return "\n".join(out)
def _escape(s):
return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
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):
"""Baut die Anker-Linkliste (Text-Links auf die Ueberschriften der Seite)."""
if not headings:
return ""
return "".join('<a href="#%s">%s</a>' % (hid, _escape(text))
for hid, text in headings)
# ------------------------------------------------------------------- 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=""):
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("{{inhalt}}", html))
if "{{anker}}" in template:
page = page.replace("{{anker}}", anker_html)
elif anker_html and "{{header}}" in template:
# Template ohne {{anker}}-Platzhalter: Anker ans Ende des Headers
page = page.replace("{{header}}",
header_html + '\n<nav class="anker-nav">' + anker_html + "</nav>")
if "{{header}}" in template:
page = page.replace("{{header}}", header_html)
elif header_html and "<body>" in page:
# Template ohne {{header}}-Platzhalter: Baustein automatisch setzen
idx = page.index("<body>") + len("<body>")
page = page[:idx] + "\n<header>\n" + header_html + "\n</header>\n" + page[idx:]
if "{{footer}}" in template:
page = page.replace("{{footer}}", footer_html)
elif footer_html and "</body>" in page:
# Template ohne {{footer}}-Platzhalter: Baustein automatisch anhaengen
idx = page.rindex("</body>")
page = page[:idx] + "<footer>\n" + footer_html + "\n</footer>\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 = block_html(FOOTER_PAGE)
header_html = block_html(HEADER_PAGE)
exported = []
for slug in list_pages():
page, _ = render_page(load_page(slug), template, header_html, 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,
"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/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 = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Mini-CMS</title>
<style>
body { font-family: -apple-system, "Segoe UI", sans-serif; margin: 0; display: flex; height: 100vh; }
#side { width: 220px; background: #f2f2f2; padding: 1rem; box-sizing: border-box; }
#side h1 { font-size: 1rem; }
#pages a { display: block; padding: .35rem .5rem; border-radius: 4px; cursor: pointer; text-decoration: none; color: #000; }
#pages a.active, #pages a:hover { background: #ddd; }
#main { flex: 1; display: flex; flex-direction: column; padding: 1rem; box-sizing: border-box; gap: .5rem; }
#toolbar { display: flex; gap: .5rem; align-items: center; }
button { padding: .45rem .9rem; border: 1px solid #999; border-radius: 4px; background: #fff; cursor: pointer; }
button.primary { background: #0066cc; color: #fff; border-color: #0066cc; }
input[type=text] { padding: .45rem; border: 1px solid #999; border-radius: 4px; }
#editor { flex: 1; width: 100%; font-family: monospace; font-size: .9rem; padding: .5rem; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; resize: none; }
#preview { flex: 1; overflow: auto; border: 1px solid #ccc; border-radius: 4px; padding: 1rem; display: none; }
#msg { min-height: 1.2rem; font-size: .85rem; color: #060; }
</style>
</head>
<body>
<div id="side">
<h1>Seiten</h1>
<div id="pages"></div>
<hr>
<p style="font-size:.8rem">Neue Seite:</p>
<input type="text" id="newslug" placeholder="z.B. ueber-uns" style="width:100%;box-sizing:border-box;padding:.35rem">
<button id="btn-new" style="width:100%;margin-top:.4rem">Neu</button>
</div>
<div id="main">
<div id="toolbar">
<b id="current">-</b>
<button id="btn-save" class="primary">Speichern</button>
<button id="btn-preview">Vorschau</button>
<button id="btn-export" class="primary">Nach output/ exportieren</button>
<button id="btn-delete" style="margin-left:auto">Seite l&ouml;schen</button>
</div>
<div id="msg"></div>
<textarea id="editor" spellcheck="false">---
titel: Neue Seite
---
Hier den Inhalt schreiben. **fett**, *kursiv*, [Link](seite.html), # Ueberschrift, - Liste.</textarea>
<div id="preview"></div>
</div>
<script>
let pages = [], current = null;
const $ = id => document.getElementById(id);
const msg = t => { $("msg").textContent = t; setTimeout(() => $("msg").textContent = "", 4000); };
async function api(path, opts) {
const r = await fetch(path, opts);
return r.json();
}
async function loadPages() {
const d = await api("/api/pages");
pages = d.pages;
$("pages").innerHTML = "";
for (const p of pages) {
const a = document.createElement("a");
a.textContent = p + (p === d.start ? " (Start)" : "");
a.onclick = () => openPage(p);
if (p === current) a.className = "active";
$("pages").appendChild(a);
}
for (const b of [["header", d.header], ["footer", d.footer]]) {
if (b[1]) {
const a = document.createElement("a");
a.textContent = b[0] + " (Baustein)";
a.onclick = () => openPage(b[0]);
if (current === b[0]) a.className = "active";
$("pages").appendChild(a);
}
}
}
async function openPage(slug) {
const d = await api("/api/page/" + encodeURIComponent(slug));
current = slug;
$("current").textContent = slug;
$("editor").value = d.raw;
$("editor").disabled = false;
loadPages();
}
async function save() {
if (!current) return;
const d = await api("/api/page", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({slug: current, raw: $("editor").value})
});
msg(d.ok ? "Gespeichert." : ("Fehler: " + d.error));
if (d.ok) loadPages();
}
$("btn-save").onclick = save;
$("btn-delete").onclick = async () => {
if (!confirm("Seite '" + current + "' loeschen?")) return;
const d = await api("/api/delete", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({slug: current})
});
msg(d.ok ? "Geloescht." : ("Fehler: " + d.error));
current = null; $("current").textContent = "-"; $("editor").disabled = true;
loadPages();
};
$("btn-new").onclick = async () => {
const slug = $("newslug").value.trim().toLowerCase();
if (!slug) return msg("Bitte Namen eingeben.");
const d = await api("/api/page", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({slug, raw: "---\\ntitel: " + slug + "\\n---\\n\\nNeue Seite.\\n"})
});
if (d.ok) { $("newslug").value = ""; openPage(slug); }
else msg("Fehler: " + d.error);
};
$("btn-preview").onclick = async () => {
const ed = $("editor"), pv = $("preview");
if (pv.style.display === "block") {
pv.style.display = "none"; ed.style.display = "block"; return;
}
const d = await api("/api/preview", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({raw: ed.value})
});
pv.innerHTML = d.html; pv.style.display = "block"; ed.style.display = "none";
};
$("btn-export").onclick = async () => {
const d = await api("/api/export");
msg(d.exported ? "Exportiert: " + d.exported.join(", ") : ("Fehler: " + d.error));
};
loadPages();
</script>
</body>
</html>"""
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()