تخطَّ إلى المحتوى

موقع Next.js متعدّد اللغات، من طرفه إلى طرفه

التوجيه والرسائل واتجاه الكتابة والبيانات الوصفية وhreflang وخرائط الموقع، والأجزاء التي لا تنكسر إلا في اللغة الرابعة. المعمارية التي نطلقها، بقراراتها التي لا رجعة فيها مُعلَّمة.

11 دقيقة قراءة

تُبنى معظم مواقع Next.js متعدّدة اللغات مرّتين. النسخة الأولى توجّه حسب اللغة، وتترجم النصوص، وتُطلَق، ثم تقضي ستة أشهر في اكتشاف أن خريطة الموقع تسرد لغة واحدة، وأن التخطيط العربي فوضى معكوسة، وأن البيانات الوصفية إنجليزية في كل مكان، وأن لا أحد يستطيع إضافة لغة دون لمس أربعين ملفًّا.

هذه هي المعمارية التي تجنّبك البناء الثاني. القرارات المكلفة التراجع عنها مُعلَّمة؛ وما عداها تستطيع تغييره يوم ثلاثاء.

شكل الرابط، وهو ما لا تستطيع تغييره لاحقًا

ثلاثة خيارات، واثنان فقط منها يُدافَع عنهما:

الشكلمثالالحكم
مجلّد فرعيexample.com/de/blogالافتراضي. سلطة نطاق واحدة، ونشر واحد.
نطاق فرعيde.example.com/blogفقط إن كانت فرق منفصلة تدير اللغات.
معامل استعلامexample.com/blog?lang=deأبدًا. الزواحف تراها صفحة واحدة.

هذا هو القرار الذي لا رجعة فيه. تغييره لاحقًا يعني إعادة توجيه كل رابط في الموقع والانتظار حتى يلحق الفهرس، وذلك يستغرق شهورًا. احسمه قبل أول نشر.

وداخل شكل المجلّد الفرعي قرار ثانٍ: هل تحمل اللغة الافتراضية بادئة؟ بلا بادئة (/blog للإنجليزية و/de/blog للألمانية) يُبقي الرابط الأقصر لأكبر جمهور، وهو ما نطلقه. ومع بادئة (/en/blog) أكثر تناظرًا وأسهل في التفكير. كلاهما يعمل؛ وخلطهما لا يعمل.

// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
 
export const routing = defineRouting({
  locales: ['en', 'ar', 'de', 'es'],
  defaultLocale: 'en',
  // 'as-needed' تترك اللغة الافتراضية بلا بادئة. و'always' تضع بادئة لكل
  // لغة. هذا الإعداد هو ما يحدّد شكل روابطك.
  localePrefix: 'as-needed',
});

التوجيه بلا إعادة توجيه في كل طلب

مقطع اللغة مقطع ديناميكي، والشجرة كلّها تحته:

app/
  [locale]/
    layout.tsx
    page.tsx
    blog/
      page.tsx
      [slug]/page.tsx

ولّد المعاملات الثابتة كي تُعرَض كل لغة مسبقًا بدل أن تُعرَض عند الطلب:

// app/[locale]/page.tsx
import { routing } from '@/i18n/routing';
import { setRequestLocale } from 'next-intl/server';
 
export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}
 
export default async function Page({ params }) {
  const { locale } = await params;
  // بدون هذا تنتقل الصفحة إلى العرض الديناميكي، لأن قراءة اللغة من الطلب
  // قراءةٌ وقت الطلب. سطر واحد، وهو الفرق بين مسار ثابت وعرضٍ لكل زائر.
  setRequestLocale(locale);
 
  // ...
}

setRequestLocale هو السطر الذي يُغفَل، وعَرَضه مخرجات بناء يظهر فيها كل مسار مترجَم بعلامة ƒ بدل . وهو يكلّفك شبكة التوزيع في كل صفحة من الموقع.

الرسائل، وألّا ترسلها كلّها

حمّل رسائل لغة واحدة لا أربع. فالاستيراد الساذج لفهرس رسائل يضع كل لغة في كل حزمة.

// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
 
