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
71 changes: 36 additions & 35 deletions packages/vinext/src/shims/metadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this uses /%s/g (global replace), so a template like "%s - %s" would produce "Blog - Blog". The old code used a single .replace("%s", ...). Next.js's resolveTitleTemplate also does a single replacement:

// Next.js: resolve-title.ts
return template.replace(/%s/g, title)

Actually, checking again — Next.js does use /%s/g too (source). So this matches. Never mind, carry on.

}

function resolveTitle(title: Metadata["title"], stashedTemplate: string | undefined) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The return type here is implicitly string | undefined. Consider annotating it explicitly as resolveTitle(...): string | undefined for clarity, since it feeds directly into merged.title which accepts both strings and title objects but the resolved value should only ever be a string or undefined at this point.

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.
*
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -456,25 +479,14 @@ 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) {
const meta = entry.metadata;
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
Expand All @@ -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;
}
}

Expand Down
2 changes: 1 addition & 1 deletion tests/app-page-head.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 25 additions & 7 deletions tests/features.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good test. Consider adding a companion test for the template-only (no default) edge case to lock in the intentional title-clearing behavior:

Suggested change
expect(result.title).toBe("Blog | Site");
expect(result.title).toBe("Blog | Site");
});
it("template-only layout (no default) clears previously set title", () => {
const result = mergeMetadataEntries([
{
metadata: { title: { template: "%s | Site", default: "Site" } },
},
{
metadata: { title: { template: "%s | Blog" } },
},
]);
expect(result.title).toBeUndefined();

});

it("title.absolute skips all templates", () => {
const result = mergeMetadata([
{ title: { template: "%s | My Site", default: "My Site" } },
Expand All @@ -2150,16 +2165,19 @@ describe("metadata title templates", () => {
expect(result.title).toBe("Hello World - Blog");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice — this correctly validates that a 3-layer scenario (root template → middle template → page string) applies the nearest template. The fact that the middle layout's default ("Blog") now also gets the root template applied is the core fix.

});

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", () => {
Expand Down Expand Up @@ -2218,7 +2236,7 @@ describe("metadata title templates", () => {
expect(result).toEqual({
description: "Page",
openGraph: { title: "Slot OG title" },
title: "Page",
title: "Page | Root",
});
});
});
Expand Down
Loading