Compare commits

...

22 Commits

Author SHA1 Message Date
Peter White 858f586d29 Merge main into peter/rnd-13222-openapi-nullable-recursion 2026-10-06 18:49:37 +02:00
Peter White 990e702035 Clarify not-found copy for login-protected pages (#4663) 2026-10-06 16:58:58 +02:00
Peter White d75bea72b6 changeset 2026-10-06 16:54:12 +02:00
Peter White 33dd0d3d7c Stop self-referencing nullable OpenAPI schemas from recursing when expanded
Part of RND-13222
2026-10-06 16:54:12 +02:00
Peter White 3ed448fed7 Keep ?ask out of the search field while integration assistants load (#4661) 2026-10-06 14:11:41 +02:00
conico974 4856a1f744 Add support for resolving missing page paths and redirecting to markdown versions (#4657) 2026-10-06 13:54:16 +02:00
Peter White ac843657ec Restore Next.js dev-mode OOM patch for 16.3.8 (#4662) 2026-10-06 13:33:50 +02:00
Brett Jephson b1102fcebe Prepare DocumentMarkColor to work with optional properties (#4659) 2026-10-05 17:55:22 +01:00
conico974 7996c362c1 Bump Next and OpenNext (#4658) 2026-10-05 15:48:35 +02:00
Steven H c7c1b79788 Improve the prompt asking agents for feedback on GitBook-powered docs (#4654) 2026-10-02 18:38:56 +01:00
Taran Vohra dff0c7903e Resolve cross-space repository page URLs when rendering sites (#4619)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:31:20 +05:30
Tomek ffebd1790e Extract page title utility and add SEO title support (#4647)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: Nolann Biron <biron.nolann@gmail.com>
Co-authored-by: Nolann B. <100787331+nolannbiron@users.noreply.github.com>
2026-10-02 14:07:15 +02:00
Tomek 0e0085e49a Fix sidebar group titles being clipped while scrolling (#4623)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 14:31:00 +02:00
Tomek 8e131693ea Auto-scroll TOC to active item when it's not visible (#4653) 2026-10-01 09:14:01 +00:00
Peter White 96325161be Lazy-load Loom embeds so pages with many of them don't stay blank (RND-13147) (#4652) 2026-09-30 14:18:29 +02:00
Surai 9f5290bccc Fix Ask AI submissions when search state is empty (#4651) 2026-09-30 09:50:39 +02:00
Peter White 61992424af Mark text over a background cover in the first frame after hydration (#4650) 2026-09-30 08:21:31 +02:00
Peter White 380af10236 Send previousUrl with site insights events (#4640) 2026-09-29 16:43:14 +00:00
Peter White 0911abc55c Fix page cover image jumping on load (#4648) 2026-09-29 18:11:03 +02:00
Tomek d8dc59b1a9 Improve dark theme contrast for Mermaid edge labels (#4645)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-29 14:02:07 +02:00
Surai d04f8bc05e Update trademark landing page (#4644) 2026-09-29 11:13:52 +02:00
conico974 576a781a8f Bump versions of @opennextjs/aws and @opennextjs/cloudflare (#4643) 2026-09-29 10:51:30 +02:00
98 changed files with 1982 additions and 312 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Stop the `?ask` query briefly appearing in the search field before an integration assistant opens.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix page cover image jumping on load
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Use the DocumentTextColor type defined in API schema.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix inline Ask AI inputs and buttons doing nothing before search is opened.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Improve the prompt for agents to ask questions.
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Fix OpenAPI schemas that reference themselves through a nullable union rendering forever when expanded, which crashed PDF exports.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Use the page's tag title, when set, for the HTML `<title>` of published pages.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Clarify the not-found page on sites with login-protected content: prompt anonymous visitors to log in, and tell logged-in visitors they may not have access.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Point the "Powered by GitBook" trademark link to gitbook.com/powered-by.
+18 -19
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.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
View File
@@ -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"
}
}
+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.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",
+7 -3
View File
@@ -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 });
});
@@ -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:
@@ -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(' | ');
}
@@ -16,6 +16,7 @@ import { useSetSearchState } from '../Search';
import { fetchSiteIndex } from '../Search/site-index';
import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink';
import {
useIsLoggedInVisitor,
useSiteAdaptiveAuthLoginHref,
useSiteIndexURL,
useSpaceBasePath,
@@ -44,6 +45,9 @@ export function SitePageNotFound() {
const setSearchState = useSetSearchState();
const { assistants } = useAI();
const isAuthenticated = useIsLoggedInVisitor();
const copy = getNotFoundCopy({ hasAdaptiveLogin: !!adaptiveAuthLoginHref, isAuthenticated });
// Show the assistant input when a non-search assistant is available (i.e. not just ask-AI).
const assistant = assistants.find((candidate) => candidate.mode !== 'search') ?? null;
const inputLabel = tString(language, assistant ? 'search_or_ask' : 'search');
@@ -124,21 +128,20 @@ export function SitePageNotFound() {
'text-tint-strong'
)}
>
{t(
language,
adaptiveAuthLoginHref
? 'notfound_adaptive_title'
: 'notfound_title'
)}
{t(language, copy.title)}
</h1>
<p className={tcls('text-base', 'text-tint')}>
{t(
language,
adaptiveAuthLoginHref ? 'notfound_adaptive' : 'notfound'
)}
<p className={tcls('text-base', 'text-tint', 'text-center')}>
{t(language, copy.message)}
</p>
</div>
{adaptiveAuthLoginHref ? (
{adaptiveAuthLoginHref && isAuthenticated ? (
// Re-running login refreshes the visitor's claims, e.g. after a role change.
<SiteAuthLoginButton
href={adaptiveAuthLoginHref}
variant="secondary"
label={t(language, 'notfound_adaptive_login_again')}
/>
) : adaptiveAuthLoginHref ? (
<div className="flex flex-col items-center gap-4">
<SiteAuthLoginButton
href={adaptiveAuthLoginHref}
@@ -190,6 +193,19 @@ export function SitePageNotFound() {
);
}
function getNotFoundCopy(args: { hasAdaptiveLogin: boolean; isAuthenticated: boolean }) {
if (!args.hasAdaptiveLogin) {
return { title: 'notfound_title', message: 'notfound' } as const;
}
if (args.isAuthenticated) {
return {
title: 'notfound_adaptive_no_access_title',
message: 'notfound_adaptive_no_access',
} as const;
}
return { title: 'notfound_adaptive_title', message: 'notfound_adaptive' } as const;
}
/**
* List of related pages, with skeleton rows while they load. Renders nothing once we know
* there are no suggestions.
@@ -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(' | ');
}
@@ -72,6 +72,7 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
<SpaceLayoutContextProvider
basePath={context.linker.toPathInSpace('')}
siteAdaptiveAuthLoginHref={siteAdaptiveAuthLoginHref}
isLoggedInVisitor={context.isLoggedInVisitor}
siteIndexURL={context.linker.toPathInSite('~gitbook/site-index')}
>
<AdaptiveVisitorContextProvider
@@ -5,6 +5,7 @@ import React from 'react';
const SpaceLayoutContext = React.createContext({
basePath: '',
siteAdaptiveAuthLoginHref: null as string | null,
isLoggedInVisitor: false,
siteIndexURL: '',
});
@@ -15,14 +16,21 @@ export function SpaceLayoutContextProvider(
props: React.PropsWithChildren<{
basePath: string;
siteAdaptiveAuthLoginHref?: string | null;
isLoggedInVisitor?: boolean;
siteIndexURL: string;
}>
) {
const { basePath, siteAdaptiveAuthLoginHref = null, siteIndexURL, children } = props;
const {
basePath,
siteAdaptiveAuthLoginHref = null,
isLoggedInVisitor = false,
siteIndexURL,
children,
} = props;
const value = React.useMemo(
() => ({ basePath, siteAdaptiveAuthLoginHref, siteIndexURL }),
[basePath, siteAdaptiveAuthLoginHref, siteIndexURL]
() => ({ basePath, siteAdaptiveAuthLoginHref, isLoggedInVisitor, siteIndexURL }),
[basePath, siteAdaptiveAuthLoginHref, isLoggedInVisitor, siteIndexURL]
);
return <SpaceLayoutContext.Provider value={value}>{children}</SpaceLayoutContext.Provider>;
@@ -50,6 +58,17 @@ export function useSiteAdaptiveAuthLoginHref() {
return context.siteAdaptiveAuthLoginHref;
}
/**
* Return whether the visitor has a visitor auth token, with or without claims.
*/
export function useIsLoggedInVisitor() {
const context = React.useContext(SpaceLayoutContext);
if (!context) {
throw new Error('SpaceLayoutContext not found');
}
return context.isLoggedInVisitor;
}
/**
* Return the URL of the site search index (`~gitbook/site-index`).
*/
@@ -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
);
}
+6 -2
View File
@@ -77,10 +77,14 @@ export const ar: TranslationLanguage = {
notfound_title: 'الصفحة غير موجودة',
notfound_link: 'يشير هذا الرابط إلى صفحة تمت إزالتها أو لم تعد موجودة.',
notfound: 'الصفحة التي تبحث عنها غير موجودة.',
notfound_adaptive_title: 'الصفحة غير متاحة',
notfound_adaptive: 'قد تكون هذه الصفحة موجودة، لكن قد تحتاج إلى تسجيل الدخول للوصول إليها.',
notfound_adaptive_title: 'سجّل الدخول لعرض هذه الصفحة',
notfound_adaptive: 'قد تتطلب هذه الصفحة تسجيل الدخول. إذا اتبعت رابطًا، فتحقق من صحته.',
notfound_adaptive_login: 'تسجيل الدخول',
notfound_adaptive_registration_hint: 'قد يكون التسجيل متاحًا حسب المؤسسة.',
notfound_adaptive_no_access_title: 'قد لا تملك صلاحية الوصول إلى هذه الصفحة',
notfound_adaptive_no_access:
'قد لا تكون هذه الصفحة موجودة، أو قد لا يملك حسابك صلاحية الوصول إليها. إذا اتبعت رابطًا، فتحقق من صحته.',
notfound_adaptive_login_again: 'تسجيل الدخول مجددًا',
notfound_goto_home: 'الانتقال إلى الصفحة الرئيسية',
notfound_suggestions_title: 'ربما تبحث عن',
unexpected_error_title: 'حدث خطأ',
+6 -2
View File
@@ -78,12 +78,16 @@ export const bg: TranslationLanguage = {
notfound_title: 'Страницата не е намерена',
notfound_link: 'Тази връзка сочи към страница, която е премахната или вече не съществува.',
notfound: 'Страницата, която търсите, не съществува.',
notfound_adaptive_title: 'Страницата не е налична',
notfound_adaptive_title: 'Влезте, за да видите тази страница',
notfound_adaptive:
'Тази страница може да съществува, но може да трябва да влезете, за да я достъпите.',
'Тази страница може да изисква вход. Ако сте последвали връзка, проверете дали е правилна.',
notfound_adaptive_login: 'Вход',
notfound_adaptive_registration_hint:
'Регистрацията може да е възможна в зависимост от организацията.',
notfound_adaptive_no_access_title: 'Възможно е да нямате достъп до тази страница',
notfound_adaptive_no_access:
'Тази страница може да не съществува или профилът ви може да няма достъп до нея. Ако сте последвали връзка, проверете дали е правилна.',
notfound_adaptive_login_again: 'Повторен вход',
notfound_goto_home: 'Към началната страница',
notfound_suggestions_title: 'Може би търсите',
unexpected_error_title: 'Възникна грешка',
+6 -2
View File
@@ -77,11 +77,15 @@ export const cs: TranslationLanguage = {
notfound_title: 'Stránka nenalezena',
notfound_link: 'Tento odkaz vede na stránku, která byla odstraněna nebo již neexistuje.',
notfound: 'Stránka, kterou hledáte, neexistuje.',
notfound_adaptive_title: 'Stránka není dostupná',
notfound_adaptive_title: 'Pro zobrazení této stránky se přihlaste',
notfound_adaptive:
'Tato stránka může existovat, ale pro přístup se možná budete muset přihlásit.',
'Tato stránka může vyžadovat přihlášení. Pokud jste přešli z odkazu, zkontrolujte, zda je správný.',
notfound_adaptive_login: 'Přihlásit se',
notfound_adaptive_registration_hint: 'Registrace může být dostupná v závislosti na organizaci.',
notfound_adaptive_no_access_title: 'K této stránce možná nemáte přístup',
notfound_adaptive_no_access:
'Tato stránka možná neexistuje nebo k ní váš účet nemá přístup. Pokud jste přešli z odkazu, zkontrolujte, zda je správný.',
notfound_adaptive_login_again: 'Přihlásit se znovu',
notfound_goto_home: 'Přejít na domovskou stránku',
notfound_suggestions_title: 'Možná hledáte',
unexpected_error_title: 'Došlo k chybě',
+6 -2
View File
@@ -77,11 +77,15 @@ export const da: TranslationLanguage = {
notfound_title: 'Siden blev ikke fundet',
notfound_link: 'Dette link peger på en side, der er blevet fjernet eller ikke længere findes.',
notfound: 'Siden, du leder efter, findes ikke.',
notfound_adaptive_title: 'Siden er ikke tilgængelig',
notfound_adaptive_title: 'Log ind for at se denne side',
notfound_adaptive:
'Denne side findes muligvis, men du skal muligvis logge ind for at få adgang.',
'Denne side kræver muligvis, at du logger ind. Hvis du har fulgt et link, så tjek, at det er korrekt.',
notfound_adaptive_login: 'Log ind',
notfound_adaptive_registration_hint: 'Registrering kan være mulig afhængigt af organisationen.',
notfound_adaptive_no_access_title: 'Du har muligvis ikke adgang til denne side',
notfound_adaptive_no_access:
'Denne side findes muligvis ikke, eller din konto har muligvis ikke adgang til den. Hvis du har fulgt et link, så tjek, at det er korrekt.',
notfound_adaptive_login_again: 'Log ind igen',
notfound_goto_home: 'Gå til forsiden',
notfound_suggestions_title: 'Du leder måske efter',
unexpected_error_title: 'Der opstod en fejl',
+6 -2
View File
@@ -80,12 +80,16 @@ export const de: TranslationLanguage = {
notfound_link:
'Dieser Link verweist auf eine Seite, die entfernt wurde oder nicht mehr existiert.',
notfound: 'Die gesuchte Seite existiert nicht.',
notfound_adaptive_title: 'Seite nicht verfügbar',
notfound_adaptive_title: 'Melden Sie sich an, um diese Seite anzuzeigen',
notfound_adaptive:
'Diese Seite existiert möglicherweise, aber Sie müssen sich eventuell anmelden, um darauf zuzugreifen.',
'Für diese Seite ist möglicherweise eine Anmeldung erforderlich. Wenn Sie einem Link gefolgt sind, prüfen Sie, ob er korrekt ist.',
notfound_adaptive_login: 'Anmelden',
notfound_adaptive_registration_hint:
'Die Registrierung ist je nach Organisation möglicherweise verfügbar.',
notfound_adaptive_no_access_title: 'Sie haben möglicherweise keinen Zugriff auf diese Seite',
notfound_adaptive_no_access:
'Diese Seite existiert möglicherweise nicht, oder Ihr Konto hat keinen Zugriff darauf. Wenn Sie einem Link gefolgt sind, prüfen Sie, ob er korrekt ist.',
notfound_adaptive_login_again: 'Erneut anmelden',
notfound_goto_home: 'Zur Startseite',
notfound_suggestions_title: 'Vielleicht suchen Sie nach',
unexpected_error_title: 'Ein Fehler ist aufgetreten',
+6 -2
View File
@@ -78,12 +78,16 @@ export const el: TranslationLanguage = {
notfound_title: 'Η σελίδα δεν βρέθηκε',
notfound_link: 'Αυτός ο σύνδεσμος οδηγεί σε μια σελίδα που έχει αφαιρεθεί ή δεν υπάρχει πλέον.',
notfound: 'Η σελίδα που αναζητάτε δεν υπάρχει.',
notfound_adaptive_title: 'Η σελίδα δεν είναι διαθέσιμη',
notfound_adaptive_title: 'Συνδεθείτε για να δείτε αυτή τη σελίδα',
notfound_adaptive:
'Αυτή η σελίδα μπορεί να υπάρχει, αλλά ίσως χρειαστεί να συνδεθείτε για πρόσβαση.',
'Αυτή η σελίδα ενδέχεται να απαιτεί σύνδεση. Αν ακολουθήσατε έναν σύνδεσμο, ελέγξτε ότι είναι σωστός.',
notfound_adaptive_login: 'Σύνδεση',
notfound_adaptive_registration_hint:
'Η εγγραφή ενδέχεται να είναι διαθέσιμη ανάλογα με τον οργανισμό.',
notfound_adaptive_no_access_title: 'Ίσως δεν έχετε πρόσβαση σε αυτή τη σελίδα',
notfound_adaptive_no_access:
'Αυτή η σελίδα ενδέχεται να μην υπάρχει ή ο λογαριασμός σας ενδέχεται να μην έχει πρόσβαση σε αυτήν. Αν ακολουθήσατε έναν σύνδεσμο, ελέγξτε ότι είναι σωστός.',
notfound_adaptive_login_again: 'Επανασύνδεση',
notfound_goto_home: 'Μετάβαση στην αρχική σελίδα',
notfound_suggestions_title: 'Ίσως αναζητάτε',
unexpected_error_title: 'Παρουσιάστηκε σφάλμα',
+7 -2
View File
@@ -75,11 +75,16 @@ export const en = {
notfound_title: 'Page not found',
notfound_link: 'This link points to a page that has been removed or no longer exists.',
notfound: "The page you're looking for doesn't exist.",
notfound_adaptive_title: 'Page unavailable',
notfound_adaptive: 'This page may exist, but you may need to log in to access it.',
notfound_adaptive_title: 'Log in to view this page',
notfound_adaptive:
"This page may require you to log in. If you followed a link, check that it's correct.",
notfound_adaptive_login: 'Log in',
notfound_adaptive_registration_hint:
'Registration may be available depending on the organization.',
notfound_adaptive_no_access_title: 'You may not have access to this page',
notfound_adaptive_no_access:
"This page may not exist, or your account may not have access to it. If you followed a link, check that it's correct.",
notfound_adaptive_login_again: 'Log in again',
notfound_goto_home: 'Go to homepage',
notfound_suggestions_title: 'You might be looking for',
unexpected_error_title: 'An error occurred',
+6 -2
View File
@@ -79,12 +79,16 @@ export const es: TranslationLanguage = {
notfound_title: 'Página no encontrada',
notfound_link: 'Este enlace apunta a una página que ha sido eliminada o ya no existe.',
notfound: 'La página que buscas no existe.',
notfound_adaptive_title: 'Página no disponible',
notfound_adaptive_title: 'Inicia sesión para ver esta página',
notfound_adaptive:
'Esta página puede existir, pero es posible que debas iniciar sesión para acceder.',
'Es posible que esta página requiera iniciar sesión. Si has seguido un enlace, comprueba que sea correcto.',
notfound_adaptive_login: 'Iniciar sesión',
notfound_adaptive_registration_hint:
'El registro puede estar disponible según la organización.',
notfound_adaptive_no_access_title: 'Es posible que no tengas acceso a esta página',
notfound_adaptive_no_access:
'Es posible que esta página no exista o que tu cuenta no tenga acceso a ella. Si has seguido un enlace, comprueba que sea correcto.',
notfound_adaptive_login_again: 'Volver a iniciar sesión',
notfound_goto_home: 'Ir al inicio',
notfound_suggestions_title: 'Quizás estés buscando',
unexpected_error_title: 'Ocurrió un error',
+7 -2
View File
@@ -77,11 +77,16 @@ export const et: TranslationLanguage = {
notfound_title: 'Lehte ei leitud',
notfound_link: 'See link viitab lehele, mis on eemaldatud või mida enam ei eksisteeri.',
notfound: 'Lehte, mida otsite, ei eksisteeri.',
notfound_adaptive_title: 'Leht pole saadaval',
notfound_adaptive: 'See leht võib olemas olla, kuid juurdepääsuks võib olla vaja sisse logida.',
notfound_adaptive_title: 'Selle lehe vaatamiseks logi sisse',
notfound_adaptive:
'See leht võib nõuda sisselogimist. Kui järgisid linki, kontrolli, et see oleks õige.',
notfound_adaptive_login: 'Logi sisse',
notfound_adaptive_registration_hint:
'Registreerimine võib olla saadaval olenevalt organisatsioonist.',
notfound_adaptive_no_access_title: 'Sul ei pruugi olla sellele lehele juurdepääsu',
notfound_adaptive_no_access:
'Seda lehte ei pruugi olla olemas või sinu kontol ei pruugi olla sellele juurdepääsu. Kui järgisid linki, kontrolli, et see oleks õige.',
notfound_adaptive_login_again: 'Logi uuesti sisse',
notfound_goto_home: 'Mine avalehele',
notfound_suggestions_title: 'Võib-olla otsite',
unexpected_error_title: 'Ilmnes tõrge',
+6 -2
View File
@@ -78,12 +78,16 @@ export const fi: TranslationLanguage = {
notfound_title: 'Sivua ei löytynyt',
notfound_link: 'Tämä linkki osoittaa sivulle, joka on poistettu tai jota ei enää ole.',
notfound: 'Etsimääsi sivua ei ole olemassa.',
notfound_adaptive_title: 'Sivu ei ole saatavilla',
notfound_adaptive_title: 'Kirjaudu sisään nähdäksesi tämän sivun',
notfound_adaptive:
'Tämä sivu voi olla olemassa, mutta sen käyttö voi edellyttää kirjautumista.',
'Tämä sivu saattaa vaatia kirjautumisen. Jos seurasit linkkiä, tarkista, että se on oikein.',
notfound_adaptive_login: 'Kirjaudu sisään',
notfound_adaptive_registration_hint:
'Rekisteröityminen voi olla mahdollista organisaatiosta riippuen.',
notfound_adaptive_no_access_title: 'Sinulla ei ehkä ole pääsyä tälle sivulle',
notfound_adaptive_no_access:
'Tätä sivua ei ehkä ole olemassa, tai tililläsi ei ehkä ole pääsyä siihen. Jos seurasit linkkiä, tarkista, että se on oikein.',
notfound_adaptive_login_again: 'Kirjaudu uudelleen',
notfound_goto_home: 'Siirry etusivulle',
notfound_suggestions_title: 'Saatat etsiä',
unexpected_error_title: 'Tapahtui virhe',
+6 -2
View File
@@ -78,11 +78,15 @@ export const fr: TranslationLanguage = {
notfound_title: 'Page introuvable',
notfound_link: "Ce lien pointe vers une page qui a été supprimée ou n'existe plus.",
notfound: 'La page que vous cherchez n’existe pas.',
notfound_adaptive_title: 'Page inaccessible',
notfound_adaptive_title: 'Connectez-vous pour afficher cette page',
notfound_adaptive:
'Cette page existe peut-être, mais vous devez peut-être vous connecter pour y accéder.',
"Cette page peut nécessiter une connexion. Si vous avez suivi un lien, vérifiez qu'il est correct.",
notfound_adaptive_login: 'Se connecter',
notfound_adaptive_registration_hint: "L'inscription peut être disponible selon l'organisation.",
notfound_adaptive_no_access_title: "Vous n'avez peut-être pas accès à cette page",
notfound_adaptive_no_access:
"Cette page n'existe peut-être pas, ou votre compte n'y a peut-être pas accès. Si vous avez suivi un lien, vérifiez qu'il est correct.",
notfound_adaptive_login_again: 'Se reconnecter',
notfound_goto_home: "Aller à l'accueil",
notfound_suggestions_title: 'Vous cherchez peut-être',
unexpected_error_title: 'Erreur inattendue',
+6 -2
View File
@@ -77,10 +77,14 @@ export const he: TranslationLanguage = {
notfound_title: 'הדף לא נמצא',
notfound_link: 'קישור זה מפנה לדף שהוסר או אינו קיים עוד.',
notfound: 'הדף שחיפשת אינו קיים.',
notfound_adaptive_title: 'הדף אינו זמין',
notfound_adaptive: 'ייתכן שהדף קיים, אך ייתכן שעליך להתחבר כדי לגשת אליו.',
notfound_adaptive_title: 'יש להתחבר כדי לצפות בדף זה',
notfound_adaptive: 'ייתכן שדף זה דורש התחברות. אם הגעת דרך קישור, יש לוודא שהוא נכון.',
notfound_adaptive_login: 'התחברות',
notfound_adaptive_registration_hint: 'ייתכן שההרשמה זמינה בהתאם לארגון.',
notfound_adaptive_no_access_title: 'ייתכן שאין לך גישה לדף זה',
notfound_adaptive_no_access:
'ייתכן שדף זה אינו קיים, או שלחשבון שלך אין גישה אליו. אם הגעת דרך קישור, יש לוודא שהוא נכון.',
notfound_adaptive_login_again: 'התחברות מחדש',
notfound_goto_home: 'מעבר לדף הבית',
notfound_suggestions_title: 'אולי אתה מחפש',
unexpected_error_title: 'אירעה שגיאה',
+7 -2
View File
@@ -77,10 +77,15 @@ export const hi: TranslationLanguage = {
notfound_title: 'पृष्ठ नहीं मिला',
notfound_link: 'यह लिंक ऐसे पृष्ठ पर जाता है जिसे हटा दिया गया है या जो अब मौजूद नहीं है।',
notfound: 'आप जिस पृष्ठ को खोज रहे हैं वह मौजूद नहीं है।',
notfound_adaptive_title: 'पृष्ठ उपलब्ध नहीं',
notfound_adaptive: 'यह पृष्ठ मौजूद हो सकता है, लेकिन इसे देखने के लिए आपको लॉग इन करना पड़ सकता है।',
notfound_adaptive_title: 'यह पृष्ठ देखने के लिए लॉग इन करें',
notfound_adaptive:
'इस पृष्ठ के लिए लॉग इन करना आवश्यक हो सकता है। यदि आप किसी लिंक से आए हैं, तो जांच लें कि वह सही है।',
notfound_adaptive_login: 'लॉग इन करें',
notfound_adaptive_registration_hint: 'संगठन के आधार पर पंजीकरण उपलब्ध हो सकता है।',
notfound_adaptive_no_access_title: 'हो सकता है कि आपके पास इस पृष्ठ की पहुंच न हो',
notfound_adaptive_no_access:
'हो सकता है कि यह पृष्ठ मौजूद न हो, या आपके खाते के पास इसकी पहुंच न हो। यदि आप किसी लिंक से आए हैं, तो जांच लें कि वह सही है।',
notfound_adaptive_login_again: 'फिर से लॉग इन करें',
notfound_goto_home: 'मुखपृष्ठ पर जाएं',
notfound_suggestions_title: 'शायद आप यह ढूँढ रहे हैं',
unexpected_error_title: 'त्रुटि हुई',
+6 -2
View File
@@ -77,11 +77,15 @@ export const hr: TranslationLanguage = {
notfound_title: 'Stranica nije pronađena',
notfound_link: 'Ova poveznica vodi na stranicu koja je uklonjena ili više ne postoji.',
notfound: 'Stranica koju tražite ne postoji.',
notfound_adaptive_title: 'Stranica nije dostupna',
notfound_adaptive_title: 'Prijavite se za prikaz ove stranice',
notfound_adaptive:
'Ova stranica možda postoji, ali možda se morate prijaviti da biste joj pristupili.',
'Ova stranica možda zahtijeva prijavu. Ako ste slijedili poveznicu, provjerite je li ispravna.',
notfound_adaptive_login: 'Prijava',
notfound_adaptive_registration_hint: 'Registracija može biti dostupna ovisno o organizaciji.',
notfound_adaptive_no_access_title: 'Možda nemate pristup ovoj stranici',
notfound_adaptive_no_access:
'Ova stranica možda ne postoji ili vaš račun nema pristup njoj. Ako ste slijedili poveznicu, provjerite je li ispravna.',
notfound_adaptive_login_again: 'Ponovna prijava',
notfound_goto_home: 'Idi na početnu stranicu',
notfound_suggestions_title: 'Možda tražite',
unexpected_error_title: 'Došlo je do pogreške',
+7 -2
View File
@@ -78,10 +78,15 @@ export const hu: TranslationLanguage = {
notfound_title: 'Az oldal nem található',
notfound_link: 'Ez a hivatkozás egy eltávolított vagy már nem létező oldalra mutat.',
notfound: 'A keresett oldal nem létezik.',
notfound_adaptive_title: 'Az oldal nem érhető el',
notfound_adaptive: 'Ez az oldal létezhet, de a hozzáféréshez lehet, hogy be kell jelentkeznie.',
notfound_adaptive_title: 'Jelentkezzen be az oldal megtekintéséhez',
notfound_adaptive:
'Ehhez az oldalhoz bejelentkezés szükséges lehet. Ha egy hivatkozást követett, ellenőrizze, hogy helyes-e.',
notfound_adaptive_login: 'Bejelentkezés',
notfound_adaptive_registration_hint: 'A regisztráció a szervezettől függően elérhető lehet.',
notfound_adaptive_no_access_title: 'Lehet, hogy nincs hozzáférése ehhez az oldalhoz',
notfound_adaptive_no_access:
'Lehet, hogy ez az oldal nem létezik, vagy a fiókja nem fér hozzá. Ha egy hivatkozást követett, ellenőrizze, hogy helyes-e.',
notfound_adaptive_login_again: 'Újbóli bejelentkezés',
notfound_goto_home: 'Ugrás a kezdőlapra',
notfound_suggestions_title: 'Talán ezt keresi',
unexpected_error_title: 'Hiba történt',
+6 -2
View File
@@ -77,11 +77,15 @@ export const id: TranslationLanguage = {
notfound_title: 'Halaman tidak ditemukan',
notfound_link: 'Tautan ini mengarah ke halaman yang telah dihapus atau sudah tidak ada.',
notfound: 'Halaman yang Anda cari tidak ada.',
notfound_adaptive_title: 'Halaman tidak tersedia',
notfound_adaptive_title: 'Masuk untuk melihat halaman ini',
notfound_adaptive:
'Halaman ini mungkin ada, tetapi Anda mungkin perlu masuk untuk mengaksesnya.',
'Halaman ini mungkin mengharuskan Anda masuk. Jika Anda mengikuti tautan, periksa apakah tautan tersebut benar.',
notfound_adaptive_login: 'Masuk',
notfound_adaptive_registration_hint: 'Pendaftaran mungkin tersedia tergantung pada organisasi.',
notfound_adaptive_no_access_title: 'Anda mungkin tidak memiliki akses ke halaman ini',
notfound_adaptive_no_access:
'Halaman ini mungkin tidak ada, atau akun Anda mungkin tidak memiliki akses ke halaman ini. Jika Anda mengikuti tautan, periksa apakah tautan tersebut benar.',
notfound_adaptive_login_again: 'Masuk lagi',
notfound_goto_home: 'Buka beranda',
notfound_suggestions_title: 'Mungkin Anda mencari',
unexpected_error_title: 'Terjadi kesalahan',
+6 -2
View File
@@ -79,12 +79,16 @@ export const it: TranslationLanguage = {
notfound_title: 'Pagina non trovata',
notfound_link: 'Questo link punta a una pagina che è stata rimossa o non esiste più.',
notfound: 'La pagina che cerchi non esiste.',
notfound_adaptive_title: 'Pagina non disponibile',
notfound_adaptive_title: 'Accedi per visualizzare questa pagina',
notfound_adaptive:
"Questa pagina potrebbe esistere, ma potrebbe essere necessario effettuare l'accesso per visualizzarla.",
"Questa pagina potrebbe richiedere l'accesso. Se hai seguito un link, verifica che sia corretto.",
notfound_adaptive_login: 'Accedi',
notfound_adaptive_registration_hint:
"La registrazione potrebbe essere disponibile a seconda dell'organizzazione.",
notfound_adaptive_no_access_title: 'Potresti non avere accesso a questa pagina',
notfound_adaptive_no_access:
'Questa pagina potrebbe non esistere, oppure il tuo account potrebbe non avervi accesso. Se hai seguito un link, verifica che sia corretto.',
notfound_adaptive_login_again: 'Accedi di nuovo',
notfound_goto_home: 'Vai alla home',
notfound_suggestions_title: 'Forse stai cercando',
unexpected_error_title: 'Si è verificato un errore',
+6 -2
View File
@@ -78,11 +78,15 @@ export const ja: TranslationLanguage = {
notfound_title: 'ページが見つかりません',
notfound_link: 'このリンクは、削除されたか、もはや存在しないページを指しています。',
notfound: 'お探しのページは存在しません。',
notfound_adaptive_title: 'ページにアクセスできません',
notfound_adaptive_title: 'このページを表示するにはログインしてください',
notfound_adaptive:
'このページは存在する可能性がありますが、アクセスするにはログインが必要な場合があります。',
'このページの閲覧にはログインが必要な場合があります。リンクからアクセスした場合は、リンクが正しいか確認してください。',
notfound_adaptive_login: 'ログイン',
notfound_adaptive_registration_hint: '組織によっては登録が可能な場合があります。',
notfound_adaptive_no_access_title: 'このページへのアクセス権がない可能性があります',
notfound_adaptive_no_access:
'このページが存在しないか、お使いのアカウントにアクセス権がない可能性があります。リンクからアクセスした場合は、リンクが正しいか確認してください。',
notfound_adaptive_login_again: '再ログイン',
notfound_goto_home: 'ホームへ移動',
notfound_suggestions_title: 'お探しのページはこちらですか',
unexpected_error_title: 'エラーが発生しました',
+7 -2
View File
@@ -78,10 +78,15 @@ export const ko: TranslationLanguage = {
notfound_title: '페이지를 찾을 수 없음',
notfound_link: '이 링크는 삭제되었거나 더 이상 존재하지 않는 페이지를 가리킵니다.',
notfound: '찾으시는 페이지가 존재하지 않습니다.',
notfound_adaptive_title: '페이지에 접근할 수 없음',
notfound_adaptive: '이 페이지는 존재할 수 있지만, 접근하려면 로그인해야 할 수 있습니다.',
notfound_adaptive_title: '이 페이지를 보려면 로그인하세요',
notfound_adaptive:
'이 페이지는 로그인이 필요할 수 있습니다. 링크를 통해 접속했다면 링크가 올바른지 확인하세요.',
notfound_adaptive_login: '로그인',
notfound_adaptive_registration_hint: '조직에 따라 등록이 가능할 수 있습니다.',
notfound_adaptive_no_access_title: '이 페이지에 대한 접근 권한이 없을 수 있습니다',
notfound_adaptive_no_access:
'이 페이지가 존재하지 않거나 계정에 이 페이지에 대한 접근 권한이 없을 수 있습니다. 링크를 통해 접속했다면 링크가 올바른지 확인하세요.',
notfound_adaptive_login_again: '다시 로그인',
notfound_goto_home: '홈으로 이동',
notfound_suggestions_title: '찾고 계신 내용일 수 있어요',
unexpected_error_title: '오류가 발생했습니다',
+6 -2
View File
@@ -77,12 +77,16 @@ export const lt: TranslationLanguage = {
notfound_title: 'Puslapis nerastas',
notfound_link: 'Ši nuoroda veda į puslapį, kuris buvo pašalintas arba nebeegzistuoja.',
notfound: 'Puslapis, kurio ieškote, neegzistuoja.',
notfound_adaptive_title: 'Puslapis nepasiekiamas',
notfound_adaptive_title: 'Prisijunkite, kad peržiūrėtumėte šį puslapį',
notfound_adaptive:
'Šis puslapis gali egzistuoti, bet norint jį pasiekti gali reikėti prisijungti.',
'Šiam puslapiui gali reikėti prisijungti. Jei sekėte nuoroda, patikrinkite, ar ji teisinga.',
notfound_adaptive_login: 'Prisijungti',
notfound_adaptive_registration_hint:
'Registracija gali būti galima priklausomai nuo organizacijos.',
notfound_adaptive_no_access_title: 'Galbūt neturite prieigos prie šio puslapio',
notfound_adaptive_no_access:
'Šis puslapis gali neegzistuoti arba jūsų paskyra gali neturėti prieigos prie jo. Jei sekėte nuoroda, patikrinkite, ar ji teisinga.',
notfound_adaptive_login_again: 'Prisijungti iš naujo',
notfound_goto_home: 'Eiti į pagrindinį puslapį',
notfound_suggestions_title: 'Galbūt ieškote',
unexpected_error_title: 'Įvyko klaida',
+7 -2
View File
@@ -77,10 +77,15 @@ export const lv: TranslationLanguage = {
notfound_title: 'Lapa nav atrasta',
notfound_link: 'Šī saite norāda uz lapu, kas ir noņemta vai vairs nepastāv.',
notfound: 'Meklētā lapa nepastāv.',
notfound_adaptive_title: 'Lapa nav pieejama',
notfound_adaptive: 'Šī lapa var pastāvēt, bet, iespējams, jums jāpierakstās, lai tai piekļūtu.',
notfound_adaptive_title: 'Pierakstieties, lai skatītu šo lapu',
notfound_adaptive:
'Šai lapai, iespējams, nepieciešams pierakstīties. Ja sekojāt saitei, pārbaudiet, vai tā ir pareiza.',
notfound_adaptive_login: 'Pierakstīties',
notfound_adaptive_registration_hint: 'Reģistrācija var būt pieejama atkarībā no organizācijas.',
notfound_adaptive_no_access_title: 'Iespējams, jums nav piekļuves šai lapai',
notfound_adaptive_no_access:
'Šī lapa, iespējams, neeksistē, vai arī jūsu kontam nav piekļuves tai. Ja sekojāt saitei, pārbaudiet, vai tā ir pareiza.',
notfound_adaptive_login_again: 'Pierakstīties vēlreiz',
notfound_goto_home: 'Doties uz sākumlapu',
notfound_suggestions_title: 'Iespējams, meklējat',
unexpected_error_title: 'Radās kļūda',
+6 -2
View File
@@ -77,11 +77,15 @@ export const ms: TranslationLanguage = {
notfound_title: 'Halaman tidak ditemui',
notfound_link: 'Pautan ini menghala ke halaman yang telah dialih keluar atau tidak lagi wujud.',
notfound: 'Halaman yang anda cari tidak wujud.',
notfound_adaptive_title: 'Halaman tidak tersedia',
notfound_adaptive_title: 'Log masuk untuk melihat halaman ini',
notfound_adaptive:
'Halaman ini mungkin wujud, tetapi anda mungkin perlu log masuk untuk mengaksesnya.',
'Halaman ini mungkin memerlukan anda log masuk. Jika anda mengikuti pautan, semak sama ada pautan itu betul.',
notfound_adaptive_login: 'Log masuk',
notfound_adaptive_registration_hint: 'Pendaftaran mungkin tersedia bergantung pada organisasi.',
notfound_adaptive_no_access_title: 'Anda mungkin tidak mempunyai akses ke halaman ini',
notfound_adaptive_no_access:
'Halaman ini mungkin tidak wujud, atau akaun anda mungkin tidak mempunyai akses kepadanya. Jika anda mengikuti pautan, semak sama ada pautan itu betul.',
notfound_adaptive_login_again: 'Log masuk semula',
notfound_goto_home: 'Pergi ke halaman utama',
notfound_suggestions_title: 'Mungkin anda mencari',
unexpected_error_title: 'Ralat berlaku',
+6 -2
View File
@@ -79,12 +79,16 @@ export const nl: TranslationLanguage = {
notfound_title: 'Pagina niet gevonden',
notfound_link: 'Deze link verwijst naar een pagina die is verwijderd of niet meer bestaat.',
notfound: 'De pagina die je zoekt, bestaat niet.',
notfound_adaptive_title: 'Pagina niet beschikbaar',
notfound_adaptive_title: 'Log in om deze pagina te bekijken',
notfound_adaptive:
'Deze pagina bestaat mogelijk, maar je moet mogelijk inloggen om toegang te krijgen.',
'Voor deze pagina moet je mogelijk inloggen. Als je een link hebt gevolgd, controleer dan of deze klopt.',
notfound_adaptive_login: 'Inloggen',
notfound_adaptive_registration_hint:
'Registratie is mogelijk beschikbaar, afhankelijk van de organisatie.',
notfound_adaptive_no_access_title: 'Je hebt mogelijk geen toegang tot deze pagina',
notfound_adaptive_no_access:
'Deze pagina bestaat mogelijk niet, of je account heeft er geen toegang toe. Als je een link hebt gevolgd, controleer dan of deze klopt.',
notfound_adaptive_login_again: 'Opnieuw inloggen',
notfound_goto_home: 'Naar startpagina',
notfound_suggestions_title: 'Misschien zoek je',
unexpected_error_title: 'Er is een fout opgetreden',
+7 -2
View File
@@ -79,11 +79,16 @@ export const no: TranslationLanguage = {
notfound_link:
'Denne lenken peker til en side som har blitt fjernet eller ikke lenger eksisterer.',
notfound: 'Siden du leter etter eksisterer ikke.',
notfound_adaptive_title: 'Siden er ikke tilgjengelig',
notfound_adaptive: 'Denne siden kan finnes, men du må kanskje logge inn for å få tilgang.',
notfound_adaptive_title: 'Logg inn for å se denne siden',
notfound_adaptive:
'Denne siden kan kreve at du logger inn. Hvis du fulgte en lenke, sjekk at den er riktig.',
notfound_adaptive_login: 'Logg inn',
notfound_adaptive_registration_hint:
'Registrering kan være tilgjengelig avhengig av organisasjonen.',
notfound_adaptive_no_access_title: 'Du har kanskje ikke tilgang til denne siden',
notfound_adaptive_no_access:
'Denne siden finnes kanskje ikke, eller kontoen din har kanskje ikke tilgang til den. Hvis du fulgte en lenke, sjekk at den er riktig.',
notfound_adaptive_login_again: 'Logg inn på nytt',
notfound_goto_home: 'Gå til forsiden',
notfound_suggestions_title: 'Kanskje du leter etter',
unexpected_error_title: 'En feil oppstod',
+7 -2
View File
@@ -77,11 +77,16 @@ export const pl: TranslationLanguage = {
notfound_title: 'Nie znaleziono strony',
notfound_link: 'Ten link prowadzi do strony, która została usunięta lub już nie istnieje.',
notfound: 'Strona, której szukasz, nie istnieje.',
notfound_adaptive_title: 'Strona niedostępna',
notfound_adaptive: 'Ta strona może istnieć, ale dostęp do niej może wymagać zalogowania.',
notfound_adaptive_title: 'Zaloguj się, aby wyświetlić tę stronę',
notfound_adaptive:
'Ta strona może wymagać zalogowania. Jeśli przechodzisz z linku, sprawdź, czy jest poprawny.',
notfound_adaptive_login: 'Zaloguj się',
notfound_adaptive_registration_hint:
'Rejestracja może być dostępna w zależności od organizacji.',
notfound_adaptive_no_access_title: 'Możesz nie mieć dostępu do tej strony',
notfound_adaptive_no_access:
'Ta strona może nie istnieć lub Twoje konto może nie mieć do niej dostępu. Jeśli przechodzisz z linku, sprawdź, czy jest poprawny.',
notfound_adaptive_login_again: 'Zaloguj się ponownie',
notfound_goto_home: 'Przejdź do strony głównej',
notfound_suggestions_title: 'Być może szukasz',
unexpected_error_title: 'Wystąpił błąd',
@@ -79,12 +79,16 @@ export const pt_br: TranslationLanguage = {
notfound_title: 'Página não encontrada',
notfound_link: 'Este link aponta para uma página que foi removida ou não existe mais.',
notfound: 'A página que você está procurando não existe.',
notfound_adaptive_title: 'Página indisponível',
notfound_adaptive_title: 'Faça login para ver esta página',
notfound_adaptive:
'Esta página pode existir, mas talvez você precise fazer login para acessá-la.',
'Esta página pode exigir login. Se você seguiu um link, verifique se ele está correto.',
notfound_adaptive_login: 'Fazer login',
notfound_adaptive_registration_hint:
'O registro pode estar disponível dependendo da organização.',
notfound_adaptive_no_access_title: 'Talvez você não tenha acesso a esta página',
notfound_adaptive_no_access:
'Esta página pode não existir ou sua conta pode não ter acesso a ela. Se você seguiu um link, verifique se ele está correto.',
notfound_adaptive_login_again: 'Fazer login novamente',
notfound_goto_home: 'Ir para a página inicial',
notfound_suggestions_title: 'Você pode estar procurando por',
unexpected_error_title: 'Aconteceu um erro',
+6 -2
View File
@@ -78,12 +78,16 @@ export const pt: TranslationLanguage = {
notfound_title: 'Página não encontrada',
notfound_link: 'Esta ligação aponta para uma página que foi removida ou já não existe.',
notfound: 'A página que procura não existe.',
notfound_adaptive_title: 'Página indisponível',
notfound_adaptive_title: 'Inicie sessão para ver esta página',
notfound_adaptive:
'Esta página pode existir, mas talvez tenha de iniciar sessão para lhe aceder.',
'Esta página pode exigir que inicie sessão. Se seguiu uma ligação, verifique se está correta.',
notfound_adaptive_login: 'Iniciar sessão',
notfound_adaptive_registration_hint:
'O registo pode estar disponível dependendo da organização.',
notfound_adaptive_no_access_title: 'Pode não ter acesso a esta página',
notfound_adaptive_no_access:
'Esta página pode não existir ou a sua conta pode não ter acesso à mesma. Se seguiu uma ligação, verifique se está correta.',
notfound_adaptive_login_again: 'Iniciar sessão novamente',
notfound_goto_home: 'Ir para a página inicial',
notfound_suggestions_title: 'Talvez esteja à procura de',
unexpected_error_title: 'Ocorreu um erro',
+6 -2
View File
@@ -78,12 +78,16 @@ export const ro: TranslationLanguage = {
notfound_title: 'Pagina nu a fost găsită',
notfound_link: 'Acest link indică o pagină care a fost eliminată sau nu mai există.',
notfound: 'Pagina pe care o cauți nu există.',
notfound_adaptive_title: 'Pagina nu este disponibilă',
notfound_adaptive_title: 'Autentifică-te pentru a vedea această pagină',
notfound_adaptive:
'Această pagină poate exista, dar este posibil să trebuiască să te autentifici pentru a o accesa.',
'Această pagină poate necesita autentificare. Dacă ai urmat un link, verifică dacă este corect.',
notfound_adaptive_login: 'Autentificare',
notfound_adaptive_registration_hint:
'Înregistrarea poate fi disponibilă în funcție de organizație.',
notfound_adaptive_no_access_title: 'Este posibil să nu ai acces la această pagină',
notfound_adaptive_no_access:
'Este posibil ca această pagină să nu existe sau contul tău să nu aibă acces la ea. Dacă ai urmat un link, verifică dacă este corect.',
notfound_adaptive_login_again: 'Reautentificare',
notfound_goto_home: 'Mergi la pagina principală',
notfound_suggestions_title: 'Poate cauți',
unexpected_error_title: 'A apărut o eroare',
+6 -2
View File
@@ -79,12 +79,16 @@ export const ru: TranslationLanguage = {
notfound_title: 'Страница не найдена',
notfound_link: 'Эта ссылка ведёт на страницу, которая была удалена или больше не существует.',
notfound: 'Страница, которую вы ищете, не существует.',
notfound_adaptive_title: 'Страница недоступна',
notfound_adaptive_title: 'Войдите, чтобы просмотреть эту страницу',
notfound_adaptive:
'Эта страница может существовать, но для доступа к ней может потребоваться вход.',
'Для просмотра этой страницы может потребоваться вход. Если вы перешли по ссылке, проверьте, что она верна.',
notfound_adaptive_login: 'Войти',
notfound_adaptive_registration_hint:
'Регистрация может быть доступна в зависимости от организации.',
notfound_adaptive_no_access_title: 'Возможно, у вас нет доступа к этой странице',
notfound_adaptive_no_access:
'Возможно, эта страница не существует или у вашей учётной записи нет к ней доступа. Если вы перешли по ссылке, проверьте, что она верна.',
notfound_adaptive_login_again: 'Войти снова',
notfound_goto_home: 'Перейти на главную',
notfound_suggestions_title: 'Возможно, вы ищете',
unexpected_error_title: 'Произошла ошибка',
+6 -2
View File
@@ -78,12 +78,16 @@ export const sk: TranslationLanguage = {
notfound_title: 'Stránka sa nenašla',
notfound_link: 'Tento odkaz smeruje na stránku, ktorá bola odstránená alebo už neexistuje.',
notfound: 'Stránka, ktorú hľadáte, neexistuje.',
notfound_adaptive_title: 'Stránka nie je dostupná',
notfound_adaptive_title: 'Na zobrazenie tejto stránky sa prihláste',
notfound_adaptive:
'Táto stránka môže existovať, ale na prístup sa možno budete musieť prihlásiť.',
'Táto stránka môže vyžadovať prihlásenie. Ak ste prešli z odkazu, skontrolujte, či je správny.',
notfound_adaptive_login: 'Prihlásiť sa',
notfound_adaptive_registration_hint:
'Registrácia môže byť dostupná v závislosti od organizácie.',
notfound_adaptive_no_access_title: 'K tejto stránke možno nemáte prístup',
notfound_adaptive_no_access:
'Táto stránka možno neexistuje alebo k nej váš účet nemá prístup. Ak ste prešli z odkazu, skontrolujte, či je správny.',
notfound_adaptive_login_again: 'Prihlásiť sa znova',
notfound_goto_home: 'Prejsť na domovskú stránku',
notfound_suggestions_title: 'Možno hľadáte',
unexpected_error_title: 'Vyskytla sa chyba',
+7 -2
View File
@@ -78,10 +78,15 @@ export const sl: TranslationLanguage = {
notfound_title: 'Strani ni bilo mogoče najti',
notfound_link: 'Ta povezava kaže na stran, ki je bila odstranjena ali ne obstaja več.',
notfound: 'Stran, ki jo iščete, ne obstaja.',
notfound_adaptive_title: 'Stran ni na voljo',
notfound_adaptive: 'Ta stran morda obstaja, vendar se boste za dostop morda morali prijaviti.',
notfound_adaptive_title: 'Za ogled te strani se prijavite',
notfound_adaptive:
'Ta stran morda zahteva prijavo. Če ste sledili povezavi, preverite, ali je pravilna.',
notfound_adaptive_login: 'Prijava',
notfound_adaptive_registration_hint: 'Registracija je morda na voljo, odvisno od organizacije.',
notfound_adaptive_no_access_title: 'Morda nimate dostopa do te strani',
notfound_adaptive_no_access:
'Ta stran morda ne obstaja ali pa vaš račun nima dostopa do nje. Če ste sledili povezavi, preverite, ali je pravilna.',
notfound_adaptive_login_again: 'Ponovna prijava',
notfound_goto_home: 'Pojdi na domačo stran',
notfound_suggestions_title: 'Morda iščete',
unexpected_error_title: 'Prišlo je do napake',
+7 -2
View File
@@ -77,11 +77,16 @@ export const sv: TranslationLanguage = {
notfound_title: 'Sidan hittades inte',
notfound_link: 'Den här länken pekar på en sida som har tagits bort eller inte längre finns.',
notfound: 'Sidan du letar efter finns inte.',
notfound_adaptive_title: 'Sidan är inte tillgänglig',
notfound_adaptive: 'Sidan kan finnas, men du kan behöva logga in för att få åtkomst.',
notfound_adaptive_title: 'Logga in för att visa den här sidan',
notfound_adaptive:
'Den här sidan kan kräva att du loggar in. Om du följde en länk, kontrollera att den är korrekt.',
notfound_adaptive_login: 'Logga in',
notfound_adaptive_registration_hint:
'Registrering kan vara tillgänglig beroende på organisationen.',
notfound_adaptive_no_access_title: 'Du kanske inte har åtkomst till den här sidan',
notfound_adaptive_no_access:
'Den här sidan kanske inte finns, eller så har ditt konto inte åtkomst till den. Om du följde en länk, kontrollera att den är korrekt.',
notfound_adaptive_login_again: 'Logga in igen',
notfound_goto_home: 'Gå till startsidan',
notfound_suggestions_title: 'Du kanske letar efter',
unexpected_error_title: 'Ett fel uppstod',
+6 -2
View File
@@ -76,10 +76,14 @@ export const th: TranslationLanguage = {
notfound_title: 'ไม่พบหน้า',
notfound_link: 'ลิงก์นี้ชี้ไปยังหน้าที่ถูกลบหรือไม่มีอยู่อีกต่อไป',
notfound: 'หน้าที่คุณกำลังค้นหาไม่มีอยู่',
notfound_adaptive_title: 'หน้าไม่พร้อมใช้งาน',
notfound_adaptive: 'หน้านี้อาจมีอยู่ แต่คุณอาจต้องเข้าสู่ระบบเพื่อเข้าถึง',
notfound_adaptive_title: 'เข้าสู่ระบบเพื่อดูหน้านี้',
notfound_adaptive: 'หน้านี้อาจต้องเข้าสู่ระบบ หากคุณเปิดจากลิงก์ โปรดตรวจสอบว่าลิงก์ถูกต้อง',
notfound_adaptive_login: 'เข้าสู่ระบบ',
notfound_adaptive_registration_hint: 'การลงทะเบียนอาจใช้ได้ ขึ้นอยู่กับองค์กร',
notfound_adaptive_no_access_title: 'คุณอาจไม่มีสิทธิ์เข้าถึงหน้านี้',
notfound_adaptive_no_access:
'หน้านี้อาจไม่มีอยู่ หรือบัญชีของคุณอาจไม่มีสิทธิ์เข้าถึง หากคุณเปิดจากลิงก์ โปรดตรวจสอบว่าลิงก์ถูกต้อง',
notfound_adaptive_login_again: 'เข้าสู่ระบบอีกครั้ง',
notfound_goto_home: 'ไปที่หน้าแรก',
notfound_suggestions_title: 'คุณอาจกำลังมองหา',
unexpected_error_title: 'เกิดข้อผิดพลาด',
+7 -2
View File
@@ -77,10 +77,15 @@ export const tr: TranslationLanguage = {
notfound_title: 'Sayfa bulunamadı',
notfound_link: 'Bu bağlantı kaldırılmış veya artık mevcut olmayan bir sayfaya işaret ediyor.',
notfound: 'Aradığınız sayfa mevcut değil.',
notfound_adaptive_title: 'Sayfa kullanılamıyor',
notfound_adaptive: 'Bu sayfa mevcut olabilir, ancak erişmek için oturum açmanız gerekebilir.',
notfound_adaptive_title: 'Bu sayfayı görüntülemek için oturum açın',
notfound_adaptive:
'Bu sayfa oturum açmanızı gerektirebilir. Bir bağlantıyı izlediyseniz doğru olduğunu kontrol edin.',
notfound_adaptive_login: 'Oturum aç',
notfound_adaptive_registration_hint: 'Kayıt, kuruluşa bağlı olarak mevcut olabilir.',
notfound_adaptive_no_access_title: 'Bu sayfaya erişiminiz olmayabilir',
notfound_adaptive_no_access:
'Bu sayfa mevcut olmayabilir veya hesabınızın bu sayfaya erişimi olmayabilir. Bir bağlantıyı izlediyseniz doğru olduğunu kontrol edin.',
notfound_adaptive_login_again: 'Tekrar oturum aç',
notfound_goto_home: 'Ana sayfaya git',
notfound_suggestions_title: 'Şunu arıyor olabilirsiniz',
unexpected_error_title: 'Bir hata oluştu',
+7 -2
View File
@@ -77,10 +77,15 @@ export const uk: TranslationLanguage = {
notfound_title: 'Сторінку не знайдено',
notfound_link: 'Це посилання веде на сторінку, яку видалено або якої більше не існує.',
notfound: 'Сторінка, яку ви шукаєте, не існує.',
notfound_adaptive_title: 'Сторінка недоступна',
notfound_adaptive: 'Ця сторінка може існувати, але для доступу до неї може знадобитися вхід.',
notfound_adaptive_title: 'Увійдіть, щоб переглянути цю сторінку',
notfound_adaptive:
'Для перегляду цієї сторінки може знадобитися вхід. Якщо ви перейшли за посиланням, перевірте, чи воно правильне.',
notfound_adaptive_login: 'Увійти',
notfound_adaptive_registration_hint: 'Реєстрація може бути доступною залежно від організації.',
notfound_adaptive_no_access_title: 'Можливо, у вас немає доступу до цієї сторінки',
notfound_adaptive_no_access:
'Можливо, ця сторінка не існує або ваш обліковий запис не має до неї доступу. Якщо ви перейшли за посиланням, перевірте, чи воно правильне.',
notfound_adaptive_login_again: 'Увійти знову',
notfound_goto_home: 'Перейти на головну',
notfound_suggestions_title: 'Можливо, ви шукаєте',
unexpected_error_title: 'Сталася помилка',
+7 -2
View File
@@ -77,10 +77,15 @@ export const vi: TranslationLanguage = {
notfound_title: 'Không tìm thấy trang',
notfound_link: 'Liên kết này trỏ đến một trang đã bị xóa hoặc không còn tồn tại.',
notfound: 'Trang bạn đang tìm kiếm không tồn tại.',
notfound_adaptive_title: 'Trang không khả dụng',
notfound_adaptive: 'Trang này có thể tồn tại, nhưng bạn có thể cần đăng nhập để truy cập.',
notfound_adaptive_title: 'Đăng nhập để xem trang này',
notfound_adaptive:
'Trang này có thể yêu cầu bạn đăng nhập. Nếu bạn truy cập qua một liên kết, hãy kiểm tra xem liên kết đó có đúng không.',
notfound_adaptive_login: 'Đăng nhập',
notfound_adaptive_registration_hint: 'Việc đăng ký có thể khả dụng tùy theo tổ chức.',
notfound_adaptive_no_access_title: 'Có thể bạn không có quyền truy cập trang này',
notfound_adaptive_no_access:
'Trang này có thể không tồn tại hoặc tài khoản của bạn có thể không có quyền truy cập. Nếu bạn truy cập qua một liên kết, hãy kiểm tra xem liên kết đó có đúng không.',
notfound_adaptive_login_again: 'Đăng nhập lại',
notfound_goto_home: 'Đi tới trang chủ',
notfound_suggestions_title: 'Có thể bạn đang tìm',
unexpected_error_title: 'Đã xảy ra lỗi',
@@ -75,10 +75,14 @@ export const yue: TranslationLanguage = {
notfound_title: '搵唔到頁面',
notfound_link: '呢條連結指向嘅頁面已被移除或已經唔存在。',
notfound: '你搵緊嘅頁面唔存在。',
notfound_adaptive_title: '頁面無法使用',
notfound_adaptive: '呢個頁面可能存在,但你可能需要登入先可以存取。',
notfound_adaptive_title: '登入先可以睇呢個頁面',
notfound_adaptive: '呢個頁面可能需要登入。如果你係經連結過嚟,請檢查連結係咪正確。',
notfound_adaptive_login: '登入',
notfound_adaptive_registration_hint: '視乎機構,可能可以註冊。',
notfound_adaptive_no_access_title: '你可能冇權限存取呢個頁面',
notfound_adaptive_no_access:
'呢個頁面可能唔存在,或者你嘅帳戶可能冇權限存取。如果你係經連結過嚟,請檢查連結係咪正確。',
notfound_adaptive_login_again: '重新登入',
notfound_goto_home: '前往首頁',
notfound_suggestions_title: '你可能喺度搵緊',
unexpected_error_title: '發生錯誤',
@@ -75,10 +75,14 @@ export const zh_tw: TranslationLanguage = {
notfound_title: '找不到頁面',
notfound_link: '此連結指向的頁面已被移除或不再存在。',
notfound: '您要尋找的頁面不存在。',
notfound_adaptive_title: '頁面無法使用',
notfound_adaptive: '此頁面可能存在,但您可能需要登入才能存取。',
notfound_adaptive_title: '登入以檢視此頁面',
notfound_adaptive: '此頁面可能需要登入。如果您是透過連結前往,請確認連結是否正確。',
notfound_adaptive_login: '登入',
notfound_adaptive_registration_hint: '視組織而定,可能可以註冊。',
notfound_adaptive_no_access_title: '您可能沒有此頁面的存取權限',
notfound_adaptive_no_access:
'此頁面可能不存在,或您的帳戶可能沒有存取權限。如果您是透過連結前往,請確認連結是否正確。',
notfound_adaptive_login_again: '重新登入',
notfound_goto_home: '前往首頁',
notfound_suggestions_title: '您可能在尋找',
unexpected_error_title: '發生錯誤',
+6 -2
View File
@@ -76,10 +76,14 @@ export const zh: TranslationLanguage = {
notfound_title: '页面未找到',
notfound_link: '此链接指向已被删除或不再存在的页面。',
notfound: '您要找的页面不存在。',
notfound_adaptive_title: '页面无法访问',
notfound_adaptive: '该页面可能存在,但您可能需要登录后才能访问。',
notfound_adaptive_title: '登录以查看此页面',
notfound_adaptive: '此页面可能需要登录。如果您是通过链接访问的,请检查链接是否正确。',
notfound_adaptive_login: '登录',
notfound_adaptive_registration_hint: '视组织而定,可能可以注册。',
notfound_adaptive_no_access_title: '您可能没有此页面的访问权限',
notfound_adaptive_no_access:
'此页面可能不存在,或您的账户可能没有访问权限。如果您是通过链接访问的,请检查链接是否正确。',
notfound_adaptive_login_again: '重新登录',
notfound_goto_home: '前往首页',
notfound_suggestions_title: '您可能在寻找',
unexpected_error_title: '发生错误',
+24
View File
@@ -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 -3
View File
@@ -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]'],
+2
View File
@@ -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,
+10 -2
View File
@@ -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,
+1
View File
@@ -79,6 +79,7 @@ export interface GitBookDataFetcher {
getRevision(params: {
spaceId: string;
revisionId: string;
metadata?: boolean;
}): Promise<DataFetcherResponse<api.Revision>>;
/**
+328
View File
@@ -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();
});
});
+196
View File
@@ -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;
}
+219 -1
View File
@@ -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');
});
});
+112 -1
View File
@@ -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');
});
});
+69 -30
View File
@@ -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, {
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'bun:test';
import { renderToReadableStream } from 'react-dom/server';
import { parseOpenAPI } from '@gitbook/openapi-parser';
import type { OpenAPIClientContext } from './context';
import { OpenAPIRootSchema } from './OpenAPISchemaServer';
import { resolveOpenAPISchemas } from './schemas/resolveOpenAPISchemas';
import { translations } from './translations';
const context: OpenAPIClientContext = {
translation: translations.en,
icons: {
chevronDown: null,
chevronRight: null,
plus: null,
copy: null,
check: null,
lock: null,
mcp: null,
hashtag: null,
},
expandAllModelSections: true,
scalarRuntimeURL: '',
$$isClientContext$$: true,
};
describe('OpenAPIRootSchema', () => {
it('should stop at a self-reference behind a nullable union when expanded', async () => {
const { filesystem } = await parseOpenAPI({
value: JSON.stringify({
openapi: '3.1.0',
info: { title: 'Test', version: '1.0.0' },
paths: {},
components: {
schemas: {
FilterClause: {
type: 'object',
properties: {
AND: {
anyOf: [
{
type: 'array',
items: { $ref: '#/components/schemas/FilterClause' },
},
{ type: 'null' },
],
},
},
},
},
},
}),
rootURL: 'memory://spec.json',
});
const resolved = await resolveOpenAPISchemas(filesystem, { schemas: ['FilterClause'] });
const schema = resolved?.schemas[0]?.schema;
if (!schema) {
throw new Error('FilterClause not resolved');
}
const stream = await renderToReadableStream(
<OpenAPIRootSchema schema={schema} context={context} />
);
const html = await new Response(stream).text();
expect(html).toContain('AND');
});
});
+8
View File
@@ -190,6 +190,14 @@ describe('extractNonNullTypes', () => {
});
describe('normalizeNullableUnion', () => {
it('should return the same object for the same input', () => {
const schema: OpenAPIV3_1.SchemaObject = {
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }],
};
expect(normalizeNullableUnion(schema)).toBe(normalizeNullableUnion(schema));
});
it('should collapse anyOf with a single non-null member into a nullable schema', () => {
const schema: OpenAPIV3_1.SchemaObject = {
anyOf: [{ type: 'string' }, { type: 'null' }],
+14
View File
@@ -287,6 +287,10 @@ function isNullSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject
return type === 'null';
}
// Circular-ref tracking compares schemas by identity: a fresh copy on every render lets a
// self-referencing nullable property (`anyOf: [{ items: Self }, null]`) recurse forever when expanded.
const normalizedNullableUnions = new WeakMap<OpenAPIV3.SchemaObject, OpenAPIV3.SchemaObject>();
/**
* Normalize the OpenAPI 3.1+ idiom of expressing nullability through `anyOf`/`oneOf`
* (e.g. `anyOf: [{ type: 'string' }, { type: 'null' }]`) into a regular nullable schema,
@@ -301,7 +305,17 @@ export function normalizeNullableUnion(
schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject
): OpenAPIV3.SchemaObject {
const typed = schema as OpenAPIV3.SchemaObject;
const cached = normalizedNullableUnions.get(typed);
if (cached) {
return cached;
}
const normalized = normalizeNullableUnionUncached(typed);
normalizedNullableUnions.set(typed, normalized);
return normalized;
}
function normalizeNullableUnionUncached(typed: OpenAPIV3.SchemaObject): OpenAPIV3.SchemaObject {
const isAnyOf = Array.isArray(typed.anyOf);
const isOneOf = !isAnyOf && Array.isArray(typed.oneOf);
if (!isAnyOf && !isOneOf) {