diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index 49e8e2c08..166e82faf 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -343,6 +343,29 @@ function resolveStringTitle(title: Metadata["title"]): string | undefined { return undefined; } +function applyTitleTemplate(template: string | undefined, title: string): string { + return template ? template.replace(/%s/g, title) : title; +} + +function resolveTitle(title: Metadata["title"], stashedTemplate: string | undefined) { + if (typeof title === "string") { + return applyTitleTemplate(stashedTemplate, title); + } + + if (title && typeof title === "object") { + let resolved = + title.default === undefined ? undefined : applyTitleTemplate(stashedTemplate, title.default); + + if (title.absolute) { + resolved = title.absolute; + } + + return resolved; + } + + return undefined; +} + /** * Post-process merged metadata to cross-fill openGraph and Twitter fields. * @@ -400,7 +423,7 @@ export function postProcessMetadata(merged: Metadata): Metadata { if (!hasTwDescription) { autoFill.description = result.openGraph.description || result.description || undefined; } - if (!hasTwImages) { + if (!hasTwImages && result.openGraph.images !== undefined) { autoFill.images = result.openGraph.images; } @@ -456,7 +479,7 @@ export function mergeMetadataEntries(entries: readonly MetadataMergeEntry[]): Me const merged: Metadata = {}; - // Track the most recent title template from LAYOUTS (not from page). + // Track the most recent ancestor title template from layouts (not from page). let parentTemplate: string | undefined; for (const entry of entries) { @@ -464,17 +487,6 @@ export function mergeMetadataEntries(entries: readonly MetadataMergeEntry[]): Me const isPage = Boolean(entry.isPage); const contributesTitle = entry.contributesTitle !== false; - // Collect template from layouts only (page templates are ignored per Next.js spec) - if ( - contributesTitle && - !isPage && - meta.title && - typeof meta.title === "object" && - meta.title.template - ) { - parentTemplate = meta.title.template; - } - // Merge non-title keys for (const key of Object.keys(meta)) { if (key === "title") continue; // Handle title separately below @@ -492,30 +504,19 @@ export function mergeMetadataEntries(entries: readonly MetadataMergeEntry[]): Me // Title resolution if (contributesTitle && meta.title !== undefined) { - merged.title = meta.title; + merged.title = resolveTitle(meta.title, parentTemplate); } - } - // Now resolve the final title, applying the parent template if applicable - const finalTitle = merged.title; - if (finalTitle) { - if (typeof finalTitle === "string") { - // Simple string title — apply parent template - if (parentTemplate) { - merged.title = parentTemplate.replace("%s", finalTitle); - } - } else if (typeof finalTitle === "object") { - if (finalTitle.absolute) { - // Absolute title — skip all templates - merged.title = finalTitle.absolute; - } else if (finalTitle.default) { - // Title object with default — this is used when the segment IS the - // defining layout (its own default doesn't get template-wrapped) - merged.title = finalTitle.default; - } else if (finalTitle.template && !finalTitle.default && !finalTitle.absolute) { - // Template only with no default — no title to render - merged.title = undefined; - } + // Collect the current layout template after resolving its own title so + // title.default is wrapped by the ancestor template, not by its own template. + if ( + contributesTitle && + !isPage && + meta.title && + typeof meta.title === "object" && + meta.title.template + ) { + parentTemplate = meta.title.template; } } diff --git a/tests/app-page-head.test.ts b/tests/app-page-head.test.ts index 19bed5476..17d66fcc6 100644 --- a/tests/app-page-head.test.ts +++ b/tests/app-page-head.test.ts @@ -383,7 +383,7 @@ describe("app page head resolution", () => { description: "Primary page", title: "Slot OG title", }, - title: "Page", + title: "Page | Root", twitter: { card: "summary", description: "Primary page", diff --git a/tests/features.test.ts b/tests/features.test.ts index 27bb3e799..141fee84f 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -2133,6 +2133,21 @@ describe("metadata title templates", () => { expect(result.title).toBe("My Site"); }); + it("applies ancestor title template to child layout default title", () => { + // Next.js resolveTitle() applies the stashed ancestor template to title.default: + // https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolvers/resolve-title.ts + const result = mergeMetadataEntries([ + { + metadata: { title: { template: "%s | Site", default: "Site" } }, + }, + { + metadata: { title: { default: "Blog" } }, + }, + ]); + + expect(result.title).toBe("Blog | Site"); + }); + it("title.absolute skips all templates", () => { const result = mergeMetadata([ { title: { template: "%s | My Site", default: "My Site" } }, @@ -2150,16 +2165,19 @@ describe("metadata title templates", () => { expect(result.title).toBe("Hello World - Blog"); }); - it("page template has no effect (page is terminal)", () => { - // If the page defines a template, it should be ignored - // Only layouts define templates, and page is always the last entry + it("applies ancestor template to page default while ignoring page template", () => { const result = mergeMetadata([ { title: { template: "%s | Site", default: "Site" } }, { title: { template: "%s - Page Template", default: "Page Default" } }, ]); - // The page's template should be ignored; the page's default is used - // because the page has a title object (not a string), so we use its default - expect(result.title).toBe("Page Default"); + + expect(result.title).toBe("Page Default | Site"); + }); + + it("does not apply a page template to the page's own default title", () => { + const result = mergeMetadata([{ title: { template: "%s | Page", default: "Page" } }]); + + expect(result.title).toBe("Page"); }); it("preserves non-title metadata during merge", () => { @@ -2218,7 +2236,7 @@ describe("metadata title templates", () => { expect(result).toEqual({ description: "Page", openGraph: { title: "Slot OG title" }, - title: "Page", + title: "Page | Root", }); }); });