Compare commits

...

2 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
3 changed files with 148 additions and 2 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.
+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.