לדלג לתוכן

11.3 בינלאומיות הרצאה

בינלאומיות - i18n

בינלאומיות (Internationalization - i18n) היא התהליך של הכנת אפליקציה לתמיכה בשפות ותרבויות שונות. לוקליזציה (Localization - l10n) היא ההתאמה בפועל לשפה ותרבות ספציפית. בשיעור זה נלמד כיצד לבנות אפליקציות רב-לשוניות, כולל תמיכה ב-RTL שחשובה במיוחד לעברית.


מושגי יסוד

  • i18n (internationalization - 18 אותיות בין i ל-n) - הארכיטקטורה שמאפשרת תמיכה בשפות שונות
  • l10n (localization - 10 אותיות בין l ל-n) - התרגום וההתאמה בפועל לשפה ספציפית
  • locale - מזהה שפה ואזור, למשל he-IL (עברית - ישראל), en-US (אנגלית - ארה"ב)

הגדרת react-i18next

ספריית react-i18next היא הפתרון הנפוץ ביותר לניהול תרגומים באפליקציות ריאקט.

npm install i18next react-i18next i18next-browser-languagedetector i18next-http-backend

הגדרת i18next

// lib/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import HttpBackend from 'i18next-http-backend';

i18n
  .use(HttpBackend) // טעינת תרגומים מקבצים
  .use(LanguageDetector) // זיהוי שפת המשתמש אוטומטית
  .use(initReactI18next) // חיבור לריאקט
  .init({
    fallbackLng: 'he', // שפת ברירת מחדל
    supportedLngs: ['he', 'en', 'ar'],
    defaultNS: 'common', // namespace ברירת מחדל
    ns: ['common', 'auth', 'dashboard'],

    interpolation: {
      escapeValue: false, // ריאקט כבר עושה escape
    },

    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json',
    },

    detection: {
      order: ['cookie', 'localStorage', 'navigator', 'htmlTag'],
      caches: ['cookie', 'localStorage'],
    },
  });

export default i18n;

קבצי תרגום

// public/locales/he/common.json
{
  "app": {
    "title": "האפליקציה שלי",
    "description": "אפליקציית דוגמה עם תמיכה רב-לשונית"
  },
  "nav": {
    "home": "דף הבית",
    "about": "אודות",
    "contact": "יצירת קשר",
    "settings": "הגדרות"
  },
  "actions": {
    "save": "שמור",
    "cancel": "ביטול",
    "delete": "מחק",
    "edit": "ערוך",
    "confirm": "אישור"
  },
  "messages": {
    "welcome": "שלום, {{name}}!",
    "itemCount": "{{count}} פריט",
    "itemCount_other": "{{count}} פריטים",
    "lastUpdated": "עודכן לאחרונה: {{date}}"
  }
}
// public/locales/en/common.json
{
  "app": {
    "title": "My Application",
    "description": "A sample application with multi-language support"
  },
  "nav": {
    "home": "Home",
    "about": "About",
    "contact": "Contact",
    "settings": "Settings"
  },
  "actions": {
    "save": "Save",
    "cancel": "Cancel",
    "delete": "Delete",
    "edit": "Edit",
    "confirm": "Confirm"
  },
  "messages": {
    "welcome": "Hello, {{name}}!",
    "itemCount_one": "{{count}} item",
    "itemCount_other": "{{count}} items",
    "lastUpdated": "Last updated: {{date}}"
  }
}

שימוש בתרגומים

// namespaces נפרדים
// public/locales/he/auth.json
{
  "login": {
    "title": "התחברות",
    "email": "אימייל",
    "password": "סיסמה",
    "submit": "התחבר",
    "forgotPassword": "שכחת סיסמה?",
    "noAccount": "אין לך חשבון?",
    "register": "הרשמה"
  }
}
'use client';

import { useTranslation } from 'react-i18next';

