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:
@@ -1,37 +0,0 @@
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { rowToMarkdown } from './render.js';
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
|
||||
// Erlaubt nur sichere, einfache Segmente — verhindert Path-Traversal über
|
||||
// section/slug aus der DB.
|
||||
function safeSegment(value, label) {
|
||||
if (!value || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(String(value))) {
|
||||
throw new Error(`Ungültiger ${label}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Posts leben unter content/library/<section>/<slug>.md → URL /library/<section>/<slug>/.
|
||||
const CONTENT_BASE = 'library';
|
||||
|
||||
export function postPath(post) {
|
||||
const section = safeSegment(post.section, 'section');
|
||||
const slug = safeSegment(post.slug, 'slug');
|
||||
return path.join(SITE_DIR, 'content', CONTENT_BASE, section, `${slug}.md`);
|
||||
}
|
||||
|
||||
// Schreibt die generierte MD nach content/<section>/<slug>.md.
|
||||
// draft:true -> Live-Build (ohne --buildDrafts) lässt den Post aus.
|
||||
// draft:false -> Post ist live.
|
||||
export async function writePostFile(post, { draft = false } = {}) {
|
||||
const file = postPath(post);
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(file, rowToMarkdown(post, { draft }), 'utf8');
|
||||
return file;
|
||||
}
|
||||
|
||||
export async function removePostFile(post) {
|
||||
await rm(postPath(post), { force: true });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { serve } from '@hono/node-server';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import posts from './routes/posts.js';
|
||||
import content from './routes/content.js';
|
||||
import preview from './routes/preview.js';
|
||||
import publish from './routes/publish.js';
|
||||
import upload from './routes/upload.js';
|
||||
@@ -18,7 +18,7 @@ const app = new Hono();
|
||||
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
|
||||
// Alles unter /api/* (ausser /health oben) braucht ein gültiges Supabase-Token.
|
||||
app.use('/api/*', requireAuth);
|
||||
app.route('/api/posts', posts);
|
||||
app.route('/api/content', content);
|
||||
app.route('/api/preview', preview);
|
||||
app.route('/api/publish', publish);
|
||||
app.route('/api/upload', upload);
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import matter from 'gray-matter';
|
||||
|
||||
// Eine posts-Zeile aus Supabase -> Hugo-Markdown (Frontmatter + Body).
|
||||
// Mappt exakt die Felder, die OPENBUREAU im content/ nutzt. Nur gesetzte
|
||||
// Felder landen im Frontmatter, damit die MD sauber bleibt.
|
||||
export function rowToMarkdown(post, { draft = false } = {}) {
|
||||
const fm = {
|
||||
title: post.title,
|
||||
// date als reines YYYY-MM-DD ausgeben (wie in den bestehenden Posts).
|
||||
date: toDateOnly(post.date),
|
||||
};
|
||||
|
||||
if (post.weight != null) fm.weight = post.weight;
|
||||
if (Array.isArray(post.tags) && post.tags.length) fm.tags = post.tags;
|
||||
if (post.summary) fm.summary = post.summary;
|
||||
if (post.cover_image) fm.cover_image = post.cover_image;
|
||||
if (post.layout) fm.layout = post.layout;
|
||||
if (post.external) fm.external = post.external;
|
||||
if (post.color) fm.color = post.color;
|
||||
if (draft) fm.draft = true;
|
||||
|
||||
return matter.stringify(post.body || '', fm);
|
||||
}
|
||||
|
||||
function toDateOnly(d) {
|
||||
if (!d) return undefined;
|
||||
// Akzeptiert Date, ISO-String oder "YYYY-MM-DD".
|
||||
const s = typeof d === 'string' ? d : new Date(d).toISOString();
|
||||
return s.slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Hono } from 'hono';
|
||||
import { listEntries, readEntry, writeEntry, entryExists } from '../files.js';
|
||||
|
||||
// Dateibasiert: liest/schreibt die echten .md unter content/.
|
||||
const content = new Hono();
|
||||
|
||||
// Liste aller Einträge (Beiträge, Seiten, Rubriken).
|
||||
content.get('/', async (c) => {
|
||||
try { return c.json(await listEntries()); }
|
||||
catch (e) { return c.json({ error: String(e.message || e) }, 500); }
|
||||
});
|
||||
|
||||
// Einen Eintrag lesen: /api/content/entry?path=library/software/stack.md
|
||||
content.get('/entry', async (c) => {
|
||||
try { return c.json(await readEntry(c.req.query('path'))); }
|
||||
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||
});
|
||||
|
||||
// Anlegen oder überschreiben.
|
||||
content.put('/entry', async (c) => {
|
||||
const { path: rel, frontmatter, body } = await c.req.json();
|
||||
try {
|
||||
const created = !(await entryExists(rel));
|
||||
const saved = await writeEntry(rel, frontmatter, body);
|
||||
return c.json({ ok: true, path: saved, created });
|
||||
} catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||
});
|
||||
|
||||
export default content;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
|
||||
// Minimales CRUD, damit der Publish/Preview-Flow ohne UI testbar ist.
|
||||
// (Auth-Middleware kommt im nächsten Meilenstein.)
|
||||
const posts = new Hono();
|
||||
|
||||
posts.get('/', async (c) => {
|
||||
const { data, error } = await supabase
|
||||
.from('posts')
|
||||
.select('*')
|
||||
.order('date', { ascending: false });
|
||||
if (error) return c.json({ error: error.message }, 500);
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
posts.get('/:id', async (c) => {
|
||||
const { data, error } = await supabase
|
||||
.from('posts')
|
||||
.select('*')
|
||||
.eq('id', c.req.param('id'))
|
||||
.single();
|
||||
if (error) return c.json({ error: error.message }, 404);
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
posts.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { data, error } = await supabase
|
||||
.from('posts')
|
||||
.insert({ ...body, status: 'draft' })
|
||||
.select()
|
||||
.single();
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json(data, 201);
|
||||
});
|
||||
|
||||
posts.put('/:id', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { data, error } = await supabase
|
||||
.from('posts')
|
||||
.update({ ...body, updated_at: new Date().toISOString() })
|
||||
.eq('id', c.req.param('id'))
|
||||
.select()
|
||||
.single();
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
export default posts;
|
||||
@@ -1,24 +1,17 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { writePostFile } from '../content.js';
|
||||
import { urlFor, safeRel } from '../files.js';
|
||||
import { hugoBuild } from '../hugo.js';
|
||||
|
||||
// Echte Hugo-Vorschau: Post als draft:true in content/ schreiben und mit
|
||||
// --buildDrafts nach preview/ bauen. Der Live-Build (public/) lässt den
|
||||
// Draft weiterhin aus.
|
||||
// Echte Hugo-Vorschau: ganze Site mit --buildDrafts nach preview/ bauen und die
|
||||
// URL des Eintrags zurückgeben (so erscheinen auch draft:true-Einträge).
|
||||
const preview = new Hono();
|
||||
|
||||
preview.post('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { data: post, error } = await supabase
|
||||
.from('posts').select('*').eq('id', id).single();
|
||||
if (error || !post) return c.json({ error: 'Post nicht gefunden' }, 404);
|
||||
|
||||
preview.post('/', async (c) => {
|
||||
const { path: rel } = await c.req.json();
|
||||
try {
|
||||
await writePostFile(post, { draft: true });
|
||||
const safe = safeRel(rel);
|
||||
const build = await hugoBuild({ dest: 'preview', drafts: true });
|
||||
const url = `/_preview/library/${post.section}/${post.slug}/`;
|
||||
return c.json({ ok: true, url, hugo: build.stdout });
|
||||
return c.json({ ok: true, url: `/_preview${urlFor(safe)}`, hugo: build.stdout });
|
||||
} catch (e) {
|
||||
return c.json({ error: String(e.message || e) }, 500);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,17 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { writePostFile } from '../content.js';
|
||||
import { urlFor, safeRel } from '../files.js';
|
||||
import { hugoBuild, gitCommit } from '../hugo.js';
|
||||
|
||||
// Publizieren: Post als live (draft:false) nach content/ schreiben, public/
|
||||
// neu bauen, Status setzen und optional nach Gitea committen.
|
||||
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
|
||||
const publish = new Hono();
|
||||
|
||||
publish.post('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { data: post, error } = await supabase
|
||||
.from('posts').select('*').eq('id', id).single();
|
||||
if (error || !post) return c.json({ error: 'Post nicht gefunden' }, 404);
|
||||
|
||||
publish.post('/', async (c) => {
|
||||
const { path: rel } = await c.req.json();
|
||||
try {
|
||||
const file = await writePostFile(post, { draft: false });
|
||||
const safe = safeRel(rel);
|
||||
const build = await hugoBuild({ dest: 'public', drafts: false });
|
||||
|
||||
const { error: upErr } = await supabase
|
||||
.from('posts')
|
||||
.update({ status: 'published', published_at: new Date().toISOString() })
|
||||
.eq('id', id);
|
||||
if (upErr) return c.json({ error: upErr.message }, 500);
|
||||
|
||||
const git = await gitCommit(`cms: publish ${post.section}/${post.slug}`)
|
||||
.catch((e) => ({ error: String(e.message || e) }));
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
path: file.replace(process.env.SITE_DIR || '/site', '').replace(/^\//, ''),
|
||||
url: `/library/${post.section}/${post.slug}/`,
|
||||
git,
|
||||
hugo: build.stdout,
|
||||
});
|
||||
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) }));
|
||||
return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
|
||||
} catch (e) {
|
||||
return c.json({ error: String(e.message || e) }, 500);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user