Files
kolaytercih/src/features/rehber/components/el-cizimi-sema.tsx
bilalgursen 1a75f42f29 fix(rehber): şema metni SSR'da + mermaid hata toparlama
Şemanın metin karşılığı yalnızca SVG çizilince `aria-label` olarak
basılıyordu; JS çalıştırmayan botlar iskeletten başka bir şey görmüyordu.
Artık aynı `ariaLabel` metni figure içinde bir `<details>` olarak sunucu
HTML'ine girer. mermaid import/render hatası yakalanır: iskelet kalkar,
metin açık gelir, mermaid'in body'de bıraktığı geçici div temizlenir.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 04:48:10 +03:00

129 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// Ürün genelindeki "el çizimi" mermaid şemalarının tek kaynağı: /meraklisina
// hunisi de rehber yazılarındaki şemalar da bu bileşenle çizilir, böylece
// görünüm (font, renk teması, çizgi titremesi) her yerde birebir aynı kalır.
// Mermaid ağır bir paket olduğu için useEffect içinde dinamik import edilir;
// sayfa paketine girmez.
import { useEffect, useId, useRef, useState } from "react";
import localFont from "next/font/local";
// Şemanın "el çizimi" görünümüne uyan el yazısı fontu (Sweet Cucumber
// Mocktail, Misti's Fonts). Türkçe glifler doğrulandı (ş, ğ, ı, İ… hepsi
// var). DİKKAT: lisans "kişisel kullanım için ücretsiz" — canlıya çıkmadan
// mistifonts.com/sweet-cucumber-mocktail adresinden ticari lisans alınmalı.
const elYazisi = localFont({
src: "../../../fonts/SweetCucumberMocktail.woff2",
display: "swap",
});
export function ElCizimiSema({
tanim,
ariaLabel,
figcaption,
}: {
/** Mermaid tanımı (flowchart vb.) */
tanim: string;
/** Şemanın tamamını anlatan erişilebilirlik metni */
ariaLabel: string;
figcaption?: React.ReactNode;
}) {
const [svg, setSvg] = useState<string | null>(null);
// mermaid yüklenemez ya da çizemezse (bazı tarayıcılarda `getBBox` hatası)
// sonsuz iskelet yerine metin karşılığı açık gelir
const [hata, setHata] = useState(false);
const figureRef = useRef<HTMLElement>(null);
// mermaid.render'ın istediği DOM id'si: useId'nin ":" karakterleri geçersiz
const semaId = `sema-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`;
// ~1 MB'lık mermaid mount'ta değil, şema görünürlüğe yaklaşınca insin —
// şemalar hep fold altında (/meraklisina sayfa sonu, rehber makale ortası).
useEffect(() => {
const el = figureRef.current;
if (!el) return;
let iptal = false;
const io = new IntersectionObserver(
(girisler) => {
if (!girisler.some((g) => g.isIntersecting)) return;
io.disconnect();
cizdir();
},
// Kullanıcı şemaya varmadan yükleme başlasın
{ rootMargin: "200px 0px" },
);
io.observe(el);
function cizdir() {
import("mermaid")
.then(async ({ default: mermaid }) => {
mermaid.initialize({
startOnLoad: false,
// Hata olursa mermaid kendi "bomba" SVG'sini basmasın; biz toparlıyoruz
suppressErrorRendering: true,
look: "handDrawn",
handDrawnSeed: 7, // her render'da aynı çizgi titremesi (deterministik)
theme: "base",
fontFamily: `${elYazisi.style.fontFamily}, cursive`,
themeVariables: {
primaryColor: "#ffffff",
primaryBorderColor: "#e2e8f0",
primaryTextColor: "#0f172a",
lineColor: "#94a3b8",
edgeLabelBackground: "#ffffff",
fontFamily: `${elYazisi.style.fontFamily}, cursive`,
fontSize: "17px", // script fontun x-yüksekliği düşük; okunurluk için büyütüldü
},
});
const sonuc = await mermaid.render(semaId, tanim);
if (!iptal) setSvg(sonuc.svg);
})
.catch(() => {
// mermaid.render atarsa geçici ölçüm div'ini body'de bırakabiliyor
document.getElementById(`d${semaId}`)?.remove();
if (!iptal) setHata(true);
});
}
return () => {
iptal = true;
io.disconnect();
};
}, [semaId, tanim]);
return (
<figure ref={figureRef} className="mt-6">
{svg ? (
<div
role="img"
aria-label={ariaLabel}
className={`${elYazisi.className} overflow-x-auto [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-w-full`}
dangerouslySetInnerHTML={{ __html: svg }}
/>
) : hata ? null : (
<div
aria-hidden
className="h-96 animate-pulse rounded-xl bg-slate-100"
/>
)}
{figcaption ? (
<figcaption className="mt-3 text-xs text-slate-500">
{figcaption}
</figcaption>
) : null}
{/* Şemanın metin karşılığı sunucu HTML'inde de durur: JS çalıştırmayan
botlar ve şemayı çizemeyen tarayıcılar aynı içeriği okur. Kaynak
yine tek `ariaLabel` prop'u — ikinci bir metin tutulmaz. */}
<details
open={hata || undefined}
className="mt-2 text-xs text-slate-500"
>
<summary className="cursor-pointer font-medium text-slate-600">
Şemayı metin olarak oku
</summary>
<p className="mt-2 text-sm leading-6 text-slate-600">{ariaLabel}</p>
</details>
</figure>
);
}