function LoginPage() {
  // שימוש ב-namespace ספציפי
  const { t } = useTranslation('auth');

  return (
    <form>
      <h1>{t('login.title')}</h1>

      <label htmlFor="email">{t('login.email')}</label>
      <input id="email" type="email" />

      <label htmlFor="password">{t('login.password')}</label>
      <input id="password" type="password" />

      <button type="submit">{t('login.submit')}</button>

      <a href="/forgot-password">{t('login.forgotPassword')}</a>
      <p>
        {t('login.noAccount')} <a href="/register">{t('login.register')}</a>
      </p>
    </form>
  );
}
// שימוש עם אינטרפולציה וריבוי
function Dashboard({ user, items }: { user: { name: string }; items: unknown[] }) {
  const { t } = useTranslation();

  return (
    <div>
      {/* אינטרפולציה - הצבת ערכים */}
      <h1>{t('messages.welcome', { name: user.name })}</h1>

      {/* ריבוי - singular/plural */}
      <p>{t('messages.itemCount', { count: items.length })}</p>

      {/* עיצוב תאריכים */}
      <p>{t('messages.lastUpdated', { date: new Date().toLocaleDateString() })}</p>
    </div>
  );
}

החלפת שפה - Language Switching

'use client';

import { useTranslation } from 'react-i18next';

const languages = [
  { code: 'he', name: 'עברית', dir: 'rtl' },
  { code: 'en', name: 'English', dir: 'ltr' },
  { code: 'ar', name: 'العربية', dir: 'rtl' },
];

function LanguageSwitcher() {
  const { i18n } = useTranslation();

  const changeLanguage = (lng: string) => {
    i18n.changeLanguage(lng);

    // עדכון כיוון הדף
    const lang = languages.find((l) => l.code === lng);
    if (lang) {
      document.documentElement.dir = lang.dir;
      document.documentElement.lang = lng;
    }
  };

  return (
    <div role="group" aria-label="בחירת שפה">
      {languages.map((lang) => (
        <button
          key={lang.code}
          onClick={() => changeLanguage(lang.code)}
          aria-pressed={i18n.language === lang.code}
          lang={lang.code}
        >
          {lang.name}
        </button>
      ))}
    </div>
  );
}

תמיכה ב-RTL

תמיכה ב-RTL (Right-to-Left) חשובה במיוחד עבור עברית וערבית. הנה הכלים והטכניקות:

שימוש ב-CSS Logical Properties

/* במקום left/right - השתמשו ב-inline-start/inline-end */

/* לא מומלץ - שובר ב-RTL */
.sidebar {
  margin-left: 20px;
  padding-right: 16px;
  text-align: left;
  border-left: 2px solid blue;
}

/* מומלץ - עובד גם ב-LTR וגם ב-RTL */
.sidebar {
  margin-inline-start: 20px;
  padding-inline-end: 16px;
  text-align: start;
  border-inline-start: 2px solid blue;
}

/* מיפוי מלא */
/* margin-left    -> margin-inline-start */
/* margin-right   -> margin-inline-end */
/* padding-left   -> padding-inline-start */
/* padding-right  -> padding-inline-end */
/* left           -> inset-inline-start */
/* right          -> inset-inline-end */
/* text-align: left  -> text-align: start */
/* text-align: right -> text-align: end */
/* border-left    -> border-inline-start */
/* float: left    -> float: inline-start */

הגדרת כיוון ב-Layout

// app/layout.tsx
import { cookies } from 'next/headers';

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const cookieStore = await cookies();
  const locale = cookieStore.get('i18next')?.value || 'he';
  const dir = ['he', 'ar'].includes(locale) ? 'rtl' : 'ltr';

  return (
    <html lang={locale} dir={dir}>
      <body>{children}</body>
    </html>
  );
}

Flexbox ו-RTL

/* Flexbox מתהפך אוטומטית ב-RTL! */
.container {
  display: flex;
  gap: 16px;
}

/* ב-LTR: [item1] [item2] [item3] -> */
/* ב-RTL: <- [item3] [item2] [item1] */

/* אם רוצים למנוע היפוך */
.container-no-flip {
  display: flex;
  direction: ltr; /* תמיד LTR */
}

אייקונים ב-RTL

