mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-08 22:31:01 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bd257d918 | |||
| 8d238f76d4 | |||
| 3ed448fed7 | |||
| 4856a1f744 | |||
| ac843657ec | |||
| b1102fcebe | |||
| 7996c362c1 | |||
| c7c1b79788 | |||
| dff0c7903e | |||
| ffebd1790e | |||
| 0e0085e49a | |||
| 8e131693ea | |||
| 96325161be | |||
| 9f5290bccc | |||
| 61992424af | |||
| 380af10236 | |||
| 0911abc55c | |||
| d8dc59b1a9 | |||
| d04f8bc05e | |||
| 576a781a8f |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Stop the `?ask` query briefly appearing in the search field before an integration assistant opens.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Recolor text over a background page cover in the first frame after hydration.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix page cover image jumping on load
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Use the DocumentTextColor type defined in API schema.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix inline Ask AI inputs and buttons doing nothing before search is opened.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Send the previous page's URL with site insights events so broken links can be traced to the page linking to them.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix Loom videos staying blank on pages with many Loom embeds by lazy-loading them.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve dark theme contrast for Mermaid edge labels.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve the prompt for agents to ask questions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Use the page's tag title, when set, for the HTML `<title>` of published pages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Scroll the table of contents to the active page after client-side navigation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Restore the Next.js dev-mode OOM patch that was silently dropped by the 16.3.8 upgrade.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the first item of a sidebar page group sometimes appearing cut off after navigating.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Point the "Powered by GitBook" trademark link to gitbook.com/powered-by.
|
||||
@@ -129,8 +129,8 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "4.1.3",
|
||||
"@opennextjs/cloudflare": "1.20.5",
|
||||
"@opennextjs/aws": "4.1.7",
|
||||
"@opennextjs/cloudflare": "1.20.8",
|
||||
"@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.6",
|
||||
"next": "^16.3.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -342,11 +342,10 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"next@16.3.6": "patches/next@16.3.6.patch",
|
||||
"next@16.3.8": "patches/next@16.3.8.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"@opennextjs/aws": "4.1.5",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"axios": "1.8.4",
|
||||
@@ -356,7 +355,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@gitbook/api": "0.202.0",
|
||||
"@gitbook/api": "0.204.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -728,7 +727,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.202.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-qhrjEQbNNmCljR0AgP79+BsVh9yelh9TIBg9X3TyYx5a3lJwDiT3sw1ND9DFQSxGHp8hH5w+20KJHn/IDpTbaw=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.204.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-SJCe0Ipyt+V5HZX4EZpFO8AHTOWIMwj2ljQTbcqgU7rVOAyCNDzILp8mRk9stbboSi1pv09+rDnZK1ZguPvFTw=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
@@ -892,23 +891,23 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
|
||||
|
||||
"@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="],
|
||||
"@next/env": ["@next/env@16.3.8", "", {}, "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="],
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="],
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q=="],
|
||||
|
||||
"@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-gnu": ["@next/swc-linux-arm64-gnu@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA=="],
|
||||
|
||||
"@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-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="],
|
||||
"@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-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
|
||||
|
||||
@@ -944,9 +943,9 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@opennextjs/aws": ["@opennextjs/aws@4.1.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/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/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=="],
|
||||
"@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=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
@@ -2650,7 +2649,7 @@
|
||||
|
||||
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
|
||||
|
||||
"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": ["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-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
|
||||
+3
-4
@@ -19,8 +19,7 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"esbuild": "0.27.3",
|
||||
"axios": "1.8.4",
|
||||
"@opennextjs/aws": "4.1.5"
|
||||
"axios": "1.8.4"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -49,7 +48,7 @@
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@gitbook/api": "0.202.0",
|
||||
"@gitbook/api": "0.204.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -68,6 +67,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.6": "patches/next@16.3.6.patch"
|
||||
"next@16.3.8": "patches/next@16.3.8.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "4.1.3",
|
||||
"@opennextjs/cloudflare": "1.20.5",
|
||||
"@opennextjs/aws": "4.1.7",
|
||||
"@opennextjs/cloudflare": "1.20.8",
|
||||
"@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.6",
|
||||
"next": "^16.3.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
|
||||
@@ -119,9 +119,13 @@ export function useAI(): AIContext {
|
||||
icon: <AISearchIcon />,
|
||||
open: (query?: string) => {
|
||||
if (query) {
|
||||
setSearchState((prev) =>
|
||||
prev ? { ...prev, query: null, ask: query, open: true } : null
|
||||
);
|
||||
setSearchState((prev) => ({
|
||||
...prev,
|
||||
query: null,
|
||||
ask: query,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: true,
|
||||
}));
|
||||
}
|
||||
},
|
||||
pageAction: false,
|
||||
|
||||
@@ -247,11 +247,15 @@ 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,7 +37,10 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: embed.html,
|
||||
__html:
|
||||
context.mode !== 'print' && shouldLazyLoad(block.data.url)
|
||||
? lazyLoadIframes(embed.html)
|
||||
: embed.html,
|
||||
}}
|
||||
data-visual-test="blackout"
|
||||
/>
|
||||
@@ -73,6 +76,23 @@ 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={inline.data.ref}
|
||||
isExternal={inline.data.ref.kind === 'url'}
|
||||
contentRef={resolved.resolvedRef ?? inline.data.ref}
|
||||
isExternal={(resolved.resolvedRef ?? 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 = inline.data.ref.kind === 'url';
|
||||
const isExternal = (resolved.resolvedRef ?? inline.data.ref).kind === 'url';
|
||||
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
|
||||
|
||||
if (isMailto) {
|
||||
|
||||
@@ -117,8 +117,8 @@ function Color(props: MarkedLeafProps<DocumentMarkColor>) {
|
||||
return (
|
||||
<span
|
||||
className={tcls([
|
||||
textColorToStyle[mark.data.text],
|
||||
backgroundColorToStyle[mark.data.background],
|
||||
textColorToStyle[mark.data.text ?? 'default'],
|
||||
backgroundColorToStyle[mark.data.background ?? 'default'],
|
||||
])}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -93,6 +94,8 @@ export async function EmbeddableRootLayout({
|
||||
/>
|
||||
</SpaceLayoutServerContext>
|
||||
</EmbeddableAIContextProvider>
|
||||
{/* Embeds load no integrations; this marks them loaded so search stops waiting. */}
|
||||
<LoadIntegrations />
|
||||
</SiteLayoutClientContexts>
|
||||
</CustomizationRootLayout>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -75,12 +76,15 @@ 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.
|
||||
@@ -105,6 +109,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
allEvents.push(
|
||||
...transformEvents({
|
||||
url: eventsForPathname.url,
|
||||
previousUrl: eventsForPathname.previousUrl,
|
||||
events: eventsForPathname.events,
|
||||
context: currentContent,
|
||||
pageContext: eventsForPathname.pageContext,
|
||||
@@ -154,6 +159,9 @@ 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
|
||||
@@ -161,6 +169,7 @@ 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 ?? []),
|
||||
{
|
||||
@@ -214,6 +223,18 @@ 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.
|
||||
*/
|
||||
@@ -240,6 +261,7 @@ function sendEvents(args: { eventUrl: string; events: api.SiteInsightsEvent[] })
|
||||
*/
|
||||
function transformEvents(input: {
|
||||
url: string;
|
||||
previousUrl: string | null;
|
||||
events: TrackEventInput<InsightsEventName>[];
|
||||
context: CurrentContentContext;
|
||||
pageContext: InsightsEventPageContext;
|
||||
@@ -258,6 +280,11 @@ 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,6 +147,10 @@ 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,6 +3,13 @@ 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';
|
||||
|
||||
@@ -15,6 +22,10 @@ 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,
|
||||
@@ -208,3 +219,183 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { notFound } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
type Revision,
|
||||
type RevisionPageDocument,
|
||||
type RevisionPageGroup,
|
||||
type SiteCustomizationSettings,
|
||||
@@ -16,7 +15,8 @@ import { ImagesLoadingStatus } from './ImagesLoadingStatus';
|
||||
import { createPDFLinker, getPagePDFContainerId } from './linker';
|
||||
import { PageControlButtons } from './PageControlButtons';
|
||||
import { PDFPrintControls } from './PDFPrintControls';
|
||||
import { type PDFSearchParams, getPDFSearchParams } from './urls';
|
||||
import { selectPages } from './selectPages';
|
||||
import { getPDFSearchParams, getPDFURLSearchParams } from './urls';
|
||||
import { DocumentView } from '@/components/DocumentView';
|
||||
import { Trademark } from '@/components/TableOfContents/Trademark';
|
||||
import type { PolymorphicComponentProp } from '@/components/utils/types';
|
||||
@@ -27,13 +27,10 @@ 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.
|
||||
*/
|
||||
@@ -62,13 +59,37 @@ export async function PDFPage(props: {
|
||||
const language = await getSpaceLanguage(baseContext);
|
||||
|
||||
// Compute the pages to render
|
||||
const { pages, total } = selectPages(baseContext.revision.pages, pdfParams);
|
||||
const selection = selectPages(baseContext.revision.pages, pdfParams);
|
||||
if (!selection) {
|
||||
notFound();
|
||||
}
|
||||
const { pages, allPages, offset, total } = selection;
|
||||
const batchParams = { ...pdfParams, offset };
|
||||
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 linker = createPDFLinker(baseContext.linker, pages, getPublishedLinker(baseContext));
|
||||
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 context: GitBookSpaceContext = {
|
||||
...baseContext,
|
||||
@@ -113,7 +134,7 @@ export async function PDFPage(props: {
|
||||
</div>
|
||||
|
||||
<PageControlButtons
|
||||
params={pdfParams}
|
||||
params={batchParams}
|
||||
pageIds={pageIds}
|
||||
total={total}
|
||||
trademark={
|
||||
@@ -126,7 +147,7 @@ export async function PDFPage(props: {
|
||||
}
|
||||
/>
|
||||
|
||||
{pdfParams.only ? null : (
|
||||
{pdfParams.only || offset > 0 ? null : (
|
||||
<PDFSpaceIntro space={context.space} customization={customization} />
|
||||
)}
|
||||
{pages.map(({ page }) =>
|
||||
@@ -273,67 +294,3 @@ 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,8 +10,6 @@ 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.
|
||||
*/
|
||||
@@ -19,7 +17,7 @@ export function PageControlButtons(props: {
|
||||
params: PDFSearchParams;
|
||||
/** Array of the [pageId, divId] */
|
||||
pageIds: [string, string][];
|
||||
/** Total number of pages targetted by the generation */
|
||||
/** Total number of pages targetted by the generation, across all batches */
|
||||
total: number;
|
||||
/** Trademark to display */
|
||||
trademark?: React.ReactNode;
|
||||
@@ -38,6 +36,10 @@ 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
|
||||
@@ -58,6 +60,8 @@ export function PageControlButtons(props: {
|
||||
...params,
|
||||
page: activePageId,
|
||||
only: true,
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
}).toString()}`}
|
||||
variant="secondary"
|
||||
label={tString(language, 'pdf_mode_only_page')}
|
||||
@@ -68,6 +72,8 @@ export function PageControlButtons(props: {
|
||||
...params,
|
||||
page: undefined,
|
||||
only: false,
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
}).toString()}`}
|
||||
variant="secondary"
|
||||
label={tString(language, 'pdf_mode_all')}
|
||||
@@ -89,7 +95,7 @@ export function PageControlButtons(props: {
|
||||
'z-50'
|
||||
)}
|
||||
>
|
||||
{total !== pageIds.length ? (
|
||||
{previousCount > 0 || nextCount > 0 ? (
|
||||
<div
|
||||
role="banner"
|
||||
className={tcls(
|
||||
@@ -112,19 +118,32 @@ export function PageControlButtons(props: {
|
||||
className={tcls('size-6', 'mr-3', 'mt-1')}
|
||||
/>{' '}
|
||||
<div>
|
||||
<div>{t(language, 'pdf_limit_reached', total, pageIds.length)}</div>
|
||||
<div>
|
||||
<a
|
||||
href={`?${getPDFURLSearchParams({
|
||||
...params,
|
||||
page: undefined,
|
||||
only: false,
|
||||
limit: params.limit + limitExtend,
|
||||
}).toString()}`}
|
||||
className={tcls('underline')}
|
||||
>
|
||||
{t(language, 'pdf_limit_reached_continue', limitExtend)}
|
||||
</a>
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,7 +165,7 @@ export function PageControlButtons(props: {
|
||||
'border'
|
||||
)}
|
||||
>
|
||||
{t(language, 'pdf_page_of', activeIndex, pageIds.length)}
|
||||
{t(language, 'pdf_page_of', params.offset + activeIndex, total)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -20,36 +20,39 @@ export function getPagePDFContainerId(
|
||||
/**
|
||||
* Create a custom linker for PDF exports.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function createPDFLinker(
|
||||
baseLinker: GitBookLinker,
|
||||
pages: { page: Revision['pages'][number] }[],
|
||||
publishedLinker?: GitBookLinker
|
||||
publishedLinker?: GitBookLinker,
|
||||
options: {
|
||||
/** URL of the export batch rendering a page, for pages outside the current batch. */
|
||||
getBatchURL?: (page: RevisionPageDocument | RevisionPageGroup) => string | undefined;
|
||||
} = {}
|
||||
): GitBookLinker {
|
||||
const pageIds = new Set(pages.map((p) => p.page.id));
|
||||
|
||||
return {
|
||||
...baseLinker,
|
||||
toPathForPage(input) {
|
||||
if (pages.some((p) => p.page.id === input.page.id)) {
|
||||
if (pageIds.has(input.page.id)) {
|
||||
return `#${getPagePDFContainerId(input.page, input.anchor)}`;
|
||||
}
|
||||
if (input.page.type === RevisionPageType.Group) {
|
||||
return '#';
|
||||
|
||||
// A published page outlives the export URL, so prefer it for documents.
|
||||
if (input.page.type !== RevisionPageType.Group && publishedLinker) {
|
||||
return publishedLinker.toPathForPage(input);
|
||||
}
|
||||
|
||||
// For pages that are not embedded in this PDF export, keep links on the published site.
|
||||
if (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 '#';
|
||||
}
|
||||
|
||||
return baseLinker.toAbsoluteURL(baseLinker.toPathForPage(input));
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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);
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,33 +1,61 @@
|
||||
const DEFAULT_LIMIT = 100;
|
||||
/** 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;
|
||||
|
||||
export interface PDFSearchParams {
|
||||
/** Page to export. If none is passed, all pages are exported. */
|
||||
page?: string;
|
||||
/** If true, only the `page` is exported, and not its descendant */
|
||||
/** If true, the `page` is exported with its descendants */
|
||||
only?: boolean;
|
||||
/** Limit the number of pages */
|
||||
/** Maximum number of pages to render in this batch */
|
||||
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: DEFAULT_LIMIT,
|
||||
limit:
|
||||
parseInteger(searchParams.get('limit'), { min: 1 }) ??
|
||||
getDefaultPDFLimit({ page, only }),
|
||||
offset: parseInteger(searchParams.get('offset'), { min: 0 }) ?? 0,
|
||||
};
|
||||
|
||||
if (searchParams.has('page')) {
|
||||
params.page = searchParams.get('page') ?? '';
|
||||
if (page !== undefined) {
|
||||
params.page = page;
|
||||
}
|
||||
if (searchParams.has('only')) {
|
||||
if (only) {
|
||||
params.only = true;
|
||||
}
|
||||
if (searchParams.has('limit')) {
|
||||
params.limit = Number(searchParams.get('limit'));
|
||||
}
|
||||
if (searchParams.has('back')) {
|
||||
params.back = searchParams.get('back') ?? '';
|
||||
}
|
||||
@@ -39,7 +67,7 @@ export function getPDFSearchParams(searchParams: URLSearchParams): PDFSearchPara
|
||||
* Get the URL search params to use for a PDF export.
|
||||
*/
|
||||
export function getPDFURLSearchParams(
|
||||
params: PDFSearchParams,
|
||||
params: PDFURLParams,
|
||||
searchParams = new URLSearchParams({})
|
||||
): URLSearchParams {
|
||||
if (params?.page) {
|
||||
@@ -53,9 +81,16 @@ export function getPDFURLSearchParams(
|
||||
searchParams.delete('only');
|
||||
}
|
||||
|
||||
// Persist limit and back
|
||||
// Persist limit, offset 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));
|
||||
@@ -63,3 +98,12 @@ 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,18 +29,21 @@ interface PageCoverImageProps {
|
||||
|
||||
export function PageCoverImage(props: PageCoverImageProps) {
|
||||
const { imgs, y, height, mask } = props;
|
||||
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
|
||||
const { objectPositionY, isLoading } = useCoverPosition(imgs, y, {
|
||||
height,
|
||||
aspectRatio: PAGE_COVER_SIZE,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden" ref={containerRef}>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<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" ref={containerRef} style={{ height }}>
|
||||
<div className="h-full w-full overflow-hidden @container" style={{ height }}>
|
||||
<img
|
||||
src={imgs.light.src}
|
||||
srcSet={imgs.light.srcSet}
|
||||
@@ -52,7 +55,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:
|
||||
@@ -73,7 +76,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,7 +533,6 @@ function getPageActionsURLs({
|
||||
`~gitbook/pdf?${getPDFURLSearchParams({
|
||||
page: page.id,
|
||||
only: true,
|
||||
limit: 100,
|
||||
}).toString()}`
|
||||
)
|
||||
: undefined,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useResizeObserver } from 'usehooks-ts';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
interface ImageSize {
|
||||
width: number;
|
||||
@@ -22,19 +21,18 @@ interface Images {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate the object position Y percentage for a cover image
|
||||
* based on the y offset, image dimensions, and container dimensions.
|
||||
* 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.
|
||||
*/
|
||||
export function useCoverPosition(imgs: Images, y: number) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
export function useCoverPosition(
|
||||
imgs: Images,
|
||||
y: number,
|
||||
container: { height: number | undefined; aspectRatio: ImageSize }
|
||||
) {
|
||||
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.
|
||||
@@ -68,42 +66,31 @@ export function useCoverPosition(imgs: Images, y: number) {
|
||||
// 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 {
|
||||
containerRef,
|
||||
objectPositionY,
|
||||
objectPositionY: imageDimensions
|
||||
? getCoverObjectPositionY(imageDimensions, y, container)
|
||||
: '50%',
|
||||
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,6 +5,7 @@ import React from 'react';
|
||||
|
||||
import { useAI } from '../AI';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { useIntegrationsLoaded } from '../Integrations';
|
||||
import { useBodyLoaded } from '../primitives';
|
||||
import {
|
||||
clearLastSearchQuery,
|
||||
@@ -140,6 +141,7 @@ 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(() => {
|
||||
@@ -169,6 +171,8 @@ 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
|
||||
@@ -203,7 +207,7 @@ export function useSearchController(
|
||||
const query =
|
||||
prev?.query ??
|
||||
getLastSearchQuery(siteSpace.id) ??
|
||||
(withSearchAI || !withAI ? prev?.ask : null) ??
|
||||
(askIsSearchQuery ? prev?.ask : null) ??
|
||||
'';
|
||||
|
||||
return {
|
||||
@@ -217,7 +221,7 @@ export function useSearchController(
|
||||
trackEvent({
|
||||
type: 'search_open',
|
||||
});
|
||||
}, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, withSearchAI]);
|
||||
}, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, askIsSearchQuery]);
|
||||
|
||||
const setQuery = React.useCallback(
|
||||
(value: string) => {
|
||||
@@ -261,7 +265,7 @@ export function useSearchController(
|
||||
});
|
||||
|
||||
const searchValue =
|
||||
state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? lastSearchQuery ?? '';
|
||||
state?.query ?? (askIsSearchQuery ? 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.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
|
||||
|
||||
@@ -150,7 +150,7 @@ function useMarkTextOverCover() {
|
||||
});
|
||||
};
|
||||
|
||||
scheduleUpdate();
|
||||
update();
|
||||
|
||||
window.addEventListener('scroll', scheduleUpdate, { passive: true });
|
||||
window.addEventListener('resize', scheduleUpdate, { passive: true });
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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,7 +4,6 @@ import { notFound, redirect } from 'next/navigation';
|
||||
import {
|
||||
CustomizationDefaultThemeMode,
|
||||
CustomizationHeaderPreset,
|
||||
type RevisionPageDocument,
|
||||
SiteInsightsDisplayContext,
|
||||
type TranslationLanguage,
|
||||
} from '@gitbook/api';
|
||||
@@ -18,6 +17,7 @@ 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,11 +33,7 @@ import { getResizedImageURL } from '@/lib/images';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { resolveContentRef } from '@/lib/references';
|
||||
import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
|
||||
import {
|
||||
getSiteSpacePagePaths,
|
||||
getSiteStructureTitle,
|
||||
resolveSiteSpaceCustomHomePage,
|
||||
} from '@/lib/sites';
|
||||
import { getSiteSpacePagePaths, resolveSiteSpaceCustomHomePage } from '@/lib/sites';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import {
|
||||
generateUpdatesFilterCSS,
|
||||
@@ -45,6 +41,8 @@ import {
|
||||
updatesFilterStyleHref,
|
||||
} from '@/lib/updates';
|
||||
|
||||
export { getPageFullTitle } from './title';
|
||||
|
||||
export type SitePageProps = {
|
||||
context: GitBookSiteContext;
|
||||
pageParams: PagePathParams;
|
||||
@@ -421,20 +419,3 @@ 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,7 +6,8 @@ import type { GitBookSiteContext } from '@/lib/context';
|
||||
|
||||
mock.module('server-only', () => ({}));
|
||||
|
||||
const { fetchPageData, getLowercasePathnameRedirect } = await import('./fetch');
|
||||
const { fetchPageData, getLowercasePathnameRedirect, resolveMissingPagePath } =
|
||||
await import('./fetch');
|
||||
const { normalizeURL } = await import('@/lib/data/urls');
|
||||
|
||||
const page = {
|
||||
@@ -94,6 +95,70 @@ describe('fetchPageData', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMissingPagePath', () => {
|
||||
function createRedirectContext(options: {
|
||||
siteRedirect?: { target: string; permanent?: boolean };
|
||||
spaceRedirectPageId?: string;
|
||||
}) {
|
||||
const getSiteRedirectBySource = mock(async ({ source }: { source: string }) =>
|
||||
options.siteRedirect && source === '/old'
|
||||
? {
|
||||
data: {
|
||||
target: options.siteRedirect.target,
|
||||
redirect: { permanent: options.siteRedirect.permanent ?? false },
|
||||
},
|
||||
}
|
||||
: { error: { code: 404, message: 'Not found' } }
|
||||
);
|
||||
const getRevisionPageByPath = mock(async () =>
|
||||
options.spaceRedirectPageId
|
||||
? { data: { id: options.spaceRedirectPageId } }
|
||||
: { error: { code: 404, message: 'Not found' } }
|
||||
);
|
||||
|
||||
return {
|
||||
organizationId: 'org-1',
|
||||
site: { id: 'site-1' },
|
||||
space: { id: 'space-1', revision: 'revision-1' },
|
||||
revisionId: 'revision-1',
|
||||
revision: { pages: [page] },
|
||||
linker: {
|
||||
toPathInSpace: (path: string) => path,
|
||||
toRelativePathInSite: (path: string) => path,
|
||||
toLinkForContent: (url: string) => new URL(url).pathname,
|
||||
},
|
||||
dataFetcher: { getSiteRedirectBySource, getRevisionPageByPath },
|
||||
} as unknown as GitBookSiteContext;
|
||||
}
|
||||
|
||||
it('resolves a site redirect', async () => {
|
||||
const context = createRedirectContext({
|
||||
siteRedirect: { target: 'https://docs.example.com/new', permanent: true },
|
||||
});
|
||||
|
||||
expect(await resolveMissingPagePath(context, 'old')).toEqual({
|
||||
type: 'redirect',
|
||||
destination: '/new',
|
||||
permanent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a space redirect to a page', async () => {
|
||||
const context = createRedirectContext({ spaceRedirectPageId: page.id });
|
||||
|
||||
expect(await resolveMissingPagePath(context, 'old')).toEqual({
|
||||
type: 'page',
|
||||
page: { page, ancestors: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when nothing matches', async () => {
|
||||
const context = createRedirectContext({});
|
||||
|
||||
expect(await resolveMissingPagePath(context, 'old')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLowercasePathnameRedirect', () => {
|
||||
it('redirects ASCII paths with uppercase letters', () => {
|
||||
expect(getLowercasePathnameRedirect('Foo/Bar')).toBe('foo/bar');
|
||||
|
||||
@@ -2,13 +2,14 @@ import { permanentRedirect, redirect } from 'next/navigation';
|
||||
|
||||
import {
|
||||
CustomizationPageActionType,
|
||||
type RevisionPageDocument,
|
||||
SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH,
|
||||
SITE_REDIRECT_SOURCE_PATH_PATTERN,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { getDataOrNull } from '@/lib/data';
|
||||
import { resolvePageId } from '@/lib/pages';
|
||||
import { type ResolvedPagePath, resolvePageId } from '@/lib/pages';
|
||||
import { withLeadingSlash } from '@/lib/paths';
|
||||
import { resolveSiteSpacePagePath } from '@/lib/sites';
|
||||
|
||||
@@ -70,7 +71,7 @@ export async function fetchPageData(context: GitBookSiteContext, params: PagePar
|
||||
* If the path can't be found, we try to resolve it from the API to handle redirects.
|
||||
*/
|
||||
async function resolvePage(context: GitBookSiteContext, params: PagePathParams | PageIdParams) {
|
||||
const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
|
||||
const { revision } = context;
|
||||
|
||||
if ('pageId' in params) {
|
||||
return resolvePageId(revision.pages, params.pageId);
|
||||
@@ -85,72 +86,102 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
|
||||
return page;
|
||||
}
|
||||
|
||||
const fallback = await resolveMissingPagePath(context, rawPathname);
|
||||
if (fallback?.type === 'redirect') {
|
||||
return fallback.permanent
|
||||
? permanentRedirect(fallback.destination)
|
||||
: redirect(fallback.destination);
|
||||
}
|
||||
|
||||
return fallback?.page;
|
||||
}
|
||||
|
||||
export type MissingPagePathResolution =
|
||||
| {
|
||||
type: 'redirect';
|
||||
/** Destination as returned by `linker.toLinkForContent` (absolute path or URL). */
|
||||
destination: string;
|
||||
permanent: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'page';
|
||||
page: ResolvedPagePath<RevisionPageDocument>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a pathname that doesn't match any page of the revision, using site-level and space-level redirects.
|
||||
*/
|
||||
export async function resolveMissingPagePath(
|
||||
context: GitBookSiteContext,
|
||||
rawPathname: string
|
||||
): Promise<MissingPagePathResolution | undefined> {
|
||||
const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
|
||||
|
||||
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
|
||||
// API has a limit of less than 512 characters for the source path, so we use the same limit here.
|
||||
if (rawPathname.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH) {
|
||||
const SITE_REDIRECT_SOURCE_PATH_REGEX = new RegExp(SITE_REDIRECT_SOURCE_PATH_PATTERN);
|
||||
const redirectPathname = withLeadingSlash(rawPathname);
|
||||
// If a page can't be found, we try with the API, in case we have a redirect at site level.
|
||||
const redirectSources = new Set(
|
||||
[
|
||||
// Test the pathname relative to the root
|
||||
// For example hello/world -> section/variant/hello/world
|
||||
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname)),
|
||||
// Test the pathname relative to the content/space
|
||||
// For example hello/world -> /hello/world
|
||||
redirectPathname,
|
||||
]
|
||||
.map(toSiteRedirectSourceCandidate)
|
||||
.filter((source) => SITE_REDIRECT_SOURCE_PATH_REGEX.test(source))
|
||||
);
|
||||
if (rawPathname.length >= SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const source of redirectSources) {
|
||||
// We try to resolve the site redirect
|
||||
const resolvedSiteRedirect =
|
||||
source.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH &&
|
||||
(await getDataOrNull(
|
||||
context.dataFetcher.getSiteRedirectBySource({
|
||||
organizationId,
|
||||
siteId: site.id,
|
||||
source,
|
||||
siteShareKey: shareKey,
|
||||
})
|
||||
));
|
||||
if (resolvedSiteRedirect) {
|
||||
const destination = linker.toLinkForContent(resolvedSiteRedirect.target);
|
||||
const isPublicLiveContext =
|
||||
!shareKey &&
|
||||
!context.changeRequest &&
|
||||
!context.preview &&
|
||||
context.revisionId === context.space.revision &&
|
||||
!context.isLoggedInVisitor;
|
||||
if (
|
||||
const SITE_REDIRECT_SOURCE_PATH_REGEX = new RegExp(SITE_REDIRECT_SOURCE_PATH_PATTERN);
|
||||
const redirectPathname = withLeadingSlash(rawPathname);
|
||||
// If a page can't be found, we try with the API, in case we have a redirect at site level.
|
||||
const redirectSources = new Set(
|
||||
[
|
||||
// Test the pathname relative to the root
|
||||
// For example hello/world -> section/variant/hello/world
|
||||
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname)),
|
||||
// Test the pathname relative to the content/space
|
||||
// For example hello/world -> /hello/world
|
||||
redirectPathname,
|
||||
]
|
||||
.map(toSiteRedirectSourceCandidate)
|
||||
.filter((source) => SITE_REDIRECT_SOURCE_PATH_REGEX.test(source))
|
||||
);
|
||||
|
||||
for (const source of redirectSources) {
|
||||
// We try to resolve the site redirect
|
||||
const resolvedSiteRedirect =
|
||||
source.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH &&
|
||||
(await getDataOrNull(
|
||||
context.dataFetcher.getSiteRedirectBySource({
|
||||
organizationId,
|
||||
siteId: site.id,
|
||||
source,
|
||||
siteShareKey: shareKey,
|
||||
})
|
||||
));
|
||||
if (resolvedSiteRedirect) {
|
||||
const isPublicLiveContext =
|
||||
!shareKey &&
|
||||
!context.changeRequest &&
|
||||
!context.preview &&
|
||||
context.revisionId === context.space.revision &&
|
||||
!context.isLoggedInVisitor;
|
||||
return {
|
||||
type: 'redirect',
|
||||
destination: linker.toLinkForContent(resolvedSiteRedirect.target),
|
||||
permanent: Boolean(
|
||||
resolvedSiteRedirect.redirect?.permanent &&
|
||||
!resolvedSiteRedirect.redirect.draft &&
|
||||
isPublicLiveContext
|
||||
) {
|
||||
return permanentRedirect(destination);
|
||||
}
|
||||
return redirect(destination);
|
||||
}
|
||||
}
|
||||
|
||||
// If page still can't be found, we try with the API, in case we have a redirect at space level.
|
||||
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
|
||||
// The page rendering will take care of redirecting to a normalized pathname.
|
||||
const resolved = await getDataOrNull(
|
||||
context.dataFetcher.getRevisionPageByPath({
|
||||
spaceId: space.id,
|
||||
revisionId: revisionId,
|
||||
path: rawPathname,
|
||||
})
|
||||
);
|
||||
if (resolved) {
|
||||
return resolvePageId(revision.pages, resolved.id);
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
// If page still can't be found, we try with the API, in case we have a redirect at space level.
|
||||
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
|
||||
// The page rendering will take care of redirecting to a normalized pathname.
|
||||
const resolved = await getDataOrNull(
|
||||
context.dataFetcher.getRevisionPageByPath({
|
||||
spaceId: space.id,
|
||||
revisionId: revisionId,
|
||||
path: rawPathname,
|
||||
})
|
||||
);
|
||||
const page = resolved ? resolvePageId(revision.pages, resolved.id) : undefined;
|
||||
return page ? { type: 'page', page } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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,7 +29,8 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
|
||||
<div ref={sentinelRef} className="h-0" aria-hidden="true" />
|
||||
<div
|
||||
className={tcls(
|
||||
'-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',
|
||||
// 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',
|
||||
isSticking ? '' : 'after:opacity-0',
|
||||
'mt-1 pt-2.5 pb-0',
|
||||
'bg-tint-base',
|
||||
|
||||
@@ -134,6 +134,7 @@ 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');
|
||||
const url = new URL('https://www.gitbook.com/powered-by');
|
||||
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 when it's initially active.
|
||||
* - Auto-scroll to the active item on mount and when it changes.
|
||||
*/
|
||||
export type ScrollContainerProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -42,6 +42,12 @@ 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>;
|
||||
@@ -53,6 +59,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
contentClassName,
|
||||
orientation,
|
||||
active,
|
||||
followActive = false,
|
||||
scrollByVisibleItems = false,
|
||||
leading = { fade: true, button: true },
|
||||
trailing = { fade: true, button: true },
|
||||
@@ -80,8 +87,44 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
if (!activeItem || !container.contains(activeItem)) {
|
||||
return;
|
||||
}
|
||||
if (followActive && isElementFullyVisibleInContainer(activeItem, container)) {
|
||||
return;
|
||||
}
|
||||
scrollToElementInContainer(activeItem, container);
|
||||
}, [active]);
|
||||
}, [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]);
|
||||
|
||||
const scrollFurther = () => {
|
||||
const container = containerRef.current;
|
||||
@@ -335,7 +378,11 @@ function scrollByViewport(
|
||||
/**
|
||||
* Scroll to an element in a container.
|
||||
*/
|
||||
function scrollToElementInContainer(element: Element, container: HTMLElement) {
|
||||
export function scrollToElementInContainer(
|
||||
element: Element,
|
||||
container: HTMLElement,
|
||||
behavior: ScrollBehavior = 'auto'
|
||||
) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
@@ -350,8 +397,26 @@ function scrollToElementInContainer(element: Element, container: HTMLElement) {
|
||||
(rect.left - containerRect.left) -
|
||||
container.clientWidth / 2 +
|
||||
rect.width / 2,
|
||||
// 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',
|
||||
behavior,
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,8 +96,9 @@ export const ar: TranslationLanguage = {
|
||||
pdf_page_of: '${1} من ${2}',
|
||||
pdf_mode_only_page: 'هذه الصفحة فقط',
|
||||
pdf_mode_all: 'كل الصفحات',
|
||||
pdf_limit_reached: 'تعذر إنشاء ملف PDF لـ ${1} صفحات، وتوقف الإنشاء عند ${2}.',
|
||||
pdf_limit_reached_continue: 'التوسيع بإضافة ${1} صفحات أخرى.',
|
||||
pdf_batch_range: 'عرض الصفحات ${1}–${2} من ${3}.',
|
||||
pdf_batch_previous: 'الصفحات الـ ${1} السابقة',
|
||||
pdf_batch_next: 'الصفحات الـ ${1} التالية',
|
||||
more: 'المزيد',
|
||||
link_tooltip_external_link: 'رابط خارجي إلى',
|
||||
link_tooltip_email: 'إرسال بريد إلكتروني إلى',
|
||||
|
||||
@@ -99,9 +99,9 @@ export const bg: TranslationLanguage = {
|
||||
pdf_page_of: '${1} от ${2}',
|
||||
pdf_mode_only_page: 'Само тази страница',
|
||||
pdf_mode_all: 'Всички страници',
|
||||
pdf_limit_reached:
|
||||
'PDF файлът за ${1} страници не можа да бъде генериран, генерирането спря на ${2}.',
|
||||
pdf_limit_reached_continue: 'Разширяване с още ${1} страници.',
|
||||
pdf_batch_range: 'Показани са страници ${1}–${2} от ${3}.',
|
||||
pdf_batch_previous: 'Предишни ${1} страници',
|
||||
pdf_batch_next: 'Следващи ${1} страници',
|
||||
more: 'Още',
|
||||
link_tooltip_external_link: 'Външна връзка към',
|
||||
link_tooltip_email: 'Изпращане на имейл до',
|
||||
|
||||
@@ -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_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.',
|
||||
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',
|
||||
more: 'Více',
|
||||
link_tooltip_external_link: 'Externí odkaz na',
|
||||
link_tooltip_email: 'Odeslat e-mail na',
|
||||
|
||||
@@ -97,8 +97,9 @@ export const da: TranslationLanguage = {
|
||||
pdf_page_of: '${1} af ${2}',
|
||||
pdf_mode_only_page: 'Kun denne side',
|
||||
pdf_mode_all: 'Alle sider',
|
||||
pdf_limit_reached: 'Kunne ikke generere PDF for ${1} sider, genereringen stoppede ved ${2}.',
|
||||
pdf_limit_reached_continue: 'Udvid med ${1} flere sider.',
|
||||
pdf_batch_range: 'Viser sider ${1}–${2} af ${3}.',
|
||||
pdf_batch_previous: 'Forrige ${1} sider',
|
||||
pdf_batch_next: 'Næste ${1} sider',
|
||||
more: 'Mere',
|
||||
link_tooltip_external_link: 'Eksternt link til',
|
||||
link_tooltip_email: 'Send e-mail til',
|
||||
|
||||
@@ -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_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.',
|
||||
pdf_batch_range: 'Seiten ${1}–${2} von ${3}.',
|
||||
pdf_batch_previous: 'Vorherige ${1} Seiten',
|
||||
pdf_batch_next: 'Nächste ${1} Seiten',
|
||||
more: 'Mehr',
|
||||
link_tooltip_external_link: 'Externer Link zu',
|
||||
link_tooltip_email: 'E-Mail senden an',
|
||||
|
||||
@@ -100,9 +100,9 @@ export const el: TranslationLanguage = {
|
||||
pdf_page_of: '${1} από ${2}',
|
||||
pdf_mode_only_page: 'Μόνο αυτή η σελίδα',
|
||||
pdf_mode_all: 'Όλες οι σελίδες',
|
||||
pdf_limit_reached:
|
||||
'Δεν ήταν δυνατή η δημιουργία PDF για ${1} σελίδες, η δημιουργία σταμάτησε στο ${2}.',
|
||||
pdf_limit_reached_continue: 'Επέκταση με ${1} ακόμη σελίδες.',
|
||||
pdf_batch_range: 'Εμφάνιση σελίδων ${1}–${2} από ${3}.',
|
||||
pdf_batch_previous: 'Προηγούμενες ${1} σελίδες',
|
||||
pdf_batch_next: 'Επόμενες ${1} σελίδες',
|
||||
more: 'Περισσότερα',
|
||||
link_tooltip_external_link: 'Εξωτερικός σύνδεσμος προς',
|
||||
link_tooltip_email: 'Αποστολή email σε',
|
||||
|
||||
@@ -95,8 +95,9 @@ export const en = {
|
||||
pdf_page_of: '${1} of ${2}',
|
||||
pdf_mode_only_page: 'Only this page',
|
||||
pdf_mode_all: 'All 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.',
|
||||
pdf_batch_range: 'Showing pages ${1}–${2} of ${3}.',
|
||||
pdf_batch_previous: 'Previous ${1} pages',
|
||||
pdf_batch_next: 'Next ${1} pages',
|
||||
more: 'More',
|
||||
link_tooltip_external_link: 'External link to',
|
||||
link_tooltip_email: 'Send an email to',
|
||||
|
||||
@@ -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_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.',
|
||||
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',
|
||||
more: 'Más',
|
||||
link_tooltip_external_link: 'Enlace externo a',
|
||||
link_tooltip_email: 'Enviar un correo electrónico a',
|
||||
|
||||
@@ -97,8 +97,9 @@ export const et: TranslationLanguage = {
|
||||
pdf_page_of: '${1}/${2}',
|
||||
pdf_mode_only_page: 'Ainult see leht',
|
||||
pdf_mode_all: 'Kõik lehed',
|
||||
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.',
|
||||
pdf_batch_range: 'Kuvatakse lehti ${1}–${2}/${3}.',
|
||||
pdf_batch_previous: 'Eelmised ${1} lehte',
|
||||
pdf_batch_next: 'Järgmised ${1} lehte',
|
||||
more: 'Rohkem',
|
||||
link_tooltip_external_link: 'Väline link aadressile',
|
||||
link_tooltip_email: 'Saada e-kiri aadressile',
|
||||
|
||||
@@ -99,8 +99,9 @@ export const fi: TranslationLanguage = {
|
||||
pdf_page_of: '${1}/${2}',
|
||||
pdf_mode_only_page: 'Vain tämä sivu',
|
||||
pdf_mode_all: 'Kaikki sivut',
|
||||
pdf_limit_reached: 'PDF-tiedostoa ei voitu luoda ${1} sivulle, luonti pysähtyi kohtaan ${2}.',
|
||||
pdf_limit_reached_continue: 'Laajenna ${1} sivulla.',
|
||||
pdf_batch_range: 'Näytetään sivut ${1}–${2}/${3}.',
|
||||
pdf_batch_previous: 'Edelliset ${1} sivua',
|
||||
pdf_batch_next: 'Seuraavat ${1} sivua',
|
||||
more: 'Lisää',
|
||||
link_tooltip_external_link: 'Ulkoinen linkki kohteeseen',
|
||||
link_tooltip_email: 'Lähetä sähköpostia osoitteeseen',
|
||||
|
||||
@@ -98,8 +98,9 @@ export const fr: TranslationLanguage = {
|
||||
pdf_page_of: '${1} sur ${2}',
|
||||
pdf_mode_only_page: 'Cette page uniquement',
|
||||
pdf_mode_all: 'Toutes les pages',
|
||||
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',
|
||||
pdf_batch_range: 'Pages ${1} à ${2} sur ${3}.',
|
||||
pdf_batch_previous: '${1} pages précédentes',
|
||||
pdf_batch_next: '${1} pages suivantes',
|
||||
more: 'Plus',
|
||||
link_tooltip_external_link: 'Lien externe vers',
|
||||
link_tooltip_email: 'Envoyer un e-mail à',
|
||||
|
||||
@@ -96,8 +96,9 @@ export const he: TranslationLanguage = {
|
||||
pdf_page_of: '${1} מתוך ${2}',
|
||||
pdf_mode_only_page: 'רק הדף הזה',
|
||||
pdf_mode_all: 'כל הדפים',
|
||||
pdf_limit_reached: 'לא ניתן ליצור PDF עבור ${1} דפים, היצירה נעצרה ב-${2}.',
|
||||
pdf_limit_reached_continue: 'הרחבה עם עוד ${1} דפים.',
|
||||
pdf_batch_range: 'מוצגים דפים ${1}–${2} מתוך ${3}.',
|
||||
pdf_batch_previous: '${1} הדפים הקודמים',
|
||||
pdf_batch_next: '${1} הדפים הבאים',
|
||||
more: 'עוד',
|
||||
link_tooltip_external_link: 'קישור חיצוני אל',
|
||||
link_tooltip_email: 'שליחת אימייל אל',
|
||||
|
||||
@@ -96,8 +96,9 @@ export const hi: TranslationLanguage = {
|
||||
pdf_page_of: '${1} में से ${2}',
|
||||
pdf_mode_only_page: 'केवल यह पृष्ठ',
|
||||
pdf_mode_all: 'सभी पृष्ठ',
|
||||
pdf_limit_reached: '${1} पृष्ठों के लिए PDF नहीं बन सका, जनरेशन ${2} पर रुक गया।',
|
||||
pdf_limit_reached_continue: '${1} और पृष्ठों के साथ बढ़ाएं।',
|
||||
pdf_batch_range: '${3} में से पृष्ठ ${1}–${2} दिखाए जा रहे हैं।',
|
||||
pdf_batch_previous: 'पिछले ${1} पृष्ठ',
|
||||
pdf_batch_next: 'अगले ${1} पृष्ठ',
|
||||
more: 'अधिक',
|
||||
link_tooltip_external_link: 'बाहरी लिंक',
|
||||
link_tooltip_email: 'ईमेल भेजें',
|
||||
|
||||
@@ -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_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.',
|
||||
pdf_batch_range: 'Prikazane stranice ${1}–${2} od ${3}.',
|
||||
pdf_batch_previous: 'Prethodnih ${1} stranica',
|
||||
pdf_batch_next: 'Sljedećih ${1} stranica',
|
||||
more: 'Više',
|
||||
link_tooltip_external_link: 'Vanjska poveznica na',
|
||||
link_tooltip_email: 'Pošalji e-poštu na',
|
||||
|
||||
@@ -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_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.',
|
||||
pdf_batch_range: '${1}–${2}. oldal, összesen ${3}.',
|
||||
pdf_batch_previous: 'Előző ${1} oldal',
|
||||
pdf_batch_next: 'Következő ${1} oldal',
|
||||
more: 'Továbbiak',
|
||||
link_tooltip_external_link: 'Külső hivatkozás ide:',
|
||||
link_tooltip_email: 'E-mail küldése ide:',
|
||||
|
||||
@@ -97,8 +97,9 @@ export const id: TranslationLanguage = {
|
||||
pdf_page_of: '${1} dari ${2}',
|
||||
pdf_mode_only_page: 'Hanya halaman ini',
|
||||
pdf_mode_all: 'Semua halaman',
|
||||
pdf_limit_reached: 'Tidak dapat membuat PDF untuk ${1} halaman, pembuatan berhenti di ${2}.',
|
||||
pdf_limit_reached_continue: 'Perluas dengan ${1} halaman lagi.',
|
||||
pdf_batch_range: 'Menampilkan halaman ${1}–${2} dari ${3}.',
|
||||
pdf_batch_previous: '${1} halaman sebelumnya',
|
||||
pdf_batch_next: '${1} halaman berikutnya',
|
||||
more: 'Lainnya',
|
||||
link_tooltip_external_link: 'Tautan eksternal ke',
|
||||
link_tooltip_email: 'Kirim email ke',
|
||||
|
||||
@@ -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_limit_reached:
|
||||
'Impossibile generare il PDF per ${1} pagine, la generazione si è fermata a ${2}.',
|
||||
pdf_limit_reached_continue: 'Estendi con altre ${1} pagine.',
|
||||
pdf_batch_range: 'Pagine ${1}–${2} di ${3}.',
|
||||
pdf_batch_previous: '${1} pagine precedenti',
|
||||
pdf_batch_next: '${1} pagine successive',
|
||||
more: 'Altro',
|
||||
link_tooltip_external_link: 'Link esterno a',
|
||||
link_tooltip_email: "Invia un'email a",
|
||||
|
||||
@@ -98,8 +98,9 @@ export const ja: TranslationLanguage = {
|
||||
pdf_page_of: '${1} / ${2}',
|
||||
pdf_mode_only_page: 'このページのみ',
|
||||
pdf_mode_all: '全てのページ',
|
||||
pdf_limit_reached: '${1}ページのPDFを生成できませんでした、${2}で生成が停止しました。',
|
||||
pdf_limit_reached_continue: 'さらに${1}ページで拡張',
|
||||
pdf_batch_range: '${3}ページ中 ${1}–${2}ページを表示しています。',
|
||||
pdf_batch_previous: '前の${1}ページ',
|
||||
pdf_batch_next: '次の${1}ページ',
|
||||
more: 'もっと見る',
|
||||
link_tooltip_external_link: '外部リンクへ',
|
||||
link_tooltip_email: 'メールを送信',
|
||||
|
||||
@@ -97,8 +97,9 @@ export const ko: TranslationLanguage = {
|
||||
pdf_page_of: '${1} / ${2}',
|
||||
pdf_mode_only_page: '이 페이지만',
|
||||
pdf_mode_all: '모든 페이지',
|
||||
pdf_limit_reached: '${1}페이지에 대한 PDF를 생성할 수 없어 ${2}에서 생성이 중지되었습니다.',
|
||||
pdf_limit_reached_continue: '${1}페이지 더 확장하기.',
|
||||
pdf_batch_range: '전체 ${3}페이지 중 ${1}–${2}페이지를 표시하고 있습니다.',
|
||||
pdf_batch_previous: '이전 ${1}페이지',
|
||||
pdf_batch_next: '다음 ${1}페이지',
|
||||
more: '더 보기',
|
||||
link_tooltip_external_link: '외부 링크',
|
||||
link_tooltip_email: '이메일 보내기',
|
||||
|
||||
@@ -98,8 +98,9 @@ export const lt: TranslationLanguage = {
|
||||
pdf_page_of: '${1} iš ${2}',
|
||||
pdf_mode_only_page: 'Tik šis puslapis',
|
||||
pdf_mode_all: 'Visi puslapiai',
|
||||
pdf_limit_reached: 'Nepavyko sugeneruoti PDF ${1} puslapiams, generavimas sustojo ties ${2}.',
|
||||
pdf_limit_reached_continue: 'Išplėsti dar ${1} puslapiais.',
|
||||
pdf_batch_range: 'Rodomi puslapiai ${1}–${2} iš ${3}.',
|
||||
pdf_batch_previous: 'Ankstesni ${1} puslapiai',
|
||||
pdf_batch_next: 'Kiti ${1} puslapiai',
|
||||
more: 'Daugiau',
|
||||
link_tooltip_external_link: 'Išorinė nuoroda į',
|
||||
link_tooltip_email: 'Siųsti el. laišką į',
|
||||
|
||||
@@ -96,8 +96,9 @@ export const lv: TranslationLanguage = {
|
||||
pdf_page_of: '${1} no ${2}',
|
||||
pdf_mode_only_page: 'Tikai šī lapa',
|
||||
pdf_mode_all: 'Visas 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.',
|
||||
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',
|
||||
more: 'Vairāk',
|
||||
link_tooltip_external_link: 'Ārēja saite uz',
|
||||
link_tooltip_email: 'Sūtīt e-pastu uz',
|
||||
|
||||
@@ -97,8 +97,9 @@ export const ms: TranslationLanguage = {
|
||||
pdf_page_of: '${1} daripada ${2}',
|
||||
pdf_mode_only_page: 'Halaman ini sahaja',
|
||||
pdf_mode_all: 'Semua halaman',
|
||||
pdf_limit_reached: 'Tidak dapat menjana PDF untuk ${1} halaman, penjanaan berhenti pada ${2}.',
|
||||
pdf_limit_reached_continue: 'Lanjutkan dengan ${1} halaman lagi.',
|
||||
pdf_batch_range: 'Memaparkan halaman ${1}–${2} daripada ${3}.',
|
||||
pdf_batch_previous: '${1} halaman sebelumnya',
|
||||
pdf_batch_next: '${1} halaman seterusnya',
|
||||
more: 'Lagi',
|
||||
link_tooltip_external_link: 'Pautan luaran ke',
|
||||
link_tooltip_email: 'Hantar e-mel kepada',
|
||||
|
||||
@@ -100,8 +100,9 @@ export const nl: TranslationLanguage = {
|
||||
pdf_page_of: '${1} van ${2}',
|
||||
pdf_mode_only_page: 'Alleen deze pagina',
|
||||
pdf_mode_all: "Alle 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.",
|
||||
pdf_batch_range: "Pagina's ${1}–${2} van ${3}.",
|
||||
pdf_batch_previous: "Vorige ${1} pagina's",
|
||||
pdf_batch_next: "Volgende ${1} pagina's",
|
||||
more: 'Meer',
|
||||
link_tooltip_external_link: 'Externe link naar',
|
||||
link_tooltip_email: 'E-mail versturen naar',
|
||||
|
||||
@@ -99,8 +99,9 @@ export const no: TranslationLanguage = {
|
||||
pdf_page_of: '${1} av ${2}',
|
||||
pdf_mode_only_page: 'Kun denne siden',
|
||||
pdf_mode_all: 'Alle sider',
|
||||
pdf_limit_reached: 'Kunne ikke generere PDF for ${1} sider, generering stoppet ved ${2}.',
|
||||
pdf_limit_reached_continue: 'Utvid med ${1} flere sider.',
|
||||
pdf_batch_range: 'Viser sider ${1}–${2} av ${3}.',
|
||||
pdf_batch_previous: 'Forrige ${1} sider',
|
||||
pdf_batch_next: 'Neste ${1} sider',
|
||||
more: 'Mer',
|
||||
link_tooltip_external_link: 'Ekstern lenke til',
|
||||
link_tooltip_email: 'Send e-post til',
|
||||
|
||||
@@ -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_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.',
|
||||
pdf_batch_range: 'Strony ${1}–${2} z ${3}.',
|
||||
pdf_batch_previous: 'Poprzednie ${1} stron',
|
||||
pdf_batch_next: 'Następne ${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_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.',
|
||||
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',
|
||||
more: 'Mais',
|
||||
link_tooltip_external_link: 'Link externo para',
|
||||
link_tooltip_email: 'Enviar e-mail para',
|
||||
|
||||
@@ -99,8 +99,9 @@ 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_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.',
|
||||
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',
|
||||
more: 'Mais',
|
||||
link_tooltip_external_link: 'Ligação externa para',
|
||||
link_tooltip_email: 'Enviar e-mail para',
|
||||
|
||||
@@ -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_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.',
|
||||
pdf_batch_range: 'Se afișează paginile ${1}–${2} din ${3}.',
|
||||
pdf_batch_previous: '${1} pagini anterioare',
|
||||
pdf_batch_next: 'Următoarele ${1} pagini',
|
||||
more: 'Mai multe',
|
||||
link_tooltip_external_link: 'Link extern către',
|
||||
link_tooltip_email: 'Trimite e-mail către',
|
||||
|
||||
@@ -100,9 +100,9 @@ export const ru: TranslationLanguage = {
|
||||
pdf_page_of: '${1} из ${2}',
|
||||
pdf_mode_only_page: 'Только эта страница',
|
||||
pdf_mode_all: 'Все страницы',
|
||||
pdf_limit_reached:
|
||||
'Не удалось создать PDF-файл для ${1} страниц, генерация остановлена на ${2}.',
|
||||
pdf_limit_reached_continue: 'Расширьте ещё на ${1} страниц.',
|
||||
pdf_batch_range: 'Страницы ${1}–${2} из ${3}.',
|
||||
pdf_batch_previous: 'Предыдущие ${1} страниц',
|
||||
pdf_batch_next: 'Следующие ${1} страниц',
|
||||
more: 'Ещё',
|
||||
link_tooltip_external_link: 'Внешняя ссылка на',
|
||||
link_tooltip_email: 'Отправить письмо на',
|
||||
|
||||
@@ -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_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.',
|
||||
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',
|
||||
more: 'Viac',
|
||||
link_tooltip_external_link: 'Externý odkaz na',
|
||||
link_tooltip_email: 'Odoslať e-mail na',
|
||||
|
||||
@@ -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_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.',
|
||||
pdf_batch_range: 'Prikazane strani ${1}–${2} od ${3}.',
|
||||
pdf_batch_previous: 'Prejšnjih ${1} strani',
|
||||
pdf_batch_next: 'Naslednjih ${1} strani',
|
||||
more: 'Več',
|
||||
link_tooltip_external_link: 'Zunanja povezava do',
|
||||
link_tooltip_email: 'Pošlji e-pošto na',
|
||||
|
||||
@@ -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_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.',
|
||||
pdf_batch_range: 'Visar sidorna ${1}–${2} av ${3}.',
|
||||
pdf_batch_previous: 'Föregående ${1} sidor',
|
||||
pdf_batch_next: 'Nästa ${1} sidor',
|
||||
more: 'Mer',
|
||||
link_tooltip_external_link: 'Extern länk till',
|
||||
link_tooltip_email: 'Skicka e-post till',
|
||||
|
||||
@@ -95,8 +95,9 @@ export const th: TranslationLanguage = {
|
||||
pdf_page_of: '${1} จาก ${2}',
|
||||
pdf_mode_only_page: 'เฉพาะหน้านี้',
|
||||
pdf_mode_all: 'ทุกหน้า',
|
||||
pdf_limit_reached: 'ไม่สามารถสร้าง PDF สำหรับ ${1} หน้าได้ การสร้างหยุดที่ ${2}',
|
||||
pdf_limit_reached_continue: 'ขยายอีก ${1} หน้า',
|
||||
pdf_batch_range: 'แสดงหน้า ${1}–${2} จาก ${3}',
|
||||
pdf_batch_previous: '${1} หน้าก่อนหน้า',
|
||||
pdf_batch_next: '${1} หน้าถัดไป',
|
||||
more: 'เพิ่มเติม',
|
||||
link_tooltip_external_link: 'ลิงก์ภายนอกไปยัง',
|
||||
link_tooltip_email: 'ส่งอีเมลไปยัง',
|
||||
|
||||
@@ -96,8 +96,9 @@ export const tr: TranslationLanguage = {
|
||||
pdf_page_of: '${1} / ${2}',
|
||||
pdf_mode_only_page: 'Yalnızca bu sayfa',
|
||||
pdf_mode_all: 'Tüm sayfalar',
|
||||
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.',
|
||||
pdf_batch_range: '${3} sayfadan ${1}–${2} arası gösteriliyor.',
|
||||
pdf_batch_previous: 'Önceki ${1} sayfa',
|
||||
pdf_batch_next: 'Sonraki ${1} sayfa',
|
||||
more: 'Daha fazla',
|
||||
link_tooltip_external_link: 'Harici bağlantı',
|
||||
link_tooltip_email: 'E-posta gönder',
|
||||
|
||||
@@ -96,8 +96,9 @@ export const uk: TranslationLanguage = {
|
||||
pdf_page_of: '${1} з ${2}',
|
||||
pdf_mode_only_page: 'Лише ця сторінка',
|
||||
pdf_mode_all: 'Усі сторінки',
|
||||
pdf_limit_reached: 'Не вдалося створити PDF для ${1} сторінок, створення зупинилося на ${2}.',
|
||||
pdf_limit_reached_continue: 'Розширити ще на ${1} сторінок.',
|
||||
pdf_batch_range: 'Сторінки ${1}–${2} з ${3}.',
|
||||
pdf_batch_previous: 'Попередні ${1} сторінок',
|
||||
pdf_batch_next: 'Наступні ${1} сторінок',
|
||||
more: 'Більше',
|
||||
link_tooltip_external_link: 'Зовнішнє посилання на',
|
||||
link_tooltip_email: 'Надіслати електронний лист на',
|
||||
|
||||
@@ -96,8 +96,9 @@ 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_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.',
|
||||
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',
|
||||
more: 'Thêm',
|
||||
link_tooltip_external_link: 'Liên kết ngoài tới',
|
||||
link_tooltip_email: 'Gửi email tới',
|
||||
|
||||
@@ -94,8 +94,9 @@ export const yue: TranslationLanguage = {
|
||||
pdf_page_of: '${1} / ${2}',
|
||||
pdf_mode_only_page: '只限呢頁',
|
||||
pdf_mode_all: '所有頁面',
|
||||
pdf_limit_reached: '無法為 ${1} 頁產生 PDF,已喺 ${2} 停止。',
|
||||
pdf_limit_reached_continue: '再加 ${1} 頁。',
|
||||
pdf_batch_range: '而家顯示緊第 ${1}–${2} 頁,總共 ${3} 頁。',
|
||||
pdf_batch_previous: '上 ${1} 頁',
|
||||
pdf_batch_next: '下 ${1} 頁',
|
||||
more: '更多',
|
||||
link_tooltip_external_link: '外部連結到',
|
||||
link_tooltip_email: '傳送電郵到',
|
||||
|
||||
@@ -94,8 +94,9 @@ export const zh_tw: TranslationLanguage = {
|
||||
pdf_page_of: '${1} / ${2}',
|
||||
pdf_mode_only_page: '僅此頁面',
|
||||
pdf_mode_all: '所有頁面',
|
||||
pdf_limit_reached: '無法為 ${1} 頁產生 PDF,已在 ${2} 停止產生。',
|
||||
pdf_limit_reached_continue: '再擴展 ${1} 頁。',
|
||||
pdf_batch_range: '正在顯示第 ${1}–${2} 頁,共 ${3} 頁。',
|
||||
pdf_batch_previous: '上 ${1} 頁',
|
||||
pdf_batch_next: '下 ${1} 頁',
|
||||
more: '更多',
|
||||
link_tooltip_external_link: '外部連結至',
|
||||
link_tooltip_email: '傳送電子郵件至',
|
||||
|
||||
@@ -95,8 +95,9 @@ export const zh: TranslationLanguage = {
|
||||
pdf_page_of: '${1} / ${2}',
|
||||
pdf_mode_only_page: '仅本页',
|
||||
pdf_mode_all: '所有页面',
|
||||
pdf_limit_reached: '无法为 ${1} 页生成 PDF,生成在 ${2} 页时停止。',
|
||||
pdf_limit_reached_continue: '再扩展 ${1} 页。',
|
||||
pdf_batch_range: '正在显示第 ${1}–${2} 页,共 ${3} 页。',
|
||||
pdf_batch_previous: '上 ${1} 页',
|
||||
pdf_batch_next: '下 ${1} 页',
|
||||
more: '更多',
|
||||
link_tooltip_external_link: '外部链接到',
|
||||
link_tooltip_email: '发送邮件到',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 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.`;
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { DocumentMarkColor } from '@gitbook/api';
|
||||
import type { DocumentTextColor } 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]'],
|
||||
|
||||
@@ -519,6 +519,7 @@ export async function fetchSpaceContextByIds(
|
||||
shareKey: string | undefined;
|
||||
changeRequest: string | undefined;
|
||||
revision: string | undefined;
|
||||
revisionMetadata?: boolean;
|
||||
}
|
||||
): Promise<GitBookSpaceContext> {
|
||||
const { dataFetcher } = baseContext;
|
||||
@@ -552,6 +553,7 @@ export async function fetchSpaceContextByIds(
|
||||
dataFetcher.getRevision({
|
||||
spaceId: ids.space,
|
||||
revisionId,
|
||||
...(ids.revisionMetadata ? { metadata: true } : {}),
|
||||
}),
|
||||
|
||||
// When trying to render a revision with an invalid / non-existing ID,
|
||||
|
||||
@@ -80,6 +80,7 @@ export function createDataFetcher(
|
||||
return getRevision(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
metadata: params.metadata ?? false,
|
||||
});
|
||||
},
|
||||
getRevisionPageByPath(params) {
|
||||
@@ -319,8 +320,15 @@ const getChangeRequest = cache(
|
||||
|
||||
// We don't use remote cache on vercel because of the 2Mb limit on cache size that makes some route crash
|
||||
const getRevision = cache(
|
||||
async (input: DataFetcherInput, params: { spaceId: string; revisionId: string }) => {
|
||||
async (
|
||||
input: DataFetcherInput,
|
||||
params: { spaceId: string; revisionId: string; metadata: boolean }
|
||||
) => {
|
||||
'use cache';
|
||||
if (params.metadata) {
|
||||
// Git paths can change without changing the content revision.
|
||||
cacheTag(getCacheTag({ tag: 'space', space: params.spaceId }));
|
||||
}
|
||||
return wrapDataFetcherError(async () => {
|
||||
return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => {
|
||||
const api = apiClient(input);
|
||||
@@ -328,7 +336,7 @@ const getRevision = cache(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
{
|
||||
metadata: false,
|
||||
metadata: params.metadata,
|
||||
},
|
||||
{
|
||||
...noCacheFetchOptions,
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface GitBookDataFetcher {
|
||||
getRevision(params: {
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
metadata?: boolean;
|
||||
}): Promise<DataFetcherResponse<api.Revision>>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
import type { Revision, RevisionPageDocument, SiteSpace, Space } from '@gitbook/api';
|
||||
|
||||
@@ -738,3 +738,221 @@ 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',
|
||||
type: 'document',
|
||||
title: 'Authentication',
|
||||
path: 'authentication',
|
||||
slug: 'authentication',
|
||||
pages: [],
|
||||
git: { path: options.pageGitPath ?? 'api/auth.md', oid: 'blob' },
|
||||
} 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',
|
||||
};
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('preserves asset URLs that do not match a page', async () => {
|
||||
const { context } = fixture();
|
||||
const assetRef = {
|
||||
kind: 'url' as const,
|
||||
url: ref.url.replace('auth.md#tokens', 'diagram.png'),
|
||||
};
|
||||
expect((await resolveContentRef(assetRef, context))?.href).toBe(assetRef.url);
|
||||
});
|
||||
|
||||
it('resolves a link to a folder with a dot in its name to its README page', async () => {
|
||||
const { context } = fixture({ pageGitPath: 'api/11.8/README.md' });
|
||||
expect(
|
||||
(
|
||||
await resolveContentRef(
|
||||
{ kind: 'url', url: ref.url.replace('auth.md', '11.8') },
|
||||
context
|
||||
)
|
||||
)?.href
|
||||
).toBe('/api/authentication#tokens');
|
||||
});
|
||||
|
||||
it('resolves pages stored with another Markdown extension', async () => {
|
||||
const { context } = fixture({ pageGitPath: 'api/Auth.MARKDOWN' });
|
||||
expect(
|
||||
(
|
||||
await resolveContentRef(
|
||||
{ kind: 'url', url: ref.url.replace('auth.md', 'Auth.MARKDOWN') },
|
||||
context
|
||||
)
|
||||
)?.href
|
||||
).toBe('/api/authentication#tokens');
|
||||
});
|
||||
|
||||
it('resolves a space syncing from the repository root', async () => {
|
||||
const { context } = fixture({
|
||||
gitSync: { url: 'https://github.com/acme/docs/tree/main' },
|
||||
});
|
||||
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
|
||||
});
|
||||
|
||||
it('resolves a space whose Git Sync was removed from its remembered project URL', async () => {
|
||||
const { context } = fixture({
|
||||
gitSync: null,
|
||||
previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' },
|
||||
});
|
||||
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
|
||||
});
|
||||
|
||||
it('decodes the anchor of the repository URL', async () => {
|
||||
const { context } = fixture();
|
||||
const result = await resolveContentRef(
|
||||
{ kind: 'url', url: ref.url.replace('#tokens', '#access%20tokens') },
|
||||
context
|
||||
);
|
||||
expect(result?.resolvedRef).toEqual({
|
||||
kind: 'anchor',
|
||||
space: 'target',
|
||||
page: 'target-page',
|
||||
anchor: 'access tokens',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads only the matching space in a 500-space site', async () => {
|
||||
const { context, getSpace, getRevision } = fixture();
|
||||
if (!('site' in context) || context.structure.type !== 'siteSpaces') {
|
||||
throw new Error('Expected a site fixture');
|
||||
}
|
||||
const target = context.structure.structure[0]!;
|
||||
context.structure.structure.push(
|
||||
...Array.from({ length: 499 }, (_, index) => ({
|
||||
...target,
|
||||
id: `site-${index}`,
|
||||
space: {
|
||||
...target.space,
|
||||
id: `space-${index}`,
|
||||
gitSync: {
|
||||
...target.space.gitSync!,
|
||||
installationProjectDirectory: `other-${index}`,
|
||||
},
|
||||
},
|
||||
}))
|
||||
);
|
||||
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
|
||||
expect(getSpace).toHaveBeenCalledTimes(1);
|
||||
expect(getRevision).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([{ denied: true }, { missing: true }, { draft: true }])(
|
||||
'preserves the fallback for unavailable content: %j',
|
||||
async (state) => {
|
||||
const { context } = fixture(state);
|
||||
const result = await resolveContentRef(ref, context);
|
||||
expect(result).toEqual({ href: ref.url, text: ref.url, active: false });
|
||||
}
|
||||
);
|
||||
|
||||
it('does not fetch revisions for a different repository or branch', async () => {
|
||||
const { context, getRevision } = fixture();
|
||||
for (const url of [
|
||||
ref.url.replace('/main/', '/preview/'),
|
||||
ref.url.replace('/acme/', '/other/'),
|
||||
]) {
|
||||
expect((await resolveContentRef({ kind: 'url', url }, context))?.href).toBe(url);
|
||||
}
|
||||
expect(getRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves the unchanged stored URL when the target becomes available', async () => {
|
||||
const state = { missing: true };
|
||||
const { context } = fixture(state);
|
||||
expect((await resolveContentRef(ref, context))?.href).toBe(ref.url);
|
||||
state.missing = false;
|
||||
expect((await resolveContentRef(ref, context))?.href).toBe('/api/authentication#tokens');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
RevisionReusableContent,
|
||||
SiteSection,
|
||||
SiteSpace,
|
||||
SiteStructure,
|
||||
Space,
|
||||
TranslationLanguage,
|
||||
} from '@gitbook/api';
|
||||
@@ -16,12 +17,19 @@ 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 {
|
||||
findSiteSpaceBy,
|
||||
getFallbackSiteSpacePath,
|
||||
getLinkerForSiteSpace,
|
||||
getLocalizedTitle,
|
||||
listAllSiteSpaces,
|
||||
} from './sites';
|
||||
import { getRevisionTags, resolveTag } from './tags';
|
||||
import type { ClassValue } from './tailwind';
|
||||
@@ -42,7 +50,15 @@ 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 */
|
||||
@@ -143,6 +159,61 @@ 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,
|
||||
@@ -619,6 +690,44 @@ 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.
|
||||
*
|
||||
@@ -627,7 +736,8 @@ async function resolveContentRefInSpace(
|
||||
*/
|
||||
async function createContextForSpace(
|
||||
spaceId: string,
|
||||
context: GitBookAnyContext
|
||||
context: GitBookAnyContext,
|
||||
revisionMetadata = false
|
||||
): Promise<{
|
||||
spaceContext: GitBookSpaceContext;
|
||||
baseURL: URL;
|
||||
@@ -639,6 +749,7 @@ async function createContextForSpace(
|
||||
shareKey: context?.shareKey,
|
||||
changeRequest: undefined,
|
||||
revision: undefined,
|
||||
revisionMetadata,
|
||||
})
|
||||
),
|
||||
getBestTargetSpace(context, spaceId),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
import type { RevisionPageDocument } from '@gitbook/api';
|
||||
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { createLinker } from '@/lib/links';
|
||||
|
||||
mock.module('server-only', () => ({}));
|
||||
|
||||
const { servePageMarkdown, toMarkdownDestination } = await import('./markdownPage');
|
||||
|
||||
const page = {
|
||||
id: 'page-1',
|
||||
title: 'New page',
|
||||
kind: 'sheet',
|
||||
type: 'document',
|
||||
path: 'new-page',
|
||||
slug: 'new-page',
|
||||
pages: [],
|
||||
} as unknown as RevisionPageDocument;
|
||||
|
||||
function createContext(options: {
|
||||
siteRedirect?: { target: string; permanent?: boolean };
|
||||
spaceRedirectPageId?: string;
|
||||
}) {
|
||||
return {
|
||||
organizationId: 'org-1',
|
||||
site: { id: 'site-1' },
|
||||
siteSpace: { id: 'site-space-1' },
|
||||
space: { id: 'space-1', revision: 'revision-1' },
|
||||
revisionId: 'revision-1',
|
||||
revision: { pages: [page] },
|
||||
linker: createLinker({
|
||||
host: 'docs.example.com',
|
||||
siteBasePath: '/docs/',
|
||||
spaceBasePath: '/docs/',
|
||||
}),
|
||||
dataFetcher: {
|
||||
getSiteRedirectBySource: async ({ source }: { source: string }) =>
|
||||
options.siteRedirect && source === '/old-page'
|
||||
? {
|
||||
data: {
|
||||
target: options.siteRedirect.target,
|
||||
redirect: { permanent: options.siteRedirect.permanent ?? false },
|
||||
},
|
||||
}
|
||||
: { error: { code: 404, message: 'Not found' } },
|
||||
getRevisionPageByPath: async () =>
|
||||
options.spaceRedirectPageId
|
||||
? { data: { id: options.spaceRedirectPageId } }
|
||||
: { error: { code: 404, message: 'Not found' } },
|
||||
},
|
||||
} as unknown as GitBookSiteContext;
|
||||
}
|
||||
|
||||
describe('servePageMarkdown', () => {
|
||||
it('redirects to the markdown version of a site redirect target', async () => {
|
||||
const context = createContext({
|
||||
siteRedirect: { target: 'https://docs.example.com/docs/new-page', permanent: true },
|
||||
});
|
||||
|
||||
const response = await servePageMarkdown(context, 'old-page');
|
||||
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get('Location')).toBe('/docs/new-page.md');
|
||||
});
|
||||
|
||||
it('redirects to the markdown version of a space redirect target', async () => {
|
||||
const context = createContext({ spaceRedirectPageId: page.id });
|
||||
|
||||
const response = await servePageMarkdown(context, 'old-page');
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get('Location')).toBe('/docs/new-page.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMarkdownDestination', () => {
|
||||
it('appends .md to same-site paths', () => {
|
||||
expect(toMarkdownDestination('/docs/new-page')).toBe('/docs/new-page.md');
|
||||
expect(toMarkdownDestination('/docs/new-page/?a=1#b')).toBe('/docs/new-page.md?a=1#b');
|
||||
});
|
||||
|
||||
it('points the site root to its markdown route', () => {
|
||||
expect(toMarkdownDestination('/')).toBe('/.md');
|
||||
expect(toMarkdownDestination('/?a=1#b')).toBe('/.md?a=1#b');
|
||||
});
|
||||
|
||||
it('leaves markdown paths and external URLs untouched', () => {
|
||||
expect(toMarkdownDestination('/docs/new-page.md')).toBe('/docs/new-page.md');
|
||||
expect(toMarkdownDestination('https://example.com/page')).toBe('https://example.com/page');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
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';
|
||||
@@ -22,12 +24,36 @@ export async function servePageMarkdown(baseContext: GitBookSiteContext, pagePat
|
||||
linker: linkerWithMarkdownPages(baseContext.linker),
|
||||
};
|
||||
|
||||
const pageLookup = resolveSiteSpacePagePathDocumentOrGroup(
|
||||
context.siteSpace,
|
||||
context.revision.pages,
|
||||
pagePath
|
||||
);
|
||||
const pageLookup =
|
||||
resolveSiteSpacePagePathDocumentOrGroup(
|
||||
context.siteSpace,
|
||||
context.revision.pages,
|
||||
pagePath
|
||||
) ??
|
||||
// Page paths are lowercase, match the case-insensitive lookup of HTML pages.
|
||||
resolveSiteSpacePagePathDocumentOrGroup(
|
||||
context.siteSpace,
|
||||
context.revision.pages,
|
||||
pagePath.toLowerCase()
|
||||
);
|
||||
if (!pageLookup) {
|
||||
const fallback = await resolveMissingPagePath(baseContext, pagePath);
|
||||
if (fallback?.type === 'redirect') {
|
||||
return markdownRedirect(
|
||||
toMarkdownDestination(fallback.destination),
|
||||
fallback.permanent
|
||||
);
|
||||
}
|
||||
if (fallback?.type === 'page') {
|
||||
return markdownRedirect(
|
||||
context.linker.toPathForPage({
|
||||
pages: context.revision.pages,
|
||||
page: fallback.page.page,
|
||||
}),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// Generates a markdown body for missing pages. Return this with a 200 status (not 404) because agents discard 404 response bodies.=
|
||||
return {
|
||||
markdown: renderNotFoundMarkdown(context, pagePath),
|
||||
@@ -63,12 +89,44 @@ 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
|
||||
|
||||
@@ -85,20 +143,7 @@ If the exact page cannot be found, you can still retrieve the information using
|
||||
|
||||
### Option 1 — Ask a question (recommended)
|
||||
|
||||
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.
|
||||
${renderQueryingDocumentation({ pageUrl: askPageUrl })}
|
||||
|
||||
### Option 2 — Browse the documentation index
|
||||
|
||||
@@ -145,16 +190,7 @@ 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.
|
||||
|
||||
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.
|
||||
${renderQueryingDocumentation({ pageUrl })}
|
||||
|
||||
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.
|
||||
`;
|
||||
@@ -164,11 +200,14 @@ Use this mechanism when the answer is not explicitly present in the current page
|
||||
* Return a markdown content.
|
||||
*/
|
||||
export async function serveMarkdown(
|
||||
fn: () => Promise<string | { markdown: string; robots: string }>,
|
||||
fn: () => Promise<string | { markdown: string; robots: string } | Response>,
|
||||
isChatGPT?: boolean
|
||||
) {
|
||||
try {
|
||||
const result = await fn();
|
||||
if (result instanceof Response) {
|
||||
return result;
|
||||
}
|
||||
const { markdown, robots } =
|
||||
typeof result === 'string' ? { markdown: result, robots: 'noindex' } : result;
|
||||
return new Response(markdown, {
|
||||
|
||||
Reference in New Issue
Block a user