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
5 changed files with 163 additions and 3 deletions
+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,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.