Skip to content

Commit 123325b

Browse files
committed
Merge branch 'main' into taran/generic-alternates
2 parents 287fb28 + 27d6c3a commit 123325b

File tree

7 files changed

+151
-30
lines changed

7 files changed

+151
-30
lines changed

packages/gitbook/e2e/internal.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,37 @@ const testCases: TestsCase[] = [
338338
).toBeVisible();
339339
},
340340
},
341+
{
342+
name: 'Switch variant with alternate link in metadata',
343+
url: 'rfcs',
344+
run: async (page) => {
345+
const spaceDropdown = page
346+
.locator('[data-testid="space-dropdown-button"]')
347+
.locator('visible=true');
348+
await spaceDropdown.click();
349+
350+
const variantSelectionDropdown = page.locator(
351+
'css=[data-testid="dropdown-menu"]'
352+
);
353+
354+
// Click the variant space called 'Multi-Variants' for which
355+
// there is an alternate link in the current (RFC variant) page metadata
356+
await variantSelectionDropdown
357+
.getByRole('menuitem', {
358+
name: 'Multi-Variants',
359+
})
360+
.click();
361+
362+
// It should navigate to the alternate link defined in the metadata (a completely different page)
363+
await page.waitForURL((url) =>
364+
url.pathname.includes('multi-variants/reference/api-reference/pets')
365+
);
366+
// Verify we are on the correct page by checking the h1
367+
await expect(
368+
page.getByRole('heading', { level: 1, name: 'Pets' })
369+
).toBeVisible();
370+
},
371+
},
341372
],
342373
},
343374
{

packages/gitbook/src/components/Header/SpacesDropdown.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,12 @@ export function SpacesDropdown(props: {
4545
}
4646
>
4747
<SpacesDropdownMenuItems
48-
slimSpaces={siteSpaces.map((space) => ({
49-
id: space.id,
50-
title: space.title,
51-
url: getSiteSpaceURL(context, space),
52-
isActive: space.id === siteSpace.id,
48+
slimSpaces={siteSpaces.map((siteSp) => ({
49+
id: siteSp.id,
50+
title: siteSp.title,
51+
url: getSiteSpaceURL(context, siteSp),
52+
isActive: siteSp.id === siteSpace.id,
53+
spaceId: siteSp.space.id,
5354
}))}
5455
curPath={siteSpace.path}
5556
/>

packages/gitbook/src/components/Header/SpacesDropdownMenuItem.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,39 @@
11
'use client';
22

3-
import type { Space } from '@gitbook/api';
4-
53
import { joinPath } from '@/lib/paths';
6-
import { useCurrentPagePath } from '../hooks';
4+
import { useCurrentPageMetadata, useCurrentPagePath } from '../hooks';
75
import { DropdownMenuItem } from '../primitives/DropdownMenu';
86

97
interface VariantSpace {
10-
id: Space['id'];
11-
title: Space['title'];
8+
id: string;
9+
title: string;
1210
url: string;
1311
isActive: boolean;
12+
spaceId: string;
1413
}
1514

16-
// When switching to a different variant space, we reconstruct the URL by swapping the space path.
17-
function useVariantSpaceHref(variantSpaceUrl: string, currentSpacePath: string, active = false) {
15+
/**
16+
* Return the href for a variant space, taking into account the current page path and metadata.
17+
*/
18+
function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: string, active = false) {
1819
const currentPathname = useCurrentPagePath();
20+
const { metaLinks } = useCurrentPageMetadata();
21+
22+
// We first check if there is an alternate link for the variant space in the current page metadata.
23+
const pageHasAlternateForVariant = metaLinks?.alternates.find(
24+
(alt) => alt.space?.id === variantSpace.spaceId
25+
);
26+
if (pageHasAlternateForVariant) {
27+
return pageHasAlternateForVariant.href;
28+
}
29+
30+
// If there is no alternate link, we reconstruct the URL by swapping the space path.
1931

2032
// We need to ensure that the variant space URL is not the same as the current space path.
2133
// If it is, we return only the variant space URL to redirect to the root of the variant space.
2234
// This is necessary in case the currentPathname is the same as the variantSpaceUrl,
2335
// otherwise we would redirect to the same space if the variant space that we are switching to is the default one.
36+
const variantSpaceUrl = variantSpace.url;
2437
if (!active && currentPathname.startsWith(`${currentSpacePath}/`)) {
2538
return variantSpaceUrl;
2639
}
@@ -44,7 +57,7 @@ export function SpacesDropdownMenuItem(props: {
4457
currentSpacePath: string;
4558
}) {
4659
const { variantSpace, active, currentSpacePath } = props;
47-
const variantHref = useVariantSpaceHref(variantSpace.url, currentSpacePath, active);
60+
const variantHref = useVariantSpaceHref(variantSpace, currentSpacePath, active);
4861

4962
return (
5063
<DropdownMenuItem key={variantSpace.id} href={variantHref} active={active}>

packages/gitbook/src/components/SitePage/PageClientLayout.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,26 @@
33
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
44
import React from 'react';
55

6-
import { useScrollPage } from '@/components/hooks';
6+
import { currentPageMetadataStore, useScrollPage } from '@/components/hooks';
7+
import type { PageMetaLinks } from './SitePage';
78

89
/**
910
* Client component to initialize interactivity for a page.
1011
*/
11-
export function PageClientLayout() {
12+
export function PageClientLayout({
13+
pageMetaLinks,
14+
}: {
15+
pageMetaLinks: PageMetaLinks | null;
16+
}) {
1217
// We use this hook in the page layout to ensure the elements for the blocks
1318
// are rendered before we scroll to a hash or to the top of the page
1419
useScrollPage();
1520

21+
// The page metadata such as meta links are generated on the server side,
22+
// but need to be registered on the client side in other parts of the layout
23+
// such as the SpaceDropdown.
24+
useRegisterPageMetadata({ pageMetaLinks });
25+
1626
useStripFallbackQueryParam();
1727
return null;
1828
}
@@ -38,3 +48,15 @@ function useStripFallbackQueryParam() {
3848
}
3949
}, [router, pathname, searchParams]);
4050
}
51+
52+
/**
53+
* Register the generated page metadata such as meta links for the current page.
54+
*/
55+
function useRegisterPageMetadata(metadata: {
56+
pageMetaLinks: PageMetaLinks | null;
57+
}) {
58+
const { pageMetaLinks } = metadata;
59+
React.useEffect(() => {
60+
currentPageMetadataStore.setState({ metaLinks: pageMetaLinks });
61+
}, [pageMetaLinks]);
62+
}

packages/gitbook/src/components/SitePage/SitePage.tsx

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,28 @@ export type SitePageProps = {
2626
pageParams: PagePathParams;
2727
};
2828

29+
type AlternateLinkSpace = {
30+
id: string;
31+
language: TranslationLanguage | undefined;
32+
};
33+
34+
export type PageMetaLinks = {
35+
/**
36+
* The canonical URL for the page, if any.
37+
*/
38+
canonical: string | null;
39+
/**
40+
* The alternate URLs for the page, if any.
41+
*/
42+
alternates: Array<{
43+
href: string;
44+
/**
45+
* Space the alternate link points to, if any.
46+
*/
47+
space: AlternateLinkSpace | null;
48+
}>;
49+
};
50+
2951
/**
3052
* Fetch and render a page.
3153
*/
@@ -39,6 +61,7 @@ export async function SitePage(props: SitePageProps) {
3961
withPageFeedback,
4062
withSections,
4163
withTopHeader,
64+
pageMetaLinks,
4265
} = await getSitePageData(props);
4366
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
4467

@@ -76,7 +99,7 @@ export async function SitePage(props: SitePageProps) {
7699
insightsDisplayContext={SiteInsightsDisplayContext.Site}
77100
/>
78101
</div>
79-
<PageClientLayout />
102+
<PageClientLayout pageMetaLinks={pageMetaLinks} />
80103
</div>
81104
</PageContextProvider>
82105
);
@@ -155,8 +178,8 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
155178
}>;
156179
}>(
157180
(acc, alt) => {
158-
if (alt.language) {
159-
acc.languages[alt.language] = URL.canParse(alt.href)
181+
if (alt.space?.language) {
182+
acc.languages[alt.space.language] = URL.canParse(alt.href)
160183
? alt.href
161184
: linker.toAbsoluteURL(alt.href);
162185
} else {
@@ -211,7 +234,7 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
211234
* Fetches all the data required to render the site page.
212235
*/
213236
export async function getSitePageData(props: SitePageProps) {
214-
const { context, pageTarget } = await getPageDataWithFallback({
237+
const { context, pageTarget, pageMetaLinks } = await getPageDataWithFallback({
215238
context: props.context,
216239
pagePathParams: props.pageParams,
217240
});
@@ -263,6 +286,7 @@ export async function getSitePageData(props: SitePageProps) {
263286
withPageFeedback,
264287
withFullPageCover,
265288
withTopHeader,
289+
pageMetaLinks,
266290
};
267291
}
268292

@@ -295,13 +319,7 @@ async function getPageDataWithFallback(args: {
295319
async function resolvePageMetaLinks(
296320
context: GitBookSiteContext,
297321
pageId: string
298-
): Promise<{
299-
canonical: string | null;
300-
alternates: Array<{
301-
href: string;
302-
language: TranslationLanguage | null;
303-
}>;
304-
}> {
322+
): Promise<PageMetaLinks> {
305323
const pageMetaLinks = await getDataOrNull(
306324
context.dataFetcher.listRevisionPageMetaLinks({
307325
spaceId: context.space.id,
@@ -318,7 +336,9 @@ async function resolvePageMetaLinks(
318336
const alternatesResolutions = (pageMetaLinks.alternates || []).map((link) =>
319337
resolveContentRef(link, context).then((resolved) => ({
320338
href: resolved?.href ?? null,
321-
language: resolved?.space?.language ?? null,
339+
space: resolved?.space
340+
? { id: resolved.space.id, language: resolved.space.language }
341+
: null,
322342
}))
323343
);
324344

@@ -330,7 +350,7 @@ async function resolvePageMetaLinks(
330350
return {
331351
canonical: resolvedCanonical ?? null,
332352
alternates: resolvedAlternates.filter(
333-
(alt): alt is { href: string; language: TranslationLanguage | null } => !!alt.href
353+
(alt): alt is { href: string; space: AlternateLinkSpace | null } => !!alt.href
334354
),
335355
};
336356
}
@@ -344,8 +364,15 @@ async function resolvePageMetaLinks(
344364
/**
345365
* Determine whether to resolve meta links for a site based on a percentage rollout.
346366
*/
347-
export function shouldResolveMetaLinks(siteId: string): boolean {
348-
const META_LINKS_PERCENTAGE_ROLLOUT = 10;
367+
function shouldResolveMetaLinks(siteId: string): boolean {
368+
const META_LINKS_PERCENTAGE_ROLLOUT = 25;
369+
const ALLOWED_SITES: Record<string, boolean> = {
370+
site_CZrtk: true,
371+
};
372+
373+
if (ALLOWED_SITES[siteId] || process.env.NODE_ENV === 'development') {
374+
return true;
375+
}
349376

350377
// compute a simple hash of the siteId
351378
let hash = 0;

packages/gitbook/src/components/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export * from './useCurrentContent';
88
export * from './useCurrentPage';
99
export * from './useNow';
1010
export * from './useListOverflow';
11+
export * from './useCurrentPageMetadata';
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use client';
2+
import type { PageMetaLinks } from '../SitePage';
3+
4+
import * as zustand from 'zustand';
5+
6+
/**
7+
* A store for the current page metadata.
8+
*
9+
* We use a global store because the metadata is generated and set by the Page component
10+
* but needs to be accessed by other components (ex - Layout) that are not its descendants.
11+
*/
12+
export const currentPageMetadataStore = zustand.create<{
13+
metaLinks: PageMetaLinks | null;
14+
}>(() => ({
15+
metaLinks: null,
16+
}));
17+
18+
/**
19+
* Return the metadata for the current page.
20+
*/
21+
export function useCurrentPageMetadata() {
22+
const metaLinks = zustand.useStore(currentPageMetadataStore, (state) => state.metaLinks);
23+
return {
24+
metaLinks,
25+
};
26+
}

0 commit comments

Comments
 (0)