From cd7dd95a8fdc635d5ad307d00f1c6635aeea0858 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:28:48 +1100 Subject: [PATCH 1/5] feat: add metadata support for appLinks, iTunes, and Twitter player/app cards Add four missing Next.js metadata features to the Metadata type and MetadataHead renderer: - appLinks: renders al:* property tags (ios, iphone, ipad, android, windows_phone, windows, windows_universal, web) - itunes: renders apple-itunes-app name tag with app-id and app-argument - twitter player cards: renders twitter:player, twitter:player:stream, twitter:player:width, twitter:player:height - twitter app cards: renders twitter:app:name/id/url per platform (iphone, ipad, googleplay) Includes fixture pages and tests ported from Next.js test suite. --- packages/vinext/src/shims/metadata.tsx | 150 ++++++++++++++++++ .../nextjs-compat/metadata-applinks/page.tsx | 24 +++ .../nextjs-compat/metadata-itunes/page.tsx | 12 ++ .../metadata-twitter-app/page.tsx | 27 ++++ .../metadata-twitter-player/page.tsx | 20 +++ tests/nextjs-compat/metadata.test.ts | 64 ++++++-- 6 files changed, 288 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/metadata-applinks/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/metadata-itunes/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/metadata-twitter-app/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/metadata-twitter-player/page.tsx diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index 07100fdf..e20dbdf8 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -171,6 +171,8 @@ export interface Metadata { | Array; creator?: string; creatorId?: string; + players?: TwitterPlayerDescriptor | TwitterPlayerDescriptor[]; + app?: TwitterAppDescriptor; }; icons?: { icon?: string | Array<{ url: string; sizes?: string; type?: string; media?: string }>; @@ -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; [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). * @@ -540,6 +601,53 @@ 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) { + elements.push( + , + ); + elements.push( + , + ); + elements.push( + , + ); + elements.push( + , + ); + } + } + // Twitter app cards + if (tw.app) { + const { app } = tw; + for (const platform of ["iphone", "ipad", "googleplay"] as const) { + if (app.id[platform] !== undefined) { + if (app.name) { + elements.push( + , + ); + } + elements.push( + , + ); + if (app.url?.[platform] !== undefined) { + elements.push( + , + ); + } + } + } + } } // Icons @@ -687,6 +795,48 @@ 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(); + } + + // 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)) { + if (v === undefined) continue; + elements.push( + , + ); + } + } + } + } + // Other custom meta tags if (metadata.other) { for (const [name, content] of Object.entries(metadata.other)) { diff --git a/tests/fixtures/app-basic/app/nextjs-compat/metadata-applinks/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/metadata-applinks/page.tsx new file mode 100644 index 00000000..630f52ac --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/metadata-applinks/page.tsx @@ -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 ; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/metadata-itunes/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/metadata-itunes/page.tsx new file mode 100644 index 00000000..a217e8a5 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/metadata-itunes/page.tsx @@ -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
iTunes page
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/metadata-twitter-app/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/metadata-twitter-app/page.tsx new file mode 100644 index 00000000..b2510917 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/metadata-twitter-app/page.tsx @@ -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
Twitter App page
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/metadata-twitter-player/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/metadata-twitter-player/page.tsx new file mode 100644 index 00000000..3bd3111c --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/metadata-twitter-player/page.tsx @@ -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
Twitter Player page
; +} diff --git a/tests/nextjs-compat/metadata.test.ts b/tests/nextjs-compat/metadata.test.ts index 0355d325..2f5f583b 100644 --- a/tests/nextjs-compat/metadata.test.ts +++ b/tests/nextjs-compat/metadata.test.ts @@ -334,6 +334,61 @@ describe("Next.js compat: metadata", () => { expect(html).toContain("search: (none)"); }); + // ── 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( + '', + ); + }); + + // ── 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 ────────────── + + 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: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' @@ -342,27 +397,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 // From d9117a31667eff0d82a4b46e889ecfea23616e8c Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:30:37 +1100 Subject: [PATCH 2/5] chore: format metadata files --- packages/vinext/src/shims/metadata.tsx | 8 +------- tests/nextjs-compat/metadata.test.ts | 4 +++- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index e20dbdf8..27febf5e 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -825,13 +825,7 @@ export function MetadataHead({ metadata }: { metadata: Metadata }) { for (const entry of list) { for (const [k, v] of Object.entries(entry)) { if (v === undefined) continue; - elements.push( - , - ); + elements.push(); } } } diff --git a/tests/nextjs-compat/metadata.test.ts b/tests/nextjs-compat/metadata.test.ts index 2f5f583b..2c7ccf98 100644 --- a/tests/nextjs-compat/metadata.test.ts +++ b/tests/nextjs-compat/metadata.test.ts @@ -386,7 +386,9 @@ describe("Next.js compat: metadata", () => { 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"'); + expect(html).toContain( + 'name="twitter:app:url:googleplay" content="https://example.com/android"', + ); }); // ── Browser-only tests (documented, not ported) ────────────── From 05f74c19fbd3ddfb129b6eb8e0a3a34c0123f1b8 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:35:47 +1100 Subject: [PATCH 3/5] fix: resolve twitter player/app and appLinks URLs against metadataBase --- packages/vinext/src/shims/metadata.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index 27febf5e..753bfe8e 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -605,11 +605,17 @@ export function MetadataHead({ metadata }: { metadata: Metadata }) { if (tw.players) { const players = Array.isArray(tw.players) ? tw.players : [tw.players]; for (const player of players) { + const playerUrl = player.playerUrl.toString(); + const streamUrl = player.streamUrl.toString(); elements.push( - , + , ); elements.push( - , + , ); elements.push( , @@ -637,11 +643,12 @@ export function MetadataHead({ metadata }: { metadata: Metadata }) { />, ); if (app.url?.[platform] !== undefined) { + const appUrl = app.url[platform]!.toString(); elements.push( , ); } @@ -825,7 +832,9 @@ export function MetadataHead({ metadata }: { metadata: Metadata }) { for (const entry of list) { for (const [k, v] of Object.entries(entry)) { if (v === undefined) continue; - elements.push(); + const str = String(v); + const content = k === "url" ? (resolveUrl(str) ?? str) : str; + elements.push(); } } } From 8d01798aa39c1973b8b753c0ee9027a93a1a7ebc Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:43:07 +1100 Subject: [PATCH 4/5] fix: un-nest twitter app card name/url checks to match Next.js behavior In Next.js, the name, id, and url checks in the twitter app card platform loop are independent. Our implementation nested name and url inside the id check, so a platform without an id wouldn't get name or url tags. This un-nests them to match Next.js parity. Also adds the missing twitter:app:name:ipad test assertion. --- packages/vinext/src/shims/metadata.tsx | 30 +++++++++++++------------- tests/nextjs-compat/metadata.test.ts | 1 + 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index 753bfe8e..a3074349 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -629,12 +629,12 @@ export function MetadataHead({ metadata }: { metadata: Metadata }) { if (tw.app) { const { app } = tw; for (const platform of ["iphone", "ipad", "googleplay"] as const) { + if (app.name) { + elements.push( + , + ); + } if (app.id[platform] !== undefined) { - if (app.name) { - elements.push( - , - ); - } elements.push( , ); - if (app.url?.[platform] !== undefined) { - const appUrl = app.url[platform]!.toString(); - elements.push( - , - ); - } + } + if (app.url?.[platform] !== undefined) { + const appUrl = app.url[platform]!.toString(); + elements.push( + , + ); } } } diff --git a/tests/nextjs-compat/metadata.test.ts b/tests/nextjs-compat/metadata.test.ts index 2c7ccf98..463ad546 100644 --- a/tests/nextjs-compat/metadata.test.ts +++ b/tests/nextjs-compat/metadata.test.ts @@ -382,6 +382,7 @@ describe("Next.js compat: metadata", () => { 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"'); expect(html).toContain('name="twitter:app:url:ipad" content="https://example.com/ipad"'); expect(html).toContain('name="twitter:app:name:googleplay" content="My App"'); From 14fdf3436d52a2169e228a6f9bc5767e0d42210c Mon Sep 17 00:00:00 2001 From: James Anderson Date: Wed, 11 Mar 2026 17:36:01 +0000 Subject: [PATCH 5/5] Update packages/vinext/src/shims/metadata.tsx --- packages/vinext/src/shims/metadata.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index a3074349..17e89716 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -831,7 +831,7 @@ export function MetadataHead({ metadata }: { metadata: Metadata }) { const list = Array.isArray(entries) ? entries : [entries]; for (const entry of list) { for (const [k, v] of Object.entries(entry)) { - if (v === undefined) continue; + if (v === undefined || v === null) continue; const str = String(v); const content = k === "url" ? (resolveUrl(str) ?? str) : str; elements.push();