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
+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");
});
});