diff --git a/packages/gitbook/src/lib/gitPageURL.test.ts b/packages/gitbook/src/lib/gitPageURL.test.ts index dcec7b89f..fd47eef3a 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 ff13b9d2f..c01c24003 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 9098f01be..affc53a9b 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 6f9ce7b21..7e1a49b1b 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. *