mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +00:00
Fix external links logic for proxy (#4012)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user