feat(blog): implement domain-specific blog fetching and canonical URL handling

This commit is contained in:
hamed
2026-07-24 10:01:35 +03:30
parent f46ba03422
commit 9053681375
4 changed files with 123 additions and 11 deletions
+26
View File
@@ -27,6 +27,32 @@ export function findCityById(cityId) {
return city && !isRootCity(city) ? city : null;
}
/**
* شناسهٔ شهری که باید به‌عنوان scope دامنه به API فرستاده شود.
* دامنهٔ ریشه (nobat724) و دامنهٔ ناشناخته scope ندارند → همهٔ محتوا را می‌بینند؛
* دامنهٔ شهری فقط پست‌های خودش + سراسری‌ها را (فیلتر سمت بک‌اند).
* @returns {number|null}
*/
export function domainScopeCityId({ matchedCity, isRoot } = {}) {
if (isRoot) return null;
const id = Number(matchedCity?.id);
return Number.isFinite(id) ? id : null;
}
/**
* آیا این پست روی این دامنه وجود دارد؟ آینهٔ فیلتر بک‌اند
* (`b.city = :cityId OR b.city IS NULL`) برای استفاده در تست و رندر سمت کلاینت.
* پست بدون شهر سراسری است و همه‌جا دیده می‌شود؛ پست شهریافته (از جمله پستِ رکورد
* ریشهٔ nobat724) فقط روی دامنهٔ خودش.
*/
export function isBlogVisibleOnDomain(blog, scope = {}) {
const scopeCityId = domainScopeCityId(scope);
if (scopeCityId === null) return true; // دامنهٔ ریشه/ناشناخته: بدون محدودیت
const ownerId = blog?.city?.id ?? blog?.city_id ?? null;
if (ownerId == null) return true; // سراسری
return Number(ownerId) === scopeCityId;
}
/**
* نام قابل‌نمایش شهر برای H1/Title.
* روی دامنهٔ ریشه نام رکورد («نوبت 724») یک برند است نه شهر — به‌جای آن «ایران».
+53
View File
@@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest";
import citiesData from "@/data/city.json";
import { ROOT_CITY_ID } from "@/lib/rootCity";
import {
domainScopeCityId,
extractEntityCityId,
findDomainByCityId,
isBlogVisibleOnDomain,
resolveCityDisplayName,
} from "@/lib/domainHelpers";
import { getBlogCanonicalOrigin } from "@/lib/getCanonicalUrl";
const YASUJ = citiesData.find((c) => c.domain === "yasuj-nobat.ir");
const ROOT = citiesData.find((c) => Number(c.id) === ROOT_CITY_ID);
@@ -89,3 +92,53 @@ describe("شکل پاسخ بلاگ (city آبجکت یا null)", () => {
expect(findDomainByCityId(extractEntityCityId(post))).toBeNull();
});
});
describe("scope دامنه برای API", () => {
it("دامنهٔ ریشه scope ندارد (همهٔ پست‌ها را می‌بیند)", () => {
expect(domainScopeCityId({ matchedCity: ROOT, isRoot: true })).toBeNull();
});
it("دامنهٔ شهری با شناسهٔ همان شهر scope می‌شود", () => {
expect(domainScopeCityId({ matchedCity: YASUJ, isRoot: false })).toBe(Number(YASUJ.id));
});
it("دامنهٔ ناشناخته (نمایندگی) scope ندارد", () => {
expect(domainScopeCityId({ matchedCity: undefined, isRoot: false })).toBeNull();
expect(domainScopeCityId()).toBeNull();
});
});
describe("دیده‌شدن پست روی دامنه (آینهٔ فیلتر بک‌اند)", () => {
const yasujScope = { matchedCity: YASUJ, isRoot: false };
const rootScope = { matchedCity: ROOT, isRoot: true };
it("پست همان شهر روی دامنهٔ خودش دیده می‌شود", () => {
expect(isBlogVisibleOnDomain({ city: { id: String(YASUJ.id) } }, yasujScope)).toBe(true);
});
it("پست شهر دیگر روی این دامنه وجود ندارد", () => {
const ahvaz = citiesData.find((c) => c.domain === "ahvaz-nobat.ir");
expect(isBlogVisibleOnDomain({ city: { id: String(ahvaz.id) } }, yasujScope)).toBe(false);
});
it("پستِ رکورد ریشه (nobat724) روی دامنهٔ شهری وجود ندارد — همان باگ گزارش‌شده", () => {
expect(isBlogVisibleOnDomain({ city: { id: String(ROOT_CITY_ID) } }, yasujScope)).toBe(false);
expect(isBlogVisibleOnDomain({ city: { id: String(ROOT_CITY_ID) } }, rootScope)).toBe(true);
});
it("پست سراسری (city: null) همه‌جا دیده می‌شود", () => {
expect(isBlogVisibleOnDomain({ city: null }, yasujScope)).toBe(true);
expect(isBlogVisibleOnDomain({ city: null }, rootScope)).toBe(true);
});
});
describe("canonical پست بلاگ", () => {
it("پست شهری → دامنهٔ همان شهر", () => {
expect(getBlogCanonicalOrigin(YASUJ.id)).toBe("https://yasuj-nobat.ir");
});
it("پست سراسری یا پستِ رکورد ریشه → دامنهٔ اصلی، نه دامنهٔ جاری", () => {
expect(getBlogCanonicalOrigin(null)).toBe("https://nobat724.com");
expect(getBlogCanonicalOrigin(ROOT_CITY_ID)).toBe("https://nobat724.com");
});
});
+13
View File
@@ -121,6 +121,19 @@ export async function getEntityOrigin(cityId) {
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 صفحه تعیین می‌کنند.