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>
</>