Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/public-page-context-breadcrumbs.md
Original file line number Diff line number Diff line change
@@ -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".
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ export type { SeoMeta, SeoMetaOptions } from "./seo/index.js";
export type {
PagePlacement,
PublicPageContext,
BreadcrumbItem,
PageMetadataEvent,
PageMetadataContribution,
PageMetadataHandler,
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/page/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
};
}
26 changes: 26 additions & 0 deletions packages/core/src/plugins/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down
37 changes: 37 additions & 0 deletions packages/core/tests/unit/plugins/page-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
Loading