152 lines
6.6 KiB
JavaScript
152 lines
6.6 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 استاتیک : /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(/\/$/, "");
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|