Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
729bfd4
feat: use breadcrumbTemplates for breadcrumb urls
briantstephan Jan 28, 2026
b1e02e9
Update component screenshots for visual-editor
github-actions[bot] Jan 28, 2026
739b872
add logging
briantstephan Feb 3, 2026
94e1f3c
Merge branch 'breadcrumbs-url-templates' of https://github.com/yext/v…
briantstephan Feb 3, 2026
12ecd47
fixed conflicts
briantstephan Feb 3, 2026
f67ebf8
fixed build
briantstephan Feb 3, 2026
0e0b558
fix breadcrumbs labels
briantstephan Feb 3, 2026
7ef8406
updated test
briantstephan Feb 3, 2026
74874ef
updated tests
briantstephan Feb 3, 2026
98fcb28
fix tests
briantstephan Feb 4, 2026
3bc6161
updated imports
briantstephan Feb 4, 2026
91b3ac1
updated to use breadcrumbPrefix
briantstephan Feb 13, 2026
c118129
Update component screenshots for visual-editor
github-actions[bot] Feb 13, 2026
320cb45
Merge branch 'main' into breadcrumbs-url-templates
briantstephan Feb 13, 2026
99deab4
fixed conflicts
briantstephan Feb 13, 2026
d836dc8
Update component screenshots for visual-editor
github-actions[bot] Feb 13, 2026
8eed9e2
Update component screenshots for visual-editor
github-actions[bot] Feb 13, 2026
3baad99
Update component screenshots for visual-editor
github-actions[bot] Feb 13, 2026
c2bc029
Update component screenshots for visual-editor
github-actions[bot] Feb 13, 2026
efa4de5
remove streamDocument name fallback
briantstephan Feb 13, 2026
42d5146
Merge branch 'breadcrumbs-url-templates' of https://github.com/yext/v…
briantstephan Feb 13, 2026
55a8b96
Update component screenshots for visual-editor
github-actions[bot] Feb 13, 2026
3ce2e6b
Update component screenshots for visual-editor
github-actions[bot] Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion packages/visual-editor/src/utils/resolveYextEntityField.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type YextEntityField } from "../editor/YextEntityFieldSelector.tsx";
import { YextEntityField } from "../editor/YextEntityFieldSelector.tsx";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason for this?


export const embeddedFieldRegex = /\[\[([a-zA-Z0-9._]+)\]\]/g;

Expand Down
2 changes: 1 addition & 1 deletion packages/visual-editor/src/utils/types/StreamDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
100 changes: 100 additions & 0 deletions packages/visual-editor/src/utils/urls/resolveBreadcrumbs.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting wording, more like uses pathInfo breadcrumbsPrefix if exists?

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([]);
});
});
49 changes: 49 additions & 0 deletions packages/visual-editor/src/utils/urls/resolveBreadcrumbs.ts
Original file line number Diff line number Diff line change
@@ -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),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One is called resolve and one is called build. Should we keep it consistent?

(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 = (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this one in this file but resolveBreadcrumbsFromPathInfo in its own file?

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;
};
Original file line number Diff line number Diff line change
@@ -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: "" },
]);
});
});
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can just return (without undefined)

}

const locale = streamDocument?.locale || streamDocument?.meta?.locale || "";
if (!locale) {
return undefined;
}

const includeLocalePrefix =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we want to do the same locale prefix logic. It depends on if Spruce has changed the DM config to have the same urlTemplate across all slug values of the DM or if they have the normal DM config to handle locale.

!isPrimaryLocale(streamDocument) ||
streamDocument.__?.pathInfo?.includeLocalePrefixForPrimaryLocale;

const normalizedPrefix = normalizeSlug(breadcrumbPrefix)
.replace(/\/+/g, "/")
.replace(/^\/+|\/+$/g, "");

const directoryParentsEntry = Object.entries(streamDocument).find(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this use the same getDirectoryParents as the other function?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't you just call getDirectoryParents, iterate through it, and apply the prefix?

([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;
};
Loading