Files
hfg_cms/cms.py
T
2026-10-03 13:29:12 +02:00

756 lines
26 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 inline(s):
"""Inline-Markdown: bilder, code, fett, kursiv, Links."""
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r'<img src="\2" alt="\1">', 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 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("<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((level, hid, text_h))
# tiefere Sektionen schliessen, bis die Ebene wieder passt
while sec_stack and sec_stack[-1] >= level:
out.append("</div>")
sec_stack.pop()
# ein # mehr als die umgebende Ueberschrift -> Stufe einruecken
if sec_stack:
out.append('<div class="sec">')
else:
out.append('<div class="sec0">')
out.append('<h%d id="%s">%s</h%d>'
% (level, hid, inline(_escape(text_h)), level))
sec_stack.append(level)
i += 1
continue
if re.match(r"^-{3,}$", stripped):
close_list()
out.append("<hr>")
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("<p>%s</p>" % 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("<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()
while sec_stack:
out.append("</div>")
sec_stack.pop()
return "\n".join(out)
def _escape(s):
return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
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 = ["<table>", "<thead><tr>"]
for j, c in enumerate(split_row(rows[0])):
out.append(cell("th", j, _escape(c)))
out.append("</tr></thead>")
if len(rows) > 2:
out.append("<tbody>")
for r in rows[2:]:
out.append("<tr>")
for j, c in enumerate(split_row(r)):
out.append(cell("td", j, _escape(c)))
out.append("</tr>")
out.append("</tbody>")
out.append("</table>")
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("<ul>")
levels.append(level)
elif level > levels[-1]:
# tiefer -> neue verschachtelte Liste im letzten <li>
parts.append("<ul>")
levels.append(level)
else:
# gleiche oder hoehere Ebene -> Listen schliessen bis Ebene passt
while len(levels) > 1 and levels[-1] > level:
parts.append("</li></ul>")
levels.pop()
parts.append("</li>")
parts.append('<li><a href="%s#%s">%s</a>' % (prefix, hid, _escape(text)))
parts.append("</li>")
while len(levels) > 1:
parts.append("</ul></li>")
levels.pop()
parts.append("</ul>")
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<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(slug=None):
"""Template fuer eine Seite: templates/<slug>.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 = """<!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; position: relative; }
#imgdrop { position: absolute; top: 3.2rem; left: 1rem; background: #fff; border: 1px solid #999; border-radius: 4px; box-shadow: 0 2px 10px rgba(0,0,0,.25); max-height: 220px; overflow: auto; z-index: 20; min-width: 240px; }
#imgdrop .imgdrop-title { font-size: .8rem; padding: .4rem .6rem; background: #eee; }
#imglist a { display: block; padding: .35rem .6rem; cursor: pointer; color: #000; }
#imglist a:hover { background: #dde8ff; }
#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>
<div id="imgdrop" style="display:none">
<div class="imgdrop-title">Bild aus images/ einf&uuml;gen:</div>
<div id="imglist"></div>
</div>
<textarea id="editor" spellcheck="false">---
titel: Neue Seite
beschreibung: Kurze Seitenbeschreibung fuer Suchmaschinen (SEO).
---
Hier den Inhalt schreiben. **fett**, *kursiv*, [Link](seite.html), # Ueberschrift, - Liste.
Das Wort "image" tippen, um ein Bild aus dem images-Ordner einzufuegen.</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 + "\\nbeschreibung: \\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));
};
// ---- Bild einfuegen: "image" tippen -> Dropdown mit Dateien aus images/ ----
let dropGen = 0;
let lastCaret = 0;
function hideImageDrop() {
dropGen++;
$("imgdrop").style.display = "none";
}
async function showImageDrop(caret) {
const gen = ++dropGen;
lastCaret = caret;
const d = await api("/api/images");
if (gen !== dropGen) return; // inzwischen wieder geschlossen
const list = $("imglist");
list.innerHTML = "";
if (!d.images.length) {
const p = document.createElement("div");
p.style.padding = ".4rem .6rem";
p.textContent = "Keine Dateien in images/.";
list.appendChild(p);
}
for (const f of d.images) {
const a = document.createElement("a");
a.textContent = f;
a.onmousedown = e => { e.preventDefault(); insertImage(f); };
list.appendChild(a);
}
$("imgdrop").style.display = "block";
}
function insertImage(name) {
const ed = $("editor");
let pos = lastCaret;
let before = ed.value.slice(0, pos), after = ed.value.slice(pos);
const md = "![" + name.replace(/\.[^.]+$/, "") + "](images/" + name + ")";
// das getippte Wort "image" durch die Bild-Syntax ersetzen
if (before.slice(-5).toLowerCase() === "image") before = before.slice(0, -5);
ed.value = before + md + after;
ed.focus();
ed.selectionStart = ed.selectionEnd = (before + md).length;
hideImageDrop();
}
$("editor").addEventListener("input", () => {
const ed = $("editor");
const pos = ed.selectionStart;
const tail = ed.value.slice(Math.max(0, pos - 5), pos).toLowerCase();
if (tail === "image") showImageDrop(pos);
else hideImageDrop();
});
$("editor").addEventListener("keydown", e => { if (e.key === "Escape") hideImageDrop(); });
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()