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>
265 lines
8.8 KiB
TypeScript
265 lines
8.8 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ı) —
|
||
* 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,
|
||
}));
|
||
} |