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

روابط أساسية وخرائط موقع لا يمكن أن تنحرف

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

4 دقيقة قراءة

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

فتنحرف. وهي تنحرف دائمًا.

كيف يبدو الانحراف

  • خريطة الموقع تُدرج /blog/old-post الذي يعيد 404.
  • كلٌّ من /pricing/ و/pricing يعيد 200 بلا رابط أساسي بينهما.
  • الرابط الأساسي للصفحة الألمانية يشير إلى الرابط الإنجليزي، لأن أحدهم نسخ كتلة البيانات الوصفية.
  • مسار جديد نُشر قبل ثلاثة أشهر وليس في أي خريطة موقع.
  • خريطة الموقع فيها https://www.example.com/... بينما الموقع يقدّم https://example.com/....

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

مصدر واحد، ثلاثة مخرجات

الحل البنيوي: اجعل الرابط قيمة محسوبة، ولا تكتب رابطًا باليد أبدًا.

// site.config.ts — the only place a URL is constructed
export function absoluteUrl(path = '/'): string {
  return `${siteConfig.url}${path.startsWith('/') ? path : `/${path}`}`;
}
 
/**
 * Locale-aware pathname. `en` is the default locale and is served without a
 * prefix, so its canonical URLs stay at the site root.
 */
export function localizedPath(locale: Locale, path = '/'): string {
  const clean = path === '/' ? '' : path.startsWith('/') ? path : `/${path}`;
  return locale === defaultLocale ? clean || '/' : `/${locale}${clean}`;
}
 
export function localizedUrl(locale: Locale, path = '/'): string {
  return absoluteUrl(localizedPath(locale, path));
}

كل رابط أساسي لصفحة، وكل مدخل في خريطة الموقع، وكل بديل hreflang يستدعي localizedUrl. غيّر النطاق في ثابت واحد فتتبعه الثلاثة. ولا فرصة لظهور شرطة مائلة ختامية في مخرج دون آخر، لأن النص يُبنى في دالة واحدة بالضبط.

اجعل نسيان الرابط الأساسي مستحيلًا

ينبغي ألا تستطيع صفحة أن تُنشر بلا رابط أساسي. وسبيل فرض ذلك تمرير البيانات الوصفية عبر مساعد تتضمّن وسائطه الإلزامية كل ما يحتاجه الرابط الأساسي:

export async function generateMetadata({ params }): Promise<Metadata> {
  const { locale, slug } = await params;
  const doc = getDoc('blog', locale, slug);
  if (!doc) return {};
 
  return buildMetadata({
    locale,
    title: doc.title,
    description: doc.description,
    path: `/blog/${slug}`,          // canonical is derived from this
    availableLocales: doc.availableLocales,
    type: 'article',
  });
}

من يضيف مسارًا يكتب path لأن الدالة لن تجتاز فحص الأنواع بدونه. لا يستطيع نسيان الرابط الأساسي ولا إخطاء شكله، لأنه لا يكتب رابطًا قط.

خريطة الموقع تقرأ المصدر نفسه الذي تقرأه الصفحات

يولّد Next.js ملف sitemap.xml من app/sitemap.ts. والخطأ أن تكتب هناك مصفوفة ثابتة.

export default function sitemap(): MetadataRoute.Sitemap {
  const entries: MetadataRoute.Sitemap = [];
 
  for (const collection of ['services', 'blog'] as const) {
    for (const locale of locales) {
      // Same helper the pages render from — the two cannot disagree.
      for (const doc of getDocs(collection, locale)) {
        const path = `/${collection}/${doc.slug}`;
        entries.push({
          url: localizedUrl(locale, path),
          lastModified: doc.updated ? new Date(doc.updated) : new Date(doc.date),
          alternates: alternatesFor(path, availableLocalesFor(collection, doc.slug)),
        });
      }
    }
  }
 
  return entries;
}

اسحب مقالًا من النشر فيغادر خريطة الموقع في البناء التالي. أضِف ترجمة فتنمو البدائل. لا خطوة صيانة، لأن خريطة الموقع ليست مستندًا — بل إسقاطًا.

الحقل lastModified ادّعاء لا إجراء شكلي

ضبط lastModified على new Date() لكل مدخل يخبر الزاحف أن موقعك كلّه تغيّر اليوم، وكل يوم. وبعد بضع دورات يكفّ عن تصديقك. استخدم تاريخ التعديل الحقيقي للمستند.

الشرطة المائلة الختامية: اختر واحدة والتزم بها

يقدّم Next.js المسارات بلا شرطة ختامية افتراضيًا. وهذا جيد. غير الجيد أن تقدّم الشكلين معًا.

// next.config.ts
const nextConfig: NextConfig = {
  trailingSlash: false,  // the default; state it so nobody 'fixes' it later
};

ثم تأكّد أن الشكل الآخر يُعيد التوجيه بدل أن يستجيب:

curl -sI https://example.com/pricing/ | head -1
# HTTP/2 308   ← correct
# HTTP/2 200   ← two URLs for one page

والفحص نفسه ينطبق على www مقابل النطاق الجذر، وعلى http مقابل https. ينبغي أن يكون كلٌّ منها إعادة توجيه دائمة واحدة، لا نسخة حيّة ثانية.

ملف Robots: لا تفهرس نسخ المعاينة

كل نشر لفرع هو نسخة كاملة من موقعك على رابط عام. وإن كان قابلًا للفهرسة فهو نسخة مكرّرة من موقع الإنتاج باسم مضيف مختلف.

export default function robots(): MetadataRoute.Robots {
  // Preview and branch deployments must never be indexed.
  const isProduction =
    process.env.VERCEL_ENV === 'production' ||
    process.env.NEXT_PUBLIC_SITE_URL === siteConfig.url;
 
  if (!isProduction) {
    return { rules: [{ userAgent: '*', disallow: '/' }] };
  }
 
  return {
    rules: [{ userAgent: '*', allow: '/', disallow: ['/api/'] }],
    sitemap: absoluteUrl('/sitemap.xml'),
    host: siteConfig.url,
  };
}

ولاحظ أن هذا يصلح metadataBase أيضًا. فنسخة معاينة ترث رابط الإنتاج ستُصدر روابط أساسية إنتاجية من مضيف تجريبي — وهذا غير ضارّ — أما التي تُصدر اسم مضيفها هي في الروابط الأساسية وهي قابلة للفهرسة فتُلحق ضررًا فعليًا.

تحقّق في خمس دقائق

# Canonical, on the page the crawler sees — not the inspector's hydrated DOM
curl -s https://example.com/de/blog/some-post | grep -E 'canonical|alternate'
 
# Sitemap parses, and the count matches what you expect
curl -s https://example.com/sitemap.xml | grep -c '<loc>'
 
# Every URL in the sitemap returns 200
curl -s https://example.com/sitemap.xml \
  | grep -oP '(?<=<loc>)[^<]+' \
  | xargs -P8 -I{} sh -c 'printf "%s %s\n" "$(curl -o /dev/null -sw "%{http_code}" "{}")" "{}"' \
  | grep -v '^200'

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

والتغيير البنيوي هو بيت القصيد. فالسيو الصحيح ليس قائمة وسوم تتذكّرها، بل معمار لا يمكن فيه كتابة الوسم الخطأ.

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