Skip to content
Open
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
153 changes: 153 additions & 0 deletions packages/vinext/src/shims/metadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ export interface Metadata {
| Array<string | { url: string; alt?: string; width?: number; height?: number }>;
creator?: string;
creatorId?: string;
players?: TwitterPlayerDescriptor | TwitterPlayerDescriptor[];
app?: TwitterAppDescriptor;
};
icons?: {
icon?: string | Array<{ url: string; sizes?: string; type?: string; media?: string }>;
Expand Down Expand Up @@ -204,10 +206,69 @@ export interface Metadata {
telephone?: boolean;
};
category?: string;
itunes?: {
appId: string;
appArgument?: string;
};
appLinks?: {
ios?: AppLinksApple | AppLinksApple[];
iphone?: AppLinksApple | AppLinksApple[];
ipad?: AppLinksApple | AppLinksApple[];
android?: AppLinksAndroid | AppLinksAndroid[];
windows_phone?: AppLinksWindows | AppLinksWindows[];
windows?: AppLinksWindows | AppLinksWindows[];
windows_universal?: AppLinksWindows | AppLinksWindows[];
web?: AppLinksWeb | AppLinksWeb[];
};
other?: Record<string, string | string[]>;
[key: string]: unknown;
}

interface AppLinksApple {
url: string | URL;
app_store_id?: string | number;
app_name?: string;
}

interface AppLinksAndroid {
package: string;
url?: string | URL;
class?: string;
app_name?: string;
}

interface AppLinksWindows {
url: string | URL;
app_id?: string;
app_name?: string;
}

interface AppLinksWeb {
url: string | URL;
should_fallback?: boolean;
}

interface TwitterPlayerDescriptor {
playerUrl: string | URL;
streamUrl: string | URL;
width: number;
height: number;
}

interface TwitterAppDescriptor {
id: {
iphone?: string | number;
ipad?: string | number;
googleplay?: string;
};
url?: {
iphone?: string | URL;
ipad?: string | URL;
googleplay?: string | URL;
};
name?: string;
}

/**
* Merge metadata from multiple sources (layouts + page).
*
Expand Down Expand Up @@ -540,6 +601,60 @@ export function MetadataHead({ metadata }: { metadata: Metadata }) {
}
}
}
// Twitter player cards
if (tw.players) {
const players = Array.isArray(tw.players) ? tw.players : [tw.players];
for (const player of players) {
const playerUrl = player.playerUrl.toString();
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: player.playerUrl.toString() is called before passing to resolveUrl, which is good. But note that if playerUrl is already a URL object, .toString() produces an absolute URL that resolveUrl will pass through unchanged (the startsWith('http') check). This means metadataBase resolution only kicks in for string values that are relative paths — which is the correct behavior and matches how it should work. Just confirming this is intentional.

const streamUrl = player.streamUrl.toString();
elements.push(
<meta key={key++} name="twitter:player" content={resolveUrl(playerUrl) ?? playerUrl} />,
);
elements.push(
<meta
key={key++}
name="twitter:player:stream"
content={resolveUrl(streamUrl) ?? streamUrl}
/>,
);
elements.push(
<meta key={key++} name="twitter:player:width" content={String(player.width)} />,
);
elements.push(
<meta key={key++} name="twitter:player:height" content={String(player.height)} />,
);
}
}
// Twitter app cards
if (tw.app) {
const { app } = tw;
for (const platform of ["iphone", "ipad", "googleplay"] as const) {
if (app.name) {
elements.push(
<meta key={key++} name={`twitter:app:name:${platform}`} content={app.name} />,
);
}
if (app.id[platform] !== undefined) {
elements.push(
<meta
key={key++}
name={`twitter:app:id:${platform}`}
content={String(app.id[platform])}
/>,
);
}
if (app.url?.[platform] !== undefined) {
const appUrl = app.url[platform]!.toString();
elements.push(
<meta
key={key++}
name={`twitter:app:url:${platform}`}
content={resolveUrl(appUrl) ?? appUrl}
/>,
);
}
Comment on lines +631 to +655
Copy link
Contributor

Choose a reason for hiding this comment

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

Behavioral difference from Next.js: In Next.js, the name, id, and url checks inside the platform loop are independent — not nested. If app.name is set, Next.js emits twitter:app:name:{platform} for all three platforms regardless of whether app.id[platform] exists:

// Next.js (packages/next/src/lib/metadata/metadata.tsx)
for (const platform of ['iphone', 'ipad', 'googleplay'] as const) {
  if (app.name) { /* emit name */ }
  if (app.id[platform]) { /* emit id */ }
  if (app.url?.[platform]) { /* emit url */ }
}

Here, name and url are nested inside if (app.id[platform] !== undefined), which means a platform without an id won't get name or url tags. This diverges from Next.js behavior.

