import { headers } from "next/headers"; import { findDomainByCityId } from "@/lib/domainHelpers"; // استراتژی canonical سه‌دسته: // C1-a per-domain : /، /doctors، /clinics، /specialties، /specialties/[slug]، /blogs // → self-canonical روی همان دامنهٔ شهری (هر شهر محتوای خودش را دارد) // C1-b موجودیت : /doctor/[uuid]، /clinic/[uuid]، /blog/[slug] // → دامنهٔ شهرِ همان موجودیت (getEntityCanonical) // C1-c استاتیک : /about-us، /contact-us، … → دامنهٔ اصلی // (محتوا روی همهٔ دامنه‌ها یکسان است — تجمیع عمدی اعتبار) const DEFAULT_HOST = "nobat724.com"; const MAIN_ORIGIN = "https://nobat724.com"; // C1-c — صفحات با محتوای یکسان روی همهٔ دامنه‌ها const STATIC_SHARED_PAGES = new Set([ "/about-us", "/contact-us", "/terms", "/privacy", ]); // تنها queryهایی که صفحه را از نظر محتوایی متمایز می‌کنند و باید در canonical بمانند. // بقیه (فیلتر چندگانه، sort، name) حذف می‌شوند تا duplicate نسازند. const CANONICAL_QUERY_KEYS = ["page"]; export function normalizeHost(rawHost) { return (rawHost || DEFAULT_HOST).toLowerCase().replace(/^www\./, ""); } export function buildCanonicalPath(pathname) { let path = (pathname || "/").split("?")[0].split("#")[0]; if (!path.startsWith("/")) path = `/${path}`; if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1); return path; } export function buildCanonicalUrl(host, pathname) { const normalizedHost = normalizeHost(host); const hostname = normalizedHost.replace(/:\d+$/, ""); const protocol = hostname.endsWith("localhost") || hostname === "127.0.0.1" ? "http" : "https"; return `${protocol}://${normalizedHost}${buildCanonicalPath(pathname)}`; } // origin هاست جاری (بدون trailing slash) — برای ساخت URLهای مطلق در JSON-LD و متادیتا export async function getRequestOrigin() { try { const headersList = await headers(); return buildCanonicalUrl(headersList.get("host"), "/").replace(/\/$/, ""); } catch { return MAIN_ORIGIN; } } export function buildMainCanonicalUrl(pathname) { return `${MAIN_ORIGIN}${buildCanonicalPath(pathname)}`; } /** * تصمیم M3-B: صفحات ۲+ لیست خودشان canonical دارند تا ایندکس‌پذیر بمانند. * (یاسوج به‌تنهایی ۳۸ صفحه دارد؛ collapse به صفحهٔ ۱ همهٔ آن‌ها را از ایندکس حذف می‌کرد.) */ const LISTING_PATHS = new Set(["/doctors", "/clinics"]); /** * صفحات لیستِ فیلتردار طبق lib/listingRobots نوایندکس هستند. کنار noindex، canonical * به یک URL دیگر سیگنال متناقض است و می‌تواند noindex را به هدف canonical ببرد — * دقیقاً همان /specialties/[slug] یا /doctors خامی که باید ایندکس بمانند. * پس این صفحات self-canonical می‌شوند و کل کوئری‌شان حفظ می‌گردد. * * پارامترها مرتب می‌شوند تا دو ترتیب متفاوت از یک فیلتر به یک canonical برسند. */ export function buildFilteredListingCanonical(host, path, search) { if (!LISTING_PATHS.has(path) || !search) return null; const params = new URLSearchParams(search); const filled = [...params.entries()].filter(([, value]) => value !== ""); const hasFilter = filled.some(([key]) => key !== "page"); if (!hasFilter) return null; const normalized = new URLSearchParams(filled); normalized.sort(); return `${buildCanonicalUrl(host, path)}?${normalized.toString()}`; } export function buildCanonicalQuery(search) { if (!search) return ""; const params = new URLSearchParams(search); const kept = new URLSearchParams(); for (const key of CANONICAL_QUERY_KEYS) { const value = params.get(key); if (key === "page" && (!value || Number(value) <= 1)) continue; if (value) kept.set(key, value); } const query = kept.toString(); return query ? `?${query}` : ""; } /** * canonical یک موجودیت (پزشک/کلینیک/پست بلاگ): دامنهٔ شهرِ خودش. * شهر بدون دامنهٔ اختصاصی یا موجودیت بدون شهر → self-canonical (هرگز canonical شکسته نده). */ export function getEntityCanonical(cityId, pathname, currentHost) { const cityDomain = findDomainByCityId(cityId); return buildCanonicalUrl(cityDomain || currentHost, pathname); } /** * origin مقصد canonical یک موجودیت — تا url/@id در JSON-LD و آیتم‌های BreadcrumbList * همان دامنه‌ای را اعلام کنند که canonical به آن اشاره می‌کند (رفع سیگنال متناقض). */ export async function getEntityOrigin(cityId) { let currentHost = DEFAULT_HOST; try { const headersList = await headers(); currentHost = headersList.get("host") || DEFAULT_HOST; } catch { // خارج از scope درخواست — دامنهٔ اصلی fallback امنی است. } return getEntityCanonical(cityId, "/", currentHost).replace(/\/$/, ""); } /** * origin canonical یک پست بلاگ. * * فرق آن با getEntityOrigin: پستی که دامنهٔ شهری ندارد (سراسری، یا متعلق به رکورد * ریشهٔ nobat724) نباید روی دامنهٔ جاری self-canonical شود — همان محتوا روی چند * دامنه سرو می‌شد و هر کدام خودش را canonical اعلام می‌کرد (duplicate content). * مرجع چنین پستی همیشه دامنهٔ اصلی است. */ export function getBlogCanonicalOrigin(cityId) { const cityDomain = findDomainByCityId(cityId); return cityDomain ? buildCanonicalUrl(cityDomain, "/").replace(/\/$/, "") : MAIN_ORIGIN; } /** * canonical پیش‌فرض لایهٔ layout — برای صفحاتی که خودشان alternates.canonical نمی‌دهند. * صفحات موجودیت canonical خودشان را در generateMetadata صفحه تعیین می‌کنند. */ export async function getCanonicalUrl() { try { const headersList = await headers(); const pathname = headersList.get("x-pathname"); // مسیرهای خارج از matcher میدل‌ور (dashboard/login) این هدر را ندارند — // آن‌ها noindex هستند و نباید canonical جعلی به ریشه بگیرند. if (!pathname) return null; const path = buildCanonicalPath(pathname); if (STATIC_SHARED_PAGES.has(path)) return buildMainCanonicalUrl(path); const search = headersList.get("x-search"); const host = headersList.get("host"); const filteredListing = buildFilteredListingCanonical(host, path, search); if (filteredListing) return filteredListing; const query = buildCanonicalQuery(search); return `${buildCanonicalUrl(host, path)}${query}`; } catch (error) { console.error("Error generating canonical URL:", error); return null; } }