feat(ui): README auf der Projektseite anzeigen

Lange READMEs sind eingeklappt, ein Knopf zeigt alles. Kurze stehen gleich
vollstaendig da - ein Knopf, der nichts freigibt, waere nur Ballast.

Relative Links werden gegen das Verzeichnis der Datei im Repository
aufgeloest. Ohne das liefen sie ins Leere, denn im README steht "docs/x.md"
und nicht die volle Adresse.

Bilder werden nicht geladen, sondern als anklickbare Marke gezeigt. Die
Content-Security-Policy erlaubt img-src nur von der eigenen Instanz; sie
dafuer zu oeffnen hiesse, jedem fremden README das Nachladen beliebiger
Bilder zu gestatten - also auch Zaehlpixeln. Fuer Abzeichen und
Bildschirmfotos, in einer Vorschau ohnehin Beiwerk, ist das kein guter
Tausch.

Der Umbau passiert im Baum und nicht ueber eine Komponente, weil es auf den
Elternknoten ankommt: "[![Abzeichen](bild)](ziel)" ist in READMEs die Regel,
und ein a in einem a ist ungueltiges HTML. Der Browser zieht es beim Parsen
auseinander - der erste Versuch scheiterte prompt an einem
Hydrationsfehler. Innerhalb eines Links bleibt jetzt nur der Text stehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 23:41:42 +02:00
co-authored by Claude Opus 5
parent f8e6c66876
commit e97bd43b23
4 changed files with 230 additions and 3 deletions
+94 -3
View File
@@ -24,12 +24,93 @@ const schema = {
},
};
/** Loest relative Verweise gegen die Basis auf; laesst absolute in Ruhe. */
function absolut(href: string | undefined, base: string | null | undefined): string | undefined {
if (!href || !base) return href;
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//") || href.startsWith("#")) {
return href;
}
try {
return new URL(href, base).toString();
} catch {
return href;
}
}
type HastNode = {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: HastNode[];
value?: string;
};
/**
* Ersetzt Bilder durch eine anklickbare Marke – siehe die Begruendung
* unten am `.md-image`-Stil.
*
* Das passiert im Baum und nicht ueber eine Komponente, weil es auf den
* Elternknoten ankommt: `[![Abzeichen](bild)](ziel)` ist in READMEs die
* Regel, und ein `<a>` in einem `<a>` ist ungueltiges HTML. Der Browser
* zieht es beim Parsen auseinander, was zu einem Hydrationsfehler fuehrt –
* genau daran ist es beim ersten Versuch gescheitert. Innerhalb eines Links
* bleibt deshalb nur der Text stehen; der umgebende Link ist ohnehin das,
* was man anklicken will.
*/
function bilderAlsMarke(base: string | null | undefined) {
return () => (tree: HastNode) => {
const gehe = (knoten: HastNode, inLink: boolean) => {
const kinder = knoten.children;
if (!kinder) return;
for (let index = 0; index < kinder.length; index += 1) {
const kind = kinder[index];
if (kind.type === "element" && kind.tagName === "img") {
const quelle = typeof kind.properties?.src === "string" ? kind.properties.src : undefined;
const alt = typeof kind.properties?.alt === "string" ? kind.properties.alt.trim() : "";
const text: HastNode = { type: "text", value: alt || "Bild" };
const ziel = absolut(quelle, base);
kinder[index] =
inLink || !ziel
? { type: "element", tagName: "span", properties: { className: ["md-image"] }, children: [text] }
: {
type: "element",
tagName: "a",
properties: {
className: ["md-image"],
href: ziel,
title: ziel,
target: "_blank",
rel: "noreferrer noopener nofollow",
},
children: [text],
};
continue;
}
gehe(kind, inLink || (kind.type === "element" && kind.tagName === "a"));
}
};
gehe(tree, false);
};
}
export function Markdown({
children,
className,
baseUrl,
}: {
children: string | null | undefined;
className?: string;
/**
* Wogegen relative Verweise aufzuloesen sind. Gesetzt beim README aus
* einem Repository – dort zeigen Links auf Dateien daneben, die ohne
* Basis ins Leere liefen.
*/
baseUrl?: string | null;
}) {
if (!children?.trim()) return null;
@@ -37,12 +118,22 @@ export function Markdown({
<div className={cn("md", className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeSanitize, schema]]}
// Erst bereinigen, dann umbauen: die Marke soll nicht selbst durch
// die Bereinigung laufen und dabei ihre Klassen verlieren.
rehypePlugins={[[rehypeSanitize, schema], bilderAlsMarke(baseUrl)]}
components={{
// Links aus fremdem Inhalt immer in neuem Tab und ohne
// Zugriff auf das oeffnende Fenster.
a: ({ href, children: content }) => (
<a href={href} target="_blank" rel="noreferrer noopener nofollow">
// className muss durchgereicht werden, sonst verlieren die
// Bildmarken aus dem Umbau oben ihre Gestalt.
a: ({ href, className: klasse, title, children: content }) => (
<a
href={absolut(href, baseUrl)}
className={klasse}
title={title}
target="_blank"
rel="noreferrer noopener nofollow"
>
{content}
</a>
),
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { BookOpen, ChevronDown, ExternalLink } from "lucide-react";
import { Markdown } from "@/components/Markdown";
import { cn } from "@/lib/utils";
/**
* Das README des verknuepften Repositories.
*
* Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
* Projekt eigentlich ging. Nicht zum Lesen ganzer Handbuecher gedacht:
* eingeklappt zeigt es die ersten Zeilen, ausgeklappt alles.
*/
export function ReadmePanel({
content,
path,
baseUrl,
fileUrl,
}: {
content: string;
path: string | null;
baseUrl: string | null;
/** Direktlink auf die Datei im Git-System. */
fileUrl: string | null;
}) {
const [offen, setOffen] = useState(false);
// Kurze READMEs vollstaendig zu zeigen ist einfacher, als sie mit einem
// Knopf zu versehen, der nichts freigibt.
const kurz = content.length < 900;
return (
<section className="glass p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<BookOpen className="h-4 w-4 text-cyan-300" />
{path ?? "README"}
</h2>
{fileUrl ? (
<a
href={fileUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-xs text-slate-500 transition-colors hover:text-violet-300"
>
Im Git öffnen
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
<div className="relative">
<div
className={cn(
"overflow-hidden transition-[max-height] duration-300",
!kurz && !offen && "max-h-72",
)}
>
<Markdown baseUrl={baseUrl}>{content}</Markdown>
</div>
{/* Verlauf ueber den abgeschnittenen Rand, damit erkennbar ist, dass
es weitergeht. Nur wenn wirklich etwas verdeckt wird. */}
{!kurz && !offen ? (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[#0b0d1c] to-transparent"
/>
) : null}
</div>
{!kurz ? (
<button
type="button"
onClick={() => setOffen((wert) => !wert)}
className="btn btn-ghost mt-2 w-full text-xs"
aria-expanded={offen}
>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", offen && "rotate-180")} />
{offen ? "Weniger" : "Ganzes README anzeigen"}
</button>
) : null}
</section>
);
}