feat(blog): implement domain-specific blog fetching and canonical URL handling
This commit is contained in:
+31
-11
@@ -4,30 +4,45 @@ import Layout from "@/components/layout/StLayout";
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchReq } from "@/lib/req";
|
||||
import { getStateInfo } from "@/lib/getStateInfo";
|
||||
import { getEntityOrigin } from "@/lib/getCanonicalUrl";
|
||||
import { extractEntityCityId, findCityById } from "@/lib/domainHelpers";
|
||||
import { getBlogCanonicalOrigin } from "@/lib/getCanonicalUrl";
|
||||
import {
|
||||
domainScopeCityId,
|
||||
extractEntityCityId,
|
||||
findCityById,
|
||||
} from "@/lib/domainHelpers";
|
||||
import { safeJsonLd } from "@/lib/sanitize";
|
||||
import { normalizeBlog, imageUrl } from "@/helper";
|
||||
|
||||
const FALLBACK_IMG = "/assets/images/og-image.png";
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
const getBlog = cache(async (slug) => {
|
||||
const res = await fetch(`${API_URL}/api/v1/blog/${slug}`, {
|
||||
// scope دامنه در کلید cache مینشیند: یک پست روی دامنهٔ شهریِ دیگر ۴۰۴ است، پس
|
||||
// پاسخِ دامنهٔ ریشه نباید به دامنهٔ شهری نشت کند.
|
||||
const getBlog = cache(async (slug, cityId) => {
|
||||
const query = cityId != null ? `?city_id=${cityId}` : "";
|
||||
const res = await fetch(`${API_URL}/api/v1/blog/${slug}${query}`, {
|
||||
next: { revalidate: 3600, tags: [`blog-${slug}`] },
|
||||
});
|
||||
// ۴۰۴ یعنی «روی این دامنه وجود ندارد» — پستِ شهرِ دیگر (یا پستِ دامنهٔ اصلی)
|
||||
// نباید روی دامنهٔ شهری باز شود؛ بکاند همان فیلتر لیست را اینجا هم اعمال میکند.
|
||||
if (res.status === 404 || res.status === 400) return null;
|
||||
if (!res.ok) throw new Error(`Failed to fetch blog: ${res.status}`);
|
||||
const json = await res.json();
|
||||
return json?.data?.data ?? null;
|
||||
});
|
||||
|
||||
/** پست را در scope دامنهٔ جاری میگیرد (دامنهٔ ریشه scope ندارد). */
|
||||
async function getScopedBlog(slug) {
|
||||
const { matchedCity, isRoot } = await getStateInfo();
|
||||
return getBlog(slug, domainScopeCityId({ matchedCity, isRoot }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const { slug } = await params;
|
||||
const { matchedCity } = await getStateInfo();
|
||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||
|
||||
const blog = await getBlog(slug);
|
||||
const blog = await getScopedBlog(slug);
|
||||
if (!blog) notFound();
|
||||
|
||||
try {
|
||||
@@ -63,10 +78,11 @@ export async function generateMetadata({ params }) {
|
||||
? imageUrl(blog.image_url)
|
||||
: FALLBACK_IMG;
|
||||
|
||||
// C1-b — پست شهریافته به دامنهٔ همان شهر canonical میشود؛
|
||||
// پست سراسری (بدون شهر) روی دامنهٔ جاری self-canonical میماند.
|
||||
// C1-b — پست شهریافته به دامنهٔ همان شهر canonical میشود؛ پست سراسری (یا پستِ
|
||||
// رکورد ریشهٔ nobat724) به دامنهٔ اصلی، نه دامنهٔ جاری — وگرنه یک محتوا روی چند
|
||||
// دامنه سرو میشد و هرکدام خودش را canonical اعلام میکرد.
|
||||
// فیلد canonical_url اختصاصی (مثلاً نسخهٔ شهریِ پایپلاین) مقدم است.
|
||||
const origin = await getEntityOrigin(extractEntityCityId(blog));
|
||||
const origin = getBlogCanonicalOrigin(extractEntityCityId(blog));
|
||||
const canonical = blog.canonical_url || `${origin}/blog/${slug}`;
|
||||
|
||||
return {
|
||||
@@ -102,16 +118,20 @@ export async function generateMetadata({ params }) {
|
||||
async function Blog({ params }) {
|
||||
const { slug } = await params;
|
||||
|
||||
const blog = normalizeBlog(await getBlog(slug));
|
||||
const blog = normalizeBlog(await getScopedBlog(slug));
|
||||
if (!blog) notFound();
|
||||
|
||||
const blogCityId = extractEntityCityId(blog);
|
||||
const blogCity = findCityById(blogCityId);
|
||||
const origin = await getEntityOrigin(blogCityId);
|
||||
const origin = getBlogCanonicalOrigin(blogCityId);
|
||||
|
||||
// «مقالات مرتبط» هم باید scope دامنه را رعایت کند، وگرنه روی دامنهٔ شهری لینکِ
|
||||
// پستِ شهر دیگر نشان داده میشود که حالا ۴۰۴ میگیرد.
|
||||
const { matchedCity, isRoot } = await getStateInfo();
|
||||
const scopeCityId = domainScopeCityId({ matchedCity, isRoot });
|
||||
let relatedBlogs = [];
|
||||
const relatedResponse = await fetchReq(
|
||||
`${API_URL}/api/v1/blogs?page=1&limit=6`
|
||||
`${API_URL}/api/v1/blogs?page=1&limit=6${scopeCityId != null ? `&city_id=${scopeCityId}` : ""}`
|
||||
);
|
||||
relatedBlogs = (relatedResponse?.data || [])
|
||||
.filter((b) => b.uuid !== blog?.uuid)
|
||||
|
||||
@@ -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») یک برند است نه شهر — بهجای آن «ایران».
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 صفحه تعیین میکنند.
|
||||
|
||||
Reference in New Issue
Block a user