From 924c614cecdf20684c5027f630acef7d64b78e9a Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Thu, 9 Apr 2026 20:27:51 +0200 Subject: [PATCH] feat(core): add breadcrumbs field to PublicPageContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Themes can now publish a breadcrumb trail alongside the rest of the page context, and SEO plugins (or any other page:metadata consumer) can read it verbatim instead of inventing their own per-theme override mechanism. The field is optional and non-breaking: - undefined — theme has no opinion; consumer falls back to its own derivation (path walking, rule maps, etc). - [] — explicit opt-out; consumer should skip BreadcrumbList emission entirely (e.g. homepages, error pages). - Non-empty array — used verbatim by consumers for BreadcrumbList schema output. BreadcrumbItem is also exported from the emdash package root so plugins can import the type directly. Why the context and not a plugin config callback? The descriptor options round-trip through JSON.stringify in generatePluginsModule (packages/core/src/astro/integration/virtual-modules.ts:200), so functions are stripped before they reach the runtime plugin. Pushing the data onto the page context is both technically necessary and semantically cleaner — it's observable, debuggable, and usable by any hook consumer rather than coupling to one plugin's config shape. Refs #413 Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/public-page-context-breadcrumbs.md | 5 +++ packages/core/src/index.ts | 1 + packages/core/src/page/context.ts | 9 ++++- packages/core/src/plugins/types.ts | 26 +++++++++++++ .../tests/unit/plugins/page-context.test.ts | 37 +++++++++++++++++++ 5 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 .changeset/public-page-context-breadcrumbs.md 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 5f343cade..0eb2048be 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -352,6 +352,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 b1f12e75a..480e5c902 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[]; } /** Input with Astro global -- used in .astro files */ @@ -89,5 +95,6 @@ export function createPublicPageContext(input: CreatePublicPageContextInput): Pu seo: input.seo, articleMeta: input.articleMeta, siteName: input.siteName, + breadcrumbs: input.breadcrumbs, }; } diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 709cedc43..7d5187dbf 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[]; } // ── page:metadata ─────────────────────────────────────────────── 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([]); + }); });