Compare commits

..

2 Commits

Author SHA1 Message Date
Peter White c3df458378 Keep inline code readable over a background page cover (#4778) 2026-11-13 13:28:17 +01:00
conico974 ddccd3d4f0 Bump Next.js to 16.4.6 and OpenNext to 1.20.17 (#4776)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 13:28:03 +01:00
69 changed files with 1269 additions and 368 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Bump Next.js to 16.4.6 and OpenNext (Cloudflare 1.20.17, AWS 4.1.16), keeping the dev-mode OOM patch.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep inline code and highlighted text readable when a paragraph crosses the bottom edge of a background page cover.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Send each search once on sites without a top header, and make search work between 768 and 1023 px wide.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Show one server-ordered search list and open its top result on Enter.
+27 -45
View File
@@ -8,7 +8,7 @@
"@changesets/cli": "^2.31.1",
"oxfmt": "^0.62.0",
"oxlint": "^1.93.0",
"turbo": "^2.11.10",
"turbo": "^2.11.11",
"vercel": "50.37.3",
},
},
@@ -128,8 +128,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.15",
"@opennextjs/cloudflare": "1.20.16",
"@opennextjs/aws": "4.1.16",
"@opennextjs/cloudflare": "1.20.17",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -159,7 +159,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.4.5",
"next": "^16.4.6",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -341,9 +341,9 @@
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"next@16.4.5": "patches/next@16.4.5.patch",
"@shikijs/langs@3.21.0": "patches/@shikijs%2Flangs@3.21.0.patch",
"mermaid@11.16.1": "patches/mermaid@11.16.1.patch",
"next@16.4.6": "patches/next@16.4.6.patch",
},
"overrides": {
"@codemirror/state": "6.4.1",
@@ -888,29 +888,29 @@
"@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.4.5", "", {}, "sha512-JwSPwpdkZL5VMl2jA/q+JI8MZdweAL3xmw5/tb9y/TLtmS0YXZnmYDzSihwk8xTj1OL+WM53gTqaj96yuLp6Xw=="],
"@next/env": ["@next/env@16.4.6", "", {}, "sha512-SunBjJ6t+oBtecS3dTjdVuMhUogP5wD/vrGL5EVuQYM1iS4O5ilL51HkiJ3sPjMzRHiCChvr4dbaSJbR7F3lxA=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W5TniBzWKASbNNwHZ/S/f1MxQUXU/LdjklVMWA0Ujbfl7SZnUzFls45hO0i/sF7Bx1jJBWp7WWmoQajXtX/O8A=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.4.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kJ4VVZkley17xKaJx72QN2IBqjrj7zenCQZaUF0l9ShS/1Mt2kW4+Z5KGG6vgr3L/J562xTOizVyM1iA/u5nlg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-uWq4qkHapBDS1oFltWS4YxTb9pLHAtLNcpbuK6SmRsebLYZqpBML/j/C0op+50JYGTpZgVL/DWNt/6oWlamrrw=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.4.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-eIPGZIqC+8JKaeZmvnCANJQF4xWkkHqSnAAajtFxgwDMAOnCgxNvP52SeWZ41PyVwoe+Le/NQuZSaQ8j7YshSA=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-FdRcyOx5u5P51mlAqLB9EXdd8jZO5fsdHtk4BAQSkqOywCsbTNN/rmw+jO7pnmjNdAtu2AG46NzbCL2FPDrrgw=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9nI6O8vvczH2D9ZNdMGqb/3NuT15T9ksOZnAmx9/gjF2cOi74+xxyf1Hk96uIMfm14c9y6wHeBq6SH8g6udvcA=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-cyvmmgtRXR8aGVT86wi9Py53IlfyCx7P2fZxuAXAoTummDnhKtUyjrMeXfapqp4qgD+gT5RqyGD/VfA16jEK7Q=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-7/qPhTxDp2ScluOJOhLuhWmlA17o1yZ0SIgpISBCpBxXgtnK4+KGywBpFkfLPiU1rnSM1faFGzYkH6i7JdTrqA=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-hPc+Fl+N22DAvqZHq559NmamXboOX5Lu8I5Wl0DezfrhTk1Zz21NQeYoZDmNI/NjTcrdsVxM72Fzn2lI+Q+vAw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oOpbNa8XOW1qCGd9VvzC4fbkkRxEls7/WI6tz+YHC0xRA7KpV1U7DrAnCPjzVyOk/karzWEepCOcTIDmwK8FwQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-gbACYE8IP/jlprNTpiWXHb8N9CofOzpnEwH2SrIz1dUbiJf1FbDssDupCAPeP1Taxe8mgG20sGZamJI7KFOdZw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-aM8CZ+OR2EwXaAYw7KGkW2GDmNDsLBIRe1MMRZCJPTdw/xhTD0IMUVO4sf3KqrzuRouGzwwLZn34+gFeVXiUQA=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-+8gj++ZMo8H20Xh2ijjNqyFk5LPLw0AC4Fs4Tq/G94rjjQXhOjN4jgSWsWKPFsyY26hfIeemJIwKtMxOsp5GBA=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.4.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-U0p7/tEtqvHX2crhd841CDwKtp5CFkKXVjOhmfccR3Pvp64sj8KBwRqsvltqLOwVBbj0NtkxwXSDpEHYGbxeXQ=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-qNZj66pYXnaWLiF9R9GUbfBdrCv7kC6TaZfIwB2KH5WPc/8pEM3nVOj8hpQhWJU8S94rlH5MPDcX15+RrgUhiw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-3ZG15/5WIdlTQsIEvc9sPTTSL5XFrlLklIKmfF4fwsDnoAhu6NcBDXsk3mbGxNyo6aVLEGex+fHCv2MahY7a9g=="],
"@node-minify/core": ["@node-minify/core@9.0.2", "", { "dependencies": { "@node-minify/utils": "9.0.1", "glob": "10.3.3", "mkdirp": "3.0.1" } }, "sha512-FNhv29Wom6wKrrFKaeAfmZqz7TX5A1E6P+bpd0VIc+DYWMLUIhAViS8riaZg3A1oD0s06s+5BG2Fg7RqMKiKHw=="],
"@node-minify/core": ["@node-minify/core@10.5.0", "", { "dependencies": { "@node-minify/utils": "10.5.0" } }, "sha512-duptyliRQY+fXszqbm5CP+NauASUwkOlx/c4bwlT7tj5AQWhG9F26D3bfU/okKto9RDTGJy3cLwPvJC/j9lCkA=="],
"@node-minify/terser": ["@node-minify/terser@9.0.1", "", { "dependencies": { "@node-minify/utils": "9.0.1", "terser": "5.36.0" } }, "sha512-WF78Ex+/xNZZDYvzwB7+sLUYQbJzyyS36ZjMhVqhORejHOsoOcTjQ9TdOgKZoY7wlfxjnLxeMKkO8R/R1KL9aQ=="],
"@node-minify/terser": ["@node-minify/terser@10.5.0", "", { "dependencies": { "@node-minify/utils": "10.5.0", "terser": "5.46.0" } }, "sha512-NvR9vYMQ/exd2lNKQ9TtoJD98gqpzQu0+bIZRqA3mci38hmacPr7H5cP4coh1vz5ZZp+WkYSrOwCm2FiC2P5MQ=="],
"@node-minify/utils": ["@node-minify/utils@9.0.1", "", { "dependencies": { "gzip-size": "6.0.0" } }, "sha512-aC1+mhKTP3IMa2VcuGl3ui92LO/7CPQWldNGzu3BVGKiMNJ70AKJW/R6huuYCSuQyHDGM9oFwiVClsZnFxn67g=="],
"@node-minify/utils": ["@node-minify/utils@10.5.0", "", { "dependencies": { "fast-glob": "^3.3.3", "gzip-size": "7.0.0" } }, "sha512-3DZEYgersSgia5V/cee35sw6B7TTZlcCKyAGiuhT274/r1obMEtHt1lRmyCd52uX5QlZqDOTrD4bqHCxKzIYLg=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -934,9 +934,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.15", "", { "dependencies": { "@ast-grep/napi": "^0.45.3", "@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": "^9.0.2", "@node-minify/terser": "^9.0.1", "@tsconfig/node18": "^18.2.7", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.1.1", "esbuild": "0.28.2", "express": "^5.2.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.9.1" }, "peerDependencies": { "diff": "^9.0.0", "next": ">=15.5.29 <16 || >=16.4.5" }, "optionalPeers": ["diff"], "bin": { "open-next": "dist/index.js" } }, "sha512-ulv7rFEAeieD0d/zDMnw4IFPU+zY/atVUhn7ezgFqIurLB5oBYeHCUGlcGk3Pg7uMPeI03SNRjHW1+xL9EwMPw=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.16", "", { "dependencies": { "@ast-grep/napi": "^0.45.3", "@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": "^10.5.0", "@node-minify/terser": "^10.5.0", "@tsconfig/node18": "^18.2.7", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.1.1", "esbuild": "0.28.2", "express": "^5.2.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.9.1" }, "peerDependencies": { "diff": "^9.0.0", "next": ">=15.5.29 <16 || >=16.4.5" }, "optionalPeers": ["diff"], "bin": { "open-next": "dist/index.js" } }, "sha512-eHb6/LCrpr+3DmbmE+/q5GkcrbSs5IRpO/O0kcay7FK5WYDhcML0K5NhO7I9H62z42wscVIi75Hv3ASsXDeEGw=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.16", "", { "dependencies": { "@ast-grep/napi": "^0.45.3", "@dotenvx/dotenvx": "^2.33.0", "@opennextjs/aws": "4.1.15", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^13.0.6", "ts-tqdm": "^0.8.6", "yargs": "^18.2.0" }, "peerDependencies": { "next": ">=15.5.29 <16 || >=16.4.5", "rclone.js": "^0.6.6", "wrangler": "^4.148.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-3c1Cwex5e2jXy4o4RZAusy8o9Uz4eowuPCjdVLYEZ/MnkAzN2jh9dOgsRjgu3wjigXuNe8rmPECmOTo+xKQHsw=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.17", "", { "dependencies": { "@ast-grep/napi": "^0.45.3", "@dotenvx/dotenvx": "^2.33.0", "@opennextjs/aws": "4.1.16", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^13.0.6", "resolve.exports": "^2.0.4", "ts-tqdm": "^0.8.6", "yargs": "^18.2.0" }, "peerDependencies": { "next": ">=15.5.29 <16 || >=16.4.5", "rclone.js": "^0.6.6", "wrangler": "^4.148.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-jGEC9ue8LLHvOFXp62LjDUSSz7JpD+PQB+Y3r0lU78PtQ84wI16+EMF9TXEDkTek3g3fGWTLTI07pbZt8/gj8w=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -2124,7 +2124,7 @@
"focus-trap": ["focus-trap@7.6.1", "", { "dependencies": { "tabbable": "^6.2.0" } }, "sha512-nB8y4nQl8PshahLpGKZOq1sb0xrMVFSn6at7u/qOsBZTlZRzaapISGENcB6mOkoezbClZyiMwEF/dGY8AZ00rA=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="],
"form-data": ["form-data@4.0.1", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw=="],
@@ -2190,7 +2190,7 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="],
"gzip-size": ["gzip-size@7.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA=="],
"hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
@@ -2338,7 +2338,7 @@
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jackspeak": ["jackspeak@2.3.6", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="],
@@ -2636,7 +2636,7 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"next": ["next@16.4.5", "", { "dependencies": { "@next/env": "16.4.5", "@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.4.5", "@next/swc-darwin-x64": "16.4.5", "@next/swc-linux-arm64-gnu": "16.4.5", "@next/swc-linux-arm64-musl": "16.4.5", "@next/swc-linux-x64-gnu": "16.4.5", "@next/swc-linux-x64-musl": "16.4.5", "@next/swc-win32-arm64-msvc": "16.4.5", "@next/swc-win32-x64-msvc": "16.4.5", "sharp": "^0.35.5" }, "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-rmdfsWosjTUO+6R0S3KokoIk6Kj0sWDRTe86tZHdqaWH/oXq/udX3/4xb+YQi7aWbyEFoglDU54kaifLCdXbBw=="],
"next": ["next@16.4.6", "", { "dependencies": { "@next/env": "16.4.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.4.6", "@next/swc-darwin-x64": "16.4.6", "@next/swc-linux-arm64-gnu": "16.4.6", "@next/swc-linux-arm64-musl": "16.4.6", "@next/swc-linux-x64-gnu": "16.4.6", "@next/swc-linux-x64-musl": "16.4.6", "@next/swc-win32-arm64-msvc": "16.4.6", "@next/swc-win32-x64-msvc": "16.4.6", "sharp": "^0.35.5" }, "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-QZJiYhaZLJMo2KpkQGQIwvmd79WVsAWeYIU554a8d7zArS3si/IRy3lBNV/rtYhE70oLwdLYhPAXLPx8HwcwtA=="],
"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=="],
@@ -2902,7 +2902,7 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"resolve.exports": ["resolve.exports@2.0.4", "", {}, "sha512-zepuGL0ulf+nt+XaFrlKeaiTJeyPFPLLY62+ncq1/VTHBdt3HsfXOH60priZ6tnXQozdGcDeIF5JVTanvIcYWQ=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
@@ -3072,7 +3072,7 @@
"term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="],
"terser": ["terser@5.36.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w=="],
"terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
@@ -3448,8 +3448,6 @@
"@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
"@node-minify/core/glob": ["glob@10.3.3", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.0.3", "minimatch": "^9.0.1", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", "path-scurry": "^1.10.1" }, "bin": { "glob": "dist/cjs/src/bin.js" } }, "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw=="],
"@opennextjs/aws/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"@oxc-transform/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
@@ -3616,6 +3614,8 @@
"@vercel/backends/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"@vercel/backends/resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"@vercel/backends/rolldown": ["rolldown@1.0.0-rc.1", "", { "dependencies": { "@oxc-project/types": "=0.110.0", "@rolldown/pluginutils": "1.0.0-rc.1" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.1", "@rolldown/binding-darwin-arm64": "1.0.0-rc.1", "@rolldown/binding-darwin-x64": "1.0.0-rc.1", "@rolldown/binding-freebsd-x64": "1.0.0-rc.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.1", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.1", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.1", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.1", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.1", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.1", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.1", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.1", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-M3AeZjYE6UclblEf531Hch0WfVC/NOL43Cc+WdF3J50kk5/fvouHhDumSGTh0oRjbZ8C4faaVr5r6Nx1xMqDGg=="],
"@vercel/backends/zod": ["zod@3.22.4", "", {}, "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg=="],
@@ -3790,8 +3790,6 @@
"finalhandler/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"foreground-child/cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"global-prefix/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="],
"globby/fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="],
@@ -4060,10 +4058,6 @@
"@jridgewell/source-map/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@node-minify/core/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@node-minify/core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"@opennextjs/aws/express/body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="],
"@opennextjs/aws/express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],
@@ -4426,10 +4420,6 @@
"remark-gfm/mdast-util-gfm/mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ=="],
"rimraf/glob/foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="],
"rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"rimraf/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"rimraf/glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
@@ -4482,12 +4472,6 @@
"@headlessui/react/@floating-ui/react/@floating-ui/react-dom/@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
"@node-minify/core/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"@node-minify/core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@node-minify/core/glob/path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"@opennextjs/aws/express/body-parser/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"@opennextjs/aws/express/mime-types/mime-db": ["mime-db@1.53.0", "", {}, "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg=="],
@@ -4570,8 +4554,6 @@
"@headlessui/react/@floating-ui/react/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
"@node-minify/core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@opennextjs/aws/express/body-parser/raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"@opennextjs/aws/express/qs/side-channel/object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
+1 -1
View File
@@ -68,7 +68,7 @@
"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",
"mermaid@11.16.1": "patches/mermaid@11.16.1.patch",
"next@16.4.5": "patches/next@16.4.5.patch",
"next@16.4.6": "patches/next@16.4.6.patch",
"@shikijs/langs@3.21.0": "patches/@shikijs%2Flangs@3.21.0.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.15",
"@opennextjs/cloudflare": "1.20.16",
"@opennextjs/aws": "4.1.16",
"@opennextjs/cloudflare": "1.20.17",
"@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.4.5",
"next": "^16.4.6",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -125,6 +125,9 @@ function Color(props: MarkedLeafProps<DocumentMarkColor>) {
const { mark, children } = props;
return (
<span
data-highlight={
mark.data.background && mark.data.background !== 'default' ? '' : undefined
}
className={tcls([
textColorToStyle[mark.data.text ?? 'default'],
backgroundColorToStyle[mark.data.background ?? 'default'],
@@ -50,7 +50,6 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
error,
fetching,
onInputKeyDown,
inputHandlers,
onResultSelect,
query,
results,
@@ -90,7 +89,6 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
{...inputHandlers}
value={searchValue}
withAI={withSearchAI}
isOpen={true}
@@ -508,6 +508,18 @@ html.dark .highlight-line.diff-deleted .highlight-line-content::before {
@apply text-contrast-cover;
}
}
/*
Inline code and highlights sit on their own background, not the cover, so they keep the regular
text color. Inherited, the transparent fill of a split element hides their text behind that
background. Kept out of the utilities layer so a highlight's own text color still applies.
*/
@media not (prefers-contrast: more) {
[data-over-cover] :is(code, [data-highlight]) {
color: var(--cover-text-below);
-webkit-text-fill-color: currentcolor;
}
}
}
/* Zoomable images */
@@ -0,0 +1,96 @@
'use client';
import { Icon, type IconName } from '@gitbook/icons';
import { Emoji } from '../primitives/Emoji/Emoji';
import { Link } from '../primitives/Link';
import type { LocalPageResult } from './useLocalSearchResults';
import { tcls } from '@/lib/tailwind';
/**
* Renders local search results above the main server results.
*
* Layout:
* - While server results are still fetching → 2-column wrapping row (cards fill width)
* - Once server results are ready → single horizontal scrollable row (fixed-width cards)
*
* The card width is CSS-transitioned so the switch between the two layouts animates smoothly.
*/
export function LocalSearchResults({
results,
fetching,
}: {
results: LocalPageResult[];
fetching: boolean;
}) {
if (results.length === 0) return null;
return (
<div
className={tcls(
'py-2',
'flex',
'flex-row',
'gap-2',
fetching
? 'flex-wrap'
: ['overflow-x-auto', '[scrollbar-width:none]', '[&::-webkit-scrollbar]:hidden']
)}
>
{results.map((result) => (
<LocalSearchResultCard key={result.id} result={result} fetching={fetching} />
))}
</div>
);
}
function LocalSearchResultCard({
result,
fetching,
}: {
result: LocalPageResult;
fetching: boolean;
}) {
return (
<Link
href={result.pathname}
className={tcls(
'group',
'flex',
'flex-col',
'gap-1',
'p-3',
'rounded-corners:rounded-lg',
'circular-corners:rounded-2xl',
'text-tint',
'bg-tint-subtle',
'hover:bg-tint',
'hover:text-tint-strong',
'transition-colors',
'cursor-pointer',
fetching ? ['basis-[calc(50%-4px)]', 'min-w-0'] : ['w-40', 'shrink-0']
)}
>
<div className={tcls('flex', 'flex-row', 'items-center', 'gap-1.5', 'min-w-0')}>
{result.emoji ? (
<span className="size-4 shrink-0 text-tint-subtle">
<Emoji code={result.emoji} style="text-base leading-none" />
</span>
) : result.icon ? (
<span className="size-4 shrink-0 text-tint-subtle">
<Icon icon={result.icon as IconName} className="size-4" />
</span>
) : null}
<p className="grow truncate font-heading text-sm font-semibold leading-snug text-tint-strong">
{result.title}
</p>
<span className="ml-auto shrink-0 text-tint-subtle">
<Icon icon="chevron-right" className="size-3" />
</span>
</div>
{result.description ? (
<p className="line-clamp-2 text-xs leading-snug">{result.description}</p>
) : null}
</Link>
);
}
@@ -43,9 +43,6 @@ export function SearchContainer({
const [searchPopup, setSearchPopup] = React.useState<HTMLDivElement | null>(null);
const language = useLanguage();
const usesSideSheet = useIsMobile(768);
// The header and sidebar copies are shown and hidden by CSS at the `lg` breakpoint.
const belowLg = useIsMobile();
const visible = viewport === 'desktop' ? !belowLg : viewport === 'mobile' ? belowLg : true;
const {
assistants,
askQuery,
@@ -56,7 +53,6 @@ export function SearchContainer({
error,
fetching,
onInputKeyDown,
inputHandlers,
onResultSelect,
open,
query,
@@ -70,7 +66,7 @@ export function SearchContainer({
withAI,
withSearchAI,
scopeControl,
} = useSearchController(searchProps, { requestsEnabled: visible });
} = useSearchController(searchProps);
const uiAssistants = assistants.filter((assistant) => assistant.ui === true);
useHotkeys(
@@ -118,6 +114,8 @@ export function SearchContainer({
};
}, [close]);
const visible =
viewport === 'desktop' ? !usesSideSheet : viewport === 'mobile' ? usesSideSheet : true;
const searchResultsActiveDescendant =
cursor !== null && cursor < results.length ? `${resultsId}-${cursor}` : undefined;
const isSearchOpen = Boolean(visible && (state?.open ?? false));
@@ -151,7 +149,6 @@ export function SearchContainer({
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
{...inputHandlers}
value={searchValue}
withAI={withSearchAI}
isOpen={isSearchOpen}
@@ -218,7 +215,6 @@ export function SearchContainer({
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
{...inputHandlers}
value={searchValue}
withAI={withSearchAI}
isOpen={isSearchOpen}
@@ -13,8 +13,6 @@ interface SearchInputProps {
onChange: (value: string) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onFocus?: () => void;
onInput?: (event: React.FormEvent<HTMLInputElement>) => void;
onCompositionEnd?: (event: React.CompositionEvent<HTMLInputElement>) => void;
value: string;
withAI: boolean;
isOpen: boolean;
@@ -2,18 +2,24 @@ import React from 'react';
import { Icon, type IconName } from '@gitbook/icons';
import { SkeletonParagraph } from '../primitives';
import { Tooltip } from '../primitives';
import { Emoji } from '../primitives/Emoji/Emoji';
import { getPageResultPresentation } from './getPageResultHref';
import { HighlightQuery } from './HighlightQuery';
import type { MergedPageResult } from './reciprocalRankFusion';
import type { ComputedPageResult } from './search-types';
import { SearchResultItem } from './SearchResultItem';
import type { LocalPageResult } from './useLocalSearchResults';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
type PageItem = ComputedPageResult | LocalPageResult | MergedPageResult;
export const SearchPageResultItem = React.forwardRef(function SearchPageResultItem(
props: {
query: string;
item: ComputedPageResult;
item: PageItem;
active: boolean;
style?: React.CSSProperties;
},
@@ -22,19 +28,46 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
const { query, item, active, style, ...rest } = props;
const language = useLanguage();
const { preview: bestSection, description, href } = getPageResultPresentation(item);
const { bestSection, description, href } = (() => {
if (item.type === 'page') {
const presentation = getPageResultPresentation(item);
return {
bestSection: presentation.preview,
description: presentation.description,
href: presentation.href,
};
}
return { bestSection: undefined, description: item.description, href: item.pathname };
})();
const preview = bestSection
? [bestSection.title, bestSection.body].filter(Boolean).join(' · ')
: undefined;
const insights = {
type: 'search_open_result' as const,
query,
result: {
pageId: item.pageId,
spaceId: item.spaceId,
},
};
const emoji = 'emoji' in item ? item.emoji : undefined;
const icon = 'icon' in item ? item.icon : undefined;
const leadingIcon = emoji ? (
<span className="flex size-4 shrink-0 items-center justify-center">
<Emoji code={emoji} style="text-base leading-none" />
</span>
) : icon ? (
<Icon icon={icon as IconName} className="size-4" />
) : (
<Icon icon="file-lines" className="size-4" />
);
const insights =
item.type === 'page'
? {
type: 'search_open_result' as const,
query,
result: {
pageId: item.pageId,
spaceId: item.spaceId,
},
}
: undefined;
return (
<SearchResultItem
@@ -43,7 +76,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
active={active}
data-testid="search-page-result"
action={tString(language, 'view')}
leadingIcon={<Icon icon="file-lines" className="size-4" />}
leadingIcon={leadingIcon}
insights={insights}
aria-label={tString(language, 'search_page_result_title', item.title)}
style={{
@@ -58,7 +91,11 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
<div
className={tcls(
'relative h-5 w-full transition-[height] duration-300',
!preview && !description ? 'h-0' : ''
item.type === 'local-page' && !preview && !description
? '[[aria-busy=false]_&]:h-0'
: !preview && !description
? 'h-0'
: ''
)}
style={{ transitionDelay: style?.animationDelay }}
>
@@ -82,15 +119,26 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
<HighlightQuery query={query} text={description} />
</p>
) : null}
{item.type === 'local-page' ? (
<SkeletonParagraph
size="small"
lines={1}
className={tcls(
'absolute inset-0 origin-left',
preview || description
? 'hidden animate-blur-out'
: '[[aria-busy=false]_&]:hidden [[aria-busy=false]_&]:animate-blur-out'
)}
style={{ animationDelay: style?.animationDelay }}
/>
) : null}
</div>
</SearchResultItem>
);
});
const Breadcrumbs = (props: {
breadcrumbs: ComputedPageResult['breadcrumbs'];
withOverflow?: boolean;
}) => {
const Breadcrumbs = (props: { breadcrumbs: PageItem['breadcrumbs']; withOverflow?: boolean }) => {
const { breadcrumbs, withOverflow = (breadcrumbs?.length ?? 0) > 4 } = props;
if (!breadcrumbs || breadcrumbs.length === 0) return null;
@@ -19,12 +19,12 @@ import { tcls } from '@/lib/tailwind';
export interface SearchResultsRef {
select(): boolean;
/** Activate the first result, ignoring the cursor. */
selectFirst(): boolean;
}
function getResultKey(item: ResultType): string {
switch (item.type) {
case 'local-page':
return `page:${item.id}`;
case 'page':
return `page:${item.pageId}`;
case 'record':
@@ -107,11 +107,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
ref,
() => ({
select,
selectFirst: () => {
const first = refs.current[0];
first?.click();
return Boolean(first);
},
}),
[select]
);
@@ -119,7 +114,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
const { assistants } = useAI();
const primaryAssistant = assistants[0];
if (error) {
if (error && results.length === 0) {
return (
<output
className={tcls(
@@ -162,6 +157,26 @@ export const SearchResults = React.forwardRef(function SearchResults(
</output>
);
const partialError = error ? (
<div
className={tcls(
'mt-3',
'rounded-corners:rounded-md',
'circular-corners:rounded-2xl',
'bg-tint-subtle',
'px-3',
'py-2',
'text-center',
'text-sm',
'text-tint-subtle',
'animate-blur-in-slow'
)}
role="status"
>
{t(language, 'search_partial_error')}
</div>
) : null;
return (
<output className="flex grow flex-col" aria-busy={fetching}>
{children}
@@ -187,7 +202,10 @@ export const SearchResults = React.forwardRef(function SearchResults(
const handleResultSelect = (
event: React.MouseEvent<HTMLAnchorElement>
) => {
const isPageResult = item.type === 'page' || item.type === 'record';
const isPageResult =
item.type === 'local-page' ||
item.type === 'page' ||
item.type === 'record';
if (
isPageResult &&
@@ -220,6 +238,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
onClickCapture: handleResultSelect,
};
switch (item.type) {
case 'local-page':
case 'page': {
return (
<motion.div
@@ -334,6 +353,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
{!fetching && results.length === 0 ? noResults : null}
</>
)}
{partialError}
{fetching ? (
<div
className={tcls(
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { computeFilterSiteSpaceIds, filterResultsBySiteSpaces } from './filter';
import type { OrderedComputedResult } from './search-types';
import { computeFilterSiteSpaceIds } from './filter';
describe('computeFilterSiteSpaceIds', () => {
const currentId = 'space-current';
@@ -35,31 +34,3 @@ describe('computeFilterSiteSpaceIds', () => {
expect(r).toEqual(['space-other-a', 'space-other-b']);
});
});
describe('filterResultsBySiteSpaces', () => {
const page = (id: string, siteSpaceId?: string): OrderedComputedResult => ({
type: 'page',
id,
title: id,
href: `/${id}`,
score: 1,
pageId: id,
spaceId: 'space',
siteSpaceId,
description: '',
rank: 1,
});
it('keeps the server order and drops pages outside the scope', () => {
const results = [page('a', 'current'), page('b', 'other'), page('c', 'current'), page('d')];
expect(filterResultsBySiteSpaces(results, ['current']).map((result) => result.id)).toEqual([
'a',
'c',
]);
});
it('returns every result when the scope is unrestricted', () => {
const results = [page('b', 'other'), page('a', 'current')];
expect(filterResultsBySiteSpaces(results, undefined)).toBe(results);
});
});
@@ -1,13 +1,16 @@
import type { OrderedComputedResult } from './search-types';
import type { SearchScope } from './useSearch';
/**
* Site spaces whose pages may be shown for a UI search scope, or `undefined` for no restriction.
* Map UI search scope to local FlexSearch filtering.
*
* The server's "default" scope also returns pages from other site spaces. Without sections
* (only variants) the UI presents "default" as the current variant, so it is restricted to
* the current `siteSpaceId`. An explicitly selected section takes precedence.
* Rationale: when a site has no sections (only variants), the remote search
* "default" scope effectively queries the current variant. To keep local
* results consistent with remote ones, we also restrict "default" to the
* current `siteSpaceId` when `withSections` is false. Otherwise (sections
* present), local "default" stays unfiltered to match the broader section UI.
* An explicitly selected section takes precedence over these legacy scopes.
*/
export function computeFilterSiteSpaceIds(
scope: SearchScope,
siteSpaceId: string,
@@ -28,21 +31,3 @@ export function computeFilterSiteSpaceIds(
return withSections ? undefined : [siteSpaceId];
}
}
/** Keep the server's order, dropping pages owned by a site space outside the scope. */
export function filterResultsBySiteSpaces(
results: OrderedComputedResult[],
allowedSiteSpaceIds: string[] | undefined
): OrderedComputedResult[] {
if (!allowedSiteSpaceIds) {
return results;
}
const allowed = new Set(allowedSiteSpaceIds);
return results.filter(
(result) =>
result.type === 'record' ||
(result.siteSpaceId !== undefined && allowed.has(result.siteSpaceId))
);
}
@@ -5,4 +5,5 @@ export * from './SearchContainer';
export * from './SearchScopeControl';
export * from './search-props';
export * from './useSearch';
export * from './useLocalSearchResults';
export * from './useSearchController';
@@ -0,0 +1,239 @@
import { describe, expect, it } from 'bun:test';
import {
type MergedPageResult,
fuseSearchResults,
getResultKey,
reciprocalRankFusion,
} from './reciprocalRankFusion';
import type { OrderedComputedResult } from './search-types';
import type { LocalPageResult } from './useLocalSearchResults';
function localPage(id: string, title = id, siteSpaceId = 'site-space-alpha'): LocalPageResult {
return {
type: 'local-page',
id,
siteSpaceId,
title,
pathname: `/${id}`,
description: `Local description for ${title}`,
breadcrumbs: [{ label: 'Local', icon: 'book-open' }],
};
}
function remotePage(
id: string,
title = id,
score = 0,
rank = 1,
spaceId = 'space-alpha',
siteSpaceId = 'site-space-alpha'
): OrderedComputedResult {
return {
type: 'page',
id: `remote-${id}`,
pageId: id,
spaceId,
siteSpaceId,
title,
description: `Remote description for ${title}`,
href: `/${id}`,
score,
rank,
breadcrumbs: [{ label: 'Remote' }],
};
}
function remoteRecord(id: string, title = id): OrderedComputedResult {
return {
type: 'record',
id,
title,
href: `/records/${id}`,
score: 0,
description: undefined,
};
}
describe('reciprocalRankFusion', () => {
it('keeps the first 3 remote results first and in remote order', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[
remotePage('remote-1'),
remoteRecord('remote-2'),
remotePage('remote-3'),
remotePage('remote-4'),
],
'alpha'
);
expect(results.slice(0, 3).map(getResultKey)).toEqual([
'page:remote-1',
'record:remote-2',
'page:remote-3',
]);
});
it('applies fusion only after the pinned remote results', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[
remotePage('remote-1'),
remotePage('remote-2'),
remotePage('remote-3'),
remotePage('remote-4'),
],
'alpha'
);
expect(results.slice(0, 3).map(getResultKey)).toEqual([
'page:remote-1',
'page:remote-2',
'page:remote-3',
]);
const firstFusedResult = results[3];
if (!firstFusedResult) {
throw new Error('Expected a fused result after the pinned remote results');
}
expect(getResultKey(firstFusedResult)).toBe('page:local-match');
});
it('pins all remote results when fewer than 3 are present', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[remoteRecord('remote-1'), remotePage('remote-2')],
'alpha'
);
expect(results.map(getResultKey)).toEqual([
'record:remote-1',
'page:remote-2',
'page:local-match',
]);
});
it('merges a pinned remote page with the matching local page without duplicating it', () => {
const results = reciprocalRankFusion(
[localPage('remote-1', 'Local title')],
[
remotePage('remote-1', 'Remote title', 1, 1),
remotePage('remote-2'),
remotePage('remote-3'),
],
'remote'
);
const pinnedResult = results[0] as MergedPageResult;
expect(pinnedResult.type).toBe('page');
expect(pinnedResult.title).toBe('Remote title');
expect(pinnedResult.pathname).toBe('/remote-1');
expect(pinnedResult.description).toBe('Remote description for Remote title');
expect(pinnedResult.rank).toBe(1);
expect(pinnedResult.breadcrumbs).toEqual([{ label: 'Local', icon: 'book-open' }]);
expect(results.map(getResultKey).filter((key) => key === 'page:remote-1')).toHaveLength(1);
});
it('does not duplicate pinned records in the fused tail', () => {
const results = reciprocalRankFusion(
[],
[
remoteRecord('record-1'),
remotePage('remote-2'),
remotePage('remote-3'),
remoteRecord('record-1'),
remotePage('remote-4'),
],
'remote'
);
expect(results.map(getResultKey).filter((key) => key === 'record:record-1')).toHaveLength(
1
);
});
});
describe('fuseSearchResults', () => {
it('filters every source to the current site space before fusion', () => {
const results = fuseSearchResults({
localResults: [
localPage('local-alpha', 'Alpha local result'),
localPage('remote-alpha-1', 'Beta local result', 'site-space-beta'),
],
remoteResults: [
remotePage('remote-alpha-1', 'Alpha remote one', 10, 1),
remotePage(
'local-alpha',
'Beta remote result',
9,
2,
'space-beta',
'site-space-beta'
),
remotePage('remote-alpha-2', 'Alpha remote two', 8, 3),
],
query: 'result',
allowedSiteSpaceIds: ['site-space-alpha'],
});
expect(results.map(getResultKey)).toEqual([
'page:remote-alpha-1',
'page:remote-alpha-2',
'page:local-alpha',
]);
expect(results[0]).not.toHaveProperty('pathname');
expect(results[2]?.type).toBe('local-page');
});
it('preserves cross-site-space results when the search is unrestricted', () => {
const results = fuseSearchResults({
localResults: [
localPage('local-alpha', 'Alpha local result'),
localPage('local-beta', 'Beta local result', 'site-space-beta'),
],
remoteResults: [
remotePage('remote-alpha', 'Alpha remote result', 10, 1),
remotePage(
'remote-beta',
'Beta remote result',
9,
2,
'space-beta',
'site-space-beta'
),
],
query: 'result',
});
expect(results.map(getResultKey)).toEqual([
'page:remote-alpha',
'page:remote-beta',
'page:local-alpha',
'page:local-beta',
]);
});
it('preserves every allowed site space in an intentionally multi-space search', () => {
const results = fuseSearchResults({
localResults: [
localPage('local-alpha'),
localPage('local-beta', 'local-beta', 'site-space-beta'),
localPage('local-gamma', 'local-gamma', 'site-space-gamma'),
],
remoteResults: [
remotePage('remote-alpha'),
remotePage('remote-beta', 'remote-beta', 0, 2, 'space-beta', 'site-space-beta'),
remotePage('remote-gamma', 'remote-gamma', 0, 3, 'space-gamma', 'site-space-gamma'),
],
query: 'page',
allowedSiteSpaceIds: ['site-space-alpha', 'site-space-beta'],
});
expect(results.map(getResultKey)).toEqual([
'page:remote-alpha',
'page:remote-beta',
'page:local-alpha',
'page:local-beta',
]);
});
});
@@ -0,0 +1,271 @@
import type { ComputedPageResult, OrderedComputedResult } from './search-types';
import type { LocalPageResult } from './useLocalSearchResults';
/**
* Jaro-Winkler similarity between two strings, in the range [0, 1].
* Higher is more similar. 1 means an exact match.
* https://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance
*/
function jaroWinkler(s1: string, s2: string, p = 0.1): number {
if (s1 === s2) return 1;
const len1 = s1.length;
const len2 = s2.length;
if (len1 === 0 || len2 === 0) return 0;
const maxDist = Math.floor(Math.max(len1, len2) / 2) - 1;
const matched1 = new Array<boolean>(len1);
const matched2 = new Array<boolean>(len2);
for (let i = 0; i < len1; i++) matched1[i] = false;
for (let i = 0; i < len2; i++) matched2[i] = false;
let matches = 0;
for (let i = 0; i < len1; i++) {
const start = Math.max(0, i - maxDist);
const end = Math.min(len2 - 1, i + maxDist);
for (let j = start; j <= end; j++) {
if (!matched2[j] && s1.charCodeAt(i) === s2.charCodeAt(j)) {
matched1[i] = true;
matched2[j] = true;
matches++;
break;
}
}
}
if (matches === 0) return 0;
let t = 0;
let k = 0;
for (let i = 0; i < len1; i++) {
if (!matched1[i]) continue;
while (!matched2[k]) k++;
if (s1.charCodeAt(i) !== s2.charCodeAt(k)) t++;
k++;
}
const transpositions = t / 2;
const m = matches;
const jaro = (m / len1 + m / len2 + (m - transpositions) / m) / 3;
let prefix = 0;
const maxPrefix = 4;
const minLen = Math.min(len1, len2);
for (let i = 0; i < maxPrefix && i < minLen; i++) {
if (s1.charCodeAt(i) === s2.charCodeAt(i)) prefix++;
else break;
}
return jaro + prefix * p * (1 - jaro);
}
/** Standard RRF constant — moderates the impact of high-ranked items. */
const RRF_K = 60;
/** Lower k for local results gives them a slightly higher score than remote ones at the same rank. */
const RRF_K_LOCAL = 50;
/** Number of remote results that should keep their API order before fusion. */
const PINNED_REMOTE_RESULTS_COUNT = 3;
/**
* A page result that was present in both local and remote lists.
* Local fields (icon, emoji, pathname) are carried over as a base, and remote fields
* (description, href, pageId, spaceId, title) override them.
* Breadcrumbs prefer local (has icon + emoji) and fall back to remote.
*/
export type MergedPageResult = Omit<ComputedPageResult, 'breadcrumbs'> & {
pathname?: string;
icon?: string;
emoji?: string;
breadcrumbs?: LocalPageResult['breadcrumbs'] | ComputedPageResult['breadcrumbs'];
};
/**
* Derive a stable deduplication key for any result type.
* Pages from both local and remote share the same `page:` namespace so they
* can be deduplicated against each other.
*/
export function getResultKey(
result: LocalPageResult | OrderedComputedResult | MergedPageResult
): string {
switch (result.type) {
case 'local-page':
return `page:${result.id}`;
case 'page':
return `page:${result.pageId}`;
case 'record':
return `record:${result.id}`;
}
}
type RRFResult = LocalPageResult | OrderedComputedResult | MergedPageResult;
/** Filter page candidates at the final assembly boundary, immediately before ranking and fusion. */
export function fuseSearchResults(args: {
localResults: LocalPageResult[];
remoteResults: OrderedComputedResult[];
query: string;
allowedSiteSpaceIds?: string[];
}): RRFResult[] {
const { localResults, remoteResults, query, allowedSiteSpaceIds } = args;
if (!allowedSiteSpaceIds) {
return reciprocalRankFusion(localResults, remoteResults, query);
}
const allowed = new Set(allowedSiteSpaceIds);
return reciprocalRankFusion(
localResults.filter((result) => allowed.has(result.siteSpaceId)),
remoteResults.filter(
(result) =>
result.type === 'record' ||
(result.siteSpaceId !== undefined && allowed.has(result.siteSpaceId))
),
query
);
}
function mergeLocalPageWithRemotePage(
localResult: LocalPageResult,
remoteResult: ComputedPageResult
): MergedPageResult {
return {
...localResult,
...remoteResult,
// Merge breadcrumbs: prefer local (has icon + emoji), fall back to remote.
breadcrumbs: localResult.breadcrumbs ?? remoteResult.breadcrumbs,
};
}
function mergePinnedRemoteResult(
result: OrderedComputedResult,
localResultsByKey: Map<string, LocalPageResult>
): RRFResult {
if (result.type !== 'page') {
return result;
}
const localResult = localResultsByKey.get(getResultKey(result));
if (!localResult) {
return result;
}
return mergeLocalPageWithRemotePage(localResult, result);
}
/**
* Merge local (FlexSearch) and remote (API) search results using
* Reciprocal Rank Fusion (RRF), while preserving the order of the first
* three remote results.
*
* RRF formula: score(d) = Σ_i 1 / (k + rank_i(d))
*
* The pinned remote results are excluded from fusion and returned first. Pages
* present in both lists are deep-merged: local fields act as the base
* (preserving icon, emoji, pathname) and remote fields override (providing the
* authoritative page description, href, pageId, spaceId, and title). Breadcrumbs prefer local (has icon
* + emoji) and fall back to remote. In the fused tail, their rank contributions
* from both lists are summed.
*
* Sections and records have no local equivalent and only accumulate their own
* rank contribution.
*
* Additionally, a Jaro-Winkler title-match bonus is applied per query word:
* for each query word that appears as a substring in the title, the best
* Jaro-Winkler similarity score against any individual title word is added.
*/
export function reciprocalRankFusion(
localResults: LocalPageResult[],
remoteResults: OrderedComputedResult[],
query: string
): RRFResult[] {
const localResultsByKey = new Map(localResults.map((result) => [getResultKey(result), result]));
const pinnedRemoteResults = remoteResults.slice(0, PINNED_REMOTE_RESULTS_COUNT);
const pinnedResultKeys = new Set(pinnedRemoteResults.map((result) => getResultKey(result)));
const remainingLocalResults = localResults.filter(
(result) => !pinnedResultKeys.has(getResultKey(result))
);
const remainingRemoteResults = remoteResults
.slice(PINNED_REMOTE_RESULTS_COUNT)
.filter((result) => !pinnedResultKeys.has(getResultKey(result)));
// Map from dedup key → { result, score }
const scoreMap = new Map<string, { result: RRFResult; score: number }>();
// Process local results first (1-indexed rank)
// Using RRF_K_LOCAL (< RRF_K) slightly boosts local scores over remote ones.
remainingLocalResults.forEach((result, index) => {
const rank = index + 1;
const key = getResultKey(result);
const contribution = 1 / (RRF_K_LOCAL + rank);
const existing = scoreMap.get(key);
if (existing) {
existing.score += contribution;
} else {
scoreMap.set(key, { result, score: contribution });
}
});
// Process remote results, deduplicating against local pages
remainingRemoteResults.forEach((result, index) => {
const rank = index + 1;
const contribution = 1 / (RRF_K + rank);
const key = getResultKey(result);
const existing = scoreMap.get(key);
if (existing) {
// Page found in both lists: sum rank contributions and deep-merge.
// Local is the base (icon, emoji, pathname), while remote provides page fields.
existing.score += contribution;
if (existing.result.type === 'local-page' && result.type === 'page') {
existing.result = mergeLocalPageWithRemotePage(existing.result, result);
} else {
existing.result = result;
}
} else {
scoreMap.set(key, { result, score: contribution });
}
});
// Apply a title-match bonus per query word using Jaro-Winkler similarity.
// For each query word that appears (as a substring) in the title, compute the
// best Jaro-Winkler score against individual title words and add it to the score.
const queryWords = query
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 0);
if (queryWords.length > 0) {
for (const entry of scoreMap.values()) {
// Only apply the bonus to page or local-page results
if (entry.result.type === 'record') continue;
const title = entry.result.title?.toLowerCase() ?? '';
const titleWords = title.split(/\s+/).filter((w) => w.length > 0);
for (const word of queryWords) {
// Only apply when there is at least a substring match in the title.
if (!title.includes(word)) continue;
let best = 0;
for (const titleWord of titleWords) {
const sim = jaroWinkler(word, titleWord);
if (sim > best) best = sim;
}
entry.score += best;
}
}
}
// Sort descending by RRF score
const fusedResults = Array.from(scoreMap.values())
.sort((a, b) => b.score - a.score)
.map(({ result }) => result);
return [
...pinnedRemoteResults.map((result) => mergePinnedRemoteResult(result, localResultsByKey)),
...fusedResults,
];
}
@@ -25,6 +25,7 @@ export interface SearchBaseProps {
sections: SearchSectionItem[];
withVariants: boolean;
withSiteVariants: boolean;
indexURL: string;
searchURL: string;
}
@@ -46,6 +47,7 @@ export function getSearchBaseProps(context: GitBookSiteContext): SearchBaseProps
visibleSections?.list.some(
(section) => section.object === 'site-section' && section.siteSpaces.length > 1
) ?? false,
indexURL: context.linker.toPathInSite('~gitbook/site-index'),
withVariants: visibleSiteSpaces.length > 1,
};
}
@@ -1,5 +1,15 @@
'use client';
/**
* Client-side access to the `~gitbook/site-index` JSON, shared by every consumer
* (instant search in `useLocalSearchResults`, related pages on the 404 page).
*
* Loading strategy: the download starts as early as possible (server-rendered
* preload hint in `SiteLayout`, deduped here at first call) and is kept as raw
* text so the multi-MB `JSON.parse` — main-thread work — is only paid when a
* consumer actually needs the pages.
*/
export interface SiteIndexBreadcrumb {
label: string;
icon?: string;
@@ -20,14 +30,50 @@ export interface SiteIndexPage {
breadcrumbs?: SiteIndexBreadcrumb[];
}
/** Fetch the pages of the site from the `~gitbook/site-index` JSON. */
let siteIndexText: Promise<string> | null = null;
/**
* Start (or reuse) the single-flight download of the raw index. Errors clear the
* cache so the next consumer retries.
*/
export function prefetchSiteIndex(indexURL: string): void {
fetchSiteIndexText(indexURL).catch(() => {
// Ignored: consumers surface errors when they actually read the index.
});
}
/**
* Get the parsed index pages. Parses per call (cheap for the rare second
* consumer) so the parsed object graph is never retained at module scope.
*/
export async function fetchSiteIndex(
indexURL: string
): Promise<{ version: 1; pages: SiteIndexPage[] }> {
const response = await fetch(indexURL);
if (!response.ok) {
throw new Error(`Failed to fetch site index: ${response.status}`);
return JSON.parse(await fetchSiteIndexText(indexURL));
}
/**
* Drop the cached raw text (several MB for large sites) once a consumer has
* turned it into a longer-lived form. Purely a memory release: a later consumer
* re-fetches, hitting the HTTP cache.
*/
export function releaseSiteIndex(): void {
siteIndexText = null;
}
function fetchSiteIndexText(indexURL: string): Promise<string> {
if (!siteIndexText) {
siteIndexText = fetch(indexURL).then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch search index: ${response.status}`);
}
return response.text();
});
siteIndexText.catch(() => {
siteIndexText = null;
});
}
return response.json();
return siteIndexText;
}
@@ -0,0 +1,262 @@
'use client';
import type { Document, DocumentValue } from 'flexsearch';
import React from 'react';
import {
type SiteIndexBreadcrumb,
type SiteIndexPage,
fetchSiteIndex,
prefetchSiteIndex,
releaseSiteIndex,
} from './site-index';
/** FlexSearch-compatible document type — satisfies DocumentData via explicit index signature */
interface IndexPage {
[key: string]: DocumentValue | DocumentValue[];
id: string;
title: string;
description: string | null;
siteSpaceId: string;
}
/** Result type returned by this hook */
export interface LocalPageResult {
type: 'local-page';
id: string;
siteSpaceId: string;
title: string;
pathname: string;
icon?: string;
emoji?: string;
description?: string;
breadcrumbs?: SiteIndexBreadcrumb[];
}
type LocalSearchState = {
results: LocalPageResult[];
fetching: boolean;
error: boolean;
};
// Module-level singletons — one Document per language per session.
// Keys are the page's `lang` value, or `''` when no language is set.
const cachedIndexes = new Map<string, Document<IndexPage>>();
// Side-map for data that doesn't belong in the FlexSearch index.
// Keyed by page id, shared across all language groups.
const cachedPageData = new Map<
string,
{ pathname: string; icon?: string; emoji?: string; breadcrumbs?: SiteIndexBreadcrumb[] }
>();
let pendingFetch: Promise<Map<string, Document<IndexPage>>> | null = null;
function buildLangIndex(
DocumentCtor: typeof import('flexsearch').Document,
pages: SiteIndexPage[]
): Document<IndexPage> {
const index = new DocumentCtor<IndexPage>({
document: {
id: 'id',
index: ['title', 'description'],
store: ['id', 'title', 'description', 'siteSpaceId'],
tag: 'siteSpaceId',
},
tokenize: 'full',
resolution: 15,
encoder: 'Normalize',
});
for (const page of pages) {
index
.addAsync({
id: page.id,
title: page.title,
description: page.description ?? null,
siteSpaceId: page.siteSpaceId,
})
.catch(() => {
// We just ignore these errors, it's not worth failing the whole index for a single bad record
// And we still have remote search as a fallback for these cases
});
cachedPageData.set(`${page.siteSpaceId}:${page.id}`, {
pathname: page.pathname,
icon: page.icon,
emoji: page.emoji,
breadcrumbs: page.breadcrumbs?.length ? page.breadcrumbs : undefined,
});
}
return index;
}
async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document<IndexPage>>> {
if (cachedIndexes.size > 0) {
return cachedIndexes;
}
if (pendingFetch) {
return pendingFetch;
}
pendingFetch = (async () => {
// FlexSearch stays in an on-demand chunk instead of the main client bundle, it's only needed once the user actually searches.
const [{ Document }, data] = await Promise.all([
import('flexsearch'),
fetchSiteIndex(indexURL),
]);
// Group pages by their `lang` value (empty string for pages without one)
const pagesByLang = new Map<string, SiteIndexPage[]>();
for (const page of data.pages) {
const key = page.lang ?? '';
const bucket = pagesByLang.get(key);
if (bucket) {
bucket.push(page);
} else {
pagesByLang.set(key, [page]);
}
}
// Build one FlexSearch Document per language group
for (const [lang, pages] of pagesByLang) {
cachedIndexes.set(lang, buildLangIndex(Document, pages));
}
releaseSiteIndex();
return cachedIndexes;
})();
// Clear pendingFetch on error so a retry is possible
pendingFetch.catch(() => {
console.error('Error fetching/building search index', indexURL);
pendingFetch = null;
});
return pendingFetch;
}
export function useLocalSearchResults(props: {
query: string;
indexURL: string;
/** BCP-47 language code of the current site space. When provided, only results
* from pages with a matching language are returned. */
lang?: string;
/** When provided, only results from pages belonging to one of these site space IDs
* are returned. Uses FlexSearch native tag filtering. Omit for no filtering (all spaces). */
filterSiteSpaceIds?: string[];
disabled?: boolean;
/** Whether the search surface is open. */
open: boolean;
}): LocalSearchState {
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false, open } = props;
const [state, setState] = React.useState<LocalSearchState>({
results: [],
fetching: false,
error: false,
});
// Track whether the indexes are loaded so the search effect re-runs after load
const [indexReady, setIndexReady] = React.useState(cachedIndexes.size > 0);
const active = open || Boolean(query);
// Load the indexes once
React.useEffect(() => {
if (cachedIndexes.size > 0) {
setIndexReady(true);
return;
}
prefetchSiteIndex(indexURL);
if (!active) {
return;
}
let cancelled = false;
setState((prev) => ({ ...prev, fetching: true, error: false }));
getOrBuildIndexes(indexURL)
.then(() => {
if (!cancelled) {
setIndexReady(true);
setState((prev) => ({ ...prev, fetching: false }));
}
})
.catch(() => {
if (!cancelled) {
setState({ results: [], fetching: false, error: true });
}
});
return () => {
cancelled = true;
};
}, [indexURL, active]);
// Perform instant local search whenever query, lang, or index readiness changes
React.useEffect(() => {
// Resolve the per-language index to query. When `lang` is not set we fall
// back to the `''` bucket (pages with no language tag).
const langKey = lang ?? '';
const index = cachedIndexes.get(langKey);
if (disabled || !indexReady || !index) {
return;
}
if (!query) {
setState({ results: [], fetching: false, error: false });
return;
}
//@ts-ignore - Typing is wrong here, tags can be arrays when using the `tag` filter option
const rawResults = index.search(query, {
enrich: true,
limit: 5,
suggest: true,
...(filterSiteSpaceIds
? {
tag: {
siteSpaceId: filterSiteSpaceIds,
},
}
: {}),
});
// Flatten and deduplicate results across fields (flexsearch returns one array per indexed field)
const seen = new Set<string>();
const results: LocalPageResult[] = [];
for (const fieldResult of rawResults) {
for (const item of fieldResult.result) {
const doc = (item as { id: string; doc: IndexPage }).doc;
const cacheKey = `${doc.siteSpaceId}:${doc.id}`;
if (!seen.has(cacheKey)) {
seen.add(cacheKey);
const extra = cachedPageData.get(cacheKey);
results.push({
type: 'local-page',
id: doc.id,
siteSpaceId: doc.siteSpaceId,
title: doc.title,
pathname: extra?.pathname ?? '',
icon: extra?.icon,
emoji: extra?.emoji,
description: (doc.description as string | null) ?? undefined,
breadcrumbs: extra?.breadcrumbs,
});
}
}
}
setState({ results, fetching: false, error: false });
}, [query, lang, filterSiteSpaceIds, indexReady, disabled]);
return state;
}
@@ -73,97 +73,17 @@ function useFilteredSiteSpaceIds(props: {
);
}
/** Edits that complete a query rather than continue typing it. */
const COMPLETING_INPUT_TYPES = new Set([
'insertFromPaste',
'insertFromDrop',
'insertReplacementText',
'insertFromYank',
'insertFromComposition',
]);
/**
* Read the intent of each edit from the native input event: completed edits are sent without the
* idle delay and a typed word boundary sends the words so far. IME composition text is searched
* like typing; committing it sends at once.
*/
function useSearchInputIntent() {
const immediateQueryRef = React.useRef<string | null>(null);
const flushRef = React.useRef<() => void>(() => {});
const consumeImmediate = React.useCallback((query: string) => {
const immediate = immediateQueryRef.current === query;
immediateQueryRef.current = null;
return immediate;
}, []);
const onInput = React.useCallback((event: React.FormEvent<HTMLInputElement>) => {
const native = event.nativeEvent as InputEvent;
if (native.isComposing) {
return;
}
const query = event.currentTarget.value.trim();
if (COMPLETING_INPUT_TYPES.has(native.inputType)) {
immediateQueryRef.current = query;
} else if (
native.inputType === 'insertText' &&
native.data &&
/^\s+$/.test(native.data) &&
query
) {
// A typed space leaves the trimmed query unchanged, so send the pending words now.
flushRef.current();
}
}, []);
const onCompositionEnd = React.useCallback(
(event: React.CompositionEvent<HTMLInputElement>) => {
// The committed text is usually already the query, so send its pending request now.
immediateQueryRef.current = event.currentTarget.value.trim();
flushRef.current();
},
[]
);
return {
consumeImmediate,
flushRef,
inputHandlers: { onInput, onCompositionEnd },
};
}
function useSearchKeyboardNavigation(props: {
query: string;
results: ReturnType<typeof useSearchResults>['results'];
fetching: boolean;
error: boolean;
isOpen: boolean;
requestKey: string;
flush: () => void;
resultsRef: React.RefObject<SearchResultsRef | null>;
abort: () => void;
askCount: number;
onAskSelect: (index: number) => void;
}) {
const {
query,
results,
fetching,
error,
isOpen,
requestKey,
flush,
resultsRef,
abort,
askCount,
onAskSelect,
} = props;
// Enter pressed before the answer arrived: open the top result of this exact query and scope,
// or Ask when that answer is empty. Closing the search cancels it.
const pendingOpenRef = React.useRef<string | null>(null);
const { query, results, resultsRef, abort, askCount, onAskSelect } = props;
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
query,
fetching,
resultCount: results.length,
totalCount: results.length + askCount,
});
@@ -172,11 +92,9 @@ function useSearchKeyboardNavigation(props: {
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
pendingOpenRef.current = null;
moveCursorBy(-1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
pendingOpenRef.current = null;
moveCursorBy(1);
} else if (event.key === 'Enter' && !isComposingKeyEvent(event)) {
event.preventDefault();
@@ -188,51 +106,12 @@ function useSearchKeyboardNavigation(props: {
if (resultsRef.current?.select()) {
// Stop any in-flight search request only when activating a result.
abort();
return;
}
if (query && fetching) {
pendingOpenRef.current = requestKey;
flush();
}
}
},
[
moveCursorBy,
cursor,
results.length,
onAskSelect,
resultsRef,
abort,
query,
fetching,
requestKey,
flush,
]
[moveCursorBy, cursor, results.length, onAskSelect, resultsRef, abort]
);
React.useEffect(() => {
const pending = pendingOpenRef.current;
if (pending === null) {
return;
}
if (!isOpen || pending !== requestKey) {
pendingOpenRef.current = null;
return;
}
if (fetching) {
return;
}
pendingOpenRef.current = null;
if (results.length > 0) {
if (resultsRef.current?.selectFirst()) {
abort();
}
} else if (!error && askCount > 0) {
onAskSelect(0);
}
}, [isOpen, requestKey, fetching, error, results, resultsRef, abort, askCount, onAskSelect]);
return {
cursor,
onInputKeyDown,
@@ -241,11 +120,7 @@ function useSearchKeyboardNavigation(props: {
export function useSearchController(
props: SearchBaseProps,
options: {
restoreLastQueryOnMount?: boolean;
/** False for a container the current viewport hides, so only the shown one sends searches. */
requestsEnabled?: boolean;
} = {}
options: { restoreLastQueryOnMount?: boolean } = {}
) {
const {
asEmbeddable,
@@ -257,6 +132,7 @@ export function useSearchController(
withSections,
siteSpaces,
searchURL,
indexURL,
} = props;
const { assistants, config } = useAI();
@@ -373,10 +249,10 @@ export function useSearchController(
});
const selectedSection = state?.section ? findSearchSection(sections, state.section) : undefined;
const { consumeImmediate, flushRef, inputHandlers } = useSearchInputIntent();
const { results, fetching, error, requestKey, flush, abort } = useSearchResults({
const { results, fetching, error, abort } = useSearchResults({
asEmbeddable,
disabled: options.requestsEnabled === false || !(normalizedQuery || withAI),
disabled: !(normalizedQuery || withAI),
open: Boolean(state?.open),
query: normalizedQuery,
siteSpaceId: siteSpace.id,
siteSpaceIds,
@@ -384,10 +260,10 @@ export function useSearchController(
scope: state?.scope ?? 'default',
suggestions: config.suggestions,
searchURL,
indexURL,
lang: siteSpace.space.language,
withSections,
consumeImmediate,
});
flushRef.current = flush;
const searchValue =
state?.query ?? (askIsSearchQuery ? state?.ask : null) ?? lastSearchQuery ?? '';
@@ -430,12 +306,6 @@ export function useSearchController(
const { cursor, onInputKeyDown } = useSearchKeyboardNavigation({
query: normalizedQuery,
results,
fetching,
error,
// A container that hides on this viewport drops its pending Enter.
isOpen: Boolean(state?.open) && options.requestsEnabled !== false,
requestKey,
flush,
resultsRef,
abort,
askCount,
@@ -462,7 +332,6 @@ export function useSearchController(
setQuery,
state,
onInputKeyDown,
inputHandlers,
showAsk,
withAI,
withSearchAI,
@@ -8,21 +8,26 @@ import {
createRecommendedQuestionResult,
getEmptySearchResults,
} from './empty-search-results';
import { computeFilterSiteSpaceIds, filterResultsBySiteSpaces } from './filter';
import { computeFilterSiteSpaceIds } from './filter';
import { useRecentSearchQueries } from './recent-queries';
import { type MergedPageResult, fuseSearchResults } from './reciprocalRankFusion';
import { computeRemoteSearchScope } from './remote-scope';
import type { OrderedComputedResult, SearchSiteContentScope } from './search-types';
import { streamRecommendedQuestions } from './server-actions';
import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults';
import type { SearchScope } from './useSearch';
import { useAI } from '@/components/AI';
export type ResultType = OrderedComputedResult | RecommendedQuestionResult;
export type ResultType =
| OrderedComputedResult
| LocalPageResult
| MergedPageResult
| RecommendedQuestionResult;
type RemoteSearchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; requestKey: string; results: OrderedComputedResult[] }
| { status: 'error' };
export type { LocalPageResult, MergedPageResult };
// Small helper extracted for unit testing of scope → local filter mapping
// computeFilterSiteSpaceIds is imported from './filter' for testability
/**
* We cache the recommended questions globally to avoid calling the API multiple times
@@ -35,6 +40,8 @@ const cachedRecommendedQuestions: Map<string, RecommendedQuestionResult[]> = new
export function useSearchResults(props: {
asEmbeddable?: boolean;
disabled: boolean;
/** Whether the search surface is open. Gates building the local search index. */
open: boolean;
query: string;
siteSpaceId: string;
siteSpaceIds: string[];
@@ -43,14 +50,17 @@ export function useSearchResults(props: {
suggestions?: string[];
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
searchURL: string;
/** Whether the site has multiple sections. If false, the default scope only shows the current site space. */
/** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/site-index')). */
indexURL: string;
/** BCP-47 language code of the current site space, used to filter local search results. */
lang?: string;
/** Whether the site has multiple sections. If false, treat default scope as current for local filtering. */
withSections?: boolean;
/** Returns true when the latest edit completed the query (paste, IME commit), so it is sent without the idle delay. */
consumeImmediate?: (query: string) => boolean;
}) {
const {
asEmbeddable,
disabled,
open,
query,
siteSpaceId,
siteSpaceIds,
@@ -58,8 +68,9 @@ export function useSearchResults(props: {
scope,
suggestions,
searchURL,
indexURL,
lang,
withSections,
consumeImmediate,
} = props;
const trackEvent = useTrackEvent();
@@ -76,21 +87,24 @@ export function useSearchResults(props: {
[scope, siteSpaceId, siteSpaceIds, withSections, selectedSectionSiteSpaceIds]
);
const remoteScope = React.useMemo(
() =>
computeRemoteSearchScope(scope, siteSpaceId, siteSpaceIds, selectedSectionSiteSpaceIds),
[scope, siteSpaceId, siteSpaceIds, selectedSectionSiteSpaceIds]
);
// Identifies the answer the UI needs now; an answer for any other key is never shown as current.
const requestKey = JSON.stringify([query, remoteScope]);
const { results: localResults } = useLocalSearchResults({
query,
indexURL,
lang,
disabled,
open,
filterSiteSpaceIds,
});
const [remoteState, setRemoteState] = React.useState<RemoteSearchState>({ status: 'idle' });
const [remoteState, setRemoteState] = React.useState<{
results: OrderedComputedResult[];
fetching: boolean;
error: boolean;
}>({ results: [], fetching: false, error: false });
// Track the current in-flight fetch so it can be aborted imperatively
// when the user navigates away before the request completes.
const abortRef = React.useRef<(() => void) | null>(null);
// Sends the pending request now instead of waiting for the idle delay.
const flushRef = React.useRef<(() => void) | null>(null);
const { assistants } = useAI();
const withAI = assistants.length > 0;
@@ -102,7 +116,11 @@ export function useSearchResults(props: {
}
if (!query) {
if (!withAI) {
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
return;
}
@@ -113,11 +131,19 @@ export function useSearchResults(props: {
`Cached recommended questions should be set for site-space ${siteSpaceId}`
);
// Recommended questions are stored as ResultType[] already
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
return;
}
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
let cancelled = false;
@@ -130,7 +156,11 @@ export function useSearchResults(props: {
suggestions.forEach((question) => {
questions.add(question);
});
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
return;
}
@@ -156,7 +186,11 @@ export function useSearchResults(props: {
if (!cancelled) {
// Recommended questions are handled via a separate path below
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
}
}
}, 100);
@@ -166,18 +200,28 @@ export function useSearchResults(props: {
clearTimeout(timeout);
};
}
setRemoteState({ status: 'loading' });
setRemoteState({
results: [],
fetching: true,
error: false,
});
let cancelled = false;
const abortController = new AbortController();
const send = async () => {
flushRef.current = null;
const timeout = setTimeout(async () => {
const fetchSearch = (
scope: Parameters<typeof fetchSearchResults>[1]
): Promise<OrderedComputedResult[]> =>
fetchSearchResults(searchURL, scope, query, abortController.signal, asEmbeddable);
try {
const resultsPromise = fetchSearch(remoteScope);
const resultsPromise = fetchSearch(
computeRemoteSearchScope(
scope,
siteSpaceId,
siteSpaceIds,
selectedSectionSiteSpaceIds
)
);
const onResults = (results: OrderedComputedResult[]) => {
if (cancelled) {
@@ -186,34 +230,38 @@ export function useSearchResults(props: {
if (!results) {
// Can happen when the route cannot be found and returns the page's html.
setRemoteState({ status: 'error' });
setRemoteState((prev) => ({ ...prev, error: true }));
return;
}
setRemoteState({ status: 'ready', requestKey, results });
setRemoteState((prev) => ({ ...prev, results }));
trackEvent({ type: 'search_type_query', query });
};
const onError = () => {
if (cancelled) {
return;
}
setRemoteState({ status: 'error' });
setRemoteState((prev) => ({ ...prev, error: true }));
};
await resultsPromise.then(onResults, onError);
if (cancelled) {
return;
}
setRemoteState((prev) => ({ ...prev, fetching: false }));
} catch {
// If there is an error, we need to catch it to avoid infinite loading state.
if (cancelled) {
return;
}
setRemoteState({ status: 'error' });
setRemoteState({
results: [],
fetching: false,
error: true,
});
}
};
const timeout = setTimeout(send, consumeImmediate?.(query) ? 0 : 150);
flushRef.current = () => {
clearTimeout(timeout);
void send();
};
}, 200);
abortRef.current = () => {
cancelled = true;
@@ -226,32 +274,29 @@ export function useSearchResults(props: {
clearTimeout(timeout);
abortController.abort();
abortRef.current = null;
flushRef.current = null;
};
}, [
query,
remoteScope,
requestKey,
scope,
trackEvent,
withAI,
siteSpaceId,
siteSpaceIds,
selectedSectionSiteSpaceIds,
disabled,
suggestions,
searchURL,
asEmbeddable,
consumeImmediate,
]);
const flush = React.useCallback(() => {
flushRef.current?.();
}, []);
const abort = React.useCallback(() => {
abortRef.current?.();
abortRef.current = null;
setRemoteState((prev) => (prev.status === 'loading' ? { status: 'idle' } : prev));
setRemoteState((prev) => (prev.fetching ? { ...prev, fetching: false } : prev));
}, []);
// Merge local and remote results.
// Re-runs immediately whenever either result set changes.
const results = React.useMemo<ResultType[]>(() => {
if (!query) {
const recommendedQuestions =
@@ -268,12 +313,15 @@ export function useSearchResults(props: {
});
}
return remoteState.status === 'ready' && remoteState.requestKey === requestKey
? filterResultsBySiteSpaces(remoteState.results, filterSiteSpaceIds)
: [];
return fuseSearchResults({
localResults,
remoteResults: remoteState.results,
query,
allowedSiteSpaceIds: filterSiteSpaceIds,
});
}, [
remoteState,
requestKey,
localResults,
remoteState.results,
query,
filterSiteSpaceIds,
withAI,
@@ -282,15 +330,10 @@ export function useSearchResults(props: {
recentQueries,
]);
// Between a query change and its loading state, the previous answer must still read as loading.
const isOutdated = remoteState.status === 'ready' && remoteState.requestKey !== requestKey;
return {
results,
fetching: remoteState.status === 'loading' || (Boolean(query) && isOutdated),
error: remoteState.status === 'error',
requestKey,
flush,
fetching: remoteState.fetching,
error: remoteState.error,
abort,
};
}
@@ -2,12 +2,11 @@ import React from 'react';
export function useSearchResultsCursor(props: {
query: string;
fetching: boolean;
resultCount: number;
totalCount: number;
}) {
const [cursor, setCursor] = React.useState<number | null>(null);
const { query, fetching, resultCount, totalCount } = props;
const { query, resultCount, totalCount } = props;
React.useEffect(() => {
if (!query) {
@@ -17,12 +16,6 @@ export function useSearchResultsCursor(props: {
}, [query]);
React.useEffect(() => {
if (fetching && resultCount === 0) {
// Nothing is highlighted while results load, so Enter can't open the Ask row instead.
setCursor(null);
return;
}
if (totalCount === 0) {
return;
}
@@ -34,7 +27,7 @@ export function useSearchResultsCursor(props: {
return prev ?? 0;
});
}, [fetching, resultCount, totalCount]);
}, [resultCount, totalCount]);
React.useEffect(() => {
setCursor((prev) => {
@@ -71,6 +71,17 @@ export async function SiteLayout(props: {
}
});
// Start the search-index download from the HTML itself. `crossOrigin` must match the
// client `fetch()` (cors + same-origin credentials) or the preload is ignored and the
// index downloads twice — the omission was exactly that bug before.
// Low priority: it's only needed once search opens and must not compete with the LCP.
ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, {
as: 'fetch',
type: 'application/json',
crossOrigin: 'anonymous',
fetchPriority: 'low',
});
scripts.forEach(({ script }) => {
if (!isDeferrableScript(script)) {
ReactDOM.preload(script, {
@@ -70,8 +70,8 @@ export function SitePageNotFound() {
return;
}
// Otherwise, rank the site's pages against the path that 404'd. The site index is
// CDN-cached, so this adds no origin request — see getRelatedPages.
// Otherwise, rank the site's pages against the path that 404'd. We reuse the search index
// (already preloaded and CDN-cached), so this adds no origin request — see getRelatedPages.
let active = true;
getRelatedPages(siteIndexURL, pathname ?? '', siteSpaceId).then(
(pages) => {
@@ -261,8 +261,9 @@ function NotFoundSuggestions(props: { suggestions: RelatedPage[] | null }) {
/**
* Return the pages whose path is closest to the one that 404'd.
*
* Rather than asking the server (which would mean an extra origin request per 404), this reads
* the CDN-cached page index served at `~gitbook/site-index`.
* Rather than asking the server (which would mean an extra request per 404), this reuses the
* search index served at `~gitbook/site-index` — preloaded on every page and shared with
* instant search via the module cache in `site-index.ts`, so at most one request is made.
* The ranking is a lighter, client-side cousin of `getSimilarPages` (which the Markdown 404
* runs server-side from the full page tree).
*/
@@ -70,7 +70,7 @@ export function useIsLoggedInVisitor() {
}
/**
* Return the URL of the site page index (`~gitbook/site-index`).
* Return the URL of the site search index (`~gitbook/site-index`).
*/
export function useSiteIndexURL() {
const context = React.useContext(SpaceLayoutContext);
@@ -43,6 +43,7 @@ export const ar: TranslationLanguage = {
search_ask_no_answer:
'تعذر العثور على إجابة لسؤالك. يمكنك محاولة إعادة صياغته أو جعله أكثر تحديدا.',
search_ask_error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى لاحقا.',
search_partial_error: 'تعذر تحميل بعض النتائج. يتم عرض النتائج المتاحة.',
on_this_page: 'في هذه الصفحة',
next_page: 'التالي',
previous_page: 'السابق',
@@ -43,6 +43,8 @@ export const bg: TranslationLanguage = {
search_ask_no_answer:
'Не беше намерен отговор на въпроса ви. Можете да опитате да го преформулирате или да бъдете по-конкретни.',
search_ask_error: 'Нещо се обърка. Моля, опитайте отново по-късно.',
search_partial_error:
'Някои резултати не можаха да се заредят. Показват се наличните резултати.',
on_this_page: 'На тази страница',
next_page: 'Следваща',
previous_page: 'Предишна',
@@ -43,6 +43,7 @@ export const cs: TranslationLanguage = {
search_ask_no_answer:
'Na vaši otázku se nepodařilo najít odpověď. Zkuste ji přeformulovat nebo být konkrétnější.',
search_ask_error: 'Něco se pokazilo. Zkuste to prosím znovu později.',
search_partial_error: 'Některé výsledky se nepodařilo načíst. Zobrazují se dostupné výsledky.',
on_this_page: 'Na této stránce',
next_page: 'Další',
previous_page: 'Předchozí',
@@ -43,6 +43,7 @@ export const da: TranslationLanguage = {
search_ask_no_answer:
'Der kunne ikke findes et svar på dit spørgsmål. Prøv at omformulere det eller være mere specifik.',
search_ask_error: 'Noget gik galt. Prøv igen senere.',
search_partial_error: 'Nogle resultater kunne ikke indlæses. Viser tilgængelige resultater.',
on_this_page: 'På denne side',
next_page: 'Næste',
previous_page: 'Forrige',
@@ -44,6 +44,8 @@ export const de: TranslationLanguage = {
search_ask_no_answer:
'Es konnte keine Antwort auf Ihre Frage gefunden werden. Versuchen Sie, sie umzuformulieren oder genauer zu sein.',
search_ask_error: 'Etwas ist schief gelaufen. Bitte versuchen Sie es später noch einmal.',
search_partial_error:
'Einige Ergebnisse konnten nicht geladen werden. Verfügbare Ergebnisse werden angezeigt.',
on_this_page: 'Auf dieser Seite',
next_page: 'Nächste',
previous_page: 'Vorherige',
@@ -43,6 +43,8 @@ export const el: TranslationLanguage = {
search_ask_no_answer:
'Δεν ήταν δυνατή η εύρεση απάντησης στην ερώτησή σας. Δοκιμάστε να τη διατυπώσετε διαφορετικά ή πιο συγκεκριμένα.',
search_ask_error: 'Κάτι πήγε στραβά. Δοκιμάστε ξανά αργότερα.',
search_partial_error:
'Δεν ήταν δυνατή η φόρτωση ορισμένων αποτελεσμάτων. Εμφανίζονται τα διαθέσιμα αποτελέσματα.',
on_this_page: 'Σε αυτήν τη σελίδα',
next_page: 'Επόμενη',
previous_page: 'Προηγούμενη',
@@ -41,6 +41,7 @@ export const en = {
search_ask_no_answer:
'An answer could not be found for your question. You could try rephrasing it, or be more specific.',
search_ask_error: 'Something went wrong. Please try again later.',
search_partial_error: "Some results couldn't load. Showing available results.",
on_this_page: 'On this page',
next_page: 'Next',
previous_page: 'Previous',
@@ -44,6 +44,8 @@ export const es: TranslationLanguage = {
search_ask_no_answer:
'No se pudo encontrar una respuesta para su pregunta. Puede intentar reformularla o ser más específico.',
search_ask_error: 'Algo salió mal. Por favor, inténtalo de nuevo más tarde.',
search_partial_error:
'No se pudieron cargar algunos resultados. Se muestran los resultados disponibles.',
on_this_page: 'En esta página',
next_page: 'Siguiente',
previous_page: 'Anterior',
@@ -43,6 +43,7 @@ export const et: TranslationLanguage = {
search_ask_no_answer:
'Teie küsimusele ei leitud vastust. Proovige seda ümber sõnastada või olla täpsem.',
search_ask_error: 'Midagi läks valesti. Palun proovige hiljem uuesti.',
search_partial_error: 'Mõnda tulemust ei saanud laadida. Kuvatakse saadaolevad tulemused.',
on_this_page: 'Sellel lehel',
next_page: 'Järgmine',
previous_page: 'Eelmine',
@@ -43,6 +43,8 @@ export const fi: TranslationLanguage = {
search_ask_no_answer:
'Kysymykseesi ei löytynyt vastausta. Voit yrittää muotoilla sen uudelleen tai olla tarkempi.',
search_ask_error: 'Jokin meni pieleen. Yritä myöhemmin uudelleen.',
search_partial_error:
'Joitakin tuloksia ei voitu ladata. Näytetään saatavilla olevat tulokset.',
on_this_page: 'Tällä sivulla',
next_page: 'Seuraava',
previous_page: 'Edellinen',
@@ -43,6 +43,8 @@ export const fr: TranslationLanguage = {
search_ask_sources_no_answer: 'Pages associées',
search_ask_no_answer: 'Pas de réponse trouvée. Essayez de reformuler votre question.',
search_ask_error: 'Une erreur est survenue. Veuillez réessayer plus tard.',
search_partial_error:
'Certains résultats n’ont pas pu être chargés. Les résultats disponibles sont affichés.',
on_this_page: 'Sur cette page',
next_page: 'Suivant',
previous_page: 'Précédent',
@@ -43,6 +43,7 @@ export const he: TranslationLanguage = {
search_ask_no_answer:
'לא נמצאה תשובה לשאלה שלך. אפשר לנסות לנסח אותה מחדש או להיות מדויקים יותר.',
search_ask_error: 'משהו השתבש. נסה שוב מאוחר יותר.',
search_partial_error: 'לא ניתן היה לטעון חלק מהתוצאות. מוצגות התוצאות הזמינות.',
on_this_page: 'בדף הזה',
next_page: 'הבא',
previous_page: 'הקודם',
@@ -43,6 +43,7 @@ export const hi: TranslationLanguage = {
search_ask_no_answer:
'आपके प्रश्न का उत्तर नहीं मिल सका। आप इसे फिर से लिखने या अधिक विशिष्ट बनाने की कोशिश कर सकते हैं।',
search_ask_error: 'कुछ गलत हो गया। कृपया बाद में फिर कोशिश करें।',
search_partial_error: 'कुछ परिणाम लोड नहीं हो सके। उपलब्ध परिणाम दिखाए जा रहे हैं।',
on_this_page: 'इस पृष्ठ पर',
next_page: 'अगला',
previous_page: 'पिछला',
@@ -43,6 +43,7 @@ export const hr: TranslationLanguage = {
search_ask_no_answer:
'Nije bilo moguće pronaći odgovor na vaše pitanje. Pokušajte ga preformulirati ili biti konkretniji.',
search_ask_error: 'Nešto je pošlo po zlu. Pokušajte ponovno kasnije.',
search_partial_error: 'Neki se rezultati nisu mogli učitati. Prikazuju se dostupni rezultati.',
on_this_page: 'Na ovoj stranici',
next_page: 'Sljedeća',
previous_page: 'Prethodna',
@@ -43,6 +43,8 @@ export const hu: TranslationLanguage = {
search_ask_no_answer:
'Nem található válasz a kérdésére. Próbálja meg átfogalmazni, vagy legyen pontosabb.',
search_ask_error: 'Valami hiba történt. Kérjük, próbálja újra később.',
search_partial_error:
'Néhány találatot nem sikerült betölteni. Az elérhető találatok jelennek meg.',
on_this_page: 'Ezen az oldalon',
next_page: 'Következő',
previous_page: 'Előző',
@@ -43,6 +43,7 @@ export const id: TranslationLanguage = {
search_ask_no_answer:
'Jawaban untuk pertanyaan Anda tidak dapat ditemukan. Anda dapat mencoba menyusun ulang atau membuatnya lebih spesifik.',
search_ask_error: 'Terjadi kesalahan. Silakan coba lagi nanti.',
search_partial_error: 'Beberapa hasil tidak dapat dimuat. Menampilkan hasil yang tersedia.',
on_this_page: 'Di halaman ini',
next_page: 'Berikutnya',
previous_page: 'Sebelumnya',
@@ -44,6 +44,8 @@ export const it: TranslationLanguage = {
search_ask_no_answer:
'Non è stato possibile trovare una risposta alla tua domanda. Prova a riformularla o a essere più specifico.',
search_ask_error: 'Qualcosa è andato storto. Riprova più tardi.',
search_partial_error:
'Non è stato possibile caricare alcuni risultati. Visualizziamo i risultati disponibili.',
on_this_page: 'In questa pagina',
next_page: 'Successivo',
previous_page: 'Precedente',
@@ -44,6 +44,7 @@ export const ja: TranslationLanguage = {
search_ask_no_answer:
'ご質問への回答が見つかりませんでした。質問を言い換えるか、もう少し具体的にしてください。',
search_ask_error: '何らかのエラーが発生しました。後ほど再度お試しください。',
search_partial_error: '一部の結果を読み込めませんでした。利用可能な結果を表示しています。',
on_this_page: 'このページ内',
next_page: '次へ',
previous_page: '前へ',
@@ -44,6 +44,7 @@ export const ko: TranslationLanguage = {
search_ask_no_answer:
'질문에 대한 답변을 찾을 수 없습니다. 질문을 다시 표현하거나 더 구체적으로 작성해 보세요.',
search_ask_error: '문제가 발생했습니다. 나중에 다시 시도해 주세요.',
search_partial_error: '일부 결과를 불러오지 못했습니다. 사용 가능한 결과를 표시합니다.',
on_this_page: '이 페이지에서',
next_page: '다음',
previous_page: '이전',
@@ -43,6 +43,7 @@ export const lt: TranslationLanguage = {
search_ask_no_answer:
'Nepavyko rasti atsakymo į jūsų klausimą. Galite pabandyti jį performuluoti arba pateikti konkrečiau.',
search_ask_error: 'Kažkas nepavyko. Bandykite dar kartą vėliau.',
search_partial_error: 'Nepavyko įkelti kai kurių rezultatų. Rodomi pasiekiami rezultatai.',
on_this_page: 'Šiame puslapyje',
next_page: 'Kitas',
previous_page: 'Ankstesnis',
@@ -43,6 +43,7 @@ export const lv: TranslationLanguage = {
search_ask_no_answer:
'Neizdevās atrast atbildi uz jūsu jautājumu. Varat mēģināt to pārfrāzēt vai būt konkrētāks.',
search_ask_error: 'Kaut kas nogāja greizi. Lūdzu, mēģiniet vēlāk vēlreiz.',
search_partial_error: 'Dažus rezultātus neizdevās ielādēt. Tiek rādīti pieejamie rezultāti.',
on_this_page: 'Šajā lapā',
next_page: 'Nākamā',
previous_page: 'Iepriekšējā',
@@ -43,6 +43,7 @@ export const ms: TranslationLanguage = {
search_ask_no_answer:
'Jawapan untuk soalan anda tidak ditemui. Anda boleh cuba menyusunnya semula atau bertanya dengan lebih khusus.',
search_ask_error: 'Ada sesuatu yang tidak kena. Sila cuba lagi kemudian.',
search_partial_error: 'Sesetengah hasil tidak dapat dimuatkan. Memaparkan hasil yang tersedia.',
on_this_page: 'Pada halaman ini',
next_page: 'Seterusnya',
previous_page: 'Sebelumnya',
@@ -44,6 +44,8 @@ export const nl: TranslationLanguage = {
search_ask_no_answer:
'Er kon geen antwoord op je vraag worden gevonden. Probeer je vraag anders te formuleren of wees specifieker.',
search_ask_error: 'Er is iets misgegaan. Probeer het later opnieuw.',
search_partial_error:
'Sommige resultaten konden niet worden geladen. Beschikbare resultaten worden weergegeven.',
on_this_page: 'Op deze pagina',
next_page: 'Volgende',
previous_page: 'Vorige',
@@ -44,6 +44,7 @@ export const no: TranslationLanguage = {
search_ask_no_answer:
'Et svar på spørsmålet ditt kunne ikke finnes. Prøv å omformulere det eller være mer spesifikk.',
search_ask_error: 'Noe gikk galt. Vennligst prøv igjen senere.',
search_partial_error: 'Noen resultater kunne ikke lastes inn. Viser tilgjengelige resultater.',
on_this_page: 'På denne siden',
next_page: 'Neste',
previous_page: 'Forrige',
@@ -43,6 +43,7 @@ export const pl: TranslationLanguage = {
search_ask_no_answer:
'Nie udało się znaleźć odpowiedzi na Twoje pytanie. Spróbuj sformułować je inaczej lub bardziej szczegółowo.',
search_ask_error: 'Coś poszło nie tak. Spróbuj ponownie później.',
search_partial_error: 'Nie udało się wczytać niektórych wyników. Wyświetlamy dostępne wyniki.',
on_this_page: 'Na tej stronie',
next_page: 'Dalej',
previous_page: 'Wstecz',
@@ -44,6 +44,8 @@ export const pt_br: TranslationLanguage = {
search_ask_no_answer:
'Não foi possível encontrar uma resposta para sua pergunta. Você pode tentar reformulá-la ou ser mais específico.',
search_ask_error: 'Algo deu errado. Por favor, tente novamente mais tarde.',
search_partial_error:
'Alguns resultados não puderam ser carregados. Mostrando os resultados disponíveis.',
on_this_page: 'Nesta página',
next_page: 'Próximo',
previous_page: 'Anterior',
@@ -43,6 +43,8 @@ export const pt: TranslationLanguage = {
search_ask_no_answer:
'Não foi possível encontrar uma resposta para a sua pergunta. Pode tentar reformulá-la ou ser mais específico.',
search_ask_error: 'Algo correu mal. Tente novamente mais tarde.',
search_partial_error:
'Não foi possível carregar alguns resultados. A mostrar os resultados disponíveis.',
on_this_page: 'Nesta página',
next_page: 'Seguinte',
previous_page: 'Anterior',
@@ -43,6 +43,8 @@ export const ro: TranslationLanguage = {
search_ask_no_answer:
'Nu s-a putut găsi un răspuns la întrebarea ta. Poți încerca să o reformulezi sau să fii mai specific.',
search_ask_error: 'Ceva nu a mers bine. Te rugăm să încerci din nou mai târziu.',
search_partial_error:
'Unele rezultate nu au putut fi încărcate. Se afișează rezultatele disponibile.',
on_this_page: 'Pe această pagină',
next_page: 'Următoarea',
previous_page: 'Anterioara',
@@ -44,6 +44,8 @@ export const ru: TranslationLanguage = {
search_ask_no_answer:
'Не удалось найти ответ на ваш вопрос. Попробуйте перефразировать его или задать более конкретный вопрос.',
search_ask_error: 'Что-то пошло не так. Пожалуйста, попробуйте позже.',
search_partial_error:
'Не удалось загрузить некоторые результаты. Показаны доступные результаты.',
on_this_page: 'На этой странице',
next_page: 'Следующая',
previous_page: 'Предыдущая',
@@ -43,6 +43,8 @@ export const sk: TranslationLanguage = {
search_ask_no_answer:
'Na vašu otázku sa nepodarilo nájsť odpoveď. Skúste ju preformulovať alebo byť konkrétnejší.',
search_ask_error: 'Niečo sa pokazilo. Skúste to znova neskôr.',
search_partial_error:
'Niektoré výsledky sa nepodarilo načítať. Zobrazujú sa dostupné výsledky.',
on_this_page: 'Na tejto stránke',
next_page: 'Ďalej',
previous_page: 'Predchádzajúca',
@@ -43,6 +43,8 @@ export const sl: TranslationLanguage = {
search_ask_no_answer:
'Odgovora na vaše vprašanje ni bilo mogoče najti. Poskusite ga preoblikovati ali biti bolj natančni.',
search_ask_error: 'Nekaj je šlo narobe. Poskusite znova pozneje.',
search_partial_error:
'Nekaterih rezultatov ni bilo mogoče naložiti. Prikazani so razpoložljivi rezultati.',
on_this_page: 'Na tej strani',
next_page: 'Naslednja',
previous_page: 'Prejšnja',
@@ -43,6 +43,7 @@ export const sv: TranslationLanguage = {
search_ask_no_answer:
'Det gick inte att hitta ett svar på din fråga. Du kan försöka formulera om den eller vara mer specifik.',
search_ask_error: 'Något gick fel. Försök igen senare.',
search_partial_error: 'Vissa resultat kunde inte läsas in. Visar tillgängliga resultat.',
on_this_page: 'På den här sidan',
next_page: 'Nästa',
previous_page: 'Föregående',
@@ -42,6 +42,7 @@ export const th: TranslationLanguage = {
search_ask_sources_no_answer: 'หน้าที่เกี่ยวข้อง',
search_ask_no_answer: 'ไม่พบคำตอบสำหรับคำถามของคุณ ลองเรียบเรียงใหม่หรือถามให้เฉพาะเจาะจงขึ้น',
search_ask_error: 'มีบางอย่างผิดพลาด โปรดลองอีกครั้งในภายหลัง',
search_partial_error: 'โหลดผลลัพธ์บางรายการไม่ได้ กำลังแสดงผลลัพธ์ที่พร้อมใช้งาน',
on_this_page: 'ในหน้านี้',
next_page: 'ถัดไป',
previous_page: 'ก่อนหน้า',
@@ -43,6 +43,7 @@ export const tr: TranslationLanguage = {
search_ask_no_answer:
'Sorunuz için bir cevap bulunamadı. Soruyu yeniden ifade etmeyi veya daha spesifik olmayı deneyebilirsiniz.',
search_ask_error: 'Bir şeyler ters gitti. Lütfen daha sonra tekrar deneyin.',
search_partial_error: 'Bazı sonuçlar yüklenemedi. Kullanılabilir sonuçlar gösteriliyor.',
on_this_page: 'Bu sayfada',
next_page: 'Sonraki',
previous_page: 'Önceki',
@@ -43,6 +43,7 @@ export const uk: TranslationLanguage = {
search_ask_no_answer:
'Не вдалося знайти відповідь на ваше запитання. Спробуйте переформулювати його або зробити конкретнішим.',
search_ask_error: 'Щось пішло не так. Спробуйте ще раз пізніше.',
search_partial_error: 'Не вдалося завантажити деякі результати. Показано доступні результати.',
on_this_page: 'На цій сторінці',
next_page: 'Наступна',
previous_page: 'Попередня',
@@ -43,6 +43,7 @@ export const vi: TranslationLanguage = {
search_ask_no_answer:
'Không tìm thấy câu trả lời cho câu hỏi của bạn. Bạn có thể thử diễn đạt lại hoặc hỏi cụ thể hơn.',
search_ask_error: 'Đã xảy ra lỗi. Vui lòng thử lại sau.',
search_partial_error: 'Không thể tải một số kết quả. Đang hiển thị các kết quả có sẵn.',
on_this_page: 'Trên trang này',
next_page: 'Tiếp theo',
previous_page: 'Trước',
@@ -42,6 +42,7 @@ export const yue: TranslationLanguage = {
search_ask_sources_no_answer: '相關頁面',
search_ask_no_answer: '搵唔到你問題嘅答案。你可以試下改寫,或者問得更具體。',
search_ask_error: '發生錯誤。請稍後再試。',
search_partial_error: '部分結果載入唔到。顯示緊可用嘅結果。',
on_this_page: '本頁內容',
next_page: '下一頁',
previous_page: '上一頁',
@@ -42,6 +42,7 @@ export const zh_tw: TranslationLanguage = {
search_ask_sources_no_answer: '相關頁面',
search_ask_no_answer: '找不到您問題的答案。您可以嘗試改寫問題,或提出更具體的內容。',
search_ask_error: '發生錯誤。請稍後再試。',
search_partial_error: '部分結果無法載入。正在顯示可用結果。',
on_this_page: '本頁內容',
next_page: '下一頁',
previous_page: '上一頁',
@@ -43,6 +43,7 @@ export const zh: TranslationLanguage = {
search_ask_sources_no_answer: '相关页面',
search_ask_no_answer: '无法找到您的问题的答案。您可以尝试改述问题或提供更具体的信息。',
search_ask_error: '出了些问题。请稍后再试。',
search_partial_error: '部分结果无法加载。正在显示可用结果。',
on_this_page: '在本页',
next_page: '下一页',
previous_page: '上一页',