// חלק מהאייקונים צריכים להתהפך ב-RTL
function DirectionalIcon({ icon }: { icon: 'arrow' | 'check' }) {
  const { i18n } = useTranslation();
  const isRTL = i18n.dir() === 'rtl';

  // חץ צריך להתהפך, סימן V לא
  const shouldFlip = icon === 'arrow' && isRTL;

  return (
    <span style={{ transform: shouldFlip ? 'scaleX(-1)' : 'none' }}>
      {icon === 'arrow' ? '>' : 'V'}
    </span>
  );
}

עיצוב תאריכים, מספרים ומטבעות - Intl API

ה-Intl API של JavaScript מספק כלים מובנים לעיצוב לוקלי:

עיצוב מספרים

// עיצוב מספרים
const number = 1234567.89;

new Intl.NumberFormat('he-IL').format(number);
// "1,234,567.89"

new Intl.NumberFormat('en-US').format(number);
// "1,234,567.89"

new Intl.NumberFormat('de-DE').format(number);
// "1.234.567,89"

// עיצוב אחוזים
new Intl.NumberFormat('he-IL', {
  style: 'percent',
  minimumFractionDigits: 1,
}).format(0.256);
// "25.6%"

עיצוב מטבעות

// מטבעות
new Intl.NumberFormat('he-IL', {
  style: 'currency',
  currency: 'ILS',
}).format(99.90);
// "99.90 ₪"

new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
}).format(99.90);
// "$99.90"

new Intl.NumberFormat('ja-JP', {
  style: 'currency',
  currency: 'JPY',
}).format(1500);
// "¥1,500"

עיצוב תאריכים

const date = new Date('2024-03-15T14:30:00');

// עיצוב בסיסי
new Intl.DateTimeFormat('he-IL').format(date);
// "15.3.2024"

new Intl.DateTimeFormat('en-US').format(date);
// "3/15/2024"

// עיצוב מפורט
new Intl.DateTimeFormat('he-IL', {
  weekday: 'long',
  year: 'numeric',
  month: 'long',
  day: 'numeric',
}).format(date);
// "יום שישי, 15 במרץ 2024"

// זמן יחסי
const rtf = new Intl.RelativeTimeFormat('he', { numeric: 'auto' });
rtf.format(-1, 'day');    // "אתמול"
rtf.format(2, 'hour');    // "בעוד שעתיים"
rtf.format(-3, 'month');  // "לפני 3 חודשים"

Hook שימושי לעיצוב

import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';

function useFormatters() {
  const { i18n } = useTranslation();
  const locale = i18n.language;

  return useMemo(() => ({
    number: (value: number) =>
      new Intl.NumberFormat(locale).format(value),

    currency: (value: number, currency = 'ILS') =>
      new Intl.NumberFormat(locale, { style: 'currency', currency }).format(value),

    percent: (value: number) =>
      new Intl.NumberFormat(locale, { style: 'percent' }).format(value),

    date: (value: Date, options?: Intl.DateTimeFormatOptions) =>
      new Intl.DateTimeFormat(locale, options).format(value),

    relativeTime: (value: number, unit: Intl.RelativeTimeFormatUnit) =>
      new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(value, unit),
  }), [locale]);
}

// שימוש
function ProductCard({ price, lastUpdated }: { price: number; lastUpdated: Date }) {
  const fmt = useFormatters();

  return (
    <div>
      <p>{fmt.currency(price)}</p>
      <p>{fmt.date(lastUpdated, { dateStyle: 'long' })}</p>
    </div>
  );
}

כללי ריבוי - Pluralization Rules

שפות שונות מטפלות בריבוי בצורות שונות. i18next תומך בכל הכללים:

// עברית - 2 צורות (יחיד וריבוי)
{
  "items_one": "פריט אחד",
  "items_other": "{{count}} פריטים"
}

// אנגלית - 2 צורות
{
  "items_one": "{{count}} item",
  "items_other": "{{count}} items"
}

