Fix external links logic for proxy (#4012)

This commit is contained in:
Greg Bergé
2026-02-17 11:41:30 +01:00
committed by GitHub
parent 9d5e397574
commit 414866cbec
7 changed files with 109 additions and 15 deletions
@@ -10,7 +10,7 @@ import {
} from '@/components/AIChat';
import { useLanguage } from '@/intl/client';
import * as api from '@gitbook/api';
import React, { useMemo } from 'react';
import React, { use, useMemo } from 'react';
import { useTrackEvent } from '../Insights';
import { LinkContext, type LinkContextType } from '../primitives';
import {
@@ -65,12 +65,18 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
const tabsRef = React.useRef<HTMLDivElement>(null);
const hasDocsTab = configuration.tabs.includes('docs');
const currentLinkContext = use(LinkContext);
const linkContext: LinkContextType = useMemo(
() =>
hasDocsTab
? { externalTarget: '_blank' }
: { isExternal: () => true, externalTarget: '_blank' },
[hasDocsTab]
? { ...currentLinkContext, externalTarget: '_blank' }
: {
...currentLinkContext,
isExternalClient: () => true,
isExternalServer: () => true,
externalTarget: '_blank',
},
[hasDocsTab, currentLinkContext]
);
return (
@@ -38,6 +38,7 @@ export async function EmbeddableRootLayout({
}
externalLinksTarget={context.customization.externalLinks.target}
contextId={context.contextId}
proxyOrigin={context.site.proxy?.origin}
>
<AIContextProvider
aiMode={context.customization.ai.mode}
@@ -53,6 +53,7 @@ export async function SiteLayout(props: {
(customization.themes.toggeable ? undefined : customization.themes.default)
}
externalLinksTarget={customization.externalLinks.target}
proxyOrigin={context.site.proxy?.origin}
>
<AIContextProvider
aiMode={customization.ai?.mode}
@@ -8,6 +8,7 @@ import { useMemo } from 'react';
import { SearchContextProvider } from '../Search';
import { useClearRouterCache } from '../hooks/useClearRouterCache';
import { LinkContext, type LinkContextType } from '../primitives';
import { isExternalLink } from '../utils/link';
/**
* Client component context providers for the site layout.
@@ -16,9 +17,10 @@ export function SiteLayoutClientContexts(props: {
forcedTheme: CustomizationThemeMode | undefined;
externalLinksTarget: SiteExternalLinksTarget;
contextId: string | undefined;
proxyOrigin: string | undefined;
children: React.ReactNode;
}) {
const { children, forcedTheme, externalLinksTarget, contextId } = props;
const { children, forcedTheme, externalLinksTarget, contextId, proxyOrigin } = props;
useClearRouterCache(contextId);
@@ -27,8 +29,16 @@ export function SiteLayoutClientContexts(props: {
externalTarget: { self: '_self' as const, blank: '_blank' as const }[
externalLinksTarget
],
isExternalServer: (href) =>
isExternalLink(href, proxyOrigin ? `https://${proxyOrigin}` : null),
isExternalClient: (href) => {
if (proxyOrigin?.startsWith(window.location.origin)) {
return isExternalLink(href, `https://${proxyOrigin}`);
}
return isExternalLink(href, window.location.origin);
},
}),
[externalLinksTarget]
[externalLinksTarget, proxyOrigin]
);
return (
@@ -37,7 +37,8 @@ type LinkTarget = '_blank' | '_self';
export type LinkContextType = {
externalTarget: LinkTarget;
isExternal?: ((href: string) => boolean) | undefined;
isExternalServer?: ((href: string) => boolean) | undefined;
isExternalClient?: ((href: string) => boolean) | undefined;
};
/**
@@ -68,8 +69,8 @@ function getTargetProps(
/**
* Check if the link is external with the origin.
*/
function defaultCheckIsExternalLink(href: string) {
return isExternalLink(href, typeof window !== 'undefined' ? window.location.origin : undefined);
function defaultIsExternalClient(href: string) {
return isExternalLink(href, window.location.origin);
}
/**
@@ -78,12 +79,15 @@ function defaultCheckIsExternalLink(href: string) {
*/
export function Link(props: LinkProps) {
const { ref, href, prefetch, children, insights, classNames, className, ...domProps } = props;
const { externalTarget, isExternal: checkIsExternalClientSide = defaultCheckIsExternalLink } =
React.useContext(LinkContext);
const {
externalTarget,
isExternalClient = defaultIsExternalClient,
isExternalServer = isExternalLink,
} = React.useContext(LinkContext);
const { onNavigationClick } = React.useContext(NavigationStatusContext);
const trackEvent = useTrackEvent();
const forwardedClassNames = useClassnames(classNames || []);
const isExternal = isExternalLink(href);
const isExternal = isExternalServer(href);
const { target, rel } = getTargetProps(props, { externalTarget, isExternal });
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
@@ -92,7 +96,7 @@ export function Link(props: LinkProps) {
onNavigationClick(href);
}
const isExternalOnClient = checkIsExternalClientSide(href);
const isExternalOnClient = isExternalClient(href);
if (insights) {
trackEvent(insights, undefined, { immediate: isExternalOnClient });
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'bun:test';
import { isExternalLink } from './link';
describe('isExternalLink', () => {
it('treats anchor links as internal', () => {
expect(isExternalLink('#section')).toBe(false);
});
it('treats relative links as internal', () => {
expect(isExternalLink('/docs')).toBe(false);
expect(isExternalLink('docs/getting-started')).toBe(false);
});
it('treats absolute links as external when no origin is provided', () => {
expect(isExternalLink('https://example.com/docs')).toBe(true);
});
it('treats links from a different origin as external', () => {
expect(isExternalLink('https://other.com/docs', 'https://example.com')).toBe(true);
});
it('treats links from the same origin as internal', () => {
expect(isExternalLink('https://example.com/docs', 'https://example.com')).toBe(false);
expect(isExternalLink('https://example.com/', 'https://example.com')).toBe(false);
});
it('handles proxy origins with a pathname prefix', () => {
expect(isExternalLink('https://gitbook.com/docs/page', 'https://gitbook.com/docs')).toBe(
false
);
expect(isExternalLink('https://gitbook.com/docs/', 'https://gitbook.com/docs')).toBe(false);
expect(isExternalLink('https://gitbook.com/docs', 'https://gitbook.com/docs')).toBe(false);
expect(isExternalLink('https://gitbook.com/docs-x', 'https://gitbook.com/docs')).toBe(true);
});
it('falls back to the legacy quick check when URL.canParse is unavailable', () => {
const originalCanParse = URL.canParse;
Object.defineProperty(URL, 'canParse', {
value: undefined,
configurable: true,
});
try {
expect(isExternalLink('http://example.com')).toBe(true);
expect(isExternalLink('https://example.com')).toBe(true);
expect(isExternalLink('/docs')).toBe(false);
} finally {
Object.defineProperty(URL, 'canParse', {
value: originalCanParse,
configurable: true,
});
}
});
});
+18 -2
View File
@@ -1,3 +1,5 @@
import { withTrailingSlash } from '@/lib/paths';
/**
* Check if a link is external, compared to an origin.
*/
@@ -22,7 +24,21 @@ export function isExternalLink(href: string, origin: string | null = null) {
return true;
}
// If the url points to the same origin, we consider it internal
// If the url points to the same origin, we consider it internal,
// a proxy origin can be "gitbook.com/docs", so we also check the pathname.
const parsed = new URL(href);
return parsed.origin !== origin;
const originUrl = new URL(origin);
// Compare origins exactly first
if (parsed.origin !== originUrl.origin) {
return true;
}
// Compare pathname exactly
if (parsed.pathname === originUrl.pathname) {
return false;
}
// Then compare the pathname by adding "/" to ensure we don't match "gitbook.com/docs-x"
return !parsed.pathname.startsWith(withTrailingSlash(originUrl.pathname));
}