From dff0c7903e431df292b4b98b02b296343ab04794 Mon Sep 17 00:00:00 2001 From: Taran Vohra Date: Fri, 2 Oct 2026 18:31:20 +0530 Subject: [PATCH] Resolve cross-space repository page URLs when rendering sites (#4619) Co-authored-by: Claude Opus 5.5 --- .changeset/cross-space-repository-links.md | 5 + .../DocumentView/InlineLink/InlineLink.tsx | 6 +- packages/gitbook/src/lib/context.ts | 2 + packages/gitbook/src/lib/data/api.ts | 12 +- packages/gitbook/src/lib/data/types.ts | 1 + packages/gitbook/src/lib/gitPageURL.test.ts | 328 ++++++++++++++++++ packages/gitbook/src/lib/gitPageURL.ts | 196 +++++++++++ packages/gitbook/src/lib/references.test.ts | 220 +++++++++++- packages/gitbook/src/lib/references.tsx | 113 +++++- 9 files changed, 876 insertions(+), 7 deletions(-) create mode 100644 .changeset/cross-space-repository-links.md create mode 100644 packages/gitbook/src/lib/gitPageURL.test.ts create mode 100644 packages/gitbook/src/lib/gitPageURL.ts diff --git a/.changeset/cross-space-repository-links.md b/.changeset/cross-space-repository-links.md new file mode 100644 index 000000000..7a3e6f56a --- /dev/null +++ b/.changeset/cross-space-repository-links.md @@ -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. diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx index c9b8588ce..88bed7195 100644 --- a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx @@ -52,8 +52,8 @@ export async function InlineLink(props: InlineProps) { const anchorElement = ( {inlinesElement} @@ -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) { diff --git a/packages/gitbook/src/lib/context.ts b/packages/gitbook/src/lib/context.ts index 97cd2adec..6e969f2ae 100644 --- a/packages/gitbook/src/lib/context.ts +++ b/packages/gitbook/src/lib/context.ts @@ -519,6 +519,7 @@ export async function fetchSpaceContextByIds( shareKey: string | undefined; changeRequest: string | undefined; revision: string | undefined; + revisionMetadata?: boolean; } ): Promise { 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, diff --git a/packages/gitbook/src/lib/data/api.ts b/packages/gitbook/src/lib/data/api.ts index 1c96e6fb6..ebacb9bdf 100644 --- a/packages/gitbook/src/lib/data/api.ts +++ b/packages/gitbook/src/lib/data/api.ts @@ -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, diff --git a/packages/gitbook/src/lib/data/types.ts b/packages/gitbook/src/lib/data/types.ts index 62bc92405..203b3cbbd 100644 --- a/packages/gitbook/src/lib/data/types.ts +++ b/packages/gitbook/src/lib/data/types.ts @@ -79,6 +79,7 @@ export interface GitBookDataFetcher { getRevision(params: { spaceId: string; revisionId: string; + metadata?: boolean; }): Promise>; /** diff --git a/packages/gitbook/src/lib/gitPageURL.test.ts b/packages/gitbook/src/lib/gitPageURL.test.ts new file mode 100644 index 000000000..fd47eef3a --- /dev/null +++ b/packages/gitbook/src/lib/gitPageURL.test.ts @@ -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(); + }); +}); diff --git a/packages/gitbook/src/lib/gitPageURL.ts b/packages/gitbook/src/lib/gitPageURL.ts new file mode 100644 index 000000000..c01c24003 --- /dev/null +++ b/packages/gitbook/src/lib/gitPageURL.ts @@ -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(); + 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( + 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; +} diff --git a/packages/gitbook/src/lib/references.test.ts b/packages/gitbook/src/lib/references.test.ts index 4342f5a78..db8c0e6d8 100644 --- a/packages/gitbook/src/lib/references.test.ts +++ b/packages/gitbook/src/lib/references.test.ts @@ -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'); + }); +}); diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index aede45c39..e174a2bc3 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -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 } +>(); + 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),