Compare commits

..

2 Commits

Author SHA1 Message Date
Nicolas Dorseuil 8ca77c85d5 Add support for root destination markdown routing in toMarkdownDestination 2026-10-05 14:36:17 +02:00
Nicolas Dorseuil 0c92b3a679 Add support for resolving missing page paths and redirecting to markdown versions 2026-10-05 10:54:36 +02:00
11 changed files with 358 additions and 129 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Use the DocumentTextColor type defined in API schema.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Improve the prompt for agents to ask questions.
+16 -15
View File
@@ -129,8 +129,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.7",
"@opennextjs/cloudflare": "1.20.8",
"@opennextjs/aws": "4.1.6",
"@opennextjs/cloudflare": "1.20.7",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -160,7 +160,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.3.8",
"next": "^16.3.6",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -342,6 +342,7 @@
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"next@16.3.6": "patches/next@16.3.6.patch",
},
"overrides": {
"@codemirror/state": "6.4.1",
@@ -890,23 +891,23 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
"@next/env": ["@next/env@16.3.8", "", {}, "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw=="],
"@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="],
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
@@ -942,9 +943,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.7", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.27 <16 || >=16.3.8" }, "bin": { "open-next": "dist/index.js" } }, "sha512-ELezSARrTfxp/76cz41eQjki1AV0CuBUKOznU3w/tOk7i87x867RkkYho+gDkog9gpnGzefHdJu463o9VaabNQ=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.6", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.26 <16 || >=16.3.6" }, "bin": { "open-next": "dist/index.js" } }, "sha512-J5mzpWo6duetc3ZtcyBxrUqtD6Dwh8msRcMTNQdvpsgTUjEFp03AJmSOnJ/7BKkuF791YTQgjk4iJYIIYLmKqA=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.8", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.7", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.27 <16 || >=16.3.8", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-7rrsBqd234GBBnHITobS0gagT4/fSfHJwm+ogYTyUb7ubg3PGC06biJgL9l4DZabFeu/ucqvml80sBr5torBgg=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.7", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.6", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.26 <16 || >=16.3.6", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-obZ5l96+MUmGqKd7akJlT9tv0XAvaUDDJj13MyGTVTzA+lbal8kNRoUwei08rtu1Gv+anASAwwMP3dvzcZx+Jw=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -2648,7 +2649,7 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"next": ["next@16.3.8", "", { "dependencies": { "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.8", "@next/swc-darwin-x64": "16.3.8", "@next/swc-linux-arm64-gnu": "16.3.8", "@next/swc-linux-arm64-musl": "16.3.8", "@next/swc-linux-x64-gnu": "16.3.8", "@next/swc-linux-x64-musl": "16.3.8", "@next/swc-win32-arm64-msvc": "16.3.8", "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw=="],
"next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
+3 -3
View File
@@ -20,8 +20,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.7",
"@opennextjs/cloudflare": "1.20.8",
"@opennextjs/aws": "4.1.6",
"@opennextjs/cloudflare": "1.20.7",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -51,7 +51,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.3.8",
"next": "^16.3.6",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -117,8 +117,8 @@ function Color(props: MarkedLeafProps<DocumentMarkColor>) {
return (
<span
className={tcls([
textColorToStyle[mark.data.text ?? 'default'],
backgroundColorToStyle[mark.data.background ?? 'default'],
textColorToStyle[mark.data.text],
backgroundColorToStyle[mark.data.background],
])}
>
{children}
@@ -6,7 +6,8 @@ import type { GitBookSiteContext } from '@/lib/context';
mock.module('server-only', () => ({}));
const { fetchPageData, getLowercasePathnameRedirect } = await import('./fetch');
const { fetchPageData, getLowercasePathnameRedirect, resolveMissingPagePath } =
await import('./fetch');
const { normalizeURL } = await import('@/lib/data/urls');
const page = {
@@ -94,6 +95,70 @@ describe('fetchPageData', () => {
});
});
describe('resolveMissingPagePath', () => {
function createRedirectContext(options: {
siteRedirect?: { target: string; permanent?: boolean };
spaceRedirectPageId?: string;
}) {
const getSiteRedirectBySource = mock(async ({ source }: { source: string }) =>
options.siteRedirect && source === '/old'
? {
data: {
target: options.siteRedirect.target,
redirect: { permanent: options.siteRedirect.permanent ?? false },
},
}
: { error: { code: 404, message: 'Not found' } }
);
const getRevisionPageByPath = mock(async () =>
options.spaceRedirectPageId
? { data: { id: options.spaceRedirectPageId } }
: { error: { code: 404, message: 'Not found' } }
);
return {
organizationId: 'org-1',
site: { id: 'site-1' },
space: { id: 'space-1', revision: 'revision-1' },
revisionId: 'revision-1',
revision: { pages: [page] },
linker: {
toPathInSpace: (path: string) => path,
toRelativePathInSite: (path: string) => path,
toLinkForContent: (url: string) => new URL(url).pathname,
},
dataFetcher: { getSiteRedirectBySource, getRevisionPageByPath },
} as unknown as GitBookSiteContext;
}
it('resolves a site redirect', async () => {
const context = createRedirectContext({
siteRedirect: { target: 'https://docs.example.com/new', permanent: true },
});
expect(await resolveMissingPagePath(context, 'old')).toEqual({
type: 'redirect',
destination: '/new',
permanent: true,
});
});
it('resolves a space redirect to a page', async () => {
const context = createRedirectContext({ spaceRedirectPageId: page.id });
expect(await resolveMissingPagePath(context, 'old')).toEqual({
type: 'page',
page: { page, ancestors: [] },
});
});
it('returns undefined when nothing matches', async () => {
const context = createRedirectContext({});
expect(await resolveMissingPagePath(context, 'old')).toBeUndefined();
});
});
describe('getLowercasePathnameRedirect', () => {
it('redirects ASCII paths with uppercase letters', () => {
expect(getLowercasePathnameRedirect('Foo/Bar')).toBe('foo/bar');
@@ -2,13 +2,14 @@ import { permanentRedirect, redirect } from 'next/navigation';
import {
CustomizationPageActionType,
type RevisionPageDocument,
SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH,
SITE_REDIRECT_SOURCE_PATH_PATTERN,
} from '@gitbook/api';
import type { GitBookSiteContext } from '@/lib/context';
import { getDataOrNull } from '@/lib/data';
import { resolvePageId } from '@/lib/pages';
import { type ResolvedPagePath, resolvePageId } from '@/lib/pages';
import { withLeadingSlash } from '@/lib/paths';
import { resolveSiteSpacePagePath } from '@/lib/sites';
@@ -70,7 +71,7 @@ export async function fetchPageData(context: GitBookSiteContext, params: PagePar
* If the path can't be found, we try to resolve it from the API to handle redirects.
*/
async function resolvePage(context: GitBookSiteContext, params: PagePathParams | PageIdParams) {
const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
const { revision } = context;
if ('pageId' in params) {
return resolvePageId(revision.pages, params.pageId);
@@ -85,72 +86,102 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
return page;
}
const fallback = await resolveMissingPagePath(context, rawPathname);
if (fallback?.type === 'redirect') {
return fallback.permanent
? permanentRedirect(fallback.destination)
: redirect(fallback.destination);
}
return fallback?.page;
}
export type MissingPagePathResolution =
| {
type: 'redirect';
/** Destination as returned by `linker.toLinkForContent` (absolute path or URL). */
destination: string;
permanent: boolean;
}
| {
type: 'page';
page: ResolvedPagePath<RevisionPageDocument>;
};
/**
* Resolve a pathname that doesn't match any page of the revision, using site-level and space-level redirects.
*/
export async function resolveMissingPagePath(
context: GitBookSiteContext,
rawPathname: string
): Promise<MissingPagePathResolution | undefined> {
const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
// API has a limit of less than 512 characters for the source path, so we use the same limit here.
if (rawPathname.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH) {
const SITE_REDIRECT_SOURCE_PATH_REGEX = new RegExp(SITE_REDIRECT_SOURCE_PATH_PATTERN);
const redirectPathname = withLeadingSlash(rawPathname);
// If a page can't be found, we try with the API, in case we have a redirect at site level.
const redirectSources = new Set(
[
// Test the pathname relative to the root
// For example hello/world -> section/variant/hello/world
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname)),
// Test the pathname relative to the content/space
// For example hello/world -> /hello/world
redirectPathname,
]
.map(toSiteRedirectSourceCandidate)
.filter((source) => SITE_REDIRECT_SOURCE_PATH_REGEX.test(source))
);
if (rawPathname.length >= SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH) {
return undefined;
}
for (const source of redirectSources) {
// We try to resolve the site redirect
const resolvedSiteRedirect =
source.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH &&
(await getDataOrNull(
context.dataFetcher.getSiteRedirectBySource({
organizationId,
siteId: site.id,
source,
siteShareKey: shareKey,
})
));
if (resolvedSiteRedirect) {
const destination = linker.toLinkForContent(resolvedSiteRedirect.target);
const isPublicLiveContext =
!shareKey &&
!context.changeRequest &&
!context.preview &&
context.revisionId === context.space.revision &&
!context.isLoggedInVisitor;
if (
const SITE_REDIRECT_SOURCE_PATH_REGEX = new RegExp(SITE_REDIRECT_SOURCE_PATH_PATTERN);
const redirectPathname = withLeadingSlash(rawPathname);
// If a page can't be found, we try with the API, in case we have a redirect at site level.
const redirectSources = new Set(
[
// Test the pathname relative to the root
// For example hello/world -> section/variant/hello/world
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname)),
// Test the pathname relative to the content/space
// For example hello/world -> /hello/world
redirectPathname,
]
.map(toSiteRedirectSourceCandidate)
.filter((source) => SITE_REDIRECT_SOURCE_PATH_REGEX.test(source))
);
for (const source of redirectSources) {
// We try to resolve the site redirect
const resolvedSiteRedirect =
source.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH &&
(await getDataOrNull(
context.dataFetcher.getSiteRedirectBySource({
organizationId,
siteId: site.id,
source,
siteShareKey: shareKey,
})
));
if (resolvedSiteRedirect) {
const isPublicLiveContext =
!shareKey &&
!context.changeRequest &&
!context.preview &&
context.revisionId === context.space.revision &&
!context.isLoggedInVisitor;
return {
type: 'redirect',
destination: linker.toLinkForContent(resolvedSiteRedirect.target),
permanent: Boolean(
resolvedSiteRedirect.redirect?.permanent &&
!resolvedSiteRedirect.redirect.draft &&
isPublicLiveContext
) {
return permanentRedirect(destination);
}
return redirect(destination);
}
}
// If page still can't be found, we try with the API, in case we have a redirect at space level.
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
// The page rendering will take care of redirecting to a normalized pathname.
const resolved = await getDataOrNull(
context.dataFetcher.getRevisionPageByPath({
spaceId: space.id,
revisionId: revisionId,
path: rawPathname,
})
);
if (resolved) {
return resolvePageId(revision.pages, resolved.id);
),
};
}
}
return undefined;
// If page still can't be found, we try with the API, in case we have a redirect at space level.
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
// The page rendering will take care of redirecting to a normalized pathname.
const resolved = await getDataOrNull(
context.dataFetcher.getRevisionPageByPath({
spaceId: space.id,
revisionId: revisionId,
path: rawPathname,
})
);
const page = resolved ? resolvePageId(revision.pages, resolved.id) : undefined;
return page ? { type: 'page', page } : undefined;
}
/**
-24
View File
@@ -1,24 +0,0 @@
/**
* Describe the `ask` and `goal` query parameters of the ask endpoint, for agent-facing prompts.
*/
export function renderAskParametersDescription(): string {
return `\`ask\` is the immediate question: it should be specific, self-contained, and written in natural language.
\`goal\` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with \`ask=how do I create an API token\`, a goal like \`build a script that syncs our docs to a CMS\` lets GitBook tailor the answer to that use case.`;
}
/**
* Render the "Querying This Documentation" section of the agent instructions.
* `pageUrl` is the URL of the current page, which the `ask` and `goal` parameters are appended to.
*/
export function renderQueryingDocumentation(options: { pageUrl: string }): string {
const { pageUrl } = options;
return `Perform an HTTP GET request on the following URL with the \`ask\` and \`goal\` query parameters:
\`\`\`
GET ${pageUrl}?ask=<question>&goal=<user_goal>
\`\`\`
${renderAskParametersDescription()}
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.`;
}
+3 -1
View File
@@ -1,7 +1,9 @@
import type { DocumentTextColor } from '@gitbook/api';
import type { DocumentMarkColor } from '@gitbook/api';
import type { ClassValue } from '@/lib/tailwind';
type DocumentTextColor = DocumentMarkColor['data']['text'] | 'pink' | 'violet' | 'cyan' | '$tint';
export const textColorToStyle = {
default: [],
blue: ['text-[#0067d1] dark:text-[#7dbcff]'],
@@ -0,0 +1,93 @@
import { describe, expect, it, mock } from 'bun:test';
import type { RevisionPageDocument } from '@gitbook/api';
import type { GitBookSiteContext } from '@/lib/context';
import { createLinker } from '@/lib/links';
mock.module('server-only', () => ({}));
const { servePageMarkdown, toMarkdownDestination } = await import('./markdownPage');
const page = {
id: 'page-1',
title: 'New page',
kind: 'sheet',
type: 'document',
path: 'new-page',
slug: 'new-page',
pages: [],
} as unknown as RevisionPageDocument;
function createContext(options: {
siteRedirect?: { target: string; permanent?: boolean };
spaceRedirectPageId?: string;
}) {
return {
organizationId: 'org-1',
site: { id: 'site-1' },
siteSpace: { id: 'site-space-1' },
space: { id: 'space-1', revision: 'revision-1' },
revisionId: 'revision-1',
revision: { pages: [page] },
linker: createLinker({
host: 'docs.example.com',
siteBasePath: '/docs/',
spaceBasePath: '/docs/',
}),
dataFetcher: {
getSiteRedirectBySource: async ({ source }: { source: string }) =>
options.siteRedirect && source === '/old-page'
? {
data: {
target: options.siteRedirect.target,
redirect: { permanent: options.siteRedirect.permanent ?? false },
},
}
: { error: { code: 404, message: 'Not found' } },
getRevisionPageByPath: async () =>
options.spaceRedirectPageId
? { data: { id: options.spaceRedirectPageId } }
: { error: { code: 404, message: 'Not found' } },
},
} as unknown as GitBookSiteContext;
}
describe('servePageMarkdown', () => {
it('redirects to the markdown version of a site redirect target', async () => {
const context = createContext({
siteRedirect: { target: 'https://docs.example.com/docs/new-page', permanent: true },
});
const response = await servePageMarkdown(context, 'old-page');
expect(response.status).toBe(308);
expect(response.headers.get('Location')).toBe('/docs/new-page.md');
});
it('redirects to the markdown version of a space redirect target', async () => {
const context = createContext({ spaceRedirectPageId: page.id });
const response = await servePageMarkdown(context, 'old-page');
expect(response.status).toBe(307);
expect(response.headers.get('Location')).toBe('/docs/new-page.md');
});
});
describe('toMarkdownDestination', () => {
it('appends .md to same-site paths', () => {
expect(toMarkdownDestination('/docs/new-page')).toBe('/docs/new-page.md');
expect(toMarkdownDestination('/docs/new-page/?a=1#b')).toBe('/docs/new-page.md?a=1#b');
});
it('points the site root to its markdown route', () => {
expect(toMarkdownDestination('/')).toBe('/.md');
expect(toMarkdownDestination('/?a=1#b')).toBe('/.md?a=1#b');
});
it('leaves markdown paths and external URLs untouched', () => {
expect(toMarkdownDestination('/docs/new-page.md')).toBe('/docs/new-page.md');
expect(toMarkdownDestination('https://example.com/page')).toBe('https://example.com/page');
});
});
+85 -14
View File
@@ -1,7 +1,7 @@
import type { RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
import { resolveMissingPagePath } from '@/components/SitePage/fetch';
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
import { renderQueryingDocumentation } from '@/lib/ask-prompt';
import type { GitBookSiteContext } from '@/lib/context';
import { getExposableError } from '@/lib/data';
import { linkerWithMarkdownPages } from '@/lib/links';
@@ -23,12 +23,36 @@ export async function servePageMarkdown(baseContext: GitBookSiteContext, pagePat
linker: linkerWithMarkdownPages(baseContext.linker),
};
const pageLookup = resolveSiteSpacePagePathDocumentOrGroup(
context.siteSpace,
context.revision.pages,
pagePath
);
const pageLookup =
resolveSiteSpacePagePathDocumentOrGroup(
context.siteSpace,
context.revision.pages,
pagePath
) ??
// Page paths are lowercase, match the case-insensitive lookup of HTML pages.
resolveSiteSpacePagePathDocumentOrGroup(
context.siteSpace,
context.revision.pages,
pagePath.toLowerCase()
);
if (!pageLookup) {
const fallback = await resolveMissingPagePath(baseContext, pagePath);
if (fallback?.type === 'redirect') {
return markdownRedirect(
toMarkdownDestination(fallback.destination),
fallback.permanent
);
}
if (fallback?.type === 'page') {
return markdownRedirect(
context.linker.toPathForPage({
pages: context.revision.pages,
page: fallback.page.page,
}),
false
);
}
// Generates a markdown body for missing pages. Return this with a 200 status (not 404) because agents discard 404 response bodies.=
return {
markdown: renderNotFoundMarkdown(context, pagePath),
@@ -64,17 +88,39 @@ function getMarkdownRobots(
return context.isAiAgent ? 'index, follow' : 'noindex';
}
/**
* Point a redirect destination to its markdown version, so agents keep receiving markdown.
* Destinations outside the site are returned as full URLs and left untouched.
*/
export function toMarkdownDestination(destination: string): string {
if (!destination.startsWith('/')) {
return destination;
}
const url = new URL(destination, 'https://gitbook.invalid');
const pathname = url.pathname.replace(/\/+$/, '');
if (pathname.endsWith('.md')) {
return destination;
}
// A root destination trims to an empty pathname; its markdown route is `/.md`.
return `${pathname || '/'}.md${url.search}${url.hash}`;
}
function markdownRedirect(location: string, permanent: boolean) {
// Same status codes as Next's `redirect` / `permanentRedirect`.
return new Response(null, {
status: permanent ? 308 : 307,
headers: { Location: location, Vary: 'Accept' },
});
}
function renderNotFoundMarkdown(context: GitBookSiteContext, pagePath: string) {
const similarPages = getSimilarPages(context.revision.pages, pagePath, 5);
const sitemapUrl = context.linker.toAbsoluteURL(context.linker.toPathInSite('sitemap.md'));
const fullContentUrl = context.linker.toAbsoluteURL(
context.linker.toPathInSite('llms-full.txt')
);
const askPageUrl = context.linker.toAbsoluteURL(
context.linker.toPathForPagePath({
path: similarPages[0]?.path ?? 'docs/example',
})
);
return `# Page Not Found
@@ -91,7 +137,20 @@ If the exact page cannot be found, you can still retrieve the information using
### Option 1 — Ask a question (recommended)
${renderQueryingDocumentation({ pageUrl: askPageUrl })}
Perform an HTTP GET request on the documentation index with the \`ask\` parameter, and the optional \`goal\` parameter:
\`\`\`
GET ${context.linker.toAbsoluteURL(
context.linker.toPathForPagePath({
path: similarPages[0]?.path ?? 'docs/example',
})
)}?ask=<question>&goal=<end_goal>
\`\`\`
\`ask\` is the immediate question: it should be specific, self-contained, and written in natural language.
\`goal\` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.
### Option 2 — Browse the documentation index
@@ -138,7 +197,16 @@ This documentation is published with GitBook. GitBook is the documentation platf
## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.
${renderQueryingDocumentation({ pageUrl })}
Perform an HTTP GET request on the current page URL with the \`ask\` query parameter, and the optional \`goal\` query parameter:
\`\`\`
GET ${pageUrl}?ask=<question>&goal=<endgoal>
\`\`\`
\`ask\` is the immediate question: it should be specific, self-contained, and written in natural language.
\`goal\` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.
Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
`;
@@ -148,11 +216,14 @@ Use this mechanism when the answer is not explicitly present in the current page
* Return a markdown content.
*/
export async function serveMarkdown(
fn: () => Promise<string | { markdown: string; robots: string }>,
fn: () => Promise<string | { markdown: string; robots: string } | Response>,
isChatGPT?: boolean
) {
try {
const result = await fn();
if (result instanceof Response) {
return result;
}
const { markdown, robots } =
typeof result === 'string' ? { markdown: result, robots: 'noindex' } : result;
return new Response(markdown, {