דילוג לתוכן הראשי
Code quality

ESLint

CLAUDE.md אומר מה לעשות. ESLint בודק שזה נעשה, ומסמן את השורה עם הסבר מה לכתוב במקום.

eslint.org

#lint-why

למה

כלל שכתוב רק ב-CLAUDE.md תלוי בזה שמי שכותב את הקוד קרא אותו וזכר אותו: אדם, v0, Cursor, או Claude באמצע שיחה ארוכה. כלל ב-ESLint נבדק בכל הרצה. ההפרה מקבלת קובץ, שורה, והודעה שכתבנו, שאומרת מה לעשות במקום. ל-AI זה הפידבק הכי ברור שיש: הוא מריץ, רואה את השגיאה, ומתקן לבד.

לא כל כלל מתאים. מה שאפשר לבדוק מכנית (תגית, import, קלאס) הולך לכאן. מה שדורש שיקול דעת (האם הקומפוננטה שייכת ל-uikit, האם הריווח נראה טוב) נשאר ב-CLAUDE.md.

#lint-run

להריץ

npm run lint                         # the whole project, ~10s
npx eslint app/demos/gym_24_09       # one folder
npx eslint app/page.tsx              # one file

כך נראית הפרה:

app/demos/x/page.tsx
  42:7  error  Internal link `${ROOT}/about`: use <Link> from next/link, not <a>.
               A plain <a> reloads the whole page (CLAUDE.md §1.4)  uk/internal-link

✖ 1 problem (1 error, 0 warnings)

שורה 42, עמודה 7, ההודעה, ושם הכלל בסוף. יש error והפקודה נכשלת (exit 1). ב-VS Code או ב-Cursor, התוסף ESLint מסמן את אותה שורה בקו אדום כבר בזמן הכתיבה. ה-build לא מריץ את ה-linter: Next 16 הוציא את זה, אז הבדיקה היא npm run lint.

#lint-rules

הכללים הפעילים

uk/internal-linkerrorCLAUDE.md §1.4

קישור פנימי עובר דרך <Link>

תגית <a> רגילה לכתובת פנימית עובדת, ובגלל זה היא עוברת בלי שאף אחד שם לב. אבל הדפדפן זורק את העמוד וטוען אותו מחדש: ה-state מתאפס, Lenis ו-GSAP עולים מאפס, ואין prefetch. <Link> מחליף רק את מה שהשתנה.

נכשל

<a href="/about">אודות</a>
<a href={`${ROOT}/contact`}>צור קשר</a>
<a href={`${variant.root}/practice/${slug}`}>…</a>
<a href="/#pricing">מחירים</a>

עובר

import Link from 'next/link';

<Link href="/about">אודות</Link>
<Link href={`${ROOT}/contact`}>צור קשר</Link>

