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 <noreply@anthropic.com>
This commit is contained in:
taranvohra
2026-10-01 21:17:35 +05:30
parent f720f1dbd6
commit 07f488e1a6
4 changed files with 432 additions and 60 deletions
+198 -2
View File
@@ -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();
});
});
+121 -42
View File
@@ -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<string, GitPageURLTarget & { root: string; tree: string }>();
const matches = new Map<string, GitPageURLTarget & { root: string; tree?: string }>();
for (const space of spaces) {
const { url: treeURL, installationProjectDirectory } = space.gitSync ?? {};
if (!treeURL || installationProjectDirectory === undefined) {
continue;
}
const tree = parseURL(treeURL);
if (!tree || tree.host !== url.host) {
continue;
}
const prefix = tree.pathname.replace(/\/$/, '');
const blobPrefix = prefix
.replace('/-/tree/', '/-/blob/')
.replace(/^(\/[^/]+\/[^/]+)\/tree\//, '$1/blob/');
const matchedPrefix = [prefix, blobPrefix].find((candidate) =>
url.pathname.startsWith(`${candidate}/`)
);
if (!matchedPrefix) {
continue;
}
let filePath: string;
try {
const encoded = url.pathname.slice(matchedPrefix.length + 1);
// Encoded separators make repository/ref boundaries ambiguous.
if (/%2f|%5c/i.test(encoded)) {
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<T extends { id: string; git?: { path: string }
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);
@@ -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;
}
+49 -7
View File
@@ -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 () => {
+64 -9
View File
@@ -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<string> }
>();
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.
*