export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale;
  const locale = routing.locales.includes(requested) ? requested : routing.defaultLocale;
 
  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

الاستيراد الديناميكي هو ما يُبقي الثلاث الأخريات خارج الحمولة. تحقّق: إن ظهر de.json في حزمة المسار الإنجليزي، فقد رُفِع الاستيراد إلى مكان ما.

أبقِ ملفات الرسائل متطابقة في بنيتها. فمفتاح ناقص في لغة خطأ وقت تشغيل في الإنتاج ولا شيء غير ذلك، ولذلك يستحقّ اختبارًا:

// أي مفتاح موجود في الإنجليزية يجب أن يوجد في كل لغة.
const flatten = (obj, prefix = '') =>
  Object.entries(obj).flatMap(([k, v]) =>
    typeof v === 'object' && !Array.isArray(v)
      ? flatten(v, `${prefix}${k}.`)
      : [`${prefix}${k}`],
  );
 
for (const locale of ['ar', 'de', 'es']) {
  const missing = flatten(en).filter((k) => !flatten(messages[locale]).includes(k));
  if (missing.length) throw new Error(`${locale} ينقصه: ${missing.join(', ')}`);
}

الاتجاه مسألة تخطيط لا مسألة ترجمة

العربية هي الموضع الذي ينهار عنده تخطيطٌ صُمِّم للّاتينية وحدها، والعلاج ليس ورقة أنماط لكل اتجاه. العلاج أن تكتب التخطيط بالخصائص المنطقية من البداية، فيصير الاتجاه بيانًا لا تفرّعًا.

<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>

عندئذٍ تصير كل قيمة أفقية منطقية:

مادّيمنطقيما يعنيه
ml-4ms-4هامش عند بداية السطر
pr-6pe-6حشو عند نهاية السطر
left-0start-0إزاحة عند البداية
text-lefttext-startمحاذاة إلى البداية

مكتوبًا هكذا يعكس التخطيط العربي نفسه، ولا توجد ورقة أنماط ثانية تُبقيها متزامنة. وثلاثة أشياء تبقى مادّية عمدًا: كتل الشيفرة، وهي من اليسار لليمين في كل لغة؛ والأيقونات التي تحمل اتجاهًا، كسهم «التالي»، وتحتاج قلبًا صريحًا؛ والأرقام، فهي لا تُعكَس.

[dir='rtl'] .rtl-flip {
  transform: scaleX(-1);
}

وواحدة أخرى تُوقِع كثيرين: العربية خطّ متّصل، والتباعد السالب بين الحروف الذي يجعل عنوانًا لاتينيًا مشدودًا يفكّ وصلاتها وهو خطأ لها ببساطة. أوقف التباعد عند حدّ اللغة.

تبديل اللغة دون فقدان الصفحة

مبدّل اللغة الذي يرسل الجميع إلى الصفحة الرئيسية هو أشيع خلل في هذه المعمارية كلّها، وهو الذي يلاحظه الزوّار. فمن يقرأ مقالًا بالإنجليزية ينقر على الألمانية فيهبط على الصفحة الرئيسية الألمانية، وقد فقد ما كان يقرأه.

بدّل على المسار الحالي لا على الجذر:

'use client';
 
import { usePathname, useRouter } from '@/i18n/navigation';
 
export function LocaleSwitcher({ current }: { current: Locale }) {
  // هذا المسار منزوع اللغة: على /de/blog/x يُعيد /blog/x، فالقيمة نفسها
  // تصلح لأي لغة هدف.
  const pathname = usePathname();
  const router = useRouter();
 
  return routing.locales.map((locale) => (
    <button
      key={locale}
      lang={locale}
      aria-current={locale === current ? 'true' : undefined}
      onClick={() => router.replace(pathname, { locale })}
    >
      {names[locale]}
    </button>
  ));
}

تنبيه يستحقّ معرفته قبل أن يجده زائر: الصفحة غير الموجودة باللغة الهدف ستُعطي 404. فإمّا أن تُخفي اللغات التي لا ترجمة للصفحة فيها، وإمّا أن توجّهها إلى الأب المترجَم - لكن احسم الأمر، فالافتراضي طريق مسدود.

