Resolve cross-space repository page URLs when rendering sites (#4619)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Taran Vohra
2026-10-02 18:31:20 +05:30
committed by GitHub
parent ffebd1790e
commit dff0c7903e
9 changed files with 876 additions and 7 deletions
@@ -52,8 +52,8 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const anchorElement = (
<InlineLinkAnchor
href={resolved.href}
contentRef={inline.data.ref}
isExternal={inline.data.ref.kind === 'url'}
contentRef={resolved.resolvedRef ?? inline.data.ref}
isExternal={(resolved.resolvedRef ?? inline.data.ref).kind === 'url'}
>
{inlinesElement}
</InlineLinkAnchor>
@@ -121,7 +121,7 @@ function InlineLinkTooltipWrapper(props: {
let breadcrumbs = resolved.ancestors ?? [];
const isMailto = resolved.href.startsWith('mailto:');
const isExternal = inline.data.ref.kind === 'url';
const isExternal = (resolved.resolvedRef ?? inline.data.ref).kind === 'url';
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
if (isMailto) {
+2
View File
@@ -519,6 +519,7 @@ export async function fetchSpaceContextByIds(
shareKey: string | undefined;
changeRequest: string | undefined;
revision: string | undefined;
revisionMetadata?: boolean;
}
): Promise<GitBookSpaceContext> {
const { dataFetcher } = baseContext;
@@ -552,6 +553,7 @@ export async function fetchSpaceContextByIds(
dataFetcher.getRevision({
spaceId: ids.space,
revisionId,
...(ids.revisionMetadata ? { metadata: true } : {}),
}),
// When trying to render a revision with an invalid / non-existing ID,
+10 -2
View File
@@ -80,6 +80,7 @@ export function createDataFetcher(
return getRevision(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
metadata: params.metadata ?? false,
});
},
getRevisionPageByPath(params) {
@@ -319,8 +320,15 @@ const getChangeRequest = cache(
// We don't use remote cache on vercel because of the 2Mb limit on cache size that makes some route crash
const getRevision = cache(
async (input: DataFetcherInput, params: { spaceId: string; revisionId: string }) => {
async (
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; metadata: boolean }
) => {
'use cache';
if (params.metadata) {
// Git paths can change without changing the content revision.
cacheTag(getCacheTag({ tag: 'space', space: params.spaceId }));
}
return wrapDataFetcherError(async () => {
return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => {
const api = apiClient(input);
@@ -328,7 +336,7 @@ const getRevision = cache(
params.spaceId,
params.revisionId,
{
metadata: false,
metadata: params.metadata,
},
{
...noCacheFetchOptions,
+1
View File
@@ -79,6 +79,7 @@ export interface GitBookDataFetcher {
getRevision(params: {
spaceId: string;
revisionId: string;
metadata?: boolean;
}): Promise<DataFetcherResponse<api.Revision>>;
/**
+328
View File
@@ -0,0 +1,328 @@
import { describe, expect, it } from 'bun:test';
import {
findGitPageURLTarget,
findPageByGitPath,
findPageForGitPageURLTarget,
matchesGitPageURLTargetPath,
} from './gitPageURL';
const SPACES = [
{
id: 'a',
gitSync: {
url: 'https://github.com/acme/docs/tree/main',
installationProjectDirectory: 'guides',
},
},
{
id: 'b',
gitSync: {
url: 'https://github.com/acme/docs/tree/main',
installationProjectDirectory: '/api/',
},
},
];
describe('findGitPageURLTarget', () => {
it.each(['header%201', '%E6%97%A5%E6%9C%AC', 'part%2Fone', 'percent%2520'])(
'decodes anchor %s once',
(anchor) => {
expect(
findGitPageURLTarget(
`https://github.com/acme/docs/tree/main/api/auth.md#${anchor}`,
SPACES
)?.anchor
).toBe(decodeURIComponent(anchor));
}
);
it.each(['%ZZ', '%E0%A4'])('keeps malformed anchor %s unresolved', (anchor) => {
expect(
findGitPageURLTarget(
`https://github.com/acme/docs/tree/main/api/auth.md#${anchor}`,
SPACES
)
).toBeNull();
});
it('accepts the www host alias without accepting unrelated hosts', () => {
expect(
findGitPageURLTarget('https://www.github.com/acme/docs/tree/main/api/auth.md', SPACES)
?.space
).toBe('b');
expect(
findGitPageURLTarget(
'https://www.github.com.evil.test/acme/docs/tree/main/api/auth.md',
SPACES
)
).toBeNull();
});
it('matches the repository, ref and directory and preserves anchors', () => {
expect(
findGitPageURLTarget(
'https://github.com/acme/docs/tree/main/api/auth.md#tokens',
SPACES
)
).toEqual({ space: 'b', path: 'api/auth.md', anchor: 'tokens' });
});
it.each([
'https://github.com/other/docs/tree/main/api/auth.md',
'https://github.com/acme/docs/tree/preview/api/auth.md',
'https://github.com.evil.test/acme/docs/tree/main/api/auth.md',
'https://github.com/acme/docs/tree/main/api-other/auth.md',
'https://github.com/acme/docs/tree/main/api/auth.md?raw=1',
'https://github.com/acme/docs/tree/main/api/%ZZ.md',
'https://github.com/acme/docs/tree/main/api/%2Fsecret.md',
])('does not reinterpret %s', (url) => {
expect(findGitPageURLTarget(url, SPACES)).toBeNull();
});
it('supports blob URLs and encoded file names', () => {
expect(
findGitPageURLTarget(
'https://github.com/acme/docs/blob/main/api/hello%20world.md',
SPACES
)
).toEqual({ space: 'b', path: 'api/hello world.md', anchor: undefined });
});
it('matches self-hosted GitLab with nested groups and a slash in the branch', () => {
const spaces = [
{
id: 'b',
gitSync: {
url: 'https://git.example.com/group/sub/docs/-/tree/release/v2',
installationProjectDirectory: 'api',
},
},
];
expect(
findGitPageURLTarget(
'https://git.example.com/group/sub/docs/-/blob/release/v2/api/auth.md',
spaces
)?.space
).toBe('b');
});
it('rejects ambiguous owners and ambiguous branch prefixes', () => {
expect(
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
...SPACES,
{ ...SPACES[1]!, id: 'duplicate' },
])
).toBeNull();
expect(
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
...SPACES,
{
id: 'other-ref',
gitSync: {
url: 'https://github.com/acme/docs/tree/main/api',
installationProjectDirectory: '',
},
},
])
).toBeNull();
});
it('prefers the most specific directory and deduplicates site placements', () => {
expect(
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
...SPACES,
SPACES[1]!,
{
id: 'root',
gitSync: { url: SPACES[0]!.gitSync.url, installationProjectDirectory: '' },
},
])?.space
).toBe('b');
});
it('treats an omitted live project directory as the repository root', () => {
expect(
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
{ id: 'old', gitSync: { url: SPACES[0]!.gitSync.url } },
])
).toEqual({ space: 'old', path: 'api/auth.md', anchor: undefined });
});
it.each(['live', 'disconnected'])('keeps similar space directories distinct (%s)', (state) => {
const spaces = ['docs/space-a', 'docs/space-b', 'api-reference'].map((directory) => ({
id: directory,
...(state === 'live'
? {
gitSync: {
url: SPACES[0]!.gitSync.url,
installationProjectDirectory: `/${directory}`,
},
}
: { previousGitSync: { url: `${SPACES[0]!.gitSync.url}/${directory}` } }),
}));
const url = new URL(
'../docs/space-b/page.md#details',
`${SPACES[0]!.gitSync.url}/api-reference/README.md`
).href;
const target = findGitPageURLTarget(url, spaces);
expect(target?.space).toBe('docs/space-b');
expect(target?.anchor).toBe('details');
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/page.md')).toBe(true);
expect(
findGitPageURLTarget(
url,
spaces.filter((space) => space.id !== 'docs/space-b')
)
).toBeNull();
});
it.each([
[
'https://github.com/old/repo/tree/main/docs/space-b',
'https://github.com/old/repo/blob/main/docs/space-b/hello%20world.md#details',
],
[
'https://git.example.com/group/repo/-/tree/release/v2/docs/space-b',
'https://git.example.com/group/repo/-/blob/release/v2/docs/space-b/hello%20world.md#details',
],
])('matches the remembered project URL %s', (previousURL, href) => {
const target = findGitPageURLTarget(href, [
{ id: 'b', previousGitSync: { url: previousURL } },
]);
expect(target?.space).toBe('b');
expect(target?.anchor).toBe('details');
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/hello world.md')).toBe(
true
);
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-a/hello world.md')).toBe(
false
);
});
it.each([
'https://github.com/someone-else/example/blob/main/docs/space-b/page.md',
'https://github.com/old/repo/tree/other/docs/space-b/page.md',
'https://github.com.evil.test/old/repo/tree/main/docs/space-b/page.md',
'https://github.com/old/repo/tree/main/docs/space-b-other/page.md',
'https://github.com/old/repo/tree/main/other-docs/space-b/page.md',
'https://github.com/old/repo/tree/main/docs/space-b/%2Fsecret.md',
'https://github.com/old/repo/tree/main/docs/space-b/%ZZ.md',
])('does not reinterpret an unrelated or invalid URL: %s', (href) => {
expect(
findGitPageURLTarget(href, [
{
id: 'b',
previousGitSync: { url: 'https://github.com/old/repo/tree/main/docs/space-b' },
},
])
).toBeNull();
});
it('requires a valid previous URL', () => {
for (const url of [undefined, 'invalid', 'https://github.com/old/repo']) {
expect(
findGitPageURLTarget('https://github.com/old/repo/tree/main/docs/space-b/page.md', [
{ id: 'b', previousGitSync: { url } },
])
).toBeNull();
}
});
it('supports disconnected repository roots and directory README links', () => {
const target = findGitPageURLTarget(
'https://github.com/old/repo/tree/release/v2/docs/space-b/',
[{ id: 'b', previousGitSync: { url: 'https://github.com/old/repo/tree/release/v2' } }]
);
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/README.md')).toBe(true);
});
it('rejects duplicate previous owners and ignores stale metadata on live installations', () => {
const previous = {
previousGitSync: { url: 'https://github.com/old/repo/tree/main/docs/space-b' },
};
const url = 'https://github.com/old/repo/tree/main/docs/space-b/page.md';
expect(
findGitPageURLTarget(url, [
{ id: 'b', ...previous },
{ id: 'copy', ...previous },
])
).toBeNull();
expect(findGitPageURLTarget(url, [{ ...SPACES[1]!, ...previous }])).toBeNull();
});
it('compares live and previous project URLs at the same directory boundary', () => {
const href = 'https://github.com/acme/docs/tree/main/api/auth.md';
const disconnected = {
id: 'old-api',
previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' },
};
expect(findGitPageURLTarget(href, [SPACES[1]!, disconnected])).toBeNull();
expect(
findGitPageURLTarget(href, [
disconnected,
{ id: 'root', gitSync: { url: SPACES[0]!.gitSync.url } },
])?.space
).toBe('old-api');
});
it('accepts the www alias for a remembered project URL', () => {
expect(
findGitPageURLTarget('https://www.github.com/acme/docs/blob/main/api/auth.md', [
{ id: 'b', previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' } },
])?.space
).toBe('b');
});
});
describe('findPageByGitPath', () => {
const pages = [
{ id: 'auth', git: { path: 'api/auth.md' }, pages: [] },
{ id: 'group', pages: [{ id: 'readme', git: { path: 'api/11.8/README.md' }, pages: [] }] },
];
it('finds nested pages and directory README links', () => {
expect(findPageByGitPath(pages, 'api/auth.md')?.id).toBe('auth');
expect(findPageByGitPath(pages, 'api/11.8/')?.id).toBe('readme');
expect(findPageByGitPath(pages, 'api/missing.md')).toBeNull();
});
it('does not select between duplicate paths', () => {
expect(
findPageByGitPath(
[...pages, { id: 'copy', git: { path: 'api/auth.md' }, pages: [] }],
'api/auth.md'
)
).toBeNull();
});
});
describe('findPageForGitPageURLTarget', () => {
const pages = [
{ id: 'auth', git: { path: 'api/auth.md' }, pages: [] },
{ id: 'group', pages: [{ id: 'readme', git: { path: 'api/11.8/README.md' }, pages: [] }] },
];
it('finds the page of a live target, including directory README links', () => {
expect(findPageForGitPageURLTarget(pages, { space: 'b', path: 'api/auth.md' })?.id).toBe(
'auth'
);
expect(findPageForGitPageURLTarget(pages, { space: 'b', path: 'api/11.8/' })?.id).toBe(
'readme'
);
});
it('finds the page of a remembered target whose path still includes the ref', () => {
expect(
findPageForGitPageURLTarget(pages, {
space: 'b',
path: 'release/v2/api/auth.md',
pathIncludesRef: true,
})?.id
).toBe('auth');
});
it('does not select between duplicate paths', () => {
expect(
findPageForGitPageURLTarget(
[...pages, { id: 'copy', git: { path: 'api/auth.md' }, pages: [] }],
{ space: 'b', path: 'api/auth.md' }
)
).toBeNull();
});
});
+196
View File
@@ -0,0 +1,196 @@
export interface GitPageURLSpace {
id: string;
gitSync?: {
url?: string;
installationProjectDirectory?: string;
};
previousGitSync?: { url?: string };
}
export interface GitPageURLTarget {
space: string;
path: string;
anchor?: string;
/** Previous project URLs do not distinguish a slash-containing ref from the file path. */
pathIncludesRef?: boolean;
}
/** Locate a unique owning space without fetching any revisions. */
export function findGitPageURLTarget(
href: string,
spaces: readonly GitPageURLSpace[]
): GitPageURLTarget | null {
const url = parseURL(href);
if (!url || url.search) {
return null;
}
let anchor: string | undefined;
try {
anchor = decodeURIComponent(url.hash.slice(1)) || undefined;
} catch {
return null;
}
const matches = new Map<string, GitPageURLTarget & { root: string; tree?: string }>();
for (const space of spaces) {
const directory = space.gitSync?.installationProjectDirectory ?? '';
let root = directory.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
let filePath: string | null;
let treeKey: string | undefined;
if (space.gitSync) {
const tree = space.gitSync.url ? parseURL(space.gitSync.url) : null;
if (!tree || (tree.host !== url.host && url.host !== `www.${tree.host}`)) {
continue;
}
const prefix = tree.pathname.replace(/\/$/, '');
const blobPrefix = prefix
.replace('/-/tree/', '/-/blob/')
.replace(/^(\/[^/]+\/[^/]+)\/tree\//, '$1/blob/');
const matchedPrefix = [prefix, blobPrefix].find((candidate) =>
url.pathname.startsWith(`${candidate}/`)
);
if (!matchedPrefix) {
continue;
}
filePath = decodeGitPath(url.pathname.slice(matchedPrefix.length + 1));
treeKey = `${tree.host}${prefix}`;
if (!filePath || (root && filePath !== root && !filePath.startsWith(`${root}/`))) {
continue;
}
root = `${prefix}/${root}`.replace(/\/$/, '');
} else {
const previous = space.previousGitSync?.url
? parseURL(space.previousGitSync.url)
: null;
filePath = previous ? findPreviousGitPath(url, previous) : null;
if (!filePath || !previous) {
continue;
}
root = previous.pathname.replace(/\/$/, '');
}
matches.set(space.id, {
space: space.id,
path: filePath,
anchor,
root,
tree: treeKey,
...(!space.gitSync ? { pathIncludesRef: true } : {}),
});
}
const candidates = [...matches.values()];
if (
new Set(candidates.flatMap((candidate) => (candidate.tree ? [candidate.tree] : []))).size >
1
) {
return null;
}
const longestRoot = Math.max(...candidates.map((candidate) => candidate.root.length));
const owners = candidates.filter((candidate) => candidate.root.length === longestRoot);
if (owners.length !== 1) {
return null;
}
const owner = owners[0]!;
return {
space: owner.space,
path: owner.path,
anchor: owner.anchor,
...(owner.pathIncludesRef ? { pathIncludesRef: true } : {}),
};
}
/** Match stored page paths after verifying the owning repository URL; callers must reject multiple pages. */
export function matchesGitPageURLTargetPath(target: GitPageURLTarget, filePath: string): boolean {
const paths = [target.path, `${target.path.replace(/\/$/, '')}/README.md`];
return paths.some((path) =>
target.pathIncludesRef ? path.endsWith(`/${filePath}`) : path === filePath
);
}
/** Match an API revision's nested page tree, including directory README links. */
export function findPageByGitPath<T extends { id: string; git?: { path: string }; pages?: T[] }>(
pages: readonly T[],
filePath: string
): T | null {
const paths = [filePath, `${filePath.replace(/\/$/, '')}/README.md`];
const matches: T[] = [];
const visit = (children: readonly T[]) => {
for (const page of children) {
if (page.git && paths.includes(page.git.path)) {
matches.push(page);
}
visit(page.pages ?? []);
}
};
visit(pages);
return matches.length === 1 ? matches[0]! : null;
}
/** Find the only page of an API revision's page tree at the path a target points to. */
export function findPageForGitPageURLTarget<
T extends { id: string; git?: { path: string }; pages?: T[] },
>(pages: readonly T[], target: GitPageURLTarget): T | null {
const matches: T[] = [];
const visit = (children: readonly T[]) => {
for (const page of children) {
if (page.git && matchesGitPageURLTargetPath(target, page.git.path)) {
matches.push(page);
}
visit(page.pages ?? []);
}
};
visit(pages);
return matches.length === 1 ? matches[0]! : null;
}
function parseURL(href: string): URL | null {
try {
const url = new URL(href);
return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password
? url
: null;
} catch {
return null;
}
}
function decodeGitPath(encoded: string): string | null {
try {
// Encoded separators make repository/ref boundaries ambiguous.
if (/%2f|%5c/i.test(encoded)) {
return null;
}
const decoded = decodeURIComponent(encoded);
return decoded.includes('\\') ||
decoded.split('/').some((part) => part === '.' || part === '..')
? null
: decoded;
} catch {
return null;
}
}
function findPreviousGitPath(url: URL, previous: URL): string | null {
if (
previous.search ||
previous.hash ||
!/\/(?:tree|blob)\/.+/.test(previous.pathname) ||
(url.host !== previous.host && url.host !== `www.${previous.host}`)
) {
return null;
}
const prefix = previous.pathname.replace(/\/$/, '');
const blobPrefix = prefix
.replace('/-/tree/', '/-/blob/')
.replace(/^(\/[^/]+\/[^/]+)\/tree\//, '$1/blob/');
if (
![prefix, blobPrefix].some(
(candidate) => url.pathname === candidate || url.pathname.startsWith(`${candidate}/`)
)
) {
return null;
}
// Keep the ref until revision lookup: its slash boundary is not recorded separately.
const suffix = url.pathname.match(/\/(?:tree|blob)\/(.+)$/)?.[1];
return suffix ? decodeGitPath(suffix) : null;
}
+219 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'bun:test';
import { describe, expect, it, mock } from 'bun:test';
import type { Revision, RevisionPageDocument, SiteSpace, Space } from '@gitbook/api';
@@ -738,3 +738,221 @@ describe('resolveContentRef for direct space links', () => {
]);
});
});
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');
});
});
+112 -1
View File
@@ -9,6 +9,7 @@ import type {
RevisionReusableContent,
SiteSection,
SiteSpace,
SiteStructure,
Space,
TranslationLanguage,
} from '@gitbook/api';
@@ -16,12 +17,19 @@ import type { Filesystem } from '@gitbook/openapi-parser';
import { getGitBookAppHref } from './app';
import { getBlockById, getBlockTitle } from './document';
import {
type GitPageURLSpace,
type GitPageURLTarget,
findGitPageURLTarget,
findPageForGitPageURLTarget,
} from './gitPageURL';
import { resolvePageId } from './pages';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
getLinkerForSiteSpace,
getLocalizedTitle,
listAllSiteSpaces,
} from './sites';
import { getRevisionTags, resolveTag } from './tags';
import type { ClassValue } from './tailwind';
@@ -42,7 +50,15 @@ import {
} from '@/lib/data';
import { type GitBookLinker, createLinker, linkerWithAbsoluteURLs } from '@/lib/links';
// The spaces of each site that can own a repository URL, and the hosts of their repositories.
const siteGitSpaces = new WeakMap<
SiteStructure,
{ spaces: GitPageURLSpace[]; hosts: Set<string> }
>();
export interface ResolvedContentRef {
/** Effective destination when a repository URL resolves to a site page. */
resolvedRef?: ContentRef;
/** Text to render in the content ref */
text: string;
/** Additional sub text to render in the content ref */
@@ -143,6 +159,61 @@ export async function resolveContentRef(
switch (contentRef.kind) {
case 'url': {
if ('site' in context) {
const target = findSiteGitPageURLTarget(context.structure, contentRef.url);
if (target) {
try {
// Site CRs must select the target member's revision here instead of main.
const targetContext = await createContextForSpace(
target.space,
context,
true
);
const page =
targetContext &&
findPageForGitPageURLTarget(
targetContext.spaceContext.revision.pages,
target
);
if (page?.type === 'document' && targetContext) {
const resolvedRef: ContentRef = target.anchor
? {
kind: 'anchor',
space: target.space,
page: page.id,
anchor: target.anchor,
}
: { kind: 'page', space: target.space, page: page.id };
const resolved = await resolveContentRef(
resolvedRef,
targetContext.spaceContext,
options
);
if (resolved) {
const foundSiteSpace = findSiteSpaceBy(
context.structure,
(entry) => entry.space.id === target.space
);
return {
...resolved,
resolvedRef,
ancestors: [
...resolvePageAncestors(
context,
resolvedRef,
foundSiteSpace,
targetContext
),
...(resolved.ancestors ?? []),
],
};
}
}
} catch {
// An unavailable or forbidden target must not prevent rendering the source page.
}
}
}
return {
href: contentRef.url,
text: contentRef.url,
@@ -619,6 +690,44 @@ async function resolveContentRefInSpace(
}
}
/**
* Locate the site space owning a repository URL. Links to other hosts, which most are, skip
* matching against every space of the site.
*/
function findSiteGitPageURLTarget(structure: SiteStructure, href: string): GitPageURLTarget | null {
let site = siteGitSpaces.get(structure);
if (!site) {
const spaces: GitPageURLSpace[] = listAllSiteSpaces(structure)
.filter((siteSpace) => !siteSpace.draft)
.map((siteSpace) => siteSpace.space);
const hosts = new Set(
spaces.flatMap((space) => {
const host = getURLHost(space.gitSync?.url ?? space.previousGitSync?.url);
return host ? [host, `www.${host}`] : [];
})
);
site = { spaces, hosts };
siteGitSpaces.set(structure, site);
}
const host = getURLHost(href);
if (!host || !site.hosts.has(host)) {
return null;
}
return findGitPageURLTarget(href, site.spaces);
}
function getURLHost(href: string | undefined): string | null {
if (!href) {
return null;
}
try {
return new URL(href).host;
} catch {
return null;
}
}
/**
* Create a new context for a specific spaceId.
*
@@ -627,7 +736,8 @@ async function resolveContentRefInSpace(
*/
async function createContextForSpace(
spaceId: string,
context: GitBookAnyContext
context: GitBookAnyContext,
revisionMetadata = false
): Promise<{
spaceContext: GitBookSpaceContext;
baseURL: URL;
@@ -639,6 +749,7 @@ async function createContextForSpace(
shareKey: context?.shareKey,
changeRequest: undefined,
revision: undefined,
revisionMetadata,
})
),
getBestTargetSpace(context, spaceId),