cms: dateibasiert + Editor im Decap/Sveltia-Look

- CMS liest/schreibt jetzt die echten content/**/*.md (gray-matter) statt DB:
  alle bestehenden Beiträge, Seiten und Rubriken erscheinen und sind editierbar.
  Supabase nur noch für Login.
- Admin neu: Collections-Sidebar (Beiträge/Seiten/Rubriken), an OPENBUREAU-Theme
  angeglichen (Newsreader-Serif, Creme, Terracotta, dunkle Topbar).
- Alle Frontmatter-Felder inkl. Farb-Dropdown mit Farbpunkten (Palette aus
  custom.css), Layout, Tags, summary, cover_image, external, toc, draft.
- Markdown-Toolbar: Fett/Kursiv/Unterstrichen/H2/H3/Link/Bild-Upload/Liste/Zitat/Code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 11:51:49 +02:00
parent e7d820b83c
commit e2d986356c
12 changed files with 526 additions and 377 deletions
+103
View File
@@ -0,0 +1,103 @@
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
const SITE_DIR = process.env.SITE_DIR || '/site';
const CONTENT = path.join(SITE_DIR, 'content');
// Pfad-Sicherheit: relativer Pfad innerhalb content/, nur .md.
export function safeRel(rel) {
if (!rel || typeof rel !== 'string') throw new Error('Pfad fehlt');
const norm = path.normalize(rel).split(path.sep).join('/');
if (norm.startsWith('..') || norm.startsWith('/') || norm.includes('../')) {
throw new Error('Ungültiger Pfad');
}
if (!norm.endsWith('.md')) throw new Error('Nur .md erlaubt');
return norm;
}
async function walk(dir) {
const out = [];
for (const e of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await walk(full)));
else if (e.name.endsWith('.md')) out.push(full);
}
return out;
}
// Beitrag (library/<section>/<slug>.md) | Rubrik (_index.md) | Seite (sonst).
function classify(rel) {
const base = path.basename(rel);
const parts = rel.split('/');
if (base === '_index.md') {
const section = parts.length >= 2 ? parts[parts.length - 2] : 'home';
return { kind: 'rubrik', section };
}
if (parts[0] === 'library' && parts.length === 3) {
return { kind: 'beitrag', section: parts[1] };
}
return { kind: 'seite', section: null };
}
// Hugo-URL aus dem relativen Pfad.
export function urlFor(rel) {
let p = rel.replace(/\.md$/, '');
if (p === '_index') return '/';
p = p.replace(/\/_index$/, '');
return '/' + p + '/';
}
export async function listEntries() {
const files = await walk(CONTENT);
const items = [];
for (const full of files) {
const rel = path.relative(CONTENT, full).split(path.sep).join('/');
let data = {};
try { data = matter(await readFile(full, 'utf8')).data || {}; } catch {}
items.push({
path: rel,
title: data.title || rel,
...classify(rel),
color: data.color || null,
layout: data.layout || null,
draft: !!data.draft,
date: data.date ? String(data.date).slice(0, 10) : null,
url: urlFor(rel),
});
}
// Beiträge zuerst, dann Seiten, dann Rubriken; je nach Datum/Titel.
const order = { beitrag: 0, seite: 1, rubrik: 2 };
items.sort((a, b) =>
(order[a.kind] - order[b.kind]) ||
(b.date || '').localeCompare(a.date || '') ||
a.title.localeCompare(b.title));
return items;
}
export async function readEntry(rel) {
rel = safeRel(rel);
const { data, content } = matter(await readFile(path.join(CONTENT, rel), 'utf8'));
return { path: rel, url: urlFor(rel), frontmatter: data || {}, body: content || '' };
}
export async function entryExists(rel) {
try { await stat(path.join(CONTENT, safeRel(rel))); return true; }
catch { return false; }
}
export async function writeEntry(rel, frontmatter = {}, body = '') {
rel = safeRel(rel);
const full = path.join(CONTENT, rel);
await mkdir(path.dirname(full), { recursive: true });
// Leere Werte rauswerfen, damit das Frontmatter sauber bleibt.
const fm = {};
for (const [k, v] of Object.entries(frontmatter)) {
if (v === '' || v === null || v === undefined) continue;
if (Array.isArray(v) && v.length === 0) continue;
fm[k] = v;
}
await writeFile(full, matter.stringify(body || '', fm), 'utf8');
return rel;
}