Files

166 lines
7.8 KiB
JavaScript

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 استاتیک : /terms، /privacy → دامنهٔ اصلی
// (متن حقوقی روی همهٔ دامنه‌ها کلمه‌به‌کلمه یکسان است)
//
// /about-us و /contact-us از C1-c بیرون آمدند: هر شهر آنجا داده و متن خودش را دارد
// (نام سایت، تلفن، نشانی، شبکه‌های اجتماعی، متن شهری)، پس self-canonical می‌شوند.
// پیش از این هر دو به دامنهٔ اصلی canonical می‌شدند و Search Console آن‌ها را
// «Alternate page with proper canonical tag» گزارش می‌کرد؛ یعنی روی هیچ دامنهٔ شهری
// ایندکس نمی‌شدند.
const DEFAULT_HOST = "nobat724.com";
const MAIN_ORIGIN = "https://nobat724.com";
// C1-c — صفحات با محتوای یکسان روی همهٔ دامنه‌ها
const STATIC_SHARED_PAGES = new Set(["/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;
}
}