diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx index cbe3d724c..7af79185f 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx @@ -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(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 ( diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx index 4b4adb2b2..106481f7d 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx @@ -38,6 +38,7 @@ export async function EmbeddableRootLayout({ } externalLinksTarget={context.customization.externalLinks.target} contextId={context.contextId} + proxyOrigin={context.site.proxy?.origin} > + 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 ( diff --git a/packages/gitbook/src/components/primitives/Link.tsx b/packages/gitbook/src/components/primitives/Link.tsx index 7604ef52a..0c8abdc81 100644 --- a/packages/gitbook/src/components/primitives/Link.tsx +++ b/packages/gitbook/src/components/primitives/Link.tsx @@ -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) => { @@ -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 }); diff --git a/packages/gitbook/src/components/utils/link.test.ts b/packages/gitbook/src/components/utils/link.test.ts new file mode 100644 index 000000000..e50d0f0ea --- /dev/null +++ b/packages/gitbook/src/components/utils/link.test.ts @@ -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, + }); + } + }); +}); diff --git a/packages/gitbook/src/components/utils/link.ts b/packages/gitbook/src/components/utils/link.ts index 8b0ef5c2e..8f0385e53 100644 --- a/packages/gitbook/src/components/utils/link.ts +++ b/packages/gitbook/src/components/utils/link.ts @@ -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)); }