Compare commits

..

3 Commits

Author SHA1 Message Date
Brett Jephson 078eacce45 changeset 2026-09-28 22:19:28 +01:00
Brett Jephson 9f168ff93b Resolve a page link by its recorded path when its page is gone
A page or anchor ref whose page ID no longer exists now looks for a page at
the path recorded on the ref, so a link keeps working after its target is
deleted and re-created at the same path. The ID still wins while it exists,
so moves and renames resolve as before. The API records the path on refs it
writes; until @gitbook/api ships the field, it is read untyped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rt9YwdSUu7gHehYbEkvEJ8
2026-09-28 22:19:18 +01:00
Peter White df2841cf36 Don't leak the internal route into the site auth login link (#4642) 2026-09-28 17:43:23 +02:00
15 changed files with 208 additions and 57 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Track which Ask AI entry point opened the chat in ask view analytics.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep links working when their target page is deleted and re-created at the same path, by falling back to the path recorded on the link.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the site auth login link sometimes redirecting back to an internal URL after login.
+4 -5
View File
@@ -7,7 +7,7 @@ import type { CustomizationAIMode } from '@gitbook/api';
import type { GitBookAssistant } from '@gitbook/browser-types';
import { Icon, type IconName } from '@gitbook/icons';
import { type AskAITrigger, useAIChatController, useAIChatState } from '.';
import { useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations';
import { useSetSearchState } from '../Search/useSearch';
@@ -26,8 +26,7 @@ export type AIConfig = {
};
};
export type Assistant = Omit<GitBookAssistant, 'icon' | 'open'> & {
open: (query?: string, trigger?: AskAITrigger) => void;
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
/**
* Unique identifier for the assistant. Generated automatically using Crypto.randomUUID().
* @example '123e4567-e89b-12d3-a456-426614174000'
@@ -103,8 +102,8 @@ export function useAI(): AIContext {
className="size-text-lg"
/>
),
open: (query?: string, trigger?: AskAITrigger) => {
chatController.open(trigger);
open: (query?: string) => {
chatController.open();
if (query) {
chatController.postMessage({ message: query });
}
@@ -13,7 +13,7 @@ import {
SiteInsightsDisplayContext,
} from '@gitbook/api';
import { getInsightsSession, trackAskView, useTrackEvent } from '../Insights';
import { getInsightsSession, useTrackEvent } from '../Insights';
import { type UpdateSearchState, useSetSearchState } from '../Search';
import { addRecentSearchQuery } from '../Search/recent-queries';
import type { AnyAIControl } from './controls';
@@ -60,8 +60,6 @@ export type AIChatStatus =
| 'error'
| 'confirm';
export type AskAITrigger = 'hover' | 'selection' | 'site' | 'page' | 'embed';
export type AIChatState = {
/**
* If true, the chat is open.
@@ -162,7 +160,7 @@ type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
export type AIChatController = {
/** Open the dialog */
open: (trigger?: AskAITrigger) => void;
open: () => void;
/** Close the dialog */
close: () => void;
/** Post a message to the session */
@@ -304,28 +302,22 @@ export function AIChatProvider(props: {
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
// Open AI chat and sync with search state
const onOpen = React.useCallback(
(trigger?: AskAITrigger) => {
setIntercomLauncherHidden(true);
if (!renderMessageOptions?.asEmbeddable) {
trackAskView(trackEvent, trigger);
}
const onOpen = React.useCallback(() => {
setIntercomLauncherHidden(true);
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
notify(eventsRef.current.get('open'), {});
},
[setSearchState, trackEvent, renderMessageOptions?.asEmbeddable]
);
notify(eventsRef.current.get('open'), {});
}, [setSearchState]);
// Close AI chat and clear ask parameter
const onClose = React.useCallback(() => {
@@ -24,6 +24,7 @@ import {
EmbeddableFrameTitle,
} from '../Embeddable/EmbeddableFrame';
import { useIsMounted, useNow } from '../hooks';
import { useTrackEvent } from '../Insights';
import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
@@ -67,6 +68,16 @@ export function AIChat() {
}
);
// Track the view of the AI chat
const trackEvent = useTrackEvent();
React.useEffect(() => {
if (chat.opened) {
trackEvent({
type: 'ask_view',
});
}
}, [chat.opened, trackEvent]);
return (
<SideSheet
side="right"
@@ -39,7 +39,7 @@ export function AIChatButton(props: {
</div>
}
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
onClick={() => assistant.open(undefined, 'site')}
onClick={() => assistant.open()}
>
{showLabel ? t(language, 'ask') : null}
</Button>
@@ -33,7 +33,7 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
id: `text-${fnv1a(text, { size: 32 })}`,
content: text,
});
chatController.open('hover');
chatController.open();
chatController.setDraft(tString(language, 'ai_chat_paragraph_draft'));
chatController.focus();
};
@@ -84,7 +84,7 @@ export function AskAITextSelection() {
id: `text-${fnv1a(content, { size: 32 })}`,
content,
});
chatController.open('selection');
chatController.open();
chatController.focus();
clear();
};
@@ -4,7 +4,7 @@ import React from 'react';
import * as api from '@gitbook/api';
import { trackAskView, useTrackEvent } from '../Insights';
import { useTrackEvent } from '../Insights';
import { LinkContext } from '../primitives';
import { useIsVisible } from '../VisibilityContext';
import {
@@ -60,10 +60,15 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
return;
}
trackAskView(trackEvent, 'embed', {
pageId: null,
displayContext: api.SiteInsightsDisplayContext.Embed,
});
trackEvent(
{
type: 'ask_view',
},
{
pageId: null,
displayContext: api.SiteInsightsDisplayContext.Embed,
}
);
}, [trackEvent, isVisible]);
const tabsRef = React.useRef<HTMLDivElement>(null);
@@ -6,7 +6,6 @@ import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import type * as api from '@gitbook/api';
import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
import type { AskAITrigger } from '../AI';
import { type CurrentContentContext, useCurrentContent } from '../hooks';
import { getSession } from './sessions';
import { type VisitorResponse, useVisitor } from './visitorId';
@@ -43,7 +42,7 @@ export type TrackEventInput<EventName extends InsightsEventName = InsightsEventN
/**
* Callback to track an event.
*/
export type TrackEventCallback = <EventName extends InsightsEventName>(
type TrackEventCallback = <EventName extends InsightsEventName>(
event: TrackEventInput<EventName>,
ctx?: InsightsEventPageContext,
options?: InsightsEventOptions
@@ -215,16 +214,6 @@ export function useTrackEvent(): TrackEventCallback {
return React.useContext(InsightsContext);
}
/** Track an ask view while the frontend uses an API type that predates its trigger field. */
export function trackAskView(
trackEvent: TrackEventCallback,
trigger: AskAITrigger | undefined,
ctx?: InsightsEventPageContext
) {
// TODO: remove this cast once @gitbook/api includes SiteInsightsEventAskView.trigger.
trackEvent({ type: 'ask_view', trigger } as unknown as TrackEventInput<'ask_view'>, ctx);
}
/**
* Post the events to the server.
*/
@@ -67,7 +67,7 @@ export function ActionOpenAssistant(props: {
href: page.href,
});
}
assistant.open(undefined, 'page');
assistant.open();
}}
/>
);
@@ -4,6 +4,7 @@ import { usePathname, useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import type React from 'react';
import { useIsMounted } from '../hooks/useIsMounted';
import { Button, type ButtonProps } from '../primitives/Button';
import { DropdownMenuItem } from '../primitives/DropdownMenu';
import { Link, type LinkInsightsProps, type LinkProps } from '../primitives/Link';
@@ -16,9 +17,17 @@ function useSiteAuthLoginHrefWithLocation(href: string) {
const rawPathname = usePathname();
const searchParams = useSearchParams();
const currentSearch = searchParams?.toString();
const pathname = rawPathname ?? '/';
// On the server, usePathname() returns the internal rewritten route (/sites/…, which includes
// the site API token), so the location is only added once mounted.
// https://nextjs.org/docs/app/api-reference/functions/use-pathname#avoid-hydration-mismatch-with-rewrites
const isMounted = useIsMounted();
const pathname = isMounted ? (rawPathname ?? '/') : null;
return useMemo(() => {
if (pathname === null) {
return href;
}
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const resolved = URL.canParse(href) ? new URL(href) : new URL(href, baseURL);
const siteBasePath = removeTrailingSlash(
+128
View File
@@ -738,3 +738,131 @@ describe('resolveContentRef for direct space links', () => {
]);
});
});
describe('resolveContentRef for a page ref whose page was re-created', () => {
function buildPage(id: string, title: string, path: string): RevisionPageDocument {
return {
object: 'page',
id,
type: 'document',
kind: 'sheet',
title,
path,
slug: path.split('/').pop(),
pages: [],
tags: [],
layout: {},
urls: { app: 'https://app.gitbook.com/page' },
} as unknown as RevisionPageDocument;
}
function buildRevision(id: string, pages: RevisionPageDocument[]): Revision {
return {
object: 'revision',
id,
type: 'edits',
pages,
files: [],
reusableContents: [],
tags: [],
parents: [],
createdAt: '',
urls: { app: '' },
} as unknown as Revision;
}
function buildSpace(id: string): Space {
return {
object: 'space',
id,
title: id,
organization: 'org',
revision: `rev-${id}`,
urls: {
location: `https://api.gitbook.com/spaces/${id}`,
app: `https://app.gitbook.com/o/org/s/${id}/`,
published: `https://${id}.gitbook.io/`,
},
} as unknown as Space;
}
const recreated = buildPage('page-new', 'Detector', 'alerts-by-name/detector');
const other = buildPage('page-other', 'Other', 'alerts-by-name/other');
const alertsRevision = buildRevision('rev-alerts', [recreated, other]);
const alerts = buildSpace('alerts');
const notes = buildSpace('notes');
const dataFetcher = {
getSpace: async ({ spaceId }: { spaceId: string }) =>
spaceId === 'alerts'
? { data: alerts }
: { error: { code: 404, message: 'Not found' } },
getRevision: async ({ spaceId }: { spaceId: string }) =>
spaceId === 'alerts'
? { data: alertsRevision }
: { error: { code: 404, message: 'Not found' } },
getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }),
withToken: function () {
return this;
},
} as unknown as GitBookDataFetcher;
function buildContext(space: Space, revision: Revision): GitBookAnyContext {
return {
dataFetcher,
linker: createLinker({
host: 'docs.example.com',
spaceBasePath: '/',
siteBasePath: '/',
}),
organizationId: 'org',
space,
revision,
revisionId: revision.id,
changeRequest: null,
shareKey: undefined,
} as unknown as GitBookAnyContext;
}
// Refs only get a typed `path` once @gitbook/api ships it, so the tests add it through a cast.
const withPath = (ref: object, path: string) => ({ ...ref, path }) as never;
it('finds the page at the recorded path when its ID no longer exists', async () => {
const result = await resolveContentRef(
withPath({ kind: 'page', page: 'page-deleted' }, 'alerts-by-name/detector'),
buildContext(alerts, alertsRevision)
);
expect(result?.text).toBe('Detector');
});
it('finds the page at the recorded path in another space', async () => {
const result = await resolveContentRef(
withPath(
{ kind: 'page', space: 'alerts', page: 'page-deleted' },
'alerts-by-name/detector'
),
buildContext(notes, buildRevision('rev-notes', []))
);
expect(result?.text).toBe('Detector');
});
it('keeps resolving by ID while the page still exists', async () => {
const result = await resolveContentRef(
withPath({ kind: 'page', page: 'page-other' }, 'alerts-by-name/detector'),
buildContext(alerts, alertsRevision)
);
expect(result?.text).toBe('Other');
});
it('does not resolve when neither the ID nor the recorded path exists', async () => {
const result = await resolveContentRef(
withPath({ kind: 'page', page: 'page-deleted' }, 'alerts-by-name/removed'),
buildContext(alerts, alertsRevision)
);
expect(result).toBeNull();
});
});
+15 -2
View File
@@ -4,6 +4,7 @@ import type React from 'react';
import type {
ContentRef,
JSONDocument,
Revision,
RevisionFile,
RevisionPageDocument,
RevisionReusableContent,
@@ -16,7 +17,7 @@ import type { Filesystem } from '@gitbook/openapi-parser';
import { getGitBookAppHref } from './app';
import { getBlockById, getBlockTitle } from './document';
import { resolvePageId } from './pages';
import { resolvePageId, resolvePagePath } from './pages';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
@@ -192,7 +193,8 @@ export async function resolveContentRef(
? activePage
? { page: activePage, ancestors: [] }
: undefined
: resolvePageId(revision.pages, contentRef.page);
: (resolvePageId(revision.pages, contentRef.page) ??
resolvePageAtRecordedPath(revision, contentRef));
const page = resolvePageResult?.page;
const ancestors =
@@ -430,6 +432,17 @@ export function resolveContentRefFallback(contentRef: ContentRef): ResolvedConte
return null;
}
/**
* Find the page at the path recorded on a page or anchor ref, for when its page ID no longer
* exists (the page was deleted and re-created at the same path).
*/
function resolvePageAtRecordedPath(revision: Revision, contentRef: ContentRef) {
// TODO: read `contentRef.path` directly once @gitbook/api ships it on ContentRefPage and
// ContentRefAnchor; until then the field is untyped.
const { path } = contentRef as { path?: unknown };
return typeof path === 'string' && path ? resolvePagePath(revision.pages, path) : undefined;
}
/**
* This function is used to get the best possible target space while resolving a content ref.
* It will try to return the space in the site context if it exists to avoid cross-site links.