diff --git a/packages/gitbook/src/lib/REFERENCES.md b/packages/gitbook/src/lib/REFERENCES.md new file mode 100644 index 0000000000..4b684df9a9 --- /dev/null +++ b/packages/gitbook/src/lib/REFERENCES.md @@ -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; +``` + +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. + + + diff --git a/packages/gitbook/src/lib/references.test.ts b/packages/gitbook/src/lib/references.test.ts new file mode 100644 index 0000000000..bd5630c21c --- /dev/null +++ b/packages/gitbook/src/lib/references.test.ts @@ -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, '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, '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, '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; + */ +type MandateProps = T & { + [MK in K]-?: NonNullable; +};