לא נבדק בכוונה: עוגן באותו עמוד (#contact), כתובות חיצוניות (https:, mailto:, tel:, //cdn), קובץ להורדה (<a download> או נתיב עם סיומת כמו .pdf), ו-href שהוא משתנה, כי אי אפשר לדעת מראש לאן הוא מוביל.

הקוד: eslint-rules/internal-link.mjs

uk/demo-no-static-styleerrorCLAUDE.md §1.8

ערך קבוע לא נכנס ל-style={{}} (בדמואים)

ל-style inline אין @media, אין :hover, אין טוקן ואין מקום אחד לשנות בו. grid של 280px באינליין נמעך בנייד, ו-margin: 0 על אותה כותרת ב-12 קבצים הוא 12 עריכות.

נכשל

<p style={{ maxWidth: '52ch' }}>…</p>
<h2 style={{ margin: 0, fontSize: '2rem' }}>…</h2>
<div style={{ display: 'grid', gridTemplateColumns: '280px 1fr' }}>

עובר

<p className="max-w-[52ch]">…</p>
<h2 className="xx-section-title">…</h2>   /* בקובץ ה-CSS של הדמו */
<div style={{ color: item.color }}>          /* ערך מהנתונים: מותר */
<div style={{ '--delay': `${i * 0.1}s` }}>  /* custom property: מותר */

לא נבדק בכוונה: ערך שמגיע מנתונים או מ-props (משתנה, ביטוי, template עם ${…}), custom properties (--x), ואובייקט style שלם ממשתנה. חריג אמיתי, כמו קומפוננטה חיצונית שמקבלת רק style, מקבל eslint-disable-next-line עם סיבה.

הקוד: eslint-rules/demo-no-static-style.mjs

uk/demo-tel-unlinkederrorskill: פרטי קשר מומצאים

מספר טלפון בדמו הוא טקסט, לא קישור

כל הדמואים מציגים את אותו מספר מומצא, 077-4444-4444. קישור tel: אליו מחייג למספר שלא קיים.באתר אמיתי המספר לחיץ, ולכן הכלל מוגבל ל-app/demos.

נכשל

<a href={S.phoneHref} dir="ltr">{S.phone}</a>
<a href={`tel:${phone}`}>{phone}</a>

עובר

<span dir="ltr">{S.phone}</span>   /* תיקון אוטומטי: npx eslint --fix app/demos */

לא נבדק בכוונה: href שנבנה בתוך מערך נתונים (href: phoneHref בתוך אובייקט) הכלל לא רואה: שם מורידים את הקישור בנתונים.

הקוד: eslint-rules/demo-tel-unlinked.mjs

uk/demo-link-new-taberrorהחלטה, 2.10

קישור לדמו נפתח בחלון חדש

מי שפותח דמו מתוך אתר הסטודיו צריך שהעמוד שממנו יצא יישאר פתוח, באותו מקום בגלילה. הכלל חל על כל הקבצים חוץ מ-app/demos, כי בתוך דמו הקישורים לעמודים שלו נשארים באותו חלון.

נכשל

<Link href="/demos/yoga">יוגה</Link>
<a href={`/demos/${slug}`}>…</a>

עובר

<Link href="/demos/yoga" target="_blank" rel="noopener">יוגה</Link>

לא נבדק בכוונה: href שהוא משתנה (demo.url, e.href) הכלל לא רואה. אותם פותח בחלון חדש ה-click handler הגלובלי ב-es-scripts/_init/uk_global_init.tsx, ובכל זאת עדיף לכתוב עליהם target="_blank".

הקוד: eslint-rules/demo-link-new-tab.mjs

uk/lead-tokenerrorthe vertical-rhythm skill, דוח 12.10 סעיף 5

פסקת פתיחה מקבלת את טוקן ה-lead

פסקה שגדולה בדרגה אחת מהגוף (שורה מתחת לכותרת סקשן, משפט ב-hero) הוקלדה כ-text-base md:text-lg leading-relaxed.מאז 12.10 זה .uk-text-lead, שקורא את --lead-size מ-app/globals.css: אותם ערכים, ומותג משנה את כל הפסקאות האלה במקום אחד. הכלל תופס את השלישייה בכל סדר, כי סשן שמעתיק עמוד ישן היה מקליד אותה שוב.

נכשל

<p className="text-base md:text-lg leading-relaxed text-muted-foreground">…</p>

עובר

<p className="uk-text-lead text-muted-foreground">…</p>   /* תיקון אוטומטי: npx eslint --fix */

לא נבדק בכוונה:

הקוד: eslint-rules/lead-token.mjs

uk/no-em-dasherrorCLAUDE.md §1.5, 2.11

בלי em-dash (—) במלל שגולש קורא

ה-em-dash הוא סימן היכר של מלל שכתב AI. הכלל בודק טקסט ב-JSX ומחרוזת של מאפיין (alt, title, aria-label), חוץ מ-className.

נכשל

<p>אתר מהיר — ויפה</p>
<img alt="צוות — משרד" />

עובר

<p>אתר מהיר, ויפה</p>

לא נבדק בכוונה: הערות בקוד, הדוקס (נכתב למפתחים), ומחרוזות בקבצי דאטה של tinyCMS: אותן קורא check:hebrew.

הקוד: eslint-rules/no-em-dash.mjs

uk/heading-own-sizewarnthe vertical-rhythm skill, החלטה 4.10

כותרת לוקחת גודל מהטוקן של הרמה שלה (בדמואים)

h1 עד h6 מקבלים גודל מהטוקנים (--h2-size וכו׳, עם המקדמים והתקרות לנייד), וזה מספיק לאתר שלם. גודל שנקבע על הקלאס של כותרת, או text-2xl על h3, עוקף את התקרות בנייד. warn ולא error: כותרות כאלה כבר קיימות.

נכשל

<h3 className="text-2xl">למה כדאי</h3>
<h3 className="card__title">…</h3>   /* .card__title { font-size: 1.5rem } */
<h2 style={{ fontSize: '2rem' }}>…</h2>

עובר

<h2>למה כדאי</h2>                        /* כותרת של סקשן היא h2 */
<h3 className="card__title">…</h3>   /* .card__title { --h3-size: 1.25rem } */
<h3 ignore-rhythm="תצוגה, 60px בכוונה">…</h3>   /* חריג מכוון: רק בהחלטה שלכם */

לא נבדק בכוונה: כותרת עם ignore-rhythm, קלאסים של הארסנל (uk-*, שם הגודל מאחורי טוקן בכוונה), וסלקטור שבו הקלאס הוא של אב (.card h3): הכלל קורא רק את החלק האחרון של הסלקטור.

הקוד: eslint-rules/heading-own-size.mjs

#lint-anatomy

איך זה בנוי

שני קבצים. eslint.config.mjs בשורש אומר על אילו קבצים לרוץ, איך לקרוא אותם, ואילו כללים פעילים. eslint-rules/ מחזיק את הכללים שכתבנו בעצמנו.

export default [
  { ignores: ['.next/**', 'node_modules/**', 'public/**'] },   // never look here
  {
    files: ['**/*.{jsx,tsx}'],                                  // which files
    languageOptions: { parser: babelParser, … },                // how to read them
    plugins: { uk: { rules: { 'internal-link': internalLink } } },  // our rules, prefix "uk/"
    rules: {
      'uk/internal-link': 'error',                              // what is on, and how hard
    },
  },
];

רמות

'off' כבוי. 'warn' קו צהוב, הפקודה עוברת. 'error' קו אדום, הפקודה נכשלת. כלל חדש שיש לו הרבה הפרות ישנות מתחיל כ-warn.

כלל מובנה או כלל שלנו

ל-ESLint יש מאות כללים מוכנים, ואז מספיקה שורה אחת. כלל שלנו הוא קובץ JS שמקבל את העץ של הקוד (AST) ומדווח על צמתים. internal-link מסתכל על כל תגית a ובודק במה ה-href מתחיל.

למה Babel ולא typescript-eslint

הפרויקט על TypeScript 7, הקומפיילר הנייטיב, ואין לו את ה-API ש-typescript-eslint צריך (הוא תומך עד 6.1). Babel קורא TS ו-JSX לבד, וזה כל מה שכלל תחבירי צריך. ההגדרות שלו inline בכוונה: babel.config.js בשורש היה מעביר את Next לקמפל עם Babel במקום SWC.

זמני, לנקות בהמשך

Babel במקום typescript-eslint, בגלל TypeScript 7

הפרויקט על TypeScript 7, ו-typescript-eslint תומך כרגע רק עד 6.1. לכן ESLint קורא את הקוד דרך Babel, ובגלל Babel הוא נעול על גרסה 9 ולא 10. זה עובד, אבל זה עקיפה. כש-typescript-eslint יתמוך ב-TypeScript 7, מנקים:

  • מסירים את ארבע חבילות @babel/* מ-devDependencies.
  • ב-eslint.config.mjs מחליפים את babelParser ב-parser של typescript-eslint.
  • ESLint עולה ל-10.
  • בונוס: נפתחים כללים שמבינים טיפוסים, מה ש-Babel לא יודע לעשות.

איך בודקים אם הגיע הזמן (צריך לראות typescript שכולל את 7):

npm view typescript-eslint peerDependencies

#lint-disable

חריגה מוצהרת

כשיש סיבה אמיתית לחרוג, כותבים את זה בשורה שמעל, עם הסיבה. החריגה נשארת גלויה: מי שקורא רואה שזו החלטה ולא טעות, וחיפוש של eslint-disable בפרויקט מראה את כל החריגות במקום אחד.

{/* eslint-disable-next-line uk/internal-link -- full reload on purpose: resets the demo state */}
<a href={`${ROOT}`}>התחל מחדש</a>

