mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-26 12:18:01 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user