Migrate to Next 16 (#4162)

This commit is contained in:
conico974
2026-04-15 13:20:25 +02:00
committed by GitHub
parent 88c38fa505
commit 3fa727c80b
21 changed files with 700 additions and 1791 deletions
@@ -19,6 +19,9 @@ inputs:
environment:
description: 'Environment to deploy to'
required: true
headSha:
description: 'Git ref to deploy, used for the deploymentId'
required: false
outputs:
deployment-url:
description: "Deployment URL"
@@ -59,6 +62,16 @@ runs:
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Inject build env vars
if: ${{ inputs.environment == 'preview' }}
shell: bash
run: |
HEAD_SHA=$(git rev-parse HEAD)
echo "resolved HEAD_SHA: $HEAD_SHA"
echo "GITBOOK_HEAD_SHA=$HEAD_SHA" >> .vercel/.env.${{ inputs.environment }}.local
echo "GITBOOK_RUNTIME=vercel" >> .vercel/.env.${{ inputs.environment }}.local
echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---"
cat .vercel/.env.${{ inputs.environment }}.local
- name: Build Project Artifacts
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
shell: bash
@@ -66,6 +79,7 @@ runs:
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
GITBOOK_RUNTIME: vercel
GITBOOK_HEAD_SHA: ${{ inputs.headSha }}
- name: Deploy Project Artifacts to Vercel
id: deploy
shell: bash
+1
View File
@@ -40,6 +40,7 @@ jobs:
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-preview
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
headSha: ${{ github.event.pull_request.head.sha || github.sha }}
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
+220 -1502
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -17,8 +17,9 @@
"@types/react-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.24.2",
"axios": "1.8.4"
"esbuild": "0.27.3",
"axios": "1.8.4",
"@radix-ui/react-slot": "1.2.4"
},
"private": true,
"scripts": {
@@ -59,6 +60,7 @@
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
"next@16.2.3": "patches/next@16.2.3.patch"
}
}
+10 -2
View File
@@ -1,10 +1,16 @@
// @ts-check
// We don't use the deployment ID yet on 2c, we need to remove it because of https://github.com/opennextjs/opennextjs-aws/issues/1136
const deploymentId =
process.env.GITBOOK_RUNTIME === 'cloudflare'
? undefined
: process.env.GITBOOK_HEAD_SHA || process.env.GITHUB_SHA || Date.now().toString(); // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
deploymentId: process.env.GITHUB_SHA || Date.now().toString(), // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
deploymentId: deploymentId?.slice(0, 32), // Vercel's deployment ID has a max length of 32 characters
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
authInterrupts: true,
@@ -18,10 +24,12 @@ const nextConfig = {
// Since content is fully static, we don't want to fetch on hover again
optimisticClientCache: false,
// Disable splitting the RSC in like 5 chunks
prefetchInlining: true,
},
env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
BUILD_VERSION: (process.env.GITBOOK_HEAD_SHA ?? process.env.GITHUB_SHA ?? '').slice(0, 7),
// GitBook envs
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
@@ -2,7 +2,7 @@
"main": "default.js",
"name": "gitbook-open-v2-server",
"keep_names": false,
"compatibility_date": "2025-04-14",
"compatibility_date": "2026-04-02",
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
@@ -1,7 +1,7 @@
{
"main": "middleware.js",
"name": "gitbook-open-v2",
"compatibility_date": "2025-04-14",
"compatibility_date": "2026-04-02",
"keep_names": false,
"compatibility_flags": [
"nodejs_compat",
+17 -13
View File
@@ -19,8 +19,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "^3.8.5",
"@opennextjs/cloudflare": "^1.14.4",
"@opennextjs/aws": "3.10.1",
"@opennextjs/cloudflare": "1.19.1",
"@panzoom/panzoom": "^4.6.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
@@ -43,6 +43,7 @@
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0",
"leven": "^4.1.0",
"mcp-handler": "^1.0.2",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-frontmatter": "^2.0.1",
@@ -53,8 +54,7 @@
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"leven": "^4.1.0",
"next": "15.4.11",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -62,8 +62,8 @@
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"quick-lru": "^7.0.1",
"react": "catalog:",
"react-dom": "catalog:",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hotkeys-hook": "^4.4.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -98,8 +98,8 @@
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/rison": "^0.0.9",
"@types/negotiator": "^0.6.4",
"bun-types": "catalog:",
@@ -112,17 +112,17 @@
"ts-essentials": "^10.0.1",
"typescript": "catalog:",
"vercel": "50.37.3",
"wrangler": "^4.43.0",
"wrangler": "^4.79.0",
"rss-parser": "^3.13.0"
},
"scripts": {
"generate": "./scripts/generate.sh",
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
"dev": "env-cmd --silent -f ../../.env.local next",
"build": "next build",
"build:local": "GITBOOK_URL=http://localhost:3000 next build",
"dev": "env-cmd --silent -f ../../.env.local next --webpack",
"build": "next build --webpack",
"build:local": "GITBOOK_URL=http://localhost:3000 next build --webpack",
"start": "GITBOOK_URL=http://localhost:3000 next start",
"build:cloudflare": "opennextjs-cloudflare build",
"build:cloudflare": "GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build",
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
@@ -138,5 +138,9 @@
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
}
}
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest) {
body.tags.forEach((tag) => {
logger.log(`Revalidating tag: ${tag}`);
revalidateTag(tag);
revalidateTag(tag, { expire: 0 }); // Force revalidation without waiting for the next scheduled revalidation
});
return NextResponse.json({
@@ -1,29 +1,16 @@
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import type { GitBookSiteContext } from '@/lib/context';
import {
type ContentRef,
type CustomizationContentLink,
type CustomizationHeaderItem,
type CustomizationHeaderPreset,
SiteInsightsLinkPosition,
} from '@gitbook/api';
import assertNever from 'assert-never';
import type React from 'react';
import { resolveContentRef } from '@/lib/references';
import { getLocalizedTitle } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import {
SiteAuthLoginButton,
SiteAuthLoginDropdownMenuItem,
SiteAuthLoginLink,
} from '../SiteAuth/SiteAuthLoginLink';
import { Button, Link, ToggleChevron } from '../primitives';
import {
type DropdownButtonProps,
DropdownMenu,
DropdownMenuItem,
} from '../primitives/DropdownMenu';
import { SiteAuthLoginDropdownMenuItem } from '../SiteAuth/SiteAuthLoginLink';
import { DropdownMenuItem } from '../primitives/DropdownMenu';
import { HeaderLinkDropdown, HeaderLinkNavItem } from './HeaderLinkDropdown';
export async function HeaderLink(props: {
context: GitBookSiteContext;
@@ -39,29 +26,22 @@ export async function HeaderLink(props: {
if (link.links && link.links.length > 0) {
return (
<DropdownMenu
className={`shrink ${customization.styling.search === 'prominent' ? 'right-0 left-auto' : null}`}
button={
!target || !link.to ? (
<HeaderItemDropdown headerPreset={headerPreset} title={title} />
) : (
<HeaderLinkNavItem
linkTarget={link.to}
linkStyle={linkStyle}
headerPreset={headerPreset}
title={title}
isDropdown
href={target?.href}
isSiteAuthLoginHref={isSiteAuthLoginHref(context.linker, target.href)}
/>
)
<HeaderLinkDropdown
headerPreset={headerPreset}
title={title}
hasTarget={!!target}
linkTarget={link.to}
linkStyle={linkStyle}
href={target?.href}
isSiteAuthLoginHref={
target ? isSiteAuthLoginHref(context.linker, target.href) : false
}
openOnHover={true}
dropdownClassName={`shrink ${customization.styling.search === 'prominent' ? 'right-0 left-auto' : null}`}
>
{link.links.map((subLink, index) => (
<SubHeaderLink key={index} {...props} link={subLink} />
))}
</DropdownMenu>
</HeaderLinkDropdown>
);
}
@@ -82,148 +62,6 @@ export async function HeaderLink(props: {
);
}
export type HeaderLinkNavItemProps = {
linkTarget: ContentRef;
linkStyle: NonNullable<CustomizationHeaderItem['style']>;
headerPreset: CustomizationHeaderPreset;
title: string;
href?: string;
isDropdown: boolean;
isSiteAuthLoginHref: boolean;
} & DropdownButtonProps<HTMLElement>;
function HeaderLinkNavItem(props: HeaderLinkNavItemProps) {
const { linkStyle, ...rest } = props;
switch (linkStyle) {
case 'button-secondary':
case 'button-primary':
return <HeaderItemButton {...rest} linkStyle={linkStyle} />;
case 'link':
return <HeaderItemLink {...rest} />;
default:
assertNever(linkStyle);
}
}
function HeaderItemButton(
props: Omit<HeaderLinkNavItemProps, 'linkStyle'> & {
linkStyle: 'button-secondary' | 'button-primary';
}
) {
const {
linkTarget,
linkStyle,
headerPreset,
title,
href,
isDropdown,
isSiteAuthLoginHref,
...rest
} = props;
const variant = (() => {
switch (linkStyle) {
case 'button-secondary':
return 'header';
case 'button-primary':
return 'primary';
default:
assertNever(linkStyle);
}
})();
const sharedProps: React.ComponentProps<typeof Button> = {
href,
variant,
size: 'medium' as const,
insights: {
type: 'link_click' as const,
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
},
label: title,
...rest,
};
return isSiteAuthLoginHref ? (
<SiteAuthLoginButton {...sharedProps} />
) : (
<Button {...sharedProps} />
);
}
function getHeaderLinkClassName(_props: { headerPreset: CustomizationHeaderPreset }) {
return tcls(
'flex items-center gap-1',
'shrink',
'contrast-more:underline',
'truncate',
'text-tint',
'links-default:hover:text-primary',
'links-default:data-[state=open]:text-primary',
'links-default:tint:hover:text-tint-strong',
'links-default:tint:data-[state=open]:text-tint-strong',
'underline-offset-2',
'links-accent:hover:underline',
'links-accent:data-[state=open]:underline',
'links-accent:underline-offset-4',
'links-accent:decoration-primary-subtle',
'links-accent:decoration-[3px]',
'links-accent:py-0.5', // Prevent underline from being cut off at the bottom
'theme-bold:text-header-link',
'hover:theme-bold:text-header-link/7!'
);
}
function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
const { linkTarget, headerPreset, title, isDropdown, href, isSiteAuthLoginHref, ...rest } =
props;
const sharedProps = {
href: href ?? '#',
className: getHeaderLinkClassName({ headerPreset }),
insights: {
type: 'link_click' as const,
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
},
...rest,
};
return isSiteAuthLoginHref ? (
<SiteAuthLoginLink {...sharedProps}>
{title}
{isDropdown ? <ToggleChevron /> : null}
</SiteAuthLoginLink>
) : (
<Link {...sharedProps}>
{title}
{isDropdown ? <ToggleChevron /> : null}
</Link>
);
}
function HeaderItemDropdown(
props: {
headerPreset: CustomizationHeaderPreset;
title: string;
} & DropdownButtonProps<HTMLElement>
) {
const { headerPreset, title, ...rest } = props;
return (
<span
className={tcls(getHeaderLinkClassName({ headerPreset }), 'cursor-default')}
{...rest}
>
{title}
<ToggleChevron />
</span>
);
}
async function SubHeaderLink(props: {
context: GitBookSiteContext;
link: CustomizationContentLink;
@@ -0,0 +1,216 @@
'use client';
import {
type ContentRef,
type CustomizationHeaderPreset,
SiteInsightsLinkPosition,
} from '@gitbook/api';
import assertNever from 'assert-never';
import type React from 'react';
import { tcls } from '@/lib/tailwind';
import { SiteAuthLoginButton, SiteAuthLoginLink } from '../SiteAuth/SiteAuthLoginLink';
import { Button, Link, ToggleChevron } from '../primitives';
import { type DropdownButtonProps, DropdownMenu } from '../primitives/DropdownMenu';
/**
* Client component wrapping the DropdownMenu for header links.
* Accepts serializable props to avoid Flight lazy chunk wrappers reaching Radix's Slot.
*/
export function HeaderLinkDropdown(props: {
headerPreset: CustomizationHeaderPreset;
title: string;
hasTarget: boolean;
linkTarget: ContentRef | null;
linkStyle: 'link' | 'button-secondary' | 'button-primary';
href?: string;
isSiteAuthLoginHref: boolean;
dropdownClassName: string;
children: React.ReactNode;
}) {
const {
headerPreset,
title,
hasTarget,
linkTarget,
linkStyle,
href,
isSiteAuthLoginHref,
dropdownClassName,
children,
} = props;
return (
<DropdownMenu
className={dropdownClassName}
button={
!hasTarget || !linkTarget ? (
<HeaderItemDropdown headerPreset={headerPreset} title={title} />
) : (
<HeaderLinkNavItem
linkTarget={linkTarget}
linkStyle={linkStyle}
headerPreset={headerPreset}
title={title}
isDropdown
href={href}
isSiteAuthLoginHref={isSiteAuthLoginHref}
/>
)
}
openOnHover={true}
>
{children}
</DropdownMenu>
);
}
export type HeaderLinkNavItemProps = {
linkTarget: ContentRef;
linkStyle: NonNullable<'link' | 'button-secondary' | 'button-primary'>;
headerPreset: CustomizationHeaderPreset;
title: string;
href?: string;
isDropdown: boolean;
isSiteAuthLoginHref: boolean;
} & DropdownButtonProps<HTMLElement>;
export function HeaderLinkNavItem(props: HeaderLinkNavItemProps) {
const { linkStyle, ...rest } = props;
switch (linkStyle) {
case 'button-secondary':
case 'button-primary':
return <HeaderItemButton {...rest} linkStyle={linkStyle} />;
case 'link':
return <HeaderItemLink {...rest} />;
default:
assertNever(linkStyle);
}
}
function HeaderItemButton(
props: Omit<HeaderLinkNavItemProps, 'linkStyle'> & {
linkStyle: 'button-secondary' | 'button-primary';
}
) {
const {
linkTarget,
linkStyle,
headerPreset,
title,
href,
isDropdown,
isSiteAuthLoginHref,
...rest
} = props;
const variant = (() => {
switch (linkStyle) {
case 'button-secondary':
return 'header';
case 'button-primary':
return 'primary';
default:
assertNever(linkStyle);
}
})();
const sharedProps: React.ComponentProps<typeof Button> = {
href,
variant,
size: 'medium' as const,
insights: {
type: 'link_click' as const,
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
},
label: title,
...rest,
};
return isSiteAuthLoginHref ? (
<SiteAuthLoginButton {...sharedProps} />
) : (
<Button {...sharedProps} />
);
}
function getHeaderLinkClassName(_props: { headerPreset: CustomizationHeaderPreset }) {
return tcls(
'flex items-center gap-1',
'shrink',
'contrast-more:underline',
'truncate',
'text-tint',
'links-default:hover:text-primary',
'links-default:data-[state=open]:text-primary',
'links-default:tint:hover:text-tint-strong',
'links-default:tint:data-[state=open]:text-tint-strong',
'underline-offset-2',
'links-accent:hover:underline',
'links-accent:data-[state=open]:underline',
'links-accent:underline-offset-4',
'links-accent:decoration-primary-subtle',
'links-accent:decoration-[3px]',
'links-accent:py-0.5', // Prevent underline from being cut off at the bottom
'theme-bold:text-header-link',
'hover:theme-bold:text-header-link/7!'
);
}
function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
const {
linkTarget,
headerPreset,
title,
isDropdown,
href,
isSiteAuthLoginHref,
className,
...rest
} = props;
const sharedProps = {
href: href ?? '#',
className: tcls(getHeaderLinkClassName({ headerPreset }), className),
insights: {
type: 'link_click' as const,
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
},
...rest,
};
return isSiteAuthLoginHref ? (
<SiteAuthLoginLink {...sharedProps}>
{title}
{isDropdown ? <ToggleChevron /> : null}
</SiteAuthLoginLink>
) : (
<Link {...sharedProps}>
{title}
{isDropdown ? <ToggleChevron /> : null}
</Link>
);
}
function HeaderItemDropdown(
props: {
headerPreset: CustomizationHeaderPreset;
title: string;
} & DropdownButtonProps<HTMLElement>
) {
const { headerPreset, title, className, ...rest } = props;
return (
<span
className={tcls(getHeaderLinkClassName({ headerPreset }), 'cursor-default', className)}
{...rest}
>
{title}
<ToggleChevron />
</span>
);
}
@@ -6,7 +6,6 @@ import {
SiteInsightsLinkPosition,
type SiteSocialAccount,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import type React from 'react';
import { resolveContentRef } from '@/lib/references';
@@ -15,13 +14,12 @@ import { tcls } from '@/lib/tailwind';
import { SocialAccountLink } from '../Footer/SocialAccounts';
import { SiteAuthLoginDropdownMenuItem } from '../SiteAuth/SiteAuthLoginLink';
import { ToggleChevron } from '../primitives';
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownSubMenu,
} from '../primitives/DropdownMenu';
import { HeaderLinkMoreDropdown } from './HeaderLinkMoreClient';
import styles from './headerLinks.module.css';
/**
@@ -35,32 +33,11 @@ export function HeaderLinkMore(props: {
}) {
const { label, links, context, socialAccounts } = props;
const renderButton = (
<button
type="button"
className={tcls(
'text-tint',
'hover:text-primary',
'dark:hover:text-primary',
'theme-bold:text-header-link',
'theme-bold:hover:text-header-link/8',
'flex',
'gap-1',
'items-center'
)}
>
<span className="sr-only">{label}</span>
<Icon icon="ellipsis" className={tcls('size-4')} />
<ToggleChevron />
</button>
);
return (
<div className={`${styles.linkEllipsis} z-20 items-center`}>
<DropdownMenu
button={renderButton}
openOnHover={true}
className={tcls(
<HeaderLinkMoreDropdown
label={label}
dropdownClassName={tcls(
'max-md:right-0 max-md:left-auto',
context.customization.styling.search === 'prominent' && 'right-0 left-auto'
)}
@@ -75,7 +52,7 @@ export function HeaderLinkMore(props: {
account={account}
/>
))}
</DropdownMenu>
</HeaderLinkMoreDropdown>
</div>
);
}
@@ -0,0 +1,48 @@
'use client';
import { Icon } from '@gitbook/icons';
import type React from 'react';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { ToggleChevron } from '../primitives';
import { DropdownMenu } from '../primitives/DropdownMenu';
/**
* Client component for the "More" header link dropdown.
* Creates the trigger button internally to avoid Flight lazy chunk wrappers reaching Radix's Slot.
*/
export function HeaderLinkMoreDropdown(props: {
label: React.ReactNode;
dropdownClassName: ClassValue;
children: React.ReactNode;
}) {
const { label, dropdownClassName, children } = props;
return (
<DropdownMenu
button={
<button
type="button"
className={tcls(
'text-tint',
'hover:text-primary',
'dark:hover:text-primary',
'theme-bold:text-header-link',
'theme-bold:hover:text-header-link/8',
'flex',
'gap-1',
'items-center'
)}
>
<span className="sr-only">{label}</span>
<Icon icon="ellipsis" className={tcls('size-4')} />
<ToggleChevron />
</button>
}
openOnHover={true}
className={dropdownClassName}
>
{children}
</DropdownMenu>
);
}
@@ -1,12 +1,11 @@
import type { SiteSpace } from '@gitbook/api';
import { useMemo } from 'react';
import type { IconName } from '@gitbook/icons';
import type { GitBookSiteContext } from '@/lib/context';
import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { Button, type ButtonProps, ToggleChevron } from '../primitives';
import { DropdownMenu } from '../primitives/DropdownMenu';
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
import type { ButtonProps } from '../primitives';
import { SpacesDropdownClient } from './SpacesDropdownClient';
// Memoized regex for checking if a string starts with an emoji
const EMOJI_REGEX = /^\p{Emoji}/u;
@@ -21,43 +20,34 @@ export function SpacesDropdown(props: {
siteSpaces: SiteSpace[];
className?: string;
variant?: ButtonProps['variant'];
icon?: ButtonProps['icon'];
icon?: IconName;
}) {
const { context, siteSpace, siteSpaces, className, variant = 'secondary', icon } = props;
const currentLanguage = context.locale;
const dropdownClassName = tcls(
'group-hover/dropdown:invisible', // Prevent hover from opening the dropdown, as it's annoying in this context
'group-focus-within/dropdown:group-hover/dropdown:visible' // When the dropdown is already open, it should remain visible when hovered
);
const slimSpaces = siteSpaces.map((siteSp) => ({
id: siteSp.id,
title: getLocalizedTitle(siteSp, currentLanguage),
url: getSiteSpaceURL(context, siteSp),
isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
}));
return (
<DropdownMenu
className={tcls(
'group-hover/dropdown:invisible', // Prevent hover from opening the dropdown, as it's annoying in this context
'group-focus-within/dropdown:group-hover/dropdown:visible' // When the dropdown is already open, it should remain visible when hovered
)}
button={
<Button
icon={icon}
data-testid="space-dropdown-button"
size="small"
variant={variant}
trailing={<ToggleChevron />}
className={tcls('bg-tint-base', className)}
>
<span className="button-content">
{getLocalizedTitle(siteSpace, currentLanguage)}
</span>
</Button>
}
>
<SpacesDropdownMenuItems
slimSpaces={siteSpaces.map((siteSp) => ({
id: siteSp.id,
title: getLocalizedTitle(siteSp, currentLanguage),
url: getSiteSpaceURL(context, siteSp),
isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
}))}
curPath={siteSpace.path}
/>
</DropdownMenu>
<SpacesDropdownClient
title={getLocalizedTitle(siteSpace, currentLanguage)}
icon={icon}
variant={variant}
className={className}
dropdownClassName={dropdownClassName}
slimSpaces={slimSpaces}
curPath={siteSpace.path}
/>
);
}
@@ -69,9 +59,8 @@ export function TranslationsDropdown(props: {
}) {
const { context, siteSpace, siteSpaces, className } = props;
// Memoize the emoji check to avoid repeated regex execution
const title = getLocalizedTitle(siteSpace, context.locale);
const hasEmojiPrefix = useMemo(() => startsWithEmoji(title), [title]);
const hasEmojiPrefix = startsWithEmoji(title);
return (
<SpacesDropdown
@@ -0,0 +1,50 @@
'use client';
import type { IconName } from '@gitbook/icons';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { Button, type ButtonProps, ToggleChevron } from '../primitives';
import { DropdownMenu } from '../primitives/DropdownMenu';
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
/**
* Client component that creates the Button trigger and DropdownMenu internally,
* avoiding server→client element serialization through Radix's Slot.
*/
export function SpacesDropdownClient(props: {
title: string;
icon?: IconName;
variant: ButtonProps['variant'];
className?: ClassValue;
dropdownClassName: string;
slimSpaces: Array<{
id: string;
title: string;
url: string;
isActive: boolean;
spaceId: string;
}>;
curPath: string;
}) {
const { title, icon, variant, className, dropdownClassName, slimSpaces, curPath } = props;
return (
<DropdownMenu
className={dropdownClassName}
button={
<Button
icon={icon}
data-testid="space-dropdown-button"
size="small"
variant={variant}
trailing={<ToggleChevron />}
className={tcls('bg-tint-base', className)}
>
<span className="button-content">{title}</span>
</Button>
}
>
<SpacesDropdownMenuItems slimSpaces={slimSpaces} curPath={curPath} />
</DropdownMenu>
);
}
@@ -12,10 +12,12 @@ export function HoverCardRoot(props: RadixHoverCard.HoverCardProps) {
);
}
// We wrap the Trigger child in a span to avoid react slot seeing a lazy chunk
export function HoverCardTrigger(props: RadixHoverCard.HoverCardTriggerProps) {
const { children, ...rest } = props;
return (
<RadixHoverCard.Trigger asChild {...props}>
{props.children}
<RadixHoverCard.Trigger asChild {...rest}>
<span>{children}</span>
</RadixHoverCard.Trigger>
);
}
+19 -19
View File
@@ -8,7 +8,7 @@ import {
} from '@gitbook/api';
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
import { parse as parseCacheControl } from '@tusbar/cache-control';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { cacheLife, cacheTag } from 'next/cache';
import { cache } from '../cache';
import { DataFetcherError, wrapCacheDataFetcherError } from './errors';
import type { GitBookDataFetcher } from './types';
@@ -21,7 +21,7 @@ interface DataFetcherInput {
}
/**
* Options to pass to the `fetch` call to disable the Next data-cache when wrapped in `use cache`.
* Options to pass to the `fetch` call to disable the Next data-cache when wrapped in `use cache: remote`.
*/
export const noCacheFetchOptions: Partial<RequestInit> = {
next: {
@@ -208,7 +208,7 @@ function cacheLifeFromResponse(
}
const getUserById = cache(async (input: DataFetcherInput, params: { userId: string }) => {
'use cache';
'use cache: remote';
return wrapCacheDataFetcherError(async () => {
return trace(`getUserById(${params.userId})`, async () => {
const api = apiClient(input);
@@ -224,7 +224,7 @@ const getUserById = cache(async (input: DataFetcherInput, params: { userId: stri
const getSpace = cache(
async (input: DataFetcherInput, params: { spaceId: string; shareKey: string | undefined }) => {
'use cache';
'use cache: remote';
cacheTag(
getCacheTag({
tag: 'space',
@@ -254,7 +254,7 @@ const getSpace = cache(
const getChangeRequest = cache(
async (input: DataFetcherInput, params: { spaceId: string; changeRequestId: string }) => {
'use cache';
'use cache: remote';
cacheTag(
getCacheTag({
tag: 'change-request',
@@ -286,7 +286,7 @@ const getChangeRequest = cache(
const getRevision = cache(
async (input: DataFetcherInput, params: { spaceId: string; revisionId: string }) => {
'use cache';
'use cache: remote';
return wrapCacheDataFetcherError(async () => {
return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => {
const api = apiClient(input);
@@ -313,7 +313,7 @@ const getRevisionPageMarkdown = cache(
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; pageId: string }
) => {
'use cache';
'use cache: remote';
return wrapCacheDataFetcherError(async () => {
return trace(
`getRevisionPageMarkdown(${params.spaceId}, ${params.revisionId}, ${params.pageId})`,
@@ -352,7 +352,7 @@ const getRevisionPageDocument = cache(
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; pageId: string }
) => {
'use cache';
'use cache: remote';
return wrapCacheDataFetcherError(async () => {
return trace(
`getRevisionPageDocument(${params.spaceId}, ${params.revisionId}, ${params.pageId})`,
@@ -386,7 +386,7 @@ const getRevisionReusableContentDocument = cache(
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; reusableContentId: string }
) => {
'use cache';
'use cache: remote';
return wrapCacheDataFetcherError(async () => {
return trace(
`getRevisionReusableContentDocument(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`,
@@ -419,7 +419,7 @@ const getRevisionPageByPath = cache(
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; path: string }
) => {
'use cache';
'use cache: remote';
return wrapCacheDataFetcherError(async () => {
return trace(
`getRevisionPageByPath(${params.spaceId}, ${params.revisionId}, ${params.path})`,
@@ -446,7 +446,7 @@ const getRevisionPageByPath = cache(
const getDocument = cache(
async (input: DataFetcherInput, params: { spaceId: string; documentId: string }) => {
'use cache';
'use cache: remote';
return wrapCacheDataFetcherError(async () => {
return trace(`getDocument(${params.spaceId}, ${params.documentId})`, async () => {
const api = apiClient(input);
@@ -476,7 +476,7 @@ const getComputedDocument = cache(
seed: string;
}
) => {
'use cache';
'use cache: remote';
cacheTag(
...getComputedContentSourceCacheTags(
{
@@ -514,7 +514,7 @@ const getComputedDocument = cache(
const getLatestOpenAPISpecVersionContent = cache(
async (input: DataFetcherInput, params: { organizationId: string; slug: string }) => {
'use cache';
'use cache: remote';
cacheTag(
getCacheTag({
tag: 'openapi',
@@ -550,7 +550,7 @@ const getPublishedContentSite = cache(
params: { organizationId: string; siteId: string; siteShareKey: string | undefined },
_apiVersion: string
) => {
'use cache';
'use cache: remote';
cacheTag(
getCacheTag({
tag: 'site',
@@ -592,7 +592,7 @@ const getSiteRedirectBySource = cache(
source: string;
}
) => {
'use cache';
'use cache: remote';
cacheTag(
getCacheTag({
tag: 'site',
@@ -627,7 +627,7 @@ const getSiteRedirectBySource = cache(
const getEmbedByUrl = cache(
async (input: DataFetcherInput, params: { spaceId: string; url: string }) => {
'use cache';
'use cache: remote';
cacheTag(
getCacheTag({
tag: 'space',
@@ -660,7 +660,7 @@ const searchSiteContent = cache(
input: DataFetcherInput,
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
) => {
'use cache';
'use cache: remote';
cacheTag(
getCacheTag({
tag: 'site',
@@ -700,7 +700,7 @@ const renderIntegrationUi = cache(
input: DataFetcherInput,
params: { integrationName: string; request: RenderIntegrationUI }
) => {
'use cache';
'use cache: remote';
cacheTag(
getCacheTag({
tag: 'integration',
@@ -734,7 +734,7 @@ const listRevisionPageMetaLinks = cache(
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; pageId: string }
) => {
'use cache';
'use cache: remote';
return wrapCacheDataFetcherError(async () => {
return trace(
`listRevisionPageMetaLinks(${params.spaceId}, ${params.revisionId}, ${params.pageId})`,
+1 -1
View File
@@ -1,6 +1,6 @@
import { GitBookAPIError } from '@gitbook/api';
import { parse as parseCacheControl } from '@tusbar/cache-control';
import { unstable_cacheLife as cacheLife } from 'next/cache';
import { cacheLife } from 'next/cache';
import type { DataFetcherErrorData, DataFetcherResponse } from './types';
export class DataFetcherError extends Error {
+2 -2
View File
@@ -8,7 +8,7 @@ import {
import { DataFetcherError, noCacheFetchOptions } from '@/lib/data';
import { resolveContentRef } from '@/lib/references';
import { getCacheTag } from '@gitbook/cache-tags';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { cacheLife, cacheTag } from 'next/cache';
import { assert } from 'ts-essentials';
import { enrichFilesystem } from './enrich';
import type {
@@ -83,7 +83,7 @@ async function fetchFilesystem(
};
}
> {
'use cache';
'use cache: remote';
try {
cacheTag(getCacheTag({ tag: 'space', space: spaceId }));
return await fetchFilesystemNoCache(url);
+3 -2
View File
@@ -13,7 +13,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"plugins": [
{
"name": "next"
@@ -32,7 +32,8 @@
"**/*.ts",
"**/*.tsx",
"types/**/*.d.ts",
"@gitbook/browser-types"
"@gitbook/browser-types",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
File diff suppressed because one or more lines are too long