From 9f168ff93b308b3a59c5516d4e38e3c2f1e35fe2 Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Mon, 28 Sep 2026 22:19:18 +0100 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01Rt9YwdSUu7gHehYbEkvEJ8 --- packages/gitbook/src/lib/references.test.ts | 128 ++++++++++++++++++++ packages/gitbook/src/lib/references.tsx | 17 ++- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/packages/gitbook/src/lib/references.test.ts b/packages/gitbook/src/lib/references.test.ts index 4342f5a78..d49d059ba 100644 --- a/packages/gitbook/src/lib/references.test.ts +++ b/packages/gitbook/src/lib/references.test.ts @@ -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(); + }); +}); diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index aede45c39..62c067fa8 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -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.