feat(breadcrumb): implement BreadcrumbList structure and validation for SEO compliance
This commit is contained in:
@@ -189,7 +189,9 @@ async function Blog({ params }) {
|
|||||||
itemListElement: [
|
itemListElement: [
|
||||||
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
|
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
|
||||||
{ "@type": "ListItem", position: 2, name: "مقالات", item: `${origin}/blogs` },
|
{ "@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;
|
: null;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Layout from "@/components/layout/StLayout";
|
|||||||
import { getStateInfo } from "@/lib/getStateInfo";
|
import { getStateInfo } from "@/lib/getStateInfo";
|
||||||
import { buildClinicParams } from "@/helper";
|
import { buildClinicParams } from "@/helper";
|
||||||
import { listingRobots } from "@/lib/listingRobots";
|
import { listingRobots } from "@/lib/listingRobots";
|
||||||
|
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
|
||||||
|
|
||||||
export async function generateMetadata({ searchParams }) {
|
export async function generateMetadata({ searchParams }) {
|
||||||
const awaitedParams = await searchParams;
|
const awaitedParams = await searchParams;
|
||||||
@@ -68,12 +69,16 @@ export default async function Clinics({ searchParams }) {
|
|||||||
if (isNextRedirectError(err)) throw err;
|
if (isNextRedirectError(err)) throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// origin برای BreadcrumbList لازم است: گوگل در itemListElement آدرس مطلق میخواهد.
|
||||||
|
const origin = await getRequestOrigin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout name="/clinics">
|
<Layout name="/clinics">
|
||||||
<ClinicsPage
|
<ClinicsPage
|
||||||
clinics={clinics}
|
clinics={clinics}
|
||||||
matchedCity={matchedCity}
|
matchedCity={matchedCity}
|
||||||
matchedState={matchedState}
|
matchedState={matchedState}
|
||||||
|
origin={origin}
|
||||||
/>
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-22
@@ -1,41 +1,31 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { safeJsonLd } from "@/lib/sanitize";
|
import { safeJsonLd } from "@/lib/sanitize";
|
||||||
|
import { buildBreadcrumbJsonLd, normalizeBreadcrumb } from "@/lib/breadcrumb";
|
||||||
|
|
||||||
// Breadcrumb واحد سایت: ظاهر دقیقاً همان نسخهٔ قبلی (همان کلاسها، همان جداکنندهٔ «>»)
|
// Breadcrumb واحد سایت: ظاهر دقیقاً همان نسخهٔ قبلی (همان کلاسها، همان جداکنندهٔ «>»)
|
||||||
// ولی ساختار سمانتیک درست (nav/ol/a) و BreadcrumbList از همان یک منبع داده تولید میشود
|
// ولی ساختار سمانتیک درست (nav/ol/a) و BreadcrumbList از همان یک منبع داده تولید میشود
|
||||||
// تا نسخهٔ بصری و schema هرگز واگرا نشوند.
|
// تا نسخهٔ بصری و 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";
|
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 }) {
|
function Pageguide({ list = [], origin }) {
|
||||||
const items = normalize(list);
|
const items = normalizeBreadcrumb(list);
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
const jsonLd = {
|
const jsonLd = buildBreadcrumbJsonLd(items, origin);
|
||||||
"@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}` }),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<script
|
{jsonLd && (
|
||||||
type="application/ld+json"
|
<script
|
||||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
type="application/ld+json"
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<nav aria-label="breadcrumb">
|
<nav aria-label="breadcrumb">
|
||||||
<ol className="flex items-center justify-start gap-1">
|
<ol className="flex items-center justify-start gap-1">
|
||||||
{items.map((item, idx) => {
|
{items.map((item, idx) => {
|
||||||
|
|||||||
@@ -262,7 +262,10 @@ async function Doctor({ params }) {
|
|||||||
itemListElement: [
|
itemListElement: [
|
||||||
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
|
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
|
||||||
{ "@type": "ListItem", position: 2, name: specialtyLabel, item: specialtyItem },
|
{ "@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;
|
: null;
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ async function SpecialtyDetail({ params }) {
|
|||||||
intro={intro}
|
intro={intro}
|
||||||
faq={faq}
|
faq={faq}
|
||||||
origin={origin}
|
origin={origin}
|
||||||
|
slug={slug}
|
||||||
/>
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ function ClinicPage({ data, doctors, slug, pages, origin }) {
|
|||||||
list={[
|
list={[
|
||||||
{ name: "خانه", href: "/" },
|
{ name: "خانه", href: "/" },
|
||||||
{ name: "کلینیک ها", href: "/clinics" },
|
{ name: "کلینیک ها", href: "/clinics" },
|
||||||
{ name: data?.title },
|
// href صفحهٔ جاری لازم است تا `item` در BreadcrumbList خالی نماند
|
||||||
|
{ name: data?.title, href: slug ? `/clinic/${slug}` : "/clinics" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import cities from "@/data/city.json";
|
|||||||
import { isRootCity } from "@/lib/rootCity";
|
import { isRootCity } from "@/lib/rootCity";
|
||||||
import { resolveCityDisplayName } from "@/lib/domainHelpers";
|
import { resolveCityDisplayName } from "@/lib/domainHelpers";
|
||||||
|
|
||||||
function ClinicsPage({ clinics, matchedCity, matchedState }) {
|
function ClinicsPage({ clinics, matchedCity, matchedState, origin }) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [page, setPage] = useState(clinics?.meta?.currentPage || 1);
|
const [page, setPage] = useState(clinics?.meta?.currentPage || 1);
|
||||||
const [filteredClinics, setFilteredClinics] = useState(clinics?.data);
|
const [filteredClinics, setFilteredClinics] = useState(clinics?.data);
|
||||||
@@ -52,6 +52,7 @@ function ClinicsPage({ clinics, matchedCity, matchedState }) {
|
|||||||
<List
|
<List
|
||||||
limit={12}
|
limit={12}
|
||||||
page={page}
|
page={page}
|
||||||
|
origin={origin}
|
||||||
setPage={setPage}
|
setPage={setPage}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
clinics={filteredClinics}
|
clinics={filteredClinics}
|
||||||
|
|||||||
@@ -10,15 +10,18 @@ function List({
|
|||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
pages,
|
pages,
|
||||||
|
origin,
|
||||||
setPage,
|
setPage,
|
||||||
clinics,
|
clinics,
|
||||||
selected,
|
selected,
|
||||||
setFilteredClinics,
|
setFilteredClinics,
|
||||||
}) {
|
}) {
|
||||||
|
// آیتم آخر هم href میگیرد: بدون آن، `item` در BreadcrumbList غایب میشود و
|
||||||
|
// سرچکنسول کل breadcrumb صفحه را نامعتبر اعلام میکند.
|
||||||
const listPage = [
|
const listPage = [
|
||||||
{ name: "خانه", href: "/" },
|
{ name: "خانه", href: "/" },
|
||||||
{ name: "کلینیک ها", href: "/clinics" },
|
{ name: "کلینیک ها", href: "/clinics" },
|
||||||
{ name: selected?.city?.name || "" },
|
{ name: selected?.city?.name || "", href: "/clinics" },
|
||||||
];
|
];
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -48,7 +51,7 @@ function List({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="padding-responsive pt-[20px]">
|
<div className="padding-responsive pt-[20px]">
|
||||||
<Pageguide list={listPage} />
|
<Pageguide list={listPage} origin={origin} />
|
||||||
{clinics && clinics.length ? (
|
{clinics && clinics.length ? (
|
||||||
<ul
|
<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"
|
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، همان فاصلهٔ بالای صفحه،
|
// چیدمان عمداً همخانوادهٔ /doctors است: همان padding-responsive، همان فاصلهٔ بالای صفحه،
|
||||||
// همان تایپوگرافی عنوان و همان گرید کارت پزشک — کاربر نباید حس کند وارد بخش دیگری شده.
|
// همان تایپوگرافی عنوان و همان گرید کارت پزشک — کاربر نباید حس کند وارد بخش دیگری شده.
|
||||||
function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, origin }) {
|
function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, origin, slug }) {
|
||||||
return (
|
return (
|
||||||
<div className="pt-[95px] sm:pt-[120px] padding-responsive">
|
<div className="pt-[95px] sm:pt-[120px] padding-responsive">
|
||||||
<Pageguide
|
<Pageguide
|
||||||
@@ -12,7 +12,8 @@ function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, ori
|
|||||||
list={[
|
list={[
|
||||||
{ name: "خانه", href: "/" },
|
{ name: "خانه", href: "/" },
|
||||||
{ name: "تخصصها", href: "/specialties" },
|
{ 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