All checks were successful
Deploy / deploy (push) Successful in 5m3s
- "Son yerleşenin netleri" ortada açılan Dialog ve text-xs hücreli tabloydu; şimdi Drawer (kap/çarpı cta-sira-form çekmecesiyle birebir), ortalı başlık + bağlam cümlesi, SATIR_BASLIK başlıklar, text-sm hücreler, en yeni yıl koyu, "Yerleşme puanı" satırı ayrılmış (DESIGN.md §4.10, §4.15, yeni §4.19). - Denetim: tablo-minik-yazi (hata) — TableCell/TableHead içinde text-xs yasak; §9.5 bitirmeden listesine ayrıntı paneli/tablo/buton kontrolü eklendi. - Kayıtlı-sıra ok düğmesi: shadow-lg kaldırıldı (§6), hover'da renk değişmiyor (orange-600 iri yüzeyde kırmızımsı okunuyordu); hover scale + ok kayması. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
/**
|
||
* 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: "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(["DrawerContent", "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: "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: "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);
|