feat(breadcrumb): implement BreadcrumbList structure and validation for SEO compliance
This commit is contained in:
@@ -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