mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-14 00:59:41 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface GitBookDataFetcher {
|
||||
getRevision(params: {
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
metadata?: boolean;
|
||||
}): Promise<DataFetcherResponse<api.Revision>>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user