diff --git a/packages/visual-editor/src/components/pageSections/Breadcrumbs.tsx b/packages/visual-editor/src/components/pageSections/Breadcrumbs.tsx index 65e12b23f..ef9003a8e 100644 --- a/packages/visual-editor/src/components/pageSections/Breadcrumbs.tsx +++ b/packages/visual-editor/src/components/pageSections/Breadcrumbs.tsx @@ -11,10 +11,10 @@ import { backgroundColors, } from "../../utils/themeConfigOptions.ts"; import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; -import { getDirectoryParents } from "../../utils/schema/helpers.ts"; import { ComponentConfig, Fields } from "@puckeditor/core"; import { AnalyticsScopeProvider } from "@yext/pages-components"; import { ComponentErrorBoundary } from "../../internal/components/ComponentErrorBoundary.tsx"; +import { resolveBreadcrumbs } from "../../utils/urls/resolveBreadcrumbs.ts"; export interface BreadcrumbsData { /** @@ -120,14 +120,7 @@ export const BreadcrumbsComponent = ({ const { t, i18n } = useTranslation(); const separator = "/"; const { document: streamDocument, relativePrefixToRoot } = useTemplateProps(); - let breadcrumbs = getDirectoryParents(streamDocument); - if (breadcrumbs?.length > 0 || streamDocument.dm_directoryChildren) { - // append the current and filter out missing or malformed data - breadcrumbs = [ - ...breadcrumbs, - { name: streamDocument.name, slug: "" }, - ].filter((b) => b.name); - } + const breadcrumbs = resolveBreadcrumbs(streamDocument); const directoryRoot = resolveComponentData( data.directoryRoot, i18n.language, diff --git a/packages/visual-editor/src/components/testing/screenshots/Locator/[desktop] version 24 with filters (after interactions).png b/packages/visual-editor/src/components/testing/screenshots/Locator/[desktop] version 24 with filters (after interactions).png index 52f21487c..fdcb244fb 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/Locator/[desktop] version 24 with filters (after interactions).png and b/packages/visual-editor/src/components/testing/screenshots/Locator/[desktop] version 24 with filters (after interactions).png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/ProductSection/[desktop] version 32 props with entity values.png b/packages/visual-editor/src/components/testing/screenshots/ProductSection/[desktop] version 32 props with entity values.png index d6751ebcf..698fc34ca 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/ProductSection/[desktop] version 32 props with entity values.png and b/packages/visual-editor/src/components/testing/screenshots/ProductSection/[desktop] version 32 props with entity values.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/PromoSection/[desktop] [classic] version 50 with constant values and video.png b/packages/visual-editor/src/components/testing/screenshots/PromoSection/[desktop] [classic] version 50 with constant values and video.png index 72a8466f9..d24e3af63 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/PromoSection/[desktop] [classic] version 50 with constant values and video.png and b/packages/visual-editor/src/components/testing/screenshots/PromoSection/[desktop] [classic] version 50 with constant values and video.png differ diff --git a/packages/visual-editor/src/utils/resolveYextEntityField.ts b/packages/visual-editor/src/utils/resolveYextEntityField.ts index c9f692ae0..9296525a9 100644 --- a/packages/visual-editor/src/utils/resolveYextEntityField.ts +++ b/packages/visual-editor/src/utils/resolveYextEntityField.ts @@ -1,4 +1,4 @@ -import { type YextEntityField } from "../editor/YextEntityFieldSelector.tsx"; +import { YextEntityField } from "../editor/YextEntityFieldSelector.tsx"; export const embeddedFieldRegex = /\[\[([a-zA-Z0-9._]+)\]\]/g; diff --git a/packages/visual-editor/src/utils/types/StreamDocument.ts b/packages/visual-editor/src/utils/types/StreamDocument.ts index 0161756fd..c95007b1f 100644 --- a/packages/visual-editor/src/utils/types/StreamDocument.ts +++ b/packages/visual-editor/src/utils/types/StreamDocument.ts @@ -24,7 +24,7 @@ export type PathInfoShape = { primaryLocale?: string; includeLocalePrefixForPrimaryLocale?: boolean; template?: string; - breadcrumbTemplates?: string[]; + breadcrumbPrefix?: string; sourceEntityPageSetTemplate?: string; [key: string]: any; // allow any other fields }; diff --git a/packages/visual-editor/src/utils/urls/resolveBreadcrumbs.test.ts b/packages/visual-editor/src/utils/urls/resolveBreadcrumbs.test.ts new file mode 100644 index 000000000..56203a355 --- /dev/null +++ b/packages/visual-editor/src/utils/urls/resolveBreadcrumbs.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi, beforeEach, type Mock } from "vitest"; +import { resolveBreadcrumbs } from "./resolveBreadcrumbs.ts"; +import { StreamDocument } from "../types/StreamDocument.ts"; +import { getDirectoryParents } from "../schema/helpers.ts"; + +vi.mock("../schema/helpers.ts", () => ({ + getDirectoryParents: vi.fn(), +})); + +const mockedGetDirectoryParents = getDirectoryParents as unknown as Mock; + +const baseDocument: StreamDocument = { + name: "123 Test Rd", + dm_directoryParents_123_locations: [ + { name: "Directory Root", slug: "index.html" }, + { name: "US", slug: "us" }, + { name: "TS", slug: "ts" }, + { name: "Testville", slug: "testville" }, + ], + address: { + line1: "123 Test Rd", + city: "Testville", + region: "TS", + postalCode: "12345", + countryCode: "US", + }, + locale: "en", + __: { + pathInfo: { + primaryLocale: "en", + breadcrumbPrefix: "locations", + }, + }, +}; + +beforeEach(() => { + mockedGetDirectoryParents.mockReset(); +}); + +describe("resolveBreadcrumbs", () => { + it("prefers pathInfo breadcrumbs over directory parents", () => { + const documentWithBreadcrumbs: StreamDocument = baseDocument; + + mockedGetDirectoryParents.mockReturnValue([ + { name: "Directory Parent", slug: "directory-parent" }, + ]); + + expect(resolveBreadcrumbs(documentWithBreadcrumbs)).toEqual([ + { name: "Directory Root", slug: "locations/index.html" }, + { name: "US", slug: "locations/us" }, + { name: "TS", slug: "locations/ts" }, + { name: "Testville", slug: "locations/testville" }, + { name: "123 Test Rd", slug: "" }, + ]); + }); + + it("falls back to directory parents when pathInfo breadcrumbs are missing", () => { + const documentWithoutPathInfo: StreamDocument = { + ...baseDocument, + __: {}, + }; + + mockedGetDirectoryParents.mockReturnValue([ + { name: "Directory Parent", slug: "directory-parent" }, + ]); + + expect(resolveBreadcrumbs(documentWithoutPathInfo)).toEqual([ + { name: "Directory Parent", slug: "directory-parent" }, + { name: "123 Test Rd", slug: "" }, + ]); + }); + + it("uses the current page when directory parents are missing but children are present", () => { + const documentWithChildren: StreamDocument = { + ...baseDocument, + __: {}, + dm_directoryChildren: [{}], + }; + + mockedGetDirectoryParents.mockReturnValue(undefined); + + expect(resolveBreadcrumbs(documentWithChildren)).toEqual([ + { name: "123 Test Rd", slug: "" }, + ]); + }); + + it("returns an empty array when no resolver yields breadcrumbs", () => { + const documentWithoutNames: StreamDocument = { + ...baseDocument, + name: undefined, + __: {}, + }; + + mockedGetDirectoryParents.mockReturnValue([ + { name: "", slug: "directory-parent" }, + ]); + + expect(resolveBreadcrumbs(documentWithoutNames)).toEqual([]); + }); +}); diff --git a/packages/visual-editor/src/utils/urls/resolveBreadcrumbs.ts b/packages/visual-editor/src/utils/urls/resolveBreadcrumbs.ts new file mode 100644 index 000000000..49c28872a --- /dev/null +++ b/packages/visual-editor/src/utils/urls/resolveBreadcrumbs.ts @@ -0,0 +1,49 @@ +import { getDirectoryParents } from "../schema/helpers.ts"; +import { StreamDocument } from "../types/StreamDocument.ts"; +import { + resolveBreadcrumbsFromPathInfo, + BreadcrumbLink, +} from "./resolveBreadcrumbsFromPathInfo.ts"; + +type BreadcrumbResolver = ( + streamDocument: StreamDocument +) => BreadcrumbLink[] | undefined; + +const resolvers: BreadcrumbResolver[] = [ + (streamDocument) => resolveBreadcrumbsFromPathInfo(streamDocument), + (streamDocument) => buildBreadcrumbsFromDirectory(streamDocument), +]; + +export const resolveBreadcrumbs = ( + streamDocument: StreamDocument +): BreadcrumbLink[] => { + for (const resolve of resolvers) { + try { + const result = resolve(streamDocument); + if (result && result.length) { + return result; + } + } catch { + // continue to next resolver + } + } + return []; +}; + +const buildBreadcrumbsFromDirectory = ( + streamDocument: StreamDocument +): BreadcrumbLink[] | undefined => { + const directoryParents = getDirectoryParents(streamDocument) || []; + + if (directoryParents.length > 0 || streamDocument.dm_directoryChildren) { + // append the current and filter out missing or malformed data + const breadcrumbs = [ + ...directoryParents, + { name: streamDocument.name, slug: "" }, + ].filter((b) => b.name); + + return breadcrumbs.length ? breadcrumbs : undefined; + } + + return undefined; +}; diff --git a/packages/visual-editor/src/utils/urls/resolveBreadcrumbsFromPathInfo.test.ts b/packages/visual-editor/src/utils/urls/resolveBreadcrumbsFromPathInfo.test.ts new file mode 100644 index 000000000..988319d7e --- /dev/null +++ b/packages/visual-editor/src/utils/urls/resolveBreadcrumbsFromPathInfo.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { resolveBreadcrumbsFromPathInfo } from "./resolveBreadcrumbsFromPathInfo.ts"; +import { StreamDocument } from "../types/StreamDocument.ts"; + +const baseDocument: StreamDocument = { + name: "123 Test Rd", + dm_directoryParents_123_locations: [ + { name: "Directory Root", slug: "index.html" }, + { name: "US", slug: "us" }, + { name: "TS", slug: "ts" }, + { name: "Testville", slug: "testville" }, + ], + address: { + line1: "123 Test Rd", + city: "Testville", + region: "TS", + postalCode: "12345", + countryCode: "US", + }, + locale: "en", + __: { + pathInfo: { + primaryLocale: "en", + breadcrumbPrefix: "locations", + }, + }, +}; + +describe("resolveBreadcrumbsFromPathInfo", () => { + it("builds breadcrumbs from breadcrumbPrefix for the primary locale without locale prefix", () => { + const englishDocument = baseDocument; + + expect(resolveBreadcrumbsFromPathInfo(englishDocument)).toEqual([ + { name: "Directory Root", slug: "locations/index.html" }, + { name: "US", slug: "locations/us" }, + { name: "TS", slug: "locations/ts" }, + { name: "Testville", slug: "locations/testville" }, + { name: "123 Test Rd", slug: "" }, + ]); + }); + + it("prefixes breadcrumbs with locale for non-primary locales", () => { + const spanishDocument: StreamDocument = { + ...baseDocument, + locale: "es", + }; + + expect(resolveBreadcrumbsFromPathInfo(spanishDocument)).toEqual([ + { name: "Directory Root", slug: "es/locations/index.html" }, + { name: "US", slug: "es/locations/us" }, + { name: "TS", slug: "es/locations/ts" }, + { name: "Testville", slug: "es/locations/testville" }, + { name: "123 Test Rd", slug: "" }, + ]); + }); + + it("omits the breadcrumb prefix separator when breadcrumbPrefix is empty", () => { + const documentWithEmptyPrefix: StreamDocument = { + ...baseDocument, + __: { + pathInfo: { + ...baseDocument.__?.pathInfo, + breadcrumbPrefix: "", + }, + }, + }; + + expect(resolveBreadcrumbsFromPathInfo(documentWithEmptyPrefix)).toEqual([ + { name: "Directory Root", slug: "index.html" }, + { name: "US", slug: "us" }, + { name: "TS", slug: "ts" }, + { name: "Testville", slug: "testville" }, + { name: "123 Test Rd", slug: "" }, + ]); + }); +}); diff --git a/packages/visual-editor/src/utils/urls/resolveBreadcrumbsFromPathInfo.ts b/packages/visual-editor/src/utils/urls/resolveBreadcrumbsFromPathInfo.ts new file mode 100644 index 000000000..a36e4624d --- /dev/null +++ b/packages/visual-editor/src/utils/urls/resolveBreadcrumbsFromPathInfo.ts @@ -0,0 +1,80 @@ +import { normalizeSlug } from "../slugifier.ts"; +import { StreamDocument } from "../types/StreamDocument.ts"; +import { isPrimaryLocale } from "./resolveUrlFromPathInfo.ts"; + +export type BreadcrumbLink = { + name: string; + slug: string; +}; + +/** + * Builds breadcrumb links from __.pathInfo.breadcrumbPrefix and the + * dm_directoryParents_* field when available. + */ +export const resolveBreadcrumbsFromPathInfo = ( + streamDocument: StreamDocument +): BreadcrumbLink[] | undefined => { + const breadcrumbPrefix = streamDocument.__?.pathInfo?.breadcrumbPrefix; + if (typeof breadcrumbPrefix !== "string") { + return undefined; + } + + const locale = streamDocument?.locale || streamDocument?.meta?.locale || ""; + if (!locale) { + return undefined; + } + + const includeLocalePrefix = + !isPrimaryLocale(streamDocument) || + streamDocument.__?.pathInfo?.includeLocalePrefixForPrimaryLocale; + + const normalizedPrefix = normalizeSlug(breadcrumbPrefix) + .replace(/\/+/g, "/") + .replace(/^\/+|\/+$/g, ""); + + const directoryParentsEntry = Object.entries(streamDocument).find( + ([key, value]) => + key.startsWith("dm_directoryParents_") && Array.isArray(value) + ); + const directoryParents = directoryParentsEntry?.[1]; + if (!Array.isArray(directoryParents) || directoryParents.length === 0) { + return undefined; + } + + const crumbs: BreadcrumbLink[] = []; + + for (const parent of directoryParents) { + if (!parent || typeof parent !== "object") { + continue; + } + + const directoryLevelSlug = + typeof parent.slug === "string" + ? normalizeSlug(parent.slug) + .replace(/\/+/g, "/") + .replace(/^\/+|\/+$/g, "") + : ""; + if (!directoryLevelSlug) { + continue; + } + + const normalizedSlug = normalizedPrefix + ? `${normalizedPrefix}/${directoryLevelSlug}` + : directoryLevelSlug; + const slug = includeLocalePrefix + ? `${locale}/${normalizedSlug}` + : normalizedSlug; + + const name = (typeof parent.name === "string" && parent.name) || slug; + + crumbs.push({ name, slug }); + } + + if (!crumbs.length) { + return undefined; + } + + crumbs.push({ name: streamDocument.name ?? "", slug: "" }); + + return crumbs; +};