Skip to content

Fix reusable content cross-space page reference resolution #3321

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/gitbook/src/lib/REFERENCES.md
Original file line number Diff line number Diff line change
@@ -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 changes: 151 additions & 0 deletions packages/gitbook/src/lib/references.test.ts
Original file line number Diff line number Diff line change
@@ -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();

Check failure on line 60 in packages/gitbook/src/lib/references.test.ts

View workflow job for this annotation

GitHub Actions / Test

error: expect(received).not.toBeNull()

Received: null at <anonymous> (/home/runner/work/gitbook/gitbook/packages/gitbook/src/lib/references.test.ts:60:28)
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]>;
};
Loading