Files
kolaytercih/src/lib/rehber.ts
bilalgursen d9e125c5de feat(rehber): guncelleme alanı → sitemap/JSON-LD/OG/görünür tarih
Frontmatter'a isteğe bağlı `guncelleme` alanı geldi ve uçtan uca akıyor:
sitemap lastmod (yazı + /rehber dizini), Article JSON-LD dateModified,
og:article modified_time ve makale başlığının altındaki görünür tarih
satırı. `tarih`/`guncelleme` YYYY-AA-GG değilse ya da güncelleme yayından
önceyse build kırılır. Tarih metni saat diliminden bağımsız `trTarih` ile
üretilir.

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

265 lines
8.8 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.
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<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";
// 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<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 = 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 `<table>`'ı ö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(
"<table>",
'<div class="rehber-tablo" role="region" aria-label="Tablo" tabindex="0"><table>',
)
.replaceAll("</table>", "</table></div>");
}
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<string, string>,
): 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,
}));
}