Files
kolaytercih/scripts/tasarim-denetim.ts
bilalgursen 3c995402c6 feat(ui): Arc bileşenleri, landing bölümleri ve bildirim yığını
Sonner/vaul yerine kendi bildirim yığını ve alt sayfa; çip grubu, kart
destesi, kaydırma eylemi, yazı dönüşümü, bölme tutamacı; landing'e program
duvarı, süzülen haplar ve veri akışı; yaylar tek dosyada (lib/hareket.ts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:12:53 +03:00

329 lines
12 KiB
TypeScript
Raw Permalink 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-card px-3\.5 py-1\.5/),
},
{
ad: "modal-rozet",
seviye: "hata",
mesaj: "Çekmece/modal içinde SectionEyebrow yok; başlık + tek cümle bağlam (DESIGN.md §4.10)",
bul: blokIcinde(["AltSayfaIcerik", "DialogContent"], /<SectionEyebrow\b/),
},
{
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: "ince-form-alani",
seviye: "hata",
mesaj: "Input/SelectTrigger/Textarea üstüne yükseklik, köşe ya da zemin yazılmaz; hap varsayılanı kullanılır (DESIGN.md §4.6)",
bul: (icerik) => {
const sonuc: Array<{ index: number; parca: string }> = [];
// Açılış etiketinden ilk > işaretine kadar olan blok (self-closing dahil)
const acilis = /<(?:Input|SelectTrigger|Textarea)\b[^>]*?>/g;
const yasak =
/\b(?:(?:sm|md|lg):)?(?:h-(?:7|8|9|10)|rounded-(?:sm|md|lg|xl)|bg-background|border-input)\b/;
for (const m of icerik.matchAll(acilis)) {
const y = yasak.exec(m[0]);
if (y) sonuc.push({ index: (m.index ?? 0) + y.index, parca: y[0] });
}
return sonuc;
},
},
{
ad: "ham-input",
seviye: "uyari",
mesaj: "Kenarlıklı ham <input> yerine Input bileşeni; kenarlıksız ham input yalnız hap kabının içinde (DESIGN.md §4.6)",
bul: (icerik) => {
const sonuc: Array<{ index: number; parca: string }> = [];
for (const m of icerik.matchAll(/<input\b[^>]*?>/g)) {
if (/type=["'](?:hidden|checkbox|radio|range|file)["']/.test(m[0])) continue;
const k = /\bborder(?:-[a-z0-9/]+)?\b/.exec(m[0]);
if (k) sonuc.push({ index: (m.index ?? 0) + k.index, parca: k[0] });
}
return sonuc;
},
},
{
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: "hata",
mesaj: "Karanlık mod token'la çalışır; dark: sınıfı yazılmaz, .dark bloğuna karşılık eklenir (DESIGN.md §2.3)",
uiDahil: true,
bul: regexKurali(/(?<=[\s"'`])dark:[a-z]/),
},
{
ad: "yuzey-beyaz",
seviye: "hata",
mesaj: "Yüzey zemini bg-card; bg-white koyu temada da beyaz kalır (DESIGN.md §2.3)",
// Bilinçli beyaz kalanlar: logo karosu, kaydırıcı topuzu, sabit paletli
// rehber kapağı. Renkli dolu zemin üstündeki bg-white/15 serbest.
uiDahil: true,
muaf: /src\/components\/(?:uni-logo|ui\/slider|ui\/skiper-ui\/skiper41)\.tsx$|src\/features\/rehber\/components\/rehber-kapak\.tsx$/,
bul: regexKurali(/(?<![\w-])(?:bg|from|to|via)-white(?!\/15)(?![\w-])/),
},
{
ad: "koyu-hap-beyaz-yazi",
seviye: "hata",
mesaj: "Koyu hapta (bg-slate-900) yazı text-slate-50; text-white koyu temada açık hap üstünde kaybolur (DESIGN.md §2.3)",
uiDahil: true,
bul: regexKurali(/\bbg-slate-900\b[^"'`\n]*\btext-white\b|\btext-white\b[^"'`\n]*\bbg-slate-900\b/),
},
{
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: "tablo-minik-yazi",
seviye: "hata",
mesaj:
"Tablo hücresi/başlığında text-xs yok: hücre text-sm, başlık SATIR_BASLIK (DESIGN.md §4.15, §4.19, §3). 24 Eyl 2026 netler panelinde yaşandı",
bul: regexKurali(/<Table(?:Cell|Head)\b[^>]*\btext-xs\b/),
},
{
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. Satırın tamamı link olduğunda group-hover çifti de sayılır.
// (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) &&
/\b(?:group-)?hover:text-primary (?:group-)?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: "samimiyetsiz-unlem",
seviye: "uyari",
mesaj: "Hata/bildirim metninde 'Hay aksi/Eyvah/Oops' ya da 'buraya tıkla' yok; ne oldu + ne yapılacak (DESIGN.md §10.4)",
bul: regexKurali(/Hay aksi|Eyvah|\bOops|\bUups|[Bb]uraya tıkla|[Tt]ıklayın(?:ız)?\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);