From f2fddad24c29bb7d3f08b242285575d6bf40fd7c Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Thu, 24 Sep 2026 14:42:16 +0100 Subject: [PATCH] Keep app URLs into the same site's spaces on the site A link stored as a URL to a GitBook app page in a space of the current site now renders as the same page path on the site, so readers of a published site aren't sent to the app. When a page ref can't be resolved, its fallback link goes to the site space instead of the app. Neither case checks that the page exists: a missing page lands on the site's not-found page rather than the app's login. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Rt9YwdSUu7gHehYbEkvEJ8 --- .changeset/giant-years-search.md | 5 ++ .../DocumentView/BlockContentRef.tsx | 2 +- .../components/DocumentView/InlineButton.tsx | 4 +- .../DocumentView/InlineLink/InlineLink.tsx | 20 ++++-- packages/gitbook/src/lib/references.test.ts | 71 ++++++++++++++++++- packages/gitbook/src/lib/references.tsx | 61 ++++++++++++++-- 6 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 .changeset/giant-years-search.md diff --git a/.changeset/giant-years-search.md b/.changeset/giant-years-search.md new file mode 100644 index 000000000..a062032b0 --- /dev/null +++ b/.changeset/giant-years-search.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Keep links to GitBook app URLs of spaces in the same site on the site, instead of sending readers to app.gitbook.com. diff --git a/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx b/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx index cdbc07978..be1cb6fe7 100644 --- a/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx +++ b/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx @@ -24,7 +24,7 @@ export async function BlockContentRef(props: BlockProps : null; if (!resolved) { - const fallback = resolveContentRefFallback(block.data.ref); + const fallback = resolveContentRefFallback(block.data.ref, context.contentContext); if (!fallback) { return null; } diff --git a/packages/gitbook/src/components/DocumentView/InlineButton.tsx b/packages/gitbook/src/components/DocumentView/InlineButton.tsx index 40ba19c41..fe546e172 100644 --- a/packages/gitbook/src/components/DocumentView/InlineButton.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineButton.tsx @@ -81,7 +81,9 @@ export async function InlineLinkButton( const href = resolved?.href ?? - (inline.data.ref ? resolveContentRefFallback(inline.data.ref)?.href : undefined); + (inline.data.ref + ? resolveContentRefFallback(inline.data.ref, context.contentContext)?.href + : undefined); const sharedProps: React.ComponentProps = { ...buttonProps, insights: { diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx index c9b8588ce..d1f47e9de 100644 --- a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx @@ -13,6 +13,7 @@ import { resolveContentRefFallback, resolveContentRefInDocument, } from '@/lib/references'; +import { checkIsExternalURL } from '@/lib/urls'; export async function InlineLink(props: InlineProps) { const { document, inline, context, ancestorInlines } = props; @@ -36,11 +37,15 @@ export async function InlineLink(props: InlineProps) { ); if (!resolved) { - const fallback = resolveContentRefFallback(inline.data.ref); + const fallback = resolveContentRefFallback(inline.data.ref, contentContext); return ( {fallback ? ( - + {inlinesElement} ) : ( @@ -53,7 +58,7 @@ export async function InlineLink(props: InlineProps) { {inlinesElement} @@ -121,7 +126,7 @@ function InlineLinkTooltipWrapper(props: { let breadcrumbs = resolved.ancestors ?? []; const isMailto = resolved.href.startsWith('mailto:'); - const isExternal = inline.data.ref.kind === 'url'; + const isExternal = isExternalLink(inline, resolved); const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined; if (isMailto) { @@ -161,3 +166,10 @@ function InlineLinkTooltipWrapper(props: { ); } + +/** + * A URL link resolved to a path in the site is internal, even though its ref is a URL. + */ +function isExternalLink(inline: DocumentInlineLink, resolved: ResolvedContentRef): boolean { + return inline.data.ref.kind === 'url' && checkIsExternalURL(resolved.href); +} diff --git a/packages/gitbook/src/lib/references.test.ts b/packages/gitbook/src/lib/references.test.ts index 4342f5a78..4458b4bdd 100644 --- a/packages/gitbook/src/lib/references.test.ts +++ b/packages/gitbook/src/lib/references.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'bun:test'; import type { Revision, RevisionPageDocument, SiteSpace, Space } from '@gitbook/api'; -import { resolveContentRef, resolveStringContentRef } from './references'; +import { + resolveContentRef, + resolveContentRefFallback, + resolveStringContentRef, +} from './references'; import type { GitBookAnyContext } from '@/lib/context'; import type { GitBookDataFetcher } from '@/lib/data'; import { createLinker } from '@/lib/links'; @@ -738,3 +742,68 @@ describe('resolveContentRef for direct space links', () => { ]); }); }); + +describe('resolveContentRef for application URLs into the site', () => { + const alerts = { + object: 'space', + id: 'space-alerts', + title: 'Alerts', + urls: { + app: 'https://app.gitbook.com/o/org/s/space-alerts/', + published: 'https://docs.example.com/analytics-alerts/', + }, + } as unknown as Space; + const alertsSiteSpace = { + object: 'site-space', + id: 'site-space-alerts', + path: 'analytics-alerts', + space: alerts, + title: 'Alerts', + urls: { published: 'https://docs.example.com/analytics-alerts/' }, + } as unknown as SiteSpace; + + const context = { + linker: createLinker({ host: 'docs.example.com', spaceBasePath: '/', siteBasePath: '/' }), + space: { id: 'space-notes' }, + site: { object: 'site', id: 'site-1' }, + sections: null, + structure: { type: 'siteSpaces', structure: [alertsSiteSpace] }, + } as unknown as GitBookAnyContext; + + it('keeps the page path and anchor of an application URL into a site space', async () => { + const result = await resolveContentRef( + { + kind: 'url', + url: 'https://app.gitbook.com/o/org/s/space-alerts/alerts-by-name/amsi-bypass#rules', + }, + context + ); + + expect(result?.href).toBe('/analytics-alerts/alerts-by-name/amsi-bypass#rules'); + }); + + it('leaves an application URL into a space outside the site untouched', async () => { + const url = 'https://app.gitbook.com/o/org/s/space-elsewhere/alerts-by-name/amsi-bypass'; + + const result = await resolveContentRef({ kind: 'url', url }, context); + + expect(result?.href).toBe(url); + }); + + it('leaves an application URL into a change request untouched', async () => { + const url = 'https://app.gitbook.com/o/org/s/space-alerts/~/changes/1/alerts-by-name'; + + const result = await resolveContentRef({ kind: 'url', url }, context); + + expect(result?.href).toBe(url); + }); + + it('falls back to the site space, not the application, for a page ref that no longer resolves', () => { + const fallback = resolveContentRefFallback( + { kind: 'page', space: 'space-alerts', page: 'page-deleted' }, + context + ); + + expect(fallback?.href).toBe('/analytics-alerts'); + }); +}); diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index aede45c39..b77d14588 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -40,8 +40,16 @@ import { getRevisionReusableContent, ignoreDataThrownError, } from '@/lib/data'; +import { GITBOOK_APP_URL } from '@/lib/env'; import { type GitBookLinker, createLinker, linkerWithAbsoluteURLs } from '@/lib/links'; +/** + * Path of an application URL to a space's content: `/o/:org/s/:space/:pagePath`, optionally under + * `/sites/:site`. A path under `~/` (a change request or revision) is not published content. + */ +const APP_SPACE_CONTENT_PATH = + /^(?:\/o\/[^/]+)?(?:\/sites\/[^/]+)?\/s\/([^/]+)(?:\/(?!~)(.*?))?\/?$/; + export interface ResolvedContentRef { /** Text to render in the content ref */ text: string; @@ -143,9 +151,10 @@ export async function resolveContentRef( switch (contentRef.kind) { case 'url': { + const href = resolveAppURLInSite(contentRef.url, context) ?? contentRef.url; return { - href: contentRef.url, - text: contentRef.url, + href, + text: href, active: false, }; } @@ -419,10 +428,14 @@ export function isContentRefInDifferentSpace( * Called if we can't resolve the content ref to have a potential fallback to display to the * user instead of not found. */ -export function resolveContentRefFallback(contentRef: ContentRef): ResolvedContentRef | null { +export function resolveContentRefFallback( + contentRef: ContentRef, + context: GitBookAnyContext | undefined +): ResolvedContentRef | null { if ('space' in contentRef && contentRef.space) { + const linker = context ? getLinkerForSpaceInSite(context, contentRef.space) : null; return { - href: getGitBookAppHref(`/s/${contentRef.space}`), + href: linker?.toPathInSpace('') ?? getGitBookAppHref(`/s/${contentRef.space}`), text: 'space', active: false, }; @@ -460,6 +473,46 @@ async function getBestTargetSpace( return fetchedSpace ? { space: fetchedSpace, siteSpace: null, siteSection: null } : undefined; } +/** + * Map an application URL into a space of the current site to the same page path on the site, so + * a link that was never resolved to a page doesn't send visitors to the app. The page isn't looked + * up: one that doesn't exist lands on the site's not-found page. + */ +function resolveAppURLInSite(url: string, context: GitBookAnyContext): string | null { + if (!URL.canParse(url)) { + return null; + } + const parsed = new URL(url); + if (parsed.origin !== new URL(GITBOOK_APP_URL).origin) { + return null; + } + const match = parsed.pathname.match(APP_SPACE_CONTENT_PATH); + const linker = match?.[1] ? getLinkerForSpaceInSite(context, match[1]) : null; + if (!linker) { + return null; + } + return linker.toPathForPagePath({ + path: match?.[2] ?? '', + anchor: parsed.hash.slice(1) || undefined, + }); +} + +/** + * Linker for a space that is part of the current site, or null when it isn't. + */ +function getLinkerForSpaceInSite( + context: GitBookAnyContext, + spaceId: string +): GitBookLinker | null { + const target = getBestTargetSpaceFromSite(context, spaceId); + if (!target?.siteSpace || !('site' in context)) { + return null; + } + return context.linker.withOtherSiteSpace({ + spaceBasePath: getFallbackSiteSpacePath(context, target.siteSpace), + }); +} + /** * Find the best target space for a content ref, from the current site. */