sitegen/framework/meta/nextjs/resolve-metadata.ts
chloe caruso af60d1172f i accidentally deleted the repo, but recovered it. i'll start committing
it was weird. i pressed delete on a subfolder, i think one of the
pages.off folders that i was using. and then, suddenly, nvim on windows
7 decided to delete every file in the directory. they weren't shred off
the space time continuum, but just marked deleted. i had to pay $80 to
get access to a software that could see them. bleh!

just seeing all my work, a little over a week, was pretty heart
shattering. but i remembered that long ago, a close friend said i could
call them whenever i was feeling sad. i finally took them up on that
offer. the first time i've ever called someone for emotional support.
but it's ok. i got it back. and the site framework is better than ever.

i'm gonna commit and push more often. the repo is private anyways.
2025-06-06 23:38:02 -07:00

453 lines
13 KiB
TypeScript

import type {
Metadata,
ResolvedMetadata,
ResolvingMetadata,
} from "./types/metadata-interface";
import type { MetadataImageModule } from "../../build/webpack/loaders/metadata/types";
import type { GetDynamicParamFromSegment } from "../../server/app-render/app-render";
import { createDefaultMetadata } from "./default-metadata";
import {
resolveOpenGraph,
resolveTwitter,
} from "./resolvers/resolve-opengraph";
import { resolveTitle } from "./resolvers/resolve-title";
import { resolveAsArrayOrUndefined } from "./generate/utils";
import { isClientReference } from "../client-reference";
import {
getLayoutOrPageModule,
LoaderTree,
} from "../../server/lib/app-dir-module";
import { ComponentsType } from "../../build/webpack/loaders/next-app-loader";
import { interopDefault } from "../interop-default";
import {
resolveAlternates,
resolveAppleWebApp,
resolveAppLinks,
resolveRobots,
resolveThemeColor,
resolveVerification,
resolveViewport,
} from "./resolvers/resolve-basics";
import { resolveIcons } from "./resolvers/resolve-icons";
import { getTracer } from "../../server/lib/trace/tracer";
import { ResolveMetadataSpan } from "../../server/lib/trace/constants";
import { Twitter } from "./types/twitter-types";
import { OpenGraph } from "./types/opengraph-types";
import { PAGE_SEGMENT_KEY } from "../../shared/lib/constants";
import process from "node:process";
type StaticMetadata = Awaited<ReturnType<typeof resolveStaticMetadata>>;
type MetadataResolver = (
_parent: ResolvingMetadata,
) => Metadata | Promise<Metadata>;
export type MetadataItems = [
Metadata | MetadataResolver | null,
StaticMetadata,
][];
function mergeStaticMetadata(
metadata: ResolvedMetadata,
staticFilesMetadata: StaticMetadata,
) {
if (!staticFilesMetadata) return;
const { icon, apple, openGraph, twitter, manifest } = staticFilesMetadata;
if (icon || apple) {
metadata.icons = {
icon: icon || [],
apple: apple || [],
};
}
if (twitter) {
const resolvedTwitter = resolveTwitter(
{ ...metadata.twitter, images: twitter } as Twitter,
metadata.metadataBase,
);
metadata.twitter = resolvedTwitter;
}
if (openGraph) {
const resolvedOpenGraph = resolveOpenGraph(
{ ...metadata.openGraph, images: openGraph } as OpenGraph,
metadata.metadataBase,
);
metadata.openGraph = resolvedOpenGraph;
}
if (manifest) {
metadata.manifest = manifest;
}
return metadata;
}
// Merge the source metadata into the resolved target metadata.
function merge({
target,
source,
staticFilesMetadata,
titleTemplates,
options,
}: {
target: ResolvedMetadata;
source: Metadata | null;
staticFilesMetadata: StaticMetadata;
titleTemplates: {
title: string | null;
twitter: string | null;
openGraph: string | null;
};
options: MetadataAccumulationOptions;
}) {
// If there's override metadata, prefer it otherwise fallback to the default metadata.
const metadataBase = typeof source?.metadataBase !== "undefined"
? source.metadataBase
: target.metadataBase;
for (const key_ in source) {
const key = key_ as keyof Metadata;
switch (key) {
case "title": {
target.title = resolveTitle(source.title, titleTemplates.title);
break;
}
case "alternates": {
target.alternates = resolveAlternates(source.alternates, metadataBase, {
pathname: options.pathname,
});
break;
}
case "openGraph": {
target.openGraph = resolveOpenGraph(source.openGraph, metadataBase);
if (target.openGraph) {
target.openGraph.title = resolveTitle(
target.openGraph.title,
titleTemplates.openGraph,
);
}
break;
}
case "twitter": {
target.twitter = resolveTwitter(source.twitter, metadataBase);
if (target.twitter) {
target.twitter.title = resolveTitle(
target.twitter.title,
titleTemplates.twitter,
);
}
break;
}
case "verification":
target.verification = resolveVerification(source.verification);
break;
case "viewport": {
target.viewport = resolveViewport(source.viewport);
break;
}
case "icons": {
target.icons = resolveIcons(source.icons);
break;
}
case "appleWebApp":
target.appleWebApp = resolveAppleWebApp(source.appleWebApp);
break;
case "appLinks":
target.appLinks = resolveAppLinks(source.appLinks);
break;
case "robots": {
target.robots = resolveRobots(source.robots);
break;
}
case "themeColor": {
target.themeColor = resolveThemeColor(source.themeColor);
break;
}
case "archives":
case "assets":
case "bookmarks":
case "keywords":
case "authors": {
// FIXME: type inferring
// @ts-ignore
target[key] = resolveAsArrayOrUndefined(source[key]) || null;
break;
}
// directly assign fields that fallback to null
case "applicationName":
case "description":
case "generator":
case "creator":
case "publisher":
case "category":
case "classification":
case "referrer":
case "colorScheme":
case "itunes":
case "formatDetection":
case "manifest":
// @ts-ignore TODO: support inferring
target[key] = source[key] || null;
break;
case "other":
target.other = Object.assign({}, target.other, source.other);
break;
case "metadataBase":
target.metadataBase = metadataBase;
break;
default:
break;
}
}
mergeStaticMetadata(target, staticFilesMetadata);
}
async function getDefinedMetadata(
mod: any,
props: any,
route: string,
): Promise<Metadata | MetadataResolver | null> {
// Layer is a client component, we just skip it. It can't have metadata exported.
// Return early to avoid accessing properties error for client references.
if (isClientReference(mod)) {
return null;
}
return (
(mod.generateMetadata
? (parent: ResolvingMetadata) =>
getTracer().trace(
ResolveMetadataSpan.generateMetadata,
{
spanName: `generateMetadata ${route}`,
attributes: {
"next.page": route,
},
},
() => mod.generateMetadata(props, parent),
)
: mod.metadata) || null
);
}
async function collectStaticImagesFiles(
metadata: ComponentsType["metadata"],
props: any,
type: keyof NonNullable<ComponentsType["metadata"]>,
) {
if (!metadata?.[type]) return undefined;
const iconPromises = metadata[type as "icon" | "apple"].map(
async (imageModule: (p: any) => Promise<MetadataImageModule[]>) =>
interopDefault(await imageModule(props)),
);
return iconPromises?.length > 0
? (await Promise.all(iconPromises))?.flat()
: undefined;
}
async function resolveStaticMetadata(components: ComponentsType, props: any) {
const { metadata } = components;
if (!metadata) return null;
const [icon, apple, openGraph, twitter] = await Promise.all([
collectStaticImagesFiles(metadata, props, "icon"),
collectStaticImagesFiles(metadata, props, "apple"),
collectStaticImagesFiles(metadata, props, "openGraph"),
collectStaticImagesFiles(metadata, props, "twitter"),
]);
const staticMetadata = {
icon,
apple,
openGraph,
twitter,
manifest: metadata.manifest,
};
return staticMetadata;
}
// [layout.metadata, static files metadata] -> ... -> [page.metadata, static files metadata]
export async function collectMetadata({
tree,
metadataItems: array,
props,
route,
}: {
tree: LoaderTree;
metadataItems: MetadataItems;
props: any;
route: string;
}) {
const [mod, modType] = await getLayoutOrPageModule(tree);
if (modType) {
route += `/${modType}`;
}
const staticFilesMetadata = await resolveStaticMetadata(tree[2], props);
const metadataExport = mod
? await getDefinedMetadata(mod, props, route)
: null;
array.push([metadataExport, staticFilesMetadata]);
}
export async function resolveMetadata({
tree,
parentParams,
metadataItems,
treePrefix = [],
getDynamicParamFromSegment,
searchParams,
}: {
tree: LoaderTree;
parentParams: { [key: string]: any };
metadataItems: MetadataItems;
/** Provided tree can be nested subtree, this argument says what is the path of such subtree */
treePrefix?: string[];
getDynamicParamFromSegment: GetDynamicParamFromSegment;
searchParams: { [key: string]: any };
}): Promise<MetadataItems> {
const [segment, parallelRoutes, { page }] = tree;
const currentTreePrefix = [...treePrefix, segment];
const isPage = typeof page !== "undefined";
// Handle dynamic segment params.
const segmentParam = getDynamicParamFromSegment(segment);
/**
* Create object holding the parent params and current params
*/
const currentParams =
// Handle null case where dynamic param is optional
segmentParam && segmentParam.value !== null
? {
...parentParams,
[segmentParam.param]: segmentParam.value,
}
// Pass through parent params to children
: parentParams;
const layerProps = {
params: currentParams,
...(isPage && { searchParams }),
};
await collectMetadata({
tree,
metadataItems,
props: layerProps,
route: currentTreePrefix
// __PAGE__ shouldn't be shown in a route
.filter((s) => s !== PAGE_SEGMENT_KEY)
.join("/"),
});
for (const key in parallelRoutes) {
const childTree = parallelRoutes[key];
await resolveMetadata({
tree: childTree,
metadataItems,
parentParams: currentParams,
treePrefix: currentTreePrefix,
searchParams,
getDynamicParamFromSegment,
});
}
return metadataItems;
}
type MetadataAccumulationOptions = {
pathname: string;
};
export async function accumulateMetadata(
metadataItems: MetadataItems,
options: MetadataAccumulationOptions,
): Promise<ResolvedMetadata> {
const resolvedMetadata = createDefaultMetadata();
const resolvers: ((value: ResolvedMetadata) => void)[] = [];
const generateMetadataResults: (Metadata | Promise<Metadata>)[] = [];
let titleTemplates: {
title: string | null;
twitter: string | null;
openGraph: string | null;
} = {
title: null,
twitter: null,
openGraph: null,
};
// Loop over all metadata items again, merging synchronously any static object exports,
// awaiting any static promise exports, and resolving parent metadata and awaiting any generated metadata
let resolvingIndex = 0;
for (let i = 0; i < metadataItems.length; i++) {
const [metadataExport, staticFilesMetadata] = metadataItems[i];
let metadata: Metadata | null = null;
if (typeof metadataExport === "function") {
if (!resolvers.length) {
for (let j = i; j < metadataItems.length; j++) {
const [preloadMetadataExport] = metadataItems[j];
// call each `generateMetadata function concurrently and stash their resolver
if (typeof preloadMetadataExport === "function") {
generateMetadataResults.push(
preloadMetadataExport(
new Promise((resolve) => {
resolvers.push(resolve);
}),
),
);
}
}
}
const resolveParent = resolvers[resolvingIndex];
const generatedMetadata = generateMetadataResults[resolvingIndex++];
// In dev we clone and freeze to prevent relying on mutating resolvedMetadata directly.
// In prod we just pass resolvedMetadata through without any copying.
const currentResolvedMetadata: ResolvedMetadata =
process.env.NODE_ENV === "development"
? Object.freeze(
require(
"next/dist/compiled/@edge-runtime/primitives/structured-clone",
).structuredClone(
resolvedMetadata,
),
)
: resolvedMetadata;
// This resolve should unblock the generateMetadata function if it awaited the parent
// argument. If it didn't await the parent argument it might already have a value since it was
// called concurrently. Regardless we await the return value before continuing on to the next layer
resolveParent(currentResolvedMetadata);
metadata = generatedMetadata instanceof Promise
? await generatedMetadata
: generatedMetadata;
} else if (metadataExport !== null && typeof metadataExport === "object") {
// This metadataExport is the object form
metadata = metadataExport;
}
merge({
options,
target: resolvedMetadata,
source: metadata,
staticFilesMetadata,
titleTemplates,
});
// If the layout is the same layer with page, skip the leaf layout and leaf page
// The leaf layout and page are the last two items
if (i < metadataItems.length - 2) {
titleTemplates = {
title: resolvedMetadata.title?.template || null,
openGraph: resolvedMetadata.openGraph?.title?.template || null,
twitter: resolvedMetadata.twitter?.title?.template || null,
};
}
}
return resolvedMetadata;
}