Compare commits

..

2 Commits

Author SHA1 Message Date
Brett Jephson 078eacce45 changeset 2026-09-28 22:19:28 +01:00
Brett Jephson 9f168ff93b Resolve a page link by its recorded path when its page is gone
A page or anchor ref whose page ID no longer exists now looks for a page at
the path recorded on the ref, so a link keeps working after its target is
deleted and re-created at the same path. The ID still wins while it exists,
so moves and renames resolve as before. The API records the path on refs it
writes; until @gitbook/api ships the field, it is read untyped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rt9YwdSUu7gHehYbEkvEJ8
2026-09-28 22:19:18 +01:00
99 changed files with 557 additions and 2108 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Stop the `?ask` query briefly appearing in the search field before an integration assistant opens.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Recolor text over a background page cover in the first frame after hydration.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep links working when their target page is deleted and re-created at the same path, by falling back to the path recorded on the link.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Automatically resolve GitHub and GitLab page links to matching pages in the same published site, including cross-space links imported before their target page was available.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix page cover image jumping on load
-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
---
Fix inline Ask AI inputs and buttons doing nothing before search is opened.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Send the previous page's URL with site insights events so broken links can be traced to the page linking to them.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix Loom videos staying blank on pages with many Loom embeds by lazy-loading them.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Improve dark theme contrast for Mermaid edge labels.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Improve the prompt for agents to ask questions.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Use the page's tag title, when set, for the HTML `<title>` of published pages.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Export page subtrees as PDF in batches of 20 pages, with links to the previous and next batch, so large exports no longer crash.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Scroll the table of contents to the active page after client-side navigation.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Restore the Next.js dev-mode OOM patch that was silently dropped by the 16.3.8 upgrade.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the first item of a sidebar page group sometimes appearing cut off after navigating.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Point the "Powered by GitBook" trademark link to gitbook.com/powered-by.
+19 -18
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.3",
"@opennextjs/cloudflare": "1.20.5",
"@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,10 +342,11 @@
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"next@16.3.8": "patches/next@16.3.8.patch",
"next@16.3.6": "patches/next@16.3.6.patch",
},
"overrides": {
"@codemirror/state": "6.4.1",
"@opennextjs/aws": "4.1.5",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"axios": "1.8.4",
@@ -355,7 +356,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.204.0",
"@gitbook/api": "0.202.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -727,7 +728,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
"@gitbook/api": ["@gitbook/api@0.204.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-SJCe0Ipyt+V5HZX4EZpFO8AHTOWIMwj2ljQTbcqgU7rVOAyCNDzILp8mRk9stbboSi1pv09+rDnZK1ZguPvFTw=="],
"@gitbook/api": ["@gitbook/api@0.202.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-qhrjEQbNNmCljR0AgP79+BsVh9yelh9TIBg9X3TyYx5a3lJwDiT3sw1ND9DFQSxGHp8hH5w+20KJHn/IDpTbaw=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -891,23 +892,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=="],
@@ -943,9 +944,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.5", "", { "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.24 <16 || >=16.3.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-41LNGtS5R5SCfn9zFxHx9QKJIRnmf1uZH+jonHkUMlRKER0bSmUSGjK6GJF7e50XdLXFpTHzaqgRvGmzvr9Btw=="],
"@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.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.3", "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.24 <16 || >=16.3.3", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-Y4qCnHTYMa8waEHvf7iqDJuiresuREp0zithSZ8hT33Wn9WyvOUMBM0SWNH961Wv7ynHRdxAJ0ggmnQ3aSdzdg=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -2649,7 +2650,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=="],
+4 -3
View File
@@ -19,7 +19,8 @@
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.27.3",
"axios": "1.8.4"
"axios": "1.8.4",
"@opennextjs/aws": "4.1.5"
},
"private": true,
"scripts": {
@@ -48,7 +49,7 @@
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.204.0",
"@gitbook/api": "0.202.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -67,6 +68,6 @@
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
"next@16.3.8": "patches/next@16.3.8.patch"
"next@16.3.6": "patches/next@16.3.6.patch"
}
}
+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.3",
"@opennextjs/cloudflare": "1.20.5",
"@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",
+3 -7
View File
@@ -119,13 +119,9 @@ export function useAI(): AIContext {
icon: <AISearchIcon />,
open: (query?: string) => {
if (query) {
setSearchState((prev) => ({
...prev,
query: null,
ask: query,
scope: prev?.scope ?? 'default',
open: true,
}));
setSearchState((prev) =>
prev ? { ...prev, query: null, ask: query, open: true } : null
);
}
},
pageAction: false,
@@ -247,15 +247,11 @@ async function renderMermaidDiagram(args: {
const { source, id, darkMode, mermaidRuntimeURL } = args;
const { mermaid } = await loadMermaid(mermaidRuntimeURL);
// Mermaid's default dark edge label pill only reaches 4.43:1 contrast, below WCAG AA.
const themeVariables = darkMode ? { edgeLabelBackground: '#3a3a3a' } : undefined;
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
darkMode,
theme: darkMode ? 'dark' : undefined,
themeVariables,
});
const renderContainer = createMermaidRenderContainer();
@@ -37,10 +37,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
<>
<div
dangerouslySetInnerHTML={{
__html:
context.mode !== 'print' && shouldLazyLoad(block.data.url)
? lazyLoadIframes(embed.html)
: embed.html,
__html: embed.html,
}}
data-visual-test="blackout"
/>
@@ -76,23 +73,6 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
);
}
/**
* Pages with many Loom embeds exhaust the browser's request budget
* (ERR_INSUFFICIENT_RESOURCES) when every player loads at once, leaving some blank.
*/
function lazyLoadIframes(html: string): string {
return html.replace(/<iframe\b(?![^>]*\bloading=)/gi, '<iframe loading="lazy"');
}
function shouldLazyLoad(url: string): boolean {
try {
const { hostname } = new URL(url);
return hostname === 'loom.com' || hostname.endsWith('.loom.com');
} catch {
return false;
}
}
/**
* Create an integration block with an unfurl action from the GitBook Embed response.
*/
@@ -52,8 +52,8 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const anchorElement = (
<InlineLinkAnchor
href={resolved.href}
contentRef={resolved.resolvedRef ?? inline.data.ref}
isExternal={(resolved.resolvedRef ?? inline.data.ref).kind === 'url'}
contentRef={inline.data.ref}
isExternal={inline.data.ref.kind === 'url'}
>
{inlinesElement}
</InlineLinkAnchor>
@@ -121,7 +121,7 @@ function InlineLinkTooltipWrapper(props: {
let breadcrumbs = resolved.ancestors ?? [];
const isMailto = resolved.href.startsWith('mailto:');
const isExternal = (resolved.resolvedRef ?? inline.data.ref).kind === 'url';
const isExternal = inline.data.ref.kind === 'url';
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
if (isMailto) {
@@ -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}
@@ -1,7 +1,6 @@
import type { CustomizationDefaultThemeMode } from '@gitbook/api';
import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { LoadIntegrations } from '../Integrations';
import { NavigationLoader } from '../primitives/NavigationLoader';
import { SpaceLayoutServerContext } from '../SpaceLayout';
import { Trademark } from '../TableOfContents/Trademark';
@@ -94,8 +93,6 @@ export async function EmbeddableRootLayout({
/>
</SpaceLayoutServerContext>
</EmbeddableAIContextProvider>
{/* Embeds load no integrations; this marks them loaded so search stops waiting. */}
<LoadIntegrations />
</SiteLayoutClientContexts>
</CustomizationRootLayout>
);
@@ -4,7 +4,6 @@ import * as React from 'react';
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import type * as api from '@gitbook/api';
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
import { type CurrentContentContext, useCurrentContent } from '../hooks';
@@ -76,15 +75,12 @@ export function InsightsProvider(props: InsightsProviderProps) {
[pathname: string]:
| {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext?: InsightsEventPageContext;
}
| undefined;
}>({});
// Href of the last page an event was tracked on; `undefined` until the first event.
const lastUrlRef = React.useRef<string | null | undefined>(undefined);
/**
* Synchronously flush all the pending events.
@@ -109,7 +105,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
allEvents.push(
...transformEvents({
url: eventsForPathname.url,
previousUrl: eventsForPathname.previousUrl,
events: eventsForPathname.events,
context: currentContent,
pageContext: eventsForPathname.pageContext,
@@ -159,9 +154,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
) => {
const pathname = window.location.pathname;
const previous = eventsRef.current[pathname];
const lastUrl =
lastUrlRef.current === undefined ? getSameOriginReferrer() : lastUrlRef.current;
lastUrlRef.current = window.location.href;
eventsRef.current[pathname] = {
// An explicitly-provided context wins so page-scoped events (e.g. feedback) can
// attribute to their page even when the pathname's ambient context has none — such
@@ -169,7 +161,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
// context keep the stored one.
pageContext: ctx ?? previous?.pageContext,
url: previous?.url ?? window.location.href,
previousUrl: previous ? previous.previousUrl : lastUrl,
events: [
...(previous?.events ?? []),
{
@@ -223,18 +214,6 @@ export function useTrackEvent(): TrackEventCallback {
return React.useContext(InsightsContext);
}
/**
* The referrer when it's another page of this origin, so a full page load (e.g. an absolute link
* to a missing page) still records the page it came from.
*/
function getSameOriginReferrer(): string | null {
if (document.referrer === window.location.href || !URL.canParse(document.referrer)) {
return null;
}
const referrer = new URL(document.referrer);
return referrer.origin === window.location.origin ? referrer.href : null;
}
/**
* Post the events to the server.
*/
@@ -261,7 +240,6 @@ function sendEvents(args: { eventUrl: string; events: api.SiteInsightsEvent[] })
*/
function transformEvents(input: {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext: InsightsEventPageContext;
@@ -280,11 +258,6 @@ function transformEvents(input: {
const location: api.SiteInsightsEventLocation = {
url: input.url,
// The embed's navigation is between its own tabs, not pages of the site.
previousUrl:
input.pageContext.displayContext === SiteInsightsDisplayContext.Embed
? null
: input.previousUrl,
siteSection: input.context.siteSectionId ?? null,
siteSpace: input.context.siteSpaceId ?? null,
space: input.context.spaceId,
@@ -147,10 +147,6 @@ export function useCustomCookieBanner(): CustomCookieBannerStore {
export function LoadIntegrations() {
React.useEffect(() => {
return whenIntegrationsReady(() => {
// Integrations must only receive `load` once, even if this is mounted twice.
if (integrationsStore.getState().loaded) {
return;
}
dispatchGitBookIntegrationEvent('load');
integrationsStore.setState({ loaded: true });
});
@@ -3,13 +3,6 @@ import { describe, expect, it } from 'bun:test';
import { RevisionPageType } from '@gitbook/api';
import { createPDFLinker } from './linker';
import { selectPages } from './selectPages';
import {
PDF_DEFAULT_LIMIT,
PDF_SUBTREE_DEFAULT_LIMIT,
getPDFSearchParams,
getPDFURLSearchParams,
} from './urls';
import { createLinker, linkerWithAbsoluteURLs } from '@/lib/links';
import { getLinkerForSiteSpace } from '@/lib/sites';
@@ -22,10 +15,6 @@ function createDocumentPage(id: string, path: string) {
} as any;
}
function createDocumentPageWithChildren(id: string, children: any[]) {
return { ...createDocumentPage(id, id), pages: children };
}
function createGroupPage(id: string, path: string) {
return {
id,
@@ -219,183 +208,3 @@ describe('createPDFLinker', () => {
).toBe('https://open-2v.gitbook.com/~space/HJ1ltuWFvsArFWtevnRn~gitbook/pdf/respond');
});
});
describe('createPDFLinker with batches', () => {
const baseLinker = createLinker({
host: 'open-2v.gitbook.com',
siteBasePath: '/~space/HJ1ltuWFvsArFWtevnRn',
spaceBasePath: '/~space/HJ1ltuWFvsArFWtevnRn',
});
const getBatchURL = (page: { id: string }) =>
page.id === 'other-batch'
? 'https://open-2v.gitbook.com/~gitbook/pdf?offset=20'
: undefined;
it('prefers the published URL for documents exported in another batch', () => {
const linker = createPDFLinker(
baseLinker,
[{ page: createDocumentPage('included', '') }],
createPublishedLinker(),
{ getBatchURL }
);
expect(
linker.toPathForPage({
pages: [
createDocumentPage('included', ''),
createDocumentPage('other-batch', 'respond'),
],
page: createDocumentPage('other-batch', 'respond'),
})
).toBe('https://docs.vectra.ai/deployment/respond');
});
it('links to the batch containing a page when there is no published URL', () => {
const linker = createPDFLinker(
baseLinker,
[{ page: createDocumentPage('included', '') }],
undefined,
{ getBatchURL }
);
expect(
linker.toPathForPage({
pages: [createDocumentPage('other-batch', 'respond')],
page: createDocumentPage('other-batch', 'respond'),
anchor: 'faq',
})
).toBe('https://open-2v.gitbook.com/~gitbook/pdf?offset=20#page-other-batch-faq');
});
it('links group pages to the batch containing them', () => {
const linker = createPDFLinker(
baseLinker,
[{ page: createDocumentPage('included', '') }],
createPublishedLinker(),
{ getBatchURL }
);
expect(
linker.toPathForPage({
pages: [createGroupPage('other-batch', 'group')],
page: createGroupPage('other-batch', 'group'),
})
).toBe('https://open-2v.gitbook.com/~gitbook/pdf?offset=20#page-other-batch');
});
});
describe('getPDFSearchParams', () => {
it('defaults to a small batch for subtree exports', () => {
expect(getPDFSearchParams(new URLSearchParams('page=abc&only=yes'))).toEqual({
page: 'abc',
only: true,
limit: PDF_SUBTREE_DEFAULT_LIMIT,
offset: 0,
});
expect(PDF_SUBTREE_DEFAULT_LIMIT).toBe(20);
});
it('keeps the larger default for whole-space and single-page exports', () => {
expect(getPDFSearchParams(new URLSearchParams('')).limit).toBe(PDF_DEFAULT_LIMIT);
expect(getPDFSearchParams(new URLSearchParams('page=abc')).limit).toBe(PDF_DEFAULT_LIMIT);
expect(PDF_DEFAULT_LIMIT).toBe(100);
});
it('parses an explicit limit and offset', () => {
const params = getPDFSearchParams(
new URLSearchParams('page=abc&only=yes&limit=5&offset=10')
);
expect(params.limit).toBe(5);
expect(params.offset).toBe(10);
});
it('ignores invalid limit and offset values', () => {
const params = getPDFSearchParams(
new URLSearchParams('page=abc&only=yes&limit=nope&offset=-3')
);
expect(params.limit).toBe(PDF_SUBTREE_DEFAULT_LIMIT);
expect(params.offset).toBe(0);
expect(getPDFSearchParams(new URLSearchParams('limit=0&offset=1.5'))).toMatchObject({
limit: PDF_DEFAULT_LIMIT,
offset: 0,
});
});
it('round-trips through getPDFURLSearchParams', () => {
const params = { page: 'abc', only: true, limit: 20, offset: 40, back: 'false' };
expect(getPDFSearchParams(getPDFURLSearchParams(params))).toEqual(params);
});
it('omits offset and limit when not set', () => {
expect(getPDFURLSearchParams({ page: 'abc', only: true }).toString()).toBe(
'page=abc&only=yes'
);
});
});
describe('selectPages', () => {
const children = Array.from({ length: 45 }, (_, index) =>
createDocumentPage(`child-${index}`, `child-${index}`)
);
const rootPages = [createDocumentPageWithChildren('root', children)];
const ids = (pages: { page: { id: string } }[] | undefined) =>
(pages ?? []).map(({ page }) => page.id);
const childIds = (from: number, to: number) => children.slice(from, to).map((page) => page.id);
it('renders the first batch of a subtree by default', () => {
const selection = selectPages(
rootPages,
getPDFSearchParams(new URLSearchParams('page=root&only=yes'))
);
expect(selection?.total).toBe(46);
expect(selection?.offset).toBe(0);
expect(ids(selection?.pages)).toEqual(['root', ...childIds(0, 19)]);
expect(selection?.allPages).toHaveLength(46);
});
it('slices the batch from offset to offset + limit', () => {
const selection = selectPages(rootPages, {
page: 'root',
only: true,
limit: 20,
offset: 20,
});
expect(selection?.offset).toBe(20);
expect(ids(selection?.pages)).toEqual(childIds(19, 39));
});
it('returns a shorter last batch', () => {
const selection = selectPages(rootPages, {
page: 'root',
only: true,
limit: 20,
offset: 40,
});
expect(ids(selection?.pages)).toEqual(childIds(39, 45));
});
it('falls back to the last batch when offset is past the end', () => {
const selection = selectPages(rootPages, {
page: 'root',
only: true,
limit: 20,
offset: 100,
});
expect(selection?.offset).toBe(26);
expect(selection?.pages).toHaveLength(20);
});
it('only selects the page itself without only', () => {
const selection = selectPages(rootPages, { page: 'root', limit: 100, offset: 0 });
expect(ids(selection?.pages)).toEqual(['root']);
expect(selection?.total).toBe(1);
});
it('returns null for an unknown page', () => {
expect(selectPages(rootPages, { page: 'missing', limit: 100, offset: 0 })).toBeNull();
});
});
+73 -30
View File
@@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
import * as React from 'react';
import {
type Revision,
type RevisionPageDocument,
type RevisionPageGroup,
type SiteCustomizationSettings,
@@ -15,8 +16,7 @@ import { ImagesLoadingStatus } from './ImagesLoadingStatus';
import { createPDFLinker, getPagePDFContainerId } from './linker';
import { PageControlButtons } from './PageControlButtons';
import { PDFPrintControls } from './PDFPrintControls';
import { selectPages } from './selectPages';
import { getPDFSearchParams, getPDFURLSearchParams } from './urls';
import { type PDFSearchParams, getPDFSearchParams } from './urls';
import { DocumentView } from '@/components/DocumentView';
import { Trademark } from '@/components/TableOfContents/Trademark';
import type { PolymorphicComponentProp } from '@/components/utils/types';
@@ -27,10 +27,13 @@ import type { GitBookSiteContext, GitBookSpaceContext } from '@/lib/context';
import { getPageDocument } from '@/lib/data';
import { type GitBookLinker, createLinker, linkerWithAbsoluteURLs } from '@/lib/links';
import './pdf.css';
import { resolvePageId } from '@/lib/pages';
import { getLinkerForSiteSpace } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { defaultCustomization } from '@/lib/utils';
const DEFAULT_LIMIT = 100;
/**
* Generate the metadata for the PDF page.
*/
@@ -59,37 +62,13 @@ export async function PDFPage(props: {
const language = await getSpaceLanguage(baseContext);
// Compute the pages to render
const selection = selectPages(baseContext.revision.pages, pdfParams);
if (!selection) {
notFound();
}
const { pages, allPages, offset, total } = selection;
const batchParams = { ...pdfParams, offset };
const { pages, total } = selectPages(baseContext.revision.pages, pdfParams);
const pageIds = pages.map(
({ page }) => [page.id, getPagePDFContainerId(page)] as [string, string]
);
// Build a linker that create anchor links for the pages rendered in the PDF page.
const pageIndexes = new Map(allPages.map(({ page }, index) => [page.id, index]));
const linker = createPDFLinker(baseContext.linker, pages, getPublishedLinker(baseContext), {
getBatchURL: (page) => {
const index = pageIndexes.get(page.id);
if (index === undefined) {
return undefined;
}
// Align on the current batch so these links match the previous/next controls.
const batchOffset = Math.max(
0,
offset + Math.floor((index - offset) / pdfParams.limit) * pdfParams.limit
);
return baseContext.linker.toAbsoluteURL(
baseContext.linker.toPathInSpace(
`~gitbook/pdf?${getPDFURLSearchParams({ ...pdfParams, offset: batchOffset }).toString()}`
)
);
},
});
const linker = createPDFLinker(baseContext.linker, pages, getPublishedLinker(baseContext));
const context: GitBookSpaceContext = {
...baseContext,
@@ -134,7 +113,7 @@ export async function PDFPage(props: {
</div>
<PageControlButtons
params={batchParams}
params={pdfParams}
pageIds={pageIds}
total={total}
trademark={
@@ -147,7 +126,7 @@ export async function PDFPage(props: {
}
/>
{pdfParams.only || offset > 0 ? null : (
{pdfParams.only ? null : (
<PDFSpaceIntro space={context.space} customization={customization} />
)}
{pages.map(({ page }) =>
@@ -294,3 +273,67 @@ function PrintPage(
</div>
);
}
type FlatPageEntry = { page: RevisionPageDocument | RevisionPageGroup; depth: number };
/**
* Compute the ordered flat set of pages to render.
*/
function selectPages(
rootPages: Revision['pages'],
params: PDFSearchParams
): { pages: FlatPageEntry[]; total: number } {
const flattenPage = (
page: RevisionPageDocument | RevisionPageGroup,
depth: number
): FlatPageEntry[] => {
return [
{ page, depth },
...page.pages.flatMap((child) => {
if (child.type !== 'document') {
return [];
}
if (child.hidden) {
return [];
}
return flattenPage(child, depth + 1);
}),
];
};
const limitTo = (entries: FlatPageEntry[]) => {
return {
// Apply a soft-limit, the limit can be controlled by the URL to allow testing
pages: entries.slice(0, params.limit ?? DEFAULT_LIMIT),
total: entries.length,
};
};
if (params.page) {
const found = resolvePageId(rootPages, params.page);
if (!found) {
notFound();
}
if (!params.only) {
return limitTo([{ page: found.page, depth: 0 }]);
}
return limitTo(flattenPage(found.page, 0));
}
const allPages = rootPages.flatMap((page) => {
if (page.type !== 'document' && page.type !== 'group') {
return [];
}
if (page.hidden) {
return [];
}
return flattenPage(page, 0);
});
return limitTo(allPages);
}
@@ -10,6 +10,8 @@ import { Button } from '@/components/primitives';
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
const limitExtend = 50;
/**
* Dynamic controls to show active page and to let the user select between modes.
*/
@@ -17,7 +19,7 @@ export function PageControlButtons(props: {
params: PDFSearchParams;
/** Array of the [pageId, divId] */
pageIds: [string, string][];
/** Total number of pages targetted by the generation, across all batches */
/** Total number of pages targetted by the generation */
total: number;
/** Trademark to display */
trademark?: React.ReactNode;
@@ -36,10 +38,6 @@ export function PageControlButtons(props: {
const activeIndex = (activeDivId ? divIds.indexOf(activeDivId) : 0) + 1;
const activePageId = pageIds[activeIndex - 1]?.[0];
const batchEnd = params.offset + pageIds.length;
const previousCount = Math.min(params.limit, params.offset);
const nextCount = Math.min(params.limit, total - batchEnd);
return (
<>
<div
@@ -60,8 +58,6 @@ export function PageControlButtons(props: {
...params,
page: activePageId,
only: true,
limit: undefined,
offset: undefined,
}).toString()}`}
variant="secondary"
label={tString(language, 'pdf_mode_only_page')}
@@ -72,8 +68,6 @@ export function PageControlButtons(props: {
...params,
page: undefined,
only: false,
limit: undefined,
offset: undefined,
}).toString()}`}
variant="secondary"
label={tString(language, 'pdf_mode_all')}
@@ -95,7 +89,7 @@ export function PageControlButtons(props: {
'z-50'
)}
>
{previousCount > 0 || nextCount > 0 ? (
{total !== pageIds.length ? (
<div
role="banner"
className={tcls(
@@ -118,32 +112,19 @@ export function PageControlButtons(props: {
className={tcls('size-6', 'mr-3', 'mt-1')}
/>{' '}
<div>
<div>{t(language, 'pdf_limit_reached', total, pageIds.length)}</div>
<div>
{t(language, 'pdf_batch_range', params.offset + 1, batchEnd, total)}
</div>
<div className={tcls('flex', 'flex-row', 'flex-wrap', 'gap-x-3')}>
{previousCount > 0 ? (
<a
href={`?${getPDFURLSearchParams({
...params,
offset: params.offset - previousCount,
}).toString()}`}
className={tcls('underline')}
>
{t(language, 'pdf_batch_previous', previousCount)}
</a>
) : null}
{nextCount > 0 ? (
<a
href={`?${getPDFURLSearchParams({
...params,
offset: batchEnd,
}).toString()}`}
className={tcls('underline')}
>
{t(language, 'pdf_batch_next', nextCount)}
</a>
) : null}
<a
href={`?${getPDFURLSearchParams({
...params,
page: undefined,
only: false,
limit: params.limit + limitExtend,
}).toString()}`}
className={tcls('underline')}
>
{t(language, 'pdf_limit_reached_continue', limitExtend)}
</a>
</div>
</div>
</div>
@@ -165,7 +146,7 @@ export function PageControlButtons(props: {
'border'
)}
>
{t(language, 'pdf_page_of', params.offset + activeIndex, total)}
{t(language, 'pdf_page_of', activeIndex, pageIds.length)}
</div>
</div>
</>
+18 -21
View File
@@ -20,41 +20,38 @@ export function getPagePDFContainerId(
/**
* Create a custom linker for PDF exports.
*
* Pages rendered in the current batch get in-document anchors. Other pages link to the
* published site when possible; pages exported in another batch otherwise link to that batch.
* This linker generates in-document anchor links for pages that are included
* in the current PDF export, using `getPagePDFContainerId` to build the
* target element ID. For pages that are not part of the exported PDF, it
* falls back to URLs pointing to the published site if `publishedLinker` is
* provided, preserving navigability for external content. Otherwise, it uses
* absolute URLs from the base linker.
*
* @param baseLinker - The base GitBook linker used to resolve standard paths and URLs.
* @param pages - The list of pages that are included in the current PDF export.
* @param publishedLinker - Optional linker for the published space for external page links.
* @returns A `GitBookLinker` configured to generate PDF-friendly links.
*/
export function createPDFLinker(
baseLinker: GitBookLinker,
pages: { page: Revision['pages'][number] }[],
publishedLinker?: GitBookLinker,
options: {
/** URL of the export batch rendering a page, for pages outside the current batch. */
getBatchURL?: (page: RevisionPageDocument | RevisionPageGroup) => string | undefined;
} = {}
publishedLinker?: GitBookLinker
): GitBookLinker {
const pageIds = new Set(pages.map((p) => p.page.id));
return {
...baseLinker,
toPathForPage(input) {
if (pageIds.has(input.page.id)) {
if (pages.some((p) => p.page.id === input.page.id)) {
return `#${getPagePDFContainerId(input.page, input.anchor)}`;
}
// A published page outlives the export URL, so prefer it for documents.
if (input.page.type !== RevisionPageType.Group && publishedLinker) {
return publishedLinker.toPathForPage(input);
}
const batchURL = options.getBatchURL?.(input.page);
if (batchURL) {
return `${batchURL}#${getPagePDFContainerId(input.page, input.anchor)}`;
}
if (input.page.type === RevisionPageType.Group) {
return '#';
}
// For pages that are not embedded in this PDF export, keep links on the published site.
if (publishedLinker) {
return publishedLinker.toPathForPage(input);
}
return baseLinker.toAbsoluteURL(baseLinker.toPathForPage(input));
},
};
@@ -1,92 +0,0 @@
import type { Revision, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
import type { PDFSearchParams } from './urls';
import { resolvePageId } from '@/lib/pages';
export type FlatPageEntry = { page: RevisionPageDocument | RevisionPageGroup; depth: number };
export interface PDFPageSelection {
/** Pages rendered in the current batch */
pages: FlatPageEntry[];
/** Every page targeted by the export, across all batches */
allPages: FlatPageEntry[];
/** Index in `allPages` of the first page of the batch */
offset: number;
/** Total number of pages targeted by the export */
total: number;
}
/**
* Compute the ordered flat set of pages targeted by the export, and the batch to render.
* Returns `null` if the requested page doesn't exist.
*/
export function selectPages(
rootPages: Revision['pages'],
params: Pick<PDFSearchParams, 'page' | 'only' | 'limit' | 'offset'>
): PDFPageSelection | null {
const allPages = selectAllPages(rootPages, params);
if (!allPages) {
return null;
}
const total = allPages.length;
// Fall back to the last batch rather than rendering an empty one.
const offset = params.offset < total ? params.offset : Math.max(0, total - params.limit);
return {
pages: allPages.slice(offset, offset + params.limit),
allPages,
offset,
total,
};
}
function selectAllPages(
rootPages: Revision['pages'],
params: Pick<PDFSearchParams, 'page' | 'only'>
): FlatPageEntry[] | null {
if (params.page) {
const found = resolvePageId(rootPages, params.page);
if (!found) {
return null;
}
if (!params.only) {
return [{ page: found.page, depth: 0 }];
}
return flattenPage(found.page, 0);
}
return rootPages.flatMap((page) => {
if (page.type !== 'document' && page.type !== 'group') {
return [];
}
if (page.hidden) {
return [];
}
return flattenPage(page, 0);
});
}
function flattenPage(
page: RevisionPageDocument | RevisionPageGroup,
depth: number
): FlatPageEntry[] {
return [
{ page, depth },
...page.pages.flatMap((child) => {
if (child.type !== 'document') {
return [];
}
if (child.hidden) {
return [];
}
return flattenPage(child, depth + 1);
}),
];
}
+12 -56
View File
@@ -1,61 +1,33 @@
/** Default number of pages rendered per request when exporting the whole space. */
export const PDF_DEFAULT_LIMIT = 100;
/**
* Default number of pages rendered per request when exporting a page and its subtree.
* Subtrees are often OpenAPI references, which are much heavier to render than prose
* and get the request killed for CPU/memory at higher limits.
*/
export const PDF_SUBTREE_DEFAULT_LIMIT = 20;
const DEFAULT_LIMIT = 100;
export interface PDFSearchParams {
/** Page to export. If none is passed, all pages are exported. */
page?: string;
/** If true, the `page` is exported with its descendants */
/** If true, only the `page` is exported, and not its descendant */
only?: boolean;
/** Maximum number of pages to render in this batch */
/** Limit the number of pages */
limit: number;
/** Index of the first page to render in this batch */
offset: number;
/** URL to redirect back to */
back?: string;
}
/**
* Params accepted when building a PDF export URL. `limit` and `offset` fall back to the defaults.
*/
export type PDFURLParams = Omit<PDFSearchParams, 'limit' | 'offset'> & {
limit?: number;
offset?: number;
};
/**
* Default batch size for a given PDF export.
*/
export function getDefaultPDFLimit(params: Pick<PDFSearchParams, 'page' | 'only'>): number {
return params.page && params.only ? PDF_SUBTREE_DEFAULT_LIMIT : PDF_DEFAULT_LIMIT;
}
/**
* Get the PDF export params from the URL serch params.
*/
export function getPDFSearchParams(searchParams: URLSearchParams): PDFSearchParams {
const page = searchParams.has('page') ? (searchParams.get('page') ?? '') : undefined;
const only = searchParams.has('only') ? true : undefined;
const params: PDFSearchParams = {
limit:
parseInteger(searchParams.get('limit'), { min: 1 }) ??
getDefaultPDFLimit({ page, only }),
offset: parseInteger(searchParams.get('offset'), { min: 0 }) ?? 0,
limit: DEFAULT_LIMIT,
};
if (page !== undefined) {
params.page = page;
if (searchParams.has('page')) {
params.page = searchParams.get('page') ?? '';
}
if (only) {
if (searchParams.has('only')) {
params.only = true;
}
if (searchParams.has('limit')) {
params.limit = Number(searchParams.get('limit'));
}
if (searchParams.has('back')) {
params.back = searchParams.get('back') ?? '';
}
@@ -67,7 +39,7 @@ export function getPDFSearchParams(searchParams: URLSearchParams): PDFSearchPara
* Get the URL search params to use for a PDF export.
*/
export function getPDFURLSearchParams(
params: PDFURLParams,
params: PDFSearchParams,
searchParams = new URLSearchParams({})
): URLSearchParams {
if (params?.page) {
@@ -81,16 +53,9 @@ export function getPDFURLSearchParams(
searchParams.delete('only');
}
// Persist limit, offset and back
// Persist limit and back
if (params?.limit) {
searchParams.set('limit', String(params.limit));
} else {
searchParams.delete('limit');
}
if (params?.offset) {
searchParams.set('offset', String(params.offset));
} else {
searchParams.delete('offset');
}
if (params?.back) {
searchParams.set('back', String(params.back));
@@ -98,12 +63,3 @@ export function getPDFURLSearchParams(
return searchParams;
}
function parseInteger(value: string | null, options: { min: number }): number | undefined {
if (value === null || value.trim() === '') {
return undefined;
}
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= options.min ? parsed : undefined;
}
@@ -29,21 +29,18 @@ interface PageCoverImageProps {
export function PageCoverImage(props: PageCoverImageProps) {
const { imgs, y, height, mask } = props;
const { objectPositionY, isLoading } = useCoverPosition(imgs, y, {
height,
aspectRatio: PAGE_COVER_SIZE,
});
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
if (isLoading) {
return (
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full overflow-hidden" ref={containerRef}>
<div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" />
</div>
);
}
return (
<div className="h-full w-full overflow-hidden @container" style={{ height }}>
<div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}>
<img
src={imgs.light.src}
srcSet={imgs.light.srcSet}
@@ -55,7 +52,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}`,
objectPosition: `50% ${objectPositionY}%`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -76,7 +73,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}`,
objectPosition: `50% ${objectPositionY}%`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -533,6 +533,7 @@ function getPageActionsURLs({
`~gitbook/pdf?${getPDFURLSearchParams({
page: page.id,
only: true,
limit: 100,
}).toString()}`
)
: undefined,
@@ -1,5 +1,6 @@
'use client';
import { useLayoutEffect, useState } from 'react';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useResizeObserver } from 'usehooks-ts';
interface ImageSize {
width: number;
@@ -21,18 +22,19 @@ interface Images {
}
/**
* Hook to compute the CSS object position Y for a cover image, from the y offset and the image
* dimensions. The container must set `container-type: inline-size` and be as wide as the image,
* since the position is computed against its width.
* Hook to calculate the object position Y percentage for a cover image
* based on the y offset, image dimensions, and container dimensions.
*/
export function useCoverPosition(
imgs: Images,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
) {
export function useCoverPosition(imgs: Images, y: number) {
const containerRef = useRef<HTMLDivElement>(null);
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(null);
const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size);
const container = useResizeObserver({
// @ts-expect-error wrong types
ref: containerRef,
});
// Load original image dimensions if not provided in `imgs`
useLayoutEffect(() => {
// Check if we have dimensions from dark (if provided) or else the default light.
@@ -66,31 +68,42 @@ export function useCoverPosition(
// Check dark first, then light, then loaded dimensions
const imageDimensions = imgs.dark?.size ?? imgs.light.size ?? loadedDimensions;
// Calculate ratio and dimensions similar to useCoverPosition hook
const ratio =
imageDimensions && container.height && container.width
? Math.max(
container.width / imageDimensions.width,
container.height / imageDimensions.height
)
: 1;
const safeRatio = ratio || 1;
const scaledHeight =
imageDimensions && container.height ? imageDimensions.height * safeRatio : null;
const maxOffset =
scaledHeight && container.height
? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio)
: 0;
// Parse the position between the allowed min/max
const objectPositionY = useMemo(() => {
if (!container.height || !imageDimensions) {
return 50;
}
const scaled = imageDimensions.height * safeRatio;
if (scaled <= container.height || maxOffset === 0) {
return 50;
}
const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y));
const relative = (maxOffset - clampedOffset) / (2 * maxOffset);
return relative * 100;
}, [container.height, imageDimensions, maxOffset, safeRatio, y]);
return {
objectPositionY: imageDimensions
? getCoverObjectPositionY(imageDimensions, y, container)
: '50%',
containerRef,
objectPositionY,
isLoading: !imageDimensions || isLoading,
};
}
/**
* Offset the image `y` natural pixels from centered, clamped so it keeps covering the container.
* Expressed in CSS against the container width (`cqw`), so it renders the same on the server as
* after hydration, without measuring the container.
*/
function getCoverObjectPositionY(
image: ImageSize,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
): string {
const containerHeight = container.height
? `${container.height}px`
: `${(100 * container.aspectRatio.height) / container.aspectRatio.width}cqw`;
// Rendered height of the image under `object-fit: cover`.
const scaledHeight = `max(${(100 * image.height) / image.width}cqw, ${containerHeight})`;
const maxOffset = `(${scaledHeight} - ${containerHeight}) / 2`;
const offset = `${scaledHeight} * ${y / image.height}`;
return `calc(50% + clamp(-1 * ${maxOffset}, ${offset}, ${maxOffset}))`;
}
@@ -5,7 +5,6 @@ import React from 'react';
import { useAI } from '../AI';
import { useTrackEvent } from '../Insights';
import { useIntegrationsLoaded } from '../Integrations';
import { useBodyLoaded } from '../primitives';
import {
clearLastSearchQuery,
@@ -141,7 +140,6 @@ export function useSearchController(
const trackEvent = useTrackEvent();
const resultsRef = React.useRef<SearchResultsRef>(null);
const isLoaded = useBodyLoaded();
const integrationsLoaded = useIntegrationsLoaded();
const restoredLastQueryForSiteSpaceRef = React.useRef<string | null>(null);
React.useEffect(() => {
@@ -171,8 +169,6 @@ export function useSearchController(
const withAI = assistants.length > 0;
const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0;
// Integration assistants register on window load, so wait for it before treating `ask` as a search query.
const askIsSearchQuery = withSearchAI || (!withAI && integrationsLoaded);
// Handle initial ask state on page load, once assistants are ready.
// `ask=` should still bootstrap the assistant on the docs site, so we must
@@ -207,7 +203,7 @@ export function useSearchController(
const query =
prev?.query ??
getLastSearchQuery(siteSpace.id) ??
(askIsSearchQuery ? prev?.ask : null) ??
(withSearchAI || !withAI ? prev?.ask : null) ??
'';
return {
@@ -221,7 +217,7 @@ export function useSearchController(
trackEvent({
type: 'search_open',
});
}, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, askIsSearchQuery]);
}, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, withSearchAI]);
const setQuery = React.useCallback(
(value: string) => {
@@ -265,7 +261,7 @@ export function useSearchController(
});
const searchValue =
state?.query ?? (askIsSearchQuery ? state?.ask : null) ?? lastSearchQuery ?? '';
state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? lastSearchQuery ?? '';
const searchResultsId = `search-results-${React.useId()}`;
// Only clears the remembered last query and stops any in-flight fetch — it must
@@ -82,7 +82,7 @@ type CoverOverlap =
* `data-over-cover="split"` with the crossing point in `--cover-edge`.
*/
function useMarkTextOverCover() {
React.useLayoutEffect(() => {
React.useEffect(() => {
const root = document.documentElement;
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
@@ -150,7 +150,7 @@ function useMarkTextOverCover() {
});
};
update();
scheduleUpdate();
window.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate, { passive: true });
@@ -1,49 +0,0 @@
import { describe, expect, it } from 'bun:test';
import type { RevisionPageDocument } from '@gitbook/api';
import { getPageFullTitle } from './title';
import type { GitBookSiteContext } from '@/lib/context';
function makeContext(siteTitle: string, sectionTitle = ''): GitBookSiteContext {
return {
site: { title: siteTitle },
visibleSections: sectionTitle
? {
current: { title: sectionTitle, default: false },
list: [{ object: 'site-section' }, { object: 'site-section' }],
}
: undefined,
visibleSiteSpaces: [],
} as unknown as GitBookSiteContext;
}
function makePage(title: string, tagTitle?: string): RevisionPageDocument {
return { title, tagTitle } as unknown as RevisionPageDocument;
}
describe('getPageFullTitle', () => {
it('uses tagTitle as the first segment', () => {
expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title', 'SEO title'))).toBe(
'SEO title | GitBook'
);
});
it('falls back to the page title when tagTitle is absent', () => {
expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title'))).toBe(
'Page title | GitBook'
);
});
it('deduplicates the section title against tagTitle', () => {
expect(
getPageFullTitle(makeContext('GitBook', 'Section'), makePage('Page title', 'Section'))
).toBe('Section | GitBook');
});
it('deduplicates the site title against tagTitle', () => {
expect(
getPageFullTitle(makeContext('SEO title'), makePage('Page title', 'SEO title'))
).toBe('SEO title');
});
});
@@ -4,6 +4,7 @@ import { notFound, redirect } from 'next/navigation';
import {
CustomizationDefaultThemeMode,
CustomizationHeaderPreset,
type RevisionPageDocument,
SiteInsightsDisplayContext,
type TranslationLanguage,
} from '@gitbook/api';
@@ -17,7 +18,6 @@ import {
getPathnameParam,
} from './fetch';
import { PageClientLayout } from './PageClientLayout';
import { getPageFullTitle } from './title';
import { UpdatesFilterProvider } from '@/components/DocumentView/UpdatesFilter';
import { UpdatesFilterScript } from '@/components/DocumentView/UpdatesFilterScript';
import { PageAside } from '@/components/PageAside';
@@ -33,7 +33,11 @@ import { getResizedImageURL } from '@/lib/images';
import { getPagePath } from '@/lib/pages';
import { resolveContentRef } from '@/lib/references';
import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
import { getSiteSpacePagePaths, resolveSiteSpaceCustomHomePage } from '@/lib/sites';
import {
getSiteSpacePagePaths,
getSiteStructureTitle,
resolveSiteSpaceCustomHomePage,
} from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import {
generateUpdatesFilterCSS,
@@ -41,8 +45,6 @@ import {
updatesFilterStyleHref,
} from '@/lib/updates';
export { getPageFullTitle } from './title';
export type SitePageProps = {
context: GitBookSiteContext;
pageParams: PagePathParams;
@@ -419,3 +421,20 @@ async function resolvePageMetaLinks(
alternates: [],
};
}
/**
* Get the <title> for a page.
*/
export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) {
const { site } = context;
const siteStructureTitle = getSiteStructureTitle(context);
return [
page.title,
// Prevent duplicate titles by comparing against the page title.
page.title !== siteStructureTitle ? siteStructureTitle : null, // The first page of a section is often the same as the section title, so we don't need to show it.
page.title !== site.title ? site.title : null, // The site title can also be the same as the site title on the site's landing page.
]
.filter(Boolean)
.join(' | ');
}
@@ -6,8 +6,7 @@ import type { GitBookSiteContext } from '@/lib/context';
mock.module('server-only', () => ({}));
const { fetchPageData, getLowercasePathnameRedirect, resolveMissingPagePath } =
await import('./fetch');
const { fetchPageData, getLowercasePathnameRedirect } = await import('./fetch');
const { normalizeURL } = await import('@/lib/data/urls');
const page = {
@@ -95,70 +94,6 @@ 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,14 +2,13 @@ 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 { type ResolvedPagePath, resolvePageId } from '@/lib/pages';
import { resolvePageId } from '@/lib/pages';
import { withLeadingSlash } from '@/lib/paths';
import { resolveSiteSpacePagePath } from '@/lib/sites';
@@ -71,7 +70,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 { revision } = context;
const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
if ('pageId' in params) {
return resolvePageId(revision.pages, params.pageId);
@@ -86,102 +85,72 @@ 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) {
return undefined;
}
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))
);
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(
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 (
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);
}
}
// 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;
return undefined;
}
/**
@@ -1,23 +0,0 @@
import type { RevisionPageDocument } from '@gitbook/api';
import type { GitBookSiteContext } from '@/lib/context';
import { getSiteStructureTitle } from '@/lib/sites';
/**
* Get the <title> for a page.
*/
export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) {
const { site } = context;
const siteStructureTitle = getSiteStructureTitle(context);
const tagTitle = page.tagTitle || page.title;
return [
tagTitle,
// The first page of a section is often the same as the section title, so we don't need to show it.
tagTitle !== siteStructureTitle ? siteStructureTitle : null,
// The site title can also be the same as the page title on the site's landing page.
tagTitle !== site.title ? site.title : null,
]
.filter(Boolean)
.join(' | ');
}
@@ -29,8 +29,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
<div ref={sentinelRef} className="h-0" aria-hidden="true" />
<div
className={tcls(
// No opacity transition on ::after: in Chrome it leaves stale pixels over the first child after the list scrolls.
'-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent',
'-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent after:transition-opacity',
isSticking ? '' : 'after:opacity-0',
'mt-1 pt-2.5 pb-0',
'bg-tint-base',
@@ -134,7 +134,6 @@ export async function TableOfContents(props: {
orientation="vertical"
contentClassName="flex flex-col p-2 gutter-stable"
active="[data-active=true]"
followActive
leading={{
fade: true,
button: {
@@ -19,7 +19,7 @@ export async function Trademark(
const { space } = context;
const language = await getSpaceLanguage(context);
const url = new URL('https://www.gitbook.com/powered-by');
const url = new URL('https://www.gitbook.com');
url.searchParams.set('utm_source', 'content');
url.searchParams.set('utm_medium', 'trademark');
url.searchParams.set('utm_campaign', space.id);
@@ -11,7 +11,7 @@ import { tcls } from '@/lib/tailwind';
* A container that encapsulates a scrollable area with usability features.
* - Faded edges when there is more content than the container can display.
* - Buttons to advance the scroll position.
* - Auto-scroll to the active item on mount and when it changes.
* - Auto-scroll to the active item when it's initially active.
*/
export type ScrollContainerProps = {
children: React.ReactNode;
@@ -42,12 +42,6 @@ export type ScrollContainerProps = {
/** The ID or ref of the active item to scroll to. */
active?: string | React.RefObject<HTMLElement | null>;
/**
* Only scroll to the active item when it is not fully visible, and keep following it
* when it changes later (requires `active` to be a selector).
*/
followActive?: boolean;
/** Scroll by one page of fully visible direct children instead of one viewport. */
scrollByVisibleItems?: boolean;
} & React.HTMLAttributes<HTMLDivElement>;
@@ -59,7 +53,6 @@ export function ScrollContainer(props: ScrollContainerProps) {
contentClassName,
orientation,
active,
followActive = false,
scrollByVisibleItems = false,
leading = { fade: true, button: true },
trailing = { fade: true, button: true },
@@ -87,44 +80,8 @@ export function ScrollContainer(props: ScrollContainerProps) {
if (!activeItem || !container.contains(activeItem)) {
return;
}
if (followActive && isElementFullyVisibleInContainer(activeItem, container)) {
return;
}
scrollToElementInContainer(activeItem, container);
}, [active, followActive]);
React.useEffect(() => {
const container = containerRef.current;
if (!followActive || !container || typeof active !== 'string') {
return;
}
let frame = 0;
// Active items can mount only after a collapsed group expands.
const observer = new MutationObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
for (const activeItem of container.querySelectorAll(active)) {
if (!isElementFullyVisibleInContainer(activeItem, container)) {
scrollToElementInContainer(activeItem, container, 'smooth');
return;
}
}
});
});
observer.observe(container, {
attributes: true,
attributeFilter: ['data-active'],
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
};
}, [active, followActive]);
}, [active]);
const scrollFurther = () => {
const container = containerRef.current;
@@ -378,11 +335,7 @@ function scrollByViewport(
/**
* Scroll to an element in a container.
*/
export function scrollToElementInContainer(
element: Element,
container: HTMLElement,
behavior: ScrollBehavior = 'auto'
) {
function scrollToElementInContainer(element: Element, container: HTMLElement) {
const containerRect = container.getBoundingClientRect();
const rect = element.getBoundingClientRect();
@@ -397,26 +350,8 @@ export function scrollToElementInContainer(
(rect.left - containerRect.left) -
container.clientWidth / 2 +
rect.width / 2,
behavior,
// Use 'auto' to avoid additional scroll animations when scrolling to an element
// as this may be called during layout/initialization when the page is not fully loaded.
behavior: 'auto',
});
}
function isElementFullyVisibleInContainer(element: Element, container: HTMLElement) {
if (
!element.getClientRects().length ||
container.clientHeight === 0 ||
container.clientWidth === 0
) {
return true;
}
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
return (
elementRect.top >= containerRect.top &&
elementRect.bottom <= containerRect.bottom &&
elementRect.left >= containerRect.left &&
elementRect.right <= containerRect.right
);
}
+2 -3
View File
@@ -96,9 +96,8 @@ export const ar: TranslationLanguage = {
pdf_page_of: '${1} من ${2}',
pdf_mode_only_page: 'هذه الصفحة فقط',
pdf_mode_all: 'كل الصفحات',
pdf_batch_range: 'عرض الصفحات ${1}–${2} من ${3}.',
pdf_batch_previous: 'الصفحات الـ ${1} السابقة',
pdf_batch_next: 'الصفحات الـ ${1} التالية',
pdf_limit_reached: 'تعذر إنشاء ملف PDF لـ ${1} صفحات، وتوقف الإنشاء عند ${2}.',
pdf_limit_reached_continue: 'التوسيع بإضافة ${1} صفحات أخرى.',
more: 'المزيد',
link_tooltip_external_link: 'رابط خارجي إلى',
link_tooltip_email: 'إرسال بريد إلكتروني إلى',
+3 -3
View File
@@ -99,9 +99,9 @@ export const bg: TranslationLanguage = {
pdf_page_of: '${1} от ${2}',
pdf_mode_only_page: 'Само тази страница',
pdf_mode_all: 'Всички страници',
pdf_batch_range: 'Показани са страници ${1}–${2} от ${3}.',
pdf_batch_previous: 'Предишни ${1} страници',
pdf_batch_next: 'Следващи ${1} страници',
pdf_limit_reached:
'PDF файлът за ${1} страници не можа да бъде генериран, генерирането спря на ${2}.',
pdf_limit_reached_continue: 'Разширяване с още ${1} страници.',
more: 'Още',
link_tooltip_external_link: 'Външна връзка към',
link_tooltip_email: 'Изпращане на имейл до',
+3 -3
View File
@@ -97,9 +97,9 @@ export const cs: TranslationLanguage = {
pdf_page_of: '${1} z ${2}',
pdf_mode_only_page: 'Pouze tato stránka',
pdf_mode_all: 'Všechny stránky',
pdf_batch_range: 'Zobrazeny stránky ${1}–${2} z ${3}.',
pdf_batch_previous: 'Předchozích ${1} stránek',
pdf_batch_next: 'Dalších ${1} stránek',
pdf_limit_reached:
'Nepodařilo se vygenerovat PDF pro ${1} stránek, generování se zastavilo na ${2}.',
pdf_limit_reached_continue: 'Rozšířit o dalších ${1} stránek.',
more: 'Více',
link_tooltip_external_link: 'Externí odkaz na',
link_tooltip_email: 'Odeslat e-mail na',
+2 -3
View File
@@ -97,9 +97,8 @@ export const da: TranslationLanguage = {
pdf_page_of: '${1} af ${2}',
pdf_mode_only_page: 'Kun denne side',
pdf_mode_all: 'Alle sider',
pdf_batch_range: 'Viser sider ${1}–${2} af ${3}.',
pdf_batch_previous: 'Forrige ${1} sider',
pdf_batch_next: 'Næste ${1} sider',
pdf_limit_reached: 'Kunne ikke generere PDF for ${1} sider, genereringen stoppede ved ${2}.',
pdf_limit_reached_continue: 'Udvid med ${1} flere sider.',
more: 'Mere',
link_tooltip_external_link: 'Eksternt link til',
link_tooltip_email: 'Send e-mail til',
+3 -3
View File
@@ -102,9 +102,9 @@ export const de: TranslationLanguage = {
pdf_page_of: '${1} von ${2}',
pdf_mode_only_page: 'Nur diese Seite',
pdf_mode_all: 'Alle Seiten',
pdf_batch_range: 'Seiten ${1}–${2} von ${3}.',
pdf_batch_previous: 'Vorherige ${1} Seiten',
pdf_batch_next: 'Nächste ${1} Seiten',
pdf_limit_reached:
'Das PDF konnte für ${1} Seiten nicht generiert werden, Generierung wurde bei ${2} gestoppt.',
pdf_limit_reached_continue: 'Mit ${1} weiteren Seiten erweitern.',
more: 'Mehr',
link_tooltip_external_link: 'Externer Link zu',
link_tooltip_email: 'E-Mail senden an',
+3 -3
View File
@@ -100,9 +100,9 @@ export const el: TranslationLanguage = {
pdf_page_of: '${1} από ${2}',
pdf_mode_only_page: 'Μόνο αυτή η σελίδα',
pdf_mode_all: 'Όλες οι σελίδες',
pdf_batch_range: 'Εμφάνιση σελίδων ${1}–${2} από ${3}.',
pdf_batch_previous: 'Προηγούμενες ${1} σελίδες',
pdf_batch_next: 'Επόμενες ${1} σελίδες',
pdf_limit_reached:
'Δεν ήταν δυνατή η δημιουργία PDF για ${1} σελίδες, η δημιουργία σταμάτησε στο ${2}.',
pdf_limit_reached_continue: 'Επέκταση με ${1} ακόμη σελίδες.',
more: 'Περισσότερα',
link_tooltip_external_link: 'Εξωτερικός σύνδεσμος προς',
link_tooltip_email: 'Αποστολή email σε',
+2 -3
View File
@@ -95,9 +95,8 @@ export const en = {
pdf_page_of: '${1} of ${2}',
pdf_mode_only_page: 'Only this page',
pdf_mode_all: 'All pages',
pdf_batch_range: 'Showing pages ${1}–${2} of ${3}.',
pdf_batch_previous: 'Previous ${1} pages',
pdf_batch_next: 'Next ${1} pages',
pdf_limit_reached: "Couldn't generate the PDF for ${1} pages, generation stopped at ${2}.",
pdf_limit_reached_continue: 'Extend with ${1} more pages.',
more: 'More',
link_tooltip_external_link: 'External link to',
link_tooltip_email: 'Send an email to',
+3 -3
View File
@@ -101,9 +101,9 @@ export const es: TranslationLanguage = {
pdf_page_of: '${1} de ${2}',
pdf_mode_only_page: 'Solo esta página',
pdf_mode_all: 'Todas las páginas',
pdf_batch_range: 'Mostrando las páginas ${1}–${2} de ${3}.',
pdf_batch_previous: '${1} páginas anteriores',
pdf_batch_next: '${1} páginas siguientes',
pdf_limit_reached:
'No se pudo generar el PDF para ${1} páginas, la generación se detuvo en ${2}.',
pdf_limit_reached_continue: 'Extender con ${1} páginas más.',
more: 'Más',
link_tooltip_external_link: 'Enlace externo a',
link_tooltip_email: 'Enviar un correo electrónico a',
+2 -3
View File
@@ -97,9 +97,8 @@ export const et: TranslationLanguage = {
pdf_page_of: '${1}/${2}',
pdf_mode_only_page: 'Ainult see leht',
pdf_mode_all: 'Kõik lehed',
pdf_batch_range: 'Kuvatakse lehti ${1}–${2}/${3}.',
pdf_batch_previous: 'Eelmised ${1} lehte',
pdf_batch_next: 'Järgmised ${1} lehte',
pdf_limit_reached: 'PDF-i ei saanud luua ${1} lehe jaoks, loomine peatus kohas ${2}.',
pdf_limit_reached_continue: 'Laienda veel ${1} lehe võrra.',
more: 'Rohkem',
link_tooltip_external_link: 'Väline link aadressile',
link_tooltip_email: 'Saada e-kiri aadressile',
+2 -3
View File
@@ -99,9 +99,8 @@ export const fi: TranslationLanguage = {
pdf_page_of: '${1}/${2}',
pdf_mode_only_page: 'Vain tämä sivu',
pdf_mode_all: 'Kaikki sivut',
pdf_batch_range: 'Näytetään sivut ${1}–${2}/${3}.',
pdf_batch_previous: 'Edelliset ${1} sivua',
pdf_batch_next: 'Seuraavat ${1} sivua',
pdf_limit_reached: 'PDF-tiedostoa ei voitu luoda ${1} sivulle, luonti pysähtyi kohtaan ${2}.',
pdf_limit_reached_continue: 'Laajenna ${1} sivulla.',
more: 'Lisää',
link_tooltip_external_link: 'Ulkoinen linkki kohteeseen',
link_tooltip_email: 'Lähetä sähköpostia osoitteeseen',
+2 -3
View File
@@ -98,9 +98,8 @@ export const fr: TranslationLanguage = {
pdf_page_of: '${1} sur ${2}',
pdf_mode_only_page: 'Cette page uniquement',
pdf_mode_all: 'Toutes les pages',
pdf_batch_range: 'Pages ${1} à ${2} sur ${3}.',
pdf_batch_previous: '${1} pages précédentes',
pdf_batch_next: '${1} pages suivantes',
pdf_limit_reached: 'Impossible de générer le PDF pour ${1} pages, arrêté à ${2}.',
pdf_limit_reached_continue: 'Ajouter ${1} pages supplémentaires',
more: 'Plus',
link_tooltip_external_link: 'Lien externe vers',
link_tooltip_email: 'Envoyer un e-mail à',
+2 -3
View File
@@ -96,9 +96,8 @@ export const he: TranslationLanguage = {
pdf_page_of: '${1} מתוך ${2}',
pdf_mode_only_page: 'רק הדף הזה',
pdf_mode_all: 'כל הדפים',
pdf_batch_range: 'מוצגים דפים ${1}–${2} מתוך ${3}.',
pdf_batch_previous: '${1} הדפים הקודמים',
pdf_batch_next: '${1} הדפים הבאים',
pdf_limit_reached: 'לא ניתן ליצור PDF עבור ${1} דפים, היצירה נעצרה ב-${2}.',
pdf_limit_reached_continue: 'הרחבה עם עוד ${1} דפים.',
more: 'עוד',
link_tooltip_external_link: 'קישור חיצוני אל',
link_tooltip_email: 'שליחת אימייל אל',
+2 -3
View File
@@ -96,9 +96,8 @@ export const hi: TranslationLanguage = {
pdf_page_of: '${1} में से ${2}',
pdf_mode_only_page: 'केवल यह पृष्ठ',
pdf_mode_all: 'सभी पृष्ठ',
pdf_batch_range: '${3} में से पृष्ठ ${1}–${2} दिखाए जा रहे हैं।',
pdf_batch_previous: 'पिछले ${1} पृष्ठ',
pdf_batch_next: 'अगले ${1} पृष्ठ',
pdf_limit_reached: '${1} पृष्ठों के लिए PDF नहीं बन सका, जनरेशन ${2} पर रुक गया।',
pdf_limit_reached_continue: '${1} और पृष्ठों के साथ बढ़ाएं।',
more: 'अधिक',
link_tooltip_external_link: 'बाहरी लिंक',
link_tooltip_email: 'ईमेल भेजें',
+3 -3
View File
@@ -97,9 +97,9 @@ export const hr: TranslationLanguage = {
pdf_page_of: '${1} od ${2}',
pdf_mode_only_page: 'Samo ova stranica',
pdf_mode_all: 'Sve stranice',
pdf_batch_range: 'Prikazane stranice ${1}–${2} od ${3}.',
pdf_batch_previous: 'Prethodnih ${1} stranica',
pdf_batch_next: 'Sljedećih ${1} stranica',
pdf_limit_reached:
'Nije moguće generirati PDF za ${1} stranica, generiranje je zaustavljeno na ${2}.',
pdf_limit_reached_continue: 'Proširi za još ${1} stranica.',
more: 'Više',
link_tooltip_external_link: 'Vanjska poveznica na',
link_tooltip_email: 'Pošalji e-poštu na',
+3 -3
View File
@@ -97,9 +97,9 @@ export const hu: TranslationLanguage = {
pdf_page_of: '${1} / ${2}',
pdf_mode_only_page: 'Csak ez az oldal',
pdf_mode_all: 'Minden oldal',
pdf_batch_range: '${1}–${2}. oldal, összesen ${3}.',
pdf_batch_previous: 'Előző ${1} oldal',
pdf_batch_next: 'Következő ${1} oldal',
pdf_limit_reached:
'Nem sikerült PDF-et létrehozni ${1} oldalhoz, a létrehozás itt megállt: ${2}.',
pdf_limit_reached_continue: 'Bővítés további ${1} oldallal.',
more: 'Továbbiak',
link_tooltip_external_link: 'Külső hivatkozás ide:',
link_tooltip_email: 'E-mail küldése ide:',
+2 -3
View File
@@ -97,9 +97,8 @@ export const id: TranslationLanguage = {
pdf_page_of: '${1} dari ${2}',
pdf_mode_only_page: 'Hanya halaman ini',
pdf_mode_all: 'Semua halaman',
pdf_batch_range: 'Menampilkan halaman ${1}–${2} dari ${3}.',
pdf_batch_previous: '${1} halaman sebelumnya',
pdf_batch_next: '${1} halaman berikutnya',
pdf_limit_reached: 'Tidak dapat membuat PDF untuk ${1} halaman, pembuatan berhenti di ${2}.',
pdf_limit_reached_continue: 'Perluas dengan ${1} halaman lagi.',
more: 'Lainnya',
link_tooltip_external_link: 'Tautan eksternal ke',
link_tooltip_email: 'Kirim email ke',
+3 -3
View File
@@ -100,9 +100,9 @@ export const it: TranslationLanguage = {
pdf_page_of: '${1} di ${2}',
pdf_mode_only_page: 'Solo questa pagina',
pdf_mode_all: 'Tutte le pagine',
pdf_batch_range: 'Pagine ${1}–${2} di ${3}.',
pdf_batch_previous: '${1} pagine precedenti',
pdf_batch_next: '${1} pagine successive',
pdf_limit_reached:
'Impossibile generare il PDF per ${1} pagine, la generazione si è fermata a ${2}.',
pdf_limit_reached_continue: 'Estendi con altre ${1} pagine.',
more: 'Altro',
link_tooltip_external_link: 'Link esterno a',
link_tooltip_email: "Invia un'email a",
+2 -3
View File
@@ -98,9 +98,8 @@ export const ja: TranslationLanguage = {
pdf_page_of: '${1} / ${2}',
pdf_mode_only_page: 'このページのみ',
pdf_mode_all: '全てのページ',
pdf_batch_range: '${3}ページ中 ${1}–${2}ページを表示しています。',
pdf_batch_previous: '前の${1}ページ',
pdf_batch_next: '次の${1}ページ',
pdf_limit_reached: '${1}ページのPDFを生成できませんでした、${2}で生成が停止しました。',
pdf_limit_reached_continue: 'さらに${1}ページで拡張',
more: 'もっと見る',
link_tooltip_external_link: '外部リンクへ',
link_tooltip_email: 'メールを送信',
+2 -3
View File
@@ -97,9 +97,8 @@ export const ko: TranslationLanguage = {
pdf_page_of: '${1} / ${2}',
pdf_mode_only_page: '이 페이지만',
pdf_mode_all: '모든 페이지',
pdf_batch_range: '전체 ${3}페이지 중 ${1}–${2}페이지를 표시하고 있습니다.',
pdf_batch_previous: '이전 ${1}페이지',
pdf_batch_next: '다음 ${1}페이지',
pdf_limit_reached: '${1}페이지에 대한 PDF를 생성할 수 없어 ${2}에서 생성이 중지되었습니다.',
pdf_limit_reached_continue: '${1}페이지 더 확장하기.',
more: '더 보기',
link_tooltip_external_link: '외부 링크',
link_tooltip_email: '이메일 보내기',
+2 -3
View File
@@ -98,9 +98,8 @@ export const lt: TranslationLanguage = {
pdf_page_of: '${1} iš ${2}',
pdf_mode_only_page: 'Tik šis puslapis',
pdf_mode_all: 'Visi puslapiai',
pdf_batch_range: 'Rodomi puslapiai ${1}–${2} iš ${3}.',
pdf_batch_previous: 'Ankstesni ${1} puslapiai',
pdf_batch_next: 'Kiti ${1} puslapiai',
pdf_limit_reached: 'Nepavyko sugeneruoti PDF ${1} puslapiams, generavimas sustojo ties ${2}.',
pdf_limit_reached_continue: 'Išplėsti dar ${1} puslapiais.',
more: 'Daugiau',
link_tooltip_external_link: 'Išorinė nuoroda į',
link_tooltip_email: 'Siųsti el. laišką į',
+2 -3
View File
@@ -96,9 +96,8 @@ export const lv: TranslationLanguage = {
pdf_page_of: '${1} no ${2}',
pdf_mode_only_page: 'Tikai šī lapa',
pdf_mode_all: 'Visas lapas',
pdf_batch_range: 'Rāda lapas ${1}–${2} no ${3}.',
pdf_batch_previous: 'Iepriekšējās ${1} lapas',
pdf_batch_next: 'Nākamās ${1} lapas',
pdf_limit_reached: 'Neizdevās ģenerēt PDF ${1} lapām, ģenerēšana apstājās pie ${2}.',
pdf_limit_reached_continue: 'Paplašināt ar vēl ${1} lapām.',
more: 'Vairāk',
link_tooltip_external_link: 'Ārēja saite uz',
link_tooltip_email: 'Sūtīt e-pastu uz',
+2 -3
View File
@@ -97,9 +97,8 @@ export const ms: TranslationLanguage = {
pdf_page_of: '${1} daripada ${2}',
pdf_mode_only_page: 'Halaman ini sahaja',
pdf_mode_all: 'Semua halaman',
pdf_batch_range: 'Memaparkan halaman ${1}–${2} daripada ${3}.',
pdf_batch_previous: '${1} halaman sebelumnya',
pdf_batch_next: '${1} halaman seterusnya',
pdf_limit_reached: 'Tidak dapat menjana PDF untuk ${1} halaman, penjanaan berhenti pada ${2}.',
pdf_limit_reached_continue: 'Lanjutkan dengan ${1} halaman lagi.',
more: 'Lagi',
link_tooltip_external_link: 'Pautan luaran ke',
link_tooltip_email: 'Hantar e-mel kepada',
+2 -3
View File
@@ -100,9 +100,8 @@ export const nl: TranslationLanguage = {
pdf_page_of: '${1} van ${2}',
pdf_mode_only_page: 'Alleen deze pagina',
pdf_mode_all: "Alle pagina's",
pdf_batch_range: "Pagina's ${1}–${2} van ${3}.",
pdf_batch_previous: "Vorige ${1} pagina's",
pdf_batch_next: "Volgende ${1} pagina's",
pdf_limit_reached: "Kon de PDF niet genereren voor ${1} pagina's, generatie gestopt bij ${2}.",
pdf_limit_reached_continue: "Verleng met ${1} extra pagina's.",
more: 'Meer',
link_tooltip_external_link: 'Externe link naar',
link_tooltip_email: 'E-mail versturen naar',
+2 -3
View File
@@ -99,9 +99,8 @@ export const no: TranslationLanguage = {
pdf_page_of: '${1} av ${2}',
pdf_mode_only_page: 'Kun denne siden',
pdf_mode_all: 'Alle sider',
pdf_batch_range: 'Viser sider ${1}–${2} av ${3}.',
pdf_batch_previous: 'Forrige ${1} sider',
pdf_batch_next: 'Neste ${1} sider',
pdf_limit_reached: 'Kunne ikke generere PDF for ${1} sider, generering stoppet ved ${2}.',
pdf_limit_reached_continue: 'Utvid med ${1} flere sider.',
more: 'Mer',
link_tooltip_external_link: 'Ekstern lenke til',
link_tooltip_email: 'Send e-post til',
+3 -3
View File
@@ -97,9 +97,9 @@ export const pl: TranslationLanguage = {
pdf_page_of: '${1} z ${2}',
pdf_mode_only_page: 'Tylko ta strona',
pdf_mode_all: 'Wszystkie strony',
pdf_batch_range: 'Strony ${1}–${2} z ${3}.',
pdf_batch_previous: 'Poprzednie ${1} stron',
pdf_batch_next: 'Następne ${1} stron',
pdf_limit_reached:
'Nie udało się wygenerować PDF dla ${1} stron, generowanie zatrzymało się na ${2}.',
pdf_limit_reached_continue: 'Rozszerz o kolejne ${1} stron.',
more: 'Więcej',
link_tooltip_external_link: 'Link zewnętrzny do',
link_tooltip_email: 'Wyślij e-mail do',
@@ -101,9 +101,9 @@ export const pt_br: TranslationLanguage = {
pdf_page_of: '${1} de ${2}',
pdf_mode_only_page: 'Somente esta página',
pdf_mode_all: 'Todas as páginas',
pdf_batch_range: 'Mostrando as páginas ${1}–${2} de ${3}.',
pdf_batch_previous: '${1} páginas anteriores',
pdf_batch_next: 'Próximas ${1} páginas',
pdf_limit_reached:
'Não foi possível gerar o PDF para ${1} páginas, a geração foi interrompida em ${2}.',
pdf_limit_reached_continue: 'Estender com mais ${1} páginas.',
more: 'Mais',
link_tooltip_external_link: 'Link externo para',
link_tooltip_email: 'Enviar e-mail para',
+2 -3
View File
@@ -99,9 +99,8 @@ export const pt: TranslationLanguage = {
pdf_page_of: '${1} de ${2}',
pdf_mode_only_page: 'Apenas esta página',
pdf_mode_all: 'Todas as páginas',
pdf_batch_range: 'A mostrar as páginas ${1}–${2} de ${3}.',
pdf_batch_previous: '${1} páginas anteriores',
pdf_batch_next: '${1} páginas seguintes',
pdf_limit_reached: 'Não foi possível gerar o PDF para ${1} páginas; a geração parou em ${2}.',
pdf_limit_reached_continue: 'Expandir com mais ${1} páginas.',
more: 'Mais',
link_tooltip_external_link: 'Ligação externa para',
link_tooltip_email: 'Enviar e-mail para',
+3 -3
View File
@@ -100,9 +100,9 @@ export const ro: TranslationLanguage = {
pdf_page_of: '${1} din ${2}',
pdf_mode_only_page: 'Doar această pagină',
pdf_mode_all: 'Toate paginile',
pdf_batch_range: 'Se afișează paginile ${1}–${2} din ${3}.',
pdf_batch_previous: '${1} pagini anterioare',
pdf_batch_next: 'Următoarele ${1} pagini',
pdf_limit_reached:
'Nu s-a putut genera PDF-ul pentru ${1} pagini, generarea s-a oprit la ${2}.',
pdf_limit_reached_continue: 'Extinde cu încă ${1} pagini.',
more: 'Mai multe',
link_tooltip_external_link: 'Link extern către',
link_tooltip_email: 'Trimite e-mail către',
+3 -3
View File
@@ -100,9 +100,9 @@ export const ru: TranslationLanguage = {
pdf_page_of: '${1} из ${2}',
pdf_mode_only_page: 'Только эта страница',
pdf_mode_all: 'Все страницы',
pdf_batch_range: 'Страницы ${1}–${2} из ${3}.',
pdf_batch_previous: 'Предыдущие ${1} страниц',
pdf_batch_next: 'Следующие ${1} страниц',
pdf_limit_reached:
'Не удалось создать PDF-файл для ${1} страниц, генерация остановлена на ${2}.',
pdf_limit_reached_continue: 'Расширьте ещё на ${1} страниц.',
more: 'Ещё',
link_tooltip_external_link: 'Внешняя ссылка на',
link_tooltip_email: 'Отправить письмо на',
+3 -3
View File
@@ -99,9 +99,9 @@ export const sk: TranslationLanguage = {
pdf_page_of: '${1} z ${2}',
pdf_mode_only_page: 'Iba táto stránka',
pdf_mode_all: 'Všetky stránky',
pdf_batch_range: 'Zobrazené stránky ${1}–${2} z ${3}.',
pdf_batch_previous: 'Predchádzajúcich ${1} stránok',
pdf_batch_next: 'Ďalších ${1} stránok',
pdf_limit_reached:
'Nepodarilo sa vygenerovať PDF pre ${1} stránok, generovanie sa zastavilo na ${2}.',
pdf_limit_reached_continue: 'Rozšíriť o ďalších ${1} stránok.',
more: 'Viac',
link_tooltip_external_link: 'Externý odkaz na',
link_tooltip_email: 'Odoslať e-mail na',
+3 -3
View File
@@ -97,9 +97,9 @@ export const sl: TranslationLanguage = {
pdf_page_of: '${1} od ${2}',
pdf_mode_only_page: 'Samo ta stran',
pdf_mode_all: 'Vse strani',
pdf_batch_range: 'Prikazane strani ${1}–${2} od ${3}.',
pdf_batch_previous: 'Prejšnjih ${1} strani',
pdf_batch_next: 'Naslednjih ${1} strani',
pdf_limit_reached:
'PDF-ja ni bilo mogoče ustvariti za ${1} strani, ustvarjanje se je ustavilo pri ${2}.',
pdf_limit_reached_continue: 'Razširi še za ${1} strani.',
more: 'Več',
link_tooltip_external_link: 'Zunanja povezava do',
link_tooltip_email: 'Pošlji e-pošto na',
+3 -3
View File
@@ -97,9 +97,9 @@ export const sv: TranslationLanguage = {
pdf_page_of: '${1} av ${2}',
pdf_mode_only_page: 'Endast denna sida',
pdf_mode_all: 'Alla sidor',
pdf_batch_range: 'Visar sidorna ${1}–${2} av ${3}.',
pdf_batch_previous: 'Föregående ${1} sidor',
pdf_batch_next: 'Nästa ${1} sidor',
pdf_limit_reached:
'Det gick inte att generera PDF för ${1} sidor, genereringen stoppades vid ${2}.',
pdf_limit_reached_continue: 'Utöka med ytterligare ${1} sidor.',
more: 'Mer',
link_tooltip_external_link: 'Extern länk till',
link_tooltip_email: 'Skicka e-post till',
+2 -3
View File
@@ -95,9 +95,8 @@ export const th: TranslationLanguage = {
pdf_page_of: '${1} จาก ${2}',
pdf_mode_only_page: 'เฉพาะหน้านี้',
pdf_mode_all: 'ทุกหน้า',
pdf_batch_range: 'แสดงหน้า ${1}–${2} จาก ${3}',
pdf_batch_previous: '${1} หน้าก่อนหน้า',
pdf_batch_next: '${1} หน้าถัดไป',
pdf_limit_reached: 'ไม่สามารถสร้าง PDF สำหรับ ${1} หน้าได้ การสร้างหยุดที่ ${2}',
pdf_limit_reached_continue: 'ขยายอีก ${1} หน้า',
more: 'เพิ่มเติม',
link_tooltip_external_link: 'ลิงก์ภายนอกไปยัง',
link_tooltip_email: 'ส่งอีเมลไปยัง',
+2 -3
View File
@@ -96,9 +96,8 @@ export const tr: TranslationLanguage = {
pdf_page_of: '${1} / ${2}',
pdf_mode_only_page: 'Yalnızca bu sayfa',
pdf_mode_all: 'Tüm sayfalar',
pdf_batch_range: '${3} sayfadan ${1}–${2} arası gösteriliyor.',
pdf_batch_previous: 'Önceki ${1} sayfa',
pdf_batch_next: 'Sonraki ${1} sayfa',
pdf_limit_reached: '${1} sayfa için PDF oluşturulamadı, oluşturma ${2} konumunda durdu.',
pdf_limit_reached_continue: '${1} sayfa daha ekleyerek genişlet.',
more: 'Daha fazla',
link_tooltip_external_link: 'Harici bağlantı',
link_tooltip_email: 'E-posta gönder',
+2 -3
View File
@@ -96,9 +96,8 @@ export const uk: TranslationLanguage = {
pdf_page_of: '${1} з ${2}',
pdf_mode_only_page: 'Лише ця сторінка',
pdf_mode_all: 'Усі сторінки',
pdf_batch_range: 'Сторінки ${1}–${2} з ${3}.',
pdf_batch_previous: 'Попередні ${1} сторінок',
pdf_batch_next: 'Наступні ${1} сторінок',
pdf_limit_reached: 'Не вдалося створити PDF для ${1} сторінок, створення зупинилося на ${2}.',
pdf_limit_reached_continue: 'Розширити ще на ${1} сторінок.',
more: 'Більше',
link_tooltip_external_link: 'Зовнішнє посилання на',
link_tooltip_email: 'Надіслати електронний лист на',
+2 -3
View File
@@ -96,9 +96,8 @@ export const vi: TranslationLanguage = {
pdf_page_of: '${1} trên ${2}',
pdf_mode_only_page: 'Chỉ trang này',
pdf_mode_all: 'Tất cả trang',
pdf_batch_range: 'Đang hiển thị trang ${1}–${2} trên ${3}.',
pdf_batch_previous: '${1} trang trước',
pdf_batch_next: '${1} trang tiếp theo',
pdf_limit_reached: 'Không thể tạo PDF cho ${1} trang, quá trình tạo đã dừng tại ${2}.',
pdf_limit_reached_continue: 'Mở rộng thêm ${1} trang.',
more: 'Thêm',
link_tooltip_external_link: 'Liên kết ngoài tới',
link_tooltip_email: 'Gửi email tới',
@@ -94,9 +94,8 @@ export const yue: TranslationLanguage = {
pdf_page_of: '${1} / ${2}',
pdf_mode_only_page: '只限呢頁',
pdf_mode_all: '所有頁面',
pdf_batch_range: '而家顯示緊第 ${1}–${2} 頁,總共 ${3} 頁。',
pdf_batch_previous: '上 ${1} 頁',
pdf_batch_next: '下 ${1} 頁',
pdf_limit_reached: '無法為 ${1} 頁產生 PDF,已喺 ${2} 停止。',
pdf_limit_reached_continue: '再加 ${1} 頁。',
more: '更多',
link_tooltip_external_link: '外部連結到',
link_tooltip_email: '傳送電郵到',
@@ -94,9 +94,8 @@ export const zh_tw: TranslationLanguage = {
pdf_page_of: '${1} / ${2}',
pdf_mode_only_page: '僅此頁面',
pdf_mode_all: '所有頁面',
pdf_batch_range: '正在顯示第 ${1}–${2} 頁,共 ${3} 頁。',
pdf_batch_previous: '上 ${1} 頁',
pdf_batch_next: '下 ${1} 頁',
pdf_limit_reached: '無法為 ${1} 頁產生 PDF,已在 ${2} 停止產生。',
pdf_limit_reached_continue: '再擴展 ${1} 頁。',
more: '更多',
link_tooltip_external_link: '外部連結至',
link_tooltip_email: '傳送電子郵件至',
+2 -3
View File
@@ -95,9 +95,8 @@ export const zh: TranslationLanguage = {
pdf_page_of: '${1} / ${2}',
pdf_mode_only_page: '仅本页',
pdf_mode_all: '所有页面',
pdf_batch_range: '正在显示第 ${1}–${2} 页,共 ${3} 页。',
pdf_batch_previous: '上 ${1} 页',
pdf_batch_next: '下 ${1} 页',
pdf_limit_reached: '无法为 ${1} 页生成 PDF,生成在 ${2} 页时停止。',
pdf_limit_reached_continue: '再扩展 ${1} 页。',
more: '更多',
link_tooltip_external_link: '外部链接到',
link_tooltip_email: '发送邮件到',
-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]'],
-2
View File
@@ -519,7 +519,6 @@ export async function fetchSpaceContextByIds(
shareKey: string | undefined;
changeRequest: string | undefined;
revision: string | undefined;
revisionMetadata?: boolean;
}
): Promise<GitBookSpaceContext> {
const { dataFetcher } = baseContext;
@@ -553,7 +552,6 @@ 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,
+2 -10
View File
@@ -80,7 +80,6 @@ export function createDataFetcher(
return getRevision(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
metadata: params.metadata ?? false,
});
},
getRevisionPageByPath(params) {
@@ -320,15 +319,8 @@ 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; metadata: boolean }
) => {
async (input: DataFetcherInput, params: { spaceId: string; revisionId: string }) => {
'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);
@@ -336,7 +328,7 @@ const getRevision = cache(
params.spaceId,
params.revisionId,
{
metadata: params.metadata,
metadata: false,
},
{
...noCacheFetchOptions,
-1
View File
@@ -79,7 +79,6 @@ export interface GitBookDataFetcher {
getRevision(params: {
spaceId: string;
revisionId: string;
metadata?: boolean;
}): Promise<DataFetcherResponse<api.Revision>>;
/**
-328
View File
@@ -1,328 +0,0 @@
import { describe, expect, it } from 'bun:test';
import {
findGitPageURLTarget,
findPageByGitPath,
findPageForGitPageURLTarget,
matchesGitPageURLTargetPath,
} from './gitPageURL';
const SPACES = [
{
id: 'a',
gitSync: {
url: 'https://github.com/acme/docs/tree/main',
installationProjectDirectory: 'guides',
},
},
{
id: 'b',
gitSync: {
url: 'https://github.com/acme/docs/tree/main',
installationProjectDirectory: '/api/',
},
},
];
describe('findGitPageURLTarget', () => {
it.each(['header%201', '%E6%97%A5%E6%9C%AC', 'part%2Fone', 'percent%2520'])(
'decodes anchor %s once',
(anchor) => {
expect(
findGitPageURLTarget(
`https://github.com/acme/docs/tree/main/api/auth.md#${anchor}`,
SPACES
)?.anchor
).toBe(decodeURIComponent(anchor));
}
);
it.each(['%ZZ', '%E0%A4'])('keeps malformed anchor %s unresolved', (anchor) => {
expect(
findGitPageURLTarget(
`https://github.com/acme/docs/tree/main/api/auth.md#${anchor}`,
SPACES
)
).toBeNull();
});
it('accepts the www host alias without accepting unrelated hosts', () => {
expect(
findGitPageURLTarget('https://www.github.com/acme/docs/tree/main/api/auth.md', SPACES)
?.space
).toBe('b');
expect(
findGitPageURLTarget(
'https://www.github.com.evil.test/acme/docs/tree/main/api/auth.md',
SPACES
)
).toBeNull();
});
it('matches the repository, ref and directory and preserves anchors', () => {
expect(
findGitPageURLTarget(
'https://github.com/acme/docs/tree/main/api/auth.md#tokens',
SPACES
)
).toEqual({ space: 'b', path: 'api/auth.md', anchor: 'tokens' });
});
it.each([
'https://github.com/other/docs/tree/main/api/auth.md',
'https://github.com/acme/docs/tree/preview/api/auth.md',
'https://github.com.evil.test/acme/docs/tree/main/api/auth.md',
'https://github.com/acme/docs/tree/main/api-other/auth.md',
'https://github.com/acme/docs/tree/main/api/auth.md?raw=1',
'https://github.com/acme/docs/tree/main/api/%ZZ.md',
'https://github.com/acme/docs/tree/main/api/%2Fsecret.md',
])('does not reinterpret %s', (url) => {
expect(findGitPageURLTarget(url, SPACES)).toBeNull();
});
it('supports blob URLs and encoded file names', () => {
expect(
findGitPageURLTarget(
'https://github.com/acme/docs/blob/main/api/hello%20world.md',
SPACES
)
).toEqual({ space: 'b', path: 'api/hello world.md', anchor: undefined });
});
it('matches self-hosted GitLab with nested groups and a slash in the branch', () => {
const spaces = [
{
id: 'b',
gitSync: {
url: 'https://git.example.com/group/sub/docs/-/tree/release/v2',
installationProjectDirectory: 'api',
},
},
];
expect(
findGitPageURLTarget(
'https://git.example.com/group/sub/docs/-/blob/release/v2/api/auth.md',
spaces
)?.space
).toBe('b');
});
it('rejects ambiguous owners and ambiguous branch prefixes', () => {
expect(
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
...SPACES,
{ ...SPACES[1]!, id: 'duplicate' },
])
).toBeNull();
expect(
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
...SPACES,
{
id: 'other-ref',
gitSync: {
url: 'https://github.com/acme/docs/tree/main/api',
installationProjectDirectory: '',
},
},
])
).toBeNull();
});
it('prefers the most specific directory and deduplicates site placements', () => {
expect(
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
...SPACES,
SPACES[1]!,
{
id: 'root',
gitSync: { url: SPACES[0]!.gitSync.url, installationProjectDirectory: '' },
},
])?.space
).toBe('b');
});
it('treats an omitted live project directory as the repository root', () => {
expect(
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
{ id: 'old', gitSync: { url: SPACES[0]!.gitSync.url } },
])
).toEqual({ space: 'old', path: 'api/auth.md', anchor: undefined });
});
it.each(['live', 'disconnected'])('keeps similar space directories distinct (%s)', (state) => {
const spaces = ['docs/space-a', 'docs/space-b', 'api-reference'].map((directory) => ({
id: directory,
...(state === 'live'
? {
gitSync: {
url: SPACES[0]!.gitSync.url,
installationProjectDirectory: `/${directory}`,
},
}
: { previousGitSync: { url: `${SPACES[0]!.gitSync.url}/${directory}` } }),
}));
const url = new URL(
'../docs/space-b/page.md#details',
`${SPACES[0]!.gitSync.url}/api-reference/README.md`
).href;
const target = findGitPageURLTarget(url, spaces);
expect(target?.space).toBe('docs/space-b');
expect(target?.anchor).toBe('details');
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/page.md')).toBe(true);
expect(
findGitPageURLTarget(
url,
spaces.filter((space) => space.id !== 'docs/space-b')
)
).toBeNull();
});
it.each([
[
'https://github.com/old/repo/tree/main/docs/space-b',
'https://github.com/old/repo/blob/main/docs/space-b/hello%20world.md#details',
],
[
'https://git.example.com/group/repo/-/tree/release/v2/docs/space-b',
'https://git.example.com/group/repo/-/blob/release/v2/docs/space-b/hello%20world.md#details',
],
])('matches the remembered project URL %s', (previousURL, href) => {
const target = findGitPageURLTarget(href, [
{ id: 'b', previousGitSync: { url: previousURL } },
]);
expect(target?.space).toBe('b');
expect(target?.anchor).toBe('details');
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/hello world.md')).toBe(
true
);
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-a/hello world.md')).toBe(
false
);
});
it.each([
'https://github.com/someone-else/example/blob/main/docs/space-b/page.md',
'https://github.com/old/repo/tree/other/docs/space-b/page.md',
'https://github.com.evil.test/old/repo/tree/main/docs/space-b/page.md',
'https://github.com/old/repo/tree/main/docs/space-b-other/page.md',
'https://github.com/old/repo/tree/main/other-docs/space-b/page.md',
'https://github.com/old/repo/tree/main/docs/space-b/%2Fsecret.md',
'https://github.com/old/repo/tree/main/docs/space-b/%ZZ.md',
])('does not reinterpret an unrelated or invalid URL: %s', (href) => {
expect(
findGitPageURLTarget(href, [
{
id: 'b',
previousGitSync: { url: 'https://github.com/old/repo/tree/main/docs/space-b' },
},
])
).toBeNull();
});
it('requires a valid previous URL', () => {
for (const url of [undefined, 'invalid', 'https://github.com/old/repo']) {
expect(
findGitPageURLTarget('https://github.com/old/repo/tree/main/docs/space-b/page.md', [
{ id: 'b', previousGitSync: { url } },
])
).toBeNull();
}
});
it('supports disconnected repository roots and directory README links', () => {
const target = findGitPageURLTarget(
'https://github.com/old/repo/tree/release/v2/docs/space-b/',
[{ id: 'b', previousGitSync: { url: 'https://github.com/old/repo/tree/release/v2' } }]
);
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/README.md')).toBe(true);
});
it('rejects duplicate previous owners and ignores stale metadata on live installations', () => {
const previous = {
previousGitSync: { url: 'https://github.com/old/repo/tree/main/docs/space-b' },
};
const url = 'https://github.com/old/repo/tree/main/docs/space-b/page.md';
expect(
findGitPageURLTarget(url, [
{ id: 'b', ...previous },
{ id: 'copy', ...previous },
])
).toBeNull();
expect(findGitPageURLTarget(url, [{ ...SPACES[1]!, ...previous }])).toBeNull();
});
it('compares live and previous project URLs at the same directory boundary', () => {
const href = 'https://github.com/acme/docs/tree/main/api/auth.md';
const disconnected = {
id: 'old-api',
previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' },
};
expect(findGitPageURLTarget(href, [SPACES[1]!, disconnected])).toBeNull();
expect(
findGitPageURLTarget(href, [
disconnected,
{ id: 'root', gitSync: { url: SPACES[0]!.gitSync.url } },
])?.space
).toBe('old-api');
});
it('accepts the www alias for a remembered project URL', () => {
expect(
findGitPageURLTarget('https://www.github.com/acme/docs/blob/main/api/auth.md', [
{ id: 'b', previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' } },
])?.space
).toBe('b');
});
});
describe('findPageByGitPath', () => {
const pages = [
{ id: 'auth', git: { path: 'api/auth.md' }, pages: [] },
{ id: 'group', pages: [{ id: 'readme', git: { path: 'api/11.8/README.md' }, pages: [] }] },
];
it('finds nested pages and directory README links', () => {
expect(findPageByGitPath(pages, 'api/auth.md')?.id).toBe('auth');
expect(findPageByGitPath(pages, 'api/11.8/')?.id).toBe('readme');
expect(findPageByGitPath(pages, 'api/missing.md')).toBeNull();
});
it('does not select between duplicate paths', () => {
expect(
findPageByGitPath(
[...pages, { id: 'copy', git: { path: 'api/auth.md' }, pages: [] }],
'api/auth.md'
)
).toBeNull();
});
});
describe('findPageForGitPageURLTarget', () => {
const pages = [
{ id: 'auth', git: { path: 'api/auth.md' }, pages: [] },
{ id: 'group', pages: [{ id: 'readme', git: { path: 'api/11.8/README.md' }, pages: [] }] },
];
it('finds the page of a live target, including directory README links', () => {
expect(findPageForGitPageURLTarget(pages, { space: 'b', path: 'api/auth.md' })?.id).toBe(
'auth'
);
expect(findPageForGitPageURLTarget(pages, { space: 'b', path: 'api/11.8/' })?.id).toBe(
'readme'
);
});
it('finds the page of a remembered target whose path still includes the ref', () => {
expect(
findPageForGitPageURLTarget(pages, {
space: 'b',
path: 'release/v2/api/auth.md',
pathIncludesRef: true,
})?.id
).toBe('auth');
});
it('does not select between duplicate paths', () => {
expect(
findPageForGitPageURLTarget(
[...pages, { id: 'copy', git: { path: 'api/auth.md' }, pages: [] }],
{ space: 'b', path: 'api/auth.md' }
)
).toBeNull();
});
});
-196
View File
@@ -1,196 +0,0 @@
export interface GitPageURLSpace {
id: string;
gitSync?: {
url?: string;
installationProjectDirectory?: string;
};
previousGitSync?: { url?: string };
}
export interface GitPageURLTarget {
space: string;
path: string;
anchor?: string;
/** Previous project URLs do not distinguish a slash-containing ref from the file path. */
pathIncludesRef?: boolean;
}
/** Locate a unique owning space without fetching any revisions. */
export function findGitPageURLTarget(
href: string,
spaces: readonly GitPageURLSpace[]
): GitPageURLTarget | null {
const url = parseURL(href);
if (!url || url.search) {
return null;
}
let anchor: string | undefined;
try {
anchor = decodeURIComponent(url.hash.slice(1)) || undefined;
} catch {
return null;
}
const matches = new Map<string, GitPageURLTarget & { root: string; tree?: string }>();
for (const space of spaces) {
const directory = space.gitSync?.installationProjectDirectory ?? '';
let root = directory.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
let filePath: string | null;
let treeKey: string | undefined;
if (space.gitSync) {
const tree = space.gitSync.url ? parseURL(space.gitSync.url) : null;
if (!tree || (tree.host !== url.host && url.host !== `www.${tree.host}`)) {
continue;
}
const prefix = tree.pathname.replace(/\/$/, '');
const blobPrefix = prefix
.replace('/-/tree/', '/-/blob/')
.replace(/^(\/[^/]+\/[^/]+)\/tree\//, '$1/blob/');
const matchedPrefix = [prefix, blobPrefix].find((candidate) =>
url.pathname.startsWith(`${candidate}/`)
);
if (!matchedPrefix) {
continue;
}
filePath = decodeGitPath(url.pathname.slice(matchedPrefix.length + 1));
treeKey = `${tree.host}${prefix}`;
if (!filePath || (root && filePath !== root && !filePath.startsWith(`${root}/`))) {
continue;
}
root = `${prefix}/${root}`.replace(/\/$/, '');
} else {
const previous = space.previousGitSync?.url
? parseURL(space.previousGitSync.url)
: null;
filePath = previous ? findPreviousGitPath(url, previous) : null;
if (!filePath || !previous) {
continue;
}
root = previous.pathname.replace(/\/$/, '');
}
matches.set(space.id, {
space: space.id,
path: filePath,
anchor,
root,
tree: treeKey,
...(!space.gitSync ? { pathIncludesRef: true } : {}),
});
}
const candidates = [...matches.values()];
if (
new Set(candidates.flatMap((candidate) => (candidate.tree ? [candidate.tree] : []))).size >
1
) {
return null;
}
const longestRoot = Math.max(...candidates.map((candidate) => candidate.root.length));
const owners = candidates.filter((candidate) => candidate.root.length === longestRoot);
if (owners.length !== 1) {
return null;
}
const owner = owners[0]!;
return {
space: owner.space,
path: owner.path,
anchor: owner.anchor,
...(owner.pathIncludesRef ? { pathIncludesRef: true } : {}),
};
}
/** Match stored page paths after verifying the owning repository URL; callers must reject multiple pages. */
export function matchesGitPageURLTargetPath(target: GitPageURLTarget, filePath: string): boolean {
const paths = [target.path, `${target.path.replace(/\/$/, '')}/README.md`];
return paths.some((path) =>
target.pathIncludesRef ? path.endsWith(`/${filePath}`) : path === filePath
);
}
/** Match an API revision's nested page tree, including directory README links. */
export function findPageByGitPath<T extends { id: string; git?: { path: string }; pages?: T[] }>(
pages: readonly T[],
filePath: string
): T | null {
const paths = [filePath, `${filePath.replace(/\/$/, '')}/README.md`];
const matches: T[] = [];
const visit = (children: readonly T[]) => {
for (const page of children) {
if (page.git && paths.includes(page.git.path)) {
matches.push(page);
}
visit(page.pages ?? []);
}
};
visit(pages);
return matches.length === 1 ? matches[0]! : null;
}
/** Find the only page of an API revision's page tree at the path a target points to. */
export function findPageForGitPageURLTarget<
T extends { id: string; git?: { path: string }; pages?: T[] },
>(pages: readonly T[], target: GitPageURLTarget): T | null {
const matches: T[] = [];
const visit = (children: readonly T[]) => {
for (const page of children) {
if (page.git && matchesGitPageURLTargetPath(target, page.git.path)) {
matches.push(page);
}
visit(page.pages ?? []);
}
};
visit(pages);
return matches.length === 1 ? matches[0]! : null;
}
function parseURL(href: string): URL | null {
try {
const url = new URL(href);
return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password
? url
: null;
} catch {
return null;
}
}
function decodeGitPath(encoded: string): string | null {
try {
// Encoded separators make repository/ref boundaries ambiguous.
if (/%2f|%5c/i.test(encoded)) {
return null;
}
const decoded = decodeURIComponent(encoded);
return decoded.includes('\\') ||
decoded.split('/').some((part) => part === '.' || part === '..')
? null
: decoded;
} catch {
return null;
}
}
function findPreviousGitPath(url: URL, previous: URL): string | null {
if (
previous.search ||
previous.hash ||
!/\/(?:tree|blob)\/.+/.test(previous.pathname) ||
(url.host !== previous.host && url.host !== `www.${previous.host}`)
) {
return null;
}
const prefix = previous.pathname.replace(/\/$/, '');
const blobPrefix = prefix
.replace('/-/tree/', '/-/blob/')
.replace(/^(\/[^/]+\/[^/]+)\/tree\//, '$1/blob/');
if (
![prefix, blobPrefix].some(
(candidate) => url.pathname === candidate || url.pathname.startsWith(`${candidate}/`)
)
) {
return null;
}
// Keep the ref until revision lookup: its slash boundary is not recorded separately.
const suffix = url.pathname.match(/\/(?:tree|blob)\/(.+)$/)?.[1];
return suffix ? decodeGitPath(suffix) : null;
}
+105 -195
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, mock } from 'bun:test';
import { describe, expect, it } from 'bun:test';
import type { Revision, RevisionPageDocument, SiteSpace, Space } from '@gitbook/api';
@@ -739,220 +739,130 @@ describe('resolveContentRef for direct space links', () => {
});
});
describe('repository page links', () => {
function fixture(
options: {
denied?: boolean;
missing?: boolean;
draft?: boolean;
gitSync?: object | null;
previousGitSync?: object;
pageGitPath?: string;
} = {}
) {
const page = {
id: 'target-page',
describe('resolveContentRef for a page ref whose page was re-created', () => {
function buildPage(id: string, title: string, path: string): RevisionPageDocument {
return {
object: 'page',
id,
type: 'document',
title: 'Authentication',
path: 'authentication',
slug: 'authentication',
kind: 'sheet',
title,
path,
slug: path.split('/').pop(),
pages: [],
git: { path: options.pageGitPath ?? 'api/auth.md', oid: 'blob' },
tags: [],
layout: {},
urls: { app: 'https://app.gitbook.com/page' },
} as unknown as RevisionPageDocument;
const targetSpace = {
id: 'target',
title: 'API',
organization: 'org',
revision: 'target-main',
gitSync:
options.gitSync === null
? undefined
: (options.gitSync ?? {
url: 'https://github.com/acme/docs/tree/main',
installationProjectDirectory: 'api',
}),
previousGitSync: options.previousGitSync,
urls: {
app: 'https://app.gitbook.com/s/target',
published: 'https://docs.example.com/api/',
},
} as unknown as Space;
const targetSiteSpace = {
id: 'site-target',
title: 'API',
space: targetSpace,
path: 'api',
draft: options.draft ?? false,
urls: { published: 'https://docs.example.com/api/' },
} as unknown as SiteSpace;
const getSpace = mock(async () =>
options.denied ? { error: { code: 403, message: 'Forbidden' } } : { data: targetSpace }
);
const getRevision = mock(async () => ({
data: {
id: 'target-main',
pages: options.missing
? []
: [{ ...page, id: 'home', path: '', slug: '', git: undefined }, page],
files: [],
reusableContents: [],
},
}));
const context = {
organizationId: 'org',
site: { id: 'site' },
space: { id: 'source', revision: 'source-main' },
revision: { pages: [] },
revisionId: 'source-main',
changeRequest: null,
structure: { type: 'siteSpaces', structure: [targetSiteSpace] },
linker: createLinker({
host: 'docs.example.com',
siteBasePath: '/',
spaceBasePath: '/source/',
}),
dataFetcher: { getSpace, getRevision },
} as unknown as GitBookAnyContext;
return { context, getSpace, getRevision };
}
const ref = {
kind: 'url' as const,
url: 'https://github.com/acme/docs/tree/main/api/auth.md#tokens',
};
function buildRevision(id: string, pages: RevisionPageDocument[]): Revision {
return {
object: 'revision',
id,
type: 'edits',
pages,
files: [],
reusableContents: [],
tags: [],
parents: [],
createdAt: '',
urls: { app: '' },
} as unknown as Revision;
}
it('renders a matching repository URL as a site page link with its anchor', async () => {
const { context, getRevision } = fixture();
const result = await resolveContentRef(ref, context);
expect(result?.href).toBe('/api/authentication#tokens');
expect(result?.text).toBe('Authentication');
expect(result?.ancestors?.[0]?.label).toBe('API');
expect(result?.resolvedRef).toEqual({
kind: 'anchor',
space: 'target',
page: 'target-page',
anchor: 'tokens',
});
expect(getRevision).toHaveBeenCalledWith({
spaceId: 'target',
revisionId: 'target-main',
metadata: true,
});
expect(ref.kind).toBe('url');
});
function buildSpace(id: string): Space {
return {
object: 'space',
id,
title: id,
organization: 'org',
revision: `rev-${id}`,
urls: {
location: `https://api.gitbook.com/spaces/${id}`,
app: `https://app.gitbook.com/o/org/s/${id}/`,
published: `https://${id}.gitbook.io/`,
},
} as unknown as Space;
}
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);
});
const recreated = buildPage('page-new', 'Detector', 'alerts-by-name/detector');
const other = buildPage('page-other', 'Other', 'alerts-by-name/other');
const alertsRevision = buildRevision('rev-alerts', [recreated, other]);
const alerts = buildSpace('alerts');
const notes = buildSpace('notes');
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');
});
const dataFetcher = {
getSpace: async ({ spaceId }: { spaceId: string }) =>
spaceId === 'alerts'
? { data: alerts }
: { error: { code: 404, message: 'Not found' } },
getRevision: async ({ spaceId }: { spaceId: string }) =>
spaceId === 'alerts'
? { data: alertsRevision }
: { error: { code: 404, message: 'Not found' } },
getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }),
withToken: function () {
return this;
},
} as unknown as GitBookDataFetcher;
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');
});
function buildContext(space: Space, revision: Revision): GitBookAnyContext {
return {
dataFetcher,
linker: createLinker({
host: 'docs.example.com',
spaceBasePath: '/',
siteBasePath: '/',
}),
organizationId: 'org',
space,
revision,
revisionId: revision.id,
changeRequest: null,
shareKey: undefined,
} as unknown as GitBookAnyContext;
}
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');
});
// Refs only get a typed `path` once @gitbook/api ships it, so the tests add it through a cast.
const withPath = (ref: object, path: string) => ({ ...ref, path }) as never;
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();
it('finds the page at the recorded path when its ID no longer exists', async () => {
const result = await resolveContentRef(
{ kind: 'url', url: ref.url.replace('#tokens', '#access%20tokens') },
context
withPath({ kind: 'page', page: 'page-deleted' }, 'alerts-by-name/detector'),
buildContext(alerts, alertsRevision)
);
expect(result?.resolvedRef).toEqual({
kind: 'anchor',
space: 'target',
page: 'target-page',
anchor: 'access tokens',
});
expect(result?.text).toBe('Detector');
});
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}`,
},
},
}))
it('finds the page at the recorded path in another space', async () => {
const result = await resolveContentRef(
withPath(
{ kind: 'page', space: 'alerts', page: 'page-deleted' },
'alerts-by-name/detector'
),
buildContext(notes, buildRevision('rev-notes', []))
);
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
expect(getSpace).toHaveBeenCalledTimes(1);
expect(getRevision).toHaveBeenCalledTimes(1);
expect(result?.text).toBe('Detector');
});
it.each([{ denied: true }, { missing: true }, { draft: true }])(
'preserves the fallback for unavailable content: %j',
async (state) => {
const { context } = fixture(state);
const result = await resolveContentRef(ref, context);
expect(result).toEqual({ href: ref.url, text: ref.url, active: false });
}
);
it('keeps resolving by ID while the page still exists', async () => {
const result = await resolveContentRef(
withPath({ kind: 'page', page: 'page-other' }, 'alerts-by-name/detector'),
buildContext(alerts, alertsRevision)
);
it('does not fetch revisions for a different repository or branch', async () => {
const { context, getRevision } = fixture();
for (const url of [
ref.url.replace('/main/', '/preview/'),
ref.url.replace('/acme/', '/other/'),
]) {
expect((await resolveContentRef({ kind: 'url', url }, context))?.href).toBe(url);
}
expect(getRevision).not.toHaveBeenCalled();
expect(result?.text).toBe('Other');
});
it('resolves the unchanged stored URL when the target becomes available', async () => {
const state = { missing: true };
const { context } = fixture(state);
expect((await resolveContentRef(ref, context))?.href).toBe(ref.url);
state.missing = false;
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
it('does not resolve when neither the ID nor the recorded path exists', async () => {
const result = await resolveContentRef(
withPath({ kind: 'page', page: 'page-deleted' }, 'alerts-by-name/removed'),
buildContext(alerts, alertsRevision)
);
expect(result).toBeNull();
});
});
+16 -114
View File
@@ -4,12 +4,12 @@ import type React from 'react';
import type {
ContentRef,
JSONDocument,
Revision,
RevisionFile,
RevisionPageDocument,
RevisionReusableContent,
SiteSection,
SiteSpace,
SiteStructure,
Space,
TranslationLanguage,
} from '@gitbook/api';
@@ -17,19 +17,12 @@ import type { Filesystem } from '@gitbook/openapi-parser';
import { getGitBookAppHref } from './app';
import { getBlockById, getBlockTitle } from './document';
import {
type GitPageURLSpace,
type GitPageURLTarget,
findGitPageURLTarget,
findPageForGitPageURLTarget,
} from './gitPageURL';
import { resolvePageId } from './pages';
import { resolvePageId, resolvePagePath } from './pages';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
getLinkerForSiteSpace,
getLocalizedTitle,
listAllSiteSpaces,
} from './sites';
import { getRevisionTags, resolveTag } from './tags';
import type { ClassValue } from './tailwind';
@@ -50,15 +43,7 @@ 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;
/** Text to render in the content ref */
text: string;
/** Additional sub text to render in the content ref */
@@ -159,61 +144,6 @@ export async function resolveContentRef(
switch (contentRef.kind) {
case 'url': {
if ('site' in context) {
const target = findSiteGitPageURLTarget(context.structure, contentRef.url);
if (target) {
try {
// Site CRs must select the target member's revision here instead of main.
const targetContext = await createContextForSpace(
target.space,
context,
true
);
const page =
targetContext &&
findPageForGitPageURLTarget(
targetContext.spaceContext.revision.pages,
target
);
if (page?.type === 'document' && targetContext) {
const resolvedRef: ContentRef = target.anchor
? {
kind: 'anchor',
space: target.space,
page: page.id,
anchor: target.anchor,
}
: { kind: 'page', space: target.space, page: page.id };
const resolved = await resolveContentRef(
resolvedRef,
targetContext.spaceContext,
options
);
if (resolved) {
const foundSiteSpace = findSiteSpaceBy(
context.structure,
(entry) => entry.space.id === target.space
);
return {
...resolved,
resolvedRef,
ancestors: [
...resolvePageAncestors(
context,
resolvedRef,
foundSiteSpace,
targetContext
),
...(resolved.ancestors ?? []),
],
};
}
}
} catch {
// An unavailable or forbidden target must not prevent rendering the source page.
}
}
}
return {
href: contentRef.url,
text: contentRef.url,
@@ -263,7 +193,8 @@ export async function resolveContentRef(
? activePage
? { page: activePage, ancestors: [] }
: undefined
: resolvePageId(revision.pages, contentRef.page);
: (resolvePageId(revision.pages, contentRef.page) ??
resolvePageAtRecordedPath(revision, contentRef));
const page = resolvePageResult?.page;
const ancestors =
@@ -501,6 +432,17 @@ export function resolveContentRefFallback(contentRef: ContentRef): ResolvedConte
return null;
}
/**
* Find the page at the path recorded on a page or anchor ref, for when its page ID no longer
* exists (the page was deleted and re-created at the same path).
*/
function resolvePageAtRecordedPath(revision: Revision, contentRef: ContentRef) {
// TODO: read `contentRef.path` directly once @gitbook/api ships it on ContentRefPage and
// ContentRefAnchor; until then the field is untyped.
const { path } = contentRef as { path?: unknown };
return typeof path === 'string' && path ? resolvePagePath(revision.pages, path) : undefined;
}
/**
* This function is used to get the best possible target space while resolving a content ref.
* It will try to return the space in the site context if it exists to avoid cross-site links.
@@ -690,44 +632,6 @@ async function resolveContentRefInSpace(
}
}
/**
* Locate the site space owning a repository URL. Links to other hosts, which most are, skip
* matching against every space of the site.
*/
function findSiteGitPageURLTarget(structure: SiteStructure, href: string): GitPageURLTarget | null {
let site = siteGitSpaces.get(structure);
if (!site) {
const spaces: GitPageURLSpace[] = listAllSiteSpaces(structure)
.filter((siteSpace) => !siteSpace.draft)
.map((siteSpace) => siteSpace.space);
const hosts = new Set(
spaces.flatMap((space) => {
const host = getURLHost(space.gitSync?.url ?? space.previousGitSync?.url);
return host ? [host, `www.${host}`] : [];
})
);
site = { spaces, hosts };
siteGitSpaces.set(structure, site);
}
const host = getURLHost(href);
if (!host || !site.hosts.has(host)) {
return null;
}
return findGitPageURLTarget(href, site.spaces);
}
function getURLHost(href: string | undefined): string | null {
if (!href) {
return null;
}
try {
return new URL(href).host;
} catch {
return null;
}
}
/**
* Create a new context for a specific spaceId.
*
@@ -736,8 +640,7 @@ function getURLHost(href: string | undefined): string | null {
*/
async function createContextForSpace(
spaceId: string,
context: GitBookAnyContext,
revisionMetadata = false
context: GitBookAnyContext
): Promise<{
spaceContext: GitBookSpaceContext;
baseURL: URL;
@@ -749,7 +652,6 @@ async function createContextForSpace(
shareKey: context?.shareKey,
changeRequest: undefined,
revision: undefined,
revisionMetadata,
})
),
getBestTargetSpace(context, spaceId),
@@ -1,93 +0,0 @@
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');
});
});
+30 -69
View File
@@ -1,8 +1,6 @@
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';
@@ -24,36 +22,12 @@ export async function servePageMarkdown(baseContext: GitBookSiteContext, pagePat
linker: linkerWithMarkdownPages(baseContext.linker),
};
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()
);
const pageLookup = resolveSiteSpacePagePathDocumentOrGroup(
context.siteSpace,
context.revision.pages,
pagePath
);
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),
@@ -89,44 +63,12 @@ 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
@@ -143,7 +85,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
@@ -190,7 +145,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.
`;
@@ -200,14 +164,11 @@ 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 } | Response>,
fn: () => Promise<string | { markdown: string; robots: string }>,
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, {