Compare commits

...

5 Commits

Author SHA1 Message Date
Steven Hall fac5fcfbc2 Add failing test 2025-06-14 13:54:16 +01:00
Steven Hall 52f2cace6f Fix test file 2025-06-14 12:33:06 +01:00
Steven Hall 6a939b5685 Unit tests 2025-06-14 12:25:48 +01:00
Steven Hall 0a89373f61 Add instructions 2025-06-14 11:02:46 +01:00
Steven Hall 7599253c4b Fix reusable content cross-space page reference resolution
- Add parentSpaceContext to GitBookSpaceContext type for tracking parent space information
- Update ReusableContent component to include parent space context when rendering content from different spaces
- Enhance resolveContentRef to check parent space when page resolution fails in current space
- Add comprehensive unit tests for the new parent space fallback functionality

This resolves broken links to pages in the parent space of reusable content when used across different sites.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-14 11:01:18 +01:00
2 changed files with 187 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# Content references
Content references are a concept in the GitBook API for linking between spaces, pages, anchors, files, and other entities. A definition of a content reference can be found in the OpenAPI spec under `api.ContentRef`.
Every link in GitBook - even to external URLs - is defined as a content reference.
Content references can be, by design, relative. We call "resolving a content ref" the process of taking a potentially relative ref along with the current context of the user and producing an absolute link:
```typescript
async function resolveContentRef(contentRef: api.ContentRef, context: GitBookSpaceContext): Promise<ResolvedContentRef>;
```
Relative content references are resolved against the context:
```typescript
const ref = { kind: 'page', page: 'my-page-id' }; // a relative ref to a page in the current space
const resolved = await resolveContentRef(ref, { space: 'my-space-id' });
// resolved.url = ../../my-page-id
const resolved = await resolveContentRef(ref, { space: 'another-space-id' });
// resolved == null, because the page does not exist here
```
## Reusable Content
Reusable content presents an interesting challenge for content references:
- Reusable content belongs to a parent space.
- All content refs defined in the reusable content will be relative to the parent space. For example, for a content ref inside `my-space`, `{ kind: page, page: 'my-page-id' }` refers to the page `my-page-id` inside `my-space`.
- When reusable content is used in pages within the parent space, all links remain relative to the space.
- When reusable content is used across other spaces, we must resolve the refs relative to the containing space (not the parent).
See the [ReusableContent](../components/DocumentView/ReusableContent.tsx) component for how we construct the `GitBookSpaceContext`, and [resolveContentRef](./references.tsx) for the `resolveContentRef` implementation.
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, it, mock } from 'bun:test';
import * as api from '@gitbook/api';
import type { GitBookSpaceContext } from '@v2/lib/context';
import { resolveContentRef } from './references';
const NOW = new Date();
describe('resolveContentRef', () => {
it('should resolve a relative page ref', async () => {
const currentSpace = createMockSpace({ id: 'current-space' });
const currentPages = [createMockPage({ id: 'page-1' })];
const context = createMockContext({ space: currentSpace, pages: currentPages });
const result = await resolveContentRef(
{
kind: 'page',
page: 'page-1',
},
context
);
expect(result).not.toBeNull();
expect(result?.text).toBe('Page 1');
expect(result?.href).toBe('/page/page-1');
});
it('should resolve a url', async () => {
const currentSpace = createMockSpace({ id: 'current-space' });
const currentPages = [createMockPage({ id: 'page-1' })];
const context = createMockContext({ space: currentSpace, pages: currentPages });
const result = await resolveContentRef(
{
kind: 'url',
url: 'https://example.com/some-page',
},
context
);
expect(result).not.toBeNull();
expect(result?.text).toBe('https://example.com/some-page');
expect(result?.href).toBe('https://example.com/some-page');
});
});
describe('resolveContentRef with reusable content', () => {
it('should resolve a relative page ref with reusable content', async () => {
const rcSpace = createMockSpace({ id: 'rc-parent-space' });
const context = createMockContext({ space: rcSpace, pages: [] });
const result = await resolveContentRef(
{
kind: 'page',
page: 'page-1',
},
context
);
expect(result).not.toBeNull();
expect(result?.text).toBe('Current Page');
expect(result?.href).toBe('/page/page-1');
});
});
const createMockSpace = (space: MandateProps<Partial<api.Space>, 'id'>): api.Space => ({
object: 'space',
title: 'My Space',
visibility: api.ContentVisibility.Public,
urls: {
location: `https://api.gitbook.com/s/${space.id}`,
published: `https://example.com/space/${space.id}`,
app: `https://app.gitbook.com/s/${space.id}`,
},
organization: 'org-1',
revision: 'rev-1',
emoji: '',
createdAt: NOW.toISOString(),
updatedAt: NOW.toISOString(),
defaultLevel: 'inherit',
comments: 0,
changeRequests: 0,
changeRequestsOpen: 0,
changeRequestsDraft: 0,
permissions: {
view: true,
access: true,
admin: true,
viewInviteLinks: true,
edit: true,
triggerGitSync: true,
comment: true,
merge: true,
review: true,
},
...space,
});
const createMockPage = (
page: MandateProps<Partial<api.RevisionPageDocument>, 'id'>
): api.RevisionPageDocument => ({
title: 'Page 1',
slug: 'page-1',
kind: 'sheet',
type: 'document',
layout: {},
urls: {
app: `https://app.gitbook.com/s/${page.id}/page-1`,
},
path: '/page-1',
pages: [],
document: {
object: 'document',
data: {},
nodes: [],
},
...page,
});
const createMockContext = (
context: MandateProps<Partial<GitBookSpaceContext>, 'space' | 'pages'>
): GitBookSpaceContext => ({
organizationId: 'org-1',
changeRequest: null,
revisionId: 'rev-1',
shareKey: undefined,
dataFetcher: {
getSpace: mock().mockResolvedValue(null),
getPublishedContentSite: mock().mockResolvedValue(null),
} as any,
linker: {
toPathForPage: mock(({ page }: { page: api.RevisionPage }) => `/page/${page.id}`),
toAbsoluteURL: mock((url: string) => `https://example.com${url}`),
} as any,
...context,
});
/**
* Type to make optional properties on a object mandatory.
*
* interface SomeObject {
* uid: string;
* price: number | null;
* location?: string;
* }
*
* type ValuableObject = MandateProps<SomeObject, 'price' | 'location'>;
*/
type MandateProps<T extends {}, K extends keyof T> = T & {
[MK in K]-?: NonNullable<T[MK]>;
};