"use client" import { useMemo, useRef, useEffect, useState } from "react" import { Marked, type Tokens } from "marked" import katex from "katex" import hljs from "highlight.js" /* ── KaTeX extensions ────────────────────────────── */ const blockMathExtension = { name: "blockMath", level: "block" as const, start: (src: string) => src.indexOf("$$"), tokenizer(src: string) { const match = /^\$\$([\s\S]+?)\$\$/.exec(src) if (match) return { type: "blockMath", raw: match[0], text: match[1].trim() } return undefined }, renderer(token: { text: string }) { try { return `
${katex.renderToString(token.text, { displayMode: true, throwOnError: false, output: "html", })}
\n` } catch { return `
$$${token.text}$$
\n` } }, } const inlineMathExtension = { name: "inlineMath", level: "inline" as const, start: (src: string) => { const i = src.indexOf("$") return i === -1 ? undefined : i }, tokenizer(src: string) { const match = /^\$(?!\$)([^$\n]+?)\$/.exec(src) if (match) return { type: "inlineMath", raw: match[0], text: match[1].trim() } return undefined }, renderer(token: { text: string }) { try { return katex.renderToString(token.text, { displayMode: false, throwOnError: false, output: "html", }) } catch { return `$${token.text}$` } }, } /* ── Wikilink extension ──────────────────────────── */ const wikilinkExtension = { name: "wikilink", level: "inline" as const, start: (src: string) => src.indexOf("[["), tokenizer(src: string) { // [[target#fragment|text]] — target and fragment are both optional const match = /^\[\[([^\]|#]*?)(?:#([^\]|]*?))?(?:\|([^\]]+?))?\]\]/.exec(src) if (!match) return undefined const target = match[1].trim() const fragment = match[2]?.trim() ?? "" const fallback = target + (fragment ? `#${fragment}` : "") return { type: "wikilink", raw: match[0], target, fragment, text: (match[3] ?? fallback).trim(), } }, renderer(token: { target: string; fragment: string; text: string }) { const escaped = token.text.replace(/&/g, "&").replace(//g, ">") const target = token.target.replace(/"/g, """) const frag = token.fragment.replace(/"/g, """) return `${escaped}` }, } /* ── Marked instance (isolated, not global) ──────── */ const md = new Marked() md.use({ renderer: { code(token: Tokens.Code): string { const lang = (token.lang ?? "").split(/\s/)[0] if (lang && hljs.getLanguage(lang)) { try { const highlighted = hljs.highlight(token.text, { language: lang, ignoreIllegals: true, }).value return `
${highlighted}
\n` } catch { /* fall through */ } } return `
${hljs.highlightAuto(token.text).value}
\n` }, heading(token: Tokens.Heading): string { const raw = token.text.replace(/<[^>]*>/g, "") const id = raw.toLowerCase().replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-") return `${token.text}\n` }, link(token: Tokens.Link): string { const href = token.href ?? "" const isExt = /^https?:\/\//.test(href) const titleAttr = token.title ? ` title="${token.title.replace(/"/g, """)}"` : "" const extAttrs = isExt ? ` target="_blank" rel="noopener noreferrer"` : "" return `${token.text}` }, }, extensions: [blockMathExtension, inlineMathExtension, wikilinkExtension], }) /* ── Frontmatter-Parser ────────────────────────────── */ interface FmData { title: string; description?: string; status?: string; lang?: string; topic?: string; draft?: boolean } function parseFm(markdown: string): { fm: FmData | null; body: string } { if (!markdown.startsWith("---\n")) return { fm: null, body: markdown } const end = markdown.indexOf("\n---\n", 4) if (end === -1) return { fm: null, body: markdown } const lines = markdown.slice(4, end).split("\n") const body = markdown.slice(end + 5) const fm: FmData = { title: "" } for (const line of lines) { const m = line.match(/^(\w+):\s*(.*)$/) if (!m) continue const [, key, rawVal] = m const val = rawVal.replace(/^["']|["']$/g, "").trim() switch (key) { case "title": fm.title = val; break case "description": fm.description = val; break case "status": fm.status = val; break case "lang": fm.lang = val; break case "topic": fm.topic = val; break case "draft": fm.draft = rawVal.trim() === "true"; break } } return { fm: fm.title ? fm : null, body } } const STATUS_COLOR: Record = { seed: "var(--seed)", budding: "var(--budding)", evergreen: "var(--evergreen)", } /* ── Component ────────────────────────────────────── */ interface NoteRef { slug: string; title: string } interface Props { content: string className?: string notes?: NoteRef[] onSelectNote?: (slug: string) => void } export default function Preview({ content, className, notes, onSelectNote }: Props) { const { fm, body } = useMemo(() => parseFm(content), [content]) const html = useMemo(() => { try { const result = md.parse(body) return typeof result === "string" ? result : "" } catch { return "" } }, [body]) // Refs so the event listener never goes stale const proseRef = useRef(null) const notesRef = useRef(notes ?? []) const selectRef = useRef(onSelectNote) const pendingFragmentRef = useRef(null) useEffect(() => { notesRef.current = notes ?? [] }, [notes]) useEffect(() => { selectRef.current = onSelectNote }, [onSelectNote]) // After HTML re-renders, scroll to any pending fragment (cross-note navigation) useEffect(() => { if (!pendingFragmentRef.current || !proseRef.current) return const frag = pendingFragmentRef.current pendingFragmentRef.current = null const target = proseRef.current.querySelector(`#${CSS.escape(frag)}`) if (target) setTimeout(() => target.scrollIntoView({ behavior: "smooth", block: "start" }), 50) }, [html]) // Toast for unresolvable wikilinks const [toast, setToast] = useState(null) const toastTimer = useRef>() const showToast = (msg: string) => { setToast(msg) clearTimeout(toastTimer.current) toastTimer.current = setTimeout(() => setToast(null), 4000) } // Mark broken wikilinks visually after render useEffect(() => { const el = proseRef.current if (!el) return el.querySelectorAll("[data-wikilink]").forEach(span => { const raw = span.dataset.wikilink ?? "" const lower = raw.toLowerCase().trim() const slug = raw.toLowerCase().normalize("NFD").replace(/\p{M}/gu, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") const list = notesRef.current const found = !!( list.find(n => n.title.toLowerCase().trim() === lower) ?? list.find(n => n.slug.toLowerCase() === lower) ?? list.find(n => (n.slug.split("/").pop() ?? "") === slug) ) span.dataset.broken = found ? "false" : "true" }) }, [html, notes]) useEffect(() => { const el = proseRef.current if (!el) return const handleClick = (e: MouseEvent) => { const t = e.target as HTMLElement const wikiSpan = t.closest("[data-wikilink]") if (wikiSpan) { const raw = wikiSpan.dataset.wikilink ?? "" const fragment = wikiSpan.dataset.fragment ?? "" // In-page anchor: [[#abschnitt]] if (!raw && fragment) { const anchor = proseRef.current?.querySelector(`#${CSS.escape(fragment)}`) if (anchor) anchor.scrollIntoView({ behavior: "smooth", block: "start" }) else showToast(`„#${fragment}" — Abschnitt nicht gefunden`) return } const lower = raw.toLowerCase().trim() const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") const list = notesRef.current const note = list.find(n => n.title.toLowerCase().trim() === lower) ?? list.find(n => n.slug.toLowerCase() === lower) ?? list.find(n => (n.slug.split("/").pop() ?? "") === slug) ?? list.find(n => (n.slug.split("/").pop() ?? "").includes(slug.slice(0, 20))) if (note) { if (fragment) pendingFragmentRef.current = fragment selectRef.current?.(note.slug) } else { showToast(`„${raw.length > 48 ? raw.slice(0, 48) + "…" : raw}" — Notiz nicht gefunden`) } return } const anchor = t.closest("a[href]") if (anchor && /^https?:\/\//.test(anchor.href)) { e.preventDefault() window.open(anchor.href, "_blank", "noopener,noreferrer") } } el.addEventListener("click", handleClick) return () => el.removeEventListener("click", handleClick) }, []) return (
{toast && (
{toast}
)}
{/* Frontmatter-Header */} {fm && (
{fm.topic && (
{fm.topic}
)}

{fm.title}

{fm.description && (

{fm.description}

)}
{fm.status && (
{fm.status} )} {fm.draft && ( in review )} {fm.lang && ( {fm.lang.toUpperCase()} )}
)}
) }