Vercel React Best Practices denetimi, sunucu tarafı: - odeme: analitik olayı after() ile yanıt sonrasına alındı; ucuz ürün doğrulaması oturum turundan öne çekildi; sonuç kartında oturum+sipariş paralel okunuyor - /api/soru POST: gövde+oturum ve 3 bağımsız sorgu Promise.all'a bağlandı - rehber: prod'da globalThis cache + özetler için frontmatter fast-path (24 dosya okuma + marked.parse her render'da tekrarlanıyordu); arama kutusuna giden payload RehberAramaKaydi'ya kırpıldı (tarih serileşmiyor) - /sonuc hattı: ProgramListeSatiri (fakulte/puan2025 client'a serileşmiyor) - katalog: universite/bolum listelerinde O(n²) filtreler tek geçişe indi - sira-gerekli + katalog-arama: effect'te setState kalktı (lint yeşil) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
218 lines
6.9 KiB
TypeScript
218 lines
6.9 KiB
TypeScript
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ı) —
|
||
* 10 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;
|
||
};
|
||
|
||
/**
|
||
* 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<RehberOzet, "slug" | "baslik" | "aciklama">;
|
||
|
||
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<string, RehberYazi | null>;
|
||
rehberOzetler?: RehberOzet[];
|
||
};
|
||
const CACHE_AKTIF = process.env.NODE_ENV === "production";
|
||
|
||
function parseFrontmatter(raw: string): {
|
||
meta: Record<string, string>;
|
||
body: string;
|
||
} {
|
||
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||
if (!match) return { meta: {}, body: raw };
|
||
const meta: Record<string, string> = {};
|
||
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 = satir
|
||
.slice(ayrac + 1)
|
||
.trim()
|
||
.replace(/^["']|["']$/g, "");
|
||
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 };
|
||
}
|
||
|
||
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: marked.parse(md, { gfm: true, async: false }),
|
||
});
|
||
}
|
||
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: marked.parse(kalan, { gfm: true, async: false }),
|
||
});
|
||
}
|
||
return parcalar;
|
||
}
|
||
|
||
function metaDogrula(
|
||
slug: string,
|
||
meta: Record<string, string>,
|
||
): asserts meta is { baslik: string; aciklama: string; tarih: string } {
|
||
if (!meta.baslik || !meta.aciklama || !meta.tarih) {
|
||
throw new Error(
|
||
`rehber: ${slug}.md frontmatter eksik (baslik/aciklama/tarih zorunlu)`,
|
||
);
|
||
}
|
||
}
|
||
|
||
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,
|
||
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 } = tam;
|
||
return { slug: s, baslik, aciklama, tarih };
|
||
}
|
||
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,
|
||
};
|
||
}
|
||
|
||
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,
|
||
}));
|
||
} |