Compare commits

..

1 Commits

Author SHA1 Message Date
Tomek Gargula 91a1e1672a Track ask AI view trigger separately from interaction events
Changes the analytics to distinguish between hover/selection trigger views and actual user interactions (clicks). This allows us to see how many users hover/select text with the Ask AI button visible versus how many actually engage with it.
2026-09-29 14:53:04 +02:00
49 changed files with 197 additions and 1296 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Track which Ask AI entry point opened the chat in ask view analytics.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Recolor text over a background page cover in the first frame after hydration.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Automatically resolve GitHub and GitLab page links to matching pages in the same published site, including cross-space links imported before their target page was available.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix page cover image jumping on load
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix inline Ask AI inputs and buttons doing nothing before search is opened.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Send the previous page's URL with site insights events so broken links can be traced to the page linking to them.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix Loom videos staying blank on pages with many Loom embeds by lazy-loading them.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Improve dark theme contrast for Mermaid edge labels.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Improve the prompt for agents to ask questions.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Use the page's tag title, when set, for the HTML `<title>` of published pages.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Scroll the table of contents to the active page after client-side navigation.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the first item of a sidebar page group sometimes appearing cut off after navigating.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the site auth login link sometimes redirecting back to an internal URL after login.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Point the "Powered by GitBook" trademark link to gitbook.com/powered-by.
+19 -17
View File
@@ -129,8 +129,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.7",
"@opennextjs/cloudflare": "1.20.8",
"@opennextjs/aws": "4.1.3",
"@opennextjs/cloudflare": "1.20.5",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -160,7 +160,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.3.8",
"next": "^16.3.6",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -342,9 +342,11 @@
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"next@16.3.6": "patches/next@16.3.6.patch",
},
"overrides": {
"@codemirror/state": "6.4.1",
"@opennextjs/aws": "4.1.5",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"axios": "1.8.4",
@@ -354,7 +356,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.204.0",
"@gitbook/api": "0.202.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -726,7 +728,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
"@gitbook/api": ["@gitbook/api@0.204.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-SJCe0Ipyt+V5HZX4EZpFO8AHTOWIMwj2ljQTbcqgU7rVOAyCNDzILp8mRk9stbboSi1pv09+rDnZK1ZguPvFTw=="],
"@gitbook/api": ["@gitbook/api@0.202.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-qhrjEQbNNmCljR0AgP79+BsVh9yelh9TIBg9X3TyYx5a3lJwDiT3sw1ND9DFQSxGHp8hH5w+20KJHn/IDpTbaw=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -890,23 +892,23 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
"@next/env": ["@next/env@16.3.8", "", {}, "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw=="],
"@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="],
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
@@ -942,9 +944,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.7", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.27 <16 || >=16.3.8" }, "bin": { "open-next": "dist/index.js" } }, "sha512-ELezSARrTfxp/76cz41eQjki1AV0CuBUKOznU3w/tOk7i87x867RkkYho+gDkog9gpnGzefHdJu463o9VaabNQ=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-41LNGtS5R5SCfn9zFxHx9QKJIRnmf1uZH+jonHkUMlRKER0bSmUSGjK6GJF7e50XdLXFpTHzaqgRvGmzvr9Btw=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.8", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.7", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.27 <16 || >=16.3.8", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-7rrsBqd234GBBnHITobS0gagT4/fSfHJwm+ogYTyUb7ubg3PGC06biJgL9l4DZabFeu/ucqvml80sBr5torBgg=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.3", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-Y4qCnHTYMa8waEHvf7iqDJuiresuREp0zithSZ8hT33Wn9WyvOUMBM0SWNH961Wv7ynHRdxAJ0ggmnQ3aSdzdg=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -2648,7 +2650,7 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"next": ["next@16.3.8", "", { "dependencies": { "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.8", "@next/swc-darwin-x64": "16.3.8", "@next/swc-linux-arm64-gnu": "16.3.8", "@next/swc-linux-arm64-musl": "16.3.8", "@next/swc-linux-x64-gnu": "16.3.8", "@next/swc-linux-x64-musl": "16.3.8", "@next/swc-win32-arm64-msvc": "16.3.8", "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw=="],
"next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
+3 -2
View File
@@ -19,7 +19,8 @@
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.27.3",
"axios": "1.8.4"
"axios": "1.8.4",
"@opennextjs/aws": "4.1.5"
},
"private": true,
"scripts": {
@@ -48,7 +49,7 @@
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.204.0",
"@gitbook/api": "0.202.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+3 -3
View File
@@ -20,8 +20,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.7",
"@opennextjs/cloudflare": "1.20.8",
"@opennextjs/aws": "4.1.3",
"@opennextjs/cloudflare": "1.20.5",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -51,7 +51,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.3.8",
"next": "^16.3.6",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
+8 -11
View File
@@ -7,7 +7,7 @@ import type { CustomizationAIMode } from '@gitbook/api';
import type { GitBookAssistant } from '@gitbook/browser-types';
import { Icon, type IconName } from '@gitbook/icons';
import { useAIChatController, useAIChatState } from '.';
import { type AskAITrigger, useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations';
import { useSetSearchState } from '../Search/useSearch';
@@ -26,7 +26,8 @@ export type AIConfig = {
};
};
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
export type Assistant = Omit<GitBookAssistant, 'icon' | 'open'> & {
open: (query?: string, trigger?: AskAITrigger) => void;
/**
* Unique identifier for the assistant. Generated automatically using Crypto.randomUUID().
* @example '123e4567-e89b-12d3-a456-426614174000'
@@ -102,8 +103,8 @@ export function useAI(): AIContext {
className="size-text-lg"
/>
),
open: (query?: string) => {
chatController.open();
open: (query?: string, trigger?: AskAITrigger) => {
chatController.open(trigger);
if (query) {
chatController.postMessage({ message: query });
}
@@ -119,13 +120,9 @@ export function useAI(): AIContext {
icon: <AISearchIcon />,
open: (query?: string) => {
if (query) {
setSearchState((prev) => ({
...prev,
query: null,
ask: query,
scope: prev?.scope ?? 'default',
open: true,
}));
setSearchState((prev) =>
prev ? { ...prev, query: null, ask: query, open: true } : null
);
}
},
pageAction: false,
@@ -13,7 +13,7 @@ import {
SiteInsightsDisplayContext,
} from '@gitbook/api';
import { getInsightsSession, useTrackEvent } from '../Insights';
import { getInsightsSession, trackAskView, useTrackEvent } from '../Insights';
import { type UpdateSearchState, useSetSearchState } from '../Search';
import { addRecentSearchQuery } from '../Search/recent-queries';
import type { AnyAIControl } from './controls';
@@ -60,6 +60,8 @@ export type AIChatStatus =
| 'error'
| 'confirm';
export type AskAITrigger = 'hover' | 'selection' | 'site' | 'page' | 'embed';
export type AIChatState = {
/**
* If true, the chat is open.
@@ -160,7 +162,7 @@ type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
export type AIChatController = {
/** Open the dialog */
open: () => void;
open: (trigger?: AskAITrigger) => void;
/** Close the dialog */
close: () => void;
/** Post a message to the session */
@@ -302,22 +304,28 @@ export function AIChatProvider(props: {
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
// Open AI chat and sync with search state
const onOpen = React.useCallback(() => {
setIntercomLauncherHidden(true);
const onOpen = React.useCallback(
(trigger?: AskAITrigger) => {
setIntercomLauncherHidden(true);
if (!renderMessageOptions?.asEmbeddable) {
trackAskView(trackEvent, trigger);
}
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
notify(eventsRef.current.get('open'), {});
}, [setSearchState]);
notify(eventsRef.current.get('open'), {});
},
[setSearchState, trackEvent, renderMessageOptions?.asEmbeddable]
);
// Close AI chat and clear ask parameter
const onClose = React.useCallback(() => {
@@ -24,7 +24,6 @@ import {
EmbeddableFrameTitle,
} from '../Embeddable/EmbeddableFrame';
import { useIsMounted, useNow } from '../hooks';
import { useTrackEvent } from '../Insights';
import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
@@ -68,16 +67,6 @@ export function AIChat() {
}
);
// Track the view of the AI chat
const trackEvent = useTrackEvent();
React.useEffect(() => {
if (chat.opened) {
trackEvent({
type: 'ask_view',
});
}
}, [chat.opened, trackEvent]);
return (
<SideSheet
side="right"
@@ -39,7 +39,7 @@ export function AIChatButton(props: {
</div>
}
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
onClick={() => assistant.open()}
onClick={() => assistant.open(undefined, 'site')}
>
{showLabel ? t(language, 'ask') : null}
</Button>
@@ -33,7 +33,7 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
id: `text-${fnv1a(text, { size: 32 })}`,
content: text,
});
chatController.open();
chatController.open('hover');
chatController.setDraft(tString(language, 'ai_chat_paragraph_draft'));
chatController.focus();
};
@@ -84,7 +84,7 @@ export function AskAITextSelection() {
id: `text-${fnv1a(content, { size: 32 })}`,
content,
});
chatController.open();
chatController.open('selection');
chatController.focus();
clear();
};
@@ -247,15 +247,11 @@ async function renderMermaidDiagram(args: {
const { source, id, darkMode, mermaidRuntimeURL } = args;
const { mermaid } = await loadMermaid(mermaidRuntimeURL);
// Mermaid's default dark edge label pill only reaches 4.43:1 contrast, below WCAG AA.
const themeVariables = darkMode ? { edgeLabelBackground: '#3a3a3a' } : undefined;
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
darkMode,
theme: darkMode ? 'dark' : undefined,
themeVariables,
});
const renderContainer = createMermaidRenderContainer();
@@ -37,10 +37,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
<>
<div
dangerouslySetInnerHTML={{
__html:
context.mode !== 'print' && shouldLazyLoad(block.data.url)
? lazyLoadIframes(embed.html)
: embed.html,
__html: embed.html,
}}
data-visual-test="blackout"
/>
@@ -76,23 +73,6 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
);
}
/**
* Pages with many Loom embeds exhaust the browser's request budget
* (ERR_INSUFFICIENT_RESOURCES) when every player loads at once, leaving some blank.
*/
function lazyLoadIframes(html: string): string {
return html.replace(/<iframe\b(?![^>]*\bloading=)/gi, '<iframe loading="lazy"');
}
function shouldLazyLoad(url: string): boolean {
try {
const { hostname } = new URL(url);
return hostname === 'loom.com' || hostname.endsWith('.loom.com');
} catch {
return false;
}
}
/**
* Create an integration block with an unfurl action from the GitBook Embed response.
*/
@@ -52,8 +52,8 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const anchorElement = (
<InlineLinkAnchor
href={resolved.href}
contentRef={resolved.resolvedRef ?? inline.data.ref}
isExternal={(resolved.resolvedRef ?? inline.data.ref).kind === 'url'}
contentRef={inline.data.ref}
isExternal={inline.data.ref.kind === 'url'}
>
{inlinesElement}
</InlineLinkAnchor>
@@ -121,7 +121,7 @@ function InlineLinkTooltipWrapper(props: {
let breadcrumbs = resolved.ancestors ?? [];
const isMailto = resolved.href.startsWith('mailto:');
const isExternal = (resolved.resolvedRef ?? inline.data.ref).kind === 'url';
const isExternal = inline.data.ref.kind === 'url';
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
if (isMailto) {
@@ -4,7 +4,7 @@ import React from 'react';
import * as api from '@gitbook/api';
import { useTrackEvent } from '../Insights';
import { trackAskView, useTrackEvent } from '../Insights';
import { LinkContext } from '../primitives';
import { useIsVisible } from '../VisibilityContext';
import {
@@ -60,15 +60,10 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
return;
}
trackEvent(
{
type: 'ask_view',
},
{
pageId: null,
displayContext: api.SiteInsightsDisplayContext.Embed,
}
);
trackAskView(trackEvent, 'embed', {
pageId: null,
displayContext: api.SiteInsightsDisplayContext.Embed,
});
}, [trackEvent, isVisible]);
const tabsRef = React.useRef<HTMLDivElement>(null);
@@ -4,9 +4,9 @@ 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 { AskAITrigger } from '../AI';
import { type CurrentContentContext, useCurrentContent } from '../hooks';
import { getSession } from './sessions';
import { type VisitorResponse, useVisitor } from './visitorId';
@@ -43,7 +43,7 @@ export type TrackEventInput<EventName extends InsightsEventName = InsightsEventN
/**
* Callback to track an event.
*/
type TrackEventCallback = <EventName extends InsightsEventName>(
export type TrackEventCallback = <EventName extends InsightsEventName>(
event: TrackEventInput<EventName>,
ctx?: InsightsEventPageContext,
options?: InsightsEventOptions
@@ -76,15 +76,12 @@ export function InsightsProvider(props: InsightsProviderProps) {
[pathname: string]:
| {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext?: InsightsEventPageContext;
}
| undefined;
}>({});
// Href of the last page an event was tracked on; `undefined` until the first event.
const lastUrlRef = React.useRef<string | null | undefined>(undefined);
/**
* Synchronously flush all the pending events.
@@ -109,7 +106,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
allEvents.push(
...transformEvents({
url: eventsForPathname.url,
previousUrl: eventsForPathname.previousUrl,
events: eventsForPathname.events,
context: currentContent,
pageContext: eventsForPathname.pageContext,
@@ -159,9 +155,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
) => {
const pathname = window.location.pathname;
const previous = eventsRef.current[pathname];
const lastUrl =
lastUrlRef.current === undefined ? getSameOriginReferrer() : lastUrlRef.current;
lastUrlRef.current = window.location.href;
eventsRef.current[pathname] = {
// An explicitly-provided context wins so page-scoped events (e.g. feedback) can
// attribute to their page even when the pathname's ambient context has none — such
@@ -169,7 +162,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
// context keep the stored one.
pageContext: ctx ?? previous?.pageContext,
url: previous?.url ?? window.location.href,
previousUrl: previous ? previous.previousUrl : lastUrl,
events: [
...(previous?.events ?? []),
{
@@ -223,16 +215,14 @@ 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;
/** Track an ask view while the frontend uses an API type that predates its trigger field. */
export function trackAskView(
trackEvent: TrackEventCallback,
trigger: AskAITrigger | undefined,
ctx?: InsightsEventPageContext
) {
// TODO: remove this cast once @gitbook/api includes SiteInsightsEventAskView.trigger.
trackEvent({ type: 'ask_view', trigger } as unknown as TrackEventInput<'ask_view'>, ctx);
}
/**
@@ -261,7 +251,6 @@ function sendEvents(args: { eventUrl: string; events: api.SiteInsightsEvent[] })
*/
function transformEvents(input: {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext: InsightsEventPageContext;
@@ -280,11 +269,6 @@ function transformEvents(input: {
const location: api.SiteInsightsEventLocation = {
url: input.url,
// The embed's navigation is between its own tabs, not pages of the site.
previousUrl:
input.pageContext.displayContext === SiteInsightsDisplayContext.Embed
? null
: input.previousUrl,
siteSection: input.context.siteSectionId ?? null,
siteSpace: input.context.siteSpaceId ?? null,
space: input.context.spaceId,
@@ -67,7 +67,7 @@ export function ActionOpenAssistant(props: {
href: page.href,
});
}
assistant.open();
assistant.open(undefined, 'page');
}}
/>
);
@@ -29,21 +29,18 @@ interface PageCoverImageProps {
export function PageCoverImage(props: PageCoverImageProps) {
const { imgs, y, height, mask } = props;
const { objectPositionY, isLoading } = useCoverPosition(imgs, y, {
height,
aspectRatio: PAGE_COVER_SIZE,
});
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
if (isLoading) {
return (
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full overflow-hidden" ref={containerRef}>
<div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" />
</div>
);
}
return (
<div className="h-full w-full overflow-hidden @container" style={{ height }}>
<div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}>
<img
src={imgs.light.src}
srcSet={imgs.light.srcSet}
@@ -55,7 +52,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}`,
objectPosition: `50% ${objectPositionY}%`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -76,7 +73,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}`,
objectPosition: `50% ${objectPositionY}%`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -1,5 +1,6 @@
'use client';
import { useLayoutEffect, useState } from 'react';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useResizeObserver } from 'usehooks-ts';
interface ImageSize {
width: number;
@@ -21,18 +22,19 @@ interface Images {
}
/**
* Hook to compute the CSS object position Y for a cover image, from the y offset and the image
* dimensions. The container must set `container-type: inline-size` and be as wide as the image,
* since the position is computed against its width.
* Hook to calculate the object position Y percentage for a cover image
* based on the y offset, image dimensions, and container dimensions.
*/
export function useCoverPosition(
imgs: Images,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
) {
export function useCoverPosition(imgs: Images, y: number) {
const containerRef = useRef<HTMLDivElement>(null);
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(null);
const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size);
const container = useResizeObserver({
// @ts-expect-error wrong types
ref: containerRef,
});
// Load original image dimensions if not provided in `imgs`
useLayoutEffect(() => {
// Check if we have dimensions from dark (if provided) or else the default light.
@@ -66,31 +68,42 @@ export function useCoverPosition(
// Check dark first, then light, then loaded dimensions
const imageDimensions = imgs.dark?.size ?? imgs.light.size ?? loadedDimensions;
// Calculate ratio and dimensions similar to useCoverPosition hook
const ratio =
imageDimensions && container.height && container.width
? Math.max(
container.width / imageDimensions.width,
container.height / imageDimensions.height
)
: 1;
const safeRatio = ratio || 1;
const scaledHeight =
imageDimensions && container.height ? imageDimensions.height * safeRatio : null;
const maxOffset =
scaledHeight && container.height
? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio)
: 0;
// Parse the position between the allowed min/max
const objectPositionY = useMemo(() => {
if (!container.height || !imageDimensions) {
return 50;
}
const scaled = imageDimensions.height * safeRatio;
if (scaled <= container.height || maxOffset === 0) {
return 50;
}
const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y));
const relative = (maxOffset - clampedOffset) / (2 * maxOffset);
return relative * 100;
}, [container.height, imageDimensions, maxOffset, safeRatio, y]);
return {
objectPositionY: imageDimensions
? getCoverObjectPositionY(imageDimensions, y, container)
: '50%',
containerRef,
objectPositionY,
isLoading: !imageDimensions || isLoading,
};
}
/**
* Offset the image `y` natural pixels from centered, clamped so it keeps covering the container.
* Expressed in CSS against the container width (`cqw`), so it renders the same on the server as
* after hydration, without measuring the container.
*/
function getCoverObjectPositionY(
image: ImageSize,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
): string {
const containerHeight = container.height
? `${container.height}px`
: `${(100 * container.aspectRatio.height) / container.aspectRatio.width}cqw`;
// Rendered height of the image under `object-fit: cover`.
const scaledHeight = `max(${(100 * image.height) / image.width}cqw, ${containerHeight})`;
const maxOffset = `(${scaledHeight} - ${containerHeight}) / 2`;
const offset = `${scaledHeight} * ${y / image.height}`;
return `calc(50% + clamp(-1 * ${maxOffset}, ${offset}, ${maxOffset}))`;
}
@@ -4,7 +4,6 @@ import { usePathname, useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import type React from 'react';
import { useIsMounted } from '../hooks/useIsMounted';
import { Button, type ButtonProps } from '../primitives/Button';
import { DropdownMenuItem } from '../primitives/DropdownMenu';
import { Link, type LinkInsightsProps, type LinkProps } from '../primitives/Link';
@@ -17,17 +16,9 @@ function useSiteAuthLoginHrefWithLocation(href: string) {
const rawPathname = usePathname();
const searchParams = useSearchParams();
const currentSearch = searchParams?.toString();
// On the server, usePathname() returns the internal rewritten route (/sites/…, which includes
// the site API token), so the location is only added once mounted.
// https://nextjs.org/docs/app/api-reference/functions/use-pathname#avoid-hydration-mismatch-with-rewrites
const isMounted = useIsMounted();
const pathname = isMounted ? (rawPathname ?? '/') : null;
const pathname = rawPathname ?? '/';
return useMemo(() => {
if (pathname === null) {
return href;
}
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const resolved = URL.canParse(href) ? new URL(href) : new URL(href, baseURL);
const siteBasePath = removeTrailingSlash(
@@ -82,7 +82,7 @@ type CoverOverlap =
* `data-over-cover="split"` with the crossing point in `--cover-edge`.
*/
function useMarkTextOverCover() {
React.useLayoutEffect(() => {
React.useEffect(() => {
const root = document.documentElement;
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
@@ -150,7 +150,7 @@ function useMarkTextOverCover() {
});
};
update();
scheduleUpdate();
window.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate, { passive: true });
@@ -1,49 +0,0 @@
import { describe, expect, it } from 'bun:test';
import type { RevisionPageDocument } from '@gitbook/api';
import { getPageFullTitle } from './title';
import type { GitBookSiteContext } from '@/lib/context';
function makeContext(siteTitle: string, sectionTitle = ''): GitBookSiteContext {
return {
site: { title: siteTitle },
visibleSections: sectionTitle
? {
current: { title: sectionTitle, default: false },
list: [{ object: 'site-section' }, { object: 'site-section' }],
}
: undefined,
visibleSiteSpaces: [],
} as unknown as GitBookSiteContext;
}
function makePage(title: string, tagTitle?: string): RevisionPageDocument {
return { title, tagTitle } as unknown as RevisionPageDocument;
}
describe('getPageFullTitle', () => {
it('uses tagTitle as the first segment', () => {
expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title', 'SEO title'))).toBe(
'SEO title | GitBook'
);
});
it('falls back to the page title when tagTitle is absent', () => {
expect(getPageFullTitle(makeContext('GitBook'), makePage('Page title'))).toBe(
'Page title | GitBook'
);
});
it('deduplicates the section title against tagTitle', () => {
expect(
getPageFullTitle(makeContext('GitBook', 'Section'), makePage('Page title', 'Section'))
).toBe('Section | GitBook');
});
it('deduplicates the site title against tagTitle', () => {
expect(
getPageFullTitle(makeContext('SEO title'), makePage('Page title', 'SEO title'))
).toBe('SEO title');
});
});
@@ -4,6 +4,7 @@ import { notFound, redirect } from 'next/navigation';
import {
CustomizationDefaultThemeMode,
CustomizationHeaderPreset,
type RevisionPageDocument,
SiteInsightsDisplayContext,
type TranslationLanguage,
} from '@gitbook/api';
@@ -17,7 +18,6 @@ import {
getPathnameParam,
} from './fetch';
import { PageClientLayout } from './PageClientLayout';
import { getPageFullTitle } from './title';
import { UpdatesFilterProvider } from '@/components/DocumentView/UpdatesFilter';
import { UpdatesFilterScript } from '@/components/DocumentView/UpdatesFilterScript';
import { PageAside } from '@/components/PageAside';
@@ -33,7 +33,11 @@ import { getResizedImageURL } from '@/lib/images';
import { getPagePath } from '@/lib/pages';
import { resolveContentRef } from '@/lib/references';
import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
import { getSiteSpacePagePaths, resolveSiteSpaceCustomHomePage } from '@/lib/sites';
import {
getSiteSpacePagePaths,
getSiteStructureTitle,
resolveSiteSpaceCustomHomePage,
} from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import {
generateUpdatesFilterCSS,
@@ -41,8 +45,6 @@ import {
updatesFilterStyleHref,
} from '@/lib/updates';
export { getPageFullTitle } from './title';
export type SitePageProps = {
context: GitBookSiteContext;
pageParams: PagePathParams;
@@ -419,3 +421,20 @@ async function resolvePageMetaLinks(
alternates: [],
};
}
/**
* Get the <title> for a page.
*/
export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) {
const { site } = context;
const siteStructureTitle = getSiteStructureTitle(context);
return [
page.title,
// Prevent duplicate titles by comparing against the page title.
page.title !== siteStructureTitle ? siteStructureTitle : null, // The first page of a section is often the same as the section title, so we don't need to show it.
page.title !== site.title ? site.title : null, // The site title can also be the same as the site title on the site's landing page.
]
.filter(Boolean)
.join(' | ');
}
@@ -1,23 +0,0 @@
import type { RevisionPageDocument } from '@gitbook/api';
import type { GitBookSiteContext } from '@/lib/context';
import { getSiteStructureTitle } from '@/lib/sites';
/**
* Get the <title> for a page.
*/
export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) {
const { site } = context;
const siteStructureTitle = getSiteStructureTitle(context);
const tagTitle = page.tagTitle || page.title;
return [
tagTitle,
// The first page of a section is often the same as the section title, so we don't need to show it.
tagTitle !== siteStructureTitle ? siteStructureTitle : null,
// The site title can also be the same as the page title on the site's landing page.
tagTitle !== site.title ? site.title : null,
]
.filter(Boolean)
.join(' | ');
}
@@ -29,8 +29,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
<div ref={sentinelRef} className="h-0" aria-hidden="true" />
<div
className={tcls(
// No opacity transition on ::after: in Chrome it leaves stale pixels over the first child after the list scrolls.
'-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent',
'-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent after:transition-opacity',
isSticking ? '' : 'after:opacity-0',
'mt-1 pt-2.5 pb-0',
'bg-tint-base',
@@ -134,7 +134,6 @@ export async function TableOfContents(props: {
orientation="vertical"
contentClassName="flex flex-col p-2 gutter-stable"
active="[data-active=true]"
followActive
leading={{
fade: true,
button: {
@@ -19,7 +19,7 @@ export async function Trademark(
const { space } = context;
const language = await getSpaceLanguage(context);
const url = new URL('https://www.gitbook.com/powered-by');
const url = new URL('https://www.gitbook.com');
url.searchParams.set('utm_source', 'content');
url.searchParams.set('utm_medium', 'trademark');
url.searchParams.set('utm_campaign', space.id);
@@ -11,7 +11,7 @@ import { tcls } from '@/lib/tailwind';
* A container that encapsulates a scrollable area with usability features.
* - Faded edges when there is more content than the container can display.
* - Buttons to advance the scroll position.
* - Auto-scroll to the active item on mount and when it changes.
* - Auto-scroll to the active item when it's initially active.
*/
export type ScrollContainerProps = {
children: React.ReactNode;
@@ -42,12 +42,6 @@ export type ScrollContainerProps = {
/** The ID or ref of the active item to scroll to. */
active?: string | React.RefObject<HTMLElement | null>;
/**
* Only scroll to the active item when it is not fully visible, and keep following it
* when it changes later (requires `active` to be a selector).
*/
followActive?: boolean;
/** Scroll by one page of fully visible direct children instead of one viewport. */
scrollByVisibleItems?: boolean;
} & React.HTMLAttributes<HTMLDivElement>;
@@ -59,7 +53,6 @@ export function ScrollContainer(props: ScrollContainerProps) {
contentClassName,
orientation,
active,
followActive = false,
scrollByVisibleItems = false,
leading = { fade: true, button: true },
trailing = { fade: true, button: true },
@@ -87,44 +80,8 @@ export function ScrollContainer(props: ScrollContainerProps) {
if (!activeItem || !container.contains(activeItem)) {
return;
}
if (followActive && isElementFullyVisibleInContainer(activeItem, container)) {
return;
}
scrollToElementInContainer(activeItem, container);
}, [active, followActive]);
React.useEffect(() => {
const container = containerRef.current;
if (!followActive || !container || typeof active !== 'string') {
return;
}
let frame = 0;
// Active items can mount only after a collapsed group expands.
const observer = new MutationObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
for (const activeItem of container.querySelectorAll(active)) {
if (!isElementFullyVisibleInContainer(activeItem, container)) {
scrollToElementInContainer(activeItem, container, 'smooth');
return;
}
}
});
});
observer.observe(container, {
attributes: true,
attributeFilter: ['data-active'],
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
};
}, [active, followActive]);
}, [active]);
const scrollFurther = () => {
const container = containerRef.current;
@@ -378,11 +335,7 @@ function scrollByViewport(
/**
* Scroll to an element in a container.
*/
export function scrollToElementInContainer(
element: Element,
container: HTMLElement,
behavior: ScrollBehavior = 'auto'
) {
function scrollToElementInContainer(element: Element, container: HTMLElement) {
const containerRect = container.getBoundingClientRect();
const rect = element.getBoundingClientRect();
@@ -397,26 +350,8 @@ export function scrollToElementInContainer(
(rect.left - containerRect.left) -
container.clientWidth / 2 +
rect.width / 2,
behavior,
// Use 'auto' to avoid additional scroll animations when scrolling to an element
// as this may be called during layout/initialization when the page is not fully loaded.
behavior: 'auto',
});
}
function isElementFullyVisibleInContainer(element: Element, container: HTMLElement) {
if (
!element.getClientRects().length ||
container.clientHeight === 0 ||
container.clientWidth === 0
) {
return true;
}
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
return (
elementRect.top >= containerRect.top &&
elementRect.bottom <= containerRect.bottom &&
elementRect.left >= containerRect.left &&
elementRect.right <= containerRect.right
);
}
-24
View File
@@ -1,24 +0,0 @@
/**
* Describe the `ask` and `goal` query parameters of the ask endpoint, for agent-facing prompts.
*/
export function renderAskParametersDescription(): string {
return `\`ask\` is the immediate question: it should be specific, self-contained, and written in natural language.
\`goal\` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with \`ask=how do I create an API token\`, a goal like \`build a script that syncs our docs to a CMS\` lets GitBook tailor the answer to that use case.`;
}
/**
* Render the "Querying This Documentation" section of the agent instructions.
* `pageUrl` is the URL of the current page, which the `ask` and `goal` parameters are appended to.
*/
export function renderQueryingDocumentation(options: { pageUrl: string }): string {
const { pageUrl } = options;
return `Perform an HTTP GET request on the following URL with the \`ask\` and \`goal\` query parameters:
\`\`\`
GET ${pageUrl}?ask=<question>&goal=<user_goal>
\`\`\`
${renderAskParametersDescription()}
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.`;
}
-2
View File
@@ -519,7 +519,6 @@ export async function fetchSpaceContextByIds(
shareKey: string | undefined;
changeRequest: string | undefined;
revision: string | undefined;
revisionMetadata?: boolean;
}
): Promise<GitBookSpaceContext> {
const { dataFetcher } = baseContext;
@@ -553,7 +552,6 @@ export async function fetchSpaceContextByIds(
dataFetcher.getRevision({
spaceId: ids.space,
revisionId,
...(ids.revisionMetadata ? { metadata: true } : {}),
}),
// When trying to render a revision with an invalid / non-existing ID,
+2 -10
View File
@@ -80,7 +80,6 @@ export function createDataFetcher(
return getRevision(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
metadata: params.metadata ?? false,
});
},
getRevisionPageByPath(params) {
@@ -320,15 +319,8 @@ const getChangeRequest = cache(
// We don't use remote cache on vercel because of the 2Mb limit on cache size that makes some route crash
const getRevision = cache(
async (
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; metadata: boolean }
) => {
async (input: DataFetcherInput, params: { spaceId: string; revisionId: string }) => {
'use cache';
if (params.metadata) {
// Git paths can change without changing the content revision.
cacheTag(getCacheTag({ tag: 'space', space: params.spaceId }));
}
return wrapDataFetcherError(async () => {
return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => {
const api = apiClient(input);
@@ -336,7 +328,7 @@ const getRevision = cache(
params.spaceId,
params.revisionId,
{
metadata: params.metadata,
metadata: false,
},
{
...noCacheFetchOptions,
-1
View File
@@ -79,7 +79,6 @@ export interface GitBookDataFetcher {
getRevision(params: {
spaceId: string;
revisionId: string;
metadata?: boolean;
}): Promise<DataFetcherResponse<api.Revision>>;
/**
-328
View File
@@ -1,328 +0,0 @@
import { describe, expect, it } from 'bun:test';
import {
findGitPageURLTarget,
findPageByGitPath,
findPageForGitPageURLTarget,
matchesGitPageURLTargetPath,
} from './gitPageURL';
const SPACES = [
{
id: 'a',
gitSync: {
url: 'https://github.com/acme/docs/tree/main',
installationProjectDirectory: 'guides',
},
},
{
id: 'b',
gitSync: {
url: 'https://github.com/acme/docs/tree/main',
installationProjectDirectory: '/api/',
},
},
];
describe('findGitPageURLTarget', () => {
it.each(['header%201', '%E6%97%A5%E6%9C%AC', 'part%2Fone', 'percent%2520'])(
'decodes anchor %s once',
(anchor) => {
expect(
findGitPageURLTarget(
`https://github.com/acme/docs/tree/main/api/auth.md#${anchor}`,
SPACES
)?.anchor
).toBe(decodeURIComponent(anchor));
}
);
it.each(['%ZZ', '%E0%A4'])('keeps malformed anchor %s unresolved', (anchor) => {
expect(
findGitPageURLTarget(
`https://github.com/acme/docs/tree/main/api/auth.md#${anchor}`,
SPACES
)
).toBeNull();
});
it('accepts the www host alias without accepting unrelated hosts', () => {
expect(
findGitPageURLTarget('https://www.github.com/acme/docs/tree/main/api/auth.md', SPACES)
?.space
).toBe('b');
expect(
findGitPageURLTarget(
'https://www.github.com.evil.test/acme/docs/tree/main/api/auth.md',
SPACES
)
).toBeNull();
});
it('matches the repository, ref and directory and preserves anchors', () => {
expect(
findGitPageURLTarget(
'https://github.com/acme/docs/tree/main/api/auth.md#tokens',
SPACES
)
).toEqual({ space: 'b', path: 'api/auth.md', anchor: 'tokens' });
});
it.each([
'https://github.com/other/docs/tree/main/api/auth.md',
'https://github.com/acme/docs/tree/preview/api/auth.md',
'https://github.com.evil.test/acme/docs/tree/main/api/auth.md',
'https://github.com/acme/docs/tree/main/api-other/auth.md',
'https://github.com/acme/docs/tree/main/api/auth.md?raw=1',
'https://github.com/acme/docs/tree/main/api/%ZZ.md',
'https://github.com/acme/docs/tree/main/api/%2Fsecret.md',
])('does not reinterpret %s', (url) => {
expect(findGitPageURLTarget(url, SPACES)).toBeNull();
});
it('supports blob URLs and encoded file names', () => {
expect(
findGitPageURLTarget(
'https://github.com/acme/docs/blob/main/api/hello%20world.md',
SPACES
)
).toEqual({ space: 'b', path: 'api/hello world.md', anchor: undefined });
});
it('matches self-hosted GitLab with nested groups and a slash in the branch', () => {
const spaces = [
{
id: 'b',
gitSync: {
url: 'https://git.example.com/group/sub/docs/-/tree/release/v2',
installationProjectDirectory: 'api',
},
},
];
expect(
findGitPageURLTarget(
'https://git.example.com/group/sub/docs/-/blob/release/v2/api/auth.md',
spaces
)?.space
).toBe('b');
});
it('rejects ambiguous owners and ambiguous branch prefixes', () => {
expect(
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
...SPACES,
{ ...SPACES[1]!, id: 'duplicate' },
])
).toBeNull();
expect(
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
...SPACES,
{
id: 'other-ref',
gitSync: {
url: 'https://github.com/acme/docs/tree/main/api',
installationProjectDirectory: '',
},
},
])
).toBeNull();
});
it('prefers the most specific directory and deduplicates site placements', () => {
expect(
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
...SPACES,
SPACES[1]!,
{
id: 'root',
gitSync: { url: SPACES[0]!.gitSync.url, installationProjectDirectory: '' },
},
])?.space
).toBe('b');
});
it('treats an omitted live project directory as the repository root', () => {
expect(
findGitPageURLTarget('https://github.com/acme/docs/tree/main/api/auth.md', [
{ id: 'old', gitSync: { url: SPACES[0]!.gitSync.url } },
])
).toEqual({ space: 'old', path: 'api/auth.md', anchor: undefined });
});
it.each(['live', 'disconnected'])('keeps similar space directories distinct (%s)', (state) => {
const spaces = ['docs/space-a', 'docs/space-b', 'api-reference'].map((directory) => ({
id: directory,
...(state === 'live'
? {
gitSync: {
url: SPACES[0]!.gitSync.url,
installationProjectDirectory: `/${directory}`,
},
}
: { previousGitSync: { url: `${SPACES[0]!.gitSync.url}/${directory}` } }),
}));
const url = new URL(
'../docs/space-b/page.md#details',
`${SPACES[0]!.gitSync.url}/api-reference/README.md`
).href;
const target = findGitPageURLTarget(url, spaces);
expect(target?.space).toBe('docs/space-b');
expect(target?.anchor).toBe('details');
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/page.md')).toBe(true);
expect(
findGitPageURLTarget(
url,
spaces.filter((space) => space.id !== 'docs/space-b')
)
).toBeNull();
});
it.each([
[
'https://github.com/old/repo/tree/main/docs/space-b',
'https://github.com/old/repo/blob/main/docs/space-b/hello%20world.md#details',
],
[
'https://git.example.com/group/repo/-/tree/release/v2/docs/space-b',
'https://git.example.com/group/repo/-/blob/release/v2/docs/space-b/hello%20world.md#details',
],
])('matches the remembered project URL %s', (previousURL, href) => {
const target = findGitPageURLTarget(href, [
{ id: 'b', previousGitSync: { url: previousURL } },
]);
expect(target?.space).toBe('b');
expect(target?.anchor).toBe('details');
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/hello world.md')).toBe(
true
);
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-a/hello world.md')).toBe(
false
);
});
it.each([
'https://github.com/someone-else/example/blob/main/docs/space-b/page.md',
'https://github.com/old/repo/tree/other/docs/space-b/page.md',
'https://github.com.evil.test/old/repo/tree/main/docs/space-b/page.md',
'https://github.com/old/repo/tree/main/docs/space-b-other/page.md',
'https://github.com/old/repo/tree/main/other-docs/space-b/page.md',
'https://github.com/old/repo/tree/main/docs/space-b/%2Fsecret.md',
'https://github.com/old/repo/tree/main/docs/space-b/%ZZ.md',
])('does not reinterpret an unrelated or invalid URL: %s', (href) => {
expect(
findGitPageURLTarget(href, [
{
id: 'b',
previousGitSync: { url: 'https://github.com/old/repo/tree/main/docs/space-b' },
},
])
).toBeNull();
});
it('requires a valid previous URL', () => {
for (const url of [undefined, 'invalid', 'https://github.com/old/repo']) {
expect(
findGitPageURLTarget('https://github.com/old/repo/tree/main/docs/space-b/page.md', [
{ id: 'b', previousGitSync: { url } },
])
).toBeNull();
}
});
it('supports disconnected repository roots and directory README links', () => {
const target = findGitPageURLTarget(
'https://github.com/old/repo/tree/release/v2/docs/space-b/',
[{ id: 'b', previousGitSync: { url: 'https://github.com/old/repo/tree/release/v2' } }]
);
expect(target && matchesGitPageURLTargetPath(target, 'docs/space-b/README.md')).toBe(true);
});
it('rejects duplicate previous owners and ignores stale metadata on live installations', () => {
const previous = {
previousGitSync: { url: 'https://github.com/old/repo/tree/main/docs/space-b' },
};
const url = 'https://github.com/old/repo/tree/main/docs/space-b/page.md';
expect(
findGitPageURLTarget(url, [
{ id: 'b', ...previous },
{ id: 'copy', ...previous },
])
).toBeNull();
expect(findGitPageURLTarget(url, [{ ...SPACES[1]!, ...previous }])).toBeNull();
});
it('compares live and previous project URLs at the same directory boundary', () => {
const href = 'https://github.com/acme/docs/tree/main/api/auth.md';
const disconnected = {
id: 'old-api',
previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' },
};
expect(findGitPageURLTarget(href, [SPACES[1]!, disconnected])).toBeNull();
expect(
findGitPageURLTarget(href, [
disconnected,
{ id: 'root', gitSync: { url: SPACES[0]!.gitSync.url } },
])?.space
).toBe('old-api');
});
it('accepts the www alias for a remembered project URL', () => {
expect(
findGitPageURLTarget('https://www.github.com/acme/docs/blob/main/api/auth.md', [
{ id: 'b', previousGitSync: { url: 'https://github.com/acme/docs/tree/main/api' } },
])?.space
).toBe('b');
});
});
describe('findPageByGitPath', () => {
const pages = [
{ id: 'auth', git: { path: 'api/auth.md' }, pages: [] },
{ id: 'group', pages: [{ id: 'readme', git: { path: 'api/11.8/README.md' }, pages: [] }] },
];
it('finds nested pages and directory README links', () => {
expect(findPageByGitPath(pages, 'api/auth.md')?.id).toBe('auth');
expect(findPageByGitPath(pages, 'api/11.8/')?.id).toBe('readme');
expect(findPageByGitPath(pages, 'api/missing.md')).toBeNull();
});
it('does not select between duplicate paths', () => {
expect(
findPageByGitPath(
[...pages, { id: 'copy', git: { path: 'api/auth.md' }, pages: [] }],
'api/auth.md'
)
).toBeNull();
});
});
describe('findPageForGitPageURLTarget', () => {
const pages = [
{ id: 'auth', git: { path: 'api/auth.md' }, pages: [] },
{ id: 'group', pages: [{ id: 'readme', git: { path: 'api/11.8/README.md' }, pages: [] }] },
];
it('finds the page of a live target, including directory README links', () => {
expect(findPageForGitPageURLTarget(pages, { space: 'b', path: 'api/auth.md' })?.id).toBe(
'auth'
);
expect(findPageForGitPageURLTarget(pages, { space: 'b', path: 'api/11.8/' })?.id).toBe(
'readme'
);
});
it('finds the page of a remembered target whose path still includes the ref', () => {
expect(
findPageForGitPageURLTarget(pages, {
space: 'b',
path: 'release/v2/api/auth.md',
pathIncludesRef: true,
})?.id
).toBe('auth');
});
it('does not select between duplicate paths', () => {
expect(
findPageForGitPageURLTarget(
[...pages, { id: 'copy', git: { path: 'api/auth.md' }, pages: [] }],
{ space: 'b', path: 'api/auth.md' }
)
).toBeNull();
});
});
-196
View File
@@ -1,196 +0,0 @@
export interface GitPageURLSpace {
id: string;
gitSync?: {
url?: string;
installationProjectDirectory?: string;
};
previousGitSync?: { url?: string };
}
export interface GitPageURLTarget {
space: string;
path: string;
anchor?: string;
/** Previous project URLs do not distinguish a slash-containing ref from the file path. */
pathIncludesRef?: boolean;
}
/** Locate a unique owning space without fetching any revisions. */
export function findGitPageURLTarget(
href: string,
spaces: readonly GitPageURLSpace[]
): GitPageURLTarget | null {
const url = parseURL(href);
if (!url || url.search) {
return null;
}
let anchor: string | undefined;
try {
anchor = decodeURIComponent(url.hash.slice(1)) || undefined;
} catch {
return null;
}
const matches = new Map<string, GitPageURLTarget & { root: string; tree?: string }>();
for (const space of spaces) {
const directory = space.gitSync?.installationProjectDirectory ?? '';
let root = directory.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
let filePath: string | null;
let treeKey: string | undefined;
if (space.gitSync) {
const tree = space.gitSync.url ? parseURL(space.gitSync.url) : null;
if (!tree || (tree.host !== url.host && url.host !== `www.${tree.host}`)) {
continue;
}
const prefix = tree.pathname.replace(/\/$/, '');
const blobPrefix = prefix
.replace('/-/tree/', '/-/blob/')
.replace(/^(\/[^/]+\/[^/]+)\/tree\//, '$1/blob/');
const matchedPrefix = [prefix, blobPrefix].find((candidate) =>
url.pathname.startsWith(`${candidate}/`)
);
if (!matchedPrefix) {
continue;
}
filePath = decodeGitPath(url.pathname.slice(matchedPrefix.length + 1));
treeKey = `${tree.host}${prefix}`;
if (!filePath || (root && filePath !== root && !filePath.startsWith(`${root}/`))) {
continue;
}
root = `${prefix}/${root}`.replace(/\/$/, '');
} else {
const previous = space.previousGitSync?.url
? parseURL(space.previousGitSync.url)
: null;
filePath = previous ? findPreviousGitPath(url, previous) : null;
if (!filePath || !previous) {
continue;
}
root = previous.pathname.replace(/\/$/, '');
}
matches.set(space.id, {
space: space.id,
path: filePath,
anchor,
root,
tree: treeKey,
...(!space.gitSync ? { pathIncludesRef: true } : {}),
});
}
const candidates = [...matches.values()];
if (
new Set(candidates.flatMap((candidate) => (candidate.tree ? [candidate.tree] : []))).size >
1
) {
return null;
}
const longestRoot = Math.max(...candidates.map((candidate) => candidate.root.length));
const owners = candidates.filter((candidate) => candidate.root.length === longestRoot);
if (owners.length !== 1) {
return null;
}
const owner = owners[0]!;
return {
space: owner.space,
path: owner.path,
anchor: owner.anchor,
...(owner.pathIncludesRef ? { pathIncludesRef: true } : {}),
};
}
/** Match stored page paths after verifying the owning repository URL; callers must reject multiple pages. */
export function matchesGitPageURLTargetPath(target: GitPageURLTarget, filePath: string): boolean {
const paths = [target.path, `${target.path.replace(/\/$/, '')}/README.md`];
return paths.some((path) =>
target.pathIncludesRef ? path.endsWith(`/${filePath}`) : path === filePath
);
}
/** Match an API revision's nested page tree, including directory README links. */
export function findPageByGitPath<T extends { id: string; git?: { path: string }; pages?: T[] }>(
pages: readonly T[],
filePath: string
): T | null {
const paths = [filePath, `${filePath.replace(/\/$/, '')}/README.md`];
const matches: T[] = [];
const visit = (children: readonly T[]) => {
for (const page of children) {
if (page.git && paths.includes(page.git.path)) {
matches.push(page);
}
visit(page.pages ?? []);
}
};
visit(pages);
return matches.length === 1 ? matches[0]! : null;
}
/** Find the only page of an API revision's page tree at the path a target points to. */
export function findPageForGitPageURLTarget<
T extends { id: string; git?: { path: string }; pages?: T[] },
>(pages: readonly T[], target: GitPageURLTarget): T | null {
const matches: T[] = [];
const visit = (children: readonly T[]) => {
for (const page of children) {
if (page.git && matchesGitPageURLTargetPath(target, page.git.path)) {
matches.push(page);
}
visit(page.pages ?? []);
}
};
visit(pages);
return matches.length === 1 ? matches[0]! : null;
}
function parseURL(href: string): URL | null {
try {
const url = new URL(href);
return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password
? url
: null;
} catch {
return null;
}
}
function decodeGitPath(encoded: string): string | null {
try {
// Encoded separators make repository/ref boundaries ambiguous.
if (/%2f|%5c/i.test(encoded)) {
return null;
}
const decoded = decodeURIComponent(encoded);
return decoded.includes('\\') ||
decoded.split('/').some((part) => part === '.' || part === '..')
? null
: decoded;
} catch {
return null;
}
}
function findPreviousGitPath(url: URL, previous: URL): string | null {
if (
previous.search ||
previous.hash ||
!/\/(?:tree|blob)\/.+/.test(previous.pathname) ||
(url.host !== previous.host && url.host !== `www.${previous.host}`)
) {
return null;
}
const prefix = previous.pathname.replace(/\/$/, '');
const blobPrefix = prefix
.replace('/-/tree/', '/-/blob/')
.replace(/^(\/[^/]+\/[^/]+)\/tree\//, '$1/blob/');
if (
![prefix, blobPrefix].some(
(candidate) => url.pathname === candidate || url.pathname.startsWith(`${candidate}/`)
)
) {
return null;
}
// Keep the ref until revision lookup: its slash boundary is not recorded separately.
const suffix = url.pathname.match(/\/(?:tree|blob)\/(.+)$/)?.[1];
return suffix ? decodeGitPath(suffix) : null;
}
+1 -219
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, mock } from 'bun:test';
import { describe, expect, it } from 'bun:test';
import type { Revision, RevisionPageDocument, SiteSpace, Space } from '@gitbook/api';
@@ -738,221 +738,3 @@ 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');
});
});
+1 -112
View File
@@ -9,7 +9,6 @@ import type {
RevisionReusableContent,
SiteSection,
SiteSpace,
SiteStructure,
Space,
TranslationLanguage,
} from '@gitbook/api';
@@ -17,19 +16,12 @@ import type { Filesystem } from '@gitbook/openapi-parser';
import { getGitBookAppHref } from './app';
import { getBlockById, getBlockTitle } from './document';
import {
type GitPageURLSpace,
type GitPageURLTarget,
findGitPageURLTarget,
findPageForGitPageURLTarget,
} from './gitPageURL';
import { resolvePageId } from './pages';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
getLinkerForSiteSpace,
getLocalizedTitle,
listAllSiteSpaces,
} from './sites';
import { getRevisionTags, resolveTag } from './tags';
import type { ClassValue } from './tailwind';
@@ -50,15 +42,7 @@ import {
} from '@/lib/data';
import { type GitBookLinker, createLinker, linkerWithAbsoluteURLs } from '@/lib/links';
// The spaces of each site that can own a repository URL, and the hosts of their repositories.
const siteGitSpaces = new WeakMap<
SiteStructure,
{ spaces: GitPageURLSpace[]; hosts: Set<string> }
>();
export interface ResolvedContentRef {
/** Effective destination when a repository URL resolves to a site page. */
resolvedRef?: ContentRef;
/** Text to render in the content ref */
text: string;
/** Additional sub text to render in the content ref */
@@ -159,61 +143,6 @@ export async function resolveContentRef(
switch (contentRef.kind) {
case 'url': {
if ('site' in context) {
const target = findSiteGitPageURLTarget(context.structure, contentRef.url);
if (target) {
try {
// Site CRs must select the target member's revision here instead of main.
const targetContext = await createContextForSpace(
target.space,
context,
true
);
const page =
targetContext &&
findPageForGitPageURLTarget(
targetContext.spaceContext.revision.pages,
target
);
if (page?.type === 'document' && targetContext) {
const resolvedRef: ContentRef = target.anchor
? {
kind: 'anchor',
space: target.space,
page: page.id,
anchor: target.anchor,
}
: { kind: 'page', space: target.space, page: page.id };
const resolved = await resolveContentRef(
resolvedRef,
targetContext.spaceContext,
options
);
if (resolved) {
const foundSiteSpace = findSiteSpaceBy(
context.structure,
(entry) => entry.space.id === target.space
);
return {
...resolved,
resolvedRef,
ancestors: [
...resolvePageAncestors(
context,
resolvedRef,
foundSiteSpace,
targetContext
),
...(resolved.ancestors ?? []),
],
};
}
}
} catch {
// An unavailable or forbidden target must not prevent rendering the source page.
}
}
}
return {
href: contentRef.url,
text: contentRef.url,
@@ -690,44 +619,6 @@ async function resolveContentRefInSpace(
}
}
/**
* Locate the site space owning a repository URL. Links to other hosts, which most are, skip
* matching against every space of the site.
*/
function findSiteGitPageURLTarget(structure: SiteStructure, href: string): GitPageURLTarget | null {
let site = siteGitSpaces.get(structure);
if (!site) {
const spaces: GitPageURLSpace[] = listAllSiteSpaces(structure)
.filter((siteSpace) => !siteSpace.draft)
.map((siteSpace) => siteSpace.space);
const hosts = new Set(
spaces.flatMap((space) => {
const host = getURLHost(space.gitSync?.url ?? space.previousGitSync?.url);
return host ? [host, `www.${host}`] : [];
})
);
site = { spaces, hosts };
siteGitSpaces.set(structure, site);
}
const host = getURLHost(href);
if (!host || !site.hosts.has(host)) {
return null;
}
return findGitPageURLTarget(href, site.spaces);
}
function getURLHost(href: string | undefined): string | null {
if (!href) {
return null;
}
try {
return new URL(href).host;
} catch {
return null;
}
}
/**
* Create a new context for a specific spaceId.
*
@@ -736,8 +627,7 @@ function getURLHost(href: string | undefined): string | null {
*/
async function createContextForSpace(
spaceId: string,
context: GitBookAnyContext,
revisionMetadata = false
context: GitBookAnyContext
): Promise<{
spaceContext: GitBookSpaceContext;
baseURL: URL;
@@ -749,7 +639,6 @@ async function createContextForSpace(
shareKey: context?.shareKey,
changeRequest: undefined,
revision: undefined,
revisionMetadata,
})
),
getBestTargetSpace(context, spaceId),
+24 -8
View File
@@ -1,7 +1,6 @@
import type { RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
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';
@@ -70,11 +69,6 @@ function renderNotFoundMarkdown(context: GitBookSiteContext, pagePath: string) {
const fullContentUrl = context.linker.toAbsoluteURL(
context.linker.toPathInSite('llms-full.txt')
);
const askPageUrl = context.linker.toAbsoluteURL(
context.linker.toPathForPagePath({
path: similarPages[0]?.path ?? 'docs/example',
})
);
return `# Page Not Found
@@ -91,7 +85,20 @@ If the exact page cannot be found, you can still retrieve the information using
### Option 1 — Ask a question (recommended)
${renderQueryingDocumentation({ pageUrl: askPageUrl })}
Perform an HTTP GET request on the documentation index with the \`ask\` parameter, and the optional \`goal\` parameter:
\`\`\`
GET ${context.linker.toAbsoluteURL(
context.linker.toPathForPagePath({
path: similarPages[0]?.path ?? 'docs/example',
})
)}?ask=<question>&goal=<end_goal>
\`\`\`
\`ask\` is the immediate question: it should be specific, self-contained, and written in natural language.
\`goal\` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.
### Option 2 — Browse the documentation index
@@ -138,7 +145,16 @@ This documentation is published with GitBook. GitBook is the documentation platf
## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.
${renderQueryingDocumentation({ pageUrl })}
Perform an HTTP GET request on the current page URL with the \`ask\` query parameter, and the optional \`goal\` query parameter:
\`\`\`
GET ${pageUrl}?ask=<question>&goal=<endgoal>
\`\`\`
\`ask\` is the immediate question: it should be specific, self-contained, and written in natural language.
\`goal\` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.
Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
`;