feat(breadcrumb): implement BreadcrumbList structure and validation for SEO compliance
This commit is contained in:
@@ -189,7 +189,9 @@ async function Blog({ params }) {
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
|
||||
{ "@type": "ListItem", position: 2, name: "مقالات", item: `${origin}/blogs` },
|
||||
{ "@type": "ListItem", position: 3, name: blog.title },
|
||||
// آیتم آخر بدون `item` کل BreadcrumbList را نامعتبر میکند
|
||||
{ "@type": "ListItem", position: 3, name: blog.title,
|
||||
item: `${origin}/blog/${slug}` },
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import Layout from "@/components/layout/StLayout";
|
||||
import { getStateInfo } from "@/lib/getStateInfo";
|
||||
import { buildClinicParams } from "@/helper";
|
||||
import { listingRobots } from "@/lib/listingRobots";
|
||||
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
|
||||
|
||||
export async function generateMetadata({ searchParams }) {
|
||||
const awaitedParams = await searchParams;
|
||||
@@ -68,12 +69,16 @@ export default async function Clinics({ searchParams }) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
}
|
||||
|
||||
// origin برای BreadcrumbList لازم است: گوگل در itemListElement آدرس مطلق میخواهد.
|
||||
const origin = await getRequestOrigin();
|
||||
|
||||
return (
|
||||
<Layout name="/clinics">
|
||||
<ClinicsPage
|
||||
clinics={clinics}
|
||||
matchedCity={matchedCity}
|
||||
matchedState={matchedState}
|
||||
origin={origin}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
+12
-22
@@ -1,41 +1,31 @@
|
||||
import Link from "next/link";
|
||||
import { safeJsonLd } from "@/lib/sanitize";
|
||||
import { buildBreadcrumbJsonLd, normalizeBreadcrumb } from "@/lib/breadcrumb";
|
||||
|
||||
// Breadcrumb واحد سایت: ظاهر دقیقاً همان نسخهٔ قبلی (همان کلاسها، همان جداکنندهٔ «>»)
|
||||
// ولی ساختار سمانتیک درست (nav/ol/a) و BreadcrumbList از همان یک منبع داده تولید میشود
|
||||
// تا نسخهٔ بصری و schema هرگز واگرا نشوند.
|
||||
//
|
||||
// list: Array<string | { name, href }> — آیتم آخر همیشه صفحهٔ جاری است (بدون لینک).
|
||||
// list: Array<string | { name, href }> — به آیتم آخر (صفحهٔ جاری) هم href بده؛
|
||||
// ظاهرش عوض نمیشود (هرگز لینک نمیشود) ولی schema بدون `item` نامعتبر است.
|
||||
// `origin` هم لازم است، چون گوگل URL مطلق میخواهد؛ بدون آن JSON-LD منتشر نمیشود.
|
||||
|
||||
const ITEM_CLASS = "text-[16px] font-normal";
|
||||
|
||||
function normalize(list) {
|
||||
return list
|
||||
.map((item) => (typeof item === "string" ? { name: item } : item))
|
||||
.filter((item) => item?.name);
|
||||
}
|
||||
|
||||
function Pageguide({ list = [], origin }) {
|
||||
const items = normalize(list);
|
||||
const items = normalizeBreadcrumb(list);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: items.map((item, idx) => ({
|
||||
"@type": "ListItem",
|
||||
position: idx + 1,
|
||||
name: item.name,
|
||||
...(item.href && origin && { item: `${origin}${item.href}` }),
|
||||
})),
|
||||
};
|
||||
const jsonLd = buildBreadcrumbJsonLd(items, origin);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
||||
/>
|
||||
{jsonLd && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
||||
/>
|
||||
)}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol className="flex items-center justify-start gap-1">
|
||||
{items.map((item, idx) => {
|
||||
|
||||
@@ -262,7 +262,10 @@ async function Doctor({ params }) {
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
|
||||
{ "@type": "ListItem", position: 2, name: specialtyLabel, item: specialtyItem },
|
||||
{ "@type": "ListItem", position: 3, name: doctorTitle(doctor.name) },
|
||||
// آیتم آخر هم باید `item` داشته باشد (URL خودِ همین صفحه)؛ بدون آن
|
||||
// سرچکنسول خطای بحرانی Missing field "item" میدهد.
|
||||
{ "@type": "ListItem", position: 3, name: doctorTitle(doctor.name),
|
||||
item: `${origin}/doctor/${slug}` },
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -141,6 +141,7 @@ async function SpecialtyDetail({ params }) {
|
||||
intro={intro}
|
||||
faq={faq}
|
||||
origin={origin}
|
||||
slug={slug}
|
||||
/>
|
||||
</Layout>
|
||||
</>
|
||||
|
||||
@@ -29,7 +29,8 @@ function ClinicPage({ data, doctors, slug, pages, origin }) {
|
||||
list={[
|
||||
{ name: "خانه", href: "/" },
|
||||
{ name: "کلینیک ها", href: "/clinics" },
|
||||
{ name: data?.title },
|
||||
// href صفحهٔ جاری لازم است تا `item` در BreadcrumbList خالی نماند
|
||||
{ name: data?.title, href: slug ? `/clinic/${slug}` : "/clinics" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import cities from "@/data/city.json";
|
||||
import { isRootCity } from "@/lib/rootCity";
|
||||
import { resolveCityDisplayName } from "@/lib/domainHelpers";
|
||||
|
||||
function ClinicsPage({ clinics, matchedCity, matchedState }) {
|
||||
function ClinicsPage({ clinics, matchedCity, matchedState, origin }) {
|
||||
const searchParams = useSearchParams();
|
||||
const [page, setPage] = useState(clinics?.meta?.currentPage || 1);
|
||||
const [filteredClinics, setFilteredClinics] = useState(clinics?.data);
|
||||
@@ -52,6 +52,7 @@ function ClinicsPage({ clinics, matchedCity, matchedState }) {
|
||||
<List
|
||||
limit={12}
|
||||
page={page}
|
||||
origin={origin}
|
||||
setPage={setPage}
|
||||
selected={selected}
|
||||
clinics={filteredClinics}
|
||||
|
||||
@@ -10,15 +10,18 @@ function List({
|
||||
page,
|
||||
limit,
|
||||
pages,
|
||||
origin,
|
||||
setPage,
|
||||
clinics,
|
||||
selected,
|
||||
setFilteredClinics,
|
||||
}) {
|
||||
// آیتم آخر هم href میگیرد: بدون آن، `item` در BreadcrumbList غایب میشود و
|
||||
// سرچکنسول کل breadcrumb صفحه را نامعتبر اعلام میکند.
|
||||
const listPage = [
|
||||
{ name: "خانه", href: "/" },
|
||||
{ name: "کلینیک ها", href: "/clinics" },
|
||||
{ name: selected?.city?.name || "" },
|
||||
{ name: selected?.city?.name || "", href: "/clinics" },
|
||||
];
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
@@ -48,7 +51,7 @@ function List({
|
||||
|
||||
return (
|
||||
<div className="padding-responsive pt-[20px]">
|
||||
<Pageguide list={listPage} />
|
||||
<Pageguide list={listPage} origin={origin} />
|
||||
{clinics && clinics.length ? (
|
||||
<ul
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-[24px] gap-y-[16px] sm:gap-y-[24px] md:gap-y-[32px] lg:gap-y-[40px] mt-6"
|
||||
|
||||
@@ -4,7 +4,7 @@ import Faq from "./Faq";
|
||||
|
||||
// چیدمان عمداً همخانوادهٔ /doctors است: همان padding-responsive، همان فاصلهٔ بالای صفحه،
|
||||
// همان تایپوگرافی عنوان و همان گرید کارت پزشک — کاربر نباید حس کند وارد بخش دیگری شده.
|
||||
function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, origin }) {
|
||||
function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, origin, slug }) {
|
||||
return (
|
||||
<div className="pt-[95px] sm:pt-[120px] padding-responsive">
|
||||
<Pageguide
|
||||
@@ -12,7 +12,8 @@ function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, ori
|
||||
list={[
|
||||
{ name: "خانه", href: "/" },
|
||||
{ name: "تخصصها", href: "/specialties" },
|
||||
{ name: specialtyName },
|
||||
// href صفحهٔ جاری: بدون آن `item` غایب میشود و اسکیما نامعتبر است
|
||||
{ name: specialtyName, href: slug ? `/specialties/${slug}` : "/specialties" },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// ساخت BreadcrumbList معتبر برای schema.org — منبع واحدِ همهٔ breadcrumbهای سایت.
|
||||
//
|
||||
// چرا این فایل هست: سرچکنسول روی nobat724.com خطای بحرانی
|
||||
// «Missing field "item" (in "itemListElement")» میداد. علتش این بود که
|
||||
// `item` فقط وقتی اضافه میشد که هم href داشته باشیم هم origin — و در
|
||||
// /clinics اصلاً origin پاس داده نمیشد، پس هیچکدام از عنصرها `item` نداشتند و
|
||||
// کل BreadcrumbList نامعتبر میشد.
|
||||
//
|
||||
// قاعدهای که اینجا تضمین میشود: یا **همهٔ** عنصرها `item` مطلق دارند، یا اصلاً
|
||||
// JSON-LD تولید نمیشود. اسکیمای ناقص از نبودِ اسکیما بدتر است (صفحه از rich
|
||||
// resultها حذف میشود و در سرچکنسول بهعنوان خطای بحرانی مینشیند).
|
||||
//
|
||||
// list: Array<string | { name, href }> — همیشه href بده، حتی برای آیتم آخر
|
||||
// (صفحهٔ جاری)؛ ظاهر تغییری نمیکند چون آیتم آخر هرگز لینک نمیشود.
|
||||
|
||||
/** ورودی خام را به [{name, href}] یکدست میکند و بینامها را میاندازد. */
|
||||
export function normalizeBreadcrumb(list = []) {
|
||||
return (Array.isArray(list) ? list : [])
|
||||
.map((item) => (typeof item === "string" ? { name: item } : item))
|
||||
.filter((item) => item && typeof item.name === "string" && item.name.trim() !== "");
|
||||
}
|
||||
|
||||
/** origin + href → URL مطلق. هر ورودی ناقص → null. */
|
||||
export function absoluteUrl(origin, href) {
|
||||
if (!origin || typeof href !== "string" || href === "") return null;
|
||||
if (/^https?:\/\//i.test(href)) return href;
|
||||
const base = String(origin).replace(/\/+$/, "");
|
||||
const path = href.startsWith("/") ? href : `/${href}`;
|
||||
return path === "/" ? base : `${base}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* BreadcrumbList معتبر، یا `null` وقتی حتی یک عنصر URL مطلق ندارد.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function buildBreadcrumbJsonLd(list, origin) {
|
||||
const items = normalizeBreadcrumb(list);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const elements = [];
|
||||
for (const [idx, item] of items.entries()) {
|
||||
const url = absoluteUrl(origin, item.href);
|
||||
if (!url) return null; // بدون item کاملِ همهٔ عنصرها، اسکیما را منتشر نمیکنیم
|
||||
elements.push({
|
||||
"@type": "ListItem",
|
||||
position: idx + 1,
|
||||
name: item.name,
|
||||
item: url,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: elements,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildBreadcrumbJsonLd,
|
||||
normalizeBreadcrumb,
|
||||
} from "@/lib/breadcrumb";
|
||||
|
||||
const ORIGIN = "https://nobat724.com";
|
||||
|
||||
describe("normalizeBreadcrumb", () => {
|
||||
it("رشته را به {name} تبدیل میکند", () => {
|
||||
expect(normalizeBreadcrumb(["خانه"])).toEqual([{ name: "خانه" }]);
|
||||
});
|
||||
|
||||
it("آیتم بینام یا خالی حذف میشود", () => {
|
||||
expect(normalizeBreadcrumb([{ name: "" }, { name: " " }, { href: "/x" }, null])).toEqual([]);
|
||||
});
|
||||
|
||||
it("ورودی نامعتبر → آرایهٔ خالی", () => {
|
||||
expect(normalizeBreadcrumb(undefined)).toEqual([]);
|
||||
expect(normalizeBreadcrumb("nope")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("absoluteUrl", () => {
|
||||
it("origin و href را میچسباند", () => {
|
||||
expect(absoluteUrl(ORIGIN, "/clinics")).toBe("https://nobat724.com/clinics");
|
||||
expect(absoluteUrl(`${ORIGIN}/`, "clinics")).toBe("https://nobat724.com/clinics");
|
||||
expect(absoluteUrl(ORIGIN, "/")).toBe("https://nobat724.com");
|
||||
});
|
||||
|
||||
it("URL مطلق را دستنخورده برمیگرداند", () => {
|
||||
expect(absoluteUrl(ORIGIN, "https://yazd-nobat.ir/x")).toBe("https://yazd-nobat.ir/x");
|
||||
});
|
||||
|
||||
it("بدون origin یا href → null", () => {
|
||||
expect(absoluteUrl(undefined, "/clinics")).toBeNull();
|
||||
expect(absoluteUrl(ORIGIN, undefined)).toBeNull();
|
||||
expect(absoluteUrl(ORIGIN, "")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBreadcrumbJsonLd", () => {
|
||||
const LIST = [
|
||||
{ name: "خانه", href: "/" },
|
||||
{ name: "کلینیک ها", href: "/clinics" },
|
||||
{ name: "یزد", href: "/clinics" },
|
||||
];
|
||||
|
||||
it("همهٔ عنصرها item مطلق دارند (خطای سرچکنسول)", () => {
|
||||
const jsonLd = buildBreadcrumbJsonLd(LIST, ORIGIN);
|
||||
expect(jsonLd["@type"]).toBe("BreadcrumbList");
|
||||
expect(jsonLd.itemListElement).toHaveLength(3);
|
||||
for (const el of jsonLd.itemListElement) {
|
||||
expect(el.item, `missing item: ${JSON.stringify(el)}`).toMatch(/^https:\/\//);
|
||||
expect(el.name).toBeTruthy();
|
||||
}
|
||||
expect(jsonLd.itemListElement.map((e) => e.position)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("آیتم آخر هم item دارد — همان چیزی که گوگل نبودش را خطای بحرانی میداند", () => {
|
||||
const jsonLd = buildBreadcrumbJsonLd(LIST, ORIGIN);
|
||||
expect(jsonLd.itemListElement.at(-1).item).toBe("https://nobat724.com/clinics");
|
||||
});
|
||||
|
||||
it("بدون origin هیچ اسکیمایی منتشر نمیشود (بهجای اسکیمای ناقص)", () => {
|
||||
expect(buildBreadcrumbJsonLd(LIST, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("اگر حتی یک آیتم href نداشته باشد، اسکیما تولید نمیشود", () => {
|
||||
const partial = [{ name: "خانه", href: "/" }, { name: "صفحهٔ جاری" }];
|
||||
expect(buildBreadcrumbJsonLd(partial, ORIGIN)).toBeNull();
|
||||
});
|
||||
|
||||
it("لیست خالی → null", () => {
|
||||
expect(buildBreadcrumbJsonLd([], ORIGIN)).toBeNull();
|
||||
expect(buildBreadcrumbJsonLd([{ name: "" }], ORIGIN)).toBeNull();
|
||||
});
|
||||
|
||||
it("روی دامنهٔ شهری همان دامنه در item مینشیند", () => {
|
||||
const jsonLd = buildBreadcrumbJsonLd(LIST, "https://yazd-nobat.ir");
|
||||
expect(jsonLd.itemListElement[1].item).toBe("https://yazd-nobat.ir/clinics");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user