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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rt9YwdSUu7gHehYbEkvEJ8
This commit is contained in:
Brett Jephson
2026-09-24 14:42:16 +01:00
parent 7f8ab7dcc7
commit f2fddad24c
6 changed files with 152 additions and 11 deletions
+5
View File
@@ -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.
@@ -24,7 +24,7 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
: null;
if (!resolved) {
const fallback = resolveContentRefFallback(block.data.ref);
const fallback = resolveContentRefFallback(block.data.ref, context.contentContext);
if (!fallback) {
return null;
}
@@ -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<typeof Button> = {
...buttonProps,
insights: {
@@ -13,6 +13,7 @@ import {
resolveContentRefFallback,
resolveContentRefInDocument,
} from '@/lib/references';
import { checkIsExternalURL } from '@/lib/urls';
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const { document, inline, context, ancestorInlines } = props;
@@ -36,11 +37,15 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
);
if (!resolved) {
const fallback = resolveContentRefFallback(inline.data.ref);
const fallback = resolveContentRefFallback(inline.data.ref, contentContext);
return (
<NotFoundRefHoverCard context={context}>
{fallback ? (
<InlineLinkAnchor href={fallback.href} contentRef={inline.data.ref} isExternal>
<InlineLinkAnchor
href={fallback.href}
contentRef={inline.data.ref}
isExternal={checkIsExternalURL(fallback.href)}
>
{inlinesElement}
</InlineLinkAnchor>
) : (
@@ -53,7 +58,7 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
<InlineLinkAnchor
href={resolved.href}
contentRef={inline.data.ref}
isExternal={inline.data.ref.kind === 'url'}
isExternal={isExternalLink(inline, resolved)}
>
{inlinesElement}
</InlineLinkAnchor>
@@ -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: {
</InlineLinkTooltip>
);
}
/**
* 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);
}
+70 -1
View File
@@ -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');
});
});
+57 -4
View File
@@ -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<Ref extends ContentRef>(
* 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.
*/