Suggested change
for (const platform of ["iphone", "ipad", "googleplay"] as const) {
if (app.id[platform] !== undefined) {
if (app.name) {
elements.push(
<meta key={key++} name={`twitter:app:name:${platform}`} content={app.name} />,
);
}
elements.push(
<meta
key={key++}
name={`twitter:app:id:${platform}`}
content={String(app.id[platform])}
/>,
);
if (app.url?.[platform] !== undefined) {
const appUrl = app.url[platform]!.toString();
elements.push(
<meta
key={key++}
name={`twitter:app:url:${platform}`}
content={resolveUrl(appUrl) ?? appUrl}
/>,
);
}
}
if (tw.app) {
const { app } = tw;
for (const platform of ["iphone", "ipad", "googleplay"] as const) {
if (app.name) {
elements.push(
<meta key={key++} name={`twitter:app:name:${platform}`} content={app.name} />,
);
}
if (app.id[platform] !== undefined) {
elements.push(
<meta
key={key++}
name={`twitter:app:id:${platform}`}
content={String(app.id[platform])}
/>,
);
}
if (app.url?.[platform] !== undefined) {
const appUrl = app.url[platform]!.toString();
elements.push(
<meta
key={key++}
name={`twitter:app:url:${platform}`}
content={resolveUrl(appUrl) ?? appUrl}
/>,
);
}
}
}

}
}
}

// Icons
Expand Down Expand Up @@ -687,6 +802,44 @@ export function MetadataHead({ metadata }: { metadata: Metadata }) {
}
}

// iTunes
if (metadata.itunes) {
const { appId, appArgument } = metadata.itunes;
let content = `app-id=${appId}`;
if (appArgument) {
content += `, app-argument=${appArgument}`;
}
elements.push(<meta key={key++} name="apple-itunes-app" content={content} />);
}

// App Links
if (metadata.appLinks) {
const al = metadata.appLinks;
const platforms = [
"ios",
"iphone",
"ipad",
"android",
"windows_phone",
"windows",
"windows_universal",
"web",
] as const;
for (const platform of platforms) {
const entries = al[platform];
if (!entries) continue;
const list = Array.isArray(entries) ? entries : [entries];
for (const entry of list) {
for (const [k, v] of Object.entries(entry)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Object.entries(entry) iterates properties in insertion order, which means the tag order depends on how the user wrote their object literal. This is fine functionally (and how Next.js does it too for resolved arrays), but worth noting that for AppLinksAndroid the package field will render as al:android:package — which is correct. Just double-checking you considered that Object.entries will include all enumerable own properties, so if someone passes unexpected extra fields they'd also be rendered. Low risk but worth being aware of.

if (v === undefined) continue;
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: this skips undefined values but would render null as content="null". Next.js uses != null checks (which catch both null and undefined). The TypeScript types here don't include null in the interfaces, so this can't happen through typed callers — but since Metadata has [key: string]: unknown, it's theoretically possible if someone passes untyped data.

Not blocking — just noting the difference.

const str = String(v);
const content = k === "url" ? (resolveUrl(str) ?? str) : str;
elements.push(<meta key={key++} property={`al:${platform}:${k}`} content={content} />);
}
}
}
}

