Compare commits

...

4 Commits

Author SHA1 Message Date
taranvohra f720f1dbd6 changeset 2026-09-18 12:23:47 +05:30
taranvohra 59bc231ccd Resolve repository page URLs without an opt-in flag 2026-09-18 12:22:53 +05:30
taranvohra 3c177f04a2 changeset 2026-09-17 20:42:47 +05:30
taranvohra 1d648a9391 Resolve repository page URLs within published sites 2026-09-17 20:42:32 +05:30
9 changed files with 490 additions and 7 deletions
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Automatically resolve GitHub and GitLab page links to matching pages in the same published site, including cross-space links imported before their target page was available.
@@ -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>>;
/**
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'bun:test';
import { findGitPageURLTarget, findPageByGitPath } 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('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('requires directory metadata, including an explicit empty root directory', () => {
expect(
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
{ id: 'old', gitSync: { url: SPACES[0]!.gitSync.url } },
])
).toBeNull();
});
});
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();
});
});
+117
View File
@@ -0,0 +1,117 @@
export interface GitPageURLSpace {
id: string;
gitSync?: {
url?: string;
installationProjectDirectory?: string;
};
}
export interface GitPageURLTarget {
space: string;
path: string;
anchor?: string;
}
/** 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;
}
const matches = new Map<string, GitPageURLTarget & { root: string; tree: string }>();
for (const space of spaces) {
const { url: treeURL, installationProjectDirectory } = space.gitSync ?? {};
if (!treeURL || installationProjectDirectory === undefined) {
continue;
}
const tree = parseURL(treeURL);
if (!tree || tree.host !== url.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;
}
let filePath: string;
try {
const encoded = url.pathname.slice(matchedPrefix.length + 1);
// Encoded separators make repository/ref boundaries ambiguous.
if (/%2f|%5c/i.test(encoded)) {
continue;
}
filePath = decodeURIComponent(encoded);
} catch {
continue;
}
const root = installationProjectDirectory.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
if (
filePath.split('/').some((part) => part === '.' || part === '..') ||
filePath.includes('\\')
) {
continue;
}
if (root && filePath !== root && !filePath.startsWith(`${root}/`)) {
continue;
}
matches.set(space.id, {
space: space.id,
path: filePath,
anchor: url.hash.slice(1) || undefined,
root,
tree: `${tree.host}${prefix}`,
});
}
const candidates = [...matches.values()];
if (new Set(candidates.map((candidate) => 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 };
}
/** 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;
}
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;
}
}
+153 -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,155 @@ describe('resolveContentRef for direct space links', () => {
]);
});
});
describe('repository page links', () => {
function fixture(options: { denied?: boolean; missing?: boolean; draft?: boolean } = {}) {
const page = {
id: 'target-page',
type: 'document',
title: 'Authentication',
path: 'authentication',
slug: 'authentication',
pages: [],
git: { path: 'api/auth.md', oid: 'blob' },
} as unknown as RevisionPageDocument;
const targetSpace = {
id: 'target',
title: 'API',
organization: 'org',
revision: 'target-main',
gitSync: {
url: 'https://github.com/acme/docs/tree/main',
installationProjectDirectory: 'api',
},
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('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');
});
});
+67 -1
View File
@@ -16,12 +16,14 @@ import type { Filesystem } from '@gitbook/openapi-parser';
import { getGitBookAppHref } from './app';
import { getBlockById, getBlockTitle } from './document';
import { findGitPageURLTarget, findPageByGitPath } from './gitPageURL';
import { resolvePageId } from './pages';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
getLinkerForSiteSpace,
getLocalizedTitle,
listAllSiteSpaces,
} from './sites';
import { getRevisionTags, resolveTag } from './tags';
import type { ClassValue } from './tailwind';
@@ -43,6 +45,8 @@ import {
import { type GitBookLinker, createLinker, linkerWithAbsoluteURLs } from '@/lib/links';
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 +147,66 @@ export async function resolveContentRef(
switch (contentRef.kind) {
case 'url': {
if ('site' in context) {
const target = findGitPageURLTarget(
contentRef.url,
listAllSiteSpaces(context.structure)
.filter((entry) => !entry.draft)
.map((entry) => entry.space)
);
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 &&
findPageByGitPath(
targetContext.spaceContext.revision.pages,
target.path
);
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,
@@ -627,7 +691,8 @@ async function resolveContentRefInSpace(
*/
async function createContextForSpace(
spaceId: string,
context: GitBookAnyContext
context: GitBookAnyContext,
revisionMetadata = false
): Promise<{
spaceContext: GitBookSpaceContext;
baseURL: URL;
@@ -639,6 +704,7 @@ async function createContextForSpace(
shareKey: context?.shareKey,
changeRequest: undefined,
revision: undefined,
revisionMetadata,
})
),
getBestTargetSpace(context, spaceId),