mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
50f435ce13
The Twitter metadata renderer emitted `twitter:app:*` tags whenever a `twitter.app` object was present and `twitter:player:*` tags whenever `twitter.players` was present, regardless of the resolved card type. It also emitted `twitter:app:id`/`twitter:app:url` for falsy values (`0`, `""`). Next.js gates these blocks on the resolved card: `if (card === 'app')` and `if (card === 'player')`, and uses truthy checks for app id/url. Mirror that: gate the app block on `card === 'app'`, the player block on `card === 'player'`, and skip falsy app ids/urls. The card is already defaulted in postProcessMetadata (summary_large_image / summary), matching Next.js. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
928 lines
29 KiB
TypeScript
928 lines
29 KiB
TypeScript
import { createElement } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { describe, expect, it, vi } from "vite-plus/test";
|
|
import { applyFileBasedMetadata } from "../packages/vinext/src/server/file-based-metadata.js";
|
|
import { MetadataHead, type Metadata } from "../packages/vinext/src/shims/metadata.js";
|
|
import type { MetadataFileRoute } from "../packages/vinext/src/server/metadata-routes.js";
|
|
|
|
const ogHeadData = {
|
|
kind: "openGraph",
|
|
href: "/blog/opengraph-image.png?hash",
|
|
type: "image/png",
|
|
width: 1200,
|
|
height: 630,
|
|
} as const;
|
|
|
|
describe("applyFileBasedMetadata", () => {
|
|
it("preserves URL metadata values while injecting file metadata", async () => {
|
|
const metadata: Metadata = {
|
|
metadataBase: new URL("https://example.com"),
|
|
};
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "icon",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/icon.png",
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/icon.png",
|
|
contentType: "image/png",
|
|
headData: {
|
|
kind: "icon",
|
|
href: "/icon.png?hash",
|
|
type: "image/png",
|
|
sizes: "32x32",
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(metadata, "/", {}, routes, {
|
|
routeSegments: [],
|
|
metadataSources: [{ routeSegments: [], metadata }],
|
|
});
|
|
|
|
expect(result?.metadataBase).toBe(metadata.metadataBase);
|
|
expect(result?.icons).toEqual({
|
|
icon: [{ url: "/icon.png?hash", sizes: "32x32", type: "image/png" }],
|
|
});
|
|
});
|
|
|
|
it("keeps explicit icon and apple metadata ahead of file icon routes", async () => {
|
|
const metadata: Metadata = {
|
|
icons: {
|
|
apple: "/manual-apple.png",
|
|
icon: "/manual-icon.png",
|
|
},
|
|
};
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "icon",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/icon.png",
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/icon.png",
|
|
contentType: "image/png",
|
|
headData: {
|
|
kind: "icon",
|
|
href: "/icon.png?hash",
|
|
type: "image/png",
|
|
sizes: "32x32",
|
|
},
|
|
},
|
|
{
|
|
type: "apple-icon",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/apple-icon.png",
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/apple-icon.png",
|
|
contentType: "image/png",
|
|
headData: {
|
|
kind: "apple",
|
|
href: "/apple-icon.png?hash",
|
|
type: "image/png",
|
|
sizes: "180x180",
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(metadata, "/", {}, routes, {
|
|
routeSegments: [],
|
|
metadataSources: [{ routeSegments: [], metadata }],
|
|
});
|
|
|
|
expect(result?.icons).toEqual({
|
|
apple: "/manual-apple.png",
|
|
icon: "/manual-icon.png",
|
|
});
|
|
});
|
|
|
|
it("keeps explicit shorthand icon metadata ahead of file icon routes", async () => {
|
|
const metadata: Metadata = { icons: "/manual-icon.png" };
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "icon",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/icon.png",
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/icon.png",
|
|
contentType: "image/png",
|
|
headData: {
|
|
kind: "icon",
|
|
href: "/icon.png?hash",
|
|
type: "image/png",
|
|
sizes: "32x32",
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(metadata, "/", {}, routes, {
|
|
routeSegments: [],
|
|
metadataSources: [{ routeSegments: [], metadata }],
|
|
});
|
|
|
|
expect(result?.icons).toBe("/manual-icon.png");
|
|
});
|
|
|
|
it("keeps inherited explicit icon metadata ahead of leaf file icon routes", async () => {
|
|
const parentMetadata: Metadata = { icons: "/parent-icon.png" };
|
|
const leafMetadata: Metadata = { title: "Leaf" };
|
|
const mergedMetadata: Metadata = { icons: "/parent-icon.png", title: "Leaf" };
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "icon",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/blog/icon.png",
|
|
routePrefix: "/blog",
|
|
routeSegments: ["blog"],
|
|
servedUrl: "/blog/icon.png",
|
|
contentType: "image/png",
|
|
headData: {
|
|
kind: "icon",
|
|
href: "/blog/icon.png?hash",
|
|
type: "image/png",
|
|
sizes: "32x32",
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(mergedMetadata, "/blog", {}, routes, {
|
|
routeSegments: ["blog"],
|
|
metadataSources: [
|
|
{ routeSegments: [], metadata: parentMetadata },
|
|
{ routeSegments: ["blog"], metadata: leafMetadata },
|
|
],
|
|
});
|
|
|
|
expect(result?.icons).toBe("/parent-icon.png");
|
|
});
|
|
|
|
it("preserves explicit shorthand icon metadata when prepending a favicon", async () => {
|
|
const metadata: Metadata = { icons: "/manual-icon.png" };
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "favicon",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/favicon.ico",
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/favicon.ico",
|
|
contentType: "image/x-icon",
|
|
headData: {
|
|
kind: "favicon",
|
|
href: "/favicon.ico?hash",
|
|
type: "image/x-icon",
|
|
sizes: "32x32",
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(metadata, "/", {}, routes, {
|
|
routeSegments: [],
|
|
metadataSources: [{ routeSegments: [], metadata }],
|
|
});
|
|
|
|
expect(result?.icons).toEqual({
|
|
icon: [
|
|
{ url: "/favicon.ico?hash", sizes: "32x32", type: "image/x-icon" },
|
|
{ url: "/manual-icon.png" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("preserves explicit descriptor icon metadata when prepending a favicon", async () => {
|
|
const metadata: Metadata = {
|
|
icons: { url: "/manual-icon.png", sizes: "64x64", type: "image/png" },
|
|
};
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "favicon",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/favicon.ico",
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/favicon.ico",
|
|
contentType: "image/x-icon",
|
|
headData: {
|
|
kind: "favicon",
|
|
href: "/favicon.ico?hash",
|
|
type: "image/x-icon",
|
|
sizes: "32x32",
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(metadata, "/", {}, routes, {
|
|
routeSegments: [],
|
|
metadataSources: [{ routeSegments: [], metadata }],
|
|
});
|
|
|
|
expect(result?.icons).toEqual({
|
|
icon: [
|
|
{ url: "/favicon.ico?hash", sizes: "32x32", type: "image/x-icon" },
|
|
{ url: "/manual-icon.png", sizes: "64x64", type: "image/png" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("lets a leaf file image replace inherited parent Open Graph images", async () => {
|
|
const parentMetadata: Metadata = {
|
|
openGraph: {
|
|
description: "Parent description",
|
|
images: ["/parent-og.png"],
|
|
siteName: "Parent site",
|
|
title: "Parent title",
|
|
type: "article",
|
|
},
|
|
};
|
|
const leafMetadata: Metadata = { title: "Blog" };
|
|
const mergedMetadata: Metadata = {
|
|
title: "Blog",
|
|
openGraph: {
|
|
description: "Parent description",
|
|
images: ["/parent-og.png"],
|
|
siteName: "Parent site",
|
|
title: "Parent title",
|
|
type: "article",
|
|
},
|
|
};
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "opengraph-image",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/blog/opengraph-image.png",
|
|
routePrefix: "/blog",
|
|
routeSegments: ["blog"],
|
|
servedUrl: "/blog/opengraph-image.png",
|
|
contentType: "image/png",
|
|
headData: ogHeadData,
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(mergedMetadata, "/blog", {}, routes, {
|
|
routeSegments: ["blog"],
|
|
metadataSources: [
|
|
{ routeSegments: [], metadata: parentMetadata },
|
|
{ routeSegments: ["blog"], metadata: leafMetadata },
|
|
],
|
|
});
|
|
|
|
expect(result?.openGraph?.images).toEqual([
|
|
{ url: "/blog/opengraph-image.png?hash", type: "image/png", width: 1200, height: 630 },
|
|
]);
|
|
expect(result?.openGraph?.description).toBe("Parent description");
|
|
expect(result?.openGraph?.siteName).toBe("Parent site");
|
|
expect(result?.openGraph?.title).toBe("Parent title");
|
|
expect(result?.openGraph?.type).toBe("article");
|
|
});
|
|
|
|
it("keeps same-segment explicit Open Graph images ahead of file images", async () => {
|
|
const leafMetadata: Metadata = { openGraph: { images: ["/manual-og.png"] } };
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "opengraph-image",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/blog/opengraph-image.png",
|
|
routePrefix: "/blog",
|
|
routeSegments: ["blog"],
|
|
servedUrl: "/blog/opengraph-image.png",
|
|
contentType: "image/png",
|
|
headData: ogHeadData,
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(leafMetadata, "/blog", {}, routes, {
|
|
routeSegments: ["blog"],
|
|
metadataSources: [
|
|
{ routeSegments: ["blog"], metadata: { title: "Blog layout" } },
|
|
{ routeSegments: ["blog"], metadata: leafMetadata },
|
|
],
|
|
});
|
|
|
|
expect(result?.openGraph?.images).toEqual(["/manual-og.png"]);
|
|
});
|
|
|
|
it("lets a leaf Twitter file image replace inherited parent Twitter images", async () => {
|
|
const parentMetadata: Metadata = {
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
images: ["/parent-twitter.png"],
|
|
},
|
|
};
|
|
const leafMetadata: Metadata = { title: "Blog" };
|
|
const mergedMetadata: Metadata = {
|
|
title: "Blog",
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
images: ["/parent-twitter.png"],
|
|
},
|
|
};
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "twitter-image",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/blog/twitter-image.png",
|
|
routePrefix: "/blog",
|
|
routeSegments: ["blog"],
|
|
servedUrl: "/blog/twitter-image.png",
|
|
contentType: "image/png",
|
|
headData: {
|
|
kind: "twitter",
|
|
href: "/blog/twitter-image.png?hash",
|
|
type: "image/png",
|
|
width: 1200,
|
|
height: 630,
|
|
alt: "Twitter alt",
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(mergedMetadata, "/blog", {}, routes, {
|
|
routeSegments: ["blog"],
|
|
metadataSources: [
|
|
{ routeSegments: [], metadata: parentMetadata },
|
|
{ routeSegments: ["blog"], metadata: leafMetadata },
|
|
],
|
|
});
|
|
|
|
expect(result?.twitter?.card).toBe("summary_large_image");
|
|
expect(result?.twitter?.images).toEqual([
|
|
{
|
|
alt: "Twitter alt",
|
|
height: 630,
|
|
type: "image/png",
|
|
url: "/blog/twitter-image.png?hash",
|
|
width: 1200,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("applies file manifest metadata over config manifest metadata", async () => {
|
|
const metadata: Metadata = { manifest: "/manual.webmanifest" };
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "manifest",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/manifest.webmanifest",
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/manifest.webmanifest",
|
|
contentType: "application/manifest+json",
|
|
headData: { kind: "manifest", href: "/manifest.webmanifest" },
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(metadata, "/", {}, routes, {
|
|
routeSegments: [],
|
|
metadataSources: [{ routeSegments: [], metadata }],
|
|
});
|
|
|
|
expect(result?.manifest).toBe("/manifest.webmanifest");
|
|
});
|
|
|
|
it("uses raw route segments so same-prefix route groups select their own file metadata", async () => {
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "opengraph-image",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/(marketing)/opengraph-image.png",
|
|
routePrefix: "",
|
|
routeSegments: ["(marketing)"],
|
|
servedUrl: "/opengraph-image-marketing.png",
|
|
contentType: "image/png",
|
|
headData: { ...ogHeadData, href: "/opengraph-image-marketing.png?hash" },
|
|
},
|
|
{
|
|
type: "opengraph-image",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/(shop)/opengraph-image.png",
|
|
routePrefix: "",
|
|
routeSegments: ["(shop)"],
|
|
servedUrl: "/opengraph-image-shop.png",
|
|
contentType: "image/png",
|
|
headData: { ...ogHeadData, href: "/opengraph-image-shop.png?hash" },
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(null, "/", {}, routes, {
|
|
routeSegments: ["(marketing)"],
|
|
metadataSources: [{ routeSegments: ["(marketing)"], metadata: null }],
|
|
});
|
|
|
|
expect(result?.openGraph?.images).toEqual([
|
|
{ url: "/opengraph-image-marketing.png?hash", type: "image/png", width: 1200, height: 630 },
|
|
]);
|
|
});
|
|
|
|
it("applies metadata from an active parallel slot page", async () => {
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "opengraph-image",
|
|
isDynamic: false,
|
|
filePath: "/tmp/app/parallel/@parallel/opengraph-image.png",
|
|
routePrefix: "/parallel",
|
|
routeSegments: ["parallel", "@parallel"],
|
|
servedUrl: "/parallel/opengraph-image-slot.png",
|
|
contentType: "image/png",
|
|
headData: { ...ogHeadData, href: "/parallel/opengraph-image-slot.png?hash" },
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(null, "/parallel", {}, routes, {
|
|
routeSegments: ["parallel"],
|
|
metadataSources: [{ routeSegments: ["parallel"], metadata: null }],
|
|
});
|
|
|
|
expect(result?.openGraph?.images).toEqual([
|
|
{
|
|
url: "/parallel/opengraph-image-slot.png?hash",
|
|
type: "image/png",
|
|
width: 1200,
|
|
height: 630,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("drops generateImageMetadata ids that are not path-segment safe", async () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "opengraph-image",
|
|
isDynamic: true,
|
|
filePath: "/tmp/app/opengraph-image.tsx",
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/opengraph-image",
|
|
contentType: "image/png",
|
|
contentHash: "hash",
|
|
module: {
|
|
generateImageMetadata: async () => [{ id: "a/b" }],
|
|
},
|
|
},
|
|
];
|
|
|
|
try {
|
|
const result = await applyFileBasedMetadata(null, "/", {}, routes, {
|
|
routeSegments: [],
|
|
metadataSources: [{ routeSegments: [], metadata: null }],
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
expect(warn).toHaveBeenCalledWith(
|
|
'[vinext] Skipping metadata route /opengraph-image image id "a/b" because metadata image ids must match /^[a-zA-Z0-9-_.]+$/.',
|
|
);
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("drops generateImageMetadata entries without ids", async () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "opengraph-image",
|
|
isDynamic: true,
|
|
filePath: "/tmp/app/opengraph-image.tsx",
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/opengraph-image",
|
|
contentType: "image/png",
|
|
contentHash: "hash",
|
|
module: {
|
|
generateImageMetadata: async () => [{ alt: "missing id" }],
|
|
},
|
|
},
|
|
];
|
|
|
|
try {
|
|
const result = await applyFileBasedMetadata(null, "/", {}, routes, {
|
|
routeSegments: [],
|
|
metadataSources: [{ routeSegments: [], metadata: null }],
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"[vinext] Skipping metadata route /opengraph-image image metadata entry because generateImageMetadata entries must include an id.",
|
|
);
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("uses dynamic metadata module exports when generateImageMetadata is absent", async () => {
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "opengraph-image",
|
|
isDynamic: true,
|
|
filePath: "/tmp/app/blog/[slug]/opengraph-image.tsx",
|
|
routePrefix: "/blog/[slug]",
|
|
routeSegments: ["blog", "[slug]"],
|
|
servedUrl: "/blog/[slug]/opengraph-image",
|
|
contentType: "image/png",
|
|
contentHash: "hash",
|
|
module: {
|
|
alt: "Dynamic alt",
|
|
contentType: "image/jpeg",
|
|
size: { width: 640, height: 360 },
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(null, "/blog/post", { slug: "post" }, routes, {
|
|
routeSegments: ["blog", "[slug]"],
|
|
metadataSources: [{ routeSegments: ["blog", "[slug]"], metadata: null }],
|
|
});
|
|
|
|
expect(result?.openGraph?.images).toEqual([
|
|
{
|
|
alt: "Dynamic alt",
|
|
height: 360,
|
|
type: "image/jpeg",
|
|
url: "/blog/post/opengraph-image?hash",
|
|
width: 640,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("passes thenable params with sync properties to generateImageMetadata", async () => {
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "opengraph-image",
|
|
isDynamic: true,
|
|
filePath: "/tmp/app/blog/[slug]/opengraph-image.tsx",
|
|
routePrefix: "/blog/[slug]",
|
|
routeSegments: ["blog", "[slug]"],
|
|
servedUrl: "/blog/[slug]/opengraph-image",
|
|
contentType: "image/png",
|
|
contentHash: "hash",
|
|
module: {
|
|
generateImageMetadata: async (props: {
|
|
params: Promise<{ slug: string }> & { slug?: string };
|
|
}) => [{ id: props.params.slug }],
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(null, "/blog/post", { slug: "post" }, routes, {
|
|
routeSegments: ["blog", "[slug]"],
|
|
metadataSources: [{ routeSegments: ["blog", "[slug]"], metadata: null }],
|
|
});
|
|
|
|
expect(result?.openGraph?.images).toEqual([
|
|
{
|
|
type: "image/png",
|
|
url: "/blog/post/opengraph-image/post?hash",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("injects multiple generateImageMetadata entries", async () => {
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "opengraph-image",
|
|
isDynamic: true,
|
|
filePath: "/tmp/app/opengraph-image.tsx",
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/opengraph-image",
|
|
contentType: "image/png",
|
|
contentHash: "hash",
|
|
module: {
|
|
generateImageMetadata: async () => [
|
|
{
|
|
id: "small",
|
|
alt: "Small image",
|
|
contentType: "image/jpeg",
|
|
size: { width: 640, height: 360 },
|
|
},
|
|
{
|
|
id: "large",
|
|
alt: "Large image",
|
|
contentType: "image/png",
|
|
size: { width: 1200, height: 630 },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(null, "/", {}, routes, {
|
|
routeSegments: [],
|
|
metadataSources: [{ routeSegments: [], metadata: null }],
|
|
});
|
|
|
|
expect(result?.openGraph?.images).toEqual([
|
|
{
|
|
alt: "Small image",
|
|
height: 360,
|
|
type: "image/jpeg",
|
|
url: "/opengraph-image/small?hash",
|
|
width: 640,
|
|
},
|
|
{
|
|
alt: "Large image",
|
|
height: 630,
|
|
type: "image/png",
|
|
url: "/opengraph-image/large?hash",
|
|
width: 1200,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("drops dynamic metadata head URLs when params cannot fill servedUrl segments", async () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "icon",
|
|
isDynamic: true,
|
|
filePath: "/tmp/app/blog/[slug]/icon.tsx",
|
|
routePrefix: "/blog/[slug]",
|
|
routeSegments: ["blog", "[slug]"],
|
|
servedUrl: "/blog/[slug]/icon",
|
|
contentType: "image/png",
|
|
contentHash: "hash",
|
|
module: {
|
|
generateImageMetadata: async () => [{ id: "small" }],
|
|
},
|
|
},
|
|
];
|
|
|
|
try {
|
|
const result = await applyFileBasedMetadata(null, "/blog/[slug]", {}, routes, {
|
|
routeSegments: ["blog", "[slug]"],
|
|
metadataSources: [{ routeSegments: ["blog", "[slug]"], metadata: null }],
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"[vinext] Skipping metadata route /blog/[slug]/icon because params did not fill all dynamic segments.",
|
|
);
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("drops dynamic metadata head URLs when single segments receive multi-value params", async () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "icon",
|
|
isDynamic: true,
|
|
filePath: "/tmp/app/blog/[slug]/icon.tsx",
|
|
routePrefix: "/blog/[slug]",
|
|
routeSegments: ["blog", "[slug]"],
|
|
servedUrl: "/blog/[slug]/icon",
|
|
contentType: "image/png",
|
|
contentHash: "hash",
|
|
module: {
|
|
generateImageMetadata: async () => [{ id: "small" }],
|
|
},
|
|
},
|
|
];
|
|
|
|
try {
|
|
const result = await applyFileBasedMetadata(
|
|
null,
|
|
"/blog/[slug]",
|
|
{ slug: ["a", "b"] },
|
|
routes,
|
|
{
|
|
routeSegments: ["blog", "[slug]"],
|
|
metadataSources: [{ routeSegments: ["blog", "[slug]"], metadata: null }],
|
|
},
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"[vinext] Skipping metadata route /blog/[slug]/icon because params did not fill all dynamic segments.",
|
|
);
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("drops required catch-all metadata head URLs when params contain no segments", async () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "icon",
|
|
isDynamic: true,
|
|
filePath: "/tmp/app/docs/[...slug]/icon.tsx",
|
|
routePrefix: "/docs/[...slug]",
|
|
routeSegments: ["docs", "[...slug]"],
|
|
servedUrl: "/docs/[...slug]/icon",
|
|
contentType: "image/png",
|
|
contentHash: "hash",
|
|
module: {
|
|
generateImageMetadata: async () => [{ id: "small" }],
|
|
},
|
|
},
|
|
];
|
|
|
|
try {
|
|
const result = await applyFileBasedMetadata(null, "/docs/[...slug]", { slug: [] }, routes, {
|
|
routeSegments: ["docs", "[...slug]"],
|
|
metadataSources: [{ routeSegments: ["docs", "[...slug]"], metadata: null }],
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"[vinext] Skipping metadata route /docs/[...slug]/icon because params did not fill all dynamic segments.",
|
|
);
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("skips empty optional catch-all metadata params instead of emitting empty URL segments", async () => {
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "icon",
|
|
isDynamic: true,
|
|
filePath: "/tmp/app/docs/[[...slug]]/icon.tsx",
|
|
routePrefix: "/docs/[[...slug]]",
|
|
routeSegments: ["docs", "[[...slug]]"],
|
|
servedUrl: "/docs/[[...slug]]/icon",
|
|
contentType: "image/png",
|
|
contentHash: "hash",
|
|
module: {
|
|
generateImageMetadata: async () => [{ id: "small" }],
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(null, "/docs", { slug: "" }, routes, {
|
|
metadataSources: [{ routeSegments: ["docs"], metadata: null }],
|
|
});
|
|
|
|
expect(result?.icons).toEqual({
|
|
icon: [{ type: "image/png", url: "/docs/icon/small?hash" }],
|
|
});
|
|
});
|
|
|
|
// Regression for cloudflare/vinext#1493 — covers the full pipeline (file-based
|
|
// metadata + MetadataHead) for the Next.js metadata-dynamic-routes test
|
|
// `should support generate multi images with generateImageMetadata`. With
|
|
// `metadataBase` configured on a parent layout, hrefs for dynamic icon and
|
|
// apple-icon image routes produced by `generateImageMetadata` must stay
|
|
// relative (matching Next.js behavior).
|
|
// Ported from .nextjs-ref/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts
|
|
it("keeps generateImageMetadata icon hrefs relative when metadataBase is configured (#1493)", async () => {
|
|
const layoutMetadata: Metadata = {
|
|
metadataBase: new URL("https://mydomain.com"),
|
|
title: "Next.js App",
|
|
};
|
|
const mergedMetadata: Metadata = {
|
|
metadataBase: layoutMetadata.metadataBase,
|
|
title: "index page",
|
|
};
|
|
const routes: MetadataFileRoute[] = [
|
|
{
|
|
type: "icon",
|
|
isDynamic: true,
|
|
filePath: "/tmp/app/(group)/dynamic/[size]/icon.tsx",
|
|
routePrefix: "/dynamic/[size]",
|
|
routeSegments: ["(group)", "dynamic", "[size]"],
|
|
servedUrl: "/dynamic/[size]/icon-ahg52g",
|
|
contentType: "image/png",
|
|
module: {
|
|
generateImageMetadata: async () => [
|
|
{ id: "small", contentType: "image/png", size: { width: 48, height: 48 } },
|
|
{ id: "medium", contentType: "image/png", size: { width: 72, height: 72 } },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
type: "apple-icon",
|
|
isDynamic: true,
|
|
filePath: "/tmp/app/(group)/dynamic/[size]/apple-icon.tsx",
|
|
routePrefix: "/dynamic/[size]",
|
|
routeSegments: ["(group)", "dynamic", "[size]"],
|
|
servedUrl: "/dynamic/[size]/apple-icon-ahg52g",
|
|
contentType: "image/png",
|
|
module: {
|
|
generateImageMetadata: async () => [
|
|
{ id: 0, contentType: "image/png", size: { width: 48, height: 48 } },
|
|
{ id: 1, contentType: "image/png", size: { width: 64, height: 64 } },
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = await applyFileBasedMetadata(
|
|
mergedMetadata,
|
|
"/dynamic/big",
|
|
{ size: "big" },
|
|
routes,
|
|
{
|
|
routeSegments: ["(group)", "dynamic", "[size]"],
|
|
metadataSources: [
|
|
{ routeSegments: [], metadata: layoutMetadata },
|
|
{ routeSegments: ["(group)", "dynamic", "[size]"], metadata: { title: "index page" } },
|
|
],
|
|
},
|
|
);
|
|
|
|
expect(result?.icons).toEqual({
|
|
icon: [
|
|
{ url: "/dynamic/big/icon-ahg52g/small", sizes: "48x48", type: "image/png" },
|
|
{ url: "/dynamic/big/icon-ahg52g/medium", sizes: "72x72", type: "image/png" },
|
|
],
|
|
apple: [
|
|
{ url: "/dynamic/big/apple-icon-ahg52g/0", sizes: "48x48", type: "image/png" },
|
|
{ url: "/dynamic/big/apple-icon-ahg52g/1", sizes: "64x64", type: "image/png" },
|
|
],
|
|
});
|
|
|
|
expect(result).not.toBeNull();
|
|
const html = renderToStaticMarkup(
|
|
createElement(MetadataHead, { metadata: result as Metadata }),
|
|
);
|
|
expect(html).toContain('href="/dynamic/big/icon-ahg52g/small"');
|
|
expect(html).toContain('href="/dynamic/big/icon-ahg52g/medium"');
|
|
expect(html).toContain('href="/dynamic/big/apple-icon-ahg52g/0"');
|
|
expect(html).toContain('href="/dynamic/big/apple-icon-ahg52g/1"');
|
|
expect(html).not.toContain("https://mydomain.com/dynamic/big");
|
|
});
|
|
});
|
|
|
|
// Regression for cloudflare/vinext#1968 — twitter:app:* tags must only be
|
|
// emitted when the resolved card type is exactly "app", and individual id/url
|
|
// tags must use truthy (not !== undefined) checks so falsy ids/urls (0, "")
|
|
// are skipped.
|
|
// Ported from Next.js: packages/next/src/lib/metadata/metadata.tsx
|
|
describe("twitter app card gating (#1968)", () => {
|
|
it("does not emit twitter:app:* tags when card type is not 'app'", () => {
|
|
const metadata: Metadata = {
|
|
twitter: {
|
|
// No `card` set -> not an app card, so no app:* tags.
|
|
app: {
|
|
name: "My App",
|
|
id: { iphone: "id123", ipad: "id123", googleplay: "com.example.app" },
|
|
url: {
|
|
iphone: "https://example.com/iphone",
|
|
ipad: "https://example.com/ipad",
|
|
googleplay: "https://example.com/android",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const html = renderToStaticMarkup(createElement(MetadataHead, { metadata }));
|
|
expect(html).not.toContain("twitter:app:name");
|
|
expect(html).not.toContain("twitter:app:id");
|
|
expect(html).not.toContain("twitter:app:url");
|
|
});
|
|
|
|
it("skips falsy app ids but emits truthy ones when card is 'app'", () => {
|
|
const metadata: Metadata = {
|
|
twitter: {
|
|
card: "app",
|
|
app: {
|
|
name: "My App",
|
|
id: { iphone: 0, ipad: "", googleplay: "999" },
|
|
},
|
|
},
|
|
};
|
|
const html = renderToStaticMarkup(createElement(MetadataHead, { metadata }));
|
|
expect(html).toContain('name="twitter:card" content="app"');
|
|
expect(html).not.toContain("twitter:app:id:iphone");
|
|
expect(html).not.toContain("twitter:app:id:ipad");
|
|
expect(html).toContain('name="twitter:app:id:googleplay" content="999"');
|
|
});
|
|
|
|
it("does not emit twitter:player:* tags when card type is not 'player'", () => {
|
|
const metadata: Metadata = {
|
|
twitter: {
|
|
// No `card` set -> not a player card, so no player:* tags.
|
|
players: {
|
|
playerUrl: "https://example.com/player",
|
|
streamUrl: "https://example.com/stream",
|
|
width: 640,
|
|
height: 480,
|
|
},
|
|
},
|
|
};
|
|
const html = renderToStaticMarkup(createElement(MetadataHead, { metadata }));
|
|
expect(html).not.toContain("twitter:player");
|
|
});
|
|
|
|
it("emits twitter:player:* tags when card is 'player'", () => {
|
|
const metadata: Metadata = {
|
|
twitter: {
|
|
card: "player",
|
|
players: {
|
|
playerUrl: "https://example.com/player",
|
|
streamUrl: "https://example.com/stream",
|
|
width: 640,
|
|
height: 480,
|
|
},
|
|
},
|
|
};
|
|
const html = renderToStaticMarkup(createElement(MetadataHead, { metadata }));
|
|
expect(html).toContain('name="twitter:card" content="player"');
|
|
expect(html).toContain("twitter:player:stream");
|
|
});
|
|
});
|