diff --git a/assets/css/custom.css b/assets/css/custom.css index c0615bb..e566827 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1529,3 +1529,71 @@ img:hover { filter: grayscale(0%); } font-size: var(--font-size-small); color: var(--color-text-muted); } + +/* ------------------------------------------------------------------------ + Versions-Marke + Verlauf — alte Fassungen direkt auf der Seite anzeigen. + ------------------------------------------------------------------------ */ +.version-line { margin: 0.5rem 0 0; } +.version-badge { + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + color: var(--color-text-muted); + background: none; + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 0.12em 0.7em; + cursor: pointer; +} +.version-badge:hover { border-color: var(--accent); color: var(--accent); } +.version-badge[aria-expanded="true"] { border-color: var(--accent); color: var(--accent); } + +.version-panel { + margin-top: 0.5rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-secondary); + padding: 0.3rem; +} +.version-list { list-style: none; margin: 0; padding: 0; } +.version-list button { + display: flex; + gap: 0.9em; + width: 100%; + text-align: left; + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + color: var(--color-text-primary); + background: none; + border: none; + border-radius: 6px; + padding: 0.45em 0.6em; + cursor: pointer; +} +.version-list button:hover { background: var(--color-bg-primary); } +.version-list .v-date { white-space: nowrap; } +.version-list .v-subject { flex: 1; color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.version-list .v-hash { white-space: nowrap; color: var(--color-text-muted); } +.version-empty { margin: 0.4rem 0.6rem; font-size: var(--font-size-small); color: var(--color-text-muted); } + +.version-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6em; + margin-bottom: var(--spacing-md); + padding: 0.5em 0.8em; + border-left: 3px solid var(--accent); + background: var(--color-bg-secondary); + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + color: var(--color-text-muted); +} +.version-back { + font: inherit; + color: var(--accent); + background: none; + border: none; + padding: 0; + cursor: pointer; +} +.version-loading { color: var(--color-text-muted); font-style: italic; } diff --git a/cms/api/package-lock.json b/cms/api/package-lock.json index 3fbcfbd..60e6589 100644 --- a/cms/api/package-lock.json +++ b/cms/api/package-lock.json @@ -12,6 +12,7 @@ "@supabase/supabase-js": "^2.47.10", "gray-matter": "^4.0.3", "hono": "^4.6.14", + "marked": "^14.1.4", "sharp": "^0.33.5" } }, @@ -672,6 +673,18 @@ "node": ">=0.10.0" } }, + "node_modules/marked": { + "version": "14.1.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz", + "integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", diff --git a/cms/api/package.json b/cms/api/package.json index 61c6a14..81eccc6 100644 --- a/cms/api/package.json +++ b/cms/api/package.json @@ -14,6 +14,7 @@ "@supabase/supabase-js": "^2.47.10", "gray-matter": "^4.0.3", "hono": "^4.6.14", + "marked": "^14.1.4", "sharp": "^0.33.5" } } diff --git a/cms/api/src/index.js b/cms/api/src/index.js index c6477b8..23c5f74 100644 --- a/cms/api/src/index.js +++ b/cms/api/src/index.js @@ -12,6 +12,7 @@ import upload from './routes/upload.js'; import profile from './routes/profile.js'; import users from './routes/users.js'; import { listComments, createComment, deleteComment, login } from './routes/comments.js'; +import history from './routes/history.js'; import { listForums, showForum, recent, threadInfo, newThread, mod, adminForums, } from './routes/dialog.js'; @@ -65,6 +66,8 @@ app.get('/api/forums', listForums); app.get('/api/forums/:slug', showForum); app.get('/api/recent', recent); app.get('/api/thread', threadInfo); +// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar. +app.route('/api/history', history); // Login gegen Brute-Force drosseln: max. 10 Versuche/IP pro 5 Minuten. app.post('/api/auth/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), login); // Alles weitere unter /api/* braucht ein gültiges Supabase-Token. diff --git a/cms/api/src/routes/history.js b/cms/api/src/routes/history.js new file mode 100644 index 0000000..918bcc0 --- /dev/null +++ b/cms/api/src/routes/history.js @@ -0,0 +1,70 @@ +import { Hono } from 'hono'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import matter from 'gray-matter'; +import { marked } from 'marked'; +import { safeRel } from '../files.js'; + +// ÖFFENTLICH: Versionsverlauf eines Library-Beitrags aus der Git-History. +// Der Container hat das Repo unter /site gemountet + git installiert. Wir +// holen alte Fassungen on-demand (kein Vorbauen) und zeigen sie auf der Site. +const execFileP = promisify(execFile); +const SITE_DIR = process.env.SITE_DIR || '/site'; +const git = (...args) => execFileP('git', ['-C', SITE_DIR, ...args], { maxBuffer: 10 * 1024 * 1024 }); +const US = '\x1f'; // Feldtrenner (Unit Separator) — kommt in Commit-Daten nicht vor. + +const history = new Hono(); + +// Liste der Versionen: neueste zuerst. +history.get('/', async (c) => { + let rel; + try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); } + try { + const { stdout } = await git( + 'log', '--follow', `--format=%H${US}%h${US}%aI${US}%an${US}%s`, '--', `content/${rel}`); + const versions = stdout.trim().split('\n').filter(Boolean).map((line) => { + const [rev, short, date, author, subject] = line.split(US); + return { rev, short, date, author, subject }; + }); + return c.json(versions); + } catch { return c.json({ error: 'Verlauf nicht verfügbar' }, 500); } +}); + +// Eine bestimmte Fassung gerendert (HTML), zum Anzeigen auf der Seite. +history.get('/version', async (c) => { + let rel; + try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); } + const rev = c.req.query('rev') || ''; + if (!/^[0-9a-f]{7,40}$/i.test(rev)) return c.json({ error: 'Ungültige Version' }, 400); + try { + const { stdout } = await git('show', `${rev}:content/${rel}`); + const { data, content } = matter(stdout); + return c.json({ + rev, + title: data.title || '', + date: data.date ? new Date(data.date).toISOString().slice(0, 10) : null, + html: renderMarkdown(content), + }); + } catch { return c.json({ error: 'Version nicht gefunden' }, 404); } +}); + +// Markdown → HTML. marked kennt Goldmarks Fußnoten ([^id]) nicht — daher +// vorab: Definitionen einsammeln, Verweise zu -Nummern, „Quellen" anhängen +// (greift dieselbe .footnotes-CSS wie die Live-Seite). +function renderMarkdown(md) { + const defs = {}; const order = []; + md = md.replace(/^\[\^([^\]]+)\]:[ \t]*(.*)$/gm, (_, id, txt) => { defs[id] = txt; return ''; }); + md = md.replace(/\[\^([^\]]+)\]/g, (_, id) => { + if (!order.includes(id)) order.push(id); + return `${order.indexOf(id) + 1}`; + }); + let html = marked.parse(md); + if (order.length) { + html += '
    ' + + order.map((id) => `
  1. ${marked.parseInline(defs[id] || '')}
  2. `).join('') + + '
'; + } + return html; +} + +export default history; diff --git a/layouts/_default/single.html b/layouts/_default/single.html index 4892322..a1eb921 100644 --- a/layouts/_default/single.html +++ b/layouts/_default/single.html @@ -40,6 +40,15 @@ {{ if $hasLastmod }}{{ if and $showReadingTime .ReadingTime }} · {{ end }}Aktualisiert am {{ .Lastmod.Format "02.01.2006" }}{{ end }}

{{ end }} + + {{/* Versions-Marke: macht bewusst, dass dies eine bestimmte Fassung ist, + und öffnet den Verlauf (alte Fassungen direkt auf der Seite). */}} + {{ if and (eq .Section "library") .GitInfo }} +

+ +

+ {{ end }} {{/* Table of Contents */}} @@ -66,6 +75,7 @@ {{/* Herkunft/Zitieren — nur bei Library-Beiträgen (lebendes Dokument). */}} {{ if eq .Section "library" }} {{ partial "provenance.html" . }} + {{ end }} {{/* Dialog nur bei Artikeln (Library), nicht auf Seiten wie Spenden/Manifest. */}} diff --git a/static/version-history.js b/static/version-history.js new file mode 100644 index 0000000..cfe73cf --- /dev/null +++ b/static/version-history.js @@ -0,0 +1,83 @@ +/* OPENBUREAU — Versionsverlauf eines Beitrags direkt auf der Seite. + Die Marke „Version xxx" neben dem Kopf öffnet die Liste der Fassungen + (aus /api/history); Auswahl ersetzt den Beitragstext mit der alten Fassung + (aus /api/history/version) und zeigt einen Banner mit Rücksprung. */ +(function () { + var badge = document.getElementById('version-badge'); + if (!badge) return; + var path = badge.dataset.path; + var content = document.querySelector('.single-content'); + var article = document.querySelector('article.single'); + if (!path || !content || !article) return; + + var originalHTML = content.innerHTML; + var originalLabel = badge.textContent; + var panel = null, banner = null; + + function api(p) { + return fetch(p).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); + } + function fmt(d) { try { return new Date(d).toLocaleDateString('de-CH'); } catch (e) { return d || ''; } } + + function closePanel() { + if (panel) { panel.remove(); panel = null; } + badge.setAttribute('aria-expanded', 'false'); + } + function restore() { + content.innerHTML = originalHTML; + badge.textContent = originalLabel; + if (banner) { banner.remove(); banner = null; } + } + function showBanner(v) { + if (banner) banner.remove(); + banner = document.createElement('div'); + banner.className = 'version-banner'; + banner.append('Ältere Fassung vom ' + fmt(v.date) + ' · Version ' + v.short + ' '); + var back = document.createElement('button'); + back.type = 'button'; back.className = 'version-back'; back.textContent = '→ Zur aktuellen Fassung'; + back.addEventListener('click', restore); + banner.appendChild(back); + article.insertBefore(banner, article.firstChild); + } + function loadVersion(v) { + closePanel(); + content.innerHTML = '

Lade Fassung …

'; + api('/api/history/version?path=' + encodeURIComponent(path) + '&rev=' + encodeURIComponent(v.rev)).then(function (data) { + if (!data || !data.html) { restore(); return; } + content.innerHTML = data.html; + badge.textContent = 'Version ' + v.short; + showBanner(v); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }); + } + function openPanel() { + api('/api/history?path=' + encodeURIComponent(path)).then(function (list) { + panel = document.createElement('div'); + panel.className = 'version-panel'; + if (!list || !list.length) { + panel.innerHTML = '

Kein Verlauf verfügbar.

'; + } else { + var ol = document.createElement('ol'); + ol.className = 'version-list'; + list.forEach(function (v, i) { + var li = document.createElement('li'); + var b = document.createElement('button'); + b.type = 'button'; + var date = document.createElement('span'); date.className = 'v-date'; date.textContent = fmt(v.date); + var subj = document.createElement('span'); subj.className = 'v-subject'; subj.textContent = v.subject || ''; + var hash = document.createElement('span'); hash.className = 'v-hash'; hash.textContent = v.short + (i === 0 ? ' · aktuell' : ''); + b.append(date, subj, hash); + if (i === 0) b.addEventListener('click', function () { restore(); closePanel(); }); + else b.addEventListener('click', function () { loadVersion(v); }); + li.appendChild(b); + ol.appendChild(li); + }); + panel.appendChild(ol); + } + badge.parentNode.insertBefore(panel, badge.nextSibling); + badge.setAttribute('aria-expanded', 'true'); + }); + } + + badge.addEventListener('click', function () { panel ? closePanel() : openPanel(); }); +})();