#lint-next

הכללים הבאים

עוד לא פעילים. כל אחד כלל ב-CLAUDE.md שאפשר לבדוק מכנית. מוסיפים אחד בכל פעם: הכלל, הודעה שאומרת מה לעשות במקום, הרצה על כל הפרויקט, ואם יש הפרות ישנות, warn עד שהן מנוקות.

טיפוגרפיה על כותרות

the vertical-rhythm skill

לחסום רק קלאסים של גודל, משקל ו-leading על h1-h6 ו-p, לא את כל ה-className: קלאסי פריסה כמו max-w-xl ו-mx-auto מותרים. יש הרבה הפרות ישנות בדמואים, אז להתחיל כ-warn.

#lint-ci

ב-GitHub, בכל push

הבדיקות רצות גם בלי שאף אחד יזכור: .github/workflows/checks.yml מריץ אותן על המכונות של GitHub בכל push ל-main ובכל pull request. אין בזה טוקנים של AI. ריצה אדומה = דף הסיכום של הריצה מראה את העמוד והבעיה המדויקת, בלי לפתוח לוג. דחיפה ישירה ל-main נבדקת גם היא, אבל רק מזהירה, לא חוסמת.

lint

eslint.config.mjs

הכללים שבדף הזה. שגיאות בלבד: האזהרות (גדלי כותרות) הן רשימת מטלות, לא שער.

check:seo

scripts/check-seo.mjs

ה-head של כל עמוד: title, description, canonical, og:image, JSON-LD, noindex בדמואים.

check:headings

scripts/check-headings.mjs

סדר הכותרות h1 עד h6 בעמודי הסטודיו ובכל עמוד דמו (רץ על האתר הבנוי, בדפדפן).

check:variants

scripts/check-variant-links.mjs

אין לינק שדולף בין הבתים של דמו אחד (בית א׳ שמקשר לעמוד פנימי של בית ב׳).

כל בדיקה רצה גם כשהקודמת נכשלה, כך שבדיקה אדומה אחת לא מסתירה את האחרות. מקומית אותן פקודות: npm run lint, npm run check:seo, npm run check:headings, npm run check:variants (השלוש האחרונות על שרת dev רץ). קומיט שנדחה בגלל הבדיקות: הסיבה נמצאת בדף הסיכום של הריצה ב-GitHub, בלשונית Actions.