790141bafe
Neue Informationsarchitektur: - ARCHIV (/archiv) = die fertigen Texte (vormals Library): Essays mit Byline, Quellen/Zitieren, Dialog, Versionsverlauf. Section "archiv". - LIBRARY (/library) = das verlinkte Werkstattwissen (vormals Wiki): zwei- spaltig mit Gruppen-Navigation + Filter. Section "library". Umgesetzt: - content/ + layouts/ verschoben (git mv), Menü (ARCHIV+LIBRARY, kein WIKI), Startseiten-Journal zieht jetzt Section "archiv", Querverweise umgeschrieben (/library→/archiv, /wiki→/library). - CMS: files.js klassifiziert archiv/<sec>→beitrag, library/→biblio; stats.js + Admin (Typ "Library-Seite", KIND_LABEL, Pfade) nachgezogen. - single.html: Byline/Provenance/Dialog an Section "archiv" gebunden. - Beide Header zentriert (section-header) — einheitlicher Look. - Interne Dialog-Werte (thread.kind='library', Forum "Beiträge") unverändert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
124 lines
4.1 KiB
JavaScript
124 lines
4.1 KiB
JavaScript
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 (archiv/<section>/<slug>.md) | Library-Seite (library/<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] === 'archiv' && parts.length === 3) {
|
|
return { kind: 'beitrag', section: parts[1] };
|
|
}
|
|
if (parts[0] === 'library') {
|
|
return { kind: 'biblio', section: 'library' };
|
|
}
|
|
return { kind: 'seite', section: null };
|
|
}
|
|
|
|
// authors-Frontmatter zu Array normalisieren (String oder Array erlaubt).
|
|
export function normAuthors(a) {
|
|
if (Array.isArray(a)) return a.map(String).filter(Boolean);
|
|
if (a) return [String(a)];
|
|
return [];
|
|
}
|
|
|
|
// Hat diese E-Mail Zugriff (steht sie in der authors-Liste)?
|
|
export function hasAccess(authors, email) {
|
|
const e = (email || '').toLowerCase();
|
|
return normAuthors(authors).some((a) => a.toLowerCase() === e);
|
|
}
|
|
|
|
// 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('/');
|
|
// Autor-Seiten werden über „Profil" verwaltet, nicht im Inhalts-Editor.
|
|
if (rel === 'authors' || rel.startsWith('authors/')) continue;
|
|
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,
|
|
authors: normAuthors(data.authors),
|
|
url: urlFor(rel),
|
|
});
|
|
}
|
|
// Beiträge zuerst, dann Library, Seiten, Rubriken; je nach Datum/Titel.
|
|
const order = { beitrag: 0, biblio: 1, seite: 2, rubrik: 3 };
|
|
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;
|
|
}
|