// ערבית - 6 צורות!
{
  "items_zero": "لا عناصر",
  "items_one": "عنصر واحد",
  "items_two": "عنصران",
  "items_few": "{{count}} عناصر",
  "items_many": "{{count}} عنصراً",
  "items_other": "{{count}} عنصر"
}
// שימוש - i18next בוחר את הצורה הנכונה אוטומטית
function ItemCounter({ count }: { count: number }) {
  const { t } = useTranslation();

  return <p>{t('items', { count })}</p>;
  // count=0: "0 פריטים"
  // count=1: "פריט אחד"
  // count=5: "5 פריטים"
}

SEO לאתרים רב-לשוניים

תגיות hreflang

// app/layout.tsx - הוספת hreflang ב-Next.js
import { type Metadata } from 'next';

export const metadata: Metadata = {
  alternates: {
    canonical: 'https://myapp.com',
    languages: {
      'he-IL': 'https://myapp.com/he',
      'en-US': 'https://myapp.com/en',
      'ar-SA': 'https://myapp.com/ar',
      'x-default': 'https://myapp.com',
    },
  },
};
<!-- הפלט ב-HTML -->
<link rel="alternate" hreflang="he-IL" href="https://myapp.com/he" />
<link rel="alternate" hreflang="en-US" href="https://myapp.com/en" />
<link rel="alternate" hreflang="ar-SA" href="https://myapp.com/ar" />
<link rel="alternate" hreflang="x-default" href="https://myapp.com" />

ניתוב לפי שפה ב-Next.js

// middleware.ts - ניתוב אוטומטי לפי שפה
import { NextRequest, NextResponse } from 'next/server';

const locales = ['he', 'en', 'ar'];
const defaultLocale = 'he';

function getLocale(request: NextRequest): string {
  // בדיקת עוגייה
  const cookieLocale = request.cookies.get('locale')?.value;
  if (cookieLocale && locales.includes(cookieLocale)) {
    return cookieLocale;
  }

  // בדיקת Accept-Language header
  const acceptLanguage = request.headers.get('accept-language') || '';
  for (const locale of locales) {
    if (acceptLanguage.includes(locale)) {
      return locale;
    }
  }

  return defaultLocale;
}

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // בדיקה אם כבר יש locale ב-URL
  const hasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (hasLocale) return;

  // ניתוב לשפה המתאימה
  const locale = getLocale(request);
  return NextResponse.redirect(
    new URL(`/${locale}${pathname}`, request.url)
  );
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Sitemap רב-לשוני

// app/sitemap.ts
import { type MetadataRoute } from 'next';

export default function sitemap(): MetadataRoute.Sitemap {
  const baseUrl = 'https://myapp.com';
  const locales = ['he', 'en', 'ar'];
  const pages = ['', '/about', '/contact', '/products'];

  return pages.flatMap((page) =>
    locales.map((locale) => ({
      url: `${baseUrl}/${locale}${page}`,
      lastModified: new Date(),
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [l, `${baseUrl}/${l}${page}`])
        ),
      },
    }))
  );
}

בינלאומיות ב-Next.js עם next-intl

חלופה פופולרית ל-react-i18next ב-Next.js:

npm install next-intl
// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async ({ locale }) => ({
  messages: (await import(`../messages/${locale}.json`)).default,
}));
// שימוש בקומפוננטה (Server Component)
import { useTranslations } from 'next-intl';

function HomePage() {
  const t = useTranslations('home');

  return (
    <div>
      <h1>{t('title')}</h1>
      <p>{t('description')}</p>
    </div>
  );
}

סיכום

בשיעור זה למדנו על:

  • i18n ו-l10n - ההבדל בין הכנת התשתית לבין התרגום בפועל
  • react-i18next - הגדרה, קבצי תרגום, namespaces ושימוש
  • החלפת שפה - מנגנון בחירת שפה ועדכון הממשק
  • תמיכה ב-RTL - CSS Logical Properties, הגדרת כיוון, היפוך אלמנטים
  • Intl API - עיצוב מספרים, מטבעות, תאריכים וזמן יחסי
  • כללי ריבוי - טיפול בריבוי שונה בכל שפה
  • SEO רב-לשוני - hreflang, ניתוב לפי שפה, Sitemap

תמיכה רב-לשונית היא השקעה שמשתלמת - היא מאפשרת להגיע לקהל רחב יותר ומשפרת את חוויית המשתמש.