diff --git a/bun.lock b/bun.lock index 5dda4dea47..8c80965865 100644 --- a/bun.lock +++ b/bun.lock @@ -6,9 +6,9 @@ "name": "gitbook", "devDependencies": { "@biomejs/biome": "^1.9.4", - "@changesets/cli": "^2.30.0", - "turbo": "^2.8.16", - "vercel": "^50.31.1", + "@changesets/cli": "^2.29.8", + "turbo": "^2.8.10", + "vercel": "catalog:", }, }, "packages/browser-types": { @@ -146,6 +146,7 @@ "direction": "^2.0.1", "event-iterator": "^2.0.0", "feed": "^5.1.0", + "flexsearch": "^0.8.212", "image-size": "^2.0.2", "js-cookie": "^3.0.5", "jsontoxml": "^1.0.1", @@ -219,7 +220,7 @@ "tailwindcss": "^4.1.11", "ts-essentials": "^10.0.1", "typescript": "catalog:", - "vercel": "^50.15.1", + "vercel": "catalog:", "wrangler": "^4.43.0", }, }, @@ -365,6 +366,7 @@ "tsdown": "^0.15.6", "typescript": "^5.5.3", "usehooks-ts": "^3.1.1", + "vercel": "^50.26.1", }, "packages": { "@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="], @@ -1705,61 +1707,61 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.2.0", "", {}, "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="], - "@vercel/backends": ["@vercel/backends@0.0.45", "", { "dependencies": { "@vercel/build-utils": "13.8.0", "@vercel/nft": "1.3.0", "execa": "3.2.0", "fs-extra": "11.1.0", "oxc-transform": "0.111.0", "path-to-regexp": "8.3.0", "resolve.exports": "2.0.3", "rolldown": "1.0.0-rc.1", "srvx": "0.8.9", "tsx": "4.21.0", "zod": "3.22.4" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" } }, "sha512-KIdt/z4LfH7NgFMqgSuKi0H9UIasly7ByzP+/ZXulgNrWyeJKT9KCas3SDT65o5tU6x1D/jBysZA9AnOt8Ivew=="], + "@vercel/backends": ["@vercel/backends@0.0.40", "", { "dependencies": { "@vercel/build-utils": "13.6.2", "@vercel/nft": "1.3.0", "execa": "3.2.0", "fs-extra": "11.1.0", "oxc-transform": "0.111.0", "path-to-regexp": "8.3.0", "resolve.exports": "2.0.3", "rolldown": "1.0.0-rc.1", "srvx": "0.8.9", "tsx": "4.21.0", "zod": "3.22.4" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" } }, "sha512-cYyfPeGrpqyxBgcgKoaZJ+9M7NRKf1eevzKkrX9QRmiBBlGduR34hV859uK4ouBJByHE7n9Tkga2dRLOH8pMcQ=="], "@vercel/blob": ["@vercel/blob@2.3.0", "", { "dependencies": { "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", "throttleit": "^2.1.0", "undici": "^6.23.0" } }, "sha512-oYWiJbWRQ7gz9Mj0X/NHFJ3OcLMOBzq/2b3j6zeNrQmtFo6dHwU8FAwNpxVIYddVMd+g8eqEi7iRueYx8FtM0Q=="], - "@vercel/build-utils": ["@vercel/build-utils@13.8.0", "", { "dependencies": { "@vercel/python-analysis": "0.9.1" } }, "sha512-moQS4Qd0pvluPd6WRTHxLN3Hh0oSObVNdFv3V0spiEmCk/wm6571up3n1th2PQFqf1a3gheNfxzL7h4I9CWs2A=="], + "@vercel/build-utils": ["@vercel/build-utils@13.6.2", "", { "dependencies": { "@vercel/python-analysis": "0.8.1" } }, "sha512-KiwRUd2x1ZHm73p+/hfdVWZHWXT8r+oN/5ZN1lJyb41AGK4Zhrug6X60jNyVhnw6GjpwBuPTBcys29cCsPjkWQ=="], - "@vercel/cervel": ["@vercel/cervel@0.0.32", "", { "dependencies": { "@vercel/backends": "0.0.45" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" }, "bin": { "cervel": "bin/cervel.mjs" } }, "sha512-g/LIa97d/m3yIZGyBjwl4FOQK1Lg5HU2+N3uHiGVajLoSgJM2aBiwETDZc8bVCr8IO3IqXZQsT2hGy1Jnyko1g=="], + "@vercel/cervel": ["@vercel/cervel@0.0.27", "", { "dependencies": { "@vercel/backends": "0.0.40" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" }, "bin": { "cervel": "bin/cervel.mjs" } }, "sha512-w6FPgnnZD2nr43pkhXSY0tGMwCJT6kh8f8VoEL97ISSkxywZYX+Bw5FxrNavAEMC0wYm7esEosOdAEAh8Twbag=="], "@vercel/detect-agent": ["@vercel/detect-agent@1.2.0", "", {}, "sha512-kY0+TPdYr170bVmVSCg0XvwakWQMENy79uf/sM86DF/HVabaagdTjPwSPujlZid6Bgr66R4kHhjg4bkOxEzFDA=="], - "@vercel/elysia": ["@vercel/elysia@0.1.48", "", { "dependencies": { "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0" } }, "sha512-QlmOHUSOx/uE67Y6u/o8VbNF7ebZ8SZFbrEoBo7iZAd0MC2Vn4xUmlrHFXNR/FEkHFXWkhFKda5Ade3XMqMY8A=="], + "@vercel/elysia": ["@vercel/elysia@0.1.43", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-Q5XCGVMuO0XF2n/XEEP43d+AIk8Yl09NwJmJ0m9w5Mx0Pyacb1Yo6dcbn6vnsj4j50dpYGeejdaqu77xOKi8mQ=="], "@vercel/error-utils": ["@vercel/error-utils@2.0.3", "", {}, "sha512-CqC01WZxbLUxoiVdh9B/poPbNpY9U+tO1N9oWHwTl5YAZxcqXmmWJ8KNMFItJCUUWdY3J3xv8LvAuQv2KZ5YdQ=="], - "@vercel/express": ["@vercel/express@0.1.57", "", { "dependencies": { "@vercel/cervel": "0.0.32", "@vercel/nft": "1.1.1", "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", "ts-morph": "12.0.0", "zod": "3.22.4" } }, "sha512-/Ih1eiJrBGSW6JPzexB8y8AMX/8MzuDUf0xzmpYbKLCbehp3NNuxde5RxT+6cALglFivDETsJBCFwaFuIh3ZqQ=="], + "@vercel/express": ["@vercel/express@0.1.52", "", { "dependencies": { "@vercel/cervel": "0.0.27", "@vercel/nft": "1.1.1", "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", "ts-morph": "12.0.0", "zod": "3.22.4" } }, "sha512-lvZyw/7IOVMx6YqniijlMrU0ys+bcbqkY+5slGQMoXrtW7Mo2RhF9f27y+1Bl1ELv8JzyrXhOLHu2f5gn4CsgQ=="], - "@vercel/fastify": ["@vercel/fastify@0.1.51", "", { "dependencies": { "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0" } }, "sha512-c9CwFQqmoUm5eEGwAcqb98DcxsRmSZGngCAo1B+nlDhaACKcrJ7Ro0tYdTDhi8Lkt+OcPnRWr4+pepgWwH94GQ=="], + "@vercel/fastify": ["@vercel/fastify@0.1.46", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-R8uSV3SSbFJ39o3VAfsKwlWOPGDfQyNXxJ2dT8Z05GQ4O3qgGTcRd/oEothQZgnJR9fjGzDQ3q7SwWBd12150A=="], "@vercel/fun": ["@vercel/fun@1.3.0", "", { "dependencies": { "@tootallnate/once": "2.0.0", "async-listen": "1.2.0", "debug": "4.3.4", "generic-pool": "3.4.2", "micro": "9.3.5-canary.3", "ms": "2.1.1", "node-fetch": "2.6.7", "path-to-regexp": "8.2.0", "promisepipe": "3.0.0", "semver": "7.5.4", "stat-mode": "0.3.0", "stream-to-promise": "2.2.0", "tar": "7.5.7", "tinyexec": "0.3.2", "tree-kill": "1.2.2", "uid-promise": "1.0.0", "xdg-app-paths": "5.1.0", "yauzl-promise": "2.1.3" } }, "sha512-8erw9uPe0dFg45THkNxmjtvMX143SkZebmjgSVbcM3XCkXu3RIiBaJMcMNG8aaS+rnTuw8+d4De9HVT0M/r3wg=="], "@vercel/gatsby-plugin-vercel-analytics": ["@vercel/gatsby-plugin-vercel-analytics@1.0.11", "", { "dependencies": { "web-vitals": "0.2.4" } }, "sha512-iTEA0vY6RBPuEzkwUTVzSHDATo1aF6bdLLspI68mQ/BTbi5UQEGjpjyzdKOVcSYApDtFU6M6vypZ1t4vIEnHvw=="], - "@vercel/gatsby-plugin-vercel-builder": ["@vercel/gatsby-plugin-vercel-builder@2.1.0", "", { "dependencies": { "@sinclair/typebox": "0.25.24", "@vercel/build-utils": "13.8.0", "esbuild": "0.27.0", "etag": "1.8.1", "fs-extra": "11.1.0" } }, "sha512-avJ5IFev2h2K6E/Pd7qd00cFLALj3OyEmQE3UoGs1dmoncINFqa1RoIZDJ9wIhWm1Euan4wrFMRsXkCwNhEGhw=="], + "@vercel/gatsby-plugin-vercel-builder": ["@vercel/gatsby-plugin-vercel-builder@2.0.142", "", { "dependencies": { "@sinclair/typebox": "0.25.24", "@vercel/build-utils": "13.6.2", "esbuild": "0.27.0", "etag": "1.8.1", "fs-extra": "11.1.0" } }, "sha512-F6Mo5fROP57wX/vAngXHA+xBZVwi98vU5YT1J+n/PYxIwbOVCa6SyPOI98o6a+uLQnchqbWbcXcV2CaYmU85VA=="], - "@vercel/go": ["@vercel/go@3.4.4", "", {}, "sha512-jfIx/gUe2YAp46/H4uqdA+PLQQuRwb5lwU37m/e5wrnMCUZ2ksuhg50xPHcBBweboYeE6WuT4e1W6Cwp3tBPzg=="], + "@vercel/go": ["@vercel/go@3.4.3", "", {}, "sha512-LDT4wpx7SW2UJHd3rOL4+2m2V4w7a5zjyuU0pu0yDBiuxp9bEh7QuAH5qZC7pINGLC3vGftiFVzynd5N454sVA=="], - "@vercel/h3": ["@vercel/h3@0.1.57", "", { "dependencies": { "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0" } }, "sha512-I7Q1ity7xEdIVw4OQuKOAR8i6DrvfjpKy+y2uTwbqBD80Gg0KpsMS/KKA+UjvCuclFmP/EHYS3kpMLc/O1rVqg=="], + "@vercel/h3": ["@vercel/h3@0.1.52", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-bk8id7w9evxD+JeOUJt6qfDohGwJFBVh7N1qh1OG/nngzzb/Ojg+poH5EGpYwYiKymPK7NMgQl0fb0ZjhIU4bg=="], - "@vercel/hono": ["@vercel/hono@0.2.51", "", { "dependencies": { "@vercel/nft": "1.1.1", "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", "ts-morph": "12.0.0", "zod": "3.22.4" } }, "sha512-iYEjjF4qR3gTZpVoB4sMQNm5OOdKw5/P2bL1CtolDTeGaea9KwqUrLyXmdK4N41HIRZr/wduID31mmHhSdC3sA=="], + "@vercel/hono": ["@vercel/hono@0.2.46", "", { "dependencies": { "@vercel/nft": "1.1.1", "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", "ts-morph": "12.0.0", "zod": "3.22.4" } }, "sha512-rg+6PnvIV20lUM3r2fcjJ/1g1z3ND6h3EqlOQytwCuwpP/jB88TUy+guutB4rDpylPvHzleZqjgi2rE9yH4URg=="], "@vercel/hydrogen": ["@vercel/hydrogen@1.3.6", "", { "dependencies": { "@vercel/static-config": "3.2.0", "ts-morph": "12.0.0" } }, "sha512-Ec8dKEjGIM4BfThcRLtQs5zaJ4+iJbgLZwkytwi7Blk8VrK6W2F1dtLDmVQYZdVnQcnmHmTx8mxUuMkfP06Mnw=="], - "@vercel/koa": ["@vercel/koa@0.1.31", "", { "dependencies": { "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0" } }, "sha512-Gj4sjqNA80/gnHpC0tRPCTtVEct69tUK21fsjVmawCa+nDNQ4bqXu3dSewna7GtCcp9KnrWgzAIaeVAQUl53mQ=="], + "@vercel/koa": ["@vercel/koa@0.1.26", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-TzH4xovhGbr2kmZyvMraaJ3A28qRdo8SxkBO+LIb3ahM739VyTAkH3EYR5CPaemkRv00+nTyBxJJxfnhP1n2MA=="], - "@vercel/nestjs": ["@vercel/nestjs@0.2.52", "", { "dependencies": { "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0" } }, "sha512-yfy4rpWJ1BRWZ1xBBPew0egVIblFe6G7laCKDzyESnbw19zY0F16g9HCu1H/0IfuKIQvOc95dtbmBsqLm9jyqw=="], + "@vercel/nestjs": ["@vercel/nestjs@0.2.47", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-/yIbLc5rv8GQ+lsJ45JkObcNiMe5yUgXx2d8wryW1SajPV1/iP+wFRK28/Q+6zdvapcgqhFRwInZh+61um/ZWg=="], - "@vercel/next": ["@vercel/next@4.16.1", "", { "dependencies": { "@vercel/nft": "1.1.1" } }, "sha512-gwy3XQRZ/f6RdKuC7BZIRMAzUOQf/R5+k9LMf1LcOm1CVZOLpDhDkeZMTG5vb3Lk9LWwBrJJ2ohhZaYuYEOHaw=="], + "@vercel/next": ["@vercel/next@4.15.39", "", { "dependencies": { "@vercel/nft": "1.1.1" } }, "sha512-E3Wnn5Wfxa7e2RvguE4YNO8OBoOVZhnRXDRfn5jJ9ct+GVRZDgPS9VdXK3eQazMJUCLmQCKdyTca/I57ImPNpQ=="], "@vercel/nft": ["@vercel/nft@1.3.0", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^13.0.0", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-i4EYGkCsIjzu4vorDUbqglZc5eFtQI2syHb++9ZUDm6TU4edVywGpVnYDein35x9sevONOn9/UabfQXuNXtuzQ=="], - "@vercel/node": ["@vercel/node@5.6.15", "", { "dependencies": { "@edge-runtime/node-utils": "2.3.0", "@edge-runtime/primitives": "4.1.0", "@edge-runtime/vm": "3.2.0", "@types/node": "20.11.0", "@vercel/build-utils": "13.8.0", "@vercel/error-utils": "2.0.3", "@vercel/nft": "1.1.1", "@vercel/static-config": "3.2.0", "async-listen": "3.0.0", "cjs-module-lexer": "1.2.3", "edge-runtime": "2.5.9", "es-module-lexer": "1.4.1", "esbuild": "0.27.0", "etag": "1.8.1", "mime-types": "2.1.35", "node-fetch": "2.6.9", "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", "ts-morph": "12.0.0", "tsx": "4.21.0", "typescript": "npm:typescript@5.9.3", "undici": "5.28.4" } }, "sha512-xc5fxmdk8jtuUY8y9/8W5UhTn8R1Ii1Fb3q+V8Zv+2moU9enrrBADA9ercHgE0/DtoiNDpb9Wmvnrb4bUcFOzA=="], + "@vercel/node": ["@vercel/node@5.6.10", "", { "dependencies": { "@edge-runtime/node-utils": "2.3.0", "@edge-runtime/primitives": "4.1.0", "@edge-runtime/vm": "3.2.0", "@types/node": "20.11.0", "@vercel/build-utils": "13.6.2", "@vercel/error-utils": "2.0.3", "@vercel/nft": "1.1.1", "@vercel/static-config": "3.1.2", "async-listen": "3.0.0", "cjs-module-lexer": "1.2.3", "edge-runtime": "2.5.9", "es-module-lexer": "1.4.1", "esbuild": "0.27.0", "etag": "1.8.1", "mime-types": "2.1.35", "node-fetch": "2.6.9", "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", "ts-morph": "12.0.0", "tsx": "4.21.0", "typescript": "npm:typescript@5.9.3", "undici": "5.28.4" } }, "sha512-HEydd6y0KPjCyI9sM3n2/XCjS/fZV/7sO7jFjia9I5XbEJ6LCwkI6rR+GQB751NL0He0vFusy+mm394hJC/Rqg=="], - "@vercel/python": ["@vercel/python@6.22.0", "", { "dependencies": { "@vercel/python-analysis": "0.9.1" } }, "sha512-wyHIWKXrDp+VkiWTIkz/++m037lwIy1PUNLRooYFUUBylBWpF/oYy42Kj8vyAKtsXO+14SBtTCekLE+9+2wmhA=="], + "@vercel/python": ["@vercel/python@6.20.1", "", { "dependencies": { "@vercel/python-analysis": "0.8.1" } }, "sha512-a/VgUJ/N7SfKgE2fen6iaNow/nWEGEQj57SNyPwo4emvPfO1dPxUlZWyAEdctfXYKjnJR2WAU2kYcCDw+zenZg=="], - "@vercel/python-analysis": ["@vercel/python-analysis@0.9.1", "", { "dependencies": { "@bytecodealliance/preview2-shim": "0.17.6", "@renovatebot/pep440": "4.2.1", "fs-extra": "11.1.1", "js-yaml": "4.1.1", "minimatch": "10.1.1", "pip-requirements-js": "1.0.3", "smol-toml": "1.5.2", "zod": "3.22.4" } }, "sha512-ZwEi/F2DPxFPYmfjHFy7qM3+JTWRxD1EMpbIotNNhUyd/pnIG0wNt7S73RJSx62n1Y7pmFOFowoImnhULQgKvA=="], + "@vercel/python-analysis": ["@vercel/python-analysis@0.8.1", "", { "dependencies": { "@bytecodealliance/preview2-shim": "0.17.6", "@renovatebot/pep440": "4.2.1", "fs-extra": "11.1.1", "js-yaml": "4.1.1", "minimatch": "10.1.1", "pip-requirements-js": "1.0.2", "smol-toml": "1.5.2", "zod": "3.22.4" } }, "sha512-gW1pZDqJaTcjZYPvNhXXLOPgLu6vJW9PKweJoX2f8EKAoW+JIiYncl8AddcSlngNhQRG7SqUl2u3qosZM4kUBA=="], "@vercel/redwood": ["@vercel/redwood@2.4.10", "", { "dependencies": { "@vercel/nft": "1.1.1", "@vercel/static-config": "3.2.0", "semver": "6.3.1", "ts-morph": "12.0.0" } }, "sha512-7C5lUn9g9kLm1KpX55b8iizVPOB6087+kVyQyKyXGk8bbkYySL26yb+LIwyL/7mXwHlq/JTC0AxVdC3nNmPZuw=="], - "@vercel/remix-builder": ["@vercel/remix-builder@5.7.0", "", { "dependencies": { "@vercel/error-utils": "2.0.3", "@vercel/nft": "1.1.1", "@vercel/static-config": "3.2.0", "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", "ts-morph": "12.0.0" } }, "sha512-R44EHl+PQjX5PrCmyGust+bk+65eT5omxOLhEIJkSI90Kx/vvuyKnFNic/Zwo//GCMjxt9httvQUr/6vIBbjHg=="], + "@vercel/remix-builder": ["@vercel/remix-builder@5.6.0", "", { "dependencies": { "@vercel/error-utils": "2.0.3", "@vercel/nft": "1.1.1", "@vercel/static-config": "3.1.2", "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", "ts-morph": "12.0.0" } }, "sha512-neTpO4aGksYcPJjTbAEUhWmsOdFqgx02H47RUsXBKHdMTW4ZKrL9oAKT3pD+Bv9kUgj7uRzqAr8JeiPILPWybg=="], "@vercel/ruby": ["@vercel/ruby@2.3.2", "", {}, "sha512-okIgMmPEePyDR9TZYaKM4oftcxVHM5Dbdl7V/tIdh3lq8MGLi7HR5vvQglmZUwZOeovE6MVtezxl960EOzeIiQ=="], "@vercel/rust": ["@vercel/rust@1.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "execa": "5" } }, "sha512-Y03g59nv1uT6Da+PvB/50WqJSHlaFZ9MSkG00R82dUcTySslMbQdOeaXymZtabrmU8zQYhWDb1/CwBki8sWnaQ=="], - "@vercel/static-build": ["@vercel/static-build@2.9.0", "", { "dependencies": { "@vercel/gatsby-plugin-vercel-analytics": "1.0.11", "@vercel/gatsby-plugin-vercel-builder": "2.1.0", "@vercel/static-config": "3.2.0", "ts-morph": "12.0.0" } }, "sha512-3SHWntz8swxL6ve750dY8kyl4NwVUplYtun/ei7y11q2UnI70WnWmm6L9fi02Wy1o3RGhbvOnlFLcHOUBm3DXQ=="], + "@vercel/static-build": ["@vercel/static-build@2.8.44", "", { "dependencies": { "@vercel/gatsby-plugin-vercel-analytics": "1.0.11", "@vercel/gatsby-plugin-vercel-builder": "2.0.142", "@vercel/static-config": "3.1.2", "ts-morph": "12.0.0" } }, "sha512-xzRBFm+tgVoyE/qSF+BeKXKckLVYXR2dGHsp+fNRttLXi7fq2AUCTM7DNNT4TkkTxcPX5FaJ1tRHxlqLizv91A=="], "@vercel/static-config": ["@vercel/static-config@3.2.0", "", { "dependencies": { "ajv": "8.6.3", "json-schema-to-ts": "1.6.4", "ts-morph": "12.0.0" } }, "sha512-UpOEIgWxWx0M+mDe1IMdHS6JuWM/L5nNIJ4ixX8v9JgBAejymo88OkgnmfLCNMem0Wd+b5vcQPWLdZybCndlsA=="], @@ -2311,6 +2313,8 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "flexsearch": ["flexsearch@0.8.212", "", {}, "sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw=="], + "focus-trap": ["focus-trap@7.6.1", "", { "dependencies": { "tabbable": "^6.2.0" } }, "sha512-nB8y4nQl8PshahLpGKZOq1sb0xrMVFSn6at7u/qOsBZTlZRzaapISGENcB6mOkoezbClZyiMwEF/dGY8AZ00rA=="], "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], @@ -3411,7 +3415,7 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vercel": ["vercel@50.31.1", "", { "dependencies": { "@vercel/backends": "0.0.45", "@vercel/blob": "2.3.0", "@vercel/build-utils": "13.8.0", "@vercel/detect-agent": "1.2.0", "@vercel/elysia": "0.1.48", "@vercel/express": "0.1.57", "@vercel/fastify": "0.1.51", "@vercel/fun": "1.3.0", "@vercel/go": "3.4.4", "@vercel/h3": "0.1.57", "@vercel/hono": "0.2.51", "@vercel/hydrogen": "1.3.6", "@vercel/koa": "0.1.31", "@vercel/nestjs": "0.2.52", "@vercel/next": "4.16.1", "@vercel/node": "5.6.15", "@vercel/python": "6.22.0", "@vercel/redwood": "2.4.10", "@vercel/remix-builder": "5.7.0", "@vercel/ruby": "2.3.2", "@vercel/rust": "1.0.5", "@vercel/static-build": "2.9.0", "chokidar": "4.0.0", "esbuild": "0.27.0", "form-data": "^4.0.0", "jose": "5.9.6", "luxon": "^3.4.0", "proxy-agent": "6.4.0" }, "bin": { "vc": "dist/vc.js", "vercel": "dist/vc.js" } }, "sha512-cu8ukSJVuKecKavhMWMP/sgcetQ/6qsEaIGf9626tWXpRddUsLLJAsGyZUoZxo+EshMEp5kjuLZvTonyOUBLcA=="], + "vercel": ["vercel@50.26.1", "", { "dependencies": { "@vercel/backends": "0.0.40", "@vercel/blob": "2.3.0", "@vercel/build-utils": "13.6.2", "@vercel/detect-agent": "1.1.0", "@vercel/elysia": "0.1.43", "@vercel/express": "0.1.52", "@vercel/fastify": "0.1.46", "@vercel/fun": "1.3.0", "@vercel/go": "3.4.3", "@vercel/h3": "0.1.52", "@vercel/hono": "0.2.46", "@vercel/hydrogen": "1.3.5", "@vercel/koa": "0.1.26", "@vercel/nestjs": "0.2.47", "@vercel/next": "4.15.39", "@vercel/node": "5.6.10", "@vercel/python": "6.20.1", "@vercel/redwood": "2.4.9", "@vercel/remix-builder": "5.6.0", "@vercel/ruby": "2.3.2", "@vercel/rust": "1.0.5", "@vercel/static-build": "2.8.44", "chokidar": "4.0.0", "esbuild": "0.27.0", "form-data": "^4.0.0", "jose": "5.9.6", "luxon": "^3.4.0", "proxy-agent": "6.4.0" }, "bin": { "vc": "dist/vc.js", "vercel": "dist/vc.js" } }, "sha512-dFIsEkqTZYBNIZwe5cprBDe1a1AoWQXtBsk+/otAjuVk9WJ3x6kA9fE4rvEWAsnpk2jYj2ZbY04xjBt2cxGt5w=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], diff --git a/package.json b/package.json index 0dd21d2b79..372b114f57 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ }, "devDependencies": { "@biomejs/biome": "^1.9.4", - "@changesets/cli": "^2.30.0", - "turbo": "^2.8.16", - "vercel": "^50.31.1" + "@changesets/cli": "^2.29.8", + "turbo": "^2.8.10", + "vercel": "catalog:" }, "packageManager": "bun@1.3.7", "overrides": { @@ -53,7 +53,8 @@ "react-dom": "^19.0.1", "tsdown": "^0.15.6", "typescript": "^5.5.3", - "usehooks-ts": "^3.1.1" + "usehooks-ts": "^3.1.1", + "vercel": "^50.26.1" } }, "patchedDependencies": { diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index c1fd604e0a..66e72231ed 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -38,6 +38,7 @@ "direction": "^2.0.1", "event-iterator": "^2.0.0", "feed": "^5.1.0", + "flexsearch": "^0.8.212", "image-size": "^2.0.2", "js-cookie": "^3.0.5", "jsontoxml": "^1.0.1", @@ -110,7 +111,7 @@ "tailwindcss": "^4.1.11", "ts-essentials": "^10.0.1", "typescript": "catalog:", - "vercel": "^50.15.1", + "vercel": "catalog:", "wrangler": "^4.43.0", "rss-parser": "^3.13.0" }, diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts new file mode 100644 index 0000000000..63ec6c05e7 --- /dev/null +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts @@ -0,0 +1,160 @@ +import type { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api'; +import type { NextRequest } from 'next/server'; + +import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils'; +import { throwIfDataError } from '@/lib/data'; +import { isPageIndexable } from '@/lib/seo'; +import { + findSiteSpaceBy, + getFallbackSiteSpacePath, + getLocalizedTitle, + listAllSiteSpaces, +} from '@/lib/sites'; + +interface Breadcrumb { + label: string; + icon?: string; + emoji?: string; +} + +interface RawIndexPage { + id: string; + title: string; + pathname: string; + lang?: string; + icon?: string; + emoji?: string; + description?: string; + breadcrumbs?: Breadcrumb[]; +} + +type AncestorPage = RevisionPageDocument | RevisionPageGroup; + +interface IndexPageEntry { + page: RevisionPageDocument; + ancestors: AncestorPage[]; +} + +/** + * Walk the page tree and return all indexable document pages together with + * their ancestor chain (groups + parent documents), enabling breadcrumb generation. + */ +function getIndexablePagesWithAncestors( + rootPages: RevisionPage[], + ancestors: AncestorPage[] = [] +): IndexPageEntry[] { + const results: IndexPageEntry[] = []; + + for (const page of rootPages) { + if (page.type === 'link' || page.type === 'computed') continue; + if (page.hidden || !isPageIndexable([], page)) continue; + + if (page.type === 'document') { + results.push({ page, ancestors }); + // Recurse into children with this document as an ancestor + if (page.pages?.length) { + results.push( + ...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [ + ...ancestors, + page, + ]) + ); + } + } else if (page.type === 'group') { + // Groups themselves are not documents — push them only as ancestors + if (page.pages?.length) { + results.push( + ...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [ + ...ancestors, + page, + ]) + ); + } + } + } + + return results; +} + +export const revalidate = 86400; // 1 day in seconds +export const dynamic = 'force-static'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise } +) { + const { context } = await getStaticSiteContext(await params); + const { dataFetcher, linker, structure } = context; + + const visibleSpaces = listAllSiteSpaces(structure).filter((ss) => !ss.hidden); + + const revisions = await Promise.all( + visibleSpaces.map((ss) => + throwIfDataError( + dataFetcher.getRevision({ + spaceId: ss.space.id, + revisionId: ss.space.revision, + }) + ) + ) + ); + + const seen = new Set(); + const pages: RawIndexPage[] = []; + + for (let i = 0; i < visibleSpaces.length; i++) { + const siteSpace = visibleSpaces[i]!; + const revision = revisions[i]!; + const forkedLinker = linker.withOtherSiteSpace({ + spaceBasePath: getFallbackSiteSpacePath(context, siteSpace), + }); + + const lang = siteSpace.space.language ?? undefined; + const sectionInfo = findSiteSpaceBy(structure, (ss) => ss.id === siteSpace.id); + const { siteSection, siteSectionGroup } = sectionInfo ?? {}; + + for (const { page, ancestors } of getIndexablePagesWithAncestors(revision.pages)) { + if (seen.has(page.id)) continue; + seen.add(page.id); + + const breadcrumbs: Breadcrumb[] = [ + siteSectionGroup + ? { + label: getLocalizedTitle(siteSectionGroup, lang), + icon: siteSectionGroup.icon ?? undefined, + } + : undefined, + siteSection + ? { + label: getLocalizedTitle(siteSection, lang), + icon: siteSection.icon ?? undefined, + } + : undefined, + ...ancestors.map((a) => ({ + label: a.title, + icon: a.icon ?? undefined, + emoji: a.emoji ?? undefined, + })), + ].filter((c) => c !== undefined); + + pages.push({ + id: page.id, + title: page.title, + pathname: forkedLinker.toPathForPage({ pages: revision.pages, page }), + lang, + icon: page.icon ?? undefined, + emoji: page.emoji ?? undefined, + description: page.description ?? undefined, + breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined, + }); + } + } + + return new Response(JSON.stringify({ pages }), { + headers: { + 'Content-Type': 'application/json', + // Cache for 5 minutes on the client, 1 day on the CDN, and allow serving stale content while revalidating for 1 day + 'Cache-Control': 'public, max-age=300, s-maxage=86400, stale-while-revalidate=86400', + }, + }); +} diff --git a/packages/gitbook/src/app/utils.ts b/packages/gitbook/src/app/utils.ts index ff6fd6ffc3..c0f2cdcfa5 100644 --- a/packages/gitbook/src/app/utils.ts +++ b/packages/gitbook/src/app/utils.ts @@ -48,6 +48,8 @@ export async function getStaticSiteContext(params: RouteLayoutParams) { return { context, visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded), + //TODO: remove, just for easier testing + token: siteURLData.apiToken, }; } diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index 0cd61a21ab..84128c2898 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -162,6 +162,7 @@ export function Header(props: { } siteSpace={siteSpace} siteSpaces={visibleSiteSpaces} + indexURL={context.linker.toPathInSite('~gitbook/site-index')} viewport={!withTopHeader ? 'mobile' : undefined} searchURL={context.linker.toPathInSpace('~gitbook/search')} /> diff --git a/packages/gitbook/src/components/Search/LocalSearchResults.tsx b/packages/gitbook/src/components/Search/LocalSearchResults.tsx new file mode 100644 index 0000000000..84bdd432c8 --- /dev/null +++ b/packages/gitbook/src/components/Search/LocalSearchResults.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { tcls } from '@/lib/tailwind'; +import { Icon, type IconName } from '@gitbook/icons'; +import { Emoji } from '../primitives/Emoji/Emoji'; +import { Link } from '../primitives/Link'; +import type { LocalPageResult } from './useLocalSearchResults'; + +/** + * 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 ( +
+ {results.map((result) => ( + + ))} +
+ ); +} + +function LocalSearchResultCard({ + result, + fetching, +}: { + result: LocalPageResult; + fetching: boolean; +}) { + return ( + +
+ {result.emoji ? ( + + + + ) : result.icon ? ( + + + + ) : null} +

+ {result.title} +

+ + + +
+ {result.description ? ( +

{result.description}

+ ) : null} + + ); +} diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index 66d2a524eb..8d23d4e450 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -46,6 +46,8 @@ interface SearchContainerProps { /** URL for the search API route, e.g. from linker.toPathInSpace('~gitbook/search'). */ searchURL: string; + /** URL for the local index JSON, e.g. from linker.toPathInSite('~gitbook/index'). */ + indexURL: string; } /** @@ -62,6 +64,7 @@ export function SearchContainer({ viewport, siteSpaces, searchURL, + indexURL, }: SearchContainerProps) { const { assistants, config } = useAI(); @@ -203,7 +206,7 @@ export function SearchContainer({ [siteSpaces, siteSpace.space.language] ); - const { results, fetching, error } = useSearchResults({ + const { results, fetching, error, setInteracted, onVisibilityChange } = useSearchResults({ disabled: !(state?.query || withAI), query: normalizedQuery, siteSpaceId: siteSpace.id, @@ -212,9 +215,14 @@ export function SearchContainer({ withAI, suggestions: config.suggestions, searchURL, + indexURL, + lang: siteSpace.space.language, }); + const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''; + const scrollContainerRef = React.useRef(null); + const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({ query: normalizedQuery, results, @@ -222,9 +230,11 @@ export function SearchContainer({ const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'ArrowUp') { event.preventDefault(); + setInteracted(); moveCursorBy(-1); } else if (event.key === 'ArrowDown') { event.preventDefault(); + setInteracted(); moveCursorBy(1); } else if (event.key === 'Enter') { event.preventDefault(); @@ -239,7 +249,11 @@ export function SearchContainer({ // Only show content if there's a query or Ask is enabled state?.query || withAI ? ( -
+
{state !== null && !showAsk ? ( ) : null} {showAsk ? : null} diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index 108edb131a..f3e3a38578 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -3,14 +3,19 @@ import { tcls } from '@/lib/tailwind'; import { Icon, type IconName } from '@gitbook/icons'; import React from 'react'; import { Tooltip } from '../primitives'; +import { Emoji } from '../primitives/Emoji/Emoji'; import { HighlightQuery } from './HighlightQuery'; import { SearchResultItem } from './SearchResultItem'; import type { ComputedPageResult } from './search-types'; +import type { MergedPageResult } from './reciprocalRankFusion'; +import type { LocalPageResult } from './useLocalSearchResults'; + +type PageItem = ComputedPageResult | LocalPageResult | MergedPageResult; export const SearchPageResultItem = React.forwardRef(function SearchPageResultItem( props: { query: string; - item: ComputedPageResult; + item: PageItem; active: boolean; }, ref: React.Ref @@ -18,22 +23,43 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt const { query, item, active, ...rest } = props; const language = useLanguage(); + const href = 'href' in item ? item.href : item.pathname; + + const emoji = 'emoji' in item ? item.emoji : undefined; + const icon = 'icon' in item ? item.icon : undefined; + + const leadingIcon = + emoji ? ( + + + + ) : icon ? ( + + ) : ( + + ); + + const insights = + item.type === 'page' + ? { + type: 'search_open_result' as const, + query, + result: { + pageId: item.pageId, + spaceId: item.spaceId, + }, + } + : undefined; + return ( } - insights={{ - type: 'search_open_result', - query, - result: { - pageId: item.pageId, - spaceId: item.spaceId, - }, - }} + leadingIcon={leadingIcon} + insights={insights} aria-label={tString(language, 'search_page_result_title', item.title)} {...rest} > @@ -41,12 +67,17 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt

+ {'description' in item && item.description ? ( +

+ +

+ ) : null}
); }); const Breadcrumbs = (props: { - breadcrumbs: ComputedPageResult['breadcrumbs']; + breadcrumbs: PageItem['breadcrumbs']; withOverflow?: boolean; }) => { const { breadcrumbs, withOverflow = (breadcrumbs?.length ?? 0) > 4 } = props; diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 1106fc5150..62e3c95188 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -12,7 +12,9 @@ import { SearchPageResultItem } from './SearchPageResultItem'; import { SearchQuestionResultItem } from './SearchQuestionResultItem'; import { SearchRecordResultItem } from './SearchRecordResultItem'; import { SearchSectionResultItem } from './SearchSectionResultItem'; +import { getResultKey } from './reciprocalRankFusion'; import type { OrderedComputedResult } from './search-types'; +import type { LocalPageResult } from './useLocalSearchResults'; export interface SearchResultsRef { select(): void; @@ -20,6 +22,7 @@ export interface SearchResultsRef { type ResultType = | OrderedComputedResult + | LocalPageResult | { type: 'question'; id: string; query: string; assistant: Assistant } | { type: 'recommended-question'; id: string; question: string }; @@ -38,10 +41,24 @@ export const SearchResults = React.forwardRef(function SearchResults( fetching: boolean; cursor: number | null; error: boolean; + /** Ref to the scroll container — used as the IntersectionObserver root. */ + scrollContainerRef?: React.RefObject; + /** Called whenever the set of visible result keys changes. */ + onVisibilityChange?: (ids: ReadonlySet) => void; }, ref: React.Ref ) { - const { children, id, query, results, fetching, cursor, error } = props; + const { + children, + id, + query, + results, + fetching, + cursor, + error, + scrollContainerRef, + onVisibilityChange, + } = props; const language = useLanguage(); @@ -75,6 +92,52 @@ export const SearchResults = React.forwardRef(function SearchResults( [select] ); + // Observe which result items are visible in the scroll viewport. + React.useEffect(() => { + if (!onVisibilityChange) { + return; + } + + const root = scrollContainerRef?.current ?? null; + const visibleKeys = new Set(); + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const index = refs.current.indexOf(entry.target as HTMLAnchorElement); + if (index === -1) continue; + const result = results[index]; + if ( + !result || + result.type !== 'local-page' + ) { + continue; + } + const key = getResultKey(result); + if (entry.isIntersecting) { + visibleKeys.add(key); + } else { + visibleKeys.delete(key); + } + } + onVisibilityChange(new Set(visibleKeys)); + }, + { root, threshold: 0.5 } + ); + + const observed: HTMLAnchorElement[] = []; + for (const el of refs.current) { + if (el) { + observer.observe(el); + observed.push(el); + } + } + + return () => { + observer.disconnect(); + }; + }, [results, scrollContainerRef, onVisibilityChange]); + const { assistants } = useAI(); if (error) { @@ -144,6 +207,7 @@ export const SearchResults = React.forwardRef(function SearchResults( id: `${id}-${index}`, }; switch (item.type) { + case 'local-page': case 'page': { return ( { + // Map from dedup key → { result, score } + const scoreMap = new Map(); + + // Process local results first (1-indexed rank) + localResults.forEach((result, index) => { + const rank = index + 1; + const key = getResultKey(result); + const contribution = 1 / (RRF_K + 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 + remoteResults.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 (description, icon, emoji, pathname), remote overrides + // (href, pageId, spaceId, breadcrumbs, title). + existing.score += contribution; + if (existing.result.type === 'local-page' && result.type === 'page') { + existing.result = { + ...existing.result, + ...result, + // Merge breadcrumbs: prefer local (has icon + emoji), fall back to remote. + breadcrumbs: existing.result.breadcrumbs ?? result.breadcrumbs, + } as MergedPageResult; + } else { + existing.result = result; + } + } else { + scoreMap.set(key, { result, score: contribution }); + } + }); + + // Sort descending by RRF score + return Array.from(scoreMap.values()) + .sort((a, b) => b.score - a.score) + .map(({ result }) => result); +} diff --git a/packages/gitbook/src/components/Search/useLocalSearchResults.tsx b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx new file mode 100644 index 0000000000..db143bbc3f --- /dev/null +++ b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { Document, type DocumentValue } from 'flexsearch'; +import React from 'react'; + +interface Breadcrumb { + label: string; + icon?: string; + emoji?: string; +} + +/** Raw entry from the `~gitbook/index` JSON response */ +interface RawIndexPage { + id: string; + title: string; + pathname: string; + /** BCP-47 language code emitted by the index route, absent when no language is set. */ + lang?: string; + icon?: string; + emoji?: string; + description?: string; + breadcrumbs?: Breadcrumb[]; +} + +/** FlexSearch-compatible document type — satisfies DocumentData via explicit index signature */ +interface IndexPage { + [key: string]: DocumentValue | DocumentValue[]; + id: string; + title: string; + description: string | null; +} + +/** Result type returned by this hook */ +export interface LocalPageResult { + type: 'local-page'; + id: string; + title: string; + pathname: string; + icon?: string; + emoji?: string; + description?: string; + breadcrumbs?: Breadcrumb[]; +} + +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>(); + +// 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?: Breadcrumb[] } +>(); + +let pendingFetch: Promise>> | null = null; + +function buildLangIndex(pages: RawIndexPage[]): Document { + const index = new Document({ + document: { + id: 'id', + index: ['title', 'description'], + store: ['id', 'title', 'description'], + }, + tokenize: 'bidirectional', + encoder: 'Normalize', + }); + + for (const page of pages) { + index.add({ + id: page.id, + title: page.title, + description: page.description ?? null, + }); + + cachedPageData.set(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>> { + if (cachedIndexes.size > 0) { + return cachedIndexes; + } + + if (pendingFetch) { + return pendingFetch; + } + + pendingFetch = (async () => { + const response = await fetch(indexURL); + if (!response.ok) { + throw new Error(`Failed to fetch search index: ${response.status}`); + } + + const data: { pages: RawIndexPage[] } = await response.json(); + + // Group pages by their `lang` value (empty string for pages without one) + const pagesByLang = new Map(); + 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(pages)); + } + + 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; + disabled?: boolean; +}): LocalSearchState { + const { query, indexURL, lang, disabled = false } = props; + + const [state, setState] = React.useState({ + 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); + + // Load the indexes once + React.useEffect(() => { + if (cachedIndexes.size > 0) { + setIndexReady(true); + 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]); + + // 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; + } + + const rawResults = index.search(query, { enrich: true, limit: 10 }); + + // Flatten and deduplicate results across fields (flexsearch returns one array per indexed field) + const seen = new Set(); + const results: LocalPageResult[] = []; + + for (const fieldResult of rawResults) { + for (const item of fieldResult.result) { + const doc = (item as { id: string; doc: IndexPage }).doc; + if (!seen.has(doc.id)) { + seen.add(doc.id); + const extra = cachedPageData.get(doc.id); + results.push({ + type: 'local-page', + id: doc.id, + 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, indexReady, disabled]); + + return state; +} diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index a9bca51916..ce06f69d9e 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -10,13 +10,66 @@ import { type Assistant, useAI } from '@/components/AI'; import assertNever from 'assert-never'; import { useTrackEvent } from '../Insights'; import { isQuestion } from './isQuestion'; +import { type MergedPageResult, getResultKey, reciprocalRankFusion } from './reciprocalRankFusion'; +import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults'; import type { SearchScope } from './useSearch'; export type ResultType = | OrderedComputedResult + | LocalPageResult + | MergedPageResult | { type: 'question'; id: string; query: string; assistant: Assistant } | { type: 'recommended-question'; id: string; question: string }; +export type { LocalPageResult, MergedPageResult }; + +type MergeableResult = LocalPageResult | OrderedComputedResult | MergedPageResult; + +/** + * Append-only merge: local results first, then remote results that aren't + * already covered by local. + * Used once the user has interacted with the list (scroll / keyboard / pointer) + * so that already-visible items never jump to a different position. + */ +function appendMerge( + localResults: LocalPageResult[], + remoteResults: OrderedComputedResult[] +): MergeableResult[] { + const localKeys = new Set(localResults.map(getResultKey)); + const remoteOnly = remoteResults.filter((item) => !localKeys.has(getResultKey(item))); + return [...localResults, ...remoteOnly]; +} + +/** + * Stable-visible merge: visible items are kept in the order they appear in + * `visibleIds` (insertion order), followed by non-visible items ranked by RRF. + */ +function stableVisibleMerge( + localResults: LocalPageResult[], + remoteResults: OrderedComputedResult[], + visibleIds: ReadonlySet +): MergeableResult[] { + const rrfResult = reciprocalRankFusion(localResults, remoteResults); + + if (visibleIds.size === 0) { + return rrfResult; + } + + const rrfByKey = new Map( + rrfResult.map((item) => [getResultKey(item), item]) + ); + + const visible: MergeableResult[] = []; + for (const id of visibleIds) { + const item = rrfByKey.get(id); + if (item) visible.push(item); + } + + const nonVisible = rrfResult.filter((item) => !visibleIds.has(getResultKey(item))); + + return [...visible, ...nonVisible]; +} + /** * We cache the recommended questions globally to avoid calling the API multiple times * when re-opening the search modal. The cache is per space, so that we can @@ -35,13 +88,34 @@ export function useSearchResults(props: { suggestions?: string[]; /** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */ searchURL: string; + /** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/index')). */ + indexURL: string; + /** BCP-47 language code of the current site space, used to filter local search results. */ + lang?: string; }) { - const { disabled, query, siteSpaceId, siteSpaceIds, scope, suggestions, searchURL } = props; + const { + disabled, + query, + siteSpaceId, + siteSpaceIds, + scope, + suggestions, + searchURL, + indexURL, + lang, + } = props; const trackEvent = useTrackEvent(); - const [resultsState, setResultsState] = React.useState<{ - results: ResultType[]; + const { results: localResults } = useLocalSearchResults({ + query, + indexURL, + lang, + disabled, + }); + + const [remoteState, setRemoteState] = React.useState<{ + results: OrderedComputedResult[]; fetching: boolean; error: boolean; }>({ results: [], fetching: false, error: false }); @@ -49,13 +123,36 @@ export function useSearchResults(props: { const { assistants } = useAI(); const withAI = assistants.length > 0; + // --- Interaction / visibility tracking (refs → no extra renders) --- + + /** True once the user has scrolled, moved the keyboard cursor, or hovered over results. */ + const hasInteractedRef = React.useRef(false); + /** Keys of result items currently visible in the scroll viewport. */ + const visibleIdsRef = React.useRef>(new Set()); + + // Reset interaction state whenever the query changes. + React.useEffect(() => { + hasInteractedRef.current = false; + visibleIdsRef.current = new Set(); + }, [query]); + + /** Call when the user scrolls, navigates with arrow keys, or hovers the list. */ + const setInteracted = React.useCallback(() => { + hasInteractedRef.current = true; + }, []); + + /** Update the set of result keys that are currently visible in the viewport. */ + const onVisibilityChange = React.useCallback((ids: ReadonlySet) => { + visibleIdsRef.current = ids; + }, []); + React.useEffect(() => { if (disabled) { return; } if (!query) { if (!withAI) { - setResultsState({ results: [], fetching: false, error: false }); + setRemoteState({ results: [], fetching: false, error: false }); return; } @@ -65,11 +162,12 @@ export function useSearchResults(props: { results, `Cached recommended questions should be set for site-space ${siteSpaceId}` ); - setResultsState({ results, fetching: false, error: false }); + // Recommended questions are stored as ResultType[] already + setRemoteState({ results: [], fetching: false, error: false }); return; } - setResultsState({ results: [], fetching: false, error: false }); + setRemoteState({ results: [], fetching: false, error: false }); let cancelled = false; @@ -82,15 +180,7 @@ export function useSearchResults(props: { suggestions.forEach((question) => { questions.add(question); }); - setResultsState({ - results: suggestions.map((question, index) => ({ - type: 'recommended-question', - id: `recommended-question-${index}`, - question, - })), - fetching: false, - error: false, - }); + setRemoteState({ results: [], fetching: false, error: false }); return; } @@ -119,11 +209,8 @@ export function useSearchResults(props: { cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions); if (!cancelled) { - setResultsState({ - results: [...recommendedQuestions], - fetching: false, - error: false, - }); + // Recommended questions are handled via a separate path below + setRemoteState({ results: [], fetching: false, error: false }); } } }, 100); @@ -133,8 +220,8 @@ export function useSearchResults(props: { clearTimeout(timeout); }; } - setResultsState({ - results: withAI ? withAskTriggers([], query, assistants) : [], + setRemoteState({ + results: [], fetching: true, error: false, }); @@ -174,15 +261,11 @@ export function useSearchResults(props: { // One time when this one returns undefined is when it cannot find the server action and returns the html from the page. // In that case, we want to avoid being stuck in a loading state, but it is an error. // We could potentially try to force reload the page here, but i'm not 100% sure it would be a better experience. - setResultsState({ results: [], fetching: false, error: true }); + setRemoteState({ results: [], fetching: false, error: true }); return; } - const aiEnrichedResults = withAI - ? withAskTriggers(results, query, assistants) - : results; - - setResultsState({ results: aiEnrichedResults, fetching: false, error: false }); + setRemoteState({ results, fetching: false, error: false }); trackEvent({ type: 'search_type_query', @@ -193,7 +276,7 @@ export function useSearchResults(props: { if (cancelled) { return; } - setResultsState({ results: [], fetching: false, error: true }); + setRemoteState({ results: [], fetching: false, error: true }); } }, 350); @@ -214,7 +297,51 @@ export function useSearchResults(props: { searchURL, ]); - return resultsState; + // Merge local and remote results. + // Re-runs immediately whenever either result set changes. + const results = React.useMemo(() => { + if (!query) { + // No query: show recommended questions (AI-only path) or nothing. + if (withAI && cachedRecommendedQuestions.has(siteSpaceId)) { + return cachedRecommendedQuestions.get(siteSpaceId) ?? []; + } + if (suggestions && suggestions.length > 0) { + return suggestions.map((question, index) => ({ + type: 'recommended-question' as const, + id: `recommended-question-${index}`, + question, + })); + } + return []; + } + + let merged: MergeableResult[]; + if (hasInteractedRef.current) { + // User has interacted: append new items, never reorder existing ones. + merged = appendMerge(localResults, remoteState.results); + } else if (!hasInteractedRef.current) { + // User hasn't interacted: keep visible items in local order, + // allow below-the-fold items to be freely re-ranked by RRF. + merged = stableVisibleMerge( + localResults, + remoteState.results, + visibleIdsRef.current + ); + } else { + // First render for this query: plain RRF with no stability constraints. + merged = reciprocalRankFusion(localResults, remoteState.results); + } + + return withAI ? withAskTriggers(merged, query, assistants) : merged; + }, [localResults, remoteState.results, query, withAI, assistants, siteSpaceId, suggestions]); + + return { + results, + fetching: remoteState.fetching, + error: remoteState.error, + setInteracted, + onVisibilityChange, + }; } /** diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx index 13a10e114c..4f551f635c 100644 --- a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx +++ b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx @@ -39,6 +39,13 @@ export async function SiteLayout(props: { ReactDOM.preconnect(GITBOOK_ASSETS_URL); } + // We also preload the site index + //TODO: enable this only for a subset of website first + ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, { + as: 'fetch', + type: 'application/json', + }); + scripts.forEach(({ script }) => { ReactDOM.preload(script, { as: 'script', diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 3303e3db66..0717878f7f 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -200,6 +200,9 @@ export function SpaceLayout(props: SpaceLayoutProps) { section={visibleSections?.current} siteSpace={siteSpace} siteSpaces={visibleSiteSpaces} + indexURL={context.linker.toPathInSite( + '~gitbook/site-index' + )} viewport="desktop" searchURL={context.linker.toPathInSpace( '~gitbook/search' diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 28dbeeb8d1..9b23377dcb 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -478,7 +478,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // When we use adaptive content, we want to ensure that the cache is not used at all on the client side. // Vercel already set this header, this is needed in OpenNext. - if (siteURLData.contextId) { + if (siteURLData.contextId && !siteRequestURL.pathname.endsWith('~gitbook/index')) { response.headers.set('cache-control', 'public, max-age=0, must-revalidate'); } @@ -732,6 +732,9 @@ function encodePathInSiteContent( case 'robots.txt': case '~gitbook/embed/script.js': case '~gitbook/embed/demo': + case '~gitbook/site-index': + // LLMs.txt, sitemap, sitemap-pages and robots.txt are always static + // as they only depend on the site structure / pages. return { pathname, routeType: 'static' }; case '~gitbook/pdf': case '~gitbook/search':