hreflang صحيح في Next.js - مُولَّد لا مُصان
تنشر معظم مواقع Next.js متعدّدة اللغات عنقود hreflang معطوبًا. والخلل واحد دائمًا - الوسوم تُكتب باليد.
مواصفة hreflang قصيرة وقواعدها بسيطة. ومع ذلك تكاد كل عملية تنفيذ ندقّقها تُخطئ فيها، والسبب الجذري هو نفسه دائمًا تقريبًا: أحدهم يصون البدائل يدويًا، ثم تتحرّك الترجمات ولا تتحرّك الوسوم.
القواعد الثلاث التي تُنتهك
1. كل صفحة في العنقود يجب أن تُدرج كل صفحات العنقود، بما فيها نفسها. فـhreflang ثنائي الاتجاه. إن أشارت صفحتك الإنجليزية إلى الألمانية ولم تُشر الألمانية إليها في المقابل، تُهمل Google العلاقة. الإشارة الذاتية ليست اختيارية.
2. لا تُعلن عن ترجمة غير موجودة. توجيه hreflang="de" إلى صفحة إنجليزية
لأن النسخة الألمانية «قادمة قريبًا» أسوأ من حذفه. أنت تخبر الزاحف أن
للناطقين بالألمانية صفحةً بالألمانية، ثم تخيّب ظنّهم.
3. الوسم x-default للاحتياط لا للإنجليزية. يشير إلى الصفحة التي تُقدَّم
لمن لا تستهدف لغتهم - عادةً أداة اختيار اللغة، أو لغتك الافتراضية إن لم تكن
لديك أداة.
لماذا تتعفّن الوسوم المصانة يدويًا دائمًا
تأمّل دورة الحياة. تُطلق بالإنجليزية والألمانية، فيكتب أحدهم البيانات الوصفية:
// The version that will be wrong within a month
alternates: {
languages: {
en: 'https://example.com/pricing',
de: 'https://example.com/de/pricing',
},
}ثم تُطلق الإسبانية، لكن لصفحات التسويق فقط. ثم يُسحب مقال ألماني من النشر. ثم يُعيد أحدهم تسمية slug. كلٌّ من هذه طلب دمج منفصل، في جزء مختلف من قاعدة الشيفرة، ولا يمسّ أيٌّ منها هذا الكائن.
وبعد ستة أشهر تُبلّغ Search Console عن «لا توجد وسوم عائدة» في بضع مئات من الروابط، ولا أحد يستطيع إعادة بناء السبب.
ولّدها من المحتوى لا من قائمة
الحل أن تجعل البدائل قيمة مشتقّة. في قاعدة شيفرة هذا الموقع - وهي عامة - تجيب طبقة المحتوى عن سؤال واحد:
/**
* Which locales actually have this document. Translations are published as
* they are written, so a page's hreflang cluster must reflect reality.
*/
export function availableLocalesFor(
collection: Collection,
slug: string,
): Locale[] {
return locales.filter((locale) =>
fs.existsSync(path.join(collectionDir(collection, locale), `${slug}.mdx`)),
);
}ويستهلكها مساعد البيانات الوصفية:
const languages: Record<string, string> = {};
for (const l of availableLocales) {
languages[hreflangMap[l]] = localizedUrl(l, path);
}
// `x-default` only makes sense when the default locale really has the page.
if (availableLocales.includes(defaultLocale)) {
languages['x-default'] = localizedUrl(defaultLocale, path);
}
return {
alternates: { canonical: localizedUrl(locale, path), languages },
// ...
};احذف ملف ترجمة فيختفي الوسم في البناء التالي. أضِف واحدًا فيظهر. لا توجد قائمة تنسى تحديثها، لأنه لا توجد قائمة أصلًا.
وتأتي الإشارة الذاتية مجانًا: اللغة الحالية داخل availableLocales بحكم
التعريف، فتُصدَر دائمًا.
الخلل الذي يُدمّر العنقود: تعارض الرابط الأساسي مع hreflang
كل ما سبق يفترض أن كل صفحة أساسيةٌ لنفسها. فإن لم تكن كذلك، لم ينفع شيء ممّا سبق - وهذا أشدّ الأخطاء ضررًا ممّا نجده في الواقع.
النمط يبدو معقولًا في طلب دمج. يقلق أحدهم من تكرار المحتوى عبر اللغات، فيوجّه الرابط الأساسي لكل لغة إلى الأصل الإنجليزي:
// كل صفحة مترجمة صارت الآن تطلب ألّا تُفهرس
alternates: {
canonical: 'https://example.com/pricing',
languages: { en: '...', de: '...', es: '...' },
}والرابط الأساسي يعني «افهرس هذا العنوان الآخر بدلًا مني». فالصفحة الألمانية تطلب أن تُسقَط، والإسبانية تطلب أن تُسقَط، والبدائل تشير إلى صفحات انسحبت من الفهرس. ينهار العنقود إلى صفحة واحدة، ويكون العَرَض - صفحات مترجمة لا تظهر قطّ في نتائج لغتها - لا يشبه سببه في شيء.
الترجمات ليست محتوًى مكرّرًا. كل لغة أساسيةٌ لنفسها، وhreflang هو ما يُخبر الزاحف بأنها صيغ بعضها من بعض. هذا هو تقسيم العمل كلّه بين الوسمين.
التحويلات التلقائية، ولماذا قد لا يرى الزاحف لغاتكم الأخرى
الخلل البنيوي الثاني ليس في الوسوم أصلًا، بل في وسيطة تقرأ Accept-Language
ثم تُحوّل.
زاحف جوجل يزحف في الغالب من عناوين في الولايات المتحدة بـAccept-Language
قيمته en-US، ولا يُغيّرها ليكتشف ما لديكم غير ذلك. فإن حُوّل طلب
/ar/pricing إلى /pricing لأن الزاحف لم يطلب العربية، فالصفحة العربية غير
موجودة بالنسبة للفهرس - وكل وسم hreflang يشير إليها إنما يشير إلى تحويل.
والقاعدة أن مفاوضة اللغة يجوز لها أن تقترح لا أن تُلزم:
- حوّلوا من الجذر العاري الذي لا لغة له فقط، لا من رابط يُسمّي لغةً أصلًا.
- اجعلوا الاختيار لاصقًا في كعكة، حتى لا يُصارَع من تجاوز التخمين عند كل تنقّل.
- اعرضوا البديل شريطًا قابلًا للإغلاق بدل التحويل حيثما أمكن.
بادئة اللغة الموجودة في الرابط إعلان نيّة، من إنسان أو من زاحف. وتجاوزها هو الطريق إلى ثلاث لغات لم تُقدَّم قطّ لأحد خارج المكتب.
حين تكون بعض الصفحات فقط مترجمة
التغطية الجزئية هي الحالة الطبيعية لأي موقع حقيقي، وليست خطأً. الخطأ هو معاملة العنقود بوصفه خاصيّة الموقع لا خاصيّة الصفحة.
الموقع يدعم أربع لغات؛ وهذه المقالة بعينها موجودة بلغتين. عنقودها مدخلان. والمقالة المجاورة لها أربعة. وكلاهما صحيح، وكلاهما محسوب لا مُعلَن - وهذا كل سبب استحقاق النهج المشتقّ أعلاه.
والحالة الوحيدة التي تحتاج قرارًا هي صفحة موجودة في لغة لكنها غير مترجمة:
منشورة بالإنجليزية داخل رابط ببادئة عربية لأن التنقّل يحتاج أن يُحلّ المسار.
إمّا أن تُترجم وإمّا أن تُترك خارج بناء العربية. أما ما لا ينبغي فعله فهو
تقديم الإنجليزية على /ar/... ووسمها hreflang="ar".
noindex وhreflang لا يجتمعان
الصفحة التي تحمل noindex لا تستطيع المشاركة في عنقود. ستُسقَط، ولأن hreflang
ثنائي الاتجاه، فغيابها يُضعف تبادليّة كل صفحة كانت تشير إليها.
وهذا يعضّ على نطاقات التجريب وعلى نشرات المعاينة التي تحمل بيانات وصفية
إنتاجية. فإن قدّمت بيئة معاينة البدائل نفسها التي تقدّمها بيئة الإنتاج، فأنتم
تُعلنون عنقودًا يستجيب أعضاؤه بـnoindex. قدّموا Disallow: / وnoindex على
المعاينات، واشتقّوا النطاق في البيانات الوصفية من النشرة بدل تثبيته بطريقة
ترثها المعاينات.
رموز اللغات، ومتى تُضاف المنطقة
استخدم رمز اللغة مجرّدًا ما لم تكن تقدّم فعلًا محتوى مختلفًا لمناطق مختلفة:
de- الناطقون بالألمانية في أي مكان.de-AT- فقط إذا كان الزوّار النمساويون يرون أسعارًا أو توافرًا أو نصوصًا قانونية مختلفة عن الألمان.
رمز منطقة لا تستطيع تبريره يُشتّت إشاراتك بين عنقودين بلا فائدة. أما
الاستثناءات المعتادة فحقيقية: en-GB مقابل en-US للإملاء والعملة،
وes-MX مقابل es-ES للمفردات والأسعار، وpt-BR مقابل pt-PT.
والمنطقة وحدها ليست آلية استهداف جغرافي أبدًا. فـhreflang="de" لا تعني
«اعرضها في ألمانيا»، بل تعني «هذه الصفحة للناطقين بالألمانية».
العربية وسائر لغات الاتجاه من اليمين إلى اليسار
لا تحتاج لغات اليمين‑إلى‑اليسار شيئًا خاصًا من hreflang - فـar رمز لغة
كغيره. ما تحتاجه فعلًا هو dir صحيح على المستند، وهذا يجب أن يكون لكل لغة على
حدة:
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
return (
<html lang={locale} dir={dirFor(locale)}>
{/* ... */}
</html>
);
}ثم اكتب تخطيطك بخصائص CSS المنطقية - padding-inline-start بدل
padding-left، وmargin-inline-end بدل margin-right، وtext-align: start
بدل left. وأدوات Tailwind ps-* وpe-* وms-* وme-* تقابلها مباشرةً.
افعل ذلك من البداية فينعكس التخطيط العربي من تلقاء نفسه؛ وأجّله فتقضي أسبوعًا
في البحث عن المواضع الاثني عشر التي لم تنعكس.
أما الاستثناءات التي يجب ألا تنعكس فهي: كتل الشيفرة، وعناوين البريد،
والروابط، وأسماء العلامات التجارية بالحروف اللاتينية. اضبط عليها
direction: ltr صراحةً.
كيف تتحقّق
ثلاثة فحوص، بترتيب تصاعدي في الموثوقية:
- اعرض مصدر الصفحة. لا المفتّش - فالمفتّش يعرض DOM بعد الترطيب. الأمر
curl -s https://example.com/de/pricing | grep alternateيُظهر ما يستلمه الزاحف. - Search Console ← الاستهداف الدولي. يُبلّغ عن أخطاء «لا توجد وسوم عائدة» و«رمز لغة غير معروف» على مستوى الموقع كلّه.
- ازحف عليه. يرسم Screaming Frog أو Sitebulb العنقود كاملًا ويخبرك أي الصفحات تنقصها وسوم متبادلة. وهذا يلتقط الحالات التي تكون فيها الوسوم صحيحة فرادى بينما الرسم البياني ناقص.
وثمّة رابع، وهو الوحيد الذي يلتقط الخلل قبل النشر. هذا الموقع يجلب كل رابط في خريطة موقعه عند كل دفعة، ويُفشل البناء حين تغيب صفحة عن عنقود hreflang الخاص بها، أو يغيب رابط أساسي أو يشير إلى موضع آخر، أو تستجيب صيغة رابط مكرّرة بدل أن تُحوّل. العنقود المُولَّد يُزيل صنف الخطأ الذي ينسى فيه أحدهم تحديث قائمة؛ والفحص في التكامل المستمر يُزيل الصنف الذي يكون فيه التوليد نفسه خاطئًا.
وخريطة الموقع يجب أن توافق
يمكن أن يسكن hreflang في ترويسة HTML أو في خريطة الموقع. وإن أصدرتهما معًا - وينبغي أن تفعل - فيجب أن يتطابقا تمامًا، لأن أي تعارض بينهما يُحسم على نحو لا يمكن التنبّؤ به.
والمبدأ نفسه ينطبق: ولّد خريطة الموقع من المساعد نفسه الذي تستخدمه الصفحات. نتناول ذلك، مع صحّة الروابط الأساسية، في روابط أساسية وخرائط موقع لا يمكن أن تنحرف.
