import "server-only"; import fs from "node:fs"; import path from "node:path"; import { marked } from "marked"; /** * Rehber makaleleri: content/rehber/*.md dosyaları build sırasında okunur ve * statik HTML'e gömülür (sayfalar generateStaticParams + dynamicParams=false * ile tamamen prerender edilir; runtime'da fs erişimi olmaz). * * Frontmatter elle parse edilir (yalnızca düz `anahtar: değer` satırları) — * bir avuç makale için gray-matter bağımlılığına gerek yok. * * ```mermaid çitleri HTML'e gömülmez: makale parçalara bölünür ve şemalar * sayfada ürünün ortak el çizimi bileşeniyle (ElCizimiSema) çizilir. Çitin * içinde `%% aria:` satırı zorunludur (erişilebilirlik metni), `%% altyazi:` * isteğe bağlı figcaption verir; iki satır da mermaid tanımından çıkarılır. */ export type RehberOzet = { slug: string; baslik: string; aciklama: string; /** ISO tarih — Article JSON-LD datePublished */ tarih: string; /** ISO tarih, isteğe bağlı — dateModified / sitemap lastmod / görünür satır */ guncelleme?: string; }; /** * Header'daki ⌘K aramasına giden asgari alan seti. `tarih` client'ta hiç * okunmadığı için RSC payload'ına taşınmaz (root layout üzerinden her * sayfaya serileşiyordu). */ export type RehberAramaKaydi = Pick; export type RehberSema = { /** Mermaid tanımı (aria/altyazi yönergeleri ayıklanmış hâli) */ tanim: string; ariaLabel: string; altyazi?: string; }; export type RehberParca = | { tip: "html"; html: string } | { tip: "sema"; sema: RehberSema }; export type RehberYazi = RehberOzet & { parcalar: RehberParca[] }; const REHBER_DIZIN = path.join(process.cwd(), "content", "rehber"); // SiteHeader root layout'ta olduğu için getAllRehberler her sayfa // render/prerender'ında koşuyor; cache'siz hâli sayfa başına 24 dosya okuma + // 24 marked.parse demekti. Yalnızca prod'da cache'lenir — dev'de içerik // düzenlenirken taze fs okuması korunur (katalog.ts/db.ts globalThis deseni). const globalRehber = globalThis as unknown as { rehberYazilar?: Map; rehberOzetler?: RehberOzet[]; }; const CACHE_AKTIF = process.env.NODE_ENV === "production"; // Değerin TAMAMI aynı tırnakla sarılıysa soyar. İki ucu bağımsız silmek // `"Alıntı" diye başlayan cümle` gibi değerlerin yalnızca baştaki tırnağını // yutuyordu; içinde aynı tırnak geçen değer de tam sarılı sayılmaz. function tirnakSoy(deger: string): string { if (deger.length < 2) return deger; const tirnak = deger[0]; if (tirnak !== '"' && tirnak !== "'") return deger; if (deger[deger.length - 1] !== tirnak) return deger; const ic = deger.slice(1, -1); return ic.includes(tirnak) ? deger : ic; } function parseFrontmatter(raw: string): { meta: Record; body: string; } { const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); if (!match) return { meta: {}, body: raw }; const meta: Record = {}; for (const satir of match[1].split(/\r?\n/)) { const ayrac = satir.indexOf(":"); if (ayrac === -1) continue; const anahtar = satir.slice(0, ayrac).trim(); const deger = tirnakSoy(satir.slice(ayrac + 1).trim()); if (anahtar) meta[anahtar] = deger; } return { meta, body: raw.slice(match[0].length) }; } const SEMA_CITI = /^```mermaid[ \t]*\r?\n([\s\S]*?)^```[ \t]*$/gm; function parseSema(slug: string, ham: string): RehberSema { let ariaLabel = ""; let altyazi: string | undefined; const satirlar: string[] = []; for (const satir of ham.split(/\r?\n/)) { const aria = satir.match(/^%%\s*aria:\s*(.+)$/); const alt = satir.match(/^%%\s*altyazi:\s*(.+)$/); if (aria) ariaLabel = aria[1].trim(); else if (alt) altyazi = alt[1].trim(); else satirlar.push(satir); } if (!ariaLabel) { throw new Error( `rehber: ${slug}.md içindeki mermaid şemasında "%% aria: ..." satırı zorunlu`, ); } return { tanim: satirlar.join("\n").trim(), ariaLabel, altyazi }; } // Geniş tablolar dar ekranda sayfayı taşırmasın: her tablo yatay kayan, // klavyeyle odaklanabilir bir bölgeye sarılır (stil rehber-makale.tsx'te). // marked ``'ı özniteliksiz basar; global `marked.use` ile renderer // değiştirmek yerine çıktı burada sarılır. function markdownHtml(md: string): string { return marked .parse(md, { gfm: true, async: false }) .replaceAll( "
", '
', ) .replaceAll("
", ""); } function parcala(slug: string, body: string): RehberParca[] { const parcalar: RehberParca[] = []; let son = 0; for (const eslesme of body.matchAll(SEMA_CITI)) { const md = body.slice(son, eslesme.index).trim(); if (md) { parcalar.push({ tip: "html", html: markdownHtml(md) }); } parcalar.push({ tip: "sema", sema: parseSema(slug, eslesme[1]) }); son = eslesme.index + eslesme[0].length; } const kalan = body.slice(son).trim(); if (kalan) { parcalar.push({ tip: "html", html: markdownHtml(kalan) }); } return parcalar; } const TARIH_BICIMI = /^\d{4}-\d{2}-\d{2}$/; function metaDogrula( slug: string, meta: Record, ): asserts meta is { baslik: string; aciklama: string; tarih: string; guncelleme?: string; } { if (!meta.baslik || !meta.aciklama || !meta.tarih) { throw new Error( `rehber: ${slug}.md frontmatter eksik (baslik/aciklama/tarih zorunlu)`, ); } // Tarihler sitemap lastmod'a, JSON-LD'ye ve görünür satıra olduğu gibi // akıyor; bozuk biçim sessizce yayına çıkmasın, build kırılsın. if (!TARIH_BICIMI.test(meta.tarih)) { throw new Error( `rehber: ${slug}.md frontmatter "tarih" YYYY-AA-GG biçiminde olmalı`, ); } if (meta.guncelleme !== undefined) { if (!TARIH_BICIMI.test(meta.guncelleme)) { throw new Error( `rehber: ${slug}.md frontmatter "guncelleme" YYYY-AA-GG biçiminde olmalı`, ); } if (meta.guncelleme < meta.tarih) { throw new Error( `rehber: ${slug}.md frontmatter "guncelleme" "tarih"ten önce olamaz`, ); } } } function oku(slug: string): RehberYazi | null { if (CACHE_AKTIF) { const onbellek = globalRehber.rehberYazilar?.get(slug); if (onbellek !== undefined) return onbellek; } const dosya = path.join(REHBER_DIZIN, `${slug}.md`); let yazi: RehberYazi | null = null; if (fs.existsSync(dosya)) { const { meta, body } = parseFrontmatter(fs.readFileSync(dosya, "utf8")); metaDogrula(slug, meta); yazi = { slug, baslik: meta.baslik, aciklama: meta.aciklama, tarih: meta.tarih, guncelleme: meta.guncelleme, parcalar: parcala(slug, body), }; } if (CACHE_AKTIF) { (globalRehber.rehberYazilar ??= new Map()).set(slug, yazi); } return yazi; } /** * Liste görünümleri için fast-path: yalnızca frontmatter parse edilir, * `parcala()`/`marked.parse` hiç çağrılmaz (özetler `parcalar`ı kullanmıyor). * Tam parse zaten cache'teyse dosyaya inmeden onu kırpar. */ function ozetOku(slug: string): RehberOzet | null { const tam = CACHE_AKTIF ? globalRehber.rehberYazilar?.get(slug) : undefined; if (tam !== undefined) { if (tam === null) return null; const { slug: s, baslik, aciklama, tarih, guncelleme } = tam; return { slug: s, baslik, aciklama, tarih, guncelleme }; } const dosya = path.join(REHBER_DIZIN, `${slug}.md`); if (!fs.existsSync(dosya)) return null; const { meta } = parseFrontmatter(fs.readFileSync(dosya, "utf8")); metaDogrula(slug, meta); return { slug, baslik: meta.baslik, aciklama: meta.aciklama, tarih: meta.tarih, guncelleme: meta.guncelleme, }; } export function getAllRehberSlugs(): string[] { if (!fs.existsSync(REHBER_DIZIN)) return []; return fs .readdirSync(REHBER_DIZIN) .filter((f) => f.endsWith(".md")) .map((f) => f.replace(/\.md$/, "")) .sort(); } export function getRehber(slug: string): RehberYazi | null { // path traversal koruması: slug yalnızca dosya adı olabilir if (!/^[a-z0-9-]+$/.test(slug)) return null; return oku(slug); } export function getAllRehberler(): RehberOzet[] { if (CACHE_AKTIF && globalRehber.rehberOzetler) { return globalRehber.rehberOzetler; } const ozetler = getAllRehberSlugs() .map((slug) => ozetOku(slug)) .filter((y): y is RehberOzet => y !== null) .sort((a, b) => b.tarih.localeCompare(a.tarih)); if (CACHE_AKTIF) globalRehber.rehberOzetler = ozetler; return ozetler; } /** Header aramasının RSC payload'ına giden kırpılmış liste. */ export function getRehberAramaKayitlari(): RehberAramaKaydi[] { return getAllRehberler().map(({ slug, baslik, aciklama }) => ({ slug, baslik, aciklama, })); }