// Other custom meta tags
if (metadata.other) {
for (const [name, content] of Object.entries(metadata.other)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Metadata } from "next";

export const metadata: Metadata = {
appLinks: {
ios: {
url: "https://example.com/ios",
app_store_id: "123456789",
app_name: "My iOS App",
},
android: {
package: "com.example.app",
url: "https://example.com/android",
app_name: "My Android App",
},
web: {
url: "https://example.com",
should_fallback: true,
},
},
};

export default function Page() {
return <div id="applinks">App Links page</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Metadata } from "next";

export const metadata: Metadata = {
itunes: {
appId: "123456789",
appArgument: "myapp://content/123",
},
};

export default function Page() {
return <div id="itunes">iTunes page</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Metadata } from "next";

export const metadata: Metadata = {
twitter: {
card: "app",
title: "App Title",
description: "App Description",
site: "@example",
app: {
id: {
iphone: "id123456789",
ipad: "id123456789",
googleplay: "com.example.app",
},
url: {
iphone: "https://example.com/iphone",
ipad: "https://example.com/ipad",
googleplay: "https://example.com/android",
},
name: "My App",
},
},
};

export default function Page() {
return <div id="twitter-app">Twitter App page</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Metadata } from "next";

export const metadata: Metadata = {
twitter: {
card: "player",
title: "Video Title",
description: "Video Description",
site: "@example",
players: {
playerUrl: "https://example.com/player",
streamUrl: "https://example.com/stream",
width: 480,
height: 360,
},
},
};

export default function Page() {
return <div id="twitter-player">Twitter Player page</div>;
}
67 changes: 58 additions & 9 deletions tests/nextjs-compat/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,64 @@ describe("Next.js compat: metadata", () => {
expect(html).toContain("<title>search: (none)</title>");
});

// ── iTunes meta tag ──────────────
// Ported from Next.js: test/e2e/app-dir/metadata/metadata.test.ts
// 'should support apple related tags itunes and appWebApp'

it("should render apple-itunes-app meta tag", async () => {
const { html } = await fetchHtml(baseUrl, "/nextjs-compat/metadata-itunes");
expect(html).toContain(
'<meta name="apple-itunes-app" content="app-id=123456789, app-argument=myapp://content/123"/>',
);
});

// ── App Links ──────────────
// Ported from Next.js: test/e2e/app-dir/metadata/metadata.test.ts
// 'should support appLinks tags'

it("should render appLinks meta tags", async () => {
const { html } = await fetchHtml(baseUrl, "/nextjs-compat/metadata-applinks");
expect(html).toContain('property="al:ios:url" content="https://example.com/ios"');
expect(html).toContain('property="al:ios:app_store_id" content="123456789"');
expect(html).toContain('property="al:ios:app_name" content="My iOS App"');
expect(html).toContain('property="al:android:package" content="com.example.app"');
expect(html).toContain('property="al:android:url" content="https://example.com/android"');
expect(html).toContain('property="al:android:app_name" content="My Android App"');
expect(html).toContain('property="al:web:url" content="https://example.com"');
expect(html).toContain('property="al:web:should_fallback" content="true"');
});

// ── Twitter player cards ──────────────
// Ported from Next.js: test/e2e/app-dir/metadata/metadata.test.ts
// 'should support twitter player/app cards'

it("should render twitter player card meta tags", async () => {
const { html } = await fetchHtml(baseUrl, "/nextjs-compat/metadata-twitter-player");
expect(html).toContain('name="twitter:card" content="player"');
expect(html).toContain('name="twitter:player" content="https://example.com/player"');
expect(html).toContain('name="twitter:player:stream" content="https://example.com/stream"');
expect(html).toContain('name="twitter:player:width" content="480"');
expect(html).toContain('name="twitter:player:height" content="360"');
});

// ── Twitter app cards ──────────────
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: this test block doesn't have the "Ported from Next.js" comment that the other three new test blocks have. Per AGENTS.md, ported tests should include a link back to the original:

Suggested change
// ── Twitter app cards ──────────────
// ── Twitter app cards ──────────────
// Ported from Next.js: test/e2e/app-dir/metadata/metadata.test.ts
// 'should support twitter player/app cards'


it("should render twitter app card meta tags", async () => {
const { html } = await fetchHtml(baseUrl, "/nextjs-compat/metadata-twitter-app");
expect(html).toContain('name="twitter:card" content="app"');
expect(html).toContain('name="twitter:app:name:iphone" content="My App"');
expect(html).toContain('name="twitter:app:id:iphone" content="id123456789"');
expect(html).toContain('name="twitter:app:url:iphone" content="https://example.com/iphone"');
expect(html).toContain('name="twitter:app:name:ipad" content="My App"');
expect(html).toContain('name="twitter:app:id:ipad" content="id123456789"');
Copy link
Contributor

Choose a reason for hiding this comment

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

The test doesn't assert twitter:app:name:ipad. Since the fixture sets name: "My App" and all three platforms have IDs, the tag should be present. Consider adding:

Suggested change
expect(html).toContain('name="twitter:app:id:ipad" content="id123456789"');
expect(html).toContain('name="twitter:app:name:ipad" content="My App"');
expect(html).toContain('name="twitter:app:id:ipad" content="id123456789"');

expect(html).toContain('name="twitter:app:url:ipad" content="https://example.com/ipad"');
expect(html).toContain('name="twitter:app:name:googleplay" content="My App"');
expect(html).toContain('name="twitter:app:id:googleplay" content="com.example.app"');
expect(html).toContain(
'name="twitter:app:url:googleplay" content="https://example.com/android"',
);
});

// ── Browser-only tests (documented, not ported) ──────────────
//
// N/A: 'should apply metadata when navigating client-side'
Expand All @@ -342,27 +400,18 @@ describe("Next.js compat: metadata", () => {
// N/A: 'should support title template' (browser eval)
// Some template tests use browser.eval('document.title') — ported above at SSR level
//
// N/A: 'should support apple related tags itunes and appWebApp'
// Would need dedicated fixture page
//
// N/A: 'should support socials related tags'
// Would need dedicated fixture page (fb:app_id, pinterest)
//
// N/A: 'should support verification tags'
// Would need dedicated fixture page
//
// N/A: 'should support appLinks tags'
// Would need dedicated fixture page
//
// N/A: 'should support icons field' (basic, string, descriptor)
// Would need dedicated fixture pages — partially covered by existing vinext tests
//
// N/A: 'should pick up opengraph-image and twitter-image as static metadata files'
// Tests file-based metadata images — different feature
//
// N/A: 'should support twitter player/app cards'
// Would need dedicated fixture pages
//
// N/A: Static routes (favicon.ico, robots.txt, sitemap.xml)
// Tests file serving, not metadata export — separate feature
//
Expand Down
Loading