Resolve page meta links for metadata (#3793)

This commit is contained in:
Taran Vohra
2025-11-12 16:12:27 +05:30
committed by GitHub
parent 4e04db8a1b
commit c8bba7bfe1
5 changed files with 5497 additions and 26907 deletions
+5346 -26901
View File
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,10 @@
import type { GitBookSiteContext } from '@/lib/context';
import { getPageDocument } from '@/lib/data';
import { getDataOrNull, getPageDocument } from '@/lib/data';
import {
CustomizationHeaderPreset,
CustomizationThemeMode,
SiteInsightsDisplayContext,
type TranslationLanguage,
} from '@gitbook/api';
import type { Metadata, Viewport } from 'next';
import { notFound, redirect } from 'next/navigation';
@@ -14,6 +15,7 @@ import { getPagePath } from '@/lib/pages';
import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
import { getResizedImageURL } from '@/lib/images';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { PageContextProvider } from '../PageContext';
import { PageClientLayout } from './PageClientLayout';
@@ -117,7 +119,7 @@ function getSiteStructureTitle(context: GitBookSiteContext): string | null {
}
export async function generateSitePageMetadata(props: SitePageProps): Promise<Metadata> {
const { context, pageTarget } = await getPageDataWithFallback({
const { context, pageTarget, pageMetaLinks } = await getPageDataWithFallback({
context: props.context,
pagePathParams: props.pageParams,
});
@@ -133,6 +135,25 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
const { site, customization, revision, linker, imageResizer } = context;
const siteStructureTitle = getSiteStructureTitle(context);
const canonical = (
pageMetaLinks?.canonical
? new URL(pageMetaLinks.canonical).toString()
: // If no canonical is set, use the current page URL (default case)
linker.toAbsoluteURL(linker.toPathForPage({ pages: revision.pages, page }))
).replace(/\/+$/, ''); // Trim trailing slashes in canonical URL to match the redirect behavior
const languages = pageMetaLinks?.alternates.reduce(
(acc, alt) => {
// TODO: We can only add language alternates for now as that's all Next.js API supports
// generic alternates are not supported yet
if (alt.language) {
acc[alt.language] = alt.href;
}
return acc;
},
{} as Record<TranslationLanguage, string>
);
return {
title: [
page.title,
@@ -144,10 +165,8 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
.join(' | '),
description: page.description ?? '',
alternates: {
// Trim trailing slashes in canonical URL to match the redirect behavior
canonical: linker
.toAbsoluteURL(linker.toPathForPage({ pages: revision.pages, page }))
.replace(/\/+$/, ''),
canonical,
languages,
types: {
'text/markdown': `${linker.toAbsoluteURL(linker.toPathInSpace(page.path))}.md`,
},
@@ -237,6 +256,9 @@ async function getPageDataWithFallback(args: {
}) {
const { context: baseContext, pagePathParams } = args;
const { context, pageTarget } = await fetchPageData(baseContext, pagePathParams);
const pageMetaLinks = await (pageTarget?.page && shouldResolveMetaLinks(context.site.id)
? resolvePageMetaLinks(context, pageTarget.page.id)
: null);
return {
context: {
@@ -244,5 +266,74 @@ async function getPageDataWithFallback(args: {
page: pageTarget?.page,
},
pageTarget,
pageMetaLinks,
};
}
/**
* Resolve the meta links (canonical and alternates) for a page.
*/
async function resolvePageMetaLinks(
context: GitBookSiteContext,
pageId: string
): Promise<{
canonical: string | null;
alternates: Array<{
href: string;
language: TranslationLanguage | null;
}>;
}> {
const pageMetaLinks = await getDataOrNull(
context.dataFetcher.listRevisionPageMetaLinks({
spaceId: context.space.id,
revisionId: context.revisionId,
pageId,
})
);
if (pageMetaLinks) {
const canonicalResolution = pageMetaLinks.canonical
? resolveContentRef(pageMetaLinks.canonical, context).then((resolved) => resolved?.href)
: null;
const alternatesResolutions = (pageMetaLinks.alternates || []).map((link) =>
resolveContentRef(link, context).then((resolved) => ({
href: resolved?.href ?? null,
language: resolved?.space?.language ?? null,
}))
);
const [resolvedCanonical, resolvedAlternates] = await Promise.all([
canonicalResolution,
Promise.all(alternatesResolutions),
]);
return {
canonical: resolvedCanonical ?? null,
alternates: resolvedAlternates.filter(
(alt): alt is { href: string; language: TranslationLanguage | null } => !!alt.href
),
};
}
return {
canonical: null,
alternates: [],
};
}
/**
* Determine whether to resolve meta links for a site based on a percentage rollout.
*/
export function shouldResolveMetaLinks(siteId: string): boolean {
const META_LINKS_PERCENTAGE_ROLLOUT = 10;
// compute a simple hash of the siteId
let hash = 0;
for (let i = 0; i < siteId.length; i++) {
hash = (hash << 5) - hash + siteId.charCodeAt(i);
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash % 100) < META_LINKS_PERCENTAGE_ROLLOUT;
}
+39
View File
@@ -151,6 +151,14 @@ export function createDataFetcher(
getUserById(userId) {
return getUserById(input, { userId });
},
listRevisionPageMetaLinks(params) {
return listRevisionPageMetaLinks(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
pageId: params.pageId,
});
},
};
}
@@ -705,6 +713,37 @@ const renderIntegrationUi = cache(
}
);
/**
* List all the meta links for a given page in a revision.
*/
const listRevisionPageMetaLinks = cache(
async (
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; pageId: string }
) => {
'use cache';
return wrapCacheDataFetcherError(async () => {
return trace(
`listRevisionPageMetaLinks(${params.spaceId}, ${params.revisionId}, ${params.pageId})`,
async () => {
const api = apiClient(input);
const res = await api.spaces.listRevisionPageMetaLinks(
params.spaceId,
params.revisionId,
params.pageId,
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
}
);
});
}
);
/**
* Create a new API client.
*/
+9
View File
@@ -173,4 +173,13 @@ export interface GitBookDataFetcher {
integrationName: string;
request: api.RenderIntegrationUI;
}): Promise<DataFetcherResponse<api.ContentKitRenderOutput>>;
/**
* List revision page meta links.
*/
listRevisionPageMetaLinks(params: {
spaceId: string;
revisionId: string;
pageId: string;
}): Promise<DataFetcherResponse<api.RevisionPageMetaLinks>>;
}
+6
View File
@@ -63,6 +63,10 @@ export interface ResolvedContentRef {
};
/** Resolve OpenAPI spec filesystem. */
openAPIFilesystem?: Filesystem;
/**
* Space that the content ref belongs to (if applicable).
*/
space?: Space;
}
export interface ResolveContentRefOptions {
@@ -108,6 +112,7 @@ export async function resolveContentRef(
text: file.name,
active: false,
file,
space,
};
}
return null;
@@ -191,6 +196,7 @@ export async function resolveContentRef(
icon,
page,
active: !anchor && page.id === activePage?.id,
space,
};
}