"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()}
)}
)}
)
}