From 1d648a9391f86ce26671e7ddccee0d39bb7dc615 Mon Sep 17 00:00:00 2001 From: taranvohra Date: Thu, 17 Sep 2026 20:42:32 +0530 Subject: [PATCH 1/7] Resolve repository page URLs within published sites --- .../DocumentView/BlockContentRef.tsx | 1 + .../components/DocumentView/InlineButton.tsx | 4 +- .../DocumentView/InlineLink/InlineLink.tsx | 7 +- .../src/components/DocumentView/Mention.tsx | 1 + .../DocumentView/Table/RecordCard.tsx | 4 +- .../DocumentView/Table/RecordColumnValue.tsx | 1 + 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 | 132 +++++++++++++++ packages/gitbook/src/lib/gitPageURL.ts | 117 +++++++++++++ packages/gitbook/src/lib/references.test.ts | 158 +++++++++++++++++- packages/gitbook/src/lib/references.tsx | 70 +++++++- 13 files changed, 501 insertions(+), 9 deletions(-) create mode 100644 packages/gitbook/src/lib/gitPageURL.test.ts create mode 100644 packages/gitbook/src/lib/gitPageURL.ts diff --git a/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx b/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx index cdbc079783..27d1a79c29 100644 --- a/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx +++ b/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx @@ -18,6 +18,7 @@ export async function BlockContentRef(props: BlockProps const resolved = context.contentContext ? await resolveContentRefInDocument(document, block.data.ref, context.contentContext, { + resolveGitPageURLs: true, resolveAnchorText: true, iconStyle: ['text-xl', 'text-tint'], }) diff --git a/packages/gitbook/src/components/DocumentView/InlineButton.tsx b/packages/gitbook/src/components/DocumentView/InlineButton.tsx index 40ba19c41a..ae699232c5 100644 --- a/packages/gitbook/src/components/DocumentView/InlineButton.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineButton.tsx @@ -76,7 +76,9 @@ export async function InlineLinkButton( const resolved = context.contentContext && inline.data.ref - ? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext) + ? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext, { + resolveGitPageURLs: true, + }) : null; const href = diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx index c9b8588ce6..d53ad41e45 100644 --- a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx @@ -19,6 +19,7 @@ export async function InlineLink(props: InlineProps) { const resolved = context.contentContext ? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext, { + resolveGitPageURLs: true, // We don't want to resolve the anchor text here, as it can be very expensive and will block rendering if there is a lot of anchors link. resolveAnchorText: false, }) @@ -52,8 +53,8 @@ export async function InlineLink(props: InlineProps) { const anchorElement = ( {inlinesElement} @@ -121,7 +122,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/components/DocumentView/Mention.tsx b/packages/gitbook/src/components/DocumentView/Mention.tsx index 7802f6ccfb..3979a7e714 100644 --- a/packages/gitbook/src/components/DocumentView/Mention.tsx +++ b/packages/gitbook/src/components/DocumentView/Mention.tsx @@ -9,6 +9,7 @@ export async function Mention(props: InlineProps) { const resolved = context.contentContext ? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext, { + resolveGitPageURLs: true, resolveAnchorText: true, }) : null; diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx index ddedaf3bb6..e3b1020def 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx @@ -41,7 +41,9 @@ export async function RecordCard( ? resolveContentRefInDocument(document, dark.contentRef, context.contentContext) : null, targetRef && context.contentContext - ? resolveContentRefInDocument(document, targetRef, context.contentContext) + ? resolveContentRefInDocument(document, targetRef, context.contentContext, { + resolveGitPageURLs: true, + }) : null, ]); diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx index 7c16b951a0..e2a3f3a8b3 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx @@ -253,6 +253,7 @@ export async function RecordColumnValue( const resolved = value && context.contentContext ? await resolveContentRefInDocument(document, value, context.contentContext, { + resolveGitPageURLs: true, resolveAnchorText: true, iconStyle: ['mr-2', 'text-tint-subtle'], }) diff --git a/packages/gitbook/src/lib/context.ts b/packages/gitbook/src/lib/context.ts index 97cd2adec4..6e969f2ae1 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 1c96e6fb62..ebacb9bdfe 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 62bc924057..203b3cbbdd 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 0000000000..dcec7b89f1 --- /dev/null +++ b/packages/gitbook/src/lib/gitPageURL.test.ts @@ -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(); + }); +}); diff --git a/packages/gitbook/src/lib/gitPageURL.ts b/packages/gitbook/src/lib/gitPageURL.ts new file mode 100644 index 0000000000..ff13b9d2f6 --- /dev/null +++ b/packages/gitbook/src/lib/gitPageURL.ts @@ -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(); + 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( + 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; + } +} diff --git a/packages/gitbook/src/lib/references.test.ts b/packages/gitbook/src/lib/references.test.ts index 4342f5a781..7befe167d0 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,159 @@ 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', + }; + const options = { resolveGitPageURLs: true }; + + 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, options); + 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('keeps media/resource URL resolution unchanged unless enabled', async () => { + const { context, getSpace } = fixture(); + expect((await resolveContentRef(ref, context))?.href).toBe(ref.url); + expect(getSpace).not.toHaveBeenCalled(); + }); + + 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, options))?.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, options); + 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, options))?.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, options))?.href).toBe(ref.url); + state.missing = false; + expect((await resolveContentRef(ref, context, options))?.href).toBe( + '/api/authentication#tokens' + ); + }); +}); diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index aede45c39e..10ff273a16 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -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 */ @@ -80,6 +84,8 @@ export interface ResolvedContentRef { } export interface ResolveContentRefOptions { + /** Enable repository-page lookup for navigational links. */ + resolveGitPageURLs?: boolean; /** * Should the content ref be rendered as text. * @default false @@ -143,6 +149,66 @@ export async function resolveContentRef( switch (contentRef.kind) { case 'url': { + if (options.resolveGitPageURLs && '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 +693,8 @@ async function resolveContentRefInSpace( */ async function createContextForSpace( spaceId: string, - context: GitBookAnyContext + context: GitBookAnyContext, + revisionMetadata = false ): Promise<{ spaceContext: GitBookSpaceContext; baseURL: URL; @@ -639,6 +706,7 @@ async function createContextForSpace( shareKey: context?.shareKey, changeRequest: undefined, revision: undefined, + revisionMetadata, }) ), getBestTargetSpace(context, spaceId), From 3c177f04a2cc067d5686af990ef6f023e8eed897 Mon Sep 17 00:00:00 2001 From: taranvohra Date: Thu, 17 Sep 2026 20:42:47 +0530 Subject: [PATCH 2/7] changeset --- .changeset/cross-space-repository-links.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cross-space-repository-links.md diff --git a/.changeset/cross-space-repository-links.md b/.changeset/cross-space-repository-links.md new file mode 100644 index 0000000000..68d7a388c8 --- /dev/null +++ b/.changeset/cross-space-repository-links.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +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. From 59bc231ccde7831fdb6b392d81ad192ccd67dfb1 Mon Sep 17 00:00:00 2001 From: taranvohra Date: Fri, 18 Sep 2026 12:22:53 +0530 Subject: [PATCH 3/7] Resolve repository page URLs without an opt-in flag --- .../DocumentView/BlockContentRef.tsx | 1 - .../components/DocumentView/InlineButton.tsx | 4 +-- .../DocumentView/InlineLink/InlineLink.tsx | 1 - .../src/components/DocumentView/Mention.tsx | 1 - .../DocumentView/Table/RecordCard.tsx | 4 +-- .../DocumentView/Table/RecordColumnValue.tsx | 1 - packages/gitbook/src/lib/references.test.ts | 30 ++++++++----------- packages/gitbook/src/lib/references.tsx | 4 +-- 8 files changed, 16 insertions(+), 30 deletions(-) diff --git a/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx b/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx index 27d1a79c29..cdbc079783 100644 --- a/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx +++ b/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx @@ -18,7 +18,6 @@ export async function BlockContentRef(props: BlockProps const resolved = context.contentContext ? await resolveContentRefInDocument(document, block.data.ref, context.contentContext, { - resolveGitPageURLs: true, resolveAnchorText: true, iconStyle: ['text-xl', 'text-tint'], }) diff --git a/packages/gitbook/src/components/DocumentView/InlineButton.tsx b/packages/gitbook/src/components/DocumentView/InlineButton.tsx index ae699232c5..40ba19c41a 100644 --- a/packages/gitbook/src/components/DocumentView/InlineButton.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineButton.tsx @@ -76,9 +76,7 @@ export async function InlineLinkButton( const resolved = context.contentContext && inline.data.ref - ? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext, { - resolveGitPageURLs: true, - }) + ? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext) : null; const href = diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx index d53ad41e45..88bed71955 100644 --- a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx @@ -19,7 +19,6 @@ export async function InlineLink(props: InlineProps) { const resolved = context.contentContext ? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext, { - resolveGitPageURLs: true, // We don't want to resolve the anchor text here, as it can be very expensive and will block rendering if there is a lot of anchors link. resolveAnchorText: false, }) diff --git a/packages/gitbook/src/components/DocumentView/Mention.tsx b/packages/gitbook/src/components/DocumentView/Mention.tsx index 3979a7e714..7802f6ccfb 100644 --- a/packages/gitbook/src/components/DocumentView/Mention.tsx +++ b/packages/gitbook/src/components/DocumentView/Mention.tsx @@ -9,7 +9,6 @@ export async function Mention(props: InlineProps) { const resolved = context.contentContext ? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext, { - resolveGitPageURLs: true, resolveAnchorText: true, }) : null; diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx index e3b1020def..ddedaf3bb6 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx @@ -41,9 +41,7 @@ export async function RecordCard( ? resolveContentRefInDocument(document, dark.contentRef, context.contentContext) : null, targetRef && context.contentContext - ? resolveContentRefInDocument(document, targetRef, context.contentContext, { - resolveGitPageURLs: true, - }) + ? resolveContentRefInDocument(document, targetRef, context.contentContext) : null, ]); diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx index e2a3f3a8b3..7c16b951a0 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx @@ -253,7 +253,6 @@ export async function RecordColumnValue( const resolved = value && context.contentContext ? await resolveContentRefInDocument(document, value, context.contentContext, { - resolveGitPageURLs: true, resolveAnchorText: true, iconStyle: ['mr-2', 'text-tint-subtle'], }) diff --git a/packages/gitbook/src/lib/references.test.ts b/packages/gitbook/src/lib/references.test.ts index 7befe167d0..9098f01bea 100644 --- a/packages/gitbook/src/lib/references.test.ts +++ b/packages/gitbook/src/lib/references.test.ts @@ -807,11 +807,10 @@ describe('repository page links', () => { kind: 'url' as const, url: 'https://github.com/acme/docs/tree/main/api/auth.md#tokens', }; - const options = { resolveGitPageURLs: true }; 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, options); + 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'); @@ -829,10 +828,13 @@ describe('repository page links', () => { expect(ref.kind).toBe('url'); }); - it('keeps media/resource URL resolution unchanged unless enabled', async () => { - const { context, getSpace } = fixture(); - expect((await resolveContentRef(ref, context))?.href).toBe(ref.url); - expect(getSpace).not.toHaveBeenCalled(); + 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 () => { @@ -855,9 +857,7 @@ describe('repository page links', () => { }, })) ); - expect((await resolveContentRef(ref, context, options))?.href).toBe( - '/api/authentication#tokens' - ); + expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens'); expect(getSpace).toHaveBeenCalledTimes(1); expect(getRevision).toHaveBeenCalledTimes(1); }); @@ -866,7 +866,7 @@ describe('repository page links', () => { 'preserves the fallback for unavailable content: %j', async (state) => { const { context } = fixture(state); - const result = await resolveContentRef(ref, context, options); + const result = await resolveContentRef(ref, context); expect(result).toEqual({ href: ref.url, text: ref.url, active: false }); } ); @@ -877,9 +877,7 @@ describe('repository page links', () => { ref.url.replace('/main/', '/preview/'), ref.url.replace('/acme/', '/other/'), ]) { - expect((await resolveContentRef({ kind: 'url', url }, context, options))?.href).toBe( - url - ); + expect((await resolveContentRef({ kind: 'url', url }, context))?.href).toBe(url); } expect(getRevision).not.toHaveBeenCalled(); }); @@ -887,10 +885,8 @@ describe('repository page links', () => { 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, options))?.href).toBe(ref.url); + expect((await resolveContentRef(ref, context))?.href).toBe(ref.url); state.missing = false; - expect((await resolveContentRef(ref, context, options))?.href).toBe( - '/api/authentication#tokens' - ); + 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 10ff273a16..6f9ce7b21b 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -84,8 +84,6 @@ export interface ResolvedContentRef { } export interface ResolveContentRefOptions { - /** Enable repository-page lookup for navigational links. */ - resolveGitPageURLs?: boolean; /** * Should the content ref be rendered as text. * @default false @@ -149,7 +147,7 @@ export async function resolveContentRef( switch (contentRef.kind) { case 'url': { - if (options.resolveGitPageURLs && 'site' in context) { + if ('site' in context) { const target = findGitPageURLTarget( contentRef.url, listAllSiteSpaces(context.structure) From f720f1dbd69cd6cdb5905aca327dac91de81a6a6 Mon Sep 17 00:00:00 2001 From: taranvohra Date: Fri, 18 Sep 2026 12:23:47 +0530 Subject: [PATCH 4/7] changeset --- .changeset/cross-space-repository-links.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cross-space-repository-links.md b/.changeset/cross-space-repository-links.md index 68d7a388c8..7a3e6f56a3 100644 --- a/.changeset/cross-space-repository-links.md +++ b/.changeset/cross-space-repository-links.md @@ -2,4 +2,4 @@ "gitbook": patch --- -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. +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. From 07f488e1a688e2fdd638db1a8ad37766ce586117 Mon Sep 17 00:00:00 2001 From: taranvohra Date: Thu, 1 Oct 2026 21:17:35 +0530 Subject: [PATCH 5/7] Match repository URLs like the GitBook editor and skip work for other links Use the matcher the editor uses, which treats an omitted project directory as the repository root, decodes anchors, accepts the www host and resolves spaces whose Git Sync was removed. Links to other hosts no longer scan every site space, and paths that cannot be pages no longer read a revision. Co-Authored-By: Claude Opus 5.5 --- packages/gitbook/src/lib/gitPageURL.test.ts | 200 +++++++++++++++++++- packages/gitbook/src/lib/gitPageURL.ts | 163 ++++++++++++---- packages/gitbook/src/lib/references.test.ts | 56 +++++- packages/gitbook/src/lib/references.tsx | 73 ++++++- 4 files changed, 432 insertions(+), 60 deletions(-) diff --git a/packages/gitbook/src/lib/gitPageURL.test.ts b/packages/gitbook/src/lib/gitPageURL.test.ts index dcec7b89f1..fd47eef3a1 100644 --- a/packages/gitbook/src/lib/gitPageURL.test.ts +++ b/packages/gitbook/src/lib/gitPageURL.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'bun:test'; -import { findGitPageURLTarget, findPageByGitPath } from './gitPageURL'; +import { + findGitPageURLTarget, + findPageByGitPath, + findPageForGitPageURLTarget, + matchesGitPageURLTargetPath, +} from './gitPageURL'; const SPACES = [ { @@ -20,6 +25,40 @@ const SPACES = [ ]; 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( @@ -102,13 +141,138 @@ describe('findGitPageURLTarget', () => { ).toBe('b'); }); - it('requires directory metadata, including an explicit empty root directory', () => { + 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', () => { @@ -130,3 +294,35 @@ describe('findPageByGitPath', () => { ).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 index ff13b9d2f6..c01c240039 100644 --- a/packages/gitbook/src/lib/gitPageURL.ts +++ b/packages/gitbook/src/lib/gitPageURL.ts @@ -4,12 +4,15 @@ export interface GitPageURLSpace { 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. */ @@ -21,60 +24,65 @@ export function findGitPageURLTarget( 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(); + const matches = new Map(); 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)) { + 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; } - 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; + 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: url.hash.slice(1) || undefined, + anchor, root, - tree: `${tree.host}${prefix}`, + tree: treeKey, + ...(!space.gitSync ? { pathIncludesRef: true } : {}), }); } const candidates = [...matches.values()]; - if (new Set(candidates.map((candidate) => candidate.tree)).size !== 1) { + if ( + new Set(candidates.flatMap((candidate) => (candidate.tree ? [candidate.tree] : []))).size > + 1 + ) { return null; } const longestRoot = Math.max(...candidates.map((candidate) => candidate.root.length)); @@ -83,7 +91,20 @@ export function findGitPageURLTarget( return null; } const owner = owners[0]!; - return { space: owner.space, path: owner.path, anchor: owner.anchor }; + 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. */ @@ -105,6 +126,23 @@ export function findPageByGitPath(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); @@ -115,3 +153,44 @@ function parseURL(href: string): URL | null { 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 9098f01bea..affc53a9b9 100644 --- a/packages/gitbook/src/lib/references.test.ts +++ b/packages/gitbook/src/lib/references.test.ts @@ -740,7 +740,15 @@ describe('resolveContentRef for direct space links', () => { }); describe('repository page links', () => { - function fixture(options: { denied?: boolean; missing?: boolean; draft?: boolean } = {}) { + function fixture( + options: { + denied?: boolean; + missing?: boolean; + draft?: boolean; + gitSync?: object | null; + previousGitSync?: object; + } = {} + ) { const page = { id: 'target-page', type: 'document', @@ -755,10 +763,14 @@ describe('repository page links', () => { title: 'API', organization: 'org', revision: 'target-main', - gitSync: { - url: 'https://github.com/acme/docs/tree/main', - installationProjectDirectory: 'api', - }, + 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/', @@ -828,13 +840,43 @@ describe('repository page links', () => { expect(ref.kind).toBe('url'); }); - it('preserves asset URLs that do not match a page', async () => { - const { context } = fixture(); + it('preserves asset URLs without reading a revision', async () => { + const { context, getRevision } = 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); + expect(getRevision).not.toHaveBeenCalled(); + }); + + 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 () => { diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index 6f9ce7b21b..7e1a49b1b7 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,7 +17,12 @@ import type { Filesystem } from '@gitbook/openapi-parser'; import { getGitBookAppHref } from './app'; import { getBlockById, getBlockTitle } from './document'; -import { findGitPageURLTarget, findPageByGitPath } from './gitPageURL'; +import { + type GitPageURLSpace, + type GitPageURLTarget, + findGitPageURLTarget, + findPageForGitPageURLTarget, +} from './gitPageURL'; import { resolvePageId } from './pages'; import { findSiteSpaceBy, @@ -44,6 +50,12 @@ 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; @@ -148,12 +160,7 @@ 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) - ); + const target = findSiteGitPageURLTarget(context.structure, contentRef.url); if (target) { try { // Site CRs must select the target member's revision here instead of main. @@ -164,9 +171,9 @@ export async function resolveContentRef( ); const page = targetContext && - findPageByGitPath( + findPageForGitPageURLTarget( targetContext.spaceContext.revision.pages, - target.path + target ); if (page?.type === 'document' && targetContext) { const resolvedRef: ContentRef = target.anchor @@ -683,6 +690,54 @@ 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. Only paths that can be pages are returned, as finding + * the page reads the revision of its space. + */ +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; + } + const target = findGitPageURLTarget(href, site.spaces); + return target && isGitPagePath(target.path) ? target : null; +} + +/** + * Whether a repository path can be a page: a Markdown file, or a directory for its README. + */ +function isGitPagePath(path: string): boolean { + const name = path.split('/').at(-1) ?? ''; + return name === '' || name.endsWith('.md') || !name.includes('.'); +} + +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. * From b0ea7acb0fcc94515aba8ff7733026eb8ca17df3 Mon Sep 17 00:00:00 2001 From: taranvohra Date: Fri, 2 Oct 2026 14:25:08 +0530 Subject: [PATCH 6/7] Read the target revision for every Markdown extension Git Sync imports Co-Authored-By: Claude Opus 5.5 --- packages/gitbook/src/lib/references.test.ts | 15 ++++++++++++++- packages/gitbook/src/lib/references.tsx | 5 ++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/gitbook/src/lib/references.test.ts b/packages/gitbook/src/lib/references.test.ts index affc53a9b9..0e52d2f9b5 100644 --- a/packages/gitbook/src/lib/references.test.ts +++ b/packages/gitbook/src/lib/references.test.ts @@ -747,6 +747,7 @@ describe('repository page links', () => { draft?: boolean; gitSync?: object | null; previousGitSync?: object; + pageGitPath?: string; } = {} ) { const page = { @@ -756,7 +757,7 @@ describe('repository page links', () => { path: 'authentication', slug: 'authentication', pages: [], - git: { path: 'api/auth.md', oid: 'blob' }, + git: { path: options.pageGitPath ?? 'api/auth.md', oid: 'blob' }, } as unknown as RevisionPageDocument; const targetSpace = { id: 'target', @@ -850,6 +851,18 @@ describe('repository page links', () => { expect(getRevision).not.toHaveBeenCalled(); }); + 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' }, diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index 7e1a49b1b7..1cfdac8d3a 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -50,6 +50,9 @@ import { } from '@/lib/data'; import { type GitBookLinker, createLinker, linkerWithAbsoluteURLs } from '@/lib/links'; +// The extensions Git Sync imports as pages. +const GIT_MARKDOWN_FILE = /\.(md|markdown|mdown|mkdn|mkd|mdwn|mkdown|ron)$/i; + // The spaces of each site that can own a repository URL, and the hosts of their repositories. const siteGitSpaces = new WeakMap< SiteStructure, @@ -724,7 +727,7 @@ function findSiteGitPageURLTarget(structure: SiteStructure, href: string): GitPa */ function isGitPagePath(path: string): boolean { const name = path.split('/').at(-1) ?? ''; - return name === '' || name.endsWith('.md') || !name.includes('.'); + return name === '' || GIT_MARKDOWN_FILE.test(name) || !name.includes('.'); } function getURLHost(href: string | undefined): string | null { From e91f26bb90b885eb08b1d80359d5cc275dca1d7c Mon Sep 17 00:00:00 2001 From: taranvohra Date: Fri, 2 Oct 2026 16:52:30 +0530 Subject: [PATCH 7/7] Look up every repository path a site space owns, including dotted folders Whether a path is a page folder or a file can only be told from the target revision: guessing from its name skipped folders like `11.8`. Co-Authored-By: Claude Opus 5.5 --- packages/gitbook/src/lib/references.test.ts | 17 ++++++++++++++--- packages/gitbook/src/lib/references.tsx | 17 ++--------------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/gitbook/src/lib/references.test.ts b/packages/gitbook/src/lib/references.test.ts index 0e52d2f9b5..db8c0e6d8a 100644 --- a/packages/gitbook/src/lib/references.test.ts +++ b/packages/gitbook/src/lib/references.test.ts @@ -841,14 +841,25 @@ describe('repository page links', () => { expect(ref.kind).toBe('url'); }); - it('preserves asset URLs without reading a revision', async () => { - const { context, getRevision } = fixture(); + 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); - expect(getRevision).not.toHaveBeenCalled(); + }); + + 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 () => { diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index 1cfdac8d3a..e174a2bc37 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -50,9 +50,6 @@ import { } from '@/lib/data'; import { type GitBookLinker, createLinker, linkerWithAbsoluteURLs } from '@/lib/links'; -// The extensions Git Sync imports as pages. -const GIT_MARKDOWN_FILE = /\.(md|markdown|mdown|mkdn|mkd|mdwn|mkdown|ron)$/i; - // The spaces of each site that can own a repository URL, and the hosts of their repositories. const siteGitSpaces = new WeakMap< SiteStructure, @@ -695,8 +692,7 @@ 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. Only paths that can be pages are returned, as finding - * the page reads the revision of its space. + * matching against every space of the site. */ function findSiteGitPageURLTarget(structure: SiteStructure, href: string): GitPageURLTarget | null { let site = siteGitSpaces.get(structure); @@ -718,16 +714,7 @@ function findSiteGitPageURLTarget(structure: SiteStructure, href: string): GitPa if (!host || !site.hosts.has(host)) { return null; } - const target = findGitPageURLTarget(href, site.spaces); - return target && isGitPagePath(target.path) ? target : null; -} - -/** - * Whether a repository path can be a page: a Markdown file, or a directory for its README. - */ -function isGitPagePath(path: string): boolean { - const name = path.split('/').at(-1) ?? ''; - return name === '' || GIT_MARKDOWN_FILE.test(name) || !name.includes('.'); + return findGitPageURLTarget(href, site.spaces); } function getURLHost(href: string | undefined): string | null {