diff --git a/.changeset/public-page-context-breadcrumbs.md b/.changeset/public-page-context-breadcrumbs.md new file mode 100644 index 000000000..6b6adf8b6 --- /dev/null +++ b/.changeset/public-page-context-breadcrumbs.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds `breadcrumbs?: BreadcrumbItem[]` to `PublicPageContext` so themes can publish a breadcrumb trail as part of the page context, and SEO plugins (or any other `page:metadata` consumer) can read it without having to invent their own per-theme override mechanism. `BreadcrumbItem` is also exported from the `emdash` package root. The field is optional and non-breaking — existing themes and plugins work unchanged, and consumers can adopt it incrementally. Empty array (`breadcrumbs: []`) is an explicit opt-out signal (e.g. for homepages); `undefined` means "no opinion, fall back to consumer's own derivation". diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 119277c11..a85d181b8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -359,6 +359,7 @@ export type { SeoMeta, SeoMetaOptions } from "./seo/index.js"; export type { PagePlacement, PublicPageContext, + BreadcrumbItem, PageMetadataEvent, PageMetadataContribution, PageMetadataHandler, diff --git a/packages/core/src/page/context.ts b/packages/core/src/page/context.ts index f1cb1595b..c35194b0e 100644 --- a/packages/core/src/page/context.ts +++ b/packages/core/src/page/context.ts @@ -5,7 +5,7 @@ * The resulting context is passed to EmDashHead / EmDashBodyStart / EmDashBodyEnd. */ -import type { PublicPageContext } from "../plugins/types.js"; +import type { BreadcrumbItem, PublicPageContext } from "../plugins/types.js"; /** Fields shared by both input forms */ interface PageContextFields { @@ -31,6 +31,12 @@ interface PageContextFields { }; /** Site name for structured data and og:site_name */ siteName?: string; + /** + * Breadcrumb trail for this page, root first. Pass an empty array + * to explicitly opt out of breadcrumbs (e.g. homepage), or omit the + * field to let consumers fall back to their own derivation. + */ + breadcrumbs?: BreadcrumbItem[]; /** Public-facing site URL (origin) for structured data */ siteUrl?: string; } @@ -91,6 +97,7 @@ export function createPublicPageContext(input: CreatePublicPageContextInput): Pu seo: input.seo, articleMeta: input.articleMeta, siteName: input.siteName, + breadcrumbs: input.breadcrumbs, siteUrl: input.siteUrl, }; } diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index c4a8fee82..e1b71c6a4 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -747,6 +747,17 @@ export type UninstallHandler = (event: UninstallEvent, ctx: PluginContext) => Pr /** Placement targets for page fragment contributions */ export type PagePlacement = "head" | "body:start" | "body:end"; +/** + * A single breadcrumb trail item. Used by `PublicPageContext.breadcrumbs` + * so themes can publish breadcrumb trails that SEO plugins consume. + */ +export interface BreadcrumbItem { + /** Display name for this crumb (e.g. "Home", "Blog", "My Post"). */ + name: string; + /** Absolute or root-relative URL for this crumb. */ + url: string; +} + /** * Describes the page being rendered. Passed to page hooks so plugins * can decide what to contribute without fetching content themselves. @@ -781,6 +792,21 @@ export interface PublicPageContext { }; /** Site name for structured data and og:site_name */ siteName?: string; + /** + * Optional breadcrumb trail for this page, root first. When set, + * SEO plugins should use this verbatim rather than deriving a trail + * from `path`. Themes typically populate this at the point they + * build the context (e.g. from a content hierarchy walk, taxonomy + * lookup, or per-`pageType` routing logic). + * + * Semantics for consumers: + * - `undefined` — theme has no opinion; consumer falls back to + * its own derivation. + * - `[]` — this page has no breadcrumbs (e.g. homepage); consumer + * should skip `BreadcrumbList` emission entirely. + * - Non-empty array — used verbatim for `BreadcrumbList` output. + */ + breadcrumbs?: BreadcrumbItem[]; /** Public-facing site URL (origin) for structured data */ siteUrl?: string; } diff --git a/packages/core/tests/unit/plugins/page-context.test.ts b/packages/core/tests/unit/plugins/page-context.test.ts index d184fc691..4ed507435 100644 --- a/packages/core/tests/unit/plugins/page-context.test.ts +++ b/packages/core/tests/unit/plugins/page-context.test.ts @@ -104,4 +104,41 @@ describe("createPublicPageContext", () => { expect(result.content).toBeUndefined(); }); + + it("passes breadcrumbs through verbatim when provided", () => { + const result = createPublicPageContext({ + url: "https://example.com/blog/hello", + kind: "content", + breadcrumbs: [ + { name: "Home", url: "/" }, + { name: "Blog", url: "/blog/" }, + { name: "Hello", url: "/blog/hello" }, + ], + }); + + expect(result.breadcrumbs).toEqual([ + { name: "Home", url: "/" }, + { name: "Blog", url: "/blog/" }, + { name: "Hello", url: "/blog/hello" }, + ]); + }); + + it("leaves breadcrumbs undefined when not provided", () => { + const result = createPublicPageContext({ + url: "https://example.com/about", + kind: "custom", + }); + + expect(result.breadcrumbs).toBeUndefined(); + }); + + it("preserves explicit empty breadcrumbs array (opt-out signal)", () => { + const result = createPublicPageContext({ + url: "https://example.com/", + kind: "custom", + breadcrumbs: [], + }); + + expect(result.breadcrumbs).toEqual([]); + }); });