feat(breadcrumb): implement BreadcrumbList structure and validation for SEO compliance

This commit is contained in:
hamed
2026-07-24 10:39:41 +03:30
parent 9053681375
commit b8514e64cf
11 changed files with 178 additions and 30 deletions
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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) => {
+4 -1
View File
@@ -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;
+1
View File
@@ -141,6 +141,7 @@ async function SpecialtyDetail({ params }) {
intro={intro}
faq={faq}
origin={origin}
slug={slug}
/>
</Layout>
</>
+2 -1
View File
@@ -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>
+2 -1
View File
@@ -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}
+5 -2
View File
@@ -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"
+3 -2
View File
@@ -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" },
]}
/>
+57
View File
@@ -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,
};
}
+84
View File
@@ -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");
});
});