mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-15 01:26:01 +00:00
959 lines
34 KiB
TypeScript
959 lines
34 KiB
TypeScript
import { describe, expect, it, mock } from 'bun:test';
|
|
|
|
import type { Revision, RevisionPageDocument, SiteSpace, Space } from '@gitbook/api';
|
|
|
|
import { resolveContentRef, resolveStringContentRef } from './references';
|
|
import type { GitBookAnyContext } from '@/lib/context';
|
|
import type { GitBookDataFetcher } from '@/lib/data';
|
|
import { createLinker } from '@/lib/links';
|
|
|
|
describe('resolveStringContentRef', () => {
|
|
it.each([
|
|
{
|
|
label: 'an external URL',
|
|
input: 'https://docs.gitbook.com/product-tour',
|
|
expected: {
|
|
kind: 'url',
|
|
url: 'https://docs.gitbook.com/product-tour',
|
|
},
|
|
},
|
|
{
|
|
label: 'a page ref in the current space',
|
|
input: '/pages/page_123',
|
|
expected: {
|
|
kind: 'page',
|
|
page: 'page_123',
|
|
},
|
|
},
|
|
{
|
|
label: 'a page ref in another space',
|
|
input: '/spaces/space-123/pages/page_123',
|
|
expected: {
|
|
kind: 'page',
|
|
space: 'space-123',
|
|
page: 'page_123',
|
|
},
|
|
},
|
|
{
|
|
label: 'an anchor in the current page',
|
|
input: '#heading-1',
|
|
expected: {
|
|
kind: 'anchor',
|
|
anchor: 'heading-1',
|
|
},
|
|
},
|
|
{
|
|
label: 'an anchor on a page in another space',
|
|
input: '/spaces/space-123/pages/page-123#heading_1',
|
|
expected: {
|
|
kind: 'anchor',
|
|
space: 'space-123',
|
|
page: 'page-123',
|
|
anchor: 'heading_1',
|
|
},
|
|
},
|
|
{
|
|
label: 'a file ref',
|
|
input: '/spaces/space-123/files/file_123',
|
|
expected: {
|
|
kind: 'file',
|
|
space: 'space-123',
|
|
file: 'file_123',
|
|
},
|
|
},
|
|
{
|
|
label: 'a space ref',
|
|
input: '/spaces/space-123',
|
|
expected: {
|
|
kind: 'space',
|
|
space: 'space-123',
|
|
},
|
|
},
|
|
{
|
|
label: 'a collection ref',
|
|
input: '/collections/collection-123',
|
|
expected: {
|
|
kind: 'collection',
|
|
collection: 'collection-123',
|
|
},
|
|
},
|
|
{
|
|
label: 'a user ref',
|
|
input: '/users/user_123',
|
|
expected: {
|
|
kind: 'user',
|
|
user: 'user_123',
|
|
},
|
|
},
|
|
{
|
|
label: 'a reusable content ref',
|
|
input: '/spaces/space-123/reusable-content/reusable_123',
|
|
expected: {
|
|
kind: 'reusable-content',
|
|
space: 'space-123',
|
|
reusableContent: 'reusable_123',
|
|
},
|
|
},
|
|
{
|
|
label: 'a tag ref',
|
|
input: '/spaces/space-123/tags/tag_123',
|
|
expected: {
|
|
kind: 'tag',
|
|
space: 'space-123',
|
|
tag: 'tag_123',
|
|
},
|
|
},
|
|
{
|
|
label: 'an OpenAPI ref',
|
|
input: '/openapi/spec-v1',
|
|
expected: {
|
|
kind: 'openapi',
|
|
spec: 'spec-v1',
|
|
},
|
|
},
|
|
])('parses $label', ({ input, expected }) => {
|
|
// @ts-expect-error
|
|
expect(resolveStringContentRef(input)).toEqual(expected);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: 'a relative page path',
|
|
input: 'getting-started',
|
|
},
|
|
{
|
|
label: 'a page path with an extra segment',
|
|
input: '/pages/page-123/child',
|
|
},
|
|
{
|
|
label: 'a missing page identifier',
|
|
input: '/pages/',
|
|
},
|
|
{
|
|
label: 'a space path with a trailing slash',
|
|
input: '/spaces/space-123/',
|
|
},
|
|
{
|
|
label: 'an OpenAPI path with nested segments',
|
|
input: '/openapi/spec/v1',
|
|
},
|
|
])('returns null for $label', ({ input }) => {
|
|
expect(resolveStringContentRef(input)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resolveContentRef', () => {
|
|
it("resolves a page in a different space using that space's page data, not the current context page", async () => {
|
|
const sharedPageId = 'page-shared';
|
|
|
|
const spaceAPage = {
|
|
object: 'page',
|
|
id: sharedPageId,
|
|
type: 'document',
|
|
kind: 'sheet',
|
|
title: 'Space A Page',
|
|
path: 'space-a-page',
|
|
slug: 'space-a-page',
|
|
pages: [],
|
|
tags: [],
|
|
layout: {},
|
|
urls: { app: 'https://app.gitbook.com/page' },
|
|
} as unknown as RevisionPageDocument;
|
|
|
|
const spaceBPage = {
|
|
object: 'page',
|
|
id: sharedPageId,
|
|
type: 'document',
|
|
kind: 'sheet',
|
|
title: 'Space B Page',
|
|
path: 'space-b-page',
|
|
slug: 'space-b-page',
|
|
pages: [],
|
|
tags: [],
|
|
layout: {},
|
|
urls: { app: 'https://app.gitbook.com/page' },
|
|
} as unknown as RevisionPageDocument;
|
|
|
|
const revisionA = {
|
|
object: 'revision',
|
|
id: 'rev-a',
|
|
type: 'edits',
|
|
pages: [spaceAPage],
|
|
files: [],
|
|
reusableContents: [],
|
|
tags: [],
|
|
parents: [],
|
|
createdAt: '',
|
|
urls: { app: '' },
|
|
} as unknown as Revision;
|
|
|
|
const revisionB = {
|
|
object: 'revision',
|
|
id: 'rev-b',
|
|
type: 'edits',
|
|
pages: [spaceBPage],
|
|
files: [],
|
|
reusableContents: [],
|
|
tags: [],
|
|
parents: [],
|
|
createdAt: '',
|
|
urls: { app: '' },
|
|
} as unknown as Revision;
|
|
|
|
const spaceA = {
|
|
object: 'space',
|
|
id: 'space-a',
|
|
title: 'Space A',
|
|
organization: 'org',
|
|
revision: 'rev-a',
|
|
urls: {
|
|
location: 'https://api.gitbook.com/spaces/space-a',
|
|
app: 'https://app.gitbook.com/o/org/s/space-a/',
|
|
published: 'https://space-a.gitbook.io/',
|
|
},
|
|
} as unknown as Space;
|
|
|
|
const spaceB = {
|
|
object: 'space',
|
|
id: 'space-b',
|
|
title: 'Space B',
|
|
organization: 'org',
|
|
revision: 'rev-b',
|
|
urls: {
|
|
location: 'https://api.gitbook.com/spaces/space-b',
|
|
app: 'https://app.gitbook.com/o/org/s/space-b/',
|
|
published: 'https://space-b.gitbook.io/',
|
|
},
|
|
} as unknown as Space;
|
|
|
|
const dataFetcher = {
|
|
getSpace: async ({ spaceId }: { spaceId: string; shareKey: string | undefined }) => {
|
|
if (spaceId === 'space-b') return { data: spaceB };
|
|
return { error: { code: 404, message: 'Not found' } };
|
|
},
|
|
getRevision: async ({ spaceId }: { spaceId: string; revisionId: string }) => {
|
|
if (spaceId === 'space-b') return { data: revisionB };
|
|
return { error: { code: 404, message: 'Not found' } };
|
|
},
|
|
getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }),
|
|
withToken: function () {
|
|
return this;
|
|
},
|
|
} as unknown as GitBookDataFetcher;
|
|
|
|
const context: GitBookAnyContext = {
|
|
dataFetcher,
|
|
linker: createLinker({
|
|
host: 'docs.example.com',
|
|
spaceBasePath: '/space-a/',
|
|
siteBasePath: '/',
|
|
}),
|
|
organizationId: 'org',
|
|
space: spaceA,
|
|
revision: revisionA,
|
|
revisionId: 'rev-a',
|
|
changeRequest: null,
|
|
shareKey: undefined,
|
|
// This page (from space A) has the same ID as the target page in space B.
|
|
// It should NOT contaminate resolution in space B.
|
|
page: spaceAPage,
|
|
} as unknown as GitBookAnyContext;
|
|
|
|
const result = await resolveContentRef(
|
|
{ kind: 'page', space: 'space-b', page: sharedPageId },
|
|
context
|
|
);
|
|
|
|
expect(result?.text).toBe('Space B Page');
|
|
});
|
|
});
|
|
|
|
describe('resolveContentRef for direct space links', () => {
|
|
function buildSpace(id: string, title: string): Space {
|
|
return {
|
|
object: 'space',
|
|
id,
|
|
title,
|
|
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;
|
|
}
|
|
|
|
function buildSiteSpace(space: Space, title: string): SiteSpace {
|
|
return {
|
|
object: 'site-space',
|
|
id: `site-${space.id}`,
|
|
path: '',
|
|
space,
|
|
title,
|
|
draft: false,
|
|
urls: { published: `https://site.gitbook.io/${space.id}/` },
|
|
} as unknown as SiteSpace;
|
|
}
|
|
|
|
function buildContext(overrides: Record<string, unknown>): GitBookAnyContext {
|
|
const currentSpace = buildSpace('space-current', 'Current Space');
|
|
|
|
const dataFetcher = {
|
|
getSpace: async () => ({ error: { code: 404, message: 'Not found' } }),
|
|
getRevision: async () => ({ error: { code: 404, message: 'Not found' } }),
|
|
getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }),
|
|
withToken: function () {
|
|
return this;
|
|
},
|
|
} as unknown as GitBookDataFetcher;
|
|
|
|
return {
|
|
dataFetcher,
|
|
linker: createLinker({
|
|
host: 'docs.example.com',
|
|
spaceBasePath: '/space-current/',
|
|
siteBasePath: '/',
|
|
}),
|
|
organizationId: 'org',
|
|
space: currentSpace,
|
|
revision: {
|
|
object: 'revision',
|
|
id: 'rev-space-current',
|
|
pages: [],
|
|
files: [],
|
|
reusableContents: [],
|
|
},
|
|
revisionId: 'rev-space-current',
|
|
changeRequest: null,
|
|
shareKey: undefined,
|
|
site: { object: 'site', id: 'site-1' },
|
|
...overrides,
|
|
} as unknown as GitBookAnyContext;
|
|
}
|
|
|
|
it('returns the site-space title with the section as a breadcrumb ancestor', async () => {
|
|
const targetSpace = buildSpace('space-target', 'Target Space Title');
|
|
const siteSpace = buildSiteSpace(targetSpace, 'Variant Title');
|
|
const section = {
|
|
object: 'site-section',
|
|
id: 'section-1',
|
|
title: 'Reference',
|
|
draft: false,
|
|
path: '',
|
|
siteSpaces: [siteSpace],
|
|
urls: {},
|
|
};
|
|
|
|
const context = buildContext({
|
|
siteSpace,
|
|
sections: { list: [section], current: section },
|
|
structure: { type: 'sections', structure: [section] },
|
|
});
|
|
|
|
const result = await resolveContentRef({ kind: 'space', space: 'space-current' }, context);
|
|
|
|
expect(result?.text).toBe('Variant Title');
|
|
expect(result?.ancestors).toEqual([{ label: 'Reference' }]);
|
|
expect(result?.href).toBe(siteSpace.urls.published!);
|
|
expect(result?.active).toBe(true);
|
|
});
|
|
|
|
it('returns the localized site-space title with a localized section breadcrumb', async () => {
|
|
const targetSpace = buildSpace('space-target', 'Target Space Title');
|
|
const siteSpace = buildSiteSpace(targetSpace, 'Variant Title');
|
|
const section = {
|
|
object: 'site-section',
|
|
id: 'section-1',
|
|
title: 'Reference',
|
|
localizedTitle: { fr: 'Référence' },
|
|
draft: false,
|
|
path: '',
|
|
siteSpaces: [siteSpace],
|
|
urls: {},
|
|
};
|
|
|
|
const contextFr = buildContext({
|
|
siteSpace,
|
|
sections: { list: [section], current: section },
|
|
structure: { type: 'sections', structure: [section] },
|
|
locale: 'fr',
|
|
});
|
|
|
|
const resultFr = await resolveContentRef(
|
|
{ kind: 'space', space: 'space-current' },
|
|
contextFr
|
|
);
|
|
expect(resultFr?.text).toBe('Variant Title');
|
|
expect(resultFr?.ancestors).toEqual([{ label: 'Référence' }]);
|
|
|
|
const contextEn = buildContext({
|
|
siteSpace,
|
|
sections: { list: [section], current: section },
|
|
structure: { type: 'sections', structure: [section] },
|
|
locale: 'en',
|
|
});
|
|
|
|
const resultEn = await resolveContentRef(
|
|
{ kind: 'space', space: 'space-current' },
|
|
contextEn
|
|
);
|
|
expect(resultEn?.text).toBe('Variant Title');
|
|
expect(resultEn?.ancestors).toEqual([{ label: 'Reference' }]);
|
|
});
|
|
|
|
it('falls back to the site-space title when the site has no sections', async () => {
|
|
const targetSpace = buildSpace('space-target', 'Target Space Title');
|
|
const siteSpace = buildSiteSpace(targetSpace, 'Variant Title');
|
|
|
|
const context = buildContext({
|
|
siteSpace,
|
|
sections: null,
|
|
structure: { type: 'siteSpaces', structure: [siteSpace] },
|
|
});
|
|
|
|
const result = await resolveContentRef({ kind: 'space', space: 'space-current' }, context);
|
|
|
|
expect(result?.text).toBe('Variant Title');
|
|
});
|
|
|
|
it('falls back to the raw space title for an external/unmapped space', async () => {
|
|
const externalSpace = buildSpace('space-external', 'External Space Title');
|
|
|
|
const dataFetcher = {
|
|
getSpace: async ({ spaceId }: { spaceId: string }) => {
|
|
if (spaceId === 'space-external') return { data: externalSpace };
|
|
return { error: { code: 404, message: 'Not found' } };
|
|
},
|
|
getRevision: async () => ({ error: { code: 404, message: 'Not found' } }),
|
|
getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }),
|
|
withToken: function () {
|
|
return this;
|
|
},
|
|
} as unknown as GitBookDataFetcher;
|
|
|
|
const context = buildContext({
|
|
siteSpace: buildSiteSpace(
|
|
buildSpace('space-current', 'Current Space'),
|
|
'Current Variant'
|
|
),
|
|
sections: null,
|
|
structure: { type: 'siteSpaces', structure: [] },
|
|
dataFetcher,
|
|
});
|
|
|
|
const result = await resolveContentRef({ kind: 'space', space: 'space-external' }, context);
|
|
|
|
expect(result?.text).toBe('External Space Title');
|
|
expect(result?.href).toBe(externalSpace.urls.published!);
|
|
expect(result?.active).toBe(false);
|
|
});
|
|
|
|
it('returns the target site-space title for a cross-space link within the same site section', async () => {
|
|
const currentSpace = buildSpace('space-current', 'Practical Guides Hub');
|
|
const guideSpace = buildSpace('space-guide', 'SNOMED CT EHR Requirements Guide');
|
|
const currentSiteSpace = buildSiteSpace(currentSpace, 'Practical Guides');
|
|
const guideSiteSpace = buildSiteSpace(guideSpace, 'SNOMED CT EHR Requirements Guide');
|
|
guideSiteSpace.urls = {
|
|
published: 'https://docs.example.com/guides/snomed-ct-ehr-requirements-guide/',
|
|
};
|
|
|
|
const section = {
|
|
object: 'site-section',
|
|
id: 'section-1',
|
|
title: 'Practical Guides',
|
|
draft: false,
|
|
path: '',
|
|
siteSpaces: [currentSiteSpace, guideSiteSpace],
|
|
urls: {},
|
|
};
|
|
|
|
const dataFetcher = {
|
|
getSpace: async () => ({ error: { code: 404, message: 'Not found' } }),
|
|
getRevision: async () => ({ error: { code: 404, message: 'Not found' } }),
|
|
getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }),
|
|
withToken: function () {
|
|
return this;
|
|
},
|
|
} as unknown as GitBookDataFetcher;
|
|
|
|
const context = buildContext({
|
|
siteSpace: currentSiteSpace,
|
|
sections: { list: [section], current: section },
|
|
structure: { type: 'sections', structure: [section] },
|
|
dataFetcher,
|
|
});
|
|
|
|
const result = await resolveContentRef({ kind: 'space', space: 'space-guide' }, context);
|
|
|
|
expect(result?.text).toBe('SNOMED CT EHR Requirements Guide');
|
|
expect(result?.ancestors).toEqual([{ label: 'Practical Guides' }]);
|
|
expect(result?.href).toBe(guideSiteSpace.urls.published!);
|
|
expect(result?.active).toBe(false);
|
|
});
|
|
|
|
function buildDocumentPage(
|
|
id: string,
|
|
title: string,
|
|
path: string,
|
|
pages: unknown[] = []
|
|
): RevisionPageDocument {
|
|
return {
|
|
object: 'page',
|
|
id,
|
|
type: 'document',
|
|
kind: 'sheet',
|
|
title,
|
|
path,
|
|
slug: path.split('/').pop() ?? path,
|
|
pages,
|
|
tags: [],
|
|
layout: {},
|
|
urls: { app: `https://app.gitbook.com/page/${id}` },
|
|
} as unknown as RevisionPageDocument;
|
|
}
|
|
|
|
function buildRevision(pages: unknown[]): Revision {
|
|
return {
|
|
object: 'revision',
|
|
id: 'rev-space-target',
|
|
type: 'edits',
|
|
pages,
|
|
files: [],
|
|
reusableContents: [],
|
|
tags: [],
|
|
parents: [],
|
|
createdAt: '',
|
|
urls: { app: '' },
|
|
} as unknown as Revision;
|
|
}
|
|
|
|
function buildCrossSpaceContext(
|
|
targetSpace: Space,
|
|
targetRevision: Revision,
|
|
structure: unknown
|
|
) {
|
|
const currentSiteSpace = buildSiteSpace(
|
|
buildSpace('space-current', 'Current Space'),
|
|
'Current Variant'
|
|
);
|
|
const dataFetcher = {
|
|
getSpace: async ({ spaceId }: { spaceId: string }) =>
|
|
spaceId === targetSpace.id
|
|
? { data: targetSpace }
|
|
: { error: { code: 404, message: 'Not found' } },
|
|
getRevision: async ({ spaceId }: { spaceId: string }) =>
|
|
spaceId === targetSpace.id
|
|
? { data: targetRevision }
|
|
: { error: { code: 404, message: 'Not found' } },
|
|
getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }),
|
|
withToken: function () {
|
|
return this;
|
|
},
|
|
} as unknown as GitBookDataFetcher;
|
|
|
|
return buildContext({
|
|
siteSpace: currentSiteSpace,
|
|
structure,
|
|
dataFetcher,
|
|
});
|
|
}
|
|
|
|
it('prepends every localized section group and section to cross-space page ancestors', async () => {
|
|
const targetSpace = buildSpace('space-target', 'Target Space');
|
|
const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
|
|
targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
|
|
const pageGroup = {
|
|
object: 'page',
|
|
id: 'page-group',
|
|
type: 'group',
|
|
kind: 'group',
|
|
title: 'Getting started',
|
|
path: 'getting-started',
|
|
slug: 'getting-started',
|
|
pages: [buildDocumentPage('page-target', 'Target page', 'getting-started/target')],
|
|
};
|
|
const section = {
|
|
object: 'site-section',
|
|
id: 'section-target',
|
|
title: 'Reference',
|
|
localizedTitle: { fr: 'Référence' },
|
|
draft: false,
|
|
path: 'reference',
|
|
siteSpaces: [targetSiteSpace],
|
|
urls: {},
|
|
};
|
|
const sectionGroup = {
|
|
object: 'site-section-group',
|
|
id: 'section-group-target',
|
|
title: 'Product documentation',
|
|
localizedTitle: { fr: 'Documentation produit' },
|
|
draft: false,
|
|
sections: [],
|
|
children: [
|
|
{
|
|
object: 'site-section-group',
|
|
id: 'section-group-nested',
|
|
title: 'API guides',
|
|
draft: false,
|
|
sections: [],
|
|
children: [
|
|
{
|
|
object: 'site-section-group',
|
|
id: 'section-group-child',
|
|
title: 'Authentication',
|
|
localizedTitle: { fr: 'Authentification' },
|
|
draft: false,
|
|
sections: [section],
|
|
children: [section],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const context = buildCrossSpaceContext(targetSpace, buildRevision([pageGroup]), {
|
|
type: 'sections',
|
|
structure: [sectionGroup],
|
|
});
|
|
|
|
const result = await resolveContentRef(
|
|
{ kind: 'page', space: targetSpace.id, page: 'page-target' },
|
|
{ ...context, locale: 'fr' } as GitBookAnyContext
|
|
);
|
|
|
|
expect(result?.text).toBe('Target page');
|
|
expect(result?.ancestors).toEqual([
|
|
{ label: 'Documentation produit' },
|
|
{ label: 'API guides' },
|
|
{ label: 'Authentification' },
|
|
{ label: 'Référence', href: targetSiteSpace.urls.published },
|
|
{ label: 'Getting started', icon: null },
|
|
]);
|
|
expect(result?.ancestors?.[0]?.href).toBeUndefined();
|
|
expect(result?.ancestors?.[1]?.href).toBeUndefined();
|
|
expect(result?.ancestors?.[2]?.href).toBeUndefined();
|
|
expect(result?.ancestors?.[4]?.href).toBeUndefined();
|
|
});
|
|
|
|
it('uses the first document as the target for a page-group reference', async () => {
|
|
const targetSpace = buildSpace('space-target', 'Target Space');
|
|
const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
|
|
targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
|
|
const pageGroup = {
|
|
object: 'page',
|
|
id: 'page-group',
|
|
type: 'group',
|
|
kind: 'group',
|
|
title: 'Learn about Cortex Agentix',
|
|
path: 'cortex-agentix',
|
|
slug: 'cortex-agentix',
|
|
pages: [buildDocumentPage('page-target', 'Cortex Agentix docs', 'cortex-agentix/docs')],
|
|
};
|
|
const context = buildCrossSpaceContext(targetSpace, buildRevision([pageGroup]), {
|
|
type: 'siteSpaces',
|
|
structure: [targetSiteSpace],
|
|
});
|
|
|
|
const result = await resolveContentRef(
|
|
{ kind: 'page', space: targetSpace.id, page: 'page-group' },
|
|
context
|
|
);
|
|
|
|
expect(result?.page?.id).toBe('page-target');
|
|
expect(result?.text).toBe('Learn about Cortex Agentix');
|
|
expect(result?.ancestors?.map((ancestor) => ancestor.label)).toEqual([
|
|
'Target Variant',
|
|
'Learn about Cortex Agentix',
|
|
]);
|
|
expect(
|
|
result?.ancestors?.filter(({ label }) => label === 'Learn about Cortex Agentix')
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it('uses the section instead of the variant for an ungrouped cross-space page', async () => {
|
|
const targetSpace = buildSpace('space-target', 'Target Space');
|
|
const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
|
|
targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
|
|
const section = {
|
|
object: 'site-section',
|
|
id: 'section-target',
|
|
title: 'Reference',
|
|
draft: false,
|
|
path: 'reference',
|
|
siteSpaces: [targetSiteSpace],
|
|
urls: {},
|
|
};
|
|
const context = buildCrossSpaceContext(
|
|
targetSpace,
|
|
buildRevision([buildDocumentPage('page-target', 'Target page', 'target')]),
|
|
{ type: 'sections', structure: [section] }
|
|
);
|
|
|
|
const result = await resolveContentRef(
|
|
{ kind: 'page', space: targetSpace.id, page: 'page-target' },
|
|
context
|
|
);
|
|
|
|
expect(result?.ancestors).toEqual([
|
|
{ label: 'Reference', href: targetSiteSpace.urls.published },
|
|
]);
|
|
});
|
|
|
|
it('falls back to the target variant for a sectionless in-site page', async () => {
|
|
const targetSpace = buildSpace('space-target', 'Target Space');
|
|
const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
|
|
targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
|
|
const context = buildCrossSpaceContext(
|
|
targetSpace,
|
|
buildRevision([buildDocumentPage('page-target', 'Target page', 'target')]),
|
|
{ type: 'siteSpaces', structure: [targetSiteSpace] }
|
|
);
|
|
|
|
const result = await resolveContentRef(
|
|
{ kind: 'page', space: targetSpace.id, page: 'page-target' },
|
|
context
|
|
);
|
|
|
|
expect(result?.ancestors).toEqual([
|
|
{ label: 'Target Variant', href: targetSiteSpace.urls.published },
|
|
]);
|
|
});
|
|
|
|
it('falls back to the raw space title for an external page', async () => {
|
|
const targetSpace = buildSpace('space-external', 'External Space');
|
|
const context = buildCrossSpaceContext(
|
|
targetSpace,
|
|
buildRevision([buildDocumentPage('page-target', 'External page', 'target')]),
|
|
{ type: 'siteSpaces', structure: [] }
|
|
);
|
|
|
|
const result = await resolveContentRef(
|
|
{ kind: 'page', space: targetSpace.id, page: 'page-target' },
|
|
context
|
|
);
|
|
|
|
expect(result?.text).toBe('External page');
|
|
expect(result?.ancestors).toEqual([
|
|
{ label: 'External Space', href: targetSpace.urls.published },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('repository page links', () => {
|
|
function fixture(
|
|
options: {
|
|
denied?: boolean;
|
|
missing?: boolean;
|
|
draft?: boolean;
|
|
gitSync?: object | null;
|
|
previousGitSync?: object;
|
|
pageGitPath?: string;
|
|
} = {}
|
|
) {
|
|
const page = {
|
|
id: 'target-page',
|
|
type: 'document',
|
|
title: 'Authentication',
|
|
path: 'authentication',
|
|
slug: 'authentication',
|
|
pages: [],
|
|
git: { path: options.pageGitPath ?? 'api/auth.md', oid: 'blob' },
|
|
} as unknown as RevisionPageDocument;
|
|
const targetSpace = {
|
|
id: 'target',
|
|
title: 'API',
|
|
organization: 'org',
|
|
revision: 'target-main',
|
|
gitSync:
|
|
options.gitSync === null
|
|
? undefined
|
|
: (options.gitSync ?? {
|
|
url: 'https://github.com/acme/docs/tree/main',
|
|
installationProjectDirectory: 'api',
|
|
}),
|
|
previousGitSync: options.previousGitSync,
|
|
urls: {
|
|
app: 'https://app.gitbook.com/s/target',
|
|
published: 'https://docs.example.com/api/',
|
|
},
|
|
} as unknown as Space;
|
|
const targetSiteSpace = {
|
|
id: 'site-target',
|
|
title: 'API',
|
|
space: targetSpace,
|
|
path: 'api',
|
|
draft: options.draft ?? false,
|
|
urls: { published: 'https://docs.example.com/api/' },
|
|
} as unknown as SiteSpace;
|
|
const getSpace = mock(async () =>
|
|
options.denied ? { error: { code: 403, message: 'Forbidden' } } : { data: targetSpace }
|
|
);
|
|
const getRevision = mock(async () => ({
|
|
data: {
|
|
id: 'target-main',
|
|
pages: options.missing
|
|
? []
|
|
: [{ ...page, id: 'home', path: '', slug: '', git: undefined }, page],
|
|
files: [],
|
|
reusableContents: [],
|
|
},
|
|
}));
|
|
const context = {
|
|
organizationId: 'org',
|
|
site: { id: 'site' },
|
|
space: { id: 'source', revision: 'source-main' },
|
|
revision: { pages: [] },
|
|
revisionId: 'source-main',
|
|
changeRequest: null,
|
|
structure: { type: 'siteSpaces', structure: [targetSiteSpace] },
|
|
linker: createLinker({
|
|
host: 'docs.example.com',
|
|
siteBasePath: '/',
|
|
spaceBasePath: '/source/',
|
|
}),
|
|
dataFetcher: { getSpace, getRevision },
|
|
} as unknown as GitBookAnyContext;
|
|
return { context, getSpace, getRevision };
|
|
}
|
|
|
|
const ref = {
|
|
kind: 'url' as const,
|
|
url: 'https://github.com/acme/docs/tree/main/api/auth.md#tokens',
|
|
};
|
|
|
|
it('renders a matching repository URL as a site page link with its anchor', async () => {
|
|
const { context, getRevision } = fixture();
|
|
const result = await resolveContentRef(ref, context);
|
|
expect(result?.href).toBe('/api/authentication#tokens');
|
|
expect(result?.text).toBe('Authentication');
|
|
expect(result?.ancestors?.[0]?.label).toBe('API');
|
|
expect(result?.resolvedRef).toEqual({
|
|
kind: 'anchor',
|
|
space: 'target',
|
|
page: 'target-page',
|
|
anchor: 'tokens',
|
|
});
|
|
expect(getRevision).toHaveBeenCalledWith({
|
|
spaceId: 'target',
|
|
revisionId: 'target-main',
|
|
metadata: true,
|
|
});
|
|
expect(ref.kind).toBe('url');
|
|
});
|
|
|
|
it('preserves asset URLs that do not match a page', async () => {
|
|
const { context } = fixture();
|
|
const assetRef = {
|
|
kind: 'url' as const,
|
|
url: ref.url.replace('auth.md#tokens', 'diagram.png'),
|
|
};
|
|
expect((await resolveContentRef(assetRef, context))?.href).toBe(assetRef.url);
|
|
});
|
|
|
|
it('resolves a link to a folder with a dot in its name to its README page', async () => {
|
|
const { context } = fixture({ pageGitPath: 'api/11.8/README.md' });
|
|
expect(
|
|
(
|
|
await resolveContentRef(
|
|
{ kind: 'url', url: ref.url.replace('auth.md', '11.8') },
|
|
context
|
|
)
|
|
)?.href
|
|
).toBe('/api/authentication#tokens');
|
|
});
|
|
|
|
it('resolves pages stored with another Markdown extension', async () => {
|
|
const { context } = fixture({ pageGitPath: 'api/Auth.MARKDOWN' });
|
|
expect(
|
|
(
|
|
await resolveContentRef(
|
|
{ kind: 'url', url: ref.url.replace('auth.md', 'Auth.MARKDOWN') },
|
|
context
|
|
)
|
|
)?.href
|
|
).toBe('/api/authentication#tokens');
|
|
});
|
|
|
|
it('resolves a space syncing from the repository root', async () => {
|
|
const { context } = fixture({
|
|
gitSync: { url: 'https://github.com/acme/docs/tree/main' },
|
|
});
|
|
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
|
|
});
|
|
|
|
it('resolves a space whose Git Sync was removed from its remembered project URL', async () => {
|
|
const { context } = fixture({
|
|
gitSync: null,
|
|
previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' },
|
|
});
|
|
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
|
|
});
|
|
|
|
it('decodes the anchor of the repository URL', async () => {
|
|
const { context } = fixture();
|
|
const result = await resolveContentRef(
|
|
{ kind: 'url', url: ref.url.replace('#tokens', '#access%20tokens') },
|
|
context
|
|
);
|
|
expect(result?.resolvedRef).toEqual({
|
|
kind: 'anchor',
|
|
space: 'target',
|
|
page: 'target-page',
|
|
anchor: 'access tokens',
|
|
});
|
|
});
|
|
|
|
it('reads only the matching space in a 500-space site', async () => {
|
|
const { context, getSpace, getRevision } = fixture();
|
|
if (!('site' in context) || context.structure.type !== 'siteSpaces') {
|
|
throw new Error('Expected a site fixture');
|
|
}
|
|
const target = context.structure.structure[0]!;
|
|
context.structure.structure.push(
|
|
...Array.from({ length: 499 }, (_, index) => ({
|
|
...target,
|
|
id: `site-${index}`,
|
|
space: {
|
|
...target.space,
|
|
id: `space-${index}`,
|
|
gitSync: {
|
|
...target.space.gitSync!,
|
|
installationProjectDirectory: `other-${index}`,
|
|
},
|
|
},
|
|
}))
|
|
);
|
|
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
|
|
expect(getSpace).toHaveBeenCalledTimes(1);
|
|
expect(getRevision).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([{ denied: true }, { missing: true }, { draft: true }])(
|
|
'preserves the fallback for unavailable content: %j',
|
|
async (state) => {
|
|
const { context } = fixture(state);
|
|
const result = await resolveContentRef(ref, context);
|
|
expect(result).toEqual({ href: ref.url, text: ref.url, active: false });
|
|
}
|
|
);
|
|
|
|
it('does not fetch revisions for a different repository or branch', async () => {
|
|
const { context, getRevision } = fixture();
|
|
for (const url of [
|
|
ref.url.replace('/main/', '/preview/'),
|
|
ref.url.replace('/acme/', '/other/'),
|
|
]) {
|
|
expect((await resolveContentRef({ kind: 'url', url }, context))?.href).toBe(url);
|
|
}
|
|
expect(getRevision).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resolves the unchanged stored URL when the target becomes available', async () => {
|
|
const state = { missing: true };
|
|
const { context } = fixture(state);
|
|
expect((await resolveContentRef(ref, context))?.href).toBe(ref.url);
|
|
state.missing = false;
|
|
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
|
|
});
|
|
});
|