Files
kolaytercih/scripts/tasarim-denetim.ts
bilalgursen 5b51fd16e0 feat(tasarim): bölüm sayfası "şişko ve sade" dile göre
- Başlık bloğu lg altında ortalı, kimlik hapları (düzey + puan türü),
  dört özet kartı (Program, Devlet, Vakıf, En iyi sıralama), piksel ayraç
- OzetKart src/components/ozet-kart.tsx'e çıkarıldı; üniversite ve bölüm
  sayfası oradan kullanıyor. 6-7 haneli sayı birimle kısalıyor (ölçüldü)
- Program listesi §4.15: ad sarar, varyant ayrı satırda, 44 px butonlar,
  telefonda taban sıra adın altında, butonlar alt alta
- Sayfalama 44 px, telefonda tek satır; hover'lar tek turuncu
- Denetim: kanonik liste satırı adının mavi hover'ı artık uyarı vermiyor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 00:39:21 +03:00

260 lines
9.4 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.
/**
* Tasarım dili denetimi — docs/tasarim/DESIGN.md kurallarının makineyle
* yakalanabilen kısmı. Ajan/insan fark etmez: `pnpm tasarim:denetim`.
*
* Çıkış kodu: hata varsa 1, yoksa 0. Uyarılar çıkış kodunu etkilemez.
* Kapsam: src/**\/*.tsx. Hariç: src/components/ui (shadcn tabanı) ve
* src/components/dev (yalnızca geliştirici paneli) — bazı kurallarda.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
type Seviye = "hata" | "uyari";
type Bulgu = {
dosya: string;
satir: number;
kural: string;
seviye: Seviye;
mesaj: string;
parca: string;
};
type Kural = {
ad: string;
seviye: Seviye;
mesaj: string;
/** ui/dev klasörleri de taransın mı */
uiDahil?: boolean;
/** Tam yolu eşleşen dosyalar kuraldan muaf */
muaf?: RegExp;
bul: (icerik: string) => Array<{ index: number; parca: string }>;
};
const KOK = process.cwd();
const KAYNAK = join(KOK, "src");
const UI_VEYA_DEV = /src\/components\/(ui|dev)\//;
function tsxDosyalari(dizin: string): string[] {
const sonuc: string[] = [];
for (const ad of readdirSync(dizin)) {
const yol = join(dizin, ad);
if (statSync(yol).isDirectory()) sonuc.push(...tsxDosyalari(yol));
else if (yol.endsWith(".tsx")) sonuc.push(yol);
}
return sonuc;
}
function satirNo(icerik: string, index: number): number {
let n = 1;
for (let i = 0; i < index && i < icerik.length; i++) if (icerik[i] === "\n") n++;
return n;
}
function satirParcasi(icerik: string, index: number): string {
const bas = icerik.lastIndexOf("\n", index) + 1;
let son = icerik.indexOf("\n", index);
if (son === -1) son = icerik.length;
return icerik.slice(bas, son).trim().slice(0, 110);
}
/** Basit regex kuralı: her eşleşme bir bulgu. */
function regexKurali(re: RegExp) {
return (icerik: string) => {
const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
const sonuc: Array<{ index: number; parca: string }> = [];
for (const m of icerik.matchAll(g)) sonuc.push({ index: m.index ?? 0, parca: m[0] });
return sonuc;
};
}
/** <Etiket …>…</Etiket> bloklarının içinde `ic` deseni varsa bulgu. */
function blokIcinde(etiketler: string[], ic: RegExp) {
return (icerik: string) => {
const sonuc: Array<{ index: number; parca: string }> = [];
for (const etiket of etiketler) {
const blok = new RegExp(`<${etiket}\\b[\\s\\S]*?<\\/${etiket}>`, "g");
for (const m of icerik.matchAll(blok)) {
const icM = ic.exec(m[0]);
if (icM) sonuc.push({ index: (m.index ?? 0) + icM.index, parca: icM[0] });
}
}
return sonuc;
};
}
const RENK_ONEK = "(?:bg|text|border|ring|fill|stroke|from|to|via|divide|outline|placeholder|decoration|accent|caret|shadow)";
const KURALLAR: Kural[] = [
{
ad: "badge-import",
seviye: "hata",
mesaj: "Rozet için @/components/ui/badge değil SectionEyebrow kullanılır (DESIGN.md §4.2)",
bul: regexKurali(/from\s+["']@\/components\/ui\/badge["']/),
},
{
ad: "elle-rozet",
seviye: "hata",
mesaj: "Elle yazılmış rozet; SectionEyebrow'u içe aktar (DESIGN.md §4.2)",
muaf: /src\/components\/pixel-decor\.tsx$/,
bul: regexKurali(/rounded-full border border-slate-200 bg-white px-3\.5 py-1\.5/),
},
{
ad: "cta-yildiz",
seviye: "hata",
mesaj: "CTA butonunda Sparkles/yıldız ikonu olmaz; metin + sondaki ArrowRight (DESIGN.md §4.1)",
bul: blokIcinde(["Button", "Link"], /<Sparkles\b/),
},
{
ad: "yabanci-notr",
seviye: "hata",
mesaj: "Nötr renk yalnızca slate; gray/zinc/neutral/stone yazılmaz (DESIGN.md §2)",
bul: regexKurali(new RegExp(`\\b${RENK_ONEK}-(?:gray|zinc|neutral|stone)-\\d+`)),
},
{
ad: "yabanci-renk",
seviye: "hata",
mesaj: "Paletin dışında ton; primary mavi / orange-500 / slate / risk renkleri kullanılır (DESIGN.md §2)",
// İstisna (DESIGN.md §2 "Gösterge karesi"): yalnız dolu bg-indigo-500 ve
// bg-green-600 zeminleri serbest; başka ton/opaklık/özellik yine hata.
bul: regexKurali(
new RegExp(
`\\b${RENK_ONEK}-(?:indigo|purple|fuchsia|pink|cyan|teal|lime|green|yellow)-\\d+(?!\\d)(?!(?<=\\bbg-(?:indigo-500|green-600))(?![\\w/-]))`,
),
),
},
{
ad: "gradyan-metin",
seviye: "hata",
mesaj: "Gradyan metin yok (DESIGN.md §2)",
bul: regexKurali(/\b(?:bg-clip-text|text-transparent)\b/),
},
{
ad: "parlama-golgesi",
seviye: "hata",
mesaj: "Parlama (glow) gölgesi yok; shadow-sm / hover:shadow-lg shadow-primary/5 (DESIGN.md §4.4)",
bul: regexKurali(/shadow-\[0_0_[1-9]\d*px/),
},
{
ad: "hareket-spam",
seviye: "hata",
mesaj:
"whileHover scale / aos / bounce yok; Reveal, piksel ısısı, kt-unblur (DESIGN.md §7). animate-ping yalnızca canlı göstergesi noktasında",
bul: regexKurali(/whileHover=\{\{\s*scale|data-aos=|\banimate-bounce\b/),
},
{
ad: "kapatma-carpisi",
seviye: "hata",
mesaj: "showCloseButton={false} verilen dosyada kanonik çarpı sınıf seti yok (DESIGN.md §4.3)",
bul: (icerik) => {
const m = /showCloseButton=\{false\}/.exec(icerik);
if (!m) return [];
const kanonik =
/size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200/.test(icerik);
return kanonik ? [] : [{ index: m.index, parca: m[0] }];
},
},
{
ad: "yabanci-font",
seviye: "hata",
mesaj: "Yalnızca font-heading / font-sans / font-bricolage / font-mono (DESIGN.md §3)",
bul: regexKurali(/\bfont-(?:serif|\[)|["'](?:Inter|Roboto|Arial|Helvetica)["']/),
},
{
ad: "dark-sinif",
seviye: "uyari",
mesaj: "Üründe karanlık mod yok; dark: sınıfı gereksiz (DESIGN.md §1)",
bul: regexKurali(/(?<=[\s"'`])dark:[a-z]/),
},
{
ad: "hex-renk",
seviye: "uyari",
mesaj: "Sınıf içinde hex renk; token ya da Tailwind tonu kullan (DESIGN.md §2)",
// Rehber kapak görseli OG görseliyle aynı sabit paleti paylaşır (satori
// tarafında Tailwind token'ı çözülmez); bilinçli istisna.
muaf: /src\/features\/rehber\/components\/rehber-kapak\.tsx$/,
bul: regexKurali(/\[#[0-9a-fA-F]{3,8}\]/),
},
{
ad: "minik-yazi",
seviye: "uyari",
mesaj: "12 px altı yazı yok; en küçük text-xs, yalnız ince yazıda (DESIGN.md §3 alt sınırlar)",
bul: regexKurali(/\btext-\[(?:[0-9]|1[01])px\]/),
},
{
ad: "buyuk-harf-etiket",
seviye: "uyari",
mesaj: "Büyük harfli minik etiket yok; büyük-küçük harfli text-sm font-semibold (DESIGN.md §3 alt sınırlar)",
bul: regexKurali(/\buppercase\b[^"'`\n]*\btracking-(?:wide|wider|widest)\b|\btracking-(?:wide|wider|widest)\b[^"'`\n]*\buppercase\b/),
},
{
ad: "mavi-hover",
seviye: "uyari",
mesaj: "Hover rengi tek turuncu (orange-500); liste satırındaki ad linkiyse istisna, mavi kalır (DESIGN.md §2.1)",
// §2.1 istisnası: §4.15 kanonik liste satırı adı (sarar, text-base
// font-semibold leading-snug + hover:text-primary hover:underline) mavi
// kalır. Eski truncate/text-sm adlar istisnaya girmez — önce §4.15'e
// çevrilir. (Bilal'in kararı, 24 Eyl 2026: istisna uyarı vermesin.)
bul: (icerik) =>
regexKurali(/\b(?:group-)?hover:(?:text|border|shadow)-primary\b/)(icerik).filter((e) => {
const bas = icerik.lastIndexOf("\n", e.index) + 1;
const son = icerik.indexOf("\n", e.index);
const satir = icerik.slice(bas, son === -1 ? undefined : son);
return !(
/\btext-base font-semibold leading-snug\b/.test(satir) &&
/\bhover:text-primary hover:underline\b/.test(satir)
);
}),
},
{
ad: "iri-sayi-tabular",
seviye: "uyari",
mesaj: "Cal Sans iri sayıda tabular-nums 'daktilo' gibi durur; yalnız font-sans tablo sütunlarında (DESIGN.md §3)",
bul: regexKurali(/\bfont-heading\b[^"'`\n]*\btabular-nums\b|\btabular-nums\b[^"'`\n]*\bfont-heading\b/),
},
{
ad: "emoji-ikon",
seviye: "uyari",
mesaj: "Emoji ikon olarak kullanılmaz; lucide ikon (DESIGN.md §4.7)",
bul: (icerik) =>
// Yorum satırındaki işaretler (// ✗ gibi) ikon değildir
regexKurali(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]\uFE0F?/u)(icerik).filter(
(e) => !/^(?:\/\/|\*|\/\*)/.test(satirParcasi(icerik, e.index)),
),
},
];
const bulgular: Bulgu[] = [];
for (const yol of tsxDosyalari(KAYNAK)) {
const gorece = relative(KOK, yol);
const icerik = readFileSync(yol, "utf8");
for (const k of KURALLAR) {
if (!k.uiDahil && UI_VEYA_DEV.test(gorece)) continue;
if (k.muaf && k.muaf.test(gorece)) continue;
for (const e of k.bul(icerik)) {
bulgular.push({
dosya: gorece,
satir: satirNo(icerik, e.index),
kural: k.ad,
seviye: k.seviye,
mesaj: k.mesaj,
parca: satirParcasi(icerik, e.index),
});
}
}
}
bulgular.sort((a, b) =>
a.seviye !== b.seviye ? (a.seviye === "hata" ? -1 : 1) : a.dosya.localeCompare(b.dosya) || a.satir - b.satir,
);
for (const b of bulgular) {
const etiket = b.seviye === "hata" ? "HATA " : "uyarı";
console.log(`${b.dosya}:${b.satir} [${etiket}] ${b.kural} — ${b.mesaj}\n ${b.parca}`);
}
const hata = bulgular.filter((b) => b.seviye === "hata").length;
const uyari = bulgular.length - hata;
console.log(`\nTasarım denetimi: ${hata} hata, ${uyari} uyarı (${KURALLAR.length} kural, docs/tasarim/DESIGN.md)`);
process.exit(hata > 0 ? 1 : 0);