البيانات الوصفية، لكل صفحة ولكل لغة

كل صفحة تحتاج عنوانها ووصفها ورابطها المعياري وبدائلها، بلغتها. مُولَّدة من مساعد واحد، وإلا انحرفت:

// lib/seo.ts
export function buildMetadata({ locale, title, description, path, availableLocales }) {
  const url = absoluteUrl(locale, path);
 
  return {
    title,
    description,
    alternates: {
      canonical: url,
      languages: {
        // اللغات التي تملك هذه الصفحة فعلًا فقط. توجيه hreflang="de" إلى
        // صفحة إنجليزية أسوأ من غياب hreflang أصلًا.
        ...Object.fromEntries(
          availableLocales.map((l) => [l, absoluteUrl(l, path)]),
        ),
        'x-default': absoluteUrl(defaultLocale, path),
      },
    },
    openGraph: { url, title, description, locale },
  };
}

القاعدة المهمّة: على hreflang أن يعكس ما هو موجود. فعنقودٌ يعلن أربع ترجمات اثنتان منها الصفحة الإنجليزية عنقودٌ تتعلّم محرّكات البحث ألّا تثق به. اشتقّ القائمة من نظام الملفات لا من ثابت. والتفصيل في hreflang صحيح في Next.js، مُولَّد لا مُصان.

خريطة الموقع، من المصدر نفسه

خريطة واحدة، وكل اللغات، والبدائل على كل مُدخَل:

// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
  return routes.flatMap((route) =>
    localesFor(route).map((locale) => ({
      url: absoluteUrl(locale, route.path),
      lastModified: route.updated,
      alternates: {
        languages: Object.fromEntries(
          localesFor(route).map((l) => [l, absoluteUrl(l, route.path)]),
        ),
      },
    })),
  );
}

وroutes تأتي من الدالّة نفسها التي تستعملها الصفحات. أما خريطة مبنيّة من قائمة تُصان يدويًا فتخالف الموجّه خلال شهر: الروابط المعيارية وخرائط الموقع التي لا يمكن أن تنحرف.

ما نفحصه قبل إطلاق أي لغة

ليست قائمة يتذكّرها المطوّر - بل سكربتات تُفشِل البناء:

  1. كل مفتاح رسالة موجود في كل لغة. المفتاح الناقص انهيارٌ في الإنتاج، وبلغة واحدة فقط.
  2. لا تمرير أفقي، عند 320 بكسل، في كل لغة. المركّبات الألمانية أطول من الإنجليزية بالثلث، ولا معجم يقطّعها. وهذه أكثر طريقة شائعة لانكسار موقع متعدّد اللغات.
  3. لا كسر في وسط الكلمة. فالحلّ الأخير الذي يمنع التجاوز يقطع كلمة بدل أن يوسّع الصفحة، وهو ما يبدو خللًا لأنه خلل فعلًا.
  4. كل رابط معياري يشير إلى عنوانه هو، في كل لغة، والشكل المبدوء ببادئة للّغة الافتراضية يعيد التوجيه.
  5. عناقيد hreflang متبادلة. إن سردت الصفحة الألمانية الإسبانية، فعلى الإسبانية أن تسرد الألمانية.

الأولان يلتقطان الأخطاء التي تُطلَق. والثلاثة الأخيرة تلتقط الأخطاء التي تكلّفك ترتيبك بصمت، بعد ستّة أسابيع، وبلا سطر في السجلّات.

الجزء الذي ليس هندسة

النصّ المترجَم ليس صفحة مترجَمة. فنيّة البحث تختلف باختلاف السوق: العبارة الألمانية التي يكتبها المشتري ليست ترجمةً للإنجليزية، بل عبارة أخرى بحجم آخر. وترجمة بحثك عن الكلمات المفتاحية تعطيك صفحات لا تتصدّر لشيء بثلاث لغات.

خصّص لذلك ميزانية منفصلة، وانظر إلى المعمارية أعلاه بوصفها ما يجعل العمل بالجواب ممكنًا، لا بوصفها الجواب.

العودة إلى كل المقالات