diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index 07100fdf..17e89716 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,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(); + const streamUrl = player.streamUrl.toString(); + 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.name) { + elements.push( + , + ); + } + if (app.id[platform] !== undefined) { + elements.push( + , + ); + } + if (app.url?.[platform] !== undefined) { + const appUrl = app.url[platform]!.toString(); + elements.push( + , + ); + } + } + } } // Icons @@ -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(); + } + + // 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 || v === null) continue; + const str = String(v); + const content = k === "url" ? (resolveUrl(str) ?? str) : str; + 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..463ad546 100644 --- a/tests/nextjs-compat/metadata.test.ts +++ b/tests/nextjs-compat/metadata.test.ts @@ -334,6 +334,64 @@ 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: